6.8 KiB
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:
- Sets SSE headers:
Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive. - Sends heartbit comment every 30s:
:heartbit\n\n(invisible to EventSource, keeps connection alive). - Subscribes to
request.server.eventBus, filters events by user identity, pushes matching events through SSE. - 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:
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:
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:
- Subscribes to
$tokenfrom Effector (@/shared/model/auth). - When token appears → creates
EventSourceviacreateEventStream(token). - When token becomes
null→ closesEventSource. - Registers
onmessagehandlers 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.
- Uses
useQueryClient()to access the query client.
Modified files
client/src/app/providers/AppProviders.tsx:
- Add
<SseProvider>as a child ofQueryClientProvider(or wrap it around, needsqueryClient).
client/src/pages/me/ui/MeLayoutPage.tsx:
- Remove
refetchInterval: 45_000from the unread count query. - Remove
refetchOnWindowFocus: true(revert to global defaultfalse).
client/src/pages/admin-layout/ui/AdminLayoutPage.tsx:
- Remove
refetchInterval: 45_000from the orders summary query. - Remove
refetchOnWindowFocus: true(revert to global defaultfalse).
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.SseProvidercreates EventSource when$tokenis set.SseProvidercloses EventSource when$tokenbecomes null.SseProvidercallsqueryClient.invalidateQuerieswith correct keys on each SSE event type.- No EventSource created when
$tokenis null.