forge-remote 0.1.1 → 0.1.2
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/firestore.rules +100 -0
- package/package.json +4 -2
- package/src/cloudflared-installer.js +164 -0
- package/src/desktop.js +17 -4
- package/src/google-auth.js +334 -0
- package/src/init.js +452 -273
- package/src/session-manager.js +116 -14
- package/src/tunnel-manager.js +226 -33
package/src/session-manager.js
CHANGED
|
@@ -226,6 +226,10 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
226
226
|
log.command("kill_session", sessionId.slice(0, 8));
|
|
227
227
|
await killSession(sessionId);
|
|
228
228
|
break;
|
|
229
|
+
case "retry_tunnel":
|
|
230
|
+
log.command("retry_tunnel", sessionId.slice(0, 8));
|
|
231
|
+
await retryTunnel(sessionId);
|
|
232
|
+
break;
|
|
229
233
|
default:
|
|
230
234
|
log.warn(`Unknown session command type: ${data.type}`);
|
|
231
235
|
}
|
|
@@ -233,6 +237,17 @@ async function handleSessionCommand(sessionId, commandDoc) {
|
|
|
233
237
|
} catch (e) {
|
|
234
238
|
log.error(`Session command failed: ${e.message}`);
|
|
235
239
|
await cmdRef.update({ status: "failed", error: e.message });
|
|
240
|
+
|
|
241
|
+
// Surface the error to the mobile app as a system message.
|
|
242
|
+
await db
|
|
243
|
+
.collection("sessions")
|
|
244
|
+
.doc(sessionId)
|
|
245
|
+
.collection("messages")
|
|
246
|
+
.add({
|
|
247
|
+
type: "system",
|
|
248
|
+
content: `Command failed: ${e.message}`,
|
|
249
|
+
timestamp: FieldValue.serverTimestamp(),
|
|
250
|
+
});
|
|
236
251
|
}
|
|
237
252
|
}
|
|
238
253
|
|
|
@@ -357,10 +372,32 @@ async function sendFollowUpPrompt(sessionId, prompt) {
|
|
|
357
372
|
throw new Error("Session not found. It may have ended.");
|
|
358
373
|
}
|
|
359
374
|
|
|
375
|
+
// If Claude is currently processing, interrupt it first.
|
|
360
376
|
if (session.process) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
377
|
+
log.session(sessionId, "Interrupting current process for new prompt...");
|
|
378
|
+
const proc = session.process;
|
|
379
|
+
session.process = null; // Prevent the close handler from changing status.
|
|
380
|
+
try {
|
|
381
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
382
|
+
} catch {
|
|
383
|
+
proc.kill("SIGTERM");
|
|
384
|
+
}
|
|
385
|
+
// Give it a moment to exit gracefully.
|
|
386
|
+
await new Promise((resolve) => {
|
|
387
|
+
const timeout = setTimeout(() => {
|
|
388
|
+
try {
|
|
389
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
390
|
+
} catch {
|
|
391
|
+
proc.kill("SIGKILL");
|
|
392
|
+
}
|
|
393
|
+
resolve();
|
|
394
|
+
}, 3000);
|
|
395
|
+
proc.on("close", () => {
|
|
396
|
+
clearTimeout(timeout);
|
|
397
|
+
resolve();
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
log.session(sessionId, "Previous process interrupted.");
|
|
364
401
|
}
|
|
365
402
|
|
|
366
403
|
// Cancel any pending permission watcher — user is overriding with a prompt.
|
|
@@ -426,6 +463,9 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
426
463
|
cwd: session.projectPath,
|
|
427
464
|
env: shellEnv,
|
|
428
465
|
stdio: ["pipe", "pipe", "pipe"],
|
|
466
|
+
// Detach from relay's process group so Ctrl+C doesn't kill Claude directly.
|
|
467
|
+
// The relay's cleanup handler will SIGTERM it explicitly via shutdownAllSessions().
|
|
468
|
+
detached: true,
|
|
429
469
|
});
|
|
430
470
|
|
|
431
471
|
session.process = claudeProcess;
|
|
@@ -701,9 +741,13 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
701
741
|
async function stopSession(sessionId) {
|
|
702
742
|
const session = activeSessions.get(sessionId);
|
|
703
743
|
|
|
704
|
-
// Kill running process if any.
|
|
744
|
+
// Kill running process (and its process group) if any.
|
|
705
745
|
if (session?.process) {
|
|
706
|
-
|
|
746
|
+
try {
|
|
747
|
+
process.kill(-session.process.pid, "SIGTERM");
|
|
748
|
+
} catch {
|
|
749
|
+
session.process.kill("SIGTERM");
|
|
750
|
+
}
|
|
707
751
|
}
|
|
708
752
|
|
|
709
753
|
// Cancel permission watcher if active.
|
|
@@ -731,6 +775,8 @@ async function stopSession(sessionId) {
|
|
|
731
775
|
status: "completed",
|
|
732
776
|
lastActivity: FieldValue.serverTimestamp(),
|
|
733
777
|
durationSeconds: duration,
|
|
778
|
+
tunnelStatus: null,
|
|
779
|
+
tunnelError: null,
|
|
734
780
|
});
|
|
735
781
|
|
|
736
782
|
await db
|
|
@@ -773,12 +819,16 @@ async function killSession(sessionId) {
|
|
|
773
819
|
stopCapturing(sessionId);
|
|
774
820
|
capturingSessions.delete(sessionId);
|
|
775
821
|
|
|
776
|
-
// Force-kill running process.
|
|
822
|
+
// Force-kill running process (and its process group).
|
|
777
823
|
if (session?.process) {
|
|
778
824
|
log.warn(
|
|
779
825
|
`Force-killing Claude process for session ${sessionId.slice(0, 8)}`,
|
|
780
826
|
);
|
|
781
|
-
|
|
827
|
+
try {
|
|
828
|
+
process.kill(-session.process.pid, "SIGKILL");
|
|
829
|
+
} catch {
|
|
830
|
+
session.process.kill("SIGKILL");
|
|
831
|
+
}
|
|
782
832
|
}
|
|
783
833
|
|
|
784
834
|
const db = getDb();
|
|
@@ -1165,16 +1215,24 @@ async function detectAndTunnelDevServer(sessionId, text) {
|
|
|
1165
1215
|
|
|
1166
1216
|
log.info(`Dev server detected on port ${port} — creating tunnel...`);
|
|
1167
1217
|
|
|
1218
|
+
const db = getDb();
|
|
1219
|
+
const sessionRef = db.collection("sessions").doc(sessionId);
|
|
1220
|
+
|
|
1221
|
+
// Write pending status immediately so the app can show a spinner.
|
|
1222
|
+
await sessionRef.update({
|
|
1223
|
+
devServerPort: port,
|
|
1224
|
+
tunnelStatus: "pending",
|
|
1225
|
+
tunnelError: null,
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1168
1228
|
const tunnelUrl = await startTunnel(sessionId, port);
|
|
1169
1229
|
if (tunnelUrl) {
|
|
1170
|
-
|
|
1171
|
-
const db = getDb();
|
|
1172
|
-
await db.collection("sessions").doc(sessionId).update({
|
|
1230
|
+
await sessionRef.update({
|
|
1173
1231
|
devServerUrl: tunnelUrl,
|
|
1174
|
-
|
|
1232
|
+
tunnelStatus: "active",
|
|
1233
|
+
tunnelError: null,
|
|
1175
1234
|
});
|
|
1176
1235
|
|
|
1177
|
-
// Notify in messages.
|
|
1178
1236
|
await db
|
|
1179
1237
|
.collection("sessions")
|
|
1180
1238
|
.doc(sessionId)
|
|
@@ -1188,12 +1246,52 @@ async function detectAndTunnelDevServer(sessionId, text) {
|
|
|
1188
1246
|
log.success(
|
|
1189
1247
|
`[${sessionId.slice(0, 8)}] Preview: localhost:${port} → ${tunnelUrl}`,
|
|
1190
1248
|
);
|
|
1249
|
+
} else {
|
|
1250
|
+
// Tunnel failed — surface error to the app.
|
|
1251
|
+
await sessionRef.update({
|
|
1252
|
+
tunnelStatus: "failed",
|
|
1253
|
+
tunnelError: "Tunnel creation failed. Check relay logs for details.",
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
await db
|
|
1257
|
+
.collection("sessions")
|
|
1258
|
+
.doc(sessionId)
|
|
1259
|
+
.collection("messages")
|
|
1260
|
+
.add({
|
|
1261
|
+
type: "system",
|
|
1262
|
+
content: `Failed to create preview tunnel for port ${port}. Tap "Retry" in the Preview tab.`,
|
|
1263
|
+
timestamp: FieldValue.serverTimestamp(),
|
|
1264
|
+
});
|
|
1191
1265
|
}
|
|
1192
1266
|
return; // Only tunnel the first detected port.
|
|
1193
1267
|
}
|
|
1194
1268
|
}
|
|
1195
1269
|
}
|
|
1196
1270
|
|
|
1271
|
+
/**
|
|
1272
|
+
* Retry creating a tunnel for the session's detected dev server port.
|
|
1273
|
+
* Called when the user taps "Retry" in the Preview tab.
|
|
1274
|
+
*/
|
|
1275
|
+
async function retryTunnel(sessionId) {
|
|
1276
|
+
const db = getDb();
|
|
1277
|
+
const sessionDoc = await db.collection("sessions").doc(sessionId).get();
|
|
1278
|
+
if (!sessionDoc.exists) throw new Error("Session not found");
|
|
1279
|
+
|
|
1280
|
+
const data = sessionDoc.data();
|
|
1281
|
+
const port = data.devServerPort;
|
|
1282
|
+
if (!port) throw new Error("No dev server port recorded for this session");
|
|
1283
|
+
|
|
1284
|
+
// Stop any existing tunnel first.
|
|
1285
|
+
await stopTunnel(sessionId);
|
|
1286
|
+
|
|
1287
|
+
// Clear the tunneled ports set so it doesn't skip this port.
|
|
1288
|
+
const ported = tunneledPorts.get(sessionId);
|
|
1289
|
+
if (ported) ported.delete(port);
|
|
1290
|
+
|
|
1291
|
+
// Re-detect and tunnel (uses the port directly).
|
|
1292
|
+
await detectAndTunnelDevServer(sessionId, `http://localhost:${port}`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1197
1295
|
// ---------------------------------------------------------------------------
|
|
1198
1296
|
// Graceful shutdown — mark all active sessions as completed
|
|
1199
1297
|
// ---------------------------------------------------------------------------
|
|
@@ -1212,9 +1310,13 @@ export async function shutdownAllSessions() {
|
|
|
1212
1310
|
const session = activeSessions.get(sessionId);
|
|
1213
1311
|
if (!session) continue;
|
|
1214
1312
|
|
|
1215
|
-
// Kill running process.
|
|
1313
|
+
// Kill running process (and its process group since we spawn detached).
|
|
1216
1314
|
if (session.process) {
|
|
1217
|
-
|
|
1315
|
+
try {
|
|
1316
|
+
process.kill(-session.process.pid, "SIGTERM");
|
|
1317
|
+
} catch {
|
|
1318
|
+
session.process.kill("SIGTERM");
|
|
1319
|
+
}
|
|
1218
1320
|
}
|
|
1219
1321
|
|
|
1220
1322
|
// Cancel permission watcher.
|
package/src/tunnel-manager.js
CHANGED
|
@@ -1,40 +1,68 @@
|
|
|
1
1
|
import { spawn, execSync } from "child_process";
|
|
2
|
+
import { createServer, request as httpRequest } from "http";
|
|
3
|
+
import { connect } from "net";
|
|
2
4
|
import * as log from "./logger.js";
|
|
5
|
+
import {
|
|
6
|
+
getLocalCloudflaredPath,
|
|
7
|
+
isCloudflaredWorking,
|
|
8
|
+
} from "./cloudflared-installer.js";
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* Manages localhost tunnels for dev server previews.
|
|
6
12
|
*
|
|
7
13
|
* Prefers `cloudflared` (no splash page, more reliable) and falls back
|
|
8
14
|
* to `localtunnel` if cloudflared is not installed.
|
|
15
|
+
*
|
|
16
|
+
* A local proxy rewrites the Host header so Vite/Webpack/Next.js dev
|
|
17
|
+
* servers don't reject requests from the tunnel hostname.
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
|
-
const activeTunnels = new Map(); // sessionId → { process, port, url }
|
|
20
|
+
const activeTunnels = new Map(); // sessionId → { process, port, url, proxy? }
|
|
12
21
|
|
|
13
|
-
//
|
|
22
|
+
// Resolve cloudflared binary: local install → PATH → localtunnel fallback.
|
|
14
23
|
let tunnelBackend = "none";
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
let cloudflaredBinary = null;
|
|
25
|
+
|
|
26
|
+
const localCf = getLocalCloudflaredPath();
|
|
27
|
+
if (localCf && isCloudflaredWorking(localCf)) {
|
|
17
28
|
tunnelBackend = "cloudflared";
|
|
18
|
-
|
|
19
|
-
|
|
29
|
+
cloudflaredBinary = localCf;
|
|
30
|
+
log.info(`Tunnel backend: cloudflared (local: ${localCf})`);
|
|
31
|
+
} else {
|
|
20
32
|
try {
|
|
21
|
-
execSync("which
|
|
22
|
-
tunnelBackend = "
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
);
|
|
33
|
+
execSync("which cloudflared", { stdio: "pipe" });
|
|
34
|
+
tunnelBackend = "cloudflared";
|
|
35
|
+
cloudflaredBinary = "cloudflared";
|
|
36
|
+
log.info("Tunnel backend: cloudflared (from PATH)");
|
|
26
37
|
} catch {
|
|
27
|
-
|
|
38
|
+
try {
|
|
39
|
+
execSync("which npx", { stdio: "pipe" });
|
|
40
|
+
tunnelBackend = "localtunnel";
|
|
41
|
+
log.info(
|
|
42
|
+
"Tunnel backend: localtunnel (install cloudflared for better experience)",
|
|
43
|
+
);
|
|
44
|
+
} catch {
|
|
45
|
+
log.warn("No tunnel backend available — live preview will not work");
|
|
46
|
+
}
|
|
28
47
|
}
|
|
29
48
|
}
|
|
30
49
|
|
|
31
50
|
/**
|
|
32
51
|
* Start a tunnel for a localhost port.
|
|
33
52
|
* Returns the public tunnel URL, or null on failure.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} sessionId
|
|
55
|
+
* @param {number} port
|
|
56
|
+
* @param {(status: 'pending'|'active'|'failed', error?: string) => Promise<void>} [statusCallback]
|
|
57
|
+
* @returns {Promise<string|null>}
|
|
34
58
|
*/
|
|
35
|
-
export async function startTunnel(sessionId, port) {
|
|
59
|
+
export async function startTunnel(sessionId, port, statusCallback) {
|
|
36
60
|
if (tunnelBackend === "none") {
|
|
37
61
|
log.warn("No tunnel backend available — skipping tunnel creation");
|
|
62
|
+
await statusCallback?.(
|
|
63
|
+
"failed",
|
|
64
|
+
"No tunnel backend installed. Run `forge-remote init` to install cloudflared.",
|
|
65
|
+
);
|
|
38
66
|
return null;
|
|
39
67
|
}
|
|
40
68
|
|
|
@@ -52,41 +80,195 @@ export async function startTunnel(sessionId, port) {
|
|
|
52
80
|
await stopTunnel(sessionId);
|
|
53
81
|
}
|
|
54
82
|
|
|
83
|
+
await statusCallback?.("pending");
|
|
55
84
|
log.info(`Starting ${tunnelBackend} tunnel for localhost:${port}...`);
|
|
56
85
|
|
|
57
86
|
if (tunnelBackend === "cloudflared") {
|
|
58
|
-
return startCloudflaredTunnel(sessionId, port);
|
|
87
|
+
return startCloudflaredTunnel(sessionId, port, statusCallback);
|
|
59
88
|
}
|
|
60
|
-
return startLocaltunnel(sessionId, port);
|
|
89
|
+
return startLocaltunnel(sessionId, port, statusCallback);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Returns the active tunnel backend name. */
|
|
93
|
+
export function getTunnelBackend() {
|
|
94
|
+
return tunnelBackend;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Host-rewriting proxy
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Scrub headers that leak the tunnel hostname to the dev server.
|
|
103
|
+
* Vite checks req.headers.host — we must rewrite it to localhost.
|
|
104
|
+
*/
|
|
105
|
+
function scrubHeaders(headers, targetPort) {
|
|
106
|
+
const clean = {};
|
|
107
|
+
for (const [key, val] of Object.entries(headers)) {
|
|
108
|
+
const lower = key.toLowerCase();
|
|
109
|
+
// Strip all forwarded/tunnel/proxy headers that could leak the tunnel hostname.
|
|
110
|
+
if (
|
|
111
|
+
lower === "x-forwarded-host" ||
|
|
112
|
+
lower === "x-forwarded-for" ||
|
|
113
|
+
lower === "x-forwarded-proto" ||
|
|
114
|
+
lower === "x-real-ip" ||
|
|
115
|
+
lower === ":authority" ||
|
|
116
|
+
lower === "forwarded" ||
|
|
117
|
+
lower.startsWith("cf-")
|
|
118
|
+
) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
clean[key] = val;
|
|
122
|
+
}
|
|
123
|
+
// Force Host to localhost so Vite's allowedHosts check passes.
|
|
124
|
+
clean.host = `localhost:${targetPort}`;
|
|
125
|
+
return clean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Start a tiny local HTTP proxy that rewrites the Host header to localhost
|
|
130
|
+
* and strips Cloudflare forwarded headers. Also handles WebSocket upgrades
|
|
131
|
+
* (needed for Vite HMR). Returns the proxy port.
|
|
132
|
+
*/
|
|
133
|
+
function startHostProxy(targetPort) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const proxy = createServer((clientReq, clientRes) => {
|
|
136
|
+
const scrubbed = scrubHeaders(clientReq.headers, targetPort);
|
|
137
|
+
log.info(
|
|
138
|
+
`[proxy] ${clientReq.method} ${clientReq.url} ` +
|
|
139
|
+
`Host: ${clientReq.headers.host} → ${scrubbed.host}`,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const opts = {
|
|
143
|
+
hostname: "127.0.0.1",
|
|
144
|
+
port: targetPort,
|
|
145
|
+
path: clientReq.url,
|
|
146
|
+
method: clientReq.method,
|
|
147
|
+
headers: scrubbed,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const proxyReq = httpRequest(opts, (proxyRes) => {
|
|
151
|
+
// Detect Vite's "Blocked request" to aid debugging.
|
|
152
|
+
if (proxyRes.statusCode === 403) {
|
|
153
|
+
let body = "";
|
|
154
|
+
proxyRes.on("data", (chunk) => (body += chunk));
|
|
155
|
+
proxyRes.on("end", () => {
|
|
156
|
+
if (body.includes("Blocked request")) {
|
|
157
|
+
log.error(
|
|
158
|
+
`[proxy] Vite STILL blocked the request (status 403). ` +
|
|
159
|
+
`This means Host rewriting is not working as expected. ` +
|
|
160
|
+
`Response: ${body.slice(0, 200)}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
164
|
+
clientRes.end(body);
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
169
|
+
proxyRes.pipe(clientRes, { end: true });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
proxyReq.on("error", (err) => {
|
|
173
|
+
log.error(
|
|
174
|
+
`[proxy] Request to localhost:${targetPort} failed: ${err.message}`,
|
|
175
|
+
);
|
|
176
|
+
clientRes.writeHead(502);
|
|
177
|
+
clientRes.end(`Proxy error: ${err.message}`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
clientReq.pipe(proxyReq, { end: true });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Handle WebSocket upgrades (Vite HMR needs this).
|
|
184
|
+
proxy.on("upgrade", (req, clientSocket, head) => {
|
|
185
|
+
const serverSocket = connect(targetPort, "127.0.0.1", () => {
|
|
186
|
+
// Rebuild the upgrade request with scrubbed headers.
|
|
187
|
+
const headers = scrubHeaders(req.headers, targetPort);
|
|
188
|
+
let reqStr = `${req.method} ${req.url} HTTP/1.1\r\n`;
|
|
189
|
+
for (const [key, val] of Object.entries(headers)) {
|
|
190
|
+
reqStr += `${key}: ${val}\r\n`;
|
|
191
|
+
}
|
|
192
|
+
reqStr += "\r\n";
|
|
193
|
+
|
|
194
|
+
serverSocket.write(reqStr);
|
|
195
|
+
if (head.length > 0) serverSocket.write(head);
|
|
196
|
+
|
|
197
|
+
serverSocket.pipe(clientSocket);
|
|
198
|
+
clientSocket.pipe(serverSocket);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
serverSocket.on("error", () => clientSocket.destroy());
|
|
202
|
+
clientSocket.on("error", () => serverSocket.destroy());
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Listen on a random available port.
|
|
206
|
+
proxy.listen(0, "127.0.0.1", () => {
|
|
207
|
+
const proxyPort = proxy.address().port;
|
|
208
|
+
log.info(`Host-rewrite proxy: :${proxyPort} → localhost:${targetPort}`);
|
|
209
|
+
resolve({ server: proxy, port: proxyPort });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
proxy.on("error", (err) => {
|
|
213
|
+
log.error(`Proxy failed to start: ${err.message}`);
|
|
214
|
+
reject(err);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
61
217
|
}
|
|
62
218
|
|
|
63
219
|
// ---------------------------------------------------------------------------
|
|
64
220
|
// Cloudflared (preferred — no splash page)
|
|
65
221
|
// ---------------------------------------------------------------------------
|
|
66
222
|
|
|
67
|
-
function startCloudflaredTunnel(sessionId, port) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
223
|
+
async function startCloudflaredTunnel(sessionId, port, statusCallback) {
|
|
224
|
+
// Start a local proxy that rewrites Host header to localhost.
|
|
225
|
+
let proxy = null;
|
|
226
|
+
let tunnelPort = port;
|
|
227
|
+
try {
|
|
228
|
+
proxy = await startHostProxy(port);
|
|
229
|
+
tunnelPort = proxy.port;
|
|
230
|
+
log.info(
|
|
231
|
+
`Host-rewrite proxy active: cloudflared → :${tunnelPort} → localhost:${port}`,
|
|
232
|
+
);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
log.warn(
|
|
235
|
+
`Could not start host-rewrite proxy (${err.message}) — ` +
|
|
236
|
+
`relying on --http-host-header flag only`,
|
|
73
237
|
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --http-host-header tells cloudflared to rewrite the Host header
|
|
241
|
+
// to localhost before sending to our proxy/dev server.
|
|
242
|
+
// Belt-and-suspenders with the proxy approach.
|
|
243
|
+
const args = [
|
|
244
|
+
"tunnel",
|
|
245
|
+
"--url",
|
|
246
|
+
`http://127.0.0.1:${tunnelPort}`,
|
|
247
|
+
"--http-host-header",
|
|
248
|
+
`localhost:${port}`,
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
const proc = spawn(cloudflaredBinary, args, {
|
|
253
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
254
|
+
});
|
|
74
255
|
|
|
75
256
|
let url = null;
|
|
76
257
|
let stderrBuf = "";
|
|
77
258
|
|
|
78
|
-
const timeout = setTimeout(() => {
|
|
259
|
+
const timeout = setTimeout(async () => {
|
|
79
260
|
if (!url) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
);
|
|
261
|
+
const msg = `cloudflared tunnel startup timed out after 30s for port ${port}`;
|
|
262
|
+
log.warn(msg);
|
|
83
263
|
proc.kill("SIGTERM");
|
|
264
|
+
if (proxy) proxy.server.close();
|
|
265
|
+
await statusCallback?.("failed", msg);
|
|
84
266
|
resolve(null);
|
|
85
267
|
}
|
|
86
268
|
}, 30_000);
|
|
87
269
|
|
|
88
270
|
// cloudflared prints the URL to stderr, not stdout.
|
|
89
|
-
proc.stderr.on("data", (data) => {
|
|
271
|
+
proc.stderr.on("data", async (data) => {
|
|
90
272
|
stderrBuf += data.toString();
|
|
91
273
|
// Matches: https://some-random-name.trycloudflare.com
|
|
92
274
|
const match = stderrBuf.match(
|
|
@@ -95,20 +277,25 @@ function startCloudflaredTunnel(sessionId, port) {
|
|
|
95
277
|
if (match && !url) {
|
|
96
278
|
url = match[0].trim();
|
|
97
279
|
clearTimeout(timeout);
|
|
98
|
-
activeTunnels.set(sessionId, { process: proc, port, url });
|
|
280
|
+
activeTunnels.set(sessionId, { process: proc, port, url, proxy });
|
|
99
281
|
log.success(`Tunnel active: localhost:${port} → ${url}`);
|
|
282
|
+
await statusCallback?.("active");
|
|
100
283
|
resolve(url);
|
|
101
284
|
}
|
|
102
285
|
});
|
|
103
286
|
|
|
104
|
-
proc.on("error", (err) => {
|
|
287
|
+
proc.on("error", async (err) => {
|
|
105
288
|
clearTimeout(timeout);
|
|
106
289
|
log.error(`cloudflared failed to start: ${err.message}`);
|
|
290
|
+
if (proxy) proxy.server.close();
|
|
291
|
+
await statusCallback?.("failed", `cloudflared failed: ${err.message}`);
|
|
107
292
|
resolve(null);
|
|
108
293
|
});
|
|
109
294
|
|
|
110
295
|
proc.on("close", (code) => {
|
|
111
296
|
clearTimeout(timeout);
|
|
297
|
+
const entry = activeTunnels.get(sessionId);
|
|
298
|
+
if (entry?.proxy) entry.proxy.server.close();
|
|
112
299
|
activeTunnels.delete(sessionId);
|
|
113
300
|
if (code !== 0 && code !== null) {
|
|
114
301
|
log.warn(`cloudflared process exited with code ${code}`);
|
|
@@ -122,7 +309,7 @@ function startCloudflaredTunnel(sessionId, port) {
|
|
|
122
309
|
// Localtunnel (fallback)
|
|
123
310
|
// ---------------------------------------------------------------------------
|
|
124
311
|
|
|
125
|
-
function startLocaltunnel(sessionId, port) {
|
|
312
|
+
function startLocaltunnel(sessionId, port, statusCallback) {
|
|
126
313
|
return new Promise((resolve) => {
|
|
127
314
|
const proc = spawn("npx", ["localtunnel", "--port", String(port)], {
|
|
128
315
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -131,15 +318,17 @@ function startLocaltunnel(sessionId, port) {
|
|
|
131
318
|
let url = null;
|
|
132
319
|
let stdoutBuf = "";
|
|
133
320
|
|
|
134
|
-
const timeout = setTimeout(() => {
|
|
321
|
+
const timeout = setTimeout(async () => {
|
|
135
322
|
if (!url) {
|
|
136
|
-
|
|
323
|
+
const msg = `localtunnel startup timed out after 30s for port ${port}`;
|
|
324
|
+
log.warn(msg);
|
|
137
325
|
proc.kill("SIGTERM");
|
|
326
|
+
await statusCallback?.("failed", msg);
|
|
138
327
|
resolve(null);
|
|
139
328
|
}
|
|
140
329
|
}, 30_000);
|
|
141
330
|
|
|
142
|
-
proc.stdout.on("data", (data) => {
|
|
331
|
+
proc.stdout.on("data", async (data) => {
|
|
143
332
|
stdoutBuf += data.toString();
|
|
144
333
|
// localtunnel outputs: "your url is: https://xyz.loca.lt"
|
|
145
334
|
const match = stdoutBuf.match(/your url is:\s*(https?:\/\/\S+)/i);
|
|
@@ -149,6 +338,7 @@ function startLocaltunnel(sessionId, port) {
|
|
|
149
338
|
|
|
150
339
|
activeTunnels.set(sessionId, { process: proc, port, url });
|
|
151
340
|
log.success(`Tunnel active: localhost:${port} → ${url}`);
|
|
341
|
+
await statusCallback?.("active");
|
|
152
342
|
resolve(url);
|
|
153
343
|
}
|
|
154
344
|
});
|
|
@@ -158,9 +348,10 @@ function startLocaltunnel(sessionId, port) {
|
|
|
158
348
|
if (msg) log.warn(`[tunnel] ${msg}`);
|
|
159
349
|
});
|
|
160
350
|
|
|
161
|
-
proc.on("error", (err) => {
|
|
351
|
+
proc.on("error", async (err) => {
|
|
162
352
|
clearTimeout(timeout);
|
|
163
353
|
log.error(`localtunnel failed to start: ${err.message}`);
|
|
354
|
+
await statusCallback?.("failed", `localtunnel failed: ${err.message}`);
|
|
164
355
|
resolve(null);
|
|
165
356
|
});
|
|
166
357
|
|
|
@@ -186,6 +377,7 @@ export async function stopTunnel(sessionId) {
|
|
|
186
377
|
`Stopping tunnel for session ${sessionId.slice(0, 8)} (port ${tunnel.port})`,
|
|
187
378
|
);
|
|
188
379
|
tunnel.process.kill("SIGTERM");
|
|
380
|
+
if (tunnel.proxy) tunnel.proxy.server.close();
|
|
189
381
|
activeTunnels.delete(sessionId);
|
|
190
382
|
}
|
|
191
383
|
|
|
@@ -196,6 +388,7 @@ export async function stopAllTunnels() {
|
|
|
196
388
|
for (const [sessionId, tunnel] of activeTunnels) {
|
|
197
389
|
log.info(`Cleanup: stopping tunnel for ${sessionId.slice(0, 8)}`);
|
|
198
390
|
tunnel.process.kill("SIGTERM");
|
|
391
|
+
if (tunnel.proxy) tunnel.proxy.server.close();
|
|
199
392
|
}
|
|
200
393
|
activeTunnels.clear();
|
|
201
394
|
}
|