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

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:

  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:

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:

  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.

  1. 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.