Skip to content

Commit 5f21822

Browse files
committed
feat: implement dynamic WebSocket reverse-proxy for debug sessions in Vite
1 parent bfbf535 commit 5f21822

2 files changed

Lines changed: 137 additions & 30 deletions

File tree

src/store/websocketMiddleware.ts

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Middleware } from "@reduxjs/toolkit";
22
import {
3-
connected,
4-
disconnected,
5-
messageReceived,
6-
WS_CONNECT,
7-
WS_DISCONNECT,
8-
WsConnectAction,
9-
WsDisconnectAction,
3+
connected,
4+
disconnected,
5+
messageReceived,
6+
WS_CONNECT,
7+
WS_DISCONNECT,
8+
WsConnectAction,
9+
WsDisconnectAction,
1010
} from "./websocketSlice";
1111

1212
export const websocketMiddleware: Middleware = (store) => {
@@ -23,29 +23,43 @@ export const websocketMiddleware: Middleware = (store) => {
2323
socket.close();
2424
}
2525

26-
socket = new WebSocket(url);
27-
28-
socket.onopen = () => {
29-
store.dispatch(connected());
30-
};
31-
32-
socket.onclose = () => {
33-
store.dispatch(disconnected());
34-
socket = null;
26+
const initSocket = (effectiveUrl: string) => {
27+
socket = new WebSocket(effectiveUrl);
28+
socket.onopen = () => store.dispatch(connected());
29+
socket.onclose = () => { store.dispatch(disconnected()); socket = null; };
30+
socket.onerror = (err) => console.error('WebSocket error', err);
31+
socket.onmessage = (event: MessageEvent<string>) => {
32+
try {
33+
store.dispatch(messageReceived(JSON.parse(event.data)));
34+
} catch (e) {
35+
console.error('Failed to parse WebSocket message', e);
36+
}
37+
};
3538
};
3639

37-
socket.onerror = (err) => {
38-
console.error("WebSocket error", err);
39-
};
40-
41-
socket.onmessage = (event: MessageEvent<string>) => {
42-
try {
43-
const msg = JSON.parse(event.data);
44-
store.dispatch(messageReceived(msg));
45-
} catch (e) {
46-
console.error("Failed to parse WebSocket message", e);
47-
}
48-
};
40+
if (import.meta.env.DEV) {
41+
// In dev: register the dynamic port with Vite's proxy plugin so it can
42+
// tunnel the connection, then connect via localhost (no cert issues).
43+
fetch('/debug/ws-register', {
44+
method: 'POST',
45+
headers: { 'Content-Type': 'application/json' },
46+
body: JSON.stringify({ url }),
47+
})
48+
.then(() => {
49+
const parsed = new URL(url);
50+
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
51+
initSocket(`${proto}//${window.location.host}${parsed.pathname}${parsed.search}`);
52+
})
53+
.catch((err) => {
54+
console.warn('[ws] Registration failed, connecting directly:', err);
55+
initSocket(url);
56+
});
57+
} else {
58+
// In production the app is served by the device itself, so the browser
59+
// already trusts the device cert (TLS cert validation is per-hostname,
60+
// not per-port) and can connect to the high-numbered port directly.
61+
initSocket(url);
62+
}
4963

5064
return;
5165
}

vite.config.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,100 @@
11
import react from '@vitejs/plugin-react';
2-
import { defineConfig, loadEnv } from 'vite';
2+
import type { IncomingMessage } from 'node:http';
3+
import net from 'node:net';
4+
import tls from 'node:tls';
5+
import { defineConfig, loadEnv, Plugin } from 'vite';
36
import svgr from 'vite-plugin-svgr';
47

8+
/**
9+
* Vite-only plugin that creates a dynamic WebSocket reverse-proxy for the
10+
* debug session endpoint. Because the backend picks a random port in the
11+
* range 65435-65535 for each session, a static proxy target is not possible.
12+
*
13+
* Flow (dev only):
14+
* 1. Browser calls POST /debug/ws-register with the wss:// URL returned by
15+
* the debugSession API, telling the plugin which host:port to tunnel to.
16+
* 2. Browser dials ws://localhost:<vite-port>/debug/join (same origin, no
17+
* cert issues).
18+
* 3. This plugin intercepts the WebSocket upgrade, opens a TLS tunnel to
19+
* the registered target, and splices the two sockets together.
20+
*/
21+
function dynamicWsProxyPlugin(): Plugin {
22+
let registeredTarget: URL | null = null;
23+
24+
return {
25+
name: 'dynamic-ws-proxy',
26+
configureServer(server) {
27+
// ── Registration endpoint ─────────────────────────────────────────────
28+
server.middlewares.use('/debug/ws-register', (req, res) => {
29+
if (req.method !== 'POST') {
30+
res.statusCode = 405;
31+
res.end();
32+
return;
33+
}
34+
let body = '';
35+
req.on('data', (chunk: Buffer) => (body += chunk.toString()));
36+
req.on('end', () => {
37+
try {
38+
const { url } = JSON.parse(body) as { url: string };
39+
registeredTarget = new URL(url);
40+
res.statusCode = 200;
41+
res.setHeader('Content-Type', 'application/json');
42+
res.end(JSON.stringify({ ok: true }));
43+
} catch {
44+
res.statusCode = 400;
45+
res.end(JSON.stringify({ error: 'Invalid request body' }));
46+
}
47+
});
48+
});
49+
50+
// ── Dynamic WebSocket tunnel ──────────────────────────────────────────
51+
server.httpServer?.prependListener(
52+
'upgrade',
53+
(req: IncomingMessage, socket: any, head: Buffer) => {
54+
if (!req.url?.startsWith('/debug/join') || !registeredTarget) return;
55+
56+
const target = registeredTarget;
57+
const useSecure =
58+
target.protocol === 'wss:' || target.protocol === 'https:';
59+
const port = target.port
60+
? parseInt(target.port, 10)
61+
: useSecure ? 443 : 80;
62+
63+
const upstream: net.Socket = useSecure
64+
? tls.connect({ host: target.hostname, port, rejectUnauthorized: false })
65+
: net.createConnection({ host: target.hostname, port });
66+
67+
upstream.once(useSecure ? 'secureConnect' : 'connect', () => {
68+
// Re-emit the full HTTP Upgrade request to the upstream server
69+
const headerLines = [
70+
`GET ${req.url} HTTP/1.1`,
71+
`Host: ${target.host}`,
72+
...Object.entries(req.headers)
73+
.filter(([k]) => k !== 'host')
74+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : (v ?? '')}` ),
75+
'',
76+
'',
77+
];
78+
upstream.write(headerLines.join('\r\n'));
79+
if (head?.length) upstream.write(head);
80+
81+
socket.pipe(upstream);
82+
upstream.pipe(socket);
83+
});
84+
85+
upstream.on('error', (err: Error) => {
86+
console.error('[dynamic-ws-proxy] upstream error:', err.message);
87+
socket.destroy();
88+
});
89+
socket.on('error', () => upstream.destroy());
90+
socket.on('close', () => upstream.destroy());
91+
upstream.on('close', () => socket.destroy());
92+
},
93+
);
94+
},
95+
};
96+
}
97+
598
export default defineConfig(({ mode }) => {
699
const env = loadEnv(mode, process.cwd(), '');
7100

@@ -12,7 +105,7 @@ export default defineConfig(({ mode }) => {
12105

13106
return {
14107
base: '/debug/',
15-
plugins: [react(), svgr({ include: '**/*.svg', svgrOptions: { exportType: 'named' } })],
108+
plugins: [react(), svgr({ include: '**/*.svg', svgrOptions: { exportType: 'named' } }), dynamicWsProxyPlugin()],
16109
server: {
17110
proxy: programHost
18111
? {

0 commit comments

Comments
 (0)