From 2ead81e6964f0ccffa3ab342c84acbc810dbc8d9 Mon Sep 17 00:00:00 2001 From: mathiasduc Date: Fri, 31 Oct 2025 12:13:42 +0100 Subject: [PATCH 1/3] chore(nest): add more test for Nest features compatibilty --- packages/nest/package.json | 5 +- packages/nest/src/nest-features.test.ts | 613 +++++++++++++++++++++++ packages/nest/src/response-types.test.ts | 590 ++++++++++++++++++++++ pnpm-lock.yaml | 95 +++- 4 files changed, 1294 insertions(+), 9 deletions(-) create mode 100644 packages/nest/src/nest-features.test.ts create mode 100644 packages/nest/src/response-types.test.ts diff --git a/packages/nest/package.json b/packages/nest/package.json index 943139088..2d72a6e6d 100644 --- a/packages/nest/package.json +++ b/packages/nest/package.json @@ -61,6 +61,7 @@ "@orpc/standard-server-node": "workspace:*" }, "devDependencies": { + "@fastify/compress": "^8.1.0", "@fastify/cookie": "^11.0.2", "@nestjs/common": "^11.1.8", "@nestjs/core": "^11.1.8", @@ -68,11 +69,13 @@ "@nestjs/platform-fastify": "^11.1.8", "@nestjs/testing": "^11.1.8", "@ts-rest/core": "^3.52.1", + "@types/compression": "^1.8.1", "@types/express": "^5.0.5", + "compression": "^1.8.1", "express": "^5.0.0", "fastify": "^5.6.1", "rxjs": "^7.8.1", "supertest": "^7.1.4", "zod": "^4.1.12" } -} +} \ No newline at end of file diff --git a/packages/nest/src/nest-features.test.ts b/packages/nest/src/nest-features.test.ts new file mode 100644 index 000000000..fe1ec234e --- /dev/null +++ b/packages/nest/src/nest-features.test.ts @@ -0,0 +1,613 @@ +import type { ArgumentsHost, CallHandler, CanActivate, ExceptionFilter, ExecutionContext, INestApplication, MiddlewareConsumer, NestInterceptor, NestMiddleware, PipeTransform } from '@nestjs/common' +import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { Observable } from 'rxjs' +import fastifyCompress from '@fastify/compress' +import { Catch, Controller, ForbiddenException, HttpException, Injectable, Module, Req, SetMetadata, UseGuards, UseInterceptors } from '@nestjs/common' +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core' +import { ExpressAdapter } from '@nestjs/platform-express' +import { FastifyAdapter } from '@nestjs/platform-fastify' +import { Test } from '@nestjs/testing' +import { oc } from '@orpc/contract' +import { implement } from '@orpc/server' +import * as StandardServerNode from '@orpc/standard-server-node' +// eslint-disable-next-line no-restricted-imports -- needed for testing compression middleware integration +import compression from 'compression' +import { map } from 'rxjs' +import request from 'supertest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +import { Implement, ORPCModule } from '.' + +// 1. oRPC Contract +const testContract = { + hello: oc.route({ + path: '/hello', + method: 'POST', + }) + .input(z.object({ name: z.string() })) + .output(z.object({ greeting: z.string() })), +} + +const testDetailedContract = { + hello: oc.route({ + path: '/hello', + method: 'POST', + outputStructure: 'detailed', + }) + .input(z.object({ name: z.string() })) + .output(z.object({ + body: z.object({ greeting: z.string() }), + status: z.number().optional(), + })), +} + +// Contract for testing global pipes (transformation) +const testPipeContract = { + transform: oc.route({ + path: '/transform', + method: 'POST', + }) + .input(z.object({ + text: z.string(), + name: z.string(), + })) + .output(z.object({ result: z.string(), original: z.string() })), +} + +// Contract for testing error handling (global filters) +const testErrorContract = { + error: oc.route({ + path: '/error', + method: 'POST', + }) + .input(z.object({ shouldThrow: z.boolean() })) + .output(z.object({ message: z.string() })), +} + +// Contract for testing guards +const testGuardContract = { + protected: oc.route({ + path: '/protected', + method: 'POST', + }) + .input(z.object({ apiKey: z.string() })) + .output(z.object({ message: z.string(), user: z.string() })), +} + +// Contract for testing compression middleware +const testCompressionContract = { + data: oc.route({ + path: '/large-data', + method: 'GET', + }) + .output(z.object({ data: z.string(), size: z.number() })), +} + +// 2. A controller for the 'raw' output test +@Controller() +class TestRawController { + @Implement(testContract.hello) + hello() { + return implement(testContract.hello).handler(async ({ input }) => { + // This handler ALWAYS returns the raw output shape + return { greeting: `Hello, ${input.name}!` } + }) + } +} + +// 3. A separate controller for the 'detailed' output test +@Controller() +class TestDetailedController { + @Implement(testDetailedContract.hello) + hello() { + return implement(testDetailedContract.hello).handler(async ({ input }) => { + // This handler returns the detailed output shape: { body, headers?, status? } + return { + status: 201, // Custom status to verify detailed output works + body: { greeting: `Hello, ${input.name}!` }, + } + }) + } +} + +// 4. Interceptor that modifies the response body +@Injectable() +class ResponseTransformInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + map((data: any) => { + return { + ...data, + intercepted: true, + timestamp: new Date().toISOString(), + } + }), + ) + } +} +// 4. Controller with interceptor to test response transformation +@Controller() +class TestInterceptorController { + @Implement(testContract.hello) + @UseInterceptors(ResponseTransformInterceptor) + hello() { + return implement(testContract.hello).handler(async ({ input }) => { + return { greeting: `Hello, ${input.name}!` } + }) + } +} + +// 6. Controller for testing global pipes (transformation) +@Controller() +class TestPipeController { + @Implement(testPipeContract.transform) + transform() { + return implement(testPipeContract.transform).handler(async ({ input }) => { + // Input should be transformed by the global pipe before reaching this handler + return { + result: `Processed: ${input.text}`, + original: `${input.name}`, + } + }) + } +} + +// 7. Controller for testing global error filter +@Controller() +class TestErrorController { + @Implement(testErrorContract.error) + error() { + return implement(testErrorContract.error).handler(async ({ input }) => { + if (input.shouldThrow) { + throw new HttpException('Custom error from handler', 418) + } + return { message: 'No error' } + }) + } +} + +// 8. Custom metadata decorator for roles (similar to your @Roles decorator) +const ROLES_KEY = 'roles' +const RequireRole = (...roles: string[]) => SetMetadata(ROLES_KEY, roles) + +// 9. Custom Guard that checks API key from request (similar to JwtAuthGuard) +@Injectable() +class ApiKeyGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() + // Simulate checking API key from headers or body + const apiKey = request.headers['x-api-key'] || request.body?.apiKey + + if (apiKey === 'valid-key') { + // Simulate adding user to request (like JWT strategy does) + request.user = { id: 1, name: 'John Doe', role: 'admin' } + return true + } + + throw new ForbiddenException('Invalid API key') + } +} + +// 10. Simplified Guard that just checks if user exists (no Reflector needed for this test) +@Injectable() +class AuthenticatedGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() + const user = request.user + + if (!user) { + throw new ForbiddenException('No user found - authentication required') + } + + return true + } +} + +// 11. Controller for testing guards (method-level) +// Also try accessing request modifications made by guards +@Controller() +class TestGuardController { + @UseGuards(ApiKeyGuard, AuthenticatedGuard) + @RequireRole('admin') + @Implement(testGuardContract.protected) + protected( + @Req() + req: any, + ) { + return implement(testGuardContract.protected).handler(async () => { + const user = req?.user + return { + message: 'Access granted', + user: user?.name || 'Unknown', + } + }) + } +} + +// 12. Simple custom pipe that uppercases string input +@Injectable() +class UpperCasePipe implements PipeTransform { + transform(value: any) { + if (typeof value === 'string') { + return value.toUpperCase() + } + if (typeof value === 'object' && value !== null) { + // Transform all string properties + const result: any = {} + for (const key in value) { + const val = value[key] + result[key] = typeof val === 'string' ? val.toUpperCase() : val + } + return result + } + return value + } +} + +// 13. Controller for testing compression middleware +@Controller() +class TestCompressionController { + @Implement(testCompressionContract.data) + data() { + return implement(testCompressionContract.data).handler(async () => { + // Return a response that can be compressed (1kb minimum) + const largeData = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(50) + return { + data: largeData, + size: largeData.length, + } + }) + } +} + +// 9. Global interceptor that modifies the response +@Injectable() +class GlobalLoggingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + map((data) => { + const res = context.switchToHttp().getResponse() + res.header('Global-Interceptor', `global-interceptor`) + return { ...data, globalInterceptor: true } + }), + ) + } +} + +// 10. Global filter that catches HTTP exceptions +@Catch(HttpException) +class GlobalHttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp() + const response = ctx.getResponse() + const status = exception.getStatus() + + // Custom error response format + const errorResponse = { + statusCode: status, + message: exception.message, + timestamp: new Date().toISOString(), + customFilter: true, // Marker to verify the filter ran + } + + response.status(status).send(errorResponse) + } +} + +// 11. Custom Middleware +class CustomHeaderMiddleware implements NestMiddleware { + use(req: any, res: any, next: (error?: any) => void) { + res.setHeader('X-Custom-Middleware', 'hello') + next() + } +} + +// Test Modules for each controller +@Module({ + controllers: [TestRawController], + providers: [], +}) +class TestRawModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CustomHeaderMiddleware).forRoutes('*') + } +} + +@Module({ + controllers: [TestDetailedController], + providers: [], +}) +class TestDetailedModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CustomHeaderMiddleware).forRoutes('*') + } +} + +@Module({ + controllers: [TestInterceptorController], + providers: [ + ResponseTransformInterceptor, + ], +}) +class TestInterceptorModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CustomHeaderMiddleware).forRoutes('*') + } +} + +@Module({ + controllers: [TestPipeController], + providers: [ + { + provide: APP_PIPE, + useClass: UpperCasePipe, + }, + ], +}) +class TestPipeModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CustomHeaderMiddleware).forRoutes('*') + } +} + +@Module({ + controllers: [TestErrorController], + providers: [ + { + provide: APP_FILTER, + useClass: GlobalHttpExceptionFilter, + }, + ], +}) +class TestErrorModule {} + +@Module({ + controllers: [TestGuardController], + providers: [], +}) +class TestGuardModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CustomHeaderMiddleware).forRoutes('*') + } +} + +@Module({ + controllers: [TestRawController], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: GlobalLoggingInterceptor, + }, + ], +}) +class TestGlobalInterceptorModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CustomHeaderMiddleware).forRoutes('*') + } +} + +@Module({ + controllers: [TestCompressionController], +}) +class TestCompressionModule { + configure(consumer: MiddlewareConsumer) { + // Use the actual compression middleware for Express + consumer.apply(compression()).forRoutes('*') + } +} + +const sendStandardResponseSpy = vi.spyOn(StandardServerNode, 'sendStandardResponse') + +describe('oRPC Nest Middleware Integration', () => { + const testSuite = ( + adapterName: 'Express' | 'Fastify', + adapter: () => ExpressAdapter | FastifyAdapter, + ) => { + describe(`with ${adapterName}`, () => { + let app: INestApplication + + async function createApp(testModule: any, orpcModuleConfig: any) { + const moduleFixture = await Test.createTestingModule({ + imports: [testModule, ORPCModule.forRoot(orpcModuleConfig)], + }).compile() + + app = moduleFixture.createNestApplication(adapter()) + app.enableCors() + + // Register compression for Fastify + if (adapterName === 'Fastify') { + await (app as NestFastifyApplication).register(fastifyCompress) + } + + await app.init() + if (adapterName === 'Fastify') { + await (app as any).getHttpAdapter().getInstance().ready() + } + } + + afterEach(async () => { + await app?.close() + }) + + it('should apply NestJS middleware and CORS with outputStructure: \'raw\'', async () => { + await createApp(TestRawModule, {}) + + await request(app.getHttpServer()) + .post('/hello') + .send({ name: 'world' }) + .expect(200) + .expect('Access-Control-Allow-Origin', '*') + .expect('X-Custom-Middleware', 'hello') + .then((response) => { + expect(response.body).toEqual({ greeting: 'Hello, world!' }) + }) + }) + + it('should apply NestJS middleware and CORS with outputStructure: \'detailed\'', async () => { + await createApp(TestDetailedModule, { outputStructure: 'detailed' }) + + await request(app.getHttpServer()) + .post('/hello') + .send({ name: 'detailed-world' }) + .expect(201) // Assert the custom status code + .expect('Access-Control-Allow-Origin', '*') + .expect('X-Custom-Middleware', 'hello') + .then((response) => { + // Manually parse the response text instead of relying on response.body + const body = JSON.parse(response.text) + expect(body).toEqual({ + greeting: 'Hello, detailed-world!', + }) + }) + }) + + it('should allow NestJS interceptors to modify the response', async () => { + await createApp(TestInterceptorModule, {}) + + await request(app.getHttpServer()) + .post('/hello') + .send({ name: 'interceptor-test' }) + .expect(200) + .expect('Access-Control-Allow-Origin', '*') + .expect('X-Custom-Middleware', 'hello') + .then((response) => { + expect(response.body).toMatchObject({ + greeting: 'Hello, interceptor-test!', + intercepted: true, + }) + expect(response.body.timestamp).toBeDefined() + }) + }) + + it('should work with global pipes (APP_PIPE provider)', async () => { + // Note: Global NestJS pipes don't transform oRPC handler inputs because: + // 1. oRPC has its own codec that decodes and validates the request body against + // the contract schema (Zod schemas) - this happens independently of NestJS pipes + // 2. NestJS pipes are designed to work with parameter decorators (@Body(), @Param(), etc.) + // but oRPC handlers don't use these decorators + // 3. The request body is parsed by NestJS, but pipe transformation only applies when + // explicitly bound to parameters via decorators + // + // For input transformation with oRPC, you should: + // - Use Zod's .transform() in your contract schemas + // - Use oRPC middleware to transform inputs + // - Transform data in the handler itself + // + // This test verifies that global pipes can be registered without breaking oRPC. + await createApp(TestPipeModule, {}) + + await request(app.getHttpServer()) + .post('/transform') + .send({ text: 'hello world', name: 'john' }) + .expect(200) + .expect('Access-Control-Allow-Origin', '*') + .expect('X-Custom-Middleware', 'hello') + .then((response) => { + // The input is NOT transformed by UpperCasePipe because oRPC's codec + // processes the request body independently from NestJS's pipe system + expect(response.body).toEqual({ + result: 'Processed: hello world', + original: 'john', + }) + }) + }) + + it('should work with Guards and custom decorators (@UseGuards)', async () => { + await createApp(TestGuardModule, {}) + + // Test successful authentication with valid API key and admin role + await request(app.getHttpServer()) + .post('/protected') + .set('X-Api-Key', 'valid-key') + .send({ apiKey: 'valid-key' }) + .expect(200) + .expect('Access-Control-Allow-Origin', '*') + .expect('X-Custom-Middleware', 'hello') + .then((response) => { + expect(response.body).toEqual({ + message: 'Access granted', + user: 'John Doe', + }) + }) + + // Test failed authentication with invalid API key + await request(app.getHttpServer()) + .post('/protected') + .set('X-Api-Key', 'invalid-key') + .send({ apiKey: 'invalid-key' }) + .expect(403) + .then((response) => { + expect(response.body.message).toBe('Invalid API key') + }) + }) + + it('should work with global exception filters (useGlobalFilters)', async () => { + await createApp(TestErrorModule, {}) + + // Request that doesn't throw should succeed + await request(app.getHttpServer()) + .post('/error') + .send({ shouldThrow: false }) + .expect(200) + .then((response) => { + expect(response.body).toEqual({ message: 'No error' }) + }) + + // Errors thrown inside oRPC handlers are now allowed to bubble up to NestJS + // so global exception filters can catch and transform them + await request(app.getHttpServer()) + .post('/error') + .send({ shouldThrow: true }) + .expect(418) // Custom status code from the HttpException + .then((response) => { + // The response should be transformed by GlobalHttpExceptionFilter + expect(response.body).toMatchObject({ + statusCode: 418, + message: 'Custom error from handler', + customFilter: true, // Marker to verify the filter ran + }) + expect(response.body.timestamp).toBeDefined() + }) + + // Ensure that the standard response was not sent via ORPCExceptionFilter + expect(sendStandardResponseSpy).not.toHaveBeenCalled() + }) + + it('should work with global interceptors (useGlobalInterceptors)', async () => { + await createApp(TestGlobalInterceptorModule, {}) + + await request(app.getHttpServer()) + .post('/hello') + .send({ name: 'global-interceptor' }) + .expect(200) + .expect('Global-Interceptor', 'global-interceptor') + .then((response) => { + expect(response.body.globalInterceptor).toBe(true) + }) + }) + + it('should work with compression middleware that accesses oRPC response body', async () => { + await createApp(TestCompressionModule, {}) + + // Make a request with Accept-Encoding header to enable compression + const response = await request(app.getHttpServer()) + .get('/large-data') + .set('Accept-Encoding', 'gzip, deflate') + .expect(200) + + // Verify compression was applied (check for content-encoding header) + // Note: compression middleware only compresses responses above a certain threshold (default 1kb) + expect(['gzip', 'deflate']).toContain(response.headers['content-encoding']) + + // Verify that the oRPC handler response is correctly returned (supertest auto-decompresses) + expect(response.body).toHaveProperty('data') + expect(response.body).toHaveProperty('size') + expect(response.body.size).toBeGreaterThan(0) + }) + }) + } + + testSuite('Express', () => new ExpressAdapter()) + testSuite('Fastify', () => new FastifyAdapter()) +}) diff --git a/packages/nest/src/response-types.test.ts b/packages/nest/src/response-types.test.ts new file mode 100644 index 000000000..498bb8e4a --- /dev/null +++ b/packages/nest/src/response-types.test.ts @@ -0,0 +1,590 @@ +import type { INestApplication } from '@nestjs/common' +import { Controller, Module } from '@nestjs/common' +import { ExpressAdapter } from '@nestjs/platform-express' +import { FastifyAdapter } from '@nestjs/platform-fastify' +import { Test } from '@nestjs/testing' +import { oc } from '@orpc/contract' +import { implement, withEventMeta } from '@orpc/server' +import request from 'supertest' +import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { Implement } from './implement' +import { ORPCModule } from './module' + +/** + * Test suite for validating all supported ORPC response types work correctly + * with the modified Nest integration. + * + * Based on the ORPC standard body types defined in @orpc/standard-server: + * - undefined (empty responses) + * - string (text/plain) + * - object/array (JSON) + * - URLSearchParams (application/x-www-form-urlencoded) + * - FormData (multipart/form-data) + * - Blob (binary data) + * - File (binary data with filename) + * - AsyncIterable (SSE/Event Streaming) + * + * References: + * - packages/standard-server-node/src/body.ts + * - https://orpc.unnoq.com/docs/event-iterator + */ + +// ============================================================================ +// Test Contracts +// ============================================================================ + +const contracts = { + // Empty response (no body) + emptyResponse: oc.route({ + path: '/empty', + method: 'GET', + }), + + // String response + stringResponse: oc.route({ + path: '/string', + method: 'GET', + }).output(z.string()), + + // Object to JSON + objectResponse: oc.route({ + path: '/object', + method: 'POST', + }) + .input(z.object({ name: z.string() })) + .output(z.object({ message: z.string(), timestamp: z.string() })), + + // Array to JSON + arrayResponse: oc.route({ + path: '/array', + method: 'GET', + }).output(z.array(z.object({ id: z.number(), value: z.string() }))), + + // URLSearchParams response + urlSearchParamsResponse: oc.route({ + path: '/url-search-params', + method: 'GET', + }), + + // FormData response + formDataResponse: oc.route({ + path: '/form-data', + method: 'GET', + }), + + // Blob response + blobResponse: oc.route({ + path: '/blob', + method: 'GET', + }), + + // File response + fileResponse: oc.route({ + path: '/file', + method: 'GET', + }), + + // Event streaming (AsyncIterable/SSE) + eventStream: oc.route({ + path: '/event-stream', + method: 'GET', + }).input(z.object({ count: z.number().optional() }).optional()), + + // Event streaming with metadata + eventStreamWithMeta: oc.route({ + path: '/event-stream-meta', + method: 'GET', + }), + + // Detailed output structure with custom status and headers + detailedResponse: oc.route({ + path: '/detailed', + method: 'POST', + outputStructure: 'detailed', + }) + .input(z.object({ data: z.string() })) + .output(z.object({ + body: z.object({ result: z.string() }), + status: z.number().optional(), + headers: z.record(z.string(), z.string()).optional(), + })), +} + +// ============================================================================ +// Test Controllers +// ============================================================================ + +@Controller() +class ResponseTypesController { + // Empty response - returns undefined + @Implement(contracts.emptyResponse) + emptyResponse() { + return implement(contracts.emptyResponse).handler(() => { + return undefined + }) + } + + // String response - returns plain text + @Implement(contracts.stringResponse) + stringResponse() { + return implement(contracts.stringResponse).handler(() => { + // Note: oRPC treats string responses as JSON by default + // This is the expected behavior according to the standard body specification + return 'Hello, World!' + }) + } + + // Object response - returns JSON + @Implement(contracts.objectResponse) + objectResponse() { + return implement(contracts.objectResponse).handler(({ input }) => { + return { + message: `Hello, ${input.name}!`, + timestamp: new Date().toISOString(), + } + }) + } + + // Array response - returns JSON array + @Implement(contracts.arrayResponse) + arrayResponse() { + return implement(contracts.arrayResponse).handler(() => { + return [ + { id: 1, value: 'first' }, + { id: 2, value: 'second' }, + { id: 3, value: 'third' }, + ] + }) + } + + // URLSearchParams response + @Implement(contracts.urlSearchParamsResponse) + urlSearchParamsResponse() { + return implement(contracts.urlSearchParamsResponse).handler(() => { + const params = new URLSearchParams() + params.append('key1', 'value1') + params.append('key2', 'value2') + params.append('items', 'item1') + params.append('items', 'item2') + return params + }) + } + + // FormData response + @Implement(contracts.formDataResponse) + formDataResponse() { + return implement(contracts.formDataResponse).handler(() => { + const formData = new FormData() + formData.append('field1', 'value1') + formData.append('field2', 'value2') + formData.append('file', new Blob(['test content'], { type: 'text/plain' }), 'test.txt') + return formData + }) + } + + // Blob response + @Implement(contracts.blobResponse) + blobResponse() { + return implement(contracts.blobResponse).handler(() => { + const content = 'This is binary blob content' + return new Blob([content], { type: 'application/octet-stream' }) + }) + } + + // File response + @Implement(contracts.fileResponse) + fileResponse() { + return implement(contracts.fileResponse).handler(() => { + const content = 'This is file content with a filename' + return new File([content], 'example.txt', { type: 'text/plain' }) + }) + } + + // Event streaming - AsyncIterable/SSE + @Implement(contracts.eventStream) + eventStream() { + return implement(contracts.eventStream).handler(async function* ({ input }) { + const count = input?.count ?? 3 + for (let i = 0; i < count; i++) { + yield { message: `Event ${i + 1}`, index: i } + // Small delay to simulate real streaming + await new Promise(resolve => setTimeout(resolve, 10)) + } + }) + } + + // Event streaming with metadata (id, retry) + @Implement(contracts.eventStreamWithMeta) + eventStreamWithMeta() { + return implement(contracts.eventStreamWithMeta).handler(async function* ({ lastEventId }) { + // Resume from lastEventId if provided + const startIndex = lastEventId ? Number.parseInt(lastEventId) + 1 : 0 + + for (let i = startIndex; i < startIndex + 3; i++) { + yield withEventMeta( + { message: `Event ${i}`, timestamp: Date.now() }, + { id: String(i), retry: 5000 }, + ) + await new Promise(resolve => setTimeout(resolve, 10)) + } + }) + } + + // Detailed output structure with custom status and headers + @Implement(contracts.detailedResponse) + detailedResponse() { + return implement(contracts.detailedResponse).handler(({ input }) => { + return { + status: 201, + headers: { + 'X-Custom-Header': 'custom-value', + 'X-Processing-Time': '100ms', + }, + body: { + result: `Processed: ${input.data}`, + }, + } + }) + } +} + +@Module({ + controllers: [ResponseTypesController], +}) +class ResponseTypesModule {} + +// ============================================================================ +// Test Suite +// ============================================================================ + +describe('oRPC response types integration', () => { + const testSuite = ( + adapterName: 'Express' | 'Fastify', + adapter: () => ExpressAdapter | FastifyAdapter, + ) => { + describe(`with ${adapterName}`, () => { + let app: INestApplication + + async function createApp() { + const moduleFixture = await Test.createTestingModule({ + imports: [ResponseTypesModule, ORPCModule.forRoot({})], + }).compile() + + app = moduleFixture.createNestApplication(adapter()) + await app.init() + if (adapterName === 'Fastify') { + await (app as any).getHttpAdapter().getInstance().ready() + } + } + + afterEach(async () => { + await app?.close() + }) + + describe('empty response', () => { + it('should handle undefined/empty response with no body', async () => { + await createApp() + + await request(app.getHttpServer()) + .get('/empty') + .expect(200) + .then((response) => { + // Body should be empty or minimal + expect(response.text).toBe('') + }) + }) + }) + + describe('string response', () => { + it('should return string as JSON (default oRPC behavior)', async () => { + await createApp() + + await request(app.getHttpServer()) + .get('/string') + .expect(200) + .expect('Content-Type', /application\/json/) + .then((response) => { + // Strings are JSON-serialized by default in oRPC + expect(response.body).toBe('Hello, World!') + }) + }) + }) + + describe('object response (JSON)', () => { + it('should return JSON object', async () => { + await createApp() + + await request(app.getHttpServer()) + .post('/object') + .send({ name: 'Alice' }) + .expect(200) + .expect('Content-Type', /application\/json/) + .then((response) => { + expect(response.body).toMatchObject({ + message: 'Hello, Alice!', + }) + expect(response.body.timestamp).toBeDefined() + expect(typeof response.body.timestamp).toBe('string') + }) + }) + }) + + describe('array response (JSON)', () => { + it('should return JSON array', async () => { + await createApp() + + await request(app.getHttpServer()) + .get('/array') + .expect(200) + .expect('Content-Type', /application\/json/) + .then((response) => { + expect(Array.isArray(response.body)).toBe(true) + expect(response.body).toHaveLength(3) + expect(response.body[0]).toEqual({ id: 1, value: 'first' }) + expect(response.body[1]).toEqual({ id: 2, value: 'second' }) + expect(response.body[2]).toEqual({ id: 3, value: 'third' }) + }) + }) + }) + + describe('url search params response', () => { + it('should return URL-encoded parameters', async () => { + await createApp() + + await request(app.getHttpServer()) + .get('/url-search-params') + .expect(200) + .expect('Content-Type', /application\/x-www-form-urlencoded/) + .then((response) => { + // Parse the response as URLSearchParams + const params = new URLSearchParams(response.text) + expect(params.get('key1')).toBe('value1') + expect(params.get('key2')).toBe('value2') + expect(params.getAll('items')).toEqual(['item1', 'item2']) + }) + }) + }) + + describe('form data response', () => { + it('should return multipart form data', async () => { + await createApp() + + const response = await request(app.getHttpServer()) + .get('/form-data') + .expect(200) + + // Verify that we got multipart content + // The exact parsing of multipart data is complex, + // but we can verify the content-type and that we got data + expect(response.headers['content-type']).toMatch(/multipart\/form-data/) + expect(response.text || response.body).toBeDefined() + }) + }) + + describe('blob response', () => { + it('should return binary blob with correct headers', async () => { + await createApp() + + await request(app.getHttpServer()) + .get('/blob') + .expect(200) + .expect('Content-Type', 'application/octet-stream') + .expect('Content-Disposition', /filename/) + .then((response) => { + expect(response.body).toBeDefined() + // Verify content length is set + expect(response.headers['content-length']).toBeDefined() + // Content-Disposition can be inline or attachment depending on implementation + expect(response.headers['content-disposition']).toMatch(/blob/) + }) + }) + }) + + describe('file response', () => { + it('should return file with filename in Content-Disposition', async () => { + await createApp() + + await request(app.getHttpServer()) + .get('/file') + .expect(200) + .expect('Content-Type', 'text/plain') + .expect('Content-Disposition', /filename/) + .then((response) => { + expect(response.text).toBe('This is file content with a filename') + // Verify filename is in Content-Disposition header + expect(response.headers['content-disposition']).toMatch(/example\.txt/) + }) + }) + }) + + describe('event streaming (SSE)', () => { + it('should stream events using Server-Sent Events', async () => { + await createApp() + + const response = await request(app.getHttpServer()) + .get('/event-stream') + .send({ count: 3 }) // Send input in request body for GET with body support + .expect(200) + .expect('Content-Type', 'text/event-stream') + .buffer(true) + .parse((res, callback) => { + let data = '' + res.on('data', (chunk) => { + data += chunk.toString() + }) + res.on('end', () => { + callback(null, data) + }) + }) + + if (!response.text) { + // Skip if response is undefined (GET with body may not be supported in all scenarios) + return + } + + // Parse SSE format + const events = response.text + .split('\n\n') + .filter(Boolean) + .map((event) => { + const dataMatch = event.match(/data: (.+)/) + return dataMatch ? JSON.parse(dataMatch[1] as string) : null + }) + .filter(Boolean) + + // Verify we got all events + expect(events.length).toBeGreaterThanOrEqual(3) + expect(events[0]).toMatchObject({ message: 'Event 1', index: 0 }) + expect(events[1]).toMatchObject({ message: 'Event 2', index: 1 }) + expect(events[2]).toMatchObject({ message: 'Event 3', index: 2 }) + }) + + it('should support event metadata (id, retry)', async () => { + await createApp() + + const response = await request(app.getHttpServer()) + .get('/event-stream-meta') + .expect(200) + .expect('Content-Type', 'text/event-stream') + .buffer(true) + .parse((res, callback) => { + let data = '' + res.on('data', (chunk) => { + data += chunk.toString() + }) + res.on('end', () => { + callback(null, data) + }) + }) + + // Parse SSE format with metadata + if (!response.text) { + // Skip test if response is undefined + return + } + + const rawEvents = response.text.split('\n\n').filter(Boolean) + + // Verify we have events with id and retry fields + expect(rawEvents.length).toBeGreaterThan(0) + + // Check first event has proper SSE format with id and retry + const firstEvent = rawEvents[0] + expect(firstEvent).toMatch(/id: \d+/) + expect(firstEvent).toMatch(/retry: 5000/) + expect(firstEvent).toMatch(/data: \{/) + }) + + it('should support resuming from lastEventId', async () => { + await createApp() + + // First, get some events + const response = await request(app.getHttpServer()) + .get('/event-stream-meta') + .set('Last-Event-ID', '1') + .expect(200) + .expect('Content-Type', 'text/event-stream') + .buffer(true) + .parse((res, callback) => { + let data = '' + res.on('data', (chunk) => { + data += chunk.toString() + }) + res.on('end', () => { + callback(null, data) + }) + }) + + if (!response.text) { + // Skip test if response is undefined + return + } + + // Parse and verify we resumed from index 2 (lastEventId + 1) + const events = response.text + .split('\n\n') + .filter(Boolean) + .map((event) => { + const idMatch = event.match(/id: (\d+)/) + const dataMatch = event.match(/data: (.+)/) + return { + id: idMatch ? idMatch[1] : null, + data: dataMatch ? JSON.parse(dataMatch[1] as string) : null, + } + }) + .filter(e => e.data) + + // First event should be index 2 (resumed from lastEventId=1) + expect(events.length).toBeGreaterThan(0) + expect(events[0]?.data.message).toBe('Event 2') + }) + }) + + describe('detailed output structure', () => { + it('should support custom status code and headers', async () => { + await createApp() + + await request(app.getHttpServer()) + .post('/detailed') + .send({ data: 'test-data' }) + .expect(201) // Custom status + .expect('X-Custom-Header', 'custom-value') + .expect('X-Processing-Time', '100ms') + .then((response) => { + expect(response.body).toEqual({ + result: 'Processed: test-data', + }) + }) + }) + }) + + describe('integration with Nest features', () => { + it('should work with all response types when interceptors are applied', async () => { + // This verifies that the response body is properly returned + // and can be modified by Nest interceptors (tested in nest-features.test.ts) + await createApp() + + // Test a few key response types + await request(app.getHttpServer()) + .get('/string') + .expect(200) + + await request(app.getHttpServer()) + .post('/object') + .send({ name: 'Test' }) + .expect(200) + + await request(app.getHttpServer()) + .get('/event-stream') + .send({ count: 2 }) // Send input in request body + .expect(200) + .expect('Content-Type', 'text/event-stream') + }) + }) + }) + } + + testSuite('Express', () => new ExpressAdapter()) + testSuite('Fastify', () => new FastifyAdapter()) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de755b092..6c39dba86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -428,6 +428,9 @@ importers: specifier: workspace:* version: link:../standard-server-node devDependencies: + '@fastify/compress': + specifier: ^8.1.0 + version: 8.1.0 '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 @@ -449,9 +452,15 @@ importers: '@ts-rest/core': specifier: ^3.52.1 version: 3.52.1(@types/node@22.18.13)(zod@4.1.12) + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 '@types/express': specifier: ^5.0.5 version: 5.0.5 + compression: + specifier: ^1.8.1 + version: 1.8.1 express: specifier: ^5.0.0 version: 5.1.0 @@ -1477,7 +1486,7 @@ importers: version: 5.90.6(vue@3.5.22(typescript@5.8.3)) nuxt: specifier: ^4.2.0 - version: 4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) + version: 4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) vue: specifier: latest version: 3.5.22(typescript@5.8.3) @@ -3016,6 +3025,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -3023,6 +3035,9 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} + '@fastify/compress@8.1.0': + resolution: {integrity: sha512-wX3I5u/SYQXxbqjG7CysvzeaCe4Sv8y13MnvnaGTpqfKkJbTLpwvdIDgqrwp/+UGvXOW7OLDLoTAQCDMJJRjDQ==} + '@fastify/cookie@11.0.2': resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} @@ -8557,6 +8572,12 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -11445,6 +11466,9 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} + peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -11859,6 +11883,9 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + pumpify@2.0.1: + resolution: {integrity: sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -12710,6 +12737,9 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -13005,6 +13035,9 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -15845,6 +15878,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fastify/accept-negotiator@2.0.1': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.17.1 @@ -15853,6 +15888,17 @@ snapshots: '@fastify/busboy@2.1.1': {} + '@fastify/compress@8.1.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + fastify-plugin: 5.1.0 + mime-db: 1.54.0 + minipass: 7.1.2 + peek-stream: 1.1.3 + pump: 3.0.3 + pumpify: 2.0.1 + readable-stream: 4.7.0 + '@fastify/cookie@11.0.2': dependencies: cookie: 1.0.2 @@ -16884,7 +16930,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.2.0(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(ioredis@5.8.2)(magicast@0.3.5)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1))(typescript@5.8.3)(xml2js@0.6.2)': + '@nuxt/nitro-server@4.2.0(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.2)(magicast@0.3.5)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1))(typescript@5.8.3)(xml2js@0.6.2)': dependencies: '@nuxt/devalue': 2.0.2 '@nuxt/kit': 4.2.0(magicast@0.3.5) @@ -16902,7 +16948,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.12.9(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(encoding@0.1.13)(xml2js@0.6.2) - nuxt: 4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) + nuxt: 4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) pathe: 2.0.3 pkg-types: 2.3.0 radix3: 1.1.2 @@ -16977,7 +17023,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/vite-builder@4.2.0(@types/node@22.18.13)(eslint@9.39.0(jiti@2.6.1))(magicast@0.3.5)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1))(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))(yaml@2.8.1)': + '@nuxt/vite-builder@4.2.0(@types/node@22.18.13)(eslint@9.39.0(jiti@2.6.1))(magicast@0.3.5)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1))(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))(yaml@2.8.1)': dependencies: '@nuxt/kit': 4.2.0(magicast@0.3.5) '@rollup/plugin-replace': 6.0.3(rollup@4.52.5) @@ -16997,7 +17043,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.0 mocked-exports: 0.1.1 - nuxt: 4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) + nuxt: 4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1) pathe: 2.0.3 pkg-types: 2.3.0 postcss: 8.5.6 @@ -22735,6 +22781,20 @@ snapshots: duplexer@0.1.2: {} + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} editorconfig@1.0.4: @@ -25938,16 +25998,16 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1): + nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1): dependencies: '@dxup/nuxt': 0.2.0(magicast@0.3.5) '@nuxt/cli': 3.29.3(magicast@0.3.5) '@nuxt/devtools': 2.7.0(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) '@nuxt/kit': 4.2.0(magicast@0.3.5) - '@nuxt/nitro-server': 4.2.0(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(ioredis@5.8.2)(magicast@0.3.5)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1))(typescript@5.8.3)(xml2js@0.6.2) + '@nuxt/nitro-server': 4.2.0(@upstash/redis@1.35.6)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(ioredis@5.8.2)(magicast@0.3.5)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1))(typescript@5.8.3)(xml2js@0.6.2) '@nuxt/schema': 4.2.0 '@nuxt/telemetry': 2.6.6(magicast@0.3.5) - '@nuxt/vite-builder': 4.2.0(@types/node@22.18.13)(eslint@9.39.0(jiti@2.6.1))(magicast@0.3.5)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1))(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))(yaml@2.8.1) + '@nuxt/vite-builder': 4.2.0(@types/node@22.18.13)(eslint@9.39.0(jiti@2.6.1))(magicast@0.3.5)(nuxt@4.2.0(@parcel/watcher@2.5.1)(@types/node@22.18.13)(@upstash/redis@1.35.6)(@vue/compiler-sfc@3.5.22)(better-sqlite3@12.4.1)(db0@0.3.4(better-sqlite3@12.4.1))(encoding@0.1.13)(eslint@9.39.0(jiti@2.6.1))(ioredis@5.8.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(xml2js@0.6.2)(yaml@2.8.1))(optionator@0.9.4)(rollup@4.52.5)(terser@5.44.0)(tsx@4.20.6)(typescript@5.8.3)(vue@3.5.22(typescript@5.8.3))(yaml@2.8.1) '@unhead/vue': 2.0.19(vue@3.5.22(typescript@5.8.3)) '@vue/shared': 3.5.22 c12: 3.3.1(magicast@0.3.5) @@ -26443,6 +26503,12 @@ snapshots: pe-library@0.4.1: {} + peek-stream@1.1.3: + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + pend@1.2.0: {} perfect-debounce@1.0.0: {} @@ -26868,6 +26934,12 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pumpify@2.0.1: + dependencies: + duplexify: 4.1.3 + inherits: 2.0.4 + pump: 3.0.3 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -27917,6 +27989,8 @@ snapshots: stoppable@1.1.0: {} + stream-shift@1.0.3: {} + streamsearch@1.1.0: {} streamx@2.23.0: @@ -28259,6 +28333,11 @@ snapshots: throttleit@2.1.0: {} + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + through@2.3.8: {} tiny-async-pool@1.3.0: From 05476c2a97be3d81e98bdefe508d6c4be4ece853 Mon Sep 17 00:00:00 2001 From: mathiasduc Date: Mon, 3 Nov 2025 09:25:47 +0100 Subject: [PATCH 2/3] fixup! chore(nest): add more test for Nest features compatibilty --- packages/nest/src/{ => test}/nest-features.test.ts | 4 +++- packages/nest/src/{ => test}/response-types.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) rename packages/nest/src/{ => test}/nest-features.test.ts (98%) rename packages/nest/src/{ => test}/response-types.test.ts (99%) diff --git a/packages/nest/src/nest-features.test.ts b/packages/nest/src/test/nest-features.test.ts similarity index 98% rename from packages/nest/src/nest-features.test.ts rename to packages/nest/src/test/nest-features.test.ts index fe1ec234e..81d0ffedf 100644 --- a/packages/nest/src/nest-features.test.ts +++ b/packages/nest/src/test/nest-features.test.ts @@ -17,7 +17,7 @@ import request from 'supertest' import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import { Implement, ORPCModule } from '.' +import { Implement, ORPCModule } from '..' // 1. oRPC Contract const testContract = { @@ -601,6 +601,8 @@ describe('oRPC Nest Middleware Integration', () => { expect(['gzip', 'deflate']).toContain(response.headers['content-encoding']) // Verify that the oRPC handler response is correctly returned (supertest auto-decompresses) + // Works because compression middleware monkeypatch the res.send method to access the body or + // use a fastify hook to access the reply payload expect(response.body).toHaveProperty('data') expect(response.body).toHaveProperty('size') expect(response.body.size).toBeGreaterThan(0) diff --git a/packages/nest/src/response-types.test.ts b/packages/nest/src/test/response-types.test.ts similarity index 99% rename from packages/nest/src/response-types.test.ts rename to packages/nest/src/test/response-types.test.ts index 498bb8e4a..23bfa80a8 100644 --- a/packages/nest/src/response-types.test.ts +++ b/packages/nest/src/test/response-types.test.ts @@ -8,8 +8,8 @@ import { implement, withEventMeta } from '@orpc/server' import request from 'supertest' import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' -import { Implement } from './implement' -import { ORPCModule } from './module' +import { Implement } from '../implement' +import { ORPCModule } from '../module' /** * Test suite for validating all supported ORPC response types work correctly From ec54944c3cf0e199c3e0f9f10a9e66f5000aaf21 Mon Sep 17 00:00:00 2001 From: unnoq Date: Mon, 3 Nov 2025 16:55:18 +0700 Subject: [PATCH 3/3] chore(nest): move test files to correct directory structure --- packages/nest/{src/test => tests}/nest-features.test.ts | 2 +- packages/nest/{src/test => tests}/response-types.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename packages/nest/{src/test => tests}/nest-features.test.ts (99%) rename packages/nest/{src/test => tests}/response-types.test.ts (99%) diff --git a/packages/nest/src/test/nest-features.test.ts b/packages/nest/tests/nest-features.test.ts similarity index 99% rename from packages/nest/src/test/nest-features.test.ts rename to packages/nest/tests/nest-features.test.ts index 81d0ffedf..7b65b0260 100644 --- a/packages/nest/src/test/nest-features.test.ts +++ b/packages/nest/tests/nest-features.test.ts @@ -17,7 +17,7 @@ import request from 'supertest' import { afterEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import { Implement, ORPCModule } from '..' +import { Implement, ORPCModule } from '../src' // 1. oRPC Contract const testContract = { diff --git a/packages/nest/src/test/response-types.test.ts b/packages/nest/tests/response-types.test.ts similarity index 99% rename from packages/nest/src/test/response-types.test.ts rename to packages/nest/tests/response-types.test.ts index 23bfa80a8..3332518ae 100644 --- a/packages/nest/src/test/response-types.test.ts +++ b/packages/nest/tests/response-types.test.ts @@ -8,8 +8,8 @@ import { implement, withEventMeta } from '@orpc/server' import request from 'supertest' import { afterEach, describe, expect, it } from 'vitest' import { z } from 'zod' -import { Implement } from '../implement' -import { ORPCModule } from '../module' +import { Implement } from '../src/implement' +import { ORPCModule } from '../src/module' /** * Test suite for validating all supported ORPC response types work correctly