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,35 @@
|
|||||||
|
name: Deploy Media Worker to Cloudflare
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [CI]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Production deploy: only on pushes to main (via CI success) or manual main runs.
|
||||||
|
if: ${{ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd infrastructure/cloudflare && npm ci
|
||||||
|
|
||||||
|
- name: Test worker
|
||||||
|
run: cd infrastructure/cloudflare && npm test
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Workers (production)
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: |
|
||||||
|
cd infrastructure/cloudflare
|
||||||
|
npx wrangler deploy --env production
|
||||||
@@ -6,11 +6,13 @@
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Cloudflare media edge worker (`linearvoid-media`)**: New self-contained project under `infrastructure/cloudflare/` that owns `media.linearvoid.tv` and serves HLS objects from the `linearvoid-media` R2 bucket via an `r2_buckets` binding. Enforces immutable `Cache-Control` for segments (`max-age=31536000, immutable`) and a short TTL for live `.m3u8`, with HLS CORS, OPTIONS preflight, Range support, and a `/health` probe. Replaces the ad-hoc `worker.js`. Includes `wrangler.toml` (default preview + `production` env attaching the media custom domain), Vitest unit tests, Miniflare/workerd R2 integration tests, and automated production deploy via `.gitea/workflows/workers-media-deploy.yml`.
|
||||||
- **Bash equivalents of Windows scripts**: Added `scripts/cache-hit-ratio.sh`, `scripts/check-env.sh`, `scripts/check-tokens.sh`, and `scripts/loadtest/run-loadtests.sh`, `scripts/loadtest/summarize-artillery.sh`, `scripts/loadtest/summarize-k6.sh` — Linux/macOS ports of the existing PowerShell scripts (the `.ps1` files are retained).
|
- **Bash equivalents of Windows scripts**: Added `scripts/cache-hit-ratio.sh`, `scripts/check-env.sh`, `scripts/check-tokens.sh`, and `scripts/loadtest/run-loadtests.sh`, `scripts/loadtest/summarize-artillery.sh`, `scripts/loadtest/summarize-k6.sh` — Linux/macOS ports of the existing PowerShell scripts (the `.ps1` files are retained).
|
||||||
- **Transient presence flash**: Join/leave announcements now appear as a 3-second flash at the top of the chat panel (`#chat-flash`) with a fade-out, instead of permanent chat rows. Presence indicators still update from the same events.
|
- **Transient presence flash**: Join/leave announcements now appear as a 3-second flash at the top of the chat panel (`#chat-flash`) with a fade-out, instead of permanent chat rows. Presence indicators still update from the same events.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **Cloudflare worker docs updated**: README, SETUP, architecture, and Cloudflare ops guides now describe the `linearvoid-media` edge worker (R2-bound on `media.linearvoid.tv`) and the `linearvoid-frontend` SPA worker, replacing stale `worker.js` references and the R2 custom-domain wording for the media host.
|
||||||
- **Coolify host-port de-collision**: Published playout on host port `8081` (`${PLAYOUT_PORT:-8081}:8081`) and moved the nginx host port from `8080`/`80` to a free `8082` (`${NGINX_PORT:-8082}:80`) in both `docker-compose.yml` and `docker-compose.prod.yml`, so no service binds ports reserved by the Coolify environment (Traefik proxy `80`/`443`, dashboard/Portainer `8000`, and the taken `8080`). `NGINX_PORT` default in `.env.example` updated from `80` to `8082`.
|
- **Coolify host-port de-collision**: Published playout on host port `8081` (`${PLAYOUT_PORT:-8081}:8081`) and moved the nginx host port from `8080`/`80` to a free `8082` (`${NGINX_PORT:-8082}:80`) in both `docker-compose.yml` and `docker-compose.prod.yml`, so no service binds ports reserved by the Coolify environment (Traefik proxy `80`/`443`, dashboard/Portainer `8000`, and the taken `8080`). `NGINX_PORT` default in `.env.example` updated from `80` to `8082`.
|
||||||
|
|
||||||
- **Docs condensed and de-Windows'd**: Removed Windows/PowerShell-specific instructions from the docs and condensed the longest documents into tighter, bash-first guides — `SETUP.md`, `DEPLOYMENT.md`, `API.md`, `MONITORING.md`, `STREAMING.md`, `COOLIFY.md`, `incident-runbook.md`, `operations/scaling-performance-playbook.md`, `architecture/README.md`, and the Prometheus metrics inventory. `scripts/loadtest/README.md` now documents the bash load-test helpers.
|
- **Docs condensed and de-Windows'd**: Removed Windows/PowerShell-specific instructions from the docs and condensed the longest documents into tighter, bash-first guides — `SETUP.md`, `DEPLOYMENT.md`, `API.md`, `MONITORING.md`, `STREAMING.md`, `COOLIFY.md`, `incident-runbook.md`, `operations/scaling-performance-playbook.md`, `architecture/README.md`, and the Prometheus metrics inventory. `scripts/loadtest/README.md` now documents the bash load-test helpers.
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
- Frontend: `presence flash` test.
|
- Frontend: `presence flash` test.
|
||||||
- Playout: `TestCleanupOldRuns`, `TestCleanupOldRuns_SizeCap`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`.
|
- Playout: `TestCleanupOldRuns`, `TestCleanupOldRuns_SizeCap`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`.
|
||||||
- Playout: `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
- Playout: `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
||||||
|
- Cloudflare media worker: Vitest unit tests (`cacheControlForPath`, `contentTypeForPath`, `parseByteRange`) and Miniflare R2 integration tests (immutable segment, short-TTL manifest, Range `206`, `404`, OPTIONS CORS, method guard).
|
||||||
|
|
||||||
## [1.0.13] - 2026-08-13
|
## [1.0.13] - 2026-08-13
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,8 @@ frontend/
|
|||||||
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||||
|
|
||||||
- Cloudflare
|
- Cloudflare
|
||||||
- [worker.js](infrastructure/cloudflare/worker.js) — Cloudflare Worker serving the static SPA and adding immutable cache headers to HLS segments
|
- [linearvoid-media worker](infrastructure/cloudflare/) — R2-bound edge Worker serving HLS on `media.linearvoid.tv` (immutable segment caching, CORS, Range)
|
||||||
|
- [linearvoid-frontend worker](frontend/wrangler.toml) — serves the static SPA on `linearvoid.tv`
|
||||||
- Nginx
|
- Nginx
|
||||||
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
||||||
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
||||||
|
|||||||
+2
-2
@@ -70,7 +70,7 @@ flowchart LR
|
|||||||
2. S3 endpoint: `https://<account-id>.r2.cloudflarestorage.com` → `R2_ENDPOINT`.
|
2. S3 endpoint: `https://<account-id>.r2.cloudflarestorage.com` → `R2_ENDPOINT`.
|
||||||
3. Attach `media.linearvoid.tv` as custom media domain → `MEDIA_DOMAIN`; add CORS allowing `https://linearvoid.tv` GET for HLS delivery.
|
3. Attach `media.linearvoid.tv` as custom media domain → `MEDIA_DOMAIN`; add CORS allowing `https://linearvoid.tv` GET for HLS delivery.
|
||||||
4. Point `linearvoid.tv` (+ `www` redirect) at the app entrypoint; keep `media.*` on R2. Proxied DNS, TLS **Full (strict)**.
|
4. Point `linearvoid.tv` (+ `www` redirect) at the app entrypoint; keep `media.*` on R2. Proxied DNS, TLS **Full (strict)**.
|
||||||
5. Apply the `.m4s`/`.m3u8` cache rules from [`cache-rules.md`](./infrastructure/cloudflare/cache-rules.md); enable Smart Tiered Cache; optionally deploy `infrastructure/cloudflare/worker.js` for header normalization.
|
5. Apply the `.m4s`/`.m3u8` cache rules from [`cache-rules.md`](./infrastructure/cloudflare/cache-rules.md); enable Smart Tiered Cache; deploy the [`linearvoid-media` worker](../infrastructure/cloudflare/) on `media.linearvoid.tv` (replace the R2 bucket's direct custom-domain binding on that host) to enforce immutable segment caching + HLS CORS.
|
||||||
6. Verify: `aws s3 ls --endpoint-url $R2_ENDPOINT`; check cache hit ratio after serving traffic.
|
6. Verify: `aws s3 ls --endpoint-url $R2_ENDPOINT`; check cache hit ratio after serving traffic.
|
||||||
|
|
||||||
## Running the Application
|
## Running the Application
|
||||||
@@ -129,7 +129,7 @@ Coolify replaces the Gitea CD `DOCKER_HOST` orchestration (Cloudflare DNS/R2/cac
|
|||||||
### Production (Hetzner VPS + Cloudflare)
|
### Production (Hetzner VPS + Cloudflare)
|
||||||
|
|
||||||
1. Provision CX22 in Ashburn, VA (ADR-001).
|
1. Provision CX22 in Ashburn, VA (ADR-001).
|
||||||
2. Cloudflare: R2 bucket (`enam` hint), `media.linearvoid.tv` custom domain + cache rules, `linearvoid.tv`/`www` at the app entrypoint with `www`→apex redirect, Full (strict) TLS, deploy `infrastructure/cloudflare/worker.js`.
|
2. Cloudflare: R2 bucket (`enam` hint), deploy the [`linearvoid-media` worker](../infrastructure/cloudflare/) on `media.linearvoid.tv` + cache rules, `linearvoid.tv`/`www` at the app entrypoint with `www`→apex redirect, Full (strict) TLS.
|
||||||
3. Deploy from the project: `docker compose -f docker-compose.prod.yml up -d` (or pull from Gitea registry); manage under systemd.
|
3. Deploy from the project: `docker compose -f docker-compose.prod.yml up -d` (or pull from Gitea registry); manage under systemd.
|
||||||
4. Monitoring: Grafana dashboards from `monitoring/grafana/`.
|
4. Monitoring: Grafana dashboards from `monitoring/grafana/`.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ Linear Void architecture is documented using the [arc42 template](https://arc42.
|
|||||||
|
|
||||||
> Companion docs: see [Documentation README](../README.md) and `adr/` for full decision rationale, alternatives, and consequences.
|
> Companion docs: see [Documentation README](../README.md) and `adr/` for full decision rationale, alternatives, and consequences.
|
||||||
|
|
||||||
|
- [Architecture Documentation for Linear Void](#architecture-documentation-for-linear-void)
|
||||||
|
- [Introduction and Goals](#introduction-and-goals)
|
||||||
|
- [Architecture Constraints](#architecture-constraints)
|
||||||
|
- [Context and Scope](#context-and-scope)
|
||||||
|
- [Solution Strategy](#solution-strategy)
|
||||||
|
- [Building Block View](#building-block-view)
|
||||||
|
- [Runtime View](#runtime-view)
|
||||||
|
- [Deployment View](#deployment-view)
|
||||||
|
- [Cross-cutting Concepts](#cross-cutting-concepts)
|
||||||
|
- [Architecture Decisions](#architecture-decisions)
|
||||||
|
- [Quality Requirements](#quality-requirements)
|
||||||
|
- [Risks and Technical Debts](#risks-and-technical-debts)
|
||||||
|
- [Glossary](#glossary)
|
||||||
|
|
||||||
## Introduction and Goals
|
## Introduction and Goals
|
||||||
|
|
||||||
**Requirements Overview** — Linear Void is an independent **24/7 linear video channel** broadcasting self-owned pre-recorded videos to a primarily US audience via a custom lightweight HTML/CSS/JS SPA with synchronized HLS playback, schedule metadata, public chat rooms, and private 1:1 messaging. Fully independent of third-party video platforms (YouTube, Twitch, etc.).
|
**Requirements Overview** — Linear Void is an independent **24/7 linear video channel** broadcasting self-owned pre-recorded videos to a primarily US audience via a custom lightweight HTML/CSS/JS SPA with synchronized HLS playback, schedule metadata, public chat rooms, and private 1:1 messaging. Fully independent of third-party video platforms (YouTube, Twitch, etc.).
|
||||||
@@ -386,7 +400,7 @@ _Cache rules baseline_: `.ts`/`.m4s` Cache Everything, long TTL, immutable; `.m3
|
|||||||
|
|
||||||
_Edge security/controls_: enforce HTTPS/WSS + redirect HTTP; WAF baseline; rate limits on abusive patterns (auth/admin); minimize origin exposure.
|
_Edge security/controls_: enforce HTTPS/WSS + redirect HTTP; WAF baseline; rate limits on abusive patterns (auth/admin); minimize origin exposure.
|
||||||
|
|
||||||
_Worker & header policy_: Worker logic for immutable cache headers/request normalization where cache rules insufficient (`infrastructure/cloudflare/worker.js`); explicit CORS for HLS.
|
_Worker & header policy_: the `linearvoid-media` Worker ([`infrastructure/cloudflare/`](../../infrastructure/cloudflare/), R2-bound) owns `media.linearvoid.tv` and enforces immutable caching for segments, short TTL for live manifests, plus HLS CORS/Range; the `linearvoid-frontend` Worker ([`frontend/wrangler.toml`](../../frontend/wrangler.toml)) serves the static SPA on `linearvoid.tv`. Automated deploys: `workers-deploy.yml` (SPA) and `workers-media-deploy.yml` (media).
|
||||||
|
|
||||||
_Operations & validation_: verify cache status progression (MISS→HIT), manifest freshness under active playout; track hit ratio, request/error rates, ToS/cost posture.
|
_Operations & validation_: verify cache status progression (MISS→HIT), manifest freshness under active playout; track hit ratio, request/error rates, ToS/cost posture.
|
||||||
|
|
||||||
|
|||||||
@@ -2,32 +2,35 @@
|
|||||||
|
|
||||||
Linear Void uses Cloudflare Workers for two purposes:
|
Linear Void uses Cloudflare Workers for two purposes:
|
||||||
|
|
||||||
| Product | Purpose | What |
|
| Product | Purpose | What |
|
||||||
| ----------- | ------------------ | ------------------------------------------------------------------------------------ |
|
| ----------- | ------------------ | -------------------------------------------------------------------------------------------- |
|
||||||
| **Workers** | Static SPA hosting | Vite-built frontend (`frontend/dist`) served as Worker static assets |
|
| **Workers** | Static SPA hosting | `linearvoid-frontend` (`frontend/wrangler.toml`) — Vite SPA (`frontend/dist`) static assets |
|
||||||
| **Workers** | Edge cache logic | `infrastructure/cloudflare/worker.js` — adds immutable cache headers to HLS segments |
|
| **Workers** | Edge HLS delivery | `linearvoid-media` (`infrastructure/cloudflare/`) — R2-bound Worker on `media.linearvoid.tv` |
|
||||||
|
|
||||||
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
||||||
|
|
||||||
## Dashboard URLs
|
## Dashboard URLs
|
||||||
|
|
||||||
| Resource | URL |
|
| Resource | URL |
|
||||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
||||||
| Worker: linearvoid | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` |
|
| Worker: linearvoid-media | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid-media/production/settings` |
|
||||||
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
||||||
|
|
||||||
## Worker: `linearvoid` (edge cache headers)
|
## Worker: `linearvoid-media` (edge HLS delivery)
|
||||||
|
|
||||||
The Worker at `infrastructure/cloudflare/worker.js` runs on the `linearvoid` Workers service. It intercepts requests and adds immutable `Cache-Control` headers to `.m4s` segment files.
|
The Worker in [`infrastructure/cloudflare/`](../../infrastructure/cloudflare/) runs on the `linearvoid-media` Workers service. It owns the `media.linearvoid.tv` host, serves HLS objects from the R2 bucket (bound as `MEDIA_BUCKET`), and stamps immutable `Cache-Control` on segments plus a short TTL on live manifests (CORS + Range supported). See its [README](../../infrastructure/cloudflare/README.md).
|
||||||
|
|
||||||
Deploy via Wrangler CLI:
|
Test / deploy:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx wrangler deploy infrastructure/cloudflare/worker.js
|
cd infrastructure/cloudflare
|
||||||
|
npm install && npm test # unit + Miniflare R2 tests
|
||||||
|
npx wrangler deploy # preview/test target
|
||||||
|
npx wrangler deploy --env production # attach media.linearvoid.tv
|
||||||
```
|
```
|
||||||
|
|
||||||
Or paste into the Workers dashboard at the URL above (Quick Edit).
|
Attach the production custom domain only after removing `media.linearvoid.tv` from the R2 bucket's direct custom-domain binding. Automated production deploy: [`.gitea/workflows/workers-media-deploy.yml`](../../.gitea/workflows/workers-media-deploy.yml).
|
||||||
|
|
||||||
## Worker: static frontend
|
## Worker: static frontend
|
||||||
|
|
||||||
@@ -56,9 +59,9 @@ Backend services (playout, chat, nginx) read their own runtime variables (`ADMIN
|
|||||||
|
|
||||||
## Custom domains
|
## Custom domains
|
||||||
|
|
||||||
- `linearvoid.tv` → Worker (static SPA)
|
- `linearvoid.tv` → Worker (static SPA, `linearvoid-frontend`)
|
||||||
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
||||||
- `media.linearvoid.tv` → R2 bucket (separate; not a Worker)
|
- `media.linearvoid.tv` → Worker (`linearvoid-media`, R2-bound) serving the `linearvoid-media` bucket
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare
|
|||||||
3. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
3. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||||
4. **Wrangler config** (`frontend/wrangler.toml`) binding the `frontend/dist` output as static assets.
|
4. **Wrangler config** (`frontend/wrangler.toml`) binding the `frontend/dist` output as static assets.
|
||||||
|
|
||||||
> **Naming conflict:** Workers share a namespace under "Workers & Pages". The edge-cache Worker (`infrastructure/cloudflare/worker.js`) already uses the `linearvoid` name, so the SPA Worker is named `linearvoid-frontend` (see `frontend/wrangler.toml`).
|
> **Naming:** Workers share a namespace under "Workers & Pages". The media edge Worker is `linearvoid-media` ([`infrastructure/cloudflare/`](../../infrastructure/cloudflare/wrangler.toml), deployed via `.gitea/workflows/workers-media-deploy.yml`) and the SPA Worker is `linearvoid-frontend` (`frontend/wrangler.toml`, deployed via `.gitea/workflows/workers-deploy.yml`).
|
||||||
|
|
||||||
## Project-specific values
|
## Project-specific values
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
@@ -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",...}
|
||||||
|
```
|
||||||
+2892
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
Reference in new issue
Block a user