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
| From | To | Transport | Auth | Purpose |
|---|---|---|---|---|
| Mobile | api | HTTPS REST | Authorization: Bearer <JWT> | Everything |
| Mobile | esu | Socket.IO (WSS) | JWT in the handshake | Realtime |
| Mobile | LiveKit | WebRTC | LiveKit access token minted by the API | Voice rooms |
| Mobile | Mux | HLS / RTMP | Public playback ID; stream key for broadcast | Livestreaming |
| Web | api | HTTPS REST | — | Public data |
| api | esu | HTTP POST /admin/emit/* | X-Realtime-Admin-Token | Fan out realtime events |
| esu | api | HTTP GET /api/v1/{dm,room}/:id/authz | Forwards the user's JWT | Authorize a join |
| ogun | esu | HTTP POST /admin/emit/* | X-Realtime-Admin-Token | Media-ready notification |
| api | Redis | BullMQ | password | Enqueue media jobs |
| ogun | Redis | BullMQ | password | Consume media jobs |
| esu | Redis | Socket.IO Redis adapter | password | Cross-pod pub/sub |
| api | Redis | ioredis | password | Feed / trending cache |
| all services | PostgreSQL | Prisma | connection string | Data |
| RevenueCat | api | HTTPS webhook | shared secret | Purchase events |
| Mux | api | HTTPS webhook | signing secret | Asset and livestream state |
| anansi | Stripe | HTTPS API | secret key | Top-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):
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):
fetch(`${process.env.REALTIME_SERVICE_URL}/admin${path}`, …)Ogun's helper is identical (apps/ogun/src/utils/interservice.ts) — keep the two copies in sync.
Both normalise the base URL before use, so REALTIME_SERVICE_URL works whether or not it already carries a trailing /admin:
const base = (process.env.REALTIME_SERVICE_URL ?? "").replace(/\/+$/, "").replace(/\/admin$/, "");
fetch(`${base}/admin${path}`, …)Prefer the bare service root (http://playpalz-esu:4010) in new configuration.
Previously: REALTIME_SERVICE_URL meant two different things
The helpers used to disagree about whether the env value included /admin, while the manifests set one value for both. That was correct for the API and wrong for ogun, so the "your photo finished processing" event silently 404'd in production; locally the polarity reversed and the API POSTed to /admin/admin/…. .catch(() => {}) at every call site hid both.
Two things stop this recurring: the normalisation above, and the helpers now logging and throwing on a non-2xx response instead of returning it unexamined.
Realtime room naming
esu groups sockets into rooms (apps/esu/src/rooms/roomNames.ts). Every emit targets one:
| Room | Contains | Used for |
|---|---|---|
user:<userId> | Every socket for one user | Notifications, media-ready, presence-targeted events |
dm:<conversationId> | Participants of a direct conversation | Messages, read receipts, typing |
room:<roomId> | Members currently in a channel room | Messages, typing |
channel:<channelId> | Channel members | Room 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.
esustays 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
| Service | Scales by | Constraint |
|---|---|---|
api | Horizontal, stateless | Postgres connection count |
esu | Horizontal via the Redis adapter | Ingress needs sticky sessions for long-polling — hence the ESUSESSION cookie on the socket ingress |
ogun | Horizontal, more workers = more throughput | CPU-bound (Sharp); Mux is rate-limited |
anansi | Single effective runner | Guarded by a Redis distributed lock; a second replica would double-pay creators |
igdb-heartbeat | Single | Cron |
web | Horizontal, 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 dies | What breaks | What keeps working |
|---|---|---|
api | Everything | Nothing meaningful |
esu | Live message delivery, typing, presence | Sending and reading messages (the API persists them; clients see them on refresh) |
ogun | Thumbnails and video transcoding | Uploads still succeed; media stays pending and processes when the worker returns |
anansi | Payout runs | The product; earnings still accrue in the database |
igdb-heartbeat | Game catalog freshness | Everything |
| Redis | Queue, realtime fan-out, caching | Degraded but mostly functional — cache misses fall through to Postgres |
| PostgreSQL | Everything | Nothing |
The useful property here is that esu and ogun are both deferrable: losing them degrades the experience but does not lose data.
