From 76c8564e77a31f10b6a7b1a050cc96636b3771c2 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 22 May 2026 18:08:24 +0500 Subject: [PATCH] docs: SSE realtime design spec --- .../specs/2026-05-22-sse-realtime-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-22-sse-realtime-design.md diff --git a/docs/superpowers/specs/2026-05-22-sse-realtime-design.md b/docs/superpowers/specs/2026-05-22-sse-realtime-design.md new file mode 100644 index 0000000..ca906cf --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-sse-realtime-design.md @@ -0,0 +1,146 @@ +# 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.