Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -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
19 changes: 15 additions & 4 deletions open-sse/utils/earlyStreamKeepalive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/early-stream-keepalive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});