You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

281 lines
14 KiB
TypeScript

export type Event_T = [name:string, payload:any];
export interface Machine_T<C> {
states: Array<State_T<C>>
}
export interface State_T<C> {
name: string;
eventReactionCouplings: Array<EventReactionCouplings_T<C>>;
}
export interface EventReactionCouplings_T<C> {
eventName: string;
reactions: Array<Reaction_T<C>>;
};
export type Reaction_T<C> = SideEffect_T<C> | ContextMutation_T<C> | Peer_T<C,unknown> | Goto_T;
export interface SideEffect_T<C> {
type: 'SideEffect';
fn: SideEffectFunction_T<C>;
};
export type SideEffectFunction_T<C> = (ctx:C,e:Event_T,self:Interpreter_T<C>,originalContext:C)=>void;
export interface ContextMutation_T<C> {
type: 'ContextMutation';
fn: ContextMutationFunction_T<C>;
};
export type ContextMutationFunction_T<C> = (ctx:C,e:Event_T,self:Interpreter_T<C>)=>C;
export interface Peer_T<C,C_Peer> {
type: 'SetPeer' | 'SetPeers' | 'AddPeers';
name: string;
peerCreationFunction: PeerCreationFunction_T<C,C_Peer>
};
export type PeerCreationFunction_T<C,C_Peer> = (ctx:C,e:Event_T,self:Interpreter_T<C>) => Interpreter_T<C_Peer> | Array<Interpreter_T<C_Peer>>;
export interface Goto_T {
type: 'Goto';
targetStateName: string;
};
export const Machine = function<C>(...states:Array<State_T<C>>) : Machine_T<C> { return {states}; };
export const State = function<C>(name:string, ...eventReactionCouplings:Array<EventReactionCouplings_T<C>>) : State_T<C>{ return {name, eventReactionCouplings}; };
export const On = function<C>(eventName:string, ...reactions:Array<Reaction_T<C>>) : EventReactionCouplings_T<C>{ return {eventName, reactions}; };
export const SideEffect = function<C>(fn:SideEffectFunction_T<C>) : SideEffect_T<C>{ return {type:'SideEffect', fn}; };
export const Goto = function(targetStateName:string) : Goto_T { return {type:'Goto', targetStateName} };
export const Context = function<C>(fn:ContextMutationFunction_T<C>) : ContextMutation_T<C> { return {type:'ContextMutation', fn} };
export const Peer = function<C,C_Peer>(name:string, peerCreationFunction:PeerCreationFunction_T<C,C_Peer>) : Peer_T<C,C_Peer>{ return {type:'SetPeer', name, peerCreationFunction}; }
export const Peers = function<C,C_Peer>(name:string, peerCreationFunction:PeerCreationFunction_T<C,C_Peer>) : Peer_T<C,C_Peer>{ return {type:'SetPeers', name, peerCreationFunction}; }
export const AddPeers = function<C,C_Peer>(name:string, peerCreationFunction:PeerCreationFunction_T<C,C_Peer>) : Peer_T<C,C_Peer>{ return {type:'AddPeers', name, peerCreationFunction}; }
export interface Interpreter_T<C> {
machine: Machine_T<C>;
state: string;
context: C;
peers: Record<string, Interpreter_T<unknown> | Array<Interpreter_T<unknown>>>;
peerSubscriptionIds: Map<Interpreter_T<unknown>,string>;
eventQueue:Array<Event_T>;
subscriptionsToEvents: Record<string, EventsSubscriptionCallbackFunction_T<C>>; // called upon every event
subscriptionsToState: Record<string, StateSubscriptionCallbackFunction_T<C>>; // every time state changes, even if it's transient
subscriptionsToSettledState: Record<string, SettledStateSubscriptionCallbackFunction_T<C>>; // only called when tick settles
isTransitioning: boolean;
isPaused: boolean;
start: ()=>Interpreter_T<C>;
Peer: (name:string, peer:Interpreter_T<any>)=>Interpreter_T<C>;
Peers: (name:string, peers:Array<Interpreter_T<any>>)=>Interpreter_T<C>;
}
/**
* Description placeholder
*
* @export
* @param {Machine_T} machine
* @param {any} initialContext
* @param {?string} [initialStateName]
* @returns {Interpreter_T}
*/
export function Interpreter<C>(machine:Machine_T<C>, initialContext:any, initialStateName?:string) : Interpreter_T<C>{
if(typeof initialStateName === 'undefined'){ initialStateName = machine.states[0].name; }
//@ts-expect-error
const interpreter : Interpreter_T<C> = {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<any>)=>{ setPeer(interpreter, name, peer); return interpreter; }
interpreter.Peers = (name:string, peers:Array<Interpreter_T<any>>)=>{ setPeers(interpreter, name, peers); return interpreter; }
send(interpreter, ['entry', null] );
return interpreter;
}
export function start<C>(interpreter:Interpreter_T<C>){
if(interpreter.isPaused === true){
interpreter.isPaused = false;
processEvents(interpreter);
}
}
export function pause<C>(interpreter:Interpreter_T<C>){
if(interpreter.isPaused === false){
interpreter.isPaused = true;
}
}
/** Helper function for `send()`
*/
function getState<C>(interpreter : Interpreter_T<C>) : State_T<C>{
return interpreter.machine.states.find((state)=>state.name===interpreter.state) as unknown as State_T<C>;
}
/** Helper function for `send()`
*/
function getMatchingEventReactionCouplings<C>(state : State_T<C>, event:Event_T) : Array<EventReactionCouplings_T<C>>{
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<C>(interpreter : Interpreter_T<C>, event:Event_T){
interpreter.eventQueue.push(event);
if(interpreter.isTransitioning === false){
processEvents(interpreter);
}
}
export const enqueue = send;
function processEvents<C>(interpreter:Interpreter_T<C>){
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<C>(interpreter:Interpreter_T<C>){
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<C>)=>{
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<unknown>);
}
else if(peering.type === 'SetPeers'){
setPeers(interpreter, peering.name, peering.peerCreationFunction(interpreter.context, event, interpreter) as Array<Interpreter_T<unknown>>);
}
else if(peering.type === 'AddPeers'){
addPeers(interpreter, peering.name, peering.peerCreationFunction(interpreter.context, event, interpreter) as Array<Interpreter_T<unknown>>);
}
});
// 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<C>(reactions:Array<Reaction_T<C>>) : {sideEffects:Array<SideEffect_T<C>>, contextMutations:Array<ContextMutation_T<C>>, peerings:Array<Peer_T<C,unknown>>, goto_:Goto_T|null}{
let
sideEffects:Array<SideEffect_T<C>> = [],
contextMutations:Array<ContextMutation_T<C>> = [],
peerings:Array<Peer_T<C,unknown>> = [],
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<C> = (e:Event_T, self:Interpreter_T<C>)=>void;
export type StateSubscriptionCallbackFunction_T<C> = (e:Event_T, self:Interpreter_T<C>)=>void;
export type SettledStateSubscriptionCallbackFunction_T<C> = (self:Interpreter_T<C>)=>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<C>(interpreter:Interpreter_T<C>, callback:SettledStateSubscriptionCallbackFunction_T<C>){
subscriptionId++;
interpreter.subscriptionsToSettledState[subscriptionId.toString()] = callback;
return subscriptionId.toString();
}
export const subscribeToSettledState = subscribe;
export function subscribeToState<C>(interpreter:Interpreter_T<C>, callback:StateSubscriptionCallbackFunction_T<C>){
subscriptionId++;
interpreter.subscriptionsToState[subscriptionId.toString()] = callback;
return subscriptionId.toString();
}
export function subscribeToEvents<C>(interpreter:Interpreter_T<C>, callback:StateSubscriptionCallbackFunction_T<C>){
subscriptionId++;
interpreter.subscriptionsToEvents[subscriptionId.toString()] = callback;
return subscriptionId.toString();
}
export function unsubscribe<C>(interpreter:Interpreter_T<C>, subscriptionId:string){
delete interpreter.subscriptionsToSettledState[subscriptionId.toString()];
delete interpreter.subscriptionsToState[subscriptionId.toString()];
delete interpreter.subscriptionsToEvents[subscriptionId.toString()];
}
export function setPeer<C, C_Peer>(self:Interpreter_T<C>, name:string, peer:Interpreter_T<C_Peer>){
// if it exists, unsubscribe:
if(self.peers.hasOwnProperty(name)){
unsubscribe(self, self.peerSubscriptionIds.get(self.peers[name] as Interpreter_T<C_Peer>));
}
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<C, C_Peer>(self:Interpreter_T<C>, name:string, peers:Array<Interpreter_T<C_Peer>>){
// if any exist, unsubscribe:
if(self.peers.hasOwnProperty(name)){
(self.peers[name] as Array<Interpreter_T<C_Peer>>).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<C, C_Peer>(self:Interpreter_T<C>, name:string, peers:Array<Interpreter_T<C_Peer>>){
// if none exist, initialize with empty array:
if(!self.peers.hasOwnProperty(name)){
self.peers[name] = [];
}
(self.peers[name] as Array<Interpreter_T<C_Peer>>).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:{}}),[]);
}
*/