diff --git a/.circleci/config.yml b/.circleci/config.yml index d4de5ede7c..6d3917a239 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -124,9 +124,7 @@ jobs: - *install-deps - run: name: Prepare tests - command: | - bash ./scripts/prepare.sh - sleep 10 + command: bash ./scripts/prepare.sh - run: name: List containers command: docker ps diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 528740cec5..06873fcbc2 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,7 @@ Please consider what kind of change it is: Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available. -We want to fix all the issues as soon as possible, but before fixing a bug we need to reproduce and confirm it. In order to reproduce bugs we will systematically ask you to provide a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us wealth of important information without going back & forth to you with additional questions like: +We want to fix all the issues as soon as possible, but before fixing a bug, we need to reproduce and confirm it. In order to reproduce bugs we will systematically ask you to provide a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us a wealth of important information without going back & forth to you with additional questions like: - version of NestJS used - 3rd-party libraries and their versions @@ -202,7 +202,7 @@ To ensure consistency throughout the source code, keep these rules in mind as yo // We're working on auto-documentation. * All public API methods **must be documented**. (Details TBC). --> -- All features or bug fixes **must be tested** by one or more specs (unit-tests). +- All features or bug fixes **must be tested** by one or more specs (unit tests). - We follow [Google's JavaScript Style Guide][js-style-guide], but wrap all code at **100 characters**. An automated formatter is available (`npm run format`). diff --git a/Readme.md b/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/Readme.md +++ b/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/integration/_support/register-local-packages.ts b/integration/_support/register-local-packages.ts new file mode 100644 index 0000000000..5dfcc76e51 --- /dev/null +++ b/integration/_support/register-local-packages.ts @@ -0,0 +1,69 @@ +import * as path from 'path'; + +type ResolveFilename = ( + request: string, + parent: NodeModule | null | undefined, + isMain: boolean, + options?: Record, +) => string; + +const Module = require('module') as { + _resolveFilename: ResolveFilename; +}; + +const workspaceRoot = path.join(__dirname, '..', '..'); +const packageRoots = new Map([ + ['@nestjs/common', path.join(workspaceRoot, 'packages/common')], + ['@nestjs/core', path.join(workspaceRoot, 'packages/core')], + ['@nestjs/microservices', path.join(workspaceRoot, 'packages/microservices')], + [ + '@nestjs/platform-express', + path.join(workspaceRoot, 'packages/platform-express'), + ], + [ + '@nestjs/platform-fastify', + path.join(workspaceRoot, 'packages/platform-fastify'), + ], + [ + '@nestjs/platform-socket.io', + path.join(workspaceRoot, 'packages/platform-socket.io'), + ], + ['@nestjs/platform-ws', path.join(workspaceRoot, 'packages/platform-ws')], + ['@nestjs/testing', path.join(workspaceRoot, 'packages/testing')], + ['@nestjs/websockets', path.join(workspaceRoot, 'packages/websockets')], +]); + +const originalResolveFilename = Module._resolveFilename; + +Module._resolveFilename = function resolveFilename( + request, + parent, + isMain, + options, +) { + const directMatch = packageRoots.get(request); + if (directMatch) { + return originalResolveFilename.call( + this, + directMatch, + parent, + isMain, + options, + ); + } + + for (const [packageName, packageRoot] of packageRoots) { + if (request.startsWith(`${packageName}/`)) { + const relativePath = request.slice(packageName.length + 1); + return originalResolveFilename.call( + this, + path.join(packageRoot, relativePath), + parent, + isMain, + options, + ); + } + } + + return originalResolveFilename.call(this, request, parent, isMain, options); +}; diff --git a/integration/docker-compose.yml b/integration/docker-compose.yml index c5b4c9786e..4aa65f0c78 100644 --- a/integration/docker-compose.yml +++ b/integration/docker-compose.yml @@ -25,7 +25,7 @@ services: - "9001:9001" restart: always mysql: - image: mysql:9.6.0 + image: mysql:9.7.0 environment: MYSQL_ROOT_HOST: '%' MYSQL_ROOT_PASSWORD: root @@ -51,7 +51,7 @@ services: zookeeper: container_name: test-zookeeper hostname: zookeeper - image: confluentinc/cp-zookeeper:7.9.5 + image: confluentinc/cp-zookeeper:7.9.6 ports: - "2181:2181" environment: @@ -60,7 +60,7 @@ services: kafka: container_name: test-kafka hostname: kafka - image: confluentinc/cp-kafka:8.1.1 + image: confluentinc/cp-kafka:8.2.0 depends_on: - zookeeper ports: diff --git a/integration/hello-world/e2e/middleware-fastify.spec.ts b/integration/hello-world/e2e/middleware-fastify.spec.ts index ad2ce09b93..66d5506df0 100644 --- a/integration/hello-world/e2e/middleware-fastify.spec.ts +++ b/integration/hello-world/e2e/middleware-fastify.spec.ts @@ -611,4 +611,185 @@ describe('Middleware (FastifyAdapter)', () => { await app.close(); }); }); + + describe('should respect fastify routing options', () => { + const MIDDLEWARE_RETURN_VALUE = 'middleware_return'; + + @Controller() + class TestController { + @Get('abc/def') + included() { + return 'whatnot'; + } + } + @Module({ + imports: [AppModule], + controllers: [TestController], + }) + class TestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply((req, res, next) => res.end(MIDDLEWARE_RETURN_VALUE)) + .forRoutes({ path: 'abc/def', method: RequestMethod.GET }); + } + } + + describe('[ignoreTrailingSlash] attribute', () => { + beforeEach(async () => { + app = ( + await Test.createTestingModule({ + imports: [TestModule], + }).compile() + ).createNestApplication( + new FastifyAdapter({ + ignoreTrailingSlash: true, + // routerOptions: { + // ignoreTrailingSlash: true, + // }, + }), + ); + + await app.init(); + }); + + it(`GET forRoutes(GET /abc/def/)`, () => { + return app + .inject({ + method: 'GET', + url: '/abc/def/', // trailing slash + }) + .then(({ payload }) => + expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE), + ); + }); + + afterEach(async () => { + await app.close(); + }); + }); + + describe('[ignoreDuplicateSlashes] attribute', () => { + beforeEach(async () => { + app = ( + await Test.createTestingModule({ + imports: [TestModule], + }).compile() + ).createNestApplication( + new FastifyAdapter({ + routerOptions: { + ignoreDuplicateSlashes: true, + }, + }), + ); + + await app.init(); + }); + + it(`GET forRoutes(GET /abc//def)`, () => { + return app + .inject({ + method: 'GET', + url: '/abc//def', // duplicate slashes + }) + .then(({ payload }) => + expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE), + ); + }); + + afterEach(async () => { + await app.close(); + }); + }); + + describe('[caseSensitive] attribute', () => { + beforeEach(async () => { + app = ( + await Test.createTestingModule({ + imports: [TestModule], + }).compile() + ).createNestApplication( + new FastifyAdapter({ + routerOptions: { + caseSensitive: true, + }, + }), + ); + + await app.init(); + }); + + it(`GET forRoutes(GET /ABC/DEF)`, () => { + return app + .inject({ + method: 'GET', + url: '/ABC/DEF', // different case + }) + .then(({ payload }) => + expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE), + ); + }); + + afterEach(async () => { + await app.close(); + }); + }); + + describe('[useSemicolonDelimiter] attribute', () => { + beforeEach(async () => { + app = ( + await Test.createTestingModule({ + imports: [TestModule], + }).compile() + ).createNestApplication( + new FastifyAdapter({ + routerOptions: { useSemicolonDelimiter: true } as any, + }), + ); + + await app.init(); + }); + + it(`GET forRoutes(GET /abc/def;foo=bar)`, () => { + return app + .inject({ + method: 'GET', + url: '/abc/def;foo=bar', // semicolon delimiter + }) + .then(({ payload }) => + expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE), + ); + }); + + afterEach(async () => { + await app.close(); + }); + }); + + describe('HEAD auto-forwarding to GET', () => { + beforeEach(async () => { + app = ( + await Test.createTestingModule({ + imports: [TestModule], + }).compile() + ).createNestApplication(new FastifyAdapter()); + + await app.init(); + }); + + it(`GET forRoutes(HEAD /abc/def)`, () => { + return app + .inject({ + method: 'HEAD', + url: '/abc/def', + }) + .then(({ payload }) => + expect(payload).to.be.eql(MIDDLEWARE_RETURN_VALUE), + ); + }); + + afterEach(async () => { + await app.close(); + }); + }); + }); }); diff --git a/integration/microservices/e2e/binary-redis.spec.ts b/integration/microservices/e2e/binary-redis.spec.ts new file mode 100644 index 0000000000..ba8dc95ba7 --- /dev/null +++ b/integration/microservices/e2e/binary-redis.spec.ts @@ -0,0 +1,115 @@ +import { INestApplication } from '@nestjs/common'; +import { + ClientRedis, + ClientsModule, + Deserializer, + IncomingResponse, + Transport, +} from '@nestjs/microservices'; +import { Test } from '@nestjs/testing'; +import { lastValueFrom } from 'rxjs'; +import Redis from 'ioredis'; + +class BinaryDeserializer implements Deserializer { + deserialize(value: Buffer): IncomingResponse { + const firstSeparatorIndex = value.indexOf(':'); + const secondSeparatorIndex = value.indexOf(':', firstSeparatorIndex + 1); + + return { + id: value.subarray(0, firstSeparatorIndex).toString(), + isDisposed: true, + err: null, + response: value.subarray(secondSeparatorIndex + 1), + }; + } +} + +describe('REDIS transport', () => { + let app: INestApplication; + let client: ClientRedis; + let pub: Redis; + let sub: Redis; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [ + ClientsModule.register({ + clients: [ + { + name: 'REDIS_SERVICE', + transport: Transport.REDIS, + options: { + returnBuffers: true, + host: '0.0.0.0', + port: 6379, + deserializer: new BinaryDeserializer(), + }, + }, + ], + }), + ], + }).compile(); + + app = module.createNestApplication(); + + pub = new Redis(); + sub = new Redis(); + + await sub.subscribe('binary', (_, __) => {}); + + sub.on('message', async (channel, message) => { + const data = JSON.parse(message); + const delay = data.data === 'slow' ? 25 : 0; + const responseBody = + data.data === 'bytes' + ? Buffer.from([0, 1, 2, 3, 255]) + : Buffer.from(`${data.data}-replied`); + const response = Buffer.concat([ + Buffer.from(`${data.id}:${channel}:`), + responseBody, + ]); + + if (delay > 0) { + await new Promise(resolve => setTimeout(resolve, delay)); + } + + await pub.publish(`${channel}.reply`, response); + }); + + client = app.get('REDIS_SERVICE'); + + await client.connect(); + + await app.init(); + }); + + it('should return a raw binary payload', async () => { + const data = await lastValueFrom(client.send('binary', 'data')); + expect(Buffer.isBuffer(data)).toBe(true); + expect(data).toEqual(Buffer.from('data-replied')); + }); + + it('should route concurrent raw binary replies to the matching request', async () => { + const [slowResponse, fastResponse] = await Promise.all([ + lastValueFrom(client.send('binary', 'slow')), + lastValueFrom(client.send('binary', 'fast')), + ]); + expect(Buffer.isBuffer(slowResponse)).toBe(true); + expect(Buffer.isBuffer(fastResponse)).toBe(true); + expect(slowResponse).toEqual(Buffer.from('slow-replied')); + expect(fastResponse).toEqual(Buffer.from('fast-replied')); + }); + + it('should preserve non-utf8 bytes in the raw binary payload', async () => { + const data = await lastValueFrom(client.send('binary', 'bytes')); + expect(Buffer.isBuffer(data)).toBe(true); + expect(data).toEqual(Buffer.from([0, 1, 2, 3, 255])); + }); + + afterEach(async () => { + await app.close(); + await client.close(); + await pub.quit(); + await sub.quit(); + }); +}); diff --git a/integration/microservices/e2e/fanout-exchange-rmq.spec.ts b/integration/microservices/e2e/fanout-exchange-rmq.spec.ts index 4561ed8e32..3cd5f967bf 100644 --- a/integration/microservices/e2e/fanout-exchange-rmq.spec.ts +++ b/integration/microservices/e2e/fanout-exchange-rmq.spec.ts @@ -24,7 +24,7 @@ describe('RabbitMQ transport (Fanout Exchange)', () => { appConsumer = consumerModule.createNestMicroservice({ transport: Transport.RMQ, options: { - urls: [`amqp://0.0.0.0:5672`], + urls: [`amqp://localhost:5672`], queue: '', exchange: 'test.fanout', exchangeType: 'fanout', diff --git a/integration/microservices/e2e/sum-rmq.spec.ts b/integration/microservices/e2e/sum-rmq.spec.ts index d5dc57ba9f..ff9ebdf738 100644 --- a/integration/microservices/e2e/sum-rmq.spec.ts +++ b/integration/microservices/e2e/sum-rmq.spec.ts @@ -19,9 +19,9 @@ describe('RabbitMQ transport', () => { app.connectMicroservice({ transport: Transport.RMQ, options: { - urls: [`amqp://0.0.0.0:5672`], + urls: [`amqp://localhost:5672`], queue: 'test', - queueOptions: { durable: false }, + queueOptions: { durable: true }, socketOptions: { noDelay: true }, }, }); diff --git a/integration/microservices/e2e/tcp-json-socket-pipeline.spec.ts b/integration/microservices/e2e/tcp-json-socket-pipeline.spec.ts new file mode 100644 index 0000000000..9e6105ecbb --- /dev/null +++ b/integration/microservices/e2e/tcp-json-socket-pipeline.spec.ts @@ -0,0 +1,159 @@ +/** + * Integration test for the TCP json-socket pipelined-frames stack-overflow fix. + * + * Vulnerability: handleData() called itself recursively once per fully-received + * frame, causing RangeError: Maximum call stack size exceeded for payloads with + * many back-to-back frames (e.g. `"2#{}" * 12_000`). + */ +import { Controller, INestMicroservice } from '@nestjs/common'; +import { + MessagePattern, + MicroserviceOptions, + Transport, +} from '@nestjs/microservices'; +import { Test } from '@nestjs/testing'; +import * as net from 'net'; + +@Controller() +class PingController { + @MessagePattern({ cmd: 'ping' }) + ping(): string { + return 'pong'; + } +} + +function frame(obj: unknown): string { + const json = JSON.stringify(obj); + return `${json.length}#${json}`; +} + +/** + * Decode as many complete `"#"` frames as possible from `raw`, + * returning parsed objects and the leftover bytes. + */ +function parseFrames(raw: string): { + frames: Record[]; + remaining: string; +} { + const frames: Record[] = []; + let buf = raw; + + while (true) { + const delim = buf.indexOf('#'); + if (delim === -1) break; + + const len = parseInt(buf.substring(0, delim), 10); + if (isNaN(len)) break; + + const payload = buf.substring(delim + 1); + if (payload.length < len) break; + + frames.push( + JSON.parse(payload.substring(0, len)) as Record, + ); + buf = payload.substring(len); + } + + return { frames, remaining: buf }; +} + +const TEST_PORT = 4500; + +describe('TCP pipelined frames – stack-overflow regression (CVE fix)', () => { + let app: INestMicroservice; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + controllers: [PingController], + }).compile(); + + app = module.createNestMicroservice({ + transport: Transport.TCP, + options: { host: '127.0.0.1', port: TEST_PORT }, + logger: false, + }); + await app.listen(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('server remains responsive after receiving a 12_000-frame pipelined burst', async () => { + /** + * Each `2#{}` frame is a valid JsonSocket event frame (payload `{}`, length + * 2 characters). The server treats it as a fire-and-forget event (no `id` + * field → no response), so no back-pressure from server replies. + */ + const FRAME_COUNT = 12_000; + const burstFrame = '2#{}'; + + const pingPayload = JSON.stringify({ + pattern: { cmd: 'ping' }, + data: null, + id: 'integration-test-ping', + }); + const pingFrame = frame({ + pattern: { cmd: 'ping' }, + data: null, + id: 'integration-test-ping', + }); + + expect(pingFrame).toBe(`${pingPayload.length}#${pingPayload}`); + + await new Promise((resolve, reject) => { + let rawBuf = ''; + const socket = new net.Socket(); + + const done = (err?: Error) => { + socket.destroy(); + err ? reject(err) : resolve(); + }; + + const timeout = setTimeout( + () => + done( + new Error( + 'Timed out waiting for pong – the server likely crashed during the burst', + ), + ), + 8_000, + ); + + socket.setEncoding('utf8'); + + socket.on('error', err => { + clearTimeout(timeout); + done( + new Error(`Socket error (server may have crashed): ${err.message}`), + ); + }); + + socket.on('data', (chunk: string) => { + rawBuf += chunk; + const { frames } = parseFrames(rawBuf); + + const pong = frames.find(f => f['id'] === 'integration-test-ping'); + if (pong) { + clearTimeout(timeout); + try { + expect(pong['response']).toBe('pong'); + expect(pong['err']).toBeUndefined(); + done(); + } catch (e) { + done(e as Error); + } + } + }); + + socket.connect(TEST_PORT, '127.0.0.1', () => { + // Send burst + ping as a single write so they arrive in one TCP segment + // and exercise the iterative parsing loop in one handleData() call. + const combined = Buffer.from( + burstFrame.repeat(FRAME_COUNT) + pingFrame, + ); + socket.write(combined); + }); + }); + }, 15_000); +}); diff --git a/integration/microservices/e2e/topic-exchange-rmq.spec.ts b/integration/microservices/e2e/topic-exchange-rmq.spec.ts index d7b9ae3458..5ec03597d1 100644 --- a/integration/microservices/e2e/topic-exchange-rmq.spec.ts +++ b/integration/microservices/e2e/topic-exchange-rmq.spec.ts @@ -19,7 +19,7 @@ describe('RabbitMQ transport (Topic Exchange - wildcards)', () => { app.connectMicroservice({ transport: Transport.RMQ, options: { - urls: [`amqp://0.0.0.0:5672`], + urls: [`amqp://localhost:5672`], queue: 'test2', wildcards: true, }, diff --git a/integration/microservices/src/rmq/rmq-broadcast.controller.ts b/integration/microservices/src/rmq/rmq-broadcast.controller.ts index c62ce381f2..8bdb927421 100644 --- a/integration/microservices/src/rmq/rmq-broadcast.controller.ts +++ b/integration/microservices/src/rmq/rmq-broadcast.controller.ts @@ -18,7 +18,7 @@ export class RMQBroadcastController { options: { urls: [`amqp://localhost:5672`], queue: 'test_broadcast', - queueOptions: { durable: false }, + queueOptions: { durable: true }, socketOptions: { noDelay: true }, }, }); diff --git a/integration/microservices/src/rmq/rmq.controller.ts b/integration/microservices/src/rmq/rmq.controller.ts index 07f8075e6b..f2543a4ed9 100644 --- a/integration/microservices/src/rmq/rmq.controller.ts +++ b/integration/microservices/src/rmq/rmq.controller.ts @@ -25,7 +25,7 @@ export class RMQController { options: { urls: [`amqp://localhost:5672`], queue: 'test', - queueOptions: { durable: false }, + queueOptions: { durable: true }, socketOptions: { noDelay: true }, }, }); @@ -69,7 +69,7 @@ export class RMQController { options: { urls: [`amqp://localhost:5671`, `amqp://localhost:5672`], queue: 'test', - queueOptions: { durable: false }, + queueOptions: { durable: true }, socketOptions: { noDelay: true }, }, }); diff --git a/integration/nest-application/get-url/e2e/utils.ts b/integration/nest-application/get-url/e2e/utils.ts index d5755ad995..9c7dc5c8a3 100644 --- a/integration/nest-application/get-url/e2e/utils.ts +++ b/integration/nest-application/get-url/e2e/utils.ts @@ -15,3 +15,18 @@ export async function randomPort(): Promise { }); }); } + +export async function getAvailableIpv4Host( + preferredHost = '127.0.0.5', +): Promise { + const server = net.createServer(); + + return new Promise(resolve => { + server.once('error', () => { + resolve('127.0.0.1'); + }); + server.listen(0, preferredHost, () => { + server.close(() => resolve(preferredHost)); + }); + }); +} diff --git a/integration/nest-application/sse/src/app.controller.ts b/integration/nest-application/sse/src/app.controller.ts index e740d542b0..bb19330048 100644 --- a/integration/nest-application/sse/src/app.controller.ts +++ b/integration/nest-application/sse/src/app.controller.ts @@ -1,5 +1,13 @@ -import { Controller, MessageEvent, Sse } from '@nestjs/common'; -import { interval, map, Observable } from 'rxjs'; +import { Type } from 'class-transformer'; +import { IsInt } from 'class-validator'; +import { Controller, MessageEvent, Query, Sse } from '@nestjs/common'; +import { interval, map, Observable, of } from 'rxjs'; + +class SseQueryDto { + @Type(() => Number) + @IsInt() + limit!: number; +} @Controller() export class AppController { @@ -9,4 +17,25 @@ export class AppController { map(() => ({ data: { hello: 'world' } }) as MessageEvent), ); } + + @Sse('sse/validated') + sseWithValidatedQuery(@Query() query: SseQueryDto): Observable { + return of({ data: { limit: query.limit } }); + } + + @Sse('sse/burst') + sseBurst( + @Query('n') n = '20', + @Query('size') size = '65536', + ): Observable { + const count = parseInt(n, 10); + const payload = 'X'.repeat(parseInt(size, 10)); + + return new Observable(subscriber => { + for (let i = 0; i < count; i++) { + subscriber.next({ data: payload }); + } + subscriber.complete(); + }); + } } diff --git a/integration/scopes/e2e/transient-scope.spec.ts b/integration/scopes/e2e/transient-scope.spec.ts index c6f777186d..6b969759bf 100644 --- a/integration/scopes/e2e/transient-scope.spec.ts +++ b/integration/scopes/e2e/transient-scope.spec.ts @@ -185,6 +185,221 @@ describe('Transient scope', () => { }); }); + describe('when multiple DEFAULT parents inject the same TRANSIENT -> TRANSIENT chain', () => { + let app: INestApplication; + + @Injectable({ scope: Scope.TRANSIENT }) + class IsolatedNestedTransient { + public static instanceCount = 0; + public readonly instanceId: number; + + constructor() { + IsolatedNestedTransient.instanceCount++; + this.instanceId = IsolatedNestedTransient.instanceCount; + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class IsolatedTransientLogger { + public static instanceCount = 0; + public readonly instanceId: number; + + constructor(public readonly nested: IsolatedNestedTransient) { + IsolatedTransientLogger.instanceCount++; + this.instanceId = IsolatedTransientLogger.instanceCount; + } + } + + @Injectable() + class ServiceA { + constructor(public readonly logger: IsolatedTransientLogger) {} + } + + @Injectable() + class ServiceB { + constructor(public readonly logger: IsolatedTransientLogger) {} + } + + beforeAll(async () => { + IsolatedNestedTransient.instanceCount = 0; + IsolatedTransientLogger.instanceCount = 0; + + const module = await Test.createTestingModule({ + providers: [ + ServiceA, + ServiceB, + IsolatedTransientLogger, + IsolatedNestedTransient, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + it('should create separate TransientLogger instances for each DEFAULT parent', () => { + const serviceA = app.get(ServiceA); + const serviceB = app.get(ServiceB); + + expect(serviceA.logger.instanceId).to.not.equal( + serviceB.logger.instanceId, + ); + }); + + it('should create separate nested TRANSIENT instances for each DEFAULT parent', () => { + const serviceA = app.get(ServiceA); + const serviceB = app.get(ServiceB); + + expect(serviceA.logger.nested.instanceId).to.not.equal( + serviceB.logger.nested.instanceId, + ); + }); + + afterAll(async () => { + await app.close(); + }); + }); + + describe('when multiple DEFAULT parents inject a deeply nested TRANSIENT chain', () => { + let app: INestApplication; + + @Injectable({ scope: Scope.TRANSIENT }) + class DepthSixTransient { + public static constructorCalled = false; + public static instanceCount = 0; + public readonly instanceId: number; + public readonly initialized = true; + + constructor() { + DepthSixTransient.constructorCalled = true; + DepthSixTransient.instanceCount++; + this.instanceId = DepthSixTransient.instanceCount; + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class DepthFiveTransient { + public static constructorCalled = false; + public static instanceCount = 0; + public readonly instanceId: number; + + constructor(public readonly next: DepthSixTransient) { + DepthFiveTransient.constructorCalled = true; + DepthFiveTransient.instanceCount++; + this.instanceId = DepthFiveTransient.instanceCount; + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class DepthFourTransient { + public static constructorCalled = false; + + constructor(public readonly next: DepthFiveTransient) { + DepthFourTransient.constructorCalled = true; + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class DepthThreeTransient { + public static constructorCalled = false; + + constructor(public readonly next: DepthFourTransient) { + DepthThreeTransient.constructorCalled = true; + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class DepthTwoTransient { + public static constructorCalled = false; + + constructor(public readonly next: DepthThreeTransient) { + DepthTwoTransient.constructorCalled = true; + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class DepthOneTransient { + public static constructorCalled = false; + + constructor(public readonly next: DepthTwoTransient) { + DepthOneTransient.constructorCalled = true; + } + } + + @Injectable() + class DeepServiceA { + constructor(public readonly chain: DepthOneTransient) {} + } + + @Injectable() + class DeepServiceB { + constructor(public readonly chain: DepthOneTransient) {} + } + + beforeAll(async () => { + DepthOneTransient.constructorCalled = false; + DepthTwoTransient.constructorCalled = false; + DepthThreeTransient.constructorCalled = false; + DepthFourTransient.constructorCalled = false; + DepthFiveTransient.constructorCalled = false; + DepthSixTransient.constructorCalled = false; + DepthFiveTransient.instanceCount = 0; + DepthSixTransient.instanceCount = 0; + + const module = await Test.createTestingModule({ + providers: [ + DeepServiceA, + DeepServiceB, + DepthOneTransient, + DepthTwoTransient, + DepthThreeTransient, + DepthFourTransient, + DepthFiveTransient, + DepthSixTransient, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + it('should create separate level-5 transient instances for each DEFAULT parent', () => { + const serviceA = app.get(DeepServiceA); + const serviceB = app.get(DeepServiceB); + + expect(serviceA.chain.next.next.next.next.instanceId).to.not.equal( + serviceB.chain.next.next.next.next.instanceId, + ); + }); + + it('should create separate level-6 transient instances for each DEFAULT parent', () => { + const serviceA = app.get(DeepServiceA); + const serviceB = app.get(DeepServiceB); + + expect(serviceA.chain.next.next.next.next.next.instanceId).to.not.equal( + serviceB.chain.next.next.next.next.next.instanceId, + ); + expect(serviceA.chain.next.next.next.next.next.initialized).to.be.true; + expect(serviceB.chain.next.next.next.next.next.initialized).to.be.true; + }); + + it('should call constructors for every transient provider in the deep chain', () => { + app.get(DeepServiceA); + app.get(DeepServiceB); + + expect(DepthOneTransient.constructorCalled).to.be.true; + expect(DepthTwoTransient.constructorCalled).to.be.true; + expect(DepthThreeTransient.constructorCalled).to.be.true; + expect(DepthFourTransient.constructorCalled).to.be.true; + expect(DepthFiveTransient.constructorCalled).to.be.true; + expect(DepthSixTransient.constructorCalled).to.be.true; + }); + + afterAll(async () => { + await app.close(); + }); + }); + describe('when nested transient providers are used in request scope', () => { let server: any; let app: INestApplication; diff --git a/package-lock.json b/package-lock.json index 1e5de2bde6..a080848456 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10739,6 +10739,7 @@ "version": "5.7.4", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "dev": true, "funding": [ { "type": "github", @@ -10954,6 +10955,7 @@ "version": "9.4.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -17272,6 +17274,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -17493,6 +17496,7 @@ "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.6" @@ -17724,6 +17728,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "dev": true, "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -17742,6 +17747,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -17751,6 +17757,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -17760,6 +17767,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -17772,6 +17780,7 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, "license": "MIT", "dependencies": { "media-typer": "0.3.0", @@ -24889,6 +24898,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -24943,6 +24953,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -25038,10 +25049,10 @@ }, "packages/common": { "name": "@nestjs/common", - "version": "11.1.13", + "version": "11.1.20", "license": "MIT", "dependencies": { - "file-type": "21.3.0", + "file-type": "21.3.4", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", @@ -25066,21 +25077,39 @@ } } }, + "packages/common/node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "packages/core": { "name": "@nestjs/core", - "version": "11.1.13", + "version": "11.1.20", "hasInstallScript": true, "license": "MIT", "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "devDependencies": { - "@nestjs/common": "11.1.13" + "@nestjs/common": "11.1.20" }, "engines": { "node": ">= 20" @@ -25109,17 +25138,27 @@ } } }, + "packages/core/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/microservices": { "name": "@nestjs/microservices", - "version": "11.1.13", + "version": "11.1.20", "license": "MIT", "dependencies": { "iterare": "1.2.1", "tslib": "2.8.1" }, "devDependencies": { - "@nestjs/common": "11.1.13", - "@nestjs/core": "11.1.13" + "@nestjs/common": "11.1.20", + "@nestjs/core": "11.1.20" }, "funding": { "type": "opencollective", @@ -25172,18 +25211,18 @@ }, "packages/platform-express": { "name": "@nestjs/platform-express", - "version": "11.1.13", + "version": "11.1.20", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.0.2", - "path-to-regexp": "8.3.0", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "devDependencies": { - "@nestjs/common": "11.1.13", - "@nestjs/core": "11.1.13" + "@nestjs/common": "11.1.20", + "@nestjs/core": "11.1.20" }, "funding": { "type": "opencollective", @@ -25194,19 +25233,91 @@ "@nestjs/core": "^11.0.0" } }, + "packages/platform-express/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "packages/platform-express/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "packages/platform-express/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "packages/platform-express/node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/platform-express/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "packages/platform-express/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "packages/platform-fastify": { "name": "@nestjs/platform-fastify", - "version": "11.1.13", + "version": "11.1.20", "license": "MIT", "dependencies": { "@fastify/cors": "11.2.0", "@fastify/formbody": "8.0.2", "fast-querystring": "1.1.2", - "fastify": "5.7.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "find-my-way": "9.4.0", + "find-my-way": "9.6.0", "light-my-request": "6.6.0", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "reusify": "1.1.0", "tslib": "2.8.1" }, @@ -25229,9 +25340,66 @@ } } }, + "packages/platform-fastify/node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "packages/platform-fastify/node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "packages/platform-fastify/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/platform-socket.io": { "name": "@nestjs/platform-socket.io", - "version": "11.1.13", + "version": "11.1.20", "license": "MIT", "dependencies": { "socket.io": "4.8.3", @@ -25249,11 +25417,11 @@ }, "packages/platform-ws": { "name": "@nestjs/platform-ws", - "version": "11.1.13", + "version": "11.1.20", "license": "MIT", "dependencies": { "tslib": "2.8.1", - "ws": "8.19.0" + "ws": "8.20.1" }, "funding": { "type": "opencollective", @@ -25265,9 +25433,30 @@ "rxjs": "^7.1.0" } }, + "packages/platform-ws/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/testing": { "name": "@nestjs/testing", - "version": "11.1.13", + "version": "11.1.20", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -25293,7 +25482,7 @@ }, "packages/websockets": { "name": "@nestjs/websockets", - "version": "11.1.13", + "version": "11.1.20", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -25301,8 +25490,8 @@ "tslib": "2.8.1" }, "devDependencies": { - "@nestjs/common": "11.1.13", - "@nestjs/core": "11.1.13" + "@nestjs/common": "11.1.20", + "@nestjs/core": "11.1.20" }, "peerDependencies": { "@nestjs/common": "^11.0.0", diff --git a/packages/common/Readme.md b/packages/common/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/common/Readme.md +++ b/packages/common/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/common/decorators/core/use-pipes.decorator.ts b/packages/common/decorators/core/use-pipes.decorator.ts index 72c66d8242..682e996e85 100644 --- a/packages/common/decorators/core/use-pipes.decorator.ts +++ b/packages/common/decorators/core/use-pipes.decorator.ts @@ -38,6 +38,7 @@ export function UsePipes( pipe && (isFunction(pipe) || isFunction(pipe.transform)); if (descriptor) { + validateEach(target.constructor, pipes, isPipeValid, '@UsePipes', 'pipe'); extendArrayMetadata(PIPES_METADATA, pipes, descriptor.value); return descriptor; } diff --git a/packages/common/package.json b/packages/common/package.json index e658f0743e..5d167a0791 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/common", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@common)", "author": "Kamil Mysliwiec", "homepage": "https://nestjs.com", @@ -26,7 +26,7 @@ "./*": "./*.js" }, "dependencies": { - "file-type": "21.3.0", + "file-type": "21.3.4", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", diff --git a/packages/common/pipes/file/file-type.validator.ts b/packages/common/pipes/file/file-type.validator.ts index a03cb73e3e..a4cb3698a3 100644 --- a/packages/common/pipes/file/file-type.validator.ts +++ b/packages/common/pipes/file/file-type.validator.ts @@ -78,6 +78,21 @@ export class FileTypeValidator extends FileValidator< : errorMessage; } + /** + * If the file buffer is not available, and fallbackToMimetype is not enabled, + * we cannot perform magic number validation, + * so we return a specific error message indicating + * that validation could not be performed due to missing buffer. + */ + if ( + file?.mimetype && + !file.buffer && + !this.validationOptions?.fallbackToMimetype && + !this.validationOptions?.skipMagicNumbersValidation + ) { + return `Validation failed (file buffer is not available; file type validation could not be performed; expected type is ${this.validationOptions.fileType})`; + } + if (file?.mimetype) { const baseMessage = `Validation failed (current file type is ${file.mimetype}, expected type is ${this.validationOptions.fileType})`; diff --git a/packages/common/pipes/parse-date.pipe.ts b/packages/common/pipes/parse-date.pipe.ts index 01fddce617..b889415384 100644 --- a/packages/common/pipes/parse-date.pipe.ts +++ b/packages/common/pipes/parse-date.pipe.ts @@ -55,7 +55,7 @@ export class ParseDatePipe implements PipeTransform { return this.options.default ? this.options.default() : value; } - if (!value) { + if (isNil(value) || value === '') { throw this.exceptionFactory('Validation failed (no Date provided)'); } diff --git a/packages/common/services/console-logger.service.ts b/packages/common/services/console-logger.service.ts index b506a04789..2fee0234f6 100644 --- a/packages/common/services/console-logger.service.ts +++ b/packages/common/services/console-logger.service.ts @@ -598,7 +598,7 @@ export class ConsoleLogger implements LoggerService { return value; } - private getContextAndMessagesToPrint(args: unknown[]) { + protected getContextAndMessagesToPrint(args: unknown[]) { if (args?.length <= 1) { return { messages: args, context: this.context }; } @@ -638,7 +638,7 @@ export class ConsoleLogger implements LoggerService { return { messages, context, params }; } - private getContextAndStackAndMessagesToPrint(args: unknown[]) { + protected getContextAndStackAndMessagesToPrint(args: unknown[]) { if (args.length === 2) { if (this.isStackFormat(args[1])) { return { @@ -682,7 +682,7 @@ export class ConsoleLogger implements LoggerService { }; } - private isStackFormat(stack: unknown) { + protected isStackFormat(stack: unknown) { if (!isString(stack) && !isUndefined(stack)) { return false; } @@ -690,7 +690,7 @@ export class ConsoleLogger implements LoggerService { return /^(.)+\n\s+at .+:\d+:\d+/.test(stack!); } - private getColorByLogLevel(level: LogLevel) { + protected getColorByLogLevel(level: LogLevel) { switch (level) { case 'debug': return clc.magentaBright; diff --git a/packages/common/test/decorators/use-pipes.decorator.spec.ts b/packages/common/test/decorators/use-pipes.decorator.spec.ts index 2fbaf5dc32..10b2c1047a 100644 --- a/packages/common/test/decorators/use-pipes.decorator.spec.ts +++ b/packages/common/test/decorators/use-pipes.decorator.spec.ts @@ -33,5 +33,11 @@ describe('@UsePipes', () => { } catch (e) { expect(e).toBeInstanceOf(InvalidDecoratorItemException); } + + try { + UsePipes('test' as any)(() => {}, 'test', {}); // with descriptor + } catch (e) { + expect(e).to.be.instanceof(InvalidDecoratorItemException); + } }); }); diff --git a/packages/common/test/pipes/file/file-type.validator.spec.ts b/packages/common/test/pipes/file/file-type.validator.spec.ts index b6808d62ee..ec79faa63d 100644 --- a/packages/common/test/pipes/file/file-type.validator.spec.ts +++ b/packages/common/test/pipes/file/file-type.validator.spec.ts @@ -14,6 +14,10 @@ const jpegBuffer = Buffer.from([ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, ]); +const pdfBuffer = Buffer.from([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, +]); + describe('FileTypeValidator', () => { describe('isValid', () => { describe('support file types', () => { @@ -279,7 +283,7 @@ describe('FileTypeValidator', () => { fileType, }); - const file = { mimetype: currentFileType } as IFile; + const file = { mimetype: currentFileType, buffer: pngBuffer } as IFile; expect(fileTypeValidator.buildErrorMessage(file)).toBe( `Validation failed (current file type is ${currentFileType}, expected type is ${fileType})`, @@ -290,7 +294,7 @@ describe('FileTypeValidator', () => { const fileTypeValidator = new FileTypeValidator({ fileType: /^image\//, }); - const file = { mimetype: 'application/pdf' } as IFile; + const file = { mimetype: 'application/pdf', buffer: pdfBuffer } as IFile; expect(fileTypeValidator.buildErrorMessage(file)).toBe( `Validation failed (current file type is application/pdf, expected type is /^image\\//)`, @@ -301,13 +305,28 @@ describe('FileTypeValidator', () => { const fileTypeValidator = new FileTypeValidator({ fileType: 'jpeg', }); - const file = { mimetype: 'image/png' } as IFile; + const file = { mimetype: 'image/png', buffer: pngBuffer } as IFile; expect(fileTypeValidator.buildErrorMessage(file)).toBe( 'Validation failed (current file type is image/png, expected type is jpeg)', ); }); + it('should return a specific error message when file buffer is not available and fallbackToMimetype is not enabled', async () => { + const fileTypeValidator = new FileTypeValidator({ + fileType: 'image/jpeg', + fallbackToMimetype: false, + }); + + const file = { + mimetype: 'image/jpeg', + } as IFile; + + expect(fileTypeValidator.buildErrorMessage(file)).to.equal( + `Validation failed (file buffer is not available; file type validation could not be performed; expected type is image/jpeg)`, + ); + }); + it('should return false for text file with small buffer and correct mimetype but fail magic number validation', async () => { const fileTypeValidator = new FileTypeValidator({ fileType: 'text/plain', diff --git a/packages/common/test/pipes/parse-date.pipe.spec.ts b/packages/common/test/pipes/parse-date.pipe.spec.ts index 46813fa1a3..016766248f 100644 --- a/packages/common/test/pipes/parse-date.pipe.spec.ts +++ b/packages/common/test/pipes/parse-date.pipe.spec.ts @@ -23,6 +23,13 @@ describe('ParseDatePipe', () => { expect(transformedNumber.getTime()).toBe(asNumber); }); + it('should parse zero timestamp as a valid date', () => { + const transformedDate = target.transform(0)!; + + expect(transformedDate).to.be.instanceOf(Date); + expect(transformedDate.getTime()).to.equal(0); + }); + it('should not throw an error if the value is undefined/null and optional is true', () => { const target = new ParseDatePipe({ optional: true }); const value = target.transform(undefined); diff --git a/packages/core/Readme.md b/packages/core/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/core/Readme.md +++ b/packages/core/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/core/errors/messages.ts b/packages/core/errors/messages.ts index 5273ced905..7754bf8091 100644 --- a/packages/core/errors/messages.ts +++ b/packages/core/errors/messages.ts @@ -127,9 +127,14 @@ For more common dependency resolution issues, see: https://docs.nestjs.com/faq/c ); dependenciesName[index] = '?'; + const tokenFragment = + !isImportTypeIssue && name !== undefined ? ` ${dependencyName}` : ''; + const contextLabel = isImportTypeIssue ? 'current' : moduleName; + message += ` (`; message += dependenciesName.join(', '); - message += `). Please make sure that the argument ${isImportTypeIssue ? 'dependency' : dependencyName} at index [${index}] is available in the ${isImportTypeIssue ? 'current' : moduleName} context.`; + message += `). Please make sure that the argument${tokenFragment} at index [${index}]`; + message += ` is available in the ${contextLabel} module.`; message += potentialSolutions; return message; diff --git a/packages/core/index.ts b/packages/core/index.ts index 4048b5ff58..64791fa649 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -23,7 +23,7 @@ export * from './metadata-scanner.js'; export * from './middleware/index.js'; export * from './nest-application.js'; export * from './nest-application-context.js'; -export { NestFactory } from './nest-factory.js'; +export { IEntryNestModule, NestFactory } from './nest-factory.js'; export * from './repl/index.js'; export * from './router/index.js'; export * from './services/index.js'; diff --git a/packages/core/injector/injector.ts b/packages/core/injector/injector.ts index c6136938c4..10d2d48eb1 100644 --- a/packages/core/injector/injector.ts +++ b/packages/core/injector/injector.ts @@ -4,6 +4,7 @@ import { Logger, type LoggerService, type OptionalFactoryDependency, + Scope, } from '@nestjs/common'; import { type Controller, @@ -78,6 +79,12 @@ export interface InjectorDependencyContext { dependencies?: InjectorDependency[]; } +interface ResolutionContext { + contextId: ContextId; + inquirer?: InstanceWrapper; + effectiveInquirerId?: string; +} + export class Injector { private logger: LoggerService = new Logger('InjectorLogger'); private readonly instanceDecorator: (target: unknown) => unknown = ( @@ -90,6 +97,10 @@ export class Injector { * Whether to enable preview mode. */ preview: boolean; + /** + * Whether to enable deterministic graph snapshot generation. + */ + snapshot?: boolean; /** * Function to decorate a freshly created instance. */ @@ -124,18 +135,20 @@ export class Injector { wrapper: InstanceWrapper, collection: Map, moduleRef: Module, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, ) { - const inquirerId = this.getInquirerId(inquirer); + const inquirerId = this.getContextInquirerId(resolutionContext); const instanceHost = wrapper.getInstanceByContextId( - this.getContextId(contextId, wrapper), + this.getContextId(resolutionContext.contextId, wrapper), inquirerId, ); if (instanceHost.isPending) { const settlementSignal = wrapper.settlementSignal; - if (inquirer && settlementSignal?.isCycle(inquirer.id)) { + if ( + resolutionContext.inquirer && + settlementSignal?.isCycle(resolutionContext.inquirer.id) + ) { throw new CircularDependencyException(`"${wrapper.name}"`); } @@ -159,21 +172,24 @@ export class Injector { } try { const t0 = this.getNowTimestamp(); + const localResolutionContext = this.createResolutionContext( + resolutionContext.contextId, + wrapper, + inquirerId, + ); const callback = async (instances: unknown[], depth = 0) => { const properties = await this.resolveProperties( wrapper, moduleRef, inject as InjectionToken[], - contextId, - wrapper, - inquirer, + localResolutionContext, + resolutionContext.inquirer, ); const instance = await this.instantiateClass( instances, wrapper, targetWrapper, - contextId, - inquirer, + wrapper.isTransient ? localResolutionContext : resolutionContext, ); this.applyProperties(instance, properties); @@ -186,13 +202,12 @@ export class Injector { moduleRef, inject as InjectionToken[], callback, - contextId, - wrapper, - inquirer, + localResolutionContext, + resolutionContext.inquirer, ); } catch (err) { wrapper.removeInstanceByContextId( - this.getContextId(contextId, wrapper), + this.getContextId(resolutionContext.contextId, wrapper), inquirerId, ); @@ -218,8 +233,7 @@ export class Injector { wrapper, collection, moduleRef, - contextId, - inquirer || wrapper, + this.createResolutionContext(contextId, inquirer || wrapper), ); } @@ -233,8 +247,7 @@ export class Injector { wrapper, controllers, moduleRef, - contextId, - wrapper, + this.createResolutionContext(contextId, wrapper), ); await this.loadEnhancersPerContext(wrapper, contextId, wrapper); } @@ -250,26 +263,30 @@ export class Injector { wrapper, injectables, moduleRef, - contextId, - inquirer, + this.createResolutionContext(contextId, inquirer), ); } public async loadProvider( wrapper: InstanceWrapper, moduleRef: Module, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, ) { + if (this.shouldSkipProviderLoading(wrapper, resolutionContext)) { + return; + } const providers = moduleRef.providers; await this.loadInstance( wrapper, providers, moduleRef, - contextId, - inquirer, + resolutionContext, + ); + await this.loadEnhancersPerContext( + wrapper, + resolutionContext.contextId, + wrapper, ); - await this.loadEnhancersPerContext(wrapper, contextId, wrapper); } public applySettlementSignal( @@ -289,17 +306,16 @@ export class Injector { moduleRef: Module, inject: InjectorDependency[] | undefined, callback: (args: unknown[], depth?: number) => void | Promise, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, parentInquirer?: InstanceWrapper, ) { const metadata = wrapper.getCtorMetadata(); - if (metadata && contextId !== STATIC_CONTEXT) { + if (metadata && resolutionContext.contextId !== STATIC_CONTEXT) { const deps = await this.loadCtorMetadata( metadata, - contextId, - inquirer, + resolutionContext.contextId, + resolutionContext.inquirer, parentInquirer, ); return callback(deps); @@ -323,19 +339,23 @@ export class Injector { return parentInquirer && parentInquirer.instance; } - if (inquirer?.isTransient && parentInquirer) { + if (resolutionContext.inquirer?.isTransient && parentInquirer) { // When `inquirer` is transient too, inherit the parent inquirer // This is required to ensure that transient providers are only resolved // when requested - inquirer.attachRootInquirer(parentInquirer); + resolutionContext.inquirer.attachRootInquirer(parentInquirer); } + const nestedResolutionContext = + this.getStaticTransientResolutionContext( + resolutionContext, + parentInquirer, + ); const paramWrapper = await this.resolveSingleParam( wrapper, param as Type | string | symbol, { index, dependencies }, moduleRef, - contextId, - inquirer, + nestedResolutionContext, index, ); @@ -346,17 +366,15 @@ export class Injector { */ await paramBarrier.signalAndWait(); - const effectiveInquirer = this.getEffectiveInquirer( + const effectiveResolutionContext = this.getEffectiveResolutionContext( paramWrapper, - inquirer, + resolutionContext, parentInquirer, - contextId, ); const paramWrapperWithInstance = await this.resolveComponentHost( moduleRef, paramWrapper, - contextId, - effectiveInquirer, + effectiveResolutionContext, ); if (paramWrapperWithInstance.hierarchyLevel > depth) { @@ -364,8 +382,11 @@ export class Injector { } const instanceHost = paramWrapperWithInstance.getInstanceByContextId( - this.getContextId(contextId, paramWrapperWithInstance), - this.getInquirerId(effectiveInquirer), + this.getContextId( + effectiveResolutionContext.contextId, + paramWrapperWithInstance, + ), + effectiveResolutionContext.effectiveInquirerId, ); if (!instanceHost.isResolved && !paramWrapperWithInstance.forwardRef) { isResolved = false; @@ -447,7 +468,7 @@ export class Injector { const selfParams = this.reflectSelfParams(type); selfParams.forEach(({ index, param }) => (paramtypes[index] = param)); - return paramtypes; + return Array.from(paramtypes); } public reflectOptionalParams(type: Type): any[] { @@ -463,8 +484,7 @@ export class Injector { param: Type | string | symbol, dependencyContext: InjectorDependencyContext, moduleRef: Module, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, keyOrIndex?: symbol | string | number, ) { if (isUndefined(param)) { @@ -483,8 +503,7 @@ export class Injector { token, dependencyContext, wrapper, - contextId, - inquirer, + resolutionContext, keyOrIndex, ); } @@ -505,11 +524,10 @@ export class Injector { token: InjectionToken, dependencyContext: InjectorDependencyContext, wrapper: InstanceWrapper, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, keyOrIndex?: symbol | string | number, ): Promise { - this.printResolvingDependenciesLog(token, inquirer); + this.printResolvingDependenciesLog(token, resolutionContext.inquirer); this.printLookingForProviderLog(token, moduleRef); const providers = moduleRef.providers; return this.lookupComponent( @@ -517,8 +535,7 @@ export class Injector { moduleRef, { ...dependencyContext, name: token }, wrapper, - contextId, - inquirer, + resolutionContext, keyOrIndex, ); } @@ -526,27 +543,27 @@ export class Injector { public async resolveComponentHost( moduleRef: Module, instanceWrapper: InstanceWrapper>, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, ): Promise { - const inquirerId = this.getInquirerId(inquirer); + const inquirerId = this.getContextInquirerId(resolutionContext); const instanceHost = instanceWrapper.getInstanceByContextId( - this.getContextId(contextId, instanceWrapper), + this.getContextId(resolutionContext.contextId, instanceWrapper), inquirerId, ); if (!instanceHost.isResolved && !instanceWrapper.forwardRef) { - inquirer?.settlementSignal?.insertRef(instanceWrapper.id); + resolutionContext.inquirer?.settlementSignal?.insertRef( + instanceWrapper.id, + ); await this.loadProvider( instanceWrapper, instanceWrapper.host ?? moduleRef, - contextId, - inquirer, + resolutionContext, ); } else if ( !instanceHost.isResolved && instanceWrapper.forwardRef && - (contextId !== STATIC_CONTEXT || !!inquirerId) + (resolutionContext.contextId !== STATIC_CONTEXT || !!inquirerId) ) { /** * When circular dependency has been detected between @@ -558,7 +575,7 @@ export class Injector { instanceHost.donePromise && void instanceHost.donePromise .then(() => - this.loadProvider(instanceWrapper, moduleRef, contextId, inquirer), + this.loadProvider(instanceWrapper, moduleRef, resolutionContext), ) .catch(err => { instanceWrapper.settlementSignal?.error(err); @@ -566,11 +583,15 @@ export class Injector { } if (instanceWrapper.async) { const host = instanceWrapper.getInstanceByContextId( - this.getContextId(contextId, instanceWrapper), + this.getContextId(resolutionContext.contextId, instanceWrapper), inquirerId, ); host.instance = await host.instance; - instanceWrapper.setInstanceByContextId(contextId, host, inquirerId); + instanceWrapper.setInstanceByContextId( + resolutionContext.contextId, + host, + inquirerId, + ); } return instanceWrapper; } @@ -580,8 +601,7 @@ export class Injector { moduleRef: Module, dependencyContext: InjectorDependencyContext, wrapper: InstanceWrapper, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, keyOrIndex?: symbol | string | number, ): Promise> { const token = wrapper.token || wrapper.name; @@ -604,8 +624,7 @@ export class Injector { dependencyContext, moduleRef, wrapper, - contextId, - inquirer, + resolutionContext, keyOrIndex, ); } @@ -614,8 +633,7 @@ export class Injector { dependencyContext: InjectorDependencyContext, moduleRef: Module, wrapper: InstanceWrapper, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, keyOrIndex?: symbol | string | number, ) { const instanceWrapper = await this.lookupComponentInImports( @@ -623,8 +641,7 @@ export class Injector { dependencyContext.name!, wrapper, new Set(), - contextId, - inquirer, + resolutionContext, keyOrIndex, ); if (isNil(instanceWrapper)) { @@ -643,8 +660,7 @@ export class Injector { name: InjectionToken, wrapper: InstanceWrapper, moduleRegistry: Set = new Set(), - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, keyOrIndex?: symbol | string | number, isTraversing?: boolean, ): Promise { @@ -673,8 +689,7 @@ export class Injector { name, wrapper, moduleRegistry, - contextId, - inquirer, + resolutionContext, keyOrIndex, true, ); @@ -688,9 +703,9 @@ export class Injector { instanceWrapperRef = providers.get(name)!; this.addDependencyMetadata(keyOrIndex!, wrapper, instanceWrapperRef); - const inquirerId = this.getInquirerId(inquirer); + const inquirerId = this.getContextInquirerId(resolutionContext); const instanceHost = instanceWrapperRef.getInstanceByContextId( - this.getContextId(contextId, instanceWrapperRef), + this.getContextId(resolutionContext.contextId, instanceWrapperRef), inquirerId, ); if (!instanceHost.isResolved && !instanceWrapperRef.forwardRef) { @@ -709,16 +724,19 @@ export class Injector { wrapper: InstanceWrapper, moduleRef: Module, inject?: InjectorDependency[], - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, parentInquirer?: InstanceWrapper, ): Promise { if (!isNil(inject)) { return []; } const metadata = wrapper.getPropertiesMetadata(); - if (metadata && contextId !== STATIC_CONTEXT) { - return this.loadPropertiesMetadata(metadata, contextId, inquirer); + if (metadata && resolutionContext.contextId !== STATIC_CONTEXT) { + return this.loadPropertiesMetadata( + metadata, + resolutionContext.contextId, + resolutionContext.inquirer, + ); } const properties = this.reflectProperties(wrapper.metatype as Type); const propertyBarrier = new Barrier(properties.length); @@ -737,13 +755,17 @@ export class Injector { return parentInquirer && parentInquirer.instance; } + const nestedResolutionContext = + this.getStaticTransientResolutionContext( + resolutionContext, + parentInquirer, + ); const paramWrapper = await this.resolveSingleParam( wrapper, item.name as string, dependencyContext, moduleRef, - contextId, - inquirer, + nestedResolutionContext, item.key, ); @@ -754,24 +776,26 @@ export class Injector { */ await propertyBarrier.signalAndWait(); - const effectivePropertyInquirer = this.getEffectiveInquirer( - paramWrapper, - inquirer, - parentInquirer, - contextId, - ); + const effectivePropertyResolutionContext = + this.getEffectiveResolutionContext( + paramWrapper, + resolutionContext, + parentInquirer, + ); const paramWrapperWithInstance = await this.resolveComponentHost( moduleRef, paramWrapper, - contextId, - effectivePropertyInquirer, + effectivePropertyResolutionContext, ); if (!paramWrapperWithInstance) { return undefined; } const instanceHost = paramWrapperWithInstance.getInstanceByContextId( - this.getContextId(contextId, paramWrapperWithInstance), - this.getInquirerId(effectivePropertyInquirer), + this.getContextId( + effectivePropertyResolutionContext.contextId, + paramWrapperWithInstance, + ), + effectivePropertyResolutionContext.effectiveInquirerId, ); return instanceHost.instance; } catch (err) { @@ -823,20 +847,15 @@ export class Injector { instances: any[], wrapper: InstanceWrapper, targetMetatype: InstanceWrapper, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, ): Promise { const { metatype, inject } = wrapper; - const inquirerId = this.getInquirerId(inquirer); + const inquirerId = this.getContextInquirerId(resolutionContext); const instanceHost = targetMetatype.getInstanceByContextId( - this.getContextId(contextId, targetMetatype), + this.getContextId(resolutionContext.contextId, targetMetatype), inquirerId, ); - const isInContext = - wrapper.isStatic(contextId, inquirer) || - wrapper.isInRequestScope(contextId, inquirer) || - wrapper.isLazyTransient(contextId, inquirer) || - wrapper.isExplicitlyRequested(contextId, inquirer); + const isInContext = this.isInContext(wrapper, resolutionContext); if (this.options?.preview && !wrapper.host?.initOnPreview) { instanceHost.isResolved = true; @@ -875,8 +894,15 @@ export class Injector { if (!wrapper) { const injectionToken = (instance as any).constructor!; wrapper = collection.get(injectionToken); + } else { + wrapper = collection.get(wrapper.token) ?? wrapper; } - await this.loadInstance(wrapper!, collection, moduleRef, ctx, wrapper); + await this.loadInstance( + wrapper!, + collection, + moduleRef, + this.createResolutionContext(ctx, wrapper), + ); await this.loadEnhancersPerContext(wrapper!, ctx, wrapper); const host = wrapper!.getInstanceByContextId( @@ -891,6 +917,9 @@ export class Injector { ctx: ContextId, inquirer?: InstanceWrapper, ) { + if (ctx === STATIC_CONTEXT) { + return; + } const enhancers = wrapper.getEnhancersMetadata() || []; const loadEnhancer = (item: InstanceWrapper) => { const hostModule = item.host!; @@ -898,8 +927,7 @@ export class Injector { item, hostModule.injectables, hostModule, - ctx, - inquirer, + this.createResolutionContext(ctx, inquirer), ); }; await Promise.all(enhancers.map(loadEnhancer)); @@ -923,16 +951,15 @@ export class Injector { ); return hosts.map((item, index) => { const dependency = metadata[index]; - const effectiveInquirer = this.getEffectiveInquirer( + const effectiveInquirerId = this.getEffectiveInquirerId( dependency, - inquirer, + this.createResolutionContext(contextId, inquirer), parentInquirer, - contextId, ); return item?.getInstanceByContextId( this.getContextId(contextId, item), - this.getInquirerId(effectiveInquirer), + effectiveInquirerId, ).instance; }); } @@ -948,8 +975,7 @@ export class Injector { host: await this.resolveComponentHost( item.host!, item, - contextId, - inquirer, + this.createResolutionContext(contextId, inquirer), ), })), ); @@ -970,6 +996,75 @@ export class Injector { return inquirer ? inquirer.id : undefined; } + private createResolutionContext( + contextId: ContextId, + inquirer?: InstanceWrapper, + effectiveInquirerId?: string, + ): ResolutionContext { + return { + contextId, + inquirer, + effectiveInquirerId, + }; + } + + private getContextInquirerId({ + inquirer, + effectiveInquirerId, + }: ResolutionContext): string | undefined { + return effectiveInquirerId ?? this.getInquirerId(inquirer); + } + + private isInContext( + wrapper: InstanceWrapper, + resolutionContext: ResolutionContext, + ) { + return ( + wrapper.isStatic( + resolutionContext.contextId, + resolutionContext.inquirer, + ) || + wrapper.isInRequestScope( + resolutionContext.contextId, + resolutionContext.inquirer, + ) || + wrapper.isLazyTransient( + resolutionContext.contextId, + resolutionContext.inquirer, + ) || + wrapper.isExplicitlyRequested( + resolutionContext.contextId, + resolutionContext.inquirer, + ) + ); + } + + private shouldSkipProviderLoading( + wrapper: InstanceWrapper, + resolutionContext: ResolutionContext, + ): boolean { + const isSnapshotGraphCompilation = !!this.options?.snapshot; + const isStaticContext = resolutionContext.contextId === STATIC_CONTEXT; + const hasNoInquirer = !resolutionContext.inquirer; + const isTopLevelStaticTransientOrRequestProvider = + hasNoInquirer && (wrapper.isTransient || wrapper.scope === Scope.REQUEST); + const isStaticInquirerOutsideResolutionContext = + !!resolutionContext.inquirer && + !this.isInContext( + resolutionContext.inquirer, + this.createResolutionContext( + resolutionContext.contextId, + resolutionContext.inquirer, + ), + ); + const shouldSkipForStaticBootstrap = + isStaticContext && + (isTopLevelStaticTransientOrRequestProvider || + isStaticInquirerOutsideResolutionContext); + + return !isSnapshotGraphCompilation && shouldSkipForStaticBootstrap; + } + /** * For nested TRANSIENT dependencies (TRANSIENT -> TRANSIENT) in non-static contexts, * returns parentInquirer to ensure each parent TRANSIENT gets its own instance. @@ -979,16 +1074,79 @@ export class Injector { */ private getEffectiveInquirer( dependency: InstanceWrapper | undefined, - inquirer: InstanceWrapper | undefined, + resolutionContext: ResolutionContext, parentInquirer: InstanceWrapper | undefined, - contextId: ContextId, ): InstanceWrapper | undefined { - return dependency?.isTransient && + const { inquirer, contextId } = resolutionContext; + if (dependency?.isTransient && inquirer?.isTransient && parentInquirer) { + if (contextId === STATIC_CONTEXT) { + return inquirer.getRootInquirer() ?? parentInquirer; + } + return parentInquirer; + } + return inquirer; + } + + private getEffectiveInquirerId( + dependency: InstanceWrapper | undefined, + resolutionContext: ResolutionContext, + parentInquirer: InstanceWrapper | undefined, + ): string | undefined { + const { contextId, inquirer, effectiveInquirerId } = resolutionContext; + if ( + contextId === STATIC_CONTEXT && + dependency?.isTransient && inquirer?.isTransient && - parentInquirer && - contextId !== STATIC_CONTEXT - ? parentInquirer - : inquirer; + parentInquirer + ) { + const baseInquirerId = + effectiveInquirerId ?? this.getInquirerId(parentInquirer); + return `${baseInquirerId}:${inquirer.id}`; + } + + const effectiveInquirer = this.getEffectiveInquirer( + dependency, + resolutionContext, + parentInquirer, + ); + return this.getInquirerId(effectiveInquirer); + } + + private getStaticTransientResolutionContext( + resolutionContext: ResolutionContext, + parentInquirer: InstanceWrapper | undefined, + ): ResolutionContext { + const { contextId, inquirer, effectiveInquirerId } = resolutionContext; + if ( + contextId === STATIC_CONTEXT && + inquirer?.isTransient && + parentInquirer + ) { + const baseInquirerId = + effectiveInquirerId ?? this.getInquirerId(parentInquirer); + return this.createResolutionContext( + contextId, + inquirer, + `${baseInquirerId}:${inquirer.id}`, + ); + } + return resolutionContext; + } + + private getEffectiveResolutionContext( + dependency: InstanceWrapper | undefined, + resolutionContext: ResolutionContext, + parentInquirer: InstanceWrapper | undefined, + ): ResolutionContext { + return this.createResolutionContext( + resolutionContext.contextId, + this.getEffectiveInquirer(dependency, resolutionContext, parentInquirer), + this.getEffectiveInquirerId( + dependency, + resolutionContext, + parentInquirer, + ), + ); } private resolveScopedComponentHost( @@ -1002,8 +1160,11 @@ export class Injector { : this.resolveComponentHost( item.host!, item, - contextId, - this.getEffectiveInquirer(item, inquirer, parentInquirer, contextId), + this.getEffectiveResolutionContext( + item, + this.createResolutionContext(contextId, inquirer), + parentInquirer, + ), ); } diff --git a/packages/core/injector/module-ref.ts b/packages/core/injector/module-ref.ts index 332062665f..104c63cb33 100644 --- a/packages/core/injector/module-ref.ts +++ b/packages/core/injector/module-ref.ts @@ -2,6 +2,7 @@ import { type IntrospectionResult, Scope, type Type } from '@nestjs/common'; import { getClassScope } from '../helpers/get-class-scope.js'; import { isDurable } from '../helpers/is-durable.js'; import { AbstractInstanceResolver } from './abstract-instance-resolver.js'; +import { STATIC_CONTEXT } from './constants.js'; import { NestContainer } from './container.js'; import { Injector } from './injector.js'; import { InstanceLinksHost } from './instance-links-host.js'; @@ -36,10 +37,12 @@ export abstract class ModuleRef extends AbstractInstanceResolver { constructor(protected readonly container: NestContainer) { super(); + const contextOptions = container.contextOptions; + this.injector = new Injector({ - preview: container.contextOptions?.preview!, - instanceDecorator: - container.contextOptions?.instrument?.instanceDecorator, + preview: contextOptions?.preview ?? false, + snapshot: contextOptions?.snapshot, + instanceDecorator: contextOptions?.instrument?.instanceDecorator, }); } @@ -171,7 +174,14 @@ export abstract class ModuleRef extends AbstractInstanceResolver { host: moduleRef, }); - /* eslint-disable-next-line no-async-promise-executor */ + if (type?.prototype) { + wrapper.setInstanceByContextId(contextId ?? STATIC_CONTEXT, { + instance: Object.create(type.prototype), + isResolved: false, + isPending: false, + }); + } + return new Promise(async (resolve, reject) => { try { const callback = async (instances: any[]) => { @@ -179,7 +189,10 @@ export abstract class ModuleRef extends AbstractInstanceResolver { wrapper, moduleRef, undefined, - contextId, + { + contextId: contextId ?? STATIC_CONTEXT, + inquirer: wrapper, + }, ); const instance = new type(...instances); this.injector.applyProperties(instance, properties); @@ -190,7 +203,10 @@ export abstract class ModuleRef extends AbstractInstanceResolver { moduleRef, undefined, callback, - contextId, + { + contextId: contextId ?? STATIC_CONTEXT, + inquirer: wrapper, + }, ); } catch (err) { reject(err); diff --git a/packages/core/middleware/middleware-module.ts b/packages/core/middleware/middleware-module.ts index c4281f82fb..0929be84f1 100644 --- a/packages/core/middleware/middleware-module.ts +++ b/packages/core/middleware/middleware-module.ts @@ -334,7 +334,12 @@ export class MiddlewareModule< res: TResponse, next: () => void, ) => { - if (applicationRef.getRequestMethod?.(req) === requestMethod) { + const actualRequestMethod = applicationRef.getRequestMethod?.(req); + if ( + actualRequestMethod === requestMethod || + (actualRequestMethod === RequestMethod[RequestMethod.HEAD] && + requestMethod === RequestMethod[RequestMethod.GET]) + ) { return proxy(req, res, next); } return next(); diff --git a/packages/core/nest-factory.ts b/packages/core/nest-factory.ts index 6fbaa1358d..aca301d8c7 100644 --- a/packages/core/nest-factory.ts +++ b/packages/core/nest-factory.ts @@ -36,7 +36,12 @@ import { Logger, } from '@nestjs/common'; -type IEntryNestModule = +/** + * Represents the entry (root) module type accepted by the NestFactory methods. + * + * @publicApi + */ +export type IEntryNestModule = | Type | DynamicModule | ForwardReference @@ -218,6 +223,7 @@ export class NestFactoryStatic { const injector = new Injector({ preview: options.preview!, + snapshot: options.snapshot, instanceDecorator: options.instrument?.instanceDecorator, }); const instanceLoader = new InstanceLoader( diff --git a/packages/core/package.json b/packages/core/package.json index 82c1ca895f..dce7e9fd6a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/core", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@core)", "author": "Kamil Mysliwiec", "license": "MIT", @@ -42,12 +42,12 @@ "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "devDependencies": { - "@nestjs/common": "11.1.13" + "@nestjs/common": "11.1.20" }, "peerDependencies": { "@nestjs/common": "^11.0.0", diff --git a/packages/core/router/router-execution-context.ts b/packages/core/router/router-execution-context.ts index df6f856473..9fb3fbb8c1 100644 --- a/packages/core/router/router-execution-context.ts +++ b/packages/core/router/router-execution-context.ts @@ -449,11 +449,17 @@ export class RouterExecutionContext { res: TResponse, req: TRequest, ) => { + const rawResponse = (res as { raw?: TResponse }).raw ?? res; await this.responseController.sse( result, - (res as any).raw || res, + rawResponse, (req as any).raw || req, - { additionalHeaders: res.getHeaders?.() as any }, + { + additionalHeaders: res.getHeaders?.(), + statusCode: + (res as { statusCode?: number }).statusCode ?? + (rawResponse as { statusCode?: number }).statusCode, + }, ); }; } diff --git a/packages/core/router/router-response-controller.ts b/packages/core/router/router-response-controller.ts index 8683dada91..8b60163e05 100644 --- a/packages/core/router/router-response-controller.ts +++ b/packages/core/router/router-response-controller.ts @@ -107,7 +107,10 @@ export class RouterResponseController { result: TInput | Promise, response: TResponse, request: TRequest, - options?: { additionalHeaders: AdditionalHeaders }, + options?: { + additionalHeaders?: AdditionalHeaders; + statusCode?: number; + }, ) { // It's possible that we sent headers already so don't use a stream if (response.writableEnded) { @@ -120,52 +123,90 @@ export class RouterResponseController { const stream = new SseStream(request); - // Extract custom status code from response if it was set - const customStatusCode = (response as any).statusCode; - const pipeOptions = - typeof customStatusCode !== 'undefined' - ? { ...options, statusCode: customStatusCode } - : options; + const statusCode = + options?.statusCode ?? + (response as { statusCode?: number }).statusCode ?? + 200; - stream.pipe(response, pipeOptions); + stream.pipe(response, { + additionalHeaders: options?.additionalHeaders, + statusCode, + }); - const subscription = observableResult - .pipe( - map((message): MessageEvent => { - if (isObject(message)) { - return message as MessageEvent; - } + return new Promise((resolve, reject) => { + let settled = false; - return { data: message as object | string }; - }), - concatMap( - message => - new Promise(resolve => - stream.writeMessage(message, () => resolve()), - ), - ), - catchError(err => { - const data = err instanceof Error ? err.message : err; - stream.writeMessage({ type: 'error', data }, writeError => { - if (writeError) { - this.logger.error(writeError); + const onClose = () => { + settled = true; + subscription.unsubscribe(); + if (!stream.writableEnded) { + stream.end(); + } + response.end(); + resolve(); + }; + + const subscription = observableResult + .pipe( + map((message): MessageEvent => { + if (isObject(message)) { + return message as MessageEvent; } - }); - return EMPTY; - }), - ) - .subscribe({ - complete: () => { - response.end(); - }, - }); + return { data: message as object | string }; + }), + concatMap( + message => + new Promise(resolve => + stream.writeMessage(message, () => resolve()), + ), + ), + catchError(err => { + if (!stream.headersCommitted) { + throw err; + } - request.on('close', () => { - subscription.unsubscribe(); - if (!stream.writableEnded) { - stream.end(); - } + const data = err instanceof Error ? err.message : err; + stream.writeMessage({ type: 'error', data }, writeError => { + if (writeError) { + this.logger.error(writeError); + } + }); + + return EMPTY; + }), + ) + .subscribe({ + error: err => { + settled = true; + request.removeListener('close', onClose); + if (!stream.writableEnded) { + stream.end(); + } + reject(err); + }, + complete: () => { + settled = true; + request.removeListener('close', onClose); + if (!stream.writableEnded) { + stream.end(); + } + resolve(); + }, + }); + + // Commit SSE headers on the next macrotask. Pipe validation errors + // propagate through microtasks (which complete before macrotasks), + // so if the lifecycle errored, `settled` is already true and we + // skip the write. Otherwise headers are sent immediately rather + // than waiting for the first Observable emission. + setTimeout(() => { + if (!settled) { + stream.commitHeaders(); + } + }, 0); + + request.on('close', onClose); }); } diff --git a/packages/core/router/sse-stream.ts b/packages/core/router/sse-stream.ts index 77fc80786d..6217ad33df 100644 --- a/packages/core/router/sse-stream.ts +++ b/packages/core/router/sse-stream.ts @@ -52,6 +52,10 @@ export type HeaderStream = WritableHeaderStream & ReadHeaders; */ export class SseStream extends Transform { private lastEventId: number | null = null; + private _headersCommitted = false; + private _destination: WritableHeaderStream | null = null; + private _statusCode = 200; + private _additionalHeaders: AdditionalHeaders | undefined; constructor(req?: IncomingMessage) { super({ objectMode: true }); @@ -62,6 +66,10 @@ export class SseStream extends Transform { } } + get headersCommitted(): boolean { + return this._headersCommitted; + } + pipe( destination: T, options?: { @@ -70,10 +78,31 @@ export class SseStream extends Transform { end?: boolean; }, ): T { - if (destination.writeHead) { - const statusCode = options?.statusCode ?? 200; - destination.writeHead(statusCode, { - ...options?.additionalHeaders, + this._destination = destination; + this._statusCode = options?.statusCode ?? 200; + this._additionalHeaders = options?.additionalHeaders; + return super.pipe(destination, options); + } + + /** + * Writes SSE headers to the destination if they have not been sent yet. + * Headers are deferred until the first message so that, if the observable + * errors before any data is emitted, the HTTP status code can still be + * changed by an exception filter. + */ + commitHeaders(): void { + if (this._headersCommitted || !this._destination) { + return; + } + if (this._destination.writableEnded) { + return; + } + this._headersCommitted = true; + const statusCode = this._statusCode ?? 200; + const additionalHeaders = this._additionalHeaders; + if (this._destination.writeHead) { + this._destination.writeHead(statusCode, { + ...additionalHeaders, // See https://github.com/dunglas/mercure/blob/master/hub/subscribe.go#L124-L130 'Content-Type': 'text/event-stream', Connection: 'keep-alive', @@ -85,11 +114,9 @@ export class SseStream extends Transform { // NGINX support https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/#x-accel-buffering 'X-Accel-Buffering': 'no', }); - destination.flushHeaders?.(); + this._destination.flushHeaders?.(); } - - destination.write('\n'); - return super.pipe(destination, options); + this._destination.write('\n'); } _transform( @@ -97,9 +124,17 @@ export class SseStream extends Transform { encoding: string, callback: (error?: Error | null, data?: any) => void, ) { - let data = message.type ? `event: ${message.type}\n` : ''; - data += message.id ? `id: ${message.id}\n` : ''; - data += message.retry ? `retry: ${message.retry}\n` : ''; + this.commitHeaders(); + + const sanitize = (val: string | number) => + String(val).replace(/[\r\n]/g, ''); + + let data = message.type ? `event: ${sanitize(message.type)}\n` : ''; + data += + message.id !== undefined && message.id !== null + ? `id: ${sanitize(message.id)}\n` + : ''; + data += message.retry ? `retry: ${sanitize(message.retry)}\n` : ''; data += message.data ? toDataString(message.data) : ''; data += '\n'; this.push(data); @@ -113,7 +148,7 @@ export class SseStream extends Transform { message: MessageEvent, cb: (error: Error | null | undefined) => void, ) { - if (!message.id) { + if (message.id === undefined || message.id === null) { this.lastEventId!++; message.id = this.lastEventId!.toString(); } diff --git a/packages/core/test/errors/test/messages.spec.ts b/packages/core/test/errors/test/messages.spec.ts index 83c7989849..bf278b6b8c 100644 --- a/packages/core/test/errors/test/messages.spec.ts +++ b/packages/core/test/errors/test/messages.spec.ts @@ -15,7 +15,7 @@ describe('Error Messages', () => { const index = 0; it('should display class', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the CatService (?, CatService). Please make sure that the argument dependency at index [0] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the CatService (?, CatService). Please make sure that the argument at index [0] is available in the current module. Potential solutions: - If dependency is a provider, is it part of the current Module? @@ -40,7 +40,7 @@ describe('Error Messages', () => { }); it('should display the provide token', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the CatService (?, MY_TOKEN). Please make sure that the argument dependency at index [0] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the CatService (?, MY_TOKEN). Please make sure that the argument at index [0] is available in the current module. Potential solutions: - If dependency is a provider, is it part of the current Module? @@ -63,7 +63,7 @@ describe('Error Messages', () => { }); it('should display the provide token as double-quoted string for string-based tokens', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the CatService (?). Please make sure that the argument "FooRepository" at index [0] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the CatService (?). Please make sure that the argument "FooRepository" at index [0] is available in the current module. Potential solutions: - If "FooRepository" is a provider, is it part of the current Module? @@ -87,7 +87,7 @@ describe('Error Messages', () => { }); it('should display the function name', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the CatService (?, CatFunction). Please make sure that the argument dependency at index [0] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the CatService (?, CatFunction). Please make sure that the argument at index [0] is available in the current module. Potential solutions: - If dependency is a provider, is it part of the current Module? @@ -110,7 +110,7 @@ describe('Error Messages', () => { }); it('should use "+" if unknown dependency name', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the CatService (?, +). Please make sure that the argument dependency at index [0] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the CatService (?, +). Please make sure that the argument at index [0] is available in the current module. Potential solutions: - If dependency is a provider, is it part of the current Module? @@ -133,7 +133,7 @@ describe('Error Messages', () => { }); it('should display the module name', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the CatService (?, MY_TOKEN). Please make sure that the argument dependency at index [0] is available in the TestModule context. + stringCleaner(`Nest can't resolve dependencies of the CatService (?, MY_TOKEN). Please make sure that the argument at index [0] is available in the TestModule module. Potential solutions: - Is TestModule a valid NestJS module? @@ -169,7 +169,7 @@ describe('Error Messages', () => { }); it('should display the symbol name of the provider', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the Symbol(CatProvider) (?). Please make sure that the argument dependency at index [0] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the Symbol(CatProvider) (?). Please make sure that the argument at index [0] is available in the current module. Potential solutions: - If dependency is a provider, is it part of the current Module? @@ -192,7 +192,7 @@ describe('Error Messages', () => { }); it('should display the symbol dependency of the provider', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the CatProvider (?, Symbol(DogProvider)). Please make sure that the argument dependency at index [0] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the CatProvider (?, Symbol(DogProvider)). Please make sure that the argument at index [0] is available in the current module. Potential solutions: - If dependency is a provider, is it part of the current Module? @@ -215,7 +215,7 @@ describe('Error Messages', () => { }); it('should detect likely import type issue and provide specific guidance', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the ResourceController (ResourceService, ?). Please make sure that the argument dependency at index [1] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the ResourceController (ResourceService, ?). Please make sure that the argument at index [1] is available in the current module. Potential solutions: - The dependency at index [1] appears to be undefined at runtime @@ -241,7 +241,7 @@ describe('Error Messages', () => { }); it('should detect import type issue with mixed dependencies', () => { const expectedResult = - stringCleaner(`Nest can't resolve dependencies of the ResourceController (ValidService, ?, AnotherService). Please make sure that the argument dependency at index [1] is available in the current context. + stringCleaner(`Nest can't resolve dependencies of the ResourceController (ValidService, ?, AnotherService). Please make sure that the argument at index [1] is available in the current module. Potential solutions: - The dependency at index [1] appears to be undefined at runtime @@ -268,6 +268,82 @@ describe('Error Messages', () => { expect(actualMessage).toBe(expectedResult); }); + it('should display class token name in argument label when name is provided', () => { + class UserRepository {} + + const expectedResult = + stringCleaner(`Nest can't resolve dependencies of the UserService (?). Please make sure that the argument UserRepository at index [0] is available in the current module. + + Potential solutions: + - If UserRepository is a provider, is it part of the current Module? + - If UserRepository is exported from a separate @Module, is that module imported within Module? + @Module({ + imports: [ /* the Module containing UserRepository */ ] + }) + + For more common dependency resolution issues, see: https://docs.nestjs.com/faq/common-errors + `); + + const actualMessage = stringCleaner( + new UnknownDependenciesException('UserService', { + index: 0, + dependencies: [UserRepository], + name: UserRepository, + }).message, + ); + + expect(actualMessage).to.equal(expectedResult); + }); + it('should display string token name in argument label when name is provided', () => { + const expectedResult = + stringCleaner(`Nest can't resolve dependencies of the UserService (?). Please make sure that the argument "DATABASE_URL" at index [0] is available in the current module. + + Potential solutions: + - If "DATABASE_URL" is a provider, is it part of the current Module? + - If "DATABASE_URL" is exported from a separate @Module, is that module imported within Module? + @Module({ + imports: [ /* the Module containing "DATABASE_URL" */ ] + }) + + For more common dependency resolution issues, see: https://docs.nestjs.com/faq/common-errors + `); + + const actualMessage = stringCleaner( + new UnknownDependenciesException('UserService', { + index: 0, + dependencies: ['DATABASE_URL'], + name: 'DATABASE_URL', + }).message, + ); + + expect(actualMessage).to.equal(expectedResult); + }); + it('should display symbol token name in argument label when name is provided', () => { + const TOKEN = Symbol('MY_TOKEN'); + + const expectedResult = + stringCleaner(`Nest can't resolve dependencies of the UserService (?). Please make sure that the argument Symbol(MY_TOKEN) at index [0] is available in the current module. + + Potential solutions: + - If Symbol(MY_TOKEN) is a provider, is it part of the current Module? + - If Symbol(MY_TOKEN) is exported from a separate @Module, is that module imported within Module? + @Module({ + imports: [ /* the Module containing Symbol(MY_TOKEN) */ ] + }) + + For more common dependency resolution issues, see: https://docs.nestjs.com/faq/common-errors + `); + + const actualMessage = stringCleaner( + new UnknownDependenciesException('UserService', { + index: 0, + dependencies: [TOKEN], + name: TOKEN, + }).message, + ); + + expect(actualMessage).to.equal(expectedResult); + }); it('should add documentation links to export errors', () => { const expectedResult = stringCleaner(`Nest cannot export a provider/module that is not a part of the currently processed module (TestModule). Please verify whether the exported TestService is available in this particular context. diff --git a/packages/core/test/injector/injector.spec.ts b/packages/core/test/injector/injector.spec.ts index 075d45b584..0ee06b1f5d 100644 --- a/packages/core/test/injector/injector.spec.ts +++ b/packages/core/test/injector/injector.spec.ts @@ -261,8 +261,10 @@ describe('Injector', () => { wrapper, module.controllers, module, - expect.anything(), - wrapper, + expect.objectContaining({ + contextId: STATIC_CONTEXT, + inquirer: wrapper, + }), ); }); }); @@ -284,8 +286,10 @@ describe('Injector', () => { wrapper, module.injectables, module, - expect.anything(), - undefined, + expect.objectContaining({ + contextId: STATIC_CONTEXT, + inquirer: undefined, + }), ); }); }); @@ -570,7 +574,9 @@ describe('Injector', () => { Promise.reject(error), ); - await injector.resolveComponentHost(module, wrapper, contextId); + await injector.resolveComponentHost(module, wrapper, { + contextId, + }); await new Promise(resolve => setImmediate(resolve)); expect(errorSpy).toHaveBeenCalledOnce(); @@ -609,7 +615,9 @@ describe('Injector', () => { describe('when context is static', () => { it('should instantiate class', async () => { const wrapper = new InstanceWrapper({ metatype: TestClass }); - await injector.instantiateClass([], wrapper, wrapper, STATIC_CONTEXT); + await injector.instantiateClass([], wrapper, wrapper, { + contextId: STATIC_CONTEXT, + }); expect(wrapper.instance).not.toBeUndefined(); expect(wrapper.instance).toBeInstanceOf(TestClass); @@ -619,7 +627,9 @@ describe('Injector', () => { inject: [], metatype: (() => ({})) as any, }); - await injector.instantiateClass([], wrapper, wrapper, STATIC_CONTEXT); + await injector.instantiateClass([], wrapper, wrapper, { + contextId: STATIC_CONTEXT, + }); expect(wrapper.instance).not.toBeUndefined(); }); @@ -628,7 +638,9 @@ describe('Injector', () => { it('should not instantiate class', async () => { const ctx = { id: 3 }; const wrapper = new InstanceWrapper({ metatype: TestClass }); - await injector.instantiateClass([], wrapper, wrapper, ctx); + await injector.instantiateClass([], wrapper, wrapper, { + contextId: ctx, + }); expect(wrapper.instance).toBeUndefined(); expect(wrapper.getInstanceByContextId(ctx).isResolved).toBe(true); @@ -639,7 +651,9 @@ describe('Injector', () => { inject: [], metatype: vi.fn() as any, }); - await injector.instantiateClass([], wrapper, wrapper, { id: 2 }); + await injector.instantiateClass([], wrapper, wrapper, { + contextId: { id: 2 }, + }); expect(wrapper.instance).toBeUndefined(); expect(wrapper.metatype as any).not.toHaveBeenCalled(); }); @@ -672,6 +686,7 @@ describe('Injector', () => { describe('loadEnhancersPerContext', () => { it('should load enhancers per context id', async () => { + const contextId = { id: 1 }; const wrapper = new InstanceWrapper(); wrapper.addEnhancerMetadata( new InstanceWrapper({ @@ -688,7 +703,7 @@ describe('Injector', () => { .spyOn(injector, 'loadInstance') .mockImplementation(async () => ({}) as any); - await injector.loadEnhancersPerContext(wrapper, STATIC_CONTEXT); + await injector.loadEnhancersPerContext(wrapper, contextId); expect(loadInstanceStub).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/core/test/injector/nested-transient-isolation.spec.ts b/packages/core/test/injector/nested-transient-isolation.spec.ts index 4e6cbefe7a..b98b7fb90f 100644 --- a/packages/core/test/injector/nested-transient-isolation.spec.ts +++ b/packages/core/test/injector/nested-transient-isolation.spec.ts @@ -97,18 +97,12 @@ describe('Nested Transient Isolation', () => { it('should create separate TRANSIENT instances for each parent', async () => { const contextId = { id: 1 }; - await injector.loadInstance( - parent1Wrapper, - module.providers, - module, + await injector.loadInstance(parent1Wrapper, module.providers, module, { contextId, - ); - await injector.loadInstance( - parent2Wrapper, - module.providers, - module, + }); + await injector.loadInstance(parent2Wrapper, module.providers, module, { contextId, - ); + }); const parent1Instance = parent1Wrapper.getInstanceByContextId(contextId).instance; @@ -124,18 +118,12 @@ describe('Nested Transient Isolation', () => { it('should create separate nested TRANSIENT instances for each parent TRANSIENT', async () => { const contextId = { id: 1 }; - await injector.loadInstance( - parent1Wrapper, - module.providers, - module, + await injector.loadInstance(parent1Wrapper, module.providers, module, { contextId, - ); - await injector.loadInstance( - parent2Wrapper, - module.providers, - module, + }); + await injector.loadInstance(parent2Wrapper, module.providers, module, { contextId, - ); + }); const parent1Instance = parent1Wrapper.getInstanceByContextId(contextId).instance; @@ -152,24 +140,15 @@ describe('Nested Transient Isolation', () => { const contextId1 = { id: 1 }; const contextId2 = { id: 2 }; - await injector.loadInstance( - parent1Wrapper, - module.providers, - module, - contextId1, - ); - await injector.loadInstance( - parent2Wrapper, - module.providers, - module, - contextId1, - ); - await injector.loadInstance( - parent1Wrapper, - module.providers, - module, - contextId2, - ); + await injector.loadInstance(parent1Wrapper, module.providers, module, { + contextId: contextId1, + }); + await injector.loadInstance(parent2Wrapper, module.providers, module, { + contextId: contextId1, + }); + await injector.loadInstance(parent1Wrapper, module.providers, module, { + contextId: contextId2, + }); const ctx1Parent1 = parent1Wrapper.getInstanceByContextId(contextId1).instance; @@ -194,19 +173,27 @@ describe('Nested Transient Isolation', () => { @Injectable({ scope: Scope.TRANSIENT }) class NestedTransientService { public static constructorCalled = false; + public static instanceCount = 0; + public readonly instanceId: number; public readonly value = 'nested-initialized'; constructor() { NestedTransientService.constructorCalled = true; + NestedTransientService.instanceCount++; + this.instanceId = NestedTransientService.instanceCount; } } @Injectable({ scope: Scope.TRANSIENT }) class TransientService { public static constructorCalled = false; + public static instanceCount = 0; + public readonly instanceId: number; constructor(public readonly nested: NestedTransientService) { TransientService.constructorCalled = true; + TransientService.instanceCount++; + this.instanceId = TransientService.instanceCount; } } @@ -215,13 +202,21 @@ describe('Nested Transient Isolation', () => { constructor(public readonly transient: TransientService) {} } + @Injectable() + class DefaultScopedParent2 { + constructor(public readonly transient: TransientService) {} + } + let nestedTransientWrapper: InstanceWrapper; let transientWrapper: InstanceWrapper; let parentWrapper: InstanceWrapper; + let parent2Wrapper: InstanceWrapper; beforeEach(() => { NestedTransientService.constructorCalled = false; + NestedTransientService.instanceCount = 0; TransientService.constructorCalled = false; + TransientService.instanceCount = 0; nestedTransientWrapper = new InstanceWrapper({ name: NestedTransientService.name, @@ -247,9 +242,18 @@ describe('Nested Transient Isolation', () => { host: module, }); + parent2Wrapper = new InstanceWrapper({ + name: DefaultScopedParent2.name, + token: DefaultScopedParent2, + metatype: DefaultScopedParent2, + scope: Scope.DEFAULT, + host: module, + }); + module.providers.set(NestedTransientService, nestedTransientWrapper); module.providers.set(TransientService, transientWrapper); module.providers.set(DefaultScopedParent, parentWrapper); + module.providers.set(DefaultScopedParent2, parent2Wrapper); }); it('should instantiate nested TRANSIENT providers from DEFAULT scope', async () => { @@ -265,5 +269,20 @@ describe('Nested Transient Isolation', () => { ); expect(parentInstance.transient.nested.value).toBe('nested-initialized'); }); + + it('should isolate nested TRANSIENT instances across DEFAULT parents in STATIC context', async () => { + await injector.loadInstance(parentWrapper, module.providers, module); + await injector.loadInstance(parent2Wrapper, module.providers, module); + + const parent1Instance = parentWrapper.instance; + const parent2Instance = parent2Wrapper.instance; + + expect(parent1Instance.transient.instanceId).to.not.equal( + parent2Instance.transient.instanceId, + ); + expect(parent1Instance.transient.nested.instanceId).to.not.equal( + parent2Instance.transient.nested.instanceId, + ); + }); }); }); diff --git a/packages/core/test/router/router-execution-context.spec.ts b/packages/core/test/router/router-execution-context.spec.ts index 9e8cbfdd34..646046e9ea 100644 --- a/packages/core/test/router/router-execution-context.spec.ts +++ b/packages/core/test/router/router-execution-context.spec.ts @@ -525,6 +525,45 @@ describe('RouterExecutionContext', () => { }), ); }); + + it('should pass through status and headers from the wrapper response at handle time', async () => { + const rawResponse = new PassThrough() as HeaderStream; + rawResponse.write = vi.fn() as any; + rawResponse.writeHead = vi.fn() as any; + rawResponse.flushHeaders = vi.fn() as any; + + const response = { + raw: rawResponse, + statusCode: 203, + getHeaders: vi + .fn() + .mockReturnValue({ 'access-control-headers': 'at-handle-time' }), + }; + const result = of('test'); + + const request = new PassThrough(); + request.on = vi.fn() as any; + + vi.spyOn(contextCreator, 'reflectRenderTemplate').mockReturnValue( + undefined!, + ); + vi.spyOn(contextCreator, 'reflectSse').mockReturnValue('/'); + + const handler = contextCreator.createHandleResponseFn( + null!, + true, + undefined, + 200, + ) as HandlerResponseBasicFn; + await handler(result, response as any, request); + + expect(rawResponse.writeHead).toHaveBeenCalledWith( + 203, + expect.objectContaining({ + 'access-control-headers': 'at-handle-time', + }), + ); + }); }); }); }); diff --git a/packages/core/test/router/router-response-controller.spec.ts b/packages/core/test/router/router-response-controller.spec.ts index 40b5eca4a3..f433a446d4 100644 --- a/packages/core/test/router/router-response-controller.spec.ts +++ b/packages/core/test/router/router-response-controller.spec.ts @@ -518,26 +518,31 @@ data: test }); describe('when there is an error', () => { - it('should close the request', () => - new Promise(done => { - const result = new Subject(); - const response = new Writable(); - response.end = done as any; - response._write = () => {}; + it('should reject when the stream errors before headers are committed', async () => { + const result = new Subject(); + const response = new Writable(); + response._write = () => {}; - const request = new Writable(); - request._write = () => {}; + const request = new Writable(); + request._write = () => {}; - void routerResponseController.sse( - result, - response as unknown as ServerResponse, - request as unknown as IncomingMessage, - ); + const promise = routerResponseController.sse( + result, + response as unknown as ServerResponse, + request as unknown as IncomingMessage, + ); - result.error(new Error('Some error')); - })); + result.error(new Error('Some error')); + + await expect(promise).rejects.toThrow('Some error'); + }); it('should write the error message to the stream', async () => { + let resolveFirstChunk: (() => void) | undefined; + const firstChunkWritten = new Promise(resolve => { + resolveFirstChunk = resolve; + }); + class Sink extends Writable { private readonly chunks: string[] = []; @@ -547,6 +552,8 @@ data: test callback: (error?: Error | null) => void, ): void { this.chunks.push(chunk); + resolveFirstChunk?.(); + resolveFirstChunk = undefined; callback(); } @@ -563,24 +570,21 @@ data: test const result = new Subject(); const response = new Sink(); const request = new PassThrough(); - void routerResponseController.sse( + const promise = routerResponseController.sse( result, response as unknown as ServerResponse, request as unknown as IncomingMessage, ); + result.next('hello'); + await firstChunkWritten; result.error(new Error('Some error')); request.destroy(); + await promise; await written(response); - expect(response.content).toEqual( - ` -event: error -id: 1 -data: Some error - -`, - ); + expect(response.content).toContain('event: error'); + expect(response.content).toContain('data: Some error'); }); }); }); diff --git a/packages/core/test/router/sse-stream.spec.ts b/packages/core/test/router/sse-stream.spec.ts index 0ae09152ec..3943f72097 100644 --- a/packages/core/test/router/sse-stream.spec.ts +++ b/packages/core/test/router/sse-stream.spec.ts @@ -142,6 +142,7 @@ data: hello }, ); sse.pipe(sink); + sse.writeMessage({ data: 'hello' }, noop); })); it('sets additional headers when provided', () => @@ -159,6 +160,7 @@ data: hello sse.pipe(sink, { additionalHeaders: { 'access-control-headers': 'some-cors-value' }, }); + sse.writeMessage({ data: 'hello' }, noop); })); it('sets custom status code when provided', () => @@ -175,6 +177,7 @@ data: hello sse.pipe(sink, { statusCode: 404, }); + sse.writeMessage({ data: 'hello' }, noop); })); it('defaults to 200 status code when not provided', () => @@ -189,6 +192,7 @@ data: hello ); sse.pipe(sink); + sse.writeMessage({ data: 'hello' }, noop); })); it('allows an eventsource to connect', () => @@ -197,6 +201,7 @@ data: hello const server = createServer((req, res) => { sse = new SseStream(req); sse.pipe(res); + process.nextTick(() => sse.writeMessage({ data: 'hello' }, noop)); }); server.listen(() => { @@ -208,7 +213,6 @@ data: hello es.close(); server.close(() => resolve()); }; - es.onopen = () => sse.writeMessage({ data: 'hello' }, noop); es.onerror = e => reject(new Error(`Error from EventSource: ${JSON.stringify(e)}`)); }); diff --git a/packages/core/test/scope/transient-scope.spec.ts b/packages/core/test/scope/transient-scope.spec.ts new file mode 100644 index 0000000000..093b960cd9 --- /dev/null +++ b/packages/core/test/scope/transient-scope.spec.ts @@ -0,0 +1,82 @@ +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { INQUIRER, ModuleRef } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; + +describe('Transient scope', () => { + const SOME_TOKEN = Symbol('SomeToken'); + + @Injectable({ scope: Scope.TRANSIENT }) + class TransientService { + public context: string; + + constructor( + @Inject(INQUIRER) private inquirer: any, + @Inject(SOME_TOKEN) public token: string, + ) { + this.context = inquirer.constructor.name; + } + } + + @Injectable() + class RegularService { + constructor( + public transient: TransientService, + @Inject(SOME_TOKEN) public token: string, + ) {} + } + + @Injectable() + class DynamicService { + constructor( + public regular: RegularService, + public transient: TransientService, + ) {} + } + + describe('creating an instance with moduleRef.create', () => { + let service: DynamicService; + let moduleRef: ModuleRef; + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [ + RegularService, + TransientService, + DynamicService, + { provide: SOME_TOKEN, useValue: 'some-value' }, + ], + }).compile(); + + moduleRef = module.get(ModuleRef); + service = await moduleRef.create(DynamicService); + }); + + it('should be able to inject a regular dependency', async () => { + expect(service.regular.token).toBe('some-value'); + expect(service.regular.transient.context).toBe(RegularService.name); + }); + + it('should be able to inject a transient-scoped dependency', async () => { + expect(service.transient.token).toBe('some-value'); + expect(service.transient.context).toBe(DynamicService.name); + }); + + it('should work correctly when there is another class that injects the same dependency', async () => { + @Injectable() + class AnotherDynamicService { + constructor( + public regular: RegularService, + public transient: TransientService, + ) {} + } + + const service2 = await moduleRef.create(AnotherDynamicService); + + expect(service2.regular.token).toBe('some-value'); + expect(service2.regular.transient.context).toBe(RegularService.name); + + expect(service2.transient.token).toBe('some-value'); + expect(service2.transient.context).toBe(AnotherDynamicService.name); + }); + }); +}); diff --git a/packages/microservices/Readme.md b/packages/microservices/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/microservices/Readme.md +++ b/packages/microservices/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/microservices/client/client-kafka.ts b/packages/microservices/client/client-kafka.ts index a2b87cc209..aee0244793 100644 --- a/packages/microservices/client/client-kafka.ts +++ b/packages/microservices/client/client-kafka.ts @@ -306,7 +306,7 @@ export class ClientKafka this._consumer.on(this._consumer.events.STOP, () => this._status$.next(KafkaStatus.STOPPED), ); - this.consumer.on(this._consumer.events.CRASH, () => + this._consumer.on(this._consumer.events.CRASH, () => this._status$.next(KafkaStatus.CRASHED), ); } diff --git a/packages/microservices/client/client-redis.ts b/packages/microservices/client/client-redis.ts index d7f0f635c0..2fc1bf4e98 100644 --- a/packages/microservices/client/client-redis.ts +++ b/packages/microservices/client/client-redis.ts @@ -16,6 +16,9 @@ import { loadPackage } from '@nestjs/common/internal'; // type Redis = import('ioredis').Redis; type Redis = any; +type RedisOutputOptions = { + returnBuffers?: boolean; +}; /** * @publicApi */ @@ -32,7 +35,10 @@ export class ClientRedis extends ClientProxy { callback: RedisEvents[keyof RedisEvents]; }> = []; - constructor(protected readonly options: Required['options']) { + constructor( + protected readonly options: Required['options'] & + RedisOutputOptions, + ) { super(); this.initializeSerializer(options); @@ -84,6 +90,7 @@ export class ClientRedis extends ClientProxy { } public async createClient(): Promise { + const clientInfoTag = this.getOptionsProp(this.options, 'clientInfoTag'); const redisPackage = await loadPackage( 'ioredis', ClientRedis.name, @@ -94,6 +101,7 @@ export class ClientRedis extends ClientProxy { host: REDIS_DEFAULT_HOST, port: REDIS_DEFAULT_PORT, ...this.getClientOptions(), + ...(clientInfoTag && { clientInfoTag }), lazyConnect: true, }); } @@ -138,7 +146,10 @@ export class ClientRedis extends ClientProxy { if (!this.wasInitialConnectionSuccessful) { this.wasInitialConnectionSuccessful = true; - this.subClient.on('message', this.createResponseCallback()); + this.subClient.on( + this.options.returnBuffers ? 'messageBuffer' : 'message', + this.createResponseCallback(), + ); } }); } @@ -222,12 +233,27 @@ export class ClientRedis extends ClientProxy { buffer: string, ) => Promise { return async (channel: string, buffer: string) => { - const packet = JSON.parse(buffer); + let packet: any; + try { + packet = JSON.parse(buffer); + } catch (err) { + this.logger.debug( + 'Redis response packet is not in json format, bypassing...', + ); + packet = buffer; + } const { err, response, isDisposed, id } = await this.deserializer.deserialize(packet); const callback = this.routingMap.get(id); if (!callback) { + if (Buffer.isBuffer(buffer)) + this.logger.debug( + 'You have to parse your buffer on your own to get id from it, because it is not in json format', + ); + this.logger.debug( + 'No matching callback found for Redis response packet with id: ' + id, + ); return; } if (isDisposed || err) { diff --git a/packages/microservices/client/client-rmq.ts b/packages/microservices/client/client-rmq.ts index d58a4cd2da..dd1f238508 100644 --- a/packages/microservices/client/client-rmq.ts +++ b/packages/microservices/client/client-rmq.ts @@ -11,6 +11,7 @@ import { } from 'rxjs'; import { first, map, retryWhen, scan, skip, switchMap } from 'rxjs/operators'; import { + BLOCKED_RMQ_MESSAGE, DISCONNECTED_RMQ_MESSAGE, RMQ_DEFAULT_IS_GLOBAL_PREFETCH_COUNT, RMQ_DEFAULT_NO_ASSERT, @@ -20,6 +21,7 @@ import { RMQ_DEFAULT_QUEUE, RMQ_DEFAULT_QUEUE_OPTIONS, RMQ_DEFAULT_URL, + UNBLOCKED_RMQ_MESSAGE, } from '../constants.js'; import { RmqEvents, RmqEventsMap, RmqStatus } from '../events/rmq.events.js'; import { ReadPacket, RmqOptions, WritePacket } from '../interfaces/index.js'; @@ -121,6 +123,8 @@ export class ClientRMQ extends ClientProxy { this.registerErrorListener(this.client); this.registerDisconnectListener(this.client); this.registerConnectListener(this.client); + this.registerBlockedListener(this.client); + this.registerUnblockedListener(this.client); this.pendingEventListeners.forEach(({ event, callback }) => this.client!.on(event, callback), ); @@ -299,6 +303,23 @@ export class ClientRMQ extends ClientProxy { }); } + public registerBlockedListener(client: AmqpConnectionManager): void { + client.addListener( + RmqEventsMap.BLOCKED, + ({ reason }: { reason: string }) => { + this._status$.next(RmqStatus.BLOCKED); + this.logger.warn(BLOCKED_RMQ_MESSAGE(reason)); + }, + ); + } + + public registerUnblockedListener(client: AmqpConnectionManager): void { + client.addListener(RmqEventsMap.UNBLOCKED, () => { + this._status$.next(RmqStatus.UNBLOCKED); + this.logger.log(UNBLOCKED_RMQ_MESSAGE); + }); + } + public on< EventKey extends keyof RmqEvents = keyof RmqEvents, EventCallback extends RmqEvents[EventKey] = RmqEvents[EventKey], @@ -346,7 +367,7 @@ export class ClientRMQ extends ClientProxy { options, ); if (isDisposed || err) { - callback?.({ + return callback?.({ err, response, isDisposed: true, diff --git a/packages/microservices/constants.ts b/packages/microservices/constants.ts index d40d6a7e4d..0e1fa5d61c 100644 --- a/packages/microservices/constants.ts +++ b/packages/microservices/constants.ts @@ -58,5 +58,8 @@ export const NO_MESSAGE_HANDLER = `There is no matching message handler defined export const DISCONNECTED_RMQ_MESSAGE = `Disconnected from RMQ. Trying to reconnect.`; export const CONNECTION_FAILED_MESSAGE = 'Connection to transport failed. Trying to reconnect...'; +export const BLOCKED_RMQ_MESSAGE = (reason: string) => + `RMQ broker has blocked the connection (flow control). Reason: ${reason}`; +export const UNBLOCKED_RMQ_MESSAGE = 'RMQ broker has unblocked the connection.'; export const NATS_DEFAULT_GRACE_PERIOD = 10000; diff --git a/packages/microservices/events/rmq.events.ts b/packages/microservices/events/rmq.events.ts index aba888901a..8dfe6779e6 100644 --- a/packages/microservices/events/rmq.events.ts +++ b/packages/microservices/events/rmq.events.ts @@ -1,15 +1,20 @@ type VoidCallback = () => void; type OnErrorCallback = (error: Error) => void; +type OnBlockedCallback = (arg: { reason: string }) => void; export const enum RmqStatus { DISCONNECTED = 'disconnected', CONNECTED = 'connected', + BLOCKED = 'blocked', + UNBLOCKED = 'unblocked', } export const enum RmqEventsMap { ERROR = 'error', DISCONNECT = 'disconnect', CONNECT = 'connect', + BLOCKED = 'blocked', + UNBLOCKED = 'unblocked', } /** @@ -21,4 +26,6 @@ export type RmqEvents = { error: OnErrorCallback; disconnect: VoidCallback; connect: VoidCallback; + blocked: OnBlockedCallback; + unblocked: VoidCallback; }; diff --git a/packages/microservices/external/redis.interface.ts b/packages/microservices/external/redis.interface.ts index 506d3669c1..3523fc3393 100644 --- a/packages/microservices/external/redis.interface.ts +++ b/packages/microservices/external/redis.interface.ts @@ -35,6 +35,13 @@ export interface IORedisOptions { */ connectionName?: string; + /** + * Set a tag to identify the framework using the Redis client library. + * This is appended to the library name (e.g., "ioredis(nestjs_v11.0.0)"). + * @link https://redis.io/docs/latest/commands/client-setinfo/ + */ + clientInfoTag?: string; + /** * If set, client will send AUTH command with the value of this option as the first argument when connected. * This is supported since Redis 6. diff --git a/packages/microservices/helpers/json-socket.ts b/packages/microservices/helpers/json-socket.ts index eed68d9298..34fc3fc4f4 100644 --- a/packages/microservices/helpers/json-socket.ts +++ b/packages/microservices/helpers/json-socket.ts @@ -33,19 +33,24 @@ export class JsonSocket extends TcpSocket { : dataRaw; this.buffer += data; - if (this.buffer.length > this.maxBufferSize) { - const bufferLength = this.buffer.length; - this.buffer = ''; - throw new MaxPacketLengthExceededException(bufferLength); - } + // Iterative loop replaces recursion to prevent stack overflow on pipelined + // TCP messages (e.g. many small frames arriving in one read event). + while (true) { + if (this.buffer.length > this.maxBufferSize) { + const bufferLength = this.buffer.length; + this.buffer = ''; + throw new MaxPacketLengthExceededException(bufferLength); + } - if (this.contentLength === null) { - const i = this.buffer.indexOf(this.delimiter); - /** - * Check if the buffer has the delimiter (#), - * if not, the end of the buffer string might be in the middle of a content length string - */ - if (i !== -1) { + if (this.contentLength === null) { + const i = this.buffer.indexOf(this.delimiter); + /** + * Check if the buffer has the delimiter (#), + * if not, the end of the buffer string might be in the middle of a content length string + */ + if (i === -1) { + break; + } const rawContentLength = this.buffer.substring(0, i); this.contentLength = parseInt(rawContentLength, 10); @@ -56,17 +61,24 @@ export class JsonSocket extends TcpSocket { } this.buffer = this.buffer.substring(i + 1); } - } - if (this.contentLength !== null) { - const length = this.buffer.length; - if (length === this.contentLength) { - this.handleMessage(this.buffer); - } else if (length > this.contentLength) { - const message = this.buffer.substring(0, this.contentLength); - const rest = this.buffer.substring(this.contentLength); - this.handleMessage(message); - this.handleData(rest); + if (this.contentLength !== null) { + const length = this.buffer.length; + if (length === this.contentLength) { + this.handleMessage(this.buffer); + // handleMessage resets contentLength and buffer; next iteration will break + } else if (length > this.contentLength) { + const message = this.buffer.substring(0, this.contentLength); + const rest = this.buffer.substring(this.contentLength); + this.handleMessage(message); // resets this.buffer to '' + this.buffer = rest; // restore remaining data for next iteration + continue; + } else { + // Incomplete message — wait for more data + break; + } + } else { + break; } } } diff --git a/packages/microservices/package.json b/packages/microservices/package.json index 3439403cda..2c535c4ed0 100644 --- a/packages/microservices/package.json +++ b/packages/microservices/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/microservices", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@microservices)", "author": "Kamil Mysliwiec", "license": "MIT", @@ -29,8 +29,8 @@ "tslib": "2.8.1" }, "devDependencies": { - "@nestjs/common": "11.1.13", - "@nestjs/core": "11.1.13" + "@nestjs/common": "11.1.20", + "@nestjs/core": "11.1.20" }, "peerDependencies": { "@grpc/grpc-js": "*", diff --git a/packages/microservices/serializers/nats-record.serializer.ts b/packages/microservices/serializers/nats-record.serializer.ts index 75b78d4344..9d88f24f2f 100644 --- a/packages/microservices/serializers/nats-record.serializer.ts +++ b/packages/microservices/serializers/nats-record.serializer.ts @@ -11,7 +11,9 @@ export class NatsRecordSerializer implements Serializer< const natsMessage = packet?.data && isObject(packet.data) && packet.data instanceof NatsRecord ? packet.data - : new NatsRecordBuilder(packet?.data).build(); + : new NatsRecordBuilder(packet?.data) + .setHeaders(packet?.headers) + .build(); return { data: JSON.stringify({ ...packet, data: natsMessage.data }), diff --git a/packages/microservices/server/server-grpc.ts b/packages/microservices/server/server-grpc.ts index 5fc0494551..8b2ee8ba77 100644 --- a/packages/microservices/server/server-grpc.ts +++ b/packages/microservices/server/server-grpc.ts @@ -467,7 +467,7 @@ export class ServerGrpc extends Server { .toLowerCase() .indexOf('cancelled'); - if (isCancelledError) { + if (isCancelledError !== -1) { call.end(); return; } diff --git a/packages/microservices/server/server-redis.ts b/packages/microservices/server/server-redis.ts index 90867fa1fb..494e6b3f09 100644 --- a/packages/microservices/server/server-redis.ts +++ b/packages/microservices/server/server-redis.ts @@ -110,6 +110,7 @@ export class ServerRedis extends Server { } public async createRedisClient(): Promise { + const clientInfoTag = this.getOptionsProp(this.options, 'clientInfoTag'); const redisPackage = await this.loadPackage( 'ioredis', ServerRedis.name, @@ -120,6 +121,7 @@ export class ServerRedis extends Server { port: REDIS_DEFAULT_PORT, host: REDIS_DEFAULT_HOST, ...this.getClientOptions(), + ...(clientInfoTag && { clientInfoTag }), lazyConnect: true, }); } diff --git a/packages/microservices/server/server-rmq.ts b/packages/microservices/server/server-rmq.ts index 0572ca0493..430364ba56 100644 --- a/packages/microservices/server/server-rmq.ts +++ b/packages/microservices/server/server-rmq.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-redundant-type-constituents */ import { createRequire } from 'module'; import { + BLOCKED_RMQ_MESSAGE, CONNECTION_FAILED_MESSAGE, DISCONNECTED_RMQ_MESSAGE, NO_MESSAGE_HANDLER, @@ -16,6 +17,7 @@ import { RMQ_DEFAULT_URL, RMQ_NO_EVENT_HANDLER, RMQ_NO_MESSAGE_HANDLER, + UNBLOCKED_RMQ_MESSAGE, } from '../constants.js'; import { RmqContext } from '../ctx-host/index.js'; import { Transport } from '../enums/index.js'; @@ -128,6 +130,8 @@ export class ServerRMQ extends Server { this.registerConnectListener(); this.registerDisconnectListener(); + this.registerBlockedListener(); + this.registerUnblockedListener(); this.pendingEventListeners.forEach(({ event, callback }) => this.server!.on(event, callback), ); @@ -186,6 +190,20 @@ export class ServerRMQ extends Server { }); } + private registerBlockedListener() { + this.server!.on(RmqEventsMap.BLOCKED, ({ reason }: { reason: string }) => { + this._status$.next(RmqStatus.BLOCKED); + this.logger.warn(BLOCKED_RMQ_MESSAGE(reason)); + }); + } + + private registerUnblockedListener() { + this.server!.on(RmqEventsMap.UNBLOCKED, () => { + this._status$.next(RmqStatus.UNBLOCKED); + this.logger.log(UNBLOCKED_RMQ_MESSAGE); + }); + } + public async setupChannel(channel: Channel, callback: Function) { const noAssert = this.getOptionsProp(this.options, 'noAssert') ?? @@ -416,13 +434,17 @@ export class ServerRMQ extends Server { } } - private initializeWildcardHandlersIfExist() { + protected initializeWildcardHandlersIfExist() { if (this.wildcardHandlers.size !== 0) { return; } const handlers = this.getHandlers(); handlers.forEach((handler, pattern) => { + if (typeof pattern !== 'string') { + return; + } + if ( pattern.includes(RMQ_WILDCARD_ALL) || pattern.includes(RMQ_WILDCARD_SINGLE) diff --git a/packages/microservices/test/client/client-kafka.spec.ts b/packages/microservices/test/client/client-kafka.spec.ts index e7ffb71768..8f926be088 100644 --- a/packages/microservices/test/client/client-kafka.spec.ts +++ b/packages/microservices/test/client/client-kafka.spec.ts @@ -291,6 +291,47 @@ describe('ClientKafka', () => { }); }); + describe('registerConsumerEventListeners', () => { + it('should use the _consumer backing field, not the consumer getter', () => { + const consumerOnSpy = vi.fn(); + const fakeConsumer = { + events: { + CONNECT: 'consumer.connect', + DISCONNECT: 'consumer.disconnect', + REBALANCING: 'consumer.rebalancing', + STOP: 'consumer.stop', + CRASH: 'consumer.crash', + }, + on: consumerOnSpy, + }; + + // Set the backing field directly + untypedClient._consumer = fakeConsumer; + + // Replace the consumer getter stub with one that throws, + // ensuring the method only uses _consumer (backing field) + consumerStub.mockImplementation(() => { + throw new Error( + 'Consumer getter should not be called in registerConsumerEventListeners', + ); + }); + + expect(() => + untypedClient.registerConsumerEventListeners(), + ).to.not.throw(); + expect(consumerOnSpy).toHaveBeenCalledTimes(5); + + const registeredEvents = consumerOnSpy.mock.calls.map( + (args: any[]) => args[0], + ); + expect(registeredEvents).toContain('consumer.connect'); + expect(registeredEvents).toContain('consumer.disconnect'); + expect(registeredEvents).toContain('consumer.rebalancing'); + expect(registeredEvents).toContain('consumer.stop'); + expect(registeredEvents).toContain('consumer.crash'); + }); + }); + describe('connect', () => { let consumerAssignmentsStub: any; let bindTopicsStub: ReturnType; diff --git a/packages/microservices/test/client/client-redis.spec.ts b/packages/microservices/test/client/client-redis.spec.ts index 82a4649681..8e46392384 100644 --- a/packages/microservices/test/client/client-redis.spec.ts +++ b/packages/microservices/test/client/client-redis.spec.ts @@ -176,6 +176,30 @@ describe('ClientRedis', () => { expect(callback).not.toHaveBeenCalled(); }); }); + describe('custom binary format (not json)', () => { + it('should use buffer directly without parsing it as json', async () => { + const clientWithBuffers = new ClientRedis({ returnBuffers: true }); + const callback = vi.fn(); + const str = `${responseMessage.id}|${responseMessage.response}`; + const bufferMessage = Buffer.from(str); + vi.spyOn( + Reflect.get(clientWithBuffers, 'deserializer'), + 'deserialize', + ).mockResolvedValue({ + ...responseMessage, + response: bufferMessage, + }); + const subscription = clientWithBuffers.createResponseCallback(); + + clientWithBuffers['routingMap'].set(responseMessage.id, callback); + await subscription('channel', bufferMessage as any); + + expect(callback).toHaveBeenCalledWith({ + err: undefined, + response: bufferMessage, + }); + }); + }); }); describe('close', () => { const untypedClient = client as any; @@ -307,6 +331,62 @@ describe('ClientRedis', () => { client.registerReadyListener(emitter as any); expect(callback.mock.calls[0][0]).toEqual('ready'); }); + it('should register "message" event when returnBuffers is not set', () => { + const onSpy = vi.fn(); + const client = new ClientRedis({}); + const untypedClient = client as any; + const emitter = { + on: onSpy, + }; + + untypedClient.wasInitialConnectionSuccessful = false; + untypedClient.subClient = emitter; + + client.registerReadyListener(emitter as any); + const readyHandler = onSpy.mock.calls[0][1]; + readyHandler(); + + expect(onSpy).toHaveBeenCalledTimes(2); + expect(onSpy.mock.calls[1][0]).toEqual('message'); + }); + it('should register "message" event when returnBuffers is false', () => { + const onSpy = vi.fn(); + const client = new ClientRedis({ returnBuffers: false }); + const untypedClient = client as any; + + const emitter = { + on: onSpy, + }; + + untypedClient.wasInitialConnectionSuccessful = false; + untypedClient.subClient = emitter; + + client.registerReadyListener(emitter as any); + const readyHandler = onSpy.mock.calls[0][1]; + readyHandler(); + + expect(onSpy).toHaveBeenCalledTimes(2); + expect(onSpy.mock.calls[1][0]).toEqual('message'); + }); + it('should register "messageBuffer" event when returnBuffers is true', () => { + const onSpy = vi.fn(); + const clientWithBuffers = new ClientRedis({ returnBuffers: true }); + const untypedClientWithBuffers = clientWithBuffers as any; + + const emitter = { + on: onSpy, + }; + + untypedClientWithBuffers.wasInitialConnectionSuccessful = false; + untypedClientWithBuffers.subClient = emitter; + + clientWithBuffers.registerReadyListener(emitter as any); + const readyHandler = onSpy.mock.calls[0][1]; + readyHandler(); + + expect(onSpy).toHaveBeenCalledTimes(2); + expect(onSpy.mock.calls[1][0]).toEqual('messageBuffer'); + }); }); describe('registerReconnectListener', () => { it('should bind reconnect event handler', () => { @@ -394,4 +474,24 @@ describe('ClientRedis', () => { ); }); }); + + describe('createClient', () => { + it('should not set clientInfoTag when not provided', async () => { + const clientWithoutTag = new ClientRedis({}); + const redisClient = await clientWithoutTag.createClient(); + + expect(redisClient).to.be.ok; + // Verify no clientInfoTag was set (opt-in only) + expect(redisClient.options.clientInfoTag).to.be.undefined; + }); + + it('should use clientInfoTag when provided', async () => { + const clientWithTag = new ClientRedis({ clientInfoTag: 'my-app' }); + const redisClient = await clientWithTag.createClient(); + + expect(redisClient).to.be.ok; + // Verify the clientInfoTag was used + expect(redisClient.options.clientInfoTag).to.equal('my-app'); + }); + }); }); diff --git a/packages/microservices/test/helpers/json-socket.spec.ts b/packages/microservices/test/helpers/json-socket.spec.ts new file mode 100644 index 0000000000..7b2b328008 --- /dev/null +++ b/packages/microservices/test/helpers/json-socket.spec.ts @@ -0,0 +1,119 @@ +import { EventEmitter } from 'events'; +import { CorruptedPacketLengthException } from '../../errors/corrupted-packet-length.exception.js'; +import { MaxPacketLengthExceededException } from '../../errors/max-packet-length-exceeded.exception.js'; +import { JsonSocket } from '../../helpers/json-socket.js'; + +function makeSocketStub(): any { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + write: vi.fn(), + end: vi.fn(), + connect: vi.fn(), + }); +} + +function frame(obj: unknown): string { + const json = JSON.stringify(obj); + return `${json.length}#${json}`; +} + +describe('JsonSocket', () => { + let socketStub: any; + let jsonSocket: JsonSocket; + let received: unknown[]; + + beforeEach(() => { + socketStub = makeSocketStub(); + jsonSocket = new JsonSocket(socketStub); + received = []; + socketStub.on('message', (msg: unknown) => received.push(msg)); + }); + + describe('handleData – basic framing', () => { + it('emits a single message from one complete chunk', () => { + (jsonSocket as any).handleData(frame({ hello: 'world' })); + + expect(received).to.have.lengthOf(1); + expect(received[0]).to.deep.equal({ hello: 'world' }); + }); + + it('buffers a partial chunk and emits once complete', () => { + const full = frame({ x: 1 }); + (jsonSocket as any).handleData(full.slice(0, 3)); + expect(received).to.have.lengthOf(0); + + (jsonSocket as any).handleData(full.slice(3)); + expect(received).to.have.lengthOf(1); + expect(received[0]).to.deep.equal({ x: 1 }); + }); + + it('emits multiple messages split across chunks', () => { + const payload = frame({ a: 1 }) + frame({ b: 2 }); + const mid = Math.floor(payload.length / 2); + (jsonSocket as any).handleData(payload.slice(0, mid)); + (jsonSocket as any).handleData(payload.slice(mid)); + + expect(received).to.have.lengthOf(2); + expect(received[0]).to.deep.equal({ a: 1 }); + expect(received[1]).to.deep.equal({ b: 2 }); + }); + }); + + describe('handleData – pipelined messages (stack-overflow regression)', () => { + it('processes many small pipelined frames without a stack overflow', () => { + /** + * A payload of `"2#{}" * 12_000` (~47 KB) used to trigger + * "RangeError: Maximum call stack size exceeded" because each + * framed message caused a recursive call back into handleData(). + * + * The iterative while-loop fix must process all frames in O(1) stack depth. + */ + const FRAME_COUNT = 12_000; + const singleFrame = frame({}); // "2#{}" + const bigPayload = singleFrame.repeat(FRAME_COUNT); + + expect(() => { + (jsonSocket as any).handleData(bigPayload); + }).not.toThrow(); + + expect(received).toHaveLength(FRAME_COUNT); + }); + + it('correctly identifies all message payloads in a pipelined burst', () => { + const messages = Array.from({ length: 500 }, (_, i) => ({ id: i })); + const payload = messages.map(frame).join(''); + + (jsonSocket as any).handleData(payload); + + expect(received).toHaveLength(messages.length); + messages.forEach((msg, i) => { + expect(received[i]).toEqual(msg); + }); + }); + }); + + describe('handleData – error cases', () => { + it('throws CorruptedPacketLengthException for a non-numeric length prefix', () => { + expect(() => { + (jsonSocket as any).handleData('abc#{}'); + }).toThrow(CorruptedPacketLengthException); + }); + + it('throws MaxPacketLengthExceededException when buffer exceeds maxBufferSize', () => { + const tiny = new JsonSocket(socketStub, { maxBufferSize: 5 }); + expect(() => { + (tiny as any).handleData('123456#'); + }).toThrow(MaxPacketLengthExceededException); + }); + + it('resets state after a corrupted length so subsequent valid frames are processed', () => { + expect(() => (jsonSocket as any).handleData('bad#')).toThrow( + CorruptedPacketLengthException, + ); + // After the error the socket state must be clean + (jsonSocket as any).handleData(frame({ ok: true })); + expect(received).toHaveLength(1); + expect(received[0]).toEqual({ ok: true }); + }); + }); +}); diff --git a/packages/microservices/test/serializers/nats-record.serializer.spec.ts b/packages/microservices/test/serializers/nats-record.serializer.spec.ts index 92584cba85..caa0a3717d 100644 --- a/packages/microservices/test/serializers/nats-record.serializer.spec.ts +++ b/packages/microservices/test/serializers/nats-record.serializer.spec.ts @@ -4,6 +4,7 @@ import { NatsRecordSerializer } from '../../serializers/nats-record.serializer.j describe('NatsRecordSerializer', () => { let instance: NatsRecordSerializer; + beforeEach(() => { instance = new NatsRecordSerializer(); }); @@ -58,6 +59,26 @@ describe('NatsRecordSerializer', () => { }); }); + it('applies packet headers when data is not a NatsRecord', () => { + const natsHeaders = nats.headers(); + natsHeaders.set('x-response', 'enabled'); + + expect( + instance.serialize({ + data: { value: 'string' }, + headers: natsHeaders, + }), + ).to.deep.eq({ + headers: natsHeaders, + data: JSON.stringify({ + data: { + value: 'string', + }, + headers: natsHeaders, + }), + }); + }); + it('nats message with data and nats headers', () => { const natsHeaders = nats.headers(); natsHeaders.set('1', 'header_1'); diff --git a/packages/microservices/test/server/server-redis.spec.ts b/packages/microservices/test/server/server-redis.spec.ts index f135555fe8..1683595779 100644 --- a/packages/microservices/test/server/server-redis.spec.ts +++ b/packages/microservices/test/server/server-redis.spec.ts @@ -294,4 +294,24 @@ describe('ServerRedis', () => { expect(handler).toHaveBeenCalledWith(data, expect.any(BaseRpcContext)); }); }); + + describe('createRedisClient', () => { + it('should not set clientInfoTag when not provided', async () => { + const serverWithoutTag = new ServerRedis({}); + const redisClient = await serverWithoutTag.createRedisClient(); + + expect(redisClient).to.be.ok; + // Verify no clientInfoTag was set (opt-in only) + expect(redisClient.options.clientInfoTag).to.be.undefined; + }); + + it('should use clientInfoTag when provided', async () => { + const serverWithTag = new ServerRedis({ clientInfoTag: 'my-app' }); + const redisClient = await serverWithTag.createRedisClient(); + + expect(redisClient).to.be.ok; + // Verify the clientInfoTag was used + expect(redisClient.options.clientInfoTag).to.equal('my-app'); + }); + }); }); diff --git a/packages/microservices/test/server/server-rmq.spec.ts b/packages/microservices/test/server/server-rmq.spec.ts index ef417b14d9..6dd9724f00 100644 --- a/packages/microservices/test/server/server-rmq.spec.ts +++ b/packages/microservices/test/server/server-rmq.spec.ts @@ -58,7 +58,7 @@ describe('ServerRMQ', () => { }); it('should bind "connectFailed" event to handler', async () => { await server.listen(callbackSpy); - expect(onStub.mock.calls[3][0]).toBe('connectFailed'); + expect(onStub.mock.calls.map(call => call[0])).toContain('connectFailed'); }); describe('when "start" throws an exception', () => { it('should call callback with a thrown error as an argument', async () => { diff --git a/packages/microservices/test/utils/transform-pattern.utils.spec.ts b/packages/microservices/test/utils/transform-pattern.utils.spec.ts index 9cc0a2ff81..b62f2dd85d 100644 --- a/packages/microservices/test/utils/transform-pattern.utils.spec.ts +++ b/packages/microservices/test/utils/transform-pattern.utils.spec.ts @@ -119,4 +119,22 @@ describe('transformPatternToRoute', () => { }); }); }); + + describe('when gets value exceeding max depth or max keys', () => { + it('should return special string indicating the limit was reached', () => { + const deepNestedPattern = { + a: { b: { c: { d: { e: { f: { g: 'too deep' } } } } } }, + }; + const tooManyKeysPattern = Object.fromEntries( + Array.from({ length: 25 }, (_, i) => [`key${i}`, `value${i}`]), + ); + + expect(transformPatternToRoute(deepNestedPattern)).to.be.equal( + '{"a":{"b":{"c":{"d":{"e":{"f":[MAX_DEPTH_REACHED]}}}}}}', + ); + expect(transformPatternToRoute(tooManyKeysPattern)).to.be.equal( + '[TOO_MANY_KEYS]', + ); + }); + }); }); diff --git a/packages/microservices/utils/transform-pattern.utils.ts b/packages/microservices/utils/transform-pattern.utils.ts index a0010578e4..7c03551c41 100644 --- a/packages/microservices/utils/transform-pattern.utils.ts +++ b/packages/microservices/utils/transform-pattern.utils.ts @@ -1,39 +1,64 @@ import { MsPattern } from '../interfaces/index.js'; import { isObject, isString, isNumber } from '@nestjs/common/internal'; +const DEFAULT_MAX_DEPTH = 5; +const DEFAULT_MAX_KEYS = 20; +const escape = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + /** - * Transforms the Pattern to Route. - * 1. If Pattern is a `string`, it will be returned as it is. - * 2. If Pattern is a `number`, it will be converted to `string`. - * 3. If Pattern is a `JSON` object, it will be transformed to Route. For that end, - * the function will sort properties of `JSON` Object and creates `route` string - * according to the following template: - * :/:/.../: + * Transforms the Pattern to Route safely. * - * @param {MsPattern} pattern - client pattern + * @param pattern - client pattern + * @param depth - current recursion depth + * @param maxDepth - maximum allowed recursion depth + * @param maxKeys - maximum allowed keys per object * @returns string */ -export function transformPatternToRoute(pattern: MsPattern): string { +export function transformPatternToRoute( + pattern: MsPattern, + depth = 0, + maxDepth = DEFAULT_MAX_DEPTH, + maxKeys = DEFAULT_MAX_KEYS, +): string { if (isString(pattern) || isNumber(pattern)) { return `${pattern}`; } + if (!isObject(pattern)) { + // For non-string, non-number, non-object values return pattern; } - const sortedKeys = Object.keys(pattern).sort((a, b) => - ('' + a).localeCompare(b), - ); + if (depth > maxDepth) { + return '[MAX_DEPTH_REACHED]'; + } + + const keys = Object.keys(pattern); + + if (keys.length > maxKeys) { + return '[TOO_MANY_KEYS]'; + } + + const sortedKeys = keys.sort((a, b) => ('' + a).localeCompare(b)); + + const parts = sortedKeys.map(key => { + const value = pattern[key]; + let partialRoute = `"${escape(key)}":`; + + // Only quote strings, numbers and objects are handled recursively + if (isString(value)) { + partialRoute += `"${escape(transformPatternToRoute(value, depth + 1, maxDepth, maxKeys))}"`; + } else { + partialRoute += transformPatternToRoute( + value, + depth + 1, + maxDepth, + maxKeys, + ); + } - // Creates the array of Pattern params from sorted keys and their corresponding values - const sortedPatternParams = sortedKeys.map(key => { - let partialRoute = `"${key}":`; - partialRoute += isString(pattern[key]) - ? `"${transformPatternToRoute(pattern[key])}"` - : transformPatternToRoute(pattern[key]); return partialRoute; }); - const route = sortedPatternParams.join(','); - return `{${route}}`; + return `{${parts.join(',')}}`; } diff --git a/packages/platform-express/Readme.md b/packages/platform-express/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/platform-express/Readme.md +++ b/packages/platform-express/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/platform-express/adapters/express-adapter.ts b/packages/platform-express/adapters/express-adapter.ts index c660570d13..6c41c2b25c 100644 --- a/packages/platform-express/adapters/express-adapter.ts +++ b/packages/platform-express/adapters/express-adapter.ts @@ -111,25 +111,7 @@ export class ExpressAdapter extends AbstractHttpAdapter< return response.send(); } if (body instanceof StreamableFile) { - const streamHeaders = body.getHeaders(); - if ( - response.getHeader('Content-Type') === undefined && - streamHeaders.type !== undefined - ) { - response.setHeader('Content-Type', streamHeaders.type); - } - if ( - response.getHeader('Content-Disposition') === undefined && - streamHeaders.disposition !== undefined - ) { - response.setHeader('Content-Disposition', streamHeaders.disposition); - } - if ( - response.getHeader('Content-Length') === undefined && - streamHeaders.length !== undefined - ) { - response.setHeader('Content-Length', streamHeaders.length); - } + this.applyStreamHeaders(response, body); const stream = body.getStream(); stream.once('error', err => { body.errorHandler(err, response); @@ -574,4 +556,27 @@ export class ExpressAdapter extends AbstractHttpAdapter< ) ); } + + private setHeaderIfNotExists( + response: any, + name: string, + value?: string | string[] | number, + ) { + if (value !== undefined && response.getHeader(name) === undefined) { + const headerValue = Array.isArray(value) ? value.join(',') : value; + response.setHeader(name, headerValue); + } + } + + private applyStreamHeaders(response: any, streamable: StreamableFile) { + const headers = streamable.getHeaders(); + + this.setHeaderIfNotExists(response, 'Content-Type', headers.type); + this.setHeaderIfNotExists( + response, + 'Content-Disposition', + headers.disposition, + ); + this.setHeaderIfNotExists(response, 'Content-Length', headers.length); + } } diff --git a/packages/platform-express/multer/interfaces/multer-options.interface.ts b/packages/platform-express/multer/interfaces/multer-options.interface.ts index a5ee5db224..30c202c8e2 100644 --- a/packages/platform-express/multer/interfaces/multer-options.interface.ts +++ b/packages/platform-express/multer/interfaces/multer-options.interface.ts @@ -31,6 +31,9 @@ export interface MulterOptions { /** Keep the full path of files instead of just the base name (Default: false) */ preservePath?: boolean; + /** Default character set for part header values (e.g. filename) (Default: 'latin1') */ + defParamCharset?: string; + fileFilter?( req: any, file: { diff --git a/packages/platform-express/package.json b/packages/platform-express/package.json index db0e4bc5af..0bb07e0714 100644 --- a/packages/platform-express/package.json +++ b/packages/platform-express/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/platform-express", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@platform-express)", "author": "Kamil Mysliwiec", "license": "MIT", @@ -27,13 +27,13 @@ "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.0.2", - "path-to-regexp": "8.3.0", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "devDependencies": { - "@nestjs/common": "11.1.13", - "@nestjs/core": "11.1.13" + "@nestjs/common": "11.1.20", + "@nestjs/core": "11.1.20" }, "peerDependencies": { "@nestjs/common": "^11.0.0", diff --git a/packages/platform-fastify/Readme.md b/packages/platform-fastify/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/platform-fastify/Readme.md +++ b/packages/platform-fastify/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/platform-fastify/adapters/fastify-adapter.ts b/packages/platform-fastify/adapters/fastify-adapter.ts index ca3a77b4a5..ddb4d27dd1 100644 --- a/packages/platform-fastify/adapters/fastify-adapter.ts +++ b/packages/platform-fastify/adapters/fastify-adapter.ts @@ -623,7 +623,9 @@ export class FastifyAdapter< public enableCors(options?: FastifyCorsOptions) { this.register( - import('@fastify/cors') as Parameters[0], + import('@fastify/cors') as unknown as Parameters< + TInstance['register'] + >[0], options, ); } @@ -720,7 +722,8 @@ export class FastifyAdapter< queryParamsIndex >= 0 ? req.originalUrl.slice(0, queryParamsIndex) : req.originalUrl; - pathname = safeDecodeURI(pathname).path; + + pathname = this.sanitizeUrl(pathname); if (!re.exec(pathname + '/') && normalizedPath) { return next(); @@ -825,7 +828,9 @@ export class FastifyAdapter< private async registerMiddie() { this.isMiddieRegistered = true; - await this.register(middie as Parameters[0]); + await this.register( + middie as unknown as Parameters[0], + ); } private getRequestOriginalUrl(rawRequest: TRawRequest) { @@ -898,4 +903,50 @@ export class FastifyAdapter< } return this.instance.route(routeToInject); } + + private sanitizeUrl(url: string): string { + const initialConfig = this.instance.initialConfig as FastifyServerOptions; + const routerOptions = + initialConfig.routerOptions as Partial; + + if ( + routerOptions.ignoreDuplicateSlashes || + initialConfig.ignoreDuplicateSlashes + ) { + url = this.removeDuplicateSlashes(url); + } + + if ( + routerOptions.ignoreTrailingSlash || + initialConfig.ignoreTrailingSlash + ) { + url = this.trimLastSlash(url); + } + + if ( + routerOptions.caseSensitive === false || + initialConfig.caseSensitive === false + ) { + url = url.toLowerCase(); + } + return safeDecodeURI( + url, + routerOptions.useSemicolonDelimiter || + initialConfig.useSemicolonDelimiter, + ).path; + } + + private removeDuplicateSlashes(path: string) { + const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g; + return path.indexOf('//') !== -1 + ? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/') + : path; + } + + private trimLastSlash(path: string) { + if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) { + return path.slice(0, -1); + } + return path; + } } diff --git a/packages/platform-fastify/adapters/middie/fastify-middie.ts b/packages/platform-fastify/adapters/middie/fastify-middie.ts index f41db77a14..4c078a32df 100644 --- a/packages/platform-fastify/adapters/middie/fastify-middie.ts +++ b/packages/platform-fastify/adapters/middie/fastify-middie.ts @@ -5,6 +5,7 @@ import { FastifyPluginCallback, FastifyReply, FastifyRequest, + FastifyServerOptions, HookHandlerDoneFunction, } from 'fastify'; import fp from 'fastify-plugin'; @@ -29,6 +30,17 @@ interface MiddlewareEntry< fn: MiddlewareFn; } +function bindLast any>( + fn: F, + last: Last>, +): (...args: DropLast>) => ReturnType { + return (...args: any[]) => fn(...args, last); +} + +// Helper types +type Last = T extends [...any[], infer L] ? L : never; +type DropLast = T extends [...infer Rest, any] ? Rest : never; + /** * A clone of `@fastify/middie` engine https://github.com/fastify/middie * with an extra vulnerability fix. Path is now decoded before matching to @@ -38,13 +50,16 @@ function middie< Req extends { url: string; originalUrl?: string }, Res extends { finished?: boolean; writableEnded?: boolean }, Ctx = unknown, ->(complete: (err: unknown, req: Req, res: Res, ctx: Ctx) => void) { +>( + complete: (err: unknown, req: Req, res: Res, ctx: Ctx) => void, + initialConfig: FastifyServerOptions | null, +) { const middlewares: MiddlewareEntry[] = []; const pool = reusify(Holder as any); return { use, - run, + run: bindLast(run, initialConfig), }; function use( @@ -80,7 +95,12 @@ function middie< return this; } - function run(req: Req, res: Res, ctx: Ctx) { + function run( + req: Req, + res: Res, + ctx: Ctx, + initialConfig: FastifyServerOptions | null, + ) { if (!middlewares.length) { complete(null, req, res, ctx); return; @@ -93,6 +113,7 @@ function middie< holder.res = res; holder.url = sanitizeUrl(req.url); holder.context = ctx; + holder.initialConfig = initialConfig; holder.done(); } @@ -101,6 +122,7 @@ function middie< res: Res | null; url: string | null; context: Ctx | null; + initialConfig: FastifyServerOptions | null; i: number; done: (err?: unknown) => void; } @@ -110,6 +132,7 @@ function middie< this.res = null; this.url = null; this.context = null; + this.initialConfig = null; this.i = 0; const that = this; @@ -136,7 +159,33 @@ function middie< if (regexp) { // Decode URL before matching to avoid bypassing middleware - const decodedUrl = safeDecodeURI(url).path; + let sanitizedUrl = url; + if ( + that.initialConfig!.ignoreDuplicateSlashes || + that.initialConfig!.routerOptions?.ignoreDuplicateSlashes + ) { + sanitizedUrl = removeDuplicateSlashes(sanitizedUrl); + } + + if ( + that.initialConfig!.ignoreTrailingSlash || + that.initialConfig!.routerOptions?.ignoreTrailingSlash + ) { + sanitizedUrl = trimLastSlash(sanitizedUrl); + } + + if ( + that.initialConfig!.caseSensitive === false || + that.initialConfig!.routerOptions?.caseSensitive === false + ) { + sanitizedUrl = sanitizedUrl.toLowerCase(); + } + + const decodedUrl = safeDecodeURI( + sanitizedUrl, + (that.initialConfig?.routerOptions as any)?.useSemicolonDelimiter || + that.initialConfig?.useSemicolonDelimiter, + ).path; const result = regexp.exec(decodedUrl); if (result) { req.url = req.url.replace(result[0], ''); @@ -155,12 +204,27 @@ function middie< that.req = null; that.res = null; that.context = null; + that.initialConfig = null; that.i = 0; pool.release(that as any); } } } +function removeDuplicateSlashes(path: string) { + const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g; + return path.indexOf('//') !== -1 + ? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/') + : path; +} + +function trimLastSlash(path: string) { + if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) { + return path.slice(0, -1); + } + return path; +} + function sanitizeUrl(url: string): string { for (let i = 0, len = url.length; i < len; i++) { const charCode = url.charCodeAt(i); @@ -215,7 +279,7 @@ function fastifyMiddie( fastify.decorate('use', use as any); fastify[kMiddlewares] = []; fastify[kMiddieHasMiddlewares] = false; - fastify[kMiddie] = middie(onMiddieEnd); + fastify[kMiddie] = middie(onMiddieEnd, fastify.initialConfig); const hook = options.hook || 'onRequest'; @@ -296,7 +360,7 @@ function fastifyMiddie( function onRegister(instance: FastifyInstance) { const middlewares = instance[kMiddlewares].slice() as Array>; instance[kMiddlewares] = []; - instance[kMiddie] = middie(onMiddieEnd); + instance[kMiddie] = middie(onMiddieEnd, instance.initialConfig); instance[kMiddieHasMiddlewares] = false; instance.decorate('use', use as any); for (const middleware of middlewares) { @@ -359,9 +423,9 @@ declare module 'fastify' { * with an extra vulnerability fix. Path is now decoded before matching to * avoid bypassing middleware with encoded characters. */ -export default fp(fastifyMiddie, { +export default (fp as any)(fastifyMiddie, { fastify: '5.x', name: '@fastify/middie', -}); +}) as unknown as typeof fastifyMiddie; export { fastifyMiddie }; diff --git a/packages/platform-fastify/package.json b/packages/platform-fastify/package.json index bd5f82e074..5919188865 100644 --- a/packages/platform-fastify/package.json +++ b/packages/platform-fastify/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/platform-fastify", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@platform-fastify)", "author": "Kamil Mysliwiec", "license": "MIT", @@ -28,11 +28,11 @@ "@fastify/cors": "11.2.0", "@fastify/formbody": "8.0.2", "fast-querystring": "1.1.2", - "fastify": "5.7.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "find-my-way": "9.4.0", + "find-my-way": "9.6.0", "light-my-request": "6.6.0", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "reusify": "1.1.0", "tslib": "2.8.1" }, diff --git a/packages/platform-socket.io/Readme.md b/packages/platform-socket.io/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/platform-socket.io/Readme.md +++ b/packages/platform-socket.io/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/platform-socket.io/adapters/io-adapter.ts b/packages/platform-socket.io/adapters/io-adapter.ts index 5044829843..340b8b9dc8 100644 --- a/packages/platform-socket.io/adapters/io-adapter.ts +++ b/packages/platform-socket.io/adapters/io-adapter.ts @@ -12,6 +12,8 @@ import { DISCONNECT_EVENT } from '@nestjs/websockets/internal'; * @publicApi */ export class IoAdapter extends AbstractWsAdapter { + private readonly disconnectMap = new WeakMap>(); + public create( port: number, options?: ServerOptions & { namespace?: string; server?: any }, @@ -39,10 +41,11 @@ export class IoAdapter extends AbstractWsAdapter { handlers: MessageMappingProperties[], transform: (data: any) => Observable, ) { - const disconnect$ = fromEvent(socket, DISCONNECT_EVENT).pipe( - share(), - first(), - ); + let disconnect$ = this.disconnectMap.get(socket); + if (!disconnect$) { + disconnect$ = fromEvent(socket, DISCONNECT_EVENT).pipe(share(), first()); + this.disconnectMap.set(socket, disconnect$); + } handlers.forEach(({ message, callback, isAckHandledManually }) => { const source$ = fromEvent(socket, message).pipe( diff --git a/packages/platform-socket.io/package.json b/packages/platform-socket.io/package.json index 44e44a42dd..5ac683138b 100644 --- a/packages/platform-socket.io/package.json +++ b/packages/platform-socket.io/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/platform-socket.io", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@platform-socket.io)", "author": "Kamil Mysliwiec", "license": "MIT", diff --git a/packages/platform-socket.io/test/io-adapter.spec.ts b/packages/platform-socket.io/test/io-adapter.spec.ts new file mode 100644 index 0000000000..7c5520a5ed --- /dev/null +++ b/packages/platform-socket.io/test/io-adapter.spec.ts @@ -0,0 +1,48 @@ +import { of } from 'rxjs'; +import { IoAdapter } from '../adapters/io-adapter.js'; + +describe('IoAdapter', () => { + let adapter: IoAdapter; + + beforeEach(() => { + adapter = new IoAdapter(); + }); + + describe('bindMessageHandlers', () => { + it('should register only one disconnect listener regardless of call count', () => { + const addListenerSpy = vi.fn(); + const fakeSocket = { + on: addListenerSpy, + off: vi.fn(), + addListener: addListenerSpy, + removeListener: vi.fn(), + } as any; + + const handler = { + message: 'test-event', + methodName: 'handleTestEvent', + callback: vi.fn().mockReturnValue({ data: 'response' }), + isAckHandledManually: false, + }; + + const transform = (data: any) => of(data); + + // Call bindMessageHandlers twice on the same socket + // (simulates two gateways sharing the same socket) + adapter.bindMessageHandlers(fakeSocket, [handler], transform); + adapter.bindMessageHandlers(fakeSocket, [handler], transform); + + const disconnectCalls = addListenerSpy.mock.calls.filter( + call => call[0] === 'disconnect', + ); + + expect(disconnectCalls).toHaveLength(1); + + // message handlers should still be registered per call + const messageCalls = addListenerSpy.mock.calls.filter( + call => call[0] === 'test-event', + ); + expect(messageCalls).toHaveLength(2); + }); + }); +}); diff --git a/packages/platform-socket.io/test/tsconfig.json b/packages/platform-socket.io/test/tsconfig.json new file mode 100644 index 0000000000..50d59661da --- /dev/null +++ b/packages/platform-socket.io/test/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.spec.json" +} diff --git a/packages/platform-ws/Readme.md b/packages/platform-ws/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/platform-ws/Readme.md +++ b/packages/platform-ws/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/platform-ws/package.json b/packages/platform-ws/package.json index 21b1c6c7e0..77335d62e1 100644 --- a/packages/platform-ws/package.json +++ b/packages/platform-ws/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/platform-ws", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@platform-ws)", "author": "Kamil Mysliwiec", "license": "MIT", @@ -26,7 +26,7 @@ }, "dependencies": { "tslib": "2.8.1", - "ws": "8.19.0" + "ws": "8.20.1" }, "peerDependencies": { "@nestjs/common": "^11.0.0", diff --git a/packages/testing/Readme.md b/packages/testing/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/testing/Readme.md +++ b/packages/testing/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/testing/package.json b/packages/testing/package.json index e49a8daa3c..eb680346d5 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/testing", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@testing)", "author": "Kamil Mysliwiec", "license": "MIT", diff --git a/packages/testing/testing-injector.ts b/packages/testing/testing-injector.ts index 1580e74fd6..09bffcc1a9 100644 --- a/packages/testing/testing-injector.ts +++ b/packages/testing/testing-injector.ts @@ -1,4 +1,5 @@ import type { NestContainer } from '@nestjs/core'; +import type { ContextId } from '@nestjs/core/injector/instance-wrapper.js'; import { MockFactory } from './interfaces/index.js'; import { STATIC_CONTEXT, @@ -8,6 +9,12 @@ import { type Module, } from '@nestjs/core/internal'; +interface ResolutionContext { + contextId: ContextId; + inquirer?: InstanceWrapper; + effectiveInquirerId?: string; +} + /** * @publicApi */ @@ -28,8 +35,7 @@ export class TestingInjector extends Injector { name: any, dependencyContext: InjectorDependencyContext, wrapper: InstanceWrapper, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, keyOrIndex?: string | number, ): Promise { try { @@ -38,8 +44,7 @@ export class TestingInjector extends Injector { name, dependencyContext, wrapper, - contextId, - inquirer, + resolutionContext, keyOrIndex, ); return existingProviderWrapper; @@ -51,15 +56,13 @@ export class TestingInjector extends Injector { public async resolveComponentHost( moduleRef: Module, instanceWrapper: InstanceWrapper, - contextId = STATIC_CONTEXT, - inquirer?: InstanceWrapper, + resolutionContext: ResolutionContext = { contextId: STATIC_CONTEXT }, ): Promise { try { const existingProviderWrapper = await super.resolveComponentHost( moduleRef, instanceWrapper, - contextId, - inquirer, + resolutionContext, ); return existingProviderWrapper; } catch (err) { diff --git a/packages/testing/testing-module.builder.ts b/packages/testing/testing-module.builder.ts index aa90d37377..2a63b0eeea 100644 --- a/packages/testing/testing-module.builder.ts +++ b/packages/testing/testing-module.builder.ts @@ -184,10 +184,11 @@ export class TestingModuleBuilder { private async createInstancesOfDependencies( graphInspector: GraphInspector, - options: { preview?: boolean }, + options: { preview?: boolean; snapshot?: boolean }, ) { const injector = new TestingInjector({ preview: options?.preview ?? false, + snapshot: options?.snapshot ?? false, }); const instanceLoader = new TestingInstanceLoader( this.container, diff --git a/packages/websockets/Readme.md b/packages/websockets/Readme.md index 9d631e4797..99efbbfd78 100644 --- a/packages/websockets/Readme.md +++ b/packages/websockets/Readme.md @@ -29,7 +29,7 @@ Nest is a framework for building efficient, scalable Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

+

In recent years, thanks to Node.js, JavaScript has become the “lingua franca” of the web for both front-end and back-end applications, giving rise to awesome projects like Angular, React, and Vue, which improve developer productivity and enable the construction of fast, testable, and extensible frontend applications. However, on the server-side, while there are a lot of superb libraries, helpers, and tools for Node, none of them effectively solve the main problem - the architecture.

Nest aims to provide an application architecture out of the box which allows for effortless creation of highly testable, scalable, and loosely coupled and easily maintainable applications. The architecture is heavily inspired by Angular.

## Getting started @@ -95,6 +95,7 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors + diff --git a/packages/websockets/package.json b/packages/websockets/package.json index 575ec23fa5..0ff5373b16 100644 --- a/packages/websockets/package.json +++ b/packages/websockets/package.json @@ -1,6 +1,6 @@ { "name": "@nestjs/websockets", - "version": "11.1.13", + "version": "11.1.20", "description": "Nest - modern, fast, powerful node.js web framework (@websockets)", "author": "Kamil Mysliwiec", "license": "MIT", @@ -26,8 +26,8 @@ "tslib": "2.8.1" }, "devDependencies": { - "@nestjs/common": "11.1.13", - "@nestjs/core": "11.1.13" + "@nestjs/common": "11.1.20", + "@nestjs/core": "11.1.20" }, "peerDependencies": { "@nestjs/common": "^11.0.0", diff --git a/readme_jp.md b/readme_jp.md index 5e793672b0..c2f5504cd3 100644 --- a/readme_jp.md +++ b/readme_jp.md @@ -32,7 +32,7 @@ ## 哲学

-近年、Node.jsの発展によって、JavaScriptはフロントエンドとバックエンドの両方でWebの「共通言語」になりました。そしてフロントエンドでは、Angular、React、Vueなどの素晴らしいプロジェクトが生まれ、開発効率が飛躍的に向上しました。一方で、サーバーサイドでは、Node.jsエコシステム上に優れたライブラリ、ヘルパー、ツールがたくさんありますが、それらのどれもが主要な問題であるアーキテクチャを効果的に解決するものではありませんでした。 +近年、Node.jsの発展によって、JavaScriptはフロントエンドとバックエンドの両方でWebの「共通言語」になりました。そしてフロントエンドでは、Angular、React、Vueなどの素晴らしいプロジェクトが生まれ、開発効率が飛躍的に向上しました。一方で、サーバーサイドでは、Node.jsエコシステム上に優れたライブラリ、ヘルパー、ツールがたくさんありますが、それらのどれもが主要な問題であるアーキテクチャを効果的に解決するものではありませんでした。

Nestは、実用的なアーキテクチャをすぐに構築できることを目的としています。具体的には、テストフレンドリーで、スケーラブルで、疎結合で、運用にやさしいアプリケーションを構築できるようになっています。また、アーキテクチャはAngularにインスパイアされています。

diff --git a/readme_kr.md b/readme_kr.md index 8da72a5776..9d5128b72f 100644 --- a/readme_kr.md +++ b/readme_kr.md @@ -30,7 +30,7 @@ Nest는 효율적이고 확장 가능한 Angular와 React, Vue와 같이 멋진 프로젝트들이 등장하여 개발자의 생산성을 향상시켰으며, 빠르고 테스트와 확장이 가능한 프론트엔드 애플리케이션을 만들 수 있게 해주었습니다. 하지만 서버 사이드에서는 Node.js를 위한 우수한 라이브러리와 툴이 많은 가운데, 주요 문제를 효과적으로 해결하는 것은 없었습니다. 그 주요 문제란 바로 아키텍처(architecture)입니다.

+

최근 몇 년간 Javascript는 Node.js의 발전에 따라 웹의 프론트엔드와 백엔드에 있어서 "공용어"가 되었습니다. 그리고 프론트엔드에서는 Angular와 React, Vue와 같이 멋진 프로젝트들이 등장하여 개발자의 생산성을 향상시켰으며, 빠르고 테스트와 확장이 가능한 프론트엔드 애플리케이션을 만들 수 있게 해주었습니다. 하지만 서버 사이드에서는 Node.js를 위한 우수한 라이브러리와 툴이 많은 가운데, 주요 문제를 효과적으로 해결하는 것은 없었습니다. 그 주요 문제란 바로 아키텍처(architecture)입니다.

Nest는 테스트 친화적이면서 확장이 가능하고 결합이 느슨하며, 유지 관리가 용이한 애플리케이션을 손쉽게 만들 수 있는 애플리케이션 아키텍처 제공을 목표로 합니다. 이 아키텍처는 Angular에서 많은 영감을 받았습니다.

## 시작하기 diff --git a/readme_zh.md b/readme_zh.md index 0959d0a1ed..d6d2a63787 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -31,7 +31,7 @@ ## 设计哲学 -

近几年,由于 Node.js,JavaScript 已经成为 Web 前端和后端应用程序的「通用语言」,从而产生了像Angular、React、Vue等令人耳目一新的项目,这些项目提高了开发人员的生产力,使得可以快速构建可测试的且可扩展的前端应用程序。 然而,在服务器端,虽然有很多优秀的库、helper 和 Node 工具,但是它们都没有有效地解决主要问题 - 架构。

+

近几年,由于 Node.js,JavaScript 已经成为 Web 前端和后端应用程序的「通用语言」,从而产生了像Angular、React、Vue等令人耳目一新的项目,这些项目提高了开发人员的生产力,使得可以快速构建可测试的且可扩展的前端应用程序。 然而,在服务器端,虽然有很多优秀的库、helper 和 Node 工具,但是它们都没有有效地解决主要问题 - 架构。

Nest 旨在提供一个开箱即用的应用程序体系结构,允许轻松创建高度可测试,可扩展,松散耦合且易于维护的应用程序。

diff --git a/sample/02-gateways/src/adapters/redis-io.adapter.ts b/sample/02-gateways/src/adapters/redis-io.adapter.ts index e8c4467d00..38e3bf1b4d 100644 --- a/sample/02-gateways/src/adapters/redis-io.adapter.ts +++ b/sample/02-gateways/src/adapters/redis-io.adapter.ts @@ -7,7 +7,10 @@ export class RedisIoAdapter extends IoAdapter { private adapterConstructor: ReturnType; async connectToRedis(): Promise { - const pubClient = createClient({ url: `redis://localhost:6379` }); + const pubClient = createClient({ + url: `redis://localhost:6379`, + clientInfoTag: this.getClientInfoTag(), + }); const subClient = pubClient.duplicate(); await Promise.all([pubClient.connect(), subClient.connect()]); @@ -20,4 +23,15 @@ export class RedisIoAdapter extends IoAdapter { server.adapter(this.adapterConstructor); return server; } + + private getClientInfoTag(): string { + try { + // Try to get NestJS version from package.json + const nestVersion = require('@nestjs/common/package.json').version; + return `nestjs_v${nestVersion}`; + } catch { + // Fallback if version cannot be determined + return 'nestjs'; + } + } } diff --git a/sample/13-mongo-typeorm/docker-compose.yml b/sample/13-mongo-typeorm/docker-compose.yml index 0da2009e56..a3cc6ba1f5 100644 --- a/sample/13-mongo-typeorm/docker-compose.yml +++ b/sample/13-mongo-typeorm/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: mongodb: - image: mongo:8.2 + image: mongo:8.3 environment: - MONGODB_DATABASE="test" ports: diff --git a/sample/25-dynamic-modules/README.md b/sample/25-dynamic-modules/README.md index ac4f775266..8bc03c325c 100644 --- a/sample/25-dynamic-modules/README.md +++ b/sample/25-dynamic-modules/README.md @@ -7,7 +7,7 @@ [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux [linux-url]: https://travis-ci.org/nestjs/nest -

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

+

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

NPM Version Package License diff --git a/sample/26-queues/README.md b/sample/26-queues/README.md index ac4f775266..8bc03c325c 100644 --- a/sample/26-queues/README.md +++ b/sample/26-queues/README.md @@ -7,7 +7,7 @@ [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux [linux-url]: https://travis-ci.org/nestjs/nest -

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

+

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

NPM Version Package License diff --git a/sample/27-scheduling/README.md b/sample/27-scheduling/README.md index ac4f775266..8bc03c325c 100644 --- a/sample/27-scheduling/README.md +++ b/sample/27-scheduling/README.md @@ -7,7 +7,7 @@ [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux [linux-url]: https://travis-ci.org/nestjs/nest -

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

+

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

NPM Version Package License diff --git a/sample/28-sse/README.md b/sample/28-sse/README.md index ac4f775266..8bc03c325c 100644 --- a/sample/28-sse/README.md +++ b/sample/28-sse/README.md @@ -7,7 +7,7 @@ [linux-image]: https://img.shields.io/travis/nestjs/nest/master.svg?label=linux [linux-url]: https://travis-ci.org/nestjs/nest -

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

+

A progressive Node.js framework for building efficient and scalable server-side applications, heavily inspired by Angular.

NPM Version Package License diff --git a/sample/34-using-esm-packages/package.json b/sample/34-using-esm-packages/package.json new file mode 100644 index 0000000000..826393ffb9 --- /dev/null +++ b/sample/34-using-esm-packages/package.json @@ -0,0 +1,53 @@ +{ + "name": "34-using-esm-packages", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "type": "module", + "engines": { + "node": ">=18.8" + }, + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "oxlint src --fix", + "test": "vitest run", + "test:watch": "vitest", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect-brk --no-file-parallelism", + "test:e2e": "vitest run --config ./vitest.config.e2e.mts" + }, + "dependencies": { + "@nestjs/common": "11.1.13", + "@nestjs/core": "11.1.13", + "@nestjs/platform-express": "11.1.13", + "delay": "7.x", + "reflect-metadata": "0.2.2", + "rimraf": "6.1.2", + "rxjs": "7.8.2", + "superjson": "2.x" + }, + "devDependencies": { + "@nestjs/cli": "11.0.16", + "@nestjs/schematics": "11.0.9", + "@nestjs/testing": "11.1.13", + "@types/express": "5.0.6", + "@types/node": "24.10.11", + "@types/supertest": "6.0.3", + "oxlint": "1.58.0", + "prettier": "3.8.1", + "source-map-support": "0.5.21", + "supertest": "7.2.2", + "ts-loader": "9.5.4", + "ts-node": "10.9.2", + "tsconfig-paths": "4.2.0", + "typescript": "6.0.2", + "vitest": "4.1.2" + } +} diff --git a/sample/35-use-esm-package-after-node22/package.json b/sample/35-use-esm-package-after-node22/package.json new file mode 100644 index 0000000000..4a581df1a0 --- /dev/null +++ b/sample/35-use-esm-package-after-node22/package.json @@ -0,0 +1,52 @@ +{ + "name": "35-use-esm-package-after-node22", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start --exec \"node --experimental-require-module\"", + "start:dev": "nest start --exec \"node --experimental-require-module\" --watch", + "start:debug": "nest start --exec \"node --experimental-require-module\" --debug --watch", + "start:prod": "node --experimental-require-module dist/main", + "lint": "oxlint src --fix", + "test": "vitest run", + "test:watch": "vitest", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect-brk --no-file-parallelism", + "test:e2e": "vitest run --config ./vitest.config.e2e.mts" + }, + "dependencies": { + "@nestjs/common": "11.1.13", + "@nestjs/core": "11.1.13", + "@nestjs/platform-express": "11.1.13", + "reflect-metadata": "0.2.2", + "rimraf": "6.1.2", + "rxjs": "7.8.2", + "superjson": "2.x" + }, + "devDependencies": { + "@nestjs/cli": "11.0.16", + "@nestjs/schematics": "11.0.9", + "@nestjs/testing": "11.1.13", + "@types/express": "5.0.6", + "@types/node": "24.10.11", + "@types/supertest": "6.0.3", + "oxlint": "1.58.0", + "prettier": "3.8.1", + "source-map-support": "0.5.21", + "supertest": "7.2.2", + "ts-loader": "9.5.4", + "ts-node": "10.9.2", + "tsconfig-paths": "4.2.0", + "typescript": "6.0.2", + "vitest": "4.1.2" + } +} diff --git a/scripts/docker-compose.js b/scripts/docker-compose.js new file mode 100644 index 0000000000..86fd339bc4 --- /dev/null +++ b/scripts/docker-compose.js @@ -0,0 +1,59 @@ +/** + * Cross-platform wrapper that forwards arguments to whichever Docker Compose + * implementation is available on the host: + * + * 1. `docker compose` (Compose V2 plugin, shipped with modern Docker Desktop) + * 2. `docker-compose` (legacy standalone V1 binary) + * + * This exists because contributors run a mix of both and neither is guaranteed + * to be installed. The wrapper prefers V2 when available and transparently + * falls back to V1, so npm scripts like `test:docker:up` work everywhere. + */ + +import { spawnSync } from 'node:child_process'; + +const forwardedArgs = process.argv.slice(2); +const isWindows = process.platform === 'win32'; + +function hasComposeV2() { + const result = spawnSync('docker', ['compose', 'version'], { + stdio: 'ignore', + shell: isWindows, + }); + return result.status === 0; +} + +function hasComposeV1() { + const probe = isWindows ? 'where' : 'which'; + const result = spawnSync(probe, ['docker-compose'], { + stdio: 'ignore', + shell: isWindows, + }); + return result.status === 0; +} + +function run(command, args) { + const result = spawnSync(command, args, { + stdio: 'inherit', + shell: isWindows, + }); + if (result.error) { + console.error( + `[docker-compose] Failed to execute: ${result.error.message}`, + ); + process.exit(1); + } + process.exit(result.status == null ? 1 : result.status); +} + +if (hasComposeV2()) { + run('docker', ['compose', ...forwardedArgs]); +} else if (hasComposeV1()) { + run('docker-compose', forwardedArgs); +} else { + console.error( + "[docker-compose] Neither 'docker compose' (V2) nor 'docker-compose' (V1) was found on PATH.\n" + + 'Install Docker Desktop or the Docker Compose plugin and try again.', + ); + process.exit(1); +} diff --git a/scripts/prepare.sh b/scripts/prepare.sh index 8d2d278328..8f7abdb64b 100644 --- a/scripts/prepare.sh +++ b/scripts/prepare.sh @@ -2,4 +2,7 @@ npm run build &>/dev/null # 2. Start docker containers to perform integration tests -npm run test:docker:up \ No newline at end of file +npm run test:docker:up + +# 3. Wait for RabbitMQ to accept AMQP connections +npm run test:docker:wait:rmq diff --git a/scripts/run-integration.sh b/scripts/run-integration.sh index 4a3c15fa2b..345a98fff2 100755 --- a/scripts/run-integration.sh +++ b/scripts/run-integration.sh @@ -4,5 +4,8 @@ npm run build &>/dev/null # 2. Start docker containers to perform integration tests npm run test:docker:up -# 3. Run integration tests -npm run test:integration \ No newline at end of file +# 3. Wait for RabbitMQ to accept AMQP connections +npm run test:docker:wait:rmq + +# 4. Run integration tests +npm run test:integration diff --git a/scripts/wait-for-rabbitmq.js b/scripts/wait-for-rabbitmq.js new file mode 100644 index 0000000000..6d077ac861 --- /dev/null +++ b/scripts/wait-for-rabbitmq.js @@ -0,0 +1,46 @@ +import amqp from 'amqplib'; + +const url = process.env.RABBITMQ_URL || 'amqp://localhost:5672'; +const readPositiveInteger = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const timeoutMs = readPositiveInteger( + process.env.RABBITMQ_WAIT_TIMEOUT_MS, + 60000, +); +const intervalMs = readPositiveInteger( + process.env.RABBITMQ_WAIT_INTERVAL_MS, + 1000, +); +const startedAt = Date.now(); + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function waitForRabbitMQ() { + let lastError; + + while (Date.now() - startedAt < timeoutMs) { + try { + const connection = await amqp.connect(url); + await connection.close(); + console.log(`[rabbitmq] Ready at ${url}`); + return; + } catch (err) { + lastError = err; + console.log(`[rabbitmq] Waiting for ${url}...`); + await sleep(intervalMs); + } + } + + console.error( + `[rabbitmq] Timed out waiting for ${url} after ${timeoutMs}ms.`, + ); + if (lastError) { + console.error(lastError); + } + process.exit(1); +} + +void waitForRabbitMQ(); diff --git a/tools/benchmarks/package-lock.json b/tools/benchmarks/package-lock.json index 349a0678e6..fbb966bafe 100644 --- a/tools/benchmarks/package-lock.json +++ b/tools/benchmarks/package-lock.json @@ -223,12 +223,12 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.13", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.13.tgz", - "integrity": "sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==", + "version": "11.1.20", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.20.tgz", + "integrity": "sha512-uivaQnTwnfnW1cam8rlh9Xpxvvq+9zKs5ywHWtHRHu1CSQ2FnsLvDewP9Ybhyuf6l075BXKgbixxyW9OKZGpmA==", "license": "MIT", "dependencies": { - "file-type": "21.3.0", + "file-type": "21.3.4", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", @@ -254,16 +254,16 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.13", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.13.tgz", - "integrity": "sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==", + "version": "11.1.20", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.20.tgz", + "integrity": "sha512-RlaeQctEtSNrc3SxwQDnAxv/R22ww2t5dbP8OMzaghXofUPRCe2dXhXZSlKMkCyCSEWX+tMhESlGRB5rcJhYTQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -295,15 +295,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.13", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.13.tgz", - "integrity": "sha512-LYmi43BrAs1n74kLCUfXcHag7s1CmGETcFbf9IVyA/KWXAuAH95G3wEaZZiyabOLFNwq4ifnRGnIwUwW7cz3+w==", + "version": "11.1.20", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.20.tgz", + "integrity": "sha512-HFJ/bf189AGziCp7o9DPdPEgj7LXvCWswR3hBNiVCIo3SwOwTIss1BsQWCCJVAtVYTmwIHOI0i0k9dMGXh3Hew==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.0.2", - "path-to-regexp": "8.3.0", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "funding": { @@ -316,19 +316,19 @@ } }, "node_modules/@nestjs/platform-fastify": { - "version": "11.1.13", - "resolved": "https://registry.npmjs.org/@nestjs/platform-fastify/-/platform-fastify-11.1.13.tgz", - "integrity": "sha512-08VkZt5SVRtRyuEzRxY5KzrtsTSiqTSQlySUI8GUVhINHV/iziB6GKOWJc3upTmxvYG/5LSmDO4hv8kDnsWNCQ==", + "version": "11.1.20", + "resolved": "https://registry.npmjs.org/@nestjs/platform-fastify/-/platform-fastify-11.1.20.tgz", + "integrity": "sha512-2dmSTnGisXEzzZ6NvdVwL43VRIfWe2v22tgyH/pD/6Qb6vj58Q8zp6nhU43uoxu2sgLOxq+9ZdS6izeUFJRcEQ==", "license": "MIT", "dependencies": { "@fastify/cors": "11.2.0", "@fastify/formbody": "8.0.2", "fast-querystring": "1.1.2", - "fastify": "5.7.4", + "fastify": "5.8.5", "fastify-plugin": "5.1.0", - "find-my-way": "9.4.0", + "find-my-way": "9.6.0", "light-my-request": "6.6.0", - "path-to-regexp": "8.3.0", + "path-to-regexp": "8.4.2", "reusify": "1.1.0", "tslib": "2.8.1" }, @@ -436,9 +436,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1094,9 +1094,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -1110,9 +1110,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastify": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", - "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", "funding": [ { "type": "github", @@ -1134,7 +1134,7 @@ "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", - "pino": "^10.1.0", + "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", @@ -1168,9 +1168,9 @@ } }, "node_modules/file-type": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", - "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "license": "MIT", "dependencies": { "@tokenizer/inflate": "^0.4.1", @@ -1207,9 +1207,9 @@ } }, "node_modules/find-my-way": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", - "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1711,23 +1711,12 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1735,21 +1724,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -1882,9 +1872,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -2623,15 +2613,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } } } }