Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/trpc-server-fetch-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/trpc-server': minor
---

Add `trpcFetchHandler`, a wrapper around `@trpc/server`'s `fetchRequestHandler` that preserves router context inference. `createContext` is required when the router has a typed context and optional otherwise, matching `fetchRequestHandler`'s own contract. `trpcServer` is unchanged.
36 changes: 36 additions & 0 deletions packages/trpc-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,42 @@ app.use(
export default app
```

## `trpcFetchHandler`

`trpcFetchHandler` is a wrapper around tRPC's `fetchRequestHandler` that
preserves router context inference. Unlike `trpcServer`, it does not
auto-merge `c.env` into the tRPC context — callers pass whatever they want
from the Hono context through their own `createContext`, which lets the
router's context type flow end-to-end.

```ts
import { Hono } from 'hono'
import { trpcFetchHandler } from '@hono/trpc-server'
import { appRouter } from './router'

const app = new Hono()

app.use(
'/trpc/*',
trpcFetchHandler({
router: appRouter,
endpoint: '/trpc',
createContext: (_opts, c) => ({
env: c.env,
userId: c.req.header('x-user-id'),
}),
})
)

export default app
```

`createContext` is required when the router uses a typed context
(`initTRPC.context<T>()`) and optional otherwise, matching tRPC's own
`fetchRequestHandler` contract. `trpcServer` remains fully supported;
`trpcFetchHandler` is an alternative for consumers who need end-to-end
router context inference.

## Author

Yusuke Wada <https://github.com/yusukebe>
Expand Down
3 changes: 0 additions & 3 deletions packages/trpc-server/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@
}
},
"src/index.ts": {
"@typescript-eslint/no-non-null-assertion": {
"count": 1
},
"@typescript-eslint/no-unsafe-assignment": {
"count": 1
},
Expand Down
130 changes: 92 additions & 38 deletions packages/trpc-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AnyRouter } from '@trpc/server'
import type { AnyRouter, CreateContextCallback, inferRouterContext } from '@trpc/server'
import type {
FetchCreateContextFnOptions,
FetchHandlerRequestOptions,
Expand All @@ -7,58 +7,112 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import type { Context, MiddlewareHandler } from 'hono'
import { routePath } from 'hono/route'

type tRPCOptions = Omit<
FetchHandlerRequestOptions<AnyRouter>,
'req' | 'endpoint' | 'createContext'
> &
Partial<Pick<FetchHandlerRequestOptions<AnyRouter>, 'endpoint'>> & {
createContext?(
opts: FetchCreateContextFnOptions,
c: Context
): Record<string, unknown> | Promise<Record<string, unknown>>
type MaybePromise<T> = T | Promise<T>

const BODY_METHODS = new Set(['arrayBuffer', 'blob', 'formData', 'json', 'text'] as const)
type BodyMethod = typeof BODY_METHODS extends Set<infer T> ? T : never

/**
* Hono's `HonoRequest` caches parsed bodies. If an upstream middleware has
* already consumed `c.req.json()` (or similar), the raw `c.req.raw.body`
* stream is locked and reading the body off `c.req.raw` throws. For request
* methods that can carry a body we proxy body reads back through `c.req`,
* which returns the cached value.
*/
const resolveRequest = (c: Context): Request => {
const isBodyless = c.req.method === 'GET' || c.req.method === 'HEAD'
if (isBodyless) {
return c.req.raw
}
return new Proxy(c.req.raw, {
get(target, prop, _receiver) {
if (BODY_METHODS.has(prop as BodyMethod)) {
return () => c.req[prop as BodyMethod]()
}
return Reflect.get(target, prop, target) as unknown
},
})
}

type LegacyPartialContext<T> = { [K in keyof T]?: T[K] | undefined }

type tRPCOptions<TRouter extends AnyRouter> = Omit<
FetchHandlerRequestOptions<TRouter>,
'req' | 'endpoint' | 'createContext'
> & {
endpoint?: string
createContext?(
opts: FetchCreateContextFnOptions,
c: Context
): MaybePromise<LegacyPartialContext<inferRouterContext<TRouter>>>
}

export const trpcServer = ({
export const trpcServer = <TRouter extends AnyRouter>({
endpoint,
createContext,
...rest
}: tRPCOptions): MiddlewareHandler => {
const bodyProps = new Set(['arrayBuffer', 'blob', 'formData', 'json', 'text'] as const)
type BodyProp = typeof bodyProps extends Set<infer T> ? T : never
}: tRPCOptions<TRouter>): MiddlewareHandler => {
return async (c) => {
const canWithBody = c.req.method === 'GET' || c.req.method === 'HEAD'

// Auto-detect endpoint from route path if not explicitly provided
let resolvedEndpoint = endpoint
if (!endpoint) {
let resolvedEndpoint: string
if (typeof endpoint === 'string') {
resolvedEndpoint = endpoint
} else {
const path = routePath(c)
if (path) {
// Remove wildcard suffix (e.g., "/v1/*" -> "/v1")
resolvedEndpoint = path.replace(/\/\*+$/, '') || '/trpc'
} else {
resolvedEndpoint = '/trpc'
}
resolvedEndpoint = path ? path.replace(/\/\*+$/, '') || '/trpc' : '/trpc'
}

const res = await fetchRequestHandler({
...rest,
createContext: async (opts) => ({
endpoint: resolvedEndpoint,
req: resolveRequest(c),
createContext: async (opts: FetchCreateContextFnOptions) => ({
...(createContext ? await createContext(opts, c) : {}),
// propagate env by default
env: c.env,
}),
endpoint: resolvedEndpoint!,
req: canWithBody
? c.req.raw
: new Proxy(c.req.raw, {
get(t, p, _r) {
if (bodyProps.has(p as BodyProp)) {
return () => c.req[p as BodyProp]()
}
return Reflect.get(t, p, t)
},
}),
})
} as unknown as FetchHandlerRequestOptions<AnyRouter>)
return res
}
}

export type TrpcFetchHandlerOptions<TRouter extends AnyRouter> = Omit<
FetchHandlerRequestOptions<TRouter>,
'req' | 'createContext'
> &
CreateContextCallback<
inferRouterContext<TRouter>,
(opts: FetchCreateContextFnOptions, c: Context) => MaybePromise<inferRouterContext<TRouter>>
>

/**
* Hono middleware around `@trpc/server`'s `fetchRequestHandler` that
* preserves router context inference end-to-end. `createContext` is required
* when the router has a typed context and optional otherwise, matching
* `fetchRequestHandler`'s own `CreateContextCallback` contract.
*
* Unlike `trpcServer`, this handler does not auto-merge `c.env` into the
* context — callers that want `c.env` in their tRPC context pass it
* explicitly from their own `createContext`, which is what allows the
* router's context type to flow through.
*/
export const trpcFetchHandler = <TRouter extends AnyRouter>(
options: TrpcFetchHandlerOptions<TRouter>
): MiddlewareHandler => {
const { createContext, ...rest } = options as Omit<
FetchHandlerRequestOptions<TRouter>,
'req' | 'createContext'
> & {
createContext?: (
opts: FetchCreateContextFnOptions,
c: Context
) => MaybePromise<inferRouterContext<TRouter>>
}
return async (c) =>
fetchRequestHandler<TRouter>({
...rest,
req: resolveRequest(c),
...(createContext && {
createContext: (fetchOpts: FetchCreateContextFnOptions) => createContext(fetchOpts, c),
}),
} as FetchHandlerRequestOptions<TRouter>)
}
52 changes: 52 additions & 0 deletions packages/trpc-server/src/trpc-fetch-handler.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { initTRPC } from '@trpc/server'
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
import type { Context, MiddlewareHandler } from 'hono'
import { Hono } from 'hono'
import { trpcFetchHandler } from '.'

describe('trpcFetchHandler types', () => {
type HonoContext = { userId: string }
const t = initTRPC.context<HonoContext>().create()
const typedRouter = t.router({
me: t.procedure.query(({ ctx }) => ctx.userId),
})

const plain = initTRPC.create()
const _plainRouter = plain.router({
ping: plain.procedure.query(() => 'pong'),
})

test('createContext is required on the options type for a typed router', () => {
type Options = Parameters<typeof trpcFetchHandler<typeof typedRouter>>[0]
type HasRequiredCreateContext = undefined extends Options['createContext'] ? false : true
expectTypeOf<HasRequiredCreateContext>().toEqualTypeOf<true>()
})

test('createContext signature enforces router context shape', () => {
type Options = Parameters<typeof trpcFetchHandler<typeof typedRouter>>[0]
type CreateContextFn = NonNullable<Options['createContext']>

expectTypeOf<Parameters<CreateContextFn>>().toEqualTypeOf<
[FetchCreateContextFnOptions, Context]
>()
expectTypeOf<ReturnType<CreateContextFn>>().toEqualTypeOf<HonoContext | Promise<HonoContext>>()
})

test('createContext is optional on the options type for a default-context router', () => {
type Options = Parameters<typeof trpcFetchHandler<typeof _plainRouter>>[0]
type HasOptionalCreateContext = undefined extends Options['createContext'] ? true : false
expectTypeOf<HasOptionalCreateContext>().toEqualTypeOf<true>()
})

test('typed router with matching createContext produces a MiddlewareHandler', () => {
const handler = trpcFetchHandler({
router: typedRouter,
endpoint: '/trpc',
createContext: (_opts, c) => ({
userId: c.req.header('x-user-id') ?? '',
}),
})
expectTypeOf(handler).toEqualTypeOf<MiddlewareHandler>()
new Hono().use('/trpc/*', handler)
})
})
52 changes: 52 additions & 0 deletions packages/trpc-server/src/trpc-fetch-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { initTRPC } from '@trpc/server'
import { Hono } from 'hono'
import { trpcFetchHandler } from '.'

describe('trpcFetchHandler', () => {
type HonoContext = { userId: string }
const t = initTRPC.context<HonoContext>().create()
const router = t.router({
me: t.procedure.query(({ ctx }) => `user:${ctx.userId}`),
rename: t.procedure
.input((v) => {
if (typeof v !== 'string') {
throw new Error('expected string')
}
return v
})
.mutation(({ ctx, input }) => `${ctx.userId}→${input}`),
})

const app = new Hono()
app.use(
'/trpc/*',
trpcFetchHandler({
router,
endpoint: '/trpc',
createContext: (_opts, c) => ({
userId: c.req.header('x-user-id') ?? 'anon',
}),
})
)

it('threads typed context through to queries', async () => {
const res = await app.request('http://localhost/trpc/me', {
headers: { 'x-user-id': 'alice' },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ result: { data: 'user:alice' } })
})

it('threads typed context through to mutations', async () => {
const res = await app.request('http://localhost/trpc/rename', {
method: 'POST',
headers: {
'x-user-id': 'alice',
'content-type': 'application/json',
},
body: JSON.stringify('bob'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ result: { data: 'alice→bob' } })
})
})
4 changes: 4 additions & 0 deletions packages/trpc-server/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@ export default defineProject({
test: {
globals: true,
include: ['src/**/*.test.ts'],
typecheck: {
tsconfig: './tsconfig.json',
enabled: true,
},
},
})
Loading