collin/mahjong · f55b8ad0
Eject antics: our own websocket relay in node
Collin Richards · 2026-08-20 16:46 UTC · f55b8ad0e3652a507739502125725eeed521af0a · parent 3f4c297b · browse files
modifiedREADME.md+43 −30
| ⋯ 142 unchanged lines | |||
| 143 | 143 | ||
| 144 | 144 | ## Playing online | |
| 145 | 145 | ||
| 146 | - | Two networked modes, both built on [antics](https://antics.gg) — a room | |
| 147 | - | service for web games with no backend of your own: one client (the antics | |
| 148 | - | *host*) runs the engine, publishes the whole `GameState` as room state after | |
| 146 | + | Two networked modes, both built on a room relay of our own — `server/rooms.ts`, | |
| 147 | + | a couple hundred lines of Node on `ws`. The relay knows nothing about mahjong: | |
| 148 | + | rooms with codes, host election by join order, one bag of shared state only the | |
| 149 | + | host may write, and events fanned out to everyone else. One client — the | |
| 150 | + | *host* — runs the engine, publishes the whole `GameState` as room state after | |
| 149 | 151 | every move, and plays intents the other devices send as events. The engine was | |
| 150 | 152 | already a pure JSON state machine, so nothing in `src/game/` knows the network | |
| 151 | - | exists — the same trick `autoplay.ts` plays, stretched over a wire. All of it | |
| 152 | - | lives in `src/net/`. | |
| 153 | + | exists — the same trick `autoplay.ts` plays, stretched over a wire. The client | |
| 154 | + | end of the wire is `src/net/room.ts`; the rest of `src/net/` is the game | |
| 155 | + | riding it. | |
| 153 | 156 | ||
| 154 | 157 | - **線上對戰 Online game** — everyone on their own device. Whoever opens the | |
| 155 | 158 | room picks 線上對戰 and gets a seat lobby; friends who open the same link | |
| ⋯ 22 unchanged lines | |||
| 178 | 181 | — the state carries a multiset and each device keeps its own order | |
| 179 | 182 | (`src/net/handOrder.ts`), because a round-trip inside a drag gesture is lag | |
| 180 | 183 | you can feel. | |
| 181 | - | - **The host can change.** antics re-elects when the host leaves; every other | |
| 182 | - | client already mirrors the full state, so the new host starts its engine | |
| 183 | - | from what it was just watching and the game carries on. | |
| 184 | + | - **The host can change.** The relay re-elects — the earliest joiner still | |
| 185 | + | connected — when the host leaves; every other client already mirrors the | |
| 186 | + | full state, so the new host starts its engine from what it was just watching | |
| 187 | + | and the game carries on. | |
| 184 | 188 | - **A hidden host must keep hosting.** Browsers suspend requestAnimationFrame | |
| 185 | - | in background tabs, and the antics SDK flushes state writes off rAF — so a | |
| 186 | - | party screen somebody tabbed away from would silently stop answering. | |
| 187 | - | `src/net/clock.ts` feeds the SDK's `__anticsClock__` injection point from a | |
| 188 | - | Web Worker, whose timers are not throttled. | |
| 189 | - | - **Fairness is social, not cryptographic.** The classic tier shares the whole | |
| 190 | - | state with every client, wall and hands included — a friend with devtools | |
| 191 | - | open can cheat. The antics sim tier would fix that, and costs money. | |
| 189 | + | and throttle timers in background tabs, so nothing in `src/net/` runs on a | |
| 190 | + | loop: state writes flush on a microtask, everything inbound arrives over the | |
| 191 | + | WebSocket (whose delivery is not throttled), and dead connections are the | |
| 192 | + | server's ping loop's problem. A party screen somebody tabbed away from | |
| 193 | + | keeps answering. | |
| 194 | + | - **Identity is a UUID in sessionStorage**, chosen by the device and taken at | |
| 195 | + | its word by the relay. The seat map is keyed on it, so a reload or a wifi | |
| 196 | + | blip walks back into its own seat. No auth — this is a home server for one | |
| 197 | + | table of friends. | |
| 198 | + | - **Fairness is social, not cryptographic.** Room state carries the whole | |
| 199 | + | game, wall and hands included — a friend with devtools open can cheat. | |
| 200 | + | Moving the engine into the relay would fix that; the server is ours now, so | |
| 201 | + | only the work stands in the way (TODO). | |
| 192 | 202 | ||
| 193 | - | ### Hosting on antics | |
| 203 | + | ### Running it | |
| 194 | 204 | ||
| 195 | 205 | ``` | |
| 196 | - | npm run build && node scripts/antics-deploy.mjs # keyless — a fresh link each deploy | |
| 197 | - | node scripts/antics-deploy.mjs --project <id> # stable /p/ link (needs `npx antics-cli login`) | |
| 206 | + | npm run dev # vite, with the relay on /ws of the same origin | |
| 207 | + | npm run build && npm run serve # production: dist/ + relay, one process | |
| 198 | 208 | ``` | |
| 199 | 209 | ||
| 200 | - | The deploy script packs the 57 voice clips into one `voice/pack.json` (an | |
| 201 | - | antics bundle holds at most 50 files; `sound.ts` prefers the pack when it is | |
| 202 | - | there). Keyless deploys are pinned to their content hash — share a new link | |
| 203 | - | after every deploy — and keyless rooms hold 8 players and expire after 24 | |
| 204 | - | hours. Deploying under a project gives a share link that follows updates. | |
| 210 | + | The relay always lives at `/ws` on the page's own origin. In development | |
| 211 | + | `vite.config.ts` attaches it to vite's server, so `npm run dev` is the whole | |
| 212 | + | stack; in production `server/index.ts` serves the built `dist/` and the relay | |
| 213 | + | from one port (`PORT`, default 8080), listening on the LAN by default so | |
| 214 | + | phones on the same wifi can scan straight in. Anything further away wants TLS | |
| 215 | + | in front — a reverse proxy with a certificate — since phones only get camera | |
| 216 | + | and fullscreen on https. | |
| 205 | 217 | ||
| 206 | - | The QR links point at the game's own URL (`/raw/<hash>/?room=…&seat=…`), not | |
| 207 | - | the antics room page: the room page forwards only the parameters it knows | |
| 208 | - | about to the game, and `seat` is not one of them — and a phone is better off | |
| 209 | - | without the chrome anyway. | |
| 218 | + | Rooms live in memory. A restart drops them; the next visitor on an old link | |
| 219 | + | recreates the room empty, and a host mid-game republishes its state on the | |
| 220 | + | next move. Joining a room writes `?room=` back into the address bar, so the | |
| 221 | + | page URL — the host's included — is the invite link. | |
| 210 | 222 | ||
| 211 | 223 | ## On a phone | |
| 212 | 224 | ||
| ⋯ 220 unchanged lines | |||
| 433 | 445 | src/game/engine.ts state machine: deal, turns, claim resolution, settlement | |
| 434 | 446 | src/game/ctl.ts TableCtl — the surface the UI drives, local or networked | |
| 435 | 447 | src/game/wall.ts what is left of the square, and what still blocks a throw | |
| 436 | - | src/net/session.ts the antics room: host duties, seats, intents, migration | |
| 448 | + | src/net/room.ts the wire itself: join, reconnect, state, events | |
| 449 | + | src/net/session.ts the room: host duties, seats, intents, migration | |
| 437 | 450 | src/net/table.ts TableCtl over the wire: mirror + intents + hand overlay | |
| 438 | 451 | src/net/protocol.ts what crosses the wire, in one place | |
| 439 | 452 | src/net/handOrder.ts your own arrangement, kept on your own device | |
| 440 | - | src/net/clock.ts worker-driven SDK clock, so a hidden host keeps hosting | |
| 441 | 453 | src/ui/Controller.tsx the phone at the party table: your tiles and the throw | |
| 442 | 454 | src/ui/OnlineLobby.tsx seats and the deal button, before an online game | |
| 443 | - | scripts/antics-deploy.mjs dist/ → antics.gg, voice pack and all | |
| 455 | + | server/rooms.ts the relay: rooms, host election, state fan-out | |
| 456 | + | server/index.ts production: dist/ and the relay from one port | |
| 444 | 457 | src/table/physics.ts the discard pool: rectangles with weight, pure and testable | |
| 445 | 458 | src/table/geometry.ts the table measured — colliders, launch points, throw gate | |
| 446 | 459 | src/table/pool.ts game state ⇄ tiles in the middle, by diffing the discards | |
| ⋯ 53 unchanged lines | |||
modifiedTODO.md+7 −6
| ⋯ 22 unchanged lines | |||
| 23 | 23 | flags plus a payment hook. | |
| 24 | 24 | ## Online (new) | |
| 25 | 25 | ||
| 26 | - | - [ ] a stable share link: deploy under an antics project (`npx antics-cli | |
| 27 | - | login`, then `node scripts/antics-deploy.mjs --project <id>`) — keyless | |
| 28 | - | links pin one build and keyless rooms die after 24h | |
| 26 | + | - [ ] put it on a real domain: `npm run serve` behind a TLS reverse proxy | |
| 27 | + | (phones only get camera and fullscreen on https) — anvil, a VPS, a | |
| 28 | + | tailscale funnel, anything that terminates TLS | |
| 29 | 29 | - [ ] undo online: only the party screen has it, and only while it is the | |
| 30 | - | antics host; online games have none at all | |
| 30 | + | room's host; online games have none at all | |
| 31 | 31 | - [ ] full-table online clients throw with the local gesture but others see | |
| 32 | 32 | the default seat-launch animation — could send the NetThrow for those too | |
| 33 | - | - [ ] cheat-resistance: room state carries the wall and every hand; the antics | |
| 34 | - | sim tier (paid) would keep secrets server-side | |
| 33 | + | - [ ] cheat-resistance: room state carries the wall and every hand; the relay | |
| 34 | + | is ours now, so the engine could move into `server/rooms.ts` and keep | |
| 35 | + | secrets server-side | |
| 35 | 36 | - [ ] voice/sfx settings on the phone controller (it inherits defaults, has no | |
| 36 | 37 | toggle of its own) | |
| 37 | 38 | ||
| ⋯ 7 unchanged lines | |||
modifiedpackage-lock.json+34 −17
| ⋯ 7 unchanged lines | |||
| 8 | 8 | "name": "mahjong", | |
| 9 | 9 | "version": "0.0.0", | |
| 10 | 10 | "dependencies": { | |
| 11 | - | "antics-sdk": "^0.3.0", | |
| 12 | 11 | "react": "^19.2.8", | |
| 13 | 12 | "react-dom": "^19.2.8", | |
| 14 | - | "uqr": "^0.1.3" | |
| 13 | + | "uqr": "^0.1.3", | |
| 14 | + | "ws": "^8.21.3" | |
| 15 | 15 | }, | |
| 16 | 16 | "devDependencies": { | |
| 17 | 17 | "@types/node": "^24.13.3", | |
| 18 | 18 | "@types/react": "^19.2.17", | |
| 19 | 19 | "@types/react-dom": "^19.2.3", | |
| 20 | + | "@types/ws": "^8.18.1", | |
| 20 | 21 | "@vitejs/plugin-react": "^6.0.4", | |
| 21 | 22 | "oxlint": "^1.75.0", | |
| 22 | 23 | "typescript": "~6.0.2", | |
| ⋯ 690 unchanged lines | |||
| 713 | 714 | "@types/react": "^19.2.0" | |
| 714 | 715 | } | |
| 715 | 716 | }, | |
| 717 | + | "node_modules/@types/ws": { | |
| 718 | + | "version": "8.18.1", | |
| 719 | + | "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", | |
| 720 | + | "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", | |
| 721 | + | "dev": true, | |
| 722 | + | "license": "MIT", | |
| 723 | + | "dependencies": { | |
| 724 | + | "@types/node": "*" | |
| 725 | + | } | |
| 726 | + | }, | |
| 716 | 727 | "node_modules/@vitejs/plugin-react": { | |
| 717 | 728 | "version": "6.0.5", | |
| 718 | 729 | "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", | |
| ⋯ 133 unchanged lines | |||
| 852 | 863 | "url": "https://opencollective.com/vitest" | |
| 853 | 864 | } | |
| 854 | 865 | }, | |
| 855 | - | "node_modules/antics-protocol": { | |
| 856 | - | "version": "0.2.0", | |
| 857 | - | "resolved": "https://registry.npmjs.org/antics-protocol/-/antics-protocol-0.2.0.tgz", | |
| 858 | - | "integrity": "sha512-9kFogFzYF99ajcxCB9hWv4IRw3sgO1dF1jbw6cK7sx9EVc+rUuUh2xGhqDzdWoajZz3AmmeBhjmhfTfg2Hlq/Q==", | |
| 859 | - | "license": "SEE LICENSE IN LICENSE" | |
| 860 | - | }, | |
| 861 | - | "node_modules/antics-sdk": { | |
| 862 | - | "version": "0.3.0", | |
| 863 | - | "resolved": "https://registry.npmjs.org/antics-sdk/-/antics-sdk-0.3.0.tgz", | |
| 864 | - | "integrity": "sha512-GhgJP2HiJaZTH89MsUZyJsNiJCkslLy965BAAudCYpFGQStJMUu6Yrg4VB1famvccZYK4ghS2eKwFnCvRgWeXg==", | |
| 865 | - | "license": "SEE LICENSE IN LICENSE", | |
| 866 | - | "dependencies": { | |
| 867 | - | "antics-protocol": "^0.1.0 || ^0.2.0" | |
| 868 | - | } | |
| 869 | - | }, | |
| 870 | 866 | "node_modules/assertion-error": { | |
| 871 | 867 | "version": "2.0.1", | |
| 872 | 868 | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", | |
| ⋯ 865 unchanged lines | |||
| 1738 | 1734 | "engines": { | |
| 1739 | 1735 | "node": ">=8" | |
| 1740 | 1736 | } | |
| 1737 | + | }, | |
| 1738 | + | "node_modules/ws": { | |
| 1739 | + | "version": "8.21.3", | |
| 1740 | + | "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", | |
| 1741 | + | "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", | |
| 1742 | + | "license": "MIT", | |
| 1743 | + | "engines": { | |
| 1744 | + | "node": ">=10.0.0" | |
| 1745 | + | }, | |
| 1746 | + | "peerDependencies": { | |
| 1747 | + | "bufferutil": "^4.0.1", | |
| 1748 | + | "utf-8-validate": ">=5.0.2" | |
| 1749 | + | }, | |
| 1750 | + | "peerDependenciesMeta": { | |
| 1751 | + | "bufferutil": { | |
| 1752 | + | "optional": true | |
| 1753 | + | }, | |
| 1754 | + | "utf-8-validate": { | |
| 1755 | + | "optional": true | |
| 1756 | + | } | |
| 1757 | + | } | |
| 1741 | 1758 | } | |
| 1742 | 1759 | } | |
| 1743 | 1760 | } | |
modifiedpackage.json+4 −2
| ⋯ 7 unchanged lines | |||
| 8 | 8 | "build": "tsc -b && vite build", | |
| 9 | 9 | "lint": "oxlint", | |
| 10 | 10 | "preview": "vite preview", | |
| 11 | + | "serve": "node server/index.ts", | |
| 11 | 12 | "test": "vitest run" | |
| 12 | 13 | }, | |
| 13 | 14 | "dependencies": { | |
| 14 | - | "antics-sdk": "^0.3.0", | |
| 15 | 15 | "react": "^19.2.8", | |
| 16 | 16 | "react-dom": "^19.2.8", | |
| 17 | - | "uqr": "^0.1.3" | |
| 17 | + | "uqr": "^0.1.3", | |
| 18 | + | "ws": "^8.21.3" | |
| 18 | 19 | }, | |
| 19 | 20 | "devDependencies": { | |
| 20 | 21 | "@types/node": "^24.13.3", | |
| 21 | 22 | "@types/react": "^19.2.17", | |
| 22 | 23 | "@types/react-dom": "^19.2.3", | |
| 24 | + | "@types/ws": "^8.18.1", | |
| 23 | 25 | "@vitejs/plugin-react": "^6.0.4", | |
| 24 | 26 | "oxlint": "^1.75.0", | |
| 25 | 27 | "typescript": "~6.0.2", | |
| ⋯ 4 unchanged lines | |||
deletedscripts/antics-deploy.mjs+0 −101
| 1 | - | #!/usr/bin/env node | |
| 2 | - | // Deploy the built game to antics.gg as a multi-file bundle. | |
| 3 | - | // | |
| 4 | - | // npm run build && node scripts/antics-deploy.mjs # keyless: a fresh link each time | |
| 5 | - | // node scripts/antics-deploy.mjs --project <proj_id> # under a project: stable /p/ share link | |
| 6 | - | // | |
| 7 | - | // A keyless deploy needs no account at all. Deploying under a project needs a | |
| 8 | - | // token from `npx antics-cli login`, read from ANTICS_TOKEN or ~/.antics/token. | |
| 9 | - | ||
| 10 | - | import { readdir, readFile } from 'node:fs/promises'; | |
| 11 | - | import { homedir } from 'node:os'; | |
| 12 | - | import { join, relative, extname } from 'node:path'; | |
| 13 | - | ||
| 14 | - | const HOST = process.env.ANTICS_HOST || 'https://antics.gg'; | |
| 15 | - | const DIST = new URL('../dist', import.meta.url).pathname; | |
| 16 | - | ||
| 17 | - | // Anything not in this list rides along as a data: URI. | |
| 18 | - | const TEXT = new Set(['.html', '.js', '.css', '.svg', '.json', '.webmanifest', '.txt', '.map']); | |
| 19 | - | const MIME = { | |
| 20 | - | '.png': 'image/png', | |
| 21 | - | '.jpg': 'image/jpeg', | |
| 22 | - | '.webp': 'image/webp', | |
| 23 | - | '.mp3': 'audio/mpeg', | |
| 24 | - | '.woff2': 'font/woff2', | |
| 25 | - | '.ico': 'image/x-icon', | |
| 26 | - | }; | |
| 27 | - | ||
| 28 | - | async function* walk(dir) { | |
| 29 | - | for (const e of await readdir(dir, { withFileTypes: true })) { | |
| 30 | - | const p = join(dir, e.name); | |
| 31 | - | if (e.isDirectory()) yield* walk(p); | |
| 32 | - | else yield p; | |
| 33 | - | } | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | async function collect() { | |
| 37 | - | const files = {}; | |
| 38 | - | const voice = {}; | |
| 39 | - | let bytes = 0; | |
| 40 | - | for await (const p of walk(DIST)) { | |
| 41 | - | const rel = relative(DIST, p); | |
| 42 | - | const ext = extname(rel); | |
| 43 | - | const buf = await readFile(p); | |
| 44 | - | bytes += buf.length; | |
| 45 | - | // A bundle may hold at most 50 files and the voice pack alone is 57, so | |
| 46 | - | // the clips ship as one JSON of base64 — sound.ts prefers it when present. | |
| 47 | - | if (rel.startsWith('voice/') && ext === '.mp3') { | |
| 48 | - | voice[rel.slice('voice/'.length, -'.mp3'.length)] = buf.toString('base64'); | |
| 49 | - | continue; | |
| 50 | - | } | |
| 51 | - | if (rel === 'voice/manifest.json') continue; | |
| 52 | - | files[rel] = TEXT.has(ext) | |
| 53 | - | ? buf.toString('utf8') | |
| 54 | - | : `data:${MIME[ext] ?? 'application/octet-stream'};base64,${buf.toString('base64')}`; | |
| 55 | - | } | |
| 56 | - | if (Object.keys(voice).length) files['voice/pack.json'] = JSON.stringify(voice); | |
| 57 | - | return { files, bytes }; | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | async function token() { | |
| 61 | - | if (process.env.ANTICS_TOKEN) return process.env.ANTICS_TOKEN; | |
| 62 | - | try { | |
| 63 | - | return (await readFile(join(homedir(), '.antics', 'token'), 'utf8')).trim(); | |
| 64 | - | } catch { | |
| 65 | - | return null; | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | const args = process.argv.slice(2); | |
| 70 | - | const projectAt = args.indexOf('--project'); | |
| 71 | - | const project = projectAt >= 0 ? args[projectAt + 1] : null; | |
| 72 | - | ||
| 73 | - | const { files, bytes } = await collect(); | |
| 74 | - | console.log(`deploying ${Object.keys(files).length} files, ${(bytes / 1024 / 1024).toFixed(1)} MB from dist/`); | |
| 75 | - | ||
| 76 | - | const body = { files }; | |
| 77 | - | const headers = { 'content-type': 'application/json' }; | |
| 78 | - | if (project) { | |
| 79 | - | body.project = project; | |
| 80 | - | const t = await token(); | |
| 81 | - | if (!t) { | |
| 82 | - | console.error('a --project deploy needs a token: run `npx antics-cli login`, or set ANTICS_TOKEN'); | |
| 83 | - | process.exit(1); | |
| 84 | - | } | |
| 85 | - | headers.authorization = `Bearer ${t}`; | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | const res = await fetch(`${HOST}/api/deploy`, { | |
| 89 | - | method: 'POST', | |
| 90 | - | headers, | |
| 91 | - | body: JSON.stringify(body), | |
| 92 | - | }); | |
| 93 | - | const out = await res.json().catch(() => ({})); | |
| 94 | - | if (!res.ok) { | |
| 95 | - | console.error(`deploy failed: ${res.status}`, out); | |
| 96 | - | process.exit(1); | |
| 97 | - | } | |
| 98 | - | console.log('deployed.'); | |
| 99 | - | if (out.playUrl) console.log(` play (this build): ${out.playUrl}`); | |
| 100 | - | if (out.shareUrl) console.log(` share (follows updates): ${out.shareUrl}`); | |
| 101 | - | if (out.hash) console.log(` hash: ${out.hash}`); |
addedserver/index.ts+69 −0
| 1 | + | import { createServer } from 'node:http'; | |
| 2 | + | import { createReadStream } from 'node:fs'; | |
| 3 | + | import { stat } from 'node:fs/promises'; | |
| 4 | + | import { extname, join, resolve } from 'node:path'; | |
| 5 | + | import { fileURLToPath } from 'node:url'; | |
| 6 | + | import { attachRooms } from './rooms.ts'; | |
| 7 | + | ||
| 8 | + | /** | |
| 9 | + | * The whole production stack in one process: the built game out of dist/, and | |
| 10 | + | * the room relay on /ws of the same origin. `npm run build && npm run serve`. | |
| 11 | + | * In development this file is not involved — vite serves the game and | |
| 12 | + | * vite.config.ts attaches the same relay to vite's own server. | |
| 13 | + | */ | |
| 14 | + | ||
| 15 | + | const DIST = resolve(fileURLToPath(import.meta.url), '../../dist'); | |
| 16 | + | const PORT = process.env.PORT ? Number(process.env.PORT) : 8080; | |
| 17 | + | const HOST = process.env.HOST || '0.0.0.0'; | |
| 18 | + | ||
| 19 | + | const MIME: Record<string, string> = { | |
| 20 | + | '.html': 'text/html; charset=utf-8', | |
| 21 | + | '.js': 'text/javascript', | |
| 22 | + | '.css': 'text/css', | |
| 23 | + | '.svg': 'image/svg+xml', | |
| 24 | + | '.png': 'image/png', | |
| 25 | + | '.mp3': 'audio/mpeg', | |
| 26 | + | '.json': 'application/json', | |
| 27 | + | '.webmanifest': 'application/manifest+json', | |
| 28 | + | '.ico': 'image/x-icon', | |
| 29 | + | '.txt': 'text/plain; charset=utf-8', | |
| 30 | + | '.map': 'application/json', | |
| 31 | + | '.woff2': 'font/woff2', | |
| 32 | + | }; | |
| 33 | + | ||
| 34 | + | const server = createServer(async (req, res) => { | |
| 35 | + | const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname); | |
| 36 | + | const file = resolve(join(DIST, pathname === '/' ? 'index.html' : pathname)); | |
| 37 | + | if (!file.startsWith(DIST)) { | |
| 38 | + | res.writeHead(403).end(); | |
| 39 | + | return; | |
| 40 | + | } | |
| 41 | + | try { | |
| 42 | + | const s = await stat(file); | |
| 43 | + | if (!s.isFile()) throw new Error('not a file'); | |
| 44 | + | const ext = extname(file); | |
| 45 | + | res.writeHead(200, { | |
| 46 | + | 'content-type': MIME[ext] ?? 'application/octet-stream', | |
| 47 | + | 'content-length': s.size, | |
| 48 | + | // Hashed assets may be kept forever; everything else asks again. | |
| 49 | + | 'cache-control': pathname.startsWith('/assets/') | |
| 50 | + | ? 'public, max-age=31536000, immutable' | |
| 51 | + | : 'no-cache', | |
| 52 | + | }); | |
| 53 | + | createReadStream(file).pipe(res); | |
| 54 | + | } catch { | |
| 55 | + | res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n'); | |
| 56 | + | } | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | attachRooms(server); | |
| 60 | + | // Upgrades to anything but /ws have no other takers here — hang up. | |
| 61 | + | server.on('upgrade', (req, socket) => { | |
| 62 | + | if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/ws') socket.destroy(); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | server.listen(PORT, HOST, async () => { | |
| 66 | + | const built = await stat(join(DIST, 'index.html')).catch(() => null); | |
| 67 | + | console.log(`mahjong on http://${HOST}:${PORT} — rooms on /ws`); | |
| 68 | + | if (!built) console.log('dist/ is empty — run `npm run build` first'); | |
| 69 | + | }); |
addedserver/net.test.mjs+95 −0
| 1 | + | import { createServer } from 'node:http'; | |
| 2 | + | import { afterAll, beforeAll, expect, test } from 'vitest'; | |
| 3 | + | import { joinRoom } from '../src/net/room.ts'; | |
| 4 | + | import { attachRooms } from './rooms.ts'; | |
| 5 | + | ||
| 6 | + | // The real client against the real relay, in one process. Plain .mjs on | |
| 7 | + | // purpose: the client is typed for the browser and the relay for node, and | |
| 8 | + | // neither tsconfig wants to swallow the other — vitest transforms both. | |
| 9 | + | ||
| 10 | + | let server; | |
| 11 | + | let url; | |
| 12 | + | const opened = []; | |
| 13 | + | ||
| 14 | + | beforeAll(async () => { | |
| 15 | + | server = createServer(); | |
| 16 | + | attachRooms(server); | |
| 17 | + | await new Promise((r) => server.listen(0, '127.0.0.1', r)); | |
| 18 | + | url = `ws://127.0.0.1:${server.address().port}/ws`; | |
| 19 | + | }); | |
| 20 | + | afterAll(async () => { | |
| 21 | + | for (const r of opened) r.leave(); | |
| 22 | + | await new Promise((r) => server.close(r)); | |
| 23 | + | }); | |
| 24 | + | ||
| 25 | + | const join = async (roomCode) => { | |
| 26 | + | const r = await joinRoom({ room: roomCode, server: url }); | |
| 27 | + | opened.push(r); | |
| 28 | + | return r; | |
| 29 | + | }; | |
| 30 | + | const next = (sub) => new Promise((r) => { | |
| 31 | + | const un = sub((...args) => { | |
| 32 | + | un(); | |
| 33 | + | r(args); | |
| 34 | + | }); | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test('a full little room: join, state, events, migration', async () => { | |
| 38 | + | const a = await join(); | |
| 39 | + | expect(a.code).toMatch(/^[A-Z2-9]{6}$/); | |
| 40 | + | expect(a.isHost).toBe(true); | |
| 41 | + | expect(a.me.name).toBe('Guest 1'); | |
| 42 | + | ||
| 43 | + | const joinSeen = next((cb) => a.onJoin(cb)); | |
| 44 | + | const b = await join(a.code); | |
| 45 | + | expect(b.code).toBe(a.code); | |
| 46 | + | expect(b.isHost).toBe(false); | |
| 47 | + | expect(b.players.length).toBe(2); | |
| 48 | + | expect((await joinSeen)[0].id).toBe(b.me.id); | |
| 49 | + | ||
| 50 | + | // Host writes, mirror reads. | |
| 51 | + | const stateSeen = next((cb) => b.onState(cb)); | |
| 52 | + | a.setState({ mode: 'party', game: { turn: 2 } }); | |
| 53 | + | await stateSeen; | |
| 54 | + | expect(b.state.mode).toBe('party'); | |
| 55 | + | expect(b.state.game).toEqual({ turn: 2 }); | |
| 56 | + | ||
| 57 | + | // Only the host writes. | |
| 58 | + | expect(() => b.setState({ evil: true })).toThrow(); | |
| 59 | + | ||
| 60 | + | // Events go the other way, stamped with the sender. | |
| 61 | + | const actSeen = next((cb) => a.on('act', (payload, from) => cb(payload, from))); | |
| 62 | + | b.send('act', { seat: 1 }); | |
| 63 | + | const [payload, from] = await actSeen; | |
| 64 | + | expect(payload).toEqual({ seat: 1 }); | |
| 65 | + | expect(from).toBe(b.me.id); | |
| 66 | + | ||
| 67 | + | // The host leaves; the room passes to the next in line, state intact. | |
| 68 | + | const leaveSeen = next((cb) => b.onLeave(cb)); | |
| 69 | + | const hostSeen = next((cb) => b.onHostChange(cb)); | |
| 70 | + | a.leave(); | |
| 71 | + | expect((await leaveSeen)[0].id).toBe(a.me.id); | |
| 72 | + | await hostSeen; | |
| 73 | + | expect(b.isHost).toBe(true); | |
| 74 | + | expect(b.state.mode).toBe('party'); | |
| 75 | + | b.setState({ mode: 'party', n: 1 }); // and the crown is real | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test('a late joiner walks in on the state as it stands', async () => { | |
| 79 | + | const a = await join(); | |
| 80 | + | a.setState({ mode: 'online', seats: [1, 2, 3, 4] }); | |
| 81 | + | // The relay applies the (microtask-coalesced) write before answering a | |
| 82 | + | // later hello, both being one client's messages in order — but ours is a | |
| 83 | + | // second connection, so give the flush a beat. | |
| 84 | + | await new Promise((r) => setTimeout(r, 50)); | |
| 85 | + | const b = await join(a.code); | |
| 86 | + | expect(b.state.mode).toBe('online'); | |
| 87 | + | expect(b.state.seats).toEqual([1, 2, 3, 4]); | |
| 88 | + | }); | |
| 89 | + | ||
| 90 | + | test('an unknown code becomes a fresh room, not an error', async () => { | |
| 91 | + | const a = await join('REBORN'); | |
| 92 | + | expect(a.code).toBe('REBORN'); | |
| 93 | + | expect(a.isHost).toBe(true); | |
| 94 | + | expect(a.state).toEqual({}); | |
| 95 | + | }); |
addedserver/rooms.ts+232 −0
| 1 | + | import type { IncomingMessage } from 'node:http'; | |
| 2 | + | import type { Duplex } from 'node:stream'; | |
| 3 | + | import { WebSocketServer, type WebSocket } from 'ws'; | |
| 4 | + | ||
| 5 | + | /** | |
| 6 | + | * The room relay: everything the game needs from a server, which is very | |
| 7 | + | * little. Rooms hold members in join order; the earliest one still connected | |
| 8 | + | * is the host. The relay never reads the game — it keeps one bag of shared | |
| 9 | + | * state that only the host may write, fans out patches and events to everyone | |
| 10 | + | * else, and announces joins, leaves, and host changes. All the mahjong lives | |
| 11 | + | * in the clients (src/net/), exactly as it did on antics. | |
| 12 | + | * | |
| 13 | + | * Attachable to any http.Server — vite's dev server in development | |
| 14 | + | * (vite.config.ts), our own static server in production (server/index.ts) — | |
| 15 | + | * so the client can always reach it at wss://<same-origin>/ws. | |
| 16 | + | * | |
| 17 | + | * Identity is client-chosen: a device sends its own id (a UUID it keeps in | |
| 18 | + | * sessionStorage) and the relay takes it at its word. That is what lets a | |
| 19 | + | * reload or a wifi blip land back in the same seat — the seat map in shared | |
| 20 | + | * state is keyed on these ids. No auth; this is a home server for a table of | |
| 21 | + | * friends, and fairness at this table is social anyway (see README). | |
| 22 | + | */ | |
| 23 | + | ||
| 24 | + | interface Member { | |
| 25 | + | id: string; | |
| 26 | + | name: string; | |
| 27 | + | ws: WebSocket; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | interface RoomRec { | |
| 31 | + | code: string; | |
| 32 | + | members: Map<string, Member>; | |
| 33 | + | /** Join order. The first id still present is the host. */ | |
| 34 | + | order: string[]; | |
| 35 | + | state: Record<string, unknown>; | |
| 36 | + | /** How many "Guest N" names this room has handed out. */ | |
| 37 | + | guests: number; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /** What a client may say. `hello` must come first; the rest need a room. */ | |
| 41 | + | type ClientMsg = | |
| 42 | + | | { t: 'hello'; id?: string; room?: string; name?: string } | |
| 43 | + | | { t: 'set'; patch?: Record<string, unknown> } | |
| 44 | + | | { t: 'ev'; type?: string; payload?: unknown }; | |
| 45 | + | ||
| 46 | + | /** No 0/O/1/I/L: these codes get read aloud across a table. */ | |
| 47 | + | const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; | |
| 48 | + | const PING_MS = 30_000; | |
| 49 | + | ||
| 50 | + | function newCode(taken: (code: string) => boolean): string { | |
| 51 | + | for (;;) { | |
| 52 | + | let code = ''; | |
| 53 | + | for (let i = 0; i < 6; i++) | |
| 54 | + | code += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)]; | |
| 55 | + | if (!taken(code)) return code; | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /** Just the two events we hang off a server — http and http2 servers both | |
| 60 | + | * fit, which is what lets the relay ride vite's as easily as our own. */ | |
| 61 | + | export interface UpgradeServer { | |
| 62 | + | on(event: 'upgrade', cb: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown; | |
| 63 | + | on(event: 'close', cb: () => void): unknown; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | export function attachRooms(server: UpgradeServer, path = '/ws'): void { | |
| 67 | + | const rooms = new Map<string, RoomRec>(); | |
| 68 | + | const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 }); | |
| 69 | + | ||
| 70 | + | server.on('upgrade', (req, socket, head) => { | |
| 71 | + | let pathname: string; | |
| 72 | + | try { | |
| 73 | + | pathname = new URL(req.url ?? '/', 'http://localhost').pathname; | |
| 74 | + | } catch { | |
| 75 | + | socket.destroy(); | |
| 76 | + | return; | |
| 77 | + | } | |
| 78 | + | // Not ours (vite's HMR socket, say) — leave it for whoever else listens. | |
| 79 | + | if (pathname !== path) return; | |
| 80 | + | wss.handleUpgrade(req, socket, head, (ws) => handle(ws)); | |
| 81 | + | }); | |
| 82 | + | ||
| 83 | + | // Dead-connection sweep: ws's usual isAlive/ping dance. A phone that fell | |
| 84 | + | // off the wifi closes nothing; this is what finally vacates its seat. | |
| 85 | + | const alive = new WeakMap<WebSocket, boolean>(); | |
| 86 | + | const sweep = setInterval(() => { | |
| 87 | + | for (const ws of wss.clients) { | |
| 88 | + | if (alive.get(ws) === false) { | |
| 89 | + | ws.terminate(); | |
| 90 | + | continue; | |
| 91 | + | } | |
| 92 | + | alive.set(ws, false); | |
| 93 | + | ws.ping(); | |
| 94 | + | } | |
| 95 | + | }, PING_MS); | |
| 96 | + | server.on('close', () => { | |
| 97 | + | clearInterval(sweep); | |
| 98 | + | wss.close(); | |
| 99 | + | }); | |
| 100 | + | ||
| 101 | + | const send = (ws: WebSocket, msg: unknown) => { | |
| 102 | + | if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg)); | |
| 103 | + | }; | |
| 104 | + | const broadcast = (room: RoomRec, msg: unknown, except?: string) => { | |
| 105 | + | for (const m of room.members.values()) if (m.id !== except) send(m.ws, msg); | |
| 106 | + | }; | |
| 107 | + | const hostOf = (room: RoomRec) => room.order[0] ?? ''; | |
| 108 | + | const roster = (room: RoomRec) => | |
| 109 | + | room.order.map((id) => { | |
| 110 | + | const m = room.members.get(id)!; | |
| 111 | + | return { id: m.id, name: m.name }; | |
| 112 | + | }); | |
| 113 | + | ||
| 114 | + | function handle(ws: WebSocket) { | |
| 115 | + | let room: RoomRec | null = null; | |
| 116 | + | let me: Member | null = null; | |
| 117 | + | alive.set(ws, true); | |
| 118 | + | ws.on('pong', () => alive.set(ws, true)); | |
| 119 | + | ws.on('error', () => {}); | |
| 120 | + | ||
| 121 | + | ws.on('message', (data) => { | |
| 122 | + | let msg: ClientMsg; | |
| 123 | + | try { | |
| 124 | + | msg = JSON.parse(String(data)); | |
| 125 | + | } catch { | |
| 126 | + | return send(ws, { | |
| 127 | + | t: 'err', | |
| 128 | + | code: 'BAD_MESSAGE', | |
| 129 | + | message: 'not JSON', | |
| 130 | + | hint: 'every frame is one JSON object', | |
| 131 | + | }); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | if (msg.t === 'hello') { | |
| 135 | + | if (room) return; // one hello per connection | |
| 136 | + | if (typeof msg.id !== 'string' || !msg.id) { | |
| 137 | + | return send(ws, { | |
| 138 | + | t: 'err', | |
| 139 | + | code: 'BAD_MESSAGE', | |
| 140 | + | message: 'hello without an id', | |
| 141 | + | hint: 'send { t: "hello", id: <your uuid>, room?: <code> }', | |
| 142 | + | }); | |
| 143 | + | } | |
| 144 | + | // Join by code, creating the room if it does not exist — a code | |
| 145 | + | // nobody knows is a room nobody made, and recreating on join is what | |
| 146 | + | // lets a table pick itself back up after the server restarts. | |
| 147 | + | const code = | |
| 148 | + | typeof msg.room === 'string' && msg.room | |
| 149 | + | ? msg.room.toUpperCase() | |
| 150 | + | : newCode((c) => rooms.has(c)); | |
| 151 | + | let r = rooms.get(code); | |
| 152 | + | if (!r) { | |
| 153 | + | r = { code, members: new Map(), order: [], state: {}, guests: 0 }; | |
| 154 | + | rooms.set(code, r); | |
| 155 | + | } | |
| 156 | + | room = r; | |
| 157 | + | ||
| 158 | + | const existing = r.members.get(msg.id); | |
| 159 | + | if (existing) { | |
| 160 | + | // The same device again — a reconnect, or a zombie socket it gave | |
| 161 | + | // up on. The new socket takes over; the old one's close handler | |
| 162 | + | // sees it no longer speaks for the member and stays quiet. | |
| 163 | + | const old = existing.ws; | |
| 164 | + | existing.ws = ws; | |
| 165 | + | me = existing; | |
| 166 | + | if (old !== ws && old.readyState === old.OPEN) old.close(4001, 'replaced'); | |
| 167 | + | } else { | |
| 168 | + | const name = | |
| 169 | + | typeof msg.name === 'string' && msg.name ? msg.name : `Guest ${++r.guests}`; | |
| 170 | + | me = { id: msg.id, name, ws }; | |
| 171 | + | r.members.set(me.id, me); | |
| 172 | + | r.order.push(me.id); | |
| 173 | + | broadcast(r, { t: 'join', player: { id: me.id, name: me.name } }, me.id); | |
| 174 | + | } | |
| 175 | + | return send(ws, { | |
| 176 | + | t: 'joined', | |
| 177 | + | code: r.code, | |
| 178 | + | self: { id: me.id, name: me.name }, | |
| 179 | + | hostId: hostOf(r), | |
| 180 | + | players: roster(r), | |
| 181 | + | state: r.state, | |
| 182 | + | }); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | if (!room || !me) { | |
| 186 | + | return send(ws, { | |
| 187 | + | t: 'err', | |
| 188 | + | code: 'NOT_JOINED', | |
| 189 | + | message: 'say hello first', | |
| 190 | + | hint: 'the first frame on a connection is { t: "hello", ... }', | |
| 191 | + | }); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | switch (msg.t) { | |
| 195 | + | case 'set': { | |
| 196 | + | if (me.id !== hostOf(room)) { | |
| 197 | + | return send(ws, { | |
| 198 | + | t: 'err', | |
| 199 | + | code: 'NOT_HOST', | |
| 200 | + | message: 'only the host writes room state', | |
| 201 | + | hint: 'send an event and let the host do the writing', | |
| 202 | + | }); | |
| 203 | + | } | |
| 204 | + | if (!msg.patch || typeof msg.patch !== 'object') return; | |
| 205 | + | Object.assign(room.state, msg.patch); | |
| 206 | + | broadcast(room, { t: 'set', patch: msg.patch }, me.id); | |
| 207 | + | return; | |
| 208 | + | } | |
| 209 | + | case 'ev': { | |
| 210 | + | if (typeof msg.type !== 'string') return; | |
| 211 | + | broadcast(room, { t: 'ev', type: msg.type, payload: msg.payload, from: me.id }, me.id); | |
| 212 | + | return; | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | }); | |
| 216 | + | ||
| 217 | + | ws.on('close', () => { | |
| 218 | + | if (!room || !me) return; | |
| 219 | + | // A replaced socket no longer speaks for the member. | |
| 220 | + | if (room.members.get(me.id)?.ws !== ws) return; | |
| 221 | + | const wasHost = hostOf(room) === me.id; | |
| 222 | + | room.members.delete(me.id); | |
| 223 | + | room.order = room.order.filter((id) => id !== me!.id); | |
| 224 | + | if (room.members.size === 0) { | |
| 225 | + | rooms.delete(room.code); | |
| 226 | + | return; | |
| 227 | + | } | |
| 228 | + | broadcast(room, { t: 'leave', id: me.id }); | |
| 229 | + | if (wasHost) broadcast(room, { t: 'host', id: hostOf(room) }); | |
| 230 | + | }); | |
| 231 | + | } | |
| 232 | + | } |
modifiedsrc/App.tsx+3 −3
| ⋯ 27 unchanged lines | |||
| 28 | 28 | const SOLO_SEATS = [false, true, true, true]; | |
| 29 | 29 | const HOTSEAT_SEATS = [false, false, false, false]; | |
| 30 | 30 | ||
| 31 | - | // One join per page, whoever asks first — the lobby button, or the ?room= the | |
| 32 | - | // antics page hands us. A promise rather than a flag, so a second caller waits | |
| 31 | + | // One join per page, whoever asks first — the lobby button, or the ?room= a | |
| 32 | + | // shared link carries. A promise rather than a flag, so a second caller waits | |
| 33 | 33 | // on the same join instead of starting another. | |
| 34 | 34 | let joining: Promise<NetSession> | null = null; | |
| 35 | 35 | const joinOnce = () => (joining ??= NetSession.join()); | |
| ⋯ 47 unchanged lines | |||
| 83 | 83 | } | |
| 84 | 84 | }; | |
| 85 | 85 | ||
| 86 | - | // Opened from a room link — the antics page passes ?room= down to us — so | |
| 86 | + | // Opened from a room link — ?room= in the address — so | |
| 87 | 87 | // everyone on that link shares one table. Join it before anyone taps | |
| 88 | 88 | // anything: presence is the first thing a shared room owes its visitors. | |
| 89 | 89 | const auto = useRef(false); | |
| ⋯ 329 unchanged lines | |||
modifiedsrc/game/sound.ts+2 −3
| ⋯ 93 unchanged lines | |||
| 94 | 94 | function fetchVoicePack(): Promise<void> { | |
| 95 | 95 | if (fetching) return fetching; | |
| 96 | 96 | fetching = (async () => { | |
| 97 | - | // Deployed, the whole pack rides in one file — an antics bundle may hold | |
| 98 | - | // at most 50 files, and the voice alone is more than that loose. In dev | |
| 99 | - | // the loose mp3s are still there and the fallback below takes them. | |
| 97 | + | // A host that packed the whole voice into one file (voice/pack.json) | |
| 98 | + | // gets it in one request; the loose mp3s below are the normal path. | |
| 100 | 99 | const packed: Record<string, string> | null = await fetch(`${VOICE_DIR}pack.json`) | |
| 101 | 100 | .then((r) => (r.ok ? r.json() : null)) | |
| 102 | 101 | .catch(() => null); | |
| ⋯ 303 unchanged lines | |||
deletedsrc/net/clock.ts+0 −58
| 1 | - | /** | |
| 2 | - | * Keep the room alive while this tab is hidden. | |
| 3 | - | * | |
| 4 | - | * The antics SDK runs its loops — the state flush above all — off | |
| 5 | - | * requestAnimationFrame, which a browser suspends the moment a tab stops being | |
| 6 | - | * visible. For most games that is right: a hidden player is not playing. The | |
| 7 | - | * party table is different — the common screen is the room's host, and a host | |
| 8 | - | * that stops flushing takes the whole room with it: a phone's seat claim is | |
| 9 | - | * processed but never announced, so the phone sits on "pick a seat" forever | |
| 10 | - | * while the answer waits in a queue for the laptop to be looked at. | |
| 11 | - | * | |
| 12 | - | * The SDK provides for exactly this: anything on `globalThis.__anticsClock__` | |
| 13 | - | * replaces its own timers, resolved when each loop starts. So the ticks come | |
| 14 | - | * from a Web Worker instead — worker timers are not throttled in background | |
| 15 | - | * tabs — and the host keeps hosting whether or not its tab is front. | |
| 16 | - | * | |
| 17 | - | * Installed before joinRoom, once. The game's own render loops are untouched: | |
| 18 | - | * they use the real requestAnimationFrame and are happy to sleep with the tab. | |
| 19 | - | */ | |
| 20 | - | export function installBackgroundClock() { | |
| 21 | - | const g = globalThis as unknown as Record<string, unknown>; | |
| 22 | - | if (g.__anticsClock__) return; | |
| 23 | - | ||
| 24 | - | let worker: Worker | null = null; | |
| 25 | - | try { | |
| 26 | - | const src = URL.createObjectURL( | |
| 27 | - | new Blob(['setInterval(() => postMessage(0), 66);'], { type: 'text/javascript' }), | |
| 28 | - | ); | |
| 29 | - | worker = new Worker(src); | |
| 30 | - | URL.revokeObjectURL(src); | |
| 31 | - | } catch { | |
| 32 | - | // A sandbox that refuses workers keeps the SDK's own timers — no worse | |
| 33 | - | // than before. | |
| 34 | - | return; | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | const callbacks = new Map<number, (t: number) => void>(); | |
| 38 | - | let nextId = 1; | |
| 39 | - | worker.onmessage = () => { | |
| 40 | - | if (callbacks.size === 0) return; | |
| 41 | - | const now = performance.now(); | |
| 42 | - | const due = [...callbacks.values()]; | |
| 43 | - | callbacks.clear(); | |
| 44 | - | for (const cb of due) cb(now); | |
| 45 | - | }; | |
| 46 | - | ||
| 47 | - | g.__anticsClock__ = { | |
| 48 | - | now: () => Date.now(), | |
| 49 | - | requestAnimationFrame(cb: (t: number) => void): number { | |
| 50 | - | const id = nextId++; | |
| 51 | - | callbacks.set(id, cb); | |
| 52 | - | return id; | |
| 53 | - | }, | |
| 54 | - | cancelAnimationFrame(id: number) { | |
| 55 | - | callbacks.delete(id); | |
| 56 | - | }, | |
| 57 | - | }; | |
| 58 | - | } |
modifiedsrc/net/protocol.ts+8 −9
| ⋯ 5 unchanged lines | |||
| 6 | 6 | /** | |
| 7 | 7 | * What goes over the wire, in one place. | |
| 8 | 8 | * | |
| 9 | - | * The room is host-authoritative: exactly one client — whichever one antics | |
| 10 | - | * says is the host — runs the real engine, and everything it decides is | |
| 9 | + | * The room is host-authoritative: exactly one client — whichever one the | |
| 10 | + | * relay says is the host — runs the real engine, and everything it decides is | |
| 11 | 11 | * published as plain `GameState` JSON under one key of the shared room state. | |
| 12 | 12 | * Everyone else renders that and asks for things by sending intents, which the | |
| 13 | 13 | * host validates against the seat map before playing them through the same | |
| ⋯ 9 unchanged lines | |||
| 23 | 23 | * Who holds a seat. A list, not a single id: a seat is a hand, and nothing | |
| 24 | 24 | * stops two people huddling over one hand — a pair playing together from two | |
| 25 | 25 | * phones simply both hold it, and whoever acts first acts. The name outlives | |
| 26 | - | * every connection — see the antics docs on leavers: record it where it | |
| 27 | - | * arrives, the match outlives the connection. | |
| 26 | + | * every connection: record it where it arrives, the match outlives the | |
| 27 | + | * connection. | |
| 28 | 28 | */ | |
| 29 | 29 | export interface SeatSlot { | |
| 30 | - | /** antics player ids of everyone holding this hand. */ | |
| 30 | + | /** Player ids of everyone holding this hand. */ | |
| 31 | 31 | ids: string[]; | |
| 32 | 32 | name: string; | |
| 33 | 33 | } | |
| ⋯ 59 unchanged lines | |||
| 93 | 93 | } | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | - | /** Whether the page arrived with a room to join — i.e. we are on antics. */ | |
| 96 | + | /** The room in the page URL — a shared link, or our own join writing it back. */ | |
| 97 | 97 | export function roomFromUrl(): string | null { | |
| 98 | 98 | try { | |
| 99 | 99 | return new URLSearchParams(window.location.search).get('room'); | |
| ⋯ 7 unchanged lines | |||
| 107 | 107 | * query. No seat — a phone that scans it lands on the seat picker, which is | |
| 108 | 108 | * also what lets a pair deliberately share a hand. (An old link with a `seat` | |
| 109 | 109 | * param still works: seatFromUrl above keeps reading it.) The page's own URL | |
| 110 | - | * (the raw bundle on antics, the dev server at home) serves the game directly | |
| 111 | - | * and keeps every param, which on a phone also means the whole screen belongs | |
| 112 | - | * to the hand. | |
| 110 | + | * serves the game directly and keeps every param, which on a phone also means | |
| 111 | + | * the whole screen belongs to the hand. | |
| 113 | 112 | */ | |
| 114 | 113 | export function roomLink(code: string): string { | |
| 115 | 114 | const u = new URL(window.location.href); | |
| ⋯ 4 unchanged lines | |||
addedsrc/net/room.ts+308 −0
| 1 | + | /** | |
| 2 | + | * The client side of our own room relay (server/rooms.ts), wearing the same | |
| 3 | + | * face as the sliver of the antics SDK the game used — code, me, isHost, | |
| 4 | + | * state/setState, send/on, the join/leave/host/error subscriptions — so | |
| 5 | + | * session.ts barely noticed the ejection. What the relay does not do for us | |
| 6 | + | * anymore we do here: reconnect with backoff, and rejoin as the same id. | |
| 7 | + | * | |
| 8 | + | * Identity is a UUID kept in sessionStorage: per tab, surviving reloads. The | |
| 9 | + | * shared seat map is keyed on it, so a reload or a wifi blip walks back into | |
| 10 | + | * its own seat. State writes coalesce per microtask; events go at once, so an | |
| 11 | + | * fx sent before a state change still arrives before it. | |
| 12 | + | */ | |
| 13 | + | ||
| 14 | + | export interface PlayerInfo { | |
| 15 | + | id: string; | |
| 16 | + | name: string; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | export interface RoomError { | |
| 20 | + | code: string; | |
| 21 | + | message: string; | |
| 22 | + | hint: string; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export interface JoinOptions { | |
| 26 | + | /** Room code to join; omit to create a fresh room. */ | |
| 27 | + | room?: string; | |
| 28 | + | /** ws(s):// endpoint override (tests). Defaults to wss://<same-origin>/ws. */ | |
| 29 | + | server?: string; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type Unsub = () => void; | |
| 33 | + | ||
| 34 | + | type ServerMsg = | |
| 35 | + | | { | |
| 36 | + | t: 'joined'; | |
| 37 | + | code: string; | |
| 38 | + | self: PlayerInfo; | |
| 39 | + | hostId: string; | |
| 40 | + | players: PlayerInfo[]; | |
| 41 | + | state: Record<string, unknown>; | |
| 42 | + | } | |
| 43 | + | | { t: 'join'; player: PlayerInfo } | |
| 44 | + | | { t: 'leave'; id: string } | |
| 45 | + | | { t: 'host'; id: string } | |
| 46 | + | | { t: 'set'; patch: Record<string, unknown> } | |
| 47 | + | | { t: 'ev'; type: string; payload: unknown; from: string } | |
| 48 | + | | { t: 'err'; code: string; message: string; hint: string }; | |
| 49 | + | ||
| 50 | + | const BASE_BACKOFF = 500; | |
| 51 | + | const MAX_BACKOFF = 10_000; | |
| 52 | + | const JOIN_TIMEOUT = 15_000; | |
| 53 | + | ||
| 54 | + | function endpoint(server?: string): string { | |
| 55 | + | if (server) return server; | |
| 56 | + | const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| 57 | + | return `${proto}//${window.location.host}/ws`; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | function myId(): string { | |
| 61 | + | try { | |
| 62 | + | const key = 'mahjong-net-id'; | |
| 63 | + | let id = sessionStorage.getItem(key); | |
| 64 | + | if (!id) { | |
| 65 | + | id = crypto.randomUUID(); | |
| 66 | + | sessionStorage.setItem(key, id); | |
| 67 | + | } | |
| 68 | + | return id; | |
| 69 | + | } catch { | |
| 70 | + | return crypto.randomUUID(); | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | export class Room { | |
| 75 | + | code = ''; | |
| 76 | + | me: PlayerInfo = { id: myId(), name: '' }; | |
| 77 | + | state: Record<string, unknown> = {}; | |
| 78 | + | players: PlayerInfo[] = []; | |
| 79 | + | ||
| 80 | + | private hostId = ''; | |
| 81 | + | private url: string; | |
| 82 | + | private ws: WebSocket | null = null; | |
| 83 | + | private joined = false; | |
| 84 | + | private left = false; | |
| 85 | + | private attempts = 0; | |
| 86 | + | private retryTimer: ReturnType<typeof setTimeout> | null = null; | |
| 87 | + | private pendingPatch: Record<string, unknown> | null = null; | |
| 88 | + | private flushQueued = false; | |
| 89 | + | private resolveJoin: ((r: Room) => void) | null = null; | |
| 90 | + | private rejectJoin: ((e: Error) => void) | null = null; | |
| 91 | + | ||
| 92 | + | private stateCbs = new Set<(state: Record<string, unknown>, from: PlayerInfo | null) => void>(); | |
| 93 | + | private joinCbs = new Set<(p: PlayerInfo) => void>(); | |
| 94 | + | private leaveCbs = new Set<(p: PlayerInfo) => void>(); | |
| 95 | + | private hostCbs = new Set<(host: PlayerInfo | null) => void>(); | |
| 96 | + | private errorCbs = new Set<(e: RoomError) => void>(); | |
| 97 | + | private evCbs = new Map<string, Set<(payload: unknown, from: string) => void>>(); | |
| 98 | + | ||
| 99 | + | private wantRoom: string | undefined; | |
| 100 | + | ||
| 101 | + | private constructor(wantRoom: string | undefined, server?: string) { | |
| 102 | + | this.wantRoom = wantRoom; | |
| 103 | + | this.url = endpoint(server); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /** Connect and resolve once the relay confirms the join with full state. */ | |
| 107 | + | static join(opts: JoinOptions = {}): Promise<Room> { | |
| 108 | + | const room = new Room(opts.room, opts.server); | |
| 109 | + | return new Promise<Room>((resolve, reject) => { | |
| 110 | + | room.resolveJoin = resolve; | |
| 111 | + | room.rejectJoin = reject; | |
| 112 | + | const deadline = setTimeout(() => { | |
| 113 | + | room.leave(); | |
| 114 | + | room.rejectJoin?.(new Error('連不上伺服器 could not reach the room server')); | |
| 115 | + | room.rejectJoin = null; | |
| 116 | + | }, JOIN_TIMEOUT); | |
| 117 | + | room.resolveJoin = (r) => { | |
| 118 | + | clearTimeout(deadline); | |
| 119 | + | resolve(r); | |
| 120 | + | }; | |
| 121 | + | room.connect(); | |
| 122 | + | }); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | get isHost(): boolean { | |
| 126 | + | return this.hostId !== '' && this.hostId === this.me.id; | |
| 127 | + | } | |
| 128 | + | get host(): PlayerInfo | null { | |
| 129 | + | return this.player(this.hostId); | |
| 130 | + | } | |
| 131 | + | player(id: string): PlayerInfo | null { | |
| 132 | + | return this.players.find((p) => p.id === id) ?? null; | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /** Write shared state (host only): apply locally at once, flush coalesced. */ | |
| 136 | + | setState(patch: Record<string, unknown>) { | |
| 137 | + | if (!this.isHost) { | |
| 138 | + | const e = new Error('only the host writes room state') as Error & RoomError; | |
| 139 | + | e.code = 'NOT_HOST'; | |
| 140 | + | e.hint = 'send an event and let the host do the writing'; | |
| 141 | + | throw e; | |
| 142 | + | } | |
| 143 | + | Object.assign(this.state, patch); | |
| 144 | + | this.pendingPatch = Object.assign(this.pendingPatch ?? {}, patch); | |
| 145 | + | if (!this.flushQueued) { | |
| 146 | + | this.flushQueued = true; | |
| 147 | + | queueMicrotask(() => this.flush()); | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /** Send an event to everyone else. Dropped while disconnected — an act lost | |
| 152 | + | * to a blip is retried by the person, not a queue. */ | |
| 153 | + | send(type: string, payload?: unknown) { | |
| 154 | + | this.post({ t: 'ev', type, payload }); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | leave() { | |
| 158 | + | this.left = true; | |
| 159 | + | if (this.retryTimer) clearTimeout(this.retryTimer); | |
| 160 | + | this.retryTimer = null; | |
| 161 | + | this.ws?.close(1000); | |
| 162 | + | this.ws = null; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | onState(cb: (state: Record<string, unknown>, from: PlayerInfo | null) => void): Unsub { | |
| 166 | + | return sub(this.stateCbs, cb); | |
| 167 | + | } | |
| 168 | + | onJoin(cb: (p: PlayerInfo) => void): Unsub { | |
| 169 | + | return sub(this.joinCbs, cb); | |
| 170 | + | } | |
| 171 | + | onLeave(cb: (p: PlayerInfo) => void): Unsub { | |
| 172 | + | return sub(this.leaveCbs, cb); | |
| 173 | + | } | |
| 174 | + | onHostChange(cb: (host: PlayerInfo | null) => void): Unsub { | |
| 175 | + | return sub(this.hostCbs, cb); | |
| 176 | + | } | |
| 177 | + | onError(cb: (e: RoomError) => void): Unsub { | |
| 178 | + | return sub(this.errorCbs, cb); | |
| 179 | + | } | |
| 180 | + | on(type: string, cb: (payload: unknown, from: string) => void): Unsub { | |
| 181 | + | let set = this.evCbs.get(type); | |
| 182 | + | if (!set) this.evCbs.set(type, (set = new Set())); | |
| 183 | + | return sub(set, cb); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // ---- the wire ---------------------------------------------------------- | |
| 187 | + | private connect() { | |
| 188 | + | if (this.left) return; | |
| 189 | + | const ws = new WebSocket(this.url); | |
| 190 | + | this.ws = ws; | |
| 191 | + | ws.onopen = () => { | |
| 192 | + | this.post({ t: 'hello', id: this.me.id, room: this.code || this.wantRoom }); | |
| 193 | + | }; | |
| 194 | + | ws.onmessage = (e) => { | |
| 195 | + | let msg: ServerMsg; | |
| 196 | + | try { | |
| 197 | + | msg = JSON.parse(String(e.data)); | |
| 198 | + | } catch { | |
| 199 | + | return; | |
| 200 | + | } | |
| 201 | + | this.dispatch(msg); | |
| 202 | + | }; | |
| 203 | + | ws.onclose = () => { | |
| 204 | + | if (this.ws !== ws || this.left) return; | |
| 205 | + | this.ws = null; | |
| 206 | + | this.emitError({ | |
| 207 | + | code: 'DISCONNECTED', | |
| 208 | + | message: '連線中斷 connection lost', | |
| 209 | + | hint: 'reconnecting…', | |
| 210 | + | }); | |
| 211 | + | const delay = | |
| 212 | + | Math.min(MAX_BACKOFF, BASE_BACKOFF * 2 ** this.attempts++) * (0.75 + Math.random() * 0.5); | |
| 213 | + | this.retryTimer = setTimeout(() => this.connect(), delay); | |
| 214 | + | }; | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | private post(msg: unknown) { | |
| 218 | + | if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg)); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | private flush() { | |
| 222 | + | this.flushQueued = false; | |
| 223 | + | const patch = this.pendingPatch; | |
| 224 | + | if (!patch || this.ws?.readyState !== WebSocket.OPEN) return; | |
| 225 | + | this.pendingPatch = null; | |
| 226 | + | this.post({ t: 'set', patch }); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | private dispatch(msg: ServerMsg) { | |
| 230 | + | switch (msg.t) { | |
| 231 | + | case 'joined': | |
| 232 | + | return this.applyJoined(msg); | |
| 233 | + | case 'join': { | |
| 234 | + | if (!this.players.some((p) => p.id === msg.player.id)) this.players.push(msg.player); | |
| 235 | + | for (const cb of this.joinCbs) cb(msg.player); | |
| 236 | + | return; | |
| 237 | + | } | |
| 238 | + | case 'leave': { | |
| 239 | + | const p = this.player(msg.id); | |
| 240 | + | this.players = this.players.filter((q) => q.id !== msg.id); | |
| 241 | + | if (p) for (const cb of this.leaveCbs) cb(p); | |
| 242 | + | return; | |
| 243 | + | } | |
| 244 | + | case 'host': { | |
| 245 | + | this.hostId = msg.id; | |
| 246 | + | for (const cb of this.hostCbs) cb(this.host); | |
| 247 | + | return; | |
| 248 | + | } | |
| 249 | + | case 'set': { | |
| 250 | + | Object.assign(this.state, msg.patch); | |
| 251 | + | for (const cb of this.stateCbs) cb(this.state, null); | |
| 252 | + | return; | |
| 253 | + | } | |
| 254 | + | case 'ev': { | |
| 255 | + | const set = this.evCbs.get(msg.type); | |
| 256 | + | if (set) for (const cb of set) cb(msg.payload, msg.from); | |
| 257 | + | return; | |
| 258 | + | } | |
| 259 | + | case 'err': | |
| 260 | + | return this.emitError(msg); | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | private applyJoined(msg: Extract<ServerMsg, { t: 'joined' }>) { | |
| 265 | + | const first = !this.joined; | |
| 266 | + | this.joined = true; | |
| 267 | + | this.attempts = 0; | |
| 268 | + | this.pendingPatch = null; // whatever we meant to say, the room has moved on | |
| 269 | + | this.code = msg.code; | |
| 270 | + | this.me = msg.self; | |
| 271 | + | const before = this.players; | |
| 272 | + | this.players = msg.players; | |
| 273 | + | const prevHost = this.hostId; | |
| 274 | + | this.hostId = msg.hostId; | |
| 275 | + | this.state = msg.state ?? {}; | |
| 276 | + | if (first) { | |
| 277 | + | // The game's listeners are registered after join() resolves; it reads | |
| 278 | + | // the initial roster and state directly rather than being told. | |
| 279 | + | this.resolveJoin?.(this); | |
| 280 | + | this.resolveJoin = null; | |
| 281 | + | return; | |
| 282 | + | } | |
| 283 | + | // A resume: the room went on without us. Say everything that changed, in | |
| 284 | + | // the order the game expects it — state first (a host's leave handler | |
| 285 | + | // rewrites seats, and should rewrite the current ones). | |
| 286 | + | for (const cb of this.stateCbs) cb(this.state, null); | |
| 287 | + | for (const p of before) | |
| 288 | + | if (!this.players.some((q) => q.id === p.id)) for (const cb of this.leaveCbs) cb(p); | |
| 289 | + | for (const p of this.players) | |
| 290 | + | if (p.id !== this.me.id && !before.some((q) => q.id === p.id)) | |
| 291 | + | for (const cb of this.joinCbs) cb(p); | |
| 292 | + | if (prevHost !== this.hostId) for (const cb of this.hostCbs) cb(this.host); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | private emitError(e: RoomError) { | |
| 296 | + | if (this.errorCbs.size === 0) console.warn(`[room] ${e.code}: ${e.message} — ${e.hint}`); | |
| 297 | + | for (const cb of this.errorCbs) cb(e); | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | function sub<T>(set: Set<T>, cb: T): Unsub { | |
| 302 | + | set.add(cb); | |
| 303 | + | return () => { | |
| 304 | + | set.delete(cb); | |
| 305 | + | }; | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | export const joinRoom = (opts: JoinOptions = {}) => Room.join(opts); |
modifiedsrc/net/session.ts+21 −15
| 1 | - | import { joinRoom, type Room } from 'antics-sdk'; | |
| 2 | 1 | import { AutoPlay } from '../game/autoplay'; | |
| 3 | - | import { installBackgroundClock } from './clock'; | |
| 4 | 2 | import type { NetThrow } from '../game/ctl'; | |
| 5 | 3 | import type { SoundEvent } from '../game/engine'; | |
| 6 | 4 | import type { Tile } from '../game/tiles'; | |
| ⋯ 7 unchanged lines | |||
| 14 | 12 | type FxMsg, | |
| 15 | 13 | type NetMode, | |
| 16 | 14 | type SeatMsg, | |
| 15 | + | roomFromUrl, | |
| 17 | 16 | type SeatSlot, | |
| 18 | 17 | } from './protocol'; | |
| 18 | + | import { joinRoom, type Room } from './room'; | |
| 19 | 19 | import { NetTable } from './table'; | |
| 20 | 20 | ||
| 21 | 21 | const SEATS: SeatId[] = [0, 1, 2, 3]; | |
| ⋯ 1 unchanged line | |||
| 23 | 23 | /** | |
| 24 | 24 | * One device's connection to a room, and everything that follows from it. | |
| 25 | 25 | * | |
| 26 | - | * antics elects a host, and the host runs the table: the real engine, the | |
| 27 | - | * computer players, the answering of everyone else's intents. Everybody else | |
| 28 | - | * mirrors the state the host publishes. If the host leaves, antics picks a new | |
| 29 | - | * one, whose mirror is already current — it starts its own engine from what it | |
| 30 | - | * was just watching and the game carries on. | |
| 26 | + | * The relay elects a host — the earliest joiner still connected — and the host | |
| 27 | + | * runs the table: the real engine, the computer players, the answering of | |
| 28 | + | * everyone else's intents. Everybody else mirrors the state the host | |
| 29 | + | * publishes. If the host leaves, the relay picks a new one, whose mirror is | |
| 30 | + | * already current — it starts its own engine from what it was just watching | |
| 31 | + | * and the game carries on. | |
| 31 | 32 | * | |
| 32 | 33 | * `role` is this device's, not the room's: the laptop that starts a party game | |
| 33 | 34 | * is the `screen` everyone throws at; every other device is a `player`. | |
| ⋯ 30 unchanged lines | |||
| 64 | 65 | const u = this.unsubs; | |
| 65 | 66 | u.push( | |
| 66 | 67 | room.onState(() => { | |
| 68 | + | // State flowing means the connection is back, whatever it said before. | |
| 69 | + | this.err = null; | |
| 67 | 70 | this.table.adopt(this.sharedGame()); | |
| 68 | 71 | this.syncAutoplay(); | |
| 69 | 72 | this.bump(); | |
| ⋯ 43 unchanged lines | |||
| 113 | 116 | ||
| 114 | 117 | /** Join the room in the page URL, or create a fresh one. */ | |
| 115 | 118 | static async join(): Promise<NetSession> { | |
| 116 | - | // Before the SDK starts any loop: this device may end up hosting the | |
| 117 | - | // room, and a host must keep answering with its tab in the background. | |
| 118 | - | installBackgroundClock(); | |
| 119 | - | // Deployed on antics the SDK's same-origin detection is right; served from | |
| 120 | - | // anywhere else — the dev server, most of all — it would knock on a host | |
| 121 | - | // that has no room server behind it and hang, so name the real one. | |
| 122 | - | const onAntics = /(^|\.)antics\.gg$/.test(window.location.hostname); | |
| 123 | - | const room = await joinRoom(onAntics ? {} : { server: 'https://antics.gg' }); | |
| 119 | + | const room = await joinRoom({ room: roomFromUrl() ?? undefined }); | |
| 120 | + | // The room code goes into the address bar: the page's own URL becomes the | |
| 121 | + | // invite link, and a reload walks back into the same room — and, with the | |
| 122 | + | // id this device keeps, the same seat. | |
| 123 | + | try { | |
| 124 | + | const u = new URL(window.location.href); | |
| 125 | + | u.searchParams.set('room', room.code); | |
| 126 | + | window.history.replaceState(null, '', u); | |
| 127 | + | } catch { | |
| 128 | + | // A sandbox that refuses history rewrites still gets to play. | |
| 129 | + | } | |
| 124 | 130 | return new NetSession(room); | |
| 125 | 131 | } | |
| 126 | 132 | ||
| ⋯ 258 unchanged lines | |||
modifiedtsconfig.node.json+1 −1
| ⋯ 18 unchanged lines | |||
| 19 | 19 | "erasableSyntaxOnly": true, | |
| 20 | 20 | "noFallthroughCasesInSwitch": true | |
| 21 | 21 | }, | |
| 22 | - | "include": ["vite.config.ts"] | |
| 22 | + | "include": ["vite.config.ts", "server"] | |
| 23 | 23 | } | |
modifiedvite.config.ts+18 −4
| 1 | - | import { defineConfig } from 'vite' | |
| 1 | + | import { defineConfig, type Plugin } from 'vite' | |
| 2 | 2 | import react from '@vitejs/plugin-react' | |
| 3 | + | import { attachRooms } from './server/rooms.ts' | |
| 4 | + | ||
| 5 | + | // The room relay rides vite's own http server, so `npm run dev` (and | |
| 6 | + | // `npm run preview`) is the whole stack — the client finds it at /ws on the | |
| 7 | + | // same origin, exactly where server/index.ts puts it in production. | |
| 8 | + | const roomRelay = (): Plugin => ({ | |
| 9 | + | name: 'room-relay', | |
| 10 | + | configureServer(s) { | |
| 11 | + | if (s.httpServer) attachRooms(s.httpServer) | |
| 12 | + | }, | |
| 13 | + | configurePreviewServer(s) { | |
| 14 | + | if (s.httpServer) attachRooms(s.httpServer) | |
| 15 | + | }, | |
| 16 | + | }) | |
| 3 | 17 | ||
| 4 | 18 | // https://vite.dev/config/ | |
| 5 | 19 | export default defineConfig({ | |
| 6 | - | plugins: [react()], | |
| 7 | - | // Served from wherever antics puts the bundle, not from a domain root — so | |
| 8 | - | // every built URL has to be relative to the page. | |
| 20 | + | plugins: [react(), roomRelay()], | |
| 21 | + | // Served happily from under any path, not just a domain root — so every | |
| 22 | + | // built URL has to be relative to the page. | |
| 9 | 23 | base: './', | |
| 10 | 24 | // portless hands us PORT/HOST; vite doesn't read them on its own | |
| 11 | 25 | server: { | |
| ⋯ 4 unchanged lines | |||