/** * The HTTP Range adapter. * * Layer 5. Turns a URL into random access without downloading the recording, which is the * whole reason a 13 GiB BDF can be opened in a browser tab. * * Three things here are load-bearing and easy to get wrong: * * 1. A byte range is INCLUSIVE at both ends. `bytes=0-0` is one byte. * 2. A `200 OK` answer to a Range request means the server ignored the header and is sending * the whole resource. That is refused by default — silently buffering gigabytes because a * CDN is misconfigured is exactly the kind of invisible cost this library exists to refuse. * 3. The source length must be known before any read, because random access is meaningless * without it. Three ways are tried, cheapest first, and failing to find one is fatal. */ import { EdfSourceError } from '../errors.js'; import { requireBooleanOption, requireFiniteOption } from '../options.js'; import { describeValue } from '../text/describe.js'; import type { AbortSignalLike, ByteSource, FetchLike, HttpResponseLike, HttpSourceOptions, ReadOptions, } from '../types.js'; import { assertExactRead, assertReadOptions, assertReadRange, throwIfSignalAborted, } from './source.js'; /** Small enough to stay polite to a shared origin, large enough to hide latency. */ const DEFAULT_MAX_CONCURRENCY = 4; const HTTP_PARTIAL_CONTENT = 206; const HTTP_OK = 200; type RequestInitLike = { headers: Record; method?: string }; /** * A promise semaphore. A released slot is handed straight to the next waiter rather than being * returned to the pool, so the in-flight count can never overshoot the limit between the * release and the waiter resuming on its microtask. */ interface Gate { acquire(): Promise; release(): void; } function createGate(limit: number): Gate { let active = 0; const waiting: Array<() => void> = []; return { async acquire(): Promise { if (active < limit) { active += 1; return; } await new Promise((resolve) => { waiting.push(resolve); }); // The releaser transferred its slot to us, so `active` is deliberately left unchanged. }, release(): void { const next = waiting.shift(); if (next !== undefined) { next(); return; } active -= 1; }, }; } /** * A real `Headers` lookup is case-insensitive, but `HttpResponseLike` is structural and a * hand-written test double usually is not. Both spellings are tried. */ function headerOf(response: HttpResponseLike, name: string): string | null { return response.headers.get(name) ?? response.headers.get(name.toLowerCase()); } function parseNonNegativeInteger(text: string | null): number | undefined { if (text === null) return undefined; const trimmed = text.trim(); if (!/^\d+$/.test(trimmed)) return undefined; const value = Number(trimmed); return Number.isSafeInteger(value) ? value : undefined; } /** * The `first-byte-pos` and `last-byte-pos` of a `Content-Range`, or `undefined` when the header is * absent or not in the `bytes -/` form. * * `undefined` means "no usable claim", never "the range was wrong": a caller-supplied `FetchLike` * test double is free to answer every header with `null`, and treating that as corruption would * break doubles rather than catch servers. A real 206 always carries the header (RFC 7233 makes it * mandatory), so a misbehaving cache is still caught. */ function rangeFromContentRange( value: string | null, ): { readonly first: number; readonly last: number } | undefined { if (value === null) return undefined; const match = /^\s*bytes\s+(\d+)-(\d+)\//.exec(value); if (match === null) return undefined; const first = parseNonNegativeInteger(match[1] ?? null); const last = parseNonNegativeInteger(match[2] ?? null); if (first === undefined || last === undefined) return undefined; return { first, last }; } /** * `Content-Range: bytes 0-0/12345` -> 12345. A `/*` total is unknown, not zero. * * The `bytes` unit is required, as it is in `rangeFromContentRange` above. RFC 7233 lets a server * answer in a range unit of its own, and a total counted in some other unit is not a byte length: * taking it built a source whose every read is then range-checked against a number measuring * something else, which is the same fiction `options.byteLength` is validated to prevent. The * other caller reaches this only after `rangeFromContentRange` has already accepted the header, * so nothing that worked before stops working (fixed in 0.4.403). */ function totalFromContentRange(value: string | null): number | undefined { if (value === null) return undefined; if (!/^\s*bytes\s/.test(value)) return undefined; const slash = value.lastIndexOf('/'); if (slash < 0) return undefined; return parseNonNegativeInteger(value.slice(slash + 1)); } function isSuccess(status: number): boolean { return status >= 200 && status < 300; } function hrefOf(url: string | { readonly href: string }): string { if (typeof url === 'string') return url; // `url.href` on an omitted argument was V8's `Cannot read properties of undefined (reading // 'href')` — from the one adapter whose whole job is an address, and before any request was // issued, so there was nothing about the network in it either (fixed in 0.6.102). const href = (url as { href?: unknown } | null | undefined)?.href; if (typeof href === 'string') return href; throw new EdfSourceError( `httpSource() needs a URL string or a URL object, and received ${describeValue(url)}. ` + 'Next: pass the address as a string, or as new URL(address).', { offset: 0, requestedLength: 0 }, ); } /** * The ADDRESS, before a request is built from it. * * `hrefOf` checks the argument is a string and stops there, so anything a string can hold reached * `fetch` — and `fetch` answered for itself. `httpSource('not a url')` was V8's * `TypeError: Failed to parse URL from not a url`: no `Next:` clause, not an `EdfSourceError`, so * `isEdfError` was false, from the one adapter whose whole argument is an address, and before any * request went out so there was nothing about the network in it either. That is the same shape * 0.6.102 fixed one function up, for the object form. * * `file://` is the mistake worth naming. It is a perfectly good URL, so no parse catches it, and * this adapter reads by asking for byte ranges over HTTP — which no runtime serves for a file URL. * `fileSource(path)` is the adapter for that and the message says so. * * Resolved against `location.href` WHEN THERE IS ONE, because that is the rule the runtime itself * applies: a relative address is legitimate in a page and meaningless in Node, Deno or Bun. Reached * structurally, like `globalThis.fetch` below, since `src/` compiles with no DOM lib. */ function assertFetchableHref(href: string): void { const UrlCtor = (globalThis as { URL?: new (url: string, base?: string) => { protocol: string } }) .URL; if (UrlCtor === undefined) return; const base = (globalThis as { location?: { href?: unknown } }).location?.href; let protocol: string; try { protocol = new UrlCtor(href, typeof base === 'string' ? base : undefined).protocol; } catch { throw new EdfSourceError( `httpSource(): ${JSON.stringify(href)} is not an address this runtime can fetch. It is not ` + 'an absolute URL, and there is no page here to resolve a relative one against, so the ' + "request would have failed with fetch's own TypeError rather than anything edfcore said. " + 'Next: pass the whole https:// address.', { offset: 0, requestedLength: 0 }, ); } if (protocol !== 'http:' && protocol !== 'https:') { throw new EdfSourceError( `httpSource(): ${JSON.stringify(href)} is a ${protocol.replace(':', '')} address, and this ` + 'adapter reads by asking a server for byte ranges over HTTP. Next: write the scheme out as ' + 'http:// or https:// if that is what is missing, or use fileSource(path) from ' + '"edfcore/node" for a file on disk, blobSource(file) for one a browser handed you, and ' + 'byteSource(bytes) for one already in memory.', { offset: 0, requestedLength: 0 }, ); } } /** A plain record of strings, which is the only shape a spread carries intact. */ function assertHeaders(headers: unknown): void { if (headers === undefined || headers === null) return; const iterable = typeof (headers as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'; if (typeof headers === 'object' && !iterable) { const values = Object.values(headers as Record); if (values.every((value) => typeof value === 'string')) return; } throw new EdfSourceError( `httpSource(): options.headers is ${describeValue(headers)}, not a plain object of header ` + 'names to string values — and it is spread into the request, so a string becomes one header ' + 'per character, a list of pairs becomes one numbered entry, and a Map or a Headers becomes ' + 'nothing at all. Next: pass an object whose values are strings — authorization to ' + '"Bearer …" — or Object.fromEntries(headers) for a Headers or a Map.', { offset: 0, requestedLength: 0 }, ); } function resolveFetch(options: HttpSourceOptions | undefined): FetchLike { const provided = options?.fetch; /* * That it is a FUNCTION, which this took on trust while refusing the case below with care. * * The refusal underneath is for a runtime that exposes no `fetch` and a caller who supplied none, * and it ends "pass options.fetch with any function matching FetchLike". A caller who passed * something that is not one got nothing of the sort: the value was handed on, called at the first * request, and threw V8's `fetchImpl is not a function` — an internal name, no `Next:` clause, and * by then the adapter had already resolved an address and built a range header. * * One function, two members of the same question, guarded on one side only. And this is the option * the guard above the call site calls the one that costs most, "because `fetch` is among them": * supplying one is how an authenticated client, a signed-URL wrapper, a proxy or a test double * gets in. */ if (provided !== undefined && typeof provided !== 'function') { throw new EdfSourceError( `httpSource(): options.fetch is ${describeValue(provided)}, not a function. It is called ` + 'once per request, so this would have failed at the first one with the whole address and ' + 'range already worked out. Next: pass any function matching FetchLike, or omit it to use ' + 'the global fetch.', { offset: 0, requestedLength: 0 }, ); } if (provided !== undefined) return provided; // `fetch` cannot be named as a global without the DOM lib, so it is reached structurally. const ambient = (globalThis as { fetch?: FetchLike }).fetch; if (ambient !== undefined) return ambient; throw new EdfSourceError( 'httpSource() found no fetch implementation: this runtime does not expose globalThis.fetch ' + 'and options.fetch was not given. Next: pass options.fetch with any function matching ' + 'FetchLike.', { offset: 0, requestedLength: 0 }, ); } /** * `FetchLike` deliberately does not name `signal`: naming it would pull in the real DOM * `AbortSignal` by parameter contravariance, the exact dependency the shims exist to avoid. It * is still handed to the implementation at runtime. * * It is attached only when it carries `addEventListener`, i.e. when it genuinely is an * `AbortSignal`. The platform `fetch` throws a `TypeError` on anything else, and a caller who * passed a bare `{ aborted }` shim is still served by the `throwIfSignalAborted` polls around the * request. */ function attachSignal(init: RequestInitLike, signal: AbortSignalLike | undefined): void { if (signal === undefined) return; if (typeof (signal as { addEventListener?: unknown }).addEventListener !== 'function') return; (init as { signal?: unknown }).signal = signal; } function request( fetchImpl: FetchLike, href: string, headers: Record, method: 'GET' | 'HEAD', signal: AbortSignalLike | undefined, ): Promise { const init: RequestInitLike = { headers, method }; attachSignal(init, signal); return fetchImpl(href, init); } function rangeIgnoredError(href: string, offset: number, length: number): EdfSourceError { const lastByte = offset + length - 1; return new EdfSourceError( `The server answered ${HTTP_OK} OK instead of ${HTTP_PARTIAL_CONTENT} Partial Content for ` + `Range bytes=${offset}-${lastByte} on ${href}, so it ignored the Range header and is ` + 'sending the whole resource (HTTP_RANGE_IGNORED). edfcore will not silently buffer a ' + 'recording nobody asked for. Next: serve the file from an origin or CDN that supports ' + 'byte ranges, or pass allowFullDownload: true to fetch it once and serve reads from ' + 'memory.', { offset, requestedLength: length }, ); } /** What `httpSource` learns before it can serve a single read. */ interface ResolvedSource { readonly byteLength: number; /** Set only when the length probe already had to download everything. */ readonly body: Uint8Array | undefined; } async function resolveSource( fetchImpl: FetchLike, href: string, baseHeaders: Record, options: HttpSourceOptions | undefined, ): Promise { const signal = options?.signal; // Resolution issues its own HEAD and probe requests, so an already-aborted source signal has // to be caught here too — otherwise httpSource() itself does network work after cancellation. throwIfSignalAborted(signal); const declared = options?.byteLength; if (declared !== undefined) { if (!Number.isSafeInteger(declared) || declared < 0) { throw new EdfSourceError( `httpSource() was given ${describeValue(declared)} as options.byteLength, which is not ` + 'a non-negative safe integer. Next: pass the real resource size in bytes, or omit it ' + 'and let edfcore probe for it.', { offset: 0, requestedLength: 0 }, ); } return { byteLength: declared, body: undefined }; } try { const head = await request(fetchImpl, href, baseHeaders, 'HEAD', signal); if (isSuccess(head.status)) { const length = parseNonNegativeInteger(headerOf(head, 'Content-Length')); if (length !== undefined) return { byteLength: length, body: undefined }; } } catch { // A rejected or forbidden HEAD is common (CORS, some object stores). Fall through to the // one-byte range probe rather than failing on it. } /* * Polled again, because the catch above swallows EVERY rejection — including the `AbortError` * the platform `fetch` raises when the caller cancels mid-HEAD. Without this the probe went out * after the flip, and for a caller holding a bare `{ aborted }` shim — which `attachSignal` * cannot hand to `fetch`, so nothing else observes it — `httpSource()` completed both requests * and resolved a live source for a call that had already been cancelled. * * `api-sources.md` promises "a caller who passed a bare `{ aborted }` shim is still served by * the polls around the request, so cancellation works either way", and the entry poll above says * resolution has to catch this "otherwise httpSource() itself does network work after * cancellation". Both were true of reads and not of resolution (fixed in 0.3.97). */ throwIfSignalAborted(signal); const probeHeaders = { ...baseHeaders, Range: 'bytes=0-0' }; const probe = await request(fetchImpl, href, probeHeaders, 'GET', signal); if (probe.status === HTTP_PARTIAL_CONTENT) { const total = totalFromContentRange(headerOf(probe, 'Content-Range')); if (total !== undefined) return { byteLength: total, body: undefined }; } else if (probe.status === HTTP_OK) { // The probe already committed the server to sending everything. Refusing now costs the // caller nothing, and accepting means one download instead of two. if (options?.allowFullDownload !== true) throw rangeIgnoredError(href, 0, 1); const body = new Uint8Array(await probe.arrayBuffer()); return { byteLength: body.byteLength, body }; } else if (!isSuccess(probe.status)) { throw new EdfSourceError( `httpSource() could not read ${href}: the server answered HTTP ${probe.status} to a ` + 'Range probe. Next: check the URL, its authentication headers and its CORS policy.', { offset: 0, requestedLength: 1 }, ); } throw new EdfSourceError( `httpSource() could not determine the size of ${href}: HEAD returned no usable ` + 'Content-Length and a Range probe returned no Content-Range total, so no byte offset can ' + 'be addressed and random access is impossible. Next: pass options.byteLength if you know ' + 'the size, or serve the file from an origin that reports one.', { offset: 0, requestedLength: 0 }, ); } /** * A `ByteSource` over an HTTP URL, using range requests — which is what makes reading ten * seconds out of a remote twelve-hour recording cost ten seconds of bytes. Async because it * probes the server for range support and a length before returning. */ export async function httpSource( url: string | { readonly href: string }, options?: HttpSourceOptions, ): Promise { const href = hrefOf(url); assertFetchableHref(href); /* * The OPTIONS, which carry the `fetch` this adapter is supposed to call. * * 0.6.130, 0.6.140 and 0.6.154 each refused a bare value where an options object belongs, on the * same argument: every option here is a field on one, so the value a caller means IS the option. * This adapter's options are the ones where that costs most, because `fetch` is among them. * * `options?.fetch` was `undefined`, so `resolveFetch` fell back to the global — and the whole * point of supplying one is that the global is not what should serve this request: an * authenticated client, a signed-URL wrapper, a proxy, or the double a test suite installs * instead of reaching the network at all. `headers` went the same way, so a bearer token was * dropped and the server answered 401 or, worse, served a different resource anonymously. * `byteLength`, `maxConcurrency` and `allowFullDownload` were dropped with them. * * `undefined` and `null` still mean "no options", which is what they already meant. */ if (options !== undefined && typeof options !== 'object') { throw new EdfSourceError( `httpSource(): the options are ${describeValue(options)}, not an object — fetch, headers ` + 'and byteLength are fields on one, so this request would have gone out on the global ' + 'fetch with none of them. Next: pass them on an options object.', { offset: 0, requestedLength: 0 }, ); } /* * The FLAG, once the options object is there. Same rule as 0.6.182, 0.6.193 and 0.6.197: it is * compared against a boolean rather than coerced, so `'true'` out of a config key reads as OFF. * * Off is the direction that costs here. `allowFullDownload` is the one option that says "yes, this * server ignores Range — fetch the resource once and serve reads out of it", so text turned the * permission off and the read was refused with `HTTP_RANGE_IGNORED` and the sentence "edfcore * will not silently buffer a recording nobody asked for" — said to a caller who asked for it. */ requireBooleanOption( options?.allowFullDownload, 'allowFullDownload', 'a server that ignores Range was refused with HTTP_RANGE_IGNORED, which is the permission this ' + 'option grants', ); /* * The HEADERS, before they are spread into one. * * `{ ...options?.headers }` launders anything into a plausible object, which is the shape 0.6.178 * found in `buildTimeline`: by the time a check could see it, the mistake is gone. Here it goes * out on the wire. * * - a bearer STRING — `headers: 'Bearer abc'`, which is what a caller writes when the token is the * only header they have — spread into thirteen single-character headers, one per index; * - an ARRAY of pairs, the form `new Headers()` takes and `Object.entries` returns, spread into * `{ 0: [...] }`; * - a `Map` or a real `Headers`, which have no own enumerable properties at all, spread into `{}`. * * The options guard above names this cost in its own words — "a bearer token was dropped and the * server answered 401 or, worse, served a different resource anonymously" — for the whole object. * The field inside it went the same way, and the last of the three is the silent one: the request * went out unauthenticated and the adapter never knew. */ assertHeaders(options?.headers); const fetchImpl = resolveFetch(options); const baseHeaders: Record = { ...options?.headers }; const gate = createGate( Math.max( 1, Math.floor( requireFiniteOption(options?.maxConcurrency, 'maxConcurrency', DEFAULT_MAX_CONCURRENCY), ), ), ); const resolved = await resolveSource(fetchImpl, href, baseHeaders, options); const byteLength = resolved.byteLength; /** Set once, and only ever when the server ignored Range and the caller allowed it. */ let fullBody: Uint8Array | undefined = resolved.body; /** * The one in-flight full download, so a server that ignores Range costs one transfer. * * Without it, every read that had already entered `fetchRange` issued its own GET, each * buffering the whole resource — N concurrent block reads downloaded the file N times and held * up to `maxConcurrency` copies at once. That turns a large remote recording into an * out-of-memory crash rather than a slow read. */ let fullBodyInflight: Promise | undefined; /** * Settles once one request has revealed whether this server honours Range. * * Only used when `allowFullDownload` is on. Until the answer is known, a request is a gamble: * if the server ignores Range it answers with the entire resource, and `maxConcurrency` * requests issued in parallel each pay for a whole copy before any of them can warn the * others. Sending the first one alone costs one round trip on the first read and bounds the * worst case at a single transfer instead of `maxConcurrency` of them. */ let rangeSupportProbe: Promise | undefined; async function fetchRange( offset: number, length: number, readOptions: ReadOptions | undefined, ): Promise { const signal = readOptions?.signal ?? options?.signal; // INCLUSIVE end: `bytes=0-0` is one byte, so the last addressed byte is offset+length-1. const headers = { ...baseHeaders, Range: `bytes=${offset}-${offset + length - 1}` }; await gate.acquire(); try { throwIfSignalAborted(signal); // Waiting for a gate slot can take arbitrarily long, and in that time another read may // have discovered that the server ignores Range. Re-checking here is what stops every // queued read from repeating the download the first one already made. if (fullBody !== undefined) return sliceFullBody(fullBody, offset, length, href, byteLength); if (fullBodyInflight !== undefined) { return sliceFullBody(await fullBodyInflight, offset, length, href, byteLength); } let announceProbeDone: (() => void) | undefined; if (options?.allowFullDownload === true) { if (rangeSupportProbe === undefined) { rangeSupportProbe = new Promise((resolve) => { announceProbeDone = resolve; }); } else { // Someone else is finding out. Their answer is ours too. await rangeSupportProbe; if (fullBody !== undefined) return sliceFullBody(fullBody, offset, length, href, byteLength); if (fullBodyInflight !== undefined) { return sliceFullBody(await fullBodyInflight, offset, length, href, byteLength); } } } try { return await issueRequest(offset, length, headers, signal); } finally { announceProbeDone?.(); } } finally { gate.release(); } } async function issueRequest( offset: number, length: number, headers: Record, signal: AbortSignalLike | undefined, ): Promise { const response = await request(fetchImpl, href, headers, 'GET', signal); if (response.status === HTTP_PARTIAL_CONTENT) { // WHICH bytes arrived, before how many. `assertExactRead` below is a LENGTH guard and cannot // see a right-sized body taken from the wrong offset — which is exactly what a cache, a // Service Worker or a CDN edge keyed on URL alone returns when it serves a stored partial // body for a differently-ranged request. The samples then decode cleanly, land at the // timestamps the caller asked for, and are the wrong seconds of the recording, with nothing // anywhere to say so. RFC 7233 makes this header the check against precisely that. const claimed = rangeFromContentRange(headerOf(response, 'Content-Range')); const expectedLast = offset + length - 1; if (claimed !== undefined && (claimed.first !== offset || claimed.last !== expectedLast)) { const received = claimed.last - claimed.first + 1; // TWO different failures, and they were reported as one. A server that started where it // was asked to and simply stopped because the resource ends there has behaved perfectly: // the bytes ARE the requested bytes, and what is wrong is the LENGTH this source is // working from — a stale HEAD `Content-Length`, a caller-supplied `options.byteLength`, or // a file replaced by a shorter one mid-session. Telling that user to bypass a cache sends // them to reconfigure a CDN that is behaving correctly, while the response just rejected // carries the resource's real size in the header being read (fixed in 0.3.37). // BOTH conditions. `claimed.first === offset` alone also matches a 206 that sent MORE than // was asked for — a CDN or nginx `slice` edge answering with a whole fixed-size block — // and that response then got the short-tail message, which is wrong in every clause: it // said the server "stopped at byte 511, because that is the end of a 4096-byte resource", // claimed a range plainly inside the length "does not exist", and advised dropping a // `byteLength` that is correct. The one fix that would have helped, varying the cache on // `Range`, is printed only by the branch it was routed away from. Introduced by the split // in 0.3.37 and narrowed here (fixed in 0.3.40). if (claimed.first === offset && claimed.last < expectedLast) { const total = totalFromContentRange(headerOf(response, 'Content-Range')); const realSize = total === undefined ? 'the resource' : `a ${total}-byte resource`; throw new EdfSourceError( `Reading bytes ${offset}..${expectedLast} of ${href}: the server started where it was ` + `asked to and stopped at byte ${claimed.last}, because that is the end of ` + `${realSize}. This source was built for ${byteLength} bytes, so the range it was ` + 'asked for does not exist. The Range header was honoured exactly; the length is ' + 'what is wrong. Next: drop options.byteLength and let edfcore probe for the size, ' + "or check the origin's Content-Length — a stale or proxied HEAD is the usual cause.", { offset, requestedLength: length, receivedLength: received }, ); } throw new EdfSourceError( `Reading bytes ${offset}..${expectedLast} of ${href}: the server answered 206 but its ` + `Content-Range says it sent bytes ${claimed.first}..${claimed.last} — a different ` + 'part of the resource. Serving these as the bytes that were asked for would put the ' + 'wrong samples at the right timestamps. Next: this is usually a cache or CDN keyed on ' + 'the URL without the Range header; bypass it, or vary on Range.', { offset, requestedLength: length, receivedLength: received }, ); } const bytes = new Uint8Array(await response.arrayBuffer()); return assertExactRead(bytes, offset, length); } if (response.status === HTTP_OK) { if (options?.allowFullDownload !== true) throw rangeIgnoredError(href, offset, length); if (fullBody !== undefined) return sliceFullBody(fullBody, offset, length, href, byteLength); // A read that raced us to the same discovery already owns the transfer. Abandon this // response body unread and take theirs — either copy is the same resource. if (fullBodyInflight === undefined) { fullBodyInflight = response .arrayBuffer() .then((buffer) => { const body = new Uint8Array(buffer); fullBody = body; return body; }) .catch((error: unknown) => { // A failed transfer must not poison every later read with a rejected promise. fullBodyInflight = undefined; throw error; }); } return sliceFullBody(await fullBodyInflight, offset, length, href, byteLength); } throw new EdfSourceError( `Reading bytes ${offset}..${offset + length - 1} of ${href} failed: the server ` + `answered HTTP ${response.status}. Next: check the URL, its authentication headers ` + 'and whether a signed URL has expired.', { offset, requestedLength: length }, ); } return { byteLength, async read(offset: number, length: number, readOptions?: ReadOptions): Promise { // The effective signal, not just the per-read one. A source-level signal is documented as // "the default for every request", and honouring it only inside `attachSignal` meant it // worked for a real AbortSignal and was a silent no-op for the published // `AbortSignalLike` shim, which has no addEventListener to attach to. // Its own, because this is the one adapter that resolves the effective signal itself rather // than going through `throwIfAborted`. assertReadOptions(readOptions); const signal = readOptions?.signal ?? options?.signal; throwIfSignalAborted(signal); assertReadRange(offset, length, byteLength); if (length === 0) return new Uint8Array(0); if (fullBody !== undefined) return sliceFullBody(fullBody, offset, length, href, byteLength); const bytes = await fetchRange(offset, length, readOptions); throwIfSignalAborted(signal); return bytes; }, }; } /** * A read served from the buffered whole body. * * `slice`, not `subarray`: the body is retained state and the caller owns its result, so a view * into it would let one reader's write change what the next reader sees. * * The overrun is diagnosed HERE rather than left to `assertExactRead`. That guard exists for a * source the CALLER wrote — its message ends "make read() loop until `length` bytes have arrived" * — and on this path the source is edfcore's own `httpSource`: there is no loop to write, and no * number of retries produces bytes the resource does not contain. One fault, "this source was * built for N bytes and the resource is really M", got the 0.3.37 message over a 206 and that * unactionable one over a 200, decided only by whether the server honoured Range. * * `options.byteLength` with `allowFullDownload` is exactly the pair `data-sources.md` recommends * when the origin is broken, so it is the combination a reader reaches for and the one that * produced the misdirected advice. The real size is in `body.byteLength` at the moment the message * is built — the same "the real size sat unread in the response just rejected" shape 0.3.37 * removed (fixed in 0.3.75). * * `assertExactRead` still backstops the slice. */ function sliceFullBody( body: Uint8Array, offset: number, length: number, href: string, byteLength: number, ): Uint8Array { if (offset + length > body.byteLength) { throw new EdfSourceError( `Reading bytes ${offset}..${offset + length - 1} of ${href}: the whole resource was ` + `buffered and it is ${body.byteLength} bytes, so the range asked for does not exist. ` + `This source was built for ${byteLength} bytes; the length is what is wrong. Next: drop ` + "options.byteLength and let edfcore probe for the size, or check the origin's " + 'Content-Length — a stale or proxied HEAD is the usual cause.', { offset, requestedLength: length, receivedLength: Math.max(0, body.byteLength - offset), }, ); } return assertExactRead(body.slice(offset, offset + length), offset, length); }