Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
54 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 three 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 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. For example, 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, the plugin extracts them 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. It 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, and instantiation expressions) are traversed to reach the inner expression. Module-level imports and declarations (functions, variables, and classes) are excluded because 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);
if (!Object.prototype.hasOwnProperty.call(__wf_cls, "classId")) 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"]. The hasOwnProperty guard before classId is defined is explained under Custom serialization.
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. For example, 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" and "use step" directives and custom serialization classes, then emits the JSON manifest comment without modifying any code.
This allows the build system to perform a fast regular expression pre-scan to identify candidate files, then run the SWC plugin in detect mode only on those candidates to validate at the AST level. The plugin eliminates false positives (for example, directive-like strings inside template literals) because it recognizes only 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);
if (!Object.prototype.hasOwnProperty.call(__wf_cls, "classId")) 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);
if (!Object.prototype.hasOwnProperty.call(__wf_cls, "classId")) 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);
if (!Object.prototype.hasOwnProperty.call(__wf_cls, "classId")) 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 approach works for third-party packages that don't depend on the workflow package directly and requires no module imports.
The Object.defineProperty call is guarded by hasOwnProperty, making it idempotent: classId is defined non-configurable, so visiting the same class a second time would otherwise throw Cannot redefine property: classId and crash the bundle at module load. This is not merely defensive: some bundler pipelines legitimately re-run this transform over its own output for the same module. Observed case: a Vite/Nitro SSR build reaching a dependency (@ai-sdk/gateway, which ships its own WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE methods) through more than one build stage, where the second stage's input is the first stage's already-registered output. On a named class expression (var Foo = class _Foo {}) the second pass also can't recover the binding name Foo — the class is no longer a bare initializer, it is now the argument of the first pass's registration call — so it falls back to the class expression's own inner name _Foo (see Class names for IDs) and would otherwise nest a second, differently-named registration around the first. The registry .set() call stays unconditional: a class visited under two different names is registered under both, and either resolves it.
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 applies 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 applies 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. The plugin appends a registerSerializationClass() call and includes the class 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
Class declarations (class Foo { ... }, export class Foo { ... }) are registered by module-level statements appended to the module body that reference the class by name (see the examples above).
Class expressions are handled differently, because there is no guarantee that the class is reachable through a module-scope binding: bundlers routinely emit var Foo = class { ... } or var Foo = class _Foo { ... } (where _Foo is only in scope inside the class body), and a class expression can appear anywhere an expression can (exports.Foo = class {}, { Foo: class {} }, foo(class Named {}), var A = class {}, B = class {}). Instead of emitting module-level code that refers to the class by name, the plugin wraps the class expression in an IIFE that receives the class as its argument, performs the registrations, and returns the class:
Input (e.g., after tsdown/esbuild pre-bundling; this is the shape @vercel/sandbox ships):
var FileSystem = class {
constructor(sandbox) { this.sandbox = sandbox; }
async readFile(path) { "use step"; return this.sandbox.read(path); }
};
export { FileSystem };
Output (step mode):
/**__internal_workflows{"steps":{"input.js":{"FileSystem#readFile":{"stepId":"step//./input//FileSystem#readFile"}}},"classes":{"input.js":{"FileSystem":{"classId":"class//./input//FileSystem"}}}}*/;
var FileSystem = function(__wf_cls) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map()), __wf_fn;
__wf_fn = __wf_cls.prototype["readFile"];
__wf_reg.set("step//./input//FileSystem#readFile", __wf_fn);
__wf_fn.stepId = "step//./input//FileSystem#readFile";
Object.defineProperty(__wf_fn, "name", { value: "readFile", configurable: true });
var __wf_cls_sym = Symbol.for("workflow-class-registry"), __wf_cls_reg = globalThis[__wf_cls_sym] || (globalThis[__wf_cls_sym] = new Map());
__wf_cls_reg.set("class//./input//FileSystem", __wf_cls);
if (!Object.prototype.hasOwnProperty.call(__wf_cls, "classId")) Object.defineProperty(__wf_cls, "classId", { value: "class//./input//FileSystem", writable: false, enumerable: false, configurable: false });
return __wf_cls;
}(class FileSystem {
constructor(sandbox) { this.sandbox = sandbox; }
async readFile(path) { return this.sandbox.read(path); }
});
export { FileSystem };
Output (workflow mode):
var FileSystem = function(__wf_cls) {
__wf_cls.prototype["readFile"] = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//FileSystem#readFile");
var __wf_cls_sym = Symbol.for("workflow-class-registry"), __wf_cls_reg = globalThis[__wf_cls_sym] || (globalThis[__wf_cls_sym] = new Map());
__wf_cls_reg.set("class//./input//FileSystem", __wf_cls);
if (!Object.prototype.hasOwnProperty.call(__wf_cls, "classId")) Object.defineProperty(__wf_cls, "classId", { /* ... */ });
return __wf_cls;
}(class FileSystem {
constructor(sandbox) { this.sandbox = sandbox; }
});
Note that:
- The IIFE closes over the class value itself (
__wf_cls), so the registration does not depend on any name being in scope at module level. The same output shape is produced for every position a class expression can appear in. - Everything recorded for the class (step methods, getters, custom serialization, static workflow methods) is emitted inside the single IIFE, in the same order as the module-level emission for class declarations. The registry lookups are hoisted once per registry rather than repeated per registration.
- A class expression with nothing to register is left untouched.
- Registration runs when the class expression is evaluated, which for a module-level class expression is module load, the same as for class declarations.
export default class { ... }is aClassExprin the AST but not an expression position; it is handled by the rewrite described below rather than by the IIFE.
Class names for IDs
The IIFE removes the need to reference the class by name, but step and class IDs still need a name (step//<module>//<ClassName>#<method>). The name is resolved, in order of preference, from:
- The variable the expression is assigned to:
var Foo = class _Foo {}usesFoo, not_Foo. This also coverslet Foo; Foo = class {}, parenthesized initializers (var Foo = (class {})), and chained assignments (var Foo = exports.Foo = class {}). With multiple declarators (var A = class {}, B = class {}) each class resolves to its own binding. - The class expression's own identifier:
foo(class Plugin {})usesPlugin. - The property the expression is assigned to or defined under:
exports.Foo = class {}and{ Foo: class {} }useFoo(string keys such as'kebab-job'are accepted as-is). - A generated
AnonymousClass<N>when none of the above applies (foo(class { ... }), an array element, a conditional branch).Ncounts, in source order, only the anonymous class expressions that have something to register, so unrelated anonymous classes do not shift the numbering; if the module already declaresAnonymousClass<N>, the name is suffixed (AnonymousClass6$1). Like the_anonymousStep<N>names used for anonymous step functions, these are positional: adding another such class earlier in the module renumbers the ones after it, and with them their step IDs. Name the class if its IDs need to be stable.
Names from (1) and (2) are bindings that already refer to the class, so when the class expression is anonymous the binding name is inserted as the class's own identifier (var Foo = class {} becomes (...)(class Foo {})). Passing the class as a call argument would otherwise defeat the .name inference the original assignment provided. For typical usage this is behaviorally equivalent to var Foo = class Foo {}; an inner class-scoped Foo binding is introduced, which can differ in edge cases that assign to or shadow that name inside the class body. Names from (3) are not inserted as an identifier, since exports.Foo = class { m() { return Foo; } } may refer to an unrelated outer Foo; the IIFE instead sets .name at runtime with Object.defineProperty(__wf_cls, "name", { value: "Foo", configurable: true }). Generated names (4) leave .name untouched, since the original position inferred no name either.
Classes that already have an identifier (e.g. class _Bash { ... }) are never renamed.
Dead-code elimination
Evaluating a wrapped class expression is what registers the class, so dead-code elimination keeps any module-level variable declaration whose initializer contains one, even when the declared binding is otherwise unreferenced (const registry = new Map([["point", class { ...serde... }]])).
Nested classes are errors
A class (declaration or expression) that has "use step"/"use workflow" methods, "use step" getters, or custom serialization but is declared inside a function is a compile error:
Classes using "use step" methods must be declared at the top level of the module, not inside a function. Registration runs at module load and cannot reach a class declared in an inner scope
Step registration must happen at module load for the step to be resolvable by ID; a class inside a function would only be registered when (and each time) that function runs. Earlier versions of the plugin emitted module-level code referencing the inner class's name (or a placeholder AnonymousClass), which threw a ReferenceError as soon as the module was evaluated. At most one error is reported per class, at the first offending member; nested classes without steps or serialization are unaffected. No errors are emitted in detect mode, which generates no code.
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. export default class { ... } is not an expression position, so the registration IIFE used for class expressions does not apply; the class is instead registered by module-level statements that reference the generated const.
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 because 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" |
| Nested class | A class with step/workflow methods, step getters, or custom serialization declared inside a function rather than at the module's top level |
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. It first collects references from all public members, then expands the referenced set by scanning surviving private members' bodies for cross-references until the set stabilizes. This process enables cascading elimination: a private field referenced only by an unreferenced private method is also 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, and others). Eliminating the methods 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. However,thiswill be whatever the caller of the step proxy passes, typicallynullorundefined, 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