diff --git a/.changeset/curvy-maps-leave.md b/.changeset/curvy-maps-leave.md new file mode 100644 index 000000000..0fbead457 --- /dev/null +++ b/.changeset/curvy-maps-leave.md @@ -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 diff --git a/lib/steps/paths-alias-test.ts b/lib/steps/paths-alias-test.ts index 30976050c..1e7e9f903 100644 --- a/lib/steps/paths-alias-test.ts +++ b/lib/steps/paths-alias-test.ts @@ -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 { + 'use step'; + return 'pathsAliasStep'; } diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts index c60f2a2ce..c3530060e 100644 --- a/packages/astro/src/builder.ts +++ b/packages/astro/src/builder.ts @@ -79,7 +79,6 @@ export class LocalBuilder extends BaseBuilder { format: 'esm', inputFiles, outfile: stepsRouteFile, - externalizeNonSteps: true, tsconfigPath, }); diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index f4db6c00f..0b37f3f68 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -124,7 +124,7 @@ export abstract class BaseBuilder { absWorkingDir: this.config.workingDir, logLevel: 'silent', }); - } catch (_) {} + } catch (_) { } console.log( `Discovering workflow directives`, @@ -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( @@ -321,7 +347,7 @@ export abstract class BaseBuilder { platform: 'node', conditions: ['node'], target: 'es2022', - write: true, + write: writeToFile, treeShaking: true, keepNames: true, minify: false, @@ -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, }), ], @@ -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; + 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 }; } /** diff --git a/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts index 4c32ae4bb..e405c6f2e 100644 --- a/packages/builders/src/standalone.ts +++ b/packages/builders/src/standalone.ts @@ -1,3 +1,4 @@ +import * as esbuild from 'esbuild'; import { BaseBuilder } from './base-builder.js'; import type { WorkflowConfig } from './types.js'; @@ -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; } diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index 903e0eb5c..108ee9cfe 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -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'; @@ -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, { diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index b7d5e62b9..8a1b51ad9 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -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'); } ); }); diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 5ec2ffe5a..242b616a7 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -402,7 +402,6 @@ export async function getNextBuilder() { format: 'esm', inputFiles, outfile: join(stepsRouteDir, 'route.js'), - externalizeNonSteps: true, tsconfigPath, }); } diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index d9c95409a..e7f411ecb 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -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, }); diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 1277c1b8f..ce5de3f63 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -84,7 +84,6 @@ export class SvelteKitBuilder extends BaseBuilder { format: 'esm', inputFiles, outfile: join(stepsRouteDir, '+server.js'), - externalizeNonSteps: true, tsconfigPath, }); diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index a715a266c..e09cfcdaf 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -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, @@ -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; }