From 27d04d7e61b21f89c72003d5e42a15932bf330e0 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 30 Mar 2026 12:28:01 -0700 Subject: [PATCH 1/2] [builders] Switch Vercel Build Output API from CJS to ESM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step, workflow, and webhook bundles produced by VercelBuildOutputAPIBuilder and the standalone builder now default to ESM format instead of CJS. This preserves native import.meta.url support, fixing issues with libraries like Prisma that rely on it. The intermediate workflow bundle (which runs inside vm.runInContext) remains CJS — the only place where CJS is genuinely required. Closes #1507 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/esm-builders.md | 5 ++++ packages/builders/src/base-builder.ts | 15 ++++-------- .../builders/src/vercel-build-output-api.ts | 20 ++++++++++------ packages/core/e2e/local-build.test.ts | 23 ++++++++----------- 4 files changed, 33 insertions(+), 30 deletions(-) create mode 100644 .changeset/esm-builders.md diff --git a/.changeset/esm-builders.md b/.changeset/esm-builders.md new file mode 100644 index 0000000000..eeee19e8fe --- /dev/null +++ b/.changeset/esm-builders.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": 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. The intermediate workflow bundle (which runs inside `vm.runInContext`) remains CJS as required by the VM execution model. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 8d172b6eaa..c9bc51902a 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -358,7 +358,7 @@ export abstract class BaseBuilder { */ protected async createStepsBundle({ inputFiles, - format = 'cjs', + format = 'esm', outfile, externalizeNonSteps, rewriteTsExtensions, @@ -623,7 +623,7 @@ export abstract class BaseBuilder { */ protected async createWorkflowsBundle({ inputFiles, - format = 'cjs', + format = 'esm', outfile, bundleFinalOutput = true, keepInterimBundleContext = this.config.watch, @@ -1098,14 +1098,10 @@ 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 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', }, stdin: { contents: routeContent, @@ -1117,7 +1113,7 @@ 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', @@ -1125,7 +1121,6 @@ export const OPTIONS = handler;`; treeShaking: true, keepNames: true, minify: false, - define: webhookImportMetaDefine, resolveExtensions: [ '.ts', '.tsx', @@ -1188,7 +1183,7 @@ export const OPTIONS = handler;`; ): Promise { 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, diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index e8dd1768d6..fdee7e2a3f 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -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, @@ -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], @@ -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: 60, experimentalTriggers: [WORKFLOW_QUEUE_TRIGGER], runtime: this.config.runtime, @@ -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, }); diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts index c6498b9324..8620226149 100644 --- a/packages/core/e2e/local-build.test.ts +++ b/packages/core/e2e/local-build.test.ts @@ -76,12 +76,11 @@ async function readFileIfExists(filePath: string): Promise { } /** - * 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 = { +const ESM_STEP_BUNDLE_PROJECTS: Record = { 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([ @@ -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)'); } }); }); From ab40104439fdebb4a92cc6a721da2ee86f7dcd50 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 30 Mar 2026 12:46:15 -0700 Subject: [PATCH 2/2] Fix ESM bundling: add createRequire banner and update standalone paths Fully-bundled ESM output needs a real `require` function for CJS dependencies that call require() on Node.js builtins (e.g. debug requiring tty). Add a createRequire banner to the steps, workflow, and webhook bundles. Also update the CLI standalone config to output .mjs files, and update world-testing to import from the new .mjs paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/esm-builders.md | 3 ++- packages/builders/src/base-builder.ts | 20 ++++++++++++++++--- .../cli/src/lib/config/workflow-config.ts | 6 +++--- packages/world-testing/package.json | 2 +- .../scripts/generate-well-known-dts.mjs | 8 ++++---- packages/world-testing/src/server.mts | 8 ++++---- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/.changeset/esm-builders.md b/.changeset/esm-builders.md index eeee19e8fe..880180aace 100644 --- a/.changeset/esm-builders.md +++ b/.changeset/esm-builders.md @@ -1,5 +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. The intermediate workflow bundle (which runs inside `vm.runInContext`) remains CJS as required by the VM execution model. +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. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index c9bc51902a..873877e93e 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -105,6 +105,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'; + } + /** * Performs the complete build process for workflows. * Subclasses must implement this to define their specific build steps. @@ -496,10 +507,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, @@ -855,9 +867,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, @@ -1098,10 +1111,11 @@ export const OPTIONS = handler;`; // For Build Output API, bundle with esbuild to resolve imports + 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', + js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${webhookEsmRequireBanner}`, }, stdin: { contents: routeContent, diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts index e8e8636515..1ea91cc4cf 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -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 diff --git a/packages/world-testing/package.json b/packages/world-testing/package.json index 85551ac95d..61c4985fcb 100644 --- a/packages/world-testing/package.json +++ b/packages/world-testing/package.json @@ -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" diff --git a/packages/world-testing/scripts/generate-well-known-dts.mjs b/packages/world-testing/scripts/generate-well-known-dts.mjs index 536bd62ba8..4464aadae4 100644 --- a/packages/world-testing/scripts/generate-well-known-dts.mjs +++ b/packages/world-testing/scripts/generate-well-known-dts.mjs @@ -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'; @@ -13,7 +13,7 @@ const stub = 'export declare const POST: (req: Request) => Response | Promise;\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); } diff --git a/packages/world-testing/src/server.mts b/packages/world-testing/src/server.mts index 9f52d29ec8..923b2f80f6 100644 --- a/packages/world-testing/src/server.mts +++ b/packages/world-testing/src/server.mts @@ -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( @@ -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) => {