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