fix(grid-wallet-prod): replay missed webhooks to reconnecting SSE clients#742
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
…ents Every connected panel already received every delivery — the demo drives one hardcoded customer, so there is no per-client filtering and pushEvent walks all subscribers. Verified with three concurrent clients: all three got the event, and one disconnecting left the others streaming. What was missing is replay. A client only saw events that arrived while it was connected, so an EventSource auto-reconnect after a network blip silently dropped anything that landed in the gap. Each frame now carries an `id:` (a per-process sequence). EventSource echoes it back as `Last-Event-ID` on reconnect, and the stream replays events after that point. A FRESH client replays nothing: it has no id, and rendering old deliveries would date them to the moment of receipt. Catch-up must neither gap nor duplicate, which rules out both naive orderings — replay-then-subscribe loses whatever lands in between, and subscribe-then-replay sends that same event twice (live, then again from the backlog). So the stream subscribes immediately but HOLDS live events, replays the backlog, then flushes the held ones the replay didn't already cover. Verified by reconnecting while deliveries stream in: no gaps, no duplicates. The connect handshake comment now reports `clients=N` — invisible to EventSource, visible in `curl -N`, useful when checking that every panel is attached. Tests cover the bus contract: fan-out to every subscriber, survival of a broken one, the seq/eventsSince replay windows, and the ring dropping the overflow a long-gone client can no longer catch up on. Limits unchanged and documented in the module: the bus is per server process (with replicas, only the instance that received a delivery fans it out), and the ring buffer holds the last 50 events. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Pjqydq34z8DBK1kBrioXK2
d930b99 to
d15af0e
Compare
… stream Self-review follow-up. /api/webhooks/stream is unauthenticated and carries whatever Grid delivered — transaction ids, amounts, and counterpartyInformation, which per Grid's schema can include a counterparty's name, birth date and nationality. That is the same exposure the polling route was deleted for earlier in this stack; the SSE replacement inherited it. Reject an EventSource opened by another site (Sec-Fetch-Site: cross-site), so a page on someone else's origin can't attach to a panel running on your machine. Deliberately narrow: only an explicit cross-site is refused, so `curl -N` and the verification scripts keep working. This is NOT access control, and the comment says so: anyone who can reach the host can still read the stream, which behind a tunnel means anyone with the URL. Gating it properly (session cookie, or not exposing it past localhost) stays a pre-prod requirement alongside proxy customer-scoping. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Pjqydq34z8DBK1kBrioXK2
|
@greptileai review Context for the review: this is a small SSE change with two self-review fixes already applied — reconnect catch-up (hold-and-flush so it neither gaps nor duplicates) and a cross-site subscription refusal. The parts I'd most like a second pair of eyes on:
|
Greptile SummaryThis PR adds SSE reconnect replay to the grid-wallet-prod demo: each event frame now carries an
Confidence Score: 5/5Safe to merge — the reconnect logic, ring-buffer replay, and resource cleanup are all correct, and the cross-site guard is intentionally narrow and well-documented. The hold-and-flush ordering is sound: subscribing before replay and gating live events through Files Needing Attention: No files require special attention. The
|
| Filename | Overview |
|---|---|
| components/grid-wallet-prod/src/app/api/webhooks/stream/route.ts | Adds reconnect-replay with hold-and-flush ordering, cross-site guard, and id: frame emission; cleanup on abort is idempotent across both the abort handler and cancel() paths. |
| components/grid-wallet-prod/src/lib/webhookEvents.ts | Adds seq counter, eventsSince(), and listenerCount() to the globalThis-pinned bus; ring-buffer trimming and fan-out error isolation unchanged. |
| components/grid-wallet-prod/src/lib/webhookEvents.test.ts | New test suite covering fan-out, broken-subscriber survival, listener count, seq ordering, eventsSince windows, and ring-buffer overflow; clearEvents() not resetting seq is correct and the tests account for it. |
Sequence Diagram
sequenceDiagram
participant C as Client (EventSource)
participant R as route.ts GET
participant B as webhookEvents bus
Note over C,B: Initial connection (no Last-Event-ID)
C->>R: GET /api/webhooks/stream
R->>R: crossSite check pass
R->>B: "subscribe holding=true"
R->>C: ": connected clients=1"
R->>R: "isFinite(NaN)=false, skip replay"
R->>R: "holding=false, flush held (empty)"
R->>C: keepalive every 15s
Note over C,B: Event arrives while connected
B->>R: pushEvent fires listener
R->>C: "id: 1 data: {...}"
Note over C,B: Network blip, client disconnects
C--xR: connection lost
Note over C,B: Auto-reconnect with Last-Event-ID: 1
C->>R: GET (Last-Event-ID: 1)
R->>R: crossSite check pass
R->>B: "subscribe holding=true"
Note over R: concurrent events go to held[]
R->>B: eventsSince(1) returns [2,3]
R->>C: "id: 2 data: {...}"
R->>C: "id: 3 data: {...}"
R->>R: "replayedThrough=3, holding=false"
R->>R: "flush held where seq > 3"
Note over C,B: Client closes tab
C->>R: abort signal fires
R->>B: unsubscribe()
R->>R: clearInterval(keepalive)
R->>R: controller.close()
Reviews (2): Last reviewed commit: "fix(grid-wallet-prod): refuse cross-site..." | Re-trigger Greptile

Summary
Fan-out to multiple clients already worked; replay on reconnect didn't. This adds it.
The demo drives one hardcoded customer, so every connected panel is watching the same account and should see every delivery. There is no per-client filtering —
pushEventwalks all subscribers — and that part is verified below. The gap was that a client only ever saw events that arrived while it was connected, so anEventSourceauto-reconnect after a network blip silently dropped whatever landed in the gap.What changed
id:(a per-process sequence number).EventSourceechoes the last one back asLast-Event-IDwhen it reconnects, and the stream replays events after that point.clients=N.EventSourceignores comment frames; it shows up incurl -N .../api/webhooks/streamwhen you want to confirm every panel is attached.Verified
Three concurrent clients, one delivery each time, with a disconnect and a reconnect in the middle:
Signature verification is untouched; the replay path only re-sends events that already passed it.
Limits (documented in the module)
Stacked on #730.
Update: catch-up must not duplicate either
Self-review caught a flaw in the first push. Subscribe-then-replay avoids gaps but double-sends: an event landing between
subscribeand the replay loop goes out live and again from the backlog. Replay-then-subscribe has the opposite bug (it loses that event). The stream now subscribes immediately but holds live events, replays the backlog, then flushes the held ones the replay didn't cover.Verified by reconnecting from
Last-Event-ID: 1while deliveries stream in:Also added
webhookEvents.test.tscovering the bus contract: fan-out to every subscriber, survival of a subscriber that throws, theseq/eventsSincereplay windows, and the ring dropping the overflow a long-gone client can no longer catch up on. 69 tests total (was 60).Update: the stream was readable by any site
Second self-review finding, and a more serious one.
/api/webhooks/streamis unauthenticated and carries whatever Grid delivered — transaction ids, amounts, andcounterpartyInformation, which per Grid's schema can include a counterparty's name, birth date and nationality. That is the same exposureGET /api/webhooks/eventswas deleted for earlier in this stack; the SSE replacement inherited it.An
EventSourceopened by another site is now refused (Sec-Fetch-Site: cross-site→ 403), so a page on someone else's origin can't attach to a panel running on your machine:This is not access control, and the code comment says so: anyone who can reach the host can still read the stream, which behind an ngrok tunnel means anyone with the URL. Gating it properly — a session cookie, or not exposing it past localhost — belongs on the pre-prod list next to proxy customer-scoping.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Pjqydq34z8DBK1kBrioXK2