mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
* fix(sse): close the synthetic keepalive reasoning item's output_item RESPONSES_STARTUP_THINKING_FRAME (the /v1/responses early-keepalive placeholder for slow-starting reasoning models) opened a synthetic "rs_keepalive" reasoning item at output_index 0 and closed its nested summary part (response.reasoning_summary_part.done), but never sent response.output_item.done to close the item itself. The comment claimed it was "closed within this one frame" — that was true for the part, not the item. Since this placeholder has no real upstream counterpart (the real response starts an independent response.created lifecycle later and never touches it), nothing else ever closes it. A client tracking open items by output_index (as the Responses API spec requires — this is exactly what OpenClaw's parser does) sees index 0 still open when the real response's own output_item.added later reuses that same index, and throws a collision. Live incident (2026-08-13, reliably reproducing by 2026-08-14): traced via a live tcpdump capture on the OmniRoute-dev container's network namespace, correlated against the OpenClaw gateway journal and 10 separate real request/response pairs (all wire-clean on the response side, ruling out provider corruption). The failing request's own outbound payload confirmed a replayed reasoning item without encrypted_content feeding a continuation call; the response wire bytes for that exact exchange showed rs_keepalive's output_item.added at index 0, then response.created/response.in_progress arriving *after* it, then a second output_item.added reusing index 0 for the real reasoning item — never preceded by an output_item.done for rs_keepalive. Reported upstream as OpenClaw issue #123342 before the OmniRoute-side root cause was found. Fix: emit response.output_item.done for the synthetic item, matching its already-buffered summary text, right after the summary part closes and before the frame ends. Test plan: - tests/unit/early-stream-keepalive.test.ts: updated the frame-shape test to assert the full 5-event closed sequence (added the missing output_item.done and its field assertions); confirmed it fails against pre-fix code (only 4 events) and passes after - node --test tests/unit/early-stream-keepalive.test.ts, tests/unit/earlyStreamKeepalive.test.ts, tests/unit/keepalive-cleanup-8140.test.ts, tests/unit/chat-body-admission.test.ts: 58 passed, 2 pre-existing skips unrelated to this change (Node test runner ReadableStream-error-simulation limitation) - tsgo --noEmit: clean on both touched files * fix(sse): allocate the keepalive output_index from a stack, not a literal Follow-up to 03f8345ac. That commit patched the specific symptom (added the missing response.output_item.done). This commit fixes the class: RESPONSES_STARTUP_THINKING_FRAME hardcoded output_index: 0 as a literal across five hand-written events, which is exactly how the missing-close bug happened in the first place — nothing enforced that every open got a matching close, so it silently didn't for months. ResponsesOutputIndexStack (open-sse/utils/responsesOutputIndexStack.ts) makes that structural: open() allocates the next sequential index, close() must name the index being closed and throws if it doesn't match the stack's top, and assertAllClosed() throws if anything is still open. The keepalive frame now calls assertAllClosed() at module load, so a future regression of this exact shape fails at import/boot time instead of shipping a malformed stream to production and surfacing days later as a live incident. Also adds tests/helpers/assertResponsesOutputIndexLifecycle.ts: a reusable version of the same invariant for replaying a full SSE event sequence (not just checking one frame's own shape), mirroring what a real client's output-index tracker enforces. Existing coverage for this bug class (responses-reasoning-close-before-message-466.test.ts) only asserted it by hand for one specific emitter (the real translator); nothing generic existed for a hand-rolled synthetic frame like this keepalive to be checked against, which is why its own test could pass while the actual downstream contract still failed. Wired into early-stream-keepalive.test.ts, including a test that concatenates the keepalive frame with a plausible real subsequent response and asserts no collision — the scenario that actually reproduced live, not just the frame's own internal shape. Test plan: - tests/unit/responses-output-index-stack.test.ts (new): open/close/ assertAllClosed behavior, including the exact mismatch and never-closed shapes this incident hit - tests/unit/early-stream-keepalive.test.ts: existing frame-shape test plus new collision-simulation test, both passing - node --test across responses-output-index-stack, early-stream-keepalive, earlyStreamKeepalive, keepalive-cleanup-8140, chat-body-admission: 65 passed, 2 pre-existing skips unrelated to this change - tsgo --noEmit: clean on all touched files --------- Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com>
409 lines
18 KiB
TypeScript
409 lines
18 KiB
TypeScript
/**
|
|
* @file earlyStreamKeepalive.ts
|
|
* @description Early SSE keepalive wrapper so short idle-read clients stay connected
|
|
* while the handler waits on upstream first-byte (reasoning models, combo failover).
|
|
*
|
|
* @changes
|
|
* - [2026-07-28] [Cursor Grok 4.5] - Scrub omniroute from client-facing keepalive id/model/comment frames
|
|
* - [2026-07-28] [Cursor Grok 4.5] - Neutralize Responses startup thinking text (no OmniRoute brand leak)
|
|
*
|
|
* Strict HTTP clients (notably Codex CLI's `reqwest`, which has a ~5s idle-read
|
|
* timeout) drop the connection if no bytes arrive shortly after the request.
|
|
* The proxy holds the streaming response until `ensureStreamReadiness` observes
|
|
* the upstream's first useful byte — which can exceed 5s for reasoning models
|
|
* that "think" before emitting any token (#2544). `curl` has no such idle
|
|
* timeout, so it was never affected, which is why the bug looked client-specific.
|
|
*
|
|
* This wrapper keeps the connection warm without disturbing the handler's
|
|
* internal logic (combo failover, stream readiness, account cooldown all still
|
|
* run inside the handler before it resolves):
|
|
*
|
|
* - Fast path: if the handler resolves within `thresholdMs`, its `Response`
|
|
* is returned verbatim — identical status, headers, and body. There is zero
|
|
* behavior change for normal latency, so metadata headers and non-200 error
|
|
* statuses are fully preserved for the common case.
|
|
*
|
|
* - Slow path: if the handler is still pending after `thresholdMs`, a 200
|
|
* `text/event-stream` response is opened immediately and SSE comment
|
|
* heartbeats are emitted every `intervalMs` until the handler resolves; its
|
|
* body is then forwarded. If the handler ultimately fails, a structured
|
|
* `event: error` frame is emitted in-band (the response is already committed
|
|
* to 200, so the HTTP status can no longer change).
|
|
*/
|
|
|
|
import { ResponsesOutputIndexStack } from "./responsesOutputIndexStack.ts";
|
|
|
|
const ENCODER = new TextEncoder();
|
|
const KEEPALIVE_FRAME = ENCODER.encode(": keepalive\n\n");
|
|
// OpenAI-compatible keepalive: a syntactically valid empty streaming chunk.
|
|
// Some OpenAI-compatible clients parse every non-empty SSE line as JSON and
|
|
// reject legal SSE comments before their first provider chunk arrives.
|
|
// id/model stay brand-neutral — these frames go to the client, not upstream.
|
|
export const OPENAI_KEEPALIVE_FRAME = ENCODER.encode(
|
|
'data: {"id":"chatcmpl-keepalive","object":"chat.completion.chunk","created":0,"model":"keepalive","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n'
|
|
);
|
|
// The first slow-path frame must be a valid OpenAI chunk without creating
|
|
// visible reasoning that clients persist into the conversation.
|
|
export const OPENAI_STARTUP_FRAME = OPENAI_KEEPALIVE_FRAME;
|
|
// Anthropic Messages-format keepalive: a REAL `ping` SSE event, not a comment.
|
|
// Anthropic clients (Claude Code, the Anthropic SDK) reset their stream/first-token
|
|
// watchdog on real SSE events but ignore SSE comments (`: ...`), so on a slow first
|
|
// token the comment frame lets the client abort and retry the stream. Anthropic's own
|
|
// API emits `event: ping` for exactly this reason; the /v1/messages route mirrors it.
|
|
export const ANTHROPIC_PING_FRAME = ENCODER.encode('event: ping\ndata: {"type":"ping"}\n\n');
|
|
// Responses API keepalive: a self-contained, self-closed synthetic reasoning
|
|
// item (added -> summary_part.added -> text.delta -> summary_part.done ->
|
|
// output_item.done). Unlike open-sse/utils/stream.ts's own
|
|
// emitSyntheticResponsesReasoningSummary — which only supplements a REAL
|
|
// upstream item that the real provider stream will close on its own — this
|
|
// placeholder item has no real counterpart: the upstream response, once it
|
|
// arrives, starts its own independent response.created lifecycle from
|
|
// scratch and will never close this one. It must therefore send its own
|
|
// response.output_item.done here, not just reasoning_summary_part.done
|
|
// (that only closes the nested summary part, not the output item itself).
|
|
// Without it, a strict client tracking open items by output_index (as the
|
|
// Responses API spec requires) sees this item still open at index 0 and
|
|
// throws a collision the moment the real response's own output_item.added
|
|
// reuses that same index — reproduced live 2026-08-13, OpenClaw issue
|
|
// https://github.com/openclaw/openclaw/issues/123342.
|
|
//
|
|
// The output_index is allocated from ResponsesOutputIndexStack instead of a
|
|
// hardcoded literal so this stays structurally correct: forgetting the
|
|
// close() call throws at module load (assertAllClosed() below), not
|
|
// silently at some future real request.
|
|
const RESPONSES_STARTUP_ITEM_ID = "rs_keepalive";
|
|
// Brand-neutral placeholder — clients persist this as visible reasoning.
|
|
const STARTUP_THINKING_TEXT = "✨";
|
|
const startupIndexStack = new ResponsesOutputIndexStack();
|
|
const RESPONSES_STARTUP_OUTPUT_INDEX = startupIndexStack.open();
|
|
const startupEvents = [
|
|
{
|
|
event: "response.output_item.added",
|
|
data: {
|
|
type: "response.output_item.added",
|
|
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
|
|
item: { id: RESPONSES_STARTUP_ITEM_ID, type: "reasoning", summary: [] },
|
|
},
|
|
},
|
|
{
|
|
event: "response.reasoning_summary_part.added",
|
|
data: {
|
|
type: "response.reasoning_summary_part.added",
|
|
item_id: RESPONSES_STARTUP_ITEM_ID,
|
|
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
|
|
summary_index: 0,
|
|
part: { type: "summary_text", text: "" },
|
|
},
|
|
},
|
|
{
|
|
event: "response.reasoning_summary_text.delta",
|
|
data: {
|
|
type: "response.reasoning_summary_text.delta",
|
|
item_id: RESPONSES_STARTUP_ITEM_ID,
|
|
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
|
|
summary_index: 0,
|
|
delta: STARTUP_THINKING_TEXT,
|
|
},
|
|
},
|
|
{
|
|
event: "response.reasoning_summary_part.done",
|
|
data: {
|
|
type: "response.reasoning_summary_part.done",
|
|
item_id: RESPONSES_STARTUP_ITEM_ID,
|
|
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
|
|
summary_index: 0,
|
|
part: { type: "summary_text", text: STARTUP_THINKING_TEXT },
|
|
},
|
|
},
|
|
];
|
|
// close() runs before the output_item.done event is built (not just before
|
|
// it's appended) so assertAllClosed() below is a real check, not scaffolding
|
|
// that always trivially passes.
|
|
startupIndexStack.close(RESPONSES_STARTUP_OUTPUT_INDEX);
|
|
startupEvents.push({
|
|
event: "response.output_item.done",
|
|
data: {
|
|
type: "response.output_item.done",
|
|
output_index: RESPONSES_STARTUP_OUTPUT_INDEX,
|
|
item: {
|
|
id: RESPONSES_STARTUP_ITEM_ID,
|
|
type: "reasoning",
|
|
summary: [{ type: "summary_text", text: STARTUP_THINKING_TEXT }],
|
|
},
|
|
},
|
|
});
|
|
startupIndexStack.assertAllClosed();
|
|
export const RESPONSES_STARTUP_THINKING_FRAME = ENCODER.encode(
|
|
startupEvents.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`).join("")
|
|
);
|
|
// Anthropic Messages API default — Anthropic's own spec really does use a named
|
|
// `event: error` SSE frame, so this is correct there. It is WRONG for the OpenAI-
|
|
// format routes below: Chat Completions and Responses streaming never use the SSE
|
|
// `event:` field at all, only bare `data: {...}` lines — a naive line-based parser
|
|
// (the kind most OpenAI-compatible clients use, not a full EventSource) can silently
|
|
// drop an unrecognized `event:` line and/or desync on the `data:` line that follows,
|
|
// so this error would never surface to the client at all (log ids
|
|
// 1784465227489-a2cbc0 / 1784457764961-73 territory: a client that gives up with no
|
|
// visible reason). See OPENAI_CHAT_ERROR_FRAME / OPENAI_RESPONSES_ERROR_FRAME below
|
|
// for the per-format-correct alternatives.
|
|
const ERROR_FRAME = ENCODER.encode(
|
|
`event: error\ndata: ${JSON.stringify({
|
|
error: { message: "Upstream stream failed before completion.", type: "stream_error" },
|
|
})}\n\n`
|
|
);
|
|
// Chat Completions convention: a plain `data:` line, no `event:` field. This
|
|
// matches what the openai-node SDK's stream iterator actually checks for — it
|
|
// inspects each parsed chunk for a top-level `error` key regardless of any SSE
|
|
// event name (there isn't one to check, since real OpenAI chat completions
|
|
// streams never send `event:` lines).
|
|
export const OPENAI_CHAT_ERROR_FRAME = ENCODER.encode(
|
|
`data: ${JSON.stringify({
|
|
error: { message: "Upstream stream failed before completion.", type: "stream_error" },
|
|
})}\n\n`
|
|
);
|
|
// Responses API convention: also a plain `data:` line, but the discriminator is
|
|
// the `type` field INSIDE the JSON payload (matching every other Responses API
|
|
// event — response.output_text.delta, response.completed, etc.), not an SSE
|
|
// `event:` field.
|
|
export const OPENAI_RESPONSES_ERROR_FRAME = ENCODER.encode(
|
|
`data: ${JSON.stringify({
|
|
type: "error",
|
|
code: null,
|
|
message: "Upstream stream failed before completion.",
|
|
param: null,
|
|
})}\n\n`
|
|
);
|
|
|
|
export type EarlyStreamKeepaliveOptions = {
|
|
/** Wait this long for the handler before committing to a keepalive stream. */
|
|
thresholdMs?: number;
|
|
/** Keepalive cadence once committed (must stay under the client idle timeout). */
|
|
intervalMs?: number;
|
|
/** Client request signal — propagated so a client disconnect cancels the upstream read. */
|
|
signal?: AbortSignal | null;
|
|
/**
|
|
* Frame emitted on each keepalive tick. Defaults to an SSE comment
|
|
* (`: keepalive`). Anthropic-format routes (/v1/messages) must pass
|
|
* `ANTHROPIC_PING_FRAME` instead, because Anthropic clients ignore SSE comments
|
|
* for their stream watchdog and only a real `event: ping` keeps them from aborting.
|
|
*/
|
|
keepaliveFrame?: Uint8Array;
|
|
/**
|
|
* Frame emitted ONCE, immediately, as the very first byte of the slow path —
|
|
* before the recurring `keepaliveFrame` ticks start. Defaults to
|
|
* `keepaliveFrame` when omitted (today's behavior, unchanged). Pass a
|
|
* content-bearing frame (e.g. `OPENAI_STARTUP_THINKING_FRAME`) so the client
|
|
* sees visible progress instead of an empty/no-op keepalive on the first byte.
|
|
*/
|
|
startupFrame?: Uint8Array;
|
|
/** Extra headers to include in the keepalive response (e.g. X-Correlation-Id). */
|
|
extraHeaders?: Record<string, string>;
|
|
/**
|
|
* Frame emitted if the handler ultimately fails (or the upstream stream dies
|
|
* mid-flight with zero bytes forwarded) after the slow path has already
|
|
* committed to HTTP 200. Defaults to the Anthropic-style `event: error` frame
|
|
* (correct for /v1/messages). OpenAI-format routes (/v1/chat/completions,
|
|
* /v1/responses) MUST pass OPENAI_CHAT_ERROR_FRAME / OPENAI_RESPONSES_ERROR_FRAME
|
|
* instead — see the doc comment on the default ERROR_FRAME above for why.
|
|
*/
|
|
errorFrame?: Uint8Array;
|
|
};
|
|
|
|
/**
|
|
* Tagged with a string rather than an `ok: true | false` boolean: this workspace compiles
|
|
* with `strictNullChecks: false`, where a boolean-literal discriminant narrows the positive
|
|
* branch but not the negative one — so reading `.error` off the rejected arm did not
|
|
* type-check. A string discriminant narrows both branches under the same settings.
|
|
*/
|
|
type SettledHandler =
|
|
{ status: "fulfilled"; response: Response } | { status: "rejected"; error: unknown };
|
|
|
|
export async function withEarlyStreamKeepalive(
|
|
handlerPromise: Promise<Response>,
|
|
options: EarlyStreamKeepaliveOptions = {}
|
|
): Promise<Response> {
|
|
const thresholdMs = Math.max(0, options.thresholdMs ?? 2_000);
|
|
const intervalMs = Math.max(250, options.intervalMs ?? 2_500);
|
|
const signal = options.signal ?? null;
|
|
const keepaliveFrame = options.keepaliveFrame ?? KEEPALIVE_FRAME;
|
|
const startupFrame = options.startupFrame ?? keepaliveFrame;
|
|
const extraHeaders = options.extraHeaders ?? {};
|
|
const errorFrame = options.errorFrame ?? ERROR_FRAME;
|
|
// Single source of truth for whether THIS route's error framing uses a named SSE
|
|
// `event: error` line (Anthropic) or a plain `data:` line (OpenAI Chat Completions /
|
|
// Responses) — derived from errorFrame itself so the dynamic real-upstream-body case
|
|
// below stays consistent with the static default-message case without a second option.
|
|
const errorFrameUsesNamedEvent = new TextDecoder().decode(errorFrame).startsWith("event:");
|
|
|
|
// Settle into a tagged result so neither race branch leaves an unhandled
|
|
// rejection when the threshold timer wins.
|
|
const settled: Promise<SettledHandler> = handlerPromise.then(
|
|
(response) => ({ status: "fulfilled" as const, response }),
|
|
(error) => ({ status: "rejected" as const, error })
|
|
);
|
|
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const raced = await Promise.race([
|
|
settled.then((result) => ({ kind: "settled" as const, result })),
|
|
new Promise<{ kind: "timeout" }>((resolve) => {
|
|
timer = setTimeout(() => resolve({ kind: "timeout" }), thresholdMs);
|
|
}),
|
|
]);
|
|
if (timer) clearTimeout(timer);
|
|
|
|
if (raced.kind === "settled") {
|
|
// Fast path — return verbatim, or rethrow so the route's normal error handling runs.
|
|
const result = raced.result;
|
|
if (result.status === "fulfilled") return result.response;
|
|
throw result.error;
|
|
}
|
|
|
|
// Slow path — open the SSE stream now and keep it warm until the handler resolves.
|
|
// Cleanup state is hoisted so both start() and cancel() (client disconnect) can stop
|
|
// the keepalive loop and cancel the upstream read.
|
|
let stopKeepalive = () => {};
|
|
let upstreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
let aborted = false;
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
let stopped = false;
|
|
const interval = setInterval(() => {
|
|
if (stopped) return;
|
|
try {
|
|
controller.enqueue(keepaliveFrame);
|
|
} catch {
|
|
stopped = true;
|
|
clearInterval(interval);
|
|
}
|
|
}, intervalMs);
|
|
if (interval && typeof interval === "object" && "unref" in interval) {
|
|
interval.unref?.();
|
|
}
|
|
// First frame immediately on commit so the client sees a byte right away.
|
|
// Use `startupFrame` (e.g. OPENAI_STARTUP_THINKING_FRAME / ANTHROPIC_PING_FRAME)
|
|
// — an SSE comment here would be ignored by Anthropic clients' watchdog on a
|
|
// sub-interval gap, defeating the keepalive for exactly the case it targets.
|
|
try {
|
|
controller.enqueue(startupFrame);
|
|
} catch {
|
|
/* consumer already gone */
|
|
}
|
|
|
|
stopKeepalive = () => {
|
|
stopped = true;
|
|
clearInterval(interval);
|
|
};
|
|
|
|
const onAbort = () => {
|
|
if (aborted) return;
|
|
aborted = true;
|
|
stopKeepalive();
|
|
upstreamReader?.cancel().catch(() => {});
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
};
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
// addEventListener does not replay an abort that happened before registration.
|
|
// Checking after registration closes that gap without missing a concurrent abort.
|
|
if (signal?.aborted) onAbort();
|
|
|
|
try {
|
|
const result = await settled;
|
|
stopKeepalive();
|
|
if (aborted) {
|
|
// The synthetic keepalive response can be cancelled before the handler resolves.
|
|
// Cancel the eventual real response so its upstream work and lifecycle hooks finish.
|
|
if (result.status === "fulfilled" && result.response.body) {
|
|
await result.response.body.cancel().catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (result.status === "rejected") {
|
|
// Handler rejected — emit a generic error frame (never the raw error/stack).
|
|
controller.enqueue(errorFrame);
|
|
} else {
|
|
const response = result.response;
|
|
const contentType = (response.headers.get("content-type") || "").toLowerCase();
|
|
const isSse = contentType.includes("text/event-stream");
|
|
|
|
if (response.body && isSse) {
|
|
// Real SSE stream — forward it verbatim.
|
|
upstreamReader = response.body.getReader();
|
|
let bytesForwarded = 0;
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await upstreamReader.read();
|
|
if (done) break;
|
|
if (value) {
|
|
controller.enqueue(value);
|
|
bytesForwarded += value.byteLength;
|
|
}
|
|
}
|
|
} catch (readErr) {
|
|
// Upstream stream failed mid-flight. Only emit an error frame if
|
|
// NO content was forwarded yet — otherwise the client already
|
|
// received partial content and a late error frame would corrupt
|
|
// the SSE stream. Silently close instead; the client will see
|
|
// the stream end naturally.
|
|
if (bytesForwarded === 0) {
|
|
controller.enqueue(errorFrame);
|
|
}
|
|
}
|
|
} else {
|
|
// Non-SSE response (e.g. a JSON error) reached us after we already
|
|
// committed to a 200 event-stream, so the HTTP status can no longer
|
|
// change. Frame the (already-sanitized) body as an in-band error event
|
|
// instead of forwarding raw JSON, which would be malformed SSE.
|
|
const text = response.body ? await response.text().catch(() => "") : "";
|
|
const dataLine =
|
|
text.trim() ||
|
|
JSON.stringify({ error: { message: "stream_error", type: "stream_error" } });
|
|
const framed = errorFrameUsesNamedEvent
|
|
? `event: error\ndata: ${dataLine}\n\n`
|
|
: `data: ${dataLine}\n\n`;
|
|
controller.enqueue(ENCODER.encode(framed));
|
|
}
|
|
}
|
|
} catch {
|
|
// Defensive: never surface a raw error/stack to the client.
|
|
if (!aborted) {
|
|
try {
|
|
controller.enqueue(errorFrame);
|
|
} catch {
|
|
/* consumer gone */
|
|
}
|
|
}
|
|
} finally {
|
|
stopKeepalive();
|
|
signal?.removeEventListener("abort", onAbort);
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
}
|
|
},
|
|
cancel() {
|
|
// Consumer (Next.js → client) went away — stop keepalives and release the upstream.
|
|
aborted = true;
|
|
stopKeepalive();
|
|
upstreamReader?.cancel().catch(() => {});
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
...extraHeaders,
|
|
},
|
|
});
|
|
}
|