# Module: `src/api/sse.ts` > **Source**: `src/api/sse.ts` (25 lines) > **Topo batch**: B3 (depends on B2 leaf: `api/client` for `getToken()` only) ## Purpose A 25-line wrapper around the browser's native `EventSource` that (a) appends the current bearer token as an `access_token` query parameter (since `EventSource` does not let callers set headers), (b) parses each `MessageEvent` payload as JSON, and (c) returns a cleanup function. Used by features that listen for server-pushed updates (annotations queue, flight ingestion progress, etc.). ## Public interface ```ts export function createSSE( url: string, onMessage: (data: T) => void, onError?: (err: Event) => void, ): () => void ``` The returned function closes the underlying `EventSource`. Callers MUST call it on unmount to avoid leaking long-lived connections. ## Internal logic 1. `const token = getToken()` — read the current bearer from `api/client.ts`. 2. Build `fullUrl`: - If `token` is non-null: append `access_token=` using `&` if the URL already has a query string, `?` otherwise. - If `token` is null: use `url` as-is. 3. `new EventSource(fullUrl)`. 4. `source.onmessage = (e) => { try { onMessage(JSON.parse(e.data) as T) } catch { /* ignore */ } }`. - JSON parse errors are silently swallowed. The contract is that the server sends valid JSON; a malformed frame degrades to "skipped". 5. `source.onerror = (e) => onError?.(e)` — forwarded straight through. The browser auto-reconnects by default; `onError` lets callers observe the disconnect. 6. Return `() => source.close()`. ## Dependencies - **Internal**: `./client` — `getToken()`. - **External**: `EventSource` (browser global). ## Consumers (intra-repo) From the §7a dependency graph: - `src/features/annotations/AnnotationsSidebar.tsx` — subscribes to the annotations stream. - `src/features/flights/FlightsPage.tsx` — subscribes to flight ingestion / state updates. ## Data models None defined here. The generic `T` is supplied by the caller. ## Configuration URLs are passed in by callers (string-literal at call sites). The same testability remark as `api/client.ts` applies: a `VITE_API_BASE_URL` is the natural Step 4 fix. ## External integrations Whichever backend exposes the SSE endpoint at the URL the caller provides. Per `nginx.conf`, the suite's `/api/*` reverse proxy forwards SSE traffic by default (no special EventSource-blocking config) — verify in Step 4. ## Security - **Bearer in query string**: `access_token=` ends up in browser-history URLs, server access logs, and proxy logs. This is a **known weakness of the EventSource API** — the API has no headers parameter, so cookie or query are the only options. The trade-off was made knowingly (SSE is a long-lived GET; the bearer is short-lived; nginx access logs are an internal-only artefact). Document in `security_approach.md` (Step 6) and consider rotating to a dedicated SSE-only short-lived token in Step 8. - **`getToken()` on connect, no refresh**: if the bearer rotates mid-session (via `client.ts`'s 401 retry path), the `EventSource` keeps using the old token and will eventually error. Callers must observe `onError` and reconnect. The current consumers (`AnnotationsSidebar`, `FlightsPage`) do NOT do this — they create the source once on mount. Flag for Step 4 / Step 8. - **Silent JSON parse error**: a single malformed frame is skipped without a `console.warn`. Acceptable for production noise reduction but obscures real backend bugs in dev. Defer. ## Tests None. ## Notes / open questions - **No reconnect / backoff logic** — relies on the browser's built-in EventSource auto-reconnect. The default is "keep retrying"; on a flaky connection this may produce a tight loop with backend logs. Confirm acceptable in Step 6 against the `annotations/` and `flights/` service rate-limit posture. - **Cleanup on token-less call**: `getToken()` returning `null` produces an unauthenticated `EventSource`. The backend should reject with `401`, the `EventSource` then errors, and `onError?` fires. The caller is expected to interpret this as "user logged out, stop subscribing". None of the current consumers do that explicitly; instead, the `ProtectedRoute` gate prevents `useEffect` from ever running while logged out, so the path is unreachable in practice. Document but no action needed. - The function is fully synchronous — does not return a `Promise`. The connection is initiated on call but the first message may arrive later. Consumers must handle the "no messages yet" UI state. - The query-string token assembly does NOT URL-encode the token. JWTs are URL-safe Base64 by default and contain only `A–Z, a–z, 0–9, -, _, .`, so this is safe for the current token shape. If the token format ever changes, add `encodeURIComponent`. Note for Step 8.