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:

  1. options.byteLength, if you passed it. No network request at all.
  2. A HEAD request, using its Content-Length. A rejected or forbidden HEAD is common (CORS, some object stores) and falls through to the next step.
  3. A GET with Range: bytes=0-0, using the total from Content-Range.

Three behaviours are load-bearing:

  • A byte range is inclusive at both ends. bytes=0-0 is one byte. edfcore emits bytes=${offset}-${offset + length - 1}.
  • A 200 OK answer to a Range request means the server ignored the header and is sending the whole resource. That throws EdfSourceError by default, rather than buffering gigabytes because a CDN is misconfigured. Pass allowFullDownload: true to 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. cachedSource is for httpSource, and for a fileSource on 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.openAsBlob is never used internally, however tempting blobSource(await openAsBlob(path)) looks. It reports size modulo 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. Use fileSource.

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.