From 4952c00b427d10296a3a626a7123f9e70810aefc Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 12:11:50 +0100 Subject: [PATCH 1/9] cursor should ignore .env files --- .cursorignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 63307e1d5a6a34fc7da609977b0694a0ec8e60f6 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 12:29:04 +0100 Subject: [PATCH 2/9] fix for duplicate builds when starting dev --- packages/cli-v3/src/dev/devSession.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index f13cbc0014..dd0dbb960a 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -160,8 +160,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); From bbe4aa35b24fb73b8a6bece164545261a2306af2 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 13:00:55 +0100 Subject: [PATCH 3/9] output metafile in dev --- packages/cli-v3/src/dev/devSession.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index dd0dbb960a..74a7f21295 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,6 +106,11 @@ 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 { From 5539a582bad9a2285a3faf429784b3ebb2a0ff7e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 13:02:28 +0100 Subject: [PATCH 4/9] attach metafile to background worker --- packages/cli-v3/src/dev/backgroundWorker.ts | 2 ++ packages/cli-v3/src/dev/devSession.ts | 6 +++++- packages/cli-v3/src/dev/devSupervisor.ts | 10 +++++++--- packages/cli-v3/src/dev/workerRuntime.ts | 3 ++- 4 files changed, 16 insertions(+), 5 deletions(-) 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/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index 74a7f21295..6445c1f75d 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -116,7 +116,11 @@ export async function startDevSession({ 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); 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 = { From ec6c4db9b8be1d4addc619ea9ddc02b7b304098f Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 13:03:37 +0100 Subject: [PATCH 5/9] attach import timings to worker manifest --- packages/cli-v3/src/entryPoints/dev-index-worker.ts | 6 ++++-- packages/cli-v3/src/entryPoints/managed-index-worker.ts | 6 ++++-- packages/cli-v3/src/indexing/registerResources.ts | 8 ++++++-- packages/core/src/v3/schemas/build.ts | 1 + 4 files changed, 15 insertions(+), 6 deletions(-) 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/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(), From 734ebc3c3b6fd758336799c7a83c8cc854577731 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 13:06:38 +0100 Subject: [PATCH 6/9] warn during dev if imports take more than 1s --- packages/cli-v3/src/commands/dev.ts | 8 +- packages/cli-v3/src/dev/devOutput.ts | 3 + packages/cli-v3/src/utilities/analyze.ts | 626 +++++++++++++++++++++++ 3 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 packages/cli-v3/src/utilities/analyze.ts 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/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/utilities/analyze.ts b/packages/cli-v3/src/utilities/analyze.ts new file mode 100644 index 0000000000..bfa2f60b86 --- /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 --no-warnings flag."); + + console.log(""); +} From 564f310ae76c5da1e4ea0328cfe56299e406a2c5 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 13:06:51 +0100 Subject: [PATCH 7/9] add analyze command --- packages/cli-v3/src/cli/index.ts | 2 + packages/cli-v3/src/commands/analyze.ts | 150 ++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 packages/cli-v3/src/commands/analyze.ts 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; From bccb81bbaabe076dbee277040ca0d25ff7ff3bf5 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 14:29:50 +0100 Subject: [PATCH 8/9] update disable warnings flag message --- packages/cli-v3/src/utilities/analyze.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-v3/src/utilities/analyze.ts b/packages/cli-v3/src/utilities/analyze.ts index bfa2f60b86..1f5b555eb9 100644 --- a/packages/cli-v3/src/utilities/analyze.ts +++ b/packages/cli-v3/src/utilities/analyze.ts @@ -620,7 +620,7 @@ function printSlowImportTips() { console.log(""); console.log("To see more details, run with the --analyze flag."); - console.log("To disable these warnings, run with the --no-warnings flag."); + console.log("To disable these warnings, run with the --disable-warnings flag."); console.log(""); } From cf886321af24088fa1272677ff4533c8ef13eab1 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 28 May 2025 14:36:30 +0100 Subject: [PATCH 9/9] add changeset --- .changeset/late-dancers-smile.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/late-dancers-smile.md 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