Skip to content

packages/core/src/features/machines/runtime.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import { assertDirectSignalWriteAllowed, signal } from '../../lib/signals/graph.js';
2 import { assignmentOf } from './assign.js';
3 import type {
4   CreateMachineOptions,
5   MachineAction,
6   MachineActionContext,
7   MachineActions,
8   MachineActor,
9   MachineDefinition,
10   MachineEvent,
11   MachineInvocation,
12   MachineSnapshot,
13   MachineState,
14   MachineStatus,
15   MachineTaskDoneEvent,
16   MachineTaskErrorEvent,
17   MachineTransition,
18   MachineTransitionConfig,
19 } from './types.js';
20 
21 interface CompiledMachine {
22   readonly states: ReadonlyMap<string, CompiledState>;
23   readonly on: ReadonlyMap<string, unknown>;
24 }
25 
26 interface CompiledState {
27   readonly definition: MachineState<object, MachineEvent, unknown, string>;
28   readonly on: ReadonlyMap<string, unknown>;
29 }
30 
31 const compiledMachines = new WeakMap<object, CompiledMachine>();
32 
33 /**
34  * Validates and records a reusable flat machine definition. Calling this function performs no
35  * work and creates no actor; use {@link createMachine} or `useMachine` to execute it.
36  * @param definition - Reusable machine behavior to validate and normalize.
37  * @returns The supplied definition, suitable for actor creation.
38  * @see {@link MachineDefinition}
39  */
40 export function defineMachine<Context, Event extends MachineEvent, Input = undefined, State extends string = string>(
41   definition: MachineDefinition<Context, Event, Input, State>,
42 ): MachineDefinition<Context, Event, Input, State> {
43   compile(definition);
44   return definition;
45 }
46 
47 /**
48  * Creates an explicitly owned, initially idle machine actor. Call `start()` when ownership begins
49  * and `stop()` when it ends. The actor has no renderer, scheduler, or animation dependency.
50  * @param definition - Reusable behavior previously authored with {@link defineMachine}.
51  * @param options - Immutable input supplied to this actor.
52  * @returns An idle actor that owns no task until it starts.
53  * @see {@link MachineActor}
54  */
55 export function createMachine<Context, Event extends MachineEvent, Input, State extends string = string>(
56   definition: MachineDefinition<Context, Event, Input, State>,
57   options: CreateMachineOptions<Input>,
58 ): MachineActor<Context, Event, Input, State> {
59   const compiled = compile(definition);
60   const input = options.input;
61   const context = typeof definition.context === 'function'
62     ? (definition.context as (value: Readonly<{ input: Input }>) => Context)({ input })
63     : definition.context;
64   let currentContext = context;
65   let value = definition.initial;
66   let status: MachineStatus = 'not-started';
67   let error: unknown | undefined;
68   let activeEntry: StateEntry<Context, Event, Input, State> | undefined;
69   let entryGeneration = 0;
70   const initial = snapshot(value, currentContext, status, error);
71   const writableSnapshot = signal(initial, { debugName: 'machine.snapshot' });
72   const publicSnapshot = writableSnapshot.asReadonly();
73   const queue: QueueItem<Context, Event, Input, State>[] = [];
74   let queueHead = 0;
75   let processing = false;
76 
77   const actor: MachineActor<Context, Event, Input, State> = {
78     snapshot: publicSnapshot,
79     start(): void {
80       if (status !== 'not-started') return;
81       assertDirectSignalWriteAllowed(writableSnapshot);
82       status = 'active';
83       // Entry actions may send events. Keep those queued until the initial entry has published
84       // and started its owned work, just like an ordinary transition.
85       processing = true;
86       try {
87         runActions(stateDefinition().entry as unknown as MachineActions<Context, Event, Input>, undefined);
88         if (status !== 'active') return;
89         publish();
90         startInvocations(undefined);
91       } catch (cause) {
92         fail(cause);
93       } finally {
94         processing = false;
95       }
96       drain();
97     },
98     send(event: Event): void {
99       if (status !== 'active') return;
100       assertDirectSignalWriteAllowed(writableSnapshot);
101       queue.push({ kind: 'event', event });
102       if (!processing) drain();
103     },
104     stop(): void {
105       if (status === 'stopped' || status === 'error') return;
106       assertDirectSignalWriteAllowed(writableSnapshot);
107       status = 'stopped';
108       try {
109         cancelEntry();
110       } finally {
111         releaseQueue();
112         publish();
113       }
114     },
115   };
116 
117   return actor;
118 
119   function stateDefinition(): MachineState<Context, Event, Input, State> {
120     const state = compiled.states.get(value);
121     if (!state) throw new Error(`Machine entered unknown state ${JSON.stringify(value)}.`);
122     return state.definition as unknown as MachineState<Context, Event, Input, State>;
123   }
124 
125   function drain(): void {
126     if (processing || status !== 'active') return;
127     processing = true;
128     try {
129       while (status === 'active' && queueHead < queue.length) {
130         const item = queue[queueHead];
131         queue[queueHead++] = undefined as never;
132         if (item.kind === 'event') processEvent(item.event);
133         else processInvocation(item);
134       }
135     } catch (cause) {
136       fail(cause);
137     } finally {
138       processing = false;
139       if (queueHead === queue.length) releaseQueue();
140     }
141   }
142 
143   function processEvent(event: Event): void {
144     const compiledState = compiled.states.get(value)!;
145     const local = compiledState.on;
146     // Local declaration intentionally shadows the machine fallback even if every guard rejects.
147     const source = local.has(event.type) ? local.get(event.type) : compiled.on.get(event.type);
148     if (source === undefined) return;
149     const selected = selectTransition(source as MachineTransition<Context, Event, Input, State, Event>, event);
150     if (selected !== undefined) applyTransition(selected, event);
151   }
152 
153   function processInvocation(item: InvocationQueueItem<Context, Event, Input, State>): void {
154     if (!item.entry.active || activeEntry !== item.entry || status !== 'active') return;
155     const source = item.kind === 'done' ? item.invocation.onDone : item.invocation.onError;
156     if (source === undefined) {
157       if (item.kind === 'error') fail(item.error);
158       return;
159     }
160     const event = item.kind === 'done'
161       ? { type: '@pibbl/machine.done', output: item.output } as MachineTaskDoneEvent
162       : { type: '@pibbl/machine.error', error: item.error } as MachineTaskErrorEvent;
163     const selected = selectTransition<MachineEvent>(
164       source as unknown as MachineTransition<Context, MachineEvent, Input, State, Event>,
165       event,
166     );
167     if (selected !== undefined) applyTransition<MachineEvent>(selected, event);
168   }
169 
170   function selectTransition<SelectedEvent extends MachineEvent>(
171     source: MachineTransition<Context, SelectedEvent, Input, State, Event>,
172     event: SelectedEvent,
173   ): MachineTransitionConfig<Context, SelectedEvent, Input, State, Event> | undefined {
174     if (typeof source === 'string') return { target: source };
175     const candidates = Array.isArray(source as unknown) ? source as readonly MachineTransitionConfig<Context, SelectedEvent, Input, State, Event>[] : undefined;
176     if (!candidates) {
177       const candidate = source as MachineTransitionConfig<Context, SelectedEvent, Input, State, Event>;
178       return !candidate.guard || candidate.guard(actionContext(event)) ? candidate : undefined;
179     }
180     for (const candidate of candidates) {
181       if (!candidate.guard || candidate.guard(actionContext(event))) return candidate;
182     }
183     return undefined;
184   }
185 
186   function applyTransition<SelectedEvent extends MachineEvent>(
187     transition: MachineTransitionConfig<Context, SelectedEvent, Input, State, Event>,
188     event: SelectedEvent,
189   ): void {
190     const target = transition.target ?? value;
191     const changesState = target !== value || transition.reenter === true;
192     if (changesState) {
193       cancelEntry();
194       runActions(stateDefinition().exit, event as unknown as Event);
195       if (status !== 'active') return;
196     }
197     const contextChanged = runActions(transition.actions, event);
198     if (status !== 'active') return;
199     if (changesState) {
200       value = target;
201       runActions(stateDefinition().entry as unknown as MachineActions<Context, Event, Input>, event as unknown as Event);
202     }
203     // Empty handlers and no-op action handlers preserve snapshot identity.
204     if (changesState || contextChanged) publish();
205     if (changesState) startInvocations(event as unknown as Event);
206   }
207 
208   function actionContext<SelectedEvent extends MachineEvent>(event: SelectedEvent | undefined): MachineActionContext<Context, SelectedEvent, Input, Event> {
209     return {
210       get context() { return currentContext; },
211       event: event as SelectedEvent,
212       input,
213       send: actor.send,
214     };
215   }
216 
217   function runActions<SelectedEvent extends MachineEvent>(
218     actions: MachineAction<Context, SelectedEvent, Input, Event> | readonly MachineAction<Context, SelectedEvent, Input, Event>[] | undefined,
219     event: SelectedEvent | undefined,
220   ): boolean {
221     if (actions === undefined) return false;
222     const list = Array.isArray(actions) ? actions : [actions];
223     let changed = false;
224     for (const action of list) {
225       const contextForAction = actionContext(event);
226       const assignment = assignmentOf(action);
227       if (assignment) {
228         const patch = assignment(contextForAction as unknown as MachineActionContext<object, MachineEvent, unknown>);
229         if (patch === null || typeof patch !== 'object') throw new TypeError('assign() must return a context object patch.');
230         if (patchChangesContext(currentContext, patch)) {
231           currentContext = Object.assign({}, currentContext as object, patch) as Context;
232           changed = true;
233         }
234       } else {
235         action(contextForAction);
236       }
237       if (status !== 'active') return changed;
238     }
239     return changed;
240   }
241 
242   function startInvocations(event: Event | undefined): void {
243     const definitions = stateDefinition().invoke;
244     if (!definitions) return;
245     const entry: StateEntry<Context, Event, Input, State> = {
246       active: true,
247       generation: ++entryGeneration,
248       controllers: [],
249     };
250     activeEntry = entry;
251     const list = Array.isArray(definitions) ? definitions : [definitions];
252     for (const invocation of list) {
253       startInvocation(entry, invocation, event);
254       if (status !== 'active' || !entry.active || activeEntry !== entry) break;
255     }
256   }
257 
258   function startInvocation(
259     entry: StateEntry<Context, Event, Input, State>,
260     invocation: MachineInvocation<Context, Event, Input, State>,
261     event: Event | undefined,
262   ): void {
263     const controller = new AbortController();
264     const owned: OwnedInvocation<Context, Event, Input, State> = { controller, invocation, cleanup: undefined };
265     entry.controllers.push(owned);
266     let returned: void | (() => void) | Promise<unknown>;
267     try {
268       returned = invocation.task({
269         context: currentContext,
270         event,
271         input,
272         signal: controller.signal,
273         send(queuedEvent: Event): void {
274           if (status !== 'active' || !entry.active || activeEntry !== entry || controller.signal.aborted) return;
275           actor.send(queuedEvent);
276         },
277       });
278     } catch (cause) {
279       queueInvocation({ kind: 'error', entry, invocation, error: cause });
280       return;
281     }
282     if (typeof returned === 'function') {
283       owned.cleanup = returned;
284     } else if (returned && typeof (returned as Promise<unknown>).then === 'function') {
285       Promise.resolve(returned).then(
286         output => queueInvocation({ kind: 'done', entry, invocation, output }),
287         cause => queueInvocation({ kind: 'error', entry, invocation, error: cause }),
288       );
289     }
290   }
291 
292   function queueInvocation(item: InvocationQueueItem<Context, Event, Input, State>): void {
293     if (status !== 'active' || !item.entry.active || activeEntry !== item.entry) return;
294     queue.push(item);
295     if (!processing) drain();
296   }
297 
298   function cancelEntry(): void {
299     const entry = activeEntry;
300     activeEntry = undefined;
301     if (!entry) return;
302     entry.active = false;
303     const failures: unknown[] = [];
304     for (const owned of entry.controllers) {
305       try { owned.controller.abort(); } catch (cause) { failures.push(cause); }
306       const cleanup = owned.cleanup;
307       owned.cleanup = undefined;
308       if (cleanup) {
309         try { cleanup(); } catch (cause) { failures.push(cause); }
310       }
311     }
312     entry.controllers.length = 0;
313     if (failures.length === 1) throw failures[0];
314     if (failures.length > 1) throw new AggregateError(failures, 'Machine state task cleanup failed.');
315   }
316 
317   function fail(cause: unknown): void {
318     if (status === 'error' || status === 'stopped') return;
319     status = 'error';
320     error = cause;
321     try { cancelEntry(); } catch (cleanupCause) { error = new AggregateError([cause, cleanupCause], 'Machine failed while cleaning up a state task.'); }
322     releaseQueue();
323     publish();
324   }
325 
326   function releaseQueue(): void {
327     for (let index = queueHead; index < queue.length; index++) queue[index] = undefined as never;
328     queue.length = 0;
329     queueHead = 0;
330   }
331 
332   function publish(): void {
333     writableSnapshot.set(snapshot(value, currentContext, status, error));
334   }
335 }
336 
337 function snapshot<Context, State extends string>(
338   value: State,
339   context: Context,
340   status: MachineStatus,
341   error: unknown | undefined,
342 ): MachineSnapshot<Context, State> {
343   return Object.freeze({ value, context, status, error, matches: (candidate: State) => candidate === value });
344 }
345 
346 /** Avoid replacing context or publishing a snapshot for an assignment that changes no own key. */
347 function patchChangesContext(context: unknown, patch: object): boolean {
348   if (context === null || typeof context !== 'object') return true;
349   for (const key of Reflect.ownKeys(patch)) {
350     if (!Object.is((context as Record<PropertyKey, unknown>)[key], (patch as Record<PropertyKey, unknown>)[key])) return true;
351   }
352   return false;
353 }
354 
355 function compile<Context, Event extends MachineEvent, Input, State extends string>(
356   definition: MachineDefinition<Context, Event, Input, State>,
357 ): CompiledMachine {
358   const known = compiledMachines.get(definition);
359   if (known) return known;
360   if (!definition || typeof definition !== 'object') throw new TypeError('Machine definition must be an object.');
361   if (!definition.states || typeof definition.states !== 'object') throw new TypeError('Machine definition requires a states object.');
362   if (!Object.prototype.hasOwnProperty.call(definition.states, definition.initial)) throw new RangeError(`Machine initial state ${JSON.stringify(definition.initial)} is not declared.`);
363   const names = new Set(Object.keys(definition.states));
364   const states = new Map<string, CompiledState>();
365   for (const [name, rawValue] of Object.entries(definition.states)) {
366     const raw = rawValue as MachineState<Context, Event, Input, State>;
367     if (!raw || typeof raw !== 'object') throw new TypeError(`Machine state ${JSON.stringify(name)} must be an object.`);
368     validateHandlers(raw.on, names, `state ${JSON.stringify(name)}`);
369     validateInvocations(raw.invoke, names, `state ${JSON.stringify(name)}`);
370     const normalized = normalizeState(raw);
371     states.set(name, { definition: normalized as unknown as MachineState<object, MachineEvent, unknown, string>, on: new Map(Object.entries(normalized.on ?? {})) });
372   }
373   validateHandlers(definition.on, names, 'machine');
374   const compiled: CompiledMachine = { states, on: normalizeHandlers(definition.on) };
375   compiledMachines.set(definition, compiled);
376   return compiled;
377 }
378 
379 function normalizeState<Context, Event extends MachineEvent, Input, State extends string>(
380   state: MachineState<Context, Event, Input, State>,
381 ): MachineState<Context, Event, Input, State> {
382   const invocations = state.invoke === undefined ? undefined : (Array.isArray(state.invoke)
383     ? state.invoke.map(invocation => normalizeInvocation(invocation))
384     : normalizeInvocation(state.invoke));
385   return Object.freeze({
386     entry: normalizeActions(state.entry),
387     exit: normalizeActions(state.exit),
388     invoke: invocations === undefined ? undefined : Object.freeze(Array.isArray(invocations) ? invocations : [invocations]),
389     on: Object.freeze(Object.fromEntries(normalizeHandlers(state.on))),
390   }) as unknown as MachineState<Context, Event, Input, State>;
391 }
392 
393 function normalizeHandlers(handlers: object | undefined): Map<string, unknown> {
394   if (!handlers) return new Map();
395   return new Map(Object.entries(handlers).map(([type, transition]) => [type, normalizeTransition(transition)]));
396 }
397 
398 function normalizeInvocation(invocation: unknown): object {
399   const source = invocation as { id?: string; task: unknown; onDone?: unknown; onError?: unknown };
400   return Object.freeze({
401     id: source.id,
402     task: source.task,
403     onDone: normalizeTransition(source.onDone),
404     onError: normalizeTransition(source.onError),
405   });
406 }
407 
408 function normalizeTransition(transition: unknown): unknown {
409   if (transition === undefined || typeof transition === 'string') return transition;
410   if (Array.isArray(transition)) return Object.freeze(transition.map(candidate => normalizeTransition(candidate)));
411   const source = transition as { target?: string; guard?: unknown; actions?: unknown; reenter?: boolean };
412   return Object.freeze({
413     target: source.target,
414     guard: source.guard,
415     actions: normalizeActions(source.actions),
416     reenter: source.reenter,
417   });
418 }
419 
420 function normalizeActions(actions: unknown): unknown {
421   return Array.isArray(actions) ? Object.freeze([...actions]) : actions;
422 }
423 
424 function validateHandlers(handlers: object | undefined, names: ReadonlySet<string>, label: string): void {
425   if (handlers === undefined) return;
426   if (!handlers || typeof handlers !== 'object') throw new TypeError(`Handlers for ${label} must be an object.`);
427   for (const transition of Object.values(handlers)) validateTransition(transition, names, label);
428 }
429 
430 function validateInvocations(invocations: unknown, names: ReadonlySet<string>, label: string): void {
431   if (invocations === undefined) return;
432   const list = Array.isArray(invocations) ? invocations : [invocations];
433   for (const invocation of list) {
434     if (!invocation || typeof invocation !== 'object' || typeof (invocation as { task?: unknown }).task !== 'function') throw new TypeError(`Each invocation in ${label} requires a task function.`);
435     const typed = invocation as { onDone?: unknown; onError?: unknown };
436     validateTransition(typed.onDone, names, label);
437     validateTransition(typed.onError, names, label);
438   }
439 }
440 
441 function validateTransition(transition: unknown, names: ReadonlySet<string>, label: string): void {
442   if (transition === undefined) return;
443   const list = Array.isArray(transition) ? transition : [transition];
444   for (const candidate of list) {
445     if (typeof candidate === 'string') {
446       if (!names.has(candidate)) throw new RangeError(`Transition in ${label} targets undeclared state ${JSON.stringify(candidate)}.`);
447       continue;
448     }
449     if (!candidate || typeof candidate !== 'object') throw new TypeError(`Transition in ${label} must be a state name or object.`);
450     const config = candidate as { target?: unknown; guard?: unknown; actions?: unknown };
451     if (config.target !== undefined && (typeof config.target !== 'string' || !names.has(config.target))) throw new RangeError(`Transition in ${label} targets an undeclared state.`);
452     if (config.guard !== undefined && typeof config.guard !== 'function') throw new TypeError(`Transition guard in ${label} must be a function.`);
453     if (config.actions !== undefined && typeof config.actions !== 'function' && (!Array.isArray(config.actions) || config.actions.some(action => typeof action !== 'function'))) throw new TypeError(`Transition actions in ${label} must be functions.`);
454   }
455 }
456 
457 interface StateEntry<Context, Event extends MachineEvent, Input, State extends string> {
458   active: boolean;
459   readonly generation: number;
460   readonly controllers: OwnedInvocation<Context, Event, Input, State>[];
461 }
462 interface OwnedInvocation<Context, Event extends MachineEvent, Input, State extends string> {
463   readonly controller: AbortController;
464   readonly invocation: MachineInvocation<Context, Event, Input, State>;
465   cleanup: (() => void) | undefined;
466 }
467 type EventQueueItem<Event extends MachineEvent> = { readonly kind: 'event'; readonly event: Event };
468 type InvocationQueueItem<Context, Event extends MachineEvent, Input, State extends string> =
469   | { readonly kind: 'done'; readonly entry: StateEntry<Context, Event, Input, State>; readonly invocation: MachineInvocation<Context, Event, Input, State>; readonly output: unknown }
470   | { readonly kind: 'error'; readonly entry: StateEntry<Context, Event, Input, State>; readonly invocation: MachineInvocation<Context, Event, Input, State>; readonly error: unknown };
471 type QueueItem<Context, Event extends MachineEvent, Input, State extends string> = EventQueueItem<Event> | InvocationQueueItem<Context, Event, Input, State>;
472 

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.