zarrextra Overview
zarrextra is the layer between zarrita and
@spatialdata/core. It holds the things a SpatialData reader needs that are not
specific to SpatialData: opening a store and reading its whole hierarchy up front,
navigating that hierarchy, decoding image codecs zarrita does not ship, and moving
chunk decode off the main thread.
It is published outside the @spatialdata/* namespace because nothing in it knows
what an image or a table is. If you are reading SpatialData stores, you want
@spatialdata/core, which re-exports the parts of this package you are likely to
need. If you are reading some other zarr hierarchy and want the same tree and codec
machinery, you can depend on zarrextra alone.
Versioned alongside the @spatialdata/* packages for now, but able to version
independently in future releases.
npm install zarrextra
# or
pnpm add zarrextra
Opening a store
openExtraConsolidated takes a URL or any zarrita Readable, resolves consolidated
metadata, and reads the entire hierarchy into a tree — every group, every array, and
every node's attributes — before returning.
import { openExtraConsolidated, isErr } from 'zarrextra';
const result = await openExtraConsolidated('https://example.com/store.zarr');
if (isErr(result)) {
console.error(result.error);
} else {
const { zarritaStore, tree } = result.value;
}
It returns a Result, not a thrown error: failing to open a store is an
ordinary outcome for a URL a user typed, and the message is worth handling rather
than catching.
The ConsolidatedStore it resolves to has two halves, and which one you want depends
on whether you need data or only structure:
| Field | What it is | Use it for |
|---|---|---|
zarritaStore | zarr.Listable<Readable> — the store, with metadata served from memory | Opening arrays, reading chunks |
tree | ZarrTree — the hierarchy as a plain object | Enumerating, navigating, reading metadata without I/O |
The tree is the reason this package exists. Because every node's attributes and array metadata are already in memory, a question like "what columns does this table have, and what type is each one?" is answerable synchronously, before deciding whether any of it is worth loading. Tree nodes covers the shape of a node, how to tell a group from an array, and how to read a data type out of one.
Zipped stores
ZipStore.open opens a zipped zarr store from a URL, a Blob/File, or any
{ getLength, read } byte reader, and the result can be passed anywhere a store is
taken, including openExtraConsolidated and readZarr:
import { openExtraConsolidated, ZipStore } from 'zarrextra';
const store = await ZipStore.open('https://example.com/store.zarr.zip');
const result = await openExtraConsolidated(store);
Consolidated metadata is used when the archive has it; otherwise the store is listed
from the zip's central directory, at one read per node. An archive whose only
top-level entry is a .zarr folder is rooted inside it. Only STORED (uncompressed)
entries are range-read; deflated ones are inflated whole on every read. A URL needs a
server that answers Range with 206, and ZipStore.open rejects one that doesn't.
A STORED zip inside another opens without extracting it: build a reader from the outer
store's entrySize(key) and getRange(key, …) (the entrySize doc comment has the
snippet). Each inner read is then an outer read, so for a remote outer archive, fetching
a small nested zip whole with get is usually fewer requests.
In Node, open a local archive over 4 GiB through a reader on a FileHandle rather
than fs.openAsBlob, which reports such files' size modulo 2^32.
FolderStore.fromFiles(input.files) does the same for an unzipped folder picked with
<input type="file" webkitdirectory>: listing, consolidated-metadata preference and
single-folder rooting behave as for a zip, and reads are Blob.slices. The browser
enumerates every file in the folder before handing them over, which is slow for a
store with hundreds of thousands of chunks; zip those.
Store extras
Two smaller helpers that come up when a store is not being consumed whole:
createPrefixedStore(store, prefix)— aReadableview rooted at a subpath. Used to hand a table's own subtree toanndata.jswithout it knowing about SpatialData's layout above.loadOmeZarrMultiscalesFromStore(store, path)— reads an OME-Zarr multiscales group into Viv-compatible pixel sources, reusing an already-open store rather than making the viewer open its own.
Result
Result<T, E> is a small Rust-style success-or-error union, used wherever a failure
is expected rather than exceptional:
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
with Ok, Err, isOk, isErr, unwrap and unwrapOr. It is re-exported from
@spatialdata/core, so importing it from either package gives the same type. See
error handling for how core uses it.
Codecs and workers
SpatialData stores in the wild use image codecs zarrita does not register by
default, and decoding them on the main thread will stall a render. Those two concerns
are documented where they are used rather than repeated here:
| Topic | Where |
|---|---|
| Registering JP2K and experimental HTJ2K decode, encoding HTJ2K | package README |
| Which setup each context needs (Node, browser with vis, browser without) | codec fixtures |
| Shipping a worker entry point consumers can use without private URLs | worker bundling pattern |
The short version: in Node, call registerJpeg2kCodec() / registerExperimentalHtj2kCodec()
on the main thread. In a browser using @spatialdata/vis, do nothing — the renderer
path enables the bundled codec worker for you. In a browser without vis, call
enableWorkerChunkDecode() from zarrextra/workers before loading codec-backed data.
Weight
zarrita is deliberately minimal. zarrextra is not, and it is worth being explicit
about where that lands before depending on it.
Measured from the current build:
| Entry | Raw | Gzipped |
|---|---|---|
zarrextra | 20.9 kB | 7.0 kB |
zarrextra/codec-worker | 3.2 MB | 899 kB |
The main entry is small, and the WASM codec packages (OpenJPEG, OpenJPH) are
optionalDependencies whose decoders are injected by the caller, so importing
zarrextra does not pull them in. What is heavy is the codec worker, and it is one bundle
carrying every codec: enabling worker decode for JP2K also ships OpenJPH, and vice
versa. An application reading uncompressed or blosc-compressed stores — which is most
of them — gets no benefit from either.
zarrita and unzipit stay external to that build. openExtraConsolidated
recognises a ZipStore, so unzipit (about 15 kB minified, 5 kB
gzipped) reaches every bundle that imports zarrextra.
:::caution subject to redesign
None of this is settled. Splitting the worker so applications can opt into lighter bundles when they do not need JP2K or HTJ2K is the obvious first move, and is already noted as future work in the package README. Treat the packaging — not the APIs on this page — as the part most likely to change.
:::