bhole 0.1.0-alpha.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/.turbo/turbo-build.log +4 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +30 -0
- package/dist/commands/http.d.ts +3 -0
- package/dist/commands/http.d.ts.map +1 -0
- package/dist/commands/http.js +95 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +24 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +40 -0
- package/dist/commands/tcp.d.ts +3 -0
- package/dist/commands/tcp.d.ts.map +1 -0
- package/dist/commands/tcp.js +10 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +38 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +35 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/lib/loginPlain.d.ts +2 -0
- package/dist/lib/loginPlain.d.ts.map +1 -0
- package/dist/lib/loginPlain.js +76 -0
- package/dist/lib/tunnelPlain.d.ts +2 -0
- package/dist/lib/tunnelPlain.d.ts.map +1 -0
- package/dist/lib/tunnelPlain.js +31 -0
- package/dist/lib/version.d.ts +4 -0
- package/dist/lib/version.d.ts.map +1 -0
- package/dist/lib/version.js +44 -0
- package/dist/lib/words.d.ts +2 -0
- package/dist/lib/words.d.ts.map +1 -0
- package/dist/lib/words.js +24 -0
- package/dist/tunnel/http.d.ts +23 -0
- package/dist/tunnel/http.d.ts.map +1 -0
- package/dist/tunnel/http.js +267 -0
- package/dist/ui/LoginApp.d.ts +8 -0
- package/dist/ui/LoginApp.d.ts.map +1 -0
- package/dist/ui/LoginApp.js +129 -0
- package/dist/ui/TunnelApp.d.ts +12 -0
- package/dist/ui/TunnelApp.d.ts.map +1 -0
- package/dist/ui/TunnelApp.js +122 -0
- package/package.json +38 -0
- package/src/commands/config.ts +37 -0
- package/src/commands/http.tsx +117 -0
- package/src/commands/tcp.ts +11 -0
- package/src/config.ts +41 -0
- package/src/index.ts +20 -0
- package/src/lib/tunnelPlain.ts +45 -0
- package/src/lib/version.ts +45 -0
- package/src/lib/words.ts +25 -0
- package/src/tunnel/http.ts +315 -0
- package/src/ui/TunnelApp.tsx +252 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
const REGISTER_TIMEOUT_MS = 15000;
|
|
4
|
+
const LOCAL_FORWARD_TIMEOUT_MS = 30000;
|
|
5
|
+
const CONNECT_TIMEOUT_MS = 10000;
|
|
6
|
+
const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024; // 10MB
|
|
7
|
+
const MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024; // 10MB
|
|
8
|
+
export function createHttpTunnel(options, callbacks) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let rejected = false;
|
|
11
|
+
const doReject = (err) => {
|
|
12
|
+
if (rejected)
|
|
13
|
+
return;
|
|
14
|
+
rejected = true;
|
|
15
|
+
callbacks?.onError?.(err);
|
|
16
|
+
reject(err);
|
|
17
|
+
};
|
|
18
|
+
const ws = new WebSocket(options.serverUrl + "/tunnel");
|
|
19
|
+
let registerTimeout = null;
|
|
20
|
+
let connectTimeout = null;
|
|
21
|
+
let ready = false;
|
|
22
|
+
const clearRegisterTimeout = () => {
|
|
23
|
+
if (registerTimeout) {
|
|
24
|
+
clearTimeout(registerTimeout);
|
|
25
|
+
registerTimeout = null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const safeSend = (data, opts) => {
|
|
29
|
+
if (ws.readyState === 1) {
|
|
30
|
+
try {
|
|
31
|
+
if (opts)
|
|
32
|
+
ws.send(data, opts);
|
|
33
|
+
else
|
|
34
|
+
ws.send(data);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (!rejected)
|
|
38
|
+
doReject(err instanceof Error ? err : new Error(String(err)));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
connectTimeout = setTimeout(() => {
|
|
43
|
+
if (ready || rejected)
|
|
44
|
+
return;
|
|
45
|
+
connectTimeout = null;
|
|
46
|
+
ws.terminate();
|
|
47
|
+
doReject(new Error("Connection timed out – server unreachable."));
|
|
48
|
+
}, CONNECT_TIMEOUT_MS);
|
|
49
|
+
ws.on("open", () => {
|
|
50
|
+
if (connectTimeout) {
|
|
51
|
+
clearTimeout(connectTimeout);
|
|
52
|
+
connectTimeout = null;
|
|
53
|
+
}
|
|
54
|
+
callbacks?.onOpen?.();
|
|
55
|
+
const msg = { type: "register", endpoint: options.endpoint };
|
|
56
|
+
if (options.authToken)
|
|
57
|
+
msg.authToken = options.authToken;
|
|
58
|
+
safeSend(JSON.stringify(msg));
|
|
59
|
+
registerTimeout = setTimeout(() => {
|
|
60
|
+
if (!ready) {
|
|
61
|
+
clearRegisterTimeout();
|
|
62
|
+
const err = new Error("Registration timed out – server may be busy. Try again.");
|
|
63
|
+
doReject(err);
|
|
64
|
+
ws.close();
|
|
65
|
+
}
|
|
66
|
+
}, REGISTER_TIMEOUT_MS);
|
|
67
|
+
});
|
|
68
|
+
ws.on("message", (data) => {
|
|
69
|
+
const str = data.toString();
|
|
70
|
+
if (str.startsWith("{")) {
|
|
71
|
+
try {
|
|
72
|
+
const msg = JSON.parse(str);
|
|
73
|
+
if (msg.error) {
|
|
74
|
+
ready = true;
|
|
75
|
+
clearRegisterTimeout();
|
|
76
|
+
const err = new Error(msg.error || "Server error");
|
|
77
|
+
doReject(err);
|
|
78
|
+
ws.close();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (msg.ok) {
|
|
82
|
+
ready = true;
|
|
83
|
+
clearRegisterTimeout();
|
|
84
|
+
callbacks?.onReady?.();
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Not JSON, treat as binary (request from server)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (Buffer.isBuffer(data) || typeof data !== "string") {
|
|
93
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
94
|
+
if (buf.length > MAX_REQUEST_BODY_BYTES) {
|
|
95
|
+
const errResp = `HTTP/1.1 413 Payload Too Large\r\nContent-Type: text/plain\r\n\r\nRequest body exceeds ${MAX_REQUEST_BODY_BYTES / (1024 * 1024)}MB limit`;
|
|
96
|
+
safeSend(Buffer.from(errResp));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const req = parseHttpRequest(buf);
|
|
100
|
+
if (!req) {
|
|
101
|
+
const errResp = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nFailed to parse request";
|
|
102
|
+
safeSend(Buffer.from(errResp));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
forwardToLocal(buf, options.localPort)
|
|
106
|
+
.then((response) => {
|
|
107
|
+
const info = parseResponseStatus(response);
|
|
108
|
+
callbacks?.onRequest?.({
|
|
109
|
+
method: req.method,
|
|
110
|
+
path: req.path,
|
|
111
|
+
bytesIn: buf.length,
|
|
112
|
+
bytesOut: response.length,
|
|
113
|
+
statusCode: info?.statusCode,
|
|
114
|
+
timestamp: new Date(),
|
|
115
|
+
});
|
|
116
|
+
safeSend(response, { binary: true });
|
|
117
|
+
})
|
|
118
|
+
.catch((err) => {
|
|
119
|
+
callbacks?.onRequest?.({
|
|
120
|
+
method: req.method,
|
|
121
|
+
path: req.path,
|
|
122
|
+
bytesIn: buf.length,
|
|
123
|
+
bytesOut: 0,
|
|
124
|
+
timestamp: new Date(),
|
|
125
|
+
});
|
|
126
|
+
const errResp = `HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\n\r\n${err.message}`;
|
|
127
|
+
safeSend(Buffer.from(errResp));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
ws.on("error", (err) => {
|
|
132
|
+
clearRegisterTimeout();
|
|
133
|
+
if (connectTimeout) {
|
|
134
|
+
clearTimeout(connectTimeout);
|
|
135
|
+
connectTimeout = null;
|
|
136
|
+
}
|
|
137
|
+
const msg = err.message || String(err) || "Connection failed";
|
|
138
|
+
doReject(new Error(msg));
|
|
139
|
+
});
|
|
140
|
+
ws.on("close", () => {
|
|
141
|
+
clearRegisterTimeout();
|
|
142
|
+
if (connectTimeout) {
|
|
143
|
+
clearTimeout(connectTimeout);
|
|
144
|
+
connectTimeout = null;
|
|
145
|
+
}
|
|
146
|
+
if (ready && !rejected) {
|
|
147
|
+
doReject(new Error("Connection closed"));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
async function forwardToLocal(rawRequest, localPort) {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const req = parseHttpRequest(rawRequest);
|
|
155
|
+
if (!req) {
|
|
156
|
+
reject(new Error("Failed to parse request"));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const opts = {
|
|
160
|
+
hostname: "localhost",
|
|
161
|
+
port: localPort,
|
|
162
|
+
path: req.path,
|
|
163
|
+
method: req.method,
|
|
164
|
+
headers: req.headers,
|
|
165
|
+
};
|
|
166
|
+
const timeout = setTimeout(() => {
|
|
167
|
+
proxyReq.destroy(new Error("Local server did not respond in time"));
|
|
168
|
+
}, LOCAL_FORWARD_TIMEOUT_MS);
|
|
169
|
+
const proxyReq = http.request(opts, (res) => {
|
|
170
|
+
clearTimeout(timeout);
|
|
171
|
+
const chunks = [];
|
|
172
|
+
let totalBytes = 0;
|
|
173
|
+
let responseExceeded = false;
|
|
174
|
+
const exceededResponse = () => Buffer.from(`HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\n\r\nResponse body exceeds ${MAX_RESPONSE_BODY_BYTES / (1024 * 1024)}MB limit`);
|
|
175
|
+
const finish = (buf) => {
|
|
176
|
+
clearTimeout(timeout);
|
|
177
|
+
resolve(buf);
|
|
178
|
+
};
|
|
179
|
+
res.on("data", (chunk) => {
|
|
180
|
+
if (responseExceeded)
|
|
181
|
+
return;
|
|
182
|
+
if (totalBytes + chunk.length <= MAX_RESPONSE_BODY_BYTES) {
|
|
183
|
+
chunks.push(chunk);
|
|
184
|
+
totalBytes += chunk.length;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
responseExceeded = true;
|
|
188
|
+
res.destroy();
|
|
189
|
+
finish(exceededResponse());
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
res.on("end", () => {
|
|
193
|
+
if (responseExceeded)
|
|
194
|
+
return;
|
|
195
|
+
const statusLine = `HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r\n`;
|
|
196
|
+
const headers = Object.entries(res.headers)
|
|
197
|
+
.filter(([, v]) => v !== undefined)
|
|
198
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}\r\n`)
|
|
199
|
+
.join("");
|
|
200
|
+
const body = Buffer.concat(chunks);
|
|
201
|
+
finish(Buffer.concat([Buffer.from(statusLine + headers + "\r\n"), body]));
|
|
202
|
+
});
|
|
203
|
+
res.on("error", (err) => {
|
|
204
|
+
if (!responseExceeded) {
|
|
205
|
+
clearTimeout(timeout);
|
|
206
|
+
reject(err);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
proxyReq.on("error", (err) => {
|
|
211
|
+
clearTimeout(timeout);
|
|
212
|
+
reject(err);
|
|
213
|
+
});
|
|
214
|
+
proxyReq.end(req.body);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function parseRequestMethod(buf) {
|
|
218
|
+
const firstLine = buf.subarray(0, buf.indexOf("\n")).toString();
|
|
219
|
+
return firstLine.split(" ")[0] ?? "?";
|
|
220
|
+
}
|
|
221
|
+
function parseRequestPath(buf) {
|
|
222
|
+
const firstLine = buf.subarray(0, buf.indexOf("\n")).toString();
|
|
223
|
+
const path = firstLine.split(" ")[1];
|
|
224
|
+
return path ?? "/";
|
|
225
|
+
}
|
|
226
|
+
function parseResponseStatus(buf) {
|
|
227
|
+
const firstLine = buf.subarray(0, buf.indexOf("\n")).toString();
|
|
228
|
+
const parts = firstLine.split(" ");
|
|
229
|
+
const code = parseInt(parts[1] ?? "0", 10);
|
|
230
|
+
return isNaN(code) ? null : { statusCode: code };
|
|
231
|
+
}
|
|
232
|
+
function parseHttpRequest(buf) {
|
|
233
|
+
const idx = buf.indexOf("\r\n\r\n");
|
|
234
|
+
const idxLf = buf.indexOf("\n\n");
|
|
235
|
+
const sepIdx = idx >= 0 ? idx : idxLf >= 0 ? idxLf : -1;
|
|
236
|
+
const sepLen = idx >= 0 ? 4 : idxLf >= 0 ? 2 : 0;
|
|
237
|
+
if (sepIdx === -1)
|
|
238
|
+
return null;
|
|
239
|
+
const headerSection = buf.subarray(0, sepIdx).toString();
|
|
240
|
+
const body = buf.subarray(sepIdx + sepLen);
|
|
241
|
+
const lineSep = idx >= 0 ? "\r\n" : "\n";
|
|
242
|
+
const lines = headerSection.split(lineSep);
|
|
243
|
+
const firstLine = lines[0];
|
|
244
|
+
if (!firstLine)
|
|
245
|
+
return null;
|
|
246
|
+
const parts = firstLine.split(" ");
|
|
247
|
+
const method = parts[0] ?? "GET";
|
|
248
|
+
const path = parts[1] ?? "/";
|
|
249
|
+
const headers = {};
|
|
250
|
+
for (let i = 1; i < lines.length; i++) {
|
|
251
|
+
const line = lines[i];
|
|
252
|
+
if (!line)
|
|
253
|
+
continue;
|
|
254
|
+
const colon = line.indexOf(":");
|
|
255
|
+
if (colon === -1)
|
|
256
|
+
continue;
|
|
257
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
258
|
+
const value = line.slice(colon + 1).trim();
|
|
259
|
+
headers[key] = value;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
method,
|
|
263
|
+
path,
|
|
264
|
+
headers,
|
|
265
|
+
body,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface LoginAppProps {
|
|
2
|
+
siteUrl: string;
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
onExit: (code: number) => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function LoginApp({ siteUrl, apiKey, onExit }: LoginAppProps): import("react/jsx-runtime").JSX.Element | null;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=LoginApp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LoginApp.d.ts","sourceRoot":"","sources":["../../src/ui/LoginApp.tsx"],"names":[],"mappings":"AAgBA,UAAU,aAAa;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,aAAa,kDA4MlE"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Text, Box, useInput } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { loadConfig, saveConfig } from "../config.js";
|
|
6
|
+
const DEFAULT_SITE_URL = process.env.BHOLE_SITE_URL ?? "http://localhost:2465";
|
|
7
|
+
const POLL_INTERVAL_MS = 1500;
|
|
8
|
+
const MAX_WAIT_MS = 10 * 60 * 1000;
|
|
9
|
+
export function LoginApp({ siteUrl, apiKey, onExit }) {
|
|
10
|
+
const [status, setStatus] = useState({ type: "starting" });
|
|
11
|
+
const [retryAfterSignOut, setRetryAfterSignOut] = useState(0);
|
|
12
|
+
useInput((input, key) => {
|
|
13
|
+
if (key.ctrl && input === "c") {
|
|
14
|
+
onExit(1);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (status.type === "alreadyLoggedIn") {
|
|
18
|
+
if (input.toLowerCase() === "s") {
|
|
19
|
+
const config = loadConfig();
|
|
20
|
+
delete config.apiKey;
|
|
21
|
+
saveConfig(config);
|
|
22
|
+
setStatus({ type: "starting" });
|
|
23
|
+
setRetryAfterSignOut((r) => r + 1);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (key.return) {
|
|
27
|
+
onExit(0);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const baseUrl = siteUrl.replace(/\/$/, "");
|
|
33
|
+
const config = loadConfig();
|
|
34
|
+
if (!apiKey?.trim() && config.apiKey && retryAfterSignOut === 0) {
|
|
35
|
+
setStatus({
|
|
36
|
+
type: "alreadyLoggedIn",
|
|
37
|
+
keyMask: "***" + config.apiKey.slice(-4),
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (apiKey?.trim()) {
|
|
42
|
+
const key = apiKey.trim();
|
|
43
|
+
if (!key.startsWith("bh_")) {
|
|
44
|
+
setStatus({ type: "error", message: "Invalid API key format. Keys should start with bh_" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
config.apiKey = key;
|
|
48
|
+
config.siteUrl = siteUrl;
|
|
49
|
+
saveConfig(config);
|
|
50
|
+
setStatus({ type: "success" });
|
|
51
|
+
setTimeout(() => onExit(0), 1500);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let cancelled = false;
|
|
55
|
+
(async () => {
|
|
56
|
+
try {
|
|
57
|
+
const startRes = await fetch(`${baseUrl}/api/cli-auth/start`, { method: "POST" });
|
|
58
|
+
if (cancelled)
|
|
59
|
+
return;
|
|
60
|
+
if (!startRes.ok) {
|
|
61
|
+
setStatus({ type: "error", message: "Failed to start login. Is the dashboard running?" });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const { code } = (await startRes.json());
|
|
65
|
+
if (!code) {
|
|
66
|
+
setStatus({ type: "error", message: "Invalid response from dashboard." });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const cliAuthUrl = `${baseUrl}/cli-auth?code=${code}`;
|
|
70
|
+
let opened = false;
|
|
71
|
+
try {
|
|
72
|
+
const { default: open } = await import("open");
|
|
73
|
+
await open(cliAuthUrl);
|
|
74
|
+
opened = true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Browser didn't open
|
|
78
|
+
}
|
|
79
|
+
setStatus({ type: "ready", url: cliAuthUrl, opened });
|
|
80
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
81
|
+
while (Date.now() < deadline && !cancelled) {
|
|
82
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
83
|
+
const statusRes = await fetch(`${baseUrl}/api/cli-auth/status?code=${encodeURIComponent(code)}`);
|
|
84
|
+
const data = (await statusRes.json());
|
|
85
|
+
if (data.status === "ready" && data.apiKey) {
|
|
86
|
+
const config = loadConfig();
|
|
87
|
+
config.apiKey = data.apiKey;
|
|
88
|
+
config.siteUrl = siteUrl;
|
|
89
|
+
saveConfig(config);
|
|
90
|
+
setStatus({ type: "success" });
|
|
91
|
+
setTimeout(() => onExit(0), 1500);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (data.status === "expired" || data.status === "invalid") {
|
|
95
|
+
setStatus({ type: "error", message: "Login expired or invalid. Run bhole login again." });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!cancelled) {
|
|
100
|
+
setStatus({ type: "error", message: "Login timed out. Run bhole login again." });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
if (!cancelled) {
|
|
105
|
+
setStatus({ type: "error", message: e instanceof Error ? e.message : "Login failed." });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
return () => {
|
|
110
|
+
cancelled = true;
|
|
111
|
+
};
|
|
112
|
+
}, [siteUrl, apiKey, onExit, retryAfterSignOut]);
|
|
113
|
+
if (status.type === "alreadyLoggedIn") {
|
|
114
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "\u2713 You are already signed in" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "dim", children: ["API key: ", status.keyMask] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "dim", children: "Press S to sign out, Enter to continue" }) })] }));
|
|
115
|
+
}
|
|
116
|
+
if (status.type === "starting") {
|
|
117
|
+
return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsx(Box, { children: _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " Creating login code..."] }) }) }));
|
|
118
|
+
}
|
|
119
|
+
if (status.type === "ready") {
|
|
120
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "\u2713 Login link ready" }) }, "title"), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "dim", children: "Open this URL to sign in:" }) }, "label"), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "cyan", children: status.url }) }, "url"), !status.opened && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "(Could not open browser automatically)" }) }, "no-browser")), _jsx(Box, { children: _jsxs(Text, { color: "dim", children: [_jsx(Spinner, { type: "dots" }), " Waiting for sign-in..."] }) })] }));
|
|
121
|
+
}
|
|
122
|
+
if (status.type === "success") {
|
|
123
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "\u2713 API key saved successfully" }) }), _jsx(Box, { children: _jsx(Text, { color: "dim", children: "You can now run: bhole http 3000" }) })] }));
|
|
124
|
+
}
|
|
125
|
+
if (status.type === "error") {
|
|
126
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "red", children: ["\u2717 ", status.message] }) }), _jsx(Box, { children: _jsx(Text, { color: "dim", children: "Press Ctrl+C to exit" }) })] }));
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface TunnelAppProps {
|
|
2
|
+
serverUrl: string;
|
|
3
|
+
endpoint: string;
|
|
4
|
+
localPort: number;
|
|
5
|
+
publicUrl: string;
|
|
6
|
+
tunnelDomain?: string;
|
|
7
|
+
authToken?: string;
|
|
8
|
+
onExit: (code: number) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function TunnelApp({ serverUrl, endpoint, localPort, publicUrl, tunnelDomain, authToken, onExit, }: TunnelAppProps): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=TunnelApp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TunnelApp.d.ts","sourceRoot":"","sources":["../../src/ui/TunnelApp.tsx"],"names":[],"mappings":"AASA,UAAU,cAAc;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAoBD,wBAAgB,SAAS,CAAC,EACxB,SAAS,EACT,QAAQ,EACR,SAAS,EACT,SAAS,EACT,YAA0B,EAC1B,SAAS,EACT,MAAM,GACP,EAAE,cAAc,2CA8MhB"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState, useRef } from "react";
|
|
3
|
+
import { Text, Box, useInput } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { createHttpTunnel } from "../tunnel/http.js";
|
|
6
|
+
import { getLatestVersion, shouldSuggestUpdate } from "../lib/version.js";
|
|
7
|
+
function formatBytes(n) {
|
|
8
|
+
if (n < 1024)
|
|
9
|
+
return `${n} B`;
|
|
10
|
+
if (n < 1024 * 1024)
|
|
11
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
12
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
13
|
+
}
|
|
14
|
+
function regionDisplay(region) {
|
|
15
|
+
const names = {
|
|
16
|
+
ord: "Chicago",
|
|
17
|
+
iad: "Virginia",
|
|
18
|
+
lax: "Los Angeles",
|
|
19
|
+
gru: "São Paulo",
|
|
20
|
+
arn: "Stockholm",
|
|
21
|
+
local: "Local",
|
|
22
|
+
};
|
|
23
|
+
return names[region] ?? region;
|
|
24
|
+
}
|
|
25
|
+
export function TunnelApp({ serverUrl, endpoint, localPort, publicUrl, tunnelDomain = "localhost", authToken, onExit, }) {
|
|
26
|
+
const [status, setStatus] = useState("connecting");
|
|
27
|
+
const [error, setError] = useState(null);
|
|
28
|
+
const [requests, setRequests] = useState(0);
|
|
29
|
+
const [bytesIn, setBytesIn] = useState(0);
|
|
30
|
+
const [bytesOut, setBytesOut] = useState(0);
|
|
31
|
+
const [recentRequests, setRecentRequests] = useState([]);
|
|
32
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
33
|
+
const [serviceInfo, setServiceInfo] = useState(null);
|
|
34
|
+
const [latency, setLatency] = useState(null);
|
|
35
|
+
const [updateAvailable, setUpdateAvailable] = useState(null);
|
|
36
|
+
const tunnelStarted = useRef(false);
|
|
37
|
+
const retriesUsed = useRef(0);
|
|
38
|
+
const maxRetries = 2;
|
|
39
|
+
const baseDelayMs = 2000;
|
|
40
|
+
useInput((input, key) => {
|
|
41
|
+
if (key.ctrl && input === "c") {
|
|
42
|
+
onExit(0);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (tunnelStarted.current)
|
|
47
|
+
return;
|
|
48
|
+
tunnelStarted.current = true;
|
|
49
|
+
const wsUrl = serverUrl.replace(/^http/, "ws");
|
|
50
|
+
const tryConnect = () => {
|
|
51
|
+
setStatus("connecting");
|
|
52
|
+
setError(null);
|
|
53
|
+
if (retriesUsed.current > 0) {
|
|
54
|
+
setRetryCount(retriesUsed.current);
|
|
55
|
+
}
|
|
56
|
+
createHttpTunnel({ serverUrl: wsUrl, endpoint, localPort, authToken }, {
|
|
57
|
+
onOpen: () => setStatus("registering"),
|
|
58
|
+
onReady: () => setStatus("ready"),
|
|
59
|
+
onRequest: (info) => {
|
|
60
|
+
setRequests((n) => n + 1);
|
|
61
|
+
setBytesIn((b) => b + info.bytesIn);
|
|
62
|
+
setBytesOut((b) => b + info.bytesOut);
|
|
63
|
+
setRecentRequests((prev) => [info, ...prev.slice(0, 4)]);
|
|
64
|
+
},
|
|
65
|
+
onError: (err) => {
|
|
66
|
+
setError(err.message);
|
|
67
|
+
setStatus("error");
|
|
68
|
+
if (retriesUsed.current < maxRetries) {
|
|
69
|
+
const attempt = retriesUsed.current + 1;
|
|
70
|
+
retriesUsed.current = attempt;
|
|
71
|
+
const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
|
|
72
|
+
setTimeout(() => tryConnect(), delayMs);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}).catch(() => {
|
|
76
|
+
if (retriesUsed.current < maxRetries) {
|
|
77
|
+
const attempt = retriesUsed.current + 1;
|
|
78
|
+
retriesUsed.current = attempt;
|
|
79
|
+
const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
|
|
80
|
+
setTimeout(() => tryConnect(), delayMs);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
tryConnect();
|
|
85
|
+
}, [serverUrl, endpoint, localPort]);
|
|
86
|
+
// Fetch account, service info, and measure latency when ready
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (status !== "ready")
|
|
89
|
+
return;
|
|
90
|
+
const host = new URL(serverUrl.replace(/^ws/, "http")).host;
|
|
91
|
+
const apiBase = serverUrl.includes("localhost")
|
|
92
|
+
? "http://localhost:8080"
|
|
93
|
+
: `https://${host}`;
|
|
94
|
+
const measureLatency = () => {
|
|
95
|
+
const start = performance.now();
|
|
96
|
+
fetch(`${apiBase}/api/health`, { cache: "no-store" })
|
|
97
|
+
.then((r) => r.json())
|
|
98
|
+
.then((data) => {
|
|
99
|
+
const total = Math.round(performance.now() - start);
|
|
100
|
+
setServiceInfo({ region: data.region ?? "?", version: data.version ?? "?" });
|
|
101
|
+
setLatency({ up: Math.round(total / 2), down: Math.round(total / 2) });
|
|
102
|
+
})
|
|
103
|
+
.catch(() => { });
|
|
104
|
+
};
|
|
105
|
+
measureLatency();
|
|
106
|
+
const latId = setInterval(measureLatency, 10000);
|
|
107
|
+
getLatestVersion()
|
|
108
|
+
.then((latest) => {
|
|
109
|
+
if (shouldSuggestUpdate(latest))
|
|
110
|
+
setUpdateAvailable(latest);
|
|
111
|
+
})
|
|
112
|
+
.catch(() => { });
|
|
113
|
+
return () => clearInterval(latId);
|
|
114
|
+
}, [status, serverUrl, tunnelDomain]);
|
|
115
|
+
if (status === "connecting" || status === "registering") {
|
|
116
|
+
return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsx(Box, { children: _jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " ", status === "connecting" ? "Connecting" : "Registering tunnel", "...", retryCount > 0 ? (_jsxs(Text, { color: "dim", children: [" (retry ", retryCount, "/", maxRetries, ")"] })) : null] }) }) }));
|
|
117
|
+
}
|
|
118
|
+
if (status === "error") {
|
|
119
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "red", children: ["\u2717 Tunnel error: ", error] }) }), _jsx(Box, { children: _jsx(Text, { color: "dim", children: "Press Ctrl+C to exit" }) })] }));
|
|
120
|
+
}
|
|
121
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "dim", children: "Status: " }), _jsx(Text, { bold: true, color: "green", children: "Tunnel active" })] }), serviceInfo && (_jsxs(Box, { children: [_jsx(Text, { color: "dim", children: "Region: " }), _jsx(Text, { color: "cyan", children: regionDisplay(serviceInfo.region) })] })), latency && (_jsxs(Box, { children: [_jsx(Text, { color: "dim", children: "Latency: " }), _jsxs(Text, { color: "cyan", children: [latency.up, "ms"] }), _jsx(Text, { color: "dim", children: " \u2191 " }), _jsxs(Text, { color: "cyan", children: [latency.down, "ms"] }), _jsx(Text, { color: "dim", children: " \u2193" })] })), _jsxs(Box, { children: [_jsx(Text, { color: "dim", children: "Forwarding: " }), _jsx(Text, { color: "cyan", children: publicUrl }), _jsx(Text, { color: "dim", children: " \u2192 " }), _jsxs(Text, { color: "green", children: ["localhost:", localPort] })] }), _jsxs(Box, { children: [_jsx(Text, { color: "dim", children: "Usage: " }), _jsxs(Text, { color: "cyan", children: ["\u2191", formatBytes(bytesIn)] }), _jsx(Text, { color: "dim", children: " " }), _jsxs(Text, { color: "cyan", children: ["\u2193", formatBytes(bytesOut)] })] }), recentRequests.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "dim", children: "Requests " }), _jsx(Text, { color: "cyan", children: requests })] }), recentRequests.slice(0, 5).map((r, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: "dim", children: ["[", r.timestamp.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }), "]"] }), _jsx(Text, { color: "dim", children: " " }), _jsx(Text, { color: "cyan", children: r.method }), _jsx(Text, { color: "dim", children: " " }), r.statusCode != null && (_jsxs(_Fragment, { children: [_jsx(Text, { color: r.statusCode >= 400 ? "red" : "green", children: r.statusCode }), _jsx(Text, { color: "dim", children: " " })] })), _jsx(Text, { color: "yellow", children: r.path })] }, i)))] })), updateAvailable && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "Update available: " }), _jsx(Text, { color: "cyan", children: updateAvailable }), _jsx(Text, { color: "dim", children: " \u2014 npm i -g bhole" })] })), _jsx(Box, { children: _jsx(Text, { color: "dim", children: "Press Ctrl+C to stop" }) })] }));
|
|
122
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bhole",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Self-hostable ngrok alternative - expose local services to the internet",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bhole": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"lint": "eslint src/",
|
|
13
|
+
"clean": "rm -rf dist",
|
|
14
|
+
"prepublishOnly": "pnpm run build",
|
|
15
|
+
"prerelease": "npm publish --tag alpha",
|
|
16
|
+
"release": "npm publish"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"commander": "^14.0.3",
|
|
20
|
+
"ink": "^6.7.0",
|
|
21
|
+
"ink-spinner": "^5.0.0",
|
|
22
|
+
"nanoid": "^5.1.6",
|
|
23
|
+
"open": "^11.0.0",
|
|
24
|
+
"react": "^19.2.4",
|
|
25
|
+
"ws": "^8.19.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@blackhole/config-typescript": "workspace:*",
|
|
29
|
+
"@types/node": "^25.2.3",
|
|
30
|
+
"@types/react": "^19.2.14",
|
|
31
|
+
"@types/ws": "^8.18.1",
|
|
32
|
+
"tsx": "^4.21.0",
|
|
33
|
+
"typescript": "^5.9.3"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { loadConfig, saveConfig, getConfigDir } from "../config.js";
|
|
3
|
+
|
|
4
|
+
export const configCommand = new Command("config")
|
|
5
|
+
.description("Manage CLI configuration")
|
|
6
|
+
.addCommand(
|
|
7
|
+
new Command("path")
|
|
8
|
+
.description("Show config file path")
|
|
9
|
+
.action(() => {
|
|
10
|
+
console.log(getConfigDir());
|
|
11
|
+
})
|
|
12
|
+
)
|
|
13
|
+
.addCommand(
|
|
14
|
+
new Command("list")
|
|
15
|
+
.description("Show current config (key hidden)")
|
|
16
|
+
.action(() => {
|
|
17
|
+
const config = loadConfig();
|
|
18
|
+
console.log("Config location:", getConfigDir());
|
|
19
|
+
console.log("Tunnel domain:", config.tunnelDomain ?? "(not set — use config set-tunnel-domain)");
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
.addCommand(
|
|
23
|
+
new Command("set-tunnel-domain")
|
|
24
|
+
.description("Set the public domain for tunnel URLs (e.g. tunnel.yourdomain.com)")
|
|
25
|
+
.argument("<domain>", "Domain for tunnels (e.g. bhole.link)")
|
|
26
|
+
.action((domain: string) => {
|
|
27
|
+
const trimmed = domain.trim().toLowerCase().replace(/^https?:\/\//, "").split("/")[0];
|
|
28
|
+
if (!trimmed) {
|
|
29
|
+
console.error("Invalid domain");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
const config = loadConfig();
|
|
33
|
+
config.tunnelDomain = trimmed;
|
|
34
|
+
saveConfig(config);
|
|
35
|
+
console.log(`Tunnel domain set to ${trimmed}. Tunnels will be at https://{endpoint}.${trimmed}`);
|
|
36
|
+
})
|
|
37
|
+
);
|