type StateTransitions = WeakMap< State, WeakSet> >; type StateName = string; type StateOrName = State | StateName; export const _states = Symbol("states"); export const _stateTransitions = Symbol("state transitions"); export const _prevState = Symbol("previous state"); export const _currState = Symbol("current state"); export class StateMachineBuilder { [_states]: Map>; [_stateTransitions]: Array<[StateName, Array]> | undefined; constructor() { this[_states] = new Map(); } withTransitions(transitions: Array<[StateName, Array]>) { this[_stateTransitions] = transitions; return this; } withStates(names: StateName[], actions?: Actions) { names.forEach((name) => this.addStateUnchecked(name, actions)); return this; } withState(name: StateName, actions?: Actions) { this.addStateUnchecked(name, actions); return this; } private addStateUnchecked(name: StateName, actions?: Actions) { const oldActions = this[_states].get(name); return this[_states].set(name, { ...oldActions, ...actions }); } build(currentStateName: StateName) { const states = this.buildStates(); const transitions = this.buildTransitions(states); const currState = validStateFromName(states, currentStateName); return new StateMachine(states, transitions, currState); } private buildStates() { return Array.from(this[_states].entries()).map((params) => new State(...params) ); } private buildTransitions(states: State[]) { const sourceTransitions = this[_stateTransitions] || []; return new WeakMap( sourceTransitions.map(([from, toStates]) => [ validStateFromName(states, from), new WeakSet(toStates.map(validStateFromName.bind(null, states))), ]), ); } } export class StateMachine { [_states]: State[]; [_stateTransitions]: StateTransitions; [_prevState]: State | undefined; [_currState]: State; constructor( states: State[], transitions: StateTransitions, currentState: State, ) { this[_states] = states; this[_stateTransitions] = transitions; this[_currState] = currentState; } async changeState(sourceState: StateOrName, context?: Context) { const fromState = validState(this[_currState]); const toState = validNormalizedState(this[_states], sourceState); if ( !this.hasTransition(toState) || !fromState.exit(fromState, toState, context) ) { throw new FsmError( `cannot change state from "${fromState.name}" to "${toState.name}"`, ); } await toState.entry(fromState, toState, context); this[_currState] = toState; this[_prevState] = fromState; } hasTransition(to: StateOrName) { return hasTransition( this[_stateTransitions], this[_currState], validNormalizedState(this[_states], to), ); } allowedTransitionStates() { const fromState = validState(this[_currState]); return this[_states].filter( hasTransition.bind(null, this[_stateTransitions], fromState), ); } } const _stateName = Symbol("state name"); const _stateActions = Symbol("state actions"); interface Actions { beforeExit?( fromState: State, toState: State, context?: Context, ): boolean; onEntry?( fromState: State, toState: State, context?: Context, ): Promise | void; } export class State { [_stateActions]: Actions; [_stateName]: StateName; get name(): StateName { return this[_stateName]; } constructor(name: StateName, actions: Actions = {}) { this[_stateName] = name; this[_stateActions] = actions; } async entry( fromState: State, toState: State, context?: Context, ) { const action = this[_stateActions].onEntry; if (isFn(action)) { await action(fromState, toState, context); } } exit(fromState: State, toState: State, context: Context) { const action = this[_stateActions].beforeExit; return isFn(action) ? action(fromState, toState, context) : true; } toString() { return this.name; } toJSON() { return this.toString(); } } function validNormalizedState( states: State[], state: StateOrName, ) { return validState(normalizeState(states, state)); } function normalizeState( states: State[], state: StateOrName, ): State | undefined { return isStr(state) ? stateFromName(states, state) : state; } function validStateFromName( states: State[], name: StateName, ) { return validState(stateFromName(states, name)); } function stateFromName(states: State[], name: StateName) { return states.find((state) => state.name === name); } function validState(val: unknown): State { if (!isState(val)) { throw new TypeError("an instance of State class is expected"); } return val; } function isState(val: unknown): val is State { return val instanceof State; } function hasTransition( transitions: StateTransitions, from: State, to: State, ) { return transitions.get(from)?.has(to) || false; } function isStr(val: unknown): val is string { return typeof val === "string"; } // deno-lint-ignore ban-types function isFn(val: unknown): val is Function { return typeof val === "function"; } export class FsmError extends Error {}