tunelo 0.0.0-stage → 1.6.2

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.
Files changed (4) hide show
  1. package/README.md +101 -2
  2. package/cli.js +2163 -0
  3. package/cli.js.map +7 -0
  4. package/package.json +42 -4
package/cli.js ADDED
@@ -0,0 +1,2163 @@
1
+ #!/usr/bin/env node
2
+ // Tunelo CLI — bundled with esbuild
3
+
4
+ // packages/client/src/cli.ts
5
+ import { program } from "commander";
6
+
7
+ // packages/client/src/cli-login-command.ts
8
+ import { randomUUID } from "node:crypto";
9
+ import { hostname } from "node:os";
10
+
11
+ // packages/client/src/api-client.ts
12
+ var ApiClientError = class extends Error {
13
+ constructor(code, message, statusCode) {
14
+ super(message);
15
+ this.code = code;
16
+ this.statusCode = statusCode;
17
+ this.name = "ApiClientError";
18
+ }
19
+ };
20
+ function getApiBaseUrl(serverUrl) {
21
+ return serverUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://").replace(/\/$/, "");
22
+ }
23
+ async function request(baseUrl, path3, options = {}) {
24
+ const headers = {};
25
+ if (options.apiKey) headers.Authorization = `Bearer ${options.apiKey}`;
26
+ if (options.body !== void 0) headers["Content-Type"] = "application/json";
27
+ const res = await fetch(`${baseUrl}/api${path3}`, {
28
+ method: options.method ?? "GET",
29
+ headers,
30
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
31
+ });
32
+ const text = await res.text();
33
+ let data;
34
+ try {
35
+ data = JSON.parse(text);
36
+ } catch {
37
+ throw new ApiClientError(
38
+ "TUNELO_UNKNOWN",
39
+ text || `HTTP ${res.status}`,
40
+ res.status
41
+ );
42
+ }
43
+ if (!res.ok) {
44
+ throw new ApiClientError(
45
+ data.error?.code ?? "TUNELO_UNKNOWN",
46
+ data.error?.message ?? `HTTP ${res.status}`,
47
+ res.status
48
+ );
49
+ }
50
+ return data;
51
+ }
52
+ async function initDeviceLogin(baseUrl, deviceId, name) {
53
+ const data = await request(baseUrl, "/device/init", {
54
+ method: "POST",
55
+ body: { deviceId, name }
56
+ });
57
+ return data.requestId;
58
+ }
59
+ async function pollDeviceLogin(baseUrl, requestId) {
60
+ return request(baseUrl, "/device/poll", {
61
+ method: "POST",
62
+ body: { req: requestId }
63
+ });
64
+ }
65
+ async function verifyKey(baseUrl, key) {
66
+ try {
67
+ await request(baseUrl, "/verify", { apiKey: key });
68
+ return true;
69
+ } catch (err) {
70
+ if (err instanceof ApiClientError && err.statusCode === 401) return false;
71
+ throw err;
72
+ }
73
+ }
74
+
75
+ // packages/client/src/config.ts
76
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
77
+ import { homedir } from "node:os";
78
+ import { join } from "node:path";
79
+ function getConfigDir() {
80
+ return join(homedir(), ".tunelo");
81
+ }
82
+ function getConfigPath() {
83
+ return join(getConfigDir(), "config.json");
84
+ }
85
+ function loadConfig() {
86
+ try {
87
+ return JSON.parse(readFileSync(getConfigPath(), "utf-8"));
88
+ } catch {
89
+ return {};
90
+ }
91
+ }
92
+ function saveConfig(config) {
93
+ const dir = getConfigDir();
94
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
95
+ writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
96
+ }
97
+
98
+ // packages/client/src/cli-login-command.ts
99
+ function getServerBaseUrl() {
100
+ const config = loadConfig();
101
+ const serverUrl = process.env.TUNELO_SERVER ?? config.server ?? "wss://tunnel.inetdev.io.vn";
102
+ return getApiBaseUrl(serverUrl);
103
+ }
104
+ function getDeviceId() {
105
+ const config = loadConfig();
106
+ if (config.deviceId) return config.deviceId;
107
+ const deviceId = randomUUID();
108
+ saveConfig({ ...config, deviceId });
109
+ return deviceId;
110
+ }
111
+ function saveKey(token) {
112
+ saveConfig({ ...loadConfig(), key: token });
113
+ }
114
+ function sleep(ms) {
115
+ return new Promise((r) => setTimeout(r, ms));
116
+ }
117
+ async function openBrowser(url) {
118
+ const { execFile } = await import("node:child_process");
119
+ const { promisify } = await import("node:util");
120
+ const run = promisify(execFile);
121
+ try {
122
+ if (process.platform === "darwin") await run("open", [url]);
123
+ else if (process.platform === "win32")
124
+ await run("cmd", ["/c", "start", "", url]);
125
+ else await run("xdg-open", [url]);
126
+ } catch {
127
+ }
128
+ }
129
+ function registerLoginCommands(program2) {
130
+ program2.command("login").description("Authorize this device via the browser").option("-k, --key <token>", "Use an existing access token (skip browser)").option("-f, --force", "Re-authorize even if already signed in").action(async (options) => {
131
+ const baseUrl = getServerBaseUrl();
132
+ if (options.key) {
133
+ if (!await verifyKey(baseUrl, options.key).catch(() => false)) {
134
+ console.error(" Invalid access token.");
135
+ process.exit(1);
136
+ }
137
+ saveKey(options.key);
138
+ console.log(" \u2713 Verified & saved. Run: tunelo http <port>");
139
+ return;
140
+ }
141
+ const existing = loadConfig().key;
142
+ if (existing && !options.force) {
143
+ if (await verifyKey(baseUrl, existing).catch(() => false)) {
144
+ console.log(
145
+ " Already authorized on this device. (use --force to redo)"
146
+ );
147
+ return;
148
+ }
149
+ }
150
+ const deviceId = getDeviceId();
151
+ let requestId;
152
+ try {
153
+ requestId = await initDeviceLogin(baseUrl, deviceId, hostname());
154
+ } catch (err) {
155
+ console.error(` Could not reach server: ${err.message}`);
156
+ process.exit(1);
157
+ }
158
+ const url = `${baseUrl}/login?req=${encodeURIComponent(requestId)}`;
159
+ console.log();
160
+ console.log(" Opening browser to authorize this device...");
161
+ console.log(` ${url}`);
162
+ console.log();
163
+ console.log(" Waiting for approval in the browser...");
164
+ await openBrowser(url);
165
+ const deadline = Date.now() + 5 * 60 * 1e3;
166
+ while (Date.now() < deadline) {
167
+ await sleep(2e3);
168
+ let result;
169
+ try {
170
+ result = await pollDeviceLogin(baseUrl, requestId);
171
+ } catch {
172
+ continue;
173
+ }
174
+ if (result.status === "approved") {
175
+ saveKey(result.token);
176
+ console.log(
177
+ " \u2713 Device authorized & signed in. Run: tunelo http <port>"
178
+ );
179
+ return;
180
+ }
181
+ if (result.status === "expired") {
182
+ console.error(" Login request expired. Run `tunelo login` again.");
183
+ process.exit(1);
184
+ }
185
+ }
186
+ console.error(" Timed out waiting for approval.");
187
+ process.exit(1);
188
+ });
189
+ program2.command("logout").description("Remove the saved access token").action(() => {
190
+ const config = loadConfig();
191
+ if (!config.key) {
192
+ console.log(" Not logged in.");
193
+ return;
194
+ }
195
+ saveConfig({ ...config, key: void 0 });
196
+ console.log(" Logged out. Access token removed.");
197
+ });
198
+ }
199
+
200
+ // packages/client/src/run-registry.ts
201
+ import {
202
+ existsSync as existsSync2,
203
+ mkdirSync as mkdirSync2,
204
+ readFileSync as readFileSync2,
205
+ readdirSync,
206
+ rmSync,
207
+ writeFileSync as writeFileSync2
208
+ } from "node:fs";
209
+ import { homedir as homedir2 } from "node:os";
210
+ import { join as join2 } from "node:path";
211
+ function getRunDir() {
212
+ return join2(homedir2(), ".tunelo", "running");
213
+ }
214
+ function recordPath(pid) {
215
+ return join2(getRunDir(), `${pid}.json`);
216
+ }
217
+ function isAlive(pid) {
218
+ try {
219
+ process.kill(pid, 0);
220
+ return true;
221
+ } catch (err) {
222
+ return err.code === "EPERM";
223
+ }
224
+ }
225
+ function saveRecord(record) {
226
+ const dir = getRunDir();
227
+ if (!existsSync2(dir)) mkdirSync2(dir, { recursive: true });
228
+ writeFileSync2(recordPath(record.pid), JSON.stringify(record));
229
+ }
230
+ function removeRecord(pid) {
231
+ rmSync(recordPath(pid), { force: true });
232
+ }
233
+ function listRecords() {
234
+ const dir = getRunDir();
235
+ if (!existsSync2(dir)) return [];
236
+ const records = [];
237
+ for (const file of readdirSync(dir)) {
238
+ if (!file.endsWith(".json")) continue;
239
+ const path3 = join2(dir, file);
240
+ let record;
241
+ try {
242
+ record = JSON.parse(readFileSync2(path3, "utf-8"));
243
+ } catch {
244
+ rmSync(path3, { force: true });
245
+ continue;
246
+ }
247
+ if (!isAlive(record.pid)) {
248
+ rmSync(path3, { force: true });
249
+ continue;
250
+ }
251
+ records.push(record);
252
+ }
253
+ return records.sort((a, b) => a.startedAt - b.startedAt);
254
+ }
255
+ function startRunRecord(kind, server) {
256
+ const pid = process.pid;
257
+ const record = {
258
+ pid,
259
+ kind,
260
+ startedAt: Date.now(),
261
+ server,
262
+ tunnels: []
263
+ };
264
+ saveRecord(record);
265
+ const cleanup = () => removeRecord(pid);
266
+ process.on("exit", cleanup);
267
+ process.on("SIGTERM", () => {
268
+ cleanup();
269
+ process.exit(0);
270
+ });
271
+ return {
272
+ update(tunnels) {
273
+ record.tunnels = tunnels;
274
+ saveRecord(record);
275
+ }
276
+ };
277
+ }
278
+
279
+ // packages/client/src/cli-status-commands.ts
280
+ function formatUptime(ms) {
281
+ const s = Math.floor(ms / 1e3);
282
+ if (s < 60) return `${s}s`;
283
+ const m = Math.floor(s / 60);
284
+ if (m < 60) return `${m}m ${s % 60}s`;
285
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
286
+ }
287
+ function registerStatusCommands(program2) {
288
+ program2.command("status").description("List tunnels running on this machine").action(() => {
289
+ const records = listRecords();
290
+ if (records.length === 0) {
291
+ console.log("No tunnels running.");
292
+ return;
293
+ }
294
+ const now = Date.now();
295
+ for (const r of records) {
296
+ const tunnels = r.tunnels.length ? r.tunnels.map((t) => `${t.url} \u2192 :${t.localPort}`).join(", ") : "(connecting\u2026)";
297
+ console.log(
298
+ `PID ${r.pid} [${r.kind}] up ${formatUptime(now - r.startedAt)} ${tunnels}`
299
+ );
300
+ }
301
+ });
302
+ program2.command("kill <target>").description("Stop a running tunnel by PID, subdomain/URL, or 'all'").action((target) => {
303
+ const records = listRecords();
304
+ const matches = target === "all" ? records : records.filter(
305
+ (r) => String(r.pid) === target || r.tunnels.some((t) => t.url.includes(target))
306
+ );
307
+ if (matches.length === 0) {
308
+ console.log(`No running tunnel matches "${target}".`);
309
+ return;
310
+ }
311
+ for (const r of matches) {
312
+ try {
313
+ process.kill(r.pid, "SIGTERM");
314
+ console.log(`Stopped PID ${r.pid} (${r.kind}).`);
315
+ } catch {
316
+ removeRecord(r.pid);
317
+ console.log(`PID ${r.pid} was not running \u2014 removed stale record.`);
318
+ }
319
+ }
320
+ });
321
+ }
322
+
323
+ // packages/client/src/tcp-proxy.ts
324
+ import net from "node:net";
325
+ var TcpProxy = class {
326
+ connections = /* @__PURE__ */ new Map();
327
+ client;
328
+ localPort;
329
+ localHost;
330
+ constructor(client, localPort, localHost = "127.0.0.1") {
331
+ this.client = client;
332
+ this.localPort = localPort;
333
+ this.localHost = localHost;
334
+ this.setupHandlers();
335
+ }
336
+ setupHandlers() {
337
+ this.client.on("tcp-connection-open", (msg) => {
338
+ this.handleConnectionOpen(msg);
339
+ });
340
+ this.client.on("tcp-data", (msg) => {
341
+ this.handleData(msg);
342
+ });
343
+ this.client.on("tcp-connection-close", (msg) => {
344
+ this.handleConnectionClose(msg);
345
+ });
346
+ }
347
+ handleConnectionOpen(msg) {
348
+ const localSocket = net.createConnection({
349
+ host: this.localHost,
350
+ port: this.localPort
351
+ });
352
+ const conn = {
353
+ socket: localSocket,
354
+ connectionId: msg.connectionId
355
+ };
356
+ this.connections.set(msg.connectionId, conn);
357
+ localSocket.on("data", (chunk) => {
358
+ this.client.sendMsg({
359
+ type: "tcp-data",
360
+ connectionId: msg.connectionId,
361
+ data: chunk.toString("base64")
362
+ });
363
+ });
364
+ localSocket.on("close", () => {
365
+ this.connections.delete(msg.connectionId);
366
+ this.client.sendMsg({
367
+ type: "tcp-connection-close",
368
+ connectionId: msg.connectionId
369
+ });
370
+ });
371
+ localSocket.on("error", () => {
372
+ localSocket.destroy();
373
+ this.connections.delete(msg.connectionId);
374
+ this.client.sendMsg({
375
+ type: "tcp-connection-close",
376
+ connectionId: msg.connectionId
377
+ });
378
+ });
379
+ }
380
+ handleData(msg) {
381
+ const conn = this.connections.get(msg.connectionId);
382
+ if (conn) {
383
+ conn.socket.write(Buffer.from(msg.data, "base64"));
384
+ }
385
+ }
386
+ handleConnectionClose(msg) {
387
+ const conn = this.connections.get(msg.connectionId);
388
+ if (conn) {
389
+ conn.socket.destroy();
390
+ this.connections.delete(msg.connectionId);
391
+ }
392
+ }
393
+ close() {
394
+ for (const [, conn] of this.connections) {
395
+ conn.socket.destroy();
396
+ }
397
+ this.connections.clear();
398
+ }
399
+ };
400
+
401
+ // packages/client/src/tunnel-client.ts
402
+ import { EventEmitter } from "node:events";
403
+
404
+ // packages/shared/src/protocol.ts
405
+ function parseMessage(data) {
406
+ return JSON.parse(data);
407
+ }
408
+
409
+ // packages/shared/src/constants.ts
410
+ var DEFAULTS = {
411
+ SERVER_PORT: 3001,
412
+ WS_PATH: "/tunnel",
413
+ HEALTH_PATH: "/health",
414
+ MAX_BODY_SIZE: 50 * 1024 * 1024,
415
+ REQUEST_TIMEOUT_MS: 3e4,
416
+ /** Idle timeout (ms) for a relayed response stream — reset on every frame so SSE/long downloads stay open */
417
+ STREAM_IDLE_TIMEOUT_MS: 6e4,
418
+ PING_INTERVAL_MS: 3e4,
419
+ /** Cap on the WS handshake, TCP connect included. Without one, a blackholed
420
+ * SYN stalls the attempt for the kernel's whole retry budget (~135s
421
+ * measured), and the reconnect backoff sits idle for all of it. */
422
+ CONNECT_TIMEOUT_MS: 1e4,
423
+ RECONNECT_BASE_MS: 1e3,
424
+ RECONNECT_MAX_MS: 3e4,
425
+ MAX_SUBDOMAIN_LENGTH: 63,
426
+ /** Max WebSocket messages per second per connection */
427
+ WS_RATE_LIMIT: 1e3,
428
+ /** Grace period (ms) to wait for client reconnect before rejecting pending requests */
429
+ RECONNECT_GRACE_MS: 5e3,
430
+ /** TCP tunnel port range start */
431
+ TCP_PORT_MIN: 1e4,
432
+ /** TCP tunnel port range end */
433
+ TCP_PORT_MAX: 10999,
434
+ /** Max TCP tunnels per server */
435
+ TCP_MAX_TUNNELS: 100,
436
+ /** Max concurrent TCP connections per tunnel */
437
+ TCP_MAX_CONNECTIONS_PER_TUNNEL: 50,
438
+ /** TCP idle connection timeout (ms) — 10 minutes */
439
+ TCP_IDLE_TIMEOUT_MS: 10 * 60 * 1e3,
440
+ /** Max new TCP connections per second per tunnel */
441
+ TCP_CONNECT_RATE_LIMIT: 10,
442
+ /** Max concurrent browser WebSocket connections per tunnel */
443
+ WS_APP_MAX_PER_TUNNEL: 50,
444
+ /** Local WS handshake timeout (ms) for the browser→app relay */
445
+ WS_APP_HANDSHAKE_TIMEOUT_MS: 1e4,
446
+ /** Max frames buffered before the local WS finishes opening */
447
+ WS_APP_PREOPEN_QUEUE_MAX: 64
448
+ };
449
+ var SUBDOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
450
+
451
+ // packages/shared/src/http-headers.ts
452
+ var HOP_BY_HOP = /* @__PURE__ */ new Set([
453
+ "connection",
454
+ "keep-alive",
455
+ "proxy-authenticate",
456
+ "proxy-authorization",
457
+ "te",
458
+ "trailers",
459
+ "transfer-encoding",
460
+ "upgrade"
461
+ ]);
462
+ function stripHopByHopHeaders(headers) {
463
+ const out = {};
464
+ for (const [key, value] of Object.entries(headers)) {
465
+ if (!HOP_BY_HOP.has(key.toLowerCase())) out[key] = value;
466
+ }
467
+ return out;
468
+ }
469
+
470
+ // packages/client/src/tunnel-client.ts
471
+ import WebSocket2 from "ws";
472
+
473
+ // packages/client/src/http-stream.ts
474
+ import http from "node:http";
475
+ var INSPECTOR_PREVIEW_CAP = 256 * 1024;
476
+ var FRAME_BYTES = 256 * 1024;
477
+ var HIGH_WATER = 4 * 1024 * 1024;
478
+ var LOW_WATER = 1 * 1024 * 1024;
479
+ function proxyRequestStream(request2, localPort, transport, localHost = "localhost") {
480
+ const id = request2.id;
481
+ let settled = false;
482
+ let started = false;
483
+ let resStream = null;
484
+ let resolveDone;
485
+ const done = new Promise((r) => {
486
+ resolveDone = r;
487
+ });
488
+ const previewChunks = [];
489
+ let previewSize = 0;
490
+ let truncated = false;
491
+ let status = 502;
492
+ let headers = {};
493
+ function capturePreview(chunk) {
494
+ if (truncated) return;
495
+ const remaining = INSPECTOR_PREVIEW_CAP - previewSize;
496
+ if (remaining <= 0) {
497
+ truncated = true;
498
+ return;
499
+ }
500
+ const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
501
+ previewChunks.push(slice);
502
+ previewSize += slice.length;
503
+ if (chunk.length > remaining) truncated = true;
504
+ }
505
+ function finish() {
506
+ if (settled) return;
507
+ settled = true;
508
+ resolveDone({
509
+ status,
510
+ headers,
511
+ bodyPreview: previewChunks.length ? Buffer.concat(previewChunks).toString("base64") : null,
512
+ truncated
513
+ });
514
+ }
515
+ function fail(message) {
516
+ if (settled) return;
517
+ if (started) {
518
+ transport.send({ type: "stream-error", id, message });
519
+ } else {
520
+ status = 502;
521
+ headers = { "content-type": "text/plain" };
522
+ const payload = Buffer.from(message);
523
+ capturePreview(payload);
524
+ transport.send({ type: "stream-start", id, status, headers });
525
+ transport.send({
526
+ type: "stream-data",
527
+ id,
528
+ data: payload.toString("base64")
529
+ });
530
+ transport.send({ type: "stream-end", id });
531
+ }
532
+ finish();
533
+ }
534
+ let pending = [];
535
+ let pendingBytes = 0;
536
+ let flushScheduled = false;
537
+ function flushNow() {
538
+ if (pending.length === 0 || settled) return;
539
+ const buf = Buffer.concat(pending);
540
+ pending = [];
541
+ pendingBytes = 0;
542
+ transport.send({ type: "stream-data", id, data: buf.toString("base64") });
543
+ applyBackpressure();
544
+ }
545
+ function scheduleFlush() {
546
+ if (flushScheduled) return;
547
+ flushScheduled = true;
548
+ setImmediate(() => {
549
+ flushScheduled = false;
550
+ flushNow();
551
+ });
552
+ }
553
+ function applyBackpressure() {
554
+ if (!resStream || resStream.isPaused()) return;
555
+ if (transport.bufferedAmount() <= HIGH_WATER) return;
556
+ resStream.pause();
557
+ const timer = setInterval(() => {
558
+ if (settled) {
559
+ clearInterval(timer);
560
+ return;
561
+ }
562
+ if (transport.bufferedAmount() <= LOW_WATER) {
563
+ clearInterval(timer);
564
+ resStream?.resume();
565
+ }
566
+ }, 25);
567
+ }
568
+ const body = request2.body ? Buffer.from(request2.body, "base64") : null;
569
+ const proxyReq = http.request(
570
+ {
571
+ hostname: localHost,
572
+ port: localPort,
573
+ path: request2.path,
574
+ method: request2.method,
575
+ headers: stripHopByHopHeaders(request2.headers)
576
+ },
577
+ (proxyRes) => {
578
+ if (settled) return;
579
+ resStream = proxyRes;
580
+ status = proxyRes.statusCode ?? 502;
581
+ headers = stripHopByHopHeaders(
582
+ proxyRes.headers
583
+ );
584
+ started = true;
585
+ transport.send({ type: "stream-start", id, status, headers });
586
+ proxyRes.on("data", (chunk) => {
587
+ if (settled) return;
588
+ capturePreview(chunk);
589
+ pending.push(chunk);
590
+ pendingBytes += chunk.length;
591
+ if (pendingBytes >= FRAME_BYTES) flushNow();
592
+ else scheduleFlush();
593
+ });
594
+ proxyRes.on("end", () => {
595
+ if (settled) return;
596
+ flushNow();
597
+ transport.send({ type: "stream-end", id });
598
+ finish();
599
+ });
600
+ proxyRes.on("error", () => fail("Local response error"));
601
+ proxyRes.on("aborted", () => fail("Local response aborted"));
602
+ }
603
+ );
604
+ proxyReq.on("error", (err) => {
605
+ const code = err.code;
606
+ fail(code === "ECONNREFUSED" ? "Local service unavailable" : err.message);
607
+ });
608
+ proxyReq.setTimeout(DEFAULTS.STREAM_IDLE_TIMEOUT_MS, () => {
609
+ fail("Local service timeout");
610
+ proxyReq.destroy();
611
+ });
612
+ if (body) proxyReq.write(body);
613
+ proxyReq.end();
614
+ return {
615
+ cancel() {
616
+ if (settled) return;
617
+ settled = true;
618
+ proxyReq.destroy();
619
+ resolveDone({
620
+ status,
621
+ headers,
622
+ bodyPreview: previewChunks.length ? Buffer.concat(previewChunks).toString("base64") : null,
623
+ truncated
624
+ });
625
+ },
626
+ done
627
+ };
628
+ }
629
+
630
+ // packages/client/src/ws-app-proxy.ts
631
+ import WebSocket from "ws";
632
+ function toBuffer(data) {
633
+ if (Buffer.isBuffer(data)) return data;
634
+ if (Array.isArray(data)) return Buffer.concat(data);
635
+ return Buffer.from(data);
636
+ }
637
+ function safeClose(ws, code, reason) {
638
+ try {
639
+ if (code && code >= 1e3 && code <= 4999) ws.close(code, reason);
640
+ else ws.close();
641
+ } catch {
642
+ ws.terminate();
643
+ }
644
+ }
645
+ var WS_HANDSHAKE_HEADERS = [
646
+ "host",
647
+ "sec-websocket-key",
648
+ "sec-websocket-version",
649
+ "sec-websocket-extensions",
650
+ "sec-websocket-protocol",
651
+ "content-length"
652
+ ];
653
+ var WsAppProxy = class {
654
+ constructor(send, resolvePort) {
655
+ this.send = send;
656
+ this.resolvePort = resolvePort;
657
+ }
658
+ conns = /* @__PURE__ */ new Map();
659
+ open(msg) {
660
+ const port = this.resolvePort(msg.subdomain);
661
+ const headers = stripHopByHopHeaders(msg.headers);
662
+ for (const h of WS_HANDSHAKE_HEADERS) delete headers[h];
663
+ const url = `ws://localhost:${port}${msg.path}`;
664
+ const protocols = msg.protocol ? msg.protocol.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
665
+ const opts = {
666
+ headers,
667
+ handshakeTimeout: DEFAULTS.WS_APP_HANDSHAKE_TIMEOUT_MS
668
+ };
669
+ const local = protocols ? new WebSocket(url, protocols, opts) : new WebSocket(url, opts);
670
+ const conn = { ws: local, ready: false, queue: [] };
671
+ this.conns.set(msg.id, conn);
672
+ local.on("open", () => {
673
+ conn.ready = true;
674
+ for (const q of conn.queue) {
675
+ local.send(Buffer.from(q.data, "base64"), { binary: q.binary });
676
+ }
677
+ conn.queue = [];
678
+ });
679
+ local.on("message", (data, isBinary) => {
680
+ this.send({
681
+ type: "ws-data",
682
+ id: msg.id,
683
+ data: toBuffer(data).toString("base64"),
684
+ binary: isBinary
685
+ });
686
+ });
687
+ local.on("close", (code, reason) => {
688
+ if (!this.conns.delete(msg.id)) return;
689
+ this.send({
690
+ type: "ws-close",
691
+ id: msg.id,
692
+ code,
693
+ reason: reason.toString()
694
+ });
695
+ });
696
+ local.on("error", () => {
697
+ });
698
+ }
699
+ data(msg) {
700
+ const conn = this.conns.get(msg.id);
701
+ if (!conn) return;
702
+ if (conn.ready && conn.ws.readyState === WebSocket.OPEN) {
703
+ conn.ws.send(Buffer.from(msg.data, "base64"), { binary: msg.binary });
704
+ return;
705
+ }
706
+ if (conn.queue.length >= DEFAULTS.WS_APP_PREOPEN_QUEUE_MAX) {
707
+ conn.ws.close();
708
+ return;
709
+ }
710
+ conn.queue.push(msg);
711
+ }
712
+ close(msg) {
713
+ const conn = this.conns.get(msg.id);
714
+ if (!conn) return;
715
+ this.conns.delete(msg.id);
716
+ safeClose(conn.ws, msg.code, msg.reason);
717
+ }
718
+ /** Control socket dropped — tear down all local WS connections. */
719
+ closeAll() {
720
+ for (const conn of this.conns.values()) conn.ws.close();
721
+ this.conns.clear();
722
+ }
723
+ };
724
+
725
+ // packages/client/src/tunnel-client.ts
726
+ var TunnelClient = class extends EventEmitter {
727
+ socket = null;
728
+ stopped = false;
729
+ attempts = 0;
730
+ /** Guards against more than one retry loop running at a time. */
731
+ reconnecting = false;
732
+ options;
733
+ /** Maps subdomain → local port for multi-tunnel routing */
734
+ subdomainPortMap = /* @__PURE__ */ new Map();
735
+ /** In-flight response streams keyed by request id (for cancel/cleanup). */
736
+ activeStreams = /* @__PURE__ */ new Map();
737
+ /** Browser→app WebSocket relay: opens a local WS per `ws-open` and bridges frames. */
738
+ wsAppProxy = new WsAppProxy(
739
+ (msg) => this.socket?.send(JSON.stringify(msg)),
740
+ (subdomain) => this.subdomainPortMap.get(subdomain) ?? this.options.localPort
741
+ );
742
+ constructor(options) {
743
+ super();
744
+ this.options = options;
745
+ }
746
+ connect() {
747
+ return new Promise((resolve, reject) => {
748
+ const wsUrl = this.buildWsUrl();
749
+ const socket = new WebSocket2(wsUrl, {
750
+ handshakeTimeout: this.options.connectTimeoutMs ?? DEFAULTS.CONNECT_TIMEOUT_MS
751
+ });
752
+ this.socket = socket;
753
+ let authResolved = false;
754
+ let authAccepted = false;
755
+ let keepalive = null;
756
+ let alive = true;
757
+ socket.on("pong", () => {
758
+ alive = true;
759
+ });
760
+ socket.on("open", () => {
761
+ const authMsg = {
762
+ type: "auth",
763
+ key: this.options.apiKey,
764
+ subdomain: this.options.subdomain ?? "",
765
+ auth: this.options.auth
766
+ };
767
+ socket.send(JSON.stringify(authMsg));
768
+ keepalive = setInterval(() => {
769
+ if (socket.readyState !== WebSocket2.OPEN) return;
770
+ if (!alive) {
771
+ socket.terminate();
772
+ return;
773
+ }
774
+ alive = false;
775
+ socket.ping();
776
+ }, this.options.pingIntervalMs ?? DEFAULTS.PING_INTERVAL_MS);
777
+ keepalive.unref?.();
778
+ });
779
+ socket.on("message", (data) => {
780
+ let msg;
781
+ try {
782
+ msg = parseMessage(data.toString());
783
+ } catch {
784
+ return;
785
+ }
786
+ switch (msg.type) {
787
+ case "auth-result":
788
+ this.handleAuthResult(msg, authResolved, socket, resolve, reject);
789
+ if (!authResolved) authResolved = true;
790
+ if (msg.success) authAccepted = true;
791
+ break;
792
+ case "register-tunnel-result":
793
+ this.handleRegisterTunnelResult(msg);
794
+ break;
795
+ case "tcp-register-result":
796
+ this.handleTcpRegisterResult(msg);
797
+ break;
798
+ case "request":
799
+ this.handleRequest(msg);
800
+ break;
801
+ case "stream-error":
802
+ this.activeStreams.get(msg.id)?.cancel();
803
+ this.activeStreams.delete(msg.id);
804
+ break;
805
+ case "tcp-connection-open":
806
+ this.emit("tcp-connection-open", msg);
807
+ break;
808
+ case "tcp-data":
809
+ this.emit("tcp-data", msg);
810
+ break;
811
+ case "tcp-connection-close":
812
+ this.emit("tcp-connection-close", msg);
813
+ break;
814
+ case "ws-open":
815
+ this.wsAppProxy.open(msg);
816
+ break;
817
+ case "ws-data":
818
+ this.wsAppProxy.data(msg);
819
+ break;
820
+ case "ws-close":
821
+ this.wsAppProxy.close(msg);
822
+ break;
823
+ default:
824
+ break;
825
+ }
826
+ });
827
+ socket.on("close", () => {
828
+ if (keepalive) {
829
+ clearInterval(keepalive);
830
+ keepalive = null;
831
+ }
832
+ if (this.socket !== socket) return;
833
+ for (const stream of this.activeStreams.values()) stream.cancel();
834
+ this.activeStreams.clear();
835
+ this.wsAppProxy.closeAll();
836
+ this.emit("disconnected");
837
+ if (!this.stopped && authAccepted) {
838
+ this.reconnect();
839
+ }
840
+ });
841
+ socket.on("error", (err) => {
842
+ if (!authResolved) {
843
+ authResolved = true;
844
+ reject(err);
845
+ }
846
+ this.emit("error", err);
847
+ });
848
+ });
849
+ }
850
+ disconnect() {
851
+ this.stopped = true;
852
+ this.socket?.close();
853
+ }
854
+ /** Register a TCP tunnel after auth */
855
+ registerTcp(localPort, remotePort) {
856
+ const msg = {
857
+ type: "tcp-register",
858
+ localPort,
859
+ remotePort
860
+ };
861
+ this.sendMsg(msg);
862
+ }
863
+ /** Get raw socket for TcpProxy to attach handlers */
864
+ getSocket() {
865
+ return this.socket;
866
+ }
867
+ /** Send a JSON message over the WebSocket */
868
+ sendMsg(msg) {
869
+ if (this.socket?.readyState === WebSocket2.OPEN) {
870
+ this.socket.send(JSON.stringify(msg));
871
+ }
872
+ }
873
+ buildWsUrl() {
874
+ const url = this.options.serverUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
875
+ return `${url}${DEFAULTS.WS_PATH}`;
876
+ }
877
+ handleAuthResult(result, alreadyResolved, socket, resolve, reject) {
878
+ if (alreadyResolved) return;
879
+ if (!result.success) {
880
+ socket.close();
881
+ reject(new Error(result.error ?? "Auth failed"));
882
+ return;
883
+ }
884
+ this.attempts = 0;
885
+ this.subdomainPortMap.set(result.subdomain, this.options.localPort);
886
+ this.emit("connected", result);
887
+ this.registerAdditionalTunnels();
888
+ resolve(result);
889
+ }
890
+ handleRegisterTunnelResult(result) {
891
+ if (result.success) {
892
+ this.subdomainPortMap.set(result.subdomain, result.localPort);
893
+ this.emit("tunnel-registered", result);
894
+ } else {
895
+ this.emit("tunnel-register-error", result);
896
+ }
897
+ }
898
+ handleTcpRegisterResult(result) {
899
+ if (result.success) {
900
+ this.emit("tcp-registered", result);
901
+ } else {
902
+ this.emit("tcp-register-error", result);
903
+ }
904
+ }
905
+ registerAdditionalTunnels() {
906
+ const tunnels = this.options.additionalTunnels;
907
+ if (!tunnels?.length) return;
908
+ for (const tunnel of tunnels) {
909
+ const msg = {
910
+ type: "register-tunnel",
911
+ subdomain: tunnel.subdomain,
912
+ localPort: tunnel.port,
913
+ auth: this.options.auth
914
+ };
915
+ this.sendMsg(msg);
916
+ }
917
+ }
918
+ async handleRequest(request2) {
919
+ const start = Date.now();
920
+ const targetPort = request2.subdomain ? this.subdomainPortMap.get(request2.subdomain) ?? this.options.localPort : this.options.localPort;
921
+ const stream = proxyRequestStream(
922
+ request2,
923
+ targetPort,
924
+ {
925
+ send: (msg) => this.sendMsg(msg),
926
+ bufferedAmount: () => this.socket?.bufferedAmount ?? 0
927
+ },
928
+ this.options.localHost
929
+ );
930
+ this.activeStreams.set(request2.id, stream);
931
+ const result = await stream.done;
932
+ this.activeStreams.delete(request2.id);
933
+ const durationMs = Date.now() - start;
934
+ this.emit("request", {
935
+ method: request2.method,
936
+ path: request2.path,
937
+ status: result.status,
938
+ durationMs,
939
+ subdomain: request2.subdomain
940
+ });
941
+ this.emit("inspector-entry", {
942
+ id: request2.id,
943
+ timestamp: start,
944
+ subdomain: request2.subdomain,
945
+ method: request2.method,
946
+ path: request2.path,
947
+ requestHeaders: request2.headers,
948
+ requestBody: request2.body,
949
+ status: result.status,
950
+ responseHeaders: result.headers,
951
+ responseBody: result.bodyPreview,
952
+ durationMs,
953
+ entryType: "normal"
954
+ });
955
+ }
956
+ async reconnect() {
957
+ if (this.reconnecting) return;
958
+ this.reconnecting = true;
959
+ this.subdomainPortMap.clear();
960
+ let delay = DEFAULTS.RECONNECT_BASE_MS;
961
+ try {
962
+ while (!this.stopped) {
963
+ this.attempts++;
964
+ const wait = Math.round(delay * (0.5 + Math.random() / 2));
965
+ this.emit("reconnecting", { attempt: this.attempts, delay: wait });
966
+ await new Promise((r) => setTimeout(r, wait));
967
+ try {
968
+ await this.connect();
969
+ return;
970
+ } catch {
971
+ delay = Math.min(delay * 2, DEFAULTS.RECONNECT_MAX_MS);
972
+ }
973
+ }
974
+ } finally {
975
+ this.reconnecting = false;
976
+ }
977
+ }
978
+ };
979
+
980
+ // packages/client/src/version.ts
981
+ var VERSION = "1.6.2";
982
+ var PACKAGE_NAME = "tunelo";
983
+
984
+ // packages/client/src/cli-tcp-command.ts
985
+ function registerTcpCommand(program2, display2) {
986
+ program2.command("tcp <port>").description("Create TCP tunnel to local port").option("-k, --key <apikey>", "API key for authentication").option("--server <url>", "Tunnel server URL").option("--remote-port <port>", "Request specific remote port").action(
987
+ async (portStr, options) => {
988
+ const port = Number(portStr);
989
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
990
+ display2.showError("Invalid port number");
991
+ process.exit(1);
992
+ }
993
+ const config = loadConfig();
994
+ const apiKey = options.key ?? process.env.TUNELO_KEY ?? config.key;
995
+ const serverUrl = options.server ?? process.env.TUNELO_SERVER ?? config.server ?? "wss://tunnel.inetdev.io.vn";
996
+ if (!apiKey) {
997
+ display2.showError("API key required.");
998
+ process.exit(1);
999
+ }
1000
+ display2.showBanner(VERSION);
1001
+ display2.showConnecting(serverUrl);
1002
+ const client = new TunnelClient({ serverUrl, apiKey, localPort: port });
1003
+ client.on("connected", () => {
1004
+ const remotePort = options.remotePort ? Number(options.remotePort) : void 0;
1005
+ client.registerTcp(port, remotePort);
1006
+ });
1007
+ client.on("tcp-registered", (result) => {
1008
+ display2.showTcpTunnel(result.url, port);
1009
+ new TcpProxy(client, port);
1010
+ });
1011
+ client.on("tcp-register-error", (result) => {
1012
+ display2.showError(`TCP tunnel failed: ${result.error}`);
1013
+ });
1014
+ client.on("disconnected", () => display2.showDisconnected());
1015
+ client.on(
1016
+ "reconnecting",
1017
+ (info) => {
1018
+ display2.showReconnecting(info.attempt, info.delay);
1019
+ }
1020
+ );
1021
+ client.on("error", (err) => display2.showError(err.message));
1022
+ try {
1023
+ await client.connect();
1024
+ } catch (err) {
1025
+ display2.showError(err.message);
1026
+ process.exit(1);
1027
+ }
1028
+ process.on("SIGINT", () => {
1029
+ client.disconnect();
1030
+ display2.showStats();
1031
+ process.exit(0);
1032
+ });
1033
+ }
1034
+ );
1035
+ }
1036
+
1037
+ // packages/client/src/cli-update-command.ts
1038
+ import { spawn } from "node:child_process";
1039
+ import path from "node:path";
1040
+ import { fileURLToPath } from "node:url";
1041
+ import chalk from "chalk";
1042
+ var isWindows = process.platform === "win32";
1043
+ function detectPackageManager(modulePath) {
1044
+ const normalized = modulePath.split(path.win32.sep).join(path.posix.sep);
1045
+ return normalized.includes("/pnpm/") ? "pnpm" : "npm";
1046
+ }
1047
+ function compareVersions(a, b) {
1048
+ const parse = (v) => v.replace(/^v/, "").split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
1049
+ const left = parse(a);
1050
+ const right = parse(b);
1051
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
1052
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
1053
+ if (diff !== 0) return diff;
1054
+ }
1055
+ return 0;
1056
+ }
1057
+ function buildInstallArgs(manager, target) {
1058
+ return manager === "pnpm" ? ["add", "-g", target] : ["install", "-g", target];
1059
+ }
1060
+ function spawnManager(manager, args, options) {
1061
+ return isWindows ? spawn([manager, ...args].join(" "), { ...options, shell: true }) : spawn(manager, args, options);
1062
+ }
1063
+ function fetchLatestVersion(manager) {
1064
+ return new Promise((resolve, reject) => {
1065
+ const child = spawnManager(manager, ["view", PACKAGE_NAME, "version"], {
1066
+ stdio: ["ignore", "pipe", "pipe"],
1067
+ timeout: 6e4
1068
+ });
1069
+ let stdout = "";
1070
+ let stderr = "";
1071
+ child.stdout?.on("data", (chunk) => {
1072
+ stdout += chunk.toString();
1073
+ });
1074
+ child.stderr?.on("data", (chunk) => {
1075
+ stderr += chunk.toString();
1076
+ });
1077
+ child.on("error", reject);
1078
+ child.on("close", (code) => {
1079
+ if (code !== 0) {
1080
+ reject(new Error(stderr.trim().split("\n").pop() ?? `exit ${code}`));
1081
+ return;
1082
+ }
1083
+ const version = stdout.trim().split("\n").pop()?.trim();
1084
+ if (!version) {
1085
+ reject(new Error("Registry returned no version"));
1086
+ return;
1087
+ }
1088
+ resolve(version);
1089
+ });
1090
+ });
1091
+ }
1092
+ function runInstall(manager, target) {
1093
+ return new Promise((resolve, reject) => {
1094
+ const child = spawnManager(manager, buildInstallArgs(manager, target), {
1095
+ stdio: "inherit"
1096
+ });
1097
+ child.on("error", reject);
1098
+ child.on("close", (code) => resolve(code ?? 1));
1099
+ });
1100
+ }
1101
+ function registerUpdateCommand(program2) {
1102
+ program2.command("update").description("Update tunelo to the latest published version").option("--check", "Only report whether an update exists").action(async (options) => {
1103
+ const manager = detectPackageManager(fileURLToPath(import.meta.url));
1104
+ let latest;
1105
+ try {
1106
+ console.log(chalk.gray(" Checking registry..."));
1107
+ latest = await fetchLatestVersion(manager);
1108
+ } catch (err) {
1109
+ console.log(
1110
+ chalk.red(
1111
+ ` Error: cannot reach registry \u2014 ${err.message}`
1112
+ )
1113
+ );
1114
+ console.log(
1115
+ chalk.gray(
1116
+ ` Update manually: ${manager} ${buildInstallArgs(manager, `${PACKAGE_NAME}@latest`).join(" ")}`
1117
+ )
1118
+ );
1119
+ process.exitCode = 1;
1120
+ return;
1121
+ }
1122
+ if (compareVersions(latest, VERSION) <= 0) {
1123
+ console.log(
1124
+ ` ${chalk.green("Already up to date")} \u2014 v${VERSION} is the latest.`
1125
+ );
1126
+ return;
1127
+ }
1128
+ console.log(
1129
+ ` Update available: ${chalk.gray(`v${VERSION}`)} \u2192 ${chalk.cyan(`v${latest}`)}`
1130
+ );
1131
+ if (options.check) {
1132
+ console.log(chalk.gray(" Run `tunelo update` to install it."));
1133
+ return;
1134
+ }
1135
+ console.log(chalk.gray(` Installing via ${manager}...`));
1136
+ let code;
1137
+ try {
1138
+ code = await runInstall(manager, `${PACKAGE_NAME}@latest`);
1139
+ } catch (err) {
1140
+ console.log(
1141
+ chalk.red(
1142
+ ` Error: ${manager} failed to start \u2014 ${err.message}`
1143
+ )
1144
+ );
1145
+ process.exitCode = 1;
1146
+ return;
1147
+ }
1148
+ if (code !== 0) {
1149
+ console.log(chalk.red(` Update failed (${manager} exited ${code}).`));
1150
+ process.exitCode = code;
1151
+ return;
1152
+ }
1153
+ console.log(` ${chalk.green("Updated")} to v${latest}.`);
1154
+ console.log(chalk.gray(" Restart any running tunnels to apply."));
1155
+ });
1156
+ }
1157
+
1158
+ // packages/client/src/display.ts
1159
+ import chalk2 from "chalk";
1160
+ function maskKey(key) {
1161
+ if (key.length <= 8) return "****";
1162
+ return `${key.slice(0, 4)}...${key.slice(-4)}`;
1163
+ }
1164
+ function formatUptime2(ms) {
1165
+ const seconds = Math.floor(ms / 1e3);
1166
+ const minutes = Math.floor(seconds / 60);
1167
+ const hours = Math.floor(minutes / 60);
1168
+ if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
1169
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
1170
+ return `${seconds}s`;
1171
+ }
1172
+ function portFallbackNote(port, requestedPort) {
1173
+ if (requestedPort === void 0 || requestedPort === port) return "";
1174
+ return chalk2.gray(` (${requestedPort} in use)`);
1175
+ }
1176
+ var Display = class {
1177
+ logLevel = "normal";
1178
+ startTime = 0;
1179
+ totalRequests = 0;
1180
+ errorRequests = 0;
1181
+ setLogLevel(level) {
1182
+ this.logLevel = level;
1183
+ }
1184
+ showBanner(version) {
1185
+ console.log(chalk2.bold(`
1186
+ tunelo v${version}
1187
+ `));
1188
+ }
1189
+ showConnecting(server) {
1190
+ console.log(chalk2.yellow(` Connecting to ${server}...`));
1191
+ }
1192
+ showConnected(tunnelUrl, localUrl, apiKey, authEnabled = false) {
1193
+ this.startTime = Date.now();
1194
+ console.log(chalk2.green(" Status: ") + chalk2.bold.green("Online"));
1195
+ console.log(chalk2.green(" Tunnel: ") + chalk2.cyan(tunnelUrl));
1196
+ console.log(`${chalk2.green(" Forwarding:")} ${localUrl}`);
1197
+ if (apiKey) {
1198
+ console.log(chalk2.green(" API Key: ") + chalk2.gray(maskKey(apiKey)));
1199
+ }
1200
+ if (authEnabled) {
1201
+ console.log(chalk2.green(" Auth: ") + chalk2.yellow("enabled"));
1202
+ }
1203
+ console.log(chalk2.gray(" \u2500".repeat(20)));
1204
+ }
1205
+ showInspector(port, requestedPort) {
1206
+ console.log(
1207
+ chalk2.green(" Inspector: ") + chalk2.cyan(`http://localhost:${port}`) + portFallbackNote(port, requestedPort)
1208
+ );
1209
+ }
1210
+ showPortal(port, requestedPort) {
1211
+ console.log(
1212
+ chalk2.green(" Portal: ") + chalk2.cyan(`http://localhost:${port}`) + portFallbackNote(port, requestedPort)
1213
+ );
1214
+ }
1215
+ showTunnelRegistered(subdomain, url, localPort) {
1216
+ console.log(
1217
+ `${chalk2.green(" + Tunnel: ") + chalk2.cyan(url)} \u2192 http://localhost:${localPort}`
1218
+ );
1219
+ }
1220
+ showTcpTunnel(url, localPort) {
1221
+ console.log(
1222
+ `${chalk2.green(" TCP: ") + chalk2.cyan(url)} \u2192 localhost:${localPort}`
1223
+ );
1224
+ }
1225
+ showDisconnected(reason) {
1226
+ console.log(chalk2.red(` Disconnected${reason ? `: ${reason}` : ""}`));
1227
+ }
1228
+ showReconnecting(attempt, delayMs) {
1229
+ console.log(
1230
+ chalk2.yellow(` Reconnecting (attempt ${attempt}, ${delayMs}ms)...`)
1231
+ );
1232
+ }
1233
+ logRequest(method, path3, status, durationMs, bodySize, subdomain) {
1234
+ this.totalRequests++;
1235
+ if (status >= 500) this.errorRequests++;
1236
+ if (this.logLevel === "quiet") return;
1237
+ const statusColor = status < 300 ? chalk2.green : status < 500 ? chalk2.yellow : chalk2.red;
1238
+ const methodStr = method.padEnd(7);
1239
+ const pathStr = path3.length > 40 ? `${path3.slice(0, 37)}...` : path3;
1240
+ const prefix = subdomain ? chalk2.magenta(`[${subdomain}] `) : "";
1241
+ let line = ` ${prefix}${chalk2.bold(methodStr)} ${pathStr.padEnd(42)} ${statusColor(String(status))} ${chalk2.gray(`${durationMs}ms`)}`;
1242
+ if (this.logLevel === "verbose" && bodySize !== void 0) {
1243
+ const sizeStr = bodySize > 1024 ? `${(bodySize / 1024).toFixed(1)}KB` : `${bodySize}B`;
1244
+ line += chalk2.gray(` ${sizeStr}`);
1245
+ }
1246
+ console.log(line);
1247
+ }
1248
+ showStats() {
1249
+ const uptime = this.startTime ? formatUptime2(Date.now() - this.startTime) : "0s";
1250
+ console.log("");
1251
+ console.log(chalk2.gray(" \u2500".repeat(20)));
1252
+ console.log(chalk2.bold(" Session Summary"));
1253
+ console.log(` Uptime: ${chalk2.cyan(uptime)}`);
1254
+ console.log(` Requests: ${chalk2.green(String(this.totalRequests))}`);
1255
+ if (this.errorRequests > 0) {
1256
+ console.log(` Errors: ${chalk2.red(String(this.errorRequests))}`);
1257
+ }
1258
+ console.log("");
1259
+ }
1260
+ showError(message) {
1261
+ console.log(chalk2.red(` Error: ${message}`));
1262
+ }
1263
+ };
1264
+
1265
+ // packages/client/src/inspector-server.ts
1266
+ import http2 from "node:http";
1267
+
1268
+ // packages/client/src/inspector-ui.ts
1269
+ function getInspectorHtml() {
1270
+ return `<!DOCTYPE html>
1271
+ <html lang="en">
1272
+ <head>
1273
+ <meta charset="UTF-8">
1274
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1275
+ <title>Tunelo Inspector</title>
1276
+ <style>
1277
+ :root{--bg:#0f172a;--bg2:#1e293b;--bg3:#334155;--fg:#e2e8f0;--fg2:#94a3b8;--accent:#38bdf8;--green:#4ade80;--yellow:#fbbf24;--red:#f87171;--border:#475569;font-family:system-ui,-apple-system,sans-serif}
1278
+ *{margin:0;padding:0;box-sizing:border-box}
1279
+ body{background:var(--bg);color:var(--fg);font-size:14px;height:100vh;display:flex;flex-direction:column}
1280
+ header{background:var(--bg2);padding:8px 16px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--border)}
1281
+ header h1{font-size:16px;color:var(--accent);font-weight:600}
1282
+ header .stats{color:var(--fg2);font-size:12px;margin-left:auto}
1283
+ .filters{background:var(--bg2);padding:6px 16px;display:flex;gap:8px;border-bottom:1px solid var(--border)}
1284
+ .filters select,.filters input{background:var(--bg3);color:var(--fg);border:1px solid var(--border);padding:4px 8px;border-radius:4px;font-size:12px}
1285
+ .filters input{flex:1;max-width:250px}
1286
+ .filters button{background:var(--bg3);color:var(--fg2);border:1px solid var(--border);padding:4px 12px;border-radius:4px;cursor:pointer;font-size:12px}
1287
+ .filters button:hover{background:var(--border)}
1288
+ .main{flex:1;display:flex;overflow:hidden}
1289
+ .list{flex:1;overflow-y:auto;min-width:0}
1290
+ .detail{width:45%;border-left:1px solid var(--border);overflow-y:auto;display:none}
1291
+ .detail.open{display:block}
1292
+ table{width:100%;border-collapse:collapse}
1293
+ th{background:var(--bg2);position:sticky;top:0;text-align:left;padding:6px 10px;font-size:11px;color:var(--fg2);text-transform:uppercase;letter-spacing:.5px}
1294
+ tr.row{cursor:pointer;border-bottom:1px solid var(--bg2)}
1295
+ tr.row:hover{background:var(--bg2)}
1296
+ tr.row.active{background:var(--bg3)}
1297
+ tr.row.replay{opacity:.8;font-style:italic}
1298
+ td{padding:6px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:300px;font-size:13px}
1299
+ .method{font-weight:700;font-family:monospace}
1300
+ .s2xx{color:var(--green)}.s3xx{color:var(--accent)}.s4xx{color:var(--yellow)}.s5xx{color:var(--red)}
1301
+ .badge{font-size:10px;padding:1px 5px;border-radius:3px;background:var(--bg3);color:var(--fg2);margin-left:4px}
1302
+ .badge.replay{background:#7c3aed;color:#fff}
1303
+ .detail-header{padding:12px 16px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
1304
+ .detail-header h2{font-size:14px;font-weight:600}
1305
+ .tabs{display:flex;gap:0;border-bottom:1px solid var(--border)}
1306
+ .tabs button{background:none;border:none;color:var(--fg2);padding:8px 16px;cursor:pointer;font-size:12px;border-bottom:2px solid transparent}
1307
+ .tabs button.active{color:var(--accent);border-bottom-color:var(--accent)}
1308
+ .tab-content{padding:12px 16px;font-family:monospace;font-size:12px;white-space:pre-wrap;word-break:break-all;line-height:1.5}
1309
+ .empty{text-align:center;padding:60px 20px;color:var(--fg2)}
1310
+ .empty h2{font-size:18px;margin-bottom:8px;color:var(--fg)}
1311
+ .modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center}
1312
+ .modal-overlay.open{display:flex}
1313
+ .modal{background:var(--bg2);border:1px solid var(--border);border-radius:8px;width:600px;max-height:80vh;display:flex;flex-direction:column}
1314
+ .modal h3{padding:12px 16px;border-bottom:1px solid var(--border);font-size:14px}
1315
+ .modal-body{padding:12px 16px;overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:8px}
1316
+ .modal-body label{font-size:11px;color:var(--fg2);text-transform:uppercase}
1317
+ .modal-body input,.modal-body textarea,.modal-body select{background:var(--bg3);color:var(--fg);border:1px solid var(--border);padding:6px 8px;border-radius:4px;font-family:monospace;font-size:12px;width:100%}
1318
+ .modal-body textarea{min-height:120px;resize:vertical}
1319
+ .modal-footer{padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end}
1320
+ .modal-footer button{padding:6px 16px;border-radius:4px;cursor:pointer;font-size:12px;border:none}
1321
+ .btn-primary{background:var(--accent);color:#000}.btn-secondary{background:var(--bg3);color:var(--fg)}
1322
+ @media(prefers-color-scheme:light){:root{--bg:#f8fafc;--bg2:#e2e8f0;--bg3:#cbd5e1;--fg:#0f172a;--fg2:#475569;--border:#94a3b8}}
1323
+ </style>
1324
+ </head>
1325
+ <body>
1326
+ <header>
1327
+ <h1>tunelo inspector</h1>
1328
+ <span class="stats" id="stats">0 requests</span>
1329
+ </header>
1330
+ <div class="filters">
1331
+ <select id="fSub"><option value="">All tunnels</option></select>
1332
+ <select id="fStatus"><option value="">All status</option><option value="2">2xx</option><option value="3">3xx</option><option value="4">4xx</option><option value="5">5xx</option></select>
1333
+ <input id="fSearch" type="text" placeholder="Search path...">
1334
+ <button id="btnClear">Clear</button>
1335
+ </div>
1336
+ <div class="main">
1337
+ <div class="list"><table><thead><tr><th>Method</th><th>Path</th><th>Status</th><th>Duration</th><th>Time</th><th>Subdomain</th></tr></thead><tbody id="tbody"></tbody></table>
1338
+ <div class="empty" id="empty"><h2>Waiting for requests...</h2><p>Send a request through your tunnel to see it here</p></div>
1339
+ </div>
1340
+ <div class="detail" id="detail">
1341
+ <div class="detail-header"><h2 id="dTitle">Request</h2><div style="display:flex;gap:6px"><button id="btnEditReplay" style="background:var(--bg3);color:var(--fg);border:1px solid var(--border);padding:4px 12px;border-radius:4px;cursor:pointer;font-size:12px">Edit & Replay</button><button id="btnReplay" style="background:var(--accent);color:#000;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:12px">Replay</button></div></div>
1342
+ <div class="tabs"><button class="active" data-tab="req-headers">Req Headers</button><button data-tab="req-body">Req Body</button><button data-tab="res-headers">Res Headers</button><button data-tab="res-body">Res Body</button></div>
1343
+ <div class="tab-content" id="tabContent"></div>
1344
+ </div>
1345
+ </div>
1346
+ <div class="modal-overlay" id="editModal">
1347
+ <div class="modal">
1348
+ <h3>Edit & Replay</h3>
1349
+ <div class="modal-body">
1350
+ <label>Method</label><select id="mMethod"><option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option></select>
1351
+ <label>Path</label><input id="mPath" type="text">
1352
+ <label>Headers (JSON)</label><textarea id="mHeaders"></textarea>
1353
+ <label>Body</label><textarea id="mBody"></textarea>
1354
+ </div>
1355
+ <div class="modal-footer"><button class="btn-secondary" id="mCancel">Cancel</button><button class="btn-primary" id="mSend">Send</button></div>
1356
+ </div>
1357
+ </div>
1358
+ <script>
1359
+ const tbody=document.getElementById("tbody"),detail=document.getElementById("detail"),empty=document.getElementById("empty"),stats=document.getElementById("stats"),tabContent=document.getElementById("tabContent"),dTitle=document.getElementById("dTitle"),fSub=document.getElementById("fSub"),fStatus=document.getElementById("fStatus"),fSearch=document.getElementById("fSearch"),btnClear=document.getElementById("btnClear"),btnReplay=document.getElementById("btnReplay"),btnEditReplay=document.getElementById("btnEditReplay"),editModal=document.getElementById("editModal"),mMethod=document.getElementById("mMethod"),mPath=document.getElementById("mPath"),mHeaders=document.getElementById("mHeaders"),mBody=document.getElementById("mBody"),mCancel=document.getElementById("mCancel"),mSend=document.getElementById("mSend");
1360
+ let entries=[],selected=null,activeTab="req-headers",knownSubs=new Set();
1361
+
1362
+ function statusClass(s){if(s<300)return"s2xx";if(s<400)return"s3xx";if(s<500)return"s4xx";return"s5xx"}
1363
+ function fmtTime(ts){return new Date(ts).toLocaleTimeString()}
1364
+ function tryJson(s){if(!s)return"(empty)";try{const b=atob(s);const j=JSON.parse(b);return JSON.stringify(j,null,2)}catch{try{return atob(s)}catch{return s}}}
1365
+ function fmtHeaders(h){if(!h)return"(none)";return Object.entries(h).map(([k,v])=>k+": "+(Array.isArray(v)?v.join(", "):v)).join("\\n")}
1366
+
1367
+ function render(){
1368
+ const sub=fSub.value,st=fStatus.value,q=fSearch.value.toLowerCase();
1369
+ let filtered=entries;
1370
+ if(sub)filtered=filtered.filter(e=>e.subdomain===sub);
1371
+ if(st)filtered=filtered.filter(e=>String(e.status).startsWith(st));
1372
+ if(q)filtered=filtered.filter(e=>e.path.toLowerCase().includes(q)||e.method.toLowerCase().includes(q));
1373
+ tbody.innerHTML="";
1374
+ empty.style.display=filtered.length?"none":"block";
1375
+ stats.textContent=entries.length+" requests";
1376
+ for(const e of filtered){
1377
+ const tr=document.createElement("tr");tr.className="row"+(e.id===selected?" active":"")+(e.entryType==="replay"?" replay":"");
1378
+ tr.innerHTML='<td class="method">'+e.method+'</td><td>'+e.path+(e.entryType==="replay"?'<span class="badge replay">replay</span>':"")+'</td><td class="'+statusClass(e.status)+'">'+e.status+"</td><td>"+e.durationMs+"ms</td><td>"+fmtTime(e.timestamp)+"</td><td>"+(e.subdomain||"-")+"</td>";
1379
+ tr.onclick=()=>showDetail(e.id);tbody.appendChild(tr);
1380
+ }}
1381
+
1382
+ function showDetail(id){
1383
+ selected=id;render();
1384
+ const e=entries.find(x=>x.id===id);if(!e){detail.classList.remove("open");return}
1385
+ detail.classList.add("open");
1386
+ dTitle.textContent=e.method+" "+e.path;
1387
+ fetch("/api/requests/"+id).then(r=>r.json()).then(full=>{
1388
+ window._detail=full;showTab(activeTab);
1389
+ }).catch(()=>{window._detail=e;showTab(activeTab)});
1390
+ }
1391
+
1392
+ function showTab(tab){
1393
+ activeTab=tab;
1394
+ document.querySelectorAll(".tabs button").forEach(b=>b.classList.toggle("active",b.dataset.tab===tab));
1395
+ const d=window._detail||{};
1396
+ if(tab==="req-headers")tabContent.textContent=fmtHeaders(d.requestHeaders);
1397
+ else if(tab==="req-body")tabContent.textContent=tryJson(d.requestBody);
1398
+ else if(tab==="res-headers")tabContent.textContent=fmtHeaders(d.responseHeaders);
1399
+ else tabContent.textContent=tryJson(d.responseBody);
1400
+ }
1401
+
1402
+ document.querySelector(".tabs").addEventListener("click",e=>{if(e.target.dataset.tab)showTab(e.target.dataset.tab)});
1403
+ fSub.onchange=fStatus.onchange=fSearch.oninput=render;
1404
+ btnClear.onclick=()=>fetch("/api/requests",{method:"DELETE"}).then(()=>{entries=[];selected=null;detail.classList.remove("open");render()});
1405
+ btnReplay.onclick=()=>{if(!selected)return;fetch("/api/requests/"+selected+"/replay",{method:"POST"}).catch(()=>{})};
1406
+ btnEditReplay.onclick=()=>{
1407
+ if(!selected||!window._detail)return;
1408
+ const d=window._detail;
1409
+ mMethod.value=d.method||"GET";mPath.value=d.path||"/";
1410
+ try{mHeaders.value=JSON.stringify(d.requestHeaders||{},null,2)}catch{mHeaders.value="{}"}
1411
+ try{mBody.value=d.requestBody?atob(d.requestBody):""}catch{mBody.value=d.requestBody||""}
1412
+ editModal.classList.add("open");
1413
+ };
1414
+ mCancel.onclick=()=>editModal.classList.remove("open");
1415
+ mSend.onclick=()=>{
1416
+ let headers;try{headers=JSON.parse(mHeaders.value)}catch{headers={}}
1417
+ const body=mBody.value?btoa(mBody.value):null;
1418
+ fetch("/api/replay",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:mMethod.value,path:mPath.value,headers,body})}).then(()=>editModal.classList.remove("open")).catch(()=>{});
1419
+ };
1420
+
1421
+ // SSE real-time updates
1422
+ const sse=new EventSource("/api/events");
1423
+ sse.onmessage=e=>{
1424
+ const entry=JSON.parse(e.data);
1425
+ entries.unshift(entry);
1426
+ if(entry.subdomain&&!knownSubs.has(entry.subdomain)){knownSubs.add(entry.subdomain);const o=document.createElement("option");o.value=entry.subdomain;o.textContent=entry.subdomain;fSub.appendChild(o)}
1427
+ render();
1428
+ };
1429
+
1430
+ // Load initial data
1431
+ fetch("/api/requests").then(r=>r.json()).then(data=>{entries=data;data.forEach(e=>{if(e.subdomain)knownSubs.add(e.subdomain)});knownSubs.forEach(s=>{const o=document.createElement("option");o.value=s;o.textContent=s;fSub.appendChild(o)});render()});
1432
+ </script>
1433
+ </body>
1434
+ </html>`;
1435
+ }
1436
+
1437
+ // packages/client/src/listen-with-port-fallback.ts
1438
+ var DEFAULT_HOST = "127.0.0.1";
1439
+ var DEFAULT_MAX_ATTEMPTS = 20;
1440
+ var MAX_PORT = 65535;
1441
+ async function listenWithPortFallback(server, preferredPort, options = {}) {
1442
+ const host = options.host ?? DEFAULT_HOST;
1443
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
1444
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1445
+ const candidate = preferredPort + attempt;
1446
+ if (candidate > MAX_PORT) break;
1447
+ if (await tryListen(server, candidate, host)) {
1448
+ return {
1449
+ port: resolveBoundPort(server, candidate),
1450
+ fellBack: attempt > 0
1451
+ };
1452
+ }
1453
+ }
1454
+ if (await tryListen(server, 0, host)) {
1455
+ return { port: resolveBoundPort(server, 0), fellBack: true };
1456
+ }
1457
+ throw new Error(
1458
+ `No free port available near ${preferredPort} on ${host} (tried ${maxAttempts} ports + ephemeral)`
1459
+ );
1460
+ }
1461
+ function tryListen(server, port, host) {
1462
+ return new Promise((resolve, reject) => {
1463
+ const onError = (err) => {
1464
+ server.removeListener("listening", onListening);
1465
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") {
1466
+ resolve(false);
1467
+ return;
1468
+ }
1469
+ reject(err);
1470
+ };
1471
+ const onListening = () => {
1472
+ server.removeListener("error", onError);
1473
+ resolve(true);
1474
+ };
1475
+ server.once("error", onError);
1476
+ server.once("listening", onListening);
1477
+ server.listen(port, host);
1478
+ });
1479
+ }
1480
+ function resolveBoundPort(server, requested) {
1481
+ const address = server.address();
1482
+ if (address && typeof address === "object") return address.port;
1483
+ return requested;
1484
+ }
1485
+
1486
+ // packages/client/src/inspector-server.ts
1487
+ function createRateLimiter(maxPerSec) {
1488
+ let count = 0;
1489
+ setInterval(() => {
1490
+ count = 0;
1491
+ }, 1e3);
1492
+ return {
1493
+ allow() {
1494
+ if (count >= maxPerSec) return false;
1495
+ count++;
1496
+ return true;
1497
+ }
1498
+ };
1499
+ }
1500
+ async function startInspectorServer(options) {
1501
+ const { port, store, onReplay, onReplayCustom, getTunnels } = options;
1502
+ const sseClients = /* @__PURE__ */ new Set();
1503
+ const replayLimiter = createRateLimiter(10);
1504
+ store.onAdd((entry) => {
1505
+ const data = `data: ${JSON.stringify(toSummary(entry))}
1506
+
1507
+ `;
1508
+ for (const client of sseClients) {
1509
+ client.write(data);
1510
+ }
1511
+ });
1512
+ const server = http2.createServer((req, res) => {
1513
+ const url = new URL(req.url ?? "/", "http://localhost");
1514
+ const pathname = url.pathname;
1515
+ res.setHeader("Access-Control-Allow-Origin", "*");
1516
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS");
1517
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1518
+ if (req.method === "OPTIONS") {
1519
+ res.writeHead(204);
1520
+ res.end();
1521
+ return;
1522
+ }
1523
+ if (pathname === "/" && req.method === "GET") {
1524
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1525
+ res.end(getInspectorHtml());
1526
+ return;
1527
+ }
1528
+ if (pathname === "/api/tunnels" && req.method === "GET") {
1529
+ const tunnels = getTunnels?.() ?? [];
1530
+ sendJson(res, 200, tunnels);
1531
+ return;
1532
+ }
1533
+ if (pathname === "/api/requests" && req.method === "GET") {
1534
+ const filter = {
1535
+ subdomain: url.searchParams.get("subdomain") ?? void 0,
1536
+ status: url.searchParams.get("status") ?? void 0,
1537
+ q: url.searchParams.get("q") ?? void 0
1538
+ };
1539
+ sendJson(res, 200, store.list(filter));
1540
+ return;
1541
+ }
1542
+ if (pathname === "/api/requests" && req.method === "DELETE") {
1543
+ store.clear();
1544
+ sendJson(res, 200, { ok: true });
1545
+ return;
1546
+ }
1547
+ if (pathname === "/api/events" && req.method === "GET") {
1548
+ res.writeHead(200, {
1549
+ "content-type": "text/event-stream",
1550
+ "cache-control": "no-cache",
1551
+ connection: "keep-alive"
1552
+ });
1553
+ res.write(":ok\n\n");
1554
+ sseClients.add(res);
1555
+ req.on("close", () => sseClients.delete(res));
1556
+ return;
1557
+ }
1558
+ const detailMatch = pathname.match(/^\/api\/requests\/([^/]+)$/);
1559
+ if (detailMatch && req.method === "GET") {
1560
+ const entry = store.get(detailMatch[1]);
1561
+ if (!entry) {
1562
+ sendJson(res, 404, { error: "Not found" });
1563
+ return;
1564
+ }
1565
+ sendJson(res, 200, entry);
1566
+ return;
1567
+ }
1568
+ const replayMatch = pathname.match(/^\/api\/requests\/([^/]+)\/replay$/);
1569
+ if (replayMatch && req.method === "POST") {
1570
+ if (!replayLimiter.allow()) {
1571
+ sendJson(res, 429, {
1572
+ error: "Replay rate limit exceeded (max 10/sec)"
1573
+ });
1574
+ return;
1575
+ }
1576
+ if (!onReplay) {
1577
+ sendJson(res, 501, { error: "Replay not available" });
1578
+ return;
1579
+ }
1580
+ onReplay(replayMatch[1]).then((entry) => {
1581
+ if (!entry) {
1582
+ sendJson(res, 404, { error: "Original request not found" });
1583
+ return;
1584
+ }
1585
+ sendJson(res, 200, toSummary(entry));
1586
+ }).catch((err) => {
1587
+ sendJson(res, 500, { error: err.message });
1588
+ });
1589
+ return;
1590
+ }
1591
+ if (pathname === "/api/replay" && req.method === "POST") {
1592
+ if (!replayLimiter.allow()) {
1593
+ sendJson(res, 429, {
1594
+ error: "Replay rate limit exceeded (max 10/sec)"
1595
+ });
1596
+ return;
1597
+ }
1598
+ if (!onReplayCustom) {
1599
+ sendJson(res, 501, { error: "Custom replay not available" });
1600
+ return;
1601
+ }
1602
+ collectBody(req, (body) => {
1603
+ try {
1604
+ const data = JSON.parse(body);
1605
+ onReplayCustom(data).then((entry) => {
1606
+ if (!entry) {
1607
+ sendJson(res, 500, { error: "Replay failed" });
1608
+ return;
1609
+ }
1610
+ sendJson(res, 200, toSummary(entry));
1611
+ }).catch((err) => {
1612
+ sendJson(res, 500, { error: err.message });
1613
+ });
1614
+ } catch {
1615
+ sendJson(res, 400, { error: "Invalid JSON" });
1616
+ }
1617
+ });
1618
+ return;
1619
+ }
1620
+ res.writeHead(404, { "content-type": "text/plain" });
1621
+ res.end("Not Found");
1622
+ });
1623
+ const { port: boundPort, fellBack } = await listenWithPortFallback(
1624
+ server,
1625
+ port
1626
+ );
1627
+ return { server, port: boundPort, fellBack };
1628
+ }
1629
+ function sendJson(res, status, data) {
1630
+ res.writeHead(status, { "content-type": "application/json" });
1631
+ res.end(JSON.stringify(data));
1632
+ }
1633
+ var MAX_REPLAY_BODY = 1024 * 1024;
1634
+ function collectBody(req, cb, onError) {
1635
+ const chunks = [];
1636
+ let size = 0;
1637
+ req.on("data", (chunk) => {
1638
+ size += chunk.length;
1639
+ if (size > MAX_REPLAY_BODY) {
1640
+ req.destroy();
1641
+ onError?.("Request body too large");
1642
+ return;
1643
+ }
1644
+ chunks.push(chunk);
1645
+ });
1646
+ req.on("end", () => cb(Buffer.concat(chunks).toString("utf-8")));
1647
+ }
1648
+ function toSummary(entry) {
1649
+ const {
1650
+ requestBody,
1651
+ responseBody,
1652
+ requestHeaders,
1653
+ responseHeaders,
1654
+ ...summary
1655
+ } = entry;
1656
+ return summary;
1657
+ }
1658
+
1659
+ // packages/client/src/local-proxy.ts
1660
+ import http3 from "node:http";
1661
+ var HOP_HEADERS = /* @__PURE__ */ new Set([
1662
+ "connection",
1663
+ "keep-alive",
1664
+ "proxy-authenticate",
1665
+ "proxy-authorization",
1666
+ "te",
1667
+ "trailers",
1668
+ "transfer-encoding",
1669
+ "upgrade"
1670
+ ]);
1671
+ function filterHeaders(headers) {
1672
+ const filtered = {};
1673
+ for (const [key, value] of Object.entries(headers)) {
1674
+ if (!HOP_HEADERS.has(key.toLowerCase())) {
1675
+ filtered[key] = value;
1676
+ }
1677
+ }
1678
+ return filtered;
1679
+ }
1680
+ function proxyRequest(request2, localPort, localHost = "localhost") {
1681
+ return new Promise((resolve) => {
1682
+ const body = request2.body ? Buffer.from(request2.body, "base64") : null;
1683
+ const headers = filterHeaders(request2.headers);
1684
+ const proxyReq = http3.request(
1685
+ {
1686
+ hostname: localHost,
1687
+ port: localPort,
1688
+ path: request2.path,
1689
+ method: request2.method,
1690
+ headers
1691
+ },
1692
+ (proxyRes) => {
1693
+ const chunks = [];
1694
+ let bodySize = 0;
1695
+ proxyRes.on("data", (chunk) => {
1696
+ bodySize += chunk.length;
1697
+ if (bodySize > DEFAULTS.MAX_BODY_SIZE) {
1698
+ proxyReq.destroy();
1699
+ resolve({
1700
+ type: "response",
1701
+ id: request2.id,
1702
+ status: 502,
1703
+ headers: { "content-type": "text/plain" },
1704
+ body: Buffer.from("Response body too large").toString("base64")
1705
+ });
1706
+ return;
1707
+ }
1708
+ chunks.push(chunk);
1709
+ });
1710
+ proxyRes.on("end", () => {
1711
+ const responseBody = chunks.length > 0 ? Buffer.concat(chunks).toString("base64") : null;
1712
+ resolve({
1713
+ type: "response",
1714
+ id: request2.id,
1715
+ status: proxyRes.statusCode ?? 502,
1716
+ headers: proxyRes.headers,
1717
+ body: responseBody
1718
+ });
1719
+ });
1720
+ }
1721
+ );
1722
+ proxyReq.on("error", (err) => {
1723
+ const isRefused = err.code === "ECONNREFUSED";
1724
+ resolve({
1725
+ type: "response",
1726
+ id: request2.id,
1727
+ status: isRefused ? 502 : 502,
1728
+ headers: { "content-type": "text/plain" },
1729
+ body: Buffer.from(
1730
+ isRefused ? "Local service unavailable" : err.message
1731
+ ).toString("base64")
1732
+ });
1733
+ });
1734
+ proxyReq.setTimeout(3e4, () => {
1735
+ proxyReq.destroy();
1736
+ resolve({
1737
+ type: "response",
1738
+ id: request2.id,
1739
+ status: 504,
1740
+ headers: { "content-type": "text/plain" },
1741
+ body: Buffer.from("Local service timeout").toString("base64")
1742
+ });
1743
+ });
1744
+ if (body) proxyReq.write(body);
1745
+ proxyReq.end();
1746
+ });
1747
+ }
1748
+
1749
+ // packages/client/src/request-store.ts
1750
+ var DEFAULT_MAX_SIZE = 500;
1751
+ var MAX_BODY_BYTES = 1024 * 1024;
1752
+ var RequestStore = class {
1753
+ entries = [];
1754
+ maxSize;
1755
+ listeners = [];
1756
+ constructor(maxSize = DEFAULT_MAX_SIZE) {
1757
+ this.maxSize = maxSize;
1758
+ }
1759
+ add(entry) {
1760
+ if (entry.requestBody && Buffer.byteLength(entry.requestBody, "utf-8") > MAX_BODY_BYTES) {
1761
+ entry.requestBody = entry.requestBody.slice(0, MAX_BODY_BYTES);
1762
+ }
1763
+ if (entry.responseBody && Buffer.byteLength(entry.responseBody, "utf-8") > MAX_BODY_BYTES) {
1764
+ entry.responseBody = entry.responseBody.slice(0, MAX_BODY_BYTES);
1765
+ }
1766
+ this.entries.push(entry);
1767
+ if (this.entries.length > this.maxSize) {
1768
+ this.entries.shift();
1769
+ }
1770
+ for (const listener of this.listeners) {
1771
+ listener(entry);
1772
+ }
1773
+ }
1774
+ list(filter) {
1775
+ let result = this.entries;
1776
+ if (filter?.subdomain) {
1777
+ result = result.filter((e) => e.subdomain === filter.subdomain);
1778
+ }
1779
+ if (filter?.status) {
1780
+ const statusPrefix = filter.status.replace(/x/gi, "");
1781
+ result = result.filter((e) => String(e.status).startsWith(statusPrefix));
1782
+ }
1783
+ if (filter?.q) {
1784
+ const q = filter.q.toLowerCase();
1785
+ result = result.filter(
1786
+ (e) => e.path.toLowerCase().includes(q) || e.method.toLowerCase().includes(q)
1787
+ );
1788
+ }
1789
+ return result.map(
1790
+ ({
1791
+ requestBody,
1792
+ responseBody,
1793
+ requestHeaders,
1794
+ responseHeaders,
1795
+ ...summary
1796
+ }) => summary
1797
+ ).reverse();
1798
+ }
1799
+ get(id) {
1800
+ return this.entries.find((e) => e.id === id);
1801
+ }
1802
+ clear() {
1803
+ this.entries = [];
1804
+ }
1805
+ onAdd(callback) {
1806
+ this.listeners.push(callback);
1807
+ }
1808
+ removeListener(callback) {
1809
+ this.listeners = this.listeners.filter((l) => l !== callback);
1810
+ }
1811
+ get size() {
1812
+ return this.entries.length;
1813
+ }
1814
+ getSubdomains() {
1815
+ const set = /* @__PURE__ */ new Set();
1816
+ for (const entry of this.entries) {
1817
+ if (entry.subdomain) set.add(entry.subdomain);
1818
+ }
1819
+ return [...set];
1820
+ }
1821
+ };
1822
+
1823
+ // packages/client/src/inspector-integration.ts
1824
+ function createInspectorIntegration(options) {
1825
+ const { inspectPort, subdomainPortMap, defaultPort } = options;
1826
+ const store = new RequestStore();
1827
+ async function start() {
1828
+ const handle = await startInspectorServer({
1829
+ port: inspectPort,
1830
+ store,
1831
+ onReplay: async (id) => {
1832
+ const original = store.get(id);
1833
+ if (!original) return null;
1834
+ const port = original.subdomain ? subdomainPortMap.get(original.subdomain) ?? defaultPort : defaultPort;
1835
+ const start2 = Date.now();
1836
+ const response = await proxyRequest(
1837
+ {
1838
+ type: "request",
1839
+ id: `replay_${Date.now().toString(36)}`,
1840
+ method: original.method,
1841
+ path: original.path,
1842
+ headers: original.requestHeaders,
1843
+ body: original.requestBody
1844
+ },
1845
+ port
1846
+ );
1847
+ const entry = {
1848
+ id: `replay_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
1849
+ timestamp: start2,
1850
+ subdomain: original.subdomain,
1851
+ method: original.method,
1852
+ path: original.path,
1853
+ requestHeaders: original.requestHeaders,
1854
+ requestBody: original.requestBody,
1855
+ status: response.status,
1856
+ responseHeaders: response.headers,
1857
+ responseBody: response.body,
1858
+ durationMs: Date.now() - start2,
1859
+ entryType: "replay",
1860
+ originalId: id
1861
+ };
1862
+ store.add(entry);
1863
+ return entry;
1864
+ },
1865
+ onReplayCustom: async (req) => {
1866
+ const start2 = Date.now();
1867
+ const response = await proxyRequest(
1868
+ {
1869
+ type: "request",
1870
+ id: `custom_${Date.now().toString(36)}`,
1871
+ method: req.method,
1872
+ path: req.path,
1873
+ headers: req.headers,
1874
+ body: req.body
1875
+ },
1876
+ defaultPort
1877
+ );
1878
+ const entry = {
1879
+ id: `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
1880
+ timestamp: start2,
1881
+ method: req.method,
1882
+ path: req.path,
1883
+ requestHeaders: req.headers,
1884
+ requestBody: req.body,
1885
+ status: response.status,
1886
+ responseHeaders: response.headers,
1887
+ responseBody: response.body,
1888
+ durationMs: Date.now() - start2,
1889
+ entryType: "replay"
1890
+ };
1891
+ store.add(entry);
1892
+ return entry;
1893
+ },
1894
+ getTunnels: () => [...subdomainPortMap.entries()].map(([subdomain, localPort]) => ({
1895
+ subdomain,
1896
+ localPort,
1897
+ url: `https://${subdomain}.tunnel.inetdev.io.vn`
1898
+ }))
1899
+ });
1900
+ return handle.port;
1901
+ }
1902
+ return { store, start };
1903
+ }
1904
+
1905
+ // packages/client/src/portal-server.ts
1906
+ import fs from "node:fs";
1907
+ import http4 from "node:http";
1908
+ import path2 from "node:path";
1909
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1910
+ var __dirname = path2.dirname(fileURLToPath2(import.meta.url));
1911
+ var MIME_TYPES = {
1912
+ ".html": "text/html; charset=utf-8",
1913
+ ".js": "application/javascript",
1914
+ ".css": "text/css",
1915
+ ".json": "application/json",
1916
+ ".png": "image/png",
1917
+ ".svg": "image/svg+xml",
1918
+ ".ico": "image/x-icon",
1919
+ ".woff2": "font/woff2",
1920
+ ".woff": "font/woff"
1921
+ };
1922
+ var activeServer = null;
1923
+ function resolveDistDir() {
1924
+ return path2.resolve(__dirname, "..", "portal-dist");
1925
+ }
1926
+ function serveFile(res, filePath, fallbackPath) {
1927
+ const ext = path2.extname(filePath);
1928
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
1929
+ fs.readFile(filePath, (err, data) => {
1930
+ if (err) {
1931
+ fs.readFile(fallbackPath, (fallbackErr, fallbackData) => {
1932
+ if (fallbackErr) {
1933
+ res.writeHead(404, { "content-type": "text/plain" });
1934
+ res.end("Portal not built. Run: pnpm build:portal");
1935
+ return;
1936
+ }
1937
+ res.writeHead(200, {
1938
+ "content-type": "text/html; charset=utf-8"
1939
+ });
1940
+ res.end(fallbackData);
1941
+ });
1942
+ return;
1943
+ }
1944
+ res.writeHead(200, { "content-type": contentType });
1945
+ res.end(data);
1946
+ });
1947
+ }
1948
+ async function startPortalServer(port) {
1949
+ const distDir = resolveDistDir();
1950
+ const indexPath = path2.join(distDir, "index.html");
1951
+ const server = http4.createServer((req, res) => {
1952
+ const rawPath = new URL(req.url ?? "/", "http://localhost").pathname;
1953
+ const safePath = path2.normalize(rawPath).replace(/^(\.\.[/\\])+/, "");
1954
+ const filePath = path2.join(distDir, safePath);
1955
+ if (!filePath.startsWith(distDir)) {
1956
+ res.writeHead(403, { "content-type": "text/plain" });
1957
+ res.end("Forbidden");
1958
+ return;
1959
+ }
1960
+ const targetPath = safePath === "/" || safePath.endsWith("/") ? indexPath : filePath;
1961
+ serveFile(res, targetPath, indexPath);
1962
+ });
1963
+ const { port: boundPort } = await listenWithPortFallback(server, port);
1964
+ activeServer = server;
1965
+ return boundPort;
1966
+ }
1967
+ function stopPortalServer() {
1968
+ return new Promise((resolve) => {
1969
+ if (!activeServer) {
1970
+ resolve();
1971
+ return;
1972
+ }
1973
+ activeServer.close(() => {
1974
+ activeServer = null;
1975
+ resolve();
1976
+ });
1977
+ });
1978
+ }
1979
+
1980
+ // packages/client/src/tunnel-config-parser.ts
1981
+ function parseTunnelArg(arg) {
1982
+ const parts = arg.split(":");
1983
+ if (parts.length === 1) {
1984
+ const port = validatePort(parts[0]);
1985
+ return { port };
1986
+ }
1987
+ if (parts.length === 2) {
1988
+ const port = validatePort(parts[0]);
1989
+ const subdomain = parts[1];
1990
+ if (!SUBDOMAIN_REGEX.test(subdomain)) {
1991
+ throw new Error(`Invalid subdomain format: "${subdomain}"`);
1992
+ }
1993
+ return { port, subdomain };
1994
+ }
1995
+ throw new Error(
1996
+ `Invalid tunnel format: "${arg}". Use <port> or <port>:<subdomain>`
1997
+ );
1998
+ }
1999
+ function parseTunnelArgs(args) {
2000
+ if (args.length === 0) throw new Error("At least one port is required");
2001
+ return args.map(parseTunnelArg);
2002
+ }
2003
+ function validatePort(portStr) {
2004
+ const port = Number(portStr);
2005
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
2006
+ throw new Error(`Invalid port: "${portStr}" (must be 1-65535)`);
2007
+ }
2008
+ return port;
2009
+ }
2010
+
2011
+ // packages/client/src/cli.ts
2012
+ var display = new Display();
2013
+ program.name("tunelo").version(VERSION).description("Expose local services to the internet");
2014
+ program.command("http <ports...>").description("Create HTTP tunnel(s). Format: <port> or <port>:<subdomain>").option(
2015
+ "-s, --subdomain <name>",
2016
+ "Request specific subdomain (single tunnel only)"
2017
+ ).option("-k, --key <apikey>", "API key for authentication").option("--server <url>", "Tunnel server URL").option("-a, --auth <user:pass>", "Protect tunnel(s) with Basic Auth").option(
2018
+ "--inspect-port <port>",
2019
+ "Inspector UI port; next free port is used if taken (default: 4040)",
2020
+ "4040"
2021
+ ).option("--no-inspect", "Disable request inspector").option(
2022
+ "--portal-port <port>",
2023
+ "Portal server port; next free port is used if taken (default: 4041)",
2024
+ "4041"
2025
+ ).option("--no-portal", "Disable the portal server").option("-q, --quiet", "Suppress request logging").option("-v, --verbose", "Show extra details (body size)").action(
2026
+ async (portArgs, options) => {
2027
+ let tunnels;
2028
+ try {
2029
+ tunnels = parseTunnelArgs(portArgs);
2030
+ } catch (err) {
2031
+ display.showError(err.message);
2032
+ process.exit(1);
2033
+ }
2034
+ const config = loadConfig();
2035
+ const apiKey = options.key ?? process.env.TUNELO_KEY ?? config.key;
2036
+ const serverUrl = options.server ?? process.env.TUNELO_SERVER ?? config.server ?? "wss://tunnel.inetdev.io.vn";
2037
+ if (!apiKey) {
2038
+ display.showError(
2039
+ "API key required. Use --key, TUNELO_KEY env, or `tunelo config --key <key>`"
2040
+ );
2041
+ process.exit(1);
2042
+ }
2043
+ if (options.quiet) display.setLogLevel("quiet");
2044
+ else if (options.verbose) display.setLogLevel("verbose");
2045
+ display.showBanner(VERSION);
2046
+ display.showConnecting(serverUrl);
2047
+ const primary = tunnels[0];
2048
+ const additional = tunnels.slice(1);
2049
+ const subdomainPortMap = /* @__PURE__ */ new Map();
2050
+ const inspectPort = Number(options.inspectPort) || 4040;
2051
+ const inspector = options.inspect !== false ? createInspectorIntegration({
2052
+ inspectPort,
2053
+ subdomainPortMap,
2054
+ defaultPort: primary.port
2055
+ }) : null;
2056
+ const client = new TunnelClient({
2057
+ serverUrl,
2058
+ apiKey,
2059
+ subdomain: options.subdomain ?? primary.subdomain,
2060
+ localPort: primary.port,
2061
+ auth: options.auth,
2062
+ additionalTunnels: additional
2063
+ });
2064
+ const run = startRunRecord("http", serverUrl);
2065
+ const runTunnels = [];
2066
+ const portalPort = Number(options.portalPort) || 4041;
2067
+ const portalEnabled = options.portal !== false;
2068
+ client.on("connected", (result) => {
2069
+ subdomainPortMap.set(result.subdomain, primary.port);
2070
+ runTunnels.push({ url: result.url, localPort: primary.port });
2071
+ run.update(runTunnels);
2072
+ display.showConnected(
2073
+ result.url,
2074
+ `http://localhost:${primary.port}`,
2075
+ apiKey,
2076
+ options.auth !== void 0
2077
+ );
2078
+ if (inspector) {
2079
+ inspector.start().then((boundPort) => {
2080
+ display.showInspector(boundPort, inspectPort);
2081
+ }).catch((err) => {
2082
+ display.showError(`Inspector failed to start: ${err.message}`);
2083
+ });
2084
+ }
2085
+ if (portalEnabled) {
2086
+ startPortalServer(portalPort).then((boundPort) => {
2087
+ display.showPortal(boundPort, portalPort);
2088
+ }).catch((err) => {
2089
+ display.showError(
2090
+ `Portal server failed to start: ${err.message}`
2091
+ );
2092
+ });
2093
+ }
2094
+ });
2095
+ client.on("tunnel-registered", (result) => {
2096
+ subdomainPortMap.set(result.subdomain, result.localPort);
2097
+ runTunnels.push({ url: result.url, localPort: result.localPort });
2098
+ run.update(runTunnels);
2099
+ display.showTunnelRegistered(
2100
+ result.subdomain,
2101
+ result.url,
2102
+ result.localPort
2103
+ );
2104
+ });
2105
+ client.on("tunnel-register-error", (result) => {
2106
+ display.showError(`Tunnel ${result.subdomain}: ${result.error}`);
2107
+ });
2108
+ if (inspector) {
2109
+ client.on("inspector-entry", (entry) => {
2110
+ inspector.store.add(entry);
2111
+ });
2112
+ }
2113
+ client.on("disconnected", () => display.showDisconnected());
2114
+ client.on("reconnecting", (info) => {
2115
+ display.showReconnecting(info.attempt, info.delay);
2116
+ });
2117
+ client.on(
2118
+ "request",
2119
+ (info) => {
2120
+ display.logRequest(
2121
+ info.method,
2122
+ info.path,
2123
+ info.status,
2124
+ info.durationMs,
2125
+ void 0,
2126
+ info.subdomain
2127
+ );
2128
+ }
2129
+ );
2130
+ client.on("error", (err) => display.showError(err.message));
2131
+ try {
2132
+ await client.connect();
2133
+ } catch (err) {
2134
+ display.showError(err.message);
2135
+ process.exit(1);
2136
+ }
2137
+ process.on("SIGINT", () => {
2138
+ client.disconnect();
2139
+ if (portalEnabled) void stopPortalServer();
2140
+ display.showStats();
2141
+ process.exit(0);
2142
+ });
2143
+ }
2144
+ );
2145
+ registerTcpCommand(program, display);
2146
+ registerLoginCommands(program);
2147
+ registerStatusCommands(program);
2148
+ registerUpdateCommand(program);
2149
+ program.command("config").description("Set default configuration").option("-k, --key <apikey>", "Set default API key").option("-s, --server <url>", "Set default server URL").option("--show", "Show current config").action((options) => {
2150
+ if (options.show) {
2151
+ const config2 = loadConfig();
2152
+ console.log(`Config path: ${getConfigPath()}`);
2153
+ console.log(JSON.stringify(config2, null, 2));
2154
+ return;
2155
+ }
2156
+ const config = loadConfig();
2157
+ if (options.key) config.key = options.key;
2158
+ if (options.server) config.server = options.server;
2159
+ saveConfig(config);
2160
+ console.log("Config saved.");
2161
+ });
2162
+ program.parse();
2163
+ //# sourceMappingURL=cli.js.map