mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-10 07:31:27 -07:00
237 lines
9.1 KiB
TypeScript
237 lines
9.1 KiB
TypeScript
import { Hono } from 'hono'
|
||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||
|
||
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. 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. */
|
||
function parseRange(header: string | undefined): R2Range | undefined {
|
||
if (!header) return undefined
|
||
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
|
||
if (!m) return undefined
|
||
const start = m[1]
|
||
const end = m[2]
|
||
if (start === '' && end !== '') return { suffix: Number(end) } // last N bytes
|
||
if (start !== '') {
|
||
return end !== ''
|
||
? { offset: Number(start), length: Number(end) - Number(start) + 1 }
|
||
: { offset: Number(start) }
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
/**
|
||
* Stream a binary asset from the CDN R2 bucket as application/octet-stream,
|
||
* honoring Range requests. 404s when the file is missing.
|
||
* Supports conditional GET and byte-range requests (206) — large-file
|
||
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
|
||
* reassembled file (e.g. EAC "Signatures don't match").
|
||
*/
|
||
async function serveAsset(c: Context<App>, key: string) {
|
||
if (key.includes('..')) return c.body(null, 400)
|
||
|
||
const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '')
|
||
const range = parseRange(c.req.header('range'))
|
||
const object = await (c.env as Env).CDN_ASSETS.get(key, {
|
||
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
|
||
...(range ? { range } : {}),
|
||
})
|
||
if (!object) return c.notFound()
|
||
|
||
const headers = new Headers()
|
||
object.writeHttpMetadata(headers)
|
||
headers.set('etag', object.httpEtag)
|
||
headers.set('content-type', 'application/octet-stream')
|
||
headers.set('accept-ranges', 'bytes')
|
||
headers.set('cache-control', 'public, max-age=3600')
|
||
|
||
// Precondition matched (If-None-Match) → R2 returns no body.
|
||
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
||
|
||
// Range honored → 206 Partial Content with Content-Range.
|
||
if (object.range && c.req.header('range')) {
|
||
// 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 })
|
||
}
|
||
|
||
return new Response(object.body, { headers })
|
||
}
|
||
|
||
const app = new Hono<App>()
|
||
.use(
|
||
'*',
|
||
// middleware
|
||
(c, next) =>
|
||
useWorkersLogger(c.env.NAME, {
|
||
environment: c.env.ENVIRONMENT,
|
||
release: c.env.SENTRY_RELEASE,
|
||
})(c, next)
|
||
)
|
||
|
||
.onError(withOnError())
|
||
.notFound(withNotFound())
|
||
|
||
.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',
|
||
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',
|
||
describeRoute({
|
||
tags: ['Assets'],
|
||
summary: 'Serve a signature blob',
|
||
description: [
|
||
'Streams the object stored under `sigs/<sigName>`. 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/<uuid>`), so match the rest of
|
||
// the path.
|
||
.get(
|
||
'/room/:dataBlob{.+}',
|
||
describeRoute({
|
||
tags: ['Assets'],
|
||
summary: 'Serve room build data',
|
||
description: [
|
||
'Streams the object stored under `room/<dataBlob>` — 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/<uuid>`, 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{.+}',
|
||
describeRoute({
|
||
tags: ['Assets'],
|
||
summary: 'Serve invention data',
|
||
description: [
|
||
'Streams the object stored under `invention/<dataBlob>` — 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
|