Files
nest/packages/platform-fastify/adapters/fastify-adapter.ts
2026-02-17 17:22:01 +01:00

918 lines
26 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-floating-promises */
import { FastifyCorsOptions } from '@fastify/cors';
import {
HttpStatus,
Logger,
RawBodyRequest,
RequestMethod,
StreamableFile,
VERSION_NEUTRAL,
VersioningOptions,
VersioningType,
} from '@nestjs/common';
import { VersionValue } from '@nestjs/common/interfaces';
import { loadPackage } from '@nestjs/common/utils/load-package.util';
import { isString, isUndefined } from '@nestjs/common/utils/shared.utils';
import { AbstractHttpAdapter } from '@nestjs/core/adapters/http-adapter';
import { LegacyRouteConverter } from '@nestjs/core/router/legacy-route-converter';
import {
FastifyBaseLogger,
FastifyBodyParser,
FastifyInstance,
FastifyListenOptions,
FastifyPluginAsync,
FastifyPluginCallback,
FastifyRegister,
FastifyReply,
FastifyRequest,
FastifyServerOptions,
HTTPMethods,
RawReplyDefaultExpression,
RawRequestDefaultExpression,
RawServerBase,
RawServerDefault,
RequestGenericInterface,
RouteGenericInterface,
RouteOptions,
RouteShorthandOptions,
fastify,
} from 'fastify';
import * as Reply from 'fastify/lib/reply';
import { kRouteContext } from 'fastify/lib/symbols';
import * as http from 'http';
import * as http2 from 'http2';
import * as https from 'https';
import {
InjectOptions,
Chain as LightMyRequestChain,
Response as LightMyRequestResponse,
} from 'light-my-request';
import { pathToRegexp } from 'path-to-regexp';
// Fastify uses `fast-querystring` internally to quickly parse URL query strings.
import { parse as querystringParse } from 'fast-querystring';
import { safeDecodeURI } from 'find-my-way/lib/url-sanitizer';
import {
FASTIFY_ROUTE_CONFIG_METADATA,
FASTIFY_ROUTE_CONSTRAINTS_METADATA,
FASTIFY_ROUTE_SCHEMA_METADATA,
} from '../constants';
import { NestFastifyBodyParserOptions } from '../interfaces';
import {
FastifyStaticOptions,
FastifyViewOptions,
} from '../interfaces/external';
import middie from './middie/fastify-middie';
type FastifyAdapterBaseOptions<
Server extends RawServerBase = RawServerDefault,
Logger extends FastifyBaseLogger = FastifyBaseLogger,
> = FastifyServerOptions<Server, Logger> & {
skipMiddie?: boolean;
};
type FastifyHttp2SecureOptions<
Server extends http2.Http2SecureServer,
Logger extends FastifyBaseLogger = FastifyBaseLogger,
> = FastifyAdapterBaseOptions<Server, Logger> & {
http2: true;
https: http2.SecureServerOptions;
};
type FastifyHttp2Options<
Server extends http2.Http2Server,
Logger extends FastifyBaseLogger = FastifyBaseLogger,
> = FastifyAdapterBaseOptions<Server, Logger> & {
http2: true;
http2SessionTimeout?: number;
};
type FastifyHttpsOptions<
Server extends https.Server,
Logger extends FastifyBaseLogger = FastifyBaseLogger,
> = FastifyAdapterBaseOptions<Server, Logger> & {
https: https.ServerOptions;
};
type FastifyHttpOptions<
Server extends http.Server,
Logger extends FastifyBaseLogger = FastifyBaseLogger,
> = FastifyAdapterBaseOptions<Server, Logger> & {
http: http.ServerOptions;
};
type VersionedRoute<TRequest, TResponse> = ((
req: TRequest,
res: TResponse,
next: Function,
) => Function) & {
version: VersionValue;
versioningOptions: VersioningOptions;
};
/**
* The following type assertion is valid as we enforce "middie" plugin registration
* which enhances the FastifyRequest.RawRequest with the "originalUrl" property.
* ref https://github.com/fastify/middie/pull/16
* ref https://github.com/fastify/fastify/pull/559
*/
type FastifyRawRequest<TServer extends RawServerBase> =
RawRequestDefaultExpression<TServer> & { originalUrl?: string };
/**
* @publicApi
*/
export class FastifyAdapter<
TServer extends RawServerBase = RawServerDefault,
TRawRequest extends FastifyRawRequest<TServer> = FastifyRawRequest<TServer>,
TRawResponse extends RawReplyDefaultExpression<TServer> =
RawReplyDefaultExpression<TServer>,
TRequest extends FastifyRequest<
RequestGenericInterface,
TServer,
TRawRequest
> = FastifyRequest<RequestGenericInterface, TServer, TRawRequest>,
TReply extends FastifyReply<
RouteGenericInterface,
TServer,
TRawRequest,
TRawResponse
> = FastifyReply<RouteGenericInterface, TServer, TRawRequest, TRawResponse>,
TInstance extends FastifyInstance<TServer, TRawRequest, TRawResponse> =
FastifyInstance<TServer, TRawRequest, TRawResponse>,
> extends AbstractHttpAdapter<TServer, TRequest, TReply> {
protected readonly logger = new Logger(FastifyAdapter.name);
protected readonly instance: TInstance;
protected _pathPrefix?: string;
private _isParserRegistered: boolean;
private onRequestHook?: (
request: TRequest,
reply: TReply,
done: (err?: Error) => void,
) => void | Promise<void>;
private onResponseHook?: (
request: TRequest,
reply: TReply,
done: (err?: Error) => void,
) => void | Promise<void>;
private isMiddieRegistered: boolean;
private pendingMiddlewares: Array<{ args: any[] }> = [];
private versioningOptions?: VersioningOptions;
private readonly versionConstraint = {
name: 'version',
validate(value: unknown) {
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<string, unknown>();
return {
get(version: string | Array<string>) {
if (Array.isArray(version)) {
return versions.get(version.find(v => versions.has(v))!) || null;
}
return versions.get(version) || null;
},
set(versionOrVersions: string | Array<string>, store: unknown) {
const storeVersionConstraint = (version: string) =>
versions.set(version, store);
if (Array.isArray(versionOrVersions))
versionOrVersions.forEach(storeVersionConstraint);
else storeVersionConstraint(versionOrVersions);
},
del(version: string | Array<string>) {
if (Array.isArray(version)) {
version.forEach(v => versions.delete(v));
} else {
versions.delete(version);
}
},
empty() {
versions.clear();
},
};
},
deriveConstraint: (req: FastifyRequest) => {
// Media Type (Accept Header) Versioning Handler
if (this.versioningOptions?.type === VersioningType.MEDIA_TYPE) {
const MEDIA_TYPE_HEADER = 'Accept';
const acceptHeaderValue: string | undefined = (req.headers?.[
MEDIA_TYPE_HEADER
] || req.headers?.[MEDIA_TYPE_HEADER.toLowerCase()]) as string;
const acceptHeaderVersionParameter = acceptHeaderValue
? acceptHeaderValue.split(';')[1]
: '';
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: string | string[] | undefined =
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(): boolean {
return !!this._isParserRegistered;
}
constructor(
instanceOrOptions?:
| TInstance
| FastifyHttp2Options<any>
| FastifyHttp2SecureOptions<any>
| FastifyHttpsOptions<any>
| FastifyHttpOptions<any>
| FastifyAdapterBaseOptions<TServer>,
) {
super();
const instance =
instanceOrOptions && (instanceOrOptions as TInstance).server
? instanceOrOptions
: fastify({
...(instanceOrOptions as FastifyServerOptions),
routerOptions: {
...(instanceOrOptions as FastifyServerOptions)?.routerOptions,
constraints: {
version: this.versionConstraint as any,
},
},
});
this.setInstance(instance);
if ((instanceOrOptions as FastifyAdapterBaseOptions)?.skipMiddie) {
this.isMiddieRegistered = true;
}
this.instance.addHook('onRequest', (request, reply, done) => {
if (this.onRequestHook) {
this.onRequestHook(request as TRequest, reply as TReply, done);
} else {
done();
}
});
this.instance.addHook('onResponse', (request, reply, done) => {
if (this.onResponseHook) {
this.onResponseHook(request as TRequest, reply as TReply, done);
} else {
done();
}
});
}
public setOnRequestHook(
hook: (
request: TRequest,
reply: TReply,
done: (err?: Error) => void,
) => void | Promise<void>,
) {
this.onRequestHook = hook;
}
public setOnResponseHook(
hook: (
request: TRequest,
reply: TReply,
done: (err?: Error) => void,
) => void | Promise<void>,
) {
this.onResponseHook = hook;
}
public async init() {
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 as any)(...args);
}
this.pendingMiddlewares = [];
}
}
public listen(port: string | number, callback?: () => void): void;
public listen(
port: string | number,
hostname: string,
callback?: () => void,
): void;
public listen(
listenOptions: string | number | FastifyListenOptions,
...args: any[]
): void {
const isFirstArgTypeofFunction = typeof args[0] === 'function';
const callback = isFirstArgTypeofFunction ? args[0] : args[1];
let options: Record<string, any>;
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);
}
public get(...args: any[]) {
return this.injectRouteOptions('GET', ...args);
}
public post(...args: any[]) {
return this.injectRouteOptions('POST', ...args);
}
public head(...args: any[]) {
return this.injectRouteOptions('HEAD', ...args);
}
public delete(...args: any[]) {
return this.injectRouteOptions('DELETE', ...args);
}
public put(...args: any[]) {
return this.injectRouteOptions('PUT', ...args);
}
public patch(...args: any[]) {
return this.injectRouteOptions('PATCH', ...args);
}
public options(...args: any[]) {
return this.injectRouteOptions('OPTIONS', ...args);
}
public search(...args: any[]) {
return this.injectRouteOptions('SEARCH', ...args);
}
public propfind(...args: any[]) {
return this.injectRouteOptions('PROPFIND', ...args);
}
public proppatch(...args: any[]) {
return this.injectRouteOptions('PROPPATCH', ...args);
}
public mkcol(...args: any[]) {
return this.injectRouteOptions('MKCOL', ...args);
}
public copy(...args: any[]) {
return this.injectRouteOptions('COPY', ...args);
}
public move(...args: any[]) {
return this.injectRouteOptions('MOVE', ...args);
}
public lock(...args: any[]) {
return this.injectRouteOptions('LOCK', ...args);
}
public unlock(...args: any[]) {
return this.injectRouteOptions('UNLOCK', ...args);
}
public applyVersionFilter(
handler: Function,
version: VersionValue,
versioningOptions: VersioningOptions,
): VersionedRoute<TRequest, TReply> {
if (!this.versioningOptions) {
this.versioningOptions = versioningOptions;
}
const versionedRoute = handler as VersionedRoute<TRequest, TReply>;
versionedRoute.version = version;
return versionedRoute;
}
public reply(
response: TRawResponse | TReply,
body: any,
statusCode?: number,
) {
const fastifyReply: TReply = this.isNativeResponse(response)
? new Reply(
response,
{
[kRouteContext]: {
preSerialization: null,
preValidation: [],
preHandler: [],
onSend: [],
onError: [],
},
},
{},
)
: response;
if (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();
}
if (
fastifyReply.getHeader('Content-Type') !== undefined &&
fastifyReply.getHeader('Content-Type') !== '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);
}
public status(response: TRawResponse | TReply, statusCode: number) {
if (this.isNativeResponse(response)) {
response.statusCode = statusCode;
return response;
}
return (response as { code: Function }).code(statusCode);
}
public end(response: TReply, message?: string) {
response.raw.end(message!);
}
public render(
response: TReply & { view: Function },
view: string,
options: any,
) {
return response && response.view(view, options);
}
public redirect(response: TReply, statusCode: number, url: string) {
const code = statusCode ?? HttpStatus.FOUND;
return response.status(code).redirect(url);
}
public setErrorHandler(handler: Parameters<TInstance['setErrorHandler']>[0]) {
return this.instance.setErrorHandler(handler);
}
public setNotFoundHandler(handler: Function) {
return this.instance.setNotFoundHandler(handler as any);
}
public getHttpServer<T = TServer>(): T {
return this.instance.server as unknown as T;
}
public getInstance<T = TInstance>(): T {
return this.instance as unknown as T;
}
public register<
TRegister extends Parameters<
FastifyRegister<FastifyInstance<TServer, TRawRequest, TRawResponse>>
>,
>(plugin: TRegister['0'], opts?: TRegister['1']) {
return (this.instance.register as any)(plugin, opts);
}
public inject(): LightMyRequestChain;
public inject(opts: InjectOptions | string): Promise<LightMyRequestResponse>;
public inject(
opts?: InjectOptions | string,
): LightMyRequestChain | Promise<LightMyRequestResponse> {
return this.instance.inject(opts!);
}
public async close() {
try {
return await this.instance.close();
} catch (err) {
// Check if server is still running
if (err.code !== 'ERR_SERVER_NOT_RUNNING') {
throw err;
}
return;
}
}
public initHttpServer() {
this.httpServer = this.instance.server;
}
public useStaticAssets(options: FastifyStaticOptions) {
return this.register(
loadPackage('@fastify/static', 'FastifyAdapter.useStaticAssets()', () =>
require('@fastify/static'),
),
options,
);
}
public setViewEngine(options: FastifyViewOptions | string) {
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()', () =>
require('@fastify/view'),
),
options,
);
}
public isHeadersSent(response: TReply): boolean {
return response.sent;
}
public getHeader(response: any, name: string) {
return response.getHeader(name);
}
public setHeader(response: TReply, name: string, value: string) {
return response.header(name, value);
}
public appendHeader(response: any, name: string, value: string) {
response.header(name, value);
}
public getRequestHostname(request: TRequest): string {
return request.hostname;
}
public getRequestMethod(request: TRequest): string {
return request.raw ? request.raw.method! : request.method;
}
public getRequestUrl(request: TRequest): string;
public getRequestUrl(request: TRawRequest): string;
public getRequestUrl(request: TRequest & TRawRequest): string {
return this.getRequestOriginalUrl(request.raw || request);
}
public enableCors(options?: FastifyCorsOptions) {
this.register(
import('@fastify/cors') as Parameters<TInstance['register']>[0],
options,
);
}
public registerParserMiddleware(prefix?: string, rawBody?: boolean) {
if (this._isParserRegistered) {
return;
}
this.registerUrlencodedContentParser(rawBody);
this.registerJsonContentParser(rawBody);
this._isParserRegistered = true;
this._pathPrefix = prefix
? !prefix.startsWith('/')
? `/${prefix}`
: prefix
: undefined;
}
public useBodyParser(
type: string | string[] | RegExp,
rawBody: boolean,
options?: NestFastifyBodyParserOptions,
parser?: FastifyBodyParser<Buffer, TServer>,
) {
const parserOptions = {
...(options || {}),
parseAs: 'buffer' as const,
};
this.getInstance().addContentTypeParser<Buffer>(
type,
parserOptions,
(
req: RawBodyRequest<FastifyRequest<any, TServer, TRawRequest>>,
body: Buffer,
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;
}
public async createMiddlewareFactory(
requestMethod: RequestMethod,
): Promise<(path: string, callback: Function) => any> {
if (!this.isMiddieRegistered) {
await this.registerMiddie();
}
return (path: string, callback: Function) => {
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 = `${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: any, res: any, next: Function) => {
const queryParamsIndex = req.originalUrl.indexOf('?');
let pathname =
queryParamsIndex >= 0
? req.originalUrl.slice(0, queryParamsIndex)
: req.originalUrl;
pathname = this.sanitizeUrl(pathname);
if (!re.exec(pathname + '/') && normalizedPath) {
return next();
}
return callback(req, res, next);
},
);
} catch (e) {
if (e instanceof TypeError) {
LegacyRouteConverter.printError(path);
}
throw e;
}
};
}
public getType(): string {
return 'fastify';
}
public use(...args: any[]) {
// 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 as any)(...args);
}
protected registerWithPrefix(
factory:
| FastifyPluginCallback<any>
| FastifyPluginAsync<any>
| Promise<{ default: FastifyPluginCallback<any> }>
| Promise<{ default: FastifyPluginAsync<any> }>,
prefix = '/',
) {
return this.instance.register(factory, { prefix });
}
private isNativeResponse(
response: TRawResponse | TReply,
): response is TRawResponse {
return !('status' in response);
}
private registerJsonContentParser(rawBody?: boolean) {
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',
) as FastifyBodyParser<string | Buffer, TServer>;
defaultJsonParser(req, body, done);
},
);
}
private registerUrlencodedContentParser(rawBody?: boolean) {
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()));
},
);
}
private async registerMiddie() {
this.isMiddieRegistered = true;
await this.register(middie as Parameters<TInstance['register']>[0]);
}
private getRequestOriginalUrl(rawRequest: TRawRequest) {
return rawRequest.originalUrl || rawRequest.url!;
}
private injectRouteOptions(
routerMethodKey: Uppercase<HTTPMethods>,
...args: any[]
) {
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: RouteOptions<TServer, TRawRequest, TRawResponse> &
RouteShorthandOptions = {
method: routerMethodKey,
url: args[0],
handler: handlerRef,
};
if (this.instance.supportedMethods.indexOf(routerMethodKey) === -1) {
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);
}
private sanitizeUrl(url: string): string {
const initialConfig = this.instance.initialConfig as FastifyServerOptions;
const routerOptions =
initialConfig.routerOptions as Partial<FastifyServerOptions>;
if (
routerOptions.ignoreDuplicateSlashes ||
initialConfig.ignoreDuplicateSlashes
) {
url = this.removeDuplicateSlashes(url);
}
if (
routerOptions.ignoreTrailingSlash ||
initialConfig.ignoreTrailingSlash
) {
url = this.trimLastSlash(url);
}
if (
routerOptions.caseSensitive === false ||
initialConfig.caseSensitive === false
) {
url = url.toLowerCase();
}
return safeDecodeURI(
url,
routerOptions.useSemicolonDelimiter ||
initialConfig.useSemicolonDelimiter,
).path;
}
private removeDuplicateSlashes(path: string) {
const REMOVE_DUPLICATE_SLASHES_REGEXP = /\/\/+/g;
return path.indexOf('//') !== -1
? path.replace(REMOVE_DUPLICATE_SLASHES_REGEXP, '/')
: path;
}
private trimLastSlash(path: string) {
if (path.length > 1 && path.charCodeAt(path.length - 1) === 47) {
return path.slice(0, -1);
}
return path;
}
}