diff --git a/benchmark/benchmark.js b/benchmark/benchmark.js index 760191f..71dc17b 100644 --- a/benchmark/benchmark.js +++ b/benchmark/benchmark.js @@ -85,7 +85,7 @@ suite new Request(mockReq) // eslint-disable-line no-new }) .add('Custom Request', function () { - new Request.CustomRequest(mockCustomReq) // eslint-disable-line no-new + new (Request.getCustomRequest(mockCustomReq.Request))(mockCustomReq) // eslint-disable-line no-new }) .add('Request With Cookies', function () { new Request(mockReqCookies) // eslint-disable-line no-new diff --git a/index.js b/index.js index cdb193b..ee8ec15 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,9 @@ 'use strict' -const assert = require('node:assert') const Request = require('./lib/request') const Response = require('./lib/response') - -const errorMessage = 'The dispatch function has already been invoked' - -const optsValidator = require('./lib/config-validator') +const Chain = require('./lib/chain') +const doInject = require('./lib/do-inject') function inject (dispatchFunc, options, callback) { if (typeof callback === 'undefined') { @@ -16,143 +13,6 @@ function inject (dispatchFunc, options, callback) { } } -function makeRequest (dispatchFunc, server, req, res) { - req.once('error', function (err) { - if (this.destroyed) res.destroy(err) - }) - - req.once('close', function () { - if (this.destroyed && !this._error) res.destroy() - }) - - return req.prepare(() => dispatchFunc.call(server, req, res)) -} - -function doInject (dispatchFunc, options, callback) { - options = (typeof options === 'string' ? { url: options } : options) - - if (options.validate !== false) { - assert(typeof dispatchFunc === 'function', 'dispatchFunc should be a function') - const isOptionValid = optsValidator(options) - if (!isOptionValid) { - throw new Error(optsValidator.errors.map(e => e.message)) - } - } - - const server = options.server || {} - - const RequestConstructor = options.Request - ? Request.CustomRequest - : Request - - // Express.js detection - if (dispatchFunc.request && dispatchFunc.request.app === dispatchFunc) { - Object.setPrototypeOf(Object.getPrototypeOf(dispatchFunc.request), RequestConstructor.prototype) - Object.setPrototypeOf(Object.getPrototypeOf(dispatchFunc.response), Response.prototype) - } - - if (typeof callback === 'function') { - const req = new RequestConstructor(options) - const res = new Response(req, callback) - - return makeRequest(dispatchFunc, server, req, res) - } else { - return new Promise((resolve, reject) => { - const req = new RequestConstructor(options) - const res = new Response(req, resolve, reject) - - makeRequest(dispatchFunc, server, req, res) - }) - } -} - -function Chain (dispatch, option) { - if (typeof option === 'string') { - this.option = { url: option } - } else { - this.option = Object.assign({}, option) - } - - this.dispatch = dispatch - this._hasInvoked = false - this._promise = null - - if (this.option.autoStart !== false) { - process.nextTick(() => { - if (!this._hasInvoked) { - this.end() - } - }) - } -} - -const httpMethods = [ - 'delete', - 'get', - 'head', - 'options', - 'patch', - 'post', - 'put', - 'trace' -] - -httpMethods.forEach(method => { - Chain.prototype[method] = function (url) { - if (this._hasInvoked === true || this._promise) { - throw new Error(errorMessage) - } - this.option.url = url - this.option.method = method.toUpperCase() - return this - } -}) - -const chainMethods = [ - 'body', - 'cookies', - 'headers', - 'payload', - 'query' -] - -chainMethods.forEach(method => { - Chain.prototype[method] = function (value) { - if (this._hasInvoked === true || this._promise) { - throw new Error(errorMessage) - } - this.option[method] = value - return this - } -}) - -Chain.prototype.end = function (callback) { - if (this._hasInvoked === true || this._promise) { - throw new Error(errorMessage) - } - this._hasInvoked = true - if (typeof callback === 'function') { - doInject(this.dispatch, this.option, callback) - } else { - this._promise = doInject(this.dispatch, this.option) - return this._promise - } -} - -Object.getOwnPropertyNames(Promise.prototype).forEach(method => { - if (method === 'constructor') return - Chain.prototype[method] = function (...args) { - if (!this._promise) { - if (this._hasInvoked === true) { - throw new Error(errorMessage) - } - this._hasInvoked = true - this._promise = doInject(this.dispatch, this.option) - } - return this._promise[method](...args) - } -}) - function isInjection (obj) { return ( obj instanceof Request || diff --git a/lib/chain.js b/lib/chain.js new file mode 100644 index 0000000..fd21335 --- /dev/null +++ b/lib/chain.js @@ -0,0 +1,107 @@ +'use strict' + +const doInject = require('./do-inject') + +const errorMessage = 'The dispatch function has already been invoked' + +class Chain { + _hasInvoked = false + _promise = null + option + dispatch + + constructor (dispatch, option) { + this.dispatch = dispatch + if (typeof option === 'string') { + this.option = { url: option } + } else { + this.option = Object.assign({}, option) + } + + if (this.option.autoStart !== false) { + process.nextTick(() => { + if (!this._hasInvoked) { + this.end() + } + }) + } + } + + /** + * @private + * @param {string} method + * @param {string} url + */ + wrapHttpMethod (method, url) { + if (this._hasInvoked === true || this._promise) { + throw new Error(errorMessage) + } + this.option.url = url + this.option.method = method.toUpperCase() + return this + } + + delete (url) { return this.wrapHttpMethod('delete', url) } + get (url) { return this.wrapHttpMethod('get', url) } + head (url) { return this.wrapHttpMethod('head', url) } + options (url) { return this.wrapHttpMethod('options', url) } + patch (url) { return this.wrapHttpMethod('patch', url) } + post (url) { return this.wrapHttpMethod('post', url) } + put (url) { return this.wrapHttpMethod('put', url) } + trace (url) { return this.wrapHttpMethod('trace', url) } + + /** + * @private + * @param {string} method + * @param {string} url + */ + wrapChainMethod (method, value) { + if (this._hasInvoked === true || this._promise) { + throw new Error(errorMessage) + } + this.option[method] = value + return this + } + + body (url) { return this.wrapChainMethod('body', url) } + cookies (url) { return this.wrapChainMethod('cookies', url) } + headers (url) { return this.wrapChainMethod('headers', url) } + payload (url) { return this.wrapChainMethod('payload', url) } + query (url) { return this.wrapChainMethod('query', url) } + + end (callback) { + if (this._hasInvoked === true || this._promise) { + throw new Error(errorMessage) + } + this._hasInvoked = true + if (typeof callback === 'function') { + doInject(this.dispatch, this.option, callback) + } else { + this._promise = doInject(this.dispatch, this.option) + return this._promise + } + } + + /** + * @private + * @template {keyof Promise} T + * @param {T} method + * @param {Parameters} args + */ + promisify (method, args) { + if (!this._promise) { + if (this._hasInvoked === true) { + throw new Error(errorMessage) + } + this._hasInvoked = true + this._promise = doInject(this.dispatch, this.option) + } + return this._promise[method](...args) + } + + then (...args) { return this.promisify('then', args) } + catch (...args) { return this.promisify('catch', args) } + finally (...args) { return this.promisify('finally', args) } +} + +module.exports = Chain diff --git a/lib/do-inject.js b/lib/do-inject.js new file mode 100644 index 0000000..575ceb7 --- /dev/null +++ b/lib/do-inject.js @@ -0,0 +1,63 @@ +'use strict' + +const assert = require('node:assert') +const optsValidator = require('./config-validator') +const Request = require('./request') +const Response = require('./response') + +function makeRequest (dispatchFunc, server, req, res) { + req.once('error', function (err) { + if (this.destroyed) res.destroy(err) + }) + + req.once('close', function () { + if (this.destroyed && !this._error) res.destroy() + }) + + return req.prepare(() => dispatchFunc.call(server, req, res)) +} + +function doInject (dispatchFunc, options, callback) { + options = (typeof options === 'string' ? { url: options } : options) + + if (options.validate !== false) { + assert(typeof dispatchFunc === 'function', 'dispatchFunc should be a function') + const isOptionValid = optsValidator(options) + if (!isOptionValid) { + throw new Error(optsValidator.errors.map(e => e.message)) + } + } + + const server = options.server || {} + + const RequestConstructor = options.Request + ? Request.getCustomRequest(options.Request) + : Request + + // Express.js detection + if (dispatchFunc.request && dispatchFunc.request.app === dispatchFunc) { + Object.setPrototypeOf(Object.getPrototypeOf(dispatchFunc.request), RequestConstructor.prototype) + Object.setPrototypeOf(Object.getPrototypeOf(dispatchFunc.response), Response.prototype) + } + + const req = new RequestConstructor(options) + if (typeof callback === 'function') { + const res = new Response(req, callback) + + return makeRequest(dispatchFunc, server, req, res) + } else { + return new Promise((resolve, reject) => { + const res = new Response(req, (err, data) => { + if (err) { + reject(err) + } else { + resolve(data) + } + }) + + makeRequest(dispatchFunc, server, req, res) + }) + } +} + +module.exports = doInject diff --git a/lib/request.js b/lib/request.js index e197c90..29e02e9 100644 --- a/lib/request.js +++ b/lib/request.js @@ -3,7 +3,6 @@ /* eslint no-prototype-builtins: 0 */ const { Readable, addAbortSignal } = require('node:stream') -const util = require('node:util') const cookie = require('cookie') const assert = require('node:assert') const warning = require('process-warning')() @@ -39,221 +38,199 @@ class MockSocket extends EventEmitter { } } -/** - * CustomRequest - * - * @constructor - * @param {Object} options - * @param {(Object|String)} options.url || options.path - * @param {String} [options.method='GET'] - * @param {String} [options.remoteAddress] - * @param {Object} [options.cookies] - * @param {Object} [options.headers] - * @param {Object} [options.query] - * @param {Object} [options.Request] - * @param {any} [options.payload] - */ -function CustomRequest (options) { - return new _CustomLMRRequest(this) - - function _CustomLMRRequest (obj) { - Request.call(obj, { - ...options, - Request: undefined - }) - Object.assign(this, obj) - - for (const fn of Object.keys(Request.prototype)) { - this.constructor.prototype[fn] = Request.prototype[fn] +class Request extends Readable { + /** + * Request + * + * @param {Object} options + * @param {(Object|String)} options.url || options.path + * @param {String} [options.method='GET'] + * @param {String} [options.remoteAddress] + * @param {Object} [options.cookies] + * @param {Object} [options.headers] + * @param {Object} [options.query] + * @param {any} [options.payload] + */ + constructor (options) { + super({ autoDestroy: false }) + const parsedURL = parseURL(options.url || options.path, options.query) + + this.url = parsedURL.pathname + parsedURL.search + + this.aborted = false + this.httpVersionMajor = 1 + this.httpVersionMinor = 1 + this.httpVersion = '1.1' + this.method = options.method ? options.method.toUpperCase() : 'GET' + + this.headers = {} + this.rawHeaders = [] + const headers = options.headers || {} + + for (const field in headers) { + const fieldLowerCase = field.toLowerCase() + if ( + ( + fieldLowerCase === 'user-agent' || + fieldLowerCase === 'content-type' + ) && headers[field] === undefined + ) { + this.headers[fieldLowerCase] = undefined + continue + } + const value = headers[field] + assert(value !== undefined, 'invalid value "undefined" for header ' + field) + this.headers[fieldLowerCase] = '' + value } - util.inherits(this.constructor, options.Request) - return this - } -} + if (('user-agent' in this.headers) === false) { + this.headers['user-agent'] = 'lightMyRequest' + } + this.headers.host = this.headers.host || options.authority || hostHeaderFromURL(parsedURL) -/** - * Request - * - * @constructor - * @param {Object} options - * @param {(Object|String)} options.url || options.path - * @param {String} [options.method='GET'] - * @param {String} [options.remoteAddress] - * @param {Object} [options.cookies] - * @param {Object} [options.headers] - * @param {Object} [options.query] - * @param {any} [options.payload] - */ -function Request (options) { - Readable.call(this, { - autoDestroy: false - }) - - const parsedURL = parseURL(options.url || options.path, options.query) - - this.url = parsedURL.pathname + parsedURL.search - - this.aborted = false - this.httpVersionMajor = 1 - this.httpVersionMinor = 1 - this.httpVersion = '1.1' - this.method = options.method ? options.method.toUpperCase() : 'GET' - - this.headers = {} - this.rawHeaders = [] - const headers = options.headers || {} - - for (const field in headers) { - const fieldLowerCase = field.toLowerCase() - if ( - ( - fieldLowerCase === 'user-agent' || - fieldLowerCase === 'content-type' - ) && headers[field] === undefined - ) { - this.headers[fieldLowerCase] = undefined - continue + if (options.cookies) { + const { cookies } = options + const cookieValues = Object.keys(cookies).map(key => cookie.serialize(key, cookies[key])) + if (this.headers.cookie) { + cookieValues.unshift(this.headers.cookie) + } + this.headers.cookie = cookieValues.join('; ') } - const value = headers[field] - assert(value !== undefined, 'invalid value "undefined" for header ' + field) - this.headers[fieldLowerCase] = '' + value - } - if (('user-agent' in this.headers) === false) { - this.headers['user-agent'] = 'lightMyRequest' - } - this.headers.host = this.headers.host || options.authority || hostHeaderFromURL(parsedURL) + this.socket = new MockSocket(options.remoteAddress || '127.0.0.1') - if (options.cookies) { - const { cookies } = options - const cookieValues = Object.keys(cookies).map(key => cookie.serialize(key, cookies[key])) - if (this.headers.cookie) { - cookieValues.unshift(this.headers.cookie) - } - this.headers.cookie = cookieValues.join('; ') - } + Object.defineProperty(this, 'connection', { + get () { + warning.emit('FST_LIGHTMYREQUEST_DEP01') + return this.socket + }, + configurable: true + }) - this.socket = new MockSocket(options.remoteAddress || '127.0.0.1') + // we keep both payload and body for compatibility reasons + let payload = options.payload || options.body || null + const payloadResume = payload && typeof payload.resume === 'function' - Object.defineProperty(this, 'connection', { - get () { - warning.emit('FST_LIGHTMYREQUEST_DEP01') - return this.socket - }, - configurable: true - }) + if (payload && typeof payload !== 'string' && !payloadResume && !Buffer.isBuffer(payload)) { + payload = JSON.stringify(payload) - // we keep both payload and body for compatibility reasons - let payload = options.payload || options.body || null - const payloadResume = payload && typeof payload.resume === 'function' + if (('content-type' in this.headers) === false) { + this.headers['content-type'] = 'application/json' + } + } - if (payload && typeof payload !== 'string' && !payloadResume && !Buffer.isBuffer(payload)) { - payload = JSON.stringify(payload) + // Set the content-length for the corresponding payload if none set + if (payload && !payloadResume && !Object.prototype.hasOwnProperty.call(this.headers, 'content-length')) { + this.headers['content-length'] = (Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(payload)).toString() + } - if (('content-type' in this.headers) === false) { - this.headers['content-type'] = 'application/json' + for (const header of Object.keys(this.headers)) { + this.rawHeaders.push(header, this.headers[header]) } - } - // Set the content-length for the corresponding payload if none set - if (payload && !payloadResume && !Object.prototype.hasOwnProperty.call(this.headers, 'content-length')) { - this.headers['content-length'] = (Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(payload)).toString() - } + // Use _lightMyRequest namespace to avoid collision with Node + this._lightMyRequest = { + payload, + isDone: false, + simulate: options.simulate || {} + } - for (const header of Object.keys(this.headers)) { - this.rawHeaders.push(header, this.headers[header]) + const signal = options.signal + /* istanbul ignore if */ + if (signal) { + addAbortSignal(signal, this) + } } - // Use _lightMyRequest namespace to avoid collision with Node - this._lightMyRequest = { - payload, - isDone: false, - simulate: options.simulate || {} - } + prepare (next) { + const payload = this._lightMyRequest.payload + if (!payload || typeof payload.resume !== 'function') { // does not quack like a stream + return next() + } - const signal = options.signal - /* istanbul ignore if */ - if (signal) { - addAbortSignal(signal, this) - } + const chunks = [] - return this -} + payload.on('data', (chunk) => chunks.push(Buffer.from(chunk))) -util.inherits(Request, Readable) -util.inherits(CustomRequest, Request) + payload.on('end', () => { + const payload = Buffer.concat(chunks) + this.headers['content-length'] = this.headers['content-length'] || ('' + payload.length) + this._lightMyRequest.payload = payload + return next() + }) -Request.prototype.prepare = function (next) { - const payload = this._lightMyRequest.payload - if (!payload || typeof payload.resume !== 'function') { // does not quack like a stream - return next() + // Force to resume the stream. Needed for Stream 1 + payload.resume() } - const chunks = [] + _read (size) { + setImmediate(() => { + if (this._lightMyRequest.isDone) { + // 'end' defaults to true + if (this._lightMyRequest.simulate.end !== false) { + this.push(null) + } - payload.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + return + } - payload.on('end', () => { - const payload = Buffer.concat(chunks) - this.headers['content-length'] = this.headers['content-length'] || ('' + payload.length) - this._lightMyRequest.payload = payload - return next() - }) + this._lightMyRequest.isDone = true - // Force to resume the stream. Needed for Stream 1 - payload.resume() -} - -Request.prototype._read = function (size) { - setImmediate(() => { - if (this._lightMyRequest.isDone) { - // 'end' defaults to true - if (this._lightMyRequest.simulate.end !== false) { - this.push(null) + if (this._lightMyRequest.payload) { + if (this._lightMyRequest.simulate.split) { + this.push(this._lightMyRequest.payload.slice(0, 1)) + this.push(this._lightMyRequest.payload.slice(1)) + } else { + this.push(this._lightMyRequest.payload) + } } - return - } + if (this._lightMyRequest.simulate.error) { + this.emit('error', new Error('Simulated')) + } - this._lightMyRequest.isDone = true + if (this._lightMyRequest.simulate.close) { + this.emit('close') + } - if (this._lightMyRequest.payload) { - if (this._lightMyRequest.simulate.split) { - this.push(this._lightMyRequest.payload.slice(0, 1)) - this.push(this._lightMyRequest.payload.slice(1)) - } else { - this.push(this._lightMyRequest.payload) + // 'end' defaults to true + if (this._lightMyRequest.simulate.end !== false) { + this.push(null) } - } + }) + } - if (this._lightMyRequest.simulate.error) { - this.emit('error', new Error('Simulated')) - } + destroy (error) { + if (this.destroyed || this._lightMyRequest.isDone) return + this.destroyed = true - if (this._lightMyRequest.simulate.close) { - this.emit('close') + if (error) { + this._error = true + process.nextTick(() => this.emit('error', error)) } - // 'end' defaults to true - if (this._lightMyRequest.simulate.end !== false) { - this.push(null) - } - }) + process.nextTick(() => this.emit('close')) + } } -Request.prototype.destroy = function (error) { - if (this.destroyed || this._lightMyRequest.isDone) return - this.destroyed = true - - if (error) { - this._error = true - process.nextTick(() => this.emit('error', error)) +/** + * @template T + * @param {new (opt: import('../types').InjectOptions) => T} CustomRequest + * @returns {new (opt: import('../types').InjectOptions) => T & Request} + */ +function getCustomRequest (CustomRequest) { + class _CustomLMRRequest extends CustomRequest { + constructor (...opt) { + super(...opt) + Object.assign(this, new Request(...opt)) + } } - - process.nextTick(() => this.emit('close')) + Object.getOwnPropertyNames(Request.prototype) + .filter(prop => prop !== 'constructor') + .forEach(prop => { _CustomLMRRequest.prototype[prop] = Request.prototype[prop] }) + return _CustomLMRRequest } module.exports = Request module.exports.Request = Request -module.exports.CustomRequest = CustomRequest +module.exports.getCustomRequest = getCustomRequest diff --git a/lib/response.js b/lib/response.js index 430d9b8..d7cab9b 100644 --- a/lib/response.js +++ b/lib/response.js @@ -2,183 +2,187 @@ const http = require('node:http') const { Writable } = require('node:stream') -const util = require('node:util') const setCookie = require('set-cookie-parser') -function Response (req, onEnd, reject) { - http.ServerResponse.call(this, req) +// Throws away all written data to prevent response from buffering payload +function getNullSocket () { + return new Writable({ + write (chunk, encoding, callback) { + setImmediate(callback) + } + }) +} - this._lightMyRequest = { headers: null, trailers: {}, payloadChunks: [] } - // This forces node@8 to always render the headers - this.setHeader('foo', 'bar'); this.removeHeader('foo') +class Response extends http.ServerResponse { + constructor (req, onEnd) { + super(req) - this.assignSocket(getNullSocket()) + this._lightMyRequest = { headers: null, trailers: {}, payloadChunks: [] } + // This forces node@8 to always render the headers + this.setHeader('foo', 'bar'); this.removeHeader('foo') - this._promiseCallback = typeof reject === 'function' + this.assignSocket(getNullSocket()) - let called = false - const onEndSuccess = (payload) => { - // no need to early-return if already called because this handler is bound `once` - called = true - if (this._promiseCallback) { - return process.nextTick(() => onEnd(payload)) + let called = false + const onEndSuccess = (payload) => { + // no need to early-return if already called because this handler is bound `once` + called = true + process.nextTick(() => onEnd(null, payload)) } - process.nextTick(() => onEnd(null, payload)) - } - const onEndFailure = (err) => { - if (called) return - called = true - if (this._promiseCallback) { - return process.nextTick(() => reject(err)) + const onEndFailure = (err) => { + if (called) return + called = true + process.nextTick(() => onEnd(err, null)) } - process.nextTick(() => onEnd(err, null)) - } - this.once('finish', () => { - const res = generatePayload(this) - res.raw.req = req - onEndSuccess(res) - }) + this.once('finish', () => { + const res = this.generatePayload(req) + onEndSuccess(res) + }) - this.connection.once('error', onEndFailure) + this.socket.once('error', onEndFailure) - this.once('error', onEndFailure) + this.once('error', onEndFailure) - this.once('close', onEndFailure) -} - -util.inherits(Response, http.ServerResponse) - -Response.prototype.setTimeout = function (msecs, callback) { - this.timeoutHandle = setTimeout(() => { - this.emit('timeout') - }, msecs) - this.on('timeout', callback) - return this -} + this.once('close', onEndFailure) + } -Response.prototype.writeHead = function () { - const result = http.ServerResponse.prototype.writeHead.apply(this, arguments) + setTimeout (msecs, callback) { + this.timeoutHandle = setTimeout(() => { + this.emit('timeout') + }, msecs) + this.on('timeout', callback) + return this + } - copyHeaders(this) + writeHead (...opt) { + const result = super.writeHead(...opt) - return result -} + this.copyHeaders() -Response.prototype.write = function (data, encoding, callback) { - if (this.timeoutHandle) { - clearTimeout(this.timeoutHandle) + return result } - http.ServerResponse.prototype.write.call(this, data, encoding, callback) - this._lightMyRequest.payloadChunks.push(Buffer.from(data, encoding)) - return true -} -Response.prototype.end = function (data, encoding, callback) { - if (data) { - this.write(data, encoding) + write (data, encoding, callback) { + if (this.timeoutHandle) { + clearTimeout(this.timeoutHandle) + } + super.write(data, encoding, callback) + this._lightMyRequest.payloadChunks.push(Buffer.from(data, encoding)) + return true } - http.ServerResponse.prototype.end.call(this, callback) - - this.emit('finish') + end (data, encoding, callback) { + if (data) { + this.write(data, encoding) + } - // We need to emit 'close' otherwise stream.finished() would - // not pick it up on Node v16 + super.end(callback) - this.destroy() -} + this.emit('finish') -Response.prototype.destroy = function (error) { - if (this.destroyed) return - this.destroyed = true + // We need to emit 'close' otherwise stream.finished() would + // not pick it up on Node v16 - if (error) { - process.nextTick(() => this.emit('error', error)) + this.destroy() } - process.nextTick(() => this.emit('close')) -} + destroy (error) { + if (this.destroyed) return + this.destroyed = true -Response.prototype.addTrailers = function (trailers) { - for (const key in trailers) { - this._lightMyRequest.trailers[key.toLowerCase().trim()] = trailers[key].toString().trim() - } -} + if (error) { + process.nextTick(() => this.emit('error', error)) + } -function generatePayload (response) { - // This seems only to happen when using `fastify-express` - see https://github.com/fastify/fastify-express/issues/47 - /* istanbul ignore if */ - if (response._lightMyRequest.headers === null) { - copyHeaders(response) + process.nextTick(() => this.emit('close')) } - serializeHeaders(response) - // Prepare response object - const res = { - raw: { - res: response - }, - headers: response._lightMyRequest.headers, - statusCode: response.statusCode, - statusMessage: response.statusMessage, - trailers: {}, - get cookies () { - return setCookie.parse(this) + + addTrailers (trailers) { + for (const key in trailers) { + this._lightMyRequest.trailers[key.toLowerCase().trim()] = trailers[key].toString().trim() } } - // Prepare payload and trailers - const rawBuffer = Buffer.concat(response._lightMyRequest.payloadChunks) - res.rawPayload = rawBuffer - - // we keep both of them for compatibility reasons - res.payload = rawBuffer.toString() - res.body = res.payload - res.trailers = response._lightMyRequest.trailers + /** + * @private + * @param {Request} req + * @returns + */ + generatePayload (req) { + // This seems only to happen when using `fastify-express` - see https://github.com/fastify/fastify-express/issues/47 + /* istanbul ignore if */ + if (this._lightMyRequest.headers === null) { + this.copyHeaders() + } + this.serializeHeaders() + // Prepare response object + const res = { + raw: { + res: this, + req + }, + headers: this._lightMyRequest.headers, + statusCode: this.statusCode, + statusMessage: this.statusMessage, + trailers: {}, + get cookies () { + return setCookie.parse(this) + } + } - // Prepare payload parsers - res.json = function parseJsonPayload () { - return JSON.parse(res.payload) - } + // Prepare payload and trailers + const rawBuffer = Buffer.concat(this._lightMyRequest.payloadChunks) + res.rawPayload = rawBuffer - return res -} + // we keep both of them for compatibility reasons + res.payload = rawBuffer.toString() + res.body = res.payload + res.trailers = this._lightMyRequest.trailers -// Throws away all written data to prevent response from buffering payload -function getNullSocket () { - return new Writable({ - write (chunk, encoding, callback) { - setImmediate(callback) + // Prepare payload parsers + res.json = function parseJsonPayload () { + return JSON.parse(res.payload) } - }) -} -function serializeHeaders (response) { - const headers = response._lightMyRequest.headers + return res + } - for (const headerName of Object.keys(headers)) { - const headerValue = headers[headerName] - if (Array.isArray(headerValue)) { - headers[headerName] = headerValue.map(value => '' + value) - } else { - headers[headerName] = '' + headerValue + /** + * @private + */ + serializeHeaders () { + const headers = this._lightMyRequest.headers + + for (const headerName of Object.keys(headers)) { + const headerValue = headers[headerName] + if (Array.isArray(headerValue)) { + headers[headerName] = headerValue.map(value => '' + value) + } else { + headers[headerName] = '' + headerValue + } } } -} - -function copyHeaders (response) { - response._lightMyRequest.headers = Object.assign({}, response.getHeaders()) - // Add raw headers - ;['Date', 'Connection', 'Transfer-Encoding'].forEach((name) => { - const regex = new RegExp('\\r\\n' + name + ': ([^\\r]*)\\r\\n') - const field = response._header.match(regex) - if (field) { - response._lightMyRequest.headers[name.toLowerCase()] = field[1] - } - }) + /** + * @private + */ + copyHeaders () { + this._lightMyRequest.headers = Object.assign({}, this.getHeaders()) + + // Add raw headers + ;['Date', 'Connection', 'Transfer-Encoding'].forEach((name) => { + // TODO change this to use the header getter + const regex = new RegExp('\\r\\n' + name + ': ([^\\r]*)\\r\\n') + const [, value] = this._header.match(regex) || [] + // const value = this.getHeader(name) + if (value) { + this._lightMyRequest.headers[name.toLowerCase()] = value + } + }) + } } module.exports = Response diff --git a/test/index.test.js b/test/index.test.js index 3739971..19f60ad 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1980,3 +1980,16 @@ test('request that is destroyed does not error', (t) => { t.equal(res.payload, 'hi') }) }) + +test('chainable api: backwards compatibility for promise (finally)', (t) => { + t.plan(1) + + function dispatch (req, res) { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('hello') + } + + inject(dispatch) + .get('/') + .finally(() => t.pass()) +})