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:
@@ -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