mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
TOMTOM_DAILY_TILE_BUDGET defaulted to 40,000 per day against a free tier that is granted per MONTH, not per day. TomTom currently allows 200,000 Traffic Flow & Incidents tile requests per month (https://docs.tomtom.com/pricing/), so the default exhausted a user's entire monthly allowance in five days and left the traffic layer dead for the rest of the billing period. The docs contradicted themselves on this. traffic.js described the free tier as "~50k/day", while .env.example cited the correct 200K per month two lines below quoting the same 40,000 default. 50k/day would be 1.5M/month, 7.5x the actual allowance. Sets the default to 6,000/day. A 31-day month lands at 186,000, inside the allowance with headroom. Corrects the stale figures in traffic.js, .env.example and DATA_SOURCES.md. This remains an application-side safety ceiling, not a billing cap, and the surrounding text still says so. TomTom's free tier requires no card, so overrunning it cuts the layer off rather than generating a charge — but a layer that dies three weeks into every month is still worth avoiding by default. Co-Authored-By: Claude Opus 5 <[email protected]>
275 lines
9.2 KiB
JavaScript
275 lines
9.2 KiB
JavaScript
import path from 'node:path';
|
|
import { promises as fsp } from 'node:fs';
|
|
|
|
import {
|
|
isValidTileCoord as isValidTomTomTile,
|
|
utcDayKey as tomtomUtcDayKey,
|
|
normalizeBudget as normalizeTomTomBudget,
|
|
isOverBudget as isTomTomOverBudget,
|
|
} from '../../src/data/tomtomTiles.js';
|
|
|
|
/**
|
|
* TomTom traffic-flow vector-tile proxy with a daily budget governor.
|
|
*
|
|
* Upstream: https://api.tomtom.com/traffic/map/4/tile/flow/relative/{z}/{x}/{y}.pbf
|
|
* (style `relative`; the response is an UNCOMPRESSED Mapbox Vector Tile, layer
|
|
* "Traffic flow"). The key comes from TOMTOM_API_KEY server-side only — the
|
|
* browser fetches same-origin `/api/tomtom/flow/{z}/{x}/{y}.pbf`.
|
|
*
|
|
* Cache: memory + disk (.gev-cache/tomtom/), TTL 120 s (traffic is fresh
|
|
* data), single-flight per tile, serve-stale-on-failure — the celestrakProxy
|
|
* pattern. Cache hits never count against the budget.
|
|
*
|
|
* Budget governor (mirrors the OpenSky credit-governor philosophy — last-good
|
|
* data beats a dead layer): a persistent counter (.gev-cache/tomtom/budget.json,
|
|
* keyed by UTC date, reset on day change) counts upstream fetch attempts
|
|
* against a soft cap (TOMTOM_DAILY_TILE_BUDGET). Over the cap the proxy
|
|
* serves stale tiles when available, else 429 {error:'budget'}.
|
|
*
|
|
* The default is derived from TomTom's published free allowance, which is
|
|
* granted MONTHLY, not daily: 200,000 Traffic Flow & Incidents tile requests
|
|
* per month (https://docs.tomtom.com/pricing/). The previous 40,000/day
|
|
* default — and the "~50k/day" figure this comment used to cite — exhausted
|
|
* a month's entire allowance in five days, leaving the traffic layer dead
|
|
* for the rest of the billing period. 6,000/day keeps a full 31-day month
|
|
* inside the allowance (186,000) with headroom to spare.
|
|
*
|
|
* GET /api/tomtom/status → {hasKey, dailyCount, budget, date}. Keyless mode:
|
|
* status reports hasKey:false and the tile endpoint 503s {error:'no_key'}
|
|
* without touching upstream — the traffic layer then stays in simulation mode.
|
|
*
|
|
* @returns {import('vite').Plugin}
|
|
*/
|
|
export function tomtomProxy() {
|
|
const TILE_TTL_MS = 120_000;
|
|
const CACHE_DIR = path.join(process.cwd(), '.gev-cache', 'tomtom');
|
|
const BUDGET_PATH = path.join(CACHE_DIR, 'budget.json');
|
|
// 200,000/month free tier / 31 days = 6,451. Rounded down for headroom.
|
|
const DEFAULT_DAILY_BUDGET = 6000;
|
|
const MEM_MAX_ENTRIES = 256;
|
|
const UPSTREAM_TIMEOUT_MS = 15000;
|
|
|
|
/** @type {Map<string, {at:number, buf:Buffer}>} tile key `z/x/y` -> cached tile (kept past TTL for serve-stale). */
|
|
const mem = new Map();
|
|
/** @type {Map<string, Promise<{at:number, buf:Buffer}|null>>} single-flight per tile. */
|
|
const inflight = new Map();
|
|
|
|
/** @type {{date:string, count:number}|null} lazily-loaded persistent counter. */
|
|
let budget = null;
|
|
let budgetLoaded = false;
|
|
|
|
function dailyBudgetLimit() {
|
|
const raw = Number.parseInt(process.env.TOMTOM_DAILY_TILE_BUDGET || '', 10);
|
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DAILY_BUDGET;
|
|
}
|
|
|
|
async function loadBudgetOnce() {
|
|
if (budgetLoaded) return;
|
|
budgetLoaded = true;
|
|
try {
|
|
const parsed = JSON.parse(await fsp.readFile(BUDGET_PATH, 'utf8'));
|
|
if (
|
|
parsed &&
|
|
typeof parsed.date === 'string' &&
|
|
Number.isFinite(parsed.count)
|
|
) {
|
|
budget = parsed;
|
|
}
|
|
} catch {
|
|
/* no budget file yet */
|
|
}
|
|
}
|
|
|
|
async function persistBudget() {
|
|
try {
|
|
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
|
await fsp.writeFile(BUDGET_PATH, JSON.stringify(budget), 'utf8');
|
|
} catch (err) {
|
|
console.warn('[tomtom-proxy] budget write failed:', err?.message || err);
|
|
}
|
|
}
|
|
|
|
/** Roll the counter to today (UTC) and return it. */
|
|
function currentBudget() {
|
|
budget = normalizeTomTomBudget(budget, tomtomUtcDayKey());
|
|
return budget;
|
|
}
|
|
|
|
/** Count one upstream fetch attempt against today's budget (async persist). */
|
|
function recordUpstreamFetch() {
|
|
currentBudget().count += 1;
|
|
void persistBudget();
|
|
}
|
|
|
|
const tilePath = (key) =>
|
|
path.join(CACHE_DIR, `flow-${key.replaceAll('/', '-')}.pbf`);
|
|
|
|
/** Disk-cache read; tile age comes from the file's mtime. */
|
|
async function readDiskTile(key) {
|
|
try {
|
|
const [stat, buf] = await Promise.all([
|
|
fsp.stat(tilePath(key)),
|
|
fsp.readFile(tilePath(key)),
|
|
]);
|
|
return { at: stat.mtimeMs, buf };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writeDiskTile(key, buf) {
|
|
try {
|
|
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
|
await fsp.writeFile(tilePath(key), buf);
|
|
} catch (err) {
|
|
console.warn(
|
|
`[tomtom-proxy] tile cache write failed for ${key}:`,
|
|
err?.message || err,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** LRU-ish memory insert (Map preserves insertion order; evict the oldest). */
|
|
function memSet(key, entry) {
|
|
if (!mem.has(key) && mem.size >= MEM_MAX_ENTRIES) {
|
|
const oldest = mem.keys().next().value;
|
|
mem.delete(oldest);
|
|
}
|
|
mem.set(key, entry);
|
|
}
|
|
|
|
async function fetchUpstream(z, x, y) {
|
|
const url =
|
|
'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' +
|
|
`${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`;
|
|
recordUpstreamFetch(); // attempts count — upstream bills the request either way
|
|
const res = await fetch(url, {
|
|
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
if (buf.length === 0) throw new Error('empty tile body');
|
|
return buf;
|
|
}
|
|
|
|
const installMiddleware = (server) => {
|
|
server.middlewares.use('/api/tomtom', async (req, res) => {
|
|
// Sanitized responses only (proxy/security baseline): no upstream
|
|
// error details, and never echo the key or the upstream URL.
|
|
const sendJson = (status, obj, extraHeaders = {}) => {
|
|
if (res.headersSent) return;
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
...extraHeaders,
|
|
});
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
const sendTile = (buf, cacheStatus) => {
|
|
if (res.headersSent) return;
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/x-protobuf',
|
|
'Cache-Control': 'no-store',
|
|
'x-tomtom-cache': cacheStatus,
|
|
});
|
|
res.end(buf);
|
|
};
|
|
|
|
try {
|
|
await loadBudgetOnce();
|
|
const urlPath = String(req.url || '').split('?')[0];
|
|
|
|
if (urlPath === '/status') {
|
|
const hasKey = Boolean(process.env.TOMTOM_API_KEY);
|
|
const b = currentBudget();
|
|
sendJson(200, {
|
|
hasKey,
|
|
dailyCount: b.count,
|
|
budget: dailyBudgetLimit(),
|
|
date: b.date,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const m = urlPath.match(/^\/flow\/(\d+)\/(\d+)\/(\d+)\.pbf$/);
|
|
if (!m) {
|
|
sendJson(404, { error: 'not_found' });
|
|
return;
|
|
}
|
|
const z = Number(m[1]);
|
|
const x = Number(m[2]);
|
|
const y = Number(m[3]);
|
|
if (!isValidTomTomTile(z, x, y)) {
|
|
sendJson(400, { error: 'invalid_tile' });
|
|
return;
|
|
}
|
|
if (!process.env.TOMTOM_API_KEY) {
|
|
sendJson(503, { error: 'no_key' });
|
|
return;
|
|
}
|
|
|
|
const key = `${z}/${x}/${y}`;
|
|
const now = Date.now();
|
|
|
|
let entry = mem.get(key);
|
|
if (!entry) {
|
|
entry = await readDiskTile(key);
|
|
if (entry) memSet(key, entry);
|
|
}
|
|
// Fresh cache hit — never counts against the budget.
|
|
if (entry && now - entry.at < TILE_TTL_MS) {
|
|
sendTile(entry.buf, 'HIT');
|
|
return;
|
|
}
|
|
|
|
// Budget governor: over the soft cap, last-good data beats a dead layer.
|
|
if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) {
|
|
if (entry) {
|
|
sendTile(entry.buf, 'STALE-BUDGET');
|
|
} else {
|
|
sendJson(429, { error: 'budget' });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Stale or missing → refresh, single-flight per tile.
|
|
if (!inflight.has(key)) {
|
|
inflight.set(
|
|
key,
|
|
fetchUpstream(z, x, y)
|
|
.then(async (buf) => {
|
|
const fresh = { at: Date.now(), buf };
|
|
memSet(key, fresh);
|
|
await writeDiskTile(key, buf);
|
|
return fresh;
|
|
})
|
|
.catch((err) => {
|
|
console.warn(
|
|
`[tomtom-proxy] ${key} fetch failed (${err?.message || err}) — serving stale if any`,
|
|
);
|
|
return null;
|
|
})
|
|
.finally(() => inflight.delete(key)),
|
|
);
|
|
}
|
|
const fresh = await inflight.get(key);
|
|
if (fresh) {
|
|
sendTile(fresh.buf, 'MISS');
|
|
} else if (entry) {
|
|
sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty
|
|
} else {
|
|
sendJson(502, { error: 'upstream' });
|
|
}
|
|
} catch (err) {
|
|
console.warn('[tomtom-proxy] error:', err?.message || err);
|
|
sendJson(500, { error: 'proxy' });
|
|
}
|
|
});
|
|
};
|
|
return {
|
|
name: 'tomtom-proxy',
|
|
configureServer: installMiddleware,
|
|
configurePreviewServer: installMiddleware,
|
|
};
|
|
}
|