fix: prime SSE streams so Firefox resolves the events fetch

Firefox does not hand a streaming `fetch()` response to JS until the
first *body* byte arrives; Chromium resolves the promise as soon as the
headers do. Both SSE endpoints flushed headers immediately and then
stayed silent until there was something to report, which deadlocked
`/api/mcp/events`: `RemoteClientTransport.openEventStream()` awaits that
fetch *before* the MCP client sends `initialize`, so no `initialize` ->
no event to report -> no body byte -> the fetch never resolves. The web
UI hung on "Connecting..." forever with nothing in the UI, the console,
or the node logs.

Write an inert `:` SSE comment the instant each stream opens. Conforming
parsers ignore a comment line, so this unblocks the read without
inventing a wire event and needs no client-side protocol change.

`X-Content-Type-Options: nosniff` was also proposed on the issue but does
not fix it -- verified against Firefox 153, the fetch still never
resolves with the header set and no body byte. The delay is not MIME
sniffing.

The priming write is deliberately ordered *after* each handler registers
its consumer (the session event consumer; the file-watch subscriber plus
`ensureWatcher()`). Callers treat the arrival of the stream's first bytes
as proof they are subscribed, so priming first would hand out that proof
across an `await` and drop an edit made in the gap.

`/api/servers/events` gets the same treatment: its fetch was equally
stuck on Firefox until the first real change event, which for an unedited
`mcp.json` never comes. Its client-side reader counted any `\n\n` as a
change, so the inert frame would have fired a spurious background
re-fetch on every connection -- it now skips frames carrying no
`event:`/`data:` field. The transport's own `parseSSE` already drops
comment frames correctly and needed no change.

Verified end to end in real Firefox against a live streamable-HTTP test
server: before, stuck on "Connecting..." past 25s; after, initialize ->
notifications/initialized -> tools/list, connected.

Closes #1858

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
This commit is contained in:
cliffhall 2026-08-06 17:05:36 -04:00
commit 9644ac7fe8
2 changed files with 282 additions and 1 deletions

View file

@ -78,6 +78,27 @@ import { formatClientConfigLoadError } from "../../../client/config-parse.js";
import { envSecretField } from "../../../auth/secret-fields.js";
import { ZodError } from "zod";
/**
* Written to every SSE stream the instant it opens, before anything else.
*
* Firefox does not hand a streaming `fetch()` response to JS until the first
* *body* byte arrives; Chromium resolves the promise as soon as the headers
* do. Both SSE endpoints here flush headers immediately and then stay silent
* until there is something to report, which deadlocks Firefox on
* `/api/mcp/events`: `RemoteClientTransport.openEventStream()` awaits that
* fetch *before* the MCP client sends `initialize`, so no `initialize` → no
* event to report → no body byte → the fetch never resolves → the web UI
* hangs on "Connecting…" forever with no error anywhere (#1858).
*
* A `:` comment line is inert per the SSE spec — conforming parsers ignore it
* — so priming with one unblocks the read without inventing a wire event.
*
* `X-Content-Type-Options: nosniff` does **not** fix this. Verified against
* Firefox 153: with the header and no body byte, the fetch still never
* resolves. The delay is not MIME sniffing.
*/
const SSE_PRIMING_COMMENT = ":\n\n";
/**
* Shape of the initial config returned by GET /api/config (defaults for client).
*/
@ -785,6 +806,7 @@ export function createRemoteApp(
}
});
// Prime every SSE stream the instant it opens — see SSE_PRIMING_COMMENT.
app.get("/api/mcp/events", async (c) => {
const sessionId = c.req.query("sessionId");
if (!sessionId) {
@ -818,6 +840,11 @@ export function createRemoteApp(
return;
}
// Prime only after the consumer is registered, so nothing observable
// to the client happens before this stream can actually report
// events. See SSE_PRIMING_COMMENT.
await stream.write(SSE_PRIMING_COMMENT);
stream.onAbort(() => {
// Client disconnected - clear event consumer
const shouldCleanup = session.clearEventConsumer();
@ -2389,6 +2416,16 @@ export function createRemoteApp(
ensureWatcher();
}
// Prime the stream so the client's fetch() actually resolves — on
// Firefox it otherwise stays pending until the first real change
// event, which for an unedited `mcp.json` is never. See
// SSE_PRIMING_COMMENT. Deliberately *after* the subscriber is
// registered and the watcher started: callers treat the arrival of
// this stream's first bytes as proof they are subscribed, so priming
// first would hand out that proof across an `await`, before the
// watcher exists, and drop an edit made in the gap.
await stream.write(SSE_PRIMING_COMMENT);
stream.onAbort(() => {
serverEventSubscribers.delete(send);
void maybeStopWatcher();

View file

@ -82,6 +82,18 @@ async function readErrorMessage(res: Response): Promise<string> {
return `HTTP ${res.status}`;
}
/**
* Whether an SSE frame carries an actual event rather than only comment
* lines. The backend primes each stream with an inert `:` comment so a
* streaming `fetch()` resolves on Firefox at all (#1858); that frame must
* not be mistaken for a change notification.
*/
function isSseDataFrame(frame: string): boolean {
return frame
.split("\n")
.some((line) => line.startsWith("event:") || line.startsWith("data:"));
}
export function useServers(opts: UseServersOptions): UseServersResult {
const { baseUrl, authToken, fetchFn } = opts;
const doFetch = fetchFn ?? globalThis.fetch;
@ -157,6 +169,10 @@ export function useServers(opts: UseServersOptions): UseServersResult {
// single background refresh per decode chunk. Two `change`
// broadcasts landing in the same chunk become one re-fetch instead
// of two concurrent ones whose setState order is unspecified.
// Frames carrying no `event:`/`data:` field are skipped: the backend
// opens the stream with an inert `:` comment frame so Firefox
// resolves this fetch at all (see SSE_PRIMING_COMMENT in the remote
// server), and that must not read as a change.
// Cross-chunk debounce is not added: `awaitWriteFinish`'s 100ms
// stability threshold already serializes external edits at the
// source, and chained fetches against the same GET endpoint are
@ -169,8 +185,9 @@ export function useServers(opts: UseServersOptions): UseServersResult {
let sawFrame = false;
let frameEnd = buffer.indexOf("\n\n");
while (frameEnd !== -1) {
const frame = buffer.slice(0, frameEnd);
buffer = buffer.slice(frameEnd + 2);
sawFrame = true;
if (isSseDataFrame(frame)) sawFrame = true;
frameEnd = buffer.indexOf("\n\n");
}
if (sawFrame) void refreshInternal(true);