diff --git a/.changeset/khaki-breads-wave.md b/.changeset/khaki-breads-wave.md new file mode 100644 index 0000000000..f89b27b0ed --- /dev/null +++ b/.changeset/khaki-breads-wave.md @@ -0,0 +1,6 @@ +--- +"@workflow/swc-plugin": patch +"@workflow/builders": patch +--- + +Add discovered serializable classes in all context modes diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 7bc5e5503e..ab6538b6c9 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -92,6 +92,7 @@ export abstract class BaseBuilder { { discoveredSteps: string[]; discoveredWorkflows: string[]; + discoveredSerdeFiles: string[]; } > = new WeakMap(); @@ -101,6 +102,7 @@ export abstract class BaseBuilder { ): Promise<{ discoveredSteps: string[]; discoveredWorkflows: string[]; + discoveredSerdeFiles: string[]; }> { const previousResult = this.discoveredEntries.get(inputs); @@ -110,9 +112,11 @@ export abstract class BaseBuilder { const state: { discoveredSteps: string[]; discoveredWorkflows: string[]; + discoveredSerdeFiles: string[]; } = { discoveredSteps: [], discoveredWorkflows: [], + discoveredSerdeFiles: [], }; const discoverStart = Date.now(); @@ -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 = {}; @@ -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}';`; + }; + // 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} // API entrypoint export { stepEntrypoint as POST } from 'workflow/runtime';`; @@ -467,35 +490,49 @@ export abstract class BaseBuilder { interimBundleCtx: esbuild.BuildContext; bundleFinal: (interimBundleResult: string) => Promise; }> { - 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 = {}; @@ -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', diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index dbcd8f82af..024226424c 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -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', @@ -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( diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index f59bbe2c65..f055cbb8eb 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -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` + ); + 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 = diff --git a/packages/swc-plugin-workflow/spec.md b/packages/swc-plugin-workflow/spec.md index c0f76e188b..ff803b1a87 100644 --- a/packages/swc-plugin-workflow/spec.md +++ b/packages/swc-plugin-workflow/spec.md @@ -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 diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 776ae35c4a..c367072d46 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -1077,3 +1077,73 @@ export async function customSerializationWorkflow(x: number, y: number) { sum: { x: sum.x, y: sum.y }, }; } + +////////////////////////////////////////////////////////// +// Cross-Context Class Registration E2E Test +////////////////////////////////////////////////////////// + +/** + * Import step functions that use Vector - but we do NOT import Vector directly. + * This tests that Vector class is registered in the workflow bundle even though + * the workflow code never directly references it. + */ +import { + addVectors, + createVector, + scaleVector, + sumVectors, +} from './serde-steps.js'; + +/** + * Workflow that tests cross-context class registration. + * + * IMPORTANT: This workflow does NOT import Vector directly. It only receives + * Vector instances through step return values. The cross-context class registration + * feature ensures Vector is registered in the workflow bundle even though + * the workflow code never imports it. + * + * Test flow: + * 1. Step creates Vector instance and returns it (step serializes) + * 2. Workflow receives Vector (workflow deserializes - THIS IS THE KEY TEST) + * 3. Workflow passes Vector to another step (workflow serializes) + * 4. Step receives Vector and operates on it (step deserializes) + * 5. Workflow returns results to client (as plain objects for simplicity) + * + * Without cross-context class registration, step 2 would fail because the + * workflow bundle wouldn't have Vector registered for deserialization. + */ +export async function crossContextSerdeWorkflow() { + 'use workflow'; + + // Step 1: Create a vector in the step + // Tests: step creating instance -> workflow deserialization + // This is the KEY test - workflow must be able to deserialize Vector + // even though the workflow code never imports Vector + const v1 = await createVector(1, 2, 3); + + // Step 2: Create another vector + const v2 = await createVector(10, 20, 30); + + // Step 3: Pass the deserialized vectors back to a step + // Tests: workflow serializing Vector instances it received from steps + const sum = await addVectors(v1, v2); + + // Step 4: Scale one of the vectors + // Tests: workflow passing a single deserialized Vector to step + const scaled = await scaleVector(v1, 5); + + // Step 5: Sum an array of vectors + // Tests: array serialization with Vector instances + const vectors = [v1, v2, scaled]; + const arraySum = await sumVectors(vectors); + + // Return plain objects (not Vector instances) so the client doesn't need + // to deserialize Vector - we're testing workflow deserialization, not client + return { + v1: { x: v1.x, y: v1.y, z: v1.z }, + v2: { x: v2.x, y: v2.y, z: v2.z }, + sum: { x: sum.x, y: sum.y, z: sum.z }, + scaled: { x: scaled.x, y: scaled.y, z: scaled.z }, + arraySum: { x: arraySum.x, y: arraySum.y, z: arraySum.z }, + }; +} diff --git a/workbench/example/workflows/serde-models.ts b/workbench/example/workflows/serde-models.ts new file mode 100644 index 0000000000..9d0b77cc18 --- /dev/null +++ b/workbench/example/workflows/serde-models.ts @@ -0,0 +1,64 @@ +/** + * Custom serializable classes for cross-context serialization e2e tests. + * + * This file is ONLY imported by steps (not directly by workflows or client code). + * The cross-context class registration feature ensures these classes are + * registered in ALL bundle contexts (client, workflow, step) even though + * they're only directly imported by step code. + * + * This tests the scenario where: + * 1. Client passes a Vector instance to start() -> needs to serialize it + * 2. Workflow receives the Vector -> needs to deserialize it + * 3. Workflow passes Vector to step -> needs to serialize it + * 4. Step receives Vector -> needs to deserialize it + * 5. Step returns Vector -> needs to serialize it + * 6. Workflow receives Vector result -> needs to deserialize it + * 7. Workflow returns Vector -> needs to serialize it + * 8. Client receives Vector result -> needs to deserialize it + */ + +/** + * A 3D vector class with custom serialization. + * This class is only imported in step code but needs to be + * serializable/deserializable in all contexts. + */ +export class Vector { + x: number; + y: number; + z: number; + + constructor(x: number, y: number, z: number) { + this.x = x; + this.y = y; + this.z = z; + } + + /** Custom serialization - converts instance to plain object */ + static [Symbol.for('workflow-serialize')](instance: Vector) { + return { x: instance.x, y: instance.y, z: instance.z }; + } + + /** Custom deserialization - reconstructs instance from plain object */ + static [Symbol.for('workflow-deserialize')](data: { + x: number; + y: number; + z: number; + }) { + return new Vector(data.x, data.y, data.z); + } + + /** Helper method to compute magnitude */ + magnitude(): number { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + } + + /** Helper method to add two vectors */ + add(other: Vector): Vector { + return new Vector(this.x + other.x, this.y + other.y, this.z + other.z); + } + + /** Helper method to scale vector */ + scale(factor: number): Vector { + return new Vector(this.x * factor, this.y * factor, this.z * factor); + } +} diff --git a/workbench/example/workflows/serde-steps.ts b/workbench/example/workflows/serde-steps.ts new file mode 100644 index 0000000000..9726bbe6c0 --- /dev/null +++ b/workbench/example/workflows/serde-steps.ts @@ -0,0 +1,61 @@ +/** + * Step functions that use the Vector class for cross-context serialization testing. + * + * These steps import Vector from serde-models.ts. The workflow code does NOT + * directly import Vector - it only receives/passes Vector instances through + * step calls. This tests cross-context class registration. + */ + +import { Vector } from './serde-models.js'; + +/** + * Step that receives a Vector and scales it. + * Tests: workflow -> step deserialization, step -> workflow serialization + */ +export async function scaleVector(vector: Vector, factor: number) { + 'use step'; + // Verify the vector was properly deserialized and has its methods + console.log('Vector magnitude:', vector.magnitude()); + // Scale and return (will be serialized on return) + return vector.scale(factor); +} + +/** + * Step that receives two Vectors and adds them. + * Tests: workflow -> step deserialization of multiple instances + */ +export async function addVectors(v1: Vector, v2: Vector) { + 'use step'; + // Verify both vectors have their methods + console.log('v1 magnitude:', v1.magnitude()); + console.log('v2 magnitude:', v2.magnitude()); + return v1.add(v2); +} + +/** + * Step that creates and returns a new Vector. + * Tests: step creating new instance -> workflow deserialization + */ +export async function createVector(x: number, y: number, z: number) { + 'use step'; + return new Vector(x, y, z); +} + +/** + * Step that receives an array of Vectors. + * Tests: serialization of arrays containing custom class instances + */ +export async function sumVectors(vectors: Vector[]) { + 'use step'; + let totalX = 0; + let totalY = 0; + let totalZ = 0; + for (const v of vectors) { + // Verify each vector has its methods + console.log('Vector magnitude:', v.magnitude()); + totalX += v.x; + totalY += v.y; + totalZ += v.z; + } + return new Vector(totalX, totalY, totalZ); +} diff --git a/workbench/nextjs-turbopack/workflows/serde-models.ts b/workbench/nextjs-turbopack/workflows/serde-models.ts new file mode 120000 index 0000000000..29ffe7bf90 --- /dev/null +++ b/workbench/nextjs-turbopack/workflows/serde-models.ts @@ -0,0 +1 @@ +../../example/workflows/serde-models.ts \ No newline at end of file diff --git a/workbench/nextjs-turbopack/workflows/serde-steps.ts b/workbench/nextjs-turbopack/workflows/serde-steps.ts new file mode 120000 index 0000000000..9df659a7b8 --- /dev/null +++ b/workbench/nextjs-turbopack/workflows/serde-steps.ts @@ -0,0 +1 @@ +../../example/workflows/serde-steps.ts \ No newline at end of file diff --git a/workbench/nextjs-webpack/workflows/serde-models.ts b/workbench/nextjs-webpack/workflows/serde-models.ts new file mode 120000 index 0000000000..29ffe7bf90 --- /dev/null +++ b/workbench/nextjs-webpack/workflows/serde-models.ts @@ -0,0 +1 @@ +../../example/workflows/serde-models.ts \ No newline at end of file diff --git a/workbench/nextjs-webpack/workflows/serde-steps.ts b/workbench/nextjs-webpack/workflows/serde-steps.ts new file mode 120000 index 0000000000..9df659a7b8 --- /dev/null +++ b/workbench/nextjs-webpack/workflows/serde-steps.ts @@ -0,0 +1 @@ +../../example/workflows/serde-steps.ts \ No newline at end of file diff --git a/workbench/nitro-v3/workflows/serde-models.ts b/workbench/nitro-v3/workflows/serde-models.ts new file mode 120000 index 0000000000..29ffe7bf90 --- /dev/null +++ b/workbench/nitro-v3/workflows/serde-models.ts @@ -0,0 +1 @@ +../../example/workflows/serde-models.ts \ No newline at end of file diff --git a/workbench/nitro-v3/workflows/serde-steps.ts b/workbench/nitro-v3/workflows/serde-steps.ts new file mode 120000 index 0000000000..9df659a7b8 --- /dev/null +++ b/workbench/nitro-v3/workflows/serde-steps.ts @@ -0,0 +1 @@ +../../example/workflows/serde-steps.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/serde-models.ts b/workbench/sveltekit/src/workflows/serde-models.ts new file mode 120000 index 0000000000..733955540b --- /dev/null +++ b/workbench/sveltekit/src/workflows/serde-models.ts @@ -0,0 +1 @@ +../../../example/workflows/serde-models.ts \ No newline at end of file diff --git a/workbench/sveltekit/src/workflows/serde-steps.ts b/workbench/sveltekit/src/workflows/serde-steps.ts new file mode 120000 index 0000000000..ecd42b1c6a --- /dev/null +++ b/workbench/sveltekit/src/workflows/serde-steps.ts @@ -0,0 +1 @@ +../../../example/workflows/serde-steps.ts \ No newline at end of file