devinfra-bridge 0.1.0-alpha.2 → 0.1.0-alpha.4
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/README.md +130 -18
- package/dist/agents/types.d.ts +5 -2
- package/dist/auth/bytedcli-provider.d.ts +7 -0
- package/dist/auth/credential-provider.d.ts +3 -0
- package/dist/daemon/auto-sync.d.ts +20 -0
- package/dist/daemon/daemon.d.ts +7 -3
- package/dist/daemon/single-instance.d.ts +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +520 -179
- package/dist/main.js +520 -179
- package/dist/proxy/errors.d.ts +2 -1
- package/dist/proxy/forward-model-request.d.ts +4 -0
- package/dist/proxy/headers.d.ts +3 -2
- package/dist/proxy/jwt-expired.d.ts +6 -0
- package/dist/util/logger.d.ts +7 -2
- package/docs/acceptance.md +14 -0
- package/docs/development.md +28 -10
- package/package.json +2 -2
- package/dist/proxy/forward-messages.d.ts +0 -3
package/dist/index.js
CHANGED
|
@@ -184,6 +184,8 @@ var BytedcliProvider = class {
|
|
|
184
184
|
controller = new AbortController();
|
|
185
185
|
pending;
|
|
186
186
|
cached;
|
|
187
|
+
latestJwt;
|
|
188
|
+
recovery;
|
|
187
189
|
timeoutMs;
|
|
188
190
|
outputBytes;
|
|
189
191
|
cacheMs;
|
|
@@ -191,24 +193,50 @@ var BytedcliProvider = class {
|
|
|
191
193
|
if (this.controller.signal.aborted || signal?.aborted) {
|
|
192
194
|
return Promise.reject(new CredentialError("AUTH_CANCELLED"));
|
|
193
195
|
}
|
|
196
|
+
if (this.pending) return this.wait(this.pending, signal);
|
|
194
197
|
if (this.cached && this.cached.until > Date.now()) return Promise.resolve(this.cached.jwt);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
return this.wait(this.track(this.acquire()), signal);
|
|
199
|
+
}
|
|
200
|
+
refreshJwt(failedJwt, { signal } = {}) {
|
|
201
|
+
if (this.controller.signal.aborted || signal?.aborted) {
|
|
202
|
+
return Promise.reject(new CredentialError("AUTH_CANCELLED"));
|
|
203
|
+
}
|
|
204
|
+
if (this.latestJwt && this.latestJwt !== failedJwt) {
|
|
205
|
+
return this.getJwt({ signal });
|
|
199
206
|
}
|
|
200
|
-
|
|
207
|
+
if (this.recovery?.failedJwt === failedJwt) return this.wait(this.recovery.promise, signal);
|
|
208
|
+
this.cached = void 0;
|
|
209
|
+
const earlier = this.pending;
|
|
210
|
+
const recovery = (async () => {
|
|
211
|
+
await earlier?.catch(() => {
|
|
212
|
+
});
|
|
213
|
+
if (this.controller.signal.aborted) throw new CredentialError("AUTH_CANCELLED");
|
|
214
|
+
if (this.latestJwt && this.latestJwt !== failedJwt) return this.latestJwt;
|
|
215
|
+
return this.acquire(true);
|
|
216
|
+
})();
|
|
217
|
+
this.recovery = { failedJwt, promise: recovery };
|
|
218
|
+
return this.wait(this.track(recovery), signal);
|
|
219
|
+
}
|
|
220
|
+
track(acquisition) {
|
|
221
|
+
const pending = acquisition.finally(() => {
|
|
222
|
+
if (this.pending === pending) this.pending = void 0;
|
|
223
|
+
});
|
|
224
|
+
this.pending = pending;
|
|
225
|
+
return pending;
|
|
226
|
+
}
|
|
227
|
+
wait(pending, signal) {
|
|
201
228
|
return new Promise((resolve, reject) => {
|
|
202
229
|
const onAbort = () => reject(new CredentialError("AUTH_CANCELLED"));
|
|
203
230
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
204
231
|
pending.then(resolve, reject).finally(() => signal?.removeEventListener("abort", onAbort));
|
|
232
|
+
if (signal?.aborted) onAbort();
|
|
205
233
|
});
|
|
206
234
|
}
|
|
207
|
-
async acquire() {
|
|
235
|
+
async acquire(forceRefresh = false) {
|
|
208
236
|
const script = [".js", ".mjs", ".cjs"].includes(extname2(this.executable));
|
|
209
237
|
const stdout = await runCredentialCommand(
|
|
210
238
|
script ? process.execPath : this.executable,
|
|
211
|
-
script ? [this.executable, ...JWT_ARGS] : JWT_ARGS,
|
|
239
|
+
script ? [this.executable, ...JWT_ARGS, ...forceRefresh ? ["--force-refresh"] : []] : [...JWT_ARGS, ...forceRefresh ? ["--force-refresh"] : []],
|
|
212
240
|
{ signal: this.controller.signal, timeoutMs: this.timeoutMs, outputBytes: this.outputBytes }
|
|
213
241
|
);
|
|
214
242
|
let result;
|
|
@@ -227,7 +255,8 @@ var BytedcliProvider = class {
|
|
|
227
255
|
}
|
|
228
256
|
} catch {
|
|
229
257
|
}
|
|
230
|
-
|
|
258
|
+
this.latestJwt = jwt;
|
|
259
|
+
this.cached = until > Date.now() ? { jwt, until } : void 0;
|
|
231
260
|
return jwt;
|
|
232
261
|
}
|
|
233
262
|
async close() {
|
|
@@ -235,6 +264,8 @@ var BytedcliProvider = class {
|
|
|
235
264
|
this.controller.abort();
|
|
236
265
|
await this.pending?.catch(() => {
|
|
237
266
|
});
|
|
267
|
+
this.latestJwt = void 0;
|
|
268
|
+
this.recovery = void 0;
|
|
238
269
|
}
|
|
239
270
|
};
|
|
240
271
|
|
|
@@ -298,36 +329,6 @@ import { randomUUID } from "node:crypto";
|
|
|
298
329
|
import { createServer } from "node:http";
|
|
299
330
|
import { Agent } from "node:https";
|
|
300
331
|
|
|
301
|
-
// src/proxy/forward-messages.ts
|
|
302
|
-
import { request } from "node:https";
|
|
303
|
-
import { pipeline } from "node:stream/promises";
|
|
304
|
-
|
|
305
|
-
// src/proxy/errors.ts
|
|
306
|
-
var ProxyError = class extends Error {
|
|
307
|
-
constructor(status, code, message) {
|
|
308
|
-
super(message);
|
|
309
|
-
this.status = status;
|
|
310
|
-
this.code = code;
|
|
311
|
-
}
|
|
312
|
-
status;
|
|
313
|
-
code;
|
|
314
|
-
};
|
|
315
|
-
function sendError(res, error) {
|
|
316
|
-
if (res.destroyed) return;
|
|
317
|
-
if (res.headersSent) {
|
|
318
|
-
res.destroy();
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
const type = error.status === 429 ? "rate_limit_error" : error.status === 413 ? "request_too_large" : error.status >= 500 ? "api_error" : "invalid_request_error";
|
|
322
|
-
const body = JSON.stringify({ type: "error", error: { type, message: error.message } });
|
|
323
|
-
res.writeHead(error.status, {
|
|
324
|
-
"content-type": "application/json",
|
|
325
|
-
"content-length": Buffer.byteLength(body),
|
|
326
|
-
connection: "close"
|
|
327
|
-
});
|
|
328
|
-
res.end(body);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
332
|
// src/agents/identity.ts
|
|
332
333
|
var AGENT_IDENTITIES = [
|
|
333
334
|
{ id: "cc", client: /^claude-cli(?:\/|\s|$)/i },
|
|
@@ -355,6 +356,121 @@ function identifyAgent(headers) {
|
|
|
355
356
|
return "x";
|
|
356
357
|
}
|
|
357
358
|
|
|
359
|
+
// src/proxy/forward-model-request.ts
|
|
360
|
+
import { request } from "node:https";
|
|
361
|
+
import { pipeline } from "node:stream/promises";
|
|
362
|
+
|
|
363
|
+
// src/util/logger.ts
|
|
364
|
+
import {
|
|
365
|
+
constants as constants3,
|
|
366
|
+
closeSync,
|
|
367
|
+
existsSync,
|
|
368
|
+
lstatSync,
|
|
369
|
+
openSync,
|
|
370
|
+
renameSync,
|
|
371
|
+
unlinkSync,
|
|
372
|
+
writeSync
|
|
373
|
+
} from "node:fs";
|
|
374
|
+
import { join as join2 } from "node:path";
|
|
375
|
+
var silentLogger = { write() {
|
|
376
|
+
} };
|
|
377
|
+
function safeTraceId(value) {
|
|
378
|
+
return typeof value === "string" && value.length <= 256 && /^[A-Za-z0-9]/.test(value) && !/[^A-Za-z0-9._:-]/.test(value) ? value : void 0;
|
|
379
|
+
}
|
|
380
|
+
function createLogger(stateRoot, maxBytes = 5 * 1024 * 1024) {
|
|
381
|
+
const path = join2(stateRoot, "daemon.log");
|
|
382
|
+
const safeFile = (file) => {
|
|
383
|
+
const info = lstatSync(file);
|
|
384
|
+
if (!info.isFile() || info.isSymbolicLink() || process.platform !== "win32" && info.uid !== process.getuid?.())
|
|
385
|
+
throw new Error();
|
|
386
|
+
return info;
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
write(record) {
|
|
390
|
+
try {
|
|
391
|
+
const line = `${JSON.stringify({
|
|
392
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
393
|
+
event: record.event,
|
|
394
|
+
request_id: record.request_id,
|
|
395
|
+
gateway_logid: safeTraceId(record.gateway_logid),
|
|
396
|
+
gateway_request_id: safeTraceId(record.gateway_request_id),
|
|
397
|
+
gateway_x_request_id: safeTraceId(record.gateway_x_request_id),
|
|
398
|
+
path: record.path,
|
|
399
|
+
protocol: record.protocol,
|
|
400
|
+
agent: record.agent,
|
|
401
|
+
duration_ms: record.duration_ms,
|
|
402
|
+
status: record.status,
|
|
403
|
+
exit_code: record.exit_code,
|
|
404
|
+
code: record.code
|
|
405
|
+
})}
|
|
406
|
+
`;
|
|
407
|
+
if (existsSync(path) && safeFile(path).size + Buffer.byteLength(line) > maxBytes) {
|
|
408
|
+
for (let i = 2; i >= 1; i--) {
|
|
409
|
+
const from = `${path}.${i}`;
|
|
410
|
+
const to = `${path}.${i + 1}`;
|
|
411
|
+
if (existsSync(from)) {
|
|
412
|
+
safeFile(from);
|
|
413
|
+
if (existsSync(to)) {
|
|
414
|
+
safeFile(to);
|
|
415
|
+
unlinkSync(to);
|
|
416
|
+
}
|
|
417
|
+
if (i === 2) unlinkSync(from);
|
|
418
|
+
else renameSync(from, to);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
renameSync(path, `${path}.1`);
|
|
422
|
+
}
|
|
423
|
+
const fd = openSync(
|
|
424
|
+
path,
|
|
425
|
+
constants3.O_WRONLY | constants3.O_APPEND | constants3.O_CREAT | constants3.O_NOFOLLOW,
|
|
426
|
+
384
|
|
427
|
+
);
|
|
428
|
+
try {
|
|
429
|
+
writeSync(fd, line);
|
|
430
|
+
} finally {
|
|
431
|
+
closeSync(fd);
|
|
432
|
+
}
|
|
433
|
+
} catch {
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/proxy/errors.ts
|
|
440
|
+
var ProxyError = class extends Error {
|
|
441
|
+
constructor(status, code, message) {
|
|
442
|
+
super(message);
|
|
443
|
+
this.status = status;
|
|
444
|
+
this.code = code;
|
|
445
|
+
}
|
|
446
|
+
status;
|
|
447
|
+
code;
|
|
448
|
+
};
|
|
449
|
+
function sendError(res, error, protocol = "messages") {
|
|
450
|
+
if (res.destroyed) return;
|
|
451
|
+
if (res.headersSent) {
|
|
452
|
+
res.destroy();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const type = error.status === 429 ? "rate_limit_error" : error.status === 413 ? "request_too_large" : error.status >= 500 ? "api_error" : "invalid_request_error";
|
|
456
|
+
const body = JSON.stringify(
|
|
457
|
+
protocol === "responses" ? {
|
|
458
|
+
error: {
|
|
459
|
+
type: error.status >= 500 ? "server_error" : type,
|
|
460
|
+
message: error.message,
|
|
461
|
+
code: error.code,
|
|
462
|
+
param: null
|
|
463
|
+
}
|
|
464
|
+
} : { type: "error", error: { type, message: error.message } }
|
|
465
|
+
);
|
|
466
|
+
res.writeHead(error.status, {
|
|
467
|
+
"content-type": "application/json",
|
|
468
|
+
"content-length": Buffer.byteLength(body),
|
|
469
|
+
connection: "close"
|
|
470
|
+
});
|
|
471
|
+
res.end(body);
|
|
472
|
+
}
|
|
473
|
+
|
|
358
474
|
// src/proxy/headers.ts
|
|
359
475
|
var HOP = /* @__PURE__ */ new Set([
|
|
360
476
|
"connection",
|
|
@@ -368,14 +484,31 @@ var HOP = /* @__PURE__ */ new Set([
|
|
|
368
484
|
"proxy-connection"
|
|
369
485
|
]);
|
|
370
486
|
var REQUEST = /* @__PURE__ */ new Set([
|
|
371
|
-
"anthropic-version",
|
|
372
|
-
"anthropic-beta",
|
|
373
487
|
"content-type",
|
|
374
488
|
"accept",
|
|
375
489
|
"accept-encoding",
|
|
376
490
|
"x-request-id",
|
|
377
|
-
"request-id"
|
|
491
|
+
"request-id",
|
|
492
|
+
"user-agent"
|
|
493
|
+
]);
|
|
494
|
+
var REQUEST_MESSAGES = /* @__PURE__ */ new Set(["anthropic-version", "anthropic-beta"]);
|
|
495
|
+
var REQUEST_RESPONSES = /* @__PURE__ */ new Set([
|
|
496
|
+
"originator",
|
|
497
|
+
"session-id",
|
|
498
|
+
"thread-id",
|
|
499
|
+
"session_id",
|
|
500
|
+
"conversation_id",
|
|
501
|
+
"x-client-request-id",
|
|
502
|
+
"openai-beta",
|
|
503
|
+
"x-codex-installation-id",
|
|
504
|
+
"x-codex-turn-state",
|
|
505
|
+
"x-codex-turn-metadata",
|
|
506
|
+
"x-codex-parent-thread-id",
|
|
507
|
+
"x-codex-window-id",
|
|
508
|
+
"x-codex-beta-features",
|
|
509
|
+
"x-openai-subagent"
|
|
378
510
|
]);
|
|
511
|
+
var RESPONSE_RESPONSES = /* @__PURE__ */ new Set(["x-codex-turn-state", "openai-model", "x-reasoning-included"]);
|
|
379
512
|
var RESPONSE = /* @__PURE__ */ new Set([
|
|
380
513
|
"x-tt-logid",
|
|
381
514
|
"content-type",
|
|
@@ -391,11 +524,13 @@ function blocked(headers) {
|
|
|
391
524
|
set.add(name.trim().toLowerCase());
|
|
392
525
|
return set;
|
|
393
526
|
}
|
|
394
|
-
function requestHeaders(incoming, jwt, bytes, agent) {
|
|
527
|
+
function requestHeaders(incoming, jwt, bytes, agent, protocol = "messages") {
|
|
395
528
|
const result = {};
|
|
396
529
|
const deny = blocked(incoming);
|
|
530
|
+
const native = protocol === "responses" ? REQUEST_RESPONSES : REQUEST_MESSAGES;
|
|
397
531
|
for (const [name, value] of Object.entries(incoming)) {
|
|
398
|
-
if (REQUEST.has(name) && !deny.has(name) && value !== void 0)
|
|
532
|
+
if ((REQUEST.has(name) || native.has(name)) && !deny.has(name) && value !== void 0)
|
|
533
|
+
result[name] = value;
|
|
399
534
|
}
|
|
400
535
|
result["content-length"] = bytes;
|
|
401
536
|
result["x-identity-token"] = jwt;
|
|
@@ -407,17 +542,81 @@ function requestHeaders(incoming, jwt, bytes, agent) {
|
|
|
407
542
|
result["x-tt-env"] = "ppe_devinfra_bridge";
|
|
408
543
|
return result;
|
|
409
544
|
}
|
|
410
|
-
function responseHeaders(incoming) {
|
|
545
|
+
function responseHeaders(incoming, protocol = "messages") {
|
|
411
546
|
const result = {};
|
|
412
547
|
const deny = blocked(incoming);
|
|
413
548
|
for (const [name, value] of Object.entries(incoming)) {
|
|
414
|
-
if (!deny.has(name) && value !== void 0 && (RESPONSE.has(name) || /^anthropic-ratelimit-[a-z0-9-]+$/.test(name)))
|
|
549
|
+
if (!deny.has(name) && value !== void 0 && (RESPONSE.has(name) || (protocol === "messages" ? /^anthropic-ratelimit-[a-z0-9-]+$/.test(name) : RESPONSE_RESPONSES.has(name) || /^x-ratelimit-[a-z0-9-]+$/.test(name) || /^x-codex-(?:primary|secondary)-(?:used-percent|window-minutes|reset-at)$/.test(name))))
|
|
415
550
|
result[name] = value;
|
|
416
551
|
}
|
|
417
552
|
return result;
|
|
418
553
|
}
|
|
419
554
|
|
|
420
|
-
// src/proxy/
|
|
555
|
+
// src/proxy/jwt-expired.ts
|
|
556
|
+
import { Readable } from "node:stream";
|
|
557
|
+
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
558
|
+
var ERROR_BYTES = 64 * 1024;
|
|
559
|
+
async function probeJwtExpired(response, idleMs) {
|
|
560
|
+
const type = String(response.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
|
|
561
|
+
const encoding = String(response.headers["content-encoding"] ?? "identity").toLowerCase();
|
|
562
|
+
if (type !== "application/json" && !type?.endsWith("+json") || !["identity", "gzip", "deflate", "br"].includes(encoding)) {
|
|
563
|
+
return { source: response, expired: false };
|
|
564
|
+
}
|
|
565
|
+
const iterator = response.iterator({ destroyOnReturn: false });
|
|
566
|
+
const chunks = [];
|
|
567
|
+
let bytes = 0;
|
|
568
|
+
let timer;
|
|
569
|
+
try {
|
|
570
|
+
while (true) {
|
|
571
|
+
timer = setTimeout(
|
|
572
|
+
() => response.destroy(
|
|
573
|
+
new ProxyError(504, "UPSTREAM_STREAM_TIMEOUT", "Gateway stream timed out.")
|
|
574
|
+
),
|
|
575
|
+
idleMs
|
|
576
|
+
);
|
|
577
|
+
const next = await iterator.next();
|
|
578
|
+
clearTimeout(timer);
|
|
579
|
+
if (next.done) break;
|
|
580
|
+
const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value);
|
|
581
|
+
chunks.push(chunk);
|
|
582
|
+
bytes += chunk.length;
|
|
583
|
+
if (bytes > ERROR_BYTES) {
|
|
584
|
+
return {
|
|
585
|
+
expired: false,
|
|
586
|
+
source: Readable.from(
|
|
587
|
+
(async function* () {
|
|
588
|
+
try {
|
|
589
|
+
yield* chunks;
|
|
590
|
+
while (true) {
|
|
591
|
+
const next2 = await iterator.next();
|
|
592
|
+
if (next2.done) break;
|
|
593
|
+
yield next2.value;
|
|
594
|
+
}
|
|
595
|
+
} finally {
|
|
596
|
+
await iterator.return?.();
|
|
597
|
+
}
|
|
598
|
+
})()
|
|
599
|
+
)
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
} finally {
|
|
604
|
+
clearTimeout(timer);
|
|
605
|
+
}
|
|
606
|
+
const body = Buffer.concat(chunks);
|
|
607
|
+
let expired = false;
|
|
608
|
+
try {
|
|
609
|
+
const options = { maxOutputLength: ERROR_BYTES };
|
|
610
|
+
const decoded = encoding === "gzip" ? gunzipSync(body, options) : encoding === "deflate" ? inflateSync(body, options) : encoding === "br" ? brotliDecompressSync(body, options) : encoding === "identity" ? body : void 0;
|
|
611
|
+
if (!decoded) throw new Error();
|
|
612
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decoded));
|
|
613
|
+
expired = value?.error?.code === "JWT_EXPIRED";
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
return { source: Readable.from([body]), expired };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/proxy/forward-model-request.ts
|
|
421
620
|
async function readBody(req, context, signal) {
|
|
422
621
|
const { bodyBytes, bodyIdleMs } = context.limits;
|
|
423
622
|
const length = req.headers["content-length"];
|
|
@@ -466,16 +665,8 @@ async function readBody(req, context, signal) {
|
|
|
466
665
|
if (signal.aborted) onAbort();
|
|
467
666
|
});
|
|
468
667
|
}
|
|
469
|
-
async function
|
|
668
|
+
async function forwardModelRequest(req, res, context, signal, agent, protocol, onUpstreamHeaders, requestId) {
|
|
470
669
|
const body = await readBody(req, context, signal);
|
|
471
|
-
try {
|
|
472
|
-
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(body);
|
|
473
|
-
const parsed = JSON.parse(text);
|
|
474
|
-
if (!parsed || Array.isArray(parsed) || typeof parsed.model !== "string" || !parsed.model.trim())
|
|
475
|
-
throw new Error();
|
|
476
|
-
} catch {
|
|
477
|
-
throw new ProxyError(400, "INVALID_BODY", "Expected a JSON object with a nonempty model.");
|
|
478
|
-
}
|
|
479
670
|
let jwt;
|
|
480
671
|
try {
|
|
481
672
|
jwt = await context.credentialProvider.getJwt({ signal });
|
|
@@ -487,6 +678,53 @@ async function forwardMessages(req, res, context, signal, agent, onUpstreamHeade
|
|
|
487
678
|
);
|
|
488
679
|
}
|
|
489
680
|
if (signal.aborted) return;
|
|
681
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
682
|
+
let nextJwt;
|
|
683
|
+
const retry = await forwardAttempt(
|
|
684
|
+
req,
|
|
685
|
+
res,
|
|
686
|
+
context,
|
|
687
|
+
signal,
|
|
688
|
+
agent,
|
|
689
|
+
protocol,
|
|
690
|
+
body,
|
|
691
|
+
jwt,
|
|
692
|
+
onUpstreamHeaders,
|
|
693
|
+
attempt === 0 && context.credentialProvider.refreshJwt ? async (headers) => {
|
|
694
|
+
const log = (event, code) => context.logger.write({
|
|
695
|
+
event,
|
|
696
|
+
request_id: requestId,
|
|
697
|
+
path: protocol === "messages" ? "/v1/messages" : "/v1/responses",
|
|
698
|
+
protocol,
|
|
699
|
+
agent: identifyAgent(req.headers),
|
|
700
|
+
code,
|
|
701
|
+
gateway_logid: safeTraceId(headers["x-tt-logid"]),
|
|
702
|
+
gateway_request_id: safeTraceId(headers["request-id"]),
|
|
703
|
+
gateway_x_request_id: safeTraceId(headers["x-request-id"])
|
|
704
|
+
});
|
|
705
|
+
log("jwt_refresh_started");
|
|
706
|
+
try {
|
|
707
|
+
nextJwt = await context.credentialProvider.refreshJwt?.(jwt, { signal });
|
|
708
|
+
if (signal.aborted) return false;
|
|
709
|
+
log("jwt_refresh_succeeded");
|
|
710
|
+
log("query_retry", "JWT_EXPIRED");
|
|
711
|
+
return true;
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (!signal.aborted)
|
|
714
|
+
log(
|
|
715
|
+
"jwt_refresh_failed",
|
|
716
|
+
error instanceof CredentialError ? error.code : "AUTH_UNAVAILABLE"
|
|
717
|
+
);
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
} : void 0
|
|
721
|
+
);
|
|
722
|
+
if (!retry || !nextJwt || signal.aborted) return;
|
|
723
|
+
jwt = nextJwt;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async function forwardAttempt(req, res, context, signal, agent, protocol, body, jwt, onUpstreamHeaders, recover) {
|
|
727
|
+
if (signal.aborted) return false;
|
|
490
728
|
const transfer = new AbortController();
|
|
491
729
|
const abort = () => transfer.abort(signal.reason);
|
|
492
730
|
signal.addEventListener("abort", abort, { once: true });
|
|
@@ -494,10 +732,10 @@ async function forwardMessages(req, res, context, signal, agent, onUpstreamHeade
|
|
|
494
732
|
let idleTimer;
|
|
495
733
|
let cleanupStreamTimer;
|
|
496
734
|
let upstreamResponse;
|
|
497
|
-
const target = new URL(req.url ?? "/
|
|
735
|
+
const target = new URL(req.url ?? "/", context.config.gatewayOrigin);
|
|
498
736
|
const upstream = request(target, {
|
|
499
737
|
method: "POST",
|
|
500
|
-
headers: requestHeaders(req.headers, jwt, body.length, agent),
|
|
738
|
+
headers: requestHeaders(req.headers, jwt, body.length, agent, protocol),
|
|
501
739
|
agent: context.httpsAgent,
|
|
502
740
|
signal: transfer.signal
|
|
503
741
|
});
|
|
@@ -520,6 +758,14 @@ async function forwardMessages(req, res, context, signal, agent, onUpstreamHeade
|
|
|
520
758
|
response.destroy();
|
|
521
759
|
throw new ProxyError(502, "UPSTREAM_REDIRECT", "Gateway redirection is not supported.");
|
|
522
760
|
}
|
|
761
|
+
let source = response;
|
|
762
|
+
if (response.statusCode === 401 && recover) {
|
|
763
|
+
const inspected = await probeJwtExpired(response, context.limits.streamIdleMs);
|
|
764
|
+
if (signal.aborted) return false;
|
|
765
|
+
if (inspected.expired && await recover(response.headers)) return true;
|
|
766
|
+
if (signal.aborted) return false;
|
|
767
|
+
source = inspected.source;
|
|
768
|
+
}
|
|
523
769
|
let backpressured = false;
|
|
524
770
|
let streamEnded = false;
|
|
525
771
|
const resetIdle = () => {
|
|
@@ -546,25 +792,26 @@ async function forwardMessages(req, res, context, signal, agent, onUpstreamHeade
|
|
|
546
792
|
streamEnded = true;
|
|
547
793
|
clearTimeout(idleTimer);
|
|
548
794
|
};
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
795
|
+
source.on("pause", onPause);
|
|
796
|
+
source.on("resume", onResume);
|
|
797
|
+
source.once("end", onEnd);
|
|
798
|
+
source.on("data", resetIdle);
|
|
553
799
|
cleanupStreamTimer = () => {
|
|
554
800
|
clearTimeout(idleTimer);
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
801
|
+
source.off("pause", onPause);
|
|
802
|
+
source.off("resume", onResume);
|
|
803
|
+
source.off("end", onEnd);
|
|
804
|
+
source.off("data", resetIdle);
|
|
559
805
|
};
|
|
560
806
|
resetIdle();
|
|
561
|
-
res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers));
|
|
807
|
+
res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, protocol));
|
|
562
808
|
res.flushHeaders();
|
|
563
|
-
await pipeline(
|
|
809
|
+
await pipeline(source, res, { signal: transfer.signal });
|
|
810
|
+
return false;
|
|
564
811
|
} catch (error) {
|
|
565
812
|
if (transfer.signal.reason instanceof ProxyError) throw transfer.signal.reason;
|
|
566
813
|
if (error instanceof ProxyError) throw error;
|
|
567
|
-
if (signal.aborted) return;
|
|
814
|
+
if (signal.aborted) return false;
|
|
568
815
|
throw new ProxyError(502, "UPSTREAM_CONNECTION", "Gateway connection failed.");
|
|
569
816
|
} finally {
|
|
570
817
|
clearTimeout(headerTimer);
|
|
@@ -578,82 +825,24 @@ async function forwardMessages(req, res, context, signal, agent, onUpstreamHeade
|
|
|
578
825
|
|
|
579
826
|
// src/agents/registry.ts
|
|
580
827
|
var AGENT_ROUTES = Object.freeze([
|
|
581
|
-
{
|
|
828
|
+
{
|
|
829
|
+
agent: "claude-code",
|
|
830
|
+
protocol: "messages",
|
|
831
|
+
path: "/v1/messages",
|
|
832
|
+
method: "POST",
|
|
833
|
+
allowedQueries: ["", "?beta=true"],
|
|
834
|
+
forward: forwardModelRequest
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
agent: "codex",
|
|
838
|
+
protocol: "responses",
|
|
839
|
+
path: "/v1/responses",
|
|
840
|
+
method: "POST",
|
|
841
|
+
allowedQueries: [""],
|
|
842
|
+
forward: forwardModelRequest
|
|
843
|
+
}
|
|
582
844
|
]);
|
|
583
845
|
|
|
584
|
-
// src/util/logger.ts
|
|
585
|
-
import {
|
|
586
|
-
constants as constants3,
|
|
587
|
-
closeSync,
|
|
588
|
-
existsSync,
|
|
589
|
-
lstatSync,
|
|
590
|
-
openSync,
|
|
591
|
-
renameSync,
|
|
592
|
-
unlinkSync,
|
|
593
|
-
writeSync
|
|
594
|
-
} from "node:fs";
|
|
595
|
-
import { join as join2 } from "node:path";
|
|
596
|
-
var silentLogger = { write() {
|
|
597
|
-
} };
|
|
598
|
-
function safeTraceId(value) {
|
|
599
|
-
return typeof value === "string" && value.length <= 256 && /^[A-Za-z0-9]/.test(value) && !/[^A-Za-z0-9._:-]/.test(value) ? value : void 0;
|
|
600
|
-
}
|
|
601
|
-
function createLogger(stateRoot, maxBytes = 5 * 1024 * 1024) {
|
|
602
|
-
const path = join2(stateRoot, "daemon.log");
|
|
603
|
-
const safeFile = (file) => {
|
|
604
|
-
const info = lstatSync(file);
|
|
605
|
-
if (!info.isFile() || info.isSymbolicLink() || process.platform !== "win32" && info.uid !== process.getuid?.())
|
|
606
|
-
throw new Error();
|
|
607
|
-
return info;
|
|
608
|
-
};
|
|
609
|
-
return {
|
|
610
|
-
write(record) {
|
|
611
|
-
try {
|
|
612
|
-
const line = `${JSON.stringify({
|
|
613
|
-
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
614
|
-
event: record.event,
|
|
615
|
-
request_id: record.request_id,
|
|
616
|
-
gateway_logid: safeTraceId(record.gateway_logid),
|
|
617
|
-
gateway_request_id: safeTraceId(record.gateway_request_id),
|
|
618
|
-
gateway_x_request_id: safeTraceId(record.gateway_x_request_id),
|
|
619
|
-
path: record.path,
|
|
620
|
-
duration_ms: record.duration_ms,
|
|
621
|
-
status: record.status,
|
|
622
|
-
code: record.code
|
|
623
|
-
})}
|
|
624
|
-
`;
|
|
625
|
-
if (existsSync(path) && safeFile(path).size + Buffer.byteLength(line) > maxBytes) {
|
|
626
|
-
for (let i = 2; i >= 1; i--) {
|
|
627
|
-
const from = `${path}.${i}`;
|
|
628
|
-
const to = `${path}.${i + 1}`;
|
|
629
|
-
if (existsSync(from)) {
|
|
630
|
-
safeFile(from);
|
|
631
|
-
if (existsSync(to)) {
|
|
632
|
-
safeFile(to);
|
|
633
|
-
unlinkSync(to);
|
|
634
|
-
}
|
|
635
|
-
if (i === 2) unlinkSync(from);
|
|
636
|
-
else renameSync(from, to);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
renameSync(path, `${path}.1`);
|
|
640
|
-
}
|
|
641
|
-
const fd = openSync(
|
|
642
|
-
path,
|
|
643
|
-
constants3.O_WRONLY | constants3.O_APPEND | constants3.O_CREAT | constants3.O_NOFOLLOW,
|
|
644
|
-
384
|
|
645
|
-
);
|
|
646
|
-
try {
|
|
647
|
-
writeSync(fd, line);
|
|
648
|
-
} finally {
|
|
649
|
-
closeSync(fd);
|
|
650
|
-
}
|
|
651
|
-
} catch {
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
|
|
657
846
|
// src/proxy/server.ts
|
|
658
847
|
function createProxyContext(config, credentialProvider, options = {}) {
|
|
659
848
|
return {
|
|
@@ -671,28 +860,19 @@ function createProxyHandler(context) {
|
|
|
671
860
|
void (async () => {
|
|
672
861
|
const raw = req.url ?? "";
|
|
673
862
|
const route = AGENT_ROUTES.find((item) => item.path === raw.split("?")[0]);
|
|
674
|
-
|
|
675
|
-
|
|
863
|
+
const protocol = route?.protocol ?? (raw.split("?")[0]?.startsWith("/v1/responses") ? "responses" : "messages");
|
|
864
|
+
const fail2 = (error) => sendError(res, error, protocol);
|
|
865
|
+
if (!route) return fail2(new ProxyError(404, "UNKNOWN_ROUTE", "Route is not supported."));
|
|
676
866
|
if (req.method !== route.method) {
|
|
677
867
|
res.setHeader("allow", route.method);
|
|
678
|
-
return
|
|
679
|
-
res,
|
|
680
|
-
new ProxyError(405, "METHOD_NOT_ALLOWED", "Method is not supported.")
|
|
681
|
-
);
|
|
868
|
+
return fail2(new ProxyError(405, "METHOD_NOT_ALLOWED", "Method is not supported."));
|
|
682
869
|
}
|
|
683
|
-
if (
|
|
684
|
-
return
|
|
685
|
-
res,
|
|
686
|
-
new ProxyError(400, "INVALID_QUERY", "Query parameters are not supported.")
|
|
687
|
-
);
|
|
870
|
+
if (!route.allowedQueries.some((query) => raw === `${route.path}${query}`)) {
|
|
871
|
+
return fail2(new ProxyError(400, "INVALID_QUERY", "Query parameters are not supported."));
|
|
688
872
|
}
|
|
689
|
-
if (context.closing)
|
|
690
|
-
return sendError(res, new ProxyError(503, "SHUTTING_DOWN", "Daemon is stopping."));
|
|
873
|
+
if (context.closing) return fail2(new ProxyError(503, "SHUTTING_DOWN", "Daemon is stopping."));
|
|
691
874
|
if (context.active.size >= context.limits.concurrency) {
|
|
692
|
-
return
|
|
693
|
-
res,
|
|
694
|
-
new ProxyError(429, "CONCURRENCY_LIMIT", "Too many active requests.")
|
|
695
|
-
);
|
|
875
|
+
return fail2(new ProxyError(429, "CONCURRENCY_LIMIT", "Too many active requests."));
|
|
696
876
|
}
|
|
697
877
|
const controller = new AbortController();
|
|
698
878
|
context.active.add(controller);
|
|
@@ -707,13 +887,22 @@ function createProxyHandler(context) {
|
|
|
707
887
|
req.once("aborted", onAbort);
|
|
708
888
|
res.once("close", onClose);
|
|
709
889
|
try {
|
|
710
|
-
await route.forward(
|
|
711
|
-
|
|
712
|
-
|
|
890
|
+
await route.forward(
|
|
891
|
+
req,
|
|
892
|
+
res,
|
|
893
|
+
context,
|
|
894
|
+
controller.signal,
|
|
895
|
+
route.agent,
|
|
896
|
+
route.protocol,
|
|
897
|
+
(headers) => {
|
|
898
|
+
upstreamHeaders = headers;
|
|
899
|
+
},
|
|
900
|
+
requestId
|
|
901
|
+
);
|
|
713
902
|
} catch (error) {
|
|
714
903
|
const failure = error instanceof ProxyError ? error : new ProxyError(502, "PROXY_FAILURE", "Request forwarding failed.");
|
|
715
904
|
code = failure.code;
|
|
716
|
-
|
|
905
|
+
fail2(failure);
|
|
717
906
|
} finally {
|
|
718
907
|
req.off("aborted", onAbort);
|
|
719
908
|
res.off("close", onClose);
|
|
@@ -724,7 +913,9 @@ function createProxyHandler(context) {
|
|
|
724
913
|
gateway_logid: safeTraceId(upstreamHeaders?.["x-tt-logid"]),
|
|
725
914
|
gateway_request_id: safeTraceId(upstreamHeaders?.["request-id"]),
|
|
726
915
|
gateway_x_request_id: safeTraceId(upstreamHeaders?.["x-request-id"]),
|
|
727
|
-
path:
|
|
916
|
+
path: route.path,
|
|
917
|
+
protocol: route.protocol,
|
|
918
|
+
agent: identifyAgent(req.headers),
|
|
728
919
|
duration_ms: Date.now() - started,
|
|
729
920
|
status: res.statusCode,
|
|
730
921
|
code
|
|
@@ -771,29 +962,175 @@ async function prepareStateRoot(path) {
|
|
|
771
962
|
}
|
|
772
963
|
|
|
773
964
|
// src/version.ts
|
|
774
|
-
var VERSION = true ? "0.1.0-alpha.
|
|
965
|
+
var VERSION = true ? "0.1.0-alpha.4" : "0.1.0-dev";
|
|
966
|
+
|
|
967
|
+
// src/daemon/auto-sync.ts
|
|
968
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
969
|
+
import { lstat as lstat2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
970
|
+
import { isAbsolute as isAbsolute2, join as join3 } from "node:path";
|
|
971
|
+
var AUTO_SYNC_STARTUP_MS = 3e4;
|
|
972
|
+
var AUTO_SYNC_INTERVAL_MS = 6e5;
|
|
973
|
+
var AUTO_SYNC_TIMEOUT_MS = 12e4;
|
|
974
|
+
var SYNC_ARGS = ["sync", "--yes", "--non-interactive"];
|
|
975
|
+
async function registration(stateRoot) {
|
|
976
|
+
const path = join3(stateRoot, "auto-sync.json");
|
|
977
|
+
try {
|
|
978
|
+
const info = await lstat2(path);
|
|
979
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 || process.platform !== "win32" && (info.uid !== process.getuid?.() || (info.mode & 18) !== 0)) {
|
|
980
|
+
throw new Error("Invalid auto-sync registration");
|
|
981
|
+
}
|
|
982
|
+
const value = JSON.parse(await readFile2(path, "utf8"));
|
|
983
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
984
|
+
throw new Error("Invalid registration");
|
|
985
|
+
const config = value;
|
|
986
|
+
if (config.enabled !== void 0 && typeof config.enabled !== "boolean")
|
|
987
|
+
throw new Error("Invalid enabled");
|
|
988
|
+
if (config.claude_config_dir !== void 0 && (typeof config.claude_config_dir !== "string" || !isAbsolute2(config.claude_config_dir) || config.claude_config_dir.includes("\0")))
|
|
989
|
+
throw new Error("Invalid directory");
|
|
990
|
+
return config;
|
|
991
|
+
} catch (error) {
|
|
992
|
+
if (error.code === "ENOENT") return {};
|
|
993
|
+
throw error;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function terminate(child, force) {
|
|
997
|
+
if (!child.pid) return;
|
|
998
|
+
if (process.platform === "win32") {
|
|
999
|
+
const killer = spawn2("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
|
|
1000
|
+
shell: false,
|
|
1001
|
+
windowsHide: true,
|
|
1002
|
+
stdio: "ignore"
|
|
1003
|
+
});
|
|
1004
|
+
killer.on("error", () => child.kill("SIGKILL"));
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
1009
|
+
} catch {
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
async function runAutoSync(stateRoot, logger, options) {
|
|
1013
|
+
const started = Date.now();
|
|
1014
|
+
let config;
|
|
1015
|
+
try {
|
|
1016
|
+
config = await registration(stateRoot);
|
|
1017
|
+
} catch {
|
|
1018
|
+
logger.write({ event: "auto_sync_failed", code: "AUTO_SYNC_CONFIG_INVALID" });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (config.enabled === false || options.signal.aborted) return;
|
|
1022
|
+
let useEntry = false;
|
|
1023
|
+
if (typeof config.cli_entry === "string" && isAbsolute2(config.cli_entry) && !config.cli_entry.includes("\0")) {
|
|
1024
|
+
useEntry = await stat2(config.cli_entry).then(
|
|
1025
|
+
(info) => info.isFile(),
|
|
1026
|
+
() => false
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
if (options.signal.aborted) return;
|
|
1030
|
+
if (!useEntry) logger.write({ event: "auto_sync_fallback", code: "CLI_ENTRY_UNAVAILABLE" });
|
|
1031
|
+
const env = {
|
|
1032
|
+
...options.env ?? process.env,
|
|
1033
|
+
DEVINFRA_BRIDGE_STATE_ROOT: stateRoot
|
|
1034
|
+
};
|
|
1035
|
+
if (config.claude_config_dir) env.CLAUDE_CONFIG_DIR = config.claude_config_dir;
|
|
1036
|
+
const command = useEntry ? process.execPath : "devinfra-bridge-cli";
|
|
1037
|
+
const args = useEntry ? [config.cli_entry, ...SYNC_ARGS] : SYNC_ARGS;
|
|
1038
|
+
logger.write({ event: "auto_sync_started" });
|
|
1039
|
+
await new Promise((resolve) => {
|
|
1040
|
+
const child = spawn2(command, args, {
|
|
1041
|
+
shell: false,
|
|
1042
|
+
windowsHide: true,
|
|
1043
|
+
detached: process.platform !== "win32",
|
|
1044
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1045
|
+
env
|
|
1046
|
+
});
|
|
1047
|
+
child.stdout.resume();
|
|
1048
|
+
child.stderr.resume();
|
|
1049
|
+
let reason;
|
|
1050
|
+
let killTimer;
|
|
1051
|
+
const stop = (code) => {
|
|
1052
|
+
if (reason) return;
|
|
1053
|
+
reason = code;
|
|
1054
|
+
terminate(child, false);
|
|
1055
|
+
killTimer = setTimeout(() => terminate(child, true), options.killGraceMs ?? 1e3);
|
|
1056
|
+
};
|
|
1057
|
+
const onAbort = () => stop("AUTO_SYNC_CANCELLED");
|
|
1058
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
1059
|
+
const timer = setTimeout(
|
|
1060
|
+
() => stop("AUTO_SYNC_TIMEOUT"),
|
|
1061
|
+
options.timeoutMs ?? AUTO_SYNC_TIMEOUT_MS
|
|
1062
|
+
);
|
|
1063
|
+
child.on("error", () => {
|
|
1064
|
+
reason ??= "CLI_UNAVAILABLE";
|
|
1065
|
+
});
|
|
1066
|
+
child.once("close", (exitCode) => {
|
|
1067
|
+
clearTimeout(timer);
|
|
1068
|
+
if (reason && killTimer) terminate(child, true);
|
|
1069
|
+
clearTimeout(killTimer);
|
|
1070
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
1071
|
+
logger.write({
|
|
1072
|
+
event: reason || exitCode !== 0 ? "auto_sync_failed" : "auto_sync_finished",
|
|
1073
|
+
code: reason ?? (exitCode === 0 ? "AUTO_SYNC_SUCCEEDED" : "CLI_SYNC_FAILED"),
|
|
1074
|
+
duration_ms: Date.now() - started,
|
|
1075
|
+
exit_code: exitCode ?? void 0
|
|
1076
|
+
});
|
|
1077
|
+
resolve();
|
|
1078
|
+
});
|
|
1079
|
+
if (options.signal.aborted) onAbort();
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
function startAutoSync(stateRoot, logger, options = {}) {
|
|
1083
|
+
const controller = new AbortController();
|
|
1084
|
+
let timer;
|
|
1085
|
+
let running = Promise.resolve();
|
|
1086
|
+
const schedule = (delay) => {
|
|
1087
|
+
timer = setTimeout(() => {
|
|
1088
|
+
if (controller.signal.aborted) return;
|
|
1089
|
+
running = (async () => {
|
|
1090
|
+
try {
|
|
1091
|
+
if (options.run) await options.run(controller.signal);
|
|
1092
|
+
else await runAutoSync(stateRoot, logger, { signal: controller.signal });
|
|
1093
|
+
} catch {
|
|
1094
|
+
logger.write({ event: "auto_sync_failed", code: "AUTO_SYNC_FAILED" });
|
|
1095
|
+
} finally {
|
|
1096
|
+
if (!controller.signal.aborted) schedule(options.intervalMs ?? AUTO_SYNC_INTERVAL_MS);
|
|
1097
|
+
}
|
|
1098
|
+
})();
|
|
1099
|
+
}, delay);
|
|
1100
|
+
timer.unref();
|
|
1101
|
+
};
|
|
1102
|
+
schedule(options.startupMs ?? AUTO_SYNC_STARTUP_MS);
|
|
1103
|
+
return {
|
|
1104
|
+
close() {
|
|
1105
|
+
clearTimeout(timer);
|
|
1106
|
+
controller.abort();
|
|
1107
|
+
return running;
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
775
1111
|
|
|
776
1112
|
// src/daemon/single-instance.ts
|
|
777
1113
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
778
|
-
import { readFile as
|
|
779
|
-
import { join as
|
|
780
|
-
async function bindSingleInstance(server) {
|
|
1114
|
+
import { readFile as readFile3, rename, unlink, writeFile } from "node:fs/promises";
|
|
1115
|
+
import { join as join4 } from "node:path";
|
|
1116
|
+
async function bindSingleInstance(server, port = PORT) {
|
|
781
1117
|
await new Promise((resolve, reject) => {
|
|
782
|
-
const onError = () => reject(new Error(
|
|
1118
|
+
const onError = () => reject(new Error(`Cannot bind ${HOST}:${port}; port is occupied or unavailable.`));
|
|
783
1119
|
server.once("error", onError);
|
|
784
|
-
server.listen({ host: HOST, port
|
|
1120
|
+
server.listen({ host: HOST, port, exclusive: true }, () => {
|
|
785
1121
|
server.off("error", onError);
|
|
786
1122
|
resolve();
|
|
787
1123
|
});
|
|
788
1124
|
});
|
|
1125
|
+
return server.address().port;
|
|
789
1126
|
}
|
|
790
|
-
async function writeInstance(stateRoot, id, version) {
|
|
791
|
-
const path =
|
|
792
|
-
const temp =
|
|
1127
|
+
async function writeInstance(stateRoot, id, version, port = PORT) {
|
|
1128
|
+
const path = join4(stateRoot, "instance.json");
|
|
1129
|
+
const temp = join4(stateRoot, `.instance-${randomUUID2()}.tmp`);
|
|
793
1130
|
try {
|
|
794
1131
|
await writeFile(
|
|
795
1132
|
temp,
|
|
796
|
-
`${JSON.stringify({ instance_id: id, pid: process.pid, host: HOST, port
|
|
1133
|
+
`${JSON.stringify({ instance_id: id, pid: process.pid, host: HOST, port, version })}
|
|
797
1134
|
`,
|
|
798
1135
|
{ flag: "wx", mode: 384 }
|
|
799
1136
|
);
|
|
@@ -804,14 +1141,14 @@ async function writeInstance(stateRoot, id, version) {
|
|
|
804
1141
|
}
|
|
805
1142
|
return async () => {
|
|
806
1143
|
try {
|
|
807
|
-
if (JSON.parse(await
|
|
1144
|
+
if (JSON.parse(await readFile3(path, "utf8")).instance_id === id) await unlink(path);
|
|
808
1145
|
} catch {
|
|
809
1146
|
}
|
|
810
1147
|
};
|
|
811
1148
|
}
|
|
812
1149
|
|
|
813
1150
|
// src/daemon/daemon.ts
|
|
814
|
-
async function startDaemon(config) {
|
|
1151
|
+
async function startDaemon(config, { port: requestedPort = PORT } = {}) {
|
|
815
1152
|
const stateRoot = await prepareStateRoot(config.stateRoot);
|
|
816
1153
|
const logger = createLogger(stateRoot);
|
|
817
1154
|
const provider = createCredentialProvider(config.auth);
|
|
@@ -824,9 +1161,10 @@ async function startDaemon(config) {
|
|
|
824
1161
|
});
|
|
825
1162
|
const instanceId = randomUUID3();
|
|
826
1163
|
let cleanup;
|
|
1164
|
+
let port;
|
|
827
1165
|
try {
|
|
828
|
-
await bindSingleInstance(server);
|
|
829
|
-
cleanup = await writeInstance(stateRoot, instanceId, VERSION);
|
|
1166
|
+
port = await bindSingleInstance(server, requestedPort);
|
|
1167
|
+
cleanup = await writeInstance(stateRoot, instanceId, VERSION, port);
|
|
830
1168
|
} catch (error) {
|
|
831
1169
|
context.closing = true;
|
|
832
1170
|
for (const controller of context.active) controller.abort();
|
|
@@ -836,6 +1174,7 @@ async function startDaemon(config) {
|
|
|
836
1174
|
await provider.close();
|
|
837
1175
|
throw error;
|
|
838
1176
|
}
|
|
1177
|
+
const autoSync = startAutoSync(stateRoot, logger);
|
|
839
1178
|
let closing;
|
|
840
1179
|
const onSignal = () => {
|
|
841
1180
|
void close().catch(() => logger.write({ event: "shutdown_error" }));
|
|
@@ -843,6 +1182,7 @@ async function startDaemon(config) {
|
|
|
843
1182
|
const close = ({ drainMs = 1e4 } = {}) => {
|
|
844
1183
|
if (closing) return closing;
|
|
845
1184
|
context.closing = true;
|
|
1185
|
+
const syncStopped = autoSync.close();
|
|
846
1186
|
process.off("SIGINT", onSignal);
|
|
847
1187
|
process.off("SIGTERM", onSignal);
|
|
848
1188
|
closing = (async () => {
|
|
@@ -862,6 +1202,7 @@ async function startDaemon(config) {
|
|
|
862
1202
|
for (const controller of context.active) controller.abort();
|
|
863
1203
|
context.httpsAgent.destroy();
|
|
864
1204
|
await provider.close();
|
|
1205
|
+
await syncStopped;
|
|
865
1206
|
await cleanup?.();
|
|
866
1207
|
logger.write({ event: "stopped" });
|
|
867
1208
|
}
|
|
@@ -871,7 +1212,7 @@ async function startDaemon(config) {
|
|
|
871
1212
|
process.on("SIGINT", onSignal);
|
|
872
1213
|
process.on("SIGTERM", onSignal);
|
|
873
1214
|
logger.write({ event: "ready" });
|
|
874
|
-
return { instanceId, host: HOST, port
|
|
1215
|
+
return { instanceId, host: HOST, port, close };
|
|
875
1216
|
}
|
|
876
1217
|
export {
|
|
877
1218
|
CC_PLACEHOLDER_TOKEN,
|