Files
shop-server/docs/superpowers/specs/2026-05-22-sse-realtime-design.md
T
2026-05-22 18:08:24 +05:00

147 lines
6.8 KiB
Markdown

# 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 `<SseProvider>` 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.