Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions benches/__shared__/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
21 changes: 21 additions & 0 deletions benches/openapi-link-handler.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand All @@ -15,6 +16,16 @@ const router = {
.input(type<any>())
.output(type<any>())
.handler(({ input }) => input),
getUser: os
.route({ method: 'GET', path: '/users/{id}' })
.input(type<any>())
.output(type<any>())
.handler(({ input }) => input),
updatePost: os
.route({ method: 'POST', path: '/posts/{id}' })
.input(type<any>())
.output(type<any>())
.handler(({ input }) => input),
}

const handler = new StandardHandler(new OpenAPIHandlerCodec(router, { serializer }), {})
Expand Down Expand Up @@ -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' })
})
})
})
61 changes: 61 additions & 0 deletions benches/procedure-call.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>())
}

return builder
.output(type<any>())
.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,
Expand All @@ -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)
})
})
34 changes: 33 additions & 1 deletion benches/rpc-link-handler.bench.ts
Original file line number Diff line number Diff line change
@@ -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<any>())
.output(type<any>())
.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 }), {})
Expand Down Expand Up @@ -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(() => {})
})
})
})
14 changes: 13 additions & 1 deletion benches/rpc-serializer.bench.ts
Original file line number Diff line number Diff line change
@@ -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 })

Expand Down Expand Up @@ -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),
)
})
})
139 changes: 139 additions & 0 deletions packages/server/src/procedure-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,3 +1026,142 @@ 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('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
.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',
])
})
})
Loading