Client-side Client in Mini oRPC
In Mini oRPC, the client-side client initiates remote procedure calls to the server. Both client and server must follow shared conventions to communicate effectively. While we could use the RPC Protocol, we'll implement simpler conventions for clarity.
INFO
The complete Mini oRPC implementation is available in our GitHub repository: Mini oRPC Repository
Implementation
Here's the complete implementation of the client-side client functionality in Mini oRPC:
ts
import { ORPCError } from '@mini-orpc/client'
import { get, parseEmptyableJSON } from '@orpc/shared'
import { isProcedure } from '../procedure'
import { createProcedureClient } from '../procedure-client'
import type { Router } from '../router'
import type { Context } from '../types'
export interface JSONHandlerHandleOptions<T extends Context> {
prefix?: `/${string}`
context: T
}
export type JSONHandlerHandleResult
= | { matched: true, response: Response }
| { matched: false, response?: undefined }
export class RPCHandler<T extends Context> {
private readonly router: Router<T>
constructor(router: Router<T>) {
this.router = router
}
async handle(
request: Request,
options: JSONHandlerHandleOptions<T>
): Promise<JSONHandlerHandleResult> {
const prefix = options.prefix
const url = new URL(request.url)
if (
prefix
&& !url.pathname.startsWith(`${prefix}/`)
&& url.pathname !== prefix
) {
return { matched: false, response: undefined }
}
const pathname = prefix ? url.pathname.replace(prefix, '') : url.pathname
const path = pathname
.replace(/^\/|\/$/g, '')
.split('/')
.map(decodeURIComponent)
const procedure = get(this.router, path)
if (!isProcedure(procedure)) {
return { matched: false, response: undefined }
}
const client = createProcedureClient(procedure, {
context: options.context,
path,
})
try {
/**
* The request body may be empty, which is interpreted as `undefined` input.
* Only JSON data is supported for input transfer.
* For more complex data types, consider using a library like [SuperJSON](https://github.com/flightcontrolhq/superjson).
* Note: oRPC uses its own optimized serialization for internal transfers.
*/
const input = parseEmptyableJSON(await request.text())
const output = await client(input, {
signal: request.signal,
})
const response = Response.json(output)
return {
matched: true,
response,
}
}
catch (e) {
const error
= e instanceof ORPCError
? e
: new ORPCError('INTERNAL_ERROR', {
message: 'An error occurred while processing the request.',
cause: e,
})
const response = new Response(JSON.stringify(error.toJSON()), {
status: error.status,
headers: {
'Content-Type': 'application/json',
},
})
return {
matched: true,
response,
}
}
}
}
ts
import { parseEmptyableJSON } from '@orpc/shared'
import { isORPCErrorJson, isORPCErrorStatus, ORPCError } from '../error'
import type { ClientOptions } from '../types'
export interface JSONLinkOptions {
url: string | URL
}
export class RPCLink {
private readonly url: string | URL
constructor(options: JSONLinkOptions) {
this.url = options.url
}
async call(
path: readonly string[],
input: any,
options: ClientOptions
): Promise<any> {
const url = new URL(this.url)
url.pathname = `${url.pathname.replace(/\/$/, '')}/${path.join('/')}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
signal: options.signal,
})
/**
* The request body may be empty, which is interpreted as `undefined` output/error.
* Only JSON data is supported for output/error transfer.
* For more complex data types, consider using a library like [SuperJSON](https://github.com/flightcontrolhq/superjson).
* Note: oRPC uses its own optimized serialization for internal transfers.
*/
const body = await parseEmptyableJSON(await response.text())
if (isORPCErrorStatus(response.status) && isORPCErrorJson(body)) {
throw new ORPCError(body.code, body)
}
if (!response.ok) {
throw new Error(
`[ORPC] Request failed with status ${response.status}: ${response.statusText}`,
{ cause: response }
)
}
return body
}
}
Type-Safe Wrapper
We can create a type-safe wrapper for easier client-side usage:
ts
import type { RPCLink } from './fetch'
import type { Client, ClientOptions, NestedClient } from './types'
export interface createORPCClientOptions {
/**
* Base path for all procedures. Useful when calling only a subset of procedures.
*/
path?: readonly string[]
}
/**
* Create an oRPC client from a link.
*/
export function createORPCClient<T extends NestedClient>(
link: RPCLink,
options: createORPCClientOptions = {}
): T {
const path = options.path ?? []
const procedureClient: Client<unknown, unknown> = async (
...[input, clientOptions = {} as ClientOptions]
) => {
return await link.call(path, input, clientOptions)
}
const recursive = new Proxy(procedureClient, {
get(target, key) {
if (typeof key !== 'string') {
return Reflect.get(target, key)
}
return createORPCClient(link, {
...options,
path: [...path, key],
})
},
})
return recursive as any
}
Usage
Simply set up a client and enjoy a server-side-like experience:
ts
const link = new RPCLink({
url: `${window.location.origin}/rpc`,
})
export const orpc: RouterClient<typeof router> = createORPCClient(link)
const result = await orpc.someProcedure({ input: 'example' })