Skip to content

Service Topology

Who talks to whom, over what, and with which credential. This is the page to read before adding a service or debugging a cross-service failure.

Communication matrix

FromToTransportAuthPurpose
MobileapiHTTPS RESTAuthorization: Bearer <JWT>Everything
MobileesuSocket.IO (WSS)JWT in the handshakeRealtime
MobileLiveKitWebRTCLiveKit access token minted by the APIVoice rooms
MobileMuxHLS / RTMPPublic playback ID; stream key for broadcastLivestreaming
WebapiHTTPS RESTPublic data
apiesuHTTP POST /admin/emit/*X-Realtime-Admin-TokenFan out realtime events
esuapiHTTP GET /api/v1/{dm,room}/:id/authzForwards the user's JWTAuthorize a join
ogunesuHTTP POST /admin/emit/*X-Realtime-Admin-TokenMedia-ready notification
apiRedisBullMQpasswordEnqueue media jobs
ogunRedisBullMQpasswordConsume media jobs
esuRedisSocket.IO Redis adapterpasswordCross-pod pub/sub
apiRedisioredispasswordFeed / trending cache
all servicesPostgreSQLPrismaconnection stringData
RevenueCatapiHTTPS webhookshared secretPurchase events
MuxapiHTTPS webhooksigning secretAsset and livestream state
anansiStripeHTTPS APIsecret keyTop-ups and creator transfers

The interservice channel

esu exposes an admin router at /admin, guarded by a shared token (apps/esu/src/interservice/adminRoutes.ts):

ts
router.use(requireAdminToken);        // checks X-Realtime-Admin-Token

router.post("/emit/dm/message/new", …);
router.post("/emit/dm/read", …);
router.post("/emit/room/message/new", …);
router.post("/emit/channel/room/created", …);
router.post("/emit/notification/new", …);
router.post("/emit/media/processing/update", …);

Each handler does one thing: emit a Socket.IO event into a room. No database access, no authorization beyond the token.

The API's helper adds the /admin prefix itself (apps/api/src/lib/interservice.ts):

ts
fetch(`${process.env.REALTIME_SERVICE_URL}/admin${path}`, …)

Ogun's helper does not (apps/ogun/src/utils/interservice.ts):

ts
fetch(`${process.env.REALTIME_SERVICE_URL}${path}`, …)

Known defect: REALTIME_SERVICE_URL means two different things

The two helpers disagree about whether REALTIME_SERVICE_URL includes the /admin prefix, and the deployment manifests set the same value for both:

yaml
- name: REALTIME_SERVICE_URL
  value: "http://playpalz-esu:4010"     # api-deployment.yaml AND ogun-deployment.yaml

That is correct for the API and wrong for ogun — ogun POSTs to /emit/media/processing/update, which esu does not route, so the "your photo finished processing" realtime event never fires in production. The mobile app falls back to polling (useMediaProcessingPoll), which is why the bug has not been visible.

Locally the polarity is reversed: apps/api/.env.example ships REALTIME_SERVICE_URL="http://localhost:4010/admin", which makes the API POST to /admin/admin/….

The fix is to pick one convention. Recommended: make REALTIME_SERVICE_URL the service root (http://playpalz-esu:4010), add the /admin prefix in ogun's helper to match the API's, and update both .env.example files.

Realtime room naming

esu groups sockets into rooms (apps/esu/src/rooms/roomNames.ts). Every emit targets one:

RoomContainsUsed for
user:<userId>Every socket for one userNotifications, media-ready, presence-targeted events
dm:<conversationId>Participants of a direct conversationMessages, read receipts, typing
room:<roomId>Members currently in a channel roomMessages, typing
channel:<channelId>Channel membersRoom created / updated, member joined / left

A socket joins user:<id> automatically on connect. All other joins are explicit and authorized via the callback to the API.

Why esu calls back into the API

It would be faster for esu to query Postgres directly for "is this user allowed in this conversation". It does not, on purpose:

  • Access rules for channels involve subscription state, blocks, and membership. Duplicating them would mean two implementations that drift.
  • esu stays free of business logic, so it can be restarted, scaled, or replaced without coordinating a data-model change.

The cost is one HTTP round trip per join. That is acceptable — joins are rare compared to messages.

Scaling characteristics

ServiceScales byConstraint
apiHorizontal, statelessPostgres connection count
esuHorizontal via the Redis adapterIngress needs sticky sessions for long-polling — hence the ESUSESSION cookie on the socket ingress
ogunHorizontal, more workers = more throughputCPU-bound (Sharp); Mux is rate-limited
anansiSingle effective runnerGuarded by a Redis distributed lock; a second replica would double-pay creators
igdb-heartbeatSingleCron
webHorizontal, stateless

anansi and igdb-heartbeat must not be scaled up casually

Both are cron-driven. anansi uses a Redis lock to make duplicate runs safe, but that lock is the only thing standing between you and duplicate Stripe transfers. Treat replica counts for those two deployments as a deliberate decision, not a knob.

Failure modes

If this diesWhat breaksWhat keeps working
apiEverythingNothing meaningful
esuLive message delivery, typing, presenceSending and reading messages (the API persists them; clients see them on refresh)
ogunThumbnails and video transcodingUploads still succeed; media stays pending and processes when the worker returns
anansiPayout runsThe product; earnings still accrue in the database
igdb-heartbeatGame catalog freshnessEverything
RedisQueue, realtime fan-out, cachingDegraded but mostly functional — cache misses fall through to Postgres
PostgreSQLEverythingNothing

The useful property here is that esu and ogun are both deferrable: losing them degrades the experience but does not lose data.

Internal documentation — PlayPalz platform