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/.npmrc b/.npmrc
deleted file mode 100644
index 4ec273f77..000000000
--- a/.npmrc
+++ /dev/null
@@ -1,2 +0,0 @@
-save=false
-
diff --git a/.travis.yml b/.travis.yml
index a420ca96d..1d563701a 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,14 +3,22 @@ language: node_js
node_js:
- "8"
- "10"
- - "11"
- - "node"
+ - "12"
sudo: false
install:
- "npm install"
+cache:
+ npm: false
+ directories:
+ - $HOME/.npm
+
+before_install:
+ - if [[ "$TRAVIS_OS_NAME" != "windows" ]]; then npm update -g npm; fi
+ - if [[ "$TRAVIS_OS_NAME" = "windows" ]]; then node `npm prefix -g`/node_modules/npm/bin/npm-cli.js i -g npm@latest; fi
+
os:
- "linux"
- "osx"
diff --git a/API.md b/API.md
index 3b7e2994b..0d1fd57cb 100755
--- a/API.md
+++ b/API.md
@@ -1,4 +1,4 @@
-# v18.3.x API Reference
+# v18.4.x API Reference
@@ -161,6 +161,7 @@
- [`route.options.validate.params`](#route.options.validate.params)
- [`route.options.validate.payload`](#route.options.validate.payload)
- [`route.options.validate.query`](#route.options.validate.query)
+ - [`route.options.validate.state`](#route.options.validate.state)
- [Request lifecycle](#request-lifecycle)
- [Lifecycle methods](#lifecycle-methods)
- [Lifecycle workflow](#lifecycle-workflow)
@@ -201,6 +202,7 @@
- [`response.charset(charset)`](#response.charset())
- [`response.code(statusCode)`](#response.code())
- [`response.message(httpMessage)`](#response.message())
+ - [`response.compressed(encoding)`](#response.compressed())
- [`response.created(uri)`](#response.created())
- [`response.encoding(encoding)`](#response.encoding())
- [`response.etag(tag, options)`](#response.etag())
@@ -792,44 +794,30 @@ 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.
+- `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` - 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
@@ -1140,7 +1128,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`
@@ -2820,7 +2808,7 @@ where:
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)`
@@ -3092,7 +3080,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.
@@ -3925,7 +3913,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. 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.
@@ -3946,7 +3934,7 @@ 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:
@@ -4197,7 +4185,7 @@ Sets the response 'ETag' and 'Last-Modified' headers and checks for any conditio
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 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.
@@ -4499,6 +4487,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:
@@ -4958,7 +4956,7 @@ The parsed request URI.
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:
+- `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
diff --git a/LICENSE.md b/LICENSE.md
index e32cefd50..698a70839 100755
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -1,6 +1,6 @@
-Copyright (c) 2011-2019, Sideway Inc, and project contributors
-Copyright (c) 2011-2014, Walmart
-Copyright (c) 2011, Yahoo Inc.
+Copyright (c) 2011-2019, Sideway Inc, and 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:
diff --git a/README.md b/README.md
index 31d700db9..a27796ee5 100755
--- a/README.md
+++ b/README.md
@@ -1,37 +1,16 @@
-
+
-### Web and services application framework
+# @hapi/hapi
-Enterprise support plans and commercial licensing are available. Please check the
-[Version Support Schedule](https://github.com/hapijs/hapi/blob/master/SUPPORT.md) for more information.
+# 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 18.x only supports node v8.12.0 and over. For older version of node please use hapi version 16.x.
+### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support
-Development version: **18.1.x** ([release notes](https://github.com/hapijs/hapi/issues?labels=release+notes&page=1&state=closed))
-[](https://travis-ci.org/hapijs/hapi)
+## Useful resources
-For the latest updates, [change log](https://hapijs.com/updates), and release information visit [hapijs.com](https://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).
-
-# Sponsorship
-
-## Featured Sponsors
-
-[](https://bit.ly/auth0h-rn)
-
-## Active Supporters
-
-- **CNN Digital**
-- **[Contentful](https://www.contentful.com/)**
-- The product development team at **Creative Artists Agency**
-
-## Legacy Supporters
-
-Past major financial support for the project was provided by:
-
-- [Condé Nast Technology](https://technology.condenast.com/)
-- [](https://lob.com/)
+- [Documentation and API](https://hapi.dev/)
+- [Version status](https://hapi.dev/resources/status/#hapi) (builds, dependencies, node versions, licenses, eol)
+- [Project policies](https://hapi.dev/policies/)
+- [Free and commercial support options](https://hapi.dev/support/)
diff --git a/SPONSORS.md b/SPONSORS.md
index e4b699434..e0956bd12 100755
--- a/SPONSORS.md
+++ b/SPONSORS.md
@@ -2,6 +2,7 @@
- Alexander Alimovs
- Alvin Lumowa
+- Andreas Zeuch
- Andres Garcia / Florence Healthcare
- Auth0
- Benjamin Flesch / StriveWire
@@ -19,6 +20,7 @@
- Dmitry Savinkov
- Elba Sánchez Márquez
- Eric Lanehart
+- Fabian Gündel / DataWrapper.de
- [gitconnected](https://gitconnected.com)
- Greg Allen / First + Third
- Healthline Media
@@ -30,6 +32,7 @@
- Luke Bond
- Mahesh Babu R
- Manny Pamintuan
+- Marie Clemenssy
- Max Fierro
- Nicklas Laine Overgaard
- NZZ Visuals
@@ -40,13 +43,16 @@
- Raffi Minassian
- Raquel Hernandez
- Robert McGuinness
+- Rocky Jaiswal
- Saul Maddox
+- Seth Holladay
- Shane Warren
- Srikanth Minnam
- Sven Lito
- Tim Boudreau
- Trey Huffine
- Umut Şirin
+- Viswanadha Pratap Kondoju
- Yehor Sergeenko
# Supporters
@@ -54,7 +60,7 @@
- Adam Recvlohe
- Adilson Schmitt Junior
- Alexander Springer
-- Andreas Zeuch
+- Amit Lahav
- Checkly
- Chris St
- Daniel Cassidy
@@ -64,12 +70,10 @@
- Finnegan
- Geraint Corneu
- Marcos Bérgamo
-- Marie Clemenssy
- Nathan Buchar
- Nathan Heskew
- PIer Bover
- Robin van der Vleuten
- Sheridan Tighe
- Troy Whiteley
-- Viswanadha Pratap Kondoju
- Wouter De Loose
diff --git a/SUPPORT.md b/SUPPORT.md
deleted file mode 100755
index 1dbee0eea..000000000
--- a/SUPPORT.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# **hapi** core modules support policy
-
-The hapi code module and its dependencies are published under the following support rules:
-
-TL;DR
-
-Every **major** version of the [core module](https://github.com/hapijs/hapi) receives at least one
-year of support from the time of publication of the first `x.0.0` release. When a new **major**
-version is published, the previous **major** version gets at least 3 months of support from that
-moment. There are typically one to three major releases each year and most are simple to upgrade.
-Core dependecies are maintained as long as they are used by a supported core version.
-
-## Current versions
-
-- `v18.x.x` - under active support. Maintenance support until at least January 18th, 2020.
-- `v17.x.x` - under maintenance support until August 1st, 2019.
-
-Extended paid support options is available for v16, v17, and v18 by contacting
-[sales@sideway.com](mailto:sales.sideway.com). For more information visit [hapi.business](http://hapi.business).
-
-## Latest version
-
-The latest version of the `hapi` module published to [npm](https://www.npmjs.com/package/hapi) is
-the currently supported version. It receives bug fixes, security patches, and new features on an
-ongoing basis.
-
-Only the latest published module of the current **major** version is supported. For example, if the
-current published version is `v10.2.3`, it is the only supported version of the `v10.x.x` branch.
-If you are using an older version of `v10.x.x` such as `v10.0.4`, you must upgrade to `v10.2.3`
-before opening issues and seeking support.
-
-## Previous **major** version
-
-Once a new major version is published, the previous major version goes into maintenance mode.
-Versions in maintenance mode receive critical bug fixes and security patches but do not receive new
-features.
-
-Each major version branch stays in maintenance mode for whichever is the **longer** period:
-- 1 year from the day of the first publication of **this** major version, or
-- 3 months from the day of the first publication of the **following** major version.
-
-For example, if:
-- version `v9.0.0` was published on January 1st, 2010, and
-- version `v10.0.0` was published on May 1st, 2010
-
-Support for `v9.x.x` will end on January 1st, 2011 (one year from time of initial publication of
-the `v9.0.0` release).
-
-However, if:
-- version `v9.0.0` was published on January 1st, 2010, and
-- version `v10.0.0` was published on December 1st, 2010
-
-Support for `v9.x.x` will end on March 1st, 2011 (three months from time of initial publication of
-the `v10.0.0` release).
-
-## Deprecated versions
-
-When a version is no longer supported, it will be marked as `deprecated` in the npm registry. You
-may continue using it at your own risk, ignoring the warning messages.
-
-Starting with v16, you may obtain extended paid support by contacting [sales@sideway.com](mailto:sales.sideway.com).
diff --git a/lib/compression.js b/lib/compression.js
index 95bb544e3..8c241a48b 100755
--- a/lib/compression.js
+++ b/lib/compression.js
@@ -77,9 +77,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;
}
@@ -95,7 +100,7 @@ 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) {
diff --git a/lib/core.js b/lib/core.js
index fb0e998bd..d74c5f340 100755
--- a/lib/core.js
+++ b/lib/core.js
@@ -77,6 +77,7 @@ exports = module.exports = internals.Core = class {
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.actives = new WeakMap(); // Active requests being processed
this.started = false;
this.states = new Statehood.Definitions(this.settings.state);
this.toolkit = new Toolkit();
@@ -119,7 +120,7 @@ exports = module.exports = internals.Core = class {
const debug = (request, event) => {
const data = event.error || event.data;
- console.error('Debug:', event.tags.join(', '), (data ? '\n ' + (data.stack || (typeof data === 'object' ? Hoek.stringify(data) : data)) : ''));
+ console.error('Debug:', event.tags.join(', '), data ? '\n ' + (data.stack || (typeof data === 'object' ? Hoek.stringify(data) : data)) : '');
};
if (this.settings.debug.log) {
@@ -409,7 +410,7 @@ exports = module.exports = internals.Core = class {
this.sockets.forEach((connection) => {
- if (!connection._isHapiProcessing) {
+ if (!this.actives.has(connection)) {
connection.end();
}
});
@@ -462,18 +463,18 @@ 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;
+ 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) {
@@ -502,8 +503,16 @@ 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.actives.get(socket);
+ if (request) {
+ const error = Boom.badRequest();
+ error.output.headers = { connection: 'close' };
+ request._reply(error);
+ }
+ else {
+ socket.end(internals.badRequestResponse);
+ }
}
else {
socket.destroy(err);
@@ -523,7 +532,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) {
@@ -579,7 +588,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 };
@@ -594,7 +603,7 @@ exports = module.exports = internals.Core = class {
internals.setup = function (options = {}) {
- let settings = Hoek.cloneWithShallow(options, ['cache', 'listener', 'routes.bind']);
+ let settings = Hoek.clone(options, { shallow: ['cache', 'listener', 'routes.bind'] });
settings.routes = Config.enable(settings.routes);
settings = Config.apply('server', settings);
@@ -634,7 +643,7 @@ internals.onFinish = function (env) {
const { core, req } = env;
- req.socket._isHapiProcessing = false;
+ core.actives.delete(req.socket);
if (!core.started) {
req.socket.end();
}
diff --git a/lib/headers.js b/lib/headers.js
index 4480d7c16..1d39e6c0d 100755
--- a/lib/headers.js
+++ b/lib/headers.js
@@ -180,8 +180,13 @@ exports.unmodified = function (request) {
modified: response.headers['last-modified']
};
- if (Response.unmodified(request, entity)) {
+ const etag = 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/methods.js b/lib/methods.js
index 528a57918..794b8c787 100755
--- a/lib/methods.js
+++ b/lib/methods.js
@@ -43,7 +43,7 @@ exports = module.exports = internals.Methods = class {
options = Config.apply('method', options, name);
- const settings = Hoek.cloneWithShallow(options, ['bind']);
+ const settings = Hoek.clone(options, { shallow: ['bind'] });
settings.generateKey = settings.generateKey || internals.generateKey;
const bind = settings.bind || realm.settings.bind || null;
diff --git a/lib/request.js b/lib/request.js
index aa056b0b3..2e7d2c71f 100755
--- a/lib/request.js
+++ b/lib/request.js
@@ -35,7 +35,7 @@ exports = module.exports = internals.Request = class {
this._states = {};
this._urlError = null;
- this.app = (options.app ? Object.assign({}, options.app) : {}); // Place for application-specific state without conflicts with hapi, should not be used by plugins (shallow cloned)
+ this.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;
@@ -47,7 +47,7 @@ exports = module.exports = internals.Request = class {
this.paramsArray = null; // Array of path parameters in path order
this.path = null;
this.payload = null;
- 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.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 };
@@ -132,9 +132,9 @@ exports = module.exports = internals.Request = class {
_setUrl(url, stripTrailingSlash) {
- const base = (url[0] === '/' ? `${this._core.info.protocol}://${this.info.host || `${this._core.info.host}:${this._core.info.port}`}` : undefined);
+ const base = url[0] === '/' ? `${this._core.info.protocol}://${this.info.host || `${this._core.info.host}:${this._core.info.port}`}` : '';
- url = new Url.URL(url, base);
+ url = new Url.URL(base + url);
// Apply path modifications
@@ -496,7 +496,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 Response.Peek(this._events);
+ }
+
+ return null;
}
log(tags, data) {
@@ -517,7 +523,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') {
diff --git a/lib/response.js b/lib/response.js
index b85a791e0..5abf775e5 100755
--- a/lib/response.js
+++ b/lib/response.js
@@ -38,19 +38,19 @@ 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._contentType = null; // Used if no explicit content-type is set and type is known
this._takeover = false;
this._statusCode = false; // true when code() called
@@ -98,7 +98,7 @@ 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';
}
@@ -154,7 +154,7 @@ exports = module.exports = internals.Response = class {
const override = options.override !== false;
const duplicate = options.duplicate !== false;
- if ((!append && override) ||
+ if (!append && override ||
!this.headers[key]) {
this.headers[key] = value;
@@ -210,12 +210,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') {
@@ -225,22 +225,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 (const etag of ifNoneMatch) {
- if (etag === options.etag) {
+
+ // 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 (const encoder of encoders) {
- if (etag === etagBase + `-${encoder}"`) {
- 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;
}
}
}
@@ -250,7 +259,7 @@ exports = module.exports = internals.Response = class {
// Weak verifier
- if (!options.modified) {
+ if (!entity.modified) {
return false;
}
@@ -264,7 +273,7 @@ exports = module.exports = internals.Response = class {
return false;
}
- const lastModified = internals.parseDate(options.modified);
+ const lastModified = internals.parseDate(entity.modified);
if (!lastModified) {
return false;
}
@@ -301,6 +310,13 @@ exports = module.exports = internals.Response = class {
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 || {};
@@ -331,7 +347,7 @@ exports = module.exports = internals.Response = class {
passThrough(enabled) {
- this.settings.passThrough = (enabled !== false); // Defaults to true
+ this.settings.passThrough = enabled !== false; // Defaults to true
return this;
}
@@ -529,7 +545,7 @@ exports = module.exports = internals.Response = class {
// Stream source
- if (source instanceof Stream) {
+ if (Streams.isStream(source)) {
if (typeof source._read !== 'function') {
throw Boom.badImplementation('Stream must have a readable interface');
}
@@ -544,7 +560,7 @@ exports = module.exports = internals.Response = class {
// 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) {
@@ -591,7 +607,13 @@ 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) {
@@ -607,14 +629,14 @@ exports = module.exports = internals.Response = class {
}
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) {
diff --git a/lib/route.js b/lib/route.js
index 953df5624..627d5c4d8 100755
--- a/lib/route.js
+++ b/lib/route.js
@@ -492,7 +492,7 @@ internals.config = function (chain) {
let config = chain[0];
for (const item of chain) {
- config = Hoek.applyToDefaultsWithShallow(config, item, ['bind', 'validate.headers', 'validate.payload', 'validate.params', 'validate.query', 'validate.state']);
+ config = Hoek.applyToDefaults(config, item, { shallow: ['bind', 'validate.headers', 'validate.payload', 'validate.params', 'validate.query', 'validate.state'] });
}
return config;
diff --git a/lib/streams.js b/lib/streams.js
index 7b9dc4168..4bad93621 100755
--- a/lib/streams.js
+++ b/lib/streams.js
@@ -8,6 +8,14 @@ const internals = {
};
+exports.isStream = function (stream) {
+
+ return stream &&
+ typeof stream === 'object' &&
+ typeof stream.pipe === 'function';
+};
+
+
exports.drain = function (stream) {
const team = new Teamwork();
@@ -16,6 +24,7 @@ exports.drain = function (stream) {
stream.on('readable', internals.read);
stream.on('error', internals.end);
stream.on('end', internals.end);
+ stream.on('close', internals.end);
return team.work;
};
@@ -23,7 +32,7 @@ exports.drain = function (stream) {
internals.read = function () {
- this.read();
+ while (this.read()) { }
};
@@ -32,6 +41,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/transmit.js b/lib/transmit.js
index cbf5a4d7d..56c96af94 100755
--- a/lib/transmit.js
+++ b/lib/transmit.js
@@ -19,11 +19,13 @@ const internals = {};
exports.send = async function (request) {
const response = request.response;
- if (response.isBoom) {
- return internals.fail(request, response);
- }
try {
+ if (response.isBoom) {
+ await internals.fail(request, response);
+ return;
+ }
+
await internals.marshal(request);
await internals.transmit(response);
}
@@ -45,7 +47,7 @@ internals.marshal = async function (request) {
internals.fail = async function (request, boom) {
- const response = internals.error(boom, request);
+ const response = internals.error(request, boom);
request.response = response; // Not using request._setResponse() to avoid double log
try {
@@ -69,7 +71,7 @@ internals.fail = async function (request, boom) {
};
-internals.error = function (boom, request) {
+internals.error = function (request, boom) {
const error = boom.output;
const response = new Response(error.payload, request);
@@ -88,14 +90,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);
if (!(isInjection || request._core.started) ||
- (request._isPayloadPending && !request.raw.req._readableState.ended)) {
+ request._isPayloadPending && !request.raw.req._readableState.ended) {
response._header('connection', 'close');
}
@@ -292,7 +294,7 @@ internals.end = function (env, event, err) {
}
err = err || new Boom(`Request ${event}`, { statusCode: request.route.settings.response.disconnectStatusCode });
- const error = internals.error(Boom.boomify(err), request);
+ const error = internals.error(request, Boom.boomify(err));
request._setResponse(error);
if (request.raw.res[Config.symbol]) {
diff --git a/lib/validation.js b/lib/validation.js
index 0bdfa9610..f83550384 100755
--- a/lib/validation.js
+++ b/lib/validation.js
@@ -10,16 +10,33 @@ const internals = {};
exports.compile = function (rule) {
- // null, undefined, true - anything allowed
// false - nothing allowed
+
+ if (rule === false) {
+ return Joi.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;
+ }
+ return Joi.compile(rule);
};
@@ -83,7 +100,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) {
@@ -174,7 +191,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);
@@ -196,3 +213,13 @@ exports.response = async function (request) {
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/package.json b/package.json
old mode 100644
new mode 100755
index a2c5f8cdd..63e9ffec1
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "@hapi/hapi",
"description": "HTTP Server framework",
"homepage": "https://hapijs.com",
- "version": "18.3.1",
+ "version": "18.4.1",
"repository": "git://github.com/hapijs/hapi",
"main": "lib/index.js",
"keywords": [
@@ -11,38 +11,42 @@
"api",
"web"
],
+ "files": [
+ "lib"
+ ],
"dependencies": {
- "@hapi/accept": "3.x.x",
- "@hapi/ammo": "3.x.x",
+ "@hapi/accept": "^3.2.4",
+ "@hapi/ammo": "^3.1.2",
"@hapi/boom": "7.x.x",
"@hapi/bounce": "1.x.x",
- "@hapi/call": "5.x.x",
+ "@hapi/call": "^5.1.3",
"@hapi/catbox": "10.x.x",
"@hapi/catbox-memory": "4.x.x",
"@hapi/heavy": "6.x.x",
- "@hapi/hoek": "6.x.x",
+ "@hapi/hoek": "8.x.x",
"@hapi/joi": "15.x.x",
"@hapi/mimos": "4.x.x",
"@hapi/podium": "3.x.x",
"@hapi/shot": "4.x.x",
"@hapi/somever": "2.x.x",
"@hapi/statehood": "6.x.x",
- "@hapi/subtext": "6.x.x",
+ "@hapi/subtext": "^6.1.3",
"@hapi/teamwork": "3.x.x",
"@hapi/topo": "3.x.x"
},
"devDependencies": {
- "@hapi/code": "5.x.x",
+ "@hapi/code": "6.x.x",
"@hapi/inert": "5.x.x",
- "@hapi/lab": "18.x.x",
+ "@hapi/joi-next-test": "npm:@hapi/joi@16.x.x",
+ "@hapi/lab": "20.x.x",
"@hapi/wreck": "15.x.x",
"@hapi/vision": "5.x.x",
"handlebars": "4.x.x"
},
"scripts": {
- "test": "lab -a @hapi/code -t 100 -L -m 3000",
- "test-tap": "lab -a @hapi/code -r tap -o tests.tap -m 3000",
- "test-cov-html": "lab -a @hapi/code -r html -o coverage.html -m 3000"
+ "test": "lab -a @hapi/code -t 100 -L -m 5000",
+ "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/core.js b/test/core.js
index 1688130ad..2938c18cb 100755
--- a/test/core.js
+++ b/test/core.js
@@ -7,6 +7,7 @@ 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('@hapi/boom');
@@ -765,6 +766,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);
@@ -829,11 +831,11 @@ describe('Core', () => {
const count1 = await internals.countConnections(server);
expect(count1).to.equal(1);
- setTimeout(() => socket.end(), 50);
+ setTimeout(() => socket.end(), 100);
const timer = new Hoek.Bench();
- await server.stop({ timeout: 200 });
- expect(timer.elapsed()).to.be.below(150);
+ await server.stop({ timeout: 400 });
+ expect(timer.elapsed()).to.be.below(300);
});
it('immediately destroys idle keep-alive connections', async () => {
@@ -1186,6 +1188,58 @@ describe('Core', () => {
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();
diff --git a/test/payload.js b/test/payload.js
index f53aba681..a9d22ca08 100755
--- a/test/payload.js
+++ b/test/payload.js
@@ -110,14 +110,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);
@@ -125,6 +145,19 @@ 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();
@@ -235,6 +268,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.' };
diff --git a/test/request.js b/test/request.js
index 87a441a6f..0bad46c12 100755
--- a/test/request.js
+++ b/test/request.js
@@ -1250,6 +1250,27 @@ describe('Request', () => {
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() });
+ });
});
describe('_tap()', () => {
@@ -1468,7 +1489,7 @@ 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();
};
@@ -1759,6 +1780,103 @@ describe('Request', () => {
expect(res.statusCode).to.equal(200);
});
+ it('creates null 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.abort();
+ const [request] = await log;
+
+ expect(request.response).to.equal(null);
+
+ 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 a logged bad request error when parser fails after request is setup', async () => {
+
+ const server = Hapi.server({ routes: { timeout: { server: false } } });
+ await server.start();
+
+ const log = server.events.once('response');
+ 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\nHost: test\nContent-Length: 0\n\n\ninvalid data');
+
+ const [request] = await log;
+ expect(request.response.statusCode).to.equal(400);
+ const clientResponse = await clientEnded;
+ 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;
@@ -1847,7 +1965,7 @@ describe('Request', () => {
it('handles race condition between equal client and server timeouts', async () => {
- 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();
@@ -1865,7 +1983,7 @@ describe('Request', () => {
const req = Http.request(options, (res) => {
expect([503, 408]).to.contain(res.statusCode);
- expect(timer.elapsed()).to.be.at.least(45);
+ expect(timer.elapsed()).to.be.at.least(80);
resolve();
});
@@ -1875,7 +1993,7 @@ describe('Request', () => {
});
req.write('\n');
- await Hoek.wait(100);
+ await Hoek.wait(200);
req.end();
});
diff --git a/test/response.js b/test/response.js
index 1b2bf5d63..2ca30eb8e 100755
--- a/test/response.js
+++ b/test/response.js
@@ -729,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 () => {
@@ -1291,6 +1320,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();
diff --git a/test/toolkit.js b/test/toolkit.js
index 871959516..3643a22d6 100755
--- a/test/toolkit.js
+++ b/test/toolkit.js
@@ -585,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 ec66a46bb..16174512a 100755
--- a/test/transmit.js
+++ b/test/transmit.js
@@ -27,6 +27,33 @@ 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 () => {
@@ -171,62 +198,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 () => {
@@ -718,7 +777,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') });
@@ -1407,6 +1466,7 @@ describe('transmission', () => {
port: server.info.port,
method: 'GET'
});
+
clientRequest.on('error', () => { /* NOP */ });
clientRequest.end();
@@ -1416,13 +1476,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 () => {
diff --git a/test/validation.js b/test/validation.js
index 93a4953c0..7d3fd9b9c 100755
--- a/test/validation.js
+++ b/test/validation.js
@@ -5,6 +5,7 @@ const Code = require('@hapi/code');
const Hapi = require('..');
const Inert = require('@hapi/inert');
const Joi = require('@hapi/joi');
+const JoiNext = require('@hapi/joi-next-test');
const Lab = require('@hapi/lab');
@@ -17,6 +18,30 @@ const expect = Code.expect;
describe('validation', () => {
+ it('validates using joi v16', async () => {
+
+ const server = Hapi.server();
+ server.route({
+ method: 'POST',
+ path: '/',
+ handler: () => 'ok',
+ options: {
+ validate: {
+ payload: JoiNext.object({
+ a: JoiNext.number(),
+ b: JoiNext.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 () => {