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:
parent
097bfafdc1
commit
9644ac7fe8
1 changed files with 282 additions and 1 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue