Two people open a shared room link and transfer files directly between their browsers. Files are end-to-end encrypted (AES‑256‑GCM with ECDH key agreement) and never touch a server — the only server-side component is a tiny WebSocket that relays connection handshakes (SDP/ICE) and never sees file data.
- No upload limits — files stream in 256 KB chunks, never fully buffered in memory (tested design target: multi‑GB files).
- End‑to‑end encrypted — keys are derived per‑session in the browser and never leave it.
- Ephemeral — received files live in IndexedDB and auto‑delete 10 minutes after completion. Rooms die when the host leaves or after 30 min idle.
- Resumable — if a peer reloads mid‑transfer, it resumes from the last stored chunk.
The full original engineering specification lives in docs/SPEC.md.
Requires Node.js ≥ 20.18.0.
npm install
cp .env.example .env.local # defaults already work for local dev
npm run devnpm run dev starts two processes (this is intentional — it mirrors the
production/Vercel split and avoids a clash between Next.js's dev HMR socket and
our signaling socket):
- Next.js on http://localhost:3000
- Signaling WebSocket on ws://localhost:3001/api/signal
The client finds the signaling server via NEXT_PUBLIC_SIGNALING_URL in
.env.local. Open http://localhost:3000.
To actually transfer a file you need two browser contexts (the two "peers"):
- In window A, click Create a Room → you get a code like
X7K2PQand a share link. - Copy the link into window B (a second tab, an incognito window, or another device on your network). Two normal tabs in the same browser work fine for testing.
- Once both show Connected, drag a file into either side and click Send File.
- The receiver gets a Download button and a 10‑minute auto‑delete countdown.
Use two separate browser profiles/incognito windows so each gets its own
sessionStorageidentity. Same-origin localhost peer-to-peer works without any TURN server.
| Script | What it does |
|---|---|
npm run dev |
Dev: Next.js (:3000) + standalone signaling server (:3001) as two processes. |
npm run dev:next |
Next.js dev only (if you run signaling yourself). |
npm run signal |
Standalone WebSocket signaling server only (port 3001). |
npm run build |
Production Next.js build. |
npm run start |
Production single host: Next.js (:3000) + signaling (:3001) as two processes (front with Caddy — see DEPLOY.md). |
npm run start:vercel |
next start only — frontend without the bundled WebSocket (Vercel). |
npm run start:combined / dev:combined |
Single-port Next+WS server — not browser-safe (Next attaches its own WS upgrade handler); kept for reference only. |
npm run typecheck |
tsc --noEmit. |
npm run lint |
Next.js ESLint. |
Browser A ──(encrypted file chunks over WebRTC DataChannel)──▶ Browser B
│ │
└──────────── SDP / ICE / public keys (JSON) ───────────────────┘
│
WebSocket signaling server
(room registry only — never sees files)
- Signaling (
lib/signaling/) — a WebSocket room registry. Pairs two peers, relays their WebRTC offer/answer/ICE and ECDH public keys, enforces 2‑peers‑per‑room, rate‑limits room creation, validatesOrigin, and reaps idle rooms. State is in‑memory and intentionally ephemeral. - WebRTC (
lib/webrtc/) — reliable, orderedRTCDataChannel. The creator is always the initiator (no glare). Backpressure viabufferedAmountevents, ICE restart on failure. - Crypto (
lib/crypto/) — ECDH P‑256 → AES‑GCM‑256. Each chunk gets a fresh 12‑byte IV. A pure‑TS streaming SHA‑256 lets us checksum huge files with constant memory. - Transfer (
lib/transfer/) —FileSenderchunks/encrypts/sends with a sliding ACK window, retransmits, and resume;FileReceiverdecrypts, stores chunks in IndexedDB, and verifies the whole‑file checksum before confirming. - Storage (
lib/storage/) —idbwrapper for chunks + transfer records, with the 10‑minute auto‑delete rule. - Orchestration (
lib/room/RoomController.ts) — ties it all together and projects state into a Zustand store the React UI renders from.
See the directory map in docs/SPEC.md §4.
Copy .env.example → .env.local and edit. All variables are documented inline there.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SIGNALING_URL |
WebSocket URL of the signaling server. Blank = same origin (combined local/Node server). Set this for Vercel/split deploys. |
NEXT_PUBLIC_APP_URL |
Public base URL used for share links. |
SIGNALING_ALLOWED_ORIGINS |
Comma‑separated allow‑list of origins the signaling server accepts. Blank = allow all (dev only). |
SIGNAL_PORT |
Port for the standalone signaling server (npm run signal). |
NEXT_PUBLIC_TURN_URL / _USERNAME / _CREDENTIAL |
Optional TURN server for users behind symmetric NATs. |
TURN matters in production. Without a TURN server, ~10–15% of real‑world peer pairs (corporate / carrier‑grade NAT) can't form a direct connection. Managed options: Metered, Twilio, Cloudflare Calls; or self‑host
coturn.
The catch with WebRTC apps: signaling needs a persistent WebSocket, and Vercel's serverless functions cannot hold one open. So pick one of these:
Deploy the whole thing to a host that runs a long‑lived Node process: Render, Railway, Fly.io, a VPS, etc.
npm run build
npm run start # serves the app AND the signaling WebSocket on $PORTSet env vars on the host before building (NEXT_PUBLIC_* are inlined at
build time):
NODE_ENV=production
NEXT_PUBLIC_APP_URL=https://your-app.example.com
SIGNALING_ALLOWED_ORIGINS=https://your-app.example.com
# leave NEXT_PUBLIC_SIGNALING_URL blank — client uses same origin (/api/signal)
Nothing else to wire up; in production Next.js attaches no HMR socket, so the
combined server's WebSocket and HTTP coexist on one port. The client connects to
/api/signal on the same origin.
This is the path for shipping the UI to Vercel.
-
Deploy the signaling server to a Node host (Render/Railway/Fly). Run
npm run signal. Note its public URL, e.g.https://peerdrop-signal.onrender.com. Set on that host:SIGNALING_ALLOWED_ORIGINS=https://your-app.vercel.app -
Deploy the frontend to Vercel. Import the repo; Vercel auto‑detects Next.js (build
next build, output handled automatically). Set these Vercel env vars:NEXT_PUBLIC_SIGNALING_URL=wss://peerdrop-signal.onrender.com/api/signal NEXT_PUBLIC_APP_URL=https://your-app.vercel.app # TURN vars if you have themThe CSP
connect-srcinnext.config.jsreadsNEXT_PUBLIC_SIGNALING_URLand whitelists that origin automatically. -
Redeploy. The browser loads from Vercel and opens its signaling WebSocket against your separate server.
Switching between environments is just
.envchanges — no code edits. BlankNEXT_PUBLIC_SIGNALING_URL→ same-origin combined server; set it → split deploy (Vercel + separate signaling). RememberNEXT_PUBLIC_*is baked in at build time, so rebuild/redeploy after changing it.
- ✅
npm run typecheck— clean (strict mode). - ✅
npm run build— production build succeeds. - ✅ Servers boot;
/api/healthresponds; signaling WebSocket accepts connections. - ✅ Signaling protocol integration‑tested with two/three clients: room create/join, OFFER/ANSWER/ICE/PUBKEY relay,
ROOM_FULLon a third peer,PEER_LEFTon disconnect, reconnect grace, origin enforcement. - ✅ Crypto/framing unit‑tested: streaming SHA‑256 matches Web Crypto, ECDH+AES‑GCM round‑trips between two parties, chunk header pack/parse round‑trips.
- ✅ Full two‑peer transfer in real headless Chrome: room create → join → WebRTC connect → encrypted chunked send → receive → SHA‑256 verified (
status: complete) → Download button + 10‑minute auto‑delete countdown. Verified the received file's chunk count and size in IndexedDB.
Still worth exercising by hand in normal browsers: resume‑on‑reload mid‑transfer, the 10‑minute deletion actually firing, very large files (multi‑GB), and cross‑browser (Firefox/Safari). Use the Quick start two‑window flow above; the full manual checklist is in docs/SPEC.md §20.
Chrome/Edge 120+, Firefox 120+, Safari 17+. WebRTC, Web Crypto (ECDH/AES‑GCM), and IndexedDB are all required.
- File bytes never reach any server — only encrypted chunks over the peer‑to‑peer DataChannel.
- E2E encryption uses ephemeral ECDH keys; the signaling server cannot decrypt anything even though it relays the public keys.
- Signaling logs only room codes and peer UUIDs — never file names or sizes.
- CSP,
X-Frame-Options: DENY,nosniff, and origin validation are configured (next.config.js,lib/signaling/server.ts). - Room codes are 6 chars from a 32‑symbol unambiguous alphabet — fine for ephemeral rooms, not meant as a long‑term secret.
A condensed view (full tree in docs/SPEC.md §4):
app/ Next.js App Router pages + API routes (health, signal info)
components/ UI primitives (ui/) and room screens (room/)
hooks/ useRoom, useTransfer, useCountdown
lib/
crypto/ E2ECrypto (ECDH/AES-GCM) + streaming sha256
webrtc/ PeerConnection, DataChannelManager, IceConfig
transfer/ FileSender, FileReceiver
storage/ FileStore (IndexedDB via idb)
signaling/ server (Node handler) + SignalingClient (browser)
room/ RoomController (orchestration)
store/ Zustand room store
server.ts combined Next + WebSocket server
server/signaling-standalone.ts WebSocket-only server (for Vercel split)