import { Event, BaseMachine, TransitionNames, TransitionArgs, MaybePromise, createMachine } from './index'; // ============================================================================= // TYPES // ============================================================================= /** * Minimal reference shared by machine actors and actor-like adapters. * * @typeParam T - Snapshot returned to readers and subscribers. * @typeParam E - Event accepted by {@link ActorRef.dispatch}. */ export interface ActorRef { /** Enqueues an event for processing. */ dispatch: (event: E) => void; /** Returns the actor's current snapshot synchronously. */ getSnapshot: () => T; /** Observes successful snapshot replacements; returns an unsubscribe callback. */ subscribe: (observer: (state: T) => void) => () => void; } /** * Inspection event type. */ export type InspectionEvent = { type: '@actor/send'; // Extendable for lifecycle actor: ActorRef; event: unknown; snapshot: unknown; }; // ============================================================================= // ACTOR CLASS // ============================================================================= /** * A reactive container for a state machine that handles dispatching, * queueing of async transitions, and state observability. * * Events are processed in arrival order. A promise-returning transition blocks * later events until it settles. Stopping the actor invalidates pending results, * clears queued events, and removes subscribers. * * @typeParam M - Complete machine or typestate union owned by the actor. * @example * ```ts * const actor = createActor(counter); * actor.subscribe(snapshot => console.log(snapshot.context.count)); * actor.send.add(2); * actor.ref.send({ type: 'reset', args: [] }); * ``` */ export class Actor> implements ActorRef> { private _state: M; private _observers: Set<(state: M) => void> = new Set(); private _queue: Array> = []; private _processing = false; private _stopped = false; private _generation = 0; // Global inspector private static _inspector: ((event: InspectionEvent) => void) | null = null; /** * Registers a global inspector. */ static inspect(inspector: ((event: InspectionEvent) => void) | null) { Actor._inspector = inspector; } /** * The "Magic" Dispatcher. * Maps machine transition names to callable functions. */ readonly send: { [K in TransitionNames]: (...args: TransitionArgs) => void; }; /** * A stable reference to the dispatch method, useful for passing around. */ readonly ref: { send: (event: Event) => void; }; constructor(initialMachine: M) { this._state = initialMachine; // Pattern B: Reference to self for event-based dispatch this.ref = { send: (event) => this.dispatch(event) }; // Pattern A: Proxy for RPC-style dispatch this.send = new Proxy({} as any, { get: (_target, prop) => { return (...args: any[]) => { this.dispatch({ type: prop as any, args: args as any } as unknown as Event); }; } }); } /** * Returns the current immutable snapshot of the machine. */ getSnapshot(): M { return this._state; } /** * Subscribes to state changes. * @param observer Callback function to be invoked on every state change. * @returns Unsubscribe function. */ subscribe(observer: (state: M) => void): () => void { this._observers.add(observer); return () => { this._observers.delete(observer); }; } /** * Selects a slice of the state. */ select(selector: (state: M) => T): T { return selector(this._state); } /** * Starts the actor. */ start(): this { this._stopped = false; return this; } /** * Stops the actor. */ stop(): void { this._stopped = true; this._generation += 1; this._queue.length = 0; this._processing = false; this._observers.clear(); } /** * Dispatches an event to the actor. * Handles both sync and async transitions. */ dispatch(event: Event): void { if (this._stopped) return; // Inspection if (Actor._inspector) { try { Actor._inspector({ type: '@actor/send', actor: this, event, snapshot: this._state }); } catch (error) { console.error('[Actor] Inspector failed:', error); } } if (this._processing) { this._queue.push(event); return; } this._processing = true; this._queue.push(event); this._flush(); } private _flush(): void { if (this._stopped) return; while (!this._stopped && this._queue.length > 0) { const event = this._queue[0]; this._queue.shift(); const transitions = this._state as any; const fn = transitions[event.type]; if (typeof fn !== 'function') { console.warn(`[Actor] Transition '${String(event.type)}' not found.`); continue; } let result: MaybePromise; try { result = fn.apply(this._state, event.args); } catch (error) { console.error(`[Actor] Error in transition '${String(event.type)}':`, error); continue; } if (isPromiseLike(result)) { const generation = this._generation; Promise.resolve(result).then((nextState) => { if (this._stopped || generation !== this._generation) return; if (!isMachineSnapshot(nextState)) { console.error(`[Actor] Transition '${String(event.type)}' did not return a machine with a context property.`); this._flush(); return; } this._state = nextState as M; this._notify(); this._flush(); }).catch((error) => { if (this._stopped || generation !== this._generation) return; console.error(`[Actor] Async error in transition '${String(event.type)}':`, error); this._flush(); }); return; } else { if (!isMachineSnapshot(result)) { console.error(`[Actor] Transition '${String(event.type)}' did not return a machine with a context property.`); continue; } this._state = result as M; this._notify(); } } this._processing = false; } private _notify() { const snapshot = this.getSnapshot(); this._observers.forEach(observer => { try { observer(snapshot); } catch (error) { console.error('[Actor] Subscriber failed:', error); } }); } } function isPromiseLike(value: unknown): value is PromiseLike { return value !== null && (typeof value === 'object' || typeof value === 'function') && typeof (value as PromiseLike).then === 'function'; } function isMachineSnapshot(value: unknown): value is BaseMachine { return value !== null && typeof value === 'object' && 'context' in value; } // ============================================================================= // INTEROP & HELPERS // ============================================================================= /** * Creates an actor that owns and serializes transitions for `machine`. * * @typeParam M - Machine or typestate union to own. * @param machine - Initial immutable snapshot. * @returns A stopped-state-aware actor with RPC-style `send` and event-style `ref.send`. * @example * ```ts * const actor = createActor(createCounter({ count: 0 })); * actor.send.increment(); * ``` */ export function createActor>(machine: M): Actor { return new Actor(machine); } /** * Creates an actor reference from a machine; alias of {@link createActor}. * * @typeParam M - Machine or typestate union to own. * @param machine - Initial immutable snapshot. * @returns The actor through the portable {@link ActorRef} interface. */ export function spawn>(machine: M): ActorRef> { return createActor(machine); } /** * Creates an actor whose context tracks one eagerly started promise. * * The promise function runs in a microtask after construction. Resolution moves * context to `resolved`; rejection moves it to `rejected`. Stopping the actor * prevents later snapshot replacement but does not cancel the underlying promise. * * @typeParam T - Promise fulfillment value. * @param promiseFn - Lazy promise producer invoked once. * @returns An actor with pending/resolved/rejected context. * @example * ```ts * const request = fromPromise(() => fetch('/api').then(r => r.json())); * request.subscribe(snapshot => console.log(snapshot.context.status)); * ``` */ export function fromPromise(promiseFn: () => Promise) { type PromiseContext = | { status: 'pending'; data: undefined; error: undefined } | { status: 'resolved'; data: T; error: undefined } | { status: 'rejected'; data: undefined; error: unknown }; const initial: PromiseContext = { status: 'pending', data: undefined, error: undefined }; const machine = createMachine(initial, (next) => ({ resolve(data: T) { return next({ status: 'resolved' as const, data, error: undefined }); }, reject(error: unknown) { return next({ status: 'rejected' as const, error, data: undefined }); } }) ); const actor = createActor(machine); Promise.resolve() .then(promiseFn) .then(data => (actor.send as any).resolve(data)) .catch(err => (actor.send as any).reject(err)); return actor; } /** * Creates an actor whose context follows an Observable-like source. * * `next`, `error`, and `complete` notifications become actor transitions. * Calling `actor.stop()` unsubscribes from the source exactly once. * * @typeParam T - Observable value type. * @param observable - Source exposing an RxJS-compatible `subscribe` method. * @returns An actor with active/done/error context. * @example * ```ts * const actor = fromObservable(source$); * actor.subscribe(snapshot => console.log(snapshot.context)); * actor.stop(); // also unsubscribes from source$ * ``` */ export function fromObservable(observable: { subscribe: (next: (val: T) => void, error?: (err: unknown) => void, complete?: () => void) => { unsubscribe: () => void } }) { type ObsContext = | { status: 'active'; value: undefined; error: undefined } | { status: 'active'; value: T; error: undefined } | { status: 'done'; value: undefined; error: undefined } | { status: 'error'; value: undefined; error: unknown }; const initial: ObsContext = { status: 'active', value: undefined, error: undefined }; const machine = createMachine(initial, (next) => ({ next(value: T) { return next({ status: 'active' as const, value, error: undefined }); }, error(error: unknown) { return next({ status: 'error' as const, error, value: undefined }); }, complete() { return next({ status: 'done' as const, value: undefined, error: undefined }); } }) ); const actor = createActor(machine); let subscription: { unsubscribe: () => void } | undefined; try { subscription = observable.subscribe( (val) => (actor.send as any).next(val), (err) => (actor.send as any).error(err), () => (actor.send as any).complete() ); } catch (error) { (actor.send as any).error(error); } const stop = actor.stop.bind(actor); let unsubscribed = false; actor.stop = () => { if (!unsubscribed) { unsubscribed = true; subscription?.unsubscribe(); } stop(); }; return actor; }