import createClient from 'openapi-fetch'; import Lacuna from '../lacuna'; import { fixNumbers } from './util'; import { isBlockingError } from './responses'; import type { ResponseEvent } from './responses'; import type { paths } from '../types/schema'; export interface ServerRequest { module: string; method: string; params: object; addSession: boolean; } export interface ServerResponse { result?: T; error?: ServerError; } export interface ServerError { code: number; message: string; data: any; } interface RpcErrorBody { id: number; jsonrpc: string; error: ServerError; } interface RpcResultBody { id: number; jsonrpc: string; result?: T; } /** * Hard backstop against a subscriber that retries unconditionally. Past this * many attempts `retry()` resolves the response it already has instead of * issuing another request. Handlers should stop well before this on their own * (the built-in RPC limit handler defaults to 5 retries). */ const MAX_RETRY_DEPTH = 10; /** Used whenever we never got a well-formed RPC response back at all. */ const networkError = (): ServerError => ({ code: -1, message: 'Could not communicate with server', data: {}, }); /** Module names come from the server or from devs who don't know the internals of the library so this * utility can basically treat those things as untrusted user input and clean them. */ const sanitizeUrlComponent = (input: string) => { return input.toLowerCase().replace(/[^a-z0-9_]/g, ''); }; class Server { lacuna: Lacuna; client: ReturnType>; constructor(lacuna: Lacuna) { this.lacuna = lacuna; this.client = createClient({ baseUrl: lacuna.config.serverUrl }); } authHeaders(options: ServerRequest): Record { if (!options.addSession) return {}; const sessionId = this.lacuna.session.get(); if (!sessionId) { this.lacuna.log.warn( `${options.module}#${options.method} called with a session required, but no session is set.` ); return {}; } return { Authorization: `Token ${sessionId}` }; } async call(options: ServerRequest, attempt = 0): Promise> { const apiModule = sanitizeUrlComponent(options.module); const apiMethod = sanitizeUrlComponent(options.method); this.lacuna.log.info('Calling', `${apiModule}/${apiMethod}`, options.params); const path = `/v2/${apiModule}/${apiMethod}` as any; const { data, error } = await this.client.POST(path, { baseUrl: this.lacuna.config.serverUrl, // Configured serverUrl can change in between requests body: options.params, headers: this.authHeaders(options), }); const response: ServerResponse = data ? { result: fixNumbers((data as RpcResultBody).result) } : { error: (error as RpcErrorBody | undefined)?.error || networkError() }; return this.publish(options, response, attempt, (request, next) => this.call(request, next)); } /** * Fallback for RPC methods with no working /v2 equivalent. Hits the legacy * non-/v2 JSON-RPC endpoint directly. No endpoint in this library uses it * any more - it's kept as an escape hatch for the /v2 methods the backend * migration hasn't wired up yet (see README "Spec Discrepancies"). */ async callLegacy(options: ServerRequest, attempt = 0): Promise> { this.lacuna.log.info('Calling (legacy)', `${options.module}/${options.method}`, options.params); const response = await this.fetchLegacy(options); return this.publish(options, response, attempt, (request, next) => this.callLegacy(request, next) ); } private async fetchLegacy(options: ServerRequest): Promise> { const sessionId = this.lacuna.session.get(); const params = options.addSession && sessionId ? [sessionId, ...(options.params as any[])] : options.params; const url = new URL(options.module, this.lacuna.config.serverUrl).href; try { const response = await fetch(url, { method: 'post', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: options.method, params }), }); const body = (await response.json()) as RpcResultBody & Partial; if (response.ok) return { result: fixNumbers(body.result) }; return { error: body.error || networkError() }; } catch { return { error: networkError() }; } } /** * Hands every response to the subscribers registered via Lacuna#onResponse. * * Responses carrying a blocking error code (an RPC limit, a captcha prompt, * or a rejected session) wait for those handlers to finish, and a response a * handler returns - from `retry()`, typically - replaces this one. That's * what lets a script ride out an RPC limit error instead of being stopped by * it. Everything else is published without waiting. */ private publish( request: ServerRequest, response: ServerResponse, attempt: number, again: (request: ServerRequest, attempt: number) => Promise> ): Promise> | ServerResponse { const event: ResponseEvent = { request, response, attempt, retry: () => attempt >= MAX_RETRY_DEPTH ? Promise.resolve(response) : again(request, attempt + 1), }; if (isBlockingError(response.error)) { return this.lacuna.responses.dispatchBlocking(event); } this.lacuna.responses.dispatch(event); return response; } } export default Server;