Skip to content

Commit ae5ee5b

Browse files
authored
fix(swc-plugin): register class expressions via an IIFE instead of by name (vercel#3971)
* fix(swc-plugin): register class expressions via an IIFE and reject unnameable classes Class expressions with "use step" methods or custom serialization were registered by module-level statements referencing the class by name. When no module-scope binding could be resolved the plugin fell back to a placeholder `AnonymousClass` identifier, which is a guaranteed ReferenceError at module evaluation (vercel#3929). Other shapes were silently wrong as well: `var A = class {}, B = class {}` registered A's steps under B, `X = class {}` assignments and classes nested inside functions emitted unresolvable references. Class expressions are now wrapped in a single IIFE that receives the class, performs every registration recorded for it, and returns it, so the registration no longer depends on a name being in scope. The class name is still needed for step/class IDs and is derived from the assigned variable, the class's own identifier, or the property key it is assigned to (`exports.Foo = class {}`, `{ Foo: class {} }`). When none is available, or the class is declared inside a function, the plugin emits a compile error instead of broken code. Class declarations keep their existing module-level output; the emitters were factored so both paths share the same statement builders. * fix(swc-plugin): generate names for anonymous class expressions instead of erroring With registration happening inside the IIFE, an anonymous class expression in a position that provides no name (`foo(class { ... })`, an array element, a conditional branch) only needs a name for its step/class IDs. Generate a deterministic `AnonymousClass<N>`, counting only anonymous classes that have something to register, instead of rejecting them. Classes declared inside a function remain an error. Dead-code elimination now keeps module-level declarations whose initializer contains a wrapped class expression: evaluating the initializer is what registers the class, and the binding may be otherwise unreferenced.
1 parent c129332 commit ae5ee5b

18 files changed

Lines changed: 3055 additions & 1773 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflow/swc-plugin': patch
3+
---
4+
5+
Register class expressions through an IIFE that closes over the class instead of module-level code that references it by name, fixing the unresolvable `AnonymousClass` reference emitted for shapes such as `var Foo = class { ... }` in pre-bundled packages.

‎packages/swc-plugin-workflow/spec.md‎

Lines changed: 60 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -714,146 +714,91 @@ Destructured require also supports renaming (analogous to `import { WORKFLOW_SER
714714
const { WORKFLOW_SERIALIZE: WS, WORKFLOW_DESERIALIZE: WD } = require("@workflow/serde");
715715
```
716716

717-
### Class expressions with binding names
717+
### Class expressions
718718

719-
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.
719+
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).
720720

721-
Input:
722-
```javascript
723-
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
724-
725-
var Bash = class _Bash {
726-
constructor(command) {
727-
this.command = command;
728-
}
729-
730-
static [WORKFLOW_SERIALIZE](instance) {
731-
return { command: instance.command };
732-
}
721+
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:
733722

734-
static [WORKFLOW_DESERIALIZE](data) {
735-
return new Bash(data.command);
736-
}
723+
Input (e.g., after tsdown/esbuild pre-bundling; this is the shape `@vercel/sandbox` ships):
724+
```javascript
725+
var FileSystem = class {
726+
constructor(sandbox) { this.sandbox = sandbox; }
727+
async readFile(path) { "use step"; return this.sandbox.read(path); }
737728
};
729+
export { FileSystem };
738730
```
739731

740-
Output:
732+
Output (step mode):
741733
```javascript
742-
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
743-
/**__internal_workflows{"classes":{"input.js":{"Bash":{"classId":"class//./input//Bash"}}}}*/;
744-
var Bash = class _Bash {
745-
constructor(command) {
746-
this.command = command;
747-
}
748-
static [WORKFLOW_SERIALIZE](instance) {
749-
return { command: instance.command };
750-
}
751-
static [WORKFLOW_DESERIALIZE](data) {
752-
return new Bash(data.command);
753-
}
754-
};
755-
(function(__wf_cls, __wf_id) {
756-
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
757-
__wf_reg.set(__wf_id, __wf_cls);
758-
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
759-
})(Bash, "class//./input//Bash");
734+
/**__internal_workflows{"steps":{"input.js":{"FileSystem#readFile":{"stepId":"step//./input//FileSystem#readFile"}}},"classes":{"input.js":{"FileSystem":{"classId":"class//./input//FileSystem"}}}}*/;
735+
var FileSystem = function(__wf_cls) {
736+
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map()), __wf_fn;
737+
__wf_fn = __wf_cls.prototype["readFile"];
738+
__wf_reg.set("step//./input//FileSystem#readFile", __wf_fn);
739+
__wf_fn.stepId = "step//./input//FileSystem#readFile";
740+
Object.defineProperty(__wf_fn, "name", { value: "readFile", configurable: true });
741+
var __wf_cls_sym = Symbol.for("workflow-class-registry"), __wf_cls_reg = globalThis[__wf_cls_sym] || (globalThis[__wf_cls_sym] = new Map());
742+
__wf_cls_reg.set("class//./input//FileSystem", __wf_cls);
743+
Object.defineProperty(__wf_cls, "classId", { value: "class//./input//FileSystem", writable: false, enumerable: false, configurable: false });
744+
return __wf_cls;
745+
}(class FileSystem {
746+
constructor(sandbox) { this.sandbox = sandbox; }
747+
async readFile(path) { return this.sandbox.read(path); }
748+
});
749+
export { FileSystem };
760750
```
761751

762-
Note that:
763-
- The registration uses `Bash` (the variable name), not `_Bash` (the internal class name)
764-
- The `classId` in the manifest also uses `Bash`
765-
- This ensures the registration call references a symbol that's actually in scope at module level
766-
767-
This binding-name preference applies to **all** generated code that references the class at module scope, including:
768-
- Class serialization registration IIFEs
769-
- Step method registrations (inline IIFE calls)
770-
- Workflow method stub assignments
752+
Output (workflow mode):
753+
```javascript
754+
var FileSystem = function(__wf_cls) {
755+
__wf_cls.prototype["readFile"] = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//FileSystem#readFile");
756+
var __wf_cls_sym = Symbol.for("workflow-class-registry"), __wf_cls_reg = globalThis[__wf_cls_sym] || (globalThis[__wf_cls_sym] = new Map());
757+
__wf_cls_reg.set("class//./input//FileSystem", __wf_cls);
758+
Object.defineProperty(__wf_cls, "classId", { /* ... */ });
759+
return __wf_cls;
760+
}(class FileSystem {
761+
constructor(sandbox) { this.sandbox = sandbox; }
762+
});
763+
```
771764

772-
For example, a class expression with step methods:
765+
Note that:
766+
- 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.
767+
- 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.
768+
- A class expression with nothing to register is left untouched.
769+
- Registration runs when the class expression is evaluated, which for a module-level class expression is module load, the same as for class declarations.
770+
- `export default class { ... }` is a `ClassExpr` in the AST but not an expression position; it is handled by the rewrite described below rather than by the IIFE.
773771

774-
Input:
775-
```javascript
776-
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
772+
#### Class names for IDs
777773

778-
var LanguageModel = class _LanguageModel {
779-
constructor(modelId) { this.modelId = modelId; }
780-
static [WORKFLOW_SERIALIZE](inst) { return { modelId: inst.modelId }; }
781-
static [WORKFLOW_DESERIALIZE](data) { return new _LanguageModel(data.modelId); }
782-
async doStream(prompt) { "use step"; return { stream: prompt }; }
783-
static async generate(input) { "use step"; return { result: input }; }
784-
};
785-
```
774+
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:
786775

787-
Output (step mode):
788-
```javascript
789-
(function(__wf_fn, __wf_id) {
790-
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
791-
__wf_reg.set(__wf_id, __wf_fn);
792-
__wf_fn.stepId = __wf_id;
793-
})(LanguageModel.generate, "step//./input//LanguageModel.generate");
794-
(function(__wf_fn, __wf_id) {
795-
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
796-
__wf_reg.set(__wf_id, __wf_fn);
797-
__wf_fn.stepId = __wf_id;
798-
})(LanguageModel.prototype["doStream"], "step//./input//LanguageModel#doStream");
799-
(function(__wf_cls, __wf_id) { /* ... */ })(LanguageModel, "class//./input//LanguageModel");
800-
```
776+
1. The variable the expression is assigned to: `var Foo = class _Foo {}` uses `Foo`, not `_Foo`. This also covers `let 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.
777+
2. The class expression's own identifier: `foo(class Plugin {})` uses `Plugin`.
778+
3. The property the expression is assigned to or defined under: `exports.Foo = class {}` and `{ Foo: class {} }` use `Foo` (string keys such as `'kebab-job'` are accepted as-is).
779+
4. A generated `AnonymousClass<N>` when none of the above applies (`foo(class { ... })`, an array element, a conditional branch). `N` counts, 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 declares `AnonymousClass<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.
801780

802-
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.
781+
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.
803782

804-
### Anonymous class expression name re-insertion
783+
Classes that already have an identifier (e.g. `class _Bash { ... }`) are never renamed.
805784

806-
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).
785+
#### Dead-code elimination
807786

808-
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.
787+
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... }]])`).
809788

810-
Input (e.g., after tsup pre-bundling):
811-
```javascript
812-
var Shell = class {
813-
constructor(cmd) {
814-
this.cmd = cmd;
815-
}
789+
#### Nested classes are errors
816790

817-
static [Symbol.for('workflow-serialize')](instance) {
818-
return { cmd: instance.cmd };
819-
}
791+
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:
820792

821-
static [Symbol.for('workflow-deserialize')](data) {
822-
return new Shell(data.cmd);
823-
}
824-
};
825793
```
826-
827-
Output:
828-
```javascript
829-
/**__internal_workflows{"classes":{"input.js":{"Shell":{"classId":"class//./input//Shell"}}}}*/;
830-
var Shell = class Shell {
831-
constructor(cmd) {
832-
this.cmd = cmd;
833-
}
834-
static [Symbol.for('workflow-serialize')](instance) {
835-
return { cmd: instance.cmd };
836-
}
837-
static [Symbol.for('workflow-deserialize')](data) {
838-
return new Shell(data.cmd);
839-
}
840-
};
841-
(function(__wf_cls, __wf_id) {
842-
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
843-
__wf_reg.set(__wf_id, __wf_cls);
844-
Object.defineProperty(__wf_cls, "classId", { value: __wf_id, writable: false, enumerable: false, configurable: false });
845-
})(Shell, "class//./input//Shell");
794+
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
846795
```
847796

848-
Note that:
849-
- The class expression `class { ... }` becomes `class Shell { ... }` with the binding name inserted
850-
- For typical usage, behavior is preserved while ensuring the `.name` property 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)
851-
- Classes that already have an internal name (e.g., `class _Bash { ... }`) are not modified
852-
- Only classes with serialization methods (`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`) are affected
797+
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.
853798

854799
### Anonymous default class export rewriting
855800

856-
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.
801+
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`.
857802

858803
Input:
859804
```javascript
@@ -958,6 +903,7 @@ The plugin emits errors for invalid usage:
958903
| Invalid exports (`"use workflow"`) | Module-level `"use workflow"` files can only export async functions |
959904
| Invalid exports (`"use step"`) | Module-level `"use step"` files can only export functions (sync or async) |
960905
| Misspelled directive | Detects typos like `"use steps"` or `"use workflows"` |
906+
| 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 |
961907

962908
---
963909

0 commit comments

Comments
 (0)