Skip to content

Commit fe89a39

Browse files
authored
feat(server): native Fastify adapter (#1127)
This PR adds a native oRPC adapter for Fastify. Previously, oRPC in Fastify used the Node adapter, which didn't integrate well with Fastify's ecosystem (e.g., cookies, helpers, middleware). This native adapter supports Fastify's request/reply APIs directly, enabling full access to Fastify features within oRPC. Closes: #998, #992 - [x] standard server adapter - [x] rpc adapter - [x] openapi adapter - [x] utilize native fastify adapter for nest.js integration (#992) - [x] docs <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * First-class Fastify support across server, OpenAPI, and standard-server packages (Fastify adapters, RPC and OpenAPI handlers) * API configuration now prefers interceptor-based error handling instead of plugin arrays * **Documentation** * Added Fastify adapter docs, examples, and a README; updated Nest guidance to note Fastify considerations * **Tests** * New Fastify-focused unit and integration tests (including cookie handling and OpenAPI scenarios) * **Chores** * Package exports and manifests updated to publish Fastify adapters and wiring <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 66a829d commit fe89a39

34 files changed

Lines changed: 1107 additions & 58 deletions

apps/content/docs/adapters/fastify.md

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,50 +8,44 @@ description: Use oRPC inside an Fastify project
88
[Fastify](https://fastify.dev/) is a web framework highly focused on providing the best developer experience with the least overhead and a powerful plugin architecture. For additional context, refer to the [HTTP Adapter](/docs/adapters/http) guide.
99

1010
::: warning
11-
Fastify automatically parses the request payload which interferes with oRPC, that apply its own parser. To avoid errors, it's necessary to create a node http server and pass the requests to oRPC first, and if there's no match, pass it to Fastify.
11+
Fastify parses common request content types by default. oRPC will use the parsed body when available.
1212
:::
1313

1414
## Basic
1515

1616
```ts
17-
import { createServer } from 'node:http'
1817
import Fastify from 'fastify'
19-
import { RPCHandler } from '@orpc/server/node'
20-
import { CORSPlugin } from '@orpc/server/plugins'
18+
import { RPCHandler } from '@orpc/server/fastify'
19+
import { onError } from '@orpc/server'
2120

2221
const handler = new RPCHandler(router, {
23-
plugins: [
24-
new CORSPlugin()
22+
interceptors: [
23+
onError((error) => {
24+
console.error(error)
25+
})
2526
]
2627
})
2728

28-
const fastify = Fastify({
29-
logger: true,
30-
serverFactory: (fastifyHandler) => {
31-
const server = createServer(async (req, res) => {
32-
const { matched } = await handler.handle(req, res, {
33-
context: {},
34-
prefix: '/rpc',
35-
})
29+
const fastify = Fastify()
3630

37-
if (matched) {
38-
return
39-
}
31+
fastify.addContentTypeParser('*', (request, payload, done) => {
32+
// Fully utilize oRPC feature by allowing any content type
33+
// And let oRPC parse the body manually by passing `undefined`
34+
done(null, undefined)
35+
})
4036

41-
fastifyHandler(req, res)
42-
})
37+
fastify.all('/rpc/*', async (req, reply) => {
38+
const { matched } = await handler.handle(req, reply, {
39+
prefix: '/rpc',
40+
context: {} // Provide initial context if needed
41+
})
4342

44-
return server
45-
},
43+
if (!matched) {
44+
reply.status(404).send('Not found')
45+
}
4646
})
4747

48-
try {
49-
await fastify.listen({ port: 3000 })
50-
}
51-
catch (err) {
52-
fastify.log.error(err)
53-
process.exit(1)
54-
}
48+
fastify.listen({ port: 3000 }).then(() => console.log('Server running on http://localhost:3000'))
5549
```
5650

5751
::: info

apps/content/docs/adapters/http.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ oRPC includes built-in HTTP support, making it easy to expose RPC endpoints in a
1313
| ------------ | -------------------------------------------------------------------------------------------------------------------------- |
1414
| `fetch` | [MDN Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (Browser, Bun, Deno, Cloudflare Workers, etc.) |
1515
| `node` | Node.js built-in [`http`](https://nodejs.org/api/http.html)/[`http2`](https://nodejs.org/api/http2.html) |
16+
| `fastify` | [Fastify](https://fastify.dev/) |
1617
| `aws-lambda` | [AWS Lambda](https://aws.amazon.com/lambda/) |
1718

1819
::: code-group
@@ -121,6 +122,33 @@ Deno.serve(async (request) => {
121122
})
122123
```
123124

125+
```ts [fastify]
126+
import Fastify from 'fastify'
127+
import { RPCHandler } from '@orpc/server/fastify'
128+
129+
const rpcHandler = new RPCHandler(router)
130+
131+
const fastify = Fastify()
132+
133+
fastify.addContentTypeParser('*', (request, payload, done) => {
134+
// Fully utilize oRPC feature by allowing any content type
135+
// And let oRPC parse the body manually by passing `undefined`
136+
done(null, undefined)
137+
})
138+
139+
fastify.all('/rpc/*', async (req, reply) => {
140+
const { matched } = await rpcHandler.handle(req, reply, {
141+
prefix: '/rpc',
142+
})
143+
144+
if (!matched) {
145+
reply.status(404).send('Not found')
146+
}
147+
})
148+
149+
fastify.listen({ port: 3000 }).then(() => console.log('Listening on 127.0.0.1:3000'))
150+
```
151+
124152
```ts [aws-lambda]
125153
import { APIGatewayProxyEventV2 } from 'aws-lambda'
126154
import { RPCHandler } from '@orpc/server/aws-lambda'

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

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,6 @@ description: Seamlessly implement oRPC contracts in your NestJS projects.
77

88
This guide explains how to easily implement [oRPC contract](/docs/contract-first/define-contract) within your [NestJS](https://nestjs.com/) application using `@orpc/nest`.
99

10-
::: warning
11-
This feature is experimental and may undergo breaking changes.
12-
We highly recommend using it with the NestJS Express Platform, as oRPC currently does not work well with Fastify (see [issue #992](https://github.com/unnoq/orpc/issues/992)).
13-
:::
14-
1510
## Installation
1611

1712
::: code-group

apps/content/learn-and-contribute/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Abstracts runtime environments, allowing oRPC adapters to run seamlessly across
3737
- [standard-server](https://github.com/unnoq/orpc/tree/main/packages/standard-server)
3838
- [standard-server-fetch](https://github.com/unnoq/orpc/tree/main/packages/standard-server-fetch)
3939
- [standard-server-node](https://github.com/unnoq/orpc/tree/main/packages/standard-server-node)
40+
- [standard-server-fastify](https://github.com/unnoq/orpc/tree/main/packages/standard-server-fastify)
4041
- [standard-server-aws-lambda](https://github.com/unnoq/orpc/tree/main/packages/standard-server-aws-lambda)
4142
- [standard-server-peer](https://github.com/unnoq/orpc/tree/main/packages/standard-server-peer)
4243

packages/nest/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@
5757
"@orpc/server": "workspace:*",
5858
"@orpc/shared": "workspace:*",
5959
"@orpc/standard-server": "workspace:*",
60+
"@orpc/standard-server-fastify": "workspace:*",
6061
"@orpc/standard-server-node": "workspace:*"
6162
},
6263
"devDependencies": {
64+
"@fastify/cookie": "^11.0.2",
6365
"@nestjs/common": "^11.1.7",
6466
"@nestjs/core": "^11.1.7",
6567
"@nestjs/platform-express": "^11.1.7",

packages/nest/src/implement.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { NodeHttpRequest } from '@orpc/standard-server-node'
22
import type { Request } from 'express'
3-
import { Controller, Req } from '@nestjs/common'
3+
import type { FastifyReply } from 'fastify'
4+
import FastifyCookie from '@fastify/cookie'
5+
import { Controller, Req, Res } from '@nestjs/common'
46
import { REQUEST } from '@nestjs/core'
57
import { FastifyAdapter } from '@nestjs/platform-fastify'
68
import { Test } from '@nestjs/testing'
@@ -462,4 +464,41 @@ describe('@Implement', async () => {
462464
eventIteratorKeepAliveComment: '__TEST__',
463465
}))
464466
})
467+
468+
it('work with fastify/cookie', async () => {
469+
@Controller()
470+
class FastifyController {
471+
@Implement(contract.ping)
472+
pong(@Res({ passthrough: true }) reply: FastifyReply) {
473+
reply.cookie('foo', 'bar')
474+
return implement(contract.ping).handler(ping_handler)
475+
}
476+
}
477+
478+
const moduleRef = await Test.createTestingModule({
479+
controllers: [FastifyController],
480+
}).compile()
481+
482+
const adapter = new FastifyAdapter()
483+
await adapter.register(FastifyCookie as any)
484+
const app = moduleRef.createNestApplication(adapter)
485+
await app.init()
486+
await app.getHttpAdapter().getInstance().ready()
487+
488+
const httpServer = app.getHttpServer()
489+
490+
const res = await supertest(httpServer)
491+
.post('/ping?param=value&param2[]=value2&param2[]=value3')
492+
.set('x-custom', 'value')
493+
.send({ hello: 'world' })
494+
495+
expect(res.statusCode).toEqual(200)
496+
expect(res.body).toEqual('pong')
497+
expect(res.headers).toEqual(expect.objectContaining({
498+
'x-ping': 'pong',
499+
'set-cookie': [
500+
expect.stringContaining('foo=bar'),
501+
],
502+
}))
503+
})
465504
})

packages/nest/src/implement.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type { Router } from '@orpc/server'
44
import type { StandardParams } from '@orpc/server/standard'
55
import type { Promisable } from '@orpc/shared'
66
import type { StandardResponse } from '@orpc/standard-server'
7-
import type { NodeHttpRequest, NodeHttpResponse } from '@orpc/standard-server-node'
87
import type { Request, Response } from 'express'
98
import type { FastifyReply, FastifyRequest } from 'fastify'
109
import type { Observable } from 'rxjs'
@@ -17,7 +16,8 @@ import { StandardOpenAPICodec } from '@orpc/openapi/standard'
1716
import { createProcedureClient, getRouter, isProcedure, ORPCError, unlazy } from '@orpc/server'
1817
import { get } from '@orpc/shared'
1918
import { flattenHeader } from '@orpc/standard-server'
20-
import { sendStandardResponse, toStandardLazyRequest } from '@orpc/standard-server-node'
19+
import * as StandardServerFastify from '@orpc/standard-server-fastify'
20+
import * as StandardServerNode from '@orpc/standard-server-node'
2121
import { mergeMap } from 'rxjs'
2222
import { ORPC_MODULE_CONFIG_SYMBOL } from './module'
2323
import { toNestPattern } from './utils'
@@ -120,15 +120,9 @@ export class ImplementInterceptor implements NestInterceptor {
120120
const req: Request | FastifyRequest = ctx.switchToHttp().getRequest()
121121
const res: Response | FastifyReply = ctx.switchToHttp().getResponse()
122122

123-
const nodeReq: NodeHttpRequest = 'raw' in req ? req.raw : req
124-
const nodeRes: NodeHttpResponse = 'raw' in res ? res.raw : res
125-
126-
const standardRequest = toStandardLazyRequest(nodeReq, nodeRes)
127-
const fallbackStandardBody = standardRequest.body.bind(standardRequest)
128-
// Prefer NestJS parsed body (in nodejs body only allow parse once)
129-
standardRequest.body = () => Promise.resolve(
130-
req.body === undefined ? fallbackStandardBody() : req.body,
131-
)
123+
const standardRequest = 'raw' in req
124+
? StandardServerFastify.toStandardLazyRequest(req, res as FastifyReply)
125+
: StandardServerNode.toStandardLazyRequest(req, res as Response)
132126

133127
const standardResponse: StandardResponse = await (async () => {
134128
let isDecoding = false
@@ -159,7 +153,12 @@ export class ImplementInterceptor implements NestInterceptor {
159153
}
160154
})()
161155

162-
await sendStandardResponse(nodeRes, standardResponse, this.config)
156+
if ('raw' in res) {
157+
await StandardServerFastify.sendStandardResponse(res, standardResponse, this.config)
158+
}
159+
else {
160+
await StandardServerNode.sendStandardResponse(res, standardResponse, this.config)
161+
}
163162
}),
164163
)
165164
}

packages/openapi/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@
4040
"import": "./dist/adapters/node/index.mjs",
4141
"default": "./dist/adapters/node/index.mjs"
4242
},
43+
"./fastify": {
44+
"types": "./dist/adapters/fastify/index.d.mts",
45+
"import": "./dist/adapters/fastify/index.mjs",
46+
"default": "./dist/adapters/fastify/index.mjs"
47+
},
4348
"./aws-lambda": {
4449
"types": "./dist/adapters/aws-lambda/index.d.mts",
4550
"import": "./dist/adapters/aws-lambda/index.mjs",
@@ -53,6 +58,7 @@
5358
"./standard": "./src/adapters/standard/index.ts",
5459
"./fetch": "./src/adapters/fetch/index.ts",
5560
"./node": "./src/adapters/node/index.ts",
61+
"./fastify": "./src/adapters/fastify/index.ts",
5662
"./aws-lambda": "./src/adapters/aws-lambda/index.ts"
5763
},
5864
"files": [
@@ -74,6 +80,7 @@
7480
"rou3": "^0.7.8"
7581
},
7682
"devDependencies": {
83+
"fastify": "^5.6.1",
7784
"zod": "^4.1.12"
7885
}
7986
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
it('exports OpenAPIHandler', async () => {
2+
expect(Object.keys(await import('./index'))).toContain('OpenAPIHandler')
3+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './openapi-handler'

0 commit comments

Comments
 (0)