/* A very simple Finite State Machine system. If you're struggling with managing a mixture of JavaScript API styles (Promise, callback, events) then this will help get things organized. The best example is in the `client/components/HLSVideo.svelte` file. The main advantage an FSM has is it's ability to constrain and organize random events, and it's ability to fully log what's happening in a stream of random events. The `HLSVideo.svelte` example is a great demonstration of this because it has to juggle events from two different styles of APIs, network events, user UI events, and __video__ loading events. Since all of these can come in at random the FSM will help order them and do the right thing at the right time. ### Theory One way to look at a Finite State Machine is an ___explicit if-while-loop___. If you have a `while-loop` that is doing a lot of processing based on variables, and you're using a lot of `if-statements` or `switch-case` statements, then the `FSM` simply formalizes exactly __what__ states and when they can run. For example, if you have a mess of nested `if-statements`, and each one might not cover every possible variable, then you'll have bugs as different branches of the `if-statements` run or don't run. The `FSM` simply says, "The only allowed states at this point in time are X, Y, and Z. The only events I expect are event1, event2, and event3. If I get anything else that's an error." It's a different way to think about data processing, but it is very reliable _and_ easy to debug because you can log every event, state, and all the data while processing random events. ### FSMs in User Interfaces Most UIs are simple enough that you can get away with the basic Svelte variables updating the state. When UIs get more complex--and especially when they trigger network events--you run into problems with random events requiring different updates to the UI. For example, if a user clicks on a button that loads a video, but they're already loading a different video, then you have to deal with the current click, the old video loading state, and the new video playing state. An FSM would be able to "serialize" the click, old video load, and new video load and play better than the usual Svelte variables. In short, if you're struggling with properly updating a user interface as random events come in from the user and the network, then switch to an FSM. ### Usage The `class FSM` takes two things: data and a event transition object. The `data` is only for logging and debugging the FSM, as it gets printed out with each log message. The core of the FSM is your event handler, which has functions named after each event: ```javascript class VideoEvents { async mount(state) { case "START": return "COUNTING_DOWN"; default: return "ERROR"; } } ``` In this snippet the `class VideoEvents` has a `async mount` event that works like this: + This `mount()` event takes a single `state` which is just a string for the state the FSM is currently in. + You then figure out what to do based on this `state`, typically using a `switch/case`. + Based on what you've done, you return the next state as another string. + The `FSM` then waits for a new event, and calls that function with this new state. To trigger this `mount` event you would then write: ```javascript await fsm.do("mount"); ``` You can pass additional arguments to the `do` function and they will be passed to the event as additional arguments: ```javascript // if fsm is in "START" state await fsm.do("mount", 1, 2, 3); // then becomes ev.mount("START", 1, 2, 3); ``` ### Logging Events The `FSM` class will already call `log.debug` on many things, but if you want to do your own logging you can use the `onTransition` callback: ```javascript fsm = new FSM(video_config, new VideoEvents()); fsm.onTransition(fsm => { video_config.state = fsm.state log.info("I'm logging something else too", the_other_thing); }); ``` This is a slight modification of how the FSM is used in `HLSVideo.svelte`. Remember that the `video_config` is only passed to `FSM()` so that it gets logged while the `FSM` is running. This is _incredibly_ useful when debugging why your `FSM` is doing something. In the `onTransition` callback I'm simply updating the `state` in `video_config` and then I added a little `log.info` to show you could log anything else you wanted there. ### Internal Transitions If I'm remembering my graduate courses right, this is called an "epsilon transition", but it's basically triggering another event inside the current event, effectively changing state without waiting for a new event. I could be wrong on the terminology, but you do this by returning an `Array[state, cb()]` pair. The `FSM` will then set state to `state` and call your callback so you can do further processing. ___BUG___: This could be improved but for now it works. Look at `HLSVideo.svelte:detect_video` for an example of doing this. ### State Names The first state is "START" by default, but after that you can use any string. I prefer to make the states UPPERCASE so they aren't confused with events, which are `lowercase()` function names in the event handler class (`VideoEvents` above). */ import assert from "./assert.js"; import { log } from "./logging.js"; /* A very simple Finite State Machine class. Nothing fancy, just a state and some transition callbacks based on the function names found in `this.events`. */ export class FSM { /* Sets up this FSM to use the `events` callback as the transitions, and then "START" as the first state. It does ___not___ call any initial transitions so you have to do the first call to `FSM.do()`. The `data` isn't used internally and only passed to logging functions for debugging. ### Event Class Signature The `events` can be anything, and you construct it so it will have your own data. The only requirement is that the functions have a `name(state)` signature and that `state` is going to be a string. It should then return the next state string for the next event to receive. + `data Anything` -- Whatever you want to log while debugging. + `events Event Object` -- An object/class that can handle event calls sent to it. */ constructor(data, events) { // data is really only for logging/debugging this.data = data; this.events = events; this.state = "START"; this.on_state = undefined; } /* A callback that runs whenever an event handler returns a state. The `cb` callback receives this `FSM` so it can debug it or do more to it. ___WARNING___: This is called called even if the state didn't change, which might confuse people if they think it's only when a "transition" happens. + `cb(FSM)` -- Callback after the transition. Receives this `FSM`. */ onTransition(cb) { this.transition_cb = cb; } /* Mostly used internally to perform the transition then call the `onTransition` callback, but sometimes you might need to force a new state externally. + `next_state string` -- The new state name. */ transition(next_state) { this.state = next_state; if(this.transition_cb) { this.transition_cb(this); } } /* All event names available in the `this.events` class. */ event_names() { return Object.getOwnPropertyNames(Object.getPrototypeOf(this.events)).filter(k => k !== "constructor"); } /* The core of this `FSM` is the `do()` function. It handles finding the next event by it's name, loading it, and calling it with the given additional arguments. + `event string` -- The __name__ of the target event, not the actual function. It's a string. + `...args` -- Additional arguments to pass to the event. + ___return___ `this.state string` -- The current state of this `FSM`. */ async do(event, ...args) { const evhandler = this.events[event]; assert(evhandler !== undefined, `Invalid event ${event}. Available ones are '${this.event_names()}'.`); // NOTE: you have to use .call to pass in the this.events object or else this === undefined in the call const next_state = await evhandler.call(this.events, this.state, ...args); assert(next_state, `The event "${event}" returned "${next_state}" but must be a truthy true state.`); if(Array.isArray(next_state)) { assert(next_state.length == 2, `Returning an array only allows 2 elements (state, func) but you returned ${next_state}`); let [state, func] = next_state; log.debug(`FSM ${this.events.constructor.name}: (${event}) = ${this.state} -> ${state} (${args})`, "DATA:", this.data, func ? `THEN ${func.name}()` : undefined); this.transition(state); await func(); } else { log.debug(`FSM ${this.events.constructor.name}: (${event}) = ${this.state} -> ${next_state} (${args})`, "DATA:", this.data); this.transition(next_state); } return this.state; } } export default FSM;