@volter/tunnel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,868 @@
1
+ /**
2
+ * WebSocket-based HTTP tunnel client.
3
+ *
4
+ * Connects to the tunnel server via WebSocket, receives HTTP requests,
5
+ * forwards them to a local port, and sends responses back.
6
+ *
7
+ * Also handles WebSocket relay: the server sends ws-upgrade messages,
8
+ * the client connects to the local WebSocket server using the `ws` library
9
+ * and relays WebSocket messages bidirectionally through the control channel.
10
+ */
11
+ import http from 'node:http';
12
+ import https from 'node:https';
13
+ import net from 'node:net';
14
+ import WebSocket from 'ws';
15
+
16
+ /** Force HTTP/1.1 for WebSocket connections — HTTP/2 breaks the Upgrade handshake. */
17
+ const http1Agent = new https.Agent({ ALPNProtocols: ['http/1.1'] });
18
+
19
+ /** Minimal logger interface for tunnel client — compatible with pino, console, etc. */
20
+ export interface TunnelLogger {
21
+ info(obj: Record<string, unknown>, msg: string): void;
22
+ warn(obj: Record<string, unknown>, msg: string): void;
23
+ debug(obj: Record<string, unknown>, msg: string): void;
24
+ }
25
+
26
+ const defaultLogger: TunnelLogger = {
27
+ info(obj, msg) {
28
+ console.log(msg, obj);
29
+ },
30
+ warn(obj, msg) {
31
+ console.warn(msg, obj);
32
+ },
33
+ debug(obj, msg) {
34
+ console.debug(msg, obj);
35
+ },
36
+ };
37
+
38
+ export interface TunnelOptions {
39
+ /** Local port to expose */
40
+ port: number;
41
+ /** Tunnel server URL (e.g., "https://vgit-tunnels.volterapp.com") */
42
+ host: string;
43
+ /** Requested tunnel ID (optional — server generates one if omitted) */
44
+ tunnelId?: string;
45
+ /** Shared secret for tunnel server authentication */
46
+ secret?: string;
47
+ /** Whether the tunnel requires JWT auth for incoming requests (default: true) */
48
+ authRequired?: boolean;
49
+ /** Logger instance (defaults to console-based logger) */
50
+ logger?: TunnelLogger;
51
+ }
52
+
53
+ export interface TunnelHandle {
54
+ /** Public tunnel URL (e.g., "https://quick-fox-123.vgit-tunnels.volterapp.com") */
55
+ url: string;
56
+ /** Assigned tunnel ID */
57
+ tunnelId: string;
58
+ /** Close the tunnel connection */
59
+ close: () => void;
60
+ }
61
+
62
+ interface TunnelRequest {
63
+ type: 'request';
64
+ reqId: number;
65
+ method: string;
66
+ path: string;
67
+ headers: Record<string, string | string[] | undefined>;
68
+ body: string | null;
69
+ }
70
+
71
+ interface TunnelRegistered {
72
+ type: 'registered';
73
+ tunnelId: string;
74
+ url: string;
75
+ }
76
+
77
+ interface TunnelWsUpgrade {
78
+ type: 'ws-upgrade';
79
+ connId: number;
80
+ path: string;
81
+ headers: Record<string, string | string[] | undefined>;
82
+ }
83
+
84
+ interface TunnelWsMessage {
85
+ type: 'ws-message';
86
+ connId: number;
87
+ data: string; // base64
88
+ binary: boolean;
89
+ }
90
+
91
+ interface TunnelWsClose {
92
+ type: 'ws-close';
93
+ connId: number;
94
+ code?: number;
95
+ reason?: string;
96
+ }
97
+
98
+ interface TunnelRequestAbort {
99
+ type: 'request-abort';
100
+ reqId: number;
101
+ }
102
+
103
+ interface TunnelError {
104
+ type: 'error';
105
+ message: string;
106
+ }
107
+
108
+ type TunnelMessage =
109
+ | TunnelRequest
110
+ | TunnelRegistered
111
+ | TunnelWsUpgrade
112
+ | TunnelWsMessage
113
+ | TunnelWsClose
114
+ | TunnelRequestAbort
115
+ | TunnelError;
116
+
117
+ // ============================================================================
118
+ // Safe WebSocket close helpers
119
+ //
120
+ // The `ws` library calls socket.destroy() (TCP RST) in two cases:
121
+ // 1. .close() in CONNECTING state → abortHandshake() → stream.socket.destroy()
122
+ // 2. Close timeout (30s after sending close frame) → socket.destroy()
123
+ //
124
+ // TCP RST causes ECONNRESET on the local server, crashing it. These helpers
125
+ // replace .close() with state-aware logic that sends FIN instead of RST.
126
+ // ============================================================================
127
+
128
+ /**
129
+ * Add a no-op error handler to the underlying net.Socket if none exists.
130
+ * Prevents unhandled ECONNRESET from crashing the tunnel client process.
131
+ */
132
+ function patchSocketErrorHandler(ws: WebSocket): void {
133
+ const internal = ws as unknown as Record<string, unknown>;
134
+ const socket = internal._socket;
135
+ if (socket instanceof net.Socket && socket.listenerCount('error') === 0) {
136
+ socket.on('error', () => {
137
+ // Intentional no-op — the WebSocket 'error' and 'close' events
138
+ // handle cleanup. This just prevents the socket error from being
139
+ // unhandled and crashing the process.
140
+ });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Close an OPEN WebSocket gracefully, replacing the ws library's 30s
146
+ * destroy timer with one that calls socket.end() (FIN) instead of
147
+ * socket.destroy() (RST).
148
+ */
149
+ function safeCloseOpen(ws: WebSocket, code: number, reason: string): void {
150
+ patchSocketErrorHandler(ws);
151
+
152
+ // Send the close frame normally
153
+ ws.close(code, reason);
154
+
155
+ // Replace the ws library's internal close timer.
156
+ // ws sets _closeTimer after calling close() — it fires socket.destroy()
157
+ // after 30s if the peer doesn't respond with a close frame.
158
+ const internal = ws as unknown as Record<string, unknown>;
159
+ const existingTimer = internal._closeTimer;
160
+ if (existingTimer) {
161
+ clearTimeout(existingTimer as ReturnType<typeof setTimeout>);
162
+ internal._closeTimer = null;
163
+ }
164
+
165
+ // Set our own timer that sends FIN instead of RST
166
+ const socket = internal._socket;
167
+ if (socket instanceof net.Socket) {
168
+ const finTimer = setTimeout(() => {
169
+ if (!socket.destroyed) {
170
+ socket.end(); // FIN, not RST
171
+ }
172
+ }, 30000);
173
+ // Don't let this timer keep the process alive
174
+ finTimer.unref();
175
+ internal._closeTimer = finTimer;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * State-aware close that never sends TCP RST to the local server.
181
+ *
182
+ * - CONNECTING: Don't call .close() (which triggers abortHandshake → destroy).
183
+ * Instead, remove relay listeners and let the connection either open
184
+ * (then close gracefully) or fail naturally.
185
+ * - OPEN: Use safeCloseOpen() to replace the destroy timer.
186
+ * - CLOSING: Already closing, just patch the error handler.
187
+ * - CLOSED: No-op.
188
+ */
189
+ function safeClose(ws: WebSocket, code?: number, reason?: string): void {
190
+ const closeCode = code ?? 1000;
191
+ const closeReason = reason ?? '';
192
+
193
+ switch (ws.readyState) {
194
+ case WebSocket.CONNECTING: {
195
+ // Don't call .close() — it would call abortHandshake() → socket.destroy()
196
+ // Remove message relay listeners so no data flows if the connection opens
197
+ ws.removeAllListeners('message');
198
+ ws.removeAllListeners('open');
199
+
200
+ // If it eventually opens, close it gracefully then
201
+ ws.on('open', () => {
202
+ safeCloseOpen(ws, closeCode, closeReason);
203
+ });
204
+ // If it errors (ECONNREFUSED, etc.), that's fine — natural teardown
205
+
206
+ // Fallback: if neither open nor error fires within 5s (e.g. TCP connects
207
+ // but HTTP upgrade hangs), terminate to prevent zombie accumulation.
208
+ const zombieTimer = setTimeout(() => {
209
+ if (ws.readyState === WebSocket.CONNECTING) {
210
+ ws.terminate();
211
+ }
212
+ }, 5000);
213
+ ws.on('open', () => clearTimeout(zombieTimer));
214
+ ws.on('error', () => clearTimeout(zombieTimer));
215
+ break;
216
+ }
217
+ case WebSocket.OPEN: {
218
+ safeCloseOpen(ws, closeCode, closeReason);
219
+ break;
220
+ }
221
+ case WebSocket.CLOSING: {
222
+ // Already closing — just make sure the socket error handler is patched
223
+ patchSocketErrorHandler(ws);
224
+ break;
225
+ }
226
+ case WebSocket.CLOSED: {
227
+ // Nothing to do
228
+ break;
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Create a tunnel to expose a local port via the tunnel server.
235
+ */
236
+ /**
237
+ * Resolve which loopback address can reach a given port.
238
+ * Tries 127.0.0.1 (IPv4) first, then ::1 (IPv6).
239
+ * Returns the working address, or '127.0.0.1' as default.
240
+ */
241
+ async function resolveLocalHost(port: number): Promise<string> {
242
+ for (const addr of ['127.0.0.1', '::1']) {
243
+ const ok = await new Promise<boolean>((resolve) => {
244
+ const sock = net.connect({ host: addr, port }, () => {
245
+ sock.destroy();
246
+ resolve(true);
247
+ });
248
+ sock.on('error', () => resolve(false));
249
+ sock.setTimeout(500, () => {
250
+ sock.destroy();
251
+ resolve(false);
252
+ });
253
+ });
254
+ if (ok) return addr;
255
+ }
256
+ return '127.0.0.1';
257
+ }
258
+
259
+ export function createTunnel({
260
+ port,
261
+ host,
262
+ tunnelId,
263
+ secret,
264
+ authRequired,
265
+ logger,
266
+ }: TunnelOptions): Promise<TunnelHandle> {
267
+ const log = logger ?? defaultLogger;
268
+ const wsUrl = `${host.replace(/^http/, 'ws')}/ws`;
269
+
270
+ // Resolved loopback address for this port (set before first connection)
271
+ let localHost = '127.0.0.1';
272
+
273
+ // Shared state across reconnections
274
+ let closed = false;
275
+ let reconnectDelay = 1000;
276
+ const MAX_RECONNECT_DELAY = 30000;
277
+ // Set to true on any successful registration. Once true, reconnect attempts that
278
+ // fail (close without receiving 'registered') will still retry instead of giving up.
279
+ let everRegistered = false;
280
+
281
+ function connect(
282
+ onRegistered: (handle: TunnelHandle) => void,
283
+ onFirstError: ((err: Error) => void) | null
284
+ ): void {
285
+ if (closed) return;
286
+
287
+ const ws = new WebSocket(wsUrl, { agent: http1Agent });
288
+ let registered = false;
289
+ // Hoisted so every teardown path (a reconnect via the 'close' handler, or an
290
+ // explicit handle.close()) can clear it. It used to be declared inside the
291
+ // 'registered' handler and cleared only on explicit close — so each reconnect
292
+ // leaked an interval that kept firing ws.ping() on a dead socket forever, and
293
+ // when the control connection flaps (reconnecting every second) those pile up
294
+ // fast.
295
+ let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
296
+ // Resets the reconnect backoff once the link has proven stable (see the
297
+ // 'registered' handler). Cleared on close so a connection that drops before
298
+ // proving stable keeps — and keeps growing — its backoff instead of snapping
299
+ // back to a 1s retry.
300
+ let stableTimer: ReturnType<typeof setTimeout> | null = null;
301
+
302
+ // Track local WebSocket connections: connId → WebSocket
303
+ const localWsConnections = new Map<number, WebSocket>();
304
+ // Track active HTTP requests for abort support: reqId → http.ClientRequest
305
+ const activeRequests = new Map<number, http.ClientRequest>();
306
+
307
+ const timeout = setTimeout(() => {
308
+ ws.close();
309
+ if (onFirstError) {
310
+ onFirstError(new Error('Tunnel connection timeout'));
311
+ onFirstError = null;
312
+ }
313
+ }, 10000);
314
+
315
+ ws.on('open', () => {
316
+ ws.send(
317
+ JSON.stringify({
318
+ type: 'register',
319
+ tunnelId,
320
+ secret,
321
+ replace: true,
322
+ authRequired: authRequired !== false,
323
+ })
324
+ );
325
+ });
326
+
327
+ ws.on('message', async (data: WebSocket.RawData) => {
328
+ let msg: TunnelMessage;
329
+ try {
330
+ msg = JSON.parse(data.toString());
331
+ } catch (err) {
332
+ log.warn(
333
+ { component: 'tunnel_client', action: 'message_parse_error', error: String(err) },
334
+ `Failed to parse tunnel message: ${err}`
335
+ );
336
+ return;
337
+ }
338
+
339
+ log.info(
340
+ { component: 'tunnel_client', action: 'message_received', port, type: msg.type },
341
+ `[MSG] Received: ${msg.type}`
342
+ );
343
+
344
+ if (msg.type === 'error') {
345
+ clearTimeout(timeout);
346
+ if (onFirstError) {
347
+ onFirstError(new Error(`Tunnel server rejected connection: ${msg.message}`));
348
+ onFirstError = null;
349
+ }
350
+ return;
351
+ }
352
+
353
+ if (msg.type === 'registered') {
354
+ clearTimeout(timeout);
355
+ registered = true;
356
+ everRegistered = true;
357
+ // Reset backoff only once the connection proves stable — NOT the instant
358
+ // we register. A server that accepts the registration then drops us a
359
+ // second later (infra cycling) would otherwise keep resetting us to a 1s
360
+ // retry, and we'd reconnect every second forever, hammering an already
361
+ // struggling server. Letting the backoff grow until the link holds for
362
+ // 30s eases off instead; a healthy link clears the timer well within it.
363
+ if (stableTimer) clearTimeout(stableTimer);
364
+ stableTimer = setTimeout(() => {
365
+ reconnectDelay = 1000;
366
+ }, 30000);
367
+ stableTimer.unref();
368
+
369
+ // Send keepalive every 25 seconds to prevent Fly.io idle timeout (30s)
370
+ keepaliveInterval = setInterval(() => {
371
+ if (ws.readyState === WebSocket.OPEN) {
372
+ ws.ping();
373
+ }
374
+ }, 25000);
375
+
376
+ log.info(
377
+ {
378
+ component: 'tunnel_client',
379
+ action: 'registered',
380
+ port,
381
+ tunnelId: msg.tunnelId,
382
+ url: msg.url,
383
+ },
384
+ `Tunnel registered: localhost:${port} → ${msg.url}`
385
+ );
386
+ onRegistered({
387
+ url: msg.url,
388
+ tunnelId: msg.tunnelId,
389
+ close: () => {
390
+ closed = true;
391
+ if (keepaliveInterval) clearInterval(keepaliveInterval);
392
+ if (stableTimer) clearTimeout(stableTimer);
393
+ for (const [, localWs] of localWsConnections) {
394
+ safeClose(localWs);
395
+ }
396
+ localWsConnections.clear();
397
+ ws.close();
398
+ },
399
+ });
400
+ }
401
+
402
+ if (msg.type === 'request') {
403
+ const localReq = forwardRequest(port, localHost, msg, ws, activeRequests, async (err) => {
404
+ // On ECONNREFUSED, re-resolve loopback and retry once
405
+ if (err.code === 'ECONNREFUSED') {
406
+ const newAddr = await resolveLocalHost(port);
407
+ if (newAddr !== localHost) {
408
+ log.info(
409
+ {
410
+ component: 'tunnel_client',
411
+ action: 're_resolved_local_host',
412
+ port,
413
+ from: localHost,
414
+ to: newAddr,
415
+ },
416
+ `Loopback changed from ${localHost} to ${newAddr} for port ${port}`
417
+ );
418
+ localHost = newAddr;
419
+ const retryReq = forwardRequest(port, localHost, msg, ws, activeRequests);
420
+ activeRequests.set(msg.reqId, retryReq);
421
+ return true; // suppressed the error
422
+ }
423
+ }
424
+ return false;
425
+ });
426
+ activeRequests.set(msg.reqId, localReq);
427
+ }
428
+
429
+ if (msg.type === 'request-abort') {
430
+ const localReq = activeRequests.get(msg.reqId);
431
+ if (localReq) {
432
+ localReq.destroy();
433
+ activeRequests.delete(msg.reqId);
434
+ }
435
+ }
436
+
437
+ // === WebSocket relay handling (message-level) ===
438
+
439
+ if (msg.type === 'ws-upgrade') {
440
+ log.info(
441
+ {
442
+ component: 'tunnel_client',
443
+ action: 'ws_upgrade_received',
444
+ connId: msg.connId,
445
+ path: msg.path,
446
+ },
447
+ `[WS-RELAY] Received ws-upgrade connId=${msg.connId}`
448
+ );
449
+ handleWsUpgrade(port, localHost, ws, msg, localWsConnections, log);
450
+ }
451
+
452
+ if (msg.type === 'ws-message') {
453
+ const localWs = localWsConnections.get(msg.connId);
454
+ if (localWs && localWs.readyState === WebSocket.OPEN) {
455
+ const buf = Buffer.from(msg.data, 'base64');
456
+ localWs.send(buf, { binary: msg.binary });
457
+ }
458
+ }
459
+
460
+ if (msg.type === 'ws-close') {
461
+ const localWs = localWsConnections.get(msg.connId);
462
+ if (localWs) {
463
+ const code =
464
+ msg.code &&
465
+ msg.code >= 1000 &&
466
+ msg.code <= 4999 &&
467
+ msg.code !== 1005 &&
468
+ msg.code !== 1006
469
+ ? msg.code
470
+ : 1000;
471
+ safeClose(localWs, code, msg.reason || '');
472
+ localWsConnections.delete(msg.connId);
473
+ }
474
+ }
475
+ });
476
+
477
+ ws.on('error', (err: Error) => {
478
+ clearTimeout(timeout);
479
+ log.warn(
480
+ { component: 'tunnel_client', action: 'ws_error', port, error: err.message },
481
+ `[ERROR] WebSocket error: ${err.message}`
482
+ );
483
+ if (onFirstError) {
484
+ onFirstError(err);
485
+ onFirstError = null;
486
+ }
487
+ });
488
+
489
+ ws.on('close', () => {
490
+ clearTimeout(timeout);
491
+ if (keepaliveInterval) {
492
+ clearInterval(keepaliveInterval);
493
+ keepaliveInterval = null;
494
+ }
495
+ if (stableTimer) {
496
+ clearTimeout(stableTimer);
497
+ stableTimer = null;
498
+ }
499
+ for (const [, localWs] of localWsConnections) {
500
+ safeClose(localWs);
501
+ }
502
+ localWsConnections.clear();
503
+
504
+ if (closed) return;
505
+
506
+ // Reconnect with exponential backoff
507
+ const delay = reconnectDelay;
508
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
509
+
510
+ if (!registered && !everRegistered) {
511
+ // This connect attempt never registered and we've never had a successful
512
+ // registration — caller got the error via onFirstError, don't retry.
513
+ log.warn(
514
+ { component: 'tunnel_client', action: 'close_before_registration', port },
515
+ `Tunnel closed before registration completed`
516
+ );
517
+ return;
518
+ }
519
+
520
+ log.info(
521
+ { component: 'tunnel_client', action: 'reconnecting', port, delay, registered },
522
+ `Tunnel disconnected, reconnecting in ${delay}ms`
523
+ );
524
+ setTimeout(() => connect(onRegistered, null), delay);
525
+ });
526
+ }
527
+
528
+ return new Promise((resolve, reject) => {
529
+ // Resolve which loopback address works before connecting
530
+ resolveLocalHost(port).then((addr) => {
531
+ localHost = addr;
532
+ if (addr !== '127.0.0.1') {
533
+ log.info(
534
+ { component: 'tunnel_client', action: 'resolved_local_host', port, address: addr },
535
+ `Using ${addr} for localhost:${port}`
536
+ );
537
+ }
538
+ connect(
539
+ (handle) => resolve(handle),
540
+ (err) => reject(err)
541
+ );
542
+ });
543
+ });
544
+ }
545
+
546
+ /**
547
+ * Handle a WebSocket upgrade request from the tunnel server.
548
+ * Uses the `ws` library to connect to the local server (avoids Bun segfault
549
+ * with raw http.request() upgrade to self).
550
+ */
551
+ function handleWsUpgrade(
552
+ port: number,
553
+ localAddr: string,
554
+ controlWs: WebSocket,
555
+ msg: TunnelWsUpgrade,
556
+ localWsConnections: Map<number, WebSocket>,
557
+ log: TunnelLogger
558
+ ): void {
559
+ const wsHost = localAddr.includes(':') ? `[${localAddr}]` : localAddr;
560
+ const localWsUrl = `ws://${wsHost}:${port}${msg.path}`;
561
+
562
+ // Forward the WebSocket subprotocol from the original browser request.
563
+ // Vite 6.x requires "vite-hmr" — without it, the upgrade is silently ignored.
564
+ const rawProtocol = msg.headers['sec-websocket-protocol'];
565
+ const protocols = rawProtocol
566
+ ? typeof rawProtocol === 'string'
567
+ ? rawProtocol.split(',').map((p) => p.trim())
568
+ : rawProtocol
569
+ : [];
570
+
571
+ log.info(
572
+ { component: 'tunnel_client', action: 'ws_upgrade_start', connId: msg.connId, port, protocols },
573
+ `[WS-RELAY] Connecting WebSocket to localhost:${port}`
574
+ );
575
+
576
+ // Mirror the HTTP relay: forward the browser's headers (notably the auth
577
+ // cookie) and stamp x-forwarded-* so the local app can both authenticate the
578
+ // upgrade and tell it arrived through the tunnel rather than from a genuine
579
+ // loopback client. Strip the hop-by-hop handshake headers the ws library
580
+ // sets itself (and sec-websocket-protocol, which is passed via `protocols`).
581
+ const headers: Record<string, string | string[] | undefined> = { ...msg.headers };
582
+ if (headers.host) {
583
+ headers['x-forwarded-host'] = headers.host;
584
+ }
585
+ headers['x-forwarded-proto'] = 'https';
586
+ headers.host = `localhost:${port}`;
587
+ headers.origin = `http://localhost:${port}`;
588
+ for (const hopByHop of [
589
+ 'connection',
590
+ 'upgrade',
591
+ 'sec-websocket-key',
592
+ 'sec-websocket-version',
593
+ 'sec-websocket-extensions',
594
+ 'sec-websocket-protocol',
595
+ ]) {
596
+ delete headers[hopByHop];
597
+ }
598
+
599
+ const localWs = new WebSocket(localWsUrl, protocols, { headers });
600
+
601
+ const connectTimeout = setTimeout(() => {
602
+ log.warn(
603
+ { component: 'tunnel_client', action: 'ws_upgrade_timeout', connId: msg.connId },
604
+ `[WS-RELAY] Connection timeout for connId=${msg.connId}`
605
+ );
606
+ safeClose(localWs);
607
+ if (controlWs.readyState === WebSocket.OPEN) {
608
+ controlWs.send(
609
+ JSON.stringify({
610
+ type: 'ws-error',
611
+ connId: msg.connId,
612
+ error: 'Connection timeout',
613
+ })
614
+ );
615
+ }
616
+ }, 15000);
617
+
618
+ localWs.on('open', () => {
619
+ clearTimeout(connectTimeout);
620
+ localWsConnections.set(msg.connId, localWs);
621
+
622
+ log.info(
623
+ { component: 'tunnel_client', action: 'ws_upgrade_success', connId: msg.connId },
624
+ `[WS-RELAY] Connected, sending ws-ready for connId=${msg.connId}`
625
+ );
626
+
627
+ controlWs.send(
628
+ JSON.stringify({
629
+ type: 'ws-ready',
630
+ connId: msg.connId,
631
+ })
632
+ );
633
+ });
634
+
635
+ localWs.on('message', (data: WebSocket.RawData, isBinary: boolean) => {
636
+ if (controlWs.readyState === WebSocket.OPEN) {
637
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
638
+ controlWs.send(
639
+ JSON.stringify({
640
+ type: 'ws-message',
641
+ connId: msg.connId,
642
+ data: buf.toString('base64'),
643
+ binary: isBinary,
644
+ })
645
+ );
646
+ }
647
+ });
648
+
649
+ localWs.on('close', (code: number, reason: Buffer) => {
650
+ log.info(
651
+ { component: 'tunnel_client', action: 'ws_relay_local_close', connId: msg.connId, code },
652
+ `[WS-RELAY] Local WS closed connId=${msg.connId} code=${code}`
653
+ );
654
+ if (controlWs.readyState === WebSocket.OPEN) {
655
+ controlWs.send(
656
+ JSON.stringify({
657
+ type: 'ws-close',
658
+ connId: msg.connId,
659
+ code,
660
+ reason: reason?.toString() || '',
661
+ })
662
+ );
663
+ }
664
+ localWsConnections.delete(msg.connId);
665
+ });
666
+
667
+ localWs.on('error', (err: Error) => {
668
+ clearTimeout(connectTimeout);
669
+ log.warn(
670
+ {
671
+ component: 'tunnel_client',
672
+ action: 'ws_relay_error',
673
+ connId: msg.connId,
674
+ error: err.message,
675
+ },
676
+ `[WS-RELAY] Local WS error connId=${msg.connId}: ${err.message}`
677
+ );
678
+ if (controlWs.readyState === WebSocket.OPEN) {
679
+ controlWs.send(
680
+ JSON.stringify({
681
+ type: 'ws-error',
682
+ connId: msg.connId,
683
+ error: err.message,
684
+ })
685
+ );
686
+ }
687
+ localWsConnections.delete(msg.connId);
688
+ });
689
+ }
690
+
691
+ function send502(ws: WebSocket, reqId: number, message: string): void {
692
+ if (ws.readyState === WebSocket.OPEN) {
693
+ ws.send(
694
+ JSON.stringify({
695
+ type: 'response',
696
+ reqId,
697
+ status: 502,
698
+ headers: { 'content-type': 'text/plain' },
699
+ body: Buffer.from(`Local server error: ${message}`).toString('base64'),
700
+ })
701
+ );
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Forward a tunneled request to the local server, streaming the response
707
+ * back over the WebSocket as response-start / response-chunk / response-end.
708
+ */
709
+ function forwardRequest(
710
+ port: number,
711
+ localAddr: string,
712
+ msg: TunnelRequest,
713
+ ws: WebSocket,
714
+ activeRequests: Map<number, http.ClientRequest>,
715
+ onConnRefused?: (err: NodeJS.ErrnoException) => Promise<boolean>
716
+ ): http.ClientRequest {
717
+ const headers: Record<string, string | string[] | undefined> = { ...msg.headers };
718
+ // Preserve the original Host for apps that build redirect URLs from it (e.g. Clerk/Next.js)
719
+ // Forward the original as X-Forwarded-Host so the app knows the public hostname
720
+ if (headers.host) {
721
+ headers['x-forwarded-host'] = headers.host;
722
+ }
723
+ headers['x-forwarded-proto'] = 'https';
724
+ // Rewrite host so the target server accepts the request
725
+ headers.host = `localhost:${port}`;
726
+ // Rewrite origin and referer to localhost so the local app behaves as if
727
+ // accessed directly. Prevents CSRF rejections from frameworks that check origin.
728
+ const localOrigin = `http://localhost:${port}`;
729
+ if (typeof headers.origin === 'string' && !headers.origin.includes('localhost')) {
730
+ headers.origin = localOrigin;
731
+ }
732
+ if (typeof headers.referer === 'string' && !headers.referer.includes('localhost')) {
733
+ try {
734
+ const ref = new URL(headers.referer);
735
+ headers.referer = `${localOrigin}${ref.pathname}${ref.search}${ref.hash}`;
736
+ } catch {
737
+ headers.referer = localOrigin;
738
+ }
739
+ }
740
+ // Remove headers that shouldn't be forwarded
741
+ delete headers['transfer-encoding'];
742
+
743
+ const req = http.request(
744
+ {
745
+ hostname: localAddr,
746
+ port,
747
+ path: msg.path,
748
+ method: msg.method,
749
+ headers: headers as http.OutgoingHttpHeaders,
750
+ },
751
+ (res) => {
752
+ // Send headers immediately
753
+ const responseHeaders: Record<string, string | string[] | undefined> = {};
754
+ for (const [key, value] of Object.entries(res.headers)) {
755
+ responseHeaders[key] = value;
756
+ }
757
+ if (ws.readyState === WebSocket.OPEN) {
758
+ ws.send(
759
+ JSON.stringify({
760
+ type: 'response-start',
761
+ reqId: msg.reqId,
762
+ status: res.statusCode ?? 200,
763
+ headers: responseHeaders,
764
+ })
765
+ );
766
+ }
767
+
768
+ // Stream body chunks
769
+ res.on('data', (chunk: Buffer) => {
770
+ if (ws.readyState === WebSocket.OPEN) {
771
+ ws.send(
772
+ JSON.stringify({
773
+ type: 'response-chunk',
774
+ reqId: msg.reqId,
775
+ data: chunk.toString('base64'),
776
+ })
777
+ );
778
+ }
779
+ });
780
+
781
+ // Signal completion
782
+ res.on('end', () => {
783
+ activeRequests.delete(msg.reqId);
784
+ if (ws.readyState === WebSocket.OPEN) {
785
+ ws.send(JSON.stringify({ type: 'response-end', reqId: msg.reqId }));
786
+ }
787
+ });
788
+ }
789
+ );
790
+
791
+ req.on('error', (err: NodeJS.ErrnoException) => {
792
+ activeRequests.delete(msg.reqId);
793
+
794
+ // If ECONNREFUSED and caller wants to retry with re-resolved address, let them
795
+ if (onConnRefused && err.code === 'ECONNREFUSED') {
796
+ onConnRefused(err).then((retried) => {
797
+ if (retried) return; // caller handled it
798
+ send502(ws, msg.reqId, err.message);
799
+ });
800
+ return;
801
+ }
802
+
803
+ send502(ws, msg.reqId, err.message);
804
+ });
805
+
806
+ if (msg.body) {
807
+ req.end(Buffer.from(msg.body, 'base64'));
808
+ } else {
809
+ req.end();
810
+ }
811
+
812
+ return req;
813
+ }
814
+
815
+ // ============================================================================
816
+ // CLI entry point — `bun run tunnel-client.ts --port 3000 [--host URL] [--tunnel-id ID]`
817
+ // Prints the public URL to stdout, stays alive until SIGTERM/SIGINT.
818
+ // ============================================================================
819
+
820
+ if (import.meta.main) {
821
+ const args = process.argv.slice(2);
822
+
823
+ function flag(name: string): string | undefined {
824
+ const i = args.indexOf(`--${name}`);
825
+ return i >= 0 ? args[i + 1] : undefined;
826
+ }
827
+
828
+ const port = Number(flag('port'));
829
+ if (!port) {
830
+ console.error(
831
+ 'Usage: bun run tunnel-client.ts --port <port> [--host <url>] [--tunnel-id <id>] [--auth-not-required]'
832
+ );
833
+ process.exit(1);
834
+ }
835
+
836
+ const host =
837
+ flag('host') || process.env.TUNNEL_SERVER_URL || 'https://vgit-tunnels.volterapp.com';
838
+ const secret = process.env.TUNNEL_SECRET;
839
+ const tunnelId = flag('tunnel-id');
840
+ const authNotRequired = args.includes('--auth-not-required');
841
+
842
+ // CLI logger: send to stderr so only the URL goes to stdout
843
+ const cliLogger: TunnelLogger = {
844
+ info(_obj, msg) {
845
+ console.error(msg);
846
+ },
847
+ warn(_obj, msg) {
848
+ console.error(msg);
849
+ },
850
+ debug(_obj, msg) {
851
+ console.error(msg);
852
+ },
853
+ };
854
+
855
+ const opts: TunnelOptions = { port, host, logger: cliLogger };
856
+ if (secret) opts.secret = secret;
857
+ if (tunnelId) opts.tunnelId = tunnelId;
858
+ if (authNotRequired) opts.authRequired = false;
859
+ const handle = await createTunnel(opts);
860
+ console.log(handle.url);
861
+
862
+ const shutdown = () => {
863
+ handle.close();
864
+ process.exit(0);
865
+ };
866
+ process.on('SIGTERM', shutdown);
867
+ process.on('SIGINT', shutdown);
868
+ }