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