codex-copilot-dx 0.4.28 → 0.4.30
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/LICENSE +21 -0
- package/README.md +8 -5
- package/package.json +12 -3
- package/src/adapter.mjs +60 -16
- package/src/copilot.mjs +3 -11
- package/src/http-transport.mjs +46 -8
- package/src/image-optimization.mjs +103 -23
- package/src/lock.mjs +58 -5
- package/src/stream.mjs +22 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dale Xiao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Codex Auto-review requests use the hidden `codex-auto-review` model ID. The adap
|
|
|
17
17
|
## Prerequisites
|
|
18
18
|
|
|
19
19
|
- GitHub Copilot subscription (Individual, Business, or Enterprise)
|
|
20
|
-
- Node.js
|
|
20
|
+
- Node.js 22.15+ (required for built-in Zstandard request decompression)
|
|
21
21
|
- [Codex Desktop](https://openai.com/codex), [Claude Code](https://claude.com/claude-code), and/or Claude App installed
|
|
22
22
|
|
|
23
23
|
## Usage
|
|
@@ -100,8 +100,10 @@ Environment variables:
|
|
|
100
100
|
| `ADAPTER_HOST` | `127.0.0.1` | Host for the adapter; only loopback hosts are allowed by default |
|
|
101
101
|
| `ADAPTER_PORT` | `2026` | Port for the adapter |
|
|
102
102
|
| `CCDX_ALLOW_LAN` | unset | Set to `1` to allow non-loopback `ADAPTER_HOST` values such as `0.0.0.0`; exposes your Copilot-backed adapter beyond this machine |
|
|
103
|
-
| `CCDX_MAX_BODY_BYTES` | `
|
|
104
|
-
| `CCDX_MAX_DECODED_BODY_BYTES` | `
|
|
103
|
+
| `CCDX_MAX_BODY_BYTES` | `67108864` | Maximum compressed/raw request body size |
|
|
104
|
+
| `CCDX_MAX_DECODED_BODY_BYTES` | `134217728` | Maximum decoded request body size after decompression |
|
|
105
|
+
| `CCDX_MAX_UPSTREAM_BODY_BYTES` | `31457280` | Responses body target; larger image payloads are compressed more aggressively before forwarding |
|
|
106
|
+
| `CCDX_MAX_SSE_BUFFER_BYTES` | `8388608` | Maximum buffered bytes for one unterminated upstream SSE line/event |
|
|
105
107
|
| `CCDX_UPSTREAM_TIMEOUT_MS` | `120000` | Timeout for non-streaming upstream Copilot requests |
|
|
106
108
|
| `CCDX_STREAM_HANDSHAKE_TIMEOUT_MS` | `120000` | Timeout while waiting for upstream streaming response headers |
|
|
107
109
|
| `CCDX_STREAM_IDLE_TIMEOUT_MS` | `120000` | Maximum idle time between upstream streaming body chunks |
|
|
@@ -114,7 +116,8 @@ Environment variables:
|
|
|
114
116
|
| `CCDX_IMG_MAX_DIM` | `2048` | Max long edge in pixels for image downscaling |
|
|
115
117
|
| `CCDX_IMG_QUALITY` | `85` | WebP quality used when re-encoding images |
|
|
116
118
|
| `CCDX_IMG_MIN_BYTES` | `100000` | Images smaller than this are left untouched |
|
|
117
|
-
| `CCDX_IMG_CONCURRENCY` | `
|
|
119
|
+
| `CCDX_IMG_CONCURRENCY` | `2` | Global concurrent image optimization tasks; values above `12` are capped at `12` |
|
|
120
|
+
| `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
|
|
118
121
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
119
122
|
| `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
|
|
120
123
|
| `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup | Bearer key written into the Claude App profile and recognized by the adapter for model discovery |
|
|
@@ -151,7 +154,7 @@ codex-copilot-dx usage
|
|
|
151
154
|
|
|
152
155
|
Long computer-use sessions can accumulate screenshots inside the conversation history. Each screenshot is shipped on later turns, and GitHub Copilot's `/responses` endpoint can reject oversized requests with `413 Payload Too Large`.
|
|
153
156
|
|
|
154
|
-
The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`.
|
|
157
|
+
The adapter automatically downsamples embedded screenshots to long-edge <= 2048 px and re-encodes them as WebP before forwarding `/v1/responses`. It keeps an existing in-bounds WebP unchanged, never replaces an image with a larger encoding, and applies one global concurrency limit across direct and tool-output images.
|
|
155
158
|
|
|
156
159
|
Newer ChatGPT/Codex clients can advertise an `image_gen` namespace that already exists upstream. The adapter removes that exact conflicting client tool before forwarding and retries once only when Copilot explicitly reports an image namespace collision. Image inputs and screenshot optimization remain enabled.
|
|
157
160
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-copilot-dx",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.30",
|
|
4
4
|
"description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,15 +25,24 @@
|
|
|
25
25
|
"responses-api"
|
|
26
26
|
],
|
|
27
27
|
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/dalexiao/codex-copilot-dx.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/dalexiao/codex-copilot-dx#readme",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/dalexiao/codex-copilot-dx/issues"
|
|
35
|
+
},
|
|
28
36
|
"engines": {
|
|
29
|
-
"node": ">=
|
|
37
|
+
"node": ">=22.15.0"
|
|
30
38
|
},
|
|
31
39
|
"scripts": {
|
|
32
40
|
"test": "node --test",
|
|
33
41
|
"test:smoke": "node scripts/offline-smoke.mjs",
|
|
34
42
|
"test:e2e": "bash test/e2e.sh",
|
|
35
43
|
"pack:check": "npm pack --dry-run --json",
|
|
36
|
-
"verify": "npm test && npm run test:smoke && npm run pack:check"
|
|
44
|
+
"verify": "npm test && npm run test:smoke && npm run pack:check",
|
|
45
|
+
"prepublishOnly": "npm run verify"
|
|
37
46
|
},
|
|
38
47
|
"dependencies": {
|
|
39
48
|
"gpt-tokenizer": "^3.4.0",
|
package/src/adapter.mjs
CHANGED
|
@@ -69,6 +69,7 @@ function isResponsesOnlyModel(model) {
|
|
|
69
69
|
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120 * 1000;
|
|
70
70
|
const DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS = 120 * 1000;
|
|
71
71
|
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120 * 1000;
|
|
72
|
+
const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
72
73
|
|
|
73
74
|
function positiveInt(value, fallback) {
|
|
74
75
|
const n = Number.parseInt(value, 10);
|
|
@@ -78,6 +79,7 @@ function positiveInt(value, fallback) {
|
|
|
78
79
|
const UPSTREAM_TIMEOUT_MS = positiveInt(process.env.CCDX_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS);
|
|
79
80
|
const STREAM_HANDSHAKE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_HANDSHAKE_TIMEOUT_MS, DEFAULT_STREAM_HANDSHAKE_TIMEOUT_MS);
|
|
80
81
|
const STREAM_IDLE_TIMEOUT_MS = positiveInt(process.env.CCDX_STREAM_IDLE_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS);
|
|
82
|
+
const MAX_SSE_BUFFER_BYTES = positiveInt(process.env.CCDX_MAX_SSE_BUFFER_BYTES, DEFAULT_MAX_SSE_BUFFER_BYTES);
|
|
81
83
|
|
|
82
84
|
export function requestPath(reqUrl) {
|
|
83
85
|
return new URL(reqUrl || "/", "http://localhost").pathname;
|
|
@@ -324,6 +326,9 @@ function readSseEvents(buffer, onData) {
|
|
|
324
326
|
const match = buffer.match(/\r?\n\r?\n/);
|
|
325
327
|
if (!match) return buffer;
|
|
326
328
|
const chunk = buffer.slice(0, match.index);
|
|
329
|
+
if (Buffer.byteLength(chunk) > MAX_SSE_BUFFER_BYTES) {
|
|
330
|
+
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
331
|
+
}
|
|
327
332
|
buffer = buffer.slice(match.index + match[0].length);
|
|
328
333
|
const data = chunk
|
|
329
334
|
.split(/\r?\n/)
|
|
@@ -334,6 +339,22 @@ function readSseEvents(buffer, onData) {
|
|
|
334
339
|
}
|
|
335
340
|
}
|
|
336
341
|
|
|
342
|
+
function streamErrorData(protocol, error, abort) {
|
|
343
|
+
const code = abort?.reason || "upstream_stream_error";
|
|
344
|
+
const message = `${code}: ${error?.message || "Upstream stream failed"}`;
|
|
345
|
+
if (protocol === "anthropic") {
|
|
346
|
+
return { type: "error", error: { type: "api_error", message } };
|
|
347
|
+
}
|
|
348
|
+
return { type: "error", code, message, param: null };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function endStreamWithError(res, protocol, error, abort) {
|
|
352
|
+
if (res.destroyed || res.writableEnded) return;
|
|
353
|
+
const data = streamErrorData(protocol, error, abort);
|
|
354
|
+
await writeOrDrain(res, `event: error\ndata: ${JSON.stringify(data)}\n\n`).catch(() => false);
|
|
355
|
+
if (!res.destroyed && !res.writableEnded) res.end();
|
|
356
|
+
}
|
|
357
|
+
|
|
337
358
|
async function proxyCopilotResponses(reqContext, req, res, upstream = copilotResponses, options = {}) {
|
|
338
359
|
const opened = await openCopilotResponse(reqContext, upstream, options);
|
|
339
360
|
const { resp, errorText } = opened;
|
|
@@ -358,6 +379,9 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
358
379
|
const { done, value } = await reader.read();
|
|
359
380
|
if (done) {
|
|
360
381
|
buffer += decoder.decode();
|
|
382
|
+
if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
|
|
383
|
+
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
384
|
+
}
|
|
361
385
|
readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
|
|
362
386
|
res.end();
|
|
363
387
|
return;
|
|
@@ -365,8 +389,14 @@ async function proxyCopilotResponses(reqContext, req, res, upstream = copilotRes
|
|
|
365
389
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
366
390
|
buffer += decoder.decode(value, { stream: true });
|
|
367
391
|
buffer = readSseEvents(buffer, (data) => storeCompletedResponseFromSse(reqContext, data));
|
|
392
|
+
if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
|
|
393
|
+
throw httpError(`Upstream SSE buffer exceeds ${MAX_SSE_BUFFER_BYTES} bytes`, 502);
|
|
394
|
+
}
|
|
368
395
|
if (!await writeOrDrain(res, value)) return;
|
|
369
396
|
}
|
|
397
|
+
} catch (e) {
|
|
398
|
+
logRequestFailure("Responses", e, options.abort);
|
|
399
|
+
await endStreamWithError(res, "responses", e, options.abort);
|
|
370
400
|
} finally {
|
|
371
401
|
await reader.cancel().catch(() => {});
|
|
372
402
|
reader.releaseLock();
|
|
@@ -508,11 +538,11 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
508
538
|
}, { signal: options.signal });
|
|
509
539
|
} catch (e) {
|
|
510
540
|
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
511
|
-
onError(statusCode, e.message);
|
|
541
|
+
await onError(statusCode, e.message);
|
|
512
542
|
return;
|
|
513
543
|
}
|
|
514
544
|
if (!resp.ok) {
|
|
515
|
-
onError(resp.status, await resp.text());
|
|
545
|
+
await onError(resp.status, await resp.text());
|
|
516
546
|
return;
|
|
517
547
|
}
|
|
518
548
|
options.abort?.setTimeout(options.streamIdleTimeoutMs, "stream_idle_timeout");
|
|
@@ -655,7 +685,8 @@ export async function forwardToChat(chatReq, emitEvent, onDone, onError, options
|
|
|
655
685
|
}
|
|
656
686
|
}
|
|
657
687
|
} catch (e) {
|
|
658
|
-
|
|
688
|
+
const statusCode = isAbortLikeError(e) ? abortErrorStatusCode(options.abort?.reason) : 502;
|
|
689
|
+
await onError(statusCode, e?.message || "upstream stream error");
|
|
659
690
|
return;
|
|
660
691
|
}
|
|
661
692
|
await emitCompleted();
|
|
@@ -723,7 +754,14 @@ export function createAdapterHandler(options = {}) {
|
|
|
723
754
|
rememberResponseHistory(prepared, data.response);
|
|
724
755
|
recordResponsesUsage({ surface: prepared.surface, mode: "stream", model, response: data.response, event: data });
|
|
725
756
|
}
|
|
726
|
-
}, () => { if (!res.writableEnded) res.end(); }, (
|
|
757
|
+
}, () => { if (!res.writableEnded) res.end(); }, async (statusCode, errMsg) => {
|
|
758
|
+
if (!res.headersSent) {
|
|
759
|
+
res.writeHead(statusCode || 500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
760
|
+
res.end(errMsg);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
await endStreamWithError(res, "responses", new Error(errMsg), abort);
|
|
764
|
+
}, {
|
|
727
765
|
signal: abort.signal,
|
|
728
766
|
abort,
|
|
729
767
|
streamIdleTimeoutMs,
|
|
@@ -855,18 +893,24 @@ export function createAdapterHandler(options = {}) {
|
|
|
855
893
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
856
894
|
let messageId;
|
|
857
895
|
let usage;
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
896
|
+
try {
|
|
897
|
+
await streamAnthropicFromLines(
|
|
898
|
+
webStreamLines(upstream, {
|
|
899
|
+
onChunk: () => abort.setTimeout(streamIdleTimeoutMs, "stream_idle_timeout"),
|
|
900
|
+
}),
|
|
901
|
+
async (event, data) => {
|
|
902
|
+
if (event === "message_start") messageId = data.message?.id;
|
|
903
|
+
if (event === "message_delta") usage = data.usage;
|
|
904
|
+
await writeOrDrain(res, `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
905
|
+
},
|
|
906
|
+
requestedModel,
|
|
907
|
+
{ forceModel: forceRequestedModel },
|
|
908
|
+
);
|
|
909
|
+
} catch (e) {
|
|
910
|
+
logRequestFailure("Messages", e, abort);
|
|
911
|
+
await endStreamWithError(res, "anthropic", e, abort);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
870
914
|
recordAnthropicUsage({ surface: "messages", mode: "stream", model: requestedModel, responseId: messageId, usage });
|
|
871
915
|
if (!res.writableEnded) res.end();
|
|
872
916
|
} else {
|
package/src/copilot.mjs
CHANGED
|
@@ -4,18 +4,13 @@ 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";
|
|
7
|
+
import { prepareResponsesPayload } from "./image-optimization.mjs";
|
|
14
8
|
|
|
15
9
|
export {
|
|
16
10
|
optimizeImageDataUrl,
|
|
17
11
|
optimizeImagesInBody,
|
|
18
12
|
parseImageConcurrency,
|
|
13
|
+
prepareResponsesPayload,
|
|
19
14
|
runWithConcurrency,
|
|
20
15
|
summarizeReqBody,
|
|
21
16
|
} from "./image-optimization.mjs";
|
|
@@ -384,10 +379,7 @@ export async function listModels({ signal, fetchImpl, retryOptions } = {}) {
|
|
|
384
379
|
// Responses-only models go directly to Copilot's /responses endpoint.
|
|
385
380
|
export async function responses(reqBody, { signal, fetchImpl, retryOptions } = {}) {
|
|
386
381
|
const token = await getCopilotToken({ signal });
|
|
387
|
-
await
|
|
388
|
-
const bodyText = JSON.stringify(reqBody);
|
|
389
|
-
const bodyBytes = Buffer.byteLength(bodyText);
|
|
390
|
-
const summary = summarizeReqBody(reqBody);
|
|
382
|
+
const { bodyText, bodyBytes, summary } = await prepareResponsesPayload(reqBody);
|
|
391
383
|
console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
|
|
392
384
|
const headers = buildHeaders({
|
|
393
385
|
token,
|
package/src/http-transport.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import { promisify } from "node:util";
|
|
|
2
2
|
import * as zlib from "node:zlib";
|
|
3
3
|
import { status } from "./status.mjs";
|
|
4
4
|
|
|
5
|
-
const DEFAULT_MAX_BODY_BYTES =
|
|
6
|
-
const DEFAULT_MAX_DECODED_BODY_BYTES =
|
|
5
|
+
const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
6
|
+
const DEFAULT_MAX_DECODED_BODY_BYTES = 128 * 1024 * 1024;
|
|
7
7
|
const gunzipAsync = promisify(zlib.gunzip);
|
|
8
8
|
const inflateAsync = promisify(zlib.inflate);
|
|
9
9
|
const brotliDecompressAsync = promisify(zlib.brotliDecompress);
|
|
@@ -27,8 +27,12 @@ function payloadTooLarge(kind, maxBytes) {
|
|
|
27
27
|
return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function requestContentLength(req) {
|
|
31
|
+
return Number.parseInt(req.headers?.["content-length"] || "", 10);
|
|
32
|
+
}
|
|
33
|
+
|
|
30
34
|
async function readRequestBuffer(req, maxBytes) {
|
|
31
|
-
const contentLength =
|
|
35
|
+
const contentLength = requestContentLength(req);
|
|
32
36
|
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
33
37
|
throw payloadTooLarge("Raw", maxBytes);
|
|
34
38
|
}
|
|
@@ -44,6 +48,35 @@ async function readRequestBuffer(req, maxBytes) {
|
|
|
44
48
|
return Buffer.concat(chunks);
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
async function readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes) {
|
|
52
|
+
const contentLength = requestContentLength(req);
|
|
53
|
+
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
54
|
+
throw payloadTooLarge("Raw", maxBodyBytes);
|
|
55
|
+
}
|
|
56
|
+
if (Number.isFinite(contentLength) && contentLength > maxDecodedBodyBytes) {
|
|
57
|
+
throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const decoder = new TextDecoder();
|
|
61
|
+
let text = "";
|
|
62
|
+
let total = 0;
|
|
63
|
+
for await (const chunk of req) {
|
|
64
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
65
|
+
total += buffer.length;
|
|
66
|
+
if (total > maxBodyBytes) throw payloadTooLarge("Raw", maxBodyBytes);
|
|
67
|
+
if (total > maxDecodedBodyBytes) throw payloadTooLarge("Decoded", maxDecodedBodyBytes);
|
|
68
|
+
text += decoder.decode(buffer, { stream: true });
|
|
69
|
+
}
|
|
70
|
+
return text + decoder.decode();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contentEncodings(contentEncoding) {
|
|
74
|
+
return String(contentEncoding || "identity")
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((value) => value.trim().toLowerCase())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
async function decompressBody(decompress, buffer, maxBytes) {
|
|
48
81
|
try {
|
|
49
82
|
return await decompress(buffer, { maxOutputLength: maxBytes });
|
|
@@ -56,10 +89,7 @@ async function decompressBody(decompress, buffer, maxBytes) {
|
|
|
56
89
|
}
|
|
57
90
|
|
|
58
91
|
async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
|
|
59
|
-
const encodings =
|
|
60
|
-
.split(",")
|
|
61
|
-
.map((value) => value.trim().toLowerCase())
|
|
62
|
-
.filter(Boolean);
|
|
92
|
+
const encodings = contentEncodings(contentEncoding);
|
|
63
93
|
|
|
64
94
|
let decoded = buffer;
|
|
65
95
|
for (const encoding of encodings.reverse()) {
|
|
@@ -86,6 +116,10 @@ export async function readJsonBody(req, {
|
|
|
86
116
|
maxBodyBytes = MAX_BODY_BYTES,
|
|
87
117
|
maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
|
|
88
118
|
} = {}) {
|
|
119
|
+
const encodings = contentEncodings(req.headers?.["content-encoding"]);
|
|
120
|
+
if (encodings.every((encoding) => encoding === "identity")) {
|
|
121
|
+
return JSON.parse(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes));
|
|
122
|
+
}
|
|
89
123
|
const buffer = await readRequestBuffer(req, maxBodyBytes);
|
|
90
124
|
const decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
|
|
91
125
|
return JSON.parse(decoded.toString("utf8"));
|
|
@@ -93,7 +127,11 @@ export async function readJsonBody(req, {
|
|
|
93
127
|
|
|
94
128
|
export function sendJsonError(res, err, fallbackStatus = 400) {
|
|
95
129
|
if (res.destroyed || res.writableEnded) return;
|
|
96
|
-
if (
|
|
130
|
+
if (res.headersSent) {
|
|
131
|
+
res.end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
res.writeHead(err?.statusCode || fallbackStatus, { "Content-Type": "application/json" });
|
|
97
135
|
res.end(JSON.stringify({ error: err?.message || "Request failed" }));
|
|
98
136
|
}
|
|
99
137
|
|
|
@@ -1,14 +1,35 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { status } from "./status.mjs";
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
const IMG_QUALITY = Number.parseInt(process.env.CCDX_IMG_QUALITY || "85", 10);
|
|
5
|
-
const IMG_MIN_BYTES = Number.parseInt(process.env.CCDX_IMG_MIN_BYTES || "100000", 10);
|
|
6
|
-
const DEFAULT_IMG_CONCURRENCY = 4;
|
|
4
|
+
const DEFAULT_IMG_CONCURRENCY = 2;
|
|
7
5
|
const IMG_MAX_CONCURRENCY = 12;
|
|
6
|
+
const DEFAULT_IMG_MAX_INPUT_PIXELS = 40 * 1000 * 1000;
|
|
7
|
+
const DEFAULT_MAX_UPSTREAM_BODY_BYTES = 30 * 1024 * 1024;
|
|
8
|
+
const MAX_OPTIMIZED_IMAGE_DIGESTS = 4096;
|
|
9
|
+
const IMG_MAX_DIM = positiveInt(process.env.CCDX_IMG_MAX_DIM, 2048);
|
|
10
|
+
const IMG_QUALITY = positiveInt(process.env.CCDX_IMG_QUALITY, 85, 100);
|
|
11
|
+
const IMG_MIN_BYTES = nonNegativeInt(process.env.CCDX_IMG_MIN_BYTES, 100000);
|
|
12
|
+
const IMG_MAX_INPUT_PIXELS = positiveInt(process.env.CCDX_IMG_MAX_INPUT_PIXELS, DEFAULT_IMG_MAX_INPUT_PIXELS);
|
|
13
|
+
const MAX_UPSTREAM_BODY_BYTES = positiveInt(process.env.CCDX_MAX_UPSTREAM_BODY_BYTES, DEFAULT_MAX_UPSTREAM_BODY_BYTES);
|
|
8
14
|
const IMG_OPT_DISABLED = process.env.CCDX_DISABLE_IMG_OPT === "1";
|
|
9
15
|
const IMG_CONCURRENCY = parseImageConcurrency(process.env.CCDX_IMG_CONCURRENCY);
|
|
16
|
+
const OVERSIZE_IMAGE_PROFILES = [
|
|
17
|
+
{ maxDim: Math.min(IMG_MAX_DIM, 1600), quality: Math.min(IMG_QUALITY, 75) },
|
|
18
|
+
{ maxDim: Math.min(IMG_MAX_DIM, 1280), quality: Math.min(IMG_QUALITY, 65) },
|
|
19
|
+
];
|
|
20
|
+
const optimizedImageDigests = new Set();
|
|
10
21
|
let sharpImport = null;
|
|
11
22
|
|
|
23
|
+
function positiveInt(value, fallback, max = Number.MAX_SAFE_INTEGER) {
|
|
24
|
+
const n = Number.parseInt(value, 10);
|
|
25
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, max) : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nonNegativeInt(value, fallback) {
|
|
29
|
+
const n = Number.parseInt(value, 10);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
export function parseImageConcurrency(value) {
|
|
13
34
|
const n = Number.parseInt(value, 10);
|
|
14
35
|
return Number.isFinite(n) && n > 0 ? Math.min(n, IMG_MAX_CONCURRENCY) : DEFAULT_IMG_CONCURRENCY;
|
|
@@ -20,7 +41,29 @@ async function sharp() {
|
|
|
20
41
|
return mod.default || mod;
|
|
21
42
|
}
|
|
22
43
|
|
|
23
|
-
|
|
44
|
+
function imageDigest(raw) {
|
|
45
|
+
return createHash("sha256").update(raw).digest("base64url");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function touchOptimizedImage(digest) {
|
|
49
|
+
if (!optimizedImageDigests.delete(digest)) return false;
|
|
50
|
+
optimizedImageDigests.add(digest);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rememberOptimizedImage(digest) {
|
|
55
|
+
optimizedImageDigests.delete(digest);
|
|
56
|
+
optimizedImageDigests.add(digest);
|
|
57
|
+
if (optimizedImageDigests.size > MAX_OPTIMIZED_IMAGE_DIGESTS) {
|
|
58
|
+
optimizedImageDigests.delete(optimizedImageDigests.values().next().value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function optimizeImageDataUrl(dataUrl, {
|
|
63
|
+
maxDim = IMG_MAX_DIM,
|
|
64
|
+
quality = IMG_QUALITY,
|
|
65
|
+
force = false,
|
|
66
|
+
} = {}) {
|
|
24
67
|
if (IMG_OPT_DISABLED) return dataUrl;
|
|
25
68
|
if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) return dataUrl;
|
|
26
69
|
const match = /^data:(image\/[a-z+.-]+);base64,(.+)$/i.exec(dataUrl);
|
|
@@ -28,14 +71,22 @@ export async function optimizeImageDataUrl(dataUrl) {
|
|
|
28
71
|
const mime = match[1].toLowerCase();
|
|
29
72
|
const raw = Buffer.from(match[2], "base64");
|
|
30
73
|
if (raw.length < IMG_MIN_BYTES || mime.includes("gif")) return dataUrl;
|
|
74
|
+
const digest = imageDigest(raw);
|
|
75
|
+
if (!force && touchOptimizedImage(digest)) return dataUrl;
|
|
31
76
|
|
|
32
77
|
try {
|
|
33
78
|
const resize = await sharp();
|
|
34
|
-
const
|
|
79
|
+
const image = resize(raw, { failOn: "none", limitInputPixels: IMG_MAX_INPUT_PIXELS });
|
|
80
|
+
const out = await image
|
|
35
81
|
.rotate()
|
|
36
|
-
.resize(IMG_MAX_DIM, IMG_MAX_DIM, { fit: "inside", withoutEnlargement: true })
|
|
37
|
-
.webp({ quality: IMG_QUALITY, effort: 4 })
|
|
82
|
+
.resize(positiveInt(maxDim, IMG_MAX_DIM), positiveInt(maxDim, IMG_MAX_DIM), { fit: "inside", withoutEnlargement: true })
|
|
83
|
+
.webp({ quality: positiveInt(quality, IMG_QUALITY, 100), effort: 4 })
|
|
38
84
|
.toBuffer();
|
|
85
|
+
if (out.length >= raw.length) {
|
|
86
|
+
rememberOptimizedImage(digest);
|
|
87
|
+
return dataUrl;
|
|
88
|
+
}
|
|
89
|
+
rememberOptimizedImage(imageDigest(out));
|
|
39
90
|
const ratio = ((out.length / raw.length) * 100).toFixed(1);
|
|
40
91
|
console.log(status("info", `image ${(raw.length / 1024).toFixed(0)}KB ${mime} -> ${(out.length / 1024).toFixed(0)}KB webp (${ratio}%)`));
|
|
41
92
|
return `data:image/webp;base64,${out.toString("base64")}`;
|
|
@@ -57,16 +108,16 @@ export async function runWithConcurrency(taskFns, concurrency) {
|
|
|
57
108
|
}));
|
|
58
109
|
}
|
|
59
110
|
|
|
60
|
-
function visitImageParts(parts, tasks) {
|
|
111
|
+
function visitImageParts(parts, tasks, optimizeImage) {
|
|
61
112
|
if (!Array.isArray(parts)) return;
|
|
62
113
|
for (const part of parts) {
|
|
63
114
|
if (!part) continue;
|
|
64
115
|
if (part.type === "input_image" && typeof part.image_url === "string") {
|
|
65
|
-
tasks.push(async () => { part.image_url = await
|
|
116
|
+
tasks.push(async () => { part.image_url = await optimizeImage(part.image_url); });
|
|
66
117
|
} else if (part.type === "image" && part.source?.type === "base64" && part.source?.data) {
|
|
67
118
|
tasks.push(async () => {
|
|
68
119
|
const dataUrl = `data:${part.source.media_type || "image/png"};base64,${part.source.data}`;
|
|
69
|
-
const optimized = await
|
|
120
|
+
const optimized = await optimizeImage(dataUrl);
|
|
70
121
|
const match = /^data:([^;]+);base64,(.+)$/.exec(optimized);
|
|
71
122
|
if (match) {
|
|
72
123
|
part.source.media_type = match[1];
|
|
@@ -77,32 +128,31 @@ function visitImageParts(parts, tasks) {
|
|
|
77
128
|
}
|
|
78
129
|
}
|
|
79
130
|
|
|
80
|
-
export async function optimizeImagesInBody(reqBody
|
|
131
|
+
export async function optimizeImagesInBody(reqBody, {
|
|
132
|
+
concurrency = IMG_CONCURRENCY,
|
|
133
|
+
optimizeImage = optimizeImageDataUrl,
|
|
134
|
+
} = {}) {
|
|
81
135
|
if (IMG_OPT_DISABLED || !Array.isArray(reqBody.input)) return reqBody;
|
|
82
136
|
const tasks = [];
|
|
137
|
+
const finalize = [];
|
|
83
138
|
|
|
84
139
|
for (const item of reqBody.input) {
|
|
85
140
|
if (!item) continue;
|
|
86
141
|
if (item.type === "message" && Array.isArray(item.content)) {
|
|
87
|
-
visitImageParts(item.content, tasks);
|
|
142
|
+
visitImageParts(item.content, tasks, optimizeImage);
|
|
88
143
|
}
|
|
89
144
|
if (item.type === "function_call_output") {
|
|
90
145
|
if (Array.isArray(item.output)) {
|
|
91
|
-
visitImageParts(item.output, tasks);
|
|
146
|
+
visitImageParts(item.output, tasks, optimizeImage);
|
|
92
147
|
} else if (typeof item.output === "string") {
|
|
93
148
|
const trimmed = item.output.trim();
|
|
94
149
|
if (trimmed.startsWith("[")) {
|
|
95
150
|
try {
|
|
96
151
|
const parsed = JSON.parse(trimmed);
|
|
97
152
|
if (Array.isArray(parsed)) {
|
|
98
|
-
const
|
|
99
|
-
visitImageParts(parsed,
|
|
100
|
-
if (
|
|
101
|
-
tasks.push(async () => {
|
|
102
|
-
await runWithConcurrency(localTasks, IMG_CONCURRENCY);
|
|
103
|
-
item.output = JSON.stringify(parsed);
|
|
104
|
-
});
|
|
105
|
-
}
|
|
153
|
+
const taskCount = tasks.length;
|
|
154
|
+
visitImageParts(parsed, tasks, optimizeImage);
|
|
155
|
+
if (tasks.length > taskCount) finalize.push(() => { item.output = JSON.stringify(parsed); });
|
|
106
156
|
}
|
|
107
157
|
} catch {
|
|
108
158
|
// Leave non-JSON tool output untouched.
|
|
@@ -112,10 +162,40 @@ export async function optimizeImagesInBody(reqBody) {
|
|
|
112
162
|
}
|
|
113
163
|
}
|
|
114
164
|
|
|
115
|
-
if (tasks.length) await runWithConcurrency(tasks,
|
|
165
|
+
if (tasks.length) await runWithConcurrency(tasks, concurrency);
|
|
166
|
+
for (const apply of finalize) apply();
|
|
116
167
|
return reqBody;
|
|
117
168
|
}
|
|
118
169
|
|
|
170
|
+
export async function prepareResponsesPayload(reqBody, {
|
|
171
|
+
maxBytes = MAX_UPSTREAM_BODY_BYTES,
|
|
172
|
+
profiles = OVERSIZE_IMAGE_PROFILES,
|
|
173
|
+
} = {}) {
|
|
174
|
+
await optimizeImagesInBody(reqBody);
|
|
175
|
+
let bodyText = JSON.stringify(reqBody);
|
|
176
|
+
let bodyBytes = Buffer.byteLength(bodyText);
|
|
177
|
+
const summary = summarizeReqBody(reqBody);
|
|
178
|
+
const targetBytes = positiveInt(maxBytes, MAX_UPSTREAM_BODY_BYTES);
|
|
179
|
+
let adapted = false;
|
|
180
|
+
|
|
181
|
+
if (!IMG_OPT_DISABLED && summary.images > 0 && bodyBytes > targetBytes) {
|
|
182
|
+
for (const profile of profiles) {
|
|
183
|
+
const beforeBytes = bodyBytes;
|
|
184
|
+
bodyText = "";
|
|
185
|
+
await optimizeImagesInBody(reqBody, {
|
|
186
|
+
optimizeImage: (dataUrl) => optimizeImageDataUrl(dataUrl, { ...profile, force: true }),
|
|
187
|
+
});
|
|
188
|
+
bodyText = JSON.stringify(reqBody);
|
|
189
|
+
bodyBytes = Buffer.byteLength(bodyText);
|
|
190
|
+
adapted ||= bodyBytes < beforeBytes;
|
|
191
|
+
console.warn(status("warn", `responses payload ${beforeBytes}b exceeds ${targetBytes}b; image profile max_dim=${profile.maxDim} quality=${profile.quality} -> ${bodyBytes}b`));
|
|
192
|
+
if (bodyBytes <= targetBytes) break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { bodyText, bodyBytes, summary, adapted };
|
|
197
|
+
}
|
|
198
|
+
|
|
119
199
|
export function summarizeReqBody(reqBody) {
|
|
120
200
|
try {
|
|
121
201
|
const input = reqBody.input;
|
package/src/lock.mjs
CHANGED
|
@@ -1,15 +1,52 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
|
|
4
5
|
function sleep(ms) {
|
|
5
6
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
function readLockSnapshot(lockPath) {
|
|
10
|
+
const stat = fs.statSync(lockPath);
|
|
11
|
+
let record = {};
|
|
12
|
+
try {
|
|
13
|
+
record = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
14
|
+
} catch {}
|
|
15
|
+
return {
|
|
16
|
+
dev: stat.dev,
|
|
17
|
+
ino: stat.ino,
|
|
18
|
+
mtimeMs: stat.mtimeMs,
|
|
19
|
+
size: stat.size,
|
|
20
|
+
pid: Number(record.pid),
|
|
21
|
+
owner: typeof record.owner === "string" ? record.owner : "",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function processIsAlive(pid) {
|
|
26
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return e?.code === "EPERM";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sameLock(left, right) {
|
|
36
|
+
return left.dev === right.dev
|
|
37
|
+
&& left.ino === right.ino
|
|
38
|
+
&& left.mtimeMs === right.mtimeMs
|
|
39
|
+
&& left.size === right.size
|
|
40
|
+
&& left.owner === right.owner;
|
|
41
|
+
}
|
|
42
|
+
|
|
8
43
|
function maybeRemoveStaleLock(lockPath, staleMs, nowMs) {
|
|
9
44
|
if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
|
|
10
45
|
try {
|
|
11
|
-
const
|
|
12
|
-
if (nowMs -
|
|
46
|
+
const first = readLockSnapshot(lockPath);
|
|
47
|
+
if (nowMs - first.mtimeMs <= staleMs || processIsAlive(first.pid)) return false;
|
|
48
|
+
const current = readLockSnapshot(lockPath);
|
|
49
|
+
if (!sameLock(first, current)) return false;
|
|
13
50
|
fs.unlinkSync(lockPath);
|
|
14
51
|
return true;
|
|
15
52
|
} catch (e) {
|
|
@@ -26,12 +63,20 @@ export async function withFileLock(lockPath, fn, {
|
|
|
26
63
|
} = {}) {
|
|
27
64
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
28
65
|
const started = now();
|
|
66
|
+
const owner = randomUUID();
|
|
29
67
|
let fd = null;
|
|
30
68
|
|
|
31
69
|
while (fd === null) {
|
|
32
70
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
71
|
+
const candidateFd = fs.openSync(lockPath, "wx", 0o600);
|
|
72
|
+
try {
|
|
73
|
+
fs.writeFileSync(candidateFd, JSON.stringify({ pid: process.pid, owner, created_at: new Date().toISOString() }));
|
|
74
|
+
fd = candidateFd;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
fs.closeSync(candidateFd);
|
|
77
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
35
80
|
break;
|
|
36
81
|
} catch (e) {
|
|
37
82
|
if (e?.code !== "EEXIST") throw e;
|
|
@@ -50,11 +95,19 @@ export async function withFileLock(lockPath, fn, {
|
|
|
50
95
|
try {
|
|
51
96
|
if (fd !== null) fs.closeSync(fd);
|
|
52
97
|
} finally {
|
|
98
|
+
let currentOwner = "";
|
|
53
99
|
try {
|
|
54
|
-
|
|
100
|
+
currentOwner = readLockSnapshot(lockPath).owner;
|
|
55
101
|
} catch (e) {
|
|
56
102
|
if (e?.code !== "ENOENT") throw e;
|
|
57
103
|
}
|
|
104
|
+
if (currentOwner === owner) {
|
|
105
|
+
try {
|
|
106
|
+
fs.unlinkSync(lockPath);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e?.code !== "ENOENT") throw e;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
58
111
|
}
|
|
59
112
|
}
|
|
60
113
|
}
|
package/src/stream.mjs
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
// Yield fetch Response body lines while preserving SSE semantics for callers.
|
|
2
2
|
// Strip CRLF tails and release the reader if iteration stops early.
|
|
3
|
-
|
|
3
|
+
const DEFAULT_MAX_SSE_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
function positiveInt(value, fallback) {
|
|
6
|
+
const n = Number.parseInt(value, 10);
|
|
7
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const MAX_SSE_BUFFER_BYTES = positiveInt(process.env.CCDX_MAX_SSE_BUFFER_BYTES, DEFAULT_MAX_SSE_BUFFER_BYTES);
|
|
11
|
+
|
|
12
|
+
function assertSseBufferLimit(buffer, maxBufferBytes) {
|
|
13
|
+
if (Buffer.byteLength(buffer) > maxBufferBytes) {
|
|
14
|
+
throw new Error(`Upstream SSE buffer exceeds ${maxBufferBytes} bytes`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function* webStreamLines(response, { onChunk, maxBufferBytes = MAX_SSE_BUFFER_BYTES } = {}) {
|
|
4
19
|
const reader = response.body.getReader();
|
|
5
20
|
const decoder = new TextDecoder();
|
|
6
21
|
let buf = "";
|
|
@@ -12,9 +27,14 @@ export async function* webStreamLines(response, { onChunk } = {}) {
|
|
|
12
27
|
buf += decoder.decode(value, { stream: true });
|
|
13
28
|
const lines = buf.split("\n");
|
|
14
29
|
buf = lines.pop();
|
|
15
|
-
|
|
30
|
+
assertSseBufferLimit(buf, maxBufferBytes);
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
assertSseBufferLimit(line, maxBufferBytes);
|
|
33
|
+
yield line.replace(/\r$/, "");
|
|
34
|
+
}
|
|
16
35
|
}
|
|
17
36
|
buf += decoder.decode();
|
|
37
|
+
assertSseBufferLimit(buf, maxBufferBytes);
|
|
18
38
|
if (buf.length > 0) yield buf.replace(/\r$/, "");
|
|
19
39
|
} finally {
|
|
20
40
|
await reader.cancel().catch(() => {});
|