Skip to content

Commit 5d22e61

Browse files
authored
fix(swc-plugin): use binding name for class expression method registrations (#1599)
* fix(builders): override sideEffects:false for discovered workflow/step/serde entries When node_modules packages include "sideEffects": false in their package.json, esbuild drops bare imports from the virtual-entry.js file. This is incorrect because the SWC compiler transform injects side-effectful registration code (workflow IDs, step IDs, class serialization) into these modules. Fix: return the resolved path alongside sideEffects: true from the onResolve handler so esbuild uses the plugin's resolution result instead of re-reading the package.json. * refactor(builders): normalize sideEffectEntries with realpaths for symlink compatibility Extract withRealpaths() helper and use it for both normalizedEntriesToBundle and sideEffectEntries at all three bundle sites. This ensures the sideEffects override works correctly under pnpm/workspace symlinked layouts where enhanced-resolve may return realpaths that differ from the original discovered file paths. * perf(builders): skip enhanced-resolve for transitive imports when only sideEffectEntries is set When entriesToBundle is not set (workflow/client bundles), only top-level import statements need the sideEffects override — transitive imports from deep within the bundle are not bare imports and don't need resolution. Skip enhanced-resolve for non-import-statement kinds to reduce overhead. * fix(swc-plugin): use binding name for class expression method registrations When a pre-bundled package (e.g. via tsup) contains class expressions like `var Foo = class _Foo { ... }`, the internal name `_Foo` is only scoped inside the class body. The SWC plugin was incorrectly using the internal name for method step registrations and class serialization registrations emitted at module scope, causing ReferenceError at runtime. Fix: always use the binding name (registration_name) for current_class_name in visit_mut_class_expr, consistent with the existing handling for anonymous class expressions. This ensures: - registerStepFunction calls reference the binding name (Foo) - Only one class registration IIFE is emitted (not duplicates for both Foo and _Foo) - Step IDs use the binding name in their qualified path * refactor(swc-plugin): rename internal_class_name to tracked_class_name for clarity The variable no longer represents the internal class expression identifier after being reassigned to the binding name. Rename to tracked_class_name and eliminate the intermediate registration_name variable to make the intent clearer and reduce confusion for future readers.
1 parent 443a9e6 commit 5d22e61

7 files changed

Lines changed: 204 additions & 17 deletions

File tree

.changeset/smooth-pens-beam.md

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+
Fix class expression method registrations to use binding name instead of internal class name, preventing `ReferenceError` at runtime for pre-bundled packages

packages/swc-plugin-workflow/spec.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,35 @@ Note that:
813813
- The `classId` in the manifest also uses `Bash`
814814
- This ensures the registration call references a symbol that's actually in scope at module level
815815

816+
This binding-name preference applies to **all** generated code that references the class at module scope, including:
817+
- Class serialization registration IIFEs
818+
- Step method registrations (`registerStepFunction` calls)
819+
- Workflow method stub assignments
820+
821+
For example, a class expression with step methods:
822+
823+
Input:
824+
```javascript
825+
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
826+
827+
var LanguageModel = class _LanguageModel {
828+
constructor(modelId) { this.modelId = modelId; }
829+
static [WORKFLOW_SERIALIZE](inst) { return { modelId: inst.modelId }; }
830+
static [WORKFLOW_DESERIALIZE](data) { return new _LanguageModel(data.modelId); }
831+
async doStream(prompt) { "use step"; return { stream: prompt }; }
832+
static async generate(input) { "use step"; return { result: input }; }
833+
};
834+
```
835+
836+
Output (step mode):
837+
```javascript
838+
registerStepFunction("step//./input//LanguageModel.generate", LanguageModel.generate);
839+
registerStepFunction("step//./input//LanguageModel#doStream", LanguageModel.prototype["doStream"]);
840+
(function(__wf_cls, __wf_id) { /* ... */ })(LanguageModel, "class//./input//LanguageModel");
841+
```
842+
843+
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.
844+
816845
### Anonymous Class Expression Name Re-insertion
817846

818847
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).

packages/swc-plugin-workflow/transform/src/lib.rs

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7327,28 +7327,34 @@ impl VisitMut for StepTransform {
73277327
// Get the binding name set by visit_mut_var_decl (e.g., "Foo" from `var Foo = class { ... }`)
73287328
let binding_name = self.current_class_binding_name.take();
73297329

7330-
// Get the internal class name (used for current_class_name tracking)
7331-
let mut internal_class_name = class_expr
7330+
// Get the internal class expression name (e.g. `_Foo` from `class _Foo { ... }`)
7331+
let expr_ident_name = class_expr
73327332
.ident
73337333
.as_ref()
73347334
.map(|i| i.sym.to_string())
73357335
.unwrap_or_else(|| "AnonymousClass".to_string());
73367336

7337-
// For serialization registration, use the binding name if available
7338-
// e.g., for `var Bash = class _Bash {}`, use "Bash" not "_Bash"
7339-
// because "_Bash" is not accessible at module scope
7340-
let registration_name = binding_name
7337+
// Compute the tracked class name: prefer the binding name (e.g. `Foo`
7338+
// from `var Foo = class _Foo {}`) over the internal class expression
7339+
// name (`_Foo`). The internal name is only scoped inside the class body
7340+
// and is not accessible at module level, so all generated code emitted
7341+
// outside the class — method step registrations, class serialization
7342+
// IIFEs, and method-stripping filters — must use the binding name.
7343+
// Without this, generated code like
7344+
// `registerStepFunction("...", _Foo.prototype["method"])` would
7345+
// produce a ReferenceError at runtime.
7346+
let tracked_class_name = binding_name
73417347
.clone()
7342-
.unwrap_or_else(|| internal_class_name.clone());
7348+
.unwrap_or_else(|| expr_ident_name.clone());
73437349

73447350
let old_class_name = self.current_class_name.take();
7345-
self.current_class_name = Some(internal_class_name.clone());
7351+
self.current_class_name = Some(tracked_class_name.clone());
73467352

73477353
// Check if class has custom serialization methods (WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE)
73487354
let has_serde = self.has_custom_serialization_methods(&class_expr.class);
73497355
if has_serde {
73507356
self.classes_needing_serialization
7351-
.insert(registration_name.clone());
7357+
.insert(tracked_class_name.clone());
73527358
}
73537359

73547360
// esbuild emits anonymous class expressions for classes that don't
@@ -7366,12 +7372,6 @@ impl VisitMut for StepTransform {
73667372
DUMMY_SP,
73677373
SyntaxContext::empty(),
73687374
));
7369-
// Recompute internal_class_name and update current_class_name so
7370-
// that subsequent logic (e.g. step/workflow method naming and
7371-
// method-stripping filters) uses the actual class name rather
7372-
// than "AnonymousClass".
7373-
internal_class_name = name.clone();
7374-
self.current_class_name = Some(name.clone());
73757375
}
73767376
}
73777377

@@ -7383,14 +7383,14 @@ impl VisitMut for StepTransform {
73837383
let static_methods_to_strip: Vec<_> = self
73847384
.static_step_methods_to_strip
73857385
.iter()
7386-
.filter(|(cn, _, _)| cn == &internal_class_name)
7386+
.filter(|(cn, _, _)| cn == &tracked_class_name)
73877387
.map(|(_, mn, _)| mn.clone())
73887388
.collect();
73897389

73907390
let instance_methods_to_strip: Vec<_> = self
73917391
.instance_step_methods_to_strip
73927392
.iter()
7393-
.filter(|(cn, _, _)| cn == &internal_class_name)
7393+
.filter(|(cn, _, _)| cn == &tracked_class_name)
73947394
.map(|(_, mn, _)| mn.clone())
73957395
.collect();
73967396

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Test class expression where binding name differs from internal class name
2+
// AND the class has step methods (instance + static).
3+
// The registration code must reference the binding name (LanguageModel),
4+
// not the internal name (_LanguageModel) which is only scoped inside the class body.
5+
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
6+
7+
var LanguageModel = class _LanguageModel {
8+
constructor(modelId, config) {
9+
this.modelId = modelId;
10+
this.config = config;
11+
}
12+
13+
static [WORKFLOW_SERIALIZE](instance) {
14+
return { modelId: instance.modelId, config: instance.config };
15+
}
16+
17+
static [WORKFLOW_DESERIALIZE](data) {
18+
return new _LanguageModel(data.modelId, data.config);
19+
}
20+
21+
async doStream(prompt) {
22+
"use step";
23+
return { stream: prompt };
24+
}
25+
26+
static async generate(input) {
27+
"use step";
28+
return { result: input };
29+
}
30+
};
31+
32+
export { LanguageModel };
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Test class expression where binding name differs from internal class name
2+
// AND the class has step methods (instance + static).
3+
// The registration code must reference the binding name (LanguageModel),
4+
// not the internal name (_LanguageModel) which is only scoped inside the class body.
5+
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
6+
/**__internal_workflows{"steps":{"input.js":{"LanguageModel#doStream":{"stepId":"step//./input//LanguageModel#doStream"},"LanguageModel.generate":{"stepId":"step//./input//LanguageModel.generate"}}},"classes":{"input.js":{"LanguageModel":{"classId":"class//./input//LanguageModel"}}}}*/;
7+
var LanguageModel = class _LanguageModel {
8+
constructor(modelId, config){
9+
this.modelId = modelId;
10+
this.config = config;
11+
}
12+
static [WORKFLOW_SERIALIZE](instance) {
13+
return {
14+
modelId: instance.modelId,
15+
config: instance.config
16+
};
17+
}
18+
static [WORKFLOW_DESERIALIZE](data) {
19+
return new _LanguageModel(data.modelId, data.config);
20+
}
21+
async doStream(prompt) {
22+
return {
23+
stream: prompt
24+
};
25+
}
26+
static async generate(input) {
27+
return {
28+
result: input
29+
};
30+
}
31+
};
32+
export { LanguageModel };
33+
(function(__wf_cls, __wf_id) {
34+
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
35+
__wf_reg.set(__wf_id, __wf_cls);
36+
Object.defineProperty(__wf_cls, "classId", {
37+
value: __wf_id,
38+
writable: false,
39+
enumerable: false,
40+
configurable: false
41+
});
42+
})(LanguageModel, "class//./input//LanguageModel");
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { registerStepFunction } from "workflow/internal/private";
2+
// Test class expression where binding name differs from internal class name
3+
// AND the class has step methods (instance + static).
4+
// The registration code must reference the binding name (LanguageModel),
5+
// not the internal name (_LanguageModel) which is only scoped inside the class body.
6+
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
7+
/**__internal_workflows{"steps":{"input.js":{"LanguageModel#doStream":{"stepId":"step//./input//LanguageModel#doStream"},"LanguageModel.generate":{"stepId":"step//./input//LanguageModel.generate"}}},"classes":{"input.js":{"LanguageModel":{"classId":"class//./input//LanguageModel"}}}}*/;
8+
var LanguageModel = class _LanguageModel {
9+
constructor(modelId, config){
10+
this.modelId = modelId;
11+
this.config = config;
12+
}
13+
static [WORKFLOW_SERIALIZE](instance) {
14+
return {
15+
modelId: instance.modelId,
16+
config: instance.config
17+
};
18+
}
19+
static [WORKFLOW_DESERIALIZE](data) {
20+
return new _LanguageModel(data.modelId, data.config);
21+
}
22+
async doStream(prompt) {
23+
return {
24+
stream: prompt
25+
};
26+
}
27+
static async generate(input) {
28+
return {
29+
result: input
30+
};
31+
}
32+
};
33+
export { LanguageModel };
34+
registerStepFunction("step//./input//LanguageModel.generate", LanguageModel.generate);
35+
registerStepFunction("step//./input//LanguageModel#doStream", LanguageModel.prototype["doStream"]);
36+
(function(__wf_cls, __wf_id) {
37+
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
38+
__wf_reg.set(__wf_id, __wf_cls);
39+
Object.defineProperty(__wf_cls, "classId", {
40+
value: __wf_id,
41+
writable: false,
42+
enumerable: false,
43+
configurable: false
44+
});
45+
})(LanguageModel, "class//./input//LanguageModel");
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Test class expression where binding name differs from internal class name
2+
// AND the class has step methods (instance + static).
3+
// The registration code must reference the binding name (LanguageModel),
4+
// not the internal name (_LanguageModel) which is only scoped inside the class body.
5+
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
6+
/**__internal_workflows{"steps":{"input.js":{"LanguageModel#doStream":{"stepId":"step//./input//LanguageModel#doStream"},"LanguageModel.generate":{"stepId":"step//./input//LanguageModel.generate"}}},"classes":{"input.js":{"LanguageModel":{"classId":"class//./input//LanguageModel"}}}}*/;
7+
var LanguageModel = class _LanguageModel {
8+
constructor(modelId, config){
9+
this.modelId = modelId;
10+
this.config = config;
11+
}
12+
static [WORKFLOW_SERIALIZE](instance) {
13+
return {
14+
modelId: instance.modelId,
15+
config: instance.config
16+
};
17+
}
18+
static [WORKFLOW_DESERIALIZE](data) {
19+
return new _LanguageModel(data.modelId, data.config);
20+
}
21+
};
22+
export { LanguageModel };
23+
LanguageModel.generate = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//LanguageModel.generate");
24+
LanguageModel.prototype["doStream"] = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//LanguageModel#doStream");
25+
(function(__wf_cls, __wf_id) {
26+
var __wf_sym = Symbol.for("workflow-class-registry"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
27+
__wf_reg.set(__wf_id, __wf_cls);
28+
Object.defineProperty(__wf_cls, "classId", {
29+
value: __wf_id,
30+
writable: false,
31+
enumerable: false,
32+
configurable: false
33+
});
34+
})(LanguageModel, "class//./input//LanguageModel");

0 commit comments

Comments
 (0)