Skip to content

fix(server,contract): guard against primitive values in router tree traversal#1522

Open
jameskranz wants to merge 3 commits intomiddleapi:mainfrom
jameskranz:fix/enhance-router-primitive-recursion-v2
Open

fix(server,contract): guard against primitive values in router tree traversal#1522
jameskranz wants to merge 3 commits intomiddleapi:mainfrom
jameskranz:fix/enhance-router-primitive-recursion-v2

Conversation

@jameskranz
Copy link
Copy Markdown

@jameskranz jameskranz commented Apr 3, 2026

Summary

  • Router utility functions that use for (const key in router) loops without validating that values are objects crash with RangeError: Maximum call stack size exceeded when a router module exports primitives (e.g., export const FOO = "bar").
  • Server package: Added type guards to enhanceRouter, traverseContractProcedures, and unlazyRouter in packages/server/src/router-utils.ts.
  • Contract package: Applied the same fix to enhanceContractRouter, minifyContractRouter, and populateContractRouterPaths in packages/contract/src/router-utils.ts.
  • Added tests covering strings, single-character strings, numbers, booleans, null, and undefined for all affected functions in both packages.

Test plan

  • All new tests pass (server + contract packages)
  • All existing tests continue to pass
  • Verified tests fail without the fix (infinite recursion crashes the test worker)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Router utilities now safely handle primitive values (strings, numbers, booleans, null/undefined) and non-object inputs without errors or infinite recursion.
  • Tests

    • Added tests validating utilities correctly process mixed router objects (procedures + primitives) and primitive-only inputs, including edge cases with single-character strings.

jameskranz and others added 2 commits April 3, 2026 11:18
`enhanceRouter`, `traverseContractProcedures`, and `unlazyRouter` all
use `for (const key in router)` without verifying the value is an
object. When a router module re-exports a primitive (e.g.
`export const FOO = 'bar'`), the `for...in` loop iterates string
character indices, eventually hitting a single-character string that
recurses on itself infinitely — crashing with RangeError: Maximum call
stack size exceeded.

Add type guards before the `for...in` fallback in all three functions
so that non-object values are returned/skipped instead of iterated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use .toEqual() with expected structure instead of .toBeDefined()
for the unlazyRouter primitive-value test case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working javascript Pull requests that update javascript code labels Apr 3, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces safety guards in packages/server/src/router-utils.ts to prevent infinite recursion when traversing router trees that contain non-object primitive values. It also adds descriptive JSDoc comments to several utility functions and includes a new suite of tests to verify correct handling of various primitive types. Feedback suggests that similar logic in the contract package should also be updated to prevent potential crashes in those modules.

Comment on lines +169 to +171
if (typeof router !== 'object' || router === null) {
return router as any
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The infinite recursion issue addressed here also affects several functions in the contract package. enhanceContractRouter, minifyContractRouter, and populateContractRouterPaths in packages/contract/src/router-utils.ts all utilize for...in loops on router objects without checking for primitives. To ensure full system stability and prevent similar crashes when processing contract routers that might contain non-router exports, those functions should be updated with similar type guards.

@jameskranz jameskranz force-pushed the fix/enhance-router-primitive-recursion-v2 branch from 32952fd to 92ffa80 Compare April 3, 2026 18:22
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 3, 2026

📝 Walkthrough

Walkthrough

Added defensive guards to router utilities so primitive or null exports are returned unchanged and not recursed into; tests added in server and contract packages to validate primitives alongside procedure exports and to prevent infinite recursion/throws.

Changes

Cohort / File(s) Summary
Server router utils
packages/server/src/router-utils.ts
Added early-return guards in enhanceRouter and unlazyRouter; tightened recursion check in traverseContractProcedures to only recurse for non-null objects.
Server router tests
packages/server/src/router-utils.test.ts
Added tests verifying routers that export primitives (string, single-char string, number, boolean, null, undefined) are preserved and do not cause infinite loops or exceptions; validated procedure enhancement and traversal behavior.
Contract router utils
packages/contract/src/router-utils.ts
Added early-return guards in enhanceContractRouter, minifyContractRouter, and populateContractRouterPaths to return non-object/null inputs unchanged.
Contract router tests
packages/contract/src/router-utils.test.ts
Added tests ensuring contract router utilities preserve primitive exports, still detect contract procedures, and complete without throwing for single-character string exports.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

size:M

Poem

🐇 I hopped through keys and nested trees,
Found strings and numbers, and tiny leaves,
I closed the gaps where loops once spun,
Kept procedures bright and mischief shun,
Hooray — the router race is won! 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title 'fix(server,contract): guard against primitive values in router tree traversal' accurately and concisely summarizes the main change: adding guards against primitives in router utility functions across two packages.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jameskranz jameskranz force-pushed the fix/enhance-router-primitive-recursion-v2 branch from 92ffa80 to 24438df Compare April 3, 2026 18:24
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/contract/src/router-utils.test.ts (2)

111-129: Consider adding boolean and null/undefined tests for consistency.

enhanceContractRouter tests cover boolean and null/undefined values (lines 98-108), but this suite doesn't. While the guard implementation handles all primitive types uniformly, adding these tests would ensure consistent coverage across all three functions.

💡 Suggested additional test cases
   it('handles number values without infinite recursion', () => {
     const routerWithNumber = { ping, VERSION: 42 } as any
     const minified = minifyContractRouter(routerWithNumber)
     expect(isContractProcedure((minified as any).ping)).toBe(true)
   })
+
+  it('handles boolean values without infinite recursion', () => {
+    const routerWithBool = { ping, ENABLED: true } as any
+    const minified = minifyContractRouter(routerWithBool)
+    expect(isContractProcedure((minified as any).ping)).toBe(true)
+  })
+
+  it('handles null and undefined values without infinite recursion', () => {
+    const routerWithNullish = { ping, NIL: null, UNDEF: undefined } as any
+    const minified = minifyContractRouter(routerWithNullish)
+    expect(isContractProcedure((minified as any).ping)).toBe(true)
+  })
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/contract/src/router-utils.test.ts` around lines 111 - 129, Add tests
to the "minifyContractRouter with primitive values" suite to cover boolean and
null/undefined cases similar to the existing string/number tests: create router
objects like { ping, FLAG: true } and { ping, MISSING: null } (or undefined),
call minifyContractRouter on each, and assert isContractProcedure((minified as
any).ping) is true; place the tests as additional it(...) blocks alongside the
existing ones to ensure parity with enhanceContractRouter coverage.

131-149: Same suggestion: consider adding boolean and null/undefined tests.

For the same consistency reason as minifyContractRouter, adding tests for boolean and null/undefined would complete the coverage parity with enhanceContractRouter.

💡 Suggested additional test cases
   it('handles number values without infinite recursion', () => {
     const routerWithNumber = { ping: oc.input(inputSchema), VERSION: 42 } as any
     const populated = populateContractRouterPaths(routerWithNumber)
     expect(isContractProcedure(populated.ping)).toBe(true)
   })
+
+  it('handles boolean values without infinite recursion', () => {
+    const routerWithBool = { ping: oc.input(inputSchema), ENABLED: true } as any
+    const populated = populateContractRouterPaths(routerWithBool)
+    expect(isContractProcedure(populated.ping)).toBe(true)
+  })
+
+  it('handles null and undefined values without infinite recursion', () => {
+    const routerWithNullish = { ping: oc.input(inputSchema), NIL: null, UNDEF: undefined } as any
+    const populated = populateContractRouterPaths(routerWithNullish)
+    expect(isContractProcedure(populated.ping)).toBe(true)
+  })
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/contract/src/router-utils.test.ts` around lines 131 - 149, Add tests
to populateContractRouterPaths to cover boolean and null/undefined primitive
values similar to the existing string/number tests: create router variations
like { ping: oc.input(inputSchema), FLAG: true } and { ping:
oc.input(inputSchema), MISSING: null } (and undefined), call
populateContractRouterPaths on each, and assert
expect(isContractProcedure(populated.ping)).toBe(true). Mirror the style and
naming of the current cases so coverage matches minifyContractRouter and
enhanceContractRouter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/contract/src/router-utils.test.ts`:
- Around line 111-129: Add tests to the "minifyContractRouter with primitive
values" suite to cover boolean and null/undefined cases similar to the existing
string/number tests: create router objects like { ping, FLAG: true } and { ping,
MISSING: null } (or undefined), call minifyContractRouter on each, and assert
isContractProcedure((minified as any).ping) is true; place the tests as
additional it(...) blocks alongside the existing ones to ensure parity with
enhanceContractRouter coverage.
- Around line 131-149: Add tests to populateContractRouterPaths to cover boolean
and null/undefined primitive values similar to the existing string/number tests:
create router variations like { ping: oc.input(inputSchema), FLAG: true } and {
ping: oc.input(inputSchema), MISSING: null } (and undefined), call
populateContractRouterPaths on each, and assert
expect(isContractProcedure(populated.ping)).toBe(true). Mirror the style and
naming of the current cases so coverage matches minifyContractRouter and
enhanceContractRouter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b4523cde-6f58-4035-85e1-37ca5c843057

📥 Commits

Reviewing files that changed from the base of the PR and between 24438df and 9167d2b.

📒 Files selected for processing (2)
  • packages/contract/src/router-utils.test.ts
  • packages/contract/src/router-utils.ts

Apply the same primitive type guards to enhanceContractRouter,
minifyContractRouter, and populateContractRouterPaths in the contract
package. These functions have the same infinite recursion vulnerability
when router modules export string values alongside contract procedures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jameskranz jameskranz force-pushed the fix/enhance-router-primitive-recursion-v2 branch from 9167d2b to f2fc836 Compare April 3, 2026 18:38
@jameskranz jameskranz changed the title fix: guard against primitive values in router tree traversal fix(server,contract): guard against primitive values in router tree traversal Apr 3, 2026
@dinwwwh
Copy link
Copy Markdown
Member

dinwwwh commented Apr 3, 2026

But why is this necessary? Why doesn't TypeScript warn you that the oRPC router is invalid when it contains non-procedure properties?

@jameskranz
Copy link
Copy Markdown
Author

But why is this necessary? Why doesn't TypeScript warn you that the oRPC router is invalid when it contains non-procedure properties?

The Router mapped type does map non-procedure properties to never, so in theory TypeScript should catch this. But it doesn't when you use import * as module to compose routers -- the namespace type is wide enough to slip past the constraint, and a few internal .router() paths cast through as any.

We've hit this twice in our codebase. Both times we were using import * as namespace imports, which is pretty much the standard way to compose routers from module files. Once it was a string constant exported alongside procedures, and once it was an exported object with circular internals. No TypeScript errors either time.

Since the type system has this gap with namespace imports, it seems like the runtime should just skip non-procedure values instead of blowing up the stack. The change itself is pretty small -- just a typeof check before the for...in fallback, same pattern traverseContractProcedures already uses.

@dinwwwh
Copy link
Copy Markdown
Member

dinwwwh commented Apr 4, 2026

@jameskranz This PR doesn't actually fix the circular object case mentioned. The typeof !== 'object' guard only handles primitives; a circular reference still passes through and causes the same stack overflow.

The as any cast also feels like an antipattern here since it suppresses the type error rather than fixing the root cause.

A cleaner approach would be filtering non-procedure exports at the boundary before they enter the router at all:

function filterInternalExport<T extends Record<any, any>>(module: T): T {
  return Object.fromEntries(
    Object.entries(module).filter(([, v]) => return false for internal exports)
  )
}

// Usage
import * as userModule from './user'
const router = { user: filterInternalExport(userModule) }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working javascript Pull requests that update javascript code size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants