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
50 changes: 22 additions & 28 deletions apps/content/docs/adapters/fastify.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,44 @@ description: Use oRPC inside an Fastify project
[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.

::: warning
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.
Fastify parses common request content types by default. oRPC will use the parsed body when available.
:::

## Basic

```ts
import { createServer } from 'node:http'
import Fastify from 'fastify'
import { RPCHandler } from '@orpc/server/node'
import { CORSPlugin } from '@orpc/server/plugins'
import { RPCHandler } from '@orpc/server/fastify'
import { onError } from '@orpc/server'

const handler = new RPCHandler(router, {
plugins: [
new CORSPlugin()
interceptors: [
onError((error) => {
console.error(error)
})
]
})

const fastify = Fastify({
logger: true,
serverFactory: (fastifyHandler) => {
const server = createServer(async (req, res) => {
const { matched } = await handler.handle(req, res, {
context: {},
prefix: '/rpc',
})
const fastify = Fastify()

if (matched) {
return
}
fastify.addContentTypeParser('*', (request, payload, done) => {
// Fully utilize oRPC feature by allowing any content type
// And let oRPC parse the body manually by passing `undefined`
done(null, undefined)
})

fastifyHandler(req, res)
})
fastify.all('/rpc/*', async (req, reply) => {
const { matched } = await handler.handle(req, reply, {
prefix: '/rpc',
context: {} // Provide initial context if needed
})

return server
},
if (!matched) {
reply.status(404).send('Not found')
}
})

try {
await fastify.listen({ port: 3000 })
}
catch (err) {
fastify.log.error(err)
process.exit(1)
}
fastify.listen({ port: 3000 }).then(() => console.log('Server running on http://localhost:3000'))
```

::: info
Expand Down
28 changes: 28 additions & 0 deletions apps/content/docs/adapters/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ oRPC includes built-in HTTP support, making it easy to expose RPC endpoints in a
| ------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `fetch` | [MDN Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (Browser, Bun, Deno, Cloudflare Workers, etc.) |
| `node` | Node.js built-in [`http`](https://nodejs.org/api/http.html)/[`http2`](https://nodejs.org/api/http2.html) |
| `fastify` | [Fastify](https://fastify.dev/) |
| `aws-lambda` | [AWS Lambda](https://aws.amazon.com/lambda/) |

::: code-group
Expand Down Expand Up @@ -121,6 +122,33 @@ Deno.serve(async (request) => {
})
```

```ts [fastify]
import Fastify from 'fastify'
import { RPCHandler } from '@orpc/server/fastify'

const rpcHandler = new RPCHandler(router)

const fastify = Fastify()

fastify.addContentTypeParser('*', (request, payload, done) => {
// Fully utilize oRPC feature by allowing any content type
// And let oRPC parse the body manually by passing `undefined`
done(null, undefined)
})

fastify.all('/rpc/*', async (req, reply) => {
const { matched } = await rpcHandler.handle(req, reply, {
prefix: '/rpc',
})

if (!matched) {
reply.status(404).send('Not found')
}
})

fastify.listen({ port: 3000 }).then(() => console.log('Listening on 127.0.0.1:3000'))
```

```ts [aws-lambda]
import { APIGatewayProxyEventV2 } from 'aws-lambda'
import { RPCHandler } from '@orpc/server/aws-lambda'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@ description: Seamlessly implement oRPC contracts in your NestJS projects.

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

::: warning
This feature is experimental and may undergo breaking changes.
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)).
:::

## Installation

::: code-group
Expand Down
1 change: 1 addition & 0 deletions apps/content/learn-and-contribute/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Abstracts runtime environments, allowing oRPC adapters to run seamlessly across
- [standard-server](https://github.com/unnoq/orpc/tree/main/packages/standard-server)
- [standard-server-fetch](https://github.com/unnoq/orpc/tree/main/packages/standard-server-fetch)
- [standard-server-node](https://github.com/unnoq/orpc/tree/main/packages/standard-server-node)
- [standard-server-fastify](https://github.com/unnoq/orpc/tree/main/packages/standard-server-fastify)
- [standard-server-aws-lambda](https://github.com/unnoq/orpc/tree/main/packages/standard-server-aws-lambda)
- [standard-server-peer](https://github.com/unnoq/orpc/tree/main/packages/standard-server-peer)

Expand Down
2 changes: 2 additions & 0 deletions packages/nest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@
"@orpc/server": "workspace:*",
"@orpc/shared": "workspace:*",
"@orpc/standard-server": "workspace:*",
"@orpc/standard-server-fastify": "workspace:*",
"@orpc/standard-server-node": "workspace:*"
},
"devDependencies": {
"@fastify/cookie": "^11.0.2",
"@nestjs/common": "^11.1.7",
"@nestjs/core": "^11.1.7",
"@nestjs/platform-express": "^11.1.7",
Expand Down
41 changes: 40 additions & 1 deletion packages/nest/src/implement.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { NodeHttpRequest } from '@orpc/standard-server-node'
import type { Request } from 'express'
import { Controller, Req } from '@nestjs/common'
import type { FastifyReply } from 'fastify'
import FastifyCookie from '@fastify/cookie'
import { Controller, Req, Res } from '@nestjs/common'
import { REQUEST } from '@nestjs/core'
import { FastifyAdapter } from '@nestjs/platform-fastify'
import { Test } from '@nestjs/testing'
Expand Down Expand Up @@ -462,4 +464,41 @@ describe('@Implement', async () => {
eventIteratorKeepAliveComment: '__TEST__',
}))
})

it('work with fastify/cookie', async () => {
@Controller()
class FastifyController {
@Implement(contract.ping)
pong(@Res({ passthrough: true }) reply: FastifyReply) {
reply.cookie('foo', 'bar')
return implement(contract.ping).handler(ping_handler)
}
}

const moduleRef = await Test.createTestingModule({
controllers: [FastifyController],
}).compile()

const adapter = new FastifyAdapter()
await adapter.register(FastifyCookie as any)
const app = moduleRef.createNestApplication(adapter)
await app.init()
await app.getHttpAdapter().getInstance().ready()

const httpServer = app.getHttpServer()

const res = await supertest(httpServer)
.post('/ping?param=value&param2[]=value2&param2[]=value3')
.set('x-custom', 'value')
.send({ hello: 'world' })

expect(res.statusCode).toEqual(200)
expect(res.body).toEqual('pong')
expect(res.headers).toEqual(expect.objectContaining({
'x-ping': 'pong',
'set-cookie': [
expect.stringContaining('foo=bar'),
],
}))
})
})
23 changes: 11 additions & 12 deletions packages/nest/src/implement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { Router } from '@orpc/server'
import type { StandardParams } from '@orpc/server/standard'
import type { Promisable } from '@orpc/shared'
import type { StandardResponse } from '@orpc/standard-server'
import type { NodeHttpRequest, NodeHttpResponse } from '@orpc/standard-server-node'
import type { Request, Response } from 'express'
import type { FastifyReply, FastifyRequest } from 'fastify'
import type { Observable } from 'rxjs'
Expand All @@ -17,7 +16,8 @@ import { StandardOpenAPICodec } from '@orpc/openapi/standard'
import { createProcedureClient, getRouter, isProcedure, ORPCError, unlazy } from '@orpc/server'
import { get } from '@orpc/shared'
import { flattenHeader } from '@orpc/standard-server'
import { sendStandardResponse, toStandardLazyRequest } from '@orpc/standard-server-node'
import * as StandardServerFastify from '@orpc/standard-server-fastify'
import * as StandardServerNode from '@orpc/standard-server-node'
import { mergeMap } from 'rxjs'
import { ORPC_MODULE_CONFIG_SYMBOL } from './module'
import { toNestPattern } from './utils'
Expand Down Expand Up @@ -120,15 +120,9 @@ export class ImplementInterceptor implements NestInterceptor {
const req: Request | FastifyRequest = ctx.switchToHttp().getRequest()
const res: Response | FastifyReply = ctx.switchToHttp().getResponse()

const nodeReq: NodeHttpRequest = 'raw' in req ? req.raw : req
const nodeRes: NodeHttpResponse = 'raw' in res ? res.raw : res

const standardRequest = toStandardLazyRequest(nodeReq, nodeRes)
const fallbackStandardBody = standardRequest.body.bind(standardRequest)
// Prefer NestJS parsed body (in nodejs body only allow parse once)
standardRequest.body = () => Promise.resolve(
req.body === undefined ? fallbackStandardBody() : req.body,
)
const standardRequest = 'raw' in req
? StandardServerFastify.toStandardLazyRequest(req, res as FastifyReply)
: StandardServerNode.toStandardLazyRequest(req, res as Response)

const standardResponse: StandardResponse = await (async () => {
let isDecoding = false
Expand Down Expand Up @@ -159,7 +153,12 @@ export class ImplementInterceptor implements NestInterceptor {
}
})()

await sendStandardResponse(nodeRes, standardResponse, this.config)
if ('raw' in res) {
await StandardServerFastify.sendStandardResponse(res, standardResponse, this.config)
}
else {
await StandardServerNode.sendStandardResponse(res, standardResponse, this.config)
}
}),
)
}
Expand Down
7 changes: 7 additions & 0 deletions packages/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
"import": "./dist/adapters/node/index.mjs",
"default": "./dist/adapters/node/index.mjs"
},
"./fastify": {
"types": "./dist/adapters/fastify/index.d.mts",
"import": "./dist/adapters/fastify/index.mjs",
"default": "./dist/adapters/fastify/index.mjs"
},
"./aws-lambda": {
"types": "./dist/adapters/aws-lambda/index.d.mts",
"import": "./dist/adapters/aws-lambda/index.mjs",
Expand All @@ -53,6 +58,7 @@
"./standard": "./src/adapters/standard/index.ts",
"./fetch": "./src/adapters/fetch/index.ts",
"./node": "./src/adapters/node/index.ts",
"./fastify": "./src/adapters/fastify/index.ts",
"./aws-lambda": "./src/adapters/aws-lambda/index.ts"
},
"files": [
Expand All @@ -74,6 +80,7 @@
"rou3": "^0.7.8"
},
"devDependencies": {
"fastify": "^5.6.1",
"zod": "^4.1.12"
}
}
3 changes: 3 additions & 0 deletions packages/openapi/src/adapters/fastify/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
it('exports OpenAPIHandler', async () => {
expect(Object.keys(await import('./index'))).toContain('OpenAPIHandler')
})
1 change: 1 addition & 0 deletions packages/openapi/src/adapters/fastify/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './openapi-handler'
21 changes: 21 additions & 0 deletions packages/openapi/src/adapters/fastify/openapi-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { os } from '@orpc/server'
import Fastify from 'fastify'
import request from 'supertest'
import { OpenAPIHandler } from './openapi-handler'

describe('openAPIHandler', () => {
it('works', async () => {
const handler = new OpenAPIHandler(os.route({ method: 'GET', path: '/ping' }).handler(({ input }) => ({ output: input })))

const fastify = Fastify()

fastify.all('/*', async (req, reply) => {
await handler.handle(req, reply, { prefix: '/prefix' })
})
await fastify.ready()
const res = await request(fastify.server).get('/prefix/ping?input=hello')

expect(res.text).toContain('hello')
expect(res.status).toBe(200)
})
})
20 changes: 20 additions & 0 deletions packages/openapi/src/adapters/fastify/openapi-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Context, Router } from '@orpc/server'
import type { FastifyHandlerOptions } from '@orpc/server/fastify'
import type { StandardOpenAPIHandlerOptions } from '../standard'
import { FastifyHandler } from '@orpc/server/fastify'
import { StandardOpenAPIHandler } from '../standard'

export interface OpenAPIHandlerOptions<T extends Context> extends FastifyHandlerOptions<T>, StandardOpenAPIHandlerOptions<T> {
}

/**
* OpenAPI Handler for Fastify Server
*
* @see {@link https://orpc.unnoq.com/docs/openapi/openapi-handler OpenAPI Handler Docs}
* @see {@link https://orpc.unnoq.com/docs/adapters/http HTTP Adapter Docs}
*/
export class OpenAPIHandler<T extends Context> extends FastifyHandler<T> {
constructor(router: Router<any, T>, options: NoInfer<OpenAPIHandlerOptions<T>> = {}) {
super(new StandardOpenAPIHandler(router, options), options)
}
}
8 changes: 8 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
"import": "./dist/adapters/node/index.mjs",
"default": "./dist/adapters/node/index.mjs"
},
"./fastify": {
"types": "./dist/adapters/fastify/index.d.mts",
"import": "./dist/adapters/fastify/index.mjs",
"default": "./dist/adapters/fastify/index.mjs"
},
"./aws-lambda": {
"types": "./dist/adapters/aws-lambda/index.d.mts",
"import": "./dist/adapters/aws-lambda/index.mjs",
Expand Down Expand Up @@ -96,6 +101,7 @@
"./standard-peer": "./src/adapters/standard-peer/index.ts",
"./fetch": "./src/adapters/fetch/index.ts",
"./node": "./src/adapters/node/index.ts",
"./fastify": "./src/adapters/fastify/index.ts",
"./aws-lambda": "./src/adapters/aws-lambda/index.ts",
"./websocket": "./src/adapters/websocket/index.ts",
"./crossws": "./src/adapters/crossws/index.ts",
Expand Down Expand Up @@ -130,6 +136,7 @@
"@orpc/shared": "workspace:*",
"@orpc/standard-server": "workspace:*",
"@orpc/standard-server-aws-lambda": "workspace:*",
"@orpc/standard-server-fastify": "workspace:*",
"@orpc/standard-server-fetch": "workspace:*",
"@orpc/standard-server-node": "workspace:*",
"@orpc/standard-server-peer": "workspace:*",
Expand All @@ -139,6 +146,7 @@
"@tanstack/router-core": "^1.133.20",
"@types/ws": "^8.18.1",
"crossws": "^0.4.1",
"fastify": "^5.6.1",
"next": "^15.5.6",
"supertest": "^7.1.4",
"ws": "^8.18.3",
Expand Down
17 changes: 17 additions & 0 deletions packages/server/src/adapters/fastify/handler.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { FastifyReply, FastifyRequest } from '@orpc/standard-server-fastify'
import type { FastifyHandler } from './handler'

describe('FastifyHandler', () => {
it('optional context when all context is optional', () => {
const handler = {} as FastifyHandler<{ auth?: boolean }>

handler.handle({} as FastifyRequest, {} as FastifyReply)
handler.handle({} as FastifyRequest, {} as FastifyReply, { context: { auth: true } })

const handler2 = {} as FastifyHandler<{ auth: boolean }>

handler2.handle({} as FastifyRequest, {} as FastifyReply, { context: { auth: true } })
// @ts-expect-error -- context is required
handler2.handle({} as FastifyRequest, {} as FastifyReply)
})
})
Loading
Loading