Source: bemap-maplibre/bemap-pmtiles-slice-source.js

/**
 * BeNomad BeMap JavaScript API — PMTiles 200-slice Source + RangeGate (v2.0)
 *
 * Two independent, opt-in tile-fetch robustness/perf features, adapted from the
 * proven bemaputnik / mp-tiles-demo reference implementations (validated on the
 * beta Worker by mp-tiles-bench):
 *
 * 1. `bemap.PMTilesSliceSource` — a custom pmtiles `Source` that reads the
 *    archive as **200-slice** requests (`<archive>?r=<start>-<end>`, inclusive
 *    offsets, NO `Range` header) instead of HTTP Range (206). The Worker returns
 *    a plain **HTTP 200** with `Cache-Control: immutable`, which the **browser's
 *    native HTTP cache stores** — so repeat tiles/visits are free with ZERO
 *    Service Worker. (206 partials are not reliably cached by browsers, which is
 *    why the SW exists on the Range path.) Requires a Worker that serves the
 *    `?r=` slice route — BeNomad Tiles does on all envs.
 *
 * 2. `bemap.RangeGate` — an optional per-request resilience wrapper: a
 *    concurrency cap (off by default), a per-attempt TTFB (pre-header) timeout
 *    with SMART-ABORT semantics, and retries with backoff on a transient
 *    failure — NEVER retrying a caller cancel (pan/zoom). SMART ABORT: every
 *    attempt runs on a PRIVATE `AbortController`; the caller's (pan/zoom)
 *    cancel and the TTFB timer may kill the socket ONLY BEFORE the response
 *    headers arrive (nothing transferred yet — cheap). Once headers are in the
 *    socket is never physically aborted — killing a streaming tile body
 *    poisons the browser's shared H2/H3 connection and stalls 15–25 % of
 *    follow-up requests (see GREY-TILES-ROOT-CAUSE.md in the Worker repo) —
 *    the body drains to completion (warming the browser cache), a caller
 *    cancel is honoured LOGICALLY afterwards (reject `AbortError`, discard
 *    the bytes), and a generous body safety cap (`bodyTimeoutMs`) replaces
 *    any body-spanning kill, guarding only a truly dead socket.
 *    Live-toggleable. Policy proven in mp-tiles-demo: 0 permanent grey tiles,
 *    +15 % bandwidth, worst tile ≤ 4 s.
 *
 * Both are wired by `bemap.MapLibreMap` per `opts.tilesSliceMode` (default
 * `'200'`) and the `tilesSlice*` knobs. Auth composes for free: `getBytes` uses
 * `window.fetch`, so `bemap.TilesAuth` injects credentials; and a 200-slice has
 * no `Range` header, so `bemap.RangeFetchPolicy`'s `no-store` correctly does NOT
 * apply — the 200 stays browser-cacheable.
 *
 * @since 2.0.0
 * @public
 */

function _bemapAbortError() {
  var e = new Error('The operation was aborted.');
  e.name = 'AbortError';
  return e;
}

/**
 * Optional resilience gate for tile reads: concurrency cap + per-attempt TTFB
 * timeout with smart-abort semantics + retries with backoff. When inactive
 * (`setActive(false)` or `?nogate`) it is a pure pass-through (raw fetch).
 * Read live per request, so toggling takes effect without a reload.
 *
 * @public
 * @since 2.0.0
 * @constructor
 * @param {Object} [options]
 * @param {Number} [options.concurrency=0] Max in-flight reads (0 = uncapped).
 * @param {Number} [options.timeoutMs=3500] Per-attempt TTFB (pre-header) timeout (0 = none).
 *   Must stay ABOVE the Worker's ~3000 ms tile deadline so its 504 is received
 *   (and retried) instead of the socket being aborted just before it lands.
 *   Once headers arrive this timer is disarmed — see `bodyTimeoutMs`.
 * @param {Number} [options.bodyTimeoutMs=20000] Post-header BODY safety cap (0 = none).
 *   Generous ON PURPOSE: physically aborting a streaming tile body poisons the
 *   browser's shared H2/H3 connection; tile bodies are small and normally finish
 *   in well under a second — this cap only guards a truly dead socket.
 * @param {Number} [options.maxRetries=3] Retries on a transient failure
 *   (429/5xx/timeout/network); never on a caller cancel. 1 → 3 (2026-07 fix):
 *   a single retry left ~1 in 25 tiles permanently grey under pan bursts;
 *   three retries bring the residual to ~0.1 %.
 * @param {Number[]} [options.retryBackoffMs=[200,500,1000]] Backoff (ms) before
 *   retries #1/#2/#3… (the last entry repeats) — gives a strained connection
 *   time to heal before the re-attempt.
 * @param {Boolean} [options.active=true] Whether the gate is engaged.
 */
bemap.RangeGate = function (options) {
  var o = options || {};
  this.concurrency = (typeof o.concurrency === 'number' && o.concurrency > 0) ? o.concurrency : 0;
  this.timeoutMs = (typeof o.timeoutMs === 'number' && o.timeoutMs >= 0) ? o.timeoutMs : 3500;
  this.bodyTimeoutMs = (typeof o.bodyTimeoutMs === 'number' && o.bodyTimeoutMs >= 0) ? o.bodyTimeoutMs : 20000;
  this.maxRetries = (typeof o.maxRetries === 'number' && o.maxRetries >= 0) ? o.maxRetries : 3;
  this.retryBackoffMs = Array.isArray(o.retryBackoffMs) ? o.retryBackoffMs.slice() : [200, 500, 1000];
  this._active = (o.active !== false);
  this._inFlight = 0;
  this._queue = [];
  this._listeners = [];
};

/** @public @return {Boolean} */
bemap.RangeGate.prototype.isActive = function () { return this._active; };

/** Toggle the gate live (no reload). Notifies subscribers. @public */
bemap.RangeGate.prototype.setActive = function (active) {
  var next = !!active;
  if (next === this._active) return;
  this._active = next;
  for (var i = 0; i < this._listeners.length; i++) {
    try { this._listeners[i](next); } catch (e) { /* ignore */ }
  }
};

/** Subscribe to active-state changes. @public @return {Function} unsubscribe */
bemap.RangeGate.prototype.subscribe = function (fn) {
  if (typeof fn !== 'function') return function () {};
  var self = this;
  this._listeners.push(fn);
  return function () {
    var idx = self._listeners.indexOf(fn);
    if (idx !== -1) self._listeners.splice(idx, 1);
  };
};

/** @private */
bemap.RangeGate.prototype._acquire = function () {
  var self = this;
  if (this._inFlight < this.concurrency) { this._inFlight++; return Promise.resolve(); }
  return new Promise(function (resolve) { self._queue.push(resolve); });
};

/** @private */
bemap.RangeGate.prototype._release = function () {
  if (this._inFlight > 0) this._inFlight--;
  if (this._queue.length) { this._inFlight++; (this._queue.shift())(); }
};

/**
 * Run one tile-read attempt through the gate. `attemptFn(signal, headersReceived)`
 * must perform the whole read (fetch + body) and resolve/reject.
 *
 * SMART ABORT: `signal` is the gate's PRIVATE per-attempt `AbortController`
 * signal — the TTFB timer and a caller (pan/zoom) cancel abort it ONLY until
 * the attempt calls `headersReceived()` (do so the moment the response headers
 * are in). From that point the socket is protected: the body drains to
 * completion under the generous `bodyTimeoutMs` safety cap, and a caller
 * cancel is honoured LOGICALLY once the attempt settles (reject `AbortError`,
 * discard the result — bytes already warmed the browser cache). An `attemptFn`
 * that never calls `headersReceived` keeps the legacy whole-attempt timeout.
 *
 * Retries (up to `maxRetries`, spaced by `retryBackoffMs`) apply to transient
 * failures only — 429/5xx, a network `TypeError`, a timeout `AbortError` —
 * and NEVER to a caller cancel. Each attempt takes its own concurrency slot
 * (a retry re-queues at the back instead of squatting a permit across its
 * backoff sleep).
 * @public
 * @param {Function} attemptFn `function(signal, headersReceived) => Promise`
 * @param {AbortSignal} [callerSignal] the pmtiles cancellation signal
 * @return {Promise}
 */
bemap.RangeGate.prototype.run = function (attemptFn, callerSignal) {
  var self = this;
  // Inactive gate → raw pass-through (no cap, no timeout, no retry).
  if (!this._active) return Promise.resolve().then(function () { return attemptFn(callerSignal); });

  var capped = this.concurrency > 0;

  function attemptOnce() {
    var acquire = capped ? self._acquire() : Promise.resolve();
    return acquire.then(function () {
      return Promise.resolve().then(function () {
        if (callerSignal && callerSignal.aborted) throw _bemapAbortError();
        // OUR socket handle — never the caller's signal directly, so a caller
        // cancel can be downgraded to a logical one once headers arrive.
        var ctrl = (typeof AbortController === 'function') ? new AbortController() : null;
        var headersIn = false;
        var bodyTimer = null;
        // TTFB timer: may abort the socket ONLY before headers.
        var ttfbTimer = (ctrl && self.timeoutMs > 0)
          ? setTimeout(function () { if (!headersIn) { try { ctrl.abort(); } catch (e) {} } }, self.timeoutMs)
          : null;
        // Caller cancel: physical only before headers; logical afterwards.
        var onCallerAbort = function () { if (!headersIn && ctrl) { try { ctrl.abort(); } catch (e) {} } };
        if (ctrl && callerSignal) { try { callerSignal.addEventListener('abort', onCallerAbort); } catch (e) {} }
        // Called by attemptFn the moment response headers arrive: disarm the
        // TTFB timer and protect the socket (physically aborting a streaming
        // body poisons the browser's shared H2/H3 connection); the generous
        // body cap only guards a truly dead socket.
        var settled = false;
        var headersReceived = function () {
          if (headersIn || settled) return;
          headersIn = true;
          if (ttfbTimer) { clearTimeout(ttfbTimer); ttfbTimer = null; }
          if (ctrl && self.bodyTimeoutMs > 0) {
            bodyTimer = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, self.bodyTimeoutMs);
          }
        };
        var cleanup = function () {
          settled = true;
          if (ttfbTimer) clearTimeout(ttfbTimer);
          if (bodyTimer) clearTimeout(bodyTimer);
          if (ctrl && callerSignal) { try { callerSignal.removeEventListener('abort', onCallerAbort); } catch (e) {} }
        };
        return Promise.resolve()
          .then(function () { return attemptFn(ctrl ? ctrl.signal : callerSignal, headersReceived); })
          .then(function (r) {
            cleanup();
            // LOGICAL cancel: the read completed cleanly (browser cache warmed,
            // connection unharmed) but the caller no longer wants it — discard.
            if (callerSignal && callerSignal.aborted) throw _bemapAbortError();
            return r;
          }, function (err) { cleanup(); throw err; });
      }).then(
        function (r) { if (capped) self._release(); return r; },
        function (e) { if (capped) self._release(); throw e; }
      );
    });
  }

  var attempt = 0;
  function tryOnce() {
    return attemptOnce().then(null, function (err) {
      // A caller cancel (pan/zoom) must NEVER be retried.
      if (callerSignal && callerSignal.aborted) throw err;
      // Retry only genuinely TRANSIENT failures — and NOT a status-less
      // programming/logic error (which would otherwise be retried and its
      // real cause masked, since `undefined == null`):
      //   - HTTP 429 / 5xx (has err.status),
      //   - a network failure (fetch rejects with a TypeError),
      //   - a per-attempt TIMEOUT (our AbortController → AbortError; the
      //     caller-cancel case was already excluded above).
      var status = err && err.status;
      var name = err && err.name;
      var retryable = (status === 429) || (status >= 500) ||
        (status == null && (name === 'TypeError' || name === 'AbortError'));
      if (attempt >= self.maxRetries || !retryable) throw err;
      var backoff = self.retryBackoffMs;
      var ms = (backoff && backoff.length) ? backoff[Math.min(attempt, backoff.length - 1)] : 0;
      attempt++;
      if (!(ms > 0)) return tryOnce();
      return new Promise(function (res) { setTimeout(res, ms); }).then(tryOnce);
    });
  }
  return tryOnce();
};

/**
 * A pmtiles `Source` that reads the archive via **200-slice** (`?r=start-end`)
 * so responses are browser-HTTP-cacheable 200s (not 206 partials).
 *
 * @public
 * @since 2.0.0
 * @constructor
 * @param {String} url the archive URL (no `pmtiles://` prefix), e.g. `https://host/default`.
 * @param {Object} [options]
 * @param {bemap.RangeGate} [options.gate] resilience gate (defaults to the shared `bemap.PMTilesSliceSource.defaultGate`).
 * @param {Boolean} [options.versioned=false] the archive URL already carries a `?v=` token (EVMOVE-457): its cache entry is already rebuild-distinct, so the offset-0 header read stays plain-immutable. An UNVERSIONED archive revalidates its header so a rebuild is still noticed on a Worker that ships no token.
 */
bemap.PMTilesSliceSource = function (url, options) {
  this.url = url;
  var o = options || {};
  this._gate = o.gate || bemap.PMTilesSliceSource.defaultGate || null;
  // EVMOVE-457: a versioned (?v=) archive URL is already rebuild-distinct, so its
  // offset-0 header read stays plain-immutable; an UNVERSIONED archive revalidates
  // the header each load so a rebuild is noticed even on a token-less Worker.
  this.versioned = !!o.versioned;
  // EVMOVE-457: sticky flag — flipped on a detected ETag mismatch (an archive
  // rebuilt under the same name) so every subsequent read forces fresh bytes.
  // NOT auto-reset (mirrors stock pmtiles): pmtiles' getZxy recovery runs ONE
  // getZxyAttempt whose header→directory→tile reads must ALL stay on fresh bytes
  // (its cache.invalidate() drops only the header key, so clearing mid-recovery
  // would re-read stale directory/tile slices and permanently fail the tile). A
  // rebuilt archive re-registers under a fresh ?v= URL on the next load, so a
  // NEW source starts cacheable again — the reload only pins the OLD instance.
  // A STABLE archive never mismatches, so caching is unchanged (retrocompatible).
  this.mustReload = false;
};

/** pmtiles cache key — the archive URL, matching the Protocol's stripped `pmtiles://` key. @public */
bemap.PMTilesSliceSource.prototype.getKey = function () { return this.url; };

/**
 * Read `[offset, offset+length)` as a 200-slice. Builds `?r=<offset>-<offset+length-1>`
 * (inclusive), sends NO `Range` header, validates the response is 2xx with the
 * exact byte length, and returns the pmtiles `RangeResponse` shape.
 * @public
 * @param {Number} offset
 * @param {Number} length
 * @param {AbortSignal} [signal] pmtiles cancellation signal
 * @param {String} [etag] the archive's expected ETag (from the pmtiles header);
 *   two STRONG, DIFFERENT etags throw `pmtiles.EtagMismatch` so the reader
 *   invalidates + re-reads fresh (EVMOVE-457). Weak/missing ⇒ no comparison.
 * @return {Promise<{data:ArrayBuffer, etag:(String|undefined), cacheControl:(String|undefined), expires:(String|undefined)}>}
 */
bemap.PMTilesSliceSource.prototype.getBytes = function (offset, length, signal, etag) {
  var self = this;
  var url = this.url;
  var sep = (url.indexOf('?') === -1) ? '?' : '&';
  var sliceUrl = url + sep + 'r=' + offset + '-' + (offset + length - 1);
  // A WEAK expected validator (W/"…") can't be compared byte-for-byte → treat
  // as absent, exactly like stock pmtiles (avoids a false mismatch → reload loop).
  var expected = etag || null;
  if (expected && expected.indexOf('W/') === 0) expected = null;

  function attempt(sig, headersReceived) {
    // Cache mode, per attempt (a RangeGate retry / a mustReload flip is honoured).
    // Precedence: a detected rebuild (mustReload) forces fresh bytes and OUTRANKS
    // all; else an UNVERSIONED archive REVALIDATES its header (offset 0) so a
    // rebuild-under-the-same-name is noticed even when the Worker ships no token;
    // a versioned (?v=) archive — and every data slice at any offset — stays
    // plain-immutable (byte-identical to earlier releases: served from HTTP cache).
    var mode = null;
    if (self.mustReload) mode = 'reload';
    else if (offset === 0 && !self.versioned) mode = 'no-cache';

    function doFetch(m) {
      // window.fetch → bemap.TilesAuth injects auth (credentials/header/token).
      // No Range header → RangeFetchPolicy's no-store does NOT apply → 200 stays cacheable.
      var init = sig ? { signal: sig } : {};
      if (m) init.cache = m;
      return fetch(sliceUrl, init);
    }
    // A 'no-cache' revalidation needs the network. If it FAILS for any reason
    // OTHER than a genuine caller cancel (a gate TTFB timeout, offline, a blip),
    // fall back to the browser's cached header so a stable archive still loads —
    // preserving 2.0.0's immutable-header behaviour on slow / degraded networks.
    // The force-cache retry uses a PLAIN fetch (no gate signal, which the TTFB
    // timer may already have aborted); only a real pan/zoom cancel propagates.
    // Only the tiny header slice ever revalidates; data slices stay immutable.
    var p = doFetch(mode);
    if (mode === 'no-cache') {
      p = p.then(null, function (err) {
        if (signal && signal.aborted) throw err;             // genuine caller cancel → propagate
        return fetch(sliceUrl, { cache: 'force-cache' });    // else serve the cached header
      });
    }
    return p.then(function (res) {
      // Headers are in — tell the gate to protect the socket (smart abort):
      // from here the body always drains; only the generous body cap remains.
      if (typeof headersReceived === 'function') headersReceived();
      if (!res.ok) {
        // Drain the (tiny) error body before rejecting — an unread body keeps
        // the connection slot busy; the rejection carries the real status so
        // the gate can decide retryability (429/5xx yes, 4xx no).
        var fail = function () {
          var e = new Error('PMTilesSliceSource: HTTP ' + res.status + ' for ' + sliceUrl);
          e.status = res.status;
          throw e;
        };
        return res.arrayBuffer().then(fail, fail);
      }
      return res.arrayBuffer().then(function (buf) {
        if (buf.byteLength !== length) {
          var e2 = new Error('PMTilesSliceSource: slice length mismatch (got ' + buf.byteLength + ', want ' + length + ') for ' + sliceUrl);
          e2.status = res.status;
          throw e2;
        }
        // Normalise the response ETag — a weak validator counts as absent.
        var respTag = res.headers.get('etag') || null;
        if (respTag && respTag.indexOf('W/') === 0) respTag = null;
        // Two STRONG, DIFFERENT etags ⇒ the archive was rebuilt under the same
        // name (EVMOVE-457): flip mustReload (sticky) and throw the REAL
        // pmtiles.EtagMismatch so PMTiles.getZxy invalidates the header and
        // re-reads fresh. No expected etag, or a missing/weak response etag ⇒
        // cannot compare ⇒ never throw (no reload loop against an etag-less
        // server). A guarded plain Error keeps this safe if pmtiles is absent.
        if (expected && respTag && expected !== respTag) {
          self.mustReload = true;
          if (typeof pmtiles !== 'undefined' && typeof pmtiles.EtagMismatch === 'function') {
            throw new pmtiles.EtagMismatch('PMTilesSliceSource: etag changed (' + expected + ' → ' + respTag + ') for ' + sliceUrl);
          }
          var em = new Error('PMTilesSliceSource: etag changed (' + expected + ' → ' + respTag + ') for ' + sliceUrl);
          em.name = 'EtagMismatch';
          throw em;
        }
        return {
          data: buf,
          // Return the weak-STRIPPED etag (stock parity): a weak header etag
          // kept as the "expected" value would false-mismatch every strong
          // slice etag forever.
          etag: respTag || undefined,
          cacheControl: res.headers.get('cache-control') || undefined,
          expires: res.headers.get('expires') || undefined
        };
      });
    });
  }

  var gate = this._gate;
  if (gate && typeof gate.run === 'function') return gate.run(attempt, signal);
  return attempt(signal);
};

/**
 * Shared default gate for slice sources — timeout+retry ON, concurrency cap OFF
 * (the tile origin is fast, so uncapped is smoother; the cap is an opt-in
 * tradeoff). `bemap.MapLibreMap` tunes it from `opts.tilesSlice*`. Honors a
 * `?nogate` URL kill-switch.
 * @public
 * @since 2.0.0
 */
bemap.PMTilesSliceSource.defaultGate = new bemap.RangeGate({
  active: !(typeof location !== 'undefined' && location.search && /[?&]nogate\b/.test(location.search))
});