From a25881a3d1ce36acd2124048d115e2a8ede60382 Mon Sep 17 00:00:00 2001 From: spa5k <79936503+spa5k@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:30:41 +0530 Subject: [PATCH 1/4] perf(server): reduce middleware pipeline overhead - precompute per-procedure execution plans (schema slices, flags) in a WeakMap - skip async runWithSpan wrappers and eager span names when tracing is disabled - skip context spreads when middleware passes an empty context (string- and symbol-keyed aware) - skip unlazy promise allocation for non-lazy procedures - cache per-procedure error constructor map and reconcileError closure - skip the prototype-chain-walking instanceof ORPCError check for primitive outputs --- packages/server/src/procedure-client.ts | 359 +++++++++++++++++------- 1 file changed, 250 insertions(+), 109 deletions(-) diff --git a/packages/server/src/procedure-client.ts b/packages/server/src/procedure-client.ts index 251533189..02863ab75 100644 --- a/packages/server/src/procedure-client.ts +++ b/packages/server/src/procedure-client.ts @@ -4,11 +4,11 @@ import type { Interceptor, MaybeOptionalOptions, Promisable, PromiseWithError, T import type { Context } from './context' import type { Lazyable } from './lazy' import type { MiddlewareDone } from './middleware' -import type { AnyProcedure, Procedure, ProcedureHandlerOptions } from './procedure' +import type { AnyProcedure, OrderedMiddleware, Procedure, ProcedureHandlerOptions } from './procedure' import { cloneORPCError, ORPCError, wrapAsyncIteratorPreservingEventMeta } from '@orpc/client' import { createORPCErrorConstructorMap, reconcileORPCError, ValidationError } from '@orpc/contract' -import { intercept, isAsyncIteratorObject, isPlainObject, mergeTwoLevels, override, resolveMaybeOptionalOptions, runWithSpan, toArray, traceAsyncIterator, traceReadableStream, value } from '@orpc/shared' -import { unlazy } from './lazy' +import { getOpenTelemetryConfig, intercept, isAsyncIteratorObject, isPlainObject, mergeTwoLevels, override, resolveMaybeOptionalOptions, runWithSpan, toArray, traceAsyncIterator, traceReadableStream, value } from '@orpc/shared' +import { Lazy, unlazy } from './lazy' export type ProcedureClient< TClientContext extends ClientContext, @@ -42,10 +42,43 @@ export type ProcedureClientOptions< interceptors?: ProcedureClientInterceptor[] } & ( - object extends TInitialContext - ? { context?: Value, [clientContext: TClientContext]> } - : { context: Value, [clientContext: TClientContext]> } - ) + object extends TInitialContext + ? { context?: Value, [clientContext: TClientContext]> } + : { context: Value, [clientContext: TClientContext]> } + ) + +interface ProcedureCallArtifacts { + readonly errors: ORPCErrorConstructorMap + readonly reconcileError: (e: ThrowableError) => Promise +} + +/** + * Per-procedure artifacts that never change between calls, so each request + * stops re-allocating them (the error constructor map allocates a `Proxy`). + */ +const procedureCallArtifacts = new WeakMap() + +function getProcedureCallArtifacts(procedure: AnyProcedure): ProcedureCallArtifacts { + const cached = procedureCallArtifacts.get(procedure) + + if (cached) { + return cached + } + + const artifacts: ProcedureCallArtifacts = { + errors: createORPCErrorConstructorMap(procedure['~orpc'].errorMap), + reconcileError: async (e: ThrowableError) => { + if (e instanceof ORPCError) { + return await reconcileORPCError(procedure['~orpc'].errorMap, e) + } + + return e + }, + } + + procedureCallArtifacts.set(procedure, artifacts) + return artifacts +} export function createProcedureClient< TInitialContext extends Context, @@ -67,24 +100,19 @@ export function createProcedureClient< > ): ProcedureClient { const options = resolveMaybeOptionalOptions(rest) + const path = toArray(options.path) return async (...[input, callerOptions]) => { - const path = toArray(options.path) - const { default: procedure } = await unlazy(lazyableProcedure) + // `unlazy` allocates and awaits a resolved promise for the common non-lazy case. + const procedure = lazyableProcedure instanceof Lazy + ? (await unlazy(lazyableProcedure)).default + : lazyableProcedure // callerOptions.context can be undefined when all field is optional const clientContext = callerOptions?.context ?? {} as TClientContext // options.context can be undefined when all field is optional const context = await value(options.context, clientContext) as TInitialContext | undefined ?? {} as TInitialContext - const errors = createORPCErrorConstructorMap(procedure['~orpc'].errorMap) - - const reconcileError = async (e: ThrowableError) => { - if (e instanceof ORPCError) { - return await reconcileORPCError(procedure['~orpc'].errorMap, e) - } - - return e - } + const { errors, reconcileError } = getProcedureCallArtifacts(procedure) try { const output = await runWithSpan('call_procedure', (span) => { @@ -145,48 +173,62 @@ export function createProcedureClient< } } -async function validateInput(i: number, schema: AnySchema, input: unknown): Promise { - return runWithSpan(`validate_input.${i}`, async (span) => { - span?.setAttribute('input_schema.index', i) +type SchemaValidateResult = Awaited> - const result = await schema['~standard'].validate(input) - - if (result.issues) { - throw new ORPCError('BAD_REQUEST', { +function unwrapInput(result: SchemaValidateResult, input: unknown): any { + if (result.issues) { + throw new ORPCError('BAD_REQUEST', { + message: 'Input validation failed', + data: { + issues: result.issues, + }, + cause: new ValidationError({ message: 'Input validation failed', - data: { - issues: result.issues, - }, - cause: new ValidationError({ - message: 'Input validation failed', - issues: result.issues, - invalidData: input, - }), - }) - } + issues: result.issues, + invalidData: input, + }), + }) + } + + return result.value +} + +function unwrapOutput(result: SchemaValidateResult, output: unknown): any { + if (result.issues) { + throw new ORPCError('INTERNAL_SERVER_ERROR', { + message: 'Output validation failed', + cause: new ValidationError({ + message: 'Output validation failed', + issues: result.issues, + invalidData: output, + }), + }) + } + + return result.value +} + +async function validateInput(traced: boolean, i: number, schema: AnySchema, input: unknown): Promise { + if (!traced) { + return unwrapInput(await schema['~standard'].validate(input), input) + } + + return runWithSpan(`validate_input.${i}`, async (span) => { + span?.setAttribute('input_schema.index', i) - return result.value + return unwrapInput(await schema['~standard'].validate(input), input) }) } -async function validateOutput(i: number, schema: AnySchema, output: unknown): Promise { +async function validateOutput(traced: boolean, i: number, schema: AnySchema, output: unknown): Promise { + if (!traced) { + return unwrapOutput(await schema['~standard'].validate(output), output) + } + return runWithSpan(`validate_output.${i}`, async (span) => { span?.setAttribute('output_schema.index', i) - 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, - invalidData: output, - }), - }) - } - - return result.value + return unwrapOutput(await schema['~standard'].validate(output), output) }) } @@ -200,10 +242,106 @@ const middlewareDone: MiddlewareDone = (...rest) => { } } +interface ProcedureExecutionPlan { + readonly orderedMiddlewares: OrderedMiddleware[] + readonly inputSchemas: AnySchema[] + readonly outputSchemas: AnySchema[] + /** + * Per-level input/output schema slice boundaries (`length` = middleware count + 1, + * the last entry being the handler level), precomputed from the snapshots + * taken when each middleware was used. + */ + readonly inputStarts: number[] + readonly inputEnds: number[] + readonly outputStarts: number[] + readonly outputEnds: number[] + readonly validateInputs: boolean + readonly validateOutputs: boolean + readonly stackedObjectInputs: boolean +} + +const executionPlans = new WeakMap() + +function getExecutionPlan(procedure: AnyProcedure): ProcedureExecutionPlan { + const cached = executionPlans.get(procedure) + + if (cached) { + return cached + } + + const def = procedure['~orpc'] + const inputSchemas = toArray(def.inputSchemas) + const outputSchemas = toArray(def.outputSchemas) + const orderedMiddlewares = def.orderedMiddlewares + + const levels = orderedMiddlewares.length + 1 + const inputStarts: number[] = [] + const inputEnds: number[] = [] + const outputStarts: number[] = [] + const outputEnds: number[] = [] + + let hasInputs = false + let hasOutputs = false + + for (let level = 0; level < levels; level++) { + const isHandler = level === orderedMiddlewares.length + const prev = level === 0 ? undefined : orderedMiddlewares[level - 1]! + const curr = isHandler ? undefined : orderedMiddlewares[level]! + + const inputStart = prev?.inputSchemasLengthAtUse ?? 0 + const inputEnd = isHandler ? inputSchemas.length : curr!.inputSchemasLengthAtUse ?? 0 + const outputStart = prev?.outputSchemasLengthAtUse ?? 0 + const outputEnd = isHandler ? outputSchemas.length : curr!.outputSchemasLengthAtUse ?? 0 + + inputStarts.push(inputStart) + inputEnds.push(inputEnd) + outputStarts.push(outputStart) + outputEnds.push(outputEnd) + hasInputs ||= inputEnd > inputStart + hasOutputs ||= outputEnd > outputStart + } + + const plan: ProcedureExecutionPlan = { + orderedMiddlewares, + inputSchemas, + outputSchemas, + inputStarts, + inputEnds, + outputStarts, + outputEnds, + validateInputs: hasInputs && !def.disableInputValidation, + validateOutputs: hasOutputs && !def.disableOutputValidation, + stackedObjectInputs: inputSchemas.length > 1, + } + + executionPlans.set(procedure, plan) + return plan +} + +/** + * `for...in` with an early exit is the cheapest "has own enumerable keys" check; + * skipping the spread entirely beats copying an empty override into the context. + * The `hasOwnProperty` guard mirrors what `{ ...context, ...next }` would copy. + */ +function hasEnumerableProperties(context: Context | undefined): boolean { + if (context === undefined) { + return false + } + + for (const key in context) { + if (Object.hasOwn(context, key)) { + return true + } + } + + // `for...in` misses symbol keys, which object spread does copy + // (e.g. rate limit bookkeeping passed through middleware context). + return Object.getOwnPropertySymbols(context).length > 0 +} + async function executeProcedureInternal(procedure: AnyProcedure, options: ProcedureHandlerOptions): Promise { - const inputSchemas = toArray(procedure['~orpc'].inputSchemas) - const outputSchemas = toArray(procedure['~orpc'].outputSchemas) - const orderedMiddlewares = procedure['~orpc'].orderedMiddlewares + const plan = getExecutionPlan(procedure) + const traced = getOpenTelemetryConfig()?.tracer !== undefined const next = async ( midIndex: number, @@ -212,24 +350,16 @@ async function executeProcedureInternal(procedure: AnyProcedure, options: Proced ): Promise<{ output: unknown, context: Record }> => { let currentInput = input - const startInputIndex = midIndex === 0 - ? 0 - : orderedMiddlewares[midIndex - 1]!.inputSchemasLengthAtUse ?? 0 - const endInputIndex = midIndex === orderedMiddlewares.length - ? inputSchemas.length - : orderedMiddlewares[midIndex]!.inputSchemasLengthAtUse ?? 0 - - /** - * Stacked object schemas each validate the original input and are merged afterwards, so every - * schema can declare its own fragment without the previous ones stripping or rejecting it. - * Anything else stays piped, which keeps schemas like `asyncIteratorObject` wrapping each other. - */ - if (!procedure['~orpc'].disableInputValidation) { - for (let i = startInputIndex; i < endInputIndex; i++) { + if (plan.validateInputs) { + const inputSchemas = plan.inputSchemas + const stackedObjectInputs = plan.stackedObjectInputs + + for (let i = plan.inputStarts[midIndex]!; i < plan.inputEnds[midIndex]!; i++) { const validated = await validateInput( + traced, i, inputSchemas[i]!, - inputSchemas.length > 1 && isPlainObject(currentInput) ? options.input : currentInput, + stackedObjectInputs && isPlainObject(currentInput) ? options.input : currentInput, ) currentInput = i !== 0 ? mergeTwoLevels(currentInput, validated) : validated @@ -239,44 +369,60 @@ async function executeProcedureInternal(procedure: AnyProcedure, options: Proced let currentOutput: unknown let currentContext = context - if (midIndex < orderedMiddlewares.length) { - const { middleware } = orderedMiddlewares[midIndex]! + if (midIndex < plan.orderedMiddlewares.length) { + const { middleware } = plan.orderedMiddlewares[midIndex]! + + const invoke = () => middleware( + { + ...options, + context, + next: (...rest) => { + const nextContext = rest.length === 0 ? undefined : rest[0]?.context + + return next( + midIndex + 1, + nextContext !== undefined && hasEnumerableProperties(nextContext) + ? { ...context, ...nextContext } + : context, + currentInput, + ) + }, + lastEventId: options.lastEventId, + }, + currentInput, + middlewareDone, + ) - const result = await runWithSpan(`middleware.${middleware.name}`, async (span) => { - span?.setAttribute('middleware.index', midIndex) + const result = traced + ? await runWithSpan(`middleware.${middleware.name}`, async (span) => { + span?.setAttribute('middleware.index', midIndex) - return await middleware( - { - ...options, - context, - next: (...rest) => { - const nextOptions = resolveMaybeOptionalOptions(rest) - // context can be undefined when all field is optional - const nextContext = nextOptions.context ?? {} as any - - return next( - midIndex + 1, - { ...context, ...nextContext }, - currentInput, - ) - }, - lastEventId: options.lastEventId, - }, - currentInput, - middlewareDone, - ) - }) + return await invoke() + }) + : await invoke() currentOutput = result.output - currentContext = { ...context, ...result.context } + + const resultContext = result.context + currentContext = resultContext !== undefined && hasEnumerableProperties(resultContext) + ? { ...context, ...resultContext } + : context } else { - currentOutput = await runWithSpan( - 'handler', - () => procedure['~orpc'].handler({ ...options, context, input: currentInput }, currentInput), - ) + const handler = procedure['~orpc'].handler + + currentOutput = traced + ? await runWithSpan( + 'handler', + () => handler({ ...options, context, input: currentInput }, currentInput), + ) + : await handler({ ...options, context, input: currentInput }, currentInput) - if (currentOutput instanceof ORPCError) { + /** + * `ORPCError` is always an object, so primitives skip the + * prototype-chain-walking `Symbol.hasInstance` on the happy path. + */ + if (typeof currentOutput === 'object' && currentOutput !== null && currentOutput instanceof ORPCError) { if (procedure['~orpc'].opaqueReturnedErrors) { throw currentOutput } @@ -294,16 +440,11 @@ async function executeProcedureInternal(procedure: AnyProcedure, options: Proced } } - const startOutputIndex = midIndex === 0 - ? 0 - : orderedMiddlewares[midIndex - 1]!.outputSchemasLengthAtUse ?? 0 - const endOutputIndex = midIndex === orderedMiddlewares.length - ? outputSchemas.length - : orderedMiddlewares[midIndex]!.outputSchemasLengthAtUse ?? 0 + if (plan.validateOutputs) { + const outputSchemas = plan.outputSchemas - if (!procedure['~orpc'].disableOutputValidation) { - for (let i = endOutputIndex - 1; i >= startOutputIndex; i--) { - currentOutput = await validateOutput(i, outputSchemas[i]!, currentOutput) + for (let i = plan.outputEnds[midIndex]! - 1; i >= plan.outputStarts[midIndex]!; i--) { + currentOutput = await validateOutput(traced, i, outputSchemas[i]!, currentOutput) } } From bc54173a4422ca8a1c8a7cf4548562e5f2f35ee9 Mon Sep 17 00:00:00 2001 From: spa5k <79936503+spa5k@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:30:42 +0530 Subject: [PATCH 2/4] test(server): cover untraced fast path, context edge cases, and span names --- packages/server/src/procedure-client.test.ts | 130 +++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/packages/server/src/procedure-client.test.ts b/packages/server/src/procedure-client.test.ts index 325ea1187..68a3cef10 100644 --- a/packages/server/src/procedure-client.test.ts +++ b/packages/server/src/procedure-client.test.ts @@ -1026,3 +1026,133 @@ describe('createProcedureClient', () => { }) }) }) + +describe('untraced fast path and context edge cases', () => { + const SYMBOL_KEY = Symbol('untraced-test') + let previousOtelConfig: unknown + + beforeEach(() => { + // The test setup registers a no-op tracer; disable it to exercise the + // untraced branches, restoring the config afterwards. + previousOtelConfig = SharedV2Module.getOpenTelemetryConfig() + SharedV2Module.setOpenTelemetryConfig(undefined) + }) + + afterEach(() => { + SharedV2Module.setOpenTelemetryConfig(previousOtelConfig as any) + }) + + it('propagates parent context when middleware passes an empty context object', async () => { + const handler = vi.fn(async ({ context }: any) => context.userId) + const procedure = os + .use(async ({ next }) => next({ context: { userId: 'user-1' } })) + .use(async ({ next }) => next({ context: {} })) + .handler(handler) + + await expect(createProcedureClient(procedure)()).resolves.toBe('user-1') + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ context: { userId: 'user-1' } }), undefined) + }) + + it('treats next(undefined) the same as next()', async () => { + const handler = vi.fn(async ({ context }: any) => context.userId) + const procedure = os + .use(async ({ next }) => next({ context: { userId: 'user-1' } })) + .use(async ({ next }) => next(undefined as any)) + .handler(handler) + + await expect(createProcedureClient(procedure)()).resolves.toBe('user-1') + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ context: { userId: 'user-1' } }), undefined) + }) + + it('propagates symbol-keyed context overrides to the handler', async () => { + const handler = vi.fn(async ({ context }: any) => context[SYMBOL_KEY]) + const procedure = os + .use(async ({ next }) => next({ context: { [SYMBOL_KEY]: 'symbol-value', visible: 'yes' } })) + .handler(handler) + + await expect(createProcedureClient(procedure)()).resolves.toBe('symbol-value') + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ context: expect.objectContaining({ visible: 'yes' }) }), + undefined, + ) + }) + + it('keeps the outer context when a middleware result omits context', async () => { + const first = vi.fn(async ({ next }: any) => next({ context: { userId: 'user-1' } })) + const second = vi.fn(async ({ next }: any) => { + const result = await next() + return { output: result.output, context: undefined } as any + }) + const procedure = os + .use(first) + .use(second) + .handler(async () => 'ok') + + await expect(createProcedureClient(procedure)()).resolves.toBe('ok') + expect(second).toHaveBeenCalledTimes(1) + expect(first).toHaveResolvedWith({ + output: 'ok', + context: expect.objectContaining({ userId: 'user-1' }), + }) + }) + + it.each([ + ['string', 'plain-string'], + ['number', 42], + ['boolean', true], + ['null', null], + ['undefined', undefined], + ])('returns a primitive %s output untouched', async (_kind, output) => { + const procedure = os.handler(async () => output) + await expect(createProcedureClient(procedure)()).resolves.toBe(output) + }) +}) + +describe('traced path span names', () => { + it('emits the expected span names through a full procedure call', async () => { + const spans: string[] = [] + const span = { + setAttribute: vi.fn(), + recordException: vi.fn(), + setStatus: vi.fn(), + addEvent: vi.fn(), + end: vi.fn(), + } + const tracer = { + startActiveSpan(name: string, _options: unknown, argA?: unknown, argB?: unknown) { + spans.push(name) + const callback = typeof argA === 'function' ? argA : argB + return (callback as (span: unknown) => unknown)(span) + }, + } + + const previousOtelConfig = SharedV2Module.getOpenTelemetryConfig() + SharedV2Module.setOpenTelemetryConfig({ + tracer, + trace: { getActiveSpan: () => undefined, setSpan: (context: unknown, _span: unknown) => context }, + context: { active: () => ({}) }, + } as any) + + try { + const namedMiddleware = async ({ next }: any) => next() + const procedure = os + .use(namedMiddleware) + .input(z.any()) + .output(z.any()) + .handler(async () => 'ok') + + await expect(createProcedureClient(procedure)()).resolves.toBe('ok') + } + finally { + SharedV2Module.setOpenTelemetryConfig(previousOtelConfig as any) + } + + expect(spans).toEqual([ + 'call_procedure', + 'middleware.namedMiddleware', + 'validate_input.0', + 'handler', + 'validate_output.0', + ]) + }) +}) From 466cff48af3126a7f3fabd0eb94b2aac3778fe66 Mon Sep 17 00:00:00 2001 From: spa5k <79936503+spa5k@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:30:42 +0530 Subject: [PATCH 3/4] bench: extend existing benchmarks instead of adding new files - procedure-call: middleware scaling (10/100 passthrough, 10/50 context-adding, 10/50 with stacked input schemas) - rpc-link-handler: tiny-payload fixed overhead scenarios (plain, middlewares x3, error thrown, 404) - openapi-link-handler: tiny dynamic-path GET/POST scenarios - rpc-serializer: pure-JSON payload scenarios --- benches/__shared__/payloads.ts | 19 +++++++++ benches/openapi-link-handler.bench.ts | 21 +++++++++ benches/procedure-call.bench.ts | 61 +++++++++++++++++++++++++++ benches/rpc-link-handler.bench.ts | 34 ++++++++++++++- benches/rpc-serializer.bench.ts | 14 +++++- 5 files changed, 147 insertions(+), 2 deletions(-) diff --git a/benches/__shared__/payloads.ts b/benches/__shared__/payloads.ts index d58b08d2a..d90fb940d 100644 --- a/benches/__shared__/payloads.ts +++ b/benches/__shared__/payloads.ts @@ -40,6 +40,25 @@ const SIZE_10KB = 10 * SIZE_1KB const SIZE_100KB = 100 * SIZE_1KB const SIZE_5MB = 5 * 1024 * 1024 +/** Plain JSON only: strings, numbers, booleans, arrays, plain objects. */ +function createJsonUnit(i: number) { + return { + id: i, + name: `item-${i}`, + active: true, + score: 0.5 + (i % 100) / 100, + tags: ['a', 'b', 'c'], + metadata: { + version: '2.0.0', + count: i, + nested: { depth: 2, ok: true }, + }, + } +} + +export const PURE_JSON_1KB = createJsonUnit(0) +export const PURE_JSON_100KB = Array.from({ length: 100 }, (_, i) => createJsonUnit(i)) + export const PAYLOAD_1KB = createUnit(0) export const PAYLOAD_10KB = Array.from({ length: 10 }, (_, i) => createUnit(i)) export const PAYLOAD_100KB = Array.from({ length: 100 }, (_, i) => createUnit(i)) diff --git a/benches/openapi-link-handler.bench.ts b/benches/openapi-link-handler.bench.ts index 23ce1e63f..faa45fc95 100644 --- a/benches/openapi-link-handler.bench.ts +++ b/benches/openapi-link-handler.bench.ts @@ -7,6 +7,7 @@ import { os, type } from '@orpc/server' import { StandardHandler } from '@orpc/server/standard' import { bench } from 'vitest' import { asReadableStream, asSyncIteratorObject, BYTES_10KB, drainBody, EVENTS_10KB, handlers, PAYLOAD_10KB } from './__shared__/payloads' +import '@orpc/openapi/extensions/route' const serializer = new OpenAPISerializer({ handlers }) @@ -15,6 +16,16 @@ const router = { .input(type()) .output(type()) .handler(({ input }) => input), + getUser: os + .route({ method: 'GET', path: '/users/{id}' }) + .input(type()) + .output(type()) + .handler(({ input }) => input), + updatePost: os + .route({ method: 'POST', path: '/posts/{id}' }) + .input(type()) + .output(type()) + .handler(({ input }) => input), } const handler = new StandardHandler(new OpenAPIHandlerCodec(router, { serializer }), {}) @@ -52,4 +63,14 @@ describe('openapi link + handler', () => { await client.ping(asReadableStream(BYTES_10KB)), ) }) + + describe('dynamic paths (tiny payload)', () => { + bench('get dynamic path param', async () => { + await client.getUser({ id: 1 }) + }) + + bench('post dynamic path param + body', async () => { + await client.updatePost({ id: 1, title: 'Hello', content: 'World' }) + }) + }) }) diff --git a/benches/procedure-call.bench.ts b/benches/procedure-call.bench.ts index 69f166c7a..2c14edd23 100644 --- a/benches/procedure-call.bench.ts +++ b/benches/procedure-call.bench.ts @@ -41,6 +41,43 @@ const fullClient = createProcedureClient(full, { interceptors: [({ next }) => next()], }) +function buildMiddlewareProcedure(middlewareCount: number, addContext: boolean) { + let builder = os as any + + for (let i = 0; i < middlewareCount; i++) { + builder = builder.use(addContext + ? os.middleware(async ({ next }) => next({ context: { [`key${i}`]: i } })) + : os.middleware(async ({ next }) => next())) + } + + return builder.handler(({ input }: any) => input) +} + +/** + * Middleware interleaved with input schemas: every level re-slices the schema + * stack (`inputSchemasLengthAtUse`) and runs stacked-object merging. + */ +function buildStackedSchemaProcedure(middlewareCount: number) { + let builder = os as any + + for (let i = 0; i < middlewareCount; i++) { + builder = builder + .use(os.middleware(async ({ next }) => next())) + .input(type()) + } + + return builder + .output(type()) + .handler(({ input }: any) => input) +} + +const passthrough10Client = createProcedureClient(buildMiddlewareProcedure(10, false)) +const passthrough100Client = createProcedureClient(buildMiddlewareProcedure(100, false)) +const context10Client = createProcedureClient(buildMiddlewareProcedure(10, true)) +const context50Client = createProcedureClient(buildMiddlewareProcedure(50, true)) +const stacked10Client = createProcedureClient(buildStackedSchemaProcedure(10)) +const stacked50Client = createProcedureClient(buildStackedSchemaProcedure(50)) + describe('procedure call', () => { const input = { id: 1, @@ -65,4 +102,28 @@ describe('procedure call', () => { bench('full (middlewares + validated + interceptors)', async () => { await fullClient(input) }) + + bench('10 middlewares (passthrough)', async () => { + await passthrough10Client(input as any) + }) + + bench('100 middlewares (passthrough)', async () => { + await passthrough100Client(input as any) + }) + + bench('10 middlewares (context-adding)', async () => { + await context10Client(input as any) + }) + + bench('50 middlewares (context-adding)', async () => { + await context50Client(input as any) + }) + + bench('10 middlewares + 11 stacked input schemas', async () => { + await stacked10Client(input as any) + }) + + bench('50 middlewares + 51 stacked input schemas', async () => { + await stacked50Client(input as any) + }) }) diff --git a/benches/rpc-link-handler.bench.ts b/benches/rpc-link-handler.bench.ts index cdd36b9cd..329410298 100644 --- a/benches/rpc-link-handler.bench.ts +++ b/benches/rpc-link-handler.bench.ts @@ -1,18 +1,30 @@ import type { RouterClient } from '@orpc/server' import { createORPCClient, RPCSerializer } from '@orpc/client' import { RPCLinkCodec, StandardLink } from '@orpc/client/standard' -import { os, type } from '@orpc/server' +import { ORPCError, os, type } from '@orpc/server' import { RPCHandlerCodec, StandardHandler } from '@orpc/server/standard' import { bench } from 'vitest' import { asReadableStream, asSyncIteratorObject, BYTES_10KB, drainBody, EVENTS_10KB, handlers, PAYLOAD_10KB } from './__shared__/payloads' const serializer = new RPCSerializer({ handlers }) +const log = os.middleware(async ({ next }) => next()) +const auth = os.middleware(async ({ next }) => next({ context: { userId: 'user-1' } })) + const router = { ping: os .input(type()) .output(type()) .handler(({ input }) => input), + plain: os.handler(({ input }) => input), + middlewares: os + .use(log) + .use(auth) + .use(log) + .handler(({ input }) => input), + fail: os.handler(() => { + throw new ORPCError('NOT_FOUND') + }), } const handler = new StandardHandler(new RPCHandlerCodec(router, { serializer }), {}) @@ -50,4 +62,24 @@ describe('rpc link + handler', () => { await client.ping(asReadableStream(BYTES_10KB)), ) }) + + describe('fixed overhead (tiny payload)', () => { + const input = { id: 1 } + + bench('plain (no schema, no middleware)', async () => { + await client.plain(input as any) + }) + + bench('middlewares x3', async () => { + await client.middlewares(input as any) + }) + + bench('error thrown', async () => { + await client.fail(undefined as any).catch(() => {}) + }) + + bench('not found (404)', async () => { + await (client as any).missing(input).catch(() => {}) + }) + }) }) diff --git a/benches/rpc-serializer.bench.ts b/benches/rpc-serializer.bench.ts index 0762c28eb..81c9e128d 100644 --- a/benches/rpc-serializer.bench.ts +++ b/benches/rpc-serializer.bench.ts @@ -1,6 +1,6 @@ import { RPCSerializer } from '@orpc/client' import { bench } from 'vitest' -import { handlers, PAYLOAD_1KB, PAYLOAD_5MB, PAYLOAD_5MB_WITH_FILES, PAYLOAD_100KB } from './__shared__/payloads' +import { handlers, PAYLOAD_1KB, PAYLOAD_5MB, PAYLOAD_5MB_WITH_FILES, PAYLOAD_100KB, PURE_JSON_1KB, PURE_JSON_100KB } from './__shared__/payloads' const serializer = new RPCSerializer({ handlers }) @@ -28,4 +28,16 @@ describe('rpc serializer', () => { serializer.serialize(PAYLOAD_5MB_WITH_FILES), ) }) + + bench('1KB payload (pure JSON)', () => { + serializer.deserialize( + serializer.serialize(PURE_JSON_1KB), + ) + }) + + bench('100KB payload (pure JSON)', () => { + serializer.deserialize( + serializer.serialize(PURE_JSON_100KB), + ) + }) }) From 5eead6e007872ff830108e89df368ff447a2ad79 Mon Sep 17 00:00:00 2001 From: spa5k <79936503+spa5k@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:06:09 +0530 Subject: [PATCH 4/4] test(server): cover untraced validation fast path --- packages/server/src/procedure-client.test.ts | 9 +++++++++ packages/server/src/procedure-client.ts | 6 +----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/server/src/procedure-client.test.ts b/packages/server/src/procedure-client.test.ts index 68a3cef10..59804bb66 100644 --- a/packages/server/src/procedure-client.test.ts +++ b/packages/server/src/procedure-client.test.ts @@ -1042,6 +1042,15 @@ describe('untraced fast path and context edge cases', () => { SharedV2Module.setOpenTelemetryConfig(previousOtelConfig as any) }) + it('validates input and output without tracing', async () => { + const procedure = os + .input(z.string().transform(value => `input:${value}`)) + .output(z.string().transform(value => `output:${value}`)) + .handler(async ({ input }) => input) + + await expect(createProcedureClient(procedure)('value')).resolves.toBe('output:input:value') + }) + it('propagates parent context when middleware passes an empty context object', async () => { const handler = vi.fn(async ({ context }: any) => context.userId) const procedure = os diff --git a/packages/server/src/procedure-client.ts b/packages/server/src/procedure-client.ts index 02863ab75..7278b166f 100644 --- a/packages/server/src/procedure-client.ts +++ b/packages/server/src/procedure-client.ts @@ -323,11 +323,7 @@ function getExecutionPlan(procedure: AnyProcedure): ProcedureExecutionPlan { * skipping the spread entirely beats copying an empty override into the context. * The `hasOwnProperty` guard mirrors what `{ ...context, ...next }` would copy. */ -function hasEnumerableProperties(context: Context | undefined): boolean { - if (context === undefined) { - return false - } - +function hasEnumerableProperties(context: Context): boolean { for (const key in context) { if (Object.hasOwn(context, key)) { return true