Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,28 @@
# Dependencies
node_modules/
*/node_modules

# Environment / secrets
.env
.env.*
!.env.example

# Local DB config (Sequelize)
server/src/db/config.json

# Build output
client/build
.DS_Store
server/dist

# Test / coverage
coverage/

# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# OS / editor
.DS_Store
Thumbs.db
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,47 @@ Verse lets teams create together in the same moment: write documents with live c

## Getting started (local)

### Prerequisites
Two ways to run Verse locally:
- **[Option A — Docker Compose](#option-a--docker-compose-whole-stack)** — one command brings up the database, API, and frontend. Easiest.
- **[Option B — Run services manually](#option-b--run-services-manually)** — Node dev servers with hot reload, best for active development.

---

### Option A — Docker Compose (whole stack)

Requires only **Docker Desktop** (running). From the repo root:

```bash
docker compose up --build
```

That builds and starts three containers:

| Service | Container | URL / Port |
|---------|-----------|------------|
| **client** | nginx-served React build | http://localhost:3000 |
| **server** | Node + Socket.IO API | http://localhost:3001 |
| **db** | PostgreSQL 16 | localhost:5432 |

Open **http://localhost:3000**, register, and start a live session. Tables are created on first boot and data persists in the `pgdata` volume.

```bash
docker compose up -d --build # rebuild + run in the background
docker compose logs -f server # follow API logs
docker compose down # stop (keeps data in the pgdata volume)
docker compose down -v # stop and DELETE the database volume
```

Notes:
- **Port 5432 conflict:** stop any other local Postgres first, or change the published port in `docker-compose.yml`.
- **Changing the API URL:** the client bakes `REACT_APP_API_URL` in at *build* time (`client/Dockerfile`), so rebuild the client image after changing it (`docker compose build client`).
- The compose file ships with dev-only secrets — replace them for any non-local use.

---

### Option B — Run services manually

#### Prerequisites
- Node.js 18+
- Docker (for Postgres + a local mail catcher)

Expand Down Expand Up @@ -131,4 +171,23 @@ Open **http://localhost:3000**, register, and start writing — or hit **Start a

## Deployment

### Render (blueprint)

The repo includes a `render.yaml` blueprint (Postgres + Node API + static frontend). The frontend needs `REACT_APP_API_URL` set to the API URL (with a trailing slash); the API needs `FRONT_END_URL` set to the frontend origin (for CORS). WebRTC video requires HTTPS, which hosts like Render provide.

### Docker

The project is fully containerized (`server/Dockerfile`, `client/Dockerfile` + `client/nginx.conf`, and `docker-compose.yml`). Run the whole stack with `docker compose up --build` (see [Option A](#option-a--docker-compose-whole-stack)), or build/push images individually:

```bash
docker build -t <registry>/verse-server:latest ./server
docker build -t <registry>/verse-client:latest \
--build-arg REACT_APP_API_URL=https://api.example.com/ ./client
```

Production notes:
- **Database SSL** — `server/src/config/db.config.ts` requires SSL by default (managed Postgres). Against a plaintext database (local compose container) set `DB_SSL=false`; leave it unset in production.
- **Single instance** — real-time doc/room state is in memory, so run **one** server replica.
- **Env vars** — the server validates all required keys on boot (`server/src/config/env.config.ts`); provide them via your platform, not a committed `.env`.
- **HTTPS** — WebRTC video needs HTTPS in production; terminate TLS at your reverse proxy.
- **Client API URL** — CRA inlines `REACT_APP_API_URL` at build time, so the frontend image is environment-specific; rebuild per target URL.
9 changes: 9 additions & 0 deletions client/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules
build
npm-debug.log*
.env
.env.*
!.env.example
.git
Dockerfile
.dockerignore
15 changes: 15 additions & 0 deletions client/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# ---------------------------------------------------------------------------
# Client environment variables (Create React App)
#
# Only vars prefixed with REACT_APP_ are exposed to the browser bundle.
# Copy to `.env` (or `.env.local`) for local development:
# cp .env.example .env
# ---------------------------------------------------------------------------

# Base URL of the backend API. MUST end with a trailing slash.
# Defaults to http://localhost:3001/ if unset (see src/services/api.ts).
REACT_APP_API_URL=http://localhost:3001/

# Optional tldraw license key for the whiteboard. Leave blank to run the
# unlicensed (watermarked) build (see src/pages/room/Whiteboard.tsx).
REACT_APP_TLDRAW_LICENSE_KEY=
21 changes: 21 additions & 0 deletions client/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1

# ---- Build stage: produce the static CRA bundle ----
FROM node:20-slim AS build
WORKDIR /app
# .npmrc carries legacy-peer-deps=true (react-scripts 5 vs TypeScript 5).
COPY package*.json .npmrc ./
RUN npm install
COPY . .
# CRA inlines REACT_APP_* at BUILD time, so the API URL is baked in here.
ARG REACT_APP_API_URL=http://localhost:3001/
ENV REACT_APP_API_URL=$REACT_APP_API_URL
# CI=false so build warnings are not treated as errors.
RUN CI=false npm run build

# ---- Runtime stage: serve static files with nginx ----
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
18 changes: 18 additions & 0 deletions client/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;

root /usr/share/nginx/html;
index index.html;

# SPA fallback: unknown paths return index.html so React Router handles them.
location / {
try_files $uri $uri/ /index.html;
}

# Cache hashed static assets aggressively.
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
53 changes: 44 additions & 9 deletions client/src/pages/room/VideoCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,14 @@ const VideoCall = ({ socket, me, mode, onModeChange }: VideoCallProps) => {
stream.getTracks().forEach((t) => t.stop());
return;
}
// Tracks stay acquired (so toggling on is instant and peers get them)
// but start disabled — muted mic, no video frames.
stream.getTracks().forEach((t) => (t.enabled = false));
// Join muted with the camera truly OFF: the mic track stays acquired
// (disabled) so unmuting is instant, but the video track is STOPPED and
// removed so the camera hardware/indicator is released until turned on.
stream.getAudioTracks().forEach((t) => (t.enabled = false));
stream.getVideoTracks().forEach((t) => {
t.stop();
stream.removeTrack(t);
});
localStreamRef.current = stream;
setLocalStream(stream);

Expand Down Expand Up @@ -292,13 +297,43 @@ const VideoCall = ({ socket, me, mode, onModeChange }: VideoCallProps) => {
socket.emit('room:media', mediaStateRef.current);
}
};
const toggleCam = () => {
const track = localStream?.getVideoTracks()[0];
if (track) {
track.enabled = !track.enabled;
setCamOn(track.enabled);
mediaStateRef.current = { ...mediaStateRef.current, camOn: track.enabled };
// Turning the camera off STOPS the video track so the hardware is released
// (indicator light off); turning it on re-acquires and re-shares with peers.
const toggleCam = async () => {
const stream = localStreamRef.current;
if (!stream) return;

if (camOn) {
stream.getVideoTracks().forEach((track) => {
Object.values(peersRef.current).forEach((peer) => {
try {
peer.removeTrack(track, stream);
} catch {
/* peer may not have negotiated this track yet */
}
});
track.stop();
stream.removeTrack(track);
});
setLocalStream(new MediaStream(stream.getTracks()));
setCamOn(false);
mediaStateRef.current = { ...mediaStateRef.current, camOn: false };
socket.emit('room:media', mediaStateRef.current);
} else {
try {
const cam = await navigator.mediaDevices.getUserMedia({ video: true });
const newTrack = cam.getVideoTracks()[0];
stream.addTrack(newTrack);
Object.values(peersRef.current).forEach((peer) => {
peer.addTrack(newTrack, stream);
});
setLocalStream(new MediaStream(stream.getTracks()));
setCamOn(true);
mediaStateRef.current = { ...mediaStateRef.current, camOn: true };
socket.emit('room:media', mediaStateRef.current);
} catch {
setCamOn(false);
}
}
};

Expand Down
67 changes: 67 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Full local stack: Postgres + Node/Socket.IO API + nginx-served React build.
# Bring it all up with: docker compose up --build
#
# Notes:
# - server runs a SINGLE instance on purpose (Yjs collab docs are in-memory).
# - REACT_APP_API_URL is baked into the client image at BUILD time; change it
# here and rebuild (docker compose build client) if the API URL changes.

services:
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: collab_docs
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d collab_docs']
interval: 5s
timeout: 5s
retries: 10

server:
build:
context: ./server
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3001
FRONT_END_URL: http://localhost:3000
# Uses the DATABASE_URL branch of db.config, but DB_SSL=false disables SSL
# so it can talk to the plaintext local Postgres container.
DATABASE_URL: postgres://postgres:postgres@db:5432/collab_docs
DB_SSL: 'false'
ACCESS_TOKEN_SECRET: dev-access-secret
ACCESS_TOKEN_EXPIRATION: 15m
REFRESH_TOKEN_SECRET: dev-refresh-secret
REFRESH_TOKEN_EXPIRATION: 7d
VERIFY_EMAIL_SECRET: dev-verify-secret
PASSWORD_RESET_SECRET: dev-reset-secret
PASSWORD_RESET_EXPIRATION: 1h
# No SMTP locally -> verify users immediately on signup.
SMTP_HOST: unused
SMTP_USER: unused
SMTP_PASSWORD: unused
AUTO_VERIFY_USERS: 'true'
ports:
- '3001:3001'
depends_on:
db:
condition: service_healthy

client:
build:
context: ./client
args:
# Browser hits the server via the host-published port, not the compose
# network — so this must be localhost, not the "server" service name.
REACT_APP_API_URL: http://localhost:3001/
ports:
- '3000:80'
depends_on:
- server

volumes:
pgdata:
10 changes: 10 additions & 0 deletions server/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
dist
npm-debug.log*
.env
.env.*
!.env.example
src/db/config.json
.git
Dockerfile
.dockerignore
54 changes: 54 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ---------------------------------------------------------------------------
# Server environment variables
#
# The server loads `.env.${NODE_ENV}` (see src/server.ts), so copy this file to
# the matching name rather than plain `.env`:
# cp .env.example .env.development # for `npm run dev` (NODE_ENV=development)
# cp .env.example .env.test # for the test suite (NODE_ENV=test)
# In production (Render) these are set as dashboard env vars, not a file.
# All values below are placeholders — replace them.
# ---------------------------------------------------------------------------

# --- Core (required) -------------------------------------------------------
NODE_ENV=development
HOST=0.0.0.0
PORT=3001
# Frontend origin — used for CORS and the Socket.IO allowed origin.
# Must match where the client runs (no trailing slash).
FRONT_END_URL=http://localhost:3000

# --- Database (required) ---------------------------------------------------
# Production connects with this single URL.
DATABASE_URL=postgres://collab_docs:password@localhost:5432/collab_docs

# Individual DB vars below are ONLY used by the local dev/test Sequelize
# config (see src/config/env.config.ts). Leave blank if you use DATABASE_URL.
USER=
PASSWORD=
DB_HOST=localhost
DB_PORT=5432
DATABASE=collab_docs

# --- JWT secrets (required) ------------------------------------------------
# Use long random strings, e.g. `openssl rand -hex 32`. Never commit real ones.
ACCESS_TOKEN_SECRET=replace-with-random-secret
ACCESS_TOKEN_EXPIRATION=15m
REFRESH_TOKEN_SECRET=replace-with-random-secret
REFRESH_TOKEN_EXPIRATION=7d
VERIFY_EMAIL_SECRET=replace-with-random-secret
PASSWORD_RESET_SECRET=replace-with-random-secret
PASSWORD_RESET_EXPIRATION=1h

# --- Email / SMTP (required) -----------------------------------------------
# Real provider for prod. For local dev you can point at a mail catcher
# (e.g. maildev on port 1025) and set SMTP_PORT=1025, SMTP_SECURE=false.
SMTP_HOST=smtp.example.com
SMTP_USER=your-smtp-user
SMTP_PASSWORD=your-smtp-password
# Optional — defaults: SMTP_PORT=465, SMTP_SECURE=true (see smtp.config.ts)
SMTP_PORT=465
SMTP_SECURE=true

# --- Optional flags --------------------------------------------------------
# Skip email verification and mark users verified on signup (handy without SMTP).
AUTO_VERIFY_USERS=false
19 changes: 19 additions & 0 deletions server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1

# ---- Build stage: install all deps and compile TypeScript ----
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# ---- Runtime stage: only production deps + compiled output ----
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
EXPOSE 3001
CMD ["node", "dist/src/server.js"]
Loading