Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/neat-buses-spend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": patch
---

fix: prevent Worker hang on HEAD requests to static assets
Original file line number Diff line number Diff line change
@@ -1,7 +1,80 @@
import { describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";

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

const mockAssetsFetch = vi.fn();

vi.mock("../../cloudflare-context.js", () => ({
getCloudflareContext: () => ({
env: {
ASSETS: { fetch: mockAssetsFetch },
},
}),
}));

describe("maybeGetAssetResult", () => {
let resolver: typeof import("./index.js").default;

beforeEach(async () => {
vi.resetModules();
mockAssetsFetch.mockReset();
globalThis.__ASSETS_RUN_WORKER_FIRST__ = true;
resolver = (await import("./index.js")).default;
});

const makeEvent = (method: string, rawPath: string) =>
({
method,
rawPath,
headers: { accept: "*/*" },
}) as Parameters<typeof resolver.maybeGetAssetResult>[0];

test("GET request returns response body", async () => {
const body = new ReadableStream();
mockAssetsFetch.mockResolvedValue(new Response(body, { status: 200 }));

const result = await resolver.maybeGetAssetResult(makeEvent("GET", "/style.css"));

expect(result).toBeDefined();
expect(result!.statusCode).toBe(200);
expect(result!.body).not.toBeNull();
});

test("HEAD request returns null body", async () => {
mockAssetsFetch.mockResolvedValue(new Response(null, { status: 200 }));

const result = await resolver.maybeGetAssetResult(makeEvent("HEAD", "/style.css"));

expect(result).toBeDefined();
expect(result!.statusCode).toBe(200);
expect(result!.body).toBeNull();
});

test("returns undefined for 404 responses", async () => {
mockAssetsFetch.mockResolvedValue(new Response(null, { status: 404 }));

const result = await resolver.maybeGetAssetResult(makeEvent("GET", "/missing.css"));

expect(result).toBeUndefined();
});

test("returns undefined for POST requests", async () => {
const result = await resolver.maybeGetAssetResult(makeEvent("POST", "/style.css"));

expect(result).toBeUndefined();
expect(mockAssetsFetch).not.toHaveBeenCalled();
});

test("returns undefined when run_worker_first is false", async () => {
globalThis.__ASSETS_RUN_WORKER_FIRST__ = false;

const result = await resolver.maybeGetAssetResult(makeEvent("GET", "/style.css"));

expect(result).toBeUndefined();
expect(mockAssetsFetch).not.toHaveBeenCalled();
});
});

describe("isUserWorkerFirst", () => {
test("run_worker_first = false", () => {
expect(isUserWorkerFirst(false, "/test")).toBe(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const resolver: AssetResolver = {
headers: Object.fromEntries(response.headers.entries()),
// Workers and Node types differ.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: response.body || (new ReadableStream() as any),
body: method === "HEAD" ? null : response.body || (new ReadableStream() as any), // // HEAD responses have no body; the `new ReadableStream()` fallback would hang the Worker.
Comment thread
alex-all3dp marked this conversation as resolved.
Outdated
isBase64Encoded: false,
} satisfies InternalResult;
},
Expand Down
Loading