diff --git a/node_modules/agents/.bun-tag-37e67b70862be2b2 b/.bun-tag-37e67b70862be2b2new file mode 100644index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391diff --git a/node_modules/agents/.bun-tag-61b2f1517ab5ced4 b/.bun-tag-61b2f1517ab5ced4new file mode 100644index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502new file mode 100644index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.tsindex 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 100644--- a/dist/agent-tool-types-CNyE1iz_.d.ts+++ b/dist/agent-tool-types-CNyE1iz_.d.ts@@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore { private readonly seqByStream; private readonly seqInit; constructor(storage: DurableObjectStorage);- storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>;+ /** Resolves `undefined` for a message too large for DO storage's 128 KiB+ * per-value cap: the event is delivered live without a replay id rather+ * than failing the send. */+ storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId | undefined>; getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>; replayEventsAfter( lastEventId: EventId,diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.tsindex c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644--- a/dist/mcp/index.d.ts+++ b/dist/mcp/index.d.ts@@ -29,6 +29,7 @@ import { xt as MCPClientOAuthCallbackConfig, zt as ElicitResult } from "../agent-tool-types-CNyE1iz_.js";+declare const MAX_SSE_AGE_MS = 1800000; export { type ClearableEventStore, type CreateMcpHandlerOptions,@@ -41,6 +42,7 @@ export { type MCPConnectionResult, type MCPDiscoverResult, type MCPServerOptions,+ MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, type McpAuthContext,diff --git a/dist/mcp/index.js b/dist/mcp/index.jsindex 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5babd1219e17 100644--- a/dist/mcp/index.js+++ b/dist/mcp/index.js@@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ const KEEPALIVE_INTERVAL_MS = 25e3; /** SSE comment frame the parser drops before any event dispatch. */ const KEEPALIVE_FRAME = ": keepalive\n\n";+// Max age is a stalled-client memory backstop, not session idleness. Keep it+// well above Executor's 5 minute session idle timeout so active clients rarely+// rotate.+const MAX_SSE_AGE_MS = 30 * 60 * 1000; /** * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle * that the stream cleanup must invoke when the stream closes. */-function startKeepalive(writer, encoder) {+function startKeepalive(writeFrame, encoder) { const handle = setInterval(() => {- writer.write(encoder.encode(KEEPALIVE_FRAME)).catch(() => clearInterval(handle));+ writeFrame(encoder.encode(KEEPALIVE_FRAME)); }, KEEPALIVE_INTERVAL_MS); return handle; }@@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { }); return new Response(body, { status: 404 }); }- const { readable, writable } = new TransformStream();- const writer = writable.getWriter();- const encoder = new TextEncoder();- const existingHeaders = {};+ const { readable, writable } = new TransformStream();+ const writer = writable.getWriter();+ const encoder = new TextEncoder();+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;+ let __pendingBytes = 0;+ let __writeChain = Promise.resolve();+ let __sseClosed = false;+ let keepAlive;+ const existingHeaders = {}; request.headers.forEach((value, key) => { existingHeaders[key] = value; });@@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { jsonrpc: "2.0" }); return new Response(body, { status: 500 });- }- ws.accept();- if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) {- ws.close();- return new Response(null, {+ }+ ws.accept();+ const __closeSse = () => {+ if (__sseClosed) return;+ __sseClosed = true;+ try {+ clearInterval(keepAlive);+ } catch {}+ try {+ ws.close(1013, "SSE client not draining");+ } catch {}+ writer.abort(new Error("SSE client not draining")).catch(() => {});+ };+ const __markSseClientClosed = () => {+ if (__sseClosed) return;+ __sseClosed = true;+ try {+ clearInterval(keepAlive);+ } catch {}+ writer.abort(new Error("SSE client disconnected")).catch(() => {});+ };+ writer.closed?.catch(() => {+ __markSseClientClosed();+ });+ request.signal.addEventListener("abort", __closeSse, { once: true });+ const __forwardSse = (frame) => {+ if (__sseClosed) return __writeChain;+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {+ __closeSse();+ return Promise.resolve();+ }+ __pendingBytes += frame.byteLength;+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {+ __closeSse();+ }).finally(() => {+ __pendingBytes -= frame.byteLength;+ });+ return __writeChain;+ };+ keepAlive = startKeepalive(__forwardSse, encoder);+ if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) {+ clearInterval(keepAlive);+ ws.close();+ return new Response(null, { headers: corsHeaders(request, options.corsOptions), status: 202- });- }- const keepAlive = startKeepalive(writer, encoder);- ws.addEventListener("message", (event) => {+ });+ }+ ws.addEventListener("message", (event) => { async function onMessage(event) { try {- const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);- const message = JSON.parse(data);- if (message.type !== "cf_mcp_agent_event") return;- await writer.write(encoder.encode(message.event));- if (message.close) {- clearInterval(keepAlive);- ws?.close();- await writer.close().catch(() => {});- }+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);+ const message = JSON.parse(data);+ if (message.type !== "cf_mcp_agent_event") return;+ const writePromise = __forwardSse(encoder.encode(message.event));+ if (message.close) {+ clearInterval(keepAlive);+ await writePromise;+ await writer.close();+ if (!__sseClosed && !request.signal.aborted) {+ // workerd resolves writer.close() even when the client+ // canceled the POST response body, and request.signal does+ // not reliably fire for that cancellation, so a successful+ // close is NOT proof of delivery. Never ack POST-stream+ // deliveries: the DO keeps each response persisted for its+ // own Last-Event-ID recovery or an explicitly cursorless+ // fresh GET. A client that DID receive the result closes the+ // POST body reader without recovery, so the worst case of+ // this at-least-once choice is a duplicate on that fresh-GET+ // fallback, never a response injected into another cursor's+ // recovery stream.+ ws?.close(1000, "SSE response delivered");+ }+ } } catch (error) { console.error("Error forwarding message to SSE:", error); } } onMessage(event).catch(console.error); });- ws.addEventListener("error", (error) => {- async function onError(_error) {- clearInterval(keepAlive);- await writer.close().catch(() => {});+ ws.addEventListener("error", (error) => {+ async function onError(_error) {+ __sseClosed = true;+ clearInterval(keepAlive);+ await writer.close().catch(() => {}); } onError(error).catch(console.error); });- ws.addEventListener("close", () => {- async function onClose() {- clearInterval(keepAlive);- await writer.close().catch(() => {});+ ws.addEventListener("close", () => {+ async function onClose() {+ __sseClosed = true;+ clearInterval(keepAlive);+ await writer.close().catch(() => {}); } onClose().catch(console.error); });@@ -279,10 +342,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { id: null, jsonrpc: "2.0" }), { status: 400 });- const { readable, writable } = new TransformStream();- const writer = writable.getWriter();- const encoder = new TextEncoder();- const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, {+ const { readable, writable } = new TransformStream();+ const writer = writable.getWriter();+ const encoder = new TextEncoder();+ const __openedAt = Date.now();+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;+ let __pendingBytes = 0;+ let __writeChain = Promise.resolve();+ let __sseClosed = false;+ let keepAlive;+ const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { props: ctx.props, jurisdiction: options.jurisdiction });@@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { if (!ws) { await writer.close(); return new Response("Failed to establish WS to DO", { status: 500 });- }- ws.accept();- ws.addEventListener("message", (event) => {+ }+ ws.accept();+ const __abortSse = () => {+ if (__sseClosed) return;+ __sseClosed = true;+ try {+ clearInterval(keepAlive);+ } catch {}+ try {+ ws.close(1013, "SSE client not draining");+ } catch {}+ writer.abort(new Error("SSE client not draining")).catch(() => {});+ };+ writer.closed?.catch(() => {+ __abortSse();+ });+ request.signal.addEventListener("abort", __abortSse, { once: true });+ const __closeSseGracefully = () => {+ if (__sseClosed) return __writeChain;+ __sseClosed = true;+ try {+ clearInterval(keepAlive);+ } catch {}+ try {+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n");+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => {+ writer.abort().catch(() => {});+ });+ } catch {+ __writeChain = writer.close().catch(() => {+ writer.abort().catch(() => {});+ });+ }+ try {+ ws.close(1000, "sse_max_age_rotation");+ } catch {}+ return __writeChain;+ };+ const __forwardSse = (frame) => {+ if (__sseClosed) return __writeChain;+ const ageMs = Date.now() - __openedAt;+ if (ageMs > MAX_SSE_AGE_MS) {+ console.log(JSON.stringify({+ event: "sse_max_age_close",+ sessionId,+ variant: "streamable-get",+ ageMs,+ pendingBytes: __pendingBytes+ }));+ __closeSseGracefully();+ return Promise.resolve();+ }+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {+ __abortSse();+ return Promise.resolve();+ }+ __pendingBytes += frame.byteLength;+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {+ __abortSse();+ }).finally(() => {+ __pendingBytes -= frame.byteLength;+ });+ return __writeChain;+ };+ keepAlive = startKeepalive(__forwardSse, encoder);+ ws.addEventListener("message", (event) => { try { async function onMessage(ev) {- const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data);- const message = JSON.parse(data);- if (message.type !== "cf_mcp_agent_event") return;- await writer.write(encoder.encode(message.event));- }+ const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data);+ const message = JSON.parse(data);+ if (message.type !== "cf_mcp_agent_event") return;+ const writePromise = __forwardSse(encoder.encode(message.event));+ if (message.close) {+ clearInterval(keepAlive);+ await writePromise;+ await writer.close();+ if (!__sseClosed && !request.signal.aborted) {+ // Storage is cleared only on this ack, which fires only+ // after writer.close() resolved with the client still+ // attached; a replayed response enqueued into a dead GET+ // never acks and stays replayable. `ackStreamIds` lets a+ // replay-complete frame confirm several replayed streams+ // at once; a live final response carries its `streamId`.+ const ackStreamIds = Array.isArray(message.ackStreamIds) ? message.ackStreamIds : message.streamId ? [message.streamId] : [];+ for (const ackStreamId of ackStreamIds) try {+ ws.send(JSON.stringify({+ type: "cf_mcp_delivery_ack",+ eventId: message.eventId,+ streamId: ackStreamId+ }));+ } catch {}+ ws?.close(1000, "SSE response delivered");+ }+ }+ } onMessage(event).catch(console.error); } catch (e) { console.error("Error forwarding message to SSE:", e); }- });- ws.addEventListener("error", () => {- writer.close().catch(() => {});- });- ws.addEventListener("close", () => {- writer.close().catch(() => {});- });+ });+ ws.addEventListener("error", () => {+ __sseClosed = true;+ clearInterval(keepAlive);+ writer.close().catch(() => {});+ });+ ws.addEventListener("close", () => {+ __sseClosed = true;+ clearInterval(keepAlive);+ writer.close().catch(() => {});+ }); return new Response(readable, { headers: { "Cache-Control": "no-cache",@@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { const url = new URL(request.url); if (request.method === "GET" && basePattern.test(url)) { const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();- const { readable, writable } = new TransformStream();- const writer = writable.getWriter();- const encoder = new TextEncoder();- const endpointUrl = new URL(request.url);+ const { readable, writable } = new TransformStream();+ const writer = writable.getWriter();+ const encoder = new TextEncoder();+ const __openedAt = Date.now();+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;+ let __pendingBytes = 0;+ let __writeChain = Promise.resolve();+ let __sseClosed = false;+ let keepAlive;+ const endpointUrl = new URL(request.url); endpointUrl.pathname = encodeURI(`${basePath}/message`); endpointUrl.searchParams.set("sessionId", sessionId); const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;@@ -414,35 +578,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { console.error("Failed to establish WebSocket connection"); await writer.close(); return new Response("Failed to establish WebSocket connection", { status: 500 });- }- ws.accept();- ws.addEventListener("message", (event) => {+ }+ ws.accept();+ const __abortSse = () => {+ __sseClosed = true;+ try {+ clearInterval(keepAlive);+ } catch {}+ try {+ ws.close(1013, "SSE client not draining");+ } catch {}+ writer.abort(new Error("SSE client not draining")).catch(() => {});+ };+ const __closeSseGracefully = () => {+ __sseClosed = true;+ try {+ clearInterval(keepAlive);+ } catch {}+ try {+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n");+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => {+ writer.abort().catch(() => {});+ });+ } catch {+ __writeChain = writer.close().catch(() => {+ writer.abort().catch(() => {});+ });+ }+ try {+ ws.close(1000, "sse_max_age_rotation");+ } catch {}+ return __writeChain;+ };+ const __forwardSse = (frame) => {+ if (__sseClosed) return __writeChain;+ const ageMs = Date.now() - __openedAt;+ if (ageMs > MAX_SSE_AGE_MS) {+ console.log(JSON.stringify({+ event: "sse_max_age_close",+ sessionId,+ variant: "legacy-sse",+ ageMs,+ pendingBytes: __pendingBytes+ }));+ __closeSseGracefully();+ return Promise.resolve();+ }+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {+ __abortSse();+ return Promise.resolve();+ }+ __pendingBytes += frame.byteLength;+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {}).finally(() => {+ __pendingBytes -= frame.byteLength;+ });+ return __writeChain;+ };+ keepAlive = startKeepalive(__forwardSse, encoder);+ ws.addEventListener("message", (event) => { async function onMessage(event) { try { const message = JSON.parse(event.data);- const result = JSONRPCMessageSchema.safeParse(message);- if (!result.success) return;- const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`;- await writer.write(encoder.encode(messageText));- } catch (error) {+ const result = JSONRPCMessageSchema.safeParse(message);+ if (!result.success) return;+ const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`;+ __forwardSse(encoder.encode(messageText));+ } catch (error) { console.error("Error forwarding message to SSE:", error); } } onMessage(event).catch(console.error); });- ws.addEventListener("error", (error) => {- async function onError(_error) {- try {- await writer.close();- } catch (_e) {}+ ws.addEventListener("error", (error) => {+ async function onError(_error) {+ try {+ __sseClosed = true;+ clearInterval(keepAlive);+ await writer.close();+ } catch (_e) {} } onError(error).catch(console.error); });- ws.addEventListener("close", () => {- async function onClose() {- try {- await writer.close();- } catch (error) {+ ws.addEventListener("close", () => {+ async function onClose() {+ try {+ __sseClosed = true;+ clearInterval(keepAlive);+ await writer.close();+ } catch (error) { console.error("Error closing SSE connection:", error);Lines 1–500 of 1053. Load more
diff --git a/node_modules/agents/.bun-tag-37e67b70862be2b2 b/.bun-tag-37e67b70862be2b2 new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/node_modules/agents/.bun-tag-61b2f1517ab5ced4 b/.bun-tag-61b2f1517ab5ced4 new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502 new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 100644 --- a/dist/agent-tool-types-CNyE1iz_.d.ts +++ b/dist/agent-tool-types-CNyE1iz_.d.ts @@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore { private readonly seqByStream; private readonly seqInit; constructor(storage: DurableObjectStorage); - storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>; + /** Resolves `undefined` for a message too large for DO storage's 128 KiB + * per-value cap: the event is delivered live without a replay id rather + * than failing the send. */ + storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId | undefined>; getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>; replayEventsAfter( lastEventId: EventId, diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644 --- a/dist/mcp/index.d.ts +++ b/dist/mcp/index.d.ts @@ -29,6 +29,7 @@ import { xt as MCPClientOAuthCallbackConfig, zt as ElicitResult } from "../agent-tool-types-CNyE1iz_.js"; +declare const MAX_SSE_AGE_MS = 1800000; export { type ClearableEventStore, type CreateMcpHandlerOptions, @@ -41,6 +42,7 @@ export { type MCPConnectionResult, type MCPDiscoverResult, type MCPServerOptions, + MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, type McpAuthContext, diff --git a/dist/mcp/index.js b/dist/mcp/index.js index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5babd1219e17 100644 --- a/dist/mcp/index.js +++ b/dist/mcp/index.js @@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ const KEEPALIVE_INTERVAL_MS = 25e3; /** SSE comment frame the parser drops before any event dispatch. */ const KEEPALIVE_FRAME = ": keepalive\n\n"; +// Max age is a stalled-client memory backstop, not session idleness. Keep it +// well above Executor's 5 minute session idle timeout so active clients rarely +// rotate. +const MAX_SSE_AGE_MS = 30 * 60 * 1000; /** * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle * that the stream cleanup must invoke when the stream closes. */ -function startKeepalive(writer, encoder) { +function startKeepalive(writeFrame, encoder) { const handle = setInterval(() => { - writer.write(encoder.encode(KEEPALIVE_FRAME)).catch(() => clearInterval(handle)); + writeFrame(encoder.encode(KEEPALIVE_FRAME)); }, KEEPALIVE_INTERVAL_MS); return handle; } @@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { }); return new Response(body, { status: 404 }); } - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - const existingHeaders = {}; + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; + let __pendingBytes = 0; + let __writeChain = Promise.resolve(); + let __sseClosed = false; + let keepAlive; + const existingHeaders = {}; request.headers.forEach((value, key) => { existingHeaders[key] = value; }); @@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { jsonrpc: "2.0" }); return new Response(body, { status: 500 }); - } - ws.accept(); - if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) { - ws.close(); - return new Response(null, { + } + ws.accept(); + const __closeSse = () => { + if (__sseClosed) return; + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + ws.close(1013, "SSE client not draining"); + } catch {} + writer.abort(new Error("SSE client not draining")).catch(() => {}); + }; + const __markSseClientClosed = () => { + if (__sseClosed) return; + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + writer.abort(new Error("SSE client disconnected")).catch(() => {}); + }; + writer.closed?.catch(() => { + __markSseClientClosed(); + }); + request.signal.addEventListener("abort", __closeSse, { once: true }); + const __forwardSse = (frame) => { + if (__sseClosed) return __writeChain; + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { + __closeSse(); + return Promise.resolve(); + } + __pendingBytes += frame.byteLength; + __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => { + __closeSse(); + }).finally(() => { + __pendingBytes -= frame.byteLength; + }); + return __writeChain; + }; + keepAlive = startKeepalive(__forwardSse, encoder); + if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) { + clearInterval(keepAlive); + ws.close(); + return new Response(null, { headers: corsHeaders(request, options.corsOptions), status: 202 - }); - } - const keepAlive = startKeepalive(writer, encoder); - ws.addEventListener("message", (event) => { + }); + } + ws.addEventListener("message", (event) => { async function onMessage(event) { try { - const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); - const message = JSON.parse(data); - if (message.type !== "cf_mcp_agent_event") return; - await writer.write(encoder.encode(message.event)); - if (message.close) { - clearInterval(keepAlive); - ws?.close(); - await writer.close().catch(() => {}); - } + const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); + const message = JSON.parse(data); + if (message.type !== "cf_mcp_agent_event") return; + const writePromise = __forwardSse(encoder.encode(message.event)); + if (message.close) { + clearInterval(keepAlive); + await writePromise; + await writer.close(); + if (!__sseClosed && !request.signal.aborted) { + // workerd resolves writer.close() even when the client + // canceled the POST response body, and request.signal does + // not reliably fire for that cancellation, so a successful + // close is NOT proof of delivery. Never ack POST-stream + // deliveries: the DO keeps each response persisted for its + // own Last-Event-ID recovery or an explicitly cursorless + // fresh GET. A client that DID receive the result closes the + // POST body reader without recovery, so the worst case of + // this at-least-once choice is a duplicate on that fresh-GET + // fallback, never a response injected into another cursor's + // recovery stream. + ws?.close(1000, "SSE response delivered"); + } + } } catch (error) { console.error("Error forwarding message to SSE:", error); } } onMessage(event).catch(console.error); }); - ws.addEventListener("error", (error) => { - async function onError(_error) { - clearInterval(keepAlive); - await writer.close().catch(() => {}); + ws.addEventListener("error", (error) => { + async function onError(_error) { + __sseClosed = true; + clearInterval(keepAlive); + await writer.close().catch(() => {}); } onError(error).catch(console.error); }); - ws.addEventListener("close", () => { - async function onClose() { - clearInterval(keepAlive); - await writer.close().catch(() => {}); + ws.addEventListener("close", () => { + async function onClose() { + __sseClosed = true; + clearInterval(keepAlive); + await writer.close().catch(() => {}); } onClose().catch(console.error); }); @@ -279,10 +342,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { id: null, jsonrpc: "2.0" }), { status: 400 }); - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + const __openedAt = Date.now(); + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; + let __pendingBytes = 0; + let __writeChain = Promise.resolve(); + let __sseClosed = false; + let keepAlive; + const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { props: ctx.props, jurisdiction: options.jurisdiction }); @@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { if (!ws) { await writer.close(); return new Response("Failed to establish WS to DO", { status: 500 }); - } - ws.accept(); - ws.addEventListener("message", (event) => { + } + ws.accept(); + const __abortSse = () => { + if (__sseClosed) return; + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + ws.close(1013, "SSE client not draining"); + } catch {} + writer.abort(new Error("SSE client not draining")).catch(() => {}); + }; + writer.closed?.catch(() => { + __abortSse(); + }); + request.signal.addEventListener("abort", __abortSse, { once: true }); + const __closeSseGracefully = () => { + if (__sseClosed) return __writeChain; + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); + __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { + writer.abort().catch(() => {}); + }); + } catch { + __writeChain = writer.close().catch(() => { + writer.abort().catch(() => {}); + }); + } + try { + ws.close(1000, "sse_max_age_rotation"); + } catch {} + return __writeChain; + }; + const __forwardSse = (frame) => { + if (__sseClosed) return __writeChain; + const ageMs = Date.now() - __openedAt; + if (ageMs > MAX_SSE_AGE_MS) { + console.log(JSON.stringify({ + event: "sse_max_age_close", + sessionId, + variant: "streamable-get", + ageMs, + pendingBytes: __pendingBytes + })); + __closeSseGracefully(); + return Promise.resolve(); + } + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { + __abortSse(); + return Promise.resolve(); + } + __pendingBytes += frame.byteLength; + __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => { + __abortSse(); + }).finally(() => { + __pendingBytes -= frame.byteLength; + }); + return __writeChain; + }; + keepAlive = startKeepalive(__forwardSse, encoder); + ws.addEventListener("message", (event) => { try { async function onMessage(ev) { - const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data); - const message = JSON.parse(data); - if (message.type !== "cf_mcp_agent_event") return; - await writer.write(encoder.encode(message.event)); - } + const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data); + const message = JSON.parse(data); + if (message.type !== "cf_mcp_agent_event") return; + const writePromise = __forwardSse(encoder.encode(message.event)); + if (message.close) { + clearInterval(keepAlive); + await writePromise; + await writer.close(); + if (!__sseClosed && !request.signal.aborted) { + // Storage is cleared only on this ack, which fires only + // after writer.close() resolved with the client still + // attached; a replayed response enqueued into a dead GET + // never acks and stays replayable. `ackStreamIds` lets a + // replay-complete frame confirm several replayed streams + // at once; a live final response carries its `streamId`. + const ackStreamIds = Array.isArray(message.ackStreamIds) ? message.ackStreamIds : message.streamId ? [message.streamId] : []; + for (const ackStreamId of ackStreamIds) try { + ws.send(JSON.stringify({ + type: "cf_mcp_delivery_ack", + eventId: message.eventId, + streamId: ackStreamId + })); + } catch {} + ws?.close(1000, "SSE response delivered"); + } + } + } onMessage(event).catch(console.error); } catch (e) { console.error("Error forwarding message to SSE:", e); } - }); - ws.addEventListener("error", () => { - writer.close().catch(() => {}); - }); - ws.addEventListener("close", () => { - writer.close().catch(() => {}); - }); + }); + ws.addEventListener("error", () => { + __sseClosed = true; + clearInterval(keepAlive); + writer.close().catch(() => {}); + }); + ws.addEventListener("close", () => { + __sseClosed = true; + clearInterval(keepAlive); + writer.close().catch(() => {}); + }); return new Response(readable, { headers: { "Cache-Control": "no-cache", @@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { const url = new URL(request.url); if (request.method === "GET" && basePattern.test(url)) { const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - const endpointUrl = new URL(request.url); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + const __openedAt = Date.now(); + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; + let __pendingBytes = 0; + let __writeChain = Promise.resolve(); + let __sseClosed = false; + let keepAlive; + const endpointUrl = new URL(request.url); endpointUrl.pathname = encodeURI(`${basePath}/message`); endpointUrl.searchParams.set("sessionId", sessionId); const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`; @@ -414,35 +578,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { console.error("Failed to establish WebSocket connection"); await writer.close(); return new Response("Failed to establish WebSocket connection", { status: 500 }); - } - ws.accept(); - ws.addEventListener("message", (event) => { + } + ws.accept(); + const __abortSse = () => { + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + ws.close(1013, "SSE client not draining"); + } catch {} + writer.abort(new Error("SSE client not draining")).catch(() => {}); + }; + const __closeSseGracefully = () => { + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); + __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { + writer.abort().catch(() => {}); + }); + } catch { + __writeChain = writer.close().catch(() => { + writer.abort().catch(() => {}); + }); + } + try { + ws.close(1000, "sse_max_age_rotation"); + } catch {} + return __writeChain; + }; + const __forwardSse = (frame) => { + if (__sseClosed) return __writeChain; + const ageMs = Date.now() - __openedAt; + if (ageMs > MAX_SSE_AGE_MS) { + console.log(JSON.stringify({ + event: "sse_max_age_close", + sessionId, + variant: "legacy-sse", + ageMs, + pendingBytes: __pendingBytes + })); + __closeSseGracefully(); + return Promise.resolve(); + } + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { + __abortSse(); + return Promise.resolve(); + } + __pendingBytes += frame.byteLength; + __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {}).finally(() => { + __pendingBytes -= frame.byteLength; + }); + return __writeChain; + }; + keepAlive = startKeepalive(__forwardSse, encoder); + ws.addEventListener("message", (event) => { async function onMessage(event) { try { const message = JSON.parse(event.data); - const result = JSONRPCMessageSchema.safeParse(message); - if (!result.success) return; - const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`; - await writer.write(encoder.encode(messageText)); - } catch (error) { + const result = JSONRPCMessageSchema.safeParse(message); + if (!result.success) return; + const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`; + __forwardSse(encoder.encode(messageText)); + } catch (error) { console.error("Error forwarding message to SSE:", error); } } onMessage(event).catch(console.error); }); - ws.addEventListener("error", (error) => { - async function onError(_error) { - try { - await writer.close(); - } catch (_e) {} + ws.addEventListener("error", (error) => { + async function onError(_error) { + try { + __sseClosed = true; + clearInterval(keepAlive); + await writer.close(); + } catch (_e) {} } onError(error).catch(console.error); }); - ws.addEventListener("close", () => { - async function onClose() { - try { - await writer.close(); - } catch (error) { + ws.addEventListener("close", () => { + async function onClose() { + try { + __sseClosed = true; + clearInterval(keepAlive); + await writer.close(); + } catch (error) { console.error("Error closing SSE connection:", error);