diff --git a/.changeset/late-dancers-smile.md b/.changeset/late-dancers-smile.md new file mode 100644 index 0000000000..58026740d8 --- /dev/null +++ b/.changeset/late-dancers-smile.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Add import timings and bundle size analysis, the dev command will now warn about slow imports diff --git a/.cursorignore b/.cursorignore index 06885fe2f5..4b81818369 100644 --- a/.cursorignore +++ b/.cursorignore @@ -4,4 +4,5 @@ apps/proxy/ apps/coordinator/ packages/rsc/ .changeset -.zed \ No newline at end of file +.zed +.env \ No newline at end of file diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index 694b2b4280..4a575831a5 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -6,6 +6,7 @@ import { configureLogoutCommand } from "../commands/logout.js"; import { configureWhoamiCommand } from "../commands/whoami.js"; import { COMMAND_NAME } from "../consts.js"; import { configureListProfilesCommand } from "../commands/list-profiles.js"; +import { configureAnalyzeCommand } from "../commands/analyze.js"; import { configureUpdateCommand } from "../commands/update.js"; import { VERSION } from "../version.js"; import { configureDeployCommand } from "../commands/deploy.js"; @@ -34,6 +35,7 @@ configureListProfilesCommand(program); configureSwitchProfilesCommand(program); configureUpdateCommand(program); configurePreviewCommand(program); +configureAnalyzeCommand(program); // configureWorkersCommand(program); // configureTriggerTaskCommand(program); diff --git a/packages/cli-v3/src/commands/analyze.ts b/packages/cli-v3/src/commands/analyze.ts new file mode 100644 index 0000000000..e8264f9336 --- /dev/null +++ b/packages/cli-v3/src/commands/analyze.ts @@ -0,0 +1,150 @@ +import { Command } from "commander"; +import { z } from "zod"; +import { CommonCommandOptions, handleTelemetry, wrapCommandAction } from "../cli/common.js"; +import { printInitialBanner } from "../utilities/initialBanner.js"; +import { logger } from "../utilities/logger.js"; +import { printBundleTree, printBundleSummaryTable } from "../utilities/analyze.js"; +import path from "node:path"; +import fs from "node:fs"; +import { readJSONFile } from "../utilities/fileSystem.js"; +import { WorkerManifest } from "@trigger.dev/core/v3"; +import { tryCatch } from "@trigger.dev/core"; + +const AnalyzeOptions = CommonCommandOptions.pick({ + logLevel: true, + skipTelemetry: true, +}).extend({ + verbose: z.boolean().optional().default(false), +}); + +type AnalyzeOptions = z.infer; + +export function configureAnalyzeCommand(program: Command) { + return program + .command("analyze [dir]", { hidden: true }) + .description("Analyze your build output (bundle size, timings, etc)") + .option( + "-l, --log-level ", + "The CLI log level to use (debug, info, log, warn, error, none). This does not effect the log level of your trigger.dev tasks.", + "log" + ) + .option("--skip-telemetry", "Opt-out of sending telemetry") + .option("--verbose", "Show detailed bundle tree (do not collapse bundles)") + .action(async (dir, options) => { + await handleTelemetry(async () => { + await analyzeCommand(dir, options); + }); + }); +} + +export async function analyzeCommand(dir: string | undefined, options: unknown) { + return await wrapCommandAction("analyze", AnalyzeOptions, options, async (opts) => { + await printInitialBanner(false); + return await analyze(dir, opts); + }); +} + +export async function analyze(dir: string | undefined, options: AnalyzeOptions) { + const cwd = process.cwd(); + const targetDir = dir ? path.resolve(cwd, dir) : cwd; + const metafilePath = path.join(targetDir, "metafile.json"); + const manifestPath = path.join(targetDir, "index.json"); + + if (!fs.existsSync(metafilePath)) { + logger.error(`Could not find metafile.json in ${targetDir}`); + logger.info("Make sure you have built your project and metafile.json exists."); + return; + } + if (!fs.existsSync(manifestPath)) { + logger.error(`Could not find index.json (worker manifest) in ${targetDir}`); + logger.info("Make sure you have built your project and index.json exists."); + return; + } + + const [metafileError, metafile] = await tryCatch(readMetafile(metafilePath)); + + if (metafileError) { + logger.error(`Failed to parse metafile.json: ${metafileError.message}`); + return; + } + + const [manifestError, manifest] = await tryCatch(readManifest(manifestPath)); + + if (manifestError) { + logger.error(`Failed to parse index.json: ${manifestError.message}`); + return; + } + + printBundleTree(manifest, metafile, { + preservePath: true, + collapseBundles: !options.verbose, + }); + + printBundleSummaryTable(manifest, metafile, { + preservePath: true, + }); +} + +async function readMetafile(metafilePath: string): Promise { + const json = await readJSONFile(metafilePath); + const metafile = MetafileSchema.parse(json); + return metafile; +} + +async function readManifest(manifestPath: string): Promise { + const json = await readJSONFile(manifestPath); + const manifest = WorkerManifest.parse(json); + return manifest; +} + +const ImportKind = z.enum([ + "entry-point", + "import-statement", + "require-call", + "dynamic-import", + "require-resolve", + "import-rule", + "composes-from", + "url-token", +]); + +const ImportSchema = z.object({ + path: z.string(), + kind: ImportKind, + external: z.boolean().optional(), + original: z.string().optional(), + with: z.record(z.string()).optional(), +}); + +const InputSchema = z.object({ + bytes: z.number(), + imports: z.array(ImportSchema), + format: z.enum(["cjs", "esm"]).optional(), + with: z.record(z.string()).optional(), +}); + +const OutputImportSchema = z.object({ + path: z.string(), + kind: z.union([ImportKind, z.literal("file-loader")]), + external: z.boolean().optional(), +}); + +const OutputInputSchema = z.object({ + bytesInOutput: z.number(), +}); + +const OutputSchema = z.object({ + bytes: z.number(), + inputs: z.record(z.string(), OutputInputSchema), + imports: z.array(OutputImportSchema), + exports: z.array(z.string()), + entryPoint: z.string().optional(), + cssBundle: z.string().optional(), +}); + +const MetafileSchema = z.object({ + inputs: z.record(z.string(), InputSchema), + outputs: z.record(z.string(), OutputSchema), +}); + +type Metafile = z.infer; diff --git a/packages/cli-v3/src/commands/dev.ts b/packages/cli-v3/src/commands/dev.ts index cfb8ba8dca..d3041ba0f8 100644 --- a/packages/cli-v3/src/commands/dev.ts +++ b/packages/cli-v3/src/commands/dev.ts @@ -1,5 +1,5 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build"; -import { Command } from "commander"; +import { Command, Option as CommandOption } from "commander"; import { z } from "zod"; import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js"; import { watchConfig } from "../config.js"; @@ -24,6 +24,8 @@ const DevCommandOptions = CommonCommandOptions.extend({ maxConcurrentRuns: z.coerce.number().optional(), mcp: z.boolean().default(false), mcpPort: z.coerce.number().optional().default(3333), + analyze: z.boolean().default(false), + disableWarnings: z.boolean().default(false), }); export type DevCommandOptions = z.infer; @@ -54,6 +56,10 @@ export function configureDevCommand(program: Command) { ) .option("--mcp", "Start the MCP server") .option("--mcp-port", "The port to run the MCP server on", "3333") + .addOption( + new CommandOption("--analyze", "Analyze the build output and import timings").hideHelp() + ) + .addOption(new CommandOption("--disable-warnings", "Suppress warnings output").hideHelp()) ).action(async (options) => { wrapCommandAction("dev", DevCommandOptions, options, async (opts) => { await devCommand(opts); diff --git a/packages/cli-v3/src/dev/backgroundWorker.ts b/packages/cli-v3/src/dev/backgroundWorker.ts index c38eaf3496..c84254eb43 100644 --- a/packages/cli-v3/src/dev/backgroundWorker.ts +++ b/packages/cli-v3/src/dev/backgroundWorker.ts @@ -5,6 +5,7 @@ import { indexWorkerManifest } from "../indexing/indexWorkerManifest.js"; import { prettyError } from "../utilities/cliOutput.js"; import { writeJSONFile } from "../utilities/fileSystem.js"; import { logger } from "../utilities/logger.js"; +import type { Metafile } from "esbuild"; export type BackgroundWorkerOptions = { env: Record; @@ -19,6 +20,7 @@ export class BackgroundWorker { constructor( public build: BuildManifest, + public metafile: Metafile, public params: BackgroundWorkerOptions ) {} diff --git a/packages/cli-v3/src/dev/devOutput.ts b/packages/cli-v3/src/dev/devOutput.ts index e3633c7ea4..f53b6f0e2e 100644 --- a/packages/cli-v3/src/dev/devOutput.ts +++ b/packages/cli-v3/src/dev/devOutput.ts @@ -26,6 +26,7 @@ import { eventBus, EventBusEventArgs } from "../utilities/eventBus.js"; import { logger } from "../utilities/logger.js"; import { Socket } from "socket.io-client"; import { BundleError } from "../build/bundle.js"; +import { analyzeWorker } from "../utilities/analyze.js"; export type DevOutputOptions = { name: string | undefined; @@ -71,6 +72,8 @@ export function startDevOutput(options: DevOutputOptions) { const backgroundWorkerInitialized = ( ...[worker]: EventBusEventArgs<"backgroundWorkerInitialized"> ) => { + analyzeWorker(worker, options.args.analyze, options.args.disableWarnings); + const logParts: string[] = []; const testUrl = `${dashboardUrl}/projects/v3/${config.project}/test?environment=dev`; diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index f13cbc0014..6445c1f75d 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -24,7 +24,8 @@ import { clearTmpDirs, EphemeralDirectory, getTmpDir } from "../utilities/tempDi import { startDevOutput } from "./devOutput.js"; import { startWorkerRuntime } from "./devSupervisor.js"; import { startMcpServer, stopMcpServer } from "./mcpServer.js"; -import { aiHelpLink } from "../utilities/cliOutput.js"; +import { writeJSONFile } from "../utilities/fileSystem.js"; +import { join } from "node:path"; export type DevSessionOptions = { name: string | undefined; @@ -105,12 +106,21 @@ export async function startDevSession({ logger.debug("Created build manifest from bundle", { buildManifest }); + await writeJSONFile( + join(workerDir?.path ?? destination.path, "metafile.json"), + bundle.metafile + ); + buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); try { logger.debug("Updated bundle", { bundle, buildManifest }); - await runtime.initializeWorker(buildManifest, workerDir?.remove ?? (() => {})); + await runtime.initializeWorker( + buildManifest, + bundle.metafile, + workerDir?.remove ?? (() => {}) + ); } catch (error) { if (error instanceof Error) { eventBus.emit("backgroundWorkerIndexingError", buildManifest, error); @@ -160,8 +170,9 @@ export async function startDevSession({ } if (!bundled) { - // First bundle, no need to update bundle bundled = true; + logger.debug("First bundle, no need to update bundle"); + return; } const workerDir = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles); diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts index 970b05e6bd..677999dcae 100644 --- a/packages/cli-v3/src/dev/devSupervisor.ts +++ b/packages/cli-v3/src/dev/devSupervisor.ts @@ -12,7 +12,6 @@ import { CliApiClient } from "../apiClient.js"; import { DevCommandOptions } from "../commands/dev.js"; import { eventBus } from "../utilities/eventBus.js"; import { logger } from "../utilities/logger.js"; -import { sanitizeEnvVars } from "../utilities/sanitizeEnvVars.js"; import { resolveSourceFiles } from "../utilities/sourceFiles.js"; import { BackgroundWorker } from "./backgroundWorker.js"; import { WorkerRuntime } from "./workerRuntime.js"; @@ -25,6 +24,7 @@ import { } from "@trigger.dev/core/v3/workers"; import pLimit from "p-limit"; import { resolveLocalEnvVars } from "../utilities/localEnvVars.js"; +import type { Metafile } from "esbuild"; export type WorkerRuntimeOptions = { name: string | undefined; @@ -113,7 +113,11 @@ class DevSupervisor implements WorkerRuntime { } } - async initializeWorker(manifest: BuildManifest, stop: () => void): Promise { + async initializeWorker( + manifest: BuildManifest, + metafile: Metafile, + stop: () => void + ): Promise { if (this.lastManifest && this.lastManifest.contentHash === manifest.contentHash) { logger.debug("worker skipped", { lastManifestContentHash: this.lastManifest?.contentHash }); eventBus.emit("workerSkipped"); @@ -123,7 +127,7 @@ class DevSupervisor implements WorkerRuntime { const env = await this.#getEnvVars(); - const backgroundWorker = new BackgroundWorker(manifest, { + const backgroundWorker = new BackgroundWorker(manifest, metafile, { env, cwd: this.options.config.workingDir, stop, diff --git a/packages/cli-v3/src/dev/workerRuntime.ts b/packages/cli-v3/src/dev/workerRuntime.ts index 27de384584..54905e8f37 100644 --- a/packages/cli-v3/src/dev/workerRuntime.ts +++ b/packages/cli-v3/src/dev/workerRuntime.ts @@ -2,10 +2,11 @@ import { BuildManifest } from "@trigger.dev/core/v3"; import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { CliApiClient } from "../apiClient.js"; import { DevCommandOptions } from "../commands/dev.js"; +import type { Metafile } from "esbuild"; export interface WorkerRuntime { shutdown(): Promise; - initializeWorker(manifest: BuildManifest, stop: () => void): Promise; + initializeWorker(manifest: BuildManifest, metafile: Metafile, stop: () => void): Promise; } export type WorkerRuntimeOptions = { diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index be4b67125b..6ae67dd49d 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -86,17 +86,18 @@ async function bootstrap() { forceFlushTimeoutMillis: 30_000, }); - const importErrors = await registerResources(buildManifest); + const { importErrors, timings } = await registerResources(buildManifest); return { tracingSDK, config, buildManifest, importErrors, + timings, }; } -const { buildManifest, importErrors, config } = await bootstrap(); +const { buildManifest, importErrors, config, timings } = await bootstrap(); let tasks = resourceCatalog.listTaskManifests(); @@ -158,6 +159,7 @@ await sendMessageInCatalog( loaderEntryPoint: buildManifest.loaderEntryPoint, customConditions: buildManifest.customConditions, initEntryPoint: buildManifest.initEntryPoint, + timings, }, importErrors, }, diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index be4b67125b..6ae67dd49d 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -86,17 +86,18 @@ async function bootstrap() { forceFlushTimeoutMillis: 30_000, }); - const importErrors = await registerResources(buildManifest); + const { importErrors, timings } = await registerResources(buildManifest); return { tracingSDK, config, buildManifest, importErrors, + timings, }; } -const { buildManifest, importErrors, config } = await bootstrap(); +const { buildManifest, importErrors, config, timings } = await bootstrap(); let tasks = resourceCatalog.listTaskManifests(); @@ -158,6 +159,7 @@ await sendMessageInCatalog( loaderEntryPoint: buildManifest.loaderEntryPoint, customConditions: buildManifest.customConditions, initEntryPoint: buildManifest.initEntryPoint, + timings, }, importErrors, }, diff --git a/packages/cli-v3/src/indexing/registerResources.ts b/packages/cli-v3/src/indexing/registerResources.ts index 6589dea322..75dd4151d4 100644 --- a/packages/cli-v3/src/indexing/registerResources.ts +++ b/packages/cli-v3/src/indexing/registerResources.ts @@ -3,14 +3,18 @@ import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; export async function registerResources( buildManifest: BuildManifest -): Promise { +): Promise<{ importErrors: ImportTaskFileErrors; timings: Record }> { const importErrors: ImportTaskFileErrors = []; + const timings: Record = {}; for (const file of buildManifest.files) { // Set the context before importing resourceCatalog.setCurrentFileContext(file.entry, file.out); + const start = performance.now(); const [error, _] = await tryImport(file.out); + const end = performance.now(); + timings[file.entry] = end - start; // Clear the context after import, regardless of success/failure resourceCatalog.clearCurrentFileContext(); @@ -34,7 +38,7 @@ export async function registerResources( } } - return importErrors; + return { importErrors, timings }; } type Result = [Error | null, T | null]; diff --git a/packages/cli-v3/src/utilities/analyze.ts b/packages/cli-v3/src/utilities/analyze.ts new file mode 100644 index 0000000000..1f5b555eb9 --- /dev/null +++ b/packages/cli-v3/src/utilities/analyze.ts @@ -0,0 +1,626 @@ +import type { WorkerManifest } from "@trigger.dev/core/v3"; +import { chalkGreen, chalkError, chalkWarning, chalkTask, chalkPurple } from "./cliOutput.js"; +import chalk from "chalk"; +import type { Metafile } from "esbuild"; +import CLITable from "cli-table3"; +import { BackgroundWorker } from "../dev/backgroundWorker.js"; + +export function analyzeWorker( + worker: BackgroundWorker, + printDetails = false, + disableWarnings = false +) { + if (!worker.manifest) { + return; + } + + if (printDetails) { + printBundleTree(worker.manifest, worker.metafile); + printBundleSummaryTable(worker.manifest, worker.metafile); + } + + if (!disableWarnings) { + printWarnings(worker.manifest); + } +} + +export function printBundleTree( + workerManifest: WorkerManifest, + metafile: Metafile, + opts?: { + sortBy?: "timing" | "bundleSize"; + preservePath?: boolean; + collapseBundles?: boolean; + } +) { + const sortBy = opts?.sortBy ?? "timing"; + const preservePath = opts?.preservePath ?? true; + const collapseBundles = opts?.collapseBundles ?? false; + + const data = getBundleTreeData(workerManifest, metafile); + + if (sortBy === "timing") { + data.sort((a, b) => (a.timing ?? Infinity) - (b.timing ?? Infinity)); + } else if (sortBy === "bundleSize") { + data.sort((a, b) => b.bundleSize - a.bundleSize); + } + + const { outputs } = metafile; + + // Build the output-defines-task-ids map once + const outputDefinesTaskIds = buildOutputDefinesTaskIdsMap(workerManifest, metafile); + + for (const item of data) { + const { filePath, taskIds, bundleSize, bundleChildren, timing } = item; + + // Print the root + const displayPath = getDisplayPath(filePath, preservePath); + const timingStr = formatTimingColored(timing, true); + console.log(chalk.bold(chalkPurple(displayPath)) + " " + timingStr); + + // Determine if we have both tasks and bundles to print as siblings + const taskCount = taskIds.length; + const hasBundles = bundleChildren.length > 0; + const hasTasks = taskCount > 0; + const showTasks = hasTasks; + const showBundles = hasBundles; + + if (showTasks) { + const symbol = showBundles ? "├──" : "└──"; + console.log(` ${symbol} ${chalk.bold(formatTaskCountLabel(taskCount))}`); + + const indent = showBundles ? " │ " : " "; + printTaskTree(taskIds, indent); + } + + if (showBundles) { + // Find the output file for this task file + const outputFile = findOutputFileByEntryPoint(outputs, filePath); + + // Calculate total bundle size and unique bundle count + const totalBundleSize = outputFile ? sumBundleTreeUnique(outputs, outputFile) : 0; + const bundleSizeColored = formatSizeColored(totalBundleSize, true); + const uniqueBundleCount = outputFile ? countUniqueBundles(outputs, outputFile) : 0; + const bundleLabel = formatBundleLabel(uniqueBundleCount, bundleSizeColored); + + console.log(` └── ${chalk.bold(bundleLabel)}`); + + if (!collapseBundles) { + // Print the root bundle as the only child under bundles + const taskSeen = new Set(); + printBundleRoot({ + outputs, + outputFile, + preservePath, + indent: " ", + taskSeen, + outputDefinesTaskIds, + }); + } + } + + console.log(""); + } +} + +export function printBundleSummaryTable( + workerManifest: WorkerManifest, + metafile: Metafile, + opts?: { preservePath?: boolean } +) { + const data = getBundleTreeData(workerManifest, metafile); + // Sort by timing (asc, missing last), then bundle size (desc), then file (asc) + const sorted = [...data].sort(sortBundleTableData); + + const preservePath = opts?.preservePath ?? true; + + const table = new CLITable({ + head: [ + chalk.bold("File"), + chalk.bold("Tasks"), + chalk.bold("Bundle size"), + chalk.bold("Import timing"), + ], + style: { + head: ["blue"], + border: ["gray"], + }, + wordWrap: true, + }); + + for (const item of sorted) { + const { filePath, taskIds, bundleSize, timing } = item; + const displayPath = getDisplayPath(filePath, preservePath); + const bundleSizeColored = formatSizeColored(bundleSize, false); + const timingStr = formatTimingColored(timing, false); + table.push([displayPath, taskIds.length, bundleSizeColored, timingStr]); + } + + console.log(table.toString()); +} + +export function printWarnings(workerManifest: WorkerManifest) { + if (!workerManifest.timings) { + return; + } + + const timings = workerManifest.timings; + const tasksByFile = getTasksByFile(workerManifest.tasks); + + let hasWarnings = false; + + for (const [filePath, timing] of Object.entries(timings)) { + // Warn if the file takes more than 1 second to import + if (timing > 1000) { + if (!hasWarnings) { + console.log(""); + hasWarnings = true; + } + + const taskIds = tasksByFile[filePath] || []; + const timingStr = chalkError(`(${Math.round(timing)}ms)`); + + // File path: bold and purple + console.log(`${chalk.bold(chalkPurple(filePath))} ${timingStr}`); + + // Tasks: blue with a nice tree symbol + taskIds.forEach((id: string, idx: number) => { + const isLast = idx === taskIds.length - 1; + const symbol = isLast ? "└──" : "├──"; + console.log(`${chalkTask(symbol)} ${chalkTask(id)}`); + }); + console.log(""); + console.log( + chalkError( + "Warning: Slow import timing detected (>1s). This will cause slow startups. Consider optimizing this file." + ) + ); + console.log(""); + } + } + + if (hasWarnings) { + printSlowImportTips(); + } +} + +function getTasksByFile(tasks: WorkerManifest["tasks"]): Record { + const tasksByFile: Record = {}; + tasks.forEach((task) => { + const filePath = task.filePath; + if (!tasksByFile[filePath]) { + tasksByFile[filePath] = []; + } + tasksByFile[filePath].push(task.id); + }); + return tasksByFile; +} + +function formatSize(bytes: number): string { + if (bytes > 1024 * 1024) { + return chalkError(`${(bytes / (1024 * 1024)).toFixed(2)} MB`); + } else if (bytes > 1024) { + return chalkWarning(`${(bytes / 1024).toFixed(1)} KB`); + } else { + return chalkGreen(`${bytes} B`); + } +} + +function normalizePath(path: string): string { + // Remove .trigger/tmp/build-/ prefix + return path.replace(/(^|\/).trigger\/tmp\/build-[^/]+\//, ""); +} + +interface BundleTreeData { + filePath: string; + taskIds: string[]; + bundleSize: number; + bundleCount: number; + timing?: number; + bundleChildren: string[]; +} + +function getBundleTreeData(workerManifest: WorkerManifest, metafile: Metafile): BundleTreeData[] { + const tasksByFile = getTasksByFile(workerManifest.tasks); + const outputs = metafile.outputs; + const timings = workerManifest.timings || {}; + + // Map entryPoint (source file) to output file in outputs + const entryToOutput: Record = {}; + for (const [outputPath, outputMeta] of Object.entries(outputs)) { + if (outputMeta.entryPoint) { + entryToOutput[outputMeta.entryPoint] = outputPath; + } + } + + const result: BundleTreeData[] = []; + + for (const filePath of Object.keys(tasksByFile)) { + const outputFile = entryToOutput[filePath]; + const taskIds = tasksByFile[filePath]; + if (!taskIds || taskIds.length === 0) continue; + let bundleTreeInfo = { total: 0, count: 0 }; + let bundleChildren: string[] = []; + if (outputFile && outputs[outputFile]) { + bundleChildren = getInternalChildren(outputs[outputFile], outputs); + // Sum up all bundles in the tree (excluding the root) + const seen = new Set(); + bundleChildren.forEach((child: string, idx: number) => { + const res = sumBundleTree(outputs, child, seen); + bundleTreeInfo.total += res.total; + bundleTreeInfo.count += res.count; + }); + } + result.push({ + filePath, + taskIds, + bundleSize: bundleTreeInfo.total, + bundleCount: bundleTreeInfo.count, + timing: typeof timings[filePath] === "number" ? timings[filePath] : undefined, + bundleChildren, + }); + } + return result; +} + +function sumBundleTree( + outputs: Metafile["outputs"], + current: string, + seen: Set +): { total: number; count: number } { + if (seen.has(current)) { + return { total: 0, count: 0 }; + } + + seen.add(current); + const output = outputs[current]; + + if (!output) { + return { total: 0, count: 0 }; + } + + const size = output.bytes; + const children = getInternalChildren(output, outputs); + let total = size; + let count = 1; + children.forEach((child: string) => { + const res = sumBundleTree(outputs, child, seen); + total += res.total; + count += res.count; + }); + + return { total, count }; +} + +// Helper to format bundle size with color +function formatSizeColored(bytes: number, withBraces = false): string { + let str: string; + if (bytes > 5 * 1024 * 1024) { + str = `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + str = withBraces ? chalkError(`(${str})`) : chalkError(str); + } else if (bytes > 1024 * 1024) { + str = `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + str = withBraces ? chalkWarning(`(${str})`) : chalkWarning(str); + } else if (bytes > 1024) { + str = `${(bytes / 1024).toFixed(1)} KB`; + str = withBraces ? chalkGreen(`(${str})`) : chalkGreen(str); + } else { + str = `${bytes} B`; + str = withBraces ? chalkGreen(`(${str})`) : chalkGreen(str); + } + return str; +} + +// Helper to format timing with color +function formatTimingColored(timing?: number, withBraces = false): string { + let str: string; + if (typeof timing !== "number") { + str = "?ms"; + return withBraces ? chalkGreen(`(${str})`) : chalkGreen(str); + } + if (timing > 1000) { + str = `${Math.round(timing)}ms`; + return withBraces ? chalkError(`(${str})`) : chalkError(str); + } else if (timing > 200) { + str = `${Math.round(timing)}ms`; + return withBraces ? chalkWarning(`(${str})`) : chalkWarning(str); + } else { + str = `${Math.round(timing)}ms`; + return withBraces ? chalkGreen(`(${str})`) : chalkGreen(str); + } +} + +interface PrintBundleTreeNodeOptions { + outputs: Metafile["outputs"]; + current: string; + branchSeen: Set; + taskSeen: Set; + prefix?: string; + isLast?: boolean; + preservePath?: boolean; + colorBundleSize?: boolean; +} + +// Helper to build a map from output file path to task IDs it actually defines (based on inputs) +function buildOutputDefinesTaskIdsMap( + workerManifest: WorkerManifest, + metafile: Metafile +): Record> { + const outputs = metafile.outputs; + + // Map from task file path to task IDs + const filePathToTaskIds: Record = {}; + for (const task of workerManifest.tasks) { + if (!filePathToTaskIds[task.filePath]) filePathToTaskIds[task.filePath] = []; + filePathToTaskIds[task.filePath]!.push(task.id); + } + + // Map from output file to set of task IDs it defines + const outputDefinesTaskIds: Record> = {}; + for (const [outputPath, outputMeta] of Object.entries(outputs)) { + if (!outputMeta.inputs) continue; + for (const inputPath of Object.keys(outputMeta.inputs)) { + if (filePathToTaskIds[inputPath]) { + if (!outputDefinesTaskIds[outputPath]) outputDefinesTaskIds[outputPath] = new Set(); + for (const taskId of filePathToTaskIds[inputPath]) { + outputDefinesTaskIds[outputPath].add(taskId); + } + } + } + } + + return outputDefinesTaskIds; +} + +function getDefinesTaskLabel( + taskIds: Set | undefined, + prefix = "<-- defines tasks: " +): string { + if (!taskIds) { + return ""; + } + + if (taskIds.size === 0) { + return ""; + } + + return " " + chalk.cyanBright(`${prefix}${Array.from(taskIds).join(", ")}`); +} + +function printBundleTreeNode({ + outputs, + current, + branchSeen, + taskSeen, + prefix = "", + isLast = true, + preservePath = true, + colorBundleSize = false, + outputDefinesTaskIds = {}, +}: PrintBundleTreeNodeOptions & { outputDefinesTaskIds?: Record> }) { + // Detect circular dependencies + if (branchSeen.has(current)) { + const displayPath = preservePath ? current : normalizePath(current); + console.log( + prefix + (isLast ? "└── " : "├── ") + chalk.grey(displayPath) + chalk.grey(" (circular)") + ); + return; + } + + // Detect already seen bundles (per task) + if (taskSeen.has(current)) { + const displayPath = preservePath ? current : normalizePath(current); + console.log(prefix + (isLast ? "└── " : "├── ") + chalk.grey(displayPath)); + return; + } + + // Add to seen cache + branchSeen.add(current); + taskSeen.add(current); + + // Get the output for the current node + const output = outputs[current]; + if (!output) { + const displayPath = preservePath ? current : normalizePath(current); + console.log( + prefix + + (isLast ? "└── " : "├── ") + + chalk.grey(displayPath) + + chalk.grey(" (not found in outputs)") + ); + return; + } + + // Get the size and children of the current node + const size = output.bytes; + const children = getInternalChildren(output, outputs); + + const newPrefix = prefix + (isLast ? " " : "│ "); + const displayPath = preservePath ? current : normalizePath(current); + const sizeStr = colorBundleSize ? formatSizeColored(size, true) : formatSize(size); + const definesTaskLabel = + output && !output.entryPoint ? getDefinesTaskLabel(outputDefinesTaskIds[current]) : ""; + console.log( + prefix + (isLast ? "└── " : "├── ") + chalk.bold(displayPath) + ` ` + sizeStr + definesTaskLabel + ); + + // Print the children + children.forEach((child: string, idx: number) => { + printBundleTreeNode({ + outputs, + current: child, + branchSeen: new Set(branchSeen), + taskSeen, + prefix: newPrefix, + isLast: idx === children.length - 1, + preservePath, + colorBundleSize, + outputDefinesTaskIds, + }); + }); +} + +// Helper to sum the size of the root bundle and all unique descendants +function sumBundleTreeUnique(outputs: Metafile["outputs"], root: string): number { + const seen = new Set(); + function walk(current: string) { + if (seen.has(current)) return 0; + seen.add(current); + const output = outputs[current]; + if (!output) return 0; + let total = output.bytes; + const children = getInternalChildren(output, outputs); + for (const child of children) { + total += walk(child); + } + return total; + } + return walk(root); +} + +// Helper to count unique bundles in the tree +function countUniqueBundles(outputs: Metafile["outputs"], root: string): number { + const seen = new Set(); + function walk(current: string) { + if (seen.has(current)) return; + seen.add(current); + const output = outputs[current]; + if (!output) return; + const children = getInternalChildren(output, outputs); + for (const child of children) { + walk(child); + } + } + walk(root); + return seen.size; +} + +function printBundleRoot({ + outputs, + outputFile, + preservePath, + indent = " ", + taskSeen, + outputDefinesTaskIds, +}: { + outputs: Metafile["outputs"]; + outputFile: string | undefined; + preservePath: boolean; + indent?: string; + taskSeen: Set; + outputDefinesTaskIds: Record>; +}) { + if (!outputFile) { + return; + } + + const output = outputs[outputFile]; + + if (!output) { + return; + } + + const rootBundleDisplayPath = getDisplayPath(outputFile, preservePath); + const rootBundleSizeColored = formatSizeColored(output.bytes, true); + + const definesTaskLabel = !output.entryPoint + ? getDefinesTaskLabel(outputDefinesTaskIds[outputFile]) + : ""; + + // Print root bundle node (always └──) + console.log( + `${indent}└── ${chalk.bold(rootBundleDisplayPath)} ${rootBundleSizeColored}${definesTaskLabel}` + ); + + // Print children as children of the root bundle node + const children = getInternalChildren(output, outputs); + + children.forEach((child: string, idx: number) => { + printBundleTreeNode({ + outputs, + current: child, + branchSeen: new Set([outputFile]), + taskSeen, + prefix: indent + " ", + isLast: idx === children.length - 1, + preservePath, + colorBundleSize: true, + outputDefinesTaskIds, + }); + }); +} + +function getInternalChildren( + output: Metafile["outputs"][string], + outputs: Metafile["outputs"] +): string[] { + return (output.imports || []) + .filter((imp) => !imp.external && outputs[imp.path]) + .map((imp) => imp.path); +} + +function findOutputFileByEntryPoint( + outputs: Metafile["outputs"], + entryPoint: string +): string | undefined { + for (const [outputPath, outputMeta] of Object.entries(outputs)) { + if (outputMeta.entryPoint === entryPoint) { + return outputPath; + } + } + return undefined; +} + +function formatTaskCountLabel(count: number): string { + return `${count} task${count === 1 ? "" : "s"}`; +} + +function formatBundleLabel(count: number, size: string): string { + return `${count} bundle${count === 1 ? "" : "s"} ${size}`; +} + +function getDisplayPath(path: string, preserve: boolean): string { + return preserve ? path : normalizePath(path); +} + +function sortBundleTableData(a: BundleTreeData, b: BundleTreeData): number { + const aTiming = typeof a.timing === "number" ? a.timing : -Infinity; + const bTiming = typeof b.timing === "number" ? b.timing : -Infinity; + if (aTiming !== bTiming) return bTiming - aTiming; + if (b.bundleSize !== a.bundleSize) return b.bundleSize - a.bundleSize; + return a.filePath.localeCompare(b.filePath); +} + +function printTaskTree(taskIds: string[], indent = "", colorFn = chalkTask) { + taskIds.forEach((id: string, idx: number) => { + const isLast = idx === taskIds.length - 1; + const symbol = isLast ? "└──" : "├──"; + console.log(`${indent}${symbol} ${colorFn(id)}`); + }); +} + +function printSlowImportTips() { + console.log("Some tips for improving slow imports:"); + console.log( + "- Are there a lot of tasks in this file? Consider splitting it into multiple files with a single task per file." + ); + console.log( + "- Are you importing any tasks? Consider importing only the task types and trigger with `tasks.trigger()` instead. See: https://trigger.dev/docs/triggering#tasks-trigger" + ); + console.log( + "- Are there expensive operations outside of your task at the top level of the file? Consider moving them inside the task or only running them on demand by moving them into a function." + ); + console.log( + "- Are you importing large libraries or modules that aren't used in all code paths? Consider importing them only when needed, for example with a dynamic `await import()`." + ); + console.log( + "- Are you using third-party packages that are known to be slow to import? Check if there are lighter alternatives or if you can import only specific submodules." + ); + + console.log(""); + + console.log("To see more details, run with the --analyze flag."); + console.log("To disable these warnings, run with the --disable-warnings flag."); + + console.log(""); +} diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index 5c0c276f42..fb044e2410 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -90,6 +90,7 @@ export const WorkerManifest = z.object({ initEntryPoint: z.string().optional(), // Optional init.ts entry point runtime: BuildRuntime, customConditions: z.array(z.string()).optional(), + timings: z.record(z.number()).optional(), otelImportHook: z .object({ include: z.array(z.string()).optional(),