Skip to content
Open
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
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
47 changes: 46 additions & 1 deletion packages/nitro/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest';
import nitroModule from './index.js';

function createNitroStub({ routing }: { routing: boolean }) {
function createNitroStub({
routing,
meta,
}: {
routing: boolean;
meta?: { version: string; majorVersion: number };
}) {
return {
routing,
...(meta && { meta }),
options: {
alias: {},
buildDir: '/tmp/.nitro',
Expand Down Expand Up @@ -46,4 +53,42 @@ describe('@workflow/nitro virtual handlers', () => {
'import { POST } from "/tmp/.nitro/workflow/steps.mjs";'
);
});

it('uses meta.majorVersion to detect Nitro v2 when available', async () => {
const nitro = createNitroStub({
routing: false,
meta: { version: '2.11.0', majorVersion: 2 },
});

await nitroModule.setup(nitro);

const source = nitro.options.virtual['#workflow/steps.mjs'];
expect(source).toContain('fromWebHandler');
});

it('uses meta.majorVersion to detect Nitro v3 when available', async () => {
const nitro = createNitroStub({
routing: true,
meta: { version: '3.0.0', majorVersion: 3 },
});

await nitroModule.setup(nitro);

const source = nitro.options.virtual['#workflow/steps.mjs'];
expect(source).not.toContain('fromWebHandler');
expect(source).toContain('export default async');
});

it('prefers meta.majorVersion over routing property', async () => {
// Simulate a hypothetical case where routing is truthy but majorVersion says v2
const nitro = createNitroStub({
routing: true,
meta: { version: '2.11.0', majorVersion: 2 },
});

await nitroModule.setup(nitro);

const source = nitro.options.virtual['#workflow/steps.mjs'];
expect(source).toContain('fromWebHandler');
});
});
218 changes: 145 additions & 73 deletions packages/nitro/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from '@workflow/builders';
import { workflowTransformPlugin } from '@workflow/rollup';
import type { Nitro, NitroModule, RollupConfig } from 'nitro/types';
import { join } from 'pathe';
Expand All @@ -8,6 +9,51 @@ import type { ModuleOptions } from './types';

export type { ModuleOptions };

/**
* Detect whether the Nitro instance is v2.
* Newer Nitro releases (both v2 and v3) expose `nitro.meta.majorVersion`.
* Fall back to checking `nitro.routing` (only present in v3+) for older
* Nitro v2 versions that don't have `majorVersion` yet (e.g. Nuxt users
* on an older nitropack).
*/
function isNitroV2(nitro: Nitro): boolean {
const majorVersion = (nitro as any).meta?.majorVersion;
if (majorVersion != null) {
return majorVersion === 2;
}
return !nitro.routing;
}

/**
* Rollup plugin that surfaces the inline source maps embedded in the
* pre-built workflow bundles (steps.mjs, workflows.mjs) to rollup's load
* pipeline. Rollup does not consume `//# sourceMappingURL=data:...` comments
* from input files by default, so without this the final Nitro output map
* only references nitro wrappers + node_modules and error stack traces point
* at the bundled output rather than the original user `.ts` sources.
*/
function workflowSourcemapLoaderPlugin(workflowBuildDir: string) {
const INLINE_MAP_RE =
/\/\/# sourceMappingURL=data:application\/json[^,]*,([A-Za-z0-9+/=]+)\s*$/;
return {
name: 'workflow:sourcemap-loader',
load(id: string) {
if (!id.startsWith(workflowBuildDir) || !id.endsWith('.mjs')) return null;
const code = readFileSync(id, 'utf8');
const match = code.match(INLINE_MAP_RE);
if (!match) return code;
try {
const map = JSON.parse(
Buffer.from(match[1], 'base64').toString('utf8')
);
return { code: code.slice(0, match.index), map };
} catch {
return code;
}
},
};
}

export default {
name: 'workflow/nitro',
async setup(nitro: Nitro) {
Expand All @@ -26,7 +72,8 @@ export default {
// These are already processed and re-processing causes issues like
// undefined class references when Nitro's bundler renames variables
exclude: [workflowBuildDir],
})
}),
workflowSourcemapLoaderPlugin(workflowBuildDir)
);
});

Expand Down Expand Up @@ -125,87 +172,112 @@ export default {
});
}

// Generate functions for vercel build
if (isVercelDeploy) {
// Nitro v2 Vercel deploy: use legacy VercelBuilder approach
if (isVercelDeploy && isNitroV2(nitro)) {
nitro.hooks.hook('compiled', async () => {
await new VercelBuilder(nitro).build();
});
return;
}

// Generate local bundles for dev and local prod
if (!isVercelDeploy) {
const builder = new LocalBuilder(nitro);
let isInitialBuild = true;

nitro.hooks.hook('build:before', async () => {
await builder.build();

// For prod: write the manifest handler file with inlined content
// now that the builder has generated the manifest. Rollup will
// bundle this file into the compiled output.
if (
!nitro.options.dev &&
process.env.WORKFLOW_PUBLIC_MANIFEST === '1'
) {
writeManifestHandler(nitro);
}
});
// Nitro v3+ Vercel deploy: configure function rules for workflow routes (queue triggers, maxDuration).
if (isVercelDeploy) {
// Enable sourcemaps so rollup chains inline sourcemaps from step bundles
// through to the output, preserving original file names in error stacks.
nitro.options.sourcemap = true;

nitro.options.vercel ??= {};
nitro.options.vercel.functionRules ??= {};

const runtime = nitro.options.workflow?.runtime;

// Allows for HMR - but skip the first dev:reload since build:before already ran
if (nitro.options.dev) {
nitro.hooks.hook('dev:reload', async () => {
if (isInitialBuild) {
isInitialBuild = false;
return;
}
try {
await builder.build();
} catch (error) {
// During dev, files may be added/removed while the builder
// is rebuilding (e.g., during test cleanup). Log the error
// but don't crash — the next file change will trigger
// another rebuild with the correct file list.
console.warn('Warning: Workflow rebuild failed:', error);
}
});
nitro.options.vercel.functionRules['/.well-known/workflow/v1/step'] = {
...(runtime && { runtime }),
maxDuration: 'max',
experimentalTriggers: [STEP_QUEUE_TRIGGER],
};

nitro.options.vercel.functionRules['/.well-known/workflow/v1/flow'] = {
...(runtime && { runtime }),
maxDuration: 60,
experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER],
};

if (runtime) {
nitro.options.vercel.functionRules[
'/.well-known/workflow/v1/webhook/**'
] = { runtime };
}
}

addVirtualHandler(
nitro,
'/.well-known/workflow/v1/webhook/:token',
'workflow/webhook.mjs'
);
// Generate workflow bundles (used by virtual handlers below)
const builder = new LocalBuilder(nitro);
let isInitialBuild = true;

addVirtualHandler(
nitro,
'/.well-known/workflow/v1/step',
'workflow/steps.mjs'
);
nitro.hooks.hook('build:before', async () => {
await builder.build();

addVirtualHandler(
nitro,
'/.well-known/workflow/v1/flow',
'workflow/workflows.mjs'
);
// For prod: write the manifest handler file with inlined content
// now that the builder has generated the manifest. Rollup will
// bundle this file into the compiled output.
if (!nitro.options.dev && process.env.WORKFLOW_PUBLIC_MANIFEST === '1') {
writeManifestHandler(nitro);
}
});

// Expose manifest as a public HTTP route when WORKFLOW_PUBLIC_MANIFEST=1
if (process.env.WORKFLOW_PUBLIC_MANIFEST === '1') {
// Write a placeholder manifest-data.mjs so rollup can resolve the
// import. It will be overwritten with the real manifest in build:before.
// Write a placeholder handler file so rollup can resolve the path
// during prod compilation. It will be overwritten with the real
// manifest content by writeManifestHandler() in build:before.
if (!nitro.options.dev) {
const dir = join(nitro.options.buildDir, 'workflow');
mkdirSync(dir, { recursive: true });
const handlerPath = join(dir, 'manifest-handler.mjs');
writeFileSync(
handlerPath,
'export default async () => new Response("Manifest not found", { status: 404 });\n'
);
// Allows for HMR - but skip the first dev:reload since build:before already ran
if (nitro.options.dev) {
nitro.hooks.hook('dev:reload', async () => {
if (isInitialBuild) {
isInitialBuild = false;
return;
}
try {
await builder.build();
} catch (error) {
// During dev, files may be added/removed while the builder
// is rebuilding (e.g., during test cleanup). Log the error
// but don't crash — the next file change will trigger
// another rebuild with the correct file list.
console.warn('Warning: Workflow rebuild failed:', error);
}
addManifestHandler(nitro);
});
}

// Register workflow routes as handlers
addVirtualHandler(
nitro,
'/.well-known/workflow/v1/webhook/:token',
'workflow/webhook.mjs'
);

addVirtualHandler(
nitro,
'/.well-known/workflow/v1/step',
'workflow/steps.mjs'
);

addVirtualHandler(
nitro,
'/.well-known/workflow/v1/flow',
'workflow/workflows.mjs'
);

// Expose manifest as a public HTTP route when WORKFLOW_PUBLIC_MANIFEST=1
if (process.env.WORKFLOW_PUBLIC_MANIFEST === '1') {
// Write a placeholder handler file so rollup can resolve the path
// during prod compilation. It will be overwritten with the real
// manifest content by writeManifestHandler() in build:before.
if (!nitro.options.dev) {
const dir = join(nitro.options.buildDir, 'workflow');
mkdirSync(dir, { recursive: true });
const handlerPath = join(dir, 'manifest-handler.mjs');
writeFileSync(
handlerPath,
'export default async () => new Response("Manifest not found", { status: 404 });\n'
);
}
addManifestHandler(nitro);
}
},
} satisfies NitroModule;
Expand Down Expand Up @@ -285,7 +357,7 @@ function addVirtualHandler(nitro: Nitro, route: string, buildPath: string) {
// step bundle's top-level registrations, so the handler loaded but steps
// were missing at runtime.

if (!nitro.routing) {
if (isNitroV2(nitro)) {
// Nitro v2 (legacy)
nitro.options.virtual[`#${buildPath}`] = /* js */ `
import ${handlerImportPath};
Expand Down Expand Up @@ -324,7 +396,7 @@ function addManifestHandler(nitro: Nitro) {
// Dev mode: use a virtual handler that reads the manifest from disk at
// request time. The absolute path is valid because we're on the build machine.
nitro.options.handlers.push({ route, handler: MANIFEST_VIRTUAL_ID });
nitro.options.virtual[MANIFEST_VIRTUAL_ID] = !nitro.routing
nitro.options.virtual[MANIFEST_VIRTUAL_ID] = isNitroV2(nitro)
? /* js */ `
import { fromWebHandler } from "h3";
import { readFileSync } from "node:fs";
Expand Down Expand Up @@ -379,7 +451,7 @@ function writeManifestHandler(nitro: Nitro) {
const manifestContent = readFileSync(manifestPath, 'utf-8');
JSON.parse(manifestContent); // validate

const handlerCode = !nitro.routing
const handlerCode = isNitroV2(nitro)
? `import { fromWebHandler } from "h3";
const manifest = ${JSON.stringify(manifestContent)};
export default fromWebHandler(() => new Response(manifest, {
Expand All @@ -394,7 +466,7 @@ export default async () => new Response(manifest, {
writeFileSync(handlerPath, handlerCode);
} catch {
// Write a 404 fallback handler
const fallback = !nitro.routing
const fallback = isNitroV2(nitro)
? `import { fromWebHandler } from "h3";
export default fromWebHandler(() => new Response("Manifest not found", { status: 404 }));
`
Expand Down
Loading
Loading