Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ae775a6
chore(deps): bump nitro catalog to 3.0.260415-beta
RihanArfan Apr 17, 2026
d73a335
refactor(nitro)!: use nitro v3 `functionRules` for vercel workflow ro…
RihanArfan Apr 17, 2026
990fd75
fix(nitro): chain pre-built workflow bundle sourcemaps through nitro'…
RihanArfan Apr 17, 2026
0eb6c1a
Merge branch 'main' into feat/nitro-builder-improvement
RihanArfan Apr 20, 2026
0a10088
perf: use rolldown filters
RihanArfan Apr 22, 2026
ec263ef
feat: support passing esbuld options to @workflow/builder
RihanArfan Apr 22, 2026
f50d2f2
refactor: replace workflow:force-inline rollup hook with noExternals
RihanArfan Apr 24, 2026
310bc62
Merge remote-tracking branch 'origin/main' into feat/nitro-builder-im…
RihanArfan Apr 24, 2026
73cea8e
fix(nitro): handle noExternals true/false, merge functionRules, align…
RihanArfan Apr 28, 2026
503049b
feat(nitro): support nitro virtual imports via #imports
RihanArfan Apr 28, 2026
437c23d
test: add sourcemap loader plugin tests
RihanArfan Apr 28, 2026
d7d9685
Merge branch 'main' into feat/nitro-builder-improvement
VaguelySerious Apr 28, 2026
c31b398
Merge remote-tracking branch 'origin/main' into feat/nitro-builder-im…
RihanArfan Apr 29, 2026
f29c92e
fix: use source maps in vercel runtime with nitro for workflow routes
RihanArfan Apr 29, 2026
37e6ba0
chore(deps): update to latest nitro beta
RihanArfan Apr 29, 2026
c2d1b17
Merge branch 'main' into feat/nitro-builder-improvement
VaguelySerious Apr 30, 2026
22a28da
Merge branch 'main' into feat/nitro-builder-improvement
RihanArfan Apr 30, 2026
9606de5
fix(nitro): make source maps opt in
RihanArfan May 1, 2026
d0ca3cb
test: update for nitro
RihanArfan May 1, 2026
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
8 changes: 8 additions & 0 deletions .changeset/polite-cat-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@workflow/builders": minor
"@workflow/nitro": patch
---

`@workflow/builders`: allow passing extra esbuild options merged into every bundle the builder produces (steps, intermediate workflow, final workflow wrapper, and webhook) via a new `esbuildOptions` config field.

`@workflow/nitro`: pass `sourcesContent: false` to the underlying builder so sources aren't duplicated — nitro re-bundles these outputs and inlines sourcemaps via `workflowSourcemapLoaderPlugin`.
5 changes: 5 additions & 0 deletions .changeset/twenty-socks-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/nitro": patch
---

Bundle workflow routes within the Nitro server using base builder, use Nitro v3 `functionRules` for Vercel workflow routes
13 changes: 13 additions & 0 deletions docs/content/docs/getting-started/nitro.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.c

<FluidComputeCallout />

### Source maps in error stacks

By default Nitro does not emit source maps, so step errors thrown in production show paths into the bundled output (`/var/task/index.mjs`) rather than your original `.ts` files. To get remapped stacks on Vercel, set `sourcemap: true` in your `nitro.config.ts`:

```typescript title="nitro.config.ts"
import { defineConfig } from "nitro";

export default defineConfig({
modules: ["workflow/nitro"],
sourcemap: true, // [!code highlight]
});
```

Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere.

## Troubleshooting
Expand Down
4 changes: 4 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ export abstract class BaseBuilder {
const esmRequireBanner = this.getEsmRequireBanner(format);

const esbuildCtx = await esbuild.context({
...(this.config.esbuildOptions ?? {}),
banner: {
js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${importMetaBanner}${esmRequireBanner}`,
},
Expand Down Expand Up @@ -746,6 +747,7 @@ export abstract class BaseBuilder {
// Bundle with esbuild and our custom SWC plugin in workflow mode.
// this bundle will be run inside a vm isolate
const interimBundleCtx = await esbuild.context({
...(this.config.esbuildOptions ?? {}),
stdin: {
contents: imports,
resolveDir: this.config.workingDir,
Expand Down Expand Up @@ -932,6 +934,7 @@ export const POST = workflowEntrypoint(workflowCode);`;
// we could remove this if we do nft tracing or similar instead
const finalEsmRequireBanner = this.getEsmRequireBanner(format);
const finalWorkflowResult = await esbuild.build({
...(this.config.esbuildOptions ?? {}),
banner: {
js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${finalEsmRequireBanner}`,
},
Expand Down Expand Up @@ -1182,6 +1185,7 @@ export const OPTIONS = handler;`;
const webhookEsmRequireBanner = this.getEsmRequireBanner('esm');
const webhookBundleStart = Date.now();
const result = await esbuild.build({
...(this.config.esbuildOptions ?? {}),
banner: {
js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${webhookEsmRequireBanner}`,
},
Expand Down
3 changes: 3 additions & 0 deletions packages/builders/src/config-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
import type { BuildOptions } from 'esbuild';
import { findUp } from 'find-up';
import JSON5 from 'json5';
import type { WorkflowConfig } from './types.js';
Expand Down Expand Up @@ -96,6 +97,7 @@ export function createBaseBuilderConfig(options: {
watch?: boolean;
externalPackages?: string[];
runtime?: string;
esbuildOptions?: Partial<BuildOptions>;
}): Omit<WorkflowConfig, 'buildTarget'> {
return {
dirs: options.dirs ?? ['workflows'],
Expand All @@ -107,5 +109,6 @@ export function createBaseBuilderConfig(options: {
webhookBundlePath: '', // Not used by base builder methods
externalPackages: options.externalPackages,
runtime: options.runtime,
esbuildOptions: options.esbuildOptions,
};
}
8 changes: 8 additions & 0 deletions packages/builders/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { BuildOptions } from 'esbuild';

export const validBuildTargets = [
'standalone',
'vercel-build-output-api',
Expand Down Expand Up @@ -56,6 +58,12 @@ interface BaseWorkflowConfig {

// Node.js runtime version for Vercel Functions (e.g., "nodejs22.x", "nodejs24.x")
runtime?: string;

/**
* Extra esbuild options merged into every bundle this builder produces
* (steps, intermediate workflow, final workflow wrapper, webhook).
*/
esbuildOptions?: Partial<BuildOptions>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

esbuildOptions: Partial<BuildOptions> is a broad surface for one knob

The new field accepts the full esbuild BuildOptions and is spread into four bundle calls with very different needs (steps: node/esm, workflow VM: neutral/cjs, webhook: node/esm, final wrapper: node/esm). The current spread order puts user options first so builder-required options like platform, format, bundle, plugins win — that's defensive — but the API still implies "you can pass any esbuild option" when in practice almost everything that matters is overridden.

Given the only consumer is sourcesContent: false, consider either narrowing the public type to a small allowlist or naming the field something closer to its actual purpose. Not blocking, but the surface area will be hard to take back later.

}

/**
Expand Down
19 changes: 15 additions & 4 deletions packages/core/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,24 @@ export function hasStepSourceMaps(): boolean {
return appName !== 'sveltekit';
}

// NestJS preserves source maps in all builds including prod
if (appName === 'nest') {
// Frameworks that emit usable step source maps in local prod builds:
// - `nest` preserves them by default in all builds.
// - The nitro-built workbenches opt in via `sourcemap: true` in their
// `nitro.config.ts` (or `nitro: { sourcemap: true }` in `nuxt.config.ts`).
const localProdSourcemapApps = [
'nest',
'nitro',
'express',
'fastify',
'hono',
'nuxt',
];
if (localProdSourcemapApps.includes(appName)) {
return true;
}

// Prod buils for frameworks typically don't consume source maps. So let's disable testing
// in local prod and local postgres tests
// Prod builds for other frameworks typically don't consume source maps,
// so disable the assertion in local prod and local postgres test runs.
if (!process.env.DEV_TEST_CONFIG) {
return false;
}
Expand Down
52 changes: 46 additions & 6 deletions packages/nitro/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,50 @@ import {
import type { Nitro } from 'nitro/types';
import { join } from 'pathe';

// Nitro virtual import prefixes (e.g. `#imports`, `#nitro/virtual/storage`,
// `#shared/utils`) that don't exist on disk — they're materialised by
// Nitro's own rollup pass via @rollup/plugin-alias and the virtual-module
// plugins. esbuild can't resolve them at workflow build time, so we mark
// them external; the bare specifier is preserved in the emitted bundle and
// Nitro resolves it when re-bundling. Mirrors Nitro's own
// `virtualRe = /^(?:\0|#|virtual:)/` handling.
function getNitroVirtualExternals(nitro: Nitro): string[] {
const staticPatterns = ['#imports'];

// Pick up any additional `#`-prefixed aliases the host app or Nitro
// modules have registered (e.g. Nuxt-specific ones).
const aliasPatterns = Object.keys(nitro.options.alias ?? {})
.filter((key) => key.startsWith('#'))
.flatMap((key) => [key, `${key}/*`]);

return [...new Set([...staticPatterns, ...aliasPatterns])];
}

/**
* Forward string entries from Nitro's `externals.external` config to the
* workflow builder's esbuild `external` option. RegExp and function entries
* are skipped since esbuild's `external` only supports literal strings.
*
* Note: `externals.external` only exists on Nitro v2's options shape — v3
* dropped it in favour of `noExternals`. The cast lets us still read it on
* v2 setups; on v3 the chained optional access just returns undefined.
*/
function getNitroStringExternals(nitro: Nitro): string[] | undefined {
const externals = nitro.options.externals?.external?.filter(
(entry): entry is string => typeof entry === 'string'
function getNitroStringExternals(nitro: Nitro): string[] {
const external = (nitro.options as { externals?: { external?: unknown[] } })
.externals?.external;
return (
external?.filter((entry): entry is string => typeof entry === 'string') ??
[]
);
return externals && externals.length > 0 ? externals : undefined;
}

function getWorkflowExternalPackages(nitro: Nitro): string[] {
return [
...new Set([
...getNitroVirtualExternals(nitro),
...getNitroStringExternals(nitro),
]),
];
}

export class VercelBuilder extends VercelBuildOutputAPIBuilder {
Expand All @@ -27,7 +61,10 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder {
workingDir: nitro.options.rootDir,
dirs: ['.'], // Different apps that use nitro have different directories
runtime: nitro.options.workflow?.runtime,
externalPackages: getNitroStringExternals(nitro),
externalPackages: getWorkflowExternalPackages(nitro),
// Nitro re-bundles these outputs through its own pipeline and inlines
// the sourcemaps via workflowSourcemapLoaderPlugin
esbuildOptions: { sourcesContent: false },
}),
buildTarget: 'vercel-build-output-api',
});
Expand All @@ -54,7 +91,10 @@ export class LocalBuilder extends BaseBuilder {
workingDir: nitro.options.rootDir,
watch: nitro.options.dev,
dirs: ['.'], // Different apps that use nitro have different directories
externalPackages: getNitroStringExternals(nitro),
externalPackages: getWorkflowExternalPackages(nitro),
// Nitro re-bundles these outputs through its own pipeline and inlines
// the sourcemaps via workflowSourcemapLoaderPlugin
esbuildOptions: { sourcesContent: false },
}),
buildTarget: 'next', // Placeholder, not actually used
});
Expand Down
Loading
Loading