Source: bemap-maplibre/bemap-browser-cache.js

/**
 * 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);
    }
  }
};