nitro.devProxy[...].ws: true is silently ignored — WebSocket upgrades bypass devProxy and hit the worker
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 11.2k
- Forks
- 899
- Avg merge
- 2d 24m
- Merged PRs (30d)
- 40
Description
Environment
nitropack@2.13.4(currentlateston npm; behavior identical onmain)- Node.js 22, dev server only
Reproduction
// nuxt.config.ts (or any nitro consumer)
export default defineNuxtConfig({
nitro: {
devProxy: {
'/maildev': {
target: 'http://127.0.0.1:1080/maildev',
changeOrigin: true,
ws: true, // <-- documented option, has no effect
},
},
},
})
Run anything behind the proxy that opens a WebSocket — e.g. MailDev's socket.io endpoint:
GET /maildev/socket.io/?EIO=4&transport=websocket Upgrade: websocket
Expected
The upgrade is forwarded to http://127.0.0.1:1080/maildev/socket.io/..., MailDev responds 101 Switching Protocols, and the WS pipe is established. This is what the ProxyServerOptions.ws field — re-exported from httpxy and documented as the WS-proxying knob — would lead a user to expect.
Actual
The upgrade lands in the Nitro worker, which has no handler for that path, so it logs:
[request error] [fatal] [GET] http://localhost:3000/maildev/socket.io/?EIO=4&transport=websocket
ℹ Error: Page not found
The httpxy ProxyServer instance receives no upgrade event and never gets a chance to call its .ws() method, even though it was constructed with ws: true.
Root cause
Two independent places in src/dev/:
1. devProxy only wires the proxy into the HTTP-request path
const routes = Object.keys(this.nitro.options.devProxy).sort().reverse();
for (const route of routes) {
let opts = this.nitro.options.devProxy[route];
if (typeof opts === "string") {
opts = { target: opts };
}
const proxy = createHTTPProxy(opts);
app.all(route, proxy.handleEvent); // ← HTTP only
}
…and createHTTPProxy at src/dev/app.ts#L145-L165 only exposes handleEvent, which calls proxy.web():
function createHTTPProxy(defaults: ProxyServerOptions = {}) {
const proxy = createProxyServer({ xfwd: true, ...defaults });
return {
proxy,
async handleEvent(event: H3Event, opts?: ProxyServerOptions) {
try {
return await fromNodeHandler((req, res) => {
return proxy.web(req as IncomingMessage, res as ServerResponse, opts);
})(event);
} catch (error: any) { /* ... */ }
},
};
}
proxy.ws() is never called and the proxy instance is not exported back to the caller in any way that gets reached during an upgrade.
2. The dev server forwards every upgrade straight to the worker
async upgrade(req: IncomingMessage, socket: Socket, head: any) {
if (!this.#manager.upgrade) {
throw new HTTPError({ status: 501, statusText: "Worker does not support upgrades." });
}
return this.#manager.upgrade({ node: { req, socket, head } });
}
listen(opts?: Partial<Omit<ServerOptions, "fetch">>): Server {
const server = serve({ ...opts, fetch: this.fetch, gracefulShutdown: false });
this.#listeners.push(server);
if (server.node?.server) {
server.node.server.on("upgrade", (req, sock, head) => this.upgrade(req, sock, head));
}
return server;
}
There is no check against the devProxy route table — every upgrade goes to this.#manager.upgrade(...) (the worker), regardless of whether the request path matches a devProxy prefix.
Type-level confusion
The config type at src/types/config.ts#L560 is Record<string, string | ProxyServerOptions>, where ProxyServerOptions is re-exported from httpxy and includes ws: boolean. So ws: true autocompletes, type-checks, and silently does nothing.
Suggested fix
In NitroDevServer.upgrade (or in listen's upgrade listener), before calling this.#manager.upgrade, match req.url against the sorted-descending devProxy routes. If one matches, call the corresponding proxy.ws(req, socket, head, opts) from the httpxy instance created in app.ts. This means createHTTPProxy needs to also expose proxy (or a handleUpgrade wrapper) and app.ts needs to register those alongside the route handlers — e.g. keyed by route prefix, looked up in server.ts.
Happy to send a PR if maintainers agree on the shape.
Workaround
For consumers hitting this today: patch nuxt.server.upgrade in a Nuxt module (or the equivalent underlying NitroDevServer.upgrade in non-Nuxt setups) and tunnel matching upgrades to the target using node:http.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with src/dev/app.ts, especially the devProxy route setup and createHTTPProxy, then trace the upgrade listener and NitroDevServer.upgrade in src/dev/server.ts. Compare the existing HTTP proxy path with the documented devProxy options; done means matching WebSocket upgrades reach the configured target while non-proxy upgrades still go to the worker.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, devtools, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100