@doeixd/machine 0.0.6 → 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 +288 -272
- package/dist/cjs/development/index.js +1269 -16
- package/dist/cjs/development/index.js.map +4 -4
- package/dist/cjs/production/index.js +5 -5
- package/dist/esm/development/index.js +1269 -16
- package/dist/esm/development/index.js.map +4 -4
- package/dist/esm/production/index.js +5 -5
- package/dist/types/extract.d.ts +40 -4
- package/dist/types/extract.d.ts.map +1 -1
- package/dist/types/generators.d.ts +40 -9
- package/dist/types/generators.d.ts.map +1 -1
- package/dist/types/higher-order.d.ts +221 -0
- package/dist/types/higher-order.d.ts.map +1 -0
- package/dist/types/index.d.ts +65 -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 +313 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/adapters.ts +407 -0
- package/src/extract.ts +180 -8
- package/src/generators.ts +25 -25
- package/src/higher-order.ts +364 -0
- package/src/index.ts +215 -9
- package/src/middleware.ts +2325 -0
- package/src/primitives.ts +194 -3
- package/src/runtime-extract.ts +15 -0
- package/src/utils.ts +386 -5
|
@@ -0,0 +1,2325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Middleware/interception system for state machines
|
|
3
|
+
* @description Provides composable middleware for logging, analytics, validation, and more.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Context, BaseMachine } from './index';
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// SECTION: MIDDLEWARE TYPES
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Context object passed to middleware hooks containing transition metadata.
|
|
14
|
+
* @template C - The context object type
|
|
15
|
+
*/
|
|
16
|
+
export interface MiddlewareContext<C extends object> {
|
|
17
|
+
/** The name of the transition being called */
|
|
18
|
+
transitionName: string;
|
|
19
|
+
/** The current machine context before the transition */
|
|
20
|
+
context: Readonly<C>;
|
|
21
|
+
/** Arguments passed to the transition function */
|
|
22
|
+
args: any[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Result object passed to after hooks containing transition outcome.
|
|
27
|
+
* @template C - The context object type
|
|
28
|
+
*/
|
|
29
|
+
export interface MiddlewareResult<C extends object> {
|
|
30
|
+
/** The name of the transition that was called */
|
|
31
|
+
transitionName: string;
|
|
32
|
+
/** The context before the transition */
|
|
33
|
+
prevContext: Readonly<C>;
|
|
34
|
+
/** The context after the transition */
|
|
35
|
+
nextContext: Readonly<C>;
|
|
36
|
+
/** Arguments that were passed to the transition */
|
|
37
|
+
args: any[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Error context passed to error hooks.
|
|
42
|
+
* @template C - The context object type
|
|
43
|
+
*/
|
|
44
|
+
export interface MiddlewareError<C extends object> {
|
|
45
|
+
/** The name of the transition that failed */
|
|
46
|
+
transitionName: string;
|
|
47
|
+
/** The context when the error occurred */
|
|
48
|
+
context: Readonly<C>;
|
|
49
|
+
/** Arguments that were passed to the transition */
|
|
50
|
+
args: any[];
|
|
51
|
+
/** The error that was thrown */
|
|
52
|
+
error: Error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Configuration object for middleware hooks.
|
|
57
|
+
* All hooks are optional - provide only the ones you need.
|
|
58
|
+
* @template C - The context object type
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* Strongly typed middleware hooks with precise context and return types.
|
|
62
|
+
* All hooks are optional - provide only the ones you need.
|
|
63
|
+
*
|
|
64
|
+
* @template C - The machine context type for precise type inference
|
|
65
|
+
*/
|
|
66
|
+
export interface MiddlewareHooks<C extends object> {
|
|
67
|
+
/**
|
|
68
|
+
* Called before a transition executes.
|
|
69
|
+
* Can be used for validation, logging, analytics, etc.
|
|
70
|
+
*
|
|
71
|
+
* @param ctx - Transition context with machine state and transition details
|
|
72
|
+
* @returns void to continue, CANCEL to abort silently, or Promise for async validation
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* before: ({ transitionName, args, context }) => {
|
|
77
|
+
* if (transitionName === 'withdraw' && context.balance < args[0]) {
|
|
78
|
+
* throw new Error('Insufficient funds');
|
|
79
|
+
* }
|
|
80
|
+
* }
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
before?: (ctx: MiddlewareContext<C>) => void | typeof CANCEL | Promise<void | typeof CANCEL>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Called after a transition successfully executes.
|
|
87
|
+
* Receives both the previous and next context.
|
|
88
|
+
* Cannot prevent the transition (it already happened).
|
|
89
|
+
*
|
|
90
|
+
* @param result - Transition result with before/after contexts and transition details
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```typescript
|
|
94
|
+
* after: ({ transitionName, before, after }) => {
|
|
95
|
+
* console.log(`${transitionName}: ${before.count} -> ${after.count}`);
|
|
96
|
+
* }
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
after?: (result: MiddlewareResult<C>) => void | Promise<void>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Called if a transition throws an error.
|
|
103
|
+
* Can be used for error logging, Sentry reporting, fallback states, etc.
|
|
104
|
+
*
|
|
105
|
+
* @param error - Error context with transition details and the thrown error
|
|
106
|
+
* @returns
|
|
107
|
+
* - void/undefined/null: Re-throw the original error (default)
|
|
108
|
+
* - BaseMachine: Use this as fallback state instead of throwing
|
|
109
|
+
* - throw new Error: Transform the error
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```typescript
|
|
113
|
+
* error: ({ transitionName, error, context }) => {
|
|
114
|
+
* // Log to error reporting service
|
|
115
|
+
* reportError(error, { transitionName, context });
|
|
116
|
+
*
|
|
117
|
+
* // Return fallback state for recoverable errors
|
|
118
|
+
* if (error.message.includes('network')) {
|
|
119
|
+
* return createMachine({ ...context, error: 'offline' }, transitions);
|
|
120
|
+
* }
|
|
121
|
+
* }
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
error?: (error: MiddlewareError<C>) => void | null | BaseMachine<C> | Promise<void | null | BaseMachine<C>>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Options for middleware configuration.
|
|
129
|
+
*/
|
|
130
|
+
export interface MiddlewareOptions {
|
|
131
|
+
/**
|
|
132
|
+
* Execution mode for middleware hooks.
|
|
133
|
+
* - 'sync': Hooks must be synchronous, throws if hooks return Promise
|
|
134
|
+
* - 'async': Always await hooks and transition
|
|
135
|
+
* - 'auto' (default): Adaptive mode - starts synchronously, automatically handles async results if encountered
|
|
136
|
+
*
|
|
137
|
+
* Note: 'auto' mode provides the best of both worlds - zero overhead for sync transitions
|
|
138
|
+
* while seamlessly handling async ones when they occur.
|
|
139
|
+
* @default 'auto'
|
|
140
|
+
*/
|
|
141
|
+
mode?: 'sync' | 'async' | 'auto';
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Properties to exclude from middleware interception.
|
|
145
|
+
* Useful for excluding utility methods or getters.
|
|
146
|
+
* @default ['context']
|
|
147
|
+
*/
|
|
148
|
+
exclude?: string[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// SECTION: CANCELLATION SUPPORT
|
|
153
|
+
// =============================================================================
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Special symbol that can be returned from before hooks to cancel a transition.
|
|
157
|
+
* When returned, the transition will not execute and the current machine state is preserved.
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* createMiddleware(machine, {
|
|
161
|
+
* before: ({ transitionName, context }) => {
|
|
162
|
+
* if (shouldCancel(context)) {
|
|
163
|
+
* return CANCEL; // Abort transition without throwing
|
|
164
|
+
* }
|
|
165
|
+
* }
|
|
166
|
+
* });
|
|
167
|
+
*/
|
|
168
|
+
export const CANCEL = Symbol('CANCEL');
|
|
169
|
+
|
|
170
|
+
// =============================================================================
|
|
171
|
+
// SECTION: UTILITY TYPES FOR AUGMENTED MACHINES
|
|
172
|
+
// =============================================================================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Augmented machine type with history tracking capabilities.
|
|
176
|
+
* @template M - The base machine type
|
|
177
|
+
* @template C - The context type
|
|
178
|
+
*/
|
|
179
|
+
export type WithHistory<M extends BaseMachine<any>> = M & {
|
|
180
|
+
/** Array of recorded transition history entries */
|
|
181
|
+
history: HistoryEntry[];
|
|
182
|
+
/** Clear all history entries */
|
|
183
|
+
clearHistory: () => void;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Augmented machine type with snapshot tracking capabilities.
|
|
188
|
+
* @template M - The base machine type
|
|
189
|
+
* @template C - The context type
|
|
190
|
+
*/
|
|
191
|
+
export type WithSnapshot<M extends BaseMachine<any>, C extends object = Context<M>> = M & {
|
|
192
|
+
/** Array of recorded context snapshots */
|
|
193
|
+
snapshots: ContextSnapshot<C>[];
|
|
194
|
+
/** Clear all snapshots */
|
|
195
|
+
clearSnapshots: () => void;
|
|
196
|
+
/** Restore machine to a previous context state */
|
|
197
|
+
restoreSnapshot: (context: C) => M;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Augmented machine type with full time-travel debugging capabilities.
|
|
202
|
+
* Combines both history and snapshot tracking.
|
|
203
|
+
* @template M - The base machine type
|
|
204
|
+
* @template C - The context type
|
|
205
|
+
*/
|
|
206
|
+
export type WithTimeTravel<M extends BaseMachine<any>, C extends object = Context<M>> = M & {
|
|
207
|
+
/** Array of recorded transition history entries */
|
|
208
|
+
history: HistoryEntry[];
|
|
209
|
+
/** Array of recorded context snapshots */
|
|
210
|
+
snapshots: ContextSnapshot<C>[];
|
|
211
|
+
/** Clear all history and snapshots */
|
|
212
|
+
clearTimeTravel: () => void;
|
|
213
|
+
/** Restore machine to a previous context state */
|
|
214
|
+
restoreSnapshot: (context: C) => M;
|
|
215
|
+
/** Replay all transitions from a specific snapshot */
|
|
216
|
+
replayFrom: (snapshotIndex: number) => M;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// SECTION: CORE MIDDLEWARE FUNCTION
|
|
221
|
+
// =============================================================================
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Wraps a machine with middleware hooks that intercept all transitions.
|
|
225
|
+
* Uses direct property wrapping for optimal performance (3x faster than Proxy).
|
|
226
|
+
*
|
|
227
|
+
* The middleware preserves:
|
|
228
|
+
* - Full type safety (return type matches input machine)
|
|
229
|
+
* - `this` binding for transitions
|
|
230
|
+
* - Async and sync transitions
|
|
231
|
+
* - Machine immutability
|
|
232
|
+
*
|
|
233
|
+
* @template M - The machine type
|
|
234
|
+
* @param machine - The machine to wrap with middleware
|
|
235
|
+
* @param hooks - Middleware hooks (before, after, error)
|
|
236
|
+
* @param options - Configuration options
|
|
237
|
+
* @returns A new machine with middleware applied
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* const instrumented = createMiddleware(counter, {
|
|
241
|
+
* before: ({ transitionName, context, args }) => {
|
|
242
|
+
* console.log(`→ ${transitionName}`, args);
|
|
243
|
+
* },
|
|
244
|
+
* after: ({ transitionName, prevContext, nextContext }) => {
|
|
245
|
+
* console.log(`✓ ${transitionName}`, nextContext);
|
|
246
|
+
* },
|
|
247
|
+
* error: ({ transitionName, error }) => {
|
|
248
|
+
* console.error(`✗ ${transitionName}:`, error);
|
|
249
|
+
* }
|
|
250
|
+
* });
|
|
251
|
+
*/
|
|
252
|
+
export function createMiddleware<M extends BaseMachine<any>>(
|
|
253
|
+
machine: M,
|
|
254
|
+
hooks: MiddlewareHooks<Context<M>>,
|
|
255
|
+
options: MiddlewareOptions = {}
|
|
256
|
+
): M {
|
|
257
|
+
const { mode = 'auto', exclude = ['context'] } = options;
|
|
258
|
+
|
|
259
|
+
// Build wrapped machine object with direct property iteration
|
|
260
|
+
const wrapped: any = {};
|
|
261
|
+
|
|
262
|
+
// Copy all properties and wrap functions
|
|
263
|
+
for (const prop in machine) {
|
|
264
|
+
if (!Object.prototype.hasOwnProperty.call(machine, prop)) continue;
|
|
265
|
+
|
|
266
|
+
const value = machine[prop];
|
|
267
|
+
|
|
268
|
+
// Always copy context
|
|
269
|
+
if (prop === 'context') {
|
|
270
|
+
wrapped.context = value;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Skip excluded properties
|
|
275
|
+
if (exclude.includes(prop)) {
|
|
276
|
+
wrapped[prop] = value;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Skip non-functions and private methods
|
|
281
|
+
if (typeof value !== 'function' || prop.startsWith('_')) {
|
|
282
|
+
wrapped[prop] = value;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Wrap transition function
|
|
287
|
+
wrapped[prop] = createTransitionWrapper(
|
|
288
|
+
prop,
|
|
289
|
+
value,
|
|
290
|
+
machine,
|
|
291
|
+
hooks,
|
|
292
|
+
mode
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return wrapped as M;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Creates a wrapped transition function with middleware hooks.
|
|
301
|
+
* Extracted as a separate function for clarity and reusability.
|
|
302
|
+
*
|
|
303
|
+
* @internal
|
|
304
|
+
*/
|
|
305
|
+
function createTransitionWrapper<M extends BaseMachine<any>>(
|
|
306
|
+
transitionName: string,
|
|
307
|
+
originalFn: Function,
|
|
308
|
+
machine: M,
|
|
309
|
+
hooks: MiddlewareHooks<Context<M>>,
|
|
310
|
+
mode: 'sync' | 'async' | 'auto'
|
|
311
|
+
): Function {
|
|
312
|
+
return function wrappedTransition(this: any, ...args: any[]) {
|
|
313
|
+
// Get current context (might be different from initial if machine changed)
|
|
314
|
+
const context = machine.context;
|
|
315
|
+
|
|
316
|
+
const middlewareCtx: MiddlewareContext<Context<M>> = {
|
|
317
|
+
transitionName,
|
|
318
|
+
context,
|
|
319
|
+
args
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Helper for sync execution
|
|
323
|
+
const executeSyncTransition = () => {
|
|
324
|
+
try {
|
|
325
|
+
// Call before hook (must be sync or throw)
|
|
326
|
+
if (hooks.before) {
|
|
327
|
+
const beforeResult = hooks.before(middlewareCtx);
|
|
328
|
+
// Check for cancellation
|
|
329
|
+
if (beforeResult === CANCEL) {
|
|
330
|
+
return machine; // Return current machine unchanged
|
|
331
|
+
}
|
|
332
|
+
// If before hook returns a promise in sync mode, throw
|
|
333
|
+
if (beforeResult instanceof Promise) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`Middleware mode is 'sync' but before hook returned Promise for transition: ${transitionName}`
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Execute the actual transition
|
|
341
|
+
const result = originalFn.call(this, ...args);
|
|
342
|
+
|
|
343
|
+
// If result is async, switch to async handling
|
|
344
|
+
if (result instanceof Promise) {
|
|
345
|
+
return handleAsyncResult(result, context);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Call after hook (must be sync or throw)
|
|
349
|
+
if (hooks.after) {
|
|
350
|
+
const middlewareResult: MiddlewareResult<Context<M>> = {
|
|
351
|
+
transitionName,
|
|
352
|
+
prevContext: context,
|
|
353
|
+
nextContext: result.context,
|
|
354
|
+
args
|
|
355
|
+
};
|
|
356
|
+
const afterResult = hooks.after(middlewareResult);
|
|
357
|
+
if (afterResult instanceof Promise) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`Middleware mode is 'sync' but after hook returned Promise for transition: ${transitionName}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return result;
|
|
365
|
+
} catch (err) {
|
|
366
|
+
// Call error hook and check for fallback state
|
|
367
|
+
if (hooks.error) {
|
|
368
|
+
const middlewareError: MiddlewareError<Context<M>> = {
|
|
369
|
+
transitionName,
|
|
370
|
+
context,
|
|
371
|
+
args,
|
|
372
|
+
error: err as Error
|
|
373
|
+
};
|
|
374
|
+
const errorResult = hooks.error(middlewareError);
|
|
375
|
+
|
|
376
|
+
// Handle async error hook in sync mode
|
|
377
|
+
if (errorResult instanceof Promise) {
|
|
378
|
+
// Fire-and-forget for async error hooks in sync mode
|
|
379
|
+
errorResult.catch(() => {});
|
|
380
|
+
throw err; // Re-throw original error
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Check if error hook returned a fallback machine
|
|
384
|
+
if (errorResult && typeof errorResult === 'object' && 'context' in errorResult) {
|
|
385
|
+
return errorResult as M; // Return fallback state
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Re-throw the error
|
|
390
|
+
throw err;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// Helper for handling async transition results
|
|
395
|
+
const handleAsyncResult = async (resultPromise: Promise<any>, ctx: any) => {
|
|
396
|
+
try {
|
|
397
|
+
const result = await resultPromise;
|
|
398
|
+
|
|
399
|
+
// Call after hook
|
|
400
|
+
if (hooks.after) {
|
|
401
|
+
const middlewareResult: MiddlewareResult<Context<M>> = {
|
|
402
|
+
transitionName,
|
|
403
|
+
prevContext: ctx,
|
|
404
|
+
nextContext: result.context,
|
|
405
|
+
args
|
|
406
|
+
};
|
|
407
|
+
await hooks.after(middlewareResult);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return result;
|
|
411
|
+
} catch (err) {
|
|
412
|
+
// Call error hook and check for fallback state
|
|
413
|
+
if (hooks.error) {
|
|
414
|
+
const middlewareError: MiddlewareError<Context<M>> = {
|
|
415
|
+
transitionName,
|
|
416
|
+
context: ctx,
|
|
417
|
+
args,
|
|
418
|
+
error: err as Error
|
|
419
|
+
};
|
|
420
|
+
const errorResult = await hooks.error(middlewareError);
|
|
421
|
+
|
|
422
|
+
// Check if error hook returned a fallback machine
|
|
423
|
+
if (errorResult && typeof errorResult === 'object' && 'context' in errorResult) {
|
|
424
|
+
return errorResult as M; // Return fallback state
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Re-throw the error
|
|
429
|
+
throw err;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Helper for fully async execution
|
|
434
|
+
const executeAsyncTransition = async () => {
|
|
435
|
+
try {
|
|
436
|
+
// Call before hook
|
|
437
|
+
if (hooks.before) {
|
|
438
|
+
const beforeResult = await hooks.before(middlewareCtx);
|
|
439
|
+
// Check for cancellation
|
|
440
|
+
if (beforeResult === CANCEL) {
|
|
441
|
+
return machine; // Return current machine unchanged
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Execute the actual transition
|
|
446
|
+
const result = await originalFn.call(this, ...args);
|
|
447
|
+
|
|
448
|
+
// Call after hook
|
|
449
|
+
if (hooks.after) {
|
|
450
|
+
const middlewareResult: MiddlewareResult<Context<M>> = {
|
|
451
|
+
transitionName,
|
|
452
|
+
prevContext: context,
|
|
453
|
+
nextContext: result.context,
|
|
454
|
+
args
|
|
455
|
+
};
|
|
456
|
+
await hooks.after(middlewareResult);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return result;
|
|
460
|
+
} catch (err) {
|
|
461
|
+
// Call error hook and check for fallback state
|
|
462
|
+
if (hooks.error) {
|
|
463
|
+
const middlewareError: MiddlewareError<Context<M>> = {
|
|
464
|
+
transitionName,
|
|
465
|
+
context,
|
|
466
|
+
args,
|
|
467
|
+
error: err as Error
|
|
468
|
+
};
|
|
469
|
+
const errorResult = await hooks.error(middlewareError);
|
|
470
|
+
|
|
471
|
+
// Check if error hook returned a fallback machine
|
|
472
|
+
if (errorResult && typeof errorResult === 'object' && 'context' in errorResult) {
|
|
473
|
+
return errorResult as M; // Return fallback state
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Re-throw the error
|
|
478
|
+
throw err;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Choose execution mode
|
|
483
|
+
if (mode === 'async') {
|
|
484
|
+
// Force async execution
|
|
485
|
+
return executeAsyncTransition();
|
|
486
|
+
} else if (mode === 'sync') {
|
|
487
|
+
// Force sync execution
|
|
488
|
+
return executeSyncTransition();
|
|
489
|
+
} else {
|
|
490
|
+
// Auto mode (adaptive): Starts synchronously for zero overhead,
|
|
491
|
+
// but automatically switches to async if the transition returns a Promise.
|
|
492
|
+
// This provides optimal performance for sync transitions while
|
|
493
|
+
// seamlessly handling async ones when they occur.
|
|
494
|
+
return executeSyncTransition();
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// =============================================================================
|
|
500
|
+
// SECTION: COMPOSABLE MIDDLEWARE HELPERS
|
|
501
|
+
// =============================================================================
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Logging middleware that logs transition calls and results to console.
|
|
505
|
+
* Useful for debugging and development.
|
|
506
|
+
*
|
|
507
|
+
* @template M - The machine type
|
|
508
|
+
* @param machine - The machine to add logging to
|
|
509
|
+
* @param options - Optional configuration for logging format
|
|
510
|
+
* @returns A new machine with logging middleware
|
|
511
|
+
*
|
|
512
|
+
* @example
|
|
513
|
+
* const logged = withLogging(counter);
|
|
514
|
+
* logged.increment(); // Console: "→ increment []" then "✓ increment { count: 1 }"
|
|
515
|
+
*/
|
|
516
|
+
export function withLogging<M extends BaseMachine<any>>(
|
|
517
|
+
machine: M,
|
|
518
|
+
options: {
|
|
519
|
+
/** Custom logger function (default: console.log) */
|
|
520
|
+
logger?: (message: string, ...args: any[]) => void;
|
|
521
|
+
/** Include context in logs (default: true) */
|
|
522
|
+
includeContext?: boolean;
|
|
523
|
+
/** Include arguments in logs (default: true) */
|
|
524
|
+
includeArgs?: boolean;
|
|
525
|
+
} = {}
|
|
526
|
+
): M {
|
|
527
|
+
const {
|
|
528
|
+
logger = console.log,
|
|
529
|
+
includeContext = true,
|
|
530
|
+
includeArgs = true
|
|
531
|
+
} = options;
|
|
532
|
+
|
|
533
|
+
return createMiddleware(machine, {
|
|
534
|
+
before: ({ transitionName, args }) => {
|
|
535
|
+
const argsStr = includeArgs && args.length > 0 ? ` ${JSON.stringify(args)}` : '';
|
|
536
|
+
logger(`→ ${transitionName}${argsStr}`);
|
|
537
|
+
},
|
|
538
|
+
after: ({ transitionName, nextContext }) => {
|
|
539
|
+
const contextStr = includeContext ? ` ${JSON.stringify(nextContext)}` : '';
|
|
540
|
+
logger(`✓ ${transitionName}${contextStr}`);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Analytics middleware that tracks state transitions.
|
|
547
|
+
* Compatible with any analytics service (Segment, Mixpanel, GA, etc.).
|
|
548
|
+
*
|
|
549
|
+
* @template M - The machine type
|
|
550
|
+
* @param machine - The machine to track
|
|
551
|
+
* @param track - Analytics tracking function
|
|
552
|
+
* @param options - Optional configuration for event naming
|
|
553
|
+
* @returns A new machine with analytics middleware
|
|
554
|
+
*
|
|
555
|
+
* @example
|
|
556
|
+
* const tracked = withAnalytics(machine, (event, props) => {
|
|
557
|
+
* analytics.track(event, props);
|
|
558
|
+
* });
|
|
559
|
+
*/
|
|
560
|
+
export function withAnalytics<M extends BaseMachine<any>>(
|
|
561
|
+
machine: M,
|
|
562
|
+
track: (event: string, properties: Record<string, any>) => void | Promise<void>,
|
|
563
|
+
options: {
|
|
564
|
+
/** Prefix for event names (default: "state_transition") */
|
|
565
|
+
eventPrefix?: string;
|
|
566
|
+
/** Include previous context in properties (default: false) */
|
|
567
|
+
includePrevContext?: boolean;
|
|
568
|
+
/** Include arguments in properties (default: true) */
|
|
569
|
+
includeArgs?: boolean;
|
|
570
|
+
} = {}
|
|
571
|
+
): M {
|
|
572
|
+
const {
|
|
573
|
+
eventPrefix = 'state_transition',
|
|
574
|
+
includePrevContext = false,
|
|
575
|
+
includeArgs = true
|
|
576
|
+
} = options;
|
|
577
|
+
|
|
578
|
+
return createMiddleware(machine, {
|
|
579
|
+
after: async ({ transitionName, prevContext, nextContext, args }) => {
|
|
580
|
+
const properties: Record<string, any> = {
|
|
581
|
+
transition: transitionName,
|
|
582
|
+
to: nextContext
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
if (includePrevContext) {
|
|
586
|
+
properties.from = prevContext;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (includeArgs && args.length > 0) {
|
|
590
|
+
properties.args = args;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
await track(`${eventPrefix}.${transitionName}`, properties);
|
|
594
|
+
}
|
|
595
|
+
}, { mode: 'async' });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Validation middleware that validates transitions before they execute.
|
|
600
|
+
* Throws an error if validation fails, preventing the transition.
|
|
601
|
+
*
|
|
602
|
+
* @template M - The machine type
|
|
603
|
+
* @param machine - The machine to validate
|
|
604
|
+
* @param validate - Validation function that throws or returns false on invalid transitions
|
|
605
|
+
* @returns A new machine with validation middleware
|
|
606
|
+
*
|
|
607
|
+
* @example
|
|
608
|
+
* const validated = withValidation(counter, ({ transitionName, context, args }) => {
|
|
609
|
+
* if (transitionName === 'decrement' && context.count === 0) {
|
|
610
|
+
* throw new Error('Cannot decrement below zero');
|
|
611
|
+
* }
|
|
612
|
+
* });
|
|
613
|
+
*/
|
|
614
|
+
export function withValidation<M extends BaseMachine<any>>(
|
|
615
|
+
machine: M,
|
|
616
|
+
validate: (ctx: MiddlewareContext<Context<M>>) => void | boolean | Promise<void | boolean>,
|
|
617
|
+
options?: Pick<MiddlewareOptions, 'mode'>
|
|
618
|
+
): M {
|
|
619
|
+
return createMiddleware(machine, {
|
|
620
|
+
before: (ctx) => {
|
|
621
|
+
const result = validate(ctx);
|
|
622
|
+
if (result instanceof Promise) {
|
|
623
|
+
return result.then(r => {
|
|
624
|
+
if (r === false) {
|
|
625
|
+
throw new Error(`Validation failed for transition: ${ctx.transitionName}`);
|
|
626
|
+
}
|
|
627
|
+
return undefined;
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
if (result === false) {
|
|
631
|
+
throw new Error(`Validation failed for transition: ${ctx.transitionName}`);
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
}, { mode: 'auto', ...options });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Permission/authorization middleware that checks if a transition is allowed.
|
|
640
|
+
* Useful for implementing role-based access control (RBAC) in state machines.
|
|
641
|
+
*
|
|
642
|
+
* @template M - The machine type
|
|
643
|
+
* @param machine - The machine to protect
|
|
644
|
+
* @param canPerform - Function that checks if the transition is allowed
|
|
645
|
+
* @returns A new machine with permission checks
|
|
646
|
+
*
|
|
647
|
+
* @example
|
|
648
|
+
* const protected = withPermissions(machine, (user) => ({ transitionName }) => {
|
|
649
|
+
* if (transitionName === 'delete' && user.role !== 'admin') {
|
|
650
|
+
* return false;
|
|
651
|
+
* }
|
|
652
|
+
* return true;
|
|
653
|
+
* });
|
|
654
|
+
*/
|
|
655
|
+
export function withPermissions<M extends BaseMachine<any>>(
|
|
656
|
+
machine: M,
|
|
657
|
+
canPerform: (ctx: MiddlewareContext<Context<M>>) => boolean | Promise<boolean>,
|
|
658
|
+
options?: Pick<MiddlewareOptions, 'mode'>
|
|
659
|
+
): M {
|
|
660
|
+
return createMiddleware(machine, {
|
|
661
|
+
before: (ctx) => {
|
|
662
|
+
const result = canPerform(ctx);
|
|
663
|
+
if (result instanceof Promise) {
|
|
664
|
+
return result.then(allowed => {
|
|
665
|
+
if (!allowed) {
|
|
666
|
+
throw new Error(`Unauthorized transition: ${ctx.transitionName}`);
|
|
667
|
+
}
|
|
668
|
+
return undefined;
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
if (!result) {
|
|
672
|
+
throw new Error(`Unauthorized transition: ${ctx.transitionName}`);
|
|
673
|
+
}
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
}, { mode: 'auto', ...options });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Error reporting middleware that sends errors to an error tracking service.
|
|
681
|
+
* Compatible with Sentry, Bugsnag, Rollbar, etc.
|
|
682
|
+
*
|
|
683
|
+
* @template M - The machine type
|
|
684
|
+
* @param machine - The machine to monitor
|
|
685
|
+
* @param captureError - Error capture function (e.g., Sentry.captureException)
|
|
686
|
+
* @param options - Optional configuration for error context
|
|
687
|
+
* @returns A new machine with error reporting
|
|
688
|
+
*
|
|
689
|
+
* @example
|
|
690
|
+
* const monitored = withErrorReporting(machine, (error, context) => {
|
|
691
|
+
* Sentry.captureException(error, { extra: context });
|
|
692
|
+
* });
|
|
693
|
+
*/
|
|
694
|
+
export function withErrorReporting<M extends BaseMachine<any>>(
|
|
695
|
+
machine: M,
|
|
696
|
+
captureError: (error: Error, context: Record<string, any>) => void | Promise<void>,
|
|
697
|
+
options: {
|
|
698
|
+
/** Include machine context in error report (default: true) */
|
|
699
|
+
includeContext?: boolean;
|
|
700
|
+
/** Include arguments in error report (default: true) */
|
|
701
|
+
includeArgs?: boolean;
|
|
702
|
+
/** Middleware execution mode */
|
|
703
|
+
mode?: MiddlewareOptions['mode'];
|
|
704
|
+
} = {}
|
|
705
|
+
): M {
|
|
706
|
+
const { includeContext = true, includeArgs = true, mode } = options;
|
|
707
|
+
|
|
708
|
+
return createMiddleware(machine, {
|
|
709
|
+
error: async ({ transitionName, context, args, error }) => {
|
|
710
|
+
const errorContext: Record<string, any> = {
|
|
711
|
+
transition: transitionName
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
if (includeContext) {
|
|
715
|
+
errorContext.context = context;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (includeArgs && args.length > 0) {
|
|
719
|
+
errorContext.args = args;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
await Promise.resolve(captureError(error, errorContext));
|
|
723
|
+
}
|
|
724
|
+
}, { mode });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Performance monitoring middleware that tracks transition execution time.
|
|
729
|
+
* Useful for identifying slow transitions and performance bottlenecks.
|
|
730
|
+
*
|
|
731
|
+
* @template M - The machine type
|
|
732
|
+
* @param machine - The machine to monitor
|
|
733
|
+
* @param onMetric - Callback to receive performance metrics
|
|
734
|
+
* @returns A new machine with performance monitoring
|
|
735
|
+
*
|
|
736
|
+
* @example
|
|
737
|
+
* const monitored = withPerformanceMonitoring(machine, ({ transition, duration }) => {
|
|
738
|
+
* if (duration > 100) {
|
|
739
|
+
* console.warn(`Slow transition: ${transition} took ${duration}ms`);
|
|
740
|
+
* }
|
|
741
|
+
* });
|
|
742
|
+
*/
|
|
743
|
+
export function withPerformanceMonitoring<M extends BaseMachine<any>>(
|
|
744
|
+
machine: M,
|
|
745
|
+
onMetric: (metric: {
|
|
746
|
+
transitionName: string;
|
|
747
|
+
duration: number;
|
|
748
|
+
context: Readonly<Context<M>>;
|
|
749
|
+
}) => void | Promise<void>
|
|
750
|
+
): M {
|
|
751
|
+
const timings = new Map<string, number>();
|
|
752
|
+
|
|
753
|
+
return createMiddleware(machine, {
|
|
754
|
+
before: ({ transitionName }) => {
|
|
755
|
+
timings.set(transitionName, performance.now());
|
|
756
|
+
return undefined;
|
|
757
|
+
},
|
|
758
|
+
after: ({ transitionName, nextContext }) => {
|
|
759
|
+
const startTime = timings.get(transitionName);
|
|
760
|
+
if (startTime) {
|
|
761
|
+
const duration = performance.now() - startTime;
|
|
762
|
+
timings.delete(transitionName);
|
|
763
|
+
const result = onMetric({ transitionName, duration, context: nextContext });
|
|
764
|
+
if (result instanceof Promise) {
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return undefined;
|
|
769
|
+
}
|
|
770
|
+
}, { mode: 'auto' });
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Retry middleware that automatically retries failed transitions.
|
|
775
|
+
* Uses direct property wrapping for optimal performance.
|
|
776
|
+
* Useful for handling transient failures in async operations.
|
|
777
|
+
*
|
|
778
|
+
* @template M - The machine type
|
|
779
|
+
* @param machine - The machine to add retry logic to
|
|
780
|
+
* @param options - Retry configuration
|
|
781
|
+
* @returns A new machine with retry logic
|
|
782
|
+
*
|
|
783
|
+
* @example
|
|
784
|
+
* const resilient = withRetry(machine, {
|
|
785
|
+
* maxRetries: 3,
|
|
786
|
+
* delay: 1000,
|
|
787
|
+
* shouldRetry: (error) => error.message.includes('network')
|
|
788
|
+
* });
|
|
789
|
+
*/
|
|
790
|
+
export function withRetry<M extends BaseMachine<any>>(
|
|
791
|
+
machine: M,
|
|
792
|
+
options: {
|
|
793
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
794
|
+
maxRetries?: number;
|
|
795
|
+
/** Delay between retries in milliseconds (default: 1000) */
|
|
796
|
+
delay?: number;
|
|
797
|
+
/** Exponential backoff multiplier (default: 1, no backoff) */
|
|
798
|
+
backoffMultiplier?: number;
|
|
799
|
+
/** Function to determine if error should trigger retry (default: always retry) */
|
|
800
|
+
shouldRetry?: (error: Error) => boolean;
|
|
801
|
+
/** Callback when retry occurs */
|
|
802
|
+
onRetry?: (attempt: number, error: Error) => void;
|
|
803
|
+
} = {}
|
|
804
|
+
): M {
|
|
805
|
+
const {
|
|
806
|
+
maxRetries = 3,
|
|
807
|
+
delay = 1000,
|
|
808
|
+
backoffMultiplier = 1,
|
|
809
|
+
shouldRetry = () => true,
|
|
810
|
+
onRetry
|
|
811
|
+
} = options;
|
|
812
|
+
|
|
813
|
+
// Build wrapped machine object with direct property iteration
|
|
814
|
+
const wrapped: any = {};
|
|
815
|
+
|
|
816
|
+
for (const prop in machine) {
|
|
817
|
+
if (!Object.prototype.hasOwnProperty.call(machine, prop)) continue;
|
|
818
|
+
|
|
819
|
+
const value = machine[prop];
|
|
820
|
+
|
|
821
|
+
// Skip context and non-functions
|
|
822
|
+
if (prop === 'context' || typeof value !== 'function') {
|
|
823
|
+
wrapped[prop] = value;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Wrap with retry logic
|
|
828
|
+
wrapped[prop] = async function retriableTransition(this: any, ...args: any[]) {
|
|
829
|
+
let lastError: Error | undefined;
|
|
830
|
+
|
|
831
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
832
|
+
try {
|
|
833
|
+
return await value.call(this, ...args);
|
|
834
|
+
} catch (error) {
|
|
835
|
+
lastError = error as Error;
|
|
836
|
+
|
|
837
|
+
// Don't retry if we've exhausted attempts
|
|
838
|
+
if (attempt === maxRetries) {
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Don't retry if shouldRetry returns false
|
|
843
|
+
if (!shouldRetry(lastError)) {
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Call onRetry callback
|
|
848
|
+
onRetry?.(attempt + 1, lastError);
|
|
849
|
+
|
|
850
|
+
// Wait before retrying
|
|
851
|
+
const currentDelay = delay * Math.pow(backoffMultiplier, attempt);
|
|
852
|
+
await new Promise(resolve => setTimeout(resolve, currentDelay));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// All retries exhausted, throw the last error
|
|
857
|
+
throw lastError;
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
return wrapped as M;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Guard configuration for a single transition.
|
|
866
|
+
*/
|
|
867
|
+
export interface GuardConfig<C extends object> {
|
|
868
|
+
/** Guard predicate function that returns true if transition is allowed */
|
|
869
|
+
guard: (ctx: MiddlewareContext<C>, ...args: any[]) => boolean | Promise<boolean>;
|
|
870
|
+
/**
|
|
871
|
+
* Action to take when guard fails.
|
|
872
|
+
* - 'throw': Throw an error (default)
|
|
873
|
+
* - 'ignore': Silently cancel the transition
|
|
874
|
+
*
|
|
875
|
+
* Note: For custom fallback machines, use the error hook in createMiddleware:
|
|
876
|
+
* @example
|
|
877
|
+
* createMiddleware(machine, {
|
|
878
|
+
* error: ({ error, context }) => {
|
|
879
|
+
* if (error.message.includes('Guard failed')) {
|
|
880
|
+
* return createMachine({ ...context, error: 'Unauthorized' }, machine);
|
|
881
|
+
* }
|
|
882
|
+
* }
|
|
883
|
+
* });
|
|
884
|
+
*/
|
|
885
|
+
onFail?: 'throw' | 'ignore';
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Guard middleware that prevents transitions based on predicate functions.
|
|
890
|
+
* Implements fundamental FSM guard concept - transitions only occur when guards pass.
|
|
891
|
+
*
|
|
892
|
+
* @template M - The machine type
|
|
893
|
+
* @param machine - The machine to protect with guards
|
|
894
|
+
* @param guards - Object mapping transition names to guard configurations
|
|
895
|
+
* @returns A new machine with guard checks
|
|
896
|
+
*
|
|
897
|
+
* @example
|
|
898
|
+
* const guarded = withGuards(counter, {
|
|
899
|
+
* decrement: {
|
|
900
|
+
* guard: ({ context }) => context.count > 0,
|
|
901
|
+
* onFail: 'throw' // or 'ignore'
|
|
902
|
+
* },
|
|
903
|
+
* delete: {
|
|
904
|
+
* guard: ({ context }) => context.user?.isAdmin === true,
|
|
905
|
+
* onFail: 'throw'
|
|
906
|
+
* }
|
|
907
|
+
* });
|
|
908
|
+
*
|
|
909
|
+
* guarded.decrement(); // Throws if count === 0
|
|
910
|
+
*
|
|
911
|
+
* // For custom fallback machines, combine with error middleware:
|
|
912
|
+
* const guardedWithFallback = createMiddleware(guarded, {
|
|
913
|
+
* error: ({ error, context }) => {
|
|
914
|
+
* if (error.message.includes('Guard failed')) {
|
|
915
|
+
* return createMachine({ ...context, error: 'Unauthorized' }, machine);
|
|
916
|
+
* }
|
|
917
|
+
* }
|
|
918
|
+
* });
|
|
919
|
+
*/
|
|
920
|
+
export function withGuards<M extends BaseMachine<any>>(
|
|
921
|
+
machine: M,
|
|
922
|
+
guards: Record<string, GuardConfig<Context<M>> | ((ctx: MiddlewareContext<Context<M>>, ...args: any[]) => boolean | Promise<boolean>)>,
|
|
923
|
+
options?: Pick<MiddlewareOptions, 'mode'>
|
|
924
|
+
): M {
|
|
925
|
+
return createMiddleware(machine, {
|
|
926
|
+
before: async (ctx) => {
|
|
927
|
+
const guardConfig = guards[ctx.transitionName];
|
|
928
|
+
if (!guardConfig) {
|
|
929
|
+
return undefined; // No guard for this transition
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Handle shorthand: function directly instead of config object
|
|
933
|
+
const guard = typeof guardConfig === 'function' ? guardConfig : guardConfig.guard;
|
|
934
|
+
const onFail = typeof guardConfig === 'object' ? guardConfig.onFail : 'throw';
|
|
935
|
+
|
|
936
|
+
// Evaluate guard
|
|
937
|
+
const allowed = await Promise.resolve(guard(ctx, ...ctx.args));
|
|
938
|
+
|
|
939
|
+
if (!allowed) {
|
|
940
|
+
if (onFail === 'ignore') {
|
|
941
|
+
return CANCEL; // Silently cancel transition
|
|
942
|
+
} else {
|
|
943
|
+
// Default to 'throw'
|
|
944
|
+
throw new Error(`Guard failed for transition: ${ctx.transitionName}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return undefined;
|
|
948
|
+
}
|
|
949
|
+
}, { mode: 'async', ...options });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Creates conditional middleware that only applies to specific transitions.
|
|
954
|
+
* Useful for targeted instrumentation without affecting all transitions.
|
|
955
|
+
*
|
|
956
|
+
* @template M - The machine type
|
|
957
|
+
* @param machine - The machine to instrument
|
|
958
|
+
* @param config - Configuration specifying which transitions to instrument
|
|
959
|
+
* @returns A new machine with conditional middleware
|
|
960
|
+
*
|
|
961
|
+
* @example
|
|
962
|
+
* const conditional = createConditionalMiddleware(counter, {
|
|
963
|
+
* only: ['delete', 'update'], // Only these transitions
|
|
964
|
+
* hooks: {
|
|
965
|
+
* before: ({ transitionName }) => console.log('Sensitive operation:', transitionName),
|
|
966
|
+
* after: ({ transitionName }) => auditLog(transitionName)
|
|
967
|
+
* }
|
|
968
|
+
* });
|
|
969
|
+
*
|
|
970
|
+
* @example
|
|
971
|
+
* const excluding = createConditionalMiddleware(counter, {
|
|
972
|
+
* except: ['increment'], // All except these
|
|
973
|
+
* hooks: {
|
|
974
|
+
* before: ({ transitionName, args }) => validate(transitionName, args)
|
|
975
|
+
* }
|
|
976
|
+
* });
|
|
977
|
+
*/
|
|
978
|
+
export function createConditionalMiddleware<M extends BaseMachine<any>>(
|
|
979
|
+
machine: M,
|
|
980
|
+
config: {
|
|
981
|
+
/** Only apply to these transitions (mutually exclusive with except) */
|
|
982
|
+
only?: string[];
|
|
983
|
+
/** Apply to all except these transitions (mutually exclusive with only) */
|
|
984
|
+
except?: string[];
|
|
985
|
+
/** Middleware hooks to apply */
|
|
986
|
+
hooks: MiddlewareHooks<Context<M>>;
|
|
987
|
+
/** Middleware options */
|
|
988
|
+
options?: MiddlewareOptions;
|
|
989
|
+
}
|
|
990
|
+
): M {
|
|
991
|
+
const { only, except, hooks, options } = config;
|
|
992
|
+
|
|
993
|
+
if (only && except) {
|
|
994
|
+
throw new Error('Cannot specify both "only" and "except" - choose one');
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Create filter function
|
|
998
|
+
const shouldApply = (transitionName: string): boolean => {
|
|
999
|
+
if (only) {
|
|
1000
|
+
return only.includes(transitionName);
|
|
1001
|
+
}
|
|
1002
|
+
if (except) {
|
|
1003
|
+
return !except.includes(transitionName);
|
|
1004
|
+
}
|
|
1005
|
+
return true;
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
// Wrap hooks to check filter
|
|
1009
|
+
const conditionalHooks: MiddlewareHooks<Context<M>> = {
|
|
1010
|
+
before: hooks.before
|
|
1011
|
+
? async (ctx) => {
|
|
1012
|
+
if (shouldApply(ctx.transitionName)) {
|
|
1013
|
+
return await hooks.before!(ctx);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
: undefined,
|
|
1017
|
+
after: hooks.after
|
|
1018
|
+
? async (result) => {
|
|
1019
|
+
if (shouldApply(result.transitionName)) {
|
|
1020
|
+
return await hooks.after!(result);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
: undefined,
|
|
1024
|
+
error: hooks.error
|
|
1025
|
+
? async (error) => {
|
|
1026
|
+
if (shouldApply(error.transitionName)) {
|
|
1027
|
+
return await hooks.error!(error);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
: undefined
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
return createMiddleware(machine, conditionalHooks, options);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Creates state-dependent middleware that only applies when a predicate is true.
|
|
1038
|
+
* Allows middleware behavior to change based on current context/state.
|
|
1039
|
+
*
|
|
1040
|
+
* @template M - The machine type
|
|
1041
|
+
* @param machine - The machine to instrument
|
|
1042
|
+
* @param config - Configuration with predicate and hooks
|
|
1043
|
+
* @returns A new machine with state-dependent middleware
|
|
1044
|
+
*
|
|
1045
|
+
* @example
|
|
1046
|
+
* const stateful = createStateMiddleware(counter, {
|
|
1047
|
+
* when: (ctx) => ctx.debugMode === true,
|
|
1048
|
+
* hooks: {
|
|
1049
|
+
* before: (ctx) => console.log('Debug:', ctx),
|
|
1050
|
+
* after: (result) => console.log('Debug result:', result)
|
|
1051
|
+
* }
|
|
1052
|
+
* });
|
|
1053
|
+
*
|
|
1054
|
+
* // Logging only happens when context.debugMode === true
|
|
1055
|
+
*/
|
|
1056
|
+
export function createStateMiddleware<M extends BaseMachine<any>>(
|
|
1057
|
+
machine: M,
|
|
1058
|
+
config: {
|
|
1059
|
+
/** Predicate that determines if middleware should apply */
|
|
1060
|
+
when: (ctx: Context<M>) => boolean | Promise<boolean>;
|
|
1061
|
+
/** Middleware hooks to apply when predicate is true */
|
|
1062
|
+
hooks: MiddlewareHooks<Context<M>>;
|
|
1063
|
+
/** Middleware options */
|
|
1064
|
+
options?: MiddlewareOptions;
|
|
1065
|
+
}
|
|
1066
|
+
): M {
|
|
1067
|
+
const { when, hooks, options } = config;
|
|
1068
|
+
|
|
1069
|
+
// Wrap hooks to check predicate
|
|
1070
|
+
const conditionalHooks: MiddlewareHooks<Context<M>> = {
|
|
1071
|
+
before: hooks.before
|
|
1072
|
+
? async (ctx) => {
|
|
1073
|
+
if (await Promise.resolve(when(ctx.context))) {
|
|
1074
|
+
return await hooks.before!(ctx);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
: undefined,
|
|
1078
|
+
after: hooks.after
|
|
1079
|
+
? async (result) => {
|
|
1080
|
+
if (await Promise.resolve(when(result.prevContext))) {
|
|
1081
|
+
return await hooks.after!(result);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
: undefined,
|
|
1085
|
+
error: hooks.error
|
|
1086
|
+
? async (error) => {
|
|
1087
|
+
if (await Promise.resolve(when(error.context))) {
|
|
1088
|
+
return await hooks.error!(error);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
: undefined
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
return createMiddleware(machine, conditionalHooks, options);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// =============================================================================
|
|
1098
|
+
// SECTION: HISTORY AND SNAPSHOT TRACKING
|
|
1099
|
+
// =============================================================================
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Represents a recorded transition call in the history.
|
|
1103
|
+
*/
|
|
1104
|
+
export interface HistoryEntry {
|
|
1105
|
+
/** Unique ID for this history entry */
|
|
1106
|
+
id: string;
|
|
1107
|
+
/** The transition that was called */
|
|
1108
|
+
transitionName: string;
|
|
1109
|
+
/** Arguments passed to the transition */
|
|
1110
|
+
args: any[];
|
|
1111
|
+
/** Timestamp when the transition was called */
|
|
1112
|
+
timestamp: number;
|
|
1113
|
+
/** Optional serialized version of args (if serializer provided) */
|
|
1114
|
+
serializedArgs?: string;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Generic serializer/deserializer interface.
|
|
1119
|
+
* Used for serializing history arguments, context snapshots, etc.
|
|
1120
|
+
* @template T - The type being serialized
|
|
1121
|
+
*/
|
|
1122
|
+
export interface Serializer<T = any> {
|
|
1123
|
+
/** Serialize data to a string */
|
|
1124
|
+
serialize: (data: T) => string;
|
|
1125
|
+
/** Deserialize string back to data */
|
|
1126
|
+
deserialize: (serialized: string) => T;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* History tracking middleware that records all transition calls.
|
|
1131
|
+
* Useful for debugging, replay, undo/redo, and audit logging.
|
|
1132
|
+
*
|
|
1133
|
+
* @template M - The machine type
|
|
1134
|
+
* @param machine - The machine to track
|
|
1135
|
+
* @param options - Configuration options
|
|
1136
|
+
* @returns A new machine with history tracking and a history array
|
|
1137
|
+
*
|
|
1138
|
+
* Note: Arguments are shallow-cloned by default. If you need deep cloning or
|
|
1139
|
+
* serialization for persistence, provide a serializer:
|
|
1140
|
+
*
|
|
1141
|
+
* @example
|
|
1142
|
+
* const tracked = withHistory(counter, {
|
|
1143
|
+
* maxSize: 100,
|
|
1144
|
+
* serializer: {
|
|
1145
|
+
* serialize: (args) => JSON.stringify(args), // For deep clone or persistence
|
|
1146
|
+
* deserialize: (str) => JSON.parse(str)
|
|
1147
|
+
* }
|
|
1148
|
+
* });
|
|
1149
|
+
*
|
|
1150
|
+
* tracked.increment();
|
|
1151
|
+
* tracked.add(5);
|
|
1152
|
+
* console.log(tracked.history); // [{ transitionName: 'increment', args: [], ... }, ...]
|
|
1153
|
+
* tracked.clearHistory(); // Clear history
|
|
1154
|
+
*/
|
|
1155
|
+
export function withHistory<M extends BaseMachine<any>>(
|
|
1156
|
+
machine: M,
|
|
1157
|
+
options: {
|
|
1158
|
+
/** Maximum number of entries to keep (default: unlimited) */
|
|
1159
|
+
maxSize?: number;
|
|
1160
|
+
/** Optional serializer for arguments */
|
|
1161
|
+
serializer?: Serializer<any[]>;
|
|
1162
|
+
/** Filter function to exclude certain transitions from history */
|
|
1163
|
+
filter?: (transitionName: string, args: any[]) => boolean;
|
|
1164
|
+
/** Callback when new entry is added */
|
|
1165
|
+
onEntry?: (entry: HistoryEntry) => void;
|
|
1166
|
+
/** Internal flag to prevent rewrapping */
|
|
1167
|
+
_isRewrap?: boolean;
|
|
1168
|
+
} = {}
|
|
1169
|
+
): WithHistory<M> {
|
|
1170
|
+
const {
|
|
1171
|
+
maxSize,
|
|
1172
|
+
serializer,
|
|
1173
|
+
filter,
|
|
1174
|
+
onEntry,
|
|
1175
|
+
_isRewrap = false
|
|
1176
|
+
} = options;
|
|
1177
|
+
|
|
1178
|
+
const history: HistoryEntry[] = [];
|
|
1179
|
+
let entryId = 0;
|
|
1180
|
+
|
|
1181
|
+
const instrumentedMachine = createMiddleware(machine, {
|
|
1182
|
+
before: ({ transitionName, args }) => {
|
|
1183
|
+
// Check filter
|
|
1184
|
+
if (filter && !filter(transitionName, args)) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Create entry
|
|
1189
|
+
const entry: HistoryEntry = {
|
|
1190
|
+
id: `entry-${entryId++}`,
|
|
1191
|
+
transitionName,
|
|
1192
|
+
args: [...args], // Shallow clone args (fast, works with any type)
|
|
1193
|
+
timestamp: Date.now()
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
// Serialize if serializer provided
|
|
1197
|
+
if (serializer) {
|
|
1198
|
+
try {
|
|
1199
|
+
entry.serializedArgs = serializer.serialize(args);
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
console.error('Failed to serialize history args:', err);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Add to history
|
|
1206
|
+
history.push(entry);
|
|
1207
|
+
|
|
1208
|
+
// Enforce max size
|
|
1209
|
+
if (maxSize && history.length > maxSize) {
|
|
1210
|
+
history.shift();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Call callback
|
|
1214
|
+
onEntry?.(entry);
|
|
1215
|
+
}
|
|
1216
|
+
}, { exclude: ['context', 'history', 'clearHistory'] });
|
|
1217
|
+
|
|
1218
|
+
// Override transitions to propagate history to returned machines
|
|
1219
|
+
if (!_isRewrap) {
|
|
1220
|
+
for (const prop in instrumentedMachine) {
|
|
1221
|
+
if (!Object.prototype.hasOwnProperty.call(instrumentedMachine, prop)) continue;
|
|
1222
|
+
const value = instrumentedMachine[prop];
|
|
1223
|
+
if (typeof value === 'function' && !prop.startsWith('_') && prop !== 'context' && !['history', 'clearHistory'].includes(prop)) {
|
|
1224
|
+
const originalFn = value;
|
|
1225
|
+
(instrumentedMachine as any)[prop] = function(this: any, ...args: any[]) {
|
|
1226
|
+
const result = originalFn.apply(this, args);
|
|
1227
|
+
// If result is a machine, re-wrap it with history tracking using the shared history array
|
|
1228
|
+
if (result && typeof result === 'object' && 'context' in result && !('history' in result)) {
|
|
1229
|
+
// Create a new wrapped machine that shares the same history array
|
|
1230
|
+
const rewrappedResult = createMiddleware(result, {
|
|
1231
|
+
before: ({ transitionName, args: transArgs }) => {
|
|
1232
|
+
// Check filter
|
|
1233
|
+
if (filter && !filter(transitionName, transArgs)) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Create entry
|
|
1238
|
+
const entry: HistoryEntry = {
|
|
1239
|
+
id: `entry-${entryId++}`,
|
|
1240
|
+
transitionName,
|
|
1241
|
+
args: [...transArgs],
|
|
1242
|
+
timestamp: Date.now()
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
// Serialize if serializer provided
|
|
1246
|
+
if (serializer) {
|
|
1247
|
+
try {
|
|
1248
|
+
entry.serializedArgs = serializer.serialize(transArgs);
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
console.error('Failed to serialize history args:', err);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Add to history
|
|
1255
|
+
history.push(entry);
|
|
1256
|
+
|
|
1257
|
+
// Enforce max size
|
|
1258
|
+
if (maxSize && history.length > maxSize) {
|
|
1259
|
+
history.shift();
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Call callback
|
|
1263
|
+
onEntry?.(entry);
|
|
1264
|
+
}
|
|
1265
|
+
}, { exclude: ['context', 'history', 'clearHistory'] });
|
|
1266
|
+
|
|
1267
|
+
// Attach the shared history
|
|
1268
|
+
return Object.assign(rewrappedResult, {
|
|
1269
|
+
history,
|
|
1270
|
+
clearHistory: () => {
|
|
1271
|
+
history.length = 0;
|
|
1272
|
+
entryId = 0;
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
return result;
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Attach history tracking properties to the instrumented machine
|
|
1283
|
+
return Object.assign(instrumentedMachine, {
|
|
1284
|
+
history,
|
|
1285
|
+
clearHistory: () => {
|
|
1286
|
+
history.length = 0;
|
|
1287
|
+
entryId = 0;
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Represents a snapshot of context at a point in time.
|
|
1294
|
+
* @template C - The context type
|
|
1295
|
+
*/
|
|
1296
|
+
export interface ContextSnapshot<C extends object = any> {
|
|
1297
|
+
/** Unique ID for this snapshot */
|
|
1298
|
+
id: string;
|
|
1299
|
+
/** The transition that caused this change */
|
|
1300
|
+
transitionName: string;
|
|
1301
|
+
/** Context before the transition */
|
|
1302
|
+
before: C;
|
|
1303
|
+
/** Context after the transition */
|
|
1304
|
+
after: C;
|
|
1305
|
+
/** Timestamp of the snapshot */
|
|
1306
|
+
timestamp: number;
|
|
1307
|
+
/** Optional serialized version of contexts */
|
|
1308
|
+
serializedBefore?: string;
|
|
1309
|
+
serializedAfter?: string;
|
|
1310
|
+
/** Optional diff information */
|
|
1311
|
+
diff?: any;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Snapshot middleware that records context before/after each transition.
|
|
1316
|
+
* Useful for time-travel debugging, undo/redo, and state inspection.
|
|
1317
|
+
*
|
|
1318
|
+
* @template M - The machine type
|
|
1319
|
+
* @param machine - The machine to track
|
|
1320
|
+
* @param options - Configuration options
|
|
1321
|
+
* @returns A new machine with snapshot tracking and snapshots array
|
|
1322
|
+
*
|
|
1323
|
+
* @example
|
|
1324
|
+
* const tracked = withSnapshot(counter, {
|
|
1325
|
+
* maxSize: 50,
|
|
1326
|
+
* serializer: {
|
|
1327
|
+
* serialize: (ctx) => JSON.stringify(ctx),
|
|
1328
|
+
* deserialize: (str) => JSON.parse(str)
|
|
1329
|
+
* },
|
|
1330
|
+
* captureSnapshot: (before, after) => ({
|
|
1331
|
+
* changed: before.count !== after.count
|
|
1332
|
+
* })
|
|
1333
|
+
* });
|
|
1334
|
+
*
|
|
1335
|
+
* tracked.increment();
|
|
1336
|
+
* console.log(tracked.snapshots); // [{ before: { count: 0 }, after: { count: 1 }, ... }]
|
|
1337
|
+
*
|
|
1338
|
+
* // Time-travel: restore to previous state
|
|
1339
|
+
* const previousState = tracked.restoreSnapshot(tracked.snapshots[0].before);
|
|
1340
|
+
*/
|
|
1341
|
+
export function withSnapshot<M extends BaseMachine<any>>(
|
|
1342
|
+
machine: M,
|
|
1343
|
+
options: {
|
|
1344
|
+
/** Maximum number of snapshots to keep (default: unlimited) */
|
|
1345
|
+
maxSize?: number;
|
|
1346
|
+
/** Optional serializer for context */
|
|
1347
|
+
serializer?: Serializer<Context<M>>;
|
|
1348
|
+
/** Custom function to capture additional snapshot data */
|
|
1349
|
+
captureSnapshot?: (before: Context<M>, after: Context<M>) => any;
|
|
1350
|
+
/** Only capture snapshots where context actually changed */
|
|
1351
|
+
onlyIfChanged?: boolean;
|
|
1352
|
+
/** Filter function to exclude certain transitions from snapshots */
|
|
1353
|
+
filter?: (transitionName: string) => boolean;
|
|
1354
|
+
/** Callback when new snapshot is taken */
|
|
1355
|
+
onSnapshot?: (snapshot: ContextSnapshot<Context<M>>) => void;
|
|
1356
|
+
/** Additional properties to exclude from middleware (for composition) */
|
|
1357
|
+
_extraExclusions?: string[];
|
|
1358
|
+
/** Internal flag to prevent rewrapping */
|
|
1359
|
+
_isRewrap?: boolean;
|
|
1360
|
+
} = {}
|
|
1361
|
+
): WithSnapshot<M, Context<M>> {
|
|
1362
|
+
const {
|
|
1363
|
+
maxSize,
|
|
1364
|
+
serializer,
|
|
1365
|
+
captureSnapshot,
|
|
1366
|
+
onlyIfChanged = false,
|
|
1367
|
+
filter,
|
|
1368
|
+
onSnapshot,
|
|
1369
|
+
_extraExclusions = [],
|
|
1370
|
+
_isRewrap = false
|
|
1371
|
+
} = options;
|
|
1372
|
+
|
|
1373
|
+
const snapshots: ContextSnapshot<Context<M>>[] = [];
|
|
1374
|
+
let snapshotId = 0;
|
|
1375
|
+
|
|
1376
|
+
const instrumentedMachine = createMiddleware(machine, {
|
|
1377
|
+
after: ({ transitionName, prevContext, nextContext }) => {
|
|
1378
|
+
// Check filter
|
|
1379
|
+
if (filter && !filter(transitionName)) {
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Check if changed (if required)
|
|
1384
|
+
if (onlyIfChanged) {
|
|
1385
|
+
const changed = JSON.stringify(prevContext) !== JSON.stringify(nextContext);
|
|
1386
|
+
if (!changed) {
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Create snapshot
|
|
1392
|
+
const snapshot: ContextSnapshot<Context<M>> = {
|
|
1393
|
+
id: `snapshot-${snapshotId++}`,
|
|
1394
|
+
transitionName,
|
|
1395
|
+
before: { ...prevContext as Context<M> }, // Clone
|
|
1396
|
+
after: { ...nextContext as Context<M> }, // Clone
|
|
1397
|
+
timestamp: Date.now()
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
// Serialize if serializer provided
|
|
1401
|
+
if (serializer) {
|
|
1402
|
+
try {
|
|
1403
|
+
snapshot.serializedBefore = serializer.serialize(prevContext as Context<M>);
|
|
1404
|
+
snapshot.serializedAfter = serializer.serialize(nextContext as Context<M>);
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
console.error('Failed to serialize snapshot:', err);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Capture custom snapshot data
|
|
1411
|
+
if (captureSnapshot) {
|
|
1412
|
+
try {
|
|
1413
|
+
snapshot.diff = captureSnapshot(prevContext as Context<M>, nextContext as Context<M>);
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
console.error('Failed to capture snapshot:', err);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Add to snapshots
|
|
1420
|
+
snapshots.push(snapshot);
|
|
1421
|
+
|
|
1422
|
+
// Enforce max size
|
|
1423
|
+
if (maxSize && snapshots.length > maxSize) {
|
|
1424
|
+
snapshots.shift();
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Call callback
|
|
1428
|
+
onSnapshot?.(snapshot);
|
|
1429
|
+
}
|
|
1430
|
+
}, { exclude: ['context', 'snapshots', 'clearSnapshots', 'restoreSnapshot', ..._extraExclusions] });
|
|
1431
|
+
|
|
1432
|
+
// Helper to restore machine to a previous context
|
|
1433
|
+
const restoreSnapshot = (context: Context<M>): M => {
|
|
1434
|
+
const { context: _, ...transitions } = machine;
|
|
1435
|
+
return { context, ...transitions } as M;
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
// Override transitions to propagate snapshots and history to returned machines
|
|
1439
|
+
if (!_isRewrap) {
|
|
1440
|
+
for (const prop in instrumentedMachine) {
|
|
1441
|
+
if (!Object.prototype.hasOwnProperty.call(instrumentedMachine, prop)) continue;
|
|
1442
|
+
const value = instrumentedMachine[prop];
|
|
1443
|
+
if (typeof value === 'function' && !prop.startsWith('_') && prop !== 'context' && !['snapshots', 'clearSnapshots', 'restoreSnapshot', 'history', 'clearHistory'].includes(prop)) {
|
|
1444
|
+
const originalWrappedFn = value;
|
|
1445
|
+
(instrumentedMachine as any)[prop] = function(this: any, ...args: any[]) {
|
|
1446
|
+
const result = originalWrappedFn.apply(this, args);
|
|
1447
|
+
// If result is a machine, re-wrap it with snapshot tracking using the shared snapshots array
|
|
1448
|
+
if (result && typeof result === 'object' && 'context' in result && !('snapshots' in result)) {
|
|
1449
|
+
// Manually handle snapshot tracking without calling createMiddleware again
|
|
1450
|
+
// to avoid infinite recursion and complex wrapping issues
|
|
1451
|
+
|
|
1452
|
+
// Create a proxy that intercepts transition calls to record snapshots
|
|
1453
|
+
|
|
1454
|
+
// Wrap each transition to record snapshots
|
|
1455
|
+
for (const transProp in result) {
|
|
1456
|
+
if (!Object.prototype.hasOwnProperty.call(result, transProp)) continue;
|
|
1457
|
+
const transValue = result[transProp];
|
|
1458
|
+
if (typeof transValue === 'function' && !transProp.startsWith('_') && transProp !== 'context' && !['snapshots', 'clearSnapshots', 'restoreSnapshot', 'history', 'clearHistory'].includes(transProp)) {
|
|
1459
|
+
const origTransFn = transValue;
|
|
1460
|
+
(result as any)[transProp] = function(this: any, ...transArgs: any[]) {
|
|
1461
|
+
const prevCtx = result.context;
|
|
1462
|
+
const transResult = origTransFn.apply(this, transArgs);
|
|
1463
|
+
|
|
1464
|
+
// Record snapshot if we got a machine back
|
|
1465
|
+
if (transResult && typeof transResult === 'object' && 'context' in transResult) {
|
|
1466
|
+
const nextCtx = transResult.context;
|
|
1467
|
+
|
|
1468
|
+
// Check filter
|
|
1469
|
+
if (!(filter && !filter(transProp))) {
|
|
1470
|
+
// Check if changed (if required)
|
|
1471
|
+
let shouldRecord = true;
|
|
1472
|
+
if (onlyIfChanged) {
|
|
1473
|
+
const changed = JSON.stringify(prevCtx) !== JSON.stringify(nextCtx);
|
|
1474
|
+
shouldRecord = changed;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (shouldRecord) {
|
|
1478
|
+
// Create snapshot
|
|
1479
|
+
const snapshot: ContextSnapshot<Context<M>> = {
|
|
1480
|
+
id: `snapshot-${snapshotId++}`,
|
|
1481
|
+
transitionName: transProp,
|
|
1482
|
+
before: { ...prevCtx as Context<M> },
|
|
1483
|
+
after: { ...nextCtx as Context<M> },
|
|
1484
|
+
timestamp: Date.now()
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
// Serialize if serializer provided
|
|
1488
|
+
if (serializer) {
|
|
1489
|
+
try {
|
|
1490
|
+
snapshot.serializedBefore = serializer.serialize(prevCtx as Context<M>);
|
|
1491
|
+
snapshot.serializedAfter = serializer.serialize(nextCtx as Context<M>);
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
console.error('Failed to serialize snapshot:', err);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Capture custom snapshot data
|
|
1498
|
+
if (captureSnapshot) {
|
|
1499
|
+
try {
|
|
1500
|
+
snapshot.diff = captureSnapshot(prevCtx as Context<M>, nextCtx as Context<M>);
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
console.error('Failed to capture snapshot:', err);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Add to snapshots
|
|
1507
|
+
snapshots.push(snapshot);
|
|
1508
|
+
|
|
1509
|
+
// Enforce max size
|
|
1510
|
+
if (maxSize && snapshots.length > maxSize) {
|
|
1511
|
+
snapshots.shift();
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Call callback
|
|
1515
|
+
onSnapshot?.(snapshot);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
return transResult;
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Attach the shared snapshots and history
|
|
1526
|
+
const resultWithTracking = Object.assign(result, {
|
|
1527
|
+
snapshots,
|
|
1528
|
+
clearSnapshots: () => {
|
|
1529
|
+
snapshots.length = 0;
|
|
1530
|
+
snapshotId = 0;
|
|
1531
|
+
},
|
|
1532
|
+
restoreSnapshot
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
// Also propagate history if it exists on the input machine
|
|
1536
|
+
if ((machine as any).history) {
|
|
1537
|
+
resultWithTracking.history = (machine as any).history;
|
|
1538
|
+
resultWithTracking.clearHistory = (machine as any).clearHistory;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
return resultWithTracking;
|
|
1542
|
+
}
|
|
1543
|
+
return result;
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Attach snapshot tracking properties to the instrumented machine
|
|
1550
|
+
return Object.assign(instrumentedMachine, {
|
|
1551
|
+
snapshots,
|
|
1552
|
+
clearSnapshots: () => {
|
|
1553
|
+
snapshots.length = 0;
|
|
1554
|
+
snapshotId = 0;
|
|
1555
|
+
},
|
|
1556
|
+
restoreSnapshot
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Combined history and snapshot middleware for full time-travel debugging.
|
|
1562
|
+
* Records both transition calls and context changes.
|
|
1563
|
+
*
|
|
1564
|
+
* @template M - The machine type
|
|
1565
|
+
* @param machine - The machine to track
|
|
1566
|
+
* @param options - Configuration options
|
|
1567
|
+
* @returns Machine with both history and snapshot tracking
|
|
1568
|
+
*
|
|
1569
|
+
* @example
|
|
1570
|
+
* const tracker = withTimeTravel(counter, {
|
|
1571
|
+
* maxSize: 100,
|
|
1572
|
+
* serializer: {
|
|
1573
|
+
* serialize: (data) => JSON.stringify(data),
|
|
1574
|
+
* deserialize: (str) => JSON.parse(str)
|
|
1575
|
+
* }
|
|
1576
|
+
* });
|
|
1577
|
+
*
|
|
1578
|
+
* tracker.increment();
|
|
1579
|
+
* tracker.add(5);
|
|
1580
|
+
*
|
|
1581
|
+
* console.log(tracker.history); // All transitions
|
|
1582
|
+
* console.log(tracker.snapshots); // All state changes
|
|
1583
|
+
*
|
|
1584
|
+
* // Replay from a specific snapshot
|
|
1585
|
+
* const replayed = tracker.replayFrom(0);
|
|
1586
|
+
*
|
|
1587
|
+
* // Restore to specific snapshot
|
|
1588
|
+
* const restored = tracker.restoreSnapshot(tracker.snapshots[0].before);
|
|
1589
|
+
*
|
|
1590
|
+
* // Clear all tracking data
|
|
1591
|
+
* tracker.clearTimeTravel();
|
|
1592
|
+
*/
|
|
1593
|
+
export function withTimeTravel<M extends BaseMachine<any>>(
|
|
1594
|
+
machine: M,
|
|
1595
|
+
options: {
|
|
1596
|
+
/** Maximum size for both history and snapshots */
|
|
1597
|
+
maxSize?: number;
|
|
1598
|
+
/** Serializer for both args and context */
|
|
1599
|
+
serializer?: Serializer<any>;
|
|
1600
|
+
/** Callback for each recorded action */
|
|
1601
|
+
onRecord?: (type: 'history' | 'snapshot', data: any) => void;
|
|
1602
|
+
} = {}
|
|
1603
|
+
): WithTimeTravel<M, Context<M>> {
|
|
1604
|
+
const { maxSize, serializer, onRecord } = options;
|
|
1605
|
+
|
|
1606
|
+
const history: HistoryEntry[] = [];
|
|
1607
|
+
const snapshots: ContextSnapshot<Context<M>>[] = [];
|
|
1608
|
+
let entryId = 0;
|
|
1609
|
+
let snapshotId = 0;
|
|
1610
|
+
|
|
1611
|
+
// Middleware hooks that record to shared arrays
|
|
1612
|
+
const recordHistory = (transitionName: string, args: any[]) => {
|
|
1613
|
+
const entry: HistoryEntry = {
|
|
1614
|
+
id: `entry-${entryId++}`,
|
|
1615
|
+
transitionName,
|
|
1616
|
+
args: [...args],
|
|
1617
|
+
timestamp: Date.now()
|
|
1618
|
+
};
|
|
1619
|
+
|
|
1620
|
+
if (serializer) {
|
|
1621
|
+
try {
|
|
1622
|
+
entry.serializedArgs = serializer.serialize(args);
|
|
1623
|
+
} catch (err) {
|
|
1624
|
+
console.error('Failed to serialize history args:', err);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
history.push(entry);
|
|
1629
|
+
if (maxSize && history.length > maxSize) {
|
|
1630
|
+
history.shift();
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
onRecord?.('history', entry);
|
|
1634
|
+
};
|
|
1635
|
+
|
|
1636
|
+
const recordSnapshot = (transitionName: string, prevContext: Context<M>, nextContext: Context<M>) => {
|
|
1637
|
+
const snapshot: ContextSnapshot<Context<M>> = {
|
|
1638
|
+
id: `snapshot-${snapshotId++}`,
|
|
1639
|
+
transitionName,
|
|
1640
|
+
before: { ...prevContext },
|
|
1641
|
+
after: { ...nextContext },
|
|
1642
|
+
timestamp: Date.now()
|
|
1643
|
+
};
|
|
1644
|
+
|
|
1645
|
+
if (serializer) {
|
|
1646
|
+
try {
|
|
1647
|
+
snapshot.serializedBefore = serializer.serialize(prevContext);
|
|
1648
|
+
snapshot.serializedAfter = serializer.serialize(nextContext);
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
console.error('Failed to serialize snapshot:', err);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
snapshots.push(snapshot);
|
|
1655
|
+
if (maxSize && snapshots.length > maxSize) {
|
|
1656
|
+
snapshots.shift();
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
onRecord?.('snapshot', snapshot);
|
|
1660
|
+
};
|
|
1661
|
+
|
|
1662
|
+
// Helper to restore machine to a previous context
|
|
1663
|
+
const restoreSnapshot = (context: Context<M>): M => {
|
|
1664
|
+
const { context: _, ...transitions } = machine;
|
|
1665
|
+
return Object.assign({ context }, context, transitions) as M;
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
// Implementation of replay functionality
|
|
1669
|
+
const replayFrom = (snapshotIndex: number = 0): M => {
|
|
1670
|
+
if (snapshotIndex < 0 || snapshotIndex >= snapshots.length) {
|
|
1671
|
+
throw new Error(`Invalid snapshot index: ${snapshotIndex}`);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
let current = restoreSnapshot(snapshots[snapshotIndex].before);
|
|
1675
|
+
|
|
1676
|
+
// Find the history index that corresponds to this snapshot
|
|
1677
|
+
const snapshot = snapshots[snapshotIndex];
|
|
1678
|
+
const historyStartIndex = history.findIndex(
|
|
1679
|
+
entry => entry.transitionName === snapshot.transitionName && entry.timestamp === snapshot.timestamp
|
|
1680
|
+
);
|
|
1681
|
+
|
|
1682
|
+
if (historyStartIndex === -1) {
|
|
1683
|
+
throw new Error('Could not find matching history entry for snapshot');
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Replay all transitions from that point
|
|
1687
|
+
for (let i = historyStartIndex; i < history.length; i++) {
|
|
1688
|
+
const entry = history[i];
|
|
1689
|
+
const transition = (current as any)[entry.transitionName];
|
|
1690
|
+
|
|
1691
|
+
if (typeof transition === 'function') {
|
|
1692
|
+
try {
|
|
1693
|
+
current = transition.apply(current.context, entry.args);
|
|
1694
|
+
} catch (err) {
|
|
1695
|
+
console.error(`Replay failed at step ${i}:`, err);
|
|
1696
|
+
throw err;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
return current;
|
|
1702
|
+
};
|
|
1703
|
+
|
|
1704
|
+
// Helper to wrap a machine with tracking properties and wrapped transitions
|
|
1705
|
+
const wrapMachine = (machine: any): any => {
|
|
1706
|
+
const wrapped: any = { ...machine };
|
|
1707
|
+
|
|
1708
|
+
// Wrap transition functions
|
|
1709
|
+
for (const prop in machine) {
|
|
1710
|
+
if (!Object.prototype.hasOwnProperty.call(machine, prop)) continue;
|
|
1711
|
+
const value = machine[prop];
|
|
1712
|
+
if (typeof value === 'function' && !prop.startsWith('_') && prop !== 'context' &&
|
|
1713
|
+
!['history', 'snapshots', 'clearHistory', 'clearSnapshots', 'clearTimeTravel', 'restoreSnapshot', 'replayFrom'].includes(prop)) {
|
|
1714
|
+
wrapped[prop] = function(this: any, ...args: any[]) {
|
|
1715
|
+
// Record history before transition
|
|
1716
|
+
recordHistory(prop, args);
|
|
1717
|
+
|
|
1718
|
+
const prevContext = wrapped.context;
|
|
1719
|
+
const result = value.apply(this, args);
|
|
1720
|
+
|
|
1721
|
+
// Record snapshot after transition
|
|
1722
|
+
if (result && typeof result === 'object' && 'context' in result) {
|
|
1723
|
+
recordSnapshot(prop, prevContext, result.context);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// Wrap returned machine
|
|
1727
|
+
if (result && typeof result === 'object' && 'context' in result) {
|
|
1728
|
+
return wrapMachine(result);
|
|
1729
|
+
}
|
|
1730
|
+
return result;
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Attach tracking properties
|
|
1736
|
+
return Object.assign(wrapped, {
|
|
1737
|
+
history,
|
|
1738
|
+
snapshots,
|
|
1739
|
+
clearHistory: () => { history.length = 0; entryId = 0; },
|
|
1740
|
+
clearSnapshots: () => { snapshots.length = 0; snapshotId = 0; },
|
|
1741
|
+
clearTimeTravel: () => {
|
|
1742
|
+
history.length = 0;
|
|
1743
|
+
snapshots.length = 0;
|
|
1744
|
+
entryId = 0;
|
|
1745
|
+
snapshotId = 0;
|
|
1746
|
+
},
|
|
1747
|
+
restoreSnapshot,
|
|
1748
|
+
replayFrom
|
|
1749
|
+
});
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
return wrapMachine(machine);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* Compose multiple middleware functions into a single middleware stack.
|
|
1757
|
+
* Middleware is applied left-to-right (first middleware wraps outermost).
|
|
1758
|
+
*
|
|
1759
|
+
* @template M - The machine type
|
|
1760
|
+
* @param machine - The base machine
|
|
1761
|
+
* @param middlewares - Array of middleware functions
|
|
1762
|
+
* @returns A new machine with all middleware applied
|
|
1763
|
+
*
|
|
1764
|
+
* @example
|
|
1765
|
+
* const instrumented = compose(
|
|
1766
|
+
* counter,
|
|
1767
|
+
* withLogging,
|
|
1768
|
+
* withAnalytics(analytics.track),
|
|
1769
|
+
* withValidation(validator),
|
|
1770
|
+
* withErrorReporting(Sentry.captureException)
|
|
1771
|
+
* );
|
|
1772
|
+
*/
|
|
1773
|
+
export function compose<M extends BaseMachine<any>>(
|
|
1774
|
+
machine: M,
|
|
1775
|
+
...middlewares: Array<(m: M) => M>
|
|
1776
|
+
): M {
|
|
1777
|
+
return middlewares.reduce((acc, middleware) => middleware(acc), machine);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Create a reusable middleware function from hooks.
|
|
1782
|
+
* Useful for defining custom middleware that can be applied to multiple machines.
|
|
1783
|
+
*
|
|
1784
|
+
* @template M - The machine type
|
|
1785
|
+
* @param hooks - Middleware hooks configuration
|
|
1786
|
+
* @param options - Middleware options
|
|
1787
|
+
* @returns A middleware function that can be applied to machines
|
|
1788
|
+
*
|
|
1789
|
+
* @example
|
|
1790
|
+
* const myMiddleware = createCustomMiddleware({
|
|
1791
|
+
* before: ({ transitionName }) => console.log('Before:', transitionName),
|
|
1792
|
+
* after: ({ transitionName }) => console.log('After:', transitionName)
|
|
1793
|
+
* });
|
|
1794
|
+
*
|
|
1795
|
+
* const machine1 = myMiddleware(counter1);
|
|
1796
|
+
* const machine2 = myMiddleware(counter2);
|
|
1797
|
+
*/
|
|
1798
|
+
export function createCustomMiddleware<M extends BaseMachine<any>>(
|
|
1799
|
+
hooks: MiddlewareHooks<Context<M>>,
|
|
1800
|
+
options?: MiddlewareOptions
|
|
1801
|
+
): (machine: M) => M {
|
|
1802
|
+
return (machine: M) => createMiddleware(machine, hooks, options);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// =============================================================================
|
|
1806
|
+
// SECTION: TYPESAFE MIDDLEWARE COMPOSITION
|
|
1807
|
+
// =============================================================================
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* A middleware function that transforms a machine.
|
|
1811
|
+
* @template M - The input machine type
|
|
1812
|
+
* @template R - The output machine type (usually extends M)
|
|
1813
|
+
*/
|
|
1814
|
+
/**
|
|
1815
|
+
* A middleware function that transforms a machine.
|
|
1816
|
+
* @template M - Input machine type
|
|
1817
|
+
* @template R - Output machine type (defaults to same as input if no augmentation)
|
|
1818
|
+
*/
|
|
1819
|
+
export type MiddlewareFn<M extends BaseMachine<any>, R extends BaseMachine<any> = M> = (machine: M) => R;
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* A conditional middleware that may or may not be applied based on a predicate.
|
|
1823
|
+
* @template M - The machine type
|
|
1824
|
+
*/
|
|
1825
|
+
export type ConditionalMiddleware<M extends BaseMachine<any>> = {
|
|
1826
|
+
/** The middleware function to apply */
|
|
1827
|
+
middleware: MiddlewareFn<M>;
|
|
1828
|
+
/** Predicate function that determines if the middleware should be applied */
|
|
1829
|
+
when: (machine: M) => boolean;
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* A named middleware entry for registry-based composition.
|
|
1834
|
+
* @template M - The machine type
|
|
1835
|
+
*/
|
|
1836
|
+
export type NamedMiddleware<M extends BaseMachine<any>> = {
|
|
1837
|
+
/** Unique name for the middleware */
|
|
1838
|
+
name: string;
|
|
1839
|
+
/** The middleware function */
|
|
1840
|
+
middleware: MiddlewareFn<M>;
|
|
1841
|
+
/** Optional description */
|
|
1842
|
+
description?: string;
|
|
1843
|
+
/** Optional priority for ordering (higher numbers = applied later) */
|
|
1844
|
+
priority?: number;
|
|
1845
|
+
};
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* Configuration for middleware pipeline execution.
|
|
1849
|
+
*/
|
|
1850
|
+
export interface PipelineConfig {
|
|
1851
|
+
/** Whether to continue executing remaining middlewares if one fails */
|
|
1852
|
+
continueOnError?: boolean;
|
|
1853
|
+
/** Whether to log errors to console */
|
|
1854
|
+
logErrors?: boolean;
|
|
1855
|
+
/** Custom error handler */
|
|
1856
|
+
onError?: (error: Error, middlewareName?: string) => void;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/**
|
|
1860
|
+
* Result of pipeline execution.
|
|
1861
|
+
*/
|
|
1862
|
+
export interface PipelineResult<M extends BaseMachine<any>> {
|
|
1863
|
+
/** The final machine after all middlewares */
|
|
1864
|
+
machine: M;
|
|
1865
|
+
/** Any errors that occurred during execution */
|
|
1866
|
+
errors: Array<{ error: Error; middlewareIndex: number; middlewareName?: string }>;
|
|
1867
|
+
/** Whether the pipeline completed successfully */
|
|
1868
|
+
success: boolean;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Compose multiple middlewares with improved type inference.
|
|
1873
|
+
* This is a more typesafe version of the basic compose function.
|
|
1874
|
+
*
|
|
1875
|
+
* @template M - The initial machine type
|
|
1876
|
+
* @template Ms - Array of middleware functions
|
|
1877
|
+
* @param machine - The initial machine
|
|
1878
|
+
* @param middlewares - Array of middleware functions to apply
|
|
1879
|
+
* @returns The machine with all middlewares applied
|
|
1880
|
+
*
|
|
1881
|
+
* @example
|
|
1882
|
+
* const enhanced = composeTyped(
|
|
1883
|
+
* counter,
|
|
1884
|
+
* withHistory(),
|
|
1885
|
+
* withSnapshot(),
|
|
1886
|
+
* withTimeTravel()
|
|
1887
|
+
* );
|
|
1888
|
+
*/
|
|
1889
|
+
/**
|
|
1890
|
+
* Recursively applies middlewares to infer the final machine type.
|
|
1891
|
+
* Provides precise type inference for middleware composition chains.
|
|
1892
|
+
*/
|
|
1893
|
+
type ComposeResult<
|
|
1894
|
+
M extends BaseMachine<any>,
|
|
1895
|
+
Ms extends readonly MiddlewareFn<any, any>[]
|
|
1896
|
+
> = Ms extends readonly []
|
|
1897
|
+
? M
|
|
1898
|
+
: Ms extends readonly [infer First, ...infer Rest]
|
|
1899
|
+
? First extends MiddlewareFn<any, infer Output>
|
|
1900
|
+
? Rest extends readonly MiddlewareFn<any, any>[]
|
|
1901
|
+
? ComposeResult<Output, Rest>
|
|
1902
|
+
: Output
|
|
1903
|
+
: M
|
|
1904
|
+
: M;
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* Type-safe middleware composition with perfect inference.
|
|
1908
|
+
* Composes multiple middlewares into a single transformation chain.
|
|
1909
|
+
*
|
|
1910
|
+
* @template M - The input machine type
|
|
1911
|
+
* @template Ms - Array of middleware functions
|
|
1912
|
+
* @param machine - The machine to enhance
|
|
1913
|
+
* @param middlewares - Middleware functions to apply in order
|
|
1914
|
+
* @returns The machine with all middlewares applied, with precise type inference
|
|
1915
|
+
*
|
|
1916
|
+
* @example
|
|
1917
|
+
* ```typescript
|
|
1918
|
+
* const enhanced = composeTyped(
|
|
1919
|
+
* counter,
|
|
1920
|
+
* withHistory(),
|
|
1921
|
+
* withSnapshot(),
|
|
1922
|
+
* withTimeTravel()
|
|
1923
|
+
* );
|
|
1924
|
+
* // enhanced: WithTimeTravel<WithSnapshot<WithHistory<Counter>>>
|
|
1925
|
+
* // Perfect IntelliSense for all methods and properties
|
|
1926
|
+
* ```
|
|
1927
|
+
*/
|
|
1928
|
+
export function composeTyped<
|
|
1929
|
+
M extends BaseMachine<any>,
|
|
1930
|
+
Ms extends readonly MiddlewareFn<any, any>[]
|
|
1931
|
+
>(
|
|
1932
|
+
machine: M,
|
|
1933
|
+
...middlewares: Ms
|
|
1934
|
+
): ComposeResult<M, Ms> {
|
|
1935
|
+
return middlewares.reduce((acc, middleware) => middleware(acc), machine) as ComposeResult<M, Ms>;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Type-safe middleware composition with fluent API.
|
|
1940
|
+
* Allows building middleware chains with method chaining.
|
|
1941
|
+
*
|
|
1942
|
+
* @example
|
|
1943
|
+
* ```typescript
|
|
1944
|
+
* const enhanced = chain(counter)
|
|
1945
|
+
* .with(withHistory())
|
|
1946
|
+
* .with(withSnapshot())
|
|
1947
|
+
* .with(withTimeTravel())
|
|
1948
|
+
* .build();
|
|
1949
|
+
* ```
|
|
1950
|
+
*/
|
|
1951
|
+
export function chain<M extends BaseMachine<any>>(machine: M) {
|
|
1952
|
+
return new MiddlewareChainBuilder(machine);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Fluent middleware composer for building complex middleware chains.
|
|
1957
|
+
* Provides excellent TypeScript inference and IntelliSense.
|
|
1958
|
+
*/
|
|
1959
|
+
class MiddlewareChainBuilder<M extends BaseMachine<any>> {
|
|
1960
|
+
constructor(private machine: M) {}
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* Add a middleware to the composition chain.
|
|
1964
|
+
* @param middleware - The middleware function to add
|
|
1965
|
+
* @returns A new composer with the middleware applied
|
|
1966
|
+
*/
|
|
1967
|
+
with<M2 extends MiddlewareFn<any, any>>(
|
|
1968
|
+
middleware: M2
|
|
1969
|
+
): MiddlewareChainBuilder<ReturnType<M2> extends BaseMachine<any> ? ReturnType<M2> : M> {
|
|
1970
|
+
const result = middleware(this.machine);
|
|
1971
|
+
return new MiddlewareChainBuilder(result as any);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* Build the final machine with all middlewares applied.
|
|
1976
|
+
*/
|
|
1977
|
+
build(): M {
|
|
1978
|
+
return this.machine;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* Common middleware combination types for better DX.
|
|
1984
|
+
* These types help with inference when using popular middleware combinations.
|
|
1985
|
+
*/
|
|
1986
|
+
export type WithDebugging<M extends BaseMachine<any>> = WithTimeTravel<WithSnapshot<WithHistory<M>>>;
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* Convenience function for the most common debugging middleware stack.
|
|
1990
|
+
* Combines history, snapshots, and time travel for full debugging capabilities.
|
|
1991
|
+
*
|
|
1992
|
+
* @example
|
|
1993
|
+
* ```typescript
|
|
1994
|
+
* const debugMachine = withDebugging(counter);
|
|
1995
|
+
* debugMachine.increment();
|
|
1996
|
+
* debugMachine.history; // Full transition history
|
|
1997
|
+
* debugMachine.snapshots; // Context snapshots
|
|
1998
|
+
* debugMachine.replayFrom(0); // Time travel
|
|
1999
|
+
* ```
|
|
2000
|
+
*/
|
|
2001
|
+
export function withDebugging<M extends BaseMachine<any>>(machine: M): WithDebugging<M> {
|
|
2002
|
+
return withTimeTravel(withSnapshot(withHistory(machine)));
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* Create a middleware pipeline with error handling and conditional execution.
|
|
2007
|
+
*
|
|
2008
|
+
* @template M - The machine type
|
|
2009
|
+
* @param config - Pipeline configuration
|
|
2010
|
+
* @returns A function that executes middlewares in a pipeline
|
|
2011
|
+
*
|
|
2012
|
+
* @example
|
|
2013
|
+
* const pipeline = createPipeline({ continueOnError: true });
|
|
2014
|
+
*
|
|
2015
|
+
* const result = pipeline(
|
|
2016
|
+
* counter,
|
|
2017
|
+
* withHistory(),
|
|
2018
|
+
* withSnapshot(),
|
|
2019
|
+
* { middleware: withLogging(), when: (m) => m.context.debug }
|
|
2020
|
+
* );
|
|
2021
|
+
*/
|
|
2022
|
+
export function createPipeline<M extends BaseMachine<any>>(
|
|
2023
|
+
config: PipelineConfig = {}
|
|
2024
|
+
): {
|
|
2025
|
+
<Ms extends Array<MiddlewareFn<M> | ConditionalMiddleware<M>>>(
|
|
2026
|
+
machine: M,
|
|
2027
|
+
...middlewares: Ms
|
|
2028
|
+
): PipelineResult<M>;
|
|
2029
|
+
} {
|
|
2030
|
+
const {
|
|
2031
|
+
continueOnError = false,
|
|
2032
|
+
logErrors = true,
|
|
2033
|
+
onError
|
|
2034
|
+
} = config;
|
|
2035
|
+
|
|
2036
|
+
return (machine: M, ...middlewares: Array<MiddlewareFn<M> | ConditionalMiddleware<M>>): PipelineResult<M> => {
|
|
2037
|
+
let currentMachine = machine;
|
|
2038
|
+
const errors: Array<{ error: Error; middlewareIndex: number; middlewareName?: string }> = [];
|
|
2039
|
+
|
|
2040
|
+
for (let i = 0; i < middlewares.length; i++) {
|
|
2041
|
+
const middleware = middlewares[i];
|
|
2042
|
+
|
|
2043
|
+
try {
|
|
2044
|
+
// Handle conditional middleware
|
|
2045
|
+
if ('middleware' in middleware && 'when' in middleware) {
|
|
2046
|
+
if (!middleware.when(currentMachine)) {
|
|
2047
|
+
continue; // Skip this middleware
|
|
2048
|
+
}
|
|
2049
|
+
currentMachine = middleware.middleware(currentMachine);
|
|
2050
|
+
} else {
|
|
2051
|
+
// Regular middleware
|
|
2052
|
+
currentMachine = (middleware as MiddlewareFn<M>)(currentMachine);
|
|
2053
|
+
}
|
|
2054
|
+
} catch (error) {
|
|
2055
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2056
|
+
errors.push({ error: err, middlewareIndex: i });
|
|
2057
|
+
|
|
2058
|
+
if (logErrors) {
|
|
2059
|
+
console.error(`Middleware pipeline error at index ${i}:`, err);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
onError?.(err, `middleware-${i}`);
|
|
2063
|
+
|
|
2064
|
+
if (!continueOnError) {
|
|
2065
|
+
break;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
return {
|
|
2071
|
+
machine: currentMachine,
|
|
2072
|
+
errors,
|
|
2073
|
+
success: errors.length === 0
|
|
2074
|
+
};
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* Create a middleware registry for named middleware composition.
|
|
2080
|
+
* Useful for building complex middleware stacks from reusable components.
|
|
2081
|
+
*
|
|
2082
|
+
* @template M - The machine type
|
|
2083
|
+
*
|
|
2084
|
+
* @example
|
|
2085
|
+
* const registry = createMiddlewareRegistry<CounterMachine>()
|
|
2086
|
+
* .register('history', withHistory(), 'Track state changes')
|
|
2087
|
+
* .register('snapshot', withSnapshot(), 'Take context snapshots', 10)
|
|
2088
|
+
* .register('timeTravel', withTimeTravel(), 'Enable time travel debugging', 20);
|
|
2089
|
+
*
|
|
2090
|
+
* const machine = registry.apply(counter, ['history', 'snapshot', 'timeTravel']);
|
|
2091
|
+
*/
|
|
2092
|
+
export function createMiddlewareRegistry<M extends BaseMachine<any>>() {
|
|
2093
|
+
const registry = new Map<string, NamedMiddleware<M>>();
|
|
2094
|
+
|
|
2095
|
+
return {
|
|
2096
|
+
/**
|
|
2097
|
+
* Register a middleware with a name and optional metadata.
|
|
2098
|
+
*/
|
|
2099
|
+
register(
|
|
2100
|
+
name: string,
|
|
2101
|
+
middleware: MiddlewareFn<M>,
|
|
2102
|
+
description?: string,
|
|
2103
|
+
priority?: number
|
|
2104
|
+
): typeof this {
|
|
2105
|
+
if (registry.has(name)) {
|
|
2106
|
+
throw new Error(`Middleware '${name}' is already registered`);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
registry.set(name, { name, middleware, description, priority });
|
|
2110
|
+
return this;
|
|
2111
|
+
},
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* Unregister a middleware by name.
|
|
2115
|
+
*/
|
|
2116
|
+
unregister(name: string): boolean {
|
|
2117
|
+
return registry.delete(name);
|
|
2118
|
+
},
|
|
2119
|
+
|
|
2120
|
+
/**
|
|
2121
|
+
* Check if a middleware is registered.
|
|
2122
|
+
*/
|
|
2123
|
+
has(name: string): boolean {
|
|
2124
|
+
return registry.has(name);
|
|
2125
|
+
},
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* Get a registered middleware by name.
|
|
2129
|
+
*/
|
|
2130
|
+
get(name: string): NamedMiddleware<M> | undefined {
|
|
2131
|
+
return registry.get(name);
|
|
2132
|
+
},
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* List all registered middlewares.
|
|
2136
|
+
*/
|
|
2137
|
+
list(): NamedMiddleware<M>[] {
|
|
2138
|
+
return Array.from(registry.values()).sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
2139
|
+
},
|
|
2140
|
+
|
|
2141
|
+
/**
|
|
2142
|
+
* Apply a selection of registered middlewares to a machine.
|
|
2143
|
+
* Middlewares are applied in priority order (lowest to highest).
|
|
2144
|
+
*/
|
|
2145
|
+
apply(machine: M, middlewareNames: string[]): M {
|
|
2146
|
+
const middlewares = middlewareNames
|
|
2147
|
+
.map(name => {
|
|
2148
|
+
const entry = registry.get(name);
|
|
2149
|
+
if (!entry) {
|
|
2150
|
+
throw new Error(`Middleware '${name}' is not registered`);
|
|
2151
|
+
}
|
|
2152
|
+
return entry;
|
|
2153
|
+
})
|
|
2154
|
+
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
|
|
2155
|
+
|
|
2156
|
+
return composeTyped(machine, ...middlewares.map(m => m.middleware));
|
|
2157
|
+
},
|
|
2158
|
+
|
|
2159
|
+
/**
|
|
2160
|
+
* Apply all registered middlewares to a machine in priority order.
|
|
2161
|
+
*/
|
|
2162
|
+
applyAll(machine: M): M {
|
|
2163
|
+
const middlewares = this.list();
|
|
2164
|
+
return composeTyped(machine, ...middlewares.map(m => m.middleware));
|
|
2165
|
+
}
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Create a conditional middleware that only applies when a predicate is true.
|
|
2171
|
+
*
|
|
2172
|
+
* @template M - The machine type
|
|
2173
|
+
* @param middleware - The middleware to conditionally apply
|
|
2174
|
+
* @param predicate - Function that determines when to apply the middleware
|
|
2175
|
+
* @returns A conditional middleware that can be called directly or used in pipelines
|
|
2176
|
+
*
|
|
2177
|
+
* @example
|
|
2178
|
+
* const debugMiddleware = when(
|
|
2179
|
+
* withTimeTravel(),
|
|
2180
|
+
* (machine) => machine.context.debugMode
|
|
2181
|
+
* );
|
|
2182
|
+
*
|
|
2183
|
+
* // Can be called directly
|
|
2184
|
+
* const machine = debugMiddleware(baseMachine);
|
|
2185
|
+
*
|
|
2186
|
+
* // Can also be used in pipelines
|
|
2187
|
+
* const pipeline = createPipeline();
|
|
2188
|
+
* const result = pipeline(machine, debugMiddleware);
|
|
2189
|
+
*/
|
|
2190
|
+
export function when<M extends BaseMachine<any>>(
|
|
2191
|
+
middleware: MiddlewareFn<M>,
|
|
2192
|
+
predicate: (machine: M) => boolean
|
|
2193
|
+
): ConditionalMiddleware<M> & MiddlewareFn<M> {
|
|
2194
|
+
const conditional: ConditionalMiddleware<M> & MiddlewareFn<M> = function(machine: M) {
|
|
2195
|
+
return predicate(machine) ? middleware(machine) : machine;
|
|
2196
|
+
};
|
|
2197
|
+
|
|
2198
|
+
conditional.middleware = middleware;
|
|
2199
|
+
conditional.when = predicate;
|
|
2200
|
+
|
|
2201
|
+
return conditional;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/**
|
|
2205
|
+
* Create a middleware that only applies in development mode.
|
|
2206
|
+
*
|
|
2207
|
+
* @template M - The machine type
|
|
2208
|
+
* @param middleware - The middleware to apply in development
|
|
2209
|
+
* @returns A conditional middleware for development mode
|
|
2210
|
+
*
|
|
2211
|
+
* @example
|
|
2212
|
+
* const devMachine = composeTyped(
|
|
2213
|
+
* counter,
|
|
2214
|
+
* inDevelopment(withTimeTravel())
|
|
2215
|
+
* );
|
|
2216
|
+
*/
|
|
2217
|
+
export function inDevelopment<M extends BaseMachine<any>>(
|
|
2218
|
+
middleware: MiddlewareFn<M>
|
|
2219
|
+
): ConditionalMiddleware<M> & MiddlewareFn<M> {
|
|
2220
|
+
return when(middleware, () => {
|
|
2221
|
+
return typeof process !== 'undefined'
|
|
2222
|
+
? process.env.NODE_ENV === 'development'
|
|
2223
|
+
: typeof window !== 'undefined'
|
|
2224
|
+
? !window.location.hostname.includes('production')
|
|
2225
|
+
: false;
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* Create a middleware that only applies when a context property matches a value.
|
|
2231
|
+
*
|
|
2232
|
+
* @template M - The machine type
|
|
2233
|
+
* @template K - The context key
|
|
2234
|
+
* @param key - The context property key
|
|
2235
|
+
* @param value - The value to match
|
|
2236
|
+
* @param middleware - The middleware to apply when the condition matches
|
|
2237
|
+
* @returns A conditional middleware
|
|
2238
|
+
*
|
|
2239
|
+
* @example
|
|
2240
|
+
* const adminMachine = composeTyped(
|
|
2241
|
+
* userMachine,
|
|
2242
|
+
* whenContext('role', 'admin', withAdminFeatures())
|
|
2243
|
+
* );
|
|
2244
|
+
*/
|
|
2245
|
+
export function whenContext<M extends BaseMachine<any>, K extends keyof Context<M>>(
|
|
2246
|
+
key: K,
|
|
2247
|
+
value: Context<M>[K],
|
|
2248
|
+
middleware: MiddlewareFn<M>
|
|
2249
|
+
): ConditionalMiddleware<M> & MiddlewareFn<M> {
|
|
2250
|
+
return when(middleware, (machine) => machine.context[key] === value);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
/**
|
|
2254
|
+
* Combine multiple middlewares with short-circuiting.
|
|
2255
|
+
* If any middleware returns a different type, the composition stops.
|
|
2256
|
+
*
|
|
2257
|
+
* @template M - The machine type
|
|
2258
|
+
* @param middlewares - Array of middlewares to combine
|
|
2259
|
+
* @returns A combined middleware function
|
|
2260
|
+
*
|
|
2261
|
+
* @example
|
|
2262
|
+
* const combined = combine(
|
|
2263
|
+
* withHistory(),
|
|
2264
|
+
* withSnapshot(),
|
|
2265
|
+
* withValidation()
|
|
2266
|
+
* );
|
|
2267
|
+
*/
|
|
2268
|
+
export function combine<M extends BaseMachine<any>>(
|
|
2269
|
+
...middlewares: Array<MiddlewareFn<M>>
|
|
2270
|
+
): MiddlewareFn<M> {
|
|
2271
|
+
return (machine: M) => composeTyped(machine, ...middlewares);
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* Create a middleware that applies different middlewares based on context.
|
|
2276
|
+
*
|
|
2277
|
+
* @template M - The machine type
|
|
2278
|
+
* @param branches - Array of [predicate, middleware] pairs
|
|
2279
|
+
* @param fallback - Optional fallback middleware if no predicates match
|
|
2280
|
+
* @returns A branching middleware
|
|
2281
|
+
*
|
|
2282
|
+
* @example
|
|
2283
|
+
* const smartMiddleware = branch(
|
|
2284
|
+
* [(m) => m.context.userType === 'admin', withAdminFeatures()],
|
|
2285
|
+
* [(m) => m.context.debug, withTimeTravel()],
|
|
2286
|
+
* withBasicLogging() // fallback
|
|
2287
|
+
* );
|
|
2288
|
+
*/
|
|
2289
|
+
export function branch<M extends BaseMachine<any>>(
|
|
2290
|
+
branches: Array<[predicate: (machine: M) => boolean, middleware: MiddlewareFn<M>]>,
|
|
2291
|
+
fallback?: MiddlewareFn<M>
|
|
2292
|
+
): MiddlewareFn<M> {
|
|
2293
|
+
return (machine: M) => {
|
|
2294
|
+
for (const [predicate, middleware] of branches) {
|
|
2295
|
+
if (predicate(machine)) {
|
|
2296
|
+
return middleware(machine);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
return fallback ? fallback(machine) : machine;
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/**
|
|
2304
|
+
* Type guard to check if a value is a middleware function.
|
|
2305
|
+
*/
|
|
2306
|
+
export function isMiddlewareFn<M extends BaseMachine<any>>(
|
|
2307
|
+
value: any
|
|
2308
|
+
): value is MiddlewareFn<M> {
|
|
2309
|
+
return typeof value === 'function' && value.length === 1;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/**
|
|
2313
|
+
* Type guard to check if a value is a conditional middleware.
|
|
2314
|
+
*/
|
|
2315
|
+
export function isConditionalMiddleware<M extends BaseMachine<any>>(
|
|
2316
|
+
value: any
|
|
2317
|
+
): value is ConditionalMiddleware<M> {
|
|
2318
|
+
return (
|
|
2319
|
+
value !== null &&
|
|
2320
|
+
'middleware' in value &&
|
|
2321
|
+
'when' in value &&
|
|
2322
|
+
isMiddlewareFn(value.middleware) &&
|
|
2323
|
+
typeof value.when === 'function'
|
|
2324
|
+
);
|
|
2325
|
+
}
|