@debugg-ai/debugg-ai-mcp 3.9.3 → 4.0.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/CHANGELOG.md +42 -0
- package/README.md +37 -1
- package/dist/handlers/probePageHandler.js +252 -139
- package/dist/handlers/runTestSuiteHandler.js +70 -51
- package/dist/handlers/testPageChangesHandler.js +32 -1
- package/dist/handlers/triggerCrawlHandler.js +26 -1
- package/dist/services/caddy/caddyProxy.js +611 -0
- package/dist/services/caddy/portLock.js +321 -0
- package/dist/services/ngrok/tunnelManager.js +526 -625
- package/dist/services/ngrok/tunnelRegistry.js +57 -70
- package/dist/services/verdictAdapter.js +2 -0
- package/dist/utils/telemetry.js +16 -0
- package/dist/utils/tunnelContext.js +52 -15
- package/dist/utils/tunnelDisposition.js +9 -17
- package/package.json +3 -1
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caddy proxy service — services/caddy/caddyProxy.ts
|
|
3
|
+
*
|
|
4
|
+
* One CaddyProxyManager instance per session key (§2.1/§2.2 of
|
|
5
|
+
* docs/local-tunnel-multiplexer-architecture-2026-07-31.md). Spawns a local
|
|
6
|
+
* `caddy` process holding EXACTLY ONE dynamic reverse-proxy upstream, bound
|
|
7
|
+
* to loopback only, repointed via Caddy's local (unauthenticated) admin API
|
|
8
|
+
* immediately before each tool dispatch that needs a different local port.
|
|
9
|
+
*
|
|
10
|
+
* This is the structural fix for the Feb 2026 path-prefix-routing bug
|
|
11
|
+
* (beads lb8/p6y/brl/vp9): there is no `/p/{port}` prefix, no path
|
|
12
|
+
* rewriting at all, so root-absolute requests (`/api/...`, `/_next/...`)
|
|
13
|
+
* never collide with a second port "in the way."
|
|
14
|
+
*
|
|
15
|
+
* See the architecture doc §2.2 for the full design rationale — this file
|
|
16
|
+
* is a direct implementation of it, not a reinterpretation.
|
|
17
|
+
*/
|
|
18
|
+
// NOTE: child_process/http are imported WITHOUT the 'node:' prefix
|
|
19
|
+
// deliberately — jest's unstable_mockModule (ESM mocking) cannot reliably
|
|
20
|
+
// intercept 'node:'-prefixed builtin specifiers in this repo's jest/ts-jest
|
|
21
|
+
// setup, but it can intercept the bare form (matches utils/gitContext.ts's
|
|
22
|
+
// existing 'child_process' mock convention). Mocked in
|
|
23
|
+
// __tests__/services/caddyProxy.test.ts.
|
|
24
|
+
import { spawn } from 'child_process';
|
|
25
|
+
import * as http from 'http';
|
|
26
|
+
import { createServer } from 'node:net';
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as os from 'node:os';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
32
|
+
import { Logger } from '../../utils/logger.js';
|
|
33
|
+
const logger = new Logger({ module: 'caddyProxy' });
|
|
34
|
+
// ── Error classes (house style: named subclasses of Error, see
|
|
35
|
+
// services/tunnels.ts's TunnelProvisionError for the pattern) ───────────
|
|
36
|
+
export class CaddyBinaryNotFoundError extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'CaddyBinaryNotFoundError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class CaddyStartupError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'CaddyStartupError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export class CaddyAdminApiError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'CaddyAdminApiError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export class CaddyPortReclaimError extends CaddyStartupError {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'CaddyPortReclaimError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ── Pure logic: HTTP/Docker upstream matrix ─────────────────────────────────
|
|
61
|
+
/**
|
|
62
|
+
* Caddy owns the full isHttpsLocal/inDocker/dockerHost resolution internally
|
|
63
|
+
* (architecture doc §2.2), reading `inDocker` exactly as
|
|
64
|
+
* tunnelManager.ts:688 does today (`process.env.DOCKER_CONTAINER === 'true'`).
|
|
65
|
+
* Callers pass only `{port, isHttpsLocal}` — the one fact Caddy cannot derive.
|
|
66
|
+
*
|
|
67
|
+
* The `localhost` (NOT `127.0.0.1`) asymmetry on the HTTPS/non-Docker branch
|
|
68
|
+
* is preserved verbatim from tunnelManager.ts:698, per explicit brief in the
|
|
69
|
+
* architecture doc — do not "fix" this to 127.0.0.1.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveDialAddress(port, isHttpsLocal, inDocker) {
|
|
72
|
+
const dockerHost = 'host.docker.internal';
|
|
73
|
+
if (isHttpsLocal)
|
|
74
|
+
return inDocker ? `${dockerHost}:${port}` : `localhost:${port}`; // NOT 127.0.0.1 — preserved verbatim
|
|
75
|
+
return inDocker ? `${dockerHost}:${port}` : `127.0.0.1:${port}`;
|
|
76
|
+
}
|
|
77
|
+
/** Whether the current process is running inside Docker, per DOCKER_CONTAINER env var. */
|
|
78
|
+
export function isDockerEnv() {
|
|
79
|
+
return process.env.DOCKER_CONTAINER === 'true';
|
|
80
|
+
}
|
|
81
|
+
// ── Bundled binary resolution ────────────────────────────────────────────────
|
|
82
|
+
//
|
|
83
|
+
// @radically-straightforward/caddy is a dependency (package.json's "caddy"
|
|
84
|
+
// field pins the exact version — see its postinstall) that downloads Caddy
|
|
85
|
+
// from the project's own GitHub releases and drops it at
|
|
86
|
+
// node_modules/.bin/caddy(.exe). Same pattern this repo already uses for the
|
|
87
|
+
// ngrok binary (the "ngrok" npm package's own postinstall). Pinned rather
|
|
88
|
+
// than "latest" deliberately: a real config incompatibility with a Caddy
|
|
89
|
+
// version (--adapter json rejected by 2.11.3) was found and fixed during
|
|
90
|
+
// development of this file — "latest" silently shipping a breaking change
|
|
91
|
+
// under us is exactly the failure mode pinning avoids.
|
|
92
|
+
//
|
|
93
|
+
// Precedence (resolveCaddyBinary): CADDY_BIN env → caddyBinOverride ctor opt
|
|
94
|
+
// → this bundled binary → bare 'caddy' resolved from PATH (last resort, e.g.
|
|
95
|
+
// a system install with no bundled binary present for some reason).
|
|
96
|
+
let _bundledCaddyBinaryCache; // undefined = not yet resolved
|
|
97
|
+
/** Same "walk up to my own package.json" pattern as config/index.ts's
|
|
98
|
+
* findPackageVersion() — finds this package's root regardless of whether
|
|
99
|
+
* it's a repo checkout, a global install, or an npx cache dir. */
|
|
100
|
+
function findOwnPackageRoot() {
|
|
101
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
while (true) {
|
|
103
|
+
try {
|
|
104
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
|
105
|
+
if (pkg.name === '@debugg-ai/debugg-ai-mcp')
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
catch { /* keep walking */ }
|
|
109
|
+
const parent = path.dirname(dir);
|
|
110
|
+
if (parent === dir)
|
|
111
|
+
return undefined;
|
|
112
|
+
dir = parent;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Resolves (and caches) the path to the bundled Caddy binary, if present.
|
|
116
|
+
* Returns undefined if @radically-straightforward/caddy's postinstall never
|
|
117
|
+
* ran or failed (e.g. npm install --ignore-scripts) — resolveCaddyBinary()
|
|
118
|
+
* falls through to a bare 'caddy' PATH lookup in that case. */
|
|
119
|
+
export function findBundledCaddyBinary() {
|
|
120
|
+
if (_bundledCaddyBinaryCache !== undefined)
|
|
121
|
+
return _bundledCaddyBinaryCache ?? undefined;
|
|
122
|
+
const root = findOwnPackageRoot();
|
|
123
|
+
if (!root) {
|
|
124
|
+
_bundledCaddyBinaryCache = null;
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const binName = process.platform === 'win32' ? 'caddy.exe' : 'caddy';
|
|
128
|
+
const binPath = path.join(root, 'node_modules', '.bin', binName);
|
|
129
|
+
_bundledCaddyBinaryCache = fs.existsSync(binPath) ? binPath : null;
|
|
130
|
+
return _bundledCaddyBinaryCache ?? undefined;
|
|
131
|
+
}
|
|
132
|
+
/** Test-only: clears the memoized bundled-binary lookup. */
|
|
133
|
+
export function _resetBundledCaddyBinaryCacheForTests() {
|
|
134
|
+
_bundledCaddyBinaryCache = undefined;
|
|
135
|
+
}
|
|
136
|
+
// ── Config file location ─────────────────────────────────────────────────────
|
|
137
|
+
/** ~/.debugg-ai/caddy — config files live here, one per (pid, instanceId). */
|
|
138
|
+
export function caddyConfigDir() {
|
|
139
|
+
return path.join(os.homedir(), '.debugg-ai', 'caddy');
|
|
140
|
+
}
|
|
141
|
+
const CONFIG_FILE_RE = /^config-(\d+)-[a-f0-9]+\.json$/;
|
|
142
|
+
function configFileName(pid, instanceId) {
|
|
143
|
+
return `config-${pid}-${instanceId}.json`;
|
|
144
|
+
}
|
|
145
|
+
function isPidAlive(pid) {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(pid, 0);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
// EPERM means the process exists but we lack permission to signal it —
|
|
152
|
+
// still alive. Anything else (ESRCH etc.) means it's gone.
|
|
153
|
+
return err.code === 'EPERM';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Startup orphan sweep: removes config files left behind by processes that
|
|
158
|
+
* are no longer alive. Runs once per MCP process lifetime (module-level
|
|
159
|
+
* guard) — cheap, but pointless to repeat per session-key instance.
|
|
160
|
+
*/
|
|
161
|
+
let orphanSweepDone = false;
|
|
162
|
+
export function sweepOrphanedConfigs(dir = caddyConfigDir()) {
|
|
163
|
+
if (orphanSweepDone)
|
|
164
|
+
return;
|
|
165
|
+
orphanSweepDone = true;
|
|
166
|
+
let entries;
|
|
167
|
+
try {
|
|
168
|
+
entries = fs.readdirSync(dir);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return; // directory doesn't exist yet — nothing to sweep
|
|
172
|
+
}
|
|
173
|
+
for (const name of entries) {
|
|
174
|
+
const m = CONFIG_FILE_RE.exec(name);
|
|
175
|
+
if (!m)
|
|
176
|
+
continue;
|
|
177
|
+
const pid = Number(m[1]);
|
|
178
|
+
if (pid === process.pid)
|
|
179
|
+
continue;
|
|
180
|
+
if (!isPidAlive(pid)) {
|
|
181
|
+
try {
|
|
182
|
+
fs.unlinkSync(path.join(dir, name));
|
|
183
|
+
logger.debug(`Swept orphaned Caddy config file: ${name}`);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
logger.debug(`Failed to remove orphaned Caddy config file ${name}: ${err}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Test-only: allow re-running the sweep within one process. */
|
|
192
|
+
export function _resetOrphanSweepForTests() {
|
|
193
|
+
orphanSweepDone = false;
|
|
194
|
+
}
|
|
195
|
+
// ── findFreePort() — bind :0 and close ──────────────────────────────────────
|
|
196
|
+
export function findFreePort() {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const srv = createServer();
|
|
199
|
+
srv.unref();
|
|
200
|
+
srv.once('error', reject);
|
|
201
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
202
|
+
const address = srv.address();
|
|
203
|
+
if (address && typeof address === 'object') {
|
|
204
|
+
const port = address.port;
|
|
205
|
+
srv.close((closeErr) => {
|
|
206
|
+
if (closeErr)
|
|
207
|
+
reject(closeErr);
|
|
208
|
+
else
|
|
209
|
+
resolve(port);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
srv.close(() => reject(new Error('findFreePort: could not determine bound port')));
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// ── Config builder ────────────────────────────────────────────────────────
|
|
219
|
+
/** The single addressable handler node every PATCH targets: GET/PATCH /id/dbg-handler. */
|
|
220
|
+
export const CADDY_HANDLER_ID = 'dbg-handler';
|
|
221
|
+
/** Placeholder upstream: dials a closed port so a stray request 502s cleanly
|
|
222
|
+
* from the first millisecond instead of exposing a raw connection-refused. */
|
|
223
|
+
const PLACEHOLDER_DIAL = '127.0.0.1:1';
|
|
224
|
+
/**
|
|
225
|
+
* Builds the eager-at-spawn Caddy JSON config. One code path for every call
|
|
226
|
+
* including the first (architecture doc §2.2): PATCH-only against a known
|
|
227
|
+
* @id-tagged node so config-shape drift 400s loudly instead of silently
|
|
228
|
+
* building new structure. Both listeners bind 127.0.0.1 only — the admin
|
|
229
|
+
* API has no built-in auth, so this is a hard security requirement.
|
|
230
|
+
*/
|
|
231
|
+
export function buildCaddyConfig(proxyPort, adminPort) {
|
|
232
|
+
return {
|
|
233
|
+
admin: { listen: `127.0.0.1:${adminPort}` },
|
|
234
|
+
apps: {
|
|
235
|
+
http: {
|
|
236
|
+
servers: {
|
|
237
|
+
srv0: {
|
|
238
|
+
listen: [`127.0.0.1:${proxyPort}`],
|
|
239
|
+
routes: [
|
|
240
|
+
{
|
|
241
|
+
handle: [
|
|
242
|
+
{
|
|
243
|
+
'@id': CADDY_HANDLER_ID,
|
|
244
|
+
handler: 'reverse_proxy',
|
|
245
|
+
// DO NOT add header_up Host — see
|
|
246
|
+
// docs/local-tunnel-multiplexer-architecture-2026-07-31.md §2.2;
|
|
247
|
+
// this would change what every local dev server sees vs. today.
|
|
248
|
+
upstreams: [{ dial: PLACEHOLDER_DIAL }],
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Builds the PATCH body for a real upstream target. One PATCH atomically
|
|
261
|
+
* replaces both `dial` and `transport` — never two separate requests — so
|
|
262
|
+
* Caddy is never briefly holding a mismatched dial/transport pair. `@id`
|
|
263
|
+
* MUST be resent in every PATCH body: PATCH replaces the value at that
|
|
264
|
+
* address, so an omitted `@id` deletes the tag and the NEXT PATCH 404s
|
|
265
|
+
* (only manifests on the second call — see LIFE-4 in the test suite).
|
|
266
|
+
*/
|
|
267
|
+
export function buildPatchBody(target, inDocker) {
|
|
268
|
+
const isHttpsLocal = !!target.isHttpsLocal;
|
|
269
|
+
const dial = resolveDialAddress(target.port, isHttpsLocal, inDocker);
|
|
270
|
+
const body = {
|
|
271
|
+
'@id': CADDY_HANDLER_ID,
|
|
272
|
+
handler: 'reverse_proxy',
|
|
273
|
+
upstreams: [{ dial }],
|
|
274
|
+
};
|
|
275
|
+
if (isHttpsLocal) {
|
|
276
|
+
// Local self-signed certs — doesn't touch the public leg's real ngrok TLS.
|
|
277
|
+
body.transport = { protocol: 'http', tls: { insecure_skip_verify: true } };
|
|
278
|
+
}
|
|
279
|
+
return body;
|
|
280
|
+
}
|
|
281
|
+
function adminHttpRequest(adminPort, method, reqPath, body, timeoutMs = 2000) {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
const req = http.request({
|
|
284
|
+
host: '127.0.0.1',
|
|
285
|
+
port: adminPort,
|
|
286
|
+
path: reqPath,
|
|
287
|
+
method,
|
|
288
|
+
headers: body != null
|
|
289
|
+
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
290
|
+
: undefined,
|
|
291
|
+
timeout: timeoutMs,
|
|
292
|
+
}, (res) => {
|
|
293
|
+
let data = '';
|
|
294
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
295
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }));
|
|
296
|
+
});
|
|
297
|
+
req.on('timeout', () => req.destroy(new Error(`admin API request timed out after ${timeoutMs}ms`)));
|
|
298
|
+
req.on('error', reject);
|
|
299
|
+
if (body != null)
|
|
300
|
+
req.write(body);
|
|
301
|
+
req.end();
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
async function probeAdminHealthy(adminPort) {
|
|
305
|
+
try {
|
|
306
|
+
const res = await adminHttpRequest(adminPort, 'GET', `/id/${CADDY_HANDLER_ID}`, undefined, 500);
|
|
307
|
+
return res.status === 200;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
export class CaddyProxyManager {
|
|
314
|
+
instanceId;
|
|
315
|
+
configDir;
|
|
316
|
+
startTimeoutMs;
|
|
317
|
+
probeIntervalMs;
|
|
318
|
+
caddyBinOverride;
|
|
319
|
+
child = null;
|
|
320
|
+
proxyPort = null;
|
|
321
|
+
adminPort = null;
|
|
322
|
+
started = false;
|
|
323
|
+
startPromise = null;
|
|
324
|
+
configPath = null;
|
|
325
|
+
lastProxyPort = null;
|
|
326
|
+
lastAppliedTarget = null;
|
|
327
|
+
portChangedListeners = [];
|
|
328
|
+
constructor(opts = {}) {
|
|
329
|
+
// Short random hex suffix — PID alone collides once one process hosts
|
|
330
|
+
// multiple session keys (HTTP mode). Must stay pure lowercase-hex: it's
|
|
331
|
+
// matched by CONFIG_FILE_RE during the orphan sweep below.
|
|
332
|
+
this.instanceId = randomBytes(6).toString('hex');
|
|
333
|
+
this.configDir = opts.configDir ?? caddyConfigDir();
|
|
334
|
+
this.startTimeoutMs = opts.startTimeoutMs ?? 5000;
|
|
335
|
+
this.probeIntervalMs = opts.probeIntervalMs ?? 100;
|
|
336
|
+
this.caddyBinOverride = opts.caddyBinOverride;
|
|
337
|
+
}
|
|
338
|
+
// -- CaddyProxy interface -------------------------------------------------
|
|
339
|
+
async ensureStarted() {
|
|
340
|
+
if (this.started && this.child && !this.isChildDead) {
|
|
341
|
+
return this.currentHandle();
|
|
342
|
+
}
|
|
343
|
+
if (this.startPromise) {
|
|
344
|
+
await this.startPromise;
|
|
345
|
+
return this.currentHandle();
|
|
346
|
+
}
|
|
347
|
+
this.startPromise = this.doStart().finally(() => {
|
|
348
|
+
this.startPromise = null;
|
|
349
|
+
});
|
|
350
|
+
await this.startPromise;
|
|
351
|
+
return this.currentHandle();
|
|
352
|
+
}
|
|
353
|
+
async setUpstream(target) {
|
|
354
|
+
const { adminPort } = await this.ensureStarted();
|
|
355
|
+
const isHttpsLocal = !!target.isHttpsLocal;
|
|
356
|
+
if (this.lastAppliedTarget &&
|
|
357
|
+
this.lastAppliedTarget.port === target.port &&
|
|
358
|
+
this.lastAppliedTarget.isHttpsLocal === isHttpsLocal) {
|
|
359
|
+
return; // idempotent no-op — this is what makes same-port calls free
|
|
360
|
+
}
|
|
361
|
+
const body = buildPatchBody(target, isDockerEnv());
|
|
362
|
+
try {
|
|
363
|
+
await this.patchHandler(adminPort, body);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
if (err instanceof CaddyAdminApiError) {
|
|
367
|
+
// Process is alive but the admin API rejected the PATCH — no respawn,
|
|
368
|
+
// propagate as-is.
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
// Network-level failure against a previously-healthy admin port —
|
|
372
|
+
// the process is presumed dead. Exactly one respawn-and-retry, then
|
|
373
|
+
// propagate uncaught — no loop (matches ngrokAgentSession.ts's
|
|
374
|
+
// onTerminated philosophy: lazy, bounded, next-call-triggered).
|
|
375
|
+
logger.warn(`Caddy admin API unreachable on port ${adminPort} — assuming process died, respawning once: ${err}`);
|
|
376
|
+
this.markDead();
|
|
377
|
+
const { adminPort: freshAdminPort } = await this.ensureStarted();
|
|
378
|
+
await this.patchHandler(freshAdminPort, body);
|
|
379
|
+
}
|
|
380
|
+
this.lastAppliedTarget = { port: target.port, isHttpsLocal };
|
|
381
|
+
}
|
|
382
|
+
async isHealthy() {
|
|
383
|
+
if (!this.started || !this.adminPort || this.isChildDead)
|
|
384
|
+
return false;
|
|
385
|
+
try {
|
|
386
|
+
return await probeAdminHealthy(this.adminPort);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async stop() {
|
|
393
|
+
const child = this.child;
|
|
394
|
+
this.started = false;
|
|
395
|
+
this.child = null;
|
|
396
|
+
this.proxyPort = null;
|
|
397
|
+
this.adminPort = null;
|
|
398
|
+
this.lastAppliedTarget = null;
|
|
399
|
+
if (child && !this.isChildDeadRef(child)) {
|
|
400
|
+
try {
|
|
401
|
+
child.kill();
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
logger.debug(`caddy.stop(): kill() failed (already dead?): ${err}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (this.configPath) {
|
|
408
|
+
try {
|
|
409
|
+
fs.unlinkSync(this.configPath);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// best effort — file may already be gone
|
|
413
|
+
}
|
|
414
|
+
this.configPath = null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
onPortChanged(cb) {
|
|
418
|
+
this.portChangedListeners.push(cb);
|
|
419
|
+
}
|
|
420
|
+
// -- internals --------------------------------------------------------------
|
|
421
|
+
get isChildDead() {
|
|
422
|
+
return this.child ? this.isChildDeadRef(this.child) : true;
|
|
423
|
+
}
|
|
424
|
+
isChildDeadRef(child) {
|
|
425
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
426
|
+
}
|
|
427
|
+
markDead() {
|
|
428
|
+
this.started = false;
|
|
429
|
+
this.child = null;
|
|
430
|
+
}
|
|
431
|
+
currentHandle() {
|
|
432
|
+
if (!this.started || this.proxyPort == null || this.adminPort == null) {
|
|
433
|
+
throw new CaddyStartupError('CaddyProxyManager: not started (internal invariant violation)');
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
localOrigin: `http://127.0.0.1:${this.proxyPort}`,
|
|
437
|
+
localPort: this.proxyPort,
|
|
438
|
+
adminPort: this.adminPort,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
resolveCaddyBinary() {
|
|
442
|
+
return process.env.CADDY_BIN || this.caddyBinOverride || findBundledCaddyBinary() || 'caddy';
|
|
443
|
+
}
|
|
444
|
+
writeConfig(proxyPort, adminPort) {
|
|
445
|
+
fs.mkdirSync(this.configDir, { recursive: true });
|
|
446
|
+
const configPath = path.join(this.configDir, configFileName(process.pid, this.instanceId));
|
|
447
|
+
const config = buildCaddyConfig(proxyPort, adminPort);
|
|
448
|
+
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
449
|
+
return configPath;
|
|
450
|
+
}
|
|
451
|
+
async patchHandler(adminPort, body) {
|
|
452
|
+
const json = JSON.stringify(body);
|
|
453
|
+
const res = await adminHttpRequest(adminPort, 'PATCH', `/id/${CADDY_HANDLER_ID}`, json);
|
|
454
|
+
if (res.status < 200 || res.status >= 300) {
|
|
455
|
+
throw new CaddyAdminApiError(`PATCH /id/${CADDY_HANDLER_ID} failed: ${res.status} ${res.body}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Waits for the admin API's readiness probe (GET /id/dbg-handler, every
|
|
460
|
+
* `probeIntervalMs`, capped at `startTimeoutMs`) racing the child's own
|
|
461
|
+
* `exit`/`error` events — an early crash or a missing binary must fail
|
|
462
|
+
* fast, not wait out the full timeout.
|
|
463
|
+
*/
|
|
464
|
+
waitForHealthy(adminPort, child) {
|
|
465
|
+
return new Promise((resolve, reject) => {
|
|
466
|
+
let settled = false;
|
|
467
|
+
// `interval` is referenced inside `finish` below before its own `const`
|
|
468
|
+
// declaration further down this block — safe because `finish` is only
|
|
469
|
+
// ever CALLED from an async event, by which point `interval` has
|
|
470
|
+
// already been assigned (closures resolve free variables at call
|
|
471
|
+
// time, not at definition time).
|
|
472
|
+
const finish = (fn) => {
|
|
473
|
+
if (settled)
|
|
474
|
+
return;
|
|
475
|
+
settled = true;
|
|
476
|
+
clearInterval(interval);
|
|
477
|
+
child.off('exit', onExit);
|
|
478
|
+
child.off('error', onError);
|
|
479
|
+
fn();
|
|
480
|
+
};
|
|
481
|
+
const onExit = (code, signal) => {
|
|
482
|
+
finish(() => reject(new CaddyStartupError(`caddy process exited during startup (code=${code}, signal=${signal})`)));
|
|
483
|
+
};
|
|
484
|
+
const onError = (err) => {
|
|
485
|
+
finish(() => {
|
|
486
|
+
if (err.code === 'ENOENT') {
|
|
487
|
+
reject(new CaddyBinaryNotFoundError(`caddy binary not found (tried "${this.resolveCaddyBinary()}"). This should have been ` +
|
|
488
|
+
`installed automatically by the @radically-straightforward/caddy dependency — if you ran ` +
|
|
489
|
+
`npm install with --ignore-scripts, or in an offline/air-gapped environment, that download ` +
|
|
490
|
+
`never ran. Fix by either installing caddy yourself ("brew install caddy" on macOS, ` +
|
|
491
|
+
`"apt install caddy" on Debian/Ubuntu, or see https://caddyserver.com/docs/install) and ` +
|
|
492
|
+
`pointing CADDY_BIN at it, or re-running npm install with scripts enabled.`));
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
reject(new CaddyStartupError(String(err)));
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
};
|
|
499
|
+
child.once('exit', onExit);
|
|
500
|
+
child.once('error', onError);
|
|
501
|
+
const deadline = Date.now() + this.startTimeoutMs;
|
|
502
|
+
const check = async () => {
|
|
503
|
+
if (settled)
|
|
504
|
+
return;
|
|
505
|
+
const ok = await probeAdminHealthy(adminPort);
|
|
506
|
+
if (ok) {
|
|
507
|
+
finish(resolve);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (Date.now() >= deadline) {
|
|
511
|
+
finish(() => reject(new CaddyStartupError(`caddy admin API did not become healthy within ${this.startTimeoutMs}ms`)));
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const interval = setInterval(check, this.probeIntervalMs);
|
|
515
|
+
check();
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Sticky proxy port across crash-respawn: attempt 1 reuses the last
|
|
520
|
+
* successfully-bound proxy port (if any); attempt 2 falls back to a fresh
|
|
521
|
+
* one. `CaddyPortReclaimError` means BOTH attempts failed — Caddy is fully
|
|
522
|
+
* down. `onPortChanged` fires on the "succeeded but moved" case, which is
|
|
523
|
+
* the signal TunnelManager needs to evict the now-orphaned ngrok tunnel.
|
|
524
|
+
*/
|
|
525
|
+
async doStart() {
|
|
526
|
+
sweepOrphanedConfigs(this.configDir);
|
|
527
|
+
const bin = this.resolveCaddyBinary();
|
|
528
|
+
let stickyAttemptFailed = false;
|
|
529
|
+
const priorProxyPort = this.lastProxyPort;
|
|
530
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
531
|
+
const useSticky = attempt === 1 && priorProxyPort != null;
|
|
532
|
+
const proxyPort = useSticky ? priorProxyPort : await findFreePort();
|
|
533
|
+
const adminPort = await findFreePort();
|
|
534
|
+
const configPath = this.writeConfig(proxyPort, adminPort);
|
|
535
|
+
// NOTE: no --adapter flag. The config file is already Caddy's native
|
|
536
|
+
// JSON format; passing `--adapter json` errors on modern Caddy
|
|
537
|
+
// ("unrecognized config adapter: json") — adapters are only for
|
|
538
|
+
// non-native formats (e.g. Caddyfile) that need converting INTO JSON.
|
|
539
|
+
// Verified directly against the real caddy binary (v2.11.3) during
|
|
540
|
+
// implementation of this file.
|
|
541
|
+
const child = spawn(bin, ['run', '--config', configPath], {
|
|
542
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
543
|
+
});
|
|
544
|
+
try {
|
|
545
|
+
await this.waitForHealthy(adminPort, child);
|
|
546
|
+
this.child = child;
|
|
547
|
+
this.proxyPort = proxyPort;
|
|
548
|
+
this.adminPort = adminPort;
|
|
549
|
+
this.configPath = configPath;
|
|
550
|
+
this.started = true;
|
|
551
|
+
this.lastProxyPort = proxyPort;
|
|
552
|
+
this.lastAppliedTarget = null;
|
|
553
|
+
this.installExitHandler(child);
|
|
554
|
+
if (priorProxyPort != null && proxyPort !== priorProxyPort) {
|
|
555
|
+
logger.error(`Caddy proxy port changed on respawn: ${priorProxyPort} -> ${proxyPort}`);
|
|
556
|
+
for (const cb of this.portChangedListeners) {
|
|
557
|
+
try {
|
|
558
|
+
cb(`http://127.0.0.1:${proxyPort}`);
|
|
559
|
+
}
|
|
560
|
+
catch (cbErr) {
|
|
561
|
+
logger.warn(`onPortChanged listener threw: ${cbErr}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
try {
|
|
569
|
+
child.kill();
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// already dead
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
fs.unlinkSync(configPath);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// best effort
|
|
579
|
+
}
|
|
580
|
+
if (err instanceof CaddyBinaryNotFoundError) {
|
|
581
|
+
// Retrying with a different port can never fix a missing binary —
|
|
582
|
+
// fail fast, don't burn the second attempt.
|
|
583
|
+
throw err;
|
|
584
|
+
}
|
|
585
|
+
if (useSticky)
|
|
586
|
+
stickyAttemptFailed = true;
|
|
587
|
+
if (attempt === 2) {
|
|
588
|
+
throw stickyAttemptFailed
|
|
589
|
+
? new CaddyPortReclaimError(`Both proxy-port reclaim and fresh-port fallback failed: ${err}`)
|
|
590
|
+
: new CaddyStartupError(String(err));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/** Restart policy: lazy, bounded, next-call-triggered — no background
|
|
596
|
+
* watchdog (matches ngrokAgentSession.ts's onTerminated philosophy). This
|
|
597
|
+
* handler only marks state as dead; it never itself triggers a respawn. */
|
|
598
|
+
installExitHandler(child) {
|
|
599
|
+
child.once('exit', (code, signal) => {
|
|
600
|
+
if (this.child === child) {
|
|
601
|
+
logger.warn(`Caddy process exited unexpectedly (code=${code}, signal=${signal})`);
|
|
602
|
+
this.started = false;
|
|
603
|
+
this.child = null;
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** Default factory — one fresh instance per session key (never a process-wide singleton). */
|
|
609
|
+
export function createCaddyProxy(opts) {
|
|
610
|
+
return new CaddyProxyManager(opts);
|
|
611
|
+
}
|