lechat

voice, video, screen share, and chat for a group of friends, with no service in the middle

one windows app that contains its own server. whoever launches it first on a machine is the host, friends reach it over tailscale funnel, and there are no accounts. livekit carries the media.

electron 43, react 19, express 5, node:sqlite, livekit, tailscale funnel, electron-builder

a voice room with participants and a shared screen
a call. the screenshots come from the app's own screenshot script, which runs two instances against a seeded database with invented people
a text channel with messages
a text channel
the settings screen
settings

LeChat is the app my friends and I use instead of a hosted chat service. The constraint is stated in the architecture doc: it is one app that contains a server, whoever launches it first on a machine becomes the host, and there is no central service, no accounts, and no database beyond a SQLite file on the host’s disk. Everything else follows from that. The host’s home machine has to be reachable from the public internet without port forwarding, and the app has to install and run with no Docker and no Node on anyone’s machine.

architecture

how do friends reach a server that lives on one person's home machine?

Tailscale Funnel hands raw TLS on 443 to a small mux that terminates once and splits by protocol, because LiveKit hardcodes turns:<domain>:443.

stored state risk or limit
friends' LeChat client mode Tailscale Funnel :443, raw TLS host's own window Electron renderer mux.js :8444 TLS once, split by first bytes Express :3000 web, /api, /sfu proxy SQLite chat, rooms LiveKit TURN :10000 livekit-server :7880 SFU https + wss http, signaling STUN-framed bytes 127.0.0.1 /sfu/* signaling media, relayed host machine: whoever launched the app first CGNAT at home: forwarded ports are unreachable, so public media rides TURN.

the mux decides on the first 8 bytes with a 1.5 s fallback. one hostname and one certificate cover the web app, signaling, and TURN. a second launch that hits EADDRINUSE becomes a client instead of a second server.

Electron main owns the window, tray, global hotkeys, the screen picker, and child supervision. It imports the Express server in-process. The renderer is a React app loaded from the local server through a thin preload bridge.

The SFU is a spawned livekit-server.exe, fetched at build time and shipped as an extra resource. Main probes port 7880 first and reuses whatever answers, otherwise it spawns the binary with keys and --node-ip on the command line and drains both stdio pipes into the app log.

Signaling rides the app’s own origin. Express proxies /sfu/* to the SFU, so one hostname and one certificate cover the web app and signaling. Media never crosses that proxy. Public ingress is Tailscale Funnel passing raw TLS on 443 to a small mux on 8444, which terminates TLS once and splits by protocol: STUN-framed bytes go to LiveKit’s TURN on 10000, everything else to Express on 3000.

Discovery is a bootstrap endpoint that returns every address the host knows: tailnet IP, port, public URL. Clients measure them and adopt the fastest that answers, with a margin, and never switch mid-call. Text chat is REST into SQLite refreshed by a two-second poll. Soundboard triggers ride the LiveKit reliable data channel. Screen share uses desktopCapturer with loopback audio, filtering out the app’s own window.

technical decisions

  • An SFU instead of a mesh. Rooms are 6 to 10 people. With adaptive stream, dynacast, simulcast, DTX and RED, each client uploads once.
  • No accounts. Identity is a UUID in localStorage sent as a header and trusted by the server. The URL is the invite. The README’s security section says plainly that anyone who reaches the server can do anything.
  • One app hosts. A second launch that hits EADDRINUSE logs “running as client” instead of starting a second server.
  • Funnel and relay-only by default. No port forwarding and no certificate work for the host. Under CGNAT a forwarded port is unreachable anyway, so public media rides TURN. LiveKit is ICE-lite, so a hosted TURN service cannot rescue an SFU that cannot be dialed.
  • SQLite in WAL mode for users, rooms, channels, messages and the clip index. Clips are files under a generated id. Configuration is an env file.
  • One-click NSIS installer that preserves app data on uninstall.

hard problems

  • Sharing 443 between the web app and TURN, because LiveKit hardcodes turns:<domain>:443. The mux decides on the first 8 bytes with a 1.5 s fallback.
  • Removing the app’s own playback from loopback capture so friends do not hear themselves. Measured with an echo probe: 0.53 peak RMS without restrictOwnAudio, noise floor with it.
  • Certificates for both the .ts.net name and a custom domain, the latter pulled from a tailnet relay.
  • Pinning LiveKit to real network adapters so the TURN relay does not bind a virtual one.
  • Log rolling on Windows, which renames only after the stream closes.
  • WASAPI session enumeration off the main thread to badge audible windows in the screen picker.

numbers

measurevalue
version1.1.0, LiveKit pinned at 1.9.12
room limits16 participants, 300 s empty timeout, 4 h token TTL
share presets14, 8, 5 and 1.5 Mbps
round tripabout 300 ms through a distant Funnel frontend, 23 ms through a same-city DERP