diff --git a/package-lock.json b/package-lock.json index ebd36d8b..b03d574d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "three": "^0.184.0", "throttled-queue": "^3.0.0", "tweakpane": "^3.1.9", - "zarrita": "^0.5.1" + "zarrita": "^0.7.2" }, "devDependencies": { "@babel/cli": "^7.25.6", @@ -154,6 +154,7 @@ "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -739,6 +740,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -762,6 +764,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2214,6 +2217,7 @@ "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -2404,6 +2408,7 @@ "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", @@ -3058,13 +3063,13 @@ "license": "Apache-2.0" }, "node_modules/@zarrita/storage": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.1.1.tgz", - "integrity": "sha512-6/NUCvpzsIxfxeMv59jRTl/bOZg3GZfMP6iR8EIqrTaaE0S2jLL/ceX1OxcFBKnuA8/Z2YmgX4SFBHwFGrCcsw==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz", + "integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==", "license": "MIT", "dependencies": { "reference-spec-reader": "^0.2.0", - "unzipit": "^1.4.3" + "unzipit": "2.0.0" } }, "node_modules/accepts": { @@ -3097,6 +3102,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3156,6 +3162,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3681,6 +3688,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4289,6 +4297,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5203,6 +5212,7 @@ "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7319,6 +7329,7 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -10380,6 +10391,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -11270,6 +11282,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11415,6 +11428,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11632,7 +11646,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tweakpane": { "version": "3.1.10", @@ -11689,6 +11704,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11867,15 +11883,12 @@ } }, "node_modules/unzipit": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", - "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz", + "integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==", "license": "MIT", - "dependencies": { - "uzip-module": "^1.0.2" - }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/update-browserslist-db": { @@ -12019,12 +12032,6 @@ "node": ">=8" } }, - "node_modules/uzip-module": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", - "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==", - "license": "MIT" - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -12157,6 +12164,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -12287,6 +12295,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12446,6 +12455,7 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12495,6 +12505,7 @@ "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -12583,6 +12594,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12711,6 +12723,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12793,6 +12806,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13187,12 +13201,12 @@ } }, "node_modules/zarrita": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.5.1.tgz", - "integrity": "sha512-cyujP70BOl5DiXuLtM+0j9nq/pAov4SKXRYIQQOVnk2TfBg/jopX+FXLbqkq3ULOxFLB5AwkPbSp5KvZXoJrbQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.2.tgz", + "integrity": "sha512-BHP+Z+yemkl9pOogkO1XMOrJ5qI4RNqrmheqJeYtIhpiaW4uvqplYx/jGkMD6edQjIZRQhniFigJZE2oTh7dwQ==", "license": "MIT", "dependencies": { - "@zarrita/storage": "^0.1.1", + "@zarrita/storage": "^0.2.0", "numcodecs": "^0.3.2" } }, diff --git a/package.json b/package.json index 1bdc58ee..951dbd3a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "three": "^0.184.0", "throttled-queue": "^3.0.0", "tweakpane": "^3.1.9", - "zarrita": "^0.5.1" + "zarrita": "^0.7.2" }, "devDependencies": { "@babel/cli": "^7.25.6", diff --git a/src/loaders/OmeZarrLoader.ts b/src/loaders/OmeZarrLoader.ts index b11bd96e..d9ca21a8 100644 --- a/src/loaders/OmeZarrLoader.ts +++ b/src/loaders/OmeZarrLoader.ts @@ -32,7 +32,7 @@ import { } from "./zarr_utils/utils.js"; import type { PrefetchDirection, SubscriberId, TCZYX, ZarrSource, NumericZarrArray } from "./zarr_utils/types.js"; import { VolumeLoadError, VolumeLoadErrorType, wrapVolumeLoadError } from "./VolumeLoadError.js"; -import wrapArray, { RelaxedFetchStore } from "./zarr_utils/wrappers.js"; +import { relaxedFetch, withVoleInstrumentation } from "./zarr_utils/wrappers.js"; import { assertMetadataHasMultiscales, toOMEZarrMetaV4, validateOMEZarrMetadata } from "./zarr_utils/validation.js"; import { remapUri } from "../utils/url_utils.js"; import type { TypedArray } from "../types.js"; @@ -112,6 +112,8 @@ class OMEZarrLoader extends ThreadableVolumeLoader { private sources: ZarrSource[], /** Handle to a `SubscribableRequestQueue` for smart concurrency management and request cancelling/reissuing. */ private requestQueue: SubscribableRequestQueue, + /** Optional shared cache for decoded chunks. Keyed by `/`. */ + private cache: VolumeCache | undefined, /** Options to configure (pre)fetching behavior. */ private fetchOptions: ZarrLoaderFetchOptions = DEFAULT_FETCH_OPTIONS, /** Direction(s) to prioritize when prefetching. Stored separate from `fetchOptions` since it may be mutated. */ @@ -149,7 +151,7 @@ class OMEZarrLoader extends ThreadableVolumeLoader { // Create one `ZarrSource` per URL const sourceProms = urlsArr.map(async (url, i) => { - const store = new RelaxedFetchStore(url); + const store = new zarr.FetchStore(url, { fetch: relaxedFetch }); const root = zarr.root(store); const group = await zarr @@ -176,7 +178,6 @@ class OMEZarrLoader extends ThreadableVolumeLoader { const lvlProms = multiscaleMetadata.datasets.map(({ path }) => zarr .open(root.resolve(path), { kind: "array" }) - .then((array) => wrapArray(array, url, cache, queue)) .catch( wrapVolumeLoadError( `Failed to open scale level ${path} of OME-Zarr data at ${url}`, @@ -189,6 +190,7 @@ class OMEZarrLoader extends ThreadableVolumeLoader { return { scaleLevels, + baseUrl: url, multiscaleMetadata, omeroMetadata: omero, axesTCZYX, @@ -215,7 +217,7 @@ class OMEZarrLoader extends ThreadableVolumeLoader { // same in every field we care about, so we only ever use the first source's `multiscaleMetadata` after this point. // Should we only store one `OMEMultiscale` record total, rather than one per source? const priorityDirs = fetchOptions?.priorityDirections ? fetchOptions.priorityDirections.slice() : undefined; - return new OMEZarrLoader(sources, queue, fetchOptions, priorityDirs); + return new OMEZarrLoader(sources, queue, cache, fetchOptions, priorityDirs); } private getUnitSymbols(): [string, string] { @@ -429,10 +431,22 @@ class OMEZarrLoader extends ThreadableVolumeLoader { return Promise.resolve({ imageInfo: imgdata, loadSpec: fullExtentLoadSpec }); } - private prefetchChunk(scaleLevel: NumericZarrArray, coords: TCZYX, subscriber: SubscriberId): void { + private prefetchChunk( + source: ZarrSource, + scaleLevel: NumericZarrArray, + coords: TCZYX, + subscriber: SubscriberId + ): void { + const instrumented = withVoleInstrumentation(scaleLevel, { + baseUrl: source.baseUrl, + cache: this.cache, + queue: this.requestQueue, + subscriber, + isPrefetch: true, + }); // Calling `get` and doing nothing with the result still triggers a cache check, fetch, and insertion - scaleLevel - .getChunk(this.orderByDimension(coords), { subscriber, isPrefetch: true }) + instrumented + .getChunk(this.orderByDimension(coords)) .catch( wrapVolumeLoadError( `Unable to prefetch chunk with coords ${coords.join(", ")}`, @@ -481,9 +495,10 @@ class OMEZarrLoader extends ThreadableVolumeLoader { } // Match absolute channel coordinate back to source index and channel index const { sourceIndex, channelIndexInSource } = this.matchChannelToSource(chunk[1]); - const sourceScaleLevel = this.sources[sourceIndex].scaleLevels[scaleLevel]; + const source = this.sources[sourceIndex]; + const sourceScaleLevel = source.scaleLevels[scaleLevel]; chunk[1] = channelIndexInSource; - this.prefetchChunk(sourceScaleLevel, chunk, subscriber); + this.prefetchChunk(source, sourceScaleLevel, chunk, subscriber); prefetchCount++; } @@ -561,26 +576,32 @@ class OMEZarrLoader extends ThreadableVolumeLoader { const { sourceIndex: sourceIdx, channelIndexInSource: sourceCh } = this.matchChannelToSource(ch); const unorderedSpec = [loadSpec.time, sourceCh, slice(min.z, max.z), slice(min.y, max.y), slice(min.x, max.x)]; - const level = this.sources[sourceIdx].scaleLevels[multiscaleLevel]; + const source = this.sources[sourceIdx]; + const level = source.scaleLevels[multiscaleLevel]; const sliceSpec = this.orderByDimension(unorderedSpec as TCZYX, sourceIdx); const reportChunk = (coords: number[], sub: SubscriberId) => reportChunkBase(sourceIdx, coords, sub); + const instrumented = withVoleInstrumentation(level, { + baseUrl: source.baseUrl, + cache: this.cache, + queue: this.requestQueue, + subscriber, + reportChunk, + }); - const result = await zarr - .get(level, sliceSpec, { opts: { subscriber, reportChunk } }) - .catch>((e) => { - if (e === CHUNK_REQUEST_CANCEL_REASON) { - return e; - } - if (e instanceof VolumeLoadError) { - throw e; - } - const msg = - e instanceof RangeError - ? "Could not allocate enough memory for the requested OME-Zarr data" - : "Could not load OME-Zarr volume data"; - const type = e instanceof RangeError ? VolumeLoadErrorType.TOO_LARGE : VolumeLoadErrorType.LOAD_DATA_FAILED; - throw new VolumeLoadError(msg, { type, cause: e }); - }); + const result = await zarr.get(instrumented, sliceSpec).catch>((e) => { + if (e === CHUNK_REQUEST_CANCEL_REASON) { + return e; + } + if (e instanceof VolumeLoadError) { + throw e; + } + const msg = + e instanceof RangeError + ? "Could not allocate enough memory for the requested OME-Zarr data" + : "Could not load OME-Zarr volume data"; + const type = e instanceof RangeError ? VolumeLoadErrorType.TOO_LARGE : VolumeLoadErrorType.LOAD_DATA_FAILED; + throw new VolumeLoadError(msg, { type, cause: e }); + }); if (result?.data === undefined) { return; diff --git a/src/loaders/VolumeLoadError.ts b/src/loaders/VolumeLoadError.ts index f31daee8..ceaff78e 100644 --- a/src/loaders/VolumeLoadError.ts +++ b/src/loaders/VolumeLoadError.ts @@ -1,5 +1,5 @@ import { errorConstructors } from "serialize-error"; -import { NodeNotFoundError, KeyError } from "zarrita"; +import { NotFoundError } from "zarrita"; // geotiff doesn't export its error types... /** Groups possible load errors into a few broad categories which we can give similar guidance to the user about. */ @@ -24,8 +24,7 @@ export class VolumeLoadError extends Error { // serialize-error only ever calls an error constructor with zero arguments. The required `ErrorConstructor` // type is a bit too restrictive - as long as the constructor can be called with no arguments it's fine. -errorConstructors.set("NodeNotFoundError", NodeNotFoundError as ErrorConstructor); -errorConstructors.set("KeyError", KeyError as ErrorConstructor); +errorConstructors.set("NotFoundError", NotFoundError as unknown as ErrorConstructor); errorConstructors.set("VolumeLoadError", VolumeLoadError as unknown as ErrorConstructor); /** Curried function to re-throw an error wrapped in a `VolumeLoadError` with the given `message` and `type`. */ diff --git a/src/loaders/zarr_utils/types.ts b/src/loaders/zarr_utils/types.ts index ab233245..75c7feff 100644 --- a/src/loaders/zarr_utils/types.ts +++ b/src/loaders/zarr_utils/types.ts @@ -1,5 +1,4 @@ import * as zarr from "zarrita"; -import { AsyncReadable } from "@zarrita/storage"; import type SubscribableRequestQueue from "../../utils/SubscribableRequestQueue.js"; @@ -92,18 +91,14 @@ export type OMEZarrMetadata = { omero?: OmeroTransitionalMetadata; }; -export type WrappedArrayOpts = { - subscriber?: SubscriberId; - reportChunk?: (coords: number[], subscriber: SubscriberId) => void; - isPrefetch?: boolean; -}; - -export type NumericZarrArray = zarr.Array>; +export type NumericZarrArray = zarr.Array; /** A record with everything we need to access and use a single remote source of multiscale OME-Zarr data. */ export type ZarrSource = { /** Representations of each scale level in this zarr. We pick one and pass it to zarrita to load data. */ scaleLevels: NumericZarrArray[]; + /** The URL the scale-level arrays were opened from. Used to key per-chunk cache entries and dedup requests. */ + baseUrl: string; /** * Zarr dimensions may be ordered in many ways or missing altogether (e.g. TCXYZ, TYX). `axesTCZYX` represents * dimension order as a mapping from dimensions to their indices in dimension-ordered arrays for this source. diff --git a/src/loaders/zarr_utils/wrappers.ts b/src/loaders/zarr_utils/wrappers.ts index ed74bbf5..064ca57c 100644 --- a/src/loaders/zarr_utils/wrappers.ts +++ b/src/loaders/zarr_utils/wrappers.ts @@ -1,83 +1,71 @@ -import type { AbsolutePath, Array as ZarrArray, AsyncReadable, Chunk, DataType } from "zarrita"; -import { FetchStore } from "zarrita"; +import { defineArrayExtension } from "zarrita"; import VolumeCache, { isChunk } from "../../VolumeCache.js"; -import type { WrappedArrayOpts } from "./types.js"; import SubscribableRequestQueue from "../../utils/SubscribableRequestQueue.js"; +import type { SubscriberId } from "./types.js"; -type AsyncReadableExt = AsyncReadable; +export type VoleInstrumentationOpts = { + baseUrl: string; + cache?: VolumeCache; + queue?: SubscribableRequestQueue; + subscriber?: SubscriberId; + reportChunk?: (coords: number[], subscriber: SubscriberId) => void; + isPrefetch?: boolean; +}; -export default function wrapArray< - T extends DataType, - Opts = unknown, - Store extends AsyncReadable = AsyncReadable, ->( - array: ZarrArray, - basePath: string, - cache?: VolumeCache, - queue?: SubscribableRequestQueue -): ZarrArray> { - const path = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; - const keyBase = path + array.path + (array.path.endsWith("/") ? "" : "/"); +/** + * Per-request array extension. Intercepts `getChunk` to: + * - fire `reportChunk(coords, subscriber)` (best-effort instrumentation hook), + * - short-circuit to `VolumeCache` on hit, + * - otherwise dedup the underlying fetch through `SubscribableRequestQueue` + * when a subscriber is supplied, and insert the decoded chunk into cache. + * + * Wrap a fresh instance around a base array per `zarr.get` / `getChunk` call so + * the closure captures the caller's `subscriber` and `reportChunk`. In zarrita + * 0.7 there's no mechanism to thread store-specific options through `zarr.get` + * any more; carrying the context on the extension itself replaces the old + * `{ opts: { subscriber, reportChunk } }` pass-through. + */ +export const withVoleInstrumentation = defineArrayExtension((array, opts: VoleInstrumentationOpts) => { + const baseUrl = opts.baseUrl.endsWith("/") ? opts.baseUrl.slice(0, -1) : opts.baseUrl; + const keyBase = baseUrl + array.path + (array.path.endsWith("/") ? "" : "/"); - const getChunk = async (coords: number[], opts?: Parameters["get"]>[1]): Promise> => { - if (opts?.subscriber && opts.reportChunk) { - opts.reportChunk(coords, opts.subscriber); - } - - const fullKey = keyBase + coords.join(","); - const cacheResult = cache?.get(fullKey); - if (cacheResult && isChunk(cacheResult)) { - return cacheResult; - } - - let result: Chunk; - if (queue && opts?.subscriber) { - result = await queue.addRequest(fullKey, opts?.subscriber, () => array.getChunk(coords, opts), opts.isPrefetch); - } else { - result = await array.getChunk(coords, opts); - } - - cache?.insert(fullKey, result); - return result; - }; - - return new Proxy(array, { - get: (target, prop) => { - if (prop === "getChunk") { - return getChunk; + return { + async getChunk(coords, options, inner) { + if (opts.subscriber !== undefined && opts.reportChunk) { + opts.reportChunk(coords, opts.subscriber); } - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy#no_private_property_forwarding - const value = target[prop]; - if (value instanceof Function) { - return function (...args: unknown[]) { - return value.apply(target, args); - }; + const fullKey = keyBase + coords.join(","); + const cached = opts.cache?.get(fullKey); + if (cached && isChunk(cached)) { + return cached; } - return value; - }, - }); -} -type NewFetchStoreOptions = ConstructorParameters[1]; + const fetchChunk = () => array.getChunk(coords, options, inner); + const result = + opts.queue && opts.subscriber !== undefined + ? await opts.queue.addRequest(fullKey, opts.subscriber, fetchChunk, opts.isPrefetch) + : await fetchChunk(); -export class RelaxedFetchStore extends FetchStore { - constructor(baseUrl: string, options?: NewFetchStoreOptions) { - super(baseUrl, options); - } + opts.cache?.insert(fullKey, result); + return result; + }, + }; +}); - // Solution for https://github.com/manzt/zarrita.js/pull/212 - // taken from https://github.com/vitessce/vitessce/pull/2069 - async get(key: AbsolutePath, options: RequestInit = {}): Promise { - try { - return await super.get(key, options); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - if (e?.message?.startsWith("Unexpected response status 403")) { - return undefined; - } - throw e; - } +/** + * `fetch` handler for `FetchStore` that remaps 403 responses to 404, so they're + * surfaced as "missing key" instead of throwing. S3 and other backends return + * 403 (not 404) for missing keys on private buckets. + * + * Based on the "Handle S3 403 as missing key" example in zarrita's + * `FetchStore` docs. + */ +export async function relaxedFetch(request: Request): Promise { + const response = await fetch(request); + if (response.status === 403) { + return new Response(null, { status: 404 }); } + return response; } diff --git a/src/test/zarr_utils.test.ts b/src/test/zarr_utils.test.ts index 98860b6f..7ac2e33c 100644 --- a/src/test/zarr_utils.test.ts +++ b/src/test/zarr_utils.test.ts @@ -71,8 +71,7 @@ class MockStore implements AsyncReadable, AsyncWritable { const MOCK_STORE = new MockStore(); const createMockArrays = (shapes: number[][]): Promise => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const promises = shapes.map((shape) => zarr.create(MOCK_STORE, { shape, chunk_shape: shape, data_type: "uint8" })); + const promises = shapes.map((shape) => zarr.create(MOCK_STORE, { shape, chunkShape: shape, dtype: "uint8" })); return Promise.all(promises); }; @@ -85,6 +84,7 @@ const createOneMockSource = async ( colors?: (string | undefined)[] ): Promise => ({ scaleLevels: await createMockArrays(shapes), + baseUrl: "mock://", multiscaleMetadata: createMockMultiscaleMetadata(scales, paths), omeroMetadata: createMockOmeroMetadata(shapes[0][1], names, colors), axesTCZYX: [0, 1, 2, 3, 4],