Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/lazy-step-loaders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@workflow/core': patch
'@workflow/builders': patch
'workflow': patch
---

Defer step module loading until step execution so module load failures are recorded as step failures instead of route 500s.
32 changes: 25 additions & 7 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1586,6 +1586,23 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
}
}

private createStepLoaderRegistrationsCode(
manifest: WorkflowManifest,
loaderSpecifier: string
): string {
const registrations: string[] = [];

for (const entries of Object.values(manifest.steps ?? {})) {
for (const { stepId } of Object.values(entries)) {
registrations.push(
`registerStepFunctionLoader(${JSON.stringify(stepId)}, () => import(${JSON.stringify(loaderSpecifier)}));`
);
}
}

return registrations.join('\n');
}

/**
* V2: Creates a combined bundle that includes both step registrations and
* workflow orchestration in a single route. The combined entrypoint executes
Expand Down Expand Up @@ -1687,20 +1704,22 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo

// 3. Generate combined route file
const stepsRelativePath = `./${basename(stepsOutfile).replace(/\\/g, '/')}`;
const stepLoaderRegistrationsCode = this.createStepLoaderRegistrationsCode(
stepsManifest,
stepsRelativePath
);
const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&');
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
});

const combinedFunctionCode = `// biome-ignore-all lint: generated file
/* eslint-disable */
import { __steps_registered } from '${stepsRelativePath}';
import { workflowEntrypoint } from 'workflow/runtime';
import { registerStepFunctionLoader, workflowEntrypoint } from 'workflow/runtime';

const workflowRouteModuleBodyStartedAt = Date.now();

// Prevent rollup from tree-shaking the steps side-effect import
void __steps_registered;
${stepLoaderRegistrationsCode}

const workflowCode = \`${escapedVMCode}\`;

Expand Down Expand Up @@ -1769,12 +1788,11 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
);
const code = `// biome-ignore-all lint: generated file
/* eslint-disable */
import { __steps_registered } from '${stepsRelativePath}';
import { workflowEntrypoint } from 'workflow/runtime';
import { registerStepFunctionLoader, workflowEntrypoint } from 'workflow/runtime';

const workflowRouteModuleBodyStartedAt = Date.now();

void __steps_registered;
${stepLoaderRegistrationsCode}

const workflowCode = \`${escaped}\`;

Expand Down
157 changes: 156 additions & 1 deletion packages/builders/src/step-source-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { BaseBuilder, type DiscoveredEntries } from './base-builder.js';
import type { StandaloneConfig } from './types.js';
Expand All @@ -31,6 +32,22 @@ class TestBuilder extends BaseBuilder {
discoveredEntries,
});
}

public createCombinedRoute(
inputFiles: string[],
stepsOutfile: string,
flowOutfile: string
) {
return this.createCombinedBundle({
inputFiles,
stepsOutfile,
flowOutfile,
bundleFinalOutput: false,
externalizeNonSteps: true,
bundleTransitiveLocalStepDependencies: false,
sourceStepRegistrationImports: true,
});
}
}

const realTmpdir = realpathSync(tmpdir());
Expand Down Expand Up @@ -59,7 +76,28 @@ describe('step source registration', () => {
testRoot = mkdtempSync(join(realTmpdir, 'workflow-step-registration-'));
writeFile(
join(testRoot, 'node_modules', 'workflow', 'package.json'),
JSON.stringify({ name: 'workflow', version: '1.0.0' })
JSON.stringify({
name: 'workflow',
version: '1.0.0',
type: 'module',
exports: {
'./runtime': './runtime.js',
'./internal/builtins': './internal/builtins.js',
},
})
);
writeFile(
join(testRoot, 'node_modules', 'workflow', 'runtime.js'),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This test fixture stubs node_modules/workflow/runtime.js with its own registerStepFunctionLoader export, so the suite passes regardless of whether the real workflow meta-package re-exports it. That's why the missing re-export blocker (see packages/core/src/runtime.ts) isn't caught here.

Worth adding coverage that exercises the real public surface — e.g. assert registerStepFunctionLoader is present in the actual workflow/runtime exports, or regenerate + build a workbench route in CI so a missing public export fails the build.

`globalThis.__capturedStepLoaders = [];
export function registerStepFunctionLoader(stepId, loader) {
globalThis.__capturedStepLoaders.push({ stepId, loader });
}
export function workflowEntrypoint() {
return function POST() {
return new Response('ok');
};
}
`
);
writeFile(
join(testRoot, 'node_modules', 'workflow', 'internal', 'builtins.js'),
Expand Down Expand Up @@ -117,4 +155,121 @@ describe('step source registration', () => {
expect(generated).toContain('import "../src/serde.ts";');
expect(Object.keys(manifest.classes ?? {})).toContain('src/serde.ts');
});

it('registers lazy step loaders in combined routes', async () => {
const workflowFile = join(testRoot, 'workflows', 'image.ts');
const stepsOutfile = join(testRoot, '.workflow', '__step_registrations.js');
const flowOutfile = join(testRoot, '.workflow', 'route.js');

mkdirSync(dirname(flowOutfile), { recursive: true });
writeFile(
workflowFile,
`export async function imageWorkflow() {
'use workflow';
return resize();
}

export async function resize() {
'use step';
return 1;
}
`
);

const { stepsManifest } = await createBuilder(testRoot).createCombinedRoute(
[workflowFile],
stepsOutfile,
flowOutfile
);
const routeCode = readFileSync(flowOutfile, 'utf-8');
const stepIds = Object.values(stepsManifest.steps ?? {}).flatMap(
(entries) => Object.values(entries).map(({ stepId }) => stepId)
);

expect(stepIds).toHaveLength(1);
expect(routeCode).toContain(
"import { registerStepFunctionLoader, workflowEntrypoint } from 'workflow/runtime';"
);
expect(routeCode).toContain(
`registerStepFunctionLoader(${JSON.stringify(stepIds[0])}, () => import("./__step_registrations.js"));`
);
expect(routeCode).not.toContain('import { __steps_registered }');
expect(routeCode).not.toContain('void __steps_registered');
});

it('defers step registration module load failures until a step loader runs', async () => {
const workflowFile = join(testRoot, 'workflows', 'image.ts');
const stepsOutfile = join(testRoot, '.workflow', '__step_registrations.js');
const legacyStepsOutfile = join(
testRoot,
'.workflow',
'__legacy_step_registrations.js'
);
const legacyRouteOutfile = join(testRoot, '.workflow', 'legacy-route.js');
const flowOutfile = join(testRoot, '.workflow', 'route.js');
const sharpLoadError =
'Could not load the "sharp" module using the linux-x64 runtime';

mkdirSync(dirname(flowOutfile), { recursive: true });
writeFile(
workflowFile,
`export async function imageWorkflow() {
'use workflow';
return resize();
}

export async function resize() {
'use step';
return 1;
}
`
);
writeFile(
legacyStepsOutfile,
`throw new Error(${JSON.stringify(sharpLoadError)});
export const __steps_registered = true;
`
);
writeFile(
legacyRouteOutfile,
`import { __steps_registered } from './__legacy_step_registrations.js';
void __steps_registered;
export function POST() {}
`
);

await expect(
import(`${pathToFileURL(legacyRouteOutfile).href}?legacy=${Date.now()}`)
).rejects.toThrow(sharpLoadError);

const { stepsManifest } = await createBuilder(testRoot).createCombinedRoute(
[workflowFile],
stepsOutfile,
flowOutfile
);
const stepIds = Object.values(stepsManifest.steps ?? {}).flatMap(
(entries) => Object.values(entries).map(({ stepId }) => stepId)
);
writeFile(
stepsOutfile,
`throw new Error(${JSON.stringify(sharpLoadError)});
export const __steps_registered = true;
`
);

await expect(
import(`${pathToFileURL(flowOutfile).href}?fixed=${Date.now()}`)
).resolves.toHaveProperty('POST');

const loaders = (
globalThis as typeof globalThis & {
__capturedStepLoaders?: Array<{
stepId: string;
loader: () => Promise<unknown>;
}>;
}
).__capturedStepLoaders;
expect(loaders).toEqual([expect.objectContaining({ stepId: stepIds[0] })]);
await expect(loaders?.[0]?.loader()).rejects.toThrow(sharpLoadError);
});
});
Loading
Loading