docs: SSE realtime design spec
This commit is contained in:
@@ -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 `<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.
|
||||
Reference in New Issue
Block a user