@doeixd/machine 0.0.7 → 0.0.8
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 +130 -272
- package/dist/cjs/development/index.js +1001 -18
- package/dist/cjs/development/index.js.map +4 -4
- package/dist/cjs/production/index.js +5 -5
- package/dist/esm/development/index.js +1001 -18
- package/dist/esm/development/index.js.map +4 -4
- package/dist/esm/production/index.js +5 -5
- package/dist/types/index.d.ts +63 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/middleware.d.ts +1048 -0
- package/dist/types/middleware.d.ts.map +1 -0
- package/dist/types/primitives.d.ts +105 -3
- package/dist/types/primitives.d.ts.map +1 -1
- package/dist/types/runtime-extract.d.ts.map +1 -1
- package/dist/types/utils.d.ts +111 -6
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/adapters.ts +407 -0
- package/src/extract.ts +1 -1
- package/src/index.ts +197 -8
- package/src/middleware.ts +2325 -0
- package/src/primitives.ts +194 -3
- package/src/runtime-extract.ts +15 -0
- package/src/utils.ts +221 -6
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// src/adapters.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Event-Driven Adapters for @doeixd/machine
|
|
5
|
+
* @description Provides primitives to adapt a machine's method-call-based API
|
|
6
|
+
* to standard event-driven interfaces like the browser's `EventTarget` and
|
|
7
|
+
* Node.js's `EventEmitter`. These adapters allow your type-safe machines to
|
|
8
|
+
* integrate seamlessly into decoupled, event-driven architectures.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EventEmitter } from 'events';
|
|
12
|
+
import {
|
|
13
|
+
Machine,
|
|
14
|
+
Runner,
|
|
15
|
+
createRunner,
|
|
16
|
+
Context,
|
|
17
|
+
TransitionNames,
|
|
18
|
+
} from './index';
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// SECTION 0: Observable Types (Minimal Implementation)
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A minimal Observer interface for reactive streams.
|
|
26
|
+
* Compatible with RxJS and other Observable implementations.
|
|
27
|
+
*/
|
|
28
|
+
export interface Observer<T> {
|
|
29
|
+
next?: (value: T) => void;
|
|
30
|
+
error?: (error: any) => void;
|
|
31
|
+
complete?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A minimal Observable interface for reactive streams.
|
|
36
|
+
* Compatible with RxJS and other Observable implementations.
|
|
37
|
+
*/
|
|
38
|
+
export interface Observable<T> {
|
|
39
|
+
subscribe(observer: Observer<T>): { unsubscribe: () => void };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// SECTION 1: EventTarget Adapter (for Browser Environments)
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
// --- Helper Types for EventTarget ---
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A helper type that extracts the detail payload for a given machine event.
|
|
50
|
+
* If the transition has arguments, it's an array of those arguments.
|
|
51
|
+
* If it has no arguments, it's `undefined`.
|
|
52
|
+
*
|
|
53
|
+
* @template M The machine type.
|
|
54
|
+
* @template K The name of the transition.
|
|
55
|
+
*/
|
|
56
|
+
export type MachineEventDetail<
|
|
57
|
+
M extends Machine<any>,
|
|
58
|
+
K extends TransitionNames<M>
|
|
59
|
+
> = M[K] extends (...args: infer A) => any ? (A extends [] ? undefined : A) : never;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A mapped type that creates a DOM-standard event map for a machine.
|
|
63
|
+
* This is crucial for providing type safety when using `addEventListener`.
|
|
64
|
+
*
|
|
65
|
+
* It includes:
|
|
66
|
+
* - A `statechange` event with the new machine state in its detail.
|
|
67
|
+
* - An `error` event with an `Error` object in its detail.
|
|
68
|
+
* - An entry for every possible machine transition.
|
|
69
|
+
*
|
|
70
|
+
* @template M The machine type.
|
|
71
|
+
*/
|
|
72
|
+
export type MachineEventMap<M extends Machine<any>> = {
|
|
73
|
+
[K in TransitionNames<M>]: CustomEvent<MachineEventDetail<M, K>>;
|
|
74
|
+
} & {
|
|
75
|
+
statechange: CustomEvent<{ state: M }>;
|
|
76
|
+
error: CustomEvent<{ error: Error }>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A type-safe, augmented EventTarget that wraps a state machine.
|
|
82
|
+
*
|
|
83
|
+
* It provides two key functionalities:
|
|
84
|
+
* 1. Emits a `CustomEvent` named 'statechange' whenever the machine's state updates.
|
|
85
|
+
* 2. Listens for other `CustomEvent`s and translates them into type-safe machine transitions.
|
|
86
|
+
*
|
|
87
|
+
* @template M The machine type (can be a union of states).
|
|
88
|
+
*/
|
|
89
|
+
export class MachineEventTarget<M extends Machine<any>> extends EventTarget {
|
|
90
|
+
private readonly runner: Runner<M>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The current, readonly state of the machine.
|
|
94
|
+
* Access this property to get the latest machine instance for UI rendering or inspection.
|
|
95
|
+
* @example
|
|
96
|
+
* console.log(machineTarget.state.context.count);
|
|
97
|
+
*/
|
|
98
|
+
public get state(): M {
|
|
99
|
+
return this.runner.state;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A direct, readonly accessor to the machine's current context.
|
|
104
|
+
* A convenience property equivalent to `machineTarget.state.context`.
|
|
105
|
+
*/
|
|
106
|
+
public get context(): Context<M> {
|
|
107
|
+
return this.runner.state.context;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
constructor(initialMachine: M) {
|
|
111
|
+
super();
|
|
112
|
+
|
|
113
|
+
this.runner = createRunner(initialMachine, (newState) => {
|
|
114
|
+
this.dispatchEvent(new CustomEvent('statechange', { detail: { state: newState } }));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const handleEvent = (event: Event) => {
|
|
118
|
+
const { type, detail } = event as CustomEvent;
|
|
119
|
+
if (type === 'statechange' || type === 'error') return;
|
|
120
|
+
|
|
121
|
+
const action = (this.runner.actions as any)[type];
|
|
122
|
+
if (typeof action === 'function') {
|
|
123
|
+
const args = Array.isArray(detail) ? detail : [];
|
|
124
|
+
action(...args);
|
|
125
|
+
} else {
|
|
126
|
+
const error = new Error(`Invalid event type "${type}" for current state "${(this.state.context as any).status || 'unknown'}".`);
|
|
127
|
+
this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Override dispatchEvent to intercept all events and route them.
|
|
132
|
+
const originalDispatchEvent = this.dispatchEvent.bind(this);
|
|
133
|
+
this.dispatchEvent = (event: Event): boolean => {
|
|
134
|
+
// The event is first handled by our logic, then passed to the native dispatcher.
|
|
135
|
+
handleEvent(event);
|
|
136
|
+
return originalDispatchEvent(event);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Type-safe event listener methods for machine events
|
|
141
|
+
public addMachineEventListener<K extends keyof MachineEventMap<M>>(
|
|
142
|
+
type: K,
|
|
143
|
+
listener: (event: MachineEventMap<M>[K]) => void,
|
|
144
|
+
options?: boolean | AddEventListenerOptions
|
|
145
|
+
): void {
|
|
146
|
+
super.addEventListener(type, listener as EventListener, options);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public removeMachineEventListener<K extends keyof MachineEventMap<M>>(
|
|
150
|
+
type: K,
|
|
151
|
+
listener: (event: MachineEventMap<M>[K]) => void,
|
|
152
|
+
options?: boolean | EventListenerOptions
|
|
153
|
+
): void {
|
|
154
|
+
super.removeEventListener(type, listener as EventListener, options);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A type-safe method for dispatching transition events.
|
|
161
|
+
* This is the recommended way to interact with the machine from your application code.
|
|
162
|
+
*
|
|
163
|
+
* @param type The name of the transition to trigger (e.g., 'add').
|
|
164
|
+
* @param detail The arguments for that transition, matching the method signature.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* // For a transition `add(n: number)`
|
|
168
|
+
* machineTarget.dispatch('add', [5]);
|
|
169
|
+
*
|
|
170
|
+
* // For a transition `increment()`
|
|
171
|
+
* machineTarget.dispatch('increment');
|
|
172
|
+
*/
|
|
173
|
+
public dispatch<K extends TransitionNames<M>>(
|
|
174
|
+
type: K,
|
|
175
|
+
detail?: MachineEventDetail<M, K>
|
|
176
|
+
): void {
|
|
177
|
+
super.dispatchEvent(new CustomEvent(type, { detail }));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Creates a browser-native EventTarget from a machine.
|
|
183
|
+
*
|
|
184
|
+
* This powerful adapter makes your machine behave like a standard DOM element,
|
|
185
|
+
* perfect for decoupling components or integrating with event-driven browser APIs.
|
|
186
|
+
*
|
|
187
|
+
* @param initialMachine The machine instance to wrap.
|
|
188
|
+
* @returns A `MachineEventTarget` instance.
|
|
189
|
+
*/
|
|
190
|
+
export function asEventTarget<M extends Machine<any>>(initialMachine: M): MachineEventTarget<M> {
|
|
191
|
+
return new MachineEventTarget(initialMachine);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A utility function to ergonomically add and clean up a listener on a MachineEventTarget.
|
|
196
|
+
* It returns an `unsubscribe` function, which is ideal for use in `useEffect` hooks.
|
|
197
|
+
*
|
|
198
|
+
* @param target The `MachineEventTarget` to listen to.
|
|
199
|
+
* @param type The name of the event to listen for.
|
|
200
|
+
* @param listener The callback function to execute.
|
|
201
|
+
* @returns A cleanup function that removes the event listener.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* useEffect(() => {
|
|
205
|
+
* // The listener is automatically typed based on the event name.
|
|
206
|
+
* const unsubscribe = listen(counterTarget, 'statechange', (event) => {
|
|
207
|
+
* setCount(event.detail.state.context.count);
|
|
208
|
+
* });
|
|
209
|
+
*
|
|
210
|
+
* // The returned function is perfect for a useEffect cleanup.
|
|
211
|
+
* return unsubscribe;
|
|
212
|
+
* }, []);
|
|
213
|
+
*/
|
|
214
|
+
export function listen<M extends Machine<any>, K extends keyof MachineEventMap<M>>(
|
|
215
|
+
target: MachineEventTarget<M>,
|
|
216
|
+
type: K,
|
|
217
|
+
listener: (event: MachineEventMap<M>[K]) => void
|
|
218
|
+
): () => void {
|
|
219
|
+
target.addMachineEventListener(type, listener);
|
|
220
|
+
return () => {
|
|
221
|
+
target.removeMachineEventListener(type, listener);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// SECTION 2: EventEmitter Adapter (for Node.js & Event-Driven Architectures)
|
|
228
|
+
// =============================================================================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Defines the events and their payloads that our MachineEventEmitter can emit,
|
|
232
|
+
* providing strict type safety for listeners.
|
|
233
|
+
*/
|
|
234
|
+
interface MachineEmitterEvents<M extends Machine<any>> {
|
|
235
|
+
statechange: (newState: M) => void;
|
|
236
|
+
error: (error: Error) => void;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A type-safe, augmented EventEmitter that wraps a state machine.
|
|
241
|
+
*
|
|
242
|
+
* It provides two key functionalities:
|
|
243
|
+
* 1. Emits a `'statechange'` event whenever the machine's state updates.
|
|
244
|
+
* 2. Exposes a type-safe `dispatch` method to trigger machine transitions.
|
|
245
|
+
*
|
|
246
|
+
* @template M The machine type (can be a union of states).
|
|
247
|
+
*/
|
|
248
|
+
export class MachineEventEmitter<M extends Machine<any>> extends EventEmitter {
|
|
249
|
+
private readonly runner: Runner<M>;
|
|
250
|
+
|
|
251
|
+
// Augment EventEmitter's methods to be fully type-safe with our event map.
|
|
252
|
+
public on<E extends keyof MachineEmitterEvents<M>>(event: E, listener: MachineEmitterEvents<M>[E]): this {
|
|
253
|
+
return super.on(event, listener);
|
|
254
|
+
}
|
|
255
|
+
public emit<E extends keyof MachineEmitterEvents<M>>(event: E, ...args: Parameters<MachineEmitterEvents<M>[E]>): boolean {
|
|
256
|
+
return super.emit(event, ...args);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
public get state(): M {
|
|
260
|
+
return this.runner.state;
|
|
261
|
+
}
|
|
262
|
+
public get context(): Context<M> {
|
|
263
|
+
return this.runner.state.context;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
constructor(initialMachine: M) {
|
|
267
|
+
super();
|
|
268
|
+
this.runner = createRunner(initialMachine, (newState) => {
|
|
269
|
+
this.emit('statechange', newState);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* A type-safe method for dispatching transitions to the machine.
|
|
275
|
+
* This is the primary input for the machine in an event-driven system.
|
|
276
|
+
*
|
|
277
|
+
* @param eventName The name of the transition to trigger.
|
|
278
|
+
* @param args The arguments for that transition, matching the method signature.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* sessionEmitter.dispatch('login', 'username', 'password');
|
|
282
|
+
*/
|
|
283
|
+
public dispatch<K extends TransitionNames<M>>(
|
|
284
|
+
eventName: K,
|
|
285
|
+
...args: M[K] extends (...args: infer A) => any ? A : never
|
|
286
|
+
): void {
|
|
287
|
+
const action = (this.runner.actions as any)[eventName];
|
|
288
|
+
|
|
289
|
+
if (typeof action === 'function') {
|
|
290
|
+
action(...args);
|
|
291
|
+
} else {
|
|
292
|
+
this.emit('error', new Error(`Invalid event "${String(eventName)}" for current state.`));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Creates a Node.js-style EventEmitter from a machine.
|
|
299
|
+
*
|
|
300
|
+
* This adapter is perfect for backend services, scripts, or any architecture that
|
|
301
|
+
* uses the classic EventEmitter pattern for decoupling system components.
|
|
302
|
+
*
|
|
303
|
+
* @param initialMachine The machine instance to wrap.
|
|
304
|
+
* @returns A `MachineEventEmitter` instance.
|
|
305
|
+
*/
|
|
306
|
+
export function asEventEmitter<M extends Machine<any>>(initialMachine: M): MachineEventEmitter<M> {
|
|
307
|
+
return new MachineEventEmitter(initialMachine);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
// =============================================================================
|
|
312
|
+
// SECTION 3: Observable Adapter (for Stream-Based Architectures)
|
|
313
|
+
// =============================================================================
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A type-safe Observable that wraps a state machine, emitting the new state
|
|
317
|
+
* on every transition.
|
|
318
|
+
*
|
|
319
|
+
* This class conforms to the standard Observable interface, making it compatible
|
|
320
|
+
* with libraries like RxJS and frameworks that use Observables (e.g., Angular).
|
|
321
|
+
*
|
|
322
|
+
* @template M The machine type (can be a union of states).
|
|
323
|
+
*/
|
|
324
|
+
export class MachineObservable<M extends Machine<any>> implements Observable<M> {
|
|
325
|
+
private readonly runner: Runner<M>;
|
|
326
|
+
private observers: Set<Observer<M>> = new Set();
|
|
327
|
+
|
|
328
|
+
public get state(): M {
|
|
329
|
+
return this.runner.state;
|
|
330
|
+
}
|
|
331
|
+
public get context(): Context<M> {
|
|
332
|
+
return this.runner.state.context;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
constructor(initialMachine: M) {
|
|
336
|
+
this.runner = createRunner(initialMachine, (newState) => {
|
|
337
|
+
// When the runner's state changes, push the new state to all subscribers.
|
|
338
|
+
this.observers.forEach(observer => observer.next?.(newState));
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// We can also forward errors from the runner if we enhance it to do so.
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Subscribes to the stream of machine states.
|
|
346
|
+
*
|
|
347
|
+
* @param observer An object with `next`, `error`, and `complete` methods.
|
|
348
|
+
* @returns A subscription object with an `unsubscribe` method.
|
|
349
|
+
*/
|
|
350
|
+
public subscribe(observer: Observer<M>): { unsubscribe: () => void } {
|
|
351
|
+
// Immediately provide the current state to the new subscriber.
|
|
352
|
+
observer.next?.(this.runner.state);
|
|
353
|
+
|
|
354
|
+
this.observers.add(observer);
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
unsubscribe: () => {
|
|
358
|
+
this.observers.delete(observer);
|
|
359
|
+
// Optional: If this is the last observer, we could tear down the machine.
|
|
360
|
+
// For now, we keep it simple and the machine lives forever.
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* A type-safe method for dispatching transitions to the machine.
|
|
367
|
+
*
|
|
368
|
+
* @param eventName The name of the transition to trigger.
|
|
369
|
+
* @param args The arguments for that transition.
|
|
370
|
+
*/
|
|
371
|
+
public dispatch<K extends TransitionNames<M>>(
|
|
372
|
+
eventName: K,
|
|
373
|
+
...args: M[K] extends (...args: infer A) => any ? A : never
|
|
374
|
+
): void {
|
|
375
|
+
const action = (this.runner.actions as any)[eventName];
|
|
376
|
+
if (typeof action === 'function') {
|
|
377
|
+
action(...args);
|
|
378
|
+
} else {
|
|
379
|
+
// Emit an error to all observers.
|
|
380
|
+
const error = new Error(`Invalid event "${String(eventName)}" for current state.`);
|
|
381
|
+
this.observers.forEach(o => o.error?.(error));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Signals to all observers that the stream is complete.
|
|
387
|
+
* This is useful when the machine reaches a final state.
|
|
388
|
+
*/
|
|
389
|
+
public complete(): void {
|
|
390
|
+
this.observers.forEach(o => o.complete?.());
|
|
391
|
+
this.observers.clear();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Creates an Observable from a machine.
|
|
397
|
+
*
|
|
398
|
+
* This adapter is perfect for integrating your machine into architectures that
|
|
399
|
+
* rely on Observables and reactive streams (e.g., RxJS, Angular). It emits the
|
|
400
|
+
* new machine state on every transition.
|
|
401
|
+
*
|
|
402
|
+
* @param initialMachine The machine instance to wrap.
|
|
403
|
+
* @returns A `MachineObservable` instance.
|
|
404
|
+
*/
|
|
405
|
+
export function asObservable<M extends Machine<any>>(initialMachine: M): MachineObservable<M> {
|
|
406
|
+
return new MachineObservable(initialMachine);
|
|
407
|
+
}
|
package/src/extract.ts
CHANGED
|
@@ -106,7 +106,7 @@ export interface ExtractionConfig {
|
|
|
106
106
|
* @returns A JSON-compatible value (string, number, object, array).
|
|
107
107
|
* @internal
|
|
108
108
|
*/
|
|
109
|
-
//
|
|
109
|
+
// @ts-expect-error - verbose parameter is used but TypeScript doesn't detect it
|
|
110
110
|
function _typeToJson(type: Type, verbose = false): any {
|
|
111
111
|
// --- Terminal Types ---
|
|
112
112
|
const symbol = type.getSymbol();
|
package/src/index.ts
CHANGED
|
@@ -25,13 +25,33 @@ export type Machine<C extends object> = {
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The shape of an asynchronous machine, where transitions can return Promises.
|
|
28
|
-
*
|
|
28
|
+
* Async transitions receive an AbortSignal as the last parameter for cancellation support.
|
|
29
|
+
* @template C - The context object type.
|
|
29
30
|
*/
|
|
30
31
|
export type AsyncMachine<C extends object> = {
|
|
31
32
|
/** The readonly state of the machine. */
|
|
32
33
|
readonly context: C;
|
|
33
34
|
} & Record<string, (...args: any[]) => MaybePromise<AsyncMachine<any>>>;
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Utility type to extract the parameters of an async transition function,
|
|
38
|
+
* which includes TransitionOptions as the last parameter.
|
|
39
|
+
*/
|
|
40
|
+
export type AsyncTransitionArgs<M extends AsyncMachine<any>, K extends keyof M & string> =
|
|
41
|
+
M[K] extends (...args: infer A) => any
|
|
42
|
+
? A extends [...infer Rest, TransitionOptions]
|
|
43
|
+
? Rest
|
|
44
|
+
: A
|
|
45
|
+
: never;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Options passed to async transition functions, including cancellation support.
|
|
49
|
+
*/
|
|
50
|
+
export interface TransitionOptions {
|
|
51
|
+
/** AbortSignal for cancelling long-running async operations. */
|
|
52
|
+
signal: AbortSignal;
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
|
|
36
56
|
// =============================================================================
|
|
37
57
|
// SECTION: TYPE UTILITIES & INTROSPECTION
|
|
@@ -130,7 +150,12 @@ export function createMachine<C extends object, T extends Record<string, (this:
|
|
|
130
150
|
context: C,
|
|
131
151
|
fns: T
|
|
132
152
|
): { context: C } & T {
|
|
133
|
-
|
|
153
|
+
// If fns is a machine (has context property), extract just the transition functions
|
|
154
|
+
const transitions = 'context' in fns ? Object.fromEntries(
|
|
155
|
+
Object.entries(fns).filter(([key]) => key !== 'context')
|
|
156
|
+
) : fns;
|
|
157
|
+
const machine = Object.assign({ context }, transitions);
|
|
158
|
+
return machine as { context: C } & T;
|
|
134
159
|
}
|
|
135
160
|
|
|
136
161
|
/**
|
|
@@ -263,6 +288,79 @@ export function extendTransitions<
|
|
|
263
288
|
return createMachine(context, combinedTransitions) as M & T;
|
|
264
289
|
}
|
|
265
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Combines two machine factories into a single factory that creates machines with merged context and transitions.
|
|
293
|
+
* This allows you to compose independent state machines that operate on different parts of the same context.
|
|
294
|
+
*
|
|
295
|
+
* The resulting factory takes the parameters of the first factory, while the second factory is called with no arguments.
|
|
296
|
+
* Context properties are merged (second factory's context takes precedence on conflicts).
|
|
297
|
+
* Transition names must not conflict between the two machines.
|
|
298
|
+
*
|
|
299
|
+
* @template F1 - The first factory function type.
|
|
300
|
+
* @template F2 - The second factory function type.
|
|
301
|
+
* @param factory1 - The first machine factory (provides parameters and primary context).
|
|
302
|
+
* @param factory2 - The second machine factory (provides additional context and transitions).
|
|
303
|
+
* @returns A new factory function that creates combined machines.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* ```typescript
|
|
307
|
+
* // Define two independent machines
|
|
308
|
+
* const createCounter = (initial: number) =>
|
|
309
|
+
* createMachine({ count: initial }, {
|
|
310
|
+
* increment: function() { return createMachine({ count: this.count + 1 }, this); },
|
|
311
|
+
* decrement: function() { return createMachine({ count: this.count - 1 }, this); }
|
|
312
|
+
* });
|
|
313
|
+
*
|
|
314
|
+
* const createLogger = () =>
|
|
315
|
+
* createMachine({ logs: [] as string[] }, {
|
|
316
|
+
* log: function(message: string) {
|
|
317
|
+
* return createMachine({ logs: [...this.logs, message] }, this);
|
|
318
|
+
* },
|
|
319
|
+
* clear: function() {
|
|
320
|
+
* return createMachine({ logs: [] }, this);
|
|
321
|
+
* }
|
|
322
|
+
* });
|
|
323
|
+
*
|
|
324
|
+
* // Combine them
|
|
325
|
+
* const createCounterWithLogging = combineFactories(createCounter, createLogger);
|
|
326
|
+
*
|
|
327
|
+
* // Use the combined factory
|
|
328
|
+
* const machine = createCounterWithLogging(5); // { count: 5, logs: [] }
|
|
329
|
+
* const incremented = machine.increment(); // { count: 6, logs: [] }
|
|
330
|
+
* const logged = incremented.log("Count incremented"); // { count: 6, logs: ["Count incremented"] }
|
|
331
|
+
* ```
|
|
332
|
+
*/
|
|
333
|
+
export function combineFactories<
|
|
334
|
+
F1 extends (...args: any[]) => Machine<any>,
|
|
335
|
+
F2 extends () => Machine<any>
|
|
336
|
+
>(
|
|
337
|
+
factory1: F1,
|
|
338
|
+
factory2: F2
|
|
339
|
+
): (
|
|
340
|
+
...args: Parameters<F1>
|
|
341
|
+
) => Machine<Context<ReturnType<F1>> & Context<ReturnType<F2>>> &
|
|
342
|
+
Omit<ReturnType<F1>, 'context'> &
|
|
343
|
+
Omit<ReturnType<F2>, 'context'> {
|
|
344
|
+
return (...args: Parameters<F1>) => {
|
|
345
|
+
// Create instances from both factories
|
|
346
|
+
const machine1 = factory1(...args);
|
|
347
|
+
const machine2 = factory2();
|
|
348
|
+
|
|
349
|
+
// Merge contexts (machine2 takes precedence on conflicts)
|
|
350
|
+
const combinedContext = { ...machine1.context, ...machine2.context };
|
|
351
|
+
|
|
352
|
+
// Extract transitions from both machines
|
|
353
|
+
const { context: _, ...transitions1 } = machine1;
|
|
354
|
+
const { context: __, ...transitions2 } = machine2;
|
|
355
|
+
|
|
356
|
+
// Combine transitions (TypeScript will catch conflicts at compile time)
|
|
357
|
+
const combinedTransitions = { ...transitions1, ...transitions2 };
|
|
358
|
+
|
|
359
|
+
// Create the combined machine
|
|
360
|
+
return createMachine(combinedContext, combinedTransitions) as any;
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
266
364
|
/**
|
|
267
365
|
* Creates a builder function from a "template" machine instance.
|
|
268
366
|
* This captures the behavior of a machine and returns a factory that can stamp out
|
|
@@ -361,27 +459,61 @@ export function hasState<
|
|
|
361
459
|
/**
|
|
362
460
|
* Runs an asynchronous state machine with a managed lifecycle and event dispatch capability.
|
|
363
461
|
* This is the "interpreter" for async machines, handling state updates and side effects.
|
|
462
|
+
* Provides automatic AbortController management to prevent async race conditions.
|
|
364
463
|
*
|
|
365
464
|
* @template M - The initial machine type.
|
|
366
465
|
* @param initial - The initial machine state.
|
|
367
466
|
* @param onChange - Optional callback invoked with the new machine state after every transition.
|
|
368
|
-
* @returns An object with a `state` getter for the current context
|
|
467
|
+
* @returns An object with a `state` getter for the current context, an async `dispatch` function, and a `stop` method.
|
|
369
468
|
*/
|
|
370
469
|
export function runMachine<M extends AsyncMachine<any>>(
|
|
371
470
|
initial: M,
|
|
372
471
|
onChange?: (m: M) => void
|
|
373
472
|
) {
|
|
374
473
|
let current = initial;
|
|
474
|
+
// Keep track of the controller for the currently-running async transition.
|
|
475
|
+
let activeController: AbortController | null = null;
|
|
375
476
|
|
|
376
477
|
async function dispatch<E extends Event<typeof current>>(event: E): Promise<M> {
|
|
478
|
+
// 1. If an async transition is already in progress, cancel it.
|
|
479
|
+
if (activeController) {
|
|
480
|
+
activeController.abort();
|
|
481
|
+
activeController = null;
|
|
482
|
+
}
|
|
483
|
+
|
|
377
484
|
const fn = (current as any)[event.type];
|
|
378
485
|
if (typeof fn !== 'function') {
|
|
379
486
|
throw new Error(`[Machine] Unknown event type '${String(event.type)}' on current state.`);
|
|
380
487
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
488
|
+
|
|
489
|
+
// 2. Create a new AbortController for this new transition.
|
|
490
|
+
const controller = new AbortController();
|
|
491
|
+
activeController = controller;
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
// 3. Pass the signal to the transition function.
|
|
495
|
+
const nextStatePromise = fn.apply(current.context, [...event.args, { signal: controller.signal }]);
|
|
496
|
+
|
|
497
|
+
const nextState = await nextStatePromise;
|
|
498
|
+
|
|
499
|
+
// 4. If this promise resolved but has since been aborted, do not update state.
|
|
500
|
+
// This prevents the race condition.
|
|
501
|
+
if (controller.signal.aborted) {
|
|
502
|
+
// Return the *current* state, as if the transition never completed.
|
|
503
|
+
return current;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
current = nextState;
|
|
507
|
+
onChange?.(current);
|
|
508
|
+
return current;
|
|
509
|
+
|
|
510
|
+
} finally {
|
|
511
|
+
// 5. Clean up the controller once the transition is complete (resolved or rejected).
|
|
512
|
+
// Only clear it if it's still the active one.
|
|
513
|
+
if (activeController === controller) {
|
|
514
|
+
activeController = null;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
385
517
|
}
|
|
386
518
|
|
|
387
519
|
return {
|
|
@@ -391,6 +523,13 @@ export function runMachine<M extends AsyncMachine<any>>(
|
|
|
391
523
|
},
|
|
392
524
|
/** Dispatches a type-safe event to the machine, triggering a transition. */
|
|
393
525
|
dispatch,
|
|
526
|
+
/** Stops any pending async operation and cleans up resources. */
|
|
527
|
+
stop: () => {
|
|
528
|
+
if (activeController) {
|
|
529
|
+
activeController.abort();
|
|
530
|
+
activeController = null;
|
|
531
|
+
}
|
|
532
|
+
},
|
|
394
533
|
};
|
|
395
534
|
}
|
|
396
535
|
|
|
@@ -535,6 +674,8 @@ export {
|
|
|
535
674
|
transitionTo,
|
|
536
675
|
describe,
|
|
537
676
|
guarded,
|
|
677
|
+
guard,
|
|
678
|
+
whenGuard,
|
|
538
679
|
invoke,
|
|
539
680
|
action,
|
|
540
681
|
metadata,
|
|
@@ -544,7 +685,10 @@ export {
|
|
|
544
685
|
type InvokeMeta,
|
|
545
686
|
type ActionMeta,
|
|
546
687
|
type ClassConstructor,
|
|
547
|
-
type WithMeta
|
|
688
|
+
type WithMeta,
|
|
689
|
+
type GuardOptions,
|
|
690
|
+
type GuardFallback,
|
|
691
|
+
type GuardedTransition
|
|
548
692
|
} from './primitives';
|
|
549
693
|
|
|
550
694
|
// =============================================================================
|
|
@@ -578,6 +722,51 @@ export * from './multi'
|
|
|
578
722
|
|
|
579
723
|
export * from './higher-order'
|
|
580
724
|
|
|
725
|
+
// =============================================================================
|
|
726
|
+
// SECTION: MIDDLEWARE & INTERCEPTION
|
|
727
|
+
// =============================================================================
|
|
728
|
+
|
|
729
|
+
export {
|
|
730
|
+
createMiddleware,
|
|
731
|
+
withLogging,
|
|
732
|
+
withAnalytics,
|
|
733
|
+
withValidation,
|
|
734
|
+
withPermissions,
|
|
735
|
+
withErrorReporting,
|
|
736
|
+
withPerformanceMonitoring,
|
|
737
|
+
withRetry,
|
|
738
|
+
withHistory,
|
|
739
|
+
withSnapshot,
|
|
740
|
+
withTimeTravel,
|
|
741
|
+
compose,
|
|
742
|
+
composeTyped,
|
|
743
|
+
createPipeline,
|
|
744
|
+
createMiddlewareRegistry,
|
|
745
|
+
when,
|
|
746
|
+
inDevelopment,
|
|
747
|
+
whenContext,
|
|
748
|
+
combine,
|
|
749
|
+
branch,
|
|
750
|
+
isMiddlewareFn,
|
|
751
|
+
isConditionalMiddleware,
|
|
752
|
+
createCustomMiddleware,
|
|
753
|
+
type MiddlewareHooks,
|
|
754
|
+
type MiddlewareOptions,
|
|
755
|
+
type MiddlewareContext,
|
|
756
|
+
type MiddlewareResult,
|
|
757
|
+
type MiddlewareError,
|
|
758
|
+
type HistoryEntry,
|
|
759
|
+
type ContextSnapshot,
|
|
760
|
+
type Serializer,
|
|
761
|
+
type MiddlewareFn,
|
|
762
|
+
type ConditionalMiddleware,
|
|
763
|
+
type NamedMiddleware,
|
|
764
|
+
type PipelineConfig,
|
|
765
|
+
type PipelineResult,
|
|
766
|
+
chain,
|
|
767
|
+
withDebugging
|
|
768
|
+
} from './middleware';
|
|
769
|
+
|
|
581
770
|
// =============================================================================
|
|
582
771
|
// SECTION: UTILITIES & HELPERS
|
|
583
772
|
// =============================================================================
|