From 691f44d85841e5c235d4f671f3d7020063bf7cc5 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 29 Aug 2026 21:57:38 -0400 Subject: [PATCH] Room invite links: /join/ recruits with a card of its own A living room's link unfurls as an invitation (waiting rooms beckon a seat, started games a gallery view) via the shared ogPage baker. The client follows the path: a held seat resumes, a waiting room offers join-or-watch from the lobby, a started game is watched at once. The address bar carries the invite link at any table, with a copy button beside the roster. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/server/src/index.ts | 74 +++++++++++++++++++++++++++++------- packages/web/src/App.svelte | 67 +++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 15 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 29d59c2..aaba267 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -147,19 +147,26 @@ function shareData(id: string): ShareData | null { const escapeHtml = (t: string) => t.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -/** index.html with this share's OpenGraph card folded into its head — - * crawlers never run the app, so the unfurl must arrive pre-baked. */ -function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string { - // Host and proto arrive from request headers — attacker-writable text - // that must never reach an HTML attribute raw. - const proto = /^https?$/.test(rawProto) ? rawProto : "https"; - const host = escapeHtml(rawHost); - // The stock page carries its own generic card; strip it, or crawlers - // (which take the FIRST tag they meet) never see this turn's. - const html = readFileSync(join(staticRoot!, "index.html"), "utf8") +/** index.html with a bespoke OpenGraph card folded into its head — + * crawlers never run the app, so the unfurl must arrive pre-baked. The + * stock page carries its own generic card; strip it, or crawlers (which + * take the FIRST tag they meet) never see this page's. */ +function ogPage(metas: string[]): string { + return readFileSync(join(staticRoot!, "index.html"), "utf8") .replace(/]*>\s*/g, "") - .replace(/[^<]*<\/title>\s*/, ""); - const base = `${proto}://${host}`; + .replace(/<title>[^<]*<\/title>\s*/, "") + .replace("</head>", ` ${metas.join("\n ")}\n </head>`); +} + +/** Host and proto arrive from request headers — attacker-writable text + * that must never reach an HTML attribute raw. */ +function safeBase(rawHost: string, rawProto: string): string { + const proto = /^https?$/.test(rawProto) ? rawProto : "https"; + return `${proto}://${escapeHtml(rawHost)}`; +} + +function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string { + const base = safeBase(rawHost, rawProto); const title = data.whole ? "The whole tale — a game of Wiz-War, replayed" : `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`; @@ -179,8 +186,34 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin `<meta property="og:image:height" content="630"/>`, `<meta name="twitter:card" content="summary_large_image"/>`, `<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`, - ].join("\n "); - return html.replace("</head>", ` ${metas}\n </head>`); + ]; + return ogPage(metas); +} + +/** The recruiting card for a /join/<code> link: an invitation while the + * room waits to start, a summons to the gallery once it has. */ +function inviteHtml(room: Room, rawHost: string, rawProto: string): string { + const base = safeBase(rawHost, rawProto); + const seats = room.players.length; + const wizards = `${seats} wizard${seats === 1 ? "" : "s"}`; + const title = `You're summoned — Wiz-War room ${room.id}`; + const desc = room.state + ? `The duel is underway, ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.` + : `${wizards} at the table, waiting to flip the boards. Follow the link, pick a name, and take a seat.`; + const metas = [ + `<title>${title}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ]; + return ogPage(metas); } const httpServer = createServer((req, res) => { try { @@ -233,6 +266,19 @@ const httpServer = createServer((req, res) => { res.end(shareHtml(watch[1]!, data, hostname, proto)); return; } + // Room invitations: a living room gets its recruiting card; a dead + // code falls through to the app, which reports it in the lobby. + const invite = url.match(/^\/join\/([A-Za-z0-9]{4})$/); + if (invite) { + const room = getRoom(invite[1]!); + if (room) { + const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim(); + const hostname = String(req.headers.host ?? `localhost:${port}`); + res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" }); + res.end(inviteHtml(room, hostname, proto)); + return; + } + } let file = normalize(join(staticRoot, url === "/" ? "index.html" : url)); if (file !== staticRoot && !file.startsWith(staticRoot + sep)) { res.writeHead(403).end(); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 1e9c89c..5211342 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -23,6 +23,33 @@ let name = $state(prefs.wizardName); let joinCode = $state(""); let claimPhrase = $state(""); + /** A /join/ recruiting link. The visitor lands looking at the + * room: a held seat walks them back to it, a waiting room offers a + * seat or the gallery, a started game is watched from the gallery. */ + const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null; + if (inviteCode) joinCode = inviteCode; + let inviteFollowed = false; + $effect(() => { + if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return; + inviteFollowed = true; + const seat = net.seats.find((s) => s.roomId === inviteCode); + if (seat) net.resume(seat); + else net.watch(inviteCode); + }); + /** The address bar holds the table's invite link while you sit at one, + * so recruiting is a copy away. */ + $effect(() => { + const path = net.roomId ? `/join/${net.roomId}` : "/"; + if (location.pathname !== path) history.replaceState(null, "", path + location.search); + }); + const inviteLink = $derived(net.roomId ? `${location.origin}/join/${net.roomId}` : ""); + let inviteCopied = $state(false); + function copyInvite() { + navigator.clipboard?.writeText(inviteLink).then(() => { + inviteCopied = true; + setTimeout(() => (inviteCopied = false), 1600); + }); + } let showHelp = $state(false); /** The finished game whose victory fanfare has been dismissed. */ let victorySeen = $state(false); @@ -1827,7 +1854,11 @@
Room {net.roomId}
-
Share the code. Two to six wizards enter the maze.
+
Share the code — or the link. Two to six wizards enter the maze.
+
+ {inviteLink} + +
    {#each net.players as p (p)} {@const chosen = net.roomColors[p]} @@ -1847,6 +1878,15 @@
{#if net.spectating}

👁 You watch from the Peanut Gallery. Waiting for the boards to flip…

+ {#if net.players.length < 6} + + {/if} {:else}
{#each [0, 1, 2, 3, 4, 5] as c (c)} @@ -2716,6 +2756,31 @@ .roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; } .check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; } .waiting { color: #6b5a41; font-style: italic; } + .invite-row { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + .invite-link { + font-size: 0.8rem; + color: #6b5a41; + background: rgba(0, 0, 0, 0.06); + border-radius: 4px; + padding: 0.15rem 0.45rem; + user-select: all; + } + .gallery-join { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-top: 0.4rem; + } + .gallery-join input { + width: 11rem; + } .transfer-slip { max-width: 30rem;