Skip to content

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

bash
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

ResponsibilityDetail
Authenticate socketsVerifies the JWT from socket.handshake.auth.token
Room membershipuser:, dm:, room:, channel: rooms
PresenceIn-memory map of user → socket ids
Typing indicatorsEphemeral, never persisted
Interservice fan-outPOST /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 esu to emit. This is why esu can restart mid-conversation with no data loss.
  • Authorize joins. It calls back into the API on every dm:join and room: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 logging

Client events handled

EventBehaviour
pingAcks with { ts }
dm:joinAuthorizes via the API, then joins dm:<id>; acks AUTHZ_DENIED on refusal
dm:leaveLeaves the room
dm:typing:start / dm:typing:stopBroadcasts dm:typing to others in the room
room:joinAuthorizes via the API, then joins room:<id>
room:leaveLeaves the room
room:typing:start / room:typing:stopBroadcasts room:typing
disconnectMarks 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:

EndpointEmitsTo room
POST /admin/emit/dm/message/newdm:message:newdm:<conversationId>
POST /admin/emit/dm/readdm:readdm:<conversationId>
POST /admin/emit/room/message/newroom:message:newroom:<roomId>
POST /admin/emit/channel/room/createdchannel:room:createdchannel:<channelId>
POST /admin/emit/notification/newnotification:newuser:<userId>
POST /admin/emit/media/processing/updatemedia:processing:updateuser:<userId>

Scaling

The Redis adapter (@socket.io/redis-adapter) makes an emit on any pod reach sockets on every pod:

ts
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:

ts
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:

bash
pnpm --filter @playpals/esu admin-ui:creds     # generates a bcrypt hash
bash
ADMIN_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

VariableDefaultNotes
PORT4010
CORS_ORIGIN*Comma-separated list
JWT_SECRET""Must match the API
JWT_ALGHS256
API_BASE_URLhttp://localhost:3000/api/v1Authorization 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_PASSWORDlocalhostAdapter connection
SOCKET_EVENT_LOGGINGtrueLogs every inbound and outbound event
ADMIN_UI_ENABLEDfalse
ADMIN_UI_USERNAME / ADMIN_UI_PASSWORD_HASHRequired 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

PropertyValue
Manifestinfra/k8s/esu-deployment.yaml
Replicas2, spread across nodes
ProbesLiveness and readiness on /health
Ingresssocket.playpalz.gg, its own Ingress object
Ingress notesproxy-http-version: 1.1, 3600 s timeouts, ESUSESSION sticky cookie
bash
./scripts/build-esu.sh [tag]

The socket ingress is separate from the REST ingress because its annotations must not apply to REST routes.

Debugging

bash
kubectl logs -l app=playpalz-esu --tail=100 -f

With 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).

Internal documentation — PlayPalz platform