machina 7.0.1 → 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 -->
24
+
25
+ <!-- sync:show-me -->
14
26
 
15
- ## Quick start — `createFsm`
27
+ ## Show Me (the Money)
16
28
 
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.
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.
18
30
 
19
- Handlers return a state name to transition, or nothing to stay put.
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`.
73
+
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.
75
+
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.
52
81
 
53
- light.currentState(); // "green"
54
- light.compositeState(); // "green" (dot-delimited path, useful with child FSMs)
55
- light.canHandle("timeout"); // true
82
+ ### Installation
56
83
 
57
- light.reset(); // back to initialState, fires _onEnter
58
- light.dispose(); // tears down; all subsequent calls are silent no-ops
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.
91
+
92
+ ### The Mental Model
93
+
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.
62
99
 
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 — or its `_child` chain — can handle 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 |
100
+ ### One, or Many
74
101
 
75
- ## `createBehavioralFsm`
102
+ The note above about the `ctx` argument mentioned the two FSM factories in machina:
76
103
 
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`.
104
+ `createFsm` builds one machine with its own `context` object. Use it for one instance of a thing, like the lock above.
78
105
 
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.
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,194 +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");
132
+ const connA = { url: "wss://a.example.com", retries: 0 };
133
+ const connB = { url: "wss://b.example.com", retries: 0 };
122
134
 
123
- connFsm.currentState(connA); // "connecting"
124
- connFsm.currentState(connB); // "disconnected"
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
125
139
  ```
126
140
 
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.
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.
128
142
 
129
- ### Rehydrating persisted clients
143
+ ### Features in the Order You'll Probably Need Them
130
144
 
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.
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.
132
149
 
133
- ```ts
134
- // Persist
135
- const snapshot = { client: connA, state: connFsm.compositeState(connA) };
150
+ ### Your FSM is a Data Structure
136
151
 
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
141
- ```
152
+ An FSM config is plain data, so tools can analyze it without executing it.
142
153
 
143
- For hierarchical FSMs, pass the dot-delimited path: `fsm.rehydrate(client, "active.uploading.retrying")`. Throws on invalid states or structural mismatches.
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.
144
157
 
145
- ## Hierarchical states
158
+ <!-- /sync:show-me -->
146
159
 
147
- Attach a child FSM to any state via `_child`. Inputs are dispatched to the child first; unhandled inputs bubble up to the parent.
160
+ <!-- sync:xstate -->
148
161
 
149
- ```ts
150
- import { createFsm } from "machina";
162
+ ## machina vs XState
151
163
 
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
- });
164
+ I'm frequently asked about the differences between machina and XState.
163
165
 
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
- });
178
-
179
- uploader.handle("start");
180
- uploader.compositeState(); // "active.preparing"
181
-
182
- uploader.handle("ready");
183
- uploader.compositeState(); // "active.uploading"
184
-
185
- uploader.handle("cancel");
186
- uploader.compositeState(); // "idle"
187
- ```
188
-
189
- The parent re-enters `initialState` of the child FSM whenever the parent transitions into the state that owns `_child`.
190
-
191
- Delegation reaches any depth: `canHandle()` answers for the whole `_child` chain, so an input handled three levels down can be dispatched from the root. When both a descendant and an ancestor could handle an input, the descendant wins.
192
-
193
- An FSM can also declare inputs it fires at itself but never handles — expecting whatever mounts it to catch them via bubbling:
194
-
195
- ```ts
196
- const phase = createFsm({
197
- id: "phase",
198
- initialState: "running",
199
- context: {},
200
- bubbles: ["phaseComplete"], // "I fire this; whoever mounts me must deal with it"
201
- states: {
202
- running: {
203
- _onEnter() {
204
- // ...later: phase.handle("phaseComplete") — typed, no handler here
205
- },
206
- },
207
- },
208
- });
209
- ```
210
-
211
- Declared bubbles join the FSM's typed input union, and they form a compile-time contract: a config that mounts `phase` via `_child` must handle `phaseComplete`, re-declare it in its own `bubbles`, or carry a `"*"` catch-all — otherwise it fails to compile, with the error naming what's missing. Standalone FSMs owe nothing; the contract only exists where FSMs get wired together. The engine never reads `bubbles` — it's purely type-level.
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.
212
167
 
213
- ## Deferred input
168
+ <!-- /sync:xstate -->
214
169
 
215
- 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.
170
+ ## API Reference
216
171
 
217
- ```ts
218
- const fsm = createFsm({
219
- id: "loader",
220
- initialState: "loading",
221
- context: {},
222
- states: {
223
- loading: {
224
- _onEnter() {
225
- // simulate async load completing
226
- setTimeout(() => fsm.handle("loaded"), 100);
227
- },
228
- // Can't process "save" while still loading — replay it later
229
- save({ defer }) {
230
- defer({ until: "ready" });
231
- },
232
- loaded: "ready",
233
- },
234
- ready: {
235
- save() {
236
- // deferred "save" replays here automatically after transitioning in
237
- console.log("saving");
238
- },
239
- },
240
- },
241
- });
172
+ The [typedoc reference](https://machina-js.org/api/) documents the full public surface. The tables below cover the parts you use most often.
242
173
 
243
- fsm.handle("save"); // deferred — not yet in "ready"
244
- // after "loaded" fires and transitions to "ready", "save" replays automatically
245
- ```
174
+ ### `Fsm` methods
246
175
 
247
- ## Events
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 |
248
187
 
249
- Both `Fsm` and `BehavioralFsm` emit lifecycle events you can subscribe to with `on()`. Use `"*"` to catch everything.
188
+ ### `BehavioralFsm` differences
250
189
 
251
- ```ts
252
- const sub = light.on("transitioned", ({ fromState, toState }) => {
253
- console.log(`${fromState} -> ${toState}`);
254
- });
255
-
256
- 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.
257
191
 
258
- // wildcard — receives every event
259
- light.on("*", (eventName, data) => {
260
- console.log(eventName, data);
261
- });
262
- ```
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.
263
193
 
264
- ### Built-in events
194
+ ### Events
265
195
 
266
- | Event | Payload | Fired when |
267
- | --------------- | ------------------------ | --------------------------------------- |
268
- | `transitioning` | `{ fromState, toState }` | A transition is about to occur |
269
- | `transitioned` | `{ fromState, toState }` | A transition completed |
270
- | `handling` | `{ inputName }` | An input is about to be dispatched |
271
- | `handled` | `{ inputName }` | An input was successfully handled |
272
- | `nohandler` | `{ inputName, args }` | No handler found in current state |
273
- | `invalidstate` | `{ stateName }` | Transition targeted a nonexistent state |
274
- | `deferred` | `{ inputName }` | An input was deferred |
196
+ Subscribe with `on()`. A `"*"` subscription receives each event, with the event name as its first argument.
275
197
 
276
- `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 |
277
207
 
278
- ## TypeScript
208
+ ### TypeScript exports
279
209
 
280
- 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:
281
211
 
282
212
  ```ts
283
213
  import type {
@@ -286,35 +216,35 @@ import type {
286
216
  BehavioralFsmEventMap,
287
217
  HandlerArgs,
288
218
  HandlerFn,
219
+ InputMapFromUnion,
220
+ InputMapOfInstance,
289
221
  StateNamesOf,
290
222
  InputNamesOf,
291
223
  Subscription,
292
224
  DisposeOptions,
293
225
  } from "machina";
294
-
295
- // StateNamesOf and InputNamesOf extract literal unions from your states config:
296
- // type MyStates = StateNamesOf<typeof myConfig.states>; // "green" | "yellow" | "red"
297
- // type MyInputs = InputNamesOf<typeof myConfig.states>; // "tick" | "timeout"
298
226
  ```
299
227
 
300
- 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
301
229
 
302
- ## Disposal
230
+ The rules below are specific to machina. A reader working from generic state-machine knowledge will miss them.
303
231
 
304
- `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.
305
237
 
306
- ```ts
307
- fsm.dispose(); // also disposes child FSMs
238
+ <!-- sync:versions -->
308
239
 
309
- fsm.dispose({ preserveChildren: true }); // leave child FSMs running
310
- ```
240
+ ## Versions and Migrating
311
241
 
312
- ## 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.
313
243
 
314
- - [machina-inspect](https://www.npmjs.com/package/machina-inspect) — static analysis for FSM configs: unreachable states, `_onEnter` loops, missing handlers.
315
- - [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.
316
- - [eslint-plugin-machina](https://www.npmjs.com/package/eslint-plugin-machina) — ESLint plugin wrapping machina-inspect for inline editor feedback.
244
+ <!-- /sync:versions -->
317
245
 
318
246
  ## License
319
247
 
320
- 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