diff --git a/.changeset/trpc-server-fetch-handler.md b/.changeset/trpc-server-fetch-handler.md new file mode 100644 index 000000000..4ea30e7e8 --- /dev/null +++ b/.changeset/trpc-server-fetch-handler.md @@ -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. diff --git a/packages/trpc-server/README.md b/packages/trpc-server/README.md index 1a12d3f41..8314d8a59 100644 --- a/packages/trpc-server/README.md +++ b/packages/trpc-server/README.md @@ -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()`) 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 diff --git a/packages/trpc-server/eslint-suppressions.json b/packages/trpc-server/eslint-suppressions.json index a03dc40ed..0821758eb 100644 --- a/packages/trpc-server/eslint-suppressions.json +++ b/packages/trpc-server/eslint-suppressions.json @@ -10,9 +10,6 @@ } }, "src/index.ts": { - "@typescript-eslint/no-non-null-assertion": { - "count": 1 - }, "@typescript-eslint/no-unsafe-assignment": { "count": 1 }, diff --git a/packages/trpc-server/src/index.ts b/packages/trpc-server/src/index.ts index 9ef9d2abc..6c3584416 100644 --- a/packages/trpc-server/src/index.ts +++ b/packages/trpc-server/src/index.ts @@ -1,4 +1,4 @@ -import type { AnyRouter } from '@trpc/server' +import type { AnyRouter, CreateContextCallback, inferRouterContext } from '@trpc/server' import type { FetchCreateContextFnOptions, FetchHandlerRequestOptions, @@ -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, - 'req' | 'endpoint' | 'createContext' -> & - Partial, 'endpoint'>> & { - createContext?( - opts: FetchCreateContextFnOptions, - c: Context - ): Record | Promise> +type MaybePromise = T | Promise + +const BODY_METHODS = new Set(['arrayBuffer', 'blob', 'formData', 'json', 'text'] as const) +type BodyMethod = typeof BODY_METHODS extends Set ? 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 = { [K in keyof T]?: T[K] | undefined } + +type tRPCOptions = Omit< + FetchHandlerRequestOptions, + 'req' | 'endpoint' | 'createContext' +> & { + endpoint?: string + createContext?( + opts: FetchCreateContextFnOptions, + c: Context + ): MaybePromise>> +} -export const trpcServer = ({ +export const trpcServer = ({ endpoint, createContext, ...rest -}: tRPCOptions): MiddlewareHandler => { - const bodyProps = new Set(['arrayBuffer', 'blob', 'formData', 'json', 'text'] as const) - type BodyProp = typeof bodyProps extends Set ? T : never +}: tRPCOptions): 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) return res } } + +export type TrpcFetchHandlerOptions = Omit< + FetchHandlerRequestOptions, + 'req' | 'createContext' +> & + CreateContextCallback< + inferRouterContext, + (opts: FetchCreateContextFnOptions, c: Context) => MaybePromise> + > + +/** + * 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 = ( + options: TrpcFetchHandlerOptions +): MiddlewareHandler => { + const { createContext, ...rest } = options as Omit< + FetchHandlerRequestOptions, + 'req' | 'createContext' + > & { + createContext?: ( + opts: FetchCreateContextFnOptions, + c: Context + ) => MaybePromise> + } + return async (c) => + fetchRequestHandler({ + ...rest, + req: resolveRequest(c), + ...(createContext && { + createContext: (fetchOpts: FetchCreateContextFnOptions) => createContext(fetchOpts, c), + }), + } as FetchHandlerRequestOptions) +} diff --git a/packages/trpc-server/src/trpc-fetch-handler.test-d.ts b/packages/trpc-server/src/trpc-fetch-handler.test-d.ts new file mode 100644 index 000000000..1c0c41991 --- /dev/null +++ b/packages/trpc-server/src/trpc-fetch-handler.test-d.ts @@ -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().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>[0] + type HasRequiredCreateContext = undefined extends Options['createContext'] ? false : true + expectTypeOf().toEqualTypeOf() + }) + + test('createContext signature enforces router context shape', () => { + type Options = Parameters>[0] + type CreateContextFn = NonNullable + + expectTypeOf>().toEqualTypeOf< + [FetchCreateContextFnOptions, Context] + >() + expectTypeOf>().toEqualTypeOf>() + }) + + test('createContext is optional on the options type for a default-context router', () => { + type Options = Parameters>[0] + type HasOptionalCreateContext = undefined extends Options['createContext'] ? true : false + expectTypeOf().toEqualTypeOf() + }) + + 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() + new Hono().use('/trpc/*', handler) + }) +}) diff --git a/packages/trpc-server/src/trpc-fetch-handler.test.ts b/packages/trpc-server/src/trpc-fetch-handler.test.ts new file mode 100644 index 000000000..65fabbe88 --- /dev/null +++ b/packages/trpc-server/src/trpc-fetch-handler.test.ts @@ -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().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' } }) + }) +}) diff --git a/packages/trpc-server/vitest.config.ts b/packages/trpc-server/vitest.config.ts index 6f5da90b4..91cabc8ef 100644 --- a/packages/trpc-server/vitest.config.ts +++ b/packages/trpc-server/vitest.config.ts @@ -4,5 +4,9 @@ export default defineProject({ test: { globals: true, include: ['src/**/*.test.ts'], + typecheck: { + tsconfig: './tsconfig.json', + enabled: true, + }, }, })