752 lines
18 KiB
JavaScript
752 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
const Stream = require('stream');
|
|
|
|
const Boom = require('@hapi/boom');
|
|
const Bounce = require('@hapi/bounce');
|
|
const Hoek = require('@hapi/hoek');
|
|
const Podium = require('@hapi/podium');
|
|
|
|
const Streams = require('./streams');
|
|
|
|
|
|
const internals = {
|
|
events: Podium.validate(['finish', { name: 'peek', spread: true }]),
|
|
hopByHop: {
|
|
connection: true,
|
|
'keep-alive': true,
|
|
'proxy-authenticate': true,
|
|
'proxy-authorization': true,
|
|
'te': 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']
|
|
};
|
|
|
|
|
|
exports = module.exports = internals.Response = class {
|
|
|
|
constructor(source, request, options = {}) {
|
|
|
|
this.app = {};
|
|
this.headers = {}; // Incomplete as some headers are stored in flags
|
|
this.plugins = {};
|
|
this.request = request;
|
|
this.source = null;
|
|
this.statusCode = null;
|
|
this.variety = null;
|
|
|
|
this.settings = {
|
|
charset: 'utf-8', // '-' required by IANA
|
|
compressed: null,
|
|
encoding: 'utf8',
|
|
message: null,
|
|
passThrough: true,
|
|
stringify: null, // JSON.stringify options
|
|
ttl: null,
|
|
varyEtag: false
|
|
};
|
|
|
|
this._events = null;
|
|
this._payload = null; // Readable stream
|
|
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,
|
|
prepare: options.prepare,
|
|
close: options.close
|
|
};
|
|
|
|
this._setSource(source, options.variety);
|
|
}
|
|
|
|
static wrap(result, request) {
|
|
|
|
if (result instanceof request._core.Response ||
|
|
typeof result === 'symbol') {
|
|
|
|
return result;
|
|
}
|
|
|
|
if (result instanceof Error) {
|
|
return Boom.boomify(result);
|
|
}
|
|
|
|
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';
|
|
|
|
if (source === null ||
|
|
source === undefined) {
|
|
|
|
source = null;
|
|
}
|
|
else if (Buffer.isBuffer(source)) {
|
|
this.variety = 'buffer';
|
|
this._contentType = 'application/octet-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._contentType = typeof this.source === 'string' ? 'text/html' : 'application/json';
|
|
}
|
|
}
|
|
|
|
get events() {
|
|
|
|
if (!this._events) {
|
|
this._events = new Podium.Podium(internals.events);
|
|
}
|
|
|
|
return this._events;
|
|
}
|
|
|
|
code(statusCode) {
|
|
|
|
Hoek.assert(Number.isSafeInteger(statusCode), 'Status code must be an integer');
|
|
|
|
this.statusCode = statusCode;
|
|
this._statusCode = true;
|
|
|
|
return this;
|
|
}
|
|
|
|
message(httpMessage) {
|
|
|
|
this.settings.message = httpMessage;
|
|
return this;
|
|
}
|
|
|
|
header(key, value, options) {
|
|
|
|
key = key.toLowerCase();
|
|
if (key === 'vary') {
|
|
return this.vary(value);
|
|
}
|
|
|
|
return this._header(key, value, options);
|
|
}
|
|
|
|
_header(key, value, options = {}) {
|
|
|
|
const append = options.append ?? false;
|
|
const separator = options.separator || ',';
|
|
const override = options.override !== false;
|
|
const duplicate = options.duplicate !== false;
|
|
|
|
if (!append && override ||
|
|
!this.headers[key]) {
|
|
|
|
this.headers[key] = value;
|
|
}
|
|
else if (override) {
|
|
if (key === 'set-cookie') {
|
|
this.headers[key] = [].concat(this.headers[key], value);
|
|
}
|
|
else {
|
|
const existing = this.headers[key];
|
|
if (!duplicate) {
|
|
const values = existing.split(separator);
|
|
for (const v of values) {
|
|
if (v === value) {
|
|
return this;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.headers[key] = existing + separator + value;
|
|
}
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
vary(value) {
|
|
|
|
if (value === '*') {
|
|
this.headers.vary = '*';
|
|
}
|
|
else if (!this.headers.vary) {
|
|
this.headers.vary = value;
|
|
}
|
|
else if (this.headers.vary !== '*') {
|
|
this._header('vary', value, { append: true, duplicate: false });
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
etag(tag, options) {
|
|
|
|
const entity = this.request._core.Response.entity(tag, options);
|
|
this._header('etag', entity.etag);
|
|
this.settings.varyEtag = entity.vary;
|
|
return this;
|
|
}
|
|
|
|
static entity(tag, options = {}) {
|
|
|
|
Hoek.assert(tag !== '*', 'ETag cannot be *');
|
|
|
|
return {
|
|
etag: (options.weak ? 'W/' : '') + '"' + tag + '"',
|
|
vary: options.vary !== false && !options.weak, // vary defaults to true
|
|
modified: options.modified
|
|
};
|
|
}
|
|
|
|
static unmodified(request, entity) {
|
|
|
|
if (request.method !== 'get' &&
|
|
request.method !== 'head') {
|
|
|
|
return false;
|
|
}
|
|
|
|
// Strong verifier
|
|
|
|
if (entity.etag &&
|
|
request.headers['if-none-match']) {
|
|
|
|
const ifNoneMatch = request.headers['if-none-match'].split(/\s*,\s*/);
|
|
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 (!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;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Weak verifier
|
|
|
|
if (!entity.modified) {
|
|
return false;
|
|
}
|
|
|
|
const ifModifiedSinceHeader = request.headers['if-modified-since'];
|
|
if (!ifModifiedSinceHeader) {
|
|
return false;
|
|
}
|
|
|
|
const ifModifiedSince = internals.parseDate(ifModifiedSinceHeader);
|
|
if (!ifModifiedSince) {
|
|
return false;
|
|
}
|
|
|
|
const lastModified = internals.parseDate(entity.modified);
|
|
if (!lastModified) {
|
|
return false;
|
|
}
|
|
|
|
return ifModifiedSince >= lastModified;
|
|
}
|
|
|
|
type(type) {
|
|
|
|
this._header('content-type', type);
|
|
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);
|
|
return this;
|
|
}
|
|
|
|
location(uri) {
|
|
|
|
this._header('location', uri);
|
|
return this;
|
|
}
|
|
|
|
created(location) {
|
|
|
|
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.replacer = method;
|
|
return this;
|
|
}
|
|
|
|
spaces(count) {
|
|
|
|
this.settings.stringify = this.settings.stringify ?? {};
|
|
this.settings.stringify.space = count;
|
|
return this;
|
|
}
|
|
|
|
suffix(suffix) {
|
|
|
|
this.settings.stringify = this.settings.stringify ?? {};
|
|
this.settings.stringify.suffix = suffix;
|
|
return this;
|
|
}
|
|
|
|
escape(escape) {
|
|
|
|
this.settings.stringify = this.settings.stringify ?? {};
|
|
this.settings.stringify.escape = escape;
|
|
return this;
|
|
}
|
|
|
|
passThrough(enabled) {
|
|
|
|
this.settings.passThrough = enabled !== false; // Defaults to true
|
|
return this;
|
|
}
|
|
|
|
redirect(location) {
|
|
|
|
this.statusCode = 302;
|
|
this.location(location);
|
|
return this;
|
|
}
|
|
|
|
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) {
|
|
|
|
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) {
|
|
|
|
Hoek.assert(this.headers.location, 'Cannot set redirection mode without first setting a location');
|
|
|
|
this._setRewritable(isRewritable !== false); // Defaults to true
|
|
return this;
|
|
}
|
|
|
|
_isTemporary() {
|
|
|
|
return this.statusCode === 302 || this.statusCode === 307;
|
|
}
|
|
|
|
_isRewritable() {
|
|
|
|
return this.statusCode === 301 || this.statusCode === 302;
|
|
}
|
|
|
|
_setTemporary(isTemporary) {
|
|
|
|
if (isTemporary) {
|
|
if (this._isRewritable()) {
|
|
this.statusCode = 302;
|
|
}
|
|
else {
|
|
this.statusCode = 307;
|
|
}
|
|
}
|
|
else {
|
|
if (this._isRewritable()) {
|
|
this.statusCode = 301;
|
|
}
|
|
else {
|
|
this.statusCode = 308;
|
|
}
|
|
}
|
|
}
|
|
|
|
_setRewritable(isRewritable) {
|
|
|
|
if (isRewritable) {
|
|
if (this._isTemporary()) {
|
|
this.statusCode = 302;
|
|
}
|
|
else {
|
|
this.statusCode = 301;
|
|
}
|
|
}
|
|
else {
|
|
if (this._isTemporary()) {
|
|
this.statusCode = 307;
|
|
}
|
|
else {
|
|
this.statusCode = 308;
|
|
}
|
|
}
|
|
}
|
|
|
|
encoding(encoding) {
|
|
|
|
this.settings.encoding = encoding;
|
|
return this;
|
|
}
|
|
|
|
charset(charset) {
|
|
|
|
this.settings.charset = charset ?? null;
|
|
return this;
|
|
}
|
|
|
|
ttl(ttl) {
|
|
|
|
this.settings.ttl = ttl;
|
|
return this;
|
|
}
|
|
|
|
state(name, value, options) {
|
|
|
|
this.request._setState(name, value, options);
|
|
return this;
|
|
}
|
|
|
|
unstate(name, options) {
|
|
|
|
this.request._clearState(name, options);
|
|
return this;
|
|
}
|
|
|
|
takeover() {
|
|
|
|
this._takeover = true;
|
|
return this;
|
|
}
|
|
|
|
_prepare() {
|
|
|
|
Hoek.assert(this._state === 'init');
|
|
|
|
this._state = 'prepare';
|
|
|
|
this._passThrough();
|
|
|
|
if (!this._processors.prepare) {
|
|
return this;
|
|
}
|
|
|
|
try {
|
|
return this._processors.prepare(this);
|
|
}
|
|
catch (err) {
|
|
throw Boom.boomify(err);
|
|
}
|
|
}
|
|
|
|
_passThrough() {
|
|
|
|
if (this.variety === 'stream' &&
|
|
this.settings.passThrough) {
|
|
|
|
if (this.source.statusCode &&
|
|
!this.statusCode) {
|
|
|
|
this.statusCode = this.source.statusCode; // Stream is an HTTP response
|
|
}
|
|
|
|
if (this.source.headers) {
|
|
let headerKeys = Object.keys(this.source.headers);
|
|
|
|
if (headerKeys.length) {
|
|
const localHeaders = this.headers;
|
|
this.headers = {};
|
|
|
|
const connection = this.source.headers.connection;
|
|
const byHop = {};
|
|
if (connection) {
|
|
connection.split(/\s*,\s*/).forEach((header) => {
|
|
|
|
byHop[header] = true;
|
|
});
|
|
}
|
|
|
|
for (const key of headerKeys) {
|
|
const lower = key.toLowerCase();
|
|
if (!internals.hopByHop[lower] &&
|
|
!byHop[lower]) {
|
|
|
|
this.header(lower, Hoek.clone(this.source.headers[key])); // Clone arrays
|
|
}
|
|
}
|
|
|
|
headerKeys = Object.keys(localHeaders);
|
|
for (const key of headerKeys) {
|
|
this.header(key, localHeaders[key], { append: key === 'set-cookie' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.statusCode = this.statusCode ?? 200;
|
|
}
|
|
|
|
async _marshal() {
|
|
|
|
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);
|
|
}
|
|
catch (err) {
|
|
throw Boom.boomify(err);
|
|
}
|
|
}
|
|
|
|
// Stream source
|
|
|
|
if (Streams.isStream(source)) {
|
|
this._payload = source;
|
|
return;
|
|
}
|
|
|
|
// Plain source (non string or null)
|
|
|
|
const jsonify = this.variety === 'plain' && source !== null && typeof source !== 'string';
|
|
|
|
if (!jsonify &&
|
|
this.settings.stringify) {
|
|
|
|
throw Boom.badImplementation('Cannot set formatting options on non object response');
|
|
}
|
|
|
|
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;
|
|
|
|
try {
|
|
if (replacer || space) {
|
|
payload = JSON.stringify(payload, replacer, space);
|
|
}
|
|
else {
|
|
payload = JSON.stringify(payload);
|
|
}
|
|
}
|
|
catch (err) {
|
|
throw Boom.boomify(err);
|
|
}
|
|
|
|
if (suffix) {
|
|
payload = payload + suffix;
|
|
}
|
|
|
|
if (escape) {
|
|
payload = Hoek.escapeJson(payload);
|
|
}
|
|
}
|
|
|
|
this._payload = new internals.Response.Payload(payload, this.settings);
|
|
}
|
|
|
|
_tap() {
|
|
|
|
if (!this._events) {
|
|
return null;
|
|
}
|
|
|
|
if (this._events.hasListeners('peek') ||
|
|
this._events.hasListeners('finish')) {
|
|
|
|
return new internals.Response.Peek(this._events);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
_close() {
|
|
|
|
if (this._state === 'close') {
|
|
return;
|
|
}
|
|
|
|
this._state = 'close';
|
|
|
|
if (this._processors.close) {
|
|
try {
|
|
this._processors.close(this);
|
|
}
|
|
catch (err) {
|
|
Bounce.rethrow(err, 'system');
|
|
this.request._log(['response', 'cleanup', 'error'], err);
|
|
}
|
|
}
|
|
|
|
const stream = this._payload || this.source;
|
|
if (Streams.isStream(stream)) {
|
|
internals.Response.drain(stream);
|
|
}
|
|
}
|
|
|
|
_isPayloadSupported() {
|
|
|
|
return this.request.method !== 'head' && this.statusCode !== 304 && this.statusCode !== 204;
|
|
}
|
|
|
|
static drain(stream) {
|
|
|
|
stream.destroy();
|
|
}
|
|
};
|
|
|
|
|
|
internals.Response.reserved = internals.reserved;
|
|
|
|
|
|
internals.parseDate = function (string) {
|
|
|
|
try {
|
|
return Date.parse(string);
|
|
}
|
|
catch (errIgnore) { }
|
|
};
|
|
|
|
|
|
internals.Response.Payload = class extends Stream.Readable {
|
|
|
|
constructor(payload, options) {
|
|
|
|
super();
|
|
|
|
this._data = payload;
|
|
this._encoding = options.encoding;
|
|
}
|
|
|
|
_read(size) {
|
|
|
|
if (this._data) {
|
|
this.push(this._data, this._encoding);
|
|
}
|
|
|
|
this.push(null);
|
|
}
|
|
|
|
size() {
|
|
|
|
if (!this._data) {
|
|
return 0;
|
|
}
|
|
|
|
return Buffer.isBuffer(this._data) ? this._data.length : Buffer.byteLength(this._data, this._encoding);
|
|
}
|
|
|
|
writeToStream(stream) {
|
|
|
|
if (this._data) {
|
|
stream.write(this._data, this._encoding);
|
|
}
|
|
|
|
stream.end();
|
|
}
|
|
};
|
|
|
|
|
|
internals.Response.Peek = class extends Stream.Transform {
|
|
|
|
constructor(podium) {
|
|
|
|
super();
|
|
|
|
this._podium = podium;
|
|
this.on('finish', () => podium.emit('finish'));
|
|
}
|
|
|
|
_transform(chunk, encoding, callback) {
|
|
|
|
this._podium.emit('peek', [chunk, encoding]);
|
|
this.push(chunk, encoding);
|
|
callback();
|
|
}
|
|
};
|