Skip to content

Commit cf9fe73

Browse files
committed
refactor(swc-plugin): remove client transform mode, merge into step mode
Remove the `client` transform mode from the SWC compiler plugin. The `client` and `step` modes were nearly identical — both preserved step function bodies, replaced workflow bodies with throw stubs, and emitted the same JSON manifest. Step mode now absorbs all client-mode behaviors: - Dead code elimination (previously only workflow + client) - Hoisted variable references for object property steps - All integrations use mode: 'step' instead of 'client' BREAKING CHANGE: The `client` value for the SWC plugin `mode` option is no longer accepted. Use `step` instead.
1 parent 9513a81 commit cf9fe73

File tree

100 files changed

+115
-2493
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

100 files changed

+115
-2493
lines changed

.changeset/remove-client-mode.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@workflow/swc-plugin": major
3+
"@workflow/builders": patch
4+
"@workflow/cli": patch
5+
"@workflow/next": patch
6+
"@workflow/rollup": patch
7+
"@workflow/nest": patch
8+
---
9+
10+
**BREAKING CHANGE**: Remove `client` transform mode from SWC plugin. The `client` and `step` modes were nearly identical — both preserved step function bodies, replaced workflow bodies with throw stubs, and emitted the same JSON manifest. The only differences were the step registration mechanism (simple property assignment vs. IIFE) and whether DCE ran. Step mode now absorbs all client-mode behaviors: hoisted variable references for object property steps (so `.stepId` is accessible), and dead code elimination. All integrations that previously used `mode: 'client'` now use `mode: 'step'`.

packages/builders/src/apply-swc-transform.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export type WorkflowManifest = {
5050
export async function applySwcTransform(
5151
filename: string,
5252
source: string,
53-
mode: 'workflow' | 'step' | 'client' | 'detect' | false,
53+
mode: 'workflow' | 'step' | 'detect' | false,
5454
/**
5555
* Optional absolute path to the file being transformed.
5656
* Used for module specifier resolution when filename is relative.

packages/builders/src/base-builder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1110,7 +1110,7 @@ export const POST = workflowEntrypoint(workflowCode);`;
11101110
],
11111111
plugins: [
11121112
createSwcPlugin({
1113-
mode: 'client',
1113+
mode: 'step',
11141114
projectRoot: this.transformProjectRoot,
11151115
sideEffectEntries: normalizedClientSideEffectEntries,
11161116
}),

packages/builders/src/swc-esbuild-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { resolveWorkflowAliasRelativePath } from './workflow-alias.js';
1515

1616
export interface SwcPluginOptions {
17-
mode: 'step' | 'workflow' | 'client';
17+
mode: 'step' | 'workflow';
1818
entriesToBundle?: string[];
1919
outdir?: string;
2020
projectRoot?: string;

packages/cli/src/commands/transform.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import {
99
import chalk from 'chalk';
1010
import { BaseCommand } from '../base.js';
1111

12-
type TransformMode = 'workflow' | 'step' | 'client';
12+
type TransformMode = 'workflow' | 'step';
1313

14-
const ALL_MODES: TransformMode[] = ['workflow', 'step', 'client'];
14+
const ALL_MODES: TransformMode[] = ['workflow', 'step'];
1515

1616
export default class Transform extends BaseCommand {
1717
static description =
@@ -35,9 +35,9 @@ export default class Transform extends BaseCommand {
3535
static flags = {
3636
mode: Flags.string({
3737
char: 'm',
38-
description: 'Transform mode (workflow, step, client, or all)',
38+
description: 'Transform mode (workflow, step, or all)',
3939
default: 'all',
40-
options: ['workflow', 'step', 'client', 'all'],
40+
options: ['workflow', 'step', 'all'],
4141
}),
4242
'check-serde': Flags.boolean({
4343
description: 'Run serde compliance analysis on the transformed output',

packages/nest/src/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function generateSwcrc(
3636
decoratorMetadata: true,
3737
},
3838
experimental: {
39-
plugins: [[pluginPath, { mode: 'client' }]],
39+
plugins: [[pluginPath, { mode: 'step' }]],
4040
},
4141
},
4242
module: {
@@ -62,7 +62,7 @@ Options:
6262
--force Overwrite existing .swcrc file
6363
6464
This command generates a .swcrc file configured with the Workflow SWC plugin
65-
for client-mode transformations. The plugin path is resolved from the
65+
for step-mode transformations. The plugin path is resolved from the
6666
@workflow/nest package, so no additional hoisting configuration is needed.
6767
`);
6868
}

packages/next/src/loader.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,7 @@ function stripDeferredStepSourceMetadataComment(source: string): string {
616616
}
617617

618618
// This loader applies the "use workflow"/"use step" transform.
619-
// Deferred step-copy files are transformed in step mode; all other files use client mode.
619+
// Deferred step-copy files are transformed in step mode; all other files also use step mode.
620620
type WorkflowLoaderContext = {
621621
resourcePath: string;
622622
async?: () => (
@@ -743,7 +743,7 @@ export default function workflowLoader(
743743
deferredStepSourceMetadata?.absolutePath || filename,
744744
workingDir
745745
);
746-
const mode = isDeferredStepCopyFile ? 'step' : 'client';
746+
const mode = 'step';
747747

748748
// Transform with SWC
749749
const result = await transform(sourceForTransform, {

packages/rollup/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function workflowTransformPlugin(
2525
return {
2626
name: 'workflow:transform',
2727
// This transform applies the "use workflow"/"use step"
28-
// client transformation
28+
// step transformation
2929
async transform(code: string, id: string) {
3030
// Skip generated workflow route files to avoid re-processing them
3131
if (isGeneratedWorkflowFile(id)) {
@@ -116,7 +116,7 @@ export function workflowTransformPlugin(
116116
},
117117
target: 'es2022',
118118
experimental: {
119-
plugins: [[swcPlugin, { mode: 'client', moduleSpecifier }]],
119+
plugins: [[swcPlugin, { mode: 'step', moduleSpecifier }]],
120120
},
121121
transform: {
122122
react: {

packages/swc-playground-wasm/src/lib.rs

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ struct TransformOutput {
3636
struct BatchOutput {
3737
workflow: TransformOutput,
3838
step: TransformOutput,
39-
client: TransformOutput,
4039
}
4140

4241
/// Custom emitter that silently consumes diagnostics.
@@ -150,12 +149,12 @@ pub fn transform(source: &str, config_json: &str) -> String {
150149
serde_json::to_string(&output).unwrap()
151150
}
152151

153-
/// Transform source code in all three modes at once (workflow, step, client).
152+
/// Transform source code in both modes at once (workflow, step).
154153
///
155154
/// `config_json` should be a JSON string like:
156155
/// `{"moduleSpecifier": "my-package@1.0.0", "filename": "input.ts"}`
157156
///
158-
/// Returns a JSON string with `{"workflow": {...}, "step": {...}, "client": {...}}`.
157+
/// Returns a JSON string with `{"workflow": {...}, "step": {...}}`.
159158
#[wasm_bindgen(js_name = "transformAll")]
160159
pub fn transform_all(source: &str, config_json: &str) -> String {
161160
#[derive(Deserialize)]
@@ -176,19 +175,14 @@ pub fn transform_all(source: &str, config_json: &str) -> String {
176175
};
177176
let output = BatchOutput {
178177
workflow: error_output.clone(),
179-
step: error_output.clone(),
180-
client: error_output,
178+
step: error_output,
181179
};
182180
return serde_json::to_string(&output).unwrap();
183181
}
184182
};
185183

186-
let modes = [
187-
TransformMode::Workflow,
188-
TransformMode::Step,
189-
TransformMode::Client,
190-
];
191-
let mut results = Vec::with_capacity(3);
184+
let modes = [TransformMode::Workflow, TransformMode::Step];
185+
let mut results = Vec::with_capacity(2);
192186

193187
for mode in &modes {
194188
let config = TransformConfig {
@@ -202,7 +196,6 @@ pub fn transform_all(source: &str, config_json: &str) -> String {
202196
let output = BatchOutput {
203197
workflow: results.remove(0),
204198
step: results.remove(0),
205-
client: results.remove(0),
206199
};
207200

208201
serde_json::to_string(&output).unwrap()

packages/swc-plugin-workflow/spec.md

Lines changed: 8 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
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.
44

5-
The SWC plugin has 4 modes: **Step mode**, **Workflow mode**, **Client mode**, and **Detect mode**.
5+
The SWC plugin has 3 modes: **Step mode**, **Workflow mode**, and **Detect mode**.
66

77
## Directive Placement
88

@@ -91,6 +91,10 @@ Note: File extensions are stripped from local paths for cleaner IDs.
9191

9292
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).
9393

94+
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.
95+
96+
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.
97+
9498
### Basic Step Function
9599

96100
Input:
@@ -249,24 +253,7 @@ export const vade = agent({
249253
});
250254
```
251255

252-
Output (Client Mode):
253-
```javascript
254-
import { agent } from "experimental-agent";
255-
/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/;
256-
var vade$tools$VercelRequest$execute = async function(input, ctx) {
257-
return 1 + 1;
258-
};
259-
export const vade = agent({
260-
tools: {
261-
VercelRequest: {
262-
execute: vade$tools$VercelRequest$execute
263-
}
264-
}
265-
});
266-
vade$tools$VercelRequest$execute.stepId = "step//./input//vade/tools/VercelRequest/execute";
267-
```
268-
269-
Note: In client mode, nested object property step functions are hoisted and have `stepId` set directly via inline property assignment. Step mode also uses inline registration (no import) via a self-contained IIFE. The original call site is replaced with a reference to the hoisted variable in both modes.
256+
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.
270257

271258
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.
272259

@@ -451,7 +438,7 @@ export async function subtract(a, b) {
451438

452439
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()`.
453440

454-
After the workflow-mode rewrite, the transform also runs a dead code elimination (DCE) pass. This pruning only affects the emitted workflow/client outputs, not step-mode output. In workflow mode, 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.
441+
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.
455442

456443
### Step Functions
457444

@@ -526,98 +513,6 @@ globalThis.__private_workflows.set("workflow//./input//myWorkflow", myWorkflow);
526513

527514
---
528515

529-
## Client Mode
530-
531-
In client mode, step function bodies are preserved as-is (allowing local testing/execution), and step functions have their `stepId` property set so they can be properly serialized when passed across boundaries (e.g., as arguments to `start()` or returned from other step functions). Workflow functions throw an error and have `workflowId` attached for use with `start()`.
532-
533-
Like step mode, client mode also uses inline property assignments with no imports. The `stepId` property is set directly on the function, similar to how `workflowId` is set on workflow functions. The difference is that client mode uses a simple property assignment, while step mode uses an inline IIFE that also adds the function to the global step registry.
534-
535-
Client mode also runs the same DCE pass after transform. The key difference from workflow mode is that module-level step bodies are still preserved and executable, so any imports, local helpers, or other declarations that are referenced only from those step bodies must also be preserved. By contrast, code that is reachable only from workflow bodies that were replaced with throwing stubs can still be removed.
536-
537-
Note: Step functions nested inside other functions (whether workflow functions or regular functions) do NOT get `stepId` assignments in client mode because they are not accessible at module level. In practice, nested steps and helpers that are only reachable from a workflow body are often pruned by the client-mode DCE pass once that workflow body has been replaced.
538-
539-
### Step Functions
540-
541-
Input:
542-
```javascript
543-
export async function add(a, b) {
544-
"use step";
545-
return a + b;
546-
}
547-
```
548-
549-
Output:
550-
```javascript
551-
/**__internal_workflows{"steps":{"input.js":{"add":{"stepId":"step//./input//add"}}}}*/;
552-
export async function add(a, b) {
553-
return a + b;
554-
}
555-
add.stepId = "step//./input//add";
556-
```
557-
558-
### Workflow Functions
559-
560-
Input:
561-
```javascript
562-
export async function myWorkflow(data) {
563-
"use workflow";
564-
return await processData(data);
565-
}
566-
```
567-
568-
Output:
569-
```javascript
570-
/**__internal_workflows{"workflows":{"input.js":{"myWorkflow":{"workflowId":"workflow//./input//myWorkflow"}}}}*/;
571-
export async function myWorkflow(data) {
572-
throw new Error("You attempted to execute workflow myWorkflow function directly. To start a workflow, use start(myWorkflow) from workflow/api");
573-
}
574-
myWorkflow.workflowId = "workflow//./input//myWorkflow";
575-
```
576-
577-
### Custom Serialization in Client Mode
578-
579-
Classes with custom serialization methods are also registered in client mode so that they can be properly serialized when passed to `start(workflow)`:
580-
581-
Input:
582-
```javascript
583-
export class Point {
584-
constructor(x, y) {
585-
this.x = x;
586-
this.y = y;
587-
}
588-
589-
static [Symbol.for("workflow-serialize")](instance) {
590-
return { x: instance.x, y: instance.y };
591-
}
592-
593-
static [Symbol.for("workflow-deserialize")](data) {
594-
return new Point(data.x, data.y);
595-
}
596-
}
597-
```
598-
599-
Output (Client Mode):
600-
```javascript
601-
/**__internal_workflows{"classes":{"input.js":{"Point":{"classId":"class//./input//Point"}}}}*/;
602-
export class Point {
603-
constructor(x, y) {
604-
this.x = x;
605-
this.y = y;
606-
}
607-
static [Symbol.for("workflow-serialize")](instance) {
608-
return { x: instance.x, y: instance.y };
609-
}
610-
static [Symbol.for("workflow-deserialize")](data) {
611-
return new Point(data.x, data.y);
612-
}
613-
}
614-
(function(__wf_cls, __wf_id) {
615-
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
616-
__wf_reg.set(__wf_id, __wf_cls);
617-
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
618-
})(Point, "class//./input//Point");
619-
```
620-
621516
---
622517

623518
## Detect Mode
@@ -1009,21 +904,18 @@ This allows serialization classes to be defined in separate files (such as Next.
1009904

1010905
### Cross-Context Class Registration
1011906

1012-
Classes with custom serialization are automatically included in **all bundle contexts** (step, workflow, client) to ensure they can be properly serialized and deserialized when crossing execution boundaries:
907+
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:
1013908

1014909
| Boundary | Serializer | Deserializer | Example |
1015910
|----------|------------|--------------|---------|
1016-
| Client → Workflow | Client mode | Workflow mode | Passing a `Point` instance to `start(workflow)` |
1017911
| Workflow → Step | Workflow mode | Step mode | Passing a `Point` instance as step argument |
1018912
| Step → Workflow | Step mode | Workflow mode | Returning a `Point` instance from a step |
1019-
| Workflow → Client | Workflow mode | Client mode | Returning a `Point` instance from a workflow |
1020913

1021914
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.
1022915

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

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

@@ -1124,8 +1016,6 @@ Object.defineProperty(ClassName.prototype, "prop", {
11241016
});
11251017
```
11261018

1127-
**Client mode**: The getter is preserved with the directive stripped (no registration).
1128-
11291019
### Static getter transformation
11301020

11311021
Same as instance getters but targets `ClassName` instead of `ClassName.prototype`, and uses `.` separator in the step ID (same as static methods).
@@ -1162,8 +1052,6 @@ const obj = {
11621052
};
11631053
```
11641054

1165-
**Client mode**: Same as step mode — the getter body is hoisted for `stepId` assignment, original getter preserved.
1166-
11671055
### Private member dead code elimination
11681056

11691057
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:

0 commit comments

Comments
 (0)