anvilsign in

collin/mahjong · 822e6726

Invite links wear the server's public face — an ngrok tunnel, found on its own

Collin Richards · 2026-08-20 17:09 UTC · 822e67260e2c02152cc36d94d2eebdbb6bc104c0 · parent f55b8ad0 · browse files

modifiedREADME.md+9 −0
⋯ 219 unchanged lines
220220 next move. Joining a room writes `?room=` back into the address bar, so the
221221 page URL — the host's included — is the invite link.
222222
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.
231+
223232 ## On a phone
224233
225234 The table assumes four people sitting around a screen lying flat, and about
⋯ 287 unchanged lines
modifiedserver/net.test.mjs+57 −1
11 import { createServer } from 'node:http';
22 import { afterAll, beforeAll, expect, test } from 'vitest';
33 import { joinRoom } from '../src/net/room.ts';
4-import { attachRooms } from './rooms.ts';
4+import { attachRooms, pickPublicUrl } from './rooms.ts';
55
66 // The real client against the real relay, in one process. Plain .mjs on
77 // purpose: the client is typed for the browser and the relay for node, and
⋯ 85 unchanged lines
9393 expect(a.isHost).toBe(true);
9494 expect(a.state).toEqual({});
9595 });
96+
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('');
110+});
111+
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));
127+ const relay = createServer();
128+ attachRooms(relay);
129+ 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+ try {
133+ const r = await joinRoom({ server: `ws://127.0.0.1:${relayPort}/ws` });
134+ expect(r.publicBase).toBe('https://ours.ngrok-free.dev');
135+ r.leave();
136+ } finally {
137+ delete process.env.NGROK_API;
138+ await new Promise((r) => relay.close(r));
139+ await new Promise((r) => api.close(r));
140+ }
141+});
142+
143+test('PUBLIC_URL outranks any tunnel', async () => {
144+ process.env.PUBLIC_URL = 'https://mahjong.example.com';
145+ try {
146+ const a = await join();
147+ expect(a.publicBase).toBe('https://mahjong.example.com');
148+ } finally {
149+ delete process.env.PUBLIC_URL;
150+ }
151+});
modifiedserver/rooms.ts+54 −1
⋯ 60 unchanged lines
6161 export interface UpgradeServer {
6262 on(event: 'upgrade', cb: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): unknown;
6363 on(event: 'close', cb: () => void): unknown;
64+ /** node's Server.address() when there is one — how we learn our own port. */
65+ address?(): unknown;
66+}
67+
68+/**
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.
76+ */
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 '';
6488 }
6589
90+const LINK_TTL = 10_000;
91+
6692 export function attachRooms(server: UpgradeServer, path = '/ws'): void {
6793 const rooms = new Map<string, RoomRec>();
6894 const wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
6995
96+ let linkAt = 0;
97+ let linkUrl = '';
98+ const publicBase = async (): Promise<string> => {
99+ 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 = '';
112+ }
113+ return linkUrl;
114+ };
115+
70116 server.on('upgrade', (req, socket, head) => {
71117 let pathname: string;
72118 try {
⋯ 45 unchanged lines
118164 ws.on('pong', () => alive.set(ws, true));
119165 ws.on('error', () => {});
120166
167+ // 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.
169+ let chain = Promise.resolve();
121170 ws.on('message', (data) => {
171+ chain = chain.then(() => onMessage(data)).catch(() => {});
172+ });
173+ const onMessage = async (data: unknown) => {
122174 let msg: ClientMsg;
123175 try {
124176 msg = JSON.parse(String(data));
⋯ 54 unchanged lines
179231 hostId: hostOf(r),
180232 players: roster(r),
181233 state: r.state,
234+ link: await publicBase(),
182235 });
183236 }
184237
⋯ 27 unchanged lines
212265 return;
213266 }
214267 }
215- });
268+ };
216269
217270 ws.on('close', () => {
218271 if (!room || !me) return;
⋯ 14 unchanged lines
modifiedsrc/net/protocol.ts+15 −1
⋯ 108 unchanged lines
109109 * param still works: seatFromUrl above keeps reading it.) The page's own URL
110110 * serves the game directly and keeps every param, which on a phone also means
111111 * the whole screen belongs to the hand.
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
115+ * screen's own `localhost` is not. The path survives the swap; the tunnel
116+ * fronts the same server.
112117 */
113-export function roomLink(code: string): string {
118+export function roomLink(code: string, base?: string): string {
114119 const u = new URL(window.location.href);
120+ if (base) {
121+ try {
122+ const b = new URL(base);
123+ u.protocol = b.protocol;
124+ u.host = b.host;
125+ } catch {
126+ // A malformed base loses to a working local link.
127+ }
128+ }
115129 u.search = `?room=${code}`;
116130 u.hash = '';
117131 return u.toString();
⋯ 1 unchanged line
modifiedsrc/net/room.ts+7 −0
⋯ 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 ''. */
43+ link?: string;
4244 }
4345 | { t: 'join'; player: PlayerInfo }
4446 | { t: 'leave'; id: string }
⋯ 31 unchanged lines
7678 me: PlayerInfo = { id: myId(), name: '' };
7779 state: Record<string, unknown> = {};
7880 players: PlayerInfo[] = [];
81+ /** Where the outside world reaches this server, if anywhere: a public
82+ * https origin the invite links and QRs should wear instead of the page's
83+ * own (possibly localhost) address. '' when the page's address is it. */
84+ publicBase = '';
7985
8086 private hostId = '';
8187 private url: string;
⋯ 186 unchanged lines
268274 this.pendingPatch = null; // whatever we meant to say, the room has moved on
269275 this.code = msg.code;
270276 this.me = msg.self;
277+ this.publicBase = msg.link ?? '';
271278 const before = this.players;
272279 this.players = msg.players;
273280 const prevHost = this.hostId;
⋯ 35 unchanged lines
modifiedvite.config.ts+3 −0
⋯ 24 unchanged lines
2525 server: {
2626 port: process.env.PORT ? Number(process.env.PORT) : undefined,
2727 host: process.env.HOST || undefined,
28+ // Tunnels and LAN names have to get past vite's Host check, or every
29+ // phone that scans the QR meets a 403.
30+ allowedHosts: ['.ngrok-free.dev', '.ngrok.app', '.ngrok.dev', '.local', '.localhost'],
2831 },
2932 })