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:
@@ -21,3 +21,11 @@ TURN_CREDENTIAL=
|
||||
|
||||
# Optional: BizGaze webhook endpoint for session events.
|
||||
# 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=
|
||||
|
||||
@@ -25,6 +25,26 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
npm:
|
||||
external: true
|
||||
|
||||
@@ -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
|
||||
@@ -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(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 = {
|
||||
PORT: process.env.PORT || 8090,
|
||||
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
|
||||
LIVEKIT_URL,
|
||||
LIVEKIT_API_KEY,
|
||||
LIVEKIT_API_SECRET,
|
||||
LIVEKIT_ENABLED,
|
||||
PUBLIC_DIR,
|
||||
REC_DIR,
|
||||
TRANS_DIR,
|
||||
|
||||
+40
-1
@@ -83,9 +83,27 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
|
||||
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
|
||||
const CALLS = require('./calls');
|
||||
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
|
||||
|
||||
// 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.
|
||||
function issueRefreshToken(userId) {
|
||||
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 });
|
||||
});
|
||||
|
||||
// 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.
|
||||
route('POST', '/api/calls/decline', async (req, res) => {
|
||||
const u = currentUser(req);
|
||||
|
||||
Reference in New Issue
Block a user