# 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<T>(
  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=<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. Since AZ-486 / F7 (commit `8a461a2`), callers obtain those URLs from `endpoints.*` builders in `src/api/endpoints.ts` rather than from inline string literals. The `STC-ARCH-02` static gate enforces this at every callsite under `src/`. `createSSE` itself is path-agnostic and takes any `url` — the `endpoints` discipline is upheld at the call site, not here.

## 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=<jwt>` 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.
