/* eslint-disable @typescript-eslint/no-floating-promises */ import { FastifyCorsOptions } from '@fastify/cors'; import { BadRequestException, HttpException, HttpStatus, Logger, type RawBodyRequest, type RequestMethod, StreamableFile, VERSION_NEUTRAL, type VersioningOptions, VersioningType, } from '@nestjs/common'; import { FastifyBaseLogger, FastifyBodyParser, FastifyError, 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.js'; import fastifySymbols from 'fastify/lib/symbols.js'; 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'; import { type VersionValue, loadPackage, 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'; import { FastifyStaticOptions, FastifyViewOptions, } from '../interfaces/external/index.js'; import { NestFastifyBodyParserOptions } from '../interfaces/index.js'; import middie from './middie/fastify-middie.js'; const { safeDecodeURI } = urlSanitizer; type FastifyAdapterBaseOptions< Server extends RawServerBase = RawServerDefault, Logger extends FastifyBaseLogger = FastifyBaseLogger, > = FastifyServerOptions & { skipMiddie?: boolean; }; type FastifyHttp2SecureOptions< Server extends http2.Http2SecureServer, Logger extends FastifyBaseLogger = FastifyBaseLogger, > = FastifyAdapterBaseOptions & { http2: true; https: http2.SecureServerOptions; }; type FastifyHttp2Options< Server extends http2.Http2Server, Logger extends FastifyBaseLogger = FastifyBaseLogger, > = FastifyAdapterBaseOptions & { http2: true; http2SessionTimeout?: number; }; type FastifyHttpsOptions< Server extends https.Server, Logger extends FastifyBaseLogger = FastifyBaseLogger, > = FastifyAdapterBaseOptions & { https: https.ServerOptions; }; type FastifyHttpOptions< Server extends http.Server, Logger extends FastifyBaseLogger = FastifyBaseLogger, > = FastifyAdapterBaseOptions & { http: http.ServerOptions; }; type VersionedRoute = (( 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 = RawRequestDefaultExpression & { originalUrl?: string }; /** * @publicApi */ export class FastifyAdapter< TServer extends RawServerBase = RawServerDefault, TRawRequest extends FastifyRawRequest = FastifyRawRequest, TRawResponse extends RawReplyDefaultExpression = RawReplyDefaultExpression, TRequest extends FastifyRequest< RequestGenericInterface, TServer, TRawRequest > = FastifyRequest, TReply extends FastifyReply< RouteGenericInterface, TServer, TRawRequest, TRawResponse > = FastifyReply, TInstance extends FastifyInstance = FastifyInstance, > extends AbstractHttpAdapter { protected readonly logger = new Logger(FastifyAdapter.name); declare protected readonly instance: TInstance; protected _pathPrefix?: string; private _isParserRegistered: boolean; private onRequestHook?: ( request: TRequest, reply: TReply, done: (err?: Error) => void, ) => void | Promise; private onResponseHook?: ( request: TRequest, reply: TReply, done: (err?: Error) => void, ) => void | Promise; 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(); return { get(version: string | Array) { if (Array.isArray(version)) { return versions.get(version.find(v => versions.has(v))!) || null; } return versions.get(version) || null; }, set(versionOrVersions: string | Array, store: unknown) { const storeVersionConstraint = (version: string) => versions.set(version, store); if (Array.isArray(versionOrVersions)) versionOrVersions.forEach(storeVersionConstraint); else storeVersionConstraint(versionOrVersions); }, del(version: string | Array) { 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 | FastifyHttp2SecureOptions | FastifyHttpsOptions | FastifyHttpOptions | FastifyAdapterBaseOptions, ) { 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, ) { this.onRequestHook = hook; } public setOnResponseHook( hook: ( request: TRequest, reply: TReply, done: (err?: Error) => void, ) => void | Promise, ) { 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; 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 { if (!this.versioningOptions) { this.versioningOptions = versioningOptions; } const versionedRoute = handler as VersionedRoute; 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[0]) { return this.instance.setErrorHandler(handler); } public setNotFoundHandler(handler: Function) { return this.instance.setNotFoundHandler(handler as any); } public getHttpServer(): T { return this.instance.server as unknown as T; } public getInstance(): T { return this.instance as unknown as T; } public register< TRegister extends Parameters< FastifyRegister> >, >(plugin: TRegister['0'], opts?: TRegister['1']) { return (this.instance.register as any)(plugin, opts); } public inject(): LightMyRequestChain; public inject(opts: InjectOptions | string): Promise; public inject( opts?: InjectOptions | string, ): LightMyRequestChain | Promise { 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 async useStaticAssets(options: FastifyStaticOptions) { return this.register( await loadPackage( '@fastify/static', 'FastifyAdapter.useStaticAssets()', () => import('@fastify/static'), ), options, ); } public async setViewEngine(options: FastifyViewOptions | string) { if (isString(options)) { new Logger('FastifyAdapter').error( "setViewEngine() doesn't support a string argument.", ); process.exit(1); } return this.register( await loadPackage( '@fastify/view', 'FastifyAdapter.setViewEngine()', () => import('@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[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, ) { const parserOptions = { ...(options || {}), parseAs: 'buffer' as const, }; this.getInstance().addContentTypeParser( type, parserOptions, ( req: RawBodyRequest>, 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 = safeDecodeURI(pathname).path; 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); } public mapException(error: unknown): unknown { if (this.isHttpFastifyError(error)) { return new HttpException(error.message, error.statusCode); } return error; } private isHttpFastifyError( error: any, ): error is Error & { statusCode: number } { // 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' ); } protected registerWithPrefix( factory: | FastifyPluginCallback | FastifyPluginAsync | Promise<{ default: FastifyPluginCallback }> | Promise<{ default: FastifyPluginAsync }>, 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; 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[0]); } private getRequestOriginalUrl(rawRequest: TRawRequest) { return rawRequest.originalUrl || rawRequest.url!; } private injectRouteOptions( routerMethodKey: Uppercase, ...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 & RouteShorthandOptions = { 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); } }