@nestjs/common 12.1.3 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/constants.d.ts CHANGED
@@ -35,6 +35,7 @@ export declare const HEADERS_METADATA = "__headers__";
35
35
  export declare const REDIRECT_METADATA = "__redirect__";
36
36
  export declare const RESPONSE_PASSTHROUGH_METADATA = "__responsePassthrough__";
37
37
  export declare const SSE_METADATA = "__sse__";
38
+ export declare const SSE_OPTIONS_METADATA = "__sseOptions__";
38
39
  export declare const VERSION_METADATA = "__version__";
39
40
  export declare const INJECTABLE_WATERMARK = "__injectable__";
40
41
  export declare const CONTROLLER_WATERMARK = "__controller__";
package/constants.js CHANGED
@@ -34,6 +34,7 @@ export const HEADERS_METADATA = '__headers__';
34
34
  export const REDIRECT_METADATA = '__redirect__';
35
35
  export const RESPONSE_PASSTHROUGH_METADATA = '__responsePassthrough__';
36
36
  export const SSE_METADATA = '__sse__';
37
+ export const SSE_OPTIONS_METADATA = '__sseOptions__';
37
38
  export const VERSION_METADATA = '__version__';
38
39
  export const INJECTABLE_WATERMARK = '__injectable__';
39
40
  export const CONTROLLER_WATERMARK = '__controller__';
@@ -37,32 +37,19 @@ export function createParamDecorator(factory, enhancers = []) {
37
37
  (Array.isArray(data.pipes) &&
38
38
  // Falsy entries are allowed, as the pipes context filters them out
39
39
  data.pipes.every(pipe => isNil(pipe) || isPipe(pipe))));
40
- // Check if the last pipe argument is actually an options object
41
- const lastPipeArg = paramPipes.length > 0 ? paramPipes[paramPipes.length - 1] : undefined;
42
- const isLastPipeOptions = !isDataOptions && isParameterDecoratorOptions(lastPipeArg);
43
- let finalData;
40
+ const finalData = isDataOptions ? undefined : paramData;
44
41
  let finalSchema;
45
- let finalPipes;
46
- if (isDataOptions) {
47
- const opts = data;
48
- finalData = undefined;
49
- finalSchema = opts.schema;
50
- // Merge positional pipes passed after the options object
51
- finalPipes = [...(opts.pipes ?? []), ...paramPipes];
52
- }
53
- else if (isLastPipeOptions) {
54
- const opts = lastPipeArg;
55
- finalData = paramData;
56
- finalSchema = opts.schema;
57
- finalPipes = [
58
- ...paramPipes.slice(0, -1),
59
- ...(opts.pipes ?? []),
60
- ];
61
- }
62
- else {
63
- finalData = paramData;
64
- finalSchema = undefined;
65
- finalPipes = paramPipes;
42
+ const finalPipes = [];
43
+ // Options objects may also appear among the pipes, at any position.
44
+ // Their pipes are merged in place, and the first schema found is used.
45
+ for (const arg of isDataOptions ? [data, ...paramPipes] : paramPipes) {
46
+ if (isParameterDecoratorOptions(arg)) {
47
+ finalSchema ??= arg.schema;
48
+ finalPipes.push(...(arg.pipes ?? []));
49
+ }
50
+ else {
51
+ finalPipes.push(arg);
52
+ }
66
53
  }
67
54
  Reflect.defineMetadata(ROUTE_ARGS_METADATA, assignCustomParameterMetadata(args, paramtype, index, factory, finalData, finalSchema, ...finalPipes), target.constructor, key);
68
55
  enhancers.forEach(fn => fn(target, key, index));
@@ -1,10 +1,47 @@
1
- import { METHOD_METADATA } from '../../constants.js';
2
1
  import { RequestMethod } from '../../enums/request-method.enum.js';
2
+ /**
3
+ * Interface defining options that can be passed to `@Sse()` decorator
4
+ *
5
+ * @publicApi
6
+ */
7
+ export interface SseOptions {
8
+ /**
9
+ * HTTP request method of the route (default: `RequestMethod.GET`).
10
+ */
11
+ method?: RequestMethod;
12
+ /**
13
+ * Maximum number of characters of event data (`data` plus `comment`) that
14
+ * may wait for a client that is not reading its events (default:
15
+ * 4 * 1024 * 1024, i.e. 4M characters). Events wait once the stream and
16
+ * socket buffers are full, as the producer cannot be paused. Past this
17
+ * limit, the connection is dropped as if the client had disconnected, and
18
+ * a warning is logged. Set to 0 or `Infinity` to disable.
19
+ */
20
+ maxPendingDataLength?: number;
21
+ }
3
22
  /**
4
23
  * Declares this route as a Server-Sent-Events endpoint
5
24
  *
25
+ * Events wait in memory for a client that is not reading them. Once more than
26
+ * `maxPendingDataLength` characters of event data (4M by default) wait for a
27
+ * client whose stream is backed up, its connection is dropped. An
28
+ * `EventSource` then reconnects, sending the id of the last event it received.
29
+ * Raise the limit for a route that streams bulk data faster than its clients
30
+ * read, or set it to 0 or `Infinity` to lift it.
31
+ *
32
+ * @param path route path (default: `/`)
33
+ * @param options `SseOptions` object specifying:
34
+ * - `method` - HTTP request method of the route (default: `GET`).
35
+ * - `maxPendingDataLength` - maximum number of characters of event data that
36
+ * may wait for a client that is not reading them (default: 4M). 0 or
37
+ * `Infinity` lifts the limit.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * @Sse('logs', { maxPendingDataLength: 64 * 1024 * 1024 })
42
+ * logs(): Observable<MessageEvent> { ... }
43
+ * ```
44
+ *
6
45
  * @publicApi
7
46
  */
8
- export declare function Sse(path?: string, options?: {
9
- [METHOD_METADATA]?: RequestMethod;
10
- }): MethodDecorator;
47
+ export declare function Sse(path?: string, options?: SseOptions): MethodDecorator;
@@ -1,18 +1,37 @@
1
- import { METHOD_METADATA, PATH_METADATA, SSE_METADATA, } from '../../constants.js';
1
+ import { METHOD_METADATA, PATH_METADATA, SSE_METADATA, SSE_OPTIONS_METADATA, } from '../../constants.js';
2
2
  import { RequestMethod } from '../../enums/request-method.enum.js';
3
3
  /**
4
4
  * Declares this route as a Server-Sent-Events endpoint
5
5
  *
6
+ * Events wait in memory for a client that is not reading them. Once more than
7
+ * `maxPendingDataLength` characters of event data (4M by default) wait for a
8
+ * client whose stream is backed up, its connection is dropped. An
9
+ * `EventSource` then reconnects, sending the id of the last event it received.
10
+ * Raise the limit for a route that streams bulk data faster than its clients
11
+ * read, or set it to 0 or `Infinity` to lift it.
12
+ *
13
+ * @param path route path (default: `/`)
14
+ * @param options `SseOptions` object specifying:
15
+ * - `method` - HTTP request method of the route (default: `GET`).
16
+ * - `maxPendingDataLength` - maximum number of characters of event data that
17
+ * may wait for a client that is not reading them (default: 4M). 0 or
18
+ * `Infinity` lifts the limit.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * @Sse('logs', { maxPendingDataLength: 64 * 1024 * 1024 })
23
+ * logs(): Observable<MessageEvent> { ... }
24
+ * ```
25
+ *
6
26
  * @publicApi
7
27
  */
8
- export function Sse(path, options = {
9
- [METHOD_METADATA]: RequestMethod.GET,
10
- }) {
28
+ export function Sse(path, options = {}) {
11
29
  return (target, key, descriptor) => {
12
30
  path = path && path.length ? path : '/';
13
31
  Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
14
- Reflect.defineMetadata(METHOD_METADATA, options[METHOD_METADATA], descriptor.value);
32
+ Reflect.defineMetadata(METHOD_METADATA, options[METHOD_METADATA] ?? RequestMethod.GET, descriptor.value);
15
33
  Reflect.defineMetadata(SSE_METADATA, true, descriptor.value);
34
+ Reflect.defineMetadata(SSE_OPTIONS_METADATA, { maxPendingDataLength: options.maxPendingDataLength }, descriptor.value);
16
35
  return descriptor;
17
36
  };
18
37
  }
@@ -20,8 +20,11 @@ export class StreamableFile {
20
20
  res.end();
21
21
  return;
22
22
  }
23
+ // The message may name the path the stream reads from, so it is
24
+ // logged rather than sent.
25
+ this.logError(err);
23
26
  res.statusCode = HttpStatus.BAD_REQUEST;
24
- res.send(err.message);
27
+ res.send('Bad Request');
25
28
  };
26
29
  logError = (err) => {
27
30
  this.logger.error(err);
@@ -39,6 +42,13 @@ export class StreamableFile {
39
42
  }
40
43
  else if (bufferOrReadStream.pipe && isFunction(bufferOrReadStream.pipe)) {
41
44
  this.stream = bufferOrReadStream;
45
+ // Without a listener, an 'error' emitted before the adapter attaches
46
+ // its own (e.g. ENOENT from `fs.createReadStream()` while an
47
+ // interceptor awaits), or by a file that is never sent, would bring
48
+ // the process down.
49
+ if (isFunction(this.stream.on)) {
50
+ this.stream.on('error', () => { });
51
+ }
42
52
  }
43
53
  }
44
54
  getStream() {
@@ -36,12 +36,13 @@ export interface CsrfProtectionOptions<TRequest = any> {
36
36
  * object. Only consulted for requests that would otherwise be rejected.
37
37
  *
38
38
  * Routes are declared like `MiddlewareConsumer.exclude()`: a path, or
39
- * `{ path, method, version? }` to narrow by method (and URI version),
40
- * without the global prefix, which is added unless the route is excluded
41
- * from it. Unlike `exclude()`, the match is exact: case-sensitive, without
42
- * an optional trailing slash, and never for non-canonical request paths
43
- * (`//`, dot segments, `;`, encoded `/` or `.`), which a router could
44
- * resolve to a route that is not excluded.
39
+ * `{ path, method, version? }` to narrow by method and version (the
40
+ * version segment of the path with URI versioning, the version the request
41
+ * asks for otherwise), without the global prefix, which is added unless
42
+ * the route is excluded from it. Unlike `exclude()`, the match is exact:
43
+ * case-sensitive, without an optional trailing slash, and never for
44
+ * non-canonical request paths (`//`, dot segments, `;`, encoded `/` or
45
+ * `.`), which a router could resolve to a route that is not excluded.
45
46
  */
46
47
  exclude?: (string | RouteInfo)[] | ((request: TRequest) => boolean);
47
48
  }
@@ -52,6 +52,22 @@ export type SecurityRequestHook<TRequest = any> = (request: TRequest, response:
52
52
  setHeader(name: string, value: string): unknown;
53
53
  removeHeader(name: string): unknown;
54
54
  }) => Error | undefined;
55
+ /**
56
+ * How a router matches request paths against route paths, returned by
57
+ * {@link HttpServer.getRouteMatchingOptions}.
58
+ *
59
+ * @publicApi
60
+ */
61
+ export interface RouteMatchingOptions {
62
+ /**
63
+ * Whether `/Users` and `/users` are different paths.
64
+ */
65
+ caseSensitive: boolean;
66
+ /**
67
+ * Whether `/users/` is served by the `/users` route.
68
+ */
69
+ ignoreTrailingSlash: boolean;
70
+ }
55
71
  /**
56
72
  * Contract between the Nest core (`NestApplication`, the router, the
57
73
  * middleware module and the exception layer) and an HTTP platform such as
@@ -524,11 +540,15 @@ export interface HttpServer<TRequest = any, TResponse = any, ServerInstance = an
524
540
  * features is enabled. The hook must run for every request (matched routes,
525
541
  * unmatched requests, routes outside the global prefix) before Nest
526
542
  * middleware, guards and route handlers, and before body parsing when the
527
- * platform allows it; registering a framework-level middleware or request
528
- * hook at call time satisfies that on Express and Fastify. It receives the
529
- * request and the Node.js `ServerResponse` (`reply.raw` on Fastify), on
530
- * which it may set headers that route handlers, `@Header()` and exception
531
- * filters can still override.
543
+ * platform allows it. Registering a framework-level middleware at call time
544
+ * satisfies that on Express. An adapter whose {@link HttpServer.init} sets
545
+ * up what runs Nest middleware cannot register at call time, as
546
+ * `NestFactory.create()` calls `init()` first: `FastifyAdapter` adds its
547
+ * request hook in the constructor, and only stores the hook here.
548
+ *
549
+ * The hook receives the request and the Node.js `ServerResponse`
550
+ * (`reply.raw` on Fastify), on which it may set headers that route
551
+ * handlers, `@Header()` and exception filters can still override.
532
552
  *
533
553
  * When the hook returns an error, the adapter must not continue to the
534
554
  * route: it hands the error to the exception layer installed through
@@ -633,4 +653,15 @@ export interface HttpServer<TRequest = any, TResponse = any, ServerInstance = an
633
653
  * `(method, path)` registrations itself.
634
654
  */
635
655
  isRouteOrderSensitive?(): boolean;
656
+ /**
657
+ * Tells the core how the underlying router matches request paths, so that
658
+ * the routes excluded with `MiddlewareConsumer.exclude()` are matched the
659
+ * same way: a request only skips the middleware when the router serves it
660
+ * with the excluded route. Called once per `forRoutes()` call, during
661
+ * `app.init()`. When not implemented, excluded routes are matched exactly:
662
+ * case-sensitively and without an optional trailing slash. With a more
663
+ * lenient router, a request for `/Users/login/` then runs the middleware
664
+ * excluded for `users/login` rather than skipping it.
665
+ */
666
+ getRouteMatchingOptions?(): RouteMatchingOptions;
636
667
  }
@@ -8,6 +8,11 @@ export interface MiddlewareConfigProxy {
8
8
  /**
9
9
  * Routes to exclude from the current middleware.
10
10
  *
11
+ * A route given with a `version` is only excluded for the requests for that
12
+ * version: with URI versioning, through the version segment of its path,
13
+ * and with header, media type or custom versioning, by the version the
14
+ * request asks for.
15
+ *
11
16
  * @param {(string | RouteInfo)[]} routes
12
17
  * @returns {MiddlewareConfigProxy}
13
18
  */
@@ -40,9 +40,10 @@ export interface INestApplication<TServer = any> extends INestApplicationContext
40
40
  *
41
41
  * Must be called once, before `app.init()` / `app.listen()`. The check runs
42
42
  * before Nest middleware, body parsing, guards and handlers. It shares one
43
- * request hook with `app.useSecurityHeaders()`, registered where the first
44
- * of the two is called: middleware registered with `app.use()` before that
45
- * runs before the check.
43
+ * request hook with `app.useSecurityHeaders()`. On Express, the hook is
44
+ * registered where the first of the two is called, so middleware registered
45
+ * with `app.use()` before that runs before the check. On Fastify, the check
46
+ * runs before all middleware.
46
47
  *
47
48
  * @param {CsrfProtectionOptions} options
48
49
  * @returns {this}
@@ -60,9 +61,10 @@ export interface INestApplication<TServer = any> extends INestApplicationContext
60
61
  * route.
61
62
  *
62
63
  * Must be called once, before `app.init()` / `app.listen()`. It shares one
63
- * request hook with `app.enableCsrfProtection()`, registered where the
64
- * first of the two is called: middleware registered with `app.use()` before
65
- * that runs first, so responses it ends itself do not carry the headers.
64
+ * request hook with `app.enableCsrfProtection()`. On Express, the hook is
65
+ * registered where the first of the two is called, so middleware registered
66
+ * with `app.use()` before that runs first, and responses it ends itself do
67
+ * not carry the headers. On Fastify, the hook runs before all middleware.
66
68
  *
67
69
  * @param {SecurityHeadersOptions} options
68
70
  * @returns {this}
package/internal.d.ts CHANGED
@@ -25,7 +25,7 @@ export type { MiddlewareConfiguration, RouteInfo, } from './interfaces/middlewar
25
25
  export type { MiddlewareConfigProxy } from './interfaces/middleware/middleware-config-proxy.interface.js';
26
26
  export type { ModuleMetadata } from './interfaces/modules/module-metadata.interface.js';
27
27
  export type { HttpArgumentsHost, RpcArgumentsHost, WsArgumentsHost, } from './interfaces/features/arguments-host.interface.js';
28
- export type { RequestHandler, SecurityRequestHook, } from './interfaces/http/http-server.interface.js';
28
+ export type { RequestHandler, RouteMatchingOptions, SecurityRequestHook, } from './interfaces/http/http-server.interface.js';
29
29
  export type { GetOrResolveOptions, SelectOptions, } from './interfaces/nest-application-context.interface.js';
30
30
  export type { ShutdownHooksOptions } from './interfaces/shutdown-hooks-options.interface.js';
31
31
  export { assignMetadata } from './decorators/http/route-params.decorator.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestjs/common",
3
- "version": "12.1.3",
3
+ "version": "12.2.0",
4
4
  "description": "Nest - modern, fast, powerful node.js web framework (@common)",
5
5
  "author": "Kamil Mysliwiec",
6
6
  "homepage": "https://nestjs.com",
@@ -21,6 +21,7 @@
21
21
  "main": "./index.js",
22
22
  "exports": {
23
23
  ".": "./index.js",
24
+ "./package.json": "./package.json",
24
25
  "./internal": "./internal.js",
25
26
  "./*.js": "./*.js",
26
27
  "./*": "./*.js"
@@ -47,5 +48,5 @@
47
48
  "optional": true
48
49
  }
49
50
  },
50
- "gitHead": "4514623279f6087808e3c8f08fbf9f37d3c17482"
51
+ "gitHead": "af37111368de7fd2dfab0bdec2f530c7845510c2"
51
52
  }
@@ -2,6 +2,11 @@ import { ArgumentMetadata, PipeTransform } from '../interfaces/features/pipe-tra
2
2
  /**
3
3
  * Defines the built-in DefaultValue Pipe
4
4
  *
5
+ * A default value that is an array or a plain object is copied on every call
6
+ * (along with the arrays and plain objects nested in it), so that a handler
7
+ * that mutates its argument does not change it for subsequent requests. Any
8
+ * other object (e.g., a class instance, a `Date` or a `Map`) is returned as is.
9
+ *
5
10
  * @see [Built-in Pipes](https://docs.nestjs.com/pipes#built-in-pipes)
6
11
  *
7
12
  * @publicApi
@@ -10,4 +15,10 @@ export declare class DefaultValuePipe<T = any, R = any> implements PipeTransform
10
15
  protected readonly defaultValue: R;
11
16
  constructor(defaultValue: R);
12
17
  transform(value?: T, _metadata?: ArgumentMetadata): T | R;
18
+ /**
19
+ * Copies `value` if it is an array or a plain object, along with the arrays
20
+ * and plain objects nested in it. `copies` maps the objects copied so far
21
+ * to their copies, so that shared and circular references are preserved.
22
+ */
23
+ protected copyDefaultValue<V>(value: V, copies?: Map<unknown, unknown>): V;
13
24
  }
@@ -1,9 +1,14 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
2
  import { Injectable } from '../decorators/core/injectable.decorator.js';
3
- import { isNil, isNumber } from '../utils/shared.utils.js';
3
+ import { isNil, isNumber, isPlainObject } from '../utils/shared.utils.js';
4
4
  /**
5
5
  * Defines the built-in DefaultValue Pipe
6
6
  *
7
+ * A default value that is an array or a plain object is copied on every call
8
+ * (along with the arrays and plain objects nested in it), so that a handler
9
+ * that mutates its argument does not change it for subsequent requests. Any
10
+ * other object (e.g., a class instance, a `Date` or a `Map`) is returned as is.
11
+ *
7
12
  * @see [Built-in Pipes](https://docs.nestjs.com/pipes#built-in-pipes)
8
13
  *
9
14
  * @publicApi
@@ -16,10 +21,31 @@ let DefaultValuePipe = class DefaultValuePipe {
16
21
  transform(value, _metadata) {
17
22
  if (isNil(value) ||
18
23
  (isNumber(value) && isNaN(value))) {
19
- return this.defaultValue;
24
+ return this.copyDefaultValue(this.defaultValue);
20
25
  }
21
26
  return value;
22
27
  }
28
+ /**
29
+ * Copies `value` if it is an array or a plain object, along with the arrays
30
+ * and plain objects nested in it. `copies` maps the objects copied so far
31
+ * to their copies, so that shared and circular references are preserved.
32
+ */
33
+ copyDefaultValue(value, copies) {
34
+ const isArray = Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
35
+ if (!isArray && !isPlainObject(value)) {
36
+ return value;
37
+ }
38
+ copies ??= new Map();
39
+ if (copies.has(value)) {
40
+ return copies.get(value);
41
+ }
42
+ const copy = Object.assign(isArray ? [] : Object.create(Object.getPrototypeOf(value)), value);
43
+ copies.set(value, copy);
44
+ for (const key of Reflect.ownKeys(copy)) {
45
+ copy[key] = this.copyDefaultValue(copy[key], copies);
46
+ }
47
+ return copy;
48
+ }
23
49
  };
24
50
  DefaultValuePipe = __decorate([
25
51
  Injectable(),
@@ -7,6 +7,10 @@ export type FileTypeValidatorOptions = {
7
7
  * Expected file type(s) for validation. Can be a string (MIME type)
8
8
  * or a regular expression to match multiple types.
9
9
  *
10
+ * A string matches a MIME type that contains it, up to the end of a name:
11
+ * 'image/png' or 'png' matches 'image/png', and 'audio/wav' does not match
12
+ * 'audio/wavpack'.
13
+ *
10
14
  * @example
11
15
  * // Match a single MIME type
12
16
  * fileType: 'image/png'
@@ -107,9 +107,14 @@ export class FileTypeValidator extends FileValidator {
107
107
  if (typeof fileType === 'string' && mimetype === fileType) {
108
108
  return true;
109
109
  }
110
+ if (typeof fileType === 'string') {
111
+ // A string that ends in a letter or a digit must match up to the end
112
+ // of a name, or "audio/wav" would accept "audio/wavpack" and
113
+ // "font/woff" would accept "font/woff2".
114
+ return !!mimetype.match(/[a-z\d]$/i.test(fileType) ? `(?:${fileType})(?![\\w-])` : fileType);
115
+ }
110
116
  // Stateful expressions must start at zero for each file, without changing
111
117
  // the caller's lastIndex (the validator can be reused across uploads).
112
- const pattern = typeof fileType === 'string' ? fileType : new RegExp(fileType);
113
- return !!mimetype.match(pattern);
118
+ return !!mimetype.match(new RegExp(fileType));
114
119
  }
115
120
  }
@@ -84,8 +84,8 @@ let ParseArrayPipe = class ParseArrayPipe {
84
84
  if (this.options.stopAtFirstError === false) {
85
85
  // strict compare to "false" to make sure
86
86
  // that this option is disabled by default
87
- let errors = [];
88
- const targetArray = value;
87
+ const errors = [];
88
+ const targetArray = Array.from(value);
89
89
  for (let i = 0; i < targetArray.length; i++) {
90
90
  try {
91
91
  targetArray[i] = await toClassInstance(targetArray[i]);
@@ -104,7 +104,17 @@ let ParseArrayPipe = class ParseArrayPipe {
104
104
  else {
105
105
  message = err;
106
106
  }
107
- errors = errors.concat(message);
107
+ // Appended in place, since `concat()` would copy all the errors so
108
+ // far for each invalid item, and one at a time, since one item can
109
+ // have more errors than `push(...message)` takes arguments.
110
+ if (Array.isArray(message)) {
111
+ for (const item of message) {
112
+ errors.push(item);
113
+ }
114
+ }
115
+ else {
116
+ errors.push(message);
117
+ }
108
118
  }
109
119
  }
110
120
  if (errors.length > 0) {
@@ -113,7 +123,18 @@ let ParseArrayPipe = class ParseArrayPipe {
113
123
  return targetArray;
114
124
  }
115
125
  else {
116
- value = await Promise.all(value.map(toClassInstance));
126
+ // One item at a time, up to the first that fails: validating them all
127
+ // at once kept the validation and the exception of every item alive
128
+ // together, and a string splits into up to one item per character.
129
+ const targetArray = value;
130
+ const items = [];
131
+ for (let i = 0; i < targetArray.length; i++) {
132
+ // As with `map()`, the holes of a sparse array are not validated.
133
+ items.push(i in targetArray
134
+ ? await toClassInstance(targetArray[i], i)
135
+ : undefined);
136
+ }
137
+ value = items;
117
138
  }
118
139
  }
119
140
  return value;
@@ -35,6 +35,16 @@ export interface ValidationPipeOptions extends ValidatorOptions {
35
35
  * If you have exception filters or interceptors that assume `message` is always an array, they will need to be updated.
36
36
  */
37
37
  errorFormat?: ValidationErrorFormat;
38
+ /**
39
+ * Maximum total length, in characters, of the messages in an error response
40
+ * of the built-in exception factory, counting the property path that comes
41
+ * with each message. The errors past it are left out, and a last message
42
+ * (in the 'grouped' format, a '...' entry) says so. Set it to `Infinity` to
43
+ * lift the limit.
44
+ *
45
+ * @default 1_000_000
46
+ */
47
+ maxErrorMessagesLength?: number;
38
48
  }
39
49
  /**
40
50
  * @see [Validation](https://docs.nestjs.com/techniques/validation)
@@ -51,19 +61,31 @@ export declare class ValidationPipe implements PipeTransform {
51
61
  protected exceptionFactory: (errors: ValidationError[]) => any;
52
62
  protected validateCustomDecorators: boolean;
53
63
  protected errorFormat: ValidationErrorFormat;
64
+ protected maxErrorMessagesLength: number;
54
65
  constructor(options?: ValidationPipeOptions);
55
66
  protected loadValidator(validatorPackage?: ValidatorPackage): ValidatorPackage | Promise<ValidatorPackage>;
56
67
  protected loadTransformer(transformerPackage?: TransformerPackage): TransformerPackage | Promise<TransformerPackage>;
57
68
  transform(value: unknown, metadata: ArgumentMetadata): Promise<any>;
58
69
  createExceptionFactory(): (validationErrors?: ValidationError[]) => unknown;
70
+ /**
71
+ * Leaves out the errors that come after the first "maxErrorMessagesLength"
72
+ * characters of messages. Each message counts with the path of its
73
+ * property, which is what makes the response to a deeply nested payload
74
+ * grow faster than the payload. The errors past the limit are not visited.
75
+ */
76
+ protected limitValidationErrors(validationErrors: ValidationError[], budget: {
77
+ remaining: number;
78
+ isExceeded: boolean;
79
+ }, parentPathLength?: number): ValidationError[];
59
80
  protected toValidate(metadata: ArgumentMetadata): boolean;
60
81
  protected transformPrimitive(value: unknown, metadata: ArgumentMetadata): unknown;
61
82
  protected toEmptyIfNil<T = any, R = T>(value: unknown, metatype: Type<unknown> | object): R | object | string;
62
83
  protected isPrimitive(value: unknown): boolean;
63
84
  protected stripProtoKeys(value: any): void;
64
85
  protected validate(object: object, validatorOptions?: ValidatorOptions): Promise<ValidationError[]> | ValidationError[];
86
+ protected hasValidationRules(metatype: Type<unknown>): Promise<boolean>;
65
87
  protected flattenValidationErrors(validationErrors: ValidationError[]): string[];
66
- protected groupValidationErrors(validationErrors: ValidationError[], parentPath?: string): Record<string, string[]>;
67
- protected mapChildrenToValidationErrors(error: ValidationError, parentPath?: string): ValidationError[];
88
+ protected groupValidationErrors(validationErrors: ValidationError[], parentPath?: string, result?: Record<string, string[]>): Record<string, string[]>;
89
+ protected mapChildrenToValidationErrors(error: ValidationError, parentPath?: string, validationErrors?: ValidationError[]): ValidationError[];
68
90
  protected prependConstraintsWithParentProp(parentPath: string, error: ValidationError): ValidationError;
69
91
  }
@@ -7,6 +7,8 @@ import { HttpErrorByCode, } from '../utils/http-error-by-code.util.js';
7
7
  import { loadPackage } from '../utils/load-package.util.js';
8
8
  import { isNil, isUndefined } from '../utils/shared.utils.js';
9
9
  import { stripProtoKeys } from '../utils/strip-proto-keys.util.js';
10
+ const DEFAULT_MAX_ERROR_MESSAGES_LENGTH = 1_000_000;
11
+ const OMITTED_ERRORS_MESSAGE = 'Further validation errors were omitted';
10
12
  let classValidator = {};
11
13
  let classTransformer = {};
12
14
  /**
@@ -24,9 +26,10 @@ let ValidationPipe = class ValidationPipe {
24
26
  exceptionFactory;
25
27
  validateCustomDecorators;
26
28
  errorFormat;
29
+ maxErrorMessagesLength;
27
30
  constructor(options) {
28
31
  options = options || {};
29
- const { transform, disableErrorMessages, errorHttpStatusCode, expectedType, transformOptions, exceptionFactory, validatorPackage, transformerPackage, validateCustomDecorators, errorFormat, ...validatorOptions } = options;
32
+ const { transform, disableErrorMessages, errorHttpStatusCode, expectedType, transformOptions, exceptionFactory, validatorPackage, transformerPackage, validateCustomDecorators, errorFormat, maxErrorMessagesLength, ...validatorOptions } = options;
30
33
  // @see [https://github.com/nestjs/nest/issues/10683#issuecomment-1413690508](https://github.com/nestjs/nest/issues/10683#issuecomment-1413690508)
31
34
  this.validatorOptions = { forbidUnknownValues: false, ...validatorOptions };
32
35
  this.isTransformEnabled = !!transform;
@@ -36,6 +39,8 @@ let ValidationPipe = class ValidationPipe {
36
39
  this.errorHttpStatusCode = errorHttpStatusCode || HttpStatus.BAD_REQUEST;
37
40
  this.expectedType = expectedType;
38
41
  this.errorFormat = errorFormat || 'list';
42
+ this.maxErrorMessagesLength =
43
+ maxErrorMessagesLength ?? DEFAULT_MAX_ERROR_MESSAGES_LENGTH;
39
44
  this.exceptionFactory = exceptionFactory || this.createExceptionFactory();
40
45
  classValidator = this.loadValidator(validatorPackage);
41
46
  classTransformer = this.loadTransformer(transformerPackage);
@@ -77,10 +82,33 @@ let ValidationPipe = class ValidationPipe {
77
82
  // metatype defined inside the handler
78
83
  entity = { constructor: metatype };
79
84
  }
80
- const errors = await this.validate(entity, this.validatorOptions);
85
+ const errors = await this.validate(entity, {
86
+ ...this.validatorOptions,
87
+ // The target of an error is the whole object it was found in, so an
88
+ // exception factory that sends the errors would repeat that object
89
+ // once per error.
90
+ validationError: {
91
+ target: false,
92
+ ...this.validatorOptions.validationError,
93
+ },
94
+ });
81
95
  if (errors.length > 0) {
82
96
  throw await this.exceptionFactory(errors);
83
97
  }
98
+ if (isCtorNotEqual &&
99
+ (isPrimitive || Array.isArray(value)) &&
100
+ (await this.hasValidationRules(metatype))) {
101
+ // An array or a primitive is never an instance of the class: the
102
+ // validation above has only seen a stand-in without any property set,
103
+ // which passes when none of them is required.
104
+ throw await this.exceptionFactory([
105
+ {
106
+ property: metadata.data || metadata.type,
107
+ constraints: { isObject: 'Validation failed (object is expected)' },
108
+ children: [],
109
+ },
110
+ ]);
111
+ }
84
112
  if (originalValue === undefined && originalEntity === '') {
85
113
  // Since SWC requires empty string for validation (to avoid an error),
86
114
  // a fallback is needed to revert to the original value (when undefined).
@@ -111,8 +139,16 @@ let ValidationPipe = class ValidationPipe {
111
139
  if (this.isDetailedOutputDisabled) {
112
140
  return new HttpErrorByCode[this.errorHttpStatusCode]();
113
141
  }
142
+ const budget = {
143
+ remaining: this.maxErrorMessagesLength,
144
+ isExceeded: false,
145
+ };
146
+ validationErrors = this.limitValidationErrors(validationErrors, budget);
114
147
  if (this.errorFormat === 'grouped') {
115
- const errors = this.groupValidationErrors(validationErrors);
148
+ let errors = this.groupValidationErrors(validationErrors);
149
+ if (budget.isExceeded) {
150
+ errors = { ...errors, '...': [OMITTED_ERRORS_MESSAGE] };
151
+ }
116
152
  // Custom (object) bodies are returned verbatim by
117
153
  // `HttpException.createBody`, so add the standard envelope fields
118
154
  // explicitly to match the shape of the "list" format.
@@ -123,10 +159,43 @@ let ValidationPipe = class ValidationPipe {
123
159
  statusCode,
124
160
  });
125
161
  }
126
- const errors = this.flattenValidationErrors(validationErrors);
162
+ let errors = this.flattenValidationErrors(validationErrors);
163
+ if (budget.isExceeded) {
164
+ errors = [...errors, OMITTED_ERRORS_MESSAGE];
165
+ }
127
166
  return new HttpErrorByCode[this.errorHttpStatusCode](errors);
128
167
  };
129
168
  }
169
+ /**
170
+ * Leaves out the errors that come after the first "maxErrorMessagesLength"
171
+ * characters of messages. Each message counts with the path of its
172
+ * property, which is what makes the response to a deeply nested payload
173
+ * grow faster than the payload. The errors past the limit are not visited.
174
+ */
175
+ limitValidationErrors(validationErrors, budget, parentPathLength = 0) {
176
+ const limited = [];
177
+ let isChanged = false;
178
+ for (const error of validationErrors) {
179
+ if (budget.remaining <= 0) {
180
+ budget.isExceeded = true;
181
+ return limited;
182
+ }
183
+ const pathLength = parentPathLength + String(error.property).length + 1;
184
+ for (const key in error.constraints) {
185
+ budget.remaining -= pathLength + String(error.constraints[key]).length;
186
+ }
187
+ const children = error.children &&
188
+ this.limitValidationErrors(error.children, budget, pathLength);
189
+ if (children === error.children) {
190
+ limited.push(error);
191
+ }
192
+ else {
193
+ limited.push({ ...error, children });
194
+ isChanged = true;
195
+ }
196
+ }
197
+ return isChanged ? limited : validationErrors;
198
+ }
130
199
  toValidate(metadata) {
131
200
  const { metatype, type } = metadata;
132
201
  if (type === 'custom' && !this.validateCustomDecorators) {
@@ -191,6 +260,11 @@ let ValidationPipe = class ValidationPipe {
191
260
  validate(object, validatorOptions) {
192
261
  return classValidator.validate(object, validatorOptions);
193
262
  }
263
+ async hasValidationRules(metatype) {
264
+ // "forbidUnknownValues" rejects a class without any rule that applies
265
+ const errors = await this.validate({ constructor: metatype }, { ...this.validatorOptions, forbidUnknownValues: true });
266
+ return !errors.some(error => error.constraints?.unknownValue);
267
+ }
194
268
  flattenValidationErrors(validationErrors) {
195
269
  return iterate(validationErrors)
196
270
  .map(error => this.mapChildrenToValidationErrors(error))
@@ -200,8 +274,7 @@ let ValidationPipe = class ValidationPipe {
200
274
  .flatten()
201
275
  .toArray();
202
276
  }
203
- groupValidationErrors(validationErrors, parentPath) {
204
- const result = {};
277
+ groupValidationErrors(validationErrors, parentPath, result = {}) {
205
278
  for (const error of validationErrors) {
206
279
  const path = parentPath
207
280
  ? `${parentPath}.${error.property}`
@@ -210,26 +283,42 @@ let ValidationPipe = class ValidationPipe {
210
283
  result[path] = Object.values(error.constraints);
211
284
  }
212
285
  if (error.children && error.children.length) {
213
- Object.assign(result, this.groupValidationErrors(error.children, path));
286
+ // Nested errors go into the same result: copying each level's result
287
+ // into its parent took as many insertions as depth times leaves.
288
+ const nested = this.groupValidationErrors(error.children, path, result);
289
+ if (nested !== result) {
290
+ // an override returned a result of its own
291
+ Object.assign(result, nested);
292
+ }
214
293
  }
215
294
  }
216
295
  return result;
217
296
  }
218
- mapChildrenToValidationErrors(error, parentPath) {
297
+ mapChildrenToValidationErrors(error, parentPath, validationErrors = []) {
219
298
  const currentError = parentPath
220
299
  ? this.prependConstraintsWithParentProp(parentPath, error)
221
300
  : error;
222
301
  if (!(error.children && error.children.length)) {
223
- return [currentError];
302
+ validationErrors.push(currentError);
303
+ return validationErrors;
304
+ }
305
+ if (error.constraints) {
306
+ validationErrors.push(currentError);
224
307
  }
225
- const validationErrors = error.constraints
226
- ? [currentError]
227
- : [];
228
308
  parentPath = parentPath
229
309
  ? `${parentPath}.${error.property}`
230
310
  : error.property;
231
311
  for (const item of error.children) {
232
- validationErrors.push(...this.mapChildrenToValidationErrors(item, parentPath));
312
+ // Nested errors go into the same list: copying each level's list into
313
+ // its parent took as many copies as depth times leaves, and spreading
314
+ // a long one into push() threw a RangeError.
315
+ const nested = this.mapChildrenToValidationErrors(item, parentPath, validationErrors);
316
+ if (nested !== validationErrors) {
317
+ // an override returned a list of its own
318
+ for (const nestedError of nested) {
319
+ validationErrors.push(nestedError);
320
+ }
321
+ }
233
322
  }
234
323
  return validationErrors;
235
324
  }
@@ -6,6 +6,11 @@ import { loadPackage } from '../utils/load-package.util.js';
6
6
  import { isObject } from '../utils/shared.utils.js';
7
7
  import { CLASS_SERIALIZER_OPTIONS } from './class-serializer.constants.js';
8
8
  let classTransformer = {};
9
+ // The package of the interceptor created last, which may still be loading.
10
+ // "intercept()" resolves it into "classTransformer", so that an interceptor
11
+ // created while responses are serialized (e.g. a request-scoped one) doesn't
12
+ // put a promise in place of the package they use.
13
+ let pendingClassTransformer;
9
14
  // NOTE (external)
10
15
  // We need to deduplicate them here due to the circular dependency
11
16
  // between core and common packages
@@ -19,12 +24,15 @@ let ClassSerializerInterceptor = class ClassSerializerInterceptor {
19
24
  constructor(reflector, defaultOptions = {}) {
20
25
  this.reflector = reflector;
21
26
  this.defaultOptions = defaultOptions;
22
- classTransformer =
27
+ pendingClassTransformer =
23
28
  defaultOptions?.transformerPackage ??
24
29
  loadPackage('class-transformer', 'ClassSerializerInterceptor', () => import('class-transformer'));
30
+ if (!(pendingClassTransformer instanceof Promise)) {
31
+ classTransformer = pendingClassTransformer;
32
+ }
25
33
  }
26
34
  async intercept(context, next) {
27
- classTransformer = (await classTransformer);
35
+ classTransformer = (await pendingClassTransformer);
28
36
  const contextOptions = this.getContextOptions(context);
29
37
  const options = {
30
38
  ...this.defaultOptions,
@@ -60,10 +60,19 @@ let StandardSchemaSerializerInterceptor = class StandardSchemaSerializerIntercep
60
60
  return result.value;
61
61
  }
62
62
  getContextOptions(context) {
63
- return this.reflector.getAllAndOverride(CLASS_SERIALIZER_OPTIONS, [
63
+ const [handlerOptions, classOptions] = this.reflector.getAll(CLASS_SERIALIZER_OPTIONS, [
64
64
  context.getHandler(),
65
65
  context.getClass(),
66
66
  ]);
67
+ if (!handlerOptions || !classOptions) {
68
+ return handlerOptions ?? classOptions;
69
+ }
70
+ // Key by key, so that a handler that only sets "validateOptions" (or the
71
+ // options of another serializer) doesn't drop the schema of its class
72
+ return {
73
+ schema: handlerOptions.schema ?? classOptions.schema,
74
+ validateOptions: handlerOptions.validateOptions ?? classOptions.validateOptions,
75
+ };
67
76
  }
68
77
  };
69
78
  StandardSchemaSerializerInterceptor = __decorate([
@@ -23,6 +23,9 @@ export interface ConsoleLoggerOptions {
23
23
  prefix?: string;
24
24
  /**
25
25
  * If enabled, will print the log message in JSON format.
26
+ * Unless `colors` are enabled or `compact` is not true, a record is cut short
27
+ * at about 1,000,000 characters: what comes past that point is replaced with
28
+ * "[Truncated]".
26
29
  */
27
30
  json?: boolean;
28
31
  /**
@@ -49,28 +52,28 @@ export interface ConsoleLoggerOptions {
49
52
  /**
50
53
  * Specifies the maximum number of Array, TypedArray, Map, Set, WeakMap, and WeakSet elements to include when formatting.
51
54
  * Set to null or Infinity to show all elements. Set to 0 or negative to show no elements.
52
- * Ignored when `json` is enabled, colors are disabled, and `compact` is set to true as it produces a parseable JSON output.
55
+ * When `json` is enabled, colors are disabled, and `compact` is set to true, only applies to Map, Set and Error values, which are inspected; everything else is serialized as JSON.
53
56
  * @default 100
54
57
  */
55
58
  maxArrayLength?: number;
56
59
  /**
57
60
  * Specifies the maximum number of characters to include when formatting.
58
61
  * Set to null or Infinity to show all elements. Set to 0 or negative to show no characters.
59
- * Ignored when `json` is enabled, colors are disabled, and `compact` is set to true as it produces a parseable JSON output.
62
+ * When `json` is enabled, colors are disabled, and `compact` is set to true, only applies to Map, Set and Error values, which are inspected; everything else is serialized as JSON.
60
63
  * @default 10000.
61
64
  */
62
65
  maxStringLength?: number;
63
66
  /**
64
67
  * If enabled, will sort keys while formatting objects.
65
68
  * Can also be a custom sorting function.
66
- * Ignored when `json` is enabled, colors are disabled, and `compact` is set to true as it produces a parseable JSON output.
69
+ * When `json` is enabled, colors are disabled, and `compact` is set to true, only applies to Map, Set and Error values, which are inspected; everything else is serialized as JSON.
67
70
  * @default false
68
71
  */
69
72
  sorted?: boolean | ((a: string, b: string) => number);
70
73
  /**
71
74
  * Specifies the number of times to recurse while formatting object.
72
75
  * This is useful for inspecting large objects. To recurse up to the maximum call stack size pass Infinity or null.
73
- * Ignored when `json` is enabled, colors are disabled, and `compact` is set to true as it produces a parseable JSON output.
76
+ * When `json` is enabled, colors are disabled, and `compact` is set to true, only applies to Map, Set and Error values, which are inspected; everything else is serialized as JSON.
74
77
  * @default 5
75
78
  */
76
79
  depth?: number;
@@ -83,7 +86,7 @@ export interface ConsoleLoggerOptions {
83
86
  /**
84
87
  * The length at which input values are split across multiple lines. Set to Infinity to format the input as a single line (in combination with "compact" set to true).
85
88
  * Default Infinity when "compact" is true, 80 otherwise.
86
- * Ignored when `json` is enabled, colors are disabled, and `compact` is set to true as it produces a parseable JSON output.
89
+ * When `json` is enabled, colors are disabled, and `compact` is set to true, only applies to Map, Set and Error values, which are inspected; everything else is serialized as JSON.
87
90
  */
88
91
  breakLength?: number;
89
92
  /**
@@ -276,7 +279,8 @@ export declare class ConsoleLogger implements LoggerService {
276
279
  protected getInspectOptions(): InspectOptions;
277
280
  /**
278
281
  * Serializes a JSON log object without ever throwing: circular references
279
- * are replaced with "[Circular]", and a value that cannot be serialized
282
+ * are replaced with "[Circular]", whatever comes past `MAX_JSON_LENGTH`
283
+ * characters with "[Truncated]", and a value that cannot be serialized
280
284
  * (e.g. a throwing `toJSON()`) makes the whole record fall back to `inspect`.
281
285
  */
282
286
  protected stringifyJsonLogObject(logObject: Record<string, unknown>): string;
@@ -292,19 +296,19 @@ export declare class ConsoleLogger implements LoggerService {
292
296
  };
293
297
  protected getContextAndStackAndMessagesToPrint(args: unknown[]): {
294
298
  messages: unknown[];
295
- stack: string;
296
299
  context: string | undefined;
297
300
  params?: undefined;
301
+ stack?: undefined;
298
302
  } | {
299
303
  messages: unknown[];
300
304
  context: string | undefined;
301
- params?: undefined;
305
+ params: any;
302
306
  stack?: undefined;
303
307
  } | {
304
308
  messages: unknown[];
309
+ stack: string;
305
310
  context: string | undefined;
306
- params: any;
307
- stack?: undefined;
311
+ params?: undefined;
308
312
  } | {
309
313
  stack: string | undefined;
310
314
  messages: unknown[];
@@ -13,6 +13,13 @@ const DEFAULT_DEPTH = 5;
13
13
  * serialized in JSON mode.
14
14
  */
15
15
  const MAX_ERROR_DEPTH = 5;
16
+ /**
17
+ * The approximate number of characters up to which a record is serialized in
18
+ * JSON mode. An object is written out once per reference to it, so a small
19
+ * value can expand to a huge record; past this length, objects and strings
20
+ * are replaced with "[Truncated]".
21
+ */
22
+ const MAX_JSON_LENGTH = 1_000_000;
16
23
  const DEFAULT_LOG_LEVELS = [
17
24
  'log',
18
25
  'error',
@@ -466,16 +473,32 @@ let ConsoleLogger = class ConsoleLogger {
466
473
  }
467
474
  /**
468
475
  * Serializes a JSON log object without ever throwing: circular references
469
- * are replaced with "[Circular]", and a value that cannot be serialized
476
+ * are replaced with "[Circular]", whatever comes past `MAX_JSON_LENGTH`
477
+ * characters with "[Truncated]", and a value that cannot be serialized
470
478
  * (e.g. a throwing `toJSON()`) makes the whole record fall back to `inspect`.
471
479
  */
472
480
  stringifyJsonLogObject(logObject) {
473
- const replacer = this.stringifyReplacer;
481
+ const replacer = this.stringifyReplacer.bind(this);
474
482
  // Objects on the path from the root to the value being serialized.
475
483
  const ancestors = [];
484
+ // Approximate length of the output so far (array indices aren't written).
485
+ let length = 0;
476
486
  try {
477
487
  return JSON.stringify(logObject, function (key, value) {
478
- value = replacer.call(this, key, value);
488
+ value = replacer(key, value);
489
+ length +=
490
+ (Array.isArray(this) ? 0 : key.length) +
491
+ (isString(value) || typeof value === 'number'
492
+ ? String(value).length
493
+ : 1);
494
+ if (length > MAX_JSON_LENGTH) {
495
+ // A primitive in place of an object is not descended into, so the
496
+ // walk stops here on every branch past the limit.
497
+ return isString(value) ||
498
+ (typeof value === 'object' && value !== null)
499
+ ? '[Truncated]'
500
+ : value;
501
+ }
479
502
  if (typeof value !== 'object' || value === null) {
480
503
  return value;
481
504
  }
@@ -550,8 +573,11 @@ let ConsoleLogger = class ConsoleLogger {
550
573
  return { messages, context, params };
551
574
  }
552
575
  getContextAndStackAndMessagesToPrint(args) {
576
+ if (args.length === 1) {
577
+ return { ...this.getContextAndMessagesToPrint(args) };
578
+ }
553
579
  if (args.length === 2) {
554
- if (this.isStackFormat(args[1])) {
580
+ if (this.isStackFormat(args[1]) || isUndefined(args[1])) {
555
581
  return {
556
582
  messages: [args[0]],
557
583
  stack: args[1],
@@ -591,7 +617,9 @@ let ConsoleLogger = class ConsoleLogger {
591
617
  if (!isString(stack) && !isUndefined(stack)) {
592
618
  return false;
593
619
  }
594
- return /^(.)+\n\s+at .+:\d+:\d+/.test(stack);
620
+ // `[ \t]+` rather than `\s+`, which also matches "\n" and would make this
621
+ // unanchored search backtrack quadratically on long runs of newlines.
622
+ return /\n[ \t]+at .+:\d+:\d+/.test(stack);
595
623
  }
596
624
  getColorByLogLevel(level) {
597
625
  switch (level) {
@@ -18,9 +18,11 @@ export type Redactor = (value: unknown) => unknown;
18
18
  * `AggregateError#errors`), `Map`s (string keys) and `Set`s are traversed.
19
19
  * Only the objects on the way to a redacted property are copied; the rest is
20
20
  * returned as is, and the input is never mutated. A circular reference is
21
- * replaced with `"[Circular]"` (so the objects on the cycle are copied too). If
22
- * the value can't be traversed (for example, a getter throws), the whole value
23
- * is replaced with the censor.
21
+ * replaced with `"[Circular]"` (so the objects on the cycle are copied too). An
22
+ * object that is referenced several times is traversed (and copied) once for
23
+ * all the references a dotted path can't tell apart. If the value can't be
24
+ * traversed (for example, a getter throws), the whole value is replaced with
25
+ * the censor.
24
26
  *
25
27
  * @returns `undefined` when there is nothing to redact.
26
28
  */
@@ -14,9 +14,11 @@ const CIRCULAR = '[Circular]';
14
14
  * `AggregateError#errors`), `Map`s (string keys) and `Set`s are traversed.
15
15
  * Only the objects on the way to a redacted property are copied; the rest is
16
16
  * returned as is, and the input is never mutated. A circular reference is
17
- * replaced with `"[Circular]"` (so the objects on the cycle are copied too). If
18
- * the value can't be traversed (for example, a getter throws), the whole value
19
- * is replaced with the censor.
17
+ * replaced with `"[Circular]"` (so the objects on the cycle are copied too). An
18
+ * object that is referenced several times is traversed (and copied) once for
19
+ * all the references a dotted path can't tell apart. If the value can't be
20
+ * traversed (for example, a getter throws), the whole value is replaced with
21
+ * the censor.
20
22
  *
21
23
  * @returns `undefined` when there is nothing to redact.
22
24
  */
@@ -38,11 +40,18 @@ export function createRedactor(paths, censor = DEFAULT_REDACT_CENSOR) {
38
40
  if (keys.size === 0 && dottedPaths.length === 0) {
39
41
  return undefined;
40
42
  }
43
+ // How many of the keys leading to an object the dotted paths can look at.
44
+ const contextLength = Math.max(0, ...dottedPaths.map(segments => segments.length - 1));
41
45
  return (value) => {
42
46
  // Lower-cased keys from the root to the value being visited.
43
47
  const path = [];
44
48
  // Objects on that path, to detect circular references.
45
49
  const ancestors = [];
50
+ // What each visited object was replaced with, by the last keys leading to
51
+ // it (a dotted path can match under some keys and not under others).
52
+ // Otherwise, an object referenced many times would be traversed, and
53
+ // copied, once per reference.
54
+ const visited = new Map();
46
55
  const isRedacted = (key) => {
47
56
  if (keys.has(key)) {
48
57
  return true;
@@ -82,6 +91,15 @@ export function createRedactor(paths, censor = DEFAULT_REDACT_CENSOR) {
82
91
  if (ancestors.includes(current)) {
83
92
  return CIRCULAR;
84
93
  }
94
+ const context = contextLength > 0 ? JSON.stringify(path.slice(-contextLength)) : '';
95
+ let results = visited.get(context);
96
+ if (!results) {
97
+ results = new Map();
98
+ visited.set(context, results);
99
+ }
100
+ if (results.has(current)) {
101
+ return results.get(current);
102
+ }
85
103
  let redacted;
86
104
  ancestors.push(current);
87
105
  if (Array.isArray(current)) {
@@ -103,6 +121,7 @@ export function createRedactor(paths, censor = DEFAULT_REDACT_CENSOR) {
103
121
  redacted = redactObject(current);
104
122
  }
105
123
  ancestors.pop();
124
+ results.set(current, redacted);
106
125
  return redacted;
107
126
  };
108
127
  const redactArray = (array) => {
@@ -17,7 +17,11 @@ export function stripProtoKeys(value) {
17
17
  return;
18
18
  }
19
19
  if (Array.isArray(value)) {
20
- for (const v of value) {
20
+ // Only the items that are present: the length of a sparse array (e.g.
21
+ // one built from a multipart field named `tags[4294967294]`) can be far
22
+ // beyond its number of items, and a loop over every index would block
23
+ // the event loop for as long. `Object.values()` skips the holes.
24
+ for (const v of Object.values(value)) {
21
25
  stripProtoKeys(v);
22
26
  }
23
27
  return;
@@ -25,9 +29,9 @@ export function stripProtoKeys(value) {
25
29
  // Delete dangerous prototype pollution keys
26
30
  delete value.__proto__;
27
31
  delete value.prototype;
28
- // Only delete constructor if it's NOT a built-in type
29
- const constructorType = value?.constructor;
30
- if (constructorType && !BUILT_IN_TYPES.includes(constructorType)) {
32
+ // Only delete constructor if it's NOT a built-in type, whatever its value:
33
+ // a falsy one (null, 0, false, "") shadows the prototype's all the same
34
+ if (!BUILT_IN_TYPES.includes(value.constructor)) {
31
35
  delete value.constructor;
32
36
  }
33
37
  for (const key in value) {