├── .gitignore ├── .prettierrc ├── static └── arch.png ├── package.json ├── storage.ts ├── machine.ts ├── server.ts ├── interpreter.ts ├── README.md └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /static/arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tom-sherman/serverless-xstate/HEAD/static/arch.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-xstate", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node-dev --respawn server.ts" 8 | }, 9 | "keywords": [], 10 | "author": "Tom Sherman (https://tom-sherman.com/)", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "@types/node": "^16.6.2", 14 | "prettier": "^2.3.2", 15 | "ts-node-dev": "^1.1.8", 16 | "typescript": "^4.3.5" 17 | }, 18 | "dependencies": { 19 | "http4ts": "^0.1.2", 20 | "xstate": "^4.23.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /storage.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | async function exists(filename: string) { 4 | try { 5 | await fs.access(filename); 6 | return true; 7 | } catch (_) { 8 | return false; 9 | } 10 | } 11 | 12 | export interface Storage { 13 | get: (key: string) => Promise; 14 | set: (key: string, value: any) => Promise; 15 | } 16 | 17 | export function createFileStorage(filename: string): Storage { 18 | return { 19 | get: async (key: string) => { 20 | let file; 21 | try { 22 | file = await fs.readFile(filename); 23 | } catch (_) { 24 | return null; 25 | } 26 | 27 | return JSON.parse(String(file))[key] ?? null; 28 | }, 29 | set: async (key: string, value: any) => { 30 | const obj = (await exists(filename)) 31 | ? JSON.parse(String(await fs.readFile(filename))) 32 | : {}; 33 | 34 | obj[key] = value; 35 | await fs.writeFile(filename, JSON.stringify(obj)); 36 | }, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /machine.ts: -------------------------------------------------------------------------------- 1 | import { createMachine, interpret, actions } from 'xstate'; 2 | 3 | const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)); 4 | 5 | export const countMachine = createMachine({ 6 | id: 'count', 7 | initial: 'idle', 8 | context: { 9 | count: 0, 10 | }, 11 | states: { 12 | idle: { 13 | on: { 14 | COUNT: 'even', 15 | }, 16 | }, 17 | even: { 18 | on: { 19 | COUNT: 'counting_even', 20 | }, 21 | tags: ['resolve'], 22 | }, 23 | counting_even: { 24 | invoke: { 25 | src: () => wait(1000), 26 | onDone: { 27 | target: 'odd', 28 | actions: actions.assign({ count: (ctx) => (ctx as any).count + 1 }), 29 | }, 30 | }, 31 | }, 32 | odd: { 33 | invoke: { 34 | src: () => wait(1000), 35 | onDone: { 36 | target: 'even', 37 | actions: actions.assign({ count: (ctx) => (ctx as any).count + 1 }), 38 | }, 39 | }, 40 | }, 41 | }, 42 | }); 43 | 44 | const service = interpret(countMachine).onTransition((state) => { 45 | console.log(state.value); 46 | }); 47 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | 3 | import { 4 | HttpRequest, 5 | toNodeRequestListener, 6 | OK, 7 | BAD_REQUEST, 8 | } from 'http4ts/dist/node'; 9 | import { createInterpreter } from './interpreter'; 10 | import { countMachine } from './machine'; 11 | import { createFileStorage } from './storage'; 12 | 13 | async function handler(req: HttpRequest) { 14 | const id = new URL(req.url, `http://${hostname}:${port}`).searchParams.get( 15 | 'id' 16 | ); 17 | if (!id) { 18 | return BAD_REQUEST({ 19 | body: JSON.stringify({ message: 'Must provide an ID' }), 20 | }); 21 | } 22 | const storage = createFileStorage('store.json'); 23 | const interpreter = createInterpreter(id, countMachine, storage); 24 | const state = await interpreter.settleMachine( 25 | JSON.parse(await req.body.asString()) 26 | ); 27 | 28 | return OK({ 29 | body: JSON.stringify({ hello: 'world', body: state }), 30 | headers: { 'content-type': 'application/json' }, 31 | }); 32 | } 33 | 34 | const server = http.createServer(toNodeRequestListener(handler)); 35 | 36 | const hostname = '127.0.0.1'; 37 | const port = 3000; 38 | 39 | server.listen(port, hostname, () => { 40 | console.log(`Server running at http://${hostname}:${port}/`); 41 | }); 42 | -------------------------------------------------------------------------------- /interpreter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | State, 3 | DefaultContext, 4 | StateSchema, 5 | EventObject, 6 | Typestate, 7 | StateMachine, 8 | Event, 9 | interpret as xstateInterpret, 10 | } from 'xstate'; 11 | import type { Storage } from './storage'; 12 | 13 | function patchStateDefinitionTagsBug(stateDefinition: any) { 14 | if (Array.isArray(stateDefinition.tags)) { 15 | stateDefinition.tags = new Set(stateDefinition.tags); 16 | } 17 | } 18 | 19 | export function createInterpreter< 20 | TContext = DefaultContext, 21 | TStateSchema extends StateSchema = any, 22 | TEvent extends EventObject = EventObject, 23 | TTypestate extends Typestate = { 24 | value: any; 25 | context: TContext; 26 | } 27 | >( 28 | key: string, 29 | machine: StateMachine, 30 | storage: Storage 31 | ) { 32 | const settleMachine = async (event: Event) => { 33 | const service = xstateInterpret(machine); 34 | const stateDefinition = (await storage.get(key)) ?? machine.initialState; 35 | 36 | // Temporary hack while waiting on xstate fix 37 | patchStateDefinitionTagsBug(stateDefinition); 38 | 39 | const previousState = machine.resolveState(State.create(stateDefinition)); 40 | 41 | let hasTransitioned = false; 42 | 43 | const nextState = await new Promise< 44 | State 45 | >((resolve, reject) => { 46 | service 47 | .onTransition((state) => { 48 | // When we resume a state machine the initial state with always have either a resolve or a reject tag. 49 | // This ensures that we don't immediately exit when entering into the inital state. 50 | if (!hasTransitioned) { 51 | hasTransitioned = true; 52 | return; 53 | } 54 | // TODO: Not sure about stopping then resolving/rejecting, maybe we should set some mutbale state just stop? 55 | if (state.hasTag('resolve')) { 56 | service.stop(); 57 | return resolve(state); 58 | } else if (state.hasTag('reject')) { 59 | service.stop(); 60 | return reject(state); 61 | } 62 | }) 63 | .start(previousState) 64 | .send(event); 65 | }); 66 | 67 | await storage.set(key, nextState); 68 | 69 | return nextState; 70 | }; 71 | 72 | return { settleMachine }; 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serverless XState 2 | 3 | Running xstate on serverless functions. 4 | 5 | ## Demo 6 | 7 | There is a demo of this architecture deployed to serverless AWS over at [ovotech/serverless-xstate-demo](https://github.com/ovotech/serverless-xstate-demo) 8 | 9 | ## Why? 10 | 11 | AWS Step functions are cool and allow you to build state machines, but they are difficult to setup for processes that are meant to wait for some external signal like a request from a user. 12 | 13 | Consider how we would deploy a "user signup machine" with AWS Step Functions. The part where we wait for the user to verify their email address can happen after some non-trivial amount of time. 14 | 15 | This is solved by what AWS terms "manual approval steps", steps in the workflow that can be approved/declined from the outside. This feels rather tacked on to me and doesn't support the full power of state machines. For example what if there are more than success and fail transitions from a state node? I'm not sure if this is even possible with step functions (please correct me)! 16 | 17 | This doesn't begin to comment on other drawbacks of using step functions such as lock in and tooling. 18 | 19 | What if we want to use a proper language (TypeScript) with proper tooling (xstate)? 20 | 21 | ## How? 22 | 23 | This is where it gets a little tricky. XState isn't designed (at the time of writing) for this use case. We need to solve a few problems: 24 | 25 | - In order to run an XState machine in a lambda, we need a way of pausing and continuing the machine on arbitrary state nodes 26 | - To do this, we need to serialise the state somewhere 27 | - Before we can pause a machine, we must wait for any async services to complete 28 | - Concurrent events need to be queued 29 | 30 | These four problems are elegantly solved in [this great Durable Entities example](https://github.com/davidkpiano/durable-entities-xstate) but we don't all have the pleasure of working in an Azure environment and until AWS releases a Durable Entities alternative, we must find a different way. 31 | 32 | ### Pausing 33 | 34 | There are a couple of ways to do this. [One approach I've seen](https://github.com/davidkpiano/durable-entities-xstate/issues/1) is to start interpreting the machine and wait for any services to end. In pseudocode: 35 | 36 | ``` 37 | service = interpret(machine) 38 | while (service.state.services.length > 0) { 39 | sleep() 40 | } 41 | 42 | return service.currentState 43 | ``` 44 | 45 | Let's call this a polling strategy. I'm sure this could work, but right now I'm tending towards something more declarative. 46 | 47 | In our machine, there are points where we must yield to the user or other external systems. These points, if we design our machine well, will be state nodes in our chart that represent when our machine is in a "settled" state. By definition they will have no services attached. 48 | 49 | From any node in our machine, we can interpret the machine until we reach one of these "settled" nodes. At which point we know the machine isn't going to transition any further right now and end the execution. 50 | 51 | We can mark these nodes as settled using some metadata eg. [Tags](https://xstate.js.org/docs/guides/statenodes.html#tags). This has the added benefit of being able to see at a glance when your machine is waiting for an external event, and when it's in the middle of processing internal ones. These nodes are boundaries where the machine can receive events from the outside world, it's quite nice to be explicit about this! 52 | 53 | ### Serialising state 54 | 55 | Now we have a way of pausing the machine, we need a way of continuing it. The nodes we mark as "settled" offer convenient places from which we can serialise our state, we can save it to some datastore each time we enter one. 56 | 57 | Then when we receive a new external event we can hydrate the state from the datastore, begin execution, and wait to reach the next settled node. 58 | 59 | ### Handling concurrent events 60 | 61 | While our machine is in an un-settled (🥲) state we must make sure not to accept any new events. Doing so could easily cause race conditions where two concurrent threads of execution will read and write the state to our datastore. 62 | 63 | We can solve this by putting some sort of FIFO queue in front of our machine. Each time we reach a settled node we can dequeue the next event and begin executing once again. In an AWS serverless design, this could be an SQS FIFO queue using different message group IDs for each machine instance. 64 | 65 | ### The whole design 66 | 67 | ![](static/arch.png) 68 | 69 | - **The User** - sends event to a FIFO queue eg. AWS SQS 70 | - **FIFO Queue** - holds events that are waiting to be picked up by a machine 71 | - **Machine** - is a serverless function that is executed from an event in the queue 72 | - **Datastore** - is where a machine retrieves it's initial state when it starts executing, and where it persists the state when it reaches a settled node 73 | 74 | ### Open questions 75 | 76 | - How do we handle migrations in the event the machine definition or context shape changes? 77 | 78 | ## Running 79 | 80 | The code in this repo can be ran locally with: 81 | 82 | ``` 83 | npm i 84 | npm run start 85 | ``` 86 | 87 | There is a demo of this architecture deployed to AWS Lambda at [ovotech/serverless-xstate-demo](https://github.com/ovotech/serverless-xstate-demo) 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "ES2021", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ 7 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 12 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | // "outDir": "./", /* Redirect output structure to the directory. */ 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 40 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 41 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | /* Advanced Options */ 62 | "moduleResolution": "Node", 63 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 64 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 65 | "outDir": "dist" 66 | } 67 | } 68 | --------------------------------------------------------------------------------