Skip to content

Commit 1fcf2d9

Browse files
authored
fix: support WebSocket upgrades in dev server (#3823)
Vite's Connect-based dev server dispatches WebSocket upgrades as 'upgrade' events on the underlying http.Server rather than routing them through middleware, so `Deno.upgradeWebSocket()` was never reached and the client hung in CONNECTING. The plugin now attaches an upgrade listener that hands the raw node socket off to Fresh's existing `ctx.upgrade()` path. The plumbing leans on denoland/deno#33342, which taught `Deno.upgradeWebSocket` to accept a `{ socket, head }` option for requests that didn't come in through `Deno.serve`. A `WeakMap<Request, { socket, head }>` kept on `globalThis` acts as the side channel: the plugin's upgrade listener populates it, `ctx.upgrade()` reads and clears it before calling Deno. The map lives on `globalThis` because Vite's SSR runner evaluates `context.ts` separately from Deno's own load of the plugin, so each side would otherwise hold its own module-local instance. Fixes #3350
1 parent acd04e1 commit 1fcf2d9

5 files changed

Lines changed: 138 additions & 2 deletions

File tree

packages/fresh/src/context.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ export interface WebSocketUpgradeOptions {
5858
protocol?: string;
5959
}
6060

61+
/**
62+
* Side channel used by `@fresh/plugin-vite` (and any other adapter sitting on
63+
* top of `node:http`) to hand a raw socket + buffered head to `ctx.upgrade()`.
64+
* `Deno.upgradeWebSocket()` normally pulls these off the request handled by
65+
* `Deno.serve`, but a request synthesized from `node:http` carries neither, so
66+
* the adapter stashes them here and `ctx.upgrade()` forwards them to Deno.
67+
*
68+
* Stored on `globalThis` so a single WeakMap is shared even when this module
69+
* is evaluated more than once (e.g. once by Deno for the Vite plugin and once
70+
* by Vite's SSR runner for the user's server code).
71+
*/
72+
const UPGRADE_SOURCE_KEY: unique symbol = Symbol.for(
73+
"fresh.upgradeSourceMap",
74+
) as typeof UPGRADE_SOURCE_KEY;
75+
// deno-lint-ignore no-explicit-any
76+
type UpgradeSource = { socket: any; head: any };
77+
// deno-lint-ignore no-explicit-any
78+
const globalAny = globalThis as any;
79+
export const upgradeSourceMap: WeakMap<Request, UpgradeSource> =
80+
globalAny[UPGRADE_SOURCE_KEY] ??
81+
(globalAny[UPGRADE_SOURCE_KEY] = new WeakMap<Request, UpgradeSource>());
82+
6183
/**
6284
* Duck-type check: treats the argument as managed-mode handlers when at least
6385
* one of the handler keys (`open`, `message`, `close`, `error`) is a
@@ -595,7 +617,16 @@ export class Context<State> {
595617
throw new HttpError(400, "Expected a WebSocket upgrade request");
596618
}
597619

598-
const { socket, response } = Deno.upgradeWebSocket(this.req, options);
620+
const source = upgradeSourceMap.get(this.req);
621+
if (source !== undefined) upgradeSourceMap.delete(this.req);
622+
const upgradeOptions = source
623+
? { ...options, socket: source.socket, head: source.head }
624+
: options;
625+
const { socket, response } = Deno.upgradeWebSocket(
626+
this.req,
627+
// deno-lint-ignore no-explicit-any
628+
upgradeOptions as any,
629+
);
599630

600631
if (handlers === undefined) {
601632
return { socket, response };

packages/fresh/src/internals.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { setBuildCache, setErrorInterceptor } from "./app.ts";
44
export { IslandPreparer, ProdBuildCache } from "./build_cache.ts";
55
export { path };
66
export { ASSET_CACHE_BUST_KEY } from "./constants.ts";
7+
export { upgradeSourceMap } from "./context.ts";
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { define } from "../../utils.ts";
2+
3+
export const handler = define.handlers({
4+
GET(ctx) {
5+
return ctx.upgrade({
6+
message(socket, e) {
7+
socket.send("echo: " + e.data);
8+
},
9+
});
10+
},
11+
});

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { DevEnvironment, Plugin } from "vite";
22
import * as path from "@std/path";
33
import { contentType as getStdContentType } from "@std/media-types/content-type";
4-
import { ASSET_CACHE_BUST_KEY } from "fresh/internal";
4+
import { ASSET_CACHE_BUST_KEY, upgradeSourceMap } from "fresh/internal";
55
import { createRequest, sendResponse } from "@remix-run/node-fetch-server";
66
import { hashCode } from "../shared.ts";
77
import type { ResolvedFreshViteConfig } from "../utils.ts";
@@ -28,6 +28,75 @@ export function devServer(freshConfig: ResolvedFreshViteConfig): Plugin[] {
2828
`^(${base})?/(@(vite|fs|id)|\\.vite)/`,
2929
);
3030

31+
// WebSocket upgrade requests don't flow through Connect middleware —
32+
// node:http dispatches them as `upgrade` events on the http.Server.
33+
// Vite's own HMR server identifies its connections via the
34+
// `sec-websocket-protocol` header (`vite-hmr`/`vite-ping`), so we
35+
// skip those and let Vite handle them. Everything else we route into
36+
// the Fresh handler with the raw socket + head stashed in
37+
// `upgradeSourceMap` so `ctx.upgrade()` can finish the handshake via
38+
// `Deno.upgradeWebSocket(req, { socket, head })`.
39+
server.httpServer?.on("upgrade", (nodeReq, nodeSocket, head) => {
40+
const protocolHeader = nodeReq.headers["sec-websocket-protocol"];
41+
if (
42+
typeof protocolHeader === "string" &&
43+
/\b(vite-hmr|vite-ping)\b/.test(protocolHeader)
44+
) {
45+
return;
46+
}
47+
48+
(async () => {
49+
try {
50+
const serverCfg = server.config.server;
51+
const protocol = serverCfg.https ? "https" : "http";
52+
const host = nodeReq.headers.host ?? "localhost";
53+
const url = new URL(
54+
nodeReq.url ?? "/",
55+
`${protocol}://${host}`,
56+
);
57+
58+
const headers = new Headers();
59+
for (const [key, value] of Object.entries(nodeReq.headers)) {
60+
if (value === undefined || value === null) continue;
61+
if (Array.isArray(value)) {
62+
for (const v of value) {
63+
if (typeof v === "string") headers.append(key, v);
64+
}
65+
} else if (typeof value === "string") {
66+
headers.set(key, value);
67+
}
68+
}
69+
70+
const req = new Request(url, {
71+
method: nodeReq.method ?? "GET",
72+
headers,
73+
});
74+
75+
upgradeSourceMap.set(req, { socket: nodeSocket, head });
76+
77+
const mod = await server.ssrLoadModule("fresh:server_entry");
78+
mod.setErrorInterceptor((err: unknown) => {
79+
if (err instanceof Error) server.ssrFixStacktrace(err);
80+
});
81+
82+
await mod.default.fetch(req);
83+
// If the handler called ctx.upgrade(), the 101 has already been
84+
// written to nodeSocket and the WebSocket is live. If it didn't,
85+
// we have no usable channel left to report an error on — close
86+
// the socket so the client doesn't hang.
87+
if (!nodeSocket.destroyed && upgradeSourceMap.has(req)) {
88+
nodeSocket.destroy();
89+
}
90+
upgradeSourceMap.delete(req);
91+
} catch (err) {
92+
if (err instanceof Error) server.ssrFixStacktrace(err);
93+
// deno-lint-ignore no-console
94+
console.error("[fresh] WebSocket upgrade failed:", err);
95+
if (!nodeSocket.destroyed) nodeSocket.destroy();
96+
}
97+
})();
98+
});
99+
31100
server.middlewares.use(async (nodeReq, nodeRes, next) => {
32101
const serverCfg = server.config.server;
33102

packages/plugin-vite/tests/dev_server_test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,30 @@ integrationTest("vite dev - launches", async () => {
2727
expect(text).toContain("it works");
2828
});
2929

30+
integrationTest("vite dev - upgrades WebSocket connections", async () => {
31+
const address = demoServer.address();
32+
const wsUrl = address.replace(/^http/, "ws") + "/tests/ws_echo";
33+
34+
const ws = new WebSocket(wsUrl);
35+
const reply = await new Promise<string>((resolve, reject) => {
36+
const timer = setTimeout(
37+
() => reject(new Error("timed out waiting for echo")),
38+
5000,
39+
);
40+
ws.onopen = () => ws.send("hello");
41+
ws.onmessage = (e) => {
42+
clearTimeout(timer);
43+
resolve(typeof e.data === "string" ? e.data : "");
44+
};
45+
ws.onerror = () => {
46+
clearTimeout(timer);
47+
reject(new Error("websocket errored"));
48+
};
49+
});
50+
ws.close();
51+
expect(reply).toBe("echo: hello");
52+
});
53+
3054
integrationTest("vite dev - serves static files", async () => {
3155
const res = await fetch(`${demoServer.address()}/test_static/foo.txt`);
3256
const text = await res.text();

0 commit comments

Comments
 (0)