├── .gitignore ├── README.md ├── biome.json ├── index.html ├── package.json ├── party ├── env.type.ts ├── hono-counter.do.ts ├── hono-ssr-mpa-react-todolist.do.tsx ├── machine.party.ts ├── main.server.ts └── xstate-payment.do.ts ├── pnpm-lock.yaml ├── server ├── game.machine.context.ts ├── game.machine.serialize.ts ├── game.machine.ts ├── lib │ ├── encode-decode.ts │ ├── murmur-hash2.ts │ ├── override-state-machine-context.ts │ ├── pick.ts │ ├── serialize-snapshot.ts │ └── server-only-event.type.ts └── player.machine.ts ├── src ├── app.tsx ├── components │ ├── player-card.tsx │ └── ui │ │ ├── color-mode.tsx │ │ ├── link-button.tsx │ │ ├── progress-circle.tsx │ │ ├── provider.tsx │ │ ├── toaster.tsx │ │ └── tooltip.tsx ├── create-actor-facade.ts ├── create-actor-party.hooks.tsx ├── create-actor-party.ts ├── env.config.ts ├── game │ ├── game.client.ts │ ├── game.devtools.tsx │ └── game.header.tsx ├── main.client.tsx ├── pages │ ├── error.screen.tsx │ ├── game.screen.tsx │ ├── home.page.tsx │ ├── loading.screen.tsx │ ├── payment.page.tsx │ ├── room.page.tsx │ ├── sandbox.page.tsx │ ├── username.section.tsx │ └── welcome-screen.tsx ├── router.ts └── styles.css ├── tsconfig.json ├── vite.config.ts └── wrangler.jsonc /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | .partykit 170 | .DS_Store 171 | .wrangler 172 | .dev.vars.* 173 | .env* 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | https://www.astahmer.dev/posts/multiplayer-state-machine-with-durable-objects 2 | 3 | --- 4 | 5 | Start the app with `pnpm dev` 6 | 7 | There are multiple examples of using Cloudflare Durable Objects: 8 | - [a very simple counter using hono](./party/hono-counter.do.ts): available at http://localhost:5173/sandbox 9 | - [a simple SSR-only todo list with persistence using hono](./party/hono-ssr-mpa-react-todolist.do.tsx): available at http://localhost:5173/api/todos?name=example 10 | - [a payment state machine with persistence (restarting the actor based on stored snapshot) using hono + xstate](./party/xstate-payment.do.ts): available at http://localhost:5173/payment 11 | - [a realtime multiplayer state machine using partyserver + xstate with public/private context based on playerId/current state](./party/machine.party.ts) available at http://localhost:5173 12 | 13 | ## Relevant links 14 | - https://developers.cloudflare.com/durable-objects/ 15 | - https://github.com/threepointone/partyserver 16 | - https://docs.partykit.io/reference/partysocket-api 17 | - https://stately.ai/docs/states 18 | - https://hono.dev/ 19 | 20 | ## Deployment 21 | 22 | register on Cloudflare, add a `VITE_API_HOST` env variable like `VITE_API_HOST=your-app-name.astahmer.workers.dev` 23 | 24 | put it in a `.env.production` file and run `pnpm run deploy` 25 | 26 | --- 27 | 28 | Initiated from https://github.com/threepointone/partyvite 29 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "files": { 4 | "ignoreUnknown": false, 5 | "ignore": [ 6 | "**/node_modules/**", 7 | "**/dist/**", 8 | "**/migrations/**", 9 | "**/routeTree.gen.ts" 10 | ] 11 | }, 12 | "organizeImports": { 13 | "enabled": true 14 | }, 15 | "formatter": { 16 | "enabled": true 17 | }, 18 | "linter": { 19 | "enabled": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Multiplayer Durable State Machine 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multiplayer-durable-state-machine", 3 | "type": "module", 4 | "scripts": { 5 | "typecheck": "tsc --noEmit", 6 | "dev": "vite dev", 7 | "deploy": "rm -rf dist && vite build && wrangler deploy", 8 | "fmt": "pnpm biome check --fix --unsafe", 9 | "test": "vitest" 10 | }, 11 | "dependencies": { 12 | "@chakra-ui/react": "^3.8.1", 13 | "@emotion/react": "^11.14.0K", 14 | "@fontsource/prompt": "^5.2.5", 15 | "@swan-io/chicane": "^2.1.0", 16 | "@tanstack/react-query": "^5.69.0", 17 | "@xstate/react": "^5.0.2", 18 | "fast-json-patch": "^3.1.1", 19 | "hono": "^4.7.5", 20 | "immer": "^10.1.1", 21 | "nanoid": "^5.1.2", 22 | "next-themes": "^0.4.4", 23 | "partyserver": "^0.0.64", 24 | "partysocket": "^1.0.3", 25 | "random-word-slugs": "^0.1.7", 26 | "react": "^18.3.1", 27 | "react-dom": "^18.3.1", 28 | "react-error-boundary": "^5.0.0", 29 | "react-icons": "^5.5.0", 30 | "xstate": "^5.19.2" 31 | }, 32 | "devDependencies": { 33 | "@biomejs/biome": "1.9.4", 34 | "@cloudflare/vite-plugin": "^0.1.7", 35 | "@cloudflare/workers-types": "^4.20250224.0", 36 | "@standard-schema/spec": "^1.0.0", 37 | "@types/react": "^18.3.18", 38 | "@types/react-dom": "^18.3.5", 39 | "@vitejs/plugin-react": "^4.3.4", 40 | "typescript": "^5.7.3", 41 | "unplugin-jsx-source": "^0.1.4", 42 | "vite": "^6.2.0", 43 | "vite-plugin-inspect": "^11.0.0", 44 | "vite-tsconfig-paths": "^5.1.4", 45 | "vitest": "^3.0.8", 46 | "wrangler": "^3.114.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /party/env.type.ts: -------------------------------------------------------------------------------- 1 | export type EnvBindings = { 2 | Counter: DurableObjectNamespace; 3 | TodoList: DurableObjectNamespace; 4 | PaymentActor: DurableObjectNamespace; 5 | Machine: DurableObjectNamespace; 6 | ASSETS: Fetcher; 7 | }; 8 | -------------------------------------------------------------------------------- /party/hono-counter.do.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import type { EnvBindings } from "./env.type"; 3 | 4 | export class Counter { 5 | static basePath = "/api/counter"; 6 | 7 | state: DurableObjectState; 8 | app = new Hono().basePath(Counter.basePath); 9 | value = 0; 10 | initialPromise: Promise | null; 11 | 12 | constructor(state: DurableObjectState, _env: EnvBindings) { 13 | this.state = state; 14 | this.initialPromise = this.state.blockConcurrencyWhile(async () => { 15 | const stored = await this.state.storage.get("value"); 16 | this.value = stored || 0; 17 | this.initialPromise = null; 18 | }); 19 | 20 | this.app.get("/increment", async (c) => { 21 | const currentValue = ++this.value; 22 | await this.state.storage.put("value", this.value); 23 | return c.json({ current: currentValue }); 24 | }); 25 | 26 | this.app.get("/decrement", async (c) => { 27 | const currentValue = --this.value; 28 | await this.state.storage.put("value", this.value); 29 | return c.json({ current: currentValue }); 30 | }); 31 | 32 | this.app.get("/current", async (c) => { 33 | return c.json({ current: this.value }); 34 | }); 35 | } 36 | 37 | async fetch(request: Request) { 38 | // ensures the in memory state is always up to date 39 | this.initialPromise && (await this.initialPromise); 40 | return this.app.fetch(request); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /party/hono-ssr-mpa-react-todolist.do.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Flex, 4 | HStack, 5 | IconButton, 6 | Input, 7 | Stack, 8 | } from "@chakra-ui/react"; 9 | import { Hono } from "hono"; 10 | import { nanoid } from "nanoid"; 11 | import { renderToStaticMarkup } from "react-dom/server"; 12 | import { LuTrash } from "react-icons/lu"; 13 | import { Provider } from "../src/components/ui/provider"; 14 | import { SandboxLayout } from "../src/pages/sandbox.page"; 15 | import type { EnvBindings } from "./env.type"; 16 | 17 | interface TodoListItem { 18 | id: string; 19 | text: string; 20 | } 21 | 22 | interface SiteData { 23 | title: string; 24 | children?: any; 25 | } 26 | 27 | const Layout = (props: SiteData) => ( 28 | 29 | 30 | {props.title} 31 | 32 | 33 | {props.children} 34 | 35 | ); 36 | 37 | const HonoApp = (props: TodoListFormProps) => ( 38 | 41 | 42 | 43 | 44 | 45 | {props.todoList.map((todo) => ( 46 | 54 | 57 | {todo.text} 58 | 59 |
63 | 64 | 65 | 66 |
67 |
68 | ))} 69 |
70 | 77 |
78 |
79 |
80 |
81 | ); 82 | 83 | interface TodoListFormProps { 84 | durableObjectName: string; 85 | todoList: TodoListItem[]; 86 | // 87 | editingTodoId?: string; 88 | editingTodoContent?: string; 89 | } 90 | 91 | const TodoListForm = (props: TodoListFormProps) => { 92 | return ( 93 |
101 | 102 | 103 | 106 | 107 |
108 | ); 109 | }; 110 | 111 | export class TodoList { 112 | static basePath = "/api/todos"; 113 | 114 | state: DurableObjectState; 115 | app = new Hono().basePath(TodoList.basePath); 116 | list: TodoListItem[] = []; 117 | initialPromise: Promise | null; 118 | 119 | constructor(state: DurableObjectState, _env: EnvBindings) { 120 | this.state = state; 121 | this.initialPromise = this.state.blockConcurrencyWhile(async () => { 122 | const stored = await this.state.storage.get("list"); 123 | this.list = stored ?? []; 124 | this.initialPromise = null; 125 | }); 126 | 127 | const todoListUrl = ({ name, error }: { name: string; error?: string }) => 128 | `${TodoList.basePath}?name=${name}${error ? `&error=${error}` : ""}`; 129 | 130 | this.app.post("/add", async (c) => { 131 | const name = c.req.query("name")!; 132 | 133 | const todo = await c.req.formData(); 134 | const content = todo.get("content"); 135 | 136 | if (!content) { 137 | return c.redirect(todoListUrl({ name, error: `missing content` })); 138 | } 139 | 140 | if (typeof content !== "string") { 141 | return c.redirect(todoListUrl({ name, error: `invalid content` })); 142 | } 143 | 144 | this.list.push({ id: nanoid(), text: content }); 145 | 146 | await this.state.storage.put("list", this.list); 147 | 148 | return c.redirect(todoListUrl({ name })); 149 | }); 150 | 151 | this.app.post("/:todoId/edit", async (c) => { 152 | const name = c.req.query("name")!; 153 | 154 | const todo = await c.req.formData(); 155 | const content = todo.get("content"); 156 | 157 | if (!content) { 158 | return c.redirect(todoListUrl({ name, error: `missing content` })); 159 | } 160 | if (typeof content !== "string") { 161 | return c.redirect(todoListUrl({ name, error: `invalid content` })); 162 | } 163 | 164 | const todoId = c.req.param("todoId"); 165 | const index = this.list.findIndex((todo) => todo.id === todoId); 166 | if (index === -1) 167 | return c.redirect(todoListUrl({ name, error: `not found` })); 168 | 169 | this.list[index]!.text = content; 170 | await this.state.storage.put("list", this.list); 171 | 172 | return c.redirect(todoListUrl({ name })); 173 | }); 174 | 175 | this.app.post("/:todoId/delete", async (c) => { 176 | const name = c.req.query("name")!; 177 | 178 | const todoId = c.req.param("todoId"); 179 | const index = this.list.findIndex((todo) => todo.id === todoId); 180 | if (index === -1) 181 | return c.redirect(todoListUrl({ name, error: `not found` })); 182 | 183 | this.list.splice(index, 1); 184 | await this.state.storage.put("list", this.list); 185 | 186 | return c.redirect(todoListUrl({ name })); 187 | }); 188 | 189 | this.app.get("/", async (c) => { 190 | const { name, todoId, content } = c.req.query(); 191 | 192 | return c.html( 193 | renderToStaticMarkup( 194 | , 200 | ), 201 | ); 202 | }); 203 | } 204 | 205 | async fetch(request: Request) { 206 | // ensures the in memory state is always up to date 207 | this.initialPromise && (await this.initialPromise); 208 | return this.app.fetch(request); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /party/machine.party.ts: -------------------------------------------------------------------------------- 1 | import * as Party from "partyserver"; 2 | import { createActor, type AnyActorRef } from "xstate"; 3 | import murmurHash2 from "../server/lib/murmur-hash2"; 4 | import { nanoid } from "nanoid"; 5 | import { gameMachine } from "../server/game.machine"; 6 | import { serializeGameSnapshot } from "../server/game.machine.serialize"; 7 | import type { EnvBindings } from "./env.type"; 8 | import { compare } from "fast-json-patch"; 9 | import { decode, encode } from "../server/lib/encode-decode"; 10 | 11 | const withDebug = false; 12 | 13 | export default class MachinePartyServer extends Party.Server { 14 | roomId = nanoid(); 15 | lastUpdateMap = new WeakMap< 16 | Party.Connection, 17 | { snapshot: Record; hash: string } 18 | >(); 19 | 20 | actor = createActor(gameMachine, { 21 | input: { 22 | roomId: this.roomId, 23 | }, 24 | inspect: withDebug 25 | ? (inspectionEvent) => { 26 | if (inspectionEvent.type === "@xstate.event") { 27 | const event = inspectionEvent.event; 28 | 29 | // Only listen for events sent to the root actor 30 | if (inspectionEvent.actorRef !== this.actor) { 31 | return; 32 | } 33 | 34 | if (event.type.startsWith("xstate.")) { 35 | console.log("[xstate:inspect]", event.type); 36 | return; 37 | } 38 | 39 | console.log("[xstate:event]", event); 40 | } 41 | } 42 | : undefined, 43 | }); 44 | 45 | constructor(ctx: DurableObjectState, env: EnvBindings) { 46 | super(ctx, env); 47 | console.log("init room:", this.roomId); 48 | this.actor.start(); 49 | this.subscribeToSnapshot(); 50 | } 51 | 52 | subscribeToSnapshot() { 53 | this.actor.subscribe({ 54 | next: (snapshot) => { 55 | for (const ws of this.getConnections()) { 56 | const serialized = serializeGameSnapshot(snapshot, ws.id); 57 | const hash = murmurHash2(JSON.stringify(serialized)); 58 | const previousUpdate = this.lastUpdateMap.get(ws); 59 | 60 | if (previousUpdate?.hash === hash) continue; 61 | 62 | this.lastUpdateMap.set(ws, { snapshot: serialized, hash }); 63 | 64 | if (!previousUpdate) { 65 | ws.send( 66 | encode({ 67 | type: "party.snapshot.update", 68 | snapshot: serialized, 69 | }), 70 | ); 71 | continue; 72 | } 73 | 74 | const operations = compare(previousUpdate.snapshot, serialized); 75 | if (operations.length === 0) continue; 76 | 77 | ws.send( 78 | encode({ 79 | type: "party.snapshot.patch", 80 | operations, 81 | }), 82 | ); 83 | } 84 | }, 85 | error: (err) => { 86 | console.log("actor subscribe error", err); 87 | }, 88 | complete: () => { 89 | console.log("actor subscribe complete"); 90 | }, 91 | }); 92 | } 93 | 94 | onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) { 95 | console.log( 96 | `Connected: 97 | conn.id: ${conn.id} 98 | room: ${this.roomId} 99 | url: ${new URL(ctx.request.url).pathname}`, 100 | ); 101 | } 102 | 103 | onMessage( 104 | sender: Party.Connection, 105 | message: Party.WSMessage, 106 | ): void | Promise { 107 | const decoded = decode>(message); 108 | if (!decoded) { 109 | console.warn("message is not decodable", message); 110 | return; 111 | } 112 | 113 | console.log( 114 | `connection ${sender.id} sent message: ${JSON.stringify(decoded)}`, 115 | ); 116 | 117 | const eventType = decoded.type; 118 | const isEvent = eventType && typeof eventType === "string"; 119 | if (!isEvent) { 120 | console.warn("message is not an event", decoded); 121 | return; 122 | } 123 | 124 | if (eventType.startsWith("party.")) { 125 | const events = { 126 | "party.snapshot.get": { 127 | onMessage: () => { 128 | const snapshot = serializeGameSnapshot( 129 | this.actor.getSnapshot(), 130 | sender.id, 131 | ); 132 | sender.send(encode({ type: "party.snapshot.update", snapshot })); 133 | }, 134 | }, 135 | "party.sendTo": { 136 | onMessage: () => { 137 | const snapshot = this.actor.getSnapshot(); 138 | const childActorRef = ( 139 | snapshot.children as Record 140 | )[decoded["actorId"] as string]; 141 | if (!childActorRef) return; 142 | 143 | childActorRef.send(decoded["event"]); 144 | }, 145 | }, 146 | }; 147 | 148 | const keys = Object.keys(events); 149 | if (!keys.includes(eventType)) { 150 | console.warn("message is not a party event", decoded); 151 | return; 152 | } 153 | 154 | events[eventType as keyof typeof events].onMessage?.(); 155 | } 156 | 157 | this.actor.send({ ...decoded, _userId: sender.id } as never); 158 | } 159 | 160 | onRequest(request: Request): Response | Promise { 161 | console.log("onRequest", request.url); 162 | return new Response("Not Implemented", { status: 501 }); 163 | } 164 | 165 | // Whenever a connection closes (or errors), 166 | // we'll broadcast a message to all other connections to remove the player 167 | onCloseOrError( 168 | connection: Party.Connection, 169 | from: "close" | "error", 170 | error?: Error, 171 | ) { 172 | console.log( 173 | "onCloseOrError", 174 | { from, id: connection.id, error }, 175 | connection.id, 176 | ); 177 | this.actor.send({ type: "Disconnect", _userId: connection.id }); 178 | } 179 | 180 | onClose(connection: Party.Connection): void | Promise { 181 | this.onCloseOrError(connection, "close"); 182 | } 183 | 184 | onError( 185 | connection: Party.Connection, 186 | error: Error, 187 | ): void | Promise { 188 | this.onCloseOrError(connection, "error", error); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /party/main.server.ts: -------------------------------------------------------------------------------- 1 | import { routePartykitRequest } from "partyserver"; 2 | import Machine from "./machine.party"; 3 | import { Counter } from "./hono-counter.do"; 4 | import type { EnvBindings } from "./env.type"; 5 | import { Hono } from "hono"; 6 | import { TodoList } from "./hono-ssr-mpa-react-todolist.do"; 7 | import { PaymentActor } from "./xstate-payment.do"; 8 | 9 | export { Machine, Counter, TodoList, PaymentActor }; 10 | 11 | const app = new Hono<{ Bindings: EnvBindings }>(); 12 | app.use(`${Counter.basePath}/*`, async (ctx) => { 13 | const name = ctx.req.query("name"); 14 | if (!name) { 15 | return ctx.text( 16 | "Missing Durable Object `name` URL query string parameter, for example, ?name=abc", 17 | 400, 18 | ); 19 | } 20 | 21 | const id = ctx.env.Counter.idFromName(name); 22 | const obj = ctx.env.Counter.get(id); 23 | return obj.fetch(ctx.req.raw); 24 | }); 25 | 26 | app.use(`${TodoList.basePath}/*`, async (ctx) => { 27 | const name = ctx.req.query("name"); 28 | if (!name) { 29 | return ctx.text( 30 | "Missing Durable Object `name` URL query string parameter, for example, ?name=abc", 31 | 400, 32 | ); 33 | } 34 | 35 | const id = ctx.env.TodoList.idFromName(name); 36 | const obj = ctx.env.TodoList.get(id); 37 | return obj.fetch(ctx.req.raw); 38 | }); 39 | 40 | app.use(`${PaymentActor.basePath}/*`, async (ctx) => { 41 | const name = ctx.req.query("name"); 42 | if (!name) { 43 | return ctx.text( 44 | "Missing Durable Object `name` URL query string parameter, for example, ?name=abc", 45 | 400, 46 | ); 47 | } 48 | 49 | const id = ctx.env.PaymentActor.idFromName(name); 50 | const obj = ctx.env.PaymentActor.get(id); 51 | return obj.fetch(ctx.req.raw); 52 | }); 53 | 54 | export default { 55 | async fetch(request: Request, env: EnvBindings) { 56 | const url = new URL(request.url); 57 | 58 | if (url.pathname.startsWith("/parties/")) { 59 | return ( 60 | (await routePartykitRequest(request, env)) || 61 | new Response("Not Found", { status: 404 }) 62 | ); 63 | } 64 | 65 | if (url.pathname.startsWith("/api")) { 66 | return app.fetch(request, env); 67 | } 68 | 69 | return env.ASSETS.fetch(request as never); 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /party/xstate-payment.do.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { 3 | type ActorRefFrom, 4 | type EventFrom, 5 | type InputFrom, 6 | type SnapshotFrom, 7 | assertEvent, 8 | assign, 9 | createActor, 10 | fromCallback, 11 | fromPromise, 12 | setup, 13 | } from "xstate"; 14 | import type { EnvBindings } from "./env.type"; 15 | 16 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 17 | 18 | export class PaymentActor implements DurableObject { 19 | static basePath = "/api/payment"; 20 | 21 | state: DurableObjectState; 22 | app = new Hono().basePath(PaymentActor.basePath); 23 | initialPromise: Promise | null; 24 | actor: ActorRefFrom | null = null; 25 | 26 | constructor(state: DurableObjectState, _env: EnvBindings) { 27 | this.state = state; 28 | this.initialPromise = this.state.blockConcurrencyWhile(async () => { 29 | await this.restoreFromSnapshot(); 30 | this.initialPromise = null; 31 | }); 32 | 33 | this.app.post("/init", async (ctx) => { 34 | if (this.actor) { 35 | return ctx.json({ error: "already initialized" }, 400); 36 | } 37 | 38 | const name = ctx.req.query("name")!; 39 | const body = await ctx.req.json<{ 40 | sender: string; 41 | recipient: string; 42 | amount: string; 43 | }>(); 44 | 45 | const input: InputFrom = { 46 | key: name, 47 | senderUserId: body.sender, 48 | recipientUserId: body.recipient, 49 | amount: parseInt(body.amount), 50 | }; 51 | 52 | await this.state.storage.put("input", input); 53 | 54 | this.actor = createActor(paymentMachine, { input }); 55 | this.actor.start(); 56 | this.subscribeToSnapshot(); 57 | 58 | return ctx.json({ paymentId: input.key }); 59 | }); 60 | 61 | this.app.get("/state", async (ctx) => { 62 | if (!this.actor) { 63 | return ctx.json({ error: "not initialized" }, 400); 64 | } 65 | 66 | return ctx.json({ snapshot: this.actor.getSnapshot() }); 67 | }); 68 | 69 | this.app.post("/send", async (ctx) => { 70 | if (!this.actor) { 71 | return ctx.json({ error: "not initialized" }, 400); 72 | } 73 | 74 | const event = await ctx.req.json>(); 75 | 76 | this.actor.send(event); 77 | 78 | return ctx.text("OK"); 79 | }); 80 | } 81 | 82 | private async restoreFromSnapshot() { 83 | if (this.actor) { 84 | throw new Error("actor already initialized"); 85 | } 86 | 87 | const snapshot = 88 | await this.state.storage.get>( 89 | "snapshot", 90 | ); 91 | 92 | if (snapshot) { 93 | const input = 94 | await this.state.storage.get>("input"); 95 | if (!input) { 96 | throw new Error("input not found"); 97 | } 98 | 99 | this.actor = createActor(paymentMachine, { input, snapshot }); 100 | this.actor.start(); 101 | this.subscribeToSnapshot(); 102 | return; 103 | } 104 | } 105 | 106 | private async subscribeToSnapshot() { 107 | if (!this.actor) { 108 | throw new Error("actor not initialized"); 109 | } 110 | 111 | this.actor.subscribe(async (snapshot) => { 112 | await this.state.storage.put( 113 | "snapshot", 114 | this.actor!.getPersistedSnapshot(), 115 | ); 116 | }); 117 | } 118 | 119 | async fetch(request: Request) { 120 | // ensures the in memory state is always up to date 121 | this.initialPromise && (await this.initialPromise); 122 | return this.app.fetch(request); 123 | } 124 | } 125 | 126 | /** @see https://github.com/restatedev/xstate/blob/311331a9a525186cecc50b6f407a4b41743ff4f7/examples/payment/app.ts */ 127 | const paymentMachine = setup({ 128 | types: { 129 | context: {} as { 130 | paymentId: string; 131 | senderUserId: string; 132 | recipientUserId: string; 133 | amount: number; 134 | logs: string[]; 135 | secondsLeftToApprove: number; 136 | }, 137 | input: {} as { 138 | key: string; // the key the state machine was created against 139 | senderUserId: string; 140 | recipientUserId: string; 141 | amount: number; 142 | }, 143 | events: {} as 144 | | { type: "start" } 145 | | { type: "timer.tick"; interval: number } 146 | | { type: "approved" } 147 | | { type: "rejected" }, 148 | }, 149 | actions: { 150 | addToLogs: assign({ 151 | logs: (opt, params: { message: string }) => 152 | opt.context.logs.concat(params.message), 153 | }), 154 | decrementSecondsLeftToApprove: assign({ 155 | secondsLeftToApprove: (opt) => { 156 | const event = opt.event; 157 | assertEvent(event, "timer.tick"); 158 | if (event.type === "timer.tick") { 159 | return opt.context.secondsLeftToApprove - event.interval / 1000; 160 | } 161 | return opt.context.secondsLeftToApprove; 162 | }, 163 | }), 164 | }, 165 | actors: { 166 | updateBalance: fromPromise( 167 | async ({ input }: { input: { userID: string; amount: number } }) => { 168 | console.log( 169 | `Attempting to add ${input.amount} to the balance of ${input.userID}`, 170 | ); 171 | await wait(1000); 172 | if (Math.random() > 0.5) throw new Error("Random error"); 173 | 174 | const res = await fetch("https://httpbin.org/get"); 175 | return res.json(); 176 | }, 177 | ), 178 | startTimer: fromCallback< 179 | { type: "timer.pause" } | { type: "timer.resume" }, 180 | { interval: number } 181 | >((opt) => { 182 | const interval = opt.input.interval; 183 | let paused = false; 184 | let isStopped = false; 185 | let timeoutId: ReturnType; 186 | 187 | const tick = () => { 188 | if (!paused) { 189 | // console.log("timer executing", { from: opt.input.from }); 190 | opt.sendBack({ 191 | type: "timer.tick", 192 | interval: interval, 193 | }); 194 | } 195 | 196 | if (isStopped) return; 197 | timeoutId = setTimeout(tick, interval); 198 | }; 199 | 200 | timeoutId = setTimeout(tick, interval); 201 | 202 | opt.receive((event) => { 203 | if (event.type === "timer.pause") { 204 | paused = true; 205 | } else if (event.type === "timer.resume") { 206 | paused = false; 207 | } 208 | }); 209 | 210 | return () => { 211 | isStopped = true; 212 | clearTimeout(timeoutId); 213 | // console.log("Timer cleared", { from: opt.input.from }); 214 | }; 215 | }), 216 | }, 217 | }).createMachine({ 218 | context: ({ input }) => ({ 219 | senderUserId: input.senderUserId, 220 | recipientUserId: input.recipientUserId, 221 | amount: input.amount, 222 | paymentId: input.key, 223 | secondsLeftToApprove: 10, 224 | logs: [ 225 | `Init payment request (${input.key}) from ${input.senderUserId} to ${input.recipientUserId} for ${input.amount}$`, 226 | ], 227 | }), 228 | id: "Payment", 229 | initial: "Needs confirm", 230 | states: { 231 | "Needs confirm": { 232 | entry: { 233 | type: "addToLogs", 234 | params: (opt) => ({ 235 | message: `Awaiting start (${opt.context.paymentId})`, 236 | }), 237 | }, 238 | on: { 239 | start: { 240 | target: "Awaiting approval", 241 | actions: { 242 | type: "addToLogs", 243 | params: { message: "Payment workflow started" }, 244 | }, 245 | }, 246 | }, 247 | }, 248 | "Awaiting approval": { 249 | entry: { 250 | type: "addToLogs", 251 | params: (opt) => ({ 252 | message: `Requesting approval for ${opt.context.paymentId}`, 253 | }), 254 | }, 255 | after: { 256 | 10_000: { 257 | target: "Awaiting admin approval", 258 | actions: { 259 | type: "addToLogs", 260 | params: { message: "Timed out, needs admin approval" }, 261 | }, 262 | }, 263 | }, 264 | invoke: { 265 | src: "startTimer", 266 | input: { interval: 1000 }, 267 | onDone: { 268 | target: "Awaiting admin approval", 269 | actions: { 270 | type: "addToLogs", 271 | params: { message: "Timed out, needs admin approval" }, 272 | }, 273 | }, 274 | onError: { 275 | target: "Cancelled", 276 | actions: { 277 | type: "addToLogs", 278 | params: { message: "Unexpected error" }, 279 | }, 280 | }, 281 | }, 282 | on: { 283 | "timer.tick": { 284 | actions: "decrementSecondsLeftToApprove", 285 | }, 286 | approved: { 287 | target: "Approved", 288 | actions: { 289 | type: "addToLogs", 290 | params: { message: "User approved payment" }, 291 | }, 292 | }, 293 | rejected: { 294 | target: "Rejected", 295 | actions: { 296 | type: "addToLogs", 297 | params: { message: "User rejected payment" }, 298 | }, 299 | }, 300 | }, 301 | }, 302 | Approved: { 303 | invoke: { 304 | src: "updateBalance", 305 | input: ({ context }) => ({ 306 | userID: context.senderUserId, 307 | amount: context.amount, 308 | }), 309 | onDone: { 310 | target: "Debited", 311 | actions: { 312 | type: "addToLogs", 313 | params: { message: "Going to debit" }, 314 | }, 315 | }, 316 | onError: { 317 | target: "Cancelled", 318 | actions: { 319 | type: "addToLogs", 320 | params: { message: "Approve failed" }, 321 | }, 322 | }, 323 | }, 324 | }, 325 | "Awaiting admin approval": { 326 | entry: { 327 | type: "addToLogs", 328 | params: (opt) => ({ 329 | message: `Sending email to ${opt.context.senderUserId}`, 330 | }), 331 | }, 332 | on: { 333 | approved: { 334 | target: "Approved", 335 | actions: { 336 | type: "addToLogs", 337 | params: { message: "Admin approved payment" }, 338 | }, 339 | }, 340 | rejected: { 341 | target: "Rejected", 342 | actions: { 343 | type: "addToLogs", 344 | params: { message: "Admin rejected payment" }, 345 | }, 346 | }, 347 | }, 348 | }, 349 | Rejected: { 350 | tags: ["final"], 351 | entry: { 352 | type: "addToLogs", 353 | params: (opt) => ({ 354 | message: `Payment rejected (${opt.context.paymentId})`, 355 | }), 356 | }, 357 | }, 358 | Cancelled: { 359 | tags: ["final"], 360 | entry: { 361 | type: "addToLogs", 362 | params: (opt) => ({ 363 | message: `Payment cancelled (${opt.context.paymentId})`, 364 | }), 365 | }, 366 | }, 367 | Debited: { 368 | invoke: { 369 | src: "updateBalance", 370 | input: ({ context }) => ({ 371 | userID: context.recipientUserId, 372 | amount: context.amount, 373 | }), 374 | onDone: { 375 | target: "Succeeded", 376 | actions: { 377 | type: "addToLogs", 378 | params: { message: "Debit succeeded" }, 379 | }, 380 | }, 381 | onError: { 382 | target: "Refunding", 383 | actions: { 384 | type: "addToLogs", 385 | params: { message: "Debit failed" }, 386 | }, 387 | }, 388 | }, 389 | }, 390 | Succeeded: { 391 | tags: ["final"], 392 | entry: { 393 | type: "addToLogs", 394 | params: (opt) => ({ 395 | message: `Payment succeeded (${opt.context.paymentId})`, 396 | }), 397 | }, 398 | }, 399 | Refunding: { 400 | invoke: { 401 | src: "updateBalance", 402 | input: ({ context }) => ({ 403 | userID: context.senderUserId, 404 | amount: context.amount, 405 | }), 406 | onDone: { 407 | target: "Cancelled", 408 | actions: { 409 | type: "addToLogs", 410 | params: { message: "Refund succeeded" }, 411 | }, 412 | }, 413 | }, 414 | }, 415 | }, 416 | }); 417 | 418 | export type PaymentActorType = typeof paymentMachine; 419 | -------------------------------------------------------------------------------- /server/game.machine.context.ts: -------------------------------------------------------------------------------- 1 | import type { ActorRefFrom } from "xstate"; 2 | import type { playerMachine } from "./player.machine"; 3 | 4 | export interface GameInitialInput { 5 | roomId: string; 6 | } 7 | 8 | export interface Player { 9 | id: string; 10 | name: string; 11 | state: "idle" | "ready"; 12 | isConnected: boolean; 13 | } 14 | 15 | export interface GameContext { 16 | roomId: string; 17 | actorList: Array>; 18 | } 19 | 20 | export const getInitialContext = ({ 21 | input, 22 | }: { input: GameInitialInput }): GameContext => { 23 | return { 24 | roomId: input.roomId, 25 | actorList: [], 26 | }; 27 | }; 28 | 29 | export const getPlayerId = (_userId: string) => `player-${_userId}`; 30 | 31 | export const gameDurationsInMs = { 32 | timerUpdateInterval: 1_000, 33 | startingDuration: 5_000, 34 | preparingDuration: 5_000, 35 | }; 36 | -------------------------------------------------------------------------------- /server/game.machine.serialize.ts: -------------------------------------------------------------------------------- 1 | import type { SnapshotFrom, ActorRefFrom } from "xstate"; 2 | import type { gameMachine } from "./game.machine"; 3 | import { 4 | getPlayerId, 5 | type GameContext, 6 | type Player, 7 | } from "./game.machine.context"; 8 | import type { OverrideStateMachineContext } from "./lib/override-state-machine-context"; 9 | import type { playerMachine } from "./player.machine"; 10 | import { pick } from "./lib/pick"; 11 | import { serializeSnapshot } from "./lib/serialize-snapshot"; 12 | 13 | type GameMachineType = typeof gameMachine; 14 | export interface GamePublicContext 15 | extends Pick { 16 | // TODO custom computed field 17 | // TODO redacted stuff 18 | } 19 | 20 | export type GameMachineClientSide = OverrideStateMachineContext< 21 | GameMachineType, 22 | GamePublicContext 23 | >; 24 | 25 | export const serializeGameSnapshot = ( 26 | snap: SnapshotFrom, 27 | _userId: string, 28 | ) => { 29 | const context = snap.context; 30 | const serialized = serializeSnapshot(snap); 31 | 32 | return { 33 | ...serialized, 34 | children: Object.fromEntries( 35 | Object.entries(serialized.children) 36 | .filter(([key, child]) => key.startsWith(getPlayerId("")) && child) 37 | .map(([key, child]) => { 38 | const playerActor = child as unknown as ActorRefFrom< 39 | typeof playerMachine 40 | >; 41 | const playerSnap = playerActor.getSnapshot(); 42 | 43 | return [ 44 | key, 45 | { 46 | ...pick(playerSnap, ["value", "status"]), 47 | context: { 48 | ...pick(playerSnap.context, [ 49 | "id", 50 | "name", 51 | "state", 52 | "isConnected", 53 | ]), 54 | } satisfies Player, 55 | }, 56 | ]; 57 | }), 58 | ), 59 | context: { 60 | ...(snap.matches("done") 61 | ? context 62 | : pick(context, ["roomId", "actorList"])), 63 | } satisfies GamePublicContext, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /server/game.machine.ts: -------------------------------------------------------------------------------- 1 | import { assertEvent, assign, enqueueActions, not, setup } from "xstate"; 2 | import { 3 | type GameContext, 4 | type GameInitialInput, 5 | type Player, 6 | getInitialContext, 7 | getPlayerId, 8 | } from "./game.machine.context"; 9 | import type { ServerOnlyEventInput } from "./lib/server-only-event.type"; 10 | import { playerMachine } from "./player.machine"; 11 | 12 | type GameEvent = 13 | | { type: "Connect"; player: Pick } 14 | | { type: "Disconnect" } 15 | | { type: "Ready" } 16 | | { type: "CancelReady" }; 17 | type GameServerEvent = ServerOnlyEventInput & GameEvent; 18 | 19 | const findCurrentPlayer = (context: GameContext, event: GameServerEvent) => 20 | context.actorList.find((actor) => actor.id === getPlayerId(event._userId)); 21 | 22 | export const gameMachine = setup({ 23 | types: {} as { 24 | context: GameContext; 25 | events: GameServerEvent; 26 | input: GameInitialInput; 27 | tags: "paused"; 28 | }, 29 | actions: { 30 | spawnPlayerActor: assign({ 31 | actorList: (opt) => { 32 | assertEvent(opt.event, "Connect"); 33 | 34 | const newPlayer = opt.spawn(playerMachine, { 35 | id: getPlayerId(opt.event._userId) as never, 36 | syncSnapshot: true, 37 | input: { 38 | id: opt.event._userId, 39 | name: opt.event.player.name, 40 | }, 41 | }); 42 | return opt.context.actorList.concat(newPlayer); 43 | }, 44 | }), 45 | reconnect: enqueueActions(({ enqueue, context, event }) => { 46 | const player = findCurrentPlayer(context, event); 47 | if (!player) return; 48 | 49 | enqueue.sendTo(player, { type: "Reconnect" }); 50 | }), 51 | disconnect: enqueueActions(({ enqueue, context, event }) => { 52 | const player = findCurrentPlayer(context, event); 53 | if (!player) return; 54 | 55 | enqueue.sendTo(player, { type: "Disconnect" }); 56 | }), 57 | setPlayerReady: enqueueActions(({ enqueue, context, event }) => { 58 | const player = findCurrentPlayer(context, event); 59 | if (!player) return; 60 | 61 | enqueue.sendTo(player, { type: "SetState", state: "ready" }); 62 | }), 63 | setPlayerIdle: enqueueActions(({ enqueue, context, event }) => { 64 | const player = findCurrentPlayer(context, event); 65 | if (!player) return; 66 | 67 | enqueue.sendTo(player, { type: "SetState", state: "idle" }); 68 | }), 69 | }, 70 | guards: { 71 | wasNeverConnected: (opt) => { 72 | const player = findCurrentPlayer(opt.context, opt.event); 73 | return !player; 74 | }, 75 | isConnected: (opt) => { 76 | const player = findCurrentPlayer(opt.context, opt.event); 77 | if (!player) return false; 78 | 79 | return player.getSnapshot().context.isConnected; 80 | }, 81 | isEveryoneReady: ({ context }) => { 82 | return ( 83 | context.actorList.length >= 1 && 84 | context.actorList.every((ref) => { 85 | const player = ref.getSnapshot().context; 86 | // Ignore players that are not connected 87 | return player.isConnected ? player.state === "ready" : true; 88 | }) 89 | ); 90 | }, 91 | }, 92 | }).createMachine({ 93 | id: "game", 94 | initial: "idle", 95 | context: ({ input }: { input: GameInitialInput }) => { 96 | return getInitialContext({ input }); 97 | }, 98 | states: { 99 | idle: { 100 | always: { target: "playing", guard: "isEveryoneReady" }, 101 | }, 102 | playing: { 103 | always: { 104 | target: "idle", 105 | guard: not("isEveryoneReady"), 106 | }, 107 | }, 108 | done: {}, 109 | }, 110 | on: { 111 | Connect: [ 112 | { 113 | guard: "wasNeverConnected", 114 | actions: ["spawnPlayerActor"], 115 | }, 116 | { 117 | guard: not("isConnected"), 118 | actions: "reconnect", 119 | }, 120 | ], 121 | Disconnect: { guard: "isConnected", actions: "disconnect" }, 122 | // 123 | Ready: { actions: "setPlayerReady" }, 124 | CancelReady: { actions: "setPlayerIdle" }, 125 | }, 126 | }); 127 | -------------------------------------------------------------------------------- /server/lib/encode-decode.ts: -------------------------------------------------------------------------------- 1 | type WSMessage = ArrayBuffer | ArrayBufferView | string; 2 | 3 | const encoder = new TextEncoder(); 4 | const decoder = new TextDecoder(); 5 | 6 | export const encode = (payload: Payload) => 7 | encoder.encode(JSON.stringify(payload)); 8 | 9 | export const decode = ( 10 | payload: WSMessage, 11 | ): Payload | undefined => { 12 | try { 13 | const dataAsString = 14 | typeof payload === "string" ? payload : decoder.decode(payload); 15 | return JSON.parse(dataAsString); 16 | } catch (err) { 17 | return undefined; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /server/lib/murmur-hash2.ts: -------------------------------------------------------------------------------- 1 | // Taken from https://github.com/facebook/stylex/blob/f1e494d3f20cc72d83db2929af371437b322c09d/packages/shared/src/hash.js 2 | // @ts-nocheck 3 | 4 | /** 5 | * JS Implementation of MurmurHash2 6 | * 7 | * @author Gary Court 8 | * @see http://github.com/garycourt/murmurhash-js 9 | * @author Austin Appleby 10 | * @see http://sites.google.com/site/murmurhash/ 11 | * 12 | * @param {string} str ASCII only 13 | * @param {number} seed Positive integer only 14 | * @return {number} 32-bit positive integer hash 15 | */ 16 | function murmurhash2_32_gc(str: string, seed = 0) { 17 | let l = str.length; 18 | let h = seed ^ l; 19 | let i = 0; 20 | let k; 21 | 22 | while (l >= 4) { 23 | k = 24 | (str.charCodeAt(i) & 0xff) | 25 | ((str.charCodeAt(++i) & 0xff) << 8) | 26 | ((str.charCodeAt(++i) & 0xff) << 16) | 27 | ((str.charCodeAt(++i) & 0xff) << 24); 28 | 29 | k = (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16); 30 | k ^= k >>> 24; 31 | k = (k & 0xffff) * 0x5bd1e995 + ((((k >>> 16) * 0x5bd1e995) & 0xffff) << 16); 32 | 33 | h = ((h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16)) ^ k; 34 | 35 | l -= 4; 36 | ++i; 37 | } 38 | 39 | switch (l) { 40 | case 3: 41 | h ^= (str.charCodeAt(i + 2) & 0xff) << 16; 42 | case 2: 43 | h ^= (str.charCodeAt(i + 1) & 0xff) << 8; 44 | case 1: 45 | h ^= str.charCodeAt(i) & 0xff; 46 | h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16); 47 | } 48 | 49 | h ^= h >>> 13; 50 | h = (h & 0xffff) * 0x5bd1e995 + ((((h >>> 16) * 0x5bd1e995) & 0xffff) << 16); 51 | h ^= h >>> 15; 52 | 53 | return h >>> 0; 54 | } 55 | 56 | const hash = (str: string): string => murmurhash2_32_gc(str, 1).toString(36); 57 | 58 | export default hash as (str: string) => string; 59 | -------------------------------------------------------------------------------- /server/lib/override-state-machine-context.ts: -------------------------------------------------------------------------------- 1 | import { type MachineContext, type StateMachine } from "xstate"; 2 | 3 | export type OverrideStateMachineContext< 4 | TMachine extends StateMachine< 5 | any, 6 | any, 7 | any, 8 | any, 9 | any, 10 | any, 11 | any, 12 | any, 13 | any, 14 | any, 15 | any, 16 | any, 17 | any, 18 | any 19 | >, 20 | TNewContext extends MachineContext, 21 | > = TMachine extends StateMachine< 22 | infer _TOldContext, 23 | infer TEvent, 24 | infer TChildren, 25 | infer TActor, 26 | infer TAction, 27 | infer TGuard, 28 | infer TDelay, 29 | infer TStateValue, 30 | infer TTag, 31 | infer TInput, 32 | infer TOutput, 33 | infer TEmitted, 34 | infer TMeta, 35 | infer TConfig 36 | > 37 | ? StateMachine< 38 | TNewContext, 39 | TEvent, 40 | TChildren, 41 | TActor, 42 | TAction, 43 | TGuard, 44 | TDelay, 45 | TStateValue, 46 | TTag, 47 | TInput, 48 | TOutput, 49 | TEmitted, 50 | TMeta, 51 | TConfig 52 | > 53 | : never; 54 | -------------------------------------------------------------------------------- /server/lib/pick.ts: -------------------------------------------------------------------------------- 1 | /** Pick given properties in object */ 2 | export function pick( 3 | obj: T, 4 | paths: K[], 5 | ): Pick { 6 | const result = {} as Pick; 7 | 8 | Object.keys(obj).forEach((key) => { 9 | if (!paths.includes(key as K)) return; 10 | // @ts-expect-error 11 | result[key] = obj[key]; 12 | }); 13 | 14 | return result as Pick; 15 | } 16 | -------------------------------------------------------------------------------- /server/lib/serialize-snapshot.ts: -------------------------------------------------------------------------------- 1 | import type { AnyMachineSnapshot } from "xstate"; 2 | import { pick } from "./pick"; 3 | 4 | export const serializeSnapshot = (snap: AnyMachineSnapshot) => { 5 | const context = snap.context; 6 | 7 | return { 8 | ...pick(snap, ["value", "matches", "status", "children"]), 9 | context: pick(context, ["roomId", "actorList", "currentVotes", "timers"]), 10 | tags: Array.from(snap.tags), 11 | error: (snap.error && snap.error instanceof Error 12 | ? { 13 | message: snap.error.message, 14 | stack: snap.error.stack, 15 | cause: (snap.error as any).cause, 16 | } 17 | : undefined) as undefined, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /server/lib/server-only-event.type.ts: -------------------------------------------------------------------------------- 1 | export interface ServerOnlyEventInput { 2 | _userId: string; 3 | } 4 | -------------------------------------------------------------------------------- /server/player.machine.ts: -------------------------------------------------------------------------------- 1 | import { assertEvent, assign, setup } from "xstate"; 2 | import type { Player } from "./game.machine.context"; 3 | 4 | type PlayerEvent = 5 | | { type: "SetName"; name: string } 6 | | { type: "SetState"; state: "idle" | "ready" } 7 | | { type: "Reconnect" } 8 | | { type: "Disconnect" }; 9 | 10 | interface PlayerInput { 11 | id: string; 12 | name: string; 13 | } 14 | 15 | const createPlayer = (opts: { id: string; name: string }): Player => ({ 16 | id: opts.id, 17 | name: opts.name, 18 | state: "idle", 19 | isConnected: true, 20 | }); 21 | 22 | export const playerMachine = setup({ 23 | types: {} as { 24 | context: Player; 25 | events: PlayerEvent; 26 | input: PlayerInput; 27 | }, 28 | actions: { 29 | reconnect: assign(({ context }) => { 30 | return { ...context, isConnected: true }; 31 | }), 32 | disconnect: assign(({ context }) => { 33 | return { ...context, isConnected: false, state: "idle" as const }; 34 | }), 35 | setName: assign(({ context, event }) => { 36 | assertEvent(event, "SetName"); 37 | return { ...context, name: event.name }; 38 | }), 39 | setState: assign(({ context, event }) => { 40 | assertEvent(event, "SetState"); 41 | return { ...context, state: event.state }; 42 | }), 43 | }, 44 | }).createMachine({ 45 | id: "player", 46 | context: ({ input }) => createPlayer(input), 47 | initial: "idle", 48 | states: { 49 | idle: { 50 | on: { 51 | SetName: { actions: "setName" }, 52 | SetState: { actions: "setState" }, 53 | Reconnect: { actions: "reconnect" }, 54 | Disconnect: { actions: "disconnect" }, 55 | }, 56 | }, 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Stack } from "@chakra-ui/react"; 2 | import { HomePage } from "./pages/home.page"; 3 | import { RoomPage } from "./pages/room.page"; 4 | import { Router } from "./router"; 5 | import { Provider } from "./components/ui/provider"; 6 | import { LinkButton } from "./components/ui/link-button"; 7 | import { SandboxPage } from "./pages/sandbox.page"; 8 | import { PaymentPage } from "./pages/payment.page"; 9 | 10 | export function App() { 11 | const route = Router.useRoute(["Home", "Room", "Sandbox", "Payment"]); 12 | const routes = { 13 | Home: () => , 14 | Sandbox: () => , 15 | Payment: () => , 16 | Room: ({ roomId }: { roomId: string }) => , 17 | NotFound: () => ( 18 |
19 | 20 | 404 21 | Back to home 22 | 23 |
24 | ), 25 | }; 26 | 27 | return ( 28 | 29 | {route 30 | ? routes[route.name]({ 31 | roomId: route.name === "Room" ? route.params.roomId : "", 32 | }) 33 | : routes.NotFound()} 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/player-card.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Box, 4 | Button, 5 | Editable, 6 | Flex, 7 | HStack, 8 | IconButton, 9 | Stack, 10 | } from "@chakra-ui/react"; 11 | import type { ReactNode } from "react"; 12 | import { GiPerspectiveDiceSixFacesTwo } from "react-icons/gi"; 13 | import { LuCheck, LuPencilLine, LuX } from "react-icons/lu"; 14 | import { type Player, getPlayerId } from "../../server/game.machine.context"; 15 | import { GameClient } from "../game/game.client"; 16 | import { generateRandomName } from "../pages/username.section"; 17 | import { Tooltip } from "./ui/tooltip"; 18 | 19 | export const PlayerCard = (props: { player: Player }) => { 20 | const { player } = props; 21 | 22 | const actor = GameClient.useContext(); 23 | const isCurrent = player.id === actor._userId; 24 | 25 | return ( 26 | 27 | {player.isConnected ? null : ( 28 | 29 | 30 | Left the room 31 | 32 | 33 | )} 34 | 35 | 39 | 40 | 41 | 42 | {isCurrent && actor.matches("idle") ? ( 43 | 44 | ) : ( 45 | {player.name} 46 | )} 47 | 48 | 49 | {player.state === "ready" ? "Ready" : "Not Ready"} 50 | 51 | 52 | 53 | 54 | {isCurrent ? : null} 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | const PlayerEditableName = (props: { player: Player }) => { 62 | const { player } = props; 63 | 64 | const actor = GameClient.useContext(); 65 | const context = actor.context; 66 | 67 | const currentPlayerActor = context.actorList.find( 68 | (child) => child.id === getPlayerId(actor._userId), 69 | )!; 70 | 71 | return ( 72 | <> 73 | 74 | { 76 | const randomName = generateRandomName(); 77 | actor.sendTo(currentPlayerActor, { 78 | type: "SetName", 79 | name: randomName, 80 | }); 81 | localStorage.setItem("username", randomName); 82 | }} 83 | variant="ghost" 84 | size="xs" 85 | padding="1" 86 | height="100%" 87 | _hover={{ 88 | transform: "scale(1.1) rotate(180deg)", 89 | }} 90 | > 91 | 94 | 95 | 96 | { 99 | if (!details.value.trim()) return; 100 | 101 | actor.sendTo(currentPlayerActor, { 102 | type: "SetName", 103 | name: details.value, 104 | }); 105 | localStorage.setItem("username", details.value); 106 | }} 107 | onValueRevert={(details) => { 108 | actor.sendTo(currentPlayerActor, { 109 | type: "SetName", 110 | name: details.value, 111 | }); 112 | localStorage.setItem("username", details.value); 113 | }} 114 | > 115 | 116 | 117 | 118 | 119 | 120 | {(ctx) => 121 | !ctx.editing && {player.name} 122 | } 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | ); 143 | }; 144 | 145 | const PlayerCardActions = (props: { player: Player; children?: ReactNode }) => { 146 | const { player, children } = props; 147 | const actor = GameClient.useContext(); 148 | 149 | return ( 150 | 151 | {children} 152 | {player.state === "idle" ? ( 153 | 160 | ) : player.state === "ready" ? ( 161 | 170 | ) : null} 171 | 172 | ); 173 | }; 174 | -------------------------------------------------------------------------------- /src/components/ui/color-mode.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import type { IconButtonProps, SpanProps } from "@chakra-ui/react" 4 | import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react" 5 | import { ThemeProvider, useTheme } from "next-themes" 6 | import type { ThemeProviderProps } from "next-themes" 7 | import * as React from "react" 8 | import { LuMoon, LuSun } from "react-icons/lu" 9 | 10 | export interface ColorModeProviderProps extends ThemeProviderProps {} 11 | 12 | export function ColorModeProvider(props: ColorModeProviderProps) { 13 | return ( 14 | 15 | ) 16 | } 17 | 18 | export type ColorMode = "light" | "dark" 19 | 20 | export interface UseColorModeReturn { 21 | colorMode: ColorMode 22 | setColorMode: (colorMode: ColorMode) => void 23 | toggleColorMode: () => void 24 | } 25 | 26 | export function useColorMode(): UseColorModeReturn { 27 | const { resolvedTheme, setTheme } = useTheme() 28 | const toggleColorMode = () => { 29 | setTheme(resolvedTheme === "dark" ? "light" : "dark") 30 | } 31 | return { 32 | colorMode: resolvedTheme as ColorMode, 33 | setColorMode: setTheme, 34 | toggleColorMode, 35 | } 36 | } 37 | 38 | export function useColorModeValue(light: T, dark: T) { 39 | const { colorMode } = useColorMode() 40 | return colorMode === "dark" ? dark : light 41 | } 42 | 43 | export function ColorModeIcon() { 44 | const { colorMode } = useColorMode() 45 | return colorMode === "dark" ? : 46 | } 47 | 48 | interface ColorModeButtonProps extends Omit {} 49 | 50 | export const ColorModeButton = React.forwardRef< 51 | HTMLButtonElement, 52 | ColorModeButtonProps 53 | >(function ColorModeButton(props, ref) { 54 | const { toggleColorMode } = useColorMode() 55 | return ( 56 | }> 57 | 71 | 72 | 73 | 74 | ) 75 | }) 76 | 77 | export const LightMode = React.forwardRef( 78 | function LightMode(props, ref) { 79 | return ( 80 | 89 | ) 90 | }, 91 | ) 92 | 93 | export const DarkMode = React.forwardRef( 94 | function DarkMode(props, ref) { 95 | return ( 96 | 105 | ) 106 | }, 107 | ) 108 | -------------------------------------------------------------------------------- /src/components/ui/link-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react"; 4 | import { createRecipeContext } from "@chakra-ui/react"; 5 | import { Link } from "@swan-io/chicane"; 6 | import type { ComponentProps } from "react"; 7 | 8 | interface LinkProps extends ComponentProps {} 9 | 10 | export interface LinkButtonProps 11 | extends HTMLChakraProps> {} 12 | 13 | const { withContext } = createRecipeContext({ key: "button" }); 14 | 15 | // Replace "a" with your framework's link component 16 | export const LinkButton = withContext(Link); 17 | -------------------------------------------------------------------------------- /src/components/ui/progress-circle.tsx: -------------------------------------------------------------------------------- 1 | import type { SystemStyleObject } from "@chakra-ui/react" 2 | import { 3 | AbsoluteCenter, 4 | ProgressCircle as ChakraProgressCircle, 5 | } from "@chakra-ui/react" 6 | import * as React from "react" 7 | 8 | interface ProgressCircleRingProps extends ChakraProgressCircle.CircleProps { 9 | trackColor?: SystemStyleObject["stroke"] 10 | cap?: SystemStyleObject["strokeLinecap"] 11 | } 12 | 13 | export const ProgressCircleRing = React.forwardRef< 14 | SVGSVGElement, 15 | ProgressCircleRingProps 16 | >(function ProgressCircleRing(props, ref) { 17 | const { trackColor, cap, color, ...rest } = props 18 | return ( 19 | 20 | 21 | 22 | 23 | ) 24 | }) 25 | 26 | export const ProgressCircleValueText = React.forwardRef< 27 | HTMLDivElement, 28 | ChakraProgressCircle.ValueTextProps 29 | >(function ProgressCircleValueText(props, ref) { 30 | return ( 31 | 32 | 33 | 34 | ) 35 | }) 36 | 37 | export const ProgressCircleRoot = ChakraProgressCircle.Root 38 | -------------------------------------------------------------------------------- /src/components/ui/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChakraProvider, defaultSystem } from "@chakra-ui/react"; 4 | import { type ColorModeProviderProps } from "./color-mode"; 5 | 6 | export function Provider(props: ColorModeProviderProps) { 7 | return ( 8 | {props.children} 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | Toaster as ChakraToaster, 5 | Portal, 6 | Spinner, 7 | Stack, 8 | Toast, 9 | createToaster, 10 | } from "@chakra-ui/react" 11 | 12 | export const toaster = createToaster({ 13 | placement: "bottom-end", 14 | pauseOnPageIdle: true, 15 | }) 16 | 17 | export const Toaster = () => { 18 | return ( 19 | 20 | 21 | {(toast) => ( 22 | 23 | {toast.type === "loading" ? ( 24 | 25 | ) : ( 26 | 27 | )} 28 | 29 | {toast.title && {toast.title}} 30 | {toast.description && ( 31 | {toast.description} 32 | )} 33 | 34 | {toast.action && ( 35 | {toast.action.label} 36 | )} 37 | {toast.meta?.closable && } 38 | 39 | )} 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"; 2 | import * as React from "react"; 3 | 4 | export interface TooltipProps extends ChakraTooltip.RootProps { 5 | showArrow?: boolean; 6 | portalled?: boolean; 7 | portalRef?: React.RefObject; 8 | content: React.ReactNode; 9 | contentProps?: ChakraTooltip.ContentProps; 10 | disabled?: boolean; 11 | } 12 | 13 | export const Tooltip = React.forwardRef( 14 | function Tooltip(props, ref) { 15 | const { 16 | showArrow, 17 | children, 18 | disabled, 19 | portalled = true, 20 | content, 21 | contentProps, 22 | portalRef, 23 | ...rest 24 | } = props; 25 | 26 | if (disabled) return children; 27 | 28 | return ( 29 | 30 | {children} 31 | 32 | 33 | 34 | {showArrow && ( 35 | 36 | 37 | 38 | )} 39 | {content} 40 | 41 | 42 | 43 | 44 | ); 45 | }, 46 | ); 47 | -------------------------------------------------------------------------------- /src/create-actor-facade.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AnyActorRef, 3 | type AnyEventObject, 4 | type AnyStateMachine, 5 | type EventFrom, 6 | type EventFromLogic, 7 | type StateFrom, 8 | type StateValueFrom, 9 | type TagsFrom, 10 | matchesState, 11 | } from "xstate"; 12 | import type { ServerOnlyEventInput } from "../server/lib/server-only-event.type"; 13 | 14 | export const createActorFacade = < 15 | TLogic extends AnyStateMachine, 16 | TState extends StateFrom = StateFrom, 17 | >( 18 | snapshot: TState, 19 | client: ActorFacadeClient, 20 | ): ActorFacade => ({ 21 | ...snapshot, 22 | _userId: client.id, 23 | snapshot, 24 | hasTag: (tag) => snapshot.tags.has(tag), 25 | send: (event) => { 26 | client.send(event); 27 | }, 28 | sendTo: (selectorOrChild, event) => { 29 | if (snapshot.status !== "active") { 30 | console.warn("sendTo: snapshot not active", snapshot.status); 31 | return; 32 | } 33 | 34 | const actorId = 35 | typeof selectorOrChild === "function" 36 | ? selectorOrChild(snapshot)?.id 37 | : selectorOrChild.id; 38 | if (!actorId) { 39 | console.warn("sendTo: no actorId found", selectorOrChild); 40 | return; 41 | } 42 | 43 | client.send({ 44 | type: "party.sendTo", 45 | actorId: actorId, 46 | event, 47 | }); 48 | }, 49 | matches: (state) => matchesState(state, snapshot?.value ?? {}), 50 | }); 51 | 52 | interface ActorFacadeClient { 53 | id: string; 54 | send: (event: AnyEventObject) => void; 55 | } 56 | 57 | interface SendTo< 58 | TLogic extends AnyStateMachine, 59 | TState extends StateFrom = StateFrom, 60 | > { 61 | >( 62 | selector: (state: Pick) => TChild, 63 | event: TEvent extends ServerOnlyEventInput 64 | ? Omit 65 | : TEvent, 66 | ): void; 67 | >( 68 | child: TChild, 69 | event: TEvent extends ServerOnlyEventInput 70 | ? Omit 71 | : TEvent, 72 | ): void; 73 | } 74 | 75 | export interface ActorFacade< 76 | TLogic extends AnyStateMachine, 77 | TState extends StateFrom = StateFrom, 78 | > { 79 | _userId: string; 80 | snapshot: Pick< 81 | TState, 82 | "context" | "value" | "matches" | "children" | "error" | "status" | "tags" 83 | >; 84 | send: >( 85 | event: TEvent extends ServerOnlyEventInput 86 | ? Omit 87 | : TEvent, 88 | ) => void; 89 | sendTo: SendTo; 90 | matches: (state: StateValueFrom) => boolean; 91 | hasTag: (tag: TagsFrom) => boolean; 92 | context: TState["context"]; 93 | value: TState["value"]; 94 | status: TState["status"]; 95 | error: TState["error"]; 96 | } 97 | -------------------------------------------------------------------------------- /src/create-actor-party.hooks.tsx: -------------------------------------------------------------------------------- 1 | import type { PartySocket, PartySocketOptions } from "partysocket"; 2 | import { 3 | type PropsWithChildren, 4 | createContext, 5 | useContext, 6 | useEffect, 7 | useMemo, 8 | useRef, 9 | useState, 10 | } from "react"; 11 | import { 12 | type AnyStateMachine, 13 | type ContextFrom, 14 | type StateFrom, 15 | type StateValueFrom, 16 | } from "xstate"; 17 | import { applyPatch, type Operation } from "fast-json-patch"; 18 | import { type ActorParty, createActorParty } from "./create-actor-party"; 19 | import { produce } from "immer"; 20 | import { enableMapSet } from "immer"; 21 | import { decode } from "../server/lib/encode-decode"; 22 | 23 | enableMapSet(); 24 | 25 | export const createActorPartyHooks = ( 26 | partySocket: PartySocket, 27 | options?: { 28 | reviver?: (snapshot: StateFrom) => StateFrom; 29 | }, 30 | ) => { 31 | const useActor = (props?: UseActorPartyProps) => { 32 | const [snapshot, setSnapshot] = useState>({ 33 | context: props?.initialContext ?? {}, 34 | status: "stopped", 35 | value: {}, 36 | } as never); 37 | 38 | const actor = useMemo( 39 | () => createActorParty(snapshot, partySocket), 40 | [snapshot, partySocket], 41 | ); 42 | 43 | // Initial connection / reconnect with new options (different roomId) 44 | useEffect(() => { 45 | if (!props?.partySocketOptions) return; 46 | 47 | partySocket.updateProperties(props?.partySocketOptions); 48 | partySocket.reconnect(); 49 | }, [props?.partySocketOptions]); 50 | 51 | // Get initial snapshot on connection open 52 | useEffect(() => { 53 | const handler = () => { 54 | partySocket.send(JSON.stringify({ type: "party.snapshot.get" })); 55 | }; 56 | partySocket.addEventListener("open", handler); 57 | 58 | return () => { 59 | partySocket.removeEventListener("open", handler); 60 | }; 61 | }, []); 62 | 63 | const isFirstActiveSnapshot = useRef(true); 64 | // Update snapshot on each update 65 | useEffect(() => { 66 | const handler = (event: MessageEvent) => { 67 | const data = event.data; 68 | if (!data) return; 69 | 70 | const decoded = decode< 71 | | { 72 | type: "party.snapshot.update"; 73 | snapshot: StateFrom; 74 | } 75 | | { 76 | type: "party.snapshot.patch"; 77 | operations: Operation[]; 78 | } 79 | >(event.data); 80 | if (!decoded) { 81 | console.warn("message is not decodable", event.data); 82 | return; 83 | } 84 | 85 | // Full state update 86 | if (decoded.type === "party.snapshot.update") { 87 | const update = options?.reviver 88 | ? options.reviver(decoded.snapshot) 89 | : decoded.snapshot; 90 | if (update.tags) { 91 | update.tags = new Set(update.tags); 92 | } 93 | 94 | setSnapshot(update); 95 | 96 | if (isFirstActiveSnapshot.current && update.status === "active") { 97 | isFirstActiveSnapshot.current = false; 98 | props?.onConnect?.(createActorParty(update, partySocket)); 99 | } 100 | } 101 | 102 | // Small patch 103 | if (decoded.type === "party.snapshot.patch") { 104 | setSnapshot((currentSnapshot) => { 105 | const patched = produce(currentSnapshot, (draft) => { 106 | applyPatch(draft, decoded.operations); 107 | }); 108 | 109 | const update = options?.reviver 110 | ? options.reviver(patched) 111 | : patched; 112 | if (update.tags) { 113 | update.tags = new Set(update.tags); 114 | } 115 | 116 | return update; 117 | }); 118 | return; 119 | } 120 | }; 121 | 122 | partySocket.addEventListener("message", handler); 123 | 124 | return () => { 125 | partySocket.removeEventListener("message", handler); 126 | }; 127 | }, []); 128 | 129 | return actor as ActorParty>; 130 | }; 131 | 132 | const ActorContext = createContext>({} as never); 133 | 134 | const ActorProvider = ({ children }: PropsWithChildren) => { 135 | const client = useActor(); 136 | return ( 137 | }> 138 | {children} 139 | 140 | ); 141 | }; 142 | 143 | const useSelector = ( 144 | selector: (state: ActorParty) => TSelectedValue, 145 | ) => { 146 | const ctx = useContext(ActorContext); 147 | if (!ctx) throw new Error("ClientSideMachineProvider not found"); 148 | return useMemo(() => selector(ctx), [ctx, selector]); 149 | }; 150 | 151 | const Matches = ( 152 | props: PropsWithChildren<{ 153 | value: StateValueFrom | Array>; 154 | or?: boolean; 155 | inversed?: boolean; 156 | }>, 157 | ) => { 158 | const { children, value, inversed } = props; 159 | const ctx = useContext(ActorContext); 160 | if (!ctx) throw new Error("ClientSideMachineProvider not found"); 161 | 162 | const isMatching = Array.isArray(value) 163 | ? value.some((v) => ctx.matches(v)) 164 | : ctx.matches(value); 165 | 166 | if (inversed) return !isMatching || Boolean(props.or) ? children : null; 167 | return isMatching || Boolean(props.or) ? children : null; 168 | }; 169 | 170 | return { 171 | Context: ActorContext, 172 | Provider: ActorProvider, 173 | Matches: Matches, 174 | useActor: useActor, 175 | useContext: () => useContext(ActorContext), 176 | useSelector, 177 | _useActorPropsType: {} as UseActorPartyProps, 178 | } as ActorPartyHooks; 179 | }; 180 | 181 | interface UseActorPartyProps { 182 | onConnect?: (actor: ActorParty) => void; 183 | initialContext?: ContextFrom; 184 | partySocketOptions?: Partial; 185 | } 186 | 187 | interface ActorPartyHooks { 188 | Context: React.Context>>; 189 | Provider: ({ children }: PropsWithChildren) => JSX.Element; 190 | Matches: ( 191 | props: PropsWithChildren<{ 192 | value: StateValueFrom | Array>; 193 | or?: boolean; 194 | inversed?: boolean; 195 | }>, 196 | ) => React.ReactNode; 197 | useActor: ( 198 | props?: UseActorPartyProps, 199 | ) => ActorParty>; 200 | useContext: () => ActorParty>; 201 | useSelector: ( 202 | selector: (state: ActorParty) => TSelectedValue, 203 | ) => TSelectedValue; 204 | _useActorPropsType: UseActorPartyProps; 205 | } 206 | -------------------------------------------------------------------------------- /src/create-actor-party.ts: -------------------------------------------------------------------------------- 1 | import type { PartySocket } from "partysocket"; 2 | import { type AnyStateMachine, type StateFrom } from "xstate"; 3 | import { createActorFacade, type ActorFacade } from "./create-actor-facade"; 4 | 5 | export const createActorParty = < 6 | TLogic extends AnyStateMachine, 7 | TState extends StateFrom = StateFrom, 8 | >( 9 | snapshot: TState, 10 | partySocket: PartySocket, 11 | ): ActorParty => ({ 12 | ...createActorFacade( 13 | { 14 | ...snapshot, 15 | partySocket: partySocket, 16 | }, 17 | { 18 | id: partySocket.id, 19 | send: (event) => partySocket.send(JSON.stringify(event)), 20 | }, 21 | ), 22 | partySocket, 23 | }); 24 | 25 | export interface ActorParty< 26 | TLogic extends AnyStateMachine, 27 | TState extends StateFrom = StateFrom, 28 | > extends ActorFacade { 29 | partySocket: PartySocket; 30 | } 31 | -------------------------------------------------------------------------------- /src/env.config.ts: -------------------------------------------------------------------------------- 1 | const Host = import.meta.env.VITE_API_HOST; 2 | if (!Host) throw new Error("VITE_API_HOST is not set"); 3 | 4 | export const EnvConfig = { 5 | Host, 6 | }; 7 | -------------------------------------------------------------------------------- /src/game/game.client.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import PartySocket from "partysocket"; 3 | import type { GameMachineClientSide } from "../../server/game.machine.serialize"; 4 | import { createActorPartyHooks } from "../create-actor-party.hooks"; 5 | import { EnvConfig } from "../env.config"; 6 | 7 | const id = localStorage.getItem("id") || nanoid(); 8 | localStorage.setItem("id", id); // this makes reconnection easy 9 | 10 | const partySocket = new PartySocket({ 11 | id, 12 | host: EnvConfig.Host, 13 | party: "machine", 14 | startClosed: true, 15 | }); 16 | partySocket.binaryType = "arraybuffer"; 17 | 18 | export const GameClient = createActorPartyHooks( 19 | partySocket, 20 | { 21 | reviver: (snapshot) => ({ 22 | ...snapshot, 23 | context: { 24 | ...snapshot.context, 25 | actorList: snapshot.context.actorList.map((actor) => ({ 26 | ...actor, 27 | getSnapshot: () => 28 | (snapshot.children as Record)[actor.id]! as any, 29 | })), 30 | }, 31 | }), 32 | }, 33 | ); 34 | -------------------------------------------------------------------------------- /src/game/game.devtools.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Container, Flex, HStack } from "@chakra-ui/react"; 2 | import { generateSlug } from "random-word-slugs"; 3 | import { GameClient } from "./game.client"; 4 | import { Router } from "../router"; 5 | import { getPlayerId } from "../../server/game.machine.context"; 6 | 7 | export const GameDevtools = () => { 8 | const actor = GameClient.useContext(); 9 | const context = actor.context; 10 | 11 | const currentPlayerActor = context.actorList.find( 12 | (child) => child.id === getPlayerId(actor._userId), 13 | ); 14 | const currentPlayer = currentPlayerActor?.getSnapshot().context; 15 | 16 | return ( 17 | 18 | current: {currentPlayer?.name} 19 |
20 | 21 | 22 | value: {JSON.stringify(actor.snapshot.value)} 23 | 24 | 25 | 26 | {JSON.stringify(actor.snapshot, null, 4)} 27 | 28 |
29 | 30 | 31 | 42 | 51 | 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/game/game.header.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, Flex, HStack, Heading } from "@chakra-ui/react"; 2 | import { GameClient } from "./game.client"; 3 | 4 | export const GameHeader = () => { 5 | const actor = GameClient.useContext(); 6 | const playerList = actor.context.actorList.map( 7 | (ref) => ref.getSnapshot().context, 8 | ); 9 | 10 | return ( 11 | 17 | Multiplayer State Machine 18 | 19 | 20 | 21 | Ready: 22 | {`${playerList.filter((player) => player.state === "ready").length}/${playerList.length}`} 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | const GameStatus = () => { 30 | const actor = GameClient.useContext(); 31 | 32 | return ( 33 | 50 | 51 | Waiting for{" "} 52 | {`${ 53 | actor.context.actorList.filter((actor) => { 54 | const playerContext = actor.getSnapshot().context; 55 | return playerContext.isConnected 56 | ? playerContext.state !== "ready" 57 | : true; 58 | }).length 59 | }/${actor.context.actorList.filter((actor) => actor.getSnapshot().context.isConnected).length}`}{" "} 60 | players 61 | 62 | Playing 63 | Game over! 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/main.client.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { App } from "./app"; 4 | 5 | import "@fontsource/prompt"; 6 | import "./styles.css"; 7 | 8 | createRoot(document.getElementById("app")!).render( 9 | 10 | 11 | , 12 | ); 13 | -------------------------------------------------------------------------------- /src/pages/error.screen.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Stack, Box, HStack, Button } from "@chakra-ui/react"; 2 | import { generateSlug } from "random-word-slugs"; 3 | import { GameClient } from "../game/game.client"; 4 | import { Router } from "../router"; 5 | 6 | export const ErrorScreen = () => { 7 | const actor = GameClient.useContext(); 8 | 9 | return ( 10 |
11 | 12 | console.log(actor)}> 13 | whoops something went boom 14 | 15 | hopefully that doesnt happen in prod 16 | 17 | 28 | 37 | 38 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/pages/game.screen.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Container, Stack } from "@chakra-ui/react"; 2 | import { GameClient } from "../game/game.client"; 3 | import { GameDevtools } from "../game/game.devtools"; 4 | import { GameHeader } from "../game/game.header"; 5 | import { PlayerCard } from "../components/player-card"; 6 | 7 | export const GameScreen = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | Hope you enjoyed the game! :) 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | const PlayerList = () => { 31 | const actor = GameClient.useContext(); 32 | const playerList = actor.context.actorList.map( 33 | (ref) => ref.getSnapshot().context, 34 | ); 35 | 36 | return ( 37 | 38 | {playerList.map((player) => ( 39 | 40 | ))} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/pages/home.page.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Container } from "@chakra-ui/react"; 2 | import { generateSlug } from "random-word-slugs"; 3 | import { LinkButton } from "../components/ui/link-button"; 4 | import { Router } from "../router"; 5 | import { UsernameSection } from "./username.section"; 6 | 7 | export const HomePage = () => { 8 | return ( 9 | 10 |
11 | { 13 | localStorage.setItem("username", username); 14 | 15 | return ( 16 | 21 | Create room 22 | 23 | ); 24 | }} 25 | /> 26 |
27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/pages/loading.screen.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Spinner } from "@chakra-ui/react"; 2 | 3 | export const LoadingScreen = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /src/pages/payment.page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Box, 4 | Button, 5 | Center, 6 | Flex, 7 | Grid, 8 | GridItem, 9 | HStack, 10 | Input, 11 | Stack, 12 | } from "@chakra-ui/react"; 13 | 14 | import { 15 | QueryClient, 16 | QueryClientProvider, 17 | queryOptions, 18 | useQuery, 19 | } from "@tanstack/react-query"; 20 | import { nanoid } from "nanoid"; 21 | import { type PropsWithChildren, useState } from "react"; 22 | import { 23 | LuArrowRight, 24 | LuCircleAlert, 25 | LuCircleCheck, 26 | LuClock, 27 | } from "react-icons/lu"; 28 | import { type SnapshotFrom, type StateValueFrom } from "xstate"; 29 | import type { PaymentActorType } from "../../party/xstate-payment.do"; 30 | import { createActorFacade } from "../create-actor-facade"; 31 | import { SandboxLayout } from "./sandbox.page"; 32 | 33 | const queryClient = new QueryClient(); 34 | 35 | export const PaymentPage = () => { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | const paymentQuery = (paymentId: string) => 48 | queryOptions({ 49 | enabled: Boolean(paymentId), 50 | queryKey: ["payment", paymentId], 51 | queryFn: () => 52 | fetch(`/api/payment/state?name=${paymentId}`).then( 53 | (res) => 54 | res.json() as Promise<{ snapshot: SnapshotFrom }>, 55 | ), 56 | refetchInterval: (query) => { 57 | return !Array.from(query.state.data?.snapshot?.tags ?? []).includes( 58 | "final", 59 | ) 60 | ? 1000 61 | : false; 62 | }, 63 | }); 64 | 65 | const Payment = () => { 66 | const search = new URLSearchParams(window.location.search); 67 | const [paymentId, setPaymentId] = useState( 68 | search.get("paymentId") || "", 69 | ); 70 | 71 | const query = useQuery(paymentQuery(paymentId)); 72 | 73 | if (paymentId) { 74 | if (query.data?.snapshot?.value === "Needs confirm") { 75 | return ( 76 | <> 77 | 87 | 88 | 89 | 90 | ); 91 | } 92 | 93 | return ( 94 | 95 | {query.data?.snapshot?.value && ( 96 | <> 97 | 98 | 99 | )} 100 | 101 | ); 102 | } 103 | 104 | return ( 105 |
{ 107 | e.preventDefault(); 108 | 109 | const formData = new FormData(e.target as HTMLFormElement); 110 | fetch(`/api/payment/init?name=${nanoid(5)}`, { 111 | method: "POST", 112 | body: JSON.stringify({ 113 | sender: formData.get("sender")!, 114 | recipient: formData.get("recipient")!, 115 | amount: Number(formData.get("amount")!), 116 | }), 117 | }) 118 | .then((res) => res.json()) 119 | .then((json) => { 120 | const paymentId = (json as { paymentId: string }).paymentId; 121 | setPaymentId(paymentId); 122 | window.history.pushState( 123 | {}, 124 | "", 125 | window.location.pathname + `?paymentId=${paymentId}`, 126 | ); 127 | }); 128 | }} 129 | > 130 | 131 | 132 | Payment State Machine 133 | 134 | 135 | From: 136 | 137 | 138 | 139 | To: 140 | 141 | 142 | 143 | Amount: 144 | 145 | 146 | 147 | 148 |
149 | ); 150 | }; 151 | 152 | const StateVisualizer = ({ 153 | value, 154 | }: { 155 | value: StateValueFrom; 156 | }) => { 157 | return ( 158 | 159 | 160 | 161 | 162 | State Machine Visualization 163 | 164 | Payment process flow 165 | 166 | 167 | 168 | {[ 169 | "Needs confirm", 170 | "Awaiting approval", 171 | "Approved", 172 | "Debited", 173 | "Awaiting admin approval", 174 | "Rejected", 175 | "Succeeded", 176 | "Cancelled", 177 | "Refunding", 178 | ].map((state) => ( 179 | 187 | {state} 188 | 189 | ))} 190 | 191 | 192 | Current state is highlighted 193 | 194 | 195 | 196 | 197 | ); 198 | }; 199 | 200 | const ActivityLog = ({ logs }: { logs: string[] }) => { 201 | return ( 202 | 203 | 204 | 205 | 206 | Activity Log 207 | 208 | Payment process events 209 | 210 | 211 | {logs.map((log, index) => ( 212 | 213 |
220 | 221 |
222 | {log} 223 |
224 | ))} 225 |
226 |
227 |
228 | ); 229 | }; 230 | 231 | function PaymentWorkflow(props: PropsWithChildren<{ paymentId: string }>) { 232 | const query = useQuery(paymentQuery(props.paymentId)); 233 | const snapshot = query.data?.snapshot; 234 | 235 | if (query.status === "error") { 236 | return Error: {query.error.message}; 237 | } 238 | 239 | if (query.status === "pending") { 240 | return null; 241 | } 242 | 243 | if (!snapshot) { 244 | return ( 245 | 246 | Payment request not found 247 | Go Back 248 | 249 | ); 250 | } 251 | 252 | const logs = snapshot.context.logs ?? []; 253 | const currentState = snapshot.value; 254 | 255 | const actor = createActorFacade(snapshot, { 256 | id: props.paymentId, 257 | send: (event) => 258 | fetch(`/api/payment/send?name=${props.paymentId}`, { 259 | method: "POST", 260 | body: JSON.stringify(event), 261 | }), 262 | }); 263 | 264 | const canApprove = 265 | actor.matches("Awaiting approval") || 266 | actor.matches("Awaiting admin approval"); 267 | 268 | return ( 269 | 270 | 271 | Payment State Machine 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | Payment Status 280 | 281 | Current state and payment details 282 | 283 | 284 | 285 | 286 | {getStateIcon(currentState)} 287 | {currentState} 288 | 289 | 290 | 291 | 292 | 293 | 294 | Payment ID 295 | 296 | 297 | {props.paymentId} 298 | 299 | 300 | 301 | 302 | Amount 303 | 304 | 305 | ${snapshot.context.amount} 306 | 307 | 308 | 309 | 310 | Sender 311 | 312 | 313 | {snapshot.context.senderUserId} 314 | 315 | 316 | 317 | 318 | Recipient 319 | 320 | 321 | {snapshot.context.recipientUserId} 322 | 323 | 324 | 325 | 326 | 336 | 347 | 348 | 349 | 350 | 351 | 352 | {props.children} 353 | 354 | ); 355 | } 356 | 357 | const getStateColor = (state: StateValueFrom) => { 358 | switch (state) { 359 | case "Awaiting approval": 360 | case "Awaiting admin approval": 361 | return "yellow"; 362 | case "Approved": 363 | case "Debited": 364 | case "Succeeded": 365 | return "green"; 366 | case "Rejected": 367 | case "Cancelled": 368 | case "Refunding": 369 | return "red"; 370 | default: 371 | return "gray"; 372 | } 373 | }; 374 | 375 | const getStateIcon = (state: StateValueFrom) => { 376 | switch (state) { 377 | case "Awaiting approval": 378 | case "Awaiting admin approval": 379 | return ; 380 | case "Approved": 381 | case "Debited": 382 | case "Succeeded": 383 | return ; 384 | case "Rejected": 385 | case "Cancelled": 386 | case "Refunding": 387 | return ; 388 | default: 389 | return null; 390 | } 391 | }; 392 | -------------------------------------------------------------------------------- /src/pages/room.page.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Stack } from "@chakra-ui/react"; 2 | import { useMemo, useState } from "react"; 3 | import { getInitialContext } from "../../server/game.machine.context"; 4 | import { GameClient } from "../game/game.client"; 5 | import { ErrorScreen } from "./error.screen"; 6 | import { GameScreen } from "./game.screen"; 7 | import { LoadingScreen } from "./loading.screen"; 8 | import { WelcomeScreen } from "./welcome-screen"; 9 | 10 | export const RoomPage = (props: { roomId: string }) => { 11 | const { roomId } = props; 12 | 13 | const [username, setUsername] = useState( 14 | localStorage.getItem("username") || "", 15 | ); 16 | 17 | const onJoin = (username: string) => { 18 | actor.send({ 19 | type: "Connect", 20 | player: { name: username }, 21 | }); 22 | }; 23 | 24 | const options: (typeof GameClient)["_useActorPropsType"] = useMemo( 25 | () => ({ 26 | context: getInitialContext({ input: { roomId } }), 27 | partySocketOptions: { room: roomId }, 28 | onConnect: () => { 29 | if (!username.trim()) return; 30 | onJoin(username); 31 | }, 32 | }), 33 | [roomId], 34 | ); 35 | const actor = GameClient.useActor(options); 36 | 37 | return ( 38 | 39 | 40 | 41 | {actor.status === "stopped" ? ( 42 | 43 | ) : actor.status === "error" ? ( 44 | 45 | ) : username ? ( 46 | 47 | ) : ( 48 | { 50 | setUsername(update); 51 | 52 | if (update.trim()) { 53 | localStorage.setItem("username", update); 54 | onJoin(update); 55 | } 56 | }} 57 | /> 58 | )} 59 | 60 | 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/pages/sandbox.page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Center, 5 | Container, 6 | HStack, 7 | Input, 8 | Stack, 9 | } from "@chakra-ui/react"; 10 | 11 | import { type ReactNode, useEffect, useState } from "react"; 12 | import { EnvConfig } from "../env.config"; 13 | 14 | export const SandboxLayout = (props: { children: ReactNode }) => { 15 | return ( 16 | 17 | 18 |
{props.children}
19 |
20 |
21 | ); 22 | }; 23 | 24 | export const SandboxPage = () => { 25 | return ( 26 | 27 | 28 | 29 | Go to TodoList sandbox 30 | Go to Payment sandbox 31 | 32 | 33 | ); 34 | }; 35 | 36 | const Counter = () => { 37 | const [name, setName] = useState("abc"); 38 | const [count, setCount] = useState(null as number | null); 39 | 40 | const getAndSetCurrent = (doName: string) => 41 | fetch(`http://${EnvConfig.Host}/api/counter/current?name=${doName}`) 42 | .then((res) => res.json()) 43 | .then((json) => setCount((json as { current: number }).current)); 44 | 45 | useEffect(() => { 46 | getAndSetCurrent(name); 47 | }, []); 48 | 49 | return ( 50 | 51 | 52 | Hono Counter 53 | 54 | 55 | Durable Object name: 56 | { 60 | setName(e.target.value); 61 | getAndSetCurrent(e.target.value); 62 | }} 63 | /> 64 | 65 | 66 | Counter value: 67 | {count ?? "Unknown"} 68 | 69 | 70 | 80 | 90 | 91 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /src/pages/username.section.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, IconButton, Input, Stack } from "@chakra-ui/react"; 2 | import { useState } from "react"; 3 | import { GiPerspectiveDiceSixFacesTwo } from "react-icons/gi"; 4 | import { generateSlug } from "random-word-slugs"; 5 | 6 | export const generateRandomName = () => { 7 | const slug = generateSlug(2, { 8 | format: "title", 9 | categories: { 10 | noun: ["animals"], 11 | adjective: ["personality", "taste", "size"], 12 | }, 13 | partsOfSpeech: ["adjective", "noun", "adjective"], 14 | }).replace(/\s/g, ""); 15 | 16 | return slug + (Math.random() > 0.6 ? 69 : ""); 17 | }; 18 | 19 | export const UsernameSection = ({ 20 | render, 21 | }: { render: (username: string) => JSX.Element }) => { 22 | const [username, setUsername] = useState(generateRandomName()); 23 | return ( 24 | 25 | 26 | setUsername(e.target.value)} 34 | /> 35 | setUsername(generateRandomName())} 37 | variant="surface" 38 | size="lg" 39 | padding="1" 40 | height="100%" 41 | _hover={{ transform: "scale(1.1) rotate(180deg)" }} 42 | > 43 | 46 | 47 | 48 | {render(username)} 49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/pages/welcome-screen.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Center } from "@chakra-ui/react"; 2 | import { toaster } from "../components/ui/toaster"; 3 | import { UsernameSection } from "./username.section"; 4 | 5 | export const WelcomeScreen = (props: { 6 | setUsername: (username: string) => void; 7 | }) => { 8 | const { setUsername } = props; 9 | 10 | return ( 11 |
12 | ( 14 | 32 | )} 33 | /> 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter } from "@swan-io/chicane"; 2 | 3 | export const Router = createRouter({ 4 | Home: "/", 5 | Room: "/rooms/:roomId", 6 | Sandbox: "/sandbox", 7 | Payment: "/payment", 8 | }); 9 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | --chakra-fonts-heading: "Prompt"; 3 | font-family: "Prompt"; 4 | font-size: 16px; 5 | line-height: 1.5; 6 | 7 | width: 100%; 8 | height: 100%; 9 | min-height: 100dvh; 10 | overflow-x: hidden; 11 | color: #000; 12 | } 13 | 14 | #app { 15 | width: 100vw; 16 | height: 100vh; 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "jsx": "react-jsx" /* Specify what JSX code is generated. */, 5 | "module": "ES2020" /* Specify what module code is generated. */, 6 | "moduleResolution": "Bundler" /* Specify how TypeScript looks up a file from a given module specifier. */, 7 | "resolveJsonModule": true /* Enable importing .json files. */, 8 | "noEmit": true /* Disable emitting files from a compilation. */, 9 | "verbatimModuleSyntax": true /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */, 10 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 11 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 12 | "strict": true /* Enable all strict type-checking options. */, 13 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 14 | "noUncheckedIndexedAccess": true, 15 | "types": [ 16 | "vite/client", 17 | "@cloudflare/workers-types/2023-07-01" 18 | ], 19 | }, 20 | "include": [ 21 | "src", 22 | "party", 23 | "server", 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { cloudflare } from "@cloudflare/vite-plugin"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | import jsxSource from "unplugin-jsx-source/vite"; 6 | import Inspect from "vite-plugin-inspect"; 7 | 8 | const defaultTransformFileName = ( 9 | id: string, 10 | loc: { 11 | start: { line: number; column: number }; 12 | end: { line: number; column: number }; 13 | }, 14 | ) => { 15 | const fileName = id.split("/").slice(-2).join("/") ?? "unknown"; 16 | return `${fileName}:${loc.start.line}`; 17 | }; 18 | 19 | export default defineConfig({ 20 | plugins: [ 21 | Inspect(), 22 | process.env.NODE_ENV === "development" && 23 | jsxSource({ 24 | enforce: "pre", 25 | transformFileName: (fileName, loc) => 26 | defaultTransformFileName(fileName, loc), 27 | }), 28 | , 29 | react(), 30 | !process.env.VITEST && cloudflare(), 31 | tsconfigPaths(), 32 | ], 33 | }); 34 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "multiplayer-durable-state-machine", 8 | "main": "./party/main.server.ts", 9 | "compatibility_date": "2025-02-04", 10 | "compatibility_flags": [ 11 | "nodejs_compat" 12 | ], 13 | "assets": { 14 | "directory": "./public", 15 | "not_found_handling": "single-page-application", 16 | "binding": "ASSETS" 17 | }, 18 | "durable_objects": { 19 | "bindings": [ 20 | { 21 | "name": "Machine", 22 | "class_name": "Machine" 23 | }, 24 | { 25 | "name": "Counter", 26 | "class_name": "Counter" 27 | }, 28 | { 29 | "name": "TodoList", 30 | "class_name": "TodoList" 31 | }, 32 | { 33 | "name": "PaymentActor", 34 | "class_name": "PaymentActor" 35 | }, 36 | ] 37 | }, 38 | "migrations": [ 39 | { 40 | "tag": "v1", 41 | "new_classes": [ 42 | "Machine", 43 | "Counter", 44 | "TodoList", 45 | "PaymentActor" 46 | ] 47 | } 48 | ], 49 | "observability": { 50 | "enabled": true 51 | } 52 | /** 53 | * Smart Placement 54 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 55 | */ 56 | // "placement": { "mode": "smart" }, 57 | /** 58 | * Bindings 59 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 60 | * databases, object storage, AI inference, real-time communication and more. 61 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 62 | */ 63 | /** 64 | * Environment Variables 65 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 66 | */ 67 | // "vars": { "MY_VARIABLE": "production_value" }, 68 | /** 69 | * Note: Use secrets to store sensitive data. 70 | * https://developers.cloudflare.com/workers/configuration/secrets/ 71 | */ 72 | /** 73 | * Static Assets 74 | * https://developers.cloudflare.com/workers/static-assets/binding/ 75 | */ 76 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 77 | /** 78 | * Service Bindings (communicate between multiple Workers) 79 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 80 | */ 81 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 82 | } 83 | --------------------------------------------------------------------------------