From d88576e116288179c99dceb5fed03e6b9cf0aa00 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 30 Apr 2026 10:07:22 +0200 Subject: [PATCH 1/3] fix(appkit): harden SSE response headers in setupHeaders - Set Content-Type to text/event-stream; charset=utf-8 - Change Cache-Control to no-cache, no-transform (prevents proxies from buffering/transforming the stream) - Add X-Accel-Buffering: no to disable nginx/proxy response buffering (respected by Cloudflare, AWS ALB, GCP, and most corporate proxies) - Remove Connection: keep-alive (forbidden by HTTP/2; Node manages it) - Remove Content-Encoding: none (invalid header value; causes 502/RST in strict intermediaries) - Write a ": ok\n\n" sentinel comment immediately after flushHeaders so any buffering proxy is forced to release the first chunk to the client before the upstream generator yields its first real event. Prevents "Failed to fetch" on cold-start SQL warehouses. Signed-off-by: MarioCadenas --- packages/appkit/src/stream/sse-writer.ts | 28 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/appkit/src/stream/sse-writer.ts b/packages/appkit/src/stream/sse-writer.ts index c9fafd183..61629f58f 100644 --- a/packages/appkit/src/stream/sse-writer.ts +++ b/packages/appkit/src/stream/sse-writer.ts @@ -11,12 +11,30 @@ import { StreamValidator } from "./validator"; export class SSEWriter { // setup SSE headers setupHeaders(res: IAppResponse): void { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - res.setHeader("Content-Encoding", "none"); - + res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + // X-Accel-Buffering: no — disables nginx-style proxy response buffering + // for SSE (used by Cloudflare, AWS, GCP, and most corporate proxies). + res.setHeader("X-Accel-Buffering", "no"); + // Intentionally NOT setting: + // - Connection: keep-alive (HTTP/2 forbids it; Node manages keep-alive) + // - Content-Encoding: none (invalid value; can trigger 502/RST in + // strict intermediaries) res.flushHeaders?.(); + // Sentinel comment — a no-op SSE line that forces the response body + // open immediately. Two purposes: + // 1. Any buffering proxy must release the response headers + this + // first chunk to the client right away, so fetch() resolves with + // response.ok before the upstream generator yields. + // 2. Prevents "Failed to fetch" symptoms where the browser gives up + // before the SQL query completes on cold-start warehouses. + if (!res.writableEnded) { + try { + res.write(": ok\n\n"); + } catch { + // ignore — handled by writeEvent's writableEnded check downstream + } + } } // write a single event to the response From 7c56cb1bb9c20000b1dec38578e8c467a49aec44 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 30 Apr 2026 10:13:31 +0200 Subject: [PATCH 2/3] fix(appkit): remove sentinel write from setupHeaders Writing res.write() synchronously inside setupHeaders fires before the caller attaches error handlers to the response stream. A failed write can emit an async error event on the stream that is not caught by the try/catch, potentially surfacing as a connection reset. The heartbeat already keeps the stream alive during cold starts, so the sentinel is redundant and risky. Signed-off-by: MarioCadenas --- packages/appkit/src/stream/sse-writer.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/appkit/src/stream/sse-writer.ts b/packages/appkit/src/stream/sse-writer.ts index 61629f58f..b73cdb587 100644 --- a/packages/appkit/src/stream/sse-writer.ts +++ b/packages/appkit/src/stream/sse-writer.ts @@ -21,20 +21,6 @@ export class SSEWriter { // - Content-Encoding: none (invalid value; can trigger 502/RST in // strict intermediaries) res.flushHeaders?.(); - // Sentinel comment — a no-op SSE line that forces the response body - // open immediately. Two purposes: - // 1. Any buffering proxy must release the response headers + this - // first chunk to the client right away, so fetch() resolves with - // response.ok before the upstream generator yields. - // 2. Prevents "Failed to fetch" symptoms where the browser gives up - // before the SQL query completes on cold-start warehouses. - if (!res.writableEnded) { - try { - res.write(": ok\n\n"); - } catch { - // ignore — handled by writeEvent's writableEnded check downstream - } - } } // write a single event to the response From 0e818adb9d737bb6edea9452b4b0e5f6045bc9a3 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 30 Apr 2026 10:23:25 +0200 Subject: [PATCH 3/3] fix(appkit): update tests and serving plugin to match new SSE headers - Update ServingPlugin._handleStream to use the same hardened headers as SSEWriter (charset=utf-8, no-transform, X-Accel-Buffering: no, drop Connection and Content-Encoding) - Update all test assertions to reflect the new header values Signed-off-by: MarioCadenas --- .../analytics/tests/analytics.integration.test.ts | 4 +++- .../src/plugins/analytics/tests/analytics.test.ts | 11 ++++------- packages/appkit/src/plugins/genie/tests/genie.test.ts | 4 ++-- packages/appkit/src/plugins/serving/serving.ts | 6 +++--- .../appkit/src/plugins/serving/tests/serving.test.ts | 7 +++++-- packages/appkit/src/stream/tests/stream.test.ts | 2 +- 6 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts index 0cec22984..5c08b8d43 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.integration.test.ts @@ -105,7 +105,9 @@ describe("Analytics Plugin Integration", () => { ); expect(response.status).toBe(200); - expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + expect(response.headers.get("Content-Type")).toBe( + "text/event-stream; charset=utf-8", + ); const sseData = await parseSSEResponse(response); expect(sseData.eventType).toBe("result"); diff --git a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts index 9a30440ed..ce351021e 100644 --- a/packages/appkit/src/plugins/analytics/tests/analytics.test.ts +++ b/packages/appkit/src/plugins/analytics/tests/analytics.test.ts @@ -174,16 +174,13 @@ describe("Analytics Plugin", () => { expect(mockRes.setHeader).toHaveBeenCalledWith( "Content-Type", - "text/event-stream", + "text/event-stream; charset=utf-8", ); expect(mockRes.setHeader).toHaveBeenCalledWith( "Cache-Control", - "no-cache", - ); - expect(mockRes.setHeader).toHaveBeenCalledWith( - "Connection", - "keep-alive", + "no-cache, no-transform", ); + expect(mockRes.setHeader).toHaveBeenCalledWith("X-Accel-Buffering", "no"); expect(mockRes.write).toHaveBeenCalledWith("event: result\n"); expect(mockRes.write).toHaveBeenCalledWith( @@ -245,7 +242,7 @@ describe("Analytics Plugin", () => { expect(mockRes.setHeader).toHaveBeenCalledWith( "Content-Type", - "text/event-stream", + "text/event-stream; charset=utf-8", ); expect(mockRes.write).toHaveBeenCalledWith("event: result\n"); diff --git a/packages/appkit/src/plugins/genie/tests/genie.test.ts b/packages/appkit/src/plugins/genie/tests/genie.test.ts index 3cf0784d6..8b6e45564 100644 --- a/packages/appkit/src/plugins/genie/tests/genie.test.ts +++ b/packages/appkit/src/plugins/genie/tests/genie.test.ts @@ -299,11 +299,11 @@ describe("Genie Plugin", () => { // Verify SSE headers expect(mockRes.setHeader).toHaveBeenCalledWith( "Content-Type", - "text/event-stream", + "text/event-stream; charset=utf-8", ); expect(mockRes.setHeader).toHaveBeenCalledWith( "Cache-Control", - "no-cache", + "no-cache, no-transform", ); // Verify SSE events are written diff --git a/packages/appkit/src/plugins/serving/serving.ts b/packages/appkit/src/plugins/serving/serving.ts index 0cae51d38..37707778e 100644 --- a/packages/appkit/src/plugins/serving/serving.ts +++ b/packages/appkit/src/plugins/serving/serving.ts @@ -261,9 +261,9 @@ export class ServingPlugin extends Plugin { return; } - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Content-Encoding", "none"); + res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders(); const nodeStream = Readable.fromWeb( diff --git a/packages/appkit/src/plugins/serving/tests/serving.test.ts b/packages/appkit/src/plugins/serving/tests/serving.test.ts index 2f2be9574..8fbe79bba 100644 --- a/packages/appkit/src/plugins/serving/tests/serving.test.ts +++ b/packages/appkit/src/plugins/serving/tests/serving.test.ts @@ -286,9 +286,12 @@ describe("Serving Plugin", () => { expect(res.setHeader).toHaveBeenCalledWith( "Content-Type", - "text/event-stream", + "text/event-stream; charset=utf-8", + ); + expect(res.setHeader).toHaveBeenCalledWith( + "Cache-Control", + "no-cache, no-transform", ); - expect(res.setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); expect(mockStream).toHaveBeenCalledWith( expect.anything(), "test-endpoint", diff --git a/packages/appkit/src/stream/tests/stream.test.ts b/packages/appkit/src/stream/tests/stream.test.ts index fae54289c..14b71cbb2 100644 --- a/packages/appkit/src/stream/tests/stream.test.ts +++ b/packages/appkit/src/stream/tests/stream.test.ts @@ -44,7 +44,7 @@ describe("StreamManager", () => { expect(mockRes.setHeader).toHaveBeenCalledWith( "Content-Type", - "text/event-stream", + "text/event-stream; charset=utf-8", ); expect(events).toContain( 'data: {"type":"start","message":"Starting"}\n\n',