Skip to content

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

PropertyValue
URLhttps://media.podcasterplus.com/{object-key}
Worker Namepodcasterplus-media-delivery
Source Location/workers/media-delivery/
TriggerHTTP (GET/HEAD only)
StorageR2 bucket podcasterplus-media (binding MEDIA_BUCKET)
AnalyticsWAE dataset media_requests (binding MEDIA_ANALYTICS)
CPU Limitcpu_ms = 50 (delivery path must stay tiny)
DatabaseNone — 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.

  1. Method gateGET/HEAD only; anything else → 405 with Allow: GET, HEAD.
  2. Key resolution — decoded pathname with leading / stripped; empty or ..-containing keys → 404.
  3. Edge cache firstcaches.default.match(request). Only full 200s are ever stored (a 206 put throws); the Cache API synthesizes 206s from a stored 200 for ranged requests because Content-Length is always stored. A cache lookup failure falls through to R2.
  4. R2 getMEDIA_BUCKET.get(key, { range, onlyIf: request.headers }) resolves everything in one call.
  5. HEAD — same resolution via bucket.head(), headers only, never logged as a download.

Response Codes

CodeCondition
200Full object; writeHttpMetadata headers + ETag + Content-Length + Accept-Ranges: bytes; written to cache
206Satisfied range; explicit Content-Range: bytes start-end/size + slice Content-Length; served direct, never cached
304onlyIf precondition matched an If-None-Match / If-Modified-Since request
404Object not found, empty key, or .. in key (plain text, Cache-Control: public, max-age=60)
405Non-GET/HEAD method
412Body-less onlyIf result for a failed If-Match / If-Unmodified-Since (rare, upload-oriented)
416Syntactically 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:

typescript
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: objectSize

bytesServed 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-IP hashes 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_ANALYTICS binding or IP_HASH_SECRET secret → serve without logging (typed optional in src/types/env.ts).
  • Any throw inside logAudioRequest()console.error, response unaffected.
  • Cache match/put failures → 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 suites

Bindings & Secrets

wrangler.toml

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:

SecretDescription
IP_HASH_SECRETHMAC 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):

  1. Deploy with the routes block commented out; smoke-test object serving (200/206/304/416, ETag, Range) on the *.workers.dev URL against the real bucket.
  2. Cloudflare dashboard: R2 → podcasterplus-media → Settings → remove the custom domain media.podcasterplus.com.
  3. Uncomment routes and npx 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

bash
cd workers/media-delivery
npx wrangler deploy

# Secrets
npx wrangler secret put IP_HASH_SECRET

# Logs
npx wrangler tail

Troubleshooting

Media 404s After a Deploy

  1. Confirm the Worker owns the hostname (dashboard → Workers → podcasterplus-media-delivery → Domains).
  2. 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

  1. Check IP_HASH_SECRET is set (npx wrangler secret list) — logging silently skips without it.
  2. Confirm the MEDIA_ANALYTICS binding exists in the deployed version.
  3. Only episode-audio keys are logged — image/avatar/imports/ traffic never produces datapoints.
  4. HEAD requests 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.

Internal documentation - Not for public distribution