feat(cloudflare): add linearvoid-media edge worker for R2 HLS delivery

Replace the ad-hoc `worker.js` with a self-contained `linearvoid-media`
worker under `infrastructure/cloudflare/` that owns `media.linearvoid.tv`.
It serves HLS objects from the R2 bucket, enforcing immutable Cache-Control
on segments, a short TTL on live manifests, plus HLS CORS, OPTIONS
preflight, Range support, and a `/health` probe. Includes wrapper config
with production domain, Vitest unit tests, Miniflare R2 integration tests,
automated deploy workflow, and docs updates.
This commit is contained in:
zwitschi committed 2026-09-03 08:52:31 +02:00
1 parent 5f1f796a20
commit 8c5e70959b
16 files changed
+3359 -42

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules/
.wrangler/
dist/
coverage/
*.log
+65
View File
@@ -0,0 +1,65 @@
# linearvoid-media Worker
Cloudflare Worker that owns **`media.linearvoid.tv`** and serves the HLS objects
stored in the `linearvoid-media` R2 bucket, applying the edge cache policy in code:
| Object | Cache-Control |
| ------------------------------------------- | ------------------------------------------------ |
| `.m4s` / `.ts` / `.mp4` / `.m4a` (segments) | `public, max-age=31536000, immutable` |
| `.m3u8` (live manifest) | `public, max-age=1, s-maxage=2, must-revalidate` |
It also adds HLS CORS (`Access-Control-Allow-Origin: *`), OPTIONS preflight, a
`/health` probe, and Range-request support. This worker replaces direct R2
custom-domain serving on the media host so cache headers/CORS are controlled in
code (cache rules on the same host remain the origin baseline).
## Layout
```text
infrastructure/cloudflare/
├── wrangler.toml # name/binding + production custom-domain route
├── package.json
├── src/worker.js # worker module (exports default.fetch + pure helpers)
└── test/ # Vitest unit tests + Miniflare (workerd) R2 tests
```
## Local dev / test
```bash
cd infrastructure/cloudflare
npm install
npm test # unit + Miniflare R2 integration tests
npx wrangler dev # run locally against your account's R2 bucket
```
## Deploy
**Preview / test target** (default environment → `*.workers.dev`):
```bash
cd infrastructure/cloudflare
npx wrangler deploy
```
**Production** (attaches `media.linearvoid.tv` as a custom domain on this worker):
```bash
cd infrastructure/cloudflare
npx wrangler deploy --env production
```
Both need `CLOUDFLARE_API_TOKEN` (Workers Scripts/Routes edit) and
`CLOUDFLARE_ACCOUNT_ID` in the environment. The media host must first be removed
from the R2 bucket's direct custom-domain binding, otherwise the route collides.
Automated production deploys run via
[`.gitea/workflows/workers-media-deploy.yml`](../../.gitea/workflows/workers-media-deploy.yml)
after CI succeeds on `main`.
## Verify
```bash
curl -I https://media.linearvoid.tv/hls/playlist.m3u8 # short TTL
curl -I https://media.linearvoid.tv/hls/<segment>.m4s # immutable + CORS
curl https://media.linearvoid.tv/health # {"status":"ok",...}
```
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "linearvoid-media-worker",
"version": "1.0.0",
"private": true,
"description": "Linear Void media edge worker: serves HLS from R2 on media.linearvoid.tv with immutable segment caching.",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"start": "wrangler dev",
"deploy": "wrangler deploy",
"deploy:production": "wrangler deploy --env production",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"miniflare": "^4.0.0",
"vitest": "^4.1.10",
"wrangler": "^4.0.0"
}
}
+144
View File
@@ -0,0 +1,144 @@
// Linear Void media edge worker (linearvoid-media).
//
// Owns the media.linearvoid.tv host and serves HLS objects from the R2 bucket
// bound as MEDIA_BUCKET. Its purpose (arc42 quality goal #1, cost efficiency) is
// to guarantee immutable, long-lived caching of media segments at the edge while
// keeping live manifests fresh — the caching baseline is also configured as
// Cloudflare cache rules on the same host, but this worker enforces it in code.
//
// Cache policy (matches docs/infrastructure/cloudflare/cache-rules.md + STREAMING.md):
// .m4s / .ts / .mp4 / .m4a -> public, max-age=31536000, immutable
// .m3u8 (live manifest) -> public, max-age=1, s-maxage=2, must-revalidate
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
const MANIFEST_CACHE = "public, max-age=1, s-maxage=2, must-revalidate";
const DEFAULT_CACHE = "public, max-age=60";
const IMMUTABLE_SUFFIXES = [".m4s", ".ts", ".mp4", ".m4a"];
const MANIFEST_SUFFIXES = [".m3u8"];
const CONTENT_TYPES = {
".m3u8": "application/vnd.apple.mpegurl",
".m4s": "video/mp4",
".mp4": "video/mp4",
".ts": "video/MP2T",
".m4a": "audio/mp4",
};
/** Map a request path to its edge Cache-Control value. Pure + testable. */
export function cacheControlForPath(pathname) {
const lower = pathname.toLowerCase();
for (const suffix of MANIFEST_SUFFIXES) {
if (lower.endsWith(suffix)) return MANIFEST_CACHE;
}
for (const suffix of IMMUTABLE_SUFFIXES) {
if (lower.endsWith(suffix)) return IMMUTABLE_CACHE;
}
return DEFAULT_CACHE;
}
/** Map a request path to a browser-friendly Content-Type. Pure + testable. */
export function contentTypeForPath(pathname) {
const lower = pathname.toLowerCase();
for (const [suffix, type] of Object.entries(CONTENT_TYPES)) {
if (lower.endsWith(suffix)) return type;
}
return "application/octet-stream";
}
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Range, If-None-Match, If-Modified-Since",
"Access-Control-Max-Age": "86400",
};
function json(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
// Parse a single, closed byte range ("bytes=start-end") into an R2 range object.
// Returns null for open/suffix/multi/invalid ranges — the caller then serves the
// full object (HTTP 200), which is valid per RFC 7233 (a server may ignore Range).
export function parseByteRange(header) {
if (!header) return null;
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!m) return null;
const start = m[1];
const end = m[2];
if (start === "" || end === "") return null; // open or suffix range
const offset = Number(start);
const length = Number(end) - offset + 1;
if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 1) {
return null;
}
return { offset, length };
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
// CORS preflight for HLS media.
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
// Liveness probe used by uptime/monitoring (see cloudflare-uptimerobot-checklist).
if (url.pathname === "/health") {
return json({ status: "ok", service: "linearvoid-media" });
}
if (request.method !== "GET" && request.method !== "HEAD") {
return json({ error: "method not allowed" }, 405);
}
const key = decodeURIComponent(url.pathname).replace(/^\/+/, "");
if (!key) {
return json({ error: "not found" }, 404);
}
if (!env.MEDIA_BUCKET) {
return json({ error: "media bucket not configured" }, 500);
}
// Serve from R2, honouring single closed Range requests (used by some hls.js
// / player paths for byte-range fetches). Unsupported ranges fall back to a
// full-body 200, which clients accept.
const rangeHeader = request.headers.get("range");
const parsedRange = parseByteRange(rangeHeader);
const object = parsedRange
? await env.MEDIA_BUCKET.get(key, { range: parsedRange })
: await env.MEDIA_BUCKET.get(key);
if (object === null) {
return json({ error: "not found" }, 404);
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("Cache-Control", cacheControlForPath(url.pathname));
headers.set("Content-Type", contentTypeForPath(url.pathname));
headers.set("ETag", object.httpEtag);
headers.set("Accept-Ranges", "bytes");
headers.set("Access-Control-Allow-Origin", "*");
let status = 200;
if (parsedRange && object.range) {
status = 206;
const { offset, length } = object.range;
headers.set(
"Content-Range",
`bytes ${offset}-${offset + length - 1}/${object.size}`
);
}
return new Response(request.method === "HEAD" ? null : object.body, {
status,
headers,
});
},
};
@@ -0,0 +1,77 @@
import { Miniflare } from "miniflare";
import { beforeAll, afterAll, describe, it, expect } from "vitest";
const IMMUTABLE = "public, max-age=31536000, immutable";
const MANIFEST = "public, max-age=1, s-maxage=2, must-revalidate";
// Real-workerd integration test: boots the worker with an in-memory R2 bucket and
// dispatches requests through the fetch handler.
let mf;
beforeAll(async () => {
mf = new Miniflare({
modules: true,
scriptPath: "src/worker.js",
r2Buckets: { MEDIA_BUCKET: "linearvoid-media" },
});
const bucket = await mf.getR2Bucket("MEDIA_BUCKET");
await bucket.put("hls/playlist.m3u8", "#EXTM3U\n#EXT-X-ENDLIST\n", {
httpMetadata: { contentType: "application/vnd.apple.mpegurl" },
});
await bucket.put("hls/seg-000001.m4s", new Uint8Array([0, 1, 2, 3, 4]));
await bucket.put("hls/init.mp4", new Uint8Array([9, 9, 9]));
});
afterAll(async () => {
await mf?.dispose();
});
const base = "http://media.linearvoid.tv";
describe("linearvoid-media worker (R2)", () => {
it("serves a media segment with immutable cache headers", async () => {
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`);
expect(res.status).toBe(200);
expect(res.headers.get("cache-control")).toBe(IMMUTABLE);
expect(res.headers.get("content-type")).toBe("video/mp4");
expect(res.headers.get("access-control-allow-origin")).toBe("*");
expect(await res.arrayBuffer()).toEqual(new Uint8Array([0, 1, 2, 3, 4]).buffer);
});
it("serves a live manifest with a short TTL", async () => {
const res = await mf.dispatchFetch(`${base}/hls/playlist.m3u8`);
expect(res.status).toBe(200);
expect(res.headers.get("cache-control")).toBe(MANIFEST);
expect(res.headers.get("content-type")).toBe("application/vnd.apple.mpegurl");
});
it("honours Range requests with 206 + Content-Range", async () => {
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
headers: { Range: "bytes=0-2" },
});
expect(res.status).toBe(206);
expect(res.headers.get("content-range")).toBe("bytes 0-2/5");
const bytes = new Uint8Array(await res.arrayBuffer());
expect([...bytes]).toEqual([0, 1, 2]);
});
it("returns 404 for a missing object", async () => {
const res = await mf.dispatchFetch(`${base}/hls/does-not-exist.m4s`);
expect(res.status).toBe(404);
});
it("answers OPTIONS preflight with CORS headers", async () => {
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
method: "OPTIONS",
});
expect(res.status).toBe(204);
expect(res.headers.get("access-control-allow-origin")).toBe("*");
});
it("rejects non-GET/HEAD methods", async () => {
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
method: "POST",
});
expect(res.status).toBe(405);
});
});
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { cacheControlForPath, contentTypeForPath, parseByteRange } from "../src/worker.js";
const IMMUTABLE = "public, max-age=31536000, immutable";
const MANIFEST = "public, max-age=1, s-maxage=2, must-revalidate";
const DEFAULT = "public, max-age=60";
describe("cacheControlForPath", () => {
it("marks media segments as immutable", () => {
expect(cacheControlForPath("/hls/seg-000001.m4s")).toBe(IMMUTABLE);
expect(cacheControlForPath("/hls/init.mp4")).toBe(IMMUTABLE);
expect(cacheControlForPath("/hls/seg-000001.ts")).toBe(IMMUTABLE);
expect(cacheControlForPath("/media/audio.m4a")).toBe(IMMUTABLE);
});
it("keeps live manifests short-lived", () => {
expect(cacheControlForPath("/hls/playlist.m3u8")).toBe(MANIFEST);
expect(cacheControlForPath("/hls/master.m3u8")).toBe(MANIFEST);
});
it("falls back to a sane default for other paths", () => {
expect(cacheControlForPath("/hls/unknown.txt")).toBe(DEFAULT);
});
});
describe("contentTypeForPath", () => {
it("maps known media types", () => {
expect(contentTypeForPath("/hls/playlist.m3u8")).toBe("application/vnd.apple.mpegurl");
expect(contentTypeForPath("/hls/seg-000001.m4s")).toBe("video/mp4");
expect(contentTypeForPath("/hls/init.mp4")).toBe("video/mp4");
expect(contentTypeForPath("/hls/seg-000001.ts")).toBe("video/MP2T");
expect(contentTypeForPath("/media/audio.m4a")).toBe("audio/mp4");
});
it("falls back to octet-stream", () => {
expect(contentTypeForPath("/hls/unknown.bin")).toBe("application/octet-stream");
});
});
describe("parseByteRange", () => {
it("parses a closed byte range", () => {
expect(parseByteRange("bytes=0-2")).toEqual({ offset: 0, length: 3 });
expect(parseByteRange("bytes=10-19")).toEqual({ offset: 10, length: 10 });
});
it("rejects open, suffix, multi, or malformed ranges", () => {
expect(parseByteRange("bytes=0-")).toBeNull();
expect(parseByteRange("bytes=-500")).toBeNull();
expect(parseByteRange("bytes=0-1,3-4")).toBeNull();
expect(parseByteRange("items=0-2")).toBeNull();
expect(parseByteRange("")).toBeNull();
expect(parseByteRange(null)).toBeNull();
});
});
-21
View File
@@ -1,21 +0,0 @@
// Cloudflare Worker for advanced cache rules and WAF (ADR-001)
addEventListener("fetch", (event) => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
// Add immutable cache headers for .m4s segments
if (url.pathname.endsWith(".m4s")) {
const response = await fetch(request);
const headers = new Headers(response.headers);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
return new Response(response.body, {
status: response.status,
headers,
});
}
return fetch(request);
}
+25
View File
@@ -0,0 +1,25 @@
# Cloudflare Worker: linearvoid-media (edge HLS delivery)
# Serves HLS objects from the `linearvoid-media` R2 bucket on media.linearvoid.tv.
# Applies immutable caching to media segments and a short TTL to live manifests.
# This worker replaces direct R2 custom-domain serving on the media host so cache
# headers/CORS are controlled in code (cache rules remain as the origin baseline).
name = "linearvoid-media"
main = "src/worker.js"
compatibility_date = "2025-08-01"
# R2 bucket holding the HLS objects. The request path is mirrored onto the bucket,
# e.g. GET /hls/playlist.m3u8 -> object key `hls/playlist.m3u8`.
[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "linearvoid-media"
# Default environment: deploy to the preview *.workers.dev URL (test target).
# npx wrangler deploy
#
# Production environment: attach the media host as a custom domain on this worker.
# npx wrangler deploy --env production
[env.production]
routes = [
{ pattern = "media.linearvoid.tv", custom_domain = true }
]