Outcome of today's `/spec-drift-review`. Concentrated on the high-and-medium-severity items; low-severity wording left for the next per-feature change to pick up naturally. High-severity URL fixes (specs were actively misleading): - account-management — email-change verification URL was specced as `/auth/verify-email-change?token=...`; the actual route is `/auth/verify?email-change=1&token=...` (see auth.verify.tsx:8). A reader implementing against the old wording would build a link that 404s. - observability — "Both apps SHALL expose a /metrics endpoint" was half right: the planner is at /metrics, but the journal exposes /api/metrics. The infrastructure spec already had the right URLs; the observability spec disagreed with itself. Now both correct, with a one-line note explaining the per-app split. Medium-severity wording drift (Stream E aftermath): - profile-settings, account-management, connected-services — all three said "the settings page SHALL include a [...] section", which described the old single-scrollable settings page. Stream E (PR #323) split /settings into four sub-pages (/settings/{profile,account,security,connections}); rewording each spec to point at its specific sub-page. The API endpoints (/api/settings/*) and behavior are unchanged. - authentication-methods — passkey add/delete previously said "via the settings page"; now specifically /settings/security. Code drift fixed inline: - apps/journal/app/routes/auth.verify.tsx — after a successful email change, the redirect was going to `/settings#account` (an anchor on the OLD single-scrollable settings page). Stream E retired that page; the right destination now is `/settings/account`. Without this the user would land on /settings/profile (which is what /settings redirects to) instead of the page that just changed. sse-broker spec wording: - The "no buffering tweaks in the Caddy reverse_proxy block" scenario asserted the entry was "a plain `reverse_proxy journal:3000`". After PR #329 the journal block has `lb_try_duration 30s` / `lb_try_interval 250ms` — neither affects streaming, so SSE still works, but the spec's "plain" language was no longer literally true. Reworded to forbid only buffering-related directives; explicitly call out that retry-on-restart directives like lb_try_duration are fine. Navbar consolidation (journal-landing): - The shipped navbar's full shape was scattered: notifications said "navbar has a bell," explore said "navbar has an Explore entry," but no spec described the avatar dropdown, the primary-nav cluster (Feed/Routes/Activities), or the mobile drawer (Stream C / PR #324). Added a "Top navbar shape" requirement to journal-landing covering all of it — anonymous vs signed-in, desktop vs mobile, dropdown contents, drawer behavior. The per-feature specs (notifications, explore) still own their own badges/entries; this requirement just says what the whole cluster looks like. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 KiB
sse-broker Specification
Purpose
Server-Sent Events (SSE) infrastructure that pushes one-way notifications from the Journal server to the user's open browser tabs in between page navigations. This is the transport layer; specific event payloads (e.g. notifications.unread) are owned by the capability that emits them. The interface is intentionally narrow so it can be swapped from the in-process registry to a Redis pub/sub adapter when the Journal goes multi-process, without caller changes.
Requirements
Requirement: SSE endpoint exposes a session-bound event stream
The Journal SHALL expose GET /api/events returning a streaming text/event-stream response that delivers per-user events to a signed-in browser tab. The endpoint SHALL be authenticated via the user's session cookie; anonymous requests SHALL receive HTTP 401 with no event stream. The response SHALL include Cache-Control: no-cache and X-Accel-Buffering: no so intermediate proxies do not buffer the stream.
Scenario: Authenticated client receives an event stream
- WHEN a signed-in user opens an
EventSource("/api/events")connection - THEN the server registers the connection in the broker keyed by the user's id, sends an SSE preamble (including a
retry: 5000reconnect hint with 0–2 s server-side jitter), and holds the response open until the client disconnects
Scenario: Anonymous client is rejected
- WHEN an unauthenticated request hits
/api/events - THEN the server responds with HTTP 401 and does not start a stream
Scenario: Heartbeat keeps idle connections alive
- WHEN a connection has been open for ≥25 s with no events
- THEN the server writes an SSE comment frame (
: ping\n\n) so intermediate proxies do not idle-timeout the connection - AND if the heartbeat write fails (broken pipe), the connection is removed from the broker registry
Scenario: Cleanup on client teardown
- WHEN the client closes the EventSource (tab closed, navigation, network drop)
- THEN the server removes the connection from the broker on the request's abort signal so subsequent emits to that user don't write to a dead socket
Requirement: Per-user broker registry with simple emit interface
The Journal SHALL provide a server-side events.server.ts module exposing two functions: register(userId, conn) returning an unregister callback, and emitTo(userId, event, data) that delivers a serialized SSE event to every connection registered for that user. The interface SHALL be the only call site for multi-process swap (in-memory Map<userId, Set<Connection>> today; Redis pub/sub later). Generation hooks (e.g. notifications.server.ts's emitUnreadCount) SHALL call emitTo after their underlying state change commits, never before.
Scenario: Emit delivers to all of a user's open connections
- WHEN
emitTo("user-1", "notifications.unread", { count: 5 })is called and "user-1" has two open SSE connections - THEN both connections receive the same SSE frame (
event: notifications.unread\ndata: {"count":5}\n\n)
Scenario: Emit to a user with no open connections is a no-op
- WHEN
emitTo("user-2", "notifications.unread", { count: 1 })is called and "user-2" has zero open connections - THEN the call returns silently without error and without queueing — events are best-effort, the loader-driven count is the source of truth on next render
Scenario: Broken connections are pruned defensively
- WHEN
emitTowrites to a connection whosesendthrows (broken pipe) - THEN the broker calls
close()on that connection so it cannot leak open file descriptors
Scenario: Emit happens after the underlying state change commits
- WHEN a generation hook (e.g.
createNotification,markRead,markAllRead) updates the database - THEN
emitTois called only after the transaction commits, so a client receiving the event observes a consistent count when its next request reaches the database
Requirement: Client hook seeds from loader, then live-updates via SSE
The Journal SHALL provide a React client hook useUnreadNotifications(initialCount, signedIn) that opens an EventSource("/api/events") when the user is signed in, listens for notifications.unread events, and returns the live count. The loader-supplied initialCount SHALL be the SSR baseline so the badge renders correctly before the first SSE event arrives. The browser's native EventSource reconnect behavior SHALL be relied on for transient disconnects; no manual reconnect loop is required.
Scenario: Loader baseline rendered on first paint
- WHEN a signed-in user navigates to any page
- THEN the navbar bell badge initially displays the count returned by the root loader, not zero, even before SSE has handshaked
Scenario: Live event overrides the loader baseline
- WHEN the server emits
notifications.unread { count: 7 }to the user's open tab via SSE - THEN the navbar bell badge updates to
7without a navigation
Scenario: Anonymous tab does not open an SSE connection
- WHEN a logged-out visitor renders a page
- THEN the hook does not call
EventSource("/api/events")(since the endpoint would 401 anyway)
Requirement: Caddy reverse-proxy passes SSE through unmodified
The reverse proxy in front of the Journal (Caddy v2) SHALL pass text/event-stream responses through without buffering. No special directive is required because Caddy v2 auto-detects the content type and disables response buffering automatically; this requirement exists so future infrastructure changes do not silently re-introduce buffering.
Scenario: Caddy reverse_proxy block carries no buffering tweaks
- WHEN the Journal upstream is added to
infrastructure/Caddyfile - THEN the
reverse_proxy journal:3000entry contains noflush_interval,buffer_requests, or buffering-related directive - AND unrelated directives that don't affect streaming (e.g.
lb_try_duration/lb_try_intervalfor retry-on-restart, added to silence deploy-time 502 alerts) are permitted because they only fire when the upstream is unreachable, not on a successful streaming response
Requirement: In-process today, Redis-pub/sub when multi-process
The broker SHALL be implemented as an in-process Map for the single-process deployment shipped today. The same register / emitTo interface SHALL be the swap point for a future Redis pub/sub adapter when the Journal scales to multiple processes; callers SHALL NOT need to change.
Scenario: Single-process deployment
- WHEN the Journal runs as one process behind Caddy
- THEN the broker is the in-process
Map<userId, Set<Connection>>and emits resolve synchronously within the same Node process
Scenario: Future multi-process swap (forward-compat)
- WHEN the Journal is split into multiple processes (e.g., behind a load balancer with sticky sessions or random routing)
- THEN
events.server.tsis rewritten as a Redis pub/sub adapter —registersubscribes to a per-user channel,emitTopublishes — without any change at the call sites