External widget — CDN and pointer pattern
The widget never fetches a finder payload from a single, stable URL. Instead it follows a two-step indirection through Cloudflare R2: a short-lived pointer file (config.json) names the current content hash, and a permanently-cached versioned payload (v/{hash}.json) holds the actual data. This split lets CDN edge nodes cache the heavy payload for a full year while only the tiny pointer — ~200 bytes — must revalidate on every publish. See storage-and-cdn.md for the R2 bucket layout and CDN configuration, and data-flow-end-user.md for the full request sequence.
R2 file types
| Key pattern | Cache-Control | Purpose |
|---|---|---|
finders/{key}/config.json | public, max-age=60 (set by backend on publish) | Pointer — contains hash and metadata |
finders/{key}/v/{hash}.json | public, max-age=31536000, immutable | Versioned payload — all locations + design |
Widget bundle files (snippet.js, ext/2/snippet.js) use a separate 5-minute TTL and are managed by the deploy script — see deploy.md.
The pointer file contains at minimum a hash field. A sample pointer response:
{ "hash": "a3f9c1d...", "variant": 2, "published_at": "2026-04-27T10:00:00Z" }The versioned payload is named by that hash, so a new publish produces a new key and the old one stays cached forever — no purge needed for payload files.
Fetch flow in api.js
The full CDN fetch chain lives in src/lib/api.js.
Pointer fetch (fetchFinderPointer)
api.js:40–50 — Fetches https://cdn.locationfinders.com/finders/{key}/config.json. Returns the parsed object if a hash field is present, or null on any error. Failures are swallowed silently so the caller can fall through to the origin API.
// api.js:43
const res = await fetch(`${CDN_BASE}/finders/${finderKey}/config.json`);Payload fetch (fetchFinderPayload)
api.js:54–95 — This is the primary function called by App.svelte. It implements the full four-stage pipeline:
- Hash resolution — uses the
knownHashargument if provided (passed fromApp.svelteviapointer?.hash), otherwise callsfetchFinderPointerto retrieve it (api.js:59). - localStorage cache check — key is
lf-cdn-v2:{finderKey}. If the stored object’shashmatches the current hash the cacheddatais returned immediately without a network request (api.js:62–67). - Versioned CDN fetch —
https://cdn.locationfinders.com/finders/{key}/v/{hash}.json. On a 200 response the payload is written back to localStorage and returned (api.js:69–78). - Origin API fallback —
GET {VITE_API_APP_URL}/ext/finders/details/{key}. Used in local dev and as a safety net when CDN infrastructure is unavailable. Throws a descriptive error on non-OK status (api.js:82–94).
// api.js:59 — hash resolution
const hash = knownHash ?? (await fetchFinderPointer(finderKey))?.hash ?? null;
// api.js:63 — localStorage read
const cached = JSON.parse(localStorage.getItem(cdnCacheKey(finderKey)) ?? 'null');
if (cached?.hash === hash && cached?.data) { return cached.data; }
// api.js:70 — versioned CDN fetch
const versionRes = await fetch(`${CDN_BASE}/finders/${finderKey}/v/${hash}.json`);The localStorage key format (api.js:5–7):
function cdnCacheKey(token) {
return `lf-cdn-v2:${token}`;
}App.svelte call site
App.svelte:245 — Inside the boot() async function called by onMount, the widget passes pointer?.hash as the second argument to skip a redundant pointer fetch when the snippet loader already pre-fetched the hash:
// App.svelte:245
const nextPayload = await fetchFinderPayload(finderKey, pointer?.hash ?? null);pointer is a Svelte prop (App.svelte:21) injected by the runtime snippet with the pre-resolved hash. If it’s null, fetchFinderPayload fetches the pointer itself.
localStorage caching layer
The cache entry stores { hash, data } under the key lf-cdn-v2:{finderKey}. On the next page load:
- If the pointer’s
hashmatches the storedhash, the payload is served from localStorage with zero network requests. - If they differ (publish happened), the versioned CDN file is fetched and the cache is overwritten.
try/catchguards both the read (api.js:62) and the write (api.js:74) so a full or unavailable localStorage does not break widget load.
There is no expiry timer on the cache entry — staleness is detected entirely via hash mismatch.
Cache invalidation on publish
When a finder is published from the dashboard, the backend’s ExternalController::syncFinderDetails (documented in data-flow-builder.md) does three things:
- Builds the payload JSON, sha1-hashes it.
- Uploads
finders/{key}/v/{hash}.jsonto R2 with a 1-year immutable cache header. - Overwrites
finders/{key}/config.jsonwith the new{hash}value (60 s TTL).
Cloudflare serves the pointer’s next request from origin within 60 seconds. Widget instances that already hold the old hash in localStorage will detect the mismatch on their next pointer fetch and refetch from CDN.
The deploy script (scripts/deploy-r2.mjs) purges only snippet.js and ext/2/snippet.js via the Cloudflare API — finder payload files are never purged; they simply accumulate as immutable objects in R2. See deploy.md.
CDN fallback to origin
If both the pointer fetch and versioned CDN fetch fail (CDN unavailable, R2 outage), fetchFinderPayload falls through to GET {API_BASE}/ext/finders/details/{key} (api.js:82). This endpoint is rate-limited and not designed for high traffic, so sustained CDN unavailability would degrade widget load performance. There is no circuit-breaker; every widget instance independently falls back.
Related pages
- overview.md — widget architecture overview
- snippet-loader.md — how
pointeris resolved beforeApp.sveltemounts - init-flow.md — routing and mount sequence
- storage-and-cdn.md — R2/CDN pattern
- data-flow-end-user.md — full end-user sequence diagram