cabletidy 0.2.0 → 0.3.0-rc.3

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/src/server.mjs DELETED
@@ -1,1335 +0,0 @@
1
- import http from "node:http";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { Readable, Transform } from "node:stream";
6
- import { pipeline } from "node:stream/promises";
7
- import { StringDecoder } from "node:string_decoder";
8
- import { randomBytes } from "node:crypto";
9
- import { acquireInstanceLock } from "./instance-lock.mjs";
10
-
11
- import {
12
- applySecretPayload,
13
- backupFile,
14
- computeConfigDiff,
15
- defaultConfig,
16
- ensureStore,
17
- getPaths,
18
- loadConfig,
19
- loadSecrets,
20
- normalizeConfig,
21
- publicConfig,
22
- readRuntimeInfo,
23
- resolveUpstreamSecret,
24
- saveConfig,
25
- saveSecrets,
26
- secretRefForUpstream,
27
- stripSecretFields,
28
- writeRuntimeInfo,
29
- } from "./config.mjs";
30
- import {
31
- applyCodexArtifacts,
32
- CODEX_NATIVE_PROVIDER_INTEGRATION,
33
- CODEX_NATIVE_REQUIRED_ENDPOINTS,
34
- CODEX_TARGET_FORMAT,
35
- } from "./codex-native-provider.mjs";
36
- import {
37
- publicTargetArtifacts,
38
- prepareTargetArtifacts,
39
- CLAUDE_ENDPOINTS,
40
- CLAUDE_TARGET_FORMAT,
41
- } from "./target-artifacts.mjs";
42
- import { applyClaudeSettings, restoreClaudeSettings } from "./claude-config-file.mjs";
43
- import {
44
- listClientModels,
45
- resolveModelProfile,
46
- resolveRequest,
47
- rewriteModelFields,
48
- selectBackend,
49
- } from "./model-resolver.mjs";
50
- import { validateConfig } from "./validation.mjs";
51
- import { configurationBaseUrl, providerIdForConfiguration } from "../web/config-identity.js";
52
- import { forceStopCodexCatalog, loadCodexCatalog, publicCodexCatalog, validateCodexChanges } from "./codex-catalog.mjs";
53
-
54
- const PROJECT_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
55
- const WEB_ROOT = path.join(PROJECT_ROOT, "web");
56
- const MAX_BODY_BYTES = 8 * 1024 * 1024;
57
- const IMPLEMENTED_INGRESS_PROTOCOLS = new Set([
58
- "openai.responses",
59
- "anthropic.messages",
60
- ]);
61
- const CODEX_NATIVE_MODEL_PATH = CODEX_NATIVE_REQUIRED_ENDPOINTS.find(
62
- (endpoint) => endpoint.purpose === "model_discovery",
63
- ).path;
64
- const CODEX_NATIVE_RESPONSES_PATH = CODEX_NATIVE_REQUIRED_ENDPOINTS.find(
65
- (endpoint) => endpoint.purpose === "responses",
66
- ).path;
67
-
68
- export async function createApplication(options = {}) {
69
- const paths = options.paths || getPaths();
70
- await fs.mkdir(paths.home, { recursive: true, mode: 0o700 });
71
- const { release, identity } = await acquireInstanceLock(paths);
72
- try {
73
- const app = await createLockedApplication(options, paths, identity);
74
- let closing;
75
- return {
76
- ...app,
77
- close() {
78
- return closing ||= app.close().finally(release);
79
- },
80
- };
81
- } catch (error) {
82
- await release();
83
- throw error;
84
- }
85
- }
86
-
87
- async function createLockedApplication(options, paths, identity) {
88
- await ensureStore(paths);
89
- let config = await loadConfig(paths);
90
- const allocatePort = config === null;
91
- config ||= defaultConfig();
92
- const secrets = await loadSecrets(paths);
93
- const startupValidation = validateConfig(config);
94
- if (!startupValidation.ok) {
95
- const details = startupValidation.errors
96
- .map((item) => `${item.path}: ${item.message}`)
97
- .join("; ");
98
- throw new Error(`配置校验失败,daemon 未启动: ${details}`);
99
- }
100
-
101
- const state = {
102
- paths,
103
- identity,
104
- config,
105
- secrets,
106
- events: [],
107
- health: new Map(),
108
- startedAt: new Date().toISOString(),
109
- webServer: null,
110
- configOperation: Promise.resolve(),
111
- loadCodexCatalog: options.loadCodexCatalog || loadCodexCatalog,
112
- codexHome: options.codexHome,
113
- claudeHome: options.claudeHome,
114
- };
115
-
116
- let webServer = null;
117
- const requests = new Set();
118
- const host = config.web.listenHost || "127.0.0.1";
119
- let port = Number(config.web.port);
120
- try {
121
- webServer = http.createServer((request, response) => {
122
- const task = handleWebRequest(state, request, response).catch((error) => {
123
- if (!response.headersSent) {
124
- sendJson(response, 500, { error: { code: "internal_error", message: error.message } });
125
- } else {
126
- response.destroy(error);
127
- }
128
- }).finally(() => requests.delete(task));
129
- requests.add(task);
130
- });
131
- state.webServer = webServer;
132
- try {
133
- await listen(webServer, host, port);
134
- } catch (error) {
135
- if (error.code !== "EADDRINUSE") throw error;
136
- if (!allocatePort) {
137
- throw Object.assign(new Error(
138
- `端口 ${host}:${port} 已被占用 (EADDRINUSE)。请停止占用端口的服务,或修改 ${paths.config} 中的 web.port 后重启,并重新应用客户端配置。`,
139
- { cause: error },
140
- ), { code: error.code });
141
- }
142
- // Keep the OS-assigned listener open so nobody can claim the chosen port.
143
- await listen(webServer, host, 0);
144
- }
145
- port = webServer.address().port;
146
- config.web.port = port;
147
-
148
- await saveSecrets(secrets, paths);
149
- if (allocatePort) await saveConfig(config, paths);
150
- await persistRuntimeInfo(state, host, port);
151
- } catch (error) {
152
- if (webServer) await closeServer(webServer).catch(() => {});
153
- throw error;
154
- }
155
-
156
- function updateConfig(nextConfig, nextSecrets) {
157
- state.config = nextConfig;
158
- state.secrets = nextSecrets;
159
- }
160
-
161
- state.updateConfig = updateConfig;
162
-
163
- const close = async () => {
164
- await closeServer(webServer);
165
- // Disconnected clients can still have probes, child processes or writes in flight.
166
- await Promise.all(requests);
167
- await state.configOperation;
168
- const runtime = await readRuntimeInfo(paths);
169
- if (runtime?.pid === process.pid) {
170
- await fs.unlink(paths.runtime).catch(() => {});
171
- }
172
- };
173
-
174
- return {
175
- state,
176
- close,
177
- url: formatWebUrl(host, port),
178
- };
179
- }
180
-
181
- async function handleWebRequest(state, request, response) {
182
- const url = new URL(request.url, "http://127.0.0.1");
183
- if (url.pathname.startsWith("/api/")) {
184
- await handleApiRequest(state, request, response, url);
185
- return;
186
- }
187
- const match = url.pathname.match(/^\/([a-z0-9][a-z0-9_-]{0,53})(\/.*)?$/);
188
- if (!match) {
189
- await serveStatic(url.pathname, response);
190
- return;
191
- }
192
-
193
- // Capture one configuration snapshot for the entire request, including streams.
194
- const { config, secrets } = state;
195
- const id = providerIdForConfiguration(match[1]);
196
- const provider = Object.hasOwn(config.virtualProviders, id) ? config.virtualProviders[id] : null;
197
- const protocol = provider?.ingressProtocol || "openai.responses";
198
- if (!isAllowedWebRequest(state, request)) {
199
- sendProtocolError(response, protocol, 403, "origin_not_allowed", "只允许来自当前本地服务的请求");
200
- return;
201
- }
202
- if (!provider) {
203
- sendProtocolError(response, protocol, 404, "not_found", `配置入口不存在: ${match[1]}`);
204
- return;
205
- }
206
- if (provider.enabled === false) {
207
- sendProtocolError(response, protocol, 503, "provider_paused", "此配置的服务已暂停");
208
- return;
209
- }
210
- url.pathname = match[2] || "/";
211
- try {
212
- await handleProxyRequest(state, config, secrets, id, provider, request, response, url);
213
- } catch (error) {
214
- if (!response.headersSent) sendProtocolError(response, protocol, 500, "internal_error", error.message);
215
- else response.destroy(error);
216
- }
217
- }
218
-
219
- function enqueueConfigUpdate(state, update) {
220
- const task = state.configOperation.then(update);
221
- state.configOperation = task.catch(() => {});
222
- return task;
223
- }
224
-
225
- async function handleApiRequest(state, request, response, url) {
226
- if (!isAllowedWebRequest(state, request)) {
227
- sendJson(response, 403, {
228
- error: { code: "origin_not_allowed", message: "只允许来自当前本地管理台的请求" },
229
- });
230
- return;
231
- }
232
-
233
- if (request.method === "OPTIONS") {
234
- const origin = request.headers.origin;
235
- response.writeHead(204, {
236
- ...(origin ? { "access-control-allow-origin": origin } : {}),
237
- "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
238
- "Access-Control-Allow-Headers": "content-type",
239
- });
240
- response.end();
241
- return;
242
- }
243
-
244
- const method = request.method;
245
- let body = {};
246
- if (method === "POST" || method === "PATCH") {
247
- try {
248
- body = await readJson(request);
249
- } catch (error) {
250
- sendJson(response, error.status || 400, {
251
- error: { code: error.code || "invalid_json", message: error.message },
252
- });
253
- return;
254
- }
255
- }
256
-
257
- if (url.pathname === "/api/v1/codex/models" && method === "GET") {
258
- try {
259
- sendJson(response, 200, publicCodexCatalog(await state.loadCodexCatalog({ refresh: url.searchParams.get("refresh") === "1" })));
260
- } catch (error) {
261
- sendJson(response, 200, { available: false, models: [], error: { message: error.message } });
262
- }
263
- return;
264
- }
265
-
266
- if (url.pathname === "/api/v1/catalog" && method === "GET") {
267
- sendJson(response, 200, {
268
- protocols: [
269
- "openai.responses",
270
- "openai.chat_completions",
271
- "anthropic.messages",
272
- "gemini.generate_content",
273
- ],
274
- implementedProtocols: [...IMPLEMENTED_INGRESS_PROTOCOLS],
275
- integrations: [CODEX_NATIVE_PROVIDER_INTEGRATION],
276
- requiredEndpoints: {
277
- [CODEX_NATIVE_PROVIDER_INTEGRATION]: CODEX_NATIVE_REQUIRED_ENDPOINTS,
278
- "claude-code": CLAUDE_ENDPOINTS,
279
- },
280
- targetFormats: [CODEX_TARGET_FORMAT, CLAUDE_TARGET_FORMAT, "claude.env.v1", "generic.env.v1"],
281
- targets: ["codex", "claude-code", "generic-env"],
282
- capabilities: [
283
- "streaming",
284
- "tools",
285
- "parallel_tool_calls",
286
- "vision",
287
- "reasoning",
288
- "prompt_cache",
289
- ],
290
- });
291
- return;
292
- }
293
-
294
- if (url.pathname === "/api/v1/config" && method === "GET") {
295
- sendJson(response, 200, { config: redactConfig(state.config, state.secrets) });
296
- return;
297
- }
298
-
299
- if (url.pathname === "/api/v1/runtime" && method === "GET") {
300
- sendJson(response, 200, runtimeStatus(state));
301
- return;
302
- }
303
-
304
- if (url.pathname === "/api/v1/events" && method === "GET") {
305
- sendJson(response, 200, { events: state.events.slice(-100).reverse() });
306
- return;
307
- }
308
-
309
- if (url.pathname === "/api/v1/integrations" && method === "GET") {
310
- sendJson(response, 200, {
311
- integrations: [
312
- {
313
- id: CODEX_NATIVE_PROVIDER_INTEGRATION,
314
- label: "Codex Native Provider Integration",
315
- capabilities: ["models", "responses", "streaming"],
316
- requiredEndpoints: CODEX_NATIVE_REQUIRED_ENDPOINTS,
317
- note: "CableTidy 直接管理上游连接、模型映射和本地 Virtual Provider",
318
- },
319
- ],
320
- });
321
- return;
322
- }
323
-
324
- if (url.pathname === "/api/v1/config/validate" && method === "POST") {
325
- const candidate = normalizeConfig(stripPresentationFields(body.config || state.config));
326
- const result = validateConfig(candidate);
327
- if (result.ok) result.errors.push(...await validateCodexChanges(candidate, state.config, state.loadCodexCatalog));
328
- result.ok = result.errors.length === 0;
329
- sendJson(response, result.ok ? 200 : 422, {
330
- ok: result.ok,
331
- errors: result.errors,
332
- warnings: result.warnings,
333
- diff: computeConfigDiff(state.config, result.config),
334
- config: redactConfig(result.config, state.secrets),
335
- });
336
- return;
337
- }
338
-
339
- if (url.pathname === "/api/v1/config/commit" && method === "POST") {
340
- await enqueueConfigUpdate(state, () => commitConfig(state, body, response));
341
- return;
342
- }
343
-
344
- const virtualProviderAction = url.pathname.match(
345
- /^\/api\/v1\/virtual-providers\/([^/]+)\/(start|pause)$/,
346
- );
347
- if (virtualProviderAction && method === "POST") {
348
- await enqueueConfigUpdate(state, () => setVirtualProviderState(
349
- state,
350
- decodeURIComponent(virtualProviderAction[1]),
351
- virtualProviderAction[2] === "start",
352
- response,
353
- ));
354
- return;
355
- }
356
-
357
- if (url.pathname === "/api/v1/tests/model-resolve" && method === "POST") {
358
- const config = normalizeConfig(body.config || state.config);
359
- const provider = config.virtualProviders?.[body.virtualProviderId];
360
- if (!provider) {
361
- sendJson(response, 404, { error: { code: "virtual_provider_not_found", message: "Virtual Provider 不存在" } });
362
- return;
363
- }
364
- try {
365
- const result = resolveRequest(config, provider, {
366
- model: body.model,
367
- stream: Boolean(body.stream),
368
- tools: body.tools || [],
369
- tool_choice: body.tool_choice,
370
- reasoning: Boolean(body.reasoning),
371
- thinking: body.thinking,
372
- });
373
- sendJson(response, 200, {
374
- ok: true,
375
- requestedModel: body.model || null,
376
- profileId: result.model.profileId,
377
- clientModelId: result.model.clientModelId,
378
- upstreamId: result.upstream.id,
379
- upstreamModelId: result.upstreamModelId,
380
- routeId: result.routeId,
381
- capabilities: result.capabilities,
382
- rejectedBackends: [],
383
- });
384
- } catch (error) {
385
- sendJson(response, 422, {
386
- ok: false,
387
- error: {
388
- code: error.code || "model_resolution_failed",
389
- message: error.message,
390
- details: error.details,
391
- },
392
- });
393
- }
394
- return;
395
- }
396
-
397
- if (url.pathname === "/api/v1/tests/upstream" && method === "POST") {
398
- await testUpstream(state, body, response);
399
- return;
400
- }
401
-
402
- if (method === "POST" && url.pathname === "/api/v1/config/preview-target-artifacts") {
403
- await previewTargetArtifacts(state, body, response);
404
- return;
405
- }
406
-
407
- if (method === "POST" && url.pathname === "/api/v1/targets/apply") {
408
- await applyTarget(state, body, response);
409
- return;
410
- }
411
-
412
- if (method === "POST" && url.pathname === "/api/v1/targets/restore") {
413
- try {
414
- const binding = state.config.bindings[body.bindingId];
415
- if (binding?.target !== "claude-code") throw new Error("请选择 Claude Code 配置");
416
- const report = await restoreClaudeSettings(body.bindingId, { paths: state.paths, claudeHome: state.claudeHome });
417
- recordEvent(state, "target.restore", { bindingId: body.bindingId, target: "claude-code" });
418
- sendJson(response, 200, { ok: true, target: "claude-code", report });
419
- } catch (error) {
420
- sendJson(response, 422, { error: { code: "target_restore_failed", message: error.message } });
421
- }
422
- return;
423
- }
424
-
425
- sendJson(response, 404, { error: { code: "not_found", message: "API endpoint 不存在" } });
426
- }
427
-
428
- async function commitConfig(state, body, response) {
429
- const baseRevision = Number(body.baseRevision);
430
- if (baseRevision !== state.config.revision) {
431
- sendJson(response, 409, {
432
- error: {
433
- code: "revision_conflict",
434
- message: `配置已经变更,当前 revision 是 ${state.config.revision}`,
435
- currentRevision: state.config.revision,
436
- },
437
- diff: computeConfigDiff(state.config, stripPresentationFields(body.config || state.config)),
438
- config: redactConfig(state.config, state.secrets),
439
- });
440
- return;
441
- }
442
-
443
- const candidate = normalizeConfig(stripPresentationFields(body.config || {}));
444
- candidate.revision = state.config.revision + 1;
445
- const candidateSecrets = applySecretPayload(candidate, state.secrets, body);
446
- const validation = validateConfig(candidate);
447
- if (validation.ok) validation.errors.push(...await validateCodexChanges(candidate, state.config, state.loadCodexCatalog));
448
- validation.ok = validation.errors.length === 0;
449
- const diff = computeConfigDiff(state.config, candidate);
450
- if (!validation.ok) {
451
- sendJson(response, 422, {
452
- error: { code: "config_invalid", message: "配置校验失败" },
453
- errors: validation.errors,
454
- warnings: validation.warnings,
455
- diff,
456
- });
457
- return;
458
- }
459
- if (
460
- candidate.web.listenHost !== state.config.web.listenHost ||
461
- Number(candidate.web.port) !== Number(state.config.web.port)
462
- ) {
463
- sendJson(response, 422, {
464
- error: {
465
- code: "web_listener_restart_required",
466
- message: "Web 管理台监听地址或端口需要重启 daemon 后才能修改",
467
- },
468
- diff,
469
- });
470
- return;
471
- }
472
-
473
- const previousConfig = state.config;
474
- const previousSecrets = state.secrets;
475
- const activeWebHost = state.config.web.listenHost || "127.0.0.1";
476
- const activeWebPort = Number(state.config.web.port || 43100);
477
- let configWritten = false;
478
- let secretsWritten = false;
479
-
480
- try {
481
- await backupFile(state.paths.config, state.paths.backups, `config-${candidate.revision}.json`);
482
- await backupFile(state.paths.secrets, state.paths.backups, `secrets-${candidate.revision}.json`);
483
- await saveConfig(candidate, state.paths);
484
- configWritten = true;
485
- await saveSecrets(candidateSecrets, state.paths);
486
- secretsWritten = true;
487
- state.updateConfig(candidate, candidateSecrets);
488
- try {
489
- await persistRuntimeInfo(
490
- state,
491
- activeWebHost,
492
- activeWebPort,
493
- );
494
- } catch (error) {
495
- recordEvent(state, "runtime.persist_failed", { message: error.message });
496
- }
497
- recordEvent(state, "config.commit", {
498
- revision: candidate.revision,
499
- virtualProviders: Object.keys(candidate.virtualProviders),
500
- upstreams: Object.keys(candidate.upstreams),
501
- });
502
- } catch (error) {
503
- if (configWritten) await saveConfig(previousConfig, state.paths).catch(() => {});
504
- if (secretsWritten) await saveSecrets(previousSecrets, state.paths).catch(() => {});
505
- state.updateConfig(previousConfig, previousSecrets);
506
- sendJson(response, 500, {
507
- error: { code: "config_reload_failed", message: error.message },
508
- });
509
- return;
510
- }
511
- sendJson(response, 200, {
512
- ok: true,
513
- revision: candidate.revision,
514
- diff,
515
- config: redactConfig(candidate, candidateSecrets),
516
- runtime: runtimeStatus(state),
517
- });
518
- }
519
-
520
- async function setVirtualProviderState(state, id, enabled, response) {
521
- const currentProvider = state.config.virtualProviders?.[id];
522
- if (!currentProvider) {
523
- sendJson(response, 404, {
524
- error: { code: "virtual_provider_not_found", message: `Virtual Provider 不存在: ${id}` },
525
- });
526
- return;
527
- }
528
-
529
- const previousConfig = state.config;
530
- const previousSecrets = state.secrets;
531
- const candidate = normalizeConfig(state.config);
532
- candidate.virtualProviders[id] = {
533
- ...candidate.virtualProviders[id],
534
- enabled,
535
- };
536
- candidate.revision = state.config.revision + 1;
537
- const validation = validateConfig(candidate);
538
- if (!validation.ok) {
539
- sendJson(response, 422, {
540
- error: { code: "config_invalid", message: "配置校验失败" },
541
- errors: validation.errors,
542
- warnings: validation.warnings,
543
- });
544
- return;
545
- }
546
-
547
- try {
548
- await backupFile(state.paths.config, state.paths.backups, `config-${candidate.revision}.json`);
549
- await saveConfig(candidate, state.paths);
550
- state.updateConfig(candidate, state.secrets);
551
- await persistRuntimeInfo(
552
- state,
553
- state.config.web.listenHost || "127.0.0.1",
554
- Number(state.config.web.port || 43100),
555
- ).catch((error) => recordEvent(state, "runtime.persist_failed", { message: error.message }));
556
- recordEvent(state, enabled ? "virtual_provider.start" : "virtual_provider.pause", {
557
- virtualProviderId: id,
558
- revision: candidate.revision,
559
- });
560
- sendJson(response, 200, {
561
- ok: true,
562
- virtualProviderId: id,
563
- enabled,
564
- revision: candidate.revision,
565
- config: redactConfig(candidate, state.secrets),
566
- runtime: runtimeStatus(state),
567
- });
568
- } catch (error) {
569
- state.updateConfig(previousConfig, previousSecrets);
570
- sendJson(response, 500, {
571
- error: { code: "virtual_provider_state_failed", message: error.message },
572
- });
573
- }
574
- }
575
-
576
- async function testUpstream(state, body, response) {
577
- const id = body?.id;
578
- const config = body?.config
579
- ? normalizeConfig(stripPresentationFields(body.config))
580
- : state.config;
581
- const secrets = body?.config
582
- ? applySecretPayload(config, state.secrets, body)
583
- : state.secrets;
584
- const upstream = config.upstreams?.[id];
585
- if (!upstream) {
586
- sendJson(response, 404, { error: { code: "upstream_not_found", message: "upstream 不存在" } });
587
- return;
588
- }
589
- const started = Date.now();
590
- const controller = new AbortController();
591
- const timeout = setTimeout(() => controller.abort(), 10_000);
592
- try {
593
- const headers = { accept: "application/json" };
594
- const secret = resolveUpstreamSecret(upstream, secrets);
595
- applyUpstreamAuth(headers, upstream, upstream.protocol, secret);
596
- const result = await fetch(upstream.baseUrl, {
597
- method: "GET",
598
- headers,
599
- signal: controller.signal,
600
- });
601
- const reachable = result.status < 500 || result.status === 501;
602
- await result.body?.cancel().catch(() => {});
603
- recordHealth(state, id, result.ok ? "success" : "response", result.status);
604
- sendJson(response, 200, {
605
- ok: reachable,
606
- status: result.status,
607
- latencyMs: Date.now() - started,
608
- secretConfigured: Boolean(secret),
609
- message: reachable ? "上游可连接" : "上游返回服务端错误",
610
- });
611
- } catch (error) {
612
- recordHealth(state, id, "failure", null);
613
- sendJson(response, 200, {
614
- ok: false,
615
- latencyMs: Date.now() - started,
616
- secretConfigured: Boolean(resolveUpstreamSecret(upstream, secrets)),
617
- message: error.name === "AbortError" ? "连接超时" : error.message,
618
- });
619
- } finally {
620
- clearTimeout(timeout);
621
- }
622
- }
623
-
624
- async function previewTargetArtifacts(state, body, response) {
625
- try {
626
- const config = normalizeConfig(body.config || state.config);
627
- const secrets = applySecretPayload(config, state.secrets, body);
628
- const artifacts = await prepareTargetArtifacts(config, {
629
- bindingId: body.bindingId,
630
- loadCatalog: state.loadCodexCatalog,
631
- codexHome: state.codexHome,
632
- claudeHome: state.claudeHome,
633
- }, secrets);
634
- sendJson(response, 200, {
635
- ok: true,
636
- artifacts: publicTargetArtifacts(artifacts),
637
- });
638
- } catch (error) {
639
- sendJson(response, 422, {
640
- error: { code: "target_artifact_preview_failed", message: error.message },
641
- });
642
- }
643
- }
644
-
645
- async function applyTarget(state, body, response) {
646
- try {
647
- const artifacts = await prepareTargetArtifacts(state.config, {
648
- bindingId: body.bindingId,
649
- loadCatalog: state.loadCodexCatalog,
650
- codexHome: state.codexHome,
651
- claudeHome: state.claudeHome,
652
- }, state.secrets);
653
- if (!["codex", "claude-code"].includes(artifacts.target)) {
654
- sendJson(response, 501, {
655
- error: {
656
- code: "target_apply_not_supported",
657
- message: `${artifacts.target} 当前没有可持久化写入的客户端配置;请使用管理台预览并将环境变量注入客户端`,
658
- },
659
- target: artifacts.target,
660
- bindingId: artifacts.bindingId,
661
- artifacts: publicTargetArtifacts(artifacts),
662
- });
663
- return;
664
- }
665
- const report = artifacts.target === "claude-code"
666
- ? await applyClaudeSettings(artifacts, { paths: state.paths, claudeHome: state.claudeHome })
667
- : await applyCodexArtifacts(artifacts, { paths: state.paths, codexHome: state.codexHome });
668
- recordEvent(state, "target.apply", {
669
- bindingId: artifacts.bindingId,
670
- target: artifacts.target,
671
- files: report.applied,
672
- });
673
- sendJson(response, 200, {
674
- ok: true,
675
- target: artifacts.target,
676
- report: {
677
- ...report,
678
- environment: publicTargetArtifacts({ ...artifacts, ...report }).environment,
679
- },
680
- });
681
- } catch (error) {
682
- sendJson(response, 422, {
683
- error: { code: "target_apply_failed", message: error.message },
684
- });
685
- }
686
- }
687
-
688
- async function handleProxyRequest(state, config, secrets, virtualProviderId, provider, request, response, url) {
689
- if (request.method === "OPTIONS") {
690
- response.writeHead(204);
691
- response.end();
692
- return;
693
- }
694
-
695
- const protocol = provider.ingressProtocol;
696
- const isClaude = protocol === "anthropic.messages";
697
-
698
- if (!IMPLEMENTED_INGRESS_PROTOCOLS.has(protocol)) {
699
- sendProtocolError(
700
- response,
701
- protocol,
702
- 501,
703
- "unsupported_protocol",
704
- `当前 MVP 尚未实现 ingress protocol: ${protocol}`,
705
- );
706
- return;
707
- }
708
-
709
- if (isClaude && request.method === "HEAD" && url.pathname === "/api/hello") {
710
- response.writeHead(204, { "cache-control": "no-store" });
711
- response.end();
712
- return;
713
- }
714
- if (isClaude && request.method === "GET" && url.pathname === "/v1/models") {
715
- const limit = url.searchParams.get("limit") ?? "1000";
716
- if (!/^\d+$/.test(limit) || Number(limit) < 1 || Number(limit) > 1000) {
717
- sendProtocolError(response, protocol, 400, "invalid_request_error", "limit 必须是 1 到 1000 之间的整数");
718
- return;
719
- }
720
- let models = listClientModels(config, provider).map((model) => ({
721
- id: model.id, type: "model", display_name: model.id,
722
- }));
723
- const after = url.searchParams.get("after_id");
724
- if (after) {
725
- const index = models.findIndex((model) => model.id === after);
726
- if (index < 0) {
727
- sendProtocolError(response, protocol, 400, "invalid_request_error", "after_id 不在当前模型列表中");
728
- return;
729
- }
730
- models = models.slice(index + 1);
731
- }
732
- const data = models.slice(0, Number(limit));
733
- sendJson(response, 200, { data, has_more: models.length > data.length,
734
- first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null });
735
- return;
736
- }
737
- if (
738
- protocol === "openai.responses" &&
739
- request.method === "GET" &&
740
- (url.pathname === CODEX_NATIVE_MODEL_PATH || url.pathname === "/models")
741
- ) {
742
- const models = listClientModels(config, provider);
743
- sendJson(response, 200, { object: "list", data: models });
744
- return;
745
- }
746
- const countTokens = isClaude && url.pathname === "/v1/messages/count_tokens";
747
- const supportedPaths = isClaude
748
- ? ["/v1/messages", "/messages", "/v1/messages/count_tokens"]
749
- : [CODEX_NATIVE_RESPONSES_PATH, "/responses"];
750
- if (request.method !== "POST" || !supportedPaths.includes(url.pathname)) {
751
- sendProtocolError(
752
- response,
753
- protocol,
754
- 404,
755
- "not_found",
756
- protocol === "anthropic.messages"
757
- ? "支持 POST /v1/messages、POST /v1/messages/count_tokens、GET /v1/models 和 HEAD /api/hello"
758
- : "只支持 POST /v1/responses 和 GET /v1/models",
759
- );
760
- return;
761
- }
762
-
763
- let body;
764
- try {
765
- body = await readJson(request);
766
- } catch (error) {
767
- sendProtocolError(
768
- response,
769
- protocol,
770
- error.status || 400,
771
- error.code || "invalid_request_error",
772
- error.message,
773
- );
774
- return;
775
- }
776
- if (!body || typeof body !== "object" || Array.isArray(body)) {
777
- sendProtocolError(response, protocol, 400, "invalid_request_error", "请求 body 必须是 JSON object");
778
- return;
779
- }
780
- let resolution;
781
- try {
782
- resolution = resolveModelProfile(config, provider, body.model);
783
- } catch (error) {
784
- sendProtocolError(response, protocol, 400, error.code || "invalid_request_error", error.message);
785
- return;
786
- }
787
-
788
- const upstreamAbort = new AbortController();
789
- const abortUpstream = () => upstreamAbort.abort();
790
- request.once("aborted", abortUpstream);
791
- response.once("close", () => {
792
- if (!response.writableFinished) upstreamAbort.abort();
793
- });
794
- let selected;
795
- try {
796
- selected = selectBackend(
797
- config,
798
- provider,
799
- resolution,
800
- countTokens ? {} : body,
801
- );
802
- } catch (error) {
803
- sendProtocolError(response, protocol, 503, error.code || "upstream_unavailable", error.message);
804
- return;
805
- }
806
- const outgoing = structuredClone(body);
807
- // Only the protocol model field is mapped; prompts and tool payloads stay intact.
808
- outgoing.model = selected.upstreamModelId;
809
- const upstreamUrl = joinUpstreamUrl(selected.upstream.baseUrl, url.pathname, url.search);
810
- const upstreamHeaders = {
811
- "content-type": "application/json",
812
- accept: request.headers.accept || "text/event-stream, application/json",
813
- };
814
- copyProtocolHeaders(request, upstreamHeaders, protocol);
815
- const secret = resolveUpstreamSecret(selected.upstream, secrets);
816
- applyUpstreamAuth(upstreamHeaders, selected.upstream, protocol, secret);
817
- const started = Date.now();
818
- try {
819
- const upstreamResponse = await fetch(upstreamUrl, {
820
- method: "POST",
821
- headers: upstreamHeaders,
822
- body: JSON.stringify(outgoing),
823
- signal: upstreamAbort.signal,
824
- ...(isClaude ? { redirect: "manual" } : {}),
825
- });
826
- recordHealth(
827
- state,
828
- selected.upstream.id,
829
- upstreamResponse.ok ? "success" : "failure",
830
- upstreamResponse.status,
831
- );
832
- recordEvent(state, "proxy.request", {
833
- virtualProviderId,
834
- upstreamId: selected.upstream.id,
835
- clientModelId: resolution.clientModelId,
836
- upstreamModelId: selected.upstreamModelId,
837
- path: url.pathname,
838
- status: upstreamResponse.status,
839
- latencyMs: Date.now() - started,
840
- });
841
- await relayResponse(
842
- response,
843
- upstreamResponse,
844
- resolution.clientModelId,
845
- selected.upstreamModelId,
846
- protocol,
847
- countTokens,
848
- );
849
- return;
850
- } catch (error) {
851
- recordHealth(state, selected.upstream.id, "failure", null);
852
- if (upstreamAbort.signal.aborted) return;
853
- if (response.headersSent || response.writableEnded) {
854
- if (!response.destroyed) response.destroy(error);
855
- return;
856
- }
857
- sendProtocolError(response, protocol, 502, "upstream_unavailable", error.message);
858
- }
859
- }
860
-
861
- async function relayResponse(response, upstreamResponse, clientModelId, upstreamModelId, protocol, countTokens = false) {
862
- const contentType = upstreamResponse.headers.get("content-type") || "application/json";
863
- const isEventStream = contentType.includes("text/event-stream");
864
- const headers = {
865
- "content-type": contentType,
866
- "cache-control": upstreamResponse.headers.get("cache-control") || "no-cache",
867
- };
868
- for (const [name, value] of upstreamResponse.headers) {
869
- if (["x-request-id", "request-id", "retry-after", "x-should-retry"].includes(name) || name.startsWith("anthropic-ratelimit-")) headers[name] = value;
870
- }
871
- response.writeHead(upstreamResponse.status, headers);
872
- response.flushHeaders();
873
-
874
- if (!upstreamResponse.body) {
875
- response.end();
876
- return;
877
- }
878
-
879
- if (!upstreamResponse.ok || countTokens || clientModelId === upstreamModelId) {
880
- await pipeline(Readable.fromWeb(upstreamResponse.body), response);
881
- return;
882
- }
883
- const rewrite = protocol === "anthropic.messages" ? rewriteClaudeModel : rewriteModelFields;
884
-
885
- if (!isEventStream) {
886
- const text = await upstreamResponse.text();
887
- if (!text) {
888
- response.end();
889
- return;
890
- }
891
- try {
892
- const body = rewrite(JSON.parse(text), clientModelId, upstreamModelId);
893
- response.end(JSON.stringify(body));
894
- } catch {
895
- response.end(text);
896
- }
897
- return;
898
- }
899
-
900
- await pipeline(
901
- Readable.fromWeb(upstreamResponse.body),
902
- createSseRewriteTransform(clientModelId, upstreamModelId, rewrite),
903
- response,
904
- );
905
- }
906
-
907
- function rewriteClaudeModel(value, clientModelId, upstreamModelId) {
908
- if (value?.type === "message" && value.model === upstreamModelId) return { ...value, model: clientModelId };
909
- if (value?.type === "message_start" && value.message?.model === upstreamModelId) {
910
- return { ...value, message: { ...value.message, model: clientModelId } };
911
- }
912
- return value;
913
- }
914
-
915
- function createSseRewriteTransform(clientModelId, upstreamModelId, rewrite) {
916
- const decoder = new StringDecoder("utf8");
917
- let buffer = "";
918
-
919
- return new Transform({
920
- transform(chunk, encoding, callback) {
921
- try {
922
- buffer += Buffer.isBuffer(chunk)
923
- ? decoder.write(chunk)
924
- : String(chunk, encoding);
925
- const drained = drainSseBuffer(buffer, clientModelId, upstreamModelId, rewrite);
926
- buffer = drained.remaining;
927
- callback(null, drained.output || undefined);
928
- } catch (error) {
929
- callback(error);
930
- }
931
- },
932
- flush(callback) {
933
- try {
934
- buffer += decoder.end();
935
- callback(
936
- null,
937
- buffer
938
- ? rewriteSseEvent(buffer, clientModelId, upstreamModelId, rewrite)
939
- : undefined,
940
- );
941
- } catch (error) {
942
- callback(error);
943
- }
944
- },
945
- });
946
- }
947
-
948
- function drainSseBuffer(buffer, clientModelId, upstreamModelId, rewrite) {
949
- let remaining = buffer;
950
- let output = "";
951
- while (true) {
952
- const delimiter = remaining.match(/\r?\n\r?\n/);
953
- if (!delimiter || delimiter.index === undefined) break;
954
- const event = remaining.slice(0, delimiter.index);
955
- output += rewriteSseEvent(event, clientModelId, upstreamModelId, rewrite);
956
- output += delimiter[0];
957
- remaining = remaining.slice(delimiter.index + delimiter[0].length);
958
- }
959
- return { output, remaining };
960
- }
961
-
962
- function rewriteSseEvent(event, clientModelId, upstreamModelId, rewrite) {
963
- const separator = event.includes("\r\n") ? "\r\n" : "\n";
964
- const lines = event.split(/\r?\n/);
965
- const dataLines = [];
966
- for (const [index, line] of lines.entries()) {
967
- const match = line.match(/^data:(?: ?)(.*)$/);
968
- if (match) dataLines.push({ index, value: match[1] });
969
- }
970
- if (!dataLines.length) return event;
971
-
972
- const payload = dataLines.map((line) => line.value).join("\n").trim();
973
- if (!payload || payload === "[DONE]") return event;
974
- try {
975
- const parsed = JSON.parse(payload);
976
- const rewritten = rewrite(parsed, clientModelId, upstreamModelId);
977
- if (rewritten === parsed) return event;
978
- const first = dataLines[0];
979
- const prefix = lines[first.index].match(/^data:/)?.[0] || "data:";
980
- lines[first.index] = `${prefix} ${JSON.stringify(rewritten)}`;
981
- for (const line of dataLines.slice(1).reverse()) lines.splice(line.index, 1);
982
- return lines.join(separator);
983
- } catch {
984
- return event;
985
- }
986
- }
987
-
988
- function copyProtocolHeaders(request, target, protocol) {
989
- const names = protocol === "anthropic.messages"
990
- ? Object.keys(request.headers).filter((name) => name.startsWith("anthropic-") || name.startsWith("x-claude-code-"))
991
- : ["openai-beta", "openai-organization", "openai-project"];
992
- for (const name of names) {
993
- const value = request.headers[name];
994
- if (value) target[name] = value;
995
- }
996
- }
997
-
998
- function applyUpstreamAuth(headers, upstream, protocol, secret) {
999
- if (!secret) return;
1000
- const configuredHeader = upstream.auth?.header || upstream.authHeader;
1001
- const header = String(
1002
- configuredHeader ||
1003
- (protocol === "anthropic.messages" ? "x-api-key" : "authorization"),
1004
- ).toLowerCase();
1005
- if (header === "authorization") {
1006
- const scheme = upstream.auth?.scheme || "Bearer";
1007
- headers.authorization = `${scheme} ${secret}`;
1008
- } else {
1009
- headers[header] = secret;
1010
- }
1011
- }
1012
-
1013
- function joinUpstreamUrl(base, incomingPath, search) {
1014
- const target = new URL(base);
1015
- const basePath = target.pathname.replace(/\/+$/, "");
1016
- let requestPath = incomingPath || "/";
1017
- if (basePath.endsWith("/v1") && requestPath.startsWith("/v1/")) {
1018
- requestPath = requestPath.slice(3);
1019
- }
1020
- target.pathname = `${basePath}/${requestPath.replace(/^\/+/, "")}`.replace(/\/{2,}/g, "/");
1021
- if (search) {
1022
- const incoming = new URLSearchParams(search);
1023
- for (const [key, value] of incoming) target.searchParams.set(key, value);
1024
- }
1025
- return target.toString();
1026
- }
1027
-
1028
- function redactConfig(config, secrets) {
1029
- const copy = publicConfig(config);
1030
- for (const [id, upstream] of Object.entries(copy.upstreams || {})) {
1031
- upstream.secretRef ||= secretRefForUpstream(id);
1032
- upstream.secretConfigured = Boolean(resolveUpstreamSecret(config.upstreams[id], secrets));
1033
- }
1034
- return copy;
1035
- }
1036
-
1037
- function stripPresentationFields(value) {
1038
- const copy = stripSecretFields(value || {});
1039
- stripFields(copy);
1040
- return copy;
1041
- }
1042
-
1043
- function stripFields(value) {
1044
- if (Array.isArray(value)) {
1045
- for (const item of value) stripFields(item);
1046
- return;
1047
- }
1048
- if (!value || typeof value !== "object") return;
1049
- delete value.secretConfigured;
1050
- for (const item of Object.values(value)) stripFields(item);
1051
- }
1052
-
1053
- function runtimeStatus(state) {
1054
- const health = {};
1055
- for (const [id, item] of state.health.entries()) health[id] = item;
1056
- return {
1057
- pid: process.pid,
1058
- startedAt: state.startedAt,
1059
- revision: state.config.revision,
1060
- web: {
1061
- host: state.config.web.listenHost,
1062
- port: state.config.web.port,
1063
- status: "listening",
1064
- },
1065
- virtualProviders: Object.entries(state.config.virtualProviders).map(([id, provider]) => {
1066
- const configurationId = id.slice("cabletidy_".length);
1067
- const baseUrl = configurationBaseUrl(state.config, configurationId);
1068
- return {
1069
- id,
1070
- configurationId,
1071
- listen: new URL(baseUrl).host,
1072
- baseUrl: `${baseUrl}/v1`,
1073
- protocol: provider.ingressProtocol,
1074
- route: provider.route,
1075
- enabled: provider.enabled !== false,
1076
- status: provider.enabled === false ? "paused" : state.webServer?.listening ? "listening" : "not_listening",
1077
- };
1078
- }),
1079
- health,
1080
- counts: {
1081
- upstreams: Object.keys(state.config.upstreams || {}).length,
1082
- models: Object.keys(state.config.models || {}).length,
1083
- routes: Object.keys(state.config.routes || {}).length,
1084
- virtualProviders: Object.keys(state.config.virtualProviders || {}).length,
1085
- bindings: Object.keys(state.config.bindings || {}).length,
1086
- },
1087
- };
1088
- }
1089
-
1090
- async function persistRuntimeInfo(state, host, port) {
1091
- await writeRuntimeInfo(
1092
- {
1093
- pid: process.pid,
1094
- pidStartTime: state.identity.startTime,
1095
- startedAt: state.startedAt,
1096
- web: {
1097
- host,
1098
- port,
1099
- url: formatWebUrl(host, port),
1100
- },
1101
- revision: state.config.revision,
1102
- },
1103
- state.paths,
1104
- );
1105
- }
1106
-
1107
- function recordHealth(state, id, outcome, status) {
1108
- const previous = state.health.get(id) || { failures: 0 };
1109
- const failures = outcome === "success" ? 0 : previous.failures + 1;
1110
- state.health.set(id, {
1111
- outcome,
1112
- status,
1113
- failures,
1114
- lastCheckedAt: new Date().toISOString(),
1115
- });
1116
- }
1117
-
1118
- function recordEvent(state, type, data) {
1119
- state.events.push({
1120
- id: randomBytes(8).toString("hex"),
1121
- type,
1122
- at: new Date().toISOString(),
1123
- data,
1124
- });
1125
- if (state.events.length > 200) state.events.splice(0, state.events.length - 200);
1126
- }
1127
-
1128
- function isAllowedWebRequest(state, request) {
1129
- const configuredHost = String(state.config.web.listenHost || "127.0.0.1").toLowerCase();
1130
- const hostHeader = request.headers.host;
1131
- if (hostHeader) {
1132
- let requestHost;
1133
- let requestPort;
1134
- try {
1135
- const parsed = new URL(`http://${hostHeader}`);
1136
- requestHost = parsed.hostname.toLowerCase();
1137
- requestPort = Number(parsed.port || state.config.web.port);
1138
- } catch {
1139
- return false;
1140
- }
1141
- const expectedPort = Number(state.config.web.port);
1142
- if (requestPort !== expectedPort) return false;
1143
- if (!isAllowedHost(configuredHost, requestHost)) return false;
1144
- }
1145
-
1146
- const origin = request.headers.origin;
1147
- if (!origin) return true;
1148
- try {
1149
- const parsed = new URL(origin);
1150
- if (!["http:", "https:"].includes(parsed.protocol)) return false;
1151
- const originPort = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
1152
- if (originPort !== Number(state.config.web.port)) return false;
1153
- return isAllowedHost(configuredHost, parsed.hostname.toLowerCase());
1154
- } catch {
1155
- return false;
1156
- }
1157
- }
1158
-
1159
- function isAllowedHost(configuredHost, requestHost) {
1160
- const loopbackHosts = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
1161
- if (configuredHost === "0.0.0.0" || configuredHost === "::" || configuredHost === "[::]") {
1162
- return true;
1163
- }
1164
- if (loopbackHosts.has(configuredHost) || configuredHost === "[::1]") {
1165
- return loopbackHosts.has(requestHost);
1166
- }
1167
- return requestHost === configuredHost.replace(/^\[|\]$/g, "");
1168
- }
1169
-
1170
- async function serveStatic(requestPath, response) {
1171
- const relative = requestPath === "/" ? "index.html" : requestPath.replace(/^\/+/, "");
1172
- const file = path.resolve(WEB_ROOT, relative);
1173
- if (!file.startsWith(`${WEB_ROOT}${path.sep}`) && file !== path.join(WEB_ROOT, "index.html")) {
1174
- sendJson(response, 403, { error: { code: "forbidden", message: "forbidden" } });
1175
- return;
1176
- }
1177
- try {
1178
- const content = await fs.readFile(file);
1179
- response.writeHead(200, { "content-type": contentType(file), "cache-control": "no-cache" });
1180
- response.end(content);
1181
- } catch (error) {
1182
- if (error.code === "ENOENT") {
1183
- sendJson(response, 404, { error: { code: "not_found", message: "静态资源不存在" } });
1184
- } else {
1185
- throw error;
1186
- }
1187
- }
1188
- }
1189
-
1190
- function contentType(file) {
1191
- if (file.endsWith(".html")) return "text/html; charset=utf-8";
1192
- if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
1193
- if (file.endsWith(".css")) return "text/css; charset=utf-8";
1194
- return "application/octet-stream";
1195
- }
1196
-
1197
- async function readJson(request, limit = MAX_BODY_BYTES) {
1198
- const chunks = [];
1199
- let size = 0;
1200
- for await (const chunk of request) {
1201
- size += chunk.length;
1202
- if (size > limit) {
1203
- const error = new Error("request body too large");
1204
- error.code = "payload_too_large";
1205
- error.status = 413;
1206
- throw error;
1207
- }
1208
- chunks.push(chunk);
1209
- }
1210
- if (chunks.length === 0) return {};
1211
- const text = Buffer.concat(chunks).toString("utf8");
1212
- try {
1213
- return JSON.parse(text);
1214
- } catch {
1215
- const error = new Error("请求 body 不是合法 JSON");
1216
- error.code = "invalid_json";
1217
- error.status = 400;
1218
- throw error;
1219
- }
1220
- }
1221
-
1222
- function sendJson(response, status, body, extraHeaders = {}) {
1223
- response.writeHead(status, {
1224
- "content-type": "application/json; charset=utf-8",
1225
- "cache-control": "no-store",
1226
- "x-content-type-options": "nosniff",
1227
- "referrer-policy": "no-referrer",
1228
- "x-cabletidy": "cabletidy",
1229
- ...extraHeaders,
1230
- });
1231
- response.end(JSON.stringify(body));
1232
- }
1233
-
1234
- function sendOpenAIError(response, status, code, message) {
1235
- sendJson(response, status, {
1236
- error: {
1237
- type: "invalid_request_error",
1238
- code,
1239
- message,
1240
- },
1241
- });
1242
- }
1243
-
1244
- function sendProtocolError(response, protocol, status, code, message) {
1245
- if (protocol === "anthropic.messages") {
1246
- const errorType = code === "authentication_error"
1247
- ? "authentication_error"
1248
- : code === "rate_limit_error"
1249
- ? "rate_limit_error"
1250
- : code === "overloaded_error"
1251
- ? "overloaded_error"
1252
- : code === "not_found"
1253
- ? "not_found_error"
1254
- : status === 413 ? "request_too_large"
1255
- : status >= 500 ? "api_error" : "invalid_request_error";
1256
- sendJson(response, status, {
1257
- type: "error",
1258
- error: {
1259
- type: errorType,
1260
- message,
1261
- },
1262
- });
1263
- return;
1264
- }
1265
- sendOpenAIError(response, status, code, message);
1266
- }
1267
-
1268
- function listen(server, host, port) {
1269
- return new Promise((resolve, reject) => {
1270
- const onError = (error) => {
1271
- server.off("listening", onListening);
1272
- reject(error);
1273
- };
1274
- const onListening = () => {
1275
- server.off("error", onError);
1276
- resolve();
1277
- };
1278
- server.once("error", onError);
1279
- server.once("listening", onListening);
1280
- server.listen(port, host);
1281
- });
1282
- }
1283
-
1284
- function formatWebUrl(host, port) {
1285
- const value = String(host || "127.0.0.1");
1286
- const formattedHost = value.includes(":") && !value.startsWith("[")
1287
- ? `[${value}]`
1288
- : value;
1289
- return `http://${formattedHost}:${port}/`;
1290
- }
1291
-
1292
- function closeServer(server) {
1293
- if (!server.listening) return Promise.resolve();
1294
- return new Promise((resolve) => server.close(() => resolve()));
1295
- }
1296
-
1297
- export async function startDaemon() {
1298
- const app = await createApplication();
1299
- let stopping = false;
1300
- const stop = async (signal) => {
1301
- if (stopping) {
1302
- if (signal === "SIGINT") {
1303
- console.error("CableTidy 强制退出");
1304
- try {
1305
- forceStopCodexCatalog();
1306
- } finally {
1307
- process.exit(130);
1308
- }
1309
- }
1310
- return;
1311
- }
1312
- stopping = true;
1313
- console.log("正在停止 CableTidy,等待请求和清理完成;再次按 Ctrl+C 强制退出。");
1314
- try {
1315
- await app.close();
1316
- } catch (error) {
1317
- console.error(error.stack || error.message);
1318
- process.exitCode = 1;
1319
- } finally {
1320
- process.off("SIGINT", stop);
1321
- process.off("SIGTERM", stop);
1322
- }
1323
- };
1324
- process.on("SIGINT", stop);
1325
- process.on("SIGTERM", stop);
1326
- console.log(`CableTidy Web 管理台: ${app.url}`);
1327
- console.log("按 Ctrl+C 停止 daemon");
1328
- }
1329
-
1330
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
1331
- startDaemon().catch((error) => {
1332
- console.error(error.stack || error.message);
1333
- process.exitCode = 1;
1334
- });
1335
- }