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