UNPKG

@hapi/hapi

Version:

HTTP Server framework

755 lines (544 loc) 22.3 kB
'use strict'; const Querystring = require('querystring'); const Url = require('url'); const Boom = require('@hapi/boom'); const Bounce = require('@hapi/bounce'); const Hoek = require('@hapi/hoek'); const Podium = require('@hapi/podium'); const Cors = require('./cors'); const Toolkit = require('./toolkit'); const Transmit = require('./transmit'); const internals = { events: Podium.validate(['finish', { name: 'peek', spread: true }, 'disconnect']), reserved: ['server', 'url', 'query', 'path', 'method', 'mime', 'setUrl', 'setMethod', 'headers', 'id', 'app', 'plugins', 'route', 'auth', 'pre', 'preResponses', 'info', 'isInjected', 'orig', 'params', 'paramsArray', 'payload', 'state', 'response', 'raw', 'domain', 'log', 'logs', 'generateResponse'] }; exports = module.exports = internals.Request = class { constructor(server, req, res, options) { this._allowInternals = !!options.allowInternals; this._closed = false; // true once the response has closed (esp. early) and will not emit any more events this._core = server._core; this._entity = null; // Entity information set via h.entity() this._eventContext = { request: this }; this._events = null; // Assigned an emitter when request.events is accessed this._expectContinue = !!options.expectContinue; this._isInjected = !!options.isInjected; this._isPayloadPending = !!(req.headers['content-length'] || req.headers['transfer-encoding']); // Changes to false when incoming payload fully processed this._isReplied = false; // true when response processing started this._route = this._core.router.specials.notFound.route; // Used prior to routing (only settings are used, not the handler) this._serverTimeoutId = null; this._states = {}; this._url = null; this._urlError = null; this.app = options.app ? Object.assign({}, options.app) : {}; // Place for application-specific state without conflicts with hapi, should not be used by plugins (shallow cloned) this.headers = req.headers; this.logs = []; this.method = req.method.toLowerCase(); this.mime = null; this.orig = {}; this.params = null; this.paramsArray = null; // Array of path parameters in path order this.path = null; this.payload = undefined; this.plugins = options.plugins ? Object.assign({}, options.plugins) : {}; // Place for plugins to store state without conflicts with hapi, should be namespaced using plugin name (shallow cloned) this.pre = {}; // Pre raw values this.preResponses = {}; // Pre response values this.raw = { req, res }; this.response = null; this.route = this._route.public; this.query = null; this.server = server; this.state = null; this.info = new internals.Info(this); this.auth = { isAuthenticated: false, isAuthorized: false, isInjected: options.auth ? true : false, [internals.Request.symbols.authPayload]: options.auth?.payload ?? true, credentials: options.auth?.credentials ?? null, // Special keys: 'app', 'user', 'scope' artifacts: options.auth?.artifacts ?? null, // Scheme-specific artifacts strategy: options.auth?.strategy ?? null, mode: null, error: null }; // Parse request url this._initializeUrl(); } static generate(server, req, res, options) { const request = new server._core.Request(server, req, res, options); // Decorate if (server._core.decorations.requestApply) { for (const [property, assignment] of server._core.decorations.requestApply.entries()) { request[property] = assignment(request); } } request._listen(); return request; } get events() { if (!this._events) { this._events = new Podium.Podium(internals.events); } return this._events; } get isInjected() { return this._isInjected; } get url() { if (this._urlError) { return null; } if (this._url) { return this._url; } return this._parseUrl(this.raw.req.url, this._core.settings.router); } _initializeUrl() { try { this._setUrl(this.raw.req.url, this._core.settings.router.stripTrailingSlash, { fast: true }); } catch (err) { this.path = this.raw.req.url; this.query = {}; this._urlError = Boom.boomify(err, { statusCode: 400, override: false }); } } setUrl(url, stripTrailingSlash) { Hoek.assert(this.params === null, 'Cannot change request URL after routing'); if (url instanceof Url.URL) { url = url.href; } Hoek.assert(typeof url === 'string', 'Url must be a string or URL object'); this._setUrl(url, stripTrailingSlash, { fast: false }); } _setUrl(source, stripTrailingSlash, { fast }) { const url = this._parseUrl(source, { stripTrailingSlash, _fast: fast }); this.query = this._parseQuery(url.searchParams); this.path = url.pathname; } _parseUrl(source, options) { if (source[0] === '/') { // Relative URL if (options._fast) { const url = { pathname: source, searchParams: '' }; const q = source.indexOf('?'); const h = source.indexOf('#'); if (q !== -1 && (h === -1 || q < h)) { url.pathname = source.slice(0, q); const query = h === -1 ? source.slice(q + 1) : source.slice(q + 1, h); url.searchParams = Querystring.parse(query); } else { url.pathname = h === -1 ? source : source.slice(0, h); } this._normalizePath(url, options); return url; } this._url = new Url.URL(`${this._core.info.protocol}://${this.info.host || `${this._core.info.host}:${this._core.info.port}`}${source}`); } else { // Absolute URI (proxied) this._url = new Url.URL(source); this.info.hostname = this._url.hostname; this.info.host = this._url.host; } this._normalizePath(this._url, options); this._urlError = null; return this._url; } _normalizePath(url, options) { let path = this._core.router.normalize(url.pathname); if (options.stripTrailingSlash && path.length > 1 && path[path.length - 1] === '/') { path = path.slice(0, -1); } url.pathname = path; } _parseQuery(searchParams) { let query = Object.create(null); // Flatten map if (searchParams instanceof Url.URLSearchParams) { for (let [key, value] of searchParams) { const entry = query[key]; if (entry !== undefined) { value = [].concat(entry, value); } query[key] = value; } } else { query = Object.assign(query, searchParams); } // Custom parser const parser = this._core.settings.query.parser; if (parser) { query = parser(query); if (!query || typeof query !== 'object') { throw Boom.badImplementation('Parsed query must be an object'); } } return query; } setMethod(method) { Hoek.assert(this.params === null, 'Cannot change request method after routing'); Hoek.assert(method && typeof method === 'string', 'Missing method'); this.method = method.toLowerCase(); } active() { return !!this._eventContext.request; } async _execute() { this.info.acceptEncoding = this._core.compression.accept(this); try { await this._onRequest(); } catch (err) { Bounce.rethrow(err, 'system'); return this._reply(err); } this._lookup(); this._setTimeouts(); await this._lifecycle(); this._reply(); } async _onRequest() { // onRequest (can change request method and url) if (this._core.extensions.route.onRequest.nodes) { const response = await this._invoke(this._core.extensions.route.onRequest); if (response) { if (!internals.skip(response)) { throw Boom.badImplementation('onRequest extension methods must return an error, a takeover response, or a continue signal'); } throw response; } } // Validate path if (this._urlError) { throw this._urlError; } } _listen() { if (this._isPayloadPending) { this.raw.req.on('end', internals.event.bind(this.raw.req, this._eventContext, 'end')); } this.raw.res.on('close', internals.event.bind(this.raw.res, this._eventContext, 'close')); this.raw.req.on('error', internals.event.bind(this.raw.req, this._eventContext, 'error')); this.raw.req.on('aborted', internals.event.bind(this.raw.req, this._eventContext, 'abort')); this.raw.res.once('close', internals.closed.bind(this.raw.res, this)); } _lookup() { const match = this._core.router.route(this.method, this.path, this.info.hostname); if (!match.route.settings.isInternal || this._allowInternals) { this._route = match.route; this.route = this._route.public; } this.params = match.params ?? {}; this.paramsArray = match.paramsArray ?? []; if (this.route.settings.cors) { this.info.cors = { isOriginMatch: Cors.matchOrigin(this.headers.origin, this.route.settings.cors) }; } } _setTimeouts() { if (this.raw.req.socket && this.route.settings.timeout.socket !== undefined) { this.raw.req.socket.setTimeout(this.route.settings.timeout.socket || 0); // Value can be false or positive } let serverTimeout = this.route.settings.timeout.server; if (!serverTimeout) { return; } const elapsed = Date.now() - this.info.received; serverTimeout = Math.floor(serverTimeout - elapsed); // Calculate the timeout from when the request was constructed if (serverTimeout <= 0) { internals.timeoutReply(this, serverTimeout); return; } this._serverTimeoutId = setTimeout(internals.timeoutReply, serverTimeout, this, serverTimeout); } async _lifecycle() { for (const func of this._route._cycle) { if (this._isReplied) { return; } try { var response = await (typeof func === 'function' ? func(this) : this._invoke(func)); } catch (err) { Bounce.rethrow(err, 'system'); response = this._core.Response.wrap(err, this); } if (!response || response === Toolkit.symbols.continue) { // Continue continue; } if (!internals.skip(response)) { response = Boom.badImplementation('Lifecycle methods called before the handler can only return an error, a takeover response, or a continue signal'); } this._setResponse(response); return; } } async _invoke(event, options = {}) { for (const ext of event.nodes) { const realm = ext.realm; const bind = ext.bind ?? realm.settings.bind; const response = await this._core.toolkit.execute(ext.func, this, { bind, realm, timeout: ext.timeout, name: event.type, ignoreResponse: options.ignoreResponse }); if (options.ignoreResponse) { if (Boom.isBoom(response)) { this._log(['ext', 'error'], response); } continue; } if (response === Toolkit.symbols.continue) { continue; } if (internals.skip(response) || this.response === null) { return response; } this._setResponse(response); } } async _reply(exit) { if (this._isReplied) { // Prevent any future responses to this request return; } this._isReplied = true; if (this._serverTimeoutId) { clearTimeout(this._serverTimeoutId); } if (exit) { // Can be a valid response or error (if returned from an ext, already handled because this.response is also set) this._setResponse(this._core.Response.wrap(exit, this)); // Wrap to ensure any object thrown is always a valid Boom or Response object } if (!this._eventContext.request) { this._finalize(); return; } if (typeof this.response === 'symbol') { // close or abandon this._abort(); return; } await this._postCycle(); if (!this._eventContext.request || typeof this.response === 'symbol') { // close or abandon this._abort(); return; } await Transmit.send(this); this._finalize(); } async _postCycle() { for (const func of this._route._postCycle) { if (!this._eventContext.request) { return; } try { var response = await (typeof func === 'function' ? func(this) : this._invoke(func)); } catch (err) { Bounce.rethrow(err, 'system'); response = this._core.Response.wrap(err, this); } if (response && response !== Toolkit.symbols.continue) { // Continue this._setResponse(response); } } } _abort() { if (this.response === Toolkit.symbols.close) { this.raw.res.end(); // End the response in case it wasn't already closed } this._finalize(); } _finalize() { this._eventContext.request = null; // Disable req events if (this.response._close) { if (this.response.statusCode === 500 && this.response._error) { const tags = this.response._error.isDeveloperError ? ['internal', 'implementation', 'error'] : ['internal', 'error']; this._log(tags, this.response._error, 'error'); } this.response._close(); } this.info.completed = Date.now(); this._core.events.emit('response', this); if (this._route._extensions.onPostResponse.nodes) { this._invoke(this._route._extensions.onPostResponse, { ignoreResponse: true }); } } _setResponse(response) { if (this.response && !this.response.isBoom && this.response !== response && this.response.source !== response.source) { this.response._close?.(); } if (this.info.completed) { response._close?.(); return; } this.response = response; } _setState(name, value, options) { const state = { name, value }; if (options) { Hoek.assert(!options.autoValue, 'Cannot set autoValue directly in a response'); state.options = Hoek.clone(options); } this._states[name] = state; } _clearState(name, options = {}) { const state = { name }; state.options = Hoek.clone(options); state.options.ttl = 0; this._states[name] = state; } _tap() { if (!this._events) { return null; } if (this._events.hasListeners('peek') || this._events.hasListeners('finish')) { return new this._core.Response.Peek(this._events); } return null; } log(tags, data) { return this._log(tags, data, 'app'); } _log(tags, data, channel = 'internal') { if (!this._core.events.hasListeners('request') && !this.route.settings.log.collect) { return; } if (!Array.isArray(tags)) { tags = [tags]; } const timestamp = Date.now(); const field = data instanceof Error ? 'error' : 'data'; let event = [this, { request: this.info.id, timestamp, tags, [field]: data, channel }]; if (typeof data === 'function') { event = () => [this, { request: this.info.id, timestamp, tags, data: data(), channel }]; } if (this.route.settings.log.collect) { if (typeof data === 'function') { event = event(); } this.logs.push(event[1]); } this._core.events.emit({ name: 'request', channel, tags }, event); } generateResponse(source, options) { return new this._core.Response(source, this, options); } }; internals.Request.reserved = internals.reserved; internals.Request.symbols = { authPayload: Symbol('auth.payload') }; internals.Info = class { constructor(request) { this._request = request; const req = request.raw.req; const host = req.headers.host ? req.headers.host.trim() : ''; const received = Date.now(); this.received = received; this.referrer = req.headers.referrer || req.headers.referer || ''; this.host = host; this.hostname = host.split(':')[0]; this.id = `${received}:${request._core.info.id}:${request._core._counter()}`; this._remoteAddress = null; this._remotePort = null; // Assigned later this.acceptEncoding = null; this.cors = null; this.responded = 0; this.completed = 0; if (request._core.settings.info.remote) { this.remoteAddress; this.remotePort; } } get remoteAddress() { if (!this._remoteAddress) { const ipv6Prefix = '::ffff:'; const socketAddress = this._request.raw.req.socket.remoteAddress; if (socketAddress && socketAddress.startsWith(ipv6Prefix) && socketAddress.includes('.', ipv6Prefix.length)) { // Normalize IPv4-mapped IPv6 address, e.g. ::ffff:127.0.0.1 -> 127.0.0.1 this._remoteAddress = socketAddress.slice(ipv6Prefix.length); } else { this._remoteAddress = socketAddress; } } return this._remoteAddress; } get remotePort() { if (this._remotePort === null) { this._remotePort = this._request.raw.req.socket.remotePort || ''; } return this._remotePort; } toJSON() { return { acceptEncoding: this.acceptEncoding, completed: this.completed, cors: this.cors, host: this.host, hostname: this.hostname, id: this.id, received: this.received, referrer: this.referrer, remoteAddress: this.remoteAddress, remotePort: this.remotePort, responded: this.responded }; } }; internals.closed = function (request) { request._closed = true; }; internals.event = function ({ request }, event, err) { if (!request) { return; } request._isPayloadPending = false; if (event === 'close' && request.raw.res.writableEnded) { return; } if (event === 'end') { return; } request._log(err ? ['request', 'error'] : ['request', 'error', event], err); if (event === 'error') { return; } request._eventContext.request = null; if (event === 'abort') { // Calling _reply() means that the abort is applied immediately, unless the response has already // called _reply(), in which case this call is ignored and the transmit logic is responsible for // handling the abort. request._reply(new Boom.Boom('Request aborted', { statusCode: request.route.settings.response.disconnectStatusCode, data: request.response })); if (request._events) { request._events.emit('disconnect'); } } }; internals.timeoutReply = function (request, timeout) { const elapsed = Date.now() - request.info.received; request._log(['request', 'server', 'timeout', 'error'], { timeout, elapsed }); request._reply(Boom.serverUnavailable()); }; internals.skip = function (response) { return response.isBoom || response._takeover || typeof response === 'symbol'; };