Skip to content

Commit 8c3a36e

Browse files
alex-all3dpclaude
andauthored
fix: prevent Worker hang on HEAD requests to static assets (#1126)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 305c20d commit 8c3a36e

3 files changed

Lines changed: 100 additions & 3 deletions

File tree

.changeset/neat-buses-spend.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
fix: prevent Worker hang on HEAD requests to static assets

packages/cloudflare/src/api/overrides/asset-resolver/index.spec.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,80 @@
1-
import { describe, expect, test } from "vitest";
1+
import { beforeEach, describe, expect, test, vi } from "vitest";
22

33
import { isUserWorkerFirst } from "./index.js";
44

5+
const mockAssetsFetch = vi.fn();
6+
7+
vi.mock("../../cloudflare-context.js", () => ({
8+
getCloudflareContext: () => ({
9+
env: {
10+
ASSETS: { fetch: mockAssetsFetch },
11+
},
12+
}),
13+
}));
14+
15+
describe("maybeGetAssetResult", () => {
16+
let resolver: typeof import("./index.js").default;
17+
18+
beforeEach(async () => {
19+
vi.resetModules();
20+
mockAssetsFetch.mockReset();
21+
globalThis.__ASSETS_RUN_WORKER_FIRST__ = true;
22+
resolver = (await import("./index.js")).default;
23+
});
24+
25+
const makeEvent = (method: string, rawPath: string) =>
26+
({
27+
method,
28+
rawPath,
29+
headers: { accept: "*/*" },
30+
}) as Parameters<typeof resolver.maybeGetAssetResult>[0];
31+
32+
test("GET request returns response body", async () => {
33+
const body = new ReadableStream();
34+
mockAssetsFetch.mockResolvedValue(new Response(body, { status: 200 }));
35+
36+
const result = await resolver.maybeGetAssetResult(makeEvent("GET", "/style.css"));
37+
38+
expect(result).toBeDefined();
39+
expect(result!.statusCode).toBe(200);
40+
expect(result!.body).not.toBeNull();
41+
});
42+
43+
test("HEAD request returns null body", async () => {
44+
mockAssetsFetch.mockResolvedValue(new Response(null, { status: 200 }));
45+
46+
const result = await resolver.maybeGetAssetResult(makeEvent("HEAD", "/style.css"));
47+
48+
expect(result).toBeDefined();
49+
expect(result!.statusCode).toBe(200);
50+
expect(result!.body).toBeNull();
51+
});
52+
53+
test("returns undefined for 404 responses", async () => {
54+
mockAssetsFetch.mockResolvedValue(new Response(null, { status: 404 }));
55+
56+
const result = await resolver.maybeGetAssetResult(makeEvent("GET", "/missing.css"));
57+
58+
expect(result).toBeUndefined();
59+
});
60+
61+
test("returns undefined for POST requests", async () => {
62+
const result = await resolver.maybeGetAssetResult(makeEvent("POST", "/style.css"));
63+
64+
expect(result).toBeUndefined();
65+
expect(mockAssetsFetch).not.toHaveBeenCalled();
66+
});
67+
68+
test("returns undefined when run_worker_first is false", async () => {
69+
globalThis.__ASSETS_RUN_WORKER_FIRST__ = false;
70+
71+
const result = await resolver.maybeGetAssetResult(makeEvent("GET", "/style.css"));
72+
73+
expect(result).toBeUndefined();
74+
expect(mockAssetsFetch).not.toHaveBeenCalled();
75+
});
76+
});
77+
578
describe("isUserWorkerFirst", () => {
679
test("run_worker_first = false", () => {
780
expect(isUserWorkerFirst(false, "/test")).toBe(false);

packages/cloudflare/src/api/overrides/asset-resolver/index.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,33 @@ const resolver: AssetResolver = {
4444
type: "core",
4545
statusCode: response.status,
4646
headers: Object.fromEntries(response.headers.entries()),
47-
// Workers and Node types differ.
4847
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49-
body: response.body || (new ReadableStream() as any),
48+
body: getResponseBody(method, response) as any,
5049
isBase64Encoded: false,
5150
} satisfies InternalResult;
5251
},
5352
};
5453

54+
/**
55+
* Returns the response body for an asset result.
56+
*
57+
* HEAD responses must return `null` because `response.body` is `null` per the HTTP spec
58+
* and the `new ReadableStream()` fallback would create a stream that never closes, hanging the Worker.
59+
*
60+
* @param method - The HTTP method of the request.
61+
* @param response - The response from the ASSETS binding.
62+
* @returns The body to use in the internal result.
63+
*/
64+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65+
function getResponseBody(method: string, response: Response): ReadableStream<any> | null {
66+
if (method === "HEAD") {
67+
return null;
68+
}
69+
// Workers and Node ReadableStream types differ.
70+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71+
return response.body || (new ReadableStream() as any);
72+
}
73+
5574
/**
5675
* @param runWorkerFirst `run_worker_first` config
5776
* @param pathname pathname of the request

0 commit comments

Comments
 (0)