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/store.ts in-memory presence map
├── 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 |
disconnect | Marks offline; emits presence:update only when the user's last socket goes |
Full contract and the known drift between it and packages/types: Socket Events.
Interservice admin API
Mounted at /admin, guarded by X-Realtime-Admin-Token:
| 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/media/processing/update | media:processing:update | user:<userId> |
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 does not survive multiple replicas
presence/store.ts is a process-local Map. With two replicas, each knows only its own connections, so online status is inconsistent depending on which pod answers. Messaging and typing are unaffected (those go through the adapter). Moving presence to Redis is the fix.
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 |
|---|---|
| Manifest | infra/k8s/esu-deployment.yaml |
| 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).
