@workflow/builders 4.0.1-beta.9 → 5.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/dist/apply-swc-transform.d.ts +19 -4
  3. package/dist/apply-swc-transform.d.ts.map +1 -1
  4. package/dist/apply-swc-transform.js +62 -7
  5. package/dist/apply-swc-transform.js.map +1 -1
  6. package/dist/base-builder.d.ts +77 -22
  7. package/dist/base-builder.d.ts.map +1 -1
  8. package/dist/base-builder.js +667 -202
  9. package/dist/base-builder.js.map +1 -1
  10. package/dist/build-queue.d.ts +18 -0
  11. package/dist/build-queue.d.ts.map +1 -0
  12. package/dist/build-queue.js +26 -0
  13. package/dist/build-queue.js.map +1 -0
  14. package/dist/config-helpers.d.ts +32 -0
  15. package/dist/config-helpers.d.ts.map +1 -1
  16. package/dist/config-helpers.js +61 -0
  17. package/dist/config-helpers.js.map +1 -1
  18. package/dist/constants.d.ts +2 -4
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +2 -4
  21. package/dist/constants.js.map +1 -1
  22. package/dist/discover-entries-esbuild-plugin.d.ts +5 -6
  23. package/dist/discover-entries-esbuild-plugin.d.ts.map +1 -1
  24. package/dist/discover-entries-esbuild-plugin.js +98 -30
  25. package/dist/discover-entries-esbuild-plugin.js.map +1 -1
  26. package/dist/discover-entries-esbuild-plugin.test.d.ts +2 -0
  27. package/dist/discover-entries-esbuild-plugin.test.d.ts.map +1 -0
  28. package/dist/discover-entries-esbuild-plugin.test.js +110 -0
  29. package/dist/discover-entries-esbuild-plugin.test.js.map +1 -0
  30. package/dist/esbuild-tsconfig.d.ts +13 -0
  31. package/dist/esbuild-tsconfig.d.ts.map +1 -0
  32. package/dist/esbuild-tsconfig.js +25 -0
  33. package/dist/esbuild-tsconfig.js.map +1 -0
  34. package/dist/esbuild-tsconfig.test.d.ts +2 -0
  35. package/dist/esbuild-tsconfig.test.d.ts.map +1 -0
  36. package/dist/esbuild-tsconfig.test.js +94 -0
  37. package/dist/esbuild-tsconfig.test.js.map +1 -0
  38. package/dist/get-input-files.test.d.ts +2 -0
  39. package/dist/get-input-files.test.d.ts.map +1 -0
  40. package/dist/get-input-files.test.js +121 -0
  41. package/dist/get-input-files.test.js.map +1 -0
  42. package/dist/index.d.ts +10 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +9 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/module-specifier.d.ts +85 -0
  47. package/dist/module-specifier.d.ts.map +1 -0
  48. package/dist/module-specifier.js +448 -0
  49. package/dist/module-specifier.js.map +1 -0
  50. package/dist/module-specifier.test.d.ts +2 -0
  51. package/dist/module-specifier.test.d.ts.map +1 -0
  52. package/dist/module-specifier.test.js +240 -0
  53. package/dist/module-specifier.test.js.map +1 -0
  54. package/dist/node-module-esbuild-plugin.d.ts +37 -0
  55. package/dist/node-module-esbuild-plugin.d.ts.map +1 -1
  56. package/dist/node-module-esbuild-plugin.js +364 -7
  57. package/dist/node-module-esbuild-plugin.js.map +1 -1
  58. package/dist/node-module-esbuild-plugin.test.js +461 -146
  59. package/dist/node-module-esbuild-plugin.test.js.map +1 -1
  60. package/dist/pseudo-package-esbuild-plugin.d.ts +20 -0
  61. package/dist/pseudo-package-esbuild-plugin.d.ts.map +1 -0
  62. package/dist/pseudo-package-esbuild-plugin.js +47 -0
  63. package/dist/pseudo-package-esbuild-plugin.js.map +1 -0
  64. package/dist/pseudo-package-esbuild-plugin.test.d.ts +2 -0
  65. package/dist/pseudo-package-esbuild-plugin.test.d.ts.map +1 -0
  66. package/dist/pseudo-package-esbuild-plugin.test.js +315 -0
  67. package/dist/pseudo-package-esbuild-plugin.test.js.map +1 -0
  68. package/dist/request-converter.d.ts +3 -0
  69. package/dist/request-converter.d.ts.map +1 -0
  70. package/dist/request-converter.js +14 -0
  71. package/dist/request-converter.js.map +1 -0
  72. package/dist/serde-checker.d.ts +70 -0
  73. package/dist/serde-checker.d.ts.map +1 -0
  74. package/dist/serde-checker.js +118 -0
  75. package/dist/serde-checker.js.map +1 -0
  76. package/dist/standalone.d.ts +3 -0
  77. package/dist/standalone.d.ts.map +1 -1
  78. package/dist/standalone.js +37 -13
  79. package/dist/standalone.js.map +1 -1
  80. package/dist/swc-esbuild-plugin.d.ts +23 -2
  81. package/dist/swc-esbuild-plugin.d.ts.map +1 -1
  82. package/dist/swc-esbuild-plugin.js +133 -43
  83. package/dist/swc-esbuild-plugin.js.map +1 -1
  84. package/dist/swc-esbuild-plugin.test.d.ts +2 -0
  85. package/dist/swc-esbuild-plugin.test.d.ts.map +1 -0
  86. package/dist/swc-esbuild-plugin.test.js +493 -0
  87. package/dist/swc-esbuild-plugin.test.js.map +1 -0
  88. package/dist/transform-utils.d.ts +68 -0
  89. package/dist/transform-utils.d.ts.map +1 -0
  90. package/dist/transform-utils.js +102 -0
  91. package/dist/transform-utils.js.map +1 -0
  92. package/dist/transform-utils.test.d.ts +2 -0
  93. package/dist/transform-utils.test.d.ts.map +1 -0
  94. package/dist/transform-utils.test.js +302 -0
  95. package/dist/transform-utils.test.js.map +1 -0
  96. package/dist/types.d.ts +31 -2
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/types.js +2 -0
  99. package/dist/types.js.map +1 -1
  100. package/dist/vercel-build-output-api.d.ts.map +1 -1
  101. package/dist/vercel-build-output-api.js +38 -14
  102. package/dist/vercel-build-output-api.js.map +1 -1
  103. package/dist/workflow-alias.d.ts +3 -0
  104. package/dist/workflow-alias.d.ts.map +1 -0
  105. package/dist/workflow-alias.js +65 -0
  106. package/dist/workflow-alias.js.map +1 -0
  107. package/dist/workflow-alias.test.d.ts +2 -0
  108. package/dist/workflow-alias.test.d.ts.map +1 -0
  109. package/dist/workflow-alias.test.js +53 -0
  110. package/dist/workflow-alias.test.js.map +1 -0
  111. package/dist/workflows-extractor.d.ts +92 -0
  112. package/dist/workflows-extractor.d.ts.map +1 -0
  113. package/dist/workflows-extractor.js +1476 -0
  114. package/dist/workflows-extractor.js.map +1 -0
  115. package/package.json +14 -13
@@ -1,17 +1,33 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import { dirname, join, relative, resolve } from 'node:path';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join, relative, resolve } from 'node:path';
3
4
  import { promisify } from 'node:util';
5
+ import { pluralize, usesVercelWorld } from '@workflow/utils';
4
6
  import chalk from 'chalk';
5
- import { parse } from 'comment-json';
6
7
  import enhancedResolveOriginal from 'enhanced-resolve';
7
8
  import * as esbuild from 'esbuild';
8
9
  import { findUp } from 'find-up';
9
10
  import { glob } from 'tinyglobby';
11
+ import { applySwcTransform, } from './apply-swc-transform.js';
10
12
  import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
13
+ import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
14
+ import { getImportPath } from './module-specifier.js';
11
15
  import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
16
+ import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
12
17
  import { createSwcPlugin } from './swc-esbuild-plugin.js';
18
+ import { extractWorkflowGraphs } from './workflows-extractor.js';
13
19
  const enhancedResolve = promisify(enhancedResolveOriginal);
14
20
  const EMIT_SOURCEMAPS_FOR_DEBUGGING = process.env.WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING === '1';
21
+ /**
22
+ * Normalize an array of file paths by appending the `realpath()` of each entry
23
+ * (to handle symlinks, e.g. pnpm/workspace layouts) and deduplicating.
24
+ */
25
+ async function withRealpaths(entries) {
26
+ return Array.from(new Set((await Promise.all(entries.map(async (entry) => {
27
+ const resolved = await realpath(entry).catch(() => undefined);
28
+ return resolved ? [entry, resolved] : [entry];
29
+ }))).flat()));
30
+ }
15
31
  /**
16
32
  * Base class for workflow builders. Provides common build logic for transforming
17
33
  * workflow source files into deployable bundles using esbuild and SWC.
@@ -23,35 +39,63 @@ export class BaseBuilder {
23
39
  constructor(config) {
24
40
  this.config = config;
25
41
  }
42
+ get transformProjectRoot() {
43
+ return this.config.projectRoot || this.config.workingDir;
44
+ }
26
45
  /**
27
- * Extracts TypeScript path mappings and baseUrl from tsconfig.json/jsconfig.json.
28
- * Used to properly resolve module imports during bundling.
46
+ * Whether informational BaseBuilder logs should be printed.
47
+ * Subclasses can override this to silence progress logs while keeping warnings/errors.
29
48
  */
30
- async getTsConfigOptions() {
31
- const options = {};
32
- const cwd = this.config.workingDir || process.cwd();
33
- const tsJsConfig = await findUp(['tsconfig.json', 'jsconfig.json'], {
34
- cwd,
35
- });
36
- if (tsJsConfig) {
37
- try {
38
- const rawJson = await readFile(tsJsConfig, 'utf8');
39
- const parsed = parse(rawJson);
40
- if (parsed) {
41
- options.paths = parsed.compilerOptions?.paths;
42
- if (parsed.compilerOptions?.baseUrl) {
43
- options.baseUrl = resolve(cwd, parsed.compilerOptions.baseUrl);
44
- }
45
- else {
46
- options.baseUrl = cwd;
47
- }
48
- }
49
- }
50
- catch (err) {
51
- console.error(`Failed to parse ${tsJsConfig} aliases might not apply properly`, err);
52
- }
49
+ get shouldLogBaseBuilderInfo() {
50
+ return true;
51
+ }
52
+ logBaseBuilderInfo(...args) {
53
+ if (this.shouldLogBaseBuilderInfo) {
54
+ console.log(...args);
55
+ }
56
+ }
57
+ logCreateWorkflowsBundleInfo(...args) {
58
+ if (!this.config.suppressCreateWorkflowsBundleLogs) {
59
+ this.logBaseBuilderInfo(...args);
60
+ }
61
+ }
62
+ logCreateWebhookBundleInfo(...args) {
63
+ if (!this.config.suppressCreateWebhookBundleLogs) {
64
+ this.logBaseBuilderInfo(...args);
65
+ }
66
+ }
67
+ logCreateManifestInfo(...args) {
68
+ if (!this.config.suppressCreateManifestLogs) {
69
+ this.logBaseBuilderInfo(...args);
53
70
  }
54
- return options;
71
+ }
72
+ /**
73
+ * When outputting CJS, esbuild replaces `import.meta` with an empty object,
74
+ * making `import.meta.url` (and `import.meta.resolve`) undefined. This method
75
+ * returns banner code and `define` entries that polyfill them using CJS
76
+ * equivalents (`__filename`, `require.resolve`) so user code (e.g. Prisma)
77
+ * that relies on `import.meta.url` works correctly in bundled CJS output.
78
+ */
79
+ getCjsImportMetaPolyfill(format) {
80
+ if (format !== 'cjs')
81
+ return { banner: '', define: {} };
82
+ return {
83
+ banner: 'var __import_meta_url = typeof __filename !== "undefined" ? require("url").pathToFileURL(__filename).href : undefined;\n' +
84
+ 'var __import_meta_resolve = typeof require !== "undefined" && typeof __filename !== "undefined" ' +
85
+ '? (s) => require("url").pathToFileURL(require.resolve(s)).href : undefined;\n',
86
+ define: {
87
+ 'import.meta.url': '__import_meta_url',
88
+ 'import.meta.resolve': '__import_meta_resolve',
89
+ },
90
+ };
91
+ }
92
+ /**
93
+ * Finds tsconfig.json/jsconfig.json for the project.
94
+ * Used by esbuild to properly resolve module imports during bundling.
95
+ */
96
+ async findTsConfigPath() {
97
+ const cwd = this.config.workingDir || process.cwd();
98
+ return findUp(['tsconfig.json', 'jsconfig.json'], { cwd });
55
99
  }
56
100
  /**
57
101
  * Discovers all source files in the configured directories.
@@ -59,24 +103,36 @@ export class BaseBuilder {
59
103
  * and dependency directories.
60
104
  */
61
105
  async getInputFiles() {
62
- const patterns = this.config.dirs.map((dir) => {
63
- const resolvedDir = resolve(this.config.workingDir, dir);
64
- // Normalize path separators to forward slashes for glob compatibility
65
- const normalizedDir = resolvedDir.replace(/\\/g, '/');
66
- return `${normalizedDir}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`;
67
- });
68
- const result = await glob(patterns, {
69
- ignore: [
70
- '**/node_modules/**',
71
- '**/.git/**',
72
- '**/.next/**',
73
- '**/.vercel/**',
74
- '**/.workflow-data/**',
75
- '**/.well-known/workflow/**',
76
- ],
77
- absolute: true,
78
- });
79
- return result;
106
+ const ignore = [
107
+ '**/node_modules/**',
108
+ '**/.git/**',
109
+ '**/.next/**',
110
+ '**/.nuxt/**',
111
+ '**/.output/**',
112
+ '**/.vercel/**',
113
+ '**/.workflow-data/**',
114
+ '**/.workflow-vitest/**',
115
+ '**/.well-known/workflow/**',
116
+ '**/.svelte-kit/**',
117
+ '**/.turbo/**',
118
+ '**/.cache/**',
119
+ '**/.yarn/**',
120
+ '**/.pnpm-store/**',
121
+ ];
122
+ // Use relative patterns with `cwd` per directory so that `dot: true`
123
+ // applies consistently to both the search pattern *and* the ignore
124
+ // patterns. When absolute patterns are used with tinyglobby, the `**`
125
+ // in ignore patterns does not match dot-prefixed path segments.
126
+ const results = await Promise.all(this.config.dirs.map((dir) => {
127
+ const cwd = resolve(this.config.workingDir, dir);
128
+ return glob(['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], {
129
+ cwd,
130
+ ignore,
131
+ absolute: true,
132
+ dot: true,
133
+ });
134
+ }));
135
+ return results.flat();
80
136
  }
81
137
  /**
82
138
  * Caches discovered workflow entries by input array reference.
@@ -85,49 +141,76 @@ export class BaseBuilder {
85
141
  * (e.g., when files are added/removed during watch mode).
86
142
  */
87
143
  discoveredEntries = new WeakMap();
88
- async discoverEntries(inputs, outdir) {
144
+ async discoverEntries(inputs, outdir, tsconfigPath) {
89
145
  const previousResult = this.discoveredEntries.get(inputs);
90
146
  if (previousResult) {
91
147
  return previousResult;
92
148
  }
93
149
  const state = {
94
- discoveredSteps: [],
95
- discoveredWorkflows: [],
150
+ discoveredSteps: new Set(),
151
+ discoveredWorkflows: new Set(),
152
+ discoveredSerdeFiles: new Set(),
96
153
  };
97
154
  const discoverStart = Date.now();
155
+ const effectiveTsconfigPath = tsconfigPath ?? (await this.findTsConfigPath());
156
+ const esbuildTsconfigOptions = await getEsbuildTsconfigOptions(effectiveTsconfigPath);
98
157
  try {
99
158
  await esbuild.build({
100
159
  treeShaking: true,
101
160
  entryPoints: inputs,
102
- plugins: [createDiscoverEntriesPlugin(state)],
161
+ plugins: [
162
+ createDiscoverEntriesPlugin(state, this.transformProjectRoot),
163
+ ],
103
164
  platform: 'node',
104
165
  write: false,
105
166
  outdir,
106
167
  bundle: true,
107
- sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING,
168
+ sourcemap: false,
108
169
  absWorkingDir: this.config.workingDir,
109
170
  logLevel: 'silent',
171
+ ...esbuildTsconfigOptions,
172
+ // External packages that should not be bundled during discovery
173
+ external: this.config.externalPackages || [],
110
174
  });
111
175
  }
112
176
  catch (_) { }
113
- console.log(`Discovering workflow directives`, `${Date.now() - discoverStart}ms`);
177
+ this.logBaseBuilderInfo(`Discovering workflow directives`, `${Date.now() - discoverStart}ms`);
114
178
  this.discoveredEntries.set(inputs, state);
115
179
  return state;
116
180
  }
117
181
  /**
118
182
  * Writes debug information to a JSON file for troubleshooting build issues.
119
- * Executes whenever called, regardless of environment variables.
183
+ * Uses atomic write (temp file + rename) to prevent race conditions when
184
+ * multiple builds run concurrently.
120
185
  */
121
186
  async writeDebugFile(outfile, debugData, merge) {
187
+ const prefix = this.config.debugFilePrefix || '';
188
+ const targetPath = `${dirname(outfile)}/${prefix}${basename(outfile)}.debug.json`;
189
+ let existing = {};
122
190
  try {
123
- let existing = {};
124
191
  if (merge) {
125
- existing = JSON.parse(await readFile(`${outfile}.debug.json`, 'utf8').catch(() => '{}'));
192
+ try {
193
+ const content = await readFile(targetPath, 'utf8');
194
+ existing = JSON.parse(content);
195
+ }
196
+ catch (e) {
197
+ // File doesn't exist yet or is corrupted - start fresh.
198
+ // Don't log error for ENOENT (file not found) as that's expected on first run.
199
+ if (e.code !== 'ENOENT') {
200
+ console.warn('Error reading debug file, starting fresh:', e);
201
+ }
202
+ }
126
203
  }
127
- await writeFile(`${outfile}.debug.json`, JSON.stringify({
204
+ const mergedData = JSON.stringify({
128
205
  ...existing,
129
206
  ...debugData,
130
- }, null, 2));
207
+ }, null, 2);
208
+ // Write atomically: write to temp file, then rename.
209
+ // rename() is atomic on POSIX systems and provides best-effort atomicity on Windows.
210
+ // Prevents race conditions where concurrent builds read partially-written files.
211
+ const tempPath = `${targetPath}.${randomUUID()}.tmp`;
212
+ await writeFile(tempPath, mergedData);
213
+ await rename(tempPath, targetPath);
131
214
  }
132
215
  catch (error) {
133
216
  console.warn('Failed to write debug file:', error);
@@ -137,7 +220,7 @@ export class BaseBuilder {
137
220
  * Logs and optionally throws on esbuild errors and warnings.
138
221
  * @param throwOnError - If true, throws an error when esbuild errors are present
139
222
  */
140
- logEsbuildMessages(result, phase, throwOnError = true) {
223
+ logEsbuildMessages(result, phase, throwOnError = true, options) {
141
224
  if (result.errors && result.errors.length > 0) {
142
225
  console.error(`❌ esbuild errors in ${phase}:`);
143
226
  const errorMessages = [];
@@ -154,7 +237,9 @@ export class BaseBuilder {
154
237
  throw new Error(`Build failed during ${phase}:\n${errorMessages.join('\n')}`);
155
238
  }
156
239
  }
157
- if (result.warnings && result.warnings.length > 0) {
240
+ if (!options?.suppressWarnings &&
241
+ result.warnings &&
242
+ result.warnings.length > 0) {
158
243
  console.warn(`! esbuild warnings in ${phase}:`);
159
244
  for (const warning of result.warnings) {
160
245
  console.warn(` ${warning.text}`);
@@ -164,18 +249,48 @@ export class BaseBuilder {
164
249
  }
165
250
  }
166
251
  }
252
+ /**
253
+ * Converts an absolute file path to a normalized relative path for the manifest.
254
+ */
255
+ getRelativeFilepath(absolutePath) {
256
+ const normalizedFile = absolutePath.replace(/\\/g, '/');
257
+ const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
258
+ let relativePath = relative(normalizedWorkingDir, normalizedFile).replace(/\\/g, '/');
259
+ // Handle files discovered outside the working directory
260
+ if (relativePath.startsWith('../')) {
261
+ relativePath = relativePath
262
+ .split('/')
263
+ .filter((part) => part !== '..')
264
+ .join('/');
265
+ }
266
+ return relativePath;
267
+ }
167
268
  /**
168
269
  * Creates a bundle for workflow step functions.
169
270
  * Steps have full Node.js runtime access and handle side effects, API calls, etc.
170
271
  *
171
272
  * @param externalizeNonSteps - If true, only bundles step entry points and externalizes other code
273
+ * @returns Build context (for watch mode) and the collected workflow manifest
172
274
  */
173
- async createStepsBundle({ inputFiles, format = 'cjs', outfile, externalizeNonSteps, tsBaseUrl, tsPaths, }) {
275
+ async createStepsBundle({ inputFiles, format = 'cjs', outfile, externalizeNonSteps, rewriteTsExtensions, tsconfigPath, discoveredEntries, }) {
174
276
  // These need to handle watching for dev to scan for
175
277
  // new entries and changes to existing ones
176
- const { discoveredSteps: stepFiles } = await this.discoverEntries(inputFiles, dirname(outfile));
278
+ const discovered = discoveredEntries ??
279
+ (await this.discoverEntries(inputFiles, dirname(outfile), tsconfigPath));
280
+ const stepFiles = [...discovered.discoveredSteps].sort();
281
+ const workflowFiles = [...discovered.discoveredWorkflows].sort();
282
+ const serdeFiles = [...discovered.discoveredSerdeFiles].sort();
283
+ // Include serde files that aren't already step files for cross-context class registration.
284
+ // Classes need to be registered in the step bundle so they can be deserialized
285
+ // when receiving data from workflows and serialized when returning data to workflows.
286
+ const stepFilesSet = new Set(stepFiles);
287
+ const serdeOnlyFiles = serdeFiles.filter((f) => !stepFilesSet.has(f));
177
288
  // log the step files for debugging
178
- await this.writeDebugFile(outfile, { stepFiles });
289
+ await this.writeDebugFile(outfile, {
290
+ stepFiles,
291
+ workflowFiles,
292
+ serdeOnlyFiles,
293
+ });
179
294
  const stepsBundleStart = Date.now();
180
295
  const workflowManifest = {};
181
296
  const builtInSteps = 'workflow/internal/builtins';
@@ -187,34 +302,65 @@ export class BaseBuilder {
187
302
  `Caused by: ${chalk.red(String(err))}`,
188
303
  ].join('\n'));
189
304
  });
190
- // Create a virtual entry that imports all files. All step definitions
191
- // will get registered thanks to the swc transform.
192
- const imports = stepFiles
193
- .map((file) => {
305
+ // Helper to create import statement from file path
306
+ // For workspace/node_modules packages, uses the package name so esbuild
307
+ // will resolve through package.json exports with the appropriate conditions
308
+ const createImport = (file) => {
309
+ const { importPath, isPackage } = getImportPath(file, this.config.workingDir);
310
+ if (isPackage) {
311
+ // Use package name - esbuild will resolve via package.json exports
312
+ return `import '${importPath}';`;
313
+ }
314
+ // Local app file - use relative path
194
315
  // Normalize both paths to forward slashes before calling relative()
195
316
  // This is critical on Windows where relative() can produce unexpected results with mixed path formats
196
317
  const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
197
318
  const normalizedFile = file.replace(/\\/g, '/');
198
319
  // Calculate relative path from working directory to the file
199
320
  let relativePath = relative(normalizedWorkingDir, normalizedFile).replace(/\\/g, '/');
200
- // Ensure relative paths start with ./ so esbuild resolves them correctly
201
- if (!relativePath.startsWith('.')) {
321
+ // Ensure relative paths start with ./ so esbuild resolves them correctly.
322
+ // Paths like ".output/..." are not valid relative specifiers and must
323
+ // become "./.output/...".
324
+ if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) {
202
325
  relativePath = `./${relativePath}`;
203
326
  }
204
327
  return `import '${relativePath}';`;
205
- })
206
- .join('\n');
328
+ };
329
+ // Create a virtual entry that imports all files. All step definitions
330
+ // will get registered thanks to the swc transform.
331
+ const stepImports = stepFiles.map(createImport).join('\n');
332
+ // Include serde-only files for class registration side effects
333
+ const serdeImports = serdeOnlyFiles.map(createImport).join('\n');
207
334
  const entryContent = `
208
335
  // Built in steps
209
336
  import '${builtInSteps}';
210
337
  // User steps
211
- ${imports}
338
+ ${stepImports}
339
+ // Serde files for cross-context class registration
340
+ ${serdeImports}
212
341
  // API entrypoint
213
342
  export { stepEntrypoint as POST } from 'workflow/runtime';`;
214
343
  // Bundle with esbuild and our custom SWC plugin
344
+ const entriesToBundle = externalizeNonSteps
345
+ ? [
346
+ ...stepFiles,
347
+ ...serdeFiles,
348
+ ...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []),
349
+ ]
350
+ : undefined;
351
+ const normalizedEntriesToBundle = entriesToBundle
352
+ ? await withRealpaths(entriesToBundle)
353
+ : undefined;
354
+ const normalizedSideEffectEntries = await withRealpaths([
355
+ ...stepFiles,
356
+ ...serdeOnlyFiles,
357
+ ...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []),
358
+ ]);
359
+ const esbuildTsconfigOptions = await getEsbuildTsconfigOptions(tsconfigPath);
360
+ const { banner: importMetaBanner, define: importMetaDefine } = this.getCjsImportMetaPolyfill(format);
215
361
  const esbuildCtx = await esbuild.context({
216
362
  banner: {
217
- js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
363
+ js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${importMetaBanner}`,
218
364
  },
219
365
  stdin: {
220
366
  contents: entryContent,
@@ -233,42 +379,76 @@ export class BaseBuilder {
233
379
  treeShaking: true,
234
380
  keepNames: true,
235
381
  minify: false,
236
- resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
237
- // TODO: investigate proper source map support
238
- sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING,
382
+ jsx: 'preserve',
383
+ logLevel: 'error',
384
+ // Use tsconfig for path alias resolution.
385
+ // For symlinked configs this uses tsconfigRaw to preserve cwd-relative aliases.
386
+ ...esbuildTsconfigOptions,
387
+ define: importMetaDefine,
388
+ resolveExtensions: [
389
+ '.ts',
390
+ '.tsx',
391
+ '.mts',
392
+ '.cts',
393
+ '.js',
394
+ '.jsx',
395
+ '.mjs',
396
+ '.cjs',
397
+ ],
398
+ // Inline source maps for better stack traces in step execution.
399
+ // Steps execute in Node.js context and inline sourcemaps ensure we get
400
+ // meaningful stack traces with proper file names and line numbers when errors
401
+ // occur in deeply nested function calls across multiple files.
402
+ sourcemap: 'inline',
239
403
  plugins: [
404
+ // Handle pseudo-packages like 'server-only' and 'client-only' by providing
405
+ // empty modules. Must run first to intercept these before other resolution.
406
+ createPseudoPackagePlugin(),
240
407
  createSwcPlugin({
241
408
  mode: 'step',
242
- entriesToBundle: externalizeNonSteps
243
- ? [
244
- ...stepFiles,
245
- ...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []),
246
- ]
247
- : undefined,
409
+ entriesToBundle: normalizedEntriesToBundle,
248
410
  outdir: outfile ? dirname(outfile) : undefined,
249
- tsBaseUrl,
250
- tsPaths,
411
+ projectRoot: this.transformProjectRoot,
251
412
  workflowManifest,
413
+ rewriteTsExtensions,
414
+ sideEffectEntries: normalizedSideEffectEntries,
252
415
  }),
253
416
  ],
254
417
  // Plugin should catch most things, but this lets users hard override
255
418
  // if the plugin misses anything that should be externalized
256
- external: this.config.externalPackages || [],
419
+ external: ['bun', 'bun:*', ...(this.config.externalPackages || [])],
257
420
  });
258
421
  const stepsResult = await esbuildCtx.rebuild();
259
422
  this.logEsbuildMessages(stepsResult, 'steps bundle creation');
260
- console.log('Created steps bundle', `${Date.now() - stepsBundleStart}ms`);
261
- const partialWorkflowManifest = {
262
- steps: workflowManifest.steps,
263
- };
264
- // always write to debug file
265
- await this.writeDebugFile(join(dirname(outfile), 'manifest'), partialWorkflowManifest, true);
423
+ this.logBaseBuilderInfo('Created steps bundle', `${Date.now() - stepsBundleStart}ms`);
424
+ // Handle workflow-only files that may have been tree-shaken from the bundle.
425
+ // These files have no steps, so esbuild removes them, but we still need their
426
+ // workflow metadata for the manifest. Transform them separately.
427
+ const workflowOnlyFiles = workflowFiles.filter((f) => !stepFiles.includes(f));
428
+ await Promise.all(workflowOnlyFiles.map(async (workflowFile) => {
429
+ try {
430
+ const source = await readFile(workflowFile, 'utf8');
431
+ const relativeFilepath = this.getRelativeFilepath(workflowFile);
432
+ const { workflowManifest: fileManifest } = await applySwcTransform(relativeFilepath, source, 'workflow', workflowFile, this.transformProjectRoot);
433
+ if (fileManifest.workflows) {
434
+ workflowManifest.workflows = Object.assign(workflowManifest.workflows || {}, fileManifest.workflows);
435
+ }
436
+ if (fileManifest.classes) {
437
+ workflowManifest.classes = Object.assign(workflowManifest.classes || {}, fileManifest.classes);
438
+ }
439
+ }
440
+ catch (error) {
441
+ // Log warning but continue - don't fail build for workflow-only file issues
442
+ console.warn(`Warning: Failed to extract workflow metadata from ${workflowFile}:`, error instanceof Error ? error.message : String(error));
443
+ }
444
+ }));
266
445
  // Create .gitignore in .swc directory
267
446
  await this.createSwcGitignore();
268
447
  if (this.config.watch) {
269
- return esbuildCtx;
448
+ return { context: esbuildCtx, manifest: workflowManifest };
270
449
  }
271
450
  await esbuildCtx.dispose();
451
+ return { context: undefined, manifest: workflowManifest };
272
452
  }
273
453
  /**
274
454
  * Creates a bundle for workflow orchestration functions.
@@ -276,30 +456,59 @@ export class BaseBuilder {
276
456
  *
277
457
  * @param bundleFinalOutput - If false, skips the final bundling step (used by Next.js)
278
458
  */
279
- async createWorkflowsBundle({ inputFiles, format = 'cjs', outfile, bundleFinalOutput = true, tsBaseUrl, tsPaths, }) {
280
- const { discoveredWorkflows: workflowFiles } = await this.discoverEntries(inputFiles, dirname(outfile));
459
+ async createWorkflowsBundle({ inputFiles, format = 'cjs', outfile, bundleFinalOutput = true, keepInterimBundleContext = this.config.watch, tsconfigPath, discoveredEntries, }) {
460
+ const discovered = discoveredEntries ??
461
+ (await this.discoverEntries(inputFiles, dirname(outfile), tsconfigPath));
462
+ const workflowFiles = [...discovered.discoveredWorkflows].sort();
463
+ const serdeFiles = [...discovered.discoveredSerdeFiles].sort();
464
+ // Include serde files that aren't already workflow files for cross-context class registration.
465
+ // Classes need to be registered in the workflow bundle so they can be deserialized
466
+ // when receiving data from steps or when serializing data to send to steps.
467
+ const workflowFilesSet = new Set(workflowFiles);
468
+ const serdeOnlyFiles = serdeFiles.filter((f) => !workflowFilesSet.has(f));
281
469
  // log the workflow files for debugging
282
- await this.writeDebugFile(outfile, { workflowFiles });
283
- // Create a virtual entry that imports all files
284
- const imports = `globalThis.__private_workflows = new Map();\n` +
285
- workflowFiles
286
- .map((file, workflowFileIdx) => {
287
- // Normalize both paths to forward slashes before calling relative()
288
- // This is critical on Windows where relative() can produce unexpected results with mixed path formats
289
- const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
290
- const normalizedFile = file.replace(/\\/g, '/');
291
- // Calculate relative path from working directory to the file
292
- let relativePath = relative(normalizedWorkingDir, normalizedFile).replace(/\\/g, '/');
293
- // Ensure relative paths start with ./ so esbuild resolves them correctly
294
- if (!relativePath.startsWith('.')) {
295
- relativePath = `./${relativePath}`;
296
- }
297
- return `import * as workflowFile${workflowFileIdx} from '${relativePath}';
298
- Object.values(workflowFile${workflowFileIdx}).map(item => item?.workflowId && globalThis.__private_workflows.set(item.workflowId, item))`;
299
- })
300
- .join('\n');
470
+ await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });
471
+ // Helper to create import statement from file path
472
+ // For packages, uses the package name so esbuild will resolve through
473
+ // package.json exports with conditions: ['workflow']
474
+ const createImport = (file) => {
475
+ const { importPath, isPackage } = getImportPath(file, this.config.workingDir);
476
+ if (isPackage) {
477
+ // Use package name - esbuild will resolve via package.json exports
478
+ // and apply the 'workflow' condition
479
+ return `import '${importPath}';`;
480
+ }
481
+ // Local app file - use relative path
482
+ // Normalize both paths to forward slashes before calling relative()
483
+ // This is critical on Windows where relative() can produce unexpected results with mixed path formats
484
+ const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
485
+ const normalizedFile = file.replace(/\\/g, '/');
486
+ // Calculate relative path from working directory to the file
487
+ let relativePath = relative(normalizedWorkingDir, normalizedFile).replace(/\\/g, '/');
488
+ // Ensure relative paths start with ./ so esbuild resolves them correctly.
489
+ // Paths like ".output/..." are not valid relative specifiers and must
490
+ // become "./.output/...".
491
+ if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) {
492
+ relativePath = `./${relativePath}`;
493
+ }
494
+ return `import '${relativePath}';`;
495
+ };
496
+ // Create a virtual entry that imports all workflow files
497
+ // The SWC plugin in workflow mode emits `globalThis.__private_workflows.set(workflowId, fn)`
498
+ // calls directly, so we just need to import the files (Map is initialized via banner)
499
+ const workflowImports = workflowFiles.map(createImport).join('\n');
500
+ // Include serde-only files for class registration side effects
501
+ const serdeImports = serdeOnlyFiles.map(createImport).join('\n');
502
+ const imports = serdeImports
503
+ ? `${workflowImports}\n// Serde files for cross-context class registration\n${serdeImports}`
504
+ : workflowImports;
301
505
  const bundleStartTime = Date.now();
302
506
  const workflowManifest = {};
507
+ const esbuildTsconfigOptions = await getEsbuildTsconfigOptions(tsconfigPath);
508
+ const normalizedWorkflowSideEffectEntries = await withRealpaths([
509
+ ...workflowFiles,
510
+ ...serdeOnlyFiles,
511
+ ]);
303
512
  // Bundle with esbuild and our custom SWC plugin in workflow mode.
304
513
  // this bundle will be run inside a vm isolate
305
514
  const interimBundleCtx = await esbuild.context({
@@ -320,106 +529,192 @@ export class BaseBuilder {
320
529
  treeShaking: true,
321
530
  keepNames: true,
322
531
  minify: false,
532
+ // Initialize the workflow registry at the very top of the bundle
533
+ // This must be in banner (not the virtual entry) because esbuild's bundling
534
+ // can reorder code, and the .set() calls need the Map to exist first
535
+ banner: {
536
+ js: 'globalThis.__private_workflows = new Map();',
537
+ },
323
538
  // Inline source maps for better stack traces in workflow VM execution.
324
539
  // This intermediate bundle is executed via runInContext() in a VM, so we need
325
540
  // inline source maps to get meaningful stack traces instead of "evalmachine.<anonymous>".
326
541
  sourcemap: 'inline',
327
- resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
542
+ // Use tsconfig for path alias resolution.
543
+ // For symlinked configs this uses tsconfigRaw to preserve cwd-relative aliases.
544
+ ...esbuildTsconfigOptions,
545
+ resolveExtensions: [
546
+ '.ts',
547
+ '.tsx',
548
+ '.mts',
549
+ '.cts',
550
+ '.js',
551
+ '.jsx',
552
+ '.mjs',
553
+ '.cjs',
554
+ ],
328
555
  plugins: [
556
+ // Handle pseudo-packages like 'server-only' and 'client-only' by providing
557
+ // empty modules. Must run first to intercept these before other resolution.
558
+ createPseudoPackagePlugin(),
329
559
  createSwcPlugin({
330
560
  mode: 'workflow',
331
- tsBaseUrl,
332
- tsPaths,
561
+ projectRoot: this.transformProjectRoot,
333
562
  workflowManifest,
563
+ sideEffectEntries: normalizedWorkflowSideEffectEntries,
334
564
  }),
335
565
  // This plugin must run after the swc plugin to ensure dead code elimination
336
566
  // happens first, preventing false positives on Node.js imports in unused code paths
337
567
  createNodeModuleErrorPlugin(),
338
568
  ],
569
+ // NOTE: We intentionally do NOT use the external option here for workflow bundles.
570
+ // When packages are marked external with format: 'cjs', esbuild generates require() calls.
571
+ // However, the workflow VM (vm.runInContext) does not have require() defined - it only
572
+ // provides module.exports and exports. External packages would fail at runtime with:
573
+ // ReferenceError: require is not defined
574
+ // Instead, we bundle everything and rely on:
575
+ // - createPseudoPackagePlugin() to handle server-only/client-only with empty modules
576
+ // - createNodeModuleErrorPlugin() to catch Node.js builtin imports at build time
339
577
  });
340
- const interimBundle = await interimBundleCtx.rebuild();
341
- this.logEsbuildMessages(interimBundle, 'intermediate workflow bundle');
342
- console.log('Created intermediate workflow bundle', `${Date.now() - bundleStartTime}ms`);
343
- const partialWorkflowManifest = {
344
- workflows: workflowManifest.workflows,
345
- };
346
- await this.writeDebugFile(join(dirname(outfile), 'manifest'), partialWorkflowManifest, true);
347
- if (this.config.workflowManifestPath) {
348
- const resolvedPath = resolve(process.cwd(), this.config.workflowManifestPath);
349
- let prefix = '';
350
- if (resolvedPath.endsWith('.cjs')) {
351
- prefix = 'module.exports = ';
578
+ let shouldDisposeInterimBundleCtx = !keepInterimBundleContext;
579
+ try {
580
+ const interimBundle = await interimBundleCtx.rebuild();
581
+ this.logEsbuildMessages(interimBundle, 'intermediate workflow bundle', true, {
582
+ suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings,
583
+ });
584
+ this.logCreateWorkflowsBundleInfo('Created intermediate workflow bundle', `${Date.now() - bundleStartTime}ms`);
585
+ if (this.config.workflowManifestPath) {
586
+ const resolvedPath = resolve(process.cwd(), this.config.workflowManifestPath);
587
+ let prefix = '';
588
+ if (resolvedPath.endsWith('.cjs')) {
589
+ prefix = 'module.exports = ';
590
+ }
591
+ else if (resolvedPath.endsWith('.js') ||
592
+ resolvedPath.endsWith('.mjs')) {
593
+ prefix = 'export default ';
594
+ }
595
+ await mkdir(dirname(resolvedPath), { recursive: true });
596
+ await writeFile(resolvedPath, prefix + JSON.stringify(workflowManifest, null, 2));
352
597
  }
353
- else if (resolvedPath.endsWith('.js') ||
354
- resolvedPath.endsWith('.mjs')) {
355
- prefix = 'export default ';
598
+ // Create .gitignore in .swc directory
599
+ await this.createSwcGitignore();
600
+ if (!interimBundle.outputFiles ||
601
+ interimBundle.outputFiles.length === 0) {
602
+ throw new Error('No output files generated from esbuild');
356
603
  }
357
- await mkdir(dirname(resolvedPath), { recursive: true });
358
- await writeFile(resolvedPath, prefix + JSON.stringify(workflowManifest.workflows, null, 2));
359
- }
360
- // Create .gitignore in .swc directory
361
- await this.createSwcGitignore();
362
- if (!interimBundle.outputFiles || interimBundle.outputFiles.length === 0) {
363
- throw new Error('No output files generated from esbuild');
364
- }
365
- const bundleFinal = async (interimBundle) => {
366
- const workflowBundleCode = interimBundle;
367
- const workflowFunctionCode = `// biome-ignore-all lint: generated file
604
+ // Serde compliance warnings: check if workflow bundle has Node.js imports
605
+ // alongside serde-registered classes (these will fail at runtime in the sandbox)
606
+ if (workflowManifest.classes &&
607
+ Object.keys(workflowManifest.classes).length > 0) {
608
+ const { analyzeSerdeCompliance } = await import('./serde-checker.js');
609
+ const bundleText = interimBundle.outputFiles[0].text;
610
+ const serdeResult = analyzeSerdeCompliance({
611
+ sourceCode: '',
612
+ workflowCode: bundleText,
613
+ manifest: workflowManifest,
614
+ });
615
+ // De-dupe warnings: group identical issues across classes
616
+ const issuesToClasses = new Map();
617
+ for (const cls of serdeResult.classes) {
618
+ if (!cls.compliant) {
619
+ for (const issue of cls.issues) {
620
+ let affectedClasses = issuesToClasses.get(issue);
621
+ if (!affectedClasses) {
622
+ affectedClasses = new Set();
623
+ issuesToClasses.set(issue, affectedClasses);
624
+ }
625
+ affectedClasses.add(cls.className);
626
+ }
627
+ }
628
+ }
629
+ for (const [issue, affectedClasses] of issuesToClasses) {
630
+ const classNames = [...affectedClasses];
631
+ const classLabel = classNames.length === 1
632
+ ? `class "${classNames[0]}"`
633
+ : `classes ${classNames.map((name) => `"${name}"`).join(', ')}`;
634
+ console.warn(chalk.yellow(`⚠ Serde warning for ${classLabel}: `) + issue);
635
+ }
636
+ }
637
+ const bundleFinal = async (interimBundle) => {
638
+ const workflowBundleCode = interimBundle;
639
+ const workflowFunctionCode = `// biome-ignore-all lint: generated file
368
640
  /* eslint-disable */
369
641
  import { workflowEntrypoint } from 'workflow/runtime';
370
642
 
371
643
  const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;
372
644
 
373
645
  export const POST = workflowEntrypoint(workflowCode);`;
374
- // we skip the final bundling step for Next.js so it can bundle itself
375
- if (!bundleFinalOutput) {
376
- if (!outfile) {
377
- throw new Error(`Invariant: missing outfile for workflow bundle`);
646
+ // we skip the final bundling step for Next.js so it can bundle itself
647
+ if (!bundleFinalOutput) {
648
+ if (!outfile) {
649
+ throw new Error(`Invariant: missing outfile for workflow bundle`);
650
+ }
651
+ // Ensure the output directory exists
652
+ const outputDir = dirname(outfile);
653
+ await mkdir(outputDir, { recursive: true });
654
+ // Atomic write: write to temp file then rename to prevent
655
+ // file watchers from reading partial file during write
656
+ const tempPath = `${outfile}.${randomUUID()}.tmp`;
657
+ await writeFile(tempPath, workflowFunctionCode);
658
+ await rename(tempPath, outfile);
659
+ return;
378
660
  }
379
- // Ensure the output directory exists
380
- const outputDir = dirname(outfile);
381
- await mkdir(outputDir, { recursive: true });
382
- await writeFile(outfile, workflowFunctionCode);
383
- return;
384
- }
385
- const bundleStartTime = Date.now();
386
- // Now bundle this so we can resolve the @workflow/core dependency
387
- // we could remove this if we do nft tracing or similar instead
388
- const finalWorkflowResult = await esbuild.build({
389
- banner: {
390
- js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
391
- },
392
- stdin: {
393
- contents: workflowFunctionCode,
394
- resolveDir: this.config.workingDir,
395
- sourcefile: 'virtual-entry.js',
396
- loader: 'js',
397
- },
398
- outfile,
399
- // Source maps for the final workflow bundle wrapper (not important since this code
400
- // doesn't run in the VM - only the intermediate bundle sourcemap is relevant)
401
- sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING,
402
- absWorkingDir: this.config.workingDir,
403
- bundle: true,
404
- format,
405
- platform: 'node',
406
- target: 'es2022',
407
- write: true,
408
- keepNames: true,
409
- minify: false,
410
- external: ['@aws-sdk/credential-provider-web-identity'],
411
- });
412
- this.logEsbuildMessages(finalWorkflowResult, 'final workflow bundle');
413
- console.log('Created final workflow bundle', `${Date.now() - bundleStartTime}ms`);
414
- };
415
- await bundleFinal(interimBundle.outputFiles[0].text);
416
- if (this.config.watch) {
417
- return {
418
- interimBundleCtx,
419
- bundleFinal,
661
+ const bundleStartTime = Date.now();
662
+ // Now bundle this so we can resolve the @workflow/core dependency
663
+ // we could remove this if we do nft tracing or similar instead
664
+ const finalWorkflowResult = await esbuild.build({
665
+ banner: {
666
+ js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
667
+ },
668
+ stdin: {
669
+ contents: workflowFunctionCode,
670
+ resolveDir: this.config.workingDir,
671
+ sourcefile: 'virtual-entry.js',
672
+ loader: 'js',
673
+ },
674
+ outfile,
675
+ // Source maps for the final workflow bundle wrapper (not important since this code
676
+ // doesn't run in the VM - only the intermediate bundle sourcemap is relevant)
677
+ sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING,
678
+ absWorkingDir: this.config.workingDir,
679
+ bundle: true,
680
+ format,
681
+ platform: 'node',
682
+ target: 'es2022',
683
+ write: true,
684
+ keepNames: true,
685
+ minify: false,
686
+ external: ['@aws-sdk/credential-provider-web-identity'],
687
+ });
688
+ this.logEsbuildMessages(finalWorkflowResult, 'final workflow bundle', true, {
689
+ suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings,
690
+ });
691
+ this.logCreateWorkflowsBundleInfo('Created final workflow bundle', `${Date.now() - bundleStartTime}ms`);
420
692
  };
693
+ await bundleFinal(interimBundle.outputFiles[0].text);
694
+ if (keepInterimBundleContext) {
695
+ shouldDisposeInterimBundleCtx = false;
696
+ return {
697
+ manifest: workflowManifest,
698
+ interimBundleCtx,
699
+ bundleFinal,
700
+ };
701
+ }
702
+ return { manifest: workflowManifest };
703
+ }
704
+ catch (error) {
705
+ shouldDisposeInterimBundleCtx = true;
706
+ throw error;
707
+ }
708
+ finally {
709
+ if (shouldDisposeInterimBundleCtx) {
710
+ try {
711
+ await interimBundleCtx.dispose();
712
+ }
713
+ catch (disposeError) {
714
+ console.warn('Warning: Failed to dispose workflow bundle context', disposeError);
715
+ }
716
+ }
421
717
  }
422
- await interimBundleCtx.dispose();
423
718
  }
424
719
  /**
425
720
  * Creates a client library bundle for workflow execution.
@@ -431,23 +726,58 @@ export const POST = workflowEntrypoint(workflowCode);`;
431
726
  // Silently exit since no client bundle was requested
432
727
  return;
433
728
  }
434
- console.log('Generating a client library at', this.config.clientBundlePath);
435
- console.log('NOTE: The recommended way to use workflow with a framework like NextJS is using the loader/plugin with webpack/turbobpack/rollup');
729
+ this.logBaseBuilderInfo('Generating a client library at', this.config.clientBundlePath);
730
+ this.logBaseBuilderInfo('NOTE: The recommended way to use workflow with a framework like NextJS is using the loader/plugin with webpack/turbobpack/rollup');
436
731
  // Ensure we have the directory for the client bundle
437
732
  const outputDir = dirname(this.config.clientBundlePath);
438
733
  await mkdir(outputDir, { recursive: true });
439
734
  const inputFiles = await this.getInputFiles();
440
- // Create a virtual entry that imports all files
441
- const imports = inputFiles
442
- .map((file) => `export * from '${file}';`)
735
+ // Discover serde files from the input files' dependency tree for cross-context class registration.
736
+ // Classes need to be registered in the client bundle so they can be serialized
737
+ // when passing data to workflows via start() and deserialized when receiving workflow results.
738
+ const { discoveredSerdeFiles } = await this.discoverEntries(inputFiles, outputDir);
739
+ // Identify serde files that aren't in the inputFiles (deduplicated)
740
+ const inputFilesNormalized = new Set(inputFiles.map((f) => f.replace(/\\/g, '/')));
741
+ const serdeOnlyFiles = [...discoveredSerdeFiles].filter((f) => !inputFilesNormalized.has(f));
742
+ // Re-exports for input files (user's workflow/step definitions).
743
+ // These must use valid relative specifiers because some frameworks pass
744
+ // generated files like ".output/server/index.mjs" as input files.
745
+ const reexports = inputFiles
746
+ .map((file) => {
747
+ const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
748
+ let relativePath = relative(normalizedWorkingDir, file).replace(/\\/g, '/');
749
+ if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) {
750
+ relativePath = `./${relativePath}`;
751
+ }
752
+ return `export * from '${relativePath}';`;
753
+ })
443
754
  .join('\n');
755
+ // Side-effect imports for serde files not in inputFiles (for class registration)
756
+ const serdeImports = serdeOnlyFiles
757
+ .map((file) => {
758
+ const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
759
+ let relativePath = relative(normalizedWorkingDir, file).replace(/\\/g, '/');
760
+ if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) {
761
+ relativePath = `./${relativePath}`;
762
+ }
763
+ return `import '${relativePath}';`;
764
+ })
765
+ .join('\n');
766
+ // Combine: serde imports (for registration side effects) + re-exports
767
+ const entryContent = serdeImports
768
+ ? `// Serde files for cross-context class registration\n${serdeImports}\n${reexports}`
769
+ : reexports;
444
770
  // Bundle with esbuild and our custom SWC plugin
771
+ const normalizedClientSideEffectEntries = await withRealpaths([
772
+ ...inputFiles,
773
+ ...serdeOnlyFiles,
774
+ ]);
445
775
  const clientResult = await esbuild.build({
446
776
  banner: {
447
777
  js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
448
778
  },
449
779
  stdin: {
450
- contents: imports,
780
+ contents: entryContent,
451
781
  resolveDir: this.config.workingDir,
452
782
  sourcefile: 'virtual-entry.js',
453
783
  loader: 'js',
@@ -456,12 +786,28 @@ export const POST = workflowEntrypoint(workflowCode);`;
456
786
  bundle: true,
457
787
  format: 'esm',
458
788
  platform: 'node',
789
+ jsx: 'preserve',
459
790
  target: 'es2022',
460
791
  write: true,
461
792
  treeShaking: true,
462
793
  external: ['@workflow/core'],
463
- resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
464
- plugins: [createSwcPlugin({ mode: 'client' })],
794
+ resolveExtensions: [
795
+ '.ts',
796
+ '.tsx',
797
+ '.mts',
798
+ '.cts',
799
+ '.js',
800
+ '.jsx',
801
+ '.mjs',
802
+ '.cjs',
803
+ ],
804
+ plugins: [
805
+ createSwcPlugin({
806
+ mode: 'client',
807
+ projectRoot: this.transformProjectRoot,
808
+ sideEffectEntries: normalizedClientSideEffectEntries,
809
+ }),
810
+ ],
465
811
  });
466
812
  this.logEsbuildMessages(clientResult, 'client library bundle');
467
813
  // Create .gitignore in .swc directory
@@ -473,7 +819,7 @@ export const POST = workflowEntrypoint(workflowCode);`;
473
819
  * @param bundle - If true, bundles dependencies (needed for Build Output API)
474
820
  */
475
821
  async createWebhookBundle({ outfile, bundle = false, }) {
476
- console.log('Creating webhook route');
822
+ this.logCreateWebhookBundleInfo('Creating webhook route');
477
823
  await mkdir(dirname(outfile), { recursive: true });
478
824
  // Create a static route that calls resumeWebhook
479
825
  // This route works for both Next.js and Vercel Build Output API
@@ -512,10 +858,12 @@ export const OPTIONS = handler;`;
512
858
  return;
513
859
  }
514
860
  // For Build Output API, bundle with esbuild to resolve imports
861
+ const webhookFormat = 'cjs';
862
+ const { banner: webhookImportMetaBanner, define: webhookImportMetaDefine } = this.getCjsImportMetaPolyfill(webhookFormat);
515
863
  const webhookBundleStart = Date.now();
516
864
  const result = await esbuild.build({
517
865
  banner: {
518
- js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
866
+ js: `// biome-ignore-all lint: generated file\n/* eslint-disable */\n${webhookImportMetaBanner}`,
519
867
  },
520
868
  stdin: {
521
869
  contents: routeContent,
@@ -526,7 +874,8 @@ export const OPTIONS = handler;`;
526
874
  outfile,
527
875
  absWorkingDir: this.config.workingDir,
528
876
  bundle: true,
529
- format: 'cjs',
877
+ jsx: 'preserve',
878
+ format: webhookFormat,
530
879
  platform: 'node',
531
880
  conditions: ['import', 'module', 'node', 'default'],
532
881
  target: 'es2022',
@@ -534,14 +883,24 @@ export const OPTIONS = handler;`;
534
883
  treeShaking: true,
535
884
  keepNames: true,
536
885
  minify: false,
537
- resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
538
- sourcemap: false,
886
+ define: webhookImportMetaDefine,
887
+ resolveExtensions: [
888
+ '.ts',
889
+ '.tsx',
890
+ '.mts',
891
+ '.cts',
892
+ '.js',
893
+ '.jsx',
894
+ '.mjs',
895
+ '.cjs',
896
+ ],
897
+ sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING,
539
898
  mainFields: ['module', 'main'],
540
899
  // Don't externalize anything - bundle everything including workflow packages
541
900
  external: [],
542
901
  });
543
902
  this.logEsbuildMessages(result, 'webhook bundle creation');
544
- console.log('Created webhook bundle', `${Date.now() - webhookBundleStart}ms`);
903
+ this.logCreateWebhookBundleInfo('Created webhook bundle', `${Date.now() - webhookBundleStart}ms`);
545
904
  }
546
905
  /**
547
906
  * Creates a package.json file with the specified module type.
@@ -560,6 +919,9 @@ export const OPTIONS = handler;`;
560
919
  launcherType: config.launcherType ?? 'Nodejs',
561
920
  architecture: config.architecture ?? 'arm64',
562
921
  shouldAddHelpers: config.shouldAddHelpers ?? true,
922
+ ...(config.maxDuration !== undefined && {
923
+ maxDuration: config.maxDuration,
924
+ }),
563
925
  ...(config.shouldAddSourcemapSupport !== undefined && {
564
926
  shouldAddSourcemapSupport: config.shouldAddSourcemapSupport,
565
927
  }),
@@ -589,5 +951,108 @@ export const OPTIONS = handler;`;
589
951
  // We're intentionally silently ignoring this error - creating .gitignore isn't critical
590
952
  }
591
953
  }
954
+ /**
955
+ * Whether the manifest should be exposed as a public HTTP route.
956
+ * Controlled by the `WORKFLOW_PUBLIC_MANIFEST` environment variable.
957
+ */
958
+ get shouldExposePublicManifest() {
959
+ return process.env.WORKFLOW_PUBLIC_MANIFEST === '1';
960
+ }
961
+ /**
962
+ * Whether diagnostics artifacts should be emitted to Vercel output.
963
+ * This is enabled when the resolved world target is Vercel.
964
+ */
965
+ get shouldEmitVercelDiagnostics() {
966
+ return (usesVercelWorld() || this.config.buildTarget === 'vercel-build-output-api');
967
+ }
968
+ /**
969
+ * Creates a manifest JSON file containing step/workflow/class metadata
970
+ * and graph data for visualization.
971
+ *
972
+ * @returns The manifest JSON string, or undefined if manifest creation failed.
973
+ */
974
+ async createManifest({ workflowBundlePath, manifestDir, manifest, }) {
975
+ const buildStart = Date.now();
976
+ this.logCreateManifestInfo('Creating manifest...');
977
+ try {
978
+ const workflowGraphs = await extractWorkflowGraphs(workflowBundlePath);
979
+ const steps = this.convertStepsManifest(manifest.steps);
980
+ const workflows = this.convertWorkflowsManifest(manifest.workflows, workflowGraphs);
981
+ const classes = this.convertClassesManifest(manifest.classes);
982
+ const output = { version: '1.0.0', steps, workflows, classes };
983
+ const manifestJson = JSON.stringify(output, null, 2);
984
+ await mkdir(manifestDir, { recursive: true });
985
+ await writeFile(join(manifestDir, 'manifest.json'), manifestJson);
986
+ if (this.shouldEmitVercelDiagnostics) {
987
+ const diagnosticsManifestPath = this.resolvePath('.vercel/output/diagnostics/workflows-manifest.json');
988
+ await this.ensureDirectory(diagnosticsManifestPath);
989
+ await writeFile(diagnosticsManifestPath, manifestJson);
990
+ }
991
+ const stepCount = Object.values(steps).reduce((acc, s) => acc + Object.keys(s).length, 0);
992
+ const workflowCount = Object.values(workflows).reduce((acc, w) => acc + Object.keys(w).length, 0);
993
+ const classCount = Object.values(classes).reduce((acc, c) => acc + Object.keys(c).length, 0);
994
+ this.logCreateManifestInfo(`Created manifest with ${stepCount} ${pluralize('step', 'steps', stepCount)}, ${workflowCount} ${pluralize('workflow', 'workflows', workflowCount)}, and ${classCount} ${pluralize('class', 'classes', classCount)}`, `${Date.now() - buildStart}ms`);
995
+ return manifestJson;
996
+ }
997
+ catch (error) {
998
+ console.warn('Failed to create manifest:', error instanceof Error ? error.message : String(error));
999
+ return undefined;
1000
+ }
1001
+ }
1002
+ convertStepsManifest(steps) {
1003
+ const result = {};
1004
+ if (!steps)
1005
+ return result;
1006
+ for (const [filePath, entries] of Object.entries(steps)) {
1007
+ result[filePath] = {};
1008
+ for (const [name, data] of Object.entries(entries)) {
1009
+ result[filePath][name] = { stepId: data.stepId };
1010
+ }
1011
+ }
1012
+ return result;
1013
+ }
1014
+ convertWorkflowsManifest(workflows, graphs) {
1015
+ const result = {};
1016
+ if (!workflows)
1017
+ return result;
1018
+ // Build a normalized lookup for graphs since the graph extractor uses
1019
+ // paths from workflowId (e.g. "./workflows/hello-agent") while the
1020
+ // manifest uses source file paths (e.g. "workflows/hello-agent.ts").
1021
+ // Normalize by stripping leading "./" and file extensions.
1022
+ const normalizedGraphs = new Map();
1023
+ for (const [graphPath, graphEntries] of Object.entries(graphs)) {
1024
+ const normalized = graphPath
1025
+ .replace(/^\.\//, '')
1026
+ .replace(/\.[^/.]+$/, '');
1027
+ normalizedGraphs.set(normalized, graphEntries);
1028
+ }
1029
+ for (const [filePath, entries] of Object.entries(workflows)) {
1030
+ result[filePath] = {};
1031
+ // Normalize the manifest file path for lookup
1032
+ const normalizedFilePath = filePath
1033
+ .replace(/^\.\//, '')
1034
+ .replace(/\.[^/.]+$/, '');
1035
+ const graphEntries = graphs[filePath] || normalizedGraphs.get(normalizedFilePath);
1036
+ for (const [name, data] of Object.entries(entries)) {
1037
+ result[filePath][name] = {
1038
+ workflowId: data.workflowId,
1039
+ graph: graphEntries?.[name]?.graph || { nodes: [], edges: [] },
1040
+ };
1041
+ }
1042
+ }
1043
+ return result;
1044
+ }
1045
+ convertClassesManifest(classes) {
1046
+ const result = {};
1047
+ if (!classes)
1048
+ return result;
1049
+ for (const [filePath, entries] of Object.entries(classes)) {
1050
+ result[filePath] = {};
1051
+ for (const [name, data] of Object.entries(entries)) {
1052
+ result[filePath][name] = { classId: data.classId };
1053
+ }
1054
+ }
1055
+ return result;
1056
+ }
592
1057
  }
593
1058
  //# sourceMappingURL=base-builder.js.map