Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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/esm-builders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workflow/builders": patch
"@workflow/cli": patch
---

Switch Vercel Build Output API and standalone builder output from CJS to ESM. Step bundles, workflow bundles, and webhook bundles now emit ESM format by default, preserving native `import.meta.url` support and eliminating the need for CJS polyfills. Fully-bundled ESM output includes a `createRequire` banner to support CJS dependencies that use `require()` for Node.js builtins. The intermediate workflow bundle (which runs inside `vm.runInContext`) remains CJS as required by the VM execution model.
33 changes: 21 additions & 12 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ export abstract class BaseBuilder {
};
}

/**
* When outputting fully-bundled ESM, CJS dependencies that call require()
* for Node.js builtins (e.g. debug → require('tty')) break because esbuild's
* CJS-to-ESM __require shim doesn't have access to a real require function.
* This banner provides one via createRequire so bundled CJS code works in ESM.
*/
private getEsmRequireBanner(format: string): string {
if (format !== 'esm') return '';
return 'import { createRequire as __createRequire } from "node:module";\nvar require = __createRequire(import.meta.url);\n';
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.

Non-blocking: The var require = __createRequire(import.meta.url) declaration shadows any ambient require in the scope. In ESM, require isn't defined anyway so there's nothing to shadow. But if esbuild's CJS shim emits its own var require = ... later in the bundle, there could be a double declaration. In practice esbuild doesn't do this for format: 'esm' (it uses __require internally), so it's fine. Just noting for awareness.

}

/**
* Performs the complete build process for workflows.
* Subclasses must implement this to define their specific build steps.
Expand Down Expand Up @@ -377,7 +388,7 @@ export abstract class BaseBuilder {
*/
protected async createStepsBundle({
inputFiles,
format = 'cjs',
format = 'esm',
outfile,
externalizeNonSteps,
rewriteTsExtensions,
Expand Down Expand Up @@ -505,10 +516,11 @@ export abstract class BaseBuilder {
await getEsbuildTsconfigOptions(tsconfigPath);
const { banner: importMetaBanner, define: importMetaDefine } =
this.getCjsImportMetaPolyfill(format);
const esmRequireBanner = this.getEsmRequireBanner(format);

const esbuildCtx = await esbuild.context({
banner: {
js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${importMetaBanner}`,
js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${importMetaBanner}${esmRequireBanner}`,
},
stdin: {
contents: entryContent,
Expand Down Expand Up @@ -633,7 +645,7 @@ export abstract class BaseBuilder {
*/
protected async createWorkflowsBundle({
inputFiles,
format = 'cjs',
format = 'esm',
outfile,
bundleFinalOutput = true,
keepInterimBundleContext = this.config.watch,
Expand Down Expand Up @@ -909,9 +921,10 @@ export const POST = workflowEntrypoint(workflowCode);`;

// Now bundle this so we can resolve the @workflow/core dependency
// we could remove this if we do nft tracing or similar instead
const finalEsmRequireBanner = this.getEsmRequireBanner(format);
const finalWorkflowResult = await esbuild.build({
banner: {
js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${finalEsmRequireBanner}`,
},
stdin: {
contents: workflowFunctionCode,
Expand Down Expand Up @@ -1157,14 +1170,11 @@ export const OPTIONS = handler;`;

// For Build Output API, bundle with esbuild to resolve imports

const webhookFormat = 'cjs' as const;
const { banner: webhookImportMetaBanner, define: webhookImportMetaDefine } =
this.getCjsImportMetaPolyfill(webhookFormat);

const webhookEsmRequireBanner = this.getEsmRequireBanner('esm');
const webhookBundleStart = Date.now();
const result = await esbuild.build({
banner: {
js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${webhookImportMetaBanner}`,
js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${webhookEsmRequireBanner}`,
},
stdin: {
contents: routeContent,
Expand All @@ -1176,15 +1186,14 @@ export const OPTIONS = handler;`;
absWorkingDir: this.config.workingDir,
bundle: true,
jsx: 'preserve',
format: webhookFormat,
format: 'esm',
platform: 'node',
conditions: ['import', 'module', 'node', 'default'],
target: 'es2022',
write: true,
treeShaking: true,
keepNames: true,
minify: false,
define: webhookImportMetaDefine,
resolveExtensions: [
'.ts',
'.tsx',
Expand Down Expand Up @@ -1247,7 +1256,7 @@ export const OPTIONS = handler;`;
): Promise<void> {
const vcConfig = {
runtime: config.runtime ?? 'nodejs22.x',
handler: config.handler ?? 'index.js',
handler: config.handler ?? 'index.mjs',
launcherType: config.launcherType ?? 'Nodejs',
architecture: config.architecture ?? 'arm64',
shouldAddHelpers: config.shouldAddHelpers ?? true,
Expand Down
20 changes: 13 additions & 7 deletions packages/builders/src/vercel-build-output-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
};

// Generate unified manifest
const workflowBundlePath = join(workflowGeneratedDir, 'flow.func/index.js');
const workflowBundlePath = join(
workflowGeneratedDir,
'flow.func/index.mjs'
);
const manifestJson = await this.createManifest({
workflowBundlePath,
manifestDir: workflowGeneratedDir,
Expand Down Expand Up @@ -72,13 +75,14 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
// Create steps bundle
const { manifest } = await this.createStepsBundle({
inputFiles,
outfile: join(stepsFuncDir, 'index.js'),
outfile: join(stepsFuncDir, 'index.mjs'),
tsconfigPath,
});

// Create package.json and .vc-config.json for steps function
await this.createPackageJson(stepsFuncDir, 'commonjs');
await this.createPackageJson(stepsFuncDir, 'module');
await this.createVcConfig(stepsFuncDir, {
handler: 'index.mjs',
shouldAddSourcemapSupport: true,
maxDuration: 'max',
experimentalTriggers: [STEP_QUEUE_TRIGGER],
Expand All @@ -102,14 +106,15 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
await mkdir(workflowsFuncDir, { recursive: true });

const { manifest } = await this.createWorkflowsBundle({
outfile: join(workflowsFuncDir, 'index.js'),
outfile: join(workflowsFuncDir, 'index.mjs'),
inputFiles,
tsconfigPath,
});

// Create package.json and .vc-config.json for workflows function
await this.createPackageJson(workflowsFuncDir, 'commonjs');
await this.createPackageJson(workflowsFuncDir, 'module');
await this.createVcConfig(workflowsFuncDir, {
handler: 'index.mjs',
maxDuration: 'max',
experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER],
runtime: this.config.runtime,
Expand All @@ -130,13 +135,14 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {

// Bundle the webhook route with dependencies resolved
await this.createWebhookBundle({
outfile: join(webhookFuncDir, 'index.js'),
outfile: join(webhookFuncDir, 'index.mjs'),
bundle, // Build Output API needs bundling (except in tests)
});

// Create package.json and .vc-config.json for webhook function
await this.createPackageJson(webhookFuncDir, 'commonjs');
await this.createPackageJson(webhookFuncDir, 'module');
await this.createVcConfig(webhookFuncDir, {
handler: 'index.mjs',
shouldAddHelpers: false,
runtime: this.config.runtime,
});
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/lib/config/workflow-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export const getWorkflowConfig = (
dirs: ['./workflows'],
workingDir: resolveObservabilityCwd(),
buildTarget: buildTarget as BuildTarget,
stepsBundlePath: './.well-known/workflow/v1/step.js',
workflowsBundlePath: './.well-known/workflow/v1/flow.js',
webhookBundlePath: './.well-known/workflow/v1/webhook.js',
stepsBundlePath: './.well-known/workflow/v1/step.mjs',
workflowsBundlePath: './.well-known/workflow/v1/flow.mjs',
webhookBundlePath: './.well-known/workflow/v1/webhook.mjs',
workflowManifestPath: workflowManifest,

// WIP: generate a client library to easily execute workflows/steps
Expand Down
23 changes: 10 additions & 13 deletions packages/core/e2e/local-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,11 @@ async function readFileIfExists(filePath: string): Promise<string | null> {
}

/**
* Projects that use the VercelBuildOutputAPIBuilder and produce CJS step bundles.
* Their step bundles should contain the import.meta.url CJS polyfill.
* Projects that use the VercelBuildOutputAPIBuilder and produce ESM step bundles.
*/
const CJS_STEP_BUNDLE_PROJECTS: Record<string, string> = {
const ESM_STEP_BUNDLE_PROJECTS: Record<string, string> = {
example:
'.vercel/output/functions/.well-known/workflow/v1/step.func/index.js',
'.vercel/output/functions/.well-known/workflow/v1/step.func/index.mjs',
};

describe.each([
Expand Down Expand Up @@ -120,18 +119,16 @@ describe.each([
await fs.access(diagnosticsManifestPath);
}

// Verify CJS import.meta polyfill is present in CJS step bundles
const cjsBundlePath = CJS_STEP_BUNDLE_PROJECTS[project];
if (cjsBundlePath) {
// Verify ESM step bundles use native import.meta (no CJS polyfill needed)
const esmBundlePath = ESM_STEP_BUNDLE_PROJECTS[project];
if (esmBundlePath) {
const bundleContent = await readFileIfExists(
path.join(getWorkbenchAppPath(project), cjsBundlePath)
path.join(getWorkbenchAppPath(project), esmBundlePath)
);
expect(bundleContent).not.toBeNull();
expect(bundleContent).toContain('var __import_meta_url');
expect(bundleContent).toContain('pathToFileURL(__filename)');
expect(bundleContent).toContain('var __import_meta_resolve');
// Raw import.meta.url should be replaced by the define
expect(bundleContent).not.toMatch(/\bimport\.meta\.url\b/);
// ESM output should NOT contain CJS polyfill
expect(bundleContent).not.toContain('var __import_meta_url');
expect(bundleContent).not.toContain('pathToFileURL(__filename)');
}
});
});
5 changes: 4 additions & 1 deletion packages/core/src/runtime/suspension-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ export async function handleSuspension({
: ((await dehydrateStepArguments(
queueItem.metadata,
runId,
encryptionKey,
// Don't encrypt hook metadata — the webhook handler reads it
// via getHookByTokenWithKey and may not have the deployment
// encryption key available (e.g. standalone webhook Lambda).
undefined,
suspension.globalThis
)) as SerializedData);
return {
Expand Down
2 changes: 1 addition & 1 deletion packages/world-testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"directory": "packages/world-testing"
},
"scripts": {
"build": "wf build && node scripts/generate-well-known-dts.mjs && tsc && cp .well-known/workflow/v1/*.js dist/.well-known/workflow/v1/",
"build": "wf build && node scripts/generate-well-known-dts.mjs && tsc && cp .well-known/workflow/v1/*.mjs dist/.well-known/workflow/v1/",
"clean": "tsc --build --clean && rm -rf dist .well-known* .workflow-data",
"start": "node --watch src/server.mts",
"test": "vitest"
Expand Down
8 changes: 4 additions & 4 deletions packages/world-testing/scripts/generate-well-known-dts.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Generate .d.ts stubs for esbuild-bundled workflow entry points.
*
* The bundled .js files may contain code (e.g., undici private fields)
* that TypeScript's JS parser cannot handle. Placing .d.ts files next
* to the .js files makes TypeScript use the declarations instead of
* The bundled .mjs files may contain code (e.g., undici private fields)
* that TypeScript's JS parser cannot handle. Placing .d.mts files next
* to the .mjs files makes TypeScript use the declarations instead of
* parsing the bundled JavaScript.
*/
import { existsSync, writeFileSync } from 'node:fs';
Expand All @@ -13,7 +13,7 @@ const stub =
'export declare const POST: (req: Request) => Response | Promise<Response>;\n';

for (const name of ['flow', 'step', 'webhook']) {
const dts = `${dir}/${name}.d.ts`;
const dts = `${dir}/${name}.d.mts`;
if (!existsSync(dts)) {
writeFileSync(dts, stub);
}
Expand Down
8 changes: 4 additions & 4 deletions packages/world-testing/src/server.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { Hono } from 'hono';
import { getHookByToken, getRun, resumeHook, start } from 'workflow/api';
import { getWorld } from 'workflow/runtime';
import * as z from 'zod';
import flow from '../.well-known/workflow/v1/flow.js';
import { POST as flowPOST } from '../.well-known/workflow/v1/flow.mjs';
import manifest from '../.well-known/workflow/v1/manifest.json' with {
type: 'json',
};
import step from '../.well-known/workflow/v1/step.js';
import { POST as stepPOST } from '../.well-known/workflow/v1/step.mjs';

if (!process.env.WORKFLOW_TARGET_WORLD) {
console.error(
Expand Down Expand Up @@ -44,10 +44,10 @@ const Invoke = z

const app = new Hono()
.post('/.well-known/workflow/v1/flow', (ctx) => {
return flow.POST(ctx.req.raw);
return flowPOST(ctx.req.raw);
})
.post('/.well-known/workflow/v1/step', (ctx) => {
return step.POST(ctx.req.raw);
return stepPOST(ctx.req.raw);
})
.get('/_manifest', (ctx) => ctx.json(manifest))
.post('/invoke', async (ctx) => {
Expand Down
Loading