Server-side Client in Mini oRPC
The server-side client in Mini oRPC transforms procedures into callable functions, enabling direct server-side invocation. This is the foundation of Mini oRPC client system - all other client functionality builds upon it.
INFO
The complete Mini oRPC implementation is available in our GitHub repository: Mini oRPC Repository
Implementation
Here is the complete implementation of the server-side client functionality in Mini oRPC:
ts
import type { Client } from '@mini-orpc/client'
import type { MaybeOptionalOptions } from '@orpc/shared'
import type { AnyProcedure, Procedure, ProcedureHandlerOptions, } from './procedure'
import type { AnySchema, Context, InferSchemaInput, InferSchemaOutput, } from './types'
import { ORPCError } from '@mini-orpc/client'
import { resolveMaybeOptionalOptions } from '@orpc/shared'
import { ValidationError } from './error'
export type ProcedureClient<
TInputSchema extends AnySchema,
TOutputSchema extends AnySchema,
> = Client<InferSchemaInput<TInputSchema>, InferSchemaOutput<TOutputSchema>>
/**
* context can be optional if `Record<never, never> extends TInitialContext`
*/
export type CreateProcedureClientOptions<TInitialContext extends Context> = {
path?: readonly string[]
} & (Record<never, never> extends TInitialContext
? {
context?: TInitialContext
}
: {
context: TInitialContext
})
/**
* Turn a procedure into a callable function
*/
export function createProcedureClient<
TInitialContext extends Context,
TInputSchema extends AnySchema,
TOutputSchema extends AnySchema,
>(
procedure: Procedure<TInitialContext, any, TInputSchema, TOutputSchema>,
...rest: MaybeOptionalOptions<CreateProcedureClientOptions<TInitialContext>>
): ProcedureClient<TInputSchema, TOutputSchema> {
const options = resolveMaybeOptionalOptions(rest)
return (...[input, callerOptions]) => {
return executeProcedureInternal(procedure, {
context: options.context ?? {},
input,
path: options.path ?? [],
procedure,
signal: callerOptions?.signal,
})
}
}
async function validateInput(
procedure: AnyProcedure,
input: unknown
): Promise<any> {
const schema = procedure['~orpc'].inputSchema
if (!schema) {
return input
}
const result = await schema['~standard'].validate(input)
if (result.issues) {
throw new ORPCError('BAD_REQUEST', {
message: 'Input validation failed',
data: {
issues: result.issues,
},
cause: new ValidationError({
message: 'Input validation failed',
issues: result.issues,
}),
})
}
return result.value
}
async function validateOutput(
procedure: AnyProcedure,
output: unknown
): Promise<any> {
const schema = procedure['~orpc'].outputSchema
if (!schema) {
return output
}
const result = await schema['~standard'].validate(output)
if (result.issues) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: 'Output validation failed',
cause: new ValidationError({
message: 'Output validation failed',
issues: result.issues,
}),
})
}
return result.value
}
function executeProcedureInternal(
procedure: AnyProcedure,
options: ProcedureHandlerOptions<any, any>
): Promise<any> {
const middlewares = procedure['~orpc'].middlewares
const inputValidationIndex = 0
const outputValidationIndex = 0
const next = async (
index: number,
context: Context,
input: unknown
): Promise<unknown> => {
let currentInput = input
if (index === inputValidationIndex) {
currentInput = await validateInput(procedure, currentInput)
}
const mid = middlewares[index]
const output = mid
? (
await mid({
...options,
context,
next: async (...[nextOptions]) => {
const nextContext: Context = nextOptions?.context ?? {}
return {
output: await next(
index + 1,
{ ...context, ...nextContext },
currentInput
),
context: nextContext,
}
},
})
).output
: await procedure['~orpc'].handler({
...options,
context,
input: currentInput,
})
if (index === outputValidationIndex) {
return await validateOutput(procedure, output)
}
return output
}
return next(0, options.context, options.input)
}
ts
import type { MaybeOptionalOptions } from '@orpc/shared'
import { isObject, resolveMaybeOptionalOptions } from '@orpc/shared'
export type ORPCErrorOptions<TData> = ErrorOptions & {
status?: number
message?: string
} & (undefined extends TData ? { data?: TData } : { data: TData })
export class ORPCError<TCode extends string, TData> extends Error {
readonly code: TCode
readonly status: number
readonly data: TData
constructor(
code: TCode,
...rest: MaybeOptionalOptions<ORPCErrorOptions<TData>>
) {
const options = resolveMaybeOptionalOptions(rest)
if (options?.status && !isORPCErrorStatus(options.status)) {
throw new Error('[ORPCError] Invalid error status code.')
}
super(options.message, options)
this.code = code
this.status = options.status ?? 500 // Default to 500 if not provided
this.data = options.data as TData // data only optional when TData is undefinable so can safely cast here
}
toJSON(): ORPCErrorJSON<TCode, TData> {
return {
code: this.code,
status: this.status,
message: this.message,
data: this.data,
}
}
}
export type ORPCErrorJSON<TCode extends string, TData> = Pick<
ORPCError<TCode, TData>,
'code' | 'status' | 'message' | 'data'
>
export function isORPCErrorStatus(status: number): boolean {
return status < 200 || status >= 400
}
export function isORPCErrorJson(
json: unknown
): json is ORPCErrorJSON<string, unknown> {
if (!isObject(json)) {
return false
}
const validKeys = ['code', 'status', 'message', 'data']
if (Object.keys(json).some(k => !validKeys.includes(k))) {
return false
}
return (
'code' in json
&& typeof json.code === 'string'
&& 'status' in json
&& typeof json.status === 'number'
&& isORPCErrorStatus(json.status)
&& 'message' in json
&& typeof json.message === 'string'
)
}
ts
export interface ClientOptions {
signal?: AbortSignal
}
export type ClientRest<TInput> = undefined extends TInput
? [input?: TInput, options?: ClientOptions]
: [input: TInput, options?: ClientOptions]
export interface Client<TInput, TOutput> {
(...rest: ClientRest<TInput>): Promise<TOutput>
}
export type NestedClient = Client<any, any> | { [k: string]: NestedClient }
Router Client
Creating a client for each procedure individually can be tedious. Here is how to create a router client that handles multiple procedures:
ts
import type { MaybeOptionalOptions } from '@orpc/shared'
import type { Procedure } from './procedure'
import type { CreateProcedureClientOptions, ProcedureClient } from './procedure-client'
import type { AnyRouter, InferRouterInitialContexts } from './router'
import { get, resolveMaybeOptionalOptions, toArray } from '@orpc/shared'
import { isProcedure } from './procedure'
import { createProcedureClient } from './procedure-client'
export type RouterClient<TRouter extends AnyRouter> = TRouter extends Procedure<
any,
any,
infer UInputSchema,
infer UOutputSchema
>
? ProcedureClient<UInputSchema, UOutputSchema>
: {
[K in keyof TRouter]: TRouter[K] extends AnyRouter
? RouterClient<TRouter[K]>
: never;
}
/**
* Turn a router into a chainable procedure client.
*/
export function createRouterClient<T extends AnyRouter>(
router: T,
...rest: MaybeOptionalOptions<
CreateProcedureClientOptions<InferRouterInitialContexts<T>>
>
): RouterClient<T> {
const options = resolveMaybeOptionalOptions(rest)
if (isProcedure(router)) {
const caller = createProcedureClient(router, options)
return caller as RouterClient<T>
}
const recursive = new Proxy(router, {
get(target, key) {
if (typeof key !== 'string') {
return Reflect.get(target, key)
}
const next = get(router, [key]) as AnyRouter | undefined
if (!next) {
return Reflect.get(target, key)
}
return createRouterClient(next, {
...options,
path: [...toArray(options.path), key],
})
},
})
return recursive as unknown as RouterClient<T>
}
Usage
Transform any procedure or router into a callable client for server-side use:
ts
// Create a client for a single procedure
const procedureClient = createProcedureClient(myProcedure, {
context: { userId: '123' },
})
const result = await procedureClient({ input: 'example' })
// Create a client for an entire router
const routerClient = createRouterClient(myRouter, {
context: { userId: '123' },
})
const result = await routerClient.someProcedure({ input: 'example' })