Four changes that together get a signed-in officer back to working data. The SSO validation in index.php and embed.php called the public hostname from inside the server. The Lands Commission's NAT rule blocks traffic that leaves the server and tries to come back in, so that curl stalled for its full 30-second timeout and then failed validation, bouncing the user back to the login. Both now call http://l4lmainbackend/sso/validate, overridable with LUPMIS_SSO_BASE. Measured from inside the spdrawing container: 25 ms with no redirect, against a 10-second timeout on the public name. API_ROOT is now the relative path /api. The PWA host already proxies it to the ApiEngine container — a deliberate route, not a catch-all: /api answers on pwa. and api. but not on permits., minio. or minioapi. Same-origin removes the mixed-content, CORS and CORP constraints that Cross-Origin-Embedder-Policy: require-corp imposes on a cross-origin call, and no API hostname is compiled into the bundle any more. The dev server proxies /api to the public host, which is right there because a developer's machine is outside the server and only callers inside it are blocked from the public name. A direct request for /index.html bypassed index.php, which is what injects window.LUPMIS_SESSION, so the app started with no session at all and said so in the menu. The LUSPA landing page links to exactly that URL, and the SPA fallback could not help because its RewriteCond !-f fails precisely when the file exists. .htaccess now routes it through index.php; index.php reads index.html from the filesystem, so there is no loop. This one is inert until AllowOverride is relaxed on the PWA container, where it is currently None. Service worker v29, and v28 before it, which carries the GIS Tools rename to devices that already have the app installed. The tile cache stays pinned at v24, so downloaded map areas survive both updates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
834 lines
36 KiB
JavaScript
834 lines
36 KiB
JavaScript
/**
|
||
* Service Worker
|
||
*
|
||
* Handles caching of:
|
||
* - App shell (HTML, CSS, JS)
|
||
* - Map tiles (passive runtime caching, per-host buckets)
|
||
* - API responses (network-first)
|
||
*
|
||
* Note: Database operations are handled by the SharedWorker (shared-db-worker.js),
|
||
* NOT by this service worker. They serve different purposes:
|
||
* - Service Worker: Caching, offline asset serving, push notifications
|
||
* - SharedWorker: Shared database connection across tabs
|
||
*/
|
||
|
||
// v3: lower per-cache limits (5000 → 1500) and counter-based eviction to
|
||
// prevent Safari memory-pressure reloads.
|
||
// v4: raise OSM and Topographic limits to 8000 to support active offline
|
||
// downloads (Phase 2). Other providers stay at 1500.
|
||
// v5: switch LayerSwitcher icon path to base-URL-aware; force shell refresh.
|
||
// v6: rename /icons/ → /app-icons/ to dodge Apache's default mod_alias
|
||
// mapping (Alias /icons/ /usr/share/apache2/icons/) which intercepts
|
||
// the path server-side. Force shell refresh so deployed clients
|
||
// re-fetch the new HTML/manifest with the new path.
|
||
// v7: HTML pages now use network-first (was cache-first) so new deploys
|
||
// are picked up immediately without needing another SW version bump.
|
||
// Hashed JS / CSS / WASM stay cache-first (they're immutable per build).
|
||
// v8: GPS trail recording feature (reusable src/geotracker/ engine, expandable
|
||
// My Location control, navbar GPS readout, gps_trails SQLocal tables) plus
|
||
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
|
||
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
|
||
// old module/shell caches.
|
||
// v9: Iframe embed endpoint (/embed via public/embed.php) + postMessage bridge
|
||
// for the permitting integration; lu_parcels structural refactor in the
|
||
// local DB; production access guard + no-district overlay; LayerSwitcher
|
||
// ordering fix. New shell + hashed bundle.
|
||
// v10: UPN-grid layer (cache + click popup + dashed-violet style + 1:25,000
|
||
// label threshold); external-dataset import → staging → upload pipeline
|
||
// (external_imports/_features tables, mapping modal, LayerSwitcher chip,
|
||
// staged-upload payload with user_id_upload from SSO); GIS export from
|
||
// Area/Circle Analysis popups (GeoJSON / SHP via shp-write / KML, with
|
||
// field-rename modal). New hashed bundle + shp-write chunk.
|
||
// v11: Permit-iframe hardening (in-iframe auth-required card replacing the
|
||
// 302 redirect, X-Frame-Options strip at both .htaccess and PHP layers);
|
||
// import-flow UX refinements (parse spinner, per-feature client_uuid
|
||
// tagging, geometry-edit + delete persistence to the staging tables,
|
||
// sample values + Unicode-bold field names in the mapping dropdown).
|
||
// New hashed bundle + updated embed.php shell.
|
||
// v12: Session/district correctness — a real /?logout=1 endpoint that destroys
|
||
// the PWA's own PHP session and then hands off to the portal's
|
||
// /user-logout for a FULL SSO logout (token invalidated + sso_auth_token
|
||
// cleared by the portal). Previously logout left the PHP session intact,
|
||
// pinning a stale district_id. SSO is validated once per session at login;
|
||
// a transfer is picked up on the next logout→login.
|
||
// Client-side stale-district guard (wipes district-scoped caches when the
|
||
// session district changes) + district-keyed boundary cache; GPS read-out
|
||
// UTM coordinate-format setting; ol-ext touch-cursor gated to touch-only
|
||
// devices. New hashed bundle + updated index.php shell.
|
||
// v13: Analytical tools Stage 1 — client-side spatial analysis. New "Analyse"
|
||
// dock button opens an Analysis panel offering vector overlays
|
||
// (intersect / clip / difference / union, via Turf.js) and vector-in-vector
|
||
// zonal statistics (count / sum / mean / min / max / area per zone); results
|
||
// are added as layers in a new "Analysis" group and work fully offline.
|
||
// Analysis can be scoped via "Apply to": whole layer, current map view,
|
||
// selected features, or the catch of a drawn Circle/Area. The Circle/Area
|
||
// analysis popup now has one "Export" button (PDF folded into the export
|
||
// modal as a fourth format) plus an "Analyse" button that opens the panel
|
||
// pre-scoped to the intersecting features.
|
||
// Raster display gains Cloud-Optimized GeoTIFF support (MapView.addCOGLayer).
|
||
// Turf, the analysis panel, geotiff.js and pako are all code-split and
|
||
// fetched on first use, so the eager bundle is unchanged.
|
||
// v14: Zonal statistics results can be exported as a two-sheet Excel workbook
|
||
// (Results + the Parameters that produced them) via a dependency-free
|
||
// XLSX writer in src/analysis/xlsx.js; Add External Layer gains a COG
|
||
// option with a URL pre-flight check.
|
||
// v15: Drawing toolbar reworked into a docked left rail, after field officers
|
||
// reported the palette had no background, sat centred over the area being
|
||
// drawn, and mixed two icon styles. It is now a two-column panel on the
|
||
// left edge with its tools banded into Draw / Edit / Shape / File, and
|
||
// every icon — including ol-ext's own CSS-drawn ones — is a Bootstrap
|
||
// Icon. On phones it becomes a horizontally scrolling strip above the
|
||
// dock, which retires the previous two-row wrapping workaround.
|
||
// New hashed bundle + updated index.html shell.
|
||
// v16: Three fixes to the v15 rail, all reported from the field. The phone
|
||
// layout stretched down the whole screen because its rule only tied with
|
||
// ol-ext's on specificity, so stylesheet order decided the winner and the
|
||
// built app ordered them the other way round from the test harness. A
|
||
// stray dark shape beside the option bar was ol-ext's pointer triangle,
|
||
// hidden by a selector that was likewise not specific enough. And the rail
|
||
// covered the left off-canvas toggle, which sits at exactly the position
|
||
// ol-ext docks a left bar to — so the rail now starts clear of it and can
|
||
// be dragged anywhere by its grip, with the position remembered.
|
||
// v17: iPhone fixes. In portrait the toolbar did not paint at all: on iOS an
|
||
// element that both blurs its backdrop and scrolls often renders blank,
|
||
// and v16's strip did both. The strip now uses an opaque background and no
|
||
// blur. In landscape the phone is 844px wide, so the width-only breakpoint
|
||
// handed it the 396px-tall rail inside a ~270px map; the layout now
|
||
// switches on height as well as width.
|
||
// v18: The phone toolbar still did not paint on iPhone. Reverted the strip from
|
||
// a horizontally scrolling row back to the two-row wrap that shipped
|
||
// before the rail and was known to work on those phones — removing the
|
||
// overflow, and with it the whole class of iOS scroll-container paint
|
||
// bugs, rather than only the backdrop-filter half of it. Group headings
|
||
// become hairline dividers so the grouping survives the narrower layout.
|
||
// Building the rail is now also wrapped in a fallback: if it throws, the
|
||
// default ol-ext bar is shown instead of no bar at all.
|
||
// v19: The phone strip sat at the very bottom edge, under the My Location and
|
||
// base-map buttons on the right and clipped by the dock. It now sits above
|
||
// that whole right-edge stack (bottom: 196px = the switcher's 144px anchor
|
||
// + its 44px height + an 8px gap).
|
||
// v20: One way to add a point instead of two. The Add Location dock button is
|
||
// gone; placing a point with Draw > Point now opens the same Add Location
|
||
// form. The sketch is discarded so the marker is the only record, and the
|
||
// add/remove pair is kept off the undo stack. Marker details, which used
|
||
// to need Add Location mode, now open in the neutral mode. The dock keeps
|
||
// its six remaining buttons and divides them evenly on narrow screens.
|
||
// v21: Location markers were saved but never drawn. The Markers layer is built
|
||
// hidden and nothing ever switched it on except the LayerSwitcher, so a
|
||
// point placed with Draw > Point was written to the database and the map
|
||
// panned to it with nothing to see. Creating a location, or picking one
|
||
// from the Locations list, now switches the layer on.
|
||
// v22: The point placed with Draw > Point now stays on screen while the Add
|
||
// Location form is open, so it visibly becomes the marker on save instead
|
||
// of vanishing and a marker appearing from nowhere. It is discarded when
|
||
// the form closes, either way. Marker loading is also kept out of the undo
|
||
// history: entering edit mode makes UndoRedo watch every source, so Undo
|
||
// after saving a location used to strip the markers off the map while
|
||
// leaving the row in the database.
|
||
// v23: Showing the Markers layer moved into loadLocations(), so it holds
|
||
// whenever the district has locations — at startup and on every reload,
|
||
// not only in the moment a location is saved. Previously a reload put the
|
||
// layer back to hidden and every marker vanished. An empty district still
|
||
// opens with the layer off.
|
||
// v24: Circle measurements can be resized after drawing: the tooltip on a
|
||
// finished circle carries minus, the figure (tap to type an exact radius)
|
||
// and plus, stepping by increments that grow with the size. Circle radius
|
||
// and area are now reported as ground distance rather than projected Web
|
||
// Mercator metres, which overstated them by 1/cos(latitude) — about 3 m on
|
||
// a 200 m circle at Wa. The Circle and Area analysis popups can be dragged
|
||
// by their title bar, staying attached to the shape they describe.
|
||
// v25: Releases no longer delete offline map tiles. Tile cache names used to
|
||
// include CACHE_VERSION, and activation deletes caches whose names are not
|
||
// current, so every release since the tile caches were split per provider
|
||
// threw away every tile officers had downloaded for field work. Tile caches
|
||
// now carry their own TILE_CACHE_VERSION, frozen at v24, and tiles in any
|
||
// older tile cache are moved into the current ones instead of deleted.
|
||
// v26: Shapefile export works again. Choosing "Shapefile" in the Export GIS
|
||
// modal threw "offset is not defined" and produced no file: shp-write's
|
||
// dbf dependency assigns an undeclared variable, which is fine in the
|
||
// browserify bundle it was written for but a ReferenceError under ESM's
|
||
// strict mode. Two further breakages sat behind it — shp-write branches
|
||
// on `process.browser`, and delivers the zip by navigating to a data:
|
||
// URL, which Chromium blocks. src/shapefile-write.js now writes the
|
||
// .shp/.shx/.dbf/.prj/.cpg itself and hands over a Blob, so shp-write,
|
||
// dbf and JSZip are gone from the dependency tree.
|
||
// Also: the build no longer preloads the geotiff and shpjs chunks at
|
||
// startup. Vite's preload helper had been parked inside the geotiff
|
||
// chunk, and JSZip inside the shpjs one, so the entry needed both on
|
||
// every visit — about 428 kB that is now fetched only when a COG
|
||
// layer or a shapefile import actually asks for it. New hashed bundle.
|
||
// v27: The drawing rail's option bar covered the Transform button. ol-ext
|
||
// positions the flyout with `left: 100%`, which is meant to clear the
|
||
// whole rail — the rule that makes that work sets the rail's controls to
|
||
// `position: static`, and it only tied with ol-ext's own rule on
|
||
// specificity, so stylesheet order decided the winner and ol-ext's
|
||
// injected sheet came last. The flyout was therefore positioned against
|
||
// the Select button and landed on the second column. The selector now
|
||
// carries one class more. This is the same failure as v16, in a second
|
||
// place.
|
||
// Also: the local database now opens when the device is offline. The
|
||
// fetch handler skipped every request whose destination was 'worker', to
|
||
// stay clear of the Vite dev server's module plumbing. In a build,
|
||
// SQLocal's worker is a hashed file under /assets/, so it was never
|
||
// cached either — with the network down the request failed, initSchema()
|
||
// never resolved, and everything waiting on the database stopped without
|
||
// an error. The map, the tiles and the shell all worked, so the app
|
||
// looked like it was working offline while nothing could be saved or
|
||
// read. The skip now covers only the dev server's own paths.
|
||
// v28: The module is named GIS Tools, not Drawing Tools. The change reaches
|
||
// the installed app through manifest.json, so the shell has to be
|
||
// re-precached for a device that already has it on its home screen.
|
||
// v29: A direct request for /index.html bypassed index.php, so no session was
|
||
// injected and the app reported "No session injected" even for a
|
||
// signed-in officer. The LUSPA landing page links to exactly that URL.
|
||
// .htaccess now routes it through index.php; the shell still precaches it,
|
||
// since it is a real entry point and a navigation that misses the cache
|
||
// throws rather than falling back. Re-precache on installed devices.
|
||
const CACHE_VERSION = 'v29';
|
||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||
const API_CACHE = `api-${CACHE_VERSION}`;
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Tile caches — one per provider so users can clear them independently.
|
||
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
|
||
// which covers a Ghana district at zoom 10–15 (typical field-work range).
|
||
// ----------------------------------------------------------------------------
|
||
//
|
||
// Tile caches are versioned separately from the app. They used to take their
|
||
// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache
|
||
// whose name is not current — so each release silently threw away every tile an
|
||
// officer had downloaded for offline field work, for every provider. Map tiles
|
||
// do not change between releases of the app, so their cache names no longer do.
|
||
//
|
||
// TILE_CACHE_VERSION is frozen at the value CACHE_VERSION had when that stopped,
|
||
// so devices already on v24 keep their existing tile caches with nothing to copy.
|
||
// Change it only if the way tiles are stored changes — and even then, activate
|
||
// moves tiles from older tile caches into the new ones rather than deleting them
|
||
// (see migrateTileCache).
|
||
const TILE_CACHE_VERSION = 'v24';
|
||
const TILES_OSM = `tiles-osm-${TILE_CACHE_VERSION}`;
|
||
const TILES_TOPO = `tiles-topo-${TILE_CACHE_VERSION}`;
|
||
const TILES_SATELLITE = `tiles-satellite-${TILE_CACHE_VERSION}`;
|
||
const TILES_CARTO_LIGHT = `tiles-carto-light-${TILE_CACHE_VERSION}`;
|
||
const TILES_CARTO_DARK = `tiles-carto-dark-${TILE_CACHE_VERSION}`;
|
||
|
||
// Per-provider tile limits.
|
||
// • OSM and Topographic are the providers offered for active offline
|
||
// download (Phase 2 dialog), so they get a higher cap (~240 MB each at
|
||
// ~30 KB/tile) — enough for a typical Ghana district at zoom 10–15.
|
||
// • The other providers serve passive caching only (whatever the user has
|
||
// already viewed), so 1 500 tiles ≈ 45 MB is plenty.
|
||
//
|
||
// Total max ≈ 5 × ~150 MB = ~750 MB on disk in the worst case, but only the
|
||
// two downloadable buckets are likely to fill. Eviction sweeps run every 100
|
||
// inserts (see EVICTION_CHECK_INTERVAL) so memory pressure stays bounded.
|
||
const TILE_LIMITS = {
|
||
[TILES_OSM]: 8000,
|
||
[TILES_TOPO]: 8000,
|
||
[TILES_SATELLITE]: 1500,
|
||
[TILES_CARTO_LIGHT]: 1500,
|
||
[TILES_CARTO_DARK]: 1500,
|
||
};
|
||
|
||
// Per-cache running insert counter, in memory. Avoids calling cache.keys()
|
||
// (which materialises every Request object in the cache) on every put — that
|
||
// was the cause of the Safari "reloaded due to memory pressure" failures.
|
||
//
|
||
// We only run a real eviction sweep every EVICTION_CHECK_INTERVAL inserts.
|
||
const _tileInsertCounters = new Map(); // cacheName → number of inserts since last eviction
|
||
const EVICTION_CHECK_INTERVAL = 100;
|
||
|
||
// Friendly name shown in the UI (matches Settings card labels)
|
||
const TILE_CACHE_LABELS = {
|
||
[TILES_OSM]: 'OpenStreetMap',
|
||
[TILES_TOPO]: 'Topographic',
|
||
[TILES_SATELLITE]: 'Satellite',
|
||
[TILES_CARTO_LIGHT]: 'Carto Light',
|
||
[TILES_CARTO_DARK]: 'Carto Dark',
|
||
};
|
||
|
||
const ALL_TILE_CACHES = Object.keys(TILE_LIMITS);
|
||
|
||
// Approximate average tile size — used for storage estimation.
|
||
// Real measurements: PNG tiles range 5–80 KB; 30 KB is a good middle ground.
|
||
const AVG_TILE_BYTES = 30 * 1024;
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// App shell assets — precached on install.
|
||
// ----------------------------------------------------------------------------
|
||
// Both '/' and '/index.html' are precached, because both are real entry
|
||
// points: the installed app opens start_url './' while the LUSPA landing page
|
||
// links to '/index.html'. Precaching both is only correct because .htaccess
|
||
// routes /index.html through index.php — before that rule, fetching it here
|
||
// stored a shell with no session injected. networkFirst() throws when a
|
||
// navigation misses the cache, so an un-precached entry point means a cold
|
||
// offline start at that URL fails outright.
|
||
const SHELL_ASSETS = [
|
||
'/',
|
||
'/index.html',
|
||
'/offline.html',
|
||
'/manifest.json'
|
||
];
|
||
|
||
// ============================================================================
|
||
// INSTALL EVENT
|
||
// ============================================================================
|
||
|
||
self.addEventListener('install', (event) => {
|
||
console.log('[SW] Installing...');
|
||
|
||
event.waitUntil(
|
||
caches.open(SHELL_CACHE)
|
||
.then((cache) => {
|
||
console.log('[SW] Precaching app shell');
|
||
return cache.addAll(SHELL_ASSETS);
|
||
})
|
||
.then(() => self.skipWaiting())
|
||
);
|
||
});
|
||
|
||
// ============================================================================
|
||
// ACTIVATE EVENT
|
||
// ============================================================================
|
||
|
||
self.addEventListener('activate', (event) => {
|
||
console.log('[SW] Activating...');
|
||
|
||
event.waitUntil((async () => {
|
||
const cacheNames = await caches.keys();
|
||
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
|
||
|
||
// App caches from earlier releases hold nothing worth keeping: delete them.
|
||
await Promise.all(
|
||
cacheNames
|
||
.filter((name) =>
|
||
(name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-'))
|
||
&& !keep.has(name))
|
||
.map((name) => {
|
||
console.log('[SW] Deleting old cache:', name);
|
||
return caches.delete(name);
|
||
})
|
||
);
|
||
|
||
// Take control before moving tiles, so the page is not held waiting on a
|
||
// copy that can run to thousands of entries.
|
||
await self.clients.claim();
|
||
|
||
// Tile caches from earlier releases hold what officers downloaded for
|
||
// offline use: move those tiles into the current caches, then delete. This
|
||
// includes the legacy single "tiles-v1" bucket, whose tiles are sorted into
|
||
// the right provider by URL.
|
||
const oldTileCaches = cacheNames.filter((name) => name.startsWith('tiles-') && !keep.has(name));
|
||
for (const name of oldTileCaches) {
|
||
await migrateTileCache(name);
|
||
}
|
||
})());
|
||
});
|
||
|
||
/**
|
||
* Move the tiles in a tile cache from an earlier release into the current
|
||
* per-provider caches, then delete it.
|
||
*
|
||
* Each tile goes where getTileCacheName() would put it today, so the legacy
|
||
* single bucket is sorted by provider too. Nothing already in the current cache
|
||
* is overwritten, and no cache is filled past its limit. If the copy fails part
|
||
* way, the old cache is left in place rather than deleted, so its tiles are not
|
||
* lost and the next activation carries on from there.
|
||
*/
|
||
async function migrateTileCache(oldName) {
|
||
try {
|
||
const oldCache = await caches.open(oldName);
|
||
const requests = await oldCache.keys();
|
||
const targets = new Map(); // cacheName -> { cache, count }
|
||
let moved = 0;
|
||
let skipped = 0;
|
||
|
||
for (const request of requests) {
|
||
const url = new URL(request.url);
|
||
const targetName = getTileCacheName(url);
|
||
|
||
// A provider no longer cached, or a Carto tile requested without a key.
|
||
// The app has sent the CARTO key on every tile request since August 2026,
|
||
// so a keyless Carto URL is never requested again — and anything cached
|
||
// after CARTO began enforcing keys carries its "API KEY REQUIRED" stamp.
|
||
const deadCarto = url.hostname.endsWith('basemaps.cartocdn.com') && !url.searchParams.has('key');
|
||
if (!targetName || deadCarto) { skipped++; continue; }
|
||
|
||
let target = targets.get(targetName);
|
||
if (!target) {
|
||
const cache = await caches.open(targetName);
|
||
target = { cache, count: (await cache.keys()).length };
|
||
targets.set(targetName, target);
|
||
}
|
||
if (target.count >= (TILE_LIMITS[targetName] || 1500)) { skipped++; continue; }
|
||
if (await target.cache.match(request)) { skipped++; continue; }
|
||
|
||
const response = await oldCache.match(request);
|
||
if (!response) { skipped++; continue; }
|
||
await target.cache.put(request, response);
|
||
target.count++;
|
||
moved++;
|
||
}
|
||
|
||
await caches.delete(oldName);
|
||
console.log(`[SW] Moved ${moved} tiles out of ${oldName} (${skipped} not needed) and removed it`);
|
||
} catch (err) {
|
||
console.warn(`[SW] Could not move tiles out of ${oldName}; kept it for the next activation:`, err);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// FETCH EVENT
|
||
// ============================================================================
|
||
|
||
self.addEventListener('fetch', (event) => {
|
||
const request = event.request;
|
||
const url = new URL(request.url);
|
||
|
||
// Only handle GET requests
|
||
if (request.method !== 'GET') return;
|
||
|
||
// Skip chrome-extension and other non-http(s) requests
|
||
if (!url.protocol.startsWith('http')) return;
|
||
|
||
// Skip the Vite dev server's own module plumbing — it serves worker scripts
|
||
// straight out of node_modules with a ?worker_file query, and intercepting
|
||
// those breaks module workers (e.g. SQLocal/SQLite).
|
||
if (url.pathname.includes('node_modules') || url.search.includes('worker_file')) return;
|
||
|
||
// A worker script in a BUILT app is a hashed file under /assets/, as
|
||
// immutable as any other asset — and it HAS to be cached, or SQLocal cannot
|
||
// start its worker with the network down and the local database never opens.
|
||
// Skipping every 'worker' destination was what made that happen: see v27.
|
||
// Anything else with destination 'worker' is still left alone.
|
||
if (request.destination === 'worker' && !isAppAsset(url)) return;
|
||
|
||
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
|
||
const tileCache = getTileCacheName(url);
|
||
if (tileCache) {
|
||
event.respondWith(tileCacheThenNetwork(request, tileCache));
|
||
return;
|
||
}
|
||
|
||
// ----- OTHER ROUTES -----
|
||
if (isApiRequest(url)) {
|
||
event.respondWith(networkFirst(request, API_CACHE));
|
||
} else if (isModuleAsset(url)) {
|
||
event.respondWith(staleWhileRevalidate(request, MODULES_CACHE));
|
||
} else if (isHtmlAsset(url)) {
|
||
// HTML uses network-first so a fresh deploy is picked up immediately.
|
||
// Falls back to the cached copy when offline (so the app still loads).
|
||
event.respondWith(networkFirst(request, SHELL_CACHE));
|
||
} else if (isAppAsset(url)) {
|
||
// Hashed JS / CSS / WASM / icons are immutable per build — cache-first
|
||
// is the right strategy here.
|
||
event.respondWith(cacheFirst(request, SHELL_CACHE));
|
||
}
|
||
// Let other requests pass through to network
|
||
});
|
||
|
||
// ============================================================================
|
||
// URL CLASSIFICATION
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Classify a URL into the appropriate tile cache.
|
||
* Returns `null` for non-tile requests, or for tile providers we deliberately
|
||
* do NOT cache (e.g. Google — caching is forbidden by their ToS).
|
||
*/
|
||
function getTileCacheName(url) {
|
||
const host = url.hostname;
|
||
|
||
// OpenStreetMap — tile.openstreetmap.org and a/b/c subdomains
|
||
if (host.endsWith('tile.openstreetmap.org')) return TILES_OSM;
|
||
|
||
// OpenTopoMap — a/b/c.tile.opentopomap.org
|
||
if (host.endsWith('tile.opentopomap.org') || host.endsWith('opentopomap.org')) return TILES_TOPO;
|
||
|
||
// Carto Basemaps — light_all / dark_all distinguished by path
|
||
if (host.endsWith('basemaps.cartocdn.com')) {
|
||
if (url.pathname.includes('/light_all/')) return TILES_CARTO_LIGHT;
|
||
if (url.pathname.includes('/dark_all/')) return TILES_CARTO_DARK;
|
||
return null; // unknown Carto style
|
||
}
|
||
|
||
// Esri — server.arcgisonline.com
|
||
if (host.endsWith('arcgisonline.com')) return TILES_SATELLITE;
|
||
|
||
// Google — caching forbidden by ToS, do not store
|
||
if (host.endsWith('google.com') || host.endsWith('googleapis.com')) return null;
|
||
|
||
// Other tile providers (WMS endpoints, OWS, custom) — not cached at this layer
|
||
// (the user's "online only" toast handles those).
|
||
return null;
|
||
}
|
||
|
||
function isApiRequest(url) {
|
||
return url.pathname.startsWith('/api/') ||
|
||
url.pathname.endsWith('.php');
|
||
}
|
||
|
||
function isModuleAsset(url) {
|
||
return url.pathname.startsWith('/modules/');
|
||
}
|
||
|
||
/**
|
||
* HTML pages (and the bare PWA root) — fetched network-first so new deploys
|
||
* roll out immediately. We never want a stale shell pointing at hashed
|
||
* asset URLs that no longer exist on the server.
|
||
*/
|
||
function isHtmlAsset(url) {
|
||
if (url.origin !== self.location.origin) return false;
|
||
if (url.pathname.endsWith('.html')) return true;
|
||
// Treat the bare site root and any trailing-slash path as HTML too,
|
||
// since they map to index.html / index.php server-side.
|
||
if (url.pathname === '/' || url.pathname.endsWith('/')) return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Immutable per-build static assets (hashed JS / CSS / WASM / images) —
|
||
* safe to cache-first; each new build produces new URLs so there's no
|
||
* stale-content risk.
|
||
*/
|
||
function isAppAsset(url) {
|
||
if (url.origin !== self.location.origin) return false;
|
||
if (isHtmlAsset(url)) return false; // HTML handled separately
|
||
return (
|
||
url.pathname.endsWith('.css') ||
|
||
url.pathname.endsWith('.js') ||
|
||
url.pathname.endsWith('.wasm') ||
|
||
url.pathname.endsWith('.json') ||
|
||
!!url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp|woff2?|ttf|otf)$/)
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// CACHING STRATEGIES
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Cache First — Use cache, fallback to network.
|
||
* Best for: App shell, static assets.
|
||
*/
|
||
async function cacheFirst(request, cacheName) {
|
||
const cached = await caches.match(request);
|
||
if (cached) return cached;
|
||
|
||
try {
|
||
const response = await fetch(request);
|
||
if (response.ok) {
|
||
const cache = await caches.open(cacheName);
|
||
cache.put(request, response.clone());
|
||
}
|
||
return response;
|
||
} catch (error) {
|
||
if (request.mode === 'navigate') {
|
||
return caches.match('/offline.html');
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Network First — Try network, fallback to cache.
|
||
* Best for: API requests, dynamic content.
|
||
*/
|
||
async function networkFirst(request, cacheName) {
|
||
try {
|
||
const response = await fetch(request);
|
||
if (response.ok) {
|
||
const cache = await caches.open(cacheName);
|
||
cache.put(request, response.clone());
|
||
}
|
||
return response;
|
||
} catch (error) {
|
||
const cached = await caches.match(request);
|
||
if (cached) return cached;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stale While Revalidate — Return cache immediately, update in background.
|
||
* Best for: Module assets, frequently updated content.
|
||
*/
|
||
async function staleWhileRevalidate(request, cacheName) {
|
||
const cache = await caches.open(cacheName);
|
||
const cached = await cache.match(request);
|
||
|
||
const fetchPromise = fetch(request).then((response) => {
|
||
if (response.ok) {
|
||
cache.put(request, response.clone());
|
||
}
|
||
return response;
|
||
}).catch(() => cached);
|
||
|
||
return cached || fetchPromise;
|
||
}
|
||
|
||
/**
|
||
* Tile Cache then Network — Per-host bucket with size limit.
|
||
* Cache first; on miss, fetch from network and store.
|
||
*
|
||
* Memory-conservative eviction:
|
||
* • Increments an in-memory counter on every successful insert
|
||
* • Only calls cache.keys() (which materialises all Request objects) every
|
||
* EVICTION_CHECK_INTERVAL inserts — so the cost is amortised
|
||
* • Eviction drops the oldest 10 % when over the per-host limit
|
||
*
|
||
* On network failure (offline), serves a 408 so the map renders a blank tile
|
||
* rather than throwing.
|
||
*/
|
||
async function tileCacheThenNetwork(request, cacheName) {
|
||
const cache = await caches.open(cacheName);
|
||
const cached = await cache.match(request);
|
||
if (cached) return cached;
|
||
|
||
try {
|
||
const response = await fetch(request);
|
||
|
||
if (response.ok) {
|
||
// Bump the counter; periodically run a real eviction sweep
|
||
const count = (_tileInsertCounters.get(cacheName) || 0) + 1;
|
||
_tileInsertCounters.set(cacheName, count);
|
||
|
||
if (count % EVICTION_CHECK_INTERVAL === 0) {
|
||
// Reset the counter — next sweep is another EVICTION_CHECK_INTERVAL away
|
||
_tileInsertCounters.set(cacheName, 0);
|
||
await maybeEvict(cache, cacheName);
|
||
}
|
||
|
||
// Don't await put() — it can run after we return the response, keeping
|
||
// the fetch hot path lightweight.
|
||
cache.put(request, response.clone()).catch((err) => {
|
||
// QuotaExceededError → run an immediate eviction sweep and retry once
|
||
if (err && err.name === 'QuotaExceededError') {
|
||
maybeEvict(cache, cacheName, /* force */ true).catch(() => {});
|
||
}
|
||
});
|
||
}
|
||
|
||
return response;
|
||
} catch (error) {
|
||
// Offline — let the map renderer show a blank tile
|
||
return new Response('', { status: 408, statusText: 'Offline' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run an eviction sweep on a cache, dropping the oldest 10 % of entries
|
||
* when over the per-cache limit. Heavy: only call periodically.
|
||
*/
|
||
async function maybeEvict(cache, cacheName, force = false) {
|
||
try {
|
||
const limit = TILE_LIMITS[cacheName] || 1500;
|
||
const keys = await cache.keys();
|
||
if (force || keys.length >= limit) {
|
||
const drop = Math.max(1, Math.ceil(limit * 0.1));
|
||
const toDelete = keys.slice(0, drop);
|
||
await Promise.all(toDelete.map((k) => cache.delete(k)));
|
||
}
|
||
} catch (err) {
|
||
console.warn('[SW] eviction sweep failed for', cacheName, err);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// MESSAGE HANDLING
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Reply to a message, preferring the transferred MessageChannel port (the
|
||
* window's pwa.js sends a port for request/response correlation), and
|
||
* falling back to the originating WindowClient if no port was supplied.
|
||
*/
|
||
function replyTo(event, message) {
|
||
if (event.ports && event.ports[0]) {
|
||
try { event.ports[0].postMessage(message); return; } catch {}
|
||
}
|
||
if (event.source && typeof event.source.postMessage === 'function') {
|
||
event.source.postMessage(message);
|
||
}
|
||
}
|
||
|
||
self.addEventListener('message', (event) => {
|
||
const { type, payload, cacheName } = event.data || {};
|
||
|
||
switch (type) {
|
||
case 'SKIP_WAITING':
|
||
self.skipWaiting();
|
||
break;
|
||
|
||
case 'CACHE_MODULES':
|
||
cacheModules(payload.modules);
|
||
break;
|
||
|
||
case 'CLEAR_USER_CACHE':
|
||
clearUserCaches();
|
||
break;
|
||
|
||
case 'GET_CACHE_STATUS':
|
||
getCacheStatus().then((status) => replyTo(event, { type: 'CACHE_STATUS', status }));
|
||
break;
|
||
|
||
// ----- Tile-cache management (Phase 1 offline maps) -----
|
||
case 'GET_TILE_STATS':
|
||
getTileStats().then((stats) => replyTo(event, { type: 'TILE_STATS', stats }));
|
||
break;
|
||
|
||
case 'CLEAR_TILE_CACHES':
|
||
clearTileCaches().then(() => replyTo(event, { type: 'TILE_CACHES_CLEARED' }));
|
||
break;
|
||
|
||
// Clear a single provider's tile cache (Phase 3 — per-provider Clear).
|
||
// Validates the requested name against the known ALL_TILE_CACHES list so
|
||
// a misbehaving caller can't drop unrelated caches.
|
||
case 'CLEAR_TILE_CACHE':
|
||
if (typeof cacheName === 'string' && ALL_TILE_CACHES.includes(cacheName)) {
|
||
caches.delete(cacheName).then((deleted) => {
|
||
_tileInsertCounters.delete(cacheName);
|
||
_cachedStats = null; _cachedStatsAt = 0; // invalidate stats cache
|
||
replyTo(event, { type: 'TILE_CACHE_CLEARED', cacheName, deleted });
|
||
});
|
||
} else {
|
||
replyTo(event, { type: 'TILE_CACHE_CLEARED', cacheName, deleted: false, error: 'Unknown or invalid cache name' });
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
|
||
// ============================================================================
|
||
// HELPER FUNCTIONS
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Cache specific modules on demand.
|
||
*/
|
||
async function cacheModules(moduleNames) {
|
||
const cache = await caches.open(MODULES_CACHE);
|
||
|
||
for (const moduleName of moduleNames) {
|
||
try {
|
||
const moduleAssets = [
|
||
`/modules/${moduleName}/index.js`,
|
||
`/modules/${moduleName}/index.css`,
|
||
`/modules/${moduleName}/index.html`
|
||
];
|
||
|
||
await cache.addAll(moduleAssets.filter(async (url) => {
|
||
try {
|
||
const response = await fetch(url, { method: 'HEAD' });
|
||
return response.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}));
|
||
|
||
console.log('[SW] Cached module:', moduleName);
|
||
} catch (error) {
|
||
console.warn('[SW] Failed to cache module:', moduleName, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear user-specific caches (call on logout).
|
||
* Tile caches are NOT cleared here — those belong to the device, not the user.
|
||
*/
|
||
async function clearUserCaches() {
|
||
await caches.delete(API_CACHE);
|
||
await caches.delete(MODULES_CACHE);
|
||
console.log('[SW] Cleared user caches');
|
||
}
|
||
|
||
/**
|
||
* Get summary status of all caches (count of entries in each).
|
||
*/
|
||
async function getCacheStatus() {
|
||
const cacheNames = await caches.keys();
|
||
const status = {};
|
||
|
||
for (const name of cacheNames) {
|
||
const cache = await caches.open(name);
|
||
const keys = await cache.keys();
|
||
status[name] = keys.length;
|
||
}
|
||
|
||
return status;
|
||
}
|
||
|
||
/**
|
||
* Get per-provider tile cache statistics.
|
||
*
|
||
* Returns shape:
|
||
* {
|
||
* totals: { count, estBytes },
|
||
* byProvider: [{ key, label, count, limit, estBytes }, ...]
|
||
* }
|
||
*
|
||
* estBytes is an approximation (count × AVG_TILE_BYTES). For an exact size,
|
||
* the caller can use navigator.storage.estimate() on the page side.
|
||
*
|
||
* Result is cached for STATS_TTL_MS so rapid re-queries (e.g. multiple
|
||
* Settings opens) don't re-enumerate every cache.
|
||
*/
|
||
const STATS_TTL_MS = 10 * 1000;
|
||
let _cachedStats = null;
|
||
let _cachedStatsAt = 0;
|
||
|
||
async function getTileStats({ force = false } = {}) {
|
||
const now = Date.now();
|
||
if (!force && _cachedStats && (now - _cachedStatsAt) < STATS_TTL_MS) {
|
||
return _cachedStats;
|
||
}
|
||
|
||
const byProvider = [];
|
||
let totalCount = 0;
|
||
|
||
for (const cacheName of ALL_TILE_CACHES) {
|
||
let count = 0;
|
||
if (await caches.has(cacheName)) {
|
||
const cache = await caches.open(cacheName);
|
||
// matchAll returns a smaller payload than keys() on Safari, but neither
|
||
// is free. Done at most once per STATS_TTL_MS thanks to the cache above.
|
||
const keys = await cache.keys();
|
||
count = keys.length;
|
||
}
|
||
byProvider.push({
|
||
key: cacheName,
|
||
label: TILE_CACHE_LABELS[cacheName] || cacheName,
|
||
count,
|
||
limit: TILE_LIMITS[cacheName] || 0,
|
||
estBytes: count * AVG_TILE_BYTES,
|
||
});
|
||
totalCount += count;
|
||
}
|
||
|
||
_cachedStats = {
|
||
totals: {
|
||
count: totalCount,
|
||
estBytes: totalCount * AVG_TILE_BYTES,
|
||
},
|
||
byProvider,
|
||
};
|
||
_cachedStatsAt = now;
|
||
return _cachedStats;
|
||
}
|
||
|
||
/**
|
||
* Delete every tile cache. Frees the device storage used by cached map tiles.
|
||
* Does not affect app-shell, modules, or API caches.
|
||
*/
|
||
async function clearTileCaches() {
|
||
const results = await Promise.all(
|
||
ALL_TILE_CACHES.map((name) => caches.delete(name))
|
||
);
|
||
// Reset counters and invalidate stats cache
|
||
_tileInsertCounters.clear();
|
||
_cachedStats = null;
|
||
_cachedStatsAt = 0;
|
||
console.log('[SW] Cleared tile caches:', ALL_TILE_CACHES.filter((_, i) => results[i]));
|
||
}
|