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
6 changes: 6 additions & 0 deletions .changeset/precompile-workflow-script-builders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/builders': patch
'@workflow/next': patch
---

Pass the bundle's workflow source filenames to `workflowEntrypoint` in generated routes so the workflow VM script is precompiled at module-init time.
5 changes: 5 additions & 0 deletions .changeset/precompile-workflow-script.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@workflow/core': minor
---

Precompile the workflow bundle `vm.Script` at module-init time via a new optional `workflowFilenames` option on `workflowEntrypoint`, so the first queue delivery's replay skips the bundle parse/compile cost.
25 changes: 25 additions & 0 deletions packages/builders/src/apply-swc-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createRequire } from 'node:module';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { transform } from '@swc/core';
import { parseWorkflowName } from '@workflow/utils/parse-name';
import { getDecoratorOptionsForDirectory } from './config-helpers.js';
import { resolveModuleSpecifier } from './module-specifier.js';

Expand Down Expand Up @@ -47,6 +48,30 @@ export type WorkflowManifest = {
};
};

/**
* Derives the deduplicated, sorted set of workflow source filenames from a
* manifest, suitable for `workflowEntrypoint`'s `workflowFilenames` option.
*
* The runtime compiles each workflow bundle `vm.Script` under the filename
* `parseWorkflowName(workflowId)?.moduleSpecifier || workflowId`, so the
* filenames are derived from the `workflowId`s (not the manifest's relative
* filename keys, which may differ from the embedded module specifier) and
* deduplicated — the filename is per source file, not per workflow function.
*/
export function getWorkflowFilenamesFromManifest(
manifest: WorkflowManifest
): string[] {
const filenames = new Set<string>();
for (const fnEntries of Object.values(manifest.workflows ?? {})) {
for (const { workflowId } of Object.values(fnEntries)) {
filenames.add(
parseWorkflowName(workflowId)?.moduleSpecifier || workflowId
);
}
}
return [...filenames].sort();
}

export async function applySwcTransform(
filename: string,
source: string,
Expand Down
22 changes: 17 additions & 5 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { findUp } from 'find-up';
import { glob } from 'tinyglobby';
import {
applySwcTransform,
getWorkflowFilenamesFromManifest,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
Expand Down Expand Up @@ -1182,8 +1183,11 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
{
workflowFilenames: getWorkflowFilenamesFromManifest(workflowManifest),
}
);

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;
Expand Down Expand Up @@ -1373,7 +1377,12 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
// 3. Generate combined route file
const stepsRelativePath = './' + basename(stepsOutfile).replace(/\\/g, '/');
const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&');
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode();
const workflowFilenames = getWorkflowFilenamesFromManifest(
workflowsResult.manifest
);
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({
workflowFilenames,
});

const combinedFunctionCode = `// biome-ignore-all lint: generated file
/* eslint-disable */
Expand Down Expand Up @@ -1446,8 +1455,11 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
// Create a custom bundleFinal for watch mode that uses workflowEntrypoint
const combinedBundleFinal = async (interimBundleText: string) => {
const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&');
const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
{
workflowFilenames,
}
);
const code = `// biome-ignore-all lint: generated file
/* eslint-disable */
import { __steps_registered } from '${stepsRelativePath}';
Expand Down
23 changes: 23 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,27 @@ describe('createWorkflowEntrypointOptionsCode', () => {
', { namespace: "custom" }'
);
});

it('inlines workflowFilenames, deduplicated and sorted', () => {
expect(
createWorkflowEntrypointOptionsCode({
workflowFilenames: ['./b.ts', './a.ts', './b.ts'],
})
).toBe(', { workflowFilenames: ["./a.ts","./b.ts"] }');
});

it('omits workflowFilenames when the list is empty', () => {
expect(createWorkflowEntrypointOptionsCode({ workflowFilenames: [] })).toBe(
''
);
});

it('combines namespace and workflowFilenames', () => {
expect(
createWorkflowEntrypointOptionsCode({
namespace: 'custom',
workflowFilenames: ['./a.ts'],
})
).toBe(', { namespace: "custom", workflowFilenames: ["./a.ts"] }');
});
});
28 changes: 23 additions & 5 deletions packages/builders/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,38 @@ export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*
* When `workflowFilenames` is provided, the deduplicated, sorted list is
* inlined so the runtime can precompile the bundle's `vm.Script` for each
* source filename at module-init time (warming the cache before the first
* queue delivery's replay). Sorting keeps the generated route file stable
* across builds.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
workflowFilenames?: string[];
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
const optionParts: string[] = [];

if (namespace) {
// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);
optionParts.push(`namespace: ${JSON.stringify(namespace)}`);
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);
const workflowFilenames = options?.workflowFilenames;
if (workflowFilenames && workflowFilenames.length > 0) {
const sorted = [...new Set(workflowFilenames)].sort();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor/nit: getWorkflowFilenamesFromManifest already returns a deduped, sorted array, so for the builder call sites this new Set(...).sort() is redundant work. It's harmless and reasonable as defensive normalization for direct callers of createWorkflowEntrypointOptionsCode — just flagging the double dedup/sort in case you'd rather pick one layer to own it.

optionParts.push(`workflowFilenames: ${JSON.stringify(sorted)}`);
}

if (optionParts.length === 0) {
return '';
}

return `, { namespace: ${JSON.stringify(namespace)} }`;
return `, { ${optionParts.join(', ')} }`;
}

/**
Expand Down
78 changes: 78 additions & 0 deletions packages/builders/src/get-workflow-filenames.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
getWorkflowFilenamesFromManifest,
type WorkflowManifest,
} from './apply-swc-transform.js';

describe('getWorkflowFilenamesFromManifest', () => {
it('returns an empty array for a manifest with no workflows', () => {
expect(getWorkflowFilenamesFromManifest({})).toEqual([]);
expect(getWorkflowFilenamesFromManifest({ workflows: {} })).toEqual([]);
});

it('derives filenames from each workflowId module specifier', () => {
const manifest: WorkflowManifest = {
workflows: {
'./src/jobs/order.ts': {
processOrder: {
workflowId: 'workflow//./src/jobs/order//processOrder',
},
},
},
};
expect(getWorkflowFilenamesFromManifest(manifest)).toEqual([
'./src/jobs/order',
]);
});

it('deduplicates filenames across multiple functions in the same file', () => {
// Two workflow functions in the same source file share one module
// specifier, so the precompile target is a single filename — not one per
// function.
const manifest: WorkflowManifest = {
workflows: {
'./src/jobs/order.ts': {
processOrder: {
workflowId: 'workflow//./src/jobs/order//processOrder',
},
cancelOrder: {
workflowId: 'workflow//./src/jobs/order//cancelOrder',
},
},
},
};
expect(getWorkflowFilenamesFromManifest(manifest)).toEqual([
'./src/jobs/order',
]);
});

it('returns a sorted set across multiple files', () => {
const manifest: WorkflowManifest = {
workflows: {
'./src/b.ts': {
b: { workflowId: 'workflow//./src/b//b' },
},
'./src/a.ts': {
a: { workflowId: 'workflow//./src/a//a' },
},
},
};
expect(getWorkflowFilenamesFromManifest(manifest)).toEqual([
'./src/a',
'./src/b',
]);
});

it('falls back to the raw workflowId when it is not a parseable name', () => {
const manifest: WorkflowManifest = {
workflows: {
'./src/weird.ts': {
weird: { workflowId: 'not-a-workflow-name' },
},
},
};
expect(getWorkflowFilenamesFromManifest(manifest)).toEqual([
'not-a-workflow-name',
]);
});
});
5 changes: 4 additions & 1 deletion packages/builders/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export type { WorkflowManifest } from './apply-swc-transform.js';
export { applySwcTransform } from './apply-swc-transform.js';
export {
applySwcTransform,
getWorkflowFilenamesFromManifest,
} from './apply-swc-transform.js';
export { BaseBuilder } from './base-builder.js';
export { createBuildQueue } from './build-queue.js';
export {
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
dehydrateStepReturnValue,
dehydrateWorkflowArguments,
} from './serialization.js';
import {
clearWorkflowScriptCache,
isWorkflowScriptCached,
} from './vm/script-cache.js';

// Capture every promise handed to `waitUntil` so tests can assert that
// progress-critical sends are never registered on a detached, unconsumed
Expand Down Expand Up @@ -1182,3 +1186,45 @@ describe('workflowEntrypoint step-dispatch ack ordering', () => {
expect(await anyWaitUntilPromiseRejected()).toBe(false);
});
});

describe('workflowEntrypoint module-init script precompile', () => {
afterEach(() => {
clearWorkflowScriptCache();
});

// A unique bundle string per test so cache state can't leak between tests.
const uniqueBundle = (marker: string) =>
`;globalThis.__private_workflows = new Map(); /* ${marker} */`;

it('precompiles the bundle Script for each provided workflow filename at construction time', () => {
const code = uniqueBundle('with-filenames');
expect(isWorkflowScriptCached(code, './src/a')).toBe(false);
expect(isWorkflowScriptCached(code, './src/b')).toBe(false);

// Constructing the entrypoint should warm the cache — no queue delivery
// required.
workflowEntrypoint(code, {
workflowFilenames: ['./src/a', './src/b'],
});

expect(isWorkflowScriptCached(code, './src/a')).toBe(true);
expect(isWorkflowScriptCached(code, './src/b')).toBe(true);
});

it('precompiles under a fallback filename when no workflow filenames are given', () => {
const code = uniqueBundle('no-filenames');

workflowEntrypoint(code);

// The fallback filename is an internal detail, but the cache must hold
// exactly one entry for this bundle (the bulk parse was paid eagerly).
expect(isWorkflowScriptCached(code, '<workflow-bundle>')).toBe(true);
});

it('does not throw at construction time when the bundle has a syntax error', () => {
// Precompile failures must never break module init / route construction.
expect(() =>
workflowEntrypoint('function (', { workflowFilenames: ['./src/broken'] })
).not.toThrow();
});
});
Loading
Loading