From a1136f19fa2f582a06283f49fe3384139baf53f0 Mon Sep 17 00:00:00 2001 From: unnoq Date: Tue, 1 Jul 2025 16:26:10 +0700 Subject: [PATCH 1/2] feat(nest): module configuration --- .../implement-contract-in-nest.md | 29 ++++++++++++++ packages/nest/build.config.ts | 13 +++++++ packages/nest/src/implement.test.ts | 38 ++++++++++++++++++- packages/nest/src/implement.ts | 14 +++++-- packages/nest/src/index.ts | 3 +- packages/nest/src/module.ts | 31 +++++++++++++++ packages/nest/tsconfig.json | 1 + playgrounds/nest/build.config.ts | 18 +++++++++ playgrounds/nest/package.json | 4 +- playgrounds/nest/src/app.module.ts | 12 +++++- 10 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 packages/nest/build.config.ts create mode 100644 packages/nest/src/module.ts create mode 100644 playgrounds/nest/build.config.ts 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 15fc469c2..e50f5a373 100644 --- a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md +++ b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md @@ -220,6 +220,35 @@ async function bootstrap() { oRPC will use NestJS parsed body when it's available, and only use the oRPC parser if the body is not parsed by NestJS. ::: +## Configuration + +Configure the `@orpc/nest` module by importing `ORPCModule` in your NestJS application: + +```ts +import { onError, ORPCModule } from '@orpc/nest' + +@Module({ + imports: [ + ORPCModule.forRoot({ + interceptors: [ + onError((error) => { + console.error(error) + }), + ], + eventIteratorKeepAliveInterval: 5000, // 5 seconds + }), + ], +}) +export class AppModule {} +``` + +::: info + +- **`interceptors`** - [Server-side client interceptors](/docs/client/server-side#lifecycle) for intercepting input, output, and errors. +- **`eventIteratorKeepAliveInterval`** - Keep-alive interval for event streams (see [Event Iterator Keep Alive](/docs/rpc-handler#event-iterator-keep-alive)) + +::: + ## Create a Type-Safe Client When you implement oRPC contracts in NestJS using `@orpc/nest`, the resulting API endpoints are OpenAPI compatible. This allows you to use an OpenAPI-compatible client link, such as [OpenAPILink](/docs/openapi/client/openapi-link), to interact with your API in a type-safe way. diff --git a/packages/nest/build.config.ts b/packages/nest/build.config.ts new file mode 100644 index 000000000..00041941d --- /dev/null +++ b/packages/nest/build.config.ts @@ -0,0 +1,13 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + rollup: { + esbuild: { + tsconfigRaw: { + compilerOptions: { + experimentalDecorators: true, + }, + }, + }, + }, +}) diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts index 9b2dd50ed..a333e2f02 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -4,10 +4,14 @@ import { FastifyAdapter } from '@nestjs/platform-fastify' import { Test } from '@nestjs/testing' import { oc, ORPCError } from '@orpc/contract' import { implement, lazy } from '@orpc/server' +import * as StandardServerNode from '@orpc/standard-server-node' import supertest from 'supertest' -import { it, vi } from 'vitest' +import { expect, it, vi } from 'vitest' import { z } from 'zod' import { Implement } from './implement' +import { ORPCModule } from './module' + +const sendStandardResponseSpy = vi.spyOn(StandardServerNode, 'sendStandardResponse') beforeEach(() => { vi.clearAllMocks() @@ -376,4 +380,36 @@ describe('@Implement', async () => { false, ]) }) + + it('works with ORPCModule.forRoot', async () => { + const interceptor = vi.fn(({ next }) => next()) + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + interceptors: [interceptor], + eventIteratorKeepAliveComment: '__TEST__', + }), + ], + controllers: [ImplProcedureController], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + + 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(interceptor).toHaveBeenCalledTimes(1) + expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) + expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ + eventIteratorKeepAliveComment: '__TEST__', + })) + }) }) diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index daa5848d0..af0a79a97 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -8,7 +8,8 @@ import type { NodeHttpRequest, NodeHttpResponse } from '@orpc/standard-server-no import type { Request, Response } from 'express' import type { FastifyReply, FastifyRequest } from 'fastify' import type { Observable } from 'rxjs' -import { applyDecorators, Delete, Get, Head, Patch, Post, Put, UseInterceptors } from '@nestjs/common' +import type { ORPCModuleConfig } from './module' +import { applyDecorators, Delete, Get, Head, Inject, Injectable, Optional, Patch, Post, Put, UseInterceptors } from '@nestjs/common' import { toORPCError } from '@orpc/client' import { fallbackContractConfig, isContractProcedure } from '@orpc/contract' import { StandardBracketNotationSerializer, StandardOpenAPIJsonSerializer, StandardOpenAPISerializer } from '@orpc/openapi-client/standard' @@ -18,6 +19,7 @@ import { get } from '@orpc/shared' import { flattenHeader } from '@orpc/standard-server' import { sendStandardResponse, toStandardLazyRequest } from '@orpc/standard-server-node' import { mergeMap } from 'rxjs' +import { ORPC_MODULE_CONFIG_SYMBOL } from './module' import { toNestPattern } from './utils' const MethodDecoratorMap = { @@ -97,7 +99,13 @@ const codec = new StandardOpenAPICodec( type NestParams = Record +@Injectable() export class ImplementInterceptor implements NestInterceptor { + constructor( + @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() private readonly config: ORPCModuleConfig | undefined, + ) { + } + intercept(ctx: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( mergeMap(async (impl: unknown) => { @@ -124,7 +132,7 @@ export class ImplementInterceptor implements NestInterceptor { let isDecoding = false try { - const client = createProcedureClient(procedure) + const client = createProcedureClient(procedure, this.config) isDecoding = true const input = await codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure) @@ -149,7 +157,7 @@ export class ImplementInterceptor implements NestInterceptor { } })() - await sendStandardResponse(nodeRes, standardResponse) + await sendStandardResponse(nodeRes, standardResponse, this.config) }), ) } diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index c9f355a1b..1681f48c8 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -1,8 +1,9 @@ export * from './implement' export { Implement as Impl } from './implement' +export * from './module' export * from './utils' -export { implement, ORPCError } from '@orpc/server' +export { implement, onError, onFinish, onStart, onSuccess, ORPCError } from '@orpc/server' export type { ImplementedProcedure, Implementer, diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts new file mode 100644 index 000000000..a220ddd96 --- /dev/null +++ b/packages/nest/src/module.ts @@ -0,0 +1,31 @@ +import type { DynamicModule } from '@nestjs/common' +import type { AnySchema } from '@orpc/contract' +import type { CreateProcedureClientOptions } from '@orpc/server' +import type { SendStandardResponseOptions } from '@orpc/standard-server-node' +import { Module } from '@nestjs/common' +import { ImplementInterceptor } from './implement' + +export const ORPC_MODULE_CONFIG_SYMBOL = Symbol('ORPC_MODULE_CONFIG') + +export interface ORPCModuleConfig extends + CreateProcedureClientOptions, + SendStandardResponseOptions { +} + +@Module({}) +export class ORPCModule { + static forRoot(config: ORPCModuleConfig): DynamicModule { + return { + module: ORPCModule, + providers: [ + { + provide: ORPC_MODULE_CONFIG_SYMBOL, + useValue: config, + }, + ImplementInterceptor, + ], + exports: [ORPC_MODULE_CONFIG_SYMBOL, ImplementInterceptor], + global: true, + } + } +} diff --git a/packages/nest/tsconfig.json b/packages/nest/tsconfig.json index 27a208305..259ffc3bf 100644 --- a/packages/nest/tsconfig.json +++ b/packages/nest/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.lib.json", "compilerOptions": { + "emitDecoratorMetadata": true, "experimentalDecorators": true }, "references": [ diff --git a/playgrounds/nest/build.config.ts b/playgrounds/nest/build.config.ts new file mode 100644 index 000000000..d1fde2f7e --- /dev/null +++ b/playgrounds/nest/build.config.ts @@ -0,0 +1,18 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + entries: [ + { input: 'dist/main.js', outDir: 'dist/unbuild', name: 'main' }, + ], + failOnWarn: false, + clean: false, + rollup: { + esbuild: { + tsconfigRaw: { + compilerOptions: { + experimentalDecorators: true, + }, + }, + }, + }, +}) diff --git a/playgrounds/nest/package.json b/playgrounds/nest/package.json index 11593296b..fc7a67c81 100644 --- a/playgrounds/nest/package.json +++ b/playgrounds/nest/package.json @@ -3,7 +3,7 @@ "version": "1.6.0", "private": true, "scripts": { - "preview": "nest build && tsx dist/main.js", + "preview": "nest build && unbuild --stub && node dist/main.mjs", "start:dev": "nest start --watch", "type:check": "tsc --noEmit" }, @@ -32,8 +32,8 @@ "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "tsx": "^4.20.3", "typescript": "^5.8.3", + "unbuild": "^3.5.0", "zod": "^3.25.67" } } diff --git a/playgrounds/nest/src/app.module.ts b/playgrounds/nest/src/app.module.ts index 572eb5b61..37742fb4c 100644 --- a/playgrounds/nest/src/app.module.ts +++ b/playgrounds/nest/src/app.module.ts @@ -5,9 +5,19 @@ import { OtherController } from './other/other.controller' import { PlanetService } from './planet/planet.service' import { ReferenceController } from './reference/reference.controller' import { ReferenceService } from './reference/reference.service' +import { onError, ORPCModule } from '@orpc/nest' @Module({ - imports: [], + imports: [ + ORPCModule.forRoot({ + interceptors: [ + onError((error) => { + console.error(error) + }), + ], + eventIteratorKeepAliveInterval: 5000, // 5 seconds + }), + ], controllers: [AuthController, PlanetController, ReferenceController, OtherController], providers: [PlanetService, ReferenceService], }) From c944f38fe86970544423e3eb855d7cab26c61c2f Mon Sep 17 00:00:00 2001 From: unnoq Date: Tue, 1 Jul 2025 16:29:05 +0700 Subject: [PATCH 2/2] update pnpm lock --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa230bb78..8189f4fa9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1077,12 +1077,12 @@ importers: tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 - tsx: - specifier: ^4.20.3 - version: 4.20.3 typescript: specifier: ^5.8.3 version: 5.8.3 + unbuild: + specifier: ^3.5.0 + version: 3.5.0(typescript@5.8.3)(vue@3.5.17(typescript@5.8.3)) zod: specifier: ^3.25.67 version: 3.25.67