type MethodKeys = { [METHOD in keyof C]: C[METHOD] extends Function ? METHOD : never }[keyof C]; // eslint-disable-next-line @typescript-eslint/no-explicit-any type PromiseFn = (...args: any) => Promise; type PromiseRes = Awaited>; // eslint-disable-next-line @typescript-eslint/no-explicit-any const isBlobMap = (value: any): value is Record => value && typeof value === 'object' && value[Object.keys(value)[0]] instanceof Blob; // eslint-disable-next-line @typescript-eslint/no-explicit-any const isBlobLike = (value: any): value is Record | Blob => value instanceof Blob || isBlobMap(value); const extendHeaders = (base: RequestInit['headers'], toAdd: Record): Headers => { const headers = new Headers(base); for (const [key, value] of Object.entries(toAdd)) { headers.set(key, value); } return headers; }; export type PreRequestHandler = (item: RequestInit) => Promise; export type PostResponseHandler = (item: Response) => Promise; export type RpcRequest = { core?: Partial & { timeout?: number; retriesOnConnectFailure?: number; path?: string; controller?: string; endpoint?: string; }; url: URL | string; consumeJSON?: (text?: unknown) => (T | Promise); consumeError?: (item: unknown) => (Error | Promise); preRequestHandlers?: PreRequestHandler[]; postResponseHandlers?: PostResponseHandler[]; }; export type RpcClient, E extends Record = {}> = { [C in keyof T]: Pick> & Record, E> }; export type RpcClientFactory> = >( baseOpts: RpcRequest, decorate?: (request: RpcRequest) => R ) => RpcClient; function isResponse(value: unknown): value is Response { return !!value && typeof value === 'object' && 'status' in value && !!value.status && 'headers' in value && !!value.headers; } function isPlainObject(value: unknown): value is Record { return typeof value === 'object' // separate from primitives && value !== undefined && value !== null // is obvious && value.constructor === Object // separate instances (Array, DOM, ...) && Object.prototype.toString.call(value) === '[object Object]'; // separate build-in like Math } function registerTimeout( controller: AbortController, timeout: number, start: (fn: (...args: unknown[]) => unknown, delay: number) => T, stop: (value: T) => void ): void { const timer = start(() => controller.abort(), timeout); if (!(typeof timer === 'number' || typeof timer === 'string')) { timer.unref(); } controller.signal.onabort = (): void => { timer && stop(timer); }; } function buildRequest(base: T, controller: string, endpoint: string): T { return { ...base, method: 'POST', path: `${controller}:${endpoint}`, controller, endpoint }; } export function getBody(inputs: unknown[], isBodyRequest: boolean): { body: FormData | string | undefined, headers: Record } { if (!isBodyRequest) { return { body: undefined, headers: { 'X-TRV-RPC-INPUTS': btoa(encodeURIComponent(JSON.stringify(inputs))) } }; } // If we do not have a blob, simple output if (!inputs.some(isBlobLike)) { return { body: JSON.stringify(inputs), headers: { 'Content-Type': 'application/json' } }; } const plainInputs = inputs.map(value => isBlobLike(value) ? null : value); const form = new FormData(); for (const input of inputs.filter(isBlobLike)) { if (input instanceof Blob) { form.append('file', input, (input instanceof File) ? input.name : undefined); } else { for (const [name, blob] of Object.entries(input)) { form.append(name, blob, (blob instanceof File) ? blob.name : undefined); } } } return { body: form, headers: { 'X-TRV-RPC-INPUTS': btoa(encodeURIComponent(JSON.stringify(plainInputs))) } }; } export function consumeJSON(text: string | unknown): T { if (typeof text !== 'string') { return consumeJSON(JSON.stringify(text)); } else if (text === null || text === undefined || text === '') { return undefined!; } try { return JSON.parse(text, (key, value): unknown => { if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[.]\d{3}Z/.test(value)) { return new Date(value); } else { return value; } }); } catch (error) { throw new Error(`Unable to parse response: ${text}, Unknown error: ${error}`); } } export async function consumeError(error: unknown): Promise { if (error instanceof Error) { return error; } else if (isResponse(error)) { const out = new Error(error.statusText); Object.assign(out, { status: error.status }); return consumeError(out); } else if (isPlainObject(error)) { const out = new Error(); Object.assign(out, error); return consumeError(out); } else { return new Error('Unknown error'); } } export async function invokeFetch(request: RpcRequest, ...params: unknown[]): Promise { let core = request.core!; try { const { body, headers } = getBody(params, /^(post|put|patch)$/i.test(request.core?.method ?? 'POST')); if (body) { core.body = body; } core.headers = extendHeaders(core.headers, headers); for (const fn of request.preRequestHandlers ?? []) { const computed = await fn(core); if (computed) { core = computed; } } const signals = []; if (core.signal) { signals.push(core.signal); } if (core.timeout) { const controller = new AbortController(); // Node/Browser handling of timeout registration registerTimeout(controller, core.timeout, setTimeout, clearTimeout); signals.push(controller.signal); } if (signals.length) { core.signal = AbortSignal.any(signals); } const url = typeof request.url === 'string' ? new URL(request.url) : request.url; if (request.core?.path) { url.pathname = `${url.pathname}/${request.core.path}`.replaceAll('//', '/'); } let resolved: Response | undefined; for (let i = 0; i <= (core.retriesOnConnectFailure ?? 0); i += 1) { try { resolved = await fetch(url, core); break; } catch (error) { if (i < (core.retriesOnConnectFailure ?? 0)) { await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s continue; } else { throw error; } } } if (!resolved) { throw new Error('Unable to connect'); } for (const fn of request.postResponseHandlers ?? []) { const computed = await fn(resolved); if (computed) { resolved = computed; } } const contentType = resolved.headers.get('Content-Type')?.split(';')[0]; if (resolved.ok) { const text = await resolved.text(); if (contentType === 'application/json') { return await request.consumeJSON!(text); } else if (contentType === 'text/plain') { return await request.consumeJSON!(text); } else { throw new Error(`Unknown content type: ${contentType}`); } } else { let responseObject; if (contentType === 'application/json') { const text = await resolved.text(); responseObject = await request.consumeJSON!(text); } else { responseObject = resolved; } throw responseObject; } } catch (error) { throw await request.consumeError!(error); } } export function clientFactory>(): RpcClientFactory { // @ts-ignore return function (request, decorate) { const client: RpcRequest = { consumeJSON, consumeError, ...request, core: { timeout: 0, credentials: 'include', mode: 'cors', ...request.core }, }; const cache: Record = {}; // @ts-ignore return new Proxy({}, { get: (_, controller: string) => cache[controller] ??= new Proxy({}, { get: (__, endpoint: string): unknown => { const final: RpcRequest = { ...client, core: buildRequest(client.core!, controller, endpoint) }; return cache[`${controller}/${endpoint}`] ??= Object.defineProperties( invokeFetch.bind(null, final), Object.fromEntries( Object.entries(decorate?.(final) ?? {}).map(([key, value]) => [key, { value }]) ) ); } }) }); }; } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function withConfigFactoryDecorator(request: RpcRequest) { return { withConfig(this: V, extra: Partial, ...params: Parameters): Promise> { return invokeFetch({ ...request, core: { ...request.core, ...extra } }, ...params); } }; }