// SPDX-FileCopyrightText: 2024 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 import type { JsonValue } from '@bufbuild/protobuf'; // twirp RPC adapter for client implementation type Options = { /** Prefix for the RPC requests */ prefix?: string; /** Timeout for fetch requests, in seconds. Must be within the valid range for abort signal timeouts. */ requestTimeout?: number; }; const defaultPrefix = '/twirp'; const defaultTimeoutSeconds = 60; export const livekitPackage = 'livekit'; export interface Rpc { request( service: string, method: string, data: JsonValue, headers: any, // eslint-disable-line @typescript-eslint/no-explicit-any timeout?: number, ): Promise; } export class TwirpError extends Error { status: number; code?: string; metadata?: Record; constructor( name: string, message: string, status: number, code?: string, metadata?: Record, ) { super(message); this.name = name; this.status = status; this.code = code; this.metadata = metadata; } } /** * JSON based Twirp V7 RPC */ export class TwirpRpc { host: string; pkg: string; prefix: string; requestTimeout: number; constructor(host: string, pkg: string, options?: Options) { if (host.startsWith('ws')) { host = host.replace('ws', 'http'); } this.host = host; this.pkg = pkg; this.requestTimeout = options?.requestTimeout ?? defaultTimeoutSeconds; this.prefix = options?.prefix || defaultPrefix; } async request( service: string, method: string, data: any, // eslint-disable-line @typescript-eslint/no-explicit-any headers: any, // eslint-disable-line @typescript-eslint/no-explicit-any timeout = this.requestTimeout, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise { const path = `${this.prefix}/${this.pkg}.${service}/${method}`; const url = new URL(path, this.host); const init: RequestInit = { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8', ...headers, }, body: JSON.stringify(data), }; if (timeout) { init.signal = AbortSignal.timeout(timeout * 1000); } const response = await fetch(url, init); if (!response.ok) { const isJson = response.headers.get('content-type') === 'application/json'; let errorMessage = 'Unknown internal error'; let errorCode: string | undefined = undefined; let metadata: Record | undefined = undefined; try { if (isJson) { const parsedError = (await response.json()) as Record; if ('msg' in parsedError) { errorMessage = parsedError.msg; } if ('code' in parsedError) { errorCode = parsedError.code; } if ('meta' in parsedError) { metadata = >parsedError.meta; } } else { errorMessage = await response.text(); } } catch (e) { // parsing went wrong, no op and we keep default error message console.debug(`Error when trying to parse error message, using defaults`, e); } throw new TwirpError(response.statusText, errorMessage, response.status, errorCode, metadata); } const parsedResp = (await response.json()) as Record; const camelcaseKeys = await import('camelcase-keys').then((mod) => mod.default); return camelcaseKeys(parsedResp, { deep: true }); } }