@nestjs/platform-fastify 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.
@@ -67,12 +67,15 @@ export declare class FastifyAdapter<TServer extends RawServerBase = RawServerDef
67
67
  private readonly registeredContentTypes;
68
68
  private onRequestHook?;
69
69
  private onResponseHook?;
70
+ private securityHook?;
70
71
  private isMiddieRegistered;
71
72
  private multipartMode;
72
73
  private multipartOptions;
73
74
  private isMultipartRequested;
74
75
  private pendingMiddlewares;
76
+ private readonly middieContexts;
75
77
  private versioningOptions?;
78
+ private readonly versionExtractorErrors;
76
79
  private readonly versionConstraint;
77
80
  get isParserRegistered(): boolean;
78
81
  constructor(instanceOrOptions?: TInstance | FastifyHttp2Options<any> | FastifyHttp2SecureOptions<any> | FastifyHttpsOptions<any> | FastifyHttpOptions<any> | FastifyAdapterBaseOptions<TServer>);
@@ -132,9 +135,9 @@ export declare class FastifyAdapter<TServer extends RawServerBase = RawServerDef
132
135
  useStaticAssets(options: FastifyStaticOptions): any;
133
136
  setViewEngine(options: FastifyViewOptions | string): any;
134
137
  isHeadersSent(response: TRawResponse | TReply): boolean;
135
- getHeader(response: any, name: string): any;
136
- setHeader(response: TReply, name: string, value: string): FastifyReply<RouteGenericInterface, TServer, TRawRequest, TRawResponse, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
137
- appendHeader(response: any, name: string, value: string): any;
138
+ getHeader(response: TRawResponse | TReply, name: string): string | number | string[] | undefined;
139
+ setHeader(response: TRawResponse | TReply, name: string, value: string): void | http.ServerResponse<http.IncomingMessage> | FastifyReply<RouteGenericInterface, TServer, TRawRequest, TRawResponse, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
140
+ appendHeader(response: TRawResponse | TReply, name: string, value: string): void | http.ServerResponse<http.IncomingMessage> | FastifyReply<RouteGenericInterface, TServer, TRawRequest, TRawResponse, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
138
141
  getRequestHostname(request: TRequest): string;
139
142
  getRequestMethod(request: TRequest): string;
140
143
  getRequestUrl(request: TRequest): string;
@@ -142,21 +145,27 @@ export declare class FastifyAdapter<TServer extends RawServerBase = RawServerDef
142
145
  enableCors(options?: FastifyCorsOptions): void;
143
146
  /**
144
147
  * Runs the request hook of the built-in HTTP security features in an
145
- * `onRequest` hook: before middie (Nest middleware), content-type parsing,
146
- * guards and handlers, and also for unmatched routes. Headers set by the
147
- * hook go to the Node.js response (`reply.raw`): Fastify merges them into
148
- * every response it sends, errors and `404`s included, while
148
+ * `onRequest` hook added by the constructor: before middie (Nest middleware
149
+ * and `app.use()` middleware, whenever they are registered), content-type
150
+ * parsing, guards and handlers, and also for unmatched routes. Headers set
151
+ * by the hook go to the Node.js response (`reply.raw`): Fastify merges them
152
+ * into every response it sends, errors and `404`s included, while
149
153
  * `reply.header()` / `@Header()` values take precedence, and responses
150
- * written to `reply.raw` directly (e.g. `@Sse()`) carry them too. A
151
- * rejection goes to `done(error)`, i.e. to the Nest exception layer
154
+ * written to `reply.raw` directly (e.g. `@Sse()`, or middleware) carry them
155
+ * too. A rejection goes to `done(error)`, i.e. to the Nest exception layer
152
156
  * installed with `setErrorHandler()`.
153
157
  */
154
158
  registerSecurityHook(hook: SecurityRequestHook<TRequest>): void;
155
159
  registerParserMiddleware(prefix?: string, rawBody?: boolean): void;
156
160
  useBodyParser(type: string | string[] | RegExp, rawBody: boolean, options?: NestFastifyBodyParserOptions, parser?: FastifyBodyParser<Buffer, TServer>): void;
157
161
  createMiddlewareFactory(requestMethod: RequestMethod): Promise<(path: string, callback: Function) => any>;
162
+ private runMiddleware;
158
163
  getType(): string;
159
164
  isRouteOrderSensitive(): boolean;
165
+ getRouteMatchingOptions(): {
166
+ caseSensitive: boolean;
167
+ ignoreTrailingSlash: boolean;
168
+ };
160
169
  use(...args: any[]): any;
161
170
  mapException(error: unknown): unknown;
162
171
  private isHttpFastifyError;
@@ -205,6 +214,24 @@ export declare class FastifyAdapter<TServer extends RawServerBase = RawServerDef
205
214
  * and leaves any other request target untouched.
206
215
  */
207
216
  private getPathFromRequestTarget;
217
+ /**
218
+ * Whether the request target is in origin-form ("/users") or in the
219
+ * absolute-form of an "http(s)" URL ("http://host/users"), the only forms
220
+ * middleware paths can match.
221
+ *
222
+ * Node.js accepts other targets too (e.g. "GET *users HTTP/1.1").
223
+ * "find-my-way" routes those as if their first character were the leading
224
+ * "/" ("*users" is served by the "/users" route), while no middleware path
225
+ * matches them, so the route would run without its middleware. They are
226
+ * answered by the not-found handler instead, as with the ExpressAdapter,
227
+ * whose router never matches a path that does not start with "/".
228
+ *
229
+ * That includes the asterisk-form "OPTIONS *" (RFC 9112, section 3.2.4),
230
+ * which asks about the server as a whole rather than about a resource:
231
+ * Nest has no handler for it, and "find-my-way" would serve it with the
232
+ * "OPTIONS /" route.
233
+ */
234
+ private isRoutableRequestTarget;
208
235
  private trackOpenConnections;
209
236
  private closeOpenConnections;
210
237
  }
@@ -2,10 +2,12 @@ import { HttpException, HttpStatus, Logger, StreamableFile, VERSION_NEUTRAL, Ver
2
2
  import { fastify, } from 'fastify';
3
3
  import Reply from 'fastify/lib/reply.js';
4
4
  import fastifySymbols from 'fastify/lib/symbols.js';
5
+ import * as http from 'http';
5
6
  import * as net from 'net';
6
7
  import { pathToRegexp } from 'path-to-regexp';
8
+ import { Readable } from 'stream';
7
9
  import middie from '@fastify/middie';
8
- import { loadPackage, tryLoadPackage, isNil, isString, isUndefined, } from '@nestjs/common/internal';
10
+ import { loadPackage, tryLoadPackage, isFunction, isNil, isString, isUndefined, } from '@nestjs/common/internal';
9
11
  import { AbstractHttpAdapter } from '@nestjs/core';
10
12
  import { LegacyRouteConverter } from '@nestjs/core/internal';
11
13
  import { getMediaTypeVersion } from './utils/get-media-type-version.util.js';
@@ -45,12 +47,18 @@ export class FastifyAdapter extends AbstractHttpAdapter {
45
47
  registeredContentTypes = new Set();
46
48
  onRequestHook;
47
49
  onResponseHook;
50
+ securityHook;
48
51
  isMiddieRegistered;
49
52
  multipartMode = 'auto';
50
53
  multipartOptions = {};
51
54
  isMultipartRequested = false;
52
55
  pendingMiddlewares = [];
56
+ // The encapsulated contexts created since middie was registered (see
57
+ // `registerMiddie()`)
58
+ middieContexts = [];
53
59
  versioningOptions;
60
+ // Exceptions thrown by the extractor of custom versioning, by raw request
61
+ versionExtractorErrors = new WeakMap();
54
62
  versionConstraint = {
55
63
  name: 'version',
56
64
  validate(value) {
@@ -108,7 +116,16 @@ export class FastifyAdapter extends AbstractHttpAdapter {
108
116
  }
109
117
  // Custom Versioning Handler
110
118
  else if (this.versioningOptions?.type === VersioningType.CUSTOM) {
111
- return this.versioningOptions.extractor(req);
119
+ try {
120
+ return this.versioningOptions.extractor(req);
121
+ }
122
+ catch (err) {
123
+ // Fastify routes the request, and so calls the extractor, before
124
+ // anything could catch the exception. It is passed to the error
125
+ // handler from an `onRequest` hook instead (see the constructor).
126
+ this.versionExtractorErrors.set(req, err);
127
+ return undefined;
128
+ }
112
129
  }
113
130
  return undefined;
114
131
  },
@@ -122,6 +139,20 @@ export class FastifyAdapter extends AbstractHttpAdapter {
122
139
  const instance = instanceOrOptions && instanceOrOptions.server
123
140
  ? instanceOrOptions
124
141
  : fastify({
142
+ // The router answers a malformed URL (400) or an over-long route
143
+ // parameter (414) before any hook runs, so the security hook (see
144
+ // `registerSecurityHook()`) runs here, for its headers.
145
+ frameworkErrors: (error, request, reply) => {
146
+ try {
147
+ this.securityHook?.(request, reply.raw);
148
+ }
149
+ catch {
150
+ // The request is rejected either way, and a throw here would
151
+ // escape the router. `request.params` and `request.query`
152
+ // are null, so a CSRF `exclude` predicate may throw.
153
+ }
154
+ reply.send(error);
155
+ },
125
156
  ...instanceOrOptions,
126
157
  routerOptions: {
127
158
  ...this.getTopLevelRouterOptions(instanceOrOptions),
@@ -176,6 +207,46 @@ export class FastifyAdapter extends AbstractHttpAdapter {
176
207
  done();
177
208
  }
178
209
  });
210
+ // The request hook of `app.enableCsrfProtection()` and
211
+ // `app.useSecurityHeaders()` (see `registerSecurityHook()`). Added here
212
+ // for the same reason as the hook above: it must run before Nest
213
+ // middleware, and `NestFactory.create()` registers middie before either
214
+ // method can be called. After the `503` hook, as on Express.
215
+ this.instance.addHook('onRequest', (request, reply, done) => {
216
+ if (this.securityHook) {
217
+ done(this.securityHook(request, reply.raw));
218
+ }
219
+ else {
220
+ done();
221
+ }
222
+ });
223
+ // Passes requests whose target middleware cannot be matched against to
224
+ // the not-found handler (see `isRoutableRequestTarget()`). Added here,
225
+ // like the hooks above, so that it runs before Nest middleware, and
226
+ // after the security hook, so that the `404` carries its headers, as on
227
+ // Express. A request the router did not match is already on its way to
228
+ // the not-found handler.
229
+ this.instance.addHook('onRequest', (request, reply, done) => {
230
+ if (request.is404 || this.isRoutableRequestTarget(request.raw.url)) {
231
+ done();
232
+ }
233
+ else {
234
+ reply.callNotFound();
235
+ }
236
+ });
237
+ // Passes an exception thrown by the extractor of custom versioning while
238
+ // Fastify routed the request (see `versionConstraint`) to the error
239
+ // handler, for matched and unmatched routes alike. Added here, like the
240
+ // hooks above, so that it runs before Nest middleware.
241
+ this.instance.addHook('onRequest', (request, _reply, done) => {
242
+ if (!this.versionExtractorErrors.has(request.raw)) {
243
+ done();
244
+ return;
245
+ }
246
+ const err = this.versionExtractorErrors.get(request.raw);
247
+ this.versionExtractorErrors.delete(request.raw);
248
+ done(err || new fastify.errorCodes.FST_ERR_SEND_UNDEFINED_ERR());
249
+ });
179
250
  this.instance.addHook('onResponse', (request, reply, done) => {
180
251
  if (this.onResponseHook) {
181
252
  this.runLifecycleHook(this.onResponseHook, request, reply, done);
@@ -317,6 +388,11 @@ export class FastifyAdapter extends AbstractHttpAdapter {
317
388
  return this.injectRouteOptions('UNLOCK', ...args);
318
389
  }
319
390
  all(...args) {
391
+ // Routes registered by Nest ("path, handler") get the route options
392
+ // (e.g. the version), with the same methods as "fastify.all()".
393
+ if (args.length === 2 && isFunction(args[1])) {
394
+ return this.injectRouteOptions(this.instance.supportedMethods, ...args);
395
+ }
320
396
  const handlerRef = args[args.length - 1];
321
397
  args[args.length - 1] = this.requireNonEmptyParams(args[0], handlerRef);
322
398
  return this.instance.all(...args);
@@ -345,6 +421,12 @@ export class FastifyAdapter extends AbstractHttpAdapter {
345
421
  fastifyReply.status(statusCode);
346
422
  }
347
423
  if (body instanceof StreamableFile) {
424
+ const stream = body.getStream();
425
+ // Once the reply was sent (e.g. by `handlerTimeout`), send() drops the
426
+ // stream without consuming or destroying it.
427
+ if (fastifyReply.sent && isFunction(stream?.destroy)) {
428
+ stream.destroy();
429
+ }
348
430
  const streamHeaders = body.getHeaders();
349
431
  if (fastifyReply.getHeader('Content-Type') === undefined &&
350
432
  streamHeaders.type !== undefined) {
@@ -358,7 +440,29 @@ export class FastifyAdapter extends AbstractHttpAdapter {
358
440
  streamHeaders.length !== undefined) {
359
441
  fastifyReply.header('Content-Length', streamHeaders.length);
360
442
  }
361
- body = body.getStream();
443
+ body = stream;
444
+ // Fastify's HEAD route handler resumes a stream payload, and a route
445
+ // declared for HEAD (or a raw response) pipes it into a response that
446
+ // discards it, so the whole source would be read for nothing. Fastify
447
+ // gets an empty stream instead, as an empty body would reset
448
+ // Content-Length to 0.
449
+ if (fastifyReply.raw?.req?.method === 'HEAD' &&
450
+ isFunction(stream?.destroy)) {
451
+ stream.destroy();
452
+ body = Readable.from([]);
453
+ }
454
+ }
455
+ // Fastify passes an Error payload to the error handler instead of
456
+ // sending it, and the reply around a raw response (e.g. the one
457
+ // middleware gets) has none. An Error body sent with an error status,
458
+ // as for `new UnauthorizedException(err)`, gets the standard body of the
459
+ // status instead, without the properties of the Error.
460
+ if (body instanceof Error &&
461
+ fastifyReply.statusCode >= HttpStatus.BAD_REQUEST) {
462
+ body = {
463
+ statusCode: fastifyReply.statusCode,
464
+ message: http.STATUS_CODES[fastifyReply.statusCode] ?? 'Error',
465
+ };
362
466
  }
363
467
  const responseContentType = fastifyReply.getHeader('Content-Type');
364
468
  if (typeof responseContentType === 'string' &&
@@ -511,20 +615,40 @@ export class FastifyAdapter extends AbstractHttpAdapter {
511
615
  return response.getHeader(name);
512
616
  }
513
617
  setHeader(response, name, value) {
514
- return response.header(name, value);
618
+ if (this.isNativeResponse(response)) {
619
+ return response.setHeader(name, value);
620
+ }
621
+ const reply = response;
622
+ return reply.header(name, value);
515
623
  }
516
624
  appendHeader(response, name, value) {
517
- const current = response.getHeader(name);
518
- if (current === undefined) {
519
- return response.header(name, value);
625
+ if (this.isNativeResponse(response)) {
626
+ const current = response.getHeader(name);
627
+ return response.setHeader(name, current === undefined ? value : [current, value].flat().map(String));
520
628
  }
521
- // Fastify Reply.header() already concatenates set-cookie.
522
- // Re-passing the accumulated list would duplicate previous cookies.
629
+ const reply = response;
630
+ // A set-cookie value can live on the reply or only on the raw response
631
+ // (e.g., set by a middleware). Reply.getHeader() reads both, and the
632
+ // merged list is written to both: reply.send() sends the reply headers,
633
+ // while a hijacked reply or a handler that ends reply.raw sends the raw
634
+ // ones.
523
635
  if (String(name).toLowerCase() === 'set-cookie') {
524
- return response.header(name, value);
636
+ const current = reply.getHeader(name);
637
+ const values = current === undefined ? [value] : [current, value].flat().map(String);
638
+ reply.removeHeader(name);
639
+ reply.header(name, values);
640
+ if (!reply.raw.headersSent) {
641
+ // A copy, so a later reply.header() call does not change the raw header
642
+ reply.raw.setHeader(name, [...values]);
643
+ }
644
+ return reply;
645
+ }
646
+ const current = reply.getHeader(name);
647
+ if (current === undefined) {
648
+ return reply.header(name, value);
525
649
  }
526
650
  const values = Array.isArray(current) ? current : [current];
527
- return response.header(name, values.concat(value));
651
+ return reply.header(name, values.concat(value));
528
652
  }
529
653
  getRequestHostname(request) {
530
654
  return request.hostname;
@@ -540,19 +664,18 @@ export class FastifyAdapter extends AbstractHttpAdapter {
540
664
  }
541
665
  /**
542
666
  * Runs the request hook of the built-in HTTP security features in an
543
- * `onRequest` hook: before middie (Nest middleware), content-type parsing,
544
- * guards and handlers, and also for unmatched routes. Headers set by the
545
- * hook go to the Node.js response (`reply.raw`): Fastify merges them into
546
- * every response it sends, errors and `404`s included, while
667
+ * `onRequest` hook added by the constructor: before middie (Nest middleware
668
+ * and `app.use()` middleware, whenever they are registered), content-type
669
+ * parsing, guards and handlers, and also for unmatched routes. Headers set
670
+ * by the hook go to the Node.js response (`reply.raw`): Fastify merges them
671
+ * into every response it sends, errors and `404`s included, while
547
672
  * `reply.header()` / `@Header()` values take precedence, and responses
548
- * written to `reply.raw` directly (e.g. `@Sse()`) carry them too. A
549
- * rejection goes to `done(error)`, i.e. to the Nest exception layer
673
+ * written to `reply.raw` directly (e.g. `@Sse()`, or middleware) carry them
674
+ * too. A rejection goes to `done(error)`, i.e. to the Nest exception layer
550
675
  * installed with `setErrorHandler()`.
551
676
  */
552
677
  registerSecurityHook(hook) {
553
- this.instance.addHook('onRequest', (request, reply, done) => {
554
- done(hook(request, reply.raw));
555
- });
678
+ this.securityHook = hook;
556
679
  }
557
680
  registerParserMiddleware(prefix, rawBody) {
558
681
  if (this._isParserRegistered) {
@@ -616,9 +739,13 @@ export class FastifyAdapter extends AbstractHttpAdapter {
616
739
  re = hasEndOfStringCharacter
617
740
  ? new RegExp(re.source + '$', re.flags)
618
741
  : re;
619
- // The following type assertion is valid as we use import('@fastify/middie') rather than require('@fastify/middie')
620
- // ref https://github.com/fastify/middie/pull/55
621
- this.instance.use(normalizedPath, (req, res, next) => {
742
+ // A case-insensitive router lower-cases the decoded path, where some
743
+ // characters only fold to ASCII ("%E2%84%AA", KELVIN SIGN, is routed
744
+ // as "k"). The RegExp of @fastify/middie does not fold them, so on
745
+ // such a router the middleware is registered for every path and the
746
+ // path is only matched below.
747
+ const { caseSensitive } = this.getRouteMatchingOptions();
748
+ const createMiddleware = (stripsPath) => (req, res, next) => {
622
749
  const queryParamsIndex = req.originalUrl.indexOf('?');
623
750
  let pathname = queryParamsIndex >= 0
624
751
  ? req.originalUrl.slice(0, queryParamsIndex)
@@ -631,9 +758,28 @@ export class FastifyAdapter extends AbstractHttpAdapter {
631
758
  if (!re.exec(pathToCheck)) {
632
759
  return next();
633
760
  }
761
+ if (stripsPath) {
762
+ // Strips the path from "req.url", as @fastify/middie does
763
+ // when it matches the path itself
764
+ const suffixIndex = req.originalUrl.search(/[?#]/);
765
+ req.url =
766
+ suffixIndex >= 0
767
+ ? `/${req.originalUrl.slice(suffixIndex)}`
768
+ : '/';
769
+ }
634
770
  }
635
- return callback(req, res, next);
636
- });
771
+ this.runMiddleware(callback, req, res, next);
772
+ };
773
+ // The following type assertion is valid as we use import('@fastify/middie') rather than require('@fastify/middie')
774
+ // ref https://github.com/fastify/middie/pull/55
775
+ this.instance.use(caseSensitive ? normalizedPath : '/', createMiddleware(!caseSensitive));
776
+ // The contexts created since middie was registered have a copy of the
777
+ // middleware of their parent without this one (see `registerMiddie()`).
778
+ // It is registered there without its path, which middie would prefix
779
+ // with the one of the context.
780
+ for (const context of this.middieContexts) {
781
+ context.use(createMiddleware(true));
782
+ }
637
783
  }
638
784
  catch (e) {
639
785
  if (e instanceof TypeError) {
@@ -643,12 +789,53 @@ export class FastifyAdapter extends AbstractHttpAdapter {
643
789
  }
644
790
  };
645
791
  }
792
+ runMiddleware(middleware, req, res, next) {
793
+ let completed = false;
794
+ const nextOnce = (err) => {
795
+ if (completed)
796
+ return;
797
+ completed = true;
798
+ next(err);
799
+ };
800
+ try {
801
+ // middie discards the return value, so the promise is observed here:
802
+ // the proxies of `RouterProxy` reject when the exception layer throws.
803
+ // Its resolution is not passed to `next`, as a middleware that answers
804
+ // the request does not call it.
805
+ const result = middleware(req, res, nextOnce);
806
+ if (result && typeof result.then === 'function') {
807
+ result.then(undefined, (err) => {
808
+ if (completed) {
809
+ this.logger.error(err);
810
+ return;
811
+ }
812
+ nextOnce(err || new fastify.errorCodes.FST_ERR_SEND_UNDEFINED_ERR());
813
+ });
814
+ }
815
+ }
816
+ catch (err) {
817
+ nextOnce(err);
818
+ }
819
+ }
646
820
  getType() {
647
821
  return 'fastify';
648
822
  }
649
823
  isRouteOrderSensitive() {
650
824
  return false;
651
825
  }
826
+ // The return type is spelled out (rather than "RouteMatchingOptions") so
827
+ // that the declarations still compile against an older "@nestjs/common"
828
+ getRouteMatchingOptions() {
829
+ const initialConfig = this.instance.initialConfig;
830
+ // Absent when a Fastify instance created without it is passed in
831
+ const routerOptions = (initialConfig.routerOptions ??
832
+ {});
833
+ return {
834
+ caseSensitive: !(routerOptions.caseSensitive === false ||
835
+ initialConfig.caseSensitive === false),
836
+ ignoreTrailingSlash: !!(routerOptions.ignoreTrailingSlash || initialConfig.ignoreTrailingSlash),
837
+ };
838
+ }
652
839
  use(...args) {
653
840
  // Fastify requires @fastify/middie plugin to be registered before middleware can be used.
654
841
  // If middie is not registered yet, we queue the middleware and register it later during init.
@@ -732,6 +919,15 @@ export class FastifyAdapter extends AbstractHttpAdapter {
732
919
  async registerMiddie() {
733
920
  this.isMiddieRegistered = true;
734
921
  await this.register(middie);
922
+ // middie gives each encapsulated context created from now on a copy of
923
+ // the middleware of its parent, as it is at that time. As
924
+ // `NestFactory.create()` registers middie before `main.ts` runs, a
925
+ // plugin registered there with an awaited `register()` gets a context
926
+ // without the Nest middleware `app.init()` registers later, so
927
+ // `createMiddlewareFactory()` adds it to the contexts tracked here.
928
+ this.instance.addHook('onRegister', instance => {
929
+ this.middieContexts.push(instance);
930
+ });
735
931
  }
736
932
  getRequestOriginalUrl(rawRequest) {
737
933
  return rawRequest.originalUrl || rawRequest.url;
@@ -751,7 +947,8 @@ export class FastifyAdapter extends AbstractHttpAdapter {
751
947
  url: args[0],
752
948
  handler: this.requireNonEmptyParams(args[0], handlerRef),
753
949
  };
754
- if (!this.instance.supportedMethods.includes(routerMethodKey)) {
950
+ if (isString(routerMethodKey) &&
951
+ !this.instance.supportedMethods.includes(routerMethodKey)) {
755
952
  this.instance.addHttpMethod(routerMethodKey, { hasBody: true });
756
953
  }
757
954
  if (isVersioned || hasConstraints || hasConfig || hasSchema) {
@@ -837,7 +1034,8 @@ export class FastifyAdapter extends AbstractHttpAdapter {
837
1034
  }
838
1035
  sanitizeUrl(url) {
839
1036
  const initialConfig = this.instance.initialConfig;
840
- const routerOptions = initialConfig.routerOptions;
1037
+ const routerOptions = (initialConfig.routerOptions ??
1038
+ {});
841
1039
  // Absolute-form request targets ("GET http://host/path HTTP/1.1") must be
842
1040
  // resolved to their path before any other normalization, as the Fastify
843
1041
  // router does, so that middleware and routes always match the same path.
@@ -846,16 +1044,15 @@ export class FastifyAdapter extends AbstractHttpAdapter {
846
1044
  initialConfig.ignoreDuplicateSlashes) {
847
1045
  url = this.removeDuplicateSlashes(url);
848
1046
  }
849
- if (routerOptions.ignoreTrailingSlash ||
850
- initialConfig.ignoreTrailingSlash) {
1047
+ const { caseSensitive, ignoreTrailingSlash } = this.getRouteMatchingOptions();
1048
+ if (ignoreTrailingSlash) {
851
1049
  url = this.trimLastSlash(url);
852
1050
  }
853
- if (routerOptions.caseSensitive === false ||
854
- initialConfig.caseSensitive === false) {
855
- url = url.toLowerCase();
856
- }
857
- return safeDecodeURI(url, routerOptions.useSemicolonDelimiter ||
858
- initialConfig.useSemicolonDelimiter).path;
1051
+ const { path } = safeDecodeURI(url, routerOptions.useSemicolonDelimiter ||
1052
+ initialConfig.useSemicolonDelimiter);
1053
+ // Lower-cased once decoded, as the router does: "%E2%84%AA" (KELVIN
1054
+ // SIGN) only lower-cases to "k" then
1055
+ return caseSensitive ? path : path.toLowerCase();
859
1056
  }
860
1057
  removeDuplicateSlashes(path) {
861
1058
  const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g;
@@ -894,6 +1091,26 @@ export class FastifyAdapter extends AbstractHttpAdapter {
894
1091
  }
895
1092
  return pathStart === -1 ? '/' : url.slice(pathStart);
896
1093
  }
1094
+ /**
1095
+ * Whether the request target is in origin-form ("/users") or in the
1096
+ * absolute-form of an "http(s)" URL ("http://host/users"), the only forms
1097
+ * middleware paths can match.
1098
+ *
1099
+ * Node.js accepts other targets too (e.g. "GET *users HTTP/1.1").
1100
+ * "find-my-way" routes those as if their first character were the leading
1101
+ * "/" ("*users" is served by the "/users" route), while no middleware path
1102
+ * matches them, so the route would run without its middleware. They are
1103
+ * answered by the not-found handler instead, as with the ExpressAdapter,
1104
+ * whose router never matches a path that does not start with "/".
1105
+ *
1106
+ * That includes the asterisk-form "OPTIONS *" (RFC 9112, section 3.2.4),
1107
+ * which asks about the server as a whole rather than about a resource:
1108
+ * Nest has no handler for it, and "find-my-way" would serve it with the
1109
+ * "OPTIONS /" route.
1110
+ */
1111
+ isRoutableRequestTarget(url) {
1112
+ return !!url && this.getPathFromRequestTarget(url).charCodeAt(0) === 47;
1113
+ }
897
1114
  trackOpenConnections() {
898
1115
  const track = (socket) => {
899
1116
  if (this.isClosing) {
@@ -6,6 +6,10 @@
6
6
  * media range of a list (`text/html, application/json;v=2`); the first match
7
7
  * wins. Returns `undefined` when no parameter starts with the key.
8
8
  *
9
+ * Quoted parameter values are unescaped, and separators inside them are ignored.
10
+ * An unterminated quoted string runs to the end of the header value, so a
11
+ * version that follows its opening quote is not read.
12
+ *
9
13
  * Duplicated in each platform package so that a platform package never
10
14
  * depends on a newer `@nestjs/core` than the one installed.
11
15
  */
@@ -1,3 +1,29 @@
1
+ /**
2
+ * Splits a header value at separators outside quoted strings in linear time.
3
+ * Backslash-escaped characters inside quoted strings are treated as literals.
4
+ */
5
+ const splitOutsideQuotes = (value, separator) => {
6
+ const parts = [];
7
+ let start = 0;
8
+ let isQuoted = false;
9
+ const len = value.length;
10
+ for (let index = 0; index < len; index++) {
11
+ const character = value[index];
12
+ if (isQuoted && character === '\\') {
13
+ // A quoted-pair consumes the next character, including an escaped quote.
14
+ index++;
15
+ }
16
+ else if (character === '"') {
17
+ isQuoted = !isQuoted;
18
+ }
19
+ else if (character === separator && !isQuoted) {
20
+ parts.push(value.slice(start, index));
21
+ start = index + 1;
22
+ }
23
+ }
24
+ parts.push(value.slice(start));
25
+ return parts;
26
+ };
1
27
  /**
2
28
  * Returns the version carried by an `Accept` header for media type versioning,
3
29
  * e.g. `2` for `application/json;v=2` with the `v=` key.
@@ -6,19 +32,27 @@
6
32
  * media range of a list (`text/html, application/json;v=2`); the first match
7
33
  * wins. Returns `undefined` when no parameter starts with the key.
8
34
  *
35
+ * Quoted parameter values are unescaped, and separators inside them are ignored.
36
+ * An unterminated quoted string runs to the end of the header value, so a
37
+ * version that follows its opening quote is not read.
38
+ *
9
39
  * Duplicated in each platform package so that a platform package never
10
40
  * depends on a newer `@nestjs/core` than the one installed.
11
41
  */
12
42
  export function getMediaTypeVersion(acceptHeader, key) {
13
43
  const mediaRanges = []
14
44
  .concat(acceptHeader ?? [])
15
- .flatMap(value => value.split(','));
45
+ .flatMap(value => splitOutsideQuotes(value, ','));
16
46
  for (const mediaRange of mediaRanges) {
17
- const [, ...parameters] = mediaRange.split(';');
47
+ const [, ...parameters] = splitOutsideQuotes(mediaRange, ';');
18
48
  for (const parameter of parameters) {
19
49
  const trimmedParameter = parameter.trim();
20
50
  if (trimmedParameter.startsWith(key)) {
21
- return trimmedParameter.slice(key.length);
51
+ const version = trimmedParameter.slice(key.length);
52
+ if (version.startsWith('"') && version.endsWith('"')) {
53
+ return version.slice(1, -1).replace(/\\([\s\S])/g, '$1');
54
+ }
55
+ return version;
22
56
  }
23
57
  }
24
58
  }
@@ -12,11 +12,11 @@ export interface MultipartStorageEngine {
12
12
  /**
13
13
  * Same keys as platform-express's `MulterLimits`.
14
14
  *
15
- * Limits that are not set fall back to the options `@fastify/multipart` was
16
- * registered with, and then to its own defaults. Unlike multer (which
17
- * defaults `fileSize` and `parts` to `Infinity`), `@fastify/multipart` caps
18
- * `fileSize` at the Fastify instance's `bodyLimit` (1 MiB unless configured)
19
- * and `parts` at 1000.
15
+ * Limits that are not set, or set to `null` or `undefined`, fall back to the
16
+ * options `@fastify/multipart` was registered with, and then to its own
17
+ * defaults. Unlike multer (which defaults `fileSize` and `parts` to
18
+ * `Infinity`), `@fastify/multipart` caps `fileSize` at the Fastify
19
+ * instance's `bodyLimit` (1 MiB unless configured) and `parts` at 1000.
20
20
  *
21
21
  * @publicApi
22
22
  */
@@ -40,15 +40,17 @@ export interface MultipartLimits {
40
40
  /** For multipart forms, the max number of header key => value pairs to parse (Default: 2000) */
41
41
  headerPairs?: number;
42
42
  /**
43
- * Max nesting depth of field names; `a[b][c]` has 2 levels (Default:
44
- * Infinity). Like `fieldNameSize`, it does not fall back to the options
43
+ * Max nesting depth of field names; `a[b][c]` has 2 levels (Default: 32,
44
+ * where multer's own default is Infinity). Set it to `Infinity` to lift
45
+ * the limit. Like `fieldNameSize`, it does not fall back to the options
45
46
  * `@fastify/multipart` was registered with.
46
47
  */
47
48
  fieldNestingDepth?: number;
48
49
  /**
49
50
  * Max numeric array index accepted in field names; `a[3]` uses index 3
50
- * (Default: Infinity). Like `fieldNameSize`, it does not fall back to the
51
- * options `@fastify/multipart` was registered with.
51
+ * (Default: 1000, where multer's own default is Infinity). Set it to
52
+ * `Infinity` to lift the limit. Like `fieldNameSize`, it does not fall back
53
+ * to the options `@fastify/multipart` was registered with.
52
54
  */
53
55
  fieldArrayIndexLimit?: number;
54
56
  }
@@ -1,10 +1,12 @@
1
1
  import { __decorate, __metadata, __param } from "tslib";
2
2
  import { Inject, mixin, Optional, StreamableFile, } from '@nestjs/common';
3
3
  import { HttpAdapterHost } from '@nestjs/core';
4
+ import { isRejectedByPipes } from '@nestjs/core/internal';
4
5
  import { catchError, concatMap, from, throwError } from 'rxjs';
6
+ import { finished } from 'stream';
5
7
  import { MULTIPART_MODULE_OPTIONS } from '../files.constants.js';
6
8
  import { MISSING_PLUGIN_MESSAGE, PLUGIN_NOT_REGISTERED_AT_STARTUP_MESSAGE, WRONG_ADAPTER_MESSAGE, } from './multipart.constants.js';
7
- import { drainRequest, processMultipart, processStream, resolveStorage, validateLimits, } from './multipart.parser.js';
9
+ import { drainRequest, processMultipart, processStream, removeFiles, resolveStorage, validateLimits, } from './multipart.parser.js';
8
10
  import { mergeMultipartOptions } from './multipart.utils.js';
9
11
  const verifiedInstances = new WeakSet();
10
12
  /**
@@ -78,9 +80,23 @@ export function createMultipartInterceptor(spec, localOptions) {
78
80
  this.storage = resolveStorage(this.options);
79
81
  }
80
82
  async intercept(context, next) {
81
- const req = context.switchToHttp().getRequest();
82
- await processMultipart(req, spec, this.options, this.storage);
83
- return next.handle();
83
+ const ctx = context.switchToHttp();
84
+ const req = ctx.getRequest();
85
+ const stored = await processMultipart(req, spec, this.options, this.storage);
86
+ return next.handle().pipe(
87
+ // When the route's pipes reject the request (e.g. `ParseFilePipe`),
88
+ // the route handler never sees the files, and nothing else would
89
+ // remove them. They are removed once the response has been sent, as
90
+ // an exception filter may still use (or remove) them.
91
+ catchError(err => {
92
+ if (isRejectedByPipes(req)) {
93
+ const reply = ctx.getResponse();
94
+ finished(reply.raw, () => {
95
+ removeFiles(this.storage, req, stored).catch(() => undefined);
96
+ });
97
+ }
98
+ return throwError(() => err);
99
+ }));
84
100
  }
85
101
  };
86
102
  MixinInterceptor = __decorate([
@@ -18,6 +18,19 @@ export declare const multerExceptions: {
18
18
  readonly INVALID_FIELD_NAME: "Invalid field name";
19
19
  };
20
20
  export type MultipartErrorCode = keyof typeof multerExceptions;
21
+ /**
22
+ * Defaults of the field name limits that multer leaves at `Infinity`, which
23
+ * let a single small request build an arbitrarily deep `req.body`
24
+ * (`fieldNestingDepth`) or arrays of arbitrary length (`fieldArrayIndexLimit`).
25
+ * `32` is the nesting depth `body-parser` allows in the urlencoded bodies the
26
+ * ExpressAdapter parses; `1000` is the number of parameters it parses, and
27
+ * the number of parts `@fastify/multipart` accepts. Kept identical to
28
+ * platform-express's `DEFAULT_FIELD_NAME_LIMITS`.
29
+ */
30
+ export declare const DEFAULT_FIELD_NAME_LIMITS: {
31
+ readonly fieldNestingDepth: 32;
32
+ readonly fieldArrayIndexLimit: 1000;
33
+ };
21
34
  /**
22
35
  * The messages platform-express reports for busboy 1.x parser errors
23
36
  * (prefixed with "Multipart: "), which is what multer bundles.
@@ -20,6 +20,19 @@ export const multerExceptions = {
20
20
  // field names that would pollute a prototype, which multer accepts.
21
21
  INVALID_FIELD_NAME: 'Invalid field name',
22
22
  };
23
+ /**
24
+ * Defaults of the field name limits that multer leaves at `Infinity`, which
25
+ * let a single small request build an arbitrarily deep `req.body`
26
+ * (`fieldNestingDepth`) or arrays of arbitrary length (`fieldArrayIndexLimit`).
27
+ * `32` is the nesting depth `body-parser` allows in the urlencoded bodies the
28
+ * ExpressAdapter parses; `1000` is the number of parameters it parses, and
29
+ * the number of parts `@fastify/multipart` accepts. Kept identical to
30
+ * platform-express's `DEFAULT_FIELD_NAME_LIMITS`.
31
+ */
32
+ export const DEFAULT_FIELD_NAME_LIMITS = {
33
+ fieldNestingDepth: 32,
34
+ fieldArrayIndexLimit: 1000,
35
+ };
23
36
  /**
24
37
  * The messages platform-express reports for busboy 1.x parser errors
25
38
  * (prefixed with "Multipart: "), which is what multer bundles.
@@ -1,6 +1,6 @@
1
1
  import type { FastifyRequest } from 'fastify';
2
2
  import type { IncomingMessage } from 'http';
3
- import type { MultipartField, MultipartLimits, MultipartOptions, MultipartStorageEngine } from '../interfaces/index.js';
3
+ import type { MultipartField, MultipartLimits, MultipartOptions, MultipartStorageEngine, UploadedMultipartFile } from '../interfaces/index.js';
4
4
  /**
5
5
  * Where accepted files end up, named after multer's `FileAppender` strategies.
6
6
  */
@@ -12,6 +12,7 @@ export interface UploadSpec {
12
12
  }
13
13
  /** Same resolution order as multer: `storage`, then `dest`, then memory. */
14
14
  export declare function resolveStorage(options: MultipartOptions): MultipartStorageEngine;
15
+ export declare function removeFiles(storage: MultipartStorageEngine, req: FastifyRequest, files: UploadedMultipartFile[]): Promise<void[]>;
15
16
  /**
16
17
  * Stops parsing and discards the rest of the body, so that an error response
17
18
  * does not race a client that is still uploading (multer does the same
@@ -26,10 +27,11 @@ export declare function drainRequest(raw: IncomingMessage): Promise<void>;
26
27
  export declare function validateLimits(limits: MultipartLimits | undefined): void;
27
28
  /**
28
29
  * Parses the multipart body with multer's semantics and populates
29
- * `req.body` and `req.file` / `req.files`. Requests that are not multipart
30
- * are passed through untouched, as multer does.
30
+ * `req.body` and `req.file` / `req.files`, and returns the stored files.
31
+ * Requests that are not multipart are passed through untouched, as multer
32
+ * does.
31
33
  */
32
- export declare function processMultipart(request: FastifyRequest, spec: UploadSpec, options: MultipartOptions, storage: MultipartStorageEngine): Promise<void>;
34
+ export declare function processMultipart(request: FastifyRequest, spec: UploadSpec, options: MultipartOptions, storage: MultipartStorageEngine): Promise<UploadedMultipartFile[]>;
33
35
  /**
34
36
  * Parses up to and including the first file in `fieldName`, then stops and
35
37
  * hands that file's stream to the route handler (`req.file.stream`). Text
@@ -2,7 +2,7 @@ import { BadRequestException, PayloadTooLargeException } from '@nestjs/common';
2
2
  import { diskStorage } from '../storage/disk.storage.js';
3
3
  import { memoryStorage } from '../storage/memory.storage.js';
4
4
  import { appendField } from './append-field.util.js';
5
- import { busboyExceptions, MISSING_PLUGIN_MESSAGE, multerExceptions, } from './multipart.constants.js';
5
+ import { busboyExceptions, DEFAULT_FIELD_NAME_LIMITS, MISSING_PLUGIN_MESSAGE, multerExceptions, } from './multipart.constants.js';
6
6
  import { MultipartError } from './multipart.error.js';
7
7
  import { transformException } from './multipart.utils.js';
8
8
  /** Same resolution order as multer: `storage`, then `dest`, then memory. */
@@ -22,15 +22,18 @@ function assertPluginRegistered(req) {
22
22
  }
23
23
  /**
24
24
  * Per-route options handed to `req.parts()`; `@fastify/multipart` deep-merges
25
- * them over the options the plugin was registered with. `@fastify/busboy`
26
- * treats `fileSize` and `parts` as "max allowed" (it fires once a limit is
25
+ * them over the options the plugin was registered with. Limits set to `null`
26
+ * or `undefined` are left out, as the merge would copy them over the
27
+ * plugin's limits (and its `fileSize` and `parts` defaults), which
28
+ * `@fastify/busboy` then reads as no limit. `@fastify/busboy` treats
29
+ * `fileSize` and `parts` as "max allowed" (it fires once a limit is
27
30
  * exceeded), so unlike multer there is no `+1` adjustment. Truncation is
28
31
  * detected here (`file.truncated`), so the plugin's own throw is disabled.
29
32
  */
30
33
  function toBusboyConfig(limits, options) {
31
34
  const config = { throwFileSizeLimit: false };
32
35
  if (limits) {
33
- config.limits = { ...limits };
36
+ config.limits = Object.fromEntries(Object.entries(limits).filter(([, value]) => value != null));
34
37
  }
35
38
  if (options.preservePath !== undefined) {
36
39
  config.preservePath = options.preservePath;
@@ -86,9 +89,12 @@ const ABORTED = Symbol('aborted');
86
89
  * the engine (e.g. by a `pipeline()` to a file that cannot be written), which
87
90
  * reports that error itself.
88
91
  *
89
- * Whatever the engine stores after an abort is removed once it calls back,
90
- * as multer does: what it stored when it succeeds, and the file at `path`
91
- * (which engines set before writing, by multer's convention) when it fails.
92
+ * Whatever the engine stores after an abort is removed, as multer does. The
93
+ * file at `path` (which engines set before writing, by multer's convention)
94
+ * is removed right away, as an engine waiting on `pipeline()` never calls
95
+ * back for a stream destroyed after its last chunk; removing it clears
96
+ * `path`. What the engine stored is removed once it calls back: what it
97
+ * returned when it succeeds, and the file at `path` when it fails.
92
98
  */
93
99
  function storeFile(storage, req, file) {
94
100
  return new Promise((resolve, reject) => {
@@ -101,6 +107,10 @@ function storeFile(storage, req, file) {
101
107
  if (!stream.readableEnded && !stream.errored) {
102
108
  aborted = true;
103
109
  resolve(ABORTED);
110
+ const partial = file;
111
+ if (partial.path) {
112
+ removeFiles(storage, req, [partial]).catch(() => undefined);
113
+ }
104
114
  }
105
115
  };
106
116
  stream.once('close', onClose);
@@ -116,7 +126,7 @@ function storeFile(storage, req, file) {
116
126
  });
117
127
  });
118
128
  }
119
- function removeFiles(storage, req, files) {
129
+ export function removeFiles(storage, req, files) {
120
130
  return Promise.all(files.map(file => new Promise(resolve => storage._removeFile(req, file, () => resolve()))));
121
131
  }
122
132
  /**
@@ -216,14 +226,17 @@ function exceedsArrayIndexLimit(name, limit) {
216
226
  /**
217
227
  * `fieldNestingDepth` and `fieldArrayIndexLimit` are multer's own limits
218
228
  * (busboy knows neither), so they are checked here, against the decoded name.
229
+ * Unlike multer, they default to `DEFAULT_FIELD_NAME_LIMITS`.
219
230
  */
220
231
  function checkFieldNameShape(name, limits) {
221
- if (typeof limits?.fieldNestingDepth === 'number' &&
222
- name.split('[').length - 1 > limits.fieldNestingDepth) {
232
+ const nestingDepth = limits?.fieldNestingDepth ?? DEFAULT_FIELD_NAME_LIMITS.fieldNestingDepth;
233
+ if (name.split('[').length - 1 > nestingDepth) {
223
234
  throw new MultipartError('LIMIT_FIELD_NESTING', name);
224
235
  }
225
- if (typeof limits?.fieldArrayIndexLimit === 'number' &&
226
- exceedsArrayIndexLimit(name, limits.fieldArrayIndexLimit)) {
236
+ const arrayIndexLimit = limits?.fieldArrayIndexLimit ??
237
+ DEFAULT_FIELD_NAME_LIMITS.fieldArrayIndexLimit;
238
+ if (arrayIndexLimit !== Infinity &&
239
+ exceedsArrayIndexLimit(name, arrayIndexLimit)) {
227
240
  throw new MultipartError('LIMIT_FIELD_ARRAY_INDEX', name);
228
241
  }
229
242
  }
@@ -260,15 +273,16 @@ function readFilePart(part, limits) {
260
273
  }
261
274
  /**
262
275
  * Parses the multipart body with multer's semantics and populates
263
- * `req.body` and `req.file` / `req.files`. Requests that are not multipart
264
- * are passed through untouched, as multer does.
276
+ * `req.body` and `req.file` / `req.files`, and returns the stored files.
277
+ * Requests that are not multipart are passed through untouched, as multer
278
+ * does.
265
279
  */
266
280
  export async function processMultipart(request, spec, options, storage) {
267
281
  const req = request;
268
282
  assertPluginRegistered(req);
269
283
  resetFiles(req);
270
284
  if (!req.isMultipart()) {
271
- return;
285
+ return [];
272
286
  }
273
287
  const filesLeft = new Map();
274
288
  if (spec.fields !== 'ANY') {
@@ -339,6 +353,7 @@ export async function processMultipart(request, spec, options, storage) {
339
353
  break;
340
354
  }
341
355
  }
356
+ return stored;
342
357
  }
343
358
  /**
344
359
  * Parses up to and including the first file in `fieldName`, then stops and
@@ -13,6 +13,11 @@ export function mergeMultipartOptions(options = {}, localOptions = {}) {
13
13
  if (isPlainObject(options.limits) && isPlainObject(localOptions.limits)) {
14
14
  merged.limits = { ...options.limits, ...localOptions.limits };
15
15
  }
16
+ // `dest` and `storage` both say where files go, and `resolveStorage()`
17
+ // prefers `storage`, so a global storage engine would shadow a local `dest`.
18
+ if (localOptions.dest && !localOptions.storage) {
19
+ delete merged.storage;
20
+ }
16
21
  return merged;
17
22
  }
18
23
  /**
@@ -3,10 +3,24 @@ import { createWriteStream, mkdirSync } from 'fs';
3
3
  import { unlink } from 'fs/promises';
4
4
  import { tmpdir } from 'os';
5
5
  import { join } from 'path';
6
- import { pipeline } from 'stream/promises';
6
+ import { finished, pipeline } from 'stream/promises';
7
7
  function resolveName(resolver, req, file) {
8
8
  return new Promise((resolve, reject) => resolver(req, file, (err, value) => (err ? reject(err) : resolve(value))));
9
9
  }
10
+ const STREAM_DESTROYED_MESSAGE = 'File stream was destroyed';
11
+ /**
12
+ * Rejects once `stream` is destroyed before its end without an error, as
13
+ * `@fastify/multipart` does when the form is cut off. `pipeline()` never
14
+ * settles then if the last chunk had already been pushed: it waits for an
15
+ * 'end' that is never emitted, with the file still open.
16
+ */
17
+ function rejectWhenCutOff(stream) {
18
+ return new Promise((_resolve, reject) => stream.once('close', () => {
19
+ if (!stream.readableEnded && !stream.errored) {
20
+ reject(new Error(STREAM_DESTROYED_MESSAGE));
21
+ }
22
+ }));
23
+ }
10
24
  /**
11
25
  * Writes each file to disk, with multer's `diskStorage` defaults and file
12
26
  * shape (`destination`, `filename`, `path`, `size`).
@@ -30,14 +44,25 @@ export function diskStorage(options = {}) {
30
44
  const store = async (req, file) => {
31
45
  const destination = await resolveName(resolveDestination, req, file);
32
46
  const filename = await resolveName(resolveFilename, req, file);
47
+ // As in multer, no file is created for a stream destroyed in the meantime.
48
+ if (file.stream.destroyed) {
49
+ throw new Error(STREAM_DESTROYED_MESSAGE);
50
+ }
33
51
  const path = join(destination, filename);
34
52
  // As in multer, `path` is known to the file before it is written.
35
53
  file.path = path;
36
54
  const out = createWriteStream(path);
37
55
  try {
38
- await pipeline(file.stream, out);
56
+ await Promise.race([
57
+ pipeline(file.stream, out),
58
+ rejectWhenCutOff(file.stream),
59
+ ]);
39
60
  }
40
61
  catch (err) {
62
+ // Removed once closed: a file still being opened would be created
63
+ // after the unlink.
64
+ out.destroy();
65
+ await finished(out).catch(() => undefined);
41
66
  await unlink(path).catch(() => undefined);
42
67
  throw err;
43
68
  }
@@ -8,12 +8,20 @@ export function memoryStorage() {
8
8
  return {
9
9
  _handleFile(_req, file, callback) {
10
10
  const chunks = [];
11
- file.stream.on('data', (chunk) => chunks.push(chunk));
12
- file.stream.once('error', callback);
13
- file.stream.once('end', () => {
11
+ const onData = (chunk) => chunks.push(chunk);
12
+ const onError = (err) => {
13
+ stopListening();
14
+ callback(err);
15
+ };
16
+ const onEnd = () => {
17
+ stopListening();
14
18
  const buffer = Buffer.concat(chunks);
15
19
  callback(null, { buffer, size: buffer.length });
16
- });
20
+ };
21
+ // `@fastify/multipart` keeps every part's stream until the request
22
+ // ends, and with it these listeners and the chunks they hold.
23
+ const stopListening = () => file.stream.off('data', onData).off('error', onError).off('end', onEnd);
24
+ file.stream.on('data', onData).once('error', onError).once('end', onEnd);
17
25
  },
18
26
  _removeFile(_req, file, callback) {
19
27
  delete file.buffer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestjs/platform-fastify",
3
- "version": "12.1.3",
3
+ "version": "12.2.0",
4
4
  "description": "Nest - modern, fast, powerful node.js web framework (@platform-fastify)",
5
5
  "author": "Kamil Mysliwiec",
6
6
  "license": "MIT",
@@ -8,6 +8,7 @@
8
8
  "main": "./index.js",
9
9
  "exports": {
10
10
  ".": "./index.js",
11
+ "./package.json": "./package.json",
11
12
  "./multipart": "./multipart/index.js",
12
13
  "./*.js": "./*.js",
13
14
  "./*": "./*.js"
@@ -30,9 +31,9 @@
30
31
  "@fastify/formbody": "9.0.1",
31
32
  "@fastify/middie": "9.3.5",
32
33
  "fast-querystring": "1.1.2",
33
- "fastify": "5.12.5",
34
- "find-my-way": "9.9.0",
35
- "light-my-request": "6.6.0",
34
+ "fastify": "5.12.7",
35
+ "find-my-way": "9.10.0",
36
+ "light-my-request": "6.8.0",
36
37
  "path-to-regexp": "8.4.2",
37
38
  "tslib": "2.8.1"
38
39
  },
@@ -54,5 +55,5 @@
54
55
  "optional": true
55
56
  }
56
57
  },
57
- "gitHead": "4514623279f6087808e3c8f08fbf9f37d3c17482"
58
+ "gitHead": "af37111368de7fd2dfab0bdec2f530c7845510c2"
58
59
  }