machina 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,651 +1,655 @@
1
- // src/emitter.ts
1
+ //#region src/emitter.ts
2
+ /**
3
+ * Minimal typed event emitter used internally by Fsm and BehavioralFsm.
4
+ *
5
+ * Two listener categories exist at runtime: named listeners (keyed by event
6
+ * name) and wildcard listeners (keyed as `"*"`). The wildcard fires on EVERY
7
+ * emit, regardless of event name, and its callback receives both the event
8
+ * name and the data. Named listeners receive only the data.
9
+ *
10
+ * @typeParam TEventMap - Record mapping event names to their payload types.
11
+ * Constrains `on()` and `emit()` to matching event/payload pairs.
12
+ */
2
13
  var Emitter = class {
3
- listeners = /* @__PURE__ */ new Map();
4
- on(event, cb) {
5
- let set = this.listeners.get(event);
6
- if (!set) {
7
- set = /* @__PURE__ */ new Set();
8
- this.listeners.set(event, set);
9
- }
10
- set.add(cb);
11
- return {
12
- off: () => {
13
- set.delete(cb);
14
- }
15
- };
16
- }
17
- /**
18
- * Emit a named event, notifying wildcard listeners first, then named listeners.
19
- *
20
- * Wildcard listeners receive `(eventName, data)`; named listeners receive
21
- * only `data`. The firing order (wildcards before named) is intentional —
22
- * it lets relay listeners (like Fsm's bfsm proxy) observe all events
23
- * before specific subscribers react.
24
- */
25
- emit(event, data) {
26
- const wildcards = this.listeners.get("*");
27
- if (wildcards) {
28
- for (const cb of wildcards) {
29
- cb(event, data);
30
- }
31
- }
32
- const named = this.listeners.get(event);
33
- if (named) {
34
- for (const cb of named) {
35
- cb(data);
36
- }
37
- }
38
- }
39
- /**
40
- * Remove all listeners (named and wildcard).
41
- *
42
- * Existing `Subscription` objects remain valid — their `off()` closures
43
- * hold a reference to the now-empty Set, so calling them is harmless.
44
- * Called by `dispose()` to prevent memory retention after an FSM shuts down.
45
- */
46
- clear() {
47
- this.listeners.clear();
48
- }
14
+ listeners = /* @__PURE__ */ new Map();
15
+ on(event, cb) {
16
+ let set = this.listeners.get(event);
17
+ if (!set) {
18
+ set = /* @__PURE__ */ new Set();
19
+ this.listeners.set(event, set);
20
+ }
21
+ set.add(cb);
22
+ return { off: () => {
23
+ set.delete(cb);
24
+ } };
25
+ }
26
+ /**
27
+ * Emit a named event, notifying wildcard listeners first, then named listeners.
28
+ *
29
+ * Wildcard listeners receive `(eventName, data)`; named listeners receive
30
+ * only `data`. The firing order (wildcards before named) is intentional —
31
+ * it lets relay listeners (like Fsm's bfsm proxy) observe all events
32
+ * before specific subscribers react.
33
+ */
34
+ emit(event, data) {
35
+ const wildcards = this.listeners.get("*");
36
+ if (wildcards) for (const cb of wildcards) cb(event, data);
37
+ const named = this.listeners.get(event);
38
+ if (named) for (const cb of named) cb(data);
39
+ }
40
+ /**
41
+ * Remove all listeners (named and wildcard).
42
+ *
43
+ * Existing `Subscription` objects remain valid — their `off()` closures
44
+ * hold a reference to the now-empty Set, so calling them is harmless.
45
+ * Called by `dispose()` to prevent memory retention after an FSM shuts down.
46
+ */
47
+ clear() {
48
+ this.listeners.clear();
49
+ }
49
50
  };
50
51
 
51
- // src/types.ts
52
- var MACHINA_TYPE = /* @__PURE__ */ Symbol("machina.type");
52
+ //#endregion
53
+ //#region src/types.ts
54
+ /**
55
+ * Symbol used as a property key to identify machina FSM instances at runtime.
56
+ * Each class stamps itself with a MachinaType value so the ChildLink adapter
57
+ * can dispatch handle()/canHandle()/reset() correctly without circular imports.
58
+ */
59
+ const MACHINA_TYPE = Symbol("machina.type");
53
60
 
54
- // src/behavioral-fsm.ts
55
- var MAX_TRANSITION_DEPTH = 20;
61
+ //#endregion
62
+ //#region src/behavioral-fsm.ts
63
+ const MAX_TRANSITION_DEPTH = 20;
64
+ /**
65
+ * Defines FSM behavior (states + transitions) while tracking per-client state
66
+ * in a `WeakMap`. A single `BehavioralFsm` instance can drive any number of
67
+ * independent client objects simultaneously — each gets its own state,
68
+ * deferred queue, and lifecycle.
69
+ *
70
+ * Prefer `createBehavioralFsm()` over constructing this directly — the factory
71
+ * infers all generic parameters from the config object.
72
+ *
73
+ * All public methods silently no-op after `dispose()` is called.
74
+ *
75
+ * @typeParam TClient - The client object type. Must be an object (non-primitive)
76
+ * so it can serve as a WeakMap key.
77
+ * @typeParam TStateNames - String literal union of valid state names.
78
+ * @typeParam TInputNames - String literal union of valid input names.
79
+ */
56
80
  var BehavioralFsm = class {
57
- id;
58
- initialState;
59
- // Type discriminant — lets ChildLink adapter identify this at runtime
60
- [MACHINA_TYPE] = "BehavioralFsm";
61
- states;
62
- emitter = new Emitter();
63
- clients = /* @__PURE__ */ new WeakMap();
64
- // Tracks all initialized clients so the Fsm-child nohandler listener can
65
- // find which clients need bubbling (Fsm events have no client in payload).
66
- // WeakRef prevents retention; clients clean themselves up naturally.
67
- knownClients = /* @__PURE__ */ new Set();
68
- // Subscriptions to child FSM wildcard events, keyed by state name.
69
- // Set up once during construction, torn down in dispose().
70
- childSubscriptions = [];
71
- disposed = false;
72
- transitionDepth = 0;
73
- constructor(config) {
74
- this.id = config.id;
75
- this.initialState = config.initialState;
76
- this.states = config.states;
77
- this.wrapChildLinks();
78
- this.setupChildSubscriptions();
79
- }
80
- // -------------------------------------------------------------------------
81
- // Public API
82
- // -------------------------------------------------------------------------
83
- /**
84
- * Dispatch an input to the given client's current state handler.
85
- *
86
- * Delegation order: if the current state has a `_child` FSM that can
87
- * handle the input, it is dispatched there. If the child emits `nohandler`,
88
- * the input bubbles up to this FSM's local handler. If no handler exists
89
- * here either, `nohandler` is emitted on this FSM's emitter.
90
- *
91
- * No-ops silently when disposed.
92
- */
93
- handle(client, inputName, ...args) {
94
- if (this.disposed) {
95
- return;
96
- }
97
- const meta = this.getOrCreateClientMeta(client);
98
- meta.currentActionArgs = args;
99
- const stateObj = this.states[meta.state];
100
- const childLink = stateObj?._child;
101
- if (childLink) {
102
- if (childLink.canHandle(client, inputName)) {
103
- try {
104
- childLink.handle(client, inputName, ...args);
105
- } finally {
106
- meta.currentActionArgs = void 0;
107
- }
108
- return;
109
- }
110
- }
111
- this.handleLocally(client, inputName, args, meta);
112
- }
113
- /**
114
- * Returns true if the client's current state has a handler for `inputName`
115
- * (or a catch-all `"*"` handler). Does NOT initialize the client — no
116
- * `_onEnter`, no events, no side effects. Unseen clients are treated as
117
- * if they were already in `initialState`. Returns false when disposed.
118
- */
119
- canHandle(client, inputName) {
120
- if (this.disposed) {
121
- return false;
122
- }
123
- const state = this.clients.get(client)?.state ?? this.initialState;
124
- const stateObj = this.states[state];
125
- return !!(stateObj?.[inputName] ?? stateObj?.["*"]);
126
- }
127
- /**
128
- * Transition the client back to `initialState`, firing `_onEnter` and
129
- * lifecycle events as if entering it fresh. No-ops when disposed.
130
- */
131
- reset(client) {
132
- if (this.disposed) {
133
- return;
134
- }
135
- this.transition(client, this.initialState);
136
- }
137
- /**
138
- * Returns the client's current state, or `undefined` if the client has
139
- * never been initialized (i.e. `handle()`, `transition()`, or `reset()`
140
- * have never been called for it). Does NOT trigger initialization.
141
- */
142
- currentState(client) {
143
- return this.clients.get(client)?.state;
144
- }
145
- /**
146
- * Directly transition `client` to `toState`, running the full lifecycle:
147
- * `_onExit` for the current state → `transitioning` event → update state →
148
- * `_onEnter` for new state → `transitioned` event → child reset → deferred
149
- * queue replay → bounce (if `_onEnter` returned a state name).
150
- *
151
- * Same-state transitions are silently ignored. Transitions to unknown state
152
- * names emit `invalidstate` instead of throwing. Throws if the transition
153
- * depth exceeds `MAX_TRANSITION_DEPTH` (likely an `_onEnter` → transition loop).
154
- *
155
- * No-ops when disposed.
156
- */
157
- transition(client, toState) {
158
- if (this.disposed) {
159
- return;
160
- }
161
- const meta = this.getOrCreateClientMeta(client);
162
- const fromState = meta.state;
163
- if (toState === fromState) {
164
- return;
165
- }
166
- if (!(toState in this.states)) {
167
- this.emitter.emit("invalidstate", { stateName: toState, client });
168
- return;
169
- }
170
- this.transitionDepth++;
171
- if (this.transitionDepth > MAX_TRANSITION_DEPTH) {
172
- this.transitionDepth = 0;
173
- throw new Error(
174
- `Max transition depth (${MAX_TRANSITION_DEPTH}) exceeded in FSM "${this.id}". Likely an infinite _onEnter \u2192 transition loop.`
175
- );
176
- }
177
- try {
178
- const curStateObj = this.states[fromState];
179
- const newStateObj = this.states[toState];
180
- if (curStateObj?._onExit && typeof curStateObj._onExit === "function") {
181
- const exitArgs = this.buildHandlerArgs(client, "", meta);
182
- curStateObj._onExit(exitArgs);
183
- }
184
- meta.state = toState;
185
- const payload = { fromState, toState, client };
186
- this.emitter.emit("transitioning", payload);
187
- let bounceTarget = void 0;
188
- if (newStateObj?._onEnter && typeof newStateObj._onEnter === "function") {
189
- const enterArgs = this.buildHandlerArgs(client, "", meta);
190
- bounceTarget = newStateObj._onEnter(enterArgs);
191
- }
192
- this.emitter.emit("transitioned", payload);
193
- const childLink = newStateObj?._child;
194
- if (childLink) {
195
- childLink.reset(client);
196
- }
197
- this.processQueue(client, meta);
198
- if (typeof bounceTarget === "string" && meta.state === toState) {
199
- this.transition(client, bounceTarget);
200
- }
201
- } finally {
202
- this.transitionDepth--;
203
- }
204
- }
205
- /**
206
- * Returns the client's state as a dot-delimited path including any active
207
- * child FSM states (e.g. `"active.connecting.retrying"`). Returns just the
208
- * current state name when no child is active. Returns `""` for clients that
209
- * have never been initialized (unlike `currentState()` which returns `undefined`).
210
- */
211
- compositeState(client) {
212
- const meta = this.clients.get(client);
213
- if (!meta) {
214
- return "";
215
- }
216
- const stateObj = this.states[meta.state];
217
- const childLink = stateObj?._child;
218
- if (childLink) {
219
- const childComposite = childLink.compositeState(client);
220
- if (childComposite) {
221
- return `${meta.state}.${childComposite}`;
222
- }
223
- }
224
- return meta.state;
225
- }
226
- on(eventName, callback) {
227
- if (this.disposed) {
228
- return { off() {
229
- } };
230
- }
231
- return this.emitter.on(eventName, callback);
232
- }
233
- /**
234
- * Emit a custom event through the FSM. Built-in lifecycle events are
235
- * emitted automatically — this is for user-defined events from handlers.
236
- * No-ops when disposed.
237
- */
238
- emit(eventName, data) {
239
- if (this.disposed) {
240
- return;
241
- }
242
- this.emitter.emit(eventName, data);
243
- }
244
- /**
245
- * Permanently shut down this FSM. Irreversible — all subsequent method
246
- * calls become silent no-ops. Tears down child subscriptions, clears all
247
- * listeners, and cascades disposal to child FSMs (unless `preserveChildren`
248
- * is set). The same child appearing in multiple states is disposed once.
249
- */
250
- dispose(options) {
251
- this.disposed = true;
252
- for (const sub of this.childSubscriptions) {
253
- sub.off();
254
- }
255
- if (!options?.preserveChildren) {
256
- const seen = /* @__PURE__ */ new Set();
257
- for (const stateName of Object.keys(this.states)) {
258
- const childLink = this.states[stateName]?._child;
259
- if (childLink && !seen.has(childLink)) {
260
- seen.add(childLink);
261
- childLink.dispose();
262
- }
263
- }
264
- }
265
- this.emitter.clear();
266
- }
267
- // -------------------------------------------------------------------------
268
- // Internal
269
- // -------------------------------------------------------------------------
270
- /**
271
- * Walks all states at construction time, detects raw FSM instances assigned
272
- * to _child, and wraps them into ChildLink adapters via createChildLink().
273
- * Must run BEFORE setupChildSubscriptions() so the subscriptions see
274
- * ChildLink objects, not raw FSM instances.
275
- */
276
- wrapChildLinks() {
277
- for (const stateName of Object.keys(this.states)) {
278
- const stateObj = this.states[stateName];
279
- const rawChild = stateObj?._child;
280
- if (!rawChild) {
281
- continue;
282
- }
283
- if (typeof rawChild !== "object") {
284
- throw new Error(
285
- `State "${stateName}"._child: expected an Fsm or BehavioralFsm instance, got ${String(rawChild)}`
286
- );
287
- }
288
- if (!(MACHINA_TYPE in rawChild)) {
289
- throw new Error(
290
- `State "${stateName}"._child: expected an Fsm or BehavioralFsm instance, got a plain object`
291
- );
292
- }
293
- stateObj._child = createChildLink(rawChild);
294
- }
295
- }
296
- /**
297
- * Walks all states at construction time, finds states with _child, and
298
- * subscribes once to each unique child's wildcard events. Subscriptions are
299
- * stored for cleanup in dispose(). We deduplicate by child reference to
300
- * avoid double-subscribing when the same child appears in multiple states.
301
- */
302
- setupChildSubscriptions() {
303
- const seenChildren = /* @__PURE__ */ new Set();
304
- for (const stateName of Object.keys(this.states)) {
305
- const stateObj = this.states[stateName];
306
- const childLink = stateObj?._child;
307
- if (!childLink || seenChildren.has(childLink)) {
308
- continue;
309
- }
310
- seenChildren.add(childLink);
311
- const sub = childLink.onAny((eventName, data) => {
312
- if (eventName === "nohandler") {
313
- const payload2 = data;
314
- if (payload2.client !== void 0) {
315
- this.bubbleNohandler(
316
- payload2.client,
317
- childLink,
318
- payload2.inputName,
319
- payload2.args ?? []
320
- );
321
- } else {
322
- for (const ref of this.knownClients) {
323
- const client = ref.deref();
324
- if (client === void 0) {
325
- this.knownClients.delete(ref);
326
- continue;
327
- }
328
- this.bubbleNohandler(
329
- client,
330
- childLink,
331
- payload2.inputName,
332
- payload2.args ?? []
333
- );
334
- }
335
- }
336
- return;
337
- }
338
- const payload = data;
339
- if (payload && typeof payload === "object" && "client" in payload) {
340
- if (this.isChildActiveForClient(payload.client, childLink)) {
341
- this.emitter.emit(eventName, data);
342
- }
343
- } else {
344
- for (const ref of this.knownClients) {
345
- const client = ref.deref();
346
- if (!client) {
347
- this.knownClients.delete(ref);
348
- continue;
349
- }
350
- if (this.isChildActiveForClient(client, childLink)) {
351
- this.emitter.emit(eventName, data);
352
- break;
353
- }
354
- }
355
- }
356
- });
357
- this.childSubscriptions.push(sub);
358
- }
359
- }
360
- /**
361
- * Bubbles a child nohandler to the parent for the given client.
362
- * Only fires if the client is currently in a state that has this childLink.
363
- * Extracted from the lambda in setupChildSubscriptions to keep it readable.
364
- */
365
- bubbleNohandler(client, childLink, inputName, args) {
366
- if (!this.isChildActiveForClient(client, childLink)) {
367
- return;
368
- }
369
- const meta = this.clients.get(client);
370
- meta.currentActionArgs = args;
371
- this.handleLocally(client, inputName, args, meta);
372
- }
373
- /**
374
- * Returns true if the given client is currently in a parent state whose
375
- * _child is the specified childLink. Returns false if the client has no
376
- * meta (never initialized) or is in a state with a different (or no) child.
377
- */
378
- isChildActiveForClient(client, childLink) {
379
- const meta = this.clients.get(client);
380
- if (!meta) {
381
- return false;
382
- }
383
- const parentStateObj = this.states[meta.state];
384
- return parentStateObj?._child === childLink;
385
- }
386
- /**
387
- * The inner handler dispatch — no delegation, no initialization side effects
388
- * beyond what getOrCreateClientMeta already did. Called by handle() after
389
- * the delegation check, and by the nohandler child listener for bubbling.
390
- */
391
- handleLocally(client, inputName, args, meta) {
392
- const stateObj = this.states[meta.state];
393
- const handler = stateObj?.[inputName] ?? stateObj?.["*"];
394
- if (!handler) {
395
- this.emitter.emit("nohandler", { inputName, args, client });
396
- meta.currentActionArgs = void 0;
397
- return;
398
- }
399
- try {
400
- this.emitter.emit("handling", { inputName, client });
401
- const handlerArgs = this.buildHandlerArgs(client, inputName, meta);
402
- let targetState = void 0;
403
- if (typeof handler === "string") {
404
- targetState = handler;
405
- } else if (typeof handler === "function") {
406
- targetState = handler(handlerArgs, ...args);
407
- }
408
- this.emitter.emit("handled", { inputName, client });
409
- if (typeof targetState === "string") {
410
- this.transition(client, targetState);
411
- }
412
- } finally {
413
- meta.currentActionArgs = void 0;
414
- }
415
- }
416
- getOrCreateClientMeta(client) {
417
- let meta = this.clients.get(client);
418
- if (meta) {
419
- return meta;
420
- }
421
- meta = {
422
- state: void 0,
423
- deferredQueue: []
424
- };
425
- this.clients.set(client, meta);
426
- this.knownClients.add(new WeakRef(client));
427
- this.transition(client, this.initialState);
428
- return meta;
429
- }
430
- buildHandlerArgs(client, inputName, meta) {
431
- return {
432
- ctx: client,
433
- inputName,
434
- defer: (opts) => {
435
- if (!meta.currentActionArgs) {
436
- return;
437
- }
438
- const deferred = {
439
- inputName,
440
- args: [...meta.currentActionArgs],
441
- untilState: opts?.until
442
- };
443
- meta.deferredQueue.push(deferred);
444
- this.emitter.emit("deferred", { inputName, client });
445
- },
446
- emit: (evtName, evtData) => {
447
- this.emitter.emit(evtName, evtData);
448
- }
449
- };
450
- }
451
- processQueue(client, meta) {
452
- const toReplay = [];
453
- const remaining = [];
454
- for (const item of meta.deferredQueue) {
455
- if (item.untilState === void 0 || item.untilState === meta.state) {
456
- toReplay.push(item);
457
- } else {
458
- remaining.push(item);
459
- }
460
- }
461
- meta.deferredQueue = remaining;
462
- for (const item of toReplay) {
463
- this.handle(client, item.inputName, ...item.args);
464
- }
465
- }
81
+ id;
82
+ initialState;
83
+ [MACHINA_TYPE] = "BehavioralFsm";
84
+ states;
85
+ emitter = new Emitter();
86
+ clients = /* @__PURE__ */ new WeakMap();
87
+ knownClients = /* @__PURE__ */ new Set();
88
+ childSubscriptions = [];
89
+ disposed = false;
90
+ transitionDepth = 0;
91
+ constructor(config) {
92
+ this.id = config.id;
93
+ this.initialState = config.initialState;
94
+ this.states = config.states;
95
+ this.wrapChildLinks();
96
+ this.setupChildSubscriptions();
97
+ }
98
+ /**
99
+ * Dispatch an input to the given client's current state handler.
100
+ *
101
+ * Delegation order: if the current state has a `_child` FSM that can
102
+ * handle the input, it is dispatched there. If the child emits `nohandler`,
103
+ * the input bubbles up to this FSM's local handler. If no handler exists
104
+ * here either, `nohandler` is emitted on this FSM's emitter.
105
+ *
106
+ * No-ops silently when disposed.
107
+ */
108
+ handle(client, inputName, ...args) {
109
+ if (this.disposed) return;
110
+ const meta = this.getOrCreateClientMeta(client);
111
+ meta.currentActionArgs = args;
112
+ const childLink = this.states[meta.state]?._child;
113
+ if (childLink) {
114
+ if (childLink.canHandle(client, inputName)) {
115
+ try {
116
+ childLink.handle(client, inputName, ...args);
117
+ } finally {
118
+ meta.currentActionArgs = void 0;
119
+ }
120
+ return;
121
+ }
122
+ }
123
+ this.handleLocally(client, inputName, args, meta);
124
+ }
125
+ /**
126
+ * Returns true if the client's current state has a handler for `inputName`
127
+ * (or a catch-all `"*"` handler). Does NOT initialize the client — no
128
+ * `_onEnter`, no events, no side effects. Unseen clients are treated as
129
+ * if they were already in `initialState`. Returns false when disposed.
130
+ */
131
+ canHandle(client, inputName) {
132
+ if (this.disposed) return false;
133
+ const state = this.clients.get(client)?.state ?? this.initialState;
134
+ const stateObj = this.states[state];
135
+ return !!(stateObj?.[inputName] ?? stateObj?.["*"]);
136
+ }
137
+ /**
138
+ * Transition the client back to `initialState`, firing `_onEnter` and
139
+ * lifecycle events as if entering it fresh. No-ops when disposed.
140
+ */
141
+ reset(client) {
142
+ if (this.disposed) return;
143
+ this.transition(client, this.initialState);
144
+ }
145
+ /**
146
+ * Returns the client's current state, or `undefined` if the client has
147
+ * never been initialized (i.e. `handle()`, `transition()`, or `reset()`
148
+ * have never been called for it). Does NOT trigger initialization.
149
+ */
150
+ currentState(client) {
151
+ return this.clients.get(client)?.state;
152
+ }
153
+ /**
154
+ * Directly transition `client` to `toState`, running the full lifecycle:
155
+ * `_onExit` for the current state → `transitioning` event → update state →
156
+ * `_onEnter` for new state → `transitioned` event → child reset → deferred
157
+ * queue replay → bounce (if `_onEnter` returned a state name).
158
+ *
159
+ * Same-state transitions are silently ignored. Transitions to unknown state
160
+ * names emit `invalidstate` instead of throwing. Throws if the transition
161
+ * depth exceeds `MAX_TRANSITION_DEPTH` (likely an `_onEnter` → transition loop).
162
+ *
163
+ * No-ops when disposed.
164
+ */
165
+ transition(client, toState) {
166
+ if (this.disposed) return;
167
+ const meta = this.getOrCreateClientMeta(client);
168
+ const fromState = meta.state;
169
+ if (toState === fromState) return;
170
+ if (!(toState in this.states)) {
171
+ this.emitter.emit("invalidstate", {
172
+ stateName: toState,
173
+ client
174
+ });
175
+ return;
176
+ }
177
+ this.transitionDepth++;
178
+ if (this.transitionDepth > MAX_TRANSITION_DEPTH) {
179
+ this.transitionDepth = 0;
180
+ throw new Error(`Max transition depth (${MAX_TRANSITION_DEPTH}) exceeded in FSM "${this.id}". Likely an infinite _onEnter → transition loop.`);
181
+ }
182
+ try {
183
+ const curStateObj = this.states[fromState];
184
+ const newStateObj = this.states[toState];
185
+ if (curStateObj?._onExit && typeof curStateObj._onExit === "function") {
186
+ const exitArgs = this.buildHandlerArgs(client, "", meta);
187
+ curStateObj._onExit(exitArgs);
188
+ }
189
+ meta.state = toState;
190
+ const payload = {
191
+ fromState,
192
+ toState,
193
+ client
194
+ };
195
+ this.emitter.emit("transitioning", payload);
196
+ let bounceTarget = void 0;
197
+ if (newStateObj?._onEnter && typeof newStateObj._onEnter === "function") {
198
+ const enterArgs = this.buildHandlerArgs(client, "", meta);
199
+ bounceTarget = newStateObj._onEnter(enterArgs);
200
+ }
201
+ this.emitter.emit("transitioned", payload);
202
+ const childLink = newStateObj?._child;
203
+ if (childLink) childLink.reset(client);
204
+ this.processQueue(client, meta);
205
+ if (typeof bounceTarget === "string" && meta.state === toState) this.transition(client, bounceTarget);
206
+ } finally {
207
+ this.transitionDepth--;
208
+ }
209
+ }
210
+ /**
211
+ * Returns the client's state as a dot-delimited path including any active
212
+ * child FSM states (e.g. `"active.connecting.retrying"`). Returns just the
213
+ * current state name when no child is active. Returns `""` for clients that
214
+ * have never been initialized (unlike `currentState()` which returns `undefined`).
215
+ */
216
+ compositeState(client) {
217
+ const meta = this.clients.get(client);
218
+ if (!meta) return "";
219
+ const childLink = this.states[meta.state]?._child;
220
+ if (childLink) {
221
+ const childComposite = childLink.compositeState(client);
222
+ if (childComposite) return `${meta.state}.${childComposite}`;
223
+ }
224
+ return meta.state;
225
+ }
226
+ on(eventName, callback) {
227
+ if (this.disposed) return { off() {} };
228
+ return this.emitter.on(eventName, callback);
229
+ }
230
+ /**
231
+ * Emit a custom event through the FSM. Built-in lifecycle events are
232
+ * emitted automatically — this is for user-defined events from handlers.
233
+ * No-ops when disposed.
234
+ */
235
+ emit(eventName, data) {
236
+ if (this.disposed) return;
237
+ this.emitter.emit(eventName, data);
238
+ }
239
+ /**
240
+ * Permanently shut down this FSM. Irreversible — all subsequent method
241
+ * calls become silent no-ops. Tears down child subscriptions, clears all
242
+ * listeners, and cascades disposal to child FSMs (unless `preserveChildren`
243
+ * is set). The same child appearing in multiple states is disposed once.
244
+ */
245
+ dispose(options) {
246
+ this.disposed = true;
247
+ for (const sub of this.childSubscriptions) sub.off();
248
+ if (!options?.preserveChildren) {
249
+ const seen = /* @__PURE__ */ new Set();
250
+ for (const stateName of Object.keys(this.states)) {
251
+ const childLink = this.states[stateName]?._child;
252
+ if (childLink && !seen.has(childLink)) {
253
+ seen.add(childLink);
254
+ childLink.dispose();
255
+ }
256
+ }
257
+ }
258
+ this.emitter.clear();
259
+ }
260
+ /**
261
+ * Walks all states at construction time, detects raw FSM instances assigned
262
+ * to _child, and wraps them into ChildLink adapters via createChildLink().
263
+ * Must run BEFORE setupChildSubscriptions() so the subscriptions see
264
+ * ChildLink objects, not raw FSM instances.
265
+ */
266
+ wrapChildLinks() {
267
+ for (const stateName of Object.keys(this.states)) {
268
+ const stateObj = this.states[stateName];
269
+ const rawChild = stateObj?._child;
270
+ if (!rawChild) continue;
271
+ if (typeof rawChild !== "object") throw new Error(`State "${stateName}"._child: expected an Fsm or BehavioralFsm instance, got ${String(rawChild)}`);
272
+ if (!(MACHINA_TYPE in rawChild)) throw new Error(`State "${stateName}"._child: expected an Fsm or BehavioralFsm instance, got a plain object`);
273
+ stateObj._child = createChildLink(rawChild);
274
+ }
275
+ }
276
+ /**
277
+ * Walks all states at construction time, finds states with _child, and
278
+ * subscribes once to each unique child's wildcard events. Subscriptions are
279
+ * stored for cleanup in dispose(). We deduplicate by child reference to
280
+ * avoid double-subscribing when the same child appears in multiple states.
281
+ */
282
+ setupChildSubscriptions() {
283
+ const seenChildren = /* @__PURE__ */ new Set();
284
+ for (const stateName of Object.keys(this.states)) {
285
+ const childLink = this.states[stateName]?._child;
286
+ if (!childLink || seenChildren.has(childLink)) continue;
287
+ seenChildren.add(childLink);
288
+ const sub = childLink.onAny((eventName, data) => {
289
+ if (eventName === "nohandler") {
290
+ const payload = data;
291
+ if (payload.client !== void 0) this.bubbleNohandler(payload.client, childLink, payload.inputName, payload.args ?? []);
292
+ else for (const ref of this.knownClients) {
293
+ const client = ref.deref();
294
+ if (client === void 0) {
295
+ this.knownClients.delete(ref);
296
+ continue;
297
+ }
298
+ this.bubbleNohandler(client, childLink, payload.inputName, payload.args ?? []);
299
+ }
300
+ return;
301
+ }
302
+ const payload = data;
303
+ if (payload && typeof payload === "object" && "client" in payload) {
304
+ if (this.isChildActiveForClient(payload.client, childLink)) this.emitter.emit(eventName, data);
305
+ } else for (const ref of this.knownClients) {
306
+ const client = ref.deref();
307
+ if (!client) {
308
+ this.knownClients.delete(ref);
309
+ continue;
310
+ }
311
+ if (this.isChildActiveForClient(client, childLink)) {
312
+ this.emitter.emit(eventName, data);
313
+ break;
314
+ }
315
+ }
316
+ });
317
+ this.childSubscriptions.push(sub);
318
+ }
319
+ }
320
+ /**
321
+ * Bubbles a child nohandler to the parent for the given client.
322
+ * Only fires if the client is currently in a state that has this childLink.
323
+ * Extracted from the lambda in setupChildSubscriptions to keep it readable.
324
+ */
325
+ bubbleNohandler(client, childLink, inputName, args) {
326
+ if (!this.isChildActiveForClient(client, childLink)) return;
327
+ const meta = this.clients.get(client);
328
+ meta.currentActionArgs = args;
329
+ this.handleLocally(client, inputName, args, meta);
330
+ }
331
+ /**
332
+ * Returns true if the given client is currently in a parent state whose
333
+ * _child is the specified childLink. Returns false if the client has no
334
+ * meta (never initialized) or is in a state with a different (or no) child.
335
+ */
336
+ isChildActiveForClient(client, childLink) {
337
+ const meta = this.clients.get(client);
338
+ if (!meta) return false;
339
+ return this.states[meta.state]?._child === childLink;
340
+ }
341
+ /**
342
+ * The inner handler dispatch — no delegation, no initialization side effects
343
+ * beyond what getOrCreateClientMeta already did. Called by handle() after
344
+ * the delegation check, and by the nohandler child listener for bubbling.
345
+ */
346
+ handleLocally(client, inputName, args, meta) {
347
+ const stateObj = this.states[meta.state];
348
+ const handler = stateObj?.[inputName] ?? stateObj?.["*"];
349
+ if (!handler) {
350
+ this.emitter.emit("nohandler", {
351
+ inputName,
352
+ args,
353
+ client
354
+ });
355
+ meta.currentActionArgs = void 0;
356
+ return;
357
+ }
358
+ try {
359
+ this.emitter.emit("handling", {
360
+ inputName,
361
+ client
362
+ });
363
+ const handlerArgs = this.buildHandlerArgs(client, inputName, meta);
364
+ let targetState = void 0;
365
+ if (typeof handler === "string") targetState = handler;
366
+ else if (typeof handler === "function") targetState = handler(handlerArgs, ...args);
367
+ this.emitter.emit("handled", {
368
+ inputName,
369
+ client
370
+ });
371
+ if (typeof targetState === "string") this.transition(client, targetState);
372
+ } finally {
373
+ meta.currentActionArgs = void 0;
374
+ }
375
+ }
376
+ getOrCreateClientMeta(client) {
377
+ let meta = this.clients.get(client);
378
+ if (meta) return meta;
379
+ meta = {
380
+ state: void 0,
381
+ deferredQueue: []
382
+ };
383
+ this.clients.set(client, meta);
384
+ this.knownClients.add(new WeakRef(client));
385
+ this.transition(client, this.initialState);
386
+ return meta;
387
+ }
388
+ buildHandlerArgs(client, inputName, meta) {
389
+ return {
390
+ ctx: client,
391
+ inputName,
392
+ defer: (opts) => {
393
+ if (!meta.currentActionArgs) return;
394
+ const deferred = {
395
+ inputName,
396
+ args: [...meta.currentActionArgs],
397
+ untilState: opts?.until
398
+ };
399
+ meta.deferredQueue.push(deferred);
400
+ this.emitter.emit("deferred", {
401
+ inputName,
402
+ client
403
+ });
404
+ },
405
+ emit: (evtName, evtData) => {
406
+ this.emitter.emit(evtName, evtData);
407
+ }
408
+ };
409
+ }
410
+ processQueue(client, meta) {
411
+ const toReplay = [];
412
+ const remaining = [];
413
+ for (const item of meta.deferredQueue) if (item.untilState === void 0 || item.untilState === meta.state) toReplay.push(item);
414
+ else remaining.push(item);
415
+ meta.deferredQueue = remaining;
416
+ for (const item of toReplay) this.handle(client, item.inputName, ...item.args);
417
+ }
466
418
  };
419
+ /**
420
+ * Internal factory called by `wrapChildLinks()` during construction.
421
+ * Wraps a raw Fsm or BehavioralFsm instance in a uniform `ChildLink` adapter
422
+ * so the parent engine doesn't need to know which type it's talking to.
423
+ *
424
+ * Users never call this directly — they assign an FSM instance to `_child`
425
+ * in their state config and `wrapChildLinks()` handles the wrapping.
426
+ */
467
427
  function createChildLink(child) {
468
- if (!child || typeof child !== "object") {
469
- throw new Error(
470
- `createChildLink: expected an Fsm or BehavioralFsm instance, got ${String(child)}`
471
- );
472
- }
473
- const childType = child[MACHINA_TYPE];
474
- if (childType === "BehavioralFsm") {
475
- return {
476
- canHandle(client, inputName) {
477
- return child.canHandle(client, inputName);
478
- },
479
- handle(client, inputName, ...args) {
480
- child.handle(client, inputName, ...args);
481
- },
482
- reset(client) {
483
- child.transition(client, child.initialState);
484
- },
485
- onAny(callback) {
486
- return child.on("*", callback);
487
- },
488
- compositeState(client) {
489
- return child.compositeState(client);
490
- },
491
- dispose() {
492
- child.dispose();
493
- }
494
- };
495
- }
496
- if (childType === "Fsm") {
497
- return {
498
- canHandle(_client, inputName) {
499
- return child.canHandle(inputName);
500
- },
501
- handle(_client, inputName, ...args) {
502
- child.handle(inputName, ...args);
503
- },
504
- reset(_client) {
505
- child.reset();
506
- },
507
- onAny(callback) {
508
- return child.on("*", callback);
509
- },
510
- compositeState(_client) {
511
- return child.compositeState();
512
- },
513
- dispose() {
514
- child.dispose();
515
- }
516
- };
517
- }
518
- throw new Error(
519
- `createChildLink: expected an Fsm or BehavioralFsm instance, got [MACHINA_TYPE] = ${String(childType ?? "undefined")}`
520
- );
428
+ if (!child || typeof child !== "object") throw new Error(`createChildLink: expected an Fsm or BehavioralFsm instance, got ${String(child)}`);
429
+ const childType = child[MACHINA_TYPE];
430
+ if (childType === "BehavioralFsm") return {
431
+ instance: child,
432
+ canHandle(client, inputName) {
433
+ return child.canHandle(client, inputName);
434
+ },
435
+ handle(client, inputName, ...args) {
436
+ child.handle(client, inputName, ...args);
437
+ },
438
+ reset(client) {
439
+ child.transition(client, child.initialState);
440
+ },
441
+ onAny(callback) {
442
+ return child.on("*", callback);
443
+ },
444
+ compositeState(client) {
445
+ return child.compositeState(client);
446
+ },
447
+ dispose() {
448
+ child.dispose();
449
+ }
450
+ };
451
+ if (childType === "Fsm") return {
452
+ instance: child,
453
+ canHandle(_client, inputName) {
454
+ return child.canHandle(inputName);
455
+ },
456
+ handle(_client, inputName, ...args) {
457
+ child.handle(inputName, ...args);
458
+ },
459
+ reset(_client) {
460
+ child.reset();
461
+ },
462
+ onAny(callback) {
463
+ return child.on("*", callback);
464
+ },
465
+ compositeState(_client) {
466
+ return child.compositeState();
467
+ },
468
+ dispose() {
469
+ child.dispose();
470
+ }
471
+ };
472
+ throw new Error(`createChildLink: expected an Fsm or BehavioralFsm instance, got [MACHINA_TYPE] = ${String(childType ?? "undefined")}`);
521
473
  }
474
+ /**
475
+ * Create a behavioral FSM (one definition, many clients) from a config object.
476
+ *
477
+ * Generic parameters are inferred automatically:
478
+ * - `TClient` must be provided explicitly as a type parameter (it can't be
479
+ * inferred from the config since no `context` property exists at the FSM level).
480
+ * - `TStates` is captured with `const` inference to preserve string literal
481
+ * types, enabling compile-time validation of transition targets and `handle()`
482
+ * input names.
483
+ *
484
+ * State names, input names, and all handler signatures derive from `TStates`.
485
+ *
486
+ * @example
487
+ * ```ts
488
+ * interface Connection { url: string; retries: number; }
489
+ *
490
+ * const connFsm = createBehavioralFsm<Connection>({
491
+ * id: "connectivity",
492
+ * initialState: "disconnected",
493
+ * states: {
494
+ * disconnected: { connect: "connecting" },
495
+ * connecting: { connected: "online", failed: "disconnected" },
496
+ * online: { disconnect: "disconnected" },
497
+ * },
498
+ * });
499
+ *
500
+ * const conn = { url: "wss://example.com", retries: 0 };
501
+ * connFsm.handle(conn, "connect");
502
+ * ```
503
+ */
522
504
  function createBehavioralFsm(config) {
523
- return new BehavioralFsm(config);
505
+ return new BehavioralFsm(config);
524
506
  }
525
507
 
526
- // src/fsm.ts
508
+ //#endregion
509
+ //#region src/fsm.ts
510
+ /**
511
+ * Single-client FSM. Wraps a BehavioralFsm and uses the config's `context`
512
+ * object as the implicit client, so callers never pass a client argument.
513
+ *
514
+ * Prefer `createFsm()` over constructing this directly — the factory infers
515
+ * all generic parameters from the config object.
516
+ *
517
+ * All public methods silently no-op after `dispose()` is called.
518
+ *
519
+ * @typeParam TCtx - The context type, inferred from `config.context`.
520
+ * @typeParam TStateNames - String literal union of valid state names.
521
+ * @typeParam TInputNames - String literal union of valid input names.
522
+ */
527
523
  var Fsm = class {
528
- id;
529
- // Type discriminant — lets ChildLink adapter identify this at runtime
530
- [MACHINA_TYPE] = "Fsm";
531
- bfsm;
532
- context;
533
- // FsmEventMap is an interface, which lacks the implicit index signature
534
- // that Emitter's Record<string, unknown> constraint needs. The mapped type
535
- // BehavioralFsmEventMap doesn't have this issue. Intersect with Record to
536
- // satisfy the constraint while preserving typed event signatures.
537
- emitter = new Emitter();
538
- disposed = false;
539
- constructor(config) {
540
- this.id = config.id;
541
- this.context = config.context ?? {};
542
- this.bfsm = new BehavioralFsm(config);
543
- this.bfsm.on("*", (eventName, data) => {
544
- if (data && typeof data === "object" && "client" in data) {
545
- const { client: _, ...payload } = data;
546
- this.emitter.emit(eventName, payload);
547
- } else {
548
- this.emitter.emit(eventName, data);
549
- }
550
- });
551
- this.bfsm.transition(this.context, config.initialState);
552
- }
553
- // -------------------------------------------------------------------------
554
- // Public API — same as BehavioralFsm, minus the client parameter
555
- // -------------------------------------------------------------------------
556
- /**
557
- * Dispatch an input to the current state's handler.
558
- * If a `_child` FSM in the current state can handle it, delegation occurs
559
- * there first; unhandled inputs bubble up to the parent.
560
- * No-ops silently when disposed.
561
- */
562
- handle(inputName, ...args) {
563
- if (this.disposed) {
564
- return;
565
- }
566
- this.bfsm.handle(this.context, inputName, ...args);
567
- }
568
- /**
569
- * Returns true if the current state has a handler for `inputName`
570
- * (or a catch-all `"*"` handler). Does not trigger initialization
571
- * or any side effects. Returns false when disposed.
572
- */
573
- canHandle(inputName) {
574
- if (this.disposed) {
575
- return false;
576
- }
577
- return this.bfsm.canHandle(this.context, inputName);
578
- }
579
- /**
580
- * Transition back to `initialState`, firing `_onEnter` and lifecycle
581
- * events as if entering it fresh. No-ops silently when disposed.
582
- */
583
- reset() {
584
- if (this.disposed) {
585
- return;
586
- }
587
- this.bfsm.reset(this.context);
588
- }
589
- /**
590
- * Returns the current state name. Always defined — Fsm eagerly
591
- * initializes into `initialState` during construction.
592
- */
593
- currentState() {
594
- return this.bfsm.currentState(this.context);
595
- }
596
- /**
597
- * Directly transition to `toState`, firing `_onExit`, `_onEnter`, and
598
- * lifecycle events. Same-state transitions are silently ignored.
599
- * No-ops when disposed.
600
- */
601
- transition(toState) {
602
- if (this.disposed) {
603
- return;
604
- }
605
- this.bfsm.transition(this.context, toState);
606
- }
607
- /**
608
- * Returns the current state as a dot-delimited path that includes
609
- * any active child FSM states (e.g. `"active.connecting.retrying"`).
610
- * Returns just the current state name when no child is active.
611
- */
612
- compositeState() {
613
- return this.bfsm.compositeState(this.context);
614
- }
615
- on(eventName, callback) {
616
- if (this.disposed) {
617
- return { off() {
618
- } };
619
- }
620
- return this.emitter.on(eventName, callback);
621
- }
622
- /**
623
- * Emit a custom event through the FSM. Built-in lifecycle events are
624
- * emitted automatically — this is for user-defined events from handlers.
625
- * Routes through the BehavioralFsm so all relay paths are consistent.
626
- * No-ops when disposed.
627
- */
628
- emit(eventName, data) {
629
- if (this.disposed) {
630
- return;
631
- }
632
- this.bfsm.emit(eventName, data);
633
- }
634
- /**
635
- * Permanently shut down this FSM. Irreversible — all subsequent method
636
- * calls become silent no-ops. Clears all listeners and cascades disposal
637
- * to child FSMs (unless `preserveChildren` is set).
638
- */
639
- dispose(options) {
640
- this.disposed = true;
641
- this.bfsm.dispose(options);
642
- this.emitter.clear();
643
- }
524
+ id;
525
+ initialState;
526
+ [MACHINA_TYPE] = "Fsm";
527
+ states;
528
+ bfsm;
529
+ context;
530
+ emitter = new Emitter();
531
+ disposed = false;
532
+ constructor(config) {
533
+ this.id = config.id;
534
+ this.initialState = config.initialState;
535
+ this.context = config.context ?? {};
536
+ this.bfsm = new BehavioralFsm(config);
537
+ this.states = config.states;
538
+ this.bfsm.on("*", (eventName, data) => {
539
+ if (data && typeof data === "object" && "client" in data) {
540
+ const { client: _, ...payload } = data;
541
+ this.emitter.emit(eventName, payload);
542
+ } else this.emitter.emit(eventName, data);
543
+ });
544
+ this.bfsm.transition(this.context, config.initialState);
545
+ }
546
+ /**
547
+ * Dispatch an input to the current state's handler.
548
+ * If a `_child` FSM in the current state can handle it, delegation occurs
549
+ * there first; unhandled inputs bubble up to the parent.
550
+ * No-ops silently when disposed.
551
+ */
552
+ handle(inputName, ...args) {
553
+ if (this.disposed) return;
554
+ this.bfsm.handle(this.context, inputName, ...args);
555
+ }
556
+ /**
557
+ * Returns true if the current state has a handler for `inputName`
558
+ * (or a catch-all `"*"` handler). Does not trigger initialization
559
+ * or any side effects. Returns false when disposed.
560
+ */
561
+ canHandle(inputName) {
562
+ if (this.disposed) return false;
563
+ return this.bfsm.canHandle(this.context, inputName);
564
+ }
565
+ /**
566
+ * Transition back to `initialState`, firing `_onEnter` and lifecycle
567
+ * events as if entering it fresh. No-ops silently when disposed.
568
+ */
569
+ reset() {
570
+ if (this.disposed) return;
571
+ this.bfsm.reset(this.context);
572
+ }
573
+ /**
574
+ * Returns the current state name. Always defined — Fsm eagerly
575
+ * initializes into `initialState` during construction.
576
+ */
577
+ currentState() {
578
+ return this.bfsm.currentState(this.context);
579
+ }
580
+ /**
581
+ * Directly transition to `toState`, firing `_onExit`, `_onEnter`, and
582
+ * lifecycle events. Same-state transitions are silently ignored.
583
+ * No-ops when disposed.
584
+ */
585
+ transition(toState) {
586
+ if (this.disposed) return;
587
+ this.bfsm.transition(this.context, toState);
588
+ }
589
+ /**
590
+ * Returns the current state as a dot-delimited path that includes
591
+ * any active child FSM states (e.g. `"active.connecting.retrying"`).
592
+ * Returns just the current state name when no child is active.
593
+ */
594
+ compositeState() {
595
+ return this.bfsm.compositeState(this.context);
596
+ }
597
+ on(eventName, callback) {
598
+ if (this.disposed) return { off() {} };
599
+ return this.emitter.on(eventName, callback);
600
+ }
601
+ /**
602
+ * Emit a custom event through the FSM. Built-in lifecycle events are
603
+ * emitted automatically — this is for user-defined events from handlers.
604
+ * Routes through the BehavioralFsm so all relay paths are consistent.
605
+ * No-ops when disposed.
606
+ */
607
+ emit(eventName, data) {
608
+ if (this.disposed) return;
609
+ this.bfsm.emit(eventName, data);
610
+ }
611
+ /**
612
+ * Permanently shut down this FSM. Irreversible — all subsequent method
613
+ * calls become silent no-ops. Clears all listeners and cascades disposal
614
+ * to child FSMs (unless `preserveChildren` is set).
615
+ */
616
+ dispose(options) {
617
+ this.disposed = true;
618
+ this.bfsm.dispose(options);
619
+ this.emitter.clear();
620
+ }
644
621
  };
622
+ /**
623
+ * Create a single-client FSM from a config object.
624
+ *
625
+ * Generic parameters are inferred automatically:
626
+ * - `TCtx` comes from `config.context` (defaults to `{}` if omitted).
627
+ * - `TStates` is captured with `const` inference to preserve string literal
628
+ * types, enabling compile-time validation of transition targets and `handle()`
629
+ * input names.
630
+ *
631
+ * State names, input names, and all handler signatures derive from `TStates`.
632
+ *
633
+ * @example
634
+ * ```ts
635
+ * const light = createFsm({
636
+ * id: "traffic-light",
637
+ * initialState: "green",
638
+ * context: { tickCount: 0 },
639
+ * states: {
640
+ * green: { timeout: "yellow" },
641
+ * yellow: { timeout: "red" },
642
+ * red: { timeout: "green" },
643
+ * },
644
+ * });
645
+ *
646
+ * light.handle("timeout"); // transitions green → yellow
647
+ * ```
648
+ */
645
649
  function createFsm(config) {
646
- return new Fsm(config);
650
+ return new Fsm(config);
647
651
  }
648
652
 
649
- export { BehavioralFsm, Fsm, createBehavioralFsm, createFsm };
650
- //# sourceMappingURL=index.mjs.map
653
+ //#endregion
654
+ export { BehavioralFsm, Fsm, MACHINA_TYPE, createBehavioralFsm, createFsm };
651
655
  //# sourceMappingURL=index.mjs.map