All work

TypeScript · Node · Socket.IO

messaging-socket

Real-time messaging backend — JWT-cookie auth, Redis presence, FCM push, plus a Prisma/Postgres expense microservice.

Stars5
LanguageTypeScript
Last push15 Oct 2026
Topics
dockerexpressjsmongodbnodejsrealtimesocket-iotypescript

Messaging Platform

Real-time messaging (friends, DMs, Socket.IO, FCM) plus a daily expense tracker. One public origin — the gateway — fronts chat-service (auth, users, chat history), socket-go (real-time) and expense-service (Postgres). The React client never talks to a backend directly.


Table of contents

  1. System topology
  2. Request flow
  3. Repository layout
  4. Expense-service API
  5. Data stores
  6. Getting started
  7. Environment
  8. Deployment

More detail: Socket.IO API · Data model


System topology

Browser  (Vite :3000 / Vercel)
   │  REST  /v1/api/*     +     WebSocket  /socket.io
   ▼
Gateway  (:4000)                         ← only public origin
   │  CORS, security headers, gzip, access log
   │  stamps X-Internal-Secret
   │  rate-limits /v1/api (100/min per IP), plus /auth/login|register (50 / 30 min)
   │
   ├── /v1/api/expenses/**  →  Expense service (:4004)  →  PostgreSQL
   ├── /v1/api/**           →  chat-service (:1500)     →  MongoDB + Redis + FCM
   └── /socket.io           →  socket-go (:1600, Go)    →  MongoDB + Redis + FCM

INTERNAL_SECRET is what makes the gateway a trust boundary. chat-service, socket-go and expense-service are public URLs on Render (private services are not on the free plan), so they reject any request that is missing that header with 403 {"success":false,"error":"Forbidden."}.

Locally, Vite proxies /v1/api and /socket.io to the gateway (VITE_API_PROXY_TARGET=http://localhost:4000). In production the client sets VITE_API_URL to the gateway’s /v1/api.


Request flow

Login

POST /v1/api/auth/login  { email, password }
        │
        ▼
Gateway  general limiter (100/min) + auth limiter (50 / 30 min) per IP → proxy + X-Internal-Secret
        │
        ▼
chat-service  auth_service
   1. User.findOne({ email })          MongoDB
   2. bcrypt.compare
   3. jwt.sign({ userId, username, email }, JWT_SECRET, 15d)
   4. Set-Cookie: token=…; HttpOnly
        │
        ▼
Browser stores the cookie. Later REST + Socket.IO send it automatically.

Local HTTP uses SameSite=Lax. Deployed (NODE_ENV=production or golive) uses Secure; SameSite=None; Partitioned because the web app and API are different sites.

Authenticated REST (chat / users)

GET /v1/api/users   Cookie: token=<jwt>
        │
        ▼
Gateway → chat-service
        │
        ▼
auth middleware
   jwt.verify → Redis GET user:{id} (60s) → else MongoDB → SETEX
   req.user = hydrated user
        │
        ▼
controller → JSON

Expense REST

GET /v1/api/expenses/categories   Cookie: token=<jwt>
        │
        ▼
Gateway  matches /v1/api/expenses → expense-service + x-internal-secret
        │
        ▼
expense-service
   internal-secret guard
   cookieParser
   shared-auth jwt.verify   (no user DB — claims become req.user)
        │
        ▼
controller → Prisma → PostgreSQL

Both services share JWT_SECRET. Expense-service never calls chat-service to resolve a user.

Socket.IO

io('/', { withCredentials: true })
        │
        ▼
Gateway /socket.io  (HTTP poll, then WS upgrade — both stamped)
        │
        ▼
socket-go handshake
   X-Internal-Secret check (403 without it)
   cookie token or handshake.auth.token → JWT verify (HS256)
   join room <userId>       (every tab/device of a user)
   Redis HSET connectedUsers
   MongoDB isOnline = true
        │
        ├── sendDirectMessage → persist → emit to recipient's room, or FCM if offline
        ├── typing / markAsRead
        └── disconnect → clear Redis; isOnline=false + lastSeen once the last socket closes

Events and payloads: docs/socket-api.md.


Repository layout

services/
  chat-service/               auth, users, friends, chat history (Express + Mongoose)
    src/app.ts                Express app + internal-secret guard
    src/index.ts              HTTP server, env check, graceful shutdown
    src/controllers/          auth, users, messages
    src/middleware/           JWT+Redis auth, internal_auth, errors
    src/models/               Mongoose: User, Message, Conversation, Friend
    src/routes/               /auth, /users, /conversations
    src/services/             auth, users, messages, FCM
    src/config/               cors, mongo, redis, firebase, validateEnv
  gateway/                    Public entry in Go — CORS, rate limit, security headers, proxies
    cmd/server/main.go        routes + middleware chain, graceful shutdown
    internal/config/          env vars (adds http:// to Render host:port values)
    internal/proxy/           expense → :4004, /socket.io → :1600, everything else → :1500
    internal/middleware/      CORS, rate limiter, helmet-style headers, access log, client IP
    internal/health/          /health (liveness), /health/ready (probes upstreams)
  socket-go/                  Socket.IO server in Go (replaced the Node socket server)
    cmd/server/main.go        connects Mongo/Redis/FCM, internal-secret check, /health, shutdown
    internal/config/          env vars (same names as chat-service)
    internal/auth/            JWT from `token` cookie or handshake.auth.token
    internal/socket/          event handlers (see docs/socket-api.md)
    internal/store/           MongoDB writes, Mongoose-compatible documents
    internal/presence/        Redis `connectedUsers` hash
    internal/push/            FCM notifications (no-op when Firebase is not configured)
  expense-service/            Expense tracker (Express + Prisma)
    prisma/schema.prisma
    prisma/seed.ts
    src/app.ts                health, Swagger, internal-secret, routes
    src/docs/openapi.ts       OpenAPI 3 spec
    src/controllers/          expense, category, budget, income, recurring, report
    src/services/
    src/routes/               mounted under /v1/api/expenses/*

packages/
  shared-auth/                JWT verify + Express / Socket helpers
  shared-config/              CORS, shutdown
  shared-errors/              typed HTTP errors

client/                       Vite + React (not an npm workspace)
  src/api/                    axios client (cookie credentials)
  src/pages/                  Login, Register, Chat, Profile, Expenses
  src/components/expenses/    Budgets, Income, Recurring, Reports, categories

Workspaces are services/* and packages/* only. The web app is started with cd client && npm run dev.


Expense-service API

Interactive docs (no auth required to view):

URL
Swagger UIhttp://localhost:4004/api-docs
OpenAPI JSONhttp://localhost:4004/api-docs.json
chat-service Swaggerhttp://localhost:1500/api-docs

In Swagger UI pick the Local gateway server, then Authorize with the JWT from the token cookie (POST /v1/api/auth/login on the gateway). Trying the expense-service host directly returns 403 when INTERNAL_SECRET is set.

MethodPathAuthWhat it does
GET/v1/api/health—Postgres probe (unguarded)
GET/POST/v1/api/expenses/categories✓List / create categories
PUT/DELETE/v1/api/expenses/categories/:id✓Update / delete custom categories
GET/POST/v1/api/expenses✓List (filters: startDate, endDate, category, page, limit) / create
GET/PUT/DELETE/v1/api/expenses/:id✓One expense
GET/v1/api/expenses/summary✓Totals by groupBy=day|category
GET/POST/v1/api/expenses/budgets✓Monthly limits (categoryId omitted = overall)
GET/v1/api/expenses/budgets/status✓Spent vs limit (month=YYYY-MM)
PUT/DELETE/v1/api/expenses/budgets/:id✓Update amount / delete
GET/POST/v1/api/expenses/income✓List / record income
GET/PUT/DELETE/v1/api/expenses/income/:id✓One income row
GET/POST/v1/api/expenses/recurring✓List / create rules (back-fills due occurrences)
GET/PUT/DELETE/v1/api/expenses/recurring/:id✓One rule (delete keeps generated expenses)
POST/v1/api/expenses/recurring/:id/pause✓Pause (no back-fill on resume)
POST/v1/api/expenses/recurring/:id/resume✓Resume
GET/v1/api/expenses/reports/monthly✓Income, expense, net, breakdowns
GET/v1/api/expenses/reports/export✓CSV (type=expense|income, from, to)

Creating or updating an expense may include budgetWarnings when a touched budget is at least 80% used.

chat-service routes (also via the gateway)

MethodPathAuth
POST/v1/api/auth/register—Create account
POST/v1/api/auth/login—Sets token cookie
POST/v1/api/auth/logout✓Clears cookie
GET/v1/api/auth/user✓Current user
PUT/v1/api/auth/fcmtoken✓FCM token
GET/v1/api/users✓Discoverable users
GET/v1/api/users/friends✓Friends
GET/v1/api/users/friendrequest✓Pending requests
POST/v1/api/users/addfriend✓Send request
PUT/v1/api/users/confirm_request✓Accept
PUT/v1/api/users/avatar✓Avatar
GET/v1/api/conversations/:friend_id/messages✓History
GET/v1/api/health—Mongo + Redis
GET/health—Gateway liveness
GET/health/ready—Gateway readiness: probes chat-service, expense-service and socket-go

Data stores

MongoDB (chat-service, socket-go) — User, Message, Conversation (2 participants), Friend (pending / accepted / blocked).

PostgreSQL (expense-service)

ModelNotes
ExpenseCategoryuserId null = global seed; otherwise owned
ExpenseuserId from JWT (no FK). Indexes (userId, spentAt), (userId, categoryId)
BudgetUnique (userId, categoryId); null category = overall cap
IncomeIndexed (userId, receivedAt)
RecurringExpenseDAILY / WEEKLY / MONTHLY / YEARLY; generates Expense rows

Amounts are Decimal(12,2), serialized as "5000.00". Payment methods: CASH, KBZ_PAY, AYA_PAY, ONLINE_PAYMENT.

Redis — user:{id} (60s profile cache, chat-service), connectedUsers hash for presence (socket-go).


Getting started

Need five processes: chat-service :1500, socket-go :1600, gateway :4000, expense-service :4004, client :3000.

# env files (from repo root) — JWT_SECRET and INTERNAL_SECRET must match in all of them
cp services/chat-service/.env.example services/chat-service/.env   # Mongo, Redis, Firebase, …
cp services/gateway/.env.example services/gateway/.env
cp services/expense-service/.env.example services/expense-service/.env
cp services/socket-go/.env.example services/socket-go/.env       # same Mongo/Redis/JWT/Firebase values as chat-service

# all backends at once (Go 1.26+ for gateway and socket-go)
npm install
npm run dev:services          # chat :1500 + socket-go :1600 + gateway :4000 + expense :4004

cd services/expense-service
npm run prisma:migrate
npm run prisma:seed           # default categories

# client (not a workspace)
cd client
cp .env.example .env
# VITE_API_PROXY_TARGET=http://localhost:4000
# VITE_EXPENSE_API_PROXY_TARGET=http://localhost:4000
npm install
npm run dev                   # :3000

Then open http://localhost:3000 and http://localhost:4004/api-docs.

Go tests:

cd services/gateway && go test ./...
cd services/socket-go && go test ./...

Docker (gateway on host port 80):

docker compose up -d --build

In Docker, socket-go reads services/chat-service/.env. client is not in the compose file — still run Vite, and point the proxy at http://localhost (port 80) if you use that stack.


Environment

Shared across services:

VariableWhoNotes
JWT_SECRETall backendsMust be identical
INTERNAL_SECRETgateway + chat-service + socket-go + expenseMust be identical. Leave empty on a backend only if Vite talks to it directly
CLIENT_URLgateway (and backends for CORS)Comma-separated origins

chat-service and socket-go also need MONGODB_URI (or DEV_MONGODB_URI), Redis (REDIS_URL / DEV_REDIS_*), and Firebase Admin keys in non-dev. Expense-service needs DATABASE_URL. Gateway needs CHAT_SERVICE_URL, EXPENSE_SERVICE_URL and SOCKET_SERVICE_URL; TRUST_PROXY_HOPS (default 1, Render's edge) sets how many proxies' X-Forwarded-For entries to trust for rate limiting.

Client: VITE_API_PROXY_TARGET / VITE_EXPENSE_API_PROXY_TARGET (local) or VITE_API_URL (production gateway). Firebase web keys for push.


Deployment

render.yaml Blueprint: gateway (public), chat-service, socket-go, expense-service. Downstream URLs come from fromService.hostport; the gateway adds http://. Health: gateway /health, chat-service /v1/api/health, socket-go /health, expense /v1/api/health.

Expense-service start: npx prisma migrate deploy && npm start.

The Vercel client should set VITE_API_URL to https://<gateway>/v1/api — never to chat-service, socket-go or expense-service.


License

Apache License