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
9 changes: 9 additions & 0 deletions .changeset/curvy-maps-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@workflow/sveltekit": patch
"@workflow/builders": patch
"@workflow/astro": patch
"@workflow/nitro": patch
"@workflow/next": patch
---

Build steps bundle in a two-pass process for "standalone" and "Vercel Build Output API" modes
9 changes: 4 additions & 5 deletions lib/steps/paths-alias-test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
/**
* This is a utility function from outside the workbench app directory.
* This is a step function from outside the workbench app directory.
* It is used to test that esbuild can resolve tsconfig path aliases.
* Note: This is NOT a step function - it's a regular function that gets called
* from within a step to verify path alias imports work correctly.
*/
export function pathsAliasHelper(): string {
return 'pathsAliasHelper';
export async function pathsAliasStep(): Promise<string> {
'use step';
return 'pathsAliasStep';
}
1 change: 0 additions & 1 deletion packages/astro/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export class LocalBuilder extends BaseBuilder {
format: 'esm',
inputFiles,
outfile: stepsRouteFile,
externalizeNonSteps: true,
tsconfigPath,
});

Expand Down
70 changes: 50 additions & 20 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export abstract class BaseBuilder {
absWorkingDir: this.config.workingDir,
logLevel: 'silent',
});
} catch (_) {}
} catch (_) { }

console.log(
`Discovering workflow directives`,
Expand Down Expand Up @@ -229,39 +229,65 @@ export abstract class BaseBuilder {
* Creates a bundle for workflow step functions.
* Steps have full Node.js runtime access and handle side effects, API calls, etc.
*
* @param externalizeNonSteps - If true, only bundles step entry points and externalizes other code
* @returns Build context (for watch mode) and the collected workflow manifest
* This method externalizes non-step code (including the 'workflow' package).
* Builders that need a fully bundled output should run a second esbuild pass
* to bundle the intermediate output with all dependencies.
*
* @param outfile - If provided, writes the bundle to disk. If omitted, returns the bundle content in memory.
* @returns Build context (for watch mode), the collected workflow manifest, and outputContent when outfile is omitted
*/
protected async createStepsBundle(options: {
tsconfigPath?: string;
inputFiles: string[];
outfile?: undefined;
format?: 'cjs' | 'esm';
}): Promise<{
context: esbuild.BuildContext | undefined;
manifest: WorkflowManifest;
outputContent: string;
}>;
protected async createStepsBundle(options: {
tsconfigPath?: string;
inputFiles: string[];
outfile: string;
format?: 'cjs' | 'esm';
}): Promise<{
context: esbuild.BuildContext | undefined;
manifest: WorkflowManifest;
}>;
protected async createStepsBundle({
inputFiles,
format = 'cjs',
outfile,
externalizeNonSteps,
tsconfigPath,
}: {
tsconfigPath?: string;
inputFiles: string[];
outfile: string;
outfile?: string;
format?: 'cjs' | 'esm';
externalizeNonSteps?: boolean;
}): Promise<{
context: esbuild.BuildContext | undefined;
manifest: WorkflowManifest;
outputContent?: string;
}> {
const writeToFile = !!outfile;
// These need to handle watching for dev to scan for
// new entries and changes to existing ones
const outDir = outfile ? dirname(outfile) : this.config.workingDir;
const { discoveredSteps: stepFiles, discoveredWorkflows: workflowFiles } =
await this.discoverEntries(inputFiles, dirname(outfile));
await this.discoverEntries(inputFiles, outDir);

// log the step files for debugging
await this.writeDebugFile(outfile, { stepFiles, workflowFiles });
// log the step files for debugging (only when writing to file)
if (outfile) {
await this.writeDebugFile(outfile, { stepFiles, workflowFiles });
}

const stepsBundleStart = Date.now();
const workflowManifest: WorkflowManifest = {};
const builtInSteps = 'workflow/internal/builtins';

const resolvedBuiltInSteps = await enhancedResolve(
dirname(outfile),
outDir,
'workflow/internal/builtins'
).catch((err) => {
throw new Error(
Expand Down Expand Up @@ -321,7 +347,7 @@ export abstract class BaseBuilder {
platform: 'node',
conditions: ['node'],
target: 'es2022',
write: true,
write: writeToFile,
treeShaking: true,
keepNames: true,
minify: false,
Expand All @@ -344,13 +370,11 @@ export abstract class BaseBuilder {
plugins: [
createSwcPlugin({
mode: 'step',
entriesToBundle: externalizeNonSteps
? [
...stepFiles,
...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []),
]
: undefined,
outdir: outfile ? dirname(outfile) : undefined,
entriesToBundle: [
...stepFiles,
...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []),
],
outdir: outDir,
workflowManifest,
}),
],
Expand All @@ -367,11 +391,17 @@ export abstract class BaseBuilder {
// Create .gitignore in .swc directory
await this.createSwcGitignore();

// Get output content if not writing to file
const outputContent =
!writeToFile && stepsResult.outputFiles?.[0]
? stepsResult.outputFiles[0].text
: undefined;
Comment on lines +394 to +398
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error check for esbuild output when creating steps bundle in-memory. The TypeScript overload promises a non-optional outputContent: string, but the implementation can return undefined, which will cause runtime errors in the second build pass.

View Details
📝 Patch Details
diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts
index 0b37f3f..be5939a 100644
--- a/packages/builders/src/base-builder.ts
+++ b/packages/builders/src/base-builder.ts
@@ -391,6 +391,13 @@ export abstract class BaseBuilder {
     // Create .gitignore in .swc directory
     await this.createSwcGitignore();
 
+    // Ensure output files exist when not writing to disk
+    if (!writeToFile) {
+      if (!stepsResult.outputFiles || stepsResult.outputFiles.length === 0) {
+        throw new Error('No output files generated from esbuild');
+      }
+    }
+
     // Get output content if not writing to file
     const outputContent =
       !writeToFile && stepsResult.outputFiles?.[0]

Analysis

Missing error check for esbuild output in createStepsBundle

What fails: The createStepsBundle() method in base-builder.ts has TypeScript overloads that promise a non-optional outputContent: string when the outfile parameter is omitted, but the implementation can return undefined, violating the type contract. This causes the callers in standalone.ts and vercel-build-output-api.ts to use the outputContent directly in esbuild's stdin.contents without null-checking, creating a type-safety issue.

How to reproduce:

  • Call createStepsBundle() without the outfile parameter (as done in standalone.ts line 54 and vercel-build-output-api.ts line 59)
  • The TypeScript overload guarantees outputContent: string
  • The implementation uses: !writeToFile && stepsResult.outputFiles?.[0] ? stepsResult.outputFiles[0].text : undefined
  • If stepsResult.outputFiles is empty or falsy, outputContent becomes undefined, violating the contract

Result: TypeScript incorrectly allows undefined to be passed where string is required by the overload contract. While esbuild should always populate outputFiles when write: false succeeds, the lack of explicit error checking creates a defensive programming gap.

Expected: The code should validate that outputFiles exists and is non-empty before extracting content, similar to the error check in createWorkflowsBundle() at line 548-550:

if (!interimBundle.outputFiles || interimBundle.outputFiles.length === 0) {
  throw new Error('No output files generated from esbuild');
}

Fix: Added explicit error check in createStepsBundle() at line 393-397 to ensure outputFiles exists when writeToFile is false, making the type contract enforceable.


if (this.config.watch) {
return { context: esbuildCtx, manifest: workflowManifest };
return { context: esbuildCtx, manifest: workflowManifest, outputContent };
}
await esbuildCtx.dispose();
return { context: undefined, manifest: workflowManifest };
return { context: undefined, manifest: workflowManifest, outputContent };
}

/**
Expand Down
29 changes: 27 additions & 2 deletions packages/builders/src/standalone.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as esbuild from 'esbuild';
import { BaseBuilder } from './base-builder.js';
import type { WorkflowConfig } from './types.js';

Expand Down Expand Up @@ -47,12 +48,36 @@ export class StandaloneBuilder extends BaseBuilder {
const stepsBundlePath = this.resolvePath(this.config.stepsBundlePath);
await this.ensureDirectory(stepsBundlePath);

const { manifest } = await this.createStepsBundle({
outfile: stepsBundlePath,
// Two-pass build approach (in-memory):
// 1. First pass: Create intermediate bundle in memory (no outfile)
// 2. Second pass: Bundle the in-memory output with all dependencies
const { manifest, outputContent } = await this.createStepsBundle({
inputFiles,
tsconfigPath,
});

// Second pass: bundle the in-memory output with all dependencies
await esbuild.build({
stdin: {
contents: outputContent,
resolveDir: this.config.workingDir,
sourcefile: 'steps-interim.js',
loader: 'js',
},
outfile: stepsBundlePath,
absWorkingDir: this.config.workingDir,
bundle: true,
format: 'cjs',
platform: 'node',
conditions: ['node'],
target: 'es2022',
treeShaking: true,
keepNames: true,
minify: false,
logLevel: 'error',
external: ['bun', 'bun:*', ...(this.config.externalPackages || [])],
});

return manifest;
}

Expand Down
34 changes: 31 additions & 3 deletions packages/builders/src/vercel-build-output-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import * as esbuild from 'esbuild';
import { BaseBuilder } from './base-builder.js';
import { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';

Expand Down Expand Up @@ -48,13 +49,40 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
const stepsFuncDir = join(workflowGeneratedDir, 'step.func');
await mkdir(stepsFuncDir, { recursive: true });

// Create steps bundle
const { manifest } = await this.createStepsBundle({
const finalOutfile = join(stepsFuncDir, 'index.js');

// Two-pass build approach (in-memory):
// 1. First pass: Create intermediate bundle in memory (no outfile)
// This externalizes the 'workflow' package so we don't need it resolved
// from files outside the project directory (e.g. via path aliases)
// 2. Second pass: Bundle the in-memory output with all dependencies
const { manifest, outputContent } = await this.createStepsBundle({
inputFiles,
outfile: join(stepsFuncDir, 'index.js'),
tsconfigPath,
});

// Second pass: bundle the in-memory output with all dependencies
await esbuild.build({
stdin: {
contents: outputContent,
resolveDir: this.config.workingDir,
sourcefile: 'steps-interim.js',
loader: 'js',
},
outfile: finalOutfile,
absWorkingDir: this.config.workingDir,
bundle: true,
format: 'cjs',
platform: 'node',
conditions: ['node'],
target: 'es2022',
treeShaking: true,
keepNames: true,
minify: false,
logLevel: 'error',
external: ['bun', 'bun:*', ...(this.config.externalPackages || [])],
});

// Create package.json and .vc-config.json for steps function
await this.createPackageJson(stepsFuncDir, 'commonjs');
await this.createVcConfig(stepsFuncDir, {
Expand Down
8 changes: 4 additions & 4 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,20 +975,20 @@ describe('e2e', () => {
'pathsAliasWorkflow - TypeScript path aliases resolve correctly',
{ timeout: 60_000 },
async () => {
// This workflow uses a step that calls a helper function imported via @repo/* path alias
// This workflow calls a step function imported via @repo/* path alias
// which resolves to a file outside the workbench directory (../../lib/steps/paths-alias-test.ts)
const run = await triggerWorkflow('pathsAliasWorkflow', []);
const returnValue = await getWorkflowReturnValue(run.runId);

// The step should return the helper's identifier string
expect(returnValue).toBe('pathsAliasHelper');
// The step should return its identifier string
expect(returnValue).toBe('pathsAliasStep');

// Verify the run completed successfully
const { json: runData } = await cliInspectJson(
`runs ${run.runId} --withData`
);
expect(runData.status).toBe('completed');
expect(runData.output).toBe('pathsAliasHelper');
expect(runData.output).toBe('pathsAliasStep');
}
);
});
1 change: 0 additions & 1 deletion packages/next/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,6 @@ export async function getNextBuilder() {
format: 'esm',
inputFiles,
outfile: join(stepsRouteDir, 'route.js'),
externalizeNonSteps: true,
tsconfigPath,
});
}
Expand Down
1 change: 0 additions & 1 deletion packages/nitro/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export class LocalBuilder extends BaseBuilder {

const { manifest } = await this.createStepsBundle({
outfile: join(this.#outDir, 'steps.mjs'),
externalizeNonSteps: true,
format: 'esm',
inputFiles,
});
Expand Down
1 change: 0 additions & 1 deletion packages/sveltekit/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ export class SvelteKitBuilder extends BaseBuilder {
format: 'esm',
inputFiles,
outfile: join(stepsRouteDir, '+server.js'),
externalizeNonSteps: true,
tsconfigPath,
});

Expand Down
19 changes: 5 additions & 14 deletions workbench/example/workflows/99_e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Test path alias resolution - imports a helper from outside the workbench directory
import { pathsAliasHelper } from '@repo/lib/steps/paths-alias-test';
// Test path alias resolution - imports a step from outside the workbench directory
import { pathsAliasStep } from '@repo/lib/steps/paths-alias-test';
import {
createHook,
createWebhook,
Expand Down Expand Up @@ -667,23 +667,14 @@ export async function spawnWorkflowFromStepWorkflow(inputValue: number) {

//////////////////////////////////////////////////////////

/**
* Step that calls a helper function imported via path alias.
*/
async function callPathsAliasHelper() {
'use step';
// Call the helper function imported via @repo/* path alias
return pathsAliasHelper();
}

/**
* Test that TypeScript path aliases work correctly.
* This workflow uses a step that calls a helper function imported via the @repo/* path alias,
* This workflow calls a step function imported via the @repo/* path alias,
* which resolves to a file outside the workbench directory.
*/
export async function pathsAliasWorkflow() {
'use workflow';
// Call the step that uses the path alias helper
const result = await callPathsAliasHelper();
// Call the step imported via path alias directly
const result = await pathsAliasStep();
return result;
}
Loading