Skip to content
Draft
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
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 @@ -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.
Expand Down Expand Up @@ -358,7 +369,7 @@ export abstract class BaseBuilder {
*/
protected async createStepsBundle({
inputFiles,
format = 'cjs',
format = 'esm',
outfile,
externalizeNonSteps,
rewriteTsExtensions,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -623,7 +635,7 @@ export abstract class BaseBuilder {
*/
protected async createWorkflowsBundle({
inputFiles,
format = 'cjs',
format = 'esm',
outfile,
bundleFinalOutput = true,
keepInterimBundleContext = this.config.watch,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -1117,15 +1127,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 @@ -1188,7 +1197,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: 60,
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)');
}
});
});
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