Skip to content
Merged
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
108 changes: 108 additions & 0 deletions packages/contract/src/router-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AnyContractProcedure } from './procedure'
import { inputSchema, outputSchema, ping, pong, router } from '../tests/shared'
import { oc } from './builder'
import { isContractProcedure } from './procedure'
Expand Down Expand Up @@ -74,6 +75,113 @@ it('minifyContractRouter', () => {
expect((minified as any).nested.pong).toEqual(minifiedPong)
})

describe('contract modules that export primitives alongside procedures', () => {
// Simulates: import * as userContract from './contracts/user'
// where the module exports contract procedures AND constants like:
// export const getUser = oc.input(userSchema)
// export const listUsers = oc.input(listSchema)
// export const API_VERSION = 'v2'
// export const MAX_PAGE_SIZE = 100
// export const ENABLE_CACHE = true

const moduleWithPrimitives = {
getUser: ping,
listUsers: pong,
API_VERSION: 'v2',
MAX_PAGE_SIZE: 100,
ENABLE_CACHE: true,
DEPRECATED: null,
OPTIONAL_FEATURE: undefined,
} as any

describe('enhanceContractRouter', () => {
const options = { errorMap: {}, prefix: '/api', tags: ['api'] } as const

it('enhances procedures and passes through primitive exports', () => {
const enhanced = enhanceContractRouter(moduleWithPrimitives, options) as unknown as {
getUser: AnyContractProcedure
listUsers: AnyContractProcedure
API_VERSION: string
MAX_PAGE_SIZE: number
ENABLE_CACHE: boolean
}
expect(isContractProcedure(enhanced.getUser)).toBe(true)
expect(isContractProcedure(enhanced.listUsers)).toBe(true)
expect(enhanced.API_VERSION).toBe('v2')
expect(enhanced.MAX_PAGE_SIZE).toBe(100)
expect(enhanced.ENABLE_CACHE).toBe(true)
})

it('handles single-character string exports without stack overflow', () => {
// Single-char strings are the worst case: for...in on 'v' yields key '0',
// and 'v'[0] === 'v' creates an infinite loop
const moduleWithFlag = { getUser: ping, v: 'v' } as any
expect(() => enhanceContractRouter(moduleWithFlag, options)).not.toThrow()
})
})

describe('minifyContractRouter', () => {
it('minifies procedures and passes through primitive exports', () => {
const minified = minifyContractRouter(moduleWithPrimitives)
expect(isContractProcedure((minified as any).getUser)).toBe(true)
expect(isContractProcedure((minified as any).listUsers)).toBe(true)
expect((minified as any).API_VERSION).toBe('v2')
expect((minified as any).MAX_PAGE_SIZE).toBe(100)
})

it('handles single-character string exports without stack overflow', () => {
const moduleWithFlag = { getUser: ping, v: 'v' } as any
expect(() => minifyContractRouter(moduleWithFlag)).not.toThrow()
})
})

describe('populateContractRouterPaths', () => {
it('populates procedure paths and passes through primitive exports', () => {
const moduleForPaths = {
getUser: oc.input(inputSchema),
listUsers: oc.output(outputSchema),
API_VERSION: 'v2',
MAX_PAGE_SIZE: 100,
ENABLE_CACHE: true,
} as any
const populated = populateContractRouterPaths(moduleForPaths) as unknown as {
getUser: AnyContractProcedure
listUsers: AnyContractProcedure
API_VERSION: string
MAX_PAGE_SIZE: number
ENABLE_CACHE: boolean
}
expect(isContractProcedure(populated.getUser)).toBe(true)
expect(populated.getUser['~orpc'].route.path).toBe('/getUser')
expect(isContractProcedure(populated.listUsers)).toBe(true)
expect(populated.listUsers['~orpc'].route.path).toBe('/listUsers')
expect(populated.API_VERSION).toBe('v2')
expect(populated.MAX_PAGE_SIZE).toBe(100)
})

it('handles single-character string exports without stack overflow', () => {
const moduleWithFlag = { getUser: oc.input(inputSchema), v: 'v' } as any
expect(() => populateContractRouterPaths(moduleWithFlag)).not.toThrow()
})
})

describe('getContractRouter', () => {
it('returns undefined when path traverses past a primitive export', () => {
expect(getContractRouter(moduleWithPrimitives, ['API_VERSION', 'length'])).toBeUndefined()
expect(getContractRouter(moduleWithPrimitives, ['MAX_PAGE_SIZE', 'toFixed'])).toBeUndefined()
expect(getContractRouter(moduleWithPrimitives, ['ENABLE_CACHE', 'valueOf'])).toBeUndefined()
})

it('returns undefined for single-character string exports instead of indexed characters', () => {
// Without the typeof guard, getContractRouter(['v', '0']) returns 'v'
// because 'v'[0] === 'v', walking character indices instead of bailing out.
const moduleWithFlag = { getUser: ping, v: 'v' } as any
expect(getContractRouter(moduleWithFlag, ['v', '0'])).toBeUndefined()
expect(getContractRouter(moduleWithFlag, ['v', '0', '0', '0'])).toBeUndefined()
})
})
})

it('populateContractRouterPaths', () => {
const contract = {
ping: oc.input(inputSchema),
Expand Down
16 changes: 16 additions & 0 deletions packages/contract/src/router-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export function getContractRouter(router: AnyContractRouter, path: readonly stri
return undefined
}

if (typeof current !== 'object') {
return undefined
}

current = current[segment]
}

Expand Down Expand Up @@ -53,6 +57,10 @@ export function enhanceContractRouter<T extends AnyContractRouter, TErrorMap ext
return enhanced as any
}

if (typeof router !== 'object' || router === null) {
return router as any
}

const enhanced: Record<string, any> = {}

for (const key in router) {
Expand Down Expand Up @@ -83,6 +91,10 @@ export function minifyContractRouter(router: AnyContractRouter): AnyContractRout
return procedure
}

if (typeof router !== 'object' || router === null) {
return router as any
}

const json: Record<string, AnyContractRouter> = {}

for (const key in router) {
Expand Down Expand Up @@ -128,6 +140,10 @@ export function populateContractRouterPaths<T extends AnyContractRouter>(router:
return router as any
}

if (typeof router !== 'object' || router === null) {
return router as any
}

const populated: Record<string, any> = {}

for (const key in router) {
Expand Down
91 changes: 91 additions & 0 deletions packages/server/src/router-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AnyProcedure } from './procedure'
import { enhanceRoute } from '@orpc/contract'
import { contract, ping, pingMiddleware, pong, router } from '../tests/shared'
import { getLazyMeta, isLazy, unlazy } from './lazy'
Expand Down Expand Up @@ -220,3 +221,93 @@ it('unlazyRouter', async () => {
},
})
})

describe('router modules that export primitives alongside procedures', () => {
// Simulates: import * as userRouter from './routes/user'
// where the module exports procedures AND constants like:
// export const getUser = os.handler(...)
// export const listUsers = os.handler(...)
// export const API_VERSION = 'v2'
// export const MAX_PAGE_SIZE = 100
// export const ENABLE_CACHE = true

const moduleWithPrimitives = {
getUser: pong,
listUsers: pong,
API_VERSION: 'v2',
MAX_PAGE_SIZE: 100,
ENABLE_CACHE: true,
DEPRECATED: null,
OPTIONAL_FEATURE: undefined,
} as any

const defaultOptions = {
errorMap: {},
middlewares: [],
prefix: undefined,
tags: [],
dedupeLeadingMiddlewares: false,
} as const

describe('enhanceRouter', () => {
it('enhances procedures and passes through primitive exports', () => {
const enhanced = enhanceRouter(moduleWithPrimitives, defaultOptions) as unknown as {
getUser: AnyProcedure
listUsers: AnyProcedure
API_VERSION: string
MAX_PAGE_SIZE: number
ENABLE_CACHE: boolean
}
expect(enhanced.getUser['~orpc']).toBeDefined()
expect(enhanced.listUsers['~orpc']).toBeDefined()
expect(enhanced.API_VERSION).toBe('v2')
expect(enhanced.MAX_PAGE_SIZE).toBe(100)
expect(enhanced.ENABLE_CACHE).toBe(true)
})

it('handles single-character string exports without stack overflow', () => {
// Single-char strings are the worst case: for...in on 'v' yields key '0',
// and 'v'[0] === 'v' creates an infinite loop
const moduleWithFlag = { getUser: pong, v: 'v' } as any
expect(() => enhanceRouter(moduleWithFlag, defaultOptions)).not.toThrow()
})
})

describe('getRouter', () => {
it('returns undefined when path traverses past a primitive export', () => {
expect(getRouter(moduleWithPrimitives, ['API_VERSION', 'length'])).toBeUndefined()
expect(getRouter(moduleWithPrimitives, ['MAX_PAGE_SIZE', 'toFixed'])).toBeUndefined()
expect(getRouter(moduleWithPrimitives, ['ENABLE_CACHE', 'valueOf'])).toBeUndefined()
})

it('returns undefined for single-character string exports instead of indexed characters', () => {
// Without the typeof guard, getRouter(['v', '0']) returns 'v' because
// 'v'[0] === 'v', walking character indices instead of bailing out.
const moduleWithFlag = { getUser: pong, v: 'v' } as any
expect(getRouter(moduleWithFlag, ['v', '0'])).toBeUndefined()
expect(getRouter(moduleWithFlag, ['v', '0', '0', '0'])).toBeUndefined()
})
})

describe('traverseContractProcedures', () => {
it('traverses procedures and skips primitive, null, and undefined exports', () => {
const callback = vi.fn()
expect(() =>
traverseContractProcedures({ router: moduleWithPrimitives, path: [] }, callback),
).not.toThrow()
expect(callback).toHaveBeenCalledTimes(2)
expect(callback).toHaveBeenCalledWith({ contract: pong, path: ['getUser'] })
expect(callback).toHaveBeenCalledWith({ contract: pong, path: ['listUsers'] })
})
})

describe('unlazyRouter', () => {
it('resolves procedures and preserves primitive exports', async () => {
const result = await unlazyRouter(moduleWithPrimitives)
expect(result.getUser).toEqual(pong)
expect(result.listUsers).toEqual(pong)
expect(result.API_VERSION).toBe('v2')
expect(result.MAX_PAGE_SIZE).toBe(100)
})
})
})
21 changes: 20 additions & 1 deletion packages/server/src/router-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export function getRouter<T extends Lazyable<AnyRouter | undefined>>(
return undefined as any
}

if (typeof current !== 'object') {
return undefined as any
}

if (!isLazy(current)) {
current = current[segment]

Expand Down Expand Up @@ -150,6 +154,10 @@ export function enhanceRouter<
return enhanced as any
}

if (typeof router !== 'object' || router === null) {
return router as any
}
Comment thread
dinwwwh marked this conversation as resolved.

const enhanced = {} as Record<string, any>

for (const key in router) {
Expand Down Expand Up @@ -184,6 +192,13 @@ export function traverseContractProcedures(
callback: (options: TraverseContractProcedureCallbackOptions) => void,
lazyOptions: LazyTraverseContractProceduresOptions[] = [],
): LazyTraverseContractProceduresOptions[] {
// Guard before reading the hidden-contract symbol so that null/undefined
// child exports don't crash in `getHiddenRouterContract`. Primitives like
// strings autobox safely; only null/undefined throw on symbol access.
if (typeof options.router !== 'object' || options.router === null) {
return lazyOptions
}

let currentRouter: AnyContractRouter | Lazyable<AnyRouter> = options.router

const hiddenContract = getHiddenRouterContract(options.router)
Expand All @@ -206,7 +221,7 @@ export function traverseContractProcedures(
})
}

else {
else if (typeof currentRouter === 'object' && currentRouter !== null) {
for (const key in currentRouter) {
traverseContractProcedures(
{
Expand Down Expand Up @@ -254,6 +269,10 @@ export async function unlazyRouter<T extends AnyRouter>(router: T): Promise<Unla
return router as any
}

if (typeof router !== 'object' || router === null) {
return router as any
}

const unlazied = {} as Record<string, any>

for (const key in router) {
Expand Down
Loading