diff --git a/apps/content/docs/adapters/fastify.md b/apps/content/docs/adapters/fastify.md index bc0cccc61..a5746ffb8 100644 --- a/apps/content/docs/adapters/fastify.md +++ b/apps/content/docs/adapters/fastify.md @@ -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 diff --git a/apps/content/docs/adapters/http.md b/apps/content/docs/adapters/http.md index 64428c04d..ba1929074 100644 --- a/apps/content/docs/adapters/http.md +++ b/apps/content/docs/adapters/http.md @@ -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 @@ -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' diff --git a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md index 9d79f28c4..3fc378e55 100644 --- a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md +++ b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md @@ -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 diff --git a/apps/content/learn-and-contribute/overview.md b/apps/content/learn-and-contribute/overview.md index fd3b4fd09..c95aa2e48 100644 --- a/apps/content/learn-and-contribute/overview.md +++ b/apps/content/learn-and-contribute/overview.md @@ -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) diff --git a/packages/nest/package.json b/packages/nest/package.json index e5790c518..b50ffbe61 100644 --- a/packages/nest/package.json +++ b/packages/nest/package.json @@ -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", diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts index d600c0497..9dd5f9185 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -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' @@ -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¶m2[]=value2¶m2[]=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'), + ], + })) + }) }) diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index 281f8acf5..affef308e 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -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' @@ -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' @@ -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 @@ -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) + } }), ) } diff --git a/packages/openapi/package.json b/packages/openapi/package.json index b490855e2..ea3f0c478 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -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", @@ -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": [ @@ -74,6 +80,7 @@ "rou3": "^0.7.8" }, "devDependencies": { + "fastify": "^5.6.1", "zod": "^4.1.12" } } diff --git a/packages/openapi/src/adapters/fastify/index.test.ts b/packages/openapi/src/adapters/fastify/index.test.ts new file mode 100644 index 000000000..0822e8028 --- /dev/null +++ b/packages/openapi/src/adapters/fastify/index.test.ts @@ -0,0 +1,3 @@ +it('exports OpenAPIHandler', async () => { + expect(Object.keys(await import('./index'))).toContain('OpenAPIHandler') +}) diff --git a/packages/openapi/src/adapters/fastify/index.ts b/packages/openapi/src/adapters/fastify/index.ts new file mode 100644 index 000000000..a2825c287 --- /dev/null +++ b/packages/openapi/src/adapters/fastify/index.ts @@ -0,0 +1 @@ +export * from './openapi-handler' diff --git a/packages/openapi/src/adapters/fastify/openapi-handler.test.ts b/packages/openapi/src/adapters/fastify/openapi-handler.test.ts new file mode 100644 index 000000000..42d1a30c7 --- /dev/null +++ b/packages/openapi/src/adapters/fastify/openapi-handler.test.ts @@ -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) + }) +}) diff --git a/packages/openapi/src/adapters/fastify/openapi-handler.ts b/packages/openapi/src/adapters/fastify/openapi-handler.ts new file mode 100644 index 000000000..2cb534a3d --- /dev/null +++ b/packages/openapi/src/adapters/fastify/openapi-handler.ts @@ -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 extends FastifyHandlerOptions, StandardOpenAPIHandlerOptions { +} + +/** + * 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 extends FastifyHandler { + constructor(router: Router, options: NoInfer> = {}) { + super(new StandardOpenAPIHandler(router, options), options) + } +} diff --git a/packages/server/package.json b/packages/server/package.json index a43ae4342..1f30a735b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", @@ -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", @@ -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:*", @@ -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", diff --git a/packages/server/src/adapters/fastify/handler.test-d.ts b/packages/server/src/adapters/fastify/handler.test-d.ts new file mode 100644 index 000000000..a77a0771f --- /dev/null +++ b/packages/server/src/adapters/fastify/handler.test-d.ts @@ -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) + }) +}) diff --git a/packages/server/src/adapters/fastify/handler.test.ts b/packages/server/src/adapters/fastify/handler.test.ts new file mode 100644 index 000000000..9027c2fc5 --- /dev/null +++ b/packages/server/src/adapters/fastify/handler.test.ts @@ -0,0 +1,133 @@ +import { sendStandardResponse, toStandardLazyRequest } from '@orpc/standard-server-fastify' +import Fastify from 'fastify' +import request from 'supertest' +import { FastifyHandler } from './handler' + +vi.mock('@orpc/standard-server-fastify', () => ({ + toStandardLazyRequest: vi.fn(), + sendStandardResponse: vi.fn(), +})) + +vi.mock('../standard', async origin => ({ + ...await origin(), + StandardHandler: vi.fn(), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('fastifyHandler', async () => { + const handle = vi.fn() + const interceptor = vi.fn(({ next }) => next()) + + const handlerOptions = { eventIteratorKeepAliveComment: '__test__', adapterInterceptors: [interceptor] } + + const handler = new FastifyHandler({ + handle, + } as any, handlerOptions) + + let req: any, reply: any + const fastify = Fastify() + + fastify.get('/api/v1', (_req, _reply) => { + req = _req + reply = _reply + + return 'body' + }) + + await fastify.ready() + await request(fastify.server).get('/api/v1') + + const standardRequest = { + method: 'POST', + url: new URL('https://example.com/api/v1/users/1'), + headers: { + 'content-type': 'application/json', + 'content-length': '12', + }, + body: () => Promise.resolve(JSON.stringify({ name: 'John Doe' })), + signal: undefined, + } + + it('on match', async () => { + vi.mocked(toStandardLazyRequest).mockReturnValueOnce(standardRequest) + handle.mockReturnValueOnce({ + matched: true, + response: { + status: 200, + headers: {}, + body: '__body__', + }, + }) + const options = { prefix: '/api/v1', context: { db: 'postgres' } } as const + + const result = await handler.handle(req, reply, options) + + expect(result).toEqual({ + matched: true, + }) + + expect(handle).toHaveBeenCalledOnce() + expect(handle).toHaveBeenCalledWith( + standardRequest, + { prefix: '/api/v1', context: { db: 'postgres' } }, + ) + + expect(toStandardLazyRequest).toHaveBeenCalledOnce() + expect(toStandardLazyRequest).toHaveBeenCalledWith(req, reply) + + expect(sendStandardResponse).toHaveBeenCalledOnce() + expect(sendStandardResponse).toHaveBeenCalledWith(reply, { + status: 200, + headers: {}, + body: '__body__', + }, handlerOptions) + + expect(interceptor).toHaveBeenCalledOnce() + expect(interceptor).toHaveBeenCalledWith({ + request: req, + reply, + sendStandardResponseOptions: handlerOptions, + ...options, + next: expect.any(Function), + }) + expect(await interceptor.mock.results[0]!.value).toEqual({ + matched: true, + }) + }) + + it('on mismatch', async () => { + vi.mocked(toStandardLazyRequest).mockReturnValueOnce(standardRequest) + handle.mockReturnValueOnce({ matched: false }) + + const options = { prefix: '/api/v1', context: { db: 'postgres' } } as const + const result = await handler.handle(req, reply, options) + + expect(result).toEqual({ matched: false }) + + expect(handle).toHaveBeenCalledOnce() + expect(handle).toHaveBeenCalledWith( + standardRequest, + options, + ) + + expect(toStandardLazyRequest).toHaveBeenCalledOnce() + expect(toStandardLazyRequest).toHaveBeenCalledWith(req, reply) + + expect(sendStandardResponse).not.toHaveBeenCalled() + + expect(interceptor).toHaveBeenCalledOnce() + expect(interceptor).toHaveBeenCalledWith({ + request: req, + reply, + sendStandardResponseOptions: handlerOptions, + ...options, + next: expect.any(Function), + }) + expect(await interceptor.mock.results[0]!.value).toEqual({ + matched: false, + }) + }) +}) diff --git a/packages/server/src/adapters/fastify/handler.ts b/packages/server/src/adapters/fastify/handler.ts new file mode 100644 index 000000000..8eb48888a --- /dev/null +++ b/packages/server/src/adapters/fastify/handler.ts @@ -0,0 +1,62 @@ +import type { Interceptor, MaybeOptionalOptions } from '@orpc/shared' +import type { FastifyReply, FastifyRequest, SendStandardResponseOptions } from '@orpc/standard-server-fastify' +import type { Context } from '../../context' +import type { StandardHandleOptions, StandardHandler } from '../standard' +import type { FriendlyStandardHandleOptions } from '../standard/utils' +import { intercept, resolveMaybeOptionalOptions, toArray } from '@orpc/shared' +import { sendStandardResponse, toStandardLazyRequest } from '@orpc/standard-server-fastify' +import { resolveFriendlyStandardHandleOptions } from '../standard/utils' + +export type FastifyHandleResult = { matched: true } | { matched: false } + +export interface FastifyInterceptorOptions extends StandardHandleOptions { + request: FastifyRequest + reply: FastifyReply + sendStandardResponseOptions: SendStandardResponseOptions +} + +export interface FastifyHandlerOptions extends SendStandardResponseOptions { + adapterInterceptors?: Interceptor, Promise>[] +} + +export class FastifyHandler { + private readonly sendStandardResponseOptions: SendStandardResponseOptions + private readonly adapterInterceptors: Exclude['adapterInterceptors'], undefined> + + constructor( + private readonly standardHandler: StandardHandler, + options: NoInfer> = {}, + ) { + this.adapterInterceptors = toArray(options.adapterInterceptors) + this.sendStandardResponseOptions = options + } + + async handle( + request: FastifyRequest, + reply: FastifyReply, + ...rest: MaybeOptionalOptions> + ): Promise { + return intercept( + this.adapterInterceptors, + { + ...resolveFriendlyStandardHandleOptions(resolveMaybeOptionalOptions(rest)), + request, + reply, + sendStandardResponseOptions: this.sendStandardResponseOptions, + }, + async ({ request, reply, sendStandardResponseOptions, ...options }) => { + const standardRequest = toStandardLazyRequest(request, reply) + + const result = await this.standardHandler.handle(standardRequest, options) + + if (!result.matched) { + return { matched: false } + } + + await sendStandardResponse(reply, result.response, sendStandardResponseOptions) + + return { matched: true } + }, + ) + } +} diff --git a/packages/server/src/adapters/fastify/index.test.ts b/packages/server/src/adapters/fastify/index.test.ts new file mode 100644 index 000000000..e9354bebe --- /dev/null +++ b/packages/server/src/adapters/fastify/index.test.ts @@ -0,0 +1,3 @@ +it('exports RPCHandler', async () => { + expect(Object.keys(await import('./index'))).toContain('RPCHandler') +}) diff --git a/packages/server/src/adapters/fastify/index.ts b/packages/server/src/adapters/fastify/index.ts new file mode 100644 index 000000000..97d3dd64c --- /dev/null +++ b/packages/server/src/adapters/fastify/index.ts @@ -0,0 +1,2 @@ +export * from './handler' +export * from './rpc-handler' diff --git a/packages/server/src/adapters/fastify/rpc-handler.test.ts b/packages/server/src/adapters/fastify/rpc-handler.test.ts new file mode 100644 index 000000000..7f119d04f --- /dev/null +++ b/packages/server/src/adapters/fastify/rpc-handler.test.ts @@ -0,0 +1,38 @@ +import Fastify from 'fastify' +import request from 'supertest' +import { os } from '../../builder' +import { RPCHandler } from './rpc-handler' + +describe('rpcHandler', () => { + const handler = new RPCHandler({ + ping: os.route({ method: 'GET' }).handler(({ input }) => ({ output: input })), + pong: os.handler(({ input }) => ({ output: input })), + }) + + it('works', async () => { + 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?data=%7B%22json%22%3A%22value%22%7D') + + expect(res.text).toContain('value') + expect(res.status).toBe(200) + }) + + it('enable StrictGetMethodPlugin by default', async () => { + const fastify = Fastify() + + fastify.all('/*', async (req, reply) => { + await handler.handle(req, reply) + }) + + await fastify.ready() + const res = await request(fastify.server).get('/pong?data=%7B%22json%22%3A%22value%22%7D') + + expect(res!.status).toEqual(405) + }) +}) diff --git a/packages/server/src/adapters/fastify/rpc-handler.ts b/packages/server/src/adapters/fastify/rpc-handler.ts new file mode 100644 index 000000000..0a1c80395 --- /dev/null +++ b/packages/server/src/adapters/fastify/rpc-handler.ts @@ -0,0 +1,33 @@ +import type { Context } from '../../context' +import type { Router } from '../../router' +import type { StandardRPCHandlerOptions } from '../standard' +import type { FastifyHandlerOptions } from './handler' +import { StrictGetMethodPlugin } from '../../plugins' +import { StandardRPCHandler } from '../standard' +import { FastifyHandler } from './handler' + +export interface RPCHandlerOptions extends FastifyHandlerOptions, StandardRPCHandlerOptions { + /** + * Enables or disables the StrictGetMethodPlugin. + * + * @default true + */ + strictGetMethodPluginEnabled?: boolean +} + +/** + * RPC Handler for Fastify Server + * + * @see {@link https://orpc.unnoq.com/docs/rpc-handler RPC Handler Docs} + * @see {@link https://orpc.unnoq.com/docs/adapters/http HTTP Adapter Docs} + */ +export class RPCHandler extends FastifyHandler { + constructor(router: Router, options: NoInfer> = {}) { + if (options.strictGetMethodPluginEnabled ?? true) { + options.plugins ??= [] + options.plugins.push(new StrictGetMethodPlugin()) + } + + super(new StandardRPCHandler(router, options), options) + } +} diff --git a/packages/server/src/adapters/node/handler.test.ts b/packages/server/src/adapters/node/handler.test.ts index 1d0c26e88..e7b877b6a 100644 --- a/packages/server/src/adapters/node/handler.test.ts +++ b/packages/server/src/adapters/node/handler.test.ts @@ -96,18 +96,12 @@ describe('nodeHttpHandlerOptions', async () => { it('on mismatch', async () => { vi.mocked(toStandardLazyRequest).mockReturnValueOnce(standardRequest) - handle.mockReturnValueOnce({ - matched: false, - response: undefined, - }) + handle.mockReturnValueOnce({ matched: false }) const options = { prefix: '/api/v1', context: { db: 'postgres' } } as const const result = await handler.handle(req, res, options) - expect(result).toEqual({ - matched: false, - response: undefined, - }) + expect(result).toEqual({ matched: false }) expect(handle).toHaveBeenCalledOnce() expect(handle).toHaveBeenCalledWith( diff --git a/packages/server/src/adapters/node/handler.ts b/packages/server/src/adapters/node/handler.ts index 4c330d0bd..c599b949b 100644 --- a/packages/server/src/adapters/node/handler.ts +++ b/packages/server/src/adapters/node/handler.ts @@ -23,7 +23,7 @@ export interface NodeHttpHandlerOptions extends SendStandardR plugins?: NodeHttpHandlerPlugin[] } -export class NodeHttpHandler implements NodeHttpHandler { +export class NodeHttpHandler { private readonly sendStandardResponseOptions: SendStandardResponseOptions private readonly adapterInterceptors: Exclude['adapterInterceptors'], undefined> diff --git a/packages/standard-server-fastify/.gitignore b/packages/standard-server-fastify/.gitignore new file mode 100644 index 000000000..f3620b55e --- /dev/null +++ b/packages/standard-server-fastify/.gitignore @@ -0,0 +1,26 @@ +# Hidden folders and files +.* +!.gitignore +!.*.example + +# Common generated folders +logs/ +node_modules/ +out/ +dist/ +dist-ssr/ +build/ +coverage/ +temp/ + +# Common generated files +*.log +*.log.* +*.tsbuildinfo +*.vitest-temp.json +vite.config.ts.timestamp-* +vitest.config.ts.timestamp-* + +# Common manual ignore files +*.local +*.pem \ No newline at end of file diff --git a/packages/standard-server-fastify/README.md b/packages/standard-server-fastify/README.md new file mode 100644 index 000000000..3dcc027d8 --- /dev/null +++ b/packages/standard-server-fastify/README.md @@ -0,0 +1,81 @@ +
+ oRPC logo +
+ +

+ + + +

Typesafe APIs Made Simple 🪄

+ +**oRPC is a powerful combination of RPC and OpenAPI**, makes it easy to build APIs that are end-to-end type-safe and adhere to OpenAPI standards + +--- + +## Highlights + +- **🔗 End-to-End Type Safety**: Ensure type-safe inputs, outputs, and errors from client to server. +- **📘 First-Class OpenAPI**: Built-in support that fully adheres to the OpenAPI standard. +- **📝 Contract-First Development**: Optionally define your API contract before implementation. +- **🔍 First-Class OpenTelemetry**: Seamlessly integrate with OpenTelemetry for observability. +- **⚙️ Framework Integrations**: Seamlessly integrate with TanStack Query (React, Vue, Solid, Svelte, Angular), SWR, Pinia Colada, and more. +- **🚀 Server Actions**: Fully compatible with React Server Actions on Next.js, TanStack Start, and other platforms. +- **🔠 Standard Schema Support**: Works out of the box with Zod, Valibot, ArkType, and other schema validators. +- **🗃️ Native Types**: Supports native types like Date, File, Blob, BigInt, URL, and more. +- **⏱️ Lazy Router**: Enhance cold start times with our lazy routing feature. +- **📡 SSE & Streaming**: Enjoy full type-safe support for SSE and streaming. +- **🌍 Multi-Runtime Support**: Fast and lightweight on Cloudflare, Deno, Bun, Node.js, and beyond. +- **🔌 Extendability**: Easily extend functionality with plugins, middleware, and interceptors. + +## Documentation + +You can find the full documentation [here](https://orpc.unnoq.com). + +## Packages + +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Build your API contract. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build your API or implement API contract. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume your API on the client with type-safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Generate OpenAPI specs and handle OpenAPI requests. +- [@orpc/otel](https://www.npmjs.com/package/@orpc/otel): [OpenTelemetry](https://opentelemetry.io/) integration for observability. +- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Deeply integrate oRPC with [NestJS](https://nestjs.com/). +- [@orpc/react](https://www.npmjs.com/package/@orpc/react): Utilities for integrating oRPC with React and React Server Actions. +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): [TanStack Query](https://tanstack.com/query/latest) integration. +- [@orpc/experimental-react-swr](https://www.npmjs.com/package/@orpc/experimental-react-swr): [SWR](https://swr.vercel.app/) integration. +- [@orpc/vue-colada](https://www.npmjs.com/package/@orpc/vue-colada): Integration with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/hey-api](https://www.npmjs.com/package/@orpc/hey-api): [Hey API](https://heyapi.dev/) integration. +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): More schemas that [Zod](https://zod.dev/) doesn't support yet. +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): OpenAPI spec generation from [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): OpenAPI spec generation from [ArkType](https://arktype.io/). + +## `@orpc/standard-server-fastify` + +[Fastify](https://fastify.dev/) server adapter for the [oRPC](https://orpc.unnoq.com). + +## Sponsors + +

+ + + +

+ +## License + +Distributed under the MIT License. See [LICENSE](https://github.com/unnoq/orpc/blob/main/LICENSE) for more information. diff --git a/packages/standard-server-fastify/package.json b/packages/standard-server-fastify/package.json new file mode 100644 index 000000000..fb6cb78f4 --- /dev/null +++ b/packages/standard-server-fastify/package.json @@ -0,0 +1,56 @@ +{ + "name": "@orpc/standard-server-fastify", + "type": "module", + "version": "0.0.0", + "license": "MIT", + "homepage": "https://orpc.unnoq.com", + "repository": { + "type": "git", + "url": "git+https://github.com/unnoq/orpc.git", + "directory": "packages/standard-server-fastify" + }, + "keywords": [ + "orpc", + "fastify" + ], + "publishConfig": { + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + } + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "build:watch": "pnpm run build --watch", + "type:check": "tsc -b" + }, + "peerDependencies": { + "fastify": ">=5.6.1" + }, + "peerDependenciesMeta": { + "fastify": { + "optional": true + } + }, + "dependencies": { + "@orpc/shared": "workspace:*", + "@orpc/standard-server": "workspace:*", + "@orpc/standard-server-node": "workspace:*" + }, + "devDependencies": { + "@fastify/cookie": "^11.0.2", + "@types/node": "^22.15.30", + "@types/supertest": "^6.0.3", + "fastify": "^5.6.1", + "supertest": "^7.1.4" + } +} diff --git a/packages/standard-server-fastify/src/index.test.ts b/packages/standard-server-fastify/src/index.test.ts new file mode 100644 index 000000000..dc436ef5d --- /dev/null +++ b/packages/standard-server-fastify/src/index.test.ts @@ -0,0 +1,3 @@ +it('exports toStandardLazyRequest', async () => { + expect(Object.keys(await import('./index'))).toContain('toStandardLazyRequest') +}) diff --git a/packages/standard-server-fastify/src/index.ts b/packages/standard-server-fastify/src/index.ts new file mode 100644 index 000000000..1a217db2d --- /dev/null +++ b/packages/standard-server-fastify/src/index.ts @@ -0,0 +1,4 @@ +export * from './request' +export * from './response' + +export type { FastifyReply, FastifyRequest } from 'fastify' diff --git a/packages/standard-server-fastify/src/request.test.ts b/packages/standard-server-fastify/src/request.test.ts new file mode 100644 index 000000000..80b938cf4 --- /dev/null +++ b/packages/standard-server-fastify/src/request.test.ts @@ -0,0 +1,79 @@ +import type { StandardLazyRequest } from '@orpc/standard-server' +import * as StandardServerNode from '@orpc/standard-server-node' +import Fastify from 'fastify' +import request from 'supertest' +import { toStandardLazyRequest } from './request' + +const toStandardBodySpy = vi.spyOn(StandardServerNode, 'toStandardBody') +const toStandardMethodSpy = vi.spyOn(StandardServerNode, 'toStandardMethod') +const toStandardUrlSpy = vi.spyOn(StandardServerNode, 'toStandardUrl') +const toAbortSignalSpy = vi.spyOn(StandardServerNode, 'toAbortSignal') + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('toStandardLazyRequest', () => { + it('works & prefer fastify parsed body', async ({ onTestFinished }) => { + let fastifyReq: any + let fastifyReply: any + let standardRequest: any + let standardBody: any + + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + fastify.all('/', async (req, reply) => { + fastifyReq = req + fastifyReply = reply + standardRequest = toStandardLazyRequest(req, reply) + standardBody = await standardRequest.body() + }) + + await fastify.ready() + // fastify has its own json body parser + await request(fastify.server).post('/').send({ foo: 'bar' }) + + expect(toStandardBodySpy).toBeCalledTimes(0) + expect(standardBody).toEqual({ foo: 'bar' }) + + expect(standardRequest.headers).toBe(fastifyReq.raw.headers) + + expect(toAbortSignalSpy).toBeCalledTimes(1) + expect(toAbortSignalSpy).toBeCalledWith(fastifyReply.raw) + expect(standardRequest.signal).toBe(toAbortSignalSpy.mock.results[0]!.value) + + expect(toStandardMethodSpy).toBeCalledTimes(1) + expect(toStandardMethodSpy).toBeCalledWith(fastifyReq.raw.method) + expect(standardRequest.method).toBe(toStandardMethodSpy.mock.results[0]!.value) + + expect(toStandardUrlSpy).toBeCalledTimes(1) + expect(toStandardUrlSpy).toBeCalledWith(fastifyReq.raw) + expect(standardRequest.url).toBe(toStandardUrlSpy.mock.results[0]!.value) + }) + + it('fallback to standard body parser', async ({ onTestFinished }) => { + let standardRequest: StandardLazyRequest + let standardBody: any + + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + // allow any content type + fastify.addContentTypeParser('*', (request, payload, done) => { + done(null, undefined) + }) + + fastify.all('/', async (req, reply) => { + standardRequest = toStandardLazyRequest(req, reply) + standardBody = await standardRequest.body() + }) + + await fastify.ready() + await request(fastify.server).post('/').field('foo', 'bar') + + const expectedBody = new FormData() + expectedBody.append('foo', 'bar') + expect(standardBody).toEqual(expectedBody) + }) +}) diff --git a/packages/standard-server-fastify/src/request.ts b/packages/standard-server-fastify/src/request.ts new file mode 100644 index 000000000..62506fda3 --- /dev/null +++ b/packages/standard-server-fastify/src/request.ts @@ -0,0 +1,26 @@ +import type { StandardLazyRequest } from '@orpc/standard-server' +import type { FastifyReply, FastifyRequest } from 'fastify' +import { once } from '@orpc/shared' +import { toAbortSignal, toStandardBody, toStandardMethod, toStandardUrl } from '@orpc/standard-server-node' + +export function toStandardLazyRequest( + req: FastifyRequest, + reply: FastifyReply, +): StandardLazyRequest { + const signal = toAbortSignal(reply.raw) + + return { + method: toStandardMethod(req.raw.method), + url: toStandardUrl(req.raw), + headers: req.headers, + body: once(async () => { + // prefer fastify parsed body + if (req.body !== undefined) { + return req.body + } + + return toStandardBody(req.raw, { signal }) + }), + signal, + } +} diff --git a/packages/standard-server-fastify/src/response.test.ts b/packages/standard-server-fastify/src/response.test.ts new file mode 100644 index 000000000..b13db165e --- /dev/null +++ b/packages/standard-server-fastify/src/response.test.ts @@ -0,0 +1,280 @@ +import { Readable } from 'node:stream' +import FastifyCookie from '@fastify/cookie' +import * as StandardServerNode from '@orpc/standard-server-node' +import Fastify from 'fastify' +import request from 'supertest' +import { sendStandardResponse } from './response' + +const toNodeHttpBodySpy = vi.spyOn(StandardServerNode, 'toNodeHttpBody') + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('sendStandardResponse', () => { + it('chunked (empty)', async ({ onTestFinished }) => { + let sendSpy: any + + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + const options = { eventIteratorKeepAliveEnabled: true } + fastify.get('/', async (req, reply) => { + sendSpy = vi.spyOn(reply, 'send') + + await sendStandardResponse(reply, { + status: 207, + headers: { + 'x-custom-header': 'custom-value', + }, + body: undefined, + }, options) + }) + + await fastify.ready() + const res = await request(fastify.server).get('/') + + expect(toNodeHttpBodySpy).toBeCalledTimes(1) + expect(toNodeHttpBodySpy).toBeCalledWith(undefined, { + 'x-custom-header': 'custom-value', + }, options) + + expect(sendSpy).toBeCalledTimes(1) + expect(sendSpy).toBeCalledWith(undefined) + + expect(res.status).toBe(207) + expect(res.headers['content-type']).toEqual(undefined) + expect(res.headers['x-custom-header']).toEqual('custom-value') + + expect(res.text).toEqual('') + }) + + it('chunked', async ({ onTestFinished }) => { + let sendSpy: any + + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + const options = { eventIteratorKeepAliveEnabled: true } + fastify.get('/', async (req, reply) => { + sendSpy = vi.spyOn(reply, 'send') + + await sendStandardResponse(reply, { + status: 207, + headers: { + 'x-custom-header': 'custom-value', + }, + body: { foo: 'bar' }, + }, options) + }) + + await fastify.ready() + const res = await request(fastify.server).get('/') + + expect(toNodeHttpBodySpy).toBeCalledTimes(1) + expect(toNodeHttpBodySpy).toBeCalledWith({ foo: 'bar' }, { + 'content-type': 'application/json', + 'x-custom-header': 'custom-value', + }, options) + + expect(sendSpy).toBeCalledTimes(1) + expect(sendSpy).toBeCalledWith(toNodeHttpBodySpy.mock.results[0]!.value) + + expect(res.status).toBe(207) + expect(res.headers).toMatchObject({ + 'content-type': 'application/json; charset=utf-8', + 'x-custom-header': 'custom-value', + }) + + expect(res.body).toEqual({ foo: 'bar' }) + }) + + it('stream (file)', async ({ onTestFinished }) => { + const blob = new Blob(['foo'], { type: 'text/plain' }) + let sendSpy: any + + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + const options = { eventIteratorKeepAliveEnabled: true } + fastify.get('/', async (req, reply) => { + sendSpy = vi.spyOn(reply, 'send') + + await sendStandardResponse(reply, { + status: 207, + headers: { + 'x-custom-header': 'custom-value', + }, + body: blob, + }, options) + }) + + await fastify.ready() + const res = await request(fastify.server).get('/') + + expect(toNodeHttpBodySpy).toBeCalledTimes(1) + expect(toNodeHttpBodySpy).toBeCalledWith(blob, { + 'content-disposition': 'inline; filename="blob"; filename*=utf-8\'\'blob', + 'content-length': '3', + 'content-type': 'text/plain', + 'x-custom-header': 'custom-value', + }, options) + + expect(sendSpy).toBeCalledTimes(1) + expect(sendSpy).toBeCalledWith(toNodeHttpBodySpy.mock.results[0]!.value) + + expect(res.status).toBe(207) + expect(res.headers).toMatchObject({ + 'content-disposition': 'inline; filename="blob"; filename*=utf-8\'\'blob', + 'content-length': '3', + 'content-type': 'text/plain', + 'x-custom-header': 'custom-value', + }) + + expect(res.text).toEqual('foo') + }) + + it('stream (async generator)', async ({ onTestFinished }) => { + async function* gen() { + yield 'foo' + yield 'bar' + return 'baz' + } + + const generator = gen() + + let sendSpy: any + + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + const options = { eventIteratorKeepAliveEnabled: true } + fastify.get('/', async (req, reply) => { + sendSpy = vi.spyOn(reply, 'send') + + await sendStandardResponse(reply, { + status: 207, + headers: { + 'x-custom-header': 'custom-value', + }, + body: generator, + }, options) + }) + + await fastify.ready() + const res = await request(fastify.server).get('/') + + expect(toNodeHttpBodySpy).toBeCalledTimes(1) + expect(toNodeHttpBodySpy).toBeCalledWith(generator, { + 'content-type': 'text/event-stream', + 'x-custom-header': 'custom-value', + }, options) + + expect(sendSpy).toBeCalledTimes(1) + expect(sendSpy).toBeCalledWith(toNodeHttpBodySpy.mock.results[0]!.value) + + expect(res.status).toBe(207) + expect(res.headers).toMatchObject({ + 'content-type': 'text/event-stream', + 'x-custom-header': 'custom-value', + }) + + expect(res.text).toEqual('event: message\ndata: "foo"\n\nevent: message\ndata: "bar"\n\nevent: done\ndata: "baz"\n\n') + }) + + describe('edge case', () => { + it('error while pulling stream', async ({ onTestFinished }) => { + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + toNodeHttpBodySpy.mockReturnValueOnce(Readable.fromWeb(new ReadableStream({ + async pull(controller) { + controller.enqueue(new TextEncoder().encode('foo')) + await new Promise(r => setTimeout(r, 100)) + controller.error(new Error('foo')) + }, + }))) + + const options = { eventIteratorKeepAliveEnabled: true } + fastify.get('/', async (req, reply) => { + await sendStandardResponse(reply, { + status: 207, + headers: { + 'x-custom-header': 'custom-value', + }, + async* body() { }, + }, options) + }) + + await fastify.ready() + await expect(request(fastify.server).get('/')).rejects.toThrow() + }) + + it('request aborted while pulling stream', async ({ onTestFinished }) => { + const cancelMock = vi.fn() + + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + toNodeHttpBodySpy.mockReturnValueOnce(Readable.fromWeb(new ReadableStream({ + async pull(controller) { + controller.enqueue(new TextEncoder().encode('foo')) + await new Promise(r => setTimeout(r, 100)) + }, + cancel: cancelMock, + }))) + + const options = { eventIteratorKeepAliveEnabled: true } + fastify.get('/', async (req, reply) => { + await sendStandardResponse(reply, { + status: 207, + headers: { + 'x-custom-header': 'custom-value', + }, + async* body() { }, + }, options) + }) + + await fastify.ready() + const res = request(fastify.server).get('/') + + setTimeout(() => { + res.abort() + }, 100) + + await expect(res).rejects.toThrow() + + await vi.waitFor(() => { + expect(cancelMock).toHaveBeenCalledTimes(1) + }) + }) + + it('work with @fastify/cookie', async ({ onTestFinished }) => { + const fastify = Fastify() + onTestFinished(() => fastify.close()) + + await fastify.register(FastifyCookie) + + fastify.get('/', async (req, reply) => { + reply.cookie('foo', 'bar') + await sendStandardResponse(reply, { + status: 207, + headers: {}, + body: { foo: 'bar' }, + }) + }) + + await fastify.ready() + const res = await request(fastify.server).get('/') + + expect(res.headers).toMatchObject({ + 'content-type': 'application/json; charset=utf-8', + 'set-cookie': [ + expect.stringContaining('foo=bar'), + ], + }) + + expect(res.text).toEqual(JSON.stringify({ foo: 'bar' })) + }) + }) +}) diff --git a/packages/standard-server-fastify/src/response.ts b/packages/standard-server-fastify/src/response.ts new file mode 100644 index 000000000..eaeaea8d1 --- /dev/null +++ b/packages/standard-server-fastify/src/response.ts @@ -0,0 +1,25 @@ +import type { StandardHeaders, StandardResponse } from '@orpc/standard-server' +import type { ToNodeHttpBodyOptions } from '@orpc/standard-server-node' +import type { FastifyReply } from 'fastify' +import { toNodeHttpBody } from '@orpc/standard-server-node' + +export interface SendStandardResponseOptions extends ToNodeHttpBodyOptions { } + +export function sendStandardResponse( + reply: FastifyReply, + standardResponse: StandardResponse, + options: SendStandardResponseOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + reply.raw.once('error', reject) + reply.raw.once('close', resolve) + + const resHeaders: StandardHeaders = { ...standardResponse.headers } + + const resBody = toNodeHttpBody(standardResponse.body, resHeaders, options) + + reply.status(standardResponse.status) + reply.headers(resHeaders) + reply.send(resBody) + }) +} diff --git a/packages/standard-server-fastify/tsconfig.json b/packages/standard-server-fastify/tsconfig.json new file mode 100644 index 000000000..c8cddf895 --- /dev/null +++ b/packages/standard-server-fastify/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"] + }, + "references": [ + { "path": "../standard-server-node" } + ], + "include": ["src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/*.bench.*", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/packages/standard-server-node/src/response.test.ts b/packages/standard-server-node/src/response.test.ts index 4c86368a4..19bbe4d2c 100644 --- a/packages/standard-server-node/src/response.test.ts +++ b/packages/standard-server-node/src/response.test.ts @@ -38,9 +38,8 @@ describe('sendStandardResponse', () => { expect(endSpy).toBeCalledWith() expect(res.status).toBe(207) - expect(res.headers).toMatchObject({ - 'x-custom-header': 'custom-value', - }) + expect(res.headers['content-type']).toEqual(undefined) + expect(res.headers['x-custom-header']).toEqual('custom-value') expect(res.text).toEqual('') }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 748ace75a..f9f74396c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,10 +396,16 @@ importers: '@orpc/standard-server': specifier: workspace:* version: link:../standard-server + '@orpc/standard-server-fastify': + specifier: workspace:* + version: link:../standard-server-fastify '@orpc/standard-server-node': specifier: workspace:* version: link:../standard-server-node devDependencies: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 '@nestjs/common': specifier: ^11.1.7 version: 11.1.7(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -464,6 +470,9 @@ importers: specifier: ^0.7.8 version: 0.7.8 devDependencies: + fastify: + specifier: ^5.6.1 + version: 5.6.1 zod: specifier: ^4.1.12 version: 4.1.12 @@ -599,6 +608,9 @@ importers: '@orpc/standard-server-aws-lambda': specifier: workspace:* version: link:../standard-server-aws-lambda + '@orpc/standard-server-fastify': + specifier: workspace:* + version: link:../standard-server-fastify '@orpc/standard-server-fetch': specifier: workspace:* version: link:../standard-server-fetch @@ -621,6 +633,9 @@ importers: crossws: specifier: ^0.4.1 version: 0.4.1(srvx@0.8.16) + fastify: + specifier: ^5.6.1 + version: 5.6.1 next: specifier: ^15.5.6 version: 15.5.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -706,6 +721,34 @@ importers: specifier: ^22.15.30 version: 22.18.11 + packages/standard-server-fastify: + dependencies: + '@orpc/shared': + specifier: workspace:* + version: link:../shared + '@orpc/standard-server': + specifier: workspace:* + version: link:../standard-server + '@orpc/standard-server-node': + specifier: workspace:* + version: link:../standard-server-node + devDependencies: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@types/node': + specifier: ^22.15.30 + version: 22.18.11 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + fastify: + specifier: ^5.6.1 + version: 5.6.1 + supertest: + specifier: ^7.1.4 + version: 7.1.4 + packages/standard-server-fetch: dependencies: '@orpc/shared': @@ -2916,6 +2959,9 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/cors@11.1.0': resolution: {integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==} @@ -15977,6 +16023,11 @@ snapshots: '@fastify/busboy@2.1.1': {} + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.0.2 + fastify-plugin: 5.1.0 + '@fastify/cors@11.1.0': dependencies: fastify-plugin: 5.1.0