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,117 @@
|
|
|
1
|
+
import net from "net";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { runTunnelPlain } from "../lib/tunnelPlain.js";
|
|
5
|
+
import { randomMnemonicId } from "../lib/words.js";
|
|
6
|
+
|
|
7
|
+
const authToken = process.env.BHOLE_AUTH_TOKEN;
|
|
8
|
+
|
|
9
|
+
function isPortListening(port: number): Promise<boolean> {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const socket = new net.Socket();
|
|
12
|
+
const timer = setTimeout(() => {
|
|
13
|
+
socket.destroy();
|
|
14
|
+
resolve(false);
|
|
15
|
+
}, 500);
|
|
16
|
+
socket.on("connect", () => {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
socket.destroy();
|
|
19
|
+
resolve(true);
|
|
20
|
+
});
|
|
21
|
+
socket.on("error", () => {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
resolve(false);
|
|
24
|
+
});
|
|
25
|
+
socket.connect(port, "127.0.0.1");
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function runTunnel(
|
|
30
|
+
serverUrl: string,
|
|
31
|
+
endpoint: string,
|
|
32
|
+
localPort: number,
|
|
33
|
+
publicUrl: string,
|
|
34
|
+
tunnelDomain?: string
|
|
35
|
+
) {
|
|
36
|
+
if (process.stdin.isTTY) {
|
|
37
|
+
const { render } = await import("ink");
|
|
38
|
+
const { TunnelApp } = await import("../ui/TunnelApp.js");
|
|
39
|
+
|
|
40
|
+
const { waitUntilExit } = render(
|
|
41
|
+
<TunnelApp
|
|
42
|
+
serverUrl={serverUrl}
|
|
43
|
+
endpoint={endpoint}
|
|
44
|
+
localPort={localPort}
|
|
45
|
+
publicUrl={publicUrl}
|
|
46
|
+
tunnelDomain={tunnelDomain}
|
|
47
|
+
authToken={authToken}
|
|
48
|
+
onExit={(code) => process.exit(code)}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
await waitUntilExit();
|
|
53
|
+
} else {
|
|
54
|
+
await runTunnelPlain(serverUrl, endpoint, localPort, publicUrl, authToken);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const httpCommand = new Command("http")
|
|
59
|
+
.description("Expose a local HTTP server to the internet")
|
|
60
|
+
.argument("<port>", "Local port to expose")
|
|
61
|
+
.option("-s, --server <url>", "Tunnel server URL (default: ws://localhost:8080)", "ws://localhost:8080")
|
|
62
|
+
.option("-d, --domain <domain>", "Static subdomain (e.g. --domain=myapp)")
|
|
63
|
+
.option("--subdomain <name>", "Custom subdomain (alias for --domain)")
|
|
64
|
+
.action(async (portStr: string, options: { server: string; domain?: string; subdomain?: string }) => {
|
|
65
|
+
const port = parseInt(portStr, 10);
|
|
66
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
67
|
+
console.error("Error: port must be a valid number between 1 and 65535");
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const config = loadConfig();
|
|
72
|
+
const tunnelDomain = config.tunnelDomain ?? process.env.BHOLE_TUNNEL_DOMAIN;
|
|
73
|
+
|
|
74
|
+
let serverUrl = (options.server || process.env.BHOLE_SERVER_URL || "ws://localhost:8080").replace(/^http/, "ws");
|
|
75
|
+
if (!serverUrl.startsWith("ws")) serverUrl = "ws://" + serverUrl;
|
|
76
|
+
|
|
77
|
+
if (!tunnelDomain && !serverUrl.includes("localhost")) {
|
|
78
|
+
console.error("Error: Set tunnel domain with 'bhole config set-tunnel-domain <domain>' or BHOLE_TUNNEL_DOMAIN");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const effectiveDomain = tunnelDomain ?? "localhost";
|
|
82
|
+
|
|
83
|
+
const domainOrSub = options.domain ?? options.subdomain;
|
|
84
|
+
let endpoint: string;
|
|
85
|
+
let publicUrl: string;
|
|
86
|
+
if (domainOrSub) {
|
|
87
|
+
let hostname = domainOrSub.trim();
|
|
88
|
+
if (hostname.startsWith("http://") || hostname.startsWith("https://")) {
|
|
89
|
+
try {
|
|
90
|
+
hostname = new URL(hostname).hostname;
|
|
91
|
+
} catch {
|
|
92
|
+
hostname = domainOrSub;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (hostname.includes(".")) {
|
|
96
|
+
endpoint = hostname.split(".")[0] ?? hostname;
|
|
97
|
+
publicUrl = domainOrSub.startsWith("http") ? domainOrSub : `https://${hostname}`;
|
|
98
|
+
} else {
|
|
99
|
+
endpoint = hostname;
|
|
100
|
+
publicUrl = `https://${endpoint}.${effectiveDomain}`;
|
|
101
|
+
}
|
|
102
|
+
if (!endpoint || endpoint.length > 63) {
|
|
103
|
+
console.error("Error: endpoint must be 1–63 characters");
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
endpoint = randomMnemonicId();
|
|
108
|
+
publicUrl = `https://${endpoint}.${effectiveDomain}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const portOk = await isPortListening(port);
|
|
112
|
+
if (!portOk) {
|
|
113
|
+
console.warn(`Note: Nothing appears to be listening on localhost:${port}. Requests will return 502 until your server is running.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await runTunnel(serverUrl, endpoint, port, publicUrl, effectiveDomain);
|
|
117
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
export const tcpCommand = new Command("tcp")
|
|
4
|
+
.description("Expose a local TCP port to the internet (coming soon)")
|
|
5
|
+
.argument("<port>", "Local port to expose")
|
|
6
|
+
.option("-s, --server <url>", "Blackhole server URL", "ws://localhost:8081")
|
|
7
|
+
.option("--subdomain <name>", "Custom subdomain for the tunnel")
|
|
8
|
+
.action((_portStr: string) => {
|
|
9
|
+
console.log("TCP tunneling is not yet implemented. Use 'bhole http <port>' for HTTP.");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(homedir(), ".blackhole");
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
7
|
+
|
|
8
|
+
export interface Config {
|
|
9
|
+
/** Public domain for tunnel URLs. Tunnels are reachable at https://{endpoint}.{tunnelDomain} */
|
|
10
|
+
tunnelDomain?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getConfigDir(): string {
|
|
14
|
+
return CONFIG_DIR;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadConfig(): Config {
|
|
18
|
+
try {
|
|
19
|
+
const data = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
20
|
+
return JSON.parse(data) as Config;
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveConfig(config: Config): void {
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
29
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
30
|
+
}
|
|
31
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
32
|
+
try {
|
|
33
|
+
fs.chmodSync(CONFIG_DIR, 0o700);
|
|
34
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
35
|
+
} catch {
|
|
36
|
+
// chmod may no-op on some platforms (e.g. Windows); ignore
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
throw new Error(`Failed to save config: ${err}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { configCommand } from "./commands/config.js";
|
|
5
|
+
import { httpCommand } from "./commands/http.js";
|
|
6
|
+
import { tcpCommand } from "./commands/tcp.js";
|
|
7
|
+
import { CLI_VERSION } from "./lib/version.js";
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name("bhole")
|
|
13
|
+
.description("Expose local services to the internet - tunnel localhost with one command")
|
|
14
|
+
.version(CLI_VERSION);
|
|
15
|
+
|
|
16
|
+
program.addCommand(configCommand);
|
|
17
|
+
program.addCommand(httpCommand);
|
|
18
|
+
program.addCommand(tcpCommand);
|
|
19
|
+
|
|
20
|
+
program.parse();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHttpTunnel } from "../tunnel/http.js";
|
|
2
|
+
|
|
3
|
+
const MAX_RETRIES = 2;
|
|
4
|
+
const BASE_DELAY_MS = 2000;
|
|
5
|
+
|
|
6
|
+
export async function runTunnelPlain(
|
|
7
|
+
serverUrl: string,
|
|
8
|
+
endpoint: string,
|
|
9
|
+
localPort: number,
|
|
10
|
+
publicUrl: string,
|
|
11
|
+
authToken?: string
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const wsUrl = serverUrl.replace(/^http/, "ws");
|
|
14
|
+
|
|
15
|
+
console.log(`Connecting to ${wsUrl}...`);
|
|
16
|
+
console.log(`Exposing localhost:${localPort} at ${publicUrl}`);
|
|
17
|
+
|
|
18
|
+
const tryConnect = (attempt: number): Promise<void> =>
|
|
19
|
+
new Promise((resolve, reject) => {
|
|
20
|
+
createHttpTunnel(
|
|
21
|
+
{ serverUrl: wsUrl, endpoint, localPort, authToken },
|
|
22
|
+
{
|
|
23
|
+
onReady: () => {
|
|
24
|
+
console.log("Tunnel established. Forwarding traffic to localhost:" + localPort);
|
|
25
|
+
console.log(`URL: ${publicUrl}`);
|
|
26
|
+
console.log(`(or use X-Blackhole-Endpoint: ${endpoint})`);
|
|
27
|
+
resolve();
|
|
28
|
+
},
|
|
29
|
+
onError: (err) => {
|
|
30
|
+
console.error("Tunnel error:", err.message);
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
).catch((err) => {
|
|
34
|
+
if (attempt < MAX_RETRIES) {
|
|
35
|
+
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
|
|
36
|
+
console.error(`Connection failed. Retrying in ${delayMs / 1000}s...`);
|
|
37
|
+
setTimeout(() => tryConnect(attempt + 1).then(resolve, reject), delayMs);
|
|
38
|
+
} else {
|
|
39
|
+
reject(err);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
tryConnect(0).catch(() => process.exit(1));
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const pkg = require(path.join(__dirname, "../../package.json")) as { version: string };
|
|
8
|
+
|
|
9
|
+
export const CLI_VERSION = pkg.version;
|
|
10
|
+
|
|
11
|
+
/** Compare semver strings. Returns true if a > b. */
|
|
12
|
+
function isNewer(a: string, b: string): boolean {
|
|
13
|
+
const parse = (s: string) =>
|
|
14
|
+
s
|
|
15
|
+
.replace(/[-].*$/, "")
|
|
16
|
+
.split(".")
|
|
17
|
+
.map((n) => parseInt(n, 10) || 0);
|
|
18
|
+
const pa = parse(a);
|
|
19
|
+
const pb = parse(b);
|
|
20
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
21
|
+
const va = pa[i] ?? 0;
|
|
22
|
+
const vb = pb[i] ?? 0;
|
|
23
|
+
if (va > vb) return true;
|
|
24
|
+
if (va < vb) return false;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function getLatestVersion(): Promise<string | null> {
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch("https://registry.npmjs.org/bhole/latest", {
|
|
32
|
+
signal: AbortSignal.timeout(3000),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) return null;
|
|
35
|
+
const data = (await res.json()) as { version?: string };
|
|
36
|
+
return data.version ?? null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function shouldSuggestUpdate(latest: string | null): boolean {
|
|
43
|
+
if (!latest) return false;
|
|
44
|
+
return isNewer(latest, CLI_VERSION);
|
|
45
|
+
}
|
package/src/lib/words.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mnemonic word list for ngrok-style random subdomains (e.g. happy-blue-frog).
|
|
3
|
+
* Short, memorable, URL-safe words.
|
|
4
|
+
*/
|
|
5
|
+
const WORDS = [
|
|
6
|
+
"angel", "apple", "arrow", "beach", "bear", "bird", "blue", "boat", "bold",
|
|
7
|
+
"bone", "book", "boss", "bush", "cake", "cash", "cat", "cave", "cold",
|
|
8
|
+
"cord", "crab", "cube", "dark", "demo", "dice", "dock", "door", "dove",
|
|
9
|
+
"dusk", "edge", "echo", "elm", "fall", "fern", "fire", "fish", "flag",
|
|
10
|
+
"flat", "fog", "fork", "frog", "gold", "gray", "grey", "grid", "gulf",
|
|
11
|
+
"harp", "hill", "hope", "iris", "iron", "java", "jazz", "key", "kite",
|
|
12
|
+
"lake", "lamp", "lane", "leaf", "lime", "lion", "log", "map", "mask",
|
|
13
|
+
"mint", "mist", "moon", "nova", "oak", "ocean", "olive", "opal", "pine",
|
|
14
|
+
"pink", "pool", "port", "rain", "reed", "rock", "rose", "rust", "sand",
|
|
15
|
+
"seed", "sky", "snow", "star", "sun", "surf", "swan", "tide", "tree",
|
|
16
|
+
"vine", "wave", "wolf", "wood", "zen",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function randomMnemonicId(): string {
|
|
20
|
+
const picks: string[] = [];
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
picks.push(WORDS[Math.floor(Math.random() * WORDS.length)]!);
|
|
23
|
+
}
|
|
24
|
+
return picks.join("-");
|
|
25
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
|
|
4
|
+
export interface HttpTunnelOptions {
|
|
5
|
+
serverUrl: string;
|
|
6
|
+
endpoint: string;
|
|
7
|
+
localPort: number;
|
|
8
|
+
/** Shared secret for auth (from BHOLE_AUTH_TOKEN) */
|
|
9
|
+
authToken?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TunnelRequestInfo {
|
|
13
|
+
method: string;
|
|
14
|
+
path: string;
|
|
15
|
+
bytesIn: number;
|
|
16
|
+
bytesOut: number;
|
|
17
|
+
statusCode?: number;
|
|
18
|
+
timestamp: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HttpTunnelCallbacks {
|
|
22
|
+
onOpen?: () => void;
|
|
23
|
+
onReady?: () => void;
|
|
24
|
+
onRequest?: (info: TunnelRequestInfo) => void;
|
|
25
|
+
onError?: (err: Error) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const REGISTER_TIMEOUT_MS = 15000;
|
|
29
|
+
const LOCAL_FORWARD_TIMEOUT_MS = 30000;
|
|
30
|
+
const CONNECT_TIMEOUT_MS = 10000;
|
|
31
|
+
const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024; // 10MB
|
|
32
|
+
const MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024; // 10MB
|
|
33
|
+
|
|
34
|
+
export function createHttpTunnel(
|
|
35
|
+
options: HttpTunnelOptions,
|
|
36
|
+
callbacks?: HttpTunnelCallbacks
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
let rejected = false;
|
|
40
|
+
const doReject = (err: Error) => {
|
|
41
|
+
if (rejected) return;
|
|
42
|
+
rejected = true;
|
|
43
|
+
callbacks?.onError?.(err);
|
|
44
|
+
reject(err);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ws = new WebSocket(options.serverUrl + "/tunnel");
|
|
48
|
+
let registerTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
49
|
+
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
50
|
+
let ready = false;
|
|
51
|
+
|
|
52
|
+
const clearRegisterTimeout = () => {
|
|
53
|
+
if (registerTimeout) {
|
|
54
|
+
clearTimeout(registerTimeout);
|
|
55
|
+
registerTimeout = null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const safeSend = (data: Buffer | string, opts?: { binary?: boolean }) => {
|
|
60
|
+
if (ws.readyState === 1) {
|
|
61
|
+
try {
|
|
62
|
+
if (opts) ws.send(data, opts);
|
|
63
|
+
else ws.send(data);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!rejected) doReject(err instanceof Error ? err : new Error(String(err)));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
connectTimeout = setTimeout(() => {
|
|
71
|
+
if (ready || rejected) return;
|
|
72
|
+
connectTimeout = null;
|
|
73
|
+
ws.terminate();
|
|
74
|
+
doReject(new Error("Connection timed out – server unreachable."));
|
|
75
|
+
}, CONNECT_TIMEOUT_MS);
|
|
76
|
+
|
|
77
|
+
ws.on("open", () => {
|
|
78
|
+
if (connectTimeout) {
|
|
79
|
+
clearTimeout(connectTimeout);
|
|
80
|
+
connectTimeout = null;
|
|
81
|
+
}
|
|
82
|
+
callbacks?.onOpen?.();
|
|
83
|
+
const msg: Record<string, string> = { type: "register", endpoint: options.endpoint };
|
|
84
|
+
if (options.authToken) msg.authToken = options.authToken;
|
|
85
|
+
safeSend(JSON.stringify(msg));
|
|
86
|
+
registerTimeout = setTimeout(() => {
|
|
87
|
+
if (!ready) {
|
|
88
|
+
clearRegisterTimeout();
|
|
89
|
+
const err = new Error("Registration timed out – server may be busy. Try again.");
|
|
90
|
+
doReject(err);
|
|
91
|
+
ws.close();
|
|
92
|
+
}
|
|
93
|
+
}, REGISTER_TIMEOUT_MS);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ws.on("message", (data: Buffer | string) => {
|
|
97
|
+
const str = data.toString();
|
|
98
|
+
if (str.startsWith("{")) {
|
|
99
|
+
try {
|
|
100
|
+
const msg = JSON.parse(str);
|
|
101
|
+
if (msg.error) {
|
|
102
|
+
ready = true;
|
|
103
|
+
clearRegisterTimeout();
|
|
104
|
+
const err = new Error(msg.error || "Server error");
|
|
105
|
+
doReject(err);
|
|
106
|
+
ws.close();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (msg.ok) {
|
|
110
|
+
ready = true;
|
|
111
|
+
clearRegisterTimeout();
|
|
112
|
+
callbacks?.onReady?.();
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
} catch {
|
|
116
|
+
// Not JSON, treat as binary (request from server)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Buffer.isBuffer(data) || typeof data !== "string") {
|
|
121
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
|
|
122
|
+
if (buf.length > MAX_REQUEST_BODY_BYTES) {
|
|
123
|
+
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`;
|
|
124
|
+
safeSend(Buffer.from(errResp));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const req = parseHttpRequest(buf);
|
|
128
|
+
if (!req) {
|
|
129
|
+
const errResp = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nFailed to parse request";
|
|
130
|
+
safeSend(Buffer.from(errResp));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
forwardToLocal(buf, options.localPort)
|
|
134
|
+
.then((response) => {
|
|
135
|
+
const info = parseResponseStatus(response);
|
|
136
|
+
callbacks?.onRequest?.({
|
|
137
|
+
method: req.method,
|
|
138
|
+
path: req.path,
|
|
139
|
+
bytesIn: buf.length,
|
|
140
|
+
bytesOut: response.length,
|
|
141
|
+
statusCode: info?.statusCode,
|
|
142
|
+
timestamp: new Date(),
|
|
143
|
+
});
|
|
144
|
+
safeSend(response, { binary: true });
|
|
145
|
+
})
|
|
146
|
+
.catch((err: Error) => {
|
|
147
|
+
callbacks?.onRequest?.({
|
|
148
|
+
method: req.method,
|
|
149
|
+
path: req.path,
|
|
150
|
+
bytesIn: buf.length,
|
|
151
|
+
bytesOut: 0,
|
|
152
|
+
timestamp: new Date(),
|
|
153
|
+
});
|
|
154
|
+
const errResp = `HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\n\r\n${err.message}`;
|
|
155
|
+
safeSend(Buffer.from(errResp));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
ws.on("error", (err) => {
|
|
161
|
+
clearRegisterTimeout();
|
|
162
|
+
if (connectTimeout) {
|
|
163
|
+
clearTimeout(connectTimeout);
|
|
164
|
+
connectTimeout = null;
|
|
165
|
+
}
|
|
166
|
+
const msg = err.message || String(err) || "Connection failed";
|
|
167
|
+
doReject(new Error(msg));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
ws.on("close", () => {
|
|
171
|
+
clearRegisterTimeout();
|
|
172
|
+
if (connectTimeout) {
|
|
173
|
+
clearTimeout(connectTimeout);
|
|
174
|
+
connectTimeout = null;
|
|
175
|
+
}
|
|
176
|
+
if (ready && !rejected) {
|
|
177
|
+
doReject(new Error("Connection closed"));
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function forwardToLocal(
|
|
184
|
+
rawRequest: Buffer,
|
|
185
|
+
localPort: number
|
|
186
|
+
): Promise<Buffer> {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const req = parseHttpRequest(rawRequest);
|
|
189
|
+
if (!req) {
|
|
190
|
+
reject(new Error("Failed to parse request"));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const opts = {
|
|
195
|
+
hostname: "localhost",
|
|
196
|
+
port: localPort,
|
|
197
|
+
path: req.path,
|
|
198
|
+
method: req.method,
|
|
199
|
+
headers: req.headers,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const timeout = setTimeout(() => {
|
|
203
|
+
proxyReq.destroy(new Error("Local server did not respond in time"));
|
|
204
|
+
}, LOCAL_FORWARD_TIMEOUT_MS);
|
|
205
|
+
|
|
206
|
+
const proxyReq = http.request(opts, (res: http.IncomingMessage) => {
|
|
207
|
+
clearTimeout(timeout);
|
|
208
|
+
const chunks: Buffer[] = [];
|
|
209
|
+
let totalBytes = 0;
|
|
210
|
+
let responseExceeded = false;
|
|
211
|
+
const exceededResponse = () =>
|
|
212
|
+
Buffer.from(
|
|
213
|
+
`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`
|
|
214
|
+
);
|
|
215
|
+
const finish = (buf: Buffer) => {
|
|
216
|
+
clearTimeout(timeout);
|
|
217
|
+
resolve(buf);
|
|
218
|
+
};
|
|
219
|
+
res.on("data", (chunk: Buffer) => {
|
|
220
|
+
if (responseExceeded) return;
|
|
221
|
+
if (totalBytes + chunk.length <= MAX_RESPONSE_BODY_BYTES) {
|
|
222
|
+
chunks.push(chunk);
|
|
223
|
+
totalBytes += chunk.length;
|
|
224
|
+
} else {
|
|
225
|
+
responseExceeded = true;
|
|
226
|
+
res.destroy();
|
|
227
|
+
finish(exceededResponse());
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
res.on("end", () => {
|
|
231
|
+
if (responseExceeded) return;
|
|
232
|
+
const statusLine = `HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r\n`;
|
|
233
|
+
const headers = Object.entries(res.headers)
|
|
234
|
+
.filter(([, v]) => v !== undefined)
|
|
235
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}\r\n`)
|
|
236
|
+
.join("");
|
|
237
|
+
const body = Buffer.concat(chunks);
|
|
238
|
+
finish(Buffer.concat([Buffer.from(statusLine + headers + "\r\n"), body]));
|
|
239
|
+
});
|
|
240
|
+
res.on("error", (err) => {
|
|
241
|
+
if (!responseExceeded) {
|
|
242
|
+
clearTimeout(timeout);
|
|
243
|
+
reject(err);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
proxyReq.on("error", (err) => {
|
|
249
|
+
clearTimeout(timeout);
|
|
250
|
+
reject(err);
|
|
251
|
+
});
|
|
252
|
+
proxyReq.end(req.body);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface ParsedRequest {
|
|
257
|
+
method: string;
|
|
258
|
+
path: string;
|
|
259
|
+
headers: Record<string, string>;
|
|
260
|
+
body: Buffer;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseRequestMethod(buf: Buffer): string {
|
|
264
|
+
const firstLine = buf.subarray(0, buf.indexOf("\n")).toString();
|
|
265
|
+
return firstLine.split(" ")[0] ?? "?";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseRequestPath(buf: Buffer): string {
|
|
269
|
+
const firstLine = buf.subarray(0, buf.indexOf("\n")).toString();
|
|
270
|
+
const path = firstLine.split(" ")[1];
|
|
271
|
+
return path ?? "/";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function parseResponseStatus(buf: Buffer): { statusCode: number } | null {
|
|
275
|
+
const firstLine = buf.subarray(0, buf.indexOf("\n")).toString();
|
|
276
|
+
const parts = firstLine.split(" ");
|
|
277
|
+
const code = parseInt(parts[1] ?? "0", 10);
|
|
278
|
+
return isNaN(code) ? null : { statusCode: code };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parseHttpRequest(buf: Buffer): ParsedRequest | null {
|
|
282
|
+
const idx = buf.indexOf("\r\n\r\n");
|
|
283
|
+
const idxLf = buf.indexOf("\n\n");
|
|
284
|
+
const sepIdx = idx >= 0 ? idx : idxLf >= 0 ? idxLf : -1;
|
|
285
|
+
const sepLen = idx >= 0 ? 4 : idxLf >= 0 ? 2 : 0;
|
|
286
|
+
if (sepIdx === -1) return null;
|
|
287
|
+
|
|
288
|
+
const headerSection = buf.subarray(0, sepIdx).toString();
|
|
289
|
+
const body = buf.subarray(sepIdx + sepLen);
|
|
290
|
+
const lineSep = idx >= 0 ? "\r\n" : "\n";
|
|
291
|
+
const lines = headerSection.split(lineSep);
|
|
292
|
+
const firstLine = lines[0];
|
|
293
|
+
if (!firstLine) return null;
|
|
294
|
+
const parts = firstLine.split(" ");
|
|
295
|
+
const method = parts[0] ?? "GET";
|
|
296
|
+
const path = parts[1] ?? "/";
|
|
297
|
+
const headers: Record<string, string> = {};
|
|
298
|
+
|
|
299
|
+
for (let i = 1; i < lines.length; i++) {
|
|
300
|
+
const line = lines[i];
|
|
301
|
+
if (!line) continue;
|
|
302
|
+
const colon = line.indexOf(":");
|
|
303
|
+
if (colon === -1) continue;
|
|
304
|
+
const key = line.slice(0, colon).trim().toLowerCase();
|
|
305
|
+
const value = line.slice(colon + 1).trim();
|
|
306
|
+
headers[key] = value;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
method,
|
|
311
|
+
path,
|
|
312
|
+
headers,
|
|
313
|
+
body,
|
|
314
|
+
};
|
|
315
|
+
}
|