// Imported by the server: the catalog-free marker, not src/i18n/index.js. The // text stays English data; layers show it translated (translateKnown). import { msg } from '../i18n/marker.js'; const tooLarge = () => Object.assign(new Error(msg('Upstream response too large')), { code: 'RESPONSE_TOO_LARGE', }); /** * Read a fetch() Response body as text with a hard byte cap. Rejects early on an * oversized Content-Length, then streams with a running cap so a chunked or * length-omitted response cannot blow past the limit. Throws { code:'RESPONSE_TOO_LARGE' }. */ export async function readResponseTextCapped(response, maxBytes, signal) { const declared = Number(response.headers.get('content-length')); if (Number.isFinite(declared) && declared > maxBytes) { void response.body?.cancel().catch(() => {}); throw tooLarge(); } signal?.throwIfAborted(); const reader = response.body?.getReader?.(); if (!reader) { const text = await response.text(); signal?.throwIfAborted(); if (new TextEncoder().encode(text).byteLength > maxBytes) throw tooLarge(); return text; } const cancel = () => { void reader.cancel().catch(() => {}); }; signal?.addEventListener('abort', cancel, { once: true }); const decoder = new TextDecoder(); let out = ''; let total = 0; try { for (;;) { signal?.throwIfAborted(); const { done, value } = await reader.read(); signal?.throwIfAborted(); if (done) break; total += value.byteLength; if (total > maxBytes) throw tooLarge(); out += decoder.decode(value, { stream: true }); } return out + decoder.decode(); } catch (error) { cancel(); throw error; } finally { signal?.removeEventListener('abort', cancel); reader.releaseLock(); } } /** Parse a fetch() JSON response only after enforcing a hard byte cap. */ export async function readResponseJsonCapped(response, maxBytes, signal) { return JSON.parse(await readResponseTextCapped(response, maxBytes, signal)); } /** * Read a fetch() Response body as bytes with the same hard cap as * 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) { const declared = Number(response.headers.get('content-length')); if (Number.isFinite(declared) && declared > maxBytes) { await response.body?.cancel().catch(() => {}); throw tooLarge(); } signal?.throwIfAborted(); const reader = response.body?.getReader?.(); if (!reader) { const bytes = new Uint8Array(await response.arrayBuffer()); signal?.throwIfAborted(); if (bytes.byteLength > maxBytes) throw tooLarge(); return bytes; } const cancel = () => { void reader.cancel().catch(() => {}); }; signal?.addEventListener('abort', cancel, { once: true }); const chunks = []; let total = 0; try { for (;;) { signal?.throwIfAborted(); const { done, value } = await reader.read(); signal?.throwIfAborted(); if (done) break; total += value.byteLength; if (total > maxBytes) throw tooLarge(); chunks.push(value); } const out = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.byteLength; } return out; } catch (error) { await reader.cancel().catch(() => {}); throw error; } finally { signal?.removeEventListener('abort', cancel); reader.releaseLock(); } } /** * Return the existing promise for a cache key, or create one and remove it * only when that exact promise settles. */ export function coalesceProxyRequest(inFlight, key, create) { const existing = inFlight.get(key); if (existing) return { promise: existing, shared: true }; let promise; promise = Promise.resolve() .then(create) .finally(() => { if (inFlight.get(key) === promise) inFlight.delete(key); }); inFlight.set(key, promise); return { promise, shared: false }; }