A self-hosted Slack-style chat platform. Two services:
backend/— Node.js (Express + ws) + TypeScript + SQLite. REST for reads/writes, WebSocket for live updates.frontend/— Vite + React + TypeScript + Tailwind. Premium dark theme with a coral accent.
Features in the box:
- Workspaces with multiple users (real signup + JWT auth)
- Public + private channels, member-scoped visibility
- Direct messages (channels with
kind=dm) - Threads — replies are messages with
parent_id, surfaced in a right drawer - Live presence — online dots driven by WebSocket connection count per user
- Typing indicator — "Alice is typing…" with 5 s expiry, dedupe per user
- File sharing — drag/drop, paste image, server stores under
uploads/ - Reactions — toggle emoji, broadcast updated counts
- Edit / delete own messages — both fan out via WS
- Full-text search (SQLite FTS5) across every channel you're in (⌘K / Ctrl+K)
- Reconnecting WS client with exponential backoff + auto re-subscribe
- Heartbeat reaper — server pings every 25 s, terminates sockets that miss a pong, marks the user offline
┌───────────────────────┐
│ React + Vite (5173) │
│ ─ TanStack Query │
│ ─ zustand store │
│ ─ realtime client │
└─────────┬─────────────┘
HTTP /api/* │ WebSocket /ws (token via subprotocol)
┌──────────────────┼──────────────────┐
▼ ▼
Express routes ws/gateway.ts
(auth, REST CRUD, ─ socket map (id → user, subs)
file upload, search) ─ channel subscriptions
│ ─ presence refcount per user
│ ─ typing relay (excl. self)
└──────┬──────┐ │
▼ ▼ ▼
SQLite (WAL) broadcastToChannel
─ FTS5 mirror broadcastToWorkspacesOfUser
Requires Node 18+.
# 1. Backend
cp backend/.env.example backend/.env
# Edit backend/.env: set JWT_SECRET to a long random string.
npm install
npm run seed # creates the Acme workspace + 4 users with realistic chat
npm run dev:backend
# In a second terminal:
npm run dev:frontendOpen http://localhost:5173.
Sign in with any of the seeded users:
| handle | password | |
|---|---|---|
| alice@acme.dev | alice | loopdemo123 |
| bob@acme.dev | bob | loopdemo123 |
| carol@acme.dev | carol | loopdemo123 |
| dave@acme.dev | dave | loopdemo123 |
To see live presence + typing, sign in as a different user in a second browser (or an incognito window). When you type in one window, the typing indicator shows in the other.
Token auth via Sec-WebSocket-Protocol: bearer.<jwt>. Query-param fallback
?token= is accepted for tools like wscat.
Client → server envelopes:
type |
payload |
|---|---|
subscribe |
{ channel_ids: string[] } |
unsubscribe |
{ channel_ids: string[] } |
typing |
{ channel_id: string, parent_id?: string } |
Server → client envelopes:
type |
payload |
|---|---|
hello |
{ user_id, online_user_ids: string[] } |
message:created |
full message DTO (with reactions, files, reply_count) |
message:updated |
full message DTO |
message:deleted |
{ id, channel_id, parent_id } |
message:reactions |
{ message_id, channel_id, reactions: [...] } |
user:typing |
{ user_id, channel_id, parent_id? } |
user:online |
{ user_id } |
user:offline |
{ user_id } |
All /api/* (except /api/auth/*) require Authorization: Bearer <jwt>.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/auth/signup |
Create account, returns token + user |
| POST | /api/auth/login |
Sign in |
| GET | /api/workspaces |
Workspaces I'm in |
| POST | /api/workspaces |
Create workspace (auto #general) |
| GET | /api/workspaces/:id/members |
Users in workspace |
| GET | /api/workspaces/:id/channels |
Channels + DMs I can see |
| POST | /api/workspaces/:id/channels |
Create channel |
| POST | /api/workspaces/:id/dms |
Open/reuse a DM with { user_id } |
| POST | /api/channels/:id/join |
Join a public channel |
| POST | /api/channels/:id/read |
Mark channel read |
| GET | /api/channels/:id/messages |
?before=<iso>&parent_id=&limit= |
| POST | /api/channels/:id/messages |
Send message (multipart: body+files) |
| PATCH | /api/messages/:id |
Edit own message |
| DELETE | /api/messages/:id |
Delete own message |
| POST | /api/messages/:id/react |
Toggle a reaction { emoji } |
| GET | /api/files/:id |
Download attachment |
| GET | /api/search?q=… |
FTS5 search across my channels |
| GET | /api/online |
Current online user IDs |
- Channels and DMs share one table (
channels.kind ∈ {channel, dm}). Messages are uniform. The frontend renders a DM with the other user's name instead of a channel name. Simpler than two parallel models. - Threads are messages with
parent_id. Top-level listing filtersparent_id IS NULL; the thread drawer fetchesparent_id = <message>. Reply counts are summarised in the SELECT for cheap rendering. - FTS5 mirror table + triggers keep search in sync without a separate
indexing job. Tokenizer:
unicode61 remove_diacritics 2so "naïve" matches "naive". - Presence by refcount, not by ping. A user is online iff at least one of their sockets is alive. This handles "open 3 tabs, close 2" correctly.
- Typing indicator is broadcast-excluding-self. The server drops the echo so the client UI doesn't have to special-case it.
- Authorisation for every WS sub: caller must already be a member of the channel. A malicious client can't subscribe to a private channel by guessing the ID.
- Every destructive action (delete message, sign out) goes through
<ConfirmModal>— nowindow.confirmanywhere.
backend/
src/
main.ts Express bootstrap, mounts gateway
env.ts, db.ts sqlite schema + WAL + FTS triggers
auth.ts bcrypt + JWT helpers, requireAuth middleware
routes/
auth.ts signup, login, /me
workspaces.ts CRUD + members
channels.ts channels, join, DMs, read receipts
messages.ts list (cursor), send (multer), edit, delete,
reactions, file download, search (FTS5)
ws/gateway.ts the WebSocket gateway (presence, typing, broadcast)
scripts/seed.ts realistic seed
frontend/
src/
main.tsx router + QueryClient
index.css Tailwind + design tokens
lib/
api.ts fetch wrapper with bearer token
auth.ts localStorage session
realtime.ts reconnecting WS client, sub re-establish
store.ts zustand: onlineUsers + typing
types.ts shared TS types (envelopes, DTOs)
format.ts date helpers
components/
Modal.tsx, ConfirmModal.tsx, Avatar.tsx
pages/
SignInPage.tsx, SignUpPage.tsx
WorkspacePicker.tsx
AppShell.tsx gates auth, wires WS, hosts cmd-k search
Sidebar.tsx workspace rail + channel/DM sidebar
ChannelView.tsx header, day-grouped message list, typing banner
MessageRow.tsx hover toolbar, reactions, edit/delete, files
Composer.tsx multiline + paste/drop + Enter-to-send + typing
ThreadDrawer.tsx reply pane
SearchModal.tsx cmd+k FTS5 results
- No video/voice. WebRTC is a different problem; we're chat-only.
- No mobile push notifications. Browser desktop notifications could be
wired in
realtime.on('message:created')— left as an exercise. - No huddles / screen share. Same reason as above.
- No SSO / SCIM. Self-hosted single-team scope.
- No emoji picker library. Common-8 set in the hover toolbar; native emoji input works in the composer.
MIT.