@bitkyc08/opencodex 2.10.2 → 2.11.0
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 +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
export const MAX_CLIENT_SSE_FRAME_BYTES = 4 * 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
const LF_LF = Uint8Array.of(10, 10);
|
|
4
|
+
const LF_CR_LF = Uint8Array.of(10, 13, 10);
|
|
5
|
+
const CR_LF_LF = Uint8Array.of(13, 10, 10);
|
|
6
|
+
const CR_LF_CR_LF = Uint8Array.of(13, 10, 13, 10);
|
|
7
|
+
|
|
8
|
+
export class SseFrameTooLargeError extends Error {
|
|
9
|
+
readonly maxBytes: number;
|
|
10
|
+
|
|
11
|
+
constructor(maxBytes: number) {
|
|
12
|
+
super(`upstream SSE frame exceeded ${maxBytes} bytes`);
|
|
13
|
+
this.name = "SseFrameTooLargeError";
|
|
14
|
+
this.maxBytes = maxBytes;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class SseFrameCountLimitError extends Error {
|
|
19
|
+
readonly maxFrames: number;
|
|
20
|
+
|
|
21
|
+
constructor(maxFrames: number) {
|
|
22
|
+
super(`upstream SSE chunk exceeded ${maxFrames} frame limit`);
|
|
23
|
+
this.name = "SseFrameCountLimitError";
|
|
24
|
+
this.maxFrames = maxFrames;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type BoundedSseFrame = {
|
|
29
|
+
block: Uint8Array;
|
|
30
|
+
delimiter: Uint8Array;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Classify the bytes at `index` as an SSE block delimiter.
|
|
35
|
+
*
|
|
36
|
+
* Returns the delimiter length in bytes, `0` when `index` does not start a
|
|
37
|
+
* delimiter, and `undefined` when more bytes are required to decide.
|
|
38
|
+
*/
|
|
39
|
+
function delimiterLengthAt(
|
|
40
|
+
index: number,
|
|
41
|
+
length: number,
|
|
42
|
+
byteAt: (index: number) => number,
|
|
43
|
+
): number | undefined {
|
|
44
|
+
const first = byteAt(index);
|
|
45
|
+
if (first === 10) {
|
|
46
|
+
if (index + 1 >= length) return undefined;
|
|
47
|
+
const second = byteAt(index + 1);
|
|
48
|
+
if (second === 10) return 2;
|
|
49
|
+
if (second !== 13) return 0;
|
|
50
|
+
if (index + 2 >= length) return undefined;
|
|
51
|
+
return byteAt(index + 2) === 10 ? 3 : 0;
|
|
52
|
+
}
|
|
53
|
+
if (first !== 13) return 0;
|
|
54
|
+
if (index + 1 >= length) return undefined;
|
|
55
|
+
if (byteAt(index + 1) !== 10) return 0;
|
|
56
|
+
if (index + 2 >= length) return undefined;
|
|
57
|
+
const third = byteAt(index + 2);
|
|
58
|
+
if (third === 10) return 3;
|
|
59
|
+
if (third !== 13) return 0;
|
|
60
|
+
if (index + 3 >= length) return undefined;
|
|
61
|
+
return byteAt(index + 3) === 10 ? 4 : 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function delimiterBytesAt(
|
|
65
|
+
index: number,
|
|
66
|
+
delimiterLength: number,
|
|
67
|
+
byteAt: (index: number) => number,
|
|
68
|
+
): Uint8Array {
|
|
69
|
+
if (delimiterLength === 2) return LF_LF;
|
|
70
|
+
if (delimiterLength === 4) return CR_LF_CR_LF;
|
|
71
|
+
return byteAt(index) === 10 ? LF_CR_LF : CR_LF_LF;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function copyRange(
|
|
75
|
+
start: number,
|
|
76
|
+
end: number,
|
|
77
|
+
tailLength: number,
|
|
78
|
+
previousTail: Uint8Array,
|
|
79
|
+
chunk: Uint8Array,
|
|
80
|
+
): Uint8Array {
|
|
81
|
+
const out = new Uint8Array(end - start);
|
|
82
|
+
for (let index = start; index < end; index += 1) {
|
|
83
|
+
out[index - start] = index < tailLength
|
|
84
|
+
? previousTail[index]!
|
|
85
|
+
: chunk[index - tailLength]!;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when a complete SSE block already commits a Responses terminal event.
|
|
92
|
+
*
|
|
93
|
+
* Framing errors in bytes *after* such a block must not retroactively turn an
|
|
94
|
+
* already-completed/failed/incomplete model turn into a transport failure. This
|
|
95
|
+
* helper is used only on the exceptional path, so decoding/JSON parsing has no
|
|
96
|
+
* cost on ordinary framing.
|
|
97
|
+
*/
|
|
98
|
+
function isResponsesTerminalFrame(block: Uint8Array): boolean {
|
|
99
|
+
const data: string[] = [];
|
|
100
|
+
for (const line of new TextDecoder().decode(block).split(/\r?\n/)) {
|
|
101
|
+
if (!line.startsWith("data:")) continue;
|
|
102
|
+
const value = line.slice(5);
|
|
103
|
+
data.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
104
|
+
}
|
|
105
|
+
if (data.length === 0) return false;
|
|
106
|
+
const payload = data.join("\n");
|
|
107
|
+
if (payload === "[DONE]") return false;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(payload) as { type?: unknown };
|
|
110
|
+
return parsed.type === "response.completed"
|
|
111
|
+
|| parsed.type === "response.failed"
|
|
112
|
+
|| parsed.type === "response.incomplete";
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Byte-bounded SSE block framer for client-facing protocol paths.
|
|
120
|
+
*
|
|
121
|
+
* The delimiter scanner works on raw bytes, so fragmented UTF-8 cannot change
|
|
122
|
+
* accounting and a hostile upstream cannot grow an unterminated JS string
|
|
123
|
+
* without limit. Candidate bytes live in one geometrically grown buffer rather
|
|
124
|
+
* than one allocation per upstream chunk, bounding both bytes and object count.
|
|
125
|
+
* Complete blocks are returned without their delimiter; the exact delimiter
|
|
126
|
+
* bytes are returned separately so callers can relay bytes unchanged.
|
|
127
|
+
*/
|
|
128
|
+
export class BoundedSseFrameBuffer {
|
|
129
|
+
private readonly maxFrameBytes: number;
|
|
130
|
+
private readonly maxFramesPerFeed: number;
|
|
131
|
+
private delimiterTail: Uint8Array = new Uint8Array(0);
|
|
132
|
+
private candidate: Uint8Array = new Uint8Array(0);
|
|
133
|
+
private candidateBytes = 0;
|
|
134
|
+
private disposed = false;
|
|
135
|
+
|
|
136
|
+
constructor(maxFrameBytes = MAX_CLIENT_SSE_FRAME_BYTES) {
|
|
137
|
+
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
|
|
138
|
+
throw new RangeError("maxFrameBytes must be a positive safe integer");
|
|
139
|
+
}
|
|
140
|
+
this.maxFrameBytes = maxFrameBytes;
|
|
141
|
+
// Delimiter-only input otherwise creates an object-amplification path that
|
|
142
|
+
// is independent of candidate bytes. Keep frame count proportional to the
|
|
143
|
+
// configured byte budget while leaving ample room for real Responses events.
|
|
144
|
+
this.maxFramesPerFeed = Math.max(1, Math.ceil(maxFrameBytes / 1024));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private clear(): void {
|
|
148
|
+
this.delimiterTail = new Uint8Array(0);
|
|
149
|
+
this.candidate = new Uint8Array(0);
|
|
150
|
+
this.candidateBytes = 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private ensureCapacity(requiredBytes: number): void {
|
|
154
|
+
if (this.candidate.byteLength >= requiredBytes) return;
|
|
155
|
+
if (requiredBytes > this.maxFrameBytes) {
|
|
156
|
+
throw new SseFrameTooLargeError(this.maxFrameBytes);
|
|
157
|
+
}
|
|
158
|
+
let capacity = this.candidate.byteLength === 0
|
|
159
|
+
? Math.min(this.maxFrameBytes, Math.max(requiredBytes, 4096))
|
|
160
|
+
: this.candidate.byteLength;
|
|
161
|
+
while (capacity < requiredBytes) {
|
|
162
|
+
capacity = Math.min(this.maxFrameBytes, Math.max(requiredBytes, capacity * 2));
|
|
163
|
+
}
|
|
164
|
+
const grown = new Uint8Array(capacity);
|
|
165
|
+
if (this.candidateBytes > 0) {
|
|
166
|
+
grown.set(this.candidate.subarray(0, this.candidateBytes));
|
|
167
|
+
}
|
|
168
|
+
this.candidate = grown;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private retain(slice: Uint8Array): void {
|
|
172
|
+
if (slice.byteLength === 0) return;
|
|
173
|
+
const nextBytes = this.candidateBytes + slice.byteLength;
|
|
174
|
+
if (nextBytes > this.maxFrameBytes) {
|
|
175
|
+
this.clear();
|
|
176
|
+
this.disposed = true;
|
|
177
|
+
throw new SseFrameTooLargeError(this.maxFrameBytes);
|
|
178
|
+
}
|
|
179
|
+
this.ensureCapacity(nextBytes);
|
|
180
|
+
this.candidate.set(slice, this.candidateBytes);
|
|
181
|
+
this.candidateBytes = nextBytes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private takeCandidate(): Uint8Array {
|
|
185
|
+
if (this.candidateBytes === 0) return new Uint8Array(0);
|
|
186
|
+
const block = this.candidate.slice(0, this.candidateBytes);
|
|
187
|
+
// Release the working allocation after each complete frame. This avoids
|
|
188
|
+
// retaining a rare multi-MiB frame allocation for the rest of a long-lived
|
|
189
|
+
// stream; normal small-frame allocation remains bounded by feed's frame cap.
|
|
190
|
+
this.candidate = new Uint8Array(0);
|
|
191
|
+
this.candidateBytes = 0;
|
|
192
|
+
return block;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
feed(chunk: Uint8Array): BoundedSseFrame[] {
|
|
196
|
+
if (this.disposed) return [];
|
|
197
|
+
if (chunk.byteLength === 0) return [];
|
|
198
|
+
|
|
199
|
+
const frames: BoundedSseFrame[] = [];
|
|
200
|
+
const previousTail = this.delimiterTail;
|
|
201
|
+
this.delimiterTail = new Uint8Array(0);
|
|
202
|
+
const tailLength = previousTail.byteLength;
|
|
203
|
+
const totalLength = tailLength + chunk.byteLength;
|
|
204
|
+
const byteAt = (index: number): number => index < tailLength
|
|
205
|
+
? previousTail[index]!
|
|
206
|
+
: chunk[index - tailLength]!;
|
|
207
|
+
const retainRange = (start: number, end: number): void => {
|
|
208
|
+
if (end <= start) return;
|
|
209
|
+
if (start < tailLength) {
|
|
210
|
+
this.retain(previousTail.subarray(start, Math.min(end, tailLength)));
|
|
211
|
+
}
|
|
212
|
+
if (end > tailLength) {
|
|
213
|
+
this.retain(chunk.subarray(Math.max(0, start - tailLength), end - tailLength));
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
let index = 0;
|
|
219
|
+
let retainedThrough = 0;
|
|
220
|
+
while (index < totalLength) {
|
|
221
|
+
const delimiterLength = delimiterLengthAt(index, totalLength, byteAt);
|
|
222
|
+
if (delimiterLength === undefined) break;
|
|
223
|
+
if (delimiterLength > 0) {
|
|
224
|
+
if (frames.length >= this.maxFramesPerFeed) {
|
|
225
|
+
this.clear();
|
|
226
|
+
this.disposed = true;
|
|
227
|
+
throw new SseFrameCountLimitError(this.maxFramesPerFeed);
|
|
228
|
+
}
|
|
229
|
+
retainRange(retainedThrough, index);
|
|
230
|
+
const block = this.takeCandidate();
|
|
231
|
+
const delimiter = delimiterBytesAt(index, delimiterLength, byteAt);
|
|
232
|
+
frames.push({ block, delimiter });
|
|
233
|
+
index += delimiterLength;
|
|
234
|
+
retainedThrough = index;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
index += 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
retainRange(retainedThrough, index);
|
|
241
|
+
if (index < totalLength) {
|
|
242
|
+
this.delimiterTail = copyRange(index, totalLength, tailLength, previousTail, chunk);
|
|
243
|
+
}
|
|
244
|
+
return frames;
|
|
245
|
+
} catch (err) {
|
|
246
|
+
const framingError = err instanceof SseFrameTooLargeError
|
|
247
|
+
|| err instanceof SseFrameCountLimitError;
|
|
248
|
+
if (framingError && frames.some(frame => isResponsesTerminalFrame(frame.block))) {
|
|
249
|
+
// A terminal frame is the Responses protocol boundary. Ignore malformed
|
|
250
|
+
// or oversized bytes that occur later in the same upstream chunk rather
|
|
251
|
+
// than retroactively replacing the committed terminal with a 502.
|
|
252
|
+
this.clear();
|
|
253
|
+
this.disposed = true;
|
|
254
|
+
return frames;
|
|
255
|
+
}
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Return the final unterminated block bytes and release all retained state. */
|
|
261
|
+
finish(): Uint8Array {
|
|
262
|
+
if (this.disposed) return new Uint8Array(0);
|
|
263
|
+
try {
|
|
264
|
+
this.retain(this.delimiterTail);
|
|
265
|
+
this.delimiterTail = new Uint8Array(0);
|
|
266
|
+
return this.takeCandidate();
|
|
267
|
+
} finally {
|
|
268
|
+
this.clear();
|
|
269
|
+
this.disposed = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
dispose(): void {
|
|
274
|
+
if (this.disposed) return;
|
|
275
|
+
this.clear();
|
|
276
|
+
this.disposed = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function joinSseFrameBytes(parts: readonly Uint8Array[]): Uint8Array {
|
|
281
|
+
let byteLength = 0;
|
|
282
|
+
for (const part of parts) byteLength += part.byteLength;
|
|
283
|
+
if (byteLength === 0) return new Uint8Array(0);
|
|
284
|
+
if (parts.length === 1 && parts[0]!.byteLength === byteLength) return parts[0]!;
|
|
285
|
+
const joined = new Uint8Array(byteLength);
|
|
286
|
+
let offset = 0;
|
|
287
|
+
for (const part of parts) {
|
|
288
|
+
joined.set(part, offset);
|
|
289
|
+
offset += part.byteLength;
|
|
290
|
+
}
|
|
291
|
+
return joined;
|
|
292
|
+
}
|
|
@@ -198,12 +198,14 @@ export function relaySseWithBlockRewrite(
|
|
|
198
198
|
const emitProcessedBlocks = (
|
|
199
199
|
controller: ReadableStreamDefaultController<Uint8Array>,
|
|
200
200
|
flushFinal = false,
|
|
201
|
-
):
|
|
201
|
+
): number => {
|
|
202
|
+
let emitted = 0;
|
|
202
203
|
let next: { block: string; delimiter: string; rest: string } | null;
|
|
203
204
|
while ((next = nextSseBlock(buffer))) {
|
|
204
205
|
replaceBuffer(next.rest);
|
|
205
206
|
for (const outBlock of rewrite(next.block)) {
|
|
206
207
|
enqueueText(controller, outBlock + next.delimiter);
|
|
208
|
+
emitted += 1;
|
|
207
209
|
}
|
|
208
210
|
}
|
|
209
211
|
if (flushFinal && buffer.length > 0) {
|
|
@@ -213,28 +215,37 @@ export function relaySseWithBlockRewrite(
|
|
|
213
215
|
const tailDelimiter = buffer.includes("\r\n") ? "\r\n\r\n" : "\n\n";
|
|
214
216
|
for (let i = 0; i < tailBlocks.length; i++) {
|
|
215
217
|
enqueueText(controller, tailBlocks[i]! + (i < tailBlocks.length - 1 ? tailDelimiter : ""));
|
|
218
|
+
emitted += 1;
|
|
216
219
|
}
|
|
217
220
|
releaseBuffer();
|
|
218
221
|
}
|
|
222
|
+
return emitted;
|
|
219
223
|
};
|
|
220
224
|
|
|
221
225
|
return new ReadableStream<Uint8Array>({
|
|
222
226
|
async pull(controller) {
|
|
223
227
|
try {
|
|
224
|
-
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
228
|
+
// A network chunk is not an SSE-event boundary. Bun may not issue a
|
|
229
|
+
// second pull after a fulfilled pull enqueues nothing, so keep reading
|
|
230
|
+
// until at least one complete rewritten block is available or EOF is
|
|
231
|
+
// reached. This also handles block rewrites that intentionally drop an
|
|
232
|
+
// event without parking the client stream.
|
|
233
|
+
for (;;) {
|
|
234
|
+
const { done, value } = await reader.read();
|
|
235
|
+
// A cancel raced this pending read: never feed the rewriter again
|
|
236
|
+
// after its disposal (#893 review).
|
|
237
|
+
if (cancelled) return;
|
|
238
|
+
if (done) {
|
|
239
|
+
appendBuffer(decoder.decode());
|
|
240
|
+
emitProcessedBlocks(controller, true);
|
|
241
|
+
releaseBuffer();
|
|
242
|
+
disposeRewrite();
|
|
243
|
+
controller.close();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
appendBuffer(decoder.decode(value, { stream: true }));
|
|
247
|
+
if (emitProcessedBlocks(controller) > 0) return;
|
|
235
248
|
}
|
|
236
|
-
appendBuffer(decoder.decode(value, { stream: true }));
|
|
237
|
-
emitProcessedBlocks(controller);
|
|
238
249
|
} catch (error) {
|
|
239
250
|
releaseBuffer();
|
|
240
251
|
disposeRewrite();
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { headersForCodexAuthContext } from "../codex/auth-context";
|
|
|
6
6
|
import type { ResponsesTerminalStatus } from "../bridge";
|
|
7
7
|
import type { DataPlaneAdmission } from "./auth-cors";
|
|
8
8
|
import type { AdmissionLease, AdmissionReservation } from "../lib/admission";
|
|
9
|
+
import { BoundedSseFrameBuffer } from "./sse-frame-buffer";
|
|
9
10
|
|
|
10
11
|
const OPEN = 1;
|
|
11
12
|
type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
|
|
@@ -163,15 +164,6 @@ function parseSseBlock(block: string): string | null {
|
|
|
163
164
|
return data.length > 0 ? data.join("\n") : null;
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
function nextSseBlock(buffer: string): { block: string; rest: string } | null {
|
|
167
|
-
const match = buffer.match(/\r?\n\r?\n/);
|
|
168
|
-
if (!match || match.index === undefined) return null;
|
|
169
|
-
return {
|
|
170
|
-
block: buffer.slice(0, match.index),
|
|
171
|
-
rest: buffer.slice(match.index + match[0].length),
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
167
|
function payloadType(payload: string): string | null {
|
|
176
168
|
try {
|
|
177
169
|
const json = JSON.parse(payload) as { type?: unknown };
|
|
@@ -231,7 +223,7 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
231
223
|
ws.data.cancel = cancel;
|
|
232
224
|
|
|
233
225
|
const decoder = new TextDecoder();
|
|
234
|
-
|
|
226
|
+
const framer = new BoundedSseFrameBuffer();
|
|
235
227
|
let terminalSeen = false;
|
|
236
228
|
|
|
237
229
|
const handlePayload = (payload: string): boolean => {
|
|
@@ -266,17 +258,14 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
266
258
|
while (!terminalSeen) {
|
|
267
259
|
const { done, value } = await reader.read();
|
|
268
260
|
if (done) break;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
while ((next = nextSseBlock(buffer))) {
|
|
272
|
-
buffer = next.rest;
|
|
273
|
-
const payload = parseSseBlock(next.block);
|
|
261
|
+
for (const frame of framer.feed(value)) {
|
|
262
|
+
const payload = parseSseBlock(decoder.decode(frame.block));
|
|
274
263
|
if (payload && handlePayload(payload)) break;
|
|
275
264
|
}
|
|
276
265
|
}
|
|
277
|
-
|
|
278
|
-
if (!terminalSeen &&
|
|
279
|
-
const payload = parseSseBlock(
|
|
266
|
+
const tail = framer.finish();
|
|
267
|
+
if (!terminalSeen && tail.byteLength > 0) {
|
|
268
|
+
const payload = parseSseBlock(decoder.decode(tail));
|
|
280
269
|
if (payload) handlePayload(payload);
|
|
281
270
|
}
|
|
282
271
|
if (!terminalSeen && isCurrent() && !clientCancelled) {
|
|
@@ -284,11 +273,27 @@ export async function pumpResponsesSseToWebSocket(
|
|
|
284
273
|
sendProtocolError(ws, 502, "Upstream stream ended before response terminal event");
|
|
285
274
|
}
|
|
286
275
|
} catch (err) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
276
|
+
framer.dispose();
|
|
277
|
+
if (err instanceof WsSendDroppedError) throw err;
|
|
278
|
+
if (!terminalSeen
|
|
279
|
+
&& isCurrent()
|
|
280
|
+
&& ws.readyState === OPEN
|
|
281
|
+
&& !(err instanceof WsSendDroppedError)) {
|
|
282
|
+
reportTerminal("incomplete");
|
|
283
|
+
try {
|
|
284
|
+
sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err));
|
|
285
|
+
} catch (sendErr) {
|
|
286
|
+
// If delivery is already dropped, there is no useful error frame left
|
|
287
|
+
// to send. Swallow only that expected transport signal; other failures
|
|
288
|
+
// still surface to the caller after the upstream reader is released.
|
|
289
|
+
if (!(sendErr instanceof WsSendDroppedError)) throw sendErr;
|
|
290
|
+
}
|
|
290
291
|
}
|
|
291
292
|
} finally {
|
|
293
|
+
framer.dispose();
|
|
294
|
+
// Framing errors can occur while the upstream body is still live. Always
|
|
295
|
+
// release the reader, even when terminal/send paths already cancelled it.
|
|
296
|
+
void reader.cancel().catch(() => {});
|
|
292
297
|
if (ws.data.cancel === cancel) ws.data.cancel = undefined;
|
|
293
298
|
}
|
|
294
299
|
}
|
|
@@ -462,4 +467,4 @@ export async function readBoundedPrefix(
|
|
|
462
467
|
export function looksLikeSse(prefix: Uint8Array): boolean {
|
|
463
468
|
const text = new TextDecoder().decode(prefix);
|
|
464
469
|
return /^\s*(event:|data:)/.test(text);
|
|
465
|
-
}
|
|
470
|
+
}
|