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 imports one Node built-in, node:fs/promises, and the universal entry cannot reach it. Keeping every such import out of that graph is what lets the universal entry point be bundled for a browser with no polyfill and no resolution alias. The bin program (dist/cli.js) imports node:fs/promises and node:process, and no import path from edfcore reaches it. 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);
A failure from the client itself reaches you as the client threw it. The HEAD probe’s rejection is swallowed — a blocked or forbidden HEAD is ordinary, and the range probe below it is the next route — but the range probe’s is not, because there is no route after it: a DNS failure, a CORS rejection or a TypeError: Failed to parse URL arrives unwrapped, and isEdfError is false for it. That is deliberate, and tests/io/http-length.test.ts pins it.
It has one consequence worth stating. httpSource does not check that the address is one a client can reach, only that an address arrived — so a relative or scheme-less address fails inside fetch rather than in edfcore, with no Next: clause and no mention of this function. In a browser a relative URL resolves against the document and is perfectly good; outside one there is no page for it to resolve against. Pass an absolute URL, or options.fetch for a client that resolves the one you have.
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 of file bytes in edfcore: opt-in, visible at the call site, and removed by deleting one wrapper from the expression that built the source. It is not the only thing in the package that remembers — the record index memoises decoded record onsets, which is what makes a second index.locate() near the first almost free, and no wrapper controls that. Large files covers the pair.
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, refused below 1, and clamped down to maxBytes, since a block wider than the whole budget evicts itself on every insert. Below 1 used to be clamped UP to a single byte, which made a 512-byte read issue 512 requests — more than not caching at all (changed in 0.6.187). |
maxBytes |
number |
67108864 (64 MiB) |
LRU budget. Floored, never below 0. |
The options argument itself has to be an object. CacheOptions is two byte counts and nothing else, so cachedSource(source, 4 * 1024 * 1024) is what gets written when the intent is a four-megabyte budget — and until 0.6.140 it had neither field, both defaults applied, and the wrapper held up to 64 MiB from the one call a caller reaches for to bound memory. A bare number is now refused; undefined and null still mean “no options”.
Blocks are byte-aligned, not record-aligned. This module is handed byte ranges and never parses a header, so it has no record size to align to. It does serve the header read — that is how opening a file leaves the first block resident. 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<ClosableByteSource>
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 yet; Symbol.asyncDispose is not Baseline yet.
Not closing is no longer a warning. Node 26 turns a FileHandle collected while still open into an uncaught ERR_INVALID_STATE, where earlier versions printed a deprecation notice — so a loop over a directory of recordings that never closes will bring the process down, at whatever moment the collector happens to run. Every snippet on this site that opens a fileSource closes it, and a test refuses one that does not.
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,
): ClosableByteSource
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?.();
ClosableByteSource
interface ClosableByteSource extends ByteSource {
close(): Promise<void>;
}
What fileSource and fileHandleSource return. close is optional on ByteSource because most
sources own nothing — byteSource holds an array you already had, blobSource holds a Blob — and
a source over a file descriptor is not like that. Until 0.6.17 both were declared as returning a
plain ByteSource, so await source.close(), the line this page tells you to write, was an
invocation of a possibly-undefined member and failed to compile under strictNullChecks. Every
snippet on this site that opened a file went on to not close it, which was not a coincidence.
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);
// `?? null` rather than `signal`: `RequestInit.signal` is `AbortSignal | null`, and under
// `exactOptionalPropertyTypes` an `undefined` is not assignable to it.
return fetch(url, { ...init, signal: signal ?? null });
};
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.