@broberg/call 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/server.cjs +8 -1
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +8 -1
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ done.files; // [{ name, size, durat
|
|
|
92
92
|
- **Consent first.** It records every participant. Get each person's consent (BID purpose `call-recording`) BEFORE calling `startRecording`.
|
|
93
93
|
- **Cold start:** when the recording machine is asleep, the picture joins after ~20 s; when it is warm, after under 2 s. Tell people recording is starting, not that it has.
|
|
94
94
|
- The request carries a 5-minute ticket signed with YOUR app's LiveKit key and bound to ONE room — an app can only start, stop or read recordings of the room it names.
|
|
95
|
-
- A refusal from the service is `VoiceError` `recording_failed` (status + reason in the message), never a recording. A `controlUrl` that is not http
|
|
95
|
+
- A refusal from the service is `VoiceError` `recording_failed` (status + reason in the message), never a recording. A `controlUrl` that is not https (plain http is allowed only for localhost) is `invalid_control_url` — the request carries a recording ticket.
|
|
96
96
|
- States: `recording` · `stopped` (after `stopRecording`) · `complete` (the room emptied by itself) · `failed` (with `error`). After a stop, `files` is absent until the file is written.
|
|
97
97
|
- Webhooks: `egress_started` and `egress_ended` arrive through `verifyWebhook` like every other event; `event.egressInfo.fileResults` names the file.
|
|
98
98
|
- In the browser, `call.recording` / `call.onRecording` tell EVERY participant that the room is being recorded — show it.
|
package/dist/server.cjs
CHANGED
|
@@ -71,7 +71,14 @@ async function recordingTicket(room, credentials) {
|
|
|
71
71
|
return token.toJwt();
|
|
72
72
|
}
|
|
73
73
|
async function control(path, room, opts, init) {
|
|
74
|
-
|
|
74
|
+
let u;
|
|
75
|
+
try {
|
|
76
|
+
u = new URL(opts.controlUrl);
|
|
77
|
+
} catch {
|
|
78
|
+
throw new VoiceError("invalid_control_url", "controlUrl must be an https URL");
|
|
79
|
+
}
|
|
80
|
+
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
81
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && local)) throw new VoiceError("invalid_control_url", "controlUrl must be https (http only for localhost)");
|
|
75
82
|
const res = await fetch(opts.controlUrl.replace(/\/+$/, "") + path, {
|
|
76
83
|
...init,
|
|
77
84
|
headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, "content-type": "application/json" },
|
package/dist/server.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"names":["TrackSource","AccessToken","WebhookReceiver"],"mappings":"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","file":"server.cjs","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\n//\n// The app never carries audio. It decides who may enter a room, signs a short\n// ticket for that one room, and hears from the media server (LiveKit) through a\n// signed webhook when people join and leave. Built on LiveKit's own SDK so the\n// ticket format and the webhook signature are LiveKit's, not ours.\n\nimport { AccessToken, TrackSource, WebhookReceiver } from \"livekit-server-sdk\";\nimport type { WebhookEvent } from \"livekit-server-sdk\";\n\nexport type { WebhookEvent };\n\nexport type VoiceErrorCode =\n | \"missing_credentials\"\n | \"invalid_room\"\n | \"invalid_identity\"\n | \"invalid_ttl\"\n | \"invalid_sources\"\n | \"invalid_webhook\"\n | \"invalid_control_url\"\n | \"recording_failed\";\n\nexport class VoiceError extends Error {\n constructor(\n readonly code: VoiceErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"VoiceError\";\n }\n}\n\nexport interface Credentials {\n apiKey: string;\n apiSecret: string;\n}\n\nexport interface RoomTokenOptions extends Credentials {\n /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */\n room: string;\n /** Stable id of the person — the same person joining twice replaces the first connection. */\n identity: string;\n /** Display name other participants see. */\n name?: string;\n /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */\n ttl?: number;\n /**\n * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear\n * everyone but publish nothing (no microphone, no data). Default `true`.\n * Pair it with `joinVoice({ publish: false })` in the browser.\n */\n canPublish?: boolean;\n /**\n * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\n};\n\nexport const DEFAULT_TTL_SECONDS = 600;\nexport const MAX_TTL_SECONDS = 3600;\n\nfunction requireCredentials({ apiKey, apiSecret }: Partial<Credentials>): void {\n // LiveKit's AccessToken falls back to LIVEKIT_API_KEY/SECRET from the\n // environment when these are missing — a ticket signed with a key the caller\n // never chose. Refuse instead.\n if (!apiKey || !apiSecret) {\n throw new VoiceError(\"missing_credentials\", \"apiKey and apiSecret are required\");\n }\n}\n\n/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\nexport async function createRoomToken(opts: RoomTokenOptions): Promise<string> {\n requireCredentials(opts);\n if (!opts.room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n if (!opts.identity?.trim()) throw new VoiceError(\"invalid_identity\", \"identity is required\");\n const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;\n if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {\n throw new VoiceError(\"invalid_ttl\", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);\n }\n\n const speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\n}\n\n/**\n * Verify a webhook from the media server and return its event.\n * `body` must be the RAW request body — re-serialised JSON changes the hash.\n */\nexport async function verifyWebhook(\n body: string,\n authHeader: string | null | undefined,\n credentials: Credentials,\n): Promise<WebhookEvent> {\n requireCredentials(credentials);\n try {\n // A missing header is refused by LiveKit itself (\"authorization header is empty\").\n return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? undefined);\n } catch (e) {\n throw new VoiceError(\"invalid_webhook\", `webhook rejected: ${(e as Error).message}`);\n }\n}\n\n// ---- F104.9.5 — recording ----------------------------------------------------\n//\n// The recording itself runs beside LiveKit (broberg-voice-control starts a\n// recording machine only while something is being recorded). The app asks for\n// it with a 5-minute ticket signed by ITS OWN LiveKit key, bound to ONE room —\n// so an app can only ever start, stop or read recordings of rooms it names.\n// Webhooks `egress_started` / `egress_ended` arrive through `verifyWebhook`\n// like every other event; `event.egressInfo` carries the file.\n\nexport interface RecordingOptions extends Credentials {\n /** The recording service, e.g. `https://broberg-voice-control.fly.dev`. */\n controlUrl: string;\n}\n\nexport interface RecordingFile {\n name: string;\n size: number;\n durationSec: number;\n}\n\nexport interface Recording {\n recordingId: string;\n egressId?: string;\n room: string;\n /**\n * `recording` while it runs; `stopped` after stopRecording; `complete` if the\n * room emptied by itself; `failed` (with `error`) if it could not be made.\n */\n state: \"recording\" | \"stopped\" | \"complete\" | \"failed\";\n attempts?: number;\n error?: string;\n /** Absent until the file is written — after a stop, poll getRecording until it is there. */\n files?: RecordingFile[];\n}\n\nasync function recordingTicket(room: string, credentials: Credentials): Promise<string> {\n requireCredentials(credentials);\n if (!room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n const token = new AccessToken(credentials.apiKey, credentials.apiSecret, { ttl: 300 });\n token.addGrant({ room, roomRecord: true });\n return token.toJwt();\n}\n\nasync function control(path: string, room: string, opts: RecordingOptions, init: { method: string; body?: string }): Promise<Recording> {\n if (!/^https?:\\/\\//.test(opts.controlUrl ?? \"\")) throw new VoiceError(\"invalid_control_url\", \"controlUrl must be an http(s) URL\");\n const res = await fetch(opts.controlUrl.replace(/\\/+$/, \"\") + path, {\n ...init,\n headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, \"content-type\": \"application/json\" },\n signal: AbortSignal.timeout(90_000), // a cold recording machine takes ~20 s to start\n });\n const text = await res.text();\n let body: any;\n try { body = text ? JSON.parse(text) : {}; } catch { body = { error: text.slice(0, 200) }; }\n if (!res.ok) throw new VoiceError(\"recording_failed\", `recording service answered ${res.status}: ${body?.error ?? \"unknown\"}`);\n return body as Recording;\n}\n\n/**\n * Start recording the whole room (everyone, one video, grid layout).\n * Ask for every participant's consent BEFORE calling this — it records all of them.\n * On a cold recording machine the picture joins after ~20 s.\n */\nexport function startRecording(room: string, opts: RecordingOptions): Promise<Recording> {\n return control(\"/v1/recordings\", room, opts, { method: \"POST\", body: JSON.stringify({ room }) });\n}\n\n/** Stop a recording. `room` must be the room it was started for. */\nexport function stopRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}/stop`, room, opts, { method: \"POST\" });\n}\n\n/** Read where a recording stands — after a stop, poll until `files` is there (or `state` is `failed`). */\nexport function getRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}`, room, opts, { method: \"GET\" });\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"names":["TrackSource","AccessToken","WebhookReceiver"],"mappings":"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","file":"server.cjs","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\n//\n// The app never carries audio. It decides who may enter a room, signs a short\n// ticket for that one room, and hears from the media server (LiveKit) through a\n// signed webhook when people join and leave. Built on LiveKit's own SDK so the\n// ticket format and the webhook signature are LiveKit's, not ours.\n\nimport { AccessToken, TrackSource, WebhookReceiver } from \"livekit-server-sdk\";\nimport type { WebhookEvent } from \"livekit-server-sdk\";\n\nexport type { WebhookEvent };\n\nexport type VoiceErrorCode =\n | \"missing_credentials\"\n | \"invalid_room\"\n | \"invalid_identity\"\n | \"invalid_ttl\"\n | \"invalid_sources\"\n | \"invalid_webhook\"\n | \"invalid_control_url\"\n | \"recording_failed\";\n\nexport class VoiceError extends Error {\n constructor(\n readonly code: VoiceErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"VoiceError\";\n }\n}\n\nexport interface Credentials {\n apiKey: string;\n apiSecret: string;\n}\n\nexport interface RoomTokenOptions extends Credentials {\n /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */\n room: string;\n /** Stable id of the person — the same person joining twice replaces the first connection. */\n identity: string;\n /** Display name other participants see. */\n name?: string;\n /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */\n ttl?: number;\n /**\n * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear\n * everyone but publish nothing (no microphone, no data). Default `true`.\n * Pair it with `joinVoice({ publish: false })` in the browser.\n */\n canPublish?: boolean;\n /**\n * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\n};\n\nexport const DEFAULT_TTL_SECONDS = 600;\nexport const MAX_TTL_SECONDS = 3600;\n\nfunction requireCredentials({ apiKey, apiSecret }: Partial<Credentials>): void {\n // LiveKit's AccessToken falls back to LIVEKIT_API_KEY/SECRET from the\n // environment when these are missing — a ticket signed with a key the caller\n // never chose. Refuse instead.\n if (!apiKey || !apiSecret) {\n throw new VoiceError(\"missing_credentials\", \"apiKey and apiSecret are required\");\n }\n}\n\n/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\nexport async function createRoomToken(opts: RoomTokenOptions): Promise<string> {\n requireCredentials(opts);\n if (!opts.room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n if (!opts.identity?.trim()) throw new VoiceError(\"invalid_identity\", \"identity is required\");\n const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;\n if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {\n throw new VoiceError(\"invalid_ttl\", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);\n }\n\n const speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\n}\n\n/**\n * Verify a webhook from the media server and return its event.\n * `body` must be the RAW request body — re-serialised JSON changes the hash.\n */\nexport async function verifyWebhook(\n body: string,\n authHeader: string | null | undefined,\n credentials: Credentials,\n): Promise<WebhookEvent> {\n requireCredentials(credentials);\n try {\n // A missing header is refused by LiveKit itself (\"authorization header is empty\").\n return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? undefined);\n } catch (e) {\n throw new VoiceError(\"invalid_webhook\", `webhook rejected: ${(e as Error).message}`);\n }\n}\n\n// ---- F104.9.5 — recording ----------------------------------------------------\n//\n// The recording itself runs beside LiveKit (broberg-voice-control starts a\n// recording machine only while something is being recorded). The app asks for\n// it with a 5-minute ticket signed by ITS OWN LiveKit key, bound to ONE room —\n// so an app can only ever start, stop or read recordings of rooms it names.\n// Webhooks `egress_started` / `egress_ended` arrive through `verifyWebhook`\n// like every other event; `event.egressInfo` carries the file.\n\nexport interface RecordingOptions extends Credentials {\n /** The recording service, e.g. `https://broberg-voice-control.fly.dev`. */\n controlUrl: string;\n}\n\nexport interface RecordingFile {\n name: string;\n size: number;\n durationSec: number;\n}\n\nexport interface Recording {\n recordingId: string;\n egressId?: string;\n room: string;\n /**\n * `recording` while it runs; `stopped` after stopRecording; `complete` if the\n * room emptied by itself; `failed` (with `error`) if it could not be made.\n */\n state: \"recording\" | \"stopped\" | \"complete\" | \"failed\";\n attempts?: number;\n error?: string;\n /** Absent until the file is written — after a stop, poll getRecording until it is there. */\n files?: RecordingFile[];\n}\n\nasync function recordingTicket(room: string, credentials: Credentials): Promise<string> {\n requireCredentials(credentials);\n if (!room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n const token = new AccessToken(credentials.apiKey, credentials.apiSecret, { ttl: 300 });\n token.addGrant({ room, roomRecord: true });\n return token.toJwt();\n}\n\nasync function control(path: string, room: string, opts: RecordingOptions, init: { method: string; body?: string }): Promise<Recording> {\n // The request carries a recording ticket: plain http only to this machine, never across a network.\n let u: URL;\n try { u = new URL(opts.controlUrl); } catch { throw new VoiceError(\"invalid_control_url\", \"controlUrl must be an https URL\"); }\n const local = u.hostname === \"localhost\" || u.hostname === \"127.0.0.1\" || u.hostname === \"[::1]\";\n if (u.protocol !== \"https:\" && !(u.protocol === \"http:\" && local)) throw new VoiceError(\"invalid_control_url\", \"controlUrl must be https (http only for localhost)\");\n const res = await fetch(opts.controlUrl.replace(/\\/+$/, \"\") + path, {\n ...init,\n headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, \"content-type\": \"application/json\" },\n signal: AbortSignal.timeout(90_000), // a cold recording machine takes ~20 s to start\n });\n const text = await res.text();\n let body: any;\n try { body = text ? JSON.parse(text) : {}; } catch { body = { error: text.slice(0, 200) }; }\n if (!res.ok) throw new VoiceError(\"recording_failed\", `recording service answered ${res.status}: ${body?.error ?? \"unknown\"}`);\n return body as Recording;\n}\n\n/**\n * Start recording the whole room (everyone, one video, grid layout).\n * Ask for every participant's consent BEFORE calling this — it records all of them.\n * On a cold recording machine the picture joins after ~20 s.\n */\nexport function startRecording(room: string, opts: RecordingOptions): Promise<Recording> {\n return control(\"/v1/recordings\", room, opts, { method: \"POST\", body: JSON.stringify({ room }) });\n}\n\n/** Stop a recording. `room` must be the room it was started for. */\nexport function stopRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}/stop`, room, opts, { method: \"POST\" });\n}\n\n/** Read where a recording stands — after a stop, poll until `files` is there (or `state` is `failed`). */\nexport function getRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}`, room, opts, { method: \"GET\" });\n}\n"]}
|
package/dist/server.js
CHANGED
|
@@ -69,7 +69,14 @@ async function recordingTicket(room, credentials) {
|
|
|
69
69
|
return token.toJwt();
|
|
70
70
|
}
|
|
71
71
|
async function control(path, room, opts, init) {
|
|
72
|
-
|
|
72
|
+
let u;
|
|
73
|
+
try {
|
|
74
|
+
u = new URL(opts.controlUrl);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new VoiceError("invalid_control_url", "controlUrl must be an https URL");
|
|
77
|
+
}
|
|
78
|
+
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
79
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && local)) throw new VoiceError("invalid_control_url", "controlUrl must be https (http only for localhost)");
|
|
73
80
|
const res = await fetch(opts.controlUrl.replace(/\/+$/, "") + path, {
|
|
74
81
|
...init,
|
|
75
82
|
headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, "content-type": "application/json" },
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\n//\n// The app never carries audio. It decides who may enter a room, signs a short\n// ticket for that one room, and hears from the media server (LiveKit) through a\n// signed webhook when people join and leave. Built on LiveKit's own SDK so the\n// ticket format and the webhook signature are LiveKit's, not ours.\n\nimport { AccessToken, TrackSource, WebhookReceiver } from \"livekit-server-sdk\";\nimport type { WebhookEvent } from \"livekit-server-sdk\";\n\nexport type { WebhookEvent };\n\nexport type VoiceErrorCode =\n | \"missing_credentials\"\n | \"invalid_room\"\n | \"invalid_identity\"\n | \"invalid_ttl\"\n | \"invalid_sources\"\n | \"invalid_webhook\"\n | \"invalid_control_url\"\n | \"recording_failed\";\n\nexport class VoiceError extends Error {\n constructor(\n readonly code: VoiceErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"VoiceError\";\n }\n}\n\nexport interface Credentials {\n apiKey: string;\n apiSecret: string;\n}\n\nexport interface RoomTokenOptions extends Credentials {\n /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */\n room: string;\n /** Stable id of the person — the same person joining twice replaces the first connection. */\n identity: string;\n /** Display name other participants see. */\n name?: string;\n /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */\n ttl?: number;\n /**\n * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear\n * everyone but publish nothing (no microphone, no data). Default `true`.\n * Pair it with `joinVoice({ publish: false })` in the browser.\n */\n canPublish?: boolean;\n /**\n * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\n};\n\nexport const DEFAULT_TTL_SECONDS = 600;\nexport const MAX_TTL_SECONDS = 3600;\n\nfunction requireCredentials({ apiKey, apiSecret }: Partial<Credentials>): void {\n // LiveKit's AccessToken falls back to LIVEKIT_API_KEY/SECRET from the\n // environment when these are missing — a ticket signed with a key the caller\n // never chose. Refuse instead.\n if (!apiKey || !apiSecret) {\n throw new VoiceError(\"missing_credentials\", \"apiKey and apiSecret are required\");\n }\n}\n\n/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\nexport async function createRoomToken(opts: RoomTokenOptions): Promise<string> {\n requireCredentials(opts);\n if (!opts.room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n if (!opts.identity?.trim()) throw new VoiceError(\"invalid_identity\", \"identity is required\");\n const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;\n if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {\n throw new VoiceError(\"invalid_ttl\", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);\n }\n\n const speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\n}\n\n/**\n * Verify a webhook from the media server and return its event.\n * `body` must be the RAW request body — re-serialised JSON changes the hash.\n */\nexport async function verifyWebhook(\n body: string,\n authHeader: string | null | undefined,\n credentials: Credentials,\n): Promise<WebhookEvent> {\n requireCredentials(credentials);\n try {\n // A missing header is refused by LiveKit itself (\"authorization header is empty\").\n return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? undefined);\n } catch (e) {\n throw new VoiceError(\"invalid_webhook\", `webhook rejected: ${(e as Error).message}`);\n }\n}\n\n// ---- F104.9.5 — recording ----------------------------------------------------\n//\n// The recording itself runs beside LiveKit (broberg-voice-control starts a\n// recording machine only while something is being recorded). The app asks for\n// it with a 5-minute ticket signed by ITS OWN LiveKit key, bound to ONE room —\n// so an app can only ever start, stop or read recordings of rooms it names.\n// Webhooks `egress_started` / `egress_ended` arrive through `verifyWebhook`\n// like every other event; `event.egressInfo` carries the file.\n\nexport interface RecordingOptions extends Credentials {\n /** The recording service, e.g. `https://broberg-voice-control.fly.dev`. */\n controlUrl: string;\n}\n\nexport interface RecordingFile {\n name: string;\n size: number;\n durationSec: number;\n}\n\nexport interface Recording {\n recordingId: string;\n egressId?: string;\n room: string;\n /**\n * `recording` while it runs; `stopped` after stopRecording; `complete` if the\n * room emptied by itself; `failed` (with `error`) if it could not be made.\n */\n state: \"recording\" | \"stopped\" | \"complete\" | \"failed\";\n attempts?: number;\n error?: string;\n /** Absent until the file is written — after a stop, poll getRecording until it is there. */\n files?: RecordingFile[];\n}\n\nasync function recordingTicket(room: string, credentials: Credentials): Promise<string> {\n requireCredentials(credentials);\n if (!room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n const token = new AccessToken(credentials.apiKey, credentials.apiSecret, { ttl: 300 });\n token.addGrant({ room, roomRecord: true });\n return token.toJwt();\n}\n\nasync function control(path: string, room: string, opts: RecordingOptions, init: { method: string; body?: string }): Promise<Recording> {\n if (!/^https?:\\/\\//.test(opts.controlUrl ?? \"\")) throw new VoiceError(\"invalid_control_url\", \"controlUrl must be an http(s) URL\");\n const res = await fetch(opts.controlUrl.replace(/\\/+$/, \"\") + path, {\n ...init,\n headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, \"content-type\": \"application/json\" },\n signal: AbortSignal.timeout(90_000), // a cold recording machine takes ~20 s to start\n });\n const text = await res.text();\n let body: any;\n try { body = text ? JSON.parse(text) : {}; } catch { body = { error: text.slice(0, 200) }; }\n if (!res.ok) throw new VoiceError(\"recording_failed\", `recording service answered ${res.status}: ${body?.error ?? \"unknown\"}`);\n return body as Recording;\n}\n\n/**\n * Start recording the whole room (everyone, one video, grid layout).\n * Ask for every participant's consent BEFORE calling this — it records all of them.\n * On a cold recording machine the picture joins after ~20 s.\n */\nexport function startRecording(room: string, opts: RecordingOptions): Promise<Recording> {\n return control(\"/v1/recordings\", room, opts, { method: \"POST\", body: JSON.stringify({ room }) });\n}\n\n/** Stop a recording. `room` must be the room it was started for. */\nexport function stopRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}/stop`, room, opts, { method: \"POST\" });\n}\n\n/** Read where a recording stands — after a stop, poll until `files` is there (or `state` is `failed`). */\nexport function getRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}`, room, opts, { method: \"GET\" });\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\n//\n// The app never carries audio. It decides who may enter a room, signs a short\n// ticket for that one room, and hears from the media server (LiveKit) through a\n// signed webhook when people join and leave. Built on LiveKit's own SDK so the\n// ticket format and the webhook signature are LiveKit's, not ours.\n\nimport { AccessToken, TrackSource, WebhookReceiver } from \"livekit-server-sdk\";\nimport type { WebhookEvent } from \"livekit-server-sdk\";\n\nexport type { WebhookEvent };\n\nexport type VoiceErrorCode =\n | \"missing_credentials\"\n | \"invalid_room\"\n | \"invalid_identity\"\n | \"invalid_ttl\"\n | \"invalid_sources\"\n | \"invalid_webhook\"\n | \"invalid_control_url\"\n | \"recording_failed\";\n\nexport class VoiceError extends Error {\n constructor(\n readonly code: VoiceErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"VoiceError\";\n }\n}\n\nexport interface Credentials {\n apiKey: string;\n apiSecret: string;\n}\n\nexport interface RoomTokenOptions extends Credentials {\n /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */\n room: string;\n /** Stable id of the person — the same person joining twice replaces the first connection. */\n identity: string;\n /** Display name other participants see. */\n name?: string;\n /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */\n ttl?: number;\n /**\n * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear\n * everyone but publish nothing (no microphone, no data). Default `true`.\n * Pair it with `joinVoice({ publish: false })` in the browser.\n */\n canPublish?: boolean;\n /**\n * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\n};\n\nexport const DEFAULT_TTL_SECONDS = 600;\nexport const MAX_TTL_SECONDS = 3600;\n\nfunction requireCredentials({ apiKey, apiSecret }: Partial<Credentials>): void {\n // LiveKit's AccessToken falls back to LIVEKIT_API_KEY/SECRET from the\n // environment when these are missing — a ticket signed with a key the caller\n // never chose. Refuse instead.\n if (!apiKey || !apiSecret) {\n throw new VoiceError(\"missing_credentials\", \"apiKey and apiSecret are required\");\n }\n}\n\n/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\nexport async function createRoomToken(opts: RoomTokenOptions): Promise<string> {\n requireCredentials(opts);\n if (!opts.room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n if (!opts.identity?.trim()) throw new VoiceError(\"invalid_identity\", \"identity is required\");\n const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;\n if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {\n throw new VoiceError(\"invalid_ttl\", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);\n }\n\n const speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\n}\n\n/**\n * Verify a webhook from the media server and return its event.\n * `body` must be the RAW request body — re-serialised JSON changes the hash.\n */\nexport async function verifyWebhook(\n body: string,\n authHeader: string | null | undefined,\n credentials: Credentials,\n): Promise<WebhookEvent> {\n requireCredentials(credentials);\n try {\n // A missing header is refused by LiveKit itself (\"authorization header is empty\").\n return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? undefined);\n } catch (e) {\n throw new VoiceError(\"invalid_webhook\", `webhook rejected: ${(e as Error).message}`);\n }\n}\n\n// ---- F104.9.5 — recording ----------------------------------------------------\n//\n// The recording itself runs beside LiveKit (broberg-voice-control starts a\n// recording machine only while something is being recorded). The app asks for\n// it with a 5-minute ticket signed by ITS OWN LiveKit key, bound to ONE room —\n// so an app can only ever start, stop or read recordings of rooms it names.\n// Webhooks `egress_started` / `egress_ended` arrive through `verifyWebhook`\n// like every other event; `event.egressInfo` carries the file.\n\nexport interface RecordingOptions extends Credentials {\n /** The recording service, e.g. `https://broberg-voice-control.fly.dev`. */\n controlUrl: string;\n}\n\nexport interface RecordingFile {\n name: string;\n size: number;\n durationSec: number;\n}\n\nexport interface Recording {\n recordingId: string;\n egressId?: string;\n room: string;\n /**\n * `recording` while it runs; `stopped` after stopRecording; `complete` if the\n * room emptied by itself; `failed` (with `error`) if it could not be made.\n */\n state: \"recording\" | \"stopped\" | \"complete\" | \"failed\";\n attempts?: number;\n error?: string;\n /** Absent until the file is written — after a stop, poll getRecording until it is there. */\n files?: RecordingFile[];\n}\n\nasync function recordingTicket(room: string, credentials: Credentials): Promise<string> {\n requireCredentials(credentials);\n if (!room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n const token = new AccessToken(credentials.apiKey, credentials.apiSecret, { ttl: 300 });\n token.addGrant({ room, roomRecord: true });\n return token.toJwt();\n}\n\nasync function control(path: string, room: string, opts: RecordingOptions, init: { method: string; body?: string }): Promise<Recording> {\n // The request carries a recording ticket: plain http only to this machine, never across a network.\n let u: URL;\n try { u = new URL(opts.controlUrl); } catch { throw new VoiceError(\"invalid_control_url\", \"controlUrl must be an https URL\"); }\n const local = u.hostname === \"localhost\" || u.hostname === \"127.0.0.1\" || u.hostname === \"[::1]\";\n if (u.protocol !== \"https:\" && !(u.protocol === \"http:\" && local)) throw new VoiceError(\"invalid_control_url\", \"controlUrl must be https (http only for localhost)\");\n const res = await fetch(opts.controlUrl.replace(/\\/+$/, \"\") + path, {\n ...init,\n headers: { authorization: `Bearer ${await recordingTicket(room, opts)}`, \"content-type\": \"application/json\" },\n signal: AbortSignal.timeout(90_000), // a cold recording machine takes ~20 s to start\n });\n const text = await res.text();\n let body: any;\n try { body = text ? JSON.parse(text) : {}; } catch { body = { error: text.slice(0, 200) }; }\n if (!res.ok) throw new VoiceError(\"recording_failed\", `recording service answered ${res.status}: ${body?.error ?? \"unknown\"}`);\n return body as Recording;\n}\n\n/**\n * Start recording the whole room (everyone, one video, grid layout).\n * Ask for every participant's consent BEFORE calling this — it records all of them.\n * On a cold recording machine the picture joins after ~20 s.\n */\nexport function startRecording(room: string, opts: RecordingOptions): Promise<Recording> {\n return control(\"/v1/recordings\", room, opts, { method: \"POST\", body: JSON.stringify({ room }) });\n}\n\n/** Stop a recording. `room` must be the room it was started for. */\nexport function stopRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}/stop`, room, opts, { method: \"POST\" });\n}\n\n/** Read where a recording stands — after a stop, poll until `files` is there (or `state` is `failed`). */\nexport function getRecording(recordingId: string, room: string, opts: RecordingOptions): Promise<Recording> {\n return control(`/v1/recordings/${encodeURIComponent(recordingId)}`, room, opts, { method: \"GET\" });\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/call",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Calls for 2–10 people on a self-hosted LiveKit SFU in the EU — audio, camera and screen share: the app's server mints a short-lived, room-bound ticket that allows exactly the sources it names (microphone only by default) and verifies LiveKit's signed webhooks; the browser joins with noise suppression, echo cancellation and auto gain, and gets mute, devices, per-person volume, who-is-speaking, the participant list, camera and screen share on/off (incl. the browser's own stop button), every video picture to attach, and an audio-only mode — with simulcast, adaptive stream and dynacast. 0.3.0 records the whole call to EU storage (startRecording/stopRecording/getRecording on the server, a recording state every participant sees in the browser).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|