UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

244 lines (206 loc) 7.85 kB
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`; }