Skip to content

Commit 1ac26e1

Browse files
bartlomiejuclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 220368a commit 1ac26e1

3 files changed

Lines changed: 115 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { FreshContext } from "fresh";
2+
3+
export const handler = {
4+
GET(ctx: FreshContext) {
5+
if (ctx.req.headers.get("upgrade") !== "websocket") {
6+
return new Response("Not a WebSocket request", { status: 400 });
7+
}
8+
9+
const { socket, response } = Deno.upgradeWebSocket(ctx.req);
10+
11+
socket.addEventListener("message", (event) => {
12+
socket.send(`echo: ${event.data}`);
13+
});
14+
15+
return response;
16+
},
17+
};

packages/plugin-vite/src/plugins/dev_server.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DevEnvironment, Plugin } from "vite";
22
import * as path from "@std/path";
3+
import { connect } from "node:net";
34
import { ASSET_CACHE_BUST_KEY } from "fresh/internal";
45
import { createRequest, sendResponse } from "@remix-run/node-fetch-server";
56
import { hashCode } from "../shared.ts";
@@ -16,6 +17,70 @@ export function devServer(): Plugin[] {
1617
configureServer(server) {
1718
const IGNORE_URLS = /^\/(@(vite|fs|id)|\.vite)\//;
1819

20+
// Start a Deno HTTP server on a random port to handle WebSocket
21+
// upgrades. Vite's Connect-based server fires 'upgrade' events
22+
// on the underlying http.Server, but those never reach Connect
23+
// middleware. We proxy upgrade requests to this Deno server
24+
// where Deno.upgradeWebSocket() works natively.
25+
let wsPort = 0;
26+
const wsServer = Deno.serve(
27+
{ port: 0, onListen: ({ port }) => wsPort = port },
28+
async (req) => {
29+
try {
30+
const mod = await server.ssrLoadModule("fresh:server_entry");
31+
return (await mod.default.fetch(req)) as Response;
32+
} catch {
33+
return new Response("Internal Server Error", { status: 500 });
34+
}
35+
},
36+
);
37+
38+
const originalClose = server.close;
39+
server.close = async () => {
40+
await wsServer.shutdown();
41+
return originalClose.call(server);
42+
};
43+
44+
server.httpServer?.on(
45+
"upgrade",
46+
(
47+
req: {
48+
url?: string;
49+
method: string;
50+
httpVersion: string;
51+
rawHeaders: string[];
52+
},
53+
clientSocket: import("node:net").Socket,
54+
head: Buffer,
55+
) => {
56+
// Let Vite handle its own HMR WebSocket upgrades
57+
if (
58+
req.url === "/__vite_hmr" ||
59+
req.url === "/__vite_ping"
60+
) {
61+
return;
62+
}
63+
64+
const proxySocket = connect(wsPort, "127.0.0.1", () => {
65+
// Rebuild the HTTP upgrade request for the Deno server
66+
let raw = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;
67+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
68+
raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`;
69+
}
70+
raw += "\r\n";
71+
72+
proxySocket.write(raw);
73+
if (head.length > 0) proxySocket.write(head);
74+
75+
clientSocket.pipe(proxySocket);
76+
proxySocket.pipe(clientSocket);
77+
});
78+
79+
proxySocket.on("error", () => clientSocket.destroy());
80+
clientSocket.on("error", () => proxySocket.destroy());
81+
},
82+
);
83+
1984
server.middlewares.use(async (nodeReq, nodeRes, next) => {
2085
const serverCfg = server.config.server;
2186

packages/plugin-vite/tests/dev_server_test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,39 @@ Deno.test({
536536
sanitizeResources: false,
537537
});
538538

539+
// issue: https://github.com/denoland/fresh/issues/3350
540+
Deno.test({
541+
name: "vite dev - websocket upgrade",
542+
fn: async () => {
543+
const wsUrl = demoServer.address().replace("http", "ws");
544+
const ws = new WebSocket(`${wsUrl}/tests/ws`);
545+
546+
const result = await new Promise<string>((resolve, reject) => {
547+
const timeout = setTimeout(() => {
548+
ws.close();
549+
reject(new Error("WebSocket timed out"));
550+
}, 5000);
551+
552+
ws.onopen = () => {
553+
ws.send("hello");
554+
};
555+
ws.onmessage = (e) => {
556+
clearTimeout(timeout);
557+
resolve(e.data);
558+
ws.close();
559+
};
560+
ws.onerror = (e) => {
561+
clearTimeout(timeout);
562+
reject(e);
563+
};
564+
});
565+
566+
expect(result).toEqual("echo: hello");
567+
},
568+
sanitizeResources: false,
569+
sanitizeOps: false,
570+
});
571+
539572
Deno.test({
540573
name: "vite dev - source mapped stack traces",
541574
fn: async () => {

0 commit comments

Comments
 (0)