├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------