# SSE Realtime Design ## Goal Replace HTTP polling (`refetchInterval: 45_000`) with Server-Sent Events (SSE) for real-time updates: chat messages, unread counters, order status changes, admin notifications. ## Decisions | Decision | Choice | Rationale | |---|---|---| | Technology | SSE over WebSocket | Native browser API, auto-reconnect, simpler server code. Messages still sent via HTTP POST. | | SSE approach | Direct EventBus bridge (no recovery buffer) | Sufficient for shop scale. EventSource has built-in auto-reconnect. | | Connection lifecycle | Connect on login, close on logout | SSE created when JWT appears in Effector `$token`, closed on `null`. | | Auth method | JWT in query param (`?token=`) | EventSource doesn't support custom headers. Server's `authenticate` decorator already handles `request.query?.token`. | ## Server-side ### New file: `server/src/routes/sse.js` Single SSE endpoint: ``` GET /api/sse/stream?token=JWT PreHandler: fastify.authenticate ``` **Behavior:** 1. Sets SSE headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`. 2. Sends heartbit comment every 30s: `:heartbit\n\n` (invisible to EventSource, keeps connection alive). 3. Subscribes to `request.server.eventBus`, filters events by user identity, pushes matching events through SSE. 4. On `response.raw.on('close')` — removes EventBus listeners. **Event mapping (EventBus → SSE):** | EventBus event | Who receives | SSE `event` type | Payload | |---|---|---|---| | `orderMessage:adminReply` | User (order.userId) | `message:new` | `{ orderId, messageId, preview }` | | `order:statusChanged` | User (order.userId) | `order:statusChanged` | `{ orderId, newStatus }` | | `payment:statusChanged` | User (order.userId) | `order:statusChanged` | `{ orderId, paymentStatus }` | | `order:deliveryFeeAdjusted` | User (order.userId) | `order:updated` | `{ orderId }` | | `orderMessage:sent` | Admin (all admins) | `message:new` | `{ orderId, messageId, preview }` | | `order:created` | Admin | `order:new` | `{ orderId }` | **Admin filtering:** If `request.user` is admin (checked via email match), subscribe to all admin events without userId filtering. Currently only one admin exists, so this is straightforward. ### Modified file: `server/src/index.js` Add import and registration: ```js import { registerSseRoutes } from './routes/sse.js' // ... await registerSseRoutes(fastify) ``` No other server changes needed. Existing `authenticate` decorator (line 75-84) already supports `request.query?.token`. ## Client-side ### New file: `client/src/shared/lib/sse.ts` Factory function: ```ts export function createEventStream(token: string): EventSource { return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`) } ``` ### New file: `client/src/app/providers/SseProvider.tsx` React component that bridges SSE events to React Query cache invalidation: 1. Subscribes to `$token` from Effector (`@/shared/model/auth`). 2. When token appears → creates `EventSource` via `createEventStream(token)`. 3. When token becomes `null` → closes `EventSource`. 4. Registers `onmessage` handlers for each SSE event type: | SSE event | Handler | |---|---| | `message:new` | User side: `invalidateQueries(['me', 'messages', 'unread-count'])`, `invalidateQueries(['me', 'conversations'])`, `invalidateQueries(['me', 'orders', orderId])`. Admin side: `invalidateQueries(['admin', 'orders', orderId])`. | | `order:statusChanged` | `invalidateQueries(['me', 'orders', orderId])`. Admin (if viewing): `invalidateQueries(['admin', 'orders', orderId])`. | | `order:new` | Admin: `invalidateQueries(['admin', 'orders', 'summary'])`, `invalidateQueries(['admin', 'orders'])`. | | `order:updated` | `invalidateQueries(['me', 'orders', orderId])`. Admin (if viewing): `invalidateQueries(['admin', 'orders', orderId])`. | React Query's `invalidateQueries` only refetches active (currently mounted) queries. Inactive queries are just marked stale. This means SseProvider can call `invalidateQueries(['me', 'orders', orderId])` unconditionally — it will only refetch if the user has that order's chat page open. 5. Uses `useQueryClient()` to access the query client. ### Modified files **`client/src/app/providers/AppProviders.tsx`:** - Add `` as a child of `QueryClientProvider` (or wrap it around, needs `queryClient`). **`client/src/pages/me/ui/MeLayoutPage.tsx`:** - Remove `refetchInterval: 45_000` from the unread count query. - Remove `refetchOnWindowFocus: true` (revert to global default `false`). **`client/src/pages/admin-layout/ui/AdminLayoutPage.tsx`:** - Remove `refetchInterval: 45_000` from the orders summary query. - Remove `refetchOnWindowFocus: true` (revert to global default `false`). ## Data Flow (example: admin replies to user) ``` Admin → POST /api/admin/orders/:id/messages → Prisma: creates OrderMessage → EventBus: emit('orderMessage:adminReply', { orderId, userId, messageId, preview }) → dispatchNotification: email/telegram to user (existing behavior, unchanged) → SSE handler: filters by userId, formats as SSE event Client (SseProvider): → SSE event 'message:new' received → invalidateQueries(['me', 'messages', 'unread-count']) → badge updates → invalidateQueries(['me', 'conversations']) → dialog list updates → invalidateQueries(['me', 'orders', orderId]) → chat updates (only if that order's query is active) ``` ## Files Summary | File | Status | Description | |---|---|---| | `server/src/routes/sse.js` | New | SSE endpoint, EventBus→SSE bridge | | `server/src/index.js` | Modify | Import and register SSE routes | | `client/src/shared/lib/sse.ts` | New | EventSource factory | | `client/src/app/providers/SseProvider.tsx` | New | SSE→ReactQuery bridge component | | `client/src/app/providers/AppProviders.tsx` | Modify | Mount SseProvider | | `client/src/pages/me/ui/MeLayoutPage.tsx` | Modify | Remove refetchInterval | | `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` | Modify | Remove refetchInterval | ## Testing **Server (vitest):** - SSE endpoint returns correct headers (`text/event-stream`, `no-cache`, `keep-alive`). - SSE endpoint sends heartbit comment on connect. - When EventBus emits an event relevant to the connected user, SSE stream contains the formatted event. - When EventBus emits an event for a different user, SSE stream does NOT receive it. - EventSource cleanup: listeners removed on response close. - Admin receives all admin events regardless of userId. **Client (vitest + jsdom):** - `createEventStream(token)` returns EventSource with correct URL including token. - `SseProvider` creates EventSource when `$token` is set. - `SseProvider` closes EventSource when `$token` becomes null. - `SseProvider` calls `queryClient.invalidateQueries` with correct keys on each SSE event type. - No EventSource created when `$token` is null.