machina 7.0.0 → 8.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/README.md CHANGED
@@ -1,82 +1,109 @@
1
1
  # machina
2
2
 
3
- Focused finite state machines for JavaScript and TypeScript. States in, states out.
3
+ <!-- sync:intro -->
4
4
 
5
- ## Full documentation
5
+ machina is a small library for building finite state machines in JavaScript and TypeScript. States in, states out.
6
6
 
7
- https://machina-js.org
7
+ [Documentation](https://machina-js.org) · [npm](https://www.npmjs.com/package/machina) · [GitHub](https://github.com/ifandelse/machina.js)
8
8
 
9
- ## Install
9
+ <!-- /sync:intro -->
10
10
 
11
- ```
12
- npm install machina
13
- ```
11
+ <!-- sync:case -->
12
+
13
+ ## The Case for State Machines
14
+
15
+ Of all the roads that lead to state bugs, the broadest ones are paved with well-intended booleans. You start with `isConnecting`, then add `hasFailed`, then `retryCount` and `hasExceededRetryCount`, and soon every callback checks some combination of these. Each new flag doubles the possible combinations (or worse). Some combinations are nonsense, like a connection that is both connecting and connected. The trickiest part of this is that the bugs tend to appear in the combinations no one ever considered, and it's much easier to spot the nonsense combinations explicitly represented in code than the actually-relevant ones that aren't.
16
+
17
+ A finite state machine (FSM) replaces these flags with one named state at a time. The same input can mean different things in each state, including nothing at all. Impossible combinations become unrepresentable. Behavior is grouped by state instead of scattered across callbacks, or wildly branching code, and the FSM's config itself documents how the machine behaves.
18
+
19
+ You already think this way. You describe connection status or the stages of an order in states. A state machine makes the code match that description. The pattern predates JavaScript by decades and it appears everywhere, from network protocols, parsers, to game AI.
20
+
21
+ If a plain boolean covers your case, use the boolean. However, if you need to tame a herd of booleans, or if changing the aggregate state of those flags needs to follow rules, or when what happens next depends on what is happening now, then you need an FSM. [The Case for State Machines](https://machina-js.org/guide/why-state-machines/) on the docs site makes the longer argument, with a before-and-after refactor.
22
+
23
+ <!-- /sync:case -->
14
24
 
15
- ## Quick start — `createFsm`
25
+ <!-- sync:show-me -->
16
26
 
17
- `createFsm` is the standard choice: one config, one FSM instance, one internal context object. Handler signatures receive a `{ ctx, inputName, defer, emit }` args object — no `this`, works with arrow functions.
27
+ ## Show Me (the Money)
18
28
 
19
- Handlers return a state name to transition, or nothing to stay put.
29
+ You use a physical state machine every day: a door lock. It can be in a `locked` or `unlocked` state. Your "input" (trying to open the door) has a different outcome based on the state the door lock is in.
30
+
31
+ Here's an FSM that represents a fancier, key code lock. It opens with the right code and sounds an alarm after three wrong attempts:
20
32
 
21
33
  ```ts
22
34
  import { createFsm } from "machina";
23
35
 
24
- const light = createFsm({
25
- id: "traffic-light",
26
- initialState: "green",
27
- context: { tickCount: 0 },
36
+ const lock = createFsm({
37
+ id: "door-lock",
38
+ initialState: "locked",
39
+ context: { attempts: 0 },
28
40
  states: {
29
- green: {
30
- _onEnter({ ctx }) {
31
- ctx.tickCount = 0;
32
- },
33
- tick({ ctx }) {
34
- ctx.tickCount++;
35
- },
36
- timeout({ ctx }) {
37
- if (ctx.tickCount >= 5) return "yellow";
41
+ locked: {
42
+ submit({ ctx }, code: unknown) {
43
+ if (code === "1234") {
44
+ ctx.attempts = 0;
45
+ return "unlocked";
46
+ }
47
+ ctx.attempts++;
48
+ if (ctx.attempts >= 3) {
49
+ return "alarming";
50
+ }
38
51
  },
39
52
  },
40
- yellow: {
41
- timeout: "red", // string shorthand — always transitions
53
+ unlocked: {
54
+ lock: "locked",
42
55
  },
43
- red: {
44
- timeout: "green",
56
+ alarming: {
57
+ reset({ ctx }) {
58
+ ctx.attempts = 0;
59
+ return "locked";
60
+ },
45
61
  },
46
62
  },
47
63
  });
48
64
 
49
- light.handle("tick");
50
- light.handle("tick");
51
- light.handle("timeout"); // stays green — tickCount is 2, not >= 5
65
+ lock.handle("submit", "wrong"); // stays in "locked"
66
+ lock.handle("submit", "1234"); // transitions to "unlocked"
67
+ lock.handle("lock"); // back to "locked"
68
+
69
+ console.log(lock.currentState()); // "locked"
70
+ ```
71
+
72
+ This FSM's states are the top-level properties under `states`: `locked`, `unlocked`, and `alarming`.
52
73
 
53
- light.currentState(); // "green"
54
- light.compositeState(); // "green" (dot-delimited path, useful with child FSMs)
55
- light.canHandle("timeout"); // true
74
+ The handlers (which are the methods attached to the state properties) return a state name to transition to a new state, or return nothing to remain in the current state. String shorthand (`lock: "locked"`) is for handlers that always transition to the same state. The `submit` handler shows the other case, a transition that depends on context.
56
75
 
57
- light.reset(); // back to initialState, fires _onEnter
58
- light.dispose(); // tears down; all subsequent calls are silent no-ops
76
+ The [examples directory](https://github.com/ifandelse/machina.js/tree/master/examples) has fuller examples of FSMs:
77
+
78
+ - [connectivity](https://github.com/ifandelse/machina.js/tree/master/examples/connectivity) for a realistic `createFsm`
79
+ - [traffic-intersection](https://github.com/ifandelse/machina.js/tree/master/examples/traffic-intersection) for hierarchy
80
+ - [dungeon-critters](https://github.com/ifandelse/machina.js/tree/master/examples/dungeon-critters) for one FSM managing many clients.
81
+
82
+ ### Installation
83
+
84
+ ```sh
85
+ npm install machina
86
+ # or
87
+ pnpm add machina
59
88
  ```
60
89
 
61
- ### Public API — `Fsm`
90
+ The package includes CJS and ESM builds and TypeScript type definitions.
62
91
 
63
- | Method | Description |
64
- | ---------------------------- | ------------------------------------------------------------------ |
65
- | `handle(inputName, ...args)` | Dispatch an input to the current state's handler |
66
- | `canHandle(inputName)` | True if the current state has a handler (or `"*"`) for this input |
67
- | `transition(toState)` | Directly transition; fires `_onExit`, `_onEnter`, lifecycle events |
68
- | `reset()` | Transition back to `initialState` |
69
- | `currentState()` | Returns the current state name |
70
- | `compositeState()` | Dot-delimited path including active child FSM states |
71
- | `on(eventName, callback)` | Subscribe to a lifecycle event (returns `{ off() }`) |
72
- | `emit(eventName, data?)` | Emit a custom event |
73
- | `dispose(options?)` | Permanently shut down; cascades to child FSMs by default |
92
+ ### The Mental Model
74
93
 
75
- ## `createBehavioralFsm`
94
+ - States are keys in the config, and inputs are keys inside each state. Handlers return the next state, or nothing.
95
+ - Handlers receive `{ ctx, inputName, defer, emit }` as a plain argument. There is no `this` binding, so arrow functions work everywhere.
96
+ - `ctx` is your data (the internal state you want to keep track of inside the FSM). For `createFsm` it is the FSM's own `context` object, and for `createBehavioralFsm` it is the client (more on this later).
97
+ - Every method is synchronous. Start async work in `_onEnter`, then call `handle()` or `transition()` when the work completes.
98
+ - Read the config from top to bottom and you know the complete behavior of the machine.
76
99
 
77
- `createBehavioralFsm` defines behavior once and applies it to any number of independent client objects. Per-client state lives in a `WeakMap` — no properties are stamped onto the client. The client object IS the context; handlers receive it as `ctx`.
100
+ ### One, or Many
78
101
 
79
- The client type can't be inferred from the config, so it's provided via a curried call — `createBehavioralFsm<Connection>()` — that fixes the client type before the config (and its literal state/input names) is inferred from the second call.
102
+ The note above about the `ctx` argument mentioned the two FSM factories in machina:
103
+
104
+ `createFsm` builds one machine with its own `context` object. Use it for one instance of a thing, like the lock above.
105
+
106
+ `createBehavioralFsm` defines the _behavior_ once and applies it to any number of client objects. machina tracks each client's state in a `WeakMap` and doesn't write properties onto your objects. This can be incredibly useful, freeing you from having to manage n-number of FSM instances, and instead, just let your plain application objects use an FSM's behavior as if they were the only one using it.
80
107
 
81
108
  ```ts
82
109
  import { createBehavioralFsm } from "machina";
@@ -90,172 +117,97 @@ const connFsm = createBehavioralFsm<Connection>()({
90
117
  id: "connectivity",
91
118
  initialState: "disconnected",
92
119
  states: {
93
- disconnected: {
94
- connect: "connecting",
95
- },
120
+ disconnected: { connect: "connecting" },
96
121
  connecting: {
97
122
  connected: "online",
98
123
  failed({ ctx }) {
99
124
  ctx.retries++;
100
- if (ctx.retries >= 3) return "error";
101
- return "disconnected";
102
- },
103
- },
104
- online: {
105
- disconnect: "disconnected",
106
- },
107
- error: {
108
- reset({ ctx }) {
109
- ctx.retries = 0;
110
125
  return "disconnected";
111
126
  },
112
127
  },
128
+ online: { disconnect: "disconnected" },
113
129
  },
114
130
  });
115
131
 
116
- const connA = { url: "wss://host-a.example.com", retries: 0 };
117
- const connB = { url: "wss://host-b.example.com", retries: 0 };
118
-
119
- connFsm.handle(connA, "connect");
120
- connFsm.handle(connB, "connect");
121
- connFsm.handle(connB, "failed");
122
-
123
- connFsm.currentState(connA); // "connecting"
124
- connFsm.currentState(connB); // "disconnected"
125
- ```
126
-
127
- `BehavioralFsm` has the same API as `Fsm`, except every method takes the client object as its first argument. It also adds `rehydrate(client, compositeState)` for restoring previously-serialized clients without triggering lifecycle hooks or events.
132
+ const connA = { url: "wss://a.example.com", retries: 0 };
133
+ const connB = { url: "wss://b.example.com", retries: 0 };
128
134
 
129
- ### Rehydrating persisted clients
130
-
131
- `rehydrate()` places a client at a known state with no lifecycle activity — no `_onEnter`, no events. It accepts the same dot-path format that `compositeState()` produces, enabling cold-resume workflows where clients are serialized to external storage and later restored.
132
-
133
- ```ts
134
- // Persist
135
- const snapshot = { client: connA, state: connFsm.compositeState(connA) };
136
-
137
- // Restore (possibly a different process)
138
- const { client, state } = JSON.parse(stored);
139
- connFsm.rehydrate(client, state); // silent — no _onEnter, no events
140
- connFsm.handle(client, "retry"); // proceeds from restored state
135
+ connFsm.handle(connA, "connect"); // connA: "connecting"
136
+ connFsm.handle(connB, "connect"); // connB: "connecting"
137
+ connFsm.handle(connA, "connected"); // connA: "online"
138
+ connFsm.handle(connB, "failed"); // connB: "disconnected", retries: 1
141
139
  ```
142
140
 
143
- For hierarchical FSMs, pass the dot-delimited path: `fsm.rehydrate(client, "active.uploading.retrying")`. Throws on invalid states or structural mismatches.
141
+ **IMPORTANT**: The extra `()` in `createBehavioralFsm<Connection>()` fixes the client type before TypeScript infers the rest from the config. Methods that act on one client (`handle`, `canHandle`, `transition`, `reset`, `currentState`, `compositeState`, `dehydrate`, `rehydrate`) take that client as their first argument, and handlers receive the client as `ctx`, so put per-client data on the client. The instance-wide methods (`on`, `emit`, `dispose`) don't take a client.
144
142
 
145
- ## Hierarchical states
143
+ ### Features in the Order You'll Probably Need Them
146
144
 
147
- Attach a child FSM to any state via `_child`. Inputs are dispatched to the child first; unhandled inputs bubble up to the parent.
145
+ - When one machine grows too large, nest another machine inside a state with [`_child`](https://machina-js.org/guide/hierarchical/). The child receives each input first, and unhandled inputs bubble up to the parent.
146
+ - When the machine receives an input it cannot act on yet, [`defer()`](https://machina-js.org/guide/defer/) queues that input for replay after the next transition.
147
+ - When the rest of your app needs to react to the machine, subscribe to [lifecycle events](https://machina-js.org/guide/events/) such as `transitioned` and `nohandler`.
148
+ - When you want the compiler to check every `handle()` call, declare [typed input payloads](https://machina-js.org/guide/typed-payloads/). Misspelled inputs and wrong payloads then become compile errors.
148
149
 
149
- ```ts
150
- import { createFsm } from "machina";
150
+ ### Your FSM is a Data Structure
151
151
 
152
- const childFsm = createFsm({
153
- id: "upload-phases",
154
- initialState: "preparing",
155
- context: {},
156
- states: {
157
- preparing: { ready: "uploading" },
158
- uploading: { done: "verifying" },
159
- verifying: { verified: "complete" },
160
- complete: {},
161
- },
162
- });
152
+ An FSM config is plain data, so tools can analyze it without executing it.
163
153
 
164
- const uploader = createFsm({
165
- id: "uploader",
166
- initialState: "idle",
167
- context: {},
168
- states: {
169
- idle: {
170
- start: "active",
171
- },
172
- active: {
173
- _child: childFsm, // delegates inputs here first
174
- cancel: "idle", // "cancel" is not on childFsm, so it bubbles up here
175
- },
176
- },
177
- });
154
+ - [machina-inspect](https://www.npmjs.com/package/machina-inspect) parses a config into a graph and reports unreachable states, `_onEnter` loops, and missing handlers.
155
+ - [machina-test](https://www.npmjs.com/package/machina-test) adds Jest and Vitest matchers, so your test suite can assert reachability and structure.
156
+ - [eslint-plugin-machina](https://www.npmjs.com/package/eslint-plugin-machina) reports the same findings inline in your editor.
178
157
 
179
- uploader.handle("start");
180
- uploader.compositeState(); // "active.preparing"
158
+ <!-- /sync:show-me -->
181
159
 
182
- uploader.handle("ready");
183
- uploader.compositeState(); // "active.uploading"
160
+ <!-- sync:xstate -->
184
161
 
185
- uploader.handle("cancel");
186
- uploader.compositeState(); // "idle"
187
- ```
162
+ ## machina vs XState
188
163
 
189
- The parent re-enters `initialState` of the child FSM whenever the parent transitions into the state that owns `_child`.
164
+ I'm frequently asked about the differences between machina and XState.
190
165
 
191
- ## Deferred input
166
+ machina was inspired by Erlang/OTP's gen_fsm module, and follows the same priorities: a small API, pragmatic, focused, minimal ceremony, and straightforward to reason about. [XState](https://xstate.js.org/) is a full statechart framework with actors, spawning, inspection tooling, and SCXML compatibility. It's a phenomenal library. If you need those features, use it. machina is for when you want a state machine (or a hierarchy of them) and nothing else.
192
167
 
193
- Call `defer()` inside a handler to queue the current input for replay after the next transition. Pass `{ until: "stateName" }` to replay only on entry to a specific state.
168
+ <!-- /sync:xstate -->
194
169
 
195
- ```ts
196
- const fsm = createFsm({
197
- id: "loader",
198
- initialState: "loading",
199
- context: {},
200
- states: {
201
- loading: {
202
- _onEnter() {
203
- // simulate async load completing
204
- setTimeout(() => fsm.handle("loaded"), 100);
205
- },
206
- // Can't process "save" while still loading — replay it later
207
- save({ defer }) {
208
- defer({ until: "ready" });
209
- },
210
- loaded: "ready",
211
- },
212
- ready: {
213
- save() {
214
- // deferred "save" replays here automatically after transitioning in
215
- console.log("saving");
216
- },
217
- },
218
- },
219
- });
170
+ ## API Reference
220
171
 
221
- fsm.handle("save"); // deferred — not yet in "ready"
222
- // after "loaded" fires and transitions to "ready", "save" replays automatically
223
- ```
172
+ The [typedoc reference](https://machina-js.org/api/) documents the full public surface. The tables below cover the parts you use most often.
224
173
 
225
- ## Events
174
+ ### `Fsm` methods
226
175
 
227
- Both `Fsm` and `BehavioralFsm` emit lifecycle events you can subscribe to with `on()`. Use `"*"` to catch everything.
176
+ | Method | Description |
177
+ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
178
+ | `handle(inputName, ...args)` | Dispatches an input to the current state's handler. Payload args are untyped unless the FSM declares an [input payload map](https://machina-js.org/guide/typed-payloads/) |
179
+ | `canHandle(inputName)` | Returns true when the current state or its `_child` chain handles the input |
180
+ | `transition(toState)` | Transitions without an input. `_onExit` and `_onEnter` run, and the transition events fire |
181
+ | `reset()` | Transitions back to `initialState` |
182
+ | `currentState()` | Returns the current state name |
183
+ | `compositeState()` | Returns the dot-delimited state path, including active child FSM states |
184
+ | `on(eventName, callback)` | Subscribes to an event and returns a `Subscription` with an `off()` method |
185
+ | `emit(eventName, data?)` | Emits a custom event |
186
+ | `dispose(options?)` | Shuts the FSM down permanently, including child FSMs unless you pass `{ preserveChildren: true }`. After disposal, method calls are silent no-ops |
228
187
 
229
- ```ts
230
- const sub = light.on("transitioned", ({ fromState, toState }) => {
231
- console.log(`${fromState} -> ${toState}`);
232
- });
188
+ ### `BehavioralFsm` differences
233
189
 
234
- sub.off(); // unsubscribe
190
+ The client-scoped methods (`handle`, `canHandle`, `transition`, `reset`, `currentState`, `compositeState`, `dehydrate`, `rehydrate`) take the client object as their first argument, and built-in event payloads include a `client` field. A custom event keeps whatever payload you pass to `emit()`, with nothing attached. The instance-wide methods (`on`, `emit`, `dispose`) don't take a client.
235
191
 
236
- // wildcard — receives every event
237
- light.on("*", (eventName, data) => {
238
- console.log(eventName, data);
239
- });
240
- ```
192
+ For persistence, `dehydrate(client)` returns a `ClientSnapshot`, or `undefined` for a client the FSM hasn't handled yet. `rehydrate(client, snapshotOrPath)` places the client at a stored position while skipping the lifecycle hooks and events. The snapshot form restores the client's deferred inputs too. A bare `compositeState()` string restores only the state path. The [persisting clients guide](https://machina-js.org/guide/persisting-clients/) covers the full persist-and-restore pattern.
241
193
 
242
- ### Built-in events
194
+ ### Events
243
195
 
244
- | Event | Payload | Fired when |
245
- | --------------- | ------------------------ | --------------------------------------- |
246
- | `transitioning` | `{ fromState, toState }` | A transition is about to occur |
247
- | `transitioned` | `{ fromState, toState }` | A transition completed |
248
- | `handling` | `{ inputName }` | An input is about to be dispatched |
249
- | `handled` | `{ inputName }` | An input was successfully handled |
250
- | `nohandler` | `{ inputName, args }` | No handler found in current state |
251
- | `invalidstate` | `{ stateName }` | Transition targeted a nonexistent state |
252
- | `deferred` | `{ inputName }` | An input was deferred |
196
+ Subscribe with `on()`. A `"*"` subscription receives each event, with the event name as its first argument.
253
197
 
254
- `BehavioralFsm` events include a `client` field in every payload to identify which client the event pertains to.
198
+ | Event | Payload | Fires when |
199
+ | --------------- | ------------------------ | ------------------------------------------ |
200
+ | `transitioning` | `{ fromState, toState }` | A transition is about to run |
201
+ | `transitioned` | `{ fromState, toState }` | A transition completed |
202
+ | `handling` | `{ inputName }` | An input is about to be dispatched |
203
+ | `handled` | `{ inputName }` | An input was handled |
204
+ | `nohandler` | `{ inputName, args }` | The current state doesn't handle the input |
205
+ | `invalidstate` | `{ stateName }` | A transition targeted an undeclared state |
206
+ | `deferred` | `{ inputName }` | An input was deferred |
255
207
 
256
- ## TypeScript
208
+ ### TypeScript exports
257
209
 
258
- machina is written in TypeScript. State names, input names, and handler signatures are all inferred from the config object — no manual type parameters needed for `createFsm`.
210
+ State names, input names, transition targets, and handler signatures are inferred from the config. The exported types below support signatures you write outside a config:
259
211
 
260
212
  ```ts
261
213
  import type {
@@ -264,35 +216,35 @@ import type {
264
216
  BehavioralFsmEventMap,
265
217
  HandlerArgs,
266
218
  HandlerFn,
219
+ InputMapFromUnion,
220
+ InputMapOfInstance,
267
221
  StateNamesOf,
268
222
  InputNamesOf,
269
223
  Subscription,
270
224
  DisposeOptions,
271
225
  } from "machina";
272
-
273
- // StateNamesOf and InputNamesOf extract literal unions from your states config:
274
- // type MyStates = StateNamesOf<typeof myConfig.states>; // "green" | "yellow" | "red"
275
- // type MyInputs = InputNamesOf<typeof myConfig.states>; // "tick" | "timeout"
276
226
  ```
277
227
 
278
- Transition targets in string shorthand are validated against actual state keys at compile time. A typo like `timeout: "yellw"` is a type error.
228
+ ## Contracts and Gotchas
279
229
 
280
- ## Disposal
230
+ The rules below are specific to machina. A reader working from generic state-machine knowledge will miss them.
281
231
 
282
- `dispose()` permanently shuts down an FSM. All subsequent method calls are silent no-ops. Child FSMs are disposed by default.
232
+ - `_child` delegation is descendant-first: the deepest handler wins, and unhandled inputs bubble up to ancestors.
233
+ - A config that mounts a child must handle the child's declared `bubbles`, redeclare them, or include a `"*"` handler. Otherwise the config fails to compile.
234
+ - An input payload map is the complete list of the machine's inputs. Undeclared handler keys and wrong payloads are compile errors, and so are undeclared `bubbles` entries.
235
+ - `dehydrate()` plus the snapshot form of `rehydrate()` preserves a client's deferred inputs. A bare `compositeState()` string restores only the state path.
236
+ - Per-client state, such as timers or counters, belongs on the BehavioralFsm client (`ctx`). Storing it on the shared FSM instance is a bug for every client at once.
283
237
 
284
- ```ts
285
- fsm.dispose(); // also disposes child FSMs
238
+ <!-- sync:versions -->
286
239
 
287
- fsm.dispose({ preserveChildren: true }); // leave child FSMs running
288
- ```
240
+ ## Versions and Migrating
289
241
 
290
- ## Companion tools
242
+ machina v8 is the current major version. v6 redesigned the API around plain config objects and handlers that receive `{ ctx }` instead of `this`. v7 extended hierarchical delegation to any depth and turned config typos into compile errors. v8 added [typed input payloads](https://machina-js.org/guide/typed-payloads/), and the [v7 to v8 guide](https://machina-js.org/migration/v7-to-v8/) covers its one type-level break. Readers on v5 or earlier should read the [v5 to v6 guide](https://machina-js.org/migration/v5-to-v6/) and then the [v6 to v7 guide](https://machina-js.org/migration/v6-to-v7/) first.
291
243
 
292
- - [machina-inspect](https://www.npmjs.com/package/machina-inspect) — static analysis for FSM configs: unreachable states, `_onEnter` loops, missing handlers.
293
- - [machina-test](https://www.npmjs.com/package/machina-test) — Jest/Vitest custom matchers for testing FSM graph topology. Assert reachability and catch dead states from your test suite.
294
- - [eslint-plugin-machina](https://www.npmjs.com/package/eslint-plugin-machina) — ESLint plugin wrapping machina-inspect for inline editor feedback.
244
+ <!-- /sync:versions -->
295
245
 
296
246
  ## License
297
247
 
298
- MIT + GPL-2.0
248
+ machina is MIT licensed and also available under GPL-2.0 (see [LICENSE](https://github.com/ifandelse/machina.js/blob/master/LICENSE)).
249
+
250
+ &copy; Jim Cowart
package/dist/index.cjs CHANGED
@@ -196,6 +196,11 @@ const MAX_TRANSITION_DEPTH = 20;
196
196
  * @typeParam TBubbles - String literal union of inputs this FSM declares via
197
197
  * `bubbles`. Type-only — carried so `BubblesOfInstance` can extract it from
198
198
  * a constructed instance; nothing at runtime reads this generic.
199
+ * @typeParam TInputs - The input payload map (#195). Defaults to the wide
200
+ * map, under which `handle()`'s signature reduces to exactly the untyped
201
+ * behavior: `keyof` of the wide map is `string`, so the key intersection
202
+ * is `TInputNames` and the payload lookup is `unknown[]`. The typed
203
+ * factory forms pass the user's map here.
199
204
  */
200
205
  var BehavioralFsm = class {
201
206
  id;
@@ -225,7 +230,17 @@ var BehavioralFsm = class {
225
230
  *
226
231
  * No-ops silently when disposed.
227
232
  */
228
- handle(client, inputName, ...args) {
233
+ handle(client, ...call) {
234
+ const [inputName, ...args] = call;
235
+ this.dispatch(client, inputName, args);
236
+ }
237
+ /**
238
+ * Untyped core of `handle()`, split out for internal callers (deferred
239
+ * replay). Replayed inputs were captured as `unknown[]`, and their
240
+ * payload types were already enforced at the original `handle()` call —
241
+ * so replay need not satisfy the payload-map generics.
242
+ */
243
+ dispatch(client, inputName, args) {
229
244
  if (this.disposed) return;
230
245
  const meta = this.getOrCreateClientMeta(client);
231
246
  meta.currentActionArgs = args;
@@ -731,7 +746,7 @@ var BehavioralFsm = class {
731
746
  for (const item of meta.deferredQueue) if (item.untilState === void 0 || item.untilState === meta.state) toReplay.push(item);
732
747
  else remaining.push(item);
733
748
  meta.deferredQueue = remaining;
734
- for (const item of toReplay) this.handle(client, item.inputName, ...item.args);
749
+ for (const item of toReplay) this.dispatch(client, item.inputName, item.args);
735
750
  }
736
751
  };
737
752
  /**
@@ -829,6 +844,9 @@ function createBehavioralFsm(config) {
829
844
  * @typeParam TBubbles - String literal union of inputs this FSM declares via
830
845
  * `bubbles`. Type-only — carried so `BubblesOfInstance` can extract it from
831
846
  * a constructed instance; nothing at runtime reads this generic.
847
+ * @typeParam TInputs - The input payload map (#195). Defaults to the wide
848
+ * map, under which `handle()`'s signature reduces to exactly the untyped
849
+ * behavior. `createFsm<TInputs>()` passes the user's map here.
832
850
  */
833
851
  var Fsm = class {
834
852
  id;
@@ -859,9 +877,10 @@ var Fsm = class {
859
877
  * there first; unhandled inputs bubble up to the parent.
860
878
  * No-ops silently when disposed.
861
879
  */
862
- handle(inputName, ...args) {
880
+ handle(...call) {
863
881
  if (this.disposed) return;
864
- this.bfsm.handle(this.context, inputName, ...args);
882
+ const [inputName, ...args] = call;
883
+ this.bfsm.handle(this.context, ...[inputName, ...args]);
865
884
  }
866
885
  /**
867
886
  * Returns true if the current state has a handler for `inputName`
@@ -931,34 +950,8 @@ var Fsm = class {
931
950
  this.emitter.clear();
932
951
  }
933
952
  };
934
- /**
935
- * Create a single-client FSM from a config object.
936
- *
937
- * Generic parameters are inferred automatically:
938
- * - `TCtx` comes from `config.context` (defaults to `{}` if omitted).
939
- * - `TStates` is captured with `const` inference to preserve string literal
940
- * types, enabling compile-time validation of transition targets and `handle()`
941
- * input names.
942
- *
943
- * State names, input names, and all handler signatures derive from `TStates`.
944
- *
945
- * @example
946
- * ```ts
947
- * const light = createFsm({
948
- * id: "traffic-light",
949
- * initialState: "green",
950
- * context: { tickCount: 0 },
951
- * states: {
952
- * green: { timeout: "yellow" },
953
- * yellow: { timeout: "red" },
954
- * red: { timeout: "green" },
955
- * },
956
- * });
957
- *
958
- * light.handle("timeout"); // transitions green → yellow
959
- * ```
960
- */
961
953
  function createFsm(config) {
954
+ if (config === void 0) return (curriedConfig) => new Fsm(curriedConfig);
962
955
  return new Fsm(config);
963
956
  }
964
957