diff --git a/.changeset/configurable-sourcemap.md b/.changeset/configurable-sourcemap.md new file mode 100644 index 0000000000..6df2abbca6 --- /dev/null +++ b/.changeset/configurable-sourcemap.md @@ -0,0 +1,6 @@ +--- +"@workflow/builders": minor +"@workflow/nitro": minor +--- + +Add a `sourcemap` builder option and matching `WORKFLOW_SOURCEMAP` environment variable that accept esbuild's sourcemap values. Setting this to `false` drops inline sourcemaps from generated bundles to reduce function size. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index b3678f1adf..22a3d22d1a 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -18,7 +18,7 @@ import { getImportPath } from './module-specifier.js'; import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js'; import { createSwcPlugin } from './swc-esbuild-plugin.js'; -import type { WorkflowConfig } from './types.js'; +import type { SourcemapMode, WorkflowConfig } from './types.js'; import { extractWorkflowGraphs } from './workflows-extractor.js'; const enhancedResolve = promisify(enhancedResolveOriginal); @@ -26,6 +26,40 @@ const enhancedResolve = promisify(enhancedResolveOriginal); const EMIT_SOURCEMAPS_FOR_DEBUGGING = process.env.WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING === '1'; +/** + * Parses the `WORKFLOW_SOURCEMAP` environment variable. Accepts the same + * values as esbuild's `sourcemap` option plus the strings `"true"`/`"false"`. + * Returns `undefined` if unset or unrecognized. + */ +function parseSourcemapEnv( + value: string | undefined +): SourcemapMode | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + switch (normalized) { + case '': + return undefined; + case 'true': + case '1': + return true; + case 'false': + case '0': + return false; + case 'inline': + case 'linked': + case 'external': + case 'both': + return normalized; + default: + console.warn( + `Unrecognized WORKFLOW_SOURCEMAP value: ${JSON.stringify(value)}. ` + + `Expected one of: true, false, inline, linked, external, both. ` + + `Falling back to the builder's configured default.` + ); + return undefined; + } +} + /** * Normalize an array of file paths by appending the `realpath()` of each entry * (to handle symlinks, e.g. pnpm/workspace layouts) and deduplicating. @@ -68,6 +102,34 @@ export abstract class BaseBuilder { return this.config.projectRoot || this.config.workingDir; } + /** + * Resolves the sourcemap mode to apply to esbuild invocations. + * + * Priority: + * 1. Explicit `sourcemap` value on the builder config. + * 2. `WORKFLOW_SOURCEMAP` environment variable. + * 3. The provided `defaultMode` (per-bundle-site default). + */ + protected resolveSourcemap(defaultMode: SourcemapMode): SourcemapMode { + if (this.config.sourcemap !== undefined) { + return this.config.sourcemap; + } + const fromEnv = parseSourcemapEnv(process.env.WORKFLOW_SOURCEMAP); + if (fromEnv !== undefined) { + return fromEnv; + } + return defaultMode; + } + + /** + * Whether the resolved sourcemap mode produces any form of sourcemap output + * (either inline, linked, external, or both). Useful for toggling + * `shouldAddSourcemapSupport` on generated Vercel function configs. + */ + protected get sourcemapsEnabled(): boolean { + return this.resolveSourcemap(true) !== false; + } + /** * Whether informational BaseBuilder logs should be printed. * Subclasses can override this to silence progress logs while keeping warnings/errors. @@ -568,7 +630,11 @@ export abstract class BaseBuilder { // Steps execute in Node.js context and inline sourcemaps ensure we get // meaningful stack traces with proper file names and line numbers when errors // occur in deeply nested function calls across multiple files. - sourcemap: 'inline', + // + // Callers can override via the `sourcemap` builder config option or the + // `WORKFLOW_SOURCEMAP` environment variable (e.g. to disable sourcemaps + // entirely and shrink the function bundle size). + sourcemap: this.resolveSourcemap('inline'), plugins: [ // Handle pseudo-packages like 'server-only' and 'client-only' by providing // empty modules. Must run first to intercept these before other resolution. @@ -772,7 +838,12 @@ export abstract class BaseBuilder { // Inline source maps for better stack traces in workflow VM execution. // This intermediate bundle is executed via runInContext() in a VM, so we need // inline source maps to get meaningful stack traces instead of "evalmachine.". - sourcemap: 'inline', + // + // This bundle is never written to disk (its contents are embedded as a string + // into the final workflow bundle), so non-inline modes like 'external' or + // 'linked' would be meaningless — we collapse them to 'inline'. A caller that + // disables sourcemaps entirely (`sourcemap: false`) still gets no sourcemap. + sourcemap: this.resolveSourcemap('inline') === false ? false : 'inline', // Use tsconfig for path alias resolution. // For symlinked configs this uses tsconfigRaw to preserve cwd-relative aliases. ...esbuildTsconfigOptions, @@ -943,8 +1014,10 @@ export const POST = workflowEntrypoint(workflowCode);`; }, outfile, // Source maps for the final workflow bundle wrapper (not important since this code - // doesn't run in the VM - only the intermediate bundle sourcemap is relevant) - sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + // doesn't run in the VM - only the intermediate bundle sourcemap is relevant). + // Default is off; callers can override via `sourcemap` config or + // `WORKFLOW_SOURCEMAP` / `WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING` env vars. + sourcemap: this.resolveSourcemap(EMIT_SOURCEMAPS_FOR_DEBUGGING), absWorkingDir: this.config.workingDir, bundle: true, format, @@ -1213,7 +1286,9 @@ export const OPTIONS = handler;`; '.mjs', '.cjs', ], - sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + // Default is off; callers can override via `sourcemap` config or + // `WORKFLOW_SOURCEMAP` / `WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING` env vars. + sourcemap: this.resolveSourcemap(EMIT_SOURCEMAPS_FOR_DEBUGGING), mainFields: ['module', 'main'], // Don't externalize anything - bundle everything including workflow packages external: [], diff --git a/packages/builders/src/config-helpers.ts b/packages/builders/src/config-helpers.ts index 94b50aa5a0..e07975e26c 100644 --- a/packages/builders/src/config-helpers.ts +++ b/packages/builders/src/config-helpers.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import { findUp } from 'find-up'; import JSON5 from 'json5'; -import type { WorkflowConfig } from './types.js'; +import type { SourcemapMode, WorkflowConfig } from './types.js'; export interface DecoratorOptions { decorators: boolean; @@ -96,6 +96,7 @@ export function createBaseBuilderConfig(options: { watch?: boolean; externalPackages?: string[]; runtime?: string; + sourcemap?: SourcemapMode; }): Omit { return { dirs: options.dirs ?? ['workflows'], @@ -107,5 +108,6 @@ export function createBaseBuilderConfig(options: { webhookBundlePath: '', // Not used by base builder methods externalPackages: options.externalPackages, runtime: options.runtime, + sourcemap: options.sourcemap, }; } diff --git a/packages/builders/src/resolve-sourcemap.test.ts b/packages/builders/src/resolve-sourcemap.test.ts new file mode 100644 index 0000000000..63f288d37c --- /dev/null +++ b/packages/builders/src/resolve-sourcemap.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BaseBuilder } from './base-builder.js'; +import type { SourcemapMode, StandaloneConfig } from './types.js'; + +/** + * Minimal subclass that exposes the protected `resolveSourcemap()` and + * `sourcemapsEnabled` members for testing. + */ +class TestBuilder extends BaseBuilder { + async build(): Promise { + // no-op + } + + public callResolveSourcemap(defaultMode: SourcemapMode): SourcemapMode { + return this.resolveSourcemap(defaultMode); + } + + public get publicSourcemapsEnabled(): boolean { + return this.sourcemapsEnabled; + } +} + +function createBuilder(sourcemap?: SourcemapMode): TestBuilder { + const config: StandaloneConfig = { + buildTarget: 'standalone', + workingDir: '/tmp/workflow-test', + dirs: ['.'], + stepsBundlePath: '', + workflowsBundlePath: '', + webhookBundlePath: '', + sourcemap, + }; + return new TestBuilder(config); +} + +describe('resolveSourcemap', () => { + const originalEnv = process.env.WORKFLOW_SOURCEMAP; + + beforeEach(() => { + delete process.env.WORKFLOW_SOURCEMAP; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.WORKFLOW_SOURCEMAP; + } else { + process.env.WORKFLOW_SOURCEMAP = originalEnv; + } + }); + + it('returns the default when no config or env var is set', () => { + const builder = createBuilder(); + expect(builder.callResolveSourcemap('inline')).toBe('inline'); + expect(builder.callResolveSourcemap(false)).toBe(false); + expect(builder.callResolveSourcemap(true)).toBe(true); + }); + + it('prefers explicit config over the default', () => { + expect(createBuilder(false).callResolveSourcemap('inline')).toBe(false); + expect(createBuilder('external').callResolveSourcemap('inline')).toBe( + 'external' + ); + expect(createBuilder('linked').callResolveSourcemap(false)).toBe('linked'); + expect(createBuilder(true).callResolveSourcemap('inline')).toBe(true); + }); + + it('prefers explicit config over environment variable', () => { + process.env.WORKFLOW_SOURCEMAP = 'inline'; + expect(createBuilder(false).callResolveSourcemap('inline')).toBe(false); + expect(createBuilder('external').callResolveSourcemap('inline')).toBe( + 'external' + ); + }); + + it('uses environment variable when config is not set', () => { + process.env.WORKFLOW_SOURCEMAP = 'false'; + expect(createBuilder().callResolveSourcemap('inline')).toBe(false); + + process.env.WORKFLOW_SOURCEMAP = 'true'; + expect(createBuilder().callResolveSourcemap(false)).toBe(true); + + for (const mode of ['inline', 'linked', 'external', 'both'] as const) { + process.env.WORKFLOW_SOURCEMAP = mode; + expect(createBuilder().callResolveSourcemap('inline')).toBe(mode); + } + }); + + it('accepts "0" / "1" as environment variable aliases for false / true', () => { + process.env.WORKFLOW_SOURCEMAP = '0'; + expect(createBuilder().callResolveSourcemap('inline')).toBe(false); + + process.env.WORKFLOW_SOURCEMAP = '1'; + expect(createBuilder().callResolveSourcemap(false)).toBe(true); + }); + + it('falls back to default when env var is empty or unrecognized', () => { + process.env.WORKFLOW_SOURCEMAP = ''; + expect(createBuilder().callResolveSourcemap('inline')).toBe('inline'); + + // Suppress the expected warning + const originalWarn = console.warn; + console.warn = () => {}; + try { + process.env.WORKFLOW_SOURCEMAP = 'nonsense'; + expect(createBuilder().callResolveSourcemap('inline')).toBe('inline'); + } finally { + console.warn = originalWarn; + } + }); +}); + +describe('sourcemapsEnabled', () => { + const originalEnv = process.env.WORKFLOW_SOURCEMAP; + + beforeEach(() => { + delete process.env.WORKFLOW_SOURCEMAP; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.WORKFLOW_SOURCEMAP; + } else { + process.env.WORKFLOW_SOURCEMAP = originalEnv; + } + }); + + it('is true by default', () => { + expect(createBuilder().publicSourcemapsEnabled).toBe(true); + }); + + it('is false when config sourcemap is false', () => { + expect(createBuilder(false).publicSourcemapsEnabled).toBe(false); + }); + + it('is true for any non-false config value', () => { + for (const mode of [ + true, + 'inline', + 'linked', + 'external', + 'both', + ] as const) { + expect(createBuilder(mode).publicSourcemapsEnabled).toBe(true); + } + }); + + it('is false when WORKFLOW_SOURCEMAP env is false', () => { + process.env.WORKFLOW_SOURCEMAP = 'false'; + expect(createBuilder().publicSourcemapsEnabled).toBe(false); + }); +}); diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index 688b14826e..f0bcb05d90 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -8,6 +8,19 @@ export const validBuildTargets = [ ] as const; export type BuildTarget = (typeof validBuildTargets)[number]; +/** + * Sourcemap mode for generated workflow bundles. Matches the values + * accepted by esbuild's `sourcemap` option. + * + * - `true` / `'linked'` — emit a separate `.map` file and append a + * `//# sourceMappingURL=` comment pointing to it. + * - `'inline'` — inline the sourcemap as a base64 data URL in the bundle. + * - `'external'` — emit a separate `.map` file without a reference comment. + * - `'both'` — inline *and* emit a separate `.map` file. + * - `false` — do not emit a sourcemap at all. + */ +export type SourcemapMode = boolean | 'inline' | 'linked' | 'external' | 'both'; + /** * Common configuration options shared across all builder types. */ @@ -48,6 +61,24 @@ interface BaseWorkflowConfig { // Node.js runtime version for Vercel Functions (e.g., "nodejs22.x", "nodejs24.x") runtime?: string; + + /** + * Sourcemap mode for generated workflow bundles (steps, workflows, webhook). + * + * Accepts the same values as esbuild's `sourcemap` option: + * `true` / `'linked'`, `'inline'`, `'external'`, `'both'`, or `false`. + * + * If unset, the value of the `WORKFLOW_SOURCEMAP` environment variable is + * consulted (valid values: `true`, `false`, `inline`, `linked`, `external`, + * `both`). If neither is set, sourcemaps default to `'inline'` so stack + * traces from step and workflow VM execution include original file names + * and line numbers. + * + * Setting this to `false` can dramatically reduce the generated function + * bundle size, which is useful for hitting Vercel's 250MB function size + * limit. + */ + sourcemap?: SourcemapMode; } /** diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index 81ab83342f..43952041ed 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -79,11 +79,13 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { tsconfigPath, }); - // Create package.json and .vc-config.json for steps function + // Create package.json and .vc-config.json for steps function. + // Only enable sourcemap support on the Vercel runtime when we're actually + // emitting sourcemaps — enabling it otherwise just adds runtime overhead. await this.createPackageJson(stepsFuncDir, 'module'); await this.createVcConfig(stepsFuncDir, { handler: 'index.mjs', - shouldAddSourcemapSupport: true, + shouldAddSourcemapSupport: this.sourcemapsEnabled, maxDuration: 'max', experimentalTriggers: [STEP_QUEUE_TRIGGER], runtime: this.config.runtime, diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index cd87a308b6..061e9af958 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -15,6 +15,7 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder { workingDir: nitro.options.rootDir, dirs: ['.'], // Different apps that use nitro have different directories runtime: nitro.options.workflow?.runtime, + sourcemap: nitro.options.workflow?.sourcemap, }), buildTarget: 'vercel-build-output-api', }); @@ -41,6 +42,7 @@ export class LocalBuilder extends BaseBuilder { workingDir: nitro.options.rootDir, watch: nitro.options.dev, dirs: ['.'], // Different apps that use nitro have different directories + sourcemap: nitro.options.workflow?.sourcemap, }), buildTarget: 'next', // Placeholder, not actually used }); diff --git a/packages/nitro/src/types.ts b/packages/nitro/src/types.ts index d8fd832f52..8289445eb8 100644 --- a/packages/nitro/src/types.ts +++ b/packages/nitro/src/types.ts @@ -21,6 +21,20 @@ export interface ModuleOptions { * @example "nodejs24.x" */ runtime?: string; + + /** + * Sourcemap mode for generated workflow bundles. Accepts the same values + * as esbuild's `sourcemap` option: `true` / `'linked'`, `'inline'`, + * `'external'`, `'both'`, or `false`. + * + * If unset, the value of the `WORKFLOW_SOURCEMAP` environment variable is + * consulted. If neither is set, the builder's default (`'inline'`) is used. + * + * Setting this to `false` can dramatically reduce the generated function + * bundle size when deploying to Vercel (useful for staying under the 250MB + * function size limit). + */ + sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; } declare module 'nitro/types' {