diff --git a/.changeset/esm-builders.md b/.changeset/esm-builders.md new file mode 100644 index 0000000000..880180aace --- /dev/null +++ b/.changeset/esm-builders.md @@ -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. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 8d172b6eaa..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. @@ -358,7 +369,7 @@ export abstract class BaseBuilder { */ protected async createStepsBundle({ inputFiles, - format = 'cjs', + format = 'esm', outfile, externalizeNonSteps, rewriteTsExtensions, @@ -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, @@ -623,7 +635,7 @@ export abstract class BaseBuilder { */ protected async createWorkflowsBundle({ inputFiles, - format = 'cjs', + format = 'esm', outfile, bundleFinalOutput = true, keepInterimBundleContext = this.config.watch, @@ -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,14 +1111,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, @@ -1117,7 +1127,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 +1135,6 @@ export const OPTIONS = handler;`; treeShaking: true, keepNames: true, minify: false, - define: webhookImportMetaDefine, resolveExtensions: [ '.ts', '.tsx', @@ -1188,7 +1197,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/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/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)'); } }); }); 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) => {