import { env, waitUntil } from "cloudflare:workers"; import { contentTypeForAsset, parseVideoPath, videoR2Key } from "~/lib/video"; const IMMUTABLE = "public, max-age=31536000, immutable"; const RANGE = /^bytes=(\d*)-(\d*)$/; function text(request: Request, status: number, message: string, extra: Record = {}): Response { return new Response(request.method === "HEAD" ? null : message, { status, headers: { "content-type": "text/plain", "cache-control": "no-store", ...extra }, }); } function parseRange(header: string | null): R2Range | undefined | null { if (!header) return undefined; const match = RANGE.exec(header.trim()); if (!match || (!match[1] && !match[2])) return null; if (!match[1]) return { suffix: Number(match[2]) }; const offset = Number(match[1]); if (!match[2]) return { offset }; const end = Number(match[2]); return end < offset ? null : { offset, length: end - offset + 1 }; } function assetHeaders(asset: string, object: R2Object): Headers { return new Headers({ "content-type": contentTypeForAsset(asset), "cache-control": IMMUTABLE, etag: object.httpEtag, "accept-ranges": "bytes", "x-content-type-options": "nosniff", }); } /** * Prepared HLS assets live in R2 under `videos///`. Each version is immutable, so the colo cache * holds full objects forever; Range and If-None-Match are answered by the cache on a hit. A ranged miss reads * just that range from R2 and is not cached, the next full fetch fills the cache. */ export async function handleVideoRoutes(request: Request, url: URL): Promise { const path = parseVideoPath(url.pathname); if (!path) return url.pathname.startsWith("/videos/") ? text(request, 404, "Not found") : null; if (request.method !== "GET" && request.method !== "HEAD") { return text(request, 405, "Method not allowed", { allow: "GET, HEAD" }); } const cache = await caches.open("videos"); const cacheKey = `${url.origin}${url.pathname}`; const hit = await cache.match(new Request(cacheKey, { headers: request.headers })); if (hit) return hit; const range = parseRange(request.headers.get("range")); if (range === null) return text(request, 416, "Invalid range"); const key = videoR2Key(path); if (request.method === "HEAD") { const head = await env.BUCKET.head(key); if (!head) return text(request, 404, "Not found"); const headers = assetHeaders(path.asset, head); headers.set("content-length", String(head.size)); return new Response(null, { headers }); } const object = await env.BUCKET.get(key, { range, onlyIf: { etagDoesNotMatch: request.headers.get("if-none-match")?.replace(/^W\//, "") ?? undefined }, }); if (!object) return text(request, 404, "Not found"); const headers = assetHeaders(path.asset, object); if (!("body" in object)) return new Response(null, { status: 304, headers }); if (range && object.range) { const { offset, length } = resolveRange(object.range, object.size); headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${object.size}`); headers.set("content-length", String(length)); return new Response(object.body, { status: 206, headers }); } headers.set("content-length", String(object.size)); const response = new Response(object.body, { headers }); waitUntil(cache.put(cacheKey, response.clone())); return response; } function resolveRange(range: R2Range, size: number): { offset: number; length: number } { if ("suffix" in range) { const length = Math.min(range.suffix, size); return { offset: size - length, length }; } const offset = range.offset ?? 0; return { offset, length: Math.min(range.length ?? size - offset, size - offset) }; }