From a5136d2bfae8c3e47003a3bc2271866ae2d3ded4 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 24 Jul 2026 21:48:10 -0400 Subject: [PATCH] cdn openapi --- apps/cdn/README.md | 30 ++++- apps/cdn/package.json | 7 +- apps/cdn/src/cdn.app.ts | 156 +++++++++++++++++++--- apps/cdn/src/openapi.ts | 114 ++++++++++++++++ apps/cdn/src/test/integration/api.test.ts | 59 ++++++++ apps/www/src/docs.ts | 1 + pnpm-lock.yaml | 15 +++ 7 files changed, 362 insertions(+), 20 deletions(-) create mode 100644 apps/cdn/src/openapi.ts diff --git a/apps/cdn/README.md b/apps/cdn/README.md index 3794500..5d6449f 100644 --- a/apps/cdn/README.md +++ b/apps/cdn/README.md @@ -1,6 +1,34 @@ # cdn -A Cloudflare Workers application using Hono +CDN Worker served on the `cdn` subdomain (`cdn.recflare.net`) — a Hono app that streams +the binary blobs the client downloads while playing out of the shared `recflare-cdn` R2 +bucket, plus the one bundled config file the loading screen reads. + +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. +Reads are unauthenticated: a caller needs the exact key, which only comes from an +authenticated call to another worker. + +This worker only reads. Uploads go through `storage`, which writes the same bucket, and +images are served by `img`. + +## API documentation + +`GET /openapi.json` serves a spec generated from `describeRoute` blocks that sit +alongside each handler, with the schemas in `src/openapi.ts`. It's also aggregated into +the docs page www serves at `/docs`. + +**The spec is descriptive, not enforced** — same rationale as the `img`/`auth` workers: a +reverse-engineered protocol, lenient handlers, no runtime validation. A test asserts +every route appears in the spec, so adding one without documenting it fails. + +## Conditional and range requests + +Every asset route honours `If-None-Match` (→ 304) and a single `Range` (→ 206). The range +support is not an optimization: large-file downloaders fetch in chunks, and answering 200 +where a 206 is expected corrupts the reassembled file — which surfaces as an anti-cheat +"Signatures don't match" failure rather than a download error. ## Development diff --git a/apps/cdn/package.json b/apps/cdn/package.json index cd8f8d3..fd68360 100644 --- a/apps/cdn/package.json +++ b/apps/cdn/package.json @@ -17,8 +17,13 @@ "dependencies": { "@repo/hono-helpers": "workspace:*", "@repo/jwt": "workspace:*", + "@standard-community/standard-json": "0.3.5", + "@standard-community/standard-openapi": "0.2.9", "hono": "4.12.27", - "workers-tagged-logger": "1.0.1" + "hono-openapi": "1.3.1", + "openapi-types": "12.1.3", + "workers-tagged-logger": "1.0.1", + "zod": "4.4.3" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.16.20", diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index fa54012..bbbf040 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -1,17 +1,26 @@ import { Hono } from 'hono' +import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' -import { withNotFound, withOnError } from '@repo/hono-helpers' +import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import loadingScreenTipData from '../static/loading-screen-tip-data.json' +import { + assetResponses, + CONDITIONAL_HEADERS, + json, + keyParam, + LoadingScreenTip, + ServiceStatus, +} from './openapi' import type { Context } from 'hono' import type { App, Env } from './context' /** * CDN routes. The `cdn` prefix maps to this worker's subdomain, so method routes - * are served bare. File-backed routes (`sigs`, `upload`) have no storage binding - * yet and are stubbed. + * are served bare. Everything but the liveness probe and the bundled tip data is + * streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix. */ /** Parse a single-range `Range: bytes=start-end` header into an R2 range. */ @@ -60,16 +69,16 @@ async function serveAsset(c: Context, key: string) { // Range honored → 206 Partial Content with Content-Range. if (object.range && c.req.header('range')) { - const r = object.range - let offset: number - let length: number - if ('suffix' in r) { - length = r.suffix - offset = object.size - length - } else { - offset = r.offset ?? 0 - length = r.length ?? object.size - offset - } + // R2 hands back the RESOLVED range, and the object it returns carries all three + // keys with the inapplicable ones set to undefined — so `'suffix' in r` is true + // even for an offset/length range and cannot discriminate between the two forms. + // (It read as a suffix range every time, making offset/length NaN and the + // Content-Range header garbage.) Read the values, not the keys. A `bytes=-N` + // request already comes back resolved to a concrete offset/length; the suffix + // fallback below is only there in case that ever stops being true. + const r = object.range as { offset?: number; length?: number; suffix?: number } + const length = r.length ?? r.suffix ?? object.size - (r.offset ?? 0) + const offset = r.offset ?? object.size - length headers.set('content-length', String(length)) headers.set('content-range', `bytes ${offset}-${offset + length - 1}/${object.size}`) return new Response(object.body, { status: 206, headers }) @@ -92,25 +101,136 @@ const app = new Hono() .onError(withOnError()) .notFound(withNotFound()) - .get('/', (c) => c.json({ service: 'cdn', status: 'ok' })) + .get( + '/', + describeRoute({ + tags: ['Service'], + summary: 'Service liveness', + description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.', + responses: { 200: json(ServiceStatus, 'Always `{ service: "cdn", status: "ok" }`') }, + }), + (c) => c.json({ service: 'cdn', status: 'ok' }) + ) // Loading-screen tips, bundled here as static JSON. - .get('/config/LoadingScreenTipData', (c) => c.json(loadingScreenTipData)) + .get( + '/config/LoadingScreenTipData', + describeRoute({ + tags: ['Config'], + summary: 'Loading-screen tips', + description: [ + 'The tips the client cycles through on a loading screen. A bundled static file', + '(`static/loading-screen-tip-data.json`), captured from the real service and served', + 'verbatim — nothing here is editable at runtime, and every client gets the same list', + 'regardless of platform or room. The per-tip `Context`/`Visibility`/`PlatformMask`', + 'fields are the client’s own filters, applied client-side.', + ].join(' '), + responses: { 200: json(LoadingScreenTip.array(), 'The bundled tips') }, + }), + (c) => c.json(loadingScreenTipData) + ) // Signature blobs by name. Streamed from R2 under the `sigs/` key prefix; // 404 when missing. - .get('/sigs/:sigName', (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`)) + .get( + '/sigs/:sigName', + describeRoute({ + tags: ['Assets'], + summary: 'Serve a signature blob', + description: [ + 'Streams the object stored under `sigs/`. These are the anti-cheat signature', + 'blobs the client fetches at startup; nothing here inspects or validates them.', + ].join(' '), + parameters: [keyParam('sigName', 'The blob name.', false), ...CONDITIONAL_HEADERS], + responses: assetResponses('The signature blob'), + }), + (c) => serveAsset(c, `sigs/${c.req.param('sigName')}`) + ) // Room build data by name. The client fetches this for a SubRoom's DataBlob to // load the room. Streamed from R2 under `room/`. The name may contain slashes // (uploads are foldered by date, e.g. `2026-02-03/`), so match the rest of // the path. - .get('/room/:dataBlob{.+}', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`)) + .get( + '/room/:dataBlob{.+}', + describeRoute({ + tags: ['Assets'], + summary: 'Serve room build data', + description: [ + 'Streams the object stored under `room/` — the saved scene the client', + 'downloads to load a room. The name comes from a subroom’s `DataBlob` (see the `rooms`', + 'worker) and is date-foldered by the upload, e.g. `2026-02-03/`, so it contains', + 'slashes.', + '', + 'A room’s IMAGE also lives under this prefix, stored by its bare `ImageName` — the', + 'same route serves both.', + ].join('\n'), + parameters: [keyParam('dataBlob', 'The blob name.', true), ...CONDITIONAL_HEADERS], + responses: assetResponses('The room data'), + }), + (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`) + ) // Invention data by name. The client fetches this for an invention's // `CurrentVersion.BlobName` to spawn it. Streamed from R2 under `invention/`. // Like room blobs the name is date-foldered, and it carries the `.inv` extension // the upload stored it under, so the rest of the path is matched as-is. - .get('/invention/:dataBlob{.+}', (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)) + .get( + '/invention/:dataBlob{.+}', + describeRoute({ + tags: ['Assets'], + summary: 'Serve invention data', + description: [ + 'Streams the object stored under `invention/` — the data the client', + 'downloads to spawn an invention. The name comes from an invention’s', + '`CurrentVersion.BlobName` (see the `api` worker); like room blobs it is date-foldered,', + 'and it keeps the `.inv` extension the upload stored it under.', + ].join(' '), + parameters: [ + keyParam('dataBlob', 'The blob name, including `.inv`.', true), + ...CONDITIONAL_HEADERS, + ], + responses: assetResponses('The invention data'), + }), + (c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`) + ) + +// 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( + '/openapi.json', + describeRoute({ hide: true }), + withCleanSpec( + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'recflare cdn', + version: '1.0.0', + 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.', + '', + 'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) 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.', + '', + 'This worker only READS. Uploads go through the `storage` worker, which writes the', + 'same bucket, and images are served by `img` rather than from here.', + '', + 'Every asset route supports conditional GETs (`If-None-Match` → 304) and single', + 'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in', + 'chunks, and answering 200 where a 206 is expected corrupts the reassembled file —', + 'which surfaces as an anti-cheat “Signatures don’t match” failure, not a download', + 'error.', + ].join('\n'), + }, + servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }], + }, + }) + ) +) export default app diff --git a/apps/cdn/src/openapi.ts b/apps/cdn/src/openapi.ts new file mode 100644 index 0000000..2b6bc36 --- /dev/null +++ b/apps/cdn/src/openapi.ts @@ -0,0 +1,114 @@ +import { resolver } from 'hono-openapi' +import { z } from 'zod' + +import type { OpenAPIV3_1 } from 'openapi-types' + +/** + * OpenAPI schemas for the cdn worker. + * + * IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to + * generate the spec and are never wired into `hono-openapi`'s `validator()`. Same + * rationale as the auth/accounts/img workers: a reverse-engineered protocol, lenient + * handlers, no runtime validation. + * + * Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a + * meta'd schema used in a response emits a `$ref` the framework doesn't always hoist + * into `components.schemas`, leaving a dangling reference. Leaving meta off makes every + * schema inline, which renders correctly in any tool. + * + * Most of this worker's surface is opaque BYTES, not JSON, so those responses are + * described with a binary content type rather than a zod schema (the same way the `img` + * worker describes image bytes). + */ + +/** Emit a zod schema as an `application/json` response body. */ +export function json(schema: z.ZodType, description: string) { + return { description, content: { 'application/json': { schema: resolver(schema) } } } +} + +/** + * A binary asset response. Everything streamed out of the bucket is served as + * `application/octet-stream` regardless of what it actually is — the client downloads + * these blobs, it never sniffs their type. + */ +export function assetBytes(description: string): OpenAPIV3_1.ResponseObject { + return { + description, + content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } }, + } +} + +/** The shared responses of every asset route: the byte-serving ones plus its failures. */ +export function assetResponses(description: string): OpenAPIV3_1.ResponsesObject { + return { + 200: assetBytes(description), + 206: assetBytes('A byte range, when the request carried a `Range` header'), + 304: { description: '`If-None-Match` matched the stored etag (no body)' }, + 400: { description: 'The key contains `..` (no body)' }, + 404: { description: 'No such object in the bucket' }, + } +} + +/** The `Range` / `If-None-Match` headers every asset route honours. */ +export const CONDITIONAL_HEADERS: OpenAPIV3_1.ParameterObject[] = [ + { + name: 'Range', + in: 'header', + required: false, + description: + 'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`). Honoured with a 206; a malformed or multi-range value is ignored and the whole object served.', + schema: { type: 'string', example: 'bytes=0-1023' }, + }, + { + name: 'If-None-Match', + in: 'header', + required: false, + description: 'The etag of a previously fetched copy; a match answers 304 with no body.', + schema: { type: 'string' }, + }, +] + +/** A path parameter naming an object in the bucket. */ +export function keyParam( + name: string, + description: string, + slashes: boolean +): OpenAPIV3_1.ParameterObject { + return { + name, + in: 'path', + required: true, + description: slashes ? `${description} May contain slashes.` : description, + schema: { type: 'string' }, + } +} + +// ---- Response schemas ------------------------------------------------------ + +/** `GET /` — the liveness probe body. */ +export const ServiceStatus = z.object({ + service: z.literal('cdn'), + status: z.literal('ok'), +}) + +/** + * One loading-screen tip. `Context`/`InputType`/`Visibility` are client-side enums that + * decide where a tip may appear, and `PlatformMask` is a bit field of the platforms it + * shows on — every tip in the bundled set is left at whatever the 2019 capture had. + */ +export const LoadingScreenTip = z.object({ + Name: z.string().describe('A GUID (no dashes) — the tip’s id, not a display name'), + Title: z.string(), + Message: z.string(), + RoomNames: z + .array(z.string()) + .describe('Rooms to restrict the tip to; empty everywhere in the bundled set'), + Context: z.int(), + InputType: z.int(), + Visibility: z.int(), + AllowCycling: z.boolean(), + RestrictToNewUsers: z.boolean(), + ImageName: z.string().describe('An image key the client resolves against the img worker'), + PlatformMask: z.int().describe('Bit field of the platforms the tip shows on'), + CreatedAt: z.string(), +}) diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts index c745082..a670ef9 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -52,6 +52,27 @@ describe('cdn endpoints', () => { expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([12, 13, 14])) }) + // The other two Range forms. Both resolve to a concrete offset/length inside R2, so + // they exercise the same Content-Range math as the closed range above — which read + // every range as a suffix range and emitted `bytes NaN-NaN/6` until it was fixed. + test('GET /sigs/:sigName honors open-ended and suffix Range requests', async () => { + await env.CDN_ASSETS.put('sigs/ranged2', new Uint8Array([10, 11, 12, 13, 14, 15])) + const fetchRange = (range: string) => + exports.default.fetch(`${ORIGIN}/sigs/ranged2`, { headers: { Range: range } }) + + // `bytes=4-` — from an offset to the end. + const open = await fetchRange('bytes=4-') + expect(open.status).toBe(206) + expect(open.headers.get('content-range')).toBe('bytes 4-5/6') + expect(new Uint8Array(await open.arrayBuffer())).toEqual(new Uint8Array([14, 15])) + + // `bytes=-2` — the last N bytes. + const suffix = await fetchRange('bytes=-2') + expect(suffix.status).toBe(206) + expect(suffix.headers.get('content-range')).toBe('bytes 4-5/6') + expect(new Uint8Array(await suffix.arrayBuffer())).toEqual(new Uint8Array([14, 15])) + }) + test('GET /room/:dataBlob streams the room blob from R2', async () => { await env.CDN_ASSETS.put('room/94tp5zjtwz0gppp8xlv1j9l5b.room', new Uint8Array([9, 8, 7])) const res = await exports.default.fetch(`${ORIGIN}/room/94tp5zjtwz0gppp8xlv1j9l5b.room`) @@ -79,4 +100,42 @@ describe('cdn endpoints', () => { const res = await exports.default.fetch(`${ORIGIN}/invention/missing.inv`) 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) + const spec = (await res.json()) as { + openapi: string + paths: Record> + } + expect(spec.openapi).toMatch(/^3\.1/) + + // The spec route hides itself. + expect(spec.paths['/openapi.json']).toBeUndefined() + + // Every route the worker serves is described. This is the drift guard: adding a + // route without a describeRoute() block fails here rather than silently shipping + // an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`. + const documented = new Set( + Object.entries(spec.paths).flatMap(([path, ops]) => + Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) + ) + ) + expect([...documented].sort()).toEqual([ + 'GET /', + 'GET /config/LoadingScreenTipData', + 'GET /invention/{dataBlob}', + 'GET /room/{dataBlob}', + 'GET /sigs/{sigName}', + ]) + + // Every operation carries a summary — a path present but undescribed is not + // documentation. + for (const ops of Object.values(spec.paths)) { + for (const op of Object.values(ops)) expect(op.summary).toBeTruthy() + } + + // Schemas must inline: a `$ref` here is a dangling reference (see openapi.ts). + expect(JSON.stringify(spec).includes('"$ref"')).toBe(false) + }) }) diff --git a/apps/www/src/docs.ts b/apps/www/src/docs.ts index 9c23cb8..be593ec 100644 --- a/apps/www/src/docs.ts +++ b/apps/www/src/docs.ts @@ -25,6 +25,7 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }> { slug: 'clubs', title: 'clubs — clubs & clubhouses' }, { slug: 'chat', title: 'chat — threads & messages' }, { slug: 'img', title: 'img — image serving & resizing' }, + { slug: 'cdn', title: 'cdn — binary asset delivery' }, { slug: 'storage', title: 'storage — uploads to the CDN bucket' }, { slug: 'playersettings', title: 'playersettings — per-player settings' }, { slug: 'api', title: 'api — everything else' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c52b85d..403e1ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,12 +215,27 @@ importers: '@repo/jwt': specifier: workspace:* version: link:../../packages/jwt + '@standard-community/standard-json': + specifier: 0.3.5 + version: 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3) + '@standard-community/standard-openapi': + specifier: 0.2.9 + version: 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3) hono: specifier: 4.12.27 version: 4.12.27 + hono-openapi: + specifier: 1.3.1 + version: 1.3.1(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.27))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.27)(openapi-types@12.1.3) + openapi-types: + specifier: 12.1.3 + version: 12.1.3 workers-tagged-logger: specifier: 1.0.1 version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@cloudflare/vitest-pool-workers': specifier: 0.16.20