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.
π Homin Demo
- Guaranteed Delivery via RabbitMQ - every user owns a durable, lazy queue (
user.<id>) bound to thechat.directexchange. Messages are publishedpersistent, consumed withnoAck: 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 fromdeliveredAt/readAton load and mutated live frommessage_delivered/messages_readframes.markDeliveredis awaited before the message frame is sent, so the two sources can never disagree. - Live Presence, Scoped to Contacts -
user_online/user_offlinereach 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; Securein 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 aneditedmarker, 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
503when 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 upbrings up RabbitMQ, backend and frontend, with a healthcheck gate so the API never boots into a broker that is not listening yet.
ββββββββββββββββ 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:
- Client sends
send_messageover WS; the payload is validated by a pure function before anything else happens. - The recipient's queue is asserted and bound before archiving - refusing the send beats writing history for a message the broker cannot route.
- The row is inserted into
archived_messagesbefore publishing - nothing can be delivered that is missing from history. - The consumer marks
deliveredAt, then emitsnew_messagewith a delivery tag; the client answersack, 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.
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)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:3000The 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.
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 |
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.
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 # browseTables: 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.
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