mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
fix(providers): bound the remaining upstream body reads
Route the six proxied fetches that still buffered upstream bodies without a limit through the shared capped readers in src/sources/httpBody.js: FIRMS CSV, CelesTrak TLE groups, adsb.lol /v2/mil (both the OK and relayed-status paths), TomTom flow tiles, OpenSky states/all and adsbdb enrichment lookups. Each cap is a bound sized well above the measured payload, not a budget. readResponseBytesCapped gains the same optional abort signal as the text reader, with the same cancel-on-abort plumbing and reader release, so the TomTom tile path can share the route deadline instead of reading arrayBuffer() unbounded. Where a route already had a deadline, the same signal now covers the body read as well as the headers. Two polls had no deadline at all: the adsb.lol military poll gains a 12-second one and the OpenSky states/all exchange, including its Basic-auth retry, a 20-second one; both miss into their existing STALE paths. FIRMS map-key status, the OpenSky token exchange and CCTV media are left as they are. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
b210ab0fe4
commit
5f8a275487
11 files changed
+278
-34
No files matched your search
@@ -1,5 +1,7 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- Bound the remaining upstream body reads (FIRMS, CelesTrak, adsb.lol, TomTom, OpenSky, adsbdb) with streaming size caps and shared deadlines; the adsb.lol military and OpenSky `states/all` polls gain deadlines of their own.
|
||||||
|
|
||||||
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
|
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
|
||||||
from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the
|
from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the
|
||||||
flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD
|
flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD
|
||||||
|
|||||||
@@ -827,6 +827,26 @@ feed address is read from the path, and a query-string form is refused with a 40
|
|||||||
streaming. The 12-second deadline includes reading the body, and rejected or
|
streaming. The 12-second deadline includes reading the body, and rejected or
|
||||||
stalled downloads are cancelled. Development and preview use the same handler.
|
stalled downloads are cancelled. Development and preview use the same handler.
|
||||||
|
|
||||||
|
## Upstream body bounds
|
||||||
|
|
||||||
|
Every proxied fetch outside CCTV media now reads its body through the shared
|
||||||
|
streaming readers in `src/sources/httpBody.js`, so a declared or observed body
|
||||||
|
over the cap is refused with `RESPONSE_TOO_LARGE` and cancelled rather than
|
||||||
|
buffered. `readResponseBytesCapped` accepts the same optional abort signal as
|
||||||
|
the text reader and cancels a body still streaming when it fires; TomTom flow
|
||||||
|
tiles use it alongside GTFS-Realtime. Caps are bounds, not budgets, sized well
|
||||||
|
above measured payloads: FIRMS 64 MiB per satellite source, CelesTrak 16 MiB
|
||||||
|
per group, OpenSky `states/all` 8 MiB, adsb.lol `/v2/mil` 8 MiB, TomTom tiles
|
||||||
|
8 MiB and adsbdb lookups 1 MiB. Where a route already had a deadline (FIRMS
|
||||||
|
60 s, CelesTrak 20 s, TomTom 15 s, adsbdb 8 s) that deadline now covers the
|
||||||
|
body read as well as the headers. Two polls that had no deadline gain one: the
|
||||||
|
adsb.lol military poll waits at most 12 seconds, and the OpenSky `states/all`
|
||||||
|
exchange, including its Basic-auth retry, at most 20. A rejected or timed-out
|
||||||
|
body is an upstream miss and follows each route's existing error and
|
||||||
|
cached-data paths. FIRMS map-key status, the OpenSky token exchange and CCTV
|
||||||
|
media keep their current handling.
|
||||||
|
|
||||||
|
|
||||||
## Remaining local service modules
|
## Remaining local service modules
|
||||||
|
|
||||||
Overpass query validation, geometry simplification, disk caching and upstream
|
Overpass query validation, geometry simplification, disk caching and upstream
|
||||||
|
|||||||
@@ -113,7 +113,12 @@
|
|||||||
"traffic-provider": {
|
"traffic-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/traffic"],
|
"exports": ["./server/providers/traffic"],
|
||||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
"modules": [
|
||||||
|
"server/providers/traffic.js",
|
||||||
|
"server/providers/common/http.js",
|
||||||
|
"src/sources/httpBody.js",
|
||||||
|
"src/data/tomtomTiles.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"traffic-source": {
|
"traffic-source": {
|
||||||
@@ -129,7 +134,12 @@
|
|||||||
"firms-provider": {
|
"firms-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/firms"],
|
"exports": ["./server/providers/firms"],
|
||||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
"modules": [
|
||||||
|
"server/providers/firms.js",
|
||||||
|
"server/providers/common/http.js",
|
||||||
|
"src/sources/httpBody.js",
|
||||||
|
"src/data/firmsCsv.js"
|
||||||
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"gbfs-provider": {
|
"gbfs-provider": {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readResponseTextCapped } from '../common/http.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
||||||
*
|
*
|
||||||
@@ -25,6 +27,10 @@ export function adsbLolProxy() {
|
|||||||
let _cooldownStatus = 0;
|
let _cooldownStatus = 0;
|
||||||
/** Response cache TTL (ms). */
|
/** Response cache TTL (ms). */
|
||||||
const CACHE_MS = 12000;
|
const CACHE_MS = 12000;
|
||||||
|
/** Upstream deadline (ms); the fetch previously had none and could hang a poll. */
|
||||||
|
const UPSTREAM_TIMEOUT_MS = 12000;
|
||||||
|
/** /v2/mil measures ~67 KB; a relayed error page is bounded by the same cap. */
|
||||||
|
const ADSBLOL_MIL_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
/** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */
|
/** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */
|
||||||
const RATE_LIMIT_COOLDOWN_MS = 30000;
|
const RATE_LIMIT_COOLDOWN_MS = 30000;
|
||||||
/** Cooldown after a 5xx (ms). */
|
/** Cooldown after a 5xx (ms). */
|
||||||
@@ -94,11 +100,17 @@ export function adsbLolProxy() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
|
||||||
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
||||||
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
if (upstream.ok) {
|
if (upstream.ok) {
|
||||||
const body = await upstream.text();
|
const body = await readResponseTextCapped(
|
||||||
|
upstream,
|
||||||
|
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
_cache = body;
|
_cache = body;
|
||||||
_cacheAt = Date.now();
|
_cacheAt = Date.now();
|
||||||
_cooldownUntil = 0;
|
_cooldownUntil = 0;
|
||||||
@@ -125,7 +137,11 @@ export function adsbLolProxy() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const body = await upstream.text();
|
const body = await readResponseTextCapped(
|
||||||
|
upstream,
|
||||||
|
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
serve(
|
serve(
|
||||||
res,
|
res,
|
||||||
upstream.status,
|
upstream.status,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promises as fsp } from 'node:fs';
|
import { promises as fsp } from 'node:fs';
|
||||||
|
import { readResponseJsonCapped } from '../common/http.js';
|
||||||
|
|
||||||
|
/** Route and aircraft lookups are a few hundred bytes; 1 MiB is a hard bound. */
|
||||||
|
const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||||
/**
|
/**
|
||||||
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
|
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
|
||||||
* airports) and hex → aircraft type/registration. Free community API — cached
|
* airports) and hex → aircraft type/registration. Free community API — cached
|
||||||
@@ -80,12 +84,16 @@ export function adsbdbProxy() {
|
|||||||
kind === 'route'
|
kind === 'route'
|
||||||
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
|
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
|
||||||
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
|
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
const signal = AbortSignal.timeout(8000);
|
||||||
|
const res = await fetch(url, { signal });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const payload = await readResponseJsonCapped(
|
||||||
|
res,
|
||||||
|
ADSBDB_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
const data =
|
const data =
|
||||||
kind === 'route'
|
kind === 'route' ? parseRoute(payload) : parseAircraft(payload);
|
||||||
? parseRoute(await res.json())
|
|
||||||
: parseAircraft(await res.json());
|
|
||||||
store[key] = { at: Date.now(), data }; // data may be null — negative cache
|
store[key] = { at: Date.now(), data }; // data may be null — negative cache
|
||||||
dirty = true;
|
dirty = true;
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback
|
|||||||
import {
|
import {
|
||||||
coalesceProxyRequest,
|
coalesceProxyRequest,
|
||||||
readResponseJsonCapped,
|
readResponseJsonCapped,
|
||||||
|
readResponseTextCapped,
|
||||||
} from '../common/http.js';
|
} from '../common/http.js';
|
||||||
import { requiredFiniteQueryNumber } from '../common/query.js';
|
import { requiredFiniteQueryNumber } from '../common/query.js';
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -72,6 +73,15 @@ const ADSBLOL_POINT_CACHE_MS = 12000;
|
|||||||
const ADSBLOL_POINT_CACHE_MAX = 80;
|
const ADSBLOL_POINT_CACHE_MAX = 80;
|
||||||
const ADSBLOL_POINT_RADIUS_NM = 250;
|
const ADSBLOL_POINT_RADIUS_NM = 250;
|
||||||
const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
|
/** states/all?extended=1 measures ~0.8 MB anonymously; bounded, not budgeted. */
|
||||||
|
const OPENSKY_STATES_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
|
/**
|
||||||
|
* Deadline for the whole states/all exchange: headers, the Basic-auth retry
|
||||||
|
* and the body. The fetch previously had none, so a hung upstream connection
|
||||||
|
* stalled the worldwide poll indefinitely; a miss here serves STALE like any
|
||||||
|
* other upstream failure.
|
||||||
|
*/
|
||||||
|
const OPENSKY_STATES_TIMEOUT_MS = 20000;
|
||||||
// A 200 response can still contain an old OpenSky snapshot. Past this point
|
// A 200 response can still contain an old OpenSky snapshot. Past this point
|
||||||
// the viewport-scoped adsb.lol source is more honest and keeps local motion
|
// the viewport-scoped adsb.lol source is more honest and keeps local motion
|
||||||
// current instead of coasting a stale worldwide frame indefinitely.
|
// current instead of coasting a stale worldwide frame indefinitely.
|
||||||
@@ -470,9 +480,10 @@ export function openSkyProxy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signal = AbortSignal.timeout(OPENSKY_STATES_TIMEOUT_MS);
|
||||||
let upstream = await fetch(
|
let upstream = await fetch(
|
||||||
'https://opensky-network.org/api/states/all?extended=1',
|
'https://opensky-network.org/api/states/all?extended=1',
|
||||||
{ headers },
|
{ headers, signal },
|
||||||
);
|
);
|
||||||
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
// Auto-mode fallback: if OAuth was rejected, retry with Basic credentials
|
||||||
if (
|
if (
|
||||||
@@ -487,13 +498,17 @@ export function openSkyProxy() {
|
|||||||
};
|
};
|
||||||
upstream = await fetch(
|
upstream = await fetch(
|
||||||
'https://opensky-network.org/api/states/all?extended=1',
|
'https://opensky-network.org/api/states/all?extended=1',
|
||||||
{ headers: retryHeaders },
|
{ headers: retryHeaders, signal },
|
||||||
);
|
);
|
||||||
usedMode = 'basic';
|
usedMode = 'basic';
|
||||||
reason = 'oauth_rejected_fallback_basic';
|
reason = 'oauth_rejected_fallback_basic';
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = await upstream.text();
|
let body = await readResponseTextCapped(
|
||||||
|
upstream,
|
||||||
|
OPENSKY_STATES_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null;
|
||||||
if (
|
if (
|
||||||
upstream.ok &&
|
upstream.ok &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|||||||
import { promises as fsp } from 'node:fs';
|
import { promises as fsp } from 'node:fs';
|
||||||
|
|
||||||
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
|
||||||
|
import { readResponseTextCapped } from './common/http.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NASA FIRMS live active-fire proxy with a memory + disk cache.
|
* NASA FIRMS live active-fire proxy with a memory + disk cache.
|
||||||
@@ -35,6 +36,12 @@ export function firmsProxy() {
|
|||||||
'VIIRS_SNPP_NRT',
|
'VIIRS_SNPP_NRT',
|
||||||
'MODIS_NRT',
|
'MODIS_NRT',
|
||||||
];
|
];
|
||||||
|
/**
|
||||||
|
* Per-source body cap. A VIIRS world/2 pull is ~130k rows x ~100 B, about
|
||||||
|
* 13 MB per satellite in heavy fire season (MODIS runs smaller), so this is a
|
||||||
|
* bound, not a budget.
|
||||||
|
*/
|
||||||
|
const FIRMS_MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
||||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||||
const CACHE_PATH = path.join(CACHE_DIR, 'firms.json');
|
const CACHE_PATH = path.join(CACHE_DIR, 'firms.json');
|
||||||
|
|
||||||
@@ -83,9 +90,12 @@ export function firmsProxy() {
|
|||||||
*/
|
*/
|
||||||
async function fetchSource(key, source) {
|
async function fetchSource(key, source) {
|
||||||
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
|
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${encodeURIComponent(key)}/${source}/world/2`;
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
|
const signal = AbortSignal.timeout(60_000);
|
||||||
|
const res = await fetch(url, { signal });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const records = parseFirmsCsv(await res.text());
|
const records = parseFirmsCsv(
|
||||||
|
await readResponseTextCapped(res, FIRMS_MAX_RESPONSE_BYTES, signal),
|
||||||
|
);
|
||||||
if (records === null) throw new Error('non-CSV upstream response');
|
if (records === null) throw new Error('non-CSV upstream response');
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promises as fsp } from 'node:fs';
|
import { promises as fsp } from 'node:fs';
|
||||||
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
|
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
|
||||||
|
import { readResponseTextCapped } from '../common/http.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin: CelesTrak TLE proxy.
|
* Vite plugin: CelesTrak TLE proxy.
|
||||||
@@ -48,10 +49,14 @@ export function celestrakProxy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */
|
||||||
|
const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
async function fetchUpstream(group) {
|
async function fetchUpstream(group) {
|
||||||
const url = celestrakTleUrl(group);
|
const url = celestrakTleUrl(group);
|
||||||
|
const signal = AbortSignal.timeout(20000);
|
||||||
const res = await fetch(url.toString(), {
|
const res = await fetch(url.toString(), {
|
||||||
signal: AbortSignal.timeout(20000),
|
signal,
|
||||||
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
|
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
|
||||||
// descriptive User-Agent with a contact point.
|
// descriptive User-Agent with a contact point.
|
||||||
headers: {
|
headers: {
|
||||||
@@ -60,7 +65,11 @@ export function celestrakProxy() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const body = await res.text();
|
const body = await readResponseTextCapped(
|
||||||
|
res,
|
||||||
|
CELESTRAK_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
|
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
|
||||||
if (!/^1 /m.test(body)) throw new Error('no TLE lines in response');
|
if (!/^1 /m.test(body)) throw new Error('no TLE lines in response');
|
||||||
return { at: Date.now(), body };
|
return { at: Date.now(), body };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promises as fsp } from 'node:fs';
|
import { promises as fsp } from 'node:fs';
|
||||||
|
|
||||||
|
import { readResponseBytesCapped } from './common/http.js';
|
||||||
import {
|
import {
|
||||||
isValidTileCoord as isValidTomTomTile,
|
isValidTileCoord as isValidTomTomTile,
|
||||||
utcDayKey as tomtomUtcDayKey,
|
utcDayKey as tomtomUtcDayKey,
|
||||||
@@ -48,6 +49,8 @@ export function tomtomProxy() {
|
|||||||
const DEFAULT_DAILY_BUDGET = 6000;
|
const DEFAULT_DAILY_BUDGET = 6000;
|
||||||
const MEM_MAX_ENTRIES = 256;
|
const MEM_MAX_ENTRIES = 256;
|
||||||
const UPSTREAM_TIMEOUT_MS = 15000;
|
const UPSTREAM_TIMEOUT_MS = 15000;
|
||||||
|
/** Flow tiles are well under 1 MB; this bounds a runaway body, not a budget. */
|
||||||
|
const TOMTOM_TILE_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
/** @type {Map<string, {at:number, buf:Buffer}>} tile key `z/x/y` -> cached tile (kept past TTL for serve-stale). */
|
/** @type {Map<string, {at:number, buf:Buffer}>} tile key `z/x/y` -> cached tile (kept past TTL for serve-stale). */
|
||||||
const mem = new Map();
|
const mem = new Map();
|
||||||
@@ -143,11 +146,15 @@ export function tomtomProxy() {
|
|||||||
'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' +
|
'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' +
|
||||||
`${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`;
|
`${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`;
|
||||||
recordUpstreamFetch(); // attempts count — upstream bills the request either way
|
recordUpstreamFetch(); // attempts count — upstream bills the request either way
|
||||||
const res = await fetch(url, {
|
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
|
||||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
const res = await fetch(url, { signal });
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
const bytes = await readResponseBytesCapped(
|
||||||
|
res,
|
||||||
|
TOMTOM_TILE_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
if (buf.length === 0) throw new Error('empty tile body');
|
if (buf.length === 0) throw new Error('empty tile body');
|
||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-15
@@ -55,38 +55,52 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a fetch() Response body as bytes with the same hard cap as
|
* Read a fetch() Response body as bytes with the same hard cap as
|
||||||
* readResponseTextCapped — for protobuf upstreams (GTFS-Realtime).
|
* readResponseTextCapped — for protobuf and tile upstreams (GTFS-Realtime,
|
||||||
|
* TomTom flow). Same early Content-Length rejection, running cap while
|
||||||
|
* streaming and cancellation on abort, so callers can treat the two alike.
|
||||||
* Throws { code:'RESPONSE_TOO_LARGE' }.
|
* Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||||
*/
|
*/
|
||||||
export async function readResponseBytesCapped(response, maxBytes) {
|
export async function readResponseBytesCapped(response, maxBytes, signal) {
|
||||||
const tooLarge = () => {
|
const tooLarge = () => {
|
||||||
const err = new Error('Upstream response too large');
|
const err = new Error('Upstream response too large');
|
||||||
err.code = 'RESPONSE_TOO_LARGE';
|
err.code = 'RESPONSE_TOO_LARGE';
|
||||||
return err;
|
return err;
|
||||||
};
|
};
|
||||||
const declared = Number(response.headers.get('content-length'));
|
const declared = Number(response.headers.get('content-length'));
|
||||||
if (Number.isFinite(declared) && declared > maxBytes) throw tooLarge();
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
||||||
|
void response.body?.cancel().catch(() => {});
|
||||||
|
throw tooLarge();
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
const reader = response.body?.getReader?.();
|
const reader = response.body?.getReader?.();
|
||||||
if (!reader) {
|
if (!reader) {
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
signal?.throwIfAborted();
|
||||||
if (bytes.byteLength > maxBytes) throw tooLarge();
|
if (bytes.byteLength > maxBytes) throw tooLarge();
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
const cancel = () => {
|
||||||
|
void reader.cancel().catch(() => {});
|
||||||
|
};
|
||||||
|
signal?.addEventListener('abort', cancel, { once: true });
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (;;) {
|
try {
|
||||||
const { done, value } = await reader.read();
|
for (;;) {
|
||||||
if (done) break;
|
signal?.throwIfAborted();
|
||||||
total += value.byteLength;
|
const { done, value } = await reader.read();
|
||||||
if (total > maxBytes) {
|
signal?.throwIfAborted();
|
||||||
try {
|
if (done) break;
|
||||||
await reader.cancel();
|
total += value.byteLength;
|
||||||
} catch {
|
if (total > maxBytes) throw tooLarge();
|
||||||
/* no-op */
|
chunks.push(value);
|
||||||
}
|
|
||||||
throw tooLarge();
|
|
||||||
}
|
}
|
||||||
chunks.push(value);
|
} catch (error) {
|
||||||
|
cancel();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener('abort', cancel);
|
||||||
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
const out = new Uint8Array(total);
|
const out = new Uint8Array(total);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readResponseBytesCapped } from './httpBody.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Response that streams `chunk` forever until the reader cancels it. The
|
||||||
|
* zero high-water mark keeps the stream from pulling eagerly at construction,
|
||||||
|
* so `pulls()` counts only what a reader actually asked for.
|
||||||
|
*/
|
||||||
|
function endlessResponse(chunk, onCancel, headers = {}) {
|
||||||
|
let pulls = 0;
|
||||||
|
const stream = new ReadableStream(
|
||||||
|
{
|
||||||
|
pull(controller) {
|
||||||
|
pulls += 1;
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
cancel: onCancel,
|
||||||
|
},
|
||||||
|
{ highWaterMark: 0 },
|
||||||
|
);
|
||||||
|
return { response: new Response(stream, { headers }), pulls: () => pulls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkedResponse(chunks, headers = {}) {
|
||||||
|
const stream = new ReadableStream(
|
||||||
|
{
|
||||||
|
start(controller) {
|
||||||
|
for (const chunk of chunks) controller.enqueue(chunk);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ highWaterMark: 0 },
|
||||||
|
);
|
||||||
|
return new Response(stream, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = (n, fill) => new Uint8Array(n).fill(fill);
|
||||||
|
|
||||||
|
test('returns the exact bytes of a body that fits, untouched by text decoding', async () => {
|
||||||
|
// 0xFF and 0xFE are invalid UTF-8 lead bytes; a text path would mangle them.
|
||||||
|
const payload = Uint8Array.from([0xff, 0xfe, 0x00, 0x7f, 0x80, 0x01]);
|
||||||
|
const buf = await readResponseBytesCapped(chunkedResponse([payload]), 1024);
|
||||||
|
assert.ok(buf instanceof Uint8Array);
|
||||||
|
assert.deepEqual([...buf], [...payload]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joins chunks in order and reports the total length', async () => {
|
||||||
|
const buf = await readResponseBytesCapped(
|
||||||
|
chunkedResponse([bytes(3, 1), bytes(2, 2), bytes(1, 3)]),
|
||||||
|
1024,
|
||||||
|
);
|
||||||
|
assert.deepEqual([...buf], [1, 1, 1, 2, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses on a declared Content-Length without pulling the body', async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const { response, pulls } = endlessResponse(
|
||||||
|
bytes(512, 0),
|
||||||
|
() => {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
{ 'content-length': '4096' },
|
||||||
|
);
|
||||||
|
await assert.rejects(readResponseBytesCapped(response, 1024), {
|
||||||
|
code: 'RESPONSE_TOO_LARGE',
|
||||||
|
});
|
||||||
|
assert.equal(pulls(), 0);
|
||||||
|
assert.ok(cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses a chunked body the moment it passes the cap and stops pulling', async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const { response, pulls } = endlessResponse(bytes(512, 0), () => {
|
||||||
|
cancelled = true;
|
||||||
|
});
|
||||||
|
await assert.rejects(readResponseBytesCapped(response, 1024), {
|
||||||
|
code: 'RESPONSE_TOO_LARGE',
|
||||||
|
});
|
||||||
|
// Three 512-byte chunks is the first total over 1024; an uncapped read of
|
||||||
|
// this never-ending stream would not terminate at all.
|
||||||
|
assert.ok(pulls() <= 3, `pulled ${pulls()} chunks`);
|
||||||
|
assert.ok(cancelled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a body exactly at the cap', async () => {
|
||||||
|
const buf = await readResponseBytesCapped(
|
||||||
|
chunkedResponse([bytes(512, 7), bytes(512, 7)]),
|
||||||
|
1024,
|
||||||
|
);
|
||||||
|
assert.equal(buf.byteLength, 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancels a body still streaming when the signal aborts, and never returns a partial one', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let cancelled = false;
|
||||||
|
let pulls = 0;
|
||||||
|
const stream = new ReadableStream(
|
||||||
|
{
|
||||||
|
pull(ctrl) {
|
||||||
|
pulls += 1;
|
||||||
|
ctrl.enqueue(bytes(64, 0));
|
||||||
|
if (pulls === 2) controller.abort();
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ highWaterMark: 0 },
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
readResponseBytesCapped(new Response(stream), 1 << 20, controller.signal),
|
||||||
|
);
|
||||||
|
assert.ok(cancelled);
|
||||||
|
assert.ok(pulls <= 3, `pulled ${pulls} chunks after abort`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses immediately when the signal is already aborted', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
const { response, pulls } = endlessResponse(bytes(8, 0), () => {});
|
||||||
|
await assert.rejects(
|
||||||
|
readResponseBytesCapped(response, 1024, controller.signal),
|
||||||
|
);
|
||||||
|
assert.equal(pulls(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to arrayBuffer() for a body with no stream reader', async () => {
|
||||||
|
const response = new Response(bytes(16, 9));
|
||||||
|
const buf = await readResponseBytesCapped(response, 1024);
|
||||||
|
assert.equal(buf.byteLength, 16);
|
||||||
|
assert.equal(buf[0], 9);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user