feat(meetings): LiveKit SFU — phase 1 (server plumbing, config-gated)

Adds the server side of scaling meetings past the ~5-peer mesh:
- config.js: LIVEKIT_URL/API_KEY/API_SECRET + LIVEKIT_ENABLED flag. All optional;
  when unset the app keeps the built-in P2P mesh (fully additive, like push).
- routes.js: GET /api/meetings/config (tells the client sfu on/off + wss url) and
  POST /api/meetings/token (mints a per-user, per-room LiveKit join token — hand-rolled
  HS256 JWT like the FCM/APNs tokens, no new dependency; secret stays server-side).
- docker-compose.yml: optional livekit service (single-node, no Redis), keys injected
  via LIVEKIT_KEYS from the same .env; media over published UDP 50000-50100 + TCP 7881,
  signaling proxied by NPM.
- livekit.yaml + .env.example documented.

Client (mesh->LiveKit media swap, behind the flag) lands in phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:11:37 +05:30
parent 30e354d58f
commit f1dbcd0f86
5 changed files with 102 additions and 1 deletions
+8
View File
@@ -21,3 +21,11 @@ TURN_CREDENTIAL=
# Optional: BizGaze webhook endpoint for session events. # Optional: BizGaze webhook endpoint for session events.
# BIZGAZE_WEBHOOK_URL= # BIZGAZE_WEBHOOK_URL=
# Optional: LiveKit SFU for meetings (scales past the ~5-peer P2P mesh). Set ALL THREE to enable;
# leave unset to keep the built-in mesh. The app mints join tokens with the secret (server-side
# only); the same key/secret feed the livekit container via LIVEKIT_KEYS in docker-compose.
# Generate a key/secret pair: two random strings, e.g. `openssl rand -hex 16` for each.
# LIVEKIT_URL=wss://livekit.bizgaze.com
# LIVEKIT_API_KEY=
# LIVEKIT_API_SECRET=
+20
View File
@@ -25,6 +25,26 @@ services:
networks: networks:
- npm - npm
# LiveKit SFU — meeting media server. Optional: only started/used when the app's .env has
# LIVEKIT_URL/API_KEY/API_SECRET set (otherwise meetings use the built-in P2P mesh). NPM proxies
# wss://livekit.bizgaze.com -> livekit:7880 (signaling); media flows over the published UDP/TCP
# ports below, NOT through NPM. Single-node (no Redis) — consistent with the app's single-instance rule.
livekit:
image: livekit/livekit-server:v1.7
container_name: bizgaze-livekit
restart: unless-stopped
command: --config /etc/livekit.yaml
environment:
# key: secret, sourced from the same .env as the app so both sign/verify with the same secret.
- "LIVEKIT_KEYS=${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
ports:
- "7881:7881" # WebRTC over TCP (fallback)
- "50000-50100:50000-50100/udp" # WebRTC media (UDP) — must match livekit.yaml port range
networks:
- npm
networks: networks:
npm: npm:
external: true external: true
+21
View File
@@ -0,0 +1,21 @@
# LiveKit SFU config (non-secret — the API key/secret are injected via the LIVEKIT_KEYS env var
# in docker-compose, sourced from .env, so nothing secret lives in git).
#
# Media plane: LiveKit needs UDP reachable from clients (NPM only proxies the HTTP/WS signaling on
# 7880). The UDP range + TCP fallback below are published as HOST ports in docker-compose. On the
# VPS, if the server sits behind NAT and can't auto-detect its public IP, set rtc.node_ip to it.
port: 7880 # signaling (HTTP/WS) — NPM proxies wss://livekit.bizgaze.com -> here
rtc:
tcp_port: 7881 # WebRTC-over-TCP fallback (restrictive networks)
port_range_start: 50000 # WebRTC media (UDP) — keep in sync with the published range in compose
port_range_end: 50100
use_external_ip: true # discover the public IP for ICE candidates (VPS). Or set node_ip below.
# node_ip: 118.95.33.89 # uncomment + set if use_external_ip can't detect the public IP
# Embedded TURN over TLS on 443 helps clients on locked-down networks. Left off by default because
# NPM already owns 443; enable via a dedicated hostname + NPM stream if you need it (see DEPLOY.md).
turn:
enabled: false
logging:
level: info
+13
View File
@@ -14,9 +14,22 @@ try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {} try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {}
try { fs.mkdirSync(DOWNLOADS_DIR, { recursive: true }); } catch (e) {} try { fs.mkdirSync(DOWNLOADS_DIR, { recursive: true }); } catch (e) {}
// LiveKit SFU (scales meetings past the ~5-peer mesh ceiling). Entirely optional and config-gated:
// when LIVEKIT_URL/API_KEY/API_SECRET are all set the client uses LiveKit for meeting media; when
// they're unset the app falls back to the built-in P2P mesh, unchanged. The API secret is used
// ONLY server-side to mint per-user join tokens — it never reaches the browser.
const LIVEKIT_URL = process.env.LIVEKIT_URL || ''; // wss://livekit.bizgaze.com
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || '';
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || '';
const LIVEKIT_ENABLED = !!(LIVEKIT_URL && LIVEKIT_API_KEY && LIVEKIT_API_SECRET);
module.exports = { module.exports = {
PORT: process.env.PORT || 8090, PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443, HTTPS_PORT: process.env.HTTPS_PORT || 8443,
LIVEKIT_URL,
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET,
LIVEKIT_ENABLED,
PUBLIC_DIR, PUBLIC_DIR,
REC_DIR, REC_DIR,
TRANS_DIR, TRANS_DIR,
+40 -1
View File
@@ -83,9 +83,27 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence'); const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls'); const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL } = require('./config'); const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED } = require('./config');
const crypto = require('crypto');
const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment
// Mint a LiveKit access token (HS256 JWT signed with the API secret) — same hand-rolled JWT
// approach as push.js's FCM/APNs tokens, so no extra dependency. Grants the holder join+publish+
// subscribe on exactly one room, as one identity. Secret stays server-side.
const _b64u = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
function livekitToken(identity, name, room, metadata) {
const nowSec = Math.floor(Date.now() / 1000);
const header = _b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = _b64u(JSON.stringify({
iss: LIVEKIT_API_KEY, sub: identity, name: name || identity,
nbf: nowSec, exp: nowSec + 6 * 3600, // 6h — long enough for any meeting
metadata: metadata || '',
video: { room, roomJoin: true, canPublish: true, canSubscribe: true, canPublishData: true },
}));
const sig = _b64u(crypto.createHmac('sha256', LIVEKIT_API_SECRET).update(header + '.' + payload).digest());
return header + '.' + payload + '.' + sig;
}
// Issue a refresh token (native clients), store only its hash, return the plaintext once. // Issue a refresh token (native clients), store only its hash, return the plaintext once.
function issueRefreshToken(userId) { function issueRefreshToken(userId) {
const rtok = A.token(32); const rtok = A.token(32);
@@ -865,6 +883,27 @@ route('POST', '/api/calls/invite', async (req, res) => {
json(res, 200, { ok: true, invited: ids.length }); json(res, 200, { ok: true, invited: ids.length });
}); });
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route('GET', '/api/meetings/config', (req, res) => {
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' });
});
// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
// The room-membership/host authorization already happens over the meeting WebSocket; this only
// hands the client a media-plane credential scoped to that room and its own identity.
route('POST', '/api/meetings/token', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
const { room } = await readBody(req);
const rm = String(room || '').trim();
if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' });
const token = livekitToken(u.id, u.name || u.email, rm, metadata);
json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email });
});
// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call. // Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
route('POST', '/api/calls/decline', async (req, res) => { route('POST', '/api/calls/decline', async (req, res) => {
const u = currentUser(req); const u = currentUser(req);