diff --git a/changelog.d/fixes/14904-deadline-signal-proxied-request.md b/changelog.d/fixes/14904-deadline-signal-proxied-request.md new file mode 100644 index 000000000000..09aea88e932b --- /dev/null +++ b/changelog.d/fixes/14904-deadline-signal-proxied-request.md @@ -0,0 +1 @@ +- **fix(sse):** `/v1/chat/completions`, `/v1/messages` and `/v1/responses` no longer fail with HTTP 500 `Cannot read private member #state` ([#14904](https://github.com/diegosouzapw/OmniRoute/pull/14904)). Next.js hands route handlers without a `dynamic` export a Proxy around the `NextRequest`, and `withDeadlineSignal()` rebuilt it with `new Request(request, init)`, which reads the input's private fields. The wrapped request is now rebuilt from its public accessors (url, method, headers, redirect, body stream). — thanks @TrippyEngineer diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index d2d600ebf7ad..59b1f1f0f3b5 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -312,9 +312,12 @@ export function __getDeadlineTokenRegistrySizeForTests(): number { * * The wrapped request MUST be the one the route hands downstream (admission, * body parse, `handleChat`): the handler snapshots `request.signal` after - * admission, so wrapping after that point would not propagate. Rebuilt via - * `new Request(request, { signal, headers })`, which preserves method, url and - * body byte-for-byte. + * admission, so wrapping after that point would not propagate. Rebuilt from the + * public accessors (url, method, headers, redirect, body stream), which + * preserves method, url and body byte-for-byte. It must NOT be + * `new Request(request, init)`: Next.js hands handlers without a `dynamic` + * export a Proxy around the NextRequest (`proxyNextRequest`), and the Request + * constructor reads the input's private `#state`, which throws through a Proxy. * * Controller recovery downstream (`getDeadlineController`) is two-layered: * the combined signal object (fast path — same object when nothing rebuilds), @@ -338,7 +341,15 @@ export function withDeadlineSignal(request: Request): { // admission rebuilds, which both copy headers but mint new signal objects. const token = `dl-${Date.now().toString(36)}-${(deadlineTokenSeq += 1)}`; headers.set(DEADLINE_TOKEN_HEADER, token); - const wrappedReq = new Request(request, { signal: combined, headers }); + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + const wrappedReq = new Request(request.url, { + method: request.method, + headers, + body: hasBody ? request.body : null, + redirect: request.redirect, + signal: combined, + duplex: "half", + } as RequestInit & { duplex: "half" }); deadlineControllers.set(combined, deadlineController); deadlineControllersByToken.set(token, new WeakRef(deadlineController)); deadlineTokenByController.set(deadlineController, token); diff --git a/tests/unit/early-stream-keepalive.test.ts b/tests/unit/early-stream-keepalive.test.ts index 8fc8dda131e4..eb735aa8774f 100644 --- a/tests/unit/early-stream-keepalive.test.ts +++ b/tests/unit/early-stream-keepalive.test.ts @@ -692,3 +692,38 @@ test("deadline token registry returns to its original size after N requests", as "a released token must not resolve through the header fallback" ); }); + +// Next.js hands App Router handlers without a `dynamic` export a Proxy around the +// NextRequest (`proxyNextRequest`). Private class fields do not pass through a +// Proxy, so `new Request(proxiedRequest, init)` threw "Cannot read private member +// #state" and every /v1/chat/completions, /v1/messages and /v1/responses call +// returned 500. The wrap must rebuild from public accessors instead. +test("withDeadlineSignal accepts a Proxy-wrapped request (Next.js proxyNextRequest)", async () => { + const client = new AbortController(); + const inner = new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Test": "kept" }, + body: '{"model":"auto"}', + signal: client.signal, + }); + const proxied = new Proxy(inner, { + get: (target, prop) => Reflect.get(target, prop, target), + }); + + const { wrappedReq, deadlineController } = withDeadlineSignal(proxied); + + assert.equal(wrappedReq.method, "POST"); + assert.equal(wrappedReq.url, "http://localhost/v1/chat/completions"); + assert.equal(wrappedReq.headers.get("x-test"), "kept"); + assert.equal(await wrappedReq.text(), '{"model":"auto"}'); + assert.equal(getDeadlineController(wrappedReq), deadlineController); + assert.equal(wrappedReq.signal.aborted, false); + client.abort(); + assert.equal(wrappedReq.signal.aborted, true, "client abort must still reach the wrapped signal"); +}); + +test("withDeadlineSignal keeps a body-less GET request valid", () => { + const { wrappedReq } = withDeadlineSignal(new Request("http://localhost/v1/models")); + assert.equal(wrappedReq.method, "GET"); + assert.equal(wrappedReq.body, null); +});