A private, self-hosted web app for 1-on-1 screen sharing. One person shares their screen, the other watches it live. No audio, no camera, no chat, no accounts, no recording. Video travels peer-to-peer over WebRTC; the server only relays signaling messages.
This is a private internal tool — optimized for simplicity and reliability, not scale or polish.
Browser (creator) ⇄ WebRTC P2P video ⇄ Browser (partner)
⇅ ⇅
└──── WebSocket signaling ───────────┘
│
Go signaling server
web/— Next.js (App Router, TypeScript) frontend.signaling/— Go WebSocket signaling server. In-memory, no database.
cp .env.example .env
docker compose up --build
# open http://localhost:3000- Open http://localhost:3000 and click Create meeting.
- Copy the room link and open it in a second browser window (or send it to your partner).
- In that window click Share my screen and accept the browser prompt.
- Live screen video appears in the first window within ~2 seconds.
Set these in .env before docker compose up --build.
Public vars are inlined into the frontend at build time by Next.js, so changing them requires a rebuild:
| Variable | Default | Purpose |
|---|---|---|
NEXT_PUBLIC_SIGNALING_URL |
ws://localhost:8080/ws |
WebSocket URL of the signaling server, as reached from the browser. |
NEXT_PUBLIC_ICE_URL |
http://localhost:8080/ice |
HTTP endpoint returning STUN + short-lived TURN credentials. |
NEXT_PUBLIC_STUN_URL |
stun:stun.l.google.com:19302 |
Fallback STUN server, used only if /ice can't be reached. |
Secret vars are read by the Go server at runtime (rotating them only needs a restart, not a rebuild) and never reach the browser:
| Variable | Default | Purpose |
|---|---|---|
TURN_TOKEN_ID |
(empty) | Cloudflare TURN Token ID. |
TURN_API_TOKEN |
(empty) | Cloudflare TURN API token — secret. |
If both TURN vars are empty, the server serves STUN only.
getDisplayMedia (screen capture) only works in a secure context:
https:// or http://localhost. Local Docker use on localhost works out of
the box.
To use ScreenLink across the internet you must serve it over HTTPS — e.g.
behind Caddy, Traefik, or a Cloudflare Tunnel — and point
NEXT_PUBLIC_SIGNALING_URL at the wss:// URL of the proxied signaling server.
Reverse-proxy setup is out of scope for this repo.
STUN alone is enough when both peers are on normal home/office networks, but it fails when a peer's firewall, antivirus, or symmetric NAT blocks the direct peer-to-peer path — the call connects but no video ever arrives. A TURN server fixes this by relaying the media.
ScreenLink supports Cloudflare's TURN service:
-
In the Cloudflare dashboard, go to Realtime → TURN and create a TURN app.
-
Copy the TURN Token ID and API Token into
.env:TURN_TOKEN_ID=your-turn-token-id TURN_API_TOKEN=your-api-token
-
docker compose up --build.
How it works: the API token stays server-side. On each call the browser
fetches GET /ice, and the Go server uses the token to mint short-lived
TURN credentials from Cloudflare and returns them (plus STUN). WebRTC still
prefers a direct path and only relays through TURN when it must. If the /ice
fetch fails, the frontend falls back to NEXT_PUBLIC_STUN_URL so good networks
keep working.
The API token is a secret: it lives only in
.env(gitignored) and is never prefixedNEXT_PUBLIC_, so it never ends up in the browser bundle. If it is ever exposed, rotate it in the Cloudflare dashboard and update.env.
JSON messages over the WebSocket. The server relays offer, answer, and ice
opaquely to the other peer, and originates peer-joined / peer-left / error.
{ "type": "peer-joined" }
{ "type": "peer-left" }
{ "type": "offer", "sdp": "..." }
{ "type": "answer", "sdp": "..." }
{ "type": "ice", "candidate": { ... } }
{ "type": "error", "message": "Room is full" }Negotiation uses a fixed-role model: the sharer always creates the offer
(after receiving peer-joined), the viewer answers.
GET /ws?room={id}— upgrades to a WebSocket and joins the room.GET /ice— returns{ "iceServers": [...] }(STUN + short-lived TURN credentials) for the browser'sRTCPeerConnection.GET /healthz— returns200 ok(used by the Docker healthcheck).
Behavior:
- In-memory map of
roomID -> [max 2 connections]. - A 3rd connection to a room gets
{"type":"error","message":"Room is full"}and is closed. - Every received message is relayed unmodified to the other peer.
- On a peer joining, both peers get
{"type":"peer-joined"}. - On a peer leaving, the remaining peer gets
{"type":"peer-left"}. - Empty rooms are deleted. Graceful shutdown on SIGTERM/SIGINT.
cd signaling
go run . # listens on :8080, override with LISTEN_ADDR
go vet ./...- Screen sharing needs a user gesture — it can never auto-start on page
load. The sharer clicks a button, which triggers
getDisplayMedia. - Secure context required (see "Serving over the internet" above).
- The browser's native "Stop sharing" bar is handled by listening for the
video track's
endedevent, which tears down the peer connection and notifies the viewer.
The WebRTC/signaling logic lives in a few well-commented files inside web/:
web/src/
├── lib/
│ ├── signaling.ts # typed WebSocket message helpers
│ ├── ice.ts # fetches ICE servers (STUN + TURN) from /ice
│ ├── webrtc.ts # RTCPeerConnection helpers (no `any` types)
│ └── room-controller.ts # signaling + WebRTC state machine
├── hooks/
│ └── useRoom.ts # React binding around RoomController
└── app/
├── page.tsx # landing page ("Create meeting")
└── room/[id]/page.tsx # meeting room (viewer / sharer)
The web/Dockerfile builds a standalone image, which needs:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;-
docker compose up --buildstarts both services with no manual steps. - Create meeting → room URL with a copy button.
- Second window → Share my screen → live video in the first within ~2s.
- Stop sharing (button or browser bar) → viewer sees "Partner stopped sharing".
- Closing either tab → other side shows "Partner disconnected".
- A third window on the same room is rejected with "Room is full".
- Refreshing either tab recovers a working session.
- No audio tracks are ever requested or transmitted.
-
go vetpasses; TypeScript compiles; noanyin the WebRTC module.