Merge upstream/main: bikeshare Toronto and scoped city failures

Upstream's #641 (bikeshare Toronto, city failures scoped to range with
partial outages read DEGRADED/STALE) replaced the layer-level GBFS
error strings this branch had msg()-marked, in the two paths that
conflicted.

- ingestion.js/viewport.js taken as upstream's per-city
  _failedCityIds tracking; the layer-level error assignments are gone.
- 'GBFS fetch error' still reaches the panel via stats.error from
  controls.js, so its mark moved there (translateFeedError keeps
  translating it).
- 'GBFS status update failed' no longer exists anywhere: pruned from
  es/ru and from the layerStatusText SITES table, which now pins the
  controls.js site instead of the removed ones.

Also brings #581 (upstream-read caps, OpenSky 429 cooldown) - no i18n
surface.

npm run i18n: complete (2553), no unmarked strings. npm test: only the
pre-existing environmental OPENAI_API_KEY leak in devFreshDotenv fails.
Build passes.
This commit is contained in:
Meir Michanie committed 2026-10-15 23:02:30 +02:00
commit 6fe8327ccb
30 files changed
+1102 -96

No files matched your search

+3 -1
View File
@@ -55,7 +55,9 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
/**
* 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' }.
*/
export async function readResponseBytesCapped(response, maxBytes, signal) {
+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);
});