├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build └── ffsm.js ├── index.js ├── jest.config.js ├── package.json ├── src └── StateMachine.js ├── tests └── StateMachineTest.test.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-transform-spread" 7 | ] 8 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "indent": [ 6 | "error", 7 | 4 8 | ], 9 | "max-len": [ 10 | "warn", 11 | { 12 | "ignoreComments": true 13 | } 14 | ] 15 | } 16 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode 2 | # Edit at https://www.gitignore.io/?templates=node,linux,macos,windows,visualstudiocode 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | # General 21 | .DS_Store 22 | .AppleDouble 23 | .LSOverride 24 | 25 | # Icon must end with two \r 26 | Icon 27 | 28 | # Thumbnails 29 | ._* 30 | 31 | # Files that might appear in the root of a volume 32 | .DocumentRevisions-V100 33 | .fseventsd 34 | .Spotlight-V100 35 | .TemporaryItems 36 | .Trashes 37 | .VolumeIcon.icns 38 | .com.apple.timemachine.donotpresent 39 | 40 | # Directories potentially created on remote AFP share 41 | .AppleDB 42 | .AppleDesktop 43 | Network Trash Folder 44 | Temporary Items 45 | .apdisk 46 | 47 | ### Node ### 48 | # Logs 49 | logs 50 | *.log 51 | npm-debug.log* 52 | yarn-debug.log* 53 | yarn-error.log* 54 | lerna-debug.log* 55 | 56 | # Diagnostic reports (https://nodejs.org/api/report.html) 57 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 58 | 59 | # Runtime data 60 | pids 61 | *.pid 62 | *.seed 63 | *.pid.lock 64 | 65 | # Directory for instrumented libs generated by jscoverage/JSCover 66 | lib-cov 67 | 68 | # Coverage directory used by tools like istanbul 69 | coverage 70 | *.lcov 71 | 72 | # nyc test coverage 73 | .nyc_output 74 | 75 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 76 | .grunt 77 | 78 | # Bower dependency directory (https://bower.io/) 79 | bower_components 80 | 81 | # node-waf configuration 82 | .lock-wscript 83 | 84 | # Compiled binary addons (https://nodejs.org/api/addons.html) 85 | build/Release 86 | 87 | # Dependency directories 88 | node_modules/ 89 | jspm_packages/ 90 | 91 | # TypeScript v1 declaration files 92 | typings/ 93 | 94 | # TypeScript cache 95 | *.tsbuildinfo 96 | 97 | # Optional npm cache directory 98 | .npm 99 | 100 | # Optional eslint cache 101 | .eslintcache 102 | 103 | # Optional REPL history 104 | .node_repl_history 105 | 106 | # Output of 'npm pack' 107 | *.tgz 108 | 109 | # Yarn Integrity file 110 | .yarn-integrity 111 | 112 | # dotenv environment variables file 113 | .env 114 | .env.test 115 | 116 | # parcel-bundler cache (https://parceljs.org/) 117 | .cache 118 | 119 | # next.js build output 120 | .next 121 | 122 | # nuxt.js build output 123 | .nuxt 124 | 125 | # vuepress build output 126 | .vuepress/dist 127 | 128 | # Serverless directories 129 | .serverless/ 130 | 131 | # FuseBox cache 132 | .fusebox/ 133 | 134 | # DynamoDB Local files 135 | .dynamodb/ 136 | 137 | ### VisualStudioCode ### 138 | .vscode/* 139 | !.vscode/settings.json 140 | !.vscode/tasks.json 141 | !.vscode/launch.json 142 | !.vscode/extensions.json 143 | 144 | ### VisualStudioCode Patch ### 145 | # Ignore all local history of files 146 | .history 147 | 148 | ### Windows ### 149 | # Windows thumbnail cache files 150 | Thumbs.db 151 | Thumbs.db:encryptable 152 | ehthumbs.db 153 | ehthumbs_vista.db 154 | 155 | # Dump file 156 | *.stackdump 157 | 158 | # Folder config file 159 | [Dd]esktop.ini 160 | 161 | # Recycle Bin used on file shares 162 | $RECYCLE.BIN/ 163 | 164 | # Windows Installer files 165 | *.cab 166 | *.msi 167 | *.msix 168 | *.msm 169 | *.msp 170 | 171 | # Windows shortcuts 172 | *.lnk 173 | 174 | # End of https://www.gitignore.io/api/node,linux,macos,windows,visualstudiocode 175 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests 2 | coverage 3 | .babelrc 4 | yarn.lock 5 | .eslintrc.json 6 | jest.config.js 7 | webpack.config.js 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 16 4 | before_deploy: 5 | - npm run build 6 | deploy: 7 | provider: npm 8 | email: madison.barry@hey.com 9 | api_key: 10 | secure: G24b/HmEGCLQ4RZqCBR3iFgKazRZN0Eb6n7TIbAzPsskwcbaGzysWVkpoN3DwnWINwYSyn90qQrRzrcVl6gORkPIUIE2WgbRGEhiIWHyUkPLK5w0gDrQrBb8H4SAVjm/lPSizXvOeEk7QmJQkRXCnbjjCsMkj4ZUcqqZNQECgEKCxB3Q2DmsPmV/FJUZaNI3H9BcgdeyjEC6iuEZfIsf1qO6OSSDS97TloRd8+8i8kU0S2ucpg1YeIFarixf17yCKHYeMb+vf06hCuFBBxz52hmCjz3i2SDkzNgeGDFVYCTl5/hVOuQkk5ryKA33Jd8w40t2e+ZQYwvjV5w0F1drMPb3piGXJaZCpb/PTsdoO98loxdIP9sgCxQAKmjLQvKjfc+IgWKtclu6Mc+lh02ny7P3mS0flYSjjFFAi/qmoPWHUaXqK2pZk9+dTxnHt1anXNbDCoDCnEslLW9jjm/9UGs42yHkoGOjYSaliXd+8ZZTQVOgd651/UfwjMeU/4K6cEXd6wlwIzSggpE33CH8gWnxnFnfWYhwcA81l24AtV+xWCgQU10JcLx5bIvO0FzmFX0Eyj25Jd0kIGOqe4Uv61kbQKbU4EacFBeR5sDoJ8S/plgm0EnvGPs0iT/5uBZV2ksCoKy8U9lCQ3KA/E26Gya/eU2WvbIeSov7k1HdF5Y= 11 | on: 12 | tags: true 13 | repo: TBPixel/functional-finite-state-machine 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `tbpixel/functional-finite-state-machine` (`ffsm`) will be documented in this file. 4 | 5 | ## 0.1.5 - September 30th, 2019 6 | 7 | - Implement a Fire and Forget API for single-use state machines. 8 | - Resolves [Issue #7](https://github.com/TBPixel/functional-finite-state-machine/issues/7) 9 | 10 | ## 0.1.4 - September 22nd, 2019 11 | 12 | - Implement a Factory API for throwaway state machines 13 | - Resolves [Issue #5](https://github.com/TBPixel/functional-finite-state-machine/issues/5) 14 | 15 | ## 0.1.3 - September 22nd, 2019 16 | 17 | - Fix missing files for npm publish 18 | 19 | ## 0.1.2 - September 22nd, 2019 20 | 21 | - Improve jsdoc and README 22 | 23 | ## 0.1.1 - September 21st, 2019 24 | 25 | - Fix CI/CD deployment script 26 | 27 | ## 0.1.0 - September 21st, 2019 28 | 29 | - Initial pre-release 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | 8 | ## Etiquette 9 | 10 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 11 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 12 | extremely unfair for them to suffer abuse or anger for their hard work. 13 | 14 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 15 | world that developers are civilized and selfless people. 16 | 17 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 18 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 19 | 20 | 21 | ## Viability 22 | 23 | When requesting or submitting new features, first consider whether it might be useful to others. Open 24 | source projects are used by many developers, who may have entirely different needs to your own. Think about 25 | whether or not your feature is likely to be used by other users of the project. 26 | 27 | 28 | ## Procedure 29 | 30 | Before filing an issue: 31 | 32 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 33 | - Check to make sure your feature suggestion isn't already present within the project. 34 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 35 | - Check the pull requests tab to ensure that the feature isn't already in progress. 36 | 37 | Before submitting a pull request: 38 | 39 | - Check the codebase to ensure that your feature doesn't already exist. 40 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 41 | 42 | 43 | ## Requirements 44 | 45 | If the project maintainer has any additional requirements, you will find them listed here. 46 | 47 | - **[Airbnb Style Guide](https://github.com/airbnb/javascript)** 48 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 49 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 50 | - **Consider our release cycle** - I try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | **Happy coding**! 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tony Barry 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 | # Functional Finite State Machine (FFSM) 2 | 3 | [![Build Status](https://img.shields.io/travis/TBPixel/functional-finite-state-machine/master.svg?style=flat-square)](https://travis-ci.org/TBPixel/functional-finite-state-machine) 4 | 5 | 6 | #### Content 7 | 8 | - [Installation](#installation) 9 | - [Examples](#examples) 10 | - [Http Request](#http-request) 11 | - [Rational](#rational) 12 | - [Limitations](#limitations) 13 | - [API](#api) 14 | - [Contributing](#contributing) 15 | - [Changelog](#changelog) 16 | - [Support Me](#support-me) 17 | - [License](#license) 18 | 19 | 20 | ## Installation 21 | 22 | You can install this package via `npm` and `yarn`. 23 | 24 | ```bash 25 | npm install ffsm 26 | # or 27 | yarn add ffsm 28 | ``` 29 | 30 | 31 | ### Examples 32 | 33 | Using `ffsm` is easy. The default export is aptly called `newStateMachine` (but feel free to name it whatever you'd like). Simply import the constructor and define out your states as an object of `key: Function` pairs! 34 | 35 | ```js 36 | // stop-lights.js 37 | import newStateMachine from 'ffsm'; 38 | 39 | const fsm = newStateMachine({ 40 | green: ({ states, transitionTo }) => { 41 | console.log("green light!"); 42 | return transitionTo(states.yellow); 43 | }, 44 | yellow: ({ states, transitionTo }) => { 45 | console.log("yellow light!"); 46 | return transitionTo(states.red); 47 | }, 48 | red: ({ states }) => { 49 | console.log("red light!"); 50 | }, 51 | }); 52 | 53 | fsm.transitionTo(fsm.states.green); 54 | // "green light!" 55 | // "yellow light!" 56 | // "red light!" 57 | ``` 58 | 59 | The classic traffic light state machine demonstrates the emphasis on simplicity for `ffsm`. The FSM moves to it's initial state with `fsm.transitionTo(fsm.states.green)`, and then the internal handler is called. We destructure the state machine that's passed in, retrieving it's internal reference of `states` and the `transitionTo` function. 60 | 61 | It's worth noting that `transitionTo` actually assigns a result to the relative state's `state` property, if one was given, otherwise it assings the optional `payload` passed to the handler. 62 | 63 | 64 | #### HTTP Request 65 | 66 | A clear use for the state machine would be handling an HTTP Request. You might have some special logic to display a "success" or "error" based on the result of an http callback. `ffsm` allows you to define conditional state transitions as part of your handler. 67 | 68 | ```js 69 | // request-fsm.js 70 | import newStateMachine from 'ffsm'; 71 | 72 | const fsm = newStateMachine({ 73 | send: ({ states, transitionTo }, uri) => { 74 | try { 75 | const response = await fetch(uri); 76 | } catch (err) { 77 | return transitionTo(states.error, err); 78 | } 79 | 80 | if (response.status >= 400) { 81 | return transitionTo(states.fail, response); 82 | } 83 | 84 | return transitionTo(states.success, response); 85 | }, 86 | fail: ({ states }, response) => { 87 | console.log(`request failed with status code: ${response.status}`); 88 | console.error(response.data); 89 | 90 | return response; 91 | }, 92 | success: ({ states }, response) => { 93 | console.log('request succeeded!'); 94 | console.log(response.data); 95 | 96 | return response; 97 | }, 98 | error: ({ states }, error }) => { 99 | console.log('something went wrong unexpectedly!'); 100 | console.error(error); 101 | 102 | return error; 103 | }, 104 | }); 105 | ``` 106 | 107 | The above code is really easy to follow and understand. It has logical error handling, and it takes advantage of `async/await` while utilizing the strictly `synchronous` state machine. Not only that, this state machine is highly re-usable, since it makes HTTP requests for us. 108 | 109 | To handle errors, we can simply call the state machine and check the resulting state: 110 | 111 | ```js 112 | // request-fsm.js 113 | const result = fsm.transitionTo(fsm.states.send, '/hello-world'); 114 | 115 | if (!result.name === 'success') { 116 | // handle error 117 | } else { 118 | // handle success 119 | } 120 | ``` 121 | 122 | The result of the `fsm` is always the last executed state. This makes it easy for us to check the results, should we need to. 123 | 124 | The returned state has a `name` key that matches the key of the handler. If you want to perform some additional validation checks, you can simply verify that key and do some extra handling. Though I'd recommend instead handling that logic within each state instead. 125 | 126 | 127 | ## Rational 128 | 129 | With the advent of great finite state machine (FSM) packages like [Xstate](https://github.com/davidkpiano/xstate#readme) and [Machina JS](http://machina-js.org/), not to mention dozens of others, it's fair to question why I've created *yet another FSM*. `ffsm` was created because the public API's were too verbose and classical for my tastes. Don't get me wrong, `xstate` is a rock solid FSM, but it's API is not very pragmatic. 130 | 131 | `ffsm` attempts to address that concern by providing an API which is function-first, allowing states to handle their own transitions internally. `ffsm` also tries to be different by keeping it's API very minimal and simple. 132 | 133 | 134 | ### Limitations 135 | 136 | Due to the design of this FSM, there are some limitations. 137 | 138 | - `states` *must* be synchronous. 139 | - Each `state` *must* handle it's own transitions. 140 | - `ffsm` has no concept of a "start" state or an "end" state, and so you must be wary of infinite loops. 141 | 142 | 143 | ### API 144 | 145 | #### newStateMachine 146 | 147 | The default export is the `newStateMachine` function. 148 | 149 | ```js 150 | // api.js 151 | import newStateMachine from 'ffsm'; 152 | 153 | const fsm = newStateMachine({ 154 | STATE_NAME: ({ states, transitionTo }, payload) => {/* ... */}, 155 | }); 156 | ``` 157 | 158 | As you can see, states are defined as the keys of the object, and their values are the transition functions called when moving to that state. 159 | 160 | 161 | #### current 162 | 163 | `current` allows you to retrieve the state that was last pushed onto the history stack. 164 | 165 | ```js 166 | // api.js 167 | const state = fsm.current(); 168 | ``` 169 | 170 | Note that if the history stack is empty, current will return `undefined`. 171 | 172 | 173 | #### history 174 | 175 | `history` returns a copy of the history stack for inspection purposes. 176 | 177 | ```js 178 | // api.js 179 | const history = fsm.history(); 180 | ``` 181 | 182 | History is displayed in _chronological order_, with the most recent being at the bottom. It's worth noting that all mutations are _push-state_, which means that `transitionTo`, `undo` and `redo` all push a new state onto the history stack, rather than attempting to splice the history array. 183 | 184 | 185 | #### transitionTo 186 | 187 | `transitionTo` accepts a state handler reference and an optional payload, then executes the handler function. 188 | 189 | ```js 190 | // api.js 191 | fsm.transitionTo(fms.states.STATE_NAME, {someData: 'foo'}); 192 | ``` 193 | 194 | `transitionTo` will validate that the handler reference passed in is one of the registered states within the state machine. This is what keeps the state machine _finite_. 195 | 196 | `transitionTo` will also return the last state pushed onto the state stack after processing. This is possible because the state machine is synchronous, and fully performs it's work before returning. 197 | 198 | ```js 199 | // api.js 200 | const state = fsm.transitionTo(fsm.states.STATE_NAME); 201 | // do whatever with state ... 202 | ``` 203 | 204 | `transitionTo` is used both internally to switch between states and externally to declare the initial state. This, to me, feels very simple and clear. 205 | 206 | 207 | #### undo 208 | 209 | `undo` steps back one referential state, and **does not execute the handler**. 210 | 211 | ```js 212 | // api.js 213 | fsm.undo(); 214 | ``` 215 | 216 | This can be useful when your next state depends on work done in the previous state. It's worth noting that `undo` will return the most recent state just like `transitionTo`. 217 | 218 | 219 | #### redo 220 | 221 | `redo` steps forward one referential state, and **does not execute the handler**. 222 | 223 | ```js 224 | // api.js 225 | fsm.redo(); 226 | ``` 227 | 228 | This can be useful when you've stepped back a few states and now want to once-again step forward. Like above, `rdo` will return the most recent state just like `transitionTo`. 229 | 230 | 231 | #### factoryStateMachine 232 | 233 | `factoryStateMachine` allows us to create single-use state machines more easily. 234 | 235 | ```js 236 | // example.js 237 | import { factoryStateMachine } from 'ffsm'; 238 | 239 | const states = { 240 | send: ({ states, transitionTo }, { method, url, data, headers }) => { 241 | const send = async () => { 242 | const h = { 243 | 'content-type': 'application/json', 244 | }; 245 | 246 | return await fetch(url, { 247 | method: method, 248 | body: data, 249 | headers: { 250 | ...h, 251 | headers, 252 | }, 253 | }); 254 | }; 255 | 256 | const res = send(); 257 | if (res.status >= 400) { 258 | return transitionTo(states.error, { 259 | request: { method, url, data, headers }, 260 | response: res, 261 | }); 262 | } 263 | 264 | return transitionTo(states.success, res); 265 | }, 266 | error: (_, { request, response }) => { 267 | console.error(`${response.status} error when sending HTTP request ${request.method}: ${request.url}`, request.data); 268 | console.error('received response body: ', JSON.parse(response.data.body)); 269 | }, 270 | success: (_, res) => { 271 | return JSON.parse(res.data.body); 272 | }, 273 | }; 274 | 275 | 276 | export const requestFSM = (method, url, data, headers) => ( 277 | factoryStateMachine(states, states.send, { method, url, data, headers }) 278 | ); 279 | 280 | // use it later.. 281 | import { requestFSM } from 'example'; 282 | 283 | const { fsm, result } = requestFSM('POST', '/my-hello-world-api', { name: 'Tony' }); 284 | // fsm is the state machine. 285 | // result is the state that returned after the state machine executed 286 | // in this case, we could access result.state and have the already 287 | // parsed JSON payload to work with. 288 | ``` 289 | 290 | There's a lot of interesting things to unpack: 291 | 292 | - `factoryStateMachine` accepts in the states object, an initialState and an optional payload. 293 | - `factoryStateMachine` always runs the state machine from the provided initial state immediately after execution. 294 | - `factoryStateMachine` returns an object with the state machine under the fsm property and the last ran state under the result property 295 | 296 | 297 | #### fireStateMachine 298 | 299 | Much like the `factory API`, the ability to easily create and throw away Finite State Machines will encourage effective use of them. Where factories allowed for easier creation of re-usable state machines, "_Fire and Forget_" intends to encourage easier single-use state machines. 300 | 301 | ```js 302 | import { fireStateMachine } from 'ffsm'; 303 | 304 | const result = fireStateMachine({ 305 | start: ({ states, transitionTo }, payload) => transitionTo(states.middle, `start-${payload}-`), 306 | middle: ({ states, transitionTo }, payload) => transitionTo(states.end, `middle-${payload}-`), 307 | end: (_, payload) => `end-${payload}`, 308 | }, 'foo'); 309 | 310 | console.log(result.state); // "start-foo-middle-food-end-foo" 311 | ``` 312 | 313 | There's a two important characteristics here. First and foremost, the initial state is simply the first one that is defined. This is to encourage `fireStateMachine` to be a fire-and-forget API. If you want to re-use the state machine, you should instead use `factoryStateMachine`. 314 | 315 | Second, the state machine does not return the machine itself, it only returns the last executed state. You *cannot* inspect the state machine for details about it's state history; it's all thrown away instead. 316 | 317 | 318 | ## Contributing 319 | 320 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 321 | 322 | 323 | ### Changelog 324 | 325 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 326 | 327 | 328 | ### Support Me 329 | 330 | Hi! I'm a developer living in Vancouver. If you wanna support me, consider following me on [Twitter @TBPixel](https://twitter.com/TBPixel), or if you're super generous [buying me a coffee](https://ko-fi.com/tbpixel) :). 331 | 332 | 333 | ## License 334 | 335 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 336 | -------------------------------------------------------------------------------- /build/ffsm.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var t={d:(r,e)=>{for(var n in e)t.o(e,n)&&!t.o(r,n)&&Object.defineProperty(r,n,{enumerable:!0,get:e[n]})},o:(t,r)=>Object.prototype.hasOwnProperty.call(t,r),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},r={};function e(t,r){(null==r||r>t.length)&&(r=t.length);for(var e=0,n=new Array(r);el,factoryStateMachine:()=>y,fireStateMachine:()=>f});var a=function(t,r,e){var n=Object.keys(t.states).find((function(e){return t.states[e]===r}));if(!n)throw new Error("failed to transition to an unknown state!");var i={name:n,payload:e,state:null,index:t.history.length,timestamp:new Date},a=t.history.push(i)-1,s=r(t,e);return s&&t.history.splice(a,1,o(o({},i),{},{state:s})),e},s=function(t){if(t.history.length<=0)return t.history;var r=t.history.length-1,e=t.history[r];if(e.index<=0)return t.history;var n=o(o({},t.history[e.index-1]),{},{timestamp:new Date});return t.history.push(n),t.history},u=function(t){if(t.history.length<=0)return t.history;var r=t.history.length-1,e=t.history[r];if(e.index>=r)return t.history;var n=o(o({},t.history[e.index+1]),{},{timestamp:new Date});return t.history.push(n),t.history},c=function(t){if(!Object.keys(t).length)throw new Error("Cannot create a state machine without states");var r=o(o({},{history:[],states:{}}),{},{states:t}),n=function(){return r.history[r.history.length-1]},i=function t(e,n){var i=o(o({},r),{},{transitionTo:t});return a(i,e,n)};return{states:t,current:n,history:function(){return function(t){if(Array.isArray(t))return e(t)}(t=r.history)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,r){if(t){if("string"==typeof t)return e(t,r);var n=Object.prototype.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?e(t,r):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}();var t},transitionTo:function(t,e){var s=o(o({},r),{},{transitionTo:i});return a(s,t,e),n()},undo:function(){return r=o(o({},r),{},{history:s(r)}),n()},redo:function(){return r=o(o({},r),{},{history:u(r)}),n()}}};const y=function(t,r,e){var n=c(t),o=n.transitionTo(r,e);return{fsm:n,result:o}},f=function(t,r){var e=c(t),n=t[Object.keys(t)[0]];return e.transitionTo(n,r)},l=c;module.exports.ffsm=r})(); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import newStateMachine, { factory, fire } from './src/StateMachine'; 2 | 3 | export const factoryStateMachine = factory; 4 | export const fireStateMachine = fire; 5 | 6 | export default newStateMachine; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/tmp/jest_rs", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | // clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: null, 95 | 96 | // Run tests from one or more projects 97 | // projects: null, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: null, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: null, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 132 | // snapshotSerializers: [], 133 | 134 | // The test environment that will be used for testing 135 | // testEnvironment: "jest-environment-jsdom", 136 | 137 | // Options that will be passed to the testEnvironment 138 | // testEnvironmentOptions: {}, 139 | 140 | // Adds a location field to test results 141 | // testLocationInResults: false, 142 | 143 | // The glob patterns Jest uses to detect test files 144 | // testMatch: [ 145 | // "**/__tests__/**/*.[jt]s?(x)", 146 | // "**/?(*.)+(spec|test).[tj]s?(x)" 147 | // ], 148 | 149 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 150 | // testPathIgnorePatterns: [ 151 | // "/node_modules/" 152 | // ], 153 | 154 | // The regexp pattern or array of patterns that Jest uses to detect test files 155 | // testRegex: [], 156 | 157 | // This option allows the use of a custom results processor 158 | // testResultsProcessor: null, 159 | 160 | // This option allows use of a custom test runner 161 | // testRunner: "jasmine2", 162 | 163 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 164 | // testURL: "http://localhost", 165 | 166 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 167 | // timers: "real", 168 | 169 | // A map from regular expressions to paths to transformers 170 | // transform: null, 171 | 172 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 173 | // transformIgnorePatterns: [ 174 | // "/node_modules/" 175 | // ], 176 | 177 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 178 | // unmockedModulePathPatterns: undefined, 179 | 180 | // Indicates whether each individual test should be reported during the run 181 | // verbose: null, 182 | 183 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 184 | // watchPathIgnorePatterns: [], 185 | 186 | // Whether to use watchman for file crawling 187 | // watchman: true, 188 | }; 189 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ffsm", 3 | "version": "0.2.0", 4 | "description": "A simple, functional finite state machine in JavaScript", 5 | "module": "index.js", 6 | "author": "Madison Barry ", 7 | "license": "MIT", 8 | "homepage": "https://github.com/TBPixel/functional-finite-state-machine", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/TBPixel/functional-finite-state-machine.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/TBPixel/functional-finite-state-machine/issues" 15 | }, 16 | "files": [ 17 | "src", 18 | "build", 19 | "index.js", 20 | "README.md" 21 | ], 22 | "publishConfig": { 23 | "access": "public" 24 | }, 25 | "keywords": [ 26 | "finite state machine", 27 | "state machine", 28 | "fsm", 29 | "functional" 30 | ], 31 | "scripts": { 32 | "dev": "webpack --mode development --watch", 33 | "build": "webpack --mode production", 34 | "test": "jest", 35 | "test-coverage": "jest --coverage" 36 | }, 37 | "devDependencies": { 38 | "@babel/cli": "^7.6.0", 39 | "@babel/core": "^7.6.0", 40 | "@babel/plugin-transform-spread": "^7.2.2", 41 | "@babel/preset-env": "^7.6.0", 42 | "babel-eslint": "^10.0.3", 43 | "babel-loader": "^8.0.6", 44 | "eslint": "^6.4.0", 45 | "eslint-config-airbnb": "^18.0.1", 46 | "eslint-loader": "^3.0.0", 47 | "eslint-plugin-import": "^2.18.2", 48 | "jest": "^28.1.3", 49 | "webpack": "^5.74.0", 50 | "webpack-cli": "^4.10.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/StateMachine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} StateSnapshot 3 | * @property {String} name 4 | * @property {Number} index 5 | * @property {Date} timestamp 6 | * @property {*} payload 7 | * @property {*} state 8 | */ 9 | 10 | /** 11 | * Handles the state transition and returns the result 12 | * 13 | * @function HandlerFunc 14 | * @param {{states: Object., transitionTo: function(HandlerFunc, *)}} machine 15 | * @param {*} [payload] 16 | */ 17 | 18 | /** 19 | * @typedef {Object} InternalStateMachine 20 | * @property {Object.} states 21 | * @property {StateSnapshot[]} history 22 | */ 23 | 24 | /** 25 | * @typedef {Object} StateMachine 26 | * @property {Object.} states A reference to the passed in object of states 27 | * @property {function():StateSnapshot} current Returns the last active state 28 | * @property {function():StateSnapshot[]} history Returns a copy of the state history array 29 | * @property {function(HandlerFunc, *)} transitionTo Transitioning the state machine to a given state, calling the handler 30 | * @property {function():StateSnapshot} undo Reverts to the previous state, and doesn't call the handler 31 | * @property {function():StateSnapshot} redo Re-does the next state, if exists 32 | */ 33 | 34 | /** 35 | * transitionTo transitions to another internal state 36 | * 37 | * @param {InternalStateMachine} m The finite state machine 38 | * @param {HandlerFunc} handler The state to transition to 39 | * @param {*} [payload] An optional payload 40 | */ 41 | const transitionTo = (m, handler, payload) => { 42 | const name = Object.keys(m.states).find((k) => m.states[k] === handler); 43 | if (!name) { 44 | throw new Error('failed to transition to an unknown state!'); 45 | } 46 | 47 | const state = { 48 | name, 49 | payload, 50 | state: null, 51 | index: m.history.length, 52 | timestamp: new Date(), 53 | }; 54 | const index = m.history.push(state) - 1; 55 | const result = handler(m, payload); 56 | if (result) { 57 | m.history.splice(index, 1, { 58 | ...state, 59 | state: result, 60 | }); 61 | } 62 | 63 | return payload; 64 | }; 65 | 66 | /** 67 | * undo one state transition on the state stack. 68 | * 69 | * @param {InternalStateMachine} m The state machine. 70 | * @return {StateSnapshot[]} The new state machine. 71 | */ 72 | const undo = (m) => { 73 | if (m.history.length <= 0) { 74 | return m.history; 75 | } 76 | 77 | const cursor = m.history.length - 1; 78 | const state = m.history[cursor]; 79 | if (state.index <= 0) { 80 | return m.history; 81 | } 82 | 83 | const prev = m.history[state.index - 1]; 84 | const next = { 85 | ...prev, 86 | timestamp: new Date(), 87 | }; 88 | m.history.push(next); 89 | 90 | return m.history; 91 | }; 92 | 93 | /** 94 | * redo moves the state machine ahead one state, if it can, and returns a new state machine. 95 | * 96 | * @param {InternalStateMachine} m The state machine 97 | * @return {StateSnapshot[]} 98 | */ 99 | const redo = (m) => { 100 | if (m.history.length <= 0) { 101 | return m.history; 102 | } 103 | 104 | const cursor = m.history.length - 1; 105 | const state = m.history[cursor]; 106 | if (state.index >= cursor) { 107 | return m.history; 108 | } 109 | 110 | const prev = m.history[state.index + 1]; 111 | const next = { 112 | ...prev, 113 | timestamp: new Date(), 114 | }; 115 | m.history.push(next); 116 | 117 | return m.history; 118 | }; 119 | 120 | /** 121 | * newStateMachine creates and returns a finite state machine. 122 | * 123 | * @param {Object} states 124 | * @return {StateMachine} The state machine 125 | */ 126 | const newStateMachine = (states) => { 127 | if (!Object.keys(states).length) { 128 | throw new Error('Cannot create a state machine without states'); 129 | } 130 | 131 | const initial = { 132 | history: [], 133 | states: {}, 134 | }; 135 | 136 | let m = { 137 | ...initial, 138 | states, 139 | }; 140 | 141 | const current = () => m.history[m.history.length - 1]; 142 | const history = () => [...m.history]; 143 | 144 | const internalTransitionTo = (handler, payload) => { 145 | const localMachine = { 146 | ...m, 147 | transitionTo: internalTransitionTo, 148 | }; 149 | 150 | return transitionTo(localMachine, handler, payload); 151 | }; 152 | 153 | const externalTransitionTo = (handler, payload) => { 154 | const localMachine = { 155 | ...m, 156 | transitionTo: internalTransitionTo, 157 | }; 158 | 159 | transitionTo(localMachine, handler, payload); 160 | 161 | return current(); 162 | }; 163 | 164 | const handleUndo = () => { 165 | m = { 166 | ...m, 167 | history: undo(m), 168 | }; 169 | 170 | return current(); 171 | }; 172 | 173 | const handleRedo = () => { 174 | m = { 175 | ...m, 176 | history: redo(m), 177 | }; 178 | 179 | return current(); 180 | }; 181 | 182 | return { 183 | states, 184 | current, 185 | history, 186 | transitionTo: externalTransitionTo, 187 | undo: handleUndo, 188 | redo: handleRedo, 189 | }; 190 | }; 191 | 192 | /** 193 | * factory returns an already executed state machine for inspection. 194 | * 195 | * @param {Object} states 196 | * @param {HandlerFunc} initialState 197 | * @param {*} payload 198 | * @returns {{fsm: StateMachine, result: StateSnapshot}} 199 | */ 200 | export const factory = (states, initialState, payload) => { 201 | const fsm = newStateMachine(states); 202 | const result = fsm.transitionTo(initialState, payload); 203 | 204 | return { fsm, result }; 205 | }; 206 | 207 | /** 208 | * fire executes a state machine and returns the resulting state. 209 | * 210 | * @param {Object} states 211 | * @param {HandlerFunc} initialState 212 | * @param {*} payload 213 | * @returns {StateSnapshot} 214 | */ 215 | export const fire = (states, payload) => { 216 | const fsm = newStateMachine(states); 217 | const keys = Object.keys(states); 218 | const initial = states[keys[0]]; 219 | 220 | return fsm.transitionTo(initial, payload); 221 | }; 222 | 223 | export default newStateMachine; 224 | -------------------------------------------------------------------------------- /tests/StateMachineTest.test.js: -------------------------------------------------------------------------------- 1 | import newStateMachine, { factoryStateMachine, fireStateMachine } from '../index'; 2 | 3 | const testStates = { 4 | foo: ({ states, transitionTo }) => transitionTo(states.bar, 'foo'), 5 | bar: ({ states, transitionTo }, payload) => transitionTo(states.baz, `${payload}-bar`), 6 | baz: (_, payload) => `${payload}-baz`, 7 | }; 8 | 9 | test('can transition between states', () => { 10 | const fsm = newStateMachine(testStates); 11 | const actual = fsm.transitionTo(fsm.states.foo); 12 | 13 | expect(actual.state).toBe('foo-bar-baz'); 14 | expect(fsm.history().length).toBe(3); 15 | }); 16 | 17 | test('can undo transition from state', () => { 18 | const fsm = newStateMachine(testStates); 19 | fsm.transitionTo(fsm.states.foo); 20 | const actual = fsm.undo(); 21 | 22 | expect(actual.state).toBe('foo-bar'); 23 | expect(fsm.history().length).toBe(4); 24 | }); 25 | 26 | test('can undo multiple times', () => { 27 | const fsm = newStateMachine(testStates); 28 | fsm.transitionTo(fsm.states.foo); 29 | fsm.undo(); 30 | const actual = fsm.undo(); 31 | 32 | expect(actual.state).toBe('foo'); 33 | expect(fsm.history().length).toBe(5); 34 | }); 35 | 36 | test('cannot undo infinitely', () => { 37 | const fsm = newStateMachine(testStates); 38 | fsm.transitionTo(fsm.states.foo); 39 | fsm.undo(); 40 | fsm.undo(); 41 | const actual = fsm.undo(); 42 | 43 | expect(actual.state).toBe('foo'); 44 | expect(fsm.history().length).toBe(5); 45 | }); 46 | 47 | test('can redo transition from state', () => { 48 | const fsm = newStateMachine(testStates); 49 | fsm.transitionTo(fsm.states.foo); 50 | fsm.undo(); 51 | fsm.undo(); 52 | const actual = fsm.redo(); 53 | 54 | expect(actual.state).toBe('foo-bar'); 55 | expect(fsm.history().length).toBe(6); 56 | }); 57 | 58 | test('can redo multiple times', () => { 59 | const fsm = newStateMachine(testStates); 60 | fsm.transitionTo(fsm.states.foo); 61 | fsm.undo(); 62 | fsm.undo(); 63 | fsm.redo(); 64 | const actual = fsm.redo(); 65 | 66 | expect(actual.state).toBe('foo-bar-baz'); 67 | expect(fsm.history().length).toBe(7); 68 | }); 69 | 70 | test('cannot redo infinitely', () => { 71 | const fsm = newStateMachine(testStates); 72 | fsm.transitionTo(fsm.states.foo); 73 | const actual = fsm.redo(); 74 | 75 | expect(actual.state).toBe('foo-bar-baz'); 76 | expect(fsm.history().length).toBe(3); 77 | }); 78 | 79 | test('cannot transition to unexpected state', () => { 80 | const fsm = newStateMachine(testStates); 81 | 82 | expect(() => { 83 | fsm.transitionTo({ foo: () => null }); 84 | }).toThrow('failed to transition to an unknown state!'); 85 | }); 86 | 87 | test('cannot undo with an empty history', () => { 88 | const fsm = newStateMachine(testStates); 89 | fsm.undo(); 90 | 91 | expect(fsm.history().length).toBe(0); 92 | }); 93 | 94 | test('cannot redo with an empty history', () => { 95 | const fsm = newStateMachine(testStates); 96 | fsm.redo(); 97 | 98 | expect(fsm.history().length).toBe(0); 99 | }); 100 | 101 | test('can create factory state machine', () => { 102 | const { fsm, result } = factoryStateMachine(testStates, testStates.foo); 103 | 104 | expect(result.state).toBe('foo-bar-baz'); 105 | expect(fsm.history().length).toBe(3); 106 | }); 107 | 108 | test('can call single-use state machine', () => { 109 | const result = fireStateMachine({ 110 | start: ({ states, transitionTo }, payload) => transitionTo(states.prependFoo, payload), 111 | prependFoo: ({ states, transitionTo }, payload) => transitionTo(states.appendBar, `foo-${payload}`), 112 | appendBar: ({ states, transitionTo }, payload) => transitionTo(states.end, `${payload}-bar`), 113 | end: (_, payload) => payload, 114 | }, 'baz'); 115 | 116 | expect(result.state).toBe('foo-baz-bar'); 117 | }); 118 | 119 | test('cannot create state machine without states', () => { 120 | expect(() => { 121 | newStateMachine({}); 122 | }).toThrow('Cannot create a state machine without states'); 123 | }); 124 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './index.js', 5 | output: { 6 | path: path.resolve(__dirname, 'build'), 7 | filename: 'ffsm.js', 8 | library: 'ffsm', 9 | libraryTarget: 'commonjs2', 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.m?js$/, 15 | include: path.resolve(__dirname, 'src'), 16 | exclude: /(node_modules|build)/, 17 | use: [ 18 | { 19 | loader: 'babel-loader', 20 | options: { 21 | presets: ['@babel/preset-env'], 22 | plugins: ['@babel/plugin-transform-spread'], 23 | }, 24 | }, 25 | { 26 | loader: 'eslint-loader', 27 | }, 28 | ], 29 | }, 30 | ], 31 | }, 32 | }; 33 | --------------------------------------------------------------------------------