codex-copilot-dx 0.4.25 → 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.
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ function existingMode(filePath, fallback) {
6
+ try {
7
+ return fs.statSync(filePath).mode & 0o777;
8
+ } catch (e) {
9
+ if (e?.code === "ENOENT") return fallback;
10
+ throw e;
11
+ }
12
+ }
13
+
14
+ function syncDirectory(directory) {
15
+ if (process.platform === "win32") return;
16
+ let fd;
17
+ try {
18
+ fd = fs.openSync(directory, "r");
19
+ fs.fsyncSync(fd);
20
+ } catch (e) {
21
+ if (!["EINVAL", "ENOTSUP", "EBADF"].includes(e?.code)) throw e;
22
+ } finally {
23
+ if (fd !== undefined) fs.closeSync(fd);
24
+ }
25
+ }
26
+
27
+ export function atomicWriteFileSync(filePath, data, { mode = 0o600 } = {}) {
28
+ const directory = path.dirname(filePath);
29
+ const finalMode = existingMode(filePath, mode);
30
+ const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
31
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
32
+
33
+ let fd;
34
+ try {
35
+ fd = fs.openSync(tempPath, "wx", finalMode);
36
+ fs.writeFileSync(fd, data);
37
+ fs.fsyncSync(fd);
38
+ fs.closeSync(fd);
39
+ fd = undefined;
40
+ fs.renameSync(tempPath, filePath);
41
+ fs.chmodSync(filePath, finalMode);
42
+ syncDirectory(directory);
43
+ } finally {
44
+ if (fd !== undefined) fs.closeSync(fd);
45
+ try {
46
+ fs.unlinkSync(tempPath);
47
+ } catch (e) {
48
+ if (e?.code !== "ENOENT") throw e;
49
+ }
50
+ }
51
+ }
52
+
53
+ export function atomicWriteFileIfChangedSync(filePath, data, options = {}) {
54
+ try {
55
+ const current = fs.readFileSync(filePath);
56
+ const next = Buffer.isBuffer(data) ? data : Buffer.from(data);
57
+ if (current.equals(next)) return false;
58
+ } catch (e) {
59
+ if (e?.code !== "ENOENT") throw e;
60
+ }
61
+ atomicWriteFileSync(filePath, data, options);
62
+ return true;
63
+ }
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { status } from "./status.mjs";
5
+ import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
6
 
6
7
  const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
7
8
 
@@ -23,20 +24,19 @@ export function computeUpdatedSettings(settings, port) {
23
24
  }
24
25
 
25
26
  // Point Claude Code at the adapter by updating ~/.claude/settings.json.
26
- export function ensureClaudeConfig(port = 2026) {
27
+ export function ensureClaudeConfig(port = 2026, { filePath = SETTINGS_PATH } = {}) {
27
28
  const target = `http://127.0.0.1:${port}`;
28
29
 
29
- if (!fs.existsSync(SETTINGS_PATH)) {
30
- fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
30
+ if (!fs.existsSync(filePath)) {
31
31
  const { json } = computeUpdatedSettings({}, port);
32
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(json, null, 2) + "\n");
32
+ atomicWriteFileIfChangedSync(filePath, JSON.stringify(json, null, 2) + "\n");
33
33
  console.log(status("ok", `Created ~/.claude/settings.json for Claude Code at ${target}`));
34
34
  return;
35
35
  }
36
36
 
37
37
  let raw, settings;
38
38
  try {
39
- raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
39
+ raw = fs.readFileSync(filePath, "utf-8");
40
40
  settings = JSON.parse(raw);
41
41
  } catch (e) {
42
42
  console.log(status("warn", `Could not parse ~/.claude/settings.json: ${e.message}`));
@@ -50,7 +50,7 @@ export function ensureClaudeConfig(port = 2026) {
50
50
  return;
51
51
  }
52
52
 
53
- fs.writeFileSync(`${SETTINGS_PATH}.bak`, raw);
54
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(json, null, 2) + "\n");
53
+ atomicWriteFileIfChangedSync(`${filePath}.bak`, raw);
54
+ atomicWriteFileIfChangedSync(filePath, JSON.stringify(json, null, 2) + "\n");
55
55
  console.log(status("ok", `Configured Claude Code ANTHROPIC_BASE_URL="${target}" and backed up settings.json.bak`));
56
56
  }
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { claudeDesktopModelIds } from "./models.mjs";
6
6
  import { status } from "./status.mjs";
7
+ import { atomicWriteFileIfChangedSync, atomicWriteFileSync } from "./atomic-file.mjs";
7
8
 
8
9
  const CONFIG_FILE = "claude_desktop_config.json";
9
10
  const CONFIG_LIBRARY_DIR = "configLibrary";
@@ -127,8 +128,7 @@ function readJsonFile(filePath, fallback = {}) {
127
128
  }
128
129
 
129
130
  function writeJsonFile(filePath, data) {
130
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
131
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
131
+ return atomicWriteFileIfChangedSync(filePath, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
132
132
  }
133
133
 
134
134
  function snapshotFiles(files) {
@@ -148,8 +148,7 @@ function restoreSnapshots(snapshots) {
148
148
  fs.rmSync(snapshot.filePath, { force: true });
149
149
  continue;
150
150
  }
151
- fs.mkdirSync(path.dirname(snapshot.filePath), { recursive: true });
152
- fs.writeFileSync(snapshot.filePath, snapshot.data, { mode: 0o600 });
151
+ atomicWriteFileSync(snapshot.filePath, snapshot.data, { mode: 0o600 });
153
152
  }
154
153
  }
155
154
 
@@ -0,0 +1,69 @@
1
+ const HELP_COMMANDS = new Set(["help", "--help", "-h"]);
2
+ const VERSION_COMMANDS = new Set(["version", "--version", "-v"]);
3
+
4
+ function unexpectedArgs(args) {
5
+ throw new Error(`Unexpected argument${args.length === 1 ? "" : "s"}: ${args.join(" ")}`);
6
+ }
7
+
8
+ export function parseCliArgs(args = []) {
9
+ const [command, ...rest] = args;
10
+ if (!command) return { command: "start", configureClaudeDesktop: false, online: false };
11
+ if (HELP_COMMANDS.has(command)) {
12
+ if (rest.length) unexpectedArgs(rest);
13
+ return { command: "help", configureClaudeDesktop: false, online: false };
14
+ }
15
+ if (VERSION_COMMANDS.has(command)) {
16
+ if (rest.length) unexpectedArgs(rest);
17
+ return { command: "version", configureClaudeDesktop: false, online: false };
18
+ }
19
+ if (command === "usage") {
20
+ if (rest.length) unexpectedArgs(rest);
21
+ return { command: "usage", configureClaudeDesktop: false, online: false };
22
+ }
23
+ if (command === "doctor" || command === "--doctor") {
24
+ const invalid = rest.filter((arg) => arg !== "--online");
25
+ if (invalid.length) unexpectedArgs(invalid);
26
+ if (rest.filter((arg) => arg === "--online").length > 1) unexpectedArgs(["--online"]);
27
+ return { command: "doctor", configureClaudeDesktop: false, online: rest.includes("--online") };
28
+ }
29
+ if (command === "--configure-claude-desktop") {
30
+ if (rest.length) unexpectedArgs(rest);
31
+ return { command: "start", configureClaudeDesktop: true, online: false };
32
+ }
33
+ throw new Error(`Unknown command or option: ${command}`);
34
+ }
35
+
36
+ function integerEnv(env, name, fallback, { min, max = Number.MAX_SAFE_INTEGER }) {
37
+ const raw = env[name];
38
+ if (raw === undefined || raw === null || String(raw).trim() === "") return fallback;
39
+ const text = String(raw).trim();
40
+ if (!/^\d+$/.test(text)) throw new Error(`${name} must be an integer between ${min} and ${max}`);
41
+ const value = Number(text);
42
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
43
+ throw new Error(`${name} must be an integer between ${min} and ${max}`);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ export function parseRuntimeOptions(env = process.env) {
49
+ return {
50
+ adapterPort: integerEnv(env, "ADAPTER_PORT", 2026, { min: 1, max: 65535 }),
51
+ adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
52
+ modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1 }),
53
+ existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
54
+ modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 30 * 60 * 1000, { min: 0 }),
55
+ upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1 }),
56
+ streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1 }),
57
+ streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1 }),
58
+ shutdownTimeoutMs: integerEnv(env, "CCDX_SHUTDOWN_TIMEOUT_MS", 5000, { min: 1 }),
59
+ };
60
+ }
61
+
62
+ export function cliHelp() {
63
+ return `Usage:
64
+ codex-copilot-dx [--configure-claude-desktop]
65
+ codex-copilot-dx doctor [--online]
66
+ codex-copilot-dx usage
67
+ codex-copilot-dx --version
68
+ codex-copilot-dx --help`;
69
+ }
package/src/config.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { status } from "./status.mjs";
5
+ import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
5
6
 
6
7
  const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
7
8
 
@@ -74,20 +75,23 @@ OPENAI_API_KEY = "dummy"
74
75
  `;
75
76
  }
76
77
 
77
- export function ensureCodexConfig(adapterPort = 2026) {
78
+ export function ensureCodexConfig(adapterPort = 2026, { filePath = CONFIG_PATH } = {}) {
78
79
  const baseUrl = `http://127.0.0.1:${adapterPort}/v1`;
79
80
 
80
- if (!fs.existsSync(CONFIG_PATH)) {
81
+ if (!fs.existsSync(filePath)) {
81
82
  // Codex config does not exist yet; create the local proxy defaults.
82
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
83
- fs.writeFileSync(CONFIG_PATH, initialCodexConfig(adapterPort));
83
+ atomicWriteFileIfChangedSync(filePath, initialCodexConfig(adapterPort));
84
84
  console.log(status("ok", "Created ~/.codex/config.toml"));
85
85
  return;
86
86
  }
87
87
 
88
- let content = fs.readFileSync(CONFIG_PATH, "utf-8");
88
+ const content = fs.readFileSync(filePath, "utf-8");
89
89
  const updated = computeUpdatedCodexConfig(content, adapterPort);
90
90
 
91
- fs.writeFileSync(CONFIG_PATH, updated.content);
91
+ if (!updated.changed) {
92
+ console.log(status("ok", `Codex already points to ${baseUrl}`));
93
+ return;
94
+ }
95
+ atomicWriteFileIfChangedSync(filePath, updated.content);
92
96
  console.log(status("ok", `Configured Codex base URL: ${baseUrl}`));
93
97
  }
package/src/copilot.mjs CHANGED
@@ -4,6 +4,21 @@ import { randomUUID } from "node:crypto";
4
4
  import { status } from "./status.mjs";
5
5
  import { debugLog } from "./log.mjs";
6
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;
@@ -35,25 +50,12 @@ export function getCachedModelEndpoints(modelId) {
35
50
  export function resetModelEndpointCacheForTests() {
36
51
  modelEndpointCache.clear();
37
52
  }
38
- const IMG_MAX_DIM = parseInt(process.env.CCDX_IMG_MAX_DIM || "2048", 10);
39
- const IMG_QUALITY = parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
40
- const IMG_MIN_BYTES = parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
41
- const DEFAULT_IMG_CONCURRENCY = 4;
42
- const IMG_MAX_CONCURRENCY = 12;
43
53
  const DEFAULT_UPSTREAM_RETRIES = 2;
44
54
  const MAX_UPSTREAM_RETRIES = 5;
45
55
  const DEFAULT_UPSTREAM_RETRY_DELAY_MS = 300;
46
56
  const MAX_UPSTREAM_RETRY_DELAY_MS = 5000;
47
- const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
48
- const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
49
57
  const UPSTREAM_RETRIES = parseUpstreamRetries(process.env.CCDX_UPSTREAM_RETRIES);
50
58
  const UPSTREAM_RETRY_DELAY_MS = parseUpstreamRetryDelayMs(process.env.CCDX_UPSTREAM_RETRY_DELAY_MS);
51
- let sharpImport = null;
52
-
53
- export function parseImageConcurrency(value) {
54
- const n = Number.parseInt(value, 10);
55
- return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
56
- }
57
59
 
58
60
  export function parseUpstreamRetries(value) {
59
61
  const n = Number.parseInt(value, 10);
@@ -200,149 +202,6 @@ export async function fetchCopilotUpstream(
200
202
  }
201
203
  }
202
204
 
203
- async function sharp() {
204
- sharpImport ||= import("sharp");
205
- const mod = await sharpImport;
206
- return mod.default || mod;
207
- }
208
-
209
- export async function optimizeImageDataUrl(dataUrl) {
210
- if (IMG_OPT_DISABLED) return dataUrl;
211
- if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
212
- const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
213
- if (!match) return dataUrl;
214
- const mime = match[1].toLowerCase();
215
- const raw = Buffer.from(match[2], "base64");
216
- if (raw.length < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
217
-
218
- try {
219
- const resize = await sharp();
220
- const out = await resize(raw, { failOn: "none" })
221
- .rotate()
222
- .resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
223
- .webp({ quality: IMG_QUALITY, effort: 4 })
224
- .toBuffer();
225
- const ratio = ((out.length / raw.length) * 100).toFixed(1);
226
- console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
227
- return `data:image/webp;base64,${out.toString("base64")}`;
228
- } catch (e) {
229
- console.warn(status("warn", `image optimize failed (${mime}, ${raw.length}b): ${e.message}`));
230
- return dataUrl;
231
- }
232
- }
233
-
234
- export async function runWithConcurrency(taskFns, concurrency) {
235
- if (!Array.isArray(taskFns) || taskFns.length === 0) return;
236
- const limit = Math.min(parseImageConcurrency(concurrency), taskFns.length);
237
- let next = 0;
238
- await Promise.all(Array.from({ length: limit }, async () => {
239
- while (next < taskFns.length) {
240
- const task = taskFns[next++];
241
- await task();
242
- }
243
- }));
244
- }
245
-
246
- function visitImageParts(parts, tasks) {
247
- if (!Array.isArray(parts)) return;
248
- for (const part of parts) {
249
- if (!part) continue;
250
- if (part.type === "input_image" && typeof part.image_url === "string") {
251
- tasks.push(async () => { part.image_url = await optimizeImageDataUrl(part.image_url); });
252
- } else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
253
- tasks.push(async () => {
254
- const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
255
- const opt = await optimizeImageDataUrl(dataUrl);
256
- const match = /^data:([^;]+);base64,(.+)$/.exec(opt);
257
- if (match) {
258
- part.source.media_type = match[1];
259
- part.source.data = match[2];
260
- }
261
- });
262
- }
263
- }
264
- }
265
-
266
- export async function optimizeImagesInBody(reqBody) {
267
- if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
268
- const tasks = [];
269
-
270
- for (const item of reqBody.input) {
271
- if (!item) continue;
272
- if (item.type === "message" && Array.isArray(item.content)) {
273
- visitImageParts(item.content, tasks);
274
- }
275
- if (item.type === "function_call_output") {
276
- if (Array.isArray(item.output)) {
277
- visitImageParts(item.output, tasks);
278
- } else if (typeof item.output === "string") {
279
- const trimmed = item.output.trim();
280
- if (trimmed.startsWith("[")) {
281
- try {
282
- const parsed = JSON.parse(trimmed);
283
- if (Array.isArray(parsed)) {
284
- const localTasks = [];
285
- visitImageParts(parsed, localTasks);
286
- if (localTasks.length) {
287
- tasks.push(async () => {
288
- await runWithConcurrency(localTasks, IMG_CONCURRENCY);
289
- item.output = JSON.stringify(parsed);
290
- });
291
- }
292
- }
293
- } catch {
294
- // Leave non-JSON tool output untouched.
295
- }
296
- }
297
- }
298
- }
299
- }
300
-
301
- if (tasks.length) await runWithConcurrency(tasks, IMG_CONCURRENCY);
302
- return reqBody;
303
- }
304
-
305
- export function summarizeReqBody(reqBody) {
306
- try {
307
- const input = reqBody.input;
308
- if (!Array.isArray(input)) return { items: 0, images: 0, biggest: 0, biggestKind: "n/a" };
309
- let biggest = 0;
310
- let biggestKind = "";
311
- let images = 0;
312
- const countImages = (parts) => {
313
- if (!Array.isArray(parts)) return;
314
- for (const part of parts) {
315
- if (part?.type === "input_image" || part?.type === "image") images++;
316
- }
317
- };
318
-
319
- for (const item of input) {
320
- const size = JSON.stringify(item).length;
321
- if (size > biggest) {
322
- biggest = size;
323
- biggestKind = item?.type || "?";
324
- }
325
- if (item?.type === "message") countImages(item.content);
326
- if (item?.type === "function_call_output") {
327
- if (Array.isArray(item.output)) {
328
- countImages(item.output);
329
- } else if (typeof item.output === "string" && item.output.trim().startsWith("[")) {
330
- try {
331
- const parsed = JSON.parse(item.output);
332
- if (Array.isArray(parsed)) countImages(parsed);
333
- } catch {
334
- // Ignore non-JSON tool output.
335
- }
336
- }
337
- }
338
- }
339
-
340
- return { items: input.length, images, biggest, biggestKind };
341
- } catch {
342
- return { items: -1, images: -1, biggest: -1, biggestKind: "err" };
343
- }
344
- }
345
-
346
205
  export function computeInitiator(messages) {
347
206
  const isAgent = Array.isArray(messages)
348
207
  && messages.some((m) => m && ["assistant", "tool"].includes(m.role));
@@ -525,11 +384,11 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
525
384
  // Responses-only models go directly to Copilot's /responses endpoint.
526
385
  export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
527
386
  const token = await getCopilotToken({ signal });
528
- const beforeBytes = Buffer.byteLength(JSON.stringify(reqBody));
529
387
  await optimizeImagesInBody(reqBody);
530
- const bodyBuf = Buffer.from(JSON.stringify(reqBody), "utf8");
388
+ const bodyText = JSON.stringify(reqBody);
389
+ const bodyBytes = Buffer.byteLength(bodyText);
531
390
  const summary = summarizeReqBody(reqBody);
532
- 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}`));
533
392
  const headers = buildHeaders({
534
393
  token,
535
394
  version: getVSCodeVersion(),
@@ -537,14 +396,14 @@ export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {
537
396
  vision: false,
538
397
  });
539
398
  headers["Content-Type"] = "application/json; charset=utf-8";
540
- headers["Content-Length"] = String(bodyBuf.length);
399
+ headers["Content-Length"] = String(bodyBytes);
541
400
  headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
542
401
 
543
402
  try {
544
403
  return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
545
404
  method: "POST",
546
405
  headers,
547
- body: bodyBuf,
406
+ body: bodyText,
548
407
  signal,
549
408
  }, { fetchImpl, ...retryOptions });
550
409
  } catch (e) {
@@ -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
+ }