|
|
|
@ -1,8 +1,145 @@ |
|
|
|
|
/* |
|
|
|
|
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; |
|
|
|
@ -11,10 +148,25 @@ export class FSM { |
|
|
|
|
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; |
|
|
|
|
|
|
|
|
@ -23,10 +175,22 @@ export class FSM { |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
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()}'.`); |
|
|
|
|