From e564d3c839474156e1a68c922dfe529601f81c1c Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 10 Aug 2026 14:10:20 -0400 Subject: [PATCH] [www] subroom data --- apps/www/src/client/App.tsx | 146 ++++++++++++++++++++-- apps/www/src/client/styles.css | 38 ++++++ apps/www/src/test/integration/api.test.ts | 1 + apps/www/src/upstream.ts | 1 + apps/www/src/www.app.ts | 2 + 5 files changed, 179 insertions(+), 9 deletions(-) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 7f22d4e..043c6c1 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -33,6 +33,7 @@ interface Hosts { img: string notify: string rooms: string + cdn: string } /** @@ -67,12 +68,12 @@ interface SelfAccount { interface SubRoom { SubRoomId: number Name: string - /** The Unity scene the client loads under the saved objects. */ - UnitySceneId: string /** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */ Accessibility: number IsSandbox: boolean MaxPlayers: number + /** The subroom's scene-data key, served back by `cdn` under `room/`. */ + DataBlob?: string /** * A save posted without `AutoPublish` waits here. Cleared when that save is * published, so a non-null value means "edited since players last saw a change". @@ -83,7 +84,17 @@ interface SubRoom { * one silently loads nothing, which is worth surfacing to an owner who can't tell * that apart from a broken room. */ - CurrentSave: { SubRoomDataSaveId: number; CreatedAt: string; Description: string } | null + CurrentSave: { + SubRoomDataSaveId: number + CreatedAt: string + Description: string + /** + * The scene-data key for the published save — what the client downloads to load + * the place, and the file worth keeping a copy of. `subRoomDataBlob()` resolves + * this one first, ahead of the subroom's own. + */ + DataBlob: string + } | null } /** @@ -1010,7 +1021,7 @@ function RoomPage({ // they have no business asking.

That isn't one of your rooms.

) : ( - + )} ) @@ -1029,7 +1040,15 @@ function platformList(room: OwnedRoom): string[] { } /** A room's settings and its subrooms. Read-only: rooms are edited in game. */ -function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) { +function RoomDetail({ + room, + imgHost, + cdnHost, +}: { + room: OwnedRoom + imgHost: string + cdnHost: string +}) { const created = new Date(room.CreatedAt) const platforms = platformList(room) const subRooms = room.SubRooms ?? [] @@ -1095,7 +1114,7 @@ function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) { ) : (
    {subRooms.map((sub) => ( - + ))}
)} @@ -1105,23 +1124,31 @@ function RoomDetail({ room, imgHost }: { room: OwnedRoom; imgHost: string }) { } /** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */ -function SubRoomRow({ sub }: { sub: SubRoom }) { +function SubRoomRow({ + sub, + roomName, + cdnHost, +}: { + sub: SubRoom + roomName: string + cdnHost: string +}) { const save = sub.CurrentSave ?? null const saved = save ? new Date(save.CreatedAt) : null // Cleared when that save is published (see publishSubRoomSave), so a value here always // means work the owner saved but players still can't see. const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined + const name = sub.Name || `Subroom ${sub.SubRoomId}` return (
  • - {sub.Name || `Subroom ${sub.SubRoomId}`} + {name} {sub.IsSandbox && Sandbox}

    #{sub.SubRoomId} · up to {sub.MaxPlayers} players - {sub.UnitySceneId && ` · scene ${sub.UnitySceneId}`}

    {save === null ? ( @@ -1139,10 +1166,111 @@ function SubRoomRow({ sub }: { sub: SubRoom }) { · a newer save is staged, waiting to be published. )}

    + {/* The published save's blob first: that's the copy of the room worth keeping, + and the one the client resolves ahead of the subroom's own key. */} + {save?.DataBlob && ( + + )} + {sub.DataBlob && ( + + )}
  • ) } +/** + * A download filename built from player-supplied names, with everything that isn't a + * word character, dot or dash flattened to a dash — a subroom can be called anything, + * and that string is about to become a path on someone's disk. + */ +const safeFilename = (...parts: string[]): string => + `${parts.join('-').replace(/[^\w.-]+/g, '-')}.bin` + +/** + * One scene-data blob: the key, and a link that downloads it from `cdn`. + * + * `href` is the real CDN URL, so open-in-new-tab and right-click → Save As work like any + * other link. The click is intercepted only to give the file a NAME: blobs are stored + * under a date-foldered UUID, so three downloads otherwise land as three + * indistinguishable extensionless files. The `download` attribute can't do that on its + * own — browsers ignore it cross-origin, and `cdn` is always a different origin from the + * website — hence fetching the bytes and saving them through an object URL. + */ +function BlobDownload({ + label, + blobKey, + filename, + cdnHost, +}: { + label: string + blobKey: string + filename: string + cdnHost: string +}) { + // Room build data is served under `room/` — the same prefix the storage worker + // uploads it to, and the one the game downloads it from. + const url = `${cdnHost}/room/${blobKey}` + const [pending, setPending] = useState(false) + const [error, setError] = useState('') + + const download = async () => { + setPending(true) + setError('') + try { + const res = await fetch(url) + // The blob key is stored on the subroom, so a miss here means the object is gone + // from the bucket — worth saying, rather than saving a file of the 404 body. + if (!res.ok) throw new Error(`the CDN answered ${res.status}`) + const href = URL.createObjectURL(await res.blob()) + const link = document.createElement('a') + link.href = href + link.download = filename + link.click() + // The click is dispatched synchronously but the save reads the URL after this + // frame, so the revoke waits a tick rather than pulling it out from under. + setTimeout(() => URL.revokeObjectURL(href), 0) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setPending(false) + } + } + + return ( +
    + {label} + { + e.preventDefault() + void download() + }} + > + {blobKey} + + {/* Only rendered when it has something to say — an empty span would still take a + gap from the flex row, leaving the key trailed by a stray space. */} + {pending ? ( + Downloading… + ) : error ? ( + Couldn’t download — {error}. + ) : null} +
    + ) +} + /** How a room or subroom's `Accessibility` reads on screen. */ const accessibilityLabel = (accessibility: number): string => // Unknown ordinals shouldn't happen, but this label is the only thing telling an owner diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index 0d357ad..e149fb1 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -835,6 +835,44 @@ h2 { color: var(--accent); } +/* + * A scene-data blob and the link that downloads it. The KEY is the link, rather than a + * "Download" button beside it: the key is the thing an owner came to find (it's what + * `CurrentSave.DataBlob` holds, and what a bug report quotes), so making it the target + * keeps it readable and clickable without a second control competing for the row. + */ +.blob { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + margin-top: 8px; + font-size: 0.8rem; +} + +.blob-label { + flex: none; + color: var(--muted); +} + +/* Blob keys are `/` — long, and with only two places a line could break. + `anywhere` lets one wrap mid-key instead of pushing the card sideways. */ +.blob-key { + color: var(--accent); + text-decoration: none; + overflow-wrap: anywhere; + min-width: 0; +} + +.blob-key:hover { + text-decoration: underline; +} + +.blob-note { + color: var(--muted); + overflow-wrap: anywhere; +} + /* ---- Forms -------------------------------------------------------------- */ label { diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 2a4e7d7..47142d3 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -49,6 +49,7 @@ it('advertises signup and where the other workers live', async () => { img: 'https://img.rec.example.com', notify: 'https://notify.rec.example.com', rooms: 'https://rooms.rec.example.com', + cdn: 'https://cdn.rec.example.com', }, }) }) diff --git a/apps/www/src/upstream.ts b/apps/www/src/upstream.ts index 361f321..72c12f2 100644 --- a/apps/www/src/upstream.ts +++ b/apps/www/src/upstream.ts @@ -16,6 +16,7 @@ export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}` export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}` export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}` export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}` +export const cdnBase = (env: Env): string => `https://cdn.${env.DOMAIN}` /** * POST a form body to the `auth` worker, carrying the browser's real IP across. diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index ad0dcad..d72558e 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -12,6 +12,7 @@ import { accountsBase, apiBase, authBase, + cdnBase, imgBase, notifyBase, postAuthForm, @@ -69,6 +70,7 @@ const app = new Hono() img: imgBase(c.env), notify: notifyBase(c.env), rooms: roomsBase(c.env), + cdn: cdnBase(c.env), }, }) })