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 +148 -218
- 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 -->
|
|
24
|
+
|
|
25
|
+
<!-- sync:show-me -->
|
|
14
26
|
|
|
15
|
-
##
|
|
27
|
+
## Show Me (the Money)
|
|
16
28
|
|
|
17
|
-
|
|
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
|
-
|
|
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`.
|
|
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
|
-
|
|
54
|
-
light.compositeState(); // "green" (dot-delimited path, useful with child FSMs)
|
|
55
|
-
light.canHandle("timeout"); // true
|
|
82
|
+
### Installation
|
|
56
83
|
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
102
|
+
The note above about the `ctx` argument mentioned the two FSM factories in machina:
|
|
76
103
|
|
|
77
|
-
`
|
|
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
|
-
|
|
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://
|
|
117
|
-
const connB = { url: "wss://
|
|
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.
|
|
124
|
-
connFsm.
|
|
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
|
-
`
|
|
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
|
-
###
|
|
143
|
+
### Features in the Order You'll Probably Need Them
|
|
130
144
|
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
// Persist
|
|
135
|
-
const snapshot = { client: connA, state: connFsm.compositeState(connA) };
|
|
150
|
+
### Your FSM is a Data Structure
|
|
136
151
|
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
+
<!-- /sync:show-me -->
|
|
146
159
|
|
|
147
|
-
|
|
160
|
+
<!-- sync:xstate -->
|
|
148
161
|
|
|
149
|
-
|
|
150
|
-
import { createFsm } from "machina";
|
|
162
|
+
## machina vs XState
|
|
151
163
|
|
|
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
|
-
});
|
|
164
|
+
I'm frequently asked about the differences between machina and XState.
|
|
163
165
|
|
|
164
|
-
|
|
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
|
-
|
|
168
|
+
<!-- /sync:xstate -->
|
|
214
169
|
|
|
215
|
-
|
|
170
|
+
## API Reference
|
|
216
171
|
|
|
217
|
-
|
|
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
|
-
|
|
244
|
-
// after "loaded" fires and transitions to "ready", "save" replays automatically
|
|
245
|
-
```
|
|
174
|
+
### `Fsm` methods
|
|
246
175
|
|
|
247
|
-
|
|
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
|
-
|
|
188
|
+
### `BehavioralFsm` differences
|
|
250
189
|
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
194
|
+
### Events
|
|
265
195
|
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
+
### TypeScript exports
|
|
279
209
|
|
|
280
|
-
|
|
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
|
-
|
|
228
|
+
## Contracts and Gotchas
|
|
301
229
|
|
|
302
|
-
|
|
230
|
+
The rules below are specific to machina. A reader working from generic state-machine knowledge will miss them.
|
|
303
231
|
|
|
304
|
-
`
|
|
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
|
-
|
|
307
|
-
fsm.dispose(); // also disposes child FSMs
|
|
238
|
+
<!-- sync:versions -->
|
|
308
239
|
|
|
309
|
-
|
|
310
|
-
```
|
|
240
|
+
## Versions and Migrating
|
|
311
241
|
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|