How do @workflow/serde keys impact bundles?
How does having a class with @workflow/serde keys impact the workflow and step bundles?
Now I have a comprehensive picture. Here's the full answer:
##How @workflow/serde Keys Impact Workflow and Step Bundles
###What @workflow/serde Provides
The @workflow/serde package exports two well-known symbols:
export const WORKFLOW_SERIALIZE = Symbol.for('workflow-serialize');
export const WORKFLOW_DESERIALIZE = Symbol.for('workflow-deserialize');
You place these as static computed property methods on a class to tell the runtime how to serialize/deserialize instances of that class:
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
class Point {
constructor(public x: number, public y: number) {}
static [WORKFLOW_SERIALIZE](instance: Point) { return { x: instance.x, y: instance.y }; }
static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) { return new Point(data.x, data.y); }
}
(You can also use Symbol.for('workflow-serialize') directly — the SWC plugin recognizes both forms.)
###Impact on Bundles — The Three Key Effects
Having a class with serde keys causes three distinct things to happen at build time:
####1. File Discovery — Serde files are auto-discovered for transformation
The build system scans all source files (not just those with "use workflow" or "use step" directives). Any file matching one of these patterns is flagged as a "serde file" (transform-utils.ts:60-75):
- Imports from
@workflow/serde Symbol.for('workflow-serialize')/Symbol.for('workflow-deserialize')usage[WORKFLOW_SERIALIZE]/[WORKFLOW_DESERIALIZE]computed property usage
This means you don't need any directive in the file — just having the serde symbols triggers discovery and transformation.
####2. SWC Transform — Registration code is injected into ALL three bundles
When the SWC plugin encounters a class with serde methods, it does two things regardless of whether the mode is step, workflow, or client:
a) Emits a JSON manifest comment at the top of the output containing the class's classId:
/**__internal_workflows{"classes":{"input.js":{"Point":{"classId":"class//./input//Point"}}}}*/;
b) Appends a registerSerializationClass() call at the bottom:
import { registerSerializationClass } from "workflow/internal/class-serialization";
// ... class definition ...
registerSerializationClass("class//./input//Point", Point);
This is identical across all three output modes — you can see this in the SWC test fixtures. The output-workflow.js, output-step.js, and output-client.js are all the same.
At runtime, registerSerializationClass() does two things:
- Stores the class in a global registry (
Map<string, Function>onglobalThis) - Sets a non-enumerable
classIdproperty on the class constructor itself
####3. Cross-Context Bundle Inclusion — Serde files are included in EVERY bundle
This is the most impactful part. The base-builder.ts explicitly includes serde files as side-effect imports in all three bundles, even if only one bundle originally imported the class:
| Bundle | How serde files are included | Purpose |
|---|---|---|
| Step bundle (line 345-350) | serdeOnlyFiles = serde files not already in step files, added as imports | So step code can deserialize Point received from workflows and serialize Point returned to workflows |
| Workflow bundle (line 592-599) | serdeOnlyFiles = serde files not already in workflow files, added as imports | So workflow code can deserialize Point from step return values and serialize Point when passing to steps |
| Client bundle (line 869-905) | serdeOnlyFiles = serde files not in input files, added as imports | So client code can serialize Point when calling start(workflow) and deserialize Point from workflow results |
The virtual entry for the step bundle, for example, looks like:
import 'workflow/internal/builtins'; // Built-in steps
import './src/my-step.ts'; // User steps
import './src/models/point.ts'; // Serde files for cross-context class registration
export { stepEntrypoint as POST } from 'workflow/runtime';
###How Serialization Works at Runtime
When data crosses a boundary (e.g., workflow → step), the serializer (using devalue) checks each value:
-
Instancereducer (line 561-583): If a value is an object whose constructor has a[WORKFLOW_SERIALIZE]method and aclassIdproperty, it calls the serialize method and emits{ classId, data }. -
Instancereviver (line 948-968): On the other side, it looks up the class byclassIdin the global registry viagetSerializationClass(), calls its[WORKFLOW_DESERIALIZE]method, and returns a proper class instance with the full prototype chain intact.
The Class reducer/reviver handle serializing the class constructors themselves (not instances).
###Summary
A class with @workflow/serde keys causes:
- Automatic file discovery — no
"use step"or"use workflow"directive needed in the file - Code injection by the SWC plugin —
registerSerializationClass()calls are added to register the class in a global registry - Inclusion in all three bundles (step, workflow, client) — ensuring the class can be serialized and deserialized at every execution boundary where data might cross contexts
Thanks. Now, what if the class, in order to deserialize, depends on some module-level code? Or on things that cannot be reconstructed. Typical case i think is some helper function that does new Point and gives it some specific methods.
Could we use a globalThis registry of these built instances? Though the file that instantiated new Point won’t be included, i assume, so this wouldn’t work. And serialization/deserialization happen in different nodejs processes, i assume.
This confirms the full picture. Here's the answer:
You're right on all counts. Let me confirm with the source code.
###Serialization is purely data — nothing more
When an instance crosses a boundary, the only thing that travels is { classId, data }, where data is whatever your [WORKFLOW_SERIALIZE] method returns (serialization.ts:582-583):
const data = serialize.call(cls, value);
return { classId, data };
On the receiving side, the runtime looks up the class by classId in the global registry and calls [WORKFLOW_DESERIALIZE](data) (serialization.ts:959-967):
const cls = getSerializationClass(classId, global);
const deserialize = (cls as any)[WORKFLOW_DESERIALIZE];
return deserialize.call(cls, data);
That's it. No module-level state, no closures, no runtime context carries over.
###Yes, these are different processes (and even different execution models)
The architecture has three distinct execution contexts, and data is serialized through a persistent queue/database layer ("the World") between them:
- Client — your app's server process (e.g., Next.js route handler calling
start()) - Workflow — runs inside a Node.js
vmsandbox viarunInContext()(workflow.ts:668,vm/index.ts). The workflow bundle is literally evaluated as a string in an isolated context with deterministicMath.random(),Date.now(),crypto, etc. - Step — runs as a queue handler (
step-handler.ts:46-718) receiving messages via the World's queue system, potentially in a completely different serverless invocation.
Data going workflow→step is serialized by dehydrateStepArguments(), stored in the queue, then deserialized by hydrateStepArguments() in the step handler. These can absolutely be different Node.js processes.
###So what breaks
If your [WORKFLOW_DESERIALIZE] depends on:
-
Module-level mutable state (e.g., a singleton, a cache, a connection pool) — that state won't exist in the receiving process. The serde file is imported in all three bundles (that's the whole cross-context registration mechanism), so module-level code does run. But it runs fresh each time — there's no shared memory between the workflow VM and the step process.
-
Things that can't be reconstructed from data alone — e.g., a helper that creates a
Pointand attaches methods dynamically, or that depends on some external service call at construction time — you're stuck. The deserializer only gets the plaindataobject your serializer produced. -
A
globalThisregistry of pre-built instances — this wouldn't work for exactly the reason you identified. The file that callednew Point()and stored it in some global map won't be in the bundle (only files with serde symbols or directives are discovered). And even if it were, it'd be a different process, so a differentglobalThis.
###What does work
Your [WORKFLOW_DESERIALIZE] can depend on:
-
Anything importable from the class's own module or its static dependencies — because the serde file is included in all bundles. Module-level
constvalues, pure helper functions, etc. that are imported by the serde file will be bundled alongside it. -
The class constructor itself — that's the typical pattern.
WORKFLOW_DESERIALIZEis a static method on the class, so it has access tonew ClassName(...). -
Other serde-registered classes — if your deserializer needs to create instances of other classes that also have serde keys, those classes will also be registered (devalue handles nested custom types through its reducer/reviver chain).
The fundamental constraint is: [WORKFLOW_DESERIALIZE](data) must be a pure function of data. It can use imported modules, static helpers, and the class itself, but it cannot rely on runtime state that was established elsewhere in a prior process.
How can the workflow team support this use case? Does it even make sense?
OK, I have a thorough understanding now. Let me formulate the answer.
This is worth breaking down carefully, because there are actually a couple of distinct sub-problems here, and they have different answers.
###What's the actual problem?
The user scenario is something like:
// helpers.ts — NOT a serde file, no serde symbols
function createEnhancedPoint(x: number, y: number) {
const p = new Point(x, y);
p.render = () => drawToCanvas(x, y); // dynamically attached
return p;
}
Or more subtly: a class whose constructor depends on module-level state:
// my-class.ts
const config = await loadConfig(); // module-level side effect
class MyThing {
constructor(public id: string) {
this.endpoint = config.endpoints[id]; // depends on module-level state
}
static [WORKFLOW_SERIALIZE](inst) { return { id: inst.id }; }
static [WORKFLOW_DESERIALIZE](data) { return new MyThing(data.id); }
}
###Does it even make sense to support?
Mostly no, and the system is already designed around the right constraint. Here's why:
1. The process boundary is real and fundamental. Workflow code runs in a vm.Context sandbox (vm/index.ts). Steps run as queue handlers (step-handler.ts). The client is yet another process. Data between them goes through the World layer (a database/queue). This isn't an implementation detail that could be abstracted away — it's the core architectural property that makes workflows durable. Pretending objects can carry un-serializable state across these boundaries would undermine the whole model.
2. The WORKFLOW_DESERIALIZE(data) contract — "pure function of data" — is the right contract. It's exactly analogous to how every durable/distributed system works: protocol buffers, JSON serialization, event sourcing. If you can't reconstruct it from data, it doesn't belong in the serialization boundary.
3. The "module-level state" sub-case already works, actually. Because serde files are included in all three bundles, their module-level code does execute in each context. So this works fine:
// point.ts — included in all bundles because it has serde symbols
import { config } from './config'; // this import is bundled too
class Point {
endpoint: string;
constructor(public id: string) {
this.endpoint = config.endpoints[id]; // ✅ works — config.ts is bundled as a dependency
}
static [WORKFLOW_SERIALIZE](inst) { return { id: inst.id }; }
static [WORKFLOW_DESERIALIZE](data) { return new Point(data.id); }
}
The serde file and all its transitive imports get bundled by esbuild into each bundle context. Module-level const values, imported configs, helper functions — all available. The constraint is just that this state must be reconstructable by re-executing the module (it's the same code running fresh in a new process).
###What genuinely doesn't work
The thing that truly can't work is runtime-mutable state that can't be derived from the serialized data or from re-running module initialization. For example:
- An object that was mutated after construction in ways not captured by
WORKFLOW_SERIALIZE - Dynamically attached methods (functions aren't serializable)
- State that depends on the specific process lifetime (open connections, file handles, cached computations keyed by memory address)
- A
globalThisregistry populated by other files that aren't serde files (those files won't be in the bundle)
###What could the workflow team do?
Rather than trying to support un-serializable state, the better question is: can the developer experience around the existing constraint be improved?
A few things that could help:
Better error messages. Right now, if WORKFLOW_DESERIALIZE throws because module-level state isn't what was expected, the error surfaces as a generic deserialization failure. The system could catch errors from the user's deserializer specifically and wrap them with guidance like: "Your [WORKFLOW_DESERIALIZE] for class X threw an error. Remember that deserialization happens in a fresh process — ensure your deserializer only depends on data passed to it and deterministic module-level initialization."
Documentation / lint guidance. A pattern guide showing the "right way" to handle cases like:
- "I need methods on my deserialized instance" → put them on the class prototype (that's what
new Point()inWORKFLOW_DESERIALIZEalready gives you) - "I need config/env" → import it; the serde file's dependencies are bundled
- "I need something that genuinely can't be reconstructed" → don't pass it across the boundary; re-acquire it on the other side inside the step/workflow
A WORKFLOW_REHYDRATE lifecycle hook (speculative). One gap is the case where the Point class can be reconstructed from data, but needs an async post-construction step (e.g., re-opening a connection). WORKFLOW_DESERIALIZE is currently synchronous (devalue revivers are sync). The system could support an optional async WORKFLOW_REHYDRATE symbol that's called after deserialization completes but before the step/workflow code uses the instance. But this feels like it's over-engineering for a rare case that's better solved by restructuring.
Bottom line: the constraint is sound. A class that can't reconstruct itself from serialized data shouldn't be passing instances across workflow/step boundaries. The real fix is always on the user's side — either serialize more state, or re-acquire the non-serializable parts on the receiving side.
Maybe including files which instantiate a serde class? Imagine this: new Point(x, y, (diff) => {}) where the third param is a callback. That’s simply impossible to serde right now.
And I don’t just mean if that new Point is inside a factory, or route handler, but even if it’s in module scope.
In case it doesn’t make sense for workflow to support this pattern: what would you suggest i, as the author of this Point class, do to support my users?
Now I have a thorough understanding of the system. Let me formulate the answer about the callback scenario.
You're right — that's flatly impossible to serialize, and no amount of build-system cleverness changes that. A closure like (diff) => { ... } captures lexical scope from the call site. It's a runtime artifact. It doesn't exist in the source file that defines Point, it doesn't exist in the file that has the serde symbols, and it can't be reconstructed from data alone in a different process.
###Could the workflow system include files that instantiate serde classes?
Not usefully, for a few reasons:
-
Discovery is unbounded. Any file in the project could do
new Point(x, y, cb). You'd need to bundle potentially every file in the app into every context. The current discovery is tractable precisely because it looks for a small set of markers (serde symbols, directives). -
It still doesn't help with the callback. Even if the file that does
new Point(x, y, (diff) => {...})were included in all bundles, the callback is a closure over that call site's local scope. Including the file doesn't recreate the specific invocation. Module-level code re-runs, sure, but it would create a different closure with different captured variables. -
The serialization boundary is between processes. As we saw, the workflow runs in a
vm.Context(vm/index.ts), steps run as queue handlers in potentially different serverless invocations (step-handler.ts). There's no way to share a function reference across these.
###So what should you, as the Point library author, do?
The core tension is: your class has a behavioral component (the callback) that users want to carry across a durable execution boundary that only supports data. Here are the patterns, from most practical to most ambitious:
####1. Separate data from behavior — the "dumb data + smart wrapper" pattern
Export two things: a serializable data class and a local-only enriched version.
// point.ts (your library)
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
// This is what crosses boundaries — pure data
export class Point {
constructor(public x: number, public y: number) {}
static [WORKFLOW_SERIALIZE](inst: Point) { return { x: inst.x, y: inst.y }; }
static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) {
return new Point(data.x, data.y);
}
}
// This is what users work with locally — never crosses a boundary
export class LivePoint extends Point {
onChange: (diff: { dx: number; dy: number }) => void;
constructor(x: number, y: number, onChange: (diff: { dx: number; dy: number }) => void) {
super(x, y);
this.onChange = onChange;
}
move(dx: number, dy: number) {
this.x += dx;
this.y += dy;
this.onChange({ dx, dy });
}
}
Users pass Point across workflow/step boundaries and wrap it with LivePoint on the receiving side when they need behavior. This is the most natural pattern and what the system is designed for.
####2. Make the callback a strategy identifier — serialize what to do, not how
If the set of callbacks is known/enumerable, serialize a key instead:
export type PointStrategy = 'log' | 'emit' | 'accumulate';
const strategies: Record<PointStrategy, (diff: any) => void> = {
log: (diff) => console.log(diff),
emit: (diff) => eventBus.emit('point-change', diff),
accumulate: (diff) => { /* ... */ },
};
export class Point {
constructor(
public x: number,
public y: number,
public strategy: PointStrategy
) {}
get onChange() { return strategies[this.strategy]; }
static [WORKFLOW_SERIALIZE](inst: Point) {
return { x: inst.x, y: inst.y, strategy: inst.strategy };
}
static [WORKFLOW_DESERIALIZE](data: { x: number; y: number; strategy: PointStrategy }) {
return new Point(data.x, data.y, data.strategy);
}
}
Now the "callback" survives serialization because it's been reified as data. The strategy implementations live in the class file (which is bundled everywhere).
####3. Accept that the callback is a step-local concern
Document to your users that Point instances with callbacks should be constructed inside steps/workflows, not passed across them:
// In the workflow:
const point = new Point(x, y); // serializable, crosses boundaries
const scaled = await scalePoint(point); // passes across boundary fine
// In the step:
async function scalePoint(point: Point) {
'use step';
// Attach behavior locally — this never needs to cross a boundary
const live = new LivePoint(point.x, point.y, (diff) => trackChange(diff));
live.move(10, 20);
return new Point(live.x, live.y); // strip behavior before returning
}
####Bottom line
This isn't something the workflow system should try to solve at the framework level. The fundamental constraint — data crosses boundaries, behavior doesn't — is inherent to durable execution. Any attempt to serialize closures would be fragile, non-portable, and philosophically at odds with the model.
As a library author, the best thing you can do is design your API with a clear separation between what's data (serializable, crosses boundaries) and what's behavior (local, reconstructed on each side). Patterns 1 and 2 above are both clean and well-supported by the existing serde system.
i know it can't be serialized, but i meant, if i used a registry in globalThis, that got populated at module-scope. then we'd only need to include that file.
// my-point-def.ts
export const myPoint = new Point(x, y, (diff) => {})
registerPoint(myPoint)
the issue of course being that this file is not included in the workflow bundle.
then i wonder if there's a way for the user to define their own workflow and nest this instance. like:
function execute() {
"use workflow"
const myPoint = new Point(x, y, (diff) => {})
registerPoint(myPoint)
await someStepThatComputesTheDiff(myPoint)
}
albeit in this case our library wouldn't be integrating workflow, which was a core tenant of it ("it features durability")
Right — the step bundle is a normal server-side bundle. When the serverless function cold-starts, all the imports run, including module-level side effects from serde files.
So the registry approach would work if the file that populates the registry were included in every bundle. The question is: how would the build system know to include it?
OK, I have a thorough understanding. Here's the answer:
Your registry idea is actually sound in principle. Let me walk through exactly where it breaks and where it doesn't.
###The registry idea, concretely
// my-point-def.ts
import { Point } from 'cool-point-lib';
export const myPoint = new Point(10, 20, (diff) => console.log('moved', diff));
registerPoint('my-special-point', myPoint);
At serialization time, instead of serializing the full Point (callback and all), WORKFLOW_SERIALIZE would emit just { registryKey: 'my-special-point' }. At deserialization time, WORKFLOW_DESERIALIZE would look up 'my-special-point' in a globalThis registry and return that pre-built instance.
This would work if my-point-def.ts were included in all three bundles, because:
- The workflow bundle is evaluated as a string via
runInContext()(workflow.ts:668) — all module-level code runs - The step bundle is a normal server module — imports execute at cold-start, including side-effect imports (
base-builder.ts:415-422) - The client bundle — same, side-effect imports run
The registry on globalThis would be populated identically in each context. The callback would be different closures in each process, but they'd be the same code — which is fine for this pattern.
###Where it breaks: discovery
The build system currently discovers files for bundle inclusion by looking for three markers (transform-utils.ts):
"use workflow"/"use step"directives@workflow/serdeimportsSymbol.for('workflow-serialize')/Symbol.for('workflow-deserialize')usage
my-point-def.ts has none of these. It just calls new Point(...) and registerPoint(...). There's no marker for the build system to pick it up.
The workflow team could add a new marker — say a "use serde" directive, or a registerPoint import from @workflow/serde — that would cause the file to be discovered and included as a side-effect import in all bundles, just like serde files are today. The machinery is all there in discover-entries-esbuild-plugin.ts and base-builder.ts. It would be a matter of adding another pattern to detectWorkflowPatterns() and another entry to the discovery state.
But this is a narrow feature for a narrow use case, and it pushes complexity into the build system for something that has a clean userland workaround.
###The workflow-scoped version
Your second idea:
function execute() {
"use workflow"
const myPoint = new Point(x, y, (diff) => {})
registerPoint(myPoint)
await someStepThatComputesTheDiff(myPoint)
}
This has a different problem. myPoint with its callback lives in the workflow VM. When someStepThatComputesTheDiff(myPoint) is called, the workflow suspends and myPoint goes through dehydrateStepArguments() (suspension-handler.ts:213-222). The Instance reducer calls Point[WORKFLOW_SERIALIZE](myPoint), which would emit something like { registryKey: 'my-special-point' }. But the step runs in a different process — the registry in the step's globalThis was populated by the step bundle's module-level code, not by the workflow function's runtime code. The registerPoint() call inside the workflow function never ran in the step process.
So the workflow-scoped pattern doesn't work: the registry populated inside a workflow function body is local to that VM execution and doesn't propagate to the step.
###What you should do as the library author
The cleanest approach is to make the callback part of the class definition rather than the instance. Put the "registry file" pattern inside your library, so that your package is the serde file:
// cool-point-lib/index.ts
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
type PointBehavior = (diff: { dx: number; dy: number }) => void;
// Library-level registry of behaviors, keyed by name
const behaviors = new Map<string, PointBehavior>();
export function defineBehavior(name: string, fn: PointBehavior) {
behaviors.set(name, fn);
}
// Ship some built-in behaviors
defineBehavior('log', (diff) => console.log('moved', diff));
defineBehavior('noop', () => {});
export class Point {
x: number;
y: number;
behaviorName: string;
constructor(x: number, y: number, behaviorName: string) {
this.x = x;
this.y = y;
this.behaviorName = behaviorName;
}
get onChange(): PointBehavior {
return behaviors.get(this.behaviorName) ?? (() => {});
}
static [WORKFLOW_SERIALIZE](inst: Point) {
return { x: inst.x, y: inst.y, behaviorName: inst.behaviorName };
}
static [WORKFLOW_DESERIALIZE](data: { x: number; y: number; behaviorName: string }) {
return new Point(data.x, data.y, data.behaviorName);
}
}
This file is a serde file (it has the @workflow/serde import), so it's auto-discovered and included in all bundles. Module-level code runs in every context, so behaviors is populated with the built-ins everywhere. Users can call defineBehavior() from their own code if that code also runs at module level in all contexts — which it will if it's in a file imported by the serde file, or if they add their own serde markers.
For users who want truly custom callbacks, you'd document:
// user's code — must be in a file that gets included in all bundles
// (e.g., the same file as their workflow/step, or a file with serde markers)
import { defineBehavior, Point } from 'cool-point-lib';
defineBehavior('my-custom', (diff) => sendToAnalytics(diff));
// Now this survives serialization:
const p = new Point(10, 20, 'my-custom');
The key insight: reify the callback as a name, ship the name across the boundary, resolve it back to a function from a registry that's populated identically on both sides. This is the strategy pattern from earlier, but structured so that your library owns the plumbing and your users just call defineBehavior.
in your last example.
// user's code — must be in a file that gets included in all bundles // (e.g., the same file as their workflow/step, or a file with serde markers) import { defineBehavior, Point } from 'cool-point-lib';
defineBehavior('my-custom', (diff) => sendToAnalytics(diff));
// Now this survives serialization: const p = new Point(10, 20, 'my-custom');
would this work? defineBehavior runs in the user's file
Yes — bundle: true. So esbuild does follow imports. If the step bundle's virtual entry imports step-file.ts, and step-file.ts imports user-behaviors.ts (which calls defineBehavior), then user-behaviors.ts would be bundled and its module-level code would run.
So the question reduces to: does the user's defineBehavior file get imported by anything that's already in the bundle?
No, it wouldn't reliably work.
The issue is about import direction. Let's say the user's project looks like:
user-behaviors.ts → import { defineBehavior } from 'cool-point-lib'
defineBehavior('my-custom', (diff) => sendToAnalytics(diff))
workflow.ts → import { Point } from 'cool-point-lib'
"use workflow"
... new Point(10, 20, 'my-custom') ...
some-step.ts → import { Point } from 'cool-point-lib'
"use step"
... uses Point ...
The actual bundles are constructed by esbuild with bundle: true, so transitive imports are followed. But look at what's imported:
-
The step bundle entry imports
some-step.tsand any serde files.some-step.tsimportscool-point-lib.cool-point-libhas the serde symbols so it's bundled. But nobody importsuser-behaviors.ts. There's no edge in the import graph pointing to it from anything in the bundle. -
The workflow bundle — same story.
workflow.tsimportscool-point-lib, but notuser-behaviors.ts.
user-behaviors.ts is a leaf in the import graph. Things import from cool-point-lib, but nothing imports user-behaviors.ts unless it's explicitly a step/workflow/serde file or imported by one.
The user would have to do something like:
// workflow.ts
import './user-behaviors.ts'; // side-effect import to ensure registration
import { Point } from 'cool-point-lib';
And they'd need that import in every file that touches the boundary — the workflow file, the step files, the client files. That works but it's fragile and easy to forget.
###The deeper problem
What you're really bumping into is that the workflow system's bundle inclusion is marker-driven (serde symbols, directives), and there's no marker that means "this file has side effects that need to run everywhere." The serde markers say "this file defines a serializable class." There's no equivalent for "this file configures a serializable class."
As the library author, you have a few options:
1. Keep it inside your package. If all the behaviors ship with your library, they're registered in cool-point-lib/index.ts (which is a serde file), and it just works. This is the simplest path — it just limits your users to behaviors you've predefined or that can be expressed as combinations of things you've predefined.
2. Tell users to put defineBehavior calls in the same file as their workflow/step. Since those files are already in the bundle:
// workflow.ts — already included in the workflow bundle
import { defineBehavior, Point } from 'cool-point-lib';
defineBehavior('my-custom', (diff) => sendToAnalytics(diff));
export async function myWorkflow() {
"use workflow"
const p = new Point(10, 20, 'my-custom');
await someStep(p);
}
But this only registers in the workflow bundle. The step bundle would need its own defineBehavior call. So the user would need to repeat it in their step file too. Messy.
3. Tell users to put defineBehavior calls in a file that also has a serde marker. This is the most robust userland workaround:
// my-behaviors.ts
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
import { defineBehavior } from 'cool-point-lib';
// This import alone makes this file get discovered as a serde file
// and included in all three bundles.
// The serde symbols aren't used, but their presence triggers discovery.
defineBehavior('my-custom', (diff) => sendToAnalytics(diff));
That's a hack though — importing serde symbols you don't use just to trigger discovery.
4. The actual clean solution (hypothetical workflow feature). A directive like "use serde" or an import from @workflow/serde like import { register } from '@workflow/serde' that marks a file as "include me in all bundles for side effects." The discovery machinery in discover-entries-esbuild-plugin.ts and the bundle construction in base-builder.ts already support this exact pattern for serde files — it would just need a new marker. That's probably the right feature request if this pattern matters to your users.
END OF POST