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

/**
 * BeNomad BeMap JavaScript API — RecoverablePromiseCache (v2.0)
 *
 * A drop-in replacement for pmtiles' `SharedPromiseCache` that FIXES the
 * permanent-hole bug: the stock cache stores the header/directory *promise* and,
 * when that promise REJECTS (a timeout / network blip / aborted read), leaves the
 * rejected promise cached forever. Every dependent tile then awaits that cached
 * rejection → a blank region until a full page reload. (`invalidate()` only runs
 * on an `EtagMismatch`, never on a timeout/error.)
 *
 * This wrapper evicts a cache entry as soon as its promise rejects, so the NEXT
 * read re-fetches instead of replaying the failure — self-healing without a
 * reload. It is **transparent**: success, in-flight de-duplication, LRU pruning
 * and `invalidate()` are all inherited unchanged (it delegates to a real
 * `SharedPromiseCache`); the ONLY behavioural change is on a *failed* read.
 *
 * Composition (not subclassing) is deliberate: the bundled `pmtiles` is an ES6
 * UMD build whose `SharedPromiseCache` is an ES6 `class`, which cannot be
 * `Function.apply`-ed for the ES5 subclass pattern this codebase uses. Composing
 * a Cache-shaped object that delegates to an internal `SharedPromiseCache` (and
 * deletes its internal entry on reject) is the portable, spec-matching form.
 *
 * Wire it via `new pmtiles.PMTiles(archiveUrl, new bemap.RecoverablePromiseCache())`
 * + `protocol.add(...)` (see `bemap.MapLibreMap` — gated on `opts.recoverableCache`,
 * default on). `bemap.MapLibreMap` does this automatically for the tiles archive.
 *
 * @since 2.0.0
 * @public
 * @constructor
 * @param {Number} [maxCacheEntries] Forwarded to `SharedPromiseCache` (default
 *   its own default, currently 100). Optional — omit for stock sizing.
 * @param {Boolean} [prefetch=true] Forwarded to `SharedPromiseCache`.
 */
bemap.RecoverablePromiseCache = function (maxCacheEntries, prefetch) {
  if (typeof pmtiles === 'undefined' || typeof pmtiles.SharedPromiseCache !== 'function') {
    throw new Error('bemap.RecoverablePromiseCache: pmtiles.SharedPromiseCache is unavailable — load the pmtiles library before constructing this cache.');
  }
  // Delegate to a real SharedPromiseCache (forward sizing knobs when given).
  this._inner = (maxCacheEntries != null)
    ? new pmtiles.SharedPromiseCache(maxCacheEntries, prefetch !== false)
    : new pmtiles.SharedPromiseCache();
};

/**
 * Page-level default for whether `bemap.MapLibreMap` wires this recoverable
 * cache into the pmtiles archive. `true` (self-healing) by default;
 * `bemap.MapLibreMap`'s `opts.recoverableCache: false` sets it to `false`, in
 * which case pmtiles uses its stock (poisoning) `SharedPromiseCache`. Mirrors
 * `bemap.RangeFetchPolicy.defaultMode`.
 * @public
 * @since 2.0.0
 * @type {Boolean}
 */
bemap.RecoverablePromiseCache.enabled = true;

/**
 * Attach a one-shot eviction to `promise`: when it REJECTS, delete `key` from
 * the inner cache's Map — but ONLY if the entry currently at `key` is still the
 * SAME entry we observed (a later success or an LRU prune may have replaced it).
 * That identity guard prevents evicting a good entry that happens to share a key.
 * Never throws; a miss (unknown key / changed pmtiles internals) is a safe no-op
 * that simply leaves stock behaviour in place.
 * @private
 */
function _evictOnReject(inner, key, promise) {
  if (!inner || !inner.cache || typeof inner.cache.get !== 'function' ||
      key == null || !promise || typeof promise.then !== 'function') {
    return promise;
  }
  var entry = inner.cache.get(key);   // the `{ lastUsed, data }` record just set (or a cache hit)
  if (entry) {
    promise.then(null, function () {
      if (inner.cache.get(key) === entry) inner.cache.delete(key);
    });
  }
  return promise;
}

/**
 * @public
 * @param {Object} source pmtiles `Source` (has `getKey()` + `getBytes()`).
 * @return {Promise} the header promise; evicted from cache if it rejects.
 */
bemap.RecoverablePromiseCache.prototype.getHeader = function (source) {
  var inner = this._inner;
  var p = inner.getHeader(source);
  // Header cache key is exactly `source.getKey()`.
  var key = (source && typeof source.getKey === 'function') ? source.getKey() : null;
  return _evictOnReject(inner, key, p);
};

/**
 * @public
 * @return {Promise} the directory promise; evicted from cache if it rejects.
 */
bemap.RecoverablePromiseCache.prototype.getDirectory = function (source, offset, length, header) {
  var inner = this._inner;
  var p = inner.getDirectory(source, offset, length, header);
  // Directory cache key format (pmtiles): `<sourceKey>|<etag>|<offset>|<length>`.
  // If pmtiles ever changes this the key simply won't match → no-op (safe).
  var key = ((source && typeof source.getKey === 'function') ? source.getKey() : '') +
    '|' + ((header && header.etag) || '') + '|' + offset + '|' + length;
  return _evictOnReject(inner, key, p);
};

/** Delegate: invalidate an entry on `EtagMismatch` (inherited behaviour). @public */
bemap.RecoverablePromiseCache.prototype.invalidate = function () {
  return this._inner.invalidate.apply(this._inner, arguments);
};

/** Delegate: LRU prune (inherited behaviour). @public */
bemap.RecoverablePromiseCache.prototype.prune = function () {
  if (typeof this._inner.prune === 'function') return this._inner.prune.apply(this._inner, arguments);
};