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/README.md +134 -0
- package/dist/index.js +1494 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
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
|