"use node"; import { action } from "./_generated/server"; import { v } from "convex/values"; import { AccessToken, RoomServiceClient } from "livekit-server-sdk"; /** * Generate a LiveKit join token for a voice channel. * * LiveKit servers run with `room.auto_create: false` reject joins for * rooms that don't already exist — the client gets a 404 "requested * room does not exist" back from the /rtc/v1/validate endpoint. To * make this deployment-agnostic, we pre-create the room via the * LiveKit Server SDK before minting the token. When auto-create is * enabled the `createRoom` call is idempotent (409 Conflict is * swallowed silently), so the same code path works on both * configurations. * * Requires `LIVEKIT_URL` (or the frontend's `VITE_LIVEKIT_URL` as a * fallback) in the Convex environment so the RoomServiceClient * can talk to the LiveKit API. */ export const getToken = action({ args: { channelId: v.string(), userId: v.string(), username: v.string(), }, returns: v.object({ token: v.string() }), handler: async (_ctx, args) => { const apiKey = process.env.LIVEKIT_API_KEY || "devkey"; const apiSecret = process.env.LIVEKIT_API_SECRET || "secret"; const livekitUrl = process.env.LIVEKIT_URL || process.env.VITE_LIVEKIT_URL || ""; // Ensure the room exists. The LiveKit API accepts `http(s)` URLs // for the management endpoint, but the frontend connect URL is a // `wss://` — swap the scheme when needed. if (livekitUrl) { const httpUrl = livekitUrl .replace(/^wss:\/\//i, "https://") .replace(/^ws:\/\//i, "http://"); try { const roomService = new RoomServiceClient(httpUrl, apiKey, apiSecret); await roomService.createRoom({ name: args.channelId, // Empty rooms auto-destroy after 5 minutes with no participants, // matching LiveKit's own default so stale rooms from a crashed // client don't pile up forever. emptyTimeout: 5 * 60, // 50 participants is plenty for a voice channel in this // single-server deployment and keeps any runaway join loop // from hitting the global limit. maxParticipants: 50, }); } catch (err: any) { // 409 / "already exists" is expected when a room has already // been created by an earlier join — swallow it and continue. const message = String(err?.message ?? err ?? ""); const status = err?.status ?? err?.statusCode; const alreadyExists = status === 409 || /already exists/i.test(message) || /AlreadyExists/i.test(message); if (!alreadyExists) { // Non-fatal: log and fall through to token generation. If the // real issue was misconfiguration the client will surface the // 404 it already does. console.warn("LiveKit createRoom failed:", message); } } } const at = new AccessToken(apiKey, apiSecret, { identity: args.userId, name: args.username, ttl: "24h", }); at.addGrant({ roomJoin: true, room: args.channelId, canPublish: true, canSubscribe: true, canPublishData: true, }); const token = await at.toJwt(); return { token }; }, });