├── .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 | faste 6 |
7 |
8 | 9 | Build status 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | Greenkeeper badge 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 | ![U17](./assets/capeu17.gif) 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 | } --------------------------------------------------------------------------------