Skip to content

fix(pwa): dodge Pages CDN gzip-of-range on the basemap archive (#118)#119

Merged
DocGerd merged 2 commits into
developfrom
fix/118-pmtiles-cdn-gzip
Jul 23, 2026
Merged

fix(pwa): dodge Pages CDN gzip-of-range on the basemap archive (#118)#119
DocGerd merged 2 commits into
developfrom
fix/118-pmtiles-cdn-gzip

Conversation

@DocGerd

@DocGerd DocGerd commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Closes #118

Problem

GitHub Pages/Fastly gzip-compresses application/octet-stream and answers Range requests with 206 slices of the compressed stream. The browser cannot inflate a mid-stream gzip fragment, so every ranged pmtiles read on a first-load/no-SW page failed (net::ERR_CONTENT_DECODING_FAILED) and the vector basemap stayed blank. SW-controlled pages were never affected (ranges are sliced from the precache).

Fix (design-workflow decision A4)

  1. Transport fix — rename: app/public/data/basemap.pmtilesbasemap.pmtiles.png via git mv (identical blob, no repo growth). image/png is the only content-type proven gzip-exempt and Range-clean on this origin; Pages derives MIME from the final extension. Loud "this is NOT a PNG" comments in pipeline/extract_basemap.sh, src/lib/basemap.ts, and pipeline/README.md prevent a future cleanup from reverting it.
  2. SW matcher: src/sw.ts's first-registered Range→206 route now matches via isBasemapArchivePath() (.pmtiles.png or legacy .pmtiles — an installed SW updating across the rename keeps owning both shapes). Route ORDER and body unchanged — still registered before precacheAndRoute.
  3. Runtime net for uncontrolled pages (src/services/basemapSource.ts): a preflight (Range: bytes=0-15, require true 206 + PM magic) runs once per mount on uncontrolled pages; on failure it warns ([#118], console-only, silent by design — no i18n), fetches the archive once full-body (a COMPLETE gzip stream decodes fine), and registers a Blob-backed pmtiles Source via protocol.add(), keyed on the exact href the style's pmtiles:// reference uses. SW-controlled pages skip everything.
  4. MapView mount effect is now async: the transport check fully resolves before new MaplibreMap(...) (protocol.add must not race the first tile request); a cancelled flag closes the unmount-during-fetch window; a thrown fallback fetch surfaces through the existing noteMapError() + one-shot banner path. All existing wiring/comments preserved.
  5. New e2e app/e2e/basemap-fallback.spec.ts (SW-blocked context = honest no-SW cohort): Case A simulates the CDN corruption exactly (page.route fulfills ranged requests with a 206 whose body starts 1f 8b 08 00) and proves one full-body fetch + painted canvas + [#118] warn + zero decoding errors + no error banner; Case B proves the passing preflight keeps the ranged fast path and the 27 MB full fetch NEVER runs. This spec is what keeps the fallback from rotting while the CDN behaves.

Evidence

  • npm --prefix app run lint — clean.
  • npm --prefix app run typecheck — clean (tsc -b, covers the new e2e spec via tsconfig.e2e.json).
  • npm --prefix app run test72 files / 723 tests passed (full run, 249 s; includes the 21 new basemap/basemapSource tests).
  • npm --prefix app run build — precache 25 entries (33 664.40 KiB); dist/data/basemap.pmtiles.png = 27 201 789 bytes; built sw.js contains the .pmtiles.png/.pmtiles matcher and the renamed precache entry.
  • Full e2e — 8/8 passed (annotations, basemap-fallback ×2 NEW, datalayers, layout, offline ×2, plan). offline.spec's killed-server Range fetch of data/basemap.pmtiles.png still returns 206 from precache.
  • Real-browser pass (production bundle, vite preview at /sail_command/, HTTP-Range-capable):
    • curl -H 'Range: bytes=0-15' on the renamed archive → 206, Content-Type: image/png, Content-Range: bytes 0-15/27201789, body starts PMTiles.
    • Fast path: map + full plan flow (Langballigau → Sonderburg, Genua ★ 7.9 nm / 1 h 20 min, barbs + depth profile) with 0 console errors; ALL archive traffic = ranged 206s, no full-body GET (network log inspected).
    • Forced fallback (CDN corruption simulated per the verification plan): e2e Case A proves in real Chromium — exactly 1 full-body GET, canvas paints from the Blob source, [#118] console warn present, 0 ERR_CONTENT_DECODING_FAILED, map-error banner absent.
    • Bonus: the manual pass ran against a browser profile with a PRIOR deployment's SW registered ("Update verfügbar") — the rename-transition window (old SW + new bundle) rendered cleanly too.

Notes for review / release

  • UAT deployment needs a visible in-app environment indicator #107 prod byte-identity check is N/A here, deliberately: this PR intentionally changes the prod artifact (renamed asset, SW matcher, MapView restructure) and touches neither the __SC_UAT__ gate nor the badge module area / App.tsx.
  • One-time precache re-download: the precache URL changes, so every installed PWA (prod at release, UAT at next develop push) re-downloads the ~27 MB archive once at its next SW update (registerType: 'prompt' — download happens in background at install, activation stays user-gated).
  • Post-merge checklist (blocks the develop→main release, run from main session): the live /uat/ probe must pass or explicitly exercise the fallback before release:
    curl -s -D- -o /tmp/head16 -H 'Accept-Encoding: gzip, deflate, br' -H 'Range: bytes=0-15' https://docgerd.github.io/sail_command/uat/data/basemap.pmtiles.png — REQUIRE HTTP 206, NO content-encoding, content-range total exactly 27201789, first 7 body bytes 50 4d 54 69 6c 65 73. Run twice (cold + warm CDN cache). If it FAILS, the cache-busted browser pass must instead show the fallback working (one full-body GET, map paints, [#118] warn, no ERR_CONTENT_DECODING_FAILED) — users are unbroken either way.
  • Spec discrepancy (main session): docs/superpowers/specs/ still references basemap.pmtiles; spec edits are main-session-gated and excluded from this PR.
  • Harden deploy: prevent a develop push from being able to change production bytes, and make prod stability legible in the Deployments UI #117 recommendation: add the deploy.yml post-deploy 206+magic smoke probe there (a future Fastly policy flip re-gzipping image/png should page the team, not users), not here.
  • Guard-hook gap (main session, config-owned): .claude/settings.json's artifact guard matches *.pmtiles but not the renamed *.pmtiles.png — consider extending the pattern so the binary stays hand-edit-protected.
  • App.test.tsx needed one consequential hunk (not in the original file list): its strict fetch mock now answers the basemap preflight with a true 206+PM so the real App tree takes the range-ok path in jsdom.

🤖 Generated with Claude Code

GitHub Pages/Fastly gzip-compresses application/octet-stream and answers
Range requests with 206 slices OF THE COMPRESSED stream — un-inflatable in
the browser, so first-load/no-SW visitors got a blank vector basemap.

- Rename data/basemap.pmtiles -> basemap.pmtiles.png (git mv, identical
  blob): image/png is the only content-type proven gzip-exempt AND
  Range-clean on this origin. Loud NOT-a-PNG comments guard the masquerade.
- sw.ts matcher now owns BOTH .pmtiles.png and legacy .pmtiles via
  lib/basemap.ts's isBasemapArchivePath (route order/body unchanged — still
  first-registered before precacheAndRoute).
- New services/basemapSource.ts: uncontrolled-page preflight (Range
  bytes=0-15, require true 206 + 'PM' magic) with a full-body fetch into a
  Blob-backed pmtiles Source via protocol.add() on failure; SW-controlled
  pages skip everything.
- MapView mount effect is async: the transport check resolves BEFORE map
  construction (protocol.add must not race the first tile request);
  cancelled flag closes the unmount-during-fetch window; fallback-fetch
  failure surfaces through the existing noteMapError/banner path.
- New e2e basemap-fallback.spec.ts (SW-blocked context) simulates the CDN
  corruption exactly and pins both the forced-fallback and the ranged
  fast path; offline.spec keeps proving the precache Range path.

Closes #118

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread app/src/components/MapView.tsx Outdated
Comment thread app/e2e/basemap-fallback.spec.ts Outdated
Comment thread app/e2e/basemap-fallback.spec.ts Outdated
Comment thread app/src/components/MapView.tsx
Comment thread app/src/components/MapView.tsx
Comment thread app/src/services/basemapSource.ts Outdated
Comment thread app/src/sw.ts Outdated
- F1: one try/catch spans the WHOLE async mount — post-await construction
  throws (WebGL init, control wiring) now route into the existing
  console.error + noteMapError + one-shot banner path instead of a silent
  floating rejection.
- F2: basemap-fallback e2e proves the Blob-backed map paints REAL tiles —
  settled-canvas byte compare (datalayers idiom) against a deterministic
  tiles-less baseline (honest preflight, all other archive reads aborted).
- F3: exact end-of-spec totals pinned after the paint proof —
  corruptedRangeRequests === 1 and fullBodyRequests === 1 (a Protocol key
  drift would surface as EXTRA ranged FetchSource reads).
- F4: new MapView.mount.test.tsx covers the cancelled-flag window (unmount
  during hanging preflight; preflight resolving post-unmount), the F1 error
  routing (RED before the fix), and pins StrictMode double-mount (two
  preflights, single map construction).
- F5: the catch logs BEFORE the cancelled check — unmount-mid-fetch no
  longer swallows the trace; banner/#27 recording stay gated on live mounts.
- F6: preflight fetch carries cache:'no-store' (Chrome can synthesize an
  honest 206 from an HTTP-cached full body, masking a broken CDN as
  range-ok) — pinned literally in the unit tests.
- F7: sw.ts + lib/basemap.ts comments corrected — the legacy .pmtiles shape
  stays OWNED by the route but degrades to a network fetch on precache miss
  (404 post-rename), self-healing on the update reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

First-load vector basemap broken: GitHub Pages CDN gzips basemap.pmtiles and range-slices the compressed form (ERR_CONTENT_DECODING_FAILED)

1 participant