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 push3 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 the monolith (auth, users, chat) 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

System topology

Browser  (Vite :3000 / Vercel)
   │  REST  /v1/api/*     +     WebSocket  /socket.io
   ▼
Gateway  (:4000)                         ← only public origin
   │  stamps x-internal-secret
   │  rate-limits /v1/api and /auth/login|register
   │
   ├── /v1/api/expenses/**  →  Expense service (:4004)  →  PostgreSQL
   ├── /v1/api/**           →  Monolith (:1500)         →  MongoDB + Redis + FCM
   └── /socket.io           →  Monolith Socket.IO

INTERNAL_SECRET is what makes the gateway a trust boundary. Monolith 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  authLimiter (10 / 15 min) → proxy + x-internal-secret
        │
        ▼
Monolith  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 → monolith
        │
        ▼
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 the monolith to resolve a user.

Socket.IO

io('/', { withCredentials: true })
        │
        ▼
Gateway /socket.io  (HTTP poll, then WS upgrade — both stamped)
        │
        ▼
Monolith handshake
   cookie token or handshake.auth.token → jwt.verify
   Redis HSET connectedUsers / userSockets
   MongoDB isOnline = true
        │
        ├── sendDirectMessage → persist → emit or FCM if offline
        ├── typing / markAsRead
        └── disconnect → clear Redis + lastSeen

Repository layout

src/                          Monolith — auth, users, conversations, Socket.IO
  app.ts                      Express app + internal-secret guard
  index.ts                    HTTP + Socket.IO, env check, graceful shutdown
  controllers/                auth, users, messages
  middleware/                 JWT+Redis auth, internal_auth, errors
  models/                     Mongoose: User, Message, Conversation, Friend
  routes/                     /auth, /users, /conversations
  services/                   auth, users, messages, FCM
  socket/                     real-time handlers
  config/                     cors, mongo, redis, firebase, validateEnv

services/
  gateway/                    Public entry — CORS, rate limit, proxies
    src/app.ts
    src/proxies.ts            expense → :4004, everything else + WS → :1500
  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
Monolith 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.

Monolith 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 + both upstreams

Data stores

MongoDB (monolith) — User, Message, Conversation (2 participants), Friend (pending / accepted / rejected).

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 (monolith) — user:{id} (60s profile cache), connectedUsers and userSockets hashes for presence.


Getting started

Need four processes: monolith :1500, gateway :4000, expense-service :4004, client :3000.

# root — monolith
cp .env.example .env          # JWT_SECRET, INTERNAL_SECRET, Mongo, Redis, …
npm install
npm run dev                   # :1500

# gateway + expense-service (from repo root)
cp services/gateway/.env.example services/gateway/.env
cp services/expense-service/.env.example services/expense-service/.env
# INTERNAL_SECRET and JWT_SECRET must match the monolith
npm run dev:services          # 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.

Docker (gateway on host port 80):

docker compose up -d --build

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 + monolith + expenseMust be identical. Leave empty on the monolith only if Vite talks to :1500 directly
CLIENT_URLgateway (and backends for CORS)Comma-separated origins

Monolith also needs 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 MONOLITH_URL and EXPENSE_SERVICE_URL.

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), monolith, expense-service. Downstream URLs come from fromService.hostport; the gateway adds http://. Health: gateway /health, monolith /v1/api/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 the monolith or expense-service.


License

Apache License