]> git.codecow.com Git - libnemo.git/commitdiff
Refactor RPC to require auth upfront instead of getting from env.
authorChris Duncan <chris@codecow.com>
Thu, 6 Aug 2026 15:19:39 +0000 (08:19 -0700)
committerChris Duncan <chris@codecow.com>
Thu, 6 Aug 2026 15:19:39 +0000 (08:19 -0700)
sample.env.mjs
src/lib/errors.ts
src/lib/rpc/index.ts
test/GLOBALS.mjs

index dde66e3ebaa8a16e7eed8b4e8a358d3e9d750abc..8ca329fb4c5171cca8b389df9068ed44e12c9067 100644 (file)
@@ -2,6 +2,10 @@
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
 // Save this file as `env.mjs` and replace the examples with real values
+
+/**
+ * @type {{LIBNEMO_RPC_URL: string | URL, LIBNEMO_RPC_AUTHORIZATION?: string | {header: string, value: string}}}
+ */
 export const env = {
 
        // Specify a valid URL pointing to a live Nano node.
index 95399a9d73ea4f63fa5bcb8afcd1091af382d63d..5d7447d66591d1364f4b8b8bd2133b1d4cff3743 100644 (file)
@@ -1,4 +1,5 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
+export class RpcError extends Error { }
 export class VaultError extends Error { }
index afe1767c59f0aa8bd9fdd824f662e1354aa35710..6bc3700a1f0a96995e6bc9b71454fd42a1e16b46 100644 (file)
@@ -1,5 +1,6 @@
 //! 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 }
@@ -12,10 +13,15 @@ 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
@@ -29,10 +35,36 @@ export class Rpc {
         * @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')
+               }
        }
 
        /**
@@ -42,39 +74,12 @@ export class Rpc {
         * @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)
@@ -85,20 +90,45 @@ export class Rpc {
                        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 {
index febde3c34da02acf2b5c4edf7d43804af10fcaee..17acc98e39e60199c55f58eaeefc5f44b3df078e 100644 (file)
@@ -7,7 +7,10 @@ import { env } from '../env.mjs'
 
 export { env }
 export const isNode = globalThis !== globalThis.window
-export const rpc = new Rpc(env?.LIBNEMO_RPC_URL ?? '', env.LIBNEMO_RPC_AUTHORIZATION)
+
+const url = env.LIBNEMO_RPC_URL
+const auth = env.LIBNEMO_RPC_AUTHORIZATION
+export const rpc = new Rpc(url, auth)
 
 if (globalThis.sessionStorage == null) {
        let _sessionStorage = {}