diff --git a/packages/contract/src/router-utils.test.ts b/packages/contract/src/router-utils.test.ts index 13487c374..901259d26 100644 --- a/packages/contract/src/router-utils.test.ts +++ b/packages/contract/src/router-utils.test.ts @@ -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' @@ -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), diff --git a/packages/contract/src/router-utils.ts b/packages/contract/src/router-utils.ts index 428f1c91e..a1b1edc17 100644 --- a/packages/contract/src/router-utils.ts +++ b/packages/contract/src/router-utils.ts @@ -22,6 +22,10 @@ export function getContractRouter(router: AnyContractRouter, path: readonly stri return undefined } + if (typeof current !== 'object') { + return undefined + } + current = current[segment] } @@ -53,6 +57,10 @@ export function enhanceContractRouter = {} for (const key in router) { @@ -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 = {} for (const key in router) { @@ -128,6 +140,10 @@ export function populateContractRouterPaths(router: return router as any } + if (typeof router !== 'object' || router === null) { + return router as any + } + const populated: Record = {} for (const key in router) { diff --git a/packages/server/src/router-utils.test.ts b/packages/server/src/router-utils.test.ts index d9441f0fd..591ea6a65 100644 --- a/packages/server/src/router-utils.test.ts +++ b/packages/server/src/router-utils.test.ts @@ -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' @@ -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) + }) + }) +}) diff --git a/packages/server/src/router-utils.ts b/packages/server/src/router-utils.ts index 60943120f..a2b02a547 100644 --- a/packages/server/src/router-utils.ts +++ b/packages/server/src/router-utils.ts @@ -27,6 +27,10 @@ export function getRouter>( return undefined as any } + if (typeof current !== 'object') { + return undefined as any + } + if (!isLazy(current)) { current = current[segment] @@ -150,6 +154,10 @@ export function enhanceRouter< return enhanced as any } + if (typeof router !== 'object' || router === null) { + return router as any + } + const enhanced = {} as Record for (const key in router) { @@ -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 = options.router const hiddenContract = getHiddenRouterContract(options.router) @@ -206,7 +221,7 @@ export function traverseContractProcedures( }) } - else { + else if (typeof currentRouter === 'object' && currentRouter !== null) { for (const key in currentRouter) { traverseContractProcedures( { @@ -254,6 +269,10 @@ export async function unlazyRouter(router: T): Promise for (const key in router) {