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:
1 parent
5f1f796a20
commit
8c5e70959b
16 files changed
+3359
-42
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user