API — sources
edfcore never opens a file itself. Everything it reads arrives through one three-member interface, which is why the same code path serves memory, disk, a dropped File and an HTTP URL.
import { byteSource, blobSource, httpSource, cachedSource } from 'edfcore';
import { fileSource, fileHandleSource } from 'edfcore/node';
edfcore/node is the only module in the package that imports a Node built-in (node:fs/promises, and nothing else). Keeping that import in exactly one file is what lets the universal entry point be bundled for a browser with no polyfill and no resolution alias. A packaging test walks the whole module graph reachable from edfcore and checks that no node: specifier hides in it.
ByteSource
interface ByteSource {
readonly byteLength: number;
read(offset: number, length: number, options?: ReadOptions): Promise<Uint8Array>;
close?(): Promise<void> | void;
}
A random-access byte range reader. That’s the entire contract, and every entry point in edfcore that reads a recording takes one.
| Member | Meaning |
|---|---|
byteLength |
The size of the whole resource, in bytes. Must be known before any read. parseHeader needs it to recover a -1 record count and to detect truncation. |
read(offset, length, options?) |
Resolves with exactly length bytes, or rejects. Never pads, never truncates. The returned array is owned by the caller. |
close?() |
Optional. Releases whatever the source holds. edfcore never calls it for you. |
The exact-length rule
A read resolves with exactly length bytes or it rejects. edfcore verifies this on every call, including calls into a source you wrote yourself, and a violation throws EdfSourceError carrying offset, requestedLength and receivedLength.
A source that returns a short buffer is indistinguishable from a truncated file. Without the check, a network mount that hiccupped shows up as PARTIAL_FINAL_RECORD on an otherwise good file. If your read can return early, loop until the bytes have arrived and reject if they never do.
Ownership
The array a read resolves with belongs to the caller. A caching implementation must therefore hand back a copy, not a view into a block it retains. Otherwise a caller that writes into the result corrupts what the next reader sees.
byteSource is the exception. It hands back a subarray of the caller’s own buffer, so it retains nothing the caller doesn’t already hold.
Writing your own
import type { ByteSource, ReadOptions } from 'edfcore';
function sliceSource(inner: ByteSource, start: number, length: number): ByteSource {
return {
byteLength: length,
async read(offset: number, count: number, options?: ReadOptions): Promise<Uint8Array> {
if (offset < 0 || count < 0 || offset + count > length) {
throw new RangeError(`read(${offset}, ${count}) is outside this ${length}-byte slice`);
}
return inner.read(start + offset, count, options);
},
close: () => inner.close?.(),
};
}
Two things to respect. Offsets are plain JavaScript numbers throughout edfcore, exact to 2^53. Never truncate one with | 0, which wraps past 2 GiB and turns a 13 GB BDF into nonsense. And honour options.signal if you can: edfcore polls it around its own reads, but only your source knows how to cancel a request in flight.
byteSource
function byteSource(bytes: ArrayBuffer | Uint8Array): ByteSource
The in-memory adapter. Zero-copy by construction: a read hands back a subarray view over the buffer you passed in.
A Uint8Array given with a non-zero byteOffset over a larger buffer is respected. subarray is relative to the view, so there’s no offset arithmetic to get wrong. An ArrayBuffer is wrapped in a fresh view. The returned source has no close.
Detection uses ArrayBuffer.isView rather than instanceof Uint8Array, because instanceof is false for a view that crossed a realm boundary, and this is a public entry point.
import { byteSource, openEdf } from 'edfcore';
const response = await fetch('/small.edf');
const source = byteSource(await response.arrayBuffer());
const recording = await openEdf(source);
Use this for files small enough to hold entirely (a few tens of megabytes). For anything larger, httpSource or fileSource read only what you ask for.
blobSource
function blobSource(blob: BlobLike): ByteSource
The Blob and File adapter. Use it for a file the user picked from <input type="file"> or dropped onto the page. The browser reads ranges off disk, so a 2 GB recording never enters memory.
byteLength comes from blob.size. A zero-length read short-circuits to an empty array. Blob.slice takes an exclusive end, unlike an HTTP byte range, and this adapter handles that difference so you never see it.
A Blob read is one of the few places where the platform can legitimately hand back fewer bytes than asked. One case is a File whose backing file changed on disk since the picker ran. The exact-length contract is verified rather than assumed there, and a short read throws EdfSourceError.
import { blobSource, openEdf } from 'edfcore';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (file === undefined) return;
const recording = await openEdf(blobSource(file));
console.log(recording.header.signals.map((s) => s.label));
});
The DOM Blob type is never named in edfcore’s own types; BlobLike is a structural shim a real File satisfies.
httpSource
function httpSource(
url: string | { readonly href: string },
options?: HttpSourceOptions,
): Promise<ByteSource>
Turns a URL into random access over HTTP Range requests, without downloading the recording. This is the adapter for opening a 13 GiB BDF in a browser tab. It accepts a URL object as well as a string, because URL structurally satisfies { href: string }.
It’s async because the source length must be known before any read. Three ways are tried, cheapest first, and failing to find one is fatal:
options.byteLength, if you passed it. No network request at all.- A
HEADrequest, using itsContent-Length. A rejected or forbiddenHEADis common (CORS, some object stores) and falls through to the next step. - A
GETwithRange: bytes=0-0, using the total fromContent-Range.
Three behaviours are load-bearing:
- A byte range is inclusive at both ends.
bytes=0-0is one byte. edfcore emitsbytes=${offset}-${offset + length - 1}. - A
200 OKanswer to a Range request means the server ignored the header and is sending the whole resource. That throwsEdfSourceErrorby default, rather than buffering gigabytes because a CDN is misconfigured. PassallowFullDownload: trueto accept it. The body is then buffered once, and every subsequent read is served from memory. - Concurrency is bounded by a semaphore. A released slot is handed straight to the next waiter rather than returned to a pool, so the in-flight count can never overshoot the limit.
Any other non-2xx status throws EdfSourceError naming the status and the range.
import { httpSource, openEdf } from 'edfcore';
const source = await httpSource('https://data.example.org/night.bdf', {
headers: { Authorization: `Bearer ${token}` },
maxConcurrency: 6,
});
const recording = await openEdf(source);
Opening an EDF+ file over that source issues five requests in total. One HEAD for the length, then bytes=0-255 and one more range covering the rest of the header. Then one whole data record at each end of the file, for the timekeeping probes. Every header you passed goes on all five. Wrap the result in cachedSource if you expect overlapping reads.
HttpSourceOptions
Extends ReadOptions, so signal and maxMaterializeBytes are available too.
| Field | Type | Default | Meaning |
|---|---|---|---|
fetch |
FetchLike |
globalThis.fetch |
The implementation to use. Throws EdfSourceError at construction when neither is available. |
headers |
Readonly<Record<string, string>> |
{} |
Sent on every request, including the HEAD and the length probe. This is where authentication goes. Range is added by edfcore on top. |
byteLength |
number |
probed | The resource size, when you already know it. Skips both probes entirely. Must be a non-negative safe integer or construction throws. |
maxConcurrency |
number |
4 |
In-flight requests. Floored, and never below 1. Small enough to stay easy on a shared origin, large enough to hide latency. |
allowFullDownload |
boolean |
false |
Accept a 200 answer to a Range request and buffer the whole resource. |
signal |
AbortSignalLike |
none | From ReadOptions. Used as the default for every request; a signal passed to an individual read wins over it. |
cachedSource
function cachedSource(source: ByteSource, options?: CacheOptions): ByteSource
A block-aligned LRU over any other ByteSource. This is the only cache in edfcore: opt-in, visible at the call site, and removed by deleting one wrapper from the expression that built the source.
Two properties matter more than the hit rate. A read returns a copy, never a view into a retained block, so a caller who writes into the result can’t corrupt what the next reader sees. And concurrent reads wanting the same block issue one underlying read. Over HTTP that is one request instead of eight.
A read wider than the entire budget bypasses the cache and goes straight to the source, because it would evict every block on its way through. close() clears the cache and delegates to the wrapped source’s close, if it has one.
import { httpSource, cachedSource } from 'edfcore';
const remote = await httpSource('https://data.example.org/night.bdf');
const cached = cachedSource(remote, { blockBytes: 4096, maxBytes: 1024 * 1024 });
await cached.read(0, 256); // one 4096-byte request to the origin
await cached.read(256, 256); // served from that block — no request
await cached.read(0, 100); // served from that block — no request
CacheOptions
| Field | Type | Default | Meaning |
|---|---|---|---|
blockBytes |
number |
1048576 (1 MiB) |
Block size. Floored, never below 1, and clamped down to maxBytes, since a block wider than the whole budget evicts itself on every insert. |
maxBytes |
number |
67108864 (64 MiB) |
LRU budget. Floored, never below 0. |
Blocks are byte-aligned, not record-aligned. This module never sees a header, so there’s no record size for it to align to. To make block boundaries fall on record boundaries, round blockBytes to a multiple of header.recordByteLength yourself.
Note Caching a source that is already in memory buys nothing and costs a copy per read.
cachedSourceis forhttpSource, and for afileSourceon a slow or networked mount.
fileSource
function fileSource(path: string): Promise<ByteSource>
From edfcore/node. Opens a file for reading and exposes it as a ByteSource.
The size comes from the open handle rather than from a separate stat(path) call. The size and the bytes therefore describe the same file even if the path is replaced between the two, which happens on a rotating log or an rsync target. When the operating system reports a size that is not a safe non-negative integer, fileSource throws EdfSourceError. Its message tells you to check that the path names a regular file rather than a directory, a pipe or a device.
The handle is closed if anything goes wrong before it has an owner. After that, closing is yours. Call source.close() when you’re done. edfcore has no other lifetime mechanism in 0.1; Symbol.asyncDispose is not Baseline yet.
import { fileSource } from 'edfcore/node';
import { openEdf, readWindow } from 'edfcore';
const source = await fileSource('/data/night.edf');
try {
const recording = await openEdf(source);
const chunks = await readWindow(recording, {
startSeconds: 0,
durationSeconds: 30,
signalIndices: recording.header.dataSignalIndices,
});
} finally {
await source.close?.();
}
Warning
fs.openAsBlobis never used internally, however temptingblobSource(await openAsBlob(path))looks. It reportssizemodulo 2^32 and yields zeros above 4 GiB, which turns a 13 GB BDF into a file that reads as silence with no error anywhere. UsefileSource.
fileHandleSource
function fileHandleSource(handle: FileHandleLike, byteLength: number): ByteSource
From edfcore/node. Wraps a file handle you already hold. close() on the returned source closes the handle.
Reads are positional and loop. FileHandle.read is allowed to return fewer bytes than asked (a signal interrupted the syscall, the file lives on a network mount). The loop runs until length bytes have arrived. A genuine end of file falls out as an EdfSourceError naming both counts. The handle’s own file position is never used, so concurrent reads through one handle can’t interleave into each other’s buffers.
byteLength is supplied by you rather than read from the handle. fileSource takes it from the handle it has only now opened. A caller wrapping a handle it already had may know something better: a range it intends to expose, or a size it verified.
import { open } from 'node:fs/promises';
import { fileHandleSource } from 'edfcore/node';
import { readHeader } from 'edfcore';
const handle = await open('/data/night.edf', 'r');
const { size } = await handle.stat();
const source = fileHandleSource(handle, size);
const header = await readHeader(source);
await source.close?.();
FileHandleLike
interface FileHandleLike {
read(
buffer: Uint8Array,
offset: number,
length: number,
position: number,
): Promise<{ bytesRead: number }>;
close(): Promise<void>;
}
A positional reader with a lifetime. A real fs.promises.FileHandle satisfies it structurally, with no cast. It is declared here rather than imported, so @types/node never enters edfcore’s published .d.ts.
Structural platform shims
edfcore compiles with lib: ["ES2022"] and types: []. Neither the DOM nor @types/node leaks into the published .d.ts. A library that names Blob forces every consumer to have lib.dom, and one that names Buffer forces @types/node on every browser build. The platform types edfcore needs are all structural, so it declares the minimum shape it uses and lets the real ones satisfy it.
The real platform types are assignable to these. You never write one by hand except when building a test double.
BlobLike
interface BlobLike {
readonly size: number;
slice(start?: number, end?: number): BlobLike;
arrayBuffer(): Promise<ArrayBuffer>;
}
A DOM Blob and a File both satisfy it. Taken by blobSource.
AbortSignalLike
interface AbortSignalLike {
readonly aborted: boolean;
}
A real AbortSignal satisfies it. Carried by ReadOptions.signal.
edfcore polls .aborted before and after each read and throws an Error whose name is 'AbortError'. DOMException cannot be named without the DOM lib, and error.name is what consumers actually branch on.
HttpResponseLike
interface HttpResponseLike {
readonly status: number;
readonly headers: { get(name: string): string | null };
arrayBuffer(): Promise<ArrayBuffer>;
}
A fetch Response satisfies it. A real Headers lookup is case-insensitive, but a hand-written test double usually is not, so httpSource tries both the given spelling and the lowercase one.
FetchLike
type FetchLike = (
url: string,
init: { headers: Record<string, string>; method?: string },
) => Promise<HttpResponseLike>;
globalThis.fetch is assignable to this.
signal is absent from the init type, and it is still passed at runtime. The reason is parameter contravariance, and there’s no way around it. init is a parameter, so for globalThis.fetch to remain assignable to FetchLike, edfcore’s init type must be assignable to fetch’s own RequestInit. Declaring signal?: AbortSignalLike breaks that, because the shim is not assignable to the real AbortSignal, which has far more members. Declaring signal?: AbortSignal puts the DOM type into the published .d.ts, which is the exact dependency these shims exist to avoid. The type therefore says less than the runtime does.
At runtime the signal is attached to init only when it carries addEventListener, i.e. when it genuinely is an AbortSignal. The platform fetch throws a TypeError on anything else. A caller who passed a bare { aborted } shim is still served by the polls around the request, so cancellation works either way.
If you write your own FetchLike, read signal off init with a cast:
import type { FetchLike } from 'edfcore';
const instrumented: FetchLike = async (url, init) => {
const signal = (init as { signal?: AbortSignal }).signal;
console.log(url, init.headers.Range);
return fetch(url, { ...init, signal });
};
No cast is needed on the way out: a real Response already satisfies HttpResponseLike.
Choosing an adapter
| Situation | Adapter |
|---|---|
| Bytes already in memory | byteSource |
A File from a picker or a drop |
blobSource |
| A path on disk, in Node | fileSource from edfcore/node |
| A handle you already opened | fileHandleSource from edfcore/node |
| A URL whose origin supports Range | httpSource |
| Any of the above, over a slow transport | wrap it in cachedSource |
For the reasoning behind each, and for what a read pattern looks like in practice, see Data sources and Large files.