From 8bcb6def4396006adb2150f735d4895de27c344e Mon Sep 17 00:00:00 2001 From: Shinchan493 Date: Tue, 14 Jul 2026 22:20:11 +0530 Subject: [PATCH] Dockerize the stack; release camera hardware on toggle off Add full containerization and fix a camera-privacy issue, rebased onto the latest main (the resizable code/whiteboard split already landed upstream, so that local change was dropped in favor of the upstream one). Docker: - server/Dockerfile (multi-stage: build TS -> run compiled dist) - client/Dockerfile + nginx.conf (CRA build served by nginx) - docker-compose.yml (db + server + client, one command) - .dockerignore for both; .env.example templates - db.config: DB_SSL=false disables SSL for the local plaintext Postgres container while managed hosts (Render/Neon) keep SSL by default Camera privacy: - toggling the camera off now STOPS the video track (releases the hardware/indicator light) and removes it from peers, instead of only disabling frames; turning it on re-acquires and re-shares the track. Sessions also join with the camera fully released, not just muted. Windows dev + tooling: - cross-env + ts-node/register so `npm run serve` works on Windows - .gitignore: keep .env.example, ignore node_modules/coverage/logs - README: Docker Compose quickstart + Docker deployment notes Co-Authored-By: Claude Opus 4.8 --- .gitignore | 22 +++++++++- README.md | 61 +++++++++++++++++++++++++- client/.dockerignore | 9 ++++ client/.env.example | 15 +++++++ client/Dockerfile | 21 +++++++++ client/nginx.conf | 18 ++++++++ client/src/pages/room/VideoCall.tsx | 53 +++++++++++++++++++---- docker-compose.yml | 67 +++++++++++++++++++++++++++++ server/.dockerignore | 10 +++++ server/.env.example | 54 +++++++++++++++++++++++ server/Dockerfile | 19 ++++++++ server/package-lock.json | 33 ++++++++++++-- server/package.json | 7 +-- server/src/config/db.config.ts | 12 +++--- 14 files changed, 378 insertions(+), 23 deletions(-) create mode 100644 client/.dockerignore create mode 100644 client/.env.example create mode 100644 client/Dockerfile create mode 100644 client/nginx.conf create mode 100644 docker-compose.yml create mode 100644 server/.dockerignore create mode 100644 server/.env.example create mode 100644 server/Dockerfile diff --git a/.gitignore b/.gitignore index ecea23b..af79f3d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 9af8622..34ffac3 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 /verse-server:latest ./server +docker build -t /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. diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 0000000..aac8f84 --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1,9 @@ +node_modules +build +npm-debug.log* +.env +.env.* +!.env.example +.git +Dockerfile +.dockerignore diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..abae862 --- /dev/null +++ b/client/.env.example @@ -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= diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..8b88d3a --- /dev/null +++ b/client/Dockerfile @@ -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;"] diff --git a/client/nginx.conf b/client/nginx.conf new file mode 100644 index 0000000..6150c06 --- /dev/null +++ b/client/nginx.conf @@ -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"; + } +} diff --git a/client/src/pages/room/VideoCall.tsx b/client/src/pages/room/VideoCall.tsx index 760f0a9..79e541a 100644 --- a/client/src/pages/room/VideoCall.tsx +++ b/client/src/pages/room/VideoCall.tsx @@ -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); @@ -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); + } } }; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..75810d7 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..cfb073f --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +npm-debug.log* +.env +.env.* +!.env.example +src/db/config.json +.git +Dockerfile +.dockerignore diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..959cfea --- /dev/null +++ b/server/.env.example @@ -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 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..b3e8220 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/package-lock.json b/server/package-lock.json index 178cc03..6455b34 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -44,6 +44,7 @@ "@types/supertest": "^2.0.11", "@types/validator": "^13.7.2", "babel-jest": "^27.5.1", + "cross-env": "^10.1.0", "jest": "^27.5.1", "nodemon": "^2.0.15", "prettier": "^2.5.1", @@ -1958,6 +1959,13 @@ "node": ">=12" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3672,11 +3680,30 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", diff --git a/server/package.json b/server/package.json index 3fc4182..7cb7374 100644 --- a/server/package.json +++ b/server/package.json @@ -4,10 +4,10 @@ "description": "", "main": "index.js", "scripts": { - "serve": "NODE_ENV=development nodemon src/server.ts", + "serve": "cross-env NODE_ENV=development nodemon --watch src --ext ts,json --exec \"node -r ts-node/register\" src/server.ts", "build": "tsc", - "start": "NODE_ENV=development node dist/src/server.js", - "test": "NODE_ENV=test jest --detectOpenHandles", + "start": "cross-env NODE_ENV=development node dist/src/server.js", + "test": "cross-env NODE_ENV=test jest --detectOpenHandles", "pretty": "npx prettier --write ." }, "author": "", @@ -25,6 +25,7 @@ "@types/supertest": "^2.0.11", "@types/validator": "^13.7.2", "babel-jest": "^27.5.1", + "cross-env": "^10.1.0", "jest": "^27.5.1", "nodemon": "^2.0.15", "prettier": "^2.5.1", diff --git a/server/src/config/db.config.ts b/server/src/config/db.config.ts index 99de8dc..a22e026 100644 --- a/server/src/config/db.config.ts +++ b/server/src/config/db.config.ts @@ -11,12 +11,12 @@ const sequelize = }) : new Sequelize(env.DATABASE_URL, { dialect: 'postgres', - dialectOptions: { - ssl: { - require: true, - rejectUnauthorized: false, - }, - }, + // SSL is required by managed hosts (Render/Neon), but a local Postgres + // container speaks plaintext — set DB_SSL=false to disable it there. + dialectOptions: + process.env.DB_SSL === 'false' + ? {} + : { ssl: { require: true, rejectUnauthorized: false } }, logging: false, });