diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100755 index 687c7d2bd..000000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,17 +0,0 @@ -# How to contribute -We welcome contributions from the community and are pleased to have them. Please follow this guide when logging issues or making code changes. - -## Logging Issues -All issues should be created using the [new issue form](https://github.com/hapijs/hapi/issues/new). Clearly describe the issue including steps to reproduce if there are any. Also, make sure to indicate the earliest version that has the issue being reported. - -## Finding help -Issues for this repository are intended for reporting bugs or requesting improvements. For questions about how to use hapi or the packages in the wider hapi universe, please post an issue in the [hapijs/discuss](https://github.com/hapijs/discuss) repository where somebody will be happy to help. - -## Patching Code -Code changes are welcome and should follow the guidelines below. - -* Fork the repository on GitHub. -* Fix the issue ensuring that your code follows the [style guide](https://github.com/hapijs/contrib/blob/master/Style.md). -* Add tests for your new code ensuring that you have 100% code coverage (we can help you reach 100% but will not merge without it). - * Run `npm run test-cov-html` to generate a report of test coverage -* [Pull requests](http://help.github.com/send-pull-requests/) should be made to the [master branch](https://github.com/hapijs/hapi/tree/master). diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100755 index 09d4b47ae..000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,47 +0,0 @@ -** DO NOT ERASE THESE INSTRUCTIONS WITHOUT READING THEM FIRST! ** - -Please read this entire template before posting any issue. If you ignore these instructions -and post an issue here that does not follow the instructions, your issue will be closed, -locked, and assigned the `missing discussion` label. - -#### Are you sure this is an issue with the hapi core module or are you just looking for some help? - -Issues should only be posted in this repository after you have been able to reproduce -them and confirm that they are a bug or incorrect/mising information in the API.md file. If you -are not sure, please first raise the question in the [discussion repository](https://github.com/hapijs/discuss). - -For new feature requests, please post your use case in the [discussion repository](https://github.com/hapijs/discuss) -first to find out if there is an existing way to accomplish it and get community feedback. If a new -feature is needed, post the request here with a link to the previous discussion. - -For all other questions, requests, help resolving an issue, or if you are not sure if this is -the right place, please do not open an issue here. Instead, open an issue in the [discussion repository](https://github.com/hapijs/discuss). - -If you have issues with the hapijs.com site, please open an issue [here](https://github.com/hapijs/hapijs.com/issues). - -#### Is this a security related issue? - -Do not open issues that might have security implications. It is critical that security related issues -are reported privately so we have time to address them before they become public knowledge. Please -email all security related issues to eran@hammer.io. - -#### What are you trying to achieve or the steps to reproduce? - -Describe your issue here, include as much detail as neccessary to reproduce the issue -or implement a missing functionality. If you are reporting a bug, you will get faster response -if you submit a pull request with a failing test. - -```js -// Wrap code in markdown source tags -``` - -#### What was the result you received? - -#### What did you expect? - -#### Context - -* *node version*: -* *hapi version*: -* *os*: -* *any other relevant information*: diff --git a/.github/workflows/ci-module.yml b/.github/workflows/ci-module.yml new file mode 100644 index 000000000..7229300a8 --- /dev/null +++ b/.github/workflows/ci-module.yml @@ -0,0 +1,15 @@ +name: ci + +on: + push: + branches: + - v21 + - master + pull_request: + workflow_dispatch: + +jobs: + test: + uses: hapijs/.github/.github/workflows/ci-module.yml@master + with: + min-node-version: 14 diff --git a/.gitignore b/.gitignore index eb305a1ef..8f679c9d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,13 @@ -.idea -*.iml -npm-debug.log -dump.rdb -node_modules -results.tap -results.xml -config.json -.DS_Store -*/.DS_Store -*/*/.DS_Store -._* -*/._* -*/*/._* +**/node_modules +**/package-lock.json + coverage.* -.settings -package-lock.json +**/.DS_Store +**/._* + +**/*.pem + +**/.vs +**/.vscode +**/.idea diff --git a/.npmignore b/.npmignore deleted file mode 100644 index adac8ad9c..000000000 --- a/.npmignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!lib/** -!.npmignore diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a3ed993fa..000000000 --- a/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -language: node_js - -node_js: - - "8" - - "9" - - "node" - -sudo: false diff --git a/API.md b/API.md index f2ac051bb..deea46d3b 100755 --- a/API.md +++ b/API.md @@ -1,250 +1,3 @@ -# v17.2.x API Reference - - - -- [Server](#server) - - [`server([options])`](#server()) - - [Server options](#server.options) - - [`server.options.address`](#server.options.address) - - [`server.options.app`](#server.options.app) - - [`server.options.autoListen`](#server.options.autolisten) - - [`server.options.cache`](#server.options.cache) - - [`server.options.compression`](#server.options.compression) - - [`server.options.compression.minBytes`](#server.options.compression.minBytes) - - [`server.options.debug`](#server.options.debug) - - [`server.options.host`](#server.options.host) - - [`server.options.listener`](#server.options.listener) - - [`server.options.load`](#server.options.load) - - [`server.options.mime`](#server.options.mime) - - [`server.options.plugins`](#server.options.plugins) - - [`server.options.port`](#server.options.port) - - [`server.options.router`](#server.options.router) - - [`server.options.routes`](#server.options.routes) - - [`server.options.state`](#server.options.state) - - [`server.options.tls`](#server.options.tls) - - [`server.options.uri`](#server.options.uri) - - [Server properties](#server-properties) - - [`server.app`](#server.app) - - [`server.auth.api`](#server.auth.api) - - [`server.auth.settings.default`](#server.auth.settings.default) - - [`server.decorations`](#server.decorations) - - [`server.events`](#server.events) - - [`'log'` Event](#server.events.log) - - [`'request'` Event](#server.events.request) - - [`'response'` Event](#server.events.response) - - [`'route'` Event](#server.events.route) - - [`'start'` Event](#server.events.start) - - [`'stop'` Event](#server.events.stop) - - [`server.info`](#server.info) - - [`server.listener`](#server.listener) - - [`server.load`](#server.load) - - [`server.methods`](#server.methods) - - [`server.mime`](#server.mime) - - [`server.plugins`](#server.plugins) - - [`server.realm`](#server.realm) - - [`server.registrations`](#server.registrations) - - [`server.settings`](#server.settings) - - [`server.states`](#server.states) - - [`server.states.settings`](#server.states.settings) - - [`server.states.cookies`](#server.states.cookies) - - [`server.states.names`](#server.states.names) - - [`server.type`](#server.type) - - [`server.version`](#server.version) - - [`server.auth.default(options)`](#server.auth.default()) - - [`server.auth.scheme(name, scheme)`](#server.auth.scheme()) - - [Authentication scheme](#authentication-scheme) - - [`server.auth.strategy(name, scheme, [options])`](#server.auth.strategy()) - - [`await server.auth.test(strategy, request)`](#server.auth.test()) - - [`server.bind(context)`](#server.bind()) - - [`server.cache(options)`](#server.cache()) - - [`await server.cache.provision(options)`](#server.cache.provision()) - - [`server.decoder(encoding, decoder)`](#server.decoder()) - - [`server.decorate(type, property, method, [options])`](#server.decorate()) - - [`server.dependency(dependencies, [after])`](#server.dependency()) - - [`server.encoder(encoding, encoder)`](#server.encoder()) - - [`server.event(events)`](#server.event()) - - [`await server.events.emit(criteria, data)`](#server.events.emit()) - - [`server.events.on(criteria, listener)`](#server.events.on()) - - [`server.events.once(criteria, listener)`](#server.events.once()) - - [`await server.events.once(criteria)`](#server.events.once.await()) - - [`server.expose(key, value)`](#server.expose()) - - [`server.expose(obj)`](#server.expose.obj()) - - [`server.ext(events)`](#server.ext()) - - [`server.ext(event, method, [options])`](#server.ext.args()) - - [`await server.initialize()`](#server.initialize()) - - [`await server.inject(options)`](#server.inject()) - - [`server.log(tags, [data, [timestamp]])`](#server.log()) - - [`server.lookup(id)`](#server.lookup()) - - [`server.match(method, path, [host])`](#server.match()) - - [`server.method(name, method, [options])`](#server.method()) - - [`server.method(methods)`](#server.method.array()) - - [`server.path(relativeTo)`](#server.path()) - - [`await server.register(plugins, [options])`](#server.register()) - - [`server.route(route)`](#server.route()) - - [Path parameters](#path-parameters) - - [Path matching order](#path-matching-order) - - [Catch all route](#catch-all-route) - - [`server.rules(processor, [options])`](#server.rules()) - - [`await server.start()`](#server.start()) - - [`server.state(name, [options])`](#server.state()) - - [`server.states.add(name, [options])`](#server.states.add()) - - [`await server.states.format(cookies)`](#server.states.format()) - - [`await server.states.parse(header)`](#server.states.parse()) - - [`await server.stop([options])`](#server.stop()) - - [`server.table([host])`](#server.table()) -- [Route options](#route-options) - - [`route.options.app`](#route.options.app) - - [`route.options.auth`](#route.options.auth) - - [`route.options.auth.access`](#route.options.auth.access) - - [`route.options.auth.access.scope`](#route.options.auth.access.scope) - - [`route.options.auth.access.entity`](#route.options.auth.access.entity) - - [`route.options.auth.mode`](#route.options.auth.mode) - - [`route.options.auth.payload`](#route.options.auth.payload) - - [`route.options.auth.strategies`](#route.options.auth.strategies) - - [`route.options.auth.strategy`](#route.options.auth.strategy) - - [`route.options.bind`](#route.options.bind) - - [`route.options.cache`](#route.options.cache) - - [`route.options.compression`](#route.options.compression) - - [`route.options.cors`](#route.options.cors) - - [`route.options.description`](#route.options.description) - - [`route.options.ext`](#route.options.ext) - - [`route.options.files`](#route.options.files) - - [`route.options.handler`](#route.options.handler) - - [`route.options.id`](#route.options.id) - - [`route.options.isInternal`](#route.options.isInternal) - - [`route.options.json`](#route.options.json) - - [`route.options.jsonp`](#route.options.jsonp) - - [`route.options.log`](#route.options.log) - - [`route.options.notes`](#route.options.notes) - - [`route.options.payload`](#route.options.payload) - - [`route.options.payload.allow`](#route.options.payload.allow) - - [`route.options.payload.compression`](#route.options.payload.compression) - - [`route.options.payload.defaultContentType`](#route.options.payload.defaultContentType) - - [`route.options.payload.failAction`](#route.options.payload.failAction) - - [`route.options.payload.maxBytes`](#route.options.payload.maxBytes) - - [`route.options.payload.multipart`](#route.options.payload.multipart) - - [`route.options.payload.output`](#route.options.payload.output) - - [`route.options.payload.override`](#route.options.payload.override) - - [`route.options.payload.parse`](#route.options.payload.parse) - - [`route.options.payload.timeout`](#route.options.payload.timeout) - - [`route.options.payload.uploads`](#route.options.payload.uploads) - - [`route.options.plugins`](#route.options.plugins) - - [`route.options.pre`](#route.options.pre) - - [`route.options.response`](#route.options.response) - - [`route.options.response.emptyStatusCode`](#route.options.response.emptyStatusCode) - - [`route.options.response.failAction`](#route.options.response.failAction) - - [`route.options.response.modify`](#route.options.response.modify) - - [`route.options.response.options`](#route.options.response.options) - - [`route.options.response.ranges`](#route.options.response.ranges) - - [`route.options.response.sample`](#route.options.response.sample) - - [`route.options.response.schema`](#route.options.response.schema) - - [`route.options.response.status`](#route.options.response.status) - - [`route.options.rules`](#route.options.rules) - - [`route.options.security`](#route.options.security) - - [`route.options.state`](#route.options.state) - - [`route.options.tags`](#route.options.tags) - - [`route.options.timeout`](#route.options.timeout) - - [`route.options.timeout.server`](#route.options.timeout.server) - - [`route.options.timeout.socket`](#route.options.timeout.socket) - - [`route.options.validate`](#route.options.validate) - - [`route.options.validate.errorFields`](#route.options.validate.errorFields) - - [`route.options.validate.failAction`](#route.options.validate.failAction) - - [`route.options.validate.headers`](#route.options.validate.headers) - - [`route.options.validate.options`](#route.options.validate.options) - - [`route.options.validate.params`](#route.options.validate.params) - - [`route.options.validate.payload`](#route.options.validate.payload) - - [`route.options.validate.query`](#route.options.validate.query) -- [Request lifecycle](#request-lifecycle) - - [Lifecycle methods](#lifecycle-methods) - - [Lifecycle workflow](#lifecycle-workflow) - - [Takeover response](#takeover-response) - - [`failAction` configuration](#lifecycle-failAction) - - [Errors](#errors) - - [Error transformation](#error-transformation) - - [Response Toolkit](#response-toolkit) - - [Toolkit properties](#toolkit-properties) - - [`h.abandon`](#h.abandon) - - [`h.close`](#h.close) - - [`h.context`](#h.context) - - [`h.continue`](#h.continue) - - [`h.realm`](#h.realm) - - [`h.request`](#h.request) - - [`h.authenticated(data)`](#h.authenticated()) - - [`h.entity(options)`](#h.entity()) - - [`h.redirect(uri)`](#h.redirect()) - - [`h.response([value])`](#h.response()) - - [`h.state(name, value, [options])`](#h.state()) - - [`h.unauthenticated(error, [data])`](#h.unauthenticated()) - - [`h.unstate(name, [options])`](#h.unstate()) - - [Response object](#response-object) - - [Response properties](#response-properties) - - [`response.app`](#response.app) - - [`response.events`](#response.events) - - [`response.headers`](#response.headers) - - [`response.plugins`](#response.plugins) - - [`response.settings`](#response.settings) - - [`response.settings.passThrough`](#response.settings.passThrough) - - [`response.settings.stringify`](#response.settings.stringify) - - [`response.settings.ttl`](#response.settings.ttl) - - [`response.settings.varyEtag`](#response.settings.varyEtag) - - [`response.source`](#response.source) - - [`response.statusCode`](#response.statusCode) - - [`response.variety`](#response.variety) - - [`response.bytes(length)`](#response.bytes()) - - [`response.charset(charset)`](#response.charset()) - - [`response.code(statusCode)`](#response.code()) - - [`response.message(httpMessage)`](#response.message()) - - [`response.created(uri)`](#response.created()) - - [`response.encoding(encoding)`](#response.encoding()) - - [`response.etag(tag, options)`](#response.etag()) - - [`response.header(name, value, options)`](#response.header()) - - [`response.location(uri)`](#response.location()) - - [`response.redirect(uri)`](#response.redirect()) - - [`response.replacer(method)`](#response.replacer()) - - [`response.spaces(count)`](#response.spaces()) - - [`response.state(name, value, [options])`](#response.state()) - - [`response.suffix(suffix)`](#response.suffix()) - - [`response.ttl(msec)`](#response.ttl()) - - [`response.type(mimeType)`](#response.type()) - - [`response.unstate(name, [options])`](#response.unstate()) - - [`response.vary(header)`](#response.vary()) - - [`response.takeover()`](#response.takeover()) - - [`response.temporary(isTemporary)`](#response.temporary()) - - [`response.permanent(isPermanent)`](#response.permanent()) - - [`response.rewritable(isRewritable)`](#response.rewritable()) -- [Request](#request) - - [Request properties](#request-properties) - - [`request.app`](#request.app) - - [`request.auth`](#request.auth) - - [`request.events`](#request.events) - - [`request.headers`](#request.headers) - - [`request.info`](#request.info) - - [`request.logs`](#request.logs) - - [`request.method`](#request.method) - - [`request.mime`](#request.mime) - - [`request.orig`](#request.orig) - - [`request.params`](#request.params) - - [`request.paramsArray`](#request.paramsArray) - - [`request.path`](#request.path) - - [`request.payload`](#request.payload) - - [`request.plugins`](#request.plugins) - - [`request.pre`](#request.pre) - - [`request.response`](#request.response) - - [`request.preResponses`](#request.preResponses) - - [`request.query`](#request.query) - - [`request.raw`](#request.raw) - - [`request.route`](#request.route) - - [`request.server`](#request.server) - - [`request.state`](#request.state) - - [`request.url`](#request.url) - - [`request.generateResponse(source, [options])`](#request.generateResponse()) - - [`request.log(tags, [data])`](#request.log()) - - [`request.route.auth.access(request)`](#request.route.auth.access()) - - [`request.setMethod(method)`](#request.setMethod()) - - [`request.setUrl(url, [stripTrailingSlash]`](#request.setUrl()) -- [Plugins](#plugins) - - ## Server @@ -258,7 +11,7 @@ Creates a new server object where: - `options` - (optional) a [server configuration object](#server.options). ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ load: { sampleInterval: 1000 } }); ``` @@ -273,11 +26,11 @@ All options are optionals. #### `server.options.address` -Default value: `'0.0.0.0'` (all available network interfaces). +Default value: `'::'` if IPv6 is available, otherwise `'0.0.0.0'` (i.e. all available network interfaces). Sets the hostname or IP address the server will listen on. If not configured, defaults to [`host`](#server.options.host) if present, otherwise to all available network interfaces. Set to -`'127.0.0.1'` or `'localhost'` to restrict the server to only those coming from the same host. +`'127.0.0.1'`, `'::1'`, or `'localhost'` to restrict the server to only those coming from the same host. #### `server.options.app` @@ -298,17 +51,17 @@ Used to disable the automatic initialization of the [`listener`](#server.options `false`, indicates that the [`listener`](#server.options.listener) will be started manually outside the framework. -Cannot be set to `true` along with a [`port`](#server.options.port) value. +Cannot be set to `false` along with a [`port`](#server.options.port) value. #### `server.options.cache` -Default value: `{ engine: require('catbox-memory' }`. +Default value: `{ provider: { constructor: require('@hapi/catbox-memory'), options: { partition: 'hapi-cache' } } }`. Sets up server-side caching providers. Every server includes a default cache for storing application state. By default, a simple memory-based cache is created which has limited capacity and capabilities. -**hapi** uses [**catbox**](https://github.com/hapijs/catbox) for its cache implementation which +**hapi** uses [**catbox**](https://hapi.dev/family/catbox/api) for its cache implementation which includes support for common storage solutions (e.g. Redis, MongoDB, Memcached, Riak, among others). Caching is only utilized if [methods](#server.methods) and [plugins](#plugins) explicitly store their state in the cache. @@ -317,26 +70,31 @@ The server cache configuration only defines the storage container itself. The co assigned one or more (array): - a class or prototype function (usually obtained by calling `require()` on a **catbox** strategy - such as `require('catbox-redis')`). A new **catbox** [client](https://github.com/hapijs/catbox#client) - will be created internally using this function. + such as `require('@hapi/catbox-redis')`). A new **catbox** [client](https://hapi.dev/family/catbox/api#client) + will be created internally using this constructor. - a configuration object with the following: - - `engine` - a class, a prototype function, or a **catbox** engine object. + - `engine` - a **catbox** engine object instance. - `name` - an identifier used later when provisioning or configuring caching for [server methods](#server.methods) or [plugins](#plugins). Each cache name must be unique. A single item may omit the `name` option which defines the default cache. If every cache includes a `name`, a default memory cache is provisioned as well. + - `provider` - a class, a constructor function, or an object with the following: + + - `constructor` - a class or a prototype function. + + - `options` - (optional) a settings object passed as-is to the `constructor` with the following: + + - `partition` - (optional) string used to isolate cached data. Defaults to `'hapi-cache'`. + - other constructor-specific options passed to the `constructor` on instantiation. + - `shared` - if `true`, allows multiple cache users to share the same segment (e.g. multiple methods using the same cache storage container). Default to `false`. - - `partition` - (optional) string used to isolate cached data. Defaults to `'hapi-cache'`. - - - other options passed to the **catbox** strategy used. Other options are only passed to - **catbox** when `engine` above is a class or function and ignored if `engine` is a **catbox** - engine object). + - One (and only one) of `engine` or `provider` is required per configuration object. #### `server.options.compression` @@ -382,13 +140,19 @@ output set the `log` or `request` to `false`. To display all server logs, set th Default value: the operating system hostname and if not available, to `'localhost'`. The public hostname or IP address. Used to set [`server.info.host`](#server.info) and -[`server.info.uri`](#server.info) and as [`address`](#server.options.address) is none provided. +[`server.info.uri`](#server.info) and as [`address`](#server.options.address) if none is provided. + +#### `server.options.info.remote` + +Default value: `false`. + +If `true`, the `request.info.remoteAddress` and `request.info.remotePort` are populated when the request is received which can consume more resource (but is ok if the information is needed, especially for aborted requests). When `false`, the fields are only populated upon demand (but will be `undefined` if accessed after the request is aborted). #### `server.options.listener` Default value: none. -An optional node HTTP (or HTTPS) [`http.Server`](http://nodejs.org/api/http.html#http_class_http_server) +An optional node HTTP (or HTTPS) [`http.Server`](https://nodejs.org/api/http.html#http_class_http_server) object (or an object with a compatible interface). If the `listener` needs to be manually started, set [`autoListen`](#server.options.autolisten) to @@ -398,50 +162,35 @@ If the `listener` uses TLS, set [`tls`](#server.options.tls) to `true`. #### `server.options.load` -Default value: `{ sampleInterval: 0, concurrent: 0 }`. +Default value: `{ sampleInterval: 0, maxHeapUsedBytes: 0, maxRssBytes: 0, maxEventLoopDelay: 0, maxEventLoopUtilization: 0 }`. Server excessive load handling limits where: -- `sampleInterval` - the frequency of sampling in milliseconds. When set to `0`, the other load - options are ignored. Defaults to `0` (no sampling). +- `sampleInterval` - the frequency of sampling in milliseconds. When set to `0`, the other load options are ignored. Defaults to `0` (no sampling). -- `maxHeapUsedBytes` - maximum V8 heap size over which incoming requests are rejected with an HTTP - Server Timeout (503) response. Defaults to `0` (no limit). +- `maxHeapUsedBytes` - maximum V8 heap size over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to `0` (no limit). -- `maxRssBytes` - maximum process RSS size over which incoming requests are rejected with an HTTP - Server Timeout (503) response. Defaults to `0` (no limit). +- `maxRssBytes` - maximum process RSS size over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to `0` (no limit). -- `maxEventLoopDelay` - maximum event loop delay duration in milliseconds over which incoming - requests are rejected with an HTTP Server Timeout (503) response. Defaults to `0` (no limit). +- `maxEventLoopDelay` - maximum event loop delay duration in milliseconds over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to `0` (no limit). -- `concurrent` - maximum number of requests to execute in parallel. This is useful to reduce - garbage collection costs on high load deployment where the actual handler computation load is - low. For example, a handler that mostly waits for upstream data will allow many incoming requests - to queue up all the way to the handler lifecycle step. This will trigger heavy garbage collection - load trying to sort out the many pending objects. Reducing the number of concurrent requests - being processed can help. There is no recommended value - you need to test what works best for - your specific deployment. Defaults to `0` (no queue). +- `maxEventLoopUtilization` - maximum event loop utilization value over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to `0` (no limit). #### `server.options.mime` Default value: none. -Options passed to the [**mimos**](https://github.com/hapijs/mimos) module when generating the mime -database used by the server (and accessed via [`server.mime`](#server.mime)): +Options passed to the [**mimos**](https://hapi.dev/family/mimos/api) module when generating the mime database used by the server (and accessed via [`server.mime`](#server.mime)): -- `override` - an object hash that is merged into the built in mime information specified - [here](https://github.com/jshttp/mime-db). Each key value pair represents a single mime object. - Each override value must contain: +- `override` - an object hash that is merged into the built in mime information specified [here](https://github.com/jshttp/mime-db). Each key value pair represents a single mime object. Each override value must contain: - `key` - the lower-cased mime-type string (e.g. `'application/javascript'`). - - `value` - an object following the specifications outlined [here](https://github.com/jshttp/mime-db#data-structure). - Additional values include: + - `value` - an object following the specifications outlined [here](https://github.com/jshttp/mime-db#data-structure). Additional values include: - `type` - specify the `type` value of result objects, defaults to `key`. - - `predicate` - method with signature `function(mime)` when this mime type is found in the - database, this function will execute to allows customizations. + - `predicate` - method with signature `function(mime)` when this mime type is found in the database, this function will execute to allows customizations. ```js const options = { @@ -476,6 +225,19 @@ const options = { }; ``` +#### `server.options.operations` + +Default value: `{ cleanStop: true }`. + +Defines server handling of server operations: + +- `cleanStop` - if `true`, the server keeps track of open connections and properly closes them + when the server is stopped. Under normal load, this should not interfere with server performance. + However, under severe load connection monitoring can consume additional resources and aggravate + the situation. If the server is never stopped, or if it is forced to stop without waiting for + open connection to close, setting this to `false` can save resources that are not being utilized + anyway. Defaults to `true`. + #### `server.options.plugins` Default value: `{}`. @@ -496,6 +258,33 @@ The TCP port the server will listen to. Defaults the next available port when th If `port` is a string containing a '/' character, it is used as a UNIX domain socket path. If it starts with '\\.\pipe', it is used as a Windows named pipe. +#### `server.options.query` + +Default value: `{}`. + +Defines server handling of the request path query component. + +##### `server.options.query.parser` + +Default value: none. + +Sets a query parameters parser method using the signature `function(query)` where: + +- `query` - an object containing the incoming [`request.query`](#request.query) parameters. +- the method must return an object where each key is a parameter and matching value is the + parameter value. If the method throws, the error is used as the response or returned when + [`request.setUrl()`](#request.setUrl()) is called. + +```js +const Qs = require('qs'); + +const options = { + query: { + parser: (query) => Qs.parse(query) + } +}; +``` + #### `server.options.router` Default value: `{ isCaseSensitive: true, stripTrailingSlash: false }`. @@ -523,6 +312,7 @@ Default value: isSecure: true, isHttpOnly: true, isSameSite: 'Strict', + isPartitioned: false, encoding: 'none' } ``` @@ -536,7 +326,7 @@ Sets the default configuration for every state (cookie) set explicitly via Default value: none. Used to create an HTTPS connection. The `tls` object is passed unchanged to the node -HTTPS server as described in the [node HTTPS documentation](http://nodejs.org/api/https.html#https_https_createserver_options_requestlistener). +HTTPS server as described in the [node HTTPS documentation](https://nodejs.org/api/https.html#https_https_createserver_options_requestlistener). Set to `true` when passing a [`listener`](#server.options.listener) object that has been configured to use TLS directly. @@ -610,7 +400,7 @@ console.log(server.auth.api.default.settings.x); // 5 Access: read only. -Contains the default authentication configuration is a default strategy was set via +Contains the default authentication configuration if a default strategy was set via [`server.auth.default()`](#server.auth.default()). #### `server.decorations` @@ -622,11 +412,12 @@ not be modified directly, but only through [`server.decorate`](#server.decorate( Contains: - `request` - decorations on the [request object](#request). +- `response` - decorations on the [response object](#response-object). - `toolkit` - decorations on the [response toolkit](#response-toolkit). - `server` - decorations on the [server](#server) object. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const success = function () { @@ -642,15 +433,15 @@ console.log(server.decorations.toolkit); // ['success'] Access: **podium** public interface. -The server events emitter. Utilizes the [**podium**](https://github.com/hapijs/podium) with support +The server events emitter. Utilizes the [**podium**](https://hapi.dev/family/podium/api) with support for event criteria validation, channels, and filters. Use the following methods to interact with `server.events`: - [`server.event(events)`](#server.event()) - register application events. - [`server.events.emit(criteria, data)`](#server.events.emit()) - emit server events. -- [`server.events.on(criteria, listener)`](#server.events.on()) - subscribe to all events. -- [`server.events.once(criteria, listener)`](#server.events.once()) - subscribe to +- [`server.events.on(criteria, listener, context)`](#server.events.on()) - subscribe to all events. +- [`server.events.once(criteria, listener, context)`](#server.events.once()) - subscribe to a single event. Other methods include: `server.events.removeListener(name, listener)`, `server.events.removeAllListeners(name)`, and `server.events.hasListeners(name)`. @@ -692,6 +483,23 @@ The internally generated events are (identified by their `tags`): - `connection` `client` `error` - a `clientError` event was received from the HTTP or HTTPS listener. The event data is the error object received. +##### `'cachePolicy'` Event + +The `'cachePolicy'` event type is emitted when a server [cache policy](https://hapi.dev/module/catbox/api#policy) +is created via [`server.cache()`](#server.cache()) or a [`server.method()`](#server.method()) with caching enabled is registered. +The `'cachePolicy'` event handler uses the function signature `function(cachePolicy, cache, segment)` where: + +- `cachePolicy` - the catbox [cache policy](https://hapi.dev/module/catbox/api#policy). +- `cache` - the [cache provision](#server.options.cache) name used when the policy was created or `undefined` if the default cache was used. +- `segment` - the segment name used when the policy was created. + +```js +server.events.on('cachePolicy', (cachePolicy, cache, segment) => { + + console.log(`New cache policy created using cache: ${cache === undefined ? 'default' : cache} and segment: ${segment}`); +}); +``` + ##### `'request'` Event The `'request'` event type emits internal request events generated by the framework as well as @@ -705,7 +513,7 @@ The `'request'` event handler uses the function signature `function(request, eve - `timestamp` - the event timestamp. - `tags` - an array of tags identifying the event (e.g. `['error', 'http']`). - `channel` - one of - - `'app'` - events generated by [`server.log()`](#server.log()). + - `'app'` - events generated by [`request.log()`](#request.log()). - `'error'` - emitted once per request if the response had a `500` status code. - `'internal'` - internally generated events. - `request` - the request [identifier](#request.info.id). @@ -739,44 +547,31 @@ The internally generated events are (identified by their `tags`): - `accept-encoding` `error` - a request received contains an invalid Accept-Encoding header. - `auth` `unauthenticated` - no authentication scheme included with the request. -- `auth` `unauthenticated` `response` `{strategy}` - the authentication strategy listed returned a - non-error response (e.g. a redirect to a login page). -- `auth` `unauthenticated` `error` `{strategy}` - the request failed to pass the listed - authentication strategy (invalid credentials). -- `auth` `unauthenticated` `missing` `{strategy}` - the request failed to pass the listed - authentication strategy (no credentials found). -- `auth` `unauthenticated` `try` `{strategy}` - the request failed to pass the listed - authentication strategy in `'try'` mode and will continue. +- `auth` `unauthenticated` `response` `{strategy}` - the authentication strategy listed returned a non-error response (e.g. a redirect to a login page). +- `auth` `unauthenticated` `error` `{strategy}` - the request failed to pass the listed authentication strategy (invalid credentials). +- `auth` `unauthenticated` `missing` `{strategy}` - the request failed to pass the listed authentication strategy (no credentials found). +- `auth` `unauthenticated` `try` `{strategy}` - the request failed to pass the listed authentication strategy in `'try'` mode and will continue. - `auth` `scope` `error` - the request authenticated but failed to meet the scope requirements. -- `auth` `entity` `user` `error` - the request authenticated but included an application entity - when a user entity was required. -- `auth` `entity` `app` `error` - the request authenticated but included a user entity when an - application entity was required. -- `handler` `error` - the route handler returned an error. Includes the execution duration and the - error message. -- `pre` `error` - a pre method was executed and returned an error. Includes the execution duration, - assignment key, and error. +- `auth` `entity` `user` `error` - the request authenticated but included an application entity when a user entity was required. +- `auth` `entity` `app` `error` - the request authenticated but included a user entity when an application entity was required. +- `ext` `error` - an `onPostResponse` extension handler errored. +- `handler` `error` - the route handler returned an error. Includes the execution duration and the error message. +- `pre` `error` - a pre method was executed and returned an error. Includes the execution duration, assignment key, and error. - `internal` `error` - an HTTP 500 error response was assigned to the request. - `internal` `implementation` `error` - an incorrectly implemented [lifecycle method](#lifecycle-methods). -- `request` `abort` `error` - the request aborted. -- `request` `closed` `error` - the request closed prematurely. +- `request` `error` `abort` - the request aborted. +- `request` `error` `close` - the request closed prematurely. - `request` `error` - the request stream emitted an error. Includes the error. -- `request` `server` `timeout` `error` - the request took too long to process by the server. - Includes the timeout configuration value and the duration. -- `state` `error` - the request included an invalid cookie or cookies. Includes the cookies and - error details. -- `state` `response` `error` - the response included an invalid cookie which prevented generating a - valid header. Includes the error. +- `request` `server` `timeout` `error` - the request took too long to process by the server. Includes the timeout configuration value and the duration. +- `state` `error` - the request included an invalid cookie or cookies. Includes the cookies and error details. +- `state` `response` `error` - the response included an invalid cookie which prevented generating a valid header. Includes the error. - `payload` `error` - failed processing the request payload. Includes the error. - `response` `error` - failed writing the response to the client. Includes the error. -- `response` `error` `close` - failed writing the response to the client due to prematurely closed - connection. -- `response` `error` `aborted` - failed writing the response to the client due to prematurely - aborted connection. +- `response` `error` `close` - failed writing the response to the client due to prematurely closed connection. +- `response` `error` `aborted` - failed writing the response to the client due to prematurely aborted connection. - `response` `error` `cleanup` - failed freeing response resources. -- `validation` `error` `{input}` - input (i.e. payload, query, params, headers) validation failed. - Includes the error. -- `validation` `response` `error` - response validation failed. Includes the error message. +- `validation` `error` `{input}` - input (i.e. payload, query, params, headers) validation failed. Includes the error. Only emitted when `failAction` is set to `'log'`. +- `validation` `response` `error` - response validation failed. Includes the error message. Only emitted when `failAction` is set to `'log'`. ##### `'response'` Event @@ -790,7 +585,7 @@ signature `function(request)` where: ```js server.events.on('response', (request) => { - console.log(`Response sent for request: ${request.id}`); + console.log(`Response sent for request: ${request.info.id}`); }); ``` @@ -820,6 +615,18 @@ server.events.on('start', () => { }); ``` +##### `'closing'` Event + +The `'closing'` event type is emitted when the server is stopped using [`server.stop()`](#server.stop()). It is triggered when incoming requests are no longer accepted but before all underlying active connections have been closed, and thus before the [`'stop'`](#server.events.stop) event is triggered. +The `'closing'` event handler uses the function signature `function()`. + +```js +server.events.on('closing', () => { + + console.log('Server is closing'); +}); +``` + ##### `'stop'` Event The `'stop'` event type is emitted when the server is stopped using [`server.stop()`](#server.stop()). @@ -868,7 +675,7 @@ An object containing information about the server where: started. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); console.log(server.info.port); // 80 @@ -881,7 +688,7 @@ Access: read only and listener public interface. The node HTTP server object. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const SocketIO = require('socket.io'); const server = Hapi.server({ port: 80 }); @@ -901,11 +708,12 @@ An object containing the process load metrics (when [`load.sampleInterval`](#ser is enabled): - `eventLoopDelay` - event loop delay milliseconds. +- `eventLoopUtilization` - current event loop utilization value. - `heapUsed` - V8 heap usage. - `rss` - RSS memory usage. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ load: { sampleInterval: 1000 } }); console.log(server.load.rss); @@ -924,7 +732,7 @@ share across multiple request handlers without having to create a common module. property. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server(); server.method('add', (a, b) => (a + b)); @@ -939,7 +747,7 @@ Provides access to the server MIME database used for setting content-type inform must not be modified directly but only through the [`mime`](#server.options.mime) server setting. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const options = { mime: { @@ -1053,7 +861,7 @@ Access: read only. The server configuration object after defaults applied. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ app: { key: 'value' @@ -1087,7 +895,7 @@ where each key is the cookie name and value is the configuration object. Access: read only. -An array containing the names of all configued cookies. +An array containing the names of all configured cookies. #### `server.type` @@ -1104,7 +912,7 @@ Access: read only. The **hapi** module version number. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server(); console.log(server.version); // '17.0.0' @@ -1137,7 +945,7 @@ The default auth strategy configuration can be accessed via [`server.auth.settin To obtain the active authentication configuration of a route, use `server.auth.lookup(request.route)`. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); server.auth.scheme('custom', scheme); @@ -1160,7 +968,7 @@ Registers an authentication scheme where: - `name` - the scheme name. - `scheme` - the method implementing the scheme with signature `function(server, options)` where: - - `server` - a reference to the server object the scheme is added to. + - `server` - a reference to the server object the scheme is added to. Each auth strategy is given its own [`server.realm`](#server.realm) whose parent is the realm of the `server` in the call to [`server.auth.strategy()`](#server.auth.strategy()). - `options` - (optional) the scheme `options` argument passed to [`server.auth.strategy()`](#server.auth.strategy()) when instantiation a strategy. @@ -1188,6 +996,14 @@ An authentication scheme is an object with the following properties: - `async response(request, h)` - (optional) a [lifecycle method](#lifecycle-methods) to decorate the response with authentication headers before the response headers or payload is written. +- `async verify(auth)` - (optional) a method used to verify the authentication credentials provided + are still valid (e.g. not expired or revoked after the initial authentication) where: + - `auth` - the [`request.auth`](#request.auth) object containing the `credentials` and + `artifacts` objects returned by the scheme's `authenticate()` method. + - the method throws an `Error` when the credentials passed are no longer valid (e.g. expired or + revoked). Note that the method does not have access to the original request, only to the + credentials and artifacts produced by the `authenticate()` method. + - `options` - (optional) an object with the following keys: - `payload` - if `true`, requires payload validation as part of the scheme and forbids routes from disabling payload auth validation. Defaults to `false`. @@ -1206,7 +1022,7 @@ failed due to bad payload. If the error has no message but includes a scheme nam [`auth.payload`](#route.options.auth.payload) configuration is set to `'optional'`. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const scheme = function (server, options) { @@ -1240,7 +1056,7 @@ Registers an authentication strategy where: Return value: none. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); server.auth.scheme('custom', scheme); @@ -1249,7 +1065,7 @@ server.auth.strategy('default', 'custom'); server.route({ method: 'GET', path: '/', - config: { + options: { auth: 'default', handler: function (request, h) { @@ -1266,15 +1082,15 @@ Tests a request against an authentication strategy where: - `strategy` - the strategy name registered with [`server.auth.strategy()`](#server.auth.strategy()). - `request` - the [request object](#request). -Return value: the authentication credentials object if authentication was successful, otherwise -throws an error. +Return value: an object containing the authentication `credentials` and `artifacts` if authentication +was successful, otherwise throws an error. Note that the `test()` method does not take into account the route authentication configuration. It also does not perform payload authentication. It is limited to the basic strategy authentication execution. It does not include verifying scope, entity, or other route properties. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); server.auth.scheme('custom', scheme); @@ -1286,7 +1102,45 @@ server.route({ handler: async function (request, h) { try { - const credentials = await request.server.auth.test('default', request); + const { credentials, artifacts } = await request.server.auth.test('default', request); + return { status: true, user: credentials.name }; + } + catch (err) { + return { status: false }; + } + } +}); +``` + + +### `await server.auth.verify(request)` + +Verify a request's authentication credentials against an authentication strategy where: + +- `request` - the [request object](#request). + +Return value: nothing if verification was successful, otherwise throws an error. + +Note that the `verify()` method does not take into account the route authentication configuration +or any other information from the request other than the `request.auth` object. It also does not +perform payload authentication. It is limited to verifying that the previously valid credentials +are still valid (e.g. have not been revoked or expired). It does not include verifying scope, +entity, or other route properties. + +```js +const Hapi = require('@hapi/hapi'); +const server = Hapi.server({ port: 80 }); + +server.auth.scheme('custom', scheme); +server.auth.strategy('default', 'custom'); + +server.route({ + method: 'GET', + path: '/', + handler: async function (request, h) { + + try { + const credentials = await request.server.auth.verify(request); return { status: true, user: credentials.name }; } catch (err) { @@ -1334,7 +1188,7 @@ exports.plugin = { Provisions a cache segment within the server cache facility where: -- `options` - [**catbox** policy](https://github.com/hapijs/catbox#policy) configuration where: +- `options` - [**catbox** policy](https://hapi.dev/family/catbox/api#policy) configuration where: - `expiresIn` - relative expiration expressed in the number of milliseconds since the item was saved in the cache. Cannot be used together with `expiresAt`. @@ -1375,7 +1229,7 @@ Provisions a cache segment within the server cache facility where: for a given id, before a subsequent `generateFunc` call is allowed. Defaults to `0` (no blocking of concurrent `generateFunc` calls beyond `staleTimeout`). - - `cache` - the cache name configured in [`server.cache`](#server.config.cache). Defaults to + - `cache` - the cache name configured in [`server.cache`](#server.options.cache). Defaults to the default cache. - `segment` - string segment name, used to isolate cached items within the cache partition. @@ -1386,8 +1240,10 @@ Provisions a cache segment within the server cache facility where: - `shared` - if `true`, allows multiple cache provisions to share the same segment. Default to `false`. +Return value: a [**catbox** policy](https://hapi.dev/family/catbox/api#policy) object. + ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -1400,7 +1256,7 @@ async function example() { ### `await server.cache.provision(options)` -Provisions a server cache as described in [`server.cache`](#server.config.cache) where: +Provisions a server cache as described in [`server.cache`](#server.options.cache) where: - `options` - same as the server [`cache`](#server.options.cache) configuration options. @@ -1410,13 +1266,13 @@ Note that if the server has been initialized or started, the cache will be autom to match the state of any other provisioned server cache. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { const server = Hapi.server({ port: 80 }); await server.initialize(); - await server.cache.provision({ engine: require('catbox-memory'), name: 'countries' }); + await server.cache.provision({ provider: require('@hapi/catbox-memory'), name: 'countries' }); const cache = server.cache({ cache: 'countries', expiresIn: 60 * 60 * 1000 }); await cache.set('norway', { capital: 'oslo' }); @@ -1424,6 +1280,14 @@ async function example() { } ``` +### `server.control(server)` + +Links another server to the initialize/start/stop state of the current server by calling the +controlled server `initialize()`/`start()`/`stop()` methods whenever the current server methods +are called, where: + +- `server` - the **hapi** server object to be controlled. + ### `server.decoder(encoding, decoder)` Registers a custom content decoding compressor to extend the built-in support for `'gzip'` and @@ -1434,13 +1298,13 @@ Registers a custom content decoding compressor to extend the built-in support fo - `decoder` - a function using the signature `function(options)` where `options` are the encoding specific options configured in the route [`payload.compression`](#route.options.payload.compression) configuration option, and the return value is an object compatible with the output of node's - [`zlib.createGunzip()`](https://nodejs.org/dist/latest-v6.x/docs/api/zlib.html#zlib_zlib_creategunzip_options). + [`zlib.createGunzip()`](https://nodejs.org/api/zlib.html#zlib_zlib_creategunzip_options). Return value: none. ```js const Zlib = require('zlib'); -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80, routes: { payload: { compression: { special: { chunkSize: 16 * 1024 } } } } }); server.decoder('special', (options) => Zlib.createGunzip(options)); @@ -1454,10 +1318,11 @@ Extends various framework interfaces with custom methods where: - `'handler'` - adds a new handler type to be used in [routes handlers](#route.options.handler). - `'request'` - adds methods to the [Request object](#request). + - `'response'` - adds methods to the [Response object](#response-object). - `'server'` - adds methods to the [Server](#server) object. - `'toolkit'` - adds methods to the [response toolkit](#response-toolkit). -- `property` - the object decoration key name. +- `property` - the object decoration key name or symbol. - `method` - the extension function or other value. @@ -1474,7 +1339,7 @@ Extends various framework interfaces with custom methods where: Return value: none. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const success = function () { @@ -1501,7 +1366,7 @@ When registering a handler decoration, the `method` must be a function using the - `options` - the configuration object provided in the handler config. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -1535,7 +1400,7 @@ property is set to a function, the function uses the signature `function(method) route default configuration. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ host: 'localhost', port: 8000 }); const handler = function (route, options) { @@ -1560,11 +1425,16 @@ server.decorate('handler', 'test', handler); ### `server.dependency(dependencies, [after])` -Used within a plugin to declare a required dependency on other [plugins](#plugins) where: +Used within a plugin to declare a required dependency on other [plugins](#plugins) required for +the current plugin to operate (plugins listed must be registered before the server is initialized +or started) where: -- `dependencies` - a single string or an array of plugin name strings which must be registered in - order for this plugin to operate. Plugins listed must be registered before the server is - initialized or started. +- `dependencies` - one of: + - a single plugin name string. + - an array of plugin name strings. + - an object where each key is a plugin name and each matching value is a + [version range string](https://www.npmjs.com/package/semver) which must match the registered + plugin version. - `after` - (optional) a function that is called after all the specified dependencies have been registered and before the server starts. The function is only called if the server is initialized @@ -1579,9 +1449,6 @@ The `after` method is identical to setting a server extension point on `'onPreSt If a circular dependency is detected, an exception is thrown (e.g. two plugins each has an `after` function to be called after the other). -The method does not provide version dependency which should be implemented using -[npm peer dependencies](http://blog.nodejs.org/2013/02/07/peer-dependencies/). - ```js const after = function (server) { @@ -1604,11 +1471,20 @@ Dependencies can also be set via the plugin `dependencies` property (does not su exports.plugin = { name: 'test', version: '1.0.0', - dependencies: 'yar', + dependencies: { + yar: '1.x.x' + }, register: function (server, options) { } }; ``` +The `dependencies` configuration accepts one of: + - a single plugin name string. + - an array of plugin name strings. + - an object where each key is a plugin name and each matching value is a + [version range string](https://www.npmjs.com/package/semver) which must match the registered + plugin version. + ### `server.encoder(encoding, encoder)` Registers a custom content encoding compressor to extend the built-in support for `'gzip'` and @@ -1619,13 +1495,13 @@ Registers a custom content encoding compressor to extend the built-in support fo - `encoder` - a function using the signature `function(options)` where `options` are the encoding specific options configured in the route [`compression`](#route.options.compression) option, and the return value is an object compatible with the output of node's - [`zlib.createGzip()`](https://nodejs.org/dist/latest-v6.x/docs/api/zlib.html#zlib_zlib_creategzip_options). + [`zlib.createGzip()`](https://nodejs.org/api/zlib.html#zlib_zlib_creategzip_options). Return value: none. ```js const Zlib = require('zlib'); -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80, routes: { compression: { special: { chunkSize: 16 * 1024 } } } }); server.encoder('special', (options) => Zlib.createGzip(options)); @@ -1643,48 +1519,33 @@ Register custom application events where: - `name` - the event name string (required). - - `channels` - a string or array of strings specifying the event channels available. - Defaults to no channel restrictions (event updates can specify a channel or not). - - - `clone` - if `true`, the `data` object passed to [`server.events.emit()`](#server.events.emit()) - is cloned before it is passed to the listeners (unless an override specified by each - listener). Defaults to `false` (`data` is passed as-is). + - `channels` - a string or array of strings specifying the event channels available. Defaults to no channel restrictions (event updates can specify a channel or not). - - `spread` - if `true`, the `data` object passed to [`server.event.emit()`](#server.event.emit()) - must be an array and the `listener` method is called with each array element passed as a - separate argument (unless an override specified by each listener). This should only be - used when the emitted data structure is known and predictable. Defaults to `false` (`data` - is emitted as a single argument regardless of its type). + - `clone` - if `true`, the `data` object passed to [`server.events.emit()`](#server.events.emit()) is cloned before it is passed to the listeners (unless an override specified by each listener). Defaults to `false` (`data` is passed as-is). - - `tags` - if `true` and the `criteria` object passed to [`server.event.emit()`](#server.event.emit()) - includes `tags`, the tags are mapped to an object (where each tag string is the key and - the value is `true`) which is appended to the arguments list at the end. A configuration - override can be set by each listener. Defaults to `false`. + - `spread` - if `true`, the `data` object passed to [`server.event.emit()`](#server.event.emit()) must be an array and the `listener` method is called with each array element passed as a separate argument (unless an override specified by each listener). This should only be used when the emitted data structure is known and predictable. Defaults to `false` (`data` is emitted as a single argument regardless of its type). - - `shared` - if `true`, the same event `name` can be registered multiple times where the - second registration is ignored. Note that if the registration config is changed between - registrations, only the first configuration is used. Defaults to `false` (a duplicate - registration will throw an error). + - `tags` - if `true` and the `criteria` object passed to [`server.event.emit()`](#server.event.emit()) includes `tags`, the tags are mapped to an object (where each tag string is the key and the value is `true`) which is appended to the arguments list at the end. A configuration override can be set by each listener. Defaults to `false`. - - a [**podium**](https://github.com/hapijs/podium) emitter object. + - `shared` - if `true`, the same event `name` can be registered multiple times where the second registration is ignored. Note that if the registration config is changed between registrations, only the first configuration is used. Defaults to `false` (a duplicate registration will throw an error). - an array containing any of the above. Return value: none. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { const server = Hapi.server({ port: 80 }); server.event('test'); server.events.on('test', (update) => console.log(update)); - await server.events.emit('test', 'hello'); + await server.events.gauge('test', 'hello'); } ``` -### `await server.events.emit(criteria, data)` +### `server.events.emit(criteria, data)` Emits a custom application event to all the subscribed listeners where: @@ -1696,35 +1557,31 @@ Emits a custom application event to all the subscribed listeners where: - `channel` - the channel name string. - `tags` - a tag string or array of tag strings. -- `data` - the value emitted to the subscribers. If `data` is a function, the function signature - is `function()` and it called once to generate (return value) the actual data emitted to the - listeners. If no listeners match the event, the `data` function is not invoked. +- `data` - the value emitted to the subscribers. If `data` is a function, the function signature is `function()` and it called once to generate (return value) the actual data emitted to the listeners. If no listeners match the event, the `data` function is not invoked. Return value: none. -Note that events must be registered before they can be emitted or subscribed to by calling -[`server.event(events)`](#server.event()). This is done to detect event name misspelling and -invalid event activities. +Note that events must be registered before they can be emitted or subscribed to by calling [`server.event(events)`](#server.event()). This is done to detect event name misspelling and invalid event activities. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { const server = Hapi.server({ port: 80 }); server.event('test'); server.events.on('test', (update) => console.log(update)); - await server.events.emit('test', 'hello'); // await is optional + server.events.emit('test', 'hello'); } ``` -### `server.events.on(criteria, listener)` +### `server.events.on(criteria, listener, context)` Subscribe to an event where: - `criteria` - the subscription criteria which must be one of: - - event name string which can be any of the [built-in server events](#server-events) or a + - event name string which can be any of the [built-in server events](#server.events) or a custom application event registered with [`server.event()`](#server.event()). - a criteria object with the following optional keys (unless noted otherwise): @@ -1767,37 +1624,38 @@ Subscribe to an event where: - `listener` - the handler method set to receive event updates. The function signature depends on the event argument, and the `spread` and `tags` options. +- `context` - an object that binds to the listener handler. Return value: none. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { const server = Hapi.server({ port: 80 }); server.event('test'); server.events.on('test', (update) => console.log(update)); - await server.events.emit('test', 'hello'); + server.events.emit('test', 'hello'); } ``` -### `server.events.once(criteria, listener)` +### `server.events.once(criteria, listener, context)` Same as calling [`server.events.on()`](#server.events.on()) with the `count` option set to `1`. Return value: none. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { const server = Hapi.server({ port: 80 }); server.event('test'); server.events.once('test', (update) => console.log(update)); - await server.events.emit('test', 'hello'); - await server.events.emit('test', 'hello'); // Ignored + server.events.emit('test', 'hello'); + server.events.emit('test', 'hello'); // Ignored } ``` @@ -1808,24 +1666,35 @@ Same as calling [`server.events.on()`](#server.events.on()) with the `count` opt Return value: a promise that resolves when the event is emitted. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { const server = Hapi.server({ port: 80 }); server.event('test'); const pending = server.events.once('test'); - await server.events.emit('test', 'hello'); + server.events.emit('test', 'hello'); const update = await pending; } ``` -### `server.expose(key, value)` +### `await server.events.gauge(criteria, data)` + +Behaves identically to [`server.events.emit()`](#server.events.emit()), but also returns an array of the results of all the event listeners that run. The return value is that of `Promise.allSettled()`, where each item in the resulting array is `{ status: 'fulfilled', value }` in the case of a successful handler, or `{ status: 'rejected', reason }` in the case of a handler that throws. + +Please note that system errors such as a `TypeError` are not handled specially, and it's recommended to scrutinize any rejections using something like [bounce](https://hapi.dev/module/bounce/). + +### `server.expose(key, value, [options])` Used within a plugin to expose a property via [`server.plugins[name]`](#server.plugins) where: - `key` - the key assigned ([`server.plugins[name][key]`](#server.plugins)). - `value` - the value assigned. +- `options` - optional settings: + - `scope` - controls how to handle the presence of a plugin scope in the name (e.g. `@hapi/test`): + - `false` - the scope is removed (e.g. `@hapi/test` is changed to `test` under `server.plugins`). This is the default. + - `true` - the scope is retained as-is (e.g. `@hapi/test` is used as `server.plugins['@hapi/test']`). + - `'underscore'` - the scope is rewritten (e.g. `@hapi/test` is used as `server.plugins.hapi__test`). Return value: none. @@ -1905,10 +1774,13 @@ points where: configuring route-level extensions, or when adding server extensions. Defaults to `'server'` which applies to any route added to the server the extension is added to. + - `timeout` - number of milliseconds to wait for the `method` to complete before returning + a timeout error. Defaults to no timeout. + Return value: none. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -1932,15 +1804,16 @@ async function example() { } ``` -### `server.ext(event, method, [options])` +### `server.ext(event, [method, [options]])` -Registers a single extension event using the same properties as used in -[`server.ext(events)`](#server.ext()), but passed as arguments. +Registers a single extension event using the same properties as used in [`server.ext(events)`](#server.ext()), but passed as arguments. -Return value: none. +The `method` may be omitted (if `options` isn't present) or passed `null` which will cause the function to return a promise. The promise is resolved with the `request` object on the first invocation of the extension point. This is primarily used for writing tests without having to write custom handlers just to handle a single event. + +Return value: a promise if `method` is omitted, otherwise `undefined`. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -1976,8 +1849,8 @@ the process when the server fails to start properly. If you must try to resume a [`server.stop()`](#server.stop()) first to reset the server state. ```js -const Hapi = require('hapi'); -const Hoek = require('hoek'); +const Hapi = require('@hapi/hapi'); +const Hoek = require('@hapi/hoek'); async function example() { @@ -1992,7 +1865,7 @@ Injects a request into the server simulating an incoming HTTP request without ma socket connection. Injection is useful for testing purposes as well as for invoking routing logic internally without the overhead and limitations of the network stack. -The method utilizes the [**shot**](https://github.com/hapijs/shot) module for performing +The method utilizes the [**shot**](https://hapi.dev/family/shot/api) module for performing injections, with some additional options and response properties: - `options` - can be assigned a string with the requested URI, or an object with: @@ -2003,6 +1876,10 @@ injections, with some additional options and response properties: (e.g. `'example.com:8080'`), it is used to automatically set an HTTP 'Host' header, unless one was specified in `headers`. + - `authority` - (optional) a string specifying the HTTP 'Host' header value. Only used if 'Host' + is not specified in `headers` and the `url` does not include an authority component. + Default is inferred from runtime server information. + - `headers` - (optional) an object with optional request headers where each key is the header name and the value is the header content. Defaults to no additions to the default **shot** headers. @@ -2011,20 +1888,29 @@ injections, with some additional options and response properties: an object it will be converted to a string for you. Defaults to no payload. Note that payload processing defaults to `'application/json'` if no 'Content-Type' header provided. - - `credentials` - (optional) an credentials object containing authentication information. The - `credentials` are used to bypass the default authentication strategies, and are validated - directly as if they were received via an authentication scheme. Defaults to no credentials. + - `auth` - (optional) an object containing parsed authentication credentials where: - - `artifacts` - (optional) an artifacts object containing authentication artifact information. - The `artifacts` are used to bypass the default authentication strategies, and are validated - directly as if they were received via an authentication scheme. Ignored if set without - `credentials`. Defaults to no artifacts. + - `strategy` - (required) the authentication strategy name matching the provided + credentials. + + - `credentials` - (required) a credentials object containing authentication information. + The `credentials` are used to bypass the default authentication strategies, and are + validated directly as if they were received via an authentication scheme. + + - `artifacts` - (optional) an artifacts object containing authentication artifact + information. The `artifacts` are used to bypass the default authentication strategies, + and are validated directly as if they were received via an authentication scheme. + Defaults to no artifacts. + + - `payload` - (optional) disables payload authentication when set to false. + Only required when an authentication strategy requires payload authentication. + Defaults to `true`. - `app` - (optional) sets the initial value of `request.app`, defaults to `{}`. - `plugins` - (optional) sets the initial value of `request.plugins`, defaults to `{}`. - - `allowInternals` - (optional) allows access to routes with `config.isInternal` set to `true`. + - `allowInternals` - (optional) allows access to routes with `options.isInternal` set to `true`. Defaults to `false`. - `remoteAddress` - (optional) sets the remote address for the incoming connection. @@ -2069,8 +1955,11 @@ Return value: a response object with the following properties: - `request` - the [request object](#request). +Throws a Boom error if the request processing fails. The partial response object is exposed on +the `data` property. + ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -2103,7 +1992,7 @@ information or output to the console. The arguments are: Return value: none. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); server.events.on('log', (event, tags) => { @@ -2125,12 +2014,12 @@ Looks up a route configuration where: Return value: the [route information](#request.route) if found, otherwise `null`. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server(); server.route({ method: 'GET', path: '/', - config: { + options: { id: 'root', handler: () => 'ok' } @@ -2150,12 +2039,12 @@ Looks up a route configuration where: Return value: the [route information](#request.route) if found, otherwise `null`. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server(); server.route({ method: 'GET', path: '/', - config: { + options: { id: 'root', handler: () => 'ok' } @@ -2172,7 +2061,9 @@ Registers a [server method](#server.methods) where: - `method` - the method function with a signature `async function(...args, [flags])` where: - `...args` - the method function arguments (can be any number of arguments or none). - - `flags` - when caching is enabled, an object used to set optional method result flags: + - `flags` - when caching is enabled, an object used to set optional method result flags. This + parameter is provided automatically and can only be accessed/modified within the method + function. It cannot be passed as an argument. - `ttl` - `0` if result is valid but cannot be cached. Defaults to cache policy. - `options` - (optional) configuration object: @@ -2182,7 +2073,7 @@ Registers a [server method](#server.methods) where: registered. Ignored if the method is an arrow function. - `cache` - the same cache configuration used in [`server.cache()`](#server.cache()). The - `generateTimeout` option is required. + `generateTimeout` option is required, and the `generateFunc` options is not allowed. - `generateKey` - a function used to generate a unique key (for caching) from the arguments passed to the method function (the `flags` argument is not passed as input). The server @@ -2204,7 +2095,7 @@ following properties and methods: Simple arguments example: ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -2220,7 +2111,7 @@ async function example() { Object argument example: ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -2334,7 +2225,7 @@ Registers a plugin where: - `vhost` - virtual host string (or array of strings) applied to every route. The outer-most `vhost` overrides the any nested configuration. -Return value: none. +Return value: a reference to the `server`. ```js async function example() { @@ -2386,7 +2277,7 @@ Note that the `options` object is deeply cloned (with the exception of `bind` wh copied) and cannot contain any values that are unsafe to perform deep copy on. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); // Handler in top level @@ -2403,7 +2294,7 @@ const user = { } }; -server.route({ method: 'GET', path: '/user', config: user }); +server.route({ method: 'GET', path: '/user', options: user }); // An array of routes @@ -2416,20 +2307,20 @@ server.route([ #### Path parameters Parameterized paths are processed by matching the named parameters to the content of the incoming -request path at that path segment. For example, '/book/{id}/cover' will match '/book/123/cover' and -`request.params.id` will be set to `'123'`. Each path segment (everything between the opening '/' -and the closing '/' unless it is the end of the path) can only include one named parameter. A -parameter can cover the entire segment ('/{param}') or part of the segment ('/file.{ext}'). A path -parameter may only contain letters, numbers and underscores, e.g. '/{file-name}' is invalid -and '/{file_name}' is valid. - -An optional '?' suffix following the parameter name indicates an optional parameter (only allowed +request path at that path segment. For example, `'/book/{id}/cover'` will match `'/book/123/cover'` and +`request.params.id` will be set to `'123'`. Each path segment (everything between the opening `'/'` +and the closing `'/'` unless it is the end of the path) can only include one named parameter. A +parameter can cover the entire segment (`'/{param}'`) or part of the segment (`'/file.{ext}'`). A path +parameter may only contain letters, numbers and underscores, e.g. `'/{file-name}'` is invalid +and `'/{file_name}'` is valid. + +An optional `'?'` suffix following the parameter name indicates an optional parameter (only allowed if the parameter is at the ends of the path or only covers part of the segment as in -'/a{param?}/b'). For example, the route '/book/{id?}' matches '/book/' with the value of +`'/a{param?}/b'`). For example, the route `'/book/{id?}'` matches `'/book/'` with the value of `request.params.id` set to an empty string `''`. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const getAlbum = function (request, h) { @@ -2452,7 +2343,7 @@ can be anything, then use `*` without a number (matching any number of segments the last path segment). ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const getPerson = function (request, h) { @@ -2490,11 +2381,10 @@ routing table node. #### Catch all route If the application needs to override the default Not Found (404) error response, it can add a -catch-all route for a specific method or all methods. Only one catch-all route can be defined per -server connection. +catch-all route for a specific method or all methods. Only one catch-all route can be defined. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const handler = function (request, h) { @@ -2510,7 +2400,7 @@ server.route({ method: '*', path: '/{p*}', handler }); Defines a route rules processor for converting route rules object into route configuration where: - `processor` - a function using the signature `function(rules, info)` where: - - `rules` - + - `rules` - the [custom object](#route.options.rules) defined in your routes configuration for you to use its values. - `info` - an object with the following properties: - `method` - the route method. - `path` - the route path. @@ -2525,8 +2415,74 @@ Defines a route rules processor for converting route rules object into route con Note that the root server and each plugin server instance can only register one rules processor. If a route is added after the rules are configured, it will not include the rules config. Routes added by plugins apply the rules to each of the parent realms' rules from the root to the route's -realm. This means the processor defined by the plugin override the config generated by the root -processor if they overlap. The route `config` overrides the rules config if the overlap. +realm. This means the processor defined by the plugin overrides the config generated by the root +processor if they overlap. Similarly, the route's own config overrides the config produced by the rules processors. + +```js +const validateSchema = { + auth: Joi.string(), + myCustomPre: Joi.array().min(2).items(Joi.string()), + payload: Joi.object() +}; + +const myPreHelper = (name) => { + + return { + method: (request, h) => { + + return `hello ${name || 'world'}!`; + }, + assign: 'myPreHelper' + }; +}; + +const processor = (rules, info) => { + + if (!rules) { + return null; + } + + const options = {}; + + if (rules.auth) { + options.auth = { + strategy: rules.auth, + validate: { + entity: 'user' + } + }; + } + + if (rules.myCustomPre) { + options.pre = [ + myPreHelper(...rules.myCustomPre) + ]; + } + + if (rules.payload) { + options.validate = { payload: Joi.object(rules.payload) }; + } + + return options; +}; + +server.rules(processor, { + validate: { schema: validateSchema } +}); + +server.route({ + method: 'GET', + path: '/', + rules: { + auth: 'jwt', + myCustomPre: ['arg1', 'arg2'], + payload: { a: Joi.boolean(), b: Joi.string() } + }, + options: { + id: 'my-route' + } +}); +``` ### `await server.start()` @@ -2546,7 +2502,7 @@ If a started server is started again, the second call to `server.start()` is ign will be emitted and no extension points invoked. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -2558,15 +2514,14 @@ async function example() { ### `server.state(name, [options])` -[HTTP state management](http://tools.ietf.org/html/rfc6265) uses client cookies to persist a state +[HTTP state management](https://tools.ietf.org/html/rfc6265) uses client cookies to persist a state across multiple requests. Registers a cookie definitions where: - `name` - the cookie name string. - `options` - are the optional cookie settings: - - `ttl` - time-to-live in milliseconds. Defaults to `null` (session time-life - cookies are - deleted when the browser is closed). + - `ttl` - time-to-live in milliseconds. Defaults to `null` (session time-life - cookies are deleted when the browser is closed). - `isSecure` - sets the 'Secure' flag. Defaults to `true`. @@ -2577,49 +2532,47 @@ across multiple requests. Registers a cookie definitions where: - `false` - no flag. - `'Strict'` - sets the value to `'Strict'` (this is the default value). - `'Lax'` - sets the value to `'Lax'`. + - `'None'` - sets the value to `'None'`. + + - `isPartitioned` - sets the ['Partitioned' flag](https://developers.google.com/privacy-sandbox/3pcd/chips). Defaults to `false`. Requires `isSecure` to be `true` and `isSameSite` to be `'None'`. - `path` - the path scope. Defaults to `null` (no path). - `domain` - the domain scope. Defaults to `null` (no domain). - - `autoValue` - if present and the cookie was not received from the client or explicitly set by - the route handler, the cookie is automatically added to the response with the provided value. - The value can be a function with signature `async function(request)` where: + - `autoValue` - if present and the cookie was not received from the client or explicitly set by the route handler, the cookie is automatically added to the response with the provided value, unless state parsing is disabled or the response code is 500. The value can be a function with signature `async function(request)` where: - `request` - the [request object](#request). - `encoding` - encoding performs on the provided value before serialization. Options are: - - `'none'` - no encoding. When used, the cookie value must be a string. This is the default - value. + - `'none'` - no encoding. When used, the cookie value must be a string. This is the default value. - `'base64'` - string value is encoded using Base64. - `'base64json'` - object value is JSON-stringified then encoded using Base64. - `'form'` - object value is encoded using the _x-www-form-urlencoded_ method. - - `'iron'` - Encrypts and sign the value using - [**iron**](https://github.com/hueniverse/iron). + - `'iron'` - Encrypts and sign the value using [**iron**](https://hapi.dev/family/iron/api). - - `sign` - an object used to calculate an HMAC for cookie integrity validation. This does not - provide privacy, only a mean to verify that the cookie value was generated by the server. - Redundant when `'iron'` encoding is used. Options are: + - `sign` - an object used to calculate an HMAC for cookie integrity validation. This does not provide privacy, only a mean to verify that the cookie value was generated by the server. Redundant when `'iron'` encoding is used. Options are: - - `integrity` - algorithm options. Defaults to - [`require('iron').defaults.integrity`](https://github.com/hueniverse/iron#options). + - `integrity` - algorithm options. Defaults to [`require('iron').defaults.integrity`](https://hapi.dev/family/iron/api/#options). - `password` - password used for HMAC key generation (must be at least 32 characters long). - `password` - password used for `'iron'` encoding (must be at least 32 characters long). - - `iron` - options for `'iron'` encoding. Defaults to - [`require('iron').defaults`](https://github.com/hueniverse/iron#options). + - `iron` - options for `'iron'` encoding. Defaults to [`require('iron').defaults`](https://hapi.dev/family/iron/api/#options). - `ignoreErrors` - if `true`, errors are ignored and treated as missing cookies. - - `clearInvalid` - if `true`, automatically instruct the client to remove invalid - cookies. Defaults to `false`. + - `clearInvalid` - if `true`, automatically instruct the client to remove invalid cookies. Defaults to `false`. + + - `strictHeader` - if `false`, allows any cookie value including values in violation of [RFC 6265](https://tools.ietf.org/html/rfc6265). Defaults to `true`. - - `strictHeader` - if `false`, allows any cookie value including values in - violation of [RFC 6265](https://tools.ietf.org/html/rfc6265). Defaults to `true`. + - `passThrough` - used by proxy plugins (e.g. [**h2o2**](https://hapi.dev/family/h2o2/api)). - - `passThrough` - used by proxy plugins (e.g. [**h2o2**](https://github.com/hapijs/h2o2)). + - `contextualize` - a function using the signature `async function(definition, request)` used to override a request-specific cookie settings where: + + - `definition` - a copy of the `options` to be used for formatting the cookie that can be manipulated by the function to customize the request cookie header. Note that changing the `definition.contextualize` property will be ignored. + - `request` - the current request object. Return value: none. @@ -2627,7 +2580,7 @@ State defaults can be modified via the [server.options.state](#server.options.st option. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); // Set cookie definition @@ -2659,14 +2612,14 @@ Registered cookies are automatically parsed when received. Parsing rules depends it is not included in [`request.state`](#request.state), regardless of the [`state.failAction`](#route.options.state.failAction) setting. When [`state.failAction`](#route.options.state.failAction) is set to `'log'` and an invalid cookie value is received, the server will emit a -`'request-internal'` event. To capture these errors subscribe to the `'request-internal'` events -and filter on `'error'` and `'state'` tags: +[`'request'` event](#server.events.request). To capture these errors subscribe to the `'request'` +event on the `'internal'` channel and filter on `'error'` and `'state'` tags: ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); -server.events.on('request-internal', (request, event, tags) => { +server.events.on({ name: 'request', channels: 'internal' }, (request, event, tags) => { if (tags.error && tags.state) { console.error(event); @@ -2688,12 +2641,12 @@ where: - `cookies` - a single object or an array of object where each contains: - `name` - the cookie name. - `value` - the cookie value. - - `options` - cookie configuration to override the server settings. + - `options` - cookie configuration to override the server settings. Return value: a header string. Note that this utility uses the server configuration but does not change the server state. It is -provided for manual cookie formating (e.g. when headers are set manually). +provided for manual cookie formatting (e.g. when headers are set manually). ### `await server.states.parse(header)` @@ -2713,13 +2666,19 @@ connections will continue until closed or timeout), where: - `options` - (optional) object with: - - `timeout` - overrides the timeout in millisecond before forcefully terminating a connection. - Defaults to `5000` (5 seconds). + - `timeout` - sets the timeout in millisecond before forcefully terminating any open + connections that arrived before the server stopped accepting new connections. The timeout + only applies to waiting for existing connections to close, and not to any + [`'onPreStop'` or `'onPostStop'` server extensions](#server.ext.args()) which can + delay or block the stop operation indefinitely. Ignored if + [`server.options.operations.cleanStop`](#server.options.operations) is `false`. Note that if + the server is set as a [group controller](#server.control()), the timeout is per controlled + server and the controlling server itself. Defaults to `5000` (5 seconds). Return value: none. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); async function example() { @@ -2743,13 +2702,34 @@ Return value: an array of routes where each route contains: - `path` - the route path. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); server.route({ method: 'GET', path: '/example', handler: () => 'ok' }); const table = server.table(); ``` +### `server.validator(validator)` + +Registers a server validation module used to compile raw validation rules into validation schemas for all routes where: + +- `validator` - the validation module (e.g. **joi**). + +Return value: none. + +Note: the validator is only used when validation rules are not pre-compiled schemas. When a validation rules is a function or schema object, the rule is used as-is and the validator is not used. When setting a validator inside a plugin, the validator is only applied to routes set up by the plugin and plugins registered by it. + +```js +const Hapi = require('@hapi/hapi'); +const Joi = require('joi'); + +async function example() { + + const server = Hapi.server({ port: 80 }); + server.validator(Joi); +} +``` + ## Route options Each route can be customized to change the default behavior of the request lifecycle. @@ -2828,7 +2808,7 @@ The authentication mode. Available values: Default value: `false`, unless the scheme requires payload authentication. If set, the incoming request payload is authenticated after it is processed. Requires a strategy -with payload authentication support (e.g. [Hawk](#https://github.com/hueniverse/hawk)). Cannot be +with payload authentication support (e.g. [Hawk](https://hapi.dev/family/hawk/api)). Cannot be set to a value other than `'required'` when the scheme sets the authentication `options.payload` to `true`. @@ -2895,7 +2875,7 @@ encoder settings. Note that decoder settings are set in [`compression`](#route.o Default value: `false` (no CORS headers). -The [Cross-Origin Resource Sharing](http://www.w3.org/TR/cors/) protocol allows browsers to make +The [Cross-Origin Resource Sharing](https://www.w3.org/TR/cors/) protocol allows browsers to make cross-origin API calls. CORS is required by web applications running inside a browser which are loaded from a different domain than the API server. To enable, set `cors` to `true`, or to an object with the following options: @@ -2925,6 +2905,9 @@ object with the following options: - `credentials` - if `true`, allows user credentials to be sent ('Access-Control-Allow-Credentials'). Defaults to `false`. + - `preflightStatusCode` - the status code used for CORS preflight responses, either `200` or `204`. + Defaults to `200`. + ### `route.options.description` Default value: none. @@ -2959,7 +2942,7 @@ The route handler function performs the main business logic of the route and set - a [lifecycle method](#lifecycle-methods). -- an object with a single property using the name of a handler type registred with the +- an object with a single property using the name of a handler type registered with the [`server.decorate()`](#server.decorate()) method. The matching property value is passed as options to the registered handler generator. @@ -3001,24 +2984,9 @@ string payload or escaping it after stringification. Supports the following: - `suffix` - string suffix added after conversion to JSON string. Defaults to no suffix. -- `escape` - calls [`Hoek.jsonEscape()`](https://github.com/hapijs/hoek/blob/master/API.md#escapejsonstring) +- `escape` - calls [`Hoek.jsonEscape()`](https://hapi.dev/family/hoek/api/#escapejsonstring) after conversion to JSON string. Defaults to `false`. -### `route.options.jsonp` - -Default value: none. - -Enables JSONP support by setting the value to the query parameter name containing the function name -used to wrap the response payload. - -For example, if the value is `'callback'`, a request comes in with `'callback=me'`, and the JSON -response is `'{ "a":"b" }'`, the payload will be `'me({ "a":"b" });'`. Cannot be used with stream -responses. - -The 'Content-Type' response header is set to `'text/javascript'` and the 'X-Content-Type-Options' -response header is set to `'nosniff'`, and will override those headers even if explicitly set by -[`response.type()`](#response.type()). - ### `route.options.log` Default value: `{ collect: false }`. @@ -3083,13 +3051,21 @@ Default value: `1048576` (1MB). Limits the size of incoming payloads to the specified byte count. Allowing very large payloads may cause the server to run out of memory. +#### `route.options.payload.maxParts` + +Default value: `1000`. + +Limits the number of parts allowed in multipart payloads. + #### `route.options.payload.multipart` -Default value: none. +Default value: `false`. Overrides payload processing for multipart requests. Value can be one of: -- `false` - disable multipart processing. +- `false` - disable multipart processing (this is the default value). + +- `true` - enable multipart processing using the [`output`](#route.options.payload.output) value. - an object with the following required options: @@ -3116,9 +3092,9 @@ The processed payload format. The value must be one of: values are presented as text while files are provided as streams. File streams from a 'multipart/form-data' upload will also have a `hapi` property containing the `filename` and `headers` properties. Note that payload streams for multipart payloads are a synthetic interface - created on top of the entire mutlipart content loaded into memory. To avoid loading large + created on top of the entire multipart content loaded into memory. To avoid loading large multipart payloads into memory, set [`parse`](#route.options.payload.parse) to `false` and handle - the multipart payload in the handler using a streaming parser (e.g. [**pez**](https://github.com/hapijs/pez)). + the multipart payload in the handler using a streaming parser (e.g. [**pez**](https://hapi.dev/family/pez/api)). - `'file'` - the incoming payload is written to temporary file in the directory specified by the [`uploads`](#route.options.payload.uploads) settings. If the payload is 'multipart/form-data' and @@ -3149,6 +3125,20 @@ Determines if the incoming payload is processed or presented raw. Available valu - `'gunzip'` - the raw payload is returned unmodified after any known content encoding is decoded. +#### `route.options.payload.protoAction` + +Default value: `'error'`. + +Sets handling of incoming payload that may contain a prototype poisoning security attack. Available +values: + +- `'error'` - returns a `400` bad request error when the payload contains a prototype. + +- `'remove'` - sanitizes the payload to remove the prototype. + +- `'ignore'` - disables the protection and allows the payload to pass as received. Use this option + only when you are sure that such incoming data cannot pose any risks to your application. + #### `route.options.payload.timeout` Default value: to `10000` (10 seconds). @@ -3209,7 +3199,7 @@ value is used to assign the corresponding [`request.pre`](#request.pre) and [lifecycle methods](#lifecycle-methods). ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const pre1 = function (request, h) { @@ -3230,7 +3220,7 @@ const pre3 = function (request, h) { server.route({ method: 'GET', path: '/', - config: { + options: { pre: [ [ // m1 and m2 executed in parallel @@ -3251,9 +3241,18 @@ server.route({ Processing rules for the outgoing response. +#### `route.options.response.disconnectStatusCode` + + Default value: `499`. + +The default HTTP status code used to set a response error when the request is closed or aborted +before the response is fully transmitted. Value can be any integer greater or equal to `400`. The +default value `499` is based on the non-standard nginx "CLIENT CLOSED REQUEST" error. The value is +only used for logging as the request has already ended. + #### `route.options.response.emptyStatusCode` - Default value: `200`. + Default value: `204`. The default HTTP status code when the payload is considered empty. Value can be `200` or `204`. Note that a `200` status code is converted to a `204` only at the time of response transmission @@ -3276,12 +3275,7 @@ If `true`, applies the validation rule changes to the response payload. Default value: none. -[**joi**](http://github.com/hapijs/joi) options object pass to the validation function. Useful to -set global options such as `stripUnknown` or `abortEarly` (the complete list is available -[here](https://github.com/hapijs/joi/blob/master/API.md#validatevalue-schema-options-callback)). -If a custom validation function is defined via [`schema`](#route.options.response.schema) or -[`status`](#route.options.response.status) then `options` can an arbitrary object that will be -passed to this function as the second argument. +[**joi**](https://joi.dev/api) options object pass to the validation function. Useful to set global options such as `stripUnknown` or `abortEarly`. If a custom validation function is defined via [`schema`](#route.options.response.schema) or [`status`](#route.options.response.status) then `options` can an arbitrary object that will be passed to this function as the second argument. #### `route.options.response.ranges` @@ -3305,15 +3299,15 @@ The default response payload validation rules (for all non-error responses) expr - `false` - no payload allowed. -- a [**joi**](http://github.com/hapijs/joi) validation object. The [`options`](#route.options.response.options) - along with the request context (`{ headers, params, query, payload, app, auth }`) are passed to +- a [**joi**](https://joi.dev/api) validation object. The [`options`](#route.options.response.options) + along with the request context (`{ headers, params, query, payload, state, app, auth }`) are passed to the validation function. - a validation function using the signature `async function(value, options)` where: - `value` - the pending response payload. - `options` - The [`options`](#route.options.response.options) along with the request context - (`{ headers, params, query, payload, app, auth }`). + (`{ headers, params, query, payload, state, app, auth }`). - if the function returns a value and [`modify`](#route.options.response.modify) is `true`, the value is used as the new response. If the original response is an error, the return @@ -3371,13 +3365,17 @@ following options: otherwise this field is ignored. If `rule` is `'allow-from'` but `source` is unset, the rule will be automatically changed to `'sameorigin'`. -- `xss` - boolean that controls the 'X-XSS-PROTECTION' header for Internet Explorer. Defaults to - `true` which sets the header to equal `'1; mode=block'`. - - Note: this setting can create a security vulnerability in versions of Internet Exploere below - 8, as well as unpatched versions of IE8. See [here](http://hackademix.net/2009/11/21/ies-xss-filter-creates-xss-vulnerabilities/) - and [here](https://technet.microsoft.com/library/security/ms10-002) for more information. If - you actively support old versions of IE, it may be wise to explicitly set this flag to - `false`. +- `xss` - controls the 'X-XSS-Protection' header, where: + + - `'disabled'` - the header will be set to `'0'`. This is the default value. + - `'enabled'` - the header will be set to `'1; mode=block'`. + - `false` - the header will be omitted. + + Note: when enabled, this setting can create a security vulnerabilities in versions of Internet Explorer + below 8, unpatched versions of IE8, and browsers that employ an XSS filter/auditor. See + [here](https://hackademix.net/2009/11/21/ies-xss-filter-creates-xss-vulnerabilities/), + [here](https://technet.microsoft.com/library/security/ms10-002), and + [here](https://blog.innerht.ml/the-misunderstood-x-xss-protection/) for more information. - `noOpen` - boolean controlling the 'X-Download-Options' header for Internet Explorer, preventing downloads from executing in your context. Defaults to `true` setting the header to `'noopen'`. @@ -3385,6 +3383,18 @@ following options: - `noSniff` - boolean controlling the 'X-Content-Type-Options' header. Defaults to `true` setting the header to its only and default option, `'nosniff'`. +- `referrer` - controls the ['Referrer-Policy'](https://www.w3.org/TR/referrer-policy/) header, which has the following possible values. + - `false` - the 'Referrer-Policy' header will not be sent to clients with responses. This is the default value. + - `''` - instructs clients that the Referrer-Policy will be [defined elsewhere](https://www.w3.org/TR/referrer-policy/#referrer-policy-empty-string), such as in a meta html tag. + - `'no-referrer'` - instructs clients to never include the referrer header when making requests. + - `'no-referrer-when-downgrade'` - instructs clients to never include the referrer when navigating from HTTPS to HTTP. + - `'same-origin'` - instructs clients to only include the referrer on the current site origin. + - `'origin'` - instructs clients to include the referrer but strip off path information so that the value is the current origin only. + - `'strict-origin'` - same as `'origin'` but instructs clients to omit the referrer header when going from HTTPS to HTTP. + - `'origin-when-cross-origin'` - instructs clients to include the full path in the referrer header for same-origin requests but only the origin components of the URL are included for cross origin requests. + - `'strict-origin-when-cross-origin'` - same as `'origin-when-cross-origin'` but the client is instructed to omit the referrer when going from HTTPS to HTTP. + - `'unsafe-url'` - instructs the client to always include the referrer with the full URL. + ### `route.options.state` Default value: `{ parse: true, failAction: 'error' }`. @@ -3430,7 +3440,7 @@ behavior. Set to `false` to disable socket timeouts. ### `route.options.validate` -Default value: `{ headers: true, params: true, query: true, payload: true, failAction: 'error' }`. +Default value: `{ headers: true, params: true, query: true, payload: true, state: true, failAction: 'error' }`. Request input validation rules for various request components. @@ -3446,7 +3456,8 @@ Default value: `'error'` (return a Bad Request (400) error response). A [`failAction` value](#lifecycle-failAction) which determines how to handle failed validations. When set to a function, the `err` argument includes the type of validation error under -`err.output.payload.validation.source`. +`err.output.payload.validation.source`. The default error that would otherwise have been logged + or returned can be accessed under `err.data.defaultError`. #### `route.options.validate.headers` @@ -3456,7 +3467,7 @@ Validation rules for incoming request headers: - `true` - any headers allowed (no validation performed). -- a [**joi**](http://github.com/hapijs/joi) validation object. +- a [**joi**](https://joi.dev/api) validation object. - a validation function using the signature `async function(value, options)` where: @@ -3473,15 +3484,14 @@ Note that all header field names must be in lowercase to match the headers norma Default value: none. -An options object passed to the [**joi**](http://github.com/hapijs/joi) rules or the custom -validation methods. Used for setting global options such as `stripUnknown` or `abortEarly` (the -complete list is available [here](https://github.com/hapijs/joi/blob/master/API.md#validatevalue-schema-options-callback)). +An options object passed to the [**joi**](https://joi.dev/api) rules or the custom +validation methods. Used for setting global options such as `stripUnknown` or `abortEarly`. If a custom validation function (see `headers`, `params`, `query`, or `payload` above) is defined then `options` can an arbitrary object that will be passed to this function as the second parameter. -The values of the other inputs (i.e. `headers`, `query`, `params`, `payload`, `app`, and `auth`) +The values of the other inputs (i.e. `headers`, `query`, `params`, `payload`, `state`, `app`, and `auth`) are added to the `options` object under the validation `context` (accessible in rules as `Joi.ref('$query.key')`). @@ -3502,7 +3512,7 @@ extracting any parameters, and storing them in [`request.params`](#request.param - `true` - any path parameter value allowed (no validation performed). -- a [**joi**](http://github.com/hapijs/joi) validation object. +- a [**joi**](https://joi.dev/api) validation object. - a validation function using the signature `async function(value, options)` where: @@ -3524,9 +3534,10 @@ Default value: `true` (no validation). Validation rules for incoming request payload (request body), where: - `true` - any payload allowed (no validation performed). + - `false` - no payload allowed. -- a [**joi**](http://github.com/hapijs/joi) validation object. +- a [**joi**](https://joi.dev/api) validation object. - Note that empty payloads are represented by a `null` value. If a validation schema is provided and empty payload are allowed, the schema must be explicitly defined by setting the rule to a **joi** schema with `null` allowed (e.g. @@ -3534,10 +3545,9 @@ Validation rules for incoming request payload (request body), where: - a validation function using the signature `async function(value, options)` where: - - `value` - the [`request.query`](#request.query) object containing the request query - parameters. + - `value` - the [`request.payload`](#request.payload) object containing the request payload. - `options` - [`options`](#route.options.validate.options). - - if a value is returned, the value is used as the new [`request.payload`](#request.query) + - if a value is returned, the value is used as the new [`request.payload`](#request.payload) value and the original value is stored in [`request.orig.payload`](#request.orig). Otherwise, the payload is left unchanged. If an error is thrown, the error is handled according to [`failAction`](#route.options.validate.failAction). @@ -3555,9 +3565,10 @@ Validation rules for incoming request URI query component (the key-value part of [`request.query`](#request.query) prior to validation. Where: - `true` - any query parameter value allowed (no validation performed). + - `false` - no query parameter value allowed. -- a [**joi**](http://github.com/hapijs/joi) validation object. +- a [**joi**](https://joi.dev/api) validation object. - a validation function using the signature `async function(value, options)` where: @@ -3571,6 +3582,36 @@ Validation rules for incoming request URI query component (the key-value part of Note that changes to the query parameters will not be reflected in [`request.url`](#request.url). +#### `route.options.validate.state` + +Default value: `true` (no validation). + +Validation rules for incoming cookies. The `cookie` header is parsed and decoded into the +[`request.state`](#request.state) prior to validation. Where: + +- `true` - any cookie value allowed (no validation performed). + +- `false` - no cookies allowed. + +- a [**joi**](https://joi.dev/api) validation object. + +- a validation function using the signature `async function(value, options)` where: + + - `value` - the [`request.state`](#request.state) object containing all parsed cookie values. + - `options` - [`options`](#route.options.validate.options). + - if a value is returned, the value is used as the new [`request.state`](#request.state) value + and the original value is stored in [`request.orig.state`](#request.orig). Otherwise, the + cookie values are left unchanged. If an error is thrown, the error is handled according to + [`failAction`](#route.options.validate.failAction). + +#### `route.options.validate.validator` + +Default value: `null` (no default validator). + +Sets a server validation module used to compile raw validation rules into validation schemas (e.g. **joi**). + +Note: the validator is only used when validation rules are not pre-compiled schemas. When a validation rules is a function or schema object, the rule is used as-is and the validator is not used. + ## Request lifecycle Each incoming request passes through the request lifecycle. The specific steps vary based on the @@ -3579,23 +3620,17 @@ the same. The following is the complete list of steps a request can go through: - _**onRequest**_ - always called when `onRequest` extensions exist. - - the request path and method can be modified via the [`request.setUrl()`](#request.setUrl()) - and [`request.setMethod()`](#request.setMethod()) methods. Changes to the request path or - method will impact how the request is routed and can be used for rewrite rules. + - the request path and method can be modified via the [`request.setUrl()`](#request.setUrl()) and [`request.setMethod()`](#request.setMethod()) methods. Changes to the request path or method will impact how the request is routed and can be used for rewrite rules. + - [`request.payload`](#request.payload) is `undefined` and can be overridden with any non-`undefined` value to bypass payload processing. - [`request.route`](#request.route) is unassigned. - - JSONP configuration is ignored for any response returned from the extension point since no - route is matched yet and the JSONP configuration is unavailable. + - [`request.url`](#request.url) can be `null` if the incoming request path is invalid. + - [`request.path`](#request.path) can be an invalid path. - _**Route lookup**_ - lookup based on `request.path` and `request.method`. - skips to _**onPreResponse**_ if no route is found or if the path violates the HTTP specification. -- _**JSONP processing**_ - - based on the route [`jsonp`](#route.options.jsonp) option. - - parses JSONP parameter from [`request.query`](#request.query). - - skips to _**Response validation**_ on error. - - _**Cookies processing**_ - based on the route [`state`](#route.options.state) option. - error handling based on [`failAction`](#route.options.state.failAction). @@ -3607,7 +3642,7 @@ the same. The following is the complete list of steps a request can go through: - based on the route [`auth`](#route.options.auth) option. - _**Payload processing**_ - - based on the route [`state`](#route.options.payload) option. + - based on the route [`payload`](#route.options.payload) option and if [`request.payload`](#request.payload) has not been overridden in _**onRequest**_. - error handling based on [`failAction`](#route.options.payload.failAction). - _**Payload authentication**_ @@ -3630,10 +3665,6 @@ the same. The following is the complete list of steps a request can go through: - based on the route [`validate.params`](#route.options.validate.params) option. - error handling based on [`failAction`](#route.options.validate.failAction). -- _**JSONP cleanup**_ - - based on the route [`jsonp`](#route.options.jsonp) option. - - remove the JSONP parameter from [`request.query`](#request.query). - - _**Query validation**_ - based on the route [`validate.query`](#route.options.validate.query) option. - error handling based on [`failAction`](#route.options.validate.failAction). @@ -3642,6 +3673,10 @@ the same. The following is the complete list of steps a request can go through: - based on the route [`validate.payload`](#route.options.validate.payload) option. - error handling based on [`failAction`](#route.options.validate.failAction). +- _**State validation**_ + - based on the route [`validate.state`](#route.options.validate.state) option. + - error handling based on [`failAction`](#route.options.validate.failAction). + - _**onPreHandler**_ - _**Pre-handler methods**_ @@ -3667,11 +3702,17 @@ the same. The following is the complete list of steps a request can go through: be passed back to _**onPreResponse**_ to prevent an infinite loop. - _**Response transmission**_ - - may emit `'request-error'` event. + - may emit a [`'request'` event](#server.events.request) on the `'error'` channel. - _**Finalize request**_ - emits `'response'` event. +- _**onPostResponse**_ + - return value is ignored since the response is already set. + - emits a [`'request'` event](#server.events.request) on the `'error'` channel if an error is returned. + - all extension handlers are executed even if some error. + - note that since the handlers are executed in serial (each is `await`ed), care must be taken to avoid blocking execution if other extension handlers expect to be called immediately when the response is sent. If an _**onPostResponse**_ handler is performing IO, it should defer that activity to another tick and return immediately (either without a return value or without a promise that is solve to resolve). + ### Lifecycle methods Lifecycle methods are the interface between the framework and the application. Many of the request @@ -3700,7 +3741,7 @@ The return value must be one of: - `Buffer` object - `Error` object - plain `Error`. - - a [`Boom`](https://github.com/hapijs/boom) object. + - a [`Boom`](https://hapi.dev/family/boom/api) object. - `Stream` object - must be compatible with the "streams2" API and not be in `objectMode`. - if the stream object has a `statusCode` property, that status code will be used as @@ -3728,7 +3769,7 @@ The return value must be one of: (auth scheme only). - a promise object that resolve to any of the above values -Any error thrown by a lifecycle method will be used as the reponse object. While errors and valid +Any error thrown by a lifecycle method will be used as the [response object](#response-object). While errors and valid values can be returned, it is recommended to throw errors. Throwing non-error values will generate a Bad Implementation (500) error response. @@ -3749,11 +3790,11 @@ via [`h.context`](#h.context). #### Lifecycle workflow -The flow between each lifecyle step depends on the value returned by each lifecycle method as +The flow between each lifecycle step depends on the value returned by each lifecycle method as follows: - an error: - - the lifecycle skips to the **_Response validation**_ step. + - the lifecycle skips to the _**Response validation**_ step. - if returned by the _**onRequest**_ step it skips to the _**onPreResponse**_ step. - if returned by the _**Response validation**_ step it skips to the _**onPreResponse**_ step. - if returned by the _**onPreResponse**_ step it skips to the _**Response transmission**_ step. @@ -3780,7 +3821,7 @@ The [`authenticate()`](#authentication-scheme) method has access to two addition - [`h.authenticated()`](#h.authenticated()) - indicate request authenticated successfully. - [`h.unauthenticated()`](#h.unauthenticated()) - indicate request failed to authenticate. -Note that these rules are apply somewhat differently when used in a [pre-handler method](#route.options.pre). +Note that these rules apply somewhat differently when used in a [pre-handler method](#route.options.pre). #### Takeover response @@ -3803,12 +3844,12 @@ values: - a [lifecycle method](#lifecycle-methods) with the signature `async function(request, h, err)` where: - `request` - the [request object](#request). - - `h` - the [response toolkit](#tookit-interface). + - `h` - the [response toolkit](#response-toolkit). - `err` - the error object. #### Errors -**hapi** uses the [**boom**](https://github.com/hapijs/boom) error library for all its internal +**hapi** uses the [**boom**](https://hapi.dev/family/boom/api) error library for all its internal error generation. **boom** provides an expressive interface to return HTTP errors. Any error thrown by a [lifecycle method](#lifecycle-methods) is converted into a **boom** object and defaults to status code `500` if the error is not already a **boom** object. @@ -3817,8 +3858,8 @@ When the error is sent back to the client, the response contains a JSON object w `statusCode`, `error`, and `message` keys. ```js -const Hapi = require('hapi'); -const Boom = require('boom'); +const Hapi = require('@hapi/hapi'); +const Boom = require('@hapi/boom'); const server = Hapi.server(); @@ -3858,7 +3899,7 @@ following properties: - `headers` - an object containing any HTTP headers where each key is a header name and value is the header content. - - `payload` - the formatted object used as the response payload (stringified). Can be directly + - `payload` - the formatted object used as the response payload. Can be directly manipulated but any changes will be lost if `reformat()` is called. Any content allowed and by default includes the following content: @@ -3876,7 +3917,7 @@ It also supports the following method: - `reformat()` - rebuilds `error.output` using the other object properties. ```js -const Boom = require('boom'); +const Boom = require('@hapi/boom'); const handler = function (request, h) { @@ -3890,11 +3931,12 @@ const handler = function (request, h) { When a different error representation is desired, such as an HTML page or a different payload format, the `'onPreResponse'` extension point may be used to identify errors and replace them with -a different response object. +a different [response object](#response-object), as in this example using [Vision's](https://hapi.dev/family/vision/api) +`.view()` [response toolkit](#response-toolkit) property. ```js -const Hapi = require('hapi'); -const Vision = require('vision'); +const Hapi = require('@hapi/hapi'); +const Vision = require('@hapi/vision'); const server = Hapi.server({ port: 80 }); server.register(Vision, (err) => { @@ -3951,7 +3993,7 @@ responsibility to write and end the response directly via [`request.raw.res`](#r Access: read only. A response symbol. When returned by a lifecycle method, the request lifecycle skips to the -finalizing step after calling `request.raw.res.end())` to close the the node response stream. +finalizing step after calling `request.raw.res.end())` to close the node response stream. ##### `h.context` @@ -3997,9 +4039,9 @@ Return value: an internal authentication object. Sets the response 'ETag' and 'Last-Modified' headers and checks for any conditional request headers to decide if the response is going to qualify for an HTTP 304 (Not Modified). If the entity values -match the request conditions, `h.entity()` returns a response object for the lifecycle method to +match the request conditions, `h.entity()` returns a [response object](#response-object) for the lifecycle method to return as its value which will set a 304 response. Otherwise, it sets the provided entity headers -and returns `undefined`. The method argumetns are: +and returns `undefined`. The method arguments are: - `options` - a required configuration object with: - `etag` - the ETag string. Required if `modified` is not present. Defaults to no header. @@ -4016,13 +4058,13 @@ is returned, it should be used as the return value (but may be customize using t methods). ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); server.route({ method: 'GET', path: '/', - config: { + options: { cache: { expiresIn: 5000 }, handler: function (request, h) { @@ -4143,6 +4185,14 @@ Application-specific state. Provides a safe place to store application data with conflicts with the framework. Should not be used by [plugins](#plugins) which should use [`plugins[name]`](#response.plugins). +##### `response.contentType` + +Access: read. + +Default value: none. + +Provides a preview of the response HTTP Content-Type header based on the implicit response type, any explicit Content-Type header set, and any content character-set defined. The returned value is only a preview as the content type can change later both internally and by user code (it represents current response state). The value is `null` if no implicit type can be determined. + ##### `response.events` Access: read only and the public **podium** interface. @@ -4157,7 +4207,7 @@ The `response.events` object supports the following events: ```js const Crypto = require('crypto'); -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const preResponse = function (request, h) { @@ -4281,7 +4331,7 @@ Return value: the current response object. Sets the 'Content-Type' HTTP header 'charset' property where: -- `charset` - the charset property value. +- `charset` - the charset property value. When `charset` value is falsy, it will prevent hapi from using its default charset setting. Return value: the current response object. @@ -4301,6 +4351,16 @@ Sets the HTTP status message where: Return value: the current response object. +#### `response.compressed(encoding)` + +Sets the HTTP 'content-encoding' header where: + +- `encoding` - the header value string. + +Return value: the current response object. + +Note that setting content encoding via this method does not set a 'vary' HTTP header with 'accept-encoding' value. To vary the response, use the `response.header()` method instead. + #### `response.created(uri)` Sets the HTTP status code to Created (201) and the HTTP 'Location' header where: @@ -4312,13 +4372,13 @@ Return value: the current response object. #### `response.encoding(encoding)` Sets the string encoding scheme used to serial data into the HTTP payload where: -- `encoding` - the encoding property value (see [node Buffer encoding](http://nodejs.org/api/buffer.html#buffer_buffer)). +- `encoding` - the encoding property value (see [node Buffer encoding](https://nodejs.org/api/buffer.html#buffer_buffers_and_character_encodings)). Return value: the current response object. #### `response.etag(tag, options)` -Sets the representation [entity tag](http://tools.ietf.org/html/rfc7232#section-2.3) where: +Sets the representation [entity tag](https://tools.ietf.org/html/rfc7232#section-2.3) where: - `tag` - the entity tag string without the double-quote. @@ -4434,7 +4494,7 @@ Return value: the current response object. Sets the HTTP 'Content-Type' header where: -- `value` - is the mime type. +- `mimeType` - is the mime type. Return value: the current response object. @@ -4528,7 +4588,7 @@ Authentication information: - `credentials` - the `credential` object received during the authentication process. The presence of an object does not mean successful authentication. -- `error` - the authentication error is failed and mode set to `'try'`. +- `error` - the authentication error if failed and mode set to `'try'`. - `isAuthenticated` - `true` if the request has been successfully authenticated, otherwise `false`. @@ -4536,6 +4596,9 @@ Authentication information: authentication [`access`](#route.options.auth.access) configuration. If the route has not access rules defined or if the request failed authorization, set to `false`. +- `isInjected` - `true` if the request has been authenticated via the + [`server.inject()`](#server.inject()) `auth` option, otherwise `undefined`. + - `mode` - the route authentication mode. - `strategy` - the name of the strategy used. @@ -4556,7 +4619,7 @@ The `request.events` supports the following events: ```js const Crypto = require('crypto'); -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const onRequest = function (request, h) { @@ -4597,18 +4660,19 @@ Request information: - `acceptEncoding` - the request preferred encoding. -- `cors` - if CORS is enabled for the route, contains the following: +- `completed` - request processing completion timestamp (`0` is still processing). + +- `cors` - request CORS information (available only after the `'onRequest'` extension point as CORS + is configured per-route and no routing decisions are made at that point in the request + lifecycle), where: - `isOriginMatch` - `true` if the request 'Origin' header matches the configured CORS restrictions. Set to `false` if no 'Origin' header is found or if it does not match. - Note that this is only available after the `'onRequest'` extension point as CORS is - configured per-route and no routing decisions are made at that point in the request - lifecycle. - `host` - content of the HTTP 'Host' header (e.g. 'example.com:8080'). - `hostname` - the hostname part of the 'Host' header (e.g. 'example.com'). -- `id` - a unique request identifier (using the format '{now}:{connection.info.id}:{5 digits counter}'). +- `id` - a unique request identifier (using the format '{now}:{server.info.id}:{5 digits counter}'). - `received` - request reception timestamp. @@ -4618,10 +4682,16 @@ Request information: - `remotePort` - remote client port. -- `responded` - request response timestamp (`0` is not responded yet). +- `responded` - request response timestamp (`0` is not responded yet or response failed when `completed` is set). Note that the `request.info` object is not meant to be modified. +#### `request.isInjected` + +Access: read only. + +`true` if the request was created via [`server.inject()`](#server.inject()), and `false` otherwise. + #### `request.logs` Access: read only. @@ -4640,22 +4710,19 @@ The request method in lower case (e.g. `'get'`, `'post'`). Access: read only. -The parsed content-type header. Only available when payload parsing enabled and no - payload error occurred. +The parsed content-type header. Only available when payload parsing enabled and no payload error occurred. #### `request.orig` Access: read only. -An object containing the values of `params`, `query`, and `payload` before any validation -modifications made. Only set when input validation is performed. +An object containing the values of `params`, `query`, `payload` and `state` before any validation modifications made. Only set when input validation is performed. #### `request.params` Access: read only. -An object where each key is a path parameter name with matching value as described in -[Path parameters](#path-parameters). +An object where each key is a path parameter name with matching value as described in [Path parameters](#path-parameters). #### `request.paramsArray` @@ -4671,33 +4738,27 @@ The request URI's [pathname](https://nodejs.org/api/url.html#url_urlobject_pathn #### `request.payload` -Access: read only. +Access: read only / write in `'onRequest'` extension method. -The request payload based on the route `payload.output` and `payload.parse` settings. +The request payload based on the route `payload.output` and `payload.parse` settings. Set to `undefined` in `'onRequest'` extension methods and can be overridden to any non-`undefined` value to bypass payload processing. #### `request.plugins` Access: read / write. -Plugin-specific state. Provides a place to store and pass request-level plugin data. The `plugins` -is an object where each key is a plugin name and the value is the state. +Plugin-specific state. Provides a place to store and pass request-level plugin data. The `plugins` is an object where each key is a plugin name and the value is the state. #### `request.pre` Access: read only. -An object where each key is the name assigned by a [route pre-handler methods](#route.options.pre) -function. The values are the raw values provided to the continuation function as argument. For the -wrapped response object, use `responses`. +An object where each key is the name assigned by a [route pre-handler methods](#route.options.pre) function. The values are the raw values provided to the continuation function as argument. For the wrapped response object, use `responses`. #### `request.response` Access: read / write (see limitations below). -The response object when set. The object can be modified but must not be assigned another object. -To replace the response with another from within an [extension point](#server.ext()), -use `reply(response)` to override with a different response. Contains `null` when no response has -been set (e.g. when a request terminates prematurely when the client disconnects). +The response object when set. The object can be modified but must not be assigned another object. To replace the response with another from within an [extension point](#server.ext()), return a new response value. Contains an error when a request terminates prematurely when the client disconnects. #### `request.preResponses` @@ -4709,17 +4770,13 @@ Same as `pre` but represented as the response object created by the pre method. Access: read only. -By default the object outputted from [node's URL parse()](https://nodejs.org/docs/latest/api/url.html#url_urlobject_query) -method. Might also be set indirectly via [request.setUrl](#request.setUrl()) -in which case it may be a `string` (if `url` is set to an object with the `query` attribute as an -unparsed string). +An object where each key is a query parameter name and each matching value is the parameter value or an array of values if a parameter repeats. Can be modified indirectly via [request.setUrl](#request.setUrl()). #### `request.raw` Access: read only. -An object containing the Node HTTP server objects. **Direct interaction with these raw objects is -not recommended.** +An object containing the Node HTTP server objects. **Direct interaction with these raw objects is not recommended.** - `req` - the node request object. - `res` - the node response object. @@ -4745,8 +4802,7 @@ The server object. Access: read only. -An object containing parsed HTTP state information (cookies) where each key is the cookie name and -value is the matching cookie content after processing using any registered cookie definition. +An object containing parsed HTTP state information (cookies) where each key is the cookie name and value is the matching cookie content after processing using any registered cookie definition. #### `request.url` @@ -4756,31 +4812,54 @@ The parsed request URI. ### `request.generateResponse(source, [options])` -Returns a [`response`](#response-object) which you can pass into the [reply interface](#response-toolkit) where: -- `source` - the value to set as the source of the [reply interface](#response-toolkit), optional. -- `options` - optional object with the following optioal properties: +Returns a [`response`](#response-object) which you can pass to [h.response()](#h.response()) where: +- `source` - the value to set as the source of [h.response()](#h.response()), optional. +- `options` - optional object with the following optional properties: - `variety` - a sting name of the response type (e.g. `'file'`). - - `prepare` - a function with the signature `async function(response)` used to prepare the - response after it is returned by a [lifecycle method](#lifecycle-methods) such as setting a - file descriptor, where: + - `prepare` - a function with the signature `async function(response)` used to prepare the response after it is returned by a [lifecycle method](#lifecycle-methods) such as setting a file descriptor, where: - `response` - the response object being prepared. - - must return the prepared response object (new object or `response`). + - must return the prepared response object (`response`). - may throw an error which is used as the prepared response. - - `marshal` - a function with the signature `async function(response)` used to repare the - response for transmission to the client before it is sent, where: + - `marshal` - a function with the signature `async function(response)` used to prepare the response for transmission to the client before it is sent, where: - `response` - the response object being marshaled. - - must return the prepared value (not as response object) which can be any value accepted - by the [`h.response()`](#h.response()) `value` argument. - - may throw an error which is used as the marhsaled value. - - `close` - a function with the signature `function(response)` used to close the resources - opened by the response object (e.g. file handlers), where: + - must return the prepared value (not as response object) which can be any value accepted by the [`h.response()`](#h.response()) `value` argument. + - may throw an error which is used as the marshaled value. + - `close` - a function with the signature `function(response)` used to close the resources opened by the response object (e.g. file handlers), where: - `response` - the response object being marshaled. - should not throw errors (which are logged but otherwise ignored). +### `request.active()` + +Returns `true` when the request is active and processing should continue and `false` when the request terminated early or completed its lifecycle. Useful when request processing is a resource-intensive operation and should be terminated early if the request is no longer active (e.g. client disconnected or aborted early). + +```js +const Hapi = require('@hapi/hapi'); +const server = Hapi.server({ port: 80 }); + +server.route({ + method: 'POST', + path: '/worker', + handler: function (request, h) { + + // Do some work... + + // Check if request is still active + if (!request.active()) { + return h.close; + } + + // Do some more work... + + return null; + } +}); +``` + ### `request.log(tags, [data])` -Logs request-specific events. When called, the server emits a `'request'` event which can be used -by other listeners or [plugins](#plugins). The arguments are: +Logs request-specific events. When called, the server emits a [`'request'` event](#server.events.request) +on the `'app'` channel which can be used by other listeners or [plugins](#plugins). The arguments +are: - `tags` - a string or an array of strings (e.g. `['error', 'database', 'read']`) used to identify the event. Tags are used instead of log levels and provide a much more expressive mechanism for describing and filtering events. @@ -4788,14 +4867,11 @@ by other listeners or [plugins](#plugins). The arguments are: is a function, the function signature is `function()` and it called once to generate (return value) the actual data emitted to the listeners. -Any logs generated by the server internally will be emitted only on the `'request-internal'` -channel and will include the `event.internal` flag set to `true`. - ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80, routes: { log: { collect: true } } }); -server.events.on('request', (request, event, tags) => { +server.events.on({ name: 'request', channels: 'app' }, (request, event, tags) => { if (tags.error) { console.log(event); @@ -4809,6 +4885,16 @@ const handler = function (request, h) { }; ``` +Note that any logs generated by the server internally will be emitted using the +[`'request'` event](#server.events.request) on the `'internal'` channel. + +```js +server.events.on({ name: 'request', channels: 'internal' }, (request, event, tags) => { + + console.log(event); +}); +``` + ### `request.route.auth.access(request)` Validates a request against the route's authentication [`access`](#route.options.auth.access) @@ -4834,7 +4920,7 @@ Changes the request method before the router begins processing the request where - `method` - is the request HTTP method (e.g. `'GET'`). ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const onRequest = function (request, h) { @@ -4852,14 +4938,13 @@ Can only be called from an `'onRequest'` extension method. ### `request.setUrl(url, [stripTrailingSlash]` Changes the request URI before the router begins processing the request where: - - `url` - the new request URI. If `url` is a string, it is parsed with [node's **URL** - `parse()`](https://nodejs.org/docs/latest/api/url.html#url_url_parse_urlstring_parsequerystring_slashesdenotehost) - method with `parseQueryString` set to `true`. `url` can also be set to an object - compatible with node's **URL** `parse()` method output. - - `stripTrailingSlash` - if `true`, strip the trailing slash from the path. Defaults to `false`. +- `url` - the new request URI. `url` can be a string or an instance of + [`Url.URL`](https://nodejs.org/dist/latest-v10.x/docs/api/url.html#url_class_url) in which case + `url.href` is used. +- `stripTrailingSlash` - if `true`, strip the trailing slash from the path. Defaults to `false`. ```js -const Hapi = require('hapi'); +const Hapi = require('@hapi/hapi'); const server = Hapi.server({ port: 80 }); const onRequest = function (request, h) { @@ -4872,28 +4957,6 @@ const onRequest = function (request, h) { server.ext('onRequest', onRequest); ``` -To use another query string parser: - -```js -const Url = require('url'); -const Hapi = require('hapi'); -const Qs = require('qs'); - -const server = Hapi.server({ port: 80 }); - -const onRequest = function (request, h) { - - const uri = request.url.href; - const parsed = Url.parse(uri, false); - parsed.query = Qs.parse(parsed.query); - request.setUrl(parsed); - - return h.continue; -}; - -server.ext('onRequest', onRequest); -``` - Can only be called from an `'onRequest'` extension method. ## Plugins @@ -4925,6 +4988,11 @@ A plugin is an object with the following properties: - `dependencies` - (optional) a string or an array of strings indicating a plugin dependency. Same as setting dependencies via [`server.dependency()`](#server.dependency()). +- `requirements` - (optional) object declaring the plugin supported [semver range](https://semver.org/) for: + + - `node` runtime [semver range](https://nodejs.org/en/about/releases/) string. + - `hapi` framework [semver range](#server.version) string. + - `once` - (optional) if `true`, will only register the plugin once per server. If set, overrides the `once` option passed to [`server.register()`](#server.register()). Defaults to no override. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index b85fd07ff..000000000 --- a/LICENSE +++ /dev/null @@ -1,32 +0,0 @@ -Copyright (c) 2011-2017, Project contributors -Copyright (c) 2011-2014, Walmart -Copyright (c) 2011, Yahoo Inc. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * The names of any contributors may not be used to endorse or promote - products derived from this software without specific prior written - permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - * * * - -The complete list of contributors can be found at: https://github.com/hapijs/hapi/graphs/contributors -Portions of this project were initially based on the Yahoo! Inc. Postmile project, -published at https://github.com/yahoo/postmile. diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 000000000..a5899ce28 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ +Copyright (c) 2011-2022, Project contributors +Copyright (c) 2011-2020, Sideway Inc +Copyright (c) 2011-2014, Walmart +Copyright (c) 2011, Yahoo Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +- The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS OFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 1b569df57..44925bc89 100755 --- a/README.md +++ b/README.md @@ -1,53 +1,26 @@ - + -### Web and services application framework +# @hapi/hapi -Lead Maintainer: [Eran Hammer](https://github.com/hueniverse) +#### The Simple, Secure Framework Developers Trust -**hapi** is a simple to use configuration-centric framework with built-in support for input validation, caching, -authentication, and other essential facilities for building web and services applications. **hapi** enables -developers to focus on writing reusable application logic in a highly modular and prescriptive approach. +Build powerful, scalable applications, with minimal overhead and full out-of-the-box functionality - your code, your way. -Version 17.x only supports node v8.9.0 and over. For older version of node please use version 16.x. +### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support -Development version: **17.1.x** ([release notes](https://github.com/hapijs/hapi/issues?labels=release+notes&page=1&state=closed)) -[![Build Status](https://secure.travis-ci.org/hapijs/hapi.svg?branch=master)](http://travis-ci.org/hapijs/hapi) +## Useful resources -For the latest updates, [change log](http://hapijs.com/updates), and release information visit [hapijs.com](http://hapijs.com) and follow [@hapijs](https://twitter.com/hapijs) on twitter. If you have questions, please open an issue in the -[discussion forum](https://github.com/hapijs/discuss). +- [Documentation and API](https://hapi.dev/) +- [Version status](https://hapi.dev/resources/status/#hapi) (builds, dependencies, node versions, licenses, eol) +- [Changelog](https://hapi.dev/resources/changelog/) +- [Project policies](https://hapi.dev/policies/) +- [Support](https://hapi.dev/support/) -# Sponsorship +## Technical Steering Committee (TSC) Members -Development of the **hapi** core module generously supported by contributions from [individuals and corporations](https://github.com/hapijs/hapi/blob/master/SPONSORS.md). -If you are benefiting from **hapi** and would like to help keep the project financially sustainable, please visit -Eran Hammer's [Patreon page](https://www.patreon.com/eranhammer) or contact him [via email](mailto:eran@hammer.io). - -## Featured Sponsors - - - -[**Lob**](http://bit.ly/lobh-rn) enables you to seamlessly print and mail documents, postcards, checks, and more via an API. They have been an early **hapi** adopters and vocal supporters. If you are looking to take your **hapi** and JS skills to the next level, check out their [career page](http://bit.ly/lobc-rn) for exciting opportunities. - -# - -
- -[![Auth0](https://user-images.githubusercontent.com/56631/31878562-5c64483a-b78f-11e7-92da-5a991ebb302d.png)](http://bit.ly/auth0h-rn) - -
- -[![Condé Nast Technology](https://user-images.githubusercontent.com/56631/32398027-e2027480-c0a9-11e7-9077-c5ecca7bc39c.png)](http://bit.ly/cn-rn) - -
- -## Active Supporters - -- **CNN Digital** -- **[Contentful](https://www.contentful.com/)** -- The product development team at **Creative Artists Agency** -- **[First + Third](http://firstandthird.com)** - -## Legacy Supporters - -Past major financial support for the project was provided by: -- Benjamin Flesch and [StriveWire](https://strivewire.com/) + - Devin Ivy ([@devinivy](https://github.com/devinivy)) + - Lloyd Benson ([@lloydbenson](https://github.com/lloydbenson)) + - Nathan LaFreniere ([@nlf](https://github.com/nlf)) + - Wyatt Lyon Preul ([@geek](https://github.com/geek)) + - Nicolas Morel ([@marsup](https://github.com/marsup)) + - Jonathan Samines ([@jonathansamines](https://github.com/jonathansamines)) diff --git a/SPONSORS.md b/SPONSORS.md index 2eecf593c..3a1841fdf 100755 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -1,61 +1,15 @@ -# Leaders +We'd like to thank our sponsors as well as the legacy sponsors who have supported hapi throughout the years. Thanks so much for your support! -- Alexander Alimovs -- Alvin Lumowa -- Andres Garcia / Florence Healthcare -- Auth0 -- Benjamin Flesch / StriveWire -- Biao Xie -- Bryan Sapot -- Christian Giacomi -- CNN Digital -- Condé Nast Technology -- Contentful -- Dave Hyndman -- David Roncancio -- Dmitry Savinkov -- Elba Sánchez Márquez -- Eric Lanehart -- [gitconnected](https://gitconnected.com) -- Greg Allen -- Healthline Media -- Jeremy Whitbred -- John Brett -- Julian Lannigan -- Lob -- Louis Beltramo -- Mahesh Babu R -- Manny Pamintuan -- NZZ Storytelling -- Paulo Vieira -- Peter Ka -- Raffi Minassian -- Raquel Hernandez -- Saul Maddox -- Shane Warren -- Srikanth Minnam -- Sven Lito -- Tim Boudreau -- Umut Şirin -- Yehor Sergeenko +> Below are hapi's top recurring sponsors, but there are many more to thank. For the complete list, see [hapi.dev/policies/sponsors](https://hapi.dev/policies/sponsors/) or [hapijs/.github/SPONSORS.md](https://github.com/hapijs/.github/blob/master/SPONSORS.md). -# Supporters +# Staff Sponsors -- Adilson Schmitt Junior -- Andreas Zeuch -- Chris St -- Dave Stevens -- Essau Ramirez -- Finnegan -- Geraint Corneu -- Luke Bond -- Marcos Bérgamo -- Max Fierro -- Nathan Buchar -- Nicklas Laine Overgaard -- Oakley Hall -- PIer Bover -- Sheridan Tighe -- Troy Whiteley -- Viswanadha Pratap Kondoju -- CodePilot.ai +- [Big Room Studios](https://www.bigroomstudios.com/) +- [Dixeed](https://dixeed.com/) + +# Top Sponsors + +- Fabian Gündel / [DataWrapper.de](https://www.datawrapper.de/) +- Devin Stewart +- [Raider.IO](https://raider.io/) +- [Florence Healthcare](https://florencehc.com/) diff --git a/images/17.png b/images/17.png deleted file mode 100755 index 035f2e561..000000000 Binary files a/images/17.png and /dev/null differ diff --git a/images/hapi.png b/images/hapi.png deleted file mode 100755 index 92938ede7..000000000 Binary files a/images/hapi.png and /dev/null differ diff --git a/lib/auth.js b/lib/auth.js index 5ef1ecb53..e3de0fc7f 100755 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,16 +1,13 @@ 'use strict'; -// Load modules - -const Boom = require('boom'); -const Bounce = require('bounce'); -const Hoek = require('hoek'); +const Boom = require('@hapi/boom'); +const Bounce = require('@hapi/bounce'); +const Hoek = require('@hapi/hoek'); const Config = require('./config'); +const Request = require('./request'); -// Declare internals - const internals = { missing: Symbol('missing') }; @@ -18,47 +15,62 @@ const internals = { exports = module.exports = internals.Auth = class { + #core = null; + #schemes = {}; + #strategies = {}; + + api = {}; // Do not reassign api or settings, as they are referenced in public() + settings = { + default: null // Strategy used as default if route has no auth settings + }; + constructor(core) { - this._core = core; - this._schemes = {}; - this._strategies = {}; - this.settings = { - default: null // Strategy used as default if route has no auth settings - }; + this.#core = core; + } - this.api = {}; + public(server) { + + return { + api: this.api, + settings: this.settings, + scheme: this.scheme.bind(this), + strategy: this._strategy.bind(this, server), + default: this.default.bind(this), + test: this.test.bind(this), + verify: this.verify.bind(this), + lookup: this.lookup.bind(this) + }; } scheme(name, scheme) { Hoek.assert(name, 'Authentication scheme must have a name'); - Hoek.assert(!this._schemes[name], 'Authentication scheme name already exists:', name); + Hoek.assert(!this.#schemes[name], 'Authentication scheme name already exists:', name); Hoek.assert(typeof scheme === 'function', 'scheme must be a function:', name); - this._schemes[name] = scheme; + this.#schemes[name] = scheme; } _strategy(server, name, scheme, options = {}) { Hoek.assert(name, 'Authentication strategy must have a name'); Hoek.assert(typeof options === 'object', 'options must be an object'); - Hoek.assert(name !== 'bypass', 'Cannot use reserved strategy name: bypass'); - Hoek.assert(!this._strategies[name], 'Authentication strategy name already exists'); + Hoek.assert(!this.#strategies[name], 'Authentication strategy name already exists'); Hoek.assert(scheme, 'Authentication strategy', name, 'missing scheme'); - Hoek.assert(this._schemes[scheme], 'Authentication strategy', name, 'uses unknown scheme:', scheme); + Hoek.assert(this.#schemes[scheme], 'Authentication strategy', name, 'uses unknown scheme:', scheme); server = server._clone(); - const strategy = this._schemes[scheme](server, options); + const strategy = this.#schemes[scheme](server, options); Hoek.assert(strategy.authenticate, 'Invalid scheme:', name, 'missing authenticate() method'); Hoek.assert(typeof strategy.authenticate === 'function', 'Invalid scheme:', name, 'invalid authenticate() method'); Hoek.assert(!strategy.payload || typeof strategy.payload === 'function', 'Invalid scheme:', name, 'invalid payload() method'); Hoek.assert(!strategy.response || typeof strategy.response === 'function', 'Invalid scheme:', name, 'invalid response() method'); - strategy.options = strategy.options || {}; + strategy.options = strategy.options ?? {}; Hoek.assert(strategy.payload || !strategy.options.payload, 'Cannot require payload validation without a payload method'); - this._strategies[name] = { + this.#strategies[name] = { methods: strategy, realm: server.realm }; @@ -75,16 +87,16 @@ exports = module.exports = internals.Auth = class { this.settings.default = this._setupRoute(Hoek.clone(options)); // Prevent changes to options - const routes = this._core.router.table(); - for (let i = 0; i < routes.length; ++i) { - routes[i].rebuild(); + const routes = this.#core.router.table(); + for (const route of routes) { + route.rebuild(); } } async test(name, request) { Hoek.assert(name, 'Missing authentication strategy name'); - const strategy = this._strategies[name]; + const strategy = this.#strategies[name]; Hoek.assert(strategy, 'Unknown authentication strategy:', name); const bind = strategy.methods; @@ -99,7 +111,30 @@ exports = module.exports = internals.Auth = class { throw response.error; } - return response.data.credentials; + return response.data; + } + + async verify(request) { + + const auth = request.auth; + + if (auth.error) { + throw auth.error; + } + + if (!auth.isAuthenticated) { + return; + } + + const strategy = this.#strategies[auth.strategy]; + Hoek.assert(strategy, 'Unknown authentication strategy:', auth.strategy); + + if (!strategy.methods.verify) { + return; + } + + const bind = strategy.methods; + await strategy.methods.verify.call(bind, auth); } static testAccess(request, route) { @@ -136,10 +171,10 @@ exports = module.exports = internals.Auth = class { options = Hoek.applyToDefaults(this.settings.default, options); } - path = path || 'default strategy'; - Hoek.assert(options.strategies && options.strategies.length, 'Missing authentication strategy:', path); + path = path ?? 'default strategy'; + Hoek.assert(options.strategies?.length, 'Missing authentication strategy:', path); - options.mode = options.mode || 'required'; + options.mode = options.mode ?? 'required'; if (options.entity !== undefined || // Backwards compatibility with <= 11.x.x options.scope !== undefined) { @@ -150,8 +185,7 @@ exports = module.exports = internals.Auth = class { } if (options.access) { - for (let i = 0; i < options.access.length; ++i) { - const access = options.access[i]; + for (const access of options.access) { access.scope = internals.setupScope(access); } } @@ -161,9 +195,8 @@ exports = module.exports = internals.Auth = class { } let hasAuthenticatePayload = false; - for (let i = 0; i < options.strategies.length; ++i) { - const name = options.strategies[i]; - const strategy = this._strategies[name]; + for (const name of options.strategies) { + const strategy = this.#strategies[name]; Hoek.assert(strategy, 'Unknown authentication strategy', name, 'in', path); Hoek.assert(strategy.methods.payload || options.payload !== 'required', 'Payload validation can only be required when all strategies support it in', path); @@ -200,9 +233,8 @@ exports = module.exports = internals.Auth = class { return !!config.access; } - for (let i = 0; i < config.strategies.length; ++i) { - const name = config.strategies[i]; - const strategy = this._strategies[name]; + for (const name of config.strategies) { + const strategy = this.#strategies[name]; if (strategy.methods[type]) { return true; } @@ -227,15 +259,14 @@ exports = module.exports = internals.Auth = class { // Injection bypass if (request.auth.credentials) { - internals.validate(null, { credentials: request.auth.credentials, artifacts: request.auth.artifacts }, 'bypass', config, request, errors); + internals.validate(null, { credentials: request.auth.credentials, artifacts: request.auth.artifacts }, request.auth.strategy, config, request, errors); return; } // Try each strategy - for (let i = 0; i < config.strategies.length; ++i) { - const name = config.strategies[i]; - const strategy = this._strategies[name]; + for (const name of config.strategies) { + const strategy = this.#strategies[name]; const bind = strategy.methods; const realm = strategy.realm; @@ -273,9 +304,7 @@ exports = module.exports = internals.Auth = class { _access(request, route) { const config = this.lookup(route || request.route); - if (!config || - !config.access) { - + if (!config?.access) { return true; } @@ -291,8 +320,7 @@ exports = module.exports = internals.Auth = class { const requestEntity = (credentials.user ? 'user' : 'app'); const scopeErrors = []; - for (let i = 0; i < config.access.length; ++i) { - const access = config.access[i]; + for (const access of config.access) { // Check entity @@ -346,21 +374,20 @@ exports = module.exports = internals.Auth = class { static async payload(request) { - if (!request.auth.isAuthenticated || - request.auth.strategy === 'bypass') { - + if (!request.auth.isAuthenticated || !request.auth[Request.symbols.authPayload]) { return; } const auth = request._core.auth; - const strategy = auth._strategies[request.auth.strategy]; + const strategy = auth.#strategies[request.auth.strategy]; + Hoek.assert(strategy, 'Unknown authentication strategy:', request.auth.strategy); if (!strategy.methods.payload) { return; } const config = auth.lookup(request.route); - const setting = config.payload || (strategy.methods.options.payload ? 'required' : false); + const setting = config.payload ?? (strategy.methods.options.payload ? 'required' : false); if (!setting) { return; } @@ -372,22 +399,23 @@ exports = module.exports = internals.Auth = class { if (response.isBoom && response.isMissing) { - return (setting === 'optional' ? undefined : Boom.unauthorized('Missing payload authentication')); + return setting === 'optional' ? undefined : Boom.unauthorized('Missing payload authentication'); } return response; } - static async response(request) { + static async response(response) { + const request = response.request; const auth = request._core.auth; - if (!request.auth.isAuthenticated || - request.auth.strategy === 'bypass') { - + if (!request.auth.isAuthenticated) { return; } - const strategy = auth._strategies[request.auth.strategy]; + const strategy = auth.#strategies[request.auth.strategy]; + Hoek.assert(strategy, 'Unknown authentication strategy:', request.auth.strategy); + if (!strategy.methods.response) { return; } @@ -404,24 +432,31 @@ exports = module.exports = internals.Auth = class { internals.setupScope = function (access) { + // No scopes + if (!access.scope) { return false; } + // Already setup + + if (!Array.isArray(access.scope)) { + return access.scope; + } + const scope = {}; - for (let i = 0; i < access.scope.length; ++i) { - const value = access.scope[i]; + for (const value of access.scope) { const prefix = value[0]; - const type = (prefix === '+' ? 'required' : (prefix === '!' ? 'forbidden' : 'selection')); - const clean = (type === 'selection' ? value : value.slice(1)); - scope[type] = scope[type] || []; + const type = prefix === '+' ? 'required' : (prefix === '!' ? 'forbidden' : 'selection'); + const clean = type === 'selection' ? value : value.slice(1); + scope[type] = scope[type] ?? []; scope[type].push(clean); - if ((!scope._parameters || !scope._parameters[type]) && + if ((!scope._hasParameters?.[type]) && /{([^}]+)}/.test(clean)) { - scope._parameters = scope._parameters || {}; - scope._parameters[type] = true; + scope._hasParameters = scope._hasParameters ?? {}; + scope._hasParameters[type] = true; } } @@ -431,52 +466,54 @@ internals.setupScope = function (access) { internals.validate = function (err, result, name, config, request, errors) { // err can be Boom, Error, or a valid response object - result = result || {}; - - // Unauthenticated + result = result ?? {}; + request.auth.isAuthenticated = !err; if (err) { + + // Non-error response + if (err instanceof Error === false) { request._log(['auth', 'unauthenticated', 'response', name], { statusCode: err.statusCode }); - return err; // Non-error response + return err; } - if (err.isMissing) { - - // Try next strategy + // Missing authenticated + if (err.isMissing) { request._log(['auth', 'unauthenticated', 'missing', name], err); errors.push(err.output.headers['WWW-Authenticate']); return internals.missing; } + } - if (config.mode === 'try') { - request.auth.isAuthenticated = false; - request.auth.strategy = name; - request.auth.credentials = result.credentials; - request.auth.artifacts = result.artifacts; - request.auth.error = err; - request._log(['auth', 'unauthenticated', 'try', name], err); - return; - } + request.auth.strategy = name; + request.auth.credentials = result.credentials; + request.auth.artifacts = result.artifacts; - request._log(['auth', 'unauthenticated', 'error', name], err); - throw err; + // Authenticated + + if (!err) { + return; } - // Authenticated + // Unauthenticated - const credentials = result.credentials; - request.auth.strategy = name; - request.auth.credentials = credentials; - request.auth.artifacts = result.artifacts; - request.auth.isAuthenticated = true; + request.auth.error = err; + + if (config.mode === 'try') { + request._log(['auth', 'unauthenticated', 'try', name], err); + return; + } + + request._log(['auth', 'unauthenticated', 'error', name], err); + throw err; }; internals.expandScope = function (request, scope) { - if (!scope._parameters) { + if (!scope._hasParameters) { return scope; } @@ -492,9 +529,7 @@ internals.expandScope = function (request, scope) { internals.expandScopeType = function (request, scope, type) { - if (!scope[type] || - !scope._parameters[type]) { - + if (!scope._hasParameters[type]) { return scope[type]; } @@ -506,8 +541,8 @@ internals.expandScopeType = function (request, scope, type) { credentials: request.auth.credentials }; - for (let i = 0; i < scope[type].length; ++i) { - expanded.push(Hoek.reachTemplate(context, scope[type][i])); + for (const template of scope[type]) { + expanded.push(Hoek.reachTemplate(context, template)); } return expanded; @@ -521,7 +556,7 @@ internals.validateScope = function (credentials, scope, type) { } const count = typeof credentials.scope === 'string' ? - (scope[type].indexOf(credentials.scope) !== -1 ? 1 : 0) : + scope[type].indexOf(credentials.scope) !== -1 ? 1 : 0 : Hoek.intersect(scope[type], credentials.scope).length; if (type === 'forbidden') { diff --git a/lib/compression.js b/lib/compression.js index 2afc6cd41..3e4c692e4 100755 --- a/lib/compression.js +++ b/lib/compression.js @@ -1,15 +1,11 @@ 'use strict'; -// Load modules - const Zlib = require('zlib'); -const Accept = require('accept'); -const Bounce = require('bounce'); -const Hoek = require('hoek'); - +const Accept = require('@hapi/accept'); +const Bounce = require('@hapi/bounce'); +const Hoek = require('@hapi/hoek'); -// Declare internals const internals = { common: ['gzip, deflate', 'deflate, gzip', 'gzip', 'deflate', 'gzip, deflate, br'] @@ -18,52 +14,59 @@ const internals = { exports = module.exports = internals.Compression = class { - constructor() { + decoders = { + gzip: (options) => Zlib.createGunzip(options), + deflate: (options) => Zlib.createInflate(options) + }; + + encodings = ['identity', 'gzip', 'deflate']; - this.encodings = ['identity', 'gzip', 'deflate']; - this._encoders = { - identity: null, - gzip: (options) => Zlib.createGzip(options), - deflate: (options) => Zlib.createDeflate(options) - }; + encoders = { + identity: null, + gzip: (options) => Zlib.createGzip(options), + deflate: (options) => Zlib.createDeflate(options) + }; - this._decoders = { - gzip: (options) => Zlib.createGunzip(options), - deflate: (options) => Zlib.createInflate(options) - }; + #common = null; + + constructor() { this._updateCommons(); } _updateCommons() { - this._common = new Map(); - internals.common.forEach((header) => { + this.#common = new Map(); - this._common.set(header, Accept.encoding(header, this.encodings)); - }); + for (const header of internals.common) { + this.#common.set(header, Accept.encoding(header, this.encodings)); + } } addEncoder(encoding, encoder) { - Hoek.assert(this._encoders[encoding] === undefined, `Cannot override existing encoder for ${encoding}`); + Hoek.assert(this.encoders[encoding] === undefined, `Cannot override existing encoder for ${encoding}`); Hoek.assert(typeof encoder === 'function', `Invalid encoder function for ${encoding}`); - this._encoders[encoding] = encoder; + this.encoders[encoding] = encoder; this.encodings.unshift(encoding); this._updateCommons(); } addDecoder(encoding, decoder) { - Hoek.assert(this._decoders[encoding] === undefined, `Cannot override existing decoder for ${encoding}`); + Hoek.assert(this.decoders[encoding] === undefined, `Cannot override existing decoder for ${encoding}`); Hoek.assert(typeof decoder === 'function', `Invalid decoder function for ${encoding}`); - this._decoders[encoding] = decoder; + this.decoders[encoding] = decoder; } accept(request) { const header = request.headers['accept-encoding']; - const common = this._common.get(header); + if (!header) { + return 'identity'; + } + + const common = this.#common.get(header); if (common) { return common; } @@ -81,9 +84,14 @@ exports = module.exports = internals.Compression = class { encoding(response, length) { + if (response.settings.compressed) { + response.headers['content-encoding'] = response.settings.compressed; + return null; + } + const request = response.request; if (!request._core.settings.compression || - (length !== null && length < request._core.settings.compression.minBytes)) { + length !== null && length < request._core.settings.compression.minBytes) { return null; } @@ -99,12 +107,12 @@ exports = module.exports = internals.Compression = class { return null; } - return (request.info.acceptEncoding === 'identity' ? null : request.info.acceptEncoding); + return request.info.acceptEncoding === 'identity' ? null : request.info.acceptEncoding; } encoder(request, encoding) { - const encoder = this._encoders[encoding]; + const encoder = this.encoders[encoding]; Hoek.assert(encoder !== undefined, `Unknown encoding ${encoding}`); return encoder(request.route.settings.compression[encoding]); } diff --git a/lib/config.js b/lib/config.js index 148eb423d..2b668f97d 100755 --- a/lib/config.js +++ b/lib/config.js @@ -1,14 +1,10 @@ 'use strict'; -// Load modules - const Os = require('os'); -const Joi = require('joi'); -const Hoek = require('hoek'); - +const Somever = require('@hapi/somever'); +const Validate = require('@hapi/validate'); -// Declare internals const internals = {}; @@ -16,17 +12,21 @@ const internals = {}; exports.symbol = Symbol('hapi-response'); -exports.apply = function (type, options, message) { +exports.apply = function (type, options, ...message) { + + const result = internals[type].validate(options); + + if (result.error) { + throw new Error(`Invalid ${type} options ${message.length ? '(' + message.join(' ') + ')' : ''} ${result.error.annotate()}`); + } - const result = Joi.validate(options, internals[type]); - Hoek.assert(!result.error, 'Invalid', type, 'options', message ? '(' + message + ')' : '', result.error && result.error.annotate()); return result.value; }; exports.enable = function (options) { - const settings = (options ? Hoek.shallow(options) : {}); + const settings = options ? Object.assign({}, options) : {}; // Shallow cloned if (settings.security === true) { settings.security = {}; @@ -39,23 +39,24 @@ exports.enable = function (options) { return settings; }; +exports.versionMatch = (version, range) => Somever.match(version, range, { includePrerelease: true }); -internals.access = Joi.object({ - entity: Joi.valid('user', 'app', 'any'), - scope: [false, Joi.array().items(Joi.string()).single().min(1)] +internals.access = Validate.object({ + entity: Validate.valid('user', 'app', 'any'), + scope: [false, Validate.array().items(Validate.string()).single().min(1)] }); -internals.auth = Joi.alternatives([ - Joi.string(), +internals.auth = Validate.alternatives([ + Validate.string(), internals.access.keys({ - mode: Joi.valid('required', 'optional', 'try'), - strategy: Joi.string(), - strategies: Joi.array().items(Joi.string()).min(1), - access: Joi.array().items(internals.access.min(1)).single().min(1), + mode: Validate.valid('required', 'optional', 'try'), + strategy: Validate.string(), + strategies: Validate.array().items(Validate.string()).min(1), + access: Validate.array().items(internals.access.min(1)).single().min(1), payload: [ - Joi.valid('required', 'optional'), - Joi.boolean() + Validate.valid('required', 'optional'), + Validate.boolean() ] }) .without('strategy', 'strategies') @@ -63,326 +64,369 @@ internals.auth = Joi.alternatives([ ]); -internals.event = Joi.object({ - method: Joi.array().items(Joi.func()).single(), - options: Joi.object({ - before: Joi.array().items(Joi.string()).single(), - after: Joi.array().items(Joi.string()).single(), - bind: Joi.any(), - sandbox: Joi.valid('server', 'plugin') +internals.event = Validate.object({ + method: Validate.array().items(Validate.function()).single(), + options: Validate.object({ + before: Validate.array().items(Validate.string()).single(), + after: Validate.array().items(Validate.string()).single(), + bind: Validate.any(), + sandbox: Validate.valid('server', 'plugin'), + timeout: Validate.number().integer().min(1) }) .default({}) }); -internals.exts = Joi.array().items(internals.event.keys({ type: Joi.string().required() })).single(); +internals.exts = Validate.array() + .items(internals.event.keys({ type: Validate.string().required() })).single(); -internals.failAction = Joi.alternatives([ - Joi.valid('error', 'log', 'ignore'), - Joi.func() +internals.failAction = Validate.alternatives([ + Validate.valid('error', 'log', 'ignore'), + Validate.function() ]) .default('error'); -internals.routeBase = Joi.object({ - app: Joi.object().allow(null), +internals.routeBase = Validate.object({ + app: Validate.object().allow(null), auth: internals.auth.allow(false), - bind: Joi.object().allow(null), - cache: Joi.object({ - expiresIn: Joi.number(), - expiresAt: Joi.string(), - privacy: Joi.valid('default', 'public', 'private'), - statuses: Joi.array().items(Joi.number().integer().min(200)).min(1).single().default([200, 204]), - otherwise: Joi.string().default('no-cache') + bind: Validate.object().allow(null), + cache: Validate.object({ + expiresIn: Validate.number(), + expiresAt: Validate.string(), + privacy: Validate.valid('default', 'public', 'private'), + statuses: Validate.array().items(Validate.number().integer().min(200)).min(1).single().default([200, 204]), + otherwise: Validate.string().default('no-cache') }) .allow(false) .default(), - compression: Joi.object() - .pattern(/.+/, Joi.object()) + compression: Validate.object() + .pattern(/.+/, Validate.object()) .default(), - cors: Joi.object({ - origin: Joi.array().min(1).allow('ignore').default(['*']), - maxAge: Joi.number().default(86400), - headers: Joi.array().items(Joi.string()).default(['Accept', 'Authorization', 'Content-Type', 'If-None-Match']), - additionalHeaders: Joi.array().items(Joi.string()).default([]), - exposedHeaders: Joi.array().items(Joi.string()).default(['WWW-Authenticate', 'Server-Authorization']), - additionalExposedHeaders: Joi.array().items(Joi.string()).default([]), - credentials: Joi.boolean().when('origin', { is: 'ignore', then: false }).default(false) + cors: Validate.object({ + origin: Validate.array().min(1).allow('ignore').default(['*']), + maxAge: Validate.number().default(86400), + headers: Validate.array().items(Validate.string()).default(['Accept', 'Authorization', 'Content-Type', 'If-None-Match']), + additionalHeaders: Validate.array().items(Validate.string()).default([]), + exposedHeaders: Validate.array().items(Validate.string()).default(['WWW-Authenticate', 'Server-Authorization']), + additionalExposedHeaders: Validate.array().items(Validate.string()).default([]), + credentials: Validate.boolean().when('origin', { is: 'ignore', then: false }).default(false), + preflightStatusCode: Validate.valid(200, 204).default(200) }) .allow(false, true) .default(false), - ext: Joi.object({ - onPreAuth: Joi.array().items(internals.event).single(), - onCredentials: Joi.array().items(internals.event).single(), - onPostAuth: Joi.array().items(internals.event).single(), - onPreHandler: Joi.array().items(internals.event).single(), - onPostHandler: Joi.array().items(internals.event).single(), - onPreResponse: Joi.array().items(internals.event).single() + ext: Validate.object({ + onPreAuth: Validate.array().items(internals.event).single(), + onCredentials: Validate.array().items(internals.event).single(), + onPostAuth: Validate.array().items(internals.event).single(), + onPreHandler: Validate.array().items(internals.event).single(), + onPostHandler: Validate.array().items(internals.event).single(), + onPreResponse: Validate.array().items(internals.event).single(), + onPostResponse: Validate.array().items(internals.event).single() }) .default({}), - files: Joi.object({ - relativeTo: Joi.string().regex(/^([\/\.])|([A-Za-z]:\\)|(\\\\)/).default('.') + files: Validate.object({ + relativeTo: Validate.string().pattern(/^([\/\.])|([A-Za-z]:\\)|(\\\\)/).default('.') }) .default(), - json: Joi.object({ - replacer: Joi.alternatives(Joi.func(), Joi.array()).allow(null).default(null), - space: Joi.number().allow(null).default(null), - suffix: Joi.string().allow(null).default(null), - escape: Joi.boolean().default(false) + json: Validate.object({ + replacer: Validate.alternatives(Validate.function(), Validate.array()).allow(null).default(null), + space: Validate.number().allow(null).default(null), + suffix: Validate.string().allow(null).default(null), + escape: Validate.boolean().default(false) }) .default(), - jsonp: Joi.string(), - log: Joi.object({ - collect: Joi.boolean().default(false) + log: Validate.object({ + collect: Validate.boolean().default(false) }) .default(), - payload: Joi.object({ - output: Joi.valid('data', 'stream', 'file').default('data'), - parse: Joi.boolean().allow('gunzip').default(true), - multipart: Joi.object({ - output: Joi.valid('data', 'stream', 'file', 'annotated').required() + payload: Validate.object({ + output: Validate.valid('data', 'stream', 'file').default('data'), + parse: Validate.boolean().allow('gunzip').default(true), + multipart: Validate.object({ + output: Validate.valid('data', 'stream', 'file', 'annotated').required() }) - .allow(false), - allow: Joi.array().items(Joi.string()).single(), - override: Joi.string(), - maxBytes: Joi.number().integer().positive().default(1024 * 1024), - uploads: Joi.string().default(Os.tmpdir()), + .default(false) + .allow(true, false), + allow: Validate.array().items(Validate.string()).single(), + override: Validate.string(), + protoAction: Validate.valid('error', 'remove', 'ignore').default('error'), + maxBytes: Validate.number().integer().positive().default(1024 * 1024), + maxParts: Validate.number().integer().positive().default(1000), + uploads: Validate.string().default(Os.tmpdir()), failAction: internals.failAction, - timeout: Joi.number().integer().positive().allow(false).default(10 * 1000), - defaultContentType: Joi.string().default('application/json'), - compression: Joi.object() - .pattern(/.+/, Joi.object()) + timeout: Validate.number().integer().positive().allow(false).default(10 * 1000), + defaultContentType: Validate.string().default('application/json'), + compression: Validate.object() + .pattern(/.+/, Validate.object()) .default() }) .default(), - plugins: Joi.object(), - response: Joi.object({ - emptyStatusCode: Joi.valid(200, 204).default(200), + plugins: Validate.object(), + response: Validate.object({ + disconnectStatusCode: Validate.number().integer().min(400).default(499), + emptyStatusCode: Validate.valid(200, 204).default(204), failAction: internals.failAction, - modify: Joi.boolean(), - options: Joi.object().default(), - ranges: Joi.boolean().default(true), - sample: Joi.number().min(0).max(100).when('modify', { is: true, then: Joi.forbidden() }), - schema: Joi.alternatives(Joi.object(), Joi.array(), Joi.func()).allow(true, false), - status: Joi.object().pattern(/\d\d\d/, Joi.alternatives(Joi.object(), Joi.array(), Joi.func()).allow(true, false)) + modify: Validate.boolean(), + options: Validate.object(), + ranges: Validate.boolean().default(true), + sample: Validate.number().min(0).max(100).when('modify', { then: Validate.forbidden() }), + schema: Validate.alternatives(Validate.object(), Validate.array(), Validate.function()).allow(true, false), + status: Validate.object().pattern(/\d\d\d/, Validate.alternatives(Validate.object(), Validate.array(), Validate.function()).allow(true, false)) }) - .default() - .assert('options.stripUnknown', Joi.when('modify', { is: true, otherwise: false }), 'meet requirement of having peer modify set to true'), - security: Joi.object({ - hsts: Joi.alternatives([ - Joi.object({ - maxAge: Joi.number(), - includeSubdomains: Joi.boolean(), - includeSubDomains: Joi.boolean(), - preload: Joi.boolean() + .default(), + security: Validate.object({ + hsts: Validate.alternatives([ + Validate.object({ + maxAge: Validate.number(), + includeSubdomains: Validate.boolean(), + includeSubDomains: Validate.boolean(), + preload: Validate.boolean() }), - Joi.boolean(), - Joi.number() + Validate.boolean(), + Validate.number() ]) .default(15768000), - xframe: Joi.alternatives([ - Joi.boolean(), - Joi.valid('sameorigin', 'deny'), - Joi.object({ - rule: Joi.valid('sameorigin', 'deny', 'allow-from'), - source: Joi.string() + xframe: Validate.alternatives([ + Validate.boolean(), + Validate.valid('sameorigin', 'deny'), + Validate.object({ + rule: Validate.valid('sameorigin', 'deny', 'allow-from'), + source: Validate.string() }) ]) .default('deny'), - xss: Joi.boolean().default(true), - noOpen: Joi.boolean().default(true), - noSniff: Joi.boolean().default(true) + xss: Validate.valid('enabled', 'disabled', false).default('disabled'), + noOpen: Validate.boolean().default(true), + noSniff: Validate.boolean().default(true), + referrer: Validate.alternatives([ + Validate.boolean().valid(false), + Validate.valid('', 'no-referrer', 'no-referrer-when-downgrade', + 'unsafe-url', 'same-origin', 'origin', 'strict-origin', + 'origin-when-cross-origin', 'strict-origin-when-cross-origin') + ]) + .default(false) }) .allow(null, false, true) .default(false), - state: Joi.object({ - parse: Joi.boolean().default(true), + state: Validate.object({ + parse: Validate.boolean().default(true), failAction: internals.failAction }) .default(), - timeout: Joi.object({ - socket: Joi.number().integer().positive().allow(false), - server: Joi.number().integer().positive().allow(false).default(false) + timeout: Validate.object({ + socket: Validate.number().integer().positive().allow(false), + server: Validate.number().integer().positive().allow(false).default(false) }) .default(), - validate: Joi.object({ - headers: Joi.alternatives(Joi.object(), Joi.array(), Joi.func()).allow(null, true), - params: Joi.alternatives(Joi.object(), Joi.array(), Joi.func()).allow(null, true), - query: Joi.alternatives(Joi.object(), Joi.array(), Joi.func()).allow(null, false, true), - payload: Joi.alternatives(Joi.object(), Joi.array(), Joi.func()).allow(null, false, true), + validate: Validate.object({ + headers: Validate.alternatives(Validate.object(), Validate.array(), Validate.function()).allow(null, true), + params: Validate.alternatives(Validate.object(), Validate.array(), Validate.function()).allow(null, true), + query: Validate.alternatives(Validate.object(), Validate.array(), Validate.function()).allow(null, false, true), + payload: Validate.alternatives(Validate.object(), Validate.array(), Validate.function()).allow(null, false, true), + state: Validate.alternatives(Validate.object(), Validate.array(), Validate.function()).allow(null, false, true), failAction: internals.failAction, - errorFields: Joi.object(), - options: Joi.object().default() + errorFields: Validate.object(), + options: Validate.object().default(), + validator: Validate.object() }) .default() }); -internals.server = Joi.object({ - address: Joi.string().hostname(), - app: Joi.object().allow(null), - autoListen: Joi.boolean(), - cache: Joi.allow(null), // Validated elsewhere - compression: Joi.object({ - minBytes: Joi.number().min(1).integer().default(1024) +internals.server = Validate.object({ + address: Validate.string().hostname(), + app: Validate.object().allow(null), + autoListen: Validate.boolean(), + cache: Validate.allow(null), // Validated elsewhere + compression: Validate.object({ + minBytes: Validate.number().min(1).integer().default(1024) }) .allow(false) .default(), - debug: Joi.object({ - request: Joi.array().items(Joi.string()).single().allow(false).default(['implementation']), - log: Joi.array().items(Joi.string()).single().allow(false) + debug: Validate.object({ + request: Validate.array().items(Validate.string()).single().allow(false).default(['implementation']), + log: Validate.array().items(Validate.string()).single().allow(false) }) .allow(false) .default(), - host: Joi.string().hostname().allow(null), - listener: Joi.any(), - load: Joi.object({ - sampleInterval: Joi.number().integer().min(0).default(0), - concurrent: Joi.number().integer().min(0).default(0) + host: Validate.string().hostname().allow(null), + info: Validate.object({ + remote: Validate.boolean().default(false) + }) + .default({}), + listener: Validate.any(), + load: Validate.object({ + sampleInterval: Validate.number().integer().min(0).default(0) }) .unknown() .default(), - mime: Joi.object().allow(null).default(null), - operations: Joi.object({ - cleanStop: Joi.boolean().default(true) + mime: Validate.object().empty(null).default(), + operations: Validate.object({ + cleanStop: Validate.boolean().default(true) }) .default(), - plugins: Joi.object(), - port: Joi.alternatives([ - Joi.number().integer().min(0), // TCP port - Joi.string().regex(/\//), // Unix domain socket - Joi.string().regex(/^\\\\\.\\pipe\\/) // Windows named pipe + plugins: Validate.object(), + port: Validate.alternatives([ + Validate.number().integer().min(0), // TCP port + Validate.string().pattern(/\//), // Unix domain socket + Validate.string().pattern(/^\\\\\.\\pipe\\/) // Windows named pipe ]) .allow(null), - router: Joi.object({ - isCaseSensitive: Joi.boolean().default(true), - stripTrailingSlash: Joi.boolean().default(false) + query: Validate.object({ + parser: Validate.function() + }) + .default(), + router: Validate.object({ + isCaseSensitive: Validate.boolean().default(true), + stripTrailingSlash: Validate.boolean().default(false) }) .default(), routes: internals.routeBase.default(), - state: Joi.object(), // Cookie defaults - tls: Joi.alternatives([ - Joi.object().allow(null), - Joi.boolean() + state: Validate.object(), // Cookie defaults + tls: Validate.alternatives([ + Validate.object().allow(null), + Validate.boolean() ]), - uri: Joi.string().regex(/[^/]$/) + uri: Validate.string().pattern(/[^/]$/) }); -internals.vhost = Joi.alternatives([ - Joi.string().hostname(), - Joi.array().items(Joi.string().hostname()).min(1) +internals.vhost = Validate.alternatives([ + Validate.string().hostname(), + Validate.array().items(Validate.string().hostname()).min(1) ]); -internals.handler = Joi.alternatives([ - Joi.func(), - Joi.object().length(1) +internals.handler = Validate.alternatives([ + Validate.function(), + Validate.object().length(1) ]); -internals.route = Joi.object({ - method: Joi.string().regex(/^[a-zA-Z0-9!#\$%&'\*\+\-\.^_`\|~]+$/).required(), - path: Joi.string().required(), - rules: Joi.object(), +internals.route = Validate.object({ + method: Validate.string().pattern(/^[a-zA-Z0-9!#\$%&'\*\+\-\.^_`\|~]+$/).required(), + path: Validate.string().required(), + rules: Validate.object(), vhost: internals.vhost, // Validated in route construction - handler: Joi.any(), - options: Joi.any(), - config: Joi.any() // Backwards compatibility + handler: Validate.any(), + options: Validate.any(), + config: Validate.any() // Backwards compatibility }) .without('config', 'options'); internals.pre = [ - Joi.func(), - Joi.object({ - method: Joi.alternatives(Joi.string(), Joi.func()).required(), - assign: Joi.string(), - mode: Joi.valid('serial', 'parallel'), + Validate.function(), + Validate.object({ + method: Validate.alternatives(Validate.string(), Validate.function()).required(), + assign: Validate.string(), + mode: Validate.valid('serial', 'parallel'), failAction: internals.failAction }) ]; internals.routeConfig = internals.routeBase.keys({ - description: Joi.string(), - id: Joi.string(), - isInternal: Joi.boolean(), + description: Validate.string(), + id: Validate.string(), + isInternal: Validate.boolean(), notes: [ - Joi.string(), - Joi.array().items(Joi.string()) + Validate.string(), + Validate.array().items(Validate.string()) ], - pre: Joi.array().items(internals.pre.concat(Joi.array().items(internals.pre).min(1))), + pre: Validate.array().items(...internals.pre.concat(Validate.array().items(...internals.pre).min(1))), tags: [ - Joi.string(), - Joi.array().items(Joi.string()) + Validate.string(), + Validate.array().items(Validate.string()) ] }); -internals.cacheConfig = Joi.object({ - name: Joi.string().invalid('_default'), - partition: Joi.string(), - shared: Joi.boolean(), - engine: Joi.alternatives([ - Joi.object(), - Joi.func() - ]) - .required() -}).unknown(); +internals.cacheConfig = Validate.alternatives([ + Validate.function(), + Validate.object({ + name: Validate.string().invalid('_default'), + shared: Validate.boolean(), + provider: [ + Validate.function(), + { + constructor: Validate.function().required(), + options: Validate.object({ + partition: Validate.string().default('hapi-cache') + }) + .unknown() // Catbox client validates other keys + .default({}) + } + ], + engine: Validate.object() + }) + .xor('provider', 'engine') +]); -internals.cache = Joi.array().items(internals.cacheConfig, Joi.func()).min(1).single(); +internals.cache = Validate.array().items(internals.cacheConfig).min(1).single(); -internals.cachePolicy = Joi.object({ - cache: Joi.string().allow(null).allow(''), - segment: Joi.string(), - shared: Joi.boolean() +internals.cachePolicy = Validate.object({ + cache: Validate.string().allow(null).allow(''), + segment: Validate.string(), + shared: Validate.boolean() }) - .options({ allowUnknown: true }); // Catbox validates other keys + .unknown(); // Catbox policy validates other keys -internals.method = Joi.object({ - bind: Joi.object().allow(null), - generateKey: Joi.func(), +internals.method = Validate.object({ + bind: Validate.object().allow(null), + generateKey: Validate.function(), cache: internals.cachePolicy }); -internals.methodObject = Joi.object({ - name: Joi.string().required(), - method: Joi.func().required(), - options: Joi.object() +internals.methodObject = Validate.object({ + name: Validate.string().required(), + method: Validate.function().required(), + options: Validate.object() }); -internals.register = Joi.object({ +internals.register = Validate.object({ once: true, - routes: Joi.object({ - prefix: Joi.string().regex(/^\/.+/), + routes: Validate.object({ + prefix: Validate.string().pattern(/^\/.+/), vhost: internals.vhost }) .default({}) }); +internals.semver = Validate.string(); + + internals.plugin = internals.register.keys({ - options: Joi.any(), - plugin: Joi.object({ - register: Joi.func().required(), - name: Joi.string().when('pkg.name', { is: Joi.exist(), otherwise: Joi.required() }), - version: Joi.string(), - multiple: Joi.boolean().default(false), - dependencies: Joi.array().items(Joi.string()).single(), + options: Validate.any(), + plugin: Validate.object({ + register: Validate.function().required(), + name: Validate.string().when('pkg.name', { is: Validate.exist(), otherwise: Validate.required() }), + version: Validate.string(), + multiple: Validate.boolean().default(false), + dependencies: [ + Validate.array().items(Validate.string()).single(), + Validate.object().pattern(/.+/, internals.semver) + ], once: true, - pkg: Joi.object({ - name: Joi.string(), - version: Joi.string().default('0.0.0') + requirements: Validate.object({ + hapi: Validate.string(), + node: Validate.string() + }) + .default(), + pkg: Validate.object({ + name: Validate.string(), + version: Validate.string().default('0.0.0') }) .unknown() .default({}) @@ -393,10 +437,10 @@ internals.plugin = internals.register.keys({ .unknown(); -internals.rules = Joi.object({ - validate: Joi.object({ - schema: Joi.alternatives(Joi.object(), Joi.array()).required(), - options: Joi.object() +internals.rules = Validate.object({ + validate: Validate.object({ + schema: Validate.alternatives(Validate.object(), Validate.array()).required(), + options: Validate.object() .default({ allowUnknown: true }) }) }); diff --git a/lib/core.js b/lib/core.js index d7ddbb931..202f6dbe0 100755 --- a/lib/core.js +++ b/lib/core.js @@ -1,22 +1,20 @@ 'use strict'; -// Load modules - const Http = require('http'); const Https = require('https'); const Os = require('os'); const Path = require('path'); -const Boom = require('boom'); -const Bounce = require('bounce'); -const Call = require('call'); -const Catbox = require('catbox'); -const CatboxMemory = require('catbox-memory'); -const Heavy = require('heavy'); -const Hoek = require('hoek'); -const Mimos = require('mimos'); -const Podium = require('podium'); -const Statehood = require('statehood'); +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'); @@ -25,23 +23,25 @@ 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'); -// Declare internals - 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') @@ -50,62 +50,87 @@ const internals = { exports = module.exports = internals.Core = class { - constructor(options) { + 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: [] } + }; - this.root = null; // Dispatch reference of the root server + constructor(options) { const { settings, type } = internals.setup(options); this.settings = settings; this.type = type; - this.app = {}; - this.auth = new Auth(this); - this.caches = new Map(); // Cache clients - this.compression = new Compression(); - this.decorations = { handler: [], request: [], server: [], toolkit: [] }; // Public decoration names - this.dependencies = []; // Plugin dependencies - this.events = new Podium(internals.events); this.heavy = new Heavy(this.settings.load); - this.instances = new Set(); - this.methods = new Methods(this); // Server methods this.mime = new Mimos(this.settings.mime); - this.onConnection = null; // Used to remove event listener on stop - this.plugins = {}; // Exposed plugin properties by name - this.queue = new internals.Queue(this.settings.load); - this.registrations = {}; // Tracks plugin for dependency validation { name -> { version } } - this.registring = 0; // > 0 while register() is waiting for plugin callbacks - this.requestCounter = { value: internals.counter.min, min: internals.counter.min, max: internals.counter.max }; this.router = new Call.Router(this.settings.router); - this.phase = 'stopped'; // 'stopped', 'initializing', 'initialized', 'starting', 'started', 'stopping', 'invalid' - this.sockets = null; // Track open sockets for graceful shutdown - this.started = false; this.states = new Statehood.Definitions(this.settings.state); - this.toolkit = new Toolkit(); - - this.extensionsSeq = 0; // Used to keep absolute order of extensions based on the order added across locations - this.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) - } - }; this._debug(); - this._decorations = { handler: {}, request: {}, server: {}, toolkit: {}, requestApply: null }; 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(); @@ -113,24 +138,27 @@ exports = module.exports = internals.Core = class { _debug() { + const debug = this.settings.debug; + if (!debug) { + return; + } + // Subscribe to server log events - if (this.settings.debug) { - const debug = (request, event) => { + 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)) : '')); - }; + const data = event.error ?? event.data; + console.error('Debug:', event.tags.join(', '), data ? '\n ' + (data.stack ?? (typeof data === 'object' ? Hoek.stringify(data) : data)) : ''); + }; - if (this.settings.debug.log) { - const filter = this.settings.debug.log.some((tag) => tag === '*') ? undefined : this.settings.debug.log; - this.events.on({ name: 'log', filter }, (event) => debug(null, event)); - } + if (debug.log) { + const filter = debug.log.some((tag) => tag === '*') ? undefined : debug.log; + this.events.on({ name: 'log', filter }, method); + } - if (this.settings.debug.request) { - const filter = this.settings.debug.request.some((tag) => tag === '*') ? undefined : this.settings.debug.request; - this.events.on({ name: 'request', filter }, debug); - } + if (debug.request) { + const filter = debug.request.some((tag) => tag === '*') ? undefined : debug.request; + this.events.on({ name: 'request', filter }, (request, event) => method(event)); } } @@ -141,7 +169,7 @@ exports = module.exports = internals.Core = class { } if (!this.caches.has('_default')) { - this._createCache([{ engine: CatboxMemory }]); // Defaults to memory-based + this._createCache([{ provider: CatboxMemory }]); // Defaults to memory-based } } @@ -159,43 +187,59 @@ exports = module.exports = internals.Core = class { port, protocol, id: Os.hostname() + ':' + process.pid + ':' + now.toString(36), - uri: this.settings.uri || (protocol + ':' + (this.type === 'tcp' ? '//' + host + (port ? ':' + port : '') : port)) + uri: this.settings.uri ?? (protocol + ':' + (this.type === 'tcp' ? '//' + host + (port ? ':' + port : '') : port)) }; return info; } - _createCache(options) { + _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'); - options = Config.apply('cache', options); + configs = Config.apply('cache', configs); const added = []; - for (let i = 0; i < options.length; ++i) { - let config = options[i]; + for (let config of configs) { + + // + // { provider: } + // { provider: { constructor: , options } } + // { engine } + if (typeof config === 'function') { - config = { engine: config }; + config = { provider: { constructor: config } }; } - const name = config.name || '_default'; + 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 (typeof config.engine === 'object') { - client = new Catbox.Client(config.engine); + + 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 { - const settings = Hoek.clone(config); - settings.partition = settings.partition || 'hapi-cache'; - delete settings.name; - delete settings.engine; - delete settings.shared; - - client = new Catbox.Client(config.engine, settings); + client = new Catbox.Client(config.engine); } - this.caches.set(name, { client, segments: {}, shared: config.shared || false }); + this.caches.set(name, { client, segments: {}, shared: config.shared ?? false }); added.push(client); } @@ -248,9 +292,13 @@ exports = module.exports = internals.Core = class { } this.phase = 'started'; - await this.events.emit('start'); + this.events.emit('start'); try { + if (this.controlled) { + await Promise.all(this.controlled.map((control) => control.start())); + } + await this._invoke('onPostStart'); } catch (err) { @@ -287,7 +335,8 @@ exports = module.exports = internals.Core = class { this.listener.listen(this.settings.port, finalize); } else { - const address = this.settings.address || this.settings.host || '0.0.0.0'; + // 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); } }); @@ -319,6 +368,10 @@ exports = module.exports = internals.Core = class { 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'; @@ -328,20 +381,18 @@ exports = module.exports = internals.Core = class { _validateDeps() { - for (let i = 0; i < this.dependencies.length; ++i) { - const dependency = this.dependencies[i]; - for (let j = 0; j < dependency.deps.length; ++j) { - const dep = dependency.deps[j]; - if (!this.registrations[dep]) { - throw new Error('Plugin ' + dependency.plugin + ' missing dependency ' + dep); - } + 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 + 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'); @@ -356,15 +407,20 @@ exports = module.exports = internals.Core = class { this.started = false; this.info.started = 0; - await this._unlisten(options); + await this._unlisten(options.timeout); } const caches = []; this.caches.forEach((cache) => caches.push(cache.client.stop())); await Promise.all(caches); - await this.events.emit('stop'); + 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'; } @@ -374,29 +430,31 @@ exports = module.exports = internals.Core = class { } } - _unlisten(options) { + _unlisten(timeout) { let timeoutId = null; if (this.settings.operations.cleanStop) { // Set connections timeout - const timeout = () => { + const destroy = () => { + + for (const connection of this.sockets) { + connection.destroy(); + } - this.sockets.forEach((connection) => connection.destroy()); this.sockets.clear(); }; - timeoutId = setTimeout(timeout, options.timeout); + timeoutId = setTimeout(destroy, timeout); // Tell idle keep-alive connections to close - this.sockets.forEach((connection) => { - - if (!connection._isHapiProcessing) { + for (const connection of this.sockets) { + if (!this.actives.has(connection)) { connection.end(); } - }); + } } // Close connection @@ -413,6 +471,8 @@ exports = module.exports = internals.Core = class { this._initializeListener(); resolve(); }); + + this.events.emit('closing'); }); } @@ -423,10 +483,12 @@ exports = module.exports = internals.Core = class { return; } - for (let i = 0; i < exts.nodes.length; ++i) { - const ext = exts.nodes[i]; - const bind = (ext.bind || ext.realm.settings.bind); - await ext.func.call(bind, ext.server, bind); + // 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 }); } } @@ -444,18 +506,20 @@ exports = module.exports = internals.Core = class { return (req, res) => { + // Create request + + const request = Request.generate(this.root, req, res, options); + // Track socket request processing state - if (req.socket) { - req.socket._isHapiProcessing = true; + 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)); } - // Create request - - const request = Request.generate(this.root, req, res, options); - // Check load if (this.settings.load.sampleInterval) { @@ -470,13 +534,13 @@ exports = module.exports = internals.Core = class { } } - this.queue.add(request); + request._execute(); }; } _createListener() { - const listener = this.settings.listener || (this.settings.tls ? Https.createServer(this.settings.tls) : Http.createServer()); + 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 })); @@ -484,8 +548,35 @@ exports = module.exports = internals.Core = class { this._log(['connection', 'client', 'error'], err); - if (socket.writable) { - socket.end(internals.badRequestResponse); + 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); @@ -505,7 +596,7 @@ exports = module.exports = internals.Core = class { 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)); + this.info.uri = this.settings.uri ?? this.info.protocol + '://' + this.info.host + ':' + this.info.port; } if (this.settings.operations.cleanStop) { @@ -532,17 +623,20 @@ exports = module.exports = internals.Core = class { options = Config.apply('cachePolicy', options); - const plugin = realm && realm.plugin; - const segment = options.segment || _segment || (plugin ? `!${plugin}` : ''); + const plugin = realm?.plugin; + const segment = options.segment ?? _segment ?? (plugin ? `!${plugin}` : ''); Hoek.assert(segment, 'Missing cache segment name'); - const cacheName = options.cache || '_default'; + 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; - return new Catbox.Policy(options, cache.client, segment); + const policy = new Catbox.Policy(options, cache.client, segment); + this.events.emit('cachePolicy', [policy, options.cache, segment]); + + return policy; } log(tags, data) { @@ -561,7 +655,7 @@ exports = module.exports = internals.Core = class { } const timestamp = Date.now(); - const field = (data instanceof Error ? 'error' : 'data'); + const field = data instanceof Error ? 'error' : 'data'; let event = { timestamp, tags, [field]: data, channel }; @@ -576,7 +670,8 @@ exports = module.exports = internals.Core = class { internals.setup = function (options = {}) { - let settings = Hoek.cloneWithShallow(options, ['listener', 'routes.bind']); + 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); @@ -616,50 +711,8 @@ internals.onFinish = function (env) { const { core, req } = env; - req.socket._isHapiProcessing = false; + core.actives.delete(req.socket); if (!core.started) { req.socket.end(); } }; - - -internals.Queue = class { - - constructor(options) { - - this.settings = options; - - this.active = 0; - this.queue = []; - } - - add(request) { - - if (this.settings.concurrent) { - this.queue.push(request); - this.next(); - } - else { - request._execute(); - } - } - - next() { - - if (this.queue.length && - this.active < this.settings.concurrent) { - - const request = this.queue.shift(); - ++this.active; - request._execute(); - } - } - - release() { - - if (this.settings.concurrent) { - --this.active; - this.next(); - } - } -}; diff --git a/lib/cors.js b/lib/cors.js index e9bb33010..6600531f2 100755 --- a/lib/cors.js +++ b/lib/cors.js @@ -1,15 +1,11 @@ 'use strict'; -// Load modules - -const Boom = require('boom'); -const Hoek = require('hoek'); +const Boom = require('@hapi/boom'); +const Hoek = require('@hapi/hoek'); let Route = null; // Delayed load due to circular dependency -// Declare internals - const internals = {}; @@ -45,8 +41,7 @@ exports.route = function (options) { wildcards: [] }; - for (let i = 0; i < settings.origin.length; ++i) { - const origin = settings.origin[i]; + for (const origin of settings.origin) { if (origin.indexOf('*') !== -1) { settings._origin.wildcards.push(new RegExp('^' + Hoek.escapeRegex(origin).replace(/\\\*/g, '.*').replace(/\\\?/g, '.') + '$')); } @@ -80,7 +75,16 @@ exports.handler = function (server) { return; } - const route = new Route({ method: '_special', path: '/{p*}', handler: internals.handler }, server, { special: true }); + const definition = { + method: '_special', + path: '/{p*}', + handler: internals.handler, + options: { + cors: false + } + }; + + const route = new Route(definition, server, { special: true }); server._core.router.special('options', route); }; @@ -133,6 +137,7 @@ internals.handler = function (request, h) { // Reply with the route CORS headers const response = h.response(); + response.code(settings.preflightStatusCode); response._header('access-control-allow-origin', settings._origin ? origin : '*'); response._header('access-control-allow-methods', method); response._header('access-control-allow-headers', settings._headersString); @@ -150,16 +155,18 @@ internals.handler = function (request, h) { }; -exports.headers = function (request) { +exports.headers = function (response) { + const request = response.request; const settings = request.route.settings.cors; - const response = request.response; if (settings._origin !== false) { response.vary('origin'); } - if (!request.info.cors.isOriginMatch) { + if ((request.info.cors && !request.info.cors.isOriginMatch) || // After route lookup + !exports.matchOrigin(request.headers.origin, request.route.settings.cors)) { // Response from onRequest + return; } @@ -191,8 +198,8 @@ exports.matchOrigin = function (origin, settings) { return true; } - for (let i = 0; i < settings._origin.wildcards.length; ++i) { - if (origin.match(settings._origin.wildcards[i])) { + for (const wildcard of settings._origin.wildcards) { + if (origin.match(wildcard)) { return true; } } diff --git a/lib/ext.js b/lib/ext.js index b423ba86e..a3caa2ffc 100755 --- a/lib/ext.js +++ b/lib/ext.js @@ -1,74 +1,72 @@ 'use strict'; -// Load modules +const Hoek = require('@hapi/hoek'); +const Topo = require('@hapi/topo'); -const Hoek = require('hoek'); -const Topo = require('topo'); - - -// Declare internals const internals = {}; exports = module.exports = internals.Ext = class { - constructor(type, core) { + type = null; + nodes = null; - this._topo = new Topo(); - this._core = core; - this._routes = []; + #core = null; + #routes = []; + #topo = new Topo.Sorter(); + constructor(type, core) { + + this.#core = core; this.type = type; - this.nodes = null; } add(event) { const methods = [].concat(event.method); - const options = event.options; - - for (let i = 0; i < methods.length; ++i) { + for (const method of methods) { const settings = { - before: options.before, - after: options.after, + before: event.options.before, + after: event.options.after, group: event.realm.plugin, - sort: this._core.extensionsSeq++ + sort: this.#core.extensionsSeq++ }; const node = { - func: methods[i], // Request: function (request, h), Server: function (server) - bind: options.bind, + func: method, // Request: function (request, h), Server: function (server) + bind: event.options.bind, server: event.server, // Server event - realm: event.realm + realm: event.realm, + timeout: event.options.timeout }; - this._topo.add(node, settings); + this.#topo.add(node, settings); } - this.nodes = this._topo.nodes; + this.nodes = this.#topo.nodes; // Notify routes - for (let i = 0; i < this._routes.length; ++i) { - this._routes[i].rebuild(event); + for (const route of this.#routes) { + route.rebuild(event); } } merge(others) { const merge = []; - for (let i = 0; i < others.length; ++i) { - merge.push(others[i]._topo); + for (const other of others) { + merge.push(other.#topo); } - this._topo.merge(merge); - this.nodes = (this._topo.nodes.length ? this._topo.nodes : null); + this.#topo.merge(merge); + this.nodes = this.#topo.nodes.length ? this.#topo.nodes : null; } subscribe(route) { - this._routes.push(route); + this.#routes.push(route); } static combine(route, type) { @@ -77,8 +75,8 @@ exports = module.exports = internals.Ext = class { const events = route.settings.ext[type]; if (events) { - for (let i = 0; i < events.length; ++i) { - const event = Hoek.shallow(events[i]); + for (let event of events) { + event = Object.assign({}, event); // Shallow cloned Hoek.assert(!event.options.sandbox, 'Cannot specify sandbox option for route extension'); event.realm = route.realm; ext.add(event); diff --git a/lib/handler.js b/lib/handler.js index e3d3e18e7..9ae261022 100755 --- a/lib/handler.js +++ b/lib/handler.js @@ -1,11 +1,7 @@ 'use strict'; -// Load modules +const Hoek = require('@hapi/hoek'); -const Hoek = require('hoek'); - - -// Declare internals const internals = {}; @@ -15,18 +11,16 @@ exports.execute = async function (request) { // Prerequisites if (request._route._prerequisites) { - for (let i = 0; i < request._route._prerequisites.length; ++i) { // Serial execution of each set - const set = request._route._prerequisites[i]; + for (const set of request._route._prerequisites) { // Serial execution of each set const pres = []; - for (let j = 0; j < set.length; ++j) { - pres.push(internals.handler(request, set[j].method, set[j])); + for (const item of set) { + pres.push(internals.handler(request, item.method, item)); } const responses = await Promise.all(pres); // Parallel execution within sets - - for (let j = 0; j < responses.length; ++j) { - if (responses[j] !== undefined) { - return responses[j]; + for (const response of responses) { + if (response !== undefined) { + return response; } } } @@ -90,7 +84,7 @@ exports.defaults = function (method, handler, core) { if (typeof handler === 'object') { const type = Object.keys(handler)[0]; - const serverHandler = core._decorations.handler[type]; + const serverHandler = core.decorations.handler.get(type); Hoek.assert(serverHandler, 'Unknown handler:', type); @@ -99,7 +93,7 @@ exports.defaults = function (method, handler, core) { } } - return defaults || {}; + return defaults ?? {}; }; @@ -107,7 +101,7 @@ exports.configure = function (handler, route) { if (typeof handler === 'object') { const type = Object.keys(handler)[0]; - const serverHandler = route._core._decorations.handler[type]; + const serverHandler = route._core.decorations.handler.get(type); Hoek.assert(serverHandler, 'Unknown handler:', type); @@ -146,12 +140,11 @@ exports.prerequisitesConfig = function (config) { const prerequisites = []; - for (let i = 0; i < config.length; ++i) { - const pres = [].concat(config[i]); + for (let pres of config) { + pres = [].concat(pres); const set = []; - for (let j = 0; j < pres.length; ++j) { - let pre = pres[j]; + for (let pre of pres) { if (typeof pre !== 'object') { pre = { method: pre }; } @@ -159,7 +152,7 @@ exports.prerequisitesConfig = function (config) { const item = { method: pre.method, assign: pre.assign, - failAction: pre.failAction || 'error' + failAction: pre.failAction ?? 'error' }; set.push(item); diff --git a/lib/headers.js b/lib/headers.js index 62878863d..088926abc 100755 --- a/lib/headers.js +++ b/lib/headers.js @@ -1,34 +1,29 @@ 'use strict'; -// Load modules const Stream = require('stream'); -const Boom = require('boom'); +const Boom = require('@hapi/boom'); -const Response = require('./response'); - - -// Declare internals const internals = {}; -exports.cache = function (request) { +exports.cache = function (response) { - const response = request.response; + const request = response.request; if (response.headers['cache-control']) { return; } const settings = request.route.settings.cache; - const policy = settings && request._route._cache && (settings._statuses[response.statusCode] || (response.statusCode === 304 && settings._statuses['200'])); + const policy = settings && request._route._cache && (settings._statuses.has(response.statusCode) || (response.statusCode === 304 && settings._statuses.has(200))); if (policy || response.settings.ttl) { - const ttl = (response.settings.ttl !== null ? response.settings.ttl : request._route._cache.ttl()); - const privacy = (request.auth.isAuthenticated || response.headers['set-cookie'] ? 'private' : settings.privacy || 'default'); + const ttl = response.settings.ttl !== null ? response.settings.ttl : request._route._cache.ttl(); + const privacy = request.auth.isAuthenticated || response.headers['set-cookie'] ? 'private' : settings.privacy ?? 'default'; response._header('cache-control', 'max-age=' + Math.floor(ttl / 1000) + ', must-revalidate' + (privacy !== 'default' ? ', ' + privacy : '')); } else if (settings) { @@ -37,84 +32,74 @@ exports.cache = function (request) { }; -exports.content = async function (request) { +exports.content = async function (response) { - const response = request.response; + const request = response.request; if (response._isPayloadSupported() || request.method === 'head') { await response._marshal(); - if (request.jsonp && - response._payload.jsonp) { - - response._header('content-type', 'text/javascript' + (response.settings.charset ? '; charset=' + response.settings.charset : '')); - response._header('x-content-type-options', 'nosniff'); - response._payload.jsonp(request.jsonp); - } - - if (response._payload.size && - typeof response._payload.size === 'function') { - + if (typeof response._payload.size === 'function') { response._header('content-length', response._payload.size(), { override: false }); } if (!response._isPayloadSupported()) { - response._close(request); // Close unused file streams + response._close(); // Close unused file streams response._payload = new internals.Empty(); // Set empty stream } - exports.type(request, true); + exports.type(response); } else { // Set empty stream - response._close(request); // Close unused file streams + response._close(); // Close unused file streams response._payload = new internals.Empty(); delete response.headers['content-length']; } }; -exports.state = async function (request) { +exports.state = async function (response) { - const response = request.response; - const names = {}; + const request = response.request; const states = []; - const requestStates = Object.keys(request._states); - for (let i = 0; i < requestStates.length; ++i) { - const stateName = requestStates[i]; - names[stateName] = true; - states.push(request._states[stateName]); - } + const clearOnly = response._error?.output.statusCode === 500; - try { - const keys = Object.keys(request._core.states.cookies); - for (let i = 0; i < keys.length; ++i) { - const name = keys[i]; - const autoValue = request._core.states.cookies[name].autoValue; - if (!autoValue || names[name]) { - continue; - } + for (const name in request._states) { + if (!clearOnly || + (request._states[name].options?.ttl === 0 && request._core.states.cookies[name])) { - names[name] = true; + states.push(request._states[name]); + } + } - if (typeof autoValue !== 'function') { - states.push({ name, value: autoValue }); - continue; + try { + if (request.state && !clearOnly) { + for (const name in request._core.states.cookies) { + const autoValue = request._core.states.cookies[name].autoValue; + if (!autoValue || name in request._states || name in request.state) { + continue; + } + + if (typeof autoValue !== 'function') { + states.push({ name, value: autoValue }); + continue; + } + + const value = await autoValue(request); + states.push({ name, value }); } - - const value = await autoValue(request); - states.push({ name, value }); } if (!states.length) { return; } - let header = await request._core.states.format(states); + let header = await request._core.states.format(states, request); const existing = response.headers['set-cookie']; if (existing) { header = (Array.isArray(existing) ? existing : [existing]).concat(header); @@ -123,47 +108,35 @@ exports.state = async function (request) { response._header('set-cookie', header); } catch (err) { - const error = Boom.boomify(err); + if (!(err instanceof Error)) { + // eslint-disable-next-line no-ex-assign + err = Boom.badImplementation('A non-Error value was thrown', err); + } + + const error = Boom.boomify(err, { statusCode: 500 }); request._log(['state', 'response', 'error'], error); - request._states = {}; // Clear broken state throw error; } }; -exports.type = function (request, postMarshal) { +exports.type = function (response) { - const response = request.response; - let type = response.headers['content-type']; - if (!type) { - if (response._contentType) { - const charset = (response.settings.charset && response._contentType !== 'application/octet-stream' ? '; charset=' + response.settings.charset : ''); - response.type(response._contentType + charset); - } - } - else { - type = type.trim(); - if ((!response._contentType || !postMarshal) && - response.settings.charset && - type.match(/^(?:text\/)|(?:application\/(?:json)|(?:javascript))/)) { - - if (!type.match(/; *charset=/)) { - const semi = (type[type.length - 1] === ';'); - response.type(type + (semi ? ' ' : '; ') + 'charset=' + (response.settings.charset)); - } - } + const type = response.contentType; + if (type !== null && type !== response.headers['content-type']) { + response.type(type); } }; -exports.entity = function (request) { +exports.entity = function (response) { + + const request = response.request; if (!request._entity) { return; } - const response = request.response; - if (request._entity.etag && !response.headers.etag) { @@ -178,9 +151,9 @@ exports.entity = function (request) { }; -exports.unmodified = function (request) { +exports.unmodified = function (response) { - const response = request.response; + const request = response.request; if (response.statusCode === 304) { return; } @@ -191,8 +164,13 @@ exports.unmodified = function (request) { modified: response.headers['last-modified'] }; - if (Response.unmodified(request, entity)) { + const etag = request._core.Response.unmodified(request, entity); + if (etag) { response.code(304); + + if (etag !== true) { // Override etag with incoming weak match + response.headers.etag = etag; + } } }; diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 000000000..fcb073fef --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/lib/index.js b/lib/index.js index fb72f5cfc..b18c38d24 100755 --- a/lib/index.js +++ b/lib/index.js @@ -1,14 +1,11 @@ 'use strict'; -// Load modules - const Server = require('./server'); -// Declare internals - const internals = {}; exports.Server = Server; + exports.server = Server; diff --git a/lib/methods.js b/lib/methods.js index a2cf822a3..858e93111 100755 --- a/lib/methods.js +++ b/lib/methods.js @@ -1,15 +1,11 @@ 'use strict'; -// Load modules - -const Boom = require('boom'); -const Hoek = require('hoek'); +const Boom = require('@hapi/boom'); +const Hoek = require('@hapi/hoek'); const Config = require('./config'); -// Declare internals - const internals = { methodNameRx: /^[_$a-zA-Z][$\w]*(?:\.[_$a-zA-Z][$\w]*)*$/ }; @@ -17,10 +13,13 @@ const internals = { exports = module.exports = internals.Methods = class { + methods = {}; + + #core = null; + constructor(core) { - this.core = core; - this.methods = {}; + this.#core = core; } add(name, method, options, realm) { @@ -32,9 +31,9 @@ exports = module.exports = internals.Methods = class { // {} or [{}, {}] const items = [].concat(name); - for (let i = 0; i < items.length; ++i) { - const item = Config.apply('methodObject', items[i]); - this._add(item.name, item.method, item.options || {}, realm); + for (let item of items) { + item = Config.apply('methodObject', item); + this._add(item.name, item.method, item.options ?? {}, realm); } } @@ -47,10 +46,10 @@ exports = module.exports = internals.Methods = class { options = Config.apply('method', options, name); - const settings = Hoek.cloneWithShallow(options, ['bind']); - settings.generateKey = settings.generateKey || internals.generateKey; + const settings = Hoek.clone(options, { shallow: ['bind'] }); + settings.generateKey = settings.generateKey ?? internals.generateKey; - const bind = settings.bind || realm.settings.bind || null; + const bind = settings.bind ?? realm.settings.bind ?? null; const bound = !bind ? method : (...args) => method.apply(bind, args); // Not cached @@ -65,7 +64,7 @@ exports = module.exports = internals.Methods = class { Hoek.assert(settings.cache.generateTimeout !== undefined, 'Method caching requires a timeout value in generateTimeout:', name); settings.cache.generateFunc = (id, flags) => bound(...id.args, flags); - const cache = this.core._cachePolicy(settings.cache, '#' + name); + const cache = this.#core._cachePolicy(settings.cache, '#' + name); const func = function (...args) { @@ -108,15 +107,15 @@ exports = module.exports = internals.Methods = class { }; +internals.supportedArgs = ['string', 'number', 'boolean']; + + internals.generateKey = function (...args) { let key = ''; for (let i = 0; i < args.length; ++i) { const arg = args[i]; - if (typeof arg !== 'string' && - typeof arg !== 'number' && - typeof arg !== 'boolean') { - + if (!internals.supportedArgs.includes(typeof arg)) { return null; } diff --git a/lib/request.js b/lib/request.js index 40aaef3ba..26edc0981 100755 --- a/lib/request.js +++ b/lib/request.js @@ -1,24 +1,21 @@ 'use strict'; -// Load modules - +const Querystring = require('querystring'); const Url = require('url'); -const Boom = require('boom'); -const Bounce = require('bounce'); -const Hoek = require('hoek'); -const Podium = require('podium'); +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 Response = require('./response'); +const Toolkit = require('./toolkit'); const Transmit = require('./transmit'); -// Declare internals - 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', 'orig', 'params', 'paramsArray', 'payload', 'state', 'jsonp', 'response', 'raw', 'domain', 'log', 'logs', 'generateResponse'] + 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'] }; @@ -27,21 +24,23 @@ 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 ? Hoek.shallow(options.app) : {}); // Place for application-specific state without conflicts with hapi, should not be used by plugins + 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.info = internals.info(this._core, req); - this.jsonp = null; this.logs = []; this.method = req.method.toLowerCase(); this.mime = null; @@ -49,8 +48,8 @@ exports = module.exports = internals.Request = class { this.params = null; this.paramsArray = null; // Array of path parameters in path order this.path = null; - this.payload = null; - this.plugins = (options.plugins ? Hoek.shallow(options.plugins) : {}); // Place for plugins to store state without conflicts with hapi, should be namespaced using plugin name + 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 }; @@ -59,34 +58,34 @@ exports = module.exports = internals.Request = class { this.query = null; this.server = server; this.state = null; - this.url = null; + + this.info = new internals.Info(this); this.auth = { isAuthenticated: false, isAuthorized: false, - credentials: options.credentials || null, // Special keys: 'app', 'user', 'scope' - artifacts: options.artifacts || null, // Scheme-specific artifacts - strategy: null, + 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.setUrl(req.url, this._core.settings.router.stripTrailingSlash); + this._initializeUrl(); } static generate(server, req, res, options) { - const request = new internals.Request(server, req, res, options); + const request = new server._core.Request(server, req, res, options); // Decorate - if (server._core._decorations.requestApply) { - const properties = Object.keys(server._core._decorations.requestApply); - for (let i = 0; i < properties.length; ++i) { - const property = properties[i]; - const assignment = server._core._decorations.requestApply[property]; + if (server._core.decorations.requestApply) { + for (const [property, assignment] of server._core.decorations.requestApply.entries()) { request[property] = assignment(request); } } @@ -98,47 +97,171 @@ exports = module.exports = internals.Request = class { get events() { if (!this._events) { - this._events = new Podium(internals.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'); - url = (typeof url === 'string' ? Url.parse(url, true) : Hoek.clone(url)); + 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; + } + + const host = this.info.host || this._formatIpv6Host(this._core.info.host, this._core.info.port); + + this._url = new Url.URL(`${this._core.info.protocol}://${host}${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; + } + + _isBareIpv6(host) { + + // An IPv6 address contains at least two colons. + + return /:[^:]*:/.test(host); + } + + _formatIpv6Host(host, port) { - // Apply path modifications + return this._isBareIpv6(host) ? `[${host}]:${port}` : `${host}:${port}`; + } + + _normalizePath(url, options) { - let path = this._core.router.normalize(url.pathname || ''); // pathname excludes query + let path = this._core.router.normalize(url.pathname); - if (stripTrailingSlash && + if (options.stripTrailingSlash && path.length > 1 && path[path.length - 1] === '/') { path = path.slice(0, -1); } - // Update derived url properties + url.pathname = path; + } + + _parseQuery(searchParams) { + + let query = Object.create(null); + + // Flatten map - if (path !== url.pathname) { - url.pathname = path; - url.path = url.search ? path + url.search : path; - url.href = Url.format(url); + 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); } - // Store request properties + // Custom parser - this.url = url; - this.query = url.query; - this.path = url.pathname; + const parser = this._core.settings.query.parser; + if (parser) { + query = parser(query); + if (!query || + typeof query !== 'object') { - if (url.hostname) { - this.info.hostname = url.hostname; - this.info.host = url.host; + throw Boom.badImplementation('Parsed query must be an object'); + } } + + return query; } setMethod(method) { @@ -149,6 +272,11 @@ exports = module.exports = internals.Request = class { this.method = method.toLowerCase(); } + active() { + + return !!this._eventContext.request; + } + async _execute() { this.info.acceptEncoding = this._core.compression.accept(this); @@ -163,7 +291,7 @@ exports = module.exports = internals.Request = class { this._lookup(); this._setTimeouts(); - await this._lifecycle(this._route._cycle, false); + await this._lifecycle(); this._reply(); } @@ -184,10 +312,8 @@ exports = module.exports = internals.Request = class { // Validate path - if (!this.path || - this.path[0] !== '/') { - - throw Boom.badRequest('Invalid path'); + if (this._urlError) { + throw this._urlError; } } @@ -197,9 +323,10 @@ exports = module.exports = internals.Request = class { this.raw.req.on('end', internals.event.bind(this.raw.req, this._eventContext, 'end')); } - this.raw.req.on('close', internals.event.bind(this.raw.req, this._eventContext, 'close')); + 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() { @@ -212,8 +339,8 @@ exports = module.exports = internals.Request = class { this.route = this._route.public; } - this.params = match.params || {}; - this.paramsArray = match.paramsArray || []; + this.params = match.params ?? {}; + this.paramsArray = match.paramsArray ?? []; if (this.route.settings.cors) { this.info.cors = { @@ -246,59 +373,52 @@ exports = module.exports = internals.Request = class { this._serverTimeoutId = setTimeout(internals.timeoutReply, serverTimeout, this, serverTimeout); } - async _lifecycle(cycle, postCycle) { - - for (let i = 0; i < cycle.length; ++i) { - if ((this._isReplied && !postCycle) || - !this._eventContext.request) { + async _lifecycle() { + for (const func of this._route._cycle) { + if (this._isReplied) { return; } - const func = cycle[i]; - try { - var response = (typeof func === 'function' ? func(this) : this._invoke(func)); - if (response && typeof response.then === 'function') { // Skip await if no reason to - response = await response; - } + var response = await (typeof func === 'function' ? func(this) : this._invoke(func)); } catch (err) { Bounce.rethrow(err, 'system'); - response = Response.wrap(err, this); + response = this._core.Response.wrap(err, this); } if (!response || - response === this._core.toolkit.continue) { // Continue + response === Toolkit.symbols.continue) { // Continue continue; } - if (internals.skip(response) && - (!postCycle || !response._takeover)) { - - this._setResponse(response); - return; - } - - if (this.response === null) { - this._setResponse(Boom.badImplementation('Lifecycle methods called before the handler can only return an error, a takeover response, or a continue signal')); - return; + 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) { + async _invoke(event, options = {}) { - for (let i = 0; i < event.nodes.length; ++i) { - const ext = event.nodes[i]; - const bind = (ext.bind || ext.realm.settings.bind); + for (const ext of event.nodes) { const realm = ext.realm; - const response = await this._core.toolkit.execute(ext.func, this, { bind, 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); + } - if (response === this._core.toolkit.continue) { + continue; + } + + if (response === Toolkit.symbols.continue) { continue; } @@ -324,23 +444,25 @@ exports = module.exports = internals.Request = class { 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 (exit) { // Can be a valid response or error (if returned from an ext, already handled because this.response is also set) - this._setResponse(Response.wrap(exit, this)); // Wrap to ensure any object thrown is always a valid Boom or Response object - } - if (typeof this.response === 'symbol') { // close or abandon this._abort(); return; } - await this._lifecycle(this._route._postCycle, true); + await this._postCycle(); + + if (!this._eventContext.request || + typeof this.response === 'symbol') { // close or abandon - if (typeof this.response === 'symbol') { // close or abandon this._abort(); return; } @@ -349,9 +471,32 @@ exports = module.exports = internals.Request = class { 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 === this._core.toolkit.close) { + if (this.response === Toolkit.symbols.close) { this.raw.res.end(); // End the response in case it wasn't already closed } @@ -360,28 +505,26 @@ exports = module.exports = internals.Request = class { _finalize() { - this.info.responded = Date.now(); + this._eventContext.request = null; // Disable req events - if (this.response && - this.response.statusCode === 500 && - this.response._error) { + 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'); + const tags = this.response._error.isDeveloperError ? ['internal', 'implementation', 'error'] : ['internal', 'error']; + this._log(tags, this.response._error, 'error'); + } + + this.response._close(); } - // Cleanup + this.info.completed = Date.now(); - this._eventContext.request = null; // Disable req events - - if (this.response && - this.response._close) { + this._core.events.emit('response', this); - this.response._close(this); + if (this._route._extensions.onPostResponse.nodes) { + this._invoke(this._route._extensions.onPostResponse, { ignoreResponse: true }); } - - this._core.events.emit('response', this); - this._core.queue.release(); } _setResponse(response) { @@ -389,15 +532,13 @@ exports = module.exports = internals.Request = class { if (this.response && !this.response.isBoom && this.response !== response && - (response.isBoom || this.response.source !== response.source)) { + this.response.source !== response.source) { - this.response._close(this); + this.response._close?.(); } - if (this.info.responded) { - if (response._close) { - response._close(this); - } + if (this.info.completed) { + response._close?.(); return; } @@ -405,6 +546,44 @@ exports = module.exports = internals.Request = class { this.response = response; } + async _parseCookies() { + + this.state = {}; + + const req = this.raw.req; + const cookies = req.headers.cookie; + if (!cookies) { + return; + } + + try { + var result = await this._core.states.parse(cookies); + } + catch (err) { + Bounce.rethrow(err, 'system'); + var parseError = err; + } + + const { states, failed = [] } = result ?? parseError; + this.state = states ?? {}; + + // Clear cookies + + for (const item of failed) { + if (item.settings.clearInvalid) { + this._clearState(item.name); + } + } + + if (!parseError) { + return; + } + + parseError.header = cookies; + + return this._core.toolkit.failAction(this, this.route.settings.state.failAction, parseError, { tags: ['state', 'error'] }); + } + _setState(name, value, options) { const state = { name, value }; @@ -432,7 +611,13 @@ exports = module.exports = internals.Request = class { return null; } - return (this._events.hasListeners('finish') || this._events.hasListeners('peek') ? new Response.Peek(this._events) : null); + if (this._events.hasListeners('peek') || + this._events.hasListeners('finish')) { + + return new this._core.Response.Peek(this._events); + } + + return null; } log(tags, data) { @@ -453,7 +638,7 @@ exports = module.exports = internals.Request = class { } const timestamp = Date.now(); - const field = (data instanceof Error ? 'error' : 'data'); + const field = data instanceof Error ? 'error' : 'data'; let event = [this, { request: this.info.id, timestamp, tags, [field]: data, channel }]; if (typeof data === 'function') { @@ -473,43 +658,99 @@ exports = module.exports = internals.Request = class { generateResponse(source, options) { - return new Response(source, this, options); + return new this._core.Response(source, this, options); } }; internals.Request.reserved = internals.reserved; +internals.Request.symbols = { + authPayload: Symbol('auth.payload') +}; + +internals.Info = class { -internals.info = function (core, req) { + constructor(request) { - const host = req.headers.host ? req.headers.host.trim() : ''; - const received = Date.now(); + this._request = request; - const info = { - received, - remoteAddress: req.connection.remoteAddress, - remotePort: req.connection.remotePort || '', - referrer: req.headers.referrer || req.headers.referer || '', - host, - hostname: host.split(':')[0], - id: `${received}:${core.info.id}:${core.requestCounter.value++}`, + const req = request.raw.req; + const host = (req.headers.host || req.headers[':authority'] || '').trim(); + const received = Date.now(); + + this.received = received; + this.referrer = req.headers.referrer || req.headers.referer || ''; + this.host = host; + this.hostname = /^(.*?)(?::\d+)?$/.exec(host)[1]; + this.id = `${received}:${request._core.info.id}:${request._core._counter()}`; + + this._remoteAddress = null; + this._remotePort = null; // Assigned later - acceptEncoding: null, - cors: null, - responded: 0 - }; + 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; + } + } - if (core.requestCounter.value > core.requestCounter.max) { - core.requestCounter.value = core.requestCounter.min; + return this._remoteAddress; } - return info; + 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) { @@ -518,6 +759,12 @@ internals.event = function ({ request }, event, err) { request._isPayloadPending = false; + if (event === 'close' && + request.raw.res.writableEnded) { + + return; + } + if (event === 'end') { return; } @@ -530,10 +777,17 @@ internals.event = function ({ request }, event, err) { request._eventContext.request = null; - if (event === 'abort' && - request._events) { + 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._events.emit('disconnect'); + request._reply(new Boom.Boom('Request aborted', { statusCode: request.route.settings.response.disconnectStatusCode, data: request.response })); + + if (request._events) { + request._events.emit('disconnect'); + } } }; @@ -548,5 +802,5 @@ internals.timeoutReply = function (request, timeout) { internals.skip = function (response) { - return (response.isBoom || response._takeover || typeof response === 'symbol'); + return response.isBoom || response._takeover || typeof response === 'symbol'; }; diff --git a/lib/response.js b/lib/response.js index b8106014d..2acd8320a 100755 --- a/lib/response.js +++ b/lib/response.js @@ -1,19 +1,15 @@ 'use strict'; -// Load modules - const Stream = require('stream'); -const Boom = require('boom'); -const Bounce = require('bounce'); -const Hoek = require('hoek'); -const Podium = require('podium'); +const Boom = require('@hapi/boom'); +const Bounce = require('@hapi/bounce'); +const Hoek = require('@hapi/hoek'); +const Podium = require('@hapi/podium'); const Streams = require('./streams'); -// Declare internals - const internals = { events: Podium.validate(['finish', { name: 'peek', spread: true }]), hopByHop: { @@ -22,10 +18,15 @@ const internals = { 'proxy-authenticate': true, 'proxy-authorization': true, 'te': true, - 'trailers': true, + 'trailer': true, 'transfer-encoding': true, 'upgrade': true - } + }, + reserved: ['app', 'headers', 'plugins', 'request', 'source', 'statusCode', 'variety', + 'settings', 'events', 'code', 'message', 'header', 'vary', 'etag', 'type', 'contentType', + 'bytes', 'location', 'created', 'compressed', 'replacer', 'space', 'suffix', 'escape', + 'passThrough', 'redirect', 'temporary', 'permanent', 'rewritable', 'encoding', 'charset', + 'ttl', 'state', 'unstate', 'takeover'] }; @@ -42,22 +43,23 @@ exports = module.exports = internals.Response = class { this.variety = null; this.settings = { - encoding: 'utf8', charset: 'utf-8', // '-' required by IANA - ttl: null, - stringify: null, // JSON.stringify options + compressed: null, + encoding: 'utf8', + message: null, passThrough: true, - varyEtag: false, - message: null + stringify: null, // JSON.stringify options + ttl: null, + varyEtag: false }; this._events = null; this._payload = null; // Readable stream - this._error = null; // The boom object when created from an error (used for logging) - this._contentEncoding = null; // Set during transmit + this._error = options.error ?? null; // The boom object when created from an error (used for logging) this._contentType = null; // Used if no explicit content-type is set and type is known this._takeover = false; this._statusCode = false; // true when code() called + this._state = this._error ? 'prepare' : 'init'; // One of 'init', 'prepare', 'marshall', 'close' this._processors = { marshal: options.marshal, @@ -65,16 +67,14 @@ exports = module.exports = internals.Response = class { close: options.close }; - this.temporary = null; - this.permanent = null; - this.rewritable = null; - this._setSource(source, options.variety); } static wrap(result, request) { - if (result instanceof internals.Response) { + if (result instanceof request._core.Response || + typeof result === 'symbol') { + return result; } @@ -82,14 +82,14 @@ exports = module.exports = internals.Response = class { return Boom.boomify(result); } - return new internals.Response(result, request); + return new request._core.Response(result, request); } _setSource(source, variety) { // Method must not set any headers or other properties as source can change later - this.variety = variety || 'plain'; + this.variety = variety ?? 'plain'; if (source === null || source === undefined) { @@ -100,24 +100,24 @@ exports = module.exports = internals.Response = class { this.variety = 'buffer'; this._contentType = 'application/octet-stream'; } - else if (source instanceof Stream) { + else if (Streams.isStream(source)) { this.variety = 'stream'; + this._contentType = 'application/octet-stream'; } this.source = source; if (this.variety === 'plain' && - this.source !== null && - this.source !== '') { + this.source !== null) { - this._contentType = (this.source && typeof this.source === 'string' ? 'text/html' : 'application/json'); + this._contentType = typeof this.source === 'string' ? 'text/html' : 'application/json'; } } get events() { if (!this._events) { - this._events = new Podium(internals.events); + this._events = new Podium.Podium(internals.events); } return this._events; @@ -125,7 +125,7 @@ exports = module.exports = internals.Response = class { code(statusCode) { - Hoek.assert(Hoek.isInteger(statusCode), 'Status code must be an integer'); + Hoek.assert(Number.isSafeInteger(statusCode), 'Status code must be an integer'); this.statusCode = statusCode; this._statusCode = true; @@ -151,12 +151,12 @@ exports = module.exports = internals.Response = class { _header(key, value, options = {}) { - const append = options.append || false; + const append = options.append ?? false; const separator = options.separator || ','; const override = options.override !== false; const duplicate = options.duplicate !== false; - if ((!append && override) || + if (!append && override || !this.headers[key]) { this.headers[key] = value; @@ -169,8 +169,8 @@ exports = module.exports = internals.Response = class { const existing = this.headers[key]; if (!duplicate) { const values = existing.split(separator); - for (let i = 0; i < values.length; ++i) { - if (values[i] === value) { + for (const v of values) { + if (v === value) { return this; } } @@ -200,7 +200,7 @@ exports = module.exports = internals.Response = class { etag(tag, options) { - const entity = internals.Response.entity(tag, options); + const entity = this.request._core.Response.entity(tag, options); this._header('etag', entity.etag); this.settings.varyEtag = entity.vary; return this; @@ -212,12 +212,12 @@ exports = module.exports = internals.Response = class { return { etag: (options.weak ? 'W/' : '') + '"' + tag + '"', - vary: (options.vary !== false && !options.weak), // vary defaults to true + vary: options.vary !== false && !options.weak, // vary defaults to true modified: options.modified }; } - static unmodified(request, options) { + static unmodified(request, entity) { if (request.method !== 'get' && request.method !== 'head') { @@ -227,23 +227,31 @@ exports = module.exports = internals.Response = class { // Strong verifier - if (options.etag && + if (entity.etag && request.headers['if-none-match']) { const ifNoneMatch = request.headers['if-none-match'].split(/\s*,\s*/); - for (let i = 0; i < ifNoneMatch.length; ++i) { - const etag = ifNoneMatch[i]; - if (etag === options.etag) { + for (const etag of ifNoneMatch) { + + // Compare tags (https://tools.ietf.org/html/rfc7232#section-2.3.2) + + if (etag === entity.etag) { // Strong comparison return true; } - if (options.vary) { - const etagBase = options.etag.slice(0, -1); - const encoders = request._core.compression.encodings; - for (let j = 0; j < encoders.length; ++j) { - if (etag === etagBase + `-${encoders[j]}"`) { - return true; - } + if (!entity.vary) { + continue; + } + + if (etag === `W/${entity.etag}`) { // Weak comparison + return etag; + } + + const etagBase = entity.etag.slice(0, -1); + const encoders = request._core.compression.encodings; + for (const encoder of encoders) { + if (etag === etagBase + `-${encoder}"`) { + return true; } } } @@ -253,23 +261,26 @@ exports = module.exports = internals.Response = class { // Weak verifier - const ifModifiedSinceHeader = request.headers['if-modified-since']; - - if (ifModifiedSinceHeader && - options.modified) { + if (!entity.modified) { + return false; + } - const ifModifiedSince = internals.parseDate(ifModifiedSinceHeader); - const lastModified = internals.parseDate(options.modified); + const ifModifiedSinceHeader = request.headers['if-modified-since']; + if (!ifModifiedSinceHeader) { + return false; + } - if (ifModifiedSince && - lastModified && - ifModifiedSince >= lastModified) { + const ifModifiedSince = internals.parseDate(ifModifiedSinceHeader); + if (!ifModifiedSince) { + return false; + } - return true; - } + const lastModified = internals.parseDate(entity.modified); + if (!lastModified) { + return false; } - return false; + return ifModifiedSince >= lastModified; } type(type) { @@ -278,6 +289,30 @@ exports = module.exports = internals.Response = class { return this; } + get contentType() { + + let type = this.headers['content-type']; + if (type) { + type = type.trim(); + if (this.settings.charset && + type.match(/^(?:text\/)|(?:application\/(?:json)|(?:javascript))/) && + !type.match(/; *charset=/)) { + + const semi = type[type.length - 1] === ';'; + return type + (semi ? ' ' : '; ') + 'charset=' + this.settings.charset; + } + + return type; + } + + if (this._contentType) { + const charset = this.settings.charset && this._contentType !== 'application/octet-stream' ? '; charset=' + this.settings.charset : ''; + return this._contentType + charset; + } + + return null; + } + bytes(bytes) { this._header('content-length', bytes); @@ -292,44 +327,53 @@ exports = module.exports = internals.Response = class { created(location) { - Hoek.assert(this.request.method === 'post' || this.request.method === 'put', 'Cannot create resource on GET'); + Hoek.assert(this.request.method === 'post' || + this.request.method === 'put' || + this.request.method === 'patch', 'Cannot return 201 status codes for ' + this.request.method.toUpperCase()); this.statusCode = 201; this.location(location); return this; } + compressed(encoding) { + + Hoek.assert(encoding && typeof encoding === 'string', 'Invalid content-encoding'); + this.settings.compressed = encoding; + return this; + } + replacer(method) { - this.settings.stringify = this.settings.stringify || {}; + this.settings.stringify = this.settings.stringify ?? {}; this.settings.stringify.replacer = method; return this; } spaces(count) { - this.settings.stringify = this.settings.stringify || {}; + this.settings.stringify = this.settings.stringify ?? {}; this.settings.stringify.space = count; return this; } suffix(suffix) { - this.settings.stringify = this.settings.stringify || {}; + this.settings.stringify = this.settings.stringify ?? {}; this.settings.stringify.suffix = suffix; return this; } escape(escape) { - this.settings.stringify = this.settings.stringify || {}; + this.settings.stringify = this.settings.stringify ?? {}; this.settings.stringify.escape = escape; return this; } passThrough(enabled) { - this.settings.passThrough = (enabled !== false); // Defaults to true + this.settings.passThrough = enabled !== false; // Defaults to true return this; } @@ -337,25 +381,28 @@ exports = module.exports = internals.Response = class { this.statusCode = 302; this.location(location); - this.temporary = this._temporary; - this.permanent = this._permanent; - this.rewritable = this._rewritable; return this; } - _temporary(isTemporary) { + temporary(isTemporary) { + + Hoek.assert(this.headers.location, 'Cannot set redirection mode without first setting a location'); this._setTemporary(isTemporary !== false); // Defaults to true return this; } - _permanent(isPermanent) { + permanent(isPermanent) { + + Hoek.assert(this.headers.location, 'Cannot set redirection mode without first setting a location'); this._setTemporary(isPermanent === false); // Defaults to true return this; } - _rewritable(isRewritable) { + rewritable(isRewritable) { + + Hoek.assert(this.headers.location, 'Cannot set redirection mode without first setting a location'); this._setRewritable(isRewritable !== false); // Defaults to true return this; @@ -419,7 +466,7 @@ exports = module.exports = internals.Response = class { charset(charset) { - this.settings.charset = charset || null; + this.settings.charset = charset ?? null; return this; } @@ -449,6 +496,10 @@ exports = module.exports = internals.Response = class { _prepare() { + Hoek.assert(this._state === 'init'); + + this._state = 'prepare'; + this._passThrough(); if (!this._processors.prepare) { @@ -490,8 +541,7 @@ exports = module.exports = internals.Response = class { }); } - for (let i = 0; i < headerKeys.length; ++i) { - const key = headerKeys[i]; + for (const key of headerKeys) { const lower = key.toLowerCase(); if (!internals.hopByHop[lower] && !byHop[lower]) { @@ -501,23 +551,26 @@ exports = module.exports = internals.Response = class { } headerKeys = Object.keys(localHeaders); - for (let i = 0; i < headerKeys.length; ++i) { - const key = headerKeys[i]; + for (const key of headerKeys) { this.header(key, localHeaders[key], { append: key === 'set-cookie' }); } } } } - this.statusCode = this.statusCode || 200; + this.statusCode = this.statusCode ?? 200; } async _marshal() { - let source = this.source; + Hoek.assert(this._state === 'prepare'); + + this._state = 'marshall'; // Processor marshal + let source = this.source; + if (this._processors.marshal) { try { source = await this._processors.marshal(this); @@ -529,22 +582,14 @@ exports = module.exports = internals.Response = class { // Stream source - if (source instanceof Stream) { - if (typeof source._read !== 'function' || typeof source._readableState !== 'object') { - throw Boom.badImplementation('Stream must have a streams2 readable interface'); - } - - if (source._readableState.objectMode) { - throw Boom.badImplementation('Cannot reply with stream in object mode'); - } - + if (Streams.isStream(source)) { this._payload = source; return; } // Plain source (non string or null) - const jsonify = (this.variety === 'plain' && source !== null && typeof source !== 'string'); + const jsonify = this.variety === 'plain' && source !== null && typeof source !== 'string'; if (!jsonify && this.settings.stringify) { @@ -555,11 +600,11 @@ exports = module.exports = internals.Response = class { let payload = source; if (jsonify) { - const options = this.settings.stringify || {}; - const space = options.space || this.request.route.settings.json.space; - const replacer = options.replacer || this.request.route.settings.json.replacer; - const suffix = options.suffix || this.request.route.settings.json.suffix || ''; - const escape = this.request.route.settings.json.escape || false; + const options = this.settings.stringify ?? {}; + const space = options.space ?? this.request.route.settings.json.space; + const replacer = options.replacer ?? this.request.route.settings.json.replacer; + const suffix = options.suffix ?? this.request.route.settings.json.suffix ?? ''; + const escape = this.request.route.settings.json.escape; try { if (replacer || space) { @@ -591,10 +636,22 @@ exports = module.exports = internals.Response = class { return null; } - return (this._events.hasListeners('finish') || this._events.hasListeners('peek') ? new internals.Response.Peek(this._events) : null); + if (this._events.hasListeners('peek') || + this._events.hasListeners('finish')) { + + return new internals.Response.Peek(this._events); + } + + return null; } - _close(request) { + _close() { + + if (this._state === 'close') { + return; + } + + this._state = 'close'; if (this._processors.close) { try { @@ -602,40 +659,31 @@ exports = module.exports = internals.Response = class { } catch (err) { Bounce.rethrow(err, 'system'); - request._log(['response', 'cleanup', 'error'], err); + this.request._log(['response', 'cleanup', 'error'], err); } } const stream = this._payload || this.source; - if (stream instanceof Stream) { + if (Streams.isStream(stream)) { internals.Response.drain(stream); } } _isPayloadSupported() { - return (this.request.method !== 'head' && this.statusCode !== 304 && this.statusCode !== 204); + return this.request.method !== 'head' && this.statusCode !== 304 && this.statusCode !== 204; } static drain(stream) { - if (stream.unpipe) { - stream.unpipe(); - } - - if (stream.close) { - stream.close(); - } - else if (stream.destroy) { - stream.destroy(); - } - else { - Streams.drain(stream); - } + stream.destroy(); } }; +internals.Response.reserved = internals.reserved; + + internals.parseDate = function (string) { try { @@ -652,60 +700,33 @@ internals.Response.Payload = class extends Stream.Readable { super(); this._data = payload; - this._prefix = null; - this._suffix = null; - this._sizeOffset = 0; this._encoding = options.encoding; } _read(size) { - if (this._prefix) { - this.push(this._prefix, this._encoding); - } - if (this._data) { this.push(this._data, this._encoding); } - if (this._suffix) { - this.push(this._suffix, this._encoding); - } - this.push(null); } size() { if (!this._data) { - return this._sizeOffset; + return 0; } - return (Buffer.isBuffer(this._data) ? this._data.length : Buffer.byteLength(this._data, this._encoding)) + this._sizeOffset; - } - - jsonp(variable) { - - this._sizeOffset = this._sizeOffset + variable.length + 7; - this._prefix = '/**/' + variable + '('; // '/**/' prefix prevents CVE-2014-4671 security exploit - this._data = (this._data === null || Buffer.isBuffer(this._data)) ? this._data : this._data.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); - this._suffix = ');'; + return Buffer.isBuffer(this._data) ? this._data.length : Buffer.byteLength(this._data, this._encoding); } writeToStream(stream) { - if (this._prefix) { - stream.write(this._prefix, this._encoding); - } - if (this._data) { stream.write(this._data, this._encoding); } - if (this._suffix) { - stream.write(this._suffix, this._encoding); - } - stream.end(); } }; diff --git a/lib/route.js b/lib/route.js index a77f5cd40..c0ee00f9a 100755 --- a/lib/route.js +++ b/lib/route.js @@ -1,13 +1,12 @@ 'use strict'; -// Load modules +const Assert = require('assert'); -const Boom = require('boom'); -const Bounce = require('bounce'); -const Catbox = require('catbox'); -const Hoek = require('hoek'); -const Joi = require('joi'); -const Subtext = require('subtext'); +const Bounce = require('@hapi/bounce'); +const Catbox = require('@hapi/catbox'); +const Hoek = require('@hapi/hoek'); +const Subtext = require('@hapi/subtext'); +const Validate = require('@hapi/validate'); const Auth = require('./auth'); const Config = require('./config'); @@ -20,8 +19,6 @@ const Streams = require('./streams'); const Validation = require('./validation'); -// Declare internals - const internals = {}; @@ -34,39 +31,48 @@ exports = module.exports = internals.Route = class { // Routing information - const display = `${route.method} ${route.path}`; - Config.apply('route', route, display); + Config.apply('route', route, route.method, route.path); const method = route.method.toLowerCase(); - Hoek.assert(method !== 'head', 'Method name not allowed:', display); + Hoek.assert(method !== 'head', 'Cannot set HEAD route:', route.path); + + const path = realm.modifiers.route.prefix ? realm.modifiers.route.prefix + (route.path !== '/' ? route.path : '') : route.path; + Hoek.assert(path === '/' || path[path.length - 1] !== '/' || !core.settings.router.stripTrailingSlash, 'Path cannot end with a trailing slash when configured to strip:', route.method, route.path); - const path = (realm.modifiers.route.prefix ? realm.modifiers.route.prefix + (route.path !== '/' ? route.path : '') : route.path); - Hoek.assert(path === '/' || path[path.length - 1] !== '/' || !core.settings.router.stripTrailingSlash, 'Path cannot end with a trailing slash when configured to strip:', display); + const vhost = realm.modifiers.route.vhost ?? route.vhost; - const vhost = (realm.modifiers.route.vhost || route.vhost); + // Set identifying members (assert) + + this.method = method; + this.path = path; // Prepare configuration - let config = route.options || route.config || {}; + let config = route.options ?? route.config ?? {}; if (typeof config === 'function') { config = config.call(realm.settings.bind, server); } config = Config.enable(config); // Shallow clone + // Verify route level config (as opposed to the merged settings) + + this._assert(method !== 'get' || !config.payload, 'Cannot set payload settings on HEAD or GET request'); + this._assert(method !== 'get' || !config.validate?.payload, 'Cannot validate HEAD or GET request payload'); + // Rules - Hoek.assert(!route.rules || !config.rules, 'Route rules can only appear once:', display); // XOR - const rules = (route.rules || config.rules); + this._assert(!route.rules || !config.rules, 'Route rules can only appear once'); // XOR + const rules = route.rules ?? config.rules; const rulesConfig = internals.rules(rules, { method, path, vhost }, server); delete config.rules; // Handler - Hoek.assert(route.handler || config.handler, 'Missing or undefined handler:', display); - Hoek.assert(!!route.handler ^ !!config.handler, 'Handler must only appear once:', display); // XOR + this._assert(route.handler || config.handler, 'Missing or undefined handler'); + this._assert(!!route.handler ^ !!config.handler, 'Handler must only appear once'); // XOR - const handler = Config.apply('handler', route.handler || config.handler); + const handler = Config.apply('handler', route.handler ?? config.handler); delete config.handler; const handlerDefaults = Handler.defaults(method, handler, core); @@ -74,24 +80,17 @@ exports = module.exports = internals.Route = class { // Apply settings in order: server <- handler <- realm <- route const settings = internals.config([core.settings.routes, handlerDefaults, realm.settings, rulesConfig, config]); - this.settings = Config.apply('routeConfig', settings, display); - - // Validate timeouts + this.settings = Config.apply('routeConfig', settings, method, path); - const socketTimeout = (this.settings.timeout.socket === undefined ? 2 * 60 * 1000 : this.settings.timeout.socket); - Hoek.assert(!this.settings.timeout.server || !socketTimeout || this.settings.timeout.server < socketTimeout, 'Server timeout must be shorter than socket timeout:', display); - Hoek.assert(!this.settings.payload.timeout || !socketTimeout || this.settings.payload.timeout < socketTimeout, 'Payload timeout must be shorter than socket timeout:', display); // Route members this._core = core; - this.path = path; - this.method = method; this.realm = realm; this.settings.vhost = vhost; - this.settings.plugins = this.settings.plugins || {}; // Route-specific plugins settings, namespaced using plugin name - this.settings.app = this.settings.app || {}; // Route-specific application settings + this.settings.plugins = this.settings.plugins ?? {}; // Route-specific plugins settings, namespaced using plugin name + this.settings.app = this.settings.app ?? {}; // Route-specific application settings // Path parsing @@ -114,46 +113,7 @@ exports = module.exports = internals.Route = class { // Validation - const validation = this.settings.validate; - if (this.method === 'get') { - - // Assert on config, not on merged settings - - Hoek.assert(!config.payload, 'Cannot set payload settings on HEAD or GET request:', display); - Hoek.assert(!config.validate || !config.validate.payload, 'Cannot validate HEAD or GET requests:', display); - - validation.payload = null; - } - - Hoek.assert(!validation.params || this.params.length, 'Cannot set path parameters validations without path parameters:', display); - - ['headers', 'params', 'query', 'payload'].forEach((type) => { - - validation[type] = Validation.compile(validation[type]); - }); - - if (this.settings.response.schema !== undefined || - this.settings.response.status) { - - this.settings.response._validate = true; - - const rule = this.settings.response.schema; - this.settings.response.status = this.settings.response.status || {}; - const statuses = Object.keys(this.settings.response.status); - - if (rule === true && - !statuses.length) { - - this.settings.response._validate = false; - } - else { - this.settings.response.schema = Validation.compile(rule); - for (let i = 0; i < statuses.length; ++i) { - const code = statuses[i]; - this.settings.response.status[code] = Validation.compile(this.settings.response.status[code]); - } - } - } + this._setupValidation(); // Payload parsing @@ -161,15 +121,15 @@ exports = module.exports = internals.Route = class { this.settings.payload = null; } else { - this.settings.payload.decoders = this._core.compression._decoders; // Reference the shared object to keep up to date + this.settings.payload.decoders = this._core.compression.decoders; // Reference the shared object to keep up to date } - Hoek.assert(!this.settings.validate.payload || this.settings.payload.parse, 'Route payload must be set to \'parse\' when payload validation enabled:', display); - Hoek.assert(!this.settings.jsonp || typeof this.settings.jsonp === 'string', 'Bad route JSONP parameter name:', display); + this._assert(!this.settings.validate.payload || this.settings.payload.parse, 'Route payload must be set to \'parse\' when payload validation enabled'); + this._assert(!this.settings.validate.state || this.settings.state.parse, 'Route state must be set to \'parse\' when state validation enabled'); // Authentication configuration - this.settings.auth = (this._special ? false : this._core.auth._setupRoute(this.settings.auth, path)); + this.settings.auth = this._special ? false : this._core.auth._setupRoute(this.settings.auth, path); // Cache @@ -177,7 +137,7 @@ exports = module.exports = internals.Route = class { typeof this.settings.cache === 'object' && (this.settings.cache.expiresIn || this.settings.cache.expiresAt)) { - this.settings.cache._statuses = Hoek.mapToObject(this.settings.cache.statuses); + this.settings.cache._statuses = new Set(this.settings.cache.statuses); this._cache = new Catbox.Policy({ expiresIn: this.settings.cache.expiresIn, expiresAt: this.settings.cache.expiresAt }); } @@ -197,7 +157,8 @@ exports = module.exports = internals.Route = class { // Route lifecycle this._extensions = { - onPreResponse: Ext.combine(this, 'onPreResponse') + onPreResponse: Ext.combine(this, 'onPreResponse'), + onPostResponse: Ext.combine(this, 'onPostResponse') }; if (this._special) { @@ -215,6 +176,42 @@ exports = module.exports = internals.Route = class { this.rebuild(); } + _setupValidation() { + + const validation = this.settings.validate; + if (this.method === 'get') { + validation.payload = null; + } + + this._assert(!validation.params || this.params.length, 'Cannot set path parameters validations without path parameters'); + + for (const type of ['headers', 'params', 'query', 'payload', 'state']) { + validation[type] = Validation.compile(validation[type], this.settings.validate.validator, this.realm, this._core); + } + + if (this.settings.response.schema !== undefined || + this.settings.response.status) { + + this.settings.response._validate = true; + + const rule = this.settings.response.schema; + this.settings.response.status = this.settings.response.status ?? {}; + const statuses = Object.keys(this.settings.response.status); + + if (rule === true && + !statuses.length) { + + this.settings.response._validate = false; + } + else { + this.settings.response.schema = Validation.compile(rule, this.settings.validate.validator, this.realm, this._core); + for (const code of statuses) { + this.settings.response.status[code] = Validation.compile(this.settings.response.status[code], this.settings.validate.validator, this.realm, this._core); + } + } + } + } + rebuild(event) { if (event) { @@ -222,8 +219,8 @@ exports = module.exports = internals.Route = class { } if (this._special) { - this._postCycle = (this._extensions.onPreResponse.nodes ? [this._extensions.onPreResponse] : []); - this._marshalCycle = [Headers.type, Headers.content]; + this._postCycle = this._extensions.onPreResponse.nodes ? [this._extensions.onPreResponse] : []; + this._buildMarshalCycle(); return; } @@ -233,10 +230,6 @@ exports = module.exports = internals.Route = class { // 'onRequest' - if (this.settings.jsonp) { - this._cycle.push(internals.parseJSONP); - } - if (this.settings.state.parse) { this._cycle.push(internals.state); } @@ -279,10 +272,6 @@ exports = module.exports = internals.Route = class { this._cycle.push(Validation.params); } - if (this.settings.jsonp) { - this._cycle.push(internals.cleanupJSONP); - } - if (this.settings.validate.query) { this._cycle.push(Validation.query); } @@ -291,6 +280,10 @@ exports = module.exports = internals.Route = class { this._cycle.push(Validation.payload); } + if (this.settings.validate.state) { + this._cycle.push(Validation.state); + } + if (this._extensions.onPreHandler.nodes) { this._cycle.push(this._extensions.onPreHandler); } @@ -313,6 +306,13 @@ exports = module.exports = internals.Route = class { this._postCycle.push(this._extensions.onPreResponse); } + this._buildMarshalCycle(); + + // onPostResponse + } + + _buildMarshalCycle() { + this._marshalCycle = [Headers.type]; if (this.settings.cors) { @@ -339,47 +339,31 @@ exports = module.exports = internals.Route = class { this._marshalCycle.push(Auth.response); // Must be last in case requires access to headers } } -}; - - -internals.state = async function (request) { - - request.state = {}; - - const req = request.raw.req; - const cookies = req.headers.cookie; - if (!cookies) { - return; - } - - try { - var result = await request._core.states.parse(cookies); - } - catch (err) { - Bounce.rethrow(err, 'system'); - var parseError = err; - } - - const { states, failed = [] } = result || parseError; - request.state = states || {}; - // Clear cookies + _assert(condition, message) { - for (let i = 0; i < failed.length; ++i) { - const item = failed[i]; + if (condition) { + return; + } - if (item.settings.clearInvalid) { - request._clearState(item.name); + if (this.method[0] !== '_') { + message = `${message}: ${this.method.toUpperCase()} ${this.path}`; } - } - if (!parseError) { - return; + throw new Assert.AssertionError({ + message, + actual: false, + expected: true, + operator: '==', + stackStartFunction: this._assert + }); } +}; - parseError.header = cookies; - return request._core.toolkit.failAction(request, request.route.settings.state.failAction, parseError, { tags: ['state', 'error'] }); +internals.state = function (request) { + + return request._parseCookies(); }; @@ -391,27 +375,26 @@ internals.payload = async function (request) { return; } + if (request.payload !== undefined) { + return internals.drain(request); + } + if (request._expectContinue) { + request._expectContinue = false; request.raw.res.writeContinue(); } try { const { payload, mime } = await Subtext.parse(request.raw.req, request._tap(), request.route.settings.payload); - request._isPayloadPending = !!(payload && payload._readableState); + request._isPayloadPending = !!payload?._readableState; request.mime = mime; request.payload = payload; } catch (err) { Bounce.rethrow(err, 'system'); - if (request._isPayloadPending) { - await internals.drain(request); - request._isPayloadPending = false; - } - else { - request._isPayloadPending = true; - } + await internals.drain(request); request.mime = err.mime; request.payload = null; @@ -425,31 +408,14 @@ internals.drain = async function (request) { // Flush out any pending request payload not consumed due to errors - await Streams.drain(request.raw.req); - request._isPayloadPending = false; -}; - - -internals.jsonpRegex = /^[\w\$\[\]\.]+$/; - - -internals.parseJSONP = function (request) { - - const jsonp = request.query[request.route.settings.jsonp]; - if (jsonp) { - if (internals.jsonpRegex.test(jsonp) === false) { - throw Boom.badRequest('Invalid JSONP parameter value'); - } - - request.jsonp = jsonp; + if (request._expectContinue) { + request._isPayloadPending = false; // If we don't continue, client should not send a payload + request._expectContinue = false; } -}; - -internals.cleanupJSONP = function (request) { - - if (request.jsonp) { - delete request.query[request.route.settings.jsonp]; + if (request._isPayloadPending) { + await Streams.drain(request.raw.req); + request._isPayloadPending = false; } }; @@ -461,9 +427,8 @@ internals.config = function (chain) { } let config = chain[0]; - - for (let i = 1; i < chain.length; ++i) { - config = Hoek.applyToDefaultsWithShallow(config, chain[i], ['bind', 'validate.headers', 'validate.payload', 'validate.params', 'validate.query']); + for (const item of chain) { + config = Hoek.applyToDefaults(config, item, { shallow: ['bind', 'validate.headers', 'validate.payload', 'validate.params', 'validate.query', 'validate.state'] }); } return config; @@ -477,7 +442,7 @@ internals.rules = function (rules, info, server) { let realm = server.realm; while (realm) { if (realm._rules) { - const source = (!realm._rules.settings.validate ? rules : Joi.attempt(rules, realm._rules.settings.validate.schema, realm._rules.settings.validate.options)); + const source = !realm._rules.settings.validate ? rules : Validate.attempt(rules, realm._rules.settings.validate.schema, realm._rules.settings.validate.options); const config = realm._rules.processor(source, info); if (config) { configs.unshift(config); diff --git a/lib/security.js b/lib/security.js index 32ae1cf6c..de8776114 100755 --- a/lib/security.js +++ b/lib/security.js @@ -1,10 +1,5 @@ 'use strict'; -// Load modules - - -// Declare internals - const internals = {}; @@ -23,10 +18,11 @@ exports.route = function (settings) { security._hsts = 'max-age=' + security.hsts; } else { - security._hsts = 'max-age=' + (security.hsts.maxAge || 15768000); + security._hsts = 'max-age=' + (security.hsts.maxAge ?? 15768000); if (security.hsts.includeSubdomains || security.hsts.includeSubDomains) { security._hsts = security._hsts + '; includeSubDomains'; } + if (security.hsts.preload) { security._hsts = security._hsts + '; preload'; } @@ -57,9 +53,8 @@ exports.route = function (settings) { }; -exports.headers = function (request) { +exports.headers = function (response) { - const response = request.response; const security = response.request.route.settings.security; if (security._hsts) { @@ -70,9 +65,12 @@ exports.headers = function (request) { response._header('x-frame-options', security._xframe, { override: false }); } - if (security.xss) { + if (security.xss === 'enabled') { response._header('x-xss-protection', '1; mode=block', { override: false }); } + else if (security.xss === 'disabled') { + response._header('x-xss-protection', '0', { override: false }); + } if (security.noOpen) { response._header('x-download-options', 'noopen', { override: false }); @@ -81,4 +79,8 @@ exports.headers = function (request) { if (security.noSniff) { response._header('x-content-type-options', 'nosniff', { override: false }); } + + if (security.referrer !== false) { + response._header('referrer-policy', security.referrer, { override: false }); + } }; diff --git a/lib/server.js b/lib/server.js index 33d836f14..b5c7e10f3 100755 --- a/lib/server.js +++ b/lib/server.js @@ -1,22 +1,19 @@ 'use strict'; -// Load modules - -const Hoek = require('hoek'); -const Joi = require('joi'); -const Shot = require('shot'); +const Hoek = require('@hapi/hoek'); +const Shot = require('@hapi/shot'); +const Teamwork = require('@hapi/teamwork'); const Config = require('./config'); const Core = require('./core'); const Cors = require('./cors'); const Ext = require('./ext'); const Package = require('../package.json'); -const Request = require('./request'); const Route = require('./route'); +const Toolkit = require('./toolkit'); +const Validation = require('./validation'); -// Declare internals - const internals = {}; @@ -36,9 +33,8 @@ internals.Server = class { // Public interface this.app = core.app; - this.auth = Object.create(this._core.auth); - this.auth.strategy = this.auth._strategy.bind(this.auth, this); - this.decorations = core.decorations; + this.auth = core.auth.public(this); + this.decorations = core.decorations.public; this.cache = internals.cache(this); this.events = core.events; this.info = core.info; @@ -60,12 +56,13 @@ internals.Server = class { onPostAuth: new Ext('onPostAuth', core), onPreHandler: new Ext('onPreHandler', core), onPostHandler: new Ext('onPostHandler', core), - onPreResponse: new Ext('onPreResponse', core) + onPreResponse: new Ext('onPreResponse', core), + onPostResponse: new Ext('onPostResponse', core) }, modifiers: { route: {} }, - parent: (parent ? parent.realm : null), + parent: parent ? parent.realm : null, plugin: name, pluginOptions: {}, plugins: {}, @@ -75,14 +72,14 @@ internals.Server = class { files: { relativeTo: undefined } - } + }, + validator: null }; // Decorations - for (let i = 0; i < core.decorations.server.length; ++i) { - const method = core.decorations.server[i]; - this[method] = core._decorations.server[method]; + for (const [property, method] of core.decorations.server.entries()) { + this[property] = method; } core.registerServer(this); @@ -99,6 +96,14 @@ internals.Server = class { this.realm.settings.bind = context; } + control(server) { + + Hoek.assert(server instanceof internals.Server, 'Can only control Server objects'); + + this._core.controlled = this._core.controlled ?? []; + this._core.controlled.push(server); + } + decoder(encoding, decoder) { return this._core.compression.addDecoder(encoding, decoder); @@ -106,56 +111,71 @@ internals.Server = class { decorate(type, property, method, options = {}) { - Hoek.assert(this._core.decorations[type], 'Unknown decoration type:', type); + Hoek.assert(this._core.decorations.public[type], 'Unknown decoration type:', type); Hoek.assert(property, 'Missing decoration property name'); - Hoek.assert(typeof property === 'string', 'Decoration property must be a string'); - Hoek.assert(property[0] !== '_', 'Property name cannot begin with an underscore:', property); + Hoek.assert(typeof property === 'string' || typeof property === 'symbol', 'Decoration property must be a string or a symbol'); + + const propertyName = property.toString(); + Hoek.assert(propertyName[0] !== '_', 'Property name cannot begin with an underscore:', propertyName); - const existing = this._core._decorations[type][property]; + const existing = this._core.decorations[type].get(property); if (options.extend) { - Hoek.assert(type !== 'handler', 'Cannot extent handler decoration:', property); - Hoek.assert(existing, `Cannot extend missing ${type} decoration: ${property}`); - Hoek.assert(typeof method === 'function', `Extended ${type} decoration method must be a function: ${property}`); + Hoek.assert(type !== 'handler', 'Cannot extend handler decoration:', propertyName); + Hoek.assert(existing, `Cannot extend missing ${type} decoration: ${propertyName}`); + Hoek.assert(typeof method === 'function', `Extended ${type} decoration method must be a function: ${propertyName}`); method = method(existing); } else { - Hoek.assert(existing === undefined, `${type[0].toUpperCase() + type.slice(1)} decoration already defined: ${property}`); + Hoek.assert(existing === undefined, `${type[0].toUpperCase() + type.slice(1)} decoration already defined: ${propertyName}`); } if (type === 'handler') { // Handler - Hoek.assert(typeof method === 'function', 'Handler must be a function:', property); + Hoek.assert(typeof method === 'function', 'Handler must be a function:', propertyName); Hoek.assert(!method.defaults || typeof method.defaults === 'object' || typeof method.defaults === 'function', 'Handler defaults property must be an object or function'); - Hoek.assert(!options.extend, 'Cannot extend handler decoration:', property); + Hoek.assert(!options.extend, 'Cannot extend handler decoration:', propertyName); } else if (type === 'request') { // Request - Hoek.assert(Request.reserved.indexOf(property) === -1, 'Cannot override built-in request interface decoration:', property); + Hoek.assert(!this._core.Request.reserved.includes(property), 'Cannot override the built-in request interface decoration:', propertyName); if (options.apply) { - this._core._decorations.requestApply = this._core._decorations.requestApply || {}; - this._core._decorations.requestApply[property] = method; + this._core.decorations.requestApply = this._core.decorations.requestApply ?? new Map(); + this._core.decorations.requestApply.set(property, method); } else { - Request.prototype[property] = method; + this._core.Request.prototype[property] = method; } } + else if (type === 'response') { + + // Response + + Hoek.assert(!this._core.Response.reserved.includes(property), 'Cannot override the built-in response interface decoration:', propertyName); + this._core.Response.prototype[property] = method; + } else if (type === 'toolkit') { // Toolkit - Hoek.assert(this._core.toolkit.reserved.indexOf(property) === -1, 'Cannot override built-in toolkit decoration:', property); + Hoek.assert(!Toolkit.reserved.includes(property), 'Cannot override the built-in toolkit decoration:', propertyName); + this._core.toolkit.decorate(property, method); } else { // Server - Hoek.assert(Object.getOwnPropertyNames(internals.Server.prototype).indexOf(property) === -1, 'Cannot override the built-in server interface method:', property); + if (typeof property === 'string') { + Hoek.assert(!Object.getOwnPropertyNames(internals.Server.prototype).includes(property), 'Cannot override the built-in server interface method:', propertyName); + } + else { + Hoek.assert(!Object.getOwnPropertySymbols(internals.Server.prototype).includes(property), 'Cannot override the built-in server interface method:', propertyName); + } this._core.instances.forEach((server) => { @@ -163,8 +183,8 @@ internals.Server = class { }); } - this._core._decorations[type][property] = method; - this._core.decorations[type].push(property); + this._core.decorations[type].set(property, method); + this._core.decorations.public[type].push(property); } dependency(dependencies, after) { @@ -172,11 +192,24 @@ internals.Server = class { Hoek.assert(this.realm.plugin, 'Cannot call dependency() outside of a plugin'); Hoek.assert(!after || typeof after === 'function', 'Invalid after method'); - dependencies = [].concat(dependencies); + // Normalize to { plugin: version } + + if (typeof dependencies === 'string') { + dependencies = { [dependencies]: '*' }; + } + else if (Array.isArray(dependencies)) { + const map = {}; + for (const dependency of dependencies) { + map[dependency] = '*'; + } + + dependencies = map; + } + this._core.dependencies.push({ plugin: this.realm.plugin, deps: dependencies }); if (after) { - this.ext('onPreStart', after, { after: dependencies }); + this.ext('onPreStart', after, { after: Object.keys(dependencies) }); } } @@ -190,12 +223,21 @@ internals.Server = class { this._core.events.registerEvent(event); } - expose(key, value) { + expose(key, value, options = {}) { Hoek.assert(this.realm.plugin, 'Cannot call expose() outside of a plugin'); - const plugin = this.realm.plugin; - this._core.plugins[plugin] = this._core.plugins[plugin] || {}; + let plugin = this.realm.plugin; + if (plugin[0] === '@' && + options.scope !== true) { + + plugin = plugin.replace(/^@([^/]+)\//, ($0, $1) => { + + return !options.scope ? '' : `${$1}__`; + }); + } + + this._core.plugins[plugin] = this._core.plugins[plugin] ?? {}; if (typeof key === 'string') { this._core.plugins[plugin][key] = value; @@ -207,20 +249,33 @@ internals.Server = class { ext(events, method, options) { // (event, method, options) -OR- (events) + let promise; if (typeof events === 'string') { + if (!method) { + const team = new Teamwork.Team(); + method = (request, h) => { + + team.attend(request); + return h.continue; + }; + + promise = team.work; + } + events = { type: events, method, options }; } events = Config.apply('exts', events); - - for (let i = 0; i < events.length; ++i) { - this._ext(events[i]); + for (const event of events) { + this._ext(event); } + + return promise; } _ext(event) { - event = Hoek.shallow(event); + event = Object.assign({}, event); // Shallow cloned event.realm = this.realm; const type = event.type; @@ -256,35 +311,50 @@ internals.Server = class { } if (!settings.authority || - settings.credentials || + settings.auth || settings.app || settings.plugins || settings.allowInternals !== undefined) { // Can be false - settings = Hoek.shallow(settings); // options can be reused - delete settings.credentials; - delete settings.artifacts; // Cannot appear without credentials + settings = Object.assign({}, settings); // options can be reused (shallow cloned) + delete settings.auth; delete settings.app; delete settings.plugins; delete settings.allowInternals; - settings.authority = settings.authority || (this._core.info.host + ':' + this._core.info.port); + settings.authority = settings.authority ?? this._core.info.host + ':' + this._core.info.port; + } + + Hoek.assert(!options.credentials, 'options.credentials no longer supported (use options.auth)'); + + if (options.auth) { + Hoek.assert(typeof options.auth === 'object', 'options.auth must be an object'); + Hoek.assert(options.auth.credentials, 'options.auth.credentials is missing'); + Hoek.assert(options.auth.strategy, 'options.auth.strategy is missing'); } const needle = this._core._dispatch({ - credentials: options.credentials, - artifacts: options.artifacts, + auth: options.auth, allowInternals: options.allowInternals, app: options.app, - plugins: options.plugins + plugins: options.plugins, + isInjected: true }); const res = await Shot.inject(needle, settings); const custom = res.raw.res[Config.symbol]; if (custom) { - res.result = custom.result; - res.request = custom.request; delete res.raw.res[Config.symbol]; + + res.request = custom.request; + + if (custom.error) { + throw custom.error; + } + + if (custom.result !== undefined) { + res.result = custom.result; + } } if (res.result === undefined) { @@ -303,7 +373,7 @@ internals.Server = class { Hoek.assert(id && typeof id === 'string', 'Invalid route id:', id); - const record = this._core.router.ids[id]; + const record = this._core.router.ids.get(id); if (!record) { return null; } @@ -343,10 +413,10 @@ internals.Server = class { this.realm.modifiers.route.vhost) { options = Hoek.clone(options); - options.routes = options.routes || {}; + options.routes = options.routes ?? {}; - options.routes.prefix = (this.realm.modifiers.route.prefix || '') + (options.routes.prefix || '') || undefined; - options.routes.vhost = this.realm.modifiers.route.vhost || options.routes.vhost; + options.routes.prefix = (this.realm.modifiers.route.prefix ?? '') + (options.routes.prefix ?? '') || undefined; + options.routes.vhost = this.realm.modifiers.route.vhost ?? options.routes.vhost; } options = Config.apply('register', options); @@ -355,8 +425,7 @@ internals.Server = class { try { const items = [].concat(plugins); - for (let i = 0; i < items.length; ++i) { - let item = items[i]; + for (let item of items) { /* { register, ...attributes } @@ -378,17 +447,23 @@ internals.Server = class { }; } else if (typeof item === 'function') { - item = Hoek.shallow(item); + item = Object.assign({}, item); // Shallow cloned } item = Config.apply('plugin', item); - const name = item.plugin.name || item.plugin.pkg.name; + const name = item.plugin.name ?? item.plugin.pkg.name; const clone = this._clone(name); - clone.realm.modifiers.route.prefix = item.routes.prefix || options.routes.prefix; - clone.realm.modifiers.route.vhost = item.routes.vhost || options.routes.vhost; - clone.realm.pluginOptions = item.options || {}; + clone.realm.modifiers.route.prefix = item.routes.prefix ?? options.routes.prefix; + clone.realm.modifiers.route.vhost = item.routes.vhost ?? options.routes.vhost; + clone.realm.pluginOptions = item.options ?? {}; + + // Validate requirements + + const requirements = item.plugin.requirements; + Hoek.assert(!requirements.node || Config.versionMatch(process.version, requirements.node), 'Plugin', name, 'requires node version', requirements.node, 'but found', process.version); + Hoek.assert(!requirements.hapi || Config.versionMatch(this.version, requirements.hapi), 'Plugin', name, 'requires hapi version', requirements.hapi, 'but found', this.version); // Protect against multiple registrations @@ -404,7 +479,7 @@ internals.Server = class { } else { this._core.registrations[name] = { - version: item.plugin.version || item.plugin.pkg.version, + version: item.plugin.version ?? item.plugin.pkg.version, name, options: item.options }; @@ -416,15 +491,14 @@ internals.Server = class { // Register - await item.plugin.register(clone, item.options || {}); + await item.plugin.register(clone, item.options ?? {}); } } - catch (err) { - throw err; - } finally { --this._core.registring; } + + return this; } route(options) { @@ -432,14 +506,10 @@ internals.Server = class { Hoek.assert(typeof options === 'object', 'Invalid route options'); options = [].concat(options); - for (let i = 0; i < options.length; ++i) { - const config = options[i]; - + for (const config of options) { if (Array.isArray(config.method)) { - for (let j = 0; j < config.method.length; ++j) { - const method = config.method[j]; - - const settings = Hoek.shallow(config); + for (const method of config.method) { + const settings = Object.assign({}, config); // Shallow cloned settings.method = method; this._addRoute(settings, this); } @@ -453,10 +523,9 @@ internals.Server = class { _addRoute(config, server) { const route = new Route(config, server); // Do no use config beyond this point, use route members - const vhosts = [].concat(route.settings.vhost || '*'); + const vhosts = [].concat(route.settings.vhost ?? '*'); - for (let i = 0; i < vhosts.length; ++i) { - const vhost = vhosts[i]; + for (const vhost of vhosts) { const record = this._core.router.add({ method: route.method, path: route.path, vhost, analysis: route._analysis, id: route.settings.id }, route); route.fingerprint = record.fingerprint; route.params = record.params; @@ -473,7 +542,7 @@ internals.Server = class { const settings = Config.apply('rules', options); if (settings.validate) { const schema = settings.validate.schema; - settings.validate.schema = Joi.compile(schema); + settings.validate.schema = Validation.compile(schema, null, this.realm, this._core); } this.realm._rules = { processor, settings }; @@ -489,6 +558,13 @@ internals.Server = class { return this._core.router.table(host); } + validator(validator) { + + Hoek.assert(!this.realm.validator, 'Validator already set'); + + this.realm.validator = Validation.validator(validator); + } + start() { return this._core._start(); @@ -519,7 +595,7 @@ internals.cache = (plugin) => { // Start cache - if (['initialized', 'starting', 'started'].indexOf(plugin._core.phase) !== -1) { + if (['initialized', 'starting', 'started'].includes(plugin._core.phase)) { await Promise.all(clients.map((client) => client.start())); } }; diff --git a/lib/streams.js b/lib/streams.js index aa7540a60..9316b9e97 100755 --- a/lib/streams.js +++ b/lib/streams.js @@ -1,25 +1,45 @@ 'use strict'; -// Load modules +const Stream = require('stream'); -const Teamwork = require('teamwork'); - - -// Declare internals +const Boom = require('@hapi/boom'); +const Teamwork = require('@hapi/teamwork'); const internals = { team: Symbol('team') }; +exports.isStream = function (stream) { + + const isReadableStream = stream instanceof Stream.Readable; + + if (!isReadableStream && + typeof stream?.pipe === 'function') { + throw Boom.badImplementation('Cannot reply with a stream-like object that is not an instance of Stream.Readable'); + } + + if (!isReadableStream) { + return false; + } + + if (stream.readableObjectMode) { + throw Boom.badImplementation('Cannot reply with stream in object mode'); + } + + return true; +}; + + exports.drain = function (stream) { - const team = new Teamwork(); + const team = new Teamwork.Team(); stream[internals.team] = team; stream.on('readable', internals.read); stream.on('error', internals.end); stream.on('end', internals.end); + stream.on('close', internals.end); return team.work; }; @@ -27,7 +47,7 @@ exports.drain = function (stream) { internals.read = function () { - this.read(); + while (this.read()) { } }; @@ -36,6 +56,7 @@ internals.end = function () { this.removeListener('readable', internals.read); this.removeListener('error', internals.end); this.removeListener('end', internals.end); + this.removeListener('close', internals.end); this[internals.team].attend(); }; diff --git a/lib/toolkit.js b/lib/toolkit.js index 3a29cf0a6..6fdd76df0 100755 --- a/lib/toolkit.js +++ b/lib/toolkit.js @@ -1,38 +1,63 @@ 'use strict'; -// Load modules - -const Boom = require('boom'); -const Bounce = require('bounce'); -const Hoek = require('hoek'); - -const Response = require('./response'); - - -// Declare internals - -const internals = { - reserved: ['abandon', 'authenticated', 'close', 'context', 'continue', 'entity', 'redirect', 'realm', 'request', 'response', 'state', 'unauthenticated', 'unstate'] +const Boom = require('@hapi/boom'); +const Bounce = require('@hapi/bounce'); +const Hoek = require('@hapi/hoek'); + + +const internals = {}; + + +exports.reserved = [ + 'abandon', + 'authenticated', + 'close', + 'context', + 'continue', + 'entity', + 'redirect', + 'realm', + 'request', + 'response', + 'state', + 'unauthenticated', + 'unstate' +]; + + +exports.symbols = { + abandon: Symbol('abandon'), + close: Symbol('close'), + continue: Symbol('continue') }; -exports = module.exports = internals.Manager = class { +exports.Manager = class { constructor() { - this.abandon = Symbol('abandon'); - this.close = Symbol('close'); - this.continue = Symbol('continue'); - this.reserved = internals.reserved; + this._toolkit = internals.toolkit(); } async execute(method, request, options) { - const h = new internals.Toolkit(request, this, options); - const bind = options.bind || null; + const h = new this._toolkit(request, options); + const bind = options.bind ?? null; try { - var response = await (options.args ? method.call(bind, request, h, ...options.args) : method.call(bind, request, h)); + let operation; + + if (bind) { + operation = method.call(bind, request, h); + } + else if (options.args) { + operation = method(request, h, ...options.args); + } + else { + operation = method(request, h); + } + + var response = await exports.timed(operation, options); } catch (err) { if (Bounce.isSystem(err)) { @@ -48,12 +73,16 @@ exports = module.exports = internals.Manager = class { // Process response + if (options.ignoreResponse) { + return response; + } + if (response === undefined) { response = Boom.badImplementation(`${method.name} method did not return a value, a promise, or throw an error`); } if (options.continue && - response === this.continue) { + response === exports.symbols.continue) { if (options.continue === 'undefined') { return; @@ -71,16 +100,21 @@ exports = module.exports = internals.Manager = class { } if (typeof response !== 'symbol') { - response = Response.wrap(response, request); - if (!response.isBoom) { - response = await response._prepare(); + response = request._core.Response.wrap(response, request); + if (!response.isBoom && response._state === 'init') { + await response._prepare(); } } return response; } - failAction(request, failAction, err, options) { + decorate(name, method) { + + this._toolkit.prototype[name] = method; + } + + async failAction(request, failAction, err, options) { const retain = options.retain ? err : undefined; if (failAction === 'ignore') { @@ -96,8 +130,28 @@ exports = module.exports = internals.Manager = class { throw err; } - return this.execute(failAction, request, { realm: request.route.realm, args: [options.details || err] }); + return await this.execute(failAction, request, { realm: request.route.realm, args: [options.details ?? err] }); + } +}; + + +exports.timed = async function (method, options) { + + if (!options.timeout) { + return method; } + + const timer = new Promise((resolve, reject) => { + + const handler = () => { + + reject(Boom.internal(`${options.name} timed out`)); + }; + + setTimeout(handler, options.timeout); + }); + + return await Promise.race([timer, method]); }; @@ -118,80 +172,78 @@ exports = module.exports = internals.Manager = class { }; */ -internals.Toolkit = class { +internals.toolkit = function () { - constructor(request, manager, options) { + const Toolkit = class { - this.abandon = manager.abandon; - this.close = manager.close; - this.continue = manager.continue; - this.context = options.bind; - this.realm = options.realm; - this.request = request; + constructor(request, options) { - if (options.auth) { - this.authenticated = internals.authenticated; - this.unauthenticated = internals.unauthenticated; - } + this.context = options.bind; + this.realm = options.realm; + this.request = request; - for (let i = 0; i < request._core.decorations.toolkit.length; ++i) { - const method = request._core.decorations.toolkit[i]; - this[method] = request._core._decorations.toolkit[method]; + this._auth = options.auth; } - } - response(result) { + response(result) { - Hoek.assert(!result || typeof result !== 'object' || typeof result.then !== 'function', 'Cannot wrap a promise'); - Hoek.assert(result instanceof Error === false, 'Cannot wrap an error'); - Hoek.assert(typeof result !== 'symbol', 'Cannot wrap a symbol'); + Hoek.assert(!result || typeof result !== 'object' || typeof result.then !== 'function', 'Cannot wrap a promise'); + Hoek.assert(result instanceof Error === false, 'Cannot wrap an error'); + Hoek.assert(typeof result !== 'symbol', 'Cannot wrap a symbol'); - return Response.wrap(result, this.request); - } + return this.request._core.Response.wrap(result, this.request); + } - redirect(location) { + redirect(location) { - return this.response('').redirect(location); - } + return this.response('').redirect(location); + } - entity(options) { + entity(options) { - Hoek.assert(options, 'Entity method missing required options'); - Hoek.assert(options.etag || options.modified, 'Entity methods missing require options key'); + Hoek.assert(options, 'Entity method missing required options'); + Hoek.assert(options.etag || options.modified, 'Entity methods missing required options key'); - this.request._entity = options; + this.request._entity = options; - const entity = Response.entity(options.etag, options); - if (Response.unmodified(this.request, entity)) { - return this.response().code(304).takeover(); + const entity = this.request._core.Response.entity(options.etag, options); + if (this.request._core.Response.unmodified(this.request, entity)) { + return this.response().code(304).takeover(); + } } - } - state(name, value, options) { + state(name, value, options) { - this.request._setState(name, value, options); - } + this.request._setState(name, value, options); + } - unstate(name, options) { + unstate(name, options) { - this.request._clearState(name, options); - } -}; + this.request._clearState(name, options); + } + authenticated(data) { -internals.authenticated = function (data) { + Hoek.assert(this._auth, 'Method not supported outside of authentication'); + Hoek.assert(data?.credentials, 'Authentication data missing credentials information'); - Hoek.assert(data && data.credentials, 'Authentication data missing credentials information'); + return new internals.Auth(null, data); + } - return new internals.Auth(null, data); -}; + unauthenticated(error, data) { + Hoek.assert(this._auth, 'Method not supported outside of authentication'); + Hoek.assert(!data || data.credentials, 'Authentication data missing credentials information'); -internals.unauthenticated = function (error, data) { + return new internals.Auth(error, data); + } + }; - Hoek.assert(!data || data.credentials, 'Authentication data missing credentials information'); + Toolkit.prototype.abandon = exports.symbols.abandon; + Toolkit.prototype.close = exports.symbols.close; + Toolkit.prototype.continue = exports.symbols.continue; - return new internals.Auth(error, data); + return Toolkit; }; diff --git a/lib/transmit.js b/lib/transmit.js index 80705f385..dafd2e369 100755 --- a/lib/transmit.js +++ b/lib/transmit.js @@ -1,21 +1,15 @@ 'use strict'; -// Load modules - const Http = require('http'); -const Ammo = require('ammo'); -const Boom = require('boom'); -const Bounce = require('bounce'); -const Hoek = require('hoek'); -const Shot = require('shot'); -const Teamwork = require('teamwork'); +const Ammo = require('@hapi/ammo'); +const Boom = require('@hapi/boom'); +const Bounce = require('@hapi/bounce'); +const Hoek = require('@hapi/hoek'); +const Teamwork = require('@hapi/teamwork'); const Config = require('./config'); -const Response = require('./response'); - -// Declare internals const internals = {}; @@ -23,12 +17,14 @@ const internals = {}; exports.send = async function (request) { const response = request.response; - if (response.isBoom) { - return internals.fail(request, response); - } try { - await internals.marshal(request); + if (response.isBoom) { + await internals.fail(request, response); + return; + } + + await internals.marshal(response); await internals.transmit(response); } catch (err) { @@ -39,29 +35,21 @@ exports.send = async function (request) { }; -internals.marshal = async function (request) { +internals.marshal = async function (response) { - for (let i = 0; i < request._route._marshalCycle.length; ++i) { - const func = request._route._marshalCycle[i]; - const result = func(request); - if (result && typeof result.then === 'function') { // Skip await if no reason to - await result; - } + for (const func of response.request._route._marshalCycle) { + await func(response); } }; internals.fail = async function (request, boom) { - const error = boom.output; - const response = new Response(error.payload, request); - response._error = boom; - response.code(error.statusCode); - response.headers = Hoek.clone(error.headers); // Prevent source from being modified + const response = internals.error(request, boom); request.response = response; // Not using request._setResponse() to avoid double log try { - await internals.marshal(request); + await internals.marshal(response); } catch (err) { Bounce.rethrow(err, 'system'); @@ -69,18 +57,28 @@ internals.fail = async function (request, boom) { // Failed to marshal an error - replace with minimal representation of original error const minimal = { - statusCode: error.statusCode, - error: Http.STATUS_CODES[error.statusCode], + statusCode: response.statusCode, + error: Http.STATUS_CODES[response.statusCode], message: boom.message }; - response._payload = new Response.Payload(JSON.stringify(minimal), {}); + response._payload = new request._core.Response.Payload(JSON.stringify(minimal), {}); } return internals.transmit(response); }; +internals.error = function (request, boom) { + + const error = boom.output; + const response = new request._core.Response(error.payload, request, { error: boom }); + response.code(error.statusCode); + response.headers = Hoek.clone(error.headers); // Prevent source from being modified + return response; +}; + + internals.transmit = function (response) { const request = response.request; @@ -89,14 +87,14 @@ internals.transmit = function (response) { // Pipes const encoding = request._core.compression.encoding(response, length); - const ranger = (encoding ? null : internals.range(response, length)); + const ranger = encoding ? null : internals.range(response, length); const compressor = internals.encoding(response, encoding); // Connection: close - const isInjection = Shot.isInjection(request.raw.req); + const isInjection = request.isInjected; if (!(isInjection || request._core.started) || - (request._isPayloadPending && !request.raw.req._readableState.ended)) { + request._isPayloadPending && !request.raw.req._readableState.ended) { response._header('connection', 'close'); } @@ -145,7 +143,7 @@ internals.length = function (response) { if (length === 0 && !response._statusCode && response.statusCode === 200 && - request.route.settings.response.emptyStatusCode === 204) { + request.route.settings.response.emptyStatusCode !== 200) { response.code(204); delete response.headers['content-length']; @@ -192,7 +190,7 @@ internals.range = function (response, length) { // Prepare transform - if (ranges.length !== 1) { // Ignore requests for multiple ranges + if (ranges.length !== 1) { // Ignore requests for multiple ranges return null; } @@ -201,7 +199,7 @@ internals.range = function (response, length) { response.bytes(range.to - range.from + 1); response._header('content-range', 'bytes ' + range.from + '-' + range.to + '/' + length); - return new Ammo.Stream(range); + return new Ammo.Clip(range); }; @@ -239,18 +237,26 @@ internals.encoding = function (response, encoding) { internals.pipe = function (request, stream) { - const team = new Teamwork(); + const team = new Teamwork.Team(); // Write payload const env = { stream, request, team }; + if (request._closed) { + + // The request has already been aborted - no need to wait or attempt to write. + + internals.end(env, 'aborted'); + return team.work; + } + const aborted = internals.end.bind(null, env, 'aborted'); const close = internals.end.bind(null, env, 'close'); const end = internals.end.bind(null, env, null); request.raw.req.on('aborted', aborted); - request.raw.req.on('close', close); + request.raw.res.on('close', close); request.raw.res.on('error', end); request.raw.res.on('finish', end); @@ -260,6 +266,7 @@ internals.pipe = function (request, stream) { } else { stream.on('error', end); + stream.on('close', aborted); stream.pipe(request.raw.res); } @@ -270,39 +277,48 @@ internals.pipe = function (request, stream) { internals.end = function (env, event, err) { const { request, stream, team } = env; - if (!team) { // Used instead of cleaning up emitter listeners + + if (!team) { // Used instead of cleaning up emitter listeners return; } env.team = null; - if (err) { - request.raw.res.destroy(); + if (request.raw.res.writableEnded) { + request.info.responded = Date.now(); - if (request.raw.res[Config.symbol]) { - request.raw.res.statusCode = 500; - request.raw.res[Config.symbol].result = Boom.boomify(err).output.payload; // Force injected response to error - } + team.attend(); + return; + } - Response.drain(stream); + if (err) { + request.raw.res.destroy(); + request._core.Response.drain(stream); } - if (!request.raw.res.finished && - event !== 'aborted') { + // Update reported response to reflect the error condition - request.raw.res.end(); - } + const origResponse = request.response; + const error = err ? Boom.boomify(err) : + new Boom.Boom(`Request ${event}`, { statusCode: request.route.settings.response.disconnectStatusCode, data: origResponse }); - if (event || - err) { + request._setResponse(error); - if (request._events) { - request._events.emit('disconnect'); - } + // Make inject throw a disconnect error + + if (request.raw.res[Config.symbol]) { + request.raw.res[Config.symbol].error = event ? error : + new Boom.Boom(`Response error`, { statusCode: request.route.settings.response.disconnectStatusCode, data: origResponse }); + } - request._log(event ? ['response', 'error', event] : ['response', 'error'], err); + if (event) { + request._log(['response', 'error', event]); + } + else { + request._log(['response', 'error'], err); } + request.raw.res.end(); // Triggers injection promise resolve team.attend(); }; @@ -349,6 +365,7 @@ internals.chain = function (sources) { for (let i = 1; i < sources.length; ++i) { const to = sources[i]; if (to) { + from.on('close', internals.destroyPipe.bind(from, to)); from.on('error', internals.errorPipe.bind(from, to)); from = from.pipe(to); } @@ -358,6 +375,13 @@ internals.chain = function (sources) { }; +internals.destroyPipe = function (to) { + + if (!this.readableEnded && !this.errored) { + to.destroy(); + } +}; + internals.errorPipe = function (to, err) { to.emit('error', err); diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts new file mode 100644 index 000000000..aa19b22f4 --- /dev/null +++ b/lib/types/index.d.ts @@ -0,0 +1,21 @@ +// Definitions adapted from DefinitelyTyped, originally created by: +// Rafael Souza Fijalkowski +// Justin Simms +// Simon Schick +// Rodrigo Saboya +// Silas Rech + +export * from './plugin'; +export * from './response'; +export * from './request'; +export * from './route'; +export * from './server'; +export * from './utils'; + +// Kept for backwards compatibility only (remove in next major) + +export namespace Utils { + interface Dictionary { + [key: string]: T; + } +} diff --git a/lib/types/plugin.d.ts b/lib/types/plugin.d.ts new file mode 100644 index 000000000..017d25fb3 --- /dev/null +++ b/lib/types/plugin.d.ts @@ -0,0 +1,266 @@ +import { RequestRoute } from './request'; +import { RouteOptions } from './route'; +import { Server } from './server'; +import { Lifecycle } from './utils'; + +/** + * one of + * a single plugin name string. + * an array of plugin name strings. + * an object where each key is a plugin name and each matching value is a + * {@link https://www.npmjs.com/package/semver version range string} which must match the registered + * plugin version. + */ +export type Dependencies = string | string[] | Record; + +/** + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverregistrations) + */ +export interface PluginsListRegistered { +} + +/** + * An object of the currently registered plugins where each key is a registered plugin name and the value is an + * object containing: + * * version - the plugin version. + * * name - the plugin name. + * * options - (optional) options passed to the plugin during registration. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverregistrations) + */ +export interface PluginRegistered { + /** + * the plugin version. + */ + version: string; + + /** + * the plugin name. + */ + name: string; + + /** + * options used to register the plugin. + */ + options: object; +} + +export interface PluginsStates { +} + +export interface PluginSpecificConfiguration { +} + +export interface PluginNameVersion { + /** + * (required) the plugin name string. The name is used as a unique key. Published plugins (e.g. published in the npm + * registry) should use the same name as the name field in their 'package.json' file. Names must be + * unique within each application. + */ + name: string; + + /** + * optional plugin version. The version is only used informatively to enable other plugins to find out the versions loaded. The version should be the same as the one specified in the plugin's + * 'package.json' file. + */ + version?: string | undefined; +} + +export interface PluginPackage { + /** + * Alternatively, the name and version can be included via the pkg property containing the 'package.json' file for the module which already has the name and version included + */ + pkg: PluginNameVersion; +} + +export interface PluginBase { + /** + * (required) the registration function with the signature async function(server, options) where: + * * server - the server object with a plugin-specific server.realm. + * * options - any options passed to the plugin during registration via server.register(). + */ + register: (server: Server, options: T) => void | Promise; + + /** (optional) if true, allows the plugin to be registered multiple times with the same server. Defaults to false. */ + multiple?: boolean | undefined; + + /** (optional) a string or an array of strings indicating a plugin dependency. Same as setting dependencies via server.dependency(). */ + dependencies?: Dependencies | undefined; + + /** + * Allows defining semver requirements for node and hapi. + * @default Allows all. + */ + requirements?: { + node?: string | undefined; + hapi?: string | undefined; + } | undefined; + + /** once - (optional) if true, will only register the plugin once per server. If set, overrides the once option passed to server.register(). Defaults to no override. */ + once?: boolean | undefined; + + /** + * We need to use D within the PluginBase type to be able to infer it later on, + * but this property has no concrete existence in the code. + * + * See https://github.com/Microsoft/TypeScript/wiki/FAQ#why-doesnt-type-inference-work-on-this-interface-interface-foot-- for details. + */ + ___$type_of_plugin_decorations$___?: D; +} + +/** + * A plugin that is registered by name and version. + */ +export interface NamedPlugin extends PluginBase, PluginNameVersion {} + +/** + * A plugin that is registered by its package.json file. + */ +export interface PackagedPlugin extends PluginBase, PluginPackage {} + + +/** + * Plugins provide a way to organize application code by splitting the server logic into smaller components. Each + * plugin can manipulate the server through the standard server interface, but with the added ability to sandbox + * certain properties. For example, setting a file path in one plugin doesn't affect the file path set + * in another plugin. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#plugins) + * + * The type T is the type of the plugin options. + */ +export type Plugin = NamedPlugin | PackagedPlugin; + +/** + * The realm object contains sandboxed server settings specific to each plugin or authentication strategy. When registering a plugin or an authentication scheme, a server object reference is provided + * with a new server.realm container specific to that registration. It allows each plugin to maintain its own settings without leaking and affecting other plugins. For example, a plugin can set a + * default file path for local resources without breaking other plugins' configured paths. When calling server.bind(), the active realm's settings.bind property is set which is then used by routes + * and extensions added at the same level (server root or plugin). + * + * https://github.com/hapijs/hapi/blob/master/API.md#server.realm + */ +export interface ServerRealm { + /** when the server object is provided as an argument to the plugin register() method, modifiers provides the registration preferences passed the server.register() method and includes: */ + modifiers: { + /** routes preferences: */ + route: { + /** + * the route path prefix used by any calls to server.route() from the server. Note that if a prefix is used and the route path is set to '/', the resulting path will not include + * the trailing slash. + */ + prefix: string; + /** the route virtual host settings used by any calls to server.route() from the server. */ + vhost: string; + } + }; + /** the realm of the parent server object, or null for the root server. */ + parent: ServerRealm | null; + /** the active plugin name (empty string if at the server root). */ + plugin: string; + /** the plugin options object passed at registration. */ + pluginOptions: object; + /** plugin-specific state to be shared only among activities sharing the same active state. plugins is an object where each key is a plugin name and the value is the plugin state. */ + plugins: PluginsStates; + /** settings overrides */ + settings: { + files: { + relativeTo: string; + }; + bind: object; + }; +} + +/** + * Registration options (different from the options passed to the registration function): + * * once - if true, subsequent registrations of the same plugin are skipped without error. Cannot be used with plugin options. Defaults to false. If not set to true, an error will be thrown the + * second time a plugin is registered on the server. + * * routes - modifiers applied to each route added by the plugin: + * * * prefix - string added as prefix to any route path (must begin with '/'). If a plugin registers a child plugin the prefix is passed on to the child or is added in front of the child-specific + * prefix. + * * * vhost - virtual host string (or array of strings) applied to every route. The outer-most vhost overrides the any nested configuration. + * For reference [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverregisterplugins-options) + */ +export interface ServerRegisterOptions { + /** + * if true, subsequent registrations of the same plugin are skipped without error. Cannot be used with plugin options. Defaults to false. If not set to true, an error will be thrown the second + * time a plugin is registered on the server. + */ + once?: boolean | undefined; + /** + * modifiers applied to each route added by the plugin: + */ + routes?: { + /** + * string added as prefix to any route path (must begin with '/'). If a plugin registers a child plugin the prefix is passed on to the child or is added in front of the child-specific prefix. + */ + prefix: string; + /** + * virtual host string (or array of strings) applied to every route. The outer-most vhost overrides the any nested configuration. + */ + vhost?: string | string[] | undefined; + } | undefined; +} + +export interface ServerRegisterPluginObjectDirect extends ServerRegisterOptions { + /** + * a plugin object. + */ + plugin: Plugin; + /** + * options passed to the plugin during registration. + */ + options?: T | undefined; +} + +export interface ServerRegisterPluginObjectWrapped extends ServerRegisterOptions { + /** + * a plugin object. + */ + plugin: { plugin: Plugin }; + /** + * options passed to the plugin during registration. + */ + options?: T | undefined; +} + +/** + * An object with the following: + * * plugin - a plugin object or a wrapped plugin loaded module. + * * options - (optional) options passed to the plugin during registration. + * * once - if true, subsequent registrations of the same plugin are skipped without error. Cannot be used with plugin options. Defaults to false. If not set to true, an error will be thrown the + * second time a plugin is registered on the server. + * * routes - modifiers applied to each route added by the plugin: + * * * prefix - string added as prefix to any route path (must begin with '/'). If a plugin registers a child plugin the prefix is passed on to the child or is added in front of the child-specific + * prefix. + * * * vhost - virtual host string (or array of strings) applied to every route. The outer-most vhost overrides the any nested configuration. + * For reference [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverregisterplugins-options) + * + * The type parameter T is the type of the plugin configuration options. + */ +export type ServerRegisterPluginObject = + ServerRegisterPluginObjectDirect | + ServerRegisterPluginObjectWrapped; + +export type ServerRegisterPluginObjectArray = ( + ServerRegisterPluginObject | + ServerRegisterPluginObject | + ServerRegisterPluginObject | + ServerRegisterPluginObject | + ServerRegisterPluginObject | + ServerRegisterPluginObject | + ServerRegisterPluginObject +)[]; + +/** + * The method function can have a defaults object or function property. If the property is set to an object, that object is used as the default route config for routes using this handler. + * If the property is set to a function, the function uses the signature function(method) and returns the route default configuration. + */ +export interface HandlerDecorationMethod { + (route: RequestRoute, options: any): Lifecycle.Method; + defaults?: RouteOptions | ((method: any) => RouteOptions) | undefined; +} + +/** + * An empty interface to allow typings of custom plugin properties. + */ + +export interface PluginProperties { +} diff --git a/lib/types/request.d.ts b/lib/types/request.d.ts new file mode 100644 index 000000000..6f91c6d68 --- /dev/null +++ b/lib/types/request.d.ts @@ -0,0 +1,531 @@ +import * as http from 'http'; +import * as stream from 'stream'; +import * as url from 'url'; + +import { Boom } from '@hapi/boom'; +import { Podium } from '@hapi/podium'; + +import { PluginsStates, ServerRealm } from './plugin'; +import { ResponseValue, ResponseObject } from "./response"; +import { RouteRules, RouteSettings } from './route'; +import { Server, ServerAuthSchemeObjectApi } from './server'; +import { HTTP_METHODS, PeekListener } from './utils'; + +/** + * User extensible types user credentials. + */ +export interface UserCredentials { +} + +/** + * User extensible types app credentials. + */ +export interface AppCredentials { +} + +/** + * User-extensible type for request.auth credentials. + */ +export interface AuthCredentials< + AuthUser = UserCredentials, + AuthApp = AppCredentials +> { + /** + * The application scopes to be granted. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsauthaccessscope) + */ + scope?: string[] | undefined; + + /** + * If set, will only work with routes that set `access.entity` to `user`. + */ + user?: AuthUser + + /** + * If set, will only work with routes that set `access.entity` to `app`. + */ + app?: AuthApp; +} + +export interface AuthArtifacts { + [key: string]: unknown; +} + +export type AuthMode = 'required' | 'optional' | 'try'; + +/** + * Authentication information: + * * artifacts - an artifact object received from the authentication strategy and used in authentication-related actions. + * * credentials - the credential object received during the authentication process. The presence of an object does not mean successful authentication. + * * error - the authentication error is failed and mode set to 'try'. + * * isAuthenticated - true if the request has been successfully authenticated, otherwise false. + * * isAuthorized - true is the request has been successfully authorized against the route authentication access configuration. If the route has not access rules defined or if the request failed + * authorization, set to false. + * * mode - the route authentication mode. + * * strategy - the name of the strategy used. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestauth) + */ +export interface RequestAuth< + AuthUser = UserCredentials, + AuthApp = AppCredentials, + CredentialsExtra = Record, + ArtifactsExtra = Record +> { + /** an artifact object received from the authentication strategy and used in authentication-related actions. */ + artifacts: ArtifactsExtra; + /** the credential object received during the authentication process. The presence of an object does not mean successful authentication. */ + credentials: ( + + AuthCredentials & + CredentialsExtra + ); + + /** the authentication error is failed and mode set to 'try'. */ + error: Error; + /** true if the request has been successfully authenticated, otherwise false. */ + isAuthenticated: boolean; + /** + * true is the request has been successfully authorized against the route authentication access configuration. If the route has not access rules defined or if the request failed authorization, + * set to false. + */ + isAuthorized: boolean; + /** true if the request has been authenticated via the `server.inject()` `auth` option, otherwise `undefined`. */ + isInjected?: boolean | undefined; + /** the route authentication mode. */ + mode: AuthMode; + /** the name of the strategy used. */ + strategy: string; +} + + +/** + * 'peek' - emitted for each chunk of payload data read from the client connection. The event method signature is function(chunk, encoding). + * 'finish' - emitted when the request payload finished reading. The event method signature is function (). + * 'disconnect' - emitted when a request errors or aborts unexpectedly. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestevents) + */ +export type RequestEventType = 'peek' | 'finish' | 'disconnect'; + +/** + * Access: read only and the public podium interface. + * The request.events supports the following events: + * * 'peek' - emitted for each chunk of payload data read from the client connection. The event method signature is function(chunk, encoding). + * * 'finish' - emitted when the request payload finished reading. The event method signature is function (). + * * 'disconnect' - emitted when a request errors or aborts unexpectedly. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestevents) + */ +export interface RequestEvents extends Podium { + /** + * Access: read only and the public podium interface. + * The request.events supports the following events: + * * 'peek' - emitted for each chunk of payload data read from the client connection. The event method signature is function(chunk, encoding). + * * 'finish' - emitted when the request payload finished reading. The event method signature is function (). + * * 'disconnect' - emitted when a request errors or aborts unexpectedly. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestevents) + */ + on(criteria: 'peek', listener: PeekListener): this; + + on(criteria: 'finish' | 'disconnect', listener: (data: undefined) => void): this; + + /** + * Access: read only and the public podium interface. + * The request.events supports the following events: + * * 'peek' - emitted for each chunk of payload data read from the client connection. The event method signature is function(chunk, encoding). + * * 'finish' - emitted when the request payload finished reading. The event method signature is function (). + * * 'disconnect' - emitted when a request errors or aborts unexpectedly. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestevents) + */ + once(criteria: 'peek', listener: PeekListener): this; + once(criteria: 'peek'): Promise>; + + once(criteria: 'finish' | 'disconnect', listener: (data: undefined) => void): this; +} + +/** + * Request information: + * * acceptEncoding - the request preferred encoding. + * * cors - if CORS is enabled for the route, contains the following: + * * isOriginMatch - true if the request 'Origin' header matches the configured CORS restrictions. Set to false if no 'Origin' header is found or if it does not match. Note that this is only + * available after the 'onRequest' extension point as CORS is configured per-route and no routing decisions are made at that point in the request lifecycle. + * * host - content of the HTTP 'Host' header (e.g. 'example.com:8080'). + * * hostname - the hostname part of the 'Host' header (e.g. 'example.com'). + * * id - a unique request identifier (using the format '{now}:{connection.info.id}:{5 digits counter}'). + * * received - request reception timestamp. + * * referrer - content of the HTTP 'Referrer' (or 'Referer') header. + * * remoteAddress - remote client IP address. + * * remotePort - remote client port. + * * responded - request response timestamp (0 is not responded yet). + * Note that the request.info object is not meant to be modified. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestinfo) + */ +export interface RequestInfo { + /** the request preferred encoding. */ + acceptEncoding: string; + /** if CORS is enabled for the route, contains the following: */ + cors: { + /** + * true if the request 'Origin' header matches the configured CORS restrictions. Set to false if no 'Origin' header is found or if it does not match. Note that this is only available after + * the 'onRequest' extension point as CORS is configured per-route and no routing decisions are made at that point in the request lifecycle. + */ + isOriginMatch?: boolean | undefined; + }; + /** content of the HTTP 'Host' header (e.g. 'example.com:8080'). */ + host: string; + /** the hostname part of the 'Host' header (e.g. 'example.com'). */ + hostname: string; + /** a unique request identifier (using the format '{now}:{connection.info.id}:{5 digits counter}') */ + id: string; + /** request reception timestamp. */ + received: number; + /** content of the HTTP 'Referrer' (or 'Referer') header. */ + referrer: string; + /** remote client IP address. */ + remoteAddress: string; + /** remote client port. */ + remotePort: string; + /** request response timestamp (0 is not responded yet). */ + responded: number; + /** request processing completion timestamp (0 is still processing). */ + completed: number; +} + +/** + * The request route information object, where: + * * method - the route HTTP method. + * * path - the route path. + * * vhost - the route vhost option if configured. + * * realm - the active realm associated with the route. + * * settings - the route options object with all defaults applied. + * * fingerprint - the route internal normalized string representing the normalized path. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestroute) + */ +export interface RequestRoute { + /** the route HTTP method. */ + method: Exclude, 'head'> | '*'; + + /** the route path. */ + path: string; + + /** the route vhost option if configured. */ + vhost?: string | string[] | undefined; + + /** the active realm associated with the route. */ + realm: ServerRealm; + + /** the route options object with all defaults applied. */ + settings: RouteSettings; + + /** the route internal normalized string representing the normalized path. */ + fingerprint: string; + + auth: { + /** + * Validates a request against the route's authentication access configuration, where: + * @param request - the request object. + * @return Return value: true if the request would have passed the route's access requirements. + * Note that the route's authentication mode and strategies are ignored. The only match is made between the request.auth.credentials scope and entity information and the route access + * configuration. If the route uses dynamic scopes, the scopes are constructed against the request.query, request.params, request.payload, and request.auth.credentials which may or may + * not match between the route and the request's route. If this method is called using a request that has not been authenticated (yet or not at all), it will return false if the route + * requires any authentication. + * [See docs](https://hapijs.com/api/17.0.1#-requestrouteauthaccessrequest) + */ + access(request: Request): boolean; + }; +} + +/** + * An object containing the values of params, query, and payload before any validation modifications made. Only set when input validation is performed. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestorig) + */ +export interface RequestOrig { + params: object; + query: object; + payload: object; +} + +export interface RequestLog { + request: string; + timestamp: number; + tags: string[]; + data: string | object; + channel: string; +} + +export interface RequestQuery { + [key: string]: unknown; +} + +/** + * Empty interface to allow for user-defined augmentations. + */ +export interface RouteOptionsApp {} + +/** + * User-extensible type for application specific state on requests (`request.app`). + */ +export interface RequestApplicationState { +} + +export interface InternalRequestDefaults { + Server: Server; + + Payload: stream.Readable | Buffer | string | object; + Query: RequestQuery; + Params: Record; + Pres: Record; + Headers: Record; + RequestApp: RequestApplicationState; + + AuthUser: UserCredentials; + AuthApp: AppCredentials; + AuthApi: ServerAuthSchemeObjectApi; + AuthCredentialsExtra: Record; + AuthArtifactsExtra: Record; + + Rules: RouteRules; + Bind: object | null; + RouteApp: RouteOptionsApp; +} + +/** + * Default request references. Used to give typing to requests, + * route handlers, lifecycle methods, auth credentials, etc. + * This can be overwritten to whatever is suitable and universal + * in your specific app, but whatever references you pass to + * server route generic, or lifecycle methods will take precedence + * over these. + */ +export interface ReqRefDefaults extends InternalRequestDefaults {} + +/** + * Route request overrides + */ +export type ReqRef = Partial>; + +/** + * Utilities for merging request refs and other things + */ +export type MergeType = Omit & U; + +export type MergeRefs = MergeType; + +/** + * The request object is created internally for each incoming request. It is not the same object received from the node + * HTTP server callback (which is available via [request.raw.req](https://github.com/hapijs/hapi/blob/master/API.md#request.raw)). The request properties change throughout + * the request [lifecycle](https://github.com/hapijs/hapi/blob/master/API.md#request-lifecycle). + */ +export interface Request extends Podium { + /** + * Application-specific state. Provides a safe place to store application data without potential conflicts with the framework. Should not be used by plugins which should use plugins[name]. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestapp) + */ + app: MergeRefs['RequestApp']; + + /** + * Authentication information: + * * artifacts - an artifact object received from the authentication strategy and used in authentication-related actions. + * * credentials - the credential object received during the authentication process. The presence of an object does not mean successful authentication. + * * error - the authentication error is failed and mode set to 'try'. + * * isAuthenticated - true if the request has been successfully authenticated, otherwise false. + * * isAuthorized - true is the request has been successfully authorized against the route authentication access configuration. If the route has not access rules defined or if the request failed + * authorization, set to false. + * * mode - the route authentication mode. + * * strategy - the name of the strategy used. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestauth) + */ + readonly auth: RequestAuth< + MergeRefs['AuthUser'], + MergeRefs['AuthApp'], + MergeRefs['AuthCredentialsExtra'], + MergeRefs['AuthArtifactsExtra'] + >; + + /** + * Access: read only and the public podium interface. + * The request.events supports the following events: + * * 'peek' - emitted for each chunk of payload data read from the client connection. The event method signature is function(chunk, encoding). + * * 'finish' - emitted when the request payload finished reading. The event method signature is function (). + * * 'disconnect' - emitted when a request errors or aborts unexpectedly. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestevents) + */ + events: RequestEvents; + + /** + * The raw request headers (references request.raw.req.headers). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestheaders) + */ + readonly headers: MergeRefs['Headers']; + + /** + * Request information: + * * acceptEncoding - the request preferred encoding. + * * cors - if CORS is enabled for the route, contains the following: + * * isOriginMatch - true if the request 'Origin' header matches the configured CORS restrictions. Set to false if no 'Origin' header is found or if it does not match. Note that this is only + * available after the 'onRequest' extension point as CORS is configured per-route and no routing decisions are made at that point in the request lifecycle. + * * host - content of the HTTP 'Host' header (e.g. 'example.com:8080'). + * * hostname - the hostname part of the 'Host' header (e.g. 'example.com'). + * * id - a unique request identifier (using the format '{now}:{connection.info.id}:{5 digits counter}'). + * * received - request reception timestamp. + * * referrer - content of the HTTP 'Referrer' (or 'Referer') header. + * * remoteAddress - remote client IP address. + * * remotePort - remote client port. + * * responded - request response timestamp (0 is not responded yet). + * Note that the request.info object is not meant to be modified. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestinfo) + */ + readonly info: RequestInfo; + + /** + * An array containing the logged request events. + * Note that this array will be empty if route log.collect is set to false. + */ + readonly logs: RequestLog[]; + + /** + * The request method in lower case (e.g. 'get', 'post'). + */ + readonly method: Lowercase; + + /** + * The parsed content-type header. Only available when payload parsing enabled and no payload error occurred. + */ + readonly mime: string; + + /** + * An object containing the values of params, query, and payload before any validation modifications made. Only set when input validation is performed. + */ + readonly orig: RequestOrig; + + /** + * An object where each key is a path parameter name with matching value as described in [Path parameters](https://github.com/hapijs/hapi/blob/master/API.md#path-parameters). + */ + readonly params: MergeRefs['Params']; + + /** + * An array containing all the path params values in the order they appeared in the path. + */ + readonly paramsArray: keyof MergeRefs['Params'] | string[]; + + /** + * The request URI's pathname component. + */ + readonly path: string; + + /** + * The request payload based on the route payload.output and payload.parse settings. + * TODO check this typing and add references / links. + */ + readonly payload: MergeRefs['Payload']; + + /** + * Plugin-specific state. Provides a place to store and pass request-level plugin data. The plugins is an object where each key is a plugin name and the value is the state. + */ + plugins: PluginsStates; + + /** + * An object where each key is the name assigned by a route pre-handler methods function. The values are the raw values provided to the continuation function as argument. For the wrapped response + * object, use responses. + */ + readonly pre: MergeRefs['Pres']; + + /** + * Access: read / write (see limitations below). + * The response object when set. The object can be modified but must not be assigned another object. To replace the response with another from within an extension point, use reply(response) to + * override with a different response. + * In case of an aborted request the status code will be set to `disconnectStatusCode`. + */ + response: ResponseObject | Boom; + + /** + * Same as pre but represented as the response object created by the pre method. + */ + readonly preResponses: Record; + + /** + * By default the object outputted from node's URL parse() method. + */ + readonly query: MergeRefs['Query']; + + /** + * An object containing the Node HTTP server objects. Direct interaction with these raw objects is not recommended. + * * req - the node request object. + * * res - the node response object. + */ + readonly raw: { + req: http.IncomingMessage; + res: http.ServerResponse; + }; + + /** + * The request route information object and method + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestroute) + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestrouteauthaccessrequest) + */ + readonly route: RequestRoute; + + /** + * Access: read only and the public server interface. + * The server object. + */ + readonly server: MergeRefs['Server']; + + /** + * An object containing parsed HTTP state information (cookies) where each key is the cookie name and value is the matching cookie content after processing using any registered cookie definition. + */ + readonly state: Record; + + /** + * The parsed request URI. + */ + readonly url: url.URL; + + /** + * Returns `true` when the request is active and processing should continue and `false` when the + * request terminated early or completed its lifecycle. Useful when request processing is a + * resource-intensive operation and should be terminated early if the request is no longer active + * (e.g. client disconnected or aborted early). + */ + active(): boolean; + + /** + * Returns a response which you can pass into the reply interface where: + * @param source - the value to set as the source of the reply interface, optional. + * @param options - options for the method, optional. + * @return ResponseObject + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestgenerateresponsesource-options) + */ + /* tslint:disable-next-line:max-line-length */ + generateResponse(source: string | object | null, options?: { variety?: string | undefined; prepare?: ((response: ResponseObject) => Promise) | undefined; marshal?: ((response: ResponseObject) => Promise) | undefined; close?: ((response: ResponseObject) => void) | undefined; } | undefined): ResponseObject; + + /** + * Logs request-specific events. When called, the server emits a 'request' event which can be used by other listeners or plugins. The arguments are: + * @param tags - a string or an array of strings (e.g. ['error', 'database', 'read']) used to identify the event. Tags are used instead of log levels and provide a much more expressive mechanism + * for describing and filtering events. + * @param data - (optional) an message string or object with the application data being logged. If data is a function, the function signature is function() and it called once to generate (return + * value) the actual data emitted to the listeners. Any logs generated by the server internally will be emitted only on the 'request-internal' channel and will include the event.internal flag + * set to true. + * @return void + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-requestlogtags-data) + */ + log(tags: string | string[], data?: string | object | (() => string | object) | undefined): void; + + /** + * Changes the request method before the router begins processing the request where: + * @param method - is the request HTTP method (e.g. 'GET'). + * @return void + * Can only be called from an 'onRequest' extension method. + * [See docs](https://hapijs.com/api/17.0.1#-requestsetmethodmethod) + */ + setMethod(method: HTTP_METHODS | Lowercase): void; + + /** + * Changes the request URI before the router begins processing the request where: + * Can only be called from an 'onRequest' extension method. + * @param url - the new request URI. If url is a string, it is parsed with node's URL parse() method with parseQueryString set to true. url can also be set to an object compatible with node's URL + * parse() method output. + * @param stripTrailingSlash - if true, strip the trailing slash from the path. Defaults to false. + * @return void + * [See docs](https://hapijs.com/api/17.0.1#-requestseturlurl-striptrailingslash) + */ + setUrl(url: string | url.URL, stripTrailingSlash?: boolean | undefined): void; +} diff --git a/lib/types/response.d.ts b/lib/types/response.d.ts new file mode 100644 index 000000000..f7c131787 --- /dev/null +++ b/lib/types/response.d.ts @@ -0,0 +1,568 @@ + +import { Podium } from '@hapi/podium'; + +import { PluginsStates, ServerRealm } from './plugin'; +import { + UserCredentials, + AppCredentials, + AuthArtifacts, + AuthCredentials, + ReqRef, + ReqRefDefaults, + MergeRefs, + Request +} from './request'; +import { PeekListener, Lifecycle, Json } from './utils'; +import { ServerStateCookieOptions } from './server'; + +/** + * User-extensible type for application specific state on responses (`response.app`). + */ +export interface ResponseApplicationState { +} + +/** + * Access: read only and the public podium interface. + * The response.events object supports the following events: + * * 'peek' - emitted for each chunk of data written back to the client connection. The event method signature is function(chunk, encoding). + * * 'finish' - emitted when the response finished writing but before the client response connection is ended. The event method signature is function (). + * [See docs](https://hapijs.com/api/17.0.1#-responseevents) + */ +export interface ResponseEvents extends Podium { + /** + * 'peek' - emitted for each chunk of data written back to the client connection. The event method signature is function(chunk, encoding). + * 'finish' - emitted when the response finished writing but before the client response connection is ended. The event method signature is function (). + */ + on(criteria: 'peek', listener: PeekListener): this; + + on(criteria: 'finish', listener: (data: undefined) => void): this; + + /** + * 'peek' - emitted for each chunk of data written back to the client connection. The event method signature is function(chunk, encoding). + * 'finish' - emitted when the response finished writing but before the client response connection is ended. The event method signature is function (). + */ + once(criteria: 'peek', listener: PeekListener): this; + once(criteria: 'peek'): Promise>; + + once(criteria: 'finish', listener: (data: undefined) => void): this; +} + +/** + * Object where: + * * append - if true, the value is appended to any existing header value using separator. Defaults to false. + * * separator - string used as separator when appending to an existing value. Defaults to ','. + * * override - if false, the header value is not set if an existing value present. Defaults to true. + * * duplicate - if false, the header value is not modified if the provided value is already included. Does not apply when append is false or if the name is 'set-cookie'. Defaults to true. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseheadername-value-options) + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#response-object) + */ +export interface ResponseObjectHeaderOptions { + append?: boolean | undefined; + separator?: string | undefined; + override?: boolean | undefined; + duplicate?: boolean | undefined; +} + +/** + * The response object contains the request response value along with various HTTP headers and flags. When a lifecycle + * method returns a value, the value is wrapped in a response object along with some default flags (e.g. 200 status + * code). In order to customize a response before it is returned, the h.response() method is provided. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#response-object) + * TODO, check extending from Podium is correct. Extending because of "The response object supports the following events" [See docs](https://hapijs.com/api/17.0.1#-responseevents) + */ +export interface ResponseObject extends Podium { + /** + * @default {}. + * Application-specific state. Provides a safe place to store application data without potential conflicts with the framework. Should not be used by plugins which should use plugins[name]. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseapp) + */ + app: ResponseApplicationState; + + /** + * Access: read only and the public podium interface. + * The response.events object supports the following events: + * * 'peek' - emitted for each chunk of data written back to the client connection. The event method signature is function(chunk, encoding). + * * 'finish' - emitted when the response finished writing but before the client response connection is ended. The event method signature is function (). + * [See docs](https://hapijs.com/api/17.0.1#-responseevents) + */ + readonly events: ResponseEvents; + + /** + * @default {}. + * An object containing the response headers where each key is a header field name and the value is the string header value or array of string. + * Note that this is an incomplete list of headers to be included with the response. Additional headers will be added once the response is prepared for transmission. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseheaders) + */ + readonly headers: Record; + + /** + * @default {}. + * Plugin-specific state. Provides a place to store and pass request-level plugin data. plugins is an object where each key is a plugin name and the value is the state. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseplugins) + */ + plugins: PluginsStates; + + /** + * Object containing the response handling flags. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsesettings) + */ + readonly settings: ResponseSettings; + + /** + * The raw value returned by the lifecycle method. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsesource) + */ + readonly source: Lifecycle.ReturnValue; + + /** + * @default 200. + * The HTTP response status code. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsestatuscode) + */ + readonly statusCode: number; + + /** + * A string indicating the type of source with available values: + * * 'plain' - a plain response such as string, number, null, or simple object. + * * 'buffer' - a Buffer. + * * 'stream' - a Stream. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsevariety) + */ + readonly variety: 'plain' | 'buffer' | 'stream'; + + /** + * Sets the HTTP 'Content-Length' header (to avoid chunked transfer encoding) where: + * @param length - the header value. Must match the actual payload size. + * @return Return value: the current response object. + * [See docs](https://hapijs.com/api/17.0.1#-responsebyteslength) + */ + bytes(length: number): ResponseObject; + + /** + * Controls the 'Content-Type' HTTP header 'charset' property of the response. + * * When invoked without any parameter, will prevent hapi from applying its default charset normalization to 'utf-8' + * * When 'charset' parameter is provided, will set the 'Content-Type' HTTP header 'charset' property where: + * @param charset - the charset property value. + * @return Return value: the current response object. + * [See docs](https://hapijs.com/api/17.0.1#-responsecharsetcharset) + */ + charset(charset?: string): ResponseObject | undefined; + + /** + * Sets the HTTP status code where: + * @param statusCode - the HTTP status code (e.g. 200). + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsecodestatuscode) + */ + code(statusCode: number): ResponseObject; + + /** + * Sets the HTTP status message where: + * @param httpMessage - the HTTP status message (e.g. 'Ok' for status code 200). + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsemessagehttpmessage) + */ + message(httpMessage: string): ResponseObject; + + /** + * Sets the HTTP 'content-encoding' header where: + * @param encoding - the header value string. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsecompressedencoding) + */ + compressed(encoding: string): ResponseObject; + + /** + * Sets the HTTP status code to Created (201) and the HTTP 'Location' header where: + * @param uri - an absolute or relative URI used as the 'Location' header value. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsecreateduri) + */ + created(uri: string): ResponseObject; + + /** + * Sets the string encoding scheme used to serial data into the HTTP payload where: + * @param encoding the encoding property value (see node Buffer encoding [See docs](https://nodejs.org/api/buffer.html#buffer_buffers_and_character_encodings)). + * * 'ascii' - for 7-bit ASCII data only. This encoding is fast and will strip the high bit if set. + * * 'utf8' - Multibyte encoded Unicode characters. Many web pages and other document formats use UTF-8. + * * 'utf16le' - 2 or 4 bytes, little-endian encoded Unicode characters. Surrogate pairs (U+10000 to U+10FFFF) are supported. + * * 'ucs2' - Alias of 'utf16le'. + * * 'base64' - Base64 encoding. When creating a Buffer from a string, this encoding will also correctly accept "URL and Filename Safe Alphabet" as specified in RFC4648, Section 5. + * * 'latin1' - A way of encoding the Buffer into a one-byte encoded string (as defined by the IANA in RFC1345, page 63, to be the Latin-1 supplement block and C0/C1 control codes). + * * 'binary' - Alias for 'latin1'. + * * 'hex' - Encode each byte as two hexadecimal characters. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseencodingencoding) + */ + encoding(encoding: 'ascii' | 'utf8' | 'utf16le' | 'ucs2' | 'base64' | 'latin1' | 'binary' | 'hex'): ResponseObject; + + /** + * Sets the representation entity tag where: + * @param tag - the entity tag string without the double-quote. + * @param options - (optional) settings where: + * * weak - if true, the tag will be prefixed with the 'W/' weak signifier. Weak tags will fail to match identical tags for the purpose of determining 304 response status. Defaults to false. + * * vary - if true and content encoding is set or applied to the response (e.g 'gzip' or 'deflate'), the encoding name will be automatically added to the tag at transmission time (separated by + * a '-' character). Ignored when weak is true. Defaults to true. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseetagtag-options) + */ + etag(tag: string, options?: {weak: boolean, vary: boolean} | undefined): ResponseObject; + + /** + * Sets an HTTP header where: + * @param name - the header name. + * @param value - the header value. + * @param options - (optional) object where: + * * append - if true, the value is appended to any existing header value using separator. Defaults to false. + * * separator - string used as separator when appending to an existing value. Defaults to ','. + * * override - if false, the header value is not set if an existing value present. Defaults to true. + * * duplicate - if false, the header value is not modified if the provided value is already included. Does not apply when append is false or if the name is 'set-cookie'. Defaults to true. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseheadername-value-options) + */ + header(name: string, value: string, options?: ResponseObjectHeaderOptions | undefined): ResponseObject; + + /** + * Sets the HTTP 'Location' header where: + * @param uri - an absolute or relative URI used as the 'Location' header value. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responselocationuri) + */ + location(uri: string): ResponseObject; + + /** + * Sets an HTTP redirection response (302) and decorates the response with additional methods, where: + * @param uri - an absolute or relative URI used to redirect the client to another resource. + * @return Return value: the current response object. + * Decorates the response object with the response.temporary(), response.permanent(), and response.rewritable() methods to easily change the default redirection code (302). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseredirecturi) + */ + redirect(uri: string): ResponseObject; + + /** + * Sets the JSON.stringify() replacer argument where: + * @param method - the replacer function or array. Defaults to none. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsereplacermethod) + */ + replacer(method: Json.StringifyReplacer): ResponseObject; + + /** + * Sets the JSON.stringify() space argument where: + * @param count - the number of spaces to indent nested object keys. Defaults to no indentation. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsespacescount) + */ + spaces(count: number): ResponseObject; + + /** + * Sets an HTTP cookie where: + * @param name - the cookie name. + * @param value - the cookie value. If no options.encoding is defined, must be a string. See server.state() for supported encoding values. + * @param options - (optional) configuration. If the state was previously registered with the server using server.state(), the specified keys in options are merged with the default server + * definition. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsestatename-value-options) + */ + state(name: string, value: object | string, options?: ServerStateCookieOptions | undefined): ResponseObject; + + /** + * Sets a string suffix when the response is process via JSON.stringify() where: + * @param suffix - the string suffix. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsesuffixsuffix) + */ + suffix(suffix: string): ResponseObject; + + /** + * Overrides the default route cache expiration rule for this response instance where: + * @param msec - the time-to-live value in milliseconds. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsettlmsec) + */ + ttl(msec: number): ResponseObject; + + /** + * Sets the HTTP 'Content-Type' header where: + * @param mimeType - is the mime type. + * @return Return value: the current response object. + * Should only be used to override the built-in default for each response type. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsetypemimetype) + */ + type(mimeType: string): ResponseObject; + + /** + * Clears the HTTP cookie by setting an expired value where: + * @param name - the cookie name. + * @param options - (optional) configuration for expiring cookie. If the state was previously registered with the server using server.state(), the specified options are merged with the server + * definition. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responseunstatename-options) + */ + unstate(name: string, options?: ServerStateCookieOptions | undefined): ResponseObject; + + /** + * Adds the provided header to the list of inputs affected the response generation via the HTTP 'Vary' header where: + * @param header - the HTTP request header name. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsevaryheader) + */ + vary(header: string): ResponseObject; + + /** + * Marks the response object as a takeover response. + * @return Return value: the current response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsetakeover) + */ + takeover(): ResponseObject; + + /** + * Sets the status code to 302 or 307 (based on the response.rewritable() setting) where: + * @param isTemporary - if false, sets status to permanent. Defaults to true. + * @return Return value: the current response object. + * Only available after calling the response.redirect() method. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsetemporaryistemporary) + */ + temporary(isTemporary?: boolean): ResponseObject; + + /** + * Sets the status code to 301 or 308 (based on the response.rewritable() setting) where: + * @param isPermanent - if false, sets status to temporary. Defaults to true. + * @return Return value: the current response object. + * Only available after calling the response.redirect() method. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsepermanentispermanent) + */ + permanent(isPermanent?: boolean): ResponseObject; + + /** + * Sets the status code to 301/302 for rewritable (allows changing the request method from 'POST' to 'GET') or 307/308 for non-rewritable (does not allow changing the request method from 'POST' + * to 'GET'). Exact code based on the response.temporary() or response.permanent() setting. Arguments: + * @param isRewritable - if false, sets to non-rewritable. Defaults to true. + * @return Return value: the current response object. + * Only available after calling the response.redirect() method. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responserewritableisrewritable) + */ + rewritable(isRewritable?: boolean): ResponseObject; +} + +/** + * Object containing the response handling flags. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-responsesettings) + */ +export interface ResponseSettings { + /** + * Defaults value: true. + * If true and source is a Stream, copies the statusCode and headers properties of the stream object to the outbound response. + */ + readonly passThrough: boolean; + + /** + * @default null (use route defaults). + * Override the route json options used when source value requires stringification. + */ + readonly stringify: Json.StringifyArguments; + + /** + * @default null (use route defaults). + * If set, overrides the route cache with an expiration value in milliseconds. + */ + readonly ttl: number; + + /** + * @default false. + * If true, a suffix will be automatically added to the 'ETag' header at transmission time (separated by a '-' character) when the HTTP 'Vary' header is present. + */ + varyEtag: boolean; +} + +/** + * See more about Lifecycle + * https://github.com/hapijs/hapi/blob/master/API.md#request-lifecycle + * + */ + +export type ResponseValue = string | object; + +export interface AuthenticationData< + + AuthUser = UserCredentials, + AuthApp = AppCredentials, + CredentialsExtra = Record, + ArtifactsExtra = AuthArtifacts +> { + credentials: AuthCredentials & CredentialsExtra; + artifacts?: ArtifactsExtra | undefined; +} + +export interface Auth< + AuthUser = UserCredentials, + AuthApp = AppCredentials, + CredentialsExtra = Record, + ArtifactsExtra = AuthArtifacts +> { + readonly isAuth: true; + readonly error?: Error | null | undefined; + readonly data?: AuthenticationData | undefined; +} + +/** + * The response toolkit is a collection of properties and utilities passed to every [lifecycle method](https://github.com/hapijs/hapi/blob/master/API.md#lifecycle-methods) + * It is somewhat hard to define as it provides both utilities for manipulating responses as well as other information. Since the + * toolkit is passed as a function argument, developers can name it whatever they want. For the purpose of this + * document the h notation is used. It is named in the spirit of the RethinkDB r method, with h for hapi. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#response-toolkit) + */ +export interface ResponseToolkit { + /** + * A response symbol. When returned by a lifecycle method, the request lifecycle skips to the finalizing step + * without further interaction with the node response stream. It is the developer's responsibility to write + * and end the response directly via [request.raw.res](https://github.com/hapijs/hapi/blob/master/API.md#request.raw). + */ + readonly abandon: symbol; + + /** + * A response symbol. When returned by a lifecycle method, the request lifecycle skips to the finalizing step after + * calling request.raw.res.end()) to close the the node response stream. + */ + readonly close: symbol; + + /** + * A response symbol. Provides access to the route or server context set via the route [bind](https://github.com/hapijs/hapi/blob/master/API.md#route.options.bind) + * option or [server.bind()](https://github.com/hapijs/hapi/blob/master/API.md#server.bind()). + */ + readonly context: any; + + /** + * A response symbol. When returned by a lifecycle method, the request lifecycle continues without changing the response. + */ + readonly continue: symbol; + + /** + * The [server realm](https://github.com/hapijs/hapi/blob/master/API.md#server.realm) associated with the matching + * route. Defaults to the root server realm in the onRequest step. + */ + readonly realm: ServerRealm; + + /** + * Access: read only and public request interface. + * The [request] object. This is a duplication of the request lifecycle method argument used by + * [toolkit decorations](https://github.com/hapijs/hapi/blob/master/API.md#server.decorate()) to access the current request. + */ + readonly request: Readonly>; + + /** + * Used by the [authentication] method to pass back valid credentials where: + * @param data - an object with: + * * credentials - (required) object representing the authenticated entity. + * * artifacts - (optional) authentication artifacts object specific to the authentication scheme. + * @return Return value: an internal authentication object. + */ + authenticated < + AuthUser = MergeRefs['AuthUser'], + AuthApp = MergeRefs['AuthApp'], + CredentialsExtra = MergeRefs['AuthCredentialsExtra'], + ArtifactsExtra = MergeRefs['AuthArtifactsExtra'] + >( + data: ( + AuthenticationData< + AuthUser, + AuthApp, + CredentialsExtra, + ArtifactsExtra + > + ) + ): Auth< + AuthUser, + AuthApp, + CredentialsExtra, + ArtifactsExtra + >; + + /** + * Sets the response 'ETag' and 'Last-Modified' headers and checks for any conditional request headers to decide if + * the response is going to qualify for an HTTP 304 (Not Modified). If the entity values match the request + * conditions, h.entity() returns a response object for the lifecycle method to return as its value which will + * set a 304 response. Otherwise, it sets the provided entity headers and returns undefined. + * The method arguments are: + * @param options - a required configuration object with: + * * etag - the ETag string. Required if modified is not present. Defaults to no header. + * * modified - the Last-Modified header value. Required if etag is not present. Defaults to no header. + * * vary - same as the response.etag() option. Defaults to true. + * @return Return value: - a response object if the response is unmodified. - undefined if the response has changed. + * If undefined is returned, the developer must return a valid lifecycle method value. If a response is returned, + * it should be used as the return value (but may be customize using the response methods). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-hentityoptions) + */ + entity(options?: {etag?: string | undefined, modified?: string | undefined, vary?: boolean | undefined} | undefined): ResponseObject; + + /** + * Redirects the client to the specified uri. Same as calling h.response().redirect(uri). + * @param url + * @return Returns a response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-hredirecturi) + */ + redirect(uri?: string | undefined): ResponseObject; + + /** + * Wraps the provided value and returns a response object which allows customizing the response + * (e.g. setting the HTTP status code, custom headers, etc.), where: + * @param value - (optional) return value. Defaults to null. + * @return Returns a response object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-hresponsevalue) + */ + response(value?: ResponseValue | undefined): ResponseObject; + + /** + * Sets a response cookie using the same arguments as response.state(). + * @param name of the cookie + * @param value of the cookie + * @param (optional) ServerStateCookieOptions object. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-hstatename-value-options) + */ + state(name: string, value: string | object, options?: ServerStateCookieOptions | undefined): void; + + /** + * Used by the [authentication] method to indicate authentication failed and pass back the credentials received where: + * @param error - (required) the authentication error. + * @param data - (optional) an object with: + * * credentials - (required) object representing the authenticated entity. + * * artifacts - (optional) authentication artifacts object specific to the authentication scheme. + * @return void. + * The method is used to pass both the authentication error and the credentials. For example, if a request included + * expired credentials, it allows the method to pass back the user information (combined with a 'try' + * authentication mode) for error customization. + * There is no difference between throwing the error or passing it with the h.unauthenticated() method is no credentials are passed, but it might still be helpful for code clarity. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-hunauthenticatederror-data) + */ + unauthenticated < + AuthUser = MergeRefs['AuthUser'], + AuthApp = MergeRefs['AuthApp'], + CredentialsExtra = MergeRefs['AuthCredentialsExtra'], + ArtifactsExtra = MergeRefs['AuthArtifactsExtra'] + >( + error: Error, + data?: ( + AuthenticationData< + AuthUser, + AuthApp, + CredentialsExtra, + ArtifactsExtra + > + ) | undefined + ): Auth< + AuthUser, + AuthApp, + CredentialsExtra, + ArtifactsExtra + >; + + /** + * Clears a response cookie using the same arguments as + * @param name of the cookie + * @param options (optional) ServerStateCookieOptions object. + * @return void. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-hunstatename-options) + */ + unstate(name: string, options?: ServerStateCookieOptions | undefined): void; +} diff --git a/lib/types/route.d.ts b/lib/types/route.d.ts new file mode 100644 index 000000000..f85cf00d8 --- /dev/null +++ b/lib/types/route.d.ts @@ -0,0 +1,982 @@ + +import { ObjectSchema, ValidationOptions, SchemaMap, Schema } from 'joi'; + +import { PluginSpecificConfiguration} from './plugin'; +import { MergeType, ReqRef, ReqRefDefaults, MergeRefs, AuthMode } from './request'; +import { ContentDecoders, ContentEncoders, RouteRequestExtType, RouteExtObject, Server } from './server'; +import { Lifecycle, Json, HTTP_METHODS } from './utils'; + +/** + * Overrides for `InternalRouteOptionType`. Extend this to have + * typings for route.options.auth['strategy' || 'scope'] + * + * @example + * + * interface RoutOptionTypes { + * Strategy: 'jwt' | 'basic' | 'myCustom' + * Scope: 'user' | 'admin' | 'manager-users' + * } + */ +export interface RouteOptionTypes { +} + +export interface InternalRouteOptionType { + Strategy: string; + Scope: RouteOptionsAccessScope; +} + +export type RouteOptionsAccessScope = false | string | string[]; + +export type AccessEntity = 'any' | 'user' | 'app'; + +export interface RouteOptionsAccessScopeObject { + scope: RouteOptionsAccessScope; +} + +export interface RouteOptionsAccessEntityObject { + entity: AccessEntity; +} + +export type RouteOptionsAccessObject = + RouteOptionsAccessScopeObject + | RouteOptionsAccessEntityObject + | (RouteOptionsAccessScopeObject & RouteOptionsAccessEntityObject); + +/** + * Route Authentication Options + */ +export interface RouteOptionsAccess { + /** + * @default none. + * An object or array of objects specifying the route access rules. Each rule is evaluated against an incoming request and access is granted if at least one of the rules matches. Each rule object + * must include at least one of scope or entity. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsauthaccess) + */ + access?: RouteOptionsAccessObject | RouteOptionsAccessObject[] | undefined; + + /** + * @default false (no scope requirements). + * The application scope required to access the route. Value can be a scope string or an array of scope strings. When authenticated, the credentials object scope property must contain at least + * one of the scopes defined to access the route. If a scope string begins with a + character, that scope is required. If a scope string begins with a ! character, that scope is forbidden. For + * example, the scope ['!a', '+b', 'c', 'd'] means the incoming request credentials' scope must not include 'a', must include 'b', and must include one of 'c' or 'd'. You may also access + * properties on the request object (query, params, payload, and credentials) to populate a dynamic scope by using the '{' and '}' characters around the property name, such as 'user-{params.id}'. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsauthaccessscope) + */ + scope?: MergeType['Scope'] | undefined; + + /** + * @default 'any'. + * The required authenticated entity type. If set, must match the entity value of the request authenticated credentials. Available values: + * * 'any' - the authentication can be on behalf of a user or application. + * * 'user' - the authentication must be on behalf of a user which is identified by the presence of a 'user' attribute in the credentials object returned by the authentication strategy. + * * 'app' - the authentication must be on behalf of an application which is identified by the lack of presence of a user attribute in the credentials object returned by the authentication + * strategy. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsauthaccessentity) + */ + entity?: AccessEntity | undefined; + + /** + * @default 'required'. + * The authentication mode. Available values: + * * 'required' - authentication is required. + * * 'optional' - authentication is optional - the request must include valid credentials or no credentials at all. + * * 'try' - similar to 'optional', any request credentials are attempted authentication, but if the credentials are invalid, the request proceeds regardless of the authentication error. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsauthmode) + */ + mode?: AuthMode | undefined; + + /** + * @default false, unless the scheme requires payload authentication. + * If set, the incoming request payload is authenticated after it is processed. Requires a strategy with payload authentication support (e.g. Hawk). Cannot be set to a value other than 'required' + * when the scheme sets the authentication options.payload to true. Available values: + * * false - no payload authentication. + * * 'required' - payload authentication required. + * * 'optional' - payload authentication performed only when the client includes payload authentication information (e.g. hash attribute in Hawk). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsauthpayload) + */ + payload?: false | 'required' | 'optional' | undefined; + + /** + * @default the default strategy set via server.auth.default(). + * An array of string strategy names in the order they should be attempted. Cannot be used together with strategy. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsauthstrategies) + */ + strategies?: (MergeType['Strategy'])[] | undefined; + + /** + * @default the default strategy set via server.auth.default(). + * A string strategy names. Cannot be used together with strategies. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsauthstrategy) + */ + strategy?: MergeType['Strategy'] | undefined; +} + +/** + * Values are: + * * * 'default' - no privacy flag. + * * * 'public' - mark the response as suitable for public caching. + * * * 'private' - mark the response as suitable only for private caching. + * * expiresIn - relative expiration expressed in the number of milliseconds since the item was saved in the cache. Cannot be used together with expiresAt. + * * expiresAt - time of day expressed in 24h notation using the 'HH:MM' format, at which point all cache records for the route expire. Cannot be used together with expiresIn. + * * statuses - an array of HTTP response status code numbers (e.g. 200) which are allowed to include a valid caching directive. + * * otherwise - a string with the value of the 'Cache-Control' header when caching is disabled. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionscache) + */ +export type RouteOptionsCache = { + privacy?: 'default' | 'public' | 'private' | undefined; + statuses?: number[] | undefined; + otherwise?: string | undefined; +} & ( + { + expiresIn?: number | undefined; + expiresAt?: undefined; + } | { + expiresIn?: undefined; + expiresAt?: string | undefined; +} | { + expiresIn?: undefined; + expiresAt?: undefined; +} + ); + +/** + * @default false (no CORS headers). + * The Cross-Origin Resource Sharing protocol allows browsers to make cross-origin API calls. CORS is required by web applications running inside a browser which are loaded from a different domain + * than the API server. To enable, set cors to true, or to an object with the following options: + * * origin - an array of allowed origin servers strings ('Access-Control-Allow-Origin'). The array can contain any combination of fully qualified origins along with origin strings containing a + * wildcard '*' character, or a single '*' origin string. If set to 'ignore', any incoming Origin header is ignored (present or not) and the 'Access-Control-Allow-Origin' header is set to '*'. + * Defaults to any origin ['*']. + * * maxAge - number of seconds the browser should cache the CORS response ('Access-Control-Max-Age'). The greater the value, the longer it will take before the browser checks for changes in policy. + * Defaults to 86400 (one day). + * * headers - a strings array of allowed headers ('Access-Control-Allow-Headers'). Defaults to ['Accept', 'Authorization', 'Content-Type', 'If-None-Match']. + * * additionalHeaders - a strings array of additional headers to headers. Use this to keep the default headers in place. + * * exposedHeaders - a strings array of exposed headers ('Access-Control-Expose-Headers'). Defaults to ['WWW-Authenticate', 'Server-Authorization']. + * * additionalExposedHeaders - a strings array of additional headers to exposedHeaders. Use this to keep the default headers in place. + * * credentials - if true, allows user credentials to be sent ('Access-Control-Allow-Credentials'). Defaults to false. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionscors) + */ +export interface RouteOptionsCors { + /** + * an array of allowed origin servers strings ('Access-Control-Allow-Origin'). The array can contain any combination of fully qualified origins along with origin strings containing a wildcard '*' + * character, or a single '*' origin string. If set to 'ignore', any incoming Origin header is ignored (present or not) and the 'Access-Control-Allow-Origin' header is set to '*'. Defaults to any + * origin ['*']. + */ + origin?: string[] | '*' | 'ignore' | undefined; + /** + * number of seconds the browser should cache the CORS response ('Access-Control-Max-Age'). The greater the value, the longer it will take before the browser checks for changes in policy. + * Defaults to 86400 (one day). + */ + maxAge?: number | undefined; + /** + * a strings array of allowed headers ('Access-Control-Allow-Headers'). Defaults to ['Accept', 'Authorization', 'Content-Type', 'If-None-Match']. + */ + headers?: string[] | undefined; + /** + * a strings array of additional headers to headers. Use this to keep the default headers in place. + */ + additionalHeaders?: string[] | undefined; + /** + * a strings array of exposed headers ('Access-Control-Expose-Headers'). Defaults to ['WWW-Authenticate', 'Server-Authorization']. + */ + exposedHeaders?: string[] | undefined; + /** + * a strings array of additional headers to exposedHeaders. Use this to keep the default headers in place. + */ + additionalExposedHeaders?: string[] | undefined; + /** + * if true, allows user credentials to be sent ('Access-Control-Allow-Credentials'). Defaults to false. + */ + credentials?: boolean | undefined; + /** + * the status code used for CORS preflight responses, either 200 or 204. Defaults to 200. + */ + preflightStatusCode?: 200 | 204; +} + +/** + * The value must be one of: + * * 'data' - the incoming payload is read fully into memory. If parse is true, the payload is parsed (JSON, form-decoded, multipart) based on the 'Content-Type' header. If parse is false, a raw + * Buffer is returned. + * * 'stream' - the incoming payload is made available via a Stream.Readable interface. If the payload is 'multipart/form-data' and parse is true, field values are presented as text while files are + * provided as streams. File streams from a 'multipart/form-data' upload will also have a hapi property containing the filename and headers properties. Note that payload streams for multipart + * payloads are a synthetic interface created on top of the entire multipart content loaded into memory. To avoid loading large multipart payloads into memory, set parse to false and handle the + * multipart payload in the handler using a streaming parser (e.g. pez). + * * 'file' - the incoming payload is written to temporary file in the directory specified by the uploads settings. If the payload is 'multipart/form-data' and parse is true, field values are + * presented as text while files are saved to disk. Note that it is the sole responsibility of the application to clean up the files generated by the framework. This can be done by keeping track of + * which files are used (e.g. using the request.app object), and listening to the server 'response' event to perform cleanup. For context [See + * docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadoutput) + */ +export type PayloadOutput = 'data' | 'stream' | 'file'; + +/** + * Determines how the request payload is processed. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayload) + */ +export interface RouteOptionsPayload { + /** + * @default allows parsing of the following mime types: + * * application/json + * * application/*+json + * * application/octet-stream + * * application/x-www-form-urlencoded + * * multipart/form-data + * * text/* + * A string or an array of strings with the allowed mime types for the endpoint. Use this settings to limit the set of allowed mime types. Note that allowing additional mime types not listed + * above will not enable them to be parsed, and if parse is true, the request will result in an error response. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadallow) + */ + allow?: string | string[] | undefined; + + /** + * @default none. + * An object where each key is a content-encoding name and each value is an object with the desired decoder settings. Note that encoder settings are set in compression. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadcompression) + */ + compression?: { [P in keyof ContentDecoders]?: Parameters[0] } | undefined; + + /** + * @default 'application/json'. + * The default content type if the 'Content-Type' request header is missing. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloaddefaultcontenttype) + */ + defaultContentType?: string | undefined; + + /** + * @default 'error' (return a Bad Request (400) error response). + * A failAction value which determines how to handle payload parsing errors. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadfailaction) + */ + failAction?: Lifecycle.FailAction | undefined; + + /** + * @default 1048576 (1MB). + * Limits the size of incoming payloads to the specified byte count. Allowing very large payloads may cause the server to run out of memory. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadmaxbytes) + */ + maxBytes?: number | undefined; + + /** + * @default 1000 + * Limits the number of parts allowed in multipart payloads. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadmaxparts) + */ + maxParts?: number; + + /** + * @default none. + * Overrides payload processing for multipart requests. Value can be one of: + * * false - disable multipart processing. + * an object with the following required options: + * * output - same as the output option with an additional value option: + * * * annotated - wraps each multipart part in an object with the following keys: // TODO type this? + * * * * headers - the part headers. + * * * * filename - the part file name. + * * * * payload - the processed part payload. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadmultipart) + */ + multipart?: boolean | { output: PayloadOutput | 'annotated' }; + + /** + * @default 'data'. + * The processed payload format. The value must be one of: + * * 'data' - the incoming payload is read fully into memory. If parse is true, the payload is parsed (JSON, form-decoded, multipart) based on the 'Content-Type' header. If parse is false, a raw + * Buffer is returned. + * * 'stream' - the incoming payload is made available via a Stream.Readable interface. If the payload is 'multipart/form-data' and parse is true, field values are presented as text while files + * are provided as streams. File streams from a 'multipart/form-data' upload will also have a hapi property containing the filename and headers properties. Note that payload streams for multipart + * payloads are a synthetic interface created on top of the entire multipart content loaded into memory. To avoid loading large multipart payloads into memory, set parse to false and handle the + * multipart payload in the handler using a streaming parser (e.g. pez). + * * 'file' - the incoming payload is written to temporary file in the directory specified by the uploads settings. If the payload is 'multipart/form-data' and parse is true, field values are + * presented as text while files are saved to disk. Note that it is the sole responsibility of the application to clean up the files generated by the framework. This can be done by keeping track + * of which files are used (e.g. using the request.app object), and listening to the server 'response' event to perform cleanup. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadoutput) + */ + output?: PayloadOutput | undefined; + + /** + * @default none. + * A mime type string overriding the 'Content-Type' header value received. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadoverride) + */ + override?: string | undefined; + + /** + * @default true. + * Determines if the incoming payload is processed or presented raw. Available values: + * * true - if the request 'Content-Type' matches the allowed mime types set by allow (for the whole payload as well as parts), the payload is converted into an object when possible. If the + * format is unknown, a Bad Request (400) error response is sent. Any known content encoding is decoded. + * * false - the raw payload is returned unmodified. + * * 'gunzip' - the raw payload is returned unmodified after any known content encoding is decoded. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadparse) + */ + parse?: boolean | 'gunzip' | undefined; + + /** + * @default to 'error'. + * Sets handling of incoming payload that may contain a prototype poisoning security attack. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadprotoaction) + */ + protoAction?: 'error' | 'remove' | 'ignore'; + + /** + * @default to 10000 (10 seconds). + * Payload reception timeout in milliseconds. Sets the maximum time allowed for the client to transmit the request payload (body) before giving up and responding with a Request Timeout (408) + * error response. Set to false to disable. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloadtimeout) + */ + timeout?: false | number | undefined; + + /** + * @default os.tmpdir(). + * The directory used for writing file uploads. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayloaduploads) + */ + uploads?: string | undefined; +} + +/** + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspre) + */ +export type RouteOptionsPreArray = RouteOptionsPreAllOptions[]; + +/** + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspre) + */ +export type RouteOptionsPreAllOptions = RouteOptionsPreObject | RouteOptionsPreObject[] | Lifecycle.Method; + +/** + * An object with: + * * method - a lifecycle method. + * * assign - key name used to assign the response of the method to in request.pre and request.preResponses. + * * failAction - A failAction value which determine what to do when a pre-handler method throws an error. If assign is specified and the failAction setting is not 'error', the error will be assigned. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspre) + */ +export interface RouteOptionsPreObject { + /** + * a lifecycle method. + */ + method: Lifecycle.Method; + /** + * key name used to assign the response of the method to in request.pre and request.preResponses. + */ + assign?: keyof MergeRefs['Pres'] | undefined; + /** + * A failAction value which determine what to do when a pre-handler method throws an error. If assign is specified and the failAction setting is not 'error', the error will be assigned. + */ + failAction?: Lifecycle.FailAction | undefined; +} + +export type ValidationObject = SchemaMap; + +/** + * * true - any query parameter value allowed (no validation performed). false - no parameter value allowed. + * * a joi validation object. + * * a validation function using the signature async function(value, options) where: + * * * value - the request.* object containing the request parameters. + * * * options - options. + */ +export type RouteOptionsResponseSchema = + boolean + | ValidationObject + | Schema + | ((value: object | Buffer | string, options: ValidationOptions) => Promise); + +/** + * Processing rules for the outgoing response. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponse) + */ +export interface RouteOptionsResponse { + /** + * @default 204. + * The default HTTP status code when the payload is considered empty. Value can be 200 or 204. Note that a 200 status code is converted to a 204 only at the time of response transmission (the + * response status code will remain 200 throughout the request lifecycle unless manually set). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponseemptystatuscode) + */ + emptyStatusCode?: 200 | 204 | undefined; + + /** + * @default 'error' (return an Internal Server Error (500) error response). + * A failAction value which defines what to do when a response fails payload validation. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponsefailaction) + */ + failAction?: Lifecycle.FailAction | undefined; + + /** + * @default false. + * If true, applies the validation rule changes to the response payload. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponsemodify) + */ + modify?: boolean | undefined; + + /** + * @default none. + * [joi](https://github.com/hapijs/joi) options object pass to the validation function. Useful to set global options such as stripUnknown or abortEarly (the complete list is available here). If a + * custom validation function is defined via schema or status then options can an arbitrary object that will be passed to this function as the second argument. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponseoptions) + */ + options?: ValidationOptions | undefined; // TODO needs validation + + /** + * @default true. + * If false, payload range support is disabled. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponseranges) + */ + ranges?: boolean | undefined; + + /** + * @default 100 (all responses). + * The percent of response payloads validated (0 - 100). Set to 0 to disable all validation. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponsesample) + */ + sample?: number | undefined; + + /** + * @default true (no validation). + * The default response payload validation rules (for all non-error responses) expressed as one of: + * * true - any payload allowed (no validation). + * * false - no payload allowed. + * * a joi validation object. The options along with the request context ({ headers, params, query, payload, app, auth }) are passed to the validation function. + * * a validation function using the signature async function(value, options) where: + * * * value - the pending response payload. + * * * options - The options along with the request context ({ headers, params, query, payload, app, auth }). + * * * if the function returns a value and modify is true, the value is used as the new response. If the original response is an error, the return value is used to override the original error + * output.payload. If an error is thrown, the error is processed according to failAction. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponseschema) + */ + schema?: RouteOptionsResponseSchema | undefined; + + /** + * @default none. + * Validation schemas for specific HTTP status codes. Responses (excluding errors) not matching the listed status codes are validated using the default schema. + * status is set to an object where each key is a 3 digit HTTP status code and the value has the same definition as schema. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponsestatus) + */ + status?: Record | undefined; + + /** + * The default HTTP status code used to set a response error when the request is closed or aborted before the + * response is fully transmitted. + * Value can be any integer greater or equal to 400. + * The default value 499 is based on the non-standard nginx "CLIENT CLOSED REQUEST" error. + * The value is only used for logging as the request has already ended. + * @default 499 + */ + disconnectStatusCode?: number | undefined; +} + +/** + * @see https://www.w3.org/TR/referrer-policy/ + */ +export type ReferrerPolicy = '' | 'no-referrer' | 'no-referrer-when-downgrade' | 'unsafe-url' | + 'same-origin' | 'origin' | 'strict-origin' | 'origin-when-cross-origin' | 'strict-origin-when-cross-origin'; + +/** + * @default false (security headers disabled). + * Sets common security headers. To enable, set security to true or to an object with the following options: + * * hsts - controls the 'Strict-Transport-Security' header, where: + * * * true - the header will be set to max-age=15768000. This is the default value. + * * * a number - the maxAge parameter will be set to the provided value. + * * * an object with the following fields: + * * * * maxAge - the max-age portion of the header, as a number. Default is 15768000. + * * * * includeSubDomains - a boolean specifying whether to add the includeSubDomains flag to the header. + * * * * preload - a boolean specifying whether to add the 'preload' flag (used to submit domains inclusion in Chrome's HTTP Strict Transport Security (HSTS) preload list) to the header. + * * xframe - controls the 'X-Frame-Options' header, where: + * * * true - the header will be set to 'DENY'. This is the default value. + * * * 'deny' - the headers will be set to 'DENY'. + * * * 'sameorigin' - the headers will be set to 'SAMEORIGIN'. + * * * an object for specifying the 'allow-from' rule, where: + * * * * rule - one of: + * * * * * 'deny' + * * * * * 'sameorigin' + * * * * * 'allow-from' + * * * * source - when rule is 'allow-from' this is used to form the rest of the header, otherwise this field is ignored. If rule is 'allow-from' but source is unset, the rule will be automatically + * changed to 'sameorigin'. + * * xss - controls the 'X-XSS-Protection' header, where: + * * * 'disabled' - the header will be set to '0'. This is the default value. + * * * 'enabled' - the header will be set to '1; mode=block'. + * * * false - the header will be omitted + * * noOpen - boolean controlling the 'X-Download-Options' header for Internet Explorer, preventing downloads from executing in your context. Defaults to true setting the header to 'noopen'. + * * noSniff - boolean controlling the 'X-Content-Type-Options' header. Defaults to true setting the header to its only and default option, 'nosniff'. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionssecurity) + */ +export interface RouteOptionsSecureObject { + /** + * hsts - controls the 'Strict-Transport-Security' header + */ + hsts?: boolean | number | { + /** + * the max-age portion of the header, as a number. Default is 15768000. + */ + maxAge?: number; + /** + * a boolean specifying whether to add the includeSubDomains flag to the header. + */ + includeSubDomains?: boolean; + /** + * a boolean specifying whether to add the 'preload' flag (used to submit domains inclusion in Chrome's HTTP Strict Transport Security (HSTS) preload list) to the header. + */ + preload?: boolean; + } | undefined; + /** + * controls the 'X-Frame-Options' header + */ + xframe?: true | 'deny' | 'sameorigin' | { + /** + * an object for specifying the 'allow-from' rule, + */ + rule: 'deny' | 'sameorigin' | 'allow-from'; + /** + * when rule is 'allow-from' this is used to form the rest of the header, otherwise this field is ignored. If rule is 'allow-from' but source is unset, the rule will be automatically changed + * to 'sameorigin'. + */ + source: string; + } | undefined; + /** + * controls the 'X-XSS-Protection' header, where: + * * 'disabled' - the header will be set to '0'. This is the default value. + * * 'enabled' - the header will be set to '1; mode=block'. + * * false - the header will be omitted + */ + xss?: 'disabled' | 'enabled' | false | undefined; + /** + * boolean controlling the 'X-Download-Options' header for Internet Explorer, preventing downloads from executing in your context. Defaults to true setting the header to 'noopen'. + */ + noOpen?: boolean | undefined; + /** + * boolean controlling the 'X-Content-Type-Options' header. Defaults to true setting the header to its only and default option, 'nosniff'. + */ + noSniff?: boolean | undefined; + + /** + * Controls the `Referrer-Policy` header, which has the following possible values. + * @default false Header will not be send. + */ + referrer?: false | ReferrerPolicy | undefined; +} + +export type RouteOptionsSecure = boolean | RouteOptionsSecureObject; + +/** + * @default { headers: true, params: true, query: true, payload: true, failAction: 'error' }. + * Request input validation rules for various request components. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidate) + */ +export interface RouteOptionsValidate { + /** + * @default none. + * An optional object with error fields copied into every validation error response. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidateerrorfields) + */ + errorFields?: object | undefined; + + /** + * @default 'error' (return a Bad Request (400) error response). + * A failAction value which determines how to handle failed validations. When set to a function, the err argument includes the type of validation error under err.output.payload.validation.source. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidatefailaction) + */ + failAction?: Lifecycle.FailAction | undefined; + + /** + * Validation rules for incoming request headers: + * * If a value is returned, the value is used as the new request.headers value and the original value is stored in request.orig.headers. Otherwise, the headers are left unchanged. If an error + * is thrown, the error is handled according to failAction. Note that all header field names must be in lowercase to match the headers normalized by node. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidateheaders) + * @default true + */ + headers?: RouteOptionsResponseSchema | undefined; + + /** + * An options object passed to the joi rules or the custom validation methods. Used for setting global options such as stripUnknown or abortEarly (the complete list is available here). + * If a custom validation function (see headers, params, query, or payload above) is defined then options can an arbitrary object that will be passed to this function as the second parameter. + * The values of the other inputs (i.e. headers, query, params, payload, app, and auth) are added to the options object under the validation context (accessible in rules as + * Joi.ref('$query.key')). + * Note that validation is performed in order (i.e. headers, params, query, and payload) and if type casting is used (e.g. converting a string to a number), the value of inputs not yet validated + * will reflect the raw, unvalidated and unmodified values. If the validation rules for headers, params, query, and payload are defined at both the server routes level and at the route level, the + * individual route settings override the routes defaults (the rules are not merged). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidateparams) + * @default true + */ + options?: ValidationOptions | object | undefined; + + /** + * Validation rules for incoming request path parameters, after matching the path against the route, extracting any parameters, and storing them in request.params, where: + * * true - any path parameter value allowed (no validation performed). + * * a joi validation object. + * * a validation function using the signature async function(value, options) where: + * * * value - the request.params object containing the request path parameters. + * * * options - options. + * if a value is returned, the value is used as the new request.params value and the original value is stored in request.orig.params. Otherwise, the path parameters are left unchanged. If an + * error is thrown, the error is handled according to failAction. Note that failing to match the validation rules to the route path parameters definition will cause all requests to fail. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidateparams) + * @default true + */ + params?: RouteOptionsResponseSchema | undefined; + + /** + * Validation rules for incoming request payload (request body), where: + * * If a value is returned, the value is used as the new request.payload value and the original value is stored in request.orig.payload. Otherwise, the payload is left unchanged. If an error is + * thrown, the error is handled according to failAction. Note that validating large payloads and modifying them will cause memory duplication of the payload (since the original is kept), as well + * as the significant performance cost of validating large amounts of data. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidatepayload) + * @default true + */ + payload?: RouteOptionsResponseSchema | undefined; + + /** + * Validation rules for incoming request URI query component (the key-value part of the URI between '?' and '#'). The query is parsed into its individual key-value pairs, decoded, and stored in + * request.query prior to validation. Where: + * * If a value is returned, the value is used as the new request.query value and the original value is stored in request.orig.query. Otherwise, the query parameters are left unchanged. + * If an error + * is thrown, the error is handled according to failAction. Note that changes to the query parameters will not be reflected in request.url. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidatequery) + * @default true + */ + query?: RouteOptionsResponseSchema | undefined; + + /** + * Validation rules for incoming cookies. + * The cookie header is parsed and decoded into the request.state prior to validation. + * @default true + */ + state?: RouteOptionsResponseSchema | undefined; +} + +export interface CommonRouteProperties { + /** + * Application-specific route configuration state. Should not be used by plugins which should use options.plugins[name] instead. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsapp) + */ + app?: MergeRefs['RouteApp'] | undefined; + + /** + * @default null. + * An object passed back to the provided handler (via this) when called. Ignored if the method is an arrow function. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsbind) + */ + bind?: MergeRefs['Bind'] | undefined; + + /** + * @default { privacy: 'default', statuses: [200], otherwise: 'no-cache' }. + * If the route method is 'GET', the route can be configured to include HTTP caching directives in the response. Caching can be customized using an object with the following options: + * privacy - determines the privacy flag included in client-side caching using the 'Cache-Control' header. Values are: + * * * 'default' - no privacy flag. + * * * 'public' - mark the response as suitable for public caching. + * * * 'private' - mark the response as suitable only for private caching. + * * expiresIn - relative expiration expressed in the number of milliseconds since the item was saved in the cache. Cannot be used together with expiresAt. + * * expiresAt - time of day expressed in 24h notation using the 'HH:MM' format, at which point all cache records for the route expire. Cannot be used together with expiresIn. + * * statuses - an array of HTTP response status code numbers (e.g. 200) which are allowed to include a valid caching directive. + * * otherwise - a string with the value of the 'Cache-Control' header when caching is disabled. + * The default Cache-Control: no-cache header can be disabled by setting cache to false. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionscache) + */ + cache?: false | RouteOptionsCache | undefined; + + /** + * An object where each key is a content-encoding name and each value is an object with the desired encoder settings. Note that decoder settings are set in compression. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionscompression) + */ + compression?: { [P in keyof ContentEncoders]?: Parameters[0] } | undefined; + + /** + * @default false (no CORS headers). + * The Cross-Origin Resource Sharing protocol allows browsers to make cross-origin API calls. CORS is required by web applications running inside a browser which are loaded from a different + * domain than the API server. To enable, set cors to true, or to an object with the following options: + * * origin - an array of allowed origin servers strings ('Access-Control-Allow-Origin'). The array can contain any combination of fully qualified origins along with origin strings containing a + * wildcard '*' character, or a single '*' origin string. If set to 'ignore', any incoming Origin header is ignored (present or not) and the 'Access-Control-Allow-Origin' header is set to '*'. + * Defaults to any origin ['*']. + * * maxAge - number of seconds the browser should cache the CORS response ('Access-Control-Max-Age'). The greater the value, the longer it will take before the browser checks for changes in + * policy. Defaults to 86400 (one day). + * * headers - a strings array of allowed headers ('Access-Control-Allow-Headers'). Defaults to ['Accept', 'Authorization', 'Content-Type', 'If-None-Match']. + * * additionalHeaders - a strings array of additional headers to headers. Use this to keep the default headers in place. + * * exposedHeaders - a strings array of exposed headers ('Access-Control-Expose-Headers'). Defaults to ['WWW-Authenticate', 'Server-Authorization']. + * * additionalExposedHeaders - a strings array of additional headers to exposedHeaders. Use this to keep the default headers in place. + * * credentials - if true, allows user credentials to be sent ('Access-Control-Allow-Credentials'). Defaults to false. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionscors) + */ + cors?: boolean | RouteOptionsCors | undefined; + + /** + * @default none. + * Route description used for generating documentation (string). + * This setting is not available when setting server route defaults using server.options.routes. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsdescription) + */ + description?: string | undefined; + + /** + * @default none. + * Route-level request extension points by setting the option to an object with a key for each of the desired extension points ('onRequest' is not allowed), and the value is the same as the + * server.ext(events) event argument. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsext) + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#request-lifecycle) + */ + ext?: { + [key in RouteRequestExtType]?: RouteExtObject | RouteExtObject[] | undefined; + } | undefined; + + /** + * @default { relativeTo: '.' }. + * Defines the behavior for accessing files: + * * relativeTo - determines the folder relative paths are resolved against. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsfiles) + */ + files?: { + relativeTo: string; + } | undefined; + + /** + * @default none. + * The route handler function performs the main business logic of the route and sets the response. handler can be assigned: + * * a lifecycle method. + * * an object with a single property using the name of a handler type registered with the server.handler() method. The matching property value is passed as options to the registered handler + * generator. Note: handlers using a fat arrow style function cannot be bound to any bind property. Instead, the bound context is available under h.context. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionshandler) + */ + handler?: Lifecycle.Method | object | undefined; + + /** + * @default none. + * An optional unique identifier used to look up the route using server.lookup(). Cannot be assigned to routes added with an array of methods. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsid) + */ + id?: string | undefined; + + /** + * @default false. + * If true, the route cannot be accessed through the HTTP listener but only through the server.inject() interface with the allowInternals option set to true. Used for internal routes that should + * not be accessible to the outside world. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsisinternal) + */ + isInternal?: boolean | undefined; + + /** + * @default none. + * Optional arguments passed to JSON.stringify() when converting an object or error response to a string payload or escaping it after stringification. Supports the following: + * * replacer - the replacer function or array. Defaults to no action. + * * space - number of spaces to indent nested object keys. Defaults to no indentation. + * * suffix - string suffix added after conversion to JSON string. Defaults to no suffix. + * * escape - calls Hoek.jsonEscape() after conversion to JSON string. Defaults to false. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsjson) + */ + json?: Json.StringifyArguments | undefined; + + /** + * @default { collect: false }. + * Request logging options: + * collect - if true, request-level logs (both internal and application) are collected and accessible via request.logs. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionslog) + */ + log?: { + collect: boolean; + } | undefined; + + /** + * @default none. + * Route notes used for generating documentation (string or array of strings). + * This setting is not available when setting server route defaults using server.options.routes. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsnotes) + */ + notes?: string | string[] | undefined; + + /** + * Determines how the request payload is processed. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspayload) + */ + payload?: RouteOptionsPayload | undefined; + + /** + * @default {}. + * Plugin-specific configuration. plugins is an object where each key is a plugin name and the value is the plugin configuration. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsplugins) + */ + plugins?: PluginSpecificConfiguration | undefined; + + /** + * @default none. + * The pre option allows defining methods for performing actions before the handler is called. These methods allow breaking the handler logic into smaller, reusable components that can be shared + * across routes, as well as provide a cleaner error handling of prerequisite operations (e.g. load required reference data from a database). pre is assigned an ordered array of methods which + * are called serially in order. If the pre array contains another array of methods as one of its elements, those methods are called in parallel. Note that during parallel execution, if any of + * the methods error, return a takeover response, or abort signal, the other parallel methods will continue to execute but will be ignored once completed. pre can be assigned a mixed array of: + * * an array containing the elements listed below, which are executed in parallel. + * * an object with: + * * * method - a lifecycle method. + * * * assign - key name used to assign the response of the method to in request.pre and request.preResponses. + * * * failAction - A failAction value which determine what to do when a pre-handler method throws an error. If assign is specified and the failAction setting is not 'error', the error will be + * assigned. + * * a method function - same as including an object with a single method key. + * Note that pre-handler methods do not behave the same way other lifecycle methods do when a value is returned. Instead of the return value becoming the new response payload, the value is used + * to assign the corresponding request.pre and request.preResponses properties. Otherwise, the handling of errors, takeover response response, or abort signal behave the same as any other + * lifecycle methods. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionspre) + */ + pre?: RouteOptionsPreArray | undefined; + + /** + * Processing rules for the outgoing response. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsresponse) + */ + response?: RouteOptionsResponse | undefined; + + /** + * @default false (security headers disabled). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionssecurity) + */ + security?: RouteOptionsSecure | undefined; + + /** + * @default { parse: true, failAction: 'error' }. + * HTTP state management (cookies) allows the server to store information on the client which is sent back to the server with every request (as defined in RFC 6265). state supports the following + * options: parse - determines if incoming 'Cookie' headers are parsed and stored in the request.state object. failAction - A failAction value which determines how to handle cookie parsing + * errors. Defaults to 'error' (return a Bad Request (400) error response). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsstate) + */ + state?: { + parse?: boolean | undefined; + failAction?: Lifecycle.FailAction | undefined; + } | undefined; + + /** + * @default none. + * Route tags used for generating documentation (array of strings). + * This setting is not available when setting server route defaults using server.options.routes. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionstags) + */ + tags?: string[] | undefined; + + /** + * @default { server: false }. + * Timeouts for processing durations. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionstimeout) + */ + timeout?: { + /** + * Response timeout in milliseconds. Sets the maximum time allowed for the server to respond to an incoming request before giving up and responding with a Service Unavailable (503) error + * response. + */ + server?: boolean | number | undefined; + + /** + * @default none (use node default of 2 minutes). + * By default, node sockets automatically timeout after 2 minutes. Use this option to override this behavior. Set to false to disable socket timeouts. + */ + socket?: boolean | number | undefined; + } | undefined; + + /** + * @default { headers: true, params: true, query: true, payload: true, failAction: 'error' }. + * Request input validation rules for various request components. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsvalidate) + */ + validate?: RouteOptionsValidate | undefined; +} + +export interface AccessScopes { + forbidden?: string[] | undefined; + required?: string[] | undefined; + selection?: string[] | undefined; +} + +export interface AccessSetting { + entity?: AccessEntity | undefined; + scope: AccessScopes | false; +} + +export interface AuthSettings { + strategies: string[]; + mode: AuthMode; + access?: AccessSetting[] | undefined; +} + +export interface RouteSettings extends CommonRouteProperties { + auth?: AuthSettings | undefined; +} + +/** + * Each route can be customized to change the default behavior of the request lifecycle. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#route-options) + */ +export interface RouteOptions extends CommonRouteProperties { + /** + * Route authentication configuration. Value can be: + * false to disable authentication if a default strategy is set. + * a string with the name of an authentication strategy registered with server.auth.strategy(). The strategy will be set to 'required' mode. + * an authentication configuration object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsapp) + */ + auth?: false | string | RouteOptionsAccess | undefined; +} + +export interface HandlerDecorations {} + +export interface RouteRules {} + +export interface RulesInfo { + method: string; + path: string; + vhost: string; +} + +export interface RulesOptions { + validate: { + schema?: ObjectSchema['Rules']> | Record['Rules'], Schema> | undefined; + options?: ValidationOptions | undefined; + }; +} + +export interface RulesProcessor { + (rules: MergeRefs['Rules'] | null, info: RulesInfo): Partial> | null; +} + +type RouteDefMethods = Exclude, 'HEAD' | 'head'>; + +/** + * A route configuration object or an array of configuration objects where each object contains: + * * path - (required) the absolute path used to match incoming requests (must begin with '/'). Incoming requests are compared to the configured paths based on the server's router configuration. The + * path can include named parameters enclosed in {} which will be matched against literal values in the request as described in Path parameters. + * * method - (required) the HTTP method. Typically one of 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', or 'OPTIONS'. Any HTTP method is allowed, except for 'HEAD'. Use '*' to match against any HTTP + * method (only when an exact match was not found, and any match with a specific method will be given a higher priority over a wildcard match). Can be assigned an array of methods which has the same + * result as adding the same route with different methods manually. + * * vhost - (optional) a domain string or an array of domain strings for limiting the route to only requests with a matching host header field. Matching is done against the hostname part of the + * header only (excluding the port). Defaults to all hosts. + * * handler - (required when handler is not set) the route handler function called to generate the response after successful authentication and validation. + * * options - additional route options. The options value can be an object or a function that returns an object using the signature function(server) where server is the server the route is being + * added to and this is bound to the current realm's bind option. + * * rules - route custom rules object. The object is passed to each rules processor registered with server.rules(). Cannot be used if route.options.rules is defined. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverrouteroute) + */ +export interface ServerRoute { + /** + * (required) the absolute path used to match incoming requests (must begin with '/'). Incoming requests are compared to the configured paths based on the server's router configuration. The path + * can include named parameters enclosed in {} which will be matched against literal values in the request as described in Path parameters. For context [See + * docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverrouteroute) For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#path-parameters) + */ + path: string; + + /** + * (required) the HTTP method. Typically one of 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', or 'OPTIONS'. Any HTTP method is allowed, except for 'HEAD'. Use '*' to match against any HTTP method + * (only when an exact match was not found, and any match with a specific method will be given a higher priority over a wildcard match). Can be assigned an array of methods which has the same + * result as adding the same route with different methods manually. + */ + method: RouteDefMethods | RouteDefMethods[] | '*'; + + /** + * (optional) a domain string or an array of domain strings for limiting the route to only requests with a matching host header field. Matching is done against the hostname part of the header + * only (excluding the port). Defaults to all hosts. + */ + vhost?: string | string[] | undefined; + + /** + * (required when handler is not set) the route handler function called to generate the response after successful authentication and validation. + */ + handler?: Lifecycle.Method | HandlerDecorations | undefined; + + /** + * additional route options. The options value can be an object or a function that returns an object using the signature function(server) where server is the server the route is being added to + * and this is bound to the current realm's bind option. + */ + options?: RouteOptions | ((server: Server) => RouteOptions) | undefined; + + /** + * route custom rules object. The object is passed to each rules processor registered with server.rules(). Cannot be used if route.options.rules is defined. + */ + rules?: MergeRefs['Rules'] | undefined; +} diff --git a/lib/types/server/auth.d.ts b/lib/types/server/auth.d.ts new file mode 100644 index 000000000..afc6ba92e --- /dev/null +++ b/lib/types/server/auth.d.ts @@ -0,0 +1,201 @@ +import { Server } from './server'; +import { + MergeType, + ReqRef, + ReqRefDefaults, + MergeRefs, + Request, + RequestAuth} from '../request'; +import { ResponseToolkit, AuthenticationData } from '../response'; +import { RouteOptionsAccess, InternalRouteOptionType, RouteOptionTypes} from '../route'; +import { Lifecycle } from '../utils'; + +/** + * The scheme options argument passed to server.auth.strategy() when instantiation a strategy. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverauthschemename-scheme) + */ +export type ServerAuthSchemeOptions = object; + +/** + * the method implementing the scheme with signature function(server, options) where: + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverauthschemename-scheme) + * @param server - a reference to the server object the scheme is added to. + * @param options - (optional) the scheme options argument passed to server.auth.strategy() when instantiation a strategy. + */ +export type ServerAuthScheme< + // tslint:disable-next-line no-unnecessary-generics + Options extends ServerAuthSchemeOptions = ServerAuthSchemeOptions, + // tslint:disable-next-line no-unnecessary-generics + Refs extends ReqRef = ReqRefDefaults +> = (server: Server, options?: Options) => ServerAuthSchemeObject; + +export interface ServerAuthSchemeObjectApi {} + +/** + * The scheme method must return an object with the following + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#authentication-scheme) + */ + +export interface ServerAuthSchemeObject { + /** + * optional object which is exposed via the [server.auth.api](https://github.com/hapijs/hapi/blob/master/API.md#server.auth.api) object. + */ + api?: MergeRefs['AuthApi'] | undefined; + + /** + * A lifecycle method function called for each incoming request configured with the authentication scheme. The + * method is provided with two special toolkit methods for returning an authenticated or an unauthenticated result: + * * h.authenticated() - indicate request authenticated successfully. + * * h.unauthenticated() - indicate request failed to authenticate. + * @param request the request object. + * @param h the ResponseToolkit + * @return the Lifecycle.ReturnValue + */ + authenticate(request: Request, h: ResponseToolkit): Lifecycle.ReturnValue; + + /** + * A lifecycle method to authenticate the request payload. + * When the scheme payload() method returns an error with a message, it means payload validation failed due to bad + * payload. If the error has no message but includes a scheme name (e.g. Boom.unauthorized(null, 'Custom')), + * authentication may still be successful if the route auth.payload configuration is set to 'optional'. + * @param request the request object. + * @param h the ResponseToolkit + * @return the Lifecycle.ReturnValue + */ + payload?(request: Request, h: ResponseToolkit): Lifecycle.ReturnValue; + + /** + * A lifecycle method to decorate the response with authentication headers before the response headers or payload is written. + * @param request the request object. + * @param h the ResponseToolkit + * @return the Lifecycle.ReturnValue + */ + response?(request: Request, h: ResponseToolkit): Lifecycle.ReturnValue; + + /** + * a method used to verify the authentication credentials provided + * are still valid (e.g. not expired or revoked after the initial authentication). + * the method throws an `Error` when the credentials passed are no longer valid (e.g. expired or + * revoked). Note that the method does not have access to the original request, only to the + * credentials and artifacts produced by the `authenticate()` method. + */ + verify?( + auth: RequestAuth< + MergeRefs['AuthUser'], + MergeRefs['AuthApp'], + MergeRefs['AuthCredentialsExtra'], + MergeRefs['AuthArtifactsExtra'] + > + ): Promise; + + /** + * An object with the following keys: + * * payload + */ + options?: { + /** + * if true, requires payload validation as part of the scheme and forbids routes from disabling payload auth validation. Defaults to false. + */ + payload?: boolean | undefined; + } | undefined; +} + +/** + * An authentication configuration object using the same format as the route auth handler options. + * For reference [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverauthdefaultoptions) + */ + +export interface ServerAuthConfig extends RouteOptionsAccess { +} + +export interface ServerAuth { + /** + * An object where each key is an authentication strategy name and the value is the exposed strategy API. + * Available only when the authentication scheme exposes an API by returning an api key in the object + * returned from its implementation function. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverauthapi) + */ + api: Record; + + /** + * Contains the default authentication configuration is a default strategy was set via + * [server.auth.default()](https://github.com/hapijs/hapi/blob/master/API.md#server.auth.default()). + */ + readonly settings: { + default: ServerAuthConfig; + }; + + /** + * Sets a default strategy which is applied to every route where: + * @param options - one of: + * * a string with the default strategy name + * * an authentication configuration object using the same format as the route auth handler options. + * @return void. + * The default does not apply when a route config specifies auth as false, or has an authentication strategy + * configured (contains the strategy or strategies authentication settings). Otherwise, the route authentication + * config is applied to the defaults. + * Note that if the route has authentication configured, the default only applies at the time of adding the route, + * not at runtime. This means that calling server.auth.default() after adding a route with some authentication + * config will have no impact on the routes added prior. However, the default will apply to routes added + * before server.auth.default() is called if those routes lack any authentication config. + * The default auth strategy configuration can be accessed via server.auth.settings.default. To obtain the active + * authentication configuration of a route, use server.auth.lookup(request.route). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverauthdefaultoptions) + */ + default(options: string | ServerAuthConfig): void; + + /** + * Registers an authentication scheme where: + * @param name the scheme name. + * @param scheme - the method implementing the scheme with signature function(server, options) where: + * * server - a reference to the server object the scheme is added to. + * * options - (optional) the scheme options argument passed to server.auth.strategy() when instantiation a strategy. + * @return void. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverauthschemename-scheme) + */ + + scheme < + Refs extends ReqRef = ReqRefDefaults, + Options extends object = {} + // tslint:disable-next-line no-unnecessary-generics + >(name: string, scheme: ServerAuthScheme): void; + + /** + * Registers an authentication strategy where: + * @param name - the strategy name. + * @param scheme - the scheme name (must be previously registered using server.auth.scheme()). + * @param options - scheme options based on the scheme requirements. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverauthstrategyname-scheme-options) + */ + strategy( + name: MergeType['Strategy'], + scheme: string, + options?: object + ): void; + + /** + * Tests a request against an authentication strategy where: + * @param strategy - the strategy name registered with server.auth.strategy(). + * @param request - the request object. + * @return an object containing the authentication credentials and artifacts if authentication was successful, otherwise throws an error. + * Note that the test() method does not take into account the route authentication configuration. It also does not + * perform payload authentication. It is limited to the basic strategy authentication execution. It does not + * include verifying scope, entity, or other route properties. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverauthteststrategy-request) + */ + test(strategy: string, request: Request): Promise; + + /** + * Verify a request's authentication credentials against an authentication strategy. + * Returns nothing if verification was successful, otherwise throws an error. + * + * Note that the `verify()` method does not take into account the route authentication configuration + * or any other information from the request other than the `request.auth` object. It also does not + * perform payload authentication. It is limited to verifying that the previously valid credentials + * are still valid (e.g. have not been revoked or expired). It does not include verifying scope, + * entity, or other route properties. + */ + // tslint:disable-next-line no-unnecessary-generics + verify (request: Request): Promise; +} diff --git a/lib/types/server/cache.d.ts b/lib/types/server/cache.d.ts new file mode 100644 index 000000000..f2b2860b2 --- /dev/null +++ b/lib/types/server/cache.d.ts @@ -0,0 +1,85 @@ +import { PolicyOptionVariants, Policy, ClientApi, ClientOptions, EnginePrototype, PolicyOptions } from '@hapi/catbox'; + +export type CachePolicyOptions = PolicyOptionVariants & { + /** + * @default '_default' + */ + cache?: string | undefined; + segment?: string | undefined; +}; + +/** + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servercacheoptions) + */ +export interface ServerCache { + /** + * Provisions a cache segment within the server cache facility where: + * @param options - [catbox policy](https://github.com/hapijs/catbox#policy) configuration where: + * * expiresIn - relative expiration expressed in the number of milliseconds since the item was saved in the cache. Cannot be used together with expiresAt. + * * expiresAt - time of day expressed in 24h notation using the 'HH:MM' format, at which point all cache records expire. Uses local time. Cannot be used together with expiresIn. + * * generateFunc - a function used to generate a new cache item if one is not found in the cache when calling get(). The method's signature is async function(id, flags) where: + * - `id` - the `id` string or object provided to the `get()` method. + * - `flags` - an object used to pass back additional flags to the cache where: + * - `ttl` - the cache ttl value in milliseconds. Set to `0` to skip storing in the cache. Defaults to the cache global policy. + * * staleIn - number of milliseconds to mark an item stored in cache as stale and attempt to regenerate it when generateFunc is provided. Must be less than expiresIn. + * * staleTimeout - number of milliseconds to wait before checking if an item is stale. + * * generateTimeout - number of milliseconds to wait before returning a timeout error when the generateFunc function takes too long to return a value. When the value is eventually returned, it + * is stored in the cache for future requests. Required if generateFunc is present. Set to false to disable timeouts which may cause all get() requests to get stuck forever. + * * generateOnReadError - if false, an upstream cache read error will stop the cache.get() method from calling the generate function and will instead pass back the cache error. Defaults to true. + * * generateIgnoreWriteError - if false, an upstream cache write error when calling cache.get() will be passed back with the generated value when calling. Defaults to true. + * * dropOnError - if true, an error or timeout in the generateFunc causes the stale value to be evicted from the cache. Defaults to true. + * * pendingGenerateTimeout - number of milliseconds while generateFunc call is in progress for a given id, before a subsequent generateFunc call is allowed. Defaults to 0 (no blocking of + * concurrent generateFunc calls beyond staleTimeout). + * * cache - the cache name configured in server.cache. Defaults to the default cache. + * * segment - string segment name, used to isolate cached items within the cache partition. When called within a plugin, defaults to '!name' where 'name' is the plugin name. When called within a + * server method, defaults to '#name' where 'name' is the server method name. Required when called outside of a plugin. + * * shared - if true, allows multiple cache provisions to share the same segment. Default to false. + * @return Catbox Policy. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servercacheoptions) + */ + = CachePolicyOptions>(options: O): Policy; + + /** + * Provisions a server cache as described in server.cache where: + * @param options - same as the server cache configuration options. + * @return Return value: none. + * Note that if the server has been initialized or started, the cache will be automatically started to match the state of any other provisioned server cache. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-servercacheprovisionoptions) + */ + provision(options: ServerOptionsCache): Promise; +} + +export type CacheProvider = EnginePrototype | { + constructor: EnginePrototype; + options?: T | undefined; +}; + +/** + * hapi uses catbox for its cache implementation which includes support for common storage solutions (e.g. Redis, + * MongoDB, Memcached, Riak, among others). Caching is only utilized if methods and plugins explicitly store their state in the cache. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-cache) + */ +export interface ServerOptionsCache extends PolicyOptions { + /** catbox engine object. */ + engine?: ClientApi | undefined; + + /** + * a class or a prototype function + */ + provider?: CacheProvider | undefined; + + /** + * an identifier used later when provisioning or configuring caching for server methods or plugins. Each cache name must be unique. A single item may omit the name option which defines + * the default cache. If every cache includes a name, a default memory cache is provisioned as well. + */ + name?: string | undefined; + + /** if true, allows multiple cache users to share the same segment (e.g. multiple methods using the same cache storage container). Default to false. */ + shared?: boolean | undefined; + + /** (optional) string used to isolate cached data. Defaults to 'hapi-cache'. */ + partition?: string | undefined; + + /** other options passed to the catbox strategy used. Other options are only passed to catbox when engine above is a class or function and ignored if engine is a catbox engine object). */ + [s: string]: any; +} diff --git a/lib/types/server/encoders.d.ts b/lib/types/server/encoders.d.ts new file mode 100644 index 000000000..c91fd7df3 --- /dev/null +++ b/lib/types/server/encoders.d.ts @@ -0,0 +1,19 @@ +import { createDeflate, createGunzip, createGzip, createInflate } from 'zlib'; + +/** + * Available [content encoders](https://github.com/hapijs/hapi/blob/master/API.md#-serverencoderencoding-encoder). + */ +export interface ContentEncoders { + + deflate: typeof createDeflate; + gzip: typeof createGzip; +} + +/** + * Available [content decoders](https://github.com/hapijs/hapi/blob/master/API.md#-serverdecoderencoding-decoder). + */ +export interface ContentDecoders { + + deflate: typeof createInflate; + gzip: typeof createGunzip; +} diff --git a/lib/types/server/events.d.ts b/lib/types/server/events.d.ts new file mode 100644 index 000000000..770cdb095 --- /dev/null +++ b/lib/types/server/events.d.ts @@ -0,0 +1,217 @@ +import { Podium } from '@hapi/podium'; + +import { Request, RequestRoute } from '../request'; + +/** + * an event name string. + * an event options object. + * a podium emitter object. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servereventevents) + */ +export type ServerEventsApplication = string | ServerEventsApplicationObject | Podium; + +/** + * Object that it will be used in Event + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servereventevents) + */ +export interface ServerEventsApplicationObject { + /** the event name string (required). */ + name: string; + /** a string or array of strings specifying the event channels available. Defaults to no channel restrictions (event updates can specify a channel or not). */ + channels?: string | string[] | undefined; + /** + * if true, the data object passed to server.events.emit() is cloned before it is passed to the listeners (unless an override specified by each listener). Defaults to false (data is passed as-is). + */ + clone?: boolean | undefined; + /** + * if true, the data object passed to server.event.emit() must be an array and the listener method is called with each array element passed as a separate argument (unless an override specified + * by each listener). This should only be used when the emitted data structure is known and predictable. Defaults to false (data is emitted as a single argument regardless of its type). + */ + spread?: boolean | undefined; + /** + * if true and the criteria object passed to server.event.emit() includes tags, the tags are mapped to an object (where each tag string is the key and the value is true) which is appended to + * the arguments list at the end. A configuration override can be set by each listener. Defaults to false. + */ + tags?: boolean | undefined; + /** + * if true, the same event name can be registered multiple times where the second registration is ignored. Note that if the registration config is changed between registrations, only the first + * configuration is used. Defaults to false (a duplicate registration will throw an error). + */ + shared?: boolean | undefined; +} + +/** + * A criteria object with the following optional keys (unless noted otherwise): + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servereventsoncriteria-listener) + * + * The type parameter T is the type of the name of the event. + */ +export interface ServerEventCriteria { + /** (required) the event name string. */ + name: T; + /** + * a string or array of strings specifying the event channels to subscribe to. If the event registration specified a list of allowed channels, the channels array must match the allowed + * channels. If channels are specified, event updates without any channel designation will not be included in the subscription. Defaults to no channels filter. + */ + channels?: string | string[] | undefined; + /** if true, the data object passed to server.event.emit() is cloned before it is passed to the listener method. Defaults to the event registration option (which defaults to false). */ + clone?: boolean | undefined; + /** + * a positive integer indicating the number of times the listener can be called after which the subscription is automatically removed. A count of 1 is the same as calling server.events.once(). + * Defaults to no limit. + */ + count?: number | undefined; + /** + * filter - the event tags (if present) to subscribe to which can be one of: + * * a tag string. + * * an array of tag strings. + * * an object with the following: + * * * tags - a tag string or array of tag strings. + * * * all - if true, all tags must be present for the event update to match the subscription. Defaults to false (at least one matching tag). + */ + filter?: string | string[] | { tags: string | string[] | undefined, all?: boolean | undefined } | undefined; + /** + * if true, and the data object passed to server.event.emit() is an array, the listener method is called with each array element passed as a separate argument. This should only be used + * when the emitted data structure is known and predictable. Defaults to the event registration option (which defaults to false). + */ + spread?: boolean | undefined; + /** + * if true and the criteria object passed to server.event.emit() includes tags, the tags are mapped to an object (where each tag string is the key and the value is true) which is appended + * to the arguments list at the end. Defaults to the event registration option (which defaults to false). + */ + tags?: boolean | undefined; +} + +export interface LogEvent { + /** the event timestamp. */ + timestamp: string; + /** an array of tags identifying the event (e.g. ['error', 'http']) */ + tags: string[]; + /** set to 'internal' for internally generated events, otherwise 'app' for events generated by server.log() */ + channel: 'internal' | 'app'; + /** the request identifier. */ + request: string; + /** event-specific information. Available when event data was provided and is not an error. Errors are passed via error. */ + data: T; + /** the error object related to the event if applicable. Cannot appear together with data */ + error: object; +} + +export interface RequestEvent { + /** the event timestamp. */ + timestamp: string; + /** an array of tags identifying the event (e.g. ['error', 'http']) */ + tags: string[]; + /** set to 'internal' for internally generated events, otherwise 'app' for events generated by server.log() */ + channel: 'internal' | 'app' | 'error'; + /** event-specific information. Available when event data was provided and is not an error. Errors are passed via error. */ + data: object | string; + /** the error object related to the event if applicable. Cannot appear together with data */ + error: object; +} + +export type LogEventHandler = (event: LogEvent, tags: { [key: string]: true }) => void; +export type RequestEventHandler = (request: Request, event: RequestEvent, tags: { [key: string]: true }) => void; +export type ResponseEventHandler = (request: Request) => void; +export type RouteEventHandler = (route: RequestRoute) => void; +export type StartEventHandler = () => void; +export type StopEventHandler = () => void; + +export interface PodiumEvent { + emit(criteria: K, listener: (value: T) => void): void; + + on(criteria: K, listener: (value: T) => void): void; + + once(criteria: K, listener: (value: T) => void): void; + + once(criteria: K): Promise; + + removeListener(criteria: K, listener: Podium.Listener): this; + + removeAllListeners(criteria: K): this; + + hasListeners(criteria: K): this; +} + +/** + * Access: podium public interface. + * The server events emitter. Utilizes the podium with support for event criteria validation, channels, and filters. + * Use the following methods to interact with server.events: + * [server.event(events)](https://github.com/hapijs/hapi/blob/master/API.md#server.event()) - register application events. + * [server.events.emit(criteria, data)](https://github.com/hapijs/hapi/blob/master/API.md#server.events.emit()) - emit server events. + * [server.events.on(criteria, listener)](https://github.com/hapijs/hapi/blob/master/API.md#server.events.on()) - subscribe to all events. + * [server.events.once(criteria, listener)](https://github.com/hapijs/hapi/blob/master/API.md#server.events.once()) - subscribe to + * Other methods include: server.events.removeListener(name, listener), server.events.removeAllListeners(name), and server.events.hasListeners(name). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverevents) + */ +export interface ServerEvents extends Podium { + /** + * Subscribe to an event where: + * @param criteria - the subscription criteria which must be one of: + * * event name string which can be any of the built-in server events + * * a custom application event registered with server.event(). + * * a criteria object + * @param listener - the handler method set to receive event updates. The function signature depends on the event argument, and the spread and tags options. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servereventsoncriteria-listener) + * See ['log' event](https://github.com/hapijs/hapi/blob/master/API.md#-log-event) + * See ['request' event](https://github.com/hapijs/hapi/blob/master/API.md#-request-event) + * See ['response' event](https://github.com/hapijs/hapi/blob/master/API.md#-response-event) + * See ['route' event](https://github.com/hapijs/hapi/blob/master/API.md#-route-event) + * See ['start' event](https://github.com/hapijs/hapi/blob/master/API.md#-start-event) + * See ['stop' event](https://github.com/hapijs/hapi/blob/master/API.md#-stop-event) + */ + on(criteria: 'log' | ServerEventCriteria<'log'>, listener: LogEventHandler): this; + on(criteria: 'request' | ServerEventCriteria<'request'>, listener: RequestEventHandler): this; + on(criteria: 'response' | ServerEventCriteria<'response'>, listener: ResponseEventHandler): this; + on(criteria: 'route' | ServerEventCriteria<'route'>, listener: RouteEventHandler): this; + on(criteria: 'start' | ServerEventCriteria<'start'>, listener: StartEventHandler): this; + on(criteria: 'stop' | ServerEventCriteria<'stop'>, listener: StopEventHandler): this; + on(criteria: string | ServerEventCriteria, listener: (value: any) => void): this; + + /** + * Same as calling [server.events.on()](https://github.com/hapijs/hapi/blob/master/API.md#server.events.on()) with the count option set to 1. + * @param criteria - the subscription criteria which must be one of: + * * event name string which can be any of the built-in server events + * * a custom application event registered with server.event(). + * * a criteria object + * @param listener - the handler method set to receive event updates. The function signature depends on the event argument, and the spread and tags options. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servereventsoncecriteria-listener) + */ + once(criteria: 'log' | ServerEventCriteria<'log'>, listener: LogEventHandler): this; + once(criteria: 'request' | ServerEventCriteria<'request'>, listener: RequestEventHandler): this; + once(criteria: 'response' | ServerEventCriteria<'response'>, listener: ResponseEventHandler): this; + once(criteria: 'route' | ServerEventCriteria<'route'>, listener: RouteEventHandler): this; + once(criteria: 'start' | ServerEventCriteria<'start'>, listener: StartEventHandler): this; + once(criteria: 'stop' | ServerEventCriteria<'stop'>, listener: StopEventHandler): this; + + /** + * Same as calling server.events.on() with the count option set to 1. + * @param criteria - the subscription criteria which must be one of: + * * event name string which can be any of the built-in server events + * * a custom application event registered with server.event(). + * * a criteria object + * @return Return value: a promise that resolves when the event is emitted. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-servereventsoncecriteria) + */ + once(criteria: string | ServerEventCriteria): Promise; + + /** + * The follow method is only mentioned in Hapi API. The doc about that method can be found [here](https://github.com/hapijs/podium/blob/master/API.md#podiumremovelistenername-listener) + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverevents) + */ + removeListener(name: string, listener: Podium.Listener): this; + + /** + * The follow method is only mentioned in Hapi API. The doc about that method can be found [here](https://github.com/hapijs/podium/blob/master/API.md#podiumremovealllistenersname) + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverevents) + */ + removeAllListeners(name: string): this; + + /** + * The follow method is only mentioned in Hapi API. The doc about that method can be found [here](https://github.com/hapijs/podium/blob/master/API.md#podiumhaslistenersname) + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverevents) + */ + hasListeners(name: string): boolean; +} diff --git a/lib/types/server/ext.d.ts b/lib/types/server/ext.d.ts new file mode 100644 index 000000000..3f4133fd0 --- /dev/null +++ b/lib/types/server/ext.d.ts @@ -0,0 +1,152 @@ +import { Server, ServerApplicationState } from './server'; +import { ReqRef, ReqRefDefaults } from '../request'; +import { Lifecycle } from '../utils'; + +/** + * The extension point event name. The available extension points include the request extension points as well as the following server extension points: + * 'onPreStart' - called before the connection listeners are started. + * 'onPostStart' - called after the connection listeners are started. + * 'onPreStop' - called before the connection listeners are stopped. + * 'onPostStop' - called after the connection listeners are stopped. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverextevents) + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#request-lifecycle) + */ +export type ServerExtType = 'onPreStart' | 'onPostStart' | 'onPreStop' | 'onPostStop'; +export type RouteRequestExtType = 'onPreAuth' + | 'onCredentials' + | 'onPostAuth' + | 'onPreHandler' + | 'onPostHandler' + | 'onPreResponse' + | 'onPostResponse'; + +export type ServerRequestExtType = + RouteRequestExtType + | 'onRequest'; + +/** + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverextevents) + * Registers an extension function in one of the request lifecycle extension points where: + * @param events - an object or array of objects with the following: + * * type - (required) the extension point event name. The available extension points include the request extension points as well as the following server extension points: + * * * 'onPreStart' - called before the connection listeners are started. + * * * 'onPostStart' - called after the connection listeners are started. + * * * 'onPreStop' - called before the connection listeners are stopped. + * * * 'onPostStop' - called after the connection listeners are stopped. + * * method - (required) a function or an array of functions to be executed at a specified point during request processing. The required extension function signature is: + * * * server extension points: async function(server) where: + * * * * server - the server object. + * * * * this - the object provided via options.bind or the current active context set with server.bind(). + * * * request extension points: a lifecycle method. + * * options - (optional) an object with the following: + * * * before - a string or array of strings of plugin names this method must execute before (on the same event). Otherwise, extension methods are executed in the order added. + * * * after - a string or array of strings of plugin names this method must execute after (on the same event). Otherwise, extension methods are executed in the order added. + * * * bind - a context object passed back to the provided method (via this) when called. Ignored if the method is an arrow function. + * * * sandbox - if set to 'plugin' when adding a request extension points the extension is only added to routes defined by the current plugin. Not allowed when configuring route-level extensions, or + * when adding server extensions. Defaults to 'server' which applies to any route added to the server the extension is added to. + * @return void + */ +export interface ServerExtEventsObject
{ + /** + * (required) the extension point event name. The available extension points include the request extension points as well as the following server extension points: + * * 'onPreStart' - called before the connection listeners are started. + * * 'onPostStart' - called after the connection listeners are started. + * * 'onPreStop' - called before the connection listeners are stopped. + */ + type: ServerExtType; + /** + * (required) a function or an array of functions to be executed at a specified point during request processing. The required extension function signature is: + * * server extension points: async function(server) where: + * * * server - the server object. + * * * this - the object provided via options.bind or the current active context set with server.bind(). + * * request extension points: a lifecycle method. + */ + method: ServerExtPointFunction | ServerExtPointFunction[]; + options?: ServerExtOptions | undefined; +} + +export interface RouteExtObject { + method: Lifecycle.Method; + options?: ServerExtOptions | undefined; +} + +/** + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverextevents) + * Registers an extension function in one of the request lifecycle extension points where: + * @param events - an object or array of objects with the following: + * * type - (required) the extension point event name. The available extension points include the request extension points as well as the following server extension points: + * * * 'onPreStart' - called before the connection listeners are started. + * * * 'onPostStart' - called after the connection listeners are started. + * * * 'onPreStop' - called before the connection listeners are stopped. + * * * 'onPostStop' - called after the connection listeners are stopped. + * * method - (required) a function or an array of functions to be executed at a specified point during request processing. The required extension function signature is: + * * * server extension points: async function(server) where: + * * * * server - the server object. + * * * * this - the object provided via options.bind or the current active context set with server.bind(). + * * * request extension points: a lifecycle method. + * * options - (optional) an object with the following: + * * * before - a string or array of strings of plugin names this method must execute before (on the same event). Otherwise, extension methods are executed in the order added. + * * * after - a string or array of strings of plugin names this method must execute after (on the same event). Otherwise, extension methods are executed in the order added. + * * * bind - a context object passed back to the provided method (via this) when called. Ignored if the method is an arrow function. + * * * sandbox - if set to 'plugin' when adding a request extension points the extension is only added to routes defined by the current plugin. Not allowed when configuring route-level extensions, or + * when adding server extensions. Defaults to 'server' which applies to any route added to the server the extension is added to. + * @return void + */ +export interface ServerExtEventsRequestObject { + /** + * (required) the extension point event name. The available extension points include the request extension points as well as the following server extension points: + * * 'onPreStart' - called before the connection listeners are started. + * * 'onPostStart' - called after the connection listeners are started. + * * 'onPreStop' - called before the connection listeners are stopped. + * * 'onPostStop' - called after the connection listeners are stopped. + */ + type: ServerRequestExtType; + /** + * (required) a function or an array of functions to be executed at a specified point during request processing. The required extension function signature is: + * * server extension points: async function(server) where: + * * * server - the server object. + * * * this - the object provided via options.bind or the current active context set with server.bind(). + * * request extension points: a lifecycle method. + */ + method: Lifecycle.Method | Lifecycle.Method[]; + /** + * (optional) an object with the following: + * * before - a string or array of strings of plugin names this method must execute before (on the same event). Otherwise, extension methods are executed in the order added. + * * after - a string or array of strings of plugin names this method must execute after (on the same event). Otherwise, extension methods are executed in the order added. + * * bind - a context object passed back to the provided method (via this) when called. Ignored if the method is an arrow function. + * * sandbox - if set to 'plugin' when adding a request extension points the extension is only added to routes defined by the current plugin. Not allowed when configuring route-level extensions, + * or when adding server extensions. Defaults to 'server' which applies to any route added to the server the extension is added to. + */ + options?: ServerExtOptions | undefined; +} + +export type ServerExtPointFunction = (server: Server) => void; + +/** + * An object with the following: + * * before - a string or array of strings of plugin names this method must execute before (on the same event). Otherwise, extension methods are executed in the order added. + * * after - a string or array of strings of plugin names this method must execute after (on the same event). Otherwise, extension methods are executed in the order added. + * * bind - a context object passed back to the provided method (via this) when called. Ignored if the method is an arrow function. + * * sandbox - if set to 'plugin' when adding a request extension points the extension is only added to routes defined by the current plugin. Not allowed when configuring route-level extensions, or + * when adding server extensions. Defaults to 'server' which applies to any route added to the server the extension is added to. For context [See + * docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverextevents) + */ +export interface ServerExtOptions { + /** + * a string or array of strings of plugin names this method must execute before (on the same event). Otherwise, extension methods are executed in the order added. + */ + before?: string | string[] | undefined; + /** + * a string or array of strings of plugin names this method must execute after (on the same event). Otherwise, extension methods are executed in the order added. + */ + after?: string | string[] | undefined; + /** + * a context object passed back to the provided method (via this) when called. Ignored if the method is an arrow function. + */ + bind?: object | undefined; + /** + * if set to 'plugin' when adding a request extension points the extension is only added to routes defined by the current plugin. Not allowed when configuring route-level extensions, or when + * adding server extensions. Defaults to 'server' which applies to any route added to the server the extension is added to. + */ + sandbox?: 'server' | 'plugin' | undefined; +} diff --git a/lib/types/server/index.d.ts b/lib/types/server/index.d.ts new file mode 100644 index 000000000..f1ff8aea4 --- /dev/null +++ b/lib/types/server/index.d.ts @@ -0,0 +1,11 @@ +export * from './auth'; +export * from './cache'; +export * from './encoders'; +export * from './events'; +export * from './ext'; +export * from './info'; +export * from './inject'; +export * from './methods'; +export * from './options'; +export * from './server'; +export * from './state'; diff --git a/lib/types/server/info.d.ts b/lib/types/server/info.d.ts new file mode 100644 index 000000000..70b145c22 --- /dev/null +++ b/lib/types/server/info.d.ts @@ -0,0 +1,53 @@ +/** + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverinfo) + * An object containing information about the server where: + */ +export interface ServerInfo { + /** + * a unique server identifier (using the format '{hostname}:{pid}:{now base36}'). + */ + id: string; + + /** + * server creation timestamp. + */ + created: number; + + /** + * server start timestamp (0 when stopped). + */ + started: number; + + /** + * the connection [port](https://github.com/hapijs/hapi/blob/master/API.md#server.options.port) based on the following rules: + * * before the server has been started: the configured port value. + * * after the server has been started: the actual port assigned when no port is configured or was set to 0. + */ + port: number | string; + + /** + * The [host](https://github.com/hapijs/hapi/blob/master/API.md#server.options.host) configuration value. + */ + host: string; + + /** + * the active IP address the connection was bound to after starting. Set to undefined until the server has been + * started or when using a non TCP port (e.g. UNIX domain socket). + */ + address: undefined | string; + + /** + * the protocol used: + * * 'http' - HTTP. + * * 'https' - HTTPS. + * * 'socket' - UNIX domain socket or Windows named pipe. + */ + protocol: 'http' | 'https' | 'socket'; + + /** + * a string representing the connection (e.g. 'http://example.com:8080' or 'socket:/unix/domain/socket/path'). Contains + * the uri value if set, otherwise constructed from the available settings. If no port is configured or is set + * to 0, the uri will not include a port component until the server is started. + */ + uri: string; +} diff --git a/lib/types/server/inject.d.ts b/lib/types/server/inject.d.ts new file mode 100644 index 000000000..01f0b266c --- /dev/null +++ b/lib/types/server/inject.d.ts @@ -0,0 +1,89 @@ +import { RequestOptions as ShotRequestOptions, ResponseObject as ShotResponseObject } from '@hapi/shot'; +import { PluginsStates } from '../plugin'; +import { AuthArtifacts, AuthCredentials, Request, RequestApplicationState } from '../request'; + +/** + * An object with: + * * method - (optional) the request HTTP method (e.g. 'POST'). Defaults to 'GET'. + * * url - (required) the request URL. If the URI includes an authority (e.g. 'example.com:8080'), it is used to automatically set an HTTP 'Host' header, unless one was specified in headers. + * * headers - (optional) an object with optional request headers where each key is the header name and the value is the header content. Defaults to no additions to the default shot headers. + * * payload - (optional) an string, buffer or object containing the request payload. In case of an object it will be converted to a string for you. Defaults to no payload. Note that payload + * processing defaults to 'application/json' if no 'Content-Type' header provided. + * * credentials - (optional) an credentials object containing authentication information. The credentials are used to bypass the default authentication strategies, and are validated directly as if + * they were received via an authentication scheme. Defaults to no credentials. + * * artifacts - (optional) an artifacts object containing authentication artifact information. The artifacts are used to bypass the default authentication strategies, and are validated directly as + * if they were received via an authentication scheme. Ignored if set without credentials. Defaults to no artifacts. + * * app - (optional) sets the initial value of request.app, defaults to {}. + * * plugins - (optional) sets the initial value of request.plugins, defaults to {}. + * * allowInternals - (optional) allows access to routes with config.isInternal set to true. Defaults to false. + * * remoteAddress - (optional) sets the remote address for the incoming connection. + * * simulate - (optional) an object with options used to simulate client request stream conditions for testing: + * * error - if true, emits an 'error' event after payload transmission (if any). Defaults to false. + * * close - if true, emits a 'close' event after payload transmission (if any). Defaults to false. + * * end - if false, does not end the stream. Defaults to true. + * * split - indicates whether the request payload will be split into chunks. Defaults to undefined, meaning payload will not be chunked. + * * validate - (optional) if false, the options inputs are not validated. This is recommended for run-time usage of inject() to make it perform faster where input validation can be tested + * separately. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverinjectoptions) + * For context [Shot module](https://github.com/hapijs/shot) + */ +export interface ServerInjectOptions extends ShotRequestOptions { + /** + * Authentication bypass options. + */ + auth?: { + /** + * The authentication strategy name matching the provided credentials. + */ + strategy: string; + /** + * The credentials are used to bypass the default authentication strategies, + * and are validated directly as if they were received via an authentication scheme. + */ + credentials: AuthCredentials; + /** + * The artifacts are used to bypass the default authentication strategies, + * and are validated directly as if they were received via an authentication scheme. Defaults to no artifacts. + */ + artifacts?: AuthArtifacts | undefined; + } | undefined; + /** + * sets the initial value of request.app, defaults to {}. + */ + app?: RequestApplicationState | undefined; + /** + * sets the initial value of request.plugins, defaults to {}. + */ + plugins?: PluginsStates | undefined; + /** + * allows access to routes with config.isInternal set to true. Defaults to false. + */ + allowInternals?: boolean | undefined; +} + +/** + * A response object with the following properties: + * * statusCode - the HTTP status code. + * * headers - an object containing the headers set. + * * payload - the response payload string. + * * rawPayload - the raw response payload buffer. + * * raw - an object with the injection request and response objects: + * * req - the simulated node request object. + * * res - the simulated node response object. + * * result - the raw handler response (e.g. when not a stream or a view) before it is serialized for transmission. If not available, the value is set to payload. Useful for inspection and reuse of + * the internal objects returned (instead of parsing the response string). + * * request - the request object. + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverinjectoptions) + * For context [Shot module](https://github.com/hapijs/shot) + */ +export interface ServerInjectResponse extends ShotResponseObject { + /** + * the raw handler response (e.g. when not a stream or a view) before it is serialized for transmission. If not available, the value is set to payload. Useful for inspection and reuse of the + * internal objects returned (instead of parsing the response string). + */ + result: Result | undefined; + /** + * the request object. + */ + request: Request; +} diff --git a/lib/types/server/methods.d.ts b/lib/types/server/methods.d.ts new file mode 100644 index 000000000..a4cb02b94 --- /dev/null +++ b/lib/types/server/methods.d.ts @@ -0,0 +1,95 @@ +import { CacheStatisticsObject, PolicyOptions } from "@hapi/catbox"; + +type AnyMethod = (...args: any[]) => any; + +export type CachedServerMethod = T & { + cache?: { + drop(...args: Parameters): Promise; + stats: CacheStatisticsObject + } +}; + +/** + * The method function with a signature async function(...args, [flags]) where: + * * ...args - the method function arguments (can be any number of arguments or none). + * * flags - when caching is enabled, an object used to set optional method result flags: + * * * ttl - 0 if result is valid but cannot be cached. Defaults to cache policy. + * For reference [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermethodname-method-options) + */ +export type ServerMethod = AnyMethod; + +/** + * The same cache configuration used in server.cache(). + * The generateTimeout option is required. + * For reference [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermethodname-method-options) + * For reference [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servercacheoptions) + */ +export interface ServerMethodCache extends PolicyOptions { + generateTimeout: number | false; + cache?: string; + segment?: string; +} + +/** + * Configuration object: + * * bind - a context object passed back to the method function (via this) when called. Defaults to active context (set via server.bind() when the method is registered. Ignored if the method is an + * arrow function. + * * cache - the same cache configuration used in server.cache(). The generateTimeout option is required. + * * generateKey - a function used to generate a unique key (for caching) from the arguments passed to the method function (the flags argument is not passed as input). The server will automatically + * generate a unique key if the function's arguments are all of types 'string', 'number', or 'boolean'. However if the method uses other types of arguments, a key generation function must be provided + * which takes the same arguments as the function and returns a unique string (or null if no key can be generated). For reference [See + * docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermethodname-method-options) + */ +export interface ServerMethodOptions { + /** + * a context object passed back to the method function (via this) when called. Defaults to active context (set via server.bind() when the method is registered. Ignored if the method is an arrow + * function. + */ + bind?: object | undefined; + /** + * the same cache configuration used in server.cache(). The generateTimeout option is required. + */ + cache?: ServerMethodCache | undefined; + /** + * a function used to generate a unique key (for caching) from the arguments passed to the method function (the flags argument is not passed as input). The server will automatically generate a + * unique key if the function's arguments are all of types 'string', 'number', or 'boolean'. However if the method uses other types of arguments, a key generation function must be provided which + * takes the same arguments as the function and returns a unique string (or null if no key can be generated). + */ + generateKey?(...args: any[]): string | null; +} + +/** + * An object or an array of objects where each one contains: + * * name - the method name. + * * method - the method function. + * * options - (optional) settings. + * For reference [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermethodmethods) + */ +export interface ServerMethodConfigurationObject { + /** + * the method name. + */ + name: string; + /** + * the method function. + */ + method: ServerMethod; + /** + * (optional) settings. + */ + options?: ServerMethodOptions | undefined; +} + +interface BaseServerMethods { + [name: string]: ( + ServerMethod | + CachedServerMethod | + BaseServerMethods + ); +} + +/** + * An empty interface to allow typings of custom server.methods. + */ +export interface ServerMethods extends BaseServerMethods { +} diff --git a/lib/types/server/options.d.ts b/lib/types/server/options.d.ts new file mode 100644 index 000000000..5b7c9a7e3 --- /dev/null +++ b/lib/types/server/options.d.ts @@ -0,0 +1,235 @@ +import * as http from 'http'; +import * as https from 'https'; + +import { MimosOptions } from '@hapi/mimos'; + +import { PluginSpecificConfiguration } from '../plugin'; +import { RouteOptions } from '../route'; +import { CacheProvider, ServerOptionsCache } from './cache'; +import { SameSitePolicy, ServerStateCookieOptions } from './state'; + +export interface ServerOptionsCompression { + minBytes: number; +} + +/** + * Empty interface to allow for custom augmentation. + */ + +export interface ServerOptionsApp { +} + +/** + * The server options control the behavior of the server object. Note that the options object is deeply cloned + * (with the exception of listener which is shallowly copied) and should not contain any values that are unsafe to perform deep copy on. + * All options are optionals. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-server-options) + */ +export interface ServerOptions { + /** + * @default '0.0.0.0' (all available network interfaces). + * Sets the hostname or IP address the server will listen on. If not configured, defaults to host if present, otherwise to all available network interfaces. Set to '127.0.0.1' or 'localhost' to + * restrict the server to only those coming from the same host. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serveroptionsaddress) + */ + address?: string | undefined; + + /** + * @default {}. + * Provides application-specific configuration which can later be accessed via server.settings.app. The framework does not interact with this object. It is simply a reference made available + * anywhere a server reference is provided. Note the difference between server.settings.app which is used to store static configuration values and server.app which is meant for storing run-time + * state. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serveroptionsapp) + */ + app?: ServerOptionsApp | undefined; + + /** + * @default true. + * Used to disable the automatic initialization of the listener. When false, indicates that the listener will be started manually outside the framework. + * Cannot be set to true along with a port value. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serveroptionsautolisten) + */ + autoListen?: boolean | undefined; + + /** + * @default { engine: require('@hapi/catbox-memory' }. + * Sets up server-side caching providers. Every server includes a default cache for storing application state. By default, a simple memory-based cache is created which has limited capacity and + * capabilities. hapi uses catbox for its cache implementation which includes support for common storage solutions (e.g. Redis, MongoDB, Memcached, Riak, among others). Caching is only utilized + * if methods and plugins explicitly store their state in the cache. The server cache configuration only defines the storage container itself. The configuration can be assigned one or more + * (array): + * * a class or prototype function (usually obtained by calling require() on a catbox strategy such as require('@hapi/catbox-redis')). A new catbox client will be created internally using this + * function. + * * a configuration object with the following: + * * * engine - a class, a prototype function, or a catbox engine object. + * * * name - an identifier used later when provisioning or configuring caching for server methods or plugins. Each cache name must be unique. A single item may omit the name option which defines + * the default cache. If every cache includes a name, a default memory cache is provisioned as well. + * * * shared - if true, allows multiple cache users to share the same segment (e.g. multiple methods using the same cache storage container). Default to false. + * * * partition - (optional) string used to isolate cached data. Defaults to 'hapi-cache'. + * * * other options passed to the catbox strategy used. Other options are only passed to catbox when engine above is a class or function and ignored if engine is a catbox engine object). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serveroptionscache) + */ + cache?: CacheProvider | ServerOptionsCache | ServerOptionsCache[] | undefined; + + /** + * @default { minBytes: 1024 }. + * Defines server handling of content encoding requests. If false, response content encoding is disabled and no compression is performed by the server. + */ + compression?: boolean | ServerOptionsCompression | undefined; + + /** + * @default { request: ['implementation'] }. + * Determines which logged events are sent to the console. This should only be used for development and does not affect which events are actually logged internally and recorded. Set to false to + * disable all console logging, or to an object with: + * * log - a string array of server log tags to be displayed via console.error() when the events are logged via server.log() as well as internally generated server logs. Defaults to no output. + * * request - a string array of request log tags to be displayed via console.error() when the events are logged via request.log() as well as internally generated request logs. For example, to + * display all errors, set the option to ['error']. To turn off all console debug messages set it to false. To display all request logs, set it to '*'. Defaults to uncaught errors thrown in + * external code (these errors are handled automatically and result in an Internal Server Error response) or runtime errors due to developer error. For example, to display all errors, set the log + * or request to ['error']. To turn off all output set the log or request to false. To display all server logs, set the log or request to '*'. To disable all debug information, set debug to + * false. + */ + debug?: false | { + log?: string | string[] | false | undefined; + request?: string | string[] | false | undefined; + } | undefined; + + /** + * @default the operating system hostname and if not available, to 'localhost'. + * The public hostname or IP address. Used to set server.info.host and server.info.uri and as address is none provided. + */ + host?: string | undefined; + + info?: { + /** + * @default false. + * If true, the request.info.remoteAddress and request.info.remotePort are populated when the request is received which can consume more resource (but is ok if the information is needed, + * especially for aborted requests). When false, the fields are only populated upon demand (but will be undefined if accessed after the request is aborted). + */ + remote?: boolean | undefined; + } | undefined; + + /** + * @default none. + * An optional node HTTP (or HTTPS) http.Server object (or an object with a compatible interface). + * If the listener needs to be manually started, set autoListen to false. + * If the listener uses TLS, set tls to true. + */ + listener?: http.Server | undefined; + + /** + * @default { sampleInterval: 0 }. + * Server excessive load handling limits where: + * * sampleInterval - the frequency of sampling in milliseconds. When set to 0, the other load options are ignored. Defaults to 0 (no sampling). + * * maxHeapUsedBytes - maximum V8 heap size over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to 0 (no limit). + * * maxRssBytes - maximum process RSS size over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to 0 (no limit). + * * maxEventLoopDelay - maximum event loop delay duration in milliseconds over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to 0 (no limit). + */ + load?: { + /** the frequency of sampling in milliseconds. When set to 0, the other load options are ignored. Defaults to 0 (no sampling). */ + sampleInterval?: number | undefined; + + /** maximum V8 heap size over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to 0 (no limit). */ + maxHeapUsedBytes?: number | undefined; + /** + * maximum process RSS size over which incoming requests are rejected with an HTTP Server Timeout (503) response. Defaults to 0 (no limit). + */ + maxRssBytes?: number | undefined; + /** + * maximum event loop delay duration in milliseconds over which incoming requests are rejected with an HTTP Server Timeout (503) response. + * Defaults to 0 (no limit). + */ + maxEventLoopDelay?: number | undefined; + } | undefined; + + /** + * @default none. + * Options passed to the mimos module when generating the mime database used by the server (and accessed via server.mime): + * * override - an object hash that is merged into the built in mime information specified here. Each key value pair represents a single mime object. Each override value must contain: + * * key - the lower-cased mime-type string (e.g. 'application/javascript'). + * * value - an object following the specifications outlined here. Additional values include: + * * * type - specify the type value of result objects, defaults to key. + * * * predicate - method with signature function(mime) when this mime type is found in the database, this function will execute to allows customizations. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serveroptionsmime) + */ + mime?: MimosOptions | undefined; + + /** + * @default { cleanStop: true } + * Defines server handling of server operations. + */ + operations?: { + /** + * @default true + * If true, the server keeps track of open connections and properly closes them when the server is stopped. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serveroptionsoperations) + */ + cleanStop?: boolean; + } + + /** + * @default {}. + * Plugin-specific configuration which can later be accessed via server.settings.plugins. plugins is an object where each key is a plugin name and the value is the configuration. Note the + * difference between server.settings.plugins which is used to store static configuration values and server.plugins which is meant for storing run-time state. + */ + plugins?: PluginSpecificConfiguration | undefined; + + /** + * @default 0 (an ephemeral port). + * The TCP port the server will listen to. Defaults the next available port when the server is started (and assigned to server.info.port). + * If port is a string containing a '/' character, it is used as a UNIX domain socket path. If it starts with '\.\pipe', it is used as a Windows named pipe. + */ + port?: number | string | undefined; + + /** + * @default { isCaseSensitive: true, stripTrailingSlash: false }. + * Controls how incoming request URIs are matched against the routing table: + * * isCaseSensitive - determines whether the paths '/example' and '/EXAMPLE' are considered different resources. Defaults to true. + * * stripTrailingSlash - removes trailing slashes on incoming paths. Defaults to false. + */ + router?: { + isCaseSensitive?: boolean | undefined; + stripTrailingSlash?: boolean | undefined; + } | undefined; + + /** + * @default none. + * A route options object used as the default configuration for every route. + */ + routes?: RouteOptions | undefined; + + /** + * Default value: + * { + * strictHeader: true, + * ignoreErrors: false, + * isSecure: true, + * isHttpOnly: true, + * isSameSite: 'Strict', + * encoding: 'none' + * } + * Sets the default configuration for every state (cookie) set explicitly via server.state() or implicitly (without definition) using the state configuration object. + */ + state?: ServerStateCookieOptions | undefined; + + /** + * @default none. + * Used to create an HTTPS connection. The tls object is passed unchanged to the node HTTPS server as described in the node HTTPS documentation. + */ + tls?: boolean | https.ServerOptions | undefined; + + /** + * @default constructed from runtime server information. + * The full public URI without the path (e.g. 'http://example.com:8080'). If present, used as the server server.info.uri, otherwise constructed from the server settings. + */ + uri?: string | undefined; + + /** + * Query parameter configuration. + */ + query?: { + /** + * the method must return an object where each key is a parameter and matching value is the parameter value. + * If the method throws, the error is used as the response or returned when `request.setUrl` is called. + */ + parser(raw: Record): Record; + } | undefined; +} diff --git a/lib/types/server/server.d.ts b/lib/types/server/server.d.ts new file mode 100644 index 000000000..4d64710e1 --- /dev/null +++ b/lib/types/server/server.d.ts @@ -0,0 +1,701 @@ +import * as http from 'http'; +import { Stream } from 'stream'; + +import { Root } from 'joi'; +import { Mimos } from '@hapi/mimos'; + +import { + Dependencies, + PluginsListRegistered, + Plugin, + ServerRealm, + ServerRegisterOptions, + ServerRegisterPluginObject, + ServerRegisterPluginObjectArray, + HandlerDecorationMethod, + PluginProperties +} from '../plugin'; +import { + ReqRef, + ReqRefDefaults, + Request, + RequestRoute +} from '../request'; +import { ResponseToolkit } from '../response'; +import { + RulesOptions, + RulesProcessor, + ServerRoute +} from '../route'; +import { HTTP_METHODS, Lifecycle } from '../utils'; +import { ServerAuth } from './auth'; +import { ServerCache } from './cache'; +import { ContentDecoders, ContentEncoders } from './encoders'; +import { ServerEventsApplication, ServerEvents } from './events'; +import { + ServerExtEventsObject, + ServerExtEventsRequestObject, + ServerExtType, + ServerExtPointFunction, + ServerExtOptions, + ServerRequestExtType +} from './ext'; +import { ServerInfo } from './info'; +import { ServerInjectOptions, ServerInjectResponse } from './inject'; +import { + ServerMethod, + ServerMethodOptions, + ServerMethodConfigurationObject, + ServerMethods +} from './methods'; +import { ServerOptions } from './options'; +import { ServerState, ServerStateCookieOptions } from './state'; + +/** + * The general case for decorators added via server.decorate. + */ +export type DecorationMethod = (this: T, ...args: any[]) => any; + +export type DecorateName = string | symbol; + +export type DecorationValue = object | any[] | boolean | number | string | symbol | Map | Set; + +type ReservedRequestKeys = ( + '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' | + + // Private functions + '_allowInternals' | '_closed' | '_core' | + '_entity' | '_eventContext' | '_events' | '_expectContinue' | + '_isInjected' | '_isPayloadPending' | '_isReplied' | + '_route' | '_serverTimeoutId' | '_states' | '_url' | + '_urlError' | '_initializeUrl' | '_setUrl' | '_parseUrl' | + '_parseQuery' + +); + +type ReservedToolkitKeys = ( + 'abandon' | 'authenticated' | 'close' | 'context' | 'continue' | + 'entity' | 'redirect' | 'realm' | 'request' | 'response' | + 'state' | 'unauthenticated' | 'unstate' +); + +type ReservedServerKeys = ( + // Public functions + 'app' | 'auth' | 'cache' | 'decorations' | 'events' | 'info' | + 'listener' | 'load' | 'methods' | 'mime' | 'plugins' | 'registrations' | + 'settings' | 'states' | 'type' | 'version' | 'realm' | 'control' | 'decoder' | + 'bind' | 'control' | 'decoder' | 'decorate' | 'dependency' | 'encoder' | + 'event' | 'expose' | 'ext' | 'inject' | 'log' | 'lookup' | 'match' | 'method' | + 'path' | 'register' | 'route' | 'rules' | 'state' | 'table' | 'validator' | + 'start' | 'initialize' | 'stop' | + + // Private functions + '_core' | '_initialize' | '_start' | '_stop' | '_cachePolicy' | '_createCache' | + '_clone' | '_ext' | '_addRoute' +); + +type ExceptName = Property extends ReservedKeys ? never : Property; + +/** + * User-extensible type for application specific state (`server.app`). + */ +export interface ServerApplicationState { +} + +/** + * The server object is the main application container. The server manages all incoming requests along with all + * the facilities provided by the framework. Each server supports a single connection (e.g. listen to port 80). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#server) + */ +export class Server { + /** + * Creates a new server object + * @param options server configuration object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serveroptions) + */ + constructor(options?: ServerOptions); + + /** + * Provides a safe place to store server-specific run-time application data without potential conflicts with + * the framework internals. The data can be accessed whenever the server is accessible. + * Initialized with an empty object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverapp) + */ + app: A; + + /** + * Server Auth: properties and methods + */ + readonly auth: ServerAuth; + + /** + * Links another server to the initialize/start/stop state of the current server by calling the + * controlled server `initialize()`/`start()`/`stop()` methods whenever the current server methods + * are called, where: + */ + control(server: Server): void; + + /** + * Provides access to the decorations already applied to various framework interfaces. The object must not be + * modified directly, but only through server.decorate. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverdecorations) + */ + readonly decorations: { + /** + * decorations on the request object. + */ + request: string[], + /** + * decorations on the response toolkit. + */ + toolkit: string[], + /** + * decorations on the server object. + */ + server: string[] + }; + + /** + * Register custom application events where: + * @param events must be one of: + * * an event name string. + * * an event options object with the following optional keys (unless noted otherwise): + * * * name - the event name string (required). + * * * channels - a string or array of strings specifying the event channels available. Defaults to no channel restrictions (event updates can specify a channel or not). + * * * clone - if true, the data object passed to server.events.emit() is cloned before it is passed to the listeners (unless an override specified by each listener). Defaults to false (data is + * passed as-is). + * * * spread - if true, the data object passed to server.event.emit() must be an array and the listener method is called with each array element passed as a separate argument (unless an override + * specified by each listener). This should only be used when the emitted data structure is known and predictable. Defaults to false (data is emitted as a single argument regardless of its + * type). + * * * tags - if true and the criteria object passed to server.event.emit() includes tags, the tags are mapped to an object (where each tag string is the key and the value is true) which is + * appended to the arguments list at the end. A configuration override can be set by each listener. Defaults to false. + * * * shared - if true, the same event name can be registered multiple times where the second registration is ignored. Note that if the registration config is changed between registrations, only + * the first configuration is used. Defaults to false (a duplicate registration will throw an error). + * * a podium emitter object. + * * an array containing any of the above. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverevents) + */ + event(events: ServerEventsApplication | ServerEventsApplication[]): void; + + /** + * Access: podium public interface. + * The server events emitter. Utilizes the podium with support for event criteria validation, channels, and filters. + * Use the following methods to interact with server.events: + * [server.events.emit(criteria, data)](https://github.com/hapijs/hapi/blob/master/API.md#server.events.emit()) - emit server events. + * [server.events.on(criteria, listener)](https://github.com/hapijs/hapi/blob/master/API.md#server.events.on()) - subscribe to all events. + * [server.events.once(criteria, listener)](https://github.com/hapijs/hapi/blob/master/API.md#server.events.once()) - subscribe to + * Other methods include: server.events.removeListener(name, listener), server.events.removeAllListeners(name), and server.events.hasListeners(name). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverevents) + */ + events: ServerEvents; + + /** + * An object containing information about the server where: + * * id - a unique server identifier (using the format '{hostname}:{pid}:{now base36}'). + * * created - server creation timestamp. + * * started - server start timestamp (0 when stopped). + * * port - the connection port based on the following rules: + * * host - The host configuration value. + * * address - the active IP address the connection was bound to after starting. Set to undefined until the server has been started or when using a non TCP port (e.g. UNIX domain socket). + * * protocol - the protocol used: + * * 'http' - HTTP. + * * 'https' - HTTPS. + * * 'socket' - UNIX domain socket or Windows named pipe. + * * uri - a string representing the connection (e.g. 'http://example.com:8080' or 'socket:/unix/domain/socket/path'). Contains the uri value if set, otherwise constructed from the available + * settings. If no port is configured or is set to 0, the uri will not include a port component until the server is started. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverinfo) + */ + readonly info: ServerInfo; + + /** + * Access: read only and listener public interface. + * The node HTTP server object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverlistener) + */ + listener: http.Server; + + /** + * An object containing the process load metrics (when load.sampleInterval is enabled): + * * eventLoopDelay - event loop delay milliseconds. + * * heapUsed - V8 heap usage. + * * rss - RSS memory usage. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverload) + */ + readonly load: { + /** + * event loop delay milliseconds. + */ + eventLoopDelay: number; + + /** + * V8 heap usage. + */ + heapUsed: number; + /** + * RSS memory usage. + */ + rss: number; + }; + + /** + * Server methods are functions registered with the server and used throughout the application as a common utility. + * Their advantage is in the ability to configure them to use the built-in cache and share across multiple request + * handlers without having to create a common module. + * sever.methods is an object which provides access to the methods registered via server.method() where each + * server method name is an object property. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermethods + */ + readonly methods: ServerMethods; + + /** + * Provides access to the server MIME database used for setting content-type information. The object must not be + * modified directly but only through the [mime](https://github.com/hapijs/hapi/blob/master/API.md#server.options.mime) server setting. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermime) + */ + mime: Mimos; + + /** + * An object containing the values exposed by each registered plugin where each key is a plugin name and the values + * are the exposed properties by each plugin using server.expose(). Plugins may set the value of + * the server.plugins[name] object directly or via the server.expose() method. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverplugins) + */ + plugins: PluginProperties; + + /** + * The realm object contains sandboxed server settings specific to each plugin or authentication strategy. When + * registering a plugin or an authentication scheme, a server object reference is provided with a new server.realm + * container specific to that registration. It allows each plugin to maintain its own settings without leaking + * and affecting other plugins. + * For example, a plugin can set a default file path for local resources without breaking other plugins' configured + * paths. When calling server.bind(), the active realm's settings.bind property is set which is then used by + * routes and extensions added at the same level (server root or plugin). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverrealm) + */ + readonly realm: ServerRealm; + + /** + * An object of the currently registered plugins where each key is a registered plugin name and the value is + * an object containing: + * * version - the plugin version. + * * name - the plugin name. + * * options - (optional) options passed to the plugin during registration. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverregistrations) + */ + readonly registrations: PluginsListRegistered; + + /** + * The server configuration object after defaults applied. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serversettings) + */ + readonly settings: ServerOptions; + + /** + * The server cookies manager. + * Access: read only and statehood public interface. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverstates) + */ + readonly states: ServerState; + + /** + * A string indicating the listener type where: + * * 'socket' - UNIX domain socket or Windows named pipe. + * * 'tcp' - an HTTP listener. + */ + readonly type: 'socket' | 'tcp'; + + /** + * The hapi module version number. + */ + readonly version: string; + + /** + * Sets a global context used as the default bind object when adding a route or an extension where: + * @param context - the object used to bind this in lifecycle methods such as the route handler and extension methods. The context is also made available as h.context. + * @return Return value: none. + * When setting a context inside a plugin, the context is applied only to methods set up by the plugin. Note that the context applies only to routes and extensions added after it has been set. + * Ignored if the method being bound is an arrow function. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverbindcontext) + */ + bind(context: object): void; + + /** + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servercacheoptions) + */ + cache: ServerCache; + + /** + * Registers a custom content decoding compressor to extend the built-in support for 'gzip' and 'deflate' where: + * @param encoding - the decoder name string. + * @param decoder - a function using the signature function(options) where options are the encoding specific options configured in the route payload.compression configuration option, and the + * return value is an object compatible with the output of node's zlib.createGunzip(). + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverdecoderencoding-decoder) + */ + decoder(encoding: T, decoder: ContentDecoders[T]): void; + decoder(encoding: string, decoder: ((options?: object) => Stream)): void; + + /** + * Extends various framework interfaces with custom methods where: + * @param type - the interface being decorated. Supported types: + * 'handler' - adds a new handler type to be used in routes handlers. + * 'request' - adds methods to the Request object. + * 'server' - adds methods to the Server object. + * 'toolkit' - adds methods to the response toolkit. + * @param property - the object decoration key name. + * @param method - the extension function or other value. + * @param options - (optional) supports the following optional settings: + * apply - when the type is 'request', if true, the method function is invoked using the signature function(request) where request is the current request object and the returned value is assigned + * as the decoration. extend - if true, overrides an existing decoration. The method must be a function with the signature function(existing) where: existing - is the previously set + * decoration method value. must return the new decoration function or value. cannot be used to extend handler decorations. + * @return void; + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverdecoratetype-property-method-options) + */ + decorate

(type: 'handler', property: P, method: HandlerDecorationMethod, options?: { apply?: boolean | undefined, extend?: never }): void; + + decorate

(type: 'request', property: ExceptName, method: (existing: ((...args: any[]) => any)) => (request: Request) => DecorationMethod, options: {apply: true, extend: true}): void; + decorate

(type: 'request', property: ExceptName, method: (request: Request) => DecorationMethod, options: {apply: true, extend?: boolean | undefined}): void; + decorate

(type: 'request', property: ExceptName, method: DecorationMethod, options?: {apply?: boolean | undefined, extend?: boolean | undefined}): void; + decorate

(type: 'request', property: ExceptName, value: (existing: ((...args: any[]) => any)) => (request: Request) => any, options: {apply: true, extend: true}): void; + decorate

(type: 'request', property: ExceptName, value: (request: Request) => any, options: {apply: true, extend?: boolean | undefined}): void; + decorate

(type: 'request', property: ExceptName, value: DecorationValue, options?: never): void; + + decorate

(type: 'toolkit', property: ExceptName, method: (existing: ((...args: any[]) => any)) => DecorationMethod, options: {apply?: boolean | undefined, extend: true}): void; + decorate

(type: 'toolkit', property: ExceptName, method: DecorationMethod, options?: {apply?: boolean | undefined, extend?: boolean | undefined}): void; + decorate

(type: 'toolkit', property: ExceptName, value: (existing: ((...args: any[]) => any)) => any, options: {apply?: boolean | undefined, extend: true}): void; + decorate

(type: 'toolkit', property: ExceptName, value: DecorationValue, options?: never): void; + + decorate

(type: 'server', property: ExceptName, method: (existing: ((...args: any[]) => any)) => DecorationMethod, options: {apply?: boolean | undefined, extend: true}): void; + decorate

(type: 'server', property: ExceptName, method: DecorationMethod, options?: {apply?: boolean | undefined, extend?: boolean | undefined}): void; + decorate

(type: 'server', property: ExceptName, value: (existing: ((...args: any[]) => any)) => any, options: {apply?: boolean | undefined, extend: true}): void; + decorate

(type: 'server', property: ExceptName, value: DecorationValue, options?: never): void; + + /** + * Used within a plugin to declare a required dependency on other plugins where: + * @param dependencies - plugins which must be registered in order for this plugin to operate. Plugins listed must be registered before the server is + * initialized or started. + * @param after - (optional) a function that is called after all the specified dependencies have been registered and before the server starts. The function is only called if the server is + * initialized or started. The function signature is async function(server) where: server - the server the dependency() method was called on. + * @return Return value: none. + * The after method is identical to setting a server extension point on 'onPreStart'. + * If a circular dependency is detected, an exception is thrown (e.g. two plugins each has an after function to be called after the other). + * The method does not provide version dependency which should be implemented using npm peer dependencies. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverdependencydependencies-after) + */ + dependency(dependencies: Dependencies, after?: ((server: Server) => Promise) | undefined): void; + + /** + * Registers a custom content encoding compressor to extend the built-in support for 'gzip' and 'deflate' where: + * @param encoding - the encoder name string. + * @param encoder - a function using the signature function(options) where options are the encoding specific options configured in the route compression option, and the return value is an object + * compatible with the output of node's zlib.createGzip(). + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverencoderencoding-encoder) + */ + encoder(encoding: T, encoder: ContentEncoders[T]): void; + encoder(encoding: string, encoder: ((options?: object) => Stream)): void; + + /** + * Used within a plugin to expose a property via server.plugins[name] where: + * @param key - the key assigned (server.plugins[name][key]). + * @param value - the value assigned. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverexposekey-value) + */ + expose(key: string, value: any): void; + + /** + * Merges an object into to the existing content of server.plugins[name] where: + * @param obj - the object merged into the exposed properties container. + * @return Return value: none. + * Note that all the properties of obj are deeply cloned into server.plugins[name], so avoid using this method + * for exposing large objects that may be expensive to clone or singleton objects such as database client + * objects. Instead favor server.expose(key, value), which only copies a reference to value. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverexposeobj) + */ + expose(obj: object): void; + + /** + * Registers an extension function in one of the request lifecycle extension points where: + * @param events - an object or array of objects with the following: + * * type - (required) the extension point event name. The available extension points include the request extension points as well as the following server extension points: + * * * 'onPreStart' - called before the connection listeners are started. + * * * 'onPostStart' - called after the connection listeners are started. + * * * 'onPreStop' - called before the connection listeners are stopped. + * * * 'onPostStop' - called after the connection listeners are stopped. + * * method - (required) a function or an array of functions to be executed at a specified point during request processing. The required extension function signature is: + * * * server extension points: async function(server) where: + * * * * server - the server object. + * * * * this - the object provided via options.bind or the current active context set with server.bind(). + * * * request extension points: a lifecycle method. + * * options - (optional) an object with the following: + * * * before - a string or array of strings of plugin names this method must execute before (on the same event). Otherwise, extension methods are executed in the order added. + * * * after - a string or array of strings of plugin names this method must execute after (on the same event). Otherwise, extension methods are executed in the order added. + * * * bind - a context object passed back to the provided method (via this) when called. Ignored if the method is an arrow function. + * * * sandbox - if set to 'plugin' when adding a request extension points the extension is only added to routes defined by the current plugin. Not allowed when configuring route-level + * extensions, or when adding server extensions. Defaults to 'server' which applies to any route added to the server the extension is added to. + * @return void + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverextevents) + */ + ext(events: ServerExtEventsObject | ServerExtEventsObject[] | ServerExtEventsRequestObject | ServerExtEventsRequestObject[]): void; + + /** + * Registers a single extension event using the same properties as used in server.ext(events), but passed as arguments. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverextevent-method-options) + */ + ext(event: ServerExtType, method: ServerExtPointFunction, options?: ServerExtOptions | undefined): void; + ext(event: ServerRequestExtType, method: Lifecycle.Method, options?: ServerExtOptions | undefined): void; + + /** + * Initializes the server (starts the caches, finalizes plugin registration) but does not start listening on the connection port. + * @return Return value: none. + * Note that if the method fails and throws an error, the server is considered to be in an undefined state and + * should be shut down. In most cases it would be impossible to fully recover as the various plugins, caches, and + * other event listeners will get confused by repeated attempts to start the server or make assumptions about the + * healthy state of the environment. It is recommended to abort the process when the server fails to start properly. + * If you must try to resume after an error, call server.stop() first to reset the server state. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverinitialize) + */ + initialize(): Promise; + + /** + * Injects a request into the server simulating an incoming HTTP request without making an actual socket connection. Injection is useful for testing purposes as well as for invoking routing logic + * internally without the overhead and limitations of the network stack. The method utilizes the shot module for performing injections, with some additional options and response properties: + * @param options - can be assigned a string with the requested URI, or an object with: + * * method - (optional) the request HTTP method (e.g. 'POST'). Defaults to 'GET'. + * * url - (required) the request URL. If the URI includes an authority (e.g. 'example.com:8080'), it is used to automatically set an HTTP 'Host' header, unless one was specified in headers. + * * headers - (optional) an object with optional request headers where each key is the header name and the value is the header content. Defaults to no additions to the default shot headers. + * * payload - (optional) an string, buffer or object containing the request payload. In case of an object it will be converted to a string for you. Defaults to no payload. Note that payload + * processing defaults to 'application/json' if no 'Content-Type' header provided. + * * credentials - (optional) an credentials object containing authentication information. The credentials are used to bypass the default authentication strategies, and are validated directly as + * if they were received via an authentication scheme. Defaults to no credentials. + * * artifacts - (optional) an artifacts object containing authentication artifact information. The artifacts are used to bypass the default authentication strategies, and are validated directly + * as if they were received via an authentication scheme. Ignored if set without credentials. Defaults to no artifacts. + * * app - (optional) sets the initial value of request.app, defaults to {}. + * * plugins - (optional) sets the initial value of request.plugins, defaults to {}. + * * allowInternals - (optional) allows access to routes with config.isInternal set to true. Defaults to false. + * * remoteAddress - (optional) sets the remote address for the incoming connection. + * * simulate - (optional) an object with options used to simulate client request stream conditions for testing: + * * error - if true, emits an 'error' event after payload transmission (if any). Defaults to false. + * * close - if true, emits a 'close' event after payload transmission (if any). Defaults to false. + * * end - if false, does not end the stream. Defaults to true. + * * split - indicates whether the request payload will be split into chunks. Defaults to undefined, meaning payload will not be chunked. + * * validate - (optional) if false, the options inputs are not validated. This is recommended for run-time usage of inject() to make it perform faster where input validation can be tested + * separately. + * @return Return value: a response object with the following properties: + * * statusCode - the HTTP status code. + * * headers - an object containing the headers set. + * * payload - the response payload string. + * * rawPayload - the raw response payload buffer. + * * raw - an object with the injection request and response objects: + * * req - the simulated node request object. + * * res - the simulated node response object. + * * result - the raw handler response (e.g. when not a stream or a view) before it is serialized for transmission. If not available, the value is set to payload. Useful for inspection and reuse + * of the internal objects returned (instead of parsing the response string). + * * request - the request object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverinjectoptions) + */ + inject (options: string | ServerInjectOptions): Promise>; + + /** + * Logs server events that cannot be associated with a specific request. When called the server emits a 'log' event which can be used by other listeners or plugins to record the information or + * output to the console. The arguments are: + * @param tags - (required) a string or an array of strings (e.g. ['error', 'database', 'read']) used to identify the event. Tags are used instead of log levels and provide a much more expressive + * mechanism for describing and filtering events. Any logs generated by the server internally include the 'hapi' tag along with event-specific information. + * @param data - (optional) an message string or object with the application data being logged. If data is a function, the function signature is function() and it called once to generate (return + * value) the actual data emitted to the listeners. If no listeners match the event, the data function is not invoked. + * @param timestamp - (optional) an timestamp expressed in milliseconds. Defaults to Date.now() (now). + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverlogtags-data-timestamp) + */ + log(tags: string | string[], data?: string | object | (() => any) | undefined, timestamp?: number | undefined): void; + + /** + * Looks up a route configuration where: + * @param id - the route identifier. + * @return Return value: the route information if found, otherwise null. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverlookupid) + */ + lookup(id: string): RequestRoute | null; + + /** + * Looks up a route configuration where: + * @param method - the HTTP method (e.g. 'GET', 'POST'). + * @param path - the requested path (must begin with '/'). + * @param host - (optional) hostname (to match against routes with vhost). + * @return Return value: the route information if found, otherwise null. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermatchmethod-path-host) + */ + match(method: HTTP_METHODS | Lowercase, path: string, host?: string | undefined): RequestRoute | null; + + /** + * Registers a server method where: + * @param name - a unique method name used to invoke the method via server.methods[name]. + * @param method - the method function with a signature async function(...args, [flags]) where: + * * ...args - the method function arguments (can be any number of arguments or none). + * * flags - when caching is enabled, an object used to set optional method result flags: + * * * ttl - 0 if result is valid but cannot be cached. Defaults to cache policy. + * @param options - (optional) configuration object: + * * bind - a context object passed back to the method function (via this) when called. Defaults to active context (set via server.bind() when the method is registered. Ignored if the method is + * an arrow function. + * * cache - the same cache configuration used in server.cache(). The generateTimeout option is required. + * * generateKey - a function used to generate a unique key (for caching) from the arguments passed to the method function (the flags argument is not passed as input). The server will + * automatically generate a unique key if the function's arguments are all of types 'string', 'number', or 'boolean'. However if the method uses other types of arguments, a key generation + * function must be provided which takes the same arguments as the function and returns a unique string (or null if no key can be generated). + * @return Return value: none. + * Method names can be nested (e.g. utils.users.get) which will automatically create the full path under server.methods (e.g. accessed via server.methods.utils.users.get). + * When configured with caching enabled, server.methods[name].cache is assigned an object with the following properties and methods: - await drop(...args) - a function that can be used to clear + * the cache for a given key. - stats - an object with cache statistics, see catbox for stats documentation. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermethodname-method-options) + */ + method(name: string, method: ServerMethod, options?: ServerMethodOptions | undefined): void; + + /** + * Registers a server method function as described in server.method() using a configuration object where: + * @param methods - an object or an array of objects where each one contains: + * * name - the method name. + * * method - the method function. + * * options - (optional) settings. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servermethodmethods) + */ + method(methods: ServerMethodConfigurationObject | ServerMethodConfigurationObject[]): void; + + /** + * Sets the path prefix used to locate static resources (files and view templates) when relative paths are used where: + * @param relativeTo - the path prefix added to any relative file path starting with '.'. + * @return Return value: none. + * Note that setting a path within a plugin only applies to resources accessed by plugin methods. If no path is set, the server default route configuration files.relativeTo settings is used. The + * path only applies to routes added after it has been set. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverpathrelativeto) + */ + path(relativeTo: string): void; + + /** + * Registers a plugin where: + * @param plugins - one or an array of: + * * a plugin object. + * * an object with the following: + * * * plugin - a plugin object. + * * * options - (optional) options passed to the plugin during registration. + * * * once, routes - (optional) plugin-specific registration options as defined below. + * @param options - (optional) registration options (different from the options passed to the registration function): + * * once - if true, subsequent registrations of the same plugin are skipped without error. Cannot be used with plugin options. Defaults to false. If not set to true, an error will be thrown the + * second time a plugin is registered on the server. + * * routes - modifiers applied to each route added by the plugin: + * * * prefix - string added as prefix to any route path (must begin with '/'). If a plugin registers a child plugin the prefix is passed on to the child or is added in front of the + * child-specific prefix. + * * * vhost - virtual host string (or array of strings) applied to every route. The outer-most vhost overrides the any nested configuration. + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverregisterplugins-options) + */ + register(plugins: Plugin, options?: ServerRegisterOptions | undefined): Promise; + register(plugin: ServerRegisterPluginObject, options?: ServerRegisterOptions | undefined): Promise; + register(plugins: Plugin[], options?: ServerRegisterOptions | undefined): Promise; + register(plugins: ServerRegisterPluginObject[], options?: ServerRegisterOptions | undefined): Promise; + register(plugins: ServerRegisterPluginObjectArray, options?: ServerRegisterOptions | undefined): Promise; + + /** + * Adds a route where: + * @param route - a route configuration object or an array of configuration objects where each object contains: + * * path - (required) the absolute path used to match incoming requests (must begin with '/'). Incoming requests are compared to the configured paths based on the server's router configuration. + * The path can include named parameters enclosed in {} which will be matched against literal values in the request as described in Path parameters. + * * method - (required) the HTTP method. Typically one of 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', or 'OPTIONS'. Any HTTP method is allowed, except for 'HEAD'. Use '*' to match against any HTTP + * method (only when an exact match was not found, and any match with a specific method will be given a higher priority over a wildcard match). Can be assigned an array of methods which has + * the same result as adding the same route with different methods manually. + * * vhost - (optional) a domain string or an array of domain strings for limiting the route to only requests with a matching host header field. Matching is done against the hostname part of the + * header only (excluding the port). Defaults to all hosts. + * * handler - (required when handler is not set) the route handler function called to generate the response after successful authentication and validation. + * * options - additional route options. The options value can be an object or a function that returns an object using the signature function(server) where server is the server the route is being + * added to and this is bound to the current realm's bind option. + * * rules - route custom rules object. The object is passed to each rules processor registered with server.rules(). Cannot be used if route.options.rules is defined. + * @return Return value: none. + * Note that the options object is deeply cloned (with the exception of bind which is shallowly copied) and cannot contain any values that are unsafe to perform deep copy on. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverrouteroute) + */ + route (route: ServerRoute | ServerRoute[]): void; + + /** + * Defines a route rules processor for converting route rules object into route configuration where: + * @param processor - a function using the signature function(rules, info) where: + * * rules - + * * info - an object with the following properties: + * * * method - the route method. + * * * path - the route path. + * * * vhost - the route virtual host (if any defined). + * * returns a route config object. + * @param options - optional settings: + * * validate - rules object validation: + * * * schema - joi schema. + * * * options - optional joi validation options. Defaults to { allowUnknown: true }. + * Note that the root server and each plugin server instance can only register one rules processor. If a route is added after the rules are configured, it will not include the rules config. + * Routes added by plugins apply the rules to each of the parent realms' rules from the root to the route's realm. This means the processor defined by the plugin override the config generated + * by the root processor if they overlap. The route config overrides the rules config if the overlap. + * @return void + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverrulesprocessor-options) + */ + rules ( + processor: RulesProcessor, + options?: RulesOptions | undefined + ): void; + + /** + * Starts the server by listening for incoming requests on the configured port (unless the connection was configured with autoListen set to false). + * @return Return value: none. + * Note that if the method fails and throws an error, the server is considered to be in an undefined state and should be shut down. In most cases it would be impossible to fully recover as the + * various plugins, caches, and other event listeners will get confused by repeated attempts to start the server or make assumptions about the healthy state of the environment. It is + * recommended to abort the process when the server fails to start properly. If you must try to resume after an error, call server.stop() first to reset the server state. If a started server + * is started again, the second call to server.start() is ignored. No events will be emitted and no extension points invoked. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverstart) + */ + start(): Promise; + + /** + * HTTP state management uses client cookies to persist a state across multiple requests. + * @param name - the cookie name string. + * @param options - are the optional cookie settings + * @return Return value: none. + * State defaults can be modified via the server default state configuration option. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverstatename-options) + */ + state(name: string, options?: ServerStateCookieOptions | undefined): void; + + /** + * Stops the server's listener by refusing to accept any new connections or requests (existing connections will continue until closed or timeout), where: + * @param options - (optional) object with: + * * timeout - overrides the timeout in millisecond before forcefully terminating a connection. Defaults to 5000 (5 seconds). + * @return Return value: none. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-await-serverstopoptions) + */ + stop(options?: {timeout: number} | undefined): Promise; + + /** + * Returns a copy of the routing table where: + * @param host - (optional) host to filter routes matching a specific virtual host. Defaults to all virtual hosts. + * @return Return value: an array of routes where each route contains: + * * settings - the route config with defaults applied. + * * method - the HTTP method in lower case. + * * path - the route path. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-servertablehost) + */ + table(host?: string | string[] | undefined): RequestRoute[]; + + /** + * Registers a server validation module used to compile raw validation rules into validation schemas for all routes. + * The validator is only used when validation rules are not pre-compiled schemas. When a validation rules is a function or schema object, the rule is used as-is and the validator is not used. + */ + validator(joi: Root): void; +} + +/** + * Factory function to create a new server object (introduced in v17). + */ +export function server(opts?: ServerOptions | undefined): Server; diff --git a/lib/types/server/state.d.ts b/lib/types/server/state.d.ts new file mode 100644 index 000000000..e0c049896 --- /dev/null +++ b/lib/types/server/state.d.ts @@ -0,0 +1,79 @@ +import { StateOptions, SameSitePolicy } from '@hapi/statehood'; + +import { Request } from '../request'; + +export { SameSitePolicy }; + +/** + * Optional cookie settings + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverstatename-options) + */ +export interface ServerStateCookieOptions extends StateOptions {} + +/** + * A single object or an array of object where each contains: + * * name - the cookie name. + * * value - the cookie value. + * * options - cookie configuration to override the server settings. + */ +export interface ServerStateFormat { + name: string; + value: string; + options: ServerStateCookieOptions; +} + +/** + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serverstatename-options) + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-serveroptionsstate) + */ +export interface ServerState { + /** + * The server cookies manager. + * Access: read only and statehood public interface. + */ + readonly states: object; + + /** + * The server cookies manager settings. The settings are based on the values configured in [server.options.state](https://github.com/hapijs/hapi/blob/master/API.md#server.options.state). + */ + readonly settings: ServerStateCookieOptions; + + /** + * An object containing the configuration of each cookie added via [server.state()](https://github.com/hapijs/hapi/blob/master/API.md#server.state()) where each key is the + * cookie name and value is the configuration object. + */ + readonly cookies: { + [key: string]: ServerStateCookieOptions; + }; + + /** + * An array containing the names of all configured cookies. + */ + readonly names: string[]; + + /** + * Same as calling [server.state()](https://github.com/hapijs/hapi/blob/master/API.md#server.state()). + */ + add(name: string, options?: ServerStateCookieOptions | undefined): void; + + /** + * Formats an HTTP 'Set-Cookie' header based on the server.options.state where: + * @param cookies - a single object or an array of object where each contains: + * * name - the cookie name. + * * value - the cookie value. + * * options - cookie configuration to override the server settings. + * @return Return value: a header string. + * Note that this utility uses the server configuration but does not change the server state. It is provided for manual cookie formatting (e.g. when headers are set manually). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-async-serverstatesformatcookies) + */ + format(cookies: ServerStateFormat | ServerStateFormat[]): Promise; + + /** + * Parses an HTTP 'Cookies' header based on the server.options.state where: + * @param header - the HTTP header. + * @return Return value: an object where each key is a cookie name and value is the parsed cookie. + * Note that this utility uses the server configuration but does not change the server state. It is provided for manual cookie parsing (e.g. when server parsing is disabled). + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-async-serverstatesparseheader) + */ + parse(header: string): Promise>; +} diff --git a/lib/types/utils.d.ts b/lib/types/utils.d.ts new file mode 100644 index 000000000..feb342e0a --- /dev/null +++ b/lib/types/utils.d.ts @@ -0,0 +1,113 @@ +import * as https from 'https'; +import * as stream from 'stream'; + +import { Boom } from '@hapi/boom'; +import { ResponseObject as ShotResponseObject } from '@hapi/shot'; + +import { + ReqRef, + ReqRefDefaults, + MergeRefs, + Request} from './request'; +import { ResponseToolkit, Auth } from './response'; + +/** + * All http parser [supported HTTP methods](https://nodejs.org/api/http.html#httpmethods). + */ +export type HTTP_METHODS = 'ACL' | 'BIND' | 'CHECKOUT' | 'CONNECT' | 'COPY' | 'DELETE' | 'GET' | 'HEAD' | 'LINK' | 'LOCK' | + 'M-SEARCH' | 'MERGE' | 'MKACTIVITY' | 'MKCALENDAR' | 'MKCOL' | 'MOVE' | 'NOTIFY' | 'OPTIONS' | 'PATCH' | 'POST' | + 'PROPFIND' | 'PROPPATCH' | 'PURGE' | 'PUT' | 'REBIND' | 'REPORT' | 'SEARCH' | 'SOURCE' | 'SUBSCRIBE' | 'TRACE' | + 'UNBIND' | 'UNLINK' | 'UNLOCK' | 'UNSUBSCRIBE'; + +export type PeekListener = (chunk: string, encoding: string) => void; + +export namespace Json { + /** + * @see {@link https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter} + */ + type StringifyReplacer = ((key: string, value: any) => any) | (string | number)[] | undefined; + + /** + * Any value greater than 10 is truncated. + */ + type StringifySpace = number | string; + + /** + * For context [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-routeoptionsjson) + */ + interface StringifyArguments { + /** the replacer function or array. Defaults to no action. */ + replacer?: StringifyReplacer | undefined; + /** number of spaces to indent nested object keys. Defaults to no indentation. */ + space?: StringifySpace | undefined; + /* string suffix added after conversion to JSON string. Defaults to no suffix. */ + suffix?: string | undefined; + /* calls Hoek.jsonEscape() after conversion to JSON string. Defaults to false. */ + escape?: boolean | undefined; + } +} + +export namespace Lifecycle { + /** + * Lifecycle methods are the interface between the framework and the application. Many of the request lifecycle steps: + * extensions, authentication, handlers, pre-handler methods, and failAction function values are lifecycle methods + * provided by the developer and executed by the framework. + * Each lifecycle method is a function with the signature await function(request, h, [err]) where: + * * request - the request object. + * * h - the response toolkit the handler must call to set a response and return control back to the framework. + * * err - an error object available only when the method is used as a failAction value. + */ + type Method< + Refs extends ReqRef = ReqRefDefaults, + R extends ReturnValue = ReturnValue + > = ( + this: MergeRefs['Bind'], + request: Request, + h: ResponseToolkit, + err?: Error | undefined + ) => R; + + /** + * Each lifecycle method must return a value or a promise that resolves into a value. If a lifecycle method returns + * without a value or resolves to an undefined value, an Internal Server Error (500) error response is sent. + * The return value must be one of: + * - Plain value: null, string, number, boolean + * - Buffer object + * - Error object: plain Error OR a Boom object. + * - Stream object + * - any object or array + * - a toolkit signal: + * - a toolkit method response: + * - a promise object that resolve to any of the above values + * For more info please [See docs](https://github.com/hapijs/hapi/blob/master/API.md#lifecycle-methods) + */ + type ReturnValue = ReturnValueTypes | (Promise>); + type ReturnValueTypes = + (null | string | number | boolean) | + (Buffer) | + (Error | Boom) | + (stream.Stream) | + (object | object[]) | + symbol | + Auth< + MergeRefs['AuthUser'], + MergeRefs['AuthApp'], + MergeRefs['AuthCredentialsExtra'], + MergeRefs['AuthArtifactsExtra'] + > | + ShotResponseObject; + + /** + * Various configuration options allows defining how errors are handled. For example, when invalid payload is received or malformed cookie, instead of returning an error, the framework can be + * configured to perform another action. When supported the failAction option supports the following values: + * * 'error' - return the error object as the response. + * * 'log' - report the error but continue processing the request. + * * 'ignore' - take no action and continue processing the request. + * * a lifecycle method with the signature async function(request, h, err) where: + * * * request - the request object. + * * * h - the response toolkit. + * * * err - the error object. + * [See docs](https://github.com/hapijs/hapi/blob/master/API.md#-failaction-configuration) + */ + type FailAction = 'error' | 'log' | 'ignore' | Method; +} diff --git a/lib/validation.js b/lib/validation.js index c9d117503..e94bf1349 100755 --- a/lib/validation.js +++ b/lib/validation.js @@ -1,29 +1,68 @@ 'use strict'; -// Load modules +const Boom = require('@hapi/boom'); +const Hoek = require('@hapi/hoek'); +const Validate = require('@hapi/validate'); -const Boom = require('boom'); -const Hoek = require('hoek'); -const Joi = require('joi'); +const internals = {}; -// Declare internals -const internals = {}; +exports.validator = function (validator) { + Hoek.assert(validator, 'Missing validator'); + Hoek.assert(typeof validator.compile === 'function', 'Invalid validator compile method'); -exports.compile = function (rule) { + return validator; +}; + + +exports.compile = function (rule, validator, realm, core) { + + validator = validator ?? internals.validator(realm, core); - // null, undefined, true - anything allowed // false - nothing allowed + + if (rule === false) { + return Validate.object({}).allow(null); + } + + // Custom function + + if (typeof rule === 'function') { + return rule; + } + + // null, undefined, true - anything allowed + + if (!rule || // false tested above + rule === true) { + + return null; + } + // {...} - ... allowed - return (rule === false) ? - Joi.object({}).allow(null) : - (typeof rule === 'function' ? - rule : - !rule || rule === true ? null : Joi.compile(rule)); // false tested earlier + if (typeof rule.validate === 'function') { + return rule; + } + + Hoek.assert(validator, 'Cannot set uncompiled validation rules without configuring a validator'); + return validator.compile(rule); +}; + + +internals.validator = function (realm, core) { + + while (realm) { + if (realm.validator) { + return realm.validator; + } + + realm = realm.parent; + } + return core.validator; }; @@ -57,6 +96,12 @@ exports.query = function (request) { }; +exports.state = function (request) { + + return internals.input('state', request); +}; + + internals.input = async function (source, request) { const localOptions = { @@ -65,6 +110,7 @@ internals.input = async function (source, request) { params: request.params, query: request.query, payload: request.payload, + state: request.state, auth: request.auth, app: { route: request.route.settings.app, @@ -80,7 +126,7 @@ internals.input = async function (source, request) { const schema = request.route.settings.validate[source]; const bind = request.route.settings.bind; - var value = await (typeof schema !== 'function' ? Joi.validate(request[source], schema, localOptions) : schema.call(bind, request[source], localOptions)); + var value = await (typeof schema !== 'function' ? internals.validate(request[source], schema, localOptions) : schema.call(bind, request[source], localOptions)); return; } catch (err) { @@ -100,19 +146,17 @@ internals.input = async function (source, request) { // Prepare error const defaultError = validationError.isBoom ? validationError : Boom.badRequest(`Invalid request ${source} input`); - const detailedError = Boom.boomify(validationError, { statusCode: 400, override: false }); + const detailedError = Boom.boomify(validationError, { statusCode: 400, override: false, data: { defaultError } }); detailedError.output.payload.validation = { source, keys: [] }; if (validationError.details) { - for (let i = 0; i < validationError.details.length; ++i) { - const path = validationError.details[i].path; + for (const details of validationError.details) { + const path = details.path; detailedError.output.payload.validation.keys.push(Hoek.escapeHtml(path.join('.'))); } } if (request.route.settings.validate.errorFields) { - const fields = Object.keys(request.route.settings.validate.errorFields); - for (let i = 0; i < fields.length; ++i) { - const field = fields[i]; + for (const field in request.route.settings.validate.errorFields) { detailedError.output.payload[field] = request.route.settings.validate.errorFields[field]; } } @@ -124,7 +168,7 @@ internals.input = async function (source, request) { exports.response = async function (request) { if (request.route.settings.response.sample) { - const currentSample = Math.ceil((Math.random() * 100)); + const currentSample = Math.ceil(Math.random() * 100); if (currentSample > request.route.settings.response.sample) { return; } @@ -140,7 +184,7 @@ exports.response = async function (request) { return; // Do not validate errors by default } - const schema = statusSchema || request.route.settings.response.schema; + const schema = statusSchema !== undefined ? statusSchema : request.route.settings.response.schema; if (schema === null) { return; // No rules } @@ -157,6 +201,7 @@ exports.response = async function (request) { params: request.params, query: request.query, payload: request.payload, + state: request.state, auth: request.auth, app: { route: request.route.settings.app, @@ -172,7 +217,7 @@ exports.response = async function (request) { let value; if (typeof schema !== 'function') { - value = await Joi.validate(source, schema, localOptions); + value = await internals.validate(source, schema, localOptions); } else { value = await schema(source, localOptions); @@ -190,7 +235,16 @@ exports.response = async function (request) { } } catch (err) { - return request._core.toolkit.failAction(request, request.route.settings.response.failAction, err, { tags: ['validation', 'response', 'error'] }); } }; + + +internals.validate = function (value, schema, options) { + + if (typeof schema.validateAsync === 'function') { + return schema.validateAsync(value, options); + } + + return schema.validate(value, options); +}; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json deleted file mode 100755 index 1a302e505..000000000 --- a/npm-shrinkwrap.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "name": "hapi", - "lockfileVersion": 1, - "dependencies": { - "accept": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/accept/-/accept-3.0.2.tgz", - "integrity": "sha1-g+Qc7H4RSfP9R0iAQjhz22xsyaw=" - }, - "ammo": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ammo/-/ammo-3.0.0.tgz", - "integrity": "sha1-MPMi9woJBOruF4j00mxb4dctoYE=" - }, - "b64": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/b64/-/b64-4.0.0.tgz", - "integrity": "sha1-w39Yfwo4PHAZ6CESDow/WPDSJ3I=" - }, - "big-time": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/boom/-/big-time-2.0.0.tgz", - "integrity": "sha1-cBk7YPr/cslu1SMxtVVJDmBNfMw=" - }, - "boom": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/boom/-/boom-7.2.0.tgz", - "integrity": "sha512-h0r6/VHlhrLVFrTj612v5EPwqyMs3L79Uf4vEw0zFmywodU8TveiIuINp0//3/GRnAWrQbgSnazSosNkyAeVNA==" - }, - "bounce": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/bounce/-/bounce-1.2.0.tgz", - "integrity": "sha512-8syCGe8B2/WC53118/F/tFy5aW00j+eaGPXmAUP7iBhxc+EBZZxS1vKelWyBCH6IqojgS2t1gF0glH30qAJKEw==" - }, - "call": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/call/-/call-5.0.1.tgz", - "integrity": "sha1-rBtcEG2e3CoXrypKT3TdTwwG6RA=" - }, - "catbox": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/catbox/-/catbox-10.0.2.tgz", - "integrity": "sha1-5qwfNRAtGpvQeRW4LlCNErUKi/o=" - }, - "catbox-memory": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/catbox-memory/-/catbox-memory-3.1.1.tgz", - "integrity": "sha1-TzmINaPY9ZksMd3uJDeEZ0mKnJo=" - }, - "content": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/content/-/content-4.0.4.tgz", - "integrity": "sha512-h0r6/VHlhrLVFrTj612v5EPwqyMs3L79Uf4vEw0zFmywodU8TveiIuINp0//3/GRnAWrQbgSnazSosNkyAeVNA==" - }, - "cryptiles": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-4.1.1.tgz", - "integrity": "sha1-FpJWud+f48c/gIXJnjCzIkfUq0Y=" - }, - "heavy": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/heavy/-/heavy-6.1.0.tgz", - "integrity": "sha512-TKS9DC9NOTGulHQI31Lx+bmeWmNOstbJbGMiN3pX6bF+Zc2GKSpbbym4oasNnB6yPGkqJ9TQXXYDGohqNSJRxA==" - }, - "hoek": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/hoek/-/hoek-5.0.3.tgz", - "integrity": "sha512-h0r6/VHlhrLVFrTj612v5EPwqyMs3L79Uf4vEw0zFmywodU8TveiIuINp0//3/GRnAWrQbgSnazSosNkyAeVNA==" - }, - "iron": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/iron/-/iron-5.0.4.tgz", - "integrity": "sha1-AD7YIvZW8HwrYnYoFfXeOUcyaGc=" - }, - "joi": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-13.1.2.tgz", - "integrity": "sha512-h0r6/VHlhrLVFrTj612v5EPwqyMs3L79Uf4vEw0zFmywodU8TveiIuINp0//3/GRnAWrQbgSnazSosNkyAeVNA==" - }, - "mime-db": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", - "integrity": "sha512-h0r6/VHlhrLVFrTj612v5EPwqyMs3L79Uf4vEw0zFmywodU8TveiIuINp0//3/GRnAWrQbgSnazSosNkyAeVNA==" - }, - "mimos": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimos/-/mimos-4.0.0.tgz", - "integrity": "sha1-duPScShDHLZIL9FbIEdXGa1ialo=" - }, - "nigel": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/nigel/-/nigel-3.0.0.tgz", - "integrity": "sha1-puM3ioo0KB51uhZB6IakFdS+k60=" - }, - "pez": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pez/-/pez-4.0.1.tgz", - "integrity": "sha1-1pjs+aFGyRiNdKvlzve1y3Hes7U=" - }, - "podium": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/podium/-/podium-3.1.2.tgz", - "integrity": "sha1-twFClznPa93mswFa5rSNQAgXzp4=" - }, - "shot": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/shot/-/shot-4.0.5.tgz", - "integrity": "sha512-h0r6/VHlhrLVFrTj612v5EPwqyMs3L79Uf4vEw0zFmywodU8TveiIuINp0//3/GRnAWrQbgSnazSosNkyAeVNA==" - }, - "statehood": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/statehood/-/statehood-6.0.5.tgz", - "integrity": "sha1-u83LOkOm+4b0vQTtSKkwm2z8Gxg=" - }, - "subtext": { - "version": "6.0.7", - "resolved": "https://registry.npmjs.org/subtext/-/subtext-6.0.7.tgz", - "integrity": "sha1-jkCmeQGnNNWYFCZlyQ45g2m4hfk=" - }, - "teamwork": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/teamwork/-/teamwork-3.0.1.tgz", - "integrity": "sha1-/zjHFh9B+AcLeBNxbrYVQDbs4ZY=" - }, - "topo": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/topo/-/topo-3.0.0.tgz", - "integrity": "sha1-N+SMMw7+rHhFOOCs0+YspeIx/no=" - }, - "vise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vise/-/vise-3.0.0.tgz", - "integrity": "sha1-dq0UqzFmnFD7sIF7wOcv7cuzv0w=" - }, - "wreck": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/wreck/-/wreck-14.0.2.tgz", - "integrity": "sha1-icF6kGHHRe0cOuvLZuoYHbqrRUw=" - } - }, - "version": "17.2.2" -} diff --git a/package.json b/package.json old mode 100644 new mode 100755 index ce72c0a7d..f586c6d1a --- a/package.json +++ b/package.json @@ -1,55 +1,66 @@ { - "name": "hapi", + "name": "@hapi/hapi", "description": "HTTP Server framework", - "homepage": "http://hapijs.com", - "version": "17.2.2", - "repository": { - "type": "git", - "url": "git://github.com/hapijs/hapi" - }, + "homepage": "https://hapi.dev", + "version": "21.4.10", + "repository": "git://github.com/hapijs/hapi", "main": "lib/index.js", + "types": "lib/index.d.ts", + "engines": { + "node": ">=14.15.0" + }, + "files": [ + "lib" + ], "keywords": [ "framework", "http", "api", "web" ], - "engines": { - "node": ">=8.9.0" + "eslintConfig": { + "extends": [ + "plugin:@hapi/module" + ] }, "dependencies": { - "accept": "3.x.x", - "ammo": "3.x.x", - "boom": "7.x.x", - "bounce": "1.x.x", - "call": "5.x.x", - "catbox": "10.x.x", - "catbox-memory": "3.x.x", - "heavy": "6.x.x", - "hoek": "5.x.x", - "joi": "13.x.x", - "mimos": "4.x.x", - "podium": "3.x.x", - "shot": "4.x.x", - "statehood": "6.x.x", - "subtext": "6.x.x", - "teamwork": "3.x.x", - "topo": "3.x.x" + "@hapi/accept": "^6.0.3", + "@hapi/ammo": "^6.0.1", + "@hapi/boom": "^10.0.1", + "@hapi/bounce": "^3.0.2", + "@hapi/call": "^9.0.1", + "@hapi/catbox": "^12.1.1", + "@hapi/catbox-memory": "^6.0.2", + "@hapi/heavy": "^8.0.1", + "@hapi/hoek": "^11.0.7", + "@hapi/mimos": "^7.0.1", + "@hapi/podium": "^5.0.2", + "@hapi/shot": "^6.0.2", + "@hapi/somever": "^4.1.1", + "@hapi/statehood": "^8.2.1", + "@hapi/subtext": "^8.1.3", + "@hapi/teamwork": "^6.0.1", + "@hapi/topo": "^6.0.2", + "@hapi/validate": "^2.0.1" }, "devDependencies": { - "code": "5.x.x", - "handlebars": "4.x.x", - "hapitoc": "1.x.x", - "inert": "5.x.x", - "lab": "15.x.x", - "vision": "5.0.0", - "wreck": "14.x.x" + "@hapi/code": "^9.0.3", + "@hapi/eslint-plugin": "^6.0.0", + "@hapi/inert": "^7.1.0", + "@hapi/joi-legacy-test": "npm:@hapi/joi@^15.0.0", + "@hapi/lab": "^25.3.2", + "@hapi/vision": "^7.0.3", + "@hapi/wreck": "^18.1.0", + "@types/node": "^18.19.130", + "handlebars": "^4.7.9", + "joi": "^17.13.3", + "legacy-readable-stream": "npm:readable-stream@^1.0.34", + "typescript": "^5.9.3" }, "scripts": { - "test": "lab -a code -t 100 -L -m 3000", - "test-tap": "lab -a code -r tap -o tests.tap -m 3000", - "test-cov-html": "lab -a code -r html -o coverage.html -m 3000", - "toc": "hapitoc" + "test": "lab -a @hapi/code -t 100 -L -m 5000 -Y", + "test-tap": "lab -a @hapi/code -r tap -o tests.tap -m 5000", + "test-cov-html": "lab -a @hapi/code -r html -o coverage.html -m 5000" }, "license": "BSD-3-Clause" } diff --git a/test/auth.js b/test/auth.js index bc26110a3..a9a22e098 100755 --- a/test/auth.js +++ b/test/auth.js @@ -1,25 +1,19 @@ 'use strict'; -// Load modules - const Path = require('path'); -const Boom = require('boom'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const Code = require('@hapi/code'); const Handlebars = require('handlebars'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Lab = require('lab'); -const Vision = require('vision'); - +const Hoek = require('@hapi/hoek'); +const Lab = require('@hapi/lab'); +const Vision = require('@hapi/vision'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -42,12 +36,13 @@ describe('authentication', () => { expect(res2.result).to.equal({ isAuthenticated: true, isAuthorized: false, + isInjected: false, credentials: { user: 'steve' }, artifacts: undefined, strategy: 'default', mode: 'required', error: null - }); + }, { symbols: false }); }); it('disables authentication on a route', async () => { @@ -98,12 +93,14 @@ describe('authentication', () => { const access = { two: request.server.lookup('two').auth.access(request), three1: request.server.lookup('three').auth.access(request), - four1: request.server.lookup('four').auth.access(request) + four1: request.server.lookup('four').auth.access(request), + five1: request.server.lookup('five').auth.access(request) }; request.auth.credentials = null; access.three2 = request.server.lookup('three').auth.access(request); access.four2 = request.server.lookup('four').auth.access(request); + access.five2 = request.server.lookup('five').auth.access(request); request.auth.credentials = credentials; return access; @@ -117,6 +114,7 @@ describe('authentication', () => { server.route({ method: 'GET', path: '/two', options: { id: 'two', handler: () => null, auth: { scope: 'two' } } }); server.route({ method: 'GET', path: '/three', options: { id: 'three', handler: () => null, auth: { scope: 'one' } } }); server.route({ method: 'GET', path: '/four', options: { id: 'four', handler: () => null, auth: false } }); + server.route({ method: 'GET', path: '/five', options: { id: 'five', handler: () => null, auth: { mode: 'required' } } }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); expect(res.statusCode).to.equal(200); @@ -125,7 +123,9 @@ describe('authentication', () => { three1: true, three2: false, four1: true, - four2: true + four2: true, + five1: true, + five2: true }); }); @@ -163,7 +163,7 @@ describe('authentication', () => { expect(res1.statusCode).to.equal(401); const res2 = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res2.statusCode).to.equal(200); + expect(res2.statusCode).to.equal(204); }); it('uses views', async () => { @@ -228,6 +228,47 @@ describe('authentication', () => { expect(server.auth.api.xyz.x).to.equal(5); }); + + it('has its own realm', async () => { + + const implementation = function (server) { + + return { + authenticate: (_, h) => h.authenticated({ credentials: server.realm }) + }; + }; + + const server = Hapi.server(); + + server.auth.scheme('custom', implementation); + server.auth.strategy('root', 'custom'); + + let pluginA; + + await server.register({ + name: 'plugin-a', + register(srv) { + + pluginA = srv; + + srv.auth.strategy('a', 'custom'); + } + }); + + const handler = (request) => request.auth.credentials; + server.route({ method: 'GET', path: '/a', handler, options: { auth: 'a' } }); + server.route({ method: 'GET', path: '/root', handler, options: { auth: 'root' } }); + + const { result: realm1 } = await server.inject('/a'); + expect(realm1.plugin).to.be.undefined(); + expect(realm1).to.not.shallow.equal(server.realm); + expect(realm1.parent).to.shallow.equal(pluginA.realm); + + const { result: realm2 } = await server.inject('/root'); + expect(realm2.plugin).to.be.undefined(); + expect(realm2).to.not.shallow.equal(server.realm); + expect(realm2.parent).to.shallow.equal(server.realm); + }); }); describe('default()', () => { @@ -247,7 +288,7 @@ describe('authentication', () => { expect(res1.statusCode).to.equal(401); const res2 = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res2.statusCode).to.equal(200); + expect(res2.statusCode).to.equal(204); }); it('sets default with object', async () => { @@ -255,14 +296,17 @@ describe('authentication', () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); server.auth.strategy('default', 'custom', { users: { steve: {} } }); + server.auth.default({ strategy: 'default' }); + expect(server.auth.settings.default).to.equal({ strategies: ['default'], mode: 'required' }); + server.route({ method: 'GET', path: '/', handler: (request) => request.auth.credentials.user }); const res1 = await server.inject('/'); expect(res1.statusCode).to.equal(401); const res2 = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res2.statusCode).to.equal(200); + expect(res2.statusCode).to.equal(204); }); it('throws when setting default twice', () => { @@ -425,7 +469,7 @@ describe('authentication', () => { const doubleHandler = async (request) => { - const options = { url: '/2', credentials: request.auth.credentials }; + const options = { url: '/2', auth: { credentials: request.auth.credentials, strategy: 'default' } }; const res = await server.inject(options); return res.result; }; @@ -447,7 +491,7 @@ describe('authentication', () => { const doubleHandler = async (request) => { - const options = { url: '/2', credentials: request.auth.credentials, artifacts: '!' }; + const options = { url: '/2', auth: { credentials: request.auth.credentials, artifacts: '!', strategy: 'default' } }; const res = await server.inject(options); return res.result; }; @@ -483,7 +527,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('authenticates a request with auth strategy name config', async () => { @@ -501,7 +545,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('tries to authenticate a request', async () => { @@ -611,7 +655,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches scope (array to array)', async () => { @@ -632,7 +676,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches scope (single to array)', async () => { @@ -653,7 +697,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches scope (single to single)', async () => { @@ -674,7 +718,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches dynamic scope (single to single)', async () => { @@ -695,7 +739,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/test', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches multiple required dynamic scopes', async () => { @@ -716,7 +760,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/test', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches multiple required dynamic scopes (mixed types)', async () => { @@ -737,7 +781,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/test', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches dynamic scope with multiple parts (single to single)', async () => { @@ -758,7 +802,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/test/admin', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('does not match broken dynamic scope (single to single)', async () => { @@ -835,7 +879,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('errors on missing scope', async () => { @@ -911,7 +955,7 @@ describe('authentication', () => { expect(res1.result.message).to.equal('Insufficient scope'); const res2 = await server.inject({ url: '/', headers: { authorization: 'Custom john' } }); - expect(res2.statusCode).to.equal(200); + expect(res2.statusCode).to.equal(204); }); it('validates forbidden scope', async () => { @@ -943,7 +987,7 @@ describe('authentication', () => { expect(res1.result.message).to.equal('Insufficient scope'); const res2 = await server.inject({ url: '/', headers: { authorization: 'Custom john' } }); - expect(res2.statusCode).to.equal(200); + expect(res2.statusCode).to.equal(204); }); it('validates complex scope', async () => { @@ -978,10 +1022,10 @@ describe('authentication', () => { expect(res1.result.message).to.equal('Insufficient scope'); const res2 = await server.inject({ url: '/', headers: { authorization: 'Custom john' } }); - expect(res2.statusCode).to.equal(200); + expect(res2.statusCode).to.equal(204); const res3 = await server.inject({ url: '/', headers: { authorization: 'Custom mary' } }); - expect(res3.statusCode).to.equal(200); + expect(res3.statusCode).to.equal(204); const res4 = await server.inject({ url: '/', headers: { authorization: 'Custom lucy' } }); expect(res4.statusCode).to.equal(403); @@ -1014,6 +1058,34 @@ describe('authentication', () => { expect(res.result.message).to.equal('Insufficient scope'); }); + it('uses default scope when no scope override is set', async () => { + + const server = Hapi.server(); + server.auth.scheme('custom', internals.implementation); + server.auth.strategy('a', 'custom', { users: { steve: { scope: ['two'] } } }); + server.auth.default({ + strategy: 'a', + access: { + scope: 'one' + } + }); + + server.route({ + path: '/', + method: 'GET', + options: { + auth: { + mode: 'required' + }, + handler: () => 'ok' + } + }); + + const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); + expect(res.statusCode).to.equal(403); + expect(res.result.message).to.equal('Insufficient scope'); + }); + it('ignores default scope when override set to null', async () => { const server = Hapi.server(); @@ -1036,7 +1108,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches scope (access single)', async () => { @@ -1063,12 +1135,13 @@ describe('authentication', () => { expect(res.result).to.equal({ isAuthenticated: true, isAuthorized: true, + isInjected: false, credentials: { scope: ['one'], user: null }, artifacts: undefined, strategy: 'default', mode: 'required', error: null - }); + }, { symbols: false }); }); it('matches scope (access array)', async () => { @@ -1092,7 +1165,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('errors on matching scope (access array)', async () => { @@ -1140,7 +1213,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('matches user entity', async () => { @@ -1161,7 +1234,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('errors on missing user entity', async () => { @@ -1204,7 +1277,7 @@ describe('authentication', () => { }); const res = await server.inject({ url: '/', headers: { authorization: 'Custom client' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('errors on missing app entity', async () => { @@ -1278,8 +1351,11 @@ describe('authentication', () => { const options = { url: '/', headers: { authorization: 'Custom steve' }, - credentials: { foo: 'bar' }, - artifacts: { bar: 'baz' } + auth: { + credentials: { foo: 'bar' }, + artifacts: { bar: 'baz' }, + strategy: 'default' + } }; const res = await server.inject(options); @@ -1304,7 +1380,7 @@ describe('authentication', () => { expect(res.statusCode).to.equal(500); }); - it('passes credentials on unauthenticated()', async () => { + it('passes credentials on unauthenticated() in try mode', async () => { const scheme = () => { @@ -1325,7 +1401,129 @@ describe('authentication', () => { server.route({ method: 'GET', path: '/', handler: () => null }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); + }); + + it('passes strategy, credentials, artifacts, error on unauthenticated() in required mode', async () => { + + const scheme = () => { + + return { authenticate: (request, h) => h.unauthenticated(Boom.unauthorized(), { credentials: { user: 'steve' }, artifacts: '!' }) }; + }; + + const server = Hapi.server(); + server.ext('onPreResponse', (request, h) => { + + if (request.auth.credentials.user === 'steve') { + return h.continue; + } + }); + + server.ext('onPreResponse', (request, h) => { + + expect(request.auth.credentials).to.equal({ user: 'steve' }); + expect(request.auth.artifacts).to.equal('!'); + expect(request.auth.strategy).to.equal('default'); + expect(request.auth.error.message).to.equal('Unauthorized'); + return h.continue; + }); + + server.auth.scheme('custom', scheme); + server.auth.strategy('default', 'custom'); + server.auth.default('default', { mode: 'required' }); + + server.route({ method: 'GET', path: '/', handler: () => null }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(401); + }); + }); + + describe('verify()', () => { + + it('verifies an authenticated request', async () => { + + const implementation = (...args) => { + + const imp = internals.implementation(...args); + imp.verify = async (auth) => { + + await Hoek.wait(1); + if (auth.credentials.user !== 'steve') { + throw Boom.unauthorized('Invalid'); + } + }; + + return imp; + }; + + const server = Hapi.server(); + server.auth.scheme('custom', implementation); + server.auth.strategy('default', 'custom', { users: { steve: { user: 'steve' }, john: { user: 'john' } } }); + + server.route({ + method: 'GET', + path: '/', + options: { + auth: { + mode: 'try', + strategy: 'default' + }, + handler: async (request) => { + + if (request.auth.error && + request.auth.error.message === 'Missing authentication') { + + request.auth.error = null; + } + + return await server.auth.verify(request) || 'ok'; + } + } + }); + + const res1 = await server.inject('/'); + expect(res1.result).to.equal('ok'); + + const res2 = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); + expect(res2.result).to.equal('ok'); + + const res3 = await server.inject({ url: '/', headers: { authorization: 'Custom unknown' } }); + expect(res3.result.message).to.equal('Missing credentials'); + + const res4 = await server.inject({ url: '/', auth: { credentials: {}, strategy: 'default' } }); + expect(res4.result.message).to.equal('Invalid'); + + const res5 = await server.inject({ url: '/', auth: { credentials: { user: 'steve' }, strategy: 'default' } }); + expect(res5.result).to.equal('ok'); + + const res6 = await server.inject({ url: '/', headers: { authorization: 'Custom john' } }); + expect(res6.result.message).to.equal('Invalid'); + }); + + it('skips when verify unsupported', async () => { + + const server = Hapi.server(); + server.auth.scheme('custom', internals.implementation); + server.auth.strategy('default', 'custom', { users: { steve: { user: 'steve' } } }); + + server.route({ + method: 'GET', + path: '/', + options: { + auth: { + mode: 'try', + strategy: 'default' + }, + handler: async (request) => { + + return await server.auth.verify(request) || 'ok'; + } + } + }); + + const res = await server.inject({ url: '/', headers: { authorization: 'Custom steve' } }); + expect(res.result).to.equal('ok'); }); }); @@ -1378,7 +1576,7 @@ describe('authentication', () => { }); const res = await server.inject({ method: 'POST', url: '/', headers: { authorization: 'Custom validPayload' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('skips when scheme does not support it', async () => { @@ -1396,7 +1594,7 @@ describe('authentication', () => { }); const res = await server.inject({ method: 'POST', url: '/', headers: { authorization: 'Custom validPayload' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('authenticates request payload (required scheme)', async () => { @@ -1415,7 +1613,7 @@ describe('authentication', () => { }); const res = await server.inject({ method: 'POST', url: '/', headers: { authorization: 'Custom validPayload' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('authenticates request payload (required scheme and required route)', async () => { @@ -1436,7 +1634,7 @@ describe('authentication', () => { }); const res = await server.inject({ method: 'POST', url: '/', headers: { authorization: 'Custom validPayload' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('throws when scheme requires payload authentication and route conflicts', () => { @@ -1556,7 +1754,7 @@ describe('authentication', () => { }); const res = await server.inject({ method: 'POST', url: '/', headers: { authorization: 'Custom skip' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('skips request payload when unauthenticated', async () => { @@ -1578,7 +1776,7 @@ describe('authentication', () => { }); const res = await server.inject({ method: 'POST', url: '/' }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('skips optional payload', async () => { @@ -1599,7 +1797,29 @@ describe('authentication', () => { }); const res = await server.inject({ method: 'POST', url: '/', headers: { authorization: 'Custom optionalPayload' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); + }); + + it('skips required payload authentication when disabled on injection', async () => { + + const server = Hapi.server(); + server.auth.scheme('custom', internals.implementation); + server.auth.strategy('default', 'custom'); + server.auth.default('default'); + server.route({ + method: 'POST', + path: '/', + options: { + handler: (request) => null, + auth: { + mode: 'try', + payload: true + } + } + }); + + const res = await server.inject({ method: 'POST', url: '/', auth: { credentials: { payload: Boom.internal('payload error') }, payload: false, strategy: 'default' } }); + expect(res.statusCode).to.equal(204); }); it('errors on missing payload when required', async () => { @@ -1689,8 +1909,8 @@ describe('authentication', () => { const handler = async (request) => { try { - const credentials = await request.server.auth.test('default', request); - return { status: true, user: credentials.name }; + const { credentials, artifacts } = await request.server.auth.test('default', request); + return { status: true, user: credentials.name, artifacts }; } catch (err) { return { status: false }; @@ -1699,7 +1919,7 @@ describe('authentication', () => { const server = Hapi.server(); server.auth.scheme('custom', internals.implementation); - server.auth.strategy('default', 'custom', { users: { steve: { name: 'steve' }, skip: 'skip' } }); + server.auth.strategy('default', 'custom', { users: { steve: { name: 'steve' }, skip: 'skip' }, artifacts: {} }); server.route({ method: 'GET', path: '/', handler }); const res1 = await server.inject('/'); @@ -1710,6 +1930,7 @@ describe('authentication', () => { expect(res2.statusCode).to.equal(200); expect(res2.result.status).to.be.true(); expect(res2.result.user).to.equal('steve'); + expect(res2.result.artifacts).to.equal({}); const res3 = await server.inject({ url: '/', headers: { authorization: 'Custom skip' } }); expect(res3.statusCode).to.equal(200); @@ -1759,7 +1980,7 @@ internals.implementation = function (server, options) { } credentials.user = credentials.user || null; - return h.authenticated({ credentials }); + return h.authenticated({ credentials, artifacts: settings.artifacts }); }, response: (request, h) => { diff --git a/test/common.js b/test/common.js new file mode 100644 index 000000000..eae48a36b --- /dev/null +++ b/test/common.js @@ -0,0 +1,32 @@ +'use strict'; + +const ChildProcess = require('child_process'); +const Http = require('http'); +const Net = require('net'); + +const internals = {}; + +internals.hasLsof = () => { + + try { + ChildProcess.execSync(`lsof -p ${process.pid}`, { stdio: 'ignore' }); + } + catch (err) { + return false; + } + + return true; +}; + +internals.hasIPv6 = () => { + + const server = Http.createServer().listen(); + const { address } = server.address(); + server.close(); + + return Net.isIPv6(address); +}; + +exports.hasLsof = internals.hasLsof(); + +exports.hasIPv6 = internals.hasIPv6(); diff --git a/test/core.js b/test/core.js index 8752d4fca..3730254a0 100755 --- a/test/core.js +++ b/test/core.js @@ -1,42 +1,47 @@ 'use strict'; -// Load modules - const ChildProcess = require('child_process'); +const Events = require('events'); const Fs = require('fs'); const Http = require('http'); const Https = require('https'); const Net = require('net'); const Os = require('os'); const Path = require('path'); +const Stream = require('stream'); const TLS = require('tls'); -const Boom = require('boom'); -const Bounce = require('bounce'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const { Engine: CatboxMemory } = require('@hapi/catbox-memory'); +const Code = require('@hapi/code'); const Handlebars = require('handlebars'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Inert = require('inert'); -const Lab = require('lab'); -const Vision = require('vision'); -const Wreck = require('wreck'); +const Hoek = require('@hapi/hoek'); +const Inert = require('@hapi/inert'); +const Lab = require('@hapi/lab'); +const Teamwork = require('@hapi/teamwork'); +const Vision = require('@hapi/vision'); +const Wreck = require('@hapi/wreck'); +const Common = require('./common'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('Core', () => { - it('sets connections defaults', () => { + it('sets app settings defaults', () => { + + const server = Hapi.server(); + expect(server.settings.app).to.equal({}); + }); + + it('sets app settings', () => { const server = Hapi.server({ app: { message: 'test defaults' } }); expect(server.settings.app.message).to.equal('test defaults'); @@ -88,45 +93,64 @@ describe('Core', () => { it('throws when disabling autoListen and providing special host', () => { - const port = Path.join(__dirname, 'hapi-server.socket'); expect(() => { - Hapi.server({ port, autoListen: false }); + Hapi.server({ port: '/a/b/hapi-server.socket', autoListen: false }); }).to.throw('Cannot specify port when autoListen is false'); }); - it('defaults address to 0.0.0.0 or :: when no host is provided', async () => { + it('defaults address to 0.0.0.0 or :: when no host is provided', async (flags) => { const server = Hapi.server(); await server.start(); + flags.onCleanup = () => server.stop(); - let expectedBoundAddress = '0.0.0.0'; - if (Net.isIPv6(server.listener.address().address)) { - expectedBoundAddress = '::'; - } + const expectedBoundAddress = Common.hasIPv6 ? '::' : '0.0.0.0'; expect(server.info.address).to.equal(expectedBoundAddress); - await server.stop(); }); - it('uses address when present instead of host', async () => { + it('is accessible on localhost when using default host', async (flags) => { + // With hapi v20 this would fail on ipv6 machines on node v18+ due to DNS resolution changes in node (see nodejs/node#40537). + // To address this in hapi v21 we bind to :: if available, otherwise the former default of 0.0.0.0. + + const server = Hapi.server(); + server.route({ method: 'get', path: '/', handler: () => 'ok' }); + + await server.start(); + flags.onCleanup = () => server.stop(); + + const req = Http.get(`http://localhost:${server.info.port}`); + const [res] = await Events.once(req, 'response'); + + let result = ''; + for await (const chunk of res) { + result += chunk.toString(); + } + + expect(result).to.equal('ok'); + }); + + it('uses address when present instead of host', async (flags) => { const server = Hapi.server({ host: 'no.such.domain.hapi', address: 'localhost' }); await server.start(); + flags.onCleanup = () => server.stop(); + expect(server.info.host).to.equal('no.such.domain.hapi'); - expect(server.info.address).to.equal('127.0.0.1'); - await server.stop(); + expect(server.info.address).to.match(/^127\.0\.0\.1|::1$/); // ::1 on node v18 with ipv6 support }); - it('uses uri when present instead of host and port', async () => { + it('uses uri when present instead of host and port', async (flags) => { const server = Hapi.server({ host: 'no.such.domain.hapi', address: 'localhost', uri: 'http://uri.example.com:8080' }); expect(server.info.uri).to.equal('http://uri.example.com:8080'); await server.start(); + flags.onCleanup = () => server.stop(); + expect(server.info.host).to.equal('no.such.domain.hapi'); - expect(server.info.address).to.equal('127.0.0.1'); + expect(server.info.address).to.match(/^127\.0\.0\.1|::1$/); // ::1 on node v18 with ipv6 support expect(server.info.uri).to.equal('http://uri.example.com:8080'); - await server.stop(); }); it('throws on uri ending with /', () => { @@ -140,6 +164,11 @@ describe('Core', () => { it('creates a server listening on a unix domain socket', { skip: process.platform === 'win32' }, async () => { const port = Path.join(__dirname, 'hapi-server.socket'); + + if (Fs.existsSync(port)) { + Fs.unlinkSync(port); + } + const server = Hapi.server({ port }); expect(server.type).to.equal('socket'); @@ -303,6 +332,7 @@ describe('Core', () => { expect(server.settings.routes.security.hsts).to.equal(2); expect(server.settings.routes.security.xss).to.be.false(); expect(server.settings.routes.security.xframe).to.equal('deny'); + expect(server.settings.routes.security.referrer).to.equal(false); }); describe('_debug()', () => { @@ -334,7 +364,35 @@ describe('Core', () => { expect(res.statusCode).to.equal(500); const [, event] = await log; - expect(event.error.message).to.equal('Cannot read property \'here\' of null'); + expect(event.error.message).to.include(['Cannot read prop', 'null', 'here']); + }); + }); + + describe('_createCache()', () => { + + it('provisions cache using engine instance', async () => { + + // Config provision + + const engine = new CatboxMemory(); + const server = Hapi.server({ cache: { engine, name: 'test1' } }); + expect(server._core.caches.get('test1').client.connection).to.shallow.equal(engine); + + // Active provision + + await server.cache.provision({ engine, name: 'test2' }); + expect(server._core.caches.get('test2').client.connection).to.shallow.equal(engine); + + // Active provision but indirect constructor + + const Provider = function (options) { + + this.settings = options; + }; + + const ref = {}; + await server.cache.provision({ provider: { constructor: Provider, options: { ref } }, name: 'test3' }); + expect(server._core.caches.get('test3').client.connection.settings.ref).to.shallow.equal(ref); }); }); @@ -524,6 +582,20 @@ describe('Core', () => { await expect(server.stop()).to.reject('failed cleanup'); }); + it('returns an extension timeout (onPreStop)', async () => { + + const server = Hapi.server(); + const preStop = function (srv) { + + return Hoek.block(); + }; + + server.ext('onPreStop', preStop, { timeout: 100 }); + + await server.start(); + await expect(server.stop()).to.reject('onPreStop timed out'); + }); + it('errors when stopping a stopping server', async () => { const server = Hapi.server(); @@ -577,9 +649,10 @@ describe('Core', () => { expect(server._core.sockets.size).to.equal(1); expect(count).to.equal(1); - promise.req.abort(); + promise.req.destroy(); await expect(promise).to.reject(); + await Hoek.wait(50); const count2 = await internals.countConnections(server); expect(count2).to.equal(0); expect(server._core.sockets.size).to.equal(0); @@ -610,15 +683,16 @@ describe('Core', () => { await server.start(); const promise = Wreck.request('GET', `https://localhost:${server.info.port}/`, { rejectUnauthorized: false }); - await Hoek.wait(50); + await Hoek.wait(100); const count1 = await internals.countConnections(server); expect(count1).to.equal(1); expect(server._core.sockets.size).to.equal(1); expect(count).to.equal(1); - promise.req.abort(); + promise.req.destroy(); await expect(promise).to.reject(); + await Hoek.wait(50); const count2 = await internals.countConnections(server); expect(count2).to.equal(0); expect(server._core.sockets.size).to.equal(0); @@ -691,6 +765,7 @@ describe('Core', () => { const socket1 = await internals.socket(server); const socket2 = await internals.socket(server); + await Hoek.wait(50); const count1 = await internals.countConnections(server); expect(count1).to.equal(2); expect(server._core.sockets.size).to.equal(2); @@ -720,6 +795,7 @@ describe('Core', () => { const socket1 = await internals.socket(server, 'tls'); const socket2 = await internals.socket(server, 'tls'); + await Hoek.wait(50); const count1 = await internals.countConnections(server); expect(count1).to.equal(2); expect(server._core.sockets.size).to.equal(2); @@ -744,12 +820,13 @@ describe('Core', () => { await internals.socket(server); await internals.socket(server); + await Hoek.wait(50); const count1 = await internals.countConnections(server); expect(count1).to.equal(2); const timer = new Hoek.Bench(); - await server.stop({ timeout: 20 }); - expect(timer.elapsed()).to.be.at.most(20); + await server.stop({ timeout: 100 }); + expect(timer.elapsed()).to.be.at.most(110); }); it('waits to destroy handled connections until after the timeout', async () => { @@ -759,7 +836,7 @@ describe('Core', () => { await server.start(); const socket = await internals.socket(server); - socket.write('GET / HTTP/1.0\nHost: test\n\n'); + socket.write('GET / HTTP/1.0\r\nHost: test\r\n\r\n'); await Hoek.wait(10); const count1 = await internals.countConnections(server); @@ -777,20 +854,20 @@ describe('Core', () => { await server.start(); const socket = await internals.socket(server); - socket.write('GET / HTTP/1.0\nHost: test\n\n'); + socket.write('GET / HTTP/1.0\r\nHost: test\r\n\r\n'); await Hoek.wait(10); const count1 = await internals.countConnections(server); expect(count1).to.equal(1); - setTimeout(() => socket.end(), 10); + setTimeout(() => socket.end(), 100); const timer = new Hoek.Bench(); - await server.stop({ timeout: 200 }); - expect(timer.elapsed()).to.be.below(25); + await server.stop({ timeout: 400 }); + expect(timer.elapsed()).to.be.below(300); }); - it('immediately destroys idle keep-alive connections', async () => { + it('immediately destroys idle keep-alive connections', { retry: true }, async () => { const server = Hapi.server(); server.route({ method: 'GET', path: '/', handler: () => null }); @@ -798,7 +875,7 @@ describe('Core', () => { await server.start(); const socket = await internals.socket(server); - socket.write('GET / HTTP/1.1\nHost: test\nConnection: Keep-Alive\n\n\n'); + socket.write('GET / HTTP/1.1\r\nHost: test\r\nConnection: Keep-Alive\r\n\r\n\r\n'); await new Promise((resolve) => socket.on('data', resolve)); const count = await internals.countConnections(server); @@ -816,22 +893,22 @@ describe('Core', () => { await server.start(); const socket = await internals.socket(server); - socket.write('GET / HTTP/1.0\nHost: test\n\n'); + socket.write('GET / HTTP/1.0\r\nHost: test\r\n\r\n'); await Hoek.wait(10); const count1 = await internals.countConnections(server); expect(count1).to.equal(1); - setTimeout(() => socket.end(), 50); + setTimeout(() => socket.end(), 100); const stop = server.stop(); - await Hoek.wait(20); + await Hoek.wait(50); const count2 = await internals.countConnections(server); expect(count2).to.equal(1); - await Hoek.wait(40); + await Hoek.wait(200); const count3 = await internals.countConnections(server); expect(count3).to.equal(0); @@ -852,11 +929,33 @@ describe('Core', () => { const { res, payload } = await first; const stop = server.stop(); - await expect(second).to.reject(); + const err = await expect(second).to.reject(Error); await stop; + await Hoek.wait(10); expect(res.headers.connection).to.equal('keep-alive'); expect(payload.toString()).to.equal('ok'); + expect(err.code).to.equal('ECONNRESET'); + expect(server._core.started).to.equal(false); + }); + + it('allows incoming requests during the stopping phase', async () => { + + const team = new Teamwork.Team(); + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => 'ok' }); + server.ext('onPreStop', () => team.work); + + await server.start(); + const stop = server.stop(); + const { res, payload } = await Wreck.get(`http://localhost:${server.info.port}`); + + team.attend(); // Allow server to finalize stop + await stop; + + expect(res.headers.connection).to.equal('close'); + expect(payload.toString()).to.equal('ok'); expect(server._core.started).to.equal(false); }); @@ -936,6 +1035,59 @@ describe('Core', () => { await server.stop(); await server.stop(); }); + + it('emits a closing event before the server\'s listener close event is emitted', async () => { + + const server = Hapi.server(); + const events = []; + + server.events.on('closing', () => events.push('closing')); + server.events.on('stop', () => events.push('stop')); + server._core.listener.on('close', () => events.push('close')); + + await server.start(); + await server.stop(); + + expect(events).to.equal(['closing', 'close', 'stop']); + }); + + it('emits a closing event before the close event when there is an active request being processed', async () => { + + const server = Hapi.server(); + const events = []; + + let stop; + const handler = async () => { + + stop = server.stop({ timeout: 200 }); + await Hoek.wait(0); + return 'ok'; + }; + + server.route({ method: 'GET', path: '/', handler }); + + server.events.on('closing', () => events.push('closing')); + server.events.on('stop', () => events.push('stop')); + server._core.listener.on('close', () => events.push('close')); + + await server.start(); + + const agent = new Http.Agent({ keepAlive: true, maxSockets: 1 }); + + // ongoing active request + const first = Wreck.get('http://localhost:' + server.info.port + '/', { agent }); + // denied incoming request + const second = Wreck.get('http://localhost:' + server.info.port + '/', { agent }); + + const { res, payload } = await first; + expect(res.headers.connection).to.equal('close'); + expect(payload.toString()).to.equal('ok'); + + await expect(second).to.reject(); + await expect(stop).to.not.reject(); + + expect(events).to.equal(['closing', 'close', 'stop']); + }); }); describe('_dispatch()', () => { @@ -944,10 +1096,10 @@ describe('Core', () => { const server = Hapi.server({ load: { sampleInterval: 5, maxRssBytes: 1 } }); + let buffer; const handler = (request) => { - const start = Date.now(); - while (Date.now() - start < 10) { } + buffer = buffer || Buffer.alloc(2048); return 'ok'; }; @@ -959,7 +1111,7 @@ describe('Core', () => { const res1 = await server.inject('/'); expect(res1.statusCode).to.equal(200); - await Hoek.wait(0); + await Hoek.wait(10); const res2 = await server.inject('/'); expect(res2.statusCode).to.equal(503); @@ -970,6 +1122,37 @@ describe('Core', () => { await server.stop(); }); + + it('doesn\'t setup listeners for cleanStop when socket is missing', async () => { + + const server = Hapi.server(); + + server.route({ + method: 'get', + path: '/', + handler: (request) => request.raw.res.listenerCount('finish') + }); + + const { result: normalFinishCount } = await server.inject('/'); + + const { _dispatch } = server._core; + + server._core._dispatch = (opts) => { + + const fn = _dispatch.call(server._core, opts); + + return (req, res) => { + + req.socket = null; + + fn(req, res); + }; + }; + + const { result: missingSocketFinishCount } = await server.inject('/'); + + expect(missingSocketFinishCount).to.be.lessThan(normalFinishCount); + }); }); describe('inject()', () => { @@ -981,12 +1164,15 @@ describe('Core', () => { const options = { url: '/', - credentials: { foo: 'bar' } + auth: { + credentials: { foo: 'bar' }, + strategy: 'test' + } }; const res = await server.inject(options); - expect(res.statusCode).to.equal(200); - expect(options.credentials).to.exist(); + expect(res.statusCode).to.equal(204); + expect(options.auth.credentials).to.exist(); }); it('sets credentials (with host header)', async () => { @@ -996,15 +1182,18 @@ describe('Core', () => { const options = { url: '/', - credentials: { foo: 'bar' }, + auth: { + credentials: { foo: 'bar' }, + strategy: 'test' + }, headers: { host: 'something' } }; const res = await server.inject(options); - expect(res.statusCode).to.equal(200); - expect(options.credentials).to.exist(); + expect(res.statusCode).to.equal(204); + expect(options.auth.credentials).to.exist(); }); it('sets credentials (with authority)', async () => { @@ -1014,14 +1203,17 @@ describe('Core', () => { const options = { url: '/', - credentials: { foo: 'bar' }, - authority: 'something' + authority: 'something', + auth: { + credentials: { foo: 'bar' }, + strategy: 'test' + } }; const res = await server.inject(options); expect(res.statusCode).to.equal(200); expect(res.result).to.equal('something'); - expect(options.credentials).to.exist(); + expect(options.auth.credentials).to.exist(); }); it('sets authority', async () => { @@ -1046,14 +1238,86 @@ describe('Core', () => { const options = { url: '/', - credentials: { foo: 'bar' }, - artifacts: { bar: 'baz' } + auth: { + credentials: { foo: 'bar' }, + artifacts: { bar: 'baz' }, + strategy: 'test' + } }; const res = await server.inject(options); expect(res.statusCode).to.equal(200); expect(res.result.bar).to.equal('baz'); - expect(options.artifacts).to.exist(); + expect(options.auth.artifacts).to.exist(); + }); + + it('sets `request.auth.isInjected = true` when `auth` option is defined', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request) => request.auth.isInjected }); + + const options = { + url: '/', + auth: { + credentials: { foo: 'bar' }, + strategy: 'test' + } + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(200); + expect(res.result).to.be.true(); + }); + + it('sets `request.isInjected = true` for requests created via `server.inject`', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request) => request.isInjected }); + + const options = { + url: '/' + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(200); + expect(res.result).to.be.true(); + }); + + it('`request.isInjected` access is read-only', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request) => { + + const illegalAssignment = () => { + + request.isInjected = false; + }; + + expect(illegalAssignment).to.throw('Cannot set property isInjected of [object Object] which has only a getter'); + + return request.isInjected; + } }); + + const options = { + url: '/' + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(200); + expect(res.result).to.be.true(); + }); + + it('sets `request.isInjected = false` for normal request', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request) => request.isInjected }); + + await server.start(); + + const { payload } = await Wreck.get(`http://localhost:${server.info.port}/`); + expect(payload.toString()).to.equal('false'); + + await server.stop(); }); it('sets app settings', async () => { @@ -1106,10 +1370,62 @@ describe('Core', () => { server.route({ method: 'GET', path: '/', handler }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); expect(res.request.app.key).to.equal('value'); }); + it('returns the request object for POST', async () => { + + const payload = { foo: true }; + const handler = (request) => { + + return request.payload; + }; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(200); + expect(JSON.parse(res.payload)).to.equal(payload); + }); + + it('returns the request string for POST', async () => { + + const payload = JSON.stringify({ foo: true }); + const handler = (request) => { + + return request.payload; + }; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal(payload); + }); + + it('returns the request stream for POST', async () => { + + const param = { foo: true }; + const payload = new Stream.Readable(); + payload.push(JSON.stringify(param)); + payload.push(null); + + const handler = (request) => { + + return request.payload; + }; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(200); + expect(JSON.parse(res.payload)).to.equal(param); + }); + it('can set a client remoteAddress', async () => { const server = Hapi.server(); @@ -1498,7 +1814,7 @@ describe('Core', () => { expect(res.result.isBoom).to.equal(true); }); - it('cleans unused file stream when response is overridden', { skip: process.platform === 'win32' }, async () => { + it('cleans unused file stream when response is overridden', { skip: !Common.hasLsof }, async () => { const server = Hapi.server(); await server.register(Inert); @@ -1521,12 +1837,6 @@ describe('Core', () => { const cmd = ChildProcess.spawn('lsof', ['-p', process.pid]); let lsof = ''; - cmd.on('error', (err) => { - - // Allow the test to pass on platforms with no lsof - Bounce.ignore(err, { errno: 'ENOENT' }); - }); - cmd.stdout.on('data', (buffer) => { lsof += buffer.toString(); @@ -1811,55 +2121,11 @@ describe('Core', () => { await server.inject('/'); expect(server.load.eventLoopDelay).to.be.above(0); + expect(server.load.eventLoopUtilization).to.be.above(0); expect(server.load.heapUsed).to.be.above(1024 * 1024); expect(server.load.rss).to.be.above(1024 * 1024); await server.stop(); }); - - it('bypasses queue when disabled', () => { - - const server = Hapi.server({ load: { concurrent: 0 } }); - - const handler = async () => { - - await Hoek.wait(100); - return null; - }; - - server.route({ method: 'GET', path: '/', handler }); - server.inject('/'); - expect(server._core.queue.active).to.equal(0); - }); - - it('bypasses queue when disabled (default)', () => { - - const server = Hapi.server(); - - const handler = async () => { - - await Hoek.wait(100); - return null; - }; - - server.route({ method: 'GET', path: '/', handler }); - server.inject('/'); - expect(server._core.queue.active).to.equal(0); - }); - - it('queues requests', () => { - - const server = Hapi.server({ load: { concurrent: 100 } }); - - const handler = async () => { - - await Hoek.wait(100); - return null; - }; - - server.route({ method: 'GET', path: '/', handler }); - server.inject('/'); - expect(server._core.queue.active).to.equal(1); - }); }); }); diff --git a/test/cors.js b/test/cors.js index b4f191d54..079562efd 100755 --- a/test/cors.js +++ b/test/cors.js @@ -1,20 +1,14 @@ 'use strict'; -// Load modules - -const Boom = require('boom'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Lab = require('lab'); - +const Lab = require('@hapi/lab'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -60,7 +54,6 @@ describe('CORS', () => { it('returns headers on single route', async () => { - const server = Hapi.server(); server.route({ method: 'GET', path: '/a', handler: () => 'ok', options: { cors: true } }); server.route({ method: 'GET', path: '/b', handler: () => 'ok' }); @@ -134,18 +127,18 @@ describe('CORS', () => { server.route({ method: 'GET', path: '/', handler: () => null }); const res = await server.inject({ url: '/', headers: { origin: 'http://example.com/' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); expect(res.result).to.equal(null); expect(res.headers['access-control-allow-credentials']).to.equal('true'); }); - it('combines connection defaults with route config', async () => { + it('combines server defaults with route config', async () => { const server = Hapi.server({ routes: { cors: { origin: ['http://example.com/'] } } }); server.route({ method: 'GET', path: '/', handler: () => null, options: { cors: { credentials: true } } }); const res1 = await server.inject({ url: '/', headers: { origin: 'http://example.com/', 'access-control-request-method': 'GET' } }); - expect(res1.statusCode).to.equal(200); + expect(res1.statusCode).to.equal(204); expect(res1.result).to.equal(null); expect(res1.headers['access-control-allow-credentials']).to.equal('true'); @@ -155,7 +148,7 @@ describe('CORS', () => { expect(res2.headers['access-control-allow-credentials']).to.equal('true'); const res3 = await server.inject({ url: '/', headers: { origin: 'http://example.org/', 'access-control-request-method': 'GET' } }); - expect(res3.statusCode).to.equal(200); + expect(res3.statusCode).to.equal(204); expect(res3.result).to.equal(null); expect(res3.headers['access-control-allow-credentials']).to.not.exist(); @@ -173,9 +166,43 @@ describe('CORS', () => { const res1 = await server.inject('/'); expect(res1.statusCode).to.equal(404); + expect(res1.headers['access-control-allow-origin']).to.not.exist(); const res2 = await server.inject('/test'); + expect(res2.statusCode).to.equal(204); + expect(res2.headers['access-control-allow-origin']).to.not.exist(); + }); + + it('handles missing routes', async () => { + + const server = Hapi.server({ port: 8080, routes: { cors: { origin: ['http://*.domain.com'] } } }); + + const res1 = await server.inject('/'); + expect(res1.statusCode).to.equal(404); + expect(res1.headers['access-control-allow-origin']).to.not.exist(); + + const res2 = await server.inject({ url: '/', headers: { origin: 'http://example.domain.com' } }); + expect(res2.statusCode).to.equal(404); + expect(res2.headers['access-control-allow-origin']).to.exist(); + }); + + it('uses server defaults in onRequest', async () => { + + const server = Hapi.server({ port: 8080, routes: { cors: { origin: ['http://*.domain.com'] } } }); + + server.ext('onRequest', (request, h) => { + + expect(request.info.cors).to.be.null(); // Do not set potentially incorrect information + return h.response('skip').takeover(); + }); + + const res1 = await server.inject({ url: '/', headers: { origin: 'http://example.domain.com' } }); + expect(res1.statusCode).to.equal(200); + expect(res1.headers['access-control-allow-origin']).to.exist(); + + const res2 = await server.inject({ url: '/', headers: { origin: 'http://example.domain.net' } }); expect(res2.statusCode).to.equal(200); + expect(res2.headers['access-control-allow-origin']).to.not.exist(); }); describe('headers()', () => { @@ -519,6 +546,35 @@ describe('CORS', () => { expect(res.headers['access-control-expose-headers']).to.equal('WWW-Authenticate,Server-Authorization'); }); + it('responds with configured preflight status code', async () => { + + const server = Hapi.server({ routes: { cors: { preflightStatusCode: 204 } } }); + server.route({ method: 'GET', path: '/204', handler: () => 'ok', options: { cors: true } }); + server.route({ method: 'GET', path: '/200', handler: () => 'ok', options: { cors: { preflightStatusCode: 200 } } }); + + const res1 = await server.inject({ + method: 'OPTIONS', + url: '/204', + headers: { + origin: 'http://test.example.com', + 'access-control-request-method': 'GET' + } + }); + + expect(res1.statusCode).to.equal(204); + + const res2 = await server.inject({ + method: 'OPTIONS', + url: '/200', + headers: { + origin: 'http://test.example.com', + 'access-control-request-method': 'GET' + } + }); + + expect(res2.statusCode).to.equal(200); + }); + it('matches allowed headers (Origin implicit)', async () => { const server = Hapi.server({ routes: { cors: true } }); diff --git a/test/handler.js b/test/handler.js index 35ef683bf..334a1a3d9 100755 --- a/test/handler.js +++ b/test/handler.js @@ -1,21 +1,15 @@ 'use strict'; -// Load modules - -const Boom = require('boom'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Lab = require('lab'); - +const Hoek = require('@hapi/hoek'); +const Lab = require('@hapi/lab'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -79,7 +73,7 @@ describe('handler', () => { expect(res.statusCode).to.equal(500); const [, event] = await log; - expect(event.error.message).to.equal('Cannot read property \'here\' of null'); + expect(event.error.message).to.include(['Cannot read prop', 'null', 'here']); }); }); @@ -141,6 +135,28 @@ describe('handler', () => { const res = await server.inject('/domain'); expect(res.statusCode).to.equal(500); }); + + it('returns 500 on custom function error', async () => { + + const server = Hapi.server({ debug: false }); + + const onPreHandler = function (request, h) { + + request.app.custom = () => { + + throw new Error('oops'); + }; + + return h.continue; + }; + + server.ext('onPreHandler', onPreHandler); + + server.route({ method: 'GET', path: '/', handler: (request) => request.app.custom() }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); }); describe('prerequisitesConfig()', () => { @@ -562,6 +578,7 @@ describe('handler', () => { expect(event.error.isBoom).to.equal(true); expect(event.error.output.statusCode).to.equal(403); expect(event.error.message).to.equal('Forbidden'); + expect(event.error.stack).to.exist(); }); }); diff --git a/test/headers.js b/test/headers.js index 8385168f0..b5ed11004 100755 --- a/test/headers.js +++ b/test/headers.js @@ -1,22 +1,16 @@ 'use strict'; -// Load modules - -const Boom = require('boom'); -const CatboxMemory = require('catbox-memory'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const { Engine: CatboxMemory } = require('@hapi/catbox-memory'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Inert = require('inert'); -const Lab = require('lab'); - +const Inert = require('@hapi/inert'); +const Lab = require('@hapi/lab'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -112,7 +106,7 @@ describe('Headers', () => { it('caches using non default cache', async () => { - const server = Hapi.server({ cache: { name: 'primary', engine: CatboxMemory } }); + const server = Hapi.server({ cache: { name: 'primary', provider: CatboxMemory } }); const defaults = server.cache({ segment: 'a', expiresIn: 2000, getDecoratedValue: true }); const primary = server.cache({ segment: 'a', expiresIn: 2000, getDecoratedValue: true, cache: 'primary' }); @@ -219,7 +213,7 @@ describe('Headers', () => { expect(res.result).to.equal('Test'); expect(res.headers['strict-transport-security']).to.equal('max-age=15768000'); expect(res.headers['x-frame-options']).to.equal('DENY'); - expect(res.headers['x-xss-protection']).to.equal('1; mode=block'); + expect(res.headers['x-xss-protection']).to.equal('0'); expect(res.headers['x-download-options']).to.equal('noopen'); expect(res.headers['x-content-type-options']).to.equal('nosniff'); }); @@ -249,7 +243,7 @@ describe('Headers', () => { expect(res.result).to.equal('Test'); expect(res.headers['strict-transport-security']).to.not.exist(); expect(res.headers['x-frame-options']).to.equal('DENY'); - expect(res.headers['x-xss-protection']).to.equal('1; mode=block'); + expect(res.headers['x-xss-protection']).to.equal('0'); expect(res.headers['x-download-options']).to.equal('noopen'); expect(res.headers['x-content-type-options']).to.equal('nosniff'); }); @@ -341,7 +335,7 @@ describe('Headers', () => { expect(res.result).to.equal('Test'); expect(res.headers['x-frame-options']).to.not.exist(); expect(res.headers['strict-transport-security']).to.equal('max-age=15768000'); - expect(res.headers['x-xss-protection']).to.equal('1; mode=block'); + expect(res.headers['x-xss-protection']).to.equal('0'); expect(res.headers['x-download-options']).to.equal('noopen'); expect(res.headers['x-content-type-options']).to.equal('nosniff'); }); @@ -424,6 +418,36 @@ describe('Headers', () => { expect(res.headers['x-content-type-options']).to.not.exist(); }); + it('sets the x-xss-protection header when security.xss is enabled', async () => { + + const server = Hapi.server({ routes: { security: { xss: 'enabled' } } }); + server.route({ method: 'GET', path: '/', handler: () => 'Test' }); + + const res = await server.inject({ url: '/' }); + expect(res.result).to.exist(); + expect(res.result).to.equal('Test'); + expect(res.headers['x-xss-protection']).to.equal('1; mode=block'); + expect(res.headers['strict-transport-security']).to.equal('max-age=15768000'); + expect(res.headers['x-frame-options']).to.equal('DENY'); + expect(res.headers['x-download-options']).to.equal('noopen'); + expect(res.headers['x-content-type-options']).to.equal('nosniff'); + }); + + it('sets the x-xss-protection header when security.xss is disabled', async () => { + + const server = Hapi.server({ routes: { security: { xss: 'disabled' } } }); + server.route({ method: 'GET', path: '/', handler: () => 'Test' }); + + const res = await server.inject({ url: '/' }); + expect(res.result).to.exist(); + expect(res.result).to.equal('Test'); + expect(res.headers['x-xss-protection']).to.equal('0'); + expect(res.headers['strict-transport-security']).to.equal('max-age=15768000'); + expect(res.headers['x-frame-options']).to.equal('DENY'); + expect(res.headers['x-download-options']).to.equal('noopen'); + expect(res.headers['x-content-type-options']).to.equal('nosniff'); + }); + it('does not set the x-xss-protection header when security.xss is false', async () => { const server = Hapi.server({ routes: { security: { xss: false } } }); @@ -438,6 +462,52 @@ describe('Headers', () => { expect(res.headers['x-download-options']).to.equal('noopen'); expect(res.headers['x-content-type-options']).to.equal('nosniff'); }); + + it('does not return the referrer-policy header by default', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => 'Test' }); + + const res = await server.inject({ url: '/' }); + expect(res.result).to.exist(); + expect(res.result).to.equal('Test'); + expect(res.headers['referrer-policy']).to.not.exist(); + }); + + it('does not return the referrer-policy header when security.referrer is false', async () => { + + const server = Hapi.server({ routes: { security: { referrer: false } } }); + server.route({ method: 'GET', path: '/', handler: () => 'Test' }); + + const res = await server.inject({ url: '/' }); + expect(res.result).to.exist(); + expect(res.result).to.equal('Test'); + expect(res.headers['referrer-policy']).to.not.exist(); + }); + + it('does not allow security.referrer to be true', () => { + + let err; + try { + Hapi.server({ routes: { security: { referrer: true } } }); + } + catch (ex) { + err = ex; + } + + expect(err).to.exist(); + }); + + it('returns correct referrer-policy header when security.referrer is a string with a valid value', async () => { + + const server = Hapi.server({ routes: { security: { referrer: 'strict-origin-when-cross-origin' } } }); + server.route({ method: 'GET', path: '/', handler: () => 'Test' }); + + const res = await server.inject({ url: '/' }); + expect(res.result).to.exist(); + expect(res.result).to.equal('Test'); + expect(res.headers['referrer-policy']).to.equal('strict-origin-when-cross-origin'); + }); }); describe('content()', () => { @@ -471,5 +541,15 @@ describe('Headers', () => { expect(res.statusCode).to.equal(200); expect(res.headers['content-type']).to.equal('text/html'); }); + + it('does not set content-type by default on 204 response', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response().code(204) }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(204); + expect(res.headers['content-type']).to.equal(undefined); + }); }); }); diff --git a/test/index.js b/test/index.js index 023aca9e8..c416a85f9 100755 --- a/test/index.js +++ b/test/index.js @@ -1,19 +1,13 @@ 'use strict'; -// Load modules - -const Code = require('code'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Lab = require('lab'); - +const Lab = require('@hapi/lab'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; diff --git a/test/methods.js b/test/methods.js index 2dc117fb5..70a3dcb38 100755 --- a/test/methods.js +++ b/test/methods.js @@ -1,21 +1,16 @@ 'use strict'; -// Load modules - -const CatboxMemory = require('catbox-memory'); -const Code = require('code'); +const Catbox = require('@hapi/catbox'); +const { Engine: CatboxMemory } = require('@hapi/catbox-memory'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Lab = require('lab'); - +const Hoek = require('@hapi/hoek'); +const Lab = require('@hapi/lab'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -214,6 +209,43 @@ describe('Methods', () => { expect(result2.gen).to.equal(0); }); + it('emits a cache policy event on cached methods with default cache provision', async () => { + + const method = function (id) { + + return { id }; + }; + + const server = Hapi.server(); + const cachePolicyEvent = server.events.once('cachePolicy'); + + server.method('test', method, { cache: { expiresIn: 1000, generateTimeout: 10 } }); + + const [policy, cacheName, segment] = await cachePolicyEvent; + expect(policy).to.be.instanceOf(Catbox.Policy); + expect(cacheName).to.equal(undefined); + expect(segment).to.equal('#test'); + }); + + it('emits a cache policy event on cached methods with named cache provision', async () => { + + const method = function (id) { + + return { id }; + }; + + const server = Hapi.server(); + await server.cache.provision({ provider: CatboxMemory, name: 'named' }); + const cachePolicyEvent = server.events.once('cachePolicy'); + + server.method('test', method, { cache: { cache: 'named', expiresIn: 1000, generateTimeout: 10 } }); + + const [policy, cacheName, segment] = await cachePolicyEvent; + expect(policy).to.be.instanceOf(Catbox.Policy); + expect(cacheName).to.equal('named'); + expect(segment).to.equal('#test'); + }); + it('caches method value (async)', async () => { let gen = 0; @@ -523,11 +555,11 @@ describe('Methods', () => { let gen = 0; const method = async function (id) { - await Hoek.wait(5); + await Hoek.wait(50); return { id, gen: ++gen }; }; - server.method('user', method, { cache: { expiresIn: 2000, generateTimeout: 3 } }); + server.method('user', method, { cache: { expiresIn: 2000, generateTimeout: 30 } }); await server.initialize(); @@ -535,7 +567,7 @@ describe('Methods', () => { const err = await expect(server.methods.user(id)).to.reject(); expect(err.output.statusCode).to.equal(503); - await Hoek.wait(3); + await Hoek.wait(30); const result2 = await server.methods.user(id); expect(result2.id).to.equal(id); diff --git a/test/payload.js b/test/payload.js index cdbf86b6a..93b045cdf 100755 --- a/test/payload.js +++ b/test/payload.js @@ -1,31 +1,27 @@ 'use strict'; -// Load modules - +const Events = require('events'); const Fs = require('fs'); const Http = require('http'); +const Net = require('net'); const Path = require('path'); const Zlib = require('zlib'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Lab = require('lab'); -const Wreck = require('wreck'); - - -// Declare internals +const Hoek = require('@hapi/hoek'); +const Lab = require('@hapi/lab'); +const Wreck = require('@hapi/wreck'); const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; -describe('payload', () => { +describe('Payload', () => { it('sets payload', async () => { @@ -75,14 +71,63 @@ describe('payload', () => { const server = Hapi.server(); server.route({ method: 'POST', path: '/', options: { handler } }); - const log = server.events.once('response'); + const responded = server.ext('onPostResponse'); server.inject({ method: 'POST', url: '/', payload: 'test', simulate: { close: true, end: false } }); - const [request] = await log; + const request = await responded; expect(request._isReplied).to.equal(true); + expect(request.response.output.statusCode).to.equal(500); + }); + + it('handles aborted request mid-lifecycle step', async (flags) => { + + let req = null; + const server = Hapi.server(); + + server.route({ + method: 'GET', + path: '/', + handler: async (request) => { + + req.destroy(); + + await request.events.once('disconnect'); + + return 'ok'; + } + }); + + // Register post handler that should not be called + + let post = 0; + server.ext('onPostHandler', () => { + + ++post; + }); + + flags.onCleanup = () => server.stop(); + await server.start(); + + req = Http.request({ + hostname: 'localhost', + port: server.info.port, + method: 'get' + }); + + req.on('error', Hoek.ignore); + req.end(); + + const [request] = await server.events.once('response'); + + expect(request.response.isBoom).to.be.true(); + expect(request.response.output.statusCode).to.equal(499); + expect(request.info.completed).to.be.above(0); + expect(request.info.responded).to.equal(0); + + expect(post).to.equal(0); }); - it('handles aborted request', async () => { + it('handles aborted request', { retry: true }, async () => { const server = Hapi.server(); server.route({ method: 'POST', path: '/', options: { handler: () => 'Success', payload: { parse: false } } }); @@ -104,7 +149,7 @@ describe('payload', () => { const req = Http.request(options, (res) => { }); req.on('error', Hoek.ignore); req.write('Hello\n'); - setTimeout(() => req.abort(), 50); + setTimeout(() => req.destroy(), 50); const [event] = await log; expect(event.error.message).to.equal('Parse Error'); @@ -115,14 +160,34 @@ describe('payload', () => { const payload = '{"x":"1","y":"2","z":"3"}'; - const handler = (request) => { + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { maxBytes: 10 } } }); - expect(request.payload.toString()).to.equal(payload); - return request.payload; - }; + const res = await server.inject({ method: 'POST', url: '/', payload, headers: { 'content-length': payload.length } }); + expect(res.statusCode).to.equal(413); + expect(res.result).to.exist(); + expect(res.result.message).to.equal('Payload content length greater than maximum allowed: 10'); + }); + + it('errors when payload too big (implicit length)', async () => { + + const payload = '{"x":"1","y":"2","z":"3"}'; const server = Hapi.server(); - server.route({ method: 'POST', path: '/', options: { handler, payload: { maxBytes: 10 } } }); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { maxBytes: 10 } } }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(413); + expect(res.result).to.exist(); + expect(res.result.message).to.equal('Payload content length greater than maximum allowed: 10'); + }); + + it('errors when payload too big (file)', async () => { + + const payload = '{"x":"1","y":"2","z":"3"}'; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { output: 'file', maxBytes: 10 } } }); const res = await server.inject({ method: 'POST', url: '/', payload, headers: { 'content-length': payload.length } }); expect(res.statusCode).to.equal(413); @@ -130,6 +195,69 @@ describe('payload', () => { expect(res.result.message).to.equal('Payload content length greater than maximum allowed: 10'); }); + it('errors when payload too big (file implicit length)', async () => { + + const payload = '{"x":"1","y":"2","z":"3"}'; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { output: 'file', maxBytes: 10 } } }); + + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(413); + expect(res.result).to.exist(); + expect(res.result.message).to.equal('Payload content length greater than maximum allowed: 10'); + }); + + it('errors when payload contains prototype poisoning', async () => { + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload.x }); + + const payload = '{"x":"1","y":"2","z":"3","__proto__":{"x":"4"}}'; + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(400); + }); + + it('ignores when payload contains prototype poisoning', async () => { + + const server = Hapi.server(); + server.route({ + method: 'POST', + path: '/', + options: { + payload: { + protoAction: 'ignore' + }, + handler: (request) => request.payload.__proto__ + } + }); + + const payload = '{"x":"1","y":"2","z":"3","__proto__":{"x":"4"}}'; + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal({ x: '4' }); + }); + + it('sanitizes when payload contains prototype poisoning', async () => { + + const server = Hapi.server(); + server.route({ + method: 'POST', + path: '/', + options: { + payload: { + protoAction: 'remove' + }, + handler: (request) => request.payload.__proto__ + } + }); + + const payload = '{"x":"1","y":"2","z":"3","__proto__":{"x":"4"}}'; + const res = await server.inject({ method: 'POST', url: '/', payload }); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal({}); + }); + it('returns 413 with response when payload is not consumed', async () => { const payload = Buffer.alloc(10 * 1024 * 1024).toString(); @@ -154,10 +282,147 @@ describe('payload', () => { await server.start(); - const uri = 'http://localhost:' + server.info.port; - const { res, payload } = await Wreck.post(uri, { payload: { hello: true }, headers: { expect: '100-continue' } }); - expect(res.statusCode).to.equal(200); - expect(payload.toString()).to.equal('{"hello":true}'); + const client = Net.connect(server.info.port); + + await Events.once(client, 'connect'); + + client.write('POST / HTTP/1.1\r\nexpect: 100-continue\r\nhost: host\r\naccept-encoding: gzip\r\n' + + 'content-type: application/json\r\ncontent-length: 14\r\nConnection: close\r\n\r\n'); + + const lines = []; + client.setEncoding('ascii'); + for await (const chunk of client) { + + if (chunk.startsWith('HTTP/1.1 100 Continue')) { + client.write('{"hello":true}'); + } + else { + lines.push(...chunk.split('\r\n')); + } + } + + const res = lines.shift(); + const payload = lines.pop(); + + expect(res).to.equal('HTTP/1.1 200 OK'); + expect(payload).to.equal('{"hello":true}'); + + await server.stop(); + }); + + it('does not continue on errors before payload processing', async () => { + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + server.ext('onPreAuth', (request, h) => { + + throw new Boom.forbidden(); + }); + + await server.start(); + + const client = Net.connect(server.info.port); + + await Events.once(client, 'connect'); + + client.write('POST / HTTP/1.1\r\nexpect: 100-continue\r\nhost: host\r\naccept-encoding: gzip\r\n' + + 'content-type: application/json\r\ncontent-length: 14\r\nConnection: close\r\n\r\n'); + + let continued = false; + const lines = []; + client.setEncoding('ascii'); + for await (const chunk of client) { + + if (chunk.startsWith('HTTP/1.1 100 Continue')) { + client.write('{"hello":true}'); + continued = true; + } + else { + lines.push(...chunk.split('\r\n')); + } + } + + const res = lines.shift(); + + expect(res).to.equal('HTTP/1.1 403 Forbidden'); + expect(continued).to.be.false(); + + await server.stop(); + }); + + it('handles expect 100-continue on undefined routes', async () => { + + const server = Hapi.server(); + await server.start(); + + const client = Net.connect(server.info.port); + + await Events.once(client, 'connect'); + + client.write('POST / HTTP/1.1\r\nexpect: 100-continue\r\nhost: host\r\naccept-encoding: gzip\r\n' + + 'content-type: application/json\r\ncontent-length: 14\r\nConnection: close\r\n\r\n'); + + let continued = false; + const lines = []; + client.setEncoding('ascii'); + for await (const chunk of client) { + + if (chunk.startsWith('HTTP/1.1 100 Continue')) { + client.write('{"hello":true}'); + continued = true; + } + else { + lines.push(...chunk.split('\r\n')); + } + } + + const res = lines.shift(); + + expect(res).to.equal('HTTP/1.1 404 Not Found'); + expect(continued).to.be.false(); + + await server.stop(); + }); + + it('does not continue on custom request.payload', async () => { + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + server.ext('onRequest', (request, h) => { + + request.payload = { custom: true }; + return h.continue; + }); + + await server.start(); + + const client = Net.connect(server.info.port); + + await Events.once(client, 'connect'); + + client.write('POST / HTTP/1.1\r\nexpect: 100-continue\r\nhost: host\r\naccept-encoding: gzip\r\n' + + 'content-type: application/json\r\ncontent-length: 14\r\nConnection: close\r\n\r\n'); + + let continued = false; + const lines = []; + client.setEncoding('ascii'); + for await (const chunk of client) { + + if (chunk.startsWith('HTTP/1.1 100 Continue')) { + client.write('{"hello":true}'); + continued = true; + } + else { + lines.push(...chunk.split('\r\n')); + } + } + + const res = lines.shift(); + const payload = lines.pop(); + + expect(res).to.equal('HTTP/1.1 200 OK'); + expect(payload).to.equal('{"custom":true}'); + expect(continued).to.be.false(); await server.stop(); }); @@ -190,6 +455,28 @@ describe('payload', () => { expect(res.result).to.equal(payload); }); + it('peeks at unparsed data (finish only)', async () => { + + let peeked = false; + const ext = (request, h) => { + + request.events.once('finish', () => { + + peeked = true; + }); + + return h.continue; + }; + + const server = Hapi.server(); + server.ext('onRequest', ext); + server.route({ method: 'POST', path: '/', options: { handler: () => null, payload: { parse: false } } }); + + const payload = '0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789'; + await server.inject({ method: 'POST', url: '/', payload }); + expect(peeked).to.be.true(); + }); + it('handles gzipped payload', async () => { const message = { 'msg': 'This message is going to be gzipped.' }; @@ -334,7 +621,7 @@ describe('payload', () => { server.route({ method: 'POST', path: '/', options: { handler: (request) => request.payload, payload: { failAction: 'ignore' } } }); const res = await server.inject({ method: 'POST', url: '/', payload: 'testing123', headers: { 'content-type': 'application/unknown' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); expect(res.result).to.equal(null); }); @@ -431,7 +718,7 @@ describe('payload', () => { expect(res.statusCode).to.equal(415); }); - it('returns parsed multipart data', async () => { + it('returns parsed multipart data (route)', async () => { const multipartPayload = '--AaB03x\r\n' + @@ -475,6 +762,60 @@ describe('payload', () => { }; const server = Hapi.server(); + server.route({ method: 'POST', path: '/echo', handler, options: { payload: { multipart: true } } }); + + const res = await server.inject({ method: 'POST', url: '/echo', payload: multipartPayload, headers: { 'content-type': 'multipart/form-data; boundary=AaB03x' } }); + expect(Object.keys(res.result).length).to.equal(3); + expect(res.result.field1).to.exist(); + expect(res.result.field1.length).to.equal(2); + expect(res.result.field1[1]).to.equal('Repeated name segment'); + expect(res.result.pics).to.exist(); + }); + + it('returns parsed multipart data (server)', async () => { + + const multipartPayload = + '--AaB03x\r\n' + + 'content-disposition: form-data; name="x"\r\n' + + '\r\n' + + 'First\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="x"\r\n' + + '\r\n' + + 'Second\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="x"\r\n' + + '\r\n' + + 'Third\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="field1"\r\n' + + '\r\n' + + 'Joe Blow\r\nalmost tricked you!\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="field1"\r\n' + + '\r\n' + + 'Repeated name segment\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="pics"; filename="file1.txt"\r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + '... contents of file1.txt ...\r\r\n' + + '--AaB03x--\r\n'; + + const handler = (request) => { + + const result = {}; + const keys = Object.keys(request.payload); + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + const value = request.payload[key]; + result[key] = value._readableState ? true : value; + } + + return result; + }; + + const server = Hapi.server({ routes: { payload: { multipart: true } } }); server.route({ method: 'POST', path: '/echo', handler }); const res = await server.inject({ method: 'POST', url: '/echo', payload: multipartPayload, headers: { 'content-type': 'multipart/form-data; boundary=AaB03x' } }); @@ -485,6 +826,19 @@ describe('payload', () => { expect(res.result.pics).to.exist(); }); + it('places default limit on max parts in multipart payloads', async () => { + + const part = '--AaB03x\r\n' + 'content-disposition: form-data; name="x"\r\n\r\n' + 'x\r\n'; + const multipartPayload = part.repeat(1001) + '--AaB03x--\r\n'; + + const server = Hapi.server({ routes: { payload: { multipart: true } } }); + server.route({ method: 'POST', path: '/', handler: () => null }); + + const res = await server.inject({ method: 'POST', url: '/', payload: multipartPayload, headers: { 'content-type': 'multipart/form-data; boundary=AaB03x' } }); + expect(res.statusCode).to.equal(400); + expect(res.result.message).to.equal('Invalid multipart payload format'); + }); + it('signals connection close when payload is unconsumed', async () => { const payload = Buffer.alloc(1024); @@ -598,10 +952,47 @@ describe('payload', () => { '--AaB03x--\r\n'; const server = Hapi.server(); - server.route({ method: 'POST', path: '/echo', options: { handler: () => 'result', payload: { output: 'data', parse: true, maxBytes: 5 } } }); + server.route({ method: 'POST', path: '/echo', options: { handler: () => 'result', payload: { output: 'data', parse: true, maxBytes: 5, multipart: true } } }); const res = await server.inject({ method: 'POST', url: '/echo', payload: multipartPayload, simulate: { split: true }, headers: { 'content-length': null, 'content-type': 'multipart/form-data; boundary=AaB03x' } }); expect(res.statusCode).to.equal(400); expect(res.payload.toString()).to.equal('{"statusCode":400,"error":"Bad Request","message":"Invalid multipart payload format"}'); }); + + it('errors if multipart disabled (default)', async () => { + + const multipartPayload = + '--AaB03x\r\n' + + 'content-disposition: form-data; name="x"\r\n' + + '\r\n' + + 'First\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="x"\r\n' + + '\r\n' + + 'Second\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="x"\r\n' + + '\r\n' + + 'Third\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="field1"\r\n' + + '\r\n' + + 'Joe Blow\r\nalmost tricked you!\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="field1"\r\n' + + '\r\n' + + 'Repeated name segment\r\n' + + '--AaB03x\r\n' + + 'content-disposition: form-data; name="pics"; filename="file1.txt"\r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + '... contents of file1.txt ...\r\r\n' + + '--AaB03x--\r\n'; + + const server = Hapi.server(); + server.route({ method: 'POST', path: '/echo', options: { handler: () => 'result', payload: { output: 'data', parse: true, maxBytes: 5 } } }); + + const res = await server.inject({ method: 'POST', url: '/echo', payload: multipartPayload, simulate: { split: true }, headers: { 'content-length': null, 'content-type': 'multipart/form-data; boundary=AaB03x' } }); + expect(res.statusCode).to.equal(415); + }); }); diff --git a/test/request.js b/test/request.js index f9053306a..d2f1760e8 100755 --- a/test/request.js +++ b/test/request.js @@ -1,28 +1,25 @@ 'use strict'; -// Load modules - const Http = require('http'); const Net = require('net'); const Stream = require('stream'); const Url = require('url'); +const Events = require('events'); -const Boom = require('boom'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Lab = require('lab'); -const Teamwork = require('teamwork'); -const Wreck = require('wreck'); - +const Hoek = require('@hapi/hoek'); +const Joi = require('joi'); +const Lab = require('@hapi/lab'); +const Teamwork = require('@hapi/teamwork'); +const Wreck = require('@hapi/wreck'); -// Declare internals +const Common = require('./common'); const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -53,16 +50,17 @@ describe('Request.Generator', () => { it('decorates request with non function method', async () => { const server = Hapi.server(); + const symbol = Symbol('abc'); server.decorate('request', 'x2', 2); - server.decorate('request', 'abc', 1); + server.decorate('request', symbol, 1); server.route({ method: 'GET', path: '/', handler: (request) => { - return request.x2 + request.abc; + return request.x2 + request[symbol]; } }); @@ -70,33 +68,233 @@ describe('Request.Generator', () => { expect(res.statusCode).to.equal(200); expect(res.result).to.equal(3); }); + + it('does not share decorations between servers via prototypes', async () => { + + const server1 = Hapi.server(); + const server2 = Hapi.server(); + const route = { + method: 'GET', + path: '/', + handler: (request) => { + + return Object.keys(Object.getPrototypeOf(request)); + } + }; + let res; + + server1.decorate('request', 'x1', 1); + server2.decorate('request', 'x2', 2); + + server1.route(route); + server2.route(route); + + res = await server1.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal(['x1']); + + res = await server2.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal(['x2']); + }); + + it('decorates symbols when apply=true', async () => { + + const server = Hapi.server(); + const symbol = Symbol('abc'); + + server.decorate('request', symbol, () => 'foo', { apply: true }); + + server.route({ + method: 'GET', + path: '/', + handler: (request) => { + + return request[symbol]; + } + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal('foo'); + + }); }); describe('Request', () => { - it('sets client address', async () => { + it('sets host and hostname', async () => { + + const server = Hapi.server(); + + const handler = (request) => { + + return [request.info.host, request.info.hostname].join('|'); + }; + + server.route({ method: 'GET', path: '/', handler }); + + const res1 = await server.inject({ url: '/', headers: { host: 'host' } }); + expect(res1.payload).to.equal('host|host'); + + const res2 = await server.inject({ url: '/', headers: { host: 'host:123' } }); + expect(res2.payload).to.equal('host:123|host'); + + const res3 = await server.inject({ url: '/', headers: { host: '127.0.0.1' } }); + expect(res3.payload).to.equal('127.0.0.1|127.0.0.1'); + + const res4 = await server.inject({ url: '/', headers: { host: '127.0.0.1:123' } }); + expect(res4.payload).to.equal('127.0.0.1:123|127.0.0.1'); + + const res5 = await server.inject({ url: '/', headers: { host: '[::1]' } }); + expect(res5.payload).to.equal('[::1]|[::1]'); + + const res6 = await server.inject({ url: '/', headers: { host: '[::1]:123' } }); + expect(res6.payload).to.equal('[::1]:123|[::1]'); + }); + + it('sets client address (default)', async (flags) => { const server = Hapi.server(); const handler = (request) => { - let expectedClientAddress = '127.0.0.1'; - if (Net.isIPv6(server.listener.address().address)) { - expectedClientAddress = '::ffff:127.0.0.1'; + // Call twice to reuse cached values + + if (Common.hasIPv6) { + // 127.0.0.1 on node v14 and v16, ::1 on node v18 since DNS resolved to IPv6. + expect(request.info.remoteAddress).to.match(/^127\.0\.0\.1|::1$/); + expect(request.info.remoteAddress).to.match(/^127\.0\.0\.1|::1$/); } + else { + expect(request.info.remoteAddress).to.equal('127.0.0.1'); + expect(request.info.remoteAddress).to.equal('127.0.0.1'); + } + + expect(request.info.remotePort).to.be.above(0); + expect(request.info.remotePort).to.be.above(0); - expect(request.info.remoteAddress).to.equal(expectedClientAddress); - expect(request.info.remoteAddress).to.equal(request.info.remoteAddress); return 'ok'; }; - server.route({ method: 'GET', path: '/', handler }); + server.route({ method: 'get', path: '/', handler }); await server.start(); + flags.onCleanup = () => server.stop(); const { payload } = await Wreck.get('http://localhost:' + server.info.port); expect(payload.toString()).to.equal('ok'); - await server.stop(); + }); + + it('sets client address (ipv4)', async (flags) => { + + const server = Hapi.server(); + + const handler = (request) => { + + Object.defineProperty(request.raw.req.socket, 'remoteAddress', { + value: '100.100.100.100' + }); + + return request.info.remoteAddress; + }; + + server.route({ method: 'get', path: '/', handler }); + + await server.start(); + flags.onCleanup = () => server.stop(); + + const { payload } = await Wreck.get('http://localhost:' + server.info.port); + expect(payload.toString()).to.equal('100.100.100.100'); + }); + + it('sets client address (ipv6)', async (flags) => { + + const server = Hapi.server(); + + const handler = (request) => { + + Object.defineProperty(request.raw.req.socket, 'remoteAddress', { + value: '::ffff:0:0:0:0:1' + }); + + return request.info.remoteAddress; + }; + + server.route({ method: 'get', path: '/', handler }); + + await server.start(); + flags.onCleanup = () => server.stop(); + + const { payload } = await Wreck.get('http://localhost:' + server.info.port); + expect(payload.toString()).to.equal('::ffff:0:0:0:0:1'); + }); + + it('sets client address (ipv4-mapped ipv6)', async (flags) => { + + const server = Hapi.server(); + + const handler = (request) => { + + Object.defineProperty(request.raw.req.socket, 'remoteAddress', { + value: '::ffff:100.100.100.100' + }); + + return request.info.remoteAddress; + }; + + server.route({ method: 'get', path: '/', handler }); + + await server.start(); + flags.onCleanup = () => server.stop(); + + const { payload } = await Wreck.get('http://localhost:' + server.info.port); + expect(payload.toString()).to.equal('100.100.100.100'); + }); + + it('sets client address to nothing when not available', async (flags) => { + + const server = Hapi.server(); + const abortedReqTeam = new Teamwork.Team(); + let remoteAddr = 'not executed'; + + server.route({ + method: 'GET', + path: '/', + options: { + handler: async (request, h) => { + + req.destroy(); + + while (request.active()) { + await Hoek.wait(5); + } + + abortedReqTeam.attend(); + + remoteAddr = request.info.remoteAddress; + return null; + } + } + }); + + await server.start(); + flags.onCleanup = () => server.stop(); + + const req = Http.get(server.info.uri, Hoek.ignore); + req.on('error', Hoek.ignore); + + await abortedReqTeam.work; + + expect(remoteAddr).to.equal(undefined); + }); + + it('sets port to nothing when not available', async () => { + + const server = Hapi.server({ debug: false }); + server.route({ method: 'GET', path: '/', handler: (request) => request.info.remotePort === '' }); + const res = await server.inject('/'); + expect(res.result).to.equal(true); }); it('sets referrer', async () => { @@ -165,6 +363,16 @@ describe('Request', () => { expect(res.payload).to.equal('shot'); }); + it('sets host info from :authority header when host header is absent', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request) => `${request.info.host}|${request.info.hostname}` }); + + const res = await server.inject({ url: '/', headers: { host: '', ':authority': 'example.com:8080' } }); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal('example.com:8080|example.com'); + }); + it('generates unique request id', async () => { const server = Hapi.server(); @@ -181,13 +389,110 @@ describe('Request', () => { expect(res3.result).to.match(/10$/); }); + it('can serialize request.info with JSON.stringify()', async () => { + + const server = Hapi.server(); + + const handler = (request) => { + + const actual = JSON.stringify(request.info); + const expected = JSON.stringify({ + acceptEncoding: request.info.acceptEncoding, + completed: request.info.completed, + cors: request.info.cors, + host: request.info.host, + hostname: request.info.hostname, + id: request.info.id, + received: request.info.received, + referrer: request.info.referrer, + remoteAddress: request.info.remoteAddress, + remotePort: request.info.remotePort, + responded: request.info.responded + }); + + expect(actual).to.equal(expected); + return 'ok'; + }; + + server.route({ method: 'GET', path: '/', handler }); + + const res = await server.inject({ url: '/' }); + expect(res.result).to.equal('ok'); + }); + + describe('active()', () => { + + it('exits handler early when request is no longer active', { retry: true }, async (flags) => { + + let testComplete = false; + + const onCleanup = []; + flags.onCleanup = async () => { + + testComplete = true; + + for (const cleanup of onCleanup) { + await cleanup(); + } + }; + + const server = Hapi.server(); + const leaveHandlerTeam = new Teamwork.Team(); + + server.route({ + method: 'GET', + path: '/', + options: { + handler: async (request, h) => { + + req.destroy(); + + while (request.active() && !testComplete) { + await Hoek.wait(10); + } + + leaveHandlerTeam.attend({ + active: request.active(), + testComplete + }); + + return null; + } + } + }); + + await server.start(); + onCleanup.unshift(() => server.stop()); + + const req = Http.get(server.info.uri, Hoek.ignore); + req.on('error', Hoek.ignore); + + const note = await leaveHandlerTeam.work; + + expect(note).to.equal({ + active: false, + testComplete: false + }); + }); + }); + describe('_execute()', () => { it('returns 400 on invalid path', async () => { const server = Hapi.server(); + + server.ext('onRequest', (request, h) => { + + expect(request.url).to.be.null(); + expect(request.query).to.equal({}); + expect(request.path).to.equal('invalid'); + return h.continue; + }); + const res = await server.inject('invalid'); expect(res.statusCode).to.equal(400); + expect(res.result.message).to.startWith('Invalid URL'); }); it('returns boom response on ext error', async () => { @@ -222,6 +527,56 @@ describe('Request', () => { expect(res.result.statusCode).to.equal(500); }); + it('returns error response on ext timeout', async () => { + + const server = Hapi.server(); + + const responded = server.ext('onPostResponse'); + const ext = (request) => { + + return Hoek.block(); + }; + + server.ext('onPostHandler', ext, { timeout: 100 }); + server.route({ method: 'GET', path: '/', handler: () => 'OK' }); + + const res = await server.inject('/'); + expect(res.result.statusCode).to.equal(500); + + const request = await responded; + expect(request.response._error).to.be.an.error('onPostHandler timed out'); + }); + + it('logs error responses on onPostResponse ext error', async () => { + + const server = Hapi.server(); + + const ext1 = () => { + + throw new Error('oops1'); + }; + + server.ext('onPostResponse', ext1); + + const ext2 = () => { + + throw new Error('oops2'); + }; + + server.ext('onPostResponse', ext2); + + server.route({ method: 'GET', path: '/', handler: () => 'OK' }); + + const log = server.events.few({ name: 'request', channels: 'internal', filter: 'ext', count: 2 }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + + const [[, event1], [, event2]] = await log; + expect(event1.error).to.be.an.error('oops1'); + expect(event2.error).to.be.an.error('oops2'); + }); + it('handles aborted requests (during response)', async () => { const handler = (request) => { @@ -233,6 +588,7 @@ describe('Request', () => { if (this.isDone) { return; } + this.isDone = true; this.push('success'); @@ -244,14 +600,16 @@ describe('Request', () => { return stream; }; - const server = Hapi.server(); + const server = Hapi.server({ info: { remote: true } }); server.route({ method: 'GET', path: '/', handler }); let disconnected = 0; + let info; const onRequest = (request, h) => { request.events.once('disconnect', () => { + info = request.info; ++disconnected; }); @@ -267,8 +625,8 @@ describe('Request', () => { const client = Net.connect(server.info.port, () => { - client.write('GET / HTTP/1.1\r\n\r\n'); - client.write('GET / HTTP/1.1\r\n\r\n'); + client.write('GET / HTTP/1.1\r\nHost: host\r\n\r\n'); + client.write('GET / HTTP/1.1\r\nHost: host\r\n\r\n'); }); client.on('data', () => { @@ -296,9 +654,11 @@ describe('Request', () => { }); await server.stop(); + expect(info.remotePort).to.exist(); + expect(info.remoteAddress).to.exist(); }); - it('handles aborted requests (pre response)', async () => { + it('handles aborted requests (before response)', { retry: true }, async (flags) => { const server = Hapi.server(); server.route({ @@ -307,7 +667,10 @@ describe('Request', () => { handler: () => null }); - const team = new Teamwork(); + const codes = []; + server.ext('onPostResponse', (request) => codes.push(Boom.isBoom(request.response) ? request.response.output.statusCode : request.response.statusCode)); + + const team = new Teamwork.Team(); const onRequest = (request, h) => { request.events.once('disconnect', () => team.attend()); @@ -316,25 +679,38 @@ describe('Request', () => { server.ext('onRequest', onRequest); - const onPreHandler = (request, h) => { + let firstRequest = true; + const onPreHandler = async (request, h) => { + + if (firstRequest) { + client.destroy(); + firstRequest = false; + } + else { + // To avoid timing differences between node versions, ensure that + // the second and third requests always experience the disconnect + await team.work; + } - client.destroy(); return h.continue; }; server.ext('onPreHandler', onPreHandler); await server.start(); + flags.onCleanup = () => server.stop(); const client = Net.connect(server.info.port, () => { - client.write('GET /test HTTP/1.1\r\n\r\n'); - client.write('GET /test HTTP/1.1\r\n\r\n'); - client.write('GET /test HTTP/1.1\r\n\r\n'); + client.write('GET /test HTTP/1.1\r\nHost: host\r\n\r\n'); + client.write('GET /test HTTP/1.1\r\nHost: host\r\n\r\n'); + client.write('GET /test HTTP/1.1\r\nHost: host\r\n\r\n'); }); await team.work; await server.stop(); + + expect(codes).to.equal([204, 499, 499]); }); it('returns empty params array when none present', async () => { @@ -363,11 +739,11 @@ describe('Request', () => { it('does not fail on abort', async () => { const server = Hapi.server(); - const team = new Teamwork(); + const team = new Teamwork.Team(); const handler = async (request) => { - clientRequest.abort(); + clientRequest.destroy(); await Hoek.wait(10); team.attend(); throw new Error('fail'); @@ -393,13 +769,13 @@ describe('Request', () => { it('does not fail on abort (onPreHandler)', async () => { const server = Hapi.server(); - const team = new Teamwork(); + const team = new Teamwork.Team(); server.route({ method: 'GET', path: '/', handler: () => null }); const preHandler = async (request, h) => { - clientRequest.abort(); + clientRequest.destroy(); await Hoek.wait(10); team.attend(); return h.continue; @@ -426,7 +802,7 @@ describe('Request', () => { const handler = async (request) => { - clientRequest.abort(); + clientRequest.destroy(); await Hoek.wait(10); throw new Error('boom'); }; @@ -508,33 +884,253 @@ describe('Request', () => { const res = await server.inject({ url: '/some/route', allowInternals: true }); expect(res.statusCode).to.equal(200); }); - }); - describe('_onRequest()', () => { + it('allows internal only route (inject with allowInternals and authority)', async () => { - it('errors on non-takeover response', async () => { + const server = Hapi.server(); + server.route({ + method: 'GET', + path: '/some/route', + options: { + isInternal: true, + handler: () => 'ok' + } + }); - const server = Hapi.server({ debug: false }); - server.ext('onRequest', () => 'something'); - server.route({ method: 'GET', path: '/', handler: () => null }); - const res = await server.inject('/'); - expect(res.statusCode).to.equal(500); + const res = await server.inject({ url: '/some/route', allowInternals: true, authority: 'server:8000' }); + expect(res.statusCode).to.equal(200); }); - }); - describe('_lifecycle()', () => { + it('creates arrays from multiple entries', async () => { - it('errors on non-takeover response in pre handler ext', async () => { + const server = Hapi.server(); - const server = Hapi.server({ debug: false }); - server.ext('onPreHandler', () => 'something'); - server.route({ method: 'GET', path: '/', handler: () => null }); - const res = await server.inject('/'); - expect(res.statusCode).to.equal(500); - }); - }); + const handler = (request) => { - describe('_reply()', () => { + return { a: request.query.a, array: Array.isArray(request.query.a), instance: request.query.a instanceof Array }; + }; + + server.route({ method: 'GET', path: '/', handler }); + + const res = await server.inject('/?a=1&a=2'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal({ a: ['1', '2'], array: true, instance: true }); + }); + + it('supports custom query parser (new object)', async () => { + + const parser = (query) => { + + return { hello: query.hi }; + }; + + const server = Hapi.server({ query: { parser } }); + + server.route({ + method: 'GET', + path: '/', + options: { + handler: (request) => request.query.hello + } + }); + + const res = await server.inject('/?hi=hola'); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('hola'); + }); + + it('supports custom query parser (same object)', async () => { + + const parser = (query) => { + + query.hello = query.hi; + return query; + }; + + const server = Hapi.server({ query: { parser } }); + + server.route({ + method: 'GET', path: '/', options: { + handler: (request) => request.query.hello + } + }); + + const res = await server.inject('/?hi=hola'); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('hola'); + }); + + it('returns 500 when custom query parser returns non-object', async () => { + + const server = Hapi.server({ debug: false, query: { parser: () => 'something' } }); + + server.route({ + method: 'GET', path: '/', options: { + handler: (request) => request.query.hello + } + }); + + const res = await server.inject('/?hi=hola'); + expect(res.statusCode).to.equal(500); + expect(res.request.response._error).to.be.an.error('Parsed query must be an object'); + }); + + it('returns 500 when custom query parser returns null', async () => { + + const server = Hapi.server({ debug: false, query: { parser: () => null } }); + + server.route({ + method: 'GET', path: '/', options: { + handler: (request) => request.query.hello + } + }); + + const res = await server.inject('/?hi=hola'); + expect(res.statusCode).to.equal(500); + expect(res.request.response._error).to.be.an.error('Parsed query must be an object'); + }); + }); + + describe('_onRequest()', () => { + + it('errors on non-takeover response', async () => { + + const server = Hapi.server({ debug: false }); + server.ext('onRequest', () => 'something'); + server.route({ method: 'GET', path: '/', handler: () => null }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + }); + + describe('_lifecycle()', () => { + + it('errors on non-takeover response in pre handler ext', async () => { + + const server = Hapi.server({ debug: false }); + server.ext('onPreHandler', () => 'something'); + server.route({ method: 'GET', path: '/', handler: () => null }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + + it('logs thrown errors as boom errors', async () => { + + const server = Hapi.server({ debug: false }); + server.route({ + method: 'GET', + path: '/', + options: { + handler: function () { + + // eslint-disable-next-line no-undef + NOT_DEFINED_VAR; + } + } + }); + + const log = new Promise((resolve) => { + + server.events.on({ name: 'request', channels: 'internal' }, (request, event, tags) => { + + if (tags.handler && + tags.error) { + + resolve({ event, tags }); + } + }); + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + + const { event } = await log; + expect(event.error.isBoom).to.equal(true); + expect(event.error.output.statusCode).to.equal(500); + expect(event.error.stack).to.exist(); + }); + }); + + describe('_postCycle()', () => { + + it('skips onPreResponse when validation terminates request', { retry: true }, async (flags) => { + + const server = Hapi.server(); + const abortedReqTeam = new Teamwork.Team(); + + let called = false; + server.ext('onPreResponse', (request, h) => { + + called = true; + return h.continue; + }); + + server.route({ + method: 'GET', + path: '/', + options: { + handler: (request) => { + + // Stash raw so that we can access it on response validation + Object.assign(request.app, request.raw); + + return null; + }, + response: { + status: { + 200: async (_, { context }) => { + + req.destroy(); + + const raw = context.app.request; + await Events.once(raw.req, 'aborted'); + + abortedReqTeam.attend(); + } + } + } + } + }); + + await server.start(); + flags.onCleanup = () => server.stop(); + + const req = Http.get(server.info.uri, Hoek.ignore); + req.on('error', Hoek.ignore); + + await abortedReqTeam.work; + + await server.events.once('response'); + + expect(called).to.be.false(); + }); + + it('handles continue signal', async () => { + + const server = Hapi.server({ debug: false }); + server.route({ + method: 'GET', + path: '/', + options: { + handler: () => ({ a: '1' }), + validate: { + validator: Joi + }, + response: { + failAction: (request, h) => h.continue, + schema: { + b: Joi.string() + } + } + } + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + }); + }); + + describe('_reply()', () => { it('returns a reply with auto end in onPreResponse', async () => { @@ -559,11 +1155,31 @@ describe('Request', () => { await server.inject('/'); const [request] = await log; expect(request.info.responded).to.be.min(request.info.received); + expect(request.info.completed).to.be.min(request.info.responded); + expect(request.response.source).to.equal('ok'); + expect(request.response.statusCode).to.equal(200); + }); + + it('skips logging error when not the result of a thrown error', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response().code(500) }); + + let called = false; + server.events.once('request', () => { + + called = true; + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + expect(res.request.response._error).to.not.exist(); + expect(called).to.be.false(); }); - it('closes response after server timeout', async () => { + it('destroys response after server timeout', async () => { - const team = new Teamwork(); + const team = new Teamwork.Team(); const handler = async (request) => { await Hoek.wait(100); @@ -575,7 +1191,7 @@ describe('Request', () => { this.push(null); }; - stream.close = () => team.attend(); + stream._destroy = () => team.attend(); return stream; }; @@ -595,11 +1211,11 @@ describe('Request', () => { const handler = async (request) => { - await Hoek.wait(10); + await Hoek.wait(40); throw new Error('after'); }; - const server = Hapi.server({ routes: { timeout: { server: 5 } } }); + const server = Hapi.server({ routes: { timeout: { server: 20 } } }); server.route({ method: 'GET', path: '/', handler }); const res = await server.inject('/'); @@ -722,12 +1338,18 @@ describe('Request', () => { const url = 'http://localhost/page?param1=something'; const server = Hapi.server(); - server.route({ method: 'GET', path: '/', handler: () => null }); + + const handler = (request) => { + + return [request.url.href, request.path, request.query.param1].join('|'); + }; + + server.route({ method: 'GET', path: '/page', handler }); const onRequest = (request, h) => { request.setUrl(url); - return h.response([request.url.href, request.path, request.query.param1].join('|')).takeover(); + return h.continue; }; server.ext('onRequest', onRequest); @@ -736,6 +1358,23 @@ describe('Request', () => { expect(res.payload).to.equal(url + '|/page|something'); }); + it('sets root url', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request) => request.url.pathname }); + + const onRequest = (request, h) => { + + request.setUrl('/'); + return h.continue; + }; + + server.ext('onRequest', onRequest); + + const res = await server.inject('/a/b/c'); + expect(res.result).to.equal('/'); + }); + it('updates host info', async () => { const url = 'http://redirected:321/'; @@ -778,24 +1417,30 @@ describe('Request', () => { expect(res2.payload).to.equal(url + '|/|initial|redirected|redirected'); }); - it('overrides query string parsing', async () => { + it('overrides query string content', async () => { const server = Hapi.server(); - server.route({ method: 'GET', path: '/', handler: () => null }); + + const handler = (request) => { + + return [request.url.href, request.path, request.query.a].join('|'); + }; + + server.route({ method: 'GET', path: '/', handler }); const onRequest = (request, h) => { const uri = request.raw.req.url; - const parsed = Url.parse(uri, true); - parsed.query.a = 2; + const parsed = new Url.URL(uri, 'http://test/'); + parsed.searchParams.set('a', 2); request.setUrl(parsed); - return h.response([request.url.href, request.path, request.query.a].join('|')).takeover(); + return h.continue; }; server.ext('onRequest', onRequest); const res = await server.inject('/?a=1'); - expect(res.payload).to.equal('/?a=1|/|2'); + expect(res.payload).to.equal('http://test/?a=2|/|2'); }); it('normalizes a path', async () => { @@ -811,121 +1456,341 @@ describe('Request', () => { const onRequest = (request, h) => { - request.setUrl(url); - return h.response([request.url.href, request.path, request.query.param1].join('|')).takeover(); + request.setUrl(url); + return h.response([request.url.href, request.path, request.url.searchParams.get('param1')].join('|')).takeover(); + }; + + server.ext('onRequest', onRequest); + + const res = await server.inject('/'); + expect(res.payload).to.equal(normUrl + '|' + normPath + '|something'); + }); + + it('errors on empty path', async () => { + + const server = Hapi.server({ debug: false }); + const onRequest = (request, h) => { + + request.setUrl(''); + return h.continue; + }; + + server.ext('onRequest', onRequest); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + + it('throws when path is missing', async () => { + + const server = Hapi.server(); + const onRequest = (request, h) => { + + try { + request.setUrl(); + } + catch (err) { + return h.response(err.message).takeover(); + } + + return h.continue; + }; + + server.ext('onRequest', onRequest); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('Url must be a string or URL object'); + }); + + it('strips trailing slash', async () => { + + const server = Hapi.server({ router: { stripTrailingSlash: true } }); + server.route({ method: 'GET', path: '/test', handler: () => null }); + + const res1 = await server.inject('/test/'); + expect(res1.statusCode).to.equal(204); + + const res2 = await server.inject('/test'); + expect(res2.statusCode).to.equal(204); + }); + + it('does not strip trailing slash on /', async () => { + + const server = Hapi.server({ router: { stripTrailingSlash: true } }); + server.route({ method: 'GET', path: '/', handler: () => null }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(204); + }); + + it('strips trailing slash with query', async () => { + + const server = Hapi.server({ router: { stripTrailingSlash: true } }); + server.route({ method: 'GET', path: '/test', handler: () => null }); + const res = await server.inject('/test/?a=b'); + expect(res.statusCode).to.equal(204); + }); + + it('clones passed url', async () => { + + const urlObject = new Url.URL('http:/%41'); + let requestUrl; + + const server = Hapi.server(); + const onRequest = (request, h) => { + + request.setUrl(urlObject); + requestUrl = request.url; + + return h.continue; + }; + + server.ext('onRequest', onRequest); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(404); + expect(requestUrl).to.equal(urlObject); + expect(requestUrl).to.not.shallow.equal(urlObject); + }); + + it('handles vhost redirection', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', vhost: 'one', handler: () => 'success' }); + + const onRequest = (request, h) => { + + request.setUrl('http://one/'); + return h.continue; + }; + + server.ext('onRequest', onRequest); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.payload).to.equal('success'); + }); + + it('handles hostname in HTTP request resource', async () => { + + const server = Hapi.server({ debug: false }); + const team = new Teamwork.Team(); + + let hostname; + server.route({ + method: 'GET', + path: '/', + handler: (request) => { + + hostname = request.info.hostname; + team.attend(); + return null; + } + }); + + await server.start(); + const socket = Net.createConnection(server.info.port, '127.0.0.1', () => socket.write('GET http://host.com\r\n\r\n')); + await team.work; + socket.destroy(); + await server.stop(); + expect(hostname).to.equal('host.com'); + }); + + it('handles url starting with multiple /', async () => { + + const server = Hapi.server(); + server.route({ + method: 'GET', + path: '/{p*}', + handler: (request) => { + + return { + p: request.params.p, + path: request.path, + hostname: request.info.hostname.toLowerCase() // Lowercase for OSX tests + }; + } + }); + + const res = await server.inject('//path'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal({ p: '/path', path: '//path', hostname: server.info.host.toLowerCase() }); + }); + + it('handles escaped path segments', async () => { + + const server = Hapi.server(); + server.route({ path: '/%2F/%2F', method: 'GET', handler: (request) => request.path }); + + const tests = [ + ['/', 404], + ['////', 404], + ['/%2F/%2F', 200, '/%2F/%2F'], + ['/%2F/%2F#x', 200, '/%2F/%2F'], + ['/%2F/%2F?a=1#x', 200, '/%2F/%2F'] + ]; + + for (const [uri, code, result] of tests) { + const res = await server.inject(uri); + expect(res.statusCode).to.equal(code); + + if (code < 400) { + expect(res.result).to.equal(result); + } + } + }); + + it('handles fragments (no query)', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/{p*}', handler: (request) => request.path }); + + await server.start(); + + const options = { + hostname: 'localhost', + port: server.info.port, + path: '/path#ignore', + method: 'GET' + }; + + const team = new Teamwork.Team(); + const req = Http.request(options, (res) => team.attend(res)); + req.end(); + + const res = await team.work; + const payload = await Wreck.read(res); + expect(payload.toString()).to.equal('/path'); + + await server.stop(); + }); + + it('handles fragments (with query)', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/{p*}', handler: (request) => request.query.a }); + + await server.start(); + + const options = { + hostname: 'localhost', + port: server.info.port, + path: '/path?a=1#ignore', + method: 'GET' }; - server.ext('onRequest', onRequest); + const team = new Teamwork.Team(); + const req = Http.request(options, (res) => team.attend(res)); + req.end(); - const res = await server.inject('/'); - expect(res.payload).to.equal(normUrl + '|' + normPath + '|something'); + const res = await team.work; + const payload = await Wreck.read(res); + expect(payload.toString()).to.equal('1'); + + await server.stop(); }); - it('allows missing path', async () => { + it('handles fragments with ? (no query)', async () => { const server = Hapi.server(); - const onRequest = (request, h) => { + server.route({ method: 'GET', path: '/{p*}', handler: (request) => request.path }); - request.setUrl(''); - return h.continue; + await server.start(); + + const options = { + hostname: 'localhost', + port: server.info.port, + path: '/path#ignore?x', + method: 'GET' }; - server.ext('onRequest', onRequest); + const team = new Teamwork.Team(); + const req = Http.request(options, (res) => team.attend(res)); + req.end(); - const res = await server.inject('/'); - expect(res.statusCode).to.equal(400); + const res = await team.work; + const payload = await Wreck.read(res); + expect(payload.toString()).to.equal('/path'); + + await server.stop(); }); - it('strips trailing slash', async () => { + it('handles absolute URL (proxy)', async () => { - const server = Hapi.server({ router: { stripTrailingSlash: true } }); - server.route({ method: 'GET', path: '/test', handler: () => null }); - const res = await server.inject('/test/'); - expect(res.statusCode).to.equal(200); - }); + const server = Hapi.server(); + server.route({ method: 'GET', path: '/{p*}', handler: (request) => request.query.a.join() }); - it('does not strip trailing slash on /', async () => { + await server.start(); - const server = Hapi.server({ router: { stripTrailingSlash: true } }); - server.route({ method: 'GET', path: '/', handler: () => null }); - const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - }); + const options = { + hostname: 'localhost', + port: server.info.port, + path: 'http://example.com/path?a=1&a=2#ignore', + method: 'GET' + }; - it('strips trailing slash with query', async () => { + const team = new Teamwork.Team(); + const req = Http.request(options, (res) => team.attend(res)); + req.end(); - const server = Hapi.server({ router: { stripTrailingSlash: true } }); - server.route({ method: 'GET', path: '/test', handler: () => null }); - const res = await server.inject('/test/?a=b'); - expect(res.statusCode).to.equal(200); + const res = await team.work; + const payload = await Wreck.read(res); + expect(payload.toString()).to.equal('1,2'); + + await server.stop(); }); + }); - it('clones passed url', async () => { + describe('url', () => { - const urlObject = { - protocol: 'http:', - pathname: '/%41' - }; - const passedUrl = Hoek.clone(urlObject); - let requestUrl; + it('generates URL object lazily', async () => { const server = Hapi.server(); - const onRequest = (request, h) => { - request.setUrl(passedUrl); - requestUrl = request.url; + const handler = (request) => { - return h.continue; + expect(request._url).to.not.exist(); + return request.url.pathname; }; - server.ext('onRequest', onRequest); - - const res = await server.inject('/'); - expect(res.statusCode).to.equal(404); - expect(passedUrl).to.equal(urlObject); - expect(requestUrl).to.not.shallow.equal(passedUrl); - expect(requestUrl).to.not.equal(urlObject); + server.route({ path: '/test', method: 'GET', handler }); + const res = await server.inject('/test?a=1'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal('/test'); }); - it('handles vhost redirection', async () => { + it('generates URL object lazily (no host header)', async () => { const server = Hapi.server(); - server.route({ method: 'GET', path: '/', vhost: 'one', handler: () => 'success' }); - const onRequest = (request, h) => { + const handler = (request) => { - request.setUrl('http://one/'); - return h.continue; + delete request.info.host; + expect(request._url).to.not.exist(); + return request.url.pathname; }; - server.ext('onRequest', onRequest); - - const res = await server.inject('/'); + server.route({ path: '/test', method: 'GET', handler }); + const res = await server.inject('/test?a=1'); expect(res.statusCode).to.equal(200); - expect(res.payload).to.equal('success'); + expect(res.result).to.equal('/test'); }); - it('handles hostname in HTTP request resource', async () => { + it('generates valid URL when server host is IPv6 and host header is absent', async () => { - const server = Hapi.server({ debug: false }); + const server = Hapi.server({ host: '::1' }); - let hostname; - server.route({ - method: 'GET', - path: '/', - handler: (request) => { + const handler = (request) => { - hostname = request.info.hostname; - return null; - } - }); + delete request.info.host; + expect(request._url).to.not.exist(); + return request.url.host; + }; - await server.start(); - const socket = Net.createConnection(server.info.port, '127.0.0.1', () => socket.write('GET http://host.com\r\n\r\n')); - await Hoek.wait(10); - socket.destroy(); - await server.stop(); - expect(hostname).to.equal('host.com'); + server.route({ path: '/test', method: 'GET', handler }); + const res = await server.inject('/test'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.match(/^\[::1\]:\d+$/); }); }); @@ -993,7 +1858,7 @@ describe('Request', () => { }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); await log; }); @@ -1016,7 +1881,7 @@ describe('Request', () => { server.route({ method: 'GET', path: '/', handler }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('emits a request event (function data + collect)', async () => { @@ -1040,7 +1905,7 @@ describe('Request', () => { server.route({ method: 'GET', path: '/', handler }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('emits a request event (function data)', async () => { @@ -1063,7 +1928,7 @@ describe('Request', () => { server.route({ method: 'GET', path: '/', handler }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('outputs log to debug console without data', async () => { @@ -1091,7 +1956,7 @@ describe('Request', () => { }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); await log; }); @@ -1120,7 +1985,7 @@ describe('Request', () => { }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); await log; }); @@ -1145,14 +2010,14 @@ describe('Request', () => { expect(args[0]).to.equal('Debug:'); expect(args[1]).to.equal('implementation'); - expect(args[2]).to.equal('\n [Cannot display object: Converting circular structure to JSON]'); + expect(args[2]).to.match(/Cannot display object: Converting circular structure to JSON/); console.error = orig; resolve(); }; }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); await log; }); @@ -1252,6 +2117,38 @@ describe('Request', () => { expect(i).to.equal(1); console.error = orig; }); + + it('logs nothing', async () => { + + const server = Hapi.server({ debug: false, routes: { log: { collect: false } } }); + + const handler = (request) => { + + expect(request.logs).to.have.length(0); + return request.info.acceptEncoding; + }; + + server.route({ method: 'GET', path: '/', handler }); + + const res = await server.inject({ url: '/', headers: { 'accept-encoding': 'a;b' } }); + expect(res.result).to.equal('identity'); + }); + + it('logs when only collect is true', async () => { + + const server = Hapi.server({ debug: false, routes: { log: { collect: true } } }); + + const handler = (request) => { + + expect(request.logs).to.have.length(1); + return request.info.acceptEncoding; + }; + + server.route({ method: 'GET', path: '/', handler }); + + const res = await server.inject({ url: '/', headers: { 'accept-encoding': 'a;b' } }); + expect(res.result).to.equal('identity'); + }); }); describe('_setResponse()', () => { @@ -1404,6 +2301,223 @@ describe('Request', () => { expect(res.statusCode).to.equal(200); }); + it('creates error response when request is aborted while draining payload', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + await server.start(); + + const log = server.events.once('response'); + const ready = new Promise((resolve) => { + + server.ext('onRequest', (request, h) => { + + resolve(); + return h.continue; + }); + }); + + const req = Http.request({ + hostname: 'localhost', + port: server.info.port, + method: 'GET', + headers: { 'content-length': 42 } + }); + + req.on('error', Hoek.ignore); + req.flushHeaders(); + + await ready; + req.destroy(); + const [request] = await log; + + expect(request.response.output.statusCode).to.equal(499); + + await server.stop({ timeout: 1 }); + }); + + it('returns an unlogged bad request error when parser fails before request is setup', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + await server.start(); + + let responseCount = 0; + server.events.on('response', () => { + + responseCount += 1; + }); + + const client = Net.connect(server.info.port); + const clientEnded = new Promise((resolve, reject) => { + + let response = ''; + client.on('data', (chunk) => { + + response = response + chunk.toString(); + }); + + client.on('end', () => resolve(response)); + client.on('error', reject); + }); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('hello\n\r'); + + const clientResponse = await clientEnded; + expect(clientResponse).to.contain('400 Bad Request'); + expect(responseCount).to.equal(0); + + await server.stop({ timeout: 1 }); + }); + + it('returns normal response when parser fails with bad method after request is setup', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + server.route({ path: '/', method: 'GET', handler: () => 'PAYLOAD' }); + await server.start(); + + const log = server.events.once('response'); + const client = Net.connect(server.info.port); + const clientEnded = Wreck.read(client); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('GET / HTTP/1.1\r\nHost: test\r\nContent-Length: 0\r\n\r\ninvalid data'); + + const [request] = await log; + expect(request.response.statusCode).to.equal(200); + expect(request.response.source).to.equal('PAYLOAD'); + const clientResponse = (await clientEnded).toString(); + expect(clientResponse).to.contain('HTTP/1.1 200 OK'); + + const nextResponse = clientResponse.slice(clientResponse.indexOf('PAYLOAD') + 7); + expect(nextResponse).to.startWith('HTTP/1.1 400 Bad Request'); + + await server.stop({ timeout: 1 }); + }); + + it('returns nothing when parser fails with bad method after request is setup and the connection is closed', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + server.route({ path: '/', method: 'GET', handler: (request, h) => { + + request.raw.res.destroy(); + return h.abandon; + } }); + + await server.start(); + + const log = server.events.once('response'); + const client = Net.connect(server.info.port); + const clientEnded = Wreck.read(client); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('GET / HTTP/1.1\r\nHost: test\r\nContent-Length: 0\r\n\r\n\r\ninvalid data'); + + const [request] = await log; + expect(request.response.statusCode).to.be.undefined(); + const clientResponse = (await clientEnded).toString(); + expect(clientResponse).to.equal(''); + + await server.stop({ timeout: 1 }); + }); + + it('returns a bad request when parser fails after request is setup (cleanStop false)', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } }, operations: { cleanStop: false } }); + server.route({ path: '/', method: 'GET', handler: Hoek.block }); + await server.start(); + + const client = Net.connect(server.info.port); + const clientEnded = new Promise((resolve, reject) => { + + let response = ''; + client.on('data', (chunk) => { + + response = response + chunk.toString(); + }); + + client.on('end', () => resolve(response)); + client.on('error', reject); + }); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('GET / HTTP/1.1\r\nHost: test\nContent-Length: 0\r\n\r\ninvalid data'); + + const clientResponse = await clientEnded; + expect(clientResponse).to.contain('400 Bad Request'); + + await server.stop({ timeout: 1 }); + }); + + it('returns a bad request for POST request when chunked parsing fails', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + server.route({ path: '/', method: 'POST', handler: () => 'ok', options: { payload: { parse: true } } }); + await server.start(); + + const log = server.events.once('response'); + const client = Net.connect(server.info.port); + const clientEnded = Wreck.read(client); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('POST / HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n'); + await Hoek.wait(10); + client.write('not chunked\r\n'); + + const [request] = await log; + expect(request.response.statusCode).to.equal(400); + expect(request.response.source).to.contain({ error: 'Bad Request' }); + const clientResponse = (await clientEnded).toString(); + expect(clientResponse).to.contain('400 Bad Request'); + + await server.stop({ timeout: 1 }); + }); + + it('returns a bad request for POST request when chunked parsing fails (cleanStop false)', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } }, operations: { cleanStop: false } }); + server.route({ path: '/', method: 'POST', handler: () => 'ok', options: { payload: { parse: true } } }); + await server.start(); + + const client = Net.connect(server.info.port); + const clientEnded = Wreck.read(client); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('POST / HTTP/1.1\r\nHost: test\r\nTransfer-Encoding: chunked\r\n\r\n'); + await Hoek.wait(10); + client.write('not chunked\r\n'); + + const clientResponse = (await clientEnded).toString(); + expect(clientResponse).to.contain('400 Bad Request'); + + await server.stop({ timeout: 1 }); + }); + + it('returns a bad request for POST request when chunked parsing fails', async () => { + + const server = Hapi.server({ routes: { timeout: { server: false } } }); + server.route({ path: '/', method: 'POST', handler: () => 'ok', options: { payload: { parse: true } } }); + await server.start(); + + const log = server.events.once('response'); + const client = Net.connect(server.info.port); + const clientEnded = Wreck.read(client); + + await new Promise((resolve) => client.on('connect', resolve)); + client.write('POST / HTTP/1.1\r\nHost: test\r\nContent-Length: 5\r\n\r\n'); + await Hoek.wait(10); + client.write('111A1'); // Doesn't work if 'A' is replaced with '1' !?! + client.write('\Q\r\n'); // Extra bytes considered to be start of next request + client.end(); + + const [request] = await log; + expect(request.response.statusCode).to.equal(400); + expect(request.response.source).to.contain({ error: 'Bad Request' }); + const clientResponse = (await clientEnded).toString(); + expect(clientResponse).to.contain('400 Bad Request'); + + await server.stop({ timeout: 1 }); + }); + it('does not return an error when server is responding when the timeout occurs', async () => { let ended = false; @@ -1454,6 +2568,7 @@ describe('Request', () => { if (this.isDone) { return; } + this.isDone = true; setTimeout(() => { @@ -1489,41 +2604,61 @@ describe('Request', () => { expect(res.statusCode).to.equal(200); }); - it('handles race condition between equal client and server timeouts', async () => { + it('handles race condition between equal client and server timeouts', async (flags) => { + + const onCleanup = []; + flags.onCleanup = async () => { + + for (const cleanup of onCleanup) { + await cleanup(); + } + }; - const server = Hapi.server({ routes: { timeout: { server: 50 }, payload: { timeout: 50 } } }); + const server = Hapi.server({ routes: { timeout: { server: 100 }, payload: { timeout: 100 } } }); server.route({ method: 'POST', path: '/timeout', options: { handler: Hoek.block } }); await server.start(); + onCleanup.unshift(() => server.stop()); const timer = new Hoek.Bench(); const options = { - hostname: '127.0.0.1', + hostname: 'localhost', port: server.info.port, path: '/timeout', method: 'POST' }; - await new Promise(async (resolve) => { + const req = Http.request(options); + onCleanup.unshift(() => req.destroy()); - const req = Http.request(options, (res) => { + req.write('\n'); - expect([503, 408]).to.contain(res.statusCode); - expect(timer.elapsed()).to.be.at.least(45); - resolve(); - }); + const [res] = await Events.once(req, 'response'); - req.on('error', (err) => { + expect([503, 408]).to.contain(res.statusCode); + expect(timer.elapsed()).to.be.at.least(80); - expect(err).to.not.exist(); - }); + await Events.once(req, 'close'); // Ensures that req closes without error + }); + }); - req.write('\n'); - await Hoek.wait(100); - req.end(); - }); + describe('event()', () => { - await server.stop({ timeout: 1 }); + it('does not emit request error on normal close', async () => { + + const server = Hapi.server(); + const events = []; + server.events.on('request', (request, event, tags) => events.push(tags)); + + server.route({ method: 'GET', path: '/', handler: () => 'ok' }); + + await server.start(); + + const { payload } = await Wreck.get('http://localhost:' + server.info.port); + expect(payload.toString()).to.equal('ok'); + await server.stop(); + + expect(events).to.have.length(0); }); }); }); diff --git a/test/response.js b/test/response.js index 438a1a1d6..61056a1ba 100755 --- a/test/response.js +++ b/test/response.js @@ -1,30 +1,24 @@ 'use strict'; -// Load modules - const Events = require('events'); const Http = require('http'); const Path = require('path'); const Stream = require('stream'); -const Code = require('code'); +const Code = require('@hapi/code'); const Handlebars = require('handlebars'); +const LegacyReadableStream = require('legacy-readable-stream'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Inert = require('inert'); -const Lab = require('lab'); -const Vision = require('vision'); +const Inert = require('@hapi/inert'); +const Lab = require('@hapi/lab'); +const Vision = require('@hapi/vision'); const Response = require('../lib/response'); -// Declare internals - const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -105,10 +99,10 @@ describe('Response', () => { }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - expect(res.headers['content-length']).to.equal(0); - expect(res.headers['content-type']).to.not.exist(); - expect(res.result).to.equal(''); + expect(res.statusCode).to.equal(204); + expect(res.headers['content-length']).to.not.exist(); + expect(res.headers['content-type']).to.equal('text/html; charset=utf-8'); + expect(res.result).to.equal(null); expect(res.payload).to.equal(''); }); @@ -122,21 +116,46 @@ describe('Response', () => { }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - expect(res.headers['content-length']).to.equal(0); + expect(res.statusCode).to.equal(204); + expect(res.headers['content-length']).to.not.exist(); + expect(res.headers['content-type']).to.not.exist(); expect(res.result).to.equal(null); expect(res.payload).to.equal(''); }); + + it('returns a stream', async () => { + + const handler = (request) => { + + const stream = new Stream.Readable({ + read() { + + this.push('x'); + this.push(null); + } + }); + + return stream; + }; + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler }); + + const res = await server.inject('/'); + expect(res.result).to.equal('x'); + expect(res.statusCode).to.equal(200); + expect(res.headers['content-type']).to.equal('application/octet-stream'); + }); }); describe('code()', () => { it('sets manual code regardless of emptyStatusCode override', async () => { - const server = Hapi.server({ routes: { response: { emptyStatusCode: 204 } } }); - server.route({ method: 'GET', path: '/', handler: (request, h) => h.response().code(200) }); + const server = Hapi.server({ routes: { response: { emptyStatusCode: 200 } } }); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response().code(204) }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); }); @@ -213,7 +232,7 @@ describe('Response', () => { describe('created()', () => { - it('returns a stream response (created)', async () => { + it('returns a response (created)', async () => { const handler = (request, h) => { @@ -243,6 +262,36 @@ describe('Response', () => { const res = await server.inject('/'); expect(res.statusCode).to.equal(500); }); + + it('does not return an error on created with PUT', async () => { + + const handler = (request, h) => { + + return h.response({ a: 1 }).created(); + }; + + const server = Hapi.server(); + server.route({ method: 'PUT', path: '/', handler }); + + const res = await server.inject({ method: 'PUT', url: '/' }); + expect(res.result).to.equal({ a: 1 }); + expect(res.statusCode).to.equal(201); + }); + + it('does not return an error on created with PATCH', async () => { + + const handler = (request, h) => { + + return h.response({ a: 1 }).created(); + }; + + const server = Hapi.server(); + server.route({ method: 'PATCH', path: '/', handler }); + + const res = await server.inject({ method: 'PATCH', url: '/' }); + expect(res.result).to.equal({ a: 1 }); + expect(res.statusCode).to.equal(201); + }); }); describe('state()', () => { @@ -278,7 +327,7 @@ describe('Response', () => { server.route({ method: 'GET', path: '/', handler }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); expect(res.headers['set-cookie']).to.equal(['session=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly; SameSite=Strict; Path=/unset']); }); }); @@ -473,7 +522,7 @@ describe('Response', () => { super(); this.statusCode = 299; - this.headers = { xcustom: 'some value' }; + this.headers = { xcustom: 'some value', 'content-type': 'something/special' }; } _read(size) { @@ -481,6 +530,7 @@ describe('Response', () => { if (this.isDone) { return; } + this.isDone = true; this.push('x'); @@ -500,6 +550,7 @@ describe('Response', () => { expect(res.result).to.equal('x'); expect(res.statusCode).to.equal(299); expect(res.headers.xcustom).to.equal('some value'); + expect(res.headers['content-type']).to.equal('something/special'); }); it('excludes connection header and connection options', async () => { @@ -524,6 +575,7 @@ describe('Response', () => { if (this.isDone) { return; } + this.isDone = true; this.push('x'); @@ -563,6 +615,7 @@ describe('Response', () => { if (this.isDone) { return; } + this.isDone = true; this.push('x'); @@ -600,6 +653,7 @@ describe('Response', () => { if (this.isDone) { return; } + this.isDone = true; this.push('x'); @@ -636,6 +690,7 @@ describe('Response', () => { if (this.isDone) { return; } + this.isDone = true; this.push('x'); @@ -674,6 +729,35 @@ describe('Response', () => { }); }); + describe('compressed()', () => { + + it('errors on missing encoding', async () => { + + const handler = (request, h) => { + + return h.response('x').compressed(); + }; + + const server = Hapi.server({ debug: false }); + server.route({ method: 'GET', path: '/', handler }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + + it('errors on invalid encoding', async () => { + + const handler = (request, h) => { + + return h.response('x').compressed(123); + }; + + const server = Hapi.server({ debug: false }); + server.route({ method: 'GET', path: '/', handler }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + }); + describe('spaces()', () => { it('errors when called on wrong type', async () => { @@ -743,7 +827,7 @@ describe('Response', () => { await server.register(Inert); const handler = (request, h) => { - return h.file('./LICENSE').type('application/example'); + return h.file('./LICENSE.md').type('application/example'); }; server.route({ method: 'GET', path: '/file', handler }); @@ -912,7 +996,7 @@ describe('Response', () => { expect(res.statusCode).to.equal(308); }); - it('returns a 301 redirection response (reveresed methods)', async () => { + it('returns a 301 redirection response (reversed methods)', async () => { const handler = (request, h) => { @@ -926,7 +1010,7 @@ describe('Response', () => { expect(res.statusCode).to.equal(301); }); - it('returns a 302 redirection response (reveresed methods)', async () => { + it('returns a 302 redirection response (reversed methods)', async () => { const handler = (request, h) => { @@ -940,7 +1024,7 @@ describe('Response', () => { expect(res.statusCode).to.equal(302); }); - it('returns a 307 redirection response (reveresed methods)', async () => { + it('returns a 307 redirection response (reversed methods)', async () => { const handler = (request, h) => { @@ -954,7 +1038,7 @@ describe('Response', () => { expect(res.statusCode).to.equal(307); }); - it('returns a 308 redirection response (reveresed methods)', async () => { + it('returns a 308 redirection response (reversed methods)', async () => { const handler = (request, h) => { @@ -1008,7 +1092,21 @@ describe('Response', () => { expect(event.error.message).to.contain('The partial x could not be found: The partial x could not be found'); }); - it('returns a formatted response', async () => { + it('returns a formatted response (spaces)', async () => { + + const handler = (request) => { + + return { a: 1, b: 2, '<': '&' }; + }; + + const server = Hapi.server({ routes: { json: { space: 4, suffix: '\n', escape: true } } }); + server.route({ method: 'GET', path: '/', handler }); + + const res = await server.inject('/'); + expect(res.payload).to.equal('{\n \"a\": 1,\n \"b\": 2,\n \"\\u003c\": \"\\u0026\"\n}\n'); + }); + + it('returns a formatted response (replacer and spaces', async () => { const handler = (request) => { @@ -1090,43 +1188,74 @@ describe('Response', () => { server.route({ method: 'GET', path: '/stream', handler: streamHandler }); server.route({ method: 'GET', path: '/writable', handler: writableHandler }); - let updates = 0; - server.events.on({ name: 'request', channels: 'error' }, (request, event) => { - - expect(event.error).to.be.an.error('Stream must have a streams2 readable interface'); - ++updates; - }); - await server.initialize(); - const res1 = await server.inject('/stream'); + const log1 = server.events.once({ name: 'request', channels: 'error' }); + const res1 = await server.inject('/stream'); expect(res1.statusCode).to.equal(500); - const res2 = await server.inject('/writable'); + const [, event1] = await log1; + expect(event1.error).to.be.an.error('Cannot reply with a stream-like object that is not an instance of Stream.Readable'); + + const log2 = server.events.once({ name: 'request', channels: 'error' }); + const res2 = await server.inject('/writable'); expect(res2.statusCode).to.equal(500); - await Hoek.wait(10); - expect(updates).to.equal(2); + + const [, event2] = await log2; + expect(event2.error).to.be.an.error('Cannot reply with a stream-like object that is not an instance of Stream.Readable'); }); it('errors on an http client stream response', async () => { - const handler = (request, h) => { + const streamHandler = (request, h) => { - return h.response('just a string'); + const req = Http.get(request.server.info.uri); + req.abort(); + return h.response(req); }; - const streamHandler = (request, h) => { + const server = Hapi.server({ debug: false }); + server.route({ method: 'GET', path: '/stream', handler: streamHandler }); + + const log = server.events.once({ name: 'request', channels: 'error' }); - return h.response(Http.get(request.server.info + '/')); + await server.initialize(); + const res = await server.inject('/stream'); + expect(res.statusCode).to.equal(500); + + const [, event] = await log; + expect(event.error).to.be.an.error('Cannot reply with a stream-like object that is not an instance of Stream.Readable'); + }); + + it('errors on a legacy readable stream response', async () => { + + const streamHandler = () => { + + const stream = new LegacyReadableStream.Readable(); + stream._read = function (size) { + + const chunk = new Array(size).join('x'); + + setTimeout(() => { + + this.push(chunk); + }, 10); + }; + + return stream; }; const server = Hapi.server({ debug: false }); - server.route({ method: 'GET', path: '/', handler }); server.route({ method: 'GET', path: '/stream', handler: streamHandler }); + const log = server.events.once({ name: 'request', channels: 'error' }); + await server.initialize(); const res = await server.inject('/stream'); expect(res.statusCode).to.equal(500); + + const [, event] = await log; + expect(event.error).to.be.an.error('Cannot reply with a stream-like object that is not an instance of Stream.Readable'); }); it('errors on objectMode stream response', async () => { @@ -1143,6 +1272,7 @@ describe('Response', () => { if (this.isDone) { return; } + this.isDone = true; this.push({ x: 1 }); @@ -1159,8 +1289,13 @@ describe('Response', () => { const server = Hapi.server({ debug: false }); server.route({ method: 'GET', path: '/', handler }); + const log = server.events.once({ name: 'request', channels: 'error' }); + const res = await server.inject('/'); expect(res.statusCode).to.equal(500); + + const [, event] = await log; + expect(event.error).to.be.an.error('Cannot reply with stream in object mode'); }); }); @@ -1187,6 +1322,37 @@ describe('Response', () => { const res = await server.inject('/'); expect(res.statusCode).to.equal(500); }); + + it('is only called once for returned responses', async () => { + + let calls = 0; + const pre = (request, h) => { + + const prepare = (response) => { + + ++calls; + return response; + }; + + return request.generateResponse(null, { prepare }); + }; + + const server = Hapi.server(); + server.route({ + method: 'GET', + path: '/', + options: { + pre: [ + { method: pre, assign: 'p' } + ], + handler: (request) => request.preResponses.p + } + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(204); + expect(calls).to.equal(1); + }); }); describe('_tap()', () => { @@ -1221,6 +1387,31 @@ describe('Response', () => { expect(output).to.equal('1234567890!'); }); + it('peeks into the response stream (finish only)', async () => { + + const server = Hapi.server(); + + let output = false; + server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + + const response = h.response('1234567890'); + + response.events.once('finish', () => { + + output = true; + }); + + return response; + } + }); + + await server.inject('/'); + expect(output).to.be.true(); + }); + it('peeks into the response stream (empty)', async () => { const server = Hapi.server(); @@ -1394,16 +1585,4 @@ describe('Response', () => { await finish; }); }); - - describe('Payload', () => { - - it('streams empty string', async () => { - - const server = Hapi.server({ compression: { minBytes: 1 } }); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => '' } }); - - const res = await server.inject({ url: '/?callback=me', headers: { 'Accept-Encoding': 'gzip' } }); - expect(res.statusCode).to.equal(200); - }); - }); }); diff --git a/test/route.js b/test/route.js index d691bb5fb..e8dae012b 100755 --- a/test/route.js +++ b/test/route.js @@ -1,23 +1,18 @@ 'use strict'; -// Load modules - const Path = require('path'); -const Code = require('code'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Inert = require('inert'); +const Inert = require('@hapi/inert'); const Joi = require('joi'); -const Lab = require('lab'); - +const Lab = require('@hapi/lab'); +const Subtext = require('@hapi/subtext'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -37,7 +32,7 @@ describe('Route', () => { const a = this.a; return { - handler: () => (a + srv.app.b) + handler: () => a + srv.app.b }; } }); @@ -96,7 +91,7 @@ describe('Route', () => { const server = Hapi.server(); server.route({ method: 'HEAD', path: '/', handler: () => null }); - }).to.throw(/Method name not allowed/); + }).to.throw('Cannot set HEAD route: /'); }); it('throws an error when a route is missing a handler', () => { @@ -105,7 +100,7 @@ describe('Route', () => { const server = Hapi.server(); server.route({ path: '/test', method: 'put' }); - }).to.throw('Missing or undefined handler: put /test'); + }).to.throw('Missing or undefined handler: PUT /test'); }); it('throws when handler is missing in config', () => { @@ -149,7 +144,7 @@ describe('Route', () => { const server = Hapi.server(); expect(() => { - server.route({ method: 'POST', path: '/', handler: () => null, options: { validate: { payload: {} }, payload: { parse: false } } }); + server.route({ method: 'POST', path: '/', handler: () => null, options: { validate: { payload: {}, validator: Joi }, payload: { parse: false } } }); }).to.throw('Route payload must be set to \'parse\' when payload validation enabled: POST /'); }); @@ -162,6 +157,26 @@ describe('Route', () => { }).to.throw('Cannot set path parameters validations without path parameters: POST /'); }); + it('ignores payload when overridden', async () => { + + const server = Hapi.server(); + server.route({ + method: 'POST', + path: '/', + handler: (request) => request.payload + }); + + server.ext('onRequest', (request, h) => { + + request.payload = 'x'; + return h.continue; + }); + + const res = await server.inject({ method: 'POST', url: '/', payload: 'y' }); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal('x'); + }); + it('ignores payload parsing errors', async () => { const server = Hapi.server(); @@ -208,7 +223,7 @@ describe('Route', () => { expect(res.statusCode).to.equal(200); expect(logged).to.be.an.object(); expect(logged.error).to.be.an.error('Invalid request payload JSON format'); - expect(logged.error.data).to.be.an.error(SyntaxError, /^Unexpected token a/); + expect(logged.error.data).to.be.an.error(SyntaxError, /at position 1/); }); it('returns payload parsing errors', async () => { @@ -260,7 +275,7 @@ describe('Route', () => { expect(() => { server.route({ method: 'GET', path: '/', handler: () => null, options: { validate: { payload: {} } } }); - }).to.throw('Cannot validate HEAD or GET requests: GET /'); + }).to.throw('Cannot validate HEAD or GET request payload: GET /'); }); it('throws when payload parsing is set on GET', () => { @@ -275,17 +290,64 @@ describe('Route', () => { it('ignores validation on * route when request is GET', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: '*', path: '/', handler: () => null, options: { validate: { payload: { a: Joi.required() } } } }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); + }); + + it('ignores validation on * route when request is HEAD', async () => { + + const server = Hapi.server(); + server.validator(Joi); + server.route({ method: '*', path: '/', handler: () => null, options: { validate: { payload: { a: Joi.required() } } } }); + const res = await server.inject({ url: '/', method: 'HEAD' }); + expect(res.statusCode).to.equal(204); + }); + + it('skips payload on * route when request is HEAD', async (flags) => { + + const orig = Subtext.parse; + let called = false; + Subtext.parse = () => { + + called = true; + }; + + flags.onCleanup = () => { + + Subtext.parse = orig; + }; + + const server = Hapi.server(); + server.route({ method: '*', path: '/', handler: () => null }); + const res = await server.inject({ url: '/', method: 'HEAD' }); + expect(res.statusCode).to.equal(204); + expect(called).to.be.false(); + }); + + it('throws error when the default routes payload validation is set without payload parsing', () => { + + expect(() => { + + Hapi.server({ routes: { validate: { payload: {}, validator: Joi }, payload: { parse: false } } }); + }).to.throw('Route payload must be set to \'parse\' when payload validation enabled'); + }); + + it('throws error when the default routes state validation is set without state parsing', () => { + + expect(() => { + + Hapi.server({ routes: { validate: { state: {}, validator: Joi }, state: { parse: false } } }); + }).to.throw('Route state must be set to \'parse\' when state validation enabled'); }); it('ignores default validation on GET', async () => { - const server = Hapi.server({ routes: { validate: { payload: { a: Joi.required() } } } }); + const server = Hapi.server({ routes: { validate: { payload: { a: Joi.required() }, validator: Joi } } }); server.route({ method: 'GET', path: '/', handler: () => null }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); }); it('shallow copies route config bind', async () => { @@ -404,20 +466,36 @@ describe('Route', () => { expect(res.payload).to.contain('hapi'); }); - it('throws when server timeout is more then socket timeout', () => { + it('allows payload timeout more then socket timeout', () => { + + expect(() => { + + Hapi.server({ routes: { payload: { timeout: 60000 }, timeout: { socket: 12000 } } }); + }).to.not.throw(); + }); + + it('allows payload timeout more then socket timeout (node default)', () => { + + expect(() => { + + Hapi.server({ routes: { payload: { timeout: 6000000 } } }); + }).to.not.throw(); + }); + + it('allows server timeout more then socket timeout', () => { expect(() => { Hapi.server({ routes: { timeout: { server: 60000, socket: 12000 } } }); - }).to.throw('Server timeout must be shorter than socket timeout: _special /{p*}'); + }).to.not.throw(); }); - it('throws when server timeout is more then socket timeout (node default)', () => { + it('allows server timeout more then socket timeout (node default)', () => { expect(() => { Hapi.server({ routes: { timeout: { server: 6000000 } } }); - }).to.throw('Server timeout must be shorter than socket timeout: _special /{p*}'); + }).to.not.throw(); }); it('ignores large server timeout when socket timeout disabled', () => { @@ -706,6 +784,7 @@ describe('Route', () => { it('compiles rules into config', async () => { const server = Hapi.server(); + server.validator(Joi); const processor = (rules) => { @@ -718,16 +797,16 @@ describe('Route', () => { server.rules(processor); - server.route({ path: '/1', method: 'GET', handler: () => null, rules: { x: 1 } }); - server.route({ path: '/2', method: 'GET', handler: () => null, rules: { x: 2 } }); + server.route({ path: '/1', method: 'GET', handler: () => null, rules: { x: Joi.number().valid(1) } }); + server.route({ path: '/2', method: 'GET', handler: () => null, rules: { x: Joi.number().valid(2) } }); server.route({ path: '/3', method: 'GET', handler: () => null }); - expect((await server.inject('/1?x=1')).statusCode).to.equal(200); + expect((await server.inject('/1?x=1')).statusCode).to.equal(204); expect((await server.inject('/1?x=2')).statusCode).to.equal(400); expect((await server.inject('/2?x=1')).statusCode).to.equal(400); - expect((await server.inject('/2?x=2')).statusCode).to.equal(200); - expect((await server.inject('/3?x=1')).statusCode).to.equal(200); - expect((await server.inject('/3?x=2')).statusCode).to.equal(200); + expect((await server.inject('/2?x=2')).statusCode).to.equal(204); + expect((await server.inject('/3?x=1')).statusCode).to.equal(204); + expect((await server.inject('/3?x=2')).statusCode).to.equal(204); }); it('compiles rules into config (route info)', async () => { @@ -749,6 +828,7 @@ describe('Route', () => { it('compiles rules into config (validate)', () => { const server = Hapi.server(); + server.validator(Joi); const processor = (rules) => { @@ -764,6 +844,7 @@ describe('Route', () => { it('compiles rules into config (validate + options)', () => { const server = Hapi.server(); + server.validator(Joi); const processor = (rules) => { diff --git a/test/security.js b/test/security.js index 8bed0729f..afc533105 100755 --- a/test/security.js +++ b/test/security.js @@ -1,25 +1,28 @@ 'use strict'; -// Load modules - -const Code = require('code'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Lab = require('lab'); - +const Lab = require('@hapi/lab'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('security', () => { + it('handles missing routes', async () => { + + const server = Hapi.server({ port: 8080, routes: { security: { xframe: true } } }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(404); + expect(res.headers['x-frame-options']).to.exist(); + }); + it('blocks response splitting through the request.create method', async () => { const server = Hapi.server(); diff --git a/test/server.js b/test/server.js index 8b498d21d..94e95fc0a 100755 --- a/test/server.js +++ b/test/server.js @@ -1,2540 +1,3012 @@ 'use strict'; -// Load modules - const Path = require('path'); const Zlib = require('zlib'); -const Boom = require('boom'); -const CatboxMemory = require('catbox-memory'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const { Engine: CatboxMemory } = require('@hapi/catbox-memory'); +const Code = require('@hapi/code'); const Handlebars = require('handlebars'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Inert = require('inert'); -const Lab = require('lab'); -const Vision = require('vision'); -const Wreck = require('wreck'); +const Hoek = require('@hapi/hoek'); +const Inert = require('@hapi/inert'); +const Lab = require('@hapi/lab'); +const Vision = require('@hapi/vision'); +const Wreck = require('@hapi/wreck'); +const Pkg = require('../package.json'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('Server', () => { - describe('register()', () => { + describe('auth', () => { - it('registers plugin with options', async () => { + it('adds auth strategy via plugin', async () => { const server = Hapi.server(); + server.route({ + method: 'GET', + path: '/', + handler: () => 'authenticated!' + }); - const test = { - name: 'test', - - register: function (srv, options) { + await server.register(internals.plugins.auth); - expect(options.something).to.be.true(); - expect(srv.realm.pluginOptions).to.equal(options); - } - }; + const res1 = await server.inject('/'); + expect(res1.statusCode).to.equal(401); - await server.register({ plugin: test, options: { something: true } }); + const res2 = await server.inject({ method: 'GET', url: '/', headers: { authorization: 'Basic ' + (Buffer.from('john:12345', 'utf8')).toString('base64') } }); + expect(res2.statusCode).to.equal(200); + expect(res2.result).to.equal('authenticated!'); }); + }); - it('registers a required plugin', async () => { + describe('bind()', () => { - const server = Hapi.server(); + it('sets plugin context', async () => { const test = { - plugin: { - name: 'test', - register: function (srv, options) { + name: 'test', + register: function (srv, options) { - expect(options.something).to.be.true(); - } - } - }; + const bind = { + value: 'in context', + suffix: ' throughout' + }; - await server.register({ plugin: test, options: { something: true } }); - }); + srv.bind(bind); - it('rejects on bad plugin (missing name)', async () => { + srv.route({ + method: 'GET', + path: '/', + handler: function () { - const plugin = { - register: Hoek.ignore - }; + return this.value; + } + }); - const server = Hapi.server(); - await expect(server.register(plugin)).to.reject(); - }); + const preResponse = function (request, h) { - it('rejects on bad plugin (empty pkg)', async () => { + return request.response.source + this.suffix; + }; - const plugin = { - pkg: {}, - register: Hoek.ignore + srv.ext('onPreResponse', preResponse); + } }; const server = Hapi.server(); - await expect(server.register(plugin)).to.reject(); + await server.register(test); + + const res = await server.inject('/'); + expect(res.result).to.equal('in context throughout'); }); + }); - it('returns plugin error', async () => { + describe('cache()', () => { - const test = { - name: 'test', - register: function (srv, options) { + it('provisions a server cache', async () => { - throw new Error('from plugin'); - } - }; + const server = Hapi.server(); + const cache = server.cache({ segment: 'test', expiresIn: 1000 }); + await server.initialize(); + + await cache.set('a', 'going in', 0); + const value = await cache.get('a'); + expect(value).to.equal('going in'); + }); + + it('throws when missing segment', () => { const server = Hapi.server(); - await expect(server.register(test)).to.reject('from plugin'); + expect(() => { + + server.cache({ expiresIn: 1000 }); + }).to.throw('Missing cache segment name'); }); - it('sets version to 0.0.0 if missing', async () => { + it('provisions a server cache with custom partition', async () => { - const test = { - pkg: { - name: 'steve' - }, - register: function (srv, options) { + const server = Hapi.server({ cache: { provider: { constructor: CatboxMemory, options: { partition: 'hapi-test-other' } } } }); + const cache = server.cache({ segment: 'test', expiresIn: 1000 }); + await server.initialize(); - srv.route({ - method: 'GET', - path: '/', - handler: () => srv.version - }); - } - }; + await cache.set('a', 'going in', 0); + const value = await cache.get('a'); + expect(value).to.equal('going in'); + expect(cache._cache.connection.settings.partition).to.equal('hapi-test-other'); + }); + + it('throws when allocating an invalid cache segment', () => { const server = Hapi.server(); - await server.register(test); - expect(server.registrations.steve.version).to.equal('0.0.0'); + expect(() => { - const res = await server.inject('/'); - expect(res.result).to.equal(require('../package.json').version); + server.cache({ segment: 'a', expiresAt: '12:00', expiresIn: 1000 }); + }).throws(); }); - it('exposes plugin registration information', async () => { + it('allows allocating a cache segment with empty options', () => { - const test = { - multiple: true, - pkg: { - name: 'bob', - version: '1.2.3' - }, - register: function (srv, options) { + const server = Hapi.server(); + expect(() => { - srv.route({ method: 'GET', path: '/', handler: () => srv.version }); - } - }; + server.cache({ segment: 'a' }); + }).to.not.throw(); + }); - const server = Hapi.server(); + it('allows reusing the same cache segment (server)', () => { - await server.register({ plugin: test, options: { foo: 'bar' } }); - const bob = server.registrations.bob; - expect(bob).to.exist(); - expect(bob).to.be.an.object(); - expect(bob.version).to.equal('1.2.3'); - expect(bob.options.foo).to.equal('bar'); + const server = Hapi.server({ cache: { provider: CatboxMemory, shared: true } }); + expect(() => { - const res = await server.inject('/'); - expect(res.result).to.equal(require('../package.json').version); + server.cache({ segment: 'a', expiresIn: 1000 }); + server.cache({ segment: 'a', expiresIn: 1000 }); + }).to.not.throw(); }); - it('prevents plugin from multiple registrations', async () => { - - const test = { - name: 'test', - register: function (srv, options) { + it('allows reusing the same cache segment (cache)', () => { - srv.route({ method: 'GET', path: '/a', handler: () => 'a' }); - } - }; + const server = Hapi.server(); + expect(() => { - const server = Hapi.server({ host: 'example.com' }); - await server.register(test); - await expect(server.register(test)).to.reject('Plugin test already registered'); + server.cache({ segment: 'a', expiresIn: 1000 }); + server.cache({ segment: 'a', expiresIn: 1000, shared: true }); + }).to.not.throw(); }); - it('allows plugin multiple registrations (plugin)', async () => { + it('uses plugin cache interface', async () => { const test = { name: 'test', - multiple: true, register: function (srv, options) { - srv.app.x = srv.app.x ? srv.app.x + 1 : 1; + const cache = srv.cache({ expiresIn: 50 }); + srv.expose({ + get: function (key) { + + return cache.get(key); + }, + set: function (key, value) { + + return cache.set(key, value, 0); + } + }); } }; const server = Hapi.server(); await server.register(test); - await server.register(test); - expect(server.app.x).to.equal(2); + await server.initialize(); + + await server.plugins.test.set('a', '1'); + const value1 = await server.plugins.test.get('a'); + expect(value1).to.equal('1'); + + await Hoek.wait(600); + const value2 = await server.plugins.test.get('a'); + expect(value2).to.equal(null); }); - it('registers multiple plugins', async () => { + it('emits a cache policy event with default cache provision', async () => { const server = Hapi.server(); - let log = null; - server.events.once('log', (event, tags) => { + const cachePolicyEvent = server.events.once('cachePolicy'); - log = [event, tags]; - }); + const cache = server.cache({ segment: 'test', expiresIn: 1000 }); - await server.register([internals.plugins.test1, internals.plugins.test2]); - expect(internals.routesList(server)).to.equal(['/test1', '/test2']); - expect(log[1].test).to.equal(true); - expect(log[0].data).to.equal('abc'); + const [policy, cacheName, segment] = await cachePolicyEvent; + expect(policy).to.shallow.equal(cache); + expect(cacheName).to.equal(undefined); + expect(segment).to.equal('test'); }); - it('registers multiple plugins (verbose)', async () => { + it('emits a cache policy event with named cache provision', async () => { const server = Hapi.server(); - let log = null; - server.events.once('log', (event, tags) => { + await server.cache.provision({ provider: CatboxMemory, name: 'named' }); + const cachePolicyEvent = server.events.once('cachePolicy'); - log = [event, tags]; - }); + const cache = server.cache({ cache: 'named', segment: 'test', expiresIn: 1000 }); - await server.register([{ plugin: internals.plugins.test1 }, { plugin: internals.plugins.test2 }]); - expect(internals.routesList(server)).to.equal(['/test1', '/test2']); - expect(log[1].test).to.equal(true); - expect(log[0].data).to.equal('abc'); + const [policy, cacheName, segment] = await cachePolicyEvent; + expect(policy).to.shallow.equal(cache); + expect(cacheName).to.equal('named'); + expect(segment).to.equal('test'); }); + }); - it('registers a child plugin', async () => { + describe('cache.provision()', () => { + + it('provisions a server cache (before initialization)', async () => { const server = Hapi.server(); - await server.register(internals.plugins.child); - const res = await server.inject('/test1'); - expect(res.result).to.equal('testing123'); + await server.cache.provision({ provider: CatboxMemory, name: 'dynamic' }); + const cache = server.cache({ cache: 'dynamic', segment: 'test', expiresIn: 1000 }); + + await expect(cache.set('a', 'going in', 0)).to.reject(); + await server.initialize(); + + await cache.set('a', 'going in', 0); + const value = await cache.get('a'); + expect(value).to.equal('going in'); }); - it('registers a plugin with routes path prefix', async () => { + it('provisions a server cache (after initialization)', async () => { const server = Hapi.server(); - await server.register(internals.plugins.test1, { routes: { prefix: '/xyz' } }); - expect(server.plugins.test1.prefix).to.equal('/xyz'); - const res = await server.inject('/xyz/test1'); - expect(res.result).to.equal('testing123'); + await server.initialize(); + await server.cache.provision({ provider: CatboxMemory, name: 'dynamic' }); + const cache = server.cache({ cache: 'dynamic', segment: 'test', expiresIn: 1000 }); + + await cache.set('a', 'going in', 0); + const value = await cache.get('a'); + expect(value).to.equal('going in'); }); - it('registers a plugin with routes path prefix (plugin options)', async () => { + it('provisions a server cache (promise)', async () => { const server = Hapi.server(); - await server.register({ plugin: internals.plugins.test1, routes: { prefix: '/abc' } }, { routes: { prefix: '/xyz' } }); + await server.initialize(); + await server.cache.provision({ provider: CatboxMemory, name: 'dynamic' }); + const cache = server.cache({ cache: 'dynamic', segment: 'test', expiresIn: 1000 }); - expect(server.plugins.test1.prefix).to.equal('/abc'); - const res = await server.inject('/abc/test1'); - expect(res.result).to.equal('testing123'); + await cache.set('a', 'going in', 0); + const value = await cache.get('a'); + expect(value).to.equal('going in'); }); + }); - it('register a plugin once (plugin options)', async () => { - - let count = 0; - const b = { - name: 'b', - register: function (srv, options) { + describe('control()', () => { - ++count; - } - }; + it('controls the phase of the controlled servers', async () => { - const a = { - name: 'a', - register: async function (srv, options) { + const server = Hapi.server(); + const controlled1 = Hapi.server(); + const controlled2 = Hapi.server(); - await srv.register({ plugin: b, once: true }); - } - }; + server.control(controlled1); + server.control(controlled2); - const server = Hapi.server(); - await server.register(b); - await server.register(a); await server.initialize(); - expect(count).to.equal(1); - }); + expect(server._core.phase).to.equal('initialized'); + expect(controlled1._core.phase).to.equal('initialized'); + expect(controlled2._core.phase).to.equal('initialized'); - it('registers plugins and adds options to realm that routes can access', async () => { + await server.start(); + expect(server._core.phase).to.equal('started'); + expect(controlled1._core.phase).to.equal('started'); + expect(controlled2._core.phase).to.equal('started'); - const server = Hapi.server(); + await server.stop(); + expect(server._core.phase).to.equal('stopped'); + expect(controlled1._core.phase).to.equal('stopped'); + expect(controlled2._core.phase).to.equal('stopped'); + }); + }); - const foo = { - name: 'foo', - register: function (srv, options) { + describe('decorate()', () => { - expect(options.something).to.be.true(); - expect(srv.realm.pluginOptions).to.equal(options); + it('decorates request with function', async () => { - srv.route({ - method: 'GET', path: '/foo', handler: (request, h) => { + const server = Hapi.server(); - expect(request.route.realm.pluginOptions).to.equal(options); - expect(h.realm.pluginOptions).to.equal(options); - return 'foo'; - } - }); - } + const getId = function () { + + return this.info.id; }; - const bar = { - name: 'bar', - register: function (srv, options) { + server.decorate('request', 'getId', getId); - expect(options.something).to.be.false(); - expect(srv.realm.pluginOptions).to.equal(options); + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.getId() + }); - srv.route({ - method: 'GET', path: '/bar', handler: (request, h) => { + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.match(/^.*\:.*\:.*\:.*\:.*$/); + }); - expect(request.route.realm.pluginOptions).to.equal(options); - expect(h.realm.pluginOptions).to.equal(options); - return 'bar'; - } - }); - } - }; + it('decorates request with object', async () => { - const plugins = [ - { plugin: foo, options: { something: true } }, - { plugin: bar, options: { something: false } } - ]; + const server = Hapi.server(); - await server.register(plugins); + const customData = { id: '123' }; - const res1 = await server.inject('/foo'); - expect(res1.result).to.equal('foo'); + server.decorate('request', 'customData', customData); - const res2 = await server.inject('/bar'); - expect(res2.result).to.equal('bar'); + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.customData + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal({ id: '123' }); }); - it('registers a plugin with routes path prefix and plugin root route', async () => { + it('decorates request (apply)', async () => { - const test = { - name: 'test', - register: function (srv, options) { + const server = Hapi.server(); - srv.route({ - method: 'GET', - path: '/', - handler: () => 'ok' - }); - } - }; + server.decorate('request', 'uri', (request) => request.server.info.uri, { apply: true }); + server.decorate('request', 'type', (request) => request.server.type, { apply: true }); - const server = Hapi.server(); - await server.register(test, { routes: { prefix: '/xyz' } }); + server.route({ + method: 'GET', + path: '/', + handler: (request) => (request.uri + ':' + request.type) + }); - const res = await server.inject('/xyz'); - expect(res.result).to.equal('ok'); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal(server.info.uri + ':tcp'); }); - it('ignores the type of the plugin value', async () => { + it('decorates request (extend)', async () => { - const a = function () { }; - a.plugin = { - name: 'a', - register: function (srv, options) { + const server = Hapi.server(); - srv.route({ - method: 'GET', - path: '/', - handler: () => 'ok' - }); - } - }; + const getId = function () { - const server = Hapi.server(); - await server.register(a, { routes: { prefix: '/xyz' } }); + return this.info.id; + }; - const res = await server.inject('/xyz'); - expect(res.result).to.equal('ok'); - }); + server.decorate('request', 'getId', getId); - it('ignores unknown plugin properties', async () => { + const getIdExtended = function (existing) { - const a = { - name: 'a', - register: function (srv, options) { + return function () { - srv.route({ - method: 'GET', - path: '/', - handler: () => 'ok' - }); - }, - other: {} + return existing.call(this) + '!'; + }; }; - const server = Hapi.server(); - await server.register(a); - }); + server.decorate('request', 'getId', getIdExtended, { extend: true }); - it('ignores unknown plugin properties (with options)', async () => { + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.getId() + }); - const a = { - name: 'a', - register: function (srv, options) { + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.match(/^.*\:.*\:.*\:.*\:.*!$/); + }); - srv.route({ - method: 'GET', - path: '/', - handler: () => 'ok' - }); - }, - other: {} - }; + it('decorates request (extend) with an array', async () => { const server = Hapi.server(); - await server.register({ plugin: a }); - }); - it('registers a child plugin with parent routes path prefix', async () => { + const items = ['one', 'two', 'three']; - const server = Hapi.server(); - await server.register(internals.plugins.child, { routes: { prefix: '/xyz' } }); + server.decorate('request', 'items', items); + server.decorate('request', 'items', (existing) => [...existing, 'four'], { extend: true }); - const res = await server.inject('/xyz/test1'); - expect(res.result).to.equal('testing123'); + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.items + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal([...items, 'four']); }); - it('registers a child plugin with parent routes vhost prefix', async () => { + it('decorates request (apply + extend)', async () => { const server = Hapi.server(); - await server.register(internals.plugins.child, { routes: { vhost: 'example.com' } }); - const res = await server.inject({ url: '/test1', headers: { host: 'example.com' } }); - expect(res.result).to.equal('testing123'); - }); + server.decorate('request', 'uri', (request) => request.server.info.uri, { apply: true }); - it('registers a child plugin with parent routes path prefix and inner register prefix', async () => { + const extended = function (existing) { - const server = Hapi.server(); - await server.register({ plugin: internals.plugins.child, options: { routes: { prefix: '/inner' } } }, { routes: { prefix: '/xyz' } }); + return function (request) { - const res = await server.inject('/xyz/inner/test1'); - expect(res.result).to.equal('testing123'); - }); + const base = existing(request); + return base + '!'; + }; + }; - it('registers a child plugin with parent routes vhost prefix and inner register vhost', async () => { + server.decorate('request', 'uri', extended, { apply: true, extend: true }); - const server = Hapi.server(); - await server.register({ plugin: internals.plugins.child, options: { routes: { vhost: 'example.net' } } }, { routes: { vhost: 'example.com' } }); + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.uri + }); - const res = await server.inject({ url: '/test1', headers: { host: 'example.com' } }); - expect(res.result).to.equal('testing123'); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal(server.info.uri + '!'); }); - it('registers a plugin with routes vhost', async () => { + it('decorates response', async () => { const server = Hapi.server(); - await server.register(internals.plugins.test1, { routes: { vhost: 'example.com' } }); - const res1 = await server.inject('/test1'); - expect(res1.statusCode).to.equal(404); + const custom = function () { - const res2 = await server.inject({ url: '/test1', headers: { host: 'example.com' } }); - expect(res2.result).to.equal('testing123'); - }); + return this.header('custom', 'test'); + }; - it('registers a plugin with routes vhost (plugin options)', async () => { + server.decorate('response', 'custom', custom); - const server = Hapi.server(); - await server.register({ plugin: internals.plugins.test1, routes: { vhost: 'example.org' } }, { routes: { vhost: 'example.com' } }); + server.ext('onPreResponse', (request, h) => { - const res1 = await server.inject('/test1'); - expect(res1.statusCode).to.equal(404); + request.response.custom(); + return h.continue; + }); - const res2 = await server.inject({ url: '/test1', headers: { host: 'example.org' } }); - expect(res2.result).to.equal('testing123'); + server.route({ + method: 'GET', + path: '/', + handler: () => null + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(204); + expect(res.headers.custom).to.equal('test'); }); - it('sets multiple dependencies in one statement', async () => { + it('decorates toolkit', async () => { - const a = { - name: 'a', - register: function (srv, options) { + const server = Hapi.server(); - srv.dependency(['b', 'c']); - } - }; + const success = function () { - const b = { - name: 'b', - register: Hoek.ignore + return this.response({ status: 'ok' }); }; - const c = { - name: 'c', - register: Hoek.ignore - }; + server.decorate('toolkit', 'success', success); - const server = Hapi.server(); - await server.register(b); - await server.register(c); - await server.register(a); - await server.initialize(); + server.route({ + method: 'GET', + path: '/', + handler: (request, h) => h.success() + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result.status).to.equal('ok'); }); - it('sets multiple dependencies in plugin', async () => { + it('decorates toolkit with boolean', async () => { - const a = { - name: 'a', - dependencies: ['b', 'c'], - register: Hoek.ignore - }; + const server = Hapi.server(); - const b = { - name: 'b', - register: Hoek.ignore - }; + const isOk = true; - const c = { - name: 'c', - register: Hoek.ignore - }; + server.decorate('toolkit', 'isOk', isOk); - const server = Hapi.server(); - await server.register(b); - await server.register(c); - await server.register(a); - await server.initialize(); + server.route({ + method: 'GET', + path: '/', + handler: (request, h) => h.isOk + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal(true); }); - it('sets multiple dependencies in multiple statements', async () => { + it('add new handler', async () => { - const a = { - name: 'a', - register: function (srv, options) { + const test = { + name: 'test', + register: function (srv, options1) { - srv.dependency('b'); - srv.dependency('c'); - } - }; + const handler = function (route, options2) { - const b = { - name: 'b', - register: Hoek.ignore - }; + return (request) => 'success'; + }; - const c = { - name: 'c', - register: Hoek.ignore + srv.decorate('handler', 'bar', handler); + } }; const server = Hapi.server(); - await server.register(b); - await server.register(c); - await server.register(a); - await server.initialize(); + await server.register(test); + + server.route({ + method: 'GET', + path: '/', + handler: { + bar: {} + } + }); + + const res = await server.inject('/'); + expect(res.payload).to.equal('success'); }); - it('sets multiple dependencies in multiple locations', async () => { + it('errors on duplicate handler', async () => { - const a = { - name: 'a', - dependencies: 'c', - register: function (srv, options) { + const server = Hapi.server(); + await server.register(Inert); - srv.dependency('b'); - } - }; + expect(() => { - const b = { - name: 'b', - register: Hoek.ignore - }; + server.decorate('handler', 'file', () => { }); + }).to.throw('Handler decoration already defined: file'); + }); - const c = { - name: 'c', - register: Hoek.ignore - }; + it('errors on unknown handler', () => { const server = Hapi.server(); - await server.register(b); - await server.register(c); - await server.register(a); - await server.initialize(); + + expect(() => { + + server.route({ method: 'GET', path: '/', handler: { test: {} } }); + }).to.throw('Unknown handler: test'); }); - it('register a plugin once per server', async () => { + it('errors on non-string name', () => { - let count = 0; - const b = { - name: 'b', - register: function (srv, options) { + const server = Hapi.server(); - ++count; - } - }; + expect(() => { - const a = { - name: 'a', - register: function (srv, options) { + server.decorate('handler', null); + }).to.throw('Missing decoration property name'); + }); - return srv.register(b, { once: true }); - } - }; + it('errors on non-function handler', () => { const server = Hapi.server(); - await server.register(b); - await server.register(a); - await server.initialize(); - expect(count).to.equal(1); + + expect(() => { + + server.decorate('handler', 'foo', 'bar'); + }).to.throw('Handler must be a function: foo'); }); - it('register a plugin once (plugin)', async () => { + it('throws on double toolkit decoration', () => { - let count = 0; - const b = { - name: 'b', - once: true, - register: function (srv, options) { + const server = Hapi.server(); - ++count; - } - }; + server.decorate('toolkit', 'success', () => { - const a = { - name: 'a', - register: function (srv, options) { + return this.response({ status: 'ok' }); + }); - return srv.register(b); - } - }; + expect(() => server.decorate('toolkit', 'success', () => { })).to.throw('Toolkit decoration already defined: success'); + }); + + it('throws on internal conflict', () => { const server = Hapi.server(); - await server.register(b); - await server.register(a); - await server.initialize(); - expect(count).to.equal(1); - }); - it('throws when once used with plugin options', async () => { + expect(() => { - const a = { - name: 'a', - register: Hoek.ignore - }; + server.decorate('toolkit', 'redirect', () => { }); + }).to.throw('Cannot override the built-in toolkit decoration: redirect'); + }); + + it('decorates server', async () => { const server = Hapi.server(); - await expect(server.register({ plugin: a, options: {}, once: true })).to.reject(); - }); - it('throws when once is false', async () => { + const ok = function (path) { - const b = { - name: 'b', - register: Hoek.ignore + server.route({ + method: 'GET', + path, + handler: () => 'ok' + }); }; - const a = { - name: 'a', - register: function (srv, options) { + server.decorate('server', 'ok', ok); - return srv.register(b); - } - }; + server.ok('/'); - const server = Hapi.server(); - await server.register(b); - await expect(server.register(a)).to.reject(Error, 'Plugin b already registered'); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal('ok'); }); - it('throws when dependencies is an object', async () => { - - const a = { - name: 'a', - dependencies: { b: true }, - register: Hoek.ignore - }; + it('decorates server with Map', async () => { const server = Hapi.server(); - await expect(server.register(a)).to.reject(); - }); - it('throws when dependencies contain something else than a string', async () => { + const itemsMap = new Map(); + itemsMap.set('one', 'One'); + itemsMap.set('two', 'Two'); + itemsMap.set('three', 'Three'); - const a = { - name: 'a', - dependencies: [true], - register: Hoek.ignore - }; + server.decorate('server', 'itemsMap', itemsMap); - const server = Hapi.server(); - await expect(server.register(a)).to.reject(); + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.server.itemsMap + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result.get('one')).to.equal('One'); + expect(res.result.get('two')).to.equal('Two'); + expect(res.result.get('three')).to.equal('Three'); }); - it('exposes server decorations to next register', async () => { + it('throws on double server decoration', () => { const server = Hapi.server(); - const b = { - name: 'b', - register: function (srv, options) { + const ok = function (path) { - if (typeof srv.a !== 'function') { - throw new Error('Missing decoration'); - } - } + server.route({ + method: 'GET', + path, + handler: () => 'ok' + }); }; - const a = { - name: 'a', - register: function (srv, options) { - - srv.decorate('server', 'a', () => { + server.decorate('server', 'ok', ok); - return 'a'; - }); - } - }; + expect(() => { - await server.register([a, b]); - await server.initialize(); + server.decorate('server', 'ok', () => { }); + }).to.throw('Server decoration already defined: ok'); }); - it('exposes server decorations to dependency (dependency first)', async () => { + it('throws on server decoration root conflict', () => { const server = Hapi.server(); - const a = { - name: 'a', - register: function (srv, options) { - - srv.decorate('server', 'a', () => { - - return 'a'; - }); - } - }; - - const b = { - name: 'b', - register: function (srv, options) { - - const after = function (srv2) { - - if (typeof srv2.a !== 'function') { - throw new Error('Missing decoration'); - } - }; - - srv.dependency('a', after); - } - }; + expect(() => { - await server.register([a, b]); - await server.initialize(); + server.decorate('server', 'start', () => { }); + }).to.throw('Cannot override the built-in server interface method: start'); }); - it('exposes server decorations to dependency (dependency second)', async () => { + it('throws on server decoration plugin conflict', () => { const server = Hapi.server(); - const a = { - name: 'a', - register: function (srv, options) { - - srv.decorate('server', 'a', () => 'a'); - } - }; - - const b = { - name: 'b', - register: function (srv, options) { - - srv.realm.x = 1; - const after = function (srv2) { - - expect(srv2.realm.x).to.equal(1); - if (typeof srv2.a !== 'function') { - throw new Error('Missing decoration'); - } - }; - - srv.dependency('a', after); - } - }; + expect(() => { - await server.register([b, a]); - await server.initialize(); + server.decorate('server', 'ext', () => { }); + }).to.throw('Cannot override the built-in server interface method: ext'); }); - it('exposes server decorations to next register when nested', async () => { + it('throws on invalid decoration name', () => { const server = Hapi.server(); - const a = { - name: 'a', - register: function (srv, options) { - - srv.decorate('server', 'a', () => { - - return 'a'; - }); - } - }; - - const b = { - name: 'b', - register: async function (srv, options) { - - await srv.register(a); - if (typeof srv.a !== 'function') { - throw new Error('Missing decoration'); - } - } - }; + expect(() => { - await server.register([b]); - await server.initialize(); + server.decorate('server', '_special', () => { }); + }).to.throw('Property name cannot begin with an underscore: _special'); }); }); - describe('auth', () => { + describe('decorations ()', () => { - it('adds auth strategy via plugin', async () => { + it('shows decorations on request (empty array)', () => { const server = Hapi.server(); - server.route({ - method: 'GET', - path: '/', - handler: () => 'authenticated!' - }); - - await server.register(internals.plugins.auth); - - const res1 = await server.inject('/'); - expect(res1.statusCode).to.equal(401); - const res2 = await server.inject({ method: 'GET', url: '/', headers: { authorization: 'Basic ' + (Buffer.from('john:12345', 'utf8')).toString('base64') } }); - expect(res2.statusCode).to.equal(200); - expect(res2.result).to.equal('authenticated!'); + expect(server.decorations.request).to.be.empty(); }); - }); - - describe('bind()', () => { - it('sets plugin context', async () => { + it('shows decorations on request (single)', () => { - const test = { - name: 'test', - register: function (srv, options) { + const server = Hapi.server(); - const bind = { - value: 'in context', - suffix: ' throughout' - }; + server.decorate('request', 'a', () => { }); - srv.bind(bind); + expect(server.decorations.request).to.equal(['a']); + }); - srv.route({ - method: 'GET', - path: '/', - handler: function () { + it('shows decorations on request (many)', () => { - return this.value; - } - }); + const server = Hapi.server(); + const symbol = Symbol('b'); - const preResponse = function (request, h) { + server.decorate('request', 'a', () => { }); + server.decorate('request', symbol, () => { }); - return request.response.source + this.suffix; - }; + expect(server.decorations.request).to.equal(['a', symbol]); + }); - srv.ext('onPreResponse', preResponse); - } - }; + it('shows decorations on toolkit (empty array)', () => { const server = Hapi.server(); - await server.register(test); - const res = await server.inject('/'); - expect(res.result).to.equal('in context throughout'); + expect(server.decorations.toolkit).to.be.empty(); }); - }); - - describe('cache()', () => { - it('provisions a server cache', async () => { + it('shows decorations on toolkit (single)', () => { const server = Hapi.server(); - const cache = server.cache({ segment: 'test', expiresIn: 1000 }); - await server.initialize(); - await cache.set('a', 'going in', 0); - const value = await cache.get('a'); - expect(value).to.equal('going in'); + server.decorate('toolkit', 'a', () => { }); + + expect(server.decorations.toolkit).to.equal(['a']); }); - it('throws when missing segment', () => { + it('shows decorations on toolkit (many)', () => { const server = Hapi.server(); - expect(() => { - - server.cache({ expiresIn: 1000 }); - }).to.throw('Missing cache segment name'); - }); - - it('provisions a server cache with custom partition', async () => { + const symbol = Symbol('b'); - const server = Hapi.server({ cache: { engine: CatboxMemory, partition: 'hapi-test-other' } }); - const cache = server.cache({ segment: 'test', expiresIn: 1000 }); - await server.initialize(); + server.decorate('toolkit', 'a', () => { }); + server.decorate('toolkit', symbol, () => { }); - await cache.set('a', 'going in', 0); - const value = await cache.get('a'); - expect(value).to.equal('going in'); - expect(cache._cache.connection.settings.partition).to.equal('hapi-test-other'); + expect(server.decorations.toolkit).to.equal(['a', symbol]); }); - it('throws when allocating an invalid cache segment', () => { + it('shows decorations on server (empty array)', () => { const server = Hapi.server(); - expect(() => { - server.cache({ segment: 'a', expiresAt: '12:00', expiresIn: 1000 }); - }).throws(); + expect(server.decorations.server).to.be.empty(); }); - it('allows allocating a cache segment with empty options', () => { + it('shows decorations on server (single)', () => { const server = Hapi.server(); - expect(() => { - - server.cache({ segment: 'a' }); - }).to.not.throw(); - }); - - it('allows reusing the same cache segment (server)', () => { - const server = Hapi.server({ cache: { engine: CatboxMemory, shared: true } }); - expect(() => { + server.decorate('server', 'a', () => { }); - server.cache({ segment: 'a', expiresIn: 1000 }); - server.cache({ segment: 'a', expiresIn: 1000 }); - }).to.not.throw(); + expect(server.decorations.server).to.equal(['a']); }); - it('allows reusing the same cache segment (cache)', () => { + it('shows decorations on server (many)', () => { const server = Hapi.server(); - expect(() => { + const symbol = Symbol('b'); - server.cache({ segment: 'a', expiresIn: 1000 }); - server.cache({ segment: 'a', expiresIn: 1000, shared: true }); - }).to.not.throw(); + server.decorate('server', 'a', () => { }); + server.decorate('server', symbol, () => { }); + + expect(server.decorations.server).to.equal(['a', symbol]); }); + }); - it('uses plugin cache interface', async () => { + describe('dependency()', () => { + + it('fails to register single plugin with dependencies', async () => { const test = { name: 'test', register: function (srv, options) { - const cache = srv.cache({ expiresIn: 10 }); - srv.expose({ - get: function (key) { - - return cache.get(key); - }, - set: function (key, value) { - - return cache.set(key, value, 0); - } - }); + srv.dependency('none'); } }; const server = Hapi.server(); await server.register(test); - await server.initialize(); - - await server.plugins.test.set('a', '1'); - const value1 = await server.plugins.test.get('a'); - expect(value1).to.equal('1'); - - await Hoek.wait(11); - const value2 = await server.plugins.test.get('a'); - expect(value2).to.equal(null); + await expect(server.initialize()).to.reject('Plugin test missing dependency none'); }); - }); - describe('cache.provision()', () => { + it('fails to register single plugin with dependencies (plugin)', async () => { - it('provisions a server cache (before initialization)', async () => { + const test = { + name: 'test', + dependencies: 'none', + register: Hoek.ignore + }; const server = Hapi.server(); - await server.cache.provision({ engine: CatboxMemory, name: 'dynamic' }); - const cache = server.cache({ cache: 'dynamic', segment: 'test', expiresIn: 1000 }); + await server.register(test); + await expect(server.initialize()).to.reject('Plugin test missing dependency none'); + }); - await expect(cache.set('a', 'going in', 0)).to.reject(); - await server.initialize(); + it('fails to register multiple plugins with dependencies', async () => { - await cache.set('a', 'going in', 0); - const value = await cache.get('a'); - expect(value).to.equal('going in'); + const server = Hapi.server({ port: 80, host: 'localhost' }); + await server.register([internals.plugins.deps1, internals.plugins.deps3]); + await expect(server.initialize()).to.reject('Plugin deps1 missing dependency deps2'); }); - it('provisions a server cache (after initialization)', async () => { + it('recognizes dependencies from peer plugins', async () => { - const server = Hapi.server(); + const b = { + name: 'b', + register: Hoek.ignore + }; - await server.initialize(); - await server.cache.provision({ engine: CatboxMemory, name: 'dynamic' }); - const cache = server.cache({ cache: 'dynamic', segment: 'test', expiresIn: 1000 }); + const a = { + name: 'a', + register: function (srv, options) { - await cache.set('a', 'going in', 0); - const value = await cache.get('a'); - expect(value).to.equal('going in'); - }); + return srv.register(b); + } + }; - it('provisions a server cache (promise)', async () => { + const c = { + name: 'c', + register: function (srv, options) { - const server = Hapi.server(); - await server.initialize(); - await server.cache.provision({ engine: CatboxMemory, name: 'dynamic' }); - const cache = server.cache({ cache: 'dynamic', segment: 'test', expiresIn: 1000 }); + srv.dependency('b'); + } + }; - await cache.set('a', 'going in', 0); - const value = await cache.get('a'); - expect(value).to.equal('going in'); + const server = Hapi.server(); + await server.register([a, c]); }); - }); - - describe('decorate()', () => { - - it('decorates request', async () => { - const server = Hapi.server(); + it('errors when missing inner dependencies', async () => { - const getId = function () { + const b = { + name: 'b', + register: function (srv, options) { - return this.info.id; + srv.dependency('c'); + } }; - server.decorate('request', 'getId', getId); + const a = { + name: 'a', + register: function (srv, options) { - server.route({ - method: 'GET', - path: '/', - handler: (request) => request.getId() - }); + return srv.register(b); + } + }; - const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - expect(res.result).to.match(/^.*\:.*\:.*\:.*\:.*$/); + const server = Hapi.server({ port: 80, host: 'localhost' }); + await server.register(a); + await expect(server.initialize()).to.reject('Plugin b missing dependency c'); }); - it('decorates request (apply)', async () => { + it('errors when missing inner dependencies (plugin)', async () => { - const server = Hapi.server(); + const b = { + name: 'b', + dependencies: 'c', + register: Hoek.ignore + }; - server.decorate('request', 'uri', (request) => request.server.info.uri, { apply: true }); - server.decorate('request', 'type', (request) => request.server.type, { apply: true }); + const a = { + name: 'a', + register: function (srv, options) { - server.route({ - method: 'GET', - path: '/', - handler: (request) => (request.uri + ':' + request.type) - }); + return srv.register(b); + } + }; - const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - expect(res.result).to.equal(server.info.uri + ':tcp'); + const server = Hapi.server({ port: 80, host: 'localhost' }); + await server.register(a); + await expect(server.initialize()).to.reject('Plugin b missing dependency c'); }); + }); - it('decorates request (extend)', async () => { - - const server = Hapi.server(); - - const getId = function () { + describe('encoder()', () => { - return this.info.id; - }; + it('adds custom encoder with higher priority than built in encoders', async () => { - server.decorate('request', 'getId', getId); + const data = '{"test":"true"}'; - const getIdExtended = function (existing) { + const server = Hapi.server({ compression: { minBytes: 1 }, routes: { compression: { test: { some: 'option' } } } }); - return function () { + const encoder = (options) => { - return existing.call(this) + '!'; - }; + expect(options).to.equal({ some: 'option' }); + return Zlib.createGzip(); }; - server.decorate('request', 'getId', getIdExtended, { extend: true }); - - server.route({ - method: 'GET', - path: '/', - handler: (request) => request.getId() - }); + server.encoder('test', encoder); + server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); + await server.start(); - const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - expect(res.result).to.match(/^.*\:.*\:.*\:.*\:.*!$/); + const uri = 'http://localhost:' + server.info.port; + const zipped = await new Promise((resolve) => Zlib.gzip(Buffer.from(data), (ignoreErr, compressed) => resolve(compressed))); + const { res, payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip, deflate, test' }, payload: data }); + expect(res.headers['content-encoding']).to.equal('test'); + expect(payload.toString()).to.equal(zipped.toString()); + await server.stop(); }); + }); - it('decorates request (apply + extend)', async () => { + describe('events', () => { + + it('extends server events', async () => { const server = Hapi.server(); - server.decorate('request', 'uri', (request) => request.server.info.uri, { apply: true }); + const updates = []; + server.event({ name: 'test', channels: ['x', 'y'] }); - const extended = function (existing) { + server.events.on({ name: 'test', channels: 'x' }, (update) => updates.push({ id: 'server', channel: 'x', update })); - return function (request) { + let plugin; + const test = { + name: 'test', + register: function (srv, options) { - const base = existing(request); - return base + '!'; - }; + srv.events.on({ name: 'test', channels: 'y' }, (update) => updates.push({ id: 'plugin', channel: 'y', update })); + plugin = srv; + } }; - server.decorate('request', 'uri', extended, { apply: true, extend: true }); + server.events.on('test', (update) => updates.push({ id: 'server', update })); - server.route({ - method: 'GET', - path: '/', - handler: (request) => request.uri - }); + await server.register(test); - const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - expect(res.result).to.equal(server.info.uri + '!'); + server.events.emit('test', 1); + server.events.emit({ name: 'test', channel: 'x' }, 2); + plugin.events.emit({ name: 'test', channel: 'y' }, 3); + + expect(updates).to.equal([ + { id: 'server', update: 1 }, + { id: 'server', channel: 'x', update: 2 }, + { id: 'server', update: 2 }, + { id: 'server', update: 3 }, + { id: 'plugin', channel: 'y', update: 3 } + ]); }); - it('decorates toolkit', async () => { + it('filters log by tags', async () => { const server = Hapi.server(); + const log = server.events.once({ name: 'log', filter: ['x'] }); + server.log('a'); + server.log(['b']); + server.log(['c', 'x'], 'test 1'); + server.log(['d', 'x'], 'test 2'); - const success = function () { + const [event] = await log; + expect(event.data).to.equal('test 1'); + }); + }); - return this.response({ status: 'ok' }); - }; + describe('expose()', () => { - server.decorate('toolkit', 'success', success); + it('exposes an api', async () => { - server.route({ - method: 'GET', - path: '/', - handler: (request, h) => h.success() - }); + const server = Hapi.server(); - const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - expect(res.result.status).to.equal('ok'); + await server.register(internals.plugins.test1); + expect(internals.routesList(server)).to.equal(['/test1']); + expect(server.plugins.test1.add(1, 3)).to.equal(4); + expect(server.plugins.test1.glue('1', '3')).to.equal('13'); }); - it('add new handler', async () => { - - const test = { - name: 'test', - register: function (srv, options1) { + it('exposes an api (scope without scope)', async () => { - const handler = function (route, options2) { + const server = Hapi.server(); - return (request) => 'success'; - }; + const plugin = { + name: 'test1', + version: '1.0.0', + register: function (srv, options) { - srv.decorate('handler', 'bar', handler); + srv.expose('x', { y: 1 }, { scope: true }); } }; + await server.register(plugin); + expect(server.plugins.test1.x.y).to.equal(1); + expect(server.registrations).to.equal({ test1: { version: '1.0.0', name: 'test1', options: undefined } }); + }); + + it('exposes an api (drops scope by default)', async () => { + const server = Hapi.server(); - await server.register(test); - server.route({ - method: 'GET', - path: '/', - handler: { - bar: {} + const plugin = { + name: '@hapi/test1', + version: '1.0.0', + register: function (srv, options) { + + srv.expose('x', { y: 1 }); } - }); + }; - const res = await server.inject('/'); - expect(res.payload).to.equal('success'); + await server.register(plugin); + expect(server.plugins.test1.x.y).to.equal(1); + expect(server.registrations).to.equal({ '@hapi/test1': { version: '1.0.0', name: '@hapi/test1', options: undefined } }); }); - it('errors on duplicate handler', async () => { + it('exposes an api (keeps scope)', async () => { const server = Hapi.server(); - await server.register(Inert); - expect(() => { + const plugin = { + name: '@hapi/test1', + version: '1.0.0', + register: function (srv, options) { - server.decorate('handler', 'file', () => { }); - }).to.throw('Handler decoration already defined: file'); + srv.expose('x', { y: 1 }, { scope: true }); + } + }; + + await server.register(plugin); + expect(server.plugins['@hapi/test1'].x.y).to.equal(1); + expect(server.registrations).to.equal({ '@hapi/test1': { version: '1.0.0', name: '@hapi/test1', options: undefined } }); }); - it('errors on unknown handler', () => { + it('exposes an api (rewrites scope)', async () => { const server = Hapi.server(); - expect(() => { - - server.route({ method: 'GET', path: '/', handler: { test: {} } }); - }).to.throw('Unknown handler: test'); - }); + const plugin = { + name: '@hapi/test1', + version: '1.0.0', + register: function (srv, options) { - it('errors on non-string name', () => { + srv.expose('x', { y: 1 }, { scope: 'underscore' }); + } + }; - const server = Hapi.server(); + await server.register(plugin); + expect(server.plugins.hapi__test1.x.y).to.equal(1); + expect(server.registrations).to.equal({ '@hapi/test1': { version: '1.0.0', name: '@hapi/test1', options: undefined } }); + }); + }); - expect(() => { + describe('ext()', () => { - server.decorate('handler', null); - }).to.throw('Missing decoration property name'); - }); + it('extends onRequest point', async () => { - it('errors on non-function handler', () => { + const test = { + name: 'test', + register: function (srv, options) { - const server = Hapi.server(); + srv.route({ + method: 'GET', + path: '/b', + handler: () => 'b' + }); - expect(() => { + const onRequest = (request, h) => { - server.decorate('handler', 'foo', 'bar'); - }).to.throw('Handler must be a function: foo'); - }); + request.setUrl('/b'); + return h.continue; + }; - it('throws on double toolkit decoration', () => { + srv.ext('onRequest', onRequest); + } + }; const server = Hapi.server(); + await server.register(test); - server.decorate('toolkit', 'success', () => { - - return this.response({ status: 'ok' }); - }); + expect(internals.routesList(server)).to.equal(['/b']); + const res = await server.inject('/a'); + expect(res.result).to.equal('b'); + }); - expect(() => { + it('returns promise on empty ext handler', async () => { - server.decorate('toolkit', 'success', () => { }); - }).to.throw('Toolkit decoration already defined: success'); + const server = Hapi.server(); + const ext = server.ext('onRequest'); + server.route({ path: '/', method: 'GET', handler: () => 'ok' }); + const res = await server.inject('/'); + expect(res.result).to.equal('ok'); + const request = await ext; + expect(request.response.source).to.equal('ok'); }); - it('throws on internal conflict', () => { + it('adds multiple ext functions with complex dependencies', async () => { - const server = Hapi.server(); + // Generate a plugin with a specific index and ext dependencies. - expect(() => { + const pluginCurrier = function (num, deps) { - server.decorate('toolkit', 'redirect', () => { }); - }).to.throw('Cannot override built-in toolkit decoration: redirect'); - }); + const plugin = { + name: 'deps' + num, + register: function (server, options) { - it('decorates server', async () => { + const onRequest = (request, h) => { - const server = Hapi.server(); + request.app.complexDeps = request.app.complexDeps || '|'; + request.app.complexDeps += num + '|'; + return h.continue; + }; - const ok = function (path) { + server.ext('onRequest', onRequest, deps); + } + }; - server.route({ - method: 'GET', - path, - handler: () => 'ok' - }); + return plugin; }; - server.decorate('server', 'ok', ok); + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request) => request.app.complexDeps }); - server.ok('/'); + await server.register([ + pluginCurrier(1, { after: 'deps2' }), + pluginCurrier(2), + pluginCurrier(3, { before: ['deps1', 'deps2'] }) + ]); + + await server.initialize(); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); - expect(res.result).to.equal('ok'); + expect(res.result).to.equal('|3|2|1|'); }); - it('throws on double server decoration', () => { + it('binds server ext to context (options)', async () => { const server = Hapi.server(); - const ok = function (path) { - - server.route({ - method: 'GET', - path, - handler: () => 'ok' - }); + const bind = { + state: false }; - server.decorate('server', 'ok', ok); + const preStart = function (srv) { - expect(() => { + this.state = true; + }; - server.decorate('server', 'ok', () => { }); - }).to.throw('Server decoration already defined: ok'); + server.ext('onPreStart', preStart, { bind }); + + await server.initialize(); + expect(bind.state).to.be.true(); }); - it('throws on server decoration root conflict', () => { + it('binds server ext to context (argument)', async () => { const server = Hapi.server(); - expect(() => { - - server.decorate('server', 'start', () => { }); - }).to.throw('Cannot override the built-in server interface method: start'); - }); + const bind = { + state: false + }; - it('throws on server decoration plugin conflict', () => { + const preStart = (srv, context) => { - const server = Hapi.server(); + context.state = true; + }; - expect(() => { + server.ext('onPreStart', preStart, { bind }); - server.decorate('server', 'ext', () => { }); - }).to.throw('Cannot override the built-in server interface method: ext'); + await server.initialize(); + expect(bind.state).to.be.true(); }); - it('throws on invalid decoration name', () => { + it('binds server ext to context (realm)', async () => { const server = Hapi.server(); - expect(() => { - - server.decorate('server', '_special', () => { }); - }).to.throw('Property name cannot begin with an underscore: _special'); - }); - }); + const bind = { + state: false + }; - describe('decorations ()', () => { + server.bind(bind); + const preStart = function (srv) { - it('shows decorations on request (empty array)', () => { + this.state = true; + }; - const server = Hapi.server(); + server.ext('onPreStart', preStart); - expect(server.decorations.request).to.be.empty(); + await server.initialize(); + expect(bind.state).to.be.true(); }); - it('shows decorations on request (single)', () => { + it('extends server actions', async () => { const server = Hapi.server(); - server.decorate('request', 'a', () => { }); + let result = ''; + const preStart = function (srv) { - expect(server.decorations.request).to.equal(['a']); - }); + result += '1'; + }; - it('shows decorations on request (many)', () => { + server.ext('onPreStart', preStart); - const server = Hapi.server(); + const postStart = function (srv) { - server.decorate('request', 'a', () => { }); - server.decorate('request', 'b', () => { }); + result += '2'; + }; - expect(server.decorations.request).to.equal(['a', 'b']); - }); + server.ext('onPostStart', postStart); - it('shows decorations on toolkit (empty array)', () => { + const preStop = function (srv) { - const server = Hapi.server(); + result += '3'; + }; - expect(server.decorations.toolkit).to.be.empty(); - }); + server.ext('onPreStop', preStop); - it('shows decorations on toolkit (single)', () => { + const postStop = function (srv) { - const server = Hapi.server(); + result += '4'; + }; - server.decorate('toolkit', 'a', () => { }); + server.ext('onPostStop', postStop); - expect(server.decorations.toolkit).to.equal(['a']); + await server.start(); + expect(result).to.equal('12'); + + await server.stop(); + expect(result).to.equal('1234'); }); - it('shows decorations on toolkit (many)', () => { + it('extends server actions (single call)', async () => { const server = Hapi.server(); - server.decorate('toolkit', 'a', () => { }); - server.decorate('toolkit', 'b', () => { }); - - expect(server.decorations.toolkit).to.equal(['a', 'b']); - }); - - it('shows decorations on server (empty array)', () => { + let result = ''; + server.ext([ + { + type: 'onPreStart', + method: function (srv) { - const server = Hapi.server(); + result += '1'; + } + }, + { + type: 'onPostStart', + method: function (srv) { - expect(server.decorations.server).to.be.empty(); - }); + result += '2'; + } + }, + { + type: 'onPreStop', + method: function (srv) { - it('shows decorations on server (single)', () => { + result += '3'; + } + }, + { + type: 'onPreStop', + method: function (srv) { - const server = Hapi.server(); + result += '4'; + } + } + ]); - server.decorate('server', 'a', () => { }); + await server.start(); + expect(result).to.equal('12'); - expect(server.decorations.server).to.equal(['a']); + await server.stop(); + expect(result).to.equal('1234'); }); - it('shows decorations on server (many)', () => { + it('combine route extensions', async () => { const server = Hapi.server(); - server.decorate('server', 'a', () => { }); - server.decorate('server', 'b', () => { }); + const preAuth = (request, h) => { - expect(server.decorations.server).to.equal(['a', 'b']); - }); - }); + request.app.x = '1'; + return h.continue; + }; - describe('dependency()', () => { - - it('fails to register single plugin with dependencies', async () => { + server.ext('onPreAuth', preAuth); - const test = { + const plugin = { name: 'test', register: function (srv, options) { - srv.dependency('none'); - } - }; - - const server = Hapi.server(); - await server.register(test); - await expect(server.initialize()).to.reject('Plugin test missing dependency none'); - }); + srv.route({ + method: 'GET', + path: '/', + options: { + ext: { + onPreAuth: { + method: (request, h) => { - it('fails to register single plugin with dependencies (plugin)', async () => { + request.app.x += '2'; + return h.continue; + } + } + }, + handler: (request) => request.app.x + } + }); - const test = { - name: 'test', - dependencies: 'none', - register: Hoek.ignore - }; + const preAuthSandbox = (request, h) => { - const server = Hapi.server(); - await server.register(test); - await expect(server.initialize()).to.reject('Plugin test missing dependency none'); - }); + request.app.x += '3'; + return h.continue; + }; - it('fails to register multiple plugins with dependencies', async () => { + srv.ext('onPreAuth', preAuthSandbox, { sandbox: 'plugin' }); + } + }; - const server = Hapi.server({ port: 80, host: 'localhost' }); - await server.register([internals.plugins.deps1, internals.plugins.deps3]); - await expect(server.initialize()).to.reject('Plugin deps1 missing dependency deps2'); - }); + await server.register(plugin); - it('recognizes dependencies from peer plugins', async () => { + server.route({ + method: 'GET', + path: '/a', + handler: (request) => request.app.x + }); - const b = { - name: 'b', - register: Hoek.ignore - }; + const res1 = await server.inject('/'); + expect(res1.result).to.equal('123'); - const a = { - name: 'a', - register: function (srv, options) { + const res2 = await server.inject('/a'); + expect(res2.result).to.equal('1'); + }); - return srv.register(b); - } - }; + it('calls method after plugin', async () => { - const c = { - name: 'c', + const x = { + name: 'x', register: function (srv, options) { - srv.dependency('b'); + srv.expose('a', 'b'); } }; const server = Hapi.server(); - await server.register([a, c]); - }); - it('errors when missing inner dependencies', async () => { + expect(server.plugins.x).to.not.exist(); - const b = { - name: 'b', - register: function (srv, options) { + let called = false; + const preStart = function (srv) { - srv.dependency('c'); - } + expect(srv.plugins.x.a).to.equal('b'); + called = true; }; - const a = { - name: 'a', - register: function (srv, options) { - - return srv.register(b); - } - }; + server.ext('onPreStart', preStart, { after: 'x' }); - const server = Hapi.server({ port: 80, host: 'localhost' }); - await server.register(a); - await expect(server.initialize()).to.reject('Plugin b missing dependency c'); + await server.register(x); + await server.initialize(); + expect(called).to.be.true(); }); - it('errors when missing inner dependencies (plugin)', async () => { + it('calls method before start', async () => { - const b = { - name: 'b', - dependencies: 'c', - register: Hoek.ignore - }; + const server = Hapi.server(); - const a = { - name: 'a', - register: function (srv, options) { + let called = false; + const preStart = function (srv) { - return srv.register(b); - } + called = true; }; - const server = Hapi.server({ port: 80, host: 'localhost' }); - await server.register(a); - await expect(server.initialize()).to.reject('Plugin b missing dependency c'); - }); - }); - - describe('encoder()', () => { + server.ext('onPreStart', preStart); - it('adds custom encoder with higher priority than built in encoders', async () => { + await server.initialize(); + expect(called).to.be.true(); + }); - const data = '{"test":"true"}'; + it('calls method before start even if plugin not registered', async () => { - const server = Hapi.server({ compression: { minBytes: 1 }, routes: { compression: { test: { some: 'option' } } } }); + const server = Hapi.server(); - const encoder = (options) => { + let called = false; + const preStart = function (srv) { - expect(options).to.equal({ some: 'option' }); - return Zlib.createGzip(); + called = true; }; - server.encoder('test', encoder); - server.route({ method: 'POST', path: '/', handler: (request) => request.payload }); - await server.start(); + server.ext('onPreStart', preStart, { after: 'x' }); - const uri = 'http://localhost:' + server.info.port; - const zipped = await new Promise((resolve) => Zlib.gzip(Buffer.from(data), (ignoreErr, compressed) => resolve(compressed))); - const { res, payload } = await Wreck.post(uri, { headers: { 'accept-encoding': 'gzip, deflate, test' }, payload: data }); - expect(res.headers['content-encoding']).to.equal('test'); - expect(payload.toString()).to.equal(zipped.toString()); - await server.stop(); + await server.initialize(); + expect(called).to.be.true(); }); - }); - describe('events', () => { + it('fails to start server when after method fails', async () => { - it('extends server events', async () => { + const test = { + name: 'test', + register: function (srv, options) { - const server = Hapi.server(); + const preStart1 = function (inner) { }; - const updates = []; - server.event({ name: 'test', channels: ['x', 'y'] }); + srv.ext('onPreStart', preStart1); - server.events.on({ name: 'test', channels: 'x' }, (update) => updates.push({ id: 'server', channel: 'x', update })); + const preStart2 = function (inner) { - let plugin; - const test = { - name: 'test', - register: function (srv, options) { + throw new Error('Not in the mood'); + }; - srv.events.on({ name: 'test', channels: 'y' }, (update) => updates.push({ id: 'plugin', channel: 'y', update })); - plugin = srv; + srv.ext('onPreStart', preStart2); } }; - server.events.on('test', (update) => updates.push({ id: 'server', update })); - + const server = Hapi.server(); await server.register(test); - - server.events.emit('test', 1); - server.events.emit({ name: 'test', channel: 'x' }, 2); - await plugin.events.emit({ name: 'test', channel: 'y' }, 3); - - expect(updates).to.equal([ - { id: 'server', update: 1 }, - { id: 'server', channel: 'x', update: 2 }, - { id: 'server', update: 2 }, - { id: 'server', update: 3 }, - { id: 'plugin', channel: 'y', update: 3 } - ]); + await expect(server.initialize()).to.reject('Not in the mood'); }); - }); - - describe('expose()', () => { - it('exposes an api', async () => { + it('errors when added after initialization', async () => { const server = Hapi.server(); - await server.register(internals.plugins.test1); - expect(internals.routesList(server)).to.equal(['/test1']); - expect(server.plugins.test1.add(1, 3)).to.equal(4); - expect(server.plugins.test1.glue('1', '3')).to.equal('13'); + await server.initialize(); + expect(() => { + + server.ext('onPreStart', () => { }); + }).to.throw('Cannot add onPreStart (after) extension after the server was initialized'); }); }); - describe('ext()', () => { + describe('log()', () => { - it('extends onRequest point', async () => { + it('emits a log event', async () => { - const test = { - name: 'test', - register: function (srv, options) { + const server = Hapi.server(); - srv.route({ - method: 'GET', - path: '/b', - handler: () => 'b' - }); + let count = 0; + server.events.once('log', (event, tags) => { - const onRequest = (request, h) => { + ++count; + expect(event.data).to.equal('log event 1'); + }); - request.setUrl('/b'); - return h.continue; - }; + server.events.once('log', (event, tags) => { - srv.ext('onRequest', onRequest); - } - }; + ++count; + expect(event.data).to.equal('log event 1'); + }); - const server = Hapi.server(); - await server.register(test); + server.log('1', 'log event 1'); - expect(internals.routesList(server)).to.equal(['/b']); - const res = await server.inject('/a'); - expect(res.result).to.equal('b'); - }); + server.events.once('log', (event, tags) => { - it('adds multiple ext functions with complex dependencies', async () => { + ++count; + expect(event.data).to.equal('log event 2'); + }); - // Generate a plugin with a specific index and ext dependencies. + server.log(['2'], 'log event 2'); + await Hoek.wait(10); + expect(count).to.equal(3); + }); - const pluginCurrier = function (num, deps) { + it('emits a log event (function data)', async () => { - const plugin = { - name: 'deps' + num, - register: function (server, options) { + const server = Hapi.server(); + const log = server.events.once('log'); + server.log('test', () => 123); + const [event] = await log; + expect(event.data).to.equal(123); + }); - const onRequest = (request, h) => { + it('emits a log event and print to console', async () => { - request.app.complexDeps = request.app.complexDeps || '|'; - request.app.complexDeps += num + '|'; - return h.continue; - }; + const server = Hapi.server({ debug: { log: 'implementation' } }); - server.ext('onRequest', onRequest, deps); - } - }; + server.events.once('log', (event, tags) => { - return plugin; - }; + expect(event.data).to.equal('log event 1'); + }); - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', handler: (request) => request.app.complexDeps }); + const log = new Promise((resolve) => { - await server.register([ - pluginCurrier(1, { after: 'deps2' }), - pluginCurrier(2), - pluginCurrier(3, { before: ['deps1', 'deps2'] }) - ]); + const orig = console.error; + console.error = function (...args) { - await server.initialize(); + console.error = orig; + expect(args[0]).to.equal('Debug:'); + expect(args[1]).to.equal('internal, implementation, error'); - const res = await server.inject('/'); - expect(res.result).to.equal('|3|2|1|'); + resolve(); + }; + }); + + server.log(['internal', 'implementation', 'error'], 'log event 1'); + await log; }); - it('binds server ext to context (options)', async () => { + it('outputs log data to debug console', async () => { - const server = Hapi.server(); + const server = Hapi.server({ debug: { log: '*' } }); - const bind = { - state: false - }; + const log = new Promise((resolve) => { - const preStart = function (srv) { + const orig = console.error; + console.error = function (...args) { - this.state = true; - }; + console.error = orig; + expect(args[0]).to.equal('Debug:'); + expect(args[1]).to.equal('implementation'); + expect(args[2]).to.equal('\n {"data":1}'); - server.ext('onPreStart', preStart, { bind }); + resolve(); + }; + }); - await server.initialize(); - expect(bind.state).to.be.true(); + server.log(['implementation'], { data: 1 }); + await log; }); - it('binds server ext to context (argument)', async () => { - - const server = Hapi.server(); + it('outputs log error data to debug console', async () => { - const bind = { - state: false - }; + const server = Hapi.server({ debug: { log: '*' } }); - const preStart = (srv, context) => { + const log = new Promise((resolve) => { - context.state = true; - }; + const orig = console.error; + console.error = function (...args) { - server.ext('onPreStart', preStart, { bind }); + console.error = orig; + expect(args[0]).to.equal('Debug:'); + expect(args[1]).to.equal('implementation'); + expect(args[2]).to.contain('\n Error: test\n at'); + resolve(); + }; + }); - await server.initialize(); - expect(bind.state).to.be.true(); + server.log(['implementation'], new Error('test')); + await log; }); - it('binds server ext to context (realm)', async () => { - - const server = Hapi.server(); + it('outputs log data to debug console without data', async () => { - const bind = { - state: false - }; + const server = Hapi.server({ debug: { log: '*' } }); - server.bind(bind); - const preStart = function (srv) { + const log = new Promise((resolve) => { - this.state = true; - }; + const orig = console.error; + console.error = function (...args) { - server.ext('onPreStart', preStart); + console.error = orig; + expect(args[0]).to.equal('Debug:'); + expect(args[1]).to.equal('implementation'); + expect(args[2]).to.equal(''); + resolve(); + }; + }); - await server.initialize(); - expect(bind.state).to.be.true(); + server.log(['implementation']); + await log; }); - it('extends server actions', async () => { + it('does not output events when debug disabled', () => { - const server = Hapi.server(); + const server = Hapi.server({ debug: false }); - let result = ''; - const preStart = function (srv) { + let i = 0; + const orig = console.error; + console.error = function () { - result += '1'; + ++i; }; - server.ext('onPreStart', preStart); + server.log(['implementation']); + console.error('nothing'); + expect(i).to.equal(1); + console.error = orig; + }); - const postStart = function (srv) { + it('does not output events when debug.log disabled', () => { - result += '2'; + const server = Hapi.server({ debug: { log: false } }); + + let i = 0; + const orig = console.error; + console.error = function () { + + ++i; }; - server.ext('onPostStart', postStart); + server.log(['implementation']); + console.error('nothing'); + expect(i).to.equal(1); + console.error = orig; + }); - const preStop = function (srv) { + it('does not output non-implementation events by default', () => { - result += '3'; + const server = Hapi.server(); + + let i = 0; + const orig = console.error; + console.error = function () { + + ++i; }; - server.ext('onPreStop', preStop); + server.log(['xyz']); + console.error('nothing'); + expect(i).to.equal(1); + console.error = orig; + }); - const postStop = function (srv) { + it('emits server log events once', async () => { - result += '4'; + let pc = 0; + const test = { + name: 'test', + register: function (srv, options) { + + srv.events.on('log', (event, tags) => ++pc); + } }; - server.ext('onPostStop', postStop); + const server = Hapi.server(); - await server.start(); - expect(result).to.equal('12'); + let sc = 0; + server.events.on('log', (event, tags) => ++sc); - await server.stop(); - expect(result).to.equal('1234'); + await server.register(test); + server.log('test'); + expect(sc).to.equal(1); + expect(pc).to.equal(1); }); - it('extends server actions (single call)', async () => { - - const server = Hapi.server(); + it('emits log events after handler error when server is started', async () => { - let result = ''; - server.ext([ - { - type: 'onPreStart', - method: function (srv) { + const server = Hapi.server({ debug: false }); - result += '1'; - } - }, - { - type: 'onPostStart', - method: function (srv) { + const updates = []; + const test = { + name: 'test', + register: function (srv, options) { - result += '2'; - } - }, - { - type: 'onPreStop', - method: function (srv) { + srv.events.on('log', (event, tags) => updates.push(event.tags)); + srv.events.on('response', (request) => updates.push('response')); + srv.events.on({ name: 'request', channels: 'error' }, (request, err) => updates.push({ name: 'request', channels: 'error' })); + } + }; - result += '3'; - } - }, - { - type: 'onPreStop', - method: function (srv) { + server.route({ + method: 'GET', + path: '/', + handler: (request) => { - result += '4'; - } + request.server.log('1'); + throw new Error('2'); } - ]); + }); + await server.register(test); await server.start(); - expect(result).to.equal('12'); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + await Hoek.wait(10); + expect(updates).to.equal([['1'], { name: 'request', channels: 'error' }, 'response']); await server.stop(); - expect(result).to.equal('1234'); }); - it('combine route extensions', async () => { + it('outputs logs for all server log events with a wildcard', async () => { - const server = Hapi.server(); + const server = Hapi.server({ debug: { log: '*' } }); - const preAuth = (request, h) => { + const log = new Promise((resolve) => { - request.app.x = '1'; - return h.continue; - }; + const orig = console.error; + console.error = function (...args) { - server.ext('onPreAuth', preAuth); + console.error = orig; + expect(args[0]).to.equal('Debug:'); + expect(args[1]).to.equal('foobar'); + expect(args[2]).to.equal('\n {"data":1}'); + resolve(); + }; + }); - const plugin = { - name: 'test', - register: function (srv, options) { + server.log(['foobar'], { data: 1 }); + await log; + }); - srv.route({ - method: 'GET', - path: '/', - options: { - ext: { - onPreAuth: { - method: (request, h) => { + it('outputs logs for all request log events with a wildcard', async () => { - request.app.x += '2'; - return h.continue; - } - } - }, - handler: (request) => request.app.x - } - }); + const server = Hapi.server({ debug: { request: '*' } }); - const preAuthSandbox = (request, h) => { + const expectedLogs = [ + ['Debug:', 'handler, error'] + ]; - request.app.x += '3'; - return h.continue; - }; + const log = new Promise((resolve) => { - srv.ext('onPreAuth', preAuthSandbox, { sandbox: 'plugin' }); - } - }; + const orig = console.error; + console.error = function (...args) { - await server.register(plugin); + expect(args).to.contain(expectedLogs.shift()); + if (expectedLogs.length === 0) { + console.error = orig; + resolve(); + } + }; + }); + + server.inject('/', () => { }); + await log; + }); + }); + + describe('lookup()', () => { + + it('returns route based on id', () => { + const server = Hapi.server(); server.route({ method: 'GET', - path: '/a', - handler: (request) => request.app.x + path: '/', + options: { + handler: () => null, + id: 'root', + app: { test: 123 } + } }); - const res1 = await server.inject('/'); - expect(res1.result).to.equal('123'); + const root = server.lookup('root'); + expect(root.path).to.equal('/'); + expect(root.settings.app.test).to.equal(123); + }); - const res2 = await server.inject('/a'); - expect(res2.result).to.equal('1'); + it('returns null on unknown route', () => { + + const server = Hapi.server(); + const root = server.lookup('root'); + expect(root).to.be.null(); }); - it('calls method after plugin', async () => { + it('throws on missing id', () => { - const x = { - name: 'x', - register: function (srv, options) { + const server = Hapi.server(); + expect(() => { - srv.expose('a', 'b'); - } - }; + server.lookup(); + }).to.throw('Invalid route id: '); + }); + }); + + describe('match()', () => { + + it('returns route based on path', () => { const server = Hapi.server(); - expect(server.plugins.x).to.not.exist(); + server.route({ + method: 'GET', + path: '/', + options: { + handler: () => null, + id: 'root' + } + }); - let called = false; - const preStart = function (srv) { + server.route({ + method: 'GET', + path: '/abc', + options: { + handler: () => null, + id: 'abc' + } + }); - expect(srv.plugins.x.a).to.equal('b'); - called = true; - }; + server.route({ + method: 'POST', + path: '/abc', + options: { + handler: () => null, + id: 'post' + } + }); - server.ext('onPreStart', preStart, { after: 'x' }); + server.route({ + method: 'GET', + path: '/{p}/{x}', + options: { + handler: () => null, + id: 'params' + } + }); - await server.register(x); - await server.initialize(); - expect(called).to.be.true(); + server.route({ + method: 'GET', + path: '/abc', + vhost: 'example.com', + options: { + handler: () => null, + id: 'vhost' + } + }); + + expect(server.match('GET', '/').settings.id).to.equal('root'); + expect(server.match('GET', '/none')).to.equal(null); + expect(server.match('GET', '/abc').settings.id).to.equal('abc'); + expect(server.match('get', '/').settings.id).to.equal('root'); + expect(server.match('post', '/abc').settings.id).to.equal('post'); + expect(server.match('get', '/a/b').settings.id).to.equal('params'); + expect(server.match('GET', '/abc', 'example.com').settings.id).to.equal('vhost'); }); - it('calls method before start', async () => { + it('throws on missing method', () => { const server = Hapi.server(); + expect(() => { - let called = false; - const preStart = function (srv) { + server.match(); + }).to.throw('Invalid method: '); + }); - called = true; - }; + it('throws on invalid method', () => { - server.ext('onPreStart', preStart); + const server = Hapi.server(); + expect(() => { - await server.initialize(); - expect(called).to.be.true(); + server.match(5); + }).to.throw('Invalid method: 5'); }); - it('calls method before start even if plugin not registered', async () => { + it('throws on missing path', () => { const server = Hapi.server(); + expect(() => { - let called = false; - const preStart = function (srv) { + server.match('get'); + }).to.throw('Invalid path: '); + }); - called = true; - }; + it('throws on invalid path type', () => { - server.ext('onPreStart', preStart, { after: 'x' }); + const server = Hapi.server(); + expect(() => { - await server.initialize(); - expect(called).to.be.true(); + server.match('get', 5); + }).to.throw('Invalid path: 5'); }); - it('fails to start server when after method fails', async () => { - - const test = { - name: 'test', - register: function (srv, options) { + it('throws on invalid path prefix', () => { - const preStart1 = function (inner) { }; + const server = Hapi.server(); + expect(() => { - srv.ext('onPreStart', preStart1); + server.match('get', '5'); + }).to.throw('Invalid path: 5'); + }); - const preStart2 = function (inner) { + it('throws on invalid path', () => { - throw new Error('Not in the mood'); - }; + const server = Hapi.server(); + server.route({ + method: 'GET', + path: '/{p}', + handler: () => null + }); - srv.ext('onPreStart', preStart2); - } - }; + expect(() => { - const server = Hapi.server(); - await server.register(test); - await expect(server.initialize()).to.reject('Not in the mood'); + server.match('GET', '/%p'); + }).to.throw('Invalid path: /%p'); }); - it('errors when added after initialization', async () => { + it('throws on invalid host type', () => { const server = Hapi.server(); - - await server.initialize(); expect(() => { - server.ext('onPreStart', () => { }); - }).to.throw('Cannot add onPreStart (after) extension after the server was initialized'); + server.match('get', '/a', 5); + }).to.throw('Invalid host: 5'); }); }); - describe('log()', () => { + describe('method()', () => { - it('emits a log event', async () => { + it('adds server method using arguments', async () => { const server = Hapi.server(); - let count = 0; - server.events.once('log', (event, tags) => { + const test = { + name: 'test', + register: function (srv, options) { - ++count; - expect(event.data).to.equal('log event 1'); - }); + const method = function (methodNext) { - server.events.once('log', (event, tags) => { + return methodNext(null); + }; - ++count; - expect(event.data).to.equal('log event 1'); - }); + srv.method('log', method); + } + }; - server.log('1', 'log event 1'); + await server.register(test); + }); - server.events.once('log', (event, tags) => { + it('adds server method with plugin bind', async () => { - ++count; - expect(event.data).to.equal('log event 2'); - }); + const server = Hapi.server(); - server.log(['2'], 'log event 2'); - await Hoek.wait(10); - expect(count).to.equal(3); - }); + const test = { + name: 'test', + register: function (srv, options) { - it('emits a log event (function data)', async () => { + srv.bind({ x: 1 }); + const method = function () { - const server = Hapi.server(); - const log = server.events.once('log'); - server.log('test', () => 123); - const [event] = await log; - expect(event.data).to.equal(123); - }); + return this.x; + }; - it('emits a log event and print to console', async () => { + srv.method('log', method); + } + }; - const server = Hapi.server({ debug: { log: 'implementation' } }); + await server.register(test); + const result = server.methods.log(); + expect(result).to.equal(1); + }); - server.events.once('log', (event, tags) => { + it('adds server method with method bind', async () => { - expect(event.data).to.equal('log event 1'); - }); + const server = Hapi.server(); - const log = new Promise((resolve) => { + const test = { + name: 'test', + register: function (srv, options) { - const orig = console.error; - console.error = function (...args) { + const method = function () { - console.error = orig; - expect(args[0]).to.equal('Debug:'); - expect(args[1]).to.equal('internal, implementation, error'); + return this.x; + }; - resolve(); - }; - }); + srv.method('log', method, { bind: { x: 2 } }); + } + }; - server.log(['internal', 'implementation', 'error'], 'log event 1'); - await log; + await server.register(test); + + const result = server.methods.log(); + expect(result).to.equal(2); }); - it('outputs log data to debug console', async () => { + it('adds server method with method and ext bind', async () => { - const server = Hapi.server({ debug: { log: '*' } }); + const server = Hapi.server(); - const log = new Promise((resolve) => { + const test = { + name: 'test', + register: function (srv, options) { - const orig = console.error; - console.error = function (...args) { + srv.bind({ x: 1 }); + const method = function () { - console.error = orig; - expect(args[0]).to.equal('Debug:'); - expect(args[1]).to.equal('implementation'); - expect(args[2]).to.equal('\n {"data":1}'); + return this.x; + }; - resolve(); - }; - }); + srv.method('log', method, { bind: { x: 2 } }); + } + }; - server.log(['implementation'], { data: 1 }); - await log; + await server.register(test); + + const result = server.methods.log(); + expect(result).to.equal(2); }); + }); - it('outputs log error data to debug console', async () => { + describe('path()', () => { - const server = Hapi.server({ debug: { log: '*' } }); + it('sets local path for directory route handler', async () => { - const log = new Promise((resolve) => { + const test = { + name: 'test', + register: function (srv, options) { - const orig = console.error; - console.error = function (...args) { + srv.path(Path.join(__dirname, '..')); - console.error = orig; - expect(args[0]).to.equal('Debug:'); - expect(args[1]).to.equal('implementation'); - expect(args[2]).to.contain('\n Error: test\n at'); - resolve(); - }; - }); + srv.route({ + method: 'GET', + path: '/handler/{file*}', + handler: { + directory: { + path: './' + } + } + }); + } + }; - server.log(['implementation'], new Error('test')); - await log; + const server = Hapi.server({ routes: { files: { relativeTo: __dirname } } }); + await server.register(Inert); + await server.register(test); + + const res = await server.inject('/handler/package.json'); + expect(res.statusCode).to.equal(200); }); - it('outputs log data to debug console without data', async () => { + it('throws when plugin sets undefined path', async () => { - const server = Hapi.server({ debug: { log: '*' } }); + const test = { + name: 'test', + register: function (srv, options) { - const log = new Promise((resolve) => { + srv.path(); + } + }; - const orig = console.error; - console.error = function (...args) { + const server = Hapi.server(); + await expect(server.register(test)).to.reject('relativeTo must be a non-empty string'); + }); + }); - console.error = orig; - expect(args[0]).to.equal('Debug:'); - expect(args[1]).to.equal('implementation'); - expect(args[2]).to.equal(''); - resolve(); - }; - }); + describe('register()', () => { - server.log(['implementation']); - await log; - }); + it('registers plugin with options', async () => { - it('does not output events when debug disabled', () => { + const server = Hapi.server(); - const server = Hapi.server({ debug: false }); + const test = { + name: 'test', - let i = 0; - const orig = console.error; - console.error = function () { + register: function (srv, options) { - ++i; + expect(options.something).to.be.true(); + expect(srv.realm.pluginOptions).to.equal(options); + } }; - server.log(['implementation']); - console.error('nothing'); - expect(i).to.equal(1); - console.error = orig; + await server.register({ plugin: test, options: { something: true } }); }); - it('does not output events when debug.log disabled', () => { + it('registers a required plugin', async () => { - const server = Hapi.server({ debug: { log: false } }); + const server = Hapi.server(); - let i = 0; - const orig = console.error; - console.error = function () { + const test = { + plugin: { + name: 'test', + register: function (srv, options) { - ++i; + expect(options.something).to.be.true(); + } + } }; - server.log(['implementation']); - console.error('nothing'); - expect(i).to.equal(1); - console.error = orig; + await server.register({ plugin: test, options: { something: true } }); }); - it('does not output non-implementation events by default', () => { + it('rejects on bad plugin (missing name)', async () => { + + const plugin = { + register: Hoek.ignore + }; const server = Hapi.server(); + await expect(server.register(plugin)).to.reject(); + }); - let i = 0; - const orig = console.error; - console.error = function () { + it('rejects on bad plugin (empty pkg)', async () => { - ++i; + const plugin = { + pkg: {}, + register: Hoek.ignore }; - server.log(['xyz']); - console.error('nothing'); - expect(i).to.equal(1); - console.error = orig; + const server = Hapi.server(); + await expect(server.register(plugin)).to.reject(); }); - it('emits server log events once', async () => { + it('returns plugin error', async () => { - let pc = 0; const test = { name: 'test', register: function (srv, options) { - srv.events.on('log', (event, tags) => ++pc); + throw new Error('from plugin'); } }; const server = Hapi.server(); - - let sc = 0; - server.events.on('log', (event, tags) => ++sc); - - await server.register(test); - server.log('test'); - expect(sc).to.equal(1); - expect(pc).to.equal(1); + await expect(server.register(test)).to.reject('from plugin'); }); - it('emits log events after handler error when server is started', async () => { - - const server = Hapi.server({ debug: false }); + it('sets version to 0.0.0 if missing', async () => { - const updates = []; const test = { - name: 'test', + pkg: { + name: 'steve' + }, register: function (srv, options) { - srv.events.on('log', (event, tags) => updates.push(event.tags)); - srv.events.on('response', (request) => updates.push('response')); - srv.events.on({ name: 'request', channels: 'error' }, (request, err) => updates.push({ name: 'request', channels: 'error' })); - } - }; - - server.route({ - method: 'GET', - path: '/', - handler: (request) => { - - request.server.log('1'); - throw new Error('2'); + srv.route({ + method: 'GET', + path: '/', + handler: () => srv.version + }); } - }); + }; + const server = Hapi.server(); await server.register(test); - await server.start(); + expect(server.registrations.steve.version).to.equal('0.0.0'); const res = await server.inject('/'); - expect(res.statusCode).to.equal(500); - await Hoek.wait(10); - expect(updates).to.equal([['1'], { name: 'request', channels: 'error' }, 'response']); - await server.stop(); + expect(res.result).to.equal(require('../package.json').version); }); - it('outputs logs for all server log events with a wildcard', async () => { + it('exposes plugin registration information', async () => { - const server = Hapi.server({ debug: { log: '*' } }); + const test = { + multiple: true, + pkg: { + name: 'bob', + version: '1.2.3' + }, + register: function (srv, options) { - const log = new Promise((resolve) => { + srv.route({ method: 'GET', path: '/', handler: () => srv.version }); + } + }; - const orig = console.error; - console.error = function (...args) { + const server = Hapi.server(); - console.error = orig; - expect(args[0]).to.equal('Debug:'); - expect(args[1]).to.equal('foobar'); - expect(args[2]).to.equal('\n {"data":1}'); - resolve(); - }; - }); + await server.register({ plugin: test, options: { foo: 'bar' } }); + const bob = server.registrations.bob; + expect(bob).to.exist(); + expect(bob).to.be.an.object(); + expect(bob.version).to.equal('1.2.3'); + expect(bob.options.foo).to.equal('bar'); - server.log(['foobar'], { data: 1 }); - await log; + const res = await server.inject('/'); + expect(res.result).to.equal(require('../package.json').version); }); - it('outputs logs for all request log events with a wildcard', async () => { + it('prevents plugin from multiple registrations', async () => { - const server = Hapi.server({ debug: { request: '*' } }); + const test = { + name: 'test', + register: function (srv, options) { - const expectedLogs = [ - ['Debug:', 'handler, error'] - ]; + srv.route({ method: 'GET', path: '/a', handler: () => 'a' }); + } + }; - const log = new Promise((resolve) => { + const server = Hapi.server({ host: 'example.com' }); + await server.register(test); + await expect(server.register(test)).to.reject('Plugin test already registered'); + }); - const orig = console.error; - console.error = function (...args) { + it('allows plugin multiple registrations (plugin)', async () => { - expect(args).to.contain(expectedLogs.shift()); - if (expectedLogs.length === 0) { - console.error = orig; - resolve(); - } - }; - }); + const test = { + name: 'test', + multiple: true, + register: function (srv, options) { - server.inject('/', () => { }); - await log; + srv.app.x = srv.app.x ? srv.app.x + 1 : 1; + } + }; + + const server = Hapi.server(); + await server.register(test); + await server.register(test); + expect(server.app.x).to.equal(2); }); - }); - describe('lookup()', () => { + it('registers multiple plugins', async () => { - it('returns route based on id', () => { + const server = Hapi.server(); + let log = null; + server.events.once('log', (event, tags) => { + + log = [event, tags]; + }); + + await server.register([internals.plugins.test1, internals.plugins.test2]); + expect(internals.routesList(server)).to.equal(['/test1', '/test2']); + expect(log[1].test).to.equal(true); + expect(log[0].data).to.equal('abc'); + }); + + it('registers multiple plugins (verbose)', async () => { const server = Hapi.server(); - server.route({ - method: 'GET', - path: '/', - options: { - handler: () => null, - id: 'root', - app: { test: 123 } - } + let log = null; + server.events.once('log', (event, tags) => { + + log = [event, tags]; }); - const root = server.lookup('root'); - expect(root.path).to.equal('/'); - expect(root.settings.app.test).to.equal(123); + await server.register([{ plugin: internals.plugins.test1 }, { plugin: internals.plugins.test2 }]); + expect(internals.routesList(server)).to.equal(['/test1', '/test2']); + expect(log[1].test).to.equal(true); + expect(log[0].data).to.equal('abc'); }); - it('returns null on unknown route', () => { + it('registers a child plugin', async () => { const server = Hapi.server(); - const root = server.lookup('root'); - expect(root).to.be.null(); + await server.register(internals.plugins.child); + const res = await server.inject('/test1'); + expect(res.result).to.equal('testing123'); }); - it('throws on missing id', () => { + it('registers a plugin with routes path prefix', async () => { const server = Hapi.server(); - expect(() => { + await server.register(internals.plugins.test1, { routes: { prefix: '/xyz' } }); - server.lookup(); - }).to.throw('Invalid route id: '); + expect(server.plugins.test1.prefix).to.equal('/xyz'); + const res = await server.inject('/xyz/test1'); + expect(res.result).to.equal('testing123'); }); - }); - - describe('match()', () => { - it('returns route based on path', () => { + it('registers a plugin with routes path prefix (plugin options)', async () => { const server = Hapi.server(); + await server.register({ plugin: internals.plugins.test1, routes: { prefix: '/abc' } }, { routes: { prefix: '/xyz' } }); - server.route({ - method: 'GET', - path: '/', - options: { - handler: () => null, - id: 'root' - } - }); + expect(server.plugins.test1.prefix).to.equal('/abc'); + const res = await server.inject('/abc/test1'); + expect(res.result).to.equal('testing123'); + }); - server.route({ - method: 'GET', - path: '/abc', - options: { - handler: () => null, - id: 'abc' - } - }); + it('register a plugin once (plugin options)', async () => { - server.route({ - method: 'POST', - path: '/abc', - options: { - handler: () => null, - id: 'post' - } - }); + let count = 0; + const b = { + name: 'b', + register: function (srv, options) { - server.route({ - method: 'GET', - path: '/{p}/{x}', - options: { - handler: () => null, - id: 'params' + ++count; } - }); + }; - server.route({ - method: 'GET', - path: '/abc', - vhost: 'example.com', - options: { - handler: () => null, - id: 'vhost' + const a = { + name: 'a', + register: async function (srv, options) { + + await srv.register({ plugin: b, once: true }); } - }); + }; - expect(server.match('GET', '/').settings.id).to.equal('root'); - expect(server.match('GET', '/none')).to.equal(null); - expect(server.match('GET', '/abc').settings.id).to.equal('abc'); - expect(server.match('get', '/').settings.id).to.equal('root'); - expect(server.match('post', '/abc').settings.id).to.equal('post'); - expect(server.match('get', '/a/b').settings.id).to.equal('params'); - expect(server.match('GET', '/abc', 'example.com').settings.id).to.equal('vhost'); + const server = Hapi.server(); + await server.register(b); + await server.register(a); + await server.initialize(); + expect(count).to.equal(1); }); - it('throws on missing method', () => { + it('registers plugins and adds options to realm that routes can access', async () => { + + const server = Hapi.server(); + + const foo = { + name: 'foo', + register: function (srv, options) { + + expect(options.something).to.be.true(); + expect(srv.realm.pluginOptions).to.equal(options); + + srv.route({ + method: 'GET', path: '/foo', handler: (request, h) => { + + expect(request.route.realm.pluginOptions).to.equal(options); + expect(h.realm.pluginOptions).to.equal(options); + return 'foo'; + } + }); + } + }; + + const bar = { + name: 'bar', + register: function (srv, options) { + + expect(options.something).to.be.false(); + expect(srv.realm.pluginOptions).to.equal(options); + + srv.route({ + method: 'GET', path: '/bar', handler: (request, h) => { + + expect(request.route.realm.pluginOptions).to.equal(options); + expect(h.realm.pluginOptions).to.equal(options); + return 'bar'; + } + }); + } + }; + + const plugins = [ + { plugin: foo, options: { something: true } }, + { plugin: bar, options: { something: false } } + ]; + + await server.register(plugins); + + const res1 = await server.inject('/foo'); + expect(res1.result).to.equal('foo'); + + const res2 = await server.inject('/bar'); + expect(res2.result).to.equal('bar'); + }); + + it('registers a plugin with routes path prefix and plugin root route', async () => { + + const test = { + name: 'test', + register: function (srv, options) { + + srv.route({ + method: 'GET', + path: '/', + handler: () => 'ok' + }); + } + }; + + const server = Hapi.server(); + await server.register(test, { routes: { prefix: '/xyz' } }); + + const res = await server.inject('/xyz'); + expect(res.result).to.equal('ok'); + }); + + it('ignores the type of the plugin value', async () => { + + const a = function () { }; + a.plugin = { + name: 'a', + register: function (srv, options) { + + srv.route({ + method: 'GET', + path: '/', + handler: () => 'ok' + }); + } + }; + + const server = Hapi.server(); + await server.register(a, { routes: { prefix: '/xyz' } }); + + const res = await server.inject('/xyz'); + expect(res.result).to.equal('ok'); + }); + + it('ignores unknown plugin properties', async () => { + + const a = { + name: 'a', + register: function (srv, options) { + + srv.route({ + method: 'GET', + path: '/', + handler: () => 'ok' + }); + }, + other: {} + }; + + const server = Hapi.server(); + await server.register(a); + }); + + it('ignores unknown plugin properties (with options)', async () => { + + const a = { + name: 'a', + register: function (srv, options) { + + srv.route({ + method: 'GET', + path: '/', + handler: () => 'ok' + }); + }, + other: {} + }; + + const server = Hapi.server(); + await server.register({ plugin: a }); + }); + + it('registers a child plugin with parent routes path prefix', async () => { + + const server = Hapi.server(); + await server.register(internals.plugins.child, { routes: { prefix: '/xyz' } }); + + const res = await server.inject('/xyz/test1'); + expect(res.result).to.equal('testing123'); + }); + + it('registers a child plugin with parent routes vhost prefix', async () => { + + const server = Hapi.server(); + await server.register(internals.plugins.child, { routes: { vhost: 'example.com' } }); + + const res = await server.inject({ url: '/test1', headers: { host: 'example.com' } }); + expect(res.result).to.equal('testing123'); + }); + + it('registers a child plugin with parent routes path prefix and inner register prefix', async () => { + + const server = Hapi.server(); + await server.register({ plugin: internals.plugins.child, options: { routes: { prefix: '/inner' } } }, { routes: { prefix: '/xyz' } }); + + const res = await server.inject('/xyz/inner/test1'); + expect(res.result).to.equal('testing123'); + }); + + it('registers a child plugin with parent routes vhost prefix and inner register vhost', async () => { + + const server = Hapi.server(); + await server.register({ plugin: internals.plugins.child, options: { routes: { vhost: 'example.net' } } }, { routes: { vhost: 'example.com' } }); + + const res = await server.inject({ url: '/test1', headers: { host: 'example.com' } }); + expect(res.result).to.equal('testing123'); + }); + + it('registers a plugin with routes vhost', async () => { + + const server = Hapi.server(); + await server.register(internals.plugins.test1, { routes: { vhost: 'example.com' } }); + + const res1 = await server.inject('/test1'); + expect(res1.statusCode).to.equal(404); + + const res2 = await server.inject({ url: '/test1', headers: { host: 'example.com' } }); + expect(res2.result).to.equal('testing123'); + }); + + it('registers a plugin with routes vhost (plugin options)', async () => { + + const server = Hapi.server(); + await server.register({ plugin: internals.plugins.test1, routes: { vhost: 'example.org' } }, { routes: { vhost: 'example.com' } }); + + const res1 = await server.inject('/test1'); + expect(res1.statusCode).to.equal(404); + + const res2 = await server.inject({ url: '/test1', headers: { host: 'example.org' } }); + expect(res2.result).to.equal('testing123'); + }); + + it('sets multiple dependencies in one statement', async () => { + + const a = { + name: 'a', + register: function (srv, options) { + + srv.dependency(['b', 'c']); + } + }; + + const b = { + name: 'b', + register: Hoek.ignore + }; + + const c = { + name: 'c', + register: Hoek.ignore + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(c); + await server.register(a); + await server.initialize(); + }); + + it('sets multiple dependencies in one statement (versioned)', async () => { + + const a = { + name: 'a', + version: '0.1.2', + register: function (srv, options) { + + srv.dependency({ + b: '1.x.x', + c: '2.x.x' + }); + } + }; + + const b = { + name: 'b', + version: '1.2.3', + register: Hoek.ignore + }; + + const c = { + name: 'c', + version: '2.3.4', + register: Hoek.ignore + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(c); + await server.register(a); + await server.initialize(); + }); + + it('sets multiple dependencies in plugin (versioned)', async () => { + + const a = { + name: 'a', + version: '0.1.2', + dependencies: { + b: '1.x.x', + c: '2.x.x' + }, + register: Hoek.ignore + }; + + const b = { + name: 'b', + version: '1.2.3', + register: Hoek.ignore + }; + + const c = { + name: 'c', + version: '2.3.4', + register: Hoek.ignore + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(c); + await server.register(a); + await server.initialize(); + }); + + it('sets multiple dependencies in plugin', async () => { + + const a = { + name: 'a', + dependencies: ['b', 'c'], + register: Hoek.ignore + }; + + const b = { + name: 'b', + register: Hoek.ignore + }; + + const c = { + name: 'c', + register: Hoek.ignore + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(c); + await server.register(a); + await server.initialize(); + }); + + it('sets multiple dependencies in multiple statements', async () => { + + const a = { + name: 'a', + register: function (srv, options) { + + srv.dependency('b'); + srv.dependency('c'); + } + }; + + const b = { + name: 'b', + register: Hoek.ignore + }; + + const c = { + name: 'c', + register: Hoek.ignore + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(c); + await server.register(a); + await server.initialize(); + }); + + it('sets multiple dependencies in multiple locations', async () => { + + const a = { + name: 'a', + dependencies: 'c', + register: function (srv, options) { + + srv.dependency('b'); + } + }; + + const b = { + name: 'b', + register: Hoek.ignore + }; + + const c = { + name: 'c', + register: Hoek.ignore + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(c); + await server.register(a); + await server.initialize(); + }); + + it('register a plugin once per server', async () => { + + let count = 0; + const b = { + name: 'b', + register: function (srv, options) { + + ++count; + } + }; + + const a = { + name: 'a', + register: function (srv, options) { + + return srv.register(b, { once: true }); + } + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(a); + await server.initialize(); + expect(count).to.equal(1); + }); + + it('register a plugin once (plugin)', async () => { + + let count = 0; + const b = { + name: 'b', + once: true, + register: function (srv, options) { + + ++count; + } + }; + + const a = { + name: 'a', + register: function (srv, options) { + + return srv.register(b); + } + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(a); + await server.initialize(); + expect(count).to.equal(1); + }); + + it('throws when once used with plugin options', async () => { + + const a = { + name: 'a', + register: Hoek.ignore + }; + + const server = Hapi.server(); + await expect(server.register({ plugin: a, options: {}, once: true })).to.reject(); + }); + + it('throws when once is false', async () => { + + const b = { + name: 'b', + register: Hoek.ignore + }; + + const a = { + name: 'a', + register: function (srv, options) { + + return srv.register(b); + } + }; + + const server = Hapi.server(); + await server.register(b); + await expect(server.register(a)).to.reject(Error, 'Plugin b already registered'); + }); + + it('throws when dependencies is an object', async () => { + + const a = { + name: 'a', + dependencies: { b: true }, + register: Hoek.ignore + }; + + const server = Hapi.server(); + await expect(server.register(a)).to.reject(); + }); + + it('throws when dependencies contain something else than a string', async () => { + + const a = { + name: 'a', + dependencies: [true], + register: Hoek.ignore + }; const server = Hapi.server(); - expect(() => { - - server.match(); - }).to.throw('Invalid method: '); + await expect(server.register(a)).to.reject(); }); - it('throws on invalid method', () => { + it('exposes server decorations to next register', async () => { const server = Hapi.server(); - expect(() => { - server.match(5); - }).to.throw('Invalid method: 5'); - }); - - it('throws on missing path', () => { + const b = { + name: 'b', + register: function (srv, options) { - const server = Hapi.server(); - expect(() => { + if (typeof srv.a !== 'function') { + throw new Error('Missing decoration'); + } + } + }; - server.match('get'); - }).to.throw('Invalid path: '); - }); + const a = { + name: 'a', + register: function (srv, options) { - it('throws on invalid path type', () => { + srv.decorate('server', 'a', () => { - const server = Hapi.server(); - expect(() => { + return 'a'; + }); + } + }; - server.match('get', 5); - }).to.throw('Invalid path: 5'); + await server.register([a, b]); + await server.initialize(); }); - it('throws on invalid path prefix', () => { + it('exposes server decorations to dependency (dependency first)', async () => { const server = Hapi.server(); - expect(() => { - server.match('get', '5'); - }).to.throw('Invalid path: 5'); - }); + const a = { + name: 'a', + register: function (srv, options) { - it('throws on invalid path', () => { + srv.decorate('server', 'a', () => { - const server = Hapi.server(); - server.route({ - method: 'GET', - path: '/{p}', - handler: () => null - }); + return 'a'; + }); + } + }; - expect(() => { + const b = { + name: 'b', + register: function (srv, options) { - server.match('GET', '/%p'); - }).to.throw('Invalid path: /%p'); - }); + const after = function (srv2) { - it('throws on invalid host type', () => { + if (typeof srv2.a !== 'function') { + throw new Error('Missing decoration'); + } + }; - const server = Hapi.server(); - expect(() => { + srv.dependency('a', after); + } + }; - server.match('get', '/a', 5); - }).to.throw('Invalid host: 5'); + await server.register([a, b]); + await server.initialize(); }); - }); - - describe('method()', () => { - it('adds server method using arguments', async () => { + it('exposes server decorations to dependency (dependency second)', async () => { const server = Hapi.server(); - const test = { - name: 'test', + const a = { + name: 'a', register: function (srv, options) { - const method = function (methodNext) { + srv.decorate('server', 'a', () => 'a'); + } + }; - return methodNext(null); + const b = { + name: 'b', + register: function (srv, options) { + + srv.realm.x = 1; + const after = function (srv2) { + + expect(srv2.realm.x).to.equal(1); + if (typeof srv2.a !== 'function') { + throw new Error('Missing decoration'); + } }; - srv.method('log', method); + srv.dependency('a', after); } }; - await server.register(test); + await server.register([b, a]); + await server.initialize(); }); - it('adds server method with plugin bind', async () => { + it('exposes server decorations to next register when nested', async () => { const server = Hapi.server(); - const test = { - name: 'test', + const a = { + name: 'a', register: function (srv, options) { - srv.bind({ x: 1 }); - const method = function () { + srv.decorate('server', 'a', () => { - return this.x; - }; + return 'a'; + }); + } + }; - srv.method('log', method); + const b = { + name: 'b', + register: async function (srv, options) { + + await srv.register(a); + if (typeof srv.a !== 'function') { + throw new Error('Missing decoration'); + } } }; - await server.register(test); - const result = server.methods.log(); - expect(result).to.equal(1); + await server.register([b]); + await server.initialize(); }); - it('adds server method with method bind', async () => { - - const server = Hapi.server(); + it('validates node version', async () => { const test = { name: 'test', - register: function (srv, options) { + requirements: { + node: '>=8.x.x' + }, + register: function (srv, options) { } + }; - const method = function () { + const server = Hapi.server(); + await expect(server.register(test)).to.not.reject(); + }); - return this.x; - }; + it('validates node version, allowing prereleases', async (flags) => { - srv.method('log', method, { bind: { x: 2 } }); - } + const test = { + name: 'test', + requirements: { + node: '>=8.x.x' + }, + register: function (srv, options) { } }; - await server.register(test); + const origVersion = process.version; + Object.defineProperty(process, 'version', { value: 'v100.0.0-beta' }); + flags.onCleanup = () => Object.defineProperty(process, 'version', { value: origVersion }); - const result = server.methods.log(); - expect(result).to.equal(2); + const server = Hapi.server(); + await expect(server.register(test)).to.not.reject(); }); - it('adds server method with method and ext bind', async () => { + it('errors on invalid node version', async () => { + + const test = { + name: 'test', + requirements: { + node: '4.x.x' + }, + register: function (srv, options) { } + }; const server = Hapi.server(); + await expect(server.register(test)).to.reject(`Plugin test requires node version 4.x.x but found ${process.version}`); + }); + + it('validates hapi version', async () => { const test = { name: 'test', - register: function (srv, options) { + requirements: { + hapi: '>=17.x.x' + }, + register: function (srv, options) { } + }; - srv.bind({ x: 1 }); - const method = function () { + const server = Hapi.server(); + await expect(server.register(test)).to.not.reject(); + }); - return this.x; - }; + it('validates hapi version, allowing prereleases', async () => { - srv.method('log', method, { bind: { x: 2 } }); - } + const test = { + name: 'test', + requirements: { + hapi: '>=17.x.x' + }, + register: function (srv, options) { } }; - await server.register(test); - - const result = server.methods.log(); - expect(result).to.equal(2); + const server = Hapi.server(); + server.version = '100.0.0-beta'; + await expect(server.register(test)).to.not.reject(); }); - }); - - describe('path()', () => { - it('sets local path for directory route handler', async () => { + it('errors on invalid hapi version', async () => { const test = { name: 'test', - register: function (srv, options) { + requirements: { + hapi: '4.x.x' + }, + register: function (srv, options) { } + }; - srv.path(Path.join(__dirname, '..')); + const server = Hapi.server(); + await expect(server.register(test)).to.reject(`Plugin test requires hapi version 4.x.x but found ${Pkg.version}`); + }); - srv.route({ - method: 'GET', - path: '/handler/{file*}', - handler: { - directory: { - path: './' - } - } - }); - } + it('validates plugin version, allowing prereleases', async () => { + + const a = { + name: 'a', + version: '0.1.2', + dependencies: { + b: '>=3.x.x', + c: '>=2.x.x' + }, + register: Hoek.ignore }; - const server = Hapi.server({ routes: { files: { relativeTo: __dirname } } }); - await server.register(Inert); - await server.register(test); + const b = { + name: 'b', + version: '4.0.0-beta', + register: Hoek.ignore + }; - const res = await server.inject('/handler/package.json'); - expect(res.statusCode).to.equal(200); + const c = { + name: 'c', + version: '2.3.4', + register: Hoek.ignore + }; + + const server = Hapi.server(); + await server.register(b); + await server.register(c); + await server.register(a); + await expect(server.initialize()).to.not.reject(); }); - it('throws when plugin sets undefined path', async () => { + it('errors on invalid plugin version', async () => { - const test = { - name: 'test', - register: function (srv, options) { + const a = { + name: 'a', + version: '0.1.2', + dependencies: { + b: '3.x.x', + c: '2.x.x' + }, + register: Hoek.ignore + }; - srv.path(); - } + const b = { + name: 'b', + version: '1.2.3', + register: Hoek.ignore + }; + + const c = { + name: 'c', + version: '2.3.4', + register: Hoek.ignore }; const server = Hapi.server(); - await expect(server.register(test)).to.reject('relativeTo must be a non-empty string'); + await server.register(b); + await server.register(c); + await server.register(a); + await expect(server.initialize()).to.reject('Plugin a requires b version 3.x.x but found 1.2.3'); }); }); diff --git a/test/state.js b/test/state.js index 972397b96..d1b21e00b 100755 --- a/test/state.js +++ b/test/state.js @@ -1,19 +1,14 @@ 'use strict'; -// Load modules - -const Code = require('code'); +const Boom = require('@hapi/boom'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Lab = require('lab'); - +const Lab = require('@hapi/lab'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -27,6 +22,7 @@ describe('state', () => { const res = await server.inject({ method: 'GET', url: '/', headers: { cookie: 'v=a' } }); expect(res.statusCode).to.equal(200); expect(res.result.v).to.equal('a'); + expect(res.headers['set-cookie']).to.not.exist(); }); it('sets a cookie value to a base64json string representation of an object', async () => { @@ -66,7 +62,7 @@ describe('state', () => { server.route({ method: 'GET', path: '/', handler: (request) => request.state }); const res = await server.inject({ method: 'GET', url: '/', headers: { cookie: 'vab' } }); expect(res.statusCode).to.equal(400); - expect(res.headers['set-cookie']).to.not.exists(); + expect(res.headers['set-cookie']).to.not.exist(); }); it('ignores invalid cookies (state level config)', async () => { @@ -114,10 +110,38 @@ describe('state', () => { server.state('a', { ignoreErrors: true, encoding: 'base64json', clearInvalid: true }); server.route({ path: '/', method: 'GET', handler: () => null }); const res = await server.inject({ method: 'GET', url: '/', headers: { cookie: 'a=x' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); + expect(res.headers['set-cookie'][0]).to.equal('a=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly; SameSite=Strict'); + }); + + it('clears invalid cookies on error 500 response', async () => { + + const server = Hapi.server(); + server.state('a', { ignoreErrors: true, encoding: 'base64json', clearInvalid: true }); + server.route({ path: '/', method: 'GET', handler: () => { + + throw new Error('Fail'); + } }); + const res = await server.inject({ method: 'GET', url: '/', headers: { cookie: 'a=x' } }); + expect(res.statusCode).to.equal(500); expect(res.headers['set-cookie'][0]).to.equal('a=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; HttpOnly; SameSite=Strict'); }); + it('does not clear unregistered cookie on error 500 response', async () => { + + const server = Hapi.server(); + server.route({ + path: '/', method: 'GET', handler: (request, h) => { + + h.unstate('a'); + throw new Error('Fail'); + } + }); + const res = await server.inject({ method: 'GET', url: '/', headers: { cookie: 'a=x' } }); + expect(res.statusCode).to.equal(500); + expect(res.headers['set-cookie']).to.not.exist(); + }); + it('sets cookie value automatically', async () => { const server = Hapi.server(); @@ -129,6 +153,28 @@ describe('state', () => { expect(res.headers['set-cookie']).to.equal(['always=present; Secure; HttpOnly; SameSite=Strict']); }); + it('does not set cookie value automatically when set by the handler', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response('ok').state('always', 'from-handler') }); + server.state('always', { autoValue: 'present' }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.headers['set-cookie']).to.equal(['always=from-handler; Secure; HttpOnly; SameSite=Strict']); + }); + + it('does not set cookie value automatically when cookie received from the client', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => 'ok' }); + server.state('always', { autoValue: 'present' }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { cookie: 'always=from-client' } }); + expect(res.statusCode).to.equal(200); + expect(res.headers['set-cookie']).to.not.exist(); + }); + it('appends handler set-cookie to server state', async () => { const server = Hapi.server(); @@ -136,7 +182,7 @@ describe('state', () => { server.state('always', { autoValue: 'present' }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); expect(res.headers['set-cookie']).to.equal(['onecookie=yes', 'twocookie=no', 'always=present; Secure; HttpOnly; SameSite=Strict']); }); @@ -144,18 +190,31 @@ describe('state', () => { const server = Hapi.server(); server.route({ method: 'GET', path: '/{x}', handler: () => 'ok' }); - server.state('always', { autoValue: (request) => request.params.x }); + server.state('always', { autoValue: (request) => Promise.resolve(request.params.x) }); const res = await server.inject('/sweet'); expect(res.statusCode).to.equal(200); expect(res.headers['set-cookie']).to.equal(['always=sweet; Secure; HttpOnly; SameSite=Strict']); }); - it('fails to set cookie value automatically using function', async () => { + it('does not respond with automatic value when state parsing is disabled', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => 'ok', options: { state: { parse: false } } }); + server.state('always', { autoValue: 'present' }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.headers['set-cookie']).to.not.exist(); + }); + + it('returns error 500 response when automatic value throws', async () => { + let called = 0; const present = (request) => { - throw new Error(); + ++called; + throw Boom.forbidden(); }; const server = Hapi.server(); @@ -165,6 +224,49 @@ describe('state', () => { const res = await server.inject('/'); expect(res.statusCode).to.equal(500); expect(res.headers['set-cookie']).to.not.exist(); + expect(called).to.equal(1); + }); + + it('handles automatic value throw with non-Error', async () => { + + let called = 0; + const present = (request) => { + + ++called; + throw 'fail'; + }; + + const server = Hapi.server({ debug: false }); + server.route({ method: 'GET', path: '/', handler: () => 'ok' }); + server.state('always', { autoValue: present }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + expect(res.headers['set-cookie']).to.not.exist(); + expect(res.request.response._error).to.be.an.error('A non-Error value was thrown'); + expect(called).to.equal(1); + }); + + it('does not send autoValue cookie on error 500 response', async () => { + + let called = 0; + const present = (request) => { + + ++called; + return 'present'; + }; + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => { + + throw new Error('Fail'); + } }); + server.state('always', { autoValue: present }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + expect(res.headers['set-cookie']).to.not.exist(); + expect(called).to.equal(0); }); it('sets cookie value with null ttl', async () => { @@ -177,4 +279,59 @@ describe('state', () => { expect(res.statusCode).to.equal(200); expect(res.headers['set-cookie']).to.equal(['a=b; Secure; HttpOnly; SameSite=Strict']); }); + + it('sets cookie value based on request', async () => { + + const server = Hapi.server(); + + const contextualize = (definition, request) => { + + definition.isSameSite = request.query.x; + definition.isSecure = false; + }; + + server.state('a', { contextualize }); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response('ok').state('a', 'b') }); + + const res = await server.inject('/?x=TEST'); + expect(res.statusCode).to.equal(200); + expect(res.headers['set-cookie']).to.equal(['a=b; HttpOnly; SameSite=TEST']); + }); + + it('sets cookie partitioned value', async () => { + + const server = Hapi.server(); + + server.state('a', { isPartitioned: true, isSameSite: 'None' }); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response('ok').state('a', 'b') }); + + const res = await server.inject('/?x=TEST'); + expect(res.statusCode).to.equal(200); + expect(res.headers['set-cookie']).to.equal(['a=b; Secure; HttpOnly; SameSite=None; Partitioned']); + }); + + it('fails to set cookie partitioned value without isSameSite=None', async () => { + + const server = Hapi.server({ debug: false }); + + server.state('a', { isPartitioned: true }); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response('ok').state('a', 'b') }); + + const res = await server.inject('/?x=TEST'); + expect(res.statusCode).to.equal(500); + expect(res.request.response._error).to.be.an.error('Partitioned cookies must have SameSite=None'); + }); + + it('returns error 500 for invalid cookie values', async () => { + + const server = Hapi.server({ debug: false }); + + server.state('a', { encoding: 'none' }); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response('ok').state('a', 'тест') }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + expect(res.headers['set-cookie']).to.not.exist(); + expect(res.request.response._error).to.be.an.error(TypeError); + }); }); diff --git a/test/toolkit.js b/test/toolkit.js index 7eede8b84..399ddde28 100755 --- a/test/toolkit.js +++ b/test/toolkit.js @@ -1,26 +1,20 @@ 'use strict'; -// Load modules - const Path = require('path'); const Stream = require('stream'); -const Code = require('code'); +const Code = require('@hapi/code'); const Handlebars = require('handlebars'); const Hapi = require('..'); -const Inert = require('inert'); -const Lab = require('lab'); -const Teamwork = require('teamwork'); -const Vision = require('vision'); - +const Inert = require('@hapi/inert'); +const Lab = require('@hapi/lab'); +const Teamwork = require('@hapi/teamwork'); +const Vision = require('@hapi/vision'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; @@ -54,13 +48,13 @@ describe('Toolkit', () => { await server.register(Inert); const handler = (request, h) => { - return h.file('./package.json').code(499); + return h.file('./package.json').code(999); }; server.route({ method: 'GET', path: '/file', handler }); const res = await server.inject('/file'); - expect(res.statusCode).to.equal(499); + expect(res.statusCode).to.equal(999); expect(res.payload).to.contain('hapi'); expect(res.headers['content-type']).to.equal('application/json; charset=utf-8'); expect(res.headers['content-length']).to.exist(); @@ -106,7 +100,7 @@ describe('Toolkit', () => { it('includes method name when method missing return', async () => { - const team = new Teamwork(); + const team = new Teamwork.Team(); const myErrorHandler = () => {}; const server = Hapi.server({ debug: false }); @@ -173,7 +167,7 @@ describe('Toolkit', () => { const server = Hapi.server(); server.route({ method: 'GET', path: '/', handler: (request, h) => h.response() }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); expect(res.result).to.equal(null); expect(res.payload).to.equal(''); expect(res.headers['content-type']).to.not.exist(); @@ -264,6 +258,7 @@ describe('Toolkit', () => { if (this.isDone) { return; } + this.isDone = true; this.push('x'); @@ -294,10 +289,11 @@ describe('Toolkit', () => { describe('abandon', () => { - it('abandon request with manual response', async () => { + it('abandon request with manual response (handler)', async () => { const handler = (request, h) => { + request.raw.res.setHeader('content-type', 'text/plain'); request.raw.res.end('manual'); return h.abandon; }; @@ -308,6 +304,54 @@ describe('Toolkit', () => { const res = await server.inject('/'); expect(res.result).to.equal('manual'); }); + + it('abandon request with manual response (onRequest)', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => null }); + + server.ext('onRequest', (request, h) => { + + request.raw.res.setHeader('content-type', 'text/plain'); + request.raw.res.end('manual'); + return h.abandon; + }); + + const res = await server.inject('/'); + expect(res.result).to.equal('manual'); + }); + + it('abandon request with manual response (lifecycle)', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => null }); + + server.ext('onPreHandler', (request, h) => { + + request.raw.res.setHeader('content-type', 'text/plain'); + request.raw.res.end('manual'); + return h.abandon; + }); + + const res = await server.inject('/'); + expect(res.result).to.equal('manual'); + }); + + it('abandon request with manual response (post cycle)', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => null }); + + server.ext('onPreResponse', (request, h) => { + + request.raw.res.setHeader('content-type', 'text/plain'); + request.raw.res.end('manual'); + return h.abandon; + }); + + const res = await server.inject('/'); + expect(res.result).to.equal('manual'); + }); }); describe('close', () => { @@ -354,7 +398,7 @@ describe('Toolkit', () => { server.route({ method: 'GET', path: '/', handler }); const res = await server.inject('/'); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); expect(res.result).to.equal(null); expect(res.payload).to.equal(''); }); @@ -486,6 +530,26 @@ describe('Toolkit', () => { expect(count).to.equal(1); }); + it('does not override manual last-modified header', async () => { + + const server = Hapi.server(); + + server.route({ + method: 'GET', + path: '/', + handler: (request, h) => { + + h.entity({ modified: 1200 }); + return h.response('ok').header('last-modified', 999); + } + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal('ok'); + expect(res.headers['last-modified']).to.equal(999); + }); + it('returns a 304 when the request has if-none-match', async () => { const server = Hapi.server(); @@ -521,6 +585,12 @@ describe('Toolkit', () => { expect(res2.headers.etag).to.equal('"abc"'); expect(res2.headers['cache-control']).to.equal('max-age=5, must-revalidate'); expect(count).to.equal(1); + + const res3 = await server.inject({ url: '/', headers: { 'if-none-match': 'W/"abc"' } }); + expect(res3.statusCode).to.equal(304); + expect(res3.headers.etag).to.equal('W/"abc"'); + expect(res3.headers['cache-control']).to.equal('max-age=5, must-revalidate'); + expect(count).to.equal(1); }); it('leaves etag header when vary is false', async () => { diff --git a/test/transmit.js b/test/transmit.js index 4ad9a4362..b6f1fd7cd 100755 --- a/test/transmit.js +++ b/test/transmit.js @@ -1,38 +1,62 @@ 'use strict'; -// Load modules - const ChildProcess = require('child_process'); const Fs = require('fs'); const Http = require('http'); +const Net = require('net'); const Path = require('path'); const Stream = require('stream'); const Zlib = require('zlib'); +const Events = require('events'); -const Boom = require('boom'); -const Bounce = require('bounce'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Hoek = require('hoek'); -const Inert = require('inert'); -const Lab = require('lab'); -const Teamwork = require('teamwork'); -const Wreck = require('wreck'); - +const Hoek = require('@hapi/hoek'); +const Bounce = require('@hapi/bounce'); +const Inert = require('@hapi/inert'); +const Lab = require('@hapi/lab'); +const Teamwork = require('@hapi/teamwork'); +const Wreck = require('@hapi/wreck'); -// Declare internals +const Common = require('./common'); const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('transmission', () => { + describe('send()', () => { + + it('handlers invalid headers in error', async () => { + + const server = Hapi.server(); + + const handler = (request, h) => { + + const error = Boom.badRequest(); + error.output.headers.invalid = '\u1000'; + throw error; + }; + + server.route({ method: 'GET', path: '/', handler }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + + it('handles invalid headers in redirect', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.redirect('/bad/path/\n') }); + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + }); + }); + describe('marshal()', () => { it('returns valid http date responses in last-modified header', async () => { @@ -65,7 +89,7 @@ describe('transmission', () => { expect(res.statusCode).to.equal(200); }); - it('closes file handlers when not reading file stream', { skip: process.platform === 'win32' }, async () => { + it('closes file handlers when not reading file stream', { skip: !Common.hasLsof }, async () => { const server = Hapi.server(); await server.register(Inert); @@ -80,12 +104,6 @@ describe('transmission', () => { const cmd = ChildProcess.spawn('lsof', ['-p', process.pid]); let lsof = ''; - cmd.on('error', (err) => { - - // Allow the test to pass on platforms with no lsof - Bounce.ignore(err, { errno: 'ENOENT' }); - }); - cmd.stdout.on('data', (buffer) => { lsof += buffer.toString(); @@ -107,7 +125,7 @@ describe('transmission', () => { }); }); - it('closes file handlers when not using a manually open file stream', { skip: process.platform === 'win32' }, async () => { + it('closes file handlers when not using a manually open file stream', { skip: !Common.hasLsof }, async () => { const server = Hapi.server(); server.route({ method: 'GET', path: '/file', handler: (request, h) => h.response(Fs.createReadStream(__dirname + '/../package.json')).header('etag', 'abc') }); @@ -121,12 +139,6 @@ describe('transmission', () => { const cmd = ChildProcess.spawn('lsof', ['-p', process.pid]); let lsof = ''; - cmd.on('error', (err) => { - - // Allow the test to pass on platforms with no lsof - Bounce.ignore(err, { errno: 'ENOENT' }); - }); - cmd.stdout.on('data', (buffer) => { lsof += buffer.toString(); @@ -177,62 +189,94 @@ describe('transmission', () => { expect(res2.headers['last-modified']).to.exist(); }); + it('returns a 200 when the request has if-modified-since and the response has been modified since (less)', async () => { + + const server = Hapi.server(); + await server.register(Inert); + server.route({ method: 'GET', path: '/file', handler: { file: __dirname + '/../package.json' } }); + + const res1 = await server.inject('/file'); + const last = new Date(Date.parse(res1.headers['last-modified']) - 1000); + const res2 = await server.inject({ url: '/file', headers: { 'if-modified-since': last.toUTCString() } }); + expect(res2.statusCode).to.equal(200); + expect(res2.headers['content-length']).to.exist(); + expect(res2.headers.etag).to.exist(); + expect(res2.headers['last-modified']).to.exist(); + }); + it('matches etag with content-encoding', async () => { const server = Hapi.server({ compression: { minBytes: 1 } }); await server.register(Inert); server.route({ method: 'GET', path: '/', handler: { file: __dirname + '/../package.json' } }); - // Initial request - no etag + // Request const res1 = await server.inject('/'); expect(res1.statusCode).to.equal(200); + expect(res1.headers.etag).to.exist(); + expect(res1.headers.etag).to.not.contain('-'); - // Second request - etag - - const res2 = await server.inject('/'); - expect(res2.statusCode).to.equal(200); - expect(res2.headers.etag).to.exist(); - expect(res2.headers.etag).to.not.contain('-'); - - const baseTag = res2.headers.etag.slice(0, -1); + const baseTag = res1.headers.etag.slice(0, -1); const gzipTag = baseTag + '-gzip"'; // Conditional request - const res3 = await server.inject({ url: '/', headers: { 'if-none-match': res2.headers.etag } }); - expect(res3.statusCode).to.equal(304); - expect(res3.headers.etag).to.equal(res2.headers.etag); + const res2 = await server.inject({ url: '/', headers: { 'if-none-match': res1.headers.etag } }); + expect(res2.statusCode).to.equal(304); + expect(res2.headers.etag).to.equal(res1.headers.etag); // Conditional request with accept-encoding - const res4 = await server.inject({ url: '/', headers: { 'if-none-match': res2.headers.etag, 'accept-encoding': 'gzip' } }); - expect(res4.statusCode).to.equal(304); - expect(res4.headers.etag).to.equal(gzipTag); + const res3 = await server.inject({ url: '/', headers: { 'if-none-match': res1.headers.etag, 'accept-encoding': 'gzip' } }); + expect(res3.statusCode).to.equal(304); + expect(res3.headers.etag).to.equal(gzipTag); // Conditional request with vary etag - const res5 = await server.inject({ url: '/', headers: { 'if-none-match': res4.headers.etag, 'accept-encoding': 'gzip' } }); - expect(res5.statusCode).to.equal(304); - expect(res5.headers.etag).to.equal(gzipTag); + const res4 = await server.inject({ url: '/', headers: { 'if-none-match': res3.headers.etag, 'accept-encoding': 'gzip' } }); + expect(res4.statusCode).to.equal(304); + expect(res4.headers.etag).to.equal(gzipTag); // Request with accept-encoding (gzip) - const res6 = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); - expect(res6.statusCode).to.equal(200); - expect(res6.headers.etag).to.equal(gzipTag); + const res5 = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); + expect(res5.statusCode).to.equal(200); + expect(res5.headers.etag).to.equal(gzipTag); // Request with accept-encoding (deflate) - const res7 = await server.inject({ url: '/', headers: { 'accept-encoding': 'deflate' } }); - expect(res7.statusCode).to.equal(200); - expect(res7.headers.etag).to.equal(baseTag + '-deflate"'); + const res6 = await server.inject({ url: '/', headers: { 'accept-encoding': 'deflate' } }); + expect(res6.statusCode).to.equal(200); + expect(res6.headers.etag).to.equal(baseTag + '-deflate"'); // Conditional request with accept-encoding (gzip) - const res8 = await server.inject({ url: '/', headers: { 'if-none-match': res7.headers.etag, 'accept-encoding': 'gzip' } }); - expect(res8.statusCode).to.equal(304); - expect(res8.headers.etag).to.equal(gzipTag); + const res7 = await server.inject({ url: '/', headers: { 'if-none-match': res6.headers.etag, 'accept-encoding': 'gzip' } }); + expect(res7.statusCode).to.equal(304); + expect(res7.headers.etag).to.equal(gzipTag); + }); + + it('matches etag with weak designator', async () => { + + const server = Hapi.server({ compression: { minBytes: 1 } }); + await server.register(Inert); + server.route({ method: 'GET', path: '/', handler: { file: __dirname + '/../package.json' } }); + + // Fetch etag + + const res1 = await server.inject('/'); + expect(res1.statusCode).to.equal(200); + expect(res1.headers.etag).to.exist(); + expect(res1.headers.etag).to.not.contain('W/"'); + + const weakEtag = `W/${res1.headers.etag}`; + + // Conditional request + + const res2 = await server.inject({ url: '/', headers: { 'if-none-match': weakEtag } }); + expect(res2.statusCode).to.equal(304); + expect(res2.headers.etag).to.equal(weakEtag); }); it('returns 304 when manually set to 304', async () => { @@ -261,6 +305,7 @@ describe('transmission', () => { if (this.isDone) { return; } + this.isDone = true; this.push('hello'); @@ -296,6 +341,7 @@ describe('transmission', () => { if (this.isDone) { return; } + this.isDone = true; this.push('hello'); @@ -313,138 +359,6 @@ describe('transmission', () => { expect(res.statusCode).to.equal(201); }); - it('returns an JSONP response', async () => { - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => ({ some: 'value' }) } }); - - const res = await server.inject('/?callback=me'); - expect(res.payload).to.equal('/**/me({"some":"value"});'); - expect(res.headers['content-length']).to.equal(25); - expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8'); - }); - - it('returns an JSONP response with no payload', async () => { - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => null } }); - - const res = await server.inject('/?callback=me'); - expect(res.payload).to.equal('/**/me();'); - expect(res.headers['content-length']).to.equal(9); - expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8'); - }); - - it('returns an JSONP response (no charset)', async () => { - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: (request, h) => h.response({ some: 'value' }).charset('') } }); - - const res = await server.inject('/?callback=me'); - expect(res.payload).to.equal('/**/me({"some":"value"});'); - expect(res.headers['content-length']).to.equal(25); - expect(res.headers['content-type']).to.equal('text/javascript'); - }); - - it('returns a X-Content-Type-Options: nosniff header on JSONP responses', async () => { - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => ({ some: 'value' }) } }); - - const res = await server.inject('/?callback=me'); - expect(res.payload).to.equal('/**/me({"some":"value"});'); - expect(res.headers['x-content-type-options']).to.equal('nosniff'); - }); - - it('returns a normal response when JSONP enabled but not requested', async () => { - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => ({ some: 'value' }) } }); - - const res = await server.inject('/'); - expect(res.payload).to.equal('{"some":"value"}'); - }); - - it('returns an JSONP response with compression', async () => { - - const server = Hapi.server({ compression: { minBytes: 1 } }); - server.route({ - method: 'GET', - path: '/user/{name*2}', - options: { - handler: (request) => { - - const parts = request.params.name.split('/'); - return { first: parts[0], last: parts[1] }; - }, - jsonp: 'callback' - } - }); - - const res = await server.inject({ url: '/user/1/2?callback=docall', headers: { 'accept-encoding': 'gzip' } }); - expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8'); - expect(res.headers['content-encoding']).to.equal('gzip'); - expect(res.headers.vary).to.equal('accept-encoding'); - - const uncompressed = await internals.uncompress('unzip', res.rawPayload); - expect(uncompressed.toString()).to.equal('/**/docall({"first":"1","last":"2"});'); - }); - - it('returns an JSONP response when response is a buffer', async () => { - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => Buffer.from('value') } }); - - const res = await server.inject('/?callback=me'); - expect(res.payload).to.equal('/**/me(value);'); - expect(res.headers['content-length']).to.equal(14); - }); - - it('returns response on bad JSONP parameter', async () => { - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => ({ some: 'value' }) } }); - - const res = await server.inject('/?callback=me*'); - expect(res.result).to.exist(); - expect(res.result.message).to.equal('Invalid JSONP parameter value'); - }); - - it('returns an JSONP handler error', async () => { - - const handler = () => { - - throw Boom.badRequest('wrong'); - }; - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler } }); - - const res = await server.inject('/?callback=me'); - expect(res.payload).to.equal('/**/me({"statusCode":400,"error":"Bad Request","message":"wrong"});'); - expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8'); - }); - - it('returns an JSONP state error', async () => { - - const server = Hapi.server(); - server.route({ method: 'GET', path: '/', options: { jsonp: 'callback', handler: () => 'ok' } }); - - let validState = false; - const preResponse = (request, h) => { - - validState = request.state && typeof request.state === 'object'; - return h.continue; - }; - - server.ext('onPreResponse', preResponse); - - const res = await server.inject({ method: 'GET', url: '/?callback=me', headers: { cookie: '+' } }); - expect(res.payload).to.equal('/**/me({"statusCode":400,"error":"Bad Request","message":"Invalid cookie header"});'); - expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8'); - expect(validState).to.equal(true); - }); - it('sets specific caching headers', async () => { const server = Hapi.server(); @@ -477,6 +391,52 @@ describe('transmission', () => { expect(res.statusCode).to.equal(200); expect(res.headers['cache-control']).to.be.undefined(); }); + + it('does not crash when request is aborted', async () => { + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: () => 'ok' }); + + const team = new Teamwork.Team(); + const onRequest = (request, h) => { + + request.events.once('disconnect', () => team.attend()); + return h.continue; + }; + + server.ext('onRequest', onRequest); + + // Use state autoValue function to intercept marshal stage + + server.state('always', { + async autoValue(request) { + + const close = new Teamwork.Team(); + request.raw.res.once('close', () => close.attend()); + + // Will trigger abort then close. Prior to node v15.7.0 the res close came + // asynchronously after req abort, but since then it comes in the same tick. + client.destroy(); + await close.work; + + return team.work; // Continue marshalling once the request has been aborted and response closed. + } + }); + + await server.start(); + + const log = server.events.once('response'); + const client = Net.connect(server.info.port, () => { + + client.write('GET / HTTP/1.1\r\nHost: host\r\naccept-encoding: gzip\r\n\r\n'); + }); + + const [request] = await log; + expect(request.response.isBoom).to.be.true(); + expect(request.response.output.statusCode).to.equal(499); + expect(request.info.completed).to.be.above(0); + expect(request.info.responded).to.equal(0); + }); }); describe('transmit()', () => { @@ -492,16 +452,33 @@ describe('transmission', () => { it('sends 204 on empty payload', async () => { - const server = Hapi.server({ routes: { response: { emptyStatusCode: 204 } } }); + const server = Hapi.server(); server.route({ method: 'GET', path: '/', handler: () => null }); const res = await server.inject('/'); expect(res.statusCode).to.equal(204); expect(res.result).to.equal(null); }); + it('overrides emptyStatusCode', async () => { + + const server = Hapi.server({ routes: { response: { emptyStatusCode: 200 } } }); + server.route({ + method: 'GET', + path: '/', + handler: () => null + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(200); + expect(res.headers['content-length']).to.equal(0); + expect(res.headers['content-type']).to.not.exist(); + expect(res.result).to.equal(null); + expect(res.payload).to.equal(''); + }); + it('does not send 204 for chunked transfer payloads', async () => { - const server = Hapi.server({ routes: { response: { emptyStatusCode: 204 } } }); + const server = Hapi.server(); const handler = (request) => { @@ -529,7 +506,7 @@ describe('transmission', () => { const server = Hapi.server({ compression: { minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler: (request, h) => h.response().type('text/html') }); const res = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); - expect(res.statusCode).to.equal(200); + expect(res.statusCode).to.equal(204); expect(res.result).to.equal(null); expect(res.headers['content-encoding']).to.not.exist(); }); @@ -601,13 +578,8 @@ describe('transmission', () => { } this.isDone = true; - this.push('success'); - - setImmediate(() => { - - this.emit('error', new Error()); - }); + setImmediate(() => this.emit('error', new Error('stream error'))); }; return stream; @@ -615,10 +587,21 @@ describe('transmission', () => { const server = Hapi.server(); server.route({ method: 'GET', path: '/', handler }); + const log = server.events.once('response'); - const res = await server.inject('/'); - expect(res.statusCode).to.equal(500); - expect(res.result.message).to.equal('An internal server error occurred'); + const err = await expect(server.inject('/')).to.reject(Boom.Boom); + expect(err.output.statusCode).to.equal(499); + expect(err.output.payload.error).to.equal('Unknown'); + expect(err.output.payload.message).to.equal('Response error'); + expect(err.data.request.response.message).to.equal('stream error'); + expect(err.data.request.raw.res.statusCode).to.equal(200); + expect(err.data.request.raw.res.statusMessage).to.equal('OK'); + + const [request] = await log; + expect(request.response.message).to.equal('stream error'); + expect(request.response.output.statusCode).to.equal(500); + expect(request.info.completed).to.be.above(0); + expect(request.info.responded).to.equal(0); }); it('handles stream errors on the response after the response has been piped (http)', async () => { @@ -635,18 +618,26 @@ describe('transmission', () => { this.isDone = true; this.push('something'); - this.emit('error', new Error()); + setImmediate(() => this.emit('error', new Error('stream error'))); }; return stream; }; const server = Hapi.server(); + const log = server.events.once('response'); server.route({ method: 'GET', path: '/', handler }); await server.start(); - await expect(Wreck.request('GET', 'http://localhost:' + server.info.port + '/')).to.reject(); + const err = await expect(Wreck.get('http://localhost:' + server.info.port + '/')).to.reject(); await server.stop(); + + const [request] = await log; + expect(err.data.res.statusCode).to.equal(200); + expect(request.response.message).to.equal('stream error'); + expect(request.response.output.statusCode).to.equal(500); + expect(request.info.completed).to.be.above(0); + expect(request.info.responded).to.equal(0); }); it('matches etag header list value', async () => { @@ -715,7 +706,7 @@ describe('transmission', () => { it('returns a plain file when compression disabled', async () => { - const server = Hapi.server({ compression: { minBytes: 1 }, routes: { files: { relativeTo: __dirname } }, compression: false }); + const server = Hapi.server({ routes: { files: { relativeTo: __dirname } }, compression: false }); await server.register(Inert); server.route({ method: 'GET', path: '/file', handler: (request, h) => h.file(__dirname + '/../package.json') }); @@ -1070,7 +1061,9 @@ describe('transmission', () => { await log; }); - it('stops processing the stream when the request closes', async () => { + it('stops processing the stream when the connection closes', async () => { + + let stream; const ErrStream = class extends Stream.Readable { @@ -1078,31 +1071,49 @@ describe('transmission', () => { super(); this.request = request; + this.reads = 0; } _read(size) { - if (this.isDone) { - return; + if (this.reads === 0) { + this.push('here is the response'); + this.request.raw.res.destroy(); } - this.isDone = true; - this.push('here is the response'); - process.nextTick(() => { + else { + // "Inifitely" push more content - this.request.raw.req.emit('close'); process.nextTick(() => { - this.push(null); + this.push('.'); }); - }); + } + + ++this.reads; } }; const server = Hapi.server(); - server.route({ method: 'GET', path: '/stream', handler: (request, h) => h.response(new ErrStream(request)).bytes(0) }); + const log = server.events.once('response'); + server.route({ method: 'GET', path: '/stream', handler: (request, h) => { - const res = await server.inject({ url: '/stream', headers: { 'Accept-Encoding': 'gzip' } }); - expect(res.statusCode).to.equal(200); + stream = new ErrStream(request); + return h.response(stream).bytes(0); + } }); + + const err = await expect(server.inject({ url: '/stream', headers: { 'Accept-Encoding': 'gzip' } })).to.reject(Boom.Boom); + expect(err.output.statusCode).to.equal(499); + expect(err.output.payload.error).to.equal('Unknown'); + expect(err.output.payload.message).to.equal('Request close'); + expect(err.data.request.raw.res.statusCode).to.equal(204); + expect(err.data.request.raw.res.statusMessage).to.equal('No Content'); + + const [request] = await log; + expect(request.response.output.statusCode).to.equal(499); + expect(request.info.completed).to.be.above(0); + expect(request.info.responded).to.equal(0); + + expect(stream.reads).to.equal(2); }); it('does not truncate the response when stream finishes before response is done', async () => { @@ -1188,37 +1199,28 @@ describe('transmission', () => { await server.stop(); }); - it('does not leak stream data when request aborts before stream drains', async () => { + it('destroy() stream when request aborts before stream drains', async () => { const server = Hapi.server(); - let destroyed = false; - const team = new Teamwork(); + const team = new Teamwork.Team(); const handler = (request) => { - const stream = new Stream.Readable(); - - stream.destroy = undefined; // Node 8 streams comes with a destroy method – disable for this test - - stream._read = function (size) { - - const chunk = new Array(size).join('x'); + return new Stream.Readable({ + read(size) { - if (destroyed) { - this.push(chunk); - this.push(null); - } - else { + const chunk = new Array(size).join('x'); setTimeout(() => { this.push(chunk); }, 10); - } - }; + }, + destroy() { - stream.once('end', () => team.attend()); - return stream; + team.attend(); + } + }); }; server.route({ method: 'GET', path: '/', handler }); @@ -1226,66 +1228,47 @@ describe('transmission', () => { await server.start(); const res = await Wreck.request('GET', 'http://localhost:' + server.info.port); - res.on('data', (chunk) => { + res.once('data', (chunk) => { - if (!destroyed) { - destroyed = true; - res.destroy(); - } + res.destroy(); }); await team.work; await server.stop(); + + expect(res.statusCode).to.equal(200); }); - it('does not leak classic stream data when passed to request and aborted', async () => { + it('destroy() stream when request timeouts before stream drains', async () => { - const server = Hapi.server({ debug: false }); + const server = Hapi.server({ routes: { timeout: { server: 20, socket: 40 }, payload: { timeout: false } } }); + const team = new Teamwork.Team(); - let destroyed = false; - const team = new Teamwork(); const handler = (request) => { - const stream = new Stream(); - stream.readable = true; - - let paused = true; - const _read = function () { + let count = 0; + const stream = new Stream.Readable({ + read(size) { - setImmediate(() => { + const timeout = 10 * count++; // Must have back off here to hit the socket timeout - if (paused) { - return; - } + setTimeout(() => { - const chunk = new Array(1024).join('x'); + if (request._isFinalized) { + stream.push(null); + return; + } - if (destroyed) { - stream.emit('data', chunk); - stream.readable = false; - stream.emit('end'); - } - else { - stream.emit('data', chunk); - _read(); - } - }); - }; + stream.push(new Array(size).join('x')); - stream.resume = function () { + }, timeout); + }, + destroy() { - if (paused) { - paused = false; - _read(); + team.attend(); } - }; - stream.pause = function () { - - paused = true; - }; + }); - stream.resume(); - stream.once('end', team.attend()); return stream; }; @@ -1294,88 +1277,37 @@ describe('transmission', () => { await server.start(); const res = await Wreck.request('GET', 'http://localhost:' + server.info.port); - res.on('data', (chunk) => { - - if (!destroyed) { - destroyed = true; - res.destroy(); - } - }); + res.on('data', (chunk) => { }); + await team.work; await server.stop(); - }); - - it('does not leak stream data when request timeouts before stream drains', async () => { - - const server = Hapi.server({ routes: { timeout: { server: 20, socket: 40 }, payload: { timeout: false } } }); - const team = new Teamwork(); - - const handler = (request) => { - - const stream = new Stream.Readable(); - let count = 0; - stream._read = function (size) { - - setTimeout(() => { - - if (request._isFinalized) { - stream.push(null); - } - else { - stream.push(new Array(size).join('x')); - } - }, 10 * (count++)); // Must have back off here to hit the socket timeout - }; - stream.once('end', () => team.attend()); - - return stream; - }; - - server.route({ method: 'GET', path: '/', handler }); - - await server.start(); - - const res = await Wreck.request('GET', 'http://localhost:' + server.info.port); - res.on('data', (chunk) => { }); - await server.stop(); + expect(res.statusCode).to.equal(200); }); - it('does not leak stream data when request aborts before stream is returned', async () => { + it('destroy() stream when request aborts before stream drains', async () => { const server = Hapi.server(); - const team = new Teamwork(); + const team = new Teamwork.Team(); const handler = async (request) => { - clientRequest.abort(); - - const stream = new Stream.Readable(); - let responded = false; - - stream.destroy = undefined; // Node 8 streams comes with a destroy method – disable for this test + clientRequest.destroy(); - stream._read = function (size) { + const stream = new Stream.Readable({ + read(size) { - const chunk = new Array(size).join('x'); + const chunk = new Array(size).join('x'); - if (responded) { - this.push(chunk); - this.push(null); - } - else { setTimeout(() => { - responded = true; this.push(chunk); }, 10); - } - }; - - stream.once('end', () => { + }, + destroy() { - expect(responded).to.be.true(); - team.attend(); + team.attend(); + } }); await Hoek.wait(100); @@ -1391,7 +1323,8 @@ describe('transmission', () => { port: server.info.port, method: 'GET' }); - clientRequest.on('error', () => { /* NOP */ }); + + clientRequest.on('error', Hoek.ignore); clientRequest.end(); await team.work; @@ -1400,13 +1333,29 @@ describe('transmission', () => { it('changes etag when content-encoding set manually', async () => { + const payload = new Array(1000).fill('x').join(); const server = Hapi.server(); - server.route({ method: 'GET', path: '/', handler: (request, h) => h.response('x').header('content-encoding', 'gzip').etag('abc') }); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response(payload).header('content-encoding', 'gzip').etag('abc') }); - const res = await server.inject('/'); + const res = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); expect(res.statusCode).to.equal(200); expect(res.headers.etag).to.exist(); expect(res.headers.etag).to.match(/-gzip"$/); + expect(res.headers.vary).to.equal('accept-encoding'); + }); + + it('changes etag without vary when content-encoding set via compressed', async () => { + + const payload = new Array(1000).fill('x').join(); + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler: (request, h) => h.response(payload).compressed('gzip').etag('abc') }); + + const res = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); + expect(res.statusCode).to.equal(200); + expect(res.headers.etag).to.exist(); + expect(res.headers.etag).to.equal('"abc-gzip"'); + expect(res.headers['content-encoding']).to.equal('gzip'); + expect(res.headers.vary).to.not.exist(); }); it('head request retains content-length header', async () => { @@ -1469,12 +1418,69 @@ describe('transmission', () => { expect(count).to.equal(1); }); + it('handles stream that is destroyed with no error', async () => { + + const handler = (request, h) => { + + const stream = new Stream.Readable({ read: Hoek.ignore }); + + stream.push('hello'); + Hoek.wait(1).then(() => stream.destroy()); + + return h.response(stream).type('text/html'); + }; + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler }); + + const log = server.events.once('response'); + const err = await expect(server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } })).to.reject(Boom.Boom); + expect(err.output.statusCode).to.equal(499); + + const [request] = await log; + expect(request.response.isBoom).to.be.true(); + expect(request.response.output.statusCode).to.equal(499); + }); + + it('handles stream that is destroyed with error', async () => { + + const handler = (request, h) => { + + const stream = new Stream.Readable({ read: Hoek.ignore }); + if (stream.errored === undefined) { + + // Expose errored property on node 14 & 16 to enable coverage + + stream.on('error', () => { + + stream.errored = true; + }); + } + + stream.push('hello'); + Hoek.wait(1).then(() => stream.destroy(new Error('failed'))); + + return h.response(stream).type('text/html'); + }; + + const server = Hapi.server(); + server.route({ method: 'GET', path: '/', handler }); + + const log = server.events.once('response'); + const err = await expect(server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } })).to.reject(Boom.Boom); + expect(err.output.statusCode).to.equal(499); + + const [request] = await log; + expect(request.response.isBoom).to.be.true(); + expect(request.response.output.statusCode).to.equal(500); + }); + describe('response range', () => { const fileStreamHandler = (request, h) => { const filePath = Path.join(__dirname, 'file', 'image.png'); - return h.response(Fs.createReadStream(filePath)).bytes(Fs.statSync(filePath).size); + return h.response(Fs.createReadStream(filePath)).bytes(Fs.statSync(filePath).size).etag('some-tag'); }; it('returns a subset of a fileStream (start)', async () => { @@ -1765,6 +1771,63 @@ describe('transmission', () => { const res = await server.inject('/'); expect(res.statusCode).to.equal(500); }); + + it('permits ending reading request stream while transmitting response.', async (flags) => { + + const server = Hapi.server(); + + server.route({ + method: 'post', + path: '/', + options: { + payload: { + output: 'stream' + } + }, + handler: (request, h) => { + + const stream = new Stream.PassThrough(); + + // Start transmitting stream response... + stream.push('hello '); + + Bounce.background(async () => { + + await Events.once(request.raw.res, 'pipe'); + + // ...but also only read and end the request once the response is transmitting... + request.raw.req.on('data', Hoek.ignore); + await Events.once(request.raw.req, 'end'); + + // ...and finally end the intended response once the request stream has ended. + stream.end('world'); + }); + + return h.response(stream); + } + }); + + flags.onCleanup = () => server.stop(); + await server.start(); + + const req = Http.request({ + hostname: 'localhost', + port: server.info.port, + method: 'post' + }); + + req.end('{}'); + + const [res] = await Events.once(req, 'response'); + + let result = ''; + for await (const chunk of res) { + result += chunk.toString(); + } + + // If not permitted then result will be "hello " without "world" + expect(result).to.equal('hello world'); + }); }); describe('length()', () => { @@ -1793,6 +1856,7 @@ describe('transmission', () => { if (this.isDone) { return; } + this.isDone = true; this.push('some payload'); @@ -1840,7 +1904,7 @@ describe('transmission', () => { describe('chain()', () => { - it('handles stream errors on the response after the response has been piped (http)', async () => { + it('handles stream errors on the response after the response has been piped', async () => { const handler = (request, h) => { @@ -1863,8 +1927,8 @@ describe('transmission', () => { const server = Hapi.server({ compression: { minBytes: 1 } }); server.route({ method: 'GET', path: '/', handler }); - const res = await server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } }); - expect(res.statusCode).to.equal(500); + const err = await expect(server.inject({ url: '/', headers: { 'accept-encoding': 'gzip' } })).to.reject(Boom.Boom); + expect(err.output.statusCode).to.equal(499); }); }); }); @@ -1877,6 +1941,7 @@ internals.TimerStream = class extends Stream.Readable { if (this.isDone) { return; } + this.isDone = true; setTimeout(() => { diff --git a/test/types/index.ts b/test/types/index.ts new file mode 100644 index 000000000..69fbfeeb2 --- /dev/null +++ b/test/types/index.ts @@ -0,0 +1,579 @@ +import { types as lab } from '@hapi/lab'; +import { expect } from '@hapi/code'; +import * as CatboxMemory from '@hapi/catbox-memory'; + +import { + Plugin, + ReqRef, + ReqRefDefaults, + Request, + RequestRoute, + ResponseToolkit, + Server, + ServerRoute, + server as createServer, + UserCredentials, + ServerRegisterPluginObject, + Lifecycle, + CachedServerMethod +} from '../..'; + +const { expect: check } = lab; + +type IsAny = ( + unknown extends T + ? [keyof T] extends [never] ? false : true + : false + ); + + +declare module '../..' { + interface UserCredentials { + someId: string; + someName: string; + } +} + +interface ServerAppSpace { + multi?: number; +} + +type MyServer = Server; + +const server = createServer(); +check.type(server); +check.type(server); + +server.app.multi = 10; + +const genericRoute: ServerRoute = { + method: 'GET', + path: '/', + handler: (request, h) => { + + check.type(request.auth.credentials!.user!); + + const y: IsAny = false; + + return 'hello!'; + } +} + +server.route(genericRoute); + +interface RequestDecorations { + Server: MyServer; + RequestApp: { + word: string; + }, + RouteApp: { + prefix: string[]; + }, + AuthUser: { + id: string; + name: string; + email: string; + }, + AuthCredentialsExtra: { + test: number; + }, + AuthApp: { + key: string; + name: string; + }, + AuthArtifactsExtra: { + some: string; + thing: number; + } +} + +type AppRequest = Request; + +const route: ServerRoute = { + method: 'POST', + path: '/', + options: { + app: { + prefix: ['xx-'] + }, + payload: { + maxParts: 100, + maxBytes: 1024 * 1024, + output: 'stream', + multipart: true + } + }, + handler: (request: AppRequest, h: ResponseToolkit) => { + + request.app.word = 'x'; + + check.type>(request.params); + check.type(request.server.app.multi!); + check.type(request.route.settings.app!.prefix); + + check.type(request.auth.credentials!.test); + + check.type(request.auth.credentials!.user!.email); + check.type(request.auth.credentials!.user!.id); + check.type(request.auth.credentials!.user!.name); + + check.type(request.auth.credentials!.app!.name); + check.type(request.auth.credentials!.app!.key); + + check.type(request.auth.artifacts.some); + check.type(request.auth.artifacts.thing); + + const y: IsAny = false; + const z: IsAny = false; + + return 'hello!' + } +}; + +server.route(route); + +interface TestPluginOptions { + x: number; +} + +interface TestPluginDecorations { + plugins: { + test: { + add(a: number, b: number): number; + }; + } +} + +const plugin: Plugin = { + name: 'test', + version: '1.0.0', + register: function (srv: MyServer, options) { + + check.type(options); + + srv.expose({ + add: function (a: number, b: number) { + + return (a + b + options.x) * srv.app.multi!; + } + }); + } +}; + +const loadedServer = await server.register({ plugin, options: { x: 10 } }); + +check.type(server.match('GET', '/')); +check.type(server.match('get', '/')); + +const sum = loadedServer.plugins.test.add(1, 2); +expect(sum).to.equal(130); +check.type(sum); + +server.cache.provision({ + name: 'some-cache', + provider: { + constructor: CatboxMemory.Engine, + options: { + partition: 'test' + } + } +}) + +declare module '../..' { + interface ServerMethods { + test: { + add: CachedServerMethod<((a: number, b: number) => number)>; + } + } +} + +server.method('test.add', (a: number, b: number) => a + b, { + bind: server, + cache: { + expiresIn: 1000, + generateTimeout: 100, + cache: 'some-cache', + segment: 'test-segment', + }, + generateKey: (a: number, b: number) => `${a}${b}` +}); + +server.methods.test.add.cache?.drop(1, 2); + +declare module '../..' { + interface Request { + obj1: { + func1(a: number, b: number): number; + }; + + func2: (a: number, b: number) => number; + } + + interface ResponseToolkit { + obj2: { + func3(a: number, b: number): number; + }; + + func4: (a: number, b: number) => number; + } + + interface Server { + obj3: { + func5(a: number, b: number): number; + }; + + func6: (a: number, b: number) => number; + } +} + +const theFunc = (a: number, b: number) => a + b; +const theLifecycleMethod: Lifecycle.Method = () => 'ok'; + +// Error when decorating existing properties +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('request', 'payload', theFunc)); +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('toolkit', 'state', theFunc)); +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('server', 'dependency', theFunc)); +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('server', 'dependency', theFunc)); + +server.decorate('handler', 'func1_1', () => theLifecycleMethod); +server.decorate('handler', 'func1_2', () => theLifecycleMethod, { apply: true }); + +// Error when extending on handler +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('handler', 'func1_3', () => theLifecycleMethod, { apply: true, extend: true })); + +// Error when handler does not return a lifecycle method +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('handler', 'func1_4', theFunc)); + +// Decorating request with functions +server.decorate('request', 'func2_1', theFunc); +server.decorate('request', 'func2_1', () => theFunc, { apply: true, extend: true }); +server.decorate('request', 'func2_2', theFunc, { apply: true }); +server.decorate('request', 'func2_2', theFunc, { extend: true }); + +// Decorating toolkit with functions +server.decorate('toolkit', 'func4_1', theFunc); +server.decorate('toolkit', 'func4_1', theFunc, { apply: true, extend: true }); +server.decorate('toolkit', 'func4_2', theFunc, { apply: true }); +server.decorate('toolkit', 'func4_2', theFunc, { extend: true }); + +// Decorating server with functions +server.decorate('server', 'func6_1', theFunc); +server.decorate('server', 'func6_1', theFunc, { apply: true, extend: true }); +server.decorate('server', 'func6_2', theFunc, { apply: true }); +server.decorate('server', 'func6_2', theFunc, { extend: true }); + +// Decorating request with objects +server.decorate('request', 'obj1_1', { func1: theFunc }); + +// Type error when extending on request with objects +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('request', 'obj1_1', { func1: theFunc }, { apply: true, extend: true })); + + +// Decorating toolkit with objects +server.decorate('toolkit', 'obj2_1', { func3: theFunc }); + +// Error when extending on toolkit with objects +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('toolkit', 'obj2_1', { func3: theFunc }, { apply: true, extend: true })); + +// Decorating server with objects +server.decorate('server', 'obj3_1', { func5: theFunc }); + +// Error when extending on server with objects +// @ts-expect-error Lab does not support overload errors +check.error(() => server.decorate('server', 'obj3_1', { func5: theFunc }, { apply: true, extend: true })); + +// Issue #4561 - Generic Request should resolve augmented ReqRefDefaults auth properties + +interface ExtraCred { + extra_id: string; +} + +interface UserProfile { + id: string; +} + +declare module '../..' { + interface ReqRefDefaults { + AuthCredentialsExtra: Partial; + } +} + +// Generic route (no custom refs) should see augmented UserCredentials +const genericAuthRoute: ServerRoute = { + method: 'GET', + path: '/auth-check', + handler: (request, h) => { + + check.type(request.auth.credentials.user!.someId); + check.type(request.auth.credentials.user!.someName); + + const credIsAny: IsAny = false; + + return 'ok'; + } +}; + +// Generic function should see augmented credentials from ReqRefDefaults +export function processAuthGeneric(req: Request): void { + + if (req.auth.isAuthenticated && req.auth.credentials.extra_id) { + check.type(req.auth.credentials.extra_id); + } +} + +// Non-generic Request should also see augmented credentials +export function processAuthConcrete(req: Request): void { + + if (req.auth.isAuthenticated && req.auth.credentials.extra_id) { + check.type(req.auth.credentials.extra_id); + } + + // credentials should NOT resolve to `any` + const credIsAny: IsAny = false; + const artifactsIsAny: IsAny = false; +} + +// Generic function should accept Request with specific route refs +interface SpecificRouteRefs { + Params: { id: string }; +} + +export function callWithSpecificRefs(req: Request): void { + + processAuthGeneric(req); +} + +// ============================================================================= +// ReqRef System Issue Tests +// Each section demonstrates a specific weakness in the current type system. +// These tests produce VISIBLE compiler errors to demonstrate each problem. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// ISSUE 1: Direct Refs['Key'] access bypasses MergeRefs (route.d.ts:361) +// +// RouteOptionsPreObject.assign uses `keyof Refs['Pres']` instead of +// `keyof MergeRefs['Pres']`. When the user doesn't explicitly provide +// `Pres` in their Refs, `Refs['Pres']` is `unknown` (from ReqRef's +// Partial>), so `keyof unknown` is `never`. +// This means `assign` is impossible unless Pres is explicitly provided. +// ----------------------------------------------------------------------------- + +// This should compile — the user only customizes Params, and the default +// Pres (Record) should allow any string for `assign`. +// ERROR: Type '"user"' is not assignable to type 'never'. +const issuePreAssign: ServerRoute<{ Params: { id: string } }> = { + method: 'GET', + path: '/users/{id}', + options: { + pre: [ + { + method: (request, h) => ({ name: 'test' }), + assign: 'user' // TS ERROR — should work + } + ], + handler: (request, h) => 'ok' + } +}; + +// ISSUE 2: Params default is `Record` — Joi-driven +// conversion can change the runtime type, so callsites must narrow. + +const issueParamsAny: ServerRoute = { + method: 'GET', + path: '/items/{id}', + handler: (request, h) => { + + // @ts-expect-error - unknown is not number + const idAsNum: number = request.params.id; + // @ts-expect-error - unknown is not string (compiled pre-fix when Params was Record) + const idAsStr: string = request.params.id; + // @ts-expect-error - unknown is not boolean[] + const wat: boolean[] = request.params.id; + + const paramsIsAny: IsAny = false; + + return 'ok'; + } +}; + +// ISSUE 3: Headers default is `Record` — header validation +// can transform values (e.g. `x-date` → Date). + +const issueHeadersAny: ServerRoute = { + method: 'GET', + path: '/headers', + handler: (request, h) => { + + // @ts-expect-error - unknown is not number + const auth: number = request.headers.authorization; + // @ts-expect-error - unknown is not string (issue #4563 — was assignable in 21.4.4) + const xCustom: string = request.headers['x-custom-header']; + + const headersIsAny: IsAny = false; + + return 'ok'; + } +}; + +// ISSUE 4: Query default is `[key: string]: unknown` — the configured parser +// (e.g. `qs` yields objects) and validation determine the runtime shape. + +const issueQueryAny: ServerRoute = { + method: 'GET', + path: '/search', + handler: (request, h) => { + + // @ts-expect-error - unknown is not number + const page: number = request.query.page; + // @ts-expect-error - unknown is not string + const q: string = request.query.q; + // @ts-expect-error - unknown is not boolean[] + const wat: boolean[] = request.query.anything; + + const queryIsAny: IsAny = false; + + return 'ok'; + } +}; + +// ----------------------------------------------------------------------------- +// ISSUE 5: Request not assignable to Request +// +// A function taking Request (no generic) can't accept Request<{ Params: ... }> +// even though the custom refs only NARROW a property. Users are forced to +// choose between generic (accepts all) or concrete (sees defaults). +// ----------------------------------------------------------------------------- + +export function concreteHelper(req: Request): string | undefined { + + if (req.auth.credentials.extra_id) { + return req.auth.credentials.extra_id; + } + + return undefined; +} + +interface MyRouteRefs { + Params: { id: string }; + Query: { expand: string }; +} + +// KNOWN LIMITATION: Request is not assignable to Request +// because TypeScript checks generic interface compatibility invariantly when +// the generic appears in contravariant positions (e.g. lifecycle method parameters). +// Workaround: use a generic function like processAuthGeneric above instead +// of concrete Request (no generic) for helper functions that need to accept +// requests with different Refs. +export function issueConcreteVsGeneric(req: Request): void { + + // @ts-expect-error - Known TS limitation: Request not assignable to Request + concreteHelper(req); +} + +// ----------------------------------------------------------------------------- +// ISSUE 6: state and preResponses are not extensible through ReqRef +// +// These properties use hardcoded Record and are NOT wired +// through InternalRequestDefaults/ReqRef, so users can't type them. +// ----------------------------------------------------------------------------- + +const issueStateAny: ServerRoute = { + method: 'GET', + path: '/state', + handler: (request, h) => { + + // FIXED: state is now Record — requires type narrowing + // @ts-expect-error - state values are unknown, not directly assignable to number + const session: number = request.state.session; + + // FIXED: state is no longer `any` + const stateIsAny: IsAny = false; + + // FIXED: preResponses is no longer `any` + const preRespIsAny: IsAny = false; + + return 'ok'; + } +}; + +// Headers/Params can be widened via global augmentation. + +type Extends = A extends B ? true : false; + +interface ParsedHeaders { + [key: string]: string | string[] | Date | number | undefined; +} +const _widensHeaders: Extends = true; + +interface ConvertedParams { [key: string]: string | number; } +const _widensParams: Extends = true; + +// Global Query augmentation must compile under `unknown` defaults and +// propagate through every generic position that reads MergeRefs['Query']. + +declare module '../..' { + interface ReqRefDefaults { + Query: { + [key: string]: string | string[] | Record | undefined; + }; + } +} + +type AugmentedQuery = string | string[] | Record | undefined; + +// ServerRoute with no generic — augmented Query flows in via Request. +const augNoGeneric: ServerRoute = { + method: 'GET', + path: '/aug-no-generic', + handler: (request, h) => { + + check.type(request.query.anything); + return 'ok'; + } +}; +server.route(augNoGeneric); + +// ServerRoute with partial Refs override — Query falls through MergeRefs as augmented. +const augPartialRefs: ServerRoute<{ Params: { id: string } }> = { + method: 'GET', + path: '/aug-partial/{id}', + handler: (request, h) => { + + check.type(request.query.foo); + check.type(request.params.id); + return 'ok'; + } +}; +server.route(augPartialRefs); + +// Lifecycle.Method (no generic) — same augmented Query. +const augLifecycle: Lifecycle.Method = (request, h) => { + + check.type(request.query.x); + return 'ok'; +}; +augLifecycle.length; // keep referenced + +// Per-route Query override still replaces the augmented default outright. +const qsParsedRoute: ServerRoute<{ Query: { filter: Record; q?: string } }> = { + method: 'GET', + path: '/qs', + handler: (request, h) => { + + check.type>(request.query.filter); + check.type(request.query.q); + + return 'ok'; + } +}; + +server.route(qsParsedRoute); diff --git a/test/validation.js b/test/validation.js index 4a189d86e..b4e363ff0 100755 --- a/test/validation.js +++ b/test/validation.js @@ -1,33 +1,54 @@ 'use strict'; -// Load modules - -const Boom = require('boom'); -const Code = require('code'); +const Boom = require('@hapi/boom'); +const Code = require('@hapi/code'); const Hapi = require('..'); -const Inert = require('inert'); +const Inert = require('@hapi/inert'); const Joi = require('joi'); -const Lab = require('lab'); - +const JoiLegacy = require('@hapi/joi-legacy-test'); +const Lab = require('@hapi/lab'); -// Declare internals const internals = {}; -// Test shortcuts - const { describe, it } = exports.lab = Lab.script(); const expect = Code.expect; describe('validation', () => { + it('validates using joi v15', async () => { + + const server = Hapi.server(); + server.validator(JoiLegacy); + server.route({ + method: 'POST', + path: '/', + handler: () => 'ok', + options: { + validate: { + payload: JoiLegacy.object({ + a: JoiLegacy.number(), + b: JoiLegacy.array() + }) + } + } + }); + + const res1 = await server.inject({ url: '/', method: 'POST', payload: { a: '1', b: [1] } }); + expect(res1.statusCode).to.equal(200); + + const res2 = await server.inject({ url: '/', method: 'POST', payload: { a: 'x', b: [1] } }); + expect(res2.statusCode).to.equal(400); + }); + describe('inputs', () => { it('validates valid input', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -48,6 +69,7 @@ describe('validation', () => { it('validates both params and query', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/b/{x}', @@ -72,6 +94,7 @@ describe('validation', () => { it('validates valid input using context', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/{user?}', @@ -101,6 +124,7 @@ describe('validation', () => { it('validates valid input using auth context', async () => { const server = Hapi.server(); + server.validator(Joi); const scheme = function (authServer, options) { @@ -148,6 +172,7 @@ describe('validation', () => { it('validates valid input using app context', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -174,6 +199,7 @@ describe('validation', () => { it('fails valid input', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -194,6 +220,7 @@ describe('validation', () => { it('retains custom validation error', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -214,6 +241,7 @@ describe('validation', () => { it('validates valid input with validation options', async () => { const server = Hapi.server({ routes: { validate: { options: { convert: false } } } }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -311,6 +339,7 @@ describe('validation', () => { it('casts input to desired type', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/{seq}', @@ -332,6 +361,7 @@ describe('validation', () => { it('uses original value before schema conversion', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/{seq}', @@ -393,6 +423,7 @@ describe('validation', () => { it('validates valid input (Object root)', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -413,6 +444,7 @@ describe('validation', () => { it('validates non-object payload', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'POST', path: '/', @@ -431,6 +463,7 @@ describe('validation', () => { it('validates boolean payload', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'POST', path: '/', @@ -449,6 +482,7 @@ describe('validation', () => { it('fails on invalid input', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -469,6 +503,7 @@ describe('validation', () => { it('ignores invalid input', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -490,6 +525,7 @@ describe('validation', () => { it('logs invalid input', async () => { const server = Hapi.server({ routes: { log: { collect: true } } }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -512,6 +548,7 @@ describe('validation', () => { it('replaces error with message on invalid input', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -534,9 +571,40 @@ describe('validation', () => { expect(res.result).to.equal('Got error in query where a is bad'); }); + it('makes default error available in failAction', async () => { + + const server = Hapi.server(); + server.validator(Joi); + server.route({ + method: 'GET', + path: '/', + handler: () => 'ok', + options: { + validate: { + query: { + a: Joi.string().min(2) + }, + failAction: function (request, h, err) { + + err.data.defaultError.output.payload.message += ': ' + err.output.payload.validation.keys.join(', '); + + throw err.data.defaultError; + } + } + } + }); + + const res = await server.inject('/?a=1'); + expect(res.statusCode).to.equal(400); + expect(res.result).to.contain({ + message: 'Invalid request query input: a' + }); + }); + it('catches error thrown in failAction', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -561,6 +629,7 @@ describe('validation', () => { it('customizes error on invalid input', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -583,7 +652,7 @@ describe('validation', () => { expect(res.result).to.equal({ statusCode: 400, error: 'Bad Request', - message: 'child "a" fails because ["a" length must be at least 2 characters long]', + message: '"a" length must be at least 2 characters long', validation: { source: 'query', keys: ['a'] @@ -607,6 +676,8 @@ describe('validation', () => { } }); + server.validator(Joi); + server.route({ method: 'GET', path: '/', @@ -644,6 +715,7 @@ describe('validation', () => { it('fails on invalid payload', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'POST', path: '/', @@ -669,6 +741,7 @@ describe('validation', () => { it('converts string input to number', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'POST', path: '/', @@ -688,6 +761,7 @@ describe('validation', () => { it('fails on text input', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'POST', path: '/', @@ -709,6 +783,7 @@ describe('validation', () => { it('fails on null input', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'POST', path: '/', @@ -731,6 +806,7 @@ describe('validation', () => { it('fails on no payload', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'POST', path: '/', @@ -753,9 +829,106 @@ describe('validation', () => { }); }); + it('rejects invalid cookies', async () => { + + const server = Hapi.server({ + routes: { + validate: { + state: { + a: Joi.string().min(8) + }, + failAction: (request, h, err) => err, // Expose detailed error + validator: Joi + } + } + }); + + server.route({ + method: 'GET', + path: '/', + handler: () => 'ok' + }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'cookie': 'a=abc' } }); + expect(res.statusCode).to.equal(400); + expect(res.result.validation).to.equal({ + source: 'state', + keys: ['a'] + }); + }); + + it('accepts valid cookies', async () => { + + const server = Hapi.server(); + server.validator(Joi); + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.state, + options: { + validate: { + state: { + a: Joi.string().min(8), + b: Joi.array().single().items(Joi.boolean()), + c: Joi.string().default('value') + }, + failAction: (request, h, err) => err // Expose detailed error + } + } + }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'cookie': 'a=abcdefghi; b=true' } }); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal({ + a: 'abcdefghi', + b: [true], + c: 'value' + }); + }); + + it('accepts all cookies', async () => { + + const server = Hapi.server(); + + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.state, + options: { + validate: { + state: true + } + } + }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'cookie': 'a=abc' } }); + expect(res.statusCode).to.equal(200); + expect(res.result).to.equal({ a: 'abc' }); + }); + + it('rejects all cookies', async () => { + + const server = Hapi.server(); + + server.route({ + method: 'GET', + path: '/', + handler: (request) => request.state, + options: { + validate: { + state: false + } + } + }); + + const res = await server.inject({ method: 'GET', url: '/', headers: { 'cookie': 'a=abc' } }); + expect(res.statusCode).to.equal(400); + }); + it('validates valid header', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -786,6 +959,7 @@ describe('validation', () => { it('rejects invalid header', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -847,6 +1021,7 @@ describe('validation', () => { it('samples responses', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -880,6 +1055,7 @@ describe('validation', () => { let i = 0; const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -904,6 +1080,7 @@ describe('validation', () => { it('validates response with context', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -912,7 +1089,8 @@ describe('validation', () => { schema: Joi.object({ some: Joi.string(), more: Joi.string() - }).when('$query.user', { is: 'admin', otherwise: Joi.object({ more: Joi.forbidden() }) }) + }) + .when('$query.user', { not: 'admin', then: Joi.object({ more: Joi.forbidden() }) }) } }, handler: () => ({ some: 'thing', more: 'stuff' }) @@ -929,6 +1107,7 @@ describe('validation', () => { it('validates response using app context', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -955,6 +1134,7 @@ describe('validation', () => { let i = 0; const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -990,6 +1170,7 @@ describe('validation', () => { let i = 0; const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1033,6 +1214,7 @@ describe('validation', () => { it('validates and modifies response', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1055,6 +1237,7 @@ describe('validation', () => { it('validates and modifies error response', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1107,6 +1290,7 @@ describe('validation', () => { it('throws on sample with response modify', () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); expect(() => { server.route({ @@ -1123,12 +1307,13 @@ describe('validation', () => { }, handler: () => ({ a: 1, b: 2 }) }); - }).to.throw(/"sample" is not allowed/); + }).to.throw(/"response.sample" is not allowed/); }); it('do not throws on sample with false response modify', () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); expect(() => { server.route({ @@ -1236,7 +1421,7 @@ describe('validation', () => { expect(res1.result).to.equal({ some: 'unchanged' }); const res2 = await server.inject('/'); - expect(res2.statusCode).to.equal(200); + expect(res2.statusCode).to.equal(204); expect(res2.result).to.equal(null); const res3 = await server.inject('/'); @@ -1269,6 +1454,7 @@ describe('validation', () => { it('skips response validation when sample is zero', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1300,6 +1486,7 @@ describe('validation', () => { it('does not delete the response object from the route when sample is 0', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1323,6 +1510,7 @@ describe('validation', () => { it('fails response validation with options', async () => { const server = Hapi.server({ debug: false, routes: { response: { options: { convert: false } } } }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1358,6 +1546,27 @@ describe('validation', () => { expect(res.statusCode).to.equal(200); }); + it('skips response validation when a status schema is true', async () => { + + const server = Hapi.server({ debug: false }); + server.route({ + method: 'GET', + path: '/', + options: { + handler: (request, h) => h.redirect('/somewhere'), + response: { + schema: false, + status: { + 302: true + } + } + } + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(302); + }); + it('skips response validation when status is empty', async () => { const server = Hapi.server({ debug: false }); @@ -1397,6 +1606,7 @@ describe('validation', () => { it('ignores error responses', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1420,6 +1630,7 @@ describe('validation', () => { it('errors on non-plain-object responses', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); await server.register(Inert); server.route({ method: 'GET', @@ -1441,6 +1652,7 @@ describe('validation', () => { it('logs invalid responses', async () => { const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1469,6 +1681,7 @@ describe('validation', () => { it('replaces error with message on invalid response', async () => { const server = Hapi.server(); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1494,6 +1707,7 @@ describe('validation', () => { it('combines onPreResponse with response validation override', async () => { const server = Hapi.server(); + server.validator(Joi); server.ext('onPreResponse', () => 'else'); server.route({ method: 'GET', @@ -1520,6 +1734,7 @@ describe('validation', () => { it('combines onPreResponse with response validation override takeover', async () => { const server = Hapi.server(); + server.validator(Joi); server.ext('onPreResponse', () => 'else'); server.route({ method: 'GET', @@ -1543,11 +1758,59 @@ describe('validation', () => { expect(res.payload).to.equal('else'); }); + it('combines onPreResponse with response validation error', async () => { + + const server = Hapi.server(); + + const responses = []; + + server.ext('onPreResponse', (request, h) => { + + responses.push(request.response); + return h.continue; + }); + + server.route({ + method: 'GET', + path: '/', + options: { + handler: () => { + + const err = Boom.internal('handler error'); + err.output.payload.x = 1; + throw err; + }, + response: { + status: { + 500: (value, options) => { + + responses.push(value); + throw new Error('500 validation error'); + } + }, + failAction: (request, h, err) => { + + responses.push(err); + throw new Error('failAction error'); + } + } + } + }); + + const res = await server.inject('/'); + expect(res.statusCode).to.equal(500); + + expect(responses).to.have.length(3); + expect(responses[0].x).to.equal(1); + expect(responses[1]).to.be.an.error('500 validation error'); + }); + it('validates string response', async () => { let value = 'abcd'; const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1573,6 +1836,7 @@ describe('validation', () => { let value = 'abcd'; const server = Hapi.server({ debug: false }); + server.validator(Joi); server.route({ method: 'GET', path: '/', @@ -1593,53 +1857,5 @@ describe('validation', () => { expect(res2.statusCode).to.equal(200); expect(res2.payload).to.equal('true'); }); - - it('throws on options.stripUnknown without modify', () => { - - const server = Hapi.server(); - - expect(() => { - - server.route({ - method: 'GET', - path: '/', - handler: () => 'ok', - options: { - response: { - schema: Joi.string(), - options: { - stripUnknown: true - } - } - } - }); - }).to.throw(/"options.stripUnknown" failed to meet requirement of having peer modify set to true/); - }); - - it('allows options.stripUnknown to be an object', () => { - - const server = Hapi.server(); - - expect(() => { - - server.route({ - method: 'GET', - path: '/', - handler: () => 'ok', - options: { - response: { - schema: Joi.string(), - modify: true, - options: { - stripUnknown: { - objects: true, - arrays: true - } - } - } - } - }); - }).to.not.throw(); - }); }); }); diff --git a/typescript.md b/typescript.md new file mode 100644 index 000000000..d1944022c --- /dev/null +++ b/typescript.md @@ -0,0 +1,800 @@ + +## TypeScript Support + +hapi ships built-in TypeScript definitions (`.d.ts`) — no `@types/hapi` package needed. +The type system is designed around two complementary patterns: + +- **Module augmentation** — declare global types that apply to every route (e.g. `UserCredentials`, `ServerApplicationState`). +- **Generic refs** — pass per-route type overrides via `ServerRoute`, `Request`, and `Lifecycle.Method`. + +Both patterns can be used together. Module augmentation sets the baseline; generic refs narrow types for individual routes. + + +## Quick Start + +```typescript +import { server as createServer, ServerRoute, Request, ResponseToolkit } from '@hapi/hapi'; + +interface AppSpace { + startedAt: number; +} + +const server = createServer({ port: 3000 }); +server.app.startedAt = Date.now(); + +const route: ServerRoute<{ Params: { id: string } }> = { + method: 'GET', + path: '/users/{id}', + handler: (request, h) => { + + const id: string = request.params.id; + return { id }; + } +}; + +server.route(route); +``` + +`createServer()` types `server.app` to the `AppSpace` interface. The route generic `{ Params: { id: string } }` overrides the default params type for that specific route. + + +## The ReqRef System + +The ReqRef system is the core architecture that makes per-route typing work. It consists of three pieces: + +### `InternalRequestDefaults` + +Defines every customizable key and its default type: + +| Key | Default Type | Controls | +| ---------------------- | ------------------------------------------------- | ---------------------------------------------- | +| `Payload` | `stream.Readable \| Buffer \| string \| object` | `request.payload` | +| `Query` | `RequestQuery` (`Record`) | `request.query` | +| `Params` | `Record` | `request.params` | +| `Pres` | `Record` | `request.pre` | +| `Headers` | `Record` | `request.headers` | +| `RequestApp` | `RequestApplicationState` | `request.app` | +| `AuthUser` | `UserCredentials` | `request.auth.credentials.user` | +| `AuthApp` | `AppCredentials` | `request.auth.credentials.app` | +| `AuthApi` | `ServerAuthSchemeObjectApi` | `server.auth.api` | +| `AuthCredentialsExtra` | `Record` | Extra properties on `request.auth.credentials` | +| `AuthArtifactsExtra` | `Record` | `request.auth.artifacts` | +| `Rules` | `RouteRules` | `route.rules` | +| `Bind` | `object \| null` | `this` binding in lifecycle methods | +| `RouteApp` | `RouteOptionsApp` | `route.options.app` | +| `Server` | `Server` | `request.server` | + +### `ReqRefDefaults` + +```typescript +interface ReqRefDefaults extends InternalRequestDefaults {} +``` + +This is the interface you augment via `declare module` to change defaults globally. Any key you add here overrides `InternalRequestDefaults` for all routes that don't provide their own refs. + +### `ReqRef` and `MergeRefs` + +```typescript +type ReqRef = Partial>; +type MergeType = Omit & U; +type MergeRefs = MergeType; +``` + +`MergeRefs` takes a partial override object and merges it with `ReqRefDefaults`. Keys you provide replace the defaults; keys you omit keep the defaults. This is how per-route typing works — you only specify what's different. + +### Example + +```typescript +interface MyRefs { + Params: { id: string }; + Query: { expand?: string }; +} + +// MergeRefs resolves to: +// { +// Params: { id: string }; ← overridden +// Query: { expand?: string }; ← overridden +// Payload: stream.Readable | ...; ← default preserved +// Headers: Record; ← default preserved +// ...all other defaults preserved +// } + +const route: ServerRoute = { + method: 'GET', + path: '/items/{id}', + handler: (request, h) => { + + const id: string = request.params.id; // typed + const expand: string | undefined = request.query.expand; // typed + return { id }; + } +}; +``` + + +## Typing Request Properties + + +### Params + +Default: `Record`. Raw path params are strings; Joi conversion can change that at runtime, so the default forces narrowing. + +```typescript +// Override with specific param names +const route: ServerRoute<{ Params: { userId: string; postId: string } }> = { + method: 'GET', + path: '/users/{userId}/posts/{postId}', + handler: (request, h) => { + + const userId: string = request.params.userId; + const postId: string = request.params.postId; + return { userId, postId }; + } +}; +``` + + +### Query + +Default: `RequestQuery`, an augmentable interface with `[key: string]: unknown`. The shape depends on the configured parser (default produces `string | string[] | undefined`; `qs` yields nested objects) and on validation. Narrow per-route, or augment `RequestQuery` globally. + +```typescript +interface SearchQuery { + q: string; + page?: string; + tags?: string[]; +} + +const route: ServerRoute<{ Query: SearchQuery }> = { + method: 'GET', + path: '/search', + handler: (request, h) => { + + const q: string = request.query.q; + const page: string | undefined = request.query.page; + return { q, page }; + } +}; +``` + + +### Payload + +Default: `stream.Readable | Buffer | string | object`. Override when you know the parsed shape. + +```typescript +interface CreateUserPayload { + name: string; + email: string; +} + +const route: ServerRoute<{ Payload: CreateUserPayload }> = { + method: 'POST', + path: '/users', + options: { + payload: { output: 'data', parse: true } + }, + handler: (request, h) => { + + const name: string = request.payload.name; + return h.response({ created: true }).code(201); + } +}; +``` + + +### Headers + +Default: `Record`. Raw headers match Node's `http.IncomingHttpHeaders` (`string | string[] | undefined`); validation can produce anything, so the default forces narrowing. + + +### RequestApp + +Default: `RequestApplicationState` (empty, augmentable). Per-request application state via `request.app`. + +```typescript +const route: ServerRoute<{ RequestApp: { startTime: number } }> = { + method: 'GET', + path: '/', + handler: (request, h) => { + + request.app.startTime = Date.now(); + return 'ok'; + } +}; +``` + + +## Authentication Types + +hapi's auth type system has three layers: global interfaces (via module augmentation), ReqRef keys (per-route), and the `AuthCredentials` generic that merges them. + + +### Global: `UserCredentials` and `AppCredentials` + +Augment these to define your application's user and app credential shapes. They apply everywhere. + +```typescript +declare module '@hapi/hapi' { + interface UserCredentials { + id: string; + name: string; + email: string; + } + + interface AppCredentials { + clientId: string; + clientName: string; + } +} +``` + +After augmentation, `request.auth.credentials.user` is typed as `UserCredentials` and `request.auth.credentials.app` as `AppCredentials` on all routes. + + +### Per-Route: `AuthCredentialsExtra` and `AuthArtifactsExtra` + +Use these ReqRef keys to add extra properties to `request.auth.credentials` and `request.auth.artifacts` for specific routes. + +```typescript +interface MyRouteRefs { + AuthUser: { id: string; name: string; email: string }; + AuthApp: { key: string; name: string }; + AuthCredentialsExtra: { token: string }; + AuthArtifactsExtra: { provider: string; raw: object }; +} + +const route: ServerRoute = { + method: 'GET', + path: '/profile', + handler: (request, h) => { + + // credentials = AuthCredentials & AuthCredentialsExtra + const token: string = request.auth.credentials.token; + const email: string = request.auth.credentials.user!.email; + + // artifacts = AuthArtifactsExtra + const provider: string = request.auth.artifacts.provider; + + return { token, email, provider }; + } +}; +``` + + +### How Credentials Resolve + +`request.auth` is typed as `RequestAuth` where: + +- `credentials` resolves to `AuthCredentials & CredentialsExtra` + - `AuthCredentials` provides `.scope`, `.user`, and `.app` + - `CredentialsExtra` adds any extra top-level credential properties +- `artifacts` resolves to `ArtifactsExtra` + + +### Augmenting `ReqRefDefaults` for Global Auth + +You can override `AuthCredentialsExtra` globally via `ReqRefDefaults` augmentation: + +```typescript +declare module '@hapi/hapi' { + interface ReqRefDefaults { + AuthCredentialsExtra: Partial<{ sessionId: string }>; + } +} + +// Now ALL routes (even generic ones) see `credentials.sessionId` +function handler(request: Request): string { + + const sid = request.auth.credentials.sessionId; // string | undefined + return sid ?? 'anonymous'; +} +``` + +This is useful for properties that your auth scheme always sets, regardless of route. + + +## Module Augmentation + +Module augmentation uses TypeScript's `declare module` to extend hapi's interfaces globally. The following interfaces support augmentation: + +| Interface | Purpose | +| --------------------------- | ---------------------------------------- | +| `UserCredentials` | Shape of `request.auth.credentials.user` | +| `AppCredentials` | Shape of `request.auth.credentials.app` | +| `RequestApplicationState` | Shape of `request.app` | +| `ServerApplicationState` | Shape of `server.app` | +| `RouteOptionsApp` | Shape of `route.options.app` | +| `ServerMethods` | Typed server methods | +| `Request` | Request decorations | +| `ResponseToolkit` | Toolkit decorations | +| `Server` | Server decorations | +| `ReqRefDefaults` | Global defaults for all ReqRef keys | +| `PluginProperties` | Typed `server.plugins` | +| `PluginsStates` | Typed `request.plugins` | +| `ServerAuthSchemeObjectApi` | Shape of `server.auth.api` | +| `RouteOptionTypes` | Auth strategy/scope type narrowing | +| `RouteRules` | Shape of `route.rules` | +| `HandlerDecorations` | Custom handler types | + +### When to Use Augmentation vs Generic Refs + +**Module augmentation** when the type applies to every route in your application: + +- Auth credentials (you have one auth scheme) +- `request.app` state (same shape everywhere) +- Server decorations and methods + +**Generic refs** when the type is route-specific: + +- Params, Query, Payload (different per route) +- Route-specific auth overrides +- Pre-handler results + +The two work together — augmentation sets the global baseline, and generic refs narrow per-route. + + +## Plugins + +### Defining a Plugin + +```typescript +import { Plugin, Server } from '@hapi/hapi'; + +interface MyPluginOptions { + prefix: string; + debug?: boolean; +} + +const myPlugin: Plugin = { + name: 'my-plugin', + version: '1.0.0', + register: async (server: Server, options: MyPluginOptions) => { + + server.expose('getPrefix', () => options.prefix); + + server.route({ + method: 'GET', + path: '/status', + handler: () => ({ status: 'ok', prefix: options.prefix }) + }); + } +}; +``` + + +### Typed Plugin Decorations + +The second type parameter of `Plugin` declares what the plugin exposes on the server. This lets `server.register()` return a server with typed `plugins` access. + +```typescript +interface MyPluginDecorations { + plugins: { + 'my-plugin': { + getPrefix(): string; + }; + }; +} + +const myPlugin: Plugin = { + name: 'my-plugin', + version: '1.0.0', + register: async (server, options) => { + + server.expose('getPrefix', () => options.prefix); + } +}; + +// Registration returns server with typed plugins +const loaded = await server.register({ + plugin: myPlugin, + options: { prefix: '/api' } +}); + +const prefix: string = loaded.plugins['my-plugin'].getPrefix(); +``` + + +### `ServerRegisterPluginObject` + +When registering with options, wrap in `ServerRegisterPluginObject`: + +```typescript +import { ServerRegisterPluginObject } from '@hapi/hapi'; + +const registration: ServerRegisterPluginObject = { + plugin: myPlugin, + options: { prefix: '/api', debug: true } +}; + +const loaded = await server.register(registration); +``` + + +## Server Methods + +Server methods are functions registered with the server and accessed via `server.methods`. They support built-in caching. + + +### Augmenting `ServerMethods` + +```typescript +import { CachedServerMethod } from '@hapi/hapi'; + +declare module '@hapi/hapi' { + interface ServerMethods { + utils: { + add: CachedServerMethod<(a: number, b: number) => number>; + }; + } +} +``` + + +### Registering a Method + +```typescript +server.method('utils.add', (a: number, b: number) => a + b, { + cache: { + expiresIn: 60000, + generateTimeout: 100 + }, + generateKey: (a: number, b: number) => `${a}:${b}` +}); +``` + +Nested names (e.g. `'utils.add'`) automatically create the object hierarchy under `server.methods`. + + +### Using Cached Methods + +```typescript +// Call the method +const sum: number = await server.methods.utils.add(1, 2); + +// Access cache controls (available when cache is configured) +await server.methods.utils.add.cache?.drop(1, 2); +const stats = server.methods.utils.add.cache?.stats; +``` + +`CachedServerMethod` extends the method type `T` with an optional `.cache` property that provides `drop()` and `stats`. + + +## Decorations + +`server.decorate()` extends framework interfaces with custom properties. TypeScript requires declaring the types via module augmentation first, then calling `server.decorate()`. + +### Step 1: Declare Types + +```typescript +declare module '@hapi/hapi' { + interface Request { + getIp(): string; + } + + interface ResponseToolkit { + success(data: object): object; + } + + interface Server { + getUptime(): number; + } +} +``` + + +### Step 2: Register Decorations + +```typescript +// Request decoration +server.decorate('request', 'getIp', function (this: Request) { + + return this.info.remoteAddress; +}); + +// Toolkit decoration +server.decorate('toolkit', 'success', function (this: ResponseToolkit, data: object) { + + return this.response(data).code(200); +}); + +// Server decoration +server.decorate('server', 'getUptime', function (this: Server) { + + return Date.now() - this.info.started; +}); +``` + + +### Decoration Targets + +| Target | `this` Binding | Decorates | +| ----------- | ----------------- | -------------------- | +| `'request'` | `Request` | `request.*` | +| `'toolkit'` | `ResponseToolkit` | `h.*` | +| `'server'` | `Server` | `server.*` | +| `'handler'` | N/A | Custom handler types | + + +### Options + +- `apply` — when `type` is `'request'`, if `true`, the function is called with the request object and the return value becomes the decoration. Useful for computed properties. +- `extend` — if `true`, overrides an existing decoration. The function receives the previous value and must return the new one. Cannot be used with `'handler'`. + + +### Reserved Property Names + +Each target has reserved names that cannot be decorated. Attempting to use them causes a TypeScript error. For example, `'request'` reserves `server`, `url`, `query`, `path`, `method`, `payload`, `params`, `auth`, `headers`, `state`, `route`, `pre`, `response`, `info`, `orig`, `app`, `plugins`, `log`, `logs`, and other internal keys. + + +## Route Configuration + + +### RouteApp + +Type the `options.app` property on routes: + +```typescript +interface AdminRefs { + RouteApp: { requiredRole: string }; +} + +const route: ServerRoute = { + method: 'GET', + path: '/admin', + options: { + app: { requiredRole: 'admin' }, + handler: (request, h) => { + + const role: string = request.route.settings.app!.requiredRole; + return { role }; + } + } +}; +``` + + +### Pre-handlers with `Pres` + +The `Pres` key types the `request.pre` object. Pre-handler results are assigned via the `assign` property. + +```typescript +interface MyRefs { + Params: { id: string }; + Pres: { user: { name: string; email: string } }; +} + +const route: ServerRoute = { + method: 'GET', + path: '/users/{id}', + options: { + pre: [ + { + method: async (request, h) => { + + return { name: 'Test', email: 'test@example.com' }; + }, + assign: 'user' + } + ], + handler: (request, h) => { + + const userName: string = request.pre.user.name; + return { userName }; + } + } +}; +``` + + +### Rules + +Type custom route rules via the `Rules` ref key: + +```typescript +interface MyRules { + mapTo: string; +} + +interface MyRefs { + Rules: MyRules; +} + +const route: ServerRoute = { + method: 'GET', + path: '/mapped', + rules: { mapTo: '/other' }, + handler: (request, h) => 'ok' +}; +``` + + +### Extension Points + +Route-level extension points use the `ext` option: + +```typescript +const route: ServerRoute = { + method: 'GET', + path: '/', + options: { + ext: { + onPreHandler: { + method: (request, h) => { + + request.log(['info'], 'pre-handler'); + return h.continue; + } + } + }, + handler: (request, h) => 'ok' + } +}; +``` + + +## Lifecycle Types + + +### `Lifecycle.Method` + +The signature for all lifecycle methods (handlers, extensions, pre-handlers, failActions): + +```typescript +type Method = ( + this: MergeRefs['Bind'], + request: Request, + h: ResponseToolkit, + err?: Error +) => ReturnValue; +``` + +The `this` binding comes from the `Bind` ref key or `server.bind()`. + + +### `Lifecycle.ReturnValue` + +All accepted return types from lifecycle methods: + +- `null`, `string`, `number`, `boolean` +- `Buffer` +- `Error` or `Boom` +- `Stream` +- `object` or `object[]` +- `symbol` (toolkit signals: `h.continue`, `h.abandon`, `h.close`) +- `Auth` (from `h.authenticated()`) +- A `Promise` resolving to any of the above + + +### `Lifecycle.FailAction` + +Error handling modes for validation failures, payload parsing errors, etc: + +- `'error'` — return the error as the response +- `'log'` — log the error, continue processing +- `'ignore'` — take no action, continue processing +- A lifecycle method with signature `(request, h, err) => ...` + + +### `Bind` Ref Key + +Controls the `this` binding in lifecycle methods: + +```typescript +interface MyContext { + greeting: string; +} + +interface MyRefs { + Bind: MyContext; +} + +const route: ServerRoute = { + method: 'GET', + path: '/', + options: { + bind: { greeting: 'Hello' }, + handler: function (request, h) { + + return this.greeting; // typed as MyContext + } + } +}; +``` + +Note: `this` binding is ignored when the handler is an arrow function. + + +## Generic Helper Functions + +When writing reusable functions that accept `Request` objects, use a generic parameter instead of the concrete `Request` type. + +### Preferred: Generic Function + +```typescript +function getAuthUser(req: Request) { + + return req.auth.credentials.user; +} +``` + +This accepts `Request` with any refs — both `Request` (defaults) and `Request<{ Params: { id: string } }>`. + + +### Why Not `Request` (No Generic)? + +```typescript +function getAuthUser(req: Request) { + + return req.auth.credentials.user; +} +``` + +This only accepts `Request`. If you call it with `Request<{ Params: { id: string } }>`, TypeScript will report an error because the generic parameter is checked invariantly (see [Known Limitations](#known-limitations--workarounds)). + + +### Bridging Example + +```typescript +import { ReqRef, Request } from '@hapi/hapi'; + +// Generic: accepts Request with any refs +function extractToken(req: Request): string | undefined { + + const auth = req.headers['authorization']; + if (typeof auth === 'string') { + return auth.replace('Bearer ', ''); + } + + return undefined; +} + +// Works with any route's request +const route: ServerRoute<{ Params: { id: string } }> = { + method: 'GET', + path: '/users/{id}', + handler: (request, h) => { + + const token = extractToken(request); // works + return { id: request.params.id, token }; + } +}; +``` + + +## Known Limitations & Workarounds + + +### Request Invariance + +`Request` is not assignable to `Request`. This is a TypeScript structural typing limitation — because `Request` uses its generic parameter in both covariant (return types) and contravariant (method parameters like lifecycle methods) positions, TypeScript treats it invariantly. + +**Workaround:** Use generic functions instead of concrete `Request`: + +```typescript +// Won't work with Request +function bad(req: Request) { ... } + +// Works with any Request +function good(req: Request) { ... } +``` + + +### `Pres` Typing Default + +The `Pres` default is `Record`. Without an explicit `Pres` override in your refs, `request.pre` allows any string key access. If you want strict pre-handler typing, always provide the `Pres` key: + +```typescript +interface StrictRefs { + Pres: { user: UserObject; permissions: string[] }; +} +``` + + +### Avoiding `any` Leakage + +Some defaults use `any` (like `Pres: Record`). To keep your code strict: + +1. Always provide explicit refs for `Pres` when using pre-handlers +2. Override `Payload` when parsing JSON bodies — the default includes `object` which is broad +3. Use `ReqRefDefaults` augmentation to tighten defaults globally when possible