Phase 14 — Realtime via Django Channels. Apex now ships an ASGI WebSocket layer that pushes notifications and presence updates to every connected tab without HTTP polling. Dev needs zero infra (the in-memory channel layer); production upgrades to Redis with a single env var.
Phase 14 — Realtime via Django Channels. Apex now ships an ASGI
WebSocket layer that pushes notifications and presence updates to
every connected tab without HTTP polling. Dev needs zero infra (the
in-memory channel layer); production upgrades to Redis with a single
env var.
Added — Infra
- Channels 4 + Daphne added as dependencies. Daphne replaces gunicorn's runserver in dev so WebSocket upgrades work natively.
apex/asgi.pyroutes HTTP through Django and WebSocket through the realtime app's URL router, wrapped inAuthMiddlewareStackso consumers seescope["user"]populated from the session cookie, andAllowedHostsOriginValidatorso cross-origin sockets are rejected at the perimeter.CHANNEL_LAYERSdefaults toInMemoryChannelLayer(dev/test).prod.pyupgrades tochannels_redis.core.RedisChannelLayerwhenREDIS_URLis set; install withuv sync --extra realtimeto pick up the optionalchannels-redisdep.
Added — Consumers
NotificationConsumer(/ws/notifications/) — per-user fan-out channel (notify.user.<id>). Receivesnotify.message(new row) andnotify.count(unread badge bump) events from the layer. Anonymous connects close with code 4401 so the client stops retrying.PresenceConsumer(/ws/presence/) — global presence channel. Tracks per-process connection set; broadcasts a count + the joined/left username on every transition.- Both built on
AsyncJsonWebsocketConsumerso consumers canawaitORM lookups viadatabase_sync_to_async.
Added — Server-side dispatch hooks
apps/realtime/dispatch.push_notification(user_id, payload)andpush_unread_count(user_id, count)— sync wrappers aroundchannel_layer.group_sendfor use from any view, signal, or management command. No-op if no layer is configured.apps.notifications.dispatch.notify()now fans out to the layer after everyNotification.objects.create()— every existing notification source (orders, invoices, mail, chat, mentions, realtime test) becomes live with no per-call wiring.
Added — Client
static/js/realtime.jsexposesapexNotifyStream()andapexPresence()Alpine factories. WebSocket auto-reconnects with exponential backoff (cap 30s); 4401 close is honored as "anonymous, stop trying".apexNotifyStreamre-renders the bell badge inline AND triggers an HTMX refresh so the dropdown body stays in sync.- Header presence pill — green pulse + count of online users, shown only when count > 0 so first paint stays clean.
- Bell badge rendered with
data-bell-badgeso the realtime client can update the count without a DOM rebuild.
Added — Demo surface
/realtime/— opens both sockets, shows the live count, and ships a "Fire test notification" button that POSTs to/realtime/fire/. Open in two tabs — both update instantly.- Sidebar entry under Apps; palette-searchable via the
realtime,websocket,presence,live,channelskeywords.
Tests
- 16 new tests: 8 async consumer tests using
WebsocketCommunicator(anonymous reject, accept, group fan-out, per-user isolation, presence count + join/leave broadcast), 4 sync dispatch tests (helper drains, end-to-end notify→layer fan-out, no-layer no-op), 4 view tests (demo render, anon redirect, fire-test creates Notification, GET rejected). Total now 1000. - New dev dep:
pytest-asyncio(strict mode; opt-in per test via@pytest.mark.asyncio, no impact on existing sync tests).
Notes
- Single-process scaling stays trivial. Multi-process needs Redis or the layer's broadcasts are per-worker. The presence count is per-process by design — fine for the demo, swap to a Redis ZSET if you need cluster-wide accuracy.
- Chat broadcast and Kanban broadcast are intentionally NOT wired yet — the foundation is in place, follow-up commits can subscribe those views to the layer without further infra changes.
- WebRTC, voice/video, and message-history search are explicitly out of scope.