Skip to content
yahohuliaPublic

About

Real-time chat where RabbitMQ owns delivery: durable per-user queues mean nothing is lost while you are offline. Nuxt 3, Express 5, WebSocket.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

πŸ’¬ Homin - Real-Time Messenger on RabbitMQ

Full-stack real-time chat built with Nuxt 3, Express 5, WebSocket and RabbitMQ. Messages are not pushed straight from socket to socket - every message goes through a durable per-user broker queue, so nothing is lost when the recipient is offline, refreshes the page, or reconnects an hour later. SQLite/libSQL keeps the cold archive; RabbitMQ owns delivery.

🎯 Demo Showcase

πŸš€ Live Demo

πŸ‘‰ Homin Demo

πŸ… Features

  • Guaranteed Delivery via RabbitMQ - every user owns a durable, lazy queue (user.<id>) bound to the chat.direct exchange. Messages are published persistent, consumed with noAck: false, and only acked once the client confirms receipt - an unacked message is redelivered on the next reconnect instead of disappearing.
  • Real-Time WebSocket Layer - HTTP and WS share one port and one http.Server. A single dispatcher routes typed frames (auth, ack, send_message, read_receipt) to one handler per event, authorising before dispatch.
  • Two-Tick Message Status - sent β†’ delivered β†’ read, derived from deliveredAt / readAt on load and mutated live from message_delivered / messages_read frames. markDelivered is awaited before the message frame is sent, so the two sources can never disagree.
  • Live Presence, Scoped to Contacts - user_online / user_offline reach only people you actually have a conversation with, never every open socket. A brand-new conversation is synced by the send path itself, so both sides get a truthful status from the first message.
  • Session-Based Auth with better-auth - email + password, unique usernames, cross-site cookies (SameSite=None; Secure in production), and a guard applied at the router mount points rather than repeated in each handler. The WS layer validates the raw session token against the same table and re-checks expiry in memory on every frame.
  • Delete for Me / Delete for Everyone - per-user message hiding (hidden_message), sender-only hard delete, message editing with an edited marker, per-user chat hiding that self-heals on new activity, and a real two-way history wipe.
  • Avatar Upload via Cloudinary - face-cropped 256Γ—256 transforms, optional: the endpoint answers a clean 503 when the credentials are absent instead of crashing.
  • Auto-Reconnect with Backoff - exponential + jittered reconnect on the client, capped at 30 s, and stopped entirely when the token was rejected rather than looping forever.
  • Fully Dockerised - one docker compose up brings up RabbitMQ, backend and frontend, with a healthcheck gate so the API never boots into a broker that is not listening yet.

πŸ’» Tech Stack

Nuxt Vue TypeScript Tailwind Express RabbitMQ WebSocket Drizzle SQLite Docker Vitest Cloudinary

🧭 Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   HTTP + cookies    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Nuxt 3     β”‚ ──────────────────▢ β”‚  Express 5  :4000           β”‚
β”‚     SPA      β”‚ ◀────────────────── β”‚  routes β†’ controllers β†’     β”‚
β”‚    :3000     β”‚      WebSocket      β”‚  services β†’ models          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ◀──────────────────▢│                            β”‚
                                     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                            β”‚           β”‚
                                   publish/ β”‚           β”‚ archive
                                   consume  β–Ό           β–Ό
                                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                   β”‚  RabbitMQ    β”‚  β”‚ SQLite/libSQL β”‚
                                   β”‚ chat.direct  β”‚  β”‚  cold archive β”‚
                                   β”‚ user.<id> Γ—N β”‚  β”‚               β”‚
                                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Backend layers, and nothing skips one: routes wire method + path, controllers validate request shape (no ORM imports allowed), services own every query and validate state, models hold tables. The WS handlers call the same services as the HTTP controllers, so archiving and un-hiding have exactly one implementation each.

Message flow - the ordering is load-bearing:

  1. Client sends send_message over WS; the payload is validated by a pure function before anything else happens.
  2. The recipient's queue is asserted and bound before archiving - refusing the send beats writing history for a message the broker cannot route.
  3. The row is inserted into archived_messages before publishing - nothing can be delivered that is missing from history.
  4. The consumer marks deliveredAt, then emits new_message with a delivery tag; the client answers ack, and only then is the broker message acknowledged.

Presence is in-memory (Set / Map in ws/presence.ts), which means the backend is deliberately single-instance as written - a second node would split the presence view.

πŸš€ Quick Start (Docker)

git clone <repository-url> homin
cd homin

cp env.example .env
docker compose up -d
Service URL
Frontend http://localhost:3000
Backend API http://localhost:4000
RabbitMQ UI http://localhost:15672 Β· guest / guest

Migrations run automatically before the server starts, so a fresh volume boots with a schema.

docker compose logs -f backend   # follow backend logs
docker compose down              # stop
docker compose down -v           # stop and drop volumes (destroys the database)

πŸ› οΈ Local Setup (without Docker)

Requires Node 20+ and a RabbitMQ instance listening on :5672.

# Backend
cd backend
cp .env.example .env
npm install
npm run db:migrate
npm run dev            # β†’ http://localhost:4000

# Frontend (second terminal)
cd frontend
cp .env.example .env   # leave NUXT_API_INTERNAL_URL empty outside Docker
npm install
npm run dev            # β†’ http://localhost:3000

The backend calls process.exit(1) if RabbitMQ is unreachable after 10 attempts - that is deliberate: an API that silently cannot deliver messages is worse than one that refuses to boot.

πŸ”Œ API Reference

Everything under /api is guarded by a session cookie except /api/auth/* and /api/session.

Method Endpoint Description
ALL /api/auth/* better-auth (sign-up, sign-in, sign-out, session)
GET /api/session Current session - unguarded on purpose
GET /api/chat-data Contacts + all visible messages + WS session token
POST /api/chat/hide Hide a chat for yourself; reappears on new activity
POST /api/chat/clear-history Delete the conversation for both sides
DELETE /api/messages/:id Delete for everyone (sender only)
PATCH /api/messages/:id Edit your own message
POST /api/messages/:id/hide Delete for me
GET /api/users/search?q= Find people by username
GET /api/users/:id Public profile (id, name, username, image)
POST /api/user/update-profile Change display name / email
POST /api/user/update-username Change username (unique)
POST /api/user/upload-avatar Upload a base64 avatar to Cloudinary
GET /health Liveness probe used by the compose healthcheck

WebSocket Protocol

ws://localhost:4000 - same port as the API.

Client β†’ server

Frame Payload
auth { token }
send_message { receiverId, content, tempId }
ack { deliveryTag }
read_receipt { withUserId }

Server β†’ client

Frame Payload
authenticated -
new_message { deliveryTag, data }
message_delivered { messageId }
messages_read { byUserId, messageIds }
user_online / user_offline { userId }
error { message }

The client-generated tempId becomes the permanent database primary key, so there is no optimistic-ID reconciliation anywhere in the codebase.

πŸ—ƒοΈ Database

Drizzle ORM over libSQL. Schema lives in backend/src/models/ - never hand-write SQL:

cd backend
npm run db:generate   # edit schema.ts first, then generate the migration
npm run db:migrate    # apply
npm run db:studio     # browse

Tables: user, session, account, verification (better-auth) plus archived_messages, hidden_contact, hidden_message.

The two hide tables behave differently on purpose: nothing ever un-hides a message. A chat you hid becoming active again is the point; a message you deleted for yourself reappearing is not.

πŸ§ͺ Tests & Quality Gates

cd backend && npm test              # vitest - 27 tests over the pure ws/ helpers
cd backend && npm run test:watch

cd backend  && npm run typecheck    # tsc --noEmit
cd frontend && npm run typecheck    # nuxt typecheck

cd backend  && npm run format       # Prettier, one config at the repo root
cd frontend && npm run format

About

Real-time chat where RabbitMQ owns delivery: durable per-user queues mean nothing is lost while you are offline. Nuxt 3, Express 5, WebSocket.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages