svamp-cli 0.2.45 → 0.2.47
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/dist/{agentCommands-dlpOoDcq.mjs → agentCommands-BuGwfYhd.mjs} +2 -2
- package/dist/cli.mjs +32 -32
- package/dist/{commands-0xDVhPKr.mjs → commands-BJR_98XX.mjs} +12 -3
- package/dist/{commands-Cd_I1MXo.mjs → commands-JWrmpGcs.mjs} +1 -1
- package/dist/{commands-C6D6TMSl.mjs → commands-TyAIFJx-.mjs} +4 -4
- package/dist/{frpc-DzRFx60H.mjs → frpc-j60b46eU.mjs} +120 -4
- package/dist/index.mjs +1 -1
- package/dist/{package-Cx2tEoke.mjs → package-CNFS7wvh.mjs} +1 -1
- package/dist/{run-D59qJKn_.mjs → run-6umeTX-K.mjs} +411 -61
- package/dist/{run-DZhogQUH.mjs → run-DR7E3IZL.mjs} +3 -53
- package/dist/{serveCommands-DtKlt1DY.mjs → serveCommands-FUE8m232.mjs} +107 -4
- package/dist/{serveManager-DOXI2QzY.mjs → serveManager-RvRL-weX.mjs} +284 -28
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import { randomUUID } from 'node:crypto';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { c as connectToHypha, a as registerSessionService } from './run-
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
5
|
+
import { c as connectToHypha, a as registerSessionService, h as generateHookSettings } from './run-6umeTX-K.mjs';
|
|
6
6
|
import { createServer } from 'node:http';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { createInterface } from 'node:readline';
|
|
@@ -66,56 +66,6 @@ async function startHookServer(onSessionHook, log) {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const SVAMP_HOME$1 = process.env.SVAMP_HOME || join(os.homedir(), ".svamp");
|
|
70
|
-
function generateHookSettings(port) {
|
|
71
|
-
const hooksDir = join(SVAMP_HOME$1, "tmp", "hooks");
|
|
72
|
-
mkdirSync(hooksDir, { recursive: true });
|
|
73
|
-
const forwarderPath = join(hooksDir, `forwarder-${process.pid}.cjs`);
|
|
74
|
-
const forwarderCode = `#!/usr/bin/env node
|
|
75
|
-
const http = require('http');
|
|
76
|
-
const port = parseInt(process.argv[2], 10);
|
|
77
|
-
if (!port || isNaN(port)) process.exit(1);
|
|
78
|
-
const chunks = [];
|
|
79
|
-
process.stdin.on('data', c => chunks.push(c));
|
|
80
|
-
process.stdin.on('end', () => {
|
|
81
|
-
const body = Buffer.concat(chunks);
|
|
82
|
-
const req = http.request({
|
|
83
|
-
host: '127.0.0.1', port, method: 'POST',
|
|
84
|
-
path: '/hook/session-start',
|
|
85
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': body.length }
|
|
86
|
-
}, res => res.resume());
|
|
87
|
-
req.on('error', () => {});
|
|
88
|
-
req.end(body);
|
|
89
|
-
});
|
|
90
|
-
process.stdin.resume();
|
|
91
|
-
`;
|
|
92
|
-
writeFileSync(forwarderPath, forwarderCode, { mode: 493 });
|
|
93
|
-
const settingsPath = join(hooksDir, `session-hook-${process.pid}.json`);
|
|
94
|
-
const hookCommand = `node "${forwarderPath}" ${port}`;
|
|
95
|
-
const settings = {
|
|
96
|
-
hooks: {
|
|
97
|
-
SessionStart: [
|
|
98
|
-
{
|
|
99
|
-
matcher: "*",
|
|
100
|
-
hooks: [{ type: "command", command: hookCommand }]
|
|
101
|
-
}
|
|
102
|
-
]
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
106
|
-
const cleanup = () => {
|
|
107
|
-
try {
|
|
108
|
-
if (existsSync(settingsPath)) unlinkSync(settingsPath);
|
|
109
|
-
} catch {
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
if (existsSync(forwarderPath)) unlinkSync(forwarderPath);
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
return { settingsPath, cleanup };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
69
|
const INTERNAL_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
120
70
|
"file-history-snapshot",
|
|
121
71
|
"change",
|
|
@@ -45,6 +45,8 @@ async function handleServeCommand() {
|
|
|
45
45
|
await serveInfo(machineId);
|
|
46
46
|
} else if (sub === "add") {
|
|
47
47
|
await serveAdd(filteredArgs.slice(1), machineId);
|
|
48
|
+
} else if (sub === "apply") {
|
|
49
|
+
await serveApply(filteredArgs.slice(1), machineId);
|
|
48
50
|
} else if (sub && !sub.startsWith("-")) {
|
|
49
51
|
await serveAdd(filteredArgs, machineId);
|
|
50
52
|
} else {
|
|
@@ -52,7 +54,7 @@ async function handleServeCommand() {
|
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
async function serveAdd(args, machineId) {
|
|
55
|
-
const { connectAndGetMachine } = await import('./commands-
|
|
57
|
+
const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
|
|
56
58
|
const pos = positionalArgs(args);
|
|
57
59
|
const name = pos[0];
|
|
58
60
|
if (!name) {
|
|
@@ -83,8 +85,93 @@ async function serveAdd(args, machineId) {
|
|
|
83
85
|
});
|
|
84
86
|
}
|
|
85
87
|
}
|
|
88
|
+
async function serveApply(args, machineId) {
|
|
89
|
+
const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
|
|
90
|
+
const fs = await import('fs');
|
|
91
|
+
const yaml = await import('yaml');
|
|
92
|
+
const file = positionalArgs(args)[0];
|
|
93
|
+
if (!file) {
|
|
94
|
+
console.error("Usage: svamp serve apply <yaml-or-json-file> [--machine <id>]");
|
|
95
|
+
console.error("");
|
|
96
|
+
console.error("YAML format:");
|
|
97
|
+
console.error(" name: my-app");
|
|
98
|
+
console.error(" # Static mount:");
|
|
99
|
+
console.error(" directory: ./public");
|
|
100
|
+
console.error(" # OR managed-process mount:");
|
|
101
|
+
console.error(" process:");
|
|
102
|
+
console.error(" command: npm");
|
|
103
|
+
console.error(" args: [start]");
|
|
104
|
+
console.error(" port: 3000");
|
|
105
|
+
console.error(" workdir: .");
|
|
106
|
+
console.error(" warmup_path: / # default '/'");
|
|
107
|
+
console.error(" warmup_timeout_ms: 30000");
|
|
108
|
+
console.error(" idle_timeout_sec: 600 # 0 = always running");
|
|
109
|
+
console.error(" wake_on_request: true # spawn lazily on first request");
|
|
110
|
+
console.error(" access: public # public | owner (default) | [emails]");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
if (!fs.existsSync(file)) {
|
|
114
|
+
console.error(`Error: file not found: ${file}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = file.endsWith(".json") ? JSON.parse(raw) : yaml.parse(raw);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error(`Error: failed to parse ${file}: ${err.message}`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
if (!parsed || typeof parsed !== "object") {
|
|
126
|
+
console.error("Error: top-level config must be an object");
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
if (!parsed.name) {
|
|
130
|
+
console.error("Error: name is required");
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
if (!parsed.directory && !parsed.process) {
|
|
134
|
+
console.error("Error: must specify either directory (static) or process (managed)");
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
const proc = parsed.process ? {
|
|
138
|
+
command: parsed.process.command,
|
|
139
|
+
args: parsed.process.args,
|
|
140
|
+
port: parsed.process.port,
|
|
141
|
+
workdir: parsed.process.workdir ? path.resolve(parsed.process.workdir) : void 0,
|
|
142
|
+
env: parsed.process.env,
|
|
143
|
+
warmupPath: parsed.process.warmupPath ?? parsed.process.warmup_path,
|
|
144
|
+
warmupTimeoutMs: parsed.process.warmupTimeoutMs ?? parsed.process.warmup_timeout_ms,
|
|
145
|
+
idleTimeoutSec: parsed.process.idleTimeoutSec ?? parsed.process.idle_timeout_sec,
|
|
146
|
+
wakeOnRequest: parsed.process.wakeOnRequest ?? parsed.process.wake_on_request
|
|
147
|
+
} : void 0;
|
|
148
|
+
const params = {
|
|
149
|
+
name: parsed.name,
|
|
150
|
+
directory: parsed.directory ? path.resolve(parsed.directory) : void 0,
|
|
151
|
+
process: proc,
|
|
152
|
+
sessionId: parsed.sessionId ?? parsed.session_id,
|
|
153
|
+
access: parsed.access ?? "owner",
|
|
154
|
+
ownerEmail: parsed.ownerEmail ?? parsed.owner_email
|
|
155
|
+
};
|
|
156
|
+
const { machine, server } = await connectAndGetMachine(machineId);
|
|
157
|
+
try {
|
|
158
|
+
const result = await machine.serveApply(params);
|
|
159
|
+
const kind = proc ? "managed" : "static";
|
|
160
|
+
const what = proc ? `${proc.command}${proc.args?.length ? " " + proc.args.join(" ") : ""} (port ${proc.port})` : params.directory;
|
|
161
|
+
console.log(`Mount applied (${kind}): ${params.name} \u2192 ${what}`);
|
|
162
|
+
if (proc?.wakeOnRequest) console.log("Wake-on-request: enabled (process starts on first incoming request)");
|
|
163
|
+
if (proc?.idleTimeoutSec) console.log(`Idle timeout: ${proc.idleTimeoutSec}s`);
|
|
164
|
+
console.log(`URL: ${result.url}`);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error(`Error: ${err.message || err}`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
} finally {
|
|
169
|
+
await server.disconnect().catch(() => {
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
86
173
|
async function serveRemove(args, machineId) {
|
|
87
|
-
const { connectAndGetMachine } = await import('./commands-
|
|
174
|
+
const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
|
|
88
175
|
const pos = positionalArgs(args);
|
|
89
176
|
const name = pos[0];
|
|
90
177
|
if (!name) {
|
|
@@ -104,7 +191,7 @@ async function serveRemove(args, machineId) {
|
|
|
104
191
|
}
|
|
105
192
|
}
|
|
106
193
|
async function serveList(args, machineId) {
|
|
107
|
-
const { connectAndGetMachine } = await import('./commands-
|
|
194
|
+
const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
|
|
108
195
|
const all = hasFlag(args, "--all", "-a");
|
|
109
196
|
const json = hasFlag(args, "--json");
|
|
110
197
|
const sessionId = getFlag(args, "--session");
|
|
@@ -137,7 +224,7 @@ async function serveList(args, machineId) {
|
|
|
137
224
|
}
|
|
138
225
|
}
|
|
139
226
|
async function serveInfo(machineId) {
|
|
140
|
-
const { connectAndGetMachine } = await import('./commands-
|
|
227
|
+
const { connectAndGetMachine } = await import('./commands-JWrmpGcs.mjs');
|
|
141
228
|
const { machine, server } = await connectAndGetMachine(machineId);
|
|
142
229
|
try {
|
|
143
230
|
const info = await machine.serveInfo();
|
|
@@ -164,6 +251,9 @@ Multiple sessions can register different mount points without port conflicts.
|
|
|
164
251
|
Usage:
|
|
165
252
|
svamp serve <name> [directory] Add a mount and print its URL (dir defaults to .)
|
|
166
253
|
svamp serve add <name> [directory] Same as above
|
|
254
|
+
svamp serve apply <yaml> Apply a declarative mount config (idempotent).
|
|
255
|
+
Supports static and managed-process mounts
|
|
256
|
+
with wake-on-request and idle timeout.
|
|
167
257
|
svamp serve remove <name> Remove a mount
|
|
168
258
|
svamp serve list [--all] [--json] List mounts (default: current session only)
|
|
169
259
|
svamp serve info Show server status and URL
|
|
@@ -183,8 +273,21 @@ Examples:
|
|
|
183
273
|
svamp serve my-report ./output # Owner-only (default)
|
|
184
274
|
svamp serve dashboard ./dist --public # Anyone can access
|
|
185
275
|
svamp serve data ./csv --access a@x.com,b@y.com # Specific users
|
|
276
|
+
svamp serve apply my-app.yaml # Declarative apply
|
|
186
277
|
svamp serve list --all # Show all mounts
|
|
187
278
|
svamp serve remove my-report # Stop serving
|
|
279
|
+
|
|
280
|
+
Declarative apply (svamp serve apply <yaml>):
|
|
281
|
+
name: my-app
|
|
282
|
+
process:
|
|
283
|
+
command: npm
|
|
284
|
+
args: [start]
|
|
285
|
+
port: 3000
|
|
286
|
+
workdir: .
|
|
287
|
+
warmup_path: /
|
|
288
|
+
idle_timeout_sec: 600
|
|
289
|
+
wake_on_request: true
|
|
290
|
+
access: public
|
|
188
291
|
`);
|
|
189
292
|
}
|
|
190
293
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
1
2
|
import * as fs from 'fs';
|
|
2
3
|
import * as http from 'http';
|
|
3
4
|
import * as net from 'net';
|
|
@@ -281,6 +282,10 @@ class ServeManager {
|
|
|
281
282
|
caddy = null;
|
|
282
283
|
proxyServer = null;
|
|
283
284
|
auth = null;
|
|
285
|
+
/** Live child processes for managed mounts. Keyed by mount name. */
|
|
286
|
+
managedProcs = /* @__PURE__ */ new Map();
|
|
287
|
+
/** Single timer that scans managed mounts every 30s for idle eviction. */
|
|
288
|
+
idleTimer = null;
|
|
284
289
|
persistFile;
|
|
285
290
|
log;
|
|
286
291
|
hyphaServerUrl;
|
|
@@ -292,35 +297,72 @@ class ServeManager {
|
|
|
292
297
|
}
|
|
293
298
|
// ── Public API ───────────────────────────────────────────────────────
|
|
294
299
|
/**
|
|
295
|
-
* Add a mount
|
|
296
|
-
*
|
|
300
|
+
* Add a static mount (backward-compatible thin wrapper around applyMount).
|
|
301
|
+
* Throws if a mount with the same name already exists, preserving the
|
|
302
|
+
* pre-existing semantics that callers may depend on.
|
|
297
303
|
*/
|
|
298
304
|
async addMount(name, directory, sessionId, access = "owner", ownerEmail) {
|
|
299
|
-
validateMountName(name);
|
|
300
|
-
const resolvedDir = path.resolve(directory);
|
|
301
|
-
if (!fs.existsSync(resolvedDir)) {
|
|
302
|
-
throw new Error(`Path does not exist: ${resolvedDir}`);
|
|
303
|
-
}
|
|
304
305
|
if (this.mounts.has(name)) {
|
|
305
306
|
throw new Error(`Mount '${name}' already exists. Remove it first or choose a different name.`);
|
|
306
307
|
}
|
|
308
|
+
return this.applyMount({ name, directory, sessionId, access, ownerEmail });
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Apply a mount declaratively. Unifies static and managed-process mounts:
|
|
312
|
+
* pass `directory` for static, `process` for managed (or both — `process`
|
|
313
|
+
* takes precedence for routing). Replaces an existing mount with the same
|
|
314
|
+
* name (idempotent), so repeated `svamp serve apply` is safe.
|
|
315
|
+
*
|
|
316
|
+
* Backward-compatible: existing `addMount(name, directory, ...)` callers
|
|
317
|
+
* still work and produce the same result as `applyMount({name, directory, ...})`.
|
|
318
|
+
*/
|
|
319
|
+
async applyMount(spec) {
|
|
320
|
+
validateMountName(spec.name);
|
|
321
|
+
if (!spec.directory && !spec.process) {
|
|
322
|
+
throw new Error(`Mount '${spec.name}': must specify either directory (static) or process (managed)`);
|
|
323
|
+
}
|
|
324
|
+
if (spec.process) {
|
|
325
|
+
if (!spec.process.command) {
|
|
326
|
+
throw new Error(`Mount '${spec.name}': process.command is required`);
|
|
327
|
+
}
|
|
328
|
+
if (!spec.process.port || spec.process.port <= 0) {
|
|
329
|
+
throw new Error(`Mount '${spec.name}': process.port must be a positive integer`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const resolvedDir = spec.directory ? path.resolve(spec.directory) : void 0;
|
|
333
|
+
if (resolvedDir && !fs.existsSync(resolvedDir)) {
|
|
334
|
+
throw new Error(`Path does not exist: ${resolvedDir}`);
|
|
335
|
+
}
|
|
336
|
+
if (this.mounts.has(spec.name)) {
|
|
337
|
+
await this.removeMount(spec.name);
|
|
338
|
+
}
|
|
307
339
|
const mount = {
|
|
308
|
-
name,
|
|
340
|
+
name: spec.name,
|
|
309
341
|
directory: resolvedDir,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
342
|
+
process: spec.process,
|
|
343
|
+
sessionId: spec.sessionId,
|
|
344
|
+
ownerEmail: spec.ownerEmail,
|
|
345
|
+
access: spec.access ?? "owner",
|
|
313
346
|
addedAt: Date.now()
|
|
314
347
|
};
|
|
315
|
-
this.mounts.set(name, mount);
|
|
348
|
+
this.mounts.set(spec.name, mount);
|
|
316
349
|
await this.ensureRunning();
|
|
317
|
-
if (this.caddy?.isRunning) {
|
|
318
|
-
await this.caddy.addMount(name, resolvedDir);
|
|
350
|
+
if (resolvedDir && this.caddy?.isRunning) {
|
|
351
|
+
await this.caddy.addMount(spec.name, resolvedDir);
|
|
319
352
|
}
|
|
320
|
-
await this.startMountTunnel(name);
|
|
353
|
+
await this.startMountTunnel(spec.name);
|
|
354
|
+
if (spec.process && !spec.process.wakeOnRequest) {
|
|
355
|
+
try {
|
|
356
|
+
await this.ensureManagedRunning(spec.name);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
this.log(`Mount '${spec.name}': initial process start failed: ${err.message}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
this.ensureIdleTimer();
|
|
321
362
|
this.persist();
|
|
322
|
-
const url = this.getMountUrl(name);
|
|
323
|
-
|
|
363
|
+
const url = this.getMountUrl(spec.name);
|
|
364
|
+
const what = spec.process ? `${spec.process.command}${spec.process.args?.length ? " " + spec.process.args.join(" ") : ""} (port ${spec.process.port})` : resolvedDir;
|
|
365
|
+
this.log(`Mount applied: ${spec.name} \u2192 ${what} (${url ?? "tunnel pending"})`);
|
|
324
366
|
return { url: url || "", mount };
|
|
325
367
|
}
|
|
326
368
|
/**
|
|
@@ -331,6 +373,8 @@ class ServeManager {
|
|
|
331
373
|
throw new Error(`Mount '${name}' not found`);
|
|
332
374
|
}
|
|
333
375
|
this.mounts.delete(name);
|
|
376
|
+
await this.stopManagedProcess(name).catch(() => {
|
|
377
|
+
});
|
|
334
378
|
const tunnel = this.mountTunnels.get(name);
|
|
335
379
|
if (tunnel) {
|
|
336
380
|
try {
|
|
@@ -375,6 +419,7 @@ class ServeManager {
|
|
|
375
419
|
return {
|
|
376
420
|
url: firstUrl,
|
|
377
421
|
port: running ? this.caddy?.port ?? this.port : 0,
|
|
422
|
+
authProxyPort: running ? this.port : 0,
|
|
378
423
|
running,
|
|
379
424
|
mountCount: this.mounts.size,
|
|
380
425
|
mounts: Array.from(this.mounts.values()).map((m) => ({
|
|
@@ -396,12 +441,16 @@ class ServeManager {
|
|
|
396
441
|
if (!raw.mounts || raw.mounts.length === 0) return;
|
|
397
442
|
let restoredCount = 0;
|
|
398
443
|
for (const m of raw.mounts) {
|
|
399
|
-
if (fs.existsSync(m.directory)) {
|
|
400
|
-
this.mounts.set(m.name, m);
|
|
401
|
-
restoredCount++;
|
|
402
|
-
} else {
|
|
444
|
+
if (m.directory && !fs.existsSync(m.directory)) {
|
|
403
445
|
this.log(`Skipping mount '${m.name}': directory no longer exists (${m.directory})`);
|
|
446
|
+
continue;
|
|
404
447
|
}
|
|
448
|
+
if (!m.directory && !m.process) {
|
|
449
|
+
this.log(`Skipping mount '${m.name}': neither directory nor process configured`);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
this.mounts.set(m.name, m);
|
|
453
|
+
restoredCount++;
|
|
405
454
|
}
|
|
406
455
|
if (restoredCount > 0) {
|
|
407
456
|
this.log(`Restoring ${restoredCount} mount(s)...`);
|
|
@@ -412,7 +461,15 @@ class ServeManager {
|
|
|
412
461
|
} catch (err) {
|
|
413
462
|
this.log(`Failed to start tunnel for restored mount '${m.name}': ${err.message}`);
|
|
414
463
|
}
|
|
464
|
+
if (m.process && !m.process.wakeOnRequest) {
|
|
465
|
+
try {
|
|
466
|
+
await this.ensureManagedRunning(m.name);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
this.log(`Restored managed process '${m.name}' failed to start: ${err.message}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
415
471
|
}
|
|
472
|
+
this.ensureIdleTimer();
|
|
416
473
|
this.persist();
|
|
417
474
|
}
|
|
418
475
|
} catch (err) {
|
|
@@ -420,9 +477,18 @@ class ServeManager {
|
|
|
420
477
|
}
|
|
421
478
|
}
|
|
422
479
|
/**
|
|
423
|
-
* Shut down auth proxy + Caddy + all per-mount frpc tunnels
|
|
480
|
+
* Shut down auth proxy + Caddy + all per-mount frpc tunnels + all
|
|
481
|
+
* managed processes.
|
|
424
482
|
*/
|
|
425
483
|
async shutdown() {
|
|
484
|
+
for (const name of Array.from(this.managedProcs.keys())) {
|
|
485
|
+
await this.stopManagedProcess(name).catch(() => {
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (this.idleTimer) {
|
|
489
|
+
clearInterval(this.idleTimer);
|
|
490
|
+
this.idleTimer = null;
|
|
491
|
+
}
|
|
426
492
|
for (const [name, tunnel] of this.mountTunnels.entries()) {
|
|
427
493
|
try {
|
|
428
494
|
tunnel.destroy();
|
|
@@ -443,6 +509,141 @@ class ServeManager {
|
|
|
443
509
|
}
|
|
444
510
|
this.auth?.destroy();
|
|
445
511
|
}
|
|
512
|
+
// ── Managed-process lifecycle ─────────────────────────────────────────
|
|
513
|
+
/**
|
|
514
|
+
* Ensure the managed process for a mount is running and warm. If a warmup
|
|
515
|
+
* is already in flight, awaits the same promise (no duplicate spawns).
|
|
516
|
+
* Throws if warmup probe never returns 200/3xx within warmupTimeoutMs.
|
|
517
|
+
*/
|
|
518
|
+
async ensureManagedRunning(name) {
|
|
519
|
+
const mount = this.mounts.get(name);
|
|
520
|
+
if (!mount?.process) return;
|
|
521
|
+
const handle = this.managedProcs.get(name);
|
|
522
|
+
if (handle && handle.warmupPromise) {
|
|
523
|
+
return handle.warmupPromise;
|
|
524
|
+
}
|
|
525
|
+
if (handle && handle.child.exitCode === null && !handle.child.killed) {
|
|
526
|
+
handle.lastRequestAt = Date.now();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const cfg = mount.process;
|
|
530
|
+
const child = spawn(cfg.command, cfg.args ?? [], {
|
|
531
|
+
cwd: cfg.workdir ?? process.cwd(),
|
|
532
|
+
env: { ...process.env, ...cfg.env ?? {} },
|
|
533
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
534
|
+
detached: false
|
|
535
|
+
});
|
|
536
|
+
child.stdout?.on("data", (d) => {
|
|
537
|
+
const line = d.toString().trimEnd();
|
|
538
|
+
if (line) this.log(`[${name}] ${line}`);
|
|
539
|
+
});
|
|
540
|
+
child.stderr?.on("data", (d) => {
|
|
541
|
+
const line = d.toString().trimEnd();
|
|
542
|
+
if (line) this.log(`[${name}] ${line}`);
|
|
543
|
+
});
|
|
544
|
+
child.on("exit", (code, signal) => {
|
|
545
|
+
this.log(`Managed process '${name}' exited (code=${code}, signal=${signal})`);
|
|
546
|
+
const h = this.managedProcs.get(name);
|
|
547
|
+
if (h && h.child === child) this.managedProcs.delete(name);
|
|
548
|
+
});
|
|
549
|
+
const warmupPath = cfg.warmupPath ?? "/";
|
|
550
|
+
const warmupTimeoutMs = cfg.warmupTimeoutMs ?? 3e4;
|
|
551
|
+
const warmupPromise = this.warmupProbe(`http://127.0.0.1:${cfg.port}${warmupPath}`, warmupTimeoutMs).then(() => {
|
|
552
|
+
const h = this.managedProcs.get(name);
|
|
553
|
+
if (h) {
|
|
554
|
+
h.warmupPromise = null;
|
|
555
|
+
h.lastRequestAt = Date.now();
|
|
556
|
+
}
|
|
557
|
+
this.log(`Managed process '${name}' ready on 127.0.0.1:${cfg.port}`);
|
|
558
|
+
}).catch((err) => {
|
|
559
|
+
this.log(`Managed process '${name}' warmup failed: ${err.message}`);
|
|
560
|
+
try {
|
|
561
|
+
child.kill("SIGTERM");
|
|
562
|
+
} catch {
|
|
563
|
+
}
|
|
564
|
+
this.managedProcs.delete(name);
|
|
565
|
+
throw err;
|
|
566
|
+
});
|
|
567
|
+
const newHandle = {
|
|
568
|
+
child,
|
|
569
|
+
pid: child.pid ?? 0,
|
|
570
|
+
lastRequestAt: Date.now(),
|
|
571
|
+
warmupPromise
|
|
572
|
+
};
|
|
573
|
+
this.managedProcs.set(name, newHandle);
|
|
574
|
+
this.log(`Managed process '${name}' starting: ${cfg.command} ${(cfg.args ?? []).join(" ")} (port ${cfg.port})`);
|
|
575
|
+
return warmupPromise;
|
|
576
|
+
}
|
|
577
|
+
/** Poll a URL until it returns <500 or the deadline passes. */
|
|
578
|
+
async warmupProbe(url, timeoutMs) {
|
|
579
|
+
const deadline = Date.now() + timeoutMs;
|
|
580
|
+
let lastErr;
|
|
581
|
+
while (Date.now() < deadline) {
|
|
582
|
+
try {
|
|
583
|
+
const ctrl = new AbortController();
|
|
584
|
+
const t = setTimeout(() => ctrl.abort(), 2e3);
|
|
585
|
+
let resp;
|
|
586
|
+
try {
|
|
587
|
+
resp = await fetch(url, {
|
|
588
|
+
method: "GET",
|
|
589
|
+
signal: ctrl.signal,
|
|
590
|
+
redirect: "manual"
|
|
591
|
+
});
|
|
592
|
+
} finally {
|
|
593
|
+
clearTimeout(t);
|
|
594
|
+
}
|
|
595
|
+
if (resp.status < 500) return;
|
|
596
|
+
lastErr = new Error(`status ${resp.status}`);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
lastErr = err;
|
|
599
|
+
}
|
|
600
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
601
|
+
}
|
|
602
|
+
throw new Error(`warmup probe ${url} did not respond within ${timeoutMs}ms (${lastErr?.message || "no response"})`);
|
|
603
|
+
}
|
|
604
|
+
/** Stop a managed process (SIGTERM + SIGKILL after 5s). */
|
|
605
|
+
async stopManagedProcess(name) {
|
|
606
|
+
const h = this.managedProcs.get(name);
|
|
607
|
+
if (!h) return;
|
|
608
|
+
this.managedProcs.delete(name);
|
|
609
|
+
try {
|
|
610
|
+
h.child.kill("SIGTERM");
|
|
611
|
+
} catch {
|
|
612
|
+
}
|
|
613
|
+
const child = h.child;
|
|
614
|
+
await new Promise((resolve) => {
|
|
615
|
+
const t = setTimeout(() => {
|
|
616
|
+
try {
|
|
617
|
+
child.kill("SIGKILL");
|
|
618
|
+
} catch {
|
|
619
|
+
}
|
|
620
|
+
resolve();
|
|
621
|
+
}, 5e3);
|
|
622
|
+
child.once("exit", () => {
|
|
623
|
+
clearTimeout(t);
|
|
624
|
+
resolve();
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
this.log(`Managed process '${name}' stopped`);
|
|
628
|
+
}
|
|
629
|
+
/** Idle eviction loop — stops processes that have been idle longer than configured. */
|
|
630
|
+
ensureIdleTimer() {
|
|
631
|
+
if (this.idleTimer) return;
|
|
632
|
+
this.idleTimer = setInterval(() => {
|
|
633
|
+
const now = Date.now();
|
|
634
|
+
for (const [name, mount] of this.mounts) {
|
|
635
|
+
const cfg = mount.process;
|
|
636
|
+
if (!cfg || !cfg.idleTimeoutSec || cfg.idleTimeoutSec <= 0) continue;
|
|
637
|
+
const h = this.managedProcs.get(name);
|
|
638
|
+
if (!h || h.warmupPromise) continue;
|
|
639
|
+
if (now - h.lastRequestAt >= cfg.idleTimeoutSec * 1e3) {
|
|
640
|
+
this.log(`Idle eviction: stopping '${name}' (idle ${Math.round((now - h.lastRequestAt) / 1e3)}s \u2265 ${cfg.idleTimeoutSec}s)`);
|
|
641
|
+
this.stopManagedProcess(name).catch(() => {
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}, 3e4);
|
|
646
|
+
}
|
|
446
647
|
// ── Internal ─────────────────────────────────────────────────────────
|
|
447
648
|
/** Get the public URL for a mount (mount-specific subdomain). */
|
|
448
649
|
getMountUrl(name) {
|
|
@@ -484,7 +685,9 @@ class ServeManager {
|
|
|
484
685
|
log: (msg) => this.log(`[Caddy] ${msg}`)
|
|
485
686
|
});
|
|
486
687
|
for (const mount of this.mounts.values()) {
|
|
487
|
-
|
|
688
|
+
if (mount.directory) {
|
|
689
|
+
await this.caddy.addMount(mount.name, mount.directory);
|
|
690
|
+
}
|
|
488
691
|
}
|
|
489
692
|
await this.caddy.start();
|
|
490
693
|
this.log(`Caddy file server started on 127.0.0.1:${this.caddyPort}`);
|
|
@@ -510,6 +713,18 @@ class ServeManager {
|
|
|
510
713
|
basePath = mountName ? url.pathname.slice(`/${mountName}`.length) || "/" : url.pathname;
|
|
511
714
|
}
|
|
512
715
|
const mount = mountName ? this.mounts.get(mountName) : void 0;
|
|
716
|
+
if (basePath === "/__svamp_health" || url.pathname === "/__svamp_health") {
|
|
717
|
+
res.writeHead(200, {
|
|
718
|
+
"Content-Type": "application/json",
|
|
719
|
+
"Cache-Control": "no-store"
|
|
720
|
+
});
|
|
721
|
+
res.end(JSON.stringify({
|
|
722
|
+
ok: true,
|
|
723
|
+
mount: mountName || null,
|
|
724
|
+
ts: Date.now()
|
|
725
|
+
}));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
513
728
|
if (basePath === "/__login__" || url.pathname === "/__login__") {
|
|
514
729
|
const returnUrl = url.searchParams.get("return") || "/";
|
|
515
730
|
const safeReturn = returnUrl.startsWith("/__login__") ? "/" : returnUrl;
|
|
@@ -535,7 +750,7 @@ class ServeManager {
|
|
|
535
750
|
return;
|
|
536
751
|
}
|
|
537
752
|
}
|
|
538
|
-
if (req.method === "PUT" && mount) {
|
|
753
|
+
if (req.method === "PUT" && mount && mount.directory) {
|
|
539
754
|
const filePath = path.join(mount.directory, basePath);
|
|
540
755
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
541
756
|
const ws = fs.createWriteStream(filePath);
|
|
@@ -550,7 +765,7 @@ class ServeManager {
|
|
|
550
765
|
});
|
|
551
766
|
return;
|
|
552
767
|
}
|
|
553
|
-
if (req.method === "DELETE" && mount) {
|
|
768
|
+
if (req.method === "DELETE" && mount && mount.directory) {
|
|
554
769
|
const filePath = path.join(mount.directory, basePath);
|
|
555
770
|
try {
|
|
556
771
|
fs.unlinkSync(filePath);
|
|
@@ -562,6 +777,39 @@ class ServeManager {
|
|
|
562
777
|
}
|
|
563
778
|
return;
|
|
564
779
|
}
|
|
780
|
+
if (mount && mount.process) {
|
|
781
|
+
const cfg = mount.process;
|
|
782
|
+
if (cfg.wakeOnRequest || !this.managedProcs.has(mount.name)) {
|
|
783
|
+
try {
|
|
784
|
+
await this.ensureManagedRunning(mount.name);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
787
|
+
res.end(`Backend not ready: ${err?.message || err}`);
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const handle = this.managedProcs.get(mount.name);
|
|
792
|
+
if (handle) handle.lastRequestAt = Date.now();
|
|
793
|
+
const targetPath = mountResolvedByHost ? req.url || "/" : (basePath || "/") + (url.search || "");
|
|
794
|
+
const proxyReq2 = http.request({
|
|
795
|
+
hostname: "127.0.0.1",
|
|
796
|
+
port: cfg.port,
|
|
797
|
+
path: targetPath,
|
|
798
|
+
method: req.method,
|
|
799
|
+
headers: req.headers
|
|
800
|
+
}, (proxyRes) => {
|
|
801
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
802
|
+
proxyRes.pipe(res);
|
|
803
|
+
});
|
|
804
|
+
proxyReq2.on("error", (err) => {
|
|
805
|
+
if (!res.headersSent) {
|
|
806
|
+
res.writeHead(502);
|
|
807
|
+
res.end(`Backend error: ${err.message}`);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
req.pipe(proxyReq2);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
565
813
|
let proxyPath = req.url || "/";
|
|
566
814
|
if (mountResolvedByHost && mountName) {
|
|
567
815
|
const search = url.search || "";
|
|
@@ -634,10 +882,17 @@ class ServeManager {
|
|
|
634
882
|
const subdomainSafe = mountName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
635
883
|
const tunnelName = `static-${subdomainSafe}`;
|
|
636
884
|
try {
|
|
637
|
-
const { FrpcTunnel } = await import('./frpc-
|
|
638
|
-
|
|
885
|
+
const { FrpcTunnel } = await import('./frpc-j60b46eU.mjs');
|
|
886
|
+
let tunnel;
|
|
887
|
+
tunnel = new FrpcTunnel({
|
|
639
888
|
name: tunnelName,
|
|
640
889
|
ports: [this.port],
|
|
890
|
+
// End-to-end probe: the daemon's health loop watches probe.ok
|
|
891
|
+
// to detect ghosted tunnel registrations (frpc says "connected"
|
|
892
|
+
// but no traffic actually flows). The sentinel route is served
|
|
893
|
+
// by the auth proxy without auth.
|
|
894
|
+
probePath: "/__svamp_health",
|
|
895
|
+
probeIntervalMs: 3e4,
|
|
641
896
|
onError: (err) => this.log(`frpc error [${mountName}]: ${err.message}`),
|
|
642
897
|
onConnect: () => {
|
|
643
898
|
const url2 = tunnel.getUrls().get(this.port);
|
|
@@ -650,7 +905,8 @@ class ServeManager {
|
|
|
650
905
|
this.log(`frpc tunnel connected for '${mountName}'. URL: ${url2}/`);
|
|
651
906
|
}
|
|
652
907
|
},
|
|
653
|
-
onDisconnect: () => this.log(`frpc tunnel for '${mountName}' disconnected, will auto-reconnect...`)
|
|
908
|
+
onDisconnect: () => this.log(`frpc tunnel for '${mountName}' disconnected, will auto-reconnect...`),
|
|
909
|
+
onProbeFail: (err) => this.log(`probe fail [${mountName}]: ${err.message}`)
|
|
654
910
|
});
|
|
655
911
|
await tunnel.connect();
|
|
656
912
|
this.mountTunnels.set(mountName, tunnel);
|