Skip to content

Commit 90a4d22

Browse files
authored
feat(nest): add Hono framework support (#1299)
## Summary Adds Hono-based HTTP adapter support to `@orpc/nest` so `@Implement` routes can run in Nest applications backed by a Hono adapter. ## Changes - Added Fetch request/response handling for Hono adapter contexts via `@orpc/standard-server-fetch`. - Detects Hono response contexts by their Fetch response factory and returns a Hono-native `Response` with `newResponse(...)`. - Adds `OPTIONS` route decorator support for implemented contract routes. - Adds compatibility coverage using `@mnigos/platform-hono@^0.1.3`, including request body/query/header parsing, route params, response headers, and error responses. - Documents Hono-based adapter usage with `@mnigos/platform-hono` as an example. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added runtime support for Hono adapters so the package can run on Hono-powered servers alongside existing environments. * Interceptor now routes and converts requests/responses correctly across supported adapters. * **Documentation** * New guide section on using the Hono adapter with recommended configuration notes. * **Tests** * Added compatibility tests verifying handlers and routes work when the app runs with the Hono adapter. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent b2ab056 commit 90a4d22

5 files changed

Lines changed: 181 additions & 12 deletions

File tree

apps/content/docs/openapi/integrations/implement-contract-in-nest.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,37 @@ async function bootstrap() {
214214
oRPC will use NestJS parsed body when it's available, and only use the oRPC parser if the body is not parsed by NestJS.
215215
:::
216216

217+
## Hono Adapter
218+
219+
`@orpc/nest` supports NestJS applications that use Hono-based HTTP adapters.
220+
221+
For example, install [`@mnigos/platform-hono`](https://www.npmjs.com/package/@mnigos/platform-hono)
222+
and its Hono peer dependencies:
223+
224+
```sh
225+
pnpm add @mnigos/platform-hono @hono/node-server hono
226+
```
227+
228+
Then pass the adapter to `NestFactory.create`:
229+
230+
```ts
231+
import { HonoAdapter } from '@mnigos/platform-hono'
232+
import { NestFactory } from '@nestjs/core'
233+
import { AppModule } from './app.module'
234+
235+
async function bootstrap() {
236+
const app = await NestFactory.create(AppModule, new HonoAdapter(), {
237+
bodyParser: false,
238+
})
239+
240+
await app.listen(process.env.PORT ?? 3000)
241+
}
242+
```
243+
244+
`@Implement` routes continue to use the same contract paths and route
245+
parameters. Internally, oRPC reads the Hono `Request` and returns a Hono-native
246+
`Response` through the adapter response context.
247+
217248
## Configuration
218249

219250
Configure the `@orpc/nest` module by importing `ORPCModule` in your NestJS application:

packages/nest/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,13 @@
5757
"@orpc/shared": "workspace:*",
5858
"@orpc/standard-server": "workspace:*",
5959
"@orpc/standard-server-fastify": "workspace:*",
60+
"@orpc/standard-server-fetch": "workspace:*",
6061
"@orpc/standard-server-node": "workspace:*"
6162
},
6263
"devDependencies": {
6364
"@fastify/cookie": "^11.0.2",
65+
"@hono/node-server": "^1.19.11",
66+
"@mnigos/platform-hono": "^0.1.3",
6467
"@nestjs/common": "^11.1.16",
6568
"@nestjs/core": "^11.1.16",
6669
"@nestjs/platform-express": "^11.1.16",
@@ -70,6 +73,7 @@
7073
"@types/express": "^5.0.6",
7174
"express": "^5.2.1",
7275
"fastify": "^5.8.3",
76+
"hono": "^4.10.7",
7377
"rxjs": "^7.8.2",
7478
"supertest": "^7.1.4",
7579
"zod": "^4.3.6"

packages/nest/src/implement.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { NodeHttpRequest } from '@orpc/standard-server-node'
22
import type { Request } from 'express'
33
import type { FastifyReply } from 'fastify'
44
import FastifyCookie from '@fastify/cookie'
5+
import { HonoAdapter } from '@mnigos/platform-hono'
56
import { Controller, Req, Res } from '@nestjs/common'
67
import { REQUEST } from '@nestjs/core'
78
import { FastifyAdapter } from '@nestjs/platform-fastify'
@@ -671,6 +672,88 @@ describe('@Implement', async () => {
671672
})
672673

673674
describe('compatibility', () => {
675+
it('works with @mnigos/platform-hono', async () => {
676+
@Controller()
677+
class HonoController {
678+
@Implement(contract.ping)
679+
ping() {
680+
return implement(contract.ping).handler(ping_handler)
681+
}
682+
683+
@Implement(contract.pong)
684+
pong() {
685+
return implement(contract.pong).handler(pong_handler)
686+
}
687+
688+
@Implement(contract.nested.peng)
689+
peng() {
690+
return implement(contract.nested.peng).handler(peng_handler)
691+
}
692+
}
693+
694+
const moduleRef = await Test.createTestingModule({
695+
controllers: [HonoController],
696+
}).compile()
697+
698+
const adapter = new HonoAdapter()
699+
const app = moduleRef.createNestApplication(adapter, { bodyParser: false })
700+
await app.init()
701+
await app.listen(0)
702+
703+
const httpServer = app.getHttpServer()
704+
705+
try {
706+
const pingRes = await supertest(httpServer)
707+
.post('/ping?param=value&param2[]=value2&param2[]=value3')
708+
.set('x-custom', 'value')
709+
.send({ hello: 'world' })
710+
711+
expect(pingRes.statusCode).toEqual(200)
712+
expect(pingRes.body).toEqual('pong')
713+
expect(pingRes.headers).toEqual(expect.objectContaining({ 'x-ping': 'pong' }))
714+
715+
expect(ping_handler).toHaveBeenCalledWith(expect.objectContaining({
716+
input: {
717+
headers: expect.objectContaining({
718+
'x-custom': 'value',
719+
}),
720+
body: { hello: 'world' },
721+
params: {},
722+
query: {
723+
param: 'value',
724+
param2: ['value2', 'value3'],
725+
},
726+
},
727+
}))
728+
729+
const pongRes = await supertest(httpServer).get('/pong/world')
730+
731+
expect(pongRes.statusCode).toEqual(408)
732+
expect(pongRes.body).toEqual(expect.objectContaining({
733+
code: 'TEST',
734+
data: 'pong world',
735+
}))
736+
expect(pong_handler).toHaveBeenCalledWith(expect.objectContaining({
737+
input: {
738+
name: 'world',
739+
},
740+
}))
741+
742+
const pengRes = await supertest(httpServer).delete('/world/who%3F')
743+
744+
expect(pengRes.statusCode).toEqual(202)
745+
expect(pengRes.body).toEqual('peng world/who?')
746+
expect(peng_handler).toHaveBeenCalledWith(expect.objectContaining({
747+
input: {
748+
path: 'world/who?',
749+
},
750+
}))
751+
}
752+
finally {
753+
await app.close()
754+
}
755+
})
756+
674757
it('work with fastify/cookie', async () => {
675758
@Controller()
676759
class FastifyController {

packages/nest/src/implement.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,35 @@ import type { Request, Response } from 'express'
77
import type { FastifyReply, FastifyRequest } from 'fastify'
88
import type { Observable } from 'rxjs'
99
import type { ORPCGlobalContext, ORPCModuleConfig } from './module'
10-
import { applyDecorators, Delete, Get, Head, HttpCode, Inject, Injectable, Optional, Patch, Post, Put, UseInterceptors } from '@nestjs/common'
10+
import { applyDecorators, Delete, Get, Head, HttpCode, Inject, Injectable, Optional, Options, Patch, Post, Put, UseInterceptors } from '@nestjs/common'
1111
import { fallbackContractConfig, isContractProcedure } from '@orpc/contract'
1212
import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard'
1313
import { StandardOpenAPICodec } from '@orpc/openapi/standard'
1414
import { getRouter, isProcedure, unlazy } from '@orpc/server'
1515
import { StandardHandler } from '@orpc/server/standard'
1616
import { get, intercept, toArray } from '@orpc/shared'
1717
import * as StandardServerFastify from '@orpc/standard-server-fastify'
18+
import * as StandardServerFetch from '@orpc/standard-server-fetch'
1819
import * as StandardServerNode from '@orpc/standard-server-node'
1920
import { mergeMap } from 'rxjs'
2021
import { ORPC_MODULE_CONFIG_SYMBOL } from './module'
2122
import { toNestPattern } from './utils'
2223

24+
interface HonoContext {
25+
req: { raw: globalThis.Request, params?: NestParams }
26+
res?: globalThis.Response
27+
finalized: boolean
28+
newResponse: (...args: any[]) => globalThis.Response
29+
}
30+
2331
const MethodDecoratorMap = {
2432
HEAD: Head,
2533
GET: Get,
2634
POST: Post,
2735
PUT: Put,
2836
PATCH: Patch,
2937
DELETE: Delete,
38+
OPTIONS: Options,
3039
}
3140

3241
/**
@@ -122,16 +131,26 @@ export class ImplementInterceptor implements NestInterceptor {
122131
`)
123132
}
124133

125-
const req: Request | FastifyRequest = ctx.switchToHttp().getRequest()
126-
const res: Response | FastifyReply = ctx.switchToHttp().getResponse()
134+
const req: Request | FastifyRequest | HonoContext['req'] = ctx.switchToHttp().getRequest()
135+
const res: Response | FastifyReply | HonoContext = ctx.switchToHttp().getResponse()
127136

128-
const standardRequest = 'raw' in req
129-
? StandardServerFastify.toStandardLazyRequest(req, res as FastifyReply)
130-
: StandardServerNode.toStandardLazyRequest(req, res as Response)
137+
// Detect a Hono adapter response context by its Fetch response factory.
138+
const isHono = 'finalized' in res && typeof (res as HonoContext).newResponse === 'function'
139+
const isFastify = 'raw' in req && !isHono
140+
141+
const standardRequest = (() => {
142+
if (isHono) {
143+
return StandardServerFetch.toStandardLazyRequest((req as HonoContext['req']).raw)
144+
}
145+
if (isFastify) {
146+
return StandardServerFastify.toStandardLazyRequest(req as FastifyRequest, res as FastifyReply)
147+
}
148+
return StandardServerNode.toStandardLazyRequest(req as Request, res as Response)
149+
})()
131150

132151
const handler = new StandardHandler(procedure, {
133152
init: () => {},
134-
match: () => Promise.resolve({ path: toArray(this.config.path), procedure, params: flattenParams(req.params as NestParams) }),
153+
match: () => Promise.resolve({ path: toArray(this.config.path), procedure, params: flattenParams(req.params as NestParams | undefined) }),
135154
}, this.codec, {
136155
// Since plugins can modify options directly, so we need to clone to avoid affecting other handlers/requests
137156
// TODO: improve plugins system to avoid this cloning
@@ -148,11 +167,15 @@ export class ImplementInterceptor implements NestInterceptor {
148167
toArray(this.config.sendResponseInterceptors),
149168
{ request: req, response: res, standardResponse: result.response },
150169
async ({ response, standardResponse }) => {
151-
if ('raw' in response) {
152-
await StandardServerFastify.sendStandardResponse(response, standardResponse, this.config)
170+
if (isHono) {
171+
const fetchResponse = StandardServerFetch.toFetchResponse(standardResponse, this.config)
172+
return (response as HonoContext).newResponse(fetchResponse.body, fetchResponse)
173+
}
174+
else if (isFastify) {
175+
await StandardServerFastify.sendStandardResponse(response as FastifyReply, standardResponse, this.config)
153176
}
154177
else {
155-
await StandardServerNode.sendStandardResponse(response, standardResponse, this.config)
178+
await StandardServerNode.sendStandardResponse(response as Response, standardResponse, this.config)
156179
}
157180
},
158181
)
@@ -162,10 +185,10 @@ export class ImplementInterceptor implements NestInterceptor {
162185
}
163186
}
164187

165-
function flattenParams(params: NestParams): StandardParams {
188+
function flattenParams(params: NestParams | undefined): StandardParams {
166189
const flatten: StandardParams = {}
167190

168-
for (const [key, value] of Object.entries(params)) {
191+
for (const [key, value] of Object.entries(params ?? {})) {
169192
if (Array.isArray(value)) {
170193
flatten[key] = value.join('/')
171194
}

pnpm-lock.yaml

Lines changed: 28 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)