From 7a2f59fd3b0ff706abafa5fd57a099e28b0ab89c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 16:48:23 -0400 Subject: [PATCH 01/54] feat: port ConnectionCredentials implementation from agent-starter-react and wire it up to Room --- examples/demo/demo.ts | 10 +- package.json | 2 + pnpm-lock.yaml | 59 +++++++----- src/index.ts | 1 + src/room/ConnectionCredentials.ts | 148 ++++++++++++++++++++++++++++++ src/room/Room.ts | 49 +++++++++- 6 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 src/room/ConnectionCredentials.ts diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 228708029f..e90adc2124 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -11,6 +11,7 @@ import type { } from '../../src/index'; import { BackupCodecPolicy, + ConnectionCredentials, ConnectionQuality, ConnectionState, DisconnectReason, @@ -164,8 +165,13 @@ const appActions = { ): Promise => { const room = new Room(roomOptions); + const credentials = new ConnectionCredentials.Literal({ + serverUrl: url, + participantToken: token, + }); + startTime = Date.now(); - await room.prepareConnection(url, token); + await room.prepareConnection(credentials); const prewarmTime = Date.now() - startTime; appendLog(`prewarmed connection in ${prewarmTime}ms`); room.localParticipant.on(ParticipantEvent.LocalTrackCpuConstrained, (track, publication) => { @@ -407,7 +413,7 @@ const appActions = { reject(error); } }); - await Promise.all([room.connect(url, token, connectOptions), publishPromise]); + await Promise.all([room.connect(credentials, connectOptions), publishPromise]); const elapsed = Date.now() - startTime; appendLog( `successfully connected to ${room.name} in ${Math.round(elapsed)}ms`, diff --git a/package.json b/package.json index 75c3f6aa62..53c1883b4d 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.41.0", "events": "^3.3.0", + "jose": "^6.1.0", "loglevel": "^1.9.2", "sdp-transform": "^2.15.0", "ts-debounce": "^4.0.0", @@ -83,6 +84,7 @@ "@size-limit/webpack": "^11.2.0", "@trivago/prettier-plugin-sort-imports": "^5.0.0", "@types/events": "^3.0.3", + "@types/node": "^24.3.1", "@types/sdp-transform": "2.15.0", "@types/ua-parser-js": "0.7.39", "@typescript-eslint/eslint-plugin": "7.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aed8df5461..2068fbc5e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: events: specifier: ^3.3.0 version: 3.3.0 + jose: + specifier: ^6.1.0 + version: 6.1.0 loglevel: specifier: ^1.9.2 version: 1.9.2 @@ -81,6 +84,9 @@ importers: '@types/events': specifier: ^3.0.3 version: 3.0.3 + '@types/node': + specifier: ^24.3.1 + version: 24.3.1 '@types/sdp-transform': specifier: 2.15.0 version: 2.15.0 @@ -146,10 +152,10 @@ importers: version: 5.8.3 vite: specifier: 7.1.2 - version: 7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + version: 7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) vitest: specifier: ^3.0.0 - version: 3.2.4(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.8.0) + version: 3.2.4(@types/node@24.3.1)(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.8.0) packages: @@ -1307,8 +1313,8 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@22.7.4': - resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} + '@types/node@24.3.1': + resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -2578,6 +2584,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@6.1.0: + resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3447,8 +3456,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20250916: - resolution: {integrity: sha512-Xfuwn53pwofpt/hGVqLcqkqEN9u7jWpPAagOj2QgGmgOZH4P8pzPpwBB4piRkOqYdmiSg6qOP4xK3L9iNRTF6A==} + typescript@6.0.0-dev.20250917: + resolution: {integrity: sha512-Z2rzVGN5WBboY5ySTadfuJm+pouf2bVSzpMZCDVjuBYZvdvZqt9m80J2gbIPPxqQOcRngk0TYlw07zCKxjlWEg==} engines: {node: '>=14.17'} hasBin: true @@ -3462,8 +3471,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.19.8: - resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@7.10.0: + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} @@ -5054,9 +5063,9 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@22.7.4': + '@types/node@24.3.1': dependencies: - undici-types: 6.19.8 + undici-types: 7.10.0 '@types/resolve@1.20.2': {} @@ -5164,13 +5173,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(vite@7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + vite: 7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -5681,7 +5690,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20250916 + typescript: 6.0.0-dev.20250917 dunder-proto@1.0.1: dependencies: @@ -6599,12 +6608,14 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.7.4 + '@types/node': 24.3.1 merge-stream: 2.0.0 supports-color: 8.1.1 jiti@2.4.2: {} + jose@6.1.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -7522,7 +7533,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20250916: {} + typescript@6.0.0-dev.20250917: {} uc.micro@2.1.0: {} @@ -7540,7 +7551,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.19.8: {} + undici-types@7.10.0: {} unicode-canonical-property-names-ecmascript@2.0.0: {} @@ -7579,13 +7590,13 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@3.2.4(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0): + vite-node@3.2.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + vite: 7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - jiti @@ -7600,7 +7611,7 @@ snapshots: - tsx - yaml - vite@7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0): + vite@7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0): dependencies: esbuild: 0.25.5 fdir: 6.4.6(picomatch@4.0.3) @@ -7609,16 +7620,17 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.3.1 fsevents: 2.3.3 jiti: 2.4.2 terser: 5.39.2 yaml: 2.8.0 - vitest@3.2.4(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.8.0): + vitest@3.2.4(@types/node@24.3.1)(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -7636,10 +7648,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) - vite-node: 3.2.4(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + vite: 7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + vite-node: 3.2.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.3.1 happy-dom: 17.6.3 jsdom: 26.1.0 transitivePeerDependencies: diff --git a/src/index.ts b/src/index.ts index d070c12f14..837f34f469 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ export * from './room/errors'; export * from './room/events'; export * from './room/track/Track'; export * from './room/track/create'; +export { type ConnectionDetails, ConnectionCredentials } from './room/ConnectionCredentials'; export { facingModeFromDeviceLabel, facingModeFromLocalTrack } from './room/track/facingMode'; export * from './room/track/options'; export * from './room/track/processor/types'; diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts new file mode 100644 index 0000000000..e1edd79e22 --- /dev/null +++ b/src/room/ConnectionCredentials.ts @@ -0,0 +1,148 @@ +import { decodeJwt } from 'jose'; + +export type ConnectionDetails = { + serverUrl: string; + roomName?: string; + participantName?: string; + participantToken: string; +}; + +const ONE_SECOND_IN_MILLISECONDS = 1000; +const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; + +/** + * ConnectionCredentials handles getting credentials for connecting to a new Room, caching + * the last result and using it until it expires. */ +export abstract class ConnectionCredentials { + private cachedConnectionDetails: ConnectionDetails | null = null; + + protected isCachedConnectionDetailsExpired() { + const token = this.cachedConnectionDetails?.participantToken; + if (!token) { + return true; + } + + const jwtPayload = decodeJwt(token); + if (!jwtPayload.exp) { + return true; + } + const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS; + const expiresAt = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS); + + const now = new Date(); + return expiresAt >= now; + } + + async generate() { + if (this.isCachedConnectionDetailsExpired()) { + await this.refresh(); + } + + return this.cachedConnectionDetails!; + } + + async refresh() { + this.cachedConnectionDetails = await this.fetch(); + } + + protected abstract fetch(): Promise; +} + +export namespace ConnectionCredentials { + /** ConnectionCredentials.Custom allows a user to define a manual function which generates new + * {@link ConnectionDetails} values on demand. Use this to get credentials from custom backends / etc. + * */ + export class Custom extends ConnectionCredentials { + protected fetch: () => Promise; + + constructor(handler: () => Promise) { + super(); + this.fetch = handler; + } + } + + /** ConnectionCredentials.Literal contains a single, literal set of credentials. + * Note that refreshing credentials isn't implemented, because there is only one set provided. + * */ + export class Literal extends ConnectionCredentials { + payload: ConnectionDetails; + + constructor(payload: ConnectionDetails) { + super(); + this.payload = payload; + } + + async fetch() { + if (this.isCachedConnectionDetailsExpired()) { + // FIXME: figure out a better logging solution? + console.warn( + 'The credentials within ConnectionCredentials.Literal have expired, so any upcoming uses of them will likely fail.', + ); + } + return this.payload; + } + } + + export type SandboxOptions = { + sandboxId: string; + baseUrl?: string; + + /** The name of the room to join. If omitted, a random new room name will be generated instead. */ + roomName?: string; + + /** The identity of the participant the token should connect as connect as. If omitted, a random + * identity will be used instead. */ + participantName?: string; + + /** Disable sandbox security related warning log if ConnectionCredentials.Sandbox is used in + * production */ + disableSecurityWarning?: boolean; + }; + + /** ConnectionCredentials.SandboxTokenServer queries a sandbox token server for credentials, + * which supports quick prototyping / getting started types of use cases. + * + * This token provider is INSECURE and should NOT be used in production. + * + * For more info: + * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ + export class SandboxTokenServer extends ConnectionCredentials { + protected options: SandboxOptions; + + constructor(options: SandboxOptions) { + super(); + this.options = options; + + if (process.env.NODE_ENV === 'production' && !this.options.disableSecurityWarning) { + // FIXME: figure out a better logging solution? + console.warn( + 'ConnectionCredentials.SandboxTokenServer is meant for development, and is not security hardened. In production, implement your own token generation solution.', + ); + } + } + + async fetch() { + const baseUrl = this.options.baseUrl ?? 'https://cloud-api.livekit.io'; + const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, { + method: 'POST', + headers: { + 'X-Sandbox-ID': this.options.sandboxId, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + roomName: this.options.roomName, + participantName: this.options.participantName, + }), + }); + + if (!response.ok) { + throw new Error( + `Error generting token from sandbox token server: received ${response.status} / ${await response.text()}`, + ); + } + + const body: ConnectionDetails = await response.json(); + return body; + } + } +} diff --git a/src/room/Room.ts b/src/room/Room.ts index ceea07f2d6..3c2939ff5a 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -43,6 +43,7 @@ import type { RoomOptions, } from '../options'; import { getBrowser } from '../utils/browserParser'; +import { ConnectionCredentials } from './ConnectionCredentials'; import DeviceManager from './DeviceManager'; import RTCEngine from './RTCEngine'; import { RegionUrlProvider } from './RegionUrlProvider'; @@ -170,6 +171,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) /** future holding client initiated connection attempt */ private connectFuture?: Future; + private connectionCredentials?: ConnectionCredentials; + private disconnectLock: Mutex; private e2eeManager: BaseE2EEManager | undefined; @@ -588,7 +591,24 @@ class Room extends (EventEmitter as new () => TypedEmitter) * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ - async prepareConnection(url: string, token?: string) { + prepareConnection(connectionCredentials: ConnectionCredentials): Promise; + prepareConnection(url: string): Promise; + /** @deprecated Use room.prepareConnection(connectionCredentials) instead */ + prepareConnection(url: string, token: string): Promise; + async prepareConnection( + urlOrConnectionCredentials: ConnectionCredentials | string, + tokenOrUnknown?: string, + ) { + let url, token; + if (urlOrConnectionCredentials instanceof ConnectionCredentials) { + const result = await urlOrConnectionCredentials.generate(); + url = result.serverUrl; + token = result.participantToken; + } else { + url = urlOrConnectionCredentials; + token = tokenOrUnknown; + } + if (this.state !== ConnectionState.Disconnected) { return; } @@ -612,7 +632,32 @@ class Room extends (EventEmitter as new () => TypedEmitter) } } - connect = async (url: string, token: string, opts?: RoomConnectOptions): Promise => { + connect: { + (connectionCredentials: ConnectionCredentials, opts?: RoomConnectOptions): Promise; + /** @deprecated Use room.connect(connectionCredentials, opts?: RoomConnectOptions) instead */ + (url: string, token: string, opts?: RoomConnectOptions): Promise; + } = async (urlOrConnectionCredentials, tokenOrOpts, optsOrUnset?: unknown): Promise => { + let opts: RoomConnectOptions = {}; + if ( + urlOrConnectionCredentials instanceof ConnectionCredentials && + typeof tokenOrOpts !== 'string' + ) { + this.connectionCredentials = urlOrConnectionCredentials; + opts = tokenOrOpts ?? {}; + } else if (typeof urlOrConnectionCredentials === 'string' && typeof tokenOrOpts === 'string') { + this.connectionCredentials = new ConnectionCredentials.Literal({ + serverUrl: urlOrConnectionCredentials, + participantToken: tokenOrOpts, + }); + opts = optsOrUnset ?? {}; + } else { + throw new Error( + `Room.connect received invalid parameters - expected url/token or connectionCredentials, received ${urlOrConnectionCredentials}, ${tokenOrOpts}, ${optsOrUnset}`, + ); + } + + const { serverUrl: url, participantToken: token } = await this.connectionCredentials.generate(); + if (!isBrowserSupported()) { if (isReactNative()) { throw Error("WebRTC isn't detected, have you called registerGlobals?"); From 8f6fb41aa8c125d80c4d4fa291f43f937b4ae9de Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 16:58:25 -0400 Subject: [PATCH 02/54] refactor: move roomName / participantName docs to ConnectionDetails --- src/room/ConnectionCredentials.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index e1edd79e22..7dbc8b707f 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -2,7 +2,12 @@ import { decodeJwt } from 'jose'; export type ConnectionDetails = { serverUrl: string; + + /** The name of the room to join. If omitted, a random new room name will be generated instead. */ roomName?: string; + + /** The identity of the participant the token should connect as connect as. If omitted, a random + * identity will be used instead. */ participantName?: string; participantToken: string; }; @@ -83,17 +88,10 @@ export namespace ConnectionCredentials { } } - export type SandboxOptions = { + export type SandboxOptions = Pick & { sandboxId: string; baseUrl?: string; - /** The name of the room to join. If omitted, a random new room name will be generated instead. */ - roomName?: string; - - /** The identity of the participant the token should connect as connect as. If omitted, a random - * identity will be used instead. */ - participantName?: string; - /** Disable sandbox security related warning log if ConnectionCredentials.Sandbox is used in * production */ disableSecurityWarning?: boolean; From 70416fe64cdba1648f14af8a89f51e7649d799ea Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 17:00:21 -0400 Subject: [PATCH 03/54] refactor: reorder literal and custom --- src/room/ConnectionCredentials.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 7dbc8b707f..344ce43115 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -54,18 +54,6 @@ export abstract class ConnectionCredentials { } export namespace ConnectionCredentials { - /** ConnectionCredentials.Custom allows a user to define a manual function which generates new - * {@link ConnectionDetails} values on demand. Use this to get credentials from custom backends / etc. - * */ - export class Custom extends ConnectionCredentials { - protected fetch: () => Promise; - - constructor(handler: () => Promise) { - super(); - this.fetch = handler; - } - } - /** ConnectionCredentials.Literal contains a single, literal set of credentials. * Note that refreshing credentials isn't implemented, because there is only one set provided. * */ @@ -88,6 +76,18 @@ export namespace ConnectionCredentials { } } + /** ConnectionCredentials.Custom allows a user to define a manual function which generates new + * {@link ConnectionDetails} values on demand. Use this to get credentials from custom backends / etc. + * */ + export class Custom extends ConnectionCredentials { + protected fetch: () => Promise; + + constructor(handler: () => Promise) { + super(); + this.fetch = handler; + } + } + export type SandboxOptions = Pick & { sandboxId: string; baseUrl?: string; From 7f8cce4453c00fecfec563c2cbb72e4c600ad696 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 17:00:53 -0400 Subject: [PATCH 04/54] fix: rename SandboxTokenServer -> SandboxTokenServerOptions --- src/room/ConnectionCredentials.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 344ce43115..8e1b96b0de 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -88,7 +88,7 @@ export namespace ConnectionCredentials { } } - export type SandboxOptions = Pick & { + export type SandboxTokenServerOptions = Pick & { sandboxId: string; baseUrl?: string; @@ -105,9 +105,9 @@ export namespace ConnectionCredentials { * For more info: * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ export class SandboxTokenServer extends ConnectionCredentials { - protected options: SandboxOptions; + protected options: SandboxTokenServerOptions; - constructor(options: SandboxOptions) { + constructor(options: SandboxTokenServerOptions) { super(); this.options = options; From 6929247249d1a0b53105e6d02c7e69fb0888f90f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 17:04:34 -0400 Subject: [PATCH 05/54] feat: optimistically regenerate new credentials on disconnect --- src/room/Room.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/room/Room.ts b/src/room/Room.ts index 3c2939ff5a..b56444c3da 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1003,6 +1003,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED); /* @ts-ignore */ this.engine = undefined; + this.connectionCredentials?.generate(); } finally { unlock(); } From 981f80c50a4d5f46ae885e6ba01267230d7696bd Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 17:29:22 -0400 Subject: [PATCH 06/54] fix: add changeset --- .changeset/mean-mirrors-change.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mean-mirrors-change.md diff --git a/.changeset/mean-mirrors-change.md b/.changeset/mean-mirrors-change.md new file mode 100644 index 0000000000..07f016e59d --- /dev/null +++ b/.changeset/mean-mirrors-change.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +add ConnectionCredentials token fetching abstraction From 1059a82a4b27b239c8f22a824b2bba905248053b Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 17:38:55 -0400 Subject: [PATCH 07/54] fix: run prettier --- src/room/ConnectionCredentials.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 8e1b96b0de..c27d2ffa71 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -88,7 +88,10 @@ export namespace ConnectionCredentials { } } - export type SandboxTokenServerOptions = Pick & { + export type SandboxTokenServerOptions = Pick< + ConnectionDetails, + 'roomName' | 'participantName' + > & { sandboxId: string; baseUrl?: string; From f2c07fefcd52a59d73ec7a64beba6e2dcff70da2 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 17:48:40 -0400 Subject: [PATCH 08/54] fix: add BigInt --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 4a81e38107..c3abf75199 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "DOM.AsyncIterable", "ES2017", "ES2018.Promise", - "ES2021.WeakRef" + "ES2021.WeakRef", + "ES2020.BigInt" ], "rootDir": "./", "outDir": "dist", From 4e54fecdbfa721093a9e1b561e067a76540efcf8 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 17:53:25 -0400 Subject: [PATCH 09/54] fix: remove @types/node to try to fix ci build issue --- package.json | 1 - pnpm-lock.yaml | 3 --- tsconfig.json | 3 +-- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/package.json b/package.json index 53c1883b4d..80fb4766a3 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "@size-limit/webpack": "^11.2.0", "@trivago/prettier-plugin-sort-imports": "^5.0.0", "@types/events": "^3.0.3", - "@types/node": "^24.3.1", "@types/sdp-transform": "2.15.0", "@types/ua-parser-js": "0.7.39", "@typescript-eslint/eslint-plugin": "7.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2068fbc5e5..6e8e3639aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,9 +84,6 @@ importers: '@types/events': specifier: ^3.0.3 version: 3.0.3 - '@types/node': - specifier: ^24.3.1 - version: 24.3.1 '@types/sdp-transform': specifier: 2.15.0 version: 2.15.0 diff --git a/tsconfig.json b/tsconfig.json index c3abf75199..4a81e38107 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,8 +9,7 @@ "DOM.AsyncIterable", "ES2017", "ES2018.Promise", - "ES2021.WeakRef", - "ES2020.BigInt" + "ES2021.WeakRef" ], "rootDir": "./", "outDir": "dist", From 4173823a6df1b8d7c6d879b90fdccd35944ee664 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 9 Sep 2025 17:58:06 -0400 Subject: [PATCH 10/54] fix: migrate back to known good pnpm lock --- pnpm-lock.yaml | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e8e3639aa..e0fc7becef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,10 +149,10 @@ importers: version: 5.8.3 vite: specifier: 7.1.2 - version: 7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + version: 7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/node@24.3.1)(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.8.0) + version: 3.2.4(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.8.0) packages: @@ -1310,8 +1310,8 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@24.3.1': - resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} + '@types/node@22.7.4': + resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -3468,8 +3468,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} @@ -5060,9 +5060,9 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@24.3.1': + '@types/node@22.7.4': dependencies: - undici-types: 7.10.0 + undici-types: 6.19.8 '@types/resolve@1.20.2': {} @@ -5170,13 +5170,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0))': + '@vitest/mocker@3.2.4(vite@7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + vite: 7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -6605,7 +6605,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.3.1 + '@types/node': 22.7.4 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -7548,7 +7548,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@7.10.0: {} + undici-types@6.19.8: {} unicode-canonical-property-names-ecmascript@2.0.0: {} @@ -7587,13 +7587,13 @@ snapshots: dependencies: punycode: 2.3.1 - vite-node@3.2.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0): + vite-node@3.2.4(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + vite: 7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) transitivePeerDependencies: - '@types/node' - jiti @@ -7608,7 +7608,7 @@ snapshots: - tsx - yaml - vite@7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0): + vite@7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0): dependencies: esbuild: 0.25.5 fdir: 6.4.6(picomatch@4.0.3) @@ -7617,17 +7617,16 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 24.3.1 fsevents: 2.3.3 jiti: 2.4.2 terser: 5.39.2 yaml: 2.8.0 - vitest@3.2.4(@types/node@24.3.1)(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.8.0): + vitest@3.2.4(happy-dom@17.6.3)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.2)(yaml@2.8.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0)) + '@vitest/mocker': 3.2.4(vite@7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -7645,11 +7644,10 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.2(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) - vite-node: 3.2.4(@types/node@24.3.1)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + vite: 7.1.2(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + vite-node: 3.2.4(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.3.1 happy-dom: 17.6.3 jsdom: 26.1.0 transitivePeerDependencies: From a3ff2d98e964325dfa990e6f5aff793780995ec8 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 10 Sep 2025 09:55:17 -0400 Subject: [PATCH 11/54] feat: use logger for warnings instead of console --- src/logger.ts | 1 + src/room/ConnectionCredentials.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/logger.ts b/src/logger.ts index b0a8ab3e06..962fdc02a1 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -12,6 +12,7 @@ export enum LogLevel { export enum LoggerNames { Default = 'livekit', Room = 'livekit-room', + ConnectionCredentials = 'livekit-connection-credentials', Participant = 'livekit-participant', Track = 'livekit-track', Publication = 'livekit-track-publication', diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index c27d2ffa71..5c20084b65 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -1,4 +1,5 @@ import { decodeJwt } from 'jose'; +import log, { LoggerNames, getLogger } from '../logger'; export type ConnectionDetails = { serverUrl: string; @@ -54,21 +55,27 @@ export abstract class ConnectionCredentials { } export namespace ConnectionCredentials { + export type LiteralOptions = { loggerName?: string }; + /** ConnectionCredentials.Literal contains a single, literal set of credentials. * Note that refreshing credentials isn't implemented, because there is only one set provided. * */ export class Literal extends ConnectionCredentials { payload: ConnectionDetails; - constructor(payload: ConnectionDetails) { + private log = log; + + constructor(payload: ConnectionDetails, options?: LiteralOptions) { super(); this.payload = payload; + + this.log = getLogger(options?.loggerName ?? LoggerNames.ConnectionCredentials); } async fetch() { if (this.isCachedConnectionDetailsExpired()) { // FIXME: figure out a better logging solution? - console.warn( + this.log.warn( 'The credentials within ConnectionCredentials.Literal have expired, so any upcoming uses of them will likely fail.', ); } @@ -94,6 +101,7 @@ export namespace ConnectionCredentials { > & { sandboxId: string; baseUrl?: string; + loggerName?: string; /** Disable sandbox security related warning log if ConnectionCredentials.Sandbox is used in * production */ @@ -110,13 +118,17 @@ export namespace ConnectionCredentials { export class SandboxTokenServer extends ConnectionCredentials { protected options: SandboxTokenServerOptions; + private log = log; + constructor(options: SandboxTokenServerOptions) { super(); this.options = options; + this.log = getLogger(options.loggerName ?? LoggerNames.ConnectionCredentials); + if (process.env.NODE_ENV === 'production' && !this.options.disableSecurityWarning) { // FIXME: figure out a better logging solution? - console.warn( + this.log.warn( 'ConnectionCredentials.SandboxTokenServer is meant for development, and is not security hardened. In production, implement your own token generation solution.', ); } From 70ed1a2eb4a54865ff51c6ed265f0d23e27d1c48 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 10 Sep 2025 17:16:26 -0400 Subject: [PATCH 12/54] feat: add ConnectionCredentials.Response / ConnectionCredentials.Response, and add explicit roomconfig support --- src/index.ts | 2 +- src/room/ConnectionCredentials.ts | 124 ++++++++++++++++++++++-------- 2 files changed, 91 insertions(+), 35 deletions(-) diff --git a/src/index.ts b/src/index.ts index 837f34f469..3e8c0501d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,7 @@ export * from './room/errors'; export * from './room/events'; export * from './room/track/Track'; export * from './room/track/create'; -export { type ConnectionDetails, ConnectionCredentials } from './room/ConnectionCredentials'; +export { ConnectionCredentials } from './room/ConnectionCredentials'; export { facingModeFromDeviceLabel, facingModeFromLocalTrack } from './room/track/facingMode'; export * from './room/track/options'; export * from './room/track/processor/types'; diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 5c20084b65..e9cb7949a6 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -1,18 +1,7 @@ +import { RoomConfiguration } from '@livekit/protocol'; import { decodeJwt } from 'jose'; import log, { LoggerNames, getLogger } from '../logger'; -export type ConnectionDetails = { - serverUrl: string; - - /** The name of the room to join. If omitted, a random new room name will be generated instead. */ - roomName?: string; - - /** The identity of the participant the token should connect as connect as. If omitted, a random - * identity will be used instead. */ - participantName?: string; - participantToken: string; -}; - const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; @@ -20,10 +9,11 @@ const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; * ConnectionCredentials handles getting credentials for connecting to a new Room, caching * the last result and using it until it expires. */ export abstract class ConnectionCredentials { - private cachedConnectionDetails: ConnectionDetails | null = null; + private cachedResponse: ConnectionCredentials.Response | null = null; + private cachedRequest: ConnectionCredentials.Request = {}; - protected isCachedConnectionDetailsExpired() { - const token = this.cachedConnectionDetails?.participantToken; + protected isCachedResponseExpired() { + const token = this.cachedResponse?.participantToken; if (!token) { return true; } @@ -39,33 +29,93 @@ export abstract class ConnectionCredentials { return expiresAt >= now; } - async generate() { - if (this.isCachedConnectionDetailsExpired()) { + protected isSameAsCachedRequest(request: ConnectionCredentials.Request) { + if (!this.cachedRequest) { + return false; + } + + if (this.cachedRequest.roomName !== request.roomName) { + return false; + } + if (this.cachedRequest.participantName !== request.participantName) { + return false; + } + if ( + (!this.cachedRequest.roomConfig && request.roomConfig) || + (this.cachedRequest.roomConfig && !request.roomConfig) + ) { + return false; + } + if ( + this.cachedRequest.roomConfig && + request.roomConfig && + !this.cachedRequest.roomConfig.equals(request.roomConfig) + ) { + return false; + } + + return true; + } + + async generate(request: ConnectionCredentials.Request = {}) { + let shouldRefresh = this.isCachedResponseExpired(); + if (!this.isSameAsCachedRequest(request)) { + this.cachedRequest = request; + shouldRefresh = true; + } + + if (shouldRefresh) { await this.refresh(); } - return this.cachedConnectionDetails!; + return this.cachedResponse!; } async refresh() { - this.cachedConnectionDetails = await this.fetch(); + this.cachedResponse = await this.fetch(this.cachedRequest); } - protected abstract fetch(): Promise; + protected abstract fetch( + request: ConnectionCredentials.Request, + ): Promise; } export namespace ConnectionCredentials { + export type Request = { + /** The name of the room to join. If omitted, a random new room name will be generated instead. */ + roomName?: string; + + /** The identity of the participant the token should connect as connect as. If omitted, a random + * identity will be used instead. */ + participantName?: string; + + roomConfig?: RoomConfiguration; + }; + export type Response = { + serverUrl: string; + + /** The name of the room to join. If omitted, a random new room name will be generated instead. */ + roomName?: string; + + /** The identity of the participant the token should connect as connect as. If omitted, a random + * identity will be used instead. */ + participantName?: string; + participantToken: string; + + roomConfig?: RoomConfiguration; + }; + export type LiteralOptions = { loggerName?: string }; /** ConnectionCredentials.Literal contains a single, literal set of credentials. * Note that refreshing credentials isn't implemented, because there is only one set provided. * */ export class Literal extends ConnectionCredentials { - payload: ConnectionDetails; + payload: Response; private log = log; - constructor(payload: ConnectionDetails, options?: LiteralOptions) { + constructor(payload: Response, options?: LiteralOptions) { super(); this.payload = payload; @@ -73,7 +123,7 @@ export namespace ConnectionCredentials { } async fetch() { - if (this.isCachedConnectionDetailsExpired()) { + if (this.isCachedResponseExpired()) { // FIXME: figure out a better logging solution? this.log.warn( 'The credentials within ConnectionCredentials.Literal have expired, so any upcoming uses of them will likely fail.', @@ -84,20 +134,20 @@ export namespace ConnectionCredentials { } /** ConnectionCredentials.Custom allows a user to define a manual function which generates new - * {@link ConnectionDetails} values on demand. Use this to get credentials from custom backends / etc. + * {@link Response} values on demand. Use this to get credentials from custom backends / etc. * */ export class Custom extends ConnectionCredentials { - protected fetch: () => Promise; + protected fetch: (request: Request) => Promise; - constructor(handler: () => Promise) { + constructor(handler: (request: Request) => Promise) { super(); this.fetch = handler; } } export type SandboxTokenServerOptions = Pick< - ConnectionDetails, - 'roomName' | 'participantName' + Response, + 'roomName' | 'participantName' | 'roomConfig' > & { sandboxId: string; baseUrl?: string; @@ -134,8 +184,13 @@ export namespace ConnectionCredentials { } } - async fetch() { + async fetch(request: Request) { const baseUrl = this.options.baseUrl ?? 'https://cloud-api.livekit.io'; + + const roomName = this.options.roomName ?? request.roomName; + const participantName = this.options.participantName ?? request.participantName; + const roomConfig = this.options.roomConfig ?? request.roomConfig; + const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, { method: 'POST', headers: { @@ -143,19 +198,20 @@ export namespace ConnectionCredentials { 'Content-Type': 'application/json', }, body: JSON.stringify({ - roomName: this.options.roomName, - participantName: this.options.participantName, + roomName, + participantName, + roomConfig: roomConfig?.toJson(), }), }); if (!response.ok) { throw new Error( - `Error generting token from sandbox token server: received ${response.status} / ${await response.text()}`, + `Error generating token from sandbox token server: received ${response.status} / ${await response.text()}`, ); } - const body: ConnectionDetails = await response.json(); - return body; + const body: Exclude = await response.json(); + return { ...body, roomConfig }; } } } From 8edc1aecf5f74d94f5e0c634c097d51834b08f30 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 10 Sep 2025 17:17:41 -0400 Subject: [PATCH 13/54] feat: add mechanism to get room config from token rather than returning it explicitly --- src/room/ConnectionCredentials.ts | 46 +++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index e9cb7949a6..3437e058a8 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -12,14 +12,18 @@ export abstract class ConnectionCredentials { private cachedResponse: ConnectionCredentials.Response | null = null; private cachedRequest: ConnectionCredentials.Request = {}; - protected isCachedResponseExpired() { + protected getCachedResponseJwtPayload() { const token = this.cachedResponse?.participantToken; if (!token) { - return true; + return null; } - const jwtPayload = decodeJwt(token); - if (!jwtPayload.exp) { + return decodeJwt<{ roomConfig: ReturnType }>(token); + } + + protected isCachedResponseExpired() { + const jwtPayload = this.getCachedResponseJwtPayload(); + if (!jwtPayload?.exp) { return true; } const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS; @@ -29,6 +33,14 @@ export abstract class ConnectionCredentials { return expiresAt >= now; } + getCachedResponseRoomConfig() { + const roomConfigJsonValue = this.getCachedResponseJwtPayload()?.roomConfig; + if (!roomConfigJsonValue) { + return null; + } + return RoomConfiguration.fromJson(roomConfigJsonValue); + } + protected isSameAsCachedRequest(request: ConnectionCredentials.Request) { if (!this.cachedRequest) { return false; @@ -71,6 +83,14 @@ export abstract class ConnectionCredentials { return this.cachedResponse!; } + async generateWithCachedRequest() { + if (this.isCachedResponseExpired()) { + await this.refresh(); + } + + return this.cachedResponse!; + } + async refresh() { this.cachedResponse = await this.fetch(this.cachedRequest); } @@ -82,17 +102,22 @@ export abstract class ConnectionCredentials { export namespace ConnectionCredentials { export type Request = { - /** The name of the room to join. If omitted, a random new room name will be generated instead. */ + /** The name of the room being requested when generating credentials */ roomName?: string; - /** The identity of the participant the token should connect as connect as. If omitted, a random - * identity will be used instead. */ + /** The identity of the participant being requested for this client when generating credentials */ participantName?: string; + /** + * A RoomConfiguration object can be passed to include extra parameters when generating + * connection credentials - dispatching agents, defining egress settings, etc + * @see https://docs.livekit.io/home/get-started/authentication/#room-configuration + */ roomConfig?: RoomConfiguration; }; export type Response = { serverUrl: string; + participantToken: string; /** The name of the room to join. If omitted, a random new room name will be generated instead. */ roomName?: string; @@ -100,9 +125,6 @@ export namespace ConnectionCredentials { /** The identity of the participant the token should connect as connect as. If omitted, a random * identity will be used instead. */ participantName?: string; - participantToken: string; - - roomConfig?: RoomConfiguration; }; export type LiteralOptions = { loggerName?: string }; @@ -146,8 +168,8 @@ export namespace ConnectionCredentials { } export type SandboxTokenServerOptions = Pick< - Response, - 'roomName' | 'participantName' | 'roomConfig' + Request, + 'roomName' | 'participantName' > & { sandboxId: string; baseUrl?: string; From c48c7c199be0f111961b376a4acc3420045e76cd Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 10 Sep 2025 17:18:29 -0400 Subject: [PATCH 14/54] feat: add ability to explicitly pass ConnectionCredentials.Request into room.connect / room.prepareConnection --- src/options.ts | 16 +++++++++++++++- src/room/Room.ts | 25 +++++++++++++++---------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/options.ts b/src/options.ts index 73d5e3b9a1..b5caf751e8 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,3 +1,4 @@ +import type { RoomConfiguration } from '@livekit/protocol'; import type { E2EEOptions } from './e2ee/types'; import type { ReconnectPolicy } from './room/ReconnectPolicy'; import type { @@ -7,6 +8,7 @@ import type { VideoCaptureOptions, } from './room/track/options'; import type { AdaptiveStreamSettings } from './room/track/types'; +import type { ConnectionCredentials } from './room/ConnectionCredentials'; export interface WebAudioSettings { audioContext: AudioContext; @@ -142,4 +144,16 @@ export interface InternalRoomConnectOptions { /** * Options for Room.connect() */ -export interface RoomConnectOptions extends Partial {} +export interface RoomConnectOptions extends Partial { + /** Request payload sent to the ConnectionCredentials when generating new credentials. + * Use this to request a room/participant name, automatically dispatch agents, etc + */ + connectionCredentialsRequest?: ConnectionCredentials.Request; +} + +export interface RoomPrepareConnectionOptions { + /** Request payload sent to the ConnectionCredentials when generating new credentials. + * Use this to request a room/participant name, automatically dispatch agents, etc + */ + connectionCredentialsRequest?: ConnectionCredentials.Request; +} diff --git a/src/room/Room.ts b/src/room/Room.ts index b56444c3da..86479fa4b4 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -41,6 +41,7 @@ import type { InternalRoomOptions, RoomConnectOptions, RoomOptions, + RoomPrepareConnectionOptions, } from '../options'; import { getBrowser } from '../utils/browserParser'; import { ConnectionCredentials } from './ConnectionCredentials'; @@ -591,22 +592,26 @@ class Room extends (EventEmitter as new () => TypedEmitter) * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ - prepareConnection(connectionCredentials: ConnectionCredentials): Promise; - prepareConnection(url: string): Promise; + prepareConnection(connectionCredentials: ConnectionCredentials, opts?: RoomPrepareConnectionOptions): Promise; + prepareConnection(url: string, opts?: RoomPrepareConnectionOptions): Promise; /** @deprecated Use room.prepareConnection(connectionCredentials) instead */ - prepareConnection(url: string, token: string): Promise; + prepareConnection(url: string, token?: string): Promise; async prepareConnection( urlOrConnectionCredentials: ConnectionCredentials | string, - tokenOrUnknown?: string, + tokenOrOpts?: string | RoomPrepareConnectionOptions | undefined, ) { let url, token; - if (urlOrConnectionCredentials instanceof ConnectionCredentials) { - const result = await urlOrConnectionCredentials.generate(); + if (urlOrConnectionCredentials instanceof ConnectionCredentials && typeof tokenOrOpts !== 'string') { + const result = await urlOrConnectionCredentials.generate(tokenOrOpts?.connectionCredentialsRequest); url = result.serverUrl; token = result.participantToken; - } else { + } else if (typeof urlOrConnectionCredentials === 'string' && (typeof tokenOrOpts === 'string' || typeof tokenOrOpts === 'undefined')) { url = urlOrConnectionCredentials; - token = tokenOrUnknown; + token = tokenOrOpts; + } else { + throw new Error( + `Room.prepareConnection received invalid parameters - expected url, url/token or connectionCredentials/opts, received ${urlOrConnectionCredentials}, ${tokenOrOpts}`, + ); } if (this.state !== ConnectionState.Disconnected) { @@ -656,7 +661,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) ); } - const { serverUrl: url, participantToken: token } = await this.connectionCredentials.generate(); + const { serverUrl: url, participantToken: token } = await this.connectionCredentials.generate(opts?.connectionCredentialsRequest); if (!isBrowserSupported()) { if (isReactNative()) { @@ -1003,7 +1008,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED); /* @ts-ignore */ this.engine = undefined; - this.connectionCredentials?.generate(); + this.connectionCredentials?.generateWithCachedRequest(); } finally { unlock(); } From f8c92f203375e26237f160c543dd6124c00a18b9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 10 Sep 2025 17:19:22 -0400 Subject: [PATCH 15/54] fix: run npm run format --- src/options.ts | 10 +++++----- src/room/ConnectionCredentials.ts | 15 ++++++--------- src/room/Room.ts | 23 ++++++++++++++++++----- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/options.ts b/src/options.ts index b5caf751e8..162e67dd5b 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,5 +1,6 @@ import type { RoomConfiguration } from '@livekit/protocol'; import type { E2EEOptions } from './e2ee/types'; +import type { ConnectionCredentials } from './room/ConnectionCredentials'; import type { ReconnectPolicy } from './room/ReconnectPolicy'; import type { AudioCaptureOptions, @@ -8,7 +9,6 @@ import type { VideoCaptureOptions, } from './room/track/options'; import type { AdaptiveStreamSettings } from './room/track/types'; -import type { ConnectionCredentials } from './room/ConnectionCredentials'; export interface WebAudioSettings { audioContext: AudioContext; @@ -146,14 +146,14 @@ export interface InternalRoomConnectOptions { */ export interface RoomConnectOptions extends Partial { /** Request payload sent to the ConnectionCredentials when generating new credentials. - * Use this to request a room/participant name, automatically dispatch agents, etc - */ + * Use this to request a room/participant name, automatically dispatch agents, etc + */ connectionCredentialsRequest?: ConnectionCredentials.Request; } export interface RoomPrepareConnectionOptions { /** Request payload sent to the ConnectionCredentials when generating new credentials. - * Use this to request a room/participant name, automatically dispatch agents, etc - */ + * Use this to request a room/participant name, automatically dispatch agents, etc + */ connectionCredentialsRequest?: ConnectionCredentials.Request; } diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 3437e058a8..b052a0e80a 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -108,11 +108,11 @@ export namespace ConnectionCredentials { /** The identity of the participant being requested for this client when generating credentials */ participantName?: string; - /** - * A RoomConfiguration object can be passed to include extra parameters when generating - * connection credentials - dispatching agents, defining egress settings, etc - * @see https://docs.livekit.io/home/get-started/authentication/#room-configuration - */ + /** + * A RoomConfiguration object can be passed to include extra parameters when generating + * connection credentials - dispatching agents, defining egress settings, etc + * @see https://docs.livekit.io/home/get-started/authentication/#room-configuration + */ roomConfig?: RoomConfiguration; }; export type Response = { @@ -167,10 +167,7 @@ export namespace ConnectionCredentials { } } - export type SandboxTokenServerOptions = Pick< - Request, - 'roomName' | 'participantName' - > & { + export type SandboxTokenServerOptions = Pick & { sandboxId: string; baseUrl?: string; loggerName?: string; diff --git a/src/room/Room.ts b/src/room/Room.ts index 86479fa4b4..a781824de0 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -592,7 +592,10 @@ class Room extends (EventEmitter as new () => TypedEmitter) * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ - prepareConnection(connectionCredentials: ConnectionCredentials, opts?: RoomPrepareConnectionOptions): Promise; + prepareConnection( + connectionCredentials: ConnectionCredentials, + opts?: RoomPrepareConnectionOptions, + ): Promise; prepareConnection(url: string, opts?: RoomPrepareConnectionOptions): Promise; /** @deprecated Use room.prepareConnection(connectionCredentials) instead */ prepareConnection(url: string, token?: string): Promise; @@ -601,11 +604,19 @@ class Room extends (EventEmitter as new () => TypedEmitter) tokenOrOpts?: string | RoomPrepareConnectionOptions | undefined, ) { let url, token; - if (urlOrConnectionCredentials instanceof ConnectionCredentials && typeof tokenOrOpts !== 'string') { - const result = await urlOrConnectionCredentials.generate(tokenOrOpts?.connectionCredentialsRequest); + if ( + urlOrConnectionCredentials instanceof ConnectionCredentials && + typeof tokenOrOpts !== 'string' + ) { + const result = await urlOrConnectionCredentials.generate( + tokenOrOpts?.connectionCredentialsRequest, + ); url = result.serverUrl; token = result.participantToken; - } else if (typeof urlOrConnectionCredentials === 'string' && (typeof tokenOrOpts === 'string' || typeof tokenOrOpts === 'undefined')) { + } else if ( + typeof urlOrConnectionCredentials === 'string' && + (typeof tokenOrOpts === 'string' || typeof tokenOrOpts === 'undefined') + ) { url = urlOrConnectionCredentials; token = tokenOrOpts; } else { @@ -661,7 +672,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) ); } - const { serverUrl: url, participantToken: token } = await this.connectionCredentials.generate(opts?.connectionCredentialsRequest); + const { serverUrl: url, participantToken: token } = await this.connectionCredentials.generate( + opts?.connectionCredentialsRequest, + ); if (!isBrowserSupported()) { if (isReactNative()) { From 30c825fbf85b19f1464b60789e4768d7ab1174c5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 10 Sep 2025 17:20:30 -0400 Subject: [PATCH 16/54] fix: remove dead code --- src/options.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/options.ts b/src/options.ts index 162e67dd5b..49715ae0ee 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,4 +1,3 @@ -import type { RoomConfiguration } from '@livekit/protocol'; import type { E2EEOptions } from './e2ee/types'; import type { ConnectionCredentials } from './room/ConnectionCredentials'; import type { ReconnectPolicy } from './room/ReconnectPolicy'; From 8a8a549bf50ca321613f207109d922b6aa37c077 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 10 Sep 2025 17:20:38 -0400 Subject: [PATCH 17/54] feat: re-add accidentally removed roomConfig from SandboxTokenServerOptions --- src/room/ConnectionCredentials.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index b052a0e80a..a06a2f88e9 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -167,7 +167,10 @@ export namespace ConnectionCredentials { } } - export type SandboxTokenServerOptions = Pick & { + export type SandboxTokenServerOptions = Pick< + Request, + 'roomName' | 'participantName' | 'roomConfig' + > & { sandboxId: string; baseUrl?: string; loggerName?: string; From e85f4b2bfc19b341ce2036ff66883bbfc70f017d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 10 Sep 2025 17:24:31 -0400 Subject: [PATCH 18/54] fix: address lint issue --- src/room/ConnectionCredentials.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index a06a2f88e9..c3cc9b2254 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -10,6 +10,7 @@ const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; * the last result and using it until it expires. */ export abstract class ConnectionCredentials { private cachedResponse: ConnectionCredentials.Response | null = null; + private cachedRequest: ConnectionCredentials.Request = {}; protected getCachedResponseJwtPayload() { From 3e3b3005e6c8c9b4e60c7548a39d288ce10898e4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 11 Sep 2025 11:28:32 -0400 Subject: [PATCH 19/54] feat: change ConnectionCredentials so Request gets set ahead of time via a method --- src/room/ConnectionCredentials.ts | 44 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index c3cc9b2254..dfef33b47b 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -9,17 +9,16 @@ const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; * ConnectionCredentials handles getting credentials for connecting to a new Room, caching * the last result and using it until it expires. */ export abstract class ConnectionCredentials { + private request: ConnectionCredentials.Request = {}; private cachedResponse: ConnectionCredentials.Response | null = null; - private cachedRequest: ConnectionCredentials.Request = {}; - protected getCachedResponseJwtPayload() { const token = this.cachedResponse?.participantToken; if (!token) { return null; } - return decodeJwt<{ roomConfig: ReturnType }>(token); + return decodeJwt<{ roomConfig?: ReturnType }>(token); } protected isCachedResponseExpired() { @@ -43,26 +42,26 @@ export abstract class ConnectionCredentials { } protected isSameAsCachedRequest(request: ConnectionCredentials.Request) { - if (!this.cachedRequest) { + if (!this.request) { return false; } - if (this.cachedRequest.roomName !== request.roomName) { + if (this.request.roomName !== request.roomName) { return false; } - if (this.cachedRequest.participantName !== request.participantName) { + if (this.request.participantName !== request.participantName) { return false; } if ( - (!this.cachedRequest.roomConfig && request.roomConfig) || - (this.cachedRequest.roomConfig && !request.roomConfig) + (!this.request.roomConfig && request.roomConfig) || + (this.request.roomConfig && !request.roomConfig) ) { return false; } if ( - this.cachedRequest.roomConfig && + this.request.roomConfig && request.roomConfig && - !this.cachedRequest.roomConfig.equals(request.roomConfig) + !this.request.roomConfig.equals(request.roomConfig) ) { return false; } @@ -70,21 +69,22 @@ export abstract class ConnectionCredentials { return true; } - async generate(request: ConnectionCredentials.Request = {}) { - let shouldRefresh = this.isCachedResponseExpired(); + /** + * Store request metadata which will be provide explicitly when fetching new credentials. + * + * @example new ConnectionCredentials.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ + setRequest(request: ConnectionCredentials.Request) { if (!this.isSameAsCachedRequest(request)) { - this.cachedRequest = request; - shouldRefresh = true; - } - - if (shouldRefresh) { - await this.refresh(); + this.cachedResponse = null; } - - return this.cachedResponse!; + this.request = request; + } + clearRequest() { + this.request = {}; + this.cachedResponse = null; } - async generateWithCachedRequest() { + async generate() { if (this.isCachedResponseExpired()) { await this.refresh(); } @@ -93,7 +93,7 @@ export abstract class ConnectionCredentials { } async refresh() { - this.cachedResponse = await this.fetch(this.cachedRequest); + this.cachedResponse = await this.fetch(this.request); } protected abstract fetch( From 1b346df4d3e8cdf5d60df74f0cadd796d6876596 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 11 Sep 2025 11:29:48 -0400 Subject: [PATCH 20/54] feat: get rid of `roomName` / `participantName` from response, these values can be gotten from decoding the token --- src/room/ConnectionCredentials.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index dfef33b47b..283315d530 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -110,8 +110,8 @@ export namespace ConnectionCredentials { participantName?: string; /** - * A RoomConfiguration object can be passed to include extra parameters when generating - * connection credentials - dispatching agents, defining egress settings, etc + * A RoomConfiguration object can be passed to request extra parameters should be included when + * generating connection credentials - dispatching agents, defining egress settings, etc * @see https://docs.livekit.io/home/get-started/authentication/#room-configuration */ roomConfig?: RoomConfiguration; @@ -119,13 +119,6 @@ export namespace ConnectionCredentials { export type Response = { serverUrl: string; participantToken: string; - - /** The name of the room to join. If omitted, a random new room name will be generated instead. */ - roomName?: string; - - /** The identity of the participant the token should connect as connect as. If omitted, a random - * identity will be used instead. */ - participantName?: string; }; export type LiteralOptions = { loggerName?: string }; From b8301bec36782686edccf3afd462ad115469c053 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 11 Sep 2025 11:31:08 -0400 Subject: [PATCH 21/54] feat: get rid of connectionCredentialsRequest parameter when calling `room.connect` / `room.prepareConnection` --- src/options.ts | 14 +------------- src/room/Room.ts | 28 ++++++++++------------------ 2 files changed, 11 insertions(+), 31 deletions(-) diff --git a/src/options.ts b/src/options.ts index 49715ae0ee..ce7f29760c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -143,16 +143,4 @@ export interface InternalRoomConnectOptions { /** * Options for Room.connect() */ -export interface RoomConnectOptions extends Partial { - /** Request payload sent to the ConnectionCredentials when generating new credentials. - * Use this to request a room/participant name, automatically dispatch agents, etc - */ - connectionCredentialsRequest?: ConnectionCredentials.Request; -} - -export interface RoomPrepareConnectionOptions { - /** Request payload sent to the ConnectionCredentials when generating new credentials. - * Use this to request a room/participant name, automatically dispatch agents, etc - */ - connectionCredentialsRequest?: ConnectionCredentials.Request; -} +export interface RoomConnectOptions extends Partial {} diff --git a/src/room/Room.ts b/src/room/Room.ts index a781824de0..c39650b5e0 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -41,7 +41,6 @@ import type { InternalRoomOptions, RoomConnectOptions, RoomOptions, - RoomPrepareConnectionOptions, } from '../options'; import { getBrowser } from '../utils/browserParser'; import { ConnectionCredentials } from './ConnectionCredentials'; @@ -592,36 +591,31 @@ class Room extends (EventEmitter as new () => TypedEmitter) * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ - prepareConnection( - connectionCredentials: ConnectionCredentials, - opts?: RoomPrepareConnectionOptions, - ): Promise; - prepareConnection(url: string, opts?: RoomPrepareConnectionOptions): Promise; + prepareConnection(connectionCredentials: ConnectionCredentials): Promise; + prepareConnection(url: string): Promise; /** @deprecated Use room.prepareConnection(connectionCredentials) instead */ prepareConnection(url: string, token?: string): Promise; async prepareConnection( urlOrConnectionCredentials: ConnectionCredentials | string, - tokenOrOpts?: string | RoomPrepareConnectionOptions | undefined, + tokenOrUnknown?: string | undefined, ) { let url, token; if ( urlOrConnectionCredentials instanceof ConnectionCredentials && - typeof tokenOrOpts !== 'string' + typeof tokenOrUnknown !== 'string' ) { - const result = await urlOrConnectionCredentials.generate( - tokenOrOpts?.connectionCredentialsRequest, - ); + const result = await urlOrConnectionCredentials.generate(); url = result.serverUrl; token = result.participantToken; } else if ( typeof urlOrConnectionCredentials === 'string' && - (typeof tokenOrOpts === 'string' || typeof tokenOrOpts === 'undefined') + (typeof tokenOrUnknown === 'string' || typeof tokenOrUnknown === 'undefined') ) { url = urlOrConnectionCredentials; - token = tokenOrOpts; + token = tokenOrUnknown; } else { throw new Error( - `Room.prepareConnection received invalid parameters - expected url, url/token or connectionCredentials/opts, received ${urlOrConnectionCredentials}, ${tokenOrOpts}`, + `Room.prepareConnection received invalid parameters - expected url, url/token or connectionCredentials, received ${urlOrConnectionCredentials}, ${tokenOrUnknown}`, ); } @@ -672,9 +666,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) ); } - const { serverUrl: url, participantToken: token } = await this.connectionCredentials.generate( - opts?.connectionCredentialsRequest, - ); + const { serverUrl: url, participantToken: token } = await this.connectionCredentials.generate(); if (!isBrowserSupported()) { if (isReactNative()) { @@ -1021,7 +1013,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED); /* @ts-ignore */ this.engine = undefined; - this.connectionCredentials?.generateWithCachedRequest(); + this.connectionCredentials?.generate(); } finally { unlock(); } From 379eda6db72c09cc0081437528d7243b048250e3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 11 Sep 2025 11:32:42 -0400 Subject: [PATCH 22/54] fix: run npm run format --- src/room/ConnectionCredentials.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 283315d530..61101a67b7 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -70,9 +70,9 @@ export abstract class ConnectionCredentials { } /** - * Store request metadata which will be provide explicitly when fetching new credentials. - * - * @example new ConnectionCredentials.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ + * Store request metadata which will be provide explicitly when fetching new credentials. + * + * @example new ConnectionCredentials.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ setRequest(request: ConnectionCredentials.Request) { if (!this.isSameAsCachedRequest(request)) { this.cachedResponse = null; From 43bb78233d9b8ba33aa650da2211d89a8f3e1dc3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 11 Sep 2025 11:35:50 -0400 Subject: [PATCH 23/54] fix: run eslint --fix --- src/options.ts | 1 - src/room/ConnectionCredentials.ts | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/options.ts b/src/options.ts index ce7f29760c..73d5e3b9a1 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,5 +1,4 @@ import type { E2EEOptions } from './e2ee/types'; -import type { ConnectionCredentials } from './room/ConnectionCredentials'; import type { ReconnectPolicy } from './room/ReconnectPolicy'; import type { AudioCaptureOptions, diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 61101a67b7..55d78a894a 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -10,6 +10,7 @@ const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; * the last result and using it until it expires. */ export abstract class ConnectionCredentials { private request: ConnectionCredentials.Request = {}; + private cachedResponse: ConnectionCredentials.Response | null = null; protected getCachedResponseJwtPayload() { @@ -79,6 +80,7 @@ export abstract class ConnectionCredentials { } this.request = request; } + clearRequest() { this.request = {}; this.cachedResponse = null; From c2848bc84768ead5c5c676ecf67c7d122432bb03 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 11 Sep 2025 13:05:56 -0400 Subject: [PATCH 24/54] fix: address issue where token expiry error would always show on the initial generate call for ConnectionCredentials.Literal --- src/room/ConnectionCredentials.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 55d78a894a..0e2caf48d6 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -11,7 +11,7 @@ const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; export abstract class ConnectionCredentials { private request: ConnectionCredentials.Request = {}; - private cachedResponse: ConnectionCredentials.Response | null = null; + protected cachedResponse: ConnectionCredentials.Response | null = null; protected getCachedResponseJwtPayload() { const token = this.cachedResponse?.participantToken; @@ -129,13 +129,11 @@ export namespace ConnectionCredentials { * Note that refreshing credentials isn't implemented, because there is only one set provided. * */ export class Literal extends ConnectionCredentials { - payload: Response; - private log = log; constructor(payload: Response, options?: LiteralOptions) { super(); - this.payload = payload; + this.cachedResponse = payload; this.log = getLogger(options?.loggerName ?? LoggerNames.ConnectionCredentials); } @@ -147,7 +145,7 @@ export namespace ConnectionCredentials { 'The credentials within ConnectionCredentials.Literal have expired, so any upcoming uses of them will likely fail.', ); } - return this.payload; + return this.cachedResponse!; } } From a51dff8b8341628181f1576a7000952f1c7d8c9e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 11 Sep 2025 13:58:27 -0400 Subject: [PATCH 25/54] feat: store currently active credentials fetch and re-use it if multiple credentials refreshes occur concurrently --- src/room/ConnectionCredentials.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 0e2caf48d6..30143bf55c 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -13,6 +13,8 @@ export abstract class ConnectionCredentials { protected cachedResponse: ConnectionCredentials.Response | null = null; + private inProgressFetch: Promise | null = null; + protected getCachedResponseJwtPayload() { const token = this.cachedResponse?.participantToken; if (!token) { @@ -95,7 +97,17 @@ export abstract class ConnectionCredentials { } async refresh() { - this.cachedResponse = await this.fetch(this.request); + if (this.inProgressFetch) { + await this.inProgressFetch; + return; + } + + try { + this.inProgressFetch = this.fetch(this.request); + this.cachedResponse = await this.inProgressFetch; + } finally { + this.inProgressFetch = null; + } } protected abstract fetch( From ab012bc8d781931613bf9bab90e8cf8c678519cd Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 12 Sep 2025 10:21:34 -0400 Subject: [PATCH 26/54] feat: remove ability to inject custom logger name --- src/room/ConnectionCredentials.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 30143bf55c..f9d24443ff 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -135,7 +135,6 @@ export namespace ConnectionCredentials { participantToken: string; }; - export type LiteralOptions = { loggerName?: string }; /** ConnectionCredentials.Literal contains a single, literal set of credentials. * Note that refreshing credentials isn't implemented, because there is only one set provided. @@ -143,16 +142,14 @@ export namespace ConnectionCredentials { export class Literal extends ConnectionCredentials { private log = log; - constructor(payload: Response, options?: LiteralOptions) { + constructor(payload: Response) { super(); this.cachedResponse = payload; - - this.log = getLogger(options?.loggerName ?? LoggerNames.ConnectionCredentials); + this.log = getLogger(LoggerNames.ConnectionCredentials); } async fetch() { if (this.isCachedResponseExpired()) { - // FIXME: figure out a better logging solution? this.log.warn( 'The credentials within ConnectionCredentials.Literal have expired, so any upcoming uses of them will likely fail.', ); @@ -179,7 +176,6 @@ export namespace ConnectionCredentials { > & { sandboxId: string; baseUrl?: string; - loggerName?: string; /** Disable sandbox security related warning log if ConnectionCredentials.Sandbox is used in * production */ @@ -202,10 +198,9 @@ export namespace ConnectionCredentials { super(); this.options = options; - this.log = getLogger(options.loggerName ?? LoggerNames.ConnectionCredentials); + this.log = getLogger(LoggerNames.ConnectionCredentials); if (process.env.NODE_ENV === 'production' && !this.options.disableSecurityWarning) { - // FIXME: figure out a better logging solution? this.log.warn( 'ConnectionCredentials.SandboxTokenServer is meant for development, and is not security hardened. In production, implement your own token generation solution.', ); From a9e038dc502581c551754214f07216d5e943c4c5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 12 Sep 2025 10:21:59 -0400 Subject: [PATCH 27/54] feat: add new ConnectionCredentials.Refreshable abstract base class This means that ConnectionCredentials.Literal cannot be refreshed --- src/room/ConnectionCredentials.ts | 168 ++++++++++++++++-------------- 1 file changed, 87 insertions(+), 81 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index f9d24443ff..15623febd7 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -5,15 +5,13 @@ import log, { LoggerNames, getLogger } from '../logger'; const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; -/** - * ConnectionCredentials handles getting credentials for connecting to a new Room, caching - * the last result and using it until it expires. */ +/** ConnectionCredentials handles generating credentials for connecting to a new Room */ export abstract class ConnectionCredentials { - private request: ConnectionCredentials.Request = {}; - protected cachedResponse: ConnectionCredentials.Response | null = null; - private inProgressFetch: Promise | null = null; + constructor(response: ConnectionCredentials.Response | null = null) { + this.cachedResponse = response; + } protected getCachedResponseJwtPayload() { const token = this.cachedResponse?.participantToken; @@ -44,77 +42,8 @@ export abstract class ConnectionCredentials { return RoomConfiguration.fromJson(roomConfigJsonValue); } - protected isSameAsCachedRequest(request: ConnectionCredentials.Request) { - if (!this.request) { - return false; - } - - if (this.request.roomName !== request.roomName) { - return false; - } - if (this.request.participantName !== request.participantName) { - return false; - } - if ( - (!this.request.roomConfig && request.roomConfig) || - (this.request.roomConfig && !request.roomConfig) - ) { - return false; - } - if ( - this.request.roomConfig && - request.roomConfig && - !this.request.roomConfig.equals(request.roomConfig) - ) { - return false; - } - - return true; - } - - /** - * Store request metadata which will be provide explicitly when fetching new credentials. - * - * @example new ConnectionCredentials.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ - setRequest(request: ConnectionCredentials.Request) { - if (!this.isSameAsCachedRequest(request)) { - this.cachedResponse = null; - } - this.request = request; - } - - clearRequest() { - this.request = {}; - this.cachedResponse = null; - } - - async generate() { - if (this.isCachedResponseExpired()) { - await this.refresh(); - } - - return this.cachedResponse!; - } - - async refresh() { - if (this.inProgressFetch) { - await this.inProgressFetch; - return; - } - - try { - this.inProgressFetch = this.fetch(this.request); - this.cachedResponse = await this.inProgressFetch; - } finally { - this.inProgressFetch = null; - } - } - - protected abstract fetch( - request: ConnectionCredentials.Request, - ): Promise; + abstract generate(): Promise; } - export namespace ConnectionCredentials { export type Request = { /** The name of the room being requested when generating credentials */ @@ -135,6 +64,84 @@ export namespace ConnectionCredentials { participantToken: string; }; + /** + * RefreshableConnectionCredentials handles getting credentials for connecting to a new Room from + * an async source, caching them and auto refreshing them if they expire. */ + export abstract class Refreshable extends ConnectionCredentials { + private request: ConnectionCredentials.Request = {}; + + private inProgressFetch: Promise | null = null; + + protected isSameAsCachedRequest(request: ConnectionCredentials.Request) { + if (!this.request) { + return false; + } + + if (this.request.roomName !== request.roomName) { + return false; + } + if (this.request.participantName !== request.participantName) { + return false; + } + if ( + (!this.request.roomConfig && request.roomConfig) || + (this.request.roomConfig && !request.roomConfig) + ) { + return false; + } + if ( + this.request.roomConfig && + request.roomConfig && + !this.request.roomConfig.equals(request.roomConfig) + ) { + return false; + } + + return true; + } + + /** + * Store request metadata which will be provide explicitly when fetching new credentials. + * + * @example new ConnectionCredentials.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ + setRequest(request: ConnectionCredentials.Request) { + if (!this.isSameAsCachedRequest(request)) { + this.cachedResponse = null; + } + this.request = request; + } + + clearRequest() { + this.request = {}; + this.cachedResponse = null; + } + + async generate() { + if (this.isCachedResponseExpired()) { + await this.refresh(); + } + + return this.cachedResponse!; + } + + async refresh() { + if (this.inProgressFetch) { + await this.inProgressFetch; + return; + } + + try { + this.inProgressFetch = this.fetch(this.request); + this.cachedResponse = await this.inProgressFetch; + } finally { + this.inProgressFetch = null; + } + } + + protected abstract fetch( + request: ConnectionCredentials.Request, + ): Promise; + } /** ConnectionCredentials.Literal contains a single, literal set of credentials. * Note that refreshing credentials isn't implemented, because there is only one set provided. @@ -143,12 +150,11 @@ export namespace ConnectionCredentials { private log = log; constructor(payload: Response) { - super(); - this.cachedResponse = payload; + super(payload); this.log = getLogger(LoggerNames.ConnectionCredentials); } - async fetch() { + async generate() { if (this.isCachedResponseExpired()) { this.log.warn( 'The credentials within ConnectionCredentials.Literal have expired, so any upcoming uses of them will likely fail.', @@ -161,7 +167,7 @@ export namespace ConnectionCredentials { /** ConnectionCredentials.Custom allows a user to define a manual function which generates new * {@link Response} values on demand. Use this to get credentials from custom backends / etc. * */ - export class Custom extends ConnectionCredentials { + export class Custom extends ConnectionCredentials.Refreshable { protected fetch: (request: Request) => Promise; constructor(handler: (request: Request) => Promise) { @@ -189,7 +195,7 @@ export namespace ConnectionCredentials { * * For more info: * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ - export class SandboxTokenServer extends ConnectionCredentials { + export class SandboxTokenServer extends ConnectionCredentials.Refreshable { protected options: SandboxTokenServerOptions; private log = log; From 352f89f5287ce09f97b0e3b1026b5e1a426fc259 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 12 Sep 2025 12:05:22 -0400 Subject: [PATCH 28/54] fix: update class name in docs comment --- src/room/ConnectionCredentials.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 15623febd7..d9b69a81e0 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -65,7 +65,7 @@ export namespace ConnectionCredentials { }; /** - * RefreshableConnectionCredentials handles getting credentials for connecting to a new Room from + * ConnectionCredentials.Refreshable handles getting credentials for connecting to a new Room from * an async source, caching them and auto refreshing them if they expire. */ export abstract class Refreshable extends ConnectionCredentials { private request: ConnectionCredentials.Request = {}; From 4d83430a4f7932ecbdb321daa5492eb7abb00263 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 15 Sep 2025 10:24:36 -0400 Subject: [PATCH 29/54] feat: add participantIdentity / participantMetadata / participantAttributes to request and cached response --- src/room/ConnectionCredentials.ts | 41 +++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index d9b69a81e0..0d9d81e69f 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -19,7 +19,19 @@ export abstract class ConnectionCredentials { return null; } - return decodeJwt<{ roomConfig?: ReturnType }>(token); + return decodeJwt<{ + name?: string; + metadata?: string; + attributes?: Record; + roomConfig?: ReturnType; + video?: { + room?: string; + roomJoin?: boolean; + canPublish?: boolean; + canPublishData?: boolean; + canSubscribe?: boolean; + }; + }>(token); } protected isCachedResponseExpired() { @@ -42,6 +54,22 @@ export abstract class ConnectionCredentials { return RoomConfiguration.fromJson(roomConfigJsonValue); } + getCachedResponseParticipantName() { + return this.getCachedResponseJwtPayload()?.name ?? null; + } + + getCachedResponseParticipantIdentity() { + return this.getCachedResponseJwtPayload()?.sub ?? null; + } + + getCachedResponseParticipantMetadata() { + return this.getCachedResponseJwtPayload()?.metadata ?? null; + } + + getCachedResponseParticipantAttributes() { + return this.getCachedResponseJwtPayload()?.attributes ?? null; + } + abstract generate(): Promise; } export namespace ConnectionCredentials { @@ -49,9 +77,18 @@ export namespace ConnectionCredentials { /** The name of the room being requested when generating credentials */ roomName?: string; - /** The identity of the participant being requested for this client when generating credentials */ + /** The name of the participant being requested for this client when generating credentials */ participantName?: string; + /** The identity of the participant being requested for this client when generating credentials */ + participantIdentity?: string; + + /** Any participant metadata being included along with the credentials generation operation */ + participantMetadata?: string; + + /** Any participant attributes being included along with the credentials generation operation */ + participantAttributes?: Record; + /** * A RoomConfiguration object can be passed to request extra parameters should be included when * generating connection credentials - dispatching agents, defining egress settings, etc From f6150cf851b9b2c85834cf7053966fba34b4edd0 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 15 Sep 2025 12:39:56 -0400 Subject: [PATCH 30/54] feat: updated deprecation warnings to include mention of ConnectionCredentials.Literal --- src/room/Room.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index c39650b5e0..f9038189a9 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -593,7 +593,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) */ prepareConnection(connectionCredentials: ConnectionCredentials): Promise; prepareConnection(url: string): Promise; - /** @deprecated Use room.prepareConnection(connectionCredentials) instead */ + /** @deprecated Use room.prepareConnection(new ConnectionCredentials.Literal({ serverUrl: "url", participantToken: "token" })) instead */ prepareConnection(url: string, token?: string): Promise; async prepareConnection( urlOrConnectionCredentials: ConnectionCredentials | string, @@ -644,7 +644,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) connect: { (connectionCredentials: ConnectionCredentials, opts?: RoomConnectOptions): Promise; - /** @deprecated Use room.connect(connectionCredentials, opts?: RoomConnectOptions) instead */ + /** @deprecated Use room.connect(new ConnectionCredentials.Literal({ serverUrl: "url", participantToken: "token" }), opts?: RoomConnectOptions) instead */ (url: string, token: string, opts?: RoomConnectOptions): Promise; } = async (urlOrConnectionCredentials, tokenOrOpts, optsOrUnset?: unknown): Promise => { let opts: RoomConnectOptions = {}; From 60a5e16553d66c8e8867f06747acb3fa0a9715c1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 16 Sep 2025 11:03:28 -0400 Subject: [PATCH 31/54] feat: remove SandboxTokenServer security warning, it sounds like something similar will be implemented in another context (web dashboard) --- src/room/ConnectionCredentials.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/room/ConnectionCredentials.ts b/src/room/ConnectionCredentials.ts index 0d9d81e69f..91e170525c 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/ConnectionCredentials.ts @@ -219,10 +219,6 @@ export namespace ConnectionCredentials { > & { sandboxId: string; baseUrl?: string; - - /** Disable sandbox security related warning log if ConnectionCredentials.Sandbox is used in - * production */ - disableSecurityWarning?: boolean; }; /** ConnectionCredentials.SandboxTokenServer queries a sandbox token server for credentials, @@ -235,19 +231,9 @@ export namespace ConnectionCredentials { export class SandboxTokenServer extends ConnectionCredentials.Refreshable { protected options: SandboxTokenServerOptions; - private log = log; - constructor(options: SandboxTokenServerOptions) { super(); this.options = options; - - this.log = getLogger(LoggerNames.ConnectionCredentials); - - if (process.env.NODE_ENV === 'production' && !this.options.disableSecurityWarning) { - this.log.warn( - 'ConnectionCredentials.SandboxTokenServer is meant for development, and is not security hardened. In production, implement your own token generation solution.', - ); - } } async fetch(request: Request) { From 15255fe1e1eca8b4cb0e19e72f718a8563d5c385 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 17 Sep 2025 11:04:26 -0400 Subject: [PATCH 32/54] feat: rename from ConnectionCredentials -> TokenSource --- examples/demo/demo.ts | 8 ++-- src/index.ts | 2 +- src/logger.ts | 2 +- src/room/Room.ts | 44 ++++++++---------- ...onnectionCredentials.ts => TokenSource.ts} | 46 +++++++++---------- 5 files changed, 47 insertions(+), 55 deletions(-) rename src/room/{ConnectionCredentials.ts => TokenSource.ts} (79%) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index e90adc2124..1a96e67593 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -11,7 +11,6 @@ import type { } from '../../src/index'; import { BackupCodecPolicy, - ConnectionCredentials, ConnectionQuality, ConnectionState, DisconnectReason, @@ -25,6 +24,7 @@ import { Room, RoomEvent, ScreenSharePresets, + TokenSource, Track, TrackPublication, VideoPresets, @@ -165,13 +165,13 @@ const appActions = { ): Promise => { const room = new Room(roomOptions); - const credentials = new ConnectionCredentials.Literal({ + const tokenSource = new TokenSource.Literal({ serverUrl: url, participantToken: token, }); startTime = Date.now(); - await room.prepareConnection(credentials); + await room.prepareConnection(tokenSource); const prewarmTime = Date.now() - startTime; appendLog(`prewarmed connection in ${prewarmTime}ms`); room.localParticipant.on(ParticipantEvent.LocalTrackCpuConstrained, (track, publication) => { @@ -413,7 +413,7 @@ const appActions = { reject(error); } }); - await Promise.all([room.connect(credentials, connectOptions), publishPromise]); + await Promise.all([room.connect(tokenSource, connectOptions), publishPromise]); const elapsed = Date.now() - startTime; appendLog( `successfully connected to ${room.name} in ${Math.round(elapsed)}ms`, diff --git a/src/index.ts b/src/index.ts index 3e8c0501d9..ce1df3715b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,7 @@ export * from './room/errors'; export * from './room/events'; export * from './room/track/Track'; export * from './room/track/create'; -export { ConnectionCredentials } from './room/ConnectionCredentials'; +export { TokenSource } from './room/TokenSource'; export { facingModeFromDeviceLabel, facingModeFromLocalTrack } from './room/track/facingMode'; export * from './room/track/options'; export * from './room/track/processor/types'; diff --git a/src/logger.ts b/src/logger.ts index 962fdc02a1..7ef6e02d1f 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -12,7 +12,7 @@ export enum LogLevel { export enum LoggerNames { Default = 'livekit', Room = 'livekit-room', - ConnectionCredentials = 'livekit-connection-credentials', + TokenSource = 'livekit-connection-credentials', Participant = 'livekit-participant', Track = 'livekit-track', Publication = 'livekit-track-publication', diff --git a/src/room/Room.ts b/src/room/Room.ts index f9038189a9..f6748221c8 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -43,10 +43,10 @@ import type { RoomOptions, } from '../options'; import { getBrowser } from '../utils/browserParser'; -import { ConnectionCredentials } from './ConnectionCredentials'; import DeviceManager from './DeviceManager'; import RTCEngine from './RTCEngine'; import { RegionUrlProvider } from './RegionUrlProvider'; +import { TokenSource } from './TokenSource'; import IncomingDataStreamManager from './data-stream/incoming/IncomingDataStreamManager'; import { type ByteStreamHandler, @@ -171,7 +171,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) /** future holding client initiated connection attempt */ private connectFuture?: Future; - private connectionCredentials?: ConnectionCredentials; + private connectionCredentials?: TokenSource; private disconnectLock: Mutex; @@ -591,31 +591,28 @@ class Room extends (EventEmitter as new () => TypedEmitter) * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ - prepareConnection(connectionCredentials: ConnectionCredentials): Promise; + prepareConnection(connectionCredentials: TokenSource): Promise; prepareConnection(url: string): Promise; - /** @deprecated Use room.prepareConnection(new ConnectionCredentials.Literal({ serverUrl: "url", participantToken: "token" })) instead */ + /** @deprecated Use room.prepareConnection(new TokenSource.Literal({ serverUrl: "url", participantToken: "token" })) instead */ prepareConnection(url: string, token?: string): Promise; async prepareConnection( - urlOrConnectionCredentials: ConnectionCredentials | string, + urlOrTokenSource: TokenSource | string, tokenOrUnknown?: string | undefined, ) { let url, token; - if ( - urlOrConnectionCredentials instanceof ConnectionCredentials && - typeof tokenOrUnknown !== 'string' - ) { - const result = await urlOrConnectionCredentials.generate(); + if (urlOrTokenSource instanceof TokenSource && typeof tokenOrUnknown !== 'string') { + const result = await urlOrTokenSource.generate(); url = result.serverUrl; token = result.participantToken; } else if ( - typeof urlOrConnectionCredentials === 'string' && + typeof urlOrTokenSource === 'string' && (typeof tokenOrUnknown === 'string' || typeof tokenOrUnknown === 'undefined') ) { - url = urlOrConnectionCredentials; + url = urlOrTokenSource; token = tokenOrUnknown; } else { throw new Error( - `Room.prepareConnection received invalid parameters - expected url, url/token or connectionCredentials, received ${urlOrConnectionCredentials}, ${tokenOrUnknown}`, + `Room.prepareConnection received invalid parameters - expected url, url/token or connectionCredentials, received ${urlOrTokenSource}, ${tokenOrUnknown}`, ); } @@ -643,26 +640,23 @@ class Room extends (EventEmitter as new () => TypedEmitter) } connect: { - (connectionCredentials: ConnectionCredentials, opts?: RoomConnectOptions): Promise; - /** @deprecated Use room.connect(new ConnectionCredentials.Literal({ serverUrl: "url", participantToken: "token" }), opts?: RoomConnectOptions) instead */ + (connectionCredentials: TokenSource, opts?: RoomConnectOptions): Promise; + /** @deprecated Use room.connect(new TokenSource.Literal({ serverUrl: "url", participantToken: "token" }), opts?: RoomConnectOptions) instead */ (url: string, token: string, opts?: RoomConnectOptions): Promise; - } = async (urlOrConnectionCredentials, tokenOrOpts, optsOrUnset?: unknown): Promise => { + } = async (urlOrTokenSource, tokenOrOpts, optsOrUnset?: unknown): Promise => { let opts: RoomConnectOptions = {}; - if ( - urlOrConnectionCredentials instanceof ConnectionCredentials && - typeof tokenOrOpts !== 'string' - ) { - this.connectionCredentials = urlOrConnectionCredentials; + if (urlOrTokenSource instanceof TokenSource && typeof tokenOrOpts !== 'string') { + this.connectionCredentials = urlOrTokenSource; opts = tokenOrOpts ?? {}; - } else if (typeof urlOrConnectionCredentials === 'string' && typeof tokenOrOpts === 'string') { - this.connectionCredentials = new ConnectionCredentials.Literal({ - serverUrl: urlOrConnectionCredentials, + } else if (typeof urlOrTokenSource === 'string' && typeof tokenOrOpts === 'string') { + this.connectionCredentials = new TokenSource.Literal({ + serverUrl: urlOrTokenSource, participantToken: tokenOrOpts, }); opts = optsOrUnset ?? {}; } else { throw new Error( - `Room.connect received invalid parameters - expected url/token or connectionCredentials, received ${urlOrConnectionCredentials}, ${tokenOrOpts}, ${optsOrUnset}`, + `Room.connect received invalid parameters - expected url/token or connectionCredentials, received ${urlOrTokenSource}, ${tokenOrOpts}, ${optsOrUnset}`, ); } diff --git a/src/room/ConnectionCredentials.ts b/src/room/TokenSource.ts similarity index 79% rename from src/room/ConnectionCredentials.ts rename to src/room/TokenSource.ts index 91e170525c..a47283fac5 100644 --- a/src/room/ConnectionCredentials.ts +++ b/src/room/TokenSource.ts @@ -5,11 +5,11 @@ import log, { LoggerNames, getLogger } from '../logger'; const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; -/** ConnectionCredentials handles generating credentials for connecting to a new Room */ -export abstract class ConnectionCredentials { - protected cachedResponse: ConnectionCredentials.Response | null = null; +/** TokenSource handles generating credentials for connecting to a new Room */ +export abstract class TokenSource { + protected cachedResponse: TokenSource.Response | null = null; - constructor(response: ConnectionCredentials.Response | null = null) { + constructor(response: TokenSource.Response | null = null) { this.cachedResponse = response; } @@ -70,9 +70,9 @@ export abstract class ConnectionCredentials { return this.getCachedResponseJwtPayload()?.attributes ?? null; } - abstract generate(): Promise; + abstract generate(): Promise; } -export namespace ConnectionCredentials { +export namespace TokenSource { export type Request = { /** The name of the room being requested when generating credentials */ roomName?: string; @@ -102,14 +102,14 @@ export namespace ConnectionCredentials { }; /** - * ConnectionCredentials.Refreshable handles getting credentials for connecting to a new Room from + * TokenSource.Refreshable handles getting credentials for connecting to a new Room from * an async source, caching them and auto refreshing them if they expire. */ - export abstract class Refreshable extends ConnectionCredentials { - private request: ConnectionCredentials.Request = {}; + export abstract class Refreshable extends TokenSource { + private request: TokenSource.Request = {}; - private inProgressFetch: Promise | null = null; + private inProgressFetch: Promise | null = null; - protected isSameAsCachedRequest(request: ConnectionCredentials.Request) { + protected isSameAsCachedRequest(request: TokenSource.Request) { if (!this.request) { return false; } @@ -140,8 +140,8 @@ export namespace ConnectionCredentials { /** * Store request metadata which will be provide explicitly when fetching new credentials. * - * @example new ConnectionCredentials.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ - setRequest(request: ConnectionCredentials.Request) { + * @example new TokenSource.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ + setRequest(request: TokenSource.Request) { if (!this.isSameAsCachedRequest(request)) { this.cachedResponse = null; } @@ -175,36 +175,34 @@ export namespace ConnectionCredentials { } } - protected abstract fetch( - request: ConnectionCredentials.Request, - ): Promise; + protected abstract fetch(request: TokenSource.Request): Promise; } - /** ConnectionCredentials.Literal contains a single, literal set of credentials. + /** TokenSource.Literal contains a single, literal set of credentials. * Note that refreshing credentials isn't implemented, because there is only one set provided. * */ - export class Literal extends ConnectionCredentials { + export class Literal extends TokenSource { private log = log; constructor(payload: Response) { super(payload); - this.log = getLogger(LoggerNames.ConnectionCredentials); + this.log = getLogger(LoggerNames.TokenSource); } async generate() { if (this.isCachedResponseExpired()) { this.log.warn( - 'The credentials within ConnectionCredentials.Literal have expired, so any upcoming uses of them will likely fail.', + 'The credentials within TokenSource.Literal have expired, so any upcoming uses of them will likely fail.', ); } return this.cachedResponse!; } } - /** ConnectionCredentials.Custom allows a user to define a manual function which generates new + /** TokenSource.Custom allows a user to define a manual function which generates new * {@link Response} values on demand. Use this to get credentials from custom backends / etc. * */ - export class Custom extends ConnectionCredentials.Refreshable { + export class Custom extends TokenSource.Refreshable { protected fetch: (request: Request) => Promise; constructor(handler: (request: Request) => Promise) { @@ -221,14 +219,14 @@ export namespace ConnectionCredentials { baseUrl?: string; }; - /** ConnectionCredentials.SandboxTokenServer queries a sandbox token server for credentials, + /** TokenSource.SandboxTokenServer queries a sandbox token server for credentials, * which supports quick prototyping / getting started types of use cases. * * This token provider is INSECURE and should NOT be used in production. * * For more info: * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ - export class SandboxTokenServer extends ConnectionCredentials.Refreshable { + export class SandboxTokenServer extends TokenSource.Refreshable { protected options: SandboxTokenServerOptions; constructor(options: SandboxTokenServerOptions) { From 1f8089d49044a7002c59c079df6045e65c3f315d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 18 Sep 2025 10:29:00 -0400 Subject: [PATCH 33/54] fix: update old token source name --- src/logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/logger.ts b/src/logger.ts index 7ef6e02d1f..23623bfb80 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -12,7 +12,7 @@ export enum LogLevel { export enum LoggerNames { Default = 'livekit', Room = 'livekit-room', - TokenSource = 'livekit-connection-credentials', + TokenSource = 'livekit-token-source', Participant = 'livekit-participant', Track = 'livekit-track', Publication = 'livekit-track-publication', From 97919559cc5586252ee9e633fac8753c89c252b9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 18 Sep 2025 10:34:20 -0400 Subject: [PATCH 34/54] feat: remove individual getters and return the whole jwt payload --- src/room/TokenSource.ts | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/room/TokenSource.ts b/src/room/TokenSource.ts index a47283fac5..8ca1f005e9 100644 --- a/src/room/TokenSource.ts +++ b/src/room/TokenSource.ts @@ -13,7 +13,7 @@ export abstract class TokenSource { this.cachedResponse = response; } - protected getCachedResponseJwtPayload() { + getCachedResponseJwtPayload() { const token = this.cachedResponse?.participantToken; if (!token) { return null; @@ -46,30 +46,6 @@ export abstract class TokenSource { return expiresAt >= now; } - getCachedResponseRoomConfig() { - const roomConfigJsonValue = this.getCachedResponseJwtPayload()?.roomConfig; - if (!roomConfigJsonValue) { - return null; - } - return RoomConfiguration.fromJson(roomConfigJsonValue); - } - - getCachedResponseParticipantName() { - return this.getCachedResponseJwtPayload()?.name ?? null; - } - - getCachedResponseParticipantIdentity() { - return this.getCachedResponseJwtPayload()?.sub ?? null; - } - - getCachedResponseParticipantMetadata() { - return this.getCachedResponseJwtPayload()?.metadata ?? null; - } - - getCachedResponseParticipantAttributes() { - return this.getCachedResponseJwtPayload()?.attributes ?? null; - } - abstract generate(): Promise; } export namespace TokenSource { From fa47c48fd4396a32a05eb82e99b5bc87a091bec2 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 18 Sep 2025 15:27:57 -0400 Subject: [PATCH 35/54] feat: add -Payload suffix to TokenSource.Request / TokenSource.Response, and make the fields snake case --- examples/demo/demo.ts | 4 +- src/room/Room.ts | 15 ++++---- src/room/TokenSource.ts | 81 ++++++++++++++++++++++------------------- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 1a96e67593..9235e73829 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -166,8 +166,8 @@ const appActions = { const room = new Room(roomOptions); const tokenSource = new TokenSource.Literal({ - serverUrl: url, - participantToken: token, + server_url: url, + participant_token: token, }); startTime = Date.now(); diff --git a/src/room/Room.ts b/src/room/Room.ts index f6748221c8..491ab3e7b0 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -593,7 +593,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) */ prepareConnection(connectionCredentials: TokenSource): Promise; prepareConnection(url: string): Promise; - /** @deprecated Use room.prepareConnection(new TokenSource.Literal({ serverUrl: "url", participantToken: "token" })) instead */ + /** @deprecated Use room.prepareConnection(new TokenSource.Literal({ server_url: "url", participant_token: "token" })) instead */ prepareConnection(url: string, token?: string): Promise; async prepareConnection( urlOrTokenSource: TokenSource | string, @@ -602,8 +602,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) let url, token; if (urlOrTokenSource instanceof TokenSource && typeof tokenOrUnknown !== 'string') { const result = await urlOrTokenSource.generate(); - url = result.serverUrl; - token = result.participantToken; + url = result.server_url; + token = result.participant_token; } else if ( typeof urlOrTokenSource === 'string' && (typeof tokenOrUnknown === 'string' || typeof tokenOrUnknown === 'undefined') @@ -641,7 +641,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) connect: { (connectionCredentials: TokenSource, opts?: RoomConnectOptions): Promise; - /** @deprecated Use room.connect(new TokenSource.Literal({ serverUrl: "url", participantToken: "token" }), opts?: RoomConnectOptions) instead */ + /** @deprecated Use room.connect(new TokenSource.Literal({ server_url: "url", participant_token: "token" }), opts?: RoomConnectOptions) instead */ (url: string, token: string, opts?: RoomConnectOptions): Promise; } = async (urlOrTokenSource, tokenOrOpts, optsOrUnset?: unknown): Promise => { let opts: RoomConnectOptions = {}; @@ -650,8 +650,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) opts = tokenOrOpts ?? {}; } else if (typeof urlOrTokenSource === 'string' && typeof tokenOrOpts === 'string') { this.connectionCredentials = new TokenSource.Literal({ - serverUrl: urlOrTokenSource, - participantToken: tokenOrOpts, + server_url: urlOrTokenSource, + participant_token: tokenOrOpts, }); opts = optsOrUnset ?? {}; } else { @@ -660,7 +660,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) ); } - const { serverUrl: url, participantToken: token } = await this.connectionCredentials.generate(); + const { server_url: url, participant_token: token } = + await this.connectionCredentials.generate(); if (!isBrowserSupported()) { if (isReactNative()) { diff --git a/src/room/TokenSource.ts b/src/room/TokenSource.ts index 8ca1f005e9..aca4247c36 100644 --- a/src/room/TokenSource.ts +++ b/src/room/TokenSource.ts @@ -7,14 +7,14 @@ const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; /** TokenSource handles generating credentials for connecting to a new Room */ export abstract class TokenSource { - protected cachedResponse: TokenSource.Response | null = null; + protected cachedResponse: TokenSource.ResponsePayload | null = null; - constructor(response: TokenSource.Response | null = null) { + constructor(response: TokenSource.ResponsePayload | null = null) { this.cachedResponse = response; } getCachedResponseJwtPayload() { - const token = this.cachedResponse?.participantToken; + const token = this.cachedResponse?.participant_token; if (!token) { return null; } @@ -46,66 +46,66 @@ export abstract class TokenSource { return expiresAt >= now; } - abstract generate(): Promise; + abstract generate(): Promise; } export namespace TokenSource { - export type Request = { + export type RequestPayload = { /** The name of the room being requested when generating credentials */ - roomName?: string; + room_name?: string; /** The name of the participant being requested for this client when generating credentials */ - participantName?: string; + participant_name?: string; /** The identity of the participant being requested for this client when generating credentials */ - participantIdentity?: string; + participant_identity?: string; /** Any participant metadata being included along with the credentials generation operation */ - participantMetadata?: string; + participant_metadata?: string; /** Any participant attributes being included along with the credentials generation operation */ - participantAttributes?: Record; + participant_attributes?: Record; /** * A RoomConfiguration object can be passed to request extra parameters should be included when * generating connection credentials - dispatching agents, defining egress settings, etc * @see https://docs.livekit.io/home/get-started/authentication/#room-configuration */ - roomConfig?: RoomConfiguration; + room_config?: RoomConfiguration; }; - export type Response = { - serverUrl: string; - participantToken: string; + export type ResponsePayload = { + server_url: string; + participant_token: string; }; /** * TokenSource.Refreshable handles getting credentials for connecting to a new Room from * an async source, caching them and auto refreshing them if they expire. */ export abstract class Refreshable extends TokenSource { - private request: TokenSource.Request = {}; + private request: TokenSource.RequestPayload = {}; - private inProgressFetch: Promise | null = null; + private inProgressFetch: Promise | null = null; - protected isSameAsCachedRequest(request: TokenSource.Request) { + protected isSameAsCachedRequest(request: TokenSource.RequestPayload) { if (!this.request) { return false; } - if (this.request.roomName !== request.roomName) { + if (this.request.room_name !== request.room_name) { return false; } - if (this.request.participantName !== request.participantName) { + if (this.request.participant_name !== request.participant_name) { return false; } if ( - (!this.request.roomConfig && request.roomConfig) || - (this.request.roomConfig && !request.roomConfig) + (!this.request.room_config && request.room_config) || + (this.request.room_config && !request.room_config) ) { return false; } if ( - this.request.roomConfig && - request.roomConfig && - !this.request.roomConfig.equals(request.roomConfig) + this.request.room_config && + request.room_config && + !this.request.room_config.equals(request.room_config) ) { return false; } @@ -117,7 +117,7 @@ export namespace TokenSource { * Store request metadata which will be provide explicitly when fetching new credentials. * * @example new TokenSource.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ - setRequest(request: TokenSource.Request) { + setRequest(request: TokenSource.RequestPayload) { if (!this.isSameAsCachedRequest(request)) { this.cachedResponse = null; } @@ -151,7 +151,9 @@ export namespace TokenSource { } } - protected abstract fetch(request: TokenSource.Request): Promise; + protected abstract fetch( + request: TokenSource.RequestPayload, + ): Promise; } /** TokenSource.Literal contains a single, literal set of credentials. @@ -160,7 +162,7 @@ export namespace TokenSource { export class Literal extends TokenSource { private log = log; - constructor(payload: Response) { + constructor(payload: ResponsePayload) { super(payload); this.log = getLogger(LoggerNames.TokenSource); } @@ -176,20 +178,20 @@ export namespace TokenSource { } /** TokenSource.Custom allows a user to define a manual function which generates new - * {@link Response} values on demand. Use this to get credentials from custom backends / etc. + * {@link ResponsePayload} values on demand. Use this to get credentials from custom backends / etc. * */ export class Custom extends TokenSource.Refreshable { - protected fetch: (request: Request) => Promise; + protected fetch: (request: RequestPayload) => Promise; - constructor(handler: (request: Request) => Promise) { + constructor(handler: (request: RequestPayload) => Promise) { super(); this.fetch = handler; } } export type SandboxTokenServerOptions = Pick< - Request, - 'roomName' | 'participantName' | 'roomConfig' + RequestPayload, + 'room_name' | 'participant_name' | 'room_config' > & { sandboxId: string; baseUrl?: string; @@ -210,12 +212,12 @@ export namespace TokenSource { this.options = options; } - async fetch(request: Request) { + async fetch(request: RequestPayload) { const baseUrl = this.options.baseUrl ?? 'https://cloud-api.livekit.io'; - const roomName = this.options.roomName ?? request.roomName; - const participantName = this.options.participantName ?? request.participantName; - const roomConfig = this.options.roomConfig ?? request.roomConfig; + const roomName = this.options.room_name ?? request.room_name; + const participantName = this.options.participant_name ?? request.participant_name; + const roomConfig = this.options.room_config ?? request.room_config; const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, { method: 'POST', @@ -236,8 +238,11 @@ export namespace TokenSource { ); } - const body: Exclude = await response.json(); - return { ...body, roomConfig }; + const rawBody = await response.json(); + return { + server_url: rawBody.serverUrl, + participant_token: rawBody.participantToken, + }; } } } From cfbc9e2c68638f643e66abe169205fe3e9d676e5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 19 Sep 2025 15:02:01 -0400 Subject: [PATCH 36/54] feat: add new TokenSource.Endpoint and update TokenSource.SandboxTokenServer to work with new v2 endpoint --- src/room/TokenSource.ts | 76 +++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/src/room/TokenSource.ts b/src/room/TokenSource.ts index aca4247c36..e4f551709e 100644 --- a/src/room/TokenSource.ts +++ b/src/room/TokenSource.ts @@ -189,60 +189,68 @@ export namespace TokenSource { } } - export type SandboxTokenServerOptions = Pick< - RequestPayload, - 'room_name' | 'participant_name' | 'room_config' - > & { - sandboxId: string; - baseUrl?: string; + export type EndpointOptions = { + headers?: Record; }; - /** TokenSource.SandboxTokenServer queries a sandbox token server for credentials, - * which supports quick prototyping / getting started types of use cases. - * - * This token provider is INSECURE and should NOT be used in production. - * - * For more info: - * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ - export class SandboxTokenServer extends TokenSource.Refreshable { - protected options: SandboxTokenServerOptions; + export class Endpoint extends TokenSource.Refreshable { + protected url: string; - constructor(options: SandboxTokenServerOptions) { + protected options: EndpointOptions | null; + + constructor(url: string, options?: EndpointOptions) { super(); - this.options = options; + this.url = url; + this.options = options ?? null; } async fetch(request: RequestPayload) { - const baseUrl = this.options.baseUrl ?? 'https://cloud-api.livekit.io'; - - const roomName = this.options.room_name ?? request.room_name; - const participantName = this.options.participant_name ?? request.participant_name; - const roomConfig = this.options.room_config ?? request.room_config; - - const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, { + const response = await fetch(this.url, { method: 'POST', headers: { - 'X-Sandbox-ID': this.options.sandboxId, 'Content-Type': 'application/json', + ...(this.options?.headers ?? {}), }, body: JSON.stringify({ - roomName, - participantName, - roomConfig: roomConfig?.toJson(), + ...request, + room_config: request.room_config?.toJson({ useProtoFieldName: true }), }), }); if (!response.ok) { throw new Error( - `Error generating token from sandbox token server: received ${response.status} / ${await response.text()}`, + `Error generating token from endpoint ${this.url}: received ${response.status} / ${await response.text()}`, ); } - const rawBody = await response.json(); - return { - server_url: rawBody.serverUrl, - participant_token: rawBody.participantToken, - }; + const body: ResponsePayload = await response.json(); + return body; + } + } + + export type SandboxTokenServerOptions = EndpointOptions & { + sandboxId: string; + baseUrl?: string; + }; + + /** TokenSource.SandboxTokenServer queries a sandbox token server for credentials, + * which supports quick prototyping / getting started types of use cases. + * + * This token provider is INSECURE and should NOT be used in production. + * + * For more info: + * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ + export class SandboxTokenServer extends TokenSource.Endpoint { + constructor(options: SandboxTokenServerOptions) { + const { sandboxId, baseUrl = 'https://cloud-api.livekit.io', ...rest } = options; + + super(`${baseUrl}/api/v2/sandbox/connection-details`, { + ...rest, + headers: { + ...(rest?.headers ?? {}), + 'X-Sandbox-ID': sandboxId, + }, + }); } } } From 9baa2607a44f364fc2f5ea1f6b9b246c8401ce67 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 19 Sep 2025 15:05:23 -0400 Subject: [PATCH 37/54] feat: add old sandbox token server back temporarily as SandboxTokenServerV1 Given the v2 changes aren't deployed to production yet, I just want to ensure that folks can still use this in the interim. I'll drop this commit before merging! --- src/room/TokenSource.ts | 56 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/room/TokenSource.ts b/src/room/TokenSource.ts index e4f551709e..bf28178476 100644 --- a/src/room/TokenSource.ts +++ b/src/room/TokenSource.ts @@ -253,4 +253,60 @@ export namespace TokenSource { }); } } + + export type SandboxTokenServerV1Options = Pick< + RequestPayload, + 'room_name' | 'participant_name' | 'room_config' + > & { + sandboxId: string; + baseUrl?: string; + }; + + /** + * A temporary v1 sandbox token server adaptor for backwards compatibility while the v2 endpoints + * are getting deployed. + * + * FIXME: get rid of this before merging the TokenSource pull request!! + * */ + export class SandboxTokenServerV1 extends TokenSource.Refreshable { + protected options: SandboxTokenServerV1Options; + + constructor(options: SandboxTokenServerV1Options) { + super(); + this.options = options; + } + + async fetch(request: RequestPayload) { + const baseUrl = this.options.baseUrl ?? 'https://cloud-api.livekit.io'; + + const roomName = this.options.room_name ?? request.room_name; + const participantName = this.options.participant_name ?? request.participant_name; + const roomConfig = this.options.room_config ?? request.room_config; + + const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, { + method: 'POST', + headers: { + 'X-Sandbox-ID': this.options.sandboxId, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + roomName, + participantName, + roomConfig: roomConfig?.toJson(), + }), + }); + + if (!response.ok) { + throw new Error( + `Error generating token from sandbox token server: received ${response.status} / ${await response.text()}`, + ); + } + + const rawBody = await response.json(); + return { + server_url: rawBody.serverUrl, + participant_token: rawBody.participantToken, + }; + } + } } From de1f6f6df20dfa1f50cf648188218bb1179ef008 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 22 Sep 2025 14:07:58 -0400 Subject: [PATCH 38/54] fix: update to TokenSource name in changeset --- .changeset/mean-mirrors-change.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mean-mirrors-change.md b/.changeset/mean-mirrors-change.md index 07f016e59d..952f63b6fb 100644 --- a/.changeset/mean-mirrors-change.md +++ b/.changeset/mean-mirrors-change.md @@ -2,4 +2,4 @@ 'livekit-client': patch --- -add ConnectionCredentials token fetching abstraction +add TokenSource token fetching abstraction From 8c8fba00354d163520396acf0bb52ef59b7b4934 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 22 Sep 2025 14:09:58 -0400 Subject: [PATCH 39/54] fix: update to tokenSource name in Room class --- src/room/Room.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 491ab3e7b0..020de19f25 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -171,7 +171,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) /** future holding client initiated connection attempt */ private connectFuture?: Future; - private connectionCredentials?: TokenSource; + private tokenSource?: TokenSource; private disconnectLock: Mutex; @@ -591,7 +591,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ - prepareConnection(connectionCredentials: TokenSource): Promise; + prepareConnection(tokenSource: TokenSource): Promise; prepareConnection(url: string): Promise; /** @deprecated Use room.prepareConnection(new TokenSource.Literal({ server_url: "url", participant_token: "token" })) instead */ prepareConnection(url: string, token?: string): Promise; @@ -612,7 +612,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) token = tokenOrUnknown; } else { throw new Error( - `Room.prepareConnection received invalid parameters - expected url, url/token or connectionCredentials, received ${urlOrTokenSource}, ${tokenOrUnknown}`, + `Room.prepareConnection received invalid parameters - expected url, url/token or tokenSource, received ${urlOrTokenSource}, ${tokenOrUnknown}`, ); } @@ -640,28 +640,27 @@ class Room extends (EventEmitter as new () => TypedEmitter) } connect: { - (connectionCredentials: TokenSource, opts?: RoomConnectOptions): Promise; + (tokenSource: TokenSource, opts?: RoomConnectOptions): Promise; /** @deprecated Use room.connect(new TokenSource.Literal({ server_url: "url", participant_token: "token" }), opts?: RoomConnectOptions) instead */ (url: string, token: string, opts?: RoomConnectOptions): Promise; } = async (urlOrTokenSource, tokenOrOpts, optsOrUnset?: unknown): Promise => { let opts: RoomConnectOptions = {}; if (urlOrTokenSource instanceof TokenSource && typeof tokenOrOpts !== 'string') { - this.connectionCredentials = urlOrTokenSource; + this.tokenSource = urlOrTokenSource; opts = tokenOrOpts ?? {}; } else if (typeof urlOrTokenSource === 'string' && typeof tokenOrOpts === 'string') { - this.connectionCredentials = new TokenSource.Literal({ + this.tokenSource = new TokenSource.Literal({ server_url: urlOrTokenSource, participant_token: tokenOrOpts, }); opts = optsOrUnset ?? {}; } else { throw new Error( - `Room.connect received invalid parameters - expected url/token or connectionCredentials, received ${urlOrTokenSource}, ${tokenOrOpts}, ${optsOrUnset}`, + `Room.connect received invalid parameters - expected url/token or tokenSource, received ${urlOrTokenSource}, ${tokenOrOpts}, ${optsOrUnset}`, ); } - const { server_url: url, participant_token: token } = - await this.connectionCredentials.generate(); + const { server_url: url, participant_token: token } = await this.tokenSource.generate(); if (!isBrowserSupported()) { if (isReactNative()) { @@ -1008,7 +1007,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED); /* @ts-ignore */ this.engine = undefined; - this.connectionCredentials?.generate(); + this.tokenSource?.generate(); } finally { unlock(); } From 5a767cb41fcf4412c4f3c40fc86db88e861d2090 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 23 Sep 2025 11:51:10 -0400 Subject: [PATCH 40/54] feat: migrate to using static method constructors for TokenSource types --- examples/demo/demo.ts | 2 +- src/room/TokenSource.ts | 40 ++++++++++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 9235e73829..85769211b0 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -165,7 +165,7 @@ const appActions = { ): Promise => { const room = new Room(roomOptions); - const tokenSource = new TokenSource.Literal({ + const tokenSource = TokenSource.literal({ server_url: url, participant_token: token, }); diff --git a/src/room/TokenSource.ts b/src/room/TokenSource.ts index bf28178476..adb64f50cc 100644 --- a/src/room/TokenSource.ts +++ b/src/room/TokenSource.ts @@ -156,9 +156,6 @@ export namespace TokenSource { ): Promise; } - /** TokenSource.Literal contains a single, literal set of credentials. - * Note that refreshing credentials isn't implemented, because there is only one set provided. - * */ export class Literal extends TokenSource { private log = log; @@ -177,9 +174,13 @@ export namespace TokenSource { } } - /** TokenSource.Custom allows a user to define a manual function which generates new - * {@link ResponsePayload} values on demand. Use this to get credentials from custom backends / etc. + /** TokenSource.Literal contains a single, literal set of credentials. + * Note that refreshing credentials isn't implemented, because there is only one set provided. * */ + export function literal(payload: ResponsePayload) { + return new Literal(payload); + } + export class Custom extends TokenSource.Refreshable { protected fetch: (request: RequestPayload) => Promise; @@ -189,6 +190,13 @@ export namespace TokenSource { } } + /** TokenSource.Custom allows a user to define a manual function which generates new + * {@link ResponsePayload} values on demand. Use this to get credentials from custom backends / etc. + * */ + export function custom(handler: (request: RequestPayload) => Promise) { + return new Custom(handler); + } + export type EndpointOptions = { headers?: Record; }; @@ -228,18 +236,15 @@ export namespace TokenSource { } } + export function endpoint(url: string, options?: EndpointOptions) { + return new Endpoint(url, options); + } + export type SandboxTokenServerOptions = EndpointOptions & { sandboxId: string; baseUrl?: string; }; - /** TokenSource.SandboxTokenServer queries a sandbox token server for credentials, - * which supports quick prototyping / getting started types of use cases. - * - * This token provider is INSECURE and should NOT be used in production. - * - * For more info: - * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ export class SandboxTokenServer extends TokenSource.Endpoint { constructor(options: SandboxTokenServerOptions) { const { sandboxId, baseUrl = 'https://cloud-api.livekit.io', ...rest } = options; @@ -254,6 +259,17 @@ export namespace TokenSource { } } + /** TokenSource.SandboxTokenServer queries a sandbox token server for credentials, + * which supports quick prototyping / getting started types of use cases. + * + * This token provider is INSECURE and should NOT be used in production. + * + * For more info: + * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ + export function sandboxTokenServer(options: SandboxTokenServerOptions) { + return new SandboxTokenServer(options); + } + export type SandboxTokenServerV1Options = Pick< RequestPayload, 'room_name' | 'participant_name' | 'room_config' From 058f10bf9f868c6840d15cc3049247bded9d79f2 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 23 Sep 2025 15:07:32 -0400 Subject: [PATCH 41/54] feat: add TokenSourceRequest/TokenSourceResponse protobuf messages into TokenSource --- examples/demo/demo.ts | 4 +- package.json | 2 +- pnpm-lock.yaml | 18 ++--- src/room/Room.ts | 10 +-- src/room/TokenSource.ts | 139 +++++++++++++++------------------- src/utils/camelToSnakeCase.ts | 16 ++++ 6 files changed, 94 insertions(+), 95 deletions(-) create mode 100644 src/utils/camelToSnakeCase.ts diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 85769211b0..1460188f43 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -166,8 +166,8 @@ const appActions = { const room = new Room(roomOptions); const tokenSource = TokenSource.literal({ - server_url: url, - participant_token: token, + serverUrl: url, + participantToken: token, }); startTime = Date.now(); diff --git a/package.json b/package.json index 80fb4766a3..1e35ff3918 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ }, "dependencies": { "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.41.0", + "@livekit/protocol": "1.42.0", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0fc7becef..49b9c4e561 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 1.1.1 version: 1.1.1 '@livekit/protocol': - specifier: 1.41.0 - version: 1.41.0 + specifier: 1.42.0 + version: 1.42.0 '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 @@ -1042,8 +1042,8 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.41.0': - resolution: {integrity: sha512-bozBB39VSbd0IjRBwShMlLskqkd9weWJNskaB1CVpcEO9UUI1gMwAtBJOKYblzZJT9kE1SJa3L4oWWwsZMzSXw==} + '@livekit/protocol@1.42.0': + resolution: {integrity: sha512-42sYSCay2PZrn5yHHt+O3RQpTElcTrA7bqg7iYbflUApeerA5tUCJDr8Z4abHsYHVKjqVUbkBq/TPmT3X6aYOQ==} '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -3453,8 +3453,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20250917: - resolution: {integrity: sha512-Z2rzVGN5WBboY5ySTadfuJm+pouf2bVSzpMZCDVjuBYZvdvZqt9m80J2gbIPPxqQOcRngk0TYlw07zCKxjlWEg==} + typescript@6.0.0-dev.20250923: + resolution: {integrity: sha512-Aiy0yklpKnRJkElhXO/bB2DhosYfL+j4RiUinq6C58ncyDOjuKn5wk6O5rds4n4AOobAqMjLU55H5q7bEyDcMQ==} engines: {node: '>=14.17'} hasBin: true @@ -4824,7 +4824,7 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.41.0': + '@livekit/protocol@1.42.0': dependencies: '@bufbuild/protobuf': 1.10.1 @@ -5687,7 +5687,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20250917 + typescript: 6.0.0-dev.20250923 dunder-proto@1.0.1: dependencies: @@ -7530,7 +7530,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20250917: {} + typescript@6.0.0-dev.20250923: {} uc.micro@2.1.0: {} diff --git a/src/room/Room.ts b/src/room/Room.ts index 020de19f25..fa1f5abb36 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -602,8 +602,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) let url, token; if (urlOrTokenSource instanceof TokenSource && typeof tokenOrUnknown !== 'string') { const result = await urlOrTokenSource.generate(); - url = result.server_url; - token = result.participant_token; + url = result.serverUrl; + token = result.participantToken; } else if ( typeof urlOrTokenSource === 'string' && (typeof tokenOrUnknown === 'string' || typeof tokenOrUnknown === 'undefined') @@ -650,8 +650,8 @@ class Room extends (EventEmitter as new () => TypedEmitter) opts = tokenOrOpts ?? {}; } else if (typeof urlOrTokenSource === 'string' && typeof tokenOrOpts === 'string') { this.tokenSource = new TokenSource.Literal({ - server_url: urlOrTokenSource, - participant_token: tokenOrOpts, + serverUrl: urlOrTokenSource, + participantToken: tokenOrOpts, }); opts = optsOrUnset ?? {}; } else { @@ -660,7 +660,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) ); } - const { server_url: url, participant_token: token } = await this.tokenSource.generate(); + const { serverUrl: url, participantToken: token } = await this.tokenSource.generate(); if (!isBrowserSupported()) { if (isReactNative()) { diff --git a/src/room/TokenSource.ts b/src/room/TokenSource.ts index adb64f50cc..d44e5ddcdf 100644 --- a/src/room/TokenSource.ts +++ b/src/room/TokenSource.ts @@ -1,20 +1,25 @@ -import { RoomConfiguration } from '@livekit/protocol'; +import { RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; import { decodeJwt } from 'jose'; import log, { LoggerNames, getLogger } from '../logger'; +import type { ValueToSnakeCase } from '../utils/camelToSnakeCase'; const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; +type RoomConfigurationPayload = ValueToSnakeCase< + NonNullable[0]> +>; + /** TokenSource handles generating credentials for connecting to a new Room */ export abstract class TokenSource { - protected cachedResponse: TokenSource.ResponsePayload | null = null; + protected cachedResponse: TokenSourceResponse | null = null; - constructor(response: TokenSource.ResponsePayload | null = null) { - this.cachedResponse = response; + constructor(response: TokenSource.PartialResponse | null = null) { + this.cachedResponse = response ? TokenSourceResponse.fromJson(response) : null; } getCachedResponseJwtPayload() { - const token = this.cachedResponse?.participant_token; + const token = this.cachedResponse?.participantToken; if (!token) { return null; } @@ -23,7 +28,7 @@ export abstract class TokenSource { name?: string; metadata?: string; attributes?: Record; - roomConfig?: ReturnType; + roomConfig?: RoomConfigurationPayload; video?: { room?: string; roomJoin?: boolean; @@ -46,90 +51,57 @@ export abstract class TokenSource { return expiresAt >= now; } - abstract generate(): Promise; + abstract generate(): Promise; } export namespace TokenSource { - export type RequestPayload = { - /** The name of the room being requested when generating credentials */ - room_name?: string; - - /** The name of the participant being requested for this client when generating credentials */ - participant_name?: string; - - /** The identity of the participant being requested for this client when generating credentials */ - participant_identity?: string; - - /** Any participant metadata being included along with the credentials generation operation */ - participant_metadata?: string; + export type PartialRequest = NonNullable[0]>; + export type PartialResponse = NonNullable[0]>; - /** Any participant attributes being included along with the credentials generation operation */ - participant_attributes?: Record; + /** The `TokenSource` request object sent to the server as part of fetching a refreshable + * `TokenSource` like Endpoint or SandboxTokenServer. + * + * Use this as a type for your request body if implementing a server endpoint in node.js. + */ + export type RequestPayload = ValueToSnakeCase; - /** - * A RoomConfiguration object can be passed to request extra parameters should be included when - * generating connection credentials - dispatching agents, defining egress settings, etc - * @see https://docs.livekit.io/home/get-started/authentication/#room-configuration - */ - room_config?: RoomConfiguration; - }; - export type ResponsePayload = { - server_url: string; - participant_token: string; - }; + /** The `TokenSource` response object sent from the server as part of fetching a refreshable + * `TokenSource` like Endpoint or SandboxTokenServer. + * + * Use this as a type for your response body if implementing a server endpoint in node.js. + */ + export type ResponsePayload = ValueToSnakeCase; /** * TokenSource.Refreshable handles getting credentials for connecting to a new Room from * an async source, caching them and auto refreshing them if they expire. */ export abstract class Refreshable extends TokenSource { - private request: TokenSource.RequestPayload = {}; + private request = new TokenSourceRequest(); private inProgressFetch: Promise | null = null; - protected isSameAsCachedRequest(request: TokenSource.RequestPayload) { - if (!this.request) { - return false; - } - - if (this.request.room_name !== request.room_name) { - return false; - } - if (this.request.participant_name !== request.participant_name) { - return false; - } - if ( - (!this.request.room_config && request.room_config) || - (this.request.room_config && !request.room_config) - ) { - return false; - } - if ( - this.request.room_config && - request.room_config && - !this.request.room_config.equals(request.room_config) - ) { - return false; - } - - return true; + protected isSameAsCachedRequest(request: TokenSourceRequest) { + return TokenSourceRequest.equals(this.request, request); } /** * Store request metadata which will be provide explicitly when fetching new credentials. * * @example new TokenSource.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ - setRequest(request: TokenSource.RequestPayload) { - if (!this.isSameAsCachedRequest(request)) { + setRequest(request: PartialRequest) { + const parsedRequest = new TokenSourceRequest(request); + + if (!this.isSameAsCachedRequest(parsedRequest)) { this.cachedResponse = null; } - this.request = request; + this.request = parsedRequest; } clearRequest() { - this.request = {}; + this.request = new TokenSourceRequest(); this.cachedResponse = null; } - async generate() { + async generate(): Promise { if (this.isCachedResponseExpired()) { await this.refresh(); } @@ -143,12 +115,23 @@ export namespace TokenSource { return; } + const requestPayload = this.request.toJson({ + useProtoFieldName: true, + }) as TokenSource.RequestPayload; + let responsePayload; + try { - this.inProgressFetch = this.fetch(this.request); - this.cachedResponse = await this.inProgressFetch; + this.inProgressFetch = this.fetch(requestPayload); + responsePayload = await this.inProgressFetch; } finally { this.inProgressFetch = null; } + + this.cachedResponse = TokenSourceResponse.fromJson(responsePayload, { + // NOTE: it could be possible that the responsePayload could contain more fields than just + // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer) + ignoreUnknownFields: true, + }); } protected abstract fetch( @@ -159,17 +142,18 @@ export namespace TokenSource { export class Literal extends TokenSource { private log = log; - constructor(payload: ResponsePayload) { + constructor(payload: PartialResponse) { super(payload); this.log = getLogger(LoggerNames.TokenSource); } - async generate() { + async generate(): Promise { if (this.isCachedResponseExpired()) { this.log.warn( 'The credentials within TokenSource.Literal have expired, so any upcoming uses of them will likely fail.', ); } + return this.cachedResponse!; } } @@ -177,8 +161,8 @@ export namespace TokenSource { /** TokenSource.Literal contains a single, literal set of credentials. * Note that refreshing credentials isn't implemented, because there is only one set provided. * */ - export function literal(payload: ResponsePayload) { - return new Literal(payload); + export function literal(response: PartialResponse) { + return new Literal(response); } export class Custom extends TokenSource.Refreshable { @@ -191,7 +175,9 @@ export namespace TokenSource { } /** TokenSource.Custom allows a user to define a manual function which generates new - * {@link ResponsePayload} values on demand. Use this to get credentials from custom backends / etc. + * {@link ResponsePayload} values on demand. + * + * Use this to get credentials from custom backends / etc. * */ export function custom(handler: (request: RequestPayload) => Promise) { return new Custom(handler); @@ -212,17 +198,14 @@ export namespace TokenSource { this.options = options ?? null; } - async fetch(request: RequestPayload) { + async fetch(request: TokenSource.RequestPayload): Promise { const response = await fetch(this.url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(this.options?.headers ?? {}), }, - body: JSON.stringify({ - ...request, - room_config: request.room_config?.toJson({ useProtoFieldName: true }), - }), + body: JSON.stringify(request), }); if (!response.ok) { @@ -231,7 +214,7 @@ export namespace TokenSource { ); } - const body: ResponsePayload = await response.json(); + const body: TokenSource.ResponsePayload = await response.json(); return body; } } @@ -308,7 +291,7 @@ export namespace TokenSource { body: JSON.stringify({ roomName, participantName, - roomConfig: roomConfig?.toJson(), + roomConfig, }), }); diff --git a/src/utils/camelToSnakeCase.ts b/src/utils/camelToSnakeCase.ts new file mode 100644 index 0000000000..c59dfbea78 --- /dev/null +++ b/src/utils/camelToSnakeCase.ts @@ -0,0 +1,16 @@ +export type CamelToSnakeCase = Str extends `${infer First}${infer Rest}` + ? `${First extends Capitalize ? '_' : ''}${Lowercase}${CamelToSnakeCase}` + : Str; + +type ArrayValuesToSnakeCase = Array>; + +type ObjectKeysToSnakeCase = { + [Key in keyof Obj as CamelToSnakeCase]: NonNullable>; +}; + +export type ValueToSnakeCase = + Value extends Array + ? ArrayValuesToSnakeCase + : Value extends object + ? ObjectKeysToSnakeCase + : Value; From cf268802e43638e895b4824a6250f23efed7bc32 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 24 Sep 2025 10:40:20 -0400 Subject: [PATCH 42/54] feat: expose protobufs in TokenSource.Custom implementation over raw snake case fields --- src/room/TokenSource.ts | 88 +++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/src/room/TokenSource.ts b/src/room/TokenSource.ts index d44e5ddcdf..4e13c00b0e 100644 --- a/src/room/TokenSource.ts +++ b/src/room/TokenSource.ts @@ -12,10 +12,10 @@ type RoomConfigurationPayload = ValueToSnakeCase< /** TokenSource handles generating credentials for connecting to a new Room */ export abstract class TokenSource { - protected cachedResponse: TokenSourceResponse | null = null; + protected cachedResponse: TokenSource.Response | null = null; constructor(response: TokenSource.PartialResponse | null = null) { - this.cachedResponse = response ? TokenSourceResponse.fromJson(response) : null; + this.cachedResponse = response ? TokenSource.Response.fromJson(response) : null; } getCachedResponseJwtPayload() { @@ -51,11 +51,17 @@ export abstract class TokenSource { return expiresAt >= now; } - abstract generate(): Promise; + abstract generate(): Promise; } export namespace TokenSource { - export type PartialRequest = NonNullable[0]>; - export type PartialResponse = NonNullable[0]>; + export const Request = TokenSourceRequest; + export type Request = TokenSourceRequest; + + export const Response = TokenSourceResponse; + export type Response = TokenSourceResponse; + + export type PartialRequest = NonNullable[0]>; + export type PartialResponse = NonNullable[0]>; /** The `TokenSource` request object sent to the server as part of fetching a refreshable * `TokenSource` like Endpoint or SandboxTokenServer. @@ -75,12 +81,12 @@ export namespace TokenSource { * TokenSource.Refreshable handles getting credentials for connecting to a new Room from * an async source, caching them and auto refreshing them if they expire. */ export abstract class Refreshable extends TokenSource { - private request = new TokenSourceRequest(); + private request = new TokenSource.Request(); - private inProgressFetch: Promise | null = null; + private inProgressFetch: Promise | null = null; - protected isSameAsCachedRequest(request: TokenSourceRequest) { - return TokenSourceRequest.equals(this.request, request); + protected isSameAsCachedRequest(request: TokenSource.Request) { + return TokenSource.Request.equals(this.request, request); } /** @@ -88,7 +94,7 @@ export namespace TokenSource { * * @example new TokenSource.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ setRequest(request: PartialRequest) { - const parsedRequest = new TokenSourceRequest(request); + const parsedRequest = new TokenSource.Request(request); if (!this.isSameAsCachedRequest(parsedRequest)) { this.cachedResponse = null; @@ -97,11 +103,11 @@ export namespace TokenSource { } clearRequest() { - this.request = new TokenSourceRequest(); + this.request = new TokenSource.Request(); this.cachedResponse = null; } - async generate(): Promise { + async generate(): Promise { if (this.isCachedResponseExpired()) { await this.refresh(); } @@ -115,28 +121,15 @@ export namespace TokenSource { return; } - const requestPayload = this.request.toJson({ - useProtoFieldName: true, - }) as TokenSource.RequestPayload; - let responsePayload; - try { - this.inProgressFetch = this.fetch(requestPayload); - responsePayload = await this.inProgressFetch; + this.inProgressFetch = this.fetch(this.request); + this.cachedResponse = await this.inProgressFetch; } finally { this.inProgressFetch = null; } - - this.cachedResponse = TokenSourceResponse.fromJson(responsePayload, { - // NOTE: it could be possible that the responsePayload could contain more fields than just - // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer) - ignoreUnknownFields: true, - }); } - protected abstract fetch( - request: TokenSource.RequestPayload, - ): Promise; + protected abstract fetch(request: TokenSource.Request): Promise; } export class Literal extends TokenSource { @@ -147,7 +140,7 @@ export namespace TokenSource { this.log = getLogger(LoggerNames.TokenSource); } - async generate(): Promise { + async generate(): Promise { if (this.isCachedResponseExpired()) { this.log.warn( 'The credentials within TokenSource.Literal have expired, so any upcoming uses of them will likely fail.', @@ -166,9 +159,9 @@ export namespace TokenSource { } export class Custom extends TokenSource.Refreshable { - protected fetch: (request: RequestPayload) => Promise; + protected fetch: (request: TokenSource.Request) => Promise; - constructor(handler: (request: RequestPayload) => Promise) { + constructor(handler: (request: TokenSource.Request) => Promise) { super(); this.fetch = handler; } @@ -179,7 +172,7 @@ export namespace TokenSource { * * Use this to get credentials from custom backends / etc. * */ - export function custom(handler: (request: RequestPayload) => Promise) { + export function custom(handler: (request: TokenSource.Request) => Promise) { return new Custom(handler); } @@ -198,14 +191,18 @@ export namespace TokenSource { this.options = options ?? null; } - async fetch(request: TokenSource.RequestPayload): Promise { + async fetch(request: TokenSource.Request): Promise { + const requestPayload = request.toJson({ + useProtoFieldName: true, + }) as TokenSource.RequestPayload; + const response = await fetch(this.url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(this.options?.headers ?? {}), }, - body: JSON.stringify(request), + body: JSON.stringify(requestPayload), }); if (!response.ok) { @@ -215,7 +212,11 @@ export namespace TokenSource { } const body: TokenSource.ResponsePayload = await response.json(); - return body; + return TokenSourceResponse.fromJson(body, { + // NOTE: it could be possible that the response body could contain more fields than just + // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer) + ignoreUnknownFields: true, + }); } } @@ -275,12 +276,16 @@ export namespace TokenSource { this.options = options; } - async fetch(request: RequestPayload) { + async fetch(request: TokenSource.Request) { + const requestPayload = request.toJson({ + useProtoFieldName: true, + }) as TokenSource.RequestPayload; + const baseUrl = this.options.baseUrl ?? 'https://cloud-api.livekit.io'; - const roomName = this.options.room_name ?? request.room_name; - const participantName = this.options.participant_name ?? request.participant_name; - const roomConfig = this.options.room_config ?? request.room_config; + const roomName = this.options.room_name ?? requestPayload.room_name; + const participantName = this.options.participant_name ?? requestPayload.participant_name; + const roomConfig = this.options.room_config ?? requestPayload.room_config; const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, { method: 'POST', @@ -302,10 +307,7 @@ export namespace TokenSource { } const rawBody = await response.json(); - return { - server_url: rawBody.serverUrl, - participant_token: rawBody.participantToken, - }; + return TokenSource.Response.fromJson(rawBody); } } } From ba1dd749518033cc1da6503fd853afc3f9c468f0 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 29 Sep 2025 11:01:07 -0400 Subject: [PATCH 43/54] feat: get rid of tokensource integration logic in Room temporarily Lukas proposed that we decouple this from the initial change, and discuss it afterwards. --- examples/demo/demo.ts | 10 ++------ src/room/Room.ts | 54 ++----------------------------------------- 2 files changed, 4 insertions(+), 60 deletions(-) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 1460188f43..228708029f 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -24,7 +24,6 @@ import { Room, RoomEvent, ScreenSharePresets, - TokenSource, Track, TrackPublication, VideoPresets, @@ -165,13 +164,8 @@ const appActions = { ): Promise => { const room = new Room(roomOptions); - const tokenSource = TokenSource.literal({ - serverUrl: url, - participantToken: token, - }); - startTime = Date.now(); - await room.prepareConnection(tokenSource); + await room.prepareConnection(url, token); const prewarmTime = Date.now() - startTime; appendLog(`prewarmed connection in ${prewarmTime}ms`); room.localParticipant.on(ParticipantEvent.LocalTrackCpuConstrained, (track, publication) => { @@ -413,7 +407,7 @@ const appActions = { reject(error); } }); - await Promise.all([room.connect(tokenSource, connectOptions), publishPromise]); + await Promise.all([room.connect(url, token, connectOptions), publishPromise]); const elapsed = Date.now() - startTime; appendLog( `successfully connected to ${room.name} in ${Math.round(elapsed)}ms`, diff --git a/src/room/Room.ts b/src/room/Room.ts index fa1f5abb36..a46e173cad 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -46,7 +46,6 @@ import { getBrowser } from '../utils/browserParser'; import DeviceManager from './DeviceManager'; import RTCEngine from './RTCEngine'; import { RegionUrlProvider } from './RegionUrlProvider'; -import { TokenSource } from './TokenSource'; import IncomingDataStreamManager from './data-stream/incoming/IncomingDataStreamManager'; import { type ByteStreamHandler, @@ -171,8 +170,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) /** future holding client initiated connection attempt */ private connectFuture?: Future; - private tokenSource?: TokenSource; - private disconnectLock: Mutex; private e2eeManager: BaseE2EEManager | undefined; @@ -591,31 +588,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ - prepareConnection(tokenSource: TokenSource): Promise; - prepareConnection(url: string): Promise; - /** @deprecated Use room.prepareConnection(new TokenSource.Literal({ server_url: "url", participant_token: "token" })) instead */ - prepareConnection(url: string, token?: string): Promise; - async prepareConnection( - urlOrTokenSource: TokenSource | string, - tokenOrUnknown?: string | undefined, - ) { - let url, token; - if (urlOrTokenSource instanceof TokenSource && typeof tokenOrUnknown !== 'string') { - const result = await urlOrTokenSource.generate(); - url = result.serverUrl; - token = result.participantToken; - } else if ( - typeof urlOrTokenSource === 'string' && - (typeof tokenOrUnknown === 'string' || typeof tokenOrUnknown === 'undefined') - ) { - url = urlOrTokenSource; - token = tokenOrUnknown; - } else { - throw new Error( - `Room.prepareConnection received invalid parameters - expected url, url/token or tokenSource, received ${urlOrTokenSource}, ${tokenOrUnknown}`, - ); - } - + async prepareConnection(url: string, token?: string) { if (this.state !== ConnectionState.Disconnected) { return; } @@ -639,29 +612,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) } } - connect: { - (tokenSource: TokenSource, opts?: RoomConnectOptions): Promise; - /** @deprecated Use room.connect(new TokenSource.Literal({ server_url: "url", participant_token: "token" }), opts?: RoomConnectOptions) instead */ - (url: string, token: string, opts?: RoomConnectOptions): Promise; - } = async (urlOrTokenSource, tokenOrOpts, optsOrUnset?: unknown): Promise => { - let opts: RoomConnectOptions = {}; - if (urlOrTokenSource instanceof TokenSource && typeof tokenOrOpts !== 'string') { - this.tokenSource = urlOrTokenSource; - opts = tokenOrOpts ?? {}; - } else if (typeof urlOrTokenSource === 'string' && typeof tokenOrOpts === 'string') { - this.tokenSource = new TokenSource.Literal({ - serverUrl: urlOrTokenSource, - participantToken: tokenOrOpts, - }); - opts = optsOrUnset ?? {}; - } else { - throw new Error( - `Room.connect received invalid parameters - expected url/token or tokenSource, received ${urlOrTokenSource}, ${tokenOrOpts}, ${optsOrUnset}`, - ); - } - - const { serverUrl: url, participantToken: token } = await this.tokenSource.generate(); - + connect = async (url: string, token: string, opts?: RoomConnectOptions) => { if (!isBrowserSupported()) { if (isReactNative()) { throw Error("WebRTC isn't detected, have you called registerGlobals?"); @@ -1007,7 +958,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED); /* @ts-ignore */ this.engine = undefined; - this.tokenSource?.generate(); } finally { unlock(); } From bcfa010e8d3df39d9d557a53cad6ddb2597cc9a8 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 29 Sep 2025 11:03:03 -0400 Subject: [PATCH 44/54] feat: more over TokenSource modifications from components-js --- src/room/TokenSource.ts | 492 ++++++++++++++++++++-------------------- 1 file changed, 243 insertions(+), 249 deletions(-) diff --git a/src/room/TokenSource.ts b/src/room/TokenSource.ts index 4e13c00b0e..e4b0fc4555 100644 --- a/src/room/TokenSource.ts +++ b/src/room/TokenSource.ts @@ -1,313 +1,307 @@ -import { RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; -import { decodeJwt } from 'jose'; -import log, { LoggerNames, getLogger } from '../logger'; -import type { ValueToSnakeCase } from '../utils/camelToSnakeCase'; +import { Mutex } from '@livekit/mutex'; +import { RoomAgentDispatch, RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; +import { decodeJwt, type JWTPayload } from 'jose'; const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; -type RoomConfigurationPayload = ValueToSnakeCase< - NonNullable[0]> ->; +export type TokenSourceResponseObject = Required[0]>>; -/** TokenSource handles generating credentials for connecting to a new Room */ -export abstract class TokenSource { - protected cachedResponse: TokenSource.Response | null = null; +type RoomConfigurationPayload = NonNullable[0]>; - constructor(response: TokenSource.PartialResponse | null = null) { - this.cachedResponse = response ? TokenSource.Response.fromJson(response) : null; - } - getCachedResponseJwtPayload() { - const token = this.cachedResponse?.participantToken; - if (!token) { - return null; - } +export abstract class TokenSourceFixed { + abstract fetch(): Promise; +} - return decodeJwt<{ - name?: string; - metadata?: string; - attributes?: Record; - roomConfig?: RoomConfigurationPayload; - video?: { - room?: string; - roomJoin?: boolean; - canPublish?: boolean; - canPublishData?: boolean; - canSubscribe?: boolean; - }; - }>(token); - } +export type TokenSourceOptions = { + roomName?: string; + participantName?: string; + participantIdentity?: string; + participantMetadata?: string; + participantAttributes?: { [key: string]: string }; + + agentName?: string; +}; + +export abstract class TokenSourceConfigurable { + abstract fetch(options: TokenSourceOptions): Promise; +} + +export type TokenSourceBase = TokenSourceFixed | TokenSourceConfigurable; - protected isCachedResponseExpired() { - const jwtPayload = this.getCachedResponseJwtPayload(); - if (!jwtPayload?.exp) { - return true; - } - const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS; - const expiresAt = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS); - const now = new Date(); - return expiresAt >= now; + +function isResponseExpired(response: TokenSourceResponse) { + const jwtPayload = decodeTokenPayload(response.participantToken); + if (!jwtPayload?.exp) { + return true; } + const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS; + const expiresAt = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS); - abstract generate(): Promise; + const now = new Date(); + return expiresAt >= now; } -export namespace TokenSource { - export const Request = TokenSourceRequest; - export type Request = TokenSourceRequest; - export const Response = TokenSourceResponse; - export type Response = TokenSourceResponse; +type TokenPayload = JWTPayload & { + name?: string; + metadata?: string; + attributes?: Record; + video?: { + room?: string; + roomJoin?: boolean; + canPublish?: boolean; + canPublishData?: boolean; + canSubscribe?: boolean; + }; + roomConfig?: RoomConfigurationPayload, +}; - export type PartialRequest = NonNullable[0]>; - export type PartialResponse = NonNullable[0]>; +function decodeTokenPayload(token: string) { + const payload = decodeJwt>(token); - /** The `TokenSource` request object sent to the server as part of fetching a refreshable - * `TokenSource` like Endpoint or SandboxTokenServer. - * - * Use this as a type for your request body if implementing a server endpoint in node.js. - */ - export type RequestPayload = ValueToSnakeCase; + const { roomConfig, ...rest } = payload; - /** The `TokenSource` response object sent from the server as part of fetching a refreshable - * `TokenSource` like Endpoint or SandboxTokenServer. - * - * Use this as a type for your response body if implementing a server endpoint in node.js. - */ - export type ResponsePayload = ValueToSnakeCase; + const mappedPayload: TokenPayload = { + ...rest, + roomConfig: payload.roomConfig + ? RoomConfiguration.fromJson(payload.roomConfig as Record) as RoomConfigurationPayload + : undefined, + }; - /** - * TokenSource.Refreshable handles getting credentials for connecting to a new Room from - * an async source, caching them and auto refreshing them if they expire. */ - export abstract class Refreshable extends TokenSource { - private request = new TokenSource.Request(); + return mappedPayload; +} - private inProgressFetch: Promise | null = null; +export abstract class TokenSourceRefreshable extends TokenSourceConfigurable { + private cachedOptions: TokenSourceOptions | null = null; + private cachedResponse: TokenSourceResponse | null = null; - protected isSameAsCachedRequest(request: TokenSource.Request) { - return TokenSource.Request.equals(this.request, request); - } + private fetchMutex = new Mutex(); - /** - * Store request metadata which will be provide explicitly when fetching new credentials. - * - * @example new TokenSource.Custom((request /* <= This value! *\/) => ({ serverUrl: "...", participantToken: "..." })) */ - setRequest(request: PartialRequest) { - const parsedRequest = new TokenSource.Request(request); + protected isSameAsCachedOptions(options: TokenSourceOptions) { + if (!this.cachedOptions) { + return false; + } - if (!this.isSameAsCachedRequest(parsedRequest)) { - this.cachedResponse = null; + for (const key of Object.keys(this.cachedOptions) as Array) { + switch (key) { + case 'roomName': + case 'participantName': + case 'participantIdentity': + case 'participantMetadata': + case 'participantAttributes': + case 'agentName': + if (this.cachedOptions[key] !== options[key]) { + return false; + } + break; + default: + // ref: https://stackoverflow.com/a/58009992 + const exhaustiveCheckedKey: never = key; + throw new Error(`Options key ${exhaustiveCheckedKey} not being checked for equality!`); } - this.request = parsedRequest; } - clearRequest() { - this.request = new TokenSource.Request(); - this.cachedResponse = null; - } + return true; + } - async generate(): Promise { - if (this.isCachedResponseExpired()) { - await this.refresh(); - } + protected shouldUseCachedValue(options: TokenSourceOptions) { + if (!this.cachedResponse) { + return false; + } + if (isResponseExpired(this.cachedResponse)) { + return false; + } + if (this.isSameAsCachedOptions(options)) { + return false; + } + return true; + } - return this.cachedResponse!; + getCachedResponseJwtPayload() { + if (!this.cachedResponse) { + return null; } + return decodeTokenPayload(this.cachedResponse.participantToken); + } - async refresh() { - if (this.inProgressFetch) { - await this.inProgressFetch; - return; + async fetch(options: TokenSourceOptions): Promise { + const unlock = await this.fetchMutex.lock(); + try { + if (this.shouldUseCachedValue(options)) { + return this.cachedResponse!.toJson() as TokenSourceResponseObject; } + this.cachedOptions = options; - try { - this.inProgressFetch = this.fetch(this.request); - this.cachedResponse = await this.inProgressFetch; - } finally { - this.inProgressFetch = null; - } + const tokenResponse = await this.update(options); + this.cachedResponse = tokenResponse; + return tokenResponse; + } finally { + unlock(); } - - protected abstract fetch(request: TokenSource.Request): Promise; } - export class Literal extends TokenSource { - private log = log; + protected abstract update(options: TokenSourceOptions): Promise; +} - constructor(payload: PartialResponse) { - super(payload); - this.log = getLogger(LoggerNames.TokenSource); - } - async generate(): Promise { - if (this.isCachedResponseExpired()) { - this.log.warn( - 'The credentials within TokenSource.Literal have expired, so any upcoming uses of them will likely fail.', - ); - } +type LiteralOrFn = TokenSourceResponseObject | (() => TokenSourceResponseObject | Promise); +export class TokenSourceLiteral extends TokenSourceFixed { + private literalOrFn: LiteralOrFn; + + constructor(literalOrFn: LiteralOrFn) { + super(); + this.literalOrFn = literalOrFn; + } - return this.cachedResponse!; + async fetch(): Promise { + if (typeof this.literalOrFn === 'function') { + return this.literalOrFn(); + } else { + return this.literalOrFn; } } +} + - /** TokenSource.Literal contains a single, literal set of credentials. - * Note that refreshing credentials isn't implemented, because there is only one set provided. - * */ - export function literal(response: PartialResponse) { - return new Literal(response); +type CustomFn = (options: TokenSourceOptions) => TokenSourceResponseObject | Promise; + +class TokenSourceCustom extends TokenSourceRefreshable { + private customFn: CustomFn; + constructor(customFn: CustomFn) { + super(); + this.customFn = customFn; } - export class Custom extends TokenSource.Refreshable { - protected fetch: (request: TokenSource.Request) => Promise; + protected async update(options: TokenSourceOptions) { + const resultMaybePromise = this.customFn(options); - constructor(handler: (request: TokenSource.Request) => Promise) { - super(); - this.fetch = handler; + let result; + if (resultMaybePromise instanceof Promise) { + result = await resultMaybePromise; + } else { + result = resultMaybePromise; } - } - /** TokenSource.Custom allows a user to define a manual function which generates new - * {@link ResponsePayload} values on demand. - * - * Use this to get credentials from custom backends / etc. - * */ - export function custom(handler: (request: TokenSource.Request) => Promise) { - return new Custom(handler); + return TokenSourceResponse.fromJson(result, { + // NOTE: it could be possible that the response body could contain more fields than just + // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer) + ignoreUnknownFields: true, + }); } +} - export type EndpointOptions = { - headers?: Record; - }; - export class Endpoint extends TokenSource.Refreshable { - protected url: string; +export type EndpointOptions = Omit; - protected options: EndpointOptions | null; +class TokenSourceEndpoint extends TokenSourceRefreshable { + private url: string; + private endpointOptions: EndpointOptions; - constructor(url: string, options?: EndpointOptions) { - super(); - this.url = url; - this.options = options ?? null; - } - - async fetch(request: TokenSource.Request): Promise { - const requestPayload = request.toJson({ - useProtoFieldName: true, - }) as TokenSource.RequestPayload; - - const response = await fetch(this.url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(this.options?.headers ?? {}), - }, - body: JSON.stringify(requestPayload), - }); - - if (!response.ok) { - throw new Error( - `Error generating token from endpoint ${this.url}: received ${response.status} / ${await response.text()}`, - ); - } + constructor(url: string, options: EndpointOptions = {}) { + super(); + this.url = url; + this.endpointOptions = options; + } - const body: TokenSource.ResponsePayload = await response.json(); - return TokenSourceResponse.fromJson(body, { - // NOTE: it could be possible that the response body could contain more fields than just - // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer) - ignoreUnknownFields: true, - }); + protected async update(options: TokenSourceOptions) { + // NOTE: I don't like the repetitive nature of this, `options` shouldn't be a thing, + // `request` should just be passed through instead... + const request = new TokenSourceRequest(); + request.roomName = options.roomName; + request.participantName = options.participantName; + request.participantIdentity = options.participantIdentity; + request.participantMetadata = options.participantMetadata; + request.participantAttributes = options.participantAttributes ?? {}; + request.roomConfig = options.agentName ? ( + new RoomConfiguration({ + agents: [ + new RoomAgentDispatch({ + agentName: options.agentName, + metadata: '', // FIXME: how do I support this? Maybe make agentName -> agentToDispatch? + }), + ], + }) + ) : undefined; + + const response = await fetch(this.url, { + ...this.endpointOptions, + method: this.endpointOptions.method ?? 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.endpointOptions.headers, + }, + body: request.toJsonString(), + }); + + if (!response.ok) { + throw new Error( + `Error generating token from endpoint ${this.url}: received ${response.status} / ${await response.text()}`, + ); } - } - export function endpoint(url: string, options?: EndpointOptions) { - return new Endpoint(url, options); + const body = await response.json(); + return TokenSourceResponse.fromJson(body, { + // NOTE: it could be possible that the response body could contain more fields than just + // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer) + ignoreUnknownFields: true, + }); } +} - export type SandboxTokenServerOptions = EndpointOptions & { - sandboxId: string; - baseUrl?: string; - }; +export type SandboxTokenServerOptions = { + baseUrl?: string; +}; - export class SandboxTokenServer extends TokenSource.Endpoint { - constructor(options: SandboxTokenServerOptions) { - const { sandboxId, baseUrl = 'https://cloud-api.livekit.io', ...rest } = options; - - super(`${baseUrl}/api/v2/sandbox/connection-details`, { - ...rest, - headers: { - ...(rest?.headers ?? {}), - 'X-Sandbox-ID': sandboxId, - }, - }); - } - } +export class TokenSourceSandboxTokenServer extends TokenSourceEndpoint { + constructor(sandboxId: string, options: SandboxTokenServerOptions) { + const { baseUrl = 'https://cloud-api.livekit.io', ...rest } = options; - /** TokenSource.SandboxTokenServer queries a sandbox token server for credentials, - * which supports quick prototyping / getting started types of use cases. - * - * This token provider is INSECURE and should NOT be used in production. - * - * For more info: - * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ - export function sandboxTokenServer(options: SandboxTokenServerOptions) { - return new SandboxTokenServer(options); + super(`${baseUrl}/api/v2/sandbox/connection-details`, { + ...rest, + headers: { + 'X-Sandbox-ID': sandboxId, + }, + }); } +} - export type SandboxTokenServerV1Options = Pick< - RequestPayload, - 'room_name' | 'participant_name' | 'room_config' - > & { - sandboxId: string; - baseUrl?: string; - }; +export const TokenSource = { + /** TokenSource.literal contains a single, literal set of credentials. */ + literal(literalOrFn: LiteralOrFn) { + return new TokenSourceLiteral(literalOrFn); + }, /** - * A temporary v1 sandbox token server adaptor for backwards compatibility while the v2 endpoints - * are getting deployed. + * TokenSource.custom allows a user to define a manual function which generates new + * {@link ResponsePayload} values on demand. * - * FIXME: get rid of this before merging the TokenSource pull request!! - * */ - export class SandboxTokenServerV1 extends TokenSource.Refreshable { - protected options: SandboxTokenServerV1Options; - - constructor(options: SandboxTokenServerV1Options) { - super(); - this.options = options; - } + * Use this to get credentials from custom backends / etc. + */ + custom(customFn: CustomFn) { + return new TokenSourceCustom(customFn); + }, - async fetch(request: TokenSource.Request) { - const requestPayload = request.toJson({ - useProtoFieldName: true, - }) as TokenSource.RequestPayload; - - const baseUrl = this.options.baseUrl ?? 'https://cloud-api.livekit.io'; - - const roomName = this.options.room_name ?? requestPayload.room_name; - const participantName = this.options.participant_name ?? requestPayload.participant_name; - const roomConfig = this.options.room_config ?? requestPayload.room_config; - - const response = await fetch(`${baseUrl}/api/sandbox/connection-details`, { - method: 'POST', - headers: { - 'X-Sandbox-ID': this.options.sandboxId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - roomName, - participantName, - roomConfig, - }), - }); - - if (!response.ok) { - throw new Error( - `Error generating token from sandbox token server: received ${response.status} / ${await response.text()}`, - ); - } + /** + * TokenSource.endpoint creates a token source that fetches credentials from a given URL using + * the standard endpoint format: + * FIXME: add docs link here in the future! + */ + endpoint(url: string, options: EndpointOptions = {}) { + return new TokenSourceEndpoint(url, options); + }, - const rawBody = await response.json(); - return TokenSource.Response.fromJson(rawBody); - } - } -} + /** + * TokenSource.sandboxTokenServer queries a sandbox token server for credentials, + * which supports quick prototyping / getting started types of use cases. + * + * This token provider is INSECURE and should NOT be used in production. + * + * For more info: + * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server + */ + sandboxTokenServer(sandboxId: string, options: SandboxTokenServerOptions) { + return new TokenSourceSandboxTokenServer(sandboxId, options); + }, +}; From dd811e610d73a018bf2d23cf373c98aed8554c88 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 29 Sep 2025 11:51:38 -0400 Subject: [PATCH 45/54] refactor: split up TokenSource into multiple files and add lots of docs --- src/index.ts | 2 +- src/room/{ => token-source}/TokenSource.ts | 151 +++++++-------------- src/room/token-source/types.ts | 75 ++++++++++ src/room/token-source/utils.ts | 33 +++++ 4 files changed, 160 insertions(+), 101 deletions(-) rename src/room/{ => token-source}/TokenSource.ts (65%) create mode 100644 src/room/token-source/types.ts create mode 100644 src/room/token-source/utils.ts diff --git a/src/index.ts b/src/index.ts index ce1df3715b..a333762051 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,7 @@ export * from './room/errors'; export * from './room/events'; export * from './room/track/Track'; export * from './room/track/create'; -export { TokenSource } from './room/TokenSource'; +export { TokenSource } from './room/token-source/TokenSource'; export { facingModeFromDeviceLabel, facingModeFromLocalTrack } from './room/track/facingMode'; export * from './room/track/options'; export * from './room/track/processor/types'; diff --git a/src/room/TokenSource.ts b/src/room/token-source/TokenSource.ts similarity index 65% rename from src/room/TokenSource.ts rename to src/room/token-source/TokenSource.ts index e4b0fc4555..bab27147c5 100644 --- a/src/room/TokenSource.ts +++ b/src/room/token-source/TokenSource.ts @@ -1,85 +1,17 @@ import { Mutex } from '@livekit/mutex'; import { RoomAgentDispatch, RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; -import { decodeJwt, type JWTPayload } from 'jose'; +import { TokenSourceConfigurable, TokenSourceFixed, type TokenSourceOptions, type TokenSourceResponseObject } from './types'; +import { decodeTokenPayload, isResponseExpired } from './utils'; -const ONE_SECOND_IN_MILLISECONDS = 1000; -const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; - -export type TokenSourceResponseObject = Required[0]>>; - -type RoomConfigurationPayload = NonNullable[0]>; - - -export abstract class TokenSourceFixed { - abstract fetch(): Promise; -} - -export type TokenSourceOptions = { - roomName?: string; - participantName?: string; - participantIdentity?: string; - participantMetadata?: string; - participantAttributes?: { [key: string]: string }; - - agentName?: string; -}; - -export abstract class TokenSourceConfigurable { - abstract fetch(options: TokenSourceOptions): Promise; -} - -export type TokenSourceBase = TokenSourceFixed | TokenSourceConfigurable; - - - -function isResponseExpired(response: TokenSourceResponse) { - const jwtPayload = decodeTokenPayload(response.participantToken); - if (!jwtPayload?.exp) { - return true; - } - const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS; - const expiresAt = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS); - - const now = new Date(); - return expiresAt >= now; -} - -type TokenPayload = JWTPayload & { - name?: string; - metadata?: string; - attributes?: Record; - video?: { - room?: string; - roomJoin?: boolean; - canPublish?: boolean; - canPublishData?: boolean; - canSubscribe?: boolean; - }; - roomConfig?: RoomConfigurationPayload, -}; - -function decodeTokenPayload(token: string) { - const payload = decodeJwt>(token); - - const { roomConfig, ...rest } = payload; - - const mappedPayload: TokenPayload = { - ...rest, - roomConfig: payload.roomConfig - ? RoomConfiguration.fromJson(payload.roomConfig as Record) as RoomConfigurationPayload - : undefined, - }; - - return mappedPayload; -} - -export abstract class TokenSourceRefreshable extends TokenSourceConfigurable { +/** A TokenSourceCached is a TokenSource which caches the last {@link TokenSourceResponseObject} value and returns it + * until a) it expires or b) the {@link TokenSourceOptions} provided to .fetch(...) change. */ +abstract class TokenSourceCached extends TokenSourceConfigurable { private cachedOptions: TokenSourceOptions | null = null; private cachedResponse: TokenSourceResponse | null = null; private fetchMutex = new Mutex(); - protected isSameAsCachedOptions(options: TokenSourceOptions) { + private isSameAsCachedOptions(options: TokenSourceOptions) { if (!this.cachedOptions) { return false; } @@ -106,7 +38,7 @@ export abstract class TokenSourceRefreshable extends TokenSourceConfigurable { return true; } - protected shouldUseCachedValue(options: TokenSourceOptions) { + private shouldReturnCachedValueFromFetch(options: TokenSourceOptions) { if (!this.cachedResponse) { return false; } @@ -129,14 +61,14 @@ export abstract class TokenSourceRefreshable extends TokenSourceConfigurable { async fetch(options: TokenSourceOptions): Promise { const unlock = await this.fetchMutex.lock(); try { - if (this.shouldUseCachedValue(options)) { + if (this.shouldReturnCachedValueFromFetch(options)) { return this.cachedResponse!.toJson() as TokenSourceResponseObject; } this.cachedOptions = options; const tokenResponse = await this.update(options); this.cachedResponse = tokenResponse; - return tokenResponse; + return tokenResponse.toJson() as TokenSourceResponseObject; } finally { unlock(); } @@ -166,8 +98,7 @@ export class TokenSourceLiteral extends TokenSourceFixed { type CustomFn = (options: TokenSourceOptions) => TokenSourceResponseObject | Promise; - -class TokenSourceCustom extends TokenSourceRefreshable { +export class TokenSourceCustom extends TokenSourceCached { private customFn: CustomFn; constructor(customFn: CustomFn) { super(); @@ -186,7 +117,7 @@ class TokenSourceCustom extends TokenSourceRefreshable { return TokenSourceResponse.fromJson(result, { // NOTE: it could be possible that the response body could contain more fields than just - // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer) + // what's in TokenSourceResponse depending on the implementation ignoreUnknownFields: true, }); } @@ -195,7 +126,7 @@ class TokenSourceCustom extends TokenSourceRefreshable { export type EndpointOptions = Omit; -class TokenSourceEndpoint extends TokenSourceRefreshable { +export class TokenSourceEndpoint extends TokenSourceCached { private url: string; private endpointOptions: EndpointOptions; @@ -205,25 +136,42 @@ class TokenSourceEndpoint extends TokenSourceRefreshable { this.endpointOptions = options; } - protected async update(options: TokenSourceOptions) { - // NOTE: I don't like the repetitive nature of this, `options` shouldn't be a thing, - // `request` should just be passed through instead... + private createRequestFromOptions(options: TokenSourceOptions) { const request = new TokenSourceRequest(); - request.roomName = options.roomName; - request.participantName = options.participantName; - request.participantIdentity = options.participantIdentity; - request.participantMetadata = options.participantMetadata; - request.participantAttributes = options.participantAttributes ?? {}; - request.roomConfig = options.agentName ? ( - new RoomConfiguration({ - agents: [ - new RoomAgentDispatch({ + + for (const key of Object.keys(options) as Array) { + switch (key) { + case 'roomName': + case 'participantName': + case 'participantIdentity': + case 'participantMetadata': + request[key] = options[key]; + break; + + case 'participantAttributes': + request.participantAttributes = options.participantAttributes ?? {}; + break; + + case 'agentName': + request.roomConfig = request.roomConfig ?? new RoomConfiguration(); + request.roomConfig.agents.push(new RoomAgentDispatch({ agentName: options.agentName, metadata: '', // FIXME: how do I support this? Maybe make agentName -> agentToDispatch? - }), - ], - }) - ) : undefined; + })); + break; + + default: + // ref: https://stackoverflow.com/a/58009992 + const exhaustiveCheckedKey: never = key; + throw new Error(`Options key ${exhaustiveCheckedKey} not being included in forming request!`); + } + } + + return request; + } + + protected async update(options: TokenSourceOptions) { + const request = this.createRequestFromOptions(options); const response = await fetch(this.url, { ...this.endpointOptions, @@ -232,7 +180,9 @@ class TokenSourceEndpoint extends TokenSourceRefreshable { 'Content-Type': 'application/json', ...this.endpointOptions.headers, }, - body: request.toJsonString(), + body: request.toJsonString({ + useProtoFieldName: true + }), }); if (!response.ok) { @@ -268,14 +218,15 @@ export class TokenSourceSandboxTokenServer extends TokenSourceEndpoint { } export const TokenSource = { - /** TokenSource.literal contains a single, literal set of credentials. */ + /** TokenSource.literal contains a single, literal set of {@link TokenSourceResponseObject} + * credentials, either provided directly or returned from a provided function. */ literal(literalOrFn: LiteralOrFn) { return new TokenSourceLiteral(literalOrFn); }, /** * TokenSource.custom allows a user to define a manual function which generates new - * {@link ResponsePayload} values on demand. + * {@link TokenSourceResponseObject} values on demand. * * Use this to get credentials from custom backends / etc. */ diff --git a/src/room/token-source/types.ts b/src/room/token-source/types.ts new file mode 100644 index 0000000000..a140145a71 --- /dev/null +++ b/src/room/token-source/types.ts @@ -0,0 +1,75 @@ +import { RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; +import type { JWTPayload } from 'jose'; +import type { TokenSourceLiteral, TokenSourceEndpoint, TokenSourceCustom } from './TokenSource'; +import type { ValueToSnakeCase } from '../../utils/camelToSnakeCase'; + +export type TokenSourceRequestObject = Required[0]>>; +export type TokenSourceResponseObject = Required[0]>>; + +/** The `TokenSource` request object sent to the server as part of fetching a configurable + * `TokenSource` like {@link TokenSourceEndpoint}. + * + * Use this as a type for your request body if implementing a server endpoint in node.js. + */ +export type TokenSourceRequestPayload = ValueToSnakeCase; + +/** The `TokenSource` response object sent from the server as part of fetching a configurable + * `TokenSource` like {@link TokenSourceEndpoint}. + * + * Use this as a type for your response body if implementing a server endpoint in node.js. + */ +export type TokenSourceResponsePayload = ValueToSnakeCase; + +/** The payload of a LiveKit JWT token. */ +export type TokenPayload = JWTPayload & { + name?: string; + metadata?: string; + attributes?: Record; + video?: { + room?: string; + roomJoin?: boolean; + canPublish?: boolean; + canPublishData?: boolean; + canSubscribe?: boolean; + }; + roomConfig?: RoomConfigurationObject, +}; +type RoomConfigurationObject = NonNullable[0]>; + + +/** A Fixed TokenSource is a token source that takes no parameters and returns a completely + * independant value on each fetch() call. + * + * The most common downstream implementer is {@link TokenSourceLiteral}. + */ +export abstract class TokenSourceFixed { + abstract fetch(): Promise; +} + +export type TokenSourceOptions = { + roomName?: string; + participantName?: string; + participantIdentity?: string; + participantMetadata?: string; + participantAttributes?: { [key: string]: string }; + + agentName?: string; +}; + +/** A Configurable TokenSource is a token source that takes a + * {@link TokenSourceOptions} object as input and returns a deterministic + * {@link TokenSourceResponseObject} output based on the options specified. + * + * For example, if options.participantName is set, it should be expected that + * all tokens that are generated will have participant name field set to the + * provided value. + * + * A few common downstream implementers are {@link TokenSourceEndpoint} + * and {@link TokenSourceCustom}. + */ +export abstract class TokenSourceConfigurable { + abstract fetch(options: TokenSourceOptions): Promise; +} + +/** A TokenSource is a mechanism for fetching credentials required to connect to a LiveKit Room. */ +export type TokenSource = TokenSourceFixed | TokenSourceConfigurable; diff --git a/src/room/token-source/utils.ts b/src/room/token-source/utils.ts new file mode 100644 index 0000000000..25d467c060 --- /dev/null +++ b/src/room/token-source/utils.ts @@ -0,0 +1,33 @@ +import { decodeJwt } from "jose"; +import { RoomConfiguration, type TokenSourceResponse } from "@livekit/protocol"; +import type { RoomConfigurationPayload, TokenPayload } from "./types"; + +const ONE_SECOND_IN_MILLISECONDS = 1000; +const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; + +export function isResponseExpired(response: TokenSourceResponse) { + const jwtPayload = decodeTokenPayload(response.participantToken); + if (!jwtPayload?.exp) { + return true; + } + const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS; + const expiresAt = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS); + + const now = new Date(); + return expiresAt >= now; +} + +export function decodeTokenPayload(token: string) { + const payload = decodeJwt>(token); + + const { roomConfig, ...rest } = payload; + + const mappedPayload: TokenPayload = { + ...rest, + roomConfig: payload.roomConfig + ? RoomConfiguration.fromJson(payload.roomConfig as Record) as RoomConfigurationPayload + : undefined, + }; + + return mappedPayload; +} From 81ab62dfe1f301d0f50a6a5388d5375c98cdd468 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 29 Sep 2025 11:55:18 -0400 Subject: [PATCH 46/54] fix: update missing instance of RoomConfigurationPayload --- src/room/token-source/types.ts | 2 +- src/room/token-source/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/room/token-source/types.ts b/src/room/token-source/types.ts index a140145a71..1b2e99514a 100644 --- a/src/room/token-source/types.ts +++ b/src/room/token-source/types.ts @@ -34,7 +34,7 @@ export type TokenPayload = JWTPayload & { }; roomConfig?: RoomConfigurationObject, }; -type RoomConfigurationObject = NonNullable[0]>; +export type RoomConfigurationObject = NonNullable[0]>; /** A Fixed TokenSource is a token source that takes no parameters and returns a completely diff --git a/src/room/token-source/utils.ts b/src/room/token-source/utils.ts index 25d467c060..e67cdc6653 100644 --- a/src/room/token-source/utils.ts +++ b/src/room/token-source/utils.ts @@ -1,6 +1,6 @@ import { decodeJwt } from "jose"; import { RoomConfiguration, type TokenSourceResponse } from "@livekit/protocol"; -import type { RoomConfigurationPayload, TokenPayload } from "./types"; +import type { RoomConfigurationObject, TokenPayload } from "./types"; const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; @@ -25,7 +25,7 @@ export function decodeTokenPayload(token: string) { const mappedPayload: TokenPayload = { ...rest, roomConfig: payload.roomConfig - ? RoomConfiguration.fromJson(payload.roomConfig as Record) as RoomConfigurationPayload + ? RoomConfiguration.fromJson(payload.roomConfig as Record) as RoomConfigurationObject : undefined, }; From e1bdbfcafadeac678037aaee305ba19d9a311b43 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 29 Sep 2025 11:57:15 -0400 Subject: [PATCH 47/54] fix: address linting errors --- src/room/token-source/TokenSource.ts | 3 +++ src/room/token-source/types.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/room/token-source/TokenSource.ts b/src/room/token-source/TokenSource.ts index bab27147c5..743c8de5e8 100644 --- a/src/room/token-source/TokenSource.ts +++ b/src/room/token-source/TokenSource.ts @@ -7,6 +7,7 @@ import { decodeTokenPayload, isResponseExpired } from './utils'; * until a) it expires or b) the {@link TokenSourceOptions} provided to .fetch(...) change. */ abstract class TokenSourceCached extends TokenSourceConfigurable { private cachedOptions: TokenSourceOptions | null = null; + private cachedResponse: TokenSourceResponse | null = null; private fetchMutex = new Mutex(); @@ -100,6 +101,7 @@ export class TokenSourceLiteral extends TokenSourceFixed { type CustomFn = (options: TokenSourceOptions) => TokenSourceResponseObject | Promise; export class TokenSourceCustom extends TokenSourceCached { private customFn: CustomFn; + constructor(customFn: CustomFn) { super(); this.customFn = customFn; @@ -128,6 +130,7 @@ export type EndpointOptions = Omit; export class TokenSourceEndpoint extends TokenSourceCached { private url: string; + private endpointOptions: EndpointOptions; constructor(url: string, options: EndpointOptions = {}) { diff --git a/src/room/token-source/types.ts b/src/room/token-source/types.ts index 1b2e99514a..baf569980d 100644 --- a/src/room/token-source/types.ts +++ b/src/room/token-source/types.ts @@ -1,5 +1,8 @@ import { RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; import type { JWTPayload } from 'jose'; +// The below imports are being linked in tsdoc comments, so they have to be imported even if they +// aren't being used. +// eslint-disable-next-line @typescript-eslint/no-unused-vars import type { TokenSourceLiteral, TokenSourceEndpoint, TokenSourceCustom } from './TokenSource'; import type { ValueToSnakeCase } from '../../utils/camelToSnakeCase'; From 44509bd0e4d59b5a58ec4e01cf08d2687e0857e1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 29 Sep 2025 11:59:02 -0400 Subject: [PATCH 48/54] fix: run npm run format --- src/room/token-source/TokenSource.ts | 45 +++++++++++++++++--------- src/room/token-source/types.ts | 47 +++++++++++++++------------- src/room/token-source/utils.ts | 10 +++--- 3 files changed, 62 insertions(+), 40 deletions(-) diff --git a/src/room/token-source/TokenSource.ts b/src/room/token-source/TokenSource.ts index 743c8de5e8..d869498d46 100644 --- a/src/room/token-source/TokenSource.ts +++ b/src/room/token-source/TokenSource.ts @@ -1,10 +1,20 @@ import { Mutex } from '@livekit/mutex'; -import { RoomAgentDispatch, RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; -import { TokenSourceConfigurable, TokenSourceFixed, type TokenSourceOptions, type TokenSourceResponseObject } from './types'; +import { + RoomAgentDispatch, + RoomConfiguration, + TokenSourceRequest, + TokenSourceResponse, +} from '@livekit/protocol'; +import { + TokenSourceConfigurable, + TokenSourceFixed, + type TokenSourceOptions, + type TokenSourceResponseObject, +} from './types'; import { decodeTokenPayload, isResponseExpired } from './utils'; /** A TokenSourceCached is a TokenSource which caches the last {@link TokenSourceResponseObject} value and returns it - * until a) it expires or b) the {@link TokenSourceOptions} provided to .fetch(...) change. */ + * until a) it expires or b) the {@link TokenSourceOptions} provided to .fetch(...) change. */ abstract class TokenSourceCached extends TokenSourceConfigurable { private cachedOptions: TokenSourceOptions | null = null; @@ -78,8 +88,9 @@ abstract class TokenSourceCached extends TokenSourceConfigurable { protected abstract update(options: TokenSourceOptions): Promise; } - -type LiteralOrFn = TokenSourceResponseObject | (() => TokenSourceResponseObject | Promise); +type LiteralOrFn = + | TokenSourceResponseObject + | (() => TokenSourceResponseObject | Promise); export class TokenSourceLiteral extends TokenSourceFixed { private literalOrFn: LiteralOrFn; @@ -97,8 +108,9 @@ export class TokenSourceLiteral extends TokenSourceFixed { } } - -type CustomFn = (options: TokenSourceOptions) => TokenSourceResponseObject | Promise; +type CustomFn = ( + options: TokenSourceOptions, +) => TokenSourceResponseObject | Promise; export class TokenSourceCustom extends TokenSourceCached { private customFn: CustomFn; @@ -125,7 +137,6 @@ export class TokenSourceCustom extends TokenSourceCached { } } - export type EndpointOptions = Omit; export class TokenSourceEndpoint extends TokenSourceCached { @@ -157,16 +168,20 @@ export class TokenSourceEndpoint extends TokenSourceCached { case 'agentName': request.roomConfig = request.roomConfig ?? new RoomConfiguration(); - request.roomConfig.agents.push(new RoomAgentDispatch({ - agentName: options.agentName, - metadata: '', // FIXME: how do I support this? Maybe make agentName -> agentToDispatch? - })); + request.roomConfig.agents.push( + new RoomAgentDispatch({ + agentName: options.agentName, + metadata: '', // FIXME: how do I support this? Maybe make agentName -> agentToDispatch? + }), + ); break; default: // ref: https://stackoverflow.com/a/58009992 const exhaustiveCheckedKey: never = key; - throw new Error(`Options key ${exhaustiveCheckedKey} not being included in forming request!`); + throw new Error( + `Options key ${exhaustiveCheckedKey} not being included in forming request!`, + ); } } @@ -184,7 +199,7 @@ export class TokenSourceEndpoint extends TokenSourceCached { ...this.endpointOptions.headers, }, body: request.toJsonString({ - useProtoFieldName: true + useProtoFieldName: true, }), }); @@ -222,7 +237,7 @@ export class TokenSourceSandboxTokenServer extends TokenSourceEndpoint { export const TokenSource = { /** TokenSource.literal contains a single, literal set of {@link TokenSourceResponseObject} - * credentials, either provided directly or returned from a provided function. */ + * credentials, either provided directly or returned from a provided function. */ literal(literalOrFn: LiteralOrFn) { return new TokenSourceLiteral(literalOrFn); }, diff --git a/src/room/token-source/types.ts b/src/room/token-source/types.ts index baf569980d..d77659ca4a 100644 --- a/src/room/token-source/types.ts +++ b/src/room/token-source/types.ts @@ -1,13 +1,17 @@ import { RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; import type { JWTPayload } from 'jose'; +import type { ValueToSnakeCase } from '../../utils/camelToSnakeCase'; // The below imports are being linked in tsdoc comments, so they have to be imported even if they // aren't being used. // eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { TokenSourceLiteral, TokenSourceEndpoint, TokenSourceCustom } from './TokenSource'; -import type { ValueToSnakeCase } from '../../utils/camelToSnakeCase'; +import type { TokenSourceCustom, TokenSourceEndpoint, TokenSourceLiteral } from './TokenSource'; -export type TokenSourceRequestObject = Required[0]>>; -export type TokenSourceResponseObject = Required[0]>>; +export type TokenSourceRequestObject = Required< + NonNullable[0]> +>; +export type TokenSourceResponseObject = Required< + NonNullable[0]> +>; /** The `TokenSource` request object sent to the server as part of fetching a configurable * `TokenSource` like {@link TokenSourceEndpoint}. @@ -35,16 +39,17 @@ export type TokenPayload = JWTPayload & { canPublishData?: boolean; canSubscribe?: boolean; }; - roomConfig?: RoomConfigurationObject, + roomConfig?: RoomConfigurationObject; }; -export type RoomConfigurationObject = NonNullable[0]>; - +export type RoomConfigurationObject = NonNullable< + ConstructorParameters[0] +>; /** A Fixed TokenSource is a token source that takes no parameters and returns a completely - * independant value on each fetch() call. - * - * The most common downstream implementer is {@link TokenSourceLiteral}. - */ + * independently derived value on each fetch() call. + * + * The most common downstream implementer is {@link TokenSourceLiteral}. + */ export abstract class TokenSourceFixed { abstract fetch(): Promise; } @@ -60,16 +65,16 @@ export type TokenSourceOptions = { }; /** A Configurable TokenSource is a token source that takes a - * {@link TokenSourceOptions} object as input and returns a deterministic - * {@link TokenSourceResponseObject} output based on the options specified. - * - * For example, if options.participantName is set, it should be expected that - * all tokens that are generated will have participant name field set to the - * provided value. - * - * A few common downstream implementers are {@link TokenSourceEndpoint} - * and {@link TokenSourceCustom}. - */ + * {@link TokenSourceOptions} object as input and returns a deterministic + * {@link TokenSourceResponseObject} output based on the options specified. + * + * For example, if options.participantName is set, it should be expected that + * all tokens that are generated will have participant name field set to the + * provided value. + * + * A few common downstream implementers are {@link TokenSourceEndpoint} + * and {@link TokenSourceCustom}. + */ export abstract class TokenSourceConfigurable { abstract fetch(options: TokenSourceOptions): Promise; } diff --git a/src/room/token-source/utils.ts b/src/room/token-source/utils.ts index e67cdc6653..f6d328b2bd 100644 --- a/src/room/token-source/utils.ts +++ b/src/room/token-source/utils.ts @@ -1,6 +1,6 @@ -import { decodeJwt } from "jose"; -import { RoomConfiguration, type TokenSourceResponse } from "@livekit/protocol"; -import type { RoomConfigurationObject, TokenPayload } from "./types"; +import { RoomConfiguration, type TokenSourceResponse } from '@livekit/protocol'; +import { decodeJwt } from 'jose'; +import type { RoomConfigurationObject, TokenPayload } from './types'; const ONE_SECOND_IN_MILLISECONDS = 1000; const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; @@ -25,7 +25,9 @@ export function decodeTokenPayload(token: string) { const mappedPayload: TokenPayload = { ...rest, roomConfig: payload.roomConfig - ? RoomConfiguration.fromJson(payload.roomConfig as Record) as RoomConfigurationObject + ? (RoomConfiguration.fromJson( + payload.roomConfig as Record, + ) as RoomConfigurationObject) : undefined, }; From 61456590bfcb9c1d95486d98aef07c5fe5f5cbaf Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 29 Sep 2025 14:17:51 -0400 Subject: [PATCH 49/54] fix: rename TokenSource to TokenSourceBase to fix name clash --- src/room/token-source/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/token-source/types.ts b/src/room/token-source/types.ts index d77659ca4a..252e1f0f36 100644 --- a/src/room/token-source/types.ts +++ b/src/room/token-source/types.ts @@ -80,4 +80,4 @@ export abstract class TokenSourceConfigurable { } /** A TokenSource is a mechanism for fetching credentials required to connect to a LiveKit Room. */ -export type TokenSource = TokenSourceFixed | TokenSourceConfigurable; +export type TokenSourceBase = TokenSourceFixed | TokenSourceConfigurable; From e3a903452f194ed8456020e339d4371fad7d0317 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 29 Sep 2025 14:18:41 -0400 Subject: [PATCH 50/54] feat: export more tokensource values --- src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a333762051..84039eacc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,8 @@ export * from './room/errors'; export * from './room/events'; export * from './room/track/Track'; export * from './room/track/create'; -export { TokenSource } from './room/token-source/TokenSource'; +export * from './room/token-source/TokenSource'; +export * from './room/token-source/types'; export { facingModeFromDeviceLabel, facingModeFromLocalTrack } from './room/track/facingMode'; export * from './room/track/options'; export * from './room/track/processor/types'; From 9a173865f4ab4cad59342406d1038aa93d46c224 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Oct 2025 09:27:07 -0400 Subject: [PATCH 51/54] feat: rename TokenSourceOptions => TokenSourceFetchOptions --- src/room/token-source/TokenSource.ts | 36 +++++++++++++++------------- src/room/token-source/types.ts | 6 ++--- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/room/token-source/TokenSource.ts b/src/room/token-source/TokenSource.ts index d869498d46..7e8f7449ab 100644 --- a/src/room/token-source/TokenSource.ts +++ b/src/room/token-source/TokenSource.ts @@ -7,27 +7,29 @@ import { } from '@livekit/protocol'; import { TokenSourceConfigurable, + type TokenSourceFetchOptions, TokenSourceFixed, - type TokenSourceOptions, type TokenSourceResponseObject, } from './types'; import { decodeTokenPayload, isResponseExpired } from './utils'; /** A TokenSourceCached is a TokenSource which caches the last {@link TokenSourceResponseObject} value and returns it - * until a) it expires or b) the {@link TokenSourceOptions} provided to .fetch(...) change. */ + * until a) it expires or b) the {@link TokenSourceFetchOptions} provided to .fetch(...) change. */ abstract class TokenSourceCached extends TokenSourceConfigurable { - private cachedOptions: TokenSourceOptions | null = null; + private cachedFetchOptions: TokenSourceFetchOptions | null = null; private cachedResponse: TokenSourceResponse | null = null; private fetchMutex = new Mutex(); - private isSameAsCachedOptions(options: TokenSourceOptions) { - if (!this.cachedOptions) { + private isSameAsCachedFetchOptions(options: TokenSourceFetchOptions) { + if (!this.cachedFetchOptions) { return false; } - for (const key of Object.keys(this.cachedOptions) as Array) { + for (const key of Object.keys(this.cachedFetchOptions) as Array< + keyof TokenSourceFetchOptions + >) { switch (key) { case 'roomName': case 'participantName': @@ -35,7 +37,7 @@ abstract class TokenSourceCached extends TokenSourceConfigurable { case 'participantMetadata': case 'participantAttributes': case 'agentName': - if (this.cachedOptions[key] !== options[key]) { + if (this.cachedFetchOptions[key] !== options[key]) { return false; } break; @@ -49,14 +51,14 @@ abstract class TokenSourceCached extends TokenSourceConfigurable { return true; } - private shouldReturnCachedValueFromFetch(options: TokenSourceOptions) { + private shouldReturnCachedValueFromFetch(fetchOptions: TokenSourceFetchOptions) { if (!this.cachedResponse) { return false; } if (isResponseExpired(this.cachedResponse)) { return false; } - if (this.isSameAsCachedOptions(options)) { + if (this.isSameAsCachedFetchOptions(fetchOptions)) { return false; } return true; @@ -69,13 +71,13 @@ abstract class TokenSourceCached extends TokenSourceConfigurable { return decodeTokenPayload(this.cachedResponse.participantToken); } - async fetch(options: TokenSourceOptions): Promise { + async fetch(options: TokenSourceFetchOptions): Promise { const unlock = await this.fetchMutex.lock(); try { if (this.shouldReturnCachedValueFromFetch(options)) { return this.cachedResponse!.toJson() as TokenSourceResponseObject; } - this.cachedOptions = options; + this.cachedFetchOptions = options; const tokenResponse = await this.update(options); this.cachedResponse = tokenResponse; @@ -85,7 +87,7 @@ abstract class TokenSourceCached extends TokenSourceConfigurable { } } - protected abstract update(options: TokenSourceOptions): Promise; + protected abstract update(options: TokenSourceFetchOptions): Promise; } type LiteralOrFn = @@ -109,7 +111,7 @@ export class TokenSourceLiteral extends TokenSourceFixed { } type CustomFn = ( - options: TokenSourceOptions, + options: TokenSourceFetchOptions, ) => TokenSourceResponseObject | Promise; export class TokenSourceCustom extends TokenSourceCached { private customFn: CustomFn; @@ -119,7 +121,7 @@ export class TokenSourceCustom extends TokenSourceCached { this.customFn = customFn; } - protected async update(options: TokenSourceOptions) { + protected async update(options: TokenSourceFetchOptions) { const resultMaybePromise = this.customFn(options); let result; @@ -150,10 +152,10 @@ export class TokenSourceEndpoint extends TokenSourceCached { this.endpointOptions = options; } - private createRequestFromOptions(options: TokenSourceOptions) { + private createRequestFromOptions(options: TokenSourceFetchOptions) { const request = new TokenSourceRequest(); - for (const key of Object.keys(options) as Array) { + for (const key of Object.keys(options) as Array) { switch (key) { case 'roomName': case 'participantName': @@ -188,7 +190,7 @@ export class TokenSourceEndpoint extends TokenSourceCached { return request; } - protected async update(options: TokenSourceOptions) { + protected async update(options: TokenSourceFetchOptions) { const request = this.createRequestFromOptions(options); const response = await fetch(this.url, { diff --git a/src/room/token-source/types.ts b/src/room/token-source/types.ts index 252e1f0f36..9b9db6a7d4 100644 --- a/src/room/token-source/types.ts +++ b/src/room/token-source/types.ts @@ -54,7 +54,7 @@ export abstract class TokenSourceFixed { abstract fetch(): Promise; } -export type TokenSourceOptions = { +export type TokenSourceFetchOptions = { roomName?: string; participantName?: string; participantIdentity?: string; @@ -65,7 +65,7 @@ export type TokenSourceOptions = { }; /** A Configurable TokenSource is a token source that takes a - * {@link TokenSourceOptions} object as input and returns a deterministic + * {@link TokenSourceFetchOptions} object as input and returns a deterministic * {@link TokenSourceResponseObject} output based on the options specified. * * For example, if options.participantName is set, it should be expected that @@ -76,7 +76,7 @@ export type TokenSourceOptions = { * and {@link TokenSourceCustom}. */ export abstract class TokenSourceConfigurable { - abstract fetch(options: TokenSourceOptions): Promise; + abstract fetch(options: TokenSourceFetchOptions): Promise; } /** A TokenSource is a mechanism for fetching credentials required to connect to a LiveKit Room. */ From 438c5dc3fb17d2537cac9d56677846a3b4f00195 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Oct 2025 09:37:07 -0400 Subject: [PATCH 52/54] fix: re-add missing Promise --- src/room/Room.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index a46e173cad..ceea07f2d6 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -612,7 +612,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) } } - connect = async (url: string, token: string, opts?: RoomConnectOptions) => { + connect = async (url: string, token: string, opts?: RoomConnectOptions): Promise => { if (!isBrowserSupported()) { if (isReactNative()) { throw Error("WebRTC isn't detected, have you called registerGlobals?"); From 98eac043f21980c132aae4e4cadd5bca841fa047 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Oct 2025 12:17:21 -0400 Subject: [PATCH 53/54] feat: add agentMetadata param alongside agentName Without this, there's no way to set the "metadata" parameter in the RoomAgentDispatch in the room configuration. This is probably a less common thing but it seems like it should be supported at least. --- src/room/token-source/TokenSource.ts | 19 +++++++++++++------ src/room/token-source/types.ts | 1 + 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/room/token-source/TokenSource.ts b/src/room/token-source/TokenSource.ts index 7e8f7449ab..0e1fd78947 100644 --- a/src/room/token-source/TokenSource.ts +++ b/src/room/token-source/TokenSource.ts @@ -37,6 +37,7 @@ abstract class TokenSourceCached extends TokenSourceConfigurable { case 'participantMetadata': case 'participantAttributes': case 'agentName': + case 'agentMetadata': if (this.cachedFetchOptions[key] !== options[key]) { return false; } @@ -170,12 +171,18 @@ export class TokenSourceEndpoint extends TokenSourceCached { case 'agentName': request.roomConfig = request.roomConfig ?? new RoomConfiguration(); - request.roomConfig.agents.push( - new RoomAgentDispatch({ - agentName: options.agentName, - metadata: '', // FIXME: how do I support this? Maybe make agentName -> agentToDispatch? - }), - ); + if (request.roomConfig.agents.length === 0) { + request.roomConfig.agents.push(new RoomAgentDispatch()); + } + request.roomConfig.agents[0].agentName = options.agentName!; + break; + + case 'agentMetadata': + request.roomConfig = request.roomConfig ?? new RoomConfiguration(); + if (request.roomConfig.agents.length === 0) { + request.roomConfig.agents.push(new RoomAgentDispatch()); + } + request.roomConfig.agents[0].metadata = options.agentMetadata!; break; default: diff --git a/src/room/token-source/types.ts b/src/room/token-source/types.ts index 9b9db6a7d4..37bd0c813a 100644 --- a/src/room/token-source/types.ts +++ b/src/room/token-source/types.ts @@ -62,6 +62,7 @@ export type TokenSourceFetchOptions = { participantAttributes?: { [key: string]: string }; agentName?: string; + agentMetadata?: string; }; /** A Configurable TokenSource is a token source that takes a From c8b46ae7d459fda74a7446f16386280b3a471686 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Oct 2025 12:21:27 -0400 Subject: [PATCH 54/54] fix: allow second parameter to TokenSource.sandboxTokenServer to be omitted --- src/room/token-source/TokenSource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/token-source/TokenSource.ts b/src/room/token-source/TokenSource.ts index 0e1fd78947..5f5cfdeefb 100644 --- a/src/room/token-source/TokenSource.ts +++ b/src/room/token-source/TokenSource.ts @@ -279,7 +279,7 @@ export const TokenSource = { * For more info: * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server */ - sandboxTokenServer(sandboxId: string, options: SandboxTokenServerOptions) { + sandboxTokenServer(sandboxId: string, options: SandboxTokenServerOptions = {}) { return new TokenSourceSandboxTokenServer(sandboxId, options); }, };