agent-office-cli 0.0.1
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/package.json +28 -0
- package/src/auth.js +178 -0
- package/src/core/config.js +13 -0
- package/src/core/index.js +36 -0
- package/src/core/providers/base.js +38 -0
- package/src/core/providers/claude-transcript.js +126 -0
- package/src/core/providers/claude.js +199 -0
- package/src/core/providers/codex-transcript.js +210 -0
- package/src/core/providers/codex.js +91 -0
- package/src/core/providers/generic.js +40 -0
- package/src/core/providers/index.js +17 -0
- package/src/core/session-contract.js +98 -0
- package/src/core/state.js +23 -0
- package/src/core/store/session-store.js +232 -0
- package/src/index.js +348 -0
- package/src/runtime/cli-helpers.js +90 -0
- package/src/runtime/ensure-node-pty.js +49 -0
- package/src/runtime/index.js +54 -0
- package/src/runtime/pty-manager.js +598 -0
- package/src/runtime/session-registry.js +74 -0
- package/src/runtime/tmux.js +152 -0
- package/src/server.js +208 -0
- package/src/tunnel.js +224 -0
- package/src/web/index.js +7 -0
- package/src/web/public/app.js +713 -0
- package/src/web/public/dashboard.html +245 -0
- package/src/web/public/index.html +84 -0
- package/src/web/public/login.css +833 -0
- package/src/web/public/login.html +28 -0
- package/src/web/public/office.html +22 -0
- package/src/web/public/register.html +316 -0
- package/src/web/public/styles.css +988 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const { spawnSync, spawn } = require("node:child_process");
|
|
2
|
+
const crypto = require("node:crypto");
|
|
3
|
+
const pty = require("node-pty");
|
|
4
|
+
|
|
5
|
+
const AGENTOFFICE_TMUX_PREFIX = "agentoffice_";
|
|
6
|
+
|
|
7
|
+
function tmuxPath() {
|
|
8
|
+
return process.env.TMUX_BIN || "tmux";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function runTmux(args, options = {}) {
|
|
12
|
+
return spawnSync(tmuxPath(), args, {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
...options
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertTmuxOk(result, action) {
|
|
19
|
+
if (result.status === 0) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const message = (result.stderr || result.stdout || `${action} failed`).trim();
|
|
23
|
+
throw new Error(`tmux ${action} failed: ${message}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function shellQuote(value) {
|
|
27
|
+
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createTmuxSession({ sessionName, cwd, command, shell }) {
|
|
31
|
+
// Clean env: remove CLAUDECODE to prevent "nested session" detection
|
|
32
|
+
const cleanEnv = { ...process.env };
|
|
33
|
+
delete cleanEnv.CLAUDECODE;
|
|
34
|
+
|
|
35
|
+
const createResult = runTmux(["new-session", "-d", "-s", sessionName, "-c", cwd], {
|
|
36
|
+
env: cleanEnv
|
|
37
|
+
});
|
|
38
|
+
assertTmuxOk(createResult, "new-session");
|
|
39
|
+
|
|
40
|
+
const remainResult = runTmux(["set-option", "-t", sessionName, "remain-on-exit", "on"]);
|
|
41
|
+
assertTmuxOk(remainResult, "set-option remain-on-exit");
|
|
42
|
+
|
|
43
|
+
const launchCommand = `exec ${shell} -lc ${shellQuote(command)}`;
|
|
44
|
+
const sendLiteralResult = runTmux(["send-keys", "-t", `${sessionName}:0.0`, "-l", launchCommand]);
|
|
45
|
+
assertTmuxOk(sendLiteralResult, "send-keys literal");
|
|
46
|
+
|
|
47
|
+
const sendEnterResult = runTmux(["send-keys", "-t", `${sessionName}:0.0`, "Enter"]);
|
|
48
|
+
assertTmuxOk(sendEnterResult, "send-keys enter");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function listSessions() {
|
|
52
|
+
const result = runTmux(["list-sessions", "-F", "#{session_name}"]);
|
|
53
|
+
if (result.status !== 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return (result.stdout || "")
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map((line) => line.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function listAgentOfficeSessions() {
|
|
63
|
+
return listSessions().filter((sessionName) => sessionName.startsWith(AGENTOFFICE_TMUX_PREFIX));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sessionExists(sessionName) {
|
|
67
|
+
const result = runTmux(["has-session", "-t", sessionName]);
|
|
68
|
+
return result.status === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function killSession(sessionName) {
|
|
72
|
+
const result = runTmux(["kill-session", "-t", sessionName]);
|
|
73
|
+
if (result.status !== 0) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describePane(sessionName) {
|
|
80
|
+
const result = runTmux([
|
|
81
|
+
"list-panes",
|
|
82
|
+
"-t",
|
|
83
|
+
sessionName,
|
|
84
|
+
"-F",
|
|
85
|
+
"#{pane_pid}\t#{pane_dead}\t#{pane_dead_status}\t#{pane_current_command}"
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
if (result.status !== 0) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const line = (result.stdout || "").trim().split("\n")[0];
|
|
93
|
+
if (!line) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [pidText, deadText, deadStatusText, currentCommand] = line.split("\t");
|
|
98
|
+
return {
|
|
99
|
+
pid: Number(pidText || 0) || null,
|
|
100
|
+
dead: deadText === "1",
|
|
101
|
+
deadStatus: deadStatusText === "" ? null : Number(deadStatusText),
|
|
102
|
+
currentCommand: currentCommand || null
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function capturePane(sessionName) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const proc = spawn(tmuxPath(), ["capture-pane", "-p", "-e", "-J", "-t", `${sessionName}:0.0`]);
|
|
109
|
+
let stdout = "";
|
|
110
|
+
proc.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
111
|
+
proc.on("close", (code) => { resolve(code === 0 ? stdout : ""); });
|
|
112
|
+
proc.on("error", () => { resolve(""); });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function attachClient(sessionName, { cwd, cols = 120, rows = 32 } = {}) {
|
|
117
|
+
// Create a linked session that shares the same window group but with status bar
|
|
118
|
+
// disabled, so the tmux chrome does not leak into the xterm.js stream.
|
|
119
|
+
const webSession = `${sessionName}_wv_${crypto.randomBytes(3).toString("hex")}`;
|
|
120
|
+
|
|
121
|
+
const proc = pty.spawn(tmuxPath(), [
|
|
122
|
+
"new-session", "-t", sessionName, "-s", webSession,
|
|
123
|
+
";", "set-option", "status", "off"
|
|
124
|
+
], {
|
|
125
|
+
name: "xterm-256color",
|
|
126
|
+
cwd: cwd || process.cwd(),
|
|
127
|
+
env: process.env,
|
|
128
|
+
cols,
|
|
129
|
+
rows
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Expose the linked session name so callers can clean it up on disconnect.
|
|
133
|
+
proc.webTmuxSession = webSession;
|
|
134
|
+
return proc;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function localAttachCommand(sessionName) {
|
|
138
|
+
return `${tmuxPath()} attach-session -t ${sessionName}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
AGENTOFFICE_TMUX_PREFIX,
|
|
143
|
+
attachClient,
|
|
144
|
+
capturePane,
|
|
145
|
+
createTmuxSession,
|
|
146
|
+
describePane,
|
|
147
|
+
killSession,
|
|
148
|
+
listAgentOfficeSessions,
|
|
149
|
+
localAttachCommand,
|
|
150
|
+
sessionExists,
|
|
151
|
+
tmuxPath
|
|
152
|
+
};
|
package/src/server.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const http = require("node:http");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const express = require("express");
|
|
4
|
+
const { WebSocketServer } = require("ws");
|
|
5
|
+
const { STATIC_DIR } = require("./web");
|
|
6
|
+
const auth = require("./auth");
|
|
7
|
+
|
|
8
|
+
function createAppServer({ host, port, store, ptyManager }) {
|
|
9
|
+
const app = express();
|
|
10
|
+
app.set("trust proxy", true);
|
|
11
|
+
app.use(express.json({ limit: "1mb" }));
|
|
12
|
+
|
|
13
|
+
const AUTH_WHITELIST = new Set([
|
|
14
|
+
"/api/auth/login",
|
|
15
|
+
"/api/auth/check",
|
|
16
|
+
"/api/auth/logout",
|
|
17
|
+
"/login.html",
|
|
18
|
+
"/register.html",
|
|
19
|
+
"/login.css"
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function isWhitelisted(pathname) {
|
|
23
|
+
if (AUTH_WHITELIST.has(pathname)) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return pathname.startsWith("/api/auth/");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isAuthorizedRequest(req) {
|
|
30
|
+
const ip = req.ip || req.socket?.remoteAddress || "";
|
|
31
|
+
const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
|
|
32
|
+
if (isLocal) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const cookieToken = auth.getTokenFromCookie(req);
|
|
36
|
+
return Boolean(cookieToken && auth.verifyToken(cookieToken));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sendOfficeShell(res) {
|
|
40
|
+
res.sendFile(path.join(STATIC_DIR, "office.html"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
app.use((req, res, next) => {
|
|
44
|
+
if (isWhitelisted(req.path)) {
|
|
45
|
+
return next();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isAuthorizedRequest(req)) {
|
|
49
|
+
return next();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (req.path.startsWith("/api/")) {
|
|
53
|
+
return res.status(401).json({ error: "unauthorized" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return res.redirect("/login.html");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.post("/api/auth/login", (req, res) => {
|
|
60
|
+
const ip = req.ip || req.connection?.remoteAddress || "";
|
|
61
|
+
const rateCheck = auth.checkRateLimit(ip);
|
|
62
|
+
|
|
63
|
+
if (!rateCheck.allowed) {
|
|
64
|
+
if (rateCheck.locked) {
|
|
65
|
+
return res.status(429).json({
|
|
66
|
+
error: "locked",
|
|
67
|
+
message: `Too many failed attempts. Locked for ${rateCheck.remainingSeconds}s.`,
|
|
68
|
+
remainingSeconds: rateCheck.remainingSeconds
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return res.status(429).json({
|
|
72
|
+
error: "rate_limited",
|
|
73
|
+
message: "Too many attempts. Try again in a minute.",
|
|
74
|
+
remaining: 0
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const token = String(req.body.token || "").trim();
|
|
79
|
+
if (!auth.verifyToken(token)) {
|
|
80
|
+
auth.recordAttempt(ip, false);
|
|
81
|
+
const afterCheck = auth.checkRateLimit(ip);
|
|
82
|
+
return res.status(401).json({
|
|
83
|
+
error: "invalid_token",
|
|
84
|
+
remaining: afterCheck.remaining
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
auth.recordAttempt(ip, true);
|
|
89
|
+
const secure = req.protocol === "https" || req.get("x-forwarded-proto") === "https";
|
|
90
|
+
auth.setAuthCookie(res, token, secure);
|
|
91
|
+
return res.json({ ok: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
app.post("/api/auth/logout", (_req, res) => {
|
|
95
|
+
auth.clearAuthCookie(res);
|
|
96
|
+
res.json({ ok: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
app.get("/api/auth/check", (req, res) => {
|
|
100
|
+
const cookieToken = auth.getTokenFromCookie(req);
|
|
101
|
+
const authenticated = cookieToken ? auth.verifyToken(cookieToken) : false;
|
|
102
|
+
res.json({ authenticated });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
app.get("/", (_req, res) => {
|
|
106
|
+
sendOfficeShell(res);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
app.get("/index.html", (_req, res) => {
|
|
110
|
+
sendOfficeShell(res);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
app.use(express.static(STATIC_DIR, { index: false }));
|
|
114
|
+
|
|
115
|
+
app.get("/api/health", (_req, res) => {
|
|
116
|
+
res.json({ ok: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
app.get("/api/sessions", (_req, res) => {
|
|
120
|
+
res.json({ sessions: store.listSessionSummaries() });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.get("/api/sessions/:sessionId", (req, res) => {
|
|
124
|
+
const session = store.getSession(req.params.sessionId);
|
|
125
|
+
if (!session) {
|
|
126
|
+
res.status(404).json({ error: "session_not_found" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
res.json(session);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
app.post("/api/sessions/launch", (req, res) => {
|
|
133
|
+
const command = String(req.body.command || "").trim();
|
|
134
|
+
if (!command) {
|
|
135
|
+
res.status(400).json({ error: "missing_command" });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const session = ptyManager.createManagedSession({
|
|
139
|
+
provider: req.body.provider || "generic",
|
|
140
|
+
title: req.body.title || command,
|
|
141
|
+
cwd: req.body.cwd || process.cwd(),
|
|
142
|
+
command,
|
|
143
|
+
transport: req.body.transport || null
|
|
144
|
+
});
|
|
145
|
+
res.json({ session });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
app.post("/api/providers/claude/hook", (req, res) => {
|
|
149
|
+
const session = ptyManager.ingestClaudeHook(req.body || {});
|
|
150
|
+
res.json({ ok: true, sessionId: session?.sessionId ?? null });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
app.get("*", (_req, res) => {
|
|
154
|
+
sendOfficeShell(res);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const server = http.createServer(app);
|
|
158
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
159
|
+
|
|
160
|
+
server.on("upgrade", (request, socket, head) => {
|
|
161
|
+
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
162
|
+
const pathname = url.pathname;
|
|
163
|
+
|
|
164
|
+
if (!pathname.startsWith("/ws/")) {
|
|
165
|
+
socket.destroy();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const remoteIp = request.socket?.remoteAddress || "";
|
|
170
|
+
const isLocal = remoteIp === "127.0.0.1" || remoteIp === "::1" || remoteIp === "::ffff:127.0.0.1";
|
|
171
|
+
if (!isLocal) {
|
|
172
|
+
const cookieToken = auth.getTokenFromCookie(request);
|
|
173
|
+
if (!cookieToken || !auth.verifyToken(cookieToken)) {
|
|
174
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
175
|
+
socket.destroy();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
181
|
+
ws.path = pathname;
|
|
182
|
+
wss.emit("connection", ws, request);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
wss.on("connection", (ws) => {
|
|
187
|
+
if (ws.path === "/ws/events") {
|
|
188
|
+
ptyManager.registerEventsSocket(ws);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (ws.path.startsWith("/ws/terminal/")) {
|
|
192
|
+
const sessionId = decodeURIComponent(ws.path.split("/").pop());
|
|
193
|
+
ptyManager.registerTerminalSocket(sessionId, ws);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
ws.close();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
server.listen(port, host, () => {
|
|
200
|
+
console.log(`AgentOffice listening on http://${host}:${port}`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return server;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
createAppServer
|
|
208
|
+
};
|
package/src/tunnel.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
const { WebSocket } = require("ws");
|
|
2
|
+
const { toSessionSummary } = require("./core");
|
|
3
|
+
|
|
4
|
+
const RECONNECT_BASE_MS = 1000;
|
|
5
|
+
const RECONNECT_MAX_MS = 30000;
|
|
6
|
+
|
|
7
|
+
function createTunnelClient({ key, relayUrl, localServerUrl }) {
|
|
8
|
+
let ws = null;
|
|
9
|
+
let reconnectDelay = RECONNECT_BASE_MS;
|
|
10
|
+
let stopped = false;
|
|
11
|
+
let authenticated = false;
|
|
12
|
+
let pendingStatusSummary = [];
|
|
13
|
+
|
|
14
|
+
function flushStatusSummary() {
|
|
15
|
+
if (!authenticated || !ws || ws.readyState !== WebSocket.OPEN) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
ws.send(JSON.stringify({
|
|
19
|
+
type: "status:summary",
|
|
20
|
+
sessions: pendingStatusSummary
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function connect() {
|
|
25
|
+
if (stopped) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
authenticated = false;
|
|
30
|
+
const url = `${relayUrl.replace(/^http/, "ws")}/upstream`;
|
|
31
|
+
ws = new WebSocket(url);
|
|
32
|
+
|
|
33
|
+
ws.on("open", () => {
|
|
34
|
+
reconnectDelay = RECONNECT_BASE_MS;
|
|
35
|
+
console.log("[tunnel] connected to relay, authenticating...");
|
|
36
|
+
ws.send(JSON.stringify({ type: "auth", key }));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
ws.on("message", async (raw) => {
|
|
40
|
+
try {
|
|
41
|
+
const str = String(raw);
|
|
42
|
+
|
|
43
|
+
// Fast path: WS data forwarding uses "W:${connId}:${data}" prefix (no JSON parse)
|
|
44
|
+
if (str.startsWith("W:")) {
|
|
45
|
+
const connId = str.slice(2, 16);
|
|
46
|
+
const data = str.slice(17);
|
|
47
|
+
const localWs = localWsConnections.get(connId);
|
|
48
|
+
if (localWs && localWs.readyState === WebSocket.OPEN) {
|
|
49
|
+
localWs.send(data);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const msg = JSON.parse(str);
|
|
55
|
+
|
|
56
|
+
if (msg.type === "auth:ok") {
|
|
57
|
+
authenticated = true;
|
|
58
|
+
console.log(`[tunnel] authenticated with relay: ${relayUrl} (userId=${msg.userId})`);
|
|
59
|
+
flushStatusSummary();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (msg.type === "auth:error") {
|
|
63
|
+
console.error(`[tunnel] authentication failed: ${msg.error || "invalid key"}`);
|
|
64
|
+
stopped = true;
|
|
65
|
+
ws.close();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!authenticated) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await handleRelayMessage(msg);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(`[tunnel] message error: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
ws.on("close", (code) => {
|
|
80
|
+
if (stopped) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (code === 4401) {
|
|
84
|
+
console.error("[tunnel] authentication rejected by relay. Not reconnecting.");
|
|
85
|
+
stopped = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
console.log(`[tunnel] disconnected (${code}). Reconnecting in ${reconnectDelay}ms...`);
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
91
|
+
connect();
|
|
92
|
+
}, reconnectDelay);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
ws.on("error", (err) => {
|
|
96
|
+
console.error(`[tunnel] ws error: ${err.message}`);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function handleRelayMessage(msg) {
|
|
101
|
+
if (msg.type === "http:request") {
|
|
102
|
+
await handleHttpRequest(msg);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (msg.type === "ws:open") {
|
|
106
|
+
handleWsOpen(msg);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (msg.type === "ws:close") {
|
|
110
|
+
handleWsClose(msg);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function handleHttpRequest(msg) {
|
|
115
|
+
try {
|
|
116
|
+
const fetchUrl = `${localServerUrl}${msg.path}`;
|
|
117
|
+
const fetchOptions = {
|
|
118
|
+
method: msg.method || "GET",
|
|
119
|
+
headers: { ...msg.headers, host: new URL(localServerUrl).host }
|
|
120
|
+
};
|
|
121
|
+
if (msg.body && msg.method !== "GET" && msg.method !== "HEAD") {
|
|
122
|
+
fetchOptions.body = msg.body;
|
|
123
|
+
fetchOptions.headers["content-type"] = fetchOptions.headers["content-type"] || "application/json";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const response = await fetch(fetchUrl, fetchOptions);
|
|
127
|
+
const body = await response.text();
|
|
128
|
+
const responseHeaders = {};
|
|
129
|
+
for (const [keyName, value] of response.headers) {
|
|
130
|
+
if (!["transfer-encoding", "connection", "content-encoding"].includes(keyName.toLowerCase())) {
|
|
131
|
+
responseHeaders[keyName] = value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
sendToRelay({
|
|
136
|
+
type: "http:response",
|
|
137
|
+
reqId: msg.reqId,
|
|
138
|
+
status: response.status,
|
|
139
|
+
headers: responseHeaders,
|
|
140
|
+
body
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
sendToRelay({
|
|
144
|
+
type: "http:response",
|
|
145
|
+
reqId: msg.reqId,
|
|
146
|
+
status: 502,
|
|
147
|
+
headers: {},
|
|
148
|
+
body: JSON.stringify({ error: err.message })
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const localWsConnections = new Map();
|
|
154
|
+
|
|
155
|
+
function handleWsOpen(msg) {
|
|
156
|
+
const connId = msg.connId;
|
|
157
|
+
const localWsUrl = `${localServerUrl.replace(/^http/, "ws")}${msg.path}`;
|
|
158
|
+
const localWs = new WebSocket(localWsUrl);
|
|
159
|
+
|
|
160
|
+
localWsConnections.set(connId, localWs);
|
|
161
|
+
|
|
162
|
+
localWs.on("message", (data) => {
|
|
163
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
164
|
+
// Fast path: prefix with connId, no JSON wrapping
|
|
165
|
+
ws.send(`W:${connId}:${String(data)}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
localWs.on("close", () => {
|
|
170
|
+
localWsConnections.delete(connId);
|
|
171
|
+
sendToRelay({
|
|
172
|
+
type: "ws:close",
|
|
173
|
+
connId
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
localWs.on("error", () => {
|
|
178
|
+
localWsConnections.delete(connId);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function handleWsClose(msg) {
|
|
183
|
+
const localWs = localWsConnections.get(msg.connId);
|
|
184
|
+
if (localWs) {
|
|
185
|
+
localWsConnections.delete(msg.connId);
|
|
186
|
+
localWs.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sendToRelay(payload) {
|
|
191
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
192
|
+
ws.send(JSON.stringify(payload));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sendStatusSummary(sessions) {
|
|
197
|
+
pendingStatusSummary = sessions
|
|
198
|
+
.map((session) => toSessionSummary(session))
|
|
199
|
+
.filter(Boolean);
|
|
200
|
+
flushStatusSummary();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function stop() {
|
|
204
|
+
stopped = true;
|
|
205
|
+
for (const localWs of localWsConnections.values()) {
|
|
206
|
+
localWs.close();
|
|
207
|
+
}
|
|
208
|
+
localWsConnections.clear();
|
|
209
|
+
if (ws) {
|
|
210
|
+
ws.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
connect();
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
sendStatusSummary,
|
|
218
|
+
stop
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
createTunnelClient
|
|
224
|
+
};
|