Area: sdk — streaming reliability. Surfaced reviewing #470 (#203), documented there but deliberately not mitigated.
A cross-origin browser stream can reach a state where it re-dials forever and can never succeed, in a deployment where the old EventSource transport worked.
The sequence
- First connect is a simple request. The transport sends
Accept: text/event-stream, which is CORS-safelisted, so no preflight. It connects and delivers events.
- Reconnect adds
Last-Event-ID (clients/ts/src/stream/sse.ts), set on every attempt once an event has been seen. That header is not safelisted, so this request preflights.
- If a proxy answers CORS itself and doesn't allow
Last-Event-ID on OPTIONS /v1/stream, the preflight fails, the fetch rejects, and the transport reports SSE_NETWORK_ERROR and re-dials.
_lastEventId is never cleared, so every subsequent attempt carries the same header and hits the same never-satisfiable preflight. The stream is permanently down.
WaveHouse's own CORS allow-lists Last-Event-ID (internal/api/router.go), so this only bites when something in front terminates CORS. But that is exactly the deployment #203's header work is aimed at.
Why it's a regression in shape. A native EventSource never preflights at all — its request isn't a fetch(), so Fetch's unsafe-request flag is never set and Last-Event-ID rides on the plain GET. An operator upgrading the SDK sees streams that connected fine start dying on their first reconnect, with a CORS error that names a header they never configured.
Note this is not the credentialed case. That one fails loudly at first connect, because Authorization isn't safelisted either — it's already in the proxy checklist in docs/src/content/docs/reverse-proxy.mdx. The uncredentialed path is worse precisely because it works until it doesn't.
Options
- Self-heal. After N consecutive attempts that fail before a response arrives, drop
_lastEventId and re-open without it. The stream resumes with a gap rather than never resuming. Needs a decision on N, and on whether to surface the dropped-resumption-point as an error rather than silently losing history — silently is arguably worse than the current loud-but-stuck behavior.
- Fall back to
?since=. The server accepts since as a query parameter and prefers Last-Event-ID only when both are present (internal/api/stream.go). A query parameter never preflights, so resuming via since sidesteps the problem entirely — at the cost of putting a timestamp in the URL and losing the header's precedence semantics.
- Document only. Where it stands today: the proxy checklist says to allow the header. That is a real mitigation for operators who read it, and no mitigation at all for those who don't.
Option 2 looks strongest — the server already supports it, it removes the failure mode rather than recovering from it, and a timestamp in a URL carries none of the risk that moving the token out of the URL was about. Worth measuring against whatever multiplexing decides, since #204 may move resumption into a POST body anyway.
Acceptance
Related: #203 (introduced the fetch transport), #215 (added the CORS allow-list), #204 (multiplexing may relocate resumption), #469 (the other streaming re-dial edge).
Area: sdk — streaming reliability. Surfaced reviewing #470 (#203), documented there but deliberately not mitigated.
A cross-origin browser stream can reach a state where it re-dials forever and can never succeed, in a deployment where the old
EventSourcetransport worked.The sequence
Accept: text/event-stream, which is CORS-safelisted, so no preflight. It connects and delivers events.Last-Event-ID(clients/ts/src/stream/sse.ts), set on every attempt once an event has been seen. That header is not safelisted, so this request preflights.Last-Event-IDonOPTIONS /v1/stream, the preflight fails, the fetch rejects, and the transport reportsSSE_NETWORK_ERRORand re-dials._lastEventIdis never cleared, so every subsequent attempt carries the same header and hits the same never-satisfiable preflight. The stream is permanently down.WaveHouse's own CORS allow-lists
Last-Event-ID(internal/api/router.go), so this only bites when something in front terminates CORS. But that is exactly the deployment #203's header work is aimed at.Why it's a regression in shape. A native
EventSourcenever preflights at all — its request isn't afetch(), so Fetch's unsafe-request flag is never set andLast-Event-IDrides on the plainGET. An operator upgrading the SDK sees streams that connected fine start dying on their first reconnect, with a CORS error that names a header they never configured.Note this is not the credentialed case. That one fails loudly at first connect, because
Authorizationisn't safelisted either — it's already in the proxy checklist indocs/src/content/docs/reverse-proxy.mdx. The uncredentialed path is worse precisely because it works until it doesn't.Options
_lastEventIdand re-open without it. The stream resumes with a gap rather than never resuming. Needs a decision on N, and on whether to surface the dropped-resumption-point as an error rather than silently losing history — silently is arguably worse than the current loud-but-stuck behavior.?since=. The server acceptssinceas a query parameter and prefersLast-Event-IDonly when both are present (internal/api/stream.go). A query parameter never preflights, so resuming viasincesidesteps the problem entirely — at the cost of putting a timestamp in the URL and losing the header's precedence semantics.Option 2 looks strongest — the server already supports it, it removes the failure mode rather than recovering from it, and a timestamp in a URL carries none of the risk that moving the token out of the URL was about. Worth measuring against whatever multiplexing decides, since #204 may move resumption into a POST body anyway.
Acceptance
reverse-proxy.mdxupdated — it currently describes the failure without a remedy beyond "allow the header"Related: #203 (introduced the fetch transport), #215 (added the CORS allow-list), #204 (multiplexing may relocate resumption), #469 (the other streaming re-dial edge).