├── .flowconfig
├── .gitignore
├── .husky
└── pre-commit
├── .npmignore
├── .nvmrc
├── .size-limit
├── .size.json
├── .travis.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── __tests__
├── attrs.test.ts
├── busy.test.ts
├── errors.test.ts
├── event-order.test.ts
├── guards.test.ts
├── hooks.test.ts
├── phone.test.ts
├── reactish.test.ts
├── signature.test.ts
├── simple.test.ts
└── timer.test.ts
├── assets
├── blocks.png
├── capeu17.gif
└── table.png
├── circle.yml
├── package.json
├── src
├── faste-executor.ts
├── faste.ts
├── helpers
│ ├── call.ts
│ ├── debug.ts
│ └── thenable.ts
├── index.ts
├── interfaces
│ ├── callbacks.ts
│ ├── guards.ts
│ ├── hooks.ts
│ ├── internal-machine.ts
│ ├── messages.ts
│ └── signatures.ts
└── types.ts
├── tsconfig.json
└── yarn.lock
/.flowconfig:
--------------------------------------------------------------------------------
1 | [ignore]
2 |
3 | [include]
4 |
5 | [libs]
6 |
7 | [lints]
8 |
9 | [options]
10 | include_warnings=true
11 |
12 | [strict]
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | /dist/
3 | /coverage/
4 | .DS_Store
5 | .idea
6 | npm-debug.log
7 | yarn-error.log
8 | *.js
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | yarn lint-staged
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .babelrc
2 | assets
3 | ___tests___
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 8.5.0
--------------------------------------------------------------------------------
/.size-limit:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "path": "dist/es2015/index.js",
4 | "limit": "2.6 KB"
5 | }
6 | ]
7 |
--------------------------------------------------------------------------------
/.size.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "dist/es2015/index.js",
4 | "passed": true,
5 | "size": 2590,
6 | "sizeLimit": 2600
7 | }
8 | ]
9 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '8'
4 | cache: yarn
5 | script:
6 | - yarn
7 | - yarn test:ci
8 | - yarn build
9 | - yarn test:size
10 | - codecov
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theKashey/faste/56d817c4134038239cb88ac127bdafb5046a5730/CHANGELOG.md
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 Anton Korzunov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
🤖 Faste 💡
3 | TypeScript centric Table Finite State Machine
4 |
5 |

6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | > no dependencies, in around 2kb
30 |
31 | - 👨🔬 a bit less state-ish than [xstate](https://xstate.js.org/docs/)
32 | - 🤖 way more state-full than anything else in your code
33 | - 🧠 made for everything in between
34 | - 🖥 it does not have Visualizer, but it's VERY TypeScript centric
35 |
36 | State machine is a blackbox you can 1) `.start` 2) `.put` some events in and 3) _observe_ how its working.
37 |
38 | Internally it will be a machine working in different "states", which called **phases** here (water/ice) recieving
39 | different events and doing something...
40 |
41 | Core concepts are:
42 |
43 | - phases: different modes your machine can be in
44 | - state: internal state machine sets and controls itself
45 | - attributes: external configuration of machine
46 | - messages: events it can receive from the outside or send to itself
47 | - signals: events it can send to the outer world
48 | - timers: in a very declarative form
49 | - hooks: callbacks which are executed when machine start or stop handling a given message
50 | - guards and traps: protectors from entering or leaving some states
51 |
52 | > In react world `attributes` are `props`. In xstate world `state` is `context`
53 |
54 | # State machine
55 |
56 | State machine _starts_ in one phase, calls _hooks_ for all _messages_ for the current phase,
57 | then _awaits_ for a _messages_ from hooks or external customer, then
58 | could _trigger_ a new message, _emit_ signal to the outer world or _change_ the current phase.
59 |
60 | Faste is a black box - you can _put_ message inside, and wait for a _signal_ it will sent outside, meanwhile
61 | observing a box _phase_. Black📦 == Component🎁.
62 |
63 | 📖 Read an article about [FASTE, and when to use it](https://medium.com/@antonkorzunov/fasten-your-state-9fb9f9b44f30).
64 |
65 | # Example
66 |
67 | ```js
68 | const light = faste()
69 | // define possible "phases" of a traffic light
70 | .withPhases(['red', 'yellow', 'green'])
71 | // define possible transitions from one phase to another
72 | .withTransitions({
73 | green: ['yellow'],
74 | yellow: ['red'],
75 | red: ['green'],
76 | })
77 | // define possible events for a machine
78 | .withMessages(['switch'])
79 | .on('switch', ['green'], ({ transitTo }) => transitTo('yellow'))
80 | .on('switch', ['yellow'], ({ transitTo }) => transitTo('red'))
81 | .on('switch', ['red'], ({ transitTo }) => transitTo('green'))
82 | // ⚠️ the following line would throw an error at _compile time_
83 | .on('switch', ['green'], ({ transitTo }) => transitTo('red')) // this transition is blocked
84 |
85 | // block transition TO green if any pedestrian is on the road
86 | .guard(['green'], () => noPedestriansOnTheRoad)
87 | // block transition FROM red if any pedestrian is on the road
88 | .trap(['red'], () => !noPedestriansOnTheRoad);
89 | // PS: noPedestriansOnTheRoad could be read from attr, passed from a higher state machine.
90 | ```
91 |
92 | # API
93 |
94 | ## Machine blueprint
95 |
96 | `faste(options)` - defines a new faste machine
97 | every faste instance provide next _chainable_ commands
98 |
99 | - `on(eventName, [phases], callback)` - set a hook `callback` for `eventName` message in states `states`.
100 | - `hooks(hooks)` - set a hook when some message begins, or ends its presence.
101 |
102 | - `guard(phases, callback)` - add a transition guard, prevention transition to the phase
103 | - `trap(phases, callback)` - add a transition guard, prevention transition from the phase
104 |
105 | In development mode, and for typed languages you could use next commands
106 |
107 | - `withState(state)` - set a initial state (use @init hook to derive state from props).
108 | - `withPhases(phases)` - limit phases to provided set.
109 | - `withTimers(timersConfuguration)` - configures timers
110 | - `withTransitions([phases]:[phases])` - limit phase transitions
111 | - `withMessages(messages)` - limit messages to provided set.
112 | - `withAttrs(attributes)` - limit attributes to provided set.
113 | - `withSignals(signals)` - limit signals to provided set.
114 | -
115 | - `withMessageArguments()` - enabled arguments for messages
116 | - `withSignalArguments()` - enabled arguments for signals
117 |
118 | - `create()` - creates a machine (copies existing, use it instead of `new`).
119 |
120 | All methods returns a `faste` constructor itself.
121 |
122 | ## Machine instance
123 |
124 | Each instance of Faste will have:
125 |
126 | - `attrs(attrs)` - set attributes.
127 | - `put` - put message in
128 | - `connect` - connects output to the destination
129 | - `observe` - observes phase changes
130 |
131 | - `phase` - returns the current phase
132 | - `instance` - returns the current internal state.
133 |
134 | - `destroy` - exits the current state, terminates all hooks, and stops machine.
135 |
136 | - `namedBy(string)` - sets name of the instance (for debug).
137 |
138 | For all callbacks the first argument is `flow` instance, containing.
139 |
140 | - `attrs` - all the attrs, you cannot change them
141 |
142 | - `state` - internal state
143 | - `setState` - internal state change command
144 |
145 | - `phase` - current phase
146 | - `transitTo` - phase change command.
147 |
148 | - `startTimer(timerName)` - starts a Timer
149 | - `stopTimer(timerName)` - stops a Timer
150 |
151 | - `emit` - emits a message to the outer world
152 |
153 | ### Magic events
154 |
155 | - `@init` - on initialization
156 | - `@enter` - on phase enter, last phase will be passed as a second arg.
157 | - `@leave` - on phase enter, new phase will be passed as a second arg.
158 | - `@change` - on state change, old state will be passed as a second arg.
159 | - `@miss` - on event without handler
160 | - `@error` - an error handler. If no errors handler will be found the real error will be thrown
161 |
162 | ### Magic phases
163 |
164 | - `@current` - set the same phase as it was on the handler entry
165 | - `@busy` - set the _busy_ phase, when no other handler could be called
166 |
167 | ### Hooks
168 |
169 | Hook activates when message starts or ends it existence, ie when there is `on` callback defined for it.
170 |
171 | ### Event bus
172 |
173 | - message handler could change phase, state and trigger a new message
174 | - hook could change state or trigger a new message, but not change phase
175 | - external consumer could only trigger a new message
176 |
177 | ## InternalState
178 |
179 | Each `on` or `hook` handler will receive `internalState` as a first argument, with following shape
180 |
181 | ```js
182 | attrs: { ...
183 | AttributesYouSet
184 | }
185 | ; // attributes
186 | state: { ..
187 | CurrentState
188 | }
189 | ; // state
190 |
191 | setState(newState); // setState (as seen in React)
192 |
193 | transitTo(phase); // move to a new phase
194 |
195 | emit(message, ...args); // emit Signal to the outer world (connected)
196 |
197 | trigger(event, ...args); // trigger own message handler (dispatch an internal action)
198 | ```
199 |
200 | # Debug
201 |
202 | Debug mode is integrated into Faste.
203 |
204 | ```js
205 | import {setFasteDebug} from 'faste'
206 |
207 | setFasteDebug(true);
208 | setFasteDebug(true);
209 | setFasteDebug((instance, message, args) => console.log(...));
210 | ```
211 |
212 | # Examples
213 |
214 | Try online : https://codesandbox.io/s/n7kv9081xp
215 |
216 | ### Using different handlers in different states
217 |
218 | ```js
219 | // common code - invert the flag
220 | onClick = () => this.setState((state) => ({ enabled: !state.enabled }));
221 |
222 | // faste - use different flags for different states
223 | faste()
224 | .on('click', 'disabled', ({ transitTo }) => transitTo('enabled'))
225 | .on('click', 'enabled', ({ transitTo }) => transitTo('disabled'));
226 | ```
227 |
228 | ### React to state change
229 |
230 | ```js
231 | // common code - try to reverse engineer the change
232 | componentDidUpdate(oldProps)
233 | {
234 | if (oldProps.enabled !== this.props.enabled) {
235 | if (this.props.enabled) {
236 | // I was enabled!
237 | } else {
238 | // I was disabled!
239 | }
240 | }
241 | }
242 |
243 | // faste - use "magic" methods
244 | faste()
245 | .on('@enter', ['disabled'], () => /* i was disabled */)
246 | .on('@enter', ['enabled'], () => /* i was enabled */)
247 | // or
248 | .on('@leave', ['disabled'], () => /* i am no more disabled */)
249 | .on('@leave', ['enabled'], () => /* i am no more enabled */)
250 | ```
251 |
252 | ### Connected states
253 |
254 | https://codesandbox.io/s/5zx8zl91ll
255 |
256 | ```js
257 | // starts a timer when active
258 | const SignalSource = faste()
259 | .on('@enter', ['active'], ({ setState, attrs, emit }) =>
260 | setState({ interval: setInterval(() => emit('message'), attrs.duration) })
261 | )
262 | .on('@leave', ['active'], ({ state }) => clearInterval(state.interval));
263 |
264 | // responds to "message" by moving from tick to tock
265 | // emiting the current state outside
266 | const TickState = faste()
267 | // autoinit to "tick" mode
268 | .on('@init', ({ transitTo }) => transitTo('tick'))
269 | // message handlers
270 | .on('message', ['tick'], ({ transitTo }) => transitTo('tock'))
271 | .on('message', ['tock'], ({ transitTo }) => transitTo('tick'))
272 | .on('@leave', ({ emit }, newPhase) => emit('currentState', newPhase));
273 |
274 | // just transfer message to attached node
275 | const DisplayState = faste().on('currentState', ({ attrs }, message) => (attrs.node.innerHTML = message));
276 |
277 | // create machines
278 | const signalSource = SignalSource.create().attrs({
279 | duration: 1000,
280 | });
281 | const tickState = TickState.create();
282 | const displayState = DisplayState.create().attrs({
283 | node: document.querySelector('.display'),
284 | });
285 |
286 | // direct connect signal source and ticker
287 | signalSource.connect(tickState);
288 |
289 | // "functionaly" connect tickes and display
290 | tickState.connect((message, payload) => displayState.put(message, payload));
291 |
292 | // RUN! start signal in active mode
293 | signalSource.start('active');
294 | ```
295 |
296 | ### Traffic light
297 |
298 | ```js
299 | const state = faste()
300 | .withPhases(['red', 'yellow', 'green'])
301 | .withMessages(['tick', 'next'])
302 |
303 | .on('tick', ['green'], ({ transit }) => transit('yellow'))
304 | .on('tick', ['yellow'], ({ transit }) => transit('red'))
305 | .on('tick', ['red'], ({ transit }) => transit('green'))
306 |
307 | // on 'next' trigger 'tick' for a better debugging.
308 | // just rethrow event
309 | .on('next', [], ({ trigger }) => trigger('tick'))
310 |
311 | // on "green" - start timer
312 | .on('@enter', ['green'], ({ setState, attrs, trigger }) =>
313 | setState({
314 | interval: setInterval(() => trigger('next'), attrs.duration),
315 | })
316 | )
317 | // on "red" - stop timer
318 | .on('@leave', ['red'], ({ state }) => clearInterval(state.interval))
319 |
320 | .check();
321 |
322 | state.create().attrs({ duration: 1000 }).start('green');
323 | ```
324 |
325 | Try online : https://codesandbox.io/s/n7kv9081xp
326 |
327 | ### Draggable
328 |
329 | ```js
330 | const domHook =
331 | (eventName) =>
332 | ({ attrs, trigger }) => {
333 | const callback = (event) => trigger(eventName, event);
334 | attrs.node.addEventListener(eventName, callback);
335 | // "hook" could return anything, callback for example
336 | return () => {
337 | attrs.node.removeEventListener(eventName, hook);
338 | };
339 | };
340 |
341 | const state = faste({})
342 | .on('@enter', ['active'], ({ emit }) => emit('start'))
343 | .on('@leave', ['active'], ({ emit }) => emit('end'))
344 |
345 | .on('mousedown', ['idle'], ({ transitTo }) => transitTo('active'))
346 | .on('mousemove', ['active'], (_, event) => emit('move', event))
347 | .on('mouseup', ['active'], ({ transitTo }) => transitTo('idle'));
348 |
349 | hooks({
350 | mousedown: domHook('mousedown'),
351 | mousemove: domHook('mousemove'),
352 | mouseup: domHook('mouseup'),
353 | })
354 | .check()
355 |
356 | .attr({ node: document.body })
357 | .start('idle');
358 | ```
359 |
360 | # Async
361 |
362 | Message handler doesn't have to be sync. But managing async commands could be hard. But will not
363 |
364 | 1. Accept command only in initial state, then transit to temporal state to prevent other commands to be executes.
365 |
366 | ```js
367 | const Login = faste().on('login', ['idle'], ({ transitTo }, { userName, password }) => {
368 | transitTo('logging-in'); // just transit to "other" state
369 | login(userName, password)
370 | .then(() => transitTo('logged'))
371 | .catch(() => transitTo('error'));
372 | });
373 | ```
374 |
375 | 2. Accept command only in initial state, then transit to execution state, and do the job on state enter
376 |
377 | ```js
378 | const Login = faste()
379 | .on('login', ['idle'], ({transitTo}, data) => transitTo('logging', data)
380 | .on('@enter', ['logging'], ({transitTo}, {userName, password}) => {
381 | login(userName, password)
382 | .then(() => transitTo('logged'))
383 | .catch(() => transitTo('error'))
384 | });
385 | ```
386 |
387 | 2. Always accept command, but be "busy" while doing stuff
388 |
389 | ```js
390 | const Login = faste().on('login', ({ transitTo }, { userName, password }) => {
391 | transitTo('@busy'); // we are "busy"
392 | return login(userName, password)
393 | .then(() => transitTo('logged'))
394 | .catch(() => transitTo('error'));
395 | });
396 | ```
397 |
398 | > handler returns Promise( could be async ) to indicate that ending in @busy state is not a mistake, and will not lead
399 | > to deadlock.
400 |
401 | By default `@busy` will queue messages, executing them after leaving busy phase.
402 | If want to ignore them - instead of `@busy`, you might use `@locked` phase, which will ignore them.
403 |
404 | PS: You probably will never need those states.
405 |
406 | ## Using timers to create timers
407 |
408 | Plain variant
409 |
410 | ```tsx
411 | const SignalSource = faste()
412 | .on('@enter', ['active'], ({ setState, attrs, emit }) =>
413 | setState({ interval: setInterval(() => emit('message'), attrs.duration) })
414 | )
415 | .on('@leave', ['active'], ({ state }) => clearInterval(state.interval));
416 | ```
417 |
418 | Hook and timer based
419 |
420 | ```tsx
421 | const SignalSource = faste()
422 | .on('tick', ['active'], ({ emit, startTimer }) => {
423 | emit('message');
424 | })
425 | .hook({
426 | tick: ({ attrs }) => {
427 | const interval = setInterval(() => emit('message'), attrs.duration);
428 | return () => clearInterval(interval);
429 | },
430 | });
431 | ```
432 |
433 | Hook and timer based
434 |
435 | ```tsx
436 | const SignalSource = faste()
437 | .withTimers({
438 | T0: 1000,
439 | })
440 | .on('on_T0', ({ emit, startTimer }) => {
441 | emit('message');
442 | startTimer('T0'); //restarts timers
443 | })
444 | .on('@enter', ['active'], ({ startTimer }) => startTimer('T0'));
445 | ```
446 |
447 | # SDL and Block
448 |
449 | Faste was born from this. From Q.931(EDSS) state definition.
450 |
451 | How it starts. What signals it accepts. What it does next.
452 |
453 | 
454 |
455 | That is quite simple diagram.
456 |
457 | ## Thoughts
458 |
459 | This is a Finite State Machine from
460 | SDL([Specification and Description Language](https://en.wikipedia.org/wiki/Specification_and_Description_Language))
461 | prospective.
462 | SDL defines state as a set of messages, it should react on, and the actions beneath.
463 |
464 | Once `state` receives a `message` it executes an `action`, which could perform calculations and/or change the
465 | current state.
466 |
467 | > The goal is not to **change the state**, but - **execute a bound action**.
468 | > From this prospective faste is closer to RxJX.
469 |
470 | Usually "FSM" are more focused on state transitions, often even omitting any operations on message receive.
471 | In the Traffic Light example it could be useful, but in more real life examples - probably not.
472 |
473 | Faste is more about _when_ you will be able to do _what_. **What** you will do, **when** you receive event, and what you
474 | will do next.
475 |
476 | Keeping in mind the best practices, like KISS and DRY, it is better to invert state->message->action connection,
477 | as long as actions are most complex part of it, and messages are usually reused across different states.
478 |
479 | And, make things more common we will call "state" as a "phase", and "state" will be for "internal state".
480 |
481 | The key idea is not about transition between states, but transition between behaviors.
482 | Keep in mind - if some handler is not defined in some state, and you are sending a message - it will be **lost**.
483 |
484 | > Written in TypeScript. To make things less flexible. Flow definitions as incomplete.
485 |
486 | # Prior art
487 |
488 | This library combines ideas from [xstate](https://github.com/davidkpiano/xstate)
489 | and [redux-saga](https://github.com/redux-saga/redux-saga).
490 | The original idea is based on [xflow](https://gist.github.com/theKashey/93f10d036961f4bd7dd728153bc4bea9) state machine,
491 | developed for [CT Company](http://www.ctcom.com.tw)'s VoIP solutions back in 2005.
492 |
493 | # Licence
494 |
495 | MIT
496 |
--------------------------------------------------------------------------------
/__tests__/attrs.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | describe('Faste attrs', () => {
4 | it('bi-dirrectional light control', () => {
5 | const light = faste()
6 | .withPhases(['red', 'yellow', 'green'])
7 | .withMessages(['tick'])
8 | .withState({ direction: null })
9 | .withAttrs({ direction: 1 })
10 |
11 | .on('@init', ({ setState, attrs }) => setState({ direction: attrs.direction }))
12 |
13 | .on('tick', ['red'], ({ transitTo, setState }) => {
14 | setState({ direction: 1 });
15 | transitTo('yellow');
16 | })
17 | .on('tick', ['yellow'], ({ transitTo, state }) => transitTo(state.direction ? 'green' : 'red'))
18 | .on('tick', ['green'], ({ transitTo, setState }) => {
19 | setState({ direction: 0 });
20 | transitTo('yellow');
21 | })
22 |
23 | .create();
24 |
25 | light.attrs({ direction: 1 });
26 | light.start('yellow');
27 |
28 | expect(light.phase()).toBe('yellow');
29 | expect(light.put('tick').phase()).toBe('green');
30 |
31 | light.attrs({ direction: 0 });
32 | light.start('yellow');
33 |
34 | expect(light.phase()).toBe('yellow');
35 | expect(light.put('tick').phase()).toBe('red');
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/__tests__/busy.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | describe('Faste busy', () => {
4 | it('busy flow', () => {
5 | const uncertainty = faste()
6 | .withMessages(['tick', 'observe'])
7 | .withPhases(['idle'])
8 | .withState({ counter: 0 })
9 | .withMessageArguments<{
10 | observe: [observer: Promise];
11 | }>()
12 | .on('tick', ({ setState, state }) => setState({ counter: state.counter + 1 }))
13 | .on('observe', async ({ transitTo }, observer) => {
14 | transitTo('@busy');
15 |
16 | observer.then(() => {
17 | transitTo('@current');
18 | });
19 | })
20 |
21 | .create()
22 | .start('idle');
23 |
24 | expect(uncertainty.put('tick').instance().state.counter).toBe(1);
25 | expect(uncertainty.put('tick').instance().state.counter).toBe(2);
26 |
27 | let pResolve: () => void;
28 | const p = new Promise((resolve) => {
29 | pResolve = resolve;
30 | });
31 | uncertainty.put('observe', p);
32 | expect(uncertainty.put('tick').instance().state.counter).toBe(2);
33 | expect(uncertainty.put('tick').instance().state.counter).toBe(2);
34 |
35 | pResolve();
36 |
37 | return p.then(() => {
38 | expect(uncertainty.put('tick').instance().state.counter).toBe(5);
39 | expect(uncertainty.put('tick').instance().state.counter).toBe(6);
40 | });
41 | });
42 |
43 | it('locked flow', () => {
44 | const uncertainty = faste()
45 | .withMessages(['tick', 'observe'])
46 | .withState({ counter: 0 })
47 | .withPhases(['idle'])
48 | .withMessageArguments<{
49 | observe: [observer: Promise];
50 | }>()
51 | .on('tick', ({ setState, state }) => setState({ counter: state.counter + 1 }))
52 | .on('observe', async ({ transitTo }, observer) => {
53 | transitTo('@locked');
54 | observer.then(() => transitTo('@current'));
55 | })
56 |
57 | .create()
58 | .start('idle');
59 |
60 | expect(uncertainty.put('tick').instance().state.counter).toBe(1);
61 | expect(uncertainty.put('tick').instance().state.counter).toBe(2);
62 |
63 | let pResolve: () => void;
64 | const p = new Promise((resolve) => {
65 | pResolve = resolve;
66 | });
67 | uncertainty.put('observe', p);
68 | expect(uncertainty.put('tick').instance().state.counter).toBe(2);
69 | expect(uncertainty.put('tick').instance().state.counter).toBe(2);
70 |
71 | pResolve();
72 |
73 | return p.then(() => {
74 | expect(uncertainty.put('tick').instance().state.counter).toBe(3);
75 | expect(uncertainty.put('tick').instance().state.counter).toBe(4);
76 | });
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/__tests__/errors.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | describe('Faste error handling', () => {
4 | const machineFactory = () =>
5 | faste().on('@init', () => {
6 | throw new Error('error');
7 | });
8 |
9 | const asyncMachineFactory = () =>
10 | faste().on('@init', () =>
11 | Promise.resolve().then(() => {
12 | throw new Error('error');
13 | })
14 | );
15 |
16 | it('throws if unprotected', () => {
17 | expect(() => {
18 | machineFactory().create().start();
19 | }).toThrow();
20 | });
21 |
22 | it('handles error', () => {
23 | const trap = jest.fn();
24 | const machine = machineFactory().on('@error', (_, error) => {
25 | trap(error);
26 | });
27 |
28 | expect(() => {
29 | machine.create().start();
30 | }).not.toThrow();
31 |
32 | expect(trap).toHaveBeenCalledWith(expect.any(Error));
33 | });
34 |
35 | describe('async', () => {
36 | it.only('does not handles async error', async () => {
37 | const trap = jest.fn();
38 | const machine = asyncMachineFactory().on('@error', (_, error) => {
39 | trap(error);
40 | });
41 |
42 | expect(() => {
43 | machine.create().start();
44 | }).not.toThrow();
45 |
46 | expect(trap).not.toHaveBeenCalled();
47 |
48 | await 1;
49 | await 1;
50 |
51 | expect(trap).toHaveBeenCalled();
52 | });
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/__tests__/event-order.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | describe('Faste order', () => {
4 | it('trigger', () => {
5 | const order: number[] = [];
6 | const machine = faste()
7 | .withMessages(['1', '2', '3'])
8 | .on('@init', ({ trigger }) => {
9 | trigger('1');
10 | trigger('2');
11 | })
12 | .on('1', ({ trigger }) => {
13 | order.push(1);
14 | trigger('3');
15 | })
16 | .on('2', ({ trigger }) => {
17 | order.push(2);
18 | })
19 | .on('3', ({ trigger }) => {
20 | order.push(3);
21 | });
22 | machine.create().start();
23 | expect(order).toEqual([1, 2, 3]);
24 | });
25 |
26 | describe('emit', () => {
27 | const factory = () =>
28 | faste()
29 | .withMessages(['ping'])
30 | .withSignals(['pong'])
31 | .on('ping', ({ emit }) => {
32 | emit('pong');
33 | });
34 |
35 | it('sync', () => {
36 | const trap = jest.fn();
37 | const instance = factory().create().start();
38 | instance.connect(trap);
39 | instance.put('ping');
40 |
41 | expect(trap).toHaveBeenCalledWith('pong');
42 | });
43 |
44 | it('async', async () => {
45 | const trap = jest.fn();
46 | const instance = factory().withAsyncSignals().create().start();
47 | instance.connect(trap);
48 | instance.put('ping');
49 |
50 | expect(trap).not.toHaveBeenCalled();
51 | await 1;
52 | expect(trap).toHaveBeenCalledWith('pong');
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/__tests__/guards.test.ts:
--------------------------------------------------------------------------------
1 | import {faste} from "../src";
2 |
3 | describe('Faste guards', () => {
4 | it('guards to red', () => {
5 | const light = faste()
6 | .withPhases(['red', 'green'])
7 | .withMessages(['tick'])
8 | .withState(({count: 0}))
9 | .on('tick', ['green'], ({transitTo, setState}) => {
10 | setState(s => ({count: s.count + 1}));
11 | transitTo('red')
12 | })
13 | .on('tick', ['red'], ({transitTo}) => transitTo('green'))
14 | .guard(['red'], ({state}) => state.count > 2)
15 |
16 | .create();
17 |
18 | light.start('green');
19 |
20 | expect(light.put('tick').phase()).toBe('green');
21 | expect(light.put('tick').phase()).toBe('green');
22 | expect(light.put('tick').phase()).toBe('red');
23 | expect(light.put('tick').phase()).toBe('green');
24 | expect(light.put('tick').phase()).toBe('red');
25 | });
26 |
27 | it('guards to green', () => {
28 | const light = faste()
29 | .withPhases(['red', 'green'])
30 | .withMessages(['tick'])
31 | .withState(({count: 0}))
32 | .on('tick', ['green'], ({transitTo, setState}) => {
33 | setState(s => ({count: s.count + 1}));
34 | transitTo('red')
35 | })
36 | .on('tick', ['red'], ({transitTo}) => transitTo('green'))
37 | .guard(['green'], ({state}) => state.count > 2)
38 |
39 | .create();
40 |
41 | expect(() => light.start('green')).toThrow();
42 | });
43 |
44 | it('guards to green from red', () => {
45 | const light = faste()
46 | .withPhases(['red', 'green'])
47 | .withMessages(['tick'])
48 | .withState(({count: 0}))
49 | .on('tick', ['green'], ({transitTo, setState}) => {
50 | setState(s => ({count: s.count + 1}));
51 | transitTo('red')
52 | })
53 | .on('tick', ['red'], ({transitTo}) => transitTo('green'))
54 | .guard(['green'], ({state}) => state.count > 2)
55 |
56 | .create();
57 |
58 | light.start('red');
59 | });
60 |
61 | it('trap to green', () => {
62 | const light = faste()
63 | .withPhases(['red', 'green'])
64 | .withMessages(['tick'])
65 | .withState(({count: 0}))
66 | .on('tick', ['green'], ({transitTo, setState}) => {
67 | setState(s => ({count: s.count + 1}));
68 | transitTo('red')
69 | })
70 | .on('tick', ['red'], ({transitTo}) => transitTo('green'))
71 | .trap(['green'], ({state}) => state.count > 2)
72 |
73 | .create();
74 |
75 | light.start('green');
76 | expect(light.put('tick').phase()).toBe('green');
77 | expect(light.put('tick').phase()).toBe('green');
78 | expect(light.put('tick').phase()).toBe('red');
79 | expect(light.put('tick').phase()).toBe('green');
80 | expect(light.put('tick').phase()).toBe('red');
81 | })
82 | });
--------------------------------------------------------------------------------
/__tests__/hooks.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | describe('Faste hooks', () => {
4 | it('simple light control', () => {
5 | let tockHandler: (a: any) => void = undefined;
6 |
7 | const light = faste()
8 | .withPhases(['red', 'yellow', 'green'])
9 | .withMessages(['tick', 'tock'])
10 |
11 | .on('tick', ['red'], ({ transitTo }) => transitTo('yellow'))
12 | .on('tick', ['yellow'], ({ transitTo }) => transitTo('green'))
13 | .on('tock', ['yellow'], ({ transitTo }) => transitTo('green'))
14 | .on('tick', ['green'], ({ transitTo }) => transitTo('red'))
15 |
16 | .hooks({
17 | tock: (st) => {
18 | if (st.message === 'tock') {
19 | tockHandler = st.trigger;
20 | }
21 |
22 | return (st) => {
23 | if (st.message === 'tock') {
24 | tockHandler = undefined;
25 | }
26 | };
27 | },
28 | })
29 | .create();
30 |
31 | light.start('red');
32 |
33 | expect(light.phase()).toBe('red');
34 | expect(tockHandler).not.toBeDefined();
35 | expect(light.put('tick').phase()).toBe('yellow');
36 |
37 | expect(tockHandler).toBeDefined();
38 | tockHandler!('tick');
39 | expect(light.phase()).toBe('green');
40 | expect(tockHandler).not.toBeDefined();
41 |
42 | expect(light.put('tick').phase()).toBe('red');
43 | });
44 |
45 | it('simple light control', () => {
46 | let tockHandler = false;
47 |
48 | const light = faste()
49 | .withMessages(['tick', 'tock'])
50 | .withPhases(['green', 'red', 'yellow'])
51 | .on('@enter', ['red'], ({ transitTo }) => transitTo('yellow'))
52 | .on('tock', ['yellow'], ({ transitTo }) => transitTo('green'))
53 |
54 | .hooks({
55 | tock: () => {
56 | tockHandler = true;
57 |
58 | return () => {
59 | tockHandler = false;
60 | };
61 | },
62 | })
63 |
64 | .create();
65 |
66 | light.start('red');
67 |
68 | expect(tockHandler).toBeTruthy();
69 | light.destroy();
70 | expect(tockHandler).toBeFalsy();
71 | });
72 | });
73 |
--------------------------------------------------------------------------------
/__tests__/phone.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | type DTMF_CHAR = `${number}` | '*';
4 |
5 | describe('Faste phone', () => {
6 | it('call me', () => {
7 | const buttons = faste()
8 | .withMessages(['press'])
9 | .withSignals<`DTMF-${DTMF_CHAR}`>()
10 | .withMessageArguments<{
11 | press: [digit: DTMF_CHAR];
12 | }>()
13 | .on('press', ({ emit }, digit) => emit(`DTMF-${digit}`))
14 | .create()
15 | .start();
16 |
17 | const collector = faste()
18 | .withState({
19 | number: '',
20 | lastNumber: '',
21 | })
22 | .withMessages<`DTMF-${DTMF_CHAR}`>()
23 | .withSignals(['call', 'digit'])
24 | .withSignalArguments<{
25 | call: [phoneNumber: string];
26 | digit: [char: string];
27 | }>()
28 | .scope((faste) =>
29 | Array(9)
30 | .fill(1)
31 | .forEach((_, number) =>
32 | faste.on(`DTMF-${number}`, ({ state, setState }) =>
33 | setState({
34 | number: state.number + number,
35 | lastNumber: String(number),
36 | })
37 | )
38 | )
39 | )
40 | .on('DTMF-*', ({ setState }) => setState({ number: '' }))
41 | .on('@change', ({ state, emit }, oldState) => {
42 | if (state.number.length >= 7) {
43 | emit('call', state.number);
44 | }
45 |
46 | if (state.lastNumber !== oldState.lastNumber) {
47 | emit('digit', state.lastNumber);
48 | }
49 | })
50 | .create()
51 | .start();
52 |
53 | const phone = faste()
54 | .withMessages(['pickup', 'call', 'digit', 'hang'])
55 | .withSignals(['DTMF'])
56 | .withPhases(['idle', 'calling', 'incall', 'end'])
57 | .withState<{ calledNumber: unknown | string }>({ calledNumber: undefined })
58 | .withMessageArguments<{
59 | call: [number: number];
60 | digit: [char: string];
61 | }>()
62 | .withSignalArguments<{
63 | DTMF: [string];
64 | }>()
65 | .on('@init', ({ transitTo }) => transitTo('idle'))
66 | .on('pickup', ['idle'], ({ transitTo }) => transitTo('calling'))
67 | .on('call', ['calling'], ({ transitTo, setState }, number) => {
68 | setState({ calledNumber: number });
69 | transitTo('incall');
70 | })
71 | .on('digit', ['incall'], ({ emit }, digit) => emit('DTMF', digit))
72 | .on('hang', ({ transitTo }) => transitTo('idle'))
73 | .create()
74 | .start();
75 |
76 | buttons.connect(collector);
77 | collector.connect(phone);
78 |
79 | const spy = jest.fn();
80 | phone.observe(spy);
81 |
82 | const callANumber = (number: string) => {
83 | number.split('').forEach((c) => buttons.put('press', c as DTMF_CHAR));
84 | };
85 |
86 | callANumber('555-55-55');
87 | expect(phone.phase()).toBe('idle');
88 | expect(spy).not.toHaveBeenCalled();
89 |
90 | phone.put('pickup');
91 | callANumber('*555-55');
92 | expect(phone.phase()).toBe('calling');
93 | callANumber('-551234');
94 | expect(phone.phase()).toBe('incall');
95 | expect(spy).toHaveBeenCalledWith('incall');
96 |
97 | expect((phone.instance().state).calledNumber).toBe('5555555');
98 | });
99 | });
100 |
--------------------------------------------------------------------------------
/__tests__/reactish.test.ts:
--------------------------------------------------------------------------------
1 | import {faste} from "../src";
2 |
3 | describe('Faste react', () => {
4 | it('react interface', () => {
5 |
6 | // class Component {
7 | //
8 | // onEvent = (event: Event) => machine.put(event.type, event);
9 | //
10 | // render() {
11 | // //return
12 | // }
13 | // }
14 | })
15 |
16 | })
--------------------------------------------------------------------------------
/__tests__/signature.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | describe('signatures', () => {
4 | it('messages', () => {
5 | faste()
6 | .withMessages(['tick', 'tock', 'test'])
7 | .withMessageArguments<{
8 | tick: [arg: { x: number }];
9 | }>()
10 | .on('tick', ({ trigger }, arg) => {
11 | console.log(
12 | arg.x,
13 | // @ts-expect-error
14 | arg.y
15 | );
16 | })
17 | .on('@init', ({ trigger }) => {
18 | // @ts-expect-error
19 | trigger('tick');
20 | // @ts-expect-error
21 | trigger('undefined');
22 | // @ts-expect-error
23 | trigger('tick', '1');
24 | // @ts-expect-error
25 | trigger('tick', 1, 2);
26 | // @ts-expect-error
27 | trigger('tock', 1, 2);
28 |
29 | trigger('tock');
30 | trigger('tick', { x: 1 });
31 | });
32 |
33 | expect(1).toBe(1);
34 | });
35 |
36 | it('signals', () => {
37 | faste()
38 | .withSignals(['tick', 'tock'])
39 | .withSignalArguments<{
40 | tick: [arg: { x: number }];
41 | }>()
42 | .on('@init', ({ emit }) => {
43 | // @ts-expect-error
44 | emit('tick');
45 | // @ts-expect-error
46 | emit('undefined');
47 | // @ts-expect-error
48 | emit('tick', '1');
49 | // @ts-expect-error
50 | emit('tick', 1, 2);
51 | // @ts-expect-error
52 | emit('tock', 1, 2);
53 |
54 | emit('tock');
55 | emit('tick', { x: 1 });
56 | });
57 |
58 | expect(1).toBe(1);
59 | });
60 |
61 | describe('default', () => {
62 | it('life cycle', () => {
63 | faste()
64 | .withMessages(['tick'])
65 | .withMessageArguments<{
66 | tick: [arg: number];
67 | }>()
68 | .on('@leave', (_, oldPhase) => {
69 | // @ts-expect-error
70 | oldPhase.startsWith('xx');
71 | });
72 |
73 | faste()
74 | .withMessages(['tick'])
75 | .withPhases(['some', 'another'])
76 | .withMessageArguments<{
77 | tick: [arg: number];
78 | }>()
79 | .on('@leave', (_, oldPhase, newPhase) => {
80 | oldPhase.startsWith('xx');
81 | newPhase.startsWith('xx');
82 | })
83 | .on('@enter', (_, oldPhase) => {
84 | oldPhase.startsWith('xx');
85 | })
86 | .on('@error', (_, error) => {
87 | error.stack;
88 | // @ts-expect-error
89 | error.x;
90 | });
91 | });
92 |
93 | it('state', () => {
94 | faste()
95 | .on('@change', (_) => {
96 | // nope
97 | })
98 | .on('@change', (_, oldState) => {
99 | // @ts-expect-error
100 | oldState.x;
101 | });
102 |
103 | faste()
104 | .withState({ x: 1 })
105 | .on('@change', (_) => {
106 | // nope
107 | })
108 | .on('@change', (_, oldState) => {
109 | oldState.x;
110 | });
111 | });
112 |
113 | it('connect', () => {
114 | const machine1 = faste()
115 | .withMessages(['in', 'out', 'sig-1', 'sig-2', 'sig-5'])
116 | .withMessageArguments<{
117 | 'sig-2': [number];
118 | }>()
119 | .withSignals(['sig-1', 'sig-2', 'sig-5', 'sig-6'])
120 | .withSignalArguments<{
121 | 'sig-1': [string];
122 | 'sig-5': [Date];
123 | }>()
124 | .create();
125 |
126 | machine1.connect((event, ...args) => {
127 | switch (event) {
128 | case 'sig-1':
129 | const [string] = machine1.castSignalArgument(event, ...args);
130 | string.startsWith('x');
131 | case 'sig-2':
132 | // @ts-expect-error
133 | const [any] = machine1.castSignalArgument(event, ...args);
134 | }
135 | });
136 |
137 | // @ts-expect-error
138 | machine1.connect(machine1);
139 |
140 | machine1.connect<'sig-1'>(machine1);
141 | machine1.connect<'sig-2'>(machine1);
142 | machine1.connect<'sig-5'>(machine1);
143 |
144 | faste()
145 | .withState({ x: 1 })
146 | .on('@change', (_) => {
147 | // nope
148 | })
149 | .on('@change', (_, oldState) => {
150 | oldState.x;
151 | });
152 | });
153 | });
154 | });
155 |
--------------------------------------------------------------------------------
/__tests__/simple.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | describe('Faste simple', () => {
4 | it('simple light control', () => {
5 | const light = faste()
6 | .withPhases(['red', 'yellow', 'green'])
7 | .withMessages(['tick'])
8 |
9 | .on('tick', ['red'], ({ transitTo }) => transitTo('yellow'))
10 | .on('tick', ['yellow'], ({ transitTo }) => transitTo('green'))
11 | .on('tick', ['green'], ({ transitTo }) => transitTo('red'))
12 |
13 | .create();
14 |
15 | light.start('red');
16 |
17 | expect(light.phase()).toBe('red');
18 | expect(light.put('tick').phase()).toBe('yellow');
19 | expect(light.put('tick').phase()).toBe('green');
20 | expect(light.put('tick').phase()).toBe('red');
21 | });
22 |
23 | it('bi-dirrectional light control', () => {
24 | const light = faste()
25 | .withPhases(['red', 'yellow', 'green'])
26 | .withMessages(['tick'])
27 | .withState({ direction: 1 })
28 |
29 | .on('tick', ['red'], ({ transitTo, setState }) => {
30 | setState({ direction: 1 });
31 | transitTo('yellow');
32 | })
33 | .on('tick', ['yellow'], ({ transitTo, state }) => transitTo(state.direction ? 'green' : 'red'))
34 | .on('tick', ['green'], ({ transitTo, setState }) => {
35 | setState({ direction: 0 });
36 | transitTo('yellow');
37 | })
38 |
39 | .create();
40 |
41 | light.start('red');
42 |
43 | expect(light.phase()).toBe('red');
44 | expect(light.put('tick').phase()).toBe('yellow');
45 | expect(light.put('tick').phase()).toBe('green');
46 | expect(light.put('tick').phase()).toBe('yellow');
47 | expect(light.put('tick').phase()).toBe('red');
48 | expect(light.put('tick').phase()).toBe('yellow');
49 | expect(light.put('tick').phase()).toBe('green');
50 | });
51 |
52 | it('simple light control @init', () => {
53 | const light = faste()
54 | .withPhases(['red', 'yellow', 'green'])
55 | .withMessages(['tick'])
56 |
57 | .on('tick', ['red'], ({ transitTo }) => transitTo('yellow'))
58 | .on('tick', ['yellow'], ({ transitTo }) => transitTo('green'))
59 | .on('tick', ['green'], ({ transitTo }) => transitTo('red'))
60 | .on('@init', ({ transitTo }) => transitTo('red'))
61 |
62 | .create();
63 |
64 | light.start();
65 |
66 | expect(light.phase()).toBe('red');
67 | expect(light.put('tick').phase()).toBe('yellow');
68 | expect(light.put('tick').phase()).toBe('green');
69 | expect(light.put('tick').phase()).toBe('red');
70 | });
71 |
72 | it('simple light control autochange', () => {
73 | const light = faste()
74 | .withPhases(['red', 'yellow', 'green'])
75 | .withMessages(['tick'])
76 |
77 | .on('@enter', ['red'], ({ transitTo }) => transitTo('yellow'))
78 | .on('@enter', ['yellow'], ({ transitTo }) => transitTo('green'))
79 | //.on('@enter', ['green'], ({transitTo}) => transitTo('red'))
80 |
81 | .create();
82 |
83 | light.start('red');
84 |
85 | expect(light.phase()).toBe('green');
86 | });
87 |
88 | it('simple light control self-trigger', () => {
89 | const light = faste()
90 | .withPhases(['red', 'yellow', 'green'])
91 | .withMessages(['tick'])
92 |
93 | .on('@enter', ['red'], ({ trigger }) => trigger('tick'))
94 | .on('@enter', ['yellow'], ({ trigger }) => trigger('tick'))
95 |
96 | .on('tick', ['red'], ({ transitTo }) => transitTo('yellow'))
97 | .on('tick', ['yellow'], ({ transitTo }) => transitTo('green'))
98 | .on('tick', ['green'], ({ transitTo }) => transitTo('red'))
99 |
100 | .create();
101 |
102 | light.start('red');
103 |
104 | expect(light.phase()).toBe('green');
105 | });
106 |
107 | it('external light control', () => {
108 | const light = faste()
109 | .withPhases(['red', 'yellow', 'green'])
110 | .withMessages(['tick'])
111 | .on('tick', ['red'], ({ transitTo }) => transitTo('yellow'))
112 | .on('tick', ['yellow'], ({ transitTo }) => transitTo('green'))
113 | .on('tick', ['green'], ({ transitTo }) => transitTo('red'))
114 |
115 | .create();
116 |
117 | const control = faste()
118 | .withMessages(['tock'])
119 | .withSignals(['tick'])
120 | .on('tock', ({ emit }) => emit('tick'))
121 |
122 | .create()
123 | .start();
124 |
125 | control.connect(light);
126 |
127 | light.start('red');
128 |
129 | expect(light.phase()).toBe('red');
130 | control.put('tock');
131 | expect(light.phase()).toBe('yellow');
132 | control.put('tock');
133 | expect(light.phase()).toBe('green');
134 | control.put('tock');
135 | expect(light.phase()).toBe('red');
136 | });
137 | });
138 |
--------------------------------------------------------------------------------
/__tests__/timer.test.ts:
--------------------------------------------------------------------------------
1 | import { faste } from '../src';
2 |
3 | describe('Faste timers', () => {
4 | it('simple light control', () => {
5 | jest.useFakeTimers();
6 |
7 | const timerCalled = jest.fn();
8 |
9 | const machine = faste()
10 | .withTimers({
11 | T0: 10,
12 | })
13 | .on('on_T0', () => timerCalled())
14 | // @ts-expect-error
15 | .on('on_TWrong', () => {
16 | // do nothing
17 | })
18 | .hooks({
19 | on_T0: ({ startTimer }) => {
20 | startTimer('T0');
21 | },
22 | });
23 |
24 | machine.create().start();
25 |
26 | expect(timerCalled).not.toHaveBeenCalled();
27 | jest.advanceTimersByTime(100);
28 | expect(timerCalled).toHaveBeenCalled();
29 | });
30 |
31 | it('lifecycle', () => {
32 | jest.useFakeTimers();
33 |
34 | const timerCalled = jest.fn();
35 |
36 | const machine = faste()
37 | .withTimers({
38 | T0: 10,
39 | })
40 | .on('on_T0', ({ startTimer }) => {
41 | timerCalled();
42 | startTimer('T0');
43 | })
44 | .hooks({
45 | on_T0: ({ startTimer }) => {
46 | startTimer('T0');
47 | },
48 | });
49 |
50 | const instance = machine.create().start();
51 |
52 | expect(timerCalled).not.toHaveBeenCalled();
53 | jest.advanceTimersByTime(15);
54 | expect(timerCalled).toHaveBeenCalledTimes(1);
55 | instance.destroy();
56 | jest.advanceTimersByTime(100);
57 | expect(timerCalled).toHaveBeenCalledTimes(1);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/assets/blocks.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theKashey/faste/56d817c4134038239cb88ac127bdafb5046a5730/assets/blocks.png
--------------------------------------------------------------------------------
/assets/capeu17.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theKashey/faste/56d817c4134038239cb88ac127bdafb5046a5730/assets/capeu17.gif
--------------------------------------------------------------------------------
/assets/table.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theKashey/faste/56d817c4134038239cb88ac127bdafb5046a5730/assets/table.png
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | machine:
2 | node:
3 | version: 8.9.1
4 |
5 | dependencies:
6 | override:
7 | - yarn
8 |
9 | test:
10 |
11 | override:
12 | - yarn test:ci
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "faste",
3 | "version": "2.0.0",
4 | "description": "Block-based, Finite State Machine, made simple",
5 | "main": "dist/es5/index.js",
6 | "types": "dist/es5/index.d.ts",
7 | "jsnext:main": "dist/es2015/index.js",
8 | "module": "dist/es2015/index.js",
9 | "files": [
10 | "dist"
11 | ],
12 | "scripts": {
13 | "dev": "lib-builder dev",
14 | "test": "jest",
15 | "test:ci": "jest --runInBand --coverage",
16 | "build": "lib-builder build && yarn size:report",
17 | "release": "yarn build && yarn test",
18 | "size": "size-limit",
19 | "size:report": "size-limit --json > .size.json",
20 | "lint": "lib-builder lint",
21 | "format": "lib-builder format",
22 | "update": "lib-builder update",
23 | "prepack": "yarn build && yarn changelog",
24 | "prepare": "husky install",
25 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
26 | "changelog:rewrite": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0"
27 | },
28 | "repository": "git+https://github.com/theKashey/faste.git",
29 | "bugs": {
30 | "url": "https://github.com/theKashey/faste/issues"
31 | },
32 | "homepage": "https://github.com/theKashey/faste#readme",
33 | "author": "Anton Korzunov (thekashey@gmail.com)",
34 | "license": "MIT",
35 | "devDependencies": {
36 | "@size-limit/preset-small-lib": "^8.1.2",
37 | "@theuiteam/lib-builder": "^0.2.3",
38 | "@types/node": "10.3.4"
39 | },
40 | "engines": {
41 | "node": ">=10"
42 | },
43 | "keywords": [
44 | "state machine",
45 | "state management"
46 | ],
47 | "dependencies": {
48 | "tslib": "^1.9.3"
49 | },
50 | "module:es2019": "dist/es2019/index.js",
51 | "lint-staged": {
52 | "*.{ts,tsx}": [
53 | "prettier --write",
54 | "eslint --fix"
55 | ],
56 | "*.{js,css,json,md}": [
57 | "prettier --write"
58 | ]
59 | },
60 | "prettier": {
61 | "printWidth": 120,
62 | "trailingComma": "es5",
63 | "tabWidth": 2,
64 | "semi": true,
65 | "singleQuote": true
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/faste-executor.ts:
--------------------------------------------------------------------------------
1 | import { callListeners, invokeAsync } from './helpers/call';
2 | import { debug } from './helpers/debug';
3 | import { isThenable } from './helpers/thenable';
4 | import { Guards } from './interfaces/guards';
5 | import { Hooks } from './interfaces/hooks';
6 | import { InternalMachine } from './interfaces/internal-machine';
7 | import { MessageHandler, MessagePhase } from './interfaces/messages';
8 | import { CallSignature, DefaultSignatures, ExtractSignature } from './interfaces/signatures';
9 | import { MAGIC_EVENTS, MAGIC_PHASES } from './types';
10 |
11 | type AnyConnectCall = (event: Signals, ...args: any[]) => void;
12 |
13 | type ConnectCall> = (
14 | event: Signal,
15 | ...args: ExtractSignature
16 | ) => void;
17 |
18 | const BUSY_PHASES: MAGIC_PHASES[] = ['@busy', '@locked'];
19 |
20 | const START_PHASE = '@start' as const;
21 | const STOP_PHASE = '@destroy' as const;
22 | type START_STOP_PHASES = typeof START_PHASE | typeof STOP_PHASE;
23 |
24 | export type FasteInstanceHooks = {
25 | handlers: MessageHandlers;
26 | hooks: FasteHooks;
27 | guards: FasteGuards;
28 | };
29 |
30 | export type FastInstanceState<
31 | State,
32 | Attributes,
33 | Phases,
34 | Messages extends string,
35 | Signals extends string,
36 | Timers extends string
37 | > = {
38 | state: State;
39 | attrs: Attributes;
40 | phase?: Phases | MAGIC_PHASES;
41 | instance?: InternalMachine;
42 | timers: Record;
43 | asyncSignals: boolean;
44 | };
45 | //
46 | // export type FastePutable> = {
47 | // put(message: Message, ...args: ExtractSignature): any;
48 | // }
49 |
50 | export type FastePutable> = {
51 | put(
52 | ...args: Parameters<
53 | FasteInstance['put']
54 | >
55 | ): any;
56 | };
57 |
58 | export class FasteInstance<
59 | State,
60 | Attributes,
61 | Phases extends string,
62 | Messages extends string,
63 | Signals extends string,
64 | MessageHandlers,
65 | FasteHooks extends Hooks,
66 | FasteGuards extends Guards,
67 | Timers extends string,
68 | MessageSignatures extends CallSignature,
69 | SignalSignatures extends CallSignature = CallSignature
70 | > {
71 | private state: FastInstanceState;
72 |
73 | private handlers: FasteInstanceHooks;
74 |
75 | private stateObservers: ((phase: Phases | MAGIC_PHASES | START_STOP_PHASES) => void)[];
76 | private messageObservers: ConnectCall[];
77 | private messageQueue: { message: Messages | MAGIC_EVENTS; args: any }[];
78 | private callDepth: number;
79 | private handlersOffValues: any;
80 | private _started = false;
81 | public name: string;
82 |
83 | private timers: Partial>;
84 |
85 | constructor(
86 | state: FastInstanceState,
87 | handlers: FasteInstanceHooks
88 | ) {
89 | this.state = { ...state };
90 | this.state.instance = this._createInstance({});
91 | this.handlers = { ...handlers };
92 | this.handlersOffValues = {};
93 |
94 | this.stateObservers = [];
95 | this.messageObservers = [];
96 | this.messageQueue = [];
97 | this.timers = {};
98 | }
99 |
100 | private _collectHandlers(phase: Phases | MAGIC_PHASES): { [key: string]: boolean } {
101 | const h = this.handlers.handlers as any;
102 |
103 | return Object.keys(h)
104 | .filter((handler) =>
105 | h[handler].some((hook: MessagePhase) => !hook.phases || hook.phases.indexOf(phase) >= 0)
106 | )
107 | .reduce((acc, key) => ({ ...acc, [key]: true }), {});
108 | }
109 |
110 | private _setState(newState: Partial) {
111 | const oldState = this.state.state;
112 | this.state.state = Object.assign({}, oldState, newState);
113 | // @ts-expect-error
114 | this.put('@change', oldState);
115 | }
116 |
117 | private _trySingleGuard(phase: Phases | MAGIC_PHASES, isTrap: boolean): boolean {
118 | const instance = this._createInstance({
119 | phase: phase,
120 | });
121 |
122 | // find traps
123 | return this.handlers.guards
124 | .filter(({ state, trap }) => state.indexOf(phase) >= 0 && trap === isTrap)
125 | .reduce((acc, { callback }) => acc && callback(instance as any), true);
126 | }
127 |
128 | private _tryGuard(oldPhase: Phases | MAGIC_PHASES, newPhase: Phases | MAGIC_PHASES): boolean {
129 | return this._trySingleGuard(oldPhase, true) && this._trySingleGuard(newPhase, false);
130 | }
131 |
132 | private _transitTo(phase: Phases | MAGIC_PHASES) {
133 | const oldPhase = this.state.phase;
134 | debug(this, 'transit', phase);
135 |
136 | if (oldPhase != phase) {
137 | if (!this._tryGuard(oldPhase, phase)) {
138 | this.__put('@guard', phase);
139 |
140 | return false;
141 | }
142 |
143 | if (oldPhase) {
144 | this.__put('@leave', phase, oldPhase);
145 | }
146 |
147 | this.__performHookOn(phase);
148 | this.state.phase = phase;
149 |
150 | if (!this._started) {
151 | this._initialize();
152 | }
153 |
154 | callListeners(this.stateObservers, phase);
155 |
156 | this.__put('@enter', oldPhase, phase);
157 | }
158 |
159 | return true;
160 | }
161 |
162 | private _createInstance(options: {
163 | phase?: Phases | MAGIC_PHASES;
164 | message?: Messages | MAGIC_EVENTS;
165 | }): InternalMachine {
166 | return {
167 | phase: this.state.phase,
168 | state: this.state.state,
169 | attrs: this.state.attrs,
170 | message: options.message,
171 | setState: (newState) =>
172 | typeof newState === 'function' ? this._setState(newState(this.state.state)) : this._setState(newState),
173 | transitTo: (phase) => this._transitTo(phase === '@current' ? options.phase : phase),
174 | emit: (message, ...args) => {
175 | if (!this._started) {
176 | // there could be events running after destruction
177 | return;
178 | }
179 |
180 | this.state.asyncSignals
181 | ? invokeAsync(() => callListeners(this.messageObservers as ConnectCall[], message, ...args))
182 | : callListeners(this.messageObservers as ConnectCall[], message, ...args);
183 | },
184 | trigger: (event, ...args) => this.put(event, ...(args as any)),
185 | startTimer: (timerName) => {
186 | if (!this._started) {
187 | // there could be events running after destruction
188 | return;
189 | }
190 |
191 | if (!this.timers[timerName]) {
192 | if (!(timerName in this.state.timers)) {
193 | throw new Error(`cannot start timer ${timerName} as it missing configuration`);
194 | }
195 |
196 | this.timers[timerName] = +setTimeout(() => {
197 | this.timers[timerName] = undefined;
198 | // @ts-expect-error
199 | this.put(`on_${timerName}` as any);
200 | }, this.state.timers[timerName]);
201 | }
202 | },
203 | stopTimer: (timerName) => {
204 | if (this.timers[timerName]) {
205 | clearTimeout(this.timers[timerName]);
206 | this.timers[timerName] = undefined;
207 | }
208 | },
209 | };
210 | }
211 |
212 | private __performHookOn(nextPhase: Phases | MAGIC_PHASES | null, initialState = false) {
213 | const oldHandlers = !initialState ? this._collectHandlers(this.state.phase) : {};
214 | const newHandlers = initialState || nextPhase ? this._collectHandlers(nextPhase) : {};
215 |
216 | const instance = this._createInstance({
217 | phase: this.state.phase,
218 | });
219 | const h = this.handlers.hooks;
220 |
221 | Object.keys(newHandlers).forEach((handler: Messages) => {
222 | if (!oldHandlers[handler] && h[handler]) {
223 | debug(this, 'hook-on', h[handler]);
224 |
225 | this.handlersOffValues[handler] = h[handler]({
226 | ...instance,
227 | phase: undefined,
228 | message: handler,
229 | });
230 | }
231 | });
232 |
233 | Object.keys(oldHandlers).forEach((handler: Messages) => {
234 | if (!newHandlers[handler] && h[handler] && this.handlersOffValues[handler]) {
235 | debug(this, 'hook-off', h[handler]);
236 |
237 | this.handlersOffValues[handler]({
238 | ...instance,
239 | phase: undefined,
240 | message: handler,
241 | });
242 |
243 | this.handlersOffValues[handler] = undefined;
244 | }
245 | });
246 | }
247 |
248 | private __put(event: string, ...args: any[]): number {
249 | this.callDepth++;
250 |
251 | const result = this.__direct_put(event, ...args);
252 | this.callDepth--;
253 |
254 | if (BUSY_PHASES.indexOf(this.state.phase as any) === -1) {
255 | if (!this.callDepth) {
256 | this._executeMessageQueue();
257 | }
258 | }
259 |
260 | return result;
261 | }
262 |
263 | private __direct_put(event: string, ...args: any[]): number {
264 | debug(this, 'put', event, args);
265 |
266 | const h: any = this.handlers.handlers;
267 | const handlers: MessageHandler void>[] = h[event] as any;
268 | let hits = 0;
269 |
270 | const assertBusy = (result: Promise | any) => {
271 | if (BUSY_PHASES.indexOf(this.state.phase as any) >= 0) {
272 | if (isThenable(result)) {
273 | // this is async handler
274 | } else {
275 | throw new Error('faste: @busy should only be applied for async handlers');
276 | }
277 | }
278 |
279 | return result;
280 | };
281 |
282 | // Precache state, to prevent message to be passed to the changed state
283 | const phase = this.state.phase;
284 |
285 | if (handlers) {
286 | const instance = this._createInstance({
287 | phase,
288 | message: event as any,
289 | });
290 |
291 | const handleError = (error: Error) => {
292 | if (!this.__direct_put('@error', error)) {
293 | throw error;
294 | }
295 | };
296 |
297 | const executeHandler = (handler: (typeof handlers)[0]) => {
298 | debug(this, 'message-handler', event, handler);
299 |
300 | try {
301 | const invocationResult = assertBusy(handler.callback(instance, ...args));
302 |
303 | if (isThenable(invocationResult)) {
304 | invocationResult.catch(handleError);
305 | }
306 | } catch (e) {
307 | handleError(e);
308 | }
309 |
310 | hits++;
311 | };
312 |
313 | handlers.forEach((handler) => {
314 | if (handler.phases && handler.phases.length > 0) {
315 | if (handler.phases.indexOf(phase as any) >= 0) {
316 | executeHandler(handler);
317 | }
318 | } else {
319 | executeHandler(handler);
320 | }
321 | });
322 | }
323 |
324 | if (!hits) {
325 | if (event[0] !== '@') {
326 | this.__put('@miss', event);
327 | }
328 | }
329 |
330 | return hits;
331 | }
332 |
333 | _executeMessageQueue() {
334 | while (this.messageQueue.length) {
335 | const q = this.messageQueue;
336 | this.messageQueue = [];
337 | this.callDepth++;
338 | q.forEach((q) => this.__put(q.message, ...q.args));
339 | this.callDepth--;
340 | }
341 | }
342 |
343 | /**
344 | * sets name to a machine (debug only)
345 | * @param n
346 | */
347 | namedBy(n: string) {
348 | this.name = n;
349 |
350 | return this;
351 | }
352 |
353 | /**
354 | * starts the machine
355 | * @param phase
356 | */
357 | start(phase?: Phases): this {
358 | this.messageQueue = [];
359 | this.callDepth = 0;
360 | this._started = false;
361 |
362 | if (phase) {
363 | callListeners(this.stateObservers, START_PHASE);
364 |
365 | if (!this._transitTo(phase)) {
366 | throw new Error('Faste machine initialization failed - phase was rejected');
367 | }
368 | } else {
369 | this._initialize();
370 | }
371 |
372 | return this;
373 | }
374 |
375 | /**
376 | * returns if machine currently running
377 | */
378 | isStarted() {
379 | return this._started;
380 | }
381 |
382 | /**
383 | * sets attributes
384 | * @param attrs
385 | */
386 | attrs(attrs: Attributes): this {
387 | this.state.attrs = Object.assign({}, this.state.attrs || {}, attrs);
388 |
389 | return this;
390 | }
391 |
392 | // private innerPut(
393 | // message: Message,
394 | // ...args: ExtractSignature
395 | // ): this {
396 | // return this.put(message as any, ...args);
397 | // }
398 | /**
399 | * put the message in
400 | * @param {String} message
401 | * @param {any} args
402 | */
403 | put>(
404 | message: Message,
405 | ...args: ExtractSignature
406 | ): this {
407 | if (!this._started) {
408 | console.error('machine is not started');
409 |
410 | return;
411 | }
412 |
413 | if (this.callDepth) {
414 | debug(this, 'queue', message, args);
415 | this.messageQueue.push({ message, args });
416 | } else {
417 | switch (this.state.phase) {
418 | case '@locked':
419 | debug(this, 'locked', message, args);
420 | break; //nop
421 | case '@busy':
422 | debug(this, 'queue', message, args);
423 | this.messageQueue.push({ message, args });
424 | break;
425 |
426 | default:
427 | this.__put(message as string, ...args);
428 | }
429 | }
430 |
431 | // find
432 | return this;
433 | }
434 |
435 | /**
436 | * Connects this machine output with another machine input
437 | * @param receiver
438 | * @returns disconnect function
439 | *
440 | * arguments are untyped, use {@see castSignalArgument} to retype them
441 | */
442 | connect(
443 | receiver: AnyConnectCall | FastePutable
444 | ) {
445 | const connector: ConnectCall =
446 | 'put' in receiver ? (event, ...args) => receiver.put(event as any, ...(args as any)) : receiver;
447 |
448 | this.messageObservers.push(connector);
449 |
450 | return () => {
451 | const element = this.messageObservers.indexOf(connector);
452 |
453 | if (element >= 0) {
454 | this.messageObservers.splice(element, 1);
455 | }
456 | };
457 | }
458 |
459 | /**
460 | * retypes signal arguments
461 | * @example
462 | * ```tsx
463 | * control.connect((event,...args)=> {
464 | * switch(event){
465 | * case "tick":
466 | * const [payload] = control.castSignalArgument(event, args);
467 | * payload.startsWith(); // now type is known
468 | * }
469 | * })
470 | * ```
471 | */
472 | castSignalArgument(name: Signal, ...args: any[]): ExtractSignature {
473 | return args as any;
474 | }
475 |
476 | /**
477 | * adds change observer. Observer could not be removed.
478 | * @param callback
479 | * @returns un-observe function
480 | */
481 | observe(callback: (phase: Phases | MAGIC_PHASES | START_STOP_PHASES) => void) {
482 | this.stateObservers.push(callback);
483 |
484 | return () => {
485 | const element = this.stateObservers.indexOf(callback);
486 |
487 | if (element >= 0) {
488 | this.stateObservers.splice(element, 1);
489 | }
490 | };
491 | }
492 |
493 | /**
494 | * returns the current phase
495 | */
496 | phase(): Phases | MAGIC_PHASES {
497 | return this.state.phase;
498 | }
499 |
500 | /**
501 | * return an internal instance
502 | */
503 | instance(): InternalMachine<
504 | State,
505 | Attributes,
506 | Phases,
507 | Messages,
508 | Signals,
509 | Timers,
510 | MessageSignatures,
511 | SignalSignatures
512 | > {
513 | return this._createInstance({});
514 | }
515 |
516 | private _initialize() {
517 | this._started = true;
518 | this.__put('@init');
519 | this.__performHookOn(null, true);
520 | }
521 | /**
522 | * destroys the machine
523 | */
524 | destroy(): void {
525 | this.__performHookOn(undefined);
526 | callListeners(this.stateObservers, STOP_PHASE);
527 |
528 | Object.entries(this.timers).forEach(([, value]) => {
529 | clearTimeout(value as any);
530 | });
531 |
532 | this.timers = {};
533 | this.stateObservers = [];
534 | //
535 | this._started = false;
536 | }
537 | }
538 |
--------------------------------------------------------------------------------
/src/faste.ts:
--------------------------------------------------------------------------------
1 | import { FasteInstance } from './faste-executor';
2 | import { OnCallback } from './interfaces/callbacks';
3 | import { GuardCallback, Guards } from './interfaces/guards';
4 | import { Hooks } from './interfaces/hooks';
5 | import { MessageHandlers } from './interfaces/messages';
6 | import {
7 | CallSignature,
8 | DefaultSignatures,
9 | EnterLeaveSignatures,
10 | ExtractSignature,
11 | StateChangeSignature,
12 | } from './interfaces/signatures';
13 | import { ENTER_LEAVE, MAGIC_EVENTS, STATE_CHANGE } from './types';
14 |
15 | export type PhaseTransition = { [key in T]: K };
16 |
17 | export type PhaseTransitionSetup>> = {
18 | [key in keyof T]: T[key][];
19 | };
20 |
21 | type FasteTimers = Record;
22 |
23 | type ExtractMessageArgument<
24 | Message extends string,
25 | MessageSignatures extends CallSignature,
26 | State,
27 | Phases
28 | > = Message extends STATE_CHANGE
29 | ? [oldState: State]
30 | : Message extends ENTER_LEAVE
31 | ? ExtractSignature, Message>
32 | : Message extends MAGIC_EVENTS
33 | ? ExtractSignature
34 | : ExtractSignature;
35 |
36 | /**
37 | * The Faste machine
38 | * @name Faste
39 | */
40 | export class Faste<
41 | State extends object = never,
42 | Attributes extends object = never,
43 | Phases extends string = never,
44 | Transitions extends PhaseTransition> = PhaseTransition,
45 | Messages extends string = MAGIC_EVENTS,
46 | Signals extends string = never,
47 | Timers extends string = never,
48 | MessageSignatures extends CallSignature = CallSignature,
49 | SignalsSignatures extends CallSignature = CallSignature<''>,
50 | FasteHooks extends Hooks = Hooks<
51 | State,
52 | Attributes,
53 | Messages,
54 | Timers,
55 | MessageSignatures
56 | >,
57 | OnCall = OnCallback<
58 | State,
59 | Attributes,
60 | Phases,
61 | Messages,
62 | Signals,
63 | Timers,
64 | any[],
65 | MessageSignatures,
66 | SignalsSignatures
67 | >,
68 | FasteMessageHandlers = MessageHandlers
69 | > {
70 | private fState: State;
71 | private fAttrs: Attributes;
72 | private fHandlers: MessageHandlers;
73 | private fHooks: FasteHooks;
74 |
75 | private fTimers: FasteTimers;
76 | private fGuards: Guards;
77 |
78 | private asyncSignals = false;
79 |
80 | constructor(
81 | state?: State,
82 | attrs?: Attributes,
83 | messages?: FasteMessageHandlers,
84 | hooks?: FasteHooks,
85 | guards?: Guards,
86 | timers?: FasteTimers
87 | ) {
88 | this.fState = state;
89 | this.fAttrs = attrs;
90 | this.fHandlers = messages || ({} as any);
91 | this.fHooks = hooks || ({} as FasteHooks);
92 | this.fGuards = guards || [];
93 | this.fTimers = timers || ({} as FasteTimers);
94 | }
95 |
96 | private _alter({ state, attrs, timers }: { state?: any; attrs?: any; timers?: any }): any {
97 | return new Faste(
98 | state || this.fState,
99 | attrs || this.fAttrs,
100 | this.fHandlers,
101 | this.fHooks,
102 | this.fGuards as any,
103 | timers || this.fTimers
104 | );
105 | }
106 |
107 | /**
108 | * Adds event handler
109 | * @param {String} eventName
110 | * @param {String[]} phases
111 | * @param callback
112 | *
113 | * @example machine.on('disable', ['enabled'], ({transitTo}) => transitTo('disabled');
114 | */
115 | public on(
116 | eventName: Message,
117 | phases: K[],
118 | callback: OnCallback<
119 | State,
120 | Attributes,
121 | Transitions[K],
122 | Messages,
123 | Signals,
124 | Timers,
125 | ExtractMessageArgument,
126 | MessageSignatures,
127 | SignalsSignatures
128 | >
129 | ): this;
130 | /**
131 | * Adds event handler
132 | * @param {String} eventName
133 | * @param callback
134 | */
135 | public on(
136 | eventName: Message,
137 | callback: OnCallback<
138 | State,
139 | Attributes,
140 | Phases,
141 | Messages,
142 | Signals,
143 | Timers,
144 | ExtractMessageArgument,
145 | MessageSignatures,
146 | SignalsSignatures
147 | >
148 | ): this;
149 |
150 | /**
151 | * Adds event handler
152 | * @param args
153 | */
154 | public on(...args: any[]): this {
155 | if (args.length == 2) {
156 | return this._addHandler(args[0], null, args[1]);
157 | } else if (args.length == 3) {
158 | return this._addHandler(args[0], args[1], args[2]);
159 | }
160 |
161 | return null;
162 | }
163 |
164 | private _addHandler(eventName: Messages, phases: Phases[], callback: OnCall): this {
165 | this.fHandlers[eventName] = this.fHandlers[eventName] || [];
166 |
167 | this.fHandlers[eventName].push({
168 | phases,
169 | callback,
170 | });
171 |
172 | return this;
173 | }
174 |
175 | /**
176 | * Adds hooks to the faste machine
177 | *
178 | * Hook is an event of message being observed
179 | * @param hooks
180 | *
181 | * @example machine.hooks({
182 | * click: () => {
183 | * onCallback();
184 | * return offCallback
185 | * }
186 | */
187 | public hooks(hooks: Hooks): this {
188 | Object.assign(this.fHooks, hooks);
189 |
190 | return this;
191 | }
192 |
193 | /**
194 | * Adds a guard, which may block transition TO the phase
195 | * @param {String[]} state
196 | * @param callback
197 | */
198 | public guard(state: Phases[], callback: GuardCallback): this {
199 | this.fGuards.push({ state, callback, trap: false });
200 |
201 | return this;
202 | }
203 |
204 | /**
205 | * Add a trap, which may block transition FROM the phase
206 | * @param state
207 | * @param callback
208 | */
209 | public trap(state: Phases[], callback: GuardCallback): this {
210 | this.fGuards.push({ state, callback, trap: true });
211 |
212 | return this;
213 | }
214 |
215 | /**
216 | * checks that machine is build properly
217 | */
218 | public check(): boolean {
219 | return true;
220 | }
221 |
222 | /**
223 | * Executes callback inside faste machine, could be used to reuse logic among different machines
224 | * @param {Function }swapper
225 | *
226 | * @example machine.scope( machine => machine.on('something');
227 | */
228 | scope(swapper: (stateIn: this) => void): this {
229 | swapper(this);
230 |
231 | return this;
232 | }
233 |
234 | /**
235 | * creates a Faste Machine from a blueprint
236 | */
237 | create(): FasteInstance<
238 | State,
239 | Attributes,
240 | Phases,
241 | Messages,
242 | Signals,
243 | MessageHandlers,
244 | FasteHooks,
245 | Guards,
246 | Timers,
247 | MessageSignatures,
248 | SignalsSignatures
249 | > {
250 | return new FasteInstance(
251 | {
252 | state: this.fState,
253 | attrs: this.fAttrs,
254 | phase: undefined,
255 | instance: undefined,
256 | timers: this.fTimers,
257 | asyncSignals: this.asyncSignals,
258 | },
259 | {
260 | handlers: this.fHandlers,
261 | hooks: this.fHooks,
262 | guards: this.fGuards,
263 | }
264 | ); // as any
265 | }
266 |
267 | /**
268 | * Defines the State
269 | * @param state
270 | */
271 | withState(
272 | state?: T
273 | ): Faste<
274 | T,
275 | Attributes,
276 | Phases,
277 | Transitions,
278 | Messages,
279 | Signals,
280 | Timers,
281 | MessageSignatures | StateChangeSignature,
282 | SignalsSignatures
283 | > {
284 | return this._alter({ state });
285 | }
286 |
287 | /**
288 | * Defines the Attributes
289 | * @param attributes
290 | */
291 | withAttrs(
292 | attributes?: T
293 | ): Faste {
294 | return this._alter({ attrs: attributes });
295 | }
296 |
297 | /**
298 | * Defines possible Phases
299 | * @param phases
300 | */
301 | withPhases(
302 | phases?: T[]
303 | ): Faste<
304 | State,
305 | Attributes,
306 | T,
307 | PhaseTransition,
308 | Messages,
309 | Signals,
310 | Timers,
311 | MessageSignatures,
312 | SignalsSignatures
313 | > {
314 | return this._alter({});
315 | }
316 |
317 | /**
318 | * Defines possible Phases Transitions
319 | * @param transitions
320 | */
321 | withTransitions>>(
322 | transitions: PhaseTransitionSetup
323 | ): Faste {
324 | return this._alter({});
325 | }
326 |
327 | /**
328 | * Defines possible "in" events
329 | * @param messages
330 | */
331 | withMessages(
332 | messages?: T[]
333 | ): Faste<
334 | State,
335 | Attributes,
336 | Phases,
337 | Transitions,
338 | T | MAGIC_EVENTS,
339 | Signals,
340 | Timers,
341 | MessageSignatures,
342 | SignalsSignatures
343 | > {
344 | return this._alter({});
345 | }
346 |
347 | /**
348 | * Defines possible "out" events
349 | * @param signals
350 | */
351 | withSignals(
352 | signals?: T[]
353 | ): Faste {
354 | return this._alter({});
355 | }
356 |
357 | /**
358 | * Defines timers to be used
359 | * @param timers
360 | * @example
361 | * ```tsx
362 | * .withTimers({
363 | * T0: 10*1000, // 10s
364 | * T1: 500,
365 | * }
366 | * ```
367 | */
368 | withTimers(
369 | timers: Record
370 | ): Faste<
371 | State,
372 | Attributes,
373 | Phases,
374 | Transitions,
375 | Messages | `on_${T}`,
376 | Signals,
377 | T,
378 | MessageSignatures,
379 | SignalsSignatures
380 | > {
381 | return this._alter({ timers });
382 | }
383 |
384 | /**
385 | * Defines a specification for message(`trigger`/`put`) arguments
386 | * @example
387 | * ```tsx
388 | * .withMessageArguments<{ signal1: [name: string, count:number]}>()
389 | * ```
390 | */
391 | withMessageArguments>(): Faste<
392 | State,
393 | Attributes,
394 | Phases,
395 | Transitions,
396 | Messages,
397 | Signals,
398 | Timers,
399 | Signature,
400 | SignalsSignatures
401 | > {
402 | return this._alter({});
403 | }
404 |
405 | /**
406 | * Defines a specification for signal(`emit`) arguments
407 | * @example
408 | * ```tsx
409 | * .withSignalArguments<{ signal1: [name: string, count:number]}>()
410 | * ```
411 | */
412 | withSignalArguments>(): Faste<
413 | State,
414 | Attributes,
415 | Phases,
416 | Transitions,
417 | Messages,
418 | Signals,
419 | Timers,
420 | MessageSignatures,
421 | Signature
422 | > {
423 | return this._alter({});
424 | }
425 |
426 | /**
427 | * defers all signal `emits` making them async
428 | */
429 | withAsyncSignals(async = true): this {
430 | this.asyncSignals = async;
431 |
432 | return this;
433 | }
434 | }
435 |
--------------------------------------------------------------------------------
/src/helpers/call.ts:
--------------------------------------------------------------------------------
1 | export const callListeners = (listeners: ((...args: T) => void)[], ...args: T) =>
2 | listeners.forEach((listener) => listener(...args));
3 |
4 | export const invokeAsync = (cb: () => void) => {
5 | Promise.resolve().then(cb);
6 | };
7 |
--------------------------------------------------------------------------------
/src/helpers/debug.ts:
--------------------------------------------------------------------------------
1 | export type debugCallback = (instance: any, event: string, ...args: any[]) => any;
2 |
3 | let debugFlag: boolean | debugCallback = false;
4 |
5 | export const debug = (instance: any, event: string, ...args: any[]) => {
6 | if (debugFlag) {
7 | if (typeof debugFlag === 'function') {
8 | debugFlag(instance, event, ...args);
9 | } else {
10 | console.debug('Faste:', instance.name ? instance.name : instance, event, ...args);
11 | }
12 | }
13 | };
14 |
15 | /**
16 | * enabled debug
17 | * @param flag
18 | */
19 | export const setFasteDebug = (flag: debugCallback | boolean) => (debugFlag = flag);
20 |
--------------------------------------------------------------------------------
/src/helpers/thenable.ts:
--------------------------------------------------------------------------------
1 | export const isThenable = (result: any | Promise): result is Promise =>
2 | result && typeof result === 'object' && 'then' in result;
3 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Faste } from './faste';
2 |
3 | /**
4 | * Creates a faste machine
5 | */
6 | export function faste(): Faste {
7 | return new Faste();
8 | }
9 |
--------------------------------------------------------------------------------
/src/interfaces/callbacks.ts:
--------------------------------------------------------------------------------
1 | import { InternalMachine } from './internal-machine';
2 | import { CallSignature } from './signatures';
3 | import { MAGIC_EVENTS } from '../types';
4 |
5 | export type OnCallback<
6 | State,
7 | Attributes,
8 | AvalablePhases,
9 | Messages extends string,
10 | Signals extends string,
11 | Timers extends string,
12 | Args extends ReadonlyArray,
13 | MessageSignatures extends CallSignature,
14 | SignalsSignatures extends CallSignature
15 | > = (
16 | slots: InternalMachine<
17 | State,
18 | Attributes,
19 | AvalablePhases,
20 | Messages,
21 | Signals,
22 | Timers,
23 | MessageSignatures,
24 | SignalsSignatures
25 | >,
26 | ...args: Args // extends any[] ? Args : never
27 | ) => Promise | unknown;
28 |
--------------------------------------------------------------------------------
/src/interfaces/guards.ts:
--------------------------------------------------------------------------------
1 | import { InternalMachine } from './internal-machine';
2 |
3 | export type GuardArgument = InternalMachine<
4 | State,
5 | Attributes,
6 | never,
7 | Messages,
8 | never,
9 | never,
10 | never,
11 | never
12 | > & { message: Messages };
13 |
14 | export type GuardCallback = (arg: GuardArgument) => boolean;
15 |
16 | export type Guards = Array<{
17 | state: Phases[];
18 | trap: boolean;
19 | callback: GuardCallback;
20 | }>;
21 |
--------------------------------------------------------------------------------
/src/interfaces/hooks.ts:
--------------------------------------------------------------------------------
1 | import { InternalMachine } from './internal-machine';
2 | import { CallSignature } from './signatures';
3 |
4 | export type HookArgument<
5 | Messages extends string,
6 | State,
7 | Attributes,
8 | Timers extends string,
9 | MessageSignatures extends CallSignature
10 | > = InternalMachine & {
11 | message: Messages;
12 | };
13 | export type OnHookCallback<
14 | Messages extends string,
15 | State,
16 | Attributes,
17 | Timers extends string,
18 | MessageSignatures extends CallSignature
19 | > = (
20 | arg: HookArgument
21 | ) => void | ((arg: HookArgument) => void);
22 |
23 | export type HookCallback<
24 | Messages extends string,
25 | State,
26 | Attributes,
27 | Timers extends string,
28 | MessageSignatures extends CallSignature
29 | > = OnHookCallback;
30 |
31 | export type AnyHookCallback = HookCallback;
32 |
33 | export type Hooks<
34 | State,
35 | Attributes,
36 | Messages extends string,
37 | Timers extends string,
38 | MessageSignatures extends CallSignature
39 | > = {
40 | [K in Messages]?: HookCallback;
41 | };
42 |
--------------------------------------------------------------------------------
/src/interfaces/internal-machine.ts:
--------------------------------------------------------------------------------
1 | import { CallSignature, ExtractSignature } from './signatures';
2 | import { MAGIC_EVENTS, MAGIC_PHASES } from '../types';
3 |
4 | export type InternalMachine<
5 | State,
6 | Attributes,
7 | AvailablePhases,
8 | Messages extends string,
9 | Signals extends string,
10 | Timers extends string,
11 | MessageSignatures extends CallSignature,
12 | SignalSignatures extends CallSignature
13 | > = Readonly<{
14 | /**
15 | * machine attributes
16 | */
17 | attrs: Attributes;
18 | /**
19 | * machine state
20 | */
21 | state: State;
22 | phase: AvailablePhases | MAGIC_PHASES;
23 | /**
24 | * current message
25 | */
26 | message?: Messages | MAGIC_EVENTS;
27 |
28 | /**
29 | * update machine state
30 | * @param newState
31 | */
32 | setState(newState: Partial): void;
33 |
34 | setState(cb: (oldState: State) => Partial): void;
35 |
36 | /**
37 | * changes machine phase
38 | * @param phase
39 | */
40 | transitTo(phase: AvailablePhases | MAGIC_PHASES): boolean;
41 |
42 | /**
43 | * sends a signal to the outer world
44 | * @param message
45 | * @param args
46 | */
47 | emit(message: Signal, ...args: ExtractSignature): void;
48 |
49 | /**
50 | * sends a signal back to the machine
51 | * @param event
52 | * @param args
53 | */
54 | trigger(
55 | message: Exclude,
56 | ...args: ExtractSignature
57 | ): void;
58 |
59 | /**
60 | * Starts timer
61 | */
62 | startTimer: (timerName: Timers) => void;
63 | /**
64 | * Stops timers
65 | * @param timerName
66 | */
67 | stopTimer: (timerName: Timers) => void;
68 | }>;
69 |
--------------------------------------------------------------------------------
/src/interfaces/messages.ts:
--------------------------------------------------------------------------------
1 | export interface MessagePhase {
2 | phases: Phases[];
3 | }
4 |
5 | export interface MessageHandler {
6 | phases: Phases[];
7 | callback: OnCallback;
8 | }
9 |
10 | export type MessageHandlerArray = MessageHandler[];
11 |
12 | export type MessageHandlers = {
13 | [name: string]: MessageHandlerArray;
14 | };
15 |
--------------------------------------------------------------------------------
/src/interfaces/signatures.ts:
--------------------------------------------------------------------------------
1 | export type CallSignature = {
2 | [k in Name]?: ReadonlyArray;
3 | };
4 |
5 | export type EnterLeaveSignatures = {
6 | '@enter': readonly [newPhase: Phases, oldPhase: Phases];
7 | '@leave': readonly [oldPhase: Phases, oldPhase: Phases];
8 | };
9 |
10 | export type DefaultSignatures = {
11 | // '@enter': readonly [newPhase: string];
12 | // '@leave': readonly [oldPhase: string];
13 | '@error': readonly [error: Error];
14 | };
15 |
16 | export type StateChangeSignature = {
17 | '@change': readonly [oldState: State];
18 | };
19 |
20 | export type ExtractSignature = Signatures extends CallSignature
21 | ? Signatures[Key]
22 | : Fallback;
23 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type STATE_CHANGE = '@change';
2 | export type ENTER_LEAVE = '@enter' | '@leave';
3 | export type MAGIC_EVENTS = '@init' | '@miss' | '@guard' | '@error' | STATE_CHANGE | ENTER_LEAVE;
4 | export type MAGIC_PHASES = '@current' | '@busy' | '@locked';
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noImplicitAny": true,
4 | "removeComments": false,
5 | "declaration": true,
6 | "target": "es5",
7 | "lib": [
8 | "dom",
9 | "es5",
10 | "scripthost",
11 | "es2015.core",
12 | "es2015.collection",
13 | "es2015.symbol",
14 | "es2015.iterable",
15 | "es2015.promise",
16 | "es2017.object"
17 | ],
18 | "jsx": "react"
19 | }
20 | }
--------------------------------------------------------------------------------