Skip to content

Commit b5296a7

Browse files
authored
Add discovered serializable classes in all context modes (#874)
This PR ensures that all classes with custom serialization are automatically included in all bundle contexts (step, workflow, client) to ensure proper serialization/deserialization when crossing execution boundaries: - Classes defined in any context can now be properly serialized when passing data between: - Client → Workflow (when starting workflows) - Workflow → Step (when calling steps) - Step → Workflow (when returning step results) - Workflow → Client (when returning workflow results) - The build system now automatically discovers all files containing serializable classes and includes them in each bundle, regardless of where the class is originally defined. - No manual configuration is required - cross-registration happens automatically during the build process.
1 parent 38e8d55 commit b5296a7

File tree

16 files changed

+412
-55
lines changed

16 files changed

+412
-55
lines changed

.changeset/khaki-breads-wave.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@workflow/swc-plugin": patch
3+
"@workflow/builders": patch
4+
---
5+
6+
Add discovered serializable classes in all context modes

packages/builders/src/base-builder.ts

Lines changed: 121 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export abstract class BaseBuilder {
9292
{
9393
discoveredSteps: string[];
9494
discoveredWorkflows: string[];
95+
discoveredSerdeFiles: string[];
9596
}
9697
> = new WeakMap();
9798

@@ -101,6 +102,7 @@ export abstract class BaseBuilder {
101102
): Promise<{
102103
discoveredSteps: string[];
103104
discoveredWorkflows: string[];
105+
discoveredSerdeFiles: string[];
104106
}> {
105107
const previousResult = this.discoveredEntries.get(inputs);
106108

@@ -110,9 +112,11 @@ export abstract class BaseBuilder {
110112
const state: {
111113
discoveredSteps: string[];
112114
discoveredWorkflows: string[];
115+
discoveredSerdeFiles: string[];
113116
} = {
114117
discoveredSteps: [],
115118
discoveredWorkflows: [],
119+
discoveredSerdeFiles: [],
116120
};
117121

118122
const discoverStart = Date.now();
@@ -277,11 +281,24 @@ export abstract class BaseBuilder {
277281
}> {
278282
// These need to handle watching for dev to scan for
279283
// new entries and changes to existing ones
280-
const { discoveredSteps: stepFiles, discoveredWorkflows: workflowFiles } =
281-
await this.discoverEntries(inputFiles, dirname(outfile));
284+
const {
285+
discoveredSteps: stepFiles,
286+
discoveredWorkflows: workflowFiles,
287+
discoveredSerdeFiles: serdeFiles,
288+
} = await this.discoverEntries(inputFiles, dirname(outfile));
289+
290+
// Include serde files that aren't already step files for cross-context class registration.
291+
// Classes need to be registered in the step bundle so they can be deserialized
292+
// when receiving data from workflows and serialized when returning data to workflows.
293+
const stepFilesSet = new Set(stepFiles);
294+
const serdeOnlyFiles = serdeFiles.filter((f) => !stepFilesSet.has(f));
282295

283296
// log the step files for debugging
284-
await this.writeDebugFile(outfile, { stepFiles, workflowFiles });
297+
await this.writeDebugFile(outfile, {
298+
stepFiles,
299+
workflowFiles,
300+
serdeOnlyFiles,
301+
});
285302

286303
const stepsBundleStart = Date.now();
287304
const workflowManifest: WorkflowManifest = {};
@@ -301,32 +318,38 @@ export abstract class BaseBuilder {
301318
);
302319
});
303320

321+
// Helper to create import statement from file path
322+
const createImport = (file: string) => {
323+
// Normalize both paths to forward slashes before calling relative()
324+
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
325+
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
326+
const normalizedFile = file.replace(/\\/g, '/');
327+
// Calculate relative path from working directory to the file
328+
let relativePath = relative(normalizedWorkingDir, normalizedFile).replace(
329+
/\\/g,
330+
'/'
331+
);
332+
// Ensure relative paths start with ./ so esbuild resolves them correctly
333+
if (!relativePath.startsWith('.')) {
334+
relativePath = `./${relativePath}`;
335+
}
336+
return `import '${relativePath}';`;
337+
};
338+
304339
// Create a virtual entry that imports all files. All step definitions
305340
// will get registered thanks to the swc transform.
306-
const imports = stepFiles
307-
.map((file) => {
308-
// Normalize both paths to forward slashes before calling relative()
309-
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
310-
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
311-
const normalizedFile = file.replace(/\\/g, '/');
312-
// Calculate relative path from working directory to the file
313-
let relativePath = relative(
314-
normalizedWorkingDir,
315-
normalizedFile
316-
).replace(/\\/g, '/');
317-
// Ensure relative paths start with ./ so esbuild resolves them correctly
318-
if (!relativePath.startsWith('.')) {
319-
relativePath = `./${relativePath}`;
320-
}
321-
return `import '${relativePath}';`;
322-
})
323-
.join('\n');
341+
const stepImports = stepFiles.map(createImport).join('\n');
342+
343+
// Include serde-only files for class registration side effects
344+
const serdeImports = serdeOnlyFiles.map(createImport).join('\n');
324345

325346
const entryContent = `
326347
// Built in steps
327348
import '${builtInSteps}';
328349
// User steps
329-
${imports}
350+
${stepImports}
351+
// Serde files for cross-context class registration
352+
${serdeImports}
330353
// API entrypoint
331354
export { stepEntrypoint as POST } from 'workflow/runtime';`;
332355

@@ -467,35 +490,49 @@ export abstract class BaseBuilder {
467490
interimBundleCtx: esbuild.BuildContext;
468491
bundleFinal: (interimBundleResult: string) => Promise<void>;
469492
}> {
470-
const { discoveredWorkflows: workflowFiles } = await this.discoverEntries(
471-
inputFiles,
472-
dirname(outfile)
473-
);
493+
const {
494+
discoveredWorkflows: workflowFiles,
495+
discoveredSerdeFiles: serdeFiles,
496+
} = await this.discoverEntries(inputFiles, dirname(outfile));
497+
498+
// Include serde files that aren't already workflow files for cross-context class registration.
499+
// Classes need to be registered in the workflow bundle so they can be deserialized
500+
// when receiving data from steps or when serializing data to send to steps.
501+
const workflowFilesSet = new Set(workflowFiles);
502+
const serdeOnlyFiles = serdeFiles.filter((f) => !workflowFilesSet.has(f));
474503

475504
// log the workflow files for debugging
476-
await this.writeDebugFile(outfile, { workflowFiles });
505+
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });
506+
507+
// Helper to create import statement from file path
508+
const createImport = (file: string) => {
509+
// Normalize both paths to forward slashes before calling relative()
510+
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
511+
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
512+
const normalizedFile = file.replace(/\\/g, '/');
513+
// Calculate relative path from working directory to the file
514+
let relativePath = relative(normalizedWorkingDir, normalizedFile).replace(
515+
/\\/g,
516+
'/'
517+
);
518+
// Ensure relative paths start with ./ so esbuild resolves them correctly
519+
if (!relativePath.startsWith('.')) {
520+
relativePath = `./${relativePath}`;
521+
}
522+
return `import '${relativePath}';`;
523+
};
477524

478525
// Create a virtual entry that imports all workflow files
479526
// The SWC plugin in workflow mode emits `globalThis.__private_workflows.set(workflowId, fn)`
480527
// calls directly, so we just need to import the files (Map is initialized via banner)
481-
const imports = workflowFiles
482-
.map((file) => {
483-
// Normalize both paths to forward slashes before calling relative()
484-
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
485-
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
486-
const normalizedFile = file.replace(/\\/g, '/');
487-
// Calculate relative path from working directory to the file
488-
let relativePath = relative(
489-
normalizedWorkingDir,
490-
normalizedFile
491-
).replace(/\\/g, '/');
492-
// Ensure relative paths start with ./ so esbuild resolves them correctly
493-
if (!relativePath.startsWith('.')) {
494-
relativePath = `./${relativePath}`;
495-
}
496-
return `import '${relativePath}';`;
497-
})
498-
.join('\n');
528+
const workflowImports = workflowFiles.map(createImport).join('\n');
529+
530+
// Include serde-only files for class registration side effects
531+
const serdeImports = serdeOnlyFiles.map(createImport).join('\n');
532+
533+
const imports = serdeImports
534+
? `${workflowImports}\n// Serde files for cross-context class registration\n${serdeImports}`
535+
: workflowImports;
499536

500537
const bundleStartTime = Date.now();
501538
const workflowManifest: WorkflowManifest = {};
@@ -697,18 +734,54 @@ export const POST = workflowEntrypoint(workflowCode);`;
697734

698735
const inputFiles = await this.getInputFiles();
699736

700-
// Create a virtual entry that imports all files
701-
const imports = inputFiles
737+
// Discover serde files from the input files' dependency tree for cross-context class registration.
738+
// Classes need to be registered in the client bundle so they can be serialized
739+
// when passing data to workflows via start() and deserialized when receiving workflow results.
740+
const { discoveredSerdeFiles } = await this.discoverEntries(
741+
inputFiles,
742+
outputDir
743+
);
744+
745+
// Identify serde files that aren't in the inputFiles (deduplicated)
746+
const inputFilesNormalized = new Set(
747+
inputFiles.map((f) => f.replace(/\\/g, '/'))
748+
);
749+
const serdeOnlyFiles = discoveredSerdeFiles.filter(
750+
(f) => !inputFilesNormalized.has(f)
751+
);
752+
753+
// Re-exports for input files (user's workflow/step definitions)
754+
const reexports = inputFiles
702755
.map((file) => `export * from '${file}';`)
703756
.join('\n');
704757

758+
// Side-effect imports for serde files not in inputFiles (for class registration)
759+
const serdeImports = serdeOnlyFiles
760+
.map((file) => {
761+
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
762+
let relativePath = relative(normalizedWorkingDir, file).replace(
763+
/\\/g,
764+
'/'
765+
);
766+
if (!relativePath.startsWith('.')) {
767+
relativePath = `./${relativePath}`;
768+
}
769+
return `import '${relativePath}';`;
770+
})
771+
.join('\n');
772+
773+
// Combine: serde imports (for registration side effects) + re-exports
774+
const entryContent = serdeImports
775+
? `// Serde files for cross-context class registration\n${serdeImports}\n${reexports}`
776+
: reexports;
777+
705778
// Bundle with esbuild and our custom SWC plugin
706779
const clientResult = await esbuild.build({
707780
banner: {
708781
js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
709782
},
710783
stdin: {
711-
contents: imports,
784+
contents: entryContent,
712785
resolveDir: this.config.workingDir,
713786
sourcefile: 'virtual-entry.js',
714787
loader: 'js',

packages/builders/src/discover-entries-esbuild-plugin.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export function parentHasChild(parent: string, childToFind: string) {
4646
export function createDiscoverEntriesPlugin(state: {
4747
discoveredSteps: string[];
4848
discoveredWorkflows: string[];
49+
discoveredSerdeFiles: string[];
4950
}): Plugin {
5051
return {
5152
name: 'discover-entries-esbuild-plugin',
@@ -102,13 +103,14 @@ export function createDiscoverEntriesPlugin(state: {
102103
state.discoveredSteps.push(normalizedPath);
103104
}
104105

105-
// Files with serde patterns are treated like step files so they get
106-
// bundled and transformed, which registers serialization classes.
107-
// However, skip @workflow SDK packages for serde-only detection since those
108-
// are internal implementation files (like serialization.js) that shouldn't
109-
// be treated as user entry points.
110-
if (patterns.hasSerde && !patterns.hasUseStep && !isSdkFile) {
111-
state.discoveredSteps.push(normalizedPath);
106+
// Track all serde files separately for cross-context class registration.
107+
// Classes need to be registered in all bundle contexts (step, workflow, client)
108+
// to support serialization across execution boundaries.
109+
// Skip @workflow SDK packages since those are internal implementation files.
110+
if (patterns.hasSerde && !isSdkFile) {
111+
if (!state.discoveredSerdeFiles.includes(normalizedPath)) {
112+
state.discoveredSerdeFiles.push(normalizedPath);
113+
}
112114
}
113115

114116
const { code: transformedCode } = await applySwcTransform(

packages/core/e2e/e2e.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,6 +1416,59 @@ describe('e2e', () => {
14161416
}
14171417
);
14181418

1419+
test(
1420+
'crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context',
1421+
{ timeout: 60_000 },
1422+
async () => {
1423+
// This is a critical test for the cross-context class registration feature.
1424+
//
1425+
// The Vector class is defined in serde-models.ts and ONLY imported by step code
1426+
// (serde-steps.ts). The workflow code (99_e2e.ts) does NOT import Vector directly.
1427+
//
1428+
// Without cross-context class registration, this test would fail because:
1429+
// - The workflow bundle wouldn't have Vector registered (never imported it)
1430+
// - The workflow couldn't deserialize Vector instances returned from steps
1431+
//
1432+
// With cross-context class registration:
1433+
// - The build system discovers serde-models.ts has serialization patterns
1434+
// - It includes serde-models.ts in ALL bundle contexts (step, workflow, client)
1435+
// - Vector is registered everywhere, enabling full round-trip serialization
1436+
//
1437+
// Test flow:
1438+
// 1. Step creates Vector(1, 2, 3) and returns it (step serializes)
1439+
// 2. Workflow receives Vector (workflow MUST deserialize - key test!)
1440+
// 3. Workflow passes Vector to another step (workflow serializes)
1441+
// 4. Step receives Vector and operates on it (step deserializes)
1442+
// 5. Workflow returns plain objects to client (no client deserialization needed)
1443+
//
1444+
// The critical part is step 2: the workflow code never imports Vector,
1445+
// so without cross-context registration it wouldn't know how to deserialize it.
1446+
1447+
const run = await triggerWorkflow('crossContextSerdeWorkflow', []);
1448+
const returnValue = await getWorkflowReturnValue(run.runId);
1449+
1450+
// Verify all the vector operations worked correctly
1451+
expect(returnValue).toEqual({
1452+
// v1 created in step: (1, 2, 3)
1453+
v1: { x: 1, y: 2, z: 3 },
1454+
// v2 created in step: (10, 20, 30)
1455+
v2: { x: 10, y: 20, z: 30 },
1456+
// sum of v1 + v2: (11, 22, 33)
1457+
sum: { x: 11, y: 22, z: 33 },
1458+
// v1 scaled by 5: (5, 10, 15)
1459+
scaled: { x: 5, y: 10, z: 15 },
1460+
// Array sum of v1 + v2 + scaled: (16, 32, 48)
1461+
arraySum: { x: 16, y: 32, z: 48 },
1462+
});
1463+
1464+
// Verify the run completed successfully
1465+
const { json: runData } = await cliInspectJson(
1466+
`runs ${run.runId} --withData`
1467+
);
1468+
expect(runData.status).toBe('completed');
1469+
}
1470+
);
1471+
14191472
// ==================== PAGES ROUTER TESTS ====================
14201473
// Tests for Next.js Pages Router API endpoint (only runs for nextjs-turbopack and nextjs-webpack)
14211474
const isNextJsApp =

packages/swc-plugin-workflow/spec.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,26 @@ Files containing classes with custom serialization are automatically discovered
537537

538538
This allows serialization classes to be defined in separate files (such as Next.js API routes or utility modules) and still be registered in the serialization system when the application is built.
539539

540+
### Cross-Context Class Registration
541+
542+
Classes with custom serialization are automatically included in **all bundle contexts** (step, workflow, client) to ensure they can be properly serialized and deserialized when crossing execution boundaries:
543+
544+
| Boundary | Serializer | Deserializer | Example |
545+
|----------|------------|--------------|---------|
546+
| Client → Workflow | Client mode | Workflow mode | Passing a `Point` instance to `start(workflow)` |
547+
| Workflow → Step | Workflow mode | Step mode | Passing a `Point` instance as step argument |
548+
| Step → Workflow | Step mode | Workflow mode | Returning a `Point` instance from a step |
549+
| Workflow → Client | Workflow mode | Client mode | Returning a `Point` instance from a workflow |
550+
551+
The build system automatically discovers all files containing serializable classes and includes them in each bundle, regardless of where the class is originally defined. This ensures the class registry has all necessary classes for any serialization boundary the data may cross.
552+
553+
For example, if a class `Point` is defined in `models/point.ts` and only used in step code:
554+
- The **step bundle** includes `Point` because the step file imports it
555+
- The **workflow bundle** also includes `Point` so it can deserialize step return values
556+
- The **client bundle** also includes `Point` so it can deserialize workflow return values
557+
558+
This cross-registration happens automatically during the build process - no manual configuration is required.
559+
540560
---
541561

542562
## Default Exports

0 commit comments

Comments
 (0)