type StateTransitions = WeakMap< State, WeakSet> >; 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?: Events) { names.forEach((name) => this.addStateUnchecked(name, actions)); return this; } withState(name: StateName, actions?: Events) { this.addStateUnchecked(name, actions); return this; } private addStateUnchecked( name: StateName, actions?: Events, ) { 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 tryChangeState( state: StateOrName, context: Context, ) { const fromState = validState(this[_currState]); const toState = validNormalizedState(this[_states], state); 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[_prevState] = fromState; this[_currState] = toState; return this[_currState]; } maybeChangeState(state: StateOrName, context: Context) { return this.tryChangeState(state, context).catch(() => null); } 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), ); } allowedTransitionStateNames() { return this.allowedTransitionStates().map(String); } } const _stateName = Symbol("state name"); const _stateEvents = Symbol("state events"); interface Events { beforeExit?( fromState: State, toState: State, context: Context, ): boolean; onEntry?( fromState: State, toState: State, context: Context, ): Promise | void; } export class State { [_stateEvents]: Events; [_stateName]: StateName; get name(): StateName { return this[_stateName]; } constructor(name: StateName, events: Events = {}) { this[_stateName] = name; this[_stateEvents] = events; } async entry( fromState: State, toState: State, context: Context, ) { const event = this[_stateEvents].onEntry; if (isFn(event)) { await event(fromState, toState, context); } } exit( fromState: State, toState: State, context: Context, ) { const event = this[_stateEvents].beforeExit; return isFn(event) ? event(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 {}