mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Knip, configured with the application, package exports, scripts, QA harnesses, workers, tools and tests as entry points, reports exports that no module imports. Each was checked by hand against string-based and namespace imports. - Symbols that are still used inside their own module lose only the `export` keyword (176 constants and helpers). - Unused re-exports are removed from barrel modules (flight, military, vessel, CCTV, directions, transit history, local ADS-B and street-level policy modules). - Declarations that nothing uses at all are deleted, among them AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText, DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band constants. - Default exports that nothing imports are removed. The ones in src/data/earthquakes.js, src/data/telegeographySubmarineCables.js, src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a layer instance at import time that nothing used; those instances are no longer created. Every export of a package.json entry stays, as does TOMTOM_CREDIT, which the traffic layer reads from the credits namespace it is given.
266 lines
9.3 KiB
JavaScript
266 lines
9.3 KiB
JavaScript
import { readResponseBytesCapped } from '../common/http.js';
|
||
import { stripTileLayers } from './trim.js';
|
||
|
||
/** Mapillary vector tiles, /{layer}/2/{z}/{x}/{y}: the only host the token goes to. */
|
||
const TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
|
||
/** Only the street zooms the app draws are proxied. */
|
||
const MIN_ZOOM = 11;
|
||
const MAX_ZOOM = 14;
|
||
/**
|
||
* The z14 `image` layer is ~98% of a tile (12 MB over a dense city) and unused
|
||
* (positions come from the Graph API), so it is dropped in transit.
|
||
*/
|
||
const DROP_LAYERS = ['image'];
|
||
/** A z14 tile with its image layer is ~12 MB; anything past this is wrong. */
|
||
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
|
||
const FETCH_TIMEOUT_MS = 60_000;
|
||
/** Upstream fetches at once; more wait their turn. */
|
||
export const TILE_UPSTREAM_CONCURRENCY = 6;
|
||
/** Tiles change only when new imagery is processed. */
|
||
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
|
||
/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */
|
||
export const TILE_MEMORY_MAX_TILES = 120;
|
||
/** A trimmed tile is ~80 KB; a body this big failed to trim and is not kept. */
|
||
export const TILE_MEMORY_MAX_TILE_BYTES = 4 * 1024 * 1024;
|
||
/** The longest upstream Retry-After honoured, in seconds. */
|
||
export const MAX_RETRY_AFTER_SEC = 600;
|
||
|
||
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
||
export function mapillaryToken() {
|
||
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
|
||
}
|
||
|
||
/** Mapillary answered with an error status (or a size this proxy refuses). */
|
||
export class TileUpstreamError extends Error {
|
||
constructor(status, message, retryAfter = null) {
|
||
super(message || `Mapillary tiles HTTP ${status}`);
|
||
this.name = 'TileUpstreamError';
|
||
this.status = status;
|
||
/** The upstream Retry-After header, as sent. */
|
||
this.retryAfter = retryAfter;
|
||
}
|
||
}
|
||
|
||
/** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */
|
||
function retryAfterSeconds(header, now = Date.now()) {
|
||
const text = String(header ?? '').trim();
|
||
if (!text) return null;
|
||
const seconds = /^\d+$/.test(text)
|
||
? Number(text)
|
||
: (Date.parse(text) - now) / 1000;
|
||
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : null;
|
||
}
|
||
|
||
/** The wait a 429 asks for, bounded; a missing or malformed header means a minute. */
|
||
export function retryAfterWait(header, now = Date.now()) {
|
||
return Math.min(retryAfterSeconds(header, now) ?? 60, MAX_RETRY_AFTER_SEC);
|
||
}
|
||
|
||
/**
|
||
* While Mapillary rate-limits, tile misses are refused here until the wait is
|
||
* over, so the rest of a view (and the fetches queued behind the first 429)
|
||
* do not go on asking. Scoped to the token that was refused.
|
||
* @type {{until: number, token: string}|null}
|
||
*/
|
||
let refusal = null;
|
||
|
||
/** The refusal still in force for `token`, as the error to answer with. */
|
||
function refusedNow(token) {
|
||
if (!refusal) return null;
|
||
const left = refusal.until - Date.now();
|
||
if (left <= 0 || refusal.token !== token) {
|
||
refusal = null;
|
||
return null;
|
||
}
|
||
return new TileUpstreamError(429, undefined, String(Math.ceil(left / 1000)));
|
||
}
|
||
|
||
/**
|
||
* Parse `/coverage/{z}/{x}/{y}`; null unless z is 11–14 and x/y are on the grid.
|
||
* @returns {{z: number, x: number, y: number, key: string}|null}
|
||
*/
|
||
export function parseTilePath(pathname) {
|
||
const match = /^\/coverage\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(pathname);
|
||
if (!match) return null;
|
||
const [z, x, y] = match.slice(1).map(Number);
|
||
if (z < MIN_ZOOM || z > MAX_ZOOM || x >= 2 ** z || y >= 2 ** z) return null;
|
||
return { z, x, y, key: `${z}/${x}/${y}` };
|
||
}
|
||
|
||
function trim(bytes) {
|
||
try {
|
||
return stripTileLayers(bytes, DROP_LAYERS);
|
||
} catch (error) {
|
||
console.warn('[Mapillary Proxy] tile trim failed:', error?.message);
|
||
return bytes;
|
||
}
|
||
}
|
||
|
||
/** One tile from Mapillary, trimmed; empty bytes mean no coverage. */
|
||
async function fetchUpstream({ z, x, y }, signal) {
|
||
const token = mapillaryToken();
|
||
const refused = refusedNow(token);
|
||
if (refused) throw refused;
|
||
const response = await fetch(
|
||
`${TILE_HOST}/mly1_public/2/${z}/${x}/${y}?access_token=${encodeURIComponent(token)}`,
|
||
{
|
||
signal: AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]),
|
||
// The token is in the query: a redirect is never followed (a 3xx is a 502).
|
||
redirect: 'manual',
|
||
headers: { Accept: 'application/x-protobuf' },
|
||
},
|
||
);
|
||
if (!response.ok || response.status === 204) {
|
||
await response.body?.cancel().catch(() => {});
|
||
if (response.status === 204 || response.status === 404)
|
||
return Buffer.alloc(0);
|
||
const retryAfter = response.headers.get('retry-after');
|
||
if (response.status === 429)
|
||
refusal = {
|
||
token,
|
||
until: Date.now() + retryAfterWait(retryAfter) * 1000,
|
||
};
|
||
throw new TileUpstreamError(response.status, undefined, retryAfter);
|
||
}
|
||
try {
|
||
const bytes = await readResponseBytesCapped(response, TILE_MAX_BYTES);
|
||
return trim(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length));
|
||
} catch (error) {
|
||
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
|
||
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
|
||
}
|
||
}
|
||
|
||
let upstreamActive = 0;
|
||
/** @type {Array<() => void>} fetches waiting for a slot, oldest first */
|
||
const upstreamWaiting = [];
|
||
|
||
/**
|
||
* Run `task` in an upstream slot, waiting for one unless `signal` aborts
|
||
* first. A freed slot passes straight to the oldest waiter, still counted, so
|
||
* a request arriving meanwhile cannot take it first.
|
||
*/
|
||
async function withUpstreamSlot(signal, task) {
|
||
if (upstreamActive < TILE_UPSTREAM_CONCURRENCY) upstreamActive++;
|
||
else
|
||
await new Promise((resolve, reject) => {
|
||
const wake = () => {
|
||
signal.removeEventListener('abort', leave);
|
||
resolve();
|
||
};
|
||
const leave = () => {
|
||
const at = upstreamWaiting.indexOf(wake);
|
||
if (at !== -1) upstreamWaiting.splice(at, 1);
|
||
reject(signal.reason);
|
||
};
|
||
upstreamWaiting.push(wake);
|
||
signal.addEventListener('abort', leave, { once: true });
|
||
});
|
||
try {
|
||
// Handed a slot after leaving: it goes on to the next waiter.
|
||
signal.throwIfAborted();
|
||
return await task();
|
||
} finally {
|
||
const next = upstreamWaiting.shift();
|
||
if (next) next();
|
||
else upstreamActive--;
|
||
}
|
||
}
|
||
|
||
/** `promise`, or a rejection as soon as `signal` aborts. */
|
||
function unlessAborted(promise, signal) {
|
||
if (!signal) return promise;
|
||
signal.throwIfAborted();
|
||
return new Promise((resolve, reject) => {
|
||
const leave = () => reject(signal.reason);
|
||
signal.addEventListener('abort', leave, { once: true });
|
||
promise
|
||
.then(resolve, reject)
|
||
.finally(() => signal.removeEventListener('abort', leave));
|
||
});
|
||
}
|
||
|
||
/** @type {Map<string, {bytes: Buffer, at: number}>} least recently used first */
|
||
const memory = new Map();
|
||
/**
|
||
* Upstream fetches shared per tile, with the requests still waiting on each.
|
||
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
|
||
*/
|
||
const inFlight = new Map();
|
||
|
||
function remember(key, bytes, at = Date.now()) {
|
||
if (bytes.length > TILE_MEMORY_MAX_TILE_BYTES) return;
|
||
memory.delete(key);
|
||
memory.set(key, { bytes, at });
|
||
for (const oldest of memory.keys()) {
|
||
if (memory.size <= TILE_MEMORY_MAX_TILES) break;
|
||
memory.delete(oldest);
|
||
}
|
||
}
|
||
|
||
function recall(key) {
|
||
const hit = memory.get(key);
|
||
if (!hit) return null;
|
||
if (Date.now() - hit.at > TILE_TTL_MS) {
|
||
memory.delete(key);
|
||
return null;
|
||
}
|
||
remember(key, hit.bytes, hit.at); // most recently used again
|
||
return hit.bytes;
|
||
}
|
||
|
||
/** Start the upstream fetch for a tile, shared until its last request leaves. */
|
||
function startFlight(address) {
|
||
const controller = new AbortController();
|
||
const flight = { controller, waiters: 0 };
|
||
flight.promise = withUpstreamSlot(controller.signal, () =>
|
||
fetchUpstream(address, controller.signal),
|
||
)
|
||
.then((bytes) => {
|
||
remember(address.key, bytes);
|
||
return bytes;
|
||
})
|
||
.finally(() => {
|
||
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
|
||
});
|
||
// Every request may have left; the rejection is then nobody's.
|
||
flight.promise.catch(() => {});
|
||
inFlight.set(address.key, flight);
|
||
return flight;
|
||
}
|
||
|
||
/**
|
||
* One tile from memory, a shared in-flight fetch, or Mapillary. `signal`
|
||
* aborts when the request's client leaves; the fetch stops once all have.
|
||
* @param {{z: number, x: number, y: number, key: string}} address from parseTilePath
|
||
* @param {{signal?: AbortSignal}} [options]
|
||
* @returns {Promise<{bytes: Buffer, source: 'memory'|'inflight'|'upstream'}>}
|
||
*/
|
||
export async function fetchTile(address, { signal } = {}) {
|
||
const cached = recall(address.key);
|
||
if (cached) return { bytes: cached, source: 'memory' };
|
||
let flight = inFlight.get(address.key);
|
||
const shared = Boolean(flight);
|
||
flight ||= startFlight(address);
|
||
flight.waiters++;
|
||
try {
|
||
const bytes = await unlessAborted(flight.promise, signal);
|
||
return { bytes, source: shared ? 'inflight' : 'upstream' };
|
||
} finally {
|
||
if (--flight.waiters === 0) {
|
||
// A later request for the tile must not join a cancelled fetch.
|
||
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
|
||
flight.controller.abort();
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Test seam: forget every cached and in-flight tile. */
|
||
export function _resetTileCacheForTest() {
|
||
memory.clear();
|
||
inFlight.clear();
|
||
refusal = null;
|
||
upstreamActive = 0;
|
||
upstreamWaiting.length = 0;
|
||
}
|