You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
219 lines
9.0 KiB
219 lines
9.0 KiB
/*
|
|
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;
|
|
|