Repository navigation
fix(api): keep Next's upgrade listener off the run-next server - #15332
Merged
diegosouzapw merged 1 commit intoOct 6, 2026
Merged
diegosouzapw merged 1 commit into
diegosouzapw merged 1 commit into
Conversation
Under scripts/dev/run-next.mjs (`npm run dev` / `npm start`) every WebSocket on /v1/responses, /api/v1/responses, /v1/ws and /api/v1/ws was cut (socket hang up, close 1006, ~50-110 ms after connect) once the server had served any HTTP request. Root cause: next() lazily attaches its router upgrade listener on the first request it serves (NextCustomServer.setupWebSocketHandler), to `options.httpServer || req.socket.server`, i.e. the run-next server. That listener runs alongside run-next's own upgrade dispatcher and, for any path that resolves to an app route, does `socket.end()` (router-server.js: `if (matchedOutput) return socket.end()`). /v1/responses and /v1/ws are rewritten to /api/v1/... app routes, so sockets the Responses WebSocket proxy and the /v1/ws bridge had already claimed were ended under them. Fix: pass next() an EventEmitter relay as `httpServer` (scripts/dev/next-upgrade-relay.mjs) so Next attaches its listener there, and have the dispatcher forward to it only the upgrades no handler claimed (dev HMR). nextApp.getUpgradeHandler() stays as the final fallback but is a no-op in Next 16 (NextNodeServer.handleUpgrade is empty), so the relay is how HMR upgrades still reach Next. The standalone wrapper is not affected (it wraps Next's single upgrade listener). Evidence: `node scripts/dev/run-next.mjs start` against a real build, one HTTP request, then a WebSocket per path: on the base all four paths end with socket hang up / 1006 before or right after the 101; with the relay all four open and stay up until the client closes them. Tests: tests/unit/run-next-upgrade-relay.test.ts drives a real NextCustomServer (stand-in init, no build needed) with the real Responses proxy and /v1/ws bridge: a control case reproduces the cut, the relay case keeps both sessions alive, unclaimed upgrades still reach Next, and guards pin the Next internals the fix relies on.
QuangBlue
force-pushed
the
fix/next-upgrade-relay
branch
from
October 2, 2026 05:18
ec7f2c2 to
9991842
Compare
diegosouzapw
merged commit Oct 6, 2026
bdaa86c
into
cheaper-inference:release/v3.8.52
44 of 51 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Under
scripts/dev/run-next.mjs(npm run dev/npm start), every WebSocket on/v1/responses,/api/v1/responses,/v1/wsand/api/v1/wsis cut once the server has served any HTTP request. The client seessocket hang upand close 1006 about 50–110 ms after connecting.Repro: run
node scripts/dev/run-next.mjs startagainst a real build, send one HTTP request, then open a WebSocket on each path./v1/wssocket hang up, 1006 at 57 mssession.ready, stays open until the client closes/api/v1/ws/v1/responses/api/v1/responsesRoot cause
next()lazily attaches its router upgrade listener on the first request it serves (NextCustomServer.setupWebSocketHandler), tooptions.httpServer || req.socket.server. That is the run-next server.This listener runs alongside run-next's own upgrade dispatcher. For every path that resolves to an app route, it calls
socket.end()(router-server.js:if (matchedOutput) return socket.end())./v1/responsesand/v1/wsare rewritten to/api/v1/...app routes, so Next ends the sockets that the Responses WebSocket proxy and the/v1/wsbridge had already claimed.Fix
scripts/dev/next-upgrade-relay.mjs: anEventEmitterthatrun-next.mjspasses tonext()ashttpServer. Next attaches its listener there instead of on the real server.nextApp.getUpgradeHandler()stays as the last fallback. It is a no-op in Next 16 (NextNodeServer.handleUpgradeis empty), so the relay is how HMR upgrades keep reaching Next's router.run-next.mjsis not packaged (npmfiles, Dockerfile,assembleStandalone), so no allowlist changes are needed.Tests
tests/unit/run-next-upgrade-relay.test.ts. It drives a realNextCustomServer, with a stand-in init because unit tests have no build, plus the real Responses WS proxy and/v1/wsbridge./v1/responsesand/v1/wssockets (1006, closed by the server)./_next/hmr) still reach Next.forward()returns false before Next attaches.run-next-*,standalone*,pack-artifact*,assemble*,graceful-shutdown*, and the other tests that readrun-next.mjs: all pass.npm run typecheck:core,npm run check:cycles, eslint (with the suppressions file) and prettier are clean.Notes
With the fix,
/v1/wsstill reports 1006 when the client closes the socket, after the full hold. I did not look into this further; it is in the bridge's close handshake and does not depend on this change. The unit test therefore checks who closed the/v1/wssocket, not the close code.