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
Tailscale Funnel hands raw TLS on 443 to a small mux that terminates once and splits by protocol, because LiveKit hardcodes turns:<domain>:443.
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
EADDRINUSElogs “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.netname 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
| measure | value |
|---|---|
| version | 1.1.0, LiveKit pinned at 1.9.12 |
| room limits | 16 participants, 300 s empty timeout, 4 h token TTL |
| share presets | 14, 8, 5 and 1.5 Mbps |
| round trip | about 300 ms through a distant Funnel frontend, 23 ms through a same-city DERP |