From 315ecccec44dba3f7d7684d96c4b98b1853515b6 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 27 Nov 2025 16:43:26 +0700 Subject: [PATCH 1/8] feat(nest): custom send response behavior --- .../implement-contract-in-nest.md | 46 ++- packages/nest/src/implement.test.ts | 281 ++++++++++++------ packages/nest/src/implement.ts | 42 ++- packages/nest/src/module.ts | 11 +- 4 files changed, 266 insertions(+), 114 deletions(-) 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 3fc378e55..d99a99bce 100644 --- a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md +++ b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md @@ -226,7 +226,7 @@ import { Request } from 'express' // if you use express adapter @Module({ imports: [ - ORPCModule.forRootAsync({ // or .forRoot + ORPCModule.forRootAsync({ // or use .forRoot for static config useFactory: (request: Request) => ({ interceptors: [ onError((error) => { @@ -235,6 +235,7 @@ import { Request } from 'express' // if you use express adapter ], context: { request }, // oRPC context, accessible from middlewares, etc. eventIteratorKeepAliveInterval: 5000, // 5 seconds + customJsonSerializers: [], }), inject: [REQUEST], }), @@ -274,3 +275,46 @@ const client: JsonifiedClient> = createORP ::: info Please refer to the [OpenAPILink](/docs/openapi/client/openapi-link) documentation for more information on client setup and options. ::: + +## Advanced + +### Custom Send Response + +By default, oRPC sends the response directly without returning it to the NestJS handler. However, you may want to preserve the return behavior for compatibility with certain NestJS features or third-party libraries. + +```ts +import { ORPCModule } from '@orpc/nest' +import { Request } from 'express' // if you use express adapter +import { isObject } from '@orpc/shared' // checks if value is a plain object (not a class instance) + +@Module({ + imports: [ + ORPCModule.forRoot({ + sendResponseInterceptors: [ + async ({ response, standardResponse, next }) => { + if ( + standardResponse.status < 200 + || standardResponse.status >= 300 + || !(isObject(standardResponse.body) || Array.isArray(standardResponse.body)) + ) { + // Only object and array is valid to return as response body + // the rest should fallback to default oRPC behavior + return next() + } + + const expressResponse = response as Response + expressResponse.status(standardResponse.status) + for (const [key, value] of Object.entries(standardResponse.headers)) { + if (value !== undefined) { + expressResponse.setHeader(key, value) + } + } + + return standardResponse.body + }, + ], + }), + ], +}) +export class AppModule {} +``` diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts index 9dd5f9185..99968ee79 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -10,7 +10,7 @@ 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 { expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import * as z from 'zod' import { Implement } from './implement' import { ORPCModule } from './module' @@ -48,6 +48,7 @@ describe('@Implement', async () => { peng: oc.route({ path: '/{+path}', method: 'DELETE', + successStatus: 202, }).input(z.object({ path: z.string(), })), @@ -198,7 +199,7 @@ describe('@Implement', async () => { it('case: call peng', async () => { const res = await supertest(httpServer).delete('/world/who%3F') - expect(res.statusCode).toEqual(200) + expect(res.statusCode).toEqual(202) expect(res.body).toEqual('peng world/who?') expect(peng_handler).toHaveBeenCalledTimes(1) @@ -212,6 +213,17 @@ describe('@Implement', async () => { expect(req!.method).toEqual('DELETE') expect(req!.url).toEqual('/world/who%3F') }) + + it('support dynamic success status', async () => { + ping_handler.mockResolvedValueOnce({ body: 'pong', headers: { 'x-ping': 'pong' }, status: 203 } as any) + + const res = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) + + expect(res.statusCode).toEqual(203) + }) }) it('can avoid conflict method name', async () => { @@ -385,120 +397,197 @@ describe('@Implement', async () => { ]) }) - 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() + describe('module configuration', () => { + it('works with ORPCModule.forRoot', async () => { + const interceptor = vi.fn(({ next }) => next()) + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + interceptors: [interceptor], + eventIteratorKeepAliveComment: '__TEST__', + customJsonSerializers: [ + { + condition: data => data === 'pong', + serialize: () => '__PONG__', + }, + ], + }), + ], + controllers: [ImplProcedureController], + }).compile() - const app = moduleRef.createNestApplication() - await app.init() + const app = moduleRef.createNestApplication() + await app.init() - const httpServer = app.getHttpServer() + const httpServer = app.getHttpServer() - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) + 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.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__', - })) - }) + expect(interceptor).toHaveBeenCalledTimes(1) + expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) + expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ + eventIteratorKeepAliveComment: '__TEST__', + })) + }) - it('works with ORPCModule.forRootAsync', async () => { - const interceptor = vi.fn(({ next }) => next()) - const moduleRef = await Test.createTestingModule({ - imports: [ - ORPCModule.forRootAsync({ - useFactory: async (request: Request) => ({ - interceptors: [interceptor], - eventIteratorKeepAliveComment: '__TEST__', - context: { - request, - }, + it('works with ORPCModule.forRootAsync', async () => { + const interceptor = vi.fn(({ next }) => next()) + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRootAsync({ + useFactory: async (request: Request) => ({ + interceptors: [interceptor], + eventIteratorKeepAliveComment: '__TEST__', + context: { + request, + }, + customJsonSerializers: [ + { + condition: data => data === 'pong', + serialize: () => '__PONG__', + }, + ], + }), + inject: [REQUEST], }), - inject: [REQUEST], - }), - ], - controllers: [ImplProcedureController], - }).compile() + ], + controllers: [ImplProcedureController], + }).compile() - const app = moduleRef.createNestApplication() - await app.init() - - const httpServer = app.getHttpServer() + const app = moduleRef.createNestApplication() + await app.init() - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) + const httpServer = app.getHttpServer() - expect(res.statusCode).toEqual(200) - expect(res.body).toEqual('pong') + const res = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) - expect(interceptor).toHaveBeenCalledTimes(1) - expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ - context: expect.objectContaining({ - request: expect.objectContaining({ - url: '/ping?param=value¶m2[]=value2¶m2[]=value3', - headers: expect.objectContaining({ - 'x-custom': 'value', + expect(res.statusCode).toEqual(200) + expect(res.body).toEqual('__PONG__') + + expect(interceptor).toHaveBeenCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + request: expect.objectContaining({ + url: '/ping?param=value¶m2[]=value2¶m2[]=value3', + headers: expect.objectContaining({ + 'x-custom': 'value', + }), }), }), - }), - })) - expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) - expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ - eventIteratorKeepAliveComment: '__TEST__', - })) + })) + expect(sendStandardResponseSpy).toHaveBeenCalledTimes(1) + expect(sendStandardResponseSpy).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ + eventIteratorKeepAliveComment: '__TEST__', + })) + }) + + describe('sendResponseInterceptors', () => { + it('can override response with default status', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + sendResponseInterceptors: [ + async ({ standardResponse }) => { + expect(standardResponse.status).toBe(202) + + return { custom: true } + }, + ], + }), + ], + controllers: [ImplProcedureController], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + + const httpServer = app.getHttpServer() + + const res = await supertest(httpServer).delete('/world/who%3F') + + expect(res.statusCode).toEqual(202) + expect(res.text).toEqual('{"custom":true}') + }) + + it('can override response with any status', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + sendResponseInterceptors: [ + async ({ standardResponse, response }) => { + expect(standardResponse.status).toBe(200) + expect(standardResponse.body).toBe('pong') + + response.status(202) + return { custom: true } + }, + ], + }), + ], + 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(202) + expect(res.text).toEqual('{"custom":true}') + }) + }) }) - 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) + describe('compatibility', () => { + 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 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 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 httpServer = app.getHttpServer() - const res = await supertest(httpServer) - .post('/ping?param=value¶m2[]=value2¶m2[]=value3') - .set('x-custom', 'value') - .send({ hello: 'world' }) + 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'), - ], - })) + 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 0bc923fbe..c057079fa 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -8,13 +8,13 @@ import type { Request, Response } from 'express' import type { FastifyReply, FastifyRequest } from 'fastify' import type { Observable } from 'rxjs' import type { ORPCModuleConfig } from './module' -import { applyDecorators, Delete, Get, Head, Inject, Injectable, Optional, Patch, Post, Put, UseInterceptors } from '@nestjs/common' +import { applyDecorators, Delete, Get, Head, HttpCode, 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' import { StandardOpenAPICodec } from '@orpc/openapi/standard' import { createProcedureClient, getRouter, isProcedure, ORPCError, unlazy } from '@orpc/server' -import { get } from '@orpc/shared' +import { get, intercept, toArray } from '@orpc/shared' import { flattenHeader } from '@orpc/standard-server' import * as StandardServerFastify from '@orpc/standard-server-fastify' import * as StandardServerNode from '@orpc/standard-server-node' @@ -58,6 +58,7 @@ export function Implement>( return (target, propertyKey, descriptor) => { applyDecorators( MethodDecoratorMap[method](toNestPattern(path)), + HttpCode(fallbackContractConfig('defaultSuccessStatus', contract['~orpc'].route.successStatus)), UseInterceptors(ImplementInterceptor), )(target, propertyKey, descriptor) } @@ -90,23 +91,26 @@ export function Implement>( } } -const codec = new StandardOpenAPICodec( - new StandardOpenAPISerializer( - new StandardOpenAPIJsonSerializer(), - new StandardBracketNotationSerializer(), - ), -) - type NestParams = Record @Injectable() export class ImplementInterceptor implements NestInterceptor { + private readonly config: ORPCModuleConfig constructor( - @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() private readonly config: ORPCModuleConfig | undefined, + @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() config: ORPCModuleConfig | undefined, ) { + // @Optional() does not allow set default value so we need to do it here + this.config = config ?? {} } intercept(ctx: ExecutionContext, next: CallHandler): Observable { + const codec = new StandardOpenAPICodec( + new StandardOpenAPISerializer( + new StandardOpenAPIJsonSerializer(this.config), + new StandardBracketNotationSerializer(this.config), + ), + ) + return next.handle().pipe( mergeMap(async (impl: unknown) => { const { default: procedure } = await unlazy(impl) @@ -153,12 +157,18 @@ export class ImplementInterceptor implements NestInterceptor { } })() - if ('raw' in res) { - await StandardServerFastify.sendStandardResponse(res, standardResponse, this.config) - } - else { - await StandardServerNode.sendStandardResponse(res, standardResponse, this.config) - } + return intercept( + toArray(this.config.sendResponseInterceptors), + { request: req, response: res, standardResponse }, + async ({ response, standardResponse }) => { + if ('raw' in response) { + await StandardServerFastify.sendStandardResponse(response, standardResponse, this.config) + } + else { + await StandardServerNode.sendStandardResponse(response, standardResponse, this.config) + } + }, + ) }), ) } diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts index eb78e234d..ae5c4320b 100644 --- a/packages/nest/src/module.ts +++ b/packages/nest/src/module.ts @@ -1,6 +1,9 @@ import type { DynamicModule } from '@nestjs/common' import type { AnySchema } from '@orpc/contract' +import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard' import type { CreateProcedureClientOptions } from '@orpc/server' +import type { Interceptor } from '@orpc/shared' +import type { StandardResponse } from '@orpc/standard-server' import type { SendStandardResponseOptions } from '@orpc/standard-server-node' import { Module } from '@nestjs/common' import { ImplementInterceptor } from './implement' @@ -9,7 +12,13 @@ export const ORPC_MODULE_CONFIG_SYMBOL = Symbol('ORPC_MODULE_CONFIG') export interface ORPCModuleConfig extends CreateProcedureClientOptions, - SendStandardResponseOptions { + SendStandardResponseOptions, + StandardOpenAPIJsonSerializerOptions, + StandardBracketNotationSerializerOptions { + sendResponseInterceptors?: Interceptor< + { request: any, response: any, standardResponse: StandardResponse }, + unknown + >[] } @Module({}) From 82db6a66a1df1460cf6e16cf7caefdf7c24e0a41 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 27 Nov 2025 19:44:30 +0700 Subject: [PATCH 2/8] global context --- .../implement-contract-in-nest.md | 9 ++++++ packages/nest/src/implement.ts | 6 ++-- packages/nest/src/index.test.ts | 21 ++++++++++++++ packages/nest/src/index.ts | 17 ++++++++++- packages/nest/src/module.ts | 17 ++++++++++- playgrounds/nest/src/app.module.ts | 29 ++++++++++++++----- 6 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 packages/nest/src/index.test.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 d99a99bce..8de913f49 100644 --- a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md +++ b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md @@ -224,6 +224,15 @@ import { REQUEST } from '@nestjs/core' import { onError, ORPCModule } from '@orpc/nest' import { Request } from 'express' // if you use express adapter +declare module '@orpc/nest' { + /** + * Extend oRPC global context to make it type-safe inside your handlers/middlewares + */ + interface ORPCGlobalContext { + request: Request + } +} + @Module({ imports: [ ORPCModule.forRootAsync({ // or use .forRoot for static config diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index c057079fa..0f8593d14 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -7,7 +7,7 @@ import type { StandardResponse } from '@orpc/standard-server' import type { Request, Response } from 'express' import type { FastifyReply, FastifyRequest } from 'fastify' import type { Observable } from 'rxjs' -import type { ORPCModuleConfig } from './module' +import type { ORPCGlobalContext, ORPCModuleConfig } from './module' import { applyDecorators, Delete, Get, Head, HttpCode, Inject, Injectable, Optional, Patch, Post, Put, UseInterceptors } from '@nestjs/common' import { toORPCError } from '@orpc/client' import { fallbackContractConfig, isContractProcedure } from '@orpc/contract' @@ -38,7 +38,7 @@ const MethodDecoratorMap = { */ export function Implement>( contract: T, -): >>>( +): >>( target: Record, propertyKey: string, descriptor: TypedPropertyDescriptor<(...args: any[]) => U>, @@ -100,7 +100,7 @@ export class ImplementInterceptor implements NestInterceptor { @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() config: ORPCModuleConfig | undefined, ) { // @Optional() does not allow set default value so we need to do it here - this.config = config ?? {} + this.config = config ?? {} as ORPCModuleConfig } intercept(ctx: ExecutionContext, next: CallHandler): Observable { diff --git a/packages/nest/src/index.test.ts b/packages/nest/src/index.test.ts new file mode 100644 index 000000000..8f0a9e309 --- /dev/null +++ b/packages/nest/src/index.test.ts @@ -0,0 +1,21 @@ +import * as ServerModule from '@orpc/server' +import { expect, it, vi } from 'vitest' +import { implement } from './index' + +vi.mock('@orpc/server', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + implement: vi.fn(original.implement), + } +}) + +it('implement is aliased', () => { + const contract = { nested: {} } + const options = { dedupeLeadingMiddlewares: false } + const impl = implement(contract, options) + + expect(ServerModule.implement).toHaveBeenCalledTimes(1) + expect(ServerModule.implement).toHaveBeenCalledWith(contract, options) + expect(impl).toBe(vi.mocked(ServerModule.implement).mock.results[0]!.value) +}) diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index 1681f48c8..364742e65 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -1,9 +1,14 @@ +import type { AnyContractRouter } from '@orpc/contract' +import type { BuilderConfig, Context, Implementer } from '@orpc/server' +import type { ORPCGlobalContext } from './module' +import { implement as baseImplement } from '@orpc/server' + export * from './implement' export { Implement as Impl } from './implement' export * from './module' export * from './utils' -export { implement, onError, onFinish, onStart, onSuccess, ORPCError } from '@orpc/server' +export { onError, onFinish, onStart, onSuccess, ORPCError } from '@orpc/server' export type { ImplementedProcedure, Implementer, @@ -13,3 +18,13 @@ export type { RouterImplementer, RouterImplementerWithMiddlewares, } from '@orpc/server' + +/** + * Alias for `implement` from `@orpc/server` with default context set to `ORPCGlobalContext` + */ +export function implement( + contract: T, + config: BuilderConfig = {}, +): Implementer { + return baseImplement(contract, config) +} diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts index ae5c4320b..564cc6eb1 100644 --- a/packages/nest/src/module.ts +++ b/packages/nest/src/module.ts @@ -10,8 +10,23 @@ import { ImplementInterceptor } from './implement' export const ORPC_MODULE_CONFIG_SYMBOL = Symbol('ORPC_MODULE_CONFIG') +/** + * You can extend this interface to add global context properties. + * @example + * ```ts + * declare module '@orpc/nest' { + * interface ORPCGlobalContext { + * user: { id: string; name: string } + * } + * } + * ``` + */ +export interface ORPCGlobalContext { + +} + export interface ORPCModuleConfig extends - CreateProcedureClientOptions, + CreateProcedureClientOptions, SendStandardResponseOptions, StandardOpenAPIJsonSerializerOptions, StandardBracketNotationSerializerOptions { diff --git a/playgrounds/nest/src/app.module.ts b/playgrounds/nest/src/app.module.ts index 37742fb4c..78cae724c 100644 --- a/playgrounds/nest/src/app.module.ts +++ b/playgrounds/nest/src/app.module.ts @@ -6,16 +6,31 @@ import { PlanetService } from './planet/planet.service' import { ReferenceController } from './reference/reference.controller' import { ReferenceService } from './reference/reference.service' import { onError, ORPCModule } from '@orpc/nest' +import { REQUEST } from '@nestjs/core' + +declare module '@orpc/nest' { + /** + * Extend oRPC global context to make it type-safe inside your handlers/middlewares + */ + interface ORPCGlobalContext { + request: Request + } +} @Module({ imports: [ - ORPCModule.forRoot({ - interceptors: [ - onError((error) => { - console.error(error) - }), - ], - eventIteratorKeepAliveInterval: 5000, // 5 seconds + ORPCModule.forRootAsync({ // or use .forRoot for static config + useFactory: (request: Request) => ({ + interceptors: [ + onError((error) => { + console.error(error) + }), + ], + context: { request }, // oRPC context, accessible from middlewares, etc. + eventIteratorKeepAliveInterval: 5000, // 5 seconds + customJsonSerializers: [], + }), + inject: [REQUEST], }), ], controllers: [AuthController, PlanetController, ReferenceController, OtherController], From 6b56ac0ae5e88d7274bfaeebdb22020a14b76583 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 27 Nov 2025 19:46:10 +0700 Subject: [PATCH 3/8] improve --- packages/nest/src/implement.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index 0f8593d14..1b80750af 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -96,21 +96,23 @@ type NestParams = Record @Injectable() export class ImplementInterceptor implements NestInterceptor { private readonly config: ORPCModuleConfig + private readonly codec: StandardOpenAPICodec + constructor( @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() config: ORPCModuleConfig | undefined, ) { // @Optional() does not allow set default value so we need to do it here this.config = config ?? {} as ORPCModuleConfig - } - intercept(ctx: ExecutionContext, next: CallHandler): Observable { - const codec = new StandardOpenAPICodec( + this.codec = new StandardOpenAPICodec( new StandardOpenAPISerializer( new StandardOpenAPIJsonSerializer(this.config), new StandardBracketNotationSerializer(this.config), ), ) + } + intercept(ctx: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( mergeMap(async (impl: unknown) => { const { default: procedure } = await unlazy(impl) @@ -135,7 +137,7 @@ export class ImplementInterceptor implements NestInterceptor { const client = createProcedureClient(procedure, this.config) isDecoding = true - const input = await codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure) + const input = await this.codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure) isDecoding = false const output = await client(input, { @@ -143,7 +145,7 @@ export class ImplementInterceptor implements NestInterceptor { lastEventId: flattenHeader(standardRequest.headers['last-event-id']), }) - return codec.encode(output, procedure) + return this.codec.encode(output, procedure) } catch (e) { const error = isDecoding && !(e instanceof ORPCError) @@ -153,7 +155,7 @@ export class ImplementInterceptor implements NestInterceptor { }) : toORPCError(e) - return codec.encodeError(error) + return this.codec.encodeError(error) } })() From d6c359737867c86d770f811e034f3a2d1c22aed3 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 27 Nov 2025 20:10:41 +0700 Subject: [PATCH 4/8] plugins --- .../implement-contract-in-nest.md | 1 + packages/nest/src/implement.test.ts | 89 +++++++++++++++++++ packages/nest/src/implement.ts | 57 +++++------- packages/nest/src/module.ts | 3 + playgrounds/nest/package.json | 1 + playgrounds/nest/src/app.module.ts | 9 ++ pnpm-lock.yaml | 3 + 7 files changed, 128 insertions(+), 35 deletions(-) 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 8de913f49..7f5282cb8 100644 --- a/apps/content/docs/openapi/integrations/implement-contract-in-nest.md +++ b/apps/content/docs/openapi/integrations/implement-contract-in-nest.md @@ -245,6 +245,7 @@ declare module '@orpc/nest' { context: { request }, // oRPC context, accessible from middlewares, etc. eventIteratorKeepAliveInterval: 5000, // 5 seconds customJsonSerializers: [], + plugins: [], // almost oRPC plugins are compatible }), inject: [REQUEST], }), diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts index 99968ee79..818db6a3c 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -550,6 +550,95 @@ describe('@Implement', async () => { expect(res.text).toEqual('{"custom":true}') }) }) + + it('plugins', async () => { + const clientInterceptor = vi.fn(({ next }) => next()) + const clientInterceptors = [clientInterceptor] + const interceptor = vi.fn(({ next }) => next()) + const plugins = [{ + init(options: any) { + options.clientInterceptors ??= [] + options.clientInterceptors.push(interceptor) + }, + }] + + // Use this clientInterceptors, plugins arrays + // to verify that handler options is cloned before applying plugins. + // Without proper cloning, interceptors would run multiple times and affect subsequent requests. + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + context: { thisIsContext: true }, + interceptors: clientInterceptors, + path: ['__PATH__'], + plugins, + }), + ], + controllers: [ImplProcedureController], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + const httpServer = app.getHttpServer() + + const res1 = await supertest(httpServer).delete('/world/who%3F') + expect(res1.statusCode).toEqual(202) + expect(interceptor).toHaveBeenCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + expect(clientInterceptor).toHaveBeenCalledTimes(1) + expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + + interceptor.mockClear() + clientInterceptor.mockClear() + const res2 = await supertest(httpServer) + .post('/ping?param=value¶m2[]=value2¶m2[]=value3') + .set('x-custom', 'value') + .send({ hello: 'world' }) + expect(res2.statusCode).toEqual(200) + expect(interceptor).toHaveBeenCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + expect(clientInterceptor).toHaveBeenCalledTimes(1) + expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + + interceptor.mockClear() + clientInterceptor.mockClear() + const res3 = await supertest(httpServer).delete('/world/who%3F') + expect(res3.statusCode).toEqual(202) + expect(interceptor).toHaveBeenCalledTimes(1) + expect(interceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + expect(clientInterceptor).toHaveBeenCalledTimes(1) + expect(clientInterceptor).toHaveBeenCalledWith(expect.objectContaining({ + context: expect.objectContaining({ + thisIsContext: true, + }), + path: ['__PATH__'], + })) + }) }) describe('compatibility', () => { diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index 1b80750af..fd578ef0f 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -3,19 +3,17 @@ import type { ContractRouter } from '@orpc/contract' 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 { Request, Response } from 'express' import type { FastifyReply, FastifyRequest } from 'fastify' import type { Observable } from 'rxjs' import type { ORPCGlobalContext, ORPCModuleConfig } from './module' import { applyDecorators, Delete, Get, Head, HttpCode, 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' import { StandardOpenAPICodec } from '@orpc/openapi/standard' -import { createProcedureClient, getRouter, isProcedure, ORPCError, unlazy } from '@orpc/server' +import { getRouter, isProcedure, unlazy } from '@orpc/server' +import { StandardHandler } from '@orpc/server/standard' import { get, intercept, toArray } from '@orpc/shared' -import { flattenHeader } from '@orpc/standard-server' import * as StandardServerFastify from '@orpc/standard-server-fastify' import * as StandardServerNode from '@orpc/standard-server-node' import { mergeMap } from 'rxjs' @@ -95,14 +93,14 @@ type NestParams = Record @Injectable() export class ImplementInterceptor implements NestInterceptor { - private readonly config: ORPCModuleConfig + private readonly config: Partial private readonly codec: StandardOpenAPICodec constructor( @Inject(ORPC_MODULE_CONFIG_SYMBOL) @Optional() config: ORPCModuleConfig | undefined, ) { // @Optional() does not allow set default value so we need to do it here - this.config = config ?? {} as ORPCModuleConfig + this.config = config ?? {} this.codec = new StandardOpenAPICodec( new StandardOpenAPISerializer( @@ -130,38 +128,27 @@ export class ImplementInterceptor implements NestInterceptor { ? StandardServerFastify.toStandardLazyRequest(req, res as FastifyReply) : StandardServerNode.toStandardLazyRequest(req, res as Response) - const standardResponse: StandardResponse = await (async () => { - let isDecoding = false - - try { - const client = createProcedureClient(procedure, this.config) - - isDecoding = true - const input = await this.codec.decode(standardRequest, flattenParams(req.params as NestParams), procedure) - isDecoding = false - - const output = await client(input, { - signal: standardRequest.signal, - lastEventId: flattenHeader(standardRequest.headers['last-event-id']), - }) - - return this.codec.encode(output, procedure) - } - catch (e) { - const error = isDecoding && !(e instanceof ORPCError) - ? new ORPCError('BAD_REQUEST', { - message: `Malformed request. Ensure the request body is properly formatted and the 'Content-Type' header is set correctly.`, - cause: e, - }) - : toORPCError(e) - - return this.codec.encodeError(error) - } - })() + const handler = new StandardHandler(procedure, { + init: () => {}, + match: () => Promise.resolve({ path: toArray(this.config.path), procedure, params: flattenParams(req.params as NestParams) }), + }, this.codec, { + // Since plugins can modify options directly, so we need to clone to avoid affecting other handlers/requests + // TODO: improve plugins system to avoid this cloning + clientInterceptors: [...toArray(this.config.interceptors)], + plugins: [...toArray(this.config.plugins)], + }) + + const result = await handler.handle(standardRequest, { + context: this.config.context, + }) + + if (!result.matched) { + return + } return intercept( toArray(this.config.sendResponseInterceptors), - { request: req, response: res, standardResponse }, + { request: req, response: res, standardResponse: result.response }, async ({ response, standardResponse }) => { if ('raw' in response) { await StandardServerFastify.sendStandardResponse(response, standardResponse, this.config) diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts index 564cc6eb1..f84ef8346 100644 --- a/packages/nest/src/module.ts +++ b/packages/nest/src/module.ts @@ -2,6 +2,7 @@ import type { DynamicModule } from '@nestjs/common' import type { AnySchema } from '@orpc/contract' import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard' import type { CreateProcedureClientOptions } from '@orpc/server' +import type { StandardHandlerOptions } from '@orpc/server/standard' import type { Interceptor } from '@orpc/shared' import type { StandardResponse } from '@orpc/standard-server' import type { SendStandardResponseOptions } from '@orpc/standard-server-node' @@ -30,6 +31,8 @@ export interface ORPCModuleConfig extends SendStandardResponseOptions, StandardOpenAPIJsonSerializerOptions, StandardBracketNotationSerializerOptions { + plugins?: StandardHandlerOptions['plugins'] + sendResponseInterceptors?: Interceptor< { request: any, response: any, standardResponse: StandardResponse }, unknown diff --git a/playgrounds/nest/package.json b/playgrounds/nest/package.json index 145e7919c..af68720d9 100644 --- a/playgrounds/nest/package.json +++ b/playgrounds/nest/package.json @@ -15,6 +15,7 @@ "@nestjs/schematics": "^11.0.9", "@orpc/client": "next", "@orpc/contract": "next", + "@orpc/json-schema": "next", "@orpc/nest": "next", "@orpc/openapi": "next", "@orpc/openapi-client": "next", diff --git a/playgrounds/nest/src/app.module.ts b/playgrounds/nest/src/app.module.ts index 78cae724c..c71ac9464 100644 --- a/playgrounds/nest/src/app.module.ts +++ b/playgrounds/nest/src/app.module.ts @@ -7,6 +7,8 @@ import { ReferenceController } from './reference/reference.controller' import { ReferenceService } from './reference/reference.service' import { onError, ORPCModule } from '@orpc/nest' import { REQUEST } from '@nestjs/core' +import { experimental_SmartCoercionPlugin as SmartCoercionPlugin } from '@orpc/json-schema' +import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' declare module '@orpc/nest' { /** @@ -29,6 +31,13 @@ declare module '@orpc/nest' { context: { request }, // oRPC context, accessible from middlewares, etc. eventIteratorKeepAliveInterval: 5000, // 5 seconds customJsonSerializers: [], + plugins: [ + new SmartCoercionPlugin({ + schemaConverters: [ + new ZodToJsonSchemaConverter(), + ], + }), + ], // almost oRPC plugins are compatible }), inject: [REQUEST], }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb123666e..cace1d2d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1384,6 +1384,9 @@ importers: '@orpc/contract': specifier: next version: link:../../packages/contract + '@orpc/json-schema': + specifier: next + version: link:../../packages/json-schema '@orpc/nest': specifier: next version: link:../../packages/nest From a53b74cd14f85d1b30c7613cc81bda8261f2b7ed Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 27 Nov 2025 20:51:28 +0700 Subject: [PATCH 5/8] custom error response body encoder --- packages/nest/src/implement.test.ts | 29 +++++++++++++++++++++++++++++ packages/nest/src/implement.ts | 1 + packages/nest/src/index.ts | 1 + packages/nest/src/module.ts | 4 +++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/nest/src/implement.test.ts b/packages/nest/src/implement.test.ts index 818db6a3c..eb4c0d0d2 100644 --- a/packages/nest/src/implement.test.ts +++ b/packages/nest/src/implement.test.ts @@ -639,6 +639,35 @@ describe('@Implement', async () => { path: ['__PATH__'], })) }) + + it('custom error response body encoder', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ORPCModule.forRoot({ + customErrorResponseBodyEncoder: error => ({ + custom: true, + code: error.code, + data: error.data, + }), + }), + ], + controllers: [ImplProcedureController], + }).compile() + + const app = moduleRef.createNestApplication() + await app.init() + + const httpServer = app.getHttpServer() + + const res = await supertest(httpServer).get('/pong/world') + + expect(res.statusCode).toEqual(408) + expect(res.body).toEqual({ + custom: true, + code: 'TEST', + data: 'pong world', + }) + }) }) describe('compatibility', () => { diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index fd578ef0f..55893bae5 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -107,6 +107,7 @@ export class ImplementInterceptor implements NestInterceptor { new StandardOpenAPIJsonSerializer(this.config), new StandardBracketNotationSerializer(this.config), ), + this.config, ) } diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index 364742e65..8b6b5b3ad 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -3,6 +3,7 @@ import type { BuilderConfig, Context, Implementer } from '@orpc/server' import type { ORPCGlobalContext } from './module' import { implement as baseImplement } from '@orpc/server' +export * from './exception-filter' export * from './implement' export { Implement as Impl } from './implement' export * from './module' diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts index f84ef8346..848bf02e5 100644 --- a/packages/nest/src/module.ts +++ b/packages/nest/src/module.ts @@ -1,6 +1,7 @@ import type { DynamicModule } from '@nestjs/common' import type { AnySchema } from '@orpc/contract' import type { StandardBracketNotationSerializerOptions, StandardOpenAPIJsonSerializerOptions } from '@orpc/openapi-client/standard' +import type { StandardOpenAPICodecOptions } from '@orpc/openapi/standard' import type { CreateProcedureClientOptions } from '@orpc/server' import type { StandardHandlerOptions } from '@orpc/server/standard' import type { Interceptor } from '@orpc/shared' @@ -30,7 +31,8 @@ export interface ORPCModuleConfig extends CreateProcedureClientOptions, SendStandardResponseOptions, StandardOpenAPIJsonSerializerOptions, - StandardBracketNotationSerializerOptions { + StandardBracketNotationSerializerOptions, + StandardOpenAPICodecOptions { plugins?: StandardHandlerOptions['plugins'] sendResponseInterceptors?: Interceptor< From 0a6f7cbcf34a9a080314a4eef053687e7e55a577 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 27 Nov 2025 20:54:50 +0700 Subject: [PATCH 6/8] improve --- packages/nest/src/implement.ts | 28 +++++++++++++--------------- packages/nest/src/index.ts | 1 - 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/nest/src/implement.ts b/packages/nest/src/implement.ts index 55893bae5..c139d7170 100644 --- a/packages/nest/src/implement.ts +++ b/packages/nest/src/implement.ts @@ -143,22 +143,20 @@ export class ImplementInterceptor implements NestInterceptor { context: this.config.context, }) - if (!result.matched) { - return + if (result.matched) { + return intercept( + toArray(this.config.sendResponseInterceptors), + { request: req, response: res, standardResponse: result.response }, + async ({ response, standardResponse }) => { + if ('raw' in response) { + await StandardServerFastify.sendStandardResponse(response, standardResponse, this.config) + } + else { + await StandardServerNode.sendStandardResponse(response, standardResponse, this.config) + } + }, + ) } - - return intercept( - toArray(this.config.sendResponseInterceptors), - { request: req, response: res, standardResponse: result.response }, - async ({ response, standardResponse }) => { - if ('raw' in response) { - await StandardServerFastify.sendStandardResponse(response, standardResponse, this.config) - } - else { - await StandardServerNode.sendStandardResponse(response, standardResponse, this.config) - } - }, - ) }), ) } diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index 8b6b5b3ad..364742e65 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -3,7 +3,6 @@ import type { BuilderConfig, Context, Implementer } from '@orpc/server' import type { ORPCGlobalContext } from './module' import { implement as baseImplement } from '@orpc/server' -export * from './exception-filter' export * from './implement' export { Implement as Impl } from './implement' export * from './module' From f58dd4720c278aba474ac3c32215ebf937741664 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 27 Nov 2025 20:57:12 +0700 Subject: [PATCH 7/8] todo --- packages/nest/src/module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nest/src/module.ts b/packages/nest/src/module.ts index 848bf02e5..fc913b7ca 100644 --- a/packages/nest/src/module.ts +++ b/packages/nest/src/module.ts @@ -26,7 +26,7 @@ export const ORPC_MODULE_CONFIG_SYMBOL = Symbol('ORPC_MODULE_CONFIG') export interface ORPCGlobalContext { } - +// TODO: replace CreateProcedureClientOptions with StandardHandlerOptions export interface ORPCModuleConfig extends CreateProcedureClientOptions, SendStandardResponseOptions, From 0ec130aa519039ab5571effdb0e81b4b2c403e46 Mon Sep 17 00:00:00 2001 From: unnoq Date: Thu, 27 Nov 2025 21:04:00 +0700 Subject: [PATCH 8/8] typo --- .../openapi/integrations/implement-contract-in-nest.md | 8 +++++--- playgrounds/nest/src/app.module.ts | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) 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 7f5282cb8..40412e48a 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,7 @@ oRPC will use NestJS parsed body when it's available, and only use the oRPC pars Configure the `@orpc/nest` module by importing `ORPCModule` in your NestJS application: ```ts +import { Module } from '@nestjs/common' import { REQUEST } from '@nestjs/core' import { onError, ORPCModule } from '@orpc/nest' import { Request } from 'express' // if you use express adapter @@ -245,7 +246,7 @@ declare module '@orpc/nest' { context: { request }, // oRPC context, accessible from middlewares, etc. eventIteratorKeepAliveInterval: 5000, // 5 seconds customJsonSerializers: [], - plugins: [], // almost oRPC plugins are compatible + plugins: [], // most oRPC plugins are compatible }), inject: [REQUEST], }), @@ -293,8 +294,9 @@ Please refer to the [OpenAPILink](/docs/openapi/client/openapi-link) documentati By default, oRPC sends the response directly without returning it to the NestJS handler. However, you may want to preserve the return behavior for compatibility with certain NestJS features or third-party libraries. ```ts +import { Module } from '@nestjs/common' import { ORPCModule } from '@orpc/nest' -import { Request } from 'express' // if you use express adapter +import { Response } from 'express' // if you use express adapter import { isObject } from '@orpc/shared' // checks if value is a plain object (not a class instance) @Module({ @@ -307,7 +309,7 @@ import { isObject } from '@orpc/shared' // checks if value is a plain object (no || standardResponse.status >= 300 || !(isObject(standardResponse.body) || Array.isArray(standardResponse.body)) ) { - // Only object and array is valid to return as response body + // Only object and array are valid to return as response body // the rest should fallback to default oRPC behavior return next() } diff --git a/playgrounds/nest/src/app.module.ts b/playgrounds/nest/src/app.module.ts index c71ac9464..7e1f13493 100644 --- a/playgrounds/nest/src/app.module.ts +++ b/playgrounds/nest/src/app.module.ts @@ -9,6 +9,7 @@ import { onError, ORPCModule } from '@orpc/nest' import { REQUEST } from '@nestjs/core' import { experimental_SmartCoercionPlugin as SmartCoercionPlugin } from '@orpc/json-schema' import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' +import { Request } from 'express' declare module '@orpc/nest' { /**