forge-remote 0.1.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/package.json +49 -0
- package/src/cli.js +213 -0
- package/src/desktop.js +121 -0
- package/src/firebase.js +46 -0
- package/src/init.js +645 -0
- package/src/logger.js +150 -0
- package/src/project-scanner.js +103 -0
- package/src/screenshot-manager.js +204 -0
- package/src/session-manager.js +1539 -0
- package/src/tunnel-manager.js +201 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { spawn, execSync } from "child_process";
|
|
2
|
+
import * as log from "./logger.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Manages localhost tunnels for dev server previews.
|
|
6
|
+
*
|
|
7
|
+
* Prefers `cloudflared` (no splash page, more reliable) and falls back
|
|
8
|
+
* to `localtunnel` if cloudflared is not installed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const activeTunnels = new Map(); // sessionId → { process, port, url }
|
|
12
|
+
|
|
13
|
+
// Detect which tunnel binary is available (checked once at startup).
|
|
14
|
+
let tunnelBackend = "none";
|
|
15
|
+
try {
|
|
16
|
+
execSync("which cloudflared", { stdio: "pipe" });
|
|
17
|
+
tunnelBackend = "cloudflared";
|
|
18
|
+
log.info("Tunnel backend: cloudflared");
|
|
19
|
+
} catch {
|
|
20
|
+
try {
|
|
21
|
+
execSync("which npx", { stdio: "pipe" });
|
|
22
|
+
tunnelBackend = "localtunnel";
|
|
23
|
+
log.info(
|
|
24
|
+
"Tunnel backend: localtunnel (install cloudflared for better experience)",
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
log.warn("No tunnel backend available — live preview will not work");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start a tunnel for a localhost port.
|
|
33
|
+
* Returns the public tunnel URL, or null on failure.
|
|
34
|
+
*/
|
|
35
|
+
export async function startTunnel(sessionId, port) {
|
|
36
|
+
if (tunnelBackend === "none") {
|
|
37
|
+
log.warn("No tunnel backend available — skipping tunnel creation");
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Don't create duplicate tunnels for the same session+port.
|
|
42
|
+
const existing = activeTunnels.get(sessionId);
|
|
43
|
+
if (existing && existing.port === port) {
|
|
44
|
+
log.info(
|
|
45
|
+
`Tunnel already active for session ${sessionId.slice(0, 8)} on port ${port}`,
|
|
46
|
+
);
|
|
47
|
+
return existing.url;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Stop any previous tunnel for this session.
|
|
51
|
+
if (existing) {
|
|
52
|
+
await stopTunnel(sessionId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
log.info(`Starting ${tunnelBackend} tunnel for localhost:${port}...`);
|
|
56
|
+
|
|
57
|
+
if (tunnelBackend === "cloudflared") {
|
|
58
|
+
return startCloudflaredTunnel(sessionId, port);
|
|
59
|
+
}
|
|
60
|
+
return startLocaltunnel(sessionId, port);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Cloudflared (preferred — no splash page)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function startCloudflaredTunnel(sessionId, port) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const proc = spawn(
|
|
70
|
+
"cloudflared",
|
|
71
|
+
["tunnel", "--url", `http://localhost:${port}`],
|
|
72
|
+
{ stdio: ["pipe", "pipe", "pipe"] },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
let url = null;
|
|
76
|
+
let stderrBuf = "";
|
|
77
|
+
|
|
78
|
+
const timeout = setTimeout(() => {
|
|
79
|
+
if (!url) {
|
|
80
|
+
log.warn(
|
|
81
|
+
`cloudflared tunnel startup timed out after 30s for port ${port}`,
|
|
82
|
+
);
|
|
83
|
+
proc.kill("SIGTERM");
|
|
84
|
+
resolve(null);
|
|
85
|
+
}
|
|
86
|
+
}, 30_000);
|
|
87
|
+
|
|
88
|
+
// cloudflared prints the URL to stderr, not stdout.
|
|
89
|
+
proc.stderr.on("data", (data) => {
|
|
90
|
+
stderrBuf += data.toString();
|
|
91
|
+
// Matches: https://some-random-name.trycloudflare.com
|
|
92
|
+
const match = stderrBuf.match(
|
|
93
|
+
/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i,
|
|
94
|
+
);
|
|
95
|
+
if (match && !url) {
|
|
96
|
+
url = match[0].trim();
|
|
97
|
+
clearTimeout(timeout);
|
|
98
|
+
activeTunnels.set(sessionId, { process: proc, port, url });
|
|
99
|
+
log.success(`Tunnel active: localhost:${port} → ${url}`);
|
|
100
|
+
resolve(url);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
proc.on("error", (err) => {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
log.error(`cloudflared failed to start: ${err.message}`);
|
|
107
|
+
resolve(null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
proc.on("close", (code) => {
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
activeTunnels.delete(sessionId);
|
|
113
|
+
if (code !== 0 && code !== null) {
|
|
114
|
+
log.warn(`cloudflared process exited with code ${code}`);
|
|
115
|
+
}
|
|
116
|
+
if (!url) resolve(null);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Localtunnel (fallback)
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function startLocaltunnel(sessionId, port) {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const proc = spawn("npx", ["localtunnel", "--port", String(port)], {
|
|
128
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
let url = null;
|
|
132
|
+
let stdoutBuf = "";
|
|
133
|
+
|
|
134
|
+
const timeout = setTimeout(() => {
|
|
135
|
+
if (!url) {
|
|
136
|
+
log.warn(`localtunnel startup timed out after 30s for port ${port}`);
|
|
137
|
+
proc.kill("SIGTERM");
|
|
138
|
+
resolve(null);
|
|
139
|
+
}
|
|
140
|
+
}, 30_000);
|
|
141
|
+
|
|
142
|
+
proc.stdout.on("data", (data) => {
|
|
143
|
+
stdoutBuf += data.toString();
|
|
144
|
+
// localtunnel outputs: "your url is: https://xyz.loca.lt"
|
|
145
|
+
const match = stdoutBuf.match(/your url is:\s*(https?:\/\/\S+)/i);
|
|
146
|
+
if (match && !url) {
|
|
147
|
+
url = match[1].trim();
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
|
|
150
|
+
activeTunnels.set(sessionId, { process: proc, port, url });
|
|
151
|
+
log.success(`Tunnel active: localhost:${port} → ${url}`);
|
|
152
|
+
resolve(url);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
proc.stderr.on("data", (data) => {
|
|
157
|
+
const msg = data.toString().trim();
|
|
158
|
+
if (msg) log.warn(`[tunnel] ${msg}`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
proc.on("error", (err) => {
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
log.error(`localtunnel failed to start: ${err.message}`);
|
|
164
|
+
resolve(null);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
proc.on("close", (code) => {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
activeTunnels.delete(sessionId);
|
|
170
|
+
if (code !== 0 && code !== null) {
|
|
171
|
+
log.warn(`localtunnel process exited with code ${code}`);
|
|
172
|
+
}
|
|
173
|
+
if (!url) resolve(null);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Stop the tunnel for a session.
|
|
180
|
+
*/
|
|
181
|
+
export async function stopTunnel(sessionId) {
|
|
182
|
+
const tunnel = activeTunnels.get(sessionId);
|
|
183
|
+
if (!tunnel) return;
|
|
184
|
+
|
|
185
|
+
log.info(
|
|
186
|
+
`Stopping tunnel for session ${sessionId.slice(0, 8)} (port ${tunnel.port})`,
|
|
187
|
+
);
|
|
188
|
+
tunnel.process.kill("SIGTERM");
|
|
189
|
+
activeTunnels.delete(sessionId);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Stop all active tunnels (relay shutdown cleanup).
|
|
194
|
+
*/
|
|
195
|
+
export async function stopAllTunnels() {
|
|
196
|
+
for (const [sessionId, tunnel] of activeTunnels) {
|
|
197
|
+
log.info(`Cleanup: stopping tunnel for ${sessionId.slice(0, 8)}`);
|
|
198
|
+
tunnel.process.kill("SIGTERM");
|
|
199
|
+
}
|
|
200
|
+
activeTunnels.clear();
|
|
201
|
+
}
|