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
2 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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue