UNPKG

@nestjs/platform-express

Version:

Nest - modern, fast, powerful node.js web framework (@platform-express)

488 lines (487 loc) • 20.1 kB
import { BadRequestException, HttpStatus, InternalServerErrorException, Logger, StreamableFile, VERSION_NEUTRAL, VersioningType, } from '@nestjs/common'; import cors from 'cors'; import express from 'express'; import * as http from 'http'; import * as https from 'https'; import { pathToRegexp } from 'path-to-regexp'; import { finished } from 'stream'; import { getBodyParserOptions } from './utils/get-body-parser-options.util.js'; import { getMediaTypeVersion } from './utils/get-media-type-version.util.js'; import { addLeadingSlash, isFunction, isNil, isObject, isString, isUndefined, stripEndSlash, } from '@nestjs/common/internal'; import { AbstractHttpAdapter } from '@nestjs/core'; import { RouterMethodFactory, LegacyRouteConverter, } from '@nestjs/core/internal'; // 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')); } /** * @publicApi */ export class ExpressAdapter extends AbstractHttpAdapter { routerMethodFactory = new RouterMethodFactory(); logger = new Logger(ExpressAdapter.name); openConnections = new Set(); registeredPrefixes = new Set(); isShuttingDown = false; onRequestHook; onResponseHook; constructor(instance) { super(instance || express()); this.instance.use((req, res, next) => { if (this.onResponseHook) { res.on('finish', async () => { try { await this.onResponseHook.apply(this, [req, res]); } catch (err) { this.logger.error(err); } }); } if (this.onRequestHook) { this.runRequestHook(req, res, next); } else { next(); } }); } runRequestHook(req, res, next) { let completed = false; const doneOnce = (err) => { if (completed) { if (err) { this.logger.error(err); } return; } completed = true; next(err); }; try { // Not returned to Express: rejections share the once-guard with done() const result = this.onRequestHook.call(this, req, res, doneOnce); if (result && typeof result.then === 'function') { result.then(undefined, err => doneOnce(err || new Error('Request hook failed'))); } } catch (err) { doneOnce(err || new Error('Request hook failed')); } } /** * Registers a request hook. Call `done()` to continue processing the request; * resolving the returned promise does not continue it. Only the first * completion is forwarded to Express. Errors after `done()` are logged * without advancing the request again. */ setOnRequestHook(onRequestHook) { this.onRequestHook = onRequestHook; } /** * Registers a hook to run after the response has been sent. * Thrown errors and rejected promises are logged. */ setOnResponseHook(onResponseHook) { this.onResponseHook = onResponseHook; } reply(response, body, statusCode) { if (!isNil(statusCode)) { response.status(statusCode); } if (isNil(body)) { return response.send(); } if (body instanceof StreamableFile) { this.applyStreamHeaders(response, body); const stream = body.getStream(); stream.once('error', err => { body.errorHandler(err, response); }); // pipe() leaves the source open when the client disconnects early, // also before reply() runs ("close" has already fired by then) finished(response, () => { if (!stream.readableEnded) { stream.destroy(); } }); return stream .pipe(response) .on('error', (err) => body.errorLogger(err)); } const responseContentType = response.getHeader('Content-Type'); if (typeof responseContentType === 'string' && !isJsonContentType(responseContentType) && body?.statusCode >= HttpStatus.BAD_REQUEST) { this.logger.warn("Content-Type doesn't match Reply body, you might need a custom ExceptionFilter for non-JSON responses"); response.setHeader('Content-Type', 'application/json'); } return isObject(body) ? response.json(body) : response.send(String(body)); } status(response, statusCode) { return response.status(statusCode); } end(response, message) { return response.end(message); } render(response, view, options) { return response.render(view, options); } redirect(response, statusCode, url) { return response.redirect(statusCode, url); } setErrorHandler(handler, prefix) { const normalizedPrefix = this.normalizePrefix(prefix); if (normalizedPrefix) { const router = express.Router(); router.use(handler); this.use(normalizedPrefix, router); } // Always mount the error handler at the root as well, so routes living // outside the global prefix (e.g. "setGlobalPrefix" exclusions or // root-mounted routes) still pass through the exception layer. return this.use(handler); } setNotFoundHandler(handler, prefix, excludedRoutes) { const normalizedPrefix = this.normalizePrefix(prefix); if (normalizedPrefix) { this.registeredPrefixes.add(normalizedPrefix); const router = express.Router(); router.all('*path', handler); this.use(normalizedPrefix, router); // Excluded routes live at the root, out of the prefix router's reach. // Mount the handler on each exact path, for every method, so a method // miss there (`POST /hello` with only `GET /hello`) still reaches the // exception layer. Not `use(path, router)`: a subtree mount would claim // a raw `/hello/live` added after `init()`; a wildcard exclusion covers // its subtree through its own pattern. for (const route of excludedRoutes ?? []) { this.instance.all(addLeadingSlash(route.path), handler); } return; } return this.use((req, res, next) => { // When multiple apps share this adapter, a non-prefixed app's 404 // handler may be registered before a prefixed app's routes. Skip // requests whose path belongs to another app's prefix so they can // reach the correct route handlers further in the stack. const path = req.originalUrl.split(/[?#]/)[0]; for (const registeredPrefix of this.registeredPrefixes) { // Match on full path segments only, so a prefix of "/api" does not // swallow unrelated paths such as "/apiary". if (path === registeredPrefix || path.startsWith(`${registeredPrefix}/`)) { return next(); } } return handler(req, res, next); }); } isHeadersSent(response) { return response.headersSent; } getHeader(response, name) { return response.get(name); } setHeader(response, name, value) { return response.set(name, value); } appendHeader(response, name, value) { return response.append(name, value); } normalizePath(path) { try { const convertedPath = LegacyRouteConverter.tryConvert(path); // Call "pathToRegexp" to trigger a TypeError if the path is invalid pathToRegexp(convertedPath); return convertedPath; } catch (e) { if (e instanceof TypeError) { LegacyRouteConverter.printError(path); } throw e; } } listen(port, ...args) { return this.httpServer.listen(port, ...args); } beforeClose() { this.isShuttingDown = true; } close() { this.isShuttingDown = true; this.closeOpenConnections(); if (!this.httpServer) { return undefined; } return new Promise(resolve => this.httpServer.close(resolve)); } set(...args) { return this.instance.set(...args); } enable(...args) { return this.instance.enable(...args); } disable(...args) { return this.instance.disable(...args); } engine(...args) { return this.instance.engine(...args); } useStaticAssets(path, options) { if (options && options.prefix) { return this.use(options.prefix, express.static(path, options)); } return this.use(express.static(path, options)); } setBaseViewsDir(path) { return this.set('views', path); } setViewEngine(engine) { return this.set('view engine', engine); } getRequestHostname(request) { return request.hostname; } getRequestMethod(request) { return request.method; } getRequestUrl(request) { return request.originalUrl; } enableCors(options) { return this.use(cors(options)); } createMiddlewareFactory(requestMethod) { return (path, callback) => { try { // The core marks an exact-match path with a trailing "$" (e.g. "/api$"). // Express 5 reads "$" literally and "use()" matches by prefix, so strip // the marker and register the path as an exact "all()" route instead. const isExactPath = path.endsWith('$'); const convertedPath = LegacyRouteConverter.tryConvert(isExactPath ? path.slice(0, -1) : path); let router = this.routerMethodFactory.get(this.instance, requestMethod); if (isExactPath && router === this.instance.use) { router = this.instance.all; } // Express routes are not strict, so "/api" also matches "/api/". That // path is left to the wildcard entry registered next to it (an optional // "{*path}" matches it), so the middleware doesn't run twice. const handler = isExactPath ? (req, res, next) => req.path.endsWith('/') ? next() : callback(req, res, next) : callback; return router.call(this.instance, convertedPath, handler); } catch (e) { if (e instanceof TypeError) { LegacyRouteConverter.printError(path); } throw e; } }; } initHttpServer(options) { const isHttpsEnabled = options && options.httpsOptions; if (isHttpsEnabled) { this.httpServer = https.createServer(options.httpsOptions, this.getInstance()); } else { this.httpServer = http.createServer(this.getInstance()); } if (options?.return503OnClosing) { this.instance.use((req, res, next) => { if (this.isShuttingDown) { res.set('Connection', 'close'); res.status(503).send('Service Unavailable'); } else { next(); } }); } if (options?.forceCloseConnections) { this.trackOpenConnections(); } } registerParserMiddleware(prefix, rawBody) { const bodyParserJsonOptions = getBodyParserOptions('json', rawBody); const bodyParserUrlencodedOptions = getBodyParserOptions('urlencoded', rawBody, { extended: true }); const parserMiddleware = { jsonParser: express.json(bodyParserJsonOptions), urlencodedParser: express.urlencoded(bodyParserUrlencodedOptions), }; Object.keys(parserMiddleware) .filter(parser => !this.isMiddlewareApplied(parser)) .forEach(parserKey => this.use(parserMiddleware[parserKey])); } useBodyParser(type, rawBody, options) { const parserOptions = getBodyParserOptions(type, rawBody, options); const parser = express[type](parserOptions); this.use(parser); return this; } setLocal(key, value) { this.instance.locals[key] = value; return this; } getType() { return 'express'; } isRouteOrderSensitive() { return true; } applyVersionFilter(handler, version, versioningOptions) { const callNextHandler = (req, res, next) => { if (!next) { throw new InternalServerErrorException('HTTP adapter does not support filtering on version'); } return next(); }; if (version === VERSION_NEUTRAL || // URL Versioning is done via the path, so the filter continues forward versioningOptions.type === VersioningType.URI) { const handlerForNoVersioning = (req, res, next) => handler(req, res, next); return handlerForNoVersioning; } // Custom Extractor Versioning Handler if (versioningOptions.type === VersioningType.CUSTOM) { const handlerForCustomVersioning = (req, res, next) => { const extractedVersion = versioningOptions.extractor(req); if (Array.isArray(version)) { if (Array.isArray(extractedVersion) && version.filter(v => extractedVersion.includes(v)).length) { return handler(req, res, next); } if (isString(extractedVersion) && version.includes(extractedVersion)) { return handler(req, res, next); } } else if (isString(version)) { // Known bug here - if there are multiple versions supported across separate // handlers/controllers, we can't select the highest matching handler. // Since this code is evaluated per-handler, then we can't see if the highest // specified version exists in a different handler. if (Array.isArray(extractedVersion) && extractedVersion.includes(version)) { return handler(req, res, next); } if (isString(extractedVersion) && version === extractedVersion) { return handler(req, res, next); } } return callNextHandler(req, res, next); }; return handlerForCustomVersioning; } // Media Type (Accept Header) Versioning Handler if (versioningOptions.type === VersioningType.MEDIA_TYPE) { const handlerForMediaTypeVersioning = (req, res, next) => { const MEDIA_TYPE_HEADER = 'Accept'; const acceptHeaderValue = req.headers?.[MEDIA_TYPE_HEADER] || req.headers?.[MEDIA_TYPE_HEADER.toLowerCase()]; const headerVersion = getMediaTypeVersion(acceptHeaderValue, versioningOptions.key); // No version was supplied if (isUndefined(headerVersion)) { if (Array.isArray(version)) { if (version.includes(VERSION_NEUTRAL)) { return handler(req, res, next); } } } else { if (Array.isArray(version)) { if (version.includes(headerVersion)) { return handler(req, res, next); } } else if (isString(version)) { if (version === headerVersion) { return handler(req, res, next); } } } return callNextHandler(req, res, next); }; return handlerForMediaTypeVersioning; } // Header Versioning Handler if (versioningOptions.type === VersioningType.HEADER) { const handlerForHeaderVersioning = (req, res, next) => { const customHeaderVersionParameter = req.headers?.[versioningOptions.header] || req.headers?.[versioningOptions.header.toLowerCase()]; // No version was supplied if (isUndefined(customHeaderVersionParameter)) { if (Array.isArray(version)) { if (version.includes(VERSION_NEUTRAL)) { return handler(req, res, next); } } } else { if (Array.isArray(version)) { if (version.includes(customHeaderVersionParameter)) { return handler(req, res, next); } } else if (isString(version)) { if (version === customHeaderVersionParameter) { return handler(req, res, next); } } } return callNextHandler(req, res, next); }; return handlerForHeaderVersioning; } throw new Error('Unsupported versioning options'); } mapException(error) { switch (true) { // SyntaxError is thrown by Express body-parser when given invalid JSON (#422, #430) // URIError is thrown by Express when given a path parameter with an invalid percentage // encoding, e.g. '%FF' (#8915) case error instanceof SyntaxError || error instanceof URIError: return new BadRequestException(error.message); default: return error; } } normalizePrefix(prefix) { return stripEndSlash(addLeadingSlash(prefix)); } trackOpenConnections() { this.httpServer.on('connection', (socket) => { this.openConnections.add(socket); socket.on('close', () => this.openConnections.delete(socket)); }); } closeOpenConnections() { for (const socket of this.openConnections) { socket.destroy(); this.openConnections.delete(socket); } } isMiddlewareApplied(name) { const app = this.getInstance(); return (!!app.router && !!app.router.stack && isFunction(app.router.stack.filter) && app.router.stack.some((layer) => layer && layer.handle && layer.handle.name === name)); } setHeaderIfNotExists(response, name, value) { if (value !== undefined && response.getHeader(name) === undefined) { const headerValue = Array.isArray(value) ? value.join(',') : value; response.setHeader(name, headerValue); } } applyStreamHeaders(response, streamable) { const headers = streamable.getHeaders(); this.setHeaderIfNotExists(response, 'Content-Type', headers.type); this.setHeaderIfNotExists(response, 'Content-Disposition', headers.disposition); this.setHeaderIfNotExists(response, 'Content-Length', headers.length); } }