@nestjs/platform-fastify
Version:
Nest - modern, fast, powerful node.js web framework (@platform-fastify)
764 lines (763 loc) • 30.7 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 net from 'net';
import { pathToRegexp } from 'path-to-regexp';
import middie from '@fastify/middie';
import { loadPackage, tryLoadPackage, isNil, isString, isUndefined, } from '@nestjs/common/internal';
import { AbstractHttpAdapter } from '@nestjs/core';
import { LegacyRouteConverter } from '@nestjs/core/internal';
const { 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'));
}
/**
* @publicApi
*/
export class FastifyAdapter extends AbstractHttpAdapter {
logger = new Logger(FastifyAdapter.name);
_pathPrefix;
openConnections = new Set();
isClosing = false;
_isParserRegistered;
onRequestHook;
onResponseHook;
isMiddieRegistered;
multipartMode = 'auto';
multipartOptions = {};
isMultipartRequested = false;
pendingMiddlewares = [];
versioningOptions;
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 acceptHeaderVersionParameter = acceptHeaderValue
? acceptHeaderValue.split(';')[1]
: undefined;
return isUndefined(acceptHeaderVersionParameter)
? VERSION_NEUTRAL // No version was supplied
: acceptHeaderVersionParameter.split(this.versioningOptions.key)[1];
}
// 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) {
return this.versioningOptions.extractor(req);
}
return undefined;
},
mustMatchWhenDerived: false,
};
get isParserRegistered() {
return !!this._isParserRegistered;
}
constructor(instanceOrOptions) {
super();
const instance = instanceOrOptions && instanceOrOptions.server
? instanceOrOptions
: fastify({
...instanceOrOptions,
routerOptions: {
...this.getTopLevelRouterOptions(instanceOrOptions),
...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.onRequestHook(request, reply, done);
}
else {
done();
}
});
this.instance.addHook('onResponse', (request, reply, done) => {
if (this.onResponseHook) {
this.onResponseHook(request, reply, done);
}
else {
done();
}
});
}
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];
let options;
if (typeof listenOptions === 'object' &&
(listenOptions.host !== undefined ||
listenOptions.port !== undefined ||
listenOptions.path !== undefined)) {
// First parameter is an object with a path, port and/or host attributes
options = listenOptions;
}
else {
options = {
port: +listenOptions,
};
}
if (!isFirstArgTypeofFunction) {
options.host = args[0];
}
return this.instance.listen(options, callback);
}
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);
}
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 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);
}
if (fastifyReply.getHeader('Content-Length') === undefined &&
streamHeaders.length !== undefined) {
fastifyReply.header('Content-Length', streamHeaders.length);
}
body = body.getStream();
}
const responseContentType = fastifyReply.getHeader('Content-Type');
if (typeof responseContentType === 'string' &&
!responseContentType.startsWith('application/json') &&
body?.statusCode >= HttpStatus.BAD_REQUEST) {
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) {
response.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);
}
async close() {
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;
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) {
return response.sent;
}
getHeader(response, name) {
return response.getHeader(name);
}
setHeader(response, name, value) {
return response.header(name, value);
}
appendHeader(response, name, value) {
const current = response.getHeader(name);
if (current === undefined) {
return response.header(name, value);
}
// Fastify Reply.header() already concatenates set-cookie.
// Re-passing the accumulated list would duplicate previous cookies.
if (String(name).toLowerCase() === 'set-cookie') {
return response.header(name, value);
}
const values = Array.isArray(current) ? current : [current];
return response.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) {
this.register(import('@fastify/cors'), options);
}
/**
* Runs the request hook of the built-in HTTP security features in an
* `onRequest` hook: before middie (Nest middleware), 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()`) carry them too. A
* rejection goes to `done(error)`, i.e. to the Nest exception layer
* installed with `setErrorHandler()`.
*/
registerSecurityHook(hook) {
this.instance.addHook('onRequest', (request, reply, done) => {
done(hook(request, reply.raw));
});
}
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) {
const parserOptions = {
...(options || {}),
parseAs: 'buffer',
};
this.getInstance().addContentTypeParser(type, parserOptions, (req, body, done) => {
if (rawBody === true && Buffer.isBuffer(body)) {
req.rawBody = body;
}
if (parser) {
parser(req, body, done);
return;
}
done(null, body);
});
// To avoid the Nest application init to override our custom
// body parser, we mark the parsers as registered.
this._isParserRegistered = true;
}
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;
// 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(normalizedPath, (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();
}
}
return callback(req, res, next);
});
}
catch (e) {
if (e instanceof TypeError) {
LegacyRouteConverter.printError(path);
}
throw e;
}
};
}
getType() {
return 'fastify';
}
isRouteOrderSensitive() {
return false;
}
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) {
if (this.isHttpFastifyError(error)) {
return new HttpException(error.message, error.statusCode);
}
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';
const withRawBody = !!rawBody;
const { bodyLimit } = this.getInstance().initialConfig;
this.useBodyParser(contentType, withRawBody, { bodyLimit }, (req, body, done) => {
const { onProtoPoisoning, onConstructorPoisoning } = this.instance.initialConfig;
const defaultJsonParser = this.instance.getDefaultJsonParser(onProtoPoisoning || 'error', onConstructorPoisoning || 'error');
defaultJsonParser(req, body, done);
});
}
registerUrlencodedContentParser(rawBody) {
const contentType = 'application/x-www-form-urlencoded';
const withRawBody = !!rawBody;
const { bodyLimit } = this.getInstance().initialConfig;
this.useBodyParser(contentType, withRawBody, { bodyLimit }, (_req, body, done) => {
done(null, querystringParse(body.toString()));
});
}
async registerMiddie() {
this.isMiddieRegistered = true;
await this.register(middie);
}
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: handlerRef,
};
if (!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);
}
/**
* 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);
}
if (routerOptions.ignoreTrailingSlash ||
initialConfig.ignoreTrailingSlash) {
url = this.trimLastSlash(url);
}
if (routerOptions.caseSensitive === false ||
initialConfig.caseSensitive === false) {
url = url.toLowerCase();
}
return safeDecodeURI(url, routerOptions.useSemicolonDelimiter ||
initialConfig.useSemicolonDelimiter).path;
}
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")
* 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;
}
return pathStart === -1 ? '/' : url.slice(pathStart);
}
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);
}
}
}