livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
305 lines (262 loc) • 9.36 kB
text/typescript
import { Mutex } from '@livekit/mutex';
import {
RoomAgentDispatch,
RoomConfiguration,
TokenSourceRequest,
TokenSourceResponse,
} from '@livekit/protocol';
import {
TokenSourceConfigurable,
type TokenSourceFetchOptions,
TokenSourceFixed,
type TokenSourceResponseObject,
} from './types';
import { areTokenSourceFetchOptionsEqual, decodeTokenPayload, isResponseTokenValid } 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 TokenSourceFetchOptions} provided to .fetch(...) change. */
abstract class TokenSourceCached extends TokenSourceConfigurable {
private cachedFetchOptions: TokenSourceFetchOptions | null = null;
private cachedResponse: TokenSourceResponse | null = null;
private fetchMutex = new Mutex();
private isSameAsCachedFetchOptions(options: TokenSourceFetchOptions) {
if (!this.cachedFetchOptions) {
return false;
}
for (const key of Object.keys(this.cachedFetchOptions) as Array<
keyof TokenSourceFetchOptions
>) {
switch (key) {
case 'roomName':
case 'participantName':
case 'participantIdentity':
case 'participantMetadata':
case 'participantAttributes':
case 'agentName':
case 'agentMetadata':
if (this.cachedFetchOptions[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!`);
}
}
return true;
}
private shouldReturnCachedValueFromFetch(fetchOptions: TokenSourceFetchOptions) {
if (!this.cachedResponse) {
return false;
}
if (!isResponseTokenValid(this.cachedResponse)) {
return false;
}
if (!this.isSameAsCachedFetchOptions(fetchOptions)) {
return false;
}
return true;
}
getCachedResponseJwtPayload() {
if (!this.cachedResponse) {
return null;
}
return decodeTokenPayload(this.cachedResponse.participantToken);
}
async fetch(
options: TokenSourceFetchOptions,
force?: boolean,
): Promise<TokenSourceResponseObject> {
const unlock = await this.fetchMutex.lock();
try {
if (force) {
this.cachedResponse = null;
}
if (this.shouldReturnCachedValueFromFetch(options)) {
return this.cachedResponse!.toJson() as TokenSourceResponseObject;
}
this.cachedFetchOptions = options;
const tokenResponse = await this.update(options);
this.cachedResponse = tokenResponse;
return tokenResponse.toJson() as TokenSourceResponseObject;
} finally {
unlock();
}
}
protected abstract update(options: TokenSourceFetchOptions): Promise<TokenSourceResponse>;
}
type LiteralOrFn =
| TokenSourceResponseObject
| (() => TokenSourceResponseObject | Promise<TokenSourceResponseObject>);
class TokenSourceLiteral extends TokenSourceFixed {
private literalOrFn: LiteralOrFn;
constructor(literalOrFn: LiteralOrFn) {
super();
this.literalOrFn = literalOrFn;
}
async fetch(): Promise<TokenSourceResponseObject> {
if (typeof this.literalOrFn === 'function') {
return this.literalOrFn();
} else {
return this.literalOrFn;
}
}
}
type CustomFn = (
options: TokenSourceFetchOptions,
) => TokenSourceResponseObject | Promise<TokenSourceResponseObject>;
class TokenSourceCustom extends TokenSourceCached {
private customFn: CustomFn;
constructor(customFn: CustomFn) {
super();
this.customFn = customFn;
}
protected async update(options: TokenSourceFetchOptions) {
const resultMaybePromise = this.customFn(options);
let result;
if (resultMaybePromise instanceof Promise) {
result = await resultMaybePromise;
} else {
result = resultMaybePromise;
}
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
ignoreUnknownFields: true,
});
}
}
export type EndpointOptions = Omit<RequestInit, 'body'>;
class TokenSourceEndpoint extends TokenSourceCached {
private url: string;
private endpointOptions: EndpointOptions;
constructor(url: string, options: EndpointOptions = {}) {
super();
this.url = url;
this.endpointOptions = options;
}
private createRequestFromOptions(options: TokenSourceFetchOptions) {
const request = new TokenSourceRequest();
for (const key of Object.keys(options) as Array<keyof TokenSourceFetchOptions>) {
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();
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:
// 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: TokenSourceFetchOptions) {
const request = this.createRequestFromOptions(options);
const response = await fetch(this.url, {
...this.endpointOptions,
method: this.endpointOptions.method ?? 'POST',
headers: {
'Content-Type': 'application/json',
...this.endpointOptions.headers,
},
body: request.toJsonString({
useProtoFieldName: true,
}),
});
if (!response.ok) {
throw new Error(
`Error generating token from endpoint ${this.url}: received ${response.status} / ${await response.text()}`,
);
}
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 = {
baseUrl?: string;
};
class TokenSourceSandboxTokenServer extends TokenSourceEndpoint {
constructor(sandboxId: string, options: SandboxTokenServerOptions) {
const { baseUrl = 'https://cloud-api.livekit.io', ...rest } = options;
super(`${baseUrl}/api/v2/sandbox/connection-details`, {
...rest,
headers: {
'X-Sandbox-ID': sandboxId,
},
});
}
}
export {
/** The return type of {@link TokenSource.literal} */
type TokenSourceLiteral,
/** The return type of {@link TokenSource.custom} */
type TokenSourceCustom,
/** The return type of {@link TokenSource.endpoint} */
type TokenSourceEndpoint,
/** The return type of {@link TokenSource.sandboxTokenServer} */
type TokenSourceSandboxTokenServer,
decodeTokenPayload,
areTokenSourceFetchOptionsEqual,
};
export const TokenSource = {
/** 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 TokenSourceResponseObject} values on demand.
*
* Use this to get credentials from custom backends / etc.
*/
custom(customFn: CustomFn) {
return new TokenSourceCustom(customFn);
},
/**
* TokenSource.endpoint creates a token source that fetches credentials from a given URL using
* the standard endpoint format:
* @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server
*/
endpoint(url: string, options: EndpointOptions = {}) {
return new TokenSourceEndpoint(url, 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
*/
sandboxTokenServer(sandboxId: string, options: SandboxTokenServerOptions = {}) {
return new TokenSourceSandboxTokenServer(sandboxId, options);
},
};