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

/*
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;