@nestjs/platform-fastify
Version:
Run NestJS on Fastify: the HTTP adapter with Fastify's plugins, JSON schemas and serialization.
1,281 lines • 56.1 kB
JavaScript
import { HttpException, HttpStatus, Logger, StreamableFile, VERSION_NEUTRAL, VersioningType, } from '@nestjs/common';
import { fastify, } from 'fastify';
import Reply from 'fastify/lib/reply.js';
import fastifySymbols from 'fastify/lib/symbols.js';
import * as http from 'http';
import * as net from 'net';
import { pathToRegexp } from 'path-to-regexp';
import { Readable } from 'stream';
import middie from '@fastify/middie';
import { loadPackage, tryLoadPackage, isFunction, isNil, isObject, isString, isUndefined, } from '@nestjs/common/internal';
import { AbstractHttpAdapter } from '@nestjs/core';
import { LegacyRouteConverter } from '@nestjs/core/internal';
import { getMediaTypeVersion } from './utils/get-media-type-version.util.js';
const { kReplySerializer, kRouteContext } = fastifySymbols;
// Fastify uses `fast-querystring` internally to quickly parse URL query strings.
import { parse as querystringParse } from 'fast-querystring';
import urlSanitizer from 'find-my-way/lib/url-sanitizer.js';
import { FASTIFY_ROUTE_CONFIG_METADATA, FASTIFY_ROUTE_CONSTRAINTS_METADATA, FASTIFY_ROUTE_SCHEMA_METADATA, } from '../constants.js';
const { safeDecodeURI } = urlSanitizer;
const MISSING_MULTIPART_PACKAGE_MESSAGE = 'The "@fastify/multipart" package is missing. Please, make sure to install it (npm i @fastify/multipart) ' +
'to use the file upload interceptors from "@nestjs/platform-fastify/multipart".';
function isFastifyMultipartPlugin(plugin) {
const fn = plugin?.default ?? plugin;
return (typeof fn === 'function' &&
(fn[Symbol.for('plugin-meta')]?.name === '@fastify/multipart' ||
fn.name === 'fastifyMultipart'));
}
// Covers "application/json" and the "+json" structured syntax suffix (RFC 6839),
// e.g. "application/problem+json", with or without parameters.
function isJsonContentType(contentType) {
const mediaType = contentType.split(';')[0].trim().toLowerCase();
return (mediaType.startsWith('application/json') || mediaType.endsWith('+json'));
}
// A custom error body need not have a "statusCode" of its own. Fastify sends
// streams, Responses and binary data as they are, and other objects through
// the serializer of `reply.serializer()`. Without one, it answers 500 for an
// object sent with a non-JSON Content-Type.
function isErrorBody(body, statusCode, reply) {
if (body?.statusCode >= HttpStatus.BAD_REQUEST) {
return true;
}
return (!isNil(statusCode) &&
statusCode >= HttpStatus.BAD_REQUEST &&
isObject(body) &&
!isFunction(body.pipe) &&
!isFunction(body.getReader) &&
Object.prototype.toString.call(body) !== '[object Response]' &&
!ArrayBuffer.isView(body) &&
isNil(reply[kReplySerializer]));
}
/**
* @publicApi
*/
export class FastifyAdapter extends AbstractHttpAdapter {
logger = new Logger(FastifyAdapter.name);
_pathPrefix;
openConnections = new Set();
isClosing = false;
isShuttingDown = false;
return503OnClosing = false;
_isParserRegistered;
// Fastify's `hasContentTypeParser('application/json')` is always true (built-in
// parser), so the types registered through `useBodyParser()` are tracked here.
registeredContentTypes = new Set();
onRequestHook;
onResponseHook;
securityHook;
// The request hook `@fastify/cors` adds for `app.enableCors()`, which the
// constructor's hook runs (see `enableCors()`)
corsHook;
isMiddieRegistered;
multipartMode = 'auto';
multipartOptions = {};
isMultipartRequested = false;
pendingMiddlewares = [];
// The encapsulated contexts created since middie was registered (see
// `registerMiddie()`)
middieContexts = [];
versioningOptions;
// Exceptions thrown by the extractor of custom versioning, by raw request
versionExtractorErrors = new WeakMap();
versionConstraint = {
name: 'version',
validate(value) {
if (!isString(value) && !Array.isArray(value)) {
throw new Error('Version constraint should be a string or an array of strings.');
}
},
storage() {
const versions = new Map();
return {
get(version) {
if (Array.isArray(version)) {
return versions.get(version.find(v => versions.has(v))) || null;
}
return versions.get(version) || null;
},
set(versionOrVersions, store) {
const storeVersionConstraint = (version) => versions.set(version, store);
if (Array.isArray(versionOrVersions))
versionOrVersions.forEach(storeVersionConstraint);
else
storeVersionConstraint(versionOrVersions);
},
del(version) {
if (Array.isArray(version)) {
version.forEach(v => versions.delete(v));
}
else {
versions.delete(version);
}
},
empty() {
versions.clear();
},
};
},
deriveConstraint: (req) => {
// Media Type (Accept Header) Versioning Handler
if (this.versioningOptions?.type === VersioningType.MEDIA_TYPE) {
const MEDIA_TYPE_HEADER = 'Accept';
const acceptHeaderValue = req.headers?.[MEDIA_TYPE_HEADER] ||
req.headers?.[MEDIA_TYPE_HEADER.toLowerCase()];
const headerVersion = getMediaTypeVersion(acceptHeaderValue, this.versioningOptions.key);
return isUndefined(headerVersion)
? VERSION_NEUTRAL // No version was supplied
: headerVersion;
}
// Header Versioning Handler
else if (this.versioningOptions?.type === VersioningType.HEADER) {
const customHeaderVersionParameter = req.headers?.[this.versioningOptions.header] ||
req.headers?.[this.versioningOptions.header.toLowerCase()];
return isUndefined(customHeaderVersionParameter)
? VERSION_NEUTRAL // No version was supplied
: customHeaderVersionParameter;
}
// Custom Versioning Handler
else if (this.versioningOptions?.type === VersioningType.CUSTOM) {
try {
return this.versioningOptions.extractor(req);
}
catch (err) {
// Fastify routes the request, and so calls the extractor, before
// anything could catch the exception. It is passed to the error
// handler from an `onRequest` hook instead (see the constructor).
this.versionExtractorErrors.set(req, err);
return undefined;
}
}
return undefined;
},
mustMatchWhenDerived: false,
};
get isParserRegistered() {
return !!this._isParserRegistered;
}
constructor(instanceOrOptions) {
super();
const instance = instanceOrOptions && instanceOrOptions.server
? instanceOrOptions
: fastify({
// The router answers a malformed URL (400) or an over-long route
// parameter (414) before any hook runs, so the security hook (see
// `registerSecurityHook()`) runs here, for its headers.
frameworkErrors: (error, request, reply) => {
try {
this.securityHook?.(request, reply.raw);
}
catch {
// The request is rejected either way, and a throw here would
// escape the router. `request.params` and `request.query`
// are null, so a CSRF `exclude` predicate may throw.
}
reply.send(error);
},
...instanceOrOptions,
routerOptions: {
...this.getTopLevelRouterOptions(instanceOrOptions),
...instanceOrOptions?.routerOptions,
constraints: {
...instanceOrOptions?.constraints,
...instanceOrOptions?.routerOptions
?.constraints,
version: this.versionConstraint,
},
},
});
this.setInstance(instance);
if (instanceOrOptions?.skipMiddie) {
this.isMiddieRegistered = true;
}
const multipart = instanceOrOptions
?.multipart;
if (multipart === false) {
this.multipartMode = 'off';
}
else if (multipart) {
this.multipartMode = 'eager';
this.multipartOptions = multipart === true ? {} : { ...multipart };
}
this.instance.addHook('onRequest', (request, reply, done) => {
if (this.onRequestHook) {
this.runLifecycleHook(this.onRequestHook, request, reply, done);
}
else {
done();
}
});
// Fastify's own `return503OnClosing` only applies once `close()` runs,
// which is after the shutdown hooks. Added here rather than in
// `initHttpServer()` so that it runs before Nest middleware:
// `NestFactory.create()` calls `init()`, which registers middie, first.
// Answers with the same body as Fastify's own 503, so that clients get
// one response shape for the whole shutdown.
this.instance.addHook('onRequest', (request, reply, done) => {
if (this.return503OnClosing && this.isShuttingDown) {
if (request.raw.httpVersionMajor !== 2) {
reply.header('Connection', 'close');
}
reply.code(503).send({
error: 'Service Unavailable',
message: 'Service Unavailable',
statusCode: 503,
});
}
else {
done();
}
});
// The request hook of `app.enableCors()` (see `enableCors()`). Added here
// for the same reason as the hook above: it must run before Nest
// middleware, so that a preflight is answered before them and the
// responses they end carry the CORS headers, as on Express. Before the
// security hook, so that its responses carry them too.
this.instance.addHook('onRequest', (request, reply, done) => {
if (!this.corsHook) {
return done();
}
this.corsHook(request, reply, (err) => {
this.copyCorsHeadersToRaw(reply);
done(err);
});
});
// The request hook of `app.enableCsrfProtection()` and
// `app.useSecurityHeaders()` (see `registerSecurityHook()`). Added here
// for the same reason as the hook above: it must run before Nest
// middleware, and `NestFactory.create()` registers middie before either
// method can be called. After the `503` hook, as on Express.
this.instance.addHook('onRequest', (request, reply, done) => {
if (this.securityHook) {
done(this.securityHook(request, reply.raw));
}
else {
done();
}
});
// Passes requests whose target middleware cannot be matched against to
// the not-found handler (see `isRoutableRequestTarget()`). Added here,
// like the hooks above, so that it runs before Nest middleware, and
// after the security hook, so that the `404` carries its headers, as on
// Express. A request the router did not match is already on its way to
// the not-found handler.
this.instance.addHook('onRequest', (request, reply, done) => {
if (request.is404 || this.isRoutableRequestTarget(request.raw.url)) {
done();
}
else {
reply.callNotFound();
}
});
// Passes an exception thrown by the extractor of custom versioning while
// Fastify routed the request (see `versionConstraint`) to the error
// handler, for matched and unmatched routes alike. Added here, like the
// hooks above, so that it runs before Nest middleware.
this.instance.addHook('onRequest', (request, _reply, done) => {
if (!this.versionExtractorErrors.has(request.raw)) {
done();
return;
}
const err = this.versionExtractorErrors.get(request.raw);
this.versionExtractorErrors.delete(request.raw);
done(err || new fastify.errorCodes.FST_ERR_SEND_UNDEFINED_ERR());
});
this.instance.addHook('onResponse', (request, reply, done) => {
if (this.onResponseHook) {
this.runLifecycleHook(this.onResponseHook, request, reply, done);
}
else {
done();
}
});
}
runLifecycleHook(hook, request, reply, done) {
let completed = false;
const doneOnce = (err) => {
if (completed)
return;
completed = true;
done(err);
};
try {
// Do not return the promise to Fastify: both completion styles share
// this guard, including hooks that call done and return a promise.
const result = hook.call(this, request, reply, doneOnce);
if (result && typeof result.then === 'function') {
result.then(() => doneOnce(), err => doneOnce(err || new fastify.errorCodes.FST_ERR_SEND_UNDEFINED_ERR()));
}
}
catch (err) {
doneOnce(err);
}
}
setOnRequestHook(hook) {
this.onRequestHook = hook;
}
setOnResponseHook(hook) {
this.onResponseHook = hook;
}
async init() {
if (this.multipartMode === 'eager') {
this.useMultipart();
}
if (this.isMiddieRegistered) {
return;
}
await this.registerMiddie();
// Register any pending middlewares that were added before init
if (this.pendingMiddlewares.length > 0) {
for (const { args } of this.pendingMiddlewares) {
this.instance.use(...args);
}
this.pendingMiddlewares = [];
}
}
listen(listenOptions, ...args) {
const isFirstArgTypeofFunction = typeof args[0] === 'function';
const callback = isFirstArgTypeofFunction ? args[0] : args[1];
if (typeof listenOptions === 'string' && Number.isNaN(+listenOptions)) {
// A string that is not a number is a Unix socket path or a named pipe.
// The host does not apply to it.
return this.instance.listen({ path: listenOptions }, callback);
}
let options;
if (typeof listenOptions === 'object' &&
(listenOptions.host !== undefined ||
listenOptions.port !== undefined ||
listenOptions.path !== undefined ||
listenOptions.fd !== undefined)) {
// First parameter is an object with a path, port, fd and/or host attributes
options = listenOptions;
}
else {
options = {
port: +listenOptions,
};
}
if (!isFirstArgTypeofFunction) {
options.host = args[0];
}
if (options.port !== undefined && !this.isValidPort(options.port)) {
// Fastify calls `server.listen()` from its ready callback, where the
// RangeError Node throws for a bad port becomes an unhandled rejection
// and the callback never runs. Throw right away instead, as the
// `http.Server` of the Express adapter does.
const error = new RangeError(`Options.port should be >= 0 and < 65536. Received ${String(options.port)}.`);
throw Object.assign(error, { code: 'ERR_SOCKET_BAD_PORT' });
}
return this.instance.listen(options, callback);
}
isValidPort(port) {
const value = typeof port === 'string' && port.trim() !== '' ? Number(port) : port;
return (typeof value === 'number' &&
Number.isInteger(value) &&
value >= 0 &&
value <= 65535);
}
get(...args) {
return this.injectRouteOptions('GET', ...args);
}
post(...args) {
return this.injectRouteOptions('POST', ...args);
}
head(...args) {
return this.injectRouteOptions('HEAD', ...args);
}
delete(...args) {
return this.injectRouteOptions('DELETE', ...args);
}
put(...args) {
return this.injectRouteOptions('PUT', ...args);
}
patch(...args) {
return this.injectRouteOptions('PATCH', ...args);
}
options(...args) {
return this.injectRouteOptions('OPTIONS', ...args);
}
search(...args) {
return this.injectRouteOptions('SEARCH', ...args);
}
query(...args) {
return this.injectRouteOptions('QUERY', ...args);
}
propfind(...args) {
return this.injectRouteOptions('PROPFIND', ...args);
}
proppatch(...args) {
return this.injectRouteOptions('PROPPATCH', ...args);
}
mkcol(...args) {
return this.injectRouteOptions('MKCOL', ...args);
}
copy(...args) {
return this.injectRouteOptions('COPY', ...args);
}
move(...args) {
return this.injectRouteOptions('MOVE', ...args);
}
lock(...args) {
return this.injectRouteOptions('LOCK', ...args);
}
unlock(...args) {
return this.injectRouteOptions('UNLOCK', ...args);
}
all(...args) {
// Routes registered by Nest ("path, handler") get the route options
// (e.g. the version), with the same methods as "fastify.all()".
if (args.length === 2 && isFunction(args[1])) {
return this.injectRouteOptions(this.instance.supportedMethods, ...args);
}
const handlerRef = args[args.length - 1];
args[args.length - 1] = this.requireNonEmptyParams(args[0], handlerRef);
return this.instance.all(...args);
}
applyVersionFilter(handler, version, versioningOptions) {
if (!this.versioningOptions) {
this.versioningOptions = versioningOptions;
}
const versionedRoute = handler;
versionedRoute.version = version;
return versionedRoute;
}
reply(response, body, statusCode) {
const fastifyReply = this.isNativeResponse(response)
? new Reply(response, {
[kRouteContext]: {
preSerialization: null,
preValidation: [],
preHandler: [],
onSend: [],
onError: [],
},
}, {})
: response;
if (!isNil(statusCode)) {
fastifyReply.status(statusCode);
}
if (body instanceof StreamableFile) {
const stream = body.getStream();
// Once the reply was sent (e.g. by `handlerTimeout`), send() drops the
// stream without consuming or destroying it.
if (fastifyReply.sent && isFunction(stream?.destroy)) {
stream.destroy();
}
const streamHeaders = body.getHeaders();
if (fastifyReply.getHeader('Content-Type') === undefined &&
streamHeaders.type !== undefined) {
fastifyReply.header('Content-Type', streamHeaders.type);
}
if (fastifyReply.getHeader('Content-Disposition') === undefined &&
streamHeaders.disposition !== undefined) {
fastifyReply.header('Content-Disposition', streamHeaders.disposition);
}
// A "Transfer-Encoding" forbids a "Content-Length" (RFC 9112)
if (fastifyReply.getHeader('Content-Length') === undefined &&
fastifyReply.getHeader('Transfer-Encoding') === undefined &&
streamHeaders.length !== undefined) {
fastifyReply.header('Content-Length', streamHeaders.length);
}
body = stream;
// Fastify's HEAD route handler resumes a stream payload, and a route
// declared for HEAD (or a raw response) pipes it into a response that
// discards it, so the whole source would be read for nothing. Fastify
// gets an empty stream instead, as an empty body would reset
// Content-Length to 0.
if (fastifyReply.raw?.req?.method === 'HEAD' &&
isFunction(stream?.destroy)) {
stream.destroy();
body = Readable.from([]);
}
}
// Fastify passes an Error payload to the error handler instead of
// sending it, and the reply around a raw response (e.g. the one
// middleware gets) has none. An Error body sent with an error status,
// as for `new UnauthorizedException(err)`, gets the standard body of the
// status instead, without the properties of the Error.
if (body instanceof Error &&
fastifyReply.statusCode >= HttpStatus.BAD_REQUEST) {
body = {
statusCode: fastifyReply.statusCode,
message: http.STATUS_CODES[fastifyReply.statusCode] ?? 'Error',
};
}
const responseContentType = fastifyReply.getHeader('Content-Type');
if (typeof responseContentType === 'string' &&
!isJsonContentType(responseContentType) &&
isErrorBody(body, statusCode, fastifyReply)) {
Logger.warn("Content-Type doesn't match Reply body, you might need a custom ExceptionFilter for non-JSON responses", FastifyAdapter.name);
fastifyReply.header('Content-Type', 'application/json');
}
return fastifyReply.send(body);
}
status(response, statusCode) {
if (this.isNativeResponse(response)) {
response.statusCode = statusCode;
return response;
}
return response.code(statusCode);
}
end(response, message) {
if (this.isNativeResponse(response)) {
response.end(message);
return;
}
const reply = response;
reply.raw.end(message);
}
render(response, view, options) {
return response && response.view(view, options);
}
redirect(response, statusCode, url) {
const code = statusCode ?? HttpStatus.FOUND;
return response.status(code).redirect(url);
}
setErrorHandler(handler) {
return this.instance.setErrorHandler(handler);
}
setNotFoundHandler(handler) {
return this.instance.setNotFoundHandler(handler);
}
getHttpServer() {
return this.instance.server;
}
getInstance() {
return this.instance;
}
register(plugin, opts) {
if (this.multipartMode !== 'off' &&
isFastifyMultipartPlugin(plugin) &&
!this.instance.hasRequestDecorator('isMultipart')) {
// The adapter owns the "@fastify/multipart" registration, so that the
// plugin is registered once whether or not the user registers it too.
// The user's options apply over the adapter's.
this.multipartOptions = { ...this.multipartOptions, ...opts };
this.useMultipart();
return this.instance;
}
return this.instance.register(plugin, opts);
}
/**
* Registers the "@fastify/multipart" plugin, unless the adapter's
* `multipart` option is `false`, or the plugin has been registered
* already by the time it loads. Idempotent. Called by the upload
* interceptors from "@nestjs/platform-fastify/multipart".
*
* The plugin is loaded lazily, and a missing package fails the
* application's startup (`ready()`) with an error naming the package,
* rather than exiting the process.
*/
useMultipart() {
if (this.multipartMode === 'off' || this.isMultipartRequested) {
return;
}
this.isMultipartRequested = true;
const registerMultipart = async (instance) => {
// Plugins load in registration order, so one the user registered
// before this point has loaded by now.
if (instance.hasRequestDecorator('isMultipart')) {
return;
}
const multipart = await tryLoadPackage('@fastify/multipart', () => import('@fastify/multipart'));
if (!multipart) {
throw new Error(MISSING_MULTIPART_PACKAGE_MESSAGE);
}
// Copied: the plugin writes its defaults into the options it receives.
const { limits, ...options } = this.multipartOptions;
await instance.register(multipart, {
...options,
...(limits && { limits: { ...limits } }),
});
};
// Like `fastify-plugin`: register on the root context, not a child one.
Object.assign(registerMultipart, {
[Symbol.for('skip-override')]: true,
[Symbol.for('fastify.display-name')]: 'nestjs-multipart',
});
this.instance.register(registerMultipart);
}
inject(opts) {
return this.instance.inject(opts);
}
beforeClose() {
this.isShuttingDown = true;
}
async close() {
this.isShuttingDown = true;
this.isClosing = true;
try {
this.closeOpenConnections();
}
finally {
await this.instance.close().catch(err => {
// Check if server is still running
if (err.code !== 'ERR_SERVER_NOT_RUNNING') {
throw err;
}
});
}
}
initHttpServer(options = {}) {
this.httpServer = this.instance.server;
this.return503OnClosing = !!options?.return503OnClosing;
if (options?.forceCloseConnections) {
this.trackOpenConnections();
}
}
// `register()` accepts a promise of a plugin, so the import is handed over
// unawaited on purpose. `NestApplication.useStaticAssets()` discards what
// this returns, so awaiting here would enqueue the plugin after the caller
// has already moved on to `listen()`.
useStaticAssets(options) {
return this.register(loadPackage('@fastify/static', 'FastifyAdapter.useStaticAssets()', () => import('@fastify/static')), options);
}
// Handed over unawaited for the same reason as `useStaticAssets()` above.
setViewEngine(options) {
if (isString(options)) {
new Logger('FastifyAdapter').error("setViewEngine() doesn't support a string argument.");
process.exit(1);
}
return this.register(loadPackage('@fastify/view', 'FastifyAdapter.setViewEngine()', () => import('@fastify/view')), options);
}
isHeadersSent(response) {
if (this.isNativeResponse(response)) {
return response.headersSent;
}
const reply = response;
// `reply.sent` only covers hijacked or ended replies, not headers
// already flushed through `reply.raw`.
return reply.sent || reply.raw.headersSent;
}
getHeader(response, name) {
return response.getHeader(name);
}
setHeader(response, name, value) {
if (this.isNativeResponse(response)) {
return response.setHeader(name, value);
}
const reply = response;
return reply.header(name, value);
}
appendHeader(response, name, value) {
if (this.isNativeResponse(response)) {
const current = response.getHeader(name);
return response.setHeader(name, current === undefined ? value : [current, value].flat().map(String));
}
const reply = response;
// A set-cookie value can live on the reply or only on the raw response
// (e.g., set by a middleware). Reply.getHeader() reads both, and the
// merged list is written to both: reply.send() sends the reply headers,
// while a hijacked reply or a handler that ends reply.raw sends the raw
// ones.
if (String(name).toLowerCase() === 'set-cookie') {
const current = reply.getHeader(name);
const values = current === undefined ? [value] : [current, value].flat().map(String);
reply.removeHeader(name);
reply.header(name, values);
if (!reply.raw.headersSent) {
// A copy, so a later reply.header() call does not change the raw header
reply.raw.setHeader(name, [...values]);
}
return reply;
}
const current = reply.getHeader(name);
if (current === undefined) {
return reply.header(name, value);
}
const values = Array.isArray(current) ? current : [current];
return reply.header(name, values.concat(value));
}
getRequestHostname(request) {
return request.hostname;
}
getRequestMethod(request) {
return request.raw ? request.raw.method : request.method;
}
getRequestUrl(request) {
return this.getRequestOriginalUrl(request.raw || request);
}
enableCors(options) {
const hook = options?.hook;
if (hook && hook !== 'onRequest') {
// A later hook runs after Nest middleware wherever it is added
this.register(import('@fastify/cors'), options);
return;
}
const registerCors = async (instance) => {
const { default: fastifyCors } = await import('@fastify/cors');
// `@fastify/cors` adds its request hook to the instance it is given,
// which would run after middie: the hook is kept so that the hook added
// in the constructor runs it instead
const corsInstance = new Proxy(instance, {
get: (target, prop) => {
if (prop === 'addHook') {
return (name, hookFn) => {
if (name === 'onRequest' && !this.corsHook) {
this.corsHook = hookFn;
return corsInstance;
}
return target.addHook.call(target, name, hookFn);
};
}
const value = Reflect.get(target, prop, target);
return isFunction(value) ? value.bind(target) : value;
},
});
// As `register()` would, call a function that takes the instance for
// the options; one that takes `(request, callback)` is the options
// delegate of `@fastify/cors` (and the `cors` option)
const corsOptions = isFunction(options) && options.length < 2
? options(instance)
: options;
await new Promise((resolve, reject) => fastifyCors(corsInstance, corsOptions ?? {}, err => (err ? reject(err) : resolve())));
};
// Like `fastify-plugin`: register on the root context, not a child one.
Object.assign(registerCors, {
[Symbol.for('skip-override')]: true,
[Symbol.for('fastify.display-name')]: 'nestjs-cors',
});
this.instance.register(registerCors);
}
/**
* Copies the headers `@fastify/cors` set on the reply to the Node.js
* response, so that a response a middleware ends itself, or the error
* response of a middleware that throws (sent through a new reply), carries
* them too. Fastify merges them into the response it sends, in which the
* reply's own values win.
*/
copyCorsHeadersToRaw(reply) {
if (reply.raw.headersSent) {
return;
}
const headers = reply.getHeaders();
for (const name of Object.keys(headers)) {
const value = headers[name];
if (!isUndefined(value) &&
(name === 'vary' || name.startsWith('access-control-'))) {
reply.raw.setHeader(name, value);
}
}
}
/**
* Runs the request hook of the built-in HTTP security features in an
* `onRequest` hook added by the constructor: before middie (Nest middleware
* and `app.use()` middleware, whenever they are registered), content-type
* parsing, guards and handlers, and also for unmatched routes. Headers set
* by the hook go to the Node.js response (`reply.raw`): Fastify merges them
* into every response it sends, errors and `404`s included, while
* `reply.header()` / `@Header()` values take precedence, and responses
* written to `reply.raw` directly (e.g. `@Sse()`, or middleware) carry them
* too. A rejection goes to `done(error)`, i.e. to the Nest exception layer
* installed with `setErrorHandler()`.
*/
registerSecurityHook(hook) {
this.securityHook = hook;
}
registerParserMiddleware(prefix, rawBody) {
if (this._isParserRegistered) {
return;
}
this.registerUrlencodedContentParser(rawBody);
this.registerJsonContentParser(rawBody);
this._isParserRegistered = true;
this._pathPrefix = prefix
? !prefix.startsWith('/')
? `/${prefix}`
: prefix
: undefined;
}
useBodyParser(type, rawBody, options, parser) {
if (Array.isArray(type)) {
// One by one, so that each content type gets its own default parser.
for (const contentType of type) {
this.useBodyParser(contentType, rawBody, options, parser);
}
return;
}
const parserOptions = {
...(options || {}),
parseAs: 'buffer',
};
const bodyParser = parser ?? this.getDefaultBodyParser(type);
this.getInstance().addContentTypeParser(type, parserOptions, (req, body, done) => {
if (rawBody === true && Buffer.isBuffer(body)) {
req.rawBody = body;
}
if (bodyParser) {
bodyParser(req, body, done);
return;
}
done(null, body);
});
this.registeredContentTypes.add(this.normalizeContentType(type));
}
async createMiddlewareFactory(requestMethod) {
if (!this.isMiddieRegistered) {
await this.registerMiddie();
}
return (path, callback) => {
const hasEndOfStringCharacter = path.endsWith('$');
path = hasEndOfStringCharacter ? path.slice(0, -1) : path;
let normalizedPath = LegacyRouteConverter.tryConvert(path);
// Fallback to "*path" to support plugins like GraphQL
normalizedPath = normalizedPath === '/*path' ? '*path' : normalizedPath;
// Normalize the path to support the prefix if it set in application
if (this._pathPrefix &&
!normalizedPath.startsWith(this._pathPrefix) &&
(normalizedPath === '/' || normalizedPath === '')) {
normalizedPath = `${this._pathPrefix}${normalizedPath}`;
if (normalizedPath.endsWith('/')) {
normalizedPath = `${normalizedPath}{*path}`;
}
}
try {
let { regexp: re } = pathToRegexp(normalizedPath);
re = hasEndOfStringCharacter
? new RegExp(re.source + '$', re.flags)
: re;
// A case-insensitive router lower-cases the decoded path, where some
// characters only fold to ASCII ("%E2%84%AA", KELVIN SIGN, is routed
// as "k"). The RegExp of @fastify/middie does not fold them, so on
// such a router the middleware is registered for every path and the
// path is only matched below.
const { caseSensitive } = this.getRouteMatchingOptions();
const createMiddleware = (stripsPath) => (req, res, next) => {
const queryParamsIndex = req.originalUrl.indexOf('?');
let pathname = queryParamsIndex >= 0
? req.originalUrl.slice(0, queryParamsIndex)
: req.originalUrl;
pathname = this.sanitizeUrl(pathname);
if (normalizedPath) {
const pathToCheck = pathname.endsWith('/')
? pathname
: `${pathname}/`;
if (!re.exec(pathToCheck)) {
return next();
}
if (stripsPath) {
// Strips the path from "req.url", as @fastify/middie does
// when it matches the path itself
const suffixIndex = req.originalUrl.search(/[?#]/);
req.url =
suffixIndex >= 0
? `/${req.originalUrl.slice(suffixIndex)}`
: '/';
}
}
this.runMiddleware(callback, req, res, next);
};
// The following type assertion is valid as we use import('@fastify/middie') rather than require('@fastify/middie')
// ref https://github.com/fastify/middie/pull/55
this.instance.use(caseSensitive ? normalizedPath : '/', createMiddleware(!caseSensitive));
// The contexts created since middie was registered have a copy of the
// middleware of their parent without this one (see `registerMiddie()`).
// It is registered there without its path, which middie would prefix
// with the one of the context.
for (const context of this.middieContexts) {
context.use(createMiddleware(true));
}
}
catch (e) {
if (e instanceof TypeError) {
LegacyRouteConverter.printError(path);
}
throw e;
}
};
}
runMiddleware(middleware, req, res, next) {
let completed = false;
const nextOnce = (err) => {
if (completed)
return;
completed = true;
next(err);
};
try {
// middie discards the return value, so the promise is observed here:
// the proxies of `RouterProxy` reject when the exception layer throws.
// Its resolution is not passed to `next`, as a middleware that answers
// the request does not call it.
const result = middleware(req, res, nextOnce);
if (result && typeof result.then === 'function') {
result.then(undefined, (err) => {
if (completed) {
this.logger.error(err);
return;
}
nextOnce(err || new fastify.errorCodes.FST_ERR_SEND_UNDEFINED_ERR());
});
}
}
catch (err) {
nextOnce(err);
}
}
getType() {
return 'fastify';
}
isRouteOrderSensitive() {
return false;
}
// The return type is spelled out (rather than "RouteMatchingOptions") so
// that the declarations still compile against an older "@nestjs/common"
getRouteMatchingOptions() {
const initialConfig = this.instance.initialConfig;
// Absent when a Fastify instance created without it is passed in
const routerOptions = (initialConfig.routerOptions ??
{});
return {
caseSensitive: !(routerOptions.caseSensitive === false ||
initialConfig.caseSensitive === false),
ignoreTrailingSlash: !!(routerOptions.ignoreTrailingSlash || initialConfig.ignoreTrailingSlash),
};
}
use(...args) {
// Fastify requires @fastify/middie plugin to be registered before middleware can be used.
// If middie is not registered yet, we queue the middleware and register it later during init.
if (!this.isMiddieRegistered) {
this.pendingMiddlewares.push({ args });
return this;
}
return this.instance.use(...args);
}
mapException(error) {
// A 5xx FastifyError (e.g. FST_ERR_HANDLER_TIMEOUT) is a server fault: it
// stays unmapped, so the built-in filter logs it, like an http-errors one.
if (this.isHttpFastifyError(error) && error.statusCode < 500) {
return new HttpException(error.message, error.statusCode, {
cause: error,
});
}
// http-errors (used by Express middleware such as body-parser) carry a
// numeric statusCode and an expose flag. Without this case, errors like
// PayloadTooLargeError reach custom exception filters as a raw http-errors
// instance rather than an HttpException, as on Express (#18213). A 5xx one
// is a server fault: it stays unmapped, so the built-in filter still logs it.
if (isHttpError(error) && error.statusCode < 500) {
return new HttpException(
// An error that must not reach the client only gets a status text,
// like the built-in exception filter answers it
error.expose === false
? (http.STATUS_CODES[error.statusCode] ?? 'Internal server error')
: error.message, error.statusCode, { cause: error });
}
return error;
}
isHttpFastifyError(error) {
// condition based on this code - https://github.com/fastify/fastify-error/blob/d669b150a82968322f9f7be992b2f6b463272de3/index.js#L22
return (error.statusCode !== undefined &&
error instanceof Error &&
error.name === 'FastifyError');
}
registerWithPrefix(factory, prefix = '/') {
return this.instance.register(factory, { prefix });
}
isNativeResponse(response) {
return !('status' in response);
}
registerJsonContentParser(rawBody) {
const contentType = 'application/json';
if (this.hasCustomBodyParser(contentType)) {
return;
}
const withRawBody = !!rawBody;
const { bodyLimit } = this.getInstance().initialConfig;
this.useBodyParser(contentType, withRawBody, { bodyLimit });
}
registerUrlencodedContentParser(rawBody) {
const contentType = 'application/x-www-form-urlencoded';
if (this.hasCustomBodyParser(contentType)) {
return;
}
const withRawBody = !!rawBody;
const { bodyLimit } = this.getInstance().initialConfig;
this.useBodyParser(contentType, withRawBody, { bodyLimit });
}
// A parser registered through `useBodyParser()` takes precedence over the
// default one, including a RegExp or catch-all ('*') parser matching the type.
hasCustomBodyParser(contentType) {
for (const type of this.registeredContentTypes) {
if (type === '*' || type === contentType) {
return true;
}
if (isString(type)) {
continue;
}
type.lastIndex = 0;
if (type.test(contentType)) {
return true;
}
}
return false;
}
// Fastify stores string content types trimmed and lower-cased.
normalizeContentType(type) {
return isString(type) ? type.trim().toLowerCase() : type;
}
getDefaultBodyParser(type) {
switch (this.normalizeContentType(type)) {
case 'application/json':
return (req, body, done) => {
const { onProtoPoisoning, onConstructorPoisoning } = this.instance.initialConfig;
const defaultJsonParser = this.instance.getDefaultJsonParser(onProtoPoisoning || 'error', onConstructorPoisoning || 'error');
defaultJsonParser(req, body, done);
};
case 'application/x-www-form-urlencoded':
return (_req, body, done) => {
done(null, querystringParse(body.toString()));
};
}
}
async registerMiddie() {
this.isMiddieRegistered = true;
await this.register(middie);
// middie gives each encapsulated context created from now on a copy of
// the middleware of its parent, as it is at that time. As
// `NestFactory.create()` registers middie before `main.ts` runs, a
// plugin registered there with an awaited `register()` gets a context
// without the Nest middleware `app.init()` registers later, so
// `createMiddlewareFactory()` adds it to the contexts tracked here.
this.instance.addHook('onRegister', instance => {
this.middieContexts.push(instance);
});
}
getRequestOriginalUrl(rawRequest) {
return rawRequest.originalUrl || rawRequest.url;
}
injectRouteOptions(routerMethodKey, ...args) {
const handlerRef = args[args.length - 1];
const isVersioned = !isUndefined(handlerRef.version) &&
handlerRef.version !== VERSION_NEUTRAL;
const routeConfig = Reflect.getMetadata(FASTIFY_ROUTE_CONFIG_METADATA, handlerRef);
const routeConstraints = Reflect.getMetadata(FASTIFY_ROUTE_CONSTRAINTS_METADATA, handlerRef);
const routeSchema = Reflect.getMetadata(FASTIFY_ROUTE_SCHEMA_METADATA, handlerRef);
const hasConfig = !isUndefined(routeConfig);
const hasConstraints = !isUndefined(routeConstraints);
const hasSchema = !isUndefined(routeSchema);
const routeToInject = {
method: routerMethodKey,
url: args[0],
handler: this.requireNonEmptyParams(args[0], handlerRef),
};
if (isString(routerMethodKey) &&
!this.instance.supportedMethods.includes(routerMethodKey)) {
this.instance.addHttpMethod(routerMethodKey, { hasBody: true });
}
if (isVersioned || hasConstraints || hasConfig || hasSchema) {
const isPathAndRouteTuple = args.length === 2;
if (isPathAndRouteTuple) {
const constraints = {
...(hasConstraints && routeConstraints),
...(isVersioned && {
version: handlerRef.version,
}),
};
const options = {
constraints,
...(hasConfig && {
config: {
...routeConfig,
},
}),
...(hasSchema && {
schema: routeSchema,
}),
};
const routeToInjectWithOptions = { ...routeToInject, ...options };
return this.instance.route(routeToInjectWithOptions);
}
}
return this.instance.route(routeToInject);
}
/**
* "find-my-way" matches an empty path segment for a route parameter
* ("/users//profile" -> { id: "" }), while "path-to-regexp", which
* middleware paths (and the ExpressAdapter's routes) are matched with,
* needs at least one character per parameter. Requests with an empty route
* parameter are passed to the not-found handler instead, as with the
* ExpressAdapter, so that middleware and routes always match the same
* requests. Only the wildcard ("*") may be empty.
*/
requireNonEmptyParams(url, handler) {
if (isString(url) && !url.includes(':')) {
return handler;
}
const routeHandler = function (request, reply) {
const params = request.params;
for (const name in params) {
if (params[name] === '' && name !== '*') {
reply.callNotFound();
return;
}
}
return handler.call(this, request, reply);
};
Object.defineProperty(routeHandler, 'name', {
configurable: true,
value: handler.name,
});
return routeHandler;
}
/**
* Fastify still accepts the router options ("ignoreTrailingSlash",
* "caseSensitive", ...) at the top level, but "initialConfig.routerOptions"
* only reflects them when they are passed through "routerOptions". As the
* adapter always passes "routerOptions" (for the version constraint), the
* top-level values are folded in so that plugins relying on
* "initialConfig.routerOptions" (like @fastify/middie) normalize request
* paths exactly like the router does.
*/
getTopLevelRouterOptions(options) {
const routerOptions = {};
const routerOptionKeys = [
'ignoreTrailingSlash',
'ignoreDuplicateSlashes',
'caseSensitive',
'useSemicolonDelimiter',
'maxParamLength',
'allowUnsafeRegex',
];
for (const key of routerOptionKeys) {
if (options?.[key] !== undefined) {
routerOptions[key] = options[key];
}
}
return routerOptions;
}
sanitizeUrl(url) {
const initialConfig = this.instance.initialConfig;
const routerOptions = (initialConfig.routerOptions ??
{});
// Absolute-form request targets ("GET http://host/path HTTP/1.1") must be
// resolved to their path before any other normalization, as the Fastify
// router does, so that middleware and routes always match the same path.
url = this.getPathFromRequestTarget(url);
if (routerOptions.ignoreDuplicateSlashes ||
initialConfig.ignoreDuplicateSlashes) {
url = this.removeDuplicateSlashes(url);
}
const { caseSensitive, ignoreTrailingSlash } = this.getRouteMatchingOptions();
if (ignoreTrailingSlash) {
url = this.trimLastSlash(url);
}
const { path } = safeDecodeURI(url, routerOptions.useSemicolonDelimiter ||
initialConfig.useSemicolonDelimiter);
// Lower-cased once decoded, as the router does: "%E2%84%AA" (KELVIN
// SIGN) only lower-cases to "k" then
return caseSensitive ? path : path.toLowerCase();
}
removeDuplicateSlashes(path) {
const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g;
return path.indexOf('//') !== -1
? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/')
: path;
}
trimLastSlash(path) {
if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) {
return path.slice(0, -1);
}
return path;
}
/**
* Mirrors the absolute-form request target handling of "find-my-way".
* Returns the path of an absolute-form target ("http://host/path" -> "/path",
* "http://host?a=b" -> "/?a=b") and leaves any other request target
* untouched.
*/
getPathFromRequestTarget(url) {
if (url.charCodeAt(0) === 47 /* '/' */) {
return url;
}
const schemeEnd = url.indexOf('://');
if (schemeEnd === -1) {
return url;
}
const scheme = url.slice(0, schemeEnd).toLowerCase();
if (scheme !== 'http' && scheme !== 'https') {
return url;
}
const authorityStart = schemeEnd + 3;
const pathStart = url.indexOf('/', authorityStart);
if (pathStart === authorityStart || !URL.canParse(url)) {
// Malformed target: the router rejects it before any middleware runs
return url;
}
const queryStart = url.indexOf('?', authorityStart);
if (queryStart !== -1 && (pathStart === -1 || queryStart < pathStart)) {
// The query ends the authority, so a "/" in it does not start the path
// ("http://host?a/users" -> "/?a/users"). "find-my-way" < 9.9.0, which
// the router of a Fastify instance passed to the adapter may still be,
// routes such a target as "/users". It is left untouched, so that it
// is answered by the not-found handler (see `isRoutableRequestTarget()`).
return pathStart === -1 ? `/${url.slice(queryStart)}` : url;
}
return pathStart === -1 ? '/' : url.slice(pathStart);
}
/**
* Whether the request target is in origin-form ("/users") or in the
* absolute-form of an "http(s)" URL ("http://host/users"), the only forms
* middleware paths can match.
*
* Node.js accepts other targets too (e.g. "GET *users HTTP/1.1").
* "find-my-way" routes those as if their first character were the leading
* "/" ("*users" is served by the "/users" route), while no middleware path
* matches them, so the route would run without its middleware. They are
* answered by the not-found handler instead, as with the ExpressAdapter,
* whose router never matches a path that does not start with "/".
*
* That includes the asterisk-form "OPTIONS *" (RFC 9112, section 3.2.4),
* which asks about the server as a whole rather than about a resource:
* Nest has no handler for it, and "find-my-way" would serve it with the
* "OPTIONS /" route.
*/
isRoutableRequestTarget(url) {
return !!url && this.getPathFromRequestTarget(url).charCodeAt(0) === 47;
}
trackOpenConnections() {
const track = (socket) => {
if (this.isClosing) {
// Fastify runs its `preClose` hooks before it stops accepting
// connections, so destroy anything that arrives in the meantime
socket.destroy();
return;
}
if (this.openConnections.has(socket)) {
return;
}
this.openConnections.add(socket);
socket.on('close', () => this.openConnections.delete(socket));
};
this.httpServer.on('connection', track);
// Sockets accepted by the secondary servers Fastify binds for every
// address `listen()` resolves to are only reachable through requests.
// `inject()` requests carry a mock socket, which must not be tracked.
this.instance.addHook('onRequest', (request, _reply, done) => {
const socket = request.raw.socket;
if (socket instanceof net.Socket) {
track(socket);
}
done();
});
}
closeOpenConnections() {
for (const socket of this.openConnections) {
socket.destroy();
this.openConnections.delete(socket);
}
}
}
/**
* Returns true when `err` is an "http-errors"-shaped error (body-parser, Express, etc.).
* Mirrors the same check in BaseExceptionFilter.isHttpError().
*/
function isHttpError(err) {
return (isObject(err) &&
err instanceof Error &&
typeof err.expose === 'boolean' &&
typeof err.statusCode === 'number' &&
err.status === err.statusCode);
}