Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
92330b5
fix(server): add bounded client SSE frame buffer
Wibias Aug 7, 2026
5230c3c
fix(server): bound WebSocket SSE frame retention
Wibias Aug 7, 2026
aaaa4f3
fix(server): bound HTTP SSE terminal framing
Wibias Aug 7, 2026
dfe511b
test(server): cover client SSE frame bounds
Wibias Aug 7, 2026
40a6e5b
fix(server): widen typed-array tail storage
Wibias Aug 7, 2026
ac938a2
test(server): preserve raw split UTF-8 bytes
Wibias Aug 7, 2026
6d4c16a
fix(server): bound SSE framer allocation count
Wibias Aug 7, 2026
7ba0225
fix(server): bound inspector allocation count
Wibias Aug 7, 2026
c78de5a
fix(server): bound SSE frame-count amplification
Wibias Aug 7, 2026
af5c157
fix(server): release WS reader on framing errors
Wibias Aug 7, 2026
b9396b4
fix(server): harden SSE failure cleanup
Wibias Aug 7, 2026
3e4725d
test(server): cover SSE framing failure paths
Wibias Aug 7, 2026
b0f79aa
chore(server): remove no-op WS expression
Wibias Aug 7, 2026
e348caa
fix(server): widen failed-tail typed array
Wibias Aug 7, 2026
17dfaf8
fix(ws): preserve dropped-send rejection semantics
Wibias Aug 7, 2026
73b30b0
test(ws): preserve dropped-send rejection contract
Wibias Aug 7, 2026
33ac593
fix(sse): honour terminal before trailing framing errors
Wibias Aug 7, 2026
97e3c87
test(sse): keep committed terminal ahead of trailing overflow
Wibias Aug 7, 2026
e1ff3b9
docs(proxy): document Responses SSE frame limit
Wibias Aug 7, 2026
65bea21
docs(proxy): sync Japanese SSE frame limit
Wibias Aug 7, 2026
49dabd9
docs(proxy): sync Korean SSE frame limit
Wibias Aug 7, 2026
1f47d21
docs(proxy): sync Russian SSE frame limit
Wibias Aug 7, 2026
55d1340
docs(proxy): sync Chinese SSE frame limit
Wibias Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 57 additions & 51 deletions src/server/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ import {
type RequestLogContext,
type RequestLogEntry,
} from "./request-log";
import {
BoundedSseFrameBuffer,
joinSseFrameBytes,
MAX_CLIENT_SSE_FRAME_BYTES,
} from "./sse-frame-buffer";

const nativePassthroughSseResponses = new WeakSet<Response>();
const eagerRelaySseResponses = new WeakSet<Response>();

export const MAX_INSPECTION_SSE_FRAME_BYTES = 4 * 1024 * 1024;
export const MAX_INSPECTION_SSE_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
export const MAX_COMPLETED_OUTPUT_ITEMS = 256;
export const MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES = 8 * 1024 * 1024;
export const MAX_TAIL_ERROR_MESSAGE_CHARS = 512;
Expand Down Expand Up @@ -104,30 +109,29 @@ export type SseTerminalOutputBoundary = {

/**
* Frame-aware client output boundary shared by both native Responses relays.
* It buffers only the current incomplete SSE block, forwards complete blocks
* through the first Responses terminal, and drops every later block/byte.
* It buffers only the current incomplete SSE block under the same hard byte
* cap as inspection, forwards complete blocks through the first Responses
* terminal, and drops every later block/byte.
*/
export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
let decoder: TextDecoder | null = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
const decoder = new TextDecoder();
const framer = new BoundedSseFrameBuffer(MAX_INSPECTION_SSE_FRAME_BYTES);
let terminal = false;
let done = false;
let disposed = false;

const process = (flush: boolean): Uint8Array => {
if (disposed || terminal) return new Uint8Array(0);
let output = "";
const processFrames = (
frames: ReturnType<BoundedSseFrameBuffer["feed"]>,
): Uint8Array => {
if (disposed || terminal || frames.length === 0) return new Uint8Array(0);
const output: Uint8Array[] = [];
let responsesTerminal = false;
for (;;) {
const next = nextSseBlock(buffer);
if (!next) break;
buffer = next.rest;
const payload = sseDataPayload(next.block);
if (!responsesTerminal) output += next.block + next.delimiter;
for (const frame of frames) {
const payload = sseDataPayload(decoder.decode(frame.block));
if (!responsesTerminal) output.push(frame.block, frame.delimiter);
if (payload === "[DONE]") {
done = true;
if (responsesTerminal) output += next.block + next.delimiter;
if (responsesTerminal) output.push(frame.block, frame.delimiter);
continue;
}
if (!responsesTerminal && payload && terminalStatusFromSsePayload(payload)) {
Expand All @@ -136,33 +140,26 @@ export function createSseTerminalOutputBoundary(): SseTerminalOutputBoundary {
}
if (responsesTerminal) {
terminal = true;
buffer = "";
}
if (flush && !terminal && buffer.length > 0) {
output += buffer;
buffer = "";
framer.dispose();
}
return encoder.encode(output);
return joinSseFrameBytes(output);
};

return {
feed(chunk) {
if (disposed || terminal) return new Uint8Array(0);
buffer += decoder!.decode(chunk, { stream: true });
return process(false);
return processFrames(framer.feed(chunk));
Comment thread
Wibias marked this conversation as resolved.
},
finish() {
if (disposed || terminal) return new Uint8Array(0);
buffer += decoder!.decode();
return process(true);
return framer.finish();
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
terminalSeen: () => terminal,
doneSeen: () => done,
dispose() {
if (disposed) return;
disposed = true;
decoder = null;
buffer = "";
framer.dispose();
},
};
}
Expand Down Expand Up @@ -622,17 +619,6 @@ function delimiterLengthAt(
return byteAt(index + 3) === 10 ? 4 : 0;
}

function joinedBytes(slices: readonly Uint8Array[], byteLength: number): Uint8Array {
if (slices.length === 1 && slices[0]!.byteLength === byteLength) return slices[0]!;
const joined = new Uint8Array(byteLength);
let offset = 0;
for (const slice of slices) {
joined.set(slice, offset);
offset += slice.byteLength;
}
return joined;
}

/**
* Per-chunk SSE inspection state machine shared by consumeForInspection,
* consumeForResponseLogMetadata, and the eager bounded relay (relay-eager.ts).
Expand All @@ -653,8 +639,8 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
let reported = false;
let sawTerminal = false;
let disposed = false;
let delimiterTail = new Uint8Array(0);
let candidateSlices: Uint8Array[] = [];
let delimiterTail: Uint8Array = new Uint8Array(0);
let candidate: Uint8Array = new Uint8Array(0);
let candidateBytes = 0;
let discardingOversizedFrame = false;
const reportFirstOutput = createFirstOutputReporter(handlers.onFirstOutput);
Expand All @@ -668,7 +654,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector

const clearFrameState = (): void => {
delimiterTail = new Uint8Array(0);
candidateSlices = [];
candidate = new Uint8Array(0);
candidateBytes = 0;
discardingOversizedFrame = false;
};
Expand All @@ -688,6 +674,30 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
firstResponseId = undefined;
};

const ensureCandidateCapacity = (requiredBytes: number): void => {
if (candidate.byteLength >= requiredBytes) return;
let capacity = candidate.byteLength === 0
? Math.min(MAX_INSPECTION_SSE_FRAME_BYTES, Math.max(requiredBytes, 4096))
: candidate.byteLength;
while (capacity < requiredBytes) {
capacity = Math.min(
MAX_INSPECTION_SSE_FRAME_BYTES,
Math.max(requiredBytes, capacity * 2),
);
}
const grown = new Uint8Array(capacity);
if (candidateBytes > 0) grown.set(candidate.subarray(0, candidateBytes));
candidate = grown;
};

const takeCandidate = (): Uint8Array => {
if (candidateBytes === 0) return new Uint8Array(0);
const frame = candidate.slice(0, candidateBytes);
candidate = new Uint8Array(0);
candidateBytes = 0;
return frame;
};

const retainCandidateSlice = (slice: Uint8Array): void => {
if (slice.byteLength === 0 || discardingOversizedFrame) return;
const nextBytes = candidateBytes + slice.byteLength;
Expand All @@ -696,7 +706,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
Math.min(nextBytes, MAX_INSPECTION_SSE_FRAME_BYTES),
);
if (nextBytes > MAX_INSPECTION_SSE_FRAME_BYTES) {
candidateSlices = [];
candidate = new Uint8Array(0);
candidateBytes = 0;
discardingOversizedFrame = true;
inspectionCounters.frameCapOverflows += 1;
Expand All @@ -706,10 +716,8 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
reconstructionTainted = true;
return;
}
// `subarray()` aliases the upstream chunk's backing buffer. Copy only the
// live candidate bytes so a tiny trailing frame cannot pin a multi-MiB
// chunk whose preceding frames have already been consumed.
candidateSlices.push(slice.slice());
ensureCandidateCapacity(nextBytes);
candidate.set(slice, candidateBytes);
candidateBytes = nextBytes;
};

Expand Down Expand Up @@ -845,9 +853,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
return;
}
const sourceBytes = candidateBytes;
const frame = joinedBytes(candidateSlices, sourceBytes);
candidateSlices = [];
candidateBytes = 0;
const frame = takeCandidate();
if (reported && !handlers.onCompletedResponse) return;
const decoded = decoder!.decode(frame);
scanPayload(sseDataPayload(decoded), sourceBytes);
Expand Down Expand Up @@ -904,7 +910,7 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
delimiterTail = new Uint8Array(0);
if (!discardingOversizedFrame && candidateBytes > 0 && !reported) {
const sourceBytes = candidateBytes;
const decoded = decoder!.decode(joinedBytes(candidateSlices, sourceBytes));
const decoded = decoder!.decode(takeCandidate());
scanPayload(decoded.trim() ? sseDataPayload(decoded) : null, sourceBytes);
}
} finally {
Expand Down
Loading
Loading