xpose-dev 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.
package/dist/index.js ADDED
@@ -0,0 +1,1494 @@
1
+ #!/usr/bin/env node
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
9
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
10
+ }) : x)(function(x) {
11
+ if (typeof require !== "undefined") return require.apply(this, arguments);
12
+ throw Error('Dynamic require of "' + x + '" is not supported');
13
+ });
14
+ var __commonJS = (cb, mod) => function __require2() {
15
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
16
+ };
17
+ var __copyProps = (to, from, except, desc) => {
18
+ if (from && typeof from === "object" || typeof from === "function") {
19
+ for (let key of __getOwnPropNames(from))
20
+ if (!__hasOwnProp.call(to, key) && key !== except)
21
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
22
+ }
23
+ return to;
24
+ };
25
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
26
+ // If the importer is in node compatibility mode or this is not an ESM
27
+ // file that has been converted to a CommonJS file using a Babel-
28
+ // compatible transform (i.e. "__esModule" has not been set), then set
29
+ // "default" to the CommonJS "module.exports" for node compatibility.
30
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
31
+ mod
32
+ ));
33
+
34
+ // ../../node_modules/.bun/picocolors@1.1.1/node_modules/picocolors/picocolors.js
35
+ var require_picocolors = __commonJS({
36
+ "../../node_modules/.bun/picocolors@1.1.1/node_modules/picocolors/picocolors.js"(exports, module) {
37
+ "use strict";
38
+ var p = process || {};
39
+ var argv = p.argv || [];
40
+ var env = p.env || {};
41
+ var isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
42
+ var formatter = (open, close, replace = open) => (input) => {
43
+ let string = "" + input, index = string.indexOf(close, open.length);
44
+ return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
45
+ };
46
+ var replaceClose = (string, close, replace, index) => {
47
+ let result = "", cursor = 0;
48
+ do {
49
+ result += string.substring(cursor, index) + replace;
50
+ cursor = index + close.length;
51
+ index = string.indexOf(close, cursor);
52
+ } while (~index);
53
+ return result + string.substring(cursor);
54
+ };
55
+ var createColors = (enabled = isColorSupported) => {
56
+ let f = enabled ? formatter : () => String;
57
+ return {
58
+ isColorSupported: enabled,
59
+ reset: f("\x1B[0m", "\x1B[0m"),
60
+ bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
61
+ dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
62
+ italic: f("\x1B[3m", "\x1B[23m"),
63
+ underline: f("\x1B[4m", "\x1B[24m"),
64
+ inverse: f("\x1B[7m", "\x1B[27m"),
65
+ hidden: f("\x1B[8m", "\x1B[28m"),
66
+ strikethrough: f("\x1B[9m", "\x1B[29m"),
67
+ black: f("\x1B[30m", "\x1B[39m"),
68
+ red: f("\x1B[31m", "\x1B[39m"),
69
+ green: f("\x1B[32m", "\x1B[39m"),
70
+ yellow: f("\x1B[33m", "\x1B[39m"),
71
+ blue: f("\x1B[34m", "\x1B[39m"),
72
+ magenta: f("\x1B[35m", "\x1B[39m"),
73
+ cyan: f("\x1B[36m", "\x1B[39m"),
74
+ white: f("\x1B[37m", "\x1B[39m"),
75
+ gray: f("\x1B[90m", "\x1B[39m"),
76
+ bgBlack: f("\x1B[40m", "\x1B[49m"),
77
+ bgRed: f("\x1B[41m", "\x1B[49m"),
78
+ bgGreen: f("\x1B[42m", "\x1B[49m"),
79
+ bgYellow: f("\x1B[43m", "\x1B[49m"),
80
+ bgBlue: f("\x1B[44m", "\x1B[49m"),
81
+ bgMagenta: f("\x1B[45m", "\x1B[49m"),
82
+ bgCyan: f("\x1B[46m", "\x1B[49m"),
83
+ bgWhite: f("\x1B[47m", "\x1B[49m"),
84
+ blackBright: f("\x1B[90m", "\x1B[39m"),
85
+ redBright: f("\x1B[91m", "\x1B[39m"),
86
+ greenBright: f("\x1B[92m", "\x1B[39m"),
87
+ yellowBright: f("\x1B[93m", "\x1B[39m"),
88
+ blueBright: f("\x1B[94m", "\x1B[39m"),
89
+ magentaBright: f("\x1B[95m", "\x1B[39m"),
90
+ cyanBright: f("\x1B[96m", "\x1B[39m"),
91
+ whiteBright: f("\x1B[97m", "\x1B[39m"),
92
+ bgBlackBright: f("\x1B[100m", "\x1B[49m"),
93
+ bgRedBright: f("\x1B[101m", "\x1B[49m"),
94
+ bgGreenBright: f("\x1B[102m", "\x1B[49m"),
95
+ bgYellowBright: f("\x1B[103m", "\x1B[49m"),
96
+ bgBlueBright: f("\x1B[104m", "\x1B[49m"),
97
+ bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
98
+ bgCyanBright: f("\x1B[106m", "\x1B[49m"),
99
+ bgWhiteBright: f("\x1B[107m", "\x1B[49m")
100
+ };
101
+ };
102
+ module.exports = createColors();
103
+ module.exports.createColors = createColors;
104
+ }
105
+ });
106
+
107
+ // src/index.ts
108
+ import { defineCommand, runMain } from "citty";
109
+
110
+ // ../../packages/protocol/src/messages.ts
111
+ function isTunnelMessage(data) {
112
+ return typeof data === "object" && data !== null && "type" in data && typeof data.type === "string";
113
+ }
114
+ function parseTextMessage(raw) {
115
+ try {
116
+ const parsed = JSON.parse(raw);
117
+ if (isTunnelMessage(parsed)) return parsed;
118
+ return null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ // ../../packages/protocol/src/constants.ts
125
+ var PROTOCOL = {
126
+ /** Length of generated subdomain IDs */
127
+ SUBDOMAIN_LENGTH: 12,
128
+ /** Length of random suffix appended to custom subdomains */
129
+ SUBDOMAIN_SUFFIX_LENGTH: 6,
130
+ /** Characters used in subdomain IDs (lowercase + digits only for DNS compatibility) */
131
+ SUBDOMAIN_ALPHABET: "abcdefghijklmnopqrstuvwxyz0123456789",
132
+ /** Length of request IDs for multiplexing */
133
+ REQUEST_ID_LENGTH: 12,
134
+ /** How long to wait for a response from the CLI before timing out */
135
+ REQUEST_TIMEOUT_MS: 3e4,
136
+ /** How long to wait for CLI reconnection before rejecting queued requests */
137
+ RECONNECT_GRACE_PERIOD_MS: 5e3,
138
+ /** Default request/response body limit: 5MB */
139
+ DEFAULT_MAX_BODY_SIZE_BYTES: 5 * 1024 * 1024,
140
+ /** Reconnection backoff parameters */
141
+ BACKOFF_BASE_MS: 1e3,
142
+ BACKOFF_MULTIPLIER: 2,
143
+ BACKOFF_MAX_MS: 3e4,
144
+ BACKOFF_MAX_ATTEMPTS: 15,
145
+ BACKOFF_JITTER_MIN: 0.1,
146
+ BACKOFF_JITTER_MAX: 0.2,
147
+ /** Default tunnel lifetime: 4 hours */
148
+ DEFAULT_TTL_SECONDS: 14400,
149
+ /** Maximum tunnel lifetime: 24 hours */
150
+ MAX_TTL_SECONDS: 86400,
151
+ /** Path where CLI connects via WebSocket */
152
+ TUNNEL_CONNECT_PATH: "/_tunnel/connect",
153
+ /** Default public tunnel domain */
154
+ DEFAULT_PUBLIC_DOMAIN: "xpose.dev",
155
+ /** Auto ping/pong strings for DO WebSocket hibernation */
156
+ PING_MESSAGE: "ping",
157
+ PONG_MESSAGE: "pong",
158
+ /** How long a CLI session can be resumed after exit (seconds) */
159
+ SESSION_RESUME_WINDOW_SECONDS: 600
160
+ };
161
+
162
+ // ../../packages/protocol/src/binary.ts
163
+ var encoder = new TextEncoder();
164
+ var decoder = new TextDecoder();
165
+ function encodeBinaryFrame(requestId, body) {
166
+ const idBytes = encoder.encode(requestId);
167
+ const frame = new Uint8Array(PROTOCOL.REQUEST_ID_LENGTH + body.byteLength);
168
+ frame.set(idBytes, 0);
169
+ frame.set(body, PROTOCOL.REQUEST_ID_LENGTH);
170
+ return frame.buffer;
171
+ }
172
+ function decodeBinaryFrame(buffer) {
173
+ const view = new Uint8Array(buffer);
174
+ const requestId = decoder.decode(
175
+ view.slice(0, PROTOCOL.REQUEST_ID_LENGTH)
176
+ );
177
+ const body = view.slice(PROTOCOL.REQUEST_ID_LENGTH);
178
+ return { requestId, body };
179
+ }
180
+
181
+ // ../../packages/protocol/src/subdomain.ts
182
+ import { customAlphabet } from "nanoid";
183
+ var generateSubdomainId = customAlphabet(
184
+ PROTOCOL.SUBDOMAIN_ALPHABET,
185
+ PROTOCOL.SUBDOMAIN_LENGTH
186
+ );
187
+ var generateRequestId = customAlphabet(
188
+ PROTOCOL.SUBDOMAIN_ALPHABET,
189
+ PROTOCOL.REQUEST_ID_LENGTH
190
+ );
191
+ var generateSuffix = customAlphabet(
192
+ PROTOCOL.SUBDOMAIN_ALPHABET,
193
+ PROTOCOL.SUBDOMAIN_SUFFIX_LENGTH
194
+ );
195
+ function buildCustomSubdomain(prefix) {
196
+ const sanitized = prefix.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
197
+ if (!sanitized) return generateSubdomainId();
198
+ return `${sanitized}-${generateSuffix()}`;
199
+ }
200
+
201
+ // src/tunnel-client.ts
202
+ import WebSocket2 from "ws";
203
+ import { EventEmitter } from "events";
204
+
205
+ // src/ws-relay.ts
206
+ import WebSocket from "ws";
207
+ var WsRelayManager = class {
208
+ relays = /* @__PURE__ */ new Map();
209
+ host;
210
+ port;
211
+ constructor(host, port) {
212
+ this.host = host;
213
+ this.port = port;
214
+ }
215
+ /**
216
+ * Handle a ws-upgrade request from the server: dial the local WebSocket
217
+ * endpoint and start relaying frames.
218
+ */
219
+ handleUpgrade(tunnelWs, msg, sendMessage) {
220
+ const localUrl = `ws://${this.host}:${this.port}${msg.path}`;
221
+ const skipHeaders = /* @__PURE__ */ new Set([
222
+ "host",
223
+ "upgrade",
224
+ "connection",
225
+ "sec-websocket-key",
226
+ "sec-websocket-version",
227
+ "sec-websocket-extensions"
228
+ ]);
229
+ const headers = {};
230
+ const subprotocols = [];
231
+ for (const [key, value] of Object.entries(msg.headers)) {
232
+ const lower = key.toLowerCase();
233
+ if (skipHeaders.has(lower)) continue;
234
+ if (lower === "sec-websocket-protocol") {
235
+ for (const p of value.split(",")) {
236
+ const trimmed = p.trim();
237
+ if (trimmed) subprotocols.push(trimmed);
238
+ }
239
+ continue;
240
+ }
241
+ headers[lower] = value;
242
+ }
243
+ const localConn = new WebSocket(localUrl, subprotocols, { headers });
244
+ localConn.binaryType = "arraybuffer";
245
+ localConn.on("error", () => {
246
+ sendMessage({
247
+ type: "ws-upgrade-ack",
248
+ streamId: msg.streamId,
249
+ ok: false,
250
+ error: `Failed to connect to ${localUrl}`
251
+ });
252
+ this.relays.delete(msg.streamId);
253
+ });
254
+ localConn.on("open", () => {
255
+ const relay = {
256
+ streamId: msg.streamId,
257
+ localConn
258
+ };
259
+ this.relays.set(msg.streamId, relay);
260
+ sendMessage({
261
+ type: "ws-upgrade-ack",
262
+ streamId: msg.streamId,
263
+ ok: true
264
+ });
265
+ this.readLocalAndForward(tunnelWs, relay, sendMessage);
266
+ });
267
+ localConn.on("close", () => {
268
+ if (!this.relays.has(msg.streamId)) return;
269
+ sendMessage({
270
+ type: "ws-close",
271
+ streamId: msg.streamId,
272
+ code: 1e3,
273
+ reason: "Local WebSocket closed"
274
+ });
275
+ this.relays.delete(msg.streamId);
276
+ });
277
+ }
278
+ /**
279
+ * Read frames from the local WebSocket and forward them through the tunnel.
280
+ */
281
+ readLocalAndForward(tunnelWs, relay, sendMessage) {
282
+ relay.localConn.on("message", (data, isBinary) => {
283
+ if (!this.relays.has(relay.streamId)) return;
284
+ const frameType = isBinary ? "binary" : "text";
285
+ sendMessage({
286
+ type: "ws-frame",
287
+ streamId: relay.streamId,
288
+ frameType
289
+ });
290
+ let body;
291
+ if (Buffer.isBuffer(data)) {
292
+ body = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
293
+ } else if (data instanceof ArrayBuffer) {
294
+ body = new Uint8Array(data);
295
+ } else if (typeof data === "string") {
296
+ body = new TextEncoder().encode(data);
297
+ } else {
298
+ body = Buffer.concat(data);
299
+ }
300
+ const frame = encodeBinaryFrame(relay.streamId, body);
301
+ tunnelWs.send(frame);
302
+ });
303
+ }
304
+ /**
305
+ * Forward a WS frame from the browser (via tunnel) to the local WS.
306
+ */
307
+ handleFrame(msg, body) {
308
+ const relay = this.relays.get(msg.streamId);
309
+ if (!relay) return;
310
+ if (msg.frameType === "text") {
311
+ const text = new TextDecoder().decode(body);
312
+ relay.localConn.send(text);
313
+ } else {
314
+ relay.localConn.send(body);
315
+ }
316
+ }
317
+ /**
318
+ * Close a relay stream.
319
+ */
320
+ handleClose(msg) {
321
+ this.closeRelay(msg.streamId);
322
+ }
323
+ /**
324
+ * Tear down a single relay connection.
325
+ */
326
+ closeRelay(streamId) {
327
+ const relay = this.relays.get(streamId);
328
+ if (!relay) return;
329
+ this.relays.delete(streamId);
330
+ try {
331
+ relay.localConn.close(1e3, "Stream closed");
332
+ } catch {
333
+ }
334
+ }
335
+ /**
336
+ * Tear down all active relay connections.
337
+ */
338
+ closeAll() {
339
+ for (const relay of this.relays.values()) {
340
+ try {
341
+ relay.localConn.close(1e3, "All streams closed");
342
+ } catch {
343
+ }
344
+ }
345
+ this.relays.clear();
346
+ }
347
+ };
348
+
349
+ // src/tunnel-client.ts
350
+ function parseContentLength(raw) {
351
+ if (!raw) return null;
352
+ const parsed = Number.parseInt(raw, 10);
353
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
354
+ return parsed;
355
+ }
356
+ function headerValue(headers, name) {
357
+ const target = name.toLowerCase();
358
+ for (const [key, value] of Object.entries(headers)) {
359
+ if (key.toLowerCase() === target) return value;
360
+ }
361
+ return void 0;
362
+ }
363
+ function toArrayBuffer(view) {
364
+ return view.buffer.slice(
365
+ view.byteOffset,
366
+ view.byteOffset + view.byteLength
367
+ );
368
+ }
369
+ function createTunnelClient(opts) {
370
+ const emitter = new EventEmitter();
371
+ let ws = null;
372
+ let reconnectAttempts = 0;
373
+ let sessionId = null;
374
+ let disconnectedIntentionally = false;
375
+ let maxBodySizeBytes = PROTOCOL.DEFAULT_MAX_BODY_SIZE_BYTES;
376
+ const requestBodyChunks = /* @__PURE__ */ new Map();
377
+ const requestBodySizes = /* @__PURE__ */ new Map();
378
+ const oversizedRequestIds = /* @__PURE__ */ new Set();
379
+ const pendingRequestMeta = /* @__PURE__ */ new Map();
380
+ const wsRelayMgr = new WsRelayManager(opts.host, opts.port);
381
+ const pendingWsFrameTypes = /* @__PURE__ */ new Map();
382
+ const tunnelDomain = opts.domain?.trim() || PROTOCOL.DEFAULT_PUBLIC_DOMAIN;
383
+ const wsUrl = `wss://${opts.subdomain}.${tunnelDomain}${PROTOCOL.TUNNEL_CONNECT_PATH}`;
384
+ function emitStatus(status) {
385
+ emitter.emit("status", status);
386
+ }
387
+ function sendMessage(message) {
388
+ ws?.send(JSON.stringify(message));
389
+ }
390
+ async function readBodyWithinLimit(body) {
391
+ if (!body) {
392
+ return { chunks: [], totalBytes: 0 };
393
+ }
394
+ const chunks = [];
395
+ let totalBytes = 0;
396
+ const reader = body.getReader();
397
+ try {
398
+ while (true) {
399
+ const { done, value } = await reader.read();
400
+ if (done) break;
401
+ totalBytes += value.byteLength;
402
+ if (totalBytes > maxBodySizeBytes) {
403
+ return null;
404
+ }
405
+ chunks.push(value);
406
+ }
407
+ } finally {
408
+ reader.releaseLock();
409
+ }
410
+ return { chunks, totalBytes };
411
+ }
412
+ function respondWith413(requestId, message) {
413
+ sendMessage({
414
+ type: "error",
415
+ message,
416
+ requestId,
417
+ status: 413
418
+ });
419
+ }
420
+ function handleBinaryFrame(data) {
421
+ const { requestId, body } = decodeBinaryFrame(data);
422
+ const frameType = pendingWsFrameTypes.get(requestId);
423
+ if (frameType !== void 0) {
424
+ pendingWsFrameTypes.delete(requestId);
425
+ wsRelayMgr.handleFrame(
426
+ {
427
+ type: "ws-frame",
428
+ streamId: requestId,
429
+ frameType
430
+ },
431
+ body
432
+ );
433
+ return;
434
+ }
435
+ const chunks = requestBodyChunks.get(requestId);
436
+ if (!chunks) return;
437
+ if (oversizedRequestIds.has(requestId)) return;
438
+ const nextSize = (requestBodySizes.get(requestId) ?? 0) + body.byteLength;
439
+ requestBodySizes.set(requestId, nextSize);
440
+ if (nextSize > maxBodySizeBytes) {
441
+ oversizedRequestIds.add(requestId);
442
+ requestBodyChunks.delete(requestId);
443
+ return;
444
+ }
445
+ chunks.push(body);
446
+ }
447
+ function connect() {
448
+ emitStatus(reconnectAttempts > 0 ? "reconnecting" : "connecting");
449
+ disconnectedIntentionally = false;
450
+ ws = new WebSocket2(wsUrl);
451
+ ws.binaryType = "arraybuffer";
452
+ ws.on("open", () => {
453
+ reconnectAttempts = 0;
454
+ sendMessage({
455
+ type: "auth",
456
+ subdomain: opts.subdomain,
457
+ ttl: opts.ttl,
458
+ sessionId: sessionId ?? void 0
459
+ });
460
+ });
461
+ ws.on("message", (data) => {
462
+ if (data instanceof ArrayBuffer) {
463
+ handleBinaryFrame(data);
464
+ return;
465
+ }
466
+ if (Buffer.isBuffer(data)) {
467
+ const str = data.toString("utf8");
468
+ if (!str.startsWith("{")) {
469
+ const ab = data.buffer.slice(
470
+ data.byteOffset,
471
+ data.byteOffset + data.byteLength
472
+ );
473
+ handleBinaryFrame(ab);
474
+ return;
475
+ }
476
+ const msg2 = parseTextMessage(str);
477
+ if (msg2) handleTextMessage(msg2);
478
+ return;
479
+ }
480
+ const raw = typeof data === "string" ? data : String(data);
481
+ const msg = parseTextMessage(raw);
482
+ if (msg) handleTextMessage(msg);
483
+ });
484
+ ws.on("close", () => {
485
+ if (disconnectedIntentionally) return;
486
+ scheduleReconnect();
487
+ });
488
+ ws.on("error", (err) => {
489
+ emitter.emit("error", err);
490
+ });
491
+ }
492
+ function handleTextMessage(msg) {
493
+ switch (msg.type) {
494
+ case "auth-ack": {
495
+ sessionId = msg.sessionId;
496
+ maxBodySizeBytes = msg.maxBodySizeBytes;
497
+ const displayTtl = msg.remainingTtl > 0 ? msg.remainingTtl : msg.ttl;
498
+ emitStatus("connected");
499
+ emitter.emit("authenticated", {
500
+ url: msg.url,
501
+ ttl: displayTtl,
502
+ sessionId: msg.sessionId,
503
+ maxBodySizeBytes: msg.maxBodySizeBytes
504
+ });
505
+ break;
506
+ }
507
+ case "http-request": {
508
+ const contentLength = parseContentLength(
509
+ headerValue(msg.headers, "content-length")
510
+ );
511
+ if (contentLength !== null && contentLength > maxBodySizeBytes) {
512
+ respondWith413(
513
+ msg.id,
514
+ `Request body exceeds ${maxBodySizeBytes} byte limit`
515
+ );
516
+ break;
517
+ }
518
+ if (msg.hasBody) {
519
+ requestBodyChunks.set(msg.id, []);
520
+ requestBodySizes.set(msg.id, 0);
521
+ pendingRequestMeta.set(msg.id, msg);
522
+ } else {
523
+ handleHttpRequest(msg, null);
524
+ }
525
+ break;
526
+ }
527
+ case "http-request-end": {
528
+ const chunks = requestBodyChunks.get(msg.id);
529
+ requestBodyChunks.delete(msg.id);
530
+ const reqMeta = pendingRequestMeta.get(msg.id);
531
+ pendingRequestMeta.delete(msg.id);
532
+ requestBodySizes.delete(msg.id);
533
+ if (!reqMeta) break;
534
+ if (oversizedRequestIds.delete(msg.id)) {
535
+ respondWith413(
536
+ msg.id,
537
+ `Request body exceeds ${maxBodySizeBytes} byte limit`
538
+ );
539
+ break;
540
+ }
541
+ const body = concatChunks(chunks ?? []);
542
+ handleHttpRequest(reqMeta, body.byteLength > 0 ? body : null);
543
+ break;
544
+ }
545
+ case "http-body-chunk": {
546
+ break;
547
+ }
548
+ case "ping": {
549
+ sendMessage({ type: "pong" });
550
+ break;
551
+ }
552
+ case "ws-upgrade": {
553
+ wsRelayMgr.handleUpgrade(ws, msg, sendMessage);
554
+ break;
555
+ }
556
+ case "ws-frame": {
557
+ pendingWsFrameTypes.set(msg.streamId, msg.frameType);
558
+ break;
559
+ }
560
+ case "ws-close": {
561
+ wsRelayMgr.handleClose(msg);
562
+ break;
563
+ }
564
+ case "error": {
565
+ if (msg.message === "Tunnel TTL expired") {
566
+ emitStatus("expired");
567
+ emitter.emit("expired");
568
+ } else {
569
+ emitter.emit("error", new Error(msg.message));
570
+ }
571
+ break;
572
+ }
573
+ }
574
+ }
575
+ async function handleHttpRequest(msg, body) {
576
+ const startTime = Date.now();
577
+ const localUrl = `http://${opts.host}:${opts.port}${msg.path}`;
578
+ try {
579
+ const reqHeaders = new Headers();
580
+ for (const [key, value] of Object.entries(msg.headers)) {
581
+ const lower = key.toLowerCase();
582
+ if (lower === "host" || lower === "connection" || lower === "transfer-encoding")
583
+ continue;
584
+ reqHeaders.set(key, value);
585
+ }
586
+ const response = await fetch(localUrl, {
587
+ method: msg.method,
588
+ headers: reqHeaders,
589
+ body: body ? toArrayBuffer(body) : void 0,
590
+ redirect: "manual"
591
+ });
592
+ const responseHeaders = {};
593
+ response.headers.forEach((value, key) => {
594
+ responseHeaders[key] = value;
595
+ });
596
+ const contentLength = parseContentLength(
597
+ headerValue(responseHeaders, "content-length")
598
+ );
599
+ if (contentLength !== null && contentLength > maxBodySizeBytes) {
600
+ respondWith413(
601
+ msg.id,
602
+ `Response body exceeds ${maxBodySizeBytes} byte limit`
603
+ );
604
+ emitter.emit("traffic", {
605
+ id: msg.id,
606
+ method: msg.method,
607
+ path: msg.path,
608
+ status: 413,
609
+ duration: Date.now() - startTime,
610
+ timestamp: /* @__PURE__ */ new Date()
611
+ });
612
+ return;
613
+ }
614
+ const bufferedResponse = await readBodyWithinLimit(response.body);
615
+ if (!bufferedResponse) {
616
+ respondWith413(
617
+ msg.id,
618
+ `Response body exceeds ${maxBodySizeBytes} byte limit`
619
+ );
620
+ emitter.emit("traffic", {
621
+ id: msg.id,
622
+ method: msg.method,
623
+ path: msg.path,
624
+ status: 413,
625
+ duration: Date.now() - startTime,
626
+ timestamp: /* @__PURE__ */ new Date()
627
+ });
628
+ return;
629
+ }
630
+ const hasBody = bufferedResponse.totalBytes > 0;
631
+ sendMessage({
632
+ type: "http-response-meta",
633
+ id: msg.id,
634
+ status: response.status,
635
+ headers: responseHeaders,
636
+ hasBody
637
+ });
638
+ if (hasBody) {
639
+ const chunkSize = 64 * 1024;
640
+ for (const chunk of bufferedResponse.chunks) {
641
+ for (let offset = 0; offset < chunk.byteLength; offset += chunkSize) {
642
+ const end = Math.min(offset + chunkSize, chunk.byteLength);
643
+ const piece = chunk.slice(offset, end);
644
+ sendMessage({
645
+ type: "http-body-chunk",
646
+ id: msg.id,
647
+ done: false
648
+ });
649
+ ws?.send(encodeBinaryFrame(msg.id, piece));
650
+ }
651
+ }
652
+ }
653
+ sendMessage({
654
+ type: "http-response-end",
655
+ id: msg.id
656
+ });
657
+ emitter.emit("traffic", {
658
+ id: msg.id,
659
+ method: msg.method,
660
+ path: msg.path,
661
+ status: response.status,
662
+ duration: Date.now() - startTime,
663
+ timestamp: /* @__PURE__ */ new Date()
664
+ });
665
+ } catch (err) {
666
+ const errMessage = err.message;
667
+ const isConnectionRefused = errMessage.includes("ECONNREFUSED") || errMessage.includes("fetch failed");
668
+ sendMessage({
669
+ type: "error",
670
+ message: isConnectionRefused ? `Could not connect to localhost:${opts.port} \u2014 is your server running?` : `Failed to reach localhost:${opts.port}: ${errMessage}`,
671
+ requestId: msg.id,
672
+ status: 502
673
+ });
674
+ emitter.emit("traffic", {
675
+ id: msg.id,
676
+ method: msg.method,
677
+ path: msg.path,
678
+ status: 502,
679
+ duration: Date.now() - startTime,
680
+ timestamp: /* @__PURE__ */ new Date()
681
+ });
682
+ }
683
+ }
684
+ function concatChunks(chunks) {
685
+ const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0);
686
+ const result = new Uint8Array(totalLength);
687
+ let offset = 0;
688
+ for (const chunk of chunks) {
689
+ result.set(chunk, offset);
690
+ offset += chunk.byteLength;
691
+ }
692
+ return result;
693
+ }
694
+ function scheduleReconnect() {
695
+ if (reconnectAttempts >= PROTOCOL.BACKOFF_MAX_ATTEMPTS) {
696
+ emitStatus("disconnected");
697
+ return;
698
+ }
699
+ emitStatus("reconnecting");
700
+ const baseDelay = PROTOCOL.BACKOFF_BASE_MS * Math.pow(PROTOCOL.BACKOFF_MULTIPLIER, reconnectAttempts);
701
+ const delay = Math.min(baseDelay, PROTOCOL.BACKOFF_MAX_MS);
702
+ const jitter = delay * (PROTOCOL.BACKOFF_JITTER_MIN + Math.random() * (PROTOCOL.BACKOFF_JITTER_MAX - PROTOCOL.BACKOFF_JITTER_MIN));
703
+ reconnectAttempts++;
704
+ setTimeout(() => connect(), delay + jitter);
705
+ }
706
+ function disconnect() {
707
+ disconnectedIntentionally = true;
708
+ emitStatus("disconnected");
709
+ wsRelayMgr.closeAll();
710
+ ws?.close(1e3, "Client disconnect");
711
+ ws = null;
712
+ }
713
+ return {
714
+ connect,
715
+ disconnect,
716
+ on: emitter.on.bind(emitter)
717
+ };
718
+ }
719
+
720
+ // src/index.ts
721
+ import { existsSync, statSync } from "fs";
722
+ import { resolve } from "path";
723
+
724
+ // ../../packages/tunnel-core/dist/index.js
725
+ var import_picocolors = __toESM(require_picocolors(), 1);
726
+ import { execFile } from "child_process";
727
+ import { promisify } from "util";
728
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
729
+ import { join } from "path";
730
+ import { homedir } from "os";
731
+ function printError(message) {
732
+ console.log(` ${import_picocolors.default.red("Error:")} ${message}`);
733
+ }
734
+ var execFileAsync = promisify(execFile);
735
+ function isValidPort(value) {
736
+ return Number.isInteger(value) && value >= 1 && value <= 65535;
737
+ }
738
+ function parseTurboDryRunOutput(output) {
739
+ const start = output.indexOf("{");
740
+ const end = output.lastIndexOf("}");
741
+ if (start < 0 || end < start) {
742
+ throw new Error("Could not find JSON payload in Turborepo dry-run output");
743
+ }
744
+ const rawJson = output.slice(start, end + 1);
745
+ const parsed = JSON.parse(rawJson);
746
+ if (!Array.isArray(parsed.tasks)) {
747
+ throw new Error(
748
+ "Unexpected Turborepo dry-run format (missing tasks array)"
749
+ );
750
+ }
751
+ return parsed.tasks;
752
+ }
753
+ function collectRegexPorts(command, pattern) {
754
+ const ports = [];
755
+ let match = null;
756
+ while ((match = pattern.exec(command)) !== null) {
757
+ const port = Number.parseInt(match[1], 10);
758
+ if (isValidPort(port)) {
759
+ ports.push(port);
760
+ }
761
+ }
762
+ return ports;
763
+ }
764
+ function extractExplicitPorts(command) {
765
+ const patterns = [
766
+ /(?:^|\s)PORT=(\d{2,5})(?=\s|$)/g,
767
+ /--port(?:=|\s+)(\d{2,5})(?=\s|$)/g,
768
+ /(?:^|\s)-p\s+(\d{2,5})(?=\s|$)/g,
769
+ /(?:^|\s)-p(\d{2,5})(?=\s|$)/g,
770
+ /--listen(?:=|\s+)(?:[^\s:]+:)?(\d{2,5})(?=\s|$)/g,
771
+ /https?:\/\/[^\s/:]+:(\d{2,5})(?=[/\s]|$)/g
772
+ ];
773
+ const discovered = /* @__PURE__ */ new Set();
774
+ for (const pattern of patterns) {
775
+ for (const port of collectRegexPorts(command, pattern)) {
776
+ discovered.add(port);
777
+ }
778
+ }
779
+ return [...discovered];
780
+ }
781
+ function inferDefaultPort(command) {
782
+ const normalized = command.toLowerCase();
783
+ if (/\bwrangler\s+dev\b/.test(normalized)) return 8787;
784
+ if (/\bnext\s+dev\b/.test(normalized)) return 3e3;
785
+ if (/\bnuxt\s+dev\b/.test(normalized)) return 3e3;
786
+ if (/\bremix\s+dev\b/.test(normalized)) return 3e3;
787
+ if (/\bastro\s+dev\b/.test(normalized)) return 4321;
788
+ if (/\bstart-storybook\b/.test(normalized)) return 6006;
789
+ if (/\bstorybook\b.*\bdev\b/.test(normalized)) return 6006;
790
+ if (/\bvite(?:\s|$)/.test(normalized) && !/\bvitest\b/.test(normalized)) {
791
+ return 5173;
792
+ }
793
+ return null;
794
+ }
795
+ async function discoverTurboPorts(options) {
796
+ const args = ["turbo", "run", options.task, "--dry=json"];
797
+ const filter = options.filter?.trim();
798
+ if (filter) {
799
+ args.push(`--filter=${filter}`);
800
+ }
801
+ let combinedOutput = "";
802
+ try {
803
+ const { stdout, stderr } = await execFileAsync("bunx", args, {
804
+ cwd: options.cwd,
805
+ maxBuffer: 10 * 1024 * 1024
806
+ });
807
+ combinedOutput = `${stdout}
808
+ ${stderr}`;
809
+ } catch (err) {
810
+ const error = err;
811
+ const extra = [error.stdout, error.stderr].filter(Boolean).join("\n");
812
+ throw new Error(
813
+ `Failed to run \`bunx ${args.join(" ")}\`${extra ? `
814
+ ${extra}` : ""}`
815
+ );
816
+ }
817
+ const tasks = parseTurboDryRunOutput(combinedOutput);
818
+ const discovered = [];
819
+ for (const task of tasks) {
820
+ const command = task.command?.trim();
821
+ if (!command) continue;
822
+ const explicitPorts = extractExplicitPorts(command);
823
+ if (explicitPorts.length > 0) {
824
+ for (const port of explicitPorts) {
825
+ discovered.push({
826
+ port,
827
+ packageName: task.package ?? "unknown",
828
+ directory: task.directory ?? "unknown",
829
+ command,
830
+ reason: "explicit"
831
+ });
832
+ }
833
+ continue;
834
+ }
835
+ const defaultPort = inferDefaultPort(command);
836
+ if (defaultPort !== null) {
837
+ discovered.push({
838
+ port: defaultPort,
839
+ packageName: task.package ?? "unknown",
840
+ directory: task.directory ?? "unknown",
841
+ command,
842
+ reason: "default"
843
+ });
844
+ }
845
+ }
846
+ const deduped = /* @__PURE__ */ new Map();
847
+ for (const entry of discovered) {
848
+ if (!deduped.has(entry.port)) {
849
+ deduped.set(entry.port, entry);
850
+ }
851
+ }
852
+ return [...deduped.values()].sort((a, b) => a.port - b.port);
853
+ }
854
+ function normalizeDomain(raw) {
855
+ return raw.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/\.$/, "");
856
+ }
857
+ var SESSION_FILE_NAME = "session.json";
858
+ var configDirOverride;
859
+ function getConfigDir(create) {
860
+ const dir = configDirOverride ?? join(homedir(), ".config", "xpose");
861
+ if (create) {
862
+ mkdirSync(dir, { recursive: true });
863
+ }
864
+ return dir;
865
+ }
866
+ function getSessionPath(create) {
867
+ return join(getConfigDir(create), SESSION_FILE_NAME);
868
+ }
869
+ function saveSession(session) {
870
+ const path = getSessionPath(true);
871
+ writeFileSync(path, JSON.stringify(session, null, 2), "utf-8");
872
+ }
873
+ function loadSession() {
874
+ const path = getSessionPath(false);
875
+ let data;
876
+ try {
877
+ data = readFileSync(path, "utf-8");
878
+ } catch {
879
+ return null;
880
+ }
881
+ let session;
882
+ try {
883
+ session = JSON.parse(data);
884
+ } catch {
885
+ return null;
886
+ }
887
+ if (!session.createdAt || !Array.isArray(session.tunnels)) {
888
+ return null;
889
+ }
890
+ const elapsed = Date.now() - new Date(session.createdAt).getTime();
891
+ const windowMs = PROTOCOL.SESSION_RESUME_WINDOW_SECONDS * 1e3;
892
+ if (elapsed > windowMs) {
893
+ return null;
894
+ }
895
+ return session;
896
+ }
897
+
898
+ // src/index.ts
899
+ import React2 from "react";
900
+ import { render } from "ink";
901
+
902
+ // src/tui/app.tsx
903
+ import React, { useState, useEffect } from "react";
904
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
905
+ import Spinner from "ink-spinner";
906
+ import { jsx, jsxs } from "react/jsx-runtime";
907
+ var MAX_TRAFFIC_ENTRIES = 100;
908
+ var MIN_SPLIT_WIDTH = 80;
909
+ function formatTtl(seconds) {
910
+ if (seconds < 0) seconds = 0;
911
+ const h = Math.floor(seconds / 3600);
912
+ const m = Math.floor(seconds % 3600 / 60);
913
+ const s = seconds % 60;
914
+ return `${h}h ${m}m ${s}s`;
915
+ }
916
+ function formatTime(date) {
917
+ const hh = String(date.getHours()).padStart(2, "0");
918
+ const mm = String(date.getMinutes()).padStart(2, "0");
919
+ const ss = String(date.getSeconds()).padStart(2, "0");
920
+ return `${hh}:${mm}:${ss}`;
921
+ }
922
+ function openBrowser(url) {
923
+ const { exec } = __require("child_process");
924
+ const platform = process.platform;
925
+ const cmd = platform === "darwin" ? `open "${url}"` : platform === "win32" ? `start "${url}"` : `xdg-open "${url}"`;
926
+ exec(cmd);
927
+ }
928
+ function TunnelCard({
929
+ tunnel,
930
+ showPortPrefix
931
+ }) {
932
+ const portLabel = showPortPrefix ? ` :${tunnel.port}` : "";
933
+ switch (tunnel.status) {
934
+ case "connected":
935
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [
936
+ /* @__PURE__ */ jsxs(Text, { children: [
937
+ /* @__PURE__ */ jsx(Text, { color: "green", bold: true, children: "\u2713 " }),
938
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "Connected" }),
939
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: portLabel })
940
+ ] }),
941
+ /* @__PURE__ */ jsxs(Text, { children: [
942
+ /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: "\u2192 " }),
943
+ /* @__PURE__ */ jsx(Text, { color: "cyan", bold: true, children: tunnel.url })
944
+ ] }),
945
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
946
+ " ",
947
+ "Forwarding to localhost:",
948
+ tunnel.port
949
+ ] }),
950
+ /* @__PURE__ */ jsxs(Text, { children: [
951
+ " ",
952
+ "TTL:",
953
+ " ",
954
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: formatTtl(tunnel.ttlRemaining) })
955
+ ] })
956
+ ] });
957
+ case "connecting":
958
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [
959
+ /* @__PURE__ */ jsxs(Text, { children: [
960
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
961
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: " Connecting..." }),
962
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: portLabel })
963
+ ] }),
964
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
965
+ " ",
966
+ "Port ",
967
+ tunnel.port
968
+ ] })
969
+ ] });
970
+ case "reconnecting":
971
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [
972
+ /* @__PURE__ */ jsxs(Text, { children: [
973
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: /* @__PURE__ */ jsx(Spinner, { type: "dots" }) }),
974
+ /* @__PURE__ */ jsx(Text, { color: "yellow", children: " Reconnecting..." }),
975
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: portLabel })
976
+ ] }),
977
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
978
+ " ",
979
+ "Port ",
980
+ tunnel.port
981
+ ] }),
982
+ tunnel.lastError && /* @__PURE__ */ jsxs(Text, { children: [
983
+ " ",
984
+ /* @__PURE__ */ jsx(Text, { color: "red", children: "Error: " }),
985
+ /* @__PURE__ */ jsx(Text, { children: tunnel.lastError })
986
+ ] })
987
+ ] });
988
+ case "disconnected":
989
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [
990
+ /* @__PURE__ */ jsxs(Text, { children: [
991
+ /* @__PURE__ */ jsx(Text, { color: "red", bold: true, children: "\u2717 " }),
992
+ /* @__PURE__ */ jsx(Text, { color: "red", children: "Disconnected" }),
993
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: portLabel })
994
+ ] }),
995
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
996
+ " ",
997
+ "Port ",
998
+ tunnel.port
999
+ ] }),
1000
+ tunnel.lastError && /* @__PURE__ */ jsxs(Text, { children: [
1001
+ " ",
1002
+ /* @__PURE__ */ jsx(Text, { color: "red", children: "Error: " }),
1003
+ /* @__PURE__ */ jsx(Text, { children: tunnel.lastError })
1004
+ ] })
1005
+ ] });
1006
+ case "expired":
1007
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [
1008
+ /* @__PURE__ */ jsxs(Text, { children: [
1009
+ /* @__PURE__ */ jsx(Text, { color: "red", bold: true, children: "\u2717 " }),
1010
+ /* @__PURE__ */ jsx(Text, { color: "red", children: "Tunnel expired" }),
1011
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: portLabel })
1012
+ ] }),
1013
+ /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1014
+ " ",
1015
+ "Port ",
1016
+ tunnel.port
1017
+ ] })
1018
+ ] });
1019
+ }
1020
+ }
1021
+ var METHOD_COLORS = {
1022
+ GET: "cyan",
1023
+ HEAD: "cyan",
1024
+ POST: "green",
1025
+ PUT: "yellow",
1026
+ DELETE: "red",
1027
+ PATCH: "magenta",
1028
+ OPTIONS: "gray"
1029
+ };
1030
+ function statusColor(status) {
1031
+ if (status >= 500) return "red";
1032
+ if (status >= 400) return "yellow";
1033
+ if (status >= 300) return "cyan";
1034
+ if (status >= 200) return "green";
1035
+ return "white";
1036
+ }
1037
+ function TrafficLine({ entry }) {
1038
+ const method = entry.method.padEnd(7);
1039
+ const path = entry.path.length > 30 ? entry.path.slice(0, 30) : entry.path.padEnd(30);
1040
+ const duration = `${String(entry.duration).padStart(5)}ms`;
1041
+ const time = formatTime(entry.timestamp);
1042
+ return /* @__PURE__ */ jsxs(Text, { children: [
1043
+ " ",
1044
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: time }),
1045
+ " ",
1046
+ /* @__PURE__ */ jsx(Text, { color: METHOD_COLORS[entry.method] ?? "white", children: method }),
1047
+ " ",
1048
+ /* @__PURE__ */ jsx(Text, { children: path }),
1049
+ " ",
1050
+ /* @__PURE__ */ jsx(Text, { color: statusColor(entry.status), children: entry.status }),
1051
+ " ",
1052
+ /* @__PURE__ */ jsx(Text, { dimColor: true, children: duration })
1053
+ ] });
1054
+ }
1055
+ function Panel({
1056
+ title,
1057
+ focused,
1058
+ width,
1059
+ height,
1060
+ children
1061
+ }) {
1062
+ const borderColor = focused ? "#3b82f6" : "gray";
1063
+ const titleColor = focused ? "#3b82f6" : "gray";
1064
+ return /* @__PURE__ */ jsxs(
1065
+ Box,
1066
+ {
1067
+ flexDirection: "column",
1068
+ width,
1069
+ height,
1070
+ borderStyle: "round",
1071
+ borderColor,
1072
+ children: [
1073
+ /* @__PURE__ */ jsx(Box, { position: "absolute", marginLeft: 1, marginTop: -1, children: /* @__PURE__ */ jsxs(Text, { color: titleColor, bold: focused, children: [
1074
+ " ",
1075
+ title,
1076
+ " "
1077
+ ] }) }),
1078
+ children
1079
+ ]
1080
+ }
1081
+ );
1082
+ }
1083
+ function App({ clients, ports, onQuit }) {
1084
+ const { exit } = useApp();
1085
+ const { stdout } = useStdout();
1086
+ const [columns, setColumns] = useState(stdout.columns || 80);
1087
+ const [rows, setRows] = useState(stdout.rows || 24);
1088
+ const [tunnels, setTunnels] = useState(
1089
+ () => ports.map((port) => ({
1090
+ port,
1091
+ status: "connecting",
1092
+ url: "",
1093
+ ttlRemaining: 0,
1094
+ maxBodySizeBytes: 0,
1095
+ lastError: ""
1096
+ }))
1097
+ );
1098
+ const [traffic, setTraffic] = useState(
1099
+ []
1100
+ );
1101
+ const seqRef = React.useRef(0);
1102
+ const [scrollOffset, setScrollOffset] = useState(0);
1103
+ const [autoScroll, setAutoScroll] = useState(true);
1104
+ const autoScrollRef = React.useRef(true);
1105
+ const [focusedPanel, setFocusedPanel] = useState("right");
1106
+ const showSplit = columns >= MIN_SPLIT_WIDTH;
1107
+ useEffect(() => {
1108
+ function onResize() {
1109
+ setColumns(stdout.columns);
1110
+ setRows(stdout.rows);
1111
+ }
1112
+ stdout.on("resize", onResize);
1113
+ return () => {
1114
+ stdout.off("resize", onResize);
1115
+ };
1116
+ }, [stdout]);
1117
+ useEffect(() => {
1118
+ const interval = setInterval(() => {
1119
+ setTunnels(
1120
+ (prev) => prev.map(
1121
+ (t) => t.status === "connected" && t.ttlRemaining > 0 ? { ...t, ttlRemaining: t.ttlRemaining - 1 } : t
1122
+ )
1123
+ );
1124
+ }, 1e3);
1125
+ return () => clearInterval(interval);
1126
+ }, []);
1127
+ useEffect(() => {
1128
+ clients.forEach((client, idx) => {
1129
+ client.on("authenticated", ({ url, ttl, maxBodySizeBytes }) => {
1130
+ setTunnels(
1131
+ (prev) => prev.map(
1132
+ (t, i) => i === idx ? {
1133
+ ...t,
1134
+ status: "connected",
1135
+ url,
1136
+ ttlRemaining: ttl,
1137
+ maxBodySizeBytes,
1138
+ lastError: ""
1139
+ } : t
1140
+ )
1141
+ );
1142
+ });
1143
+ client.on("traffic", (entry) => {
1144
+ const portPrefix = clients.length > 1 ? `[${ports[idx]}] ` : "";
1145
+ const seq = ++seqRef.current;
1146
+ setTraffic((prev) => {
1147
+ const next = [
1148
+ ...prev,
1149
+ { ...entry, path: `${portPrefix}${entry.path}`, seq }
1150
+ ];
1151
+ if (next.length > MAX_TRAFFIC_ENTRIES) {
1152
+ return next.slice(next.length - MAX_TRAFFIC_ENTRIES);
1153
+ }
1154
+ return next;
1155
+ });
1156
+ if (autoScrollRef.current) {
1157
+ setScrollOffset(Number.MAX_SAFE_INTEGER);
1158
+ }
1159
+ });
1160
+ client.on("status", (status) => {
1161
+ setTunnels(
1162
+ (prev) => prev.map((t, i) => i === idx ? { ...t, status } : t)
1163
+ );
1164
+ });
1165
+ client.on("error", (err) => {
1166
+ setTunnels(
1167
+ (prev) => prev.map(
1168
+ (t, i) => i === idx ? { ...t, lastError: err.message } : t
1169
+ )
1170
+ );
1171
+ });
1172
+ client.on("expired", () => {
1173
+ setTunnels(
1174
+ (prev) => prev.map(
1175
+ (t, i) => i === idx ? { ...t, status: "expired" } : t
1176
+ )
1177
+ );
1178
+ });
1179
+ });
1180
+ }, [clients, ports]);
1181
+ useEffect(() => {
1182
+ if (tunnels.length > 0 && tunnels.every((t) => t.status === "expired")) {
1183
+ onQuit();
1184
+ exit();
1185
+ }
1186
+ }, [tunnels, exit, onQuit]);
1187
+ const footerHeight = 1;
1188
+ const availableRows = rows - footerHeight;
1189
+ const trafficViewportHeight = showSplit ? Math.max(1, availableRows - 3) : Math.max(1, availableRows - 7);
1190
+ const isRawModeSupported = process.stdin.isTTY ?? false;
1191
+ useInput(
1192
+ (input, key) => {
1193
+ if (input === "q" || input === "c" && key.ctrl) {
1194
+ onQuit();
1195
+ exit();
1196
+ return;
1197
+ }
1198
+ if (input === "b") {
1199
+ const connected = tunnels.find(
1200
+ (t) => t.status === "connected" && t.url
1201
+ );
1202
+ if (connected) {
1203
+ openBrowser(connected.url);
1204
+ }
1205
+ return;
1206
+ }
1207
+ if (key.tab && showSplit) {
1208
+ setFocusedPanel((prev) => prev === "left" ? "right" : "left");
1209
+ return;
1210
+ }
1211
+ if (!showSplit || focusedPanel === "right") {
1212
+ const maxOff = Math.max(0, traffic.length - trafficViewportHeight);
1213
+ if (key.upArrow && maxOff > 0) {
1214
+ setScrollOffset((prev) => {
1215
+ const current = Math.min(prev, maxOff);
1216
+ return Math.max(0, current - 1);
1217
+ });
1218
+ setAutoScroll(false);
1219
+ autoScrollRef.current = false;
1220
+ }
1221
+ if (key.downArrow) {
1222
+ setScrollOffset((prev) => {
1223
+ const current = Math.min(prev, maxOff);
1224
+ const next = current + 1;
1225
+ if (next >= maxOff) {
1226
+ setAutoScroll(true);
1227
+ autoScrollRef.current = true;
1228
+ }
1229
+ return Math.min(next, maxOff);
1230
+ });
1231
+ }
1232
+ }
1233
+ },
1234
+ { isActive: isRawModeSupported }
1235
+ );
1236
+ const maxScroll = Math.max(0, traffic.length - trafficViewportHeight);
1237
+ const clampedOffset = Math.min(scrollOffset, maxScroll);
1238
+ const visibleTraffic = traffic.slice(
1239
+ clampedOffset,
1240
+ clampedOffset + trafficViewportHeight
1241
+ );
1242
+ const scrollPercent = traffic.length <= trafficViewportHeight ? 100 : Math.round(clampedOffset / maxScroll * 100);
1243
+ const footerParts = ["q quit", "b open browser"];
1244
+ if (showSplit) {
1245
+ footerParts.push("tab switch panel");
1246
+ }
1247
+ const canScroll = !showSplit || focusedPanel === "right";
1248
+ if (canScroll && traffic.length > 0) {
1249
+ const scrollLabel = autoScroll ? `\u2191\u2193 scroll ${scrollPercent}%` : `\u2191\u2193 scroll ${scrollPercent}% (paused)`;
1250
+ footerParts.push(scrollLabel);
1251
+ }
1252
+ const footer = ` ${footerParts.join(" | ")}`;
1253
+ if (!showSplit) {
1254
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [
1255
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", flexShrink: 0, children: tunnels.map((tunnel, i) => /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
1256
+ i > 0 && /* @__PURE__ */ jsx(Text, { children: "" }),
1257
+ /* @__PURE__ */ jsx(TunnelCard, { tunnel, showPortPrefix: tunnels.length > 1 })
1258
+ ] }, i)) }),
1259
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", flexGrow: 1, marginTop: 1, children: traffic.length === 0 ? /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
1260
+ " ",
1261
+ "Waiting for requests..."
1262
+ ] }) : visibleTraffic.map((entry) => /* @__PURE__ */ jsx(TrafficLine, { entry }, entry.seq)) }),
1263
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: footer }) })
1264
+ ] });
1265
+ }
1266
+ const leftWidth = Math.floor(columns * 0.35);
1267
+ const rightWidth = columns - leftWidth;
1268
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [
1269
+ /* @__PURE__ */ jsxs(Box, { flexGrow: 1, children: [
1270
+ /* @__PURE__ */ jsx(
1271
+ Panel,
1272
+ {
1273
+ title: "Tunnels",
1274
+ focused: focusedPanel === "left",
1275
+ width: leftWidth,
1276
+ height: availableRows,
1277
+ children: /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingTop: 1, children: tunnels.map((tunnel, i) => /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
1278
+ i > 0 && /* @__PURE__ */ jsx(Text, { children: "" }),
1279
+ /* @__PURE__ */ jsx(
1280
+ TunnelCard,
1281
+ {
1282
+ tunnel,
1283
+ showPortPrefix: tunnels.length > 1
1284
+ }
1285
+ )
1286
+ ] }, i)) })
1287
+ }
1288
+ ),
1289
+ /* @__PURE__ */ jsx(
1290
+ Panel,
1291
+ {
1292
+ title: "Traffic",
1293
+ focused: focusedPanel === "right",
1294
+ width: rightWidth,
1295
+ height: availableRows,
1296
+ children: /* @__PURE__ */ jsx(
1297
+ Box,
1298
+ {
1299
+ flexDirection: "column",
1300
+ paddingTop: 1,
1301
+ paddingLeft: 1,
1302
+ overflowY: "hidden",
1303
+ children: traffic.length === 0 ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: "Waiting for requests..." }) : visibleTraffic.map((entry) => /* @__PURE__ */ jsx(TrafficLine, { entry }, entry.seq))
1304
+ }
1305
+ )
1306
+ }
1307
+ )
1308
+ ] }),
1309
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: footer }) })
1310
+ ] });
1311
+ }
1312
+
1313
+ // src/index.ts
1314
+ function runTunnels(entries, rawTtl) {
1315
+ const tunnelTtl = Math.min(rawTtl, PROTOCOL.MAX_TTL_SECONDS);
1316
+ saveSession({
1317
+ tunnels: entries,
1318
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1319
+ });
1320
+ const clients = entries.map(
1321
+ ({ subdomain, port, domain }) => createTunnelClient({
1322
+ subdomain,
1323
+ port,
1324
+ ttl: tunnelTtl,
1325
+ host: "localhost",
1326
+ domain
1327
+ })
1328
+ );
1329
+ const ports = entries.map((e) => e.port);
1330
+ for (const client of clients) {
1331
+ client.connect();
1332
+ }
1333
+ function shutdown() {
1334
+ saveSession({
1335
+ tunnels: entries,
1336
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1337
+ });
1338
+ for (const client of clients) {
1339
+ client.disconnect();
1340
+ }
1341
+ const minutes = PROTOCOL.SESSION_RESUME_WINDOW_SECONDS / 60;
1342
+ console.error(
1343
+ `
1344
+ Session saved. Resume within ${minutes} minutes with: xpose-dev -r
1345
+ `
1346
+ );
1347
+ }
1348
+ const inkApp = render(
1349
+ React2.createElement(App, {
1350
+ clients,
1351
+ ports,
1352
+ onQuit: shutdown
1353
+ })
1354
+ );
1355
+ inkApp.waitUntilExit().then(() => {
1356
+ process.exit(0);
1357
+ });
1358
+ }
1359
+ var main = defineCommand({
1360
+ meta: {
1361
+ name: "xpose-dev",
1362
+ version: "0.0.1",
1363
+ description: "Expose local servers to the internet via Cloudflare"
1364
+ },
1365
+ args: {
1366
+ port: {
1367
+ type: "positional",
1368
+ description: "Local port to expose (optional when using --from-turbo)",
1369
+ required: false
1370
+ },
1371
+ fromTurbo: {
1372
+ type: "boolean",
1373
+ description: "Auto-detect ports from `turbo run <task> --dry=json`"
1374
+ },
1375
+ turboTask: {
1376
+ type: "string",
1377
+ description: "Turborepo task to inspect when using --from-turbo",
1378
+ default: "dev"
1379
+ },
1380
+ turboFilter: {
1381
+ type: "string",
1382
+ description: "Optional Turborepo filter when using --from-turbo"
1383
+ },
1384
+ turboPath: {
1385
+ type: "string",
1386
+ alias: "path",
1387
+ description: "Path to the Turborepo project root when using --from-turbo"
1388
+ },
1389
+ ttl: {
1390
+ type: "string",
1391
+ description: `Tunnel TTL in seconds (default: ${PROTOCOL.DEFAULT_TTL_SECONDS})`,
1392
+ default: String(PROTOCOL.DEFAULT_TTL_SECONDS)
1393
+ },
1394
+ subdomain: {
1395
+ type: "string",
1396
+ description: "Custom subdomain (default: random)"
1397
+ },
1398
+ domain: {
1399
+ type: "string",
1400
+ description: "Public tunnel domain (default: xpose.dev)",
1401
+ default: PROTOCOL.DEFAULT_PUBLIC_DOMAIN
1402
+ },
1403
+ resume: {
1404
+ type: "boolean",
1405
+ alias: "r",
1406
+ description: "Resume the previous tunnel session"
1407
+ }
1408
+ },
1409
+ async run({ args }) {
1410
+ const ttl = parseInt(args.ttl, 10);
1411
+ if (isNaN(ttl) || ttl < 1) {
1412
+ printError("Invalid TTL. Must be a positive number of seconds.");
1413
+ process.exit(1);
1414
+ }
1415
+ if (args.resume) {
1416
+ const manualRawPorts2 = (args._.length > 0 ? args._ : args.port ? [args.port] : []).map(String);
1417
+ if (manualRawPorts2.length > 0 || args.fromTurbo) {
1418
+ printError("Cannot use --resume with port arguments or --from-turbo.");
1419
+ process.exit(1);
1420
+ }
1421
+ const prev = loadSession();
1422
+ if (!prev) {
1423
+ const minutes = PROTOCOL.SESSION_RESUME_WINDOW_SECONDS / 60;
1424
+ printError(
1425
+ `No session to resume (sessions expire after ${minutes} minutes).`
1426
+ );
1427
+ process.exit(1);
1428
+ }
1429
+ return runTunnels(prev.tunnels, ttl);
1430
+ }
1431
+ const manualRawPorts = (args._.length > 0 ? args._ : args.port ? [args.port] : []).map(String);
1432
+ const parsedManualPorts = manualRawPorts.map(
1433
+ (raw) => Number.parseInt(raw, 10)
1434
+ );
1435
+ const invalidManualPort = parsedManualPorts.find(
1436
+ (port) => Number.isNaN(port) || port < 1 || port > 65535
1437
+ );
1438
+ if (invalidManualPort !== void 0) {
1439
+ printError("Invalid port number. Ports must be between 1 and 65535.");
1440
+ process.exit(1);
1441
+ }
1442
+ const ports = new Set(parsedManualPorts);
1443
+ if (args.fromTurbo) {
1444
+ const turboTask = args.turboTask?.trim() || "dev";
1445
+ const turboCwd = args.turboPath ? resolve(process.cwd(), args.turboPath) : process.cwd();
1446
+ if (!existsSync(turboCwd) || !statSync(turboCwd).isDirectory()) {
1447
+ printError(`Invalid --path. Directory does not exist: ${turboCwd}`);
1448
+ process.exit(1);
1449
+ }
1450
+ try {
1451
+ const discovered = await discoverTurboPorts({
1452
+ cwd: turboCwd,
1453
+ task: turboTask,
1454
+ filter: args.turboFilter
1455
+ });
1456
+ if (discovered.length === 0) {
1457
+ printError(`No ports detected from Turborepo task "${turboTask}".`);
1458
+ } else {
1459
+ console.log(
1460
+ ` Discovered from Turborepo (${turboTask}): ${discovered.map((entry) => `${entry.port} [${entry.packageName}]`).join(", ")}`
1461
+ );
1462
+ }
1463
+ for (const entry of discovered) {
1464
+ ports.add(entry.port);
1465
+ }
1466
+ } catch (err) {
1467
+ printError(`Failed to inspect Turborepo: ${err.message}`);
1468
+ process.exit(1);
1469
+ }
1470
+ }
1471
+ if (ports.size === 0) {
1472
+ printError(
1473
+ "No ports provided. Pass ports directly (e.g. `xpose-dev 3000 8787`) or use --from-turbo."
1474
+ );
1475
+ process.exit(1);
1476
+ }
1477
+ const resolvedPorts = [...ports];
1478
+ const baseSubdomain = args.subdomain?.trim();
1479
+ const tunnelDomain = normalizeDomain(
1480
+ args.domain ?? PROTOCOL.DEFAULT_PUBLIC_DOMAIN
1481
+ );
1482
+ if (!tunnelDomain) {
1483
+ printError("Invalid domain. Pass a hostname like xpose.dev.");
1484
+ process.exit(1);
1485
+ }
1486
+ const entries = resolvedPorts.map((port) => {
1487
+ const subdomain = baseSubdomain ? resolvedPorts.length === 1 ? buildCustomSubdomain(baseSubdomain) : buildCustomSubdomain(`${baseSubdomain}-${port}`) : generateSubdomainId();
1488
+ return { subdomain, port, domain: tunnelDomain };
1489
+ });
1490
+ return runTunnels(entries, ttl);
1491
+ }
1492
+ });
1493
+ runMain(main);
1494
+ //# sourceMappingURL=index.js.map