Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/configurable-sourcemap.md
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 81 additions & 6 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,48 @@ 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);

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.
Expand Down Expand Up @@ -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);
Comment on lines +113 to +117
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveSourcemap() re-parses WORKFLOW_SOURCEMAP on every call. When the env value is unrecognized, parseSourcemapEnv() emits a warning, which can result in repeated identical warnings during a single build (steps + workflows + webhook + vc-config). Consider memoizing the parsed env value (and/or warning once) to keep logs from getting noisy.

Copilot uses AI. Check for mistakes.
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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.<anonymous>".
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: [],
Expand Down
4 changes: 3 additions & 1 deletion packages/builders/src/config-helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -96,6 +96,7 @@ export function createBaseBuilderConfig(options: {
watch?: boolean;
externalPackages?: string[];
runtime?: string;
sourcemap?: SourcemapMode;
}): Omit<WorkflowConfig, 'buildTarget'> {
return {
dirs: options.dirs ?? ['workflows'],
Expand All @@ -107,5 +108,6 @@ export function createBaseBuilderConfig(options: {
webhookBundlePath: '', // Not used by base builder methods
externalPackages: options.externalPackages,
runtime: options.runtime,
sourcemap: options.sourcemap,
};
}
151 changes: 151 additions & 0 deletions packages/builders/src/resolve-sourcemap.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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);
});
});
31 changes: 31 additions & 0 deletions packages/builders/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Comment on lines +73 to +75
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring implies a single global default ("sourcemaps default to 'inline'") for all bundles including the webhook, but BaseBuilder uses different per-invocation defaults (e.g. webhook/final wrapper default off unless WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING or an explicit sourcemap override is set). Please clarify the wording so users don’t assume webhook bundles default to inline sourcemaps.

Suggested change
* `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.
* `both`). If neither is set, the effective default depends on the bundle
* being generated. Step and workflow VM bundles typically default to
* `'inline'` so stack traces include original file names and line numbers,
* while webhook/final-wrapper bundles may default to `false` unless
* debugging or an explicit sourcemap override enables them.

Copilot uses AI. Check for mistakes.
*
* 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;
}

/**
Expand Down
6 changes: 4 additions & 2 deletions packages/builders/src/vercel-build-output-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/nitro/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand All @@ -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
});
Expand Down
Loading
Loading