Skip to content

Commit e41a4e1

Browse files
billfclaude
andcommitted
Support single byte ranges for static assets
Preserve conditional range handling and storage response headers. Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 47ec93e commit e41a4e1

6 files changed

Lines changed: 467 additions & 17 deletions

File tree

‎src/client/index.test.ts‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,56 @@ describe("registerStaticRoutes", () => {
8585
expect(await response.text()).toBe("<h1>Hello</h1>");
8686
});
8787

88+
test("does not advertise Accept-Ranges when storage ignores Range", async () => {
89+
const handler = staticHandler();
90+
const runQuery = vi.fn().mockResolvedValue({
91+
storageUrl: "https://storage.example/ep.mp3",
92+
contentType: "audio/mpeg",
93+
etag: '"storage-id"',
94+
});
95+
vi.stubGlobal(
96+
"fetch",
97+
vi.fn().mockResolvedValue(new Response("0123456789")),
98+
);
99+
100+
const response = await invokeHandler(
101+
handler,
102+
runQuery,
103+
new Request("https://app.convex.site/ep.mp3", {
104+
headers: { Range: "bytes=2-4" },
105+
}),
106+
);
107+
108+
expect(response.status).toBe(200);
109+
expect(response.headers.get("Accept-Ranges")).toBeNull();
110+
});
111+
112+
test("ignores Range when If-Range does not match the current ETag", async () => {
113+
const handler = staticHandler();
114+
const runQuery = vi.fn().mockResolvedValue({
115+
storageUrl: "https://storage.example/ep.mp3",
116+
contentType: "audio/mpeg",
117+
etag: '"storage-id"',
118+
});
119+
const fetchMock = vi
120+
.fn()
121+
.mockResolvedValue(new Response("0123456789", { status: 200 }));
122+
vi.stubGlobal("fetch", fetchMock);
123+
124+
const response = await invokeHandler(
125+
handler,
126+
runQuery,
127+
new Request("https://app.convex.site/ep.mp3", {
128+
headers: { Range: "bytes=2-4", "If-Range": '"old-storage-id"' },
129+
}),
130+
);
131+
132+
expect(response.status).toBe(200);
133+
expect(await response.text()).toBe("0123456789");
134+
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
135+
expect(new Headers(init?.headers).get("Range")).toBeNull();
136+
});
137+
88138
test("serves an inherited v1 asset from the app's storage during migration", async () => {
89139
const handler = staticHandler();
90140
const runQuery = vi.fn().mockResolvedValue({
@@ -302,3 +352,141 @@ describe("exposeDeploymentQuery", () => {
302352
});
303353
});
304354
});
355+
356+
describe("storage-URL Range passthrough", () => {
357+
const storedFile = {
358+
storageUrl: "https://storage.example/ep.mp3",
359+
contentType: "audio/mpeg",
360+
etag: '"storage-id"',
361+
};
362+
363+
test("passes a storage 206 through with its Content-Range and Accept-Ranges", async () => {
364+
const handler = staticHandler();
365+
const runQuery = vi.fn().mockResolvedValue(storedFile);
366+
const fetchMock = vi.fn().mockResolvedValue(
367+
new Response("234", {
368+
status: 206,
369+
headers: { "Content-Range": "bytes 2-4/10", "Accept-Ranges": "bytes" },
370+
}),
371+
);
372+
vi.stubGlobal("fetch", fetchMock);
373+
374+
const response = await invokeHandler(
375+
handler,
376+
runQuery,
377+
new Request("https://app.convex.site/ep.mp3", {
378+
headers: { Range: "bytes=2-4" },
379+
}),
380+
);
381+
382+
expect(response.status).toBe(206);
383+
expect(response.headers.get("Content-Range")).toBe("bytes 2-4/10");
384+
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
385+
expect(await response.text()).toBe("234");
386+
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
387+
expect(new Headers(init?.headers).get("Range")).toBe("bytes=2-4");
388+
});
389+
390+
test("passes a storage 416 through with its Content-Range", async () => {
391+
const handler = staticHandler();
392+
const runQuery = vi.fn().mockResolvedValue(storedFile);
393+
vi.stubGlobal(
394+
"fetch",
395+
vi.fn().mockResolvedValue(
396+
new Response(null, {
397+
status: 416,
398+
headers: { "Content-Range": "bytes */10" },
399+
}),
400+
),
401+
);
402+
403+
const response = await invokeHandler(
404+
handler,
405+
runQuery,
406+
new Request("https://app.convex.site/ep.mp3", {
407+
headers: { Range: "bytes=50-" },
408+
}),
409+
);
410+
411+
expect(response.status).toBe(416);
412+
expect(response.headers.get("Content-Range")).toBe("bytes */10");
413+
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
414+
expect(response.headers.get("Cache-Control")).toBe("no-store");
415+
expect(response.headers.get("ETag")).toBe('"storage-id"');
416+
});
417+
418+
test("forwards Range when If-Range matches the current ETag", async () => {
419+
const handler = staticHandler();
420+
const runQuery = vi.fn().mockResolvedValue(storedFile);
421+
const fetchMock = vi.fn().mockResolvedValue(
422+
new Response("234", {
423+
status: 206,
424+
headers: { "Content-Range": "bytes 2-4/10" },
425+
}),
426+
);
427+
vi.stubGlobal("fetch", fetchMock);
428+
429+
const response = await invokeHandler(
430+
handler,
431+
runQuery,
432+
new Request("https://app.convex.site/ep.mp3", {
433+
headers: { Range: "bytes=2-4", "If-Range": '"storage-id"' },
434+
}),
435+
);
436+
437+
expect(response.status).toBe(206);
438+
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
439+
expect(new Headers(init?.headers).get("Range")).toBe("bytes=2-4");
440+
});
441+
});
442+
443+
describe("storage Accept-Ranges advertisement", () => {
444+
test("does not advertise bytes when storage answers Accept-Ranges: none", async () => {
445+
const handler = staticHandler();
446+
const runQuery = vi.fn().mockResolvedValue({
447+
storageUrl: "https://storage.example/ep.mp3",
448+
contentType: "audio/mpeg",
449+
etag: '"storage-id"',
450+
});
451+
vi.stubGlobal(
452+
"fetch",
453+
vi.fn().mockResolvedValue(
454+
new Response("0123456789", { headers: { "Accept-Ranges": "none" } }),
455+
),
456+
);
457+
458+
const response = await invokeHandler(
459+
handler,
460+
runQuery,
461+
new Request("https://app.convex.site/ep.mp3"),
462+
);
463+
464+
expect(response.status).toBe(200);
465+
expect(response.headers.get("Accept-Ranges")).toBeNull();
466+
});
467+
});
468+
469+
describe("storage passthrough with multi-range headers", () => {
470+
test("a multi-range header is not forwarded to storage", async () => {
471+
const handler = staticHandler();
472+
const runQuery = vi.fn().mockResolvedValue({
473+
storageUrl: "https://storage.example/ep.mp3",
474+
contentType: "audio/mpeg",
475+
etag: '"storage-id"',
476+
});
477+
const fetchMock = vi.fn().mockResolvedValue(new Response("0123456789"));
478+
vi.stubGlobal("fetch", fetchMock);
479+
480+
const response = await invokeHandler(
481+
handler,
482+
runQuery,
483+
new Request("https://app.convex.site/ep.mp3", {
484+
headers: { Range: "bytes=0-1,3-4" },
485+
}),
486+
);
487+
488+
expect(response.status).toBe(200);
489+
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
490+
expect(new Headers(init?.headers).get("Range")).toBeNull();
491+
});
492+
});

‎src/client/index.ts‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ import {
1010
decodeRequestPath,
1111
etagMatches,
1212
getSetupHtml,
13-
resolveContentType,
1413
isHtmlContentType,
1514
} from "../component/serving.js";
15+
import {
16+
blobResponse,
17+
ifRangeMatches,
18+
resolveContentType,
19+
} from "../mime/mime.js";
1620

1721
const deploymentInfoValidator = v.object({
1822
_id: v.string(),
@@ -160,15 +164,17 @@ export function registerStaticRoutes(
160164
headers: { "Content-Type": "text/plain" },
161165
});
162166
}
163-
return new Response(blob, {
164-
status: 200,
165-
headers: {
167+
return blobResponse(
168+
blob,
169+
{
166170
"Content-Type": contentType,
167171
"Cache-Control": cacheControl,
168172
...(asset.etag ? { ETag: asset.etag } : {}),
169173
"X-Content-Type-Options": "nosniff",
170174
},
171-
});
175+
request.headers.get("Range"),
176+
request.headers.get("If-Range"),
177+
);
172178
}
173179

174180
if (!asset.storageUrl) {
@@ -188,21 +194,58 @@ export function registerStaticRoutes(
188194
});
189195
}
190196

191-
const storageResponse = await fetch(asset.storageUrl);
197+
// Forward Range so audio/video can seek; the storage response's 206 and
198+
// range headers are passed through below. A stale If-Range drops Range so
199+
// storage returns the full representation.
200+
// Only a single byte range is forwarded. A multi-range header is ignored here,
201+
// as the local parser does, so storage never frames a multipart body under the asset type.
202+
const requestedRange = request.headers.get("Range");
203+
const rangeHeader =
204+
ifRangeMatches(request.headers.get("If-Range"), asset.etag) &&
205+
requestedRange !== null &&
206+
!requestedRange.includes(",")
207+
? requestedRange
208+
: null;
209+
const storageResponse = await fetch(
210+
asset.storageUrl,
211+
rangeHeader ? { headers: { Range: rangeHeader } } : undefined,
212+
);
213+
if (storageResponse.status === 416) {
214+
const contentRange = storageResponse.headers.get("Content-Range");
215+
return new Response(null, {
216+
status: 416,
217+
headers: {
218+
"Cache-Control": "no-store",
219+
"X-Content-Type-Options": "nosniff",
220+
...(asset.etag ? { ETag: asset.etag } : {}),
221+
...(contentRange ? { "Content-Range": contentRange } : {}),
222+
},
223+
});
224+
}
192225
if (!storageResponse.ok || !storageResponse.body) {
193226
return new Response("Storage error", {
194227
status: 500,
195228
headers: { "Content-Type": "text/plain" },
196229
});
197230
}
198231

232+
const contentRange = storageResponse.headers.get("Content-Range");
233+
// Advertise bytes only when storage actually lists it, so "none" does not count.
234+
const storageAcceptsBytes = (storageResponse.headers.get("Accept-Ranges") ?? "")
235+
.split(",")
236+
.map((unit) => unit.trim().toLowerCase())
237+
.includes("bytes");
238+
const storageSupportsRanges =
239+
storageResponse.status === 206 || storageAcceptsBytes;
199240
return new Response(storageResponse.body, {
200-
status: 200,
241+
status: storageResponse.status === 206 ? 206 : 200,
201242
headers: {
202243
"Content-Type": contentType,
203244
"Cache-Control": cacheControl,
204245
...(asset.etag ? { ETag: asset.etag } : {}),
205246
"X-Content-Type-Options": "nosniff",
247+
...(storageSupportsRanges ? { "Accept-Ranges": "bytes" } : {}),
248+
...(contentRange ? { "Content-Range": contentRange } : {}),
206249
},
207250
});
208251
});

0 commit comments

Comments
 (0)