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
|
|
@ -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