Cost-minimized ad-URL rotationTotal runtime & authoring flow — remote URL rotation at $0 incremental

Sibling of architecture-flow.html. The ad config is a tiny cfg/<id>.js on R2 loaded by a <script> tag → CORS-exempt, edge-cached, $0 Worker.

Publisher page Visitor · browser Worker cool-brook-03cb KV WRAPPERS R2 engine R2 ad config (NEW) pickAdUrl() · operator
billed Worker request $0 · R2 direct · edge-cached ad config (NEW · $0) async / on cache miss
wrapper analyytics.com/w/<id>.js engine cloudfroze.com/e/<id>.js ad config cfg.analyytics.com/cfg/<id>.js Worker cool-brook-03cb KV WRAPPERS R2 popunder-engine

1 · Runtime — a real visitor, the hot path · 1 billed Worker request total

R2 direct — $0 Worker · edge-cached · free egress PUBLISHER PAGE <head> · 1-line loader WORKER · cool-brook-03cb cache.match → wrapper bytes billed 1× / pageview — the ONLY cost KV WRAPPERS w:<id> · read on miss only VISITOR browser · $0 R2 · ENGINE cloudfroze.com/e/<id>.js per-wrapper copy · loads always · NOT the Worker AD TARGET — switch POP_CONFIG.window.url the baked url = the switch plain → open · cfg.js → rotate R2 · AD CONFIG cfg.analyytics.com/cfg/<id>.js → window.__ADCFG · <script> load CONDITIONAL · only in rotation mode planned · wrapper JS pending pickAdUrl() · in browser · $0 read __ADCFG → rotate (cfg mode only) weighted / round-robin / by-device + subid + tracking params not loaded yet? → fallback (never breaks) planned · wrapper JS pending POP → AD URL opens in browser · $0 single ad · or rotated pick ① GET /w/<id>.js 1 Worker req miss: get w:<id> ② wrapper bytes ③ <script> e/<id>.js · always reads window.url = cfg.js plain url → POP direct no cfg request · today __ADCFG → rotate ⑤ rotated url → POP (cfg mode · planned)
Only step ① touches the Worker — 1 billed request, 0 KV on an edge-cache hit, 1 KV read on a miss. Everything after it is R2-direct at $0.
The switch is a host test on the baked url · rotation is planned, wrapper JS pending

The baked POP_CONFIG.window.url decides the mode via /^https:\/\/cfg\.analyytics\.com\/.*\.js$/i: a plain landing url → the engine opens it directly, no cfg request (today's behaviour, backward compatible); a cfg.analyytics.com/<id>.js url → the wrapper loads that cfg via <script> at init, sets window.__ADCFG, and pickAdUrl() rotates (still R2-direct, $0). That conditional load is not yet baked into the wrapper. Triggers stay governed by POP_CONFIG; rotation only picks WHICH url.

2 · Authoring & update — operator actions (rare) · admin-only R2 binding

OPERATOR config.html config.simple.html admin panel WORKER · cool-brook-03cb admin · bearer auth admin-only R2 (ENGINE) binding — visitors never hit Worker for e/ or cfg/ PUBLISH KV put w:<id> · R2 copy base → e/<id>.js R2 create starter cfg/<id>.js 1 KV write + 2 R2 Class-A writes NEW CHANGE URLs ENGINE.put("cfg/"+id+".js", "window.__ADCFG="+json) PURGE 1 URL · reuse CF_ZONE_ID (analyytics.com zone) no new CF_ENGINE_ZONE_ID · live ~250 ms · NO re-bake DELETE (cascade) KV del w:<id> · R2 del e/<id>.js · R2 del cfg/<id>.js DeleteObject is FREE on R2 one call cleans all three stores PUT /admin/wrapper GET/PUT /admin/adconfig DELETE ?id=
Publish bakes once; a URL change rewrites one tiny cfg/<id>.js and purges that one URL — live in ~250 ms, no wrapper re-bake. Delete cascades across KV + both R2 keys for free.
The editor's two calls, and why no second zone token is needed

The panel reads with GET /admin/adconfig?id={id, cfg} and writes with PUT /admin/adconfig {id, cfg}, which validateCfg()-gates the payload server-side, writes window.__ADCFG=+JSON and purges → {ok:true, purged}. The purge reuses the wrapper zone + token CF_ZONE_ID, because cfg.analyytics.com lives in the analyytics.com zone — no separate CF_ENGINE_ZONE_ID.

3 · Cost — rotation adds $0 incremental · green=free · orange=billed

EventWorkerKVR2PurgeBilled?
Pageview — wrapper /w/<id>.js10 hit / 1 miss001 Worker req — the ONLY billed item
Pageview — engine e/<id>.js (R2 direct)000 edge-cached0$0 · free egress
Pageview — ad config cfg/<id>.js NEW000 edge-cached0$0 · free egress
Pop & rotation — pickAdUrl()0000$0 · runs in browser
URL change — PUT /admin/adconfig (operator, rare)1 admin01 Class-A write1$0 · ≪ free tiers
Delete wrapper (operator, rare)1 admin1 del2 del (e/ + cfg/)1$0 · DeleteObject free
Storage — cfg/<id>.js ≈ 300 B × N wrappers≪ 10 GB free$0

Verified CF rates (2026-07-04): R2 Class-B read $0.36/M (10M free/mo) · R2 Class-A write $4.50/M (1M free/mo) · R2 storage $0.015/GB-mo (10 GB free) · egress $0 · DeleteObject free. Rotation adds $0 incremental: the config is one more edge-cached .js served exactly like the engine.

4 · The cache knob — what makes cfg/* both $0 AND fresh

EDGE TTL — LONG

Cache Rule on cfg/* · analyytics.com zone · e.g. 1 day

1000 wrappers × ~250 colos × 30 ≈ 7.5M reads/mo < 10M free = $0. The cost lever.

BROWSER TTL — SHORT

e.g. 60 s

The only lever for already-cached repeat visitors — browser caches are not purgeable. Costs nothing at the edge.

PURGE ON UPDATE

one URL, on write

Freshness on new loads (~250 ms) without lowering Edge TTL. A short Edge TTL instead would explode R2 reads → up to ~$104/mo.

5 · Problems & notes — honest gotchas · required fixes before shipping

7 notes. 2 required fixes — double-cursor (memoize the pick per pop) and admin route preflight (OPTIONS→204). 3 accepted limits — first-pop uses the baked default, browser cache is never purged, 9.9M reads at extreme fan-out is just under the 10M free tier. 2 resolved — .js+<script> over .json+fetch, and the purge token (Option A).

Why .js + <script>, not .json + fetch

A <script> load is CORS-exempt and edge-cached by default. A fetch().json() plan would need R2 CORS configured, and .json is not edge-cached by default while .js is. Using .js removes both audit blockers at once.

Double-cursor bug

The round-robin cursor must advance exactly once per pop. The engine may call the URL resolver a browser-dependent number of times, so compute the pick once and memoize per pop. Also delete the leftover getPopUrl() in beforeOpen, or the cursor double-steps.

Admin route preflight

Guard /admin/adconfig with an OPTIONS/preflight branch (→ 204), bearer auth (→ 401), and a 501 when the R2 ENGINE binding is absent. GET ?id= reads the stored cfg; PUT {id, cfg} validates + writes. Without the preflight branch the browser's CORS preflight fails and the update call never fires.

Purge token — RESOLVED (Option A)

Because the config now lives on cfg.analyytics.com, inside the analyytics.com zone, the purge reuses the existing wrapper zone + token CF_ZONE_IDno separate CF_ENGINE_ZONE_ID is needed. If a purge ever fails, propagation just degrades from ~250 ms to the Edge TTL (still correct).

First-pop race

If the config <script> hasn't loaded before pop #1, impression #1 uses the baked default URL; rotation kicks in from pop #2. Accepted resilience trade-off — the pop always fires.

Browser cache is not purged

Purge clears the edge, not browsers. A repeat visitor keeps the old config until their Browser TTL expires — hence keep Browser TTL short (≈ 60 s) so freshness is bounded.

$0 read margin at extreme fan-out

Worst case 1000 wrappers × ~330 colos ≈ 9.9M Class-B reads/mo — just under the 10M free tier. Tiered Cache collapses colo fan-out so real reads stay well below that, keeping the read cost at $0.