ox
Version:
Ethereum Standard Library
226 lines (208 loc) • 6.69 kB
text/typescript
import * as Errors from './Errors.js'
import { getUrl } from './internal/errors.js'
import * as promise from './internal/promise.js'
import type * as RpcSchema_internal from './internal/rpcSchema.js'
import * as internal from './internal/rpcTransport.js'
import type { Compute } from './internal/types.js'
import type * as RpcResponse from './RpcResponse.js'
import type * as RpcSchema from './RpcSchema.js'
/** Root type for an RPC Transport. */
export type RpcTransport<
raw extends boolean = false,
options extends Record<string, unknown> = {},
schema extends RpcSchema.Generic = RpcSchema.Default,
> = Compute<{
request: RequestFn<raw, options, schema>
}>
/** HTTP-based RPC Transport. */
export type Http<
raw extends boolean = false,
schema extends RpcSchema.Generic = RpcSchema.Default,
> = RpcTransport<raw, HttpOptions, schema>
export type HttpOptions = {
/** Request configuration to pass to `fetch`. */
fetchOptions?:
| Omit<RequestInit, 'body'>
| ((
method: RpcSchema.Generic['Request'],
) => Omit<RequestInit, 'body'> | Promise<Omit<RequestInit, 'body'>>)
| undefined
/** Function to use to make the request. @default fetch */
fetchFn?: typeof fetch | undefined
/** Timeout for the request in milliseconds. @default 10_000 */
timeout?: number | undefined
}
export type RequestFn<
raw extends boolean = false,
options extends Record<string, unknown> = {},
schema extends RpcSchema.Generic = RpcSchema.Default,
> = <
methodName extends RpcSchema.MethodNameGeneric,
raw_override extends boolean | undefined = undefined,
>(
parameters: Compute<
RpcSchema_internal.ExtractRequestOpaque<schema, methodName>
>,
options?: internal.Options<raw_override, options, schema>,
) => Promise<
raw_override extends boolean
? raw_override extends true
? RpcResponse.RpcResponse<RpcSchema.ExtractReturnType<schema, methodName>>
: RpcSchema.ExtractReturnType<schema, methodName>
: raw extends true
? RpcResponse.RpcResponse<RpcSchema.ExtractReturnType<schema, methodName>>
: RpcSchema.ExtractReturnType<schema, methodName>
>
/**
* Creates a HTTP JSON-RPC Transport from a URL.
*
* @example
* ```ts twoslash
* import { RpcTransport } from 'ox'
*
* const transport = RpcTransport.fromHttp(
* 'https://1.rpc.thirdweb.com'
* )
*
* const blockNumber = await transport.request({
* method: 'eth_blockNumber'
* })
* // @log: '0x1a2b3c'
* ```
*
* @param url - URL to perform the JSON-RPC requests to.
* @param options - Transport options.
* @returns HTTP JSON-RPC Transport.
*/
export function fromHttp<
raw extends boolean = false,
schema extends RpcSchema.Schema = RpcSchema.Default,
>(
url: string,
options: fromHttp.Options<raw, schema> = {},
): Http<raw, RpcSchema.ToGeneric<schema>> {
return internal.create<HttpOptions, RpcSchema.ToGeneric<schema>, raw>(
{
async request(body_, options_) {
const {
fetchFn = options.fetchFn ?? fetch,
fetchOptions: fetchOptions_ = options.fetchOptions,
timeout = options.timeout ?? 10_000,
} = options_
const body = JSON.stringify(body_)
const fetchOptions =
typeof fetchOptions_ === 'function'
? await fetchOptions_(body_)
: fetchOptions_
const response = await promise.withTimeout(
({ signal }) => {
const externalSignal = fetchOptions?.signal
const timeoutSignal = timeout > 0 ? signal : null
const signals = [externalSignal, timeoutSignal].filter(
(s): s is AbortSignal => Boolean(s),
)
const composedSignal =
signals.length === 0
? null
: signals.length === 1
? signals[0]!
: AbortSignal.any(signals)
const init: RequestInit = {
...fetchOptions,
body,
headers: {
'Content-Type': 'application/json',
...Object.fromEntries(new Headers(fetchOptions?.headers)),
},
method: fetchOptions?.method ?? 'POST',
signal: composedSignal,
}
return fetchFn(url, init)
},
{
timeout,
signal: true,
},
)
// Always read as text so we can handle empty bodies and non-JSON
// payloads (some servers return `Content-Type: application/json` with
// an empty body for error responses).
const data = await response.text().then((data) => {
if (data === '') {
if (response.ok)
throw new MalformedResponseError({ response: data })
return { error: undefined }
}
try {
return JSON.parse(data)
} catch {
if (response.ok)
throw new MalformedResponseError({
response: data,
})
return { error: data }
}
})
if (!response.ok) {
const error = (data as { error?: unknown })?.error
throw new HttpError({
body,
details:
typeof error === 'string'
? error
: error
? JSON.stringify(error)
: response.statusText,
response,
url,
})
}
return data as never
},
},
{ raw: options.raw },
)
}
export declare namespace fromHttp {
type Options<
raw extends boolean = false,
schema extends RpcSchema.Schema = RpcSchema.Default,
> = internal.Options<raw, HttpOptions, schema>
type ErrorType =
| promise.withTimeout.ErrorType
| HttpError
| Errors.GlobalErrorType
}
/** Thrown when a HTTP request fails. */
export class HttpError extends Errors.BaseError {
override readonly name = 'RpcTransport.HttpError'
constructor({
body,
details,
response,
url,
}: {
body: unknown
details: string
response: Response
url: string
}) {
super('HTTP request failed.', {
details,
metaMessages: [
`Status: ${response.status}`,
`URL: ${getUrl(url)}`,
body ? `Body: ${JSON.stringify(body)}` : undefined,
],
})
}
}
/** Thrown when a HTTP response is malformed. */
export class MalformedResponseError extends Errors.BaseError {
override readonly name = 'RpcTransport.MalformedResponseError'
constructor({ response }: { response: string }) {
super('HTTP Response could not be parsed as JSON.', {
metaMessages: [`Response: ${response}`],
})
}
}