Skip to content

Latest commit

Β 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ’¬ Zentro

A production-grade, horizontally-scaled real-time chat platform.

Built to prove β€” and to stress-test β€” the Valkey adapter for Socket.IO.

npm license tests layers realtime renders

⚑ Built for low latency β€” measured in the things that actually cost you time

πŸ”§ Real regressions fixed β€” before β†’ after, genuinely in this repo

βœ“βœ“ Read receipts 🧩 Re-renders πŸ“„ History
O(NΒ²) socket frames per message β†’ 1 write / burst every bubble re-rendered β†’ only what changed oldest 100, rest unreachable β†’ cursor-paged, all reachable

πŸ— By design β€” the naive implementation, deliberately avoided

πŸ”” A message arrives πŸ”Ž "Is there more?" πŸ“¦ The login screen
refetch the list β†’ 0 HTTP requests a count() query β†’ one extra row ship the app β†’ βˆ’29 kB gzip

⚑ See how, with the code β†’


⚑ The engine: socket.io-valkey-adapter

This project exists because of this package. Zentro is its reference implementation β€” the place where the adapter is integrated, exercised across multiple nodes, and proven correct by an automated suite that boots two independent Node processes and makes them talk to each other through Valkey.

The Socket.IO Valkey adapter β€” broadcast events between several Socket.IO servers.

πŸ“¦ npm npmjs.com/package/socket.io-valkey-adapter
πŸ’» Source github.com/webdevelopersrinu/socket.io-valkey-adapter
🌐 Docs valkey.srinudesetti.in
🏷️ Version 0.1.1 · MIT
npm install socket.io-valkey-adapter iovalkey

The problem it solves

A single Socket.IO server keeps its rooms in memory. The moment you put a second server behind a load balancer, that memory is no longer shared:

        ❌  WITHOUT AN ADAPTER

   Alice ──▢  Server A   [rooms in RAM]  ──▢ βœ‰οΈ  emitted into the void
                                                 Server B never hears it

   Bob   ──▢  Server B   [rooms in RAM]      πŸ”‡  Bob sees nothing

Alice and Bob are in the same room β€” but on different machines, so they cannot talk to each other. Scaling out silently breaks the app.

The adapter fixes this by making Valkey the message bus between every node:

        βœ…  WITH socket.io-valkey-adapter

   Alice ──▢  Server A ──── publish ────┐
                                        β–Ό
                              πŸ”΄  V A L K E Y   (pub/sub)
                                        β”‚
   Bob   ◀──  Server B ◀─── subscribe β”€β”€β”˜

        Server B's subClient receives A's publish and
        delivers the message to Bob.        βœ‰οΈ  β†’  πŸ””

How Zentro wires it up

Each server opens two connections to the same Valkey β€” one to publish, one to subscribe. A subscribed connection cannot issue ordinary commands, which is exactly why it is duplicated rather than shared.

// backend/src/config/valkey.js
import Valkey from "iovalkey";
import { createAdapter } from "socket.io-valkey-adapter";

export async function attachValkeyAdapter(io, valkeyUrl) {
  const pubClient = new Valkey(valkeyUrl);   // publishes out to Valkey
  const subClient = pubClient.duplicate();   // receives from other servers

  io.adapter(createAdapter(pubClient, subClient));
  return { pubClient, subClient };
}

That is the entire integration. From this line on, io.to(roomId).emit(...) reaches every member of that room on every server in the cluster β€” and the application code never has to know there is more than one machine.

πŸ§ͺ Proven, not assumed

The adapter is not merely used here. It is verified by a suite that boots two real Node processes (:4101 and :4102) sharing one MongoDB and one Valkey, and asserts that events genuinely cross the process boundary.

If Valkey is stopped, these tests fail. That is the point of them.

backend/e2e/realtime/multi-server.spec.js   ──  12 cross-server tests
Cross-server behaviour proven
Both servers are genuinely different processes (asserts pidA !== pidB) βœ…
A message sent on server A reaches a member on server B βœ…
Messages flow in both directions βœ…
A room created after the sockets connect still syncs βœ…
Typing indicators cross servers βœ…
Presence β€” leaving server A notifies a user on server B βœ…
A join request on server A notifies the admin on server B βœ…
A read receipt on server B turns the author's ticks blue on server A βœ…
A non-member on server B never receives the room's messages πŸ”’ βœ…
Message history written on A is readable from B βœ…
cd backend && npm run test:e2e      # 27 passed β€” two servers, one Valkey

⚑ Performance β€” engineered for low latency

Every claim below is verifiable in the source, and is stated as one of two things β€” never blurred:

  • πŸ”§ Fixed β€” a real regression that existed in this repository.
  • πŸ— By design β€” a property of how it is built, contrasted with the naive implementation it deliberately avoids.

The units are the ones that actually cost a user milliseconds: queries issued, socket frames emitted, components re-rendered, bytes shipped. There are no invented latency figures here β€” this project ships no benchmark harness, and a fabricated "800 ms β†’ 120 ms" is the fastest way to lose a reviewer who checks.

πŸ”§ Real regressions, found and fixed

Before (genuinely, in this repo) After
βœ“βœ“ Read-receipt storm Each incoming message triggered a /read write per viewer, and the server broadcasts every receipt to every member β€” O(membersΒ²) socket frames per chat message. Past ~20 msg/min it exhausted the rate limiter and 429'd the user out of their own session. One write per burst (2 s trailing debounce)
🧩 Wasted re-renders MessageBubble is memo'd, but the memo never hit: the whole receipts array was passed to every bubble (new identity whenever anyone read anything), and inline arrows were recreated each render. One person opening a room re-rendered every bubble in it. Reader counts computed once into a Map; each bubble takes a plain number; callbacks hoisted to stable useCallback β€” only what changed re-renders
πŸ“„ Unreachable history .limit(100) sorted ascending β€” it returned the oldest 100 messages, and everything newer was silently unreachable. A correctness bug that grows with use. Cursor pagination β€” 50 rows/page, newest first, all history reachable

πŸ— Design properties β€” the naive implementation, deliberately avoided

The obvious way What Zentro does
πŸ”Ž "Is there more?" A second count() query per page One extra row (.limit(size + 1)) β€” no second query, ever
πŸ”” A message arrives Invalidate β†’ refetch the whole list (an N+1 storm) The socket already handed us the data β€” patch the cache. 0 HTTP requests.
πŸ’¬ "3 replies" / unread dots A count() per row while rendering Denormalised replyCount / lastMessageAt β€” no extra query
πŸ“¦ The login screen Ship the whole app Chat is lazy() β€” 29 kB gzip never downloaded
πŸ—‘ Expired tokens / OTPs A cron job MongoDB TTL indexes β€” the database expires them

1. πŸ“„ Cursor pagination β€” and the bug it fixed πŸ”§

The original query was not merely un-paginated. It was wrong:

// BEFORE β€” returns the OLDEST 100. Everything newer was unreachable.
Message.find({ room }).sort({ createdAt: 1 }).limit(100);
// AFTER β€” backend/src/services/room.service.js
const page = await Message.find({ room, parent: null, ...(before && { _id: { $lt: before } }) })
  .sort({ _id: -1 })
  .limit(size + 1);          // ← ONE extra row answers "hasMore"

const hasMore = page.length > size;
if (hasMore) page.pop();
  • 50 messages per page (hard cap 100). Older pages load on scroll-to-top.
  • hasMore costs one extra row β€” never a second count() query. πŸ—
  • The cursor is _id, not createdAt β€” _id is unique, so two messages written in the same millisecond can never make the cursor skip one. πŸ—

2. πŸ—‚ Indexes shaped to the query that uses them

Each index exists to serve one specific access path β€” not sprinkled on afterwards.

Index Serves
{ room, parent, _id: -1 } Message history β€” equality on room+parent, then a descending walk from the cursor
{ parent, _id: 1 } A whole thread, oldest first
{ members, updatedAt: -1 } "My rooms" β€” the sidebar, on every load
{ room, user } (unique) Read receipts β€” and it enforces one row per member
{ provider, providerId } (unique) OAuth identity lookup
{ expiresAt } (TTL Γ— 2) Refresh tokens & OTPs β€” MongoDB expires them for us; no cron job

πŸ”Ž Two known scans β€” documented, not hidden

A reviewer will find these, so here they are, stated plainly:

  • GET /rooms/discover runs { members: { $ne: userId } }. $ne is not selective and cannot use the index β€” this is a collection scan. Acceptable while room count is in the dozens; it needs pagination (or a materialised "public rooms" projection) before it is not.
  • Message search uses an escaped, unanchored regex. It is bounded to one room by the compound index, but scans that room's messages. Chosen deliberately over a $text index because users expect "stand" to find "standup" β€” which word-based text search cannot do. It carries a ponytail: note in the source naming the ceiling and the upgrade path.

Both are capped and bounded. Neither is index-served, and claiming otherwise would be false.

3. πŸ”” Real-time costs zero HTTP πŸ—

The naive approach is an N+1 disaster: a message arrives β†’ invalidate the query β†’ refetch the entire list. Ten people chatting = ten full history downloads.

// The socket already handed us the data. Write it straight into the cache.
queryClient.setQueryData(key, (page) => appendMessage(page, message));

A message arriving triggers 0 network requests. Not a smaller one β€” none.

4. βœ“βœ“ Killing an O(NΒ²) broadcast storm πŸ”§

// frontend/src/hooks/useChatState.js
const READ_TRAIL_MS = 2000;   // one read per burst of chatter, not one per message

Read receipts fired a write per incoming message, per viewer β€” and the server broadcasts every receipt to every member. In a room of N people, a single chat message cost NΒ² socket frames; past ~20 messages/minute it burned through the rate limiter and 429'd the user out of their own session.

O(NΒ²) per message β†’ one write per burst. The receipt is just as accurate. It simply stops shouting.

5. 🧩 Modular, memoised components β€” render only what changed πŸ”§

Every bubble is memo'd β€” but a memo only works if its props are stable. Two leaks were silently defeating it:

The leak The fix
The whole receipts array was handed to every bubble β€” and it gets a new identity whenever anyone reads anything Reader counts computed once for the list into a Map; each bubble receives a plain number
Fresh inline arrows (onRetry, onOpenThread, …) on every render = a changed prop on every bubble Hoisted to stable useCallback identities

Before: one person opening a room re-rendered every bubble in it. After: only the bubbles that actually changed.

6. πŸ“¦ Ship less JavaScript β€” code splitting + long-term caching πŸ—

Real figures, straight from npm run build:

dist/assets/react-….js       57.19 kB gzip   ← vendor, cached across deploys
dist/assets/data-….js        29.84 kB gzip   ← TanStack Query + axios
dist/assets/realtime-….js    12.86 kB gzip   ← socket.io
dist/assets/Chat-….js        24.14 kB gzip   ← πŸ”’ LAZY β€” not on the login path
dist/assets/Chat-….css        4.76 kB gzip   ← πŸ”’ LAZY
  • The chat bundle is lazy()-loaded β€” someone sitting on the login screen never downloads 29 kB gzip of an app they cannot yet see.
  • Vendor is split by change frequency (react Β· data Β· realtime), so shipping your own app code does not bust React's cache in every user's browser.

7. πŸ›‘ Bounded by design β€” nothing is unbounded πŸ—

Limit Value Stops
Request body 10 KB Payload DoS
Messages per page 50 (max 100) Unbounded reads
Search results 25 Unbounded reads
Socket events 20 / 10 s per socket Flood
Search term Regex-escaped + length-capped ReDoS β€” a CPU pinned by (a+)+$

🎯 What Zentro is

A real-time chat platform with public and private rooms, threads, reactions, read receipts, search and role-based moderation β€” engineered to the standard you would ship to production, not to a tutorial.

πŸ” Auth Google Β· GitHub OAuth Β· passwordless email OTP β€” no passwords, ever
🏠 Rooms Public β†’ join instantly Β· Private β†’ request, an admin approves
βœ‰οΈ Invites The invitee accepts β€” nobody joins a room without their own consent
πŸ’¬ Messaging Threads Β· reactions (any emoji) Β· edit (1 h window) Β· delete (tombstone)
βœ“βœ“ Receipts WhatsApp-style ticks β€” grey β†’ blue once everyone has read it
πŸ” Search Per-room Β· ReDoS-safe Β· never surfaces deleted messages
πŸ‘‘ Moderation The creator grants admin; admins approve, invite and rename
⚑ Real-time Messages Β· typing Β· presence Β· unread β€” across every server

πŸ› Architecture

                        ☁️  Cloudflare  β€” DNS + TLS
                                   β”‚
                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                   β”‚  AWS Application Load Balancer β”‚
                   β”‚   (sticky sessions for OAuth)  β”‚
                   β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚                β”‚
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚     EC2  #1     β”‚  β”‚     EC2  #2     β”‚
                β”‚  Node Β· Socket  β”‚  β”‚  Node Β· Socket  β”‚
                β”‚      .IO        β”‚  β”‚      .IO        β”‚
                β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
                     β”‚       β”‚            β”‚       β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚                                                      β”‚
   β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”
   β”‚  πŸ”΄  V A L K E Y      β”‚                β”‚  πŸƒ  M O N G O D B   β”‚
   β”‚      THE WIRE         β”‚                β”‚      THE TRUTH       β”‚
   β”‚                       β”‚                β”‚                      β”‚
   β”‚  pub/sub only.        β”‚                β”‚  users Β· rooms       β”‚
   β”‚  Transient.           β”‚                β”‚  messages Β· threads  β”‚
   β”‚  Nothing is stored.   β”‚                β”‚  reactions Β· receiptsβ”‚
   β”‚                       β”‚                β”‚  refresh tokens      β”‚
   β”‚  Losing it costs      β”‚                β”‚                      β”‚
   β”‚  a broadcast β€”        β”‚                β”‚  Losing it costs     β”‚
   β”‚  never data.          β”‚                β”‚  everything.         β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The single most important architectural decision

MongoDB is the source of truth. Valkey is the wire between servers β€” never a data store.

Every durable fact β€” a message, a membership, a refresh token β€” lives in MongoDB. Valkey carries only transient pub/sub traffic. This is deliberate, and it is load-bearing:

  • A FLUSHALL on Valkey costs you one broadcast, not one byte of user data.
  • Valkey can be restarted, resized or replaced with zero data loss.
  • Refresh tokens were deliberately migrated out of Valkey into MongoDB β€” a cache eviction must never silently log out every user in the system.

πŸ— Enterprise engineering practices

This is not a tutorial codebase. Every item below is enforced in the source and covered by tests.

Backend β€” layered, and the layers mean something

request β†’ route β†’ middleware β†’ validator β†’ controller β†’ service β†’ model
                                              β”‚            β”‚
                                  HTTP only β”€β”€β”˜            └── business rules
                                  no logic                     + authorization
Layer Responsibility The rule it enforces
routes/ URL β†’ handler wiring Nothing else. No logic.
middleware/ auth Β· validation Β· security Β· rate limiting Cross-cutting concerns only
validators/ zod schemas Input is coerced & trimmed before a controller ever sees it
controllers/ HTTP in, HTTP out Zero business logic. Zero authorization.
services/ Business rules and authorization Never trusts the client. The one place a rule can live.
models/ Mongoose schemas + indexes Indexes are shaped to the query that uses them (the two known scans are documented, not hidden)
socket/ Real-time handlers Re-checks membership on every event
utils/ Β· lib/ Pure helpers Β· infrastructure seams Testable in isolation

Authorization lives in services, never in controllers. A rule written in a controller is a rule the socket layer does not have. Every mutation re-checks membership server-side β€” leaving a room ends your ability to touch anything in it, including a message you wrote while you were still a member.

Frontend β€” a real state architecture

Concern Owner Why
Server state TanStack Query One cache. One source of truth.
Client state React state / context Which room is open, which drawer is up
Real-time Socket events write into the Query cache Never a refetch β€” the socket already handed us the data
Network One axios instance Single-flight 401 β†’ refresh β†’ retry, in exactly one place
Styling CSS Modules + design tokens No raw hex/px where a token exists; correct in both themes

Socket events patch the cache β€” they never trigger a refetch. Refetching an entire message list on every arriving message would be an N+1 disaster.

A reusable component library

Fourteen primitives. Every screen is assembled from them; nothing is styled ad-hoc.

components/ui/
   Avatar Β· Badge Β· BrandIcons Β· Button Β· ConfirmDialog Β· EmptyState
   ErrorBoundary Β· IconButton Β· Input Β· Logo Β· Modal Β· PresenceDot
   Skeleton Β· Spinner
  • Modal is built on the native <dialog> β€” focus trap, Esc, top layer, inert background and focus restoration, all for free and all correct. Hand-rolled modals get every one of those wrong.
  • Input passes native constraints (required, pattern, maxLength) straight through β€” the browser validates for free, with correct accessibility. State-based validation is reserved for what the browser cannot know.
  • IconButton guarantees a 44 Γ— 44 px hit area on touch (WCAG 2.5.8) without changing a single pixel of the desktop layout.

Accessibility is a requirement, not a nice-to-have

  • βœ… Every action is reachable by keyboard β€” focus is trapped in drawers and modals, and restored to the trigger on close
  • βœ… Overlay drawers are inert β€” no invisible tab stops hiding behind a scrim
  • βœ… No meaning is carried by colour alone β€” unread, presence, ticks and admin each have a text/ARIA equivalent
  • βœ… Live regions announce new messages and toasts
  • βœ… prefers-reduced-motion is honoured
  • βœ… Both themes are built from the same design tokens, so contrast is a property of the token set rather than of individual components

⌨️ Known gap β€” documented, not hidden

There is no roving tabindex across the message list. Every action is keyboard-reachable, but crossing a 50-message room costs ~150 Tab presses. That is a re-architecture of the list's keyboard model, and it is deliberately deferred rather than quietly claimed as done.

Comments explain why, never what

// The window is measured from `createdAt`, not from the previous edit β€”
// otherwise editing every 59 minutes would keep a message editable forever.

The code already says what it does. A comment exists only to record a constraint the code cannot show.


πŸ§ͺ Testing β€” the five-layer pyramid

725 tests. Five layers. Each testing the thing it is genuinely good at.

Layer Tooling Location What it drives Count
Unit Jest backend/tests/unit Pure functions, guards 163
Integration Jest + Supertest backend/tests/integration Real API + real MongoDB 284
Component Vitest Β· RTL Β· MSW frontend/tests Rendered React, real axios 245
API E2E Playwright backend/e2e Two live servers + Valkey ⚑ 27
Browser E2E Playwright frontend/e2e Real Chromium, the real app 6
  • MSW mocks the network, not axios β€” so the interceptors, the 401β†’refreshβ†’retry, and the error normalisation all genuinely execute. Mocking axios would skip the very code most worth testing.
  • Browser E2E drives the real UI β€” a real Chromium signs in through the real email-OTP screens, creates a room, sends a message, and a second browser watches it arrive live.
  • Tests are centralised, mirroring src/. src/ contains source and nothing else.
# backend                          # frontend
npm run test:unit                  npm test            # Vitest
npm run test:integration           npm run test:e2e    # real browser
npm run test:e2e                   npm run build

⚠️ Run the E2E suites as their own CI step. Chaining them behind the unit suites causes database contention and false failures.


πŸ” Security

Every defence below is implemented and covered by a test.

Threat Defence
XSS Markup stripped at the source (stripHtml) + React escaping. Zero dangerouslySetInnerHTML.
NoSQL injection Mongo operators ($gt, $ne, dotted paths) stripped from body, params and query
ReDoS Every user-supplied search term is regex-escaped and length-capped before it reaches the database
CSRF SameSite=Lax plus a server-side Origin check that fails closed in production
Token theft 15-min JWT held in memory (never localStorage); 30-day opaque refresh token in an httpOnly, path-scoped cookie
Token replay Refresh tokens rotate; a replayed token revokes the whole family. Only a SHA-256 hash is ever stored.
Brute force Rate limiting Β· a 5-attempt OTP burn Β· and no account enumeration
Misconfiguration The server refuses to boot in production with a missing/weak JWT_SECRET or a wildcard CLIENT_ORIGIN
Algorithm confusion JWT algorithm pinned to HS256 on both sign and verify
Payload DoS 10 KB body limit Β· per-socket flood control
Headers helmet β€” nosniff, frame-options, no x-powered-by
Dependencies npm audit β†’ 0 vulnerabilities, both projects

πŸ“ Project structure

zentro/
β”œβ”€β”€ backend/
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ app.js            Express only β€” mountable by supertest, no listener
β”‚   β”‚   β”œβ”€β”€ server.js         Bootstrap: env β†’ db β†’ valkey ⚑ β†’ io β†’ listen
β”‚   β”‚   β”œβ”€β”€ config/           env Β· db Β· valkey ⚑ Β· passport
β”‚   β”‚   β”œβ”€β”€ constants/        Every magic value, in one place
β”‚   β”‚   β”œβ”€β”€ controllers/      HTTP in, HTTP out. No logic.
β”‚   β”‚   β”œβ”€β”€ services/         Business rules + authorization
β”‚   β”‚   β”œβ”€β”€ models/           Mongoose schemas + indexes
β”‚   β”‚   β”œβ”€β”€ middleware/       auth Β· validate Β· security Β· rateLimit Β· errorHandler
β”‚   β”‚   β”œβ”€β”€ routes/           URL β†’ handler
β”‚   β”‚   β”œβ”€β”€ socket/           Real-time handlers (membership re-checked per event)
β”‚   β”‚   β”œβ”€β”€ validators/       zod schemas
β”‚   β”‚   β”œβ”€β”€ lib/              io Β· logger Β· tokenStore Β· mailer
β”‚   β”‚   └── utils/            AppError Β· sanitize Β· serializers Β· notify
β”‚   β”œβ”€β”€ tests/{unit,integration}
β”‚   └── e2e/                  Playwright β€” TWO servers over Valkey ⚑
β”‚
β”œβ”€β”€ frontend/
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ components/{ui,chat,modals,auth}
β”‚   β”‚   β”œβ”€β”€ context/          Auth Β· Socket Β· Theme Β· Toast
β”‚   β”‚   β”œβ”€β”€ hooks/            Server-state + real-time bridges
β”‚   β”‚   β”œβ”€β”€ lib/              apiClient (ONE axios) Β· socket Β· tokenStore Β· queryKeys
β”‚   β”‚   β”œβ”€β”€ services/         API calls, grouped by resource
β”‚   β”‚   β”œβ”€β”€ pages/            Login Β· AuthCallback Β· Chat
β”‚   β”‚   └── styles/           tokens.css Β· global.css
β”‚   β”œβ”€β”€ tests/                Vitest Β· RTL Β· MSW
β”‚   └── e2e/                  Playwright β€” a real browser
β”‚
β”œβ”€β”€ DEPLOYMENT.md             ☁️  The full AWS runbook
└── README.md

πŸš€ Getting started

Prerequisites β€” Node 20+ Β· MongoDB Β· Valkey (or Redis)

docker run -d --name valkey -p 6379:6379 valkey/valkey
# 1 ─ Backend
cd backend
npm install
cp .env.example .env          # fill in MONGO_URI, JWT_SECRET, OAuth keys
npm run dev                   # β†’ :4000

# 2 ─ Frontend
cd frontend
npm install
npm run dev                   # β†’ :5173

Prove the adapter locally β€” run two servers

# terminal 1
PORT=4000 npm start

# terminal 2  β€” same MONGO_URI, same VALKEY_URL
PORT=4001 npm start

Open the app in two browsers, point each at a different port, and chat between them.

The messages cross because Valkey is carrying them. Stop Valkey, and the two servers immediately go deaf to one another β€” which is the clearest possible demonstration of what the adapter does.


☁️ Deployment on AWS

Zentro runs across two EC2 instances behind an Application Load Balancer, with a self-hosted Valkey node, MongoDB Atlas, an ACM certificate and Cloudflare DNS.

It is a genuine step-by-step production runbook, not a summary:

πŸ— System design Security groups, VPC, the complete topology
πŸ”΄ The Valkey node Docker, locked to the app security group β€” never exposed to 0.0.0.0/0
πŸ–₯ Golden AMI Build one app server, image it, launch the second from that image
πŸ”’ TLS ACM certificate + DNS validation β€” and the Cloudflare proxy trap that silently breaks it
βš–οΈ Load balancer Target groups, health checks, sticky sessions for the OAuth round-trip
🌐 Cloudflare DNS records and the grey-cloud requirement
πŸ› Troubleshooting Every real bug hit during the deployment β€” and its fix

The proof it works: curl the health endpoint repeatedly and the returned pid alternates between the two EC2 instances β€” while a chat message sent on one still lands instantly on the other, because Valkey is carrying it.


πŸ“‘ API reference

HTTP endpoints

Auth

Method Path Purpose
GET /api/auth/google Β· /api/auth/github Begin OAuth
GET /api/auth/callback/:provider OAuth callback
POST /api/auth/email/request Email a one-time code
POST /api/auth/email/verify Exchange the code for a session
POST /api/auth/refresh Rotate the refresh token
POST /api/auth/logout Revoke the session
GET /api/auth/me The current user

Rooms

Method Path Purpose
GET /api/rooms My rooms (with unread flags)
GET /api/rooms/discover Rooms I am not in
POST /api/rooms Create
PATCH DELETE /api/rooms/:id Update Β· delete (creator)
POST /api/rooms/:id/join Β· /leave Join (or request) Β· leave
POST /api/rooms/:id/invite Β· /invite/decline Invite Β· decline
GET POST /api/rooms/:id/requests[/:userId/approve|reject] Moderate join requests (admin)
POST DELETE /api/rooms/:id/admins/:userId Promote Β· demote (creator)

Messages

Method Path Purpose
GET /api/rooms/:id/messages?before=&limit= History (cursor-paginated)
GET /api/rooms/:id/messages/search?q= Search
GET /api/rooms/:id/messages/:messageId/replies A thread
GET /api/rooms/:id/members Β· /receipts Roster Β· read receipts
POST /api/rooms/:id/read Mark read
Socket.IO events β€” every one of these crosses servers via Valkey ⚑

Client β†’ Server room:join Β· room:leave Β· message:send Β· message:edit Β· message:delete Β· message:react Β· typing

Server β†’ Client ready Β· message:new Β· message:updated Β· message:deleted Β· presence:joined Β· presence:left Β· request:new Β· request:approved Β· request:rejected Β· room:invited Β· invite:declined Β· room:deleted Β· room:read Β· room:admin

Client contract: wait for ready, not connect. Handlers are registered synchronously, but the server finishes joining you to your rooms asynchronously β€” ready is the signal that it is safe to emit.


πŸ›  Tech stack

Real-time Socket.IO 4 · socket.io-valkey-adapter ⚑ · iovalkey
Backend Node 20 (ESM) Β· Express 4 Β· Mongoose 8 Β· Passport Β· zod Β· helmet
Frontend React 19 Β· Vite 8 Β· TanStack Query Β· axios Β· CSS Modules
Data MongoDB β€” the truth Β· Valkey β€” the wire
Testing Jest Β· Supertest Β· Vitest Β· React Testing Library Β· MSW Β· Playwright
Infrastructure AWS EC2 Β· ALB Β· ACM Β· MongoDB Atlas Β· Cloudflare Β· Docker

πŸ“„ License

MIT

Built by @webdevelopersrinu and powered by socket.io-valkey-adapter ⚑

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages