collin/mahjong · a0adfbc3
The QR always wears rsgrok: the relay opens its own tunnel
Collin Richards · 2026-08-20 19:11 UTC · a0adfbc39dfb019da49e254fe8c3a0373508cdb9 · parent b445619e · browse files
modifiedREADME.md+7 −7
| ⋯ 220 unchanged lines | |||
| 221 | 221 | page URL — the host's included — is the invite link. | |
| 222 | 222 | ||
| 223 | 223 | A QR pointing at `localhost` is a QR only the host's machine can scan, so the | |
| 224 | - | server also looks for a public face to give the invite links: `PUBLIC_URL` if | |
| 225 | - | set, else a local ngrok agent (its API on `:4040`) with a tunnel aimed at | |
| 226 | - | this server's own port — strictly its own, one agent can carry other | |
| 227 | - | projects' tunnels too. Found either way, every QR and invite link wears that | |
| 228 | - | origin instead of the page's, rechecked every few seconds so a tunnel started | |
| 229 | - | mid-game still gets picked up on the next join. `ngrok http <port>` is all it | |
| 230 | - | takes; on a free account note the one static domain serves one app at a time. | |
| 224 | + | server always gives the invite links a public face: `PUBLIC_URL` if set, else | |
| 225 | + | it runs its own rsgrok tunnel (the house ngrok replacement) — spawned | |
| 226 | + | the moment the server knows its port, the https URL read off the tunnel-up | |
| 227 | + | line, no `:4040` inspection API (another agent may own that port). Every QR | |
| 228 | + | and invite link wears that origin instead of the page's. If the tunnel dies, | |
| 229 | + | or `rsgrok` is not on the PATH (`RSGROK_BIN` points elsewhere), links fall | |
| 230 | + | back to the page's own address and the tunnel is retried on later joins. | |
| 231 | 231 | ||
| 232 | 232 | ## On a phone | |
| 233 | 233 | ||
| ⋯ 288 unchanged lines | |||
modifiedserver/net.test.mjs+30 −35
| 1 | + | import { writeFileSync, rmSync } from 'node:fs'; | |
| 1 | 2 | import { createServer } from 'node:http'; | |
| 3 | + | import { tmpdir } from 'node:os'; | |
| 4 | + | import { join as joinPath } from 'node:path'; | |
| 2 | 5 | import { afterAll, beforeAll, expect, test } from 'vitest'; | |
| 3 | 6 | import { joinRoom } from '../src/net/room.ts'; | |
| 4 | - | import { attachRooms, pickPublicUrl } from './rooms.ts'; | |
| 7 | + | import { attachRooms } from './rooms.ts'; | |
| 5 | 8 | ||
| 6 | 9 | // The real client against the real relay, in one process. Plain .mjs on | |
| 7 | 10 | // purpose: the client is typed for the browser and the relay for node, and | |
| 8 | 11 | // neither tsconfig wants to swallow the other — vitest transforms both. | |
| 9 | 12 | ||
| 13 | + | // A fake rsgrok, so the relay's own tunnel-spawning runs for real without | |
| 14 | + | // leaving the machine: prints the tunnel-up line and then holds, like the | |
| 15 | + | // real one. Installed before any server exists — the relay spawns on listen. | |
| 16 | + | const stub = joinPath(tmpdir(), `fake-rsgrok-${process.pid}.sh`); | |
| 17 | + | writeFileSync( | |
| 18 | + | stub, | |
| 19 | + | '#!/bin/sh\necho "rsgrok: https://ours.vibe.richardscollin.com -> $2"\nexec sleep 60\n', | |
| 20 | + | { mode: 0o755 }, | |
| 21 | + | ); | |
| 22 | + | process.env.RSGROK_BIN = stub; | |
| 23 | + | ||
| 10 | 24 | let server; | |
| 11 | 25 | let url; | |
| 12 | 26 | const opened = []; | |
| ⋯ 7 unchanged lines | |||
| 20 | 34 | afterAll(async () => { | |
| 21 | 35 | for (const r of opened) r.leave(); | |
| 22 | 36 | await new Promise((r) => server.close(r)); | |
| 37 | + | delete process.env.RSGROK_BIN; | |
| 38 | + | rmSync(stub, { force: true }); | |
| 23 | 39 | }); | |
| 24 | 40 | ||
| 25 | 41 | const join = async (roomCode) => { | |
| ⋯ 68 unchanged lines | |||
| 94 | 110 | expect(a.state).toEqual({}); | |
| 95 | 111 | }); | |
| 96 | 112 | ||
| 97 | - | test('pickPublicUrl takes only an https tunnel aimed at our port', () => { | |
| 98 | - | const tunnels = { | |
| 99 | - | tunnels: [ | |
| 100 | - | { public_url: 'https://other.ngrok-free.dev', config: { addr: 'http://127.0.0.1:4061' } }, | |
| 101 | - | { public_url: 'http://ours.ngrok-free.dev', config: { addr: 'http://127.0.0.1:4510' } }, | |
| 102 | - | { public_url: 'https://ours.ngrok-free.dev', config: { addr: 'http://127.0.0.1:4510' } }, | |
| 103 | - | ], | |
| 104 | - | }; | |
| 105 | - | expect(pickPublicUrl(tunnels, 4510)).toBe('https://ours.ngrok-free.dev'); | |
| 106 | - | expect(pickPublicUrl(tunnels, 8080)).toBe(''); // nobody tunnels us — no link | |
| 107 | - | expect(pickPublicUrl(tunnels, null)).toBe(''); | |
| 108 | - | expect(pickPublicUrl({}, 4510)).toBe(''); | |
| 109 | - | expect(pickPublicUrl('nonsense', 4510)).toBe(''); | |
| 113 | + | test('the join carries the url of the tunnel the relay opened itself', async () => { | |
| 114 | + | const r = await join(); | |
| 115 | + | expect(r.publicBase).toBe('https://ours.vibe.richardscollin.com'); | |
| 110 | 116 | }); | |
| 111 | 117 | ||
| 112 | - | test('the join carries the public url of a tunnel aimed at us', async () => { | |
| 113 | - | // A fake ngrok agent: one tunnel for somebody else, one for us. | |
| 114 | - | let relayPort = 0; | |
| 115 | - | const api = createServer((_req, res) => { | |
| 116 | - | res.setHeader('content-type', 'application/json'); | |
| 117 | - | res.end( | |
| 118 | - | JSON.stringify({ | |
| 119 | - | tunnels: [ | |
| 120 | - | { public_url: 'https://other.ngrok-free.dev', config: { addr: 'http://127.0.0.1:59999' } }, | |
| 121 | - | { public_url: 'https://ours.ngrok-free.dev', config: { addr: `http://127.0.0.1:${relayPort}` } }, | |
| 122 | - | ], | |
| 123 | - | }), | |
| 124 | - | ); | |
| 125 | - | }); | |
| 126 | - | await new Promise((r) => api.listen(0, '127.0.0.1', r)); | |
| 118 | + | test('no rsgrok, no link — a join still lands, just local', async () => { | |
| 119 | + | // A "binary" that dies at once: the relay must shrug, not stall the hello. | |
| 120 | + | const dead = joinPath(tmpdir(), `dead-rsgrok-${process.pid}.sh`); | |
| 121 | + | writeFileSync(dead, '#!/bin/sh\nexit 1\n', { mode: 0o755 }); | |
| 122 | + | const was = process.env.RSGROK_BIN; | |
| 123 | + | process.env.RSGROK_BIN = dead; | |
| 127 | 124 | const relay = createServer(); | |
| 128 | 125 | attachRooms(relay); | |
| 129 | 126 | await new Promise((r) => relay.listen(0, '127.0.0.1', r)); | |
| 130 | - | relayPort = relay.address().port; | |
| 131 | - | process.env.NGROK_API = `http://127.0.0.1:${api.address().port}/api/tunnels`; | |
| 132 | 127 | try { | |
| 133 | - | const r = await joinRoom({ server: `ws://127.0.0.1:${relayPort}/ws` }); | |
| 134 | - | expect(r.publicBase).toBe('https://ours.ngrok-free.dev'); | |
| 128 | + | const r = await joinRoom({ server: `ws://127.0.0.1:${relay.address().port}/ws` }); | |
| 129 | + | expect(r.publicBase).toBe(''); | |
| 135 | 130 | r.leave(); | |
| 136 | 131 | } finally { | |
| 137 | - | delete process.env.NGROK_API; | |
| 132 | + | process.env.RSGROK_BIN = was; | |
| 133 | + | rmSync(dead, { force: true }); | |
| 138 | 134 | await new Promise((r) => relay.close(r)); | |
| 139 | - | await new Promise((r) => api.close(r)); | |
| 140 | 135 | } | |
| 141 | 136 | }); | |
| 142 | 137 | ||
| ⋯ 9 unchanged lines | |||
modifiedserver/rooms.ts+74 −38
| 1 | + | import { spawn, type ChildProcess } from 'node:child_process'; | |
| 1 | 2 | import type { IncomingMessage } from 'node:http'; | |
| 2 | 3 | import type { Duplex } from 'node:stream'; | |
| 3 | 4 | import { WebSocketServer, type WebSocket } from 'ws'; | |
| ⋯ 52 unchanged lines | |||
| 56 | 57 | } | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | - | /** Just the two events we hang off a server — http and http2 servers both | |
| 60 | + | /** Just the few events we hang off a server — http and http2 servers both | |
| 60 | 61 | * fit, which is what lets the relay ride vite's as easily as our own. */ | |
| 61 | 62 | export interface UpgradeServer { | |
| 62 | 63 | on(event: 'upgrade', cb: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown; | |
| 64 | + | on(event: 'listening', cb: () => void): unknown; | |
| 63 | 65 | on(event: 'close', cb: () => void): unknown; | |
| 64 | 66 | /** node's Server.address() when there is one — how we learn our own port. */ | |
| 65 | 67 | address?(): unknown; | |
| 66 | 68 | } | |
| 67 | 69 | ||
| 68 | 70 | /** | |
| 69 | - | * The public face of this server, if it has one: a QR pointing at localhost | |
| 70 | - | * is a QR only the host's own machine can scan. `PUBLIC_URL` names it | |
| 71 | - | * outright; otherwise we ask a local ngrok agent (its API sits on :4040) for | |
| 72 | - | * a tunnel aimed at OUR port — strictly ours, since one agent may carry | |
| 73 | - | * tunnels for other projects too. Handed to every client on join, and looked | |
| 74 | - | * up fresh every few seconds so a tunnel started after the server still gets | |
| 75 | - | * found. | |
| 71 | + | * The public face of this server: a QR pointing at localhost is a QR only the | |
| 72 | + | * host's own machine can scan. `PUBLIC_URL` names a face outright; otherwise | |
| 73 | + | * we open our own rsgrok tunnel (`RSGROK_BIN` overrides the binary) the | |
| 74 | + | * moment the server knows its port, read the https URL off the tunnel-up | |
| 75 | + | * line, and hand it to every client on join. A tunnel that dies — network | |
| 76 | + | * gone, rsgrok missing — is retried on later joins, at most once per window. | |
| 76 | 77 | */ | |
| 77 | - | export function pickPublicUrl(tunnels: unknown, port: number | null): string { | |
| 78 | - | if (port === null || typeof tunnels !== 'object' || tunnels === null) return ''; | |
| 79 | - | const list = (tunnels as { tunnels?: unknown }).tunnels; | |
| 80 | - | if (!Array.isArray(list)) return ''; | |
| 81 | - | for (const t of list) { | |
| 82 | - | const pub = (t as { public_url?: unknown }).public_url; | |
| 83 | - | const addr = (t as { config?: { addr?: unknown } }).config?.addr; | |
| 84 | - | if (typeof pub !== 'string' || !pub.startsWith('https:')) continue; | |
| 85 | - | if (typeof addr === 'string' && addr.endsWith(`:${port}`)) return pub; | |
| 86 | - | } | |
| 87 | - | return ''; | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | const LINK_TTL = 10_000; | |
| 78 | + | const TUNNEL_RETRY = 10_000; | |
| 79 | + | /** How long a join will wait on a tunnel still shaking hands. */ | |
| 80 | + | const TUNNEL_WAIT = 3_000; | |
| 91 | 81 | ||
| 92 | 82 | export function attachRooms(server: UpgradeServer, path = '/ws'): void { | |
| 93 | 83 | const rooms = new Map<string, RoomRec>(); | |
| 94 | 84 | const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 }); | |
| 95 | 85 | ||
| 96 | - | let linkAt = 0; | |
| 97 | - | let linkUrl = ''; | |
| 86 | + | const ownPort = (): number | null => { | |
| 87 | + | const addr = server.address?.(); | |
| 88 | + | return addr && typeof addr === 'object' ? ((addr as { port?: number }).port ?? null) : null; | |
| 89 | + | }; | |
| 90 | + | ||
| 91 | + | let tunnel: ChildProcess | null = null; | |
| 92 | + | let tunnelUrl = ''; | |
| 93 | + | let tunnelDiedAt = 0; | |
| 94 | + | let waiters: Array<() => void> = []; | |
| 95 | + | const wake = () => { | |
| 96 | + | for (const w of waiters) w(); | |
| 97 | + | waiters = []; | |
| 98 | + | }; | |
| 99 | + | ||
| 100 | + | const ensureTunnel = (): void => { | |
| 101 | + | if (process.env.PUBLIC_URL || tunnel || Date.now() - tunnelDiedAt < TUNNEL_RETRY) return; | |
| 102 | + | const port = ownPort(); | |
| 103 | + | if (port === null) return; | |
| 104 | + | const child = spawn( | |
| 105 | + | process.env.RSGROK_BIN ?? 'rsgrok', | |
| 106 | + | // No :4040 inspection API — another agent may already own that port. | |
| 107 | + | ['http', `127.0.0.1:${port}`, '--web-addr', 'false'], | |
| 108 | + | { stdio: ['ignore', 'pipe', 'inherit'] }, | |
| 109 | + | ); | |
| 110 | + | tunnel = child; | |
| 111 | + | let out = ''; | |
| 112 | + | child.stdout!.on('data', (chunk: Buffer) => { | |
| 113 | + | out += chunk.toString(); | |
| 114 | + | const up = /^rsgrok: (https:\/\/\S+) ->/m.exec(out); | |
| 115 | + | if (up && up[1] !== tunnelUrl) { | |
| 116 | + | tunnelUrl = up[1]; | |
| 117 | + | // The tunnel-up line lands in our pipe, not the terminal — re-say it. | |
| 118 | + | console.log(`public link: ${tunnelUrl}`); | |
| 119 | + | wake(); | |
| 120 | + | } | |
| 121 | + | }); | |
| 122 | + | const gone = () => { | |
| 123 | + | if (tunnel === child) { | |
| 124 | + | tunnel = null; | |
| 125 | + | tunnelUrl = ''; | |
| 126 | + | tunnelDiedAt = Date.now(); | |
| 127 | + | } | |
| 128 | + | wake(); | |
| 129 | + | }; | |
| 130 | + | child.on('error', gone); // rsgrok not installed, say | |
| 131 | + | child.on('exit', gone); | |
| 132 | + | }; | |
| 133 | + | ||
| 134 | + | // Open the tunnel as soon as there is a port to aim it at, not on the | |
| 135 | + | // first join — by the time anyone scans a QR the link should exist. | |
| 136 | + | if (ownPort() !== null) ensureTunnel(); | |
| 137 | + | else server.on('listening', ensureTunnel); | |
| 138 | + | ||
| 98 | 139 | const publicBase = async (): Promise<string> => { | |
| 99 | 140 | if (process.env.PUBLIC_URL) return process.env.PUBLIC_URL; | |
| 100 | - | const now = Date.now(); | |
| 101 | - | if (now - linkAt < LINK_TTL) return linkUrl; | |
| 102 | - | linkAt = now; // failures are cached too — no stampede on a missing agent | |
| 103 | - | try { | |
| 104 | - | const api = process.env.NGROK_API ?? 'http://127.0.0.1:4040/api/tunnels'; | |
| 105 | - | const res = await fetch(api, { signal: AbortSignal.timeout(400) }); | |
| 106 | - | const addr = server.address?.(); | |
| 107 | - | const port = | |
| 108 | - | addr && typeof addr === 'object' ? ((addr as { port?: number }).port ?? null) : null; | |
| 109 | - | linkUrl = pickPublicUrl(await res.json(), port); | |
| 110 | - | } catch { | |
| 111 | - | linkUrl = ''; | |
| 141 | + | ensureTunnel(); | |
| 142 | + | if (!tunnelUrl && tunnel) { | |
| 143 | + | await new Promise<void>((resolve) => { | |
| 144 | + | waiters.push(resolve); | |
| 145 | + | setTimeout(resolve, TUNNEL_WAIT).unref(); | |
| 146 | + | }); | |
| 112 | 147 | } | |
| 113 | - | return linkUrl; | |
| 148 | + | return tunnelUrl; | |
| 114 | 149 | }; | |
| 115 | 150 | ||
| 116 | 151 | server.on('upgrade', (req, socket, head) => { | |
| ⋯ 25 unchanged lines | |||
| 142 | 177 | server.on('close', () => { | |
| 143 | 178 | clearInterval(sweep); | |
| 144 | 179 | wss.close(); | |
| 180 | + | tunnel?.kill(); | |
| 145 | 181 | }); | |
| 146 | 182 | ||
| 147 | 183 | const send = (ws: WebSocket, msg: unknown) => { | |
| ⋯ 17 unchanged lines | |||
| 165 | 201 | ws.on('error', () => {}); | |
| 166 | 202 | ||
| 167 | 203 | // Messages are handled strictly in arrival order even though hello is | |
| 168 | - | // async (it may go ask ngrok for the public link) — a chain, not a race. | |
| 204 | + | // async (it may wait on the tunnel's public link) — a chain, not a race. | |
| 169 | 205 | let chain = Promise.resolve(); | |
| 170 | 206 | ws.on('message', (data) => { | |
| 171 | 207 | chain = chain.then(() => onMessage(data)).catch(() => {}); | |
| ⋯ 114 unchanged lines | |||
modifiedsrc/net/protocol.ts+2 −2
| ⋯ 109 unchanged lines | |||
| 110 | 110 | * serves the game directly and keeps every param, which on a phone also means | |
| 111 | 111 | * the whole screen belongs to the hand. | |
| 112 | 112 | * | |
| 113 | - | * `base` is the server's public face (`room.publicBase` — an ngrok tunnel, | |
| 114 | - | * say): a link has to be an address the phone can reach, which the host | |
| 113 | + | * `base` is the server's public face (`room.publicBase` — its rsgrok | |
| 114 | + | * tunnel): a link has to be an address the phone can reach, which the host | |
| 115 | 115 | * screen's own `localhost` is not. The path survives the swap; the tunnel | |
| 116 | 116 | * fronts the same server. | |
| 117 | 117 | */ | |
| ⋯ 15 unchanged lines | |||
modifiedsrc/net/room.ts+1 −1
| ⋯ 38 unchanged lines | |||
| 39 | 39 | hostId: string; | |
| 40 | 40 | players: PlayerInfo[]; | |
| 41 | 41 | state: Record<string, unknown>; | |
| 42 | - | /** The server's public https origin (an ngrok tunnel, say), or ''. */ | |
| 42 | + | /** The server's public https origin (its own rsgrok tunnel), or ''. */ | |
| 43 | 43 | link?: string; | |
| 44 | 44 | } | |
| 45 | 45 | | { t: 'join'; player: PlayerInfo } | |
| ⋯ 270 unchanged lines | |||