/**
* BeNomad BeMap JavaScript API — BrowserCache (v2.0)
*
* Page-side companion to `dist/bemap-sw-tiles.js`. Wraps the Service
* Worker registration, the INIT handshake, and the enable/disable/stats
* message channel.
*
* The instance is owned by `bemap.MapLibreMap` and exposed through these
* map methods:
* - `map.enableBrowserCache()` → `cache.enable()`
* - `map.disableBrowserCache()` → `cache.disable()`
* - `map.getBrowserCacheStats()` → `cache.getStats()`
* - `map.clearBrowserCache()` → `cache.clear()`
*
* Persists the on/off toggle in `localStorage` under the key
* `bemap_browser_cache` so customer apps can wire it to a consent banner.
*
* @since 2.0.0
* @public
* @constructor
* @param {Object} [options]
* @param {String} [options.tilesHost] Forwarded to the SW so it knows which host to intercept.
* @param {String} [options.serviceWorkerPath='/bemap-sw-tiles.js']
* @param {Function} [options.onStats] Live `{ hits, misses }` updates.
* @param {Function} [options.onError] Called with a `bemap.Error` on registration / message failures.
*/
bemap.BrowserCache = function (options) {
var opts = options || {};
this.tilesHost = opts.tilesHost || null;
// An explicit caller-supplied path turns off the auto-fallback chain so
// we never silently register a different file than they asked for.
this._explicitSwPath = (opts.serviceWorkerPath != null);
this.swPath = opts.serviceWorkerPath || '/bemap-sw-tiles.js';
this._onStats = typeof opts.onStats === 'function' ? opts.onStats : function () {};
this._onError = typeof opts.onError === 'function' ? opts.onError : function () {};
this._registration = null;
this._enabled = this._loadToggle();
this._stats = { enabled: this._enabled, hits: 0, misses: 0, entries: 0, bytesEstimated: 0 };
this._messageHandler = null;
this._lastAttempts = [];
this._ackWaiters = {}; // ackType → [resolver] for INIT/ENABLE/CLAIM acks
};
bemap.BrowserCache.TOGGLE_KEY = 'bemap_browser_cache';
/**
* Locate the directory the `bemap-js-api.js` (or `.min.js`) bundle was loaded
* from, so we can derive sensible default SW paths when the consumer hasn't
* pinned one. Returns a path ending in `/`, or `null` if not detectable.
*/
bemap.BrowserCache._findBemapScriptDir = function () {
var info = bemap.BrowserCache._findBemapScriptInfo();
return info ? info.dir : null;
};
/**
* Locate the bemap-js-api bundle's script tag. Returns `{ src, dir }` where
* `src` is the absolute URL we matched and `dir` is its directory (ending
* in `/`). Used both for SW path discovery and for diagnostic warnings.
*/
bemap.BrowserCache._findBemapScriptInfo = function () {
if (typeof document === 'undefined') return null;
var scripts = document.getElementsByTagName('script');
for (var i = scripts.length - 1; i >= 0; i--) {
var src = scripts[i].src || '';
if (/bemap-js-api(\.min)?\.js(\?|#|$)/.test(src)) {
try {
var u = new URL(src, window.location.href);
return { src: u.href, dir: u.pathname.replace(/[^/]*$/, '') };
} catch (e) { return null; }
}
}
return null;
};
bemap.BrowserCache.prototype._loadToggle = function () {
try {
if (typeof localStorage === 'undefined') return true;
var raw = localStorage.getItem(bemap.BrowserCache.TOGGLE_KEY);
if (raw === null) return true;
return raw !== 'false';
} catch (e) { return true; }
};
bemap.BrowserCache.prototype._saveToggle = function (on) {
try {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(bemap.BrowserCache.TOGGLE_KEY, on ? 'true' : 'false');
} catch (e) { /* ignore */ }
};
/**
* Detect whether the current environment supports the Service Worker.
* @public
* @return {Boolean}
*/
bemap.BrowserCache.prototype.isSupported = function () {
if (typeof navigator === 'undefined' || !navigator.serviceWorker) return false;
if (typeof window !== 'undefined' && window.location) {
var protocol = window.location.protocol;
var hostname = window.location.hostname;
if (protocol === 'http:' && hostname !== 'localhost' && hostname !== '127.0.0.1') return false;
}
return true;
};
/**
* Register the SW and send the INIT handshake.
*
* @public
* @return {Promise<ServiceWorkerRegistration>}
*/
/**
* Build the ordered list of paths to try when registering the SW. When the
* caller explicitly passed `opts.serviceWorkerPath` we honour that exactly
* (single entry, no fallback). Otherwise we try, in order:
* 1. `/bemap-sw-tiles.js` — canonical site-root install
* 2. `<bemap-js-api.js dir>../bemap-sw-tiles.js` — common: SW lives one
* level up from the dist/ folder the bundle ships in (e.g. evmove root)
* 3. `<bemap-js-api.js dir>bemap-sw-tiles.js` — SW sits next to the
* bundle inside dist/. Scope is narrower but still works for apps
* whose pages live under that directory.
*/
bemap.BrowserCache.prototype._candidatePaths = function () {
if (this._explicitSwPath) return [this.swPath];
var paths = [];
var push = function (p) { if (p && paths.indexOf(p) === -1) paths.push(p); };
push('/bemap-sw-tiles.js');
// Page-directory-relative — covers apps served from a sub-path (e.g.
// `/evmove/index.html` → `/evmove/bemap-sw-tiles.js`). For root pages
// this dedups against the canonical entry.
if (typeof window !== 'undefined' && window.location) {
var pageDir = window.location.pathname.replace(/[^/]*$/, '');
if (pageDir) push(pageDir + 'bemap-sw-tiles.js');
}
var scriptDir = bemap.BrowserCache._findBemapScriptDir();
if (scriptDir) {
var parentDir = scriptDir.replace(/[^/]+\/$/, '');
if (parentDir && parentDir !== scriptDir) push(parentDir + 'bemap-sw-tiles.js');
push(scriptDir + 'bemap-sw-tiles.js');
}
return paths;
};
/**
* Unregister any previously-installed bemap tile-cache Service Worker on this
* origin and delete its Cache Storage. Called automatically when browserCache
* is OFF (the default) so a stale / mis-scoped worker can't keep controlling
* the page and serving cached range data (which can corrupt PMTiles rendering
* and stop the map loading). Safe no-op when none exist.
* @public
* @since 2.0.0
*/
bemap.BrowserCache.unregisterStale = function () {
try {
if (typeof navigator !== 'undefined' && navigator.serviceWorker &&
typeof navigator.serviceWorker.getRegistrations === 'function') {
navigator.serviceWorker.getRegistrations().then(function (regs) {
regs.forEach(function (r) {
var w = r.active || r.waiting || r.installing;
var url = (w && w.scriptURL) || '';
if (url.indexOf('bemap-sw-tiles') !== -1) {
r.unregister().then(function () {
console.info('[bemap-cache] removed stale Service Worker: ' + url);
});
}
});
}).catch(function () {});
}
if (typeof caches !== 'undefined' && typeof caches.keys === 'function') {
caches.keys().then(function (keys) {
keys.forEach(function (k) { if (k.indexOf('bemap-tiles-') === 0) caches.delete(k); });
}).catch(function () {});
}
} catch (e) { /* ignore */ }
};
bemap.BrowserCache.prototype.register = function () {
if (!this.isSupported()) {
console.warn('bemap.BrowserCache: Service Worker not supported (or page served over plain HTTP)');
return Promise.resolve(null);
}
var self = this;
var paths = this._candidatePaths();
self._lastAttempts = [];
var tryAt = function (i) {
if (i >= paths.length) {
self._onError(new bemap.Error({
code: bemap.Error.NETWORK,
message: 'BrowserCache.register failed — tried: ' + self._lastAttempts.join(' | ')
}));
return null;
}
var path = paths[i];
return navigator.serviceWorker.register(path)
.then(function (reg) {
self.swPath = path;
self._registration = reg;
// WARNING #1 — the worker's scope does not COVER this page, so it can
// never control the app. Correct for root AND sub-path hosting: a
// sub-path scope (e.g. /app/) legitimately controls /app/* and must NOT
// warn — only a scope that doesn't contain the page path is wrong.
try {
var scopePath = new URL(reg.scope).pathname;
var pagePath = (typeof location !== 'undefined') ? location.pathname : '/';
if (pagePath.indexOf(scopePath) !== 0) {
console.warn(
'[bemap] BeNomad Tiles: Service Worker scope "' + reg.scope + '" does not cover this page (' +
pagePath + ') — it cannot control the app, so tile caching is OFF. The worker must be served from\n' +
' the folder your app is served from (next to index.html); a relative serviceWorkerPath ("bemap-sw-tiles.js") does this.'
);
}
} catch (e) { /* ignore */ }
self._installMessageHandler();
// INIT handshake — wait (non-blocking) for the SW's READY ack. This is
// fire-and-forget: we never await it, so it can NEVER delay map load; a
// missing ack just logs a warning after the timeout.
self._postAndWait({ type: 'INIT', tilesHost: self.tilesHost, enabled: self._enabled }, 'READY', 3000);
// Recover an active-but-uncontrolled worker (the SW activated AFTER this
// page loaded — the common F5-less case) WITHOUT a reload: post CLAIM
// directly to the active worker. The controller is null (that's exactly
// why we claim), so the normal controller-gated queue cannot deliver it;
// on CLAIMED, `controllerchange` fires and any queued messages flush.
if (navigator.serviceWorker && !navigator.serviceWorker.controller && reg.active) {
self._postAndWait({ type: 'CLAIM' }, 'CLAIMED', 3000, reg.active);
}
// WARNING #2 — registered but still not controlling after activation
// (file 404s where it was registered, or a stale worker lingers).
if (typeof setTimeout === 'function') {
setTimeout(function () {
if (!(navigator.serviceWorker && navigator.serviceWorker.controller)) {
console.warn(
'[bemap] BeNomad Tiles: Service Worker registered but is NOT controlling this page — tile caching is OFF.\n' +
' Ensure bemap-sw-tiles.js is served from the folder your app is served from (next to index.html).'
);
}
}, 3000);
}
return reg;
})
.catch(function (err) {
self._lastAttempts.push(path + ' → ' + (err && err.message ? err.message : err));
return tryAt(i + 1);
});
};
return tryAt(0);
};
/**
* The full list of registration attempts from the last `register()` call —
* each entry is `"<path> → <error message>"`. Exposed so the wrapper can
* surface a useful warning when nothing worked.
* @public
* @return {Array<String>}
*/
bemap.BrowserCache.prototype.getRegistrationAttempts = function () {
return (this._lastAttempts || []).slice();
};
bemap.BrowserCache.prototype._installMessageHandler = function () {
if (this._messageHandler) return;
var self = this;
// Scope to messages from the SW we registered. Multiple SW
// registrations on the same origin all broadcast through
// `navigator.serviceWorker` — without this filter, stats from a
// different SW would overwrite ours.
var ourScriptURL = (this._registration && this._registration.active && this._registration.active.scriptURL)
|| this.swPath || null;
this._messageHandler = function (event) {
var data = event && event.data;
if (!data || !data.type) return;
if (ourScriptURL && event.source && event.source.scriptURL && event.source.scriptURL !== ourScriptURL) return;
// Live stats: ignore the sentinel -1 placeholders that the SW emits
// when computing them would be too expensive on the hot path.
function mergeNonSentinel(target, source) {
var out = Object.assign({}, target);
for (var k in source) {
if (!Object.prototype.hasOwnProperty.call(source, k)) continue;
if (k === 'entries' || k === 'bytesEstimated') {
if (typeof source[k] === 'number' && source[k] >= 0) out[k] = source[k];
} else {
out[k] = source[k];
}
}
return out;
}
switch (data.type) {
case 'CACHE_STATS_LIVE':
case 'CACHE_STATS_RESULT':
self._stats = mergeNonSentinel(self._stats, data.stats || {});
self._stats.enabled = self._enabled;
self._onStats(self._stats);
break;
case 'CACHE_HOST_CONFLICT':
self._onError(new bemap.Error({
code: bemap.Error.CACHE_HOST_CONFLICT,
message: 'BrowserCache: too many concurrent tilesHosts registered with the Service Worker',
context: { rejectedHost: data.host }
}));
break;
// Handshake acks — resolve any pending `_postAndWait()` for that type.
// INIT → READY, ENABLE/DISABLE → STATE, CLAIM → CLAIMED. Purely additive:
// no ack still leaves the cache working (fire-and-forget elsewhere).
case 'READY': self._resolveAck('READY'); break;
case 'STATE': self._resolveAck('STATE'); break;
case 'CLAIMED': self._resolveAck('CLAIMED'); break;
default: break;
}
};
navigator.serviceWorker.addEventListener('message', this._messageHandler);
};
bemap.BrowserCache.prototype._postMessage = function (msg) {
var self = this;
// First visit: navigator.serviceWorker.controller is null until the
// freshly installed SW claims the page. If we post-and-bail here, the
// INIT / ENABLE / DISABLE message is lost. Use `serviceWorker.ready`
// (and the `controllerchange` event) to await the active controller.
if (navigator.serviceWorker.controller) {
try { navigator.serviceWorker.controller.postMessage(msg); } catch (e) { /* ignore */ }
return;
}
// Queue the message until a controller appears. We listen for both
// `controllerchange` and the explicit `ready` promise; whichever fires
// first wins (typically `ready` on first install).
this._pendingMessages = this._pendingMessages || [];
this._pendingMessages.push(msg);
if (this._pendingFlushScheduled) return;
this._pendingFlushScheduled = true;
var flush = function () {
if (!navigator.serviceWorker.controller) return;
self._pendingFlushScheduled = false;
var pending = self._pendingMessages || [];
self._pendingMessages = [];
for (var i = 0; i < pending.length; i++) {
try { navigator.serviceWorker.controller.postMessage(pending[i]); } catch (e) { /* ignore */ }
}
};
navigator.serviceWorker.ready.then(function () { flush(); }).catch(function () {
self._pendingFlushScheduled = false;
});
navigator.serviceWorker.addEventListener('controllerchange', flush);
};
/**
* Resolve every pending `_postAndWait()` registered for `ackType`.
* @private
* @param {String} ackType 'READY' | 'STATE' | 'CLAIMED'
*/
bemap.BrowserCache.prototype._resolveAck = function (ackType) {
var arr = this._ackWaiters && this._ackWaiters[ackType];
if (!arr || !arr.length) return;
var waiters = arr.slice();
this._ackWaiters[ackType] = [];
for (var i = 0; i < waiters.length; i++) {
try { waiters[i](); } catch (e) { /* ignore */ }
}
};
/**
* Post a message to the SW and resolve when the matching ack arrives (or after
* `timeoutMs`, resolving `false` and logging a warning). Purely additive and
* NON-BLOCKING — callers do not await it, so a slow/absent SW never delays the
* map. Set `directTarget` (a ServiceWorker) to post straight to that worker,
* bypassing the controller-gated queue — needed for CLAIM, where the controller
* is null by definition.
* @private
* @param {Object} msg
* @param {String} ackType 'READY' | 'STATE' | 'CLAIMED'
* @param {Number} [timeoutMs=3000]
* @param {ServiceWorker} [directTarget]
* @return {Promise<Boolean>} resolves true on ack, false on timeout.
*/
bemap.BrowserCache.prototype._postAndWait = function (msg, ackType, timeoutMs, directTarget) {
var self = this;
this._installMessageHandler();
this._ackWaiters = this._ackWaiters || {};
var wait = (typeof timeoutMs === 'number') ? timeoutMs : 3000;
return new Promise(function (resolve) {
var settled = false;
var timer = null;
var resolver = function () {
if (settled) return;
settled = true;
if (timer) { try { clearTimeout(timer); } catch (e) {} }
resolve(true);
};
(self._ackWaiters[ackType] = self._ackWaiters[ackType] || []).push(resolver);
if (typeof setTimeout === 'function') {
timer = setTimeout(function () {
if (settled) return;
settled = true;
var arr = self._ackWaiters[ackType] || [];
var idx = arr.indexOf(resolver);
if (idx !== -1) arr.splice(idx, 1);
if (typeof console !== 'undefined' && console.warn) {
console.warn('[bemap] BeNomad Tiles: no "' + ackType + '" ack from the Service Worker within ' +
wait + 'ms — continuing (tile caching is non-blocking and may still initialise).');
}
resolve(false);
}, wait);
}
if (directTarget && typeof directTarget.postMessage === 'function') {
try { directTarget.postMessage(msg); } catch (e) { /* ignore */ }
} else {
self._postMessage(msg);
}
});
};
/** @public */
bemap.BrowserCache.prototype.enable = function () {
this._enabled = true;
this._stats.enabled = true;
this._saveToggle(true);
this._postMessage({ type: 'ENABLE' });
return this;
};
/** @public */
bemap.BrowserCache.prototype.disable = function () {
this._enabled = false;
this._stats.enabled = false;
this._saveToggle(false);
this._postMessage({ type: 'DISABLE' });
return this;
};
/** @public */
bemap.BrowserCache.prototype.isEnabled = function () { return this._enabled; };
/**
* Returns the latest known stats. The SW broadcasts updates regularly via
* `'cache:stats'` (see the map's `on()`); this method returns the locally
* cached snapshot synchronously.
* @public
* @return {{ enabled: Boolean, hits: Number, misses: Number, entries: Number, bytesEstimated: Number }}
*/
bemap.BrowserCache.prototype.getStats = function () {
return Object.assign({}, this._stats);
};
/**
* Request a fresh snapshot from the SW (one-shot). Resolves with the stats.
* @public
* @return {Promise<Object>}
*/
bemap.BrowserCache.prototype.requestStats = function () {
var self = this;
return new Promise(function (resolve) {
if (!navigator.serviceWorker || !navigator.serviceWorker.controller) {
resolve(self.getStats());
return;
}
var channel = new MessageChannel();
var settled = false;
var done = function (data) {
if (settled) return;
settled = true;
if (data && data.type === 'CACHE_STATS_RESULT') {
self._stats = Object.assign({}, self._stats, data.stats || {});
self._stats.enabled = self._enabled;
}
// Close the port so the SW side can release its handle; without
// this the channel leaks if the SW is replaced mid-flight.
try { channel.port1.close(); } catch (e) { /* ignore */ }
clearTimeout(timer);
resolve(self.getStats());
};
channel.port1.onmessage = function (event) { done(event.data); };
// 3 s safety net for SW updates mid-flight or unresponsive workers.
var timer = setTimeout(function () { done(null); }, 3000);
try {
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_STATS' }, [channel.port2]);
} catch (e) {
done(null);
}
});
};
/**
* Empty the cache.
* @public
* @return {Promise<void>}
*/
bemap.BrowserCache.prototype.clear = function () {
var self = this;
this._postMessage({ type: 'CACHE_CLEAR' });
this._stats.hits = 0;
this._stats.misses = 0;
this._stats.entries = 0;
this._stats.bytesEstimated = 0;
self._onStats(self.getStats());
return Promise.resolve();
};
/**
* Tear down. Removes the message listener; does NOT unregister the SW
* (other tabs may still be using it).
* @public
*/
bemap.BrowserCache.prototype.destroy = function () {
if (this._messageHandler && navigator.serviceWorker) {
navigator.serviceWorker.removeEventListener('message', this._messageHandler);
this._messageHandler = null;
}
// Resolve any still-pending ack waiters so their timers don't dangle.
if (this._ackWaiters) {
for (var k in this._ackWaiters) {
if (Object.prototype.hasOwnProperty.call(this._ackWaiters, k)) this._resolveAck(k);
}
}
};