Skip to content

Commit b2e8698

Browse files
authored
Add custom fetch option to FetchStore (#388)
Closes #325, #288. `FetchStore` previously only exposed an `overrides` option for setting default `RequestInit` properties, which made it difficult to intercept requests for things like URL presigning or status code remapping. These changes add a `fetch` option that receives a standard WinterTC-style fetch handler (`Request` in, `Response` out), similar to Cloudflare Workers, Deno.serve, and Bun.serve. Internally the store now builds Request objects and routes them through the custom handler (or global fetch). ```ts const store = new FetchStore("https://my-bucket.s3.amazonaws.com/data.zarr", { async fetch(request) { const newUrl = await presign(request.url); return fetch(new Request(newUrl, request)); }, }); ``` The `overrides` option is deprecated but still works and is merged into the Request before it reaches the handler. Docs call out that sharding and partial reads depend on Range headers, so transforming a request should use `new Request(newUrl, original)` to preserve headers, abort signals, and other options from `store.get(key, init)`.
1 parent 7d9224e commit b2e8698

File tree

5 files changed

+404
-54
lines changed

5 files changed

+404
-54
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@zarrita/storage": minor
3+
---
4+
5+
Add custom `fetch` option to `FetchStore`. Accepts a WinterTC-style fetch handler ([`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) in, [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) out) to cover the long tail of things users need at the fetch level. Deprecates `overrides`.
6+
7+
Presigning a URL:
8+
9+
```ts
10+
const store = new FetchStore("https://my-bucket.s3.amazonaws.com/data.zarr", {
11+
async fetch(request) {
12+
const newUrl = await presign(request.url);
13+
return fetch(new Request(newUrl, request));
14+
},
15+
});
16+
```
17+
18+
Remapping response status codes:
19+
20+
```ts
21+
const store = new FetchStore("https://my-bucket.s3.amazonaws.com/data.zarr", {
22+
async fetch(request) {
23+
const response = await fetch(request);
24+
if (response.status === 403) {
25+
return new Response(null, { status: 404 });
26+
}
27+
return response;
28+
},
29+
});
30+
```
31+
32+
#### Migrating from `overrides`
33+
34+
`overrides` only supported static [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) properties. For anything dynamic (like refreshing a token), you had to thread it through every call site:
35+
36+
```ts
37+
const store = new FetchStore("https://example.com/data.zarr");
38+
const arr = await zarr.open(store);
39+
40+
// token logic leaks into every get call
41+
let chunk = await zarr.get(arr, null, {
42+
opts: { headers: { Authorization: `Bearer ${await getAccessToken()}` } },
43+
});
44+
```
45+
46+
With `fetch`, auth is configured once on the store and every request picks it up:
47+
48+
```ts
49+
const store = new FetchStore("https://example.com/data.zarr", {
50+
async fetch(request) {
51+
const token = await getAccessToken();
52+
request.headers.set("Authorization", `Bearer ${token}`);
53+
return fetch(request);
54+
},
55+
});
56+
const arr = await zarr.open(store);
57+
58+
// call sites don't need to know about auth
59+
let chunk = await zarr.get(arr);
60+
```

docs/packages/storage.md

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,80 @@ import { FetchStore } from "@zarrita/storage";
6161
const store = new FetchStore("http://localhost:8080/data.zarr");
6262
```
6363

64-
#### Default Fetch Options
64+
#### Response handling
6565

66-
You can specify default fetch options using the `overrides` parameter when
67-
initializing the `FetchStore`. These act as base configurations for every fetch
68-
request:
66+
The store interprets HTTP responses as follows:
67+
68+
| Status | Meaning |
69+
| ------------- | ------------------------------------------ |
70+
| **404** | Missing key — returns `undefined` |
71+
| **200 / 206** | Success — body is read as `Uint8Array` |
72+
| **Any other** | Throws an error |
73+
74+
If your backend returns different status codes for missing keys (e.g., S3
75+
returns **403** for missing keys on private buckets), you can remap them with a
76+
custom `fetch` (see below).
77+
78+
#### Custom `fetch`
79+
80+
You can provide a custom `fetch` function to intercept every request made by the
81+
store. It receives a standard
82+
[WinterTC fetch handler](https://github.com/nicolo-ribaudo/tc55-proposal-functions-api),
83+
similar to Cloudflare Workers, Deno.serve, and Bun.serve — a
84+
[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) in, a
85+
[`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) out.
86+
87+
This is the recommended way to add authentication, presign URLs, or transform
88+
requests.
89+
90+
::: warning Important
91+
Sharding and partial reads rely on `Range` headers in the request. When
92+
transforming a request (e.g., changing the URL), always use
93+
`new Request(newUrl, originalRequest)` to preserve headers, abort signals, and
94+
other options passed via `store.get(key, init)`.
95+
:::
6996

7097
```javascript
71-
const fetchOptions = { headers: { Authorization: "XXXXX" } };
72-
const store = new FetchStore("http://localhost:8080/data.zarr", {
73-
overrides: fetchOptions,
98+
const store = new FetchStore("https://my-bucket.s3.amazonaws.com/data.zarr", {
99+
async fetch(request) {
100+
const newUrl = await presign(request.url);
101+
// Preserves headers, abort signal, and other options from store.get(key, init)
102+
return fetch(new Request(newUrl, request));
103+
},
104+
});
105+
```
106+
107+
##### Adding authentication headers
108+
109+
You can set headers on the request directly, which replaces the need for the
110+
deprecated `overrides` option. Since the handler runs on every request, you can
111+
also dynamically refresh credentials:
112+
113+
```javascript
114+
const store = new FetchStore("https://example.com/data.zarr", {
115+
async fetch(request) {
116+
const token = await getAccessToken();
117+
request.headers.set("Authorization", `Bearer ${token}`);
118+
return fetch(request);
119+
},
120+
});
121+
```
122+
123+
##### Handling S3 403 responses
124+
125+
S3 returns **403** (not 404) for missing keys on private buckets, which causes
126+
the store to throw. If you know that 403 means "not found" in your setup, remap
127+
it:
128+
129+
```javascript
130+
const store = new FetchStore("https://my-bucket.s3.amazonaws.com/data.zarr", {
131+
async fetch(request) {
132+
const response = await fetch(request);
133+
if (response.status === 403) {
134+
return new Response(null, { status: 404 });
135+
}
136+
return response;
137+
},
74138
});
75139
```
76140

@@ -88,8 +152,10 @@ const bytes = await store.get("/zarr.json", {
88152
});
89153
```
90154

91-
These options override the defaults for the store, except headers are merged
92-
(with the latter taking precedent).
155+
These per-request options are merged into the `Request` passed to your custom
156+
`fetch` (or the global `fetch` if none is provided). Headers are merged, with
157+
per-request headers taking precedence.
158+
93159

94160
### FileSystemStore <Badge type="tip" text="Readable" /> <Badge type="tip" text="Writable" />
95161

packages/@zarrita-storage/__tests__/fetch.test.ts

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, assert, describe, expect, it, vi } from "vitest";
22

33
import FetchStore from "../src/fetch.js";
44

@@ -117,15 +117,21 @@ describe("FetchStore", () => {
117117
let store = new FetchStore(href);
118118
let spy = vi.spyOn(globalThis, "fetch");
119119
await store.get("/zarr.json", { headers });
120-
expect(spy).toHaveBeenCalledWith(`${href}/zarr.json`, { headers });
120+
let request = spy.mock.calls[0][0];
121+
assert(request instanceof Request);
122+
expect(request.url).toBe(`${href}/zarr.json`);
123+
expect(request.headers.get("x-test")).toBe("test");
121124
});
122125

123126
it("forwards request options to fetch when configured globally", async () => {
124127
let headers = { "x-test": "test" };
125128
let store = new FetchStore(href, { overrides: { headers } });
126129
let spy = vi.spyOn(globalThis, "fetch");
127130
await store.get("/zarr.json");
128-
expect(spy).toHaveBeenCalledWith(`${href}/zarr.json`, { headers });
131+
let request = spy.mock.calls[0][0];
132+
assert(request instanceof Request);
133+
expect(request.url).toBe(`${href}/zarr.json`);
134+
expect(request.headers.get("x-test")).toBe("test");
129135
});
130136

131137
it("merges request options", async () => {
@@ -136,10 +142,12 @@ describe("FetchStore", () => {
136142
let store = new FetchStore(href, { overrides });
137143
let spy = vi.spyOn(globalThis, "fetch");
138144
await store.get("/zarr.json", { headers: { "x-test": "override" } });
139-
expect(spy).toHaveBeenCalledWith(`${href}/zarr.json`, {
140-
headers: { "x-test": "override", "x-test2": "root" },
141-
cache: "no-cache",
142-
});
145+
let request = spy.mock.calls[0][0];
146+
assert(request instanceof Request);
147+
expect(request.url).toBe(`${href}/zarr.json`);
148+
expect(request.headers.get("x-test")).toBe("override");
149+
expect(request.headers.get("x-test2")).toBe("root");
150+
expect(request.cache).toBe("no-cache");
143151
});
144152

145153
it("reads partial - suffixLength", async () => {
@@ -165,4 +173,108 @@ describe("FetchStore", () => {
165173
`,
166174
);
167175
});
176+
177+
describe("custom fetch", () => {
178+
it("uses custom fetch for get requests", async () => {
179+
let customFetch = vi.fn((request: Request) => fetch(request));
180+
let store = new FetchStore(href, { fetch: customFetch });
181+
let bytes = await store.get("/zarr.json");
182+
expect(bytes).toBeInstanceOf(Uint8Array);
183+
expect(customFetch).toHaveBeenCalledOnce();
184+
let request = customFetch.mock.calls[0][0];
185+
expect(request).toBeInstanceOf(Request);
186+
expect(request.url).toBe(`${href}/zarr.json`);
187+
});
188+
189+
it("uses custom fetch for getRange requests", async () => {
190+
let customFetch = vi.fn((request: Request) => fetch(request));
191+
let store = new FetchStore(href, { fetch: customFetch });
192+
let bytes = await store.getRange("/zarr.json", {
193+
offset: 4,
194+
length: 50,
195+
});
196+
expect(bytes).toBeInstanceOf(Uint8Array);
197+
expect(customFetch).toHaveBeenCalledOnce();
198+
let request = customFetch.mock.calls[0][0];
199+
expect(request.headers.get("Range")).toBe("bytes=4-53");
200+
});
201+
202+
it("allows intercepting and modifying the request", async () => {
203+
let store = new FetchStore(href, {
204+
async fetch(request) {
205+
// Add a custom header before sending
206+
let modified = new Request(request, {
207+
headers: {
208+
...Object.fromEntries(request.headers),
209+
"x-custom": "test",
210+
},
211+
});
212+
return fetch(modified);
213+
},
214+
});
215+
let bytes = await store.get("/zarr.json");
216+
expect(bytes).toBeInstanceOf(Uint8Array);
217+
});
218+
219+
it("allows remapping status codes", async () => {
220+
let store = new FetchStore("http://localhost:51204/does-not-exist", {
221+
async fetch(request) {
222+
let response = await fetch(request);
223+
// Remap any error to 404 (simulates S3 403 → 404 pattern)
224+
if (!response.ok && response.status !== 404) {
225+
return new Response(null, { status: 404 });
226+
}
227+
return response;
228+
},
229+
});
230+
let bytes = await store.get("/zarr.json");
231+
expect(bytes).toBeUndefined();
232+
});
233+
234+
it("receives merged overrides in the request", async () => {
235+
let customFetch = vi.fn((request: Request) => fetch(request));
236+
let store = new FetchStore(href, {
237+
fetch: customFetch,
238+
overrides: { headers: { "x-base": "base" } },
239+
});
240+
await store.get("/zarr.json", {
241+
headers: { "x-request": "request" },
242+
});
243+
let request = customFetch.mock.calls[0][0];
244+
expect(request.headers.get("x-base")).toBe("base");
245+
expect(request.headers.get("x-request")).toBe("request");
246+
});
247+
248+
it("allows setting headers on the request directly", async () => {
249+
let spy = vi.spyOn(globalThis, "fetch");
250+
let store = new FetchStore(href, {
251+
async fetch(request) {
252+
request.headers.set("Authorization", "Bearer test-token");
253+
return fetch(request);
254+
},
255+
});
256+
await store.get("/zarr.json");
257+
let request = spy.mock.calls[0][0];
258+
assert(request instanceof Request);
259+
expect(request.headers.get("Authorization")).toBe("Bearer test-token");
260+
});
261+
262+
it("abort signal survives request transformation", async () => {
263+
let controller = new AbortController();
264+
let store = new FetchStore(href, {
265+
async fetch(request) {
266+
// Simulate presigning: clone request with a new URL
267+
let transformed = new Request(request.url, request);
268+
expect(transformed.signal).toBe(request.signal);
269+
expect(transformed.signal.aborted).toBe(false);
270+
controller.abort();
271+
expect(transformed.signal.aborted).toBe(true);
272+
throw transformed.signal.reason;
273+
},
274+
});
275+
await expect(
276+
store.get("/zarr.json", { signal: controller.signal }),
277+
).rejects.toThrow();
278+
});
279+
});
168280
});

0 commit comments

Comments
 (0)