├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── auth │ └── app.ts └── payment │ └── app.ts ├── package-lock.json ├── package.json ├── src ├── lib.ts ├── promise.ts └── public_api.ts ├── tsconfig.eslint.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended-type-checked", 10 | "plugin:require-extensions/recommended", 11 | "plugin:import/recommended", 12 | "plugin:import/typescript" 13 | ], 14 | "settings": { 15 | "import/resolver": { 16 | "typescript": true, 17 | "node": true 18 | } 19 | }, 20 | "parser": "@typescript-eslint/parser", 21 | "parserOptions": { 22 | "ecmaVersion": "latest", 23 | "project": ["./tsconfig.eslint.json"] 24 | }, 25 | "plugins": ["@typescript-eslint", "require-extensions"], 26 | "rules": { 27 | "no-console": "off", 28 | "@typescript-eslint/consistent-type-imports": "error", 29 | "@typescript-eslint/consistent-type-exports": "error", 30 | "@typescript-eslint/restrict-template-expressions": [ 31 | "error", 32 | { 33 | "allowNever": true, 34 | "allowArray": true 35 | } 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to NPM 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | packages: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | # Setup .npmrc file to publish to NPM 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: "19.x" 17 | registry-url: "https://registry.npmjs.org" 18 | - run: npm ci 19 | - run: npm run build 20 | - run: npm publish --tag latest --access public 21 | env: 22 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | # prevent from running on forks 12 | if: github.repository_owner == 'restatedev' 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [19.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | registry-url: "https://registry.npmjs.org" 25 | - run: npm run clean 26 | - run: npm ci 27 | - run: npm run verify 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | dist/* 3 | 4 | src/generated/* 5 | buf.lock 6 | 7 | .idea 8 | 9 | 10 | packages/*/node_modules/* 11 | packages/*/dist/* 12 | packages/*/src/generated/* 13 | packages/*/buf.lock 14 | 15 | restate-data 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2023-2024 - Restate Software, Inc., Restate GmbH 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE 23 | 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deploying a XState state machine on Restate 2 | 3 | This repo shows how to integrate Restate deeply with 4 | [XState](https://stately.ai/docs/xstate). The code in [src/lib.ts](./src/lib.ts) 5 | converts an XState machine into a Restate virtual object, which stores the state 6 | of the state machine, keyed on an identifier for this instance of the machine. 7 | This service is called with every event that must be processed by the state machine. 8 | XState machines are generally pure and are not async; side effects generally 9 | happen through [Promise Actors](https://stately.ai/docs/promise-actors). 10 | As such, this service should never block the machine, so other events can always be 11 | processed. The provided Promise actor `fromPromise` should be used to handle 12 | async operations, which will run in a shared virtual object handler so as to 13 | avoid blocking the event loop. 14 | 15 | The service is set up and managed automatically by interpreting the state 16 | machine definition, and can be deployed as a Lambda or as a long-lived service. 17 | 18 | In [`examples/auth/app.ts`](./examples/auth/app.ts) you will see an example of an XState machine 19 | that uses cross-machine communication, delays, and Promise actors, all running in Restate. 20 | Most XState machines should work out of the box, but this is still experimental, so 21 | we haven't tested everything yet! 22 | 23 | To try out this example: 24 | 25 | ```bash 26 | # start a local Restate instance 27 | restate-server 28 | # start the service 29 | npm run auth-example 30 | # register the state machine service against restate 31 | restate dep register http://localhost:9080 32 | 33 | # create a state machine 34 | curl http://localhost:8080/auth/myMachine/create 35 | # watch the state 36 | watch -n1 'curl -s http://localhost:8080/auth/myMachine/snapshot' 37 | # kick off the machine 38 | curl http://localhost:8080/auth/myMachine/send --json '{"event": {"type": "AUTH"}}' 39 | # and watch the auth flow progress! 40 | ``` 41 | -------------------------------------------------------------------------------- /examples/auth/app.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023 - Restate Software, Inc., Restate GmbH 3 | * 4 | * This file is part of the Restate Examples for the Node.js/TypeScript SDK, 5 | * which is released under the MIT license. 6 | * 7 | * You can find a copy of the license in the file LICENSE 8 | * in the root directory of this repository or package or at 9 | * https://github.com/restatedev/examples/blob/main/LICENSE 10 | */ 11 | 12 | import * as restate from "@restatedev/restate-sdk"; 13 | import { xstate, fromPromise } from "@restatedev/xstate"; 14 | import { createMachine, sendTo } from "xstate"; 15 | 16 | const authServerMachine = createMachine( 17 | { 18 | id: "server", 19 | initial: "waitingForCode", 20 | states: { 21 | waitingForCode: { 22 | on: { 23 | CODE: { 24 | target: "process", 25 | }, 26 | }, 27 | }, 28 | process: { 29 | invoke: { 30 | id: "process", 31 | src: "authorise", 32 | onDone: { 33 | actions: sendTo( 34 | ({ self }) => self._parent!, 35 | { type: "TOKEN" }, 36 | { delay: 1000 } 37 | ), 38 | }, 39 | }, 40 | }, 41 | }, 42 | }, 43 | { 44 | actors: { 45 | authorise: fromPromise( 46 | () => new Promise((resolve) => setTimeout(resolve, 5000)) 47 | ), 48 | }, 49 | } 50 | ); 51 | 52 | const authClientMachine = createMachine({ 53 | id: "client", 54 | initial: "idle", 55 | states: { 56 | idle: { 57 | on: { 58 | AUTH: { target: "authorizing" }, 59 | }, 60 | }, 61 | authorizing: { 62 | invoke: { 63 | id: "auth-server", 64 | src: authServerMachine, 65 | }, 66 | entry: sendTo("auth-server", ({ self }) => ({ 67 | type: "CODE", 68 | sender: self, 69 | })), 70 | on: { 71 | TOKEN: { target: "authorized" }, 72 | }, 73 | }, 74 | authorized: { 75 | type: "final", 76 | }, 77 | }, 78 | }); 79 | 80 | await restate.endpoint().bind(xstate("auth", authClientMachine)).listen(); 81 | -------------------------------------------------------------------------------- /examples/payment/app.ts: -------------------------------------------------------------------------------- 1 | import { log, setup } from "xstate"; 2 | import * as restate from "@restatedev/restate-sdk"; 3 | import { fromPromise, xstate } from "@restatedev/xstate"; 4 | 5 | export const machine = setup({ 6 | types: { 7 | context: {} as { 8 | paymentID: string; 9 | senderUserID: string; 10 | recipientUserID: string; 11 | amount: number; 12 | }, 13 | input: {} as { 14 | key: string; // the key the state machine was created against 15 | senderUserID: string; 16 | recipientUserID: string; 17 | amount: number; 18 | }, 19 | events: {} as { type: "approved" } | { type: "rejected" }, 20 | }, 21 | actions: { 22 | requestApproval: log( 23 | ({ context }) => `Requesting approval for ${context.paymentID}` 24 | ), 25 | sendEmail: log(({ context }) => `Sending email to ${context.senderUserID}`), 26 | }, 27 | actors: { 28 | updateBalance: fromPromise( 29 | async ({ input }: { input: { userID: string; amount: number } }) => { 30 | console.log(`Adding ${input.amount} to the balance of ${input.userID}`); 31 | const res = await fetch("https://httpbin.org/get"); 32 | return res.json(); 33 | } 34 | ), 35 | }, 36 | }).createMachine({ 37 | context: ({ input }) => ({ 38 | senderUserID: input.senderUserID, 39 | recipientUserID: input.recipientUserID, 40 | amount: input.amount, 41 | paymentID: input.key, 42 | }), 43 | id: "Payment", 44 | initial: "Awaiting approval", 45 | states: { 46 | "Awaiting approval": { 47 | on: { 48 | approved: { 49 | target: "Approved", 50 | }, 51 | rejected: { 52 | target: "Rejected", 53 | }, 54 | }, 55 | after: { 56 | "10000": { 57 | target: "Awaiting manual approval", 58 | }, 59 | }, 60 | entry: { 61 | type: "requestApproval", 62 | }, 63 | }, 64 | Approved: { 65 | invoke: { 66 | input: ({ context }) => ({ 67 | userID: context.senderUserID, 68 | amount: context.amount, 69 | }), 70 | onDone: { 71 | target: "Debited", 72 | }, 73 | onError: { 74 | target: "Cancelled", 75 | }, 76 | src: "updateBalance", 77 | }, 78 | }, 79 | "Awaiting manual approval": { 80 | on: { 81 | approved: { 82 | target: "Approved", 83 | }, 84 | rejected: { 85 | target: "Rejected", 86 | }, 87 | }, 88 | entry: { 89 | type: "sendEmail", 90 | }, 91 | }, 92 | Rejected: {}, 93 | Cancelled: {}, 94 | Debited: { 95 | invoke: { 96 | input: ({ context }) => ({ 97 | userID: context.recipientUserID, 98 | amount: context.amount, 99 | }), 100 | onDone: { 101 | target: "Succeeded", 102 | }, 103 | onError: { 104 | target: "Refunding", 105 | }, 106 | src: "updateBalance", 107 | }, 108 | }, 109 | Succeeded: {}, 110 | Refunding: { 111 | invoke: { 112 | input: ({ context }) => ({ 113 | userID: context.senderUserID, 114 | amount: context.amount, 115 | }), 116 | onDone: { 117 | target: "Cancelled", 118 | }, 119 | src: "updateBalance", 120 | }, 121 | }, 122 | }, 123 | }); 124 | 125 | await restate.endpoint().bind(xstate("payment", machine)).listen(); 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@restatedev/xstate", 3 | "version": "0.1.1", 4 | "description": "Run XState state machines on Restate", 5 | "license": "MIT", 6 | "author": "Restate developers", 7 | "email": "code@restate.dev", 8 | "type": "module", 9 | "main": "./dist/cjs/src/public_api.js", 10 | "types": "./dist/cjs/src/public_api.d.ts", 11 | "module": "./dist/esm/src/public_api.js", 12 | "exports": { 13 | ".": { 14 | "import": { 15 | "types": "./dist/esm/src/public_api.d.ts", 16 | "default": "./dist/esm/src/public_api.js" 17 | }, 18 | "require": { 19 | "types": "./dist/cjs/src/public_api.d.ts", 20 | "default": "./dist/cjs/src/public_api.js" 21 | } 22 | } 23 | }, 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "clean": "rm -rf dist", 29 | "build": "npm run build:cjs && npm run build:esm", 30 | "build:cjs": "tsc --module commonjs --verbatimModuleSyntax false --moduleResolution node10 --outDir ./dist/cjs --declaration --declarationDir ./dist/cjs && echo >./dist/cjs/package.json '{\"type\":\"commonjs\"}'", 31 | "build:esm": "tsc --outDir ./dist/esm --declaration --declarationDir ./dist/esm", 32 | "test": "vitest run --silent --passWithNoTests", 33 | "lint": "eslint --ignore-path .eslintignore --max-warnings=0 --ext .ts .", 34 | "format": "prettier --ignore-path .eslintignore --write \"**/*.+(js|ts|json)\"", 35 | "format-check": "prettier --ignore-path .eslintignore --check \"**/*.+(js|ts|json)\"", 36 | "attw": "attw --pack", 37 | "verify": "npm run format-check && npm run lint && npm run test && npm run build && npm run attw", 38 | "release": "release-it", 39 | "auth-example": "tsx --watch examples/auth/app.ts", 40 | "payment-example": "tsx --watch examples/payment/app.ts" 41 | }, 42 | "dependencies": { 43 | "@restatedev/restate-sdk": "^1.3.0", 44 | "xstate": "^5.18.0" 45 | }, 46 | "devDependencies": { 47 | "@arethetypeswrong/cli": "^0.15.3", 48 | "@types/node": "^20.10.4", 49 | "@typescript-eslint/eslint-plugin": "^7.13.0", 50 | "@typescript-eslint/parser": "^7.13.0", 51 | "eslint": "^8.57.0", 52 | "eslint-import-resolver-typescript": "^3.6.1", 53 | "eslint-plugin-import": "^2.29.1", 54 | "eslint-plugin-require-extensions": "^0.1.3", 55 | "prettier": "^2.8.4", 56 | "release-it": "^17.3.0", 57 | "tsx": "^4.19.0", 58 | "typescript": "^5.4.5", 59 | "vitest": "^1.6.0" 60 | }, 61 | "publishConfig": { 62 | "@restatedev:registry": "https://registry.npmjs.org" 63 | }, 64 | "release-it": { 65 | "git": { 66 | "pushRepo": "https://github.com/restatedev/xstate.git" 67 | }, 68 | "github": { 69 | "release": true 70 | }, 71 | "npm": { 72 | "publish": "false" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/lib.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Actor, 3 | ActorLogicFrom, 4 | ActorOptions, 5 | ActorSystem, 6 | ActorSystemInfo, 7 | AnyActorLogic, 8 | AnyActorRef, 9 | AnyEventObject, 10 | AnyStateMachine, 11 | EventObject, 12 | HomomorphicOmit, 13 | InputFrom, 14 | InspectionEvent, 15 | InteropSubscribable, 16 | Observer, 17 | PromiseActorLogic, 18 | Snapshot, 19 | Subscription, 20 | } from "xstate"; 21 | import { toObserver, createActor as createXActor } from "xstate"; 22 | import * as restate from "@restatedev/restate-sdk"; 23 | import { TerminalError } from "@restatedev/restate-sdk"; 24 | import { 25 | type PromiseCreator, 26 | resolveReferencedActor, 27 | RESTATE_PROMISE_REJECT, 28 | RESTATE_PROMISE_RESOLVE, 29 | } from "./promise.js"; 30 | 31 | export interface RestateActorSystem 32 | extends ActorSystem { 33 | _bookId: () => string; 34 | _register: (sessionId: string, actorRef: ActorRefEventSender) => string; 35 | _unregister: (actorRef: AnyActorRef) => void; 36 | _sendInspectionEvent: ( 37 | event: HomomorphicOmit 38 | ) => void; 39 | actor: (sessionId: string) => ActorRefEventSender | undefined; 40 | _set: (key: K, actorRef: T["actors"][K]) => void; 41 | _relay: ( 42 | source: AnyActorRef | SerialisableActorRef | undefined, 43 | target: ActorRefEventSender, 44 | event: AnyEventObject 45 | ) => void; 46 | api: XStateApi>; 47 | ctx: restate.ObjectContext; 48 | systemName: string; 49 | } 50 | 51 | type SerialisableActorRef = { 52 | id: string; 53 | sessionId: string; 54 | _parent?: SerialisableActorRef; 55 | }; 56 | 57 | export const serialiseActorRef = ( 58 | actorRef: AnyActorRef 59 | ): SerialisableActorRef => { 60 | return { 61 | id: actorRef.id, 62 | sessionId: actorRef.sessionId, 63 | _parent: 64 | actorRef._parent === undefined 65 | ? undefined 66 | : serialiseActorRef(actorRef._parent), 67 | }; 68 | }; 69 | 70 | type SerialisableScheduledEvent = { 71 | id: string; 72 | event: EventObject; 73 | startedAt: number; 74 | delay: number; 75 | source: SerialisableActorRef; 76 | target: SerialisableActorRef; 77 | uuid: string; 78 | }; 79 | 80 | type State = { 81 | events: { [key: string]: SerialisableScheduledEvent }; 82 | children: { [key: string]: SerialisableActorRef }; 83 | snapshot: Snapshot; 84 | }; 85 | 86 | async function createSystem( 87 | ctx: restate.ObjectContext, 88 | api: XStateApi>, 89 | systemName: string 90 | ): Promise> { 91 | const events = (await ctx.get("events")) ?? {}; 92 | const childrenByID = (await ctx.get("children")) ?? {}; 93 | 94 | const children = new Map(); 95 | const keyedActors = new Map(); 96 | const reverseKeyedActors = new WeakMap(); 97 | const observers = new Set< 98 | Observer | ((inspectionEvent: InspectionEvent) => void) 99 | >(); 100 | 101 | const scheduler = { 102 | schedule( 103 | _source: AnyActorRef, 104 | _target: AnyActorRef, 105 | event: EventObject, 106 | delay: number, 107 | id: string | undefined 108 | ): void { 109 | if (id === undefined) { 110 | id = ctx.rand.random().toString(36).slice(2); 111 | } 112 | 113 | const { source, target } = { 114 | source: serialiseActorRef(_source), 115 | target: serialiseActorRef(_target), 116 | }; 117 | 118 | ctx.console.log( 119 | "Scheduling event from", 120 | source.id, 121 | "to", 122 | target.id, 123 | "with id", 124 | id, 125 | "and delay", 126 | delay 127 | ); 128 | 129 | const scheduledEvent: SerialisableScheduledEvent = { 130 | source, 131 | target, 132 | event, 133 | delay, 134 | id, 135 | startedAt: Date.now(), 136 | uuid: ctx.rand.uuidv4(), 137 | }; 138 | const scheduledEventId = createScheduledEventId(source, id); 139 | if (scheduledEventId in events) { 140 | ctx.console.log( 141 | "Ignoring duplicate schedule from", 142 | source.id, 143 | "to", 144 | target.id 145 | ); 146 | return; 147 | } 148 | 149 | events[scheduledEventId] = scheduledEvent; 150 | 151 | ctx 152 | .objectSendClient(api, systemName, { delay }) 153 | .send({ scheduledEvent, source, target, event }); 154 | ctx.set("events", events); 155 | }, 156 | cancel(source: AnyActorRef, id: string): void { 157 | const scheduledEventId = createScheduledEventId(source, id); 158 | 159 | if (!(scheduledEventId in events)) return; 160 | 161 | ctx.console.log( 162 | "Cancelling scheduled event from", 163 | source.id, 164 | "with id", 165 | id 166 | ); 167 | 168 | delete events[scheduledEventId]; 169 | ctx.set("events", events); 170 | }, 171 | cancelAll(actorRef: AnyActorRef): void { 172 | if (Object.keys(events).length == 0) return; 173 | 174 | ctx.console.log("Cancel all events for", actorRef.id); 175 | 176 | for (const scheduledEventId in events) { 177 | const scheduledEvent = events[scheduledEventId]; 178 | if (scheduledEvent.source.sessionId === actorRef.sessionId) { 179 | delete events[scheduledEventId]; 180 | } 181 | } 182 | ctx.set("events", events); 183 | }, 184 | }; 185 | 186 | const system: RestateActorSystem = { 187 | ctx, 188 | api, 189 | systemName, 190 | 191 | _bookId: () => ctx.rand.uuidv4(), 192 | _register: (sessionId, actorRef) => { 193 | if (actorRef.id in childrenByID) { 194 | // rehydration case; ensure session ID maintains continuity 195 | sessionId = childrenByID[actorRef.id].sessionId; 196 | actorRef.sessionId = sessionId; 197 | } else { 198 | // new actor case 199 | childrenByID[actorRef.id] = serialiseActorRef(actorRef); 200 | ctx.set("children", childrenByID); 201 | } 202 | children.set(sessionId, actorRef); 203 | return sessionId; 204 | }, 205 | _unregister: (actorRef) => { 206 | if (actorRef.id in childrenByID) { 207 | // rehydration case; ensure session ID maintains continuity 208 | actorRef.sessionId = childrenByID[actorRef.id].sessionId; 209 | } 210 | 211 | children.delete(actorRef.sessionId); 212 | delete childrenByID[actorRef.id]; 213 | ctx.set("children", childrenByID); 214 | const systemId = reverseKeyedActors.get(actorRef); 215 | 216 | if (systemId !== undefined) { 217 | keyedActors.delete(systemId); 218 | reverseKeyedActors.delete(actorRef); 219 | } 220 | }, 221 | _sendInspectionEvent: (event) => { 222 | const resolvedInspectionEvent: InspectionEvent = { 223 | ...event, 224 | rootId: ctx.key, 225 | }; 226 | observers.forEach((observer) => { 227 | if (typeof observer == "function") { 228 | observer(resolvedInspectionEvent); 229 | } else { 230 | observer.next?.(resolvedInspectionEvent); 231 | } 232 | }); 233 | }, 234 | actor: (sessionId) => { 235 | return children.get(sessionId); 236 | }, 237 | get: (systemId) => { 238 | return keyedActors.get(systemId) as T["actors"][typeof systemId]; 239 | }, 240 | _set: (systemId, actorRef) => { 241 | const existing = keyedActors.get(systemId); 242 | if (existing && existing !== actorRef) { 243 | throw new Error( 244 | `Actor with system ID '${systemId as string}' already exists.` 245 | ); 246 | } 247 | 248 | keyedActors.set(systemId, actorRef); 249 | reverseKeyedActors.set(actorRef, systemId); 250 | }, 251 | inspect: (observer) => { 252 | observers.add(observer); 253 | return { 254 | unsubscribe: () => { 255 | observers.delete(observer); 256 | }, 257 | }; 258 | }, 259 | _relay: (source, target, event) => { 260 | ctx.console.log( 261 | "Relaying message from", 262 | source?.id, 263 | "to", 264 | target.id, 265 | ":", 266 | event.type 267 | ); 268 | target._send(event); 269 | }, 270 | scheduler, 271 | getSnapshot: () => { 272 | return { 273 | _scheduledEvents: {}, // unused 274 | }; 275 | }, 276 | start: () => {}, 277 | _logger: (...args: unknown[]) => ctx.console.log(...args), 278 | _clock: { 279 | setTimeout() { 280 | throw new Error("clock should be unused"); 281 | }, 282 | clearTimeout() { 283 | throw new Error("clock should be unused"); 284 | }, 285 | }, 286 | }; 287 | 288 | return system; 289 | } 290 | 291 | interface ActorEventSender extends Actor { 292 | _send: (event: AnyEventObject) => void; 293 | } 294 | 295 | export interface ActorRefEventSender extends AnyActorRef { 296 | _send: (event: AnyEventObject) => void; 297 | } 298 | 299 | async function createActor( 300 | ctx: restate.ObjectContext, 301 | api: XStateApi, 302 | systemName: string, 303 | logic: TLogic, 304 | options?: ActorOptions 305 | ): Promise> { 306 | const system = await createSystem(ctx, api, systemName); 307 | const snapshot = (await ctx.get("snapshot")) ?? undefined; 308 | 309 | const parent: ActorRefEventSender = { 310 | id: "fakeRoot", 311 | sessionId: "fakeRoot", 312 | send: () => {}, 313 | _send: () => {}, 314 | start: () => {}, 315 | getSnapshot: (): null => { 316 | return null; 317 | }, // TODO 318 | getPersistedSnapshot: (): Snapshot => { 319 | return { 320 | status: "active", 321 | output: undefined, 322 | error: undefined, 323 | }; 324 | }, // TODO 325 | stop: () => {}, // TODO 326 | on: () => { 327 | return { unsubscribe: () => {} }; 328 | }, // TODO 329 | system, 330 | src: "fakeRoot", 331 | subscribe: (): Subscription => { 332 | return { 333 | unsubscribe() {}, 334 | }; 335 | }, 336 | [Symbol.observable]: (): InteropSubscribable => { 337 | return { 338 | subscribe(): Subscription { 339 | return { 340 | unsubscribe() {}, 341 | }; 342 | }, 343 | }; 344 | }, 345 | }; 346 | 347 | if (options?.inspect) { 348 | // Always inspect at the system-level 349 | system.inspect(toObserver(options.inspect)); 350 | } 351 | 352 | const actor = createXActor(logic, { 353 | id: ctx.key, 354 | ...options, 355 | parent, 356 | snapshot, 357 | }); 358 | 359 | return actor as ActorEventSender; 360 | } 361 | 362 | const actorObject = ( 363 | path: string, 364 | logic: TLogic 365 | ) => { 366 | const api = xStateApi(path); 367 | 368 | return restate.object({ 369 | name: path, 370 | handlers: { 371 | create: async ( 372 | ctx: restate.ObjectContext, 373 | request?: { input?: InputFrom } 374 | ): Promise> => { 375 | const systemName = ctx.key; 376 | 377 | ctx.clear("snapshot"); 378 | ctx.clear("events"); 379 | ctx.clear("children"); 380 | 381 | const root = ( 382 | await createActor(ctx, api, systemName, logic, { 383 | input: { 384 | ctx, 385 | key: ctx.key, 386 | ...(request?.input ?? {}), 387 | } as InputFrom, 388 | }) 389 | ).start(); 390 | 391 | ctx.set("snapshot", root.getPersistedSnapshot()); 392 | 393 | return root.getPersistedSnapshot(); 394 | }, 395 | send: async ( 396 | ctx: restate.ObjectContext, 397 | request?: { 398 | scheduledEvent?: SerialisableScheduledEvent; 399 | source?: SerialisableActorRef; 400 | target?: SerialisableActorRef; 401 | event: AnyEventObject; 402 | } 403 | ): Promise | undefined> => { 404 | const systemName = ctx.key; 405 | 406 | if (!request) { 407 | throw new TerminalError("Must provide a request"); 408 | } 409 | 410 | if (request.scheduledEvent) { 411 | const events = (await ctx.get("events")) ?? {}; 412 | const scheduledEventId = createScheduledEventId( 413 | request.scheduledEvent.source, 414 | request.scheduledEvent.id 415 | ); 416 | if (!(scheduledEventId in events)) { 417 | ctx.console.log( 418 | "Received now cancelled event", 419 | scheduledEventId, 420 | "for target", 421 | request.target 422 | ); 423 | return; 424 | } 425 | if (events[scheduledEventId].uuid !== request.scheduledEvent.uuid) { 426 | ctx.console.log( 427 | "Received now replaced event", 428 | scheduledEventId, 429 | "for target", 430 | request.target 431 | ); 432 | return; 433 | } 434 | delete events[scheduledEventId]; 435 | ctx.set("events", events); 436 | } 437 | 438 | const root = (await createActor(ctx, api, systemName, logic)).start(); 439 | 440 | let actor; 441 | if (request.target) { 442 | actor = (root.system as RestateActorSystem).actor( 443 | request.target.sessionId 444 | ); 445 | if (!actor) { 446 | throw new TerminalError( 447 | `Actor ${request.target.id} not found; it may have since stopped` 448 | ); 449 | } 450 | } else { 451 | actor = root; 452 | } 453 | 454 | (root.system as RestateActorSystem)._relay( 455 | request.source, 456 | actor, 457 | request.event 458 | ); 459 | 460 | const nextSnapshot = root.getPersistedSnapshot(); 461 | ctx.set("snapshot", nextSnapshot); 462 | 463 | return nextSnapshot; 464 | }, 465 | snapshot: async ( 466 | ctx: restate.ObjectContext, 467 | systemName: string 468 | ): Promise> => { 469 | const root = await createActor(ctx, api, systemName, logic); 470 | 471 | return root.getPersistedSnapshot(); 472 | }, 473 | invokePromise: restate.handlers.object.shared( 474 | async ( 475 | ctx: restate.ObjectSharedContext, 476 | { 477 | self, 478 | srcs, 479 | input, 480 | }: { 481 | self: SerialisableActorRef; 482 | srcs: string[]; 483 | input: unknown; 484 | } 485 | ) => { 486 | const systemName = ctx.key; 487 | 488 | ctx.console.log( 489 | "run promise with srcs", 490 | srcs, 491 | "in system", 492 | systemName, 493 | "with input", 494 | input 495 | ); 496 | 497 | const [promiseSrc, ...machineSrcs] = srcs; 498 | 499 | let stateMachine: AnyStateMachine = logic; 500 | for (const src of machineSrcs) { 501 | let maybeSM; 502 | try { 503 | maybeSM = resolveReferencedActor(stateMachine, src); 504 | } catch (e) { 505 | throw new TerminalError( 506 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 507 | `Failed to resolve promise actor ${src}: ${e}` 508 | ); 509 | } 510 | if (maybeSM === undefined) { 511 | throw new TerminalError( 512 | `Couldn't find state machine actor with src ${src}` 513 | ); 514 | } 515 | if ("implementations" in maybeSM) { 516 | stateMachine = maybeSM as AnyStateMachine; 517 | } else { 518 | throw new TerminalError( 519 | `Couldn't recognise machine actor with src ${src}` 520 | ); 521 | } 522 | } 523 | 524 | let promiseActor: PromiseActorLogic | undefined; 525 | let maybePA; 526 | try { 527 | maybePA = resolveReferencedActor(stateMachine, promiseSrc); 528 | } catch (e) { 529 | throw new TerminalError( 530 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 531 | `Failed to resolve promise actor ${promiseSrc}: ${e}` 532 | ); 533 | } 534 | if (maybePA === undefined) { 535 | throw new TerminalError( 536 | `Couldn't find promise actor with src ${promiseSrc}` 537 | ); 538 | } 539 | if ( 540 | "sentinel" in maybePA && 541 | maybePA.sentinel === "restate.promise.actor" 542 | ) { 543 | promiseActor = maybePA as PromiseActorLogic; 544 | } else { 545 | throw new TerminalError( 546 | `Couldn't recognise promise actor with src ${promiseSrc}` 547 | ); 548 | } 549 | 550 | const resolvedPromise = Promise.resolve( 551 | (promiseActor.config as PromiseCreator)({ 552 | input, 553 | ctx, 554 | }) 555 | ); 556 | 557 | await resolvedPromise.then( 558 | (response) => { 559 | ctx.objectSendClient(api, systemName).send({ 560 | source: self, 561 | target: self, 562 | event: { 563 | type: RESTATE_PROMISE_RESOLVE, 564 | data: response, 565 | }, 566 | }); 567 | }, 568 | (errorData: unknown) => { 569 | ctx.objectSendClient(api, systemName).send({ 570 | source: self, 571 | target: self, 572 | event: { 573 | type: RESTATE_PROMISE_REJECT, 574 | data: errorData, 575 | }, 576 | }); 577 | } 578 | ); 579 | } 580 | ), 581 | }, 582 | }); 583 | }; 584 | 585 | export const xstate = ( 586 | path: string, 587 | logic: TLogic 588 | ) => { 589 | return actorObject(path, logic); 590 | }; 591 | 592 | export const xStateApi = ( 593 | path: string 594 | ): XStateApi => { 595 | return { name: path }; 596 | }; 597 | 598 | type XStateApi = ReturnType< 599 | typeof actorObject 600 | >; 601 | 602 | function createScheduledEventId( 603 | actorRef: SerialisableActorRef, 604 | id: string 605 | ): string { 606 | return `${actorRef.sessionId}.${id}`; 607 | } 608 | -------------------------------------------------------------------------------- /src/promise.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ActorLogic, 3 | ActorRefFrom, 4 | ActorSystem, 5 | ActorSystemInfo, 6 | AnyActorLogic, 7 | AnyActorRef, 8 | AnyInvokeConfig, 9 | AnyStateMachine, 10 | NonReducibleUnknown, 11 | Snapshot, 12 | } from "xstate"; 13 | import { 14 | serialiseActorRef, 15 | type ActorRefEventSender, 16 | type RestateActorSystem, 17 | } from "./lib.js"; 18 | import type { ObjectSharedContext } from "@restatedev/restate-sdk"; 19 | 20 | export type PromiseSnapshot = Snapshot & { 21 | input: TInput | undefined; 22 | sent: boolean; 23 | }; 24 | 25 | const RESTATE_PROMISE_SENT = "restate.promise.sent"; 26 | export const RESTATE_PROMISE_RESOLVE = "restate.promise.resolve"; 27 | export const RESTATE_PROMISE_REJECT = "restate.promise.reject"; 28 | const XSTATE_STOP = "xstate.stop"; 29 | 30 | export type PromiseCreator = ({ 31 | input, 32 | ctx, 33 | }: { 34 | input: TInput; 35 | ctx: ObjectSharedContext; 36 | }) => PromiseLike; 37 | 38 | export type PromiseActorLogic = ActorLogic< 39 | PromiseSnapshot, 40 | { type: string; [k: string]: unknown }, 41 | TInput, // input 42 | ActorSystem 43 | > & { 44 | sentinel: "restate.promise.actor"; 45 | config: PromiseCreator; 46 | }; 47 | 48 | export type PromiseActorRef = ActorRefFrom< 49 | PromiseActorLogic 50 | >; 51 | 52 | export function fromPromise( 53 | promiseCreator: PromiseCreator 54 | ): PromiseActorLogic { 55 | const logic: PromiseActorLogic = { 56 | sentinel: "restate.promise.actor", 57 | config: promiseCreator, 58 | transition: (state, event) => { 59 | if (state.status !== "active") { 60 | return state; 61 | } 62 | 63 | switch (event.type) { 64 | case RESTATE_PROMISE_SENT: { 65 | return { 66 | ...state, 67 | sent: true, 68 | }; 69 | } 70 | case RESTATE_PROMISE_RESOLVE: { 71 | const resolvedValue = (event as unknown as { data: TOutput }).data; 72 | return { 73 | ...state, 74 | status: "done", 75 | output: resolvedValue, 76 | input: undefined, 77 | }; 78 | } 79 | case RESTATE_PROMISE_REJECT: 80 | return { 81 | ...state, 82 | status: "error", 83 | error: (event as unknown as { data: unknown }).data, 84 | input: undefined, 85 | }; 86 | case XSTATE_STOP: 87 | return { 88 | ...state, 89 | status: "stopped", 90 | input: undefined, 91 | }; 92 | default: 93 | return state; 94 | } 95 | }, 96 | start: (state, { self, system }) => { 97 | if (state.status !== "active") { 98 | return; 99 | } 100 | 101 | if (state.sent) { 102 | return; 103 | } 104 | 105 | const rs = system as RestateActorSystem; 106 | 107 | rs.ctx.objectSendClient(rs.api, rs.systemName).invokePromise({ 108 | self: serialiseActorRef(self), 109 | srcs: actorSrc(self), 110 | input: state.input, 111 | }); 112 | 113 | // note that we sent off the promise so we don't do it again 114 | rs._relay(self, self as ActorRefEventSender, { 115 | type: RESTATE_PROMISE_SENT, 116 | }); 117 | }, 118 | getInitialSnapshot: (_, input) => { 119 | return { 120 | status: "active", 121 | output: undefined, 122 | error: undefined, 123 | input, 124 | sent: false, 125 | }; 126 | }, 127 | getPersistedSnapshot: (snapshot) => snapshot, 128 | restoreSnapshot: (snapshot: Snapshot) => 129 | snapshot as PromiseSnapshot, 130 | }; 131 | 132 | return logic; 133 | } 134 | 135 | function actorSrc(actor?: AnyActorRef): string[] { 136 | if (actor === undefined) { 137 | return []; 138 | } 139 | if (typeof actor.src !== "string") { 140 | return []; 141 | } 142 | return [actor.src, ...actorSrc(actor._parent)]; 143 | } 144 | 145 | export function resolveReferencedActor( 146 | machine: AnyStateMachine, 147 | src: string 148 | ): AnyActorLogic | undefined { 149 | const match = src.match(/^xstate\.invoke\.(\d+)\.(.*)/)!; 150 | if (!match) { 151 | return machine.implementations.actors[src] as AnyActorLogic; 152 | } 153 | const [, indexStr, nodeId] = match; 154 | const node = machine.getStateNodeById(nodeId); 155 | const invokeConfig = node.config.invoke!; 156 | return ( 157 | Array.isArray(invokeConfig) 158 | ? (invokeConfig[Number(indexStr)] as AnyInvokeConfig) 159 | : (invokeConfig as AnyInvokeConfig) 160 | )?.src as AnyActorLogic; 161 | } 162 | -------------------------------------------------------------------------------- /src/public_api.ts: -------------------------------------------------------------------------------- 1 | export { xstate } from "./lib.js"; 2 | export { fromPromise } from "./promise.js"; 3 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "baseUrl": ".", 5 | "outDir": "./dist/esm", 6 | "target": "esnext", 7 | "module": "nodenext", 8 | "lib": ["esnext"], 9 | "declaration": true, 10 | "declarationMap": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "allowJs": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "allowSyntheticDefaultImports": true, 18 | "paths": { 19 | "@restatedev/xstate": ["./src/public_api.ts"] 20 | } 21 | }, 22 | "include": ["src/**/*.ts"] 23 | } 24 | --------------------------------------------------------------------------------