Skip to main content

@developmentseed/range-reader

Streamed, coalesced HTTP byte-range reads with a budgeted byte cache.

A client that knows a file's layout, such as a Cloud-Optimized GeoTIFF reader with its tile offset table in hand or a sharded Zarr reader with a shard index in hand, wants many small byte ranges at once. This package merges the reads issued in one task into few range requests, reads each response body as a stream and resolves every read the moment its last byte arrives. It also cancels a request as soon as nobody wants what is left of it. Everything received, including the bytes between the reads a request was merged across, lands in a byte cache with separate budgets for metadata and data bytes, so a later read inside an earlier request is served from memory.

Why merge at all: a browser opens at most six HTTP/1.1 connections per host, and the big object stores (S3, Azure Blob) speak only HTTP/1.1, so a viewport of a hundred tiles fetched one request each waits in a queue six at a time. One request per run of adjacent tiles empties that queue, and it also pays the store's per-request latency and per-request price once per run instead of once per tile. Over HTTP/2 (GCS, CDN fronts such as CloudFront) the queue is gone, but the per-request latency and price remain, and a server sharing one connection across a hundred streams finishes every tile late rather than in order.

Usage​

import {
fileIdentity,
PerOriginSemaphore,
RangeReader,
} from "@developmentseed/range-reader";

const reader = new RangeReader();
// Six matches the browser's HTTP/1.1 connections per host, so requests wait
// here, where priority and cancellation apply, not inside the browser.
const limiter = new PerOriginSemaphore({ maxRequests: 6 });

// One identity per file, built once: the URL plus the request parameters
// (headers, credentials, mode) it is read with.
const file = fileIdentity("https://example.com/scene.tif", {
headers: { authorization: "Bearer ..." },
});

// Reads issued in the same task are merged into as few range requests as
// the gap and over-fetch limits allow. Each resolves as its bytes arrive.
const [a, b] = await Promise.all([
reader.read(file, { offset: 1_048_576, length: 65_536 }, { limiter }),
reader.read(file, { offset: 1_114_112, length: 65_536 }, { limiter }),
]);

if (a.kind === "bytes") {
console.log(a.bytes.length); // 65536
}
console.log(reader.stats); // requests, bytes, cache hits, ...

A read whose signal aborts rejects at once with the signal's reason; the request it was part of runs on while another read still wants it, and is cancelled, keeping what arrived, when the last one leaves.

Options​

OptionDefaultMeaning
fetchglobalThis.fetchTransport. Replace it for custom auth or in tests.
cachein-memory, 32 MiB metadata + 64 MiB dataBudgets for the default cache, or a ByteCache of your own.
maxGap"median"Largest gap between two reads that still share a request; "median" is the batch's median read length.
maxOverfetchBytes1 MiBLargest number of bytes in one request that no read covers.
maxRequestBytes1 MiBLargest merged request; also the largest whole object accepted from a server that ignores Range.
minMetadataReadBytes64 KiBMetadata reads shorter than this are widened to an aligned block of this size, read to its end.
cacheModethe browser's defaultRequestInit.cache for every request.

A read may name its purpose: "data", the default, for payload such as tiles and chunks, or "metadata" for the structural bytes a format reads before it can ask for data and that every later read depends on: a COG's IFDs and offset tables, a sharded Zarr shard's index, a PMTiles directory. Metadata reads are widened to aligned blocks, merged separately, and counted against their own cache budget, so data churn never evicts them. A read is served from any cached span that contains it, and attaches to any request in flight that covers it, whichever purpose fetched it: the first tiles of a COG often arrive inside its first metadata block.

Bring your own cache​

The byte cache is an interface. The default keeps spans in memory under two budgets; a disk-backed one (OPFS, Cache Storage) implements the same two methods, plus the optional byteSize:

interface ByteCache {
get(file: FileIdentity, range: ByteRange, purpose: Purpose): Promise<Uint8Array | undefined>;
put(file: FileIdentity, offset: number, bytes: Uint8Array, purpose: Purpose): Promise<void>;
byteSize?(purpose: Purpose): number;
delete?(file: FileIdentity): Promise<void>;
}

get answers with the bytes of range when one stored span contains all of it. In-flight requests and the not-found outcome stay in the reader; a cache only ever holds bytes.

Objects replaced in place​

The reader remembers each file's validator, its ETag or, failing that, its Last-Modified, from the latest response. A response that carries a different one means the object was replaced: the reader drops the file's cached spans (delete on the cache) before storing the new bytes, so the cache never holds two versions of one object, and counts the change in stats.validatorChanges. Every result carries the validator it came from, so a caller that parsed a file's layout can fail its own reads when the layout is stale, as @developmentseed/geotiff does. The reader never sends If-Match itself: a conditional header forces a CORS preflight on every request.

Status​

@developmentseed/geotiff reads every COG through the range reader, and the deck.gl COG layers share one reader by default.

Zarr reads don't go through it yet: see #855.

Classes​

Interfaces​

Type Aliases​

Variables​

Functions​