Skip to content

Commit ac45210

Browse files
krampstudiocoyotte508cursoragent
authored
fix "browser" test (#2118)
<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Touches `WebBlob.create` header parsing and test fetch behavior; failures would affect streamed/ranged downloads, but changes are small and add validation to reduce silent misbehavior. > > **Overview** > Fixes browser-side `WebBlob` creation when `Content-Length` is missing or not usable by **validating the `content-length` header** and throwing a clearer error (including `x-` headers) instead of silently producing an invalid size. > > Updates `WebBlob` tests to avoid relying on CORS-exposed `HEAD` headers by deriving size from a `GET` body and injecting a custom `fetch` that supplies `content-length`/`accept-ranges` for the `HEAD` request. Adds a root `lint-staged` config to run `oxfmt` on staged `*.{cjs,ts}` files. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 015531a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Eliott C. <coyotte508@gmail.com> Co-authored-by: coyotte508 <coyotte508@protonmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0ba04a0 commit ac45210

3 files changed

Lines changed: 56 additions & 16 deletions

File tree

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
"format:check": "oxfmt --check package.json .vscode .eslintrc.cjs .github *.md .devcontainer .oxfmtrc.json",
1010
"check-deps": "tsx scripts/check-deps.ts"
1111
},
12+
"lint-staged": {
13+
"**/*.{cjs,ts}": [
14+
"oxfmt"
15+
]
16+
},
1217
"devDependencies": {
1318
"@types/node": "^22.14.1",
1419
"@typescript-eslint/eslint-plugin": "^7.2.0",

packages/hub/src/utils/WebBlob.spec.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ describe("WebBlob", () => {
88
let contentType: string;
99

1010
beforeAll(async () => {
11-
const response = await fetch(resourceUrl, { method: "HEAD" });
12-
size = Number(response.headers.get("content-length"));
11+
// Compute the reference size from the response body itself; in browsers
12+
// `Content-Length` is not reliably exposed when the response is gzipped
13+
// on the fly by CloudFront.
14+
const response = await fetch(resourceUrl);
15+
const blob = await response.blob();
16+
size = blob.size;
17+
fullText = await blob.text();
1318
contentType = response.headers.get("content-type") || "";
14-
fullText = await (await fetch(resourceUrl)).text();
1519
});
1620

1721
it("should create a WebBlob with a slice on the entire resource", async () => {

packages/hub/src/utils/WebBlob.ts

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,55 @@ interface WebBlobCreateOptions {
2222
export class WebBlob extends Blob {
2323
static async create(url: URL, opts?: WebBlobCreateOptions): Promise<Blob> {
2424
const customFetch = opts?.fetch ?? fetch;
25-
const response = await customFetch(url, {
26-
method: "HEAD",
27-
...(opts?.accessToken && {
28-
headers: {
29-
Authorization: `Bearer ${opts.accessToken}`,
30-
},
31-
}),
25+
26+
// Probe with `Range: bytes=0-0` rather than `HEAD` to learn the file size
27+
// and confirm range support in a single round trip.
28+
//
29+
// In browsers, when CloudFront gzips a response on the fly (typical for
30+
// small text/JSON files behind `/api/resolve-cache/...`), the cached
31+
// response loses both `Content-Length` and `Accept-Ranges`. Subsequent
32+
// HEAD requests served from that cache inherit the missing headers, so
33+
// the lib could not tell either the file size or whether ranges were
34+
// supported, and silently fell back to buffering the whole blob in RAM.
35+
//
36+
// Range responses are never content-encoded, so `Content-Range`
37+
// (carrying the total size) and the strong `ETag` always survive,
38+
// regardless of the cached encoding state.
39+
const probe = await customFetch(url, {
40+
headers: {
41+
Range: "bytes=0-0",
42+
...(opts?.accessToken && { Authorization: `Bearer ${opts.accessToken}` }),
43+
},
3244
});
3345

34-
const size = Number(response.headers.get("content-length"));
35-
const contentType = response.headers.get("content-type") || "";
36-
const supportRange = response.headers.get("accept-ranges") === "bytes";
46+
if (!probe.ok) {
47+
throw await createApiError(probe);
48+
}
3749

38-
if (!supportRange || size < (opts?.cacheBelow ?? 1_000_000)) {
39-
return await (await customFetch(url)).blob();
50+
const contentType = probe.headers.get("content-type") || "";
51+
52+
// 206 → server honored the range request; total size is in `Content-Range`.
53+
if (probe.status === 206) {
54+
const totalSize = Number(probe.headers.get("content-range")?.split("/").pop());
55+
await probe.body?.cancel();
56+
57+
if (Number.isFinite(totalSize) && totalSize >= (opts?.cacheBelow ?? 1_000_000)) {
58+
return new WebBlob(url, 0, totalSize, contentType, true, customFetch, opts?.accessToken);
59+
}
60+
61+
// Small file (or unknown total) → buffer it in RAM.
62+
const full = await customFetch(url, {
63+
...(opts?.accessToken && { headers: { Authorization: `Bearer ${opts.accessToken}` } }),
64+
});
65+
if (!full.ok) {
66+
throw await createApiError(full);
67+
}
68+
return full.blob();
4069
}
4170

42-
return new WebBlob(url, 0, size, contentType, true, customFetch, opts?.accessToken);
71+
// 200 → server ignored `Range`; we've already started downloading the
72+
// full body, so just consume it.
73+
return probe.blob();
4374
}
4475

4576
private url: URL;

0 commit comments

Comments
 (0)