//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
//! SPDX-License-Identifier: GPL-3.0-or-later
+import { RpcError } from '../errors'
import { Schema } from './schema'
export { Schema }
*
* The URL protocol must be HTTPS; any other protocol will be changed to
* `https:` automatically.
+ *
+ * The optional authentication parameter accepts three kinds of argument:
+ * - JS object with a `header` property and a `value` property
+ * - JSON string that can be parsed into the above JS object format
+ * - Plain string used as the value for the standard "Authorization" HTTP header
*/
export class Rpc {
#url: URL
- #auth?: string | { header: string, value: string }
+ #auth?: { header: string, value: string }
/**
* @param {(string|URL)} url Nano network node
* @param {string} auth.value authorization token value
*/
constructor (url: string | URL, auth?: { header: string, value: string })
- constructor (url: string | URL, auth?: string | { header: string, value: string }) {
- this.#url = new URL(url)
- this.#url.protocol = 'https:'
- this.#auth = auth
+ constructor (url: unknown, auth: unknown) {
+ if ((typeof url === 'string' || url instanceof URL) && URL.canParse(url)) {
+ this.#url = new URL(url)
+ this.#url.protocol = 'https:'
+ } else {
+ throw new RpcError('Invalid URL', { cause: url })
+ }
+
+ if (typeof auth === 'string') {
+ (auth as string) = auth.trim()
+ if (/^\{.*\}$/.test(auth)) {
+ auth = JSON.parse(auth)
+ } else {
+ auth = { header: 'Authorization', value: auth }
+ }
+ }
+ if (auth != null
+ && typeof auth === 'object'
+ && 'header' in auth
+ && typeof auth.header === 'string'
+ && 'value' in auth
+ && typeof auth.value === 'string'
+ ) {
+ this.#auth = {
+ header: auth.header,
+ value: auth.value
+ }
+ } else if (auth !== undefined) {
+ throw new RpcError('Unrecognized RPC authorization format')
+ }
}
/**
* @param {object} [data] - JSON to send to the node as defined by the action
* @returns {Promise<any>} JSON-formatted RPC results from the node
*/
- async post (action: string, data?: Record<string, unknown>): Promise<unknown>
+ async post<T extends keyof typeof Schema> (action: T, data?: Record<string, unknown>): Promise<typeof Schema[T]>
async post (action: unknown, data: unknown): Promise<unknown> {
- const env = typeof process !== 'undefined' && 'env' in process ? process.env : null
this.#validate(action)
- const headers: Record<string, string> = {
- 'Content-Type': 'application/json'
- }
- const auth = this.#auth ?? env?.LIBNEMO_RPC_AUTHORIZATION
- if (typeof auth === 'string') {
- const token = /^\{.*\}$/.test(auth.trim()) ? JSON.parse(auth) : auth
- headers['Authorization'] = token
- } else if (typeof auth?.header === 'string' && typeof auth.value === 'string') {
- headers[auth.header] = auth.value
- } else {
- console.warn('Unrecognized RPC authorization format')
- }
- if (data !== undefined && typeof data !== 'object') {
- throw new TypeError('Invalid RPC post data')
- }
- const reqBody: Record<string, unknown> = { ...data }
- if ('action' in reqBody && (typeof reqBody.action !== 'string' || reqBody.action.toLowerCase() !== action.toLowerCase())) {
- throw new RangeError(`RPC post data contains 'action' property and does not match 'action' parameter argument. Do not include 'action' in request data.`, { cause: JSON.stringify(reqBody) })
- }
- reqBody.action = action.toLowerCase()
-
const aborter = new AbortController()
- const req = new Request(this.#url, {
- signal: aborter.signal,
- method: 'POST',
- headers,
- body: JSON.stringify(reqBody)
- })
- const kill = setTimeout(() => {
+ const req = this.#createRequest(action, data, aborter.signal)
+ const abort = setTimeout(() => {
console.log('aborting RPC call')
aborter.abort()
}, 10000)
if (status !== 200) {
throw new Error(`${status}${statusText}`)
}
- const resBody = await res.json()
- const code = resBody.code ? `${resBody.code} ` : ''
- const error = resBody.error ? `${resBody.error}\n` : ''
- const message = resBody.message ?? ''
+ const body = await res.json()
+ const code = body.code ? `${body.code} ` : ''
+ const error = body.error ? `${body.error}\n` : ''
+ const message = body.message ?? ''
if (error) {
- throw new Error(`${code}${error}${message}`, { cause: JSON.stringify(resBody) })
+ throw new Error(`${code}${error}${message}`, { cause: JSON.stringify(body) })
}
- return resBody
+ return body
} catch (err) {
console.error(err)
throw new Error(`RPC ${action} request failed`, { cause: err })
} finally {
- clearTimeout(kill)
+ clearTimeout(abort)
+ }
+ }
+
+ #createRequest (action: string, data: unknown, signal: AbortSignal): Request {
+ const headers: Record<string, string> = {
+ 'Content-Type': 'application/json'
}
+ if (this.#auth) {
+ headers[this.#auth.header] = this.#auth.value
+ }
+
+ if (data !== undefined && typeof data !== 'object') {
+ throw new RpcError('Invalid RPC post data')
+ }
+ const body: Record<string, unknown> = { ...data }
+ if ('action' in body && (typeof body.action !== 'string' || body.action.toLowerCase() !== action.toLowerCase())) {
+ throw new RangeError(`RPC post data contains 'action' property and does not match 'action' parameter argument. Do not include 'action' in request data.`, { cause: JSON.stringify(body) })
+ }
+ body.action = action.toLowerCase()
+
+ return new Request(this.#url, {
+ body: JSON.stringify(body),
+ headers,
+ method: 'POST',
+ signal,
+ })
}
#validate (action: unknown): asserts action is string {