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
1 change: 1 addition & 0 deletions packages/trpc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@trpc/server": ">=11.4.2"
},
"dependencies": {
"@orpc/client": "workspace:*",
"@orpc/server": "workspace:*",
"@orpc/shared": "workspace:*"
},
Expand Down
7 changes: 4 additions & 3 deletions packages/trpc/src/to-orpc-router.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ContractRouter, InferRouterInitialContext, Procedure, Router, Schema } from '@orpc/server'
import type { AsyncIteratorClass } from '@orpc/shared'
import type { inferRouterContext } from '@trpc/server'
import type { inferRouterMeta } from '@trpc/server/unstable-core-do-not-import'
import type { inferRouterMeta, TrackedData } from '@trpc/server/unstable-core-do-not-import'
import type { TRPCContext, TRPCMeta, trpcRouter } from '../tests/shared'
import type { experimental_ToORPCRouterResult as ToORPCRouterResult } from './to-orpc-router'

Expand All @@ -24,7 +25,7 @@ it('ToORPCRouterResult', () => {
>()

expectTypeOf(orpcRouter.subscribe).toEqualTypeOf<
Procedure<TRPCContext, object, Schema<{ u: string }, unknown>, Schema<unknown, AsyncIterable<string, void, any>>, object, TRPCMeta>
Procedure<TRPCContext, object, Schema<{ u: string }, unknown>, Schema<unknown, AsyncIteratorClass<'pong' | TrackedData<{ order: number }>, void, any>>, object, TRPCMeta>
>()

expectTypeOf(orpcRouter.nested).toEqualTypeOf<
Expand All @@ -35,7 +36,7 @@ it('ToORPCRouterResult', () => {

expectTypeOf(orpcRouter.lazy).toEqualTypeOf<
{
subscribe: Procedure<TRPCContext, object, Schema<void, unknown>, Schema<unknown, AsyncIterable<string, void, any>>, object, TRPCMeta>
subscribe: Procedure<TRPCContext, object, Schema<void, unknown>, Schema<unknown, AsyncIteratorClass<string, void, any>>, object, TRPCMeta>
lazy: {
throw: Procedure<TRPCContext, object, Schema<{ input: number }, unknown>, Schema<unknown, { output: string }>, object, TRPCMeta>
}
Expand Down
95 changes: 93 additions & 2 deletions packages/trpc/src/to-orpc-router.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { call, createRouterClient, isProcedure, ORPCError, unlazy } from '@orpc/server'
import { call, createRouterClient, getEventMeta, isProcedure, ORPCError, unlazy } from '@orpc/server'
import { isAsyncIteratorObject } from '@orpc/shared'
import { tracked } from '@trpc/server'
import { z } from 'zod'
import { inputSchema, outputSchema } from '../../contract/tests/shared'
import { trpcRouter } from '../tests/shared'
import { t, trpcRouter } from '../tests/shared'
import { experimental_toORPCRouter as toORPCRouter } from './to-orpc-router'

beforeEach(() => {
vi.clearAllMocks()
})

describe('toORPCRouter', async () => {
const orpcRouter = toORPCRouter(trpcRouter)

Expand Down Expand Up @@ -73,4 +79,89 @@ describe('toORPCRouter', async () => {
})
})
})

describe('event iterators', () => {
it('subscribe & tracked', async () => {
const output = await call(orpcRouter.subscribe, { u: '2' }, { lastEventId: 'id-1', context: { a: 'test' } }) as any
expect(output).toSatisfy(isAsyncIteratorObject)
await expect(output.next()).resolves.toEqual({ done: false, value: 'pong' })
await expect(output.next()).resolves.toSatisfy((result) => {
expect(result.done).toEqual(false)
expect(result.value).toEqual({ id: 'id-1', data: { order: 1 } })
expect(getEventMeta(result.value)).toEqual({ id: 'id-1' })

return true
})
await expect(output.next()).resolves.toSatisfy((result) => {
expect(result.done).toEqual(false)
expect(result.value).toEqual({ id: 'id-2', data: { order: 2 } })
expect(getEventMeta(result.value)).toEqual({ id: 'id-2' })

return true
})
await expect(output.next()).resolves.toEqual({ done: true, value: undefined })
})

it('lastEventId', async () => {
const trackedSubscription = vi.fn(async function* () {
yield { order: 1 }
yield tracked('id-2', { order: 2 })
})

const trpcRouter = t.router({
tracked: t.procedure
.input(z.any())
.subscription(trackedSubscription),
})

const orpcRouter = toORPCRouter(trpcRouter)

await call(orpcRouter.tracked, { u: 'u' }, { lastEventId: 'id-1', context: { a: 'test' } })
expect(trackedSubscription).toHaveBeenNthCalledWith(1, expect.objectContaining({
input: { u: 'u', lastEventId: 'id-1' },
}))

await call(orpcRouter.tracked, undefined, { lastEventId: 'id-2', context: { a: 'test' } })
expect(trackedSubscription).toHaveBeenNthCalledWith(2, expect.objectContaining({
input: { lastEventId: 'id-2' },
}))

await call(orpcRouter.tracked, 1234, { lastEventId: 'id-3', context: { a: 'test' } })
expect(trackedSubscription).toHaveBeenNthCalledWith(3, expect.objectContaining({
input: 1234,
}))
})

it('works with AsyncIterable & cleanup', async () => {
let cleanupCalled = false

const trackedSubscription = vi.fn(async () => {
return {
async* [Symbol.asyncIterator]() {
try {
yield { order: 1 }
yield tracked('id-2', { order: 2 })
}
finally {
cleanupCalled = true
}
},
}
})

const trpcRouter = t.router({
tracked: t.procedure
.input(z.any())
.subscription(trackedSubscription),
})

const orpcRouter = toORPCRouter(trpcRouter)

const output = await call(orpcRouter.tracked, { u: 'u' }, { lastEventId: 'id-1', context: { a: 'test' } })

await expect(output.next()).resolves.toEqual({ done: false, value: { order: 1 } })
await expect(output.return?.()).resolves.toEqual({ done: true, value: undefined })
expect(cleanupCalled).toBe(true)
})
})
})
51 changes: 42 additions & 9 deletions packages/trpc/src/to-orpc-router.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import type { AsyncIteratorClass } from '@orpc/shared'
import type { AnyProcedure, AnyRouter, inferRouterContext } from '@trpc/server'
import type { inferRouterMeta, Parser } from '@trpc/server/unstable-core-do-not-import'
import type { inferRouterMeta, Parser, TrackedData } from '@trpc/server/unstable-core-do-not-import'
import { mapEventIterator } from '@orpc/client'
import * as ORPC from '@orpc/server'
import { isTypescriptObject } from '@orpc/shared'
import { TRPCError } from '@trpc/server'
import { getHTTPStatusCodeFromError } from '@trpc/server/unstable-core-do-not-import'
import { isObject, isTypescriptObject } from '@orpc/shared'
import { isTrackedEnvelope, TRPCError } from '@trpc/server'
import { getHTTPStatusCodeFromError, isAsyncIterable } from '@trpc/server/unstable-core-do-not-import'

export interface experimental_ORPCMeta extends ORPC.Route {

}

export type experimental_ToORPCOutput<T>
= T extends AsyncIterable<infer TData, infer TReturn, infer TNext>
? AsyncIteratorClass<TData, TReturn, TNext>
: T

export type experimental_ToORPCRouterResult<TContext extends ORPC.Context, TMeta extends ORPC.Meta, TRecord extends Record<string, any>>
= {
[K in keyof TRecord]:
Expand All @@ -17,7 +24,7 @@ export type experimental_ToORPCRouterResult<TContext extends ORPC.Context, TMeta
TContext,
object,
ORPC.Schema<TRecord[K]['_def']['$types']['input'], unknown>,
ORPC.Schema<unknown, TRecord[K]['_def']['$types']['output']>,
ORPC.Schema<unknown, experimental_ToORPCOutput<TRecord[K]['_def']['$types']['output']>>,
object,
TMeta
>
Expand Down Expand Up @@ -88,16 +95,42 @@ function toORPCProcedure(procedure: AnyProcedure) {
middlewares: [],
inputSchema: toDisabledStandardSchema(procedure._def.inputs.at(-1)),
outputSchema: toDisabledStandardSchema((procedure as any)._def.output),
handler: async ({ context, signal, path, input }) => {
handler: async ({ context, signal, path, input, lastEventId }) => {
try {
return await procedure({
const trpcInput = lastEventId !== undefined && (input === undefined || isObject(input))
? { ...input, lastEventId }
: input

Comment on lines +98 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify input spreading behavior with complex objects.

The input merging logic looks correct, but spreading could have unexpected behavior with objects containing getters, symbols, or non-enumerable properties.


🏁 Script executed:

#!/bin/bash
# Description: Search for test cases that verify input merging behavior with complex objects
# Expected: Tests covering edge cases like objects with getters, symbols, or prototype pollution

rg -A 10 -B 5 "lastEventId.*input|input.*lastEventId" --type ts

Length of output: 17376


Add tests for input merging edge cases with complex objects

The current tests only cover primitive and plain‐object inputs. Since object spread only copies own, enumerable, string-keyed properties, please add unit tests in packages/trpc/src/to-orpc-router.test.ts to verify behavior when input contains:

  • Getter properties (e.g. Object.defineProperty(input, 'foo', { get: … }))
  • Symbol-keyed properties
  • Non-enumerable properties
  • Inherited (prototype) properties

This will ensure the merge { …input, lastEventId } behaves as expected in all edge cases.

🤖 Prompt for AI Agents
In packages/trpc/src/to-orpc-router.ts around lines 97 to 102, the code merges
input with lastEventId using object spread, which only copies own, enumerable,
string-keyed properties. To ensure correct behavior in edge cases, add unit
tests in packages/trpc/src/to-orpc-router.test.ts that cover inputs containing
getter properties, symbol-keyed properties, non-enumerable properties, and
inherited prototype properties. These tests should verify how the merge handles
these cases and confirm the output matches expected behavior.

const output = await procedure({
ctx: context,
signal,
path: path.join('.'),
type: procedure._def.type,
input,
getRawInput: () => input,
input: trpcInput,
getRawInput: () => trpcInput,
})

if (isAsyncIterable(output)) {
return mapEventIterator(output[Symbol.asyncIterator](), {
error: async error => error,
value: (value) => {
if (isTrackedEnvelope(value)) {
const [id, data] = value

return ORPC.withEventMeta({
id,
data,
Comment on lines +119 to +122
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.

critical

The cast as TrackedData<unknown> is incorrect. The object { id, data } is not a tuple of type [string, unknown], which is what TrackedData<unknown> resolves to. Remove this cast to avoid potential type-related bugs.

Suggested change
return ORPC.withEventMeta({
id,
data,
return ORPC.withEventMeta({
id,
data,
}, {
id,
})

} satisfies TrackedData<unknown>, {
id,
})
}

return value
},
})
}

return output
}
catch (cause) {
if (cause instanceof TRPCError) {
Expand Down
4 changes: 3 additions & 1 deletion packages/trpc/tests/shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { experimental_ORPCMeta as ORPCMeta } from '../src/to-orpc-router'
import { initTRPC, lazy, TRPCError } from '@trpc/server'
import { initTRPC, lazy, tracked, TRPCError } from '@trpc/server'
import { z } from 'zod/v4'
import { inputSchema, outputSchema } from '../../contract/tests/shared'

Expand Down Expand Up @@ -34,6 +34,8 @@ export const trpcRouter = t.router({
.input(z.object({ u: z.string() }))
.subscription(async function* () {
yield 'pong'
yield tracked('id-1', { order: 1 })
yield tracked('id-2', { order: 2 })
}),

nested: {
Expand Down
1 change: 1 addition & 0 deletions packages/trpc/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"extends": "../../tsconfig.lib.json",
"references": [
{ "path": "../server" },
{ "path": "../client" },
{ "path": "../shared" }
],
"include": ["src"],
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.