fix(webapp): tabs of one project share a single /events stream (BEA-228) - #219
Merged
Merged
Conversation
Every tab dialed /events for itself. Each of those is a permanently in-flight request, so N tabs burned N of the hub's finite per-instance concurrency — and on HTTP/1.1 (local and self-hosted plain-http hubs) N of the browser's ~6 per-origin sockets, which is how six tabs wedged the whole app rather than merely stopping live updates. Every tab of a project receives the identical fan-out, so N-1 of those streams bought nothing. One tab now holds the EventSource and relays each frame verbatim over a BroadcastChannel; the rest run the handler they already ran. The relay ships the raw `data` string, so a frame is handled identically whether it came off the wire or from a peer tab. Leadership is a Web Lock held for the leader's lifetime. The browser hands it to the next waiter when that tab dies — including a crash or force-quit, which is exactly the case a heartbeat-and-TTL scheme gets wrong. No stale leader to detect, no timeout to tune. Where Web Locks or BroadcastChannel are missing the hook opens a stream per tab, as before. Web Locks needs a secure context, so a hub served over plain http to a LAN address gets the old behavior — the deployment that most wants this is the one that cannot have it yet. /collab is deliberately left alone: two tabs editing one document are two distinct CRDT peers with their own awareness state. Verified by negative control — with sharing forced off the new spec sees 2 streams where it asserts 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er tab Records the leader/follower split in the frontend diagram: the Web Lock that elects the tab holding the EventSource, the BroadcastChannel that relays frames to the rest, the secure-context fallback, and why /collab stays per-tab. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
/eventsstream and burned one of the browser's ~6 per-origin sockets.Closes BEA-228. Split out of BEA-227, which tracks the rest (device-side streams and hub scale-out).
The problem
useProjectEventsdidnew EventSource(apiBase + "events")per mount, and there was noSharedWorkerorBroadcastChannelanywhere in the frontend — so every tab dialed independently. Each SSE request is permanently in-flight, which costs at both ends:/collab) plus two idle ones exhausts the pool, and subsequent API calls queue behind streams that never finish.Since all tabs of a project receive byte-identical frames, tab two onward was pure waste.
The change
One tab holds the
EventSourceand rebroadcasts each frame verbatim over aBroadcastChannelkeyed per project. The frame handler was extracted so it runs identically whether a frame arrived off the wire or from a peer tab — the relay ships the rawdatastring rather than anything it has already interpreted.Leader election is a Web Lock held for the leader's lifetime. The browser reassigns it when that tab's document is destroyed, including a crash or force-quit, so there is no stale leader to detect and no timeout to tune. Frames lost in the handover gap are the 15s poll's job, as they were before any of this existed.
Where
BroadcastChannelornavigator.locksis missing, every tab opens its own stream and behaves exactly as before./collabis deliberately left alone. Two tabs editing one document are two distinct CRDT peers with their own awareness state; sharing that stream would be wrong, not thrifty.Verification
Two specs in
live.spec.ts, both using two pages in one context (two contexts are two browsers and are supposed to hold a stream each — running it that way would pass for the wrong reason):/eventsrequest for 2 tabs, and the follower still updates live within 10sNegative control run: with sharing forced off, the first spec sees 2 streams where it asserts 1 — so the test measures the fix rather than passing either way.
tsc,go build ./...,go vet ./...clean; fulllive.spec.ts6/6.Architecture changes
architecture/webapp-frontend.md: thehooksclass member foruseProjectEventschanges from a per-tabEventSourceto one stream per browser, and the hook gains two browser-platform collaborators that were not previously drawn — aBroadcastChannel(per-project relay) and a Web Lock (leader election). A note records the handover semantics, the secure-context fallback, and why/collabstays per-tab.flowchart TB Browser["Browser.tsx"] hooks["<div style='text-align:left'><b>hooks</b><br/>+useConfig<br/>+useHub<br/>+useBrowse<br/><span style='background:#ef444455;padding:0 4px;border-radius:3px'>❌ <s>+useProjectEvents (SSE → invalidate)</s></span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +useProjectEvents (one SSE per BROWSER → invalidate)</span><br/>+usePresence (10s heartbeat)<br/>+useTextAt (any URL) → useBlobText (sha-keyed, immutable)<br/>+fetchBlobText(url) BlobText<br/>+fileURLFor(apiBase, path, version) string</div>"] WebLock["<div style='text-align:left'><b>Web Lock</b><br/>navigator.locks<br/>bdrive:events:<apiBase>:leader</div>"] Channel["<div style='text-align:left'><b>BroadcastChannel</b><br/>bdrive:events:<apiBase></div>"] Note["ONE stream per browser, not per tab.<br/>Leader holds the EventSource;<br/>followers run the same handler<br/>on the same raw data string.<br/>Lock is released by the browser when<br/>the tab dies — crash and force-quit included.<br/>No secure context → a stream per tab, as before.<br/>/collab deliberately NOT shared."] Browser --> hooks hooks -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ elects the one tab that dials</span>" --> WebLock hooks -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ relays each frame verbatim</span>" --> Channel hooks -.- Note classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class WebLock added class Channel added class Note noteBox linkStyle 1 stroke:#22c55e,stroke-width:2px linkStyle 2 stroke:#22c55e,stroke-width:2px🤖 Generated with Claude Code