/** * The `ByteSource` contract guard. * * Layer 5, and the only file in `io/` that imports nothing from `io/`: every adapter here calls * into this one and it calls into none of them. Nothing here knows anything about EDF. * * It said "the only file in `io/` that imports an error class" until 0.6.58, which `bytes.ts`, * `http.ts` and `read.ts` each disprove by raising one of their own. What is true is the * dependency direction, and it is the load-bearing half: a guard the adapters could not all * reach would be a contract checked in some of them. * * The contract is one sentence — a read resolves with EXACTLY `length` bytes or rejects, it * never pads and never truncates — and it is *checked on every call*, including calls into a * source the caller wrote. A source that quietly returns a short buffer is indistinguishable * from a truncated file, so without this guard the parser would confidently report the wrong * cause for the wrong thing. */ import { isByteArray } from '../bytes/latin1.js'; import { EdfSourceError } from '../errors.js'; import { describeValue } from '../text/describe.js'; import type { AbortSignalLike, ByteSource, ReadOptions } from '../types.js'; /** * The byte count of a genuine byte array, or `undefined` for anything else — which is why * `EdfSourceError.receivedLength` is `number | undefined` rather than `number`. * * Reading `.length` off the value was not enough, even though the message it feeds already * promised to detect "a value that is not a byte array". A `string`, a `number[]` and a * `{ length }` object all satisfied it and then threw an unrelated `TypeError` deeper in — noisy, * but at least loud. The case that was not loud is a typed-array view of the WRONG signedness: * `Int8Array` has one byte per element, so it passes a length check, and `decodeInt16` then * sign-extends an already-signed element a second time. A file holding `[-32768, -1, 200, 32767]` * decodes as `[-98304, -65537, -65592, -65537]` with no error anywhere — fabricated microvolts. * * The test is on the built-in tag, not on `instanceof` and not on `BYTES_PER_ELEMENT`. * `instanceof Uint8Array` is false across a realm boundary, and a `Uint8Array` from a worker or an * iframe is a perfectly good byte array — the same reason `io/bytes.ts` reaches for * `ArrayBuffer.isView`. `BYTES_PER_ELEMENT === 1` looks like the right test and is not: `Int8Array` * has one byte per element too, so it is exactly the dangerous case that check would admit. * * `Object.prototype.toString` reads `Symbol.toStringTag` off the TypedArray prototype, which every * realm agrees on. It admits `Uint8Array` — including Node's `Buffer`, a subclass that inherits the * tag — and `Uint8ClampedArray`, and rejects `Int8Array`, every wider view, and `DataView`. * * The test itself lives in `bytes/latin1.ts` at Layer 0, which is the lowest module that needs it; * this re-export is what `byteSource` and the rest of `io/` reach it by (0.6.121). */ export { isByteArray }; function receivedLengthOf(received: unknown): number | undefined { return isByteArray(received) ? received.byteLength : undefined; } /** * Enforces the exact-length contract and returns the value unchanged so it can wrap a read * expression directly. */ export function assertExactRead(received: Uint8Array, offset: number, length: number): Uint8Array { const receivedLength = receivedLengthOf(received); if (receivedLength === length) return received; const got = receivedLength === undefined ? 'a value that is not a byte array' : `${receivedLength} bytes`; throw new EdfSourceError( `ByteSource.read(offset ${offset}, length ${length}) resolved with ${got}. A ByteSource ` + 'must resolve with exactly the requested number of bytes or reject: padding or ' + 'truncating makes a short read indistinguishable from a truncated file. Next: make ' + 'read() loop until `length` bytes have arrived, and reject if they never do.', { offset, requestedLength: length, receivedLength }, ); } /** * Validates a requested range against the source length, with the real numbers in the message. * * Offsets are plain JS numbers throughout edfcore — exact to 2^53 — so this checks * safe-integer-ness rather than truncating with `| 0`, which silently wraps past 2 GiB. */ export function assertReadRange(offset: number, length: number, byteLength: number): void { // `describeValue`, not `${offset}`. A string interpolates as its digits, so a `'0'` out of a // query parameter or a JSON range was refused with "was given offset 0, which is not a // non-negative safe integer" — naming a rule that 0 satisfies, about the first byte of the file. // The same sentence about the same value, printed two ways, is the defect 0.6.114 fixed for // `maxMaterializeBytes` and 0.6.131 for a record index; these are the two numbers every read in // the package passes through. if (!Number.isSafeInteger(offset) || offset < 0) { throw new EdfSourceError( `ByteSource.read was given ${describeValue(offset)} as its offset, which is not a ` + 'non-negative safe integer. ' + 'Next: pass a plain integer byte offset; edfcore never truncates offsets to 32 bits.', { offset, requestedLength: length }, ); } if (!Number.isSafeInteger(length) || length < 0) { throw new EdfSourceError( `ByteSource.read was given ${describeValue(length)} as its length, which is not a ` + 'non-negative safe integer. Next: pass a plain integer byte count.', { offset, requestedLength: length }, ); } if (offset + length > byteLength) { throw new EdfSourceError( `ByteSource.read(offset ${offset}, length ${length}) ends at byte ${offset + length}, ` + `past the end of a ${byteLength}-byte source. Next: clamp the request, or check that ` + 'the source was built over the whole file rather than a prefix of it.', { offset, requestedLength: length }, ); } } /** * Aborts a read when the caller's signal is already aborted. * * `DOMException` cannot be named without the DOM lib, and the thing consumers actually branch * on is `error.name === 'AbortError'`, so that is what this produces. */ export function throwIfAborted(options?: ReadOptions): void { assertReadOptions(options); throwIfSignalAborted(options?.signal); } /** * The SIGNAL passed as the options rather than as the field on them. * * 0.6.130, 0.6.140 and 0.6.154 each refused a bare value where an options object belongs, and the * argument is always the same: every option here is a field on an object, so the value a caller * means is the option. This is that mistake made with an OBJECT, so none of those guards can see * it — `typeof options === 'object'` is true of an `AbortSignal`. * * It is also the likeliest spelling of it. The field is named `signal`, the thing a caller holds * is named `signal`, and `fetch(url, signal)` for `fetch(url, { signal })` is a mistake this * ecosystem makes constantly. `readWindow(recording, selection, controller.signal)` reads as * correct at the call site. * * `options?.signal` was then `undefined`, so the read ran to completion and RESOLVED WITH DATA. * Nothing distinguishes that from a read that finished before the abort, which is the ordinary * outcome a caller is already handling — so a viewer that cancels on every scroll cancelled * nothing, and neither the reads nor their memory stopped. * * Named by shape, not by class, for the reason `bytes.ts` gives: `instanceof` is false across a * realm boundary, and `AbortSignalLike` is published as `aborted` and nothing more, so the shim a * consumer writes has to be recognised too. */ export function assertReadOptions(options: unknown): void { if (typeof (options as { aborted?: unknown } | null | undefined)?.aborted === 'boolean') { throw new RangeError( 'the read options are an AbortSignal, not an object carrying one — signal is a field on ' + 'the options, so this read ignored the cancellation it was given and would have resolved ' + 'with data. Next: pass the signal as options.signal.', ); } /* * And a bare value, which 0.6.155 walked past because it was looking for an object. * * That guard answers the mistake made WITH an object. This is the plain one the rest of the * package has been closing since 0.6.130 — `cachedSource(source, 4 * 1024 * 1024)`, * `openEdf(source, true)`, `httpSource(url, token)`, `validateRecording(recording, true)`, * `formatHeader(header, true)` — and the read options are where the number a caller writes is * likeliest to be a byte count, because `maxMaterializeBytes` is one. * * `readRecords(recording, selection, 64 * 1024 * 1024)` took the 256 MiB default instead, on the * one option whose job is to refuse an allocation before it is attempted, and any `signal` a * caller meant went with it: the read was neither bounded nor cancellable, and it resolved. */ if (options !== undefined && options !== null && typeof options !== 'object') { throw new RangeError( `the read options are ${describeValue(options)}, not an object — maxMaterializeBytes and ` + 'signal are fields on one, so this read took the default budget and no cancellation. ' + 'Next: pass maxMaterializeBytes on an options object.', ); } } /** * The same check against a signal that was resolved by the caller. * * A source can carry its own signal as well as the one passed per read, and the effective signal * is `readOptions?.signal ?? sourceOptions?.signal`. That cannot be handed back to * `throwIfAborted` as an object literal, because `exactOptionalPropertyTypes` refuses * `{ signal: undefined }` where the property is declared optional. */ export function throwIfSignalAborted(signal?: AbortSignalLike): void { /* * That it IS a signal, which `aborted !== true` never asked. * * `AbortController` is the value this option is most often given, because it is the object a * caller holds: `{ signal: controller }` for `{ signal: controller.signal }` is the same slip * `fetch(url, signal)` is, one field in. A controller has `abort()` and `signal` and no `aborted`, * so the comparison was false, the read ran to completion and RESOLVED WITH DATA — which is * verbatim the failure 0.6.155 describes for the signal passed as the whole options object: * "nothing distinguishes that from a read that finished before the abort... so a viewer that * cancels on every scroll cancelled nothing, and neither the reads nor their memory stopped". * * That release caught the argument one level out. This is the field itself, and every other shape * with it: a string, a number, `{}`, and `{ aborted: 'yes' }` out of a JSON config all meant "not * cancelled" and could never mean anything else. * * `AbortSignalLike` is published as `aborted` and nothing more — `bytes.ts` gives the reason it is * named by shape rather than by class — so a boolean `aborted` is the whole test, and the shim a * consumer writes still passes. */ if (signal !== undefined && signal !== null && typeof signal.aborted !== 'boolean') { const controller = typeof (signal as { abort?: unknown }).abort === 'function'; throw new RangeError( `options.signal is ${ controller ? 'an AbortController, not the signal on it — a controller has no `aborted`' : `${describeValue(signal)}, which carries no \`aborted\`` }, so this read could never be cancelled and would have resolved with data. Next: pass ` + 'controller.signal, or any object with a boolean aborted on it.', ); } if (signal?.aborted !== true) return; const error = new Error('The read was aborted through options.signal.'); error.name = 'AbortError'; throw error; } /** * What the caller passed, and the adapter that would have turned it into a `ByteSource`. * * Named by shape rather than by class, for the reason `bytes.ts` gives: `instanceof` is false * across a realm boundary, and a `File` from an iframe is still a `File`. */ function adapterFor(source: unknown): string { if ( ArrayBuffer.isView(source) || Object.prototype.toString.call(source) === '[object ArrayBuffer]' ) { return 'wrap them with byteSource(bytes)'; } if (typeof source === 'string') { return ( 'that looks like a path — use fileSource(path) from "edfcore/node", or read the bytes ' + 'yourself and pass byteSource(bytes)' ); } const candidate = source as { size?: unknown; arrayBuffer?: unknown } | null; if ( typeof candidate?.size === 'number' && typeof (candidate as { arrayBuffer?: unknown }).arrayBuffer === 'function' ) { return 'that looks like a Blob or a File — use blobSource(file)'; } /* * The RECORDING, which carries the source on `.source` rather than being it. * * No adapter turns one into a `ByteSource` — it already holds the one it was opened from — so * this reader is the only one the generic list has nothing for: four adapters for bytes, a path, * a File and a URL, to someone holding none of them and an open file instead. * * `inspectEdf(source)` is where they meet it. It is the one call in the convenience layer whose * first argument is a source, and it sits in the barrel beside `readWindow`, `readRecords`, * `readAnnotations`, `readEnvelope` and `readTriggers` — every one of which takes the recording. * `readRecordBytes(recording.source, recording.header, records)` is the other route: the idiom * `physical-values.md` and `reading-signals.md` both write out, with one field left off the * argument that needs it and kept on the one beside it. * * `assertRecording` has named the mirror of this since 0.6.90 — "that is a header, not a * recording — a recording also carries the source, the timeline and the index" — because every * primitive takes the header and the shape is easy to reach for. This is that confusion pointed * the other way, and it is the one direction the package answered with a list. */ /* * A FORGOTTEN AWAIT, and the one whose advice named the call that produced it. * * `fileSource(path)` is the only async adapter in the package — it opens the file to learn its * size — and `openEdf(fileSource("recording.edf"))` is the Node quickstart with one keyword * missing. The list below answered it with `fileSource(path) from "edfcore/node" for a file`, * which is the call the reader had just made. 0.6.215 named that shape for the index guard: * advice a reader follows and arrives back where they started. * * Four published entry points take a source — `openEdf`, `readHeader`, `readRecordBytes`, * `inspectEdf` — and `cachedSource` wraps one, so all five said it. * * The other adapters are synchronous, which is why the keyword is worth naming rather than just * the shape: there is exactly one call in this package whose result needs awaiting here. */ if (typeof (source as { then?: unknown } | null | undefined)?.then === 'function') { return ( 'that is a pending Promise — fileSource(path) from "edfcore/node" is async, so it is the ' + 'one adapter whose result needs awaiting; byteSource, blobSource and httpSource are not' ); } const opened = source as { source?: { read?: unknown }; header?: unknown } | null | undefined; if (typeof opened?.source?.read === 'function' && typeof opened.header === 'object') { return 'that is a recording — it carries the source on .source rather than being one, so pass recording.source'; } return ( 'use byteSource(bytes) for bytes in memory, fileSource(path) from "edfcore/node" for a file, ' + 'blobSource(file) for a File, or httpSource(url) for a URL' ); } /** * `Uint8Array`, `ArrayBuffer`, `string`, `nothing`. Never the value: it could be anything. * * No article, and `byteSource`'s own refusal is phrased the same way — "received Int8Array". An * article needs to know that `Uint8Array` is said "yoo-int", which no rule about vowels gets * right, and getting it wrong is the kind of thing a reader notices instead of the message. */ function describeGiven(source: unknown): string { if (source === null) return 'null'; if (source === undefined) return 'nothing'; // The built-in tag, not `instanceof`: it is the same across realms, which is the reason // `bytes.ts` uses it too. return typeof source === 'object' || typeof source === 'function' ? Object.prototype.toString.call(source).slice(8, -1) : typeof source; } /** * The source is a `ByteSource` before anything reads from it. * * This is the first argument of the first call, and passing the bytes themselves — `openEdf(bytes)` * rather than `openEdf(byteSource(bytes))` — is the likeliest mistake anyone makes with this * library. Until 0.4.444 it produced `TypeError: source.read is not a function`, which names * neither edfcore, nor the adapter that was missing, nor the one word that fixes it; `undefined` * produced a `TypeError` about a property of undefined instead, from a different line. * * `byteSource` itself has refused a wrong argument by name since the beginning, and says what to * pass instead. This is the same courtesy one call earlier, where more people meet it. * * Checked structurally — a `read` function and a numeric `byteLength` — because a `ByteSource` is * an interface a caller may implement, and `api-sources.md` documents writing one. */ export function assertByteSource(source: unknown): asserts source is ByteSource { const candidate = source as { read?: unknown; byteLength?: unknown } | null | undefined; if (typeof candidate?.read === 'function' && typeof candidate.byteLength === 'number') { /* * And that the number is a BYTE COUNT, which "typeof number" is not. * * 0.6.85 made this exact argument for `fileHandleSource` and recorded what a bad one costs: "a * `NaN` or an absent `byteLength` disabled the range guard rather than failing: `assertReadRange` * compares against it, every comparison against `NaN` is false", and "the failure then surfaced * in `parseHeader` — which does guard it — blaming a caller who passed it the right arguments". * * That fix went into one adapter. This is the boundary every source crosses, and the one shape * no adapter can cover: a `ByteSource` the CALLER wrote, which `api-sources.md` documents * writing. A `NaN` from a `Content-Length` header, a `-1` from a stat that failed, a fractional * size from a division — each built a source, and the first read then reported * `ByteSource.read(offset 0, length NaN) resolved with 0 bytes. A ByteSource must resolve with * exactly the requested number of bytes or reject`. That accuses the caller's `read()` of * breaking its contract when it answered correctly for the length edfcore computed and handed * it. */ if (Number.isSafeInteger(candidate.byteLength) && (candidate.byteLength as number) >= 0) return; /* * A plain `RangeError`, not an `EdfSourceError`, and that is the load-bearing half. * `inspect-rethrows-caller-bugs.test.ts` pins the rule for exactly this class: `inspectEdf` * turns an `EdfError` into a diagnostic about the FILE, so a mistake in the arguments has to * stay outside the family or a caller's triage absorbs it. The bytes here are usually a * perfectly good recording. */ throw new RangeError( `source.byteLength must be a non-negative safe integer, and this ByteSource advertises ` + `${describeValue(candidate.byteLength)}. Every offset and length edfcore computes is ` + 'measured against it, so the first read asked for a range derived from it and then blamed ' + 'read() for the answer. Next: give the source the real size of the file in bytes.', ); } const received = describeGiven(source); throw new EdfSourceError( `a ByteSource is needed — an object with a byteLength and a read() — and received ` + `${received}. Next: ${adapterFor(source)}.`, { offset: 0, requestedLength: 0 }, ); }