From 8d638072b47afb3862e9237dc7e65358c81e267a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 15 Sep 2026 22:24:44 -0400 Subject: [PATCH] [cdn] allow 2025 store assets --- apps/cdn/README.md | 4 +-- apps/cdn/src/cdn.app.ts | 30 +++++++++++++++++++++-- apps/cdn/src/test/integration/api.test.ts | 16 ++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/cdn/README.md b/apps/cdn/README.md index 75f326f..1b99fd8 100644 --- a/apps/cdn/README.md +++ b/apps/cdn/README.md @@ -15,8 +15,8 @@ route of its own, bundled rather than an asset, because its file is named differ its path. Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room -scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and -served as `application/octet-stream`; the worker never interprets what it hands back. +scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/` +(generic client uploads), `avatar/` (custom avatar item assetbundles) — 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/cdn.app.ts b/apps/cdn/src/cdn.app.ts index 214cc47..729fbe0 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -329,6 +329,30 @@ const app = new Hono() (c) => serveAsset(c, `data/${c.req.param('id')}`) ) + // Custom avatar item assetbundles by name. A first-party custom avatar item (see the + // `api` worker's `custom_avatar_item`) is rendered from its `CurrentSaves`, each of + // which names a built Unity assetbundle by bare filename (`UnityAsset`, e.g. + // `anx442dm1a79kp9n4kugkbgd0.assetbundle`) that the client fetches from `/avatar/`. + // Streamed from R2 under `avatar/`. The rest of the path is matched so a foldered + // upload would resolve the same way as the other blob prefixes. + .get( + '/avatar/:asset{.+}', + describeRoute({ + tags: ['Assets'], + summary: 'Serve a custom avatar item assetbundle', + description: [ + 'Streams the object stored under `avatar/` — the built Unity assetbundle the', + 'client downloads to render a first-party custom avatar item on one body type. The', + 'name comes from a save’s `UnityAsset` (or `UnityAsset2`) in the item’s `CurrentSaves`', + '(see the `api` worker), a bare filename such as `anx442dm1a79kp9n4kugkbgd0.assetbundle`.', + 'The worker does not interpret the bytes.', + ].join(' '), + parameters: [keyParam('asset', 'The assetbundle filename.', true), ...CONDITIONAL_HEADERS], + responses: assetResponses('The assetbundle'), + }), + (c) => serveAsset(c, `avatar/${c.req.param('asset')}`) + ) + // 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( @@ -343,11 +367,13 @@ 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, invention data and generic client uploads — out of', + 'signatures, saved room scenes, invention data, generic client uploads and custom', + 'avatar item assetbundles — out of', 'the shared `recflare-cdn` R2 bucket, plus the JSON config files the client reads', 'from `/config/`.', '', - 'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as', + 'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`, `avatar/`) 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 00eef0f..370d523 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -271,6 +271,21 @@ describe('cdn endpoints', () => { expect(res.status).toBe(404) }) + test('GET /avatar/:asset streams the custom avatar item assetbundle from R2', async () => { + // A bare filename — what a first-party item's `CurrentSaves[].UnityAsset` names. + const name = 'anx442dm1a79kp9n4kugkbgd0.assetbundle' + await env.CDN_ASSETS.put(`avatar/${name}`, new Uint8Array([7, 8, 9])) + const res = await exports.default.fetch(`${ORIGIN}/avatar/${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([7, 8, 9])) + }) + + test('GET /avatar/:asset 404s when the assetbundle is absent', async () => { + const res = await exports.default.fetch(`${ORIGIN}/avatar/missing.assetbundle`) + 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) @@ -293,6 +308,7 @@ describe('cdn endpoints', () => { ) expect([...documented].sort()).toEqual([ 'GET /', + 'GET /avatar/{asset}', 'GET /config/LoadingScreenTipData', 'GET /config/{name}', 'GET /data/{id}',