From cd32e1be3f22c5b19e8e79d9286b7231ec5f5d62 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 25 Jul 2026 08:24:42 +0000 Subject: [PATCH 1/2] fix(dev): proxy websocket upgrades for `devProxy` routes with `ws` --- src/dev/app.ts | 29 ++++++++ src/dev/server.ts | 3 + test/unit/dev-proxy-ws.test.ts | 122 +++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 test/unit/dev-proxy-ws.test.ts diff --git a/src/dev/app.ts b/src/dev/app.ts index 24888410d7..53450afcf6 100644 --- a/src/dev/app.ts +++ b/src/dev/app.ts @@ -2,6 +2,7 @@ import type { Nitro } from "nitro/types"; import type { H3Event, HTTPHandler } from "h3"; import { createProxyServer, type ProxyServerOptions } from "httpxy"; import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Socket } from "node:net"; import { H3, toEventHandler, serveStatic, fromNodeHandler, HTTPError } from "h3"; import { joinURL } from "ufo"; import mime from "mime"; @@ -21,6 +22,8 @@ export class NitroDevApp { nitro: Nitro; fetch: (req: Request) => Response | Promise; + #wsProxies: { route: string; proxy: ReturnType }[] = []; + constructor(nitro: Nitro, catchAllHandler?: HTTPHandler) { this.nitro = nitro; const app = this.#createApp(catchAllHandler); @@ -97,6 +100,9 @@ export class NitroDevApp { } const proxy = createHTTPProxy(opts); app.all(route, proxy.handleEvent); + if (opts.ws) { + this.#wsProxies.push({ route, proxy }); + } } // Main handler @@ -106,6 +112,29 @@ export class NitroDevApp { return app; } + + /** + * Proxy a WebSocket upgrade request if it matches a `devProxy` rule with `ws` enabled. + * + * @returns `true` if the socket was handed to a proxy, `false` if the caller should handle it. + */ + proxyUpgrade(req: IncomingMessage, socket: Socket, head: any): boolean { + if (this.#wsProxies.length === 0) { + return false; + } + const path = (req.url || "/").split("?")[0]!; + const match = this.#wsProxies.find( + ({ route }) => path === route || path.startsWith(route.endsWith("/") ? route : `${route}/`) + ); + if (!match) { + return false; + } + match.proxy.proxy.ws(req, socket, {}, head).catch((error) => { + this.nitro.logger.error(`Failed to proxy WebSocket upgrade for \`${path}\`:`, error); + socket.destroy(); + }); + return true; + } } // TODO: upstream to h3/node diff --git a/src/dev/server.ts b/src/dev/server.ts index a6b5e33d87..94f3e80ab3 100644 --- a/src/dev/server.ts +++ b/src/dev/server.ts @@ -123,6 +123,9 @@ export class NitroDevServer extends NitroDevApp implements RunnerRPCHooks { // #region Public Methods async upgrade(req: IncomingMessage, socket: Socket, head: any) { + if (this.proxyUpgrade(req, socket, head)) { + return; + } if (!this.#manager.upgrade) { throw new HTTPError({ status: 501, diff --git a/test/unit/dev-proxy-ws.test.ts b/test/unit/dev-proxy-ws.test.ts new file mode 100644 index 0000000000..5a7a7180ff --- /dev/null +++ b/test/unit/dev-proxy-ws.test.ts @@ -0,0 +1,122 @@ +import { createServer, request, type Server } from "node:http"; +import type { AddressInfo, Socket } from "node:net"; +import type { Nitro } from "nitro/types"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { NitroDevApp } from "../../src/dev/app.ts"; + +const sockets = new Set(); + +function listen(server: Server): Promise { + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + return new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + }); +} + +/** Minimal upgrade-aware target: replies `101` then echoes raw frames. */ +function createUpgradeTarget(upgrades: string[]): Server { + const server = createServer((_req, res) => res.end("http")); + server.on("upgrade", (req, socket) => { + upgrades.push(req.url!); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" + ); + socket.on("data", (chunk) => socket.write(chunk)); + }); + return server; +} + +function upgradeRequest(port: number, path: string) { + return new Promise<{ status: number; echo: string }>((resolve, reject) => { + const req = request({ + port, + host: "127.0.0.1", + path, + headers: { Connection: "Upgrade", Upgrade: "websocket" }, + }); + const timeout = setTimeout(() => reject(new Error("upgrade timed out")), 5000); + req.on("error", reject); + req.on("response", (res) => { + clearTimeout(timeout); + reject(new Error(`unexpected HTTP response: ${res.statusCode}`)); + }); + req.on("upgrade", (res, socket) => { + socket.once("data", (chunk) => { + clearTimeout(timeout); + socket.destroy(); + resolve({ status: res.statusCode!, echo: chunk.toString() }); + }); + socket.write("ping"); + }); + req.end(); + }); +} + +describe("dev server devProxy websocket upgrades", () => { + const upgrades: string[] = []; + const target = createUpgradeTarget(upgrades); + let devServer: Server; + let devPort: number; + let workerUpgrades = 0; + + beforeAll(async () => { + const targetPort = await listen(target); + const app = new NitroDevApp({ + logger: console, + options: { + baseURL: "/", + devHandlers: [], + publicAssets: [], + devProxy: { + "/proxy/ws": { target: `http://127.0.0.1:${targetPort}`, ws: true }, + "/proxy/http": { target: `http://127.0.0.1:${targetPort}` }, + }, + }, + } as unknown as Nitro); + devServer = createServer(async (req, res) => { + const response = await app.fetch(new Request(`http://127.0.0.1${req.url}`)); + res.end(await response.text()); + }); + devServer.on("upgrade", (req, socket, head) => { + if (!app.proxyUpgrade(req, socket as Socket, head)) { + workerUpgrades++; + socket.destroy(); + } + }); + devPort = await listen(devServer); + }); + + afterAll(async () => { + for (const socket of sockets) { + socket.destroy(); + } + await Promise.all([ + new Promise((resolve) => devServer.close(resolve)), + new Promise((resolve) => target.close(resolve)), + ]); + }); + + it("proxies upgrades for a matching rule with `ws` enabled", async () => { + const res = await upgradeRequest(devPort, "/proxy/ws"); + expect(res.status).toBe(101); + expect(res.echo).toBe("ping"); + expect(upgrades).toContain("/proxy/ws"); + }); + + it("proxies upgrades for subpaths of a matching rule", async () => { + const res = await upgradeRequest(devPort, "/proxy/ws/nested?foo=1"); + expect(res.status).toBe(101); + expect(upgrades).toContain("/proxy/ws/nested?foo=1"); + }); + + it("forwards upgrades to the worker when no rule enables `ws`", async () => { + await expect(upgradeRequest(devPort, "/proxy/http")).rejects.toThrow(); + await expect(upgradeRequest(devPort, "/other")).rejects.toThrow(); + expect(workerUpgrades).toBe(2); + expect(upgrades).not.toContain("/proxy/http"); + }); +}); From 1a6f45d5fce6e5f8ee7a03e2fdbdf4ca93de36bc Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 3 Oct 2026 17:19:21 +0000 Subject: [PATCH 2/2] fix(dev): match `devProxy` websocket upgrades like http routes Use rou3 so `/**` rules match upgrades and exact rules stay exact, and proxy upgrades in the vite dev server too. --- docs/3.config/0.index.md | 3 +++ src/build/vite/dev.ts | 14 ++++++++++++-- src/dev/app.ts | 14 +++++++------- test/unit/dev-proxy-ws.test.ts | 13 ++++++++----- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/docs/3.config/0.index.md b/docs/3.config/0.index.md index 69b82e7ceb..4384b0b9a3 100644 --- a/docs/3.config/0.index.md +++ b/docs/3.config/0.index.md @@ -665,10 +665,13 @@ export default defineConfig({ devProxy: { "/proxy/test": "http://localhost:3001", "/proxy/example": { target: "https://example.com", changeOrigin: true }, + "/proxy/socket/**": { target: "http://localhost:3002", ws: true }, }, }); ``` +Routes match like other Nitro routes: `/proxy/test` only matches that exact path, use `/proxy/test/**` to also proxy sub-paths. Set `ws: true` to also proxy WebSocket upgrade requests. + See [httpxy](https://github.com/unjs/httpxy) for all available target options. ## Logging diff --git a/src/build/vite/dev.ts b/src/build/vite/dev.ts index e0a0aa9b45..2f27ab4dc7 100644 --- a/src/build/vite/dev.ts +++ b/src/build/vite/dev.ts @@ -2,6 +2,7 @@ import type { NitroPluginContext } from "./types.ts"; import type { DevEnvironment, DevEnvironmentContext, ResolvedConfig, ViteDevServer } from "vite"; import type { FetchFunctionOptions, FetchResult } from "vite/module-runner"; import type { RunnerRPCHooks, UpgradeContext } from "env-runner"; +import type { Socket } from "node:net"; import { IncomingMessage, ServerResponse } from "node:http"; import { readFile } from "node:fs/promises"; @@ -185,14 +186,23 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi } // Websocket (`httpServer` is null in middleware mode, the parent server handles upgrades) - if (nitro.options.features.websocket ?? nitro.options.experimental.websocket) { + const websocket = nitro.options.features.websocket ?? nitro.options.experimental.websocket; + const wsProxy = Object.values(nitro.options.devProxy).some( + (opts) => typeof opts === "object" && opts.ws + ); + if (websocket || wsProxy) { server.httpServer?.on("upgrade", (req, socket, head) => { const protocol = req.headers["sec-websocket-protocol"]; if (protocol?.startsWith("vite-")) { // Vite HMR WebSocket connection return; } - nitroEnv.devServer.upgrade?.({ node: { req, socket, head } }); + if (ctx.devApp?.proxyUpgrade(req, socket as Socket, head)) { + return; + } + if (websocket) { + nitroEnv.devServer.upgrade?.({ node: { req, socket, head } }); + } }); } diff --git a/src/dev/app.ts b/src/dev/app.ts index 53450afcf6..d4dbfac5af 100644 --- a/src/dev/app.ts +++ b/src/dev/app.ts @@ -5,6 +5,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Socket } from "node:net"; import { H3, toEventHandler, serveStatic, fromNodeHandler, HTTPError } from "h3"; import { joinURL } from "ufo"; +import { addRoute, createRouter, findRoute } from "rou3"; import mime from "mime"; import { join, resolve, extname } from "pathe"; import { stat } from "node:fs/promises"; @@ -22,7 +23,7 @@ export class NitroDevApp { nitro: Nitro; fetch: (req: Request) => Response | Promise; - #wsProxies: { route: string; proxy: ReturnType }[] = []; + #wsProxies?: ReturnType>>; constructor(nitro: Nitro, catchAllHandler?: HTTPHandler) { this.nitro = nitro; @@ -101,7 +102,8 @@ export class NitroDevApp { const proxy = createHTTPProxy(opts); app.all(route, proxy.handleEvent); if (opts.ws) { - this.#wsProxies.push({ route, proxy }); + this.#wsProxies ??= createRouter(); + addRoute(this.#wsProxies, "", route, proxy); } } @@ -119,17 +121,15 @@ export class NitroDevApp { * @returns `true` if the socket was handed to a proxy, `false` if the caller should handle it. */ proxyUpgrade(req: IncomingMessage, socket: Socket, head: any): boolean { - if (this.#wsProxies.length === 0) { + if (!this.#wsProxies) { return false; } const path = (req.url || "/").split("?")[0]!; - const match = this.#wsProxies.find( - ({ route }) => path === route || path.startsWith(route.endsWith("/") ? route : `${route}/`) - ); + const match = findRoute(this.#wsProxies, "", path); if (!match) { return false; } - match.proxy.proxy.ws(req, socket, {}, head).catch((error) => { + match.data.proxy.ws(req, socket, {}, head).catch((error) => { this.nitro.logger.error(`Failed to proxy WebSocket upgrade for \`${path}\`:`, error); socket.destroy(); }); diff --git a/test/unit/dev-proxy-ws.test.ts b/test/unit/dev-proxy-ws.test.ts index 5a7a7180ff..7c2a8aab29 100644 --- a/test/unit/dev-proxy-ws.test.ts +++ b/test/unit/dev-proxy-ws.test.ts @@ -73,6 +73,7 @@ describe("dev server devProxy websocket upgrades", () => { publicAssets: [], devProxy: { "/proxy/ws": { target: `http://127.0.0.1:${targetPort}`, ws: true }, + "/proxy/wild/**": { target: `http://127.0.0.1:${targetPort}`, ws: true }, "/proxy/http": { target: `http://127.0.0.1:${targetPort}` }, }, }, @@ -107,16 +108,18 @@ describe("dev server devProxy websocket upgrades", () => { expect(upgrades).toContain("/proxy/ws"); }); - it("proxies upgrades for subpaths of a matching rule", async () => { - const res = await upgradeRequest(devPort, "/proxy/ws/nested?foo=1"); + it("proxies upgrades for a matching wildcard rule", async () => { + const res = await upgradeRequest(devPort, "/proxy/wild/nested?foo=1"); expect(res.status).toBe(101); - expect(upgrades).toContain("/proxy/ws/nested?foo=1"); + expect(upgrades).toContain("/proxy/wild/nested?foo=1"); }); - it("forwards upgrades to the worker when no rule enables `ws`", async () => { + it("forwards upgrades to the worker when no `ws` rule matches", async () => { await expect(upgradeRequest(devPort, "/proxy/http")).rejects.toThrow(); + await expect(upgradeRequest(devPort, "/proxy/ws/nested")).rejects.toThrow(); await expect(upgradeRequest(devPort, "/other")).rejects.toThrow(); - expect(workerUpgrades).toBe(2); + expect(workerUpgrades).toBe(3); expect(upgrades).not.toContain("/proxy/http"); + expect(upgrades).not.toContain("/proxy/ws/nested"); }); });