codex-copilot-dx 0.4.23 → 0.4.26

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/copilot.mjs CHANGED
@@ -3,24 +3,43 @@ import os from "node:os";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { status } from "./status.mjs";
5
5
  import { debugLog } from "./log.mjs";
6
- import { githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
6
+ import { ensureGithubTokenMetadata, githubReauthMessage, githubTokenPath, importDiscoveredGithubToken } from "./auth.mjs";
7
+ import {
8
+ optimizeImageDataUrl,
9
+ optimizeImagesInBody,
10
+ parseImageConcurrency,
11
+ runWithConcurrency,
12
+ summarizeReqBody,
13
+ } from "./image-optimization.mjs";
14
+
15
+ export {
16
+ optimizeImageDataUrl,
17
+ optimizeImagesInBody,
18
+ parseImageConcurrency,
19
+ runWithConcurrency,
20
+ summarizeReqBody,
21
+ } from "./image-optimization.mjs";
7
22
 
8
23
  export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
9
24
  let apiBase = DEFAULT_API_BASE;
10
25
 
11
26
  // Cache of model id -> supported_endpoints, populated from listModels(). Used to
12
27
  // route requests to /responses vs /chat/completions based on real model metadata.
13
- const modelEndpointCache = new Map();
28
+ let modelEndpointCache = new Map();
14
29
 
15
30
  export function cacheModelEndpoints(models) {
16
31
  const data = Array.isArray(models) ? models : models?.data;
17
- if (!Array.isArray(data)) return;
32
+ if (!Array.isArray(data)) return false;
33
+ const next = new Map();
18
34
  for (const model of data) {
19
35
  const id = String(model?.id || "").trim();
20
36
  if (id && Array.isArray(model?.supported_endpoints)) {
21
- modelEndpointCache.set(id, model.supported_endpoints);
37
+ next.set(id, [...model.supported_endpoints]);
22
38
  }
23
39
  }
40
+ if (!next.size) return false;
41
+ modelEndpointCache = next;
42
+ return true;
24
43
  }
25
44
 
26
45
  // Returns the cached supported_endpoints for a model id, or null if unknown.
@@ -31,25 +50,12 @@ export function getCachedModelEndpoints(modelId) {
31
50
  export function resetModelEndpointCacheForTests() {
32
51
  modelEndpointCache.clear();
33
52
  }
34
- const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
35
- const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
36
- const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
37
- const DEFAULT_IMG_CONCURRENCY = 4;
38
- const IMG_MAX_CONCURRENCY = 12;
39
53
  const DEFAULT_UPSTREAM_RETRIES = 2;
40
54
  const MAX_UPSTREAM_RETRIES = 5;
41
55
  const DEFAULT_UPSTREAM_RETRY_DELAY_MS = 300;
42
56
  const MAX_UPSTREAM_RETRY_DELAY_MS = 5000;
43
- const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
44
- const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
45
57
  const UPSTREAM_RETRIES = parseUpstreamRetries(process.env.CCDX_UPSTREAM_RETRIES);
46
58
  const UPSTREAM_RETRY_DELAY_MS = parseUpstreamRetryDelayMs(process.env.CCDX_UPSTREAM_RETRY_DELAY_MS);
47
- let sharpImport = null;
48
-
49
- export function parseImageConcurrency(value) {
50
- const n = Number.parseInt(value, 10);
51
- return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
52
- }
53
59
 
54
60
  export function parseUpstreamRetries(value) {
55
61
  const n = Number.parseInt(value, 10);
@@ -196,149 +202,6 @@ export async function fetchCopilotUpstream(
196
202
  }
197
203
  }
198
204
 
199
- async function sharp() {
200
- sharpImport ||= import("sharp");
201
- const mod = await sharpImport;
202
- return mod.default || mod;
203
- }
204
-
205
- export async function optimizeImageDataUrl(dataUrl) {
206
- if (IMG_OPT_DISABLED) return dataUrl;
207
- if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
208
- const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
209
- if (!match) return dataUrl;
210
- const mime = match[1].toLowerCase();
211
- const raw = Buffer.from(match[2], "base64");
212
- if (raw.length < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
213
-
214
- try {
215
- const resize = await sharp();
216
- const out = await resize(raw, { failOn: "none" })
217
- .rotate()
218
- .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
219
- .webp({ quality: IMG_QUALITY, effort: 4 })
220
- .toBuffer();
221
- const ratio = ((out.length / raw.length) * 100).toFixed(1);
222
- console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
223
- return `data:image/webp;base64,${out.toString("base64")}`;
224
- } catch (e) {
225
- console.warn(status("warn", `image optimize failed (${mime}, ${raw.length}b): ${e.message}`));
226
- return dataUrl;
227
- }
228
- }
229
-
230
- export async function runWithConcurrency(taskFns, concurrency) {
231
- if (!Array.isArray(taskFns) || taskFns.length === 0) return;
232
- const limit = Math.min(parseImageConcurrency(concurrency), taskFns.length);
233
- let next = 0;
234
- await Promise.all(Array.from({ length: limit }, async () => {
235
- while (next < taskFns.length) {
236
- const task = taskFns[next++];
237
- await task();
238
- }
239
- }));
240
- }
241
-
242
- function visitImageParts(parts, tasks) {
243
- if (!Array.isArray(parts)) return;
244
- for (const part of parts) {
245
- if (!part) continue;
246
- if (part.type === "input_image" && typeof part.image_url === "string") {
247
- tasks.push(async () => { part.image_url = await optimizeImageDataUrl(part.image_url); });
248
- } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
249
- tasks.push(async () => {
250
- const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
251
- const opt = await optimizeImageDataUrl(dataUrl);
252
- const match = /^data:([^;]+);base64,(.+)$/.exec(opt);
253
- if (match) {
254
- part.source.media_type = match[1];
255
- part.source.data = match[2];
256
- }
257
- });
258
- }
259
- }
260
- }
261
-
262
- export async function optimizeImagesInBody(reqBody) {
263
- if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
264
- const tasks = [];
265
-
266
- for (const item of reqBody.input) {
267
- if (!item) continue;
268
- if (item.type === "message" && Array.isArray(item.content)) {
269
- visitImageParts(item.content, tasks);
270
- }
271
- if (item.type === "function_call_output") {
272
- if (Array.isArray(item.output)) {
273
- visitImageParts(item.output, tasks);
274
- } else if (typeof item.output === "string") {
275
- const trimmed = item.output.trim();
276
- if (trimmed.startsWith("[")) {
277
- try {
278
- const parsed = JSON.parse(trimmed);
279
- if (Array.isArray(parsed)) {
280
- const localTasks = [];
281
- visitImageParts(parsed, localTasks);
282
- if (localTasks.length) {
283
- tasks.push(async () => {
284
- await runWithConcurrency(localTasks, IMG_CONCURRENCY);
285
- item.output = JSON.stringify(parsed);
286
- });
287
- }
288
- }
289
- } catch {
290
- // Leave non-JSON tool output untouched.
291
- }
292
- }
293
- }
294
- }
295
- }
296
-
297
- if (tasks.length) await runWithConcurrency(tasks, IMG_CONCURRENCY);
298
- return reqBody;
299
- }
300
-
301
- export function summarizeReqBody(reqBody) {
302
- try {
303
- const input = reqBody.input;
304
- if (!Array.isArray(input)) return { items: 0, images: 0, biggest: 0, biggestKind: "n/a" };
305
- let biggest = 0;
306
- let biggestKind = "";
307
- let images = 0;
308
- const countImages = (parts) => {
309
- if (!Array.isArray(parts)) return;
310
- for (const part of parts) {
311
- if (part?.type === "input_image" || part?.type === "image") images++;
312
- }
313
- };
314
-
315
- for (const item of input) {
316
- const size = JSON.stringify(item).length;
317
- if (size > biggest) {
318
- biggest = size;
319
- biggestKind = item?.type || "?";
320
- }
321
- if (item?.type === "message") countImages(item.content);
322
- if (item?.type === "function_call_output") {
323
- if (Array.isArray(item.output)) {
324
- countImages(item.output);
325
- } else if (typeof item.output === "string" && item.output.trim().startsWith("[")) {
326
- try {
327
- const parsed = JSON.parse(item.output);
328
- if (Array.isArray(parsed)) countImages(parsed);
329
- } catch {
330
- // Ignore non-JSON tool output.
331
- }
332
- }
333
- }
334
- }
335
-
336
- return { items: input.length, images, biggest, biggestKind };
337
- } catch {
338
- return { items: -1, images: -1, biggest: -1, biggestKind: "err" };
339
- }
340
- }
341
-
342
205
  export function computeInitiator(messages) {
343
206
  const isAgent = Array.isArray(messages)
344
207
  && messages.some((m) => m && ["assistant", "tool"].includes(m.role));
@@ -463,6 +326,7 @@ async function refreshCopilotToken({
463
326
  throw new Error(`Failed to get Copilot token: ${resp.status}`);
464
327
  }
465
328
  const data = await resp.json();
329
+ await ensureGithubTokenMetadata(ghToken, { home, fetchImpl, signal });
466
330
  return cacheCopilotTokenData(data);
467
331
  }
468
332
 
@@ -520,11 +384,11 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
520
384
  // Responses-only models go directly to Copilot's /responses endpoint.
521
385
  export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
522
386
  const token = await getCopilotToken({ signal });
523
- const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
524
387
  await optimizeImagesInBody(reqBody);
525
- const bodyBuf = Buffer.from(JSON.stringify(reqBody), "utf8");
388
+ const bodyText = JSON.stringify(reqBody);
389
+ const bodyBytes = Buffer.byteLength(bodyText);
526
390
  const summary = summarizeReqBody(reqBody);
527
- console.log(status("info", `responses payload bytes=${bodyBuf.length} was=${beforeBytes} input_items=${summary.items} images=${summary.images} biggest_item=${summary.biggest}b (${summary.biggestKind})`));
391
+ console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
528
392
  const headers = buildHeaders({
529
393
  token,
530
394
  version: getVSCodeVersion(),
@@ -532,14 +396,14 @@ export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {
532
396
  vision: false,
533
397
  });
534
398
  headers["Content-Type"] = "application/json; charset=utf-8";
535
- headers["Content-Length"] = String(bodyBuf.length);
399
+ headers["Content-Length"] = String(bodyBytes);
536
400
  headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
537
401
 
538
402
  try {
539
403
  return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
540
404
  method: "POST",
541
405
  headers,
542
- body: bodyBuf,
406
+ body: bodyText,
543
407
  signal,
544
408
  }, { fetchImpl, ...retryOptions });
545
409
  } catch (e) {
package/src/doctor.mjs CHANGED
@@ -2,9 +2,11 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import net from "node:net";
5
- import { githubTokenPath } from "./auth.mjs";
5
+ import { githubTokenPath, readGithubTokenMetadata, validateGithubToken } from "./auth.mjs";
6
6
  import { claudeDesktopPaths } from "./claude-desktop-config.mjs";
7
7
  import { status } from "./status.mjs";
8
+ import { buildHeaders, DEFAULT_API_BASE, FALLBACK_VSCODE_VERSION } from "./copilot.mjs";
9
+ import { checkRunningAdapter } from "./running-adapter.mjs";
8
10
 
9
11
  function localGatewayBaseUrl(host, port) {
10
12
  const safeHost = String(host || "127.0.0.1");
@@ -55,7 +57,60 @@ export function inspectGitHubToken({ home = os.homedir() } = {}) {
55
57
  if (!token.text.trim()) {
56
58
  return [{ kind: "err", message: `GitHub token file is empty at ${displayPath(home, filePath)}` }];
57
59
  }
58
- return [{ kind: "ok", message: "GitHub token found" }];
60
+ const metadata = readGithubTokenMetadata(home, token.text.trim());
61
+ const account = metadata?.login ? ` for ${metadata.login}` : "";
62
+ return [{ kind: "ok", message: `GitHub token found${account}` }];
63
+ }
64
+
65
+ export async function inspectGitHubTokenOnline({
66
+ home = os.homedir(),
67
+ fetchImpl = fetch,
68
+ timeoutMs = 10000,
69
+ } = {}) {
70
+ const token = readText(githubTokenPath(home));
71
+ if (!token.ok || !token.text.trim()) {
72
+ return [{ kind: "warn", message: "Online Copilot check skipped because the GitHub token is missing" }];
73
+ }
74
+
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
77
+ try {
78
+ const validation = await validateGithubToken(token.text.trim(), {
79
+ fetchImpl,
80
+ signal: controller.signal,
81
+ });
82
+ if (!validation.ok) {
83
+ const statusText = validation.status ? ` (HTTP ${validation.status})` : "";
84
+ return [{ kind: validation.transient ? "warn" : "err", message: `GitHub Copilot authentication failed: ${validation.reason}${statusText}` }];
85
+ }
86
+
87
+ const tokenData = validation.copilotTokenData;
88
+ const apiBase = tokenData.endpoints?.api || DEFAULT_API_BASE;
89
+ const headers = buildHeaders({
90
+ token: tokenData.token,
91
+ version: FALLBACK_VSCODE_VERSION,
92
+ initiator: "user",
93
+ vision: false,
94
+ });
95
+ const modelResp = await fetchImpl(`${apiBase}/models`, { headers, signal: controller.signal });
96
+ if (!modelResp.ok) {
97
+ return [
98
+ { kind: "ok", message: `GitHub Copilot access verified for ${validation.login || "current account"}` },
99
+ { kind: "err", message: `Copilot models endpoint returned HTTP ${modelResp.status}` },
100
+ ];
101
+ }
102
+ const models = await modelResp.json();
103
+ const data = Array.isArray(models) ? models : models?.data;
104
+ return [
105
+ { kind: "ok", message: `GitHub Copilot access verified for ${validation.login || "current account"}` },
106
+ { kind: "ok", message: `Copilot models endpoint returned ${Array.isArray(data) ? data.length : 0} models` },
107
+ ];
108
+ } catch (e) {
109
+ const reason = e?.name === "AbortError" ? `timed out after ${timeoutMs}ms` : e.message;
110
+ return [{ kind: "warn", message: `Online Copilot check failed: ${reason}` }];
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
59
114
  }
60
115
 
61
116
  export function inspectCodexConfig({ home = os.homedir(), port = 2026 } = {}) {
@@ -180,6 +235,10 @@ export async function collectDoctorChecks({
180
235
  port = 2026,
181
236
  checkAdapter = true,
182
237
  checkAdapterListeningFn = checkAdapterListening,
238
+ checkRunningAdapterFn = checkRunningAdapter,
239
+ online = false,
240
+ fetchImpl = fetch,
241
+ onlineTimeoutMs = 10000,
183
242
  } = {}) {
184
243
  const checks = [
185
244
  ...inspectGitHubToken({ home }),
@@ -188,11 +247,27 @@ export async function collectDoctorChecks({
188
247
  ...inspectClaudeAppConfig({ home, platform, env, host, port }),
189
248
  ];
190
249
 
250
+ if (online) {
251
+ checks.push(...await inspectGitHubTokenOnline({ home, fetchImpl, timeoutMs: onlineTimeoutMs }));
252
+ }
253
+
191
254
  if (checkAdapter) {
192
- const listening = await checkAdapterListeningFn({ host, port });
193
- checks.push(listening
194
- ? { kind: "ok", message: `Adapter is listening on ${localGatewayBaseUrl(host, port)}` }
195
- : { kind: "warn", message: `Adapter is not listening on ${localGatewayBaseUrl(host, port)}` });
255
+ let running;
256
+ try {
257
+ running = await checkRunningAdapterFn({ host, port, fetchImpl });
258
+ } catch {
259
+ running = null;
260
+ }
261
+ if (running?.ok) {
262
+ checks.push({ kind: "ok", message: `Adapter ${running.data.version} is listening on ${running.baseUrl}` });
263
+ } else if (running?.incompatible) {
264
+ checks.push({ kind: "warn", message: `Adapter ${running.data?.version || "legacy"} is running at ${running.baseUrl}, but it is incompatible with this CLI` });
265
+ } else {
266
+ const listening = await checkAdapterListeningFn({ host, port });
267
+ checks.push(listening
268
+ ? { kind: "warn", message: `A service is listening on ${localGatewayBaseUrl(host, port)}, but it is not a compatible codex-copilot-dx adapter` }
269
+ : { kind: "warn", message: `Adapter is not listening on ${localGatewayBaseUrl(host, port)}` });
270
+ }
196
271
  }
197
272
 
198
273
  return checks;
@@ -200,7 +275,7 @@ export async function collectDoctorChecks({
200
275
 
201
276
  export async function runDoctor(options = {}) {
202
277
  const log = options.log || console.log;
203
- log("codex-copilot-dx doctor");
278
+ log(`codex-copilot-dx doctor${options.online ? " --online" : ""}`);
204
279
  const checks = await collectDoctorChecks(options);
205
280
  for (const check of checks) log(status(check.kind, check.message));
206
281
  return checks;
@@ -0,0 +1,30 @@
1
+ import fs from "node:fs";
2
+
3
+ export function parseByteLimit(value, fallback) {
4
+ if (value === undefined || value === null || String(value).trim() === "") return fallback;
5
+ const text = String(value).trim();
6
+ if (!/^\d+$/.test(text)) return fallback;
7
+ const parsed = Number(text);
8
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
9
+ }
10
+
11
+ export function rotatedFilePath(filePath) {
12
+ return `${filePath}.1`;
13
+ }
14
+
15
+ export function rotateFileIfNeededSync(filePath, incomingBytes, maxBytes) {
16
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0) return false;
17
+ let stat;
18
+ try {
19
+ stat = fs.statSync(filePath);
20
+ } catch (e) {
21
+ if (e?.code === "ENOENT") return false;
22
+ throw e;
23
+ }
24
+ if (!stat.isFile() || stat.size === 0 || stat.size + incomingBytes <= maxBytes) return false;
25
+
26
+ const backupPath = rotatedFilePath(filePath);
27
+ fs.rmSync(backupPath, { force: true });
28
+ fs.renameSync(filePath, backupPath);
29
+ return true;
30
+ }
@@ -0,0 +1,182 @@
1
+ import { promisify } from "node:util";
2
+ import * as zlib from "node:zlib";
3
+ import { status } from "./status.mjs";
4
+
5
+ const DEFAULT_MAX_BODY_BYTES = 128 * 1024 * 1024;
6
+ const DEFAULT_MAX_DECODED_BODY_BYTES = 256 * 1024 * 1024;
7
+ const gunzipAsync = promisify(zlib.gunzip);
8
+ const inflateAsync = promisify(zlib.inflate);
9
+ const brotliDecompressAsync = promisify(zlib.brotliDecompress);
10
+ const zstdDecompressAsync = zlib.zstdDecompress ? promisify(zlib.zstdDecompress) : null;
11
+
12
+ function positiveInt(value, fallback) {
13
+ const n = Number.parseInt(value, 10);
14
+ return Number.isFinite(n) && n > 0 ? n : fallback;
15
+ }
16
+
17
+ const MAX_BODY_BYTES = positiveInt(process.env.CCDX_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
18
+ const MAX_DECODED_BODY_BYTES = positiveInt(process.env.CCDX_MAX_DECODED_BODY_BYTES, DEFAULT_MAX_DECODED_BODY_BYTES);
19
+
20
+ export function httpError(message, statusCode) {
21
+ const err = new Error(message);
22
+ err.statusCode = statusCode;
23
+ return err;
24
+ }
25
+
26
+ function payloadTooLarge(kind, maxBytes) {
27
+ return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
28
+ }
29
+
30
+ async function readRequestBuffer(req, maxBytes) {
31
+ const contentLength = Number.parseInt(req.headers?.["content-length"] || "", 10);
32
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
33
+ throw payloadTooLarge("Raw", maxBytes);
34
+ }
35
+
36
+ const chunks = [];
37
+ let total = 0;
38
+ for await (const chunk of req) {
39
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
40
+ total += buffer.length;
41
+ if (total > maxBytes) throw payloadTooLarge("Raw", maxBytes);
42
+ chunks.push(buffer);
43
+ }
44
+ return Buffer.concat(chunks);
45
+ }
46
+
47
+ async function decompressBody(decompress, buffer, maxBytes) {
48
+ try {
49
+ return await decompress(buffer, { maxOutputLength: maxBytes });
50
+ } catch (e) {
51
+ if (e?.code === "ERR_BUFFER_TOO_LARGE" || /maxOutputLength/i.test(e?.message || "")) {
52
+ throw payloadTooLarge("Decoded", maxBytes);
53
+ }
54
+ throw e;
55
+ }
56
+ }
57
+
58
+ async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
59
+ const encodings = String(contentEncoding || "identity")
60
+ .split(",")
61
+ .map((value) => value.trim().toLowerCase())
62
+ .filter(Boolean);
63
+
64
+ let decoded = buffer;
65
+ for (const encoding of encodings.reverse()) {
66
+ if (encoding === "identity") continue;
67
+ if (encoding === "gzip" || encoding === "x-gzip") {
68
+ decoded = await decompressBody(gunzipAsync, decoded, maxBytes);
69
+ } else if (encoding === "deflate") {
70
+ decoded = await decompressBody(inflateAsync, decoded, maxBytes);
71
+ } else if (encoding === "br") {
72
+ decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
73
+ } else if (encoding === "zstd") {
74
+ if (!zstdDecompressAsync) throw new Error("Unsupported Content-Encoding: zstd");
75
+ decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
76
+ } else {
77
+ throw new Error(`Unsupported Content-Encoding: ${encoding}`);
78
+ }
79
+ if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
80
+ }
81
+ if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
82
+ return decoded;
83
+ }
84
+
85
+ export async function readJsonBody(req, {
86
+ maxBodyBytes = MAX_BODY_BYTES,
87
+ maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
88
+ } = {}) {
89
+ const buffer = await readRequestBuffer(req, maxBodyBytes);
90
+ const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
91
+ return JSON.parse(decoded.toString("utf8"));
92
+ }
93
+
94
+ export function sendJsonError(res, err, fallbackStatus = 400) {
95
+ if (res.destroyed || res.writableEnded) return;
96
+ if (!res.headersSent) res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
97
+ res.end(JSON.stringify({ error: err?.message || "Request failed" }));
98
+ }
99
+
100
+ export function sendUpstreamError(res, response, text) {
101
+ if (!res.headersSent) {
102
+ res.writeHead(response.status || 502, { "Content-Type": response.headers?.get("content-type") || "application/json" });
103
+ }
104
+ res.end(text || JSON.stringify({ error: "Upstream request failed" }));
105
+ }
106
+
107
+ export function writeOrDrain(res, chunk) {
108
+ if (res.destroyed || res.writableEnded) return Promise.resolve(false);
109
+ if (res.write(chunk)) return Promise.resolve(true);
110
+ return new Promise((resolve, reject) => {
111
+ const cleanup = () => {
112
+ res.off("drain", onDrain);
113
+ res.off("error", onError);
114
+ res.off("close", onClose);
115
+ };
116
+ const onDrain = () => { cleanup(); resolve(true); };
117
+ const onClose = () => { cleanup(); resolve(false); };
118
+ const onError = (err) => { cleanup(); reject(err); };
119
+ res.once("drain", onDrain);
120
+ res.once("close", onClose);
121
+ res.once("error", onError);
122
+ });
123
+ }
124
+
125
+ export function isAbortLikeError(err) {
126
+ return err?.name === "AbortError" || /operation was aborted/i.test(String(err?.message || ""));
127
+ }
128
+
129
+ export function abortErrorStatusCode(reason) {
130
+ if (["upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
131
+ if (reason === "client_aborted" || reason === "client_closed") return 499;
132
+ return 400;
133
+ }
134
+
135
+ export function createRequestAbort(req, res) {
136
+ const controller = new AbortController();
137
+ let timer = null;
138
+ let cleaned = false;
139
+ let reason = null;
140
+ const abort = (nextReason = "aborted") => {
141
+ if (!cleaned && !controller.signal.aborted) {
142
+ reason = nextReason;
143
+ controller.abort();
144
+ }
145
+ };
146
+ const onReqAborted = () => abort("client_aborted");
147
+ const onResClose = () => {
148
+ if (!res.writableEnded) abort("client_closed");
149
+ };
150
+ req.on("aborted", onReqAborted);
151
+ res.on("close", onResClose);
152
+ return {
153
+ signal: controller.signal,
154
+ get reason() { return reason; },
155
+ setTimeout(ms, nextReason = "upstream_timeout") {
156
+ if (timer) clearTimeout(timer);
157
+ timer = null;
158
+ if (ms > 0) timer = setTimeout(() => abort(nextReason), ms);
159
+ },
160
+ clearTimeout() {
161
+ if (timer) clearTimeout(timer);
162
+ timer = null;
163
+ },
164
+ cleanup() {
165
+ cleaned = true;
166
+ if (timer) clearTimeout(timer);
167
+ req.off("aborted", onReqAborted);
168
+ res.off("close", onResClose);
169
+ },
170
+ };
171
+ }
172
+
173
+ export function logRequestFailure(label, err, abort) {
174
+ if (!isAbortLikeError(err)) {
175
+ console.error(status("err", `${label} request failed: ${err.message}`));
176
+ return;
177
+ }
178
+
179
+ const reason = abort?.reason || "aborted";
180
+ err.statusCode ||= abortErrorStatusCode(reason);
181
+ console.warn(status("warn", `${label} request aborted: ${reason}`));
182
+ }