anvilsign in

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
221221 page URL — the host's included — is the invite link.
222222
223223 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.
231231
232232 ## On a phone
233233
⋯ 288 unchanged lines
modifiedserver/net.test.mjs+30 −35
1+import { writeFileSync, rmSync } from 'node:fs';
12 import { createServer } from 'node:http';
3+import { tmpdir } from 'node:os';
4+import { join as joinPath } from 'node:path';
25 import { afterAll, beforeAll, expect, test } from 'vitest';
36 import { joinRoom } from '../src/net/room.ts';
4-import { attachRooms, pickPublicUrl } from './rooms.ts';
7+import { attachRooms } from './rooms.ts';
58
69 // The real client against the real relay, in one process. Plain .mjs on
710 // purpose: the client is typed for the browser and the relay for node, and
811 // neither tsconfig wants to swallow the other — vitest transforms both.
912
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+
1024 let server;
1125 let url;
1226 const opened = [];
⋯ 7 unchanged lines
2034 afterAll(async () => {
2135 for (const r of opened) r.leave();
2236 await new Promise((r) => server.close(r));
37+ delete process.env.RSGROK_BIN;
38+ rmSync(stub, { force: true });
2339 });
2440
2541 const join = async (roomCode) => {
⋯ 68 unchanged lines
94110 expect(a.state).toEqual({});
95111 });
96112
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');
110116 });
111117
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;
127124 const relay = createServer();
128125 attachRooms(relay);
129126 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`;
132127 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('');
135130 r.leave();
136131 } finally {
137- delete process.env.NGROK_API;
132+ process.env.RSGROK_BIN = was;
133+ rmSync(dead, { force: true });
138134 await new Promise((r) => relay.close(r));
139- await new Promise((r) => api.close(r));
140135 }
141136 });
142137
⋯ 9 unchanged lines
modifiedserver/rooms.ts+74 −38
1+import { spawn, type ChildProcess } from 'node:child_process';
12 import type { IncomingMessage } from 'node:http';
23 import type { Duplex } from 'node:stream';
34 import { WebSocketServer, type WebSocket } from 'ws';
⋯ 52 unchanged lines
5657 }
5758 }
5859
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
6061 * fit, which is what lets the relay ride vite's as easily as our own. */
6162 export interface UpgradeServer {
6263 on(event: 'upgrade', cb: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown;
64+ on(event: 'listening', cb: () => void): unknown;
6365 on(event: 'close', cb: () => void): unknown;
6466 /** node's Server.address() when there is one — how we learn our own port. */
6567 address?(): unknown;
6668 }
6769
6870 /**
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.
7677 */
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;
9181
9282 export function attachRooms(server: UpgradeServer, path = '/ws'): void {
9383 const rooms = new Map<string, RoomRec>();
9484 const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
9585
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+
98139 const publicBase = async (): Promise<string> => {
99140 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+ });
112147 }
113- return linkUrl;
148+ return tunnelUrl;
114149 };
115150
116151 server.on('upgrade', (req, socket, head) => {
⋯ 25 unchanged lines
142177 server.on('close', () => {
143178 clearInterval(sweep);
144179 wss.close();
180+ tunnel?.kill();
145181 });
146182
147183 const send = (ws: WebSocket, msg: unknown) => {
⋯ 17 unchanged lines
165201 ws.on('error', () => {});
166202
167203 // 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.
169205 let chain = Promise.resolve();
170206 ws.on('message', (data) => {
171207 chain = chain.then(() => onMessage(data)).catch(() => {});
⋯ 114 unchanged lines
modifiedsrc/net/protocol.ts+2 −2
⋯ 109 unchanged lines
110110 * serves the game directly and keeps every param, which on a phone also means
111111 * the whole screen belongs to the hand.
112112 *
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
115115 * screen's own `localhost` is not. The path survives the swap; the tunnel
116116 * fronts the same server.
117117 */
⋯ 15 unchanged lines
modifiedsrc/net/room.ts+1 −1
⋯ 38 unchanged lines
3939 hostId: string;
4040 players: PlayerInfo[];
4141 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 ''. */
4343 link?: string;
4444 }
4545 | { t: 'join'; player: PlayerInfo }
⋯ 270 unchanged lines