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:
Michael JohnstonandClaude Opus 5 committed 2026-09-26 14:40:08 -04:00
1 parent b210ab0fe4
commit 5f8a275487
11 files changed
+278 -34

No files matched your search

+2
View File
@@ -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
+20
View File
@@ -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
+12 -2
View File
@@ -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": {
+18 -2
View File
@@ -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,
+12 -4
View File
@@ -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;
+18 -3
View File
@@ -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 &&
+12 -2
View File
@@ -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;
} }
+11 -2
View File
@@ -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 };
+11 -4
View File
@@ -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
View File
@@ -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;
+133
View File
@@ -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);
});