UNPKG

@hapi/hapi

Version:

HTTP Server framework

719 lines (539 loc) 22.2 kB
'use strict'; const Http = require('http'); const Https = require('https'); const Os = require('os'); const Path = require('path'); const Boom = require('@hapi/boom'); const Bounce = require('@hapi/bounce'); const Call = require('@hapi/call'); const Catbox = require('@hapi/catbox'); const { Engine: CatboxMemory } = require('@hapi/catbox-memory'); const { Heavy } = require('@hapi/heavy'); const Hoek = require('@hapi/hoek'); const { Mimos } = require('@hapi/mimos'); const Podium = require('@hapi/podium'); const Statehood = require('@hapi/statehood'); const Auth = require('./auth'); const Compression = require('./compression'); const Config = require('./config'); const Cors = require('./cors'); const Ext = require('./ext'); const Methods = require('./methods'); const Request = require('./request'); const Response = require('./response'); const Route = require('./route'); const Toolkit = require('./toolkit'); const Validation = require('./validation'); const internals = { counter: { min: 10000, max: 99999 }, events: [ { name: 'cachePolicy', spread: true }, { name: 'log', channels: ['app', 'internal'], tags: true }, { name: 'request', channels: ['app', 'internal', 'error'], tags: true, spread: true }, 'response', 'route', 'start', 'closing', 'stop' ], badRequestResponse: Buffer.from('HTTP/1.1 400 Bad Request\r\n\r\n', 'ascii') }; exports = module.exports = internals.Core = class { actives = new WeakMap(); // Active requests being processed app = {}; auth = new Auth(this); caches = new Map(); // Cache clients compression = new Compression(); controlled = null; // Other servers linked to the phases of this server dependencies = []; // Plugin dependencies events = new Podium.Podium(internals.events); heavy = null; info = null; instances = new Set(); listener = null; methods = new Methods(this); // Server methods mime = null; onConnection = null; // Used to remove event listener on stop phase = 'stopped'; // 'stopped', 'initializing', 'initialized', 'starting', 'started', 'stopping', 'invalid' plugins = {}; // Exposed plugin properties by name registrations = {}; // Tracks plugin for dependency validation { name -> { version } } registring = 0; // > 0 while register() is waiting for plugin callbacks Request = class extends Request { }; Response = class extends Response { }; requestCounter = { value: internals.counter.min, min: internals.counter.min, max: internals.counter.max }; root = null; router = null; settings = null; sockets = null; // Track open sockets for graceful shutdown started = false; states = null; toolkit = new Toolkit.Manager(); type = null; validator = null; extensionsSeq = 0; // Used to keep absolute order of extensions based on the order added across locations extensions = { server: { onPreStart: new Ext('onPreStart', this), onPostStart: new Ext('onPostStart', this), onPreStop: new Ext('onPreStop', this), onPostStop: new Ext('onPostStop', this) }, route: { onRequest: new Ext('onRequest', this), onPreAuth: new Ext('onPreAuth', this), onCredentials: new Ext('onCredentials', this), onPostAuth: new Ext('onPostAuth', this), onPreHandler: new Ext('onPreHandler', this), onPostHandler: new Ext('onPostHandler', this), onPreResponse: new Ext('onPreResponse', this), onPostResponse: new Ext('onPostResponse', this) } }; decorations = { handler: new Map(), request: new Map(), response: new Map(), server: new Map(), toolkit: new Map(), requestApply: null, public: { handler: [], request: [], response: [], server: [], toolkit: [] } }; constructor(options) { const { settings, type } = internals.setup(options); this.settings = settings; this.type = type; this.heavy = new Heavy(this.settings.load); this.mime = new Mimos(this.settings.mime); this.router = new Call.Router(this.settings.router); this.states = new Statehood.Definitions(this.settings.state); this._debug(); this._initializeCache(); if (this.settings.routes.validate.validator) { this.validator = Validation.validator(this.settings.routes.validate.validator); } this.listener = this._createListener(); this._initializeListener(); this.info = this._info(); } _debug() { const debug = this.settings.debug; if (!debug) { return; } // Subscribe to server log events const method = (event) => { const data = event.error ?? event.data; console.error('Debug:', event.tags.join(', '), data ? '\n ' + (data.stack ?? (typeof data === 'object' ? Hoek.stringify(data) : data)) : ''); }; if (debug.log) { const filter = debug.log.some((tag) => tag === '*') ? undefined : debug.log; this.events.on({ name: 'log', filter }, method); } if (debug.request) { const filter = debug.request.some((tag) => tag === '*') ? undefined : debug.request; this.events.on({ name: 'request', filter }, (request, event) => method(event)); } } _initializeCache() { if (this.settings.cache) { this._createCache(this.settings.cache); } if (!this.caches.has('_default')) { this._createCache([{ provider: CatboxMemory }]); // Defaults to memory-based } } _info() { const now = Date.now(); const protocol = this.type === 'tcp' ? (this.settings.tls ? 'https' : 'http') : this.type; const host = this.settings.host || Os.hostname() || 'localhost'; const port = this.settings.port; const info = { created: now, started: 0, host, port, protocol, id: Os.hostname() + ':' + process.pid + ':' + now.toString(36), uri: this.settings.uri ?? (protocol + ':' + (this.type === 'tcp' ? '//' + host + (port ? ':' + port : '') : port)) }; return info; } _counter() { const next = ++this.requestCounter.value; if (this.requestCounter.value > this.requestCounter.max) { this.requestCounter.value = this.requestCounter.min; } return next - 1; } _createCache(configs) { Hoek.assert(this.phase !== 'initializing', 'Cannot provision server cache while server is initializing'); configs = Config.apply('cache', configs); const added = []; for (let config of configs) { // <function> // { provider: <function> } // { provider: { constructor: <function>, options } } // { engine } if (typeof config === 'function') { config = { provider: { constructor: config } }; } const name = config.name ?? '_default'; Hoek.assert(!this.caches.has(name), 'Cannot configure the same cache more than once: ', name === '_default' ? 'default cache' : name); let client = null; if (config.provider) { let provider = config.provider; if (typeof provider === 'function') { provider = { constructor: provider }; } client = new Catbox.Client(provider.constructor, provider.options ?? { partition: 'hapi-cache' }); } else { client = new Catbox.Client(config.engine); } this.caches.set(name, { client, segments: {}, shared: config.shared ?? false }); added.push(client); } return added; } registerServer(server) { if (!this.root) { this.root = server; this._defaultRoutes(); } this.instances.add(server); } async _start() { if (this.phase === 'initialized' || this.phase === 'started') { this._validateDeps(); } if (this.phase === 'started') { return; } if (this.phase !== 'stopped' && this.phase !== 'initialized') { throw new Error('Cannot start server while it is in ' + this.phase + ' phase'); } if (this.phase !== 'initialized') { await this._initialize(); } this.phase = 'starting'; this.started = true; this.info.started = Date.now(); try { await this._listen(); } catch (err) { this.started = false; this.phase = 'invalid'; throw err; } this.phase = 'started'; this.events.emit('start'); try { if (this.controlled) { await Promise.all(this.controlled.map((control) => control.start())); } await this._invoke('onPostStart'); } catch (err) { this.phase = 'invalid'; throw err; } } _listen() { return new Promise((resolve, reject) => { if (!this.settings.autoListen) { resolve(); return; } const onError = (err) => { reject(err); return; }; this.listener.once('error', onError); const finalize = () => { this.listener.removeListener('error', onError); resolve(); return; }; if (this.type !== 'tcp') { this.listener.listen(this.settings.port, finalize); } else { // Default is the unspecified address, :: if IPv6 is available or otherwise the IPv4 address 0.0.0.0 const address = this.settings.address || this.settings.host || null; this.listener.listen(this.settings.port, address, finalize); } }); } async _initialize() { if (this.registring) { throw new Error('Cannot start server before plugins finished registration'); } if (this.phase === 'initialized') { return; } if (this.phase !== 'stopped') { throw new Error('Cannot initialize server while it is in ' + this.phase + ' phase'); } this._validateDeps(); this.phase = 'initializing'; // Start cache try { const caches = []; this.caches.forEach((cache) => caches.push(cache.client.start())); await Promise.all(caches); await this._invoke('onPreStart'); this.heavy.start(); this.phase = 'initialized'; if (this.controlled) { await Promise.all(this.controlled.map((control) => control.initialize())); } } catch (err) { this.phase = 'invalid'; throw err; } } _validateDeps() { for (const { deps, plugin } of this.dependencies) { for (const dep in deps) { const version = deps[dep]; Hoek.assert(this.registrations[dep], 'Plugin', plugin, 'missing dependency', dep); Hoek.assert(version === '*' || Config.versionMatch(this.registrations[dep].version, version), 'Plugin', plugin, 'requires', dep, 'version', version, 'but found', this.registrations[dep].version); } } } async _stop(options = {}) { options.timeout = options.timeout ?? 5000; // Default timeout to 5 seconds if (['stopped', 'initialized', 'started', 'invalid'].indexOf(this.phase) === -1) { throw new Error('Cannot stop server while in ' + this.phase + ' phase'); } this.phase = 'stopping'; try { await this._invoke('onPreStop'); if (this.started) { this.started = false; this.info.started = 0; await this._unlisten(options.timeout); } const caches = []; this.caches.forEach((cache) => caches.push(cache.client.stop())); await Promise.all(caches); this.events.emit('stop'); this.heavy.stop(); if (this.controlled) { await Promise.all(this.controlled.map((control) => control.stop(options))); } await this._invoke('onPostStop'); this.phase = 'stopped'; } catch (err) { this.phase = 'invalid'; throw err; } } _unlisten(timeout) { let timeoutId = null; if (this.settings.operations.cleanStop) { // Set connections timeout const destroy = () => { for (const connection of this.sockets) { connection.destroy(); } this.sockets.clear(); }; timeoutId = setTimeout(destroy, timeout); // Tell idle keep-alive connections to close for (const connection of this.sockets) { if (!this.actives.has(connection)) { connection.end(); } } } // Close connection return new Promise((resolve) => { this.listener.close(() => { if (this.settings.operations.cleanStop) { this.listener.removeListener(this.settings.tls ? 'secureConnection' : 'connection', this.onConnection); clearTimeout(timeoutId); } this._initializeListener(); resolve(); }); this.events.emit('closing'); }); } async _invoke(type) { const exts = this.extensions.server[type]; if (!exts.nodes) { return; } // Execute extensions for (const ext of exts.nodes) { const bind = ext.bind ?? ext.realm.settings.bind; const operation = ext.func.call(bind, ext.server, bind); await Toolkit.timed(operation, { timeout: ext.timeout, name: type }); } } _defaultRoutes() { this.router.special('notFound', new Route({ method: '_special', path: '/{p*}', handler: internals.notFound }, this.root, { special: true })); this.router.special('badRequest', new Route({ method: '_special', path: '/{p*}', handler: internals.badRequest }, this.root, { special: true })); if (this.settings.routes.cors) { Cors.handler(this.root); } } _dispatch(options = {}) { return (req, res) => { // Create request const request = Request.generate(this.root, req, res, options); // Track socket request processing state if (this.settings.operations.cleanStop && req.socket) { this.actives.set(req.socket, request); const env = { core: this, req }; res.on('finish', internals.onFinish.bind(res, env)); } // Check load if (this.settings.load.sampleInterval) { try { this.heavy.check(); } catch (err) { Bounce.rethrow(err, 'system'); this._log(['load'], this.heavy.load); request._reply(err); return; } } request._execute(); }; } _createListener() { const listener = this.settings.listener ?? (this.settings.tls ? Https.createServer(this.settings.tls) : Http.createServer()); listener.on('request', this._dispatch()); listener.on('checkContinue', this._dispatch({ expectContinue: true })); listener.on('clientError', (err, socket) => { this._log(['connection', 'client', 'error'], err); if (socket.readable) { const request = this.settings.operations.cleanStop && this.actives.get(socket); if (request) { // If a request is available, it means that the connection and parsing has progressed far enough to have created the request. if (err.code === 'HPE_INVALID_METHOD') { // This parser error is for a pipelined request. Schedule destroy once current request is done. request.raw.res.once('close', () => { if (socket.readable) { socket.end(internals.badRequestResponse); } else { socket.destroy(err); } }); return; } const error = Boom.badRequest(); error.output.headers = { connection: 'close' }; request._reply(error); } else { socket.end(internals.badRequestResponse); } } else { socket.destroy(err); } }); return listener; } _initializeListener() { this.listener.once('listening', () => { // Update the address, port, and uri with active values if (this.type === 'tcp') { const address = this.listener.address(); this.info.address = address.address; this.info.port = address.port; this.info.uri = this.settings.uri ?? this.info.protocol + '://' + this.info.host + ':' + this.info.port; } if (this.settings.operations.cleanStop) { this.sockets = new Set(); const self = this; const onClose = function () { // 'this' is bound to the emitter self.sockets.delete(this); }; this.onConnection = (connection) => { this.sockets.add(connection); connection.on('close', onClose); }; this.listener.on(this.settings.tls ? 'secureConnection' : 'connection', this.onConnection); } }); } _cachePolicy(options, _segment, realm) { options = Config.apply('cachePolicy', options); const plugin = realm?.plugin; const segment = options.segment ?? _segment ?? (plugin ? `!${plugin}` : ''); Hoek.assert(segment, 'Missing cache segment name'); const cacheName = options.cache ?? '_default'; const cache = this.caches.get(cacheName); Hoek.assert(cache, 'Unknown cache', cacheName); Hoek.assert(!cache.segments[segment] || cache.shared || options.shared, 'Cannot provision the same cache segment more than once'); cache.segments[segment] = true; const policy = new Catbox.Policy(options, cache.client, segment); this.events.emit('cachePolicy', [policy, options.cache, segment]); return policy; } log(tags, data) { return this._log(tags, data, 'app'); } _log(tags, data, channel = 'internal') { if (!this.events.hasListeners('log')) { return; } if (!Array.isArray(tags)) { tags = [tags]; } const timestamp = Date.now(); const field = data instanceof Error ? 'error' : 'data'; let event = { timestamp, tags, [field]: data, channel }; if (typeof data === 'function') { event = () => ({ timestamp, tags, data: data(), channel }); } this.events.emit({ name: 'log', tags, channel }, event); } }; internals.setup = function (options = {}) { let settings = Hoek.clone(options, { shallow: ['cache', 'listener', 'routes.bind'] }); settings.app = settings.app ?? {}; settings.routes = Config.enable(settings.routes); settings = Config.apply('server', settings); if (settings.port === undefined) { settings.port = 0; } const type = (typeof settings.port === 'string' ? 'socket' : 'tcp'); if (type === 'socket') { settings.port = (settings.port.indexOf('/') !== -1 ? Path.resolve(settings.port) : settings.port.toLowerCase()); } if (settings.autoListen === undefined) { settings.autoListen = true; } Hoek.assert(settings.autoListen || !settings.port, 'Cannot specify port when autoListen is false'); Hoek.assert(settings.autoListen || !settings.address, 'Cannot specify address when autoListen is false'); return { settings, type }; }; internals.notFound = function () { throw Boom.notFound(); }; internals.badRequest = function () { throw Boom.badRequest(); }; internals.onFinish = function (env) { const { core, req } = env; core.actives.delete(req.socket); if (!core.started) { req.socket.end(); } };