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:

  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);

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. cachedSource is for httpSource, and for a fileSource on 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.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): 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.