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 +148 -196
- package/dist/index.cjs +24 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +167 -18
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +167 -18
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +24 -31
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,82 +1,109 @@
|
|
|
1
1
|
# machina
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- sync:intro -->
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
9
|
+
<!-- /sync:intro -->
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
25
|
+
<!-- sync:show-me -->
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
## Show Me (the Money)
|
|
18
28
|
|
|
19
|
-
|
|
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
|
|
25
|
-
id: "
|
|
26
|
-
initialState: "
|
|
27
|
-
context: {
|
|
36
|
+
const lock = createFsm({
|
|
37
|
+
id: "door-lock",
|
|
38
|
+
initialState: "locked",
|
|
39
|
+
context: { attempts: 0 },
|
|
28
40
|
states: {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
53
|
+
unlocked: {
|
|
54
|
+
lock: "locked",
|
|
42
55
|
},
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
alarming: {
|
|
57
|
+
reset({ ctx }) {
|
|
58
|
+
ctx.attempts = 0;
|
|
59
|
+
return "locked";
|
|
60
|
+
},
|
|
45
61
|
},
|
|
46
62
|
},
|
|
47
63
|
});
|
|
48
64
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
90
|
+
The package includes CJS and ESM builds and TypeScript type definitions.
|
|
62
91
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
### One, or Many
|
|
78
101
|
|
|
79
|
-
The
|
|
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://
|
|
117
|
-
const connB = { url: "wss://
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
+
### Features in the Order You'll Probably Need Them
|
|
146
144
|
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
import { createFsm } from "machina";
|
|
150
|
+
### Your FSM is a Data Structure
|
|
151
151
|
|
|
152
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
180
|
-
uploader.compositeState(); // "active.preparing"
|
|
158
|
+
<!-- /sync:show-me -->
|
|
181
159
|
|
|
182
|
-
|
|
183
|
-
uploader.compositeState(); // "active.uploading"
|
|
160
|
+
<!-- sync:xstate -->
|
|
184
161
|
|
|
185
|
-
|
|
186
|
-
uploader.compositeState(); // "idle"
|
|
187
|
-
```
|
|
162
|
+
## machina vs XState
|
|
188
163
|
|
|
189
|
-
|
|
164
|
+
I'm frequently asked about the differences between machina and XState.
|
|
190
165
|
|
|
191
|
-
|
|
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
|
-
|
|
168
|
+
<!-- /sync:xstate -->
|
|
194
169
|
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
+
### `Fsm` methods
|
|
226
175
|
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
const sub = light.on("transitioned", ({ fromState, toState }) => {
|
|
231
|
-
console.log(`${fromState} -> ${toState}`);
|
|
232
|
-
});
|
|
188
|
+
### `BehavioralFsm` differences
|
|
233
189
|
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
194
|
+
### Events
|
|
243
195
|
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
+
### TypeScript exports
|
|
257
209
|
|
|
258
|
-
|
|
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
|
-
|
|
228
|
+
## Contracts and Gotchas
|
|
279
229
|
|
|
280
|
-
|
|
230
|
+
The rules below are specific to machina. A reader working from generic state-machine knowledge will miss them.
|
|
281
231
|
|
|
282
|
-
`
|
|
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
|
-
|
|
285
|
-
fsm.dispose(); // also disposes child FSMs
|
|
238
|
+
<!-- sync:versions -->
|
|
286
239
|
|
|
287
|
-
|
|
288
|
-
```
|
|
240
|
+
## Versions and Migrating
|
|
289
241
|
|
|
290
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
© 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,
|
|
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.
|
|
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(
|
|
880
|
+
handle(...call) {
|
|
863
881
|
if (this.disposed) return;
|
|
864
|
-
|
|
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
|
|