Skip to content

Latest commit

 

History

History
23 lines (16 loc) · 5.85 KB

realtime.md

File metadata and controls

23 lines (16 loc) · 5.85 KB

Real-time collaboration internals

How changes reach other users: the WebSocket endpoints and close codes, the connection manager and per-user pushes, the two broadcast tiers, and on the client the reconnecting hook and the entity → data-slice map that turns change:<entity> events into debounced refetches. It was moved verbatim out of the repo-root CLAUDE.md.

Code described: app/websocket/ (handler.py, manager.py, broadcast.py), app/middleware/broadcast.py, frontend/src/hooks/useWebSocket.ts, frontend/src/contexts/WebSocketContext.tsx, frontend/src/contexts/wsSlices.ts.

Real-Time Collaboration

Backend (app/websocket/)

  • Endpoints: /ws (single-tenant) and /t/{slug}/ws (multi-tenant), in handler.py. Origin check first (is_origin_allowed: the Origin must match Host/X-Forwarded-Host or a configured CORS origin; absent Origin = non-browser client, allowed) → else close 4003. Auth = the connect.sid cookie validated against the tenant DB. Close codes: 4001 no/invalid/expired session or revoked (disconnect_user), 4002 auth error, 4003 origin not allowed / user disabled, 4004 tenant not found, 4005 tenant not active, 4006 tenant DB not configured.
  • ConnectionManager (manager.py, global manager): per-tenant rooms, multiple connections per user (multi-tab), presence join/leave/list with deduped online users, broadcast_to_tenant(exclude_user/exclude_connection), send_to_user(tenant, user_id, message) for per-user delivery (notifications), disconnect_user(tenant, user_id, except_session_id=…) for session revocation (per process, best effort), asyncio-lock guarded. Fan-outs count only successful sends and prune a connection whose send failed (which emits presence:leave).
  • Two broadcast tiers: routers with meaningful UX events call broadcast_change(request, user, entity_type, entity_id, project_id, action, summary) (broadcast.py) → clients receive an attributed change:<entity> with user name and summary. All other successful writes get a coarse change:<entity> from BroadcastMiddleware (sender excluded; SKIP_PATTERNS prevents doubles).
  • Receive loop handles ping → pong (client pings every 25 s).
  • notification:new is sent per-user via send_notification() (websocket/broadcast.py). It is a live hint only — the manager is per-process, so a user on another worker misses it; the DB row is the source of truth and the bell reloads on mount/focus. On the client it needs its own case in useWebSocket (the default: branch only handles change:*).

Frontend

  • useWebSocket (src/hooks/useWebSocket.ts): connects to ws(s)://<host><tenantPrefix>/ws, exponential backoff (2 s base, 60 s max, 5 attempts), does not reconnect on close codes 1000/1001/4000–4006; after giving up (or a non-terminal drop) it retries immediately on the browser online event and when the tab becomes visible (visibilitychange) — never after 4000–4006. 4001/4003 call onSessionInvalid → WebSocketContext re-checks /auth/me and calls expireSession() if the session is gone; if /auth/me says the session is still valid (a handshake cookie race), the hook retries once with a fresh backoff budget, and a repeated 4001/4003 stays terminal. Exposes offline (browser offline, or settled and not connected/connecting) → the Header's SyncStatus "Offline" badge. Tracks onlineUsers (presence messages) and recentChanges (any change:*, expiring 30 s after local receipt to dodge clock skew).
  • WebSocketContext (src/contexts/WebSocketContext.tsx) turns changes into data refreshes: slicesForEntity() maps entity type → data slices (project/phase/subphase/assignment/staff_assignment/equipment_assignment/predefined_phase→projects, staff→staff+projects, equipment→equipment+projects, equipment_block→equipmentBlocks, vacation→vacations, skill→skills+staff, bank_holiday/company_event→siteCalendar (current site's holidays + events), absence_type→absenceTypes+vacations, site→sites+siteCalendar+projects+tags+equipment (site writes are rare; a site delete cascades its projects, site-only tags and site-only equipment), tag→tags+projects, tag_group→tags (the tags slice reloads tags and groups), custom_column/custom_column_value→customColumns (customColumnStore only, no project reload), settings→settings, user→staff+currentUser+projects (role/site changes and deactivation of the signed-in user apply live), note/card_comment→ nothing, unknown → everything; the mapping lives in src/contexts/wsSlices.ts; Kanban writes reuse phase/subphase/staff_assignment/card_comment, there is no Kanban-specific entity), then refreshes via a 200 ms debounced, coalesced runner with an in-flight guard that re-stages if more changes arrive mid-fetch. A phase drag producing N child updates results in one refetch per slice. Unknown entity types refresh everything, so new backend entities need no client change.
  • While What-If is active, refreshes of the sandboxed slices (projects, customColumns — WHAT_IF_SANDBOXED_SLICES in wsSlices.ts) are deferred and run once What-If ends, so a teammate's change never replaces the plan being edited.
  • notification:new → WebSocketContext.handleNotificationReceived → notificationStore.receive() (dedupes by id). It is only a live hint; the bell reloads from the server on mount and focus.
  • OnlineUsers and ActivityFeed render presence and the change feed (recentChanges from useWebSocket). A per-entity "changed by X" badge (ChangeIndicator + useEntityChangeIndicator) was built but never wired up and has been removed as dead code, together with its per-entity query helpers; the context exposes recentChanges only.