Media Delivery Worker
The Media Delivery Worker serves the podcasterplus-media R2 bucket at media.podcasterplus.com and — additively since Epic 16 GATE-2 — at m.cdn.media, the enclosures' forever-URL home after the show.fm flip (the old host never comes down; live enclosure URLs hold it, D-23). It replaced the bare R2 custom domain that previously answered on the original hostname. It exists for one reason: a Worker in front of the bucket can log episode-audio downloads to Workers Analytics Engine while preserving the exact serving behaviour listener apps already depend on (full and ranged GETs, HEAD, conditional requests, edge caching).
Delivery is sacred: every logging step is wrapped so an analytics failure can never affect a response, and a missing binding or secret degrades to "serve without logging".
Overview
| Property | Value |
|---|---|
| URL | https://media.podcasterplus.com/{object-key} |
| Worker Name | podcasterplus-media-delivery |
| Source Location | /workers/media-delivery/ |
| Trigger | HTTP (GET/HEAD only) |
| Storage | R2 bucket podcasterplus-media (binding MEDIA_BUCKET) |
| Analytics | WAE dataset media_requests (binding MEDIA_ANALYTICS) |
| CPU Limit | cpu_ms = 50 (delivery path must stay tiny) |
| Database | None — this worker never touches Postgres |
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ Request Flow │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Listener app / browser │
│ │ GET media.podcasterplus.com/podcasts/{p}/e/{e}/audio/... │
│ ▼ │
│ ┌──────────────────┐ hit ┌──────────────────────────────────┐ │
│ │ caches.default │────────▶│ Response (200, or synthesized │ │
│ │ (edge cache) │ │ 206 from the stored full 200) │ │
│ └────────┬─────────┘ └──────────────────────────────────┘ │
│ │ miss │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ MEDIA_BUCKET.get(key, │ │
│ │ { range, onlyIf: headers }) │ │
│ └────────┬─────────────────────────┘ │
│ ▼ │
│ 200 / 206 / 304 / 412 / 416 / 404 │
│ │ │
│ ├── full 200 → ctx.waitUntil(cache.put(...)) │
│ └── audio key + 200/206/304 │
│ → ctx.waitUntil(writeDataPoint → media_requests) │
│ │
└─────────────────────────────────────────────────────────────────────────┘The bucket is public today; this worker preserves that exactly. Non-audio keys (podcast/episode images, avatars, imports/ staging) are served identically but never logged.
Serving Behaviour (R2 Parity)
Implemented in workers/media-delivery/src/index.ts.
- Method gate —
GET/HEADonly; anything else →405withAllow: GET, HEAD. - Key resolution — decoded pathname with leading
/stripped; empty or..-containing keys →404. - Edge cache first —
caches.default.match(request). Only full200s are ever stored (a206put throws); the Cache API synthesizes206s from a stored200for ranged requests becauseContent-Lengthis always stored. A cache lookup failure falls through to R2. - R2 get —
MEDIA_BUCKET.get(key, { range, onlyIf: request.headers })resolves everything in one call. - HEAD — same resolution via
bucket.head(), headers only, never logged as a download.
Response Codes
| Code | Condition |
|---|---|
200 | Full object; writeHttpMetadata headers + ETag + Content-Length + Accept-Ranges: bytes; written to cache |
206 | Satisfied range; explicit Content-Range: bytes start-end/size + slice Content-Length; served direct, never cached |
304 | onlyIf precondition matched an If-None-Match / If-Modified-Since request |
404 | Object not found, empty key, or .. in key (plain text, Cache-Control: public, max-age=60) |
405 | Non-GET/HEAD method |
412 | Body-less onlyIf result for a failed If-Match / If-Unmodified-Since (rare, upload-oriented) |
416 | Syntactically valid but unsatisfiable range (R2 get throws) → Content-Range: bytes */<size> |
Range Handling
src/lib/range.ts parses single byte ranges (bytes=0-1 probes, bytes=N- resumes, -N suffixes). Per RFC 9110 an unparseable Range header is ignored (full 200); multipart ranges (bytes=a-b,c-d) are deliberately served as a full 200 (RFC-permitted, not worth multipart/byteranges complexity). resolveSatisfiedRange() reads the range R2 actually returned so Content-Range and Content-Length are always accurate.
Cache-Control
Objects uploaded by the app carry public, max-age=31536000, immutable (audio is content-addressed by uploadId), restored via object.writeHttpMetadata(headers). Older/unstamped objects get the fallback:
const FALLBACK_CACHE_CONTROL = 'public, max-age=14400';ETag is always object.httpEtag; Last-Modified comes from object.uploaded.
Analytics Ingest
Only keys matching the episode-audio shape are logged (src/lib/audio-key.ts):
podcasts/{podcastId}/e/{episodeId}/audio/{uploadId}.{ext} (versioned)
podcasts/{podcastId}/e/{episodeId}/audio/original.{ext} (legacy)Statuses 200/206/304 are download signals and are logged; errors are not. Logging runs in ctx.waitUntil() inside its own try/catch — writeDataPoint is fire-and-forget and a throw is only ever console.error'd.
Datapoint Contract (media_requests)
BLOB ORDER IS A CONTRACT — the analytics-rollup worker addresses these by position (blob1..blob12 / double1..double2) in its SQL. Changing order or meaning requires a matching change in workers/analytics-rollup/src/lib/wae-queries.ts. Documented order from src/lib/log.ts:
index1: podcastId (per-show sampling fairness)
blob1: episodeId
blob2: podcastId
blob3: userAgent (truncated 400)
blob4: ipHash (daily-salted, see client-hash.ts)
blob5: country
blob6: region
blob7: city
blob8: colo
blob9: asOrganization (truncated 100)
blob10: rangeHeader (truncated 64)
blob11: status ('200' | '206' | '304')
blob12: cacheState ('hit' | 'miss')
double1: bytesServed (0 for 304)
double2: objectSizebytesServed is the slice length for 206, the object size for 200, and 0 for 304 (the rollup's SQL only reads 200/206 rows). Geo fields come from request.cf.
Privacy: Daily-HMAC IP Hash
Raw IPs are never stored. src/lib/client-hash.ts writes:
ipHash = hex(SHA-256(dailySalt + ':' + normalizedIp))
dailySalt = hex(HMAC-SHA-256(IP_HASH_SECRET, 'YYYY-MM-DD' UTC))- The salt is deterministic across isolates (no salt storage) and rotates at UTC midnight — exactly the IAB dedupe-window boundary, so cross-day correlation is impossible by design.
- IPv6 addresses are truncated to their /64 prefix before hashing (IAB rule); IPv4 uses the full address. The normalizer canonicalizes compressed/expanded spellings, zone indices, and IPv4-mapped tails so the same client always lands in the same per-day bucket.
- A missing
CF-Connecting-IPhashes the empty string — still a stable per-day bucket rather than a dropped row.
LOCKSTEP: workers/rss-feed/src/analytics/client-hash.ts is a copy of this module (workers are isolated package roots). Both workers share the same IP_HASH_SECRET value so media and feed datapoints hash consistently.
Fail-Open Logging Posture
The worker's posture is fail open on analytics, never on delivery:
- Missing
MEDIA_ANALYTICSbinding orIP_HASH_SECRETsecret → serve without logging (typed optional insrc/types/env.ts). - Any throw inside
logAudioRequest()→console.error, response unaffected. - Cache
match/putfailures → fall through to R2 / skip caching; serving already succeeded.
Project Structure
workers/media-delivery/
├── wrangler.toml # Worker configuration + takeover runbook
├── package.json # No runtime dependencies
├── tsconfig.json # TypeScript configuration
└── src/
├── index.ts # Entry point: serve + log orchestration
├── types/
│ └── env.ts # Environment bindings
├── lib/
│ ├── range.ts # Range header parsing / satisfied-range resolution
│ ├── audio-key.ts # Episode-audio key detection (the only logged keys)
│ ├── client-hash.ts # Daily-rotating HMAC ipHash (privacy scheme)
│ └── log.ts # WAE datapoint assembly (blob-order contract)
└── __tests__/ # serve / range / audio-key / client-hash suitesBindings & Secrets
wrangler.toml
name = "podcasterplus-media-delivery"
routes = [
{ pattern = "media.podcasterplus.com/*", zone_name = "podcasterplus.com" },
{ pattern = "m.cdn.media/*", zone_name = "cdn.media" }
]
[limits]
cpu_ms = 50
[observability.logs]
enabled = true
[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "podcasterplus-media"
[[analytics_engine_datasets]]
binding = "MEDIA_ANALYTICS"
dataset = "media_requests"Secrets
Set via wrangler secret put:
| Secret | Description |
|---|---|
IP_HASH_SECRET | HMAC key for the daily-rotating client IP hash (never store raw IPs). Shared with the RSS Feed Worker |
Hostname Takeover / Rollback Runbook
An R2 custom domain and a Worker custom domain cannot coexist on one hostname, so claiming media.podcasterplus.com was a one-time manual swap (documented in wrangler.toml):
- Deploy with the
routesblock commented out; smoke-test object serving (200/206/304/416, ETag, Range) on the*.workers.devURL against the real bucket. - Cloudflare dashboard: R2 →
podcasterplus-media→ Settings → remove the custom domainmedia.podcasterplus.com. - Uncomment
routesandnpx wrangler deploy— the Worker claims the hostname. Expect seconds-to-minutes of 404/522 on media between steps 2–3.
Rollback: comment routes, redeploy, re-add the R2 custom domain.
After the swap, verify: full GET (200 + etag + cache-control), ranged GET (206 + Content-Range), repeat GET (cache hit), If-None-Match (304), an RSS enclosure plays in a real podcast app, and datapoints arrive (SELECT * FROM media_requests LIMIT 10 via the WAE SQL API).
Deployment
cd workers/media-delivery
npx wrangler deploy
# Secrets
npx wrangler secret put IP_HASH_SECRET
# Logs
npx wrangler tailTroubleshooting
Media 404s After a Deploy
- Confirm the Worker owns the hostname (dashboard → Workers →
podcasterplus-media-delivery→ Domains). - If the custom domain was removed without re-adding the R2 custom domain, media is orphaned — follow the rollback in the runbook above.
No Datapoints Arriving
- Check
IP_HASH_SECRETis set (npx wrangler secret list) — logging silently skips without it. - Confirm the
MEDIA_ANALYTICSbinding exists in the deployed version. - Only episode-audio keys are logged — image/avatar/
imports/traffic never produces datapoints. HEADrequests and error statuses are never logged by design.
Range Requests Returning 200
Multipart ranges and unparseable Range headers are deliberately served as full 200s (RFC-permitted). Only single ranges produce 206s.
Related Documentation
- Analytics Rollup Worker — consumes
media_requestshourly - Analytics Measurement Methodology — what counts as a download
- Analytics API — the read layer over the rolled-up aggregates
- RSS Feed Worker — logs the sibling
feed_requestsdataset - Implementation plan:
docs/planning/plans/2026-07-05-analytics-platform.md(repo root)