diff --git a/packages/server/src/implementer-procedure.ts b/packages/server/src/implementer-procedure.ts index ecb3046cf..a2bc9ef82 100644 --- a/packages/server/src/implementer-procedure.ts +++ b/packages/server/src/implementer-procedure.ts @@ -44,13 +44,17 @@ export interface ImplementedProcedure< /** * Make this procedure callable (works like a function while still being a procedure). */ - callable(...rest: CreateProcedureClientRest): & Procedure + callable( + ...rest: CreateProcedureClientRest + ): Procedure & ProcedureClient < TClientContext, TInputSchema, TOutputSchema, THandlerOutput, TErrorMap > /** * Make this procedure compatible with server action (the same as .callable, but the type is compatible with server action). */ - actionable(...rest: CreateProcedureClientRest): & Procedure + actionable( + ...rest: CreateProcedureClientRest + ): Procedure & ((...rest: ClientRest>) => Promise>) } diff --git a/packages/server/src/procedure-client.test-d.ts b/packages/server/src/procedure-client.test-d.ts index d1905151d..df126a337 100644 --- a/packages/server/src/procedure-client.test-d.ts +++ b/packages/server/src/procedure-client.test-d.ts @@ -1,5 +1,8 @@ -import type { baseErrorMap, inputSchema, outputSchema } from '../../contract/tests/shared' -import { type Client, type ORPCError, safe } from '@orpc/contract' +import type { Client, ErrorMap, ORPCError, ORPCErrorConstructorMap, Schema } from '@orpc/contract' +import type { baseErrorMap, BaseMeta, inputSchema, outputSchema } from '../../contract/tests/shared' +import type { Context } from './context' +import type { Procedure } from './procedure' +import { safe } from '@orpc/contract' import { ping, pong } from '../tests/shared' import { createProcedureClient, type ProcedureClient } from './procedure-client' @@ -77,4 +80,39 @@ describe('createProcedureClient', () => { > >() }) + + it('optional context when all fields are optional', () => { + createProcedureClient(pong) + createProcedureClient(pong, {}) + + // @ts-expect-error - context is required + createProcedureClient(ping) + // @ts-expect-error - context is required + createProcedureClient(ping, {}) + createProcedureClient(ping, { context: { db: 'postgres' } }) + }) + + it('well type interceptor', () => { + createProcedureClient(ping, { + context: { db: 'postgres' }, + interceptors: [ + async ({ next, signal, procedure, path, errors, context, input }) => { + expectTypeOf(signal).toEqualTypeOf>() + expectTypeOf(procedure).toEqualTypeOf< + Procedure + >() + expectTypeOf(path).toEqualTypeOf() + expectTypeOf(errors).toEqualTypeOf>() + expectTypeOf(context).toEqualTypeOf<{ db: string }>() + expectTypeOf(input).toEqualTypeOf<{ input: number }>() + + const output = await next() + + expectTypeOf(output).toEqualTypeOf<{ output: string }>() + + return output + }, + ], + }) + }) }) diff --git a/packages/server/src/procedure-client.test.ts b/packages/server/src/procedure-client.test.ts index 20d798c5f..d6a00d821 100644 --- a/packages/server/src/procedure-client.test.ts +++ b/packages/server/src/procedure-client.test.ts @@ -344,60 +344,36 @@ describe.each(procedureCases)('createProcedureClient - case %s', async (_, proce expect(handler).toBeCalledWith(expect.objectContaining({ context: { val: '__val__' } })) }) - it.each(contextCases)('can accept hooks - context: %s', async (_, context) => { - const execute = vi.fn((input, context, meta) => meta.next()) - const onStart = vi.fn() - const onSuccess = vi.fn() - const onError = vi.fn() - const onFinish = vi.fn() + it.each(contextCases)('can intercept - context: %s', async (_, context) => { + const interceptor = vi.fn(({ next }) => next()) const client = createProcedureClient(procedure, { context, path: ['users'], - interceptor: execute, - onStart, - onSuccess, - onError, - onFinish, + interceptors: [interceptor], }) - await client({ val: '123' }) + vi.mocked(createORPCErrorConstructorMap).mockReturnValueOnce('__constructors__' as any) - const meta = { - path: ['users'], - procedure: unwrappedProcedure, - } + const signal = new AbortController().signal - const contextValue = { val: '__val__' } + await client({ val: '123' }, { signal }) - expect(execute).toBeCalledTimes(1) - expect(execute).toHaveBeenCalledWith({ val: '123' }, contextValue, expect.objectContaining({ - ...meta, + expect(interceptor).toBeCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith({ + input: { val: '123' }, + context: { val: '__val__' }, + signal, + path: ['users'], + errors: '__constructors__', + procedure: unwrappedProcedure, next: expect.any(Function), - })) - - expect(onStart).toBeCalledTimes(1) - expect(onStart).toHaveBeenCalledWith( - { status: 'pending', input: { val: '123' }, output: undefined, error: undefined }, - contextValue, - expect.objectContaining(meta), - ) - - expect(onSuccess).toBeCalledTimes(1) - expect(onSuccess).toHaveBeenCalledWith( - { status: 'success', input: { val: '123' }, output: { val: 123 }, error: undefined }, - contextValue, - expect.objectContaining(meta), - ) - - expect(onError).toBeCalledTimes(0) + }) }) it('accept paths', async () => { - const onSuccess = vi.fn() const client = createProcedureClient(procedure, { path: ['users'], - onSuccess, }) await client({ val: '123' }) @@ -410,19 +386,13 @@ describe.each(procedureCases)('createProcedureClient - case %s', async (_, proce expect(handler).toBeCalledTimes(1) expect(handler).toHaveBeenCalledWith(expect.objectContaining({ path: ['users'] })) - - expect(onSuccess).toBeCalledTimes(1) - expect(onSuccess).toHaveBeenCalledWith(expect.any(Object), {}, expect.objectContaining({ path: ['users'] })) }) it('support signal', async () => { const controller = new AbortController() const signal = controller.signal - const onSuccess = vi.fn() - const client = createProcedureClient(procedure, { - onSuccess, context: { userId: '123' }, }) @@ -442,9 +412,6 @@ describe.each(procedureCases)('createProcedureClient - case %s', async (_, proce expect(handler).toBeCalledTimes(1) expect(handler).toHaveBeenCalledWith(expect.objectContaining({ signal })) - - expect(onSuccess).toBeCalledTimes(1) - expect(onSuccess).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), expect.objectContaining({ signal })) }) describe('error validation', () => { diff --git a/packages/server/src/procedure-client.ts b/packages/server/src/procedure-client.ts index 1266ccd9a..aad4a88dd 100644 --- a/packages/server/src/procedure-client.ts +++ b/packages/server/src/procedure-client.ts @@ -1,11 +1,11 @@ -import type { Client, ErrorFromErrorMap, ErrorMap, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' -import type { Hooks, Value } from '@orpc/shared' +import type { Client, ErrorFromErrorMap, ErrorMap, Meta, ORPCErrorConstructorMap, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { Interceptor, Value } from '@orpc/shared' import type { Context } from './context' import type { Lazyable } from './lazy' import type { MiddlewareNextFn } from './middleware' import type { AnyProcedure, Procedure, ProcedureHandlerOptions } from './procedure' import { createORPCErrorConstructorMap, ORPCError, validateORPCError, ValidationError } from '@orpc/contract' -import { executeWithHooks, toError, value } from '@orpc/shared' +import { intercept, toError, value } from '@orpc/shared' import { unlazy } from './lazy' import { middlewareOutputFn } from './middleware' @@ -17,13 +17,30 @@ export type ProcedureClient< TErrorMap extends ErrorMap, > = Client, SchemaOutput, ErrorFromErrorMap> +export interface ProcedureClientInterceptorOptions< + TInitialContext extends Context, + TInputSchema extends Schema, + TErrorMap extends ErrorMap, + TMeta extends Meta, +> { + context: TInitialContext + input: SchemaInput + errors: ORPCErrorConstructorMap + path: string[] + procedure: Procedure + signal?: AbortSignal +} + /** * Options for creating a procedure caller with comprehensive type safety */ export type CreateProcedureClientOptions< TInitialContext extends Context, - TCurrentContext extends Schema, - THandlerOutput extends SchemaInput, + TInputSchema extends Schema, + TOutputSchema extends Schema, + THandlerOutput extends SchemaInput, + TErrorMap extends ErrorMap, + TMeta extends Meta, TClientContext, > = & { @@ -31,20 +48,29 @@ export type CreateProcedureClientOptions< * This is helpful for logging and analytics. */ path?: string[] + + interceptors?: Interceptor< + ProcedureClientInterceptorOptions, + SchemaOutput, + ErrorFromErrorMap + >[] } & ( - | { context: Value } - | (Record extends TInitialContext ? Record : never) + Record extends TInitialContext + ? { context?: Value } + : { context: Value } ) - & Hooks, TInitialContext, any> export type CreateProcedureClientRest< TInitialContext extends Context, + TInputSchema extends Schema, TOutputSchema extends Schema, THandlerOutput extends SchemaInput, + TErrorMap extends ErrorMap, + TMeta extends Meta, TClientContext, > = - | [options: CreateProcedureClientOptions] + | [options: CreateProcedureClientOptions] | (Record extends TInitialContext ? [] : never) export function createProcedureClient< @@ -53,10 +79,19 @@ export function createProcedureClient< TOutputSchema extends Schema, THandlerOutput extends SchemaInput, TErrorMap extends ErrorMap, + TMeta extends Meta, TClientContext, >( - lazyableProcedure: Lazyable>, - ...[options]: CreateProcedureClientRest + lazyableProcedure: Lazyable>, + ...[options]: CreateProcedureClientRest< + TInitialContext, + TInputSchema, + TOutputSchema, + THandlerOutput, + TErrorMap, + TMeta, + TClientContext + > ): ProcedureClient { return async (...[input, callerOptions]) => { const path = options?.path ?? [] @@ -65,9 +100,9 @@ export function createProcedureClient< const context = await value(options?.context ?? {}, callerOptions?.context) as TInitialContext const errors = createORPCErrorConstructorMap(procedure['~orpc'].errorMap) - const executeOptions = { - input, + const interceptorOptions: ProcedureClientInterceptorOptions = { context, + input: input as SchemaInput, // input only optional when it undefinable so we can safely cast it errors, path, procedure: procedure as AnyProcedure, @@ -75,15 +110,11 @@ export function createProcedureClient< } try { - const output = await executeWithHooks({ - hooks: options, - input, - context, - meta: executeOptions, - execute: () => executeProcedureInternal(procedure, executeOptions), - }) - - return output + return await intercept( + options?.interceptors ?? [], + interceptorOptions, + interceptorOptions => executeProcedureInternal(interceptorOptions.procedure, interceptorOptions), + ) } catch (e) { if (!(e instanceof ORPCError)) { diff --git a/packages/server/src/procedure-decorated.ts b/packages/server/src/procedure-decorated.ts index 104f291eb..f9c96394b 100644 --- a/packages/server/src/procedure-decorated.ts +++ b/packages/server/src/procedure-decorated.ts @@ -91,8 +91,9 @@ export class DecoratedProcedure< /** * Make this procedure callable (works like a function while still being a procedure). */ - callable(...rest: CreateProcedureClientRest): - & Procedure + callable( + ...rest: CreateProcedureClientRest + ): Procedure & ProcedureClient { return Object.assign(createProcedureClient(this, ...rest), { '~type': 'Procedure' as const, @@ -103,7 +104,9 @@ export class DecoratedProcedure< /** * Make this procedure compatible with server action (the same as .callable, but the type is compatible with server action). */ - actionable(...rest: CreateProcedureClientRest): + actionable( + ...rest: CreateProcedureClientRest + ): & Procedure & ((...rest: ClientRest>) => Promise>) { return this.callable(...rest) diff --git a/packages/server/src/procedure-utils.ts b/packages/server/src/procedure-utils.ts index 82c0b9059..ec0fbc0ed 100644 --- a/packages/server/src/procedure-utils.ts +++ b/packages/server/src/procedure-utils.ts @@ -1,4 +1,4 @@ -import type { ClientPromiseResult, ErrorFromErrorMap, ErrorMap, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { ClientPromiseResult, ErrorFromErrorMap, ErrorMap, Meta, Schema, SchemaInput, SchemaOutput } from '@orpc/contract' import type { Context } from './context' import type { Lazyable } from './lazy' import type { Procedure } from './procedure' @@ -20,10 +20,11 @@ export function call< TOutputSchema extends Schema, THandlerOutput extends SchemaInput, TErrorMap extends ErrorMap, + TMeta extends Meta, >( - procedure: Lazyable>, + procedure: Lazyable>, input: SchemaInput, - ...rest: CreateProcedureClientRest + ...rest: CreateProcedureClientRest ): ClientPromiseResult, ErrorFromErrorMap> { return createProcedureClient(procedure, ...rest)(input) } diff --git a/packages/server/src/router-client.test.ts b/packages/server/src/router-client.test.ts index 492511fa3..c56bfe465 100644 --- a/packages/server/src/router-client.test.ts +++ b/packages/server/src/router-client.test.ts @@ -46,19 +46,11 @@ describe('createRouterClient', () => { }) it('hooks', async () => { - const onStart = vi.fn() - const onSuccess = vi.fn() - const onError = vi.fn() - const onFinish = vi.fn() const interceptor = vi.fn() const client = createRouterClient(router, { context: { db: 'postgres' }, - onStart, - onSuccess, - onError, - onFinish, - interceptor, + interceptors: [interceptor], }) expect(client.pong({ val: '123' })).toEqual('__mocked__') @@ -67,11 +59,7 @@ describe('createRouterClient', () => { expect(createProcedureClient).toHaveBeenCalledWith(pong, expect.objectContaining({ context: { db: 'postgres' }, path: ['pong'], - onStart, - onSuccess, - onError, - onFinish, - interceptor, + interceptors: [interceptor], })) }) diff --git a/packages/server/src/router-client.ts b/packages/server/src/router-client.ts index 444ba6ad1..4aea4a408 100644 --- a/packages/server/src/router-client.ts +++ b/packages/server/src/router-client.ts @@ -1,4 +1,4 @@ -import type { Hooks, Value } from '@orpc/shared' +import type { ErrorMap, Meta } from '@orpc/contract' import type { Lazy } from './lazy' import type { Procedure } from './procedure' import type { CreateProcedureClientRest, ProcedureClient } from './procedure-client' @@ -17,26 +17,20 @@ export type RouterClient = TRouter ex [K in keyof TRouter]: TRouter[K] extends AnyRouter ? RouterClient : never } -export type CreateRouterClientOptions = - & { - /** - * This is helpful for logging and analytics. - * - * @internal - */ - path?: string[] - } - & (TRouter extends Router - ? undefined extends UContext ? { context?: Value } : { context: Value } - : never) - & Hooks ? UContext : never, any> - export function createRouterClient< TRouter extends AnyRouter, TClientContext, >( router: TRouter | Lazy, - ...rest: CreateProcedureClientRest ? UContext : never, undefined, unknown, TClientContext> + ...rest: CreateProcedureClientRest< + TRouter extends Router ? UContext : never, + undefined, + undefined, + unknown, + ErrorMap, + Meta, + TClientContext + > ): RouterClient { if (isProcedure(router)) { const caller = createProcedureClient(router, ...rest) diff --git a/packages/shared/src/hook.ts b/packages/shared/src/hook.ts deleted file mode 100644 index 4536e748e..000000000 --- a/packages/shared/src/hook.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Arrayable, Promisable } from 'type-fest' -import { toError } from './error' - -export type OnStartState = { status: 'pending', input: TInput, output: undefined, error: undefined } -export type OnSuccessState = { status: 'success', input: TInput, output: TOutput, error: undefined } -export type OnErrorState = { status: 'error', input: TInput, output: undefined, error: Error } - -export interface BaseHookMeta { - next(): Promise -} - -export interface Hooks & { next?: never }) | undefined> { - interceptor?: Arrayable<(input: TInput, context: TContext, meta: (TMeta extends undefined ? unknown : TMeta) & BaseHookMeta) => Promise> - onStart?: Arrayable<(state: OnStartState, context: TContext, meta: TMeta) => Promisable> - onSuccess?: Arrayable<(state: OnSuccessState, context: TContext, meta: TMeta) => Promisable> - onError?: Arrayable<(state: OnErrorState, context: TContext, meta: TMeta) => Promisable> - onFinish?: Arrayable<(state: OnSuccessState | OnErrorState, context: TContext, meta: TMeta) => Promisable> -} - -export async function executeWithHooks & { next?: never }) | undefined>( - options: { - hooks?: Hooks - input: TInput - context: TContext - meta: TMeta - execute: BaseHookMeta['next'] - }, -): Promise { - const interceptors = convertToArray(options.hooks?.interceptor) - const onStarts = convertToArray(options.hooks?.onStart) - const onSuccesses = convertToArray(options.hooks?.onSuccess) - const onErrors = convertToArray(options.hooks?.onError) - const onFinishes = convertToArray(options.hooks?.onFinish) - - let currentExecuteIndex = 0 - - const next = async (): Promise => { - const execute = interceptors[currentExecuteIndex] - - if (execute) { - currentExecuteIndex++ - return await execute(options.input, options.context, { - ...options.meta, - next, - } as any) - } - - let state: OnSuccessState | OnErrorState | OnStartState - = { status: 'pending', input: options.input, output: undefined, error: undefined } - - try { - for (const onStart of onStarts) { - await onStart(state, options.context, options.meta) - } - - const output = await options.execute() - - state = { status: 'success', input: options.input, output, error: undefined } - - for (let i = onSuccesses.length - 1; i >= 0; i--) { - await onSuccesses[i]!(state, options.context, options.meta) - } - } - catch (e) { - state = { status: 'error', input: options.input, error: toError(e), output: undefined } - - for (let i = onErrors.length - 1; i >= 0; i--) { - try { - await onErrors[i]!(state, options.context, options.meta) - } - catch (e) { - state = { status: 'error', input: options.input, error: toError(e), output: undefined } - } - } - } - - for (let i = onFinishes.length - 1; i >= 0; i--) { - try { - await onFinishes[i]!(state, options.context, options.meta) - } - catch (e) { - state = { status: 'error', input: options.input, error: toError(e), output: undefined } - } - } - - if (state.status === 'error') { - throw state.error - } - - return state.output - } - - return await next() -} - -export function convertToArray(value: undefined | T | readonly T[]): readonly T[] { - if (value === undefined) { - return [] - } - - return Array.isArray(value) ? value : [value] as any -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c36037fbc..9b77cfc16 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,7 +2,6 @@ export * from './chain' export * from './constants' export * from './error' export * from './function' -export * from './hook' export * from './interceptor' export * from './json' export * from './object'