packages/core/src/features/machines/runtime.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.