From 8b804eaa333ae3e02297624f5d6f37ebe7a873d5 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 5 Aug 2026 17:54:07 -0400 Subject: [PATCH] add CDN /data/ endpoint --- apps/cdn/src/cdn.app.ts | 29 ++++++++++++++++++++--- apps/cdn/src/test/integration/api.test.ts | 16 +++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index bbbf040..5d35762 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -195,6 +195,28 @@ const app = new Hono() (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`) ) + // Generic client data by name. Anything the client uploads as FileType 2 lands + // under `data/` (a Holotar recording is the one seen in the wild) and the client + // fetches it back from this prefix. Date-foldered like the room and invention + // blobs, so the rest of the path is matched as-is. + .get( + '/data/:id{.+}', + describeRoute({ + tags: ['Assets'], + summary: 'Serve a client data blob', + description: [ + 'Streams the object stored under `data/` — whatever the client uploaded as', + '`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case', + 'observed. Like room and invention blobs the name is date-foldered by the upload,', + 'e.g. `2026-02-03/`, so it contains slashes. The worker does not interpret the', + 'bytes — the prefix exists because the client expects to read these back from `/data/`.', + ].join(' '), + parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS], + responses: assetResponses('The data blob'), + }), + (c) => serveAsset(c, `data/${c.req.param('id')}`) + ) + // The generated spec. Documentation only — no request is validated against it (see // openapi.ts). `hide: true` keeps this route out of its own output. app.get( @@ -209,10 +231,11 @@ app.get( description: [ 'Binary asset delivery for recflare, a private-server reimplementation of the Rec', 'Room backend. Streams the blobs the client downloads while playing — anti-cheat', - 'signatures, saved room scenes and invention data — out of the shared `recflare-cdn`', - 'R2 bucket, plus the one bundled config file the loading screen reads.', + 'signatures, saved room scenes, invention data and generic client uploads — out of', + 'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading', + 'screen reads.', '', - 'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) and served as', + 'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as', '`application/octet-stream`; the worker never interprets what it hands back. Reads', 'are unauthenticated — a caller needs the exact key, which only comes from an', 'authenticated call to another worker.', diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts index a670ef9..adf3bd2 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -101,6 +101,21 @@ describe('cdn endpoints', () => { expect(res.status).toBe(404) }) + test('GET /data/:id streams the data blob from R2', async () => { + // Date-foldered — the name the storage worker generates for a FileType 2 upload. + const name = '2026-08-05/3b9c1f0a-5d2e-4c1b-9a77-2e6f0b4d8c31' + await env.CDN_ASSETS.put(`data/${name}`, new Uint8Array([4, 5, 6])) + const res = await exports.default.fetch(`${ORIGIN}/data/${name}`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('application/octet-stream') + expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6])) + }) + + test('GET /data/:id 404s when the blob is absent', async () => { + const res = await exports.default.fetch(`${ORIGIN}/data/missing`) + expect(res.status).toBe(404) + }) + test('GET /openapi.json documents every route', async () => { const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) expect(res.status).toBe(200) @@ -124,6 +139,7 @@ describe('cdn endpoints', () => { expect([...documented].sort()).toEqual([ 'GET /', 'GET /config/LoadingScreenTipData', + 'GET /data/{id}', 'GET /invention/{dataBlob}', 'GET /room/{dataBlob}', 'GET /sigs/{sigName}',