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
|
|
@ -969,6 +969,74 @@ describe("useServers", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("ignores the backend's inert priming comment frame (#1858)", async () => {
|
||||
// The backend opens every SSE stream with a `:` comment so a streaming
|
||||
// fetch() resolves on Firefox at all. That frame carries no event/data
|
||||
// field and must NOT be read as a change — otherwise every connection
|
||||
// would fire a spurious background re-fetch on open.
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: { seed: { type: "stdio", command: "s" } },
|
||||
}),
|
||||
);
|
||||
|
||||
// Count list GETs rather than watching the rendered list: a spurious
|
||||
// refresh fires the instant the priming frame is read, so it would race
|
||||
// ahead of any later disk mutation and land the same data — invisible in
|
||||
// the output but a real extra round-trip on every connection.
|
||||
let listGets = 0;
|
||||
let reads = 0;
|
||||
let releaseSecondRead: (() => void) | undefined;
|
||||
const secondRead = new Promise<void>((r) => {
|
||||
releaseSecondRead = r;
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
// Must be referentially stable: the hook's SSE effect keys off `fetchFn`,
|
||||
// so an inline arrow would re-subscribe (and re-refresh) every render and
|
||||
// inflate the very count this test asserts on.
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
if (url.endsWith("/api/servers/events")) {
|
||||
const body = {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
reads += 1;
|
||||
// Priming comment only — no `event:` / `data:` line.
|
||||
if (reads === 1) {
|
||||
return { done: false, value: encoder.encode(":\n\n") };
|
||||
}
|
||||
// Hold the stream open so the loop can't end and let a
|
||||
// teardown-time settle hide a queued refresh.
|
||||
await secondRead;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { ok: true, body } as unknown as Response;
|
||||
}
|
||||
if (url.endsWith("/api/servers")) listGets += 1;
|
||||
return h.fetchFn(url, init);
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useServers({ baseUrl: "http://test.local", fetchFn }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.servers.map((s) => s.id)).toEqual(["seed"]);
|
||||
|
||||
// The priming frame has been consumed (the loop is parked on read #2).
|
||||
await waitFor(() => expect(reads).toBeGreaterThanOrEqual(2));
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Exactly one GET: the hook's mount refresh. The comment frame added none.
|
||||
expect(listGets).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
releaseSecondRead?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to globalThis.fetch when no fetchFn is provided", async () => {
|
||||
// No fetchFn → the `doFetch = fetchFn ?? globalThis.fetch` default branch.
|
||||
const globalFetch = vi
|
||||
|
|
|
|||
Loading…
Reference in a new issue