Skip to content
Merged
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/khaki-breads-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workflow/swc-plugin": patch
"@workflow/builders": patch
---

Add discovered serializable classes in all context modes
169 changes: 121 additions & 48 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export abstract class BaseBuilder {
{
discoveredSteps: string[];
discoveredWorkflows: string[];
discoveredSerdeFiles: string[];
}
> = new WeakMap();

Expand All @@ -101,6 +102,7 @@ export abstract class BaseBuilder {
): Promise<{
discoveredSteps: string[];
discoveredWorkflows: string[];
discoveredSerdeFiles: string[];
}> {
const previousResult = this.discoveredEntries.get(inputs);

Expand All @@ -110,9 +112,11 @@ export abstract class BaseBuilder {
const state: {
discoveredSteps: string[];
discoveredWorkflows: string[];
discoveredSerdeFiles: string[];
} = {
discoveredSteps: [],
discoveredWorkflows: [],
discoveredSerdeFiles: [],
};

const discoverStart = Date.now();
Expand Down Expand Up @@ -277,11 +281,24 @@ export abstract class BaseBuilder {
}> {
// These need to handle watching for dev to scan for
// new entries and changes to existing ones
const { discoveredSteps: stepFiles, discoveredWorkflows: workflowFiles } =
await this.discoverEntries(inputFiles, dirname(outfile));
const {
discoveredSteps: stepFiles,
discoveredWorkflows: workflowFiles,
discoveredSerdeFiles: serdeFiles,
} = await this.discoverEntries(inputFiles, dirname(outfile));

// Include serde files that aren't already step files for cross-context class registration.
// Classes need to be registered in the step bundle so they can be deserialized
// when receiving data from workflows and serialized when returning data to workflows.
const stepFilesSet = new Set(stepFiles);
const serdeOnlyFiles = serdeFiles.filter((f) => !stepFilesSet.has(f));

// log the step files for debugging
await this.writeDebugFile(outfile, { stepFiles, workflowFiles });
await this.writeDebugFile(outfile, {
stepFiles,
workflowFiles,
serdeOnlyFiles,
});

const stepsBundleStart = Date.now();
const workflowManifest: WorkflowManifest = {};
Expand All @@ -301,32 +318,38 @@ export abstract class BaseBuilder {
);
});

// Helper to create import statement from file path
const createImport = (file: string) => {
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
const normalizedFile = file.replace(/\\/g, '/');
// Calculate relative path from working directory to the file
let relativePath = relative(normalizedWorkingDir, normalizedFile).replace(
/\\/g,
'/'
);
// Ensure relative paths start with ./ so esbuild resolves them correctly
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}
return `import '${relativePath}';`;
};
Comment on lines +322 to +337
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The createImport helper function is duplicated three times in this file (here at lines 320-335, at lines 503-518 for workflow bundle, and at lines 746-756 for client bundle). Consider extracting this into a private class method to improve maintainability and ensure consistency across all three bundle contexts. This would make the code DRY and easier to maintain if the path normalization logic needs to be updated in the future.

Copilot uses AI. Check for mistakes.

// Create a virtual entry that imports all files. All step definitions
// will get registered thanks to the swc transform.
const imports = stepFiles
.map((file) => {
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
const normalizedFile = file.replace(/\\/g, '/');
// Calculate relative path from working directory to the file
let relativePath = relative(
normalizedWorkingDir,
normalizedFile
).replace(/\\/g, '/');
// Ensure relative paths start with ./ so esbuild resolves them correctly
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}
return `import '${relativePath}';`;
})
.join('\n');
const stepImports = stepFiles.map(createImport).join('\n');

// Include serde-only files for class registration side effects
const serdeImports = serdeOnlyFiles.map(createImport).join('\n');

const entryContent = `
// Built in steps
import '${builtInSteps}';
// User steps
${imports}
${stepImports}
// Serde files for cross-context class registration
${serdeImports}
Comment on lines 325 to +352
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The entry content generation here always includes the "Serde files for cross-context class registration" comment even when serdeImports is empty (when serdeOnlyFiles is empty). For consistency with the workflow bundle implementation (lines 528-530), consider using a conditional approach to only include the comment when there are actual serde imports.

Copilot uses AI. Check for mistakes.
// API entrypoint
export { stepEntrypoint as POST } from 'workflow/runtime';`;

Expand Down Expand Up @@ -467,35 +490,49 @@ export abstract class BaseBuilder {
interimBundleCtx: esbuild.BuildContext;
bundleFinal: (interimBundleResult: string) => Promise<void>;
}> {
const { discoveredWorkflows: workflowFiles } = await this.discoverEntries(
inputFiles,
dirname(outfile)
);
const {
discoveredWorkflows: workflowFiles,
discoveredSerdeFiles: serdeFiles,
} = await this.discoverEntries(inputFiles, dirname(outfile));

// Include serde files that aren't already workflow files for cross-context class registration.
// Classes need to be registered in the workflow bundle so they can be deserialized
// when receiving data from steps or when serializing data to send to steps.
const workflowFilesSet = new Set(workflowFiles);
const serdeOnlyFiles = serdeFiles.filter((f) => !workflowFilesSet.has(f));

// log the workflow files for debugging
await this.writeDebugFile(outfile, { workflowFiles });
await this.writeDebugFile(outfile, { workflowFiles, serdeOnlyFiles });

// Helper to create import statement from file path
const createImport = (file: string) => {
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
const normalizedFile = file.replace(/\\/g, '/');
// Calculate relative path from working directory to the file
let relativePath = relative(normalizedWorkingDir, normalizedFile).replace(
/\\/g,
'/'
);
// Ensure relative paths start with ./ so esbuild resolves them correctly
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}
return `import '${relativePath}';`;
};

// Create a virtual entry that imports all workflow files
// The SWC plugin in workflow mode emits `globalThis.__private_workflows.set(workflowId, fn)`
// calls directly, so we just need to import the files (Map is initialized via banner)
const imports = workflowFiles
.map((file) => {
// Normalize both paths to forward slashes before calling relative()
// This is critical on Windows where relative() can produce unexpected results with mixed path formats
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
const normalizedFile = file.replace(/\\/g, '/');
// Calculate relative path from working directory to the file
let relativePath = relative(
normalizedWorkingDir,
normalizedFile
).replace(/\\/g, '/');
// Ensure relative paths start with ./ so esbuild resolves them correctly
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}
return `import '${relativePath}';`;
})
.join('\n');
const workflowImports = workflowFiles.map(createImport).join('\n');

// Include serde-only files for class registration side effects
const serdeImports = serdeOnlyFiles.map(createImport).join('\n');

const imports = serdeImports
? `${workflowImports}\n// Serde files for cross-context class registration\n${serdeImports}`
: workflowImports;

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

const inputFiles = await this.getInputFiles();

// Create a virtual entry that imports all files
const imports = inputFiles
// Discover serde files from the input files' dependency tree for cross-context class registration.
// Classes need to be registered in the client bundle so they can be serialized
// when passing data to workflows via start() and deserialized when receiving workflow results.
const { discoveredSerdeFiles } = await this.discoverEntries(
inputFiles,
outputDir
);

// Identify serde files that aren't in the inputFiles (deduplicated)
const inputFilesNormalized = new Set(
inputFiles.map((f) => f.replace(/\\/g, '/'))
);
const serdeOnlyFiles = discoveredSerdeFiles.filter(
(f) => !inputFilesNormalized.has(f)
);

// Re-exports for input files (user's workflow/step definitions)
const reexports = inputFiles
.map((file) => `export * from '${file}';`)
.join('\n');

// Side-effect imports for serde files not in inputFiles (for class registration)
const serdeImports = serdeOnlyFiles
.map((file) => {
const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/');
let relativePath = relative(normalizedWorkingDir, file).replace(
/\\/g,
'/'
);
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`;
}
return `import '${relativePath}';`;
})
.join('\n');

// Combine: serde imports (for registration side effects) + re-exports
const entryContent = serdeImports
? `// Serde files for cross-context class registration\n${serdeImports}\n${reexports}`
: reexports;

// Bundle with esbuild and our custom SWC plugin
const clientResult = await esbuild.build({
banner: {
js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n',
},
stdin: {
contents: imports,
contents: entryContent,
resolveDir: this.config.workingDir,
sourcefile: 'virtual-entry.js',
loader: 'js',
Expand Down
16 changes: 9 additions & 7 deletions packages/builders/src/discover-entries-esbuild-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function parentHasChild(parent: string, childToFind: string) {
export function createDiscoverEntriesPlugin(state: {
discoveredSteps: string[];
discoveredWorkflows: string[];
discoveredSerdeFiles: string[];
}): Plugin {
return {
name: 'discover-entries-esbuild-plugin',
Expand Down Expand Up @@ -102,13 +103,14 @@ export function createDiscoverEntriesPlugin(state: {
state.discoveredSteps.push(normalizedPath);
}

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

const { code: transformedCode } = await applySwcTransform(
Expand Down
53 changes: 53 additions & 0 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,59 @@ describe('e2e', () => {
}
);

test(
'crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context',
{ timeout: 60_000 },
async () => {
// This is a critical test for the cross-context class registration feature.
//
// The Vector class is defined in serde-models.ts and ONLY imported by step code
// (serde-steps.ts). The workflow code (99_e2e.ts) does NOT import Vector directly.
//
// Without cross-context class registration, this test would fail because:
// - The workflow bundle wouldn't have Vector registered (never imported it)
// - The workflow couldn't deserialize Vector instances returned from steps
//
// With cross-context class registration:
// - The build system discovers serde-models.ts has serialization patterns
// - It includes serde-models.ts in ALL bundle contexts (step, workflow, client)
// - Vector is registered everywhere, enabling full round-trip serialization
//
// Test flow:
// 1. Step creates Vector(1, 2, 3) and returns it (step serializes)
// 2. Workflow receives Vector (workflow MUST deserialize - key test!)
// 3. Workflow passes Vector to another step (workflow serializes)
// 4. Step receives Vector and operates on it (step deserializes)
// 5. Workflow returns plain objects to client (no client deserialization needed)
//
// The critical part is step 2: the workflow code never imports Vector,
// so without cross-context registration it wouldn't know how to deserialize it.

const run = await triggerWorkflow('crossContextSerdeWorkflow', []);
const returnValue = await getWorkflowReturnValue(run.runId);

// Verify all the vector operations worked correctly
expect(returnValue).toEqual({
// v1 created in step: (1, 2, 3)
v1: { x: 1, y: 2, z: 3 },
// v2 created in step: (10, 20, 30)
v2: { x: 10, y: 20, z: 30 },
// sum of v1 + v2: (11, 22, 33)
sum: { x: 11, y: 22, z: 33 },
// v1 scaled by 5: (5, 10, 15)
scaled: { x: 5, y: 10, z: 15 },
// Array sum of v1 + v2 + scaled: (16, 32, 48)
arraySum: { x: 16, y: 32, z: 48 },
});

// Verify the run completed successfully
const { json: runData } = await cliInspectJson(
`runs ${run.runId} --withData`
);
Comment on lines 1416 to +1467
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

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

The test verifies cross-context serialization for workflow → step and step → workflow boundaries, but the workflow intentionally returns plain objects instead of Vector instances (lines 1142-1148), so it doesn't test client-side deserialization. Consider adding a test case that returns a Vector instance from the workflow to fully validate the "Workflow → Client" boundary mentioned in the PR description and documentation (spec.md lines 544-549). This would provide more complete coverage of the cross-context class registration feature.

Copilot uses AI. Check for mistakes.
expect(runData.status).toBe('completed');
}
);

// ==================== PAGES ROUTER TESTS ====================
// Tests for Next.js Pages Router API endpoint (only runs for nextjs-turbopack and nextjs-webpack)
const isNextJsApp =
Expand Down
20 changes: 20 additions & 0 deletions packages/swc-plugin-workflow/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,26 @@ Files containing classes with custom serialization are automatically discovered

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.

### Cross-Context Class Registration

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:

| Boundary | Serializer | Deserializer | Example |
|----------|------------|--------------|---------|
| Client → Workflow | Client mode | Workflow mode | Passing a `Point` instance to `start(workflow)` |
| Workflow → Step | Workflow mode | Step mode | Passing a `Point` instance as step argument |
| Step → Workflow | Step mode | Workflow mode | Returning a `Point` instance from a step |
| Workflow → Client | Workflow mode | Client mode | Returning a `Point` instance from a workflow |

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.

For example, if a class `Point` is defined in `models/point.ts` and only used in step code:
- The **step bundle** includes `Point` because the step file imports it
- The **workflow bundle** also includes `Point` so it can deserialize step return values
- The **client bundle** also includes `Point` so it can deserialize workflow return values

This cross-registration happens automatically during the build process - no manual configuration is required.

---

## Default Exports
Expand Down
Loading
Loading