Skip to content
This repository was archived by the owner on Jun 1, 2026. It is now read-only.

Commit 801f7d8

Browse files
committed
feat: add websocket relay support to http tunnels
1 parent b5cc5a9 commit 801f7d8

4 files changed

Lines changed: 560 additions & 17 deletions

File tree

packages/cli/src/commands/http.ts

Lines changed: 196 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import type {
66
HttpResponseEndMessage,
77
HttpResponseMessage,
88
HttpResponseStartMessage,
9+
WsCloseMessage,
10+
WsFrameMessage,
11+
WsOpenMessage,
912
} from '@auger/shared';
1013
import { buildWsUrl } from '../utils';
1114

@@ -17,6 +20,71 @@ export type HttpCommandOptions = {
1720
subdomain?: string;
1821
};
1922

23+
type LocalSocketEntry = {
24+
socket: WebSocket;
25+
open: boolean;
26+
closingFromServer: boolean;
27+
queuedFrames: Array<{ payload: Uint8Array; isBinary: boolean }>;
28+
};
29+
30+
const textEncoder = new TextEncoder();
31+
const textDecoder = new TextDecoder();
32+
33+
function normalizeCloseCode(code: number | undefined): number {
34+
if (code === undefined) return 1000;
35+
if (!Number.isInteger(code)) return 1000;
36+
if (code < 1000 || code > 4999) return 1000;
37+
return code;
38+
}
39+
40+
async function messageDataToText(data: unknown): Promise<string> {
41+
if (typeof data === 'string') return data;
42+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
43+
if (ArrayBuffer.isView(data)) {
44+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
45+
}
46+
if (data instanceof Blob) {
47+
return Buffer.from(await data.arrayBuffer()).toString('utf8');
48+
}
49+
return String(data);
50+
}
51+
52+
async function messageDataToPayload(
53+
data: unknown
54+
): Promise<{ payload: Uint8Array; isBinary: boolean } | null> {
55+
if (typeof data === 'string') {
56+
return {
57+
payload: textEncoder.encode(data),
58+
isBinary: false,
59+
};
60+
}
61+
62+
if (data instanceof ArrayBuffer) {
63+
return {
64+
payload: new Uint8Array(data),
65+
isBinary: true,
66+
};
67+
}
68+
69+
if (ArrayBuffer.isView(data)) {
70+
return {
71+
payload: new Uint8Array(
72+
data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
73+
),
74+
isBinary: true,
75+
};
76+
}
77+
78+
if (data instanceof Blob) {
79+
return {
80+
payload: new Uint8Array(await data.arrayBuffer()),
81+
isBinary: true,
82+
};
83+
}
84+
85+
return null;
86+
}
87+
2088
async function handleHttpRequest(
2189
ws: WebSocket,
2290
localPort: number,
@@ -111,11 +179,121 @@ export async function runHttpCommand(options: HttpCommandOptions): Promise<void>
111179
? `${options.subdomain}:${options.localPort}`
112180
: `${options.localPort}`;
113181
let ws: WebSocket | null = null;
182+
const localSockets = new Map<string, LocalSocketEntry>();
114183
let consecutiveFailures = 0;
115184
let reconnectScheduled = false;
116185
let fatalError = false;
117186

187+
const sendControl = (
188+
payload:
189+
| HelloMessage
190+
| HttpResponseMessage
191+
| HttpResponseStartMessage
192+
| HttpResponseChunkMessage
193+
| HttpResponseEndMessage
194+
| WsFrameMessage
195+
| WsCloseMessage
196+
) => {
197+
if (ws?.readyState === WebSocket.OPEN) {
198+
ws.send(toMessage(payload));
199+
}
200+
};
201+
202+
const closeAllLocalSockets = (code: number, reason: string) => {
203+
for (const [id, entry] of localSockets.entries()) {
204+
entry.closingFromServer = true;
205+
localSockets.delete(id);
206+
entry.socket.close(code, reason);
207+
}
208+
};
209+
210+
const flushQueuedFrames = (entry: LocalSocketEntry) => {
211+
while (entry.queuedFrames.length > 0) {
212+
const frame = entry.queuedFrames.shift();
213+
if (!frame) continue;
214+
if (frame.isBinary) {
215+
entry.socket.send(frame.payload);
216+
} else {
217+
entry.socket.send(textDecoder.decode(frame.payload));
218+
}
219+
}
220+
};
221+
222+
const handleWsOpen = (message: WsOpenMessage) => {
223+
const localUrl = new URL(message.path, `ws://127.0.0.1:${options.localPort}`).toString();
224+
const localSocket =
225+
message.protocols.length > 0
226+
? new WebSocket(localUrl, message.protocols)
227+
: new WebSocket(localUrl);
228+
localSocket.binaryType = 'arraybuffer';
229+
230+
const entry: LocalSocketEntry = {
231+
socket: localSocket,
232+
open: false,
233+
closingFromServer: false,
234+
queuedFrames: [],
235+
};
236+
localSockets.set(message.id, entry);
237+
238+
localSocket.addEventListener('open', () => {
239+
entry.open = true;
240+
flushQueuedFrames(entry);
241+
});
242+
243+
localSocket.addEventListener('message', async (event) => {
244+
const payload = await messageDataToPayload(event.data);
245+
if (!payload) return;
246+
sendControl({
247+
type: 'ws_frame',
248+
id: message.id,
249+
dataBase64: encodeBase64(payload.payload),
250+
isBinary: payload.isBinary,
251+
});
252+
});
253+
254+
localSocket.addEventListener('close', (event) => {
255+
localSockets.delete(message.id);
256+
if (entry.closingFromServer) return;
257+
sendControl({
258+
type: 'ws_close',
259+
id: message.id,
260+
code: event.code,
261+
reason: event.reason,
262+
});
263+
});
264+
};
265+
266+
const handleWsFrame = (message: WsFrameMessage) => {
267+
const entry = localSockets.get(message.id);
268+
if (!entry) return;
269+
270+
const payload = decodeBase64(message.dataBase64);
271+
if (!entry.open) {
272+
entry.queuedFrames.push({
273+
payload,
274+
isBinary: message.isBinary,
275+
});
276+
return;
277+
}
278+
279+
if (message.isBinary) {
280+
entry.socket.send(payload);
281+
} else {
282+
entry.socket.send(textDecoder.decode(payload));
283+
}
284+
};
285+
286+
const handleWsClose = (message: WsCloseMessage) => {
287+
const entry = localSockets.get(message.id);
288+
if (!entry) return;
289+
290+
entry.closingFromServer = true;
291+
localSockets.delete(message.id);
292+
entry.socket.close(normalizeCloseCode(message.code), message.reason ?? '');
293+
};
294+
118295
const scheduleReconnect = (reason: string) => {
296+
closeAllLocalSockets(1011, 'Tunnel connection lost');
119297
if (fatalError) {
120298
process.exit(1);
121299
}
@@ -136,6 +314,7 @@ export async function runHttpCommand(options: HttpCommandOptions): Promise<void>
136314

137315
const connect = () => {
138316
ws = new WebSocket(wsUrl);
317+
ws.binaryType = 'arraybuffer';
139318

140319
ws.addEventListener('open', () => {
141320
consecutiveFailures = 0;
@@ -147,14 +326,11 @@ export async function runHttpCommand(options: HttpCommandOptions): Promise<void>
147326
requestedSubdomain: options.subdomain,
148327
};
149328

150-
ws?.send(toMessage(hello));
329+
sendControl(hello);
151330
});
152331

153332
ws.addEventListener('message', async (event) => {
154-
const data =
155-
typeof event.data === 'string'
156-
? event.data
157-
: Buffer.from(event.data as ArrayBuffer).toString('utf8');
333+
const data = await messageDataToText(event.data);
158334
const message = parseMessage(data);
159335

160336
if (message.type === 'error') {
@@ -175,6 +351,21 @@ export async function runHttpCommand(options: HttpCommandOptions): Promise<void>
175351

176352
if (message.type === 'http_request') {
177353
await handleHttpRequest(ws as WebSocket, options.localPort, message, label);
354+
return;
355+
}
356+
357+
if (message.type === 'ws_open') {
358+
handleWsOpen(message);
359+
return;
360+
}
361+
362+
if (message.type === 'ws_frame') {
363+
handleWsFrame(message);
364+
return;
365+
}
366+
367+
if (message.type === 'ws_close') {
368+
handleWsClose(message);
178369
}
179370
});
180371

0 commit comments

Comments
 (0)