The DCE usage collector skipped the entire variable name pattern when
visiting a `VarDeclarator` (to avoid marking the binding name as "used").
But default-value initializers inside destructuring patterns live in that
pattern — e.g. the `TTL` in `const { ttl = TTL } = options;` — so those
references were invisible to the collector. A module-scope `const`
referenced only through such a default was treated as unused and stripped,
while the surviving code kept reading it, producing a runtime
`ReferenceError` when the default fired.
Traverse the default-value initializer expressions (and computed keys)
within destructuring patterns while still not marking the binding names
themselves, so the referenced declaration is preserved. Function-parameter
defaults were already covered (params are visited in full).
Fixes #2396.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
50 KiB
Workflow Directives Specification
The "use step" and "use workflow" directives work similarly to "use server" in React. A function marked with "use step" represents a durable step that executes on the server. A function marked with "use workflow" represents a durable workflow that orchestrates steps.
The SWC plugin has 3 modes: Step mode, Workflow mode, and Detect mode.
Directive Placement
Directives can be placed:
- At the top of a file (module-level) to mark all exported async functions
- At the start of a function body to mark individual functions
Directives must:
- Be at the very beginning (above any other code, including imports for module-level)
- Use single or double quotes (not backticks)
- Comments before directives are allowed
JSON Manifest
All modes emit a JSON manifest comment at the top of the file containing metadata about discovered workflows, steps, and classes with custom serialization:
/**__internal_workflows{"workflows":{"path/file.ts":{"myWorkflow":{"workflowId":"workflow//./path/file//myWorkflow"}}},"steps":{"path/file.ts":{"myStep":{"stepId":"step//./path/file//myStep"}}},"classes":{"path/file.ts":{"Point":{"classId":"class//./path/file//Point"}}}}*/
The manifest includes:
workflows: Map of workflow function names to theirworkflowIdsteps: Map of step function names to theirstepIdclasses: Map of class names with custom serialization to theirclassId
This manifest is used by bundlers and the runtime to discover and register workflows, steps, and serializable classes.
ID Generation
IDs use the format {type}//{modulePath}//{identifier} where:
typeisworkflow,step, orclassmodulePathis either:- A module specifier with version (e.g.,
point@0.0.1,@myorg/shared@1.2.3,workflow/internal/builtins@4.0.0) when provided via plugin config - A relative path prefixed with
./(e.g.,./src/jobs/order) when no specifier is provided
- A module specifier with version (e.g.,
identifieris the function/class name, with nested functions using/separators
Module Specifier Support
The plugin accepts an optional moduleSpecifier config option that allows IDs to be based on the
import specifier rather than the file path. This is useful for:
-
Package exports conditions: When a package has different entrypoints for different conditions (e.g.,
"workflow"vs"default"inpackage.jsonexports), the same import specifier can map to different files. Using the specifier ensures consistent IDs across conditions. -
Versioned IDs: Package specifiers can include versions (e.g.,
point@0.0.1) for cache invalidation. -
Stable cross-bundle references: Classes serialized in one bundle can be deserialized in another bundle as long as both use the same module specifier.
-
Subpath exports: For packages with multiple entry points (e.g.,
workflow/internal/builtins), the full subpath is included in the module specifier to avoid collisions between steps with the same name in different subpaths.
Plugin Config:
{
"mode": "step",
"moduleSpecifier": "workflow/internal/builtins@4.0.0"
}
Examples
With module specifier (npm package root export):
class//point@0.0.1//Pointstep//@myorg/tasks@2.0.0//processOrder
With module specifier (npm package subpath export):
step//workflow/internal/builtins@4.0.0//__builtin_response_jsonclass//@myorg/shared/models@1.0.0//User
Without module specifier (local files):
workflow//./src/jobs/order//processOrderstep//./src/jobs/order//fetchDatastep//./src/jobs/order//processOrder/innerStep(nested step)step//./src/jobs/order//MyClass.staticMethod(static method)step//./src/jobs/order//MyClass#instanceMethod(instance method)class//./src/models/Point//Point(serialization class)
Note: File extensions are stripped from local paths for cleaner IDs.
Step Mode
In step mode, step function bodies are kept intact and registered using an inline IIFE that stores them in a global registry via Symbol.for("@workflow/core//registeredSteps"), with no module imports. Workflow functions throw an error if called directly (since they should only run in the workflow runtime).
After the step-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are preserved (unlike workflow mode where they are replaced with proxies), imports, helper functions, and other declarations referenced from step bodies are also preserved. However, code that is reachable only from workflow bodies that were replaced with throwing stubs can still be removed. A reference counts even when it appears only inside a destructuring-default initializer — e.g. const { ttl = TTL } = options; counts as a use of TTL, so the declaration is not stripped.
Object property step functions are hoisted to module-level variables and the original call site is replaced with a reference to the hoisted variable, making .stepId accessible at the call site.
Basic Step Function
Input:
export async function add(a, b) {
"use step";
return a + b;
}
Output:
/**__internal_workflows{"steps":{"input.js":{"add":{"stepId":"step//./input//add"}}}}*/;
export async function add(a, b) {
return a + b;
}
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(add, "step//./input//add");
Arrow Function Step
Input:
export const multiply = async (a, b) => {
"use step";
return a * b;
};
Output:
/**__internal_workflows{"steps":{"input.js":{"multiply":{"stepId":"step//./input//multiply"}}}}*/;
export const multiply = async (a, b) => {
return a * b;
};
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(multiply, "step//./input//multiply");
Workflow Functions in Step Mode
Workflow functions throw an error to prevent direct execution and have workflowId attached:
Input:
export async function myWorkflow(data) {
"use workflow";
return await processData(data);
}
Output:
/**__internal_workflows{"workflows":{"input.js":{"myWorkflow":{"workflowId":"workflow//./input//myWorkflow"}}}}*/;
export async function myWorkflow(data) {
throw new Error("You attempted to execute workflow myWorkflow function directly. To start a workflow, use start(myWorkflow) from workflow/api");
}
myWorkflow.workflowId = "workflow//./input//myWorkflow";
Nested Steps in Workflows
Steps defined inside workflow functions are hoisted to module level with prefixed names:
Input:
export async function example(a, b) {
"use workflow";
async function innerStep(x, y) {
"use step";
return x + y;
}
return await innerStep(a, b);
}
Output:
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"innerStep":{"stepId":"step//./input//innerStep"}}}}*/;
async function example$innerStep(x, y) {
return x + y;
}
export async function example(a, b) {
throw new Error("You attempted to execute workflow example function directly. To start a workflow, use start(example) from workflow/api");
}
example.workflowId = "workflow//./input//example";
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(example$innerStep, "step//./input//example/innerStep");
Steps in Nested Object Properties
Step functions can be defined inside deeply nested object properties, including function call arguments. The plugin recursively processes nested objects to find step functions, generating compound paths for the step IDs.
Input:
import { agent } from "experimental-agent";
export const vade = agent({
tools: {
VercelRequest: {
execute: async (input, ctx) => {
"use step";
return 1 + 1;
},
},
},
});
Output (Step Mode):
import { agent } from "experimental-agent";
/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/;
var vade$tools$VercelRequest$execute = async function(input, ctx) {
return 1 + 1;
};
export const vade = agent({
tools: {
VercelRequest: {
execute: vade$tools$VercelRequest$execute
}
}
});
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(vade$tools$VercelRequest$execute, "step//./input//vade/tools/VercelRequest/execute");
Note: Step functions are hoisted as regular function expressions (not arrow functions) to preserve this binding when called with .call() or .apply(). This applies even when the original step function was defined as an arrow function.
Output (Workflow Mode):
import { agent } from "experimental-agent";
/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/;
export const vade = agent({
tools: {
VercelRequest: {
execute: globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//vade/tools/VercelRequest/execute")
}
}
});
Note: In step mode, nested object property step functions are hoisted and registered via a self-contained IIFE (no imports). The original call site is replaced with a reference to the hoisted variable.
Note: The step ID includes the full path through nested objects (vade/tools/VercelRequest/execute), while the hoisted variable name uses $ as the separator (vade$tools$VercelRequest$execute) to create a valid JavaScript identifier.
Shorthand Method Syntax
Shorthand method syntax (non-arrow functions) is also supported in nested object properties:
Input:
import { agent } from "experimental-agent";
export const vade = agent({
tools: {
VercelRequest: {
async execute(input, { experimental_context }) {
"use step";
return 1 + 1;
},
},
},
});
Output (Step Mode):
import { agent } from "experimental-agent";
/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/;
var vade$tools$VercelRequest$execute = async function(input, { experimental_context }) {
return 1 + 1;
};
export const vade = agent({
tools: {
VercelRequest: {
execute: vade$tools$VercelRequest$execute
}
}
});
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(vade$tools$VercelRequest$execute, "step//./input//vade/tools/VercelRequest/execute");
Note: Shorthand methods are hoisted as regular function expressions (not arrow functions) to preserve this binding when called with .call() or .apply(). Closure variables are handled the same way as other step functions.
Closure Variables
When nested steps capture closure variables, they are extracted using an inline IIFE that reads from the workflow step context storage via Symbol.for("WORKFLOW_STEP_CONTEXT_STORAGE"). Closure variable detection recursively walks the step function body — including nested function, arrow, method, getter/setter, and class bodies — and collects identifiers that are not parameters, local declarations, known globals, module-level imports, or module-level declarations. TypeScript expression wrappers (as, satisfies, !, type assertions, const assertions, instantiation expressions) are traversed to reach the inner expression. Module-level imports and declarations (functions, variables, classes) are excluded since they are available directly in the step bundle and should not be serialized as closure values:
Input:
function wrapper(multiplier) {
return async () => {
"use step";
return 10 * multiplier;
};
}
Output:
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"}}}}*/;
var wrapper$_anonymousStep0 = async () => {
const { multiplier } = function() {
var __wf_ctx = globalThis[Symbol.for("WORKFLOW_STEP_CONTEXT_STORAGE")], __wf_store = __wf_ctx && __wf_ctx.getStore();
if (!__wf_store) throw new Error("Closure variables can only be accessed inside a step function");
return __wf_store.closureVars || {};
}();
return 10 * multiplier;
};
function wrapper(multiplier) {
return async () => {
return 10 * multiplier;
};
}
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(wrapper$_anonymousStep0, "step//./input//wrapper/_anonymousStep0");
Note: The hoisted copy (wrapper$_anonymousStep0) uses an inline IIFE to extract closure variables from the workflow step context for workflow-driven execution, while the original function body is preserved in wrapper() with the directive stripped. This allows the enclosing function to work correctly when called directly (non-workflow), since JavaScript's normal closure semantics naturally capture multiplier.
Instance Method Step
Instance methods can use "use step" if the class provides custom serialization methods. The this context is serialized when calling the step and deserialized before execution.
Input:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
export class Counter {
static [WORKFLOW_SERIALIZE](instance) {
return { value: instance.value };
}
static [WORKFLOW_DESERIALIZE](data) {
return new Counter(data.value);
}
constructor(value) {
this.value = value;
}
async add(amount) {
'use step';
return this.value + amount;
}
}
Output:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
/**__internal_workflows{"steps":{"input.js":{"Counter#add":{"stepId":"step//./input//Counter#add"}}},"classes":{"input.js":{"Counter":{"classId":"class//./input//Counter"}}}}*/;
export class Counter {
static [WORKFLOW_SERIALIZE](instance) {
return { value: instance.value };
}
static [WORKFLOW_DESERIALIZE](data) {
return new Counter(data.value);
}
constructor(value) {
this.value = value;
}
async add(amount) {
return this.value + amount;
}
}
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(Counter.prototype["add"], "step//./input//Counter#add");
(function(__wf_cls, __wf_id) {
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_cls);
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
})(Counter, "class//./input//Counter");
Note: Instance methods use # in the step ID (e.g., Counter#add) and are registered via ClassName.prototype["methodName"].
Module-Level Directive
Input:
"use step";
export async function add(a, b) {
return a + b;
}
export async function subtract(a, b) {
return a - b;
}
Output:
/**__internal_workflows{"steps":{"input.js":{"add":{"stepId":"step//./input//add"},"subtract":{"stepId":"step//./input//subtract"}}}}*/;
export async function add(a, b) {
return a + b;
}
export async function subtract(a, b) {
return a - b;
}
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(add, "step//./input//add");
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(subtract, "step//./input//subtract");
Workflow Mode
In workflow mode, step function bodies are replaced with a globalThis[Symbol.for("WORKFLOW_USE_STEP")] call. Workflow functions keep their bodies and are registered with globalThis.__private_workflows.set().
After the workflow-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are replaced with step proxies, imports, helper functions, nested steps, and other pure statements that were only referenced from those original step bodies become eligible for removal. Exports and any identifiers still referenced by the transformed workflow code are preserved. A reference counts even when it appears only inside a destructuring-default initializer — e.g. const { ttl = TTL } = options; counts as a use of TTL, so the declaration is not stripped.
Step Functions
Input:
export async function add(a, b) {
"use step";
return a + b;
}
Output:
/**__internal_workflows{"steps":{"input.js":{"add":{"stepId":"step//./input//add"}}}}*/;
export var add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//add");
Workflow Functions
Input:
export async function myWorkflow(data) {
"use workflow";
const result = await fetchData(data);
return result;
}
Output:
/**__internal_workflows{"workflows":{"input.js":{"myWorkflow":{"workflowId":"workflow//./input//myWorkflow"}}}}*/;
export async function myWorkflow(data) {
const result = await fetchData(data);
return result;
}
myWorkflow.workflowId = "workflow//./input//myWorkflow";
globalThis.__private_workflows.set("workflow//./input//myWorkflow", myWorkflow);
Nested Steps with Closures
When steps capture closure variables, a closure function is passed as the second argument:
Input:
export async function myWorkflow(config) {
"use workflow";
let count = 0;
async function increment() {
"use step";
return count + 1;
}
return await increment();
}
Output:
/**__internal_workflows{"workflows":{"input.js":{"myWorkflow":{"workflowId":"workflow//./input//myWorkflow"}}},"steps":{"input.js":{"increment":{"stepId":"step//./input//increment"}}}}*/;
export async function myWorkflow(config) {
let count = 0;
var increment = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//myWorkflow/increment", () => ({
count
}));
return await increment();
}
myWorkflow.workflowId = "workflow//./input//myWorkflow";
globalThis.__private_workflows.set("workflow//./input//myWorkflow", myWorkflow);
Detect Mode
Detect mode is a lightweight, non-transforming mode used during the build discovery phase. It walks the AST to find "use workflow", "use step" directives and custom serialization classes, then emits the JSON manifest comment — but does not modify any code.
This allows the build system to perform a fast regexp pre-scan to identify candidate files, then run the SWC plugin in detect mode only on those candidates to validate at the AST level. False positives (e.g. directive-like strings inside template literals) are eliminated because the plugin only recognises genuine directive expression statements.
Plugin Config:
{
"mode": "detect",
"moduleSpecifier": null
}
Given the same input as the other mode examples, detect mode produces:
/**__internal_workflows{"steps":{"input.js":{"fetchInventory":{"stepId":"step//./input//fetchInventory"}}},"workflows":{"input.js":{"placeOrder":{"workflowId":"workflow//./input//placeOrder"}}}}*/
// ... original source code unchanged ...
Static Methods
Static class methods can be marked with directives. Instance methods are not supported.
Static Step Method
Input:
export class MyService {
static async process(data) {
"use step";
return data.value * 2;
}
}
Output (Step Mode):
/**__internal_workflows{"steps":{"input.js":{"MyService.process":{"stepId":"step//./input//MyService.process"}}},"classes":{"input.js":{"MyService":{"classId":"class//./input//MyService"}}}}*/;
export class MyService {
static async process(data) {
return data.value * 2;
}
}
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(MyService.process, "step//./input//MyService.process");
(function(__wf_cls, __wf_id) {
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_cls);
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
})(MyService, "class//./input//MyService");
Output (Workflow Mode):
/**__internal_workflows{"steps":{"input.js":{"MyService.process":{"stepId":"step//./input//MyService.process"}}},"classes":{"input.js":{"MyService":{"classId":"class//./input//MyService"}}}}*/;
export class MyService {
}
MyService.process = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//MyService.process");
(function(__wf_cls, __wf_id) {
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_cls);
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
})(MyService, "class//./input//MyService");
Static Workflow Method
Input:
export class JobRunner {
static async runJob(jobId) {
"use workflow";
return await processJob(jobId);
}
}
Output (Workflow Mode):
/**__internal_workflows{"workflows":{"input.js":{"JobRunner.runJob":{"workflowId":"workflow//./input//JobRunner.runJob"}}}}*/;
export class JobRunner {
static async runJob(jobId) {
return await processJob(jobId);
}
}
JobRunner.runJob.workflowId = "workflow//./input//JobRunner.runJob";
globalThis.__private_workflows.set("workflow//./input//JobRunner.runJob", JobRunner.runJob);
Custom Serialization
Classes can define custom serialization/deserialization using symbols. These are automatically registered for use across workflow boundaries.
Input:
export class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
static [Symbol.for("workflow-serialize")](instance) {
return { x: instance.x, y: instance.y };
}
static [Symbol.for("workflow-deserialize")](data) {
return new Point(data.x, data.y);
}
}
Output:
/**__internal_workflows{"classes":{"input.js":{"Point":{"classId":"class//./input//Point"}}}}*/;
export class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
static [Symbol.for("workflow-serialize")](instance) {
return { x: instance.x, y: instance.y };
}
static [Symbol.for("workflow-deserialize")](data) {
return new Point(data.x, data.y);
}
}
(function(__wf_cls, __wf_id) {
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_cls);
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
})(Point, "class//./input//Point");
The registration is inlined as a self-contained IIFE that uses Symbol.for("workflow-class-registry") on globalThis. This ensures it works for 3rd-party packages that don't depend on the workflow package directly — no module imports are needed.
You can also use imported symbols from @workflow/serde:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
export class Vector {
static [WORKFLOW_SERIALIZE](instance) { ... }
static [WORKFLOW_DESERIALIZE](data) { ... }
}
CommonJS require() Patterns
The plugin also detects serialization symbols obtained via CommonJS require() calls. This handles code that has been pre-compiled from ESM to CommonJS by tools like TypeScript (tsc), esbuild, or tsup.
Namespace require — when the entire module is assigned to a variable and symbols are accessed as properties:
const serde_1 = require("@workflow/serde");
class Sandbox {
static [serde_1.WORKFLOW_SERIALIZE](instance) {
return { sandbox: instance.sandbox };
}
static [serde_1.WORKFLOW_DESERIALIZE](data) {
const instance = Object.create(Sandbox.prototype);
instance.sandbox = data.sandbox;
return instance;
}
}
Destructured require — when symbols are destructured directly from the require() call:
const { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } = require("@workflow/serde");
class Sandbox {
static [WORKFLOW_SERIALIZE](instance) {
return { sandbox: instance.sandbox };
}
static [WORKFLOW_DESERIALIZE](data) {
const instance = Object.create(Sandbox.prototype);
instance.sandbox = data.sandbox;
return instance;
}
}
Both patterns produce the same output as the ESM import version — a registerSerializationClass() call is appended and the class is included in the manifest.
Destructured require also supports renaming (analogous to import { WORKFLOW_SERIALIZE as WS }):
const { WORKFLOW_SERIALIZE: WS, WORKFLOW_DESERIALIZE: WD } = require("@workflow/serde");
Class Expressions with Binding Names
When a class expression is assigned to a variable, the plugin uses the variable name (binding name) for registration, not the internal class name. This is important because the internal class name is only accessible inside the class body.
Input:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
var Bash = class _Bash {
constructor(command) {
this.command = command;
}
static [WORKFLOW_SERIALIZE](instance) {
return { command: instance.command };
}
static [WORKFLOW_DESERIALIZE](data) {
return new Bash(data.command);
}
};
Output:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
/**__internal_workflows{"classes":{"input.js":{"Bash":{"classId":"class//./input//Bash"}}}}*/;
var Bash = class _Bash {
constructor(command) {
this.command = command;
}
static [WORKFLOW_SERIALIZE](instance) {
return { command: instance.command };
}
static [WORKFLOW_DESERIALIZE](data) {
return new Bash(data.command);
}
};
(function(__wf_cls, __wf_id) {
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_cls);
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
})(Bash, "class//./input//Bash");
Note that:
- The registration uses
Bash(the variable name), not_Bash(the internal class name) - The
classIdin the manifest also usesBash - This ensures the registration call references a symbol that's actually in scope at module level
This binding-name preference applies to all generated code that references the class at module scope, including:
- Class serialization registration IIFEs
- Step method registrations (inline IIFE calls)
- Workflow method stub assignments
For example, a class expression with step methods:
Input:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
var LanguageModel = class _LanguageModel {
constructor(modelId) { this.modelId = modelId; }
static [WORKFLOW_SERIALIZE](inst) { return { modelId: inst.modelId }; }
static [WORKFLOW_DESERIALIZE](data) { return new _LanguageModel(data.modelId); }
async doStream(prompt) { "use step"; return { stream: prompt }; }
static async generate(input) { "use step"; return { result: input }; }
};
Output (step mode):
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(LanguageModel.generate, "step//./input//LanguageModel.generate");
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(LanguageModel.prototype["doStream"], "step//./input//LanguageModel#doStream");
(function(__wf_cls, __wf_id) { /* ... */ })(LanguageModel, "class//./input//LanguageModel");
All references use LanguageModel (the binding name), not _LanguageModel (the internal class expression name). Only a single class registration IIFE is emitted. The step IDs also use the binding name.
Anonymous Class Expression Name Re-insertion
When a serializable class expression has no internal name (anonymous) but has a binding name from a variable declaration, the plugin re-inserts the binding name as the class expression's identifier. This handles the common case where upstream bundlers like esbuild/tsup transform class Foo { ... } into var Foo = class { ... } (stripping the class name).
Without this fix, the anonymous class would have an empty .name property, which can break downstream bundlers that rely on the class name for serialization registration.
Input (e.g., after tsup pre-bundling):
var Shell = class {
constructor(cmd) {
this.cmd = cmd;
}
static [Symbol.for('workflow-serialize')](instance) {
return { cmd: instance.cmd };
}
static [Symbol.for('workflow-deserialize')](data) {
return new Shell(data.cmd);
}
};
Output:
/**__internal_workflows{"classes":{"input.js":{"Shell":{"classId":"class//./input//Shell"}}}}*/;
var Shell = class Shell {
constructor(cmd) {
this.cmd = cmd;
}
static [Symbol.for('workflow-serialize')](instance) {
return { cmd: instance.cmd };
}
static [Symbol.for('workflow-deserialize')](data) {
return new Shell(data.cmd);
}
};
(function(__wf_cls, __wf_id) {
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_cls);
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
})(Shell, "class//./input//Shell");
Note that:
- The class expression
class { ... }becomesclass Shell { ... }— the binding name is inserted - For typical usage, behavior is preserved while ensuring the
.nameproperty survives subsequent bundling (an inner class name binding is introduced, which can differ in edge cases that depend on assigning to or shadowing that name inside the class body) - Classes that already have an internal name (e.g.,
class _Bash { ... }) are not modified - Only classes with serialization methods (
WORKFLOW_SERIALIZEandWORKFLOW_DESERIALIZE) are affected
Anonymous Default Class Export Rewriting
When an anonymous class with serialization methods or step methods is exported as the default export, the plugin rewrites it into a const declaration + re-export so that the class has a binding name accessible at module scope. Without this, the generated registration code would reference an undefined variable.
Input:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
export default class {
constructor(id) { this.id = id; }
static [WORKFLOW_SERIALIZE](inst) { return { id: inst.id }; }
static [WORKFLOW_DESERIALIZE](data) { return new this(data.id); }
async process(input) { "use step"; return { result: input }; }
}
Output (step mode):
const __DefaultClass = class __DefaultClass {
constructor(id) { this.id = id; }
// ... serde methods preserved ...
async process(input) { return { result: input }; }
};
export default __DefaultClass;
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(__DefaultClass.prototype["process"], "step//./input//__DefaultClass#process");
(function(__wf_cls, __wf_id) { /* ... */ })(__DefaultClass, "class//./input//__DefaultClass");
Note that:
- The anonymous class
export default class { ... }is rewritten toconst __DefaultClass = class __DefaultClass { ... }; export default __DefaultClass; - When the class has serialization methods, the class expression also gets the binding name re-inserted (e.g.,
class __DefaultClass { ... }). For step-only classes without serde, the class expression remains anonymous (e.g.,class { ... }) — but theconstbinding name is what matters for module-scope registration code - The generated name
__DefaultClassis used for all registrations (step, class, serde) - If
__DefaultClassis already declared in scope, the name is suffixed (__DefaultClass$1, etc.) - Named default exports (e.g.,
export default class MyService { ... }) are NOT rewritten — the class nameMyServiceis already in scope
File Discovery for Custom Serialization
Files containing classes with custom serialization are automatically discovered for transformation, even if they don't contain "use step" or "use workflow" directives. The discovery mechanism looks for:
- Imports from
@workflow/serde: Files that importWORKFLOW_SERIALIZEorWORKFLOW_DESERIALIZEfrom@workflow/serde - Direct Symbol.for usage: Files containing
Symbol.for('workflow-serialize')orSymbol.for('workflow-deserialize') - CommonJS
require()calls: Files that userequire("@workflow/serde")(or any module) and accessWORKFLOW_SERIALIZEorWORKFLOW_DESERIALIZEvia destructuring or namespace property access
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 and workflow) to ensure they can be properly serialized and deserialized when crossing execution boundaries:
| Boundary | Serializer | Deserializer | Example |
|---|---|---|---|
| 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 |
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
Pointbecause the step file imports it - The workflow bundle also includes
Pointso it can deserialize step return values
This cross-registration happens automatically during the build process - no manual configuration is required.
Default Exports
Anonymous default exports are given the name __default:
Input:
export default async (data) => {
"use workflow";
return await process(data);
};
Output (Workflow Mode):
/**__internal_workflows{"workflows":{"input.js":{"default":{"workflowId":"workflow//./input//default"}}}}*/;
const __default = async (data) => {
return await process(data);
};
__default.workflowId = "workflow//./input//default";
globalThis.__private_workflows.set("workflow//./input//default", __default);
export default __default;
Validation Errors
The plugin emits errors for invalid usage:
| Error | Description |
|---|---|
| Non-async workflow function | Functions with "use workflow" must be async (step functions may be sync) |
Instance methods with "use workflow" |
Only static methods can have "use workflow" (not instance methods) |
Getters with "use workflow" |
Getters cannot be marked with "use workflow" |
| Misplaced directive | Directive must be at top of file or start of function body |
| Conflicting directives | Cannot have both "use step" and "use workflow" at module level |
Invalid exports ("use workflow") |
Module-level "use workflow" files can only export async functions |
Invalid exports ("use step") |
Module-level "use step" files can only export functions (sync or async) |
| Misspelled directive | Detects typos like "use steps" or "use workflows" |
Supported Function Forms
The plugin supports various function declaration styles. Step functions may be synchronous or asynchronous. Workflow functions must be async.
async function name() { "use step"; }- Async function declarationfunction name() { "use step"; }- Sync function declarationconst name = async () => { "use step"; }- Async arrow functionconst name = () => { "use step"; }- Sync arrow functionlet name = async () => { "use step"; }- Async arrow function with letlet name = () => { "use step"; }- Sync arrow function with letvar name = async () => { "use step"; }- Async arrow function with varvar name = () => { "use step"; }- Sync arrow function with varconst name = async function() { "use step"; }- Async function expressionconst name = function() { "use step"; }- Sync function expression{ async method() { "use step"; } }- Async object method{ method() { "use step"; } }- Sync object method{ nested: { execute: async () => { "use step"; } } }- Nested object propertystatic async method() { "use step"; }- Static class methodasync method() { "use step"; }- Instance class method (requires custom serialization)get name() { "use step"; }- Object literal getterget name() { "use step"; }- Class instance getter (requires custom serialization)static get name() { "use step"; }- Static class getter
Getter Step Functions
Getters (property accessors) can be marked with "use step" to make property access trigger a step invocation. Unlike regular step functions, getters cannot be async syntactically, but the framework treats them as async steps. The pattern await obj.prop works when prop is a getter step.
Getters cannot be marked with "use workflow" — only "use step" is supported.
Instance getter transformation
Step mode: The getter is preserved on the class with the directive stripped. Registration uses an inline IIFE with Object.getOwnPropertyDescriptor to extract the getter function:
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"),
__wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(Object.getOwnPropertyDescriptor(ClassName.prototype, "prop").get, "step_id");
Workflow mode: The getter is removed from the class body. A hoisted step proxy variable and Object.defineProperty call are emitted:
var __step_ClassName$prop = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step_id");
Object.defineProperty(ClassName.prototype, "prop", {
get() { return __step_ClassName$prop.call(this); },
configurable: true,
enumerable: false
});
Static getter transformation
Same as instance getters but targets ClassName instead of ClassName.prototype, and uses . separator in the step ID (same as static methods).
Step mode:
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"),
__wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
})(Object.getOwnPropertyDescriptor(ClassName, "prop").get, "step_id");
Workflow mode:
var __step_ClassName$prop = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step_id");
Object.defineProperty(ClassName, "prop", {
get() { return __step_ClassName$prop(); },
configurable: true,
enumerable: false
});
Object literal getter transformation
Step mode: The getter body is hoisted into an async function wrapper for registration. The original getter is preserved with the directive stripped.
Workflow mode: A hoisted step proxy variable is created before the object literal. The getter body is replaced to call the proxy:
var __step_varName$prop = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step_id");
const obj = {
get prop() { return __step_varName$prop(); }
};
Private member dead code elimination
In workflow mode, after stripping "use step" methods and getters from a class body, the plugin eliminates private class members that are no longer referenced by any remaining (non-private) member. This applies to both:
- JS native private members:
#field,#method()(ClassMember::PrivateMethod,ClassMember::PrivateProp) - TypeScript
privatemembers:private field,private method()(ClassMethod/ClassPropwithaccessibility: Private)
The algorithm is iterative: references are first collected from all public members, then the referenced set is expanded by scanning surviving private members' bodies for cross-references, repeating until the set stabilizes. This enables cascading elimination — a private field only referenced by a private method that is itself unreferenced will also be removed.
Input:
export class Run {
static [WORKFLOW_SERIALIZE](instance) { return { id: instance.id }; }
static [WORKFLOW_DESERIALIZE](data) { return new Run(data.id); }
id: string;
private encryptionKeyPromise: Promise<any> | null = null;
private async getEncryptionKey() {
if (!this.encryptionKeyPromise) {
this.encryptionKeyPromise = importKey(this.id);
}
return this.encryptionKeyPromise;
}
constructor(id: string) { this.id = id; }
get value(): Promise<any> {
'use step';
return this.getEncryptionKey().then(() => getWorld().get(this.id));
}
}
Workflow output:
export class Run {
static [WORKFLOW_SERIALIZE](instance) { return { id: instance.id }; }
static [WORKFLOW_DESERIALIZE](data) { return new Run(data.id); }
id;
// private encryptionKeyPromise — ELIMINATED (only referenced by getEncryptionKey)
// private getEncryptionKey() — ELIMINATED (only referenced by stripped getter)
constructor(id) { this.id = id; }
}
// getter replaced with step proxy
var __step_Run$value = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step_id");
Object.defineProperty(Run.prototype, "value", {
get() { return __step_Run$value.call(this); },
configurable: true, enumerable: false
});
This optimization is critical for SDK classes like Run where private helper methods reference Node.js-only imports (encryption, world access, etc.) — eliminating them allows the downstream module-level DCE to also remove those imports from the workflow bundle.
Parameter Handling
The plugin supports complex parameter patterns including:
- Object destructuring:
async function({ a, b }) { "use step"; } - Array destructuring:
async function([first, second]) { "use step"; } - Default values:
async function({ x = 10 }) { "use step"; } - Rest parameters:
async function(a, ...rest) { "use step"; } - Nested destructuring:
async function({ user: { name } }) { "use step"; }
Disposable Resources (using declarations)
The plugin supports directives inside functions that use TypeScript's using declarations (disposable resources). When TypeScript transforms using declarations, it wraps the function body in a try-catch-finally block:
Original TypeScript:
async function testStep() {
'use step';
using writer = getWriter(getWritable());
await writer.write('Hello, world!');
}
After TypeScript transformation:
async function testStep() {
const env = {
stack: [],
error: void 0,
hasError: false
};
try {
"use step"; // Directive is now inside try block
const writer = _ts_add_disposable_resource(env, getWriter(getWritable()), false);
await writer.write("Hello, world!");
} catch (e) {
env.error = e;
env.hasError = true;
} finally {
_ts_dispose_resources(env);
}
}
The plugin detects this pattern and correctly identifies the directive inside the try block, removing it during transformation while preserving the disposable resource handling.
Lexical this Capture in Nested Arrow Steps
When a nested arrow-function step references this from an enclosing
function/method scope, the plugin captures that this so the workflow
runtime can rebind it inside the executing step body. This makes the
following pattern work — the user's class is responsible for providing
custom serialization (WORKFLOW_SERIALIZE / WORKFLOW_DESERIALIZE) so the
captured this can survive the workflow→step boundary:
Input:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
export class ReadFileTool {
static [WORKFLOW_SERIALIZE](instance) {
return { service: instance.service };
}
static [WORKFLOW_DESERIALIZE](data) {
return new ReadFileTool(data.service);
}
constructor(service) {
this.service = service;
}
createTool(context) {
return tool({
execute: async (input) => {
'use step';
return this.service.readFileContent(input, context);
},
});
}
}
Output (Workflow Mode) — the proxy reference is wrapped with .bind(this)
so the runtime's step proxy captures the caller's this as thisVal on the
invocation queue item:
createTool(context) {
return tool({
execute: globalThis[Symbol.for("WORKFLOW_USE_STEP")](
"step//./input//_anonymousStep0",
() => ({ context })
).bind(this),
});
}
Output (Step Mode) — the step body is hoisted as a regular function (not
an arrow) so the runtime's stepFn.apply(thisVal, args) can rebind this
to the value that was captured at call time:
async function _anonymousStep0(input) {
const { context } = (function() { /* closure-var IIFE */ })();
return this.service.readFileContent(input, context);
}
Detection rules:
- Only
thisreferences that are lexically captured by an arrow count. An arrow function inheritsthisfrom its enclosing scope; a nestedfunction/method/getter/setter introduces its ownthisand is therefore not traversed by the detector. - The detector only flags arrows that are themselves step functions. A
thisreference inside a non-step nested arrow inside a step does still count, because the inner arrow inheritsthisfrom the step function body, which in turn inherits from the enclosing function.
Caveat: capturing this only works at runtime if the captured value is
serializable across the workflow→step boundary. Classes registered with
WORKFLOW_SERIALIZE / WORKFLOW_DESERIALIZE work; ordinary class
instances without custom serialization will fail at proxy-invocation time.
Notes
-
Arguments and return values must be serializable (JSON-compatible or using custom serialization)
-
thisis syntactically allowed inside step bodies, but it only carries a meaningful value in two shapes that both flow through the runtime'sthisValplumbing:- Instance-method steps on a class with custom serialization (e.g.
Counter#add). Callinginstance.add(...)capturesinstanceasthisValso the step body seesthis === instance. - Nested arrow steps that lexically capture
this(see "LexicalthisCapture in Nested Arrow Steps" above). The compiler emits.bind(this)on the proxy in workflow mode and hoists the body as a regularfunctionin step mode sostepFn.apply(thisVal, args)rebinds correctly.
Other shapes (a top-level
async functionstep that referencesthis, an arrow step assigned to a module-level variable, etc.) compile without error butthiswill be whatever the caller of the step proxy passes — typicallynull/undefined— so referencing it is rarely useful. - Instance-method steps on a class with custom serialization (e.g.
-
argumentsis allowed insidefunction-form step bodies (it reflects the positional arguments the runtime passes viastepFn.apply(thisVal, args)). It does not work inside arrow-form steps — arrows don't have their ownargumentsbinding, and the compiler doesn't capture the enclosing scope'sargumentsthe way it does forthis. Use rest parameters (...args) instead if you need that pattern in an arrow step. -
supercalls are not allowed in step functions -
Imports from the module are excluded from closure variable detection
-
Module-level declarations (functions, variables, classes) are excluded from closure variable detection, since they are available directly in the step bundle and should not be serialized as closure values
-
newexpressions are analyzed for closure variables in the same way as regular function calls (both the callee and arguments are checked) -
Workflow functions always throw when called directly; use
start(workflow)fromworkflow/apiinstead