livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
244 lines (206 loc) • 7.85 kB
text/typescript
import { Mutex } from '@livekit/mutex';
import type { RegionInfo, RegionSettings } from '@livekit/protocol';
import log from '../logger';
import { ConnectionError, ConnectionErrorReason } from './errors';
import { extractMaxAgeFromRequestHeaders, isCloud } from './utils';
export const DEFAULT_MAX_AGE_MS = 5_000;
export const STOP_REFETCH_DELAY_MS = 30_000;
type CachedRegionSettings = {
regionSettings: RegionSettings;
updatedAtInMs: number;
maxAgeInMs: number;
};
type ConnectionTracker = {
connectionCount: number;
cleanupTimeout?: ReturnType<typeof setTimeout>;
};
export class RegionUrlProvider {
private static readonly cache: Map<string, CachedRegionSettings> = new Map();
private static settingsTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
private static connectionTrackers: Map<string, ConnectionTracker> = new Map();
private static fetchLock = new Mutex();
private static async fetchRegionSettings(
serverUrl: URL,
token: string,
signal?: AbortSignal,
): Promise<CachedRegionSettings> {
const unlock = await RegionUrlProvider.fetchLock.lock();
try {
const regionSettingsResponse = await fetch(`${getCloudConfigUrl(serverUrl)}/regions`, {
headers: { authorization: `Bearer ${token}` },
signal,
});
if (regionSettingsResponse.ok) {
const maxAge = extractMaxAgeFromRequestHeaders(regionSettingsResponse.headers);
const maxAgeInMs = maxAge ? maxAge * 1000 : DEFAULT_MAX_AGE_MS;
const regionSettings = (await regionSettingsResponse.json()) as RegionSettings;
return { regionSettings, updatedAtInMs: Date.now(), maxAgeInMs };
} else {
if (regionSettingsResponse.status === 401) {
throw ConnectionError.notAllowed(
`Could not fetch region settings: ${regionSettingsResponse.statusText}`,
regionSettingsResponse.status,
);
} else {
throw ConnectionError.internal(
`Could not fetch region settings: ${regionSettingsResponse.statusText}`,
);
}
}
} catch (e: unknown) {
if (e instanceof ConnectionError) {
// rethrow connection errors
throw e;
} else if (signal?.aborted) {
throw ConnectionError.cancelled(`Region fetching was aborted`);
} else {
// wrap other errors as connection errors
throw ConnectionError.serverUnreachable(
`Could not fetch region settings, ${e instanceof Error ? `${e.name}: ${e.message}` : e}`,
);
}
} finally {
unlock();
}
}
private static async scheduleRefetch(url: URL, token: string, maxAgeInMs: number) {
const timeout = RegionUrlProvider.settingsTimeouts.get(url.hostname);
clearTimeout(timeout);
RegionUrlProvider.settingsTimeouts.set(
url.hostname,
setTimeout(async () => {
try {
const newSettings = await RegionUrlProvider.fetchRegionSettings(url, token);
RegionUrlProvider.updateCachedRegionSettings(url, token, newSettings);
} catch (error: unknown) {
if (
error instanceof ConnectionError &&
error.reason === ConnectionErrorReason.NotAllowed
) {
log.debug('token is not valid, cancelling auto region refresh');
return;
}
log.debug('auto refetching of region settings failed', { error });
// continue retrying with the same max age
RegionUrlProvider.scheduleRefetch(url, token, maxAgeInMs);
}
}, maxAgeInMs),
);
}
private static updateCachedRegionSettings(
url: URL,
token: string,
settings: CachedRegionSettings,
) {
RegionUrlProvider.cache.set(url.hostname, settings);
RegionUrlProvider.scheduleRefetch(url, token, settings.maxAgeInMs);
}
private static stopRefetch(hostname: string) {
const timeout = RegionUrlProvider.settingsTimeouts.get(hostname);
if (timeout) {
clearTimeout(timeout);
RegionUrlProvider.settingsTimeouts.delete(hostname);
}
}
private static scheduleCleanup(hostname: string) {
let tracker = RegionUrlProvider.connectionTrackers.get(hostname);
if (!tracker) {
return;
}
// Cancel any existing cleanup timeout
if (tracker.cleanupTimeout) {
clearTimeout(tracker.cleanupTimeout);
}
// Schedule cleanup to stop refetch after delay
tracker.cleanupTimeout = setTimeout(() => {
const currentTracker = RegionUrlProvider.connectionTrackers.get(hostname);
if (currentTracker && currentTracker.connectionCount === 0) {
log.debug('stopping region refetch after disconnect delay', { hostname });
RegionUrlProvider.stopRefetch(hostname);
}
if (currentTracker) {
currentTracker.cleanupTimeout = undefined;
}
}, STOP_REFETCH_DELAY_MS);
}
private static cancelCleanup(hostname: string) {
const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
if (tracker?.cleanupTimeout) {
clearTimeout(tracker.cleanupTimeout);
tracker.cleanupTimeout = undefined;
}
}
notifyConnected() {
const hostname = this.serverUrl.hostname;
let tracker = RegionUrlProvider.connectionTrackers.get(hostname);
if (!tracker) {
tracker = { connectionCount: 0 };
RegionUrlProvider.connectionTrackers.set(hostname, tracker);
}
tracker.connectionCount++;
// Cancel any scheduled cleanup since we have an active connection
RegionUrlProvider.cancelCleanup(hostname);
}
notifyDisconnected() {
const hostname = this.serverUrl.hostname;
const tracker = RegionUrlProvider.connectionTrackers.get(hostname);
if (!tracker) {
return;
}
tracker.connectionCount = Math.max(0, tracker.connectionCount - 1);
// If no more connections, schedule cleanup
if (tracker.connectionCount === 0) {
RegionUrlProvider.scheduleCleanup(hostname);
}
}
private serverUrl: URL;
private token: string;
private attemptedRegions: RegionInfo[] = [];
constructor(url: string, token: string) {
this.serverUrl = new URL(url);
this.token = token;
}
updateToken(token: string) {
this.token = token;
}
isCloud() {
return isCloud(this.serverUrl);
}
getServerUrl() {
return this.serverUrl;
}
/** @internal */
async fetchRegionSettings(abortSignal?: AbortSignal): Promise<CachedRegionSettings> {
return RegionUrlProvider.fetchRegionSettings(this.serverUrl, this.token, abortSignal);
}
async getNextBestRegionUrl(abortSignal?: AbortSignal) {
if (!this.isCloud()) {
throw Error('region availability is only supported for LiveKit Cloud domains');
}
let cachedSettings = RegionUrlProvider.cache.get(this.serverUrl.hostname);
if (!cachedSettings || Date.now() - cachedSettings.updatedAtInMs > cachedSettings.maxAgeInMs) {
cachedSettings = await this.fetchRegionSettings(abortSignal);
RegionUrlProvider.updateCachedRegionSettings(this.serverUrl, this.token, cachedSettings);
}
const regionsLeft = cachedSettings.regionSettings.regions.filter(
(region) => !this.attemptedRegions.find((attempted) => attempted.url === region.url),
);
if (regionsLeft.length > 0) {
const nextRegion = regionsLeft[0];
this.attemptedRegions.push(nextRegion);
log.debug(`next region: ${nextRegion.region}`);
return nextRegion.url;
} else {
return null;
}
}
resetAttempts() {
this.attemptedRegions = [];
}
setServerReportedRegions(settings: CachedRegionSettings) {
RegionUrlProvider.updateCachedRegionSettings(this.serverUrl, this.token, settings);
}
}
function getCloudConfigUrl(serverUrl: URL) {
return `${serverUrl.protocol.replace('ws', 'http')}//${serverUrl.host}/settings`;
}