@statewalker/fsm 0.36.0 → 0.38.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 +257 -0
- package/dist/index.d.ts +297 -279
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +543 -761
- package/dist/index.js.map +1 -1
- package/package.json +10 -20
- package/src/core/fsm-base-class.ts +26 -26
- package/src/core/fsm-process.ts +69 -11
- package/src/core/fsm-state-config.ts +27 -0
- package/src/core/fsm-state-descriptor.ts +21 -0
- package/src/core/fsm-state.ts +22 -21
- package/src/core/fsm-transitions.ts +72 -0
- package/src/core/index.ts +2 -1
- package/src/index.ts +2 -5
- package/src/start-process.ts +185 -0
- package/src/trace/index.ts +2 -0
- package/src/trace/printer.ts +62 -0
- package/src/{utils → trace}/tracer.ts +12 -1
- package/CHANGELOG.md +0 -59
- package/bin/cli.js +0 -5
- package/src/core/new-fsm-process.ts +0 -83
- package/src/orchestrator/constants.ts +0 -130
- package/src/orchestrator/index.ts +0 -9
- package/src/orchestrator/is-generator.ts +0 -12
- package/src/orchestrator/launcher.ts +0 -172
- package/src/orchestrator/process-config-manager.ts +0 -70
- package/src/orchestrator/resolve-module-refs.ts +0 -52
- package/src/orchestrator/start-node-processes.ts +0 -19
- package/src/orchestrator/start-process.ts +0 -73
- package/src/orchestrator/start-processes.ts +0 -32
- package/src/orchestrator/types.ts +0 -34
- package/src/utils/handlers.ts +0 -35
- package/src/utils/index.ts +0 -4
- package/src/utils/printer.ts +0 -48
- package/src/utils/process.ts +0 -26
- package/src/utils/transitions.ts +0 -56
package/README.md
CHANGED
|
@@ -1 +1,258 @@
|
|
|
1
1
|
# @statewalker/fsm: Hierarchical Finite State Machine
|
|
2
|
+
|
|
3
|
+
A tiny, zero-dependency **hierarchical finite state machine (HFSM)** for TypeScript.
|
|
4
|
+
Declare a tree of nested states and event-driven transitions, attach behaviour to
|
|
5
|
+
each state, and drive the machine with events. The entire machine — its stack of
|
|
6
|
+
active states *and* the per-state data you record — can be dumped to a plain object
|
|
7
|
+
and restored, so a running process can be paused, persisted, and resumed later.
|
|
8
|
+
|
|
9
|
+
## Why it exists
|
|
10
|
+
|
|
11
|
+
Event-driven control flow — UI wizards, agent reasoning loops, connection/session
|
|
12
|
+
lifecycles, long-running workflows — is painful to express with ad-hoc booleans and
|
|
13
|
+
callbacks. It degrades into "am I in this phase yet? did that step already run? what
|
|
14
|
+
must unwind on cancel?".
|
|
15
|
+
|
|
16
|
+
This package lets you write that flow **declaratively** as a nested state tree and
|
|
17
|
+
attach behaviour per state. The engine owns the hard parts:
|
|
18
|
+
|
|
19
|
+
- **descending** into sub-states (entering a composite state auto-enters its initial child),
|
|
20
|
+
- **bubbling** an unhandled event up to the parent state,
|
|
21
|
+
- running **enter/exit hooks** in the correct order (exit unwinds inner-to-outer),
|
|
22
|
+
- **serializing** the whole machine for pause/resume.
|
|
23
|
+
|
|
24
|
+
It is deliberately small (a single bundled entry, **zero runtime dependencies**) so it
|
|
25
|
+
can sit at the core of larger systems without pulling weight.
|
|
26
|
+
|
|
27
|
+
There are **two layers — pick your altitude**:
|
|
28
|
+
|
|
29
|
+
- **Engine** (`FsmProcess` + `FsmState`) — the low-level machine you drive by hand with
|
|
30
|
+
`dispatch(event)` and lifecycle hooks. Use it when you want full control.
|
|
31
|
+
- **Runner** (`startProcess`) — an ergonomic wrapper that attaches per-state handlers via
|
|
32
|
+
a single `load` callback and binds the machine into a shared context object. **Most
|
|
33
|
+
consumers use this.**
|
|
34
|
+
|
|
35
|
+
## Mental model
|
|
36
|
+
|
|
37
|
+
A `FsmProcess` keeps a **stack** of active states from the root down to the current leaf.
|
|
38
|
+
Dispatching an event resolves a transition, unwinds the states that exit, and enters the
|
|
39
|
+
states that become active — always leaving the stack resting on a leaf:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
config (declarative) runtime stack (one FsmProcess)
|
|
43
|
+
Player Player ← root, always active
|
|
44
|
+
├─ Idle └─ Active ← composite, entered on "play"
|
|
45
|
+
└─ Active (composite) └─ Playing ← leaf, the "current" state
|
|
46
|
+
├─ Playing
|
|
47
|
+
└─ Paused dispatch("pause"): exit Playing → enter Paused (inside Active)
|
|
48
|
+
dispatch("stop") : neither Playing nor Active has a "stop" rule,
|
|
49
|
+
so it bubbles to Player's "*"→Idle, unwinding
|
|
50
|
+
Playing then Active
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Transitions are `[from, event, to]` tuples. `""` means *initial* (as `from`) or *final*
|
|
54
|
+
(as `to`); `"*"` is a wildcard matching *any* state or *any* event. When the current leaf
|
|
55
|
+
has no matching rule, the lookup walks up the parent chain — that is how an event handled
|
|
56
|
+
only by an outer state still fires from deep inside.
|
|
57
|
+
|
|
58
|
+
## How to use
|
|
59
|
+
|
|
60
|
+
This package is consumed inside the workspace via `"@statewalker/fsm": "workspace:*"`.
|
|
61
|
+
Everything is exported from the package root:
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { startProcess, FsmProcess, type FsmStateConfig } from "@statewalker/fsm";
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 1. Declare the machine — `FsmStateConfig`
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const config: FsmStateConfig = {
|
|
71
|
+
key: "Player",
|
|
72
|
+
transitions: [
|
|
73
|
+
["", "", "Idle"], // initial → Idle (eventless start)
|
|
74
|
+
["Idle", "play", "Active"], // play → Active (which enters its initial child)
|
|
75
|
+
["*", "stop", "Idle"], // from ANY state, "stop" → Idle
|
|
76
|
+
],
|
|
77
|
+
states: [
|
|
78
|
+
{ key: "Idle" },
|
|
79
|
+
{
|
|
80
|
+
// A composite state: entering it descends into its initial child. The
|
|
81
|
+
// initial rule uses "*" (any event) so descent works whatever event caused
|
|
82
|
+
// the entry — here, "play".
|
|
83
|
+
key: "Active",
|
|
84
|
+
transitions: [
|
|
85
|
+
["", "*", "Playing"], // initial child of Active
|
|
86
|
+
["Playing", "pause", "Paused"],
|
|
87
|
+
["Paused", "play", "Playing"],
|
|
88
|
+
],
|
|
89
|
+
states: [{ key: "Playing" }, { key: "Paused" }],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 2. Run it — `startProcess`
|
|
96
|
+
|
|
97
|
+
Pass a `load(stateKey, event)` callback that returns the handlers for each state. A handler
|
|
98
|
+
runs on enter; what it *returns* wires up the rest:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const context: Record<string, unknown> = {};
|
|
102
|
+
await startProcess(context, config, (stateKey) => {
|
|
103
|
+
if (stateKey === "Playing") {
|
|
104
|
+
return [(ctx) => {
|
|
105
|
+
console.log("▶ playing");
|
|
106
|
+
return () => console.log("⏸ left Playing"); // returned fn → onExit cleanup
|
|
107
|
+
}];
|
|
108
|
+
}
|
|
109
|
+
return [];
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// startProcess binds control functions into `context`:
|
|
113
|
+
const dispatch = context["fsm:dispatch"] as (e: string) => Promise<void>;
|
|
114
|
+
await dispatch("play"); // Idle → Active → Playing → logs "▶ playing"
|
|
115
|
+
await dispatch("pause"); // Playing → Paused → logs "⏸ left Playing"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A handler may instead return an **async generator** — each string it yields is dispatched
|
|
119
|
+
back into the machine, which is how a state drives its own timed/reactive transitions:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// while in "Playing", emit a "pause" event after 1s:
|
|
123
|
+
return [async function* (ctx) {
|
|
124
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
125
|
+
yield "pause";
|
|
126
|
+
}];
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 3. Or drive the engine directly — `FsmProcess`
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
const process = new FsmProcess(config);
|
|
133
|
+
process.onStateCreate((state) => {
|
|
134
|
+
state.onEnter(() => console.log("→", state.key));
|
|
135
|
+
state.onExit(() => console.log("←", state.key)); // runs inner-to-outer
|
|
136
|
+
});
|
|
137
|
+
await process.dispatch(""); // enter the initial state (Player → Idle)
|
|
138
|
+
await process.dispatch("play"); // Idle → Active → Playing
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 4. Pause & resume — `dump` / `restore`
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
const snapshot = await process.dump(); // plain JSON-safe object
|
|
145
|
+
// ...persist it, ship it elsewhere, then on a fresh process:
|
|
146
|
+
await new FsmProcess(config).restore(snapshot);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
To record your own per-state data in the snapshot, register `dump`/`restore` hooks on the
|
|
150
|
+
state (they receive a mutable `data` bag):
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
state.dump((s, data) => { data.scrollTop = readScroll(); });
|
|
154
|
+
state.restore((s, data) => { applyScroll(data.scrollTop); });
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### 5. Observe — printer & tracer
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { setProcessPrinter, setProcessTracer } from "@statewalker/fsm";
|
|
161
|
+
|
|
162
|
+
setProcessPrinter(process, { prefix: "[player]", lineNumbers: true });
|
|
163
|
+
setProcessTracer(process); // logs <Playing event="play"> … </Playing> <!-- event="pause" --> per state
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## API reference — what each export is for
|
|
167
|
+
|
|
168
|
+
**Configuration (the declarative vocabulary)**
|
|
169
|
+
|
|
170
|
+
- **`FsmStateConfig`** — one plain-object shape to declare an entire nested machine, so configs
|
|
171
|
+
stay serializable, diffable, and toolable: a `key`, a list of `[from, event, to]` transition
|
|
172
|
+
tuples, and optional nested `states`.
|
|
173
|
+
- **`STATE_INITIAL` / `STATE_FINAL` (`""`), `STATE_ANY` / `EVENT_ANY` (`"*"`), `EVENT_EMPTY` (`""`)**
|
|
174
|
+
— named sentinels that make the meaning of the empty string and the wildcard explicit at the call
|
|
175
|
+
site (`["", "start", "Active"]` reads as "from *initial*"). The runtime values are shared; the
|
|
176
|
+
names document intent.
|
|
177
|
+
|
|
178
|
+
**Engine**
|
|
179
|
+
|
|
180
|
+
- **`FsmProcess`** — the running machine: it owns the active-state stack and the traversal algorithm.
|
|
181
|
+
`dispatch(event)` advances the machine and resolves to a leaf; `shutdown(event?)` unwinds every
|
|
182
|
+
active state; `state` is the current leaf; `onStateCreate(handler)` is the primary extension point
|
|
183
|
+
(fires once per state instance); `dump()` / `restore(data)` serialize and rehydrate it.
|
|
184
|
+
- **`FsmState`** — a live node in the stack you hang behaviour on. It carries the `onEnter` / `onExit`
|
|
185
|
+
(exit runs in reverse-registration, inner-to-outer) / `onStateError` lifecycle hooks and the `dump`
|
|
186
|
+
/ `restore` hooks for per-state data; `key` and `parent` locate it in the tree.
|
|
187
|
+
- **`FsmStateDescriptor`** — the *compiled* form of a config subtree, since resolving a transition at
|
|
188
|
+
runtime must be cheap: an indexed transition table with wildcard-fallback lookup
|
|
189
|
+
(`getTargetStateKey`). You rarely touch it directly; `FsmProcess` builds it for you.
|
|
190
|
+
- **`getStateTransitions(state)` / `isStateTransitionEnabled(process, event)`** — read-only queries
|
|
191
|
+
over the graph for UIs, viewers, and dispatch guards ("which events are available here? will this
|
|
192
|
+
event do anything?"). The first lists the currently-reachable `[from, event, to]` transitions
|
|
193
|
+
(walking up the parent chain, nearest wins); the second is the boolean guard used internally before
|
|
194
|
+
dispatching.
|
|
195
|
+
- **`FsmBaseClass` / `bindMethods`** — the shared handler-registry substrate under both `FsmProcess`
|
|
196
|
+
and `FsmState` (add/run/remove typed handler lists, sequential with error routing). Mostly internal;
|
|
197
|
+
exported for subclassing.
|
|
198
|
+
|
|
199
|
+
**Runner**
|
|
200
|
+
|
|
201
|
+
- **`startProcess` / `startFsmProcess`** — wiring `onStateCreate` + `onEnter` + a loader by hand is
|
|
202
|
+
repetitive, so the runner does it once and binds the machine into a shared `context`: it creates the
|
|
203
|
+
process, on each state entry calls `load(stateKey, event)` and installs the returned `StageHandler`s,
|
|
204
|
+
and returns a `ProcessHandle`. `startFsmProcess` is a permanent equal alias.
|
|
205
|
+
- **`StageHandler`** — the contract for per-state behaviour, with its return type doing double duty: a
|
|
206
|
+
function of `context` returning `void` (nothing), a cleanup `function` (→ `onExit`), or an async/sync
|
|
207
|
+
generator (its yielded strings are dispatched as events).
|
|
208
|
+
- **`ProcessHandle`** — the caller's remote control after `startProcess` returns: `shutdown()`,
|
|
209
|
+
`dump(...)`, `restore(dump, ...)`.
|
|
210
|
+
- **`KEY_DISPATCH` / `KEY_TERMINATE` / `KEY_STATES` / `KEY_EVENT`** — the context keys under which
|
|
211
|
+
`startProcess` binds the dispatch fn, terminate fn, current state-stack, and last event, so handlers
|
|
212
|
+
reach the machine through the shared context rather than closures over the process.
|
|
213
|
+
|
|
214
|
+
**Debug / observability**
|
|
215
|
+
|
|
216
|
+
- **`setProcessPrinter` / `getProcessPrinter` / `getPrinter` / `preparePrinter` (+ `Printer`,
|
|
217
|
+
`PrinterConfig`)** — readable, hierarchy-indented logging you can attach without touching handler
|
|
218
|
+
code: build or attach a `Printer` that prefixes each line with the current nesting depth (and
|
|
219
|
+
optional line numbers).
|
|
220
|
+
- **`setProcessTracer` / `setStateTracer`** — see the machine's motion as a stream of enter/exit
|
|
221
|
+
events: emit `<state event="…">` on enter and `</state>` on exit, for a whole process or a single
|
|
222
|
+
state.
|
|
223
|
+
|
|
224
|
+
## Internals
|
|
225
|
+
|
|
226
|
+
- **Traversal & the `status` bitmask.** `dispatch` pumps an enter/exit cycle over the state stack until
|
|
227
|
+
it rests on a leaf (`STATUS_LEAF`) or the machine finishes (`STATUS_FINISHED`). The `STATUS_*` bits
|
|
228
|
+
encode where in that cycle the process is: `FIRST`/`NEXT` while entering (descending to a first child
|
|
229
|
+
vs. advancing to a target), `LEAF` when settled, `LAST` when popping to a parent. Dispatching while
|
|
230
|
+
the machine is already running just queues the next event (`nextEvent`) and returns — runs never
|
|
231
|
+
re-enter.
|
|
232
|
+
- **Transition resolution order.** For `(state, event)` the descriptor tries, in order:
|
|
233
|
+
`(state, event)` → `(*, event)` → `(state, *)` → `(*, *)`, then falls back to `STATE_FINAL`. Unhandled
|
|
234
|
+
events bubble up the parent chain, so an outer state can catch events its children ignore.
|
|
235
|
+
- **Generator handlers.** A handler returning an async generator runs concurrently; its yielded events
|
|
236
|
+
feed back through `dispatch`, and the generator is `return()`-ed automatically when its state exits —
|
|
237
|
+
this is the mechanism for self-driving/timed states.
|
|
238
|
+
- **Serialization.** `dump()` walks root→leaf producing `{ status, event, stack: [{ key, data }] }`;
|
|
239
|
+
`restore()` rebuilds the stack and replays each state's `restore` hooks. Only what you record via
|
|
240
|
+
`state.dump(...)` is persisted — the engine keeps snapshots minimal.
|
|
241
|
+
- **Dependencies.** None. Zero runtime dependencies by design.
|
|
242
|
+
|
|
243
|
+
## License
|
|
244
|
+
|
|
245
|
+
MIT.
|
|
246
|
+
|
|
247
|
+
## Migration from pre-0.35
|
|
248
|
+
|
|
249
|
+
Removed in 0.35:
|
|
250
|
+
- `FsmBaseClass.data`, `.setData()`, `.getData()` — use closures or context instead
|
|
251
|
+
- `FsmState.getData(key, recursive)`, `.useData(key)` — use closures
|
|
252
|
+
- `FsmBaseClass._runHandlerParallel()` — handlers run sequentially now
|
|
253
|
+
- `newFsmProcess()` — use `startProcess()`
|
|
254
|
+
- `utils/handlers.ts` (`addSubstateHandlers`, `callStateHandlers`) — pass a `load` callback to `startProcess()`
|
|
255
|
+
- `utils/process.ts` — use `startProcess()` directly
|
|
256
|
+
|
|
257
|
+
Removed in 0.38 (see `CHANGELOG.md`): the unused orchestration layer (`launcher`,
|
|
258
|
+
`createHandlerRegistry`, the `fsm` CLI). Use `startProcess` + your own `load` callback.
|