esu — Realtime
@playpals/esu · port 4010 · apps/esu
Esu governs language, mediation, and dynamic exchange — the metaphor for a socket server relaying real-time communication between clients.
A Socket.IO server that holds connections and fans out events. It persists nothing and authorizes nothing on its own. Two replicas in production, coordinated through the Redis adapter.
Running it
pnpm --filter @playpals/esu dev
curl http://localhost:4010/health # {"ok":true}JWT_SECRET must match the API's, or every connection is rejected.
What it does
| Responsibility | Detail |
|---|---|
| Authenticate sockets | Verifies the JWT from socket.handshake.auth.token |
| Room membership | user:, dm:, room:, channel: rooms |
| Presence | In-memory map of user → socket ids |
| Typing indicators | Ephemeral, never persisted |
| Interservice fan-out | POST /admin/emit/*, called by api and ogun |
What it deliberately does not do
- Write to the database. Messages are POSTed to the API, which persists and then asks
esuto emit. This is whyesucan restart mid-conversation with no data loss. - Authorize joins. It calls back into the API on every
dm:joinandroom:join.
That separation is the main design decision in this service. Preserve it.
Source layout
src/
├── index.ts dotenv + start()
├── http/server.ts HTTP server, io setup, /health, /admin mount
├── http/app.ts Express app
├── config/index.ts typed env config
├── auth/
│ ├── jwt.ts token verification
│ ├── socketAuth.ts handshake authentication
│ └── apiAuthz.ts callbacks to the API for join authorization
├── socket/
│ ├── io.ts Socket.IO server, Redis adapter, Admin UI
│ └── handlers.ts every client event handler
├── interservice/
│ ├── adminRoutes.ts POST /admin/emit/*
│ └── adminAuth.ts X-Realtime-Admin-Token guard
├── presence/status.ts online = any active socket, via fetchSockets()
├── rooms/roomNames.ts room key builders
└── lib/ logger, socket event loggingClient events handled
| Event | Behaviour |
|---|---|
ping | Acks with { ts } |
dm:join | Authorizes via the API, then joins dm:<id>; acks AUTHZ_DENIED on refusal |
dm:leave | Leaves the room |
dm:typing:start / dm:typing:stop | Broadcasts dm:typing to others in the room |
room:join | Authorizes via the API, then joins room:<id> |
room:leave | Leaves the room |
room:typing:start / room:typing:stop | Broadcasts room:typing |
presence:state | { active } — app foregrounded or not; republishes the user's status |
presence:subscribe | Authorizes via the API, joins presence:<id> per allowed user, acks { ok, statuses } |
presence:unsubscribe | Leaves presence:<id> rooms |
disconnect | Republishes the user's status (offline once no active socket remains) |
Full contract and the known drift between it and packages/types: Socket Events.
Interservice admin API
Mounted at /admin, guarded by requireAdminToken (src/interservice/adminAuth.ts):
- The API and ogun call esu on its cluster Service (
http://playpalz-esu:4010). Anything that came through the public ingress carriesX-Forwarded-For, so such a request gets a404before the token is even read. X-Realtime-Admin-Tokenmust matchREALTIME_ADMIN_TOKEN, compared in constant time.
Tests: pnpm test in apps/esu (Node's test runner through tsx).
| Endpoint | Emits | To room |
|---|---|---|
POST /admin/emit/dm/message/new | dm:message:new | dm:<conversationId> |
POST /admin/emit/dm/read | dm:read | dm:<conversationId> |
POST /admin/emit/room/message/new | room:message:new | room:<roomId> |
POST /admin/emit/channel/room/created | channel:room:created | channel:<channelId> |
POST /admin/emit/notification/new | notification:new | user:<userId> |
POST /admin/emit/user/disconnect | (disconnects every socket) | user:<userId> |
POST /admin/emit/media/processing/update | media:processing:update | user:<userId> |
One query endpoint sits alongside them: POST /admin/dm/active-viewers returns which of userIds are in dm:<conversationId>. The API uses it to skip DM notifications for people already viewing the thread.
Scaling
The Redis adapter (@socket.io/redis-adapter) makes an emit on any pod reach sockets on every pod:
const pub = new Redis(port, host, { maxRetriesPerRequest: null, enableReadyCheck: true, password });
const sub = pub.duplicate();
await Promise.all([onceReady(pub), onceReady(sub)]);
io.adapter(createAdapter(pub, sub));Startup blocks until both clients are ready, so the server never accepts a connection it cannot fan out from. SIGTERM and SIGINT quit both clients cleanly.
Both transports stay enabled:
transports: ["websocket", "polling"]Polling is required by the Socket.IO Admin UI's initial handshake, and polling requests for one client must land on one pod — hence the ESUSESSION sticky cookie on the socket ingress.
Presence is derived from live sockets through the adapter's fetchSockets(), so it is consistent across replicas. See Realtime.
Socket.IO Admin UI
A live dashboard of connections, rooms, and events, disabled by default:
pnpm --filter @playpals/esu admin-ui:creds # generates a bcrypt hashADMIN_UI_ENABLED=true
ADMIN_UI_USERNAME=admin
ADMIN_UI_PASSWORD_HASH=<hash>Then open admin.socket.io and point it at the server. Enabling it also adds https://admin.socket.io to the CORS allowlist. It is instrumented in production mode, so event payloads are not exposed.
Configuration
| Variable | Default | Notes |
|---|---|---|
PORT | 4010 | |
CORS_ORIGIN | * | Comma-separated list |
JWT_SECRET | "" | Must match the API |
JWT_ALG | HS256 | |
API_BASE_URL | http://localhost:3000/api/v1 | Authorization callbacks — set to :4000 locally |
API_SERVICE_TOKEN | "" | Sent as X-Service-Token on callbacks |
REALTIME_ADMIN_TOKEN | "" | Guards /admin |
REDIS_URL / REDIS_HOST / REDIS_PORT / REDIS_PASSWORD | localhost | Adapter connection |
SOCKET_EVENT_LOGGING | true | Logs every inbound and outbound event |
ADMIN_UI_ENABLED | false | |
ADMIN_UI_USERNAME / ADMIN_UI_PASSWORD_HASH | — | Required when the UI is enabled |
The default API_BASE_URL port is wrong for local development
.env.example ships http://localhost:3000/api/v1, but the API's own .env.example sets PORT=4000. Left as shipped, every room join is denied. Set API_BASE_URL=http://localhost:4000/api/v1.
Deployment
| Property | Value |
|---|---|
| Helm values | infra/helm/values/production/esu.yaml (release playpalz-esu) |
| Replicas | 2, spread across nodes |
| Probes | Liveness and readiness on /health |
| Ingress | socket.playpalz.gg, its own Ingress object |
| Ingress notes | proxy-http-version: 1.1, 3600 s timeouts, ESUSESSION sticky cookie |
./scripts/build-esu.sh [tag]The socket ingress is separate from the REST ingress because its annotations must not apply to REST routes.
Debugging
kubectl logs -l app=playpalz-esu --tail=100 -fWith SOCKET_EVENT_LOGGING=true, every event is logged as socket:in / socket:out with the event name, user id, and socket id. In Grafana:
{app="playpalz-esu"} | json | event = "socket:in"Common failures: a JWT_SECRET mismatch (connect-then-drop), a wrong API_BASE_URL (every join denied), and Redis auth failures (fan-out silently stops).
