Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
49 changes: 49 additions & 0 deletions src/server/next-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,55 @@ describe("next-compat", () => {
expect(parsed).toEqual({ foo: "bar" });
});

it("should not throw when the input body is already used", async () => {
const plainReq = new Request("https://example.com/api/data", {
method: "POST",
body: JSON.stringify({ foo: "bar" })
});

await plainReq.text(); // consume body

const nextReq = toNextRequest(plainReq);
expect(nextReq).toBeInstanceOf(NextRequest);
expect(nextReq.method).toBe("POST");
});

it("should handle a Request instance without a clone function", () => {
const req = new Request("https://example.com/no-clone", {
method: "GET"
});
Object.defineProperty(req, "clone", { value: undefined });

const nextReq = toNextRequest(req);
expect(nextReq).toBeInstanceOf(NextRequest);
expect(nextReq.url).toBe("https://example.com/no-clone");
});

it("should tolerate inputs without a body property", () => {
const fakeReq: any = {
url: "https://example.com/no-body",
method: "GET",
headers: new Headers()
};

const nextReq = toNextRequest(fakeReq);
expect(nextReq).toBeInstanceOf(NextRequest);
expect(nextReq.url).toBe("https://example.com/no-body");
});

it("should skip locked bodies when rebuilding requests", () => {
const fakeReq: any = {
url: "https://example.com/locked-body",
method: "POST",
headers: new Headers(),
body: { locked: true }
};

const nextReq = toNextRequest(fakeReq);
expect(nextReq).toBeInstanceOf(NextRequest);
expect(nextReq.url).toBe("https://example.com/locked-body");
});

it("should set duplex to 'half' if not provided", () => {
const req = new Request("https://example.com", { method: "GET" });
const nextReq = toNextRequest(req);
Expand Down
49 changes: 44 additions & 5 deletions src/server/next-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,41 @@ function collectFromNextUrl(input: Request): NextConfig | undefined {
return config && Object.keys(config).length ? config : undefined;
}

function tryCloneRequest(input: Request): Request | null {
if (typeof (input as any).clone !== "function") {
return null;
}

try {
return input.clone();
} catch {
return null;
}
}

function getSafeBody(input: any): BodyInit | null | undefined {
if (!("body" in input)) {
return undefined;
}

const body = input.body;

if (body == null) {
return body;
}

if (input.bodyUsed) {
return undefined;
}

const locked = (body as any).locked;
if (typeof locked === "boolean" && locked) {
return undefined;
}

return body as BodyInit;
}

/**
* Normalize a Request or NextRequest to a NextRequest instance.
* Ensures consistent behavior across Next.js 15 (Edge) and 16 (Node Proxy).
Expand All @@ -54,18 +89,22 @@ export function toNextRequest(input: Request | NextRequest): NextRequest {

const nextConfig = collectFromNextUrl(input);

const source =
input instanceof Request ? (tryCloneRequest(input) ?? input) : input;
const body = getSafeBody(source);

const init: any = {
method: input.method,
headers: input.headers,
body: input.body as any,
duplex: (input as any).duplex ?? "half"
method: source.method,
headers: source.headers,
duplex: (source as any).duplex ?? "half",
...(body !== undefined ? { body } : {})
};

if (nextConfig) {
init.nextConfig = nextConfig;
}

return new NextRequest(input.url, init);
return new NextRequest(source.url, init);
}

/**
Expand Down