@uuxxx/fsm 1.2.3 → 1.3.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
@@ -2,7 +2,16 @@
2
2
 
3
3
  [![npm version](https://badge.fury.io/js/@uuxxx%2Ffsm.svg)](https://badge.fury.io/js/@uuxxx%2Ffsm)
4
4
 
5
- A lightweight, type-safe finite state machine library for JavaScript/TypeScript with plugin support and lifecycle hooks.
5
+ A lightweight, type-safe finite state machine library for TypeScript with plugin support, lifecycle hooks, and full type inference.
6
+
7
+ ## Features
8
+
9
+ - **Full type inference** — transition methods, states, and plugin APIs are auto-generated from config
10
+ - **Multiple transition types** — static, dynamic, async, wildcard (`*`), and multi-source
11
+ - **Lifecycle hooks** — `onBeforeTransition` (with veto) and `onAfterTransition`
12
+ - **Plugin system** — extend your FSM with custom APIs
13
+ - **Custom error handling** — provide an `onError` callback or let errors throw
14
+ - **Zero dependencies** aside from `@uuxxx/utils`
6
15
 
7
16
  ## Installation
8
17
 
@@ -21,70 +30,66 @@ import { makeFsm } from '@uuxxx/fsm';
21
30
 
22
31
  type State = 'idle' | 'loading' | 'success' | 'error';
23
32
 
24
- const STATES: State[] = ['idle', 'loading', 'success', 'error']
25
-
26
33
  const fsm = makeFsm({
27
- init: 'idle',
28
- states: STATES,
29
- transitions: {
30
- start: {
31
- from: 'idle',
32
- to: 'loading',
33
- },
34
- succeed: {
35
- from: 'loading',
36
- to: 'success',
37
- },
38
- fail: {
39
- from: 'loading',
40
- to: 'error',
41
- },
42
- reset: {
43
- from: ['success', 'error'],
44
- to: 'idle',
45
- },
46
- goto: {
47
- from: '*',
48
- to: (state: State) => state
49
- }
50
- },
34
+ init: 'idle',
35
+ states: ['idle', 'loading', 'success', 'error'] as State[],
36
+ transitions: {
37
+ start: {
38
+ from: 'idle',
39
+ to: 'loading',
40
+ },
41
+ succeed: {
42
+ from: 'loading',
43
+ to: 'success',
44
+ },
45
+ fail: {
46
+ from: 'loading',
47
+ to: 'error',
48
+ },
49
+ reset: {
50
+ from: ['success', 'error'],
51
+ to: 'idle',
52
+ },
53
+ goto: {
54
+ from: '*',
55
+ to: (state: State) => state,
56
+ },
57
+ },
51
58
  });
52
59
 
53
- // Check current state
54
- console.log(fsm.state()); // 'idle'
55
-
56
- // Perform transitions
57
- fsm.start();
58
- console.log(fsm.state()); // 'loading'
59
-
60
- fsm.succeed();
61
- console.log(fsm.state()); // 'success'
62
-
63
- fsm.reset()
64
- console.log(fsm.state()) // 'idle'
65
-
66
- fsm.goto('error')
67
- console.log(fsm.state()) // 'error'
60
+ fsm.state(); // 'idle'
61
+ fsm.start(); // 'loading'
62
+ fsm.succeed(); // 'success'
63
+ fsm.reset(); // 'idle'
64
+ fsm.goto('error'); // 'error'
68
65
  ```
69
66
 
67
+ Each transition key becomes a method on the FSM instance with the correct type signature inferred from config.
68
+
70
69
  ## API Reference
71
70
 
72
71
  ### `makeFsm(config)`
73
72
 
74
73
  Creates a new finite state machine instance.
75
74
 
76
- #### Parameters
75
+ #### Config
77
76
 
78
- - `config`: Configuration object with the following properties:
79
- - `init`: Initial state
80
- - `states`: Array of all possible states
81
- - `transitions`: Object defining state transitions
82
- - `methods?`: Optional lifecycle methods
83
- - `plugins?`: Optional array of plugins
77
+ | Property | Type | Required | Description |
78
+ | ------------- | --------------------------------------------- | -------- | ----------------------------------------------------------------------------- |
79
+ | `init` | `TState` | Yes | Initial state |
80
+ | `states` | `TState[]` | Yes | All valid states |
81
+ | `transitions` | `Record<string, Transition<TState>>` | Yes | Transition definitions (keys become methods) |
82
+ | `methods` | `{ onBeforeTransition?, onAfterTransition? }` | No | Lifecycle hooks |
83
+ | `plugins` | `Plugin[]` | No | Array of plugins |
84
+ | `onError` | `(msg: string) => void` | No | Custom error handler. By default, errors throw `Error` with a `[FSM]:` prefix |
84
85
 
85
86
  #### Returns
86
87
 
87
- An FSM instance with transition methods, state methods, and plugin APIs.
88
+ An FSM instance combining:
89
+
90
+ - **State methods** — `state()`, `allStates()`
91
+ - **Transition methods** — one per key in `transitions`
92
+ - **Plugin APIs** — one namespace per plugin
88
93
 
89
94
  ### State Methods
90
95
 
@@ -92,17 +97,9 @@ An FSM instance with transition methods, state methods, and plugin APIs.
92
97
 
93
98
  Returns the current state.
94
99
 
95
- ```typescript
96
- const currentState = fsm.state();
97
- ```
98
-
99
100
  #### `fsm.allStates()`
100
101
 
101
- Returns an array of all possible states.
102
-
103
- ```typescript
104
- const allStates = fsm.allStates();
105
- ```
102
+ Returns an array of all valid states.
106
103
 
107
104
  ### Transitions
108
105
 
@@ -110,204 +107,252 @@ Transitions are defined as objects with `from` and `to` properties:
110
107
 
111
108
  ```typescript
112
109
  type Transition<TState> = {
113
- from: '*' | TState | TState[];
114
- to: TState | ((...args: any[]) => TState | Promise<TState>);
110
+ from: '*' | TState | TState[];
111
+ to: TState | ((...args: any[]) => TState | Promise<TState>);
115
112
  };
116
113
  ```
117
114
 
118
- - `from`: The state(s) this transition can occur from
119
- - Single state: `'idle'`
120
- - Multiple states: `['loading', 'error']`
121
- - Any state: `'*'`
122
- - `to`: The target state or a function returning the target state
123
- - Static: `'loading'`
124
- - Dynamic: `(userId: string) => \`user_\${userId}\``
125
- - Async: `async (data) => await apiCall(data)`
115
+ #### `from` source state(s)
116
+
117
+ | Form | Example | Description |
118
+ | --------------- | ---------------------- | --------------------- |
119
+ | Single state | `'idle'` | Only from this state |
120
+ | Multiple states | `['loading', 'error']` | From any listed state |
121
+ | Wildcard | `'*'` | From any state |
122
+
123
+ #### `to` — target state
124
+
125
+ | Form | Example | Description |
126
+ | ------- | --------------------------------- | -------------------------------- |
127
+ | Static | `'loading'` | Always transitions to this state |
128
+ | Dynamic | `(id: string) => \`user\_${id}\`` | Compute target from arguments |
129
+ | Async | `async () => await fetchState()` | Returns `Promise<TState>` |
130
+
131
+ #### Transition behavior
132
+
133
+ - **Circular transitions are skipped** — if `from === to`, the transition is silently canceled with a warning.
134
+ - **Concurrent async transitions are blocked** — starting a new transition while an async one is pending triggers an error.
135
+ - **Invalid target states** — transitioning to a state not in `states` triggers an error.
136
+ - **Forbidden transitions** — calling a transition from a state not matching `from` triggers an error.
137
+ - **Return value** — every transition method returns the new state (or `Promise<TState>` for async transitions).
126
138
 
127
139
  #### Examples
128
140
 
129
141
  ```typescript
130
142
  const transitions = {
131
- // Simple transition
132
- 'idle -> loading': {
133
- from: 'idle',
134
- to: 'loading',
135
- },
136
-
137
- // Multiple source states
138
- reset: {
139
- from: ['success', 'error'],
140
- to: 'idle',
141
- },
142
-
143
- // Wildcard (from any state)
144
- goto: {
145
- from: '*',
146
- to: (targetState: State) => targetState,
147
- },
148
-
149
- // Async transition
150
- 'async fetch': {
151
- from: 'idle',
152
- to: async () => {
153
- const result = await fetchData();
154
- return result.success ? 'success' : 'error';
155
- },
156
- },
143
+ // Static transition
144
+ start: {
145
+ from: 'idle',
146
+ to: 'loading',
147
+ },
148
+
149
+ // Multiple source states
150
+ reset: {
151
+ from: ['success', 'error'],
152
+ to: 'idle',
153
+ },
154
+
155
+ // Wildcard (from any state)
156
+ goto: {
157
+ from: '*',
158
+ to: (target: State) => target,
159
+ },
160
+
161
+ // Async transition
162
+ fetch: {
163
+ from: 'idle',
164
+ to: async () => {
165
+ const result = await fetchData();
166
+ return result.ok ? 'success' : 'error';
167
+ },
168
+ },
157
169
  };
158
170
  ```
159
171
 
172
+ ### Error Handling
173
+
174
+ By default, the FSM throws on errors (forbidden transitions, invalid states, concurrent transitions). You can provide a custom `onError` handler to change this behavior:
175
+
176
+ ```typescript
177
+ const fsm = makeFsm({
178
+ init: 'idle',
179
+ states: ['idle', 'loading'],
180
+ transitions: {
181
+ start: { from: 'idle', to: 'loading' },
182
+ stop: { from: 'loading', to: 'idle' },
183
+ },
184
+ onError: (msg) => {
185
+ console.warn(msg); // Handle gracefully instead of throwing
186
+ },
187
+ });
188
+
189
+ // Won't throw — calls onError instead
190
+ fsm.stop(); // "idle" → "idle" via "stop" is forbidden (from doesn't match)
191
+ ```
192
+
193
+ When `onError` is provided, the FSM state remains unchanged after an error.
194
+
160
195
  ### Lifecycle Methods
161
196
 
162
- Lifecycle methods can be attached to the FSM configuration:
197
+ Lifecycle methods hook into the transition process:
163
198
 
164
199
  ```typescript
165
- const config = {
166
- // ... other config
167
- methods: {
168
- onBeforeTransition: (event) => {
169
- console.log('About to transition:', event);
170
- // Return false to cancel the transition
171
- return true;
172
- },
173
- onAfterTransition: (event) => {
174
- console.log('Transition completed:', event);
175
- },
176
- },
177
- };
200
+ const fsm = makeFsm({
201
+ // ...
202
+ methods: {
203
+ onBeforeTransition: (event) => {
204
+ console.log(`${event.from} → ${event.to} via ${event.transition}`);
205
+ return false; // Return false to cancel the transition
206
+ },
207
+ onAfterTransition: (event) => {
208
+ console.log('Transition complete:', event.transition);
209
+ },
210
+ },
211
+ });
178
212
  ```
179
213
 
180
- #### `onBeforeTransition(event)`
214
+ #### Lifecycle event object
181
215
 
182
- Called before a transition occurs. Return `false` to cancel the transition.
216
+ | Property | Type | Description |
217
+ | ------------ | -------------------- | --------------------------------------- |
218
+ | `transition` | `string` | Name of the transition (the config key) |
219
+ | `from` | `TState` | State before the transition |
220
+ | `to` | `TState` | Target state |
221
+ | `args` | `any[] \| undefined` | Arguments passed to dynamic transitions |
183
222
 
184
- **Parameters:**
185
- - `event`: Object with `transition`, `from`, `to`, and optional `args`
223
+ #### `onBeforeTransition(event)`
186
224
 
187
- #### `onAfterTransition(event)`
225
+ Called before a transition. Return `false` to veto (cancel) the transition.
188
226
 
189
- Called after a successful transition.
227
+ #### `onAfterTransition(event)`
190
228
 
191
- **Parameters:**
192
- - `event`: Object with `transition`, `from`, `to`, and optional `args`
229
+ Called after a successful transition. The FSM state is already updated at this point.
193
230
 
194
231
  ## Plugins
195
232
 
196
- Plugins extend the FSM with additional functionality. Each plugin receives an API object and returns a plugin definition.
233
+ Plugins extend the FSM with additional methods, grouped under a namespace.
197
234
 
198
235
  ### Plugin API
199
236
 
200
- Plugins have access to:
237
+ Each plugin receives an `api` object with:
201
238
 
202
- - `api.state()`: Get current state
203
- - `api.allStates()`: Get all states
204
- - `api.init(callback)`: Register initialization callback
205
- - `api.onBeforeTransition(callback)`: Register before transition callback
206
- - `api.onAfterTransition(callback)`: Register after transition callback
239
+ | Method | Description |
240
+ | ---------------------------------- | ----------------------------------------------------------------- |
241
+ | `api.state()` | Get current state |
242
+ | `api.allStates()` | Get all valid states |
243
+ | `api.init(callback)` | Run callback when FSM is created (receives initial state) |
244
+ | `api.onBeforeTransition(callback)` | Register before-transition listener. Returns unsubscribe function |
245
+ | `api.onAfterTransition(callback)` | Register after-transition listener. Returns unsubscribe function |
246
+ | `api.onError(callback)` | Register error listener. Returns unsubscribe function |
207
247
 
208
248
  ### Creating a Plugin
209
249
 
210
250
  ```typescript
211
- const myPlugin = (options) => (api) => {
212
- // Plugin initialization
213
- api.init((initialState) => {
214
- console.log('FSM initialized with state:', initialState);
215
- });
216
-
217
- // Listen to transitions
218
- api.onBeforeTransition((event) => {
219
- console.log('Transition starting:', event);
220
- });
221
-
222
- // Return plugin definition
223
- return {
224
- name: 'my-plugin',
225
- api: {
226
- // Custom methods exposed on fsm['my-plugin']
227
- doSomething: () => {
228
- return api.state();
229
- },
230
- },
231
- };
232
- };
251
+ import type { FsmLabel, FsmPlugin, FsmTransition } from '@uuxxx/fsm';
252
+
253
+ export const myPlugin = <TState extends FsmLabel, TTransitions extends Record<string, FsmTransition<TState>>>() =>
254
+ ((api) => {
255
+ let count = 0;
256
+
257
+ api.onAfterTransition(() => {
258
+ count++;
259
+ });
260
+
261
+ return {
262
+ name: 'counter' as const,
263
+ api: {
264
+ getCount: () => count,
265
+ },
266
+ };
267
+ }) satisfies FsmPlugin<TState, TTransitions>;
233
268
  ```
234
269
 
235
270
  ### Using Plugins
236
271
 
237
272
  ```typescript
238
- const config = {
239
- // ... other config
240
- plugins: [myPlugin({ someOption: true })],
241
- };
242
-
243
- const fsm = makeFsm(config);
273
+ const fsm = makeFsm({
274
+ // ...
275
+ plugins: [myPlugin()],
276
+ });
244
277
 
245
- // Access plugin API
246
- const currentState = fsm['my-plugin'].doSomething();
278
+ fsm.start();
279
+ fsm.counter.getCount(); // 1
247
280
  ```
248
281
 
282
+ Plugin names must be unique — registering two plugins with the same name triggers an error.
283
+
249
284
  ## Built-in Plugins
250
285
 
251
286
  ### History Plugin
252
287
 
253
- Tracks state history and provides navigation methods.
288
+ Tracks state history with pointer-based navigation.
254
289
 
255
290
  ```typescript
256
- import { makeFsm, historyPlugin } from '@uuxxx/fsm';
257
-
258
- const config = {
259
- // ... config
260
- plugins: [historyPlugin()],
261
- };
262
-
263
- const fsm = makeFsm(config);
291
+ import { makeFsm } from '@uuxxx/fsm';
292
+ import { fsmHistoryPlugin } from '@uuxxx/fsm/history-plugin';
264
293
 
265
- // Navigate
266
- fsm.goto('state1');
267
- fsm.goto('state2');
294
+ const fsm = makeFsm({
295
+ init: 'a',
296
+ states: ['a', 'b', 'c'],
297
+ transitions: {
298
+ goto: { from: '*', to: (s: 'a' | 'b' | 'c') => s },
299
+ },
300
+ plugins: [fsmHistoryPlugin()],
301
+ });
268
302
 
269
- // History API
270
- console.log(fsm.history.get()); // ['initial', 'state1', 'state2']
303
+ fsm.goto('b');
304
+ fsm.goto('c');
305
+ fsm.history.get(); // ['a', 'b', 'c']
271
306
 
272
- fsm.history.back(1); // Go back 1 step
273
- fsm.history.forward(1); // Go forward 1 step
307
+ fsm.history.back(1); // returns 'b'
308
+ fsm.history.back(1); // returns 'a'
309
+ fsm.history.forward(2); // returns 'c'
274
310
  ```
275
311
 
276
- #### History API Methods
312
+ #### History API
313
+
314
+ | Method | Returns | Description |
315
+ | ---------------------------- | ---------- | ---------------------------------------------------------------------------------- |
316
+ | `fsm.history.get()` | `TState[]` | Full history array |
317
+ | `fsm.history.back(steps)` | `TState` | Move pointer back by `steps`, returns the state at that position. Clamps to start |
318
+ | `fsm.history.forward(steps)` | `TState` | Move pointer forward by `steps`, returns the state at that position. Clamps to end |
277
319
 
278
- - `fsm.history.get()`: Get the full history array
279
- - `fsm.history.back(steps?)`: Go back N steps (default: 1)
280
- - `fsm.history.forward(steps?)`: Go forward N steps (default: 1)
320
+ > **Note:** `back()` and `forward()` move the internal history pointer and return the state at that position. They do **not** trigger a state transition on the FSM — use transition methods if you need to change the actual FSM state.
281
321
 
282
- ## Error Handling
322
+ When a transition occurs, any forward history after the current pointer is discarded (like browser navigation).
283
323
 
284
- The FSM throws errors in the following cases:
324
+ ## Exported Types
285
325
 
286
- - Invalid transition (current state doesn't match `from`)
287
- - Pending async transition when starting a new sync transition
288
- - Invalid target state
289
- - Duplicate plugin names
326
+ The library exports the following types for use in plugins and generic code:
290
327
 
291
328
  ```typescript
292
- try {
293
- fsm.invalidTransition();
294
- } catch (error) {
295
- console.error(error.message); // [FSM]: Transition: "invalidTransition" is forbidden
296
- }
329
+ import type {
330
+ FsmConfig, // Config<TState, TTransitions, TPlugins>
331
+ FsmTransition, // Transition<TState>
332
+ FsmPlugin, // Plugin<TState, TTransitions>
333
+ FsmLabel, // string (state label type)
334
+ } from '@uuxxx/fsm';
297
335
  ```
298
336
 
299
337
  ## TypeScript Support
300
338
 
301
- The library is fully typed. Type inference works automatically:
339
+ The library is built with TypeScript-first design. All types are inferred from config — no manual type annotations needed:
302
340
 
303
341
  ```typescript
304
342
  const fsm = makeFsm({
305
- init: 'idle',
306
- states: ['idle', 'running', 'stopped'],
307
- transitions: {
308
- start: { from: 'idle', to: 'running' },
309
- stop: { from: 'running', to: 'stopped' },
310
- },
343
+ init: 'idle',
344
+ states: ['idle', 'running', 'stopped'],
345
+ transitions: {
346
+ start: { from: 'idle', to: 'running' },
347
+ stop: { from: 'running', to: 'stopped' },
348
+ },
311
349
  });
312
- // fsm is fully typed - autocomplete works for transitions and states
350
+
351
+ fsm.start(); // ✓ typed — only callable from 'idle'
352
+ fsm.stop(); // ✓ typed — only callable from 'running'
353
+ fsm.state(); // ✓ returns 'idle' | 'running' | 'stopped'
313
354
  ```
355
+
356
+ ## License
357
+
358
+ [MIT](./LICENSE)
package/dist/index.d.ts CHANGED
@@ -1,15 +1,89 @@
1
- import { a as Transition, c as KeyOf, i as LifecycleMethods, l as Rec, n as Plugin, o as Label, r as StateMethods, s as EmptyArray } from "./Plugin-DGULTFg-.js";
1
+ type AnyFn = (...args: any[]) => any; //#endregion
2
+ type Rec<T = unknown> = Record<string, T>; //#endregion
3
+ type KeyOf<T extends Rec> = keyof T; //#endregion
4
+ type Noop = () => void; //#endregion
5
+ type Key = string | number | symbol; //#endregion
6
+ type Vdx<T> = T | void; //#endregion
7
+ type EmptyArray = []; //#endregion
8
+ type Entries<T extends Rec> = { [K in KeyOf<T>]: [K, T[K]] }[KeyOf<T>]; //#endregion
9
+ type Label = string;
10
+ /**
11
+ * Defines a single state transition.
12
+ *
13
+ * - `from` — source state(s): a single state, an array of states, or `'*'` for any state.
14
+ * - `to` — target: a static state, a function returning a state, or an async function returning `Promise<TState>`.
15
+ */
16
+ type Transition<TState extends Label> = {
17
+ from: '*' | TState | TState[];
18
+ to: TState | ((...args: any[]) => TState | Promise<TState>);
19
+ };
20
+ /** Event object passed to lifecycle hooks during a transition. */
21
+ type Lifecycle<TState extends Label, TEntry extends [Key, Transition<Label>]> = {
22
+ args?: Parameters<Extract<TEntry[1]['to'], AnyFn>>;
23
+ transition: TEntry[0];
24
+ from: TState;
25
+ to: TState;
26
+ };
27
+ type LifecycleMethod<TState extends Label, TTransitions extends Rec<Transition<TState>>> = (lifecycle: Lifecycle<TState, Entries<TTransitions>>) => void;
28
+ type CancelableLifecycleMethod<TState extends Label, TTransitions extends Rec<Transition<TState>>> = (lifecycle: Lifecycle<TState, Entries<TTransitions>>) => Vdx<boolean>;
29
+ type LifecycleMethods<TState extends Label, TTransitions extends Rec<Transition<TState>>> = {
30
+ onBeforeTransition?: CancelableLifecycleMethod<TState, TTransitions>;
31
+ onAfterTransition?: LifecycleMethod<TState, TTransitions>;
32
+ };
33
+ type StateMethods<TState extends Label> = {
34
+ state: () => TState;
35
+ allStates: () => TState[];
36
+ };
37
+ /** API object passed to each plugin during registration. Provides state access, lifecycle hooks, and error listeners. */
38
+ type ApiForPlugin<TState extends Label, TTransitions extends Rec<Transition<TState>>> = {
39
+ init: (listener: (state: TState) => void) => void;
40
+ onError: (listener: (msg: string) => void) => Noop;
41
+ } & StateMethods<TState> & { [K in KeyOf<LifecycleMethods<TState, TTransitions>>]-?: (listener: LifecycleMethods<TState, TTransitions>[K]) => Noop };
42
+ type PluginApi = {
43
+ name: string;
44
+ api: Rec<AnyFn>;
45
+ };
46
+ /**
47
+ * A plugin is a function that receives the {@link ApiForPlugin} and returns
48
+ * `{ name, api }` — the name becomes a namespace on the FSM instance,
49
+ * and `api` methods are accessible under that namespace.
50
+ */
51
+ type Plugin<TState extends Label = Label, TTransitions extends Rec<Transition<TState>> = Rec<Transition<TState>>> = (api: ApiForPlugin<TState, TTransitions>) => PluginApi;
52
+ /** Configuration object for {@link makeFsm}. */
2
53
  type Config<TState extends Label, TTransitions extends Rec<Transition<TState>>, TPlugins extends Array<Plugin<TState, TTransitions>> = EmptyArray> = {
3
- init: TState;
4
- states: TState[];
5
- transitions: TTransitions;
6
- methods?: LifecycleMethods<TState, TTransitions>;
7
- plugins?: TPlugins;
54
+ /** Initial state of the FSM. */init: TState; /** All valid states. The FSM will reject transitions to states not in this list. */
55
+ states: TState[]; /** Transition definitions. Each key becomes a method on the FSM instance. */
56
+ transitions: TTransitions; /** Optional lifecycle hooks (`onBeforeTransition`, `onAfterTransition`). */
57
+ methods?: LifecycleMethods<TState, TTransitions>; /** Optional plugins to extend the FSM with additional APIs. */
58
+ plugins?: TPlugins; /** Custom error handler. By default, errors throw with a `[FSM]:` prefix. */
59
+ onError?: (msg: string) => void;
8
60
  };
9
61
  type TransitionMethods<TTransitions extends Rec<Transition<Label>>> = { [K in KeyOf<TTransitions>]: TTransitions[K]['to'] extends Label ? () => TTransitions[K]['to'] : TTransitions[K]['to'] };
10
62
  type PluginsMethods<TState extends Label, TTransitions extends Rec<Transition<TState>>, TPlugins extends Array<Plugin<TState, TTransitions>>> = { [K in ReturnType<TPlugins[number]>['name']]: Extract<ReturnType<TPlugins[number]>, {
11
63
  name: K;
12
64
  }>['api'] };
13
65
  type Methods<TState extends Label, TTransitions extends Rec<Transition<TState>>, TPlugins extends Array<Plugin<TState, TTransitions>>> = TransitionMethods<TTransitions> & StateMethods<TState> & PluginsMethods<TState, TTransitions, TPlugins>;
66
+ /**
67
+ * Creates a finite state machine instance from the given configuration.
68
+ *
69
+ * Returns an object combining state methods (`state()`, `allStates()`),
70
+ * auto-generated transition methods (one per key in `config.transitions`),
71
+ * and plugin APIs (one namespace per plugin).
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * const fsm = makeFsm({
76
+ * init: 'idle',
77
+ * states: ['idle', 'loading', 'done'],
78
+ * transitions: {
79
+ * start: { from: 'idle', to: 'loading' },
80
+ * finish: { from: 'loading', to: 'done' },
81
+ * },
82
+ * });
83
+ *
84
+ * fsm.start(); // 'loading'
85
+ * fsm.finish(); // 'done'
86
+ * ```
87
+ */
14
88
  declare const makeFsm: <TState extends Label, TTransitions extends Rec<Transition<TState>>, TPlugins extends Array<Plugin<TState, TTransitions>>>(config: Config<TState, TTransitions, TPlugins>) => Methods<TState, TTransitions, TPlugins>;
15
89
  export { type Config as FsmConfig, type Label as FsmLabel, type Plugin as FsmPlugin, type Transition as FsmTransition, makeFsm };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- let t={nlx:t=>null===t,ulx:t=>void 0===t,nil:e=>t.nlx(e)||t.ulx(e),not:{nlx:t=>null!==t,ulx:t=>void 0!==t,nil:e=>t.not.nlx(e)&&t.not.ulx(e)},array:t=>Array.isArray(t),string:t=>"string"==typeof t,function:t=>"function"==typeof t,promise:t=>t instanceof Promise,boolean:t=>"boolean"==typeof t,false:t=>!1===t,true:t=>!0===t};function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function r(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);e&&(n=n.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),r.push.apply(r,n)}return r}function n(t){for(var n=1;n<arguments.length;n++){var i=null!=arguments[n]?arguments[n]:{};n%2?r(Object(i),!0).forEach(function(r){!function(t,r,n){var i;(i=function(t,r){if("object"!=e(t)||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var i=n.call(t,r||"default");if("object"!=e(i))return i;throw TypeError("@@toPrimitive must return a primitive value.")}return("string"===r?String:Number)(t)}(r,"string"),(r="symbol"==e(i)?i:i+"")in t)?Object.defineProperty(t,r,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[r]=n}(t,r,i[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(i)):r(Object(i)).forEach(function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(i,e))})}return t}let i=e=>{var r,i,o;let l,s,a,u,f,c,m=e.init,b=e.states.includes(e.init)?[...e.states]:[...e.states,e.init],p=(l=new Map,{listen(t,e){var r;return l.has(t)||l.set(t,[]),null==(r=l.get(t))||r.push(e),()=>{this.unlisten(t,e)}},unlisten(t,e){let r=l.get(t);if(!r)return;let n=r.filter(t=>t!==e);n.length?l.set(t,n):l.delete(t)},emit(e,...r){var n,i;return null!=(n=null==(i=l.get(e))?void 0:i.map(t=>t(...r)).filter(t.not.ulx))?n:[]},unlistenAll(t){l.delete(t)}});Object.entries(null!=(r=e.methods)?r:{}).forEach(([t,e])=>{p.listen(t,e)}),p.listen("onAfterTransition",({to:t})=>{m=t}),p.listen("error",t=>{throw Error(`[FSM]: ${t}`)});let y={state:()=>m,allStates:()=>b},g=(i=e.transitions,s=((e,{state:r,allStates:n})=>{let i,o={},l={register(s,a){let u=r=>{n().includes(r.to)?r.to===r.from?e.emit("warn",`
1
+ let t={nlx:t=>null===t,ulx:t=>void 0===t,nil:e=>t.nlx(e)||t.ulx(e),not:{nlx:t=>null!==t,ulx:t=>void 0!==t,nil:e=>t.not.nlx(e)&&t.not.ulx(e)},array:t=>Array.isArray(t),string:t=>"string"==typeof t,function:t=>"function"==typeof t,promise:t=>t instanceof Promise,boolean:t=>"boolean"==typeof t,false:t=>!1===t,true:t=>!0===t};function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function r(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);e&&(n=n.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),r.push.apply(r,n)}return r}function n(t){for(var n=1;n<arguments.length;n++){var i=null!=arguments[n]?arguments[n]:{};n%2?r(Object(i),!0).forEach(function(r){!function(t,r,n){var i;(i=function(t,r){if("object"!=e(t)||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var i=n.call(t,r||"default");if("object"!=e(i))return i;throw TypeError("@@toPrimitive must return a primitive value.")}return("string"===r?String:Number)(t)}(r,"string"),(r="symbol"==e(i)?i:i+"")in t)?Object.defineProperty(t,r,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[r]=n}(t,r,i[r])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(i)):r(Object(i)).forEach(function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(i,e))})}return t}let i=e=>{var r,i,o,l;let s,a,u,f,c,m,b=e.init,p=e.states.includes(e.init)?[...e.states]:[...e.states,e.init],y=(s=new Map,{listen(t,e){var r;return s.has(t)||s.set(t,[]),null==(r=s.get(t))||r.push(e),()=>{this.unlisten(t,e)}},unlisten(t,e){let r=s.get(t);if(!r)return;let n=r.filter(t=>t!==e);n.length?s.set(t,n):s.delete(t)},emit(e,...r){var n,i;return null!=(n=null==(i=s.get(e))?void 0:i.map(t=>t(...r)).filter(t.not.ulx))?n:[]},unlistenAll(t){s.delete(t)}});Object.entries(null!=(r=e.methods)?r:{}).forEach(([t,e])=>{y.listen(t,e)}),y.listen("onAfterTransition",({to:t})=>{b=t}),y.listen("error",null!=(i=e.onError)?i:t=>{throw Error(`[FSM]: ${t}`)});let g={state:()=>b,allStates:()=>p},d=(o=e.transitions,a=((e,{state:r,allStates:n})=>{let i,o={},l={register(s,a){let u=r=>{n().includes(r.to)?r.to===r.from?e.emit("warn",`
2
2
  Transition: "${s}" is canceled because it's circular.
3
3
  Current state is ${r.from}. Transition target state is ${r.to}
4
- `):e.emit("onBeforeTransition",r).filter(t.boolean).every(t.true)&&e.emit("onAfterTransition",r):e.emit("error",`Transition: "${s}" can't be executed. It has invalid "to": "${r.to}"`)};return o[s]=(...n)=>{if(t.array(a.from)?!a.from.includes(r()):"*"!==a.from&&a.from!==r())return e.emit("error",`Transition: "${s}" is forbidden`),r();if(i)return e.emit("error",`Transition: "${s}" can't be made. Has pending transtion: "${i}"`),r();if(!t.function(a.to))return u({transition:s,from:r(),to:a.to}),r();let o=a.to(...n);return t.promise(o)?(i=s,o.then(t=>(i=void 0,u({transition:s,from:r(),to:t,args:n}),r()))):(u({transition:s,from:r(),to:o,args:n}),r())},l},make:()=>o};return l})(p,y),Object.entries(i).forEach(([t,e])=>s.register(t,e)),s.make()),d=(o=e.plugins,a={},u=n({init(t){p.listen("init",t)},onBeforeTransition:t=>p.listen("onBeforeTransition",t),onAfterTransition:t=>p.listen("onAfterTransition",t)},y),c=f={register(t){let{name:e,api:r}=t(u);return e in a&&p.emit("error",`There are at least two plugins with the same name: "${e}"`),a[e]=r,f},make:()=>a},(null!=o?o:[]).forEach(c.register),c.make());return p.emit("init",m),n(n(n({},y),d),g)};export{i as makeFsm};
4
+ `):e.emit("onBeforeTransition",r).filter(t.boolean).every(t.true)&&e.emit("onAfterTransition",r):e.emit("error",`Transition: "${s}" can't be executed. It has invalid "to": "${r.to}"`)};return o[s]=(...n)=>{if(t.array(a.from)?!a.from.includes(r()):"*"!==a.from&&a.from!==r())return e.emit("error",`Transition: "${s}" is forbidden`),r();if(i)return e.emit("error",`Transition: "${s}" can't be made. Has pending transtion: "${i}"`),r();if(!t.function(a.to))return u({transition:s,from:r(),to:a.to}),r();let o=a.to(...n);return t.promise(o)?(i=s,o.then(t=>(i=void 0,u({transition:s,from:r(),to:t,args:n}),r()))):(u({transition:s,from:r(),to:o,args:n}),r())},l},make:()=>o};return l})(y,g),Object.entries(o).forEach(([t,e])=>a.register(t,e)),a.make()),v=(l=e.plugins,u={},f=n({init(t){y.listen("init",t)},onError:t=>y.listen("error",t),onBeforeTransition:t=>y.listen("onBeforeTransition",t),onAfterTransition:t=>y.listen("onAfterTransition",t)},g),m=c={register(t){let{name:e,api:r}=t(f);return e in u&&y.emit("error",`There are at least two plugins with the same name: "${e}"`),u[e]=r,c},make:()=>u},(null!=l?l:[]).forEach(m.register),m.make());return y.emit("init",b),n(n(n({},g),v),d)};export{i as makeFsm};
package/package.json CHANGED
@@ -1,68 +1,48 @@
1
1
  {
2
2
  "name": "@uuxxx/fsm",
3
- "version": "1.2.3",
4
- "author": "Artem Tryapichnikov <golysheeet@gmail.com>",
5
- "license": "MIT",
6
- "description": "Javascript library for creating finite state machine",
7
- "homepage": "https://github.com/uuxxx/fsm/blob/main/README.md",
8
- "type": "module",
9
- "publishConfig": {
10
- "access": "public"
11
- },
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/uuxxx/fsm.git"
15
- },
3
+ "version": "1.3.0",
4
+ "description": "Lightweight, type-safe finite state machine for TypeScript with plugin support and lifecycle hooks",
16
5
  "keywords": [
17
6
  "finite state machine",
18
- "fsm"
7
+ "fsm",
8
+ "lifecycle",
9
+ "plugins",
10
+ "state machine",
11
+ "type-safe",
12
+ "typescript"
19
13
  ],
20
- "engines": {
21
- "node": ">= 20",
22
- "pnpm": ">= 10"
14
+ "homepage": "https://github.com/uuxxx/fsm/blob/main/packages/core/README.md",
15
+ "license": "MIT",
16
+ "author": "Artem Tryapichnikov <golysheeet@gmail.com>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/uuxxx/fsm.git",
20
+ "directory": "packages/core"
23
21
  },
24
22
  "files": [
25
23
  "dist",
26
24
  "README.md",
27
25
  "LICENSE"
28
26
  ],
27
+ "type": "module",
29
28
  "exports": {
30
29
  ".": {
31
30
  "types": "./dist/index.d.ts",
32
31
  "default": "./dist/index.js"
33
- },
34
- "./history-plugin": {
35
- "types": "./dist/history-plugin.d.ts",
36
- "default": "./dist/history-plugin.js"
37
32
  }
38
33
  },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
39
37
  "devDependencies": {
40
- "@babel/core": "^7.28.4",
41
- "@babel/preset-env": "^7.28.3",
42
- "@babel/preset-typescript": "^7.27.1",
43
- "@changesets/cli": "^2.29.7",
44
38
  "@swc/core": "^1.13.5",
45
- "eslint": "^9.37.0",
46
- "eslint-config-xo-typescript": "^9.0.0",
47
- "lefthook": "^2.0.2",
48
39
  "rolldown": "^1.0.0-beta.41",
49
40
  "rolldown-plugin-dts": "^0.22.1",
50
41
  "rollup-plugin-swc3": "^0.12.1",
51
- "ts-node": "^10.9.2",
52
- "tslib": "^2.8.1",
53
- "typescript": "^5.9.3",
54
- "vitest": "^4.0.18"
55
- },
56
- "dependencies": {
57
- "@uuxxx/utils": "^0.0.3"
42
+ "tslib": "^2.8.1"
58
43
  },
59
44
  "scripts": {
60
45
  "build": "rolldown -c rolldown.config.ts",
61
- "changeset:version": "changeset version && git add --all && git commit -m 'chore: bump'",
62
- "changeset:publish": "changeset publish",
63
- "test": "vitest run",
64
- "lint": "eslint",
65
- "lint:fix": "eslint --fix",
66
46
  "check:types": "tsc --noEmit"
67
47
  }
68
48
  }
@@ -1,38 +0,0 @@
1
- type AnyFn = (...args: any[]) => any; //#endregion
2
- type Rec<T = unknown> = Record<string, T>; //#endregion
3
- type KeyOf<T extends Rec> = keyof T; //#endregion
4
- type Noop = () => void; //#endregion
5
- type Key = string | number | symbol; //#endregion
6
- type Vdx<T> = T | void; //#endregion
7
- type EmptyArray = []; //#endregion
8
- type Entries<T extends Rec> = { [K in KeyOf<T>]: [K, T[K]] }[KeyOf<T>]; //#endregion
9
- type Label = string;
10
- type Transition<TState extends Label> = {
11
- from: '*' | TState | TState[];
12
- to: TState | ((...args: any[]) => TState | Promise<TState>);
13
- };
14
- type Lifecycle<TState extends Label, TEntry extends [Key, Transition<Label>]> = {
15
- args?: Parameters<Extract<TEntry[1]['to'], AnyFn>>;
16
- transition: TEntry[0];
17
- from: TState;
18
- to: TState;
19
- };
20
- type LifecycleMethod<TState extends Label, TTransitions extends Rec<Transition<TState>>> = (lifecycle: Lifecycle<TState, Entries<TTransitions>>) => void;
21
- type CancelableLifecycleMethod<TState extends Label, TTransitions extends Rec<Transition<TState>>> = (lifecycle: Lifecycle<TState, Entries<TTransitions>>) => Vdx<boolean>;
22
- type LifecycleMethods<TState extends Label, TTransitions extends Rec<Transition<TState>>> = {
23
- onBeforeTransition?: CancelableLifecycleMethod<TState, TTransitions>;
24
- onAfterTransition?: LifecycleMethod<TState, TTransitions>;
25
- };
26
- type StateMethods<TState extends Label> = {
27
- state: () => TState;
28
- allStates: () => TState[];
29
- };
30
- type ApiForPlugin<TState extends Label, TTransitions extends Rec<Transition<TState>>> = {
31
- init: (listener: (state: TState) => void) => void;
32
- } & StateMethods<TState> & { [K in KeyOf<LifecycleMethods<TState, TTransitions>>]-?: (listener: LifecycleMethods<TState, TTransitions>[K]) => Noop };
33
- type PluginApi = {
34
- name: string;
35
- api: Rec<AnyFn>;
36
- };
37
- type Plugin<TState extends Label = Label, TTransitions extends Rec<Transition<TState>> = Rec<Transition<TState>>> = (api: ApiForPlugin<TState, TTransitions>) => PluginApi;
38
- export { Transition as a, KeyOf as c, LifecycleMethods as i, Rec as l, Plugin as n, Label as o, StateMethods as r, EmptyArray as s, ApiForPlugin as t };
@@ -1,10 +0,0 @@
1
- import { a as Transition, l as Rec, o as Label, t as ApiForPlugin } from "./Plugin-DGULTFg-.js";
2
- declare const historyPlugin: <TState extends Label, TTransitions extends Rec<Transition<TState>>>() => (api: ApiForPlugin<TState, TTransitions>) => {
3
- name: "history";
4
- api: {
5
- get(): TState[];
6
- back(steps: number): TState;
7
- forward(steps: number): TState;
8
- };
9
- };
10
- export { historyPlugin as fsmHistoryPlugin };
@@ -1 +0,0 @@
1
- let t=()=>t=>{let i=[],n=0;return t.init(t=>i.push(t)),t.onAfterTransition(({to:t})=>{n++,i.splice(n,i.length-n,t)}),{name:"history",api:{get:()=>i,back:t=>i[n=Math.max(0,n-t)],forward:t=>(n=Math.min(i.length-1,n+t),i[n])}}};export{t as fsmHistoryPlugin};