export type Event_T = [name:string, payload:any]; export interface Machine_T { states: Array> } export interface State_T { name: string; eventReactionCouplings: Array>; } export interface EventReactionCouplings_T { eventName: string; reactions: Array>; }; export type Reaction_T = SideEffect_T | ContextMutation_T | Peer_T | Goto_T; export interface SideEffect_T { type: 'SideEffect'; fn: SideEffectFunction_T; }; export type SideEffectFunction_T = (ctx:C,e:Event_T,self:Interpreter_T,originalContext:C)=>void; export interface ContextMutation_T { type: 'ContextMutation'; fn: ContextMutationFunction_T; }; export type ContextMutationFunction_T = (ctx:C,e:Event_T,self:Interpreter_T)=>C; export interface Peer_T { type: 'SetPeer' | 'SetPeers' | 'AddPeers'; name: string; peerCreationFunction: PeerCreationFunction_T }; export type PeerCreationFunction_T = (ctx:C,e:Event_T,self:Interpreter_T) => Interpreter_T | Array>; export interface Goto_T { type: 'Goto'; targetStateName: string; }; export const Machine = function(...states:Array>) : Machine_T { return {states}; }; export const State = function(name:string, ...eventReactionCouplings:Array>) : State_T{ return {name, eventReactionCouplings}; }; export const On = function(eventName:string, ...reactions:Array>) : EventReactionCouplings_T{ return {eventName, reactions}; }; export const SideEffect = function(fn:SideEffectFunction_T) : SideEffect_T{ return {type:'SideEffect', fn}; }; export const Goto = function(targetStateName:string) : Goto_T { return {type:'Goto', targetStateName} }; export const Context = function(fn:ContextMutationFunction_T) : ContextMutation_T { return {type:'ContextMutation', fn} }; export const Peer = function(name:string, peerCreationFunction:PeerCreationFunction_T) : Peer_T{ return {type:'SetPeer', name, peerCreationFunction}; } export const Peers = function(name:string, peerCreationFunction:PeerCreationFunction_T) : Peer_T{ return {type:'SetPeers', name, peerCreationFunction}; } export const AddPeers = function(name:string, peerCreationFunction:PeerCreationFunction_T) : Peer_T{ return {type:'AddPeers', name, peerCreationFunction}; } export interface Interpreter_T { machine: Machine_T; state: string; context: C; peers: Record | Array>>; peerSubscriptionIds: Map,string>; eventQueue:Array; subscriptionsToEvents: Record>; // called upon every event subscriptionsToState: Record>; // every time state changes, even if it's transient subscriptionsToSettledState: Record>; // only called when tick settles isTransitioning: boolean; isPaused: boolean; start: ()=>Interpreter_T; Peer: (name:string, peer:Interpreter_T)=>Interpreter_T; Peers: (name:string, peers:Array>)=>Interpreter_T; } /** * Description placeholder * * @export * @param {Machine_T} machine * @param {any} initialContext * @param {?string} [initialStateName] * @returns {Interpreter_T} */ export function Interpreter(machine:Machine_T, initialContext:any, initialStateName?:string) : Interpreter_T{ if(typeof initialStateName === 'undefined'){ initialStateName = machine.states[0].name; } //@ts-expect-error const interpreter : Interpreter_T = {machine, state: initialStateName, context:initialContext, eventQueue:[], isTransitioning:false, peers:{}, peerSubscriptionIds:new Map(), subscriptionsToEvents: {}, subscriptionsToState: {}, subscriptionsToSettledState: {}, isPaused: true}; interpreter.start = ()=>{ start(interpreter); return interpreter; } interpreter.Peer = (name:string, peer:Interpreter_T)=>{ setPeer(interpreter, name, peer); return interpreter; } interpreter.Peers = (name:string, peers:Array>)=>{ setPeers(interpreter, name, peers); return interpreter; } send(interpreter, ['entry', null] ); return interpreter; } export function start(interpreter:Interpreter_T){ if(interpreter.isPaused === true){ interpreter.isPaused = false; processEvents(interpreter); } } export function pause(interpreter:Interpreter_T){ if(interpreter.isPaused === false){ interpreter.isPaused = true; } } /** Helper function for `send()` */ function getState(interpreter : Interpreter_T) : State_T{ return interpreter.machine.states.find((state)=>state.name===interpreter.state) as unknown as State_T; } /** Helper function for `send()` */ function getMatchingEventReactionCouplings(state : State_T, event:Event_T) : Array>{ return state.eventReactionCouplings.filter((eventReactionCoupling)=>eventReactionCoupling.eventName===event[0]); } /** Inject an Event into the Interpreter's "tick queue". * * An event can be signify something "new" happening, such that its reactions should run on the next Tick; * or it can signify a milestone "within" the current Tick, such that a Tick can be thought of as having * "sub-Ticks". * * This distinction is significant for proper ordering of reaction execution, and also for determining * whether to run a reaction at all. If an Event is received, and is specified to be applied on a past * Tick, it is discarded. */ export function send(interpreter : Interpreter_T, event:Event_T){ interpreter.eventQueue.push(event); if(interpreter.isTransitioning === false){ processEvents(interpreter); } } export const enqueue = send; function processEvents(interpreter:Interpreter_T){ interpreter.isTransitioning = true; while(interpreter.eventQueue.length > 0 && interpreter.isPaused===false){ processNextEvent(interpreter); } interpreter.isTransitioning = false; // only run subscriptions here, once the machine's state has settled: Object.values(interpreter.subscriptionsToSettledState).forEach((callbackFunction)=>{ callbackFunction(interpreter); }); } function processNextEvent(interpreter:Interpreter_T){ const event = interpreter.eventQueue.shift(); if(typeof event !== 'undefined'){ const state = getState(interpreter); const eventReactionCouplings = getMatchingEventReactionCouplings(state, event); const reactions = eventReactionCouplings .map((eventReactionCoupling)=>eventReactionCoupling.reactions) .flat(); const {sideEffects, contextMutations, peerings, goto_} = categorizeReactions(reactions); // save the current context, before it's mutated, so as to pass it to sideEffects below: const originalContext = interpreter.context; // must process contextMutations in-series: contextMutations.forEach((contextMutation:ContextMutation_T)=>{ interpreter.context = contextMutation.fn(interpreter.context, event, interpreter); }); // processing of `goto` must be last: if(goto_ !== null){ send(interpreter, ['exit', null]); interpreter.state = goto_.targetStateName; // run subscription-to-state callbacks (can be in parallel), since state just changed, possibly transiently (depends on whether the loop in `processEvents()` runs again): Object.values(interpreter.subscriptionsToState).forEach((callbackFunction)=>{ callbackFunction(event, interpreter); }); send(interpreter, ['entry', null]); } // now that "internal" stuff has been run, we can run "external" stuff: // process peerings (possibly in parallel): peerings.forEach((peering)=>{ if(peering.type === 'SetPeer'){ setPeer(interpreter, peering.name, peering.peerCreationFunction(interpreter.context, event, interpreter) as Interpreter_T); } else if(peering.type === 'SetPeers'){ setPeers(interpreter, peering.name, peering.peerCreationFunction(interpreter.context, event, interpreter) as Array>); } else if(peering.type === 'AddPeers'){ addPeers(interpreter, peering.name, peering.peerCreationFunction(interpreter.context, event, interpreter) as Array>); } }); // run subscription-to-events callbacks (can be in parallel), since an event just happened: Object.values(interpreter.subscriptionsToEvents).forEach((callbackFunction)=>{ callbackFunction(event, interpreter); }); // can process sideEffects in parallel (though we currently don't due to the overhead of doing so in Node.js): // they're processed *after* the context changes, since that's what most sideEffects would be interested in; but nevertheless the original context is passed in case this sideEffect needs it: sideEffects.forEach((sideEffect)=>{ sideEffect.fn(interpreter.context, event, interpreter, originalContext); }); } } function categorizeReactions(reactions:Array>) : {sideEffects:Array>, contextMutations:Array>, peerings:Array>, goto_:Goto_T|null}{ let sideEffects:Array> = [], contextMutations:Array> = [], peerings:Array> = [], goto_:Goto_T|null = null; reactions.forEach((reaction)=>{ if(reaction.type === 'SideEffect'){ sideEffects.push(reaction); } else if(reaction.type === 'ContextMutation'){ contextMutations.push(reaction); } else if(reaction.type === 'SetPeer'){ peerings.push(reaction); } else if(reaction.type === 'Goto'){ goto_ = reaction; } }); return {sideEffects, contextMutations, peerings, goto_}; } export type EventsSubscriptionCallbackFunction_T = (e:Event_T, self:Interpreter_T)=>void; export type StateSubscriptionCallbackFunction_T = (e:Event_T, self:Interpreter_T)=>void; export type SettledStateSubscriptionCallbackFunction_T = (self:Interpreter_T)=>void; // we don't pass an event, because these only run once state settles, so a whole chain of events could have been responsible for that; it's unlikely a subscriber is interested only in the final one // TODO: add subscribeToContext and subscribeToSettledContext functions, to get only changes to context, regardless of events happening or state changing let subscriptionId : number = 0; export function subscribe(interpreter:Interpreter_T, callback:SettledStateSubscriptionCallbackFunction_T){ subscriptionId++; interpreter.subscriptionsToSettledState[subscriptionId.toString()] = callback; return subscriptionId.toString(); } export const subscribeToSettledState = subscribe; export function subscribeToState(interpreter:Interpreter_T, callback:StateSubscriptionCallbackFunction_T){ subscriptionId++; interpreter.subscriptionsToState[subscriptionId.toString()] = callback; return subscriptionId.toString(); } export function subscribeToEvents(interpreter:Interpreter_T, callback:StateSubscriptionCallbackFunction_T){ subscriptionId++; interpreter.subscriptionsToEvents[subscriptionId.toString()] = callback; return subscriptionId.toString(); } export function unsubscribe(interpreter:Interpreter_T, subscriptionId:string){ delete interpreter.subscriptionsToSettledState[subscriptionId.toString()]; delete interpreter.subscriptionsToState[subscriptionId.toString()]; delete interpreter.subscriptionsToEvents[subscriptionId.toString()]; } export function setPeer(self:Interpreter_T, name:string, peer:Interpreter_T){ // if it exists, unsubscribe: if(self.peers.hasOwnProperty(name)){ unsubscribe(self, self.peerSubscriptionIds.get(self.peers[name] as Interpreter_T)); } self.peers[name] = peer; const subscriptionId = subscribeToEvents(peer, (e, peer)=>{ // TODO: ensure there's no faulty logic in having this `if`: // this `if` prevents infinite loops due to mutually-subscribed peers (cyclical dependencies): if(self.isTransitioning === false){ send(self, [name+'.'+e[0], e[1]]); } }); self.peerSubscriptionIds.set(peer, subscriptionId); } export function setPeers(self:Interpreter_T, name:string, peers:Array>){ // if any exist, unsubscribe: if(self.peers.hasOwnProperty(name)){ (self.peers[name] as Array>).forEach(peer => { unsubscribe(self, self.peerSubscriptionIds.get(peer)); }); } self.peers[name] = peers; peers.forEach((peer)=>{ const subscriptionId = subscribeToEvents(peer, (e, peer)=>{ // TODO: ensure there's no faulty logic in having this `if`: // this `if` prevents infinite loops due to mutually-subscribed peers (cyclical dependencies): if(self.isTransitioning === false){ send(self, [name+'.'+e[0], e[1]]); } }); self.peerSubscriptionIds.set(peer, subscriptionId); }); } export function addPeers(self:Interpreter_T, name:string, peers:Array>){ // if none exist, initialize with empty array: if(!self.peers.hasOwnProperty(name)){ self.peers[name] = []; } (self.peers[name] as Array>).concat(peers); peers.forEach((peer)=>{ const subscriptionId = subscribeToEvents(peer, (e, peer)=>{ // TODO: ensure there's no faulty logic in having this `if`: // this `if` prevents infinite loops due to mutually-subscribed peers (cyclical dependencies): if(self.isTransitioning === false){ send(self, [name+'.'+e[0], e[1]]); } }); self.peerSubscriptionIds.set(peer, subscriptionId); }); } /* export function useMachine(machine, options){ return useMemo(()=>interpret(AppMachine, {context:{}}),[]); } */