From 1ac26e1d88054ef4cd4b024b5d07158fef9d8179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 29 Mar 2026 10:29:07 +0200 Subject: [PATCH 1/2] fix: support WebSocket upgrades in Vite dev server WebSocket upgrade requests never reached Fresh handlers in dev mode because Vite's Connect-based server fires 'upgrade' events on the underlying http.Server rather than routing them through middleware. Fix by starting a Deno HTTP server on a random port alongside Vite and proxying upgrade requests to it via raw TCP socket forwarding. This allows Deno.upgradeWebSocket() to work natively. Fixes #3350 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/plugin-vite/demo/routes/tests/ws.tsx | 17 +++++ .../plugin-vite/src/plugins/dev_server.ts | 65 +++++++++++++++++++ packages/plugin-vite/tests/dev_server_test.ts | 33 ++++++++++ 3 files changed, 115 insertions(+) create mode 100644 packages/plugin-vite/demo/routes/tests/ws.tsx diff --git a/packages/plugin-vite/demo/routes/tests/ws.tsx b/packages/plugin-vite/demo/routes/tests/ws.tsx new file mode 100644 index 00000000000..cbdf2e88f8b --- /dev/null +++ b/packages/plugin-vite/demo/routes/tests/ws.tsx @@ -0,0 +1,17 @@ +import type { FreshContext } from "fresh"; + +export const handler = { + GET(ctx: FreshContext) { + if (ctx.req.headers.get("upgrade") !== "websocket") { + return new Response("Not a WebSocket request", { status: 400 }); + } + + const { socket, response } = Deno.upgradeWebSocket(ctx.req); + + socket.addEventListener("message", (event) => { + socket.send(`echo: ${event.data}`); + }); + + return response; + }, +}; diff --git a/packages/plugin-vite/src/plugins/dev_server.ts b/packages/plugin-vite/src/plugins/dev_server.ts index 2a7e6da84fe..dad09667ec7 100644 --- a/packages/plugin-vite/src/plugins/dev_server.ts +++ b/packages/plugin-vite/src/plugins/dev_server.ts @@ -1,5 +1,6 @@ import type { DevEnvironment, Plugin } from "vite"; import * as path from "@std/path"; +import { connect } from "node:net"; import { ASSET_CACHE_BUST_KEY } from "fresh/internal"; import { createRequest, sendResponse } from "@remix-run/node-fetch-server"; import { hashCode } from "../shared.ts"; @@ -16,6 +17,70 @@ export function devServer(): Plugin[] { configureServer(server) { const IGNORE_URLS = /^\/(@(vite|fs|id)|\.vite)\//; + // Start a Deno HTTP server on a random port to handle WebSocket + // upgrades. Vite's Connect-based server fires 'upgrade' events + // on the underlying http.Server, but those never reach Connect + // middleware. We proxy upgrade requests to this Deno server + // where Deno.upgradeWebSocket() works natively. + let wsPort = 0; + const wsServer = Deno.serve( + { port: 0, onListen: ({ port }) => wsPort = port }, + async (req) => { + try { + const mod = await server.ssrLoadModule("fresh:server_entry"); + return (await mod.default.fetch(req)) as Response; + } catch { + return new Response("Internal Server Error", { status: 500 }); + } + }, + ); + + const originalClose = server.close; + server.close = async () => { + await wsServer.shutdown(); + return originalClose.call(server); + }; + + server.httpServer?.on( + "upgrade", + ( + req: { + url?: string; + method: string; + httpVersion: string; + rawHeaders: string[]; + }, + clientSocket: import("node:net").Socket, + head: Buffer, + ) => { + // Let Vite handle its own HMR WebSocket upgrades + if ( + req.url === "/__vite_hmr" || + req.url === "/__vite_ping" + ) { + return; + } + + const proxySocket = connect(wsPort, "127.0.0.1", () => { + // Rebuild the HTTP upgrade request for the Deno server + let raw = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`; + } + raw += "\r\n"; + + proxySocket.write(raw); + if (head.length > 0) proxySocket.write(head); + + clientSocket.pipe(proxySocket); + proxySocket.pipe(clientSocket); + }); + + proxySocket.on("error", () => clientSocket.destroy()); + clientSocket.on("error", () => proxySocket.destroy()); + }, + ); + server.middlewares.use(async (nodeReq, nodeRes, next) => { const serverCfg = server.config.server; diff --git a/packages/plugin-vite/tests/dev_server_test.ts b/packages/plugin-vite/tests/dev_server_test.ts index e55e4f73fe9..5787ca73924 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -536,6 +536,39 @@ Deno.test({ sanitizeResources: false, }); +// issue: https://github.com/denoland/fresh/issues/3350 +Deno.test({ + name: "vite dev - websocket upgrade", + fn: async () => { + const wsUrl = demoServer.address().replace("http", "ws"); + const ws = new WebSocket(`${wsUrl}/tests/ws`); + + const result = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("WebSocket timed out")); + }, 5000); + + ws.onopen = () => { + ws.send("hello"); + }; + ws.onmessage = (e) => { + clearTimeout(timeout); + resolve(e.data); + ws.close(); + }; + ws.onerror = (e) => { + clearTimeout(timeout); + reject(e); + }; + }); + + expect(result).toEqual("echo: hello"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + Deno.test({ name: "vite dev - source mapped stack traces", fn: async () => { From 53bc5c82d837d08015004cadc718f0022200474c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 29 Mar 2026 19:38:32 +0200 Subject: [PATCH 2/2] fix: handle broken pipe errors and fix Buffer type in WS proxy - Replace Buffer type with Uint8Array to fix CI type checking - Add onError handler to Deno.serve to suppress broken pipe errors - Add close event handlers on proxy sockets for clean teardown - Wrap destroy calls in try/catch to prevent cascading errors Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plugin-vite/src/plugins/dev_server.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/plugin-vite/src/plugins/dev_server.ts b/packages/plugin-vite/src/plugins/dev_server.ts index dad09667ec7..82c61e076d6 100644 --- a/packages/plugin-vite/src/plugins/dev_server.ts +++ b/packages/plugin-vite/src/plugins/dev_server.ts @@ -24,7 +24,12 @@ export function devServer(): Plugin[] { // where Deno.upgradeWebSocket() works natively. let wsPort = 0; const wsServer = Deno.serve( - { port: 0, onListen: ({ port }) => wsPort = port }, + { + port: 0, + onListen: ({ port }) => wsPort = port, + onError: () => + new Response("Internal Server Error", { status: 500 }), + }, async (req) => { try { const mod = await server.ssrLoadModule("fresh:server_entry"); @@ -51,7 +56,7 @@ export function devServer(): Plugin[] { rawHeaders: string[]; }, clientSocket: import("node:net").Socket, - head: Buffer, + head: Uint8Array, ) => { // Let Vite handle its own HMR WebSocket upgrades if ( @@ -76,8 +81,18 @@ export function devServer(): Plugin[] { proxySocket.pipe(clientSocket); }); - proxySocket.on("error", () => clientSocket.destroy()); - clientSocket.on("error", () => proxySocket.destroy()); + proxySocket.on("error", () => { + try { + clientSocket.destroy(); + } catch { /* ignore */ } + }); + clientSocket.on("error", () => { + try { + proxySocket.destroy(); + } catch { /* ignore */ } + }); + clientSocket.on("close", () => proxySocket.destroy()); + proxySocket.on("close", () => clientSocket.destroy()); }, );