diff --git a/benchmark/benchmark.js b/benchmark/benchmark.js index bfc3871..8af393b 100644 --- a/benchmark/benchmark.js +++ b/benchmark/benchmark.js @@ -5,7 +5,7 @@ const http = require('http') const Benchmark = require('benchmark') const suite = new Benchmark.Suite() const Request = require('../lib/request') -const parseURL = require('../lib/parseURL') +const parseURL = require('../lib/parse-url') const mockReq = { url: 'http://localhost', @@ -28,6 +28,7 @@ const mockCustomReq = { }, Request: http.IncomingMessage } + const mockReqCookies = { url: 'http://localhost', method: 'GET', @@ -59,7 +60,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 5d86bc7..c640b17 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,9 @@ 'use strict' -const assert = require('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 { Response } = require('./lib/response') +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 || @@ -165,3 +25,7 @@ module.exports = inject module.exports.default = inject module.exports.inject = inject module.exports.isInjection = isInjection +module.exports.errors = { + ...Request.errors, + ...Response.errors +} 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..738b05c --- /dev/null +++ b/lib/do-inject.js @@ -0,0 +1,77 @@ +'use strict' + +const assert = require('assert') +const optsValidator = require('./config-validator') +const Request = require('./request') +const { Response, once } = require('./response') +const { Readable, addAbortSignal } = require('stream') + +function promisify (fn) { + if (fn) { + return { ret: Promise.resolve(), cb: once(fn) } + } + let resolve, reject + const ret = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + return { + ret, + cb: (err, res) => { + err ? reject(err) : resolve(res) + } + } +} + +function makeRequest (dispatchFunc, server, req, res) { + req.socket.once('close', function () { + res.emit('close') + }) + + return req.prepare(() => dispatchFunc.call(server, req, res), (err) => { res.emit('error', err) }) +} + +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 { ret, cb } = promisify(callback) + + const req = new RequestConstructor(options) + const res = new Response(req, cb) + + if (options.signal) { + const r = new Readable() + r.once('error', (err) => { + cb(err) + res.destroy(err) + }) + res.once('close', () => { + r.destroy() + }) + addAbortSignal(options.signal, r) + } + + return Promise.resolve().then(() => makeRequest(dispatchFunc, server, req, res)).then(() => ret) +} + +module.exports = doInject diff --git a/lib/request.js b/lib/request.js index 34333ba..81a3bdd 100644 --- a/lib/request.js +++ b/lib/request.js @@ -2,14 +2,14 @@ /* eslint no-prototype-builtins: 0 */ -const { Readable, addAbortSignal } = require('stream') -const util = require('util') +const { Readable } = require('stream') const cookie = require('cookie') const assert = require('assert') const warning = require('process-warning')() const parseURL = require('./parse-url') -const { EventEmitter } = require('events') +const { IncomingMessage } = require('http') +const { Socket } = require('net') // request.connectin deprecation https://nodejs.org/api/http.html#http_request_connection warning.create('FastifyDeprecationLightMyRequest', 'FST_LIGHTMYREQUEST_DEP01', 'You are accessing "request.connection", use "request.socket" instead.') @@ -32,228 +32,224 @@ function hostHeaderFromURL (parsedURL) { * @constructor * @param {String} remoteAddress the fake address to show consumers of the socket */ -class MockSocket extends EventEmitter { +class MockSocket extends Socket { constructor (remoteAddress) { super() - this.remoteAddress = remoteAddress - } -} - -/** - * 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.defineProperty(this, 'remoteAddress', { + __proto__: null, + configurable: false, + enumerable: true, + get: () => remoteAddress }) - Object.assign(this, obj) - - for (const fn of Object.keys(Request.prototype)) { - this.constructor.prototype[fn] = Request.prototype[fn] - } - - util.inherits(this.constructor, options.Request) - return this } } -/** - * 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 +class Request extends IncomingMessage { + static errors = { + ContentLength: class ContentLengthError extends Error { + constructor () { + super('Content length is different than the value specified by the Content-Length header') + } } - 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) - 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) + /** + * 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 {{end: boolean,split: boolean,error: boolean,close: boolean}} [options.simulate] + * @param {any} [options.payload] + */ + constructor (options) { + super(new MockSocket(options.remoteAddress || '127.0.0.1')) + 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' + + // Use _lightMyRequest namespace to avoid collision with Node + this._lightMyRequest = { + payload: options.payload || options.body || null, + isDone: false, + simulate: options.simulate || {}, + authority: options.authority, + cookies: options.cookies, + hostHeader: hostHeaderFromURL(parsedURL) } - this.headers.cookie = cookieValues.join('; ') - } - this.socket = new MockSocket(options.remoteAddress || '127.0.0.1') + this.headers = {} + this.rawHeaders = [] + const headers = options.headers || {} - Object.defineProperty(this, 'connection', { - get () { - warning.emit('FST_LIGHTMYREQUEST_DEP01') - return this.socket - }, - configurable: true - }) - - // we keep both payload and body for compatibility reasons - let payload = options.payload || options.body || null - const payloadResume = payload && typeof payload.resume === 'function' - - if (payload && typeof payload !== 'string' && !payloadResume && !Buffer.isBuffer(payload)) { - payload = JSON.stringify(payload) - - if (('content-type' in this.headers) === false) { - this.headers['content-type'] = 'application/json' + 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 } - } - - // 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() - } - for (const header of Object.keys(this.headers)) { - this.rawHeaders.push(header, this.headers[header]) + Object.defineProperty(this, 'connection', { + get () { + warning.emit('FST_LIGHTMYREQUEST_DEP01') + return this.socket + }, + configurable: true + }) } - // Use _lightMyRequest namespace to avoid collision with Node - this._lightMyRequest = { - payload, - isDone: false, - simulate: options.simulate || {} - } + getLength (payload) { + if (typeof payload === 'string') { + return Buffer.byteLength(payload) + } - const signal = options.signal - /* istanbul ignore if */ - if (signal) { - addAbortSignal(signal, this) + return payload.length } - return this -} - -util.inherits(Request, Readable) -util.inherits(CustomRequest, Request) + parseHeader () { + if (('user-agent' in this.headers) === false) { + this.headers['user-agent'] = 'lightMyRequest' + } + this.headers.host = this.headers.host || this._lightMyRequest.authority || this._lightMyRequest.hostHeader -Request.prototype.prepare = function (next) { - const payload = this._lightMyRequest.payload - if (!payload || typeof payload.resume !== 'function') { // does not quack like a stream - return next() + if (this._lightMyRequest.cookies) { + const { cookies } = this._lightMyRequest + 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 chunks = [] - - payload.on('data', (chunk) => chunks.push(Buffer.from(chunk))) - - payload.on('end', () => { - const payload = Buffer.concat(chunks) - this.headers['content-length'] = this.headers['content-length'] || ('' + payload.length) - this._lightMyRequest.payload = payload - return next() - }) + parsePayload () { + // we keep both payload and body for compatibility reasons + let payload = this._lightMyRequest.payload + const payloadResume = payload && typeof payload.resume === 'function' - // Force to resume the stream. Needed for Stream 1 - payload.resume() -} + if (payload && typeof payload !== 'string' && !payloadResume && !Buffer.isBuffer(payload)) { + payload = JSON.stringify(payload) -Request.prototype._read = function (size) { - setImmediate(() => { - if (this._lightMyRequest.isDone) { - // 'end' defaults to true - if (this._lightMyRequest.simulate.end !== false) { - this.push(null) + if (('content-type' in this.headers) === false) { + this.headers['content-type'] = 'application/json' } - - return } - this._lightMyRequest.isDone = true - - if (this._lightMyRequest.payload) { - if (this._lightMyRequest.simulate.split) { - this.push(this._lightMyRequest.payload.slice(0, 1)) - this.push(this._lightMyRequest.payload.slice(1)) + if (this._lightMyRequest.simulate.end === false) { + const prevPayload = payload + if (payloadResume) { + payload = new Readable({ + read (n) { + prevPayload.read(n) + } + }) + prevPayload.on('data', (d) => { + payload.push(d) + }) } else { - this.push(this._lightMyRequest.payload) + payload = new Readable({ + read (n) { + if (prevPayload) this.push(prevPayload) + this.pause() + } + }) } } - if (this._lightMyRequest.simulate.error) { - this.emit('error', new Error('Simulated')) - } + this._lightMyRequest.payload = payload + } - if (this._lightMyRequest.simulate.close) { - this.emit('close') + prepare (next, onError) { + this.parseHeader() + this.parsePayload() + for (const header of Object.keys(this.headers)) { + this.rawHeaders.push(header, this.headers[header]) } - - // 'end' defaults to true - if (this._lightMyRequest.simulate.end !== false) { + let payload = this._lightMyRequest.payload + this.complete = true + if (payload) { + if (typeof payload.resume !== 'function') { + const length = this.getLength(payload) + if (this.headers['content-length']) { + if (this.headers['content-length'].toString() > length.toString()) { + return onError(new Request.errors.ContentLength()) + } + payload = payload.slice(0, this.headers['content-length']) + } else { + this.headers['content-length'] = length?.toString() + } + this.push(payload) + this.push(null) + } else { + let i = 0 + const max = this.headers['content-length'] ? parseInt(this.headers['content-length'], 10) : null + payload.on('data', (chunk) => { + if (max != null) { + if (max > i && max <= i + chunk.length) { + this.push(chunk.slice(0, max - i)) + } + } else { + this.push(chunk) + } + i += chunk.length + }) + payload.on('end', () => { + if (max != null) { + if (max > i) { + return onError(new Request.errors.ContentLength()) + } + } + this.push(null) + }) + payload.resume() + } + } else { + if (this.headers['content-length'] && this.headers['content-length'] !== '0') { + return onError(new Request.errors.ContentLength()) + } this.push(null) } - }) + return next() + } } -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 bcdf60b..d9be629 100644 --- a/lib/response.js +++ b/lib/response.js @@ -1,184 +1,120 @@ 'use strict' const http = require('http') -const { Writable } = require('stream') -const util = require('util') const setCookie = require('set-cookie-parser') +const MySocket = require('./socket') -function Response (req, onEnd, reject) { - http.ServerResponse.call(this, req) - - this._lightMyRequest = { headers: null, trailers: {}, payloadChunks: [] } - // This forces node@8 to always render the headers - this.setHeader('foo', 'bar'); this.removeHeader('foo') - - this.assignSocket(getNullSocket()) - - this._promiseCallback = typeof reject === 'function' - +function once (cb) { 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)) - } - process.nextTick(() => onEnd(null, payload)) - } - - const onEndFailure = (err) => { + return function () { if (called) return called = true - if (this._promiseCallback) { - return process.nextTick(() => reject(err)) - } - process.nextTick(() => onEnd(err, null)) + cb.apply(this, arguments) } - - this.once('finish', () => { - const res = generatePayload(this) - res.raw.req = req - onEndSuccess(res) - }) - - this.connection.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 } -Response.prototype.writeHead = function () { - const result = http.ServerResponse.prototype.writeHead.apply(this, arguments) - - copyHeaders(this) +module.exports.once = once - return result -} - -Response.prototype.write = function (data, encoding, callback) { - if (this.timeoutHandle) { - clearTimeout(this.timeoutHandle) - } - http.ServerResponse.prototype.write.call(this, data, encoding, callback) - this._lightMyRequest.payloadChunks.push(Buffer.from(data, encoding)) - return true -} +// Throws away all written data to prevent response from buffering payload -Response.prototype.end = function (data, encoding, callback) { - if (data) { - this.write(data, encoding) +class Response extends http.ServerResponse { + static errors = { + SocketHangUpError: class SocketHangUpError extends Error { + constructor () { + super('socket hang up') + this.code = 'ECONNRESET' + } + } } - http.ServerResponse.prototype.end.call(this, callback) + /** + * @param {import('./request').Request} req + * @param {(err: Error, data: any) => void} onEnd + * @param {http.Server} server + */ + constructor (req, onEnd) { + super(req) + onEnd = once(onEnd) + this._lightSocket = new MySocket() + this.setHeader('foo', 'bar'); this.removeHeader('foo') + this.assignSocket(this._lightSocket) + + const onEndCb = (err) => { + if (err) { + return process.nextTick(() => onEnd(err)) + } + const res = this.generatePayload(req) + if (res.end) { + return process.nextTick(() => onEnd(null, res)) + } + process.nextTick(() => onEnd(new Response.errors.SocketHangUpError())) + } - this.emit('finish') + this.once('finish', () => { + this.destroyed = true + this._closed = true + this.emit('close') + }) - // We need to emit 'close' otherwise stream.finished() would - // not pick it up on Node v16 + this.once('close', () => { + onEndCb() + }) - this.destroy() -} + this.socket.once('error', () => { + onEndCb(new Response.errors.SocketHangUpError()) + }) -Response.prototype.destroy = function (error) { - if (this.destroyed) return - this.destroyed = true + this.socket.once('close', () => { + process.nextTick(() => onEndCb()) + }) - if (error) { - process.nextTick(() => this.emit('error', error)) + this.once('error', (err) => { + onEndCb(err) + }) } - process.nextTick(() => this.emit('close')) -} - -Response.prototype.addTrailers = function (trailers) { - for (const key in trailers) { - this._lightMyRequest.trailers[key.toLowerCase().trim()] = trailers[key].toString().trim() + setTimeout (msecs, callback) { + this.timeoutHandle = setTimeout(() => { + this.emit('timeout') + }, msecs) + this.on('timeout', callback) + return this } -} -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) - } - 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) + /** + * @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 + // Prepare response object + const state = this._lightSocket.getState() + const body = state.body.toString() + const res = { + raw: { + res: this, + req + }, + headers: state.headers, + statusCode: this.statusCode, + statusMessage: this.statusMessage, + trailers: state.trailers, + rawPayload: state.body, + end: state.isEnd, + payload: body, + body, + json: function parseJsonPayload () { + return JSON.parse(res.payload) + }, + get cookies () { + return setCookie.parse(this) + } } - } - - // 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 - // Prepare payload parsers - res.json = function parseJsonPayload () { - return JSON.parse(res.payload) + return res } - - return res -} - -// Throws away all written data to prevent response from buffering payload -function getNullSocket () { - return new Writable({ - write (chunk, encoding, callback) { - setImmediate(callback) - } - }) -} - -function serializeHeaders (response) { - const headers = response._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] - } - }) } -module.exports = Response +module.exports.Response = Response diff --git a/lib/socket.js b/lib/socket.js new file mode 100644 index 0000000..28e522d --- /dev/null +++ b/lib/socket.js @@ -0,0 +1,138 @@ +const { Socket } = require('net') + +const crlfBuf = Buffer.from('\r\n') + +class State { + constructor () { + this.state = 'firstHead' + this.headers = {} + this.trailers = {} + this.body = [] + this.waitSizeBody = 0 + this.rawBody = [] + } + + /** + * @param {Uint8Array | string} chunk + * @param {BufferEncoding} [encoding] + */ + write (chunk, encoding) { + if (typeof chunk === 'string') { + chunk = Buffer.from(chunk, encoding) + } + this.rawBody.push(chunk) + this.process(Buffer.concat(this.rawBody)) + } + + /** + * @private + * @param {Buffer} buffer + * @returns + */ + process (buffer) { + if (!buffer.length) { + this.rawBody = [] + return + }; + if (this.state === 'body') { + if (buffer.length < this.waitSizeBody) { + this.body.push(buffer) + this.waitSizeBody -= buffer.length + this.rawBody = [] + return + } + this.body.push(buffer.subarray(0, this.waitSizeBody)) + const size = this.waitSizeBody + this.waitSizeBody = 0 + this.state = 'afterBody' + this.process(buffer.subarray(size)) + return + } + this.rawBody = [buffer] + const i = buffer.indexOf(crlfBuf) + if (i === -1) { + return + }; + if (this.state === 'firstHead') { + this.state = 'head' + this.process(buffer.subarray(i + 2)) + return + } + if (this.state === 'head') { + const line = buffer.subarray(0, i).toString() + if (line) { + const [, key, value] = line.match(/^([^:]+): (.*)$/) + if (this.headers[key.toLowerCase()]) { + if (!Array.isArray(this.headers[key.toLowerCase()])) { + this.headers[key.toLowerCase()] = [this.headers[key.toLowerCase()]] + } + this.headers[key.toLowerCase()].push(value) + } else { + this.headers[key.toLowerCase()] = value + } + } else if (this.headers['content-length']) { + this.waitSizeBody = parseInt(this.headers['content-length']) + if (this.waitSizeBody) { + this.state = 'body' + } else { + this.state = 'trailers' + } + } else { + this.state = 'bodySize' + } + this.process(buffer.subarray(i + 2)) + return + } + if (this.state === 'bodySize') { + const chunk = buffer.subarray(0, i).toString() + this.waitSizeBody = parseInt(chunk.toString(), 16) + if (this.waitSizeBody !== 0) { + this.state = 'body' + } else { + this.state = 'trailers' + } + this.process(buffer.subarray(i + 2)) + return + } + if (this.state === 'afterBody') { + this.state = 'bodySize' + this.process(buffer.subarray(i + 2)) + } + if (this.state === 'trailers') { + const line = buffer.subarray(0, i).toString() + if (line) { + const [, key, value] = line.match(/^([^:]+): (.*)$/) + this.trailers[key.toLowerCase()] = value + } else { + this.state = 'end' + } + this.process(buffer.subarray(i + 2)) + } + } +} + +class MySocket extends Socket { + _myData = new State() + + /** + * @param {Uint8Array | string} chunk + * @param {BufferEncoding | (err?: Error) => void} [encoding] + * @param {(err?: Error) => void} [callback] + */ + write (chunk, encoding, callback) { + this._myData.write(chunk, encoding) + callback && setImmediate(callback) + return true + } + + getState () { + return { + headers: this._myData.headers, + body: Buffer.concat(this._myData.body), + trailers: this._myData.trailers, + isEnd: ['end', 'trailers', 'afterBody', 'bodySize'].includes(this._myData.state) + } + } +} + +module.exports = MySocket diff --git a/test/index.test.js b/test/index.test.js index 8af5ca2..0971cbf 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -27,6 +27,38 @@ const httpMethods = [ 'trace' ] +const parseQuery = url => { + const parsedURL = parseURL(url) + return qs.parse(parsedURL.search.slice(1)) +} + +function getTestStream (encoding) { + const word = 'hi' + let i = 0 + + const stream = new Readable({ + read (n) { + this.push(word[i] ? word[i++] : null) + } + }) + + if (encoding) { + stream.setEncoding(encoding) + } + + return stream +} + +function readStream (stream, callback) { + const chunks = [] + + stream.on('data', (chunk) => chunks.push(chunk)) + + stream.on('end', () => { + return callback(Buffer.concat(chunks)) + }) +} + test('returns non-chunked payload', (t) => { t.plan(7) const output = 'example.com:8080|/hello' @@ -162,12 +194,13 @@ test('passes a socket which emits events like a normal one does', (t) => { const dispatch = function (req, res) { res.writeHead(200, { 'Content-Type': 'text/plain' }) req.socket.on('timeout', () => {}) + res.write('some test ') res.end('added') } inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello' }, (err, res) => { t.error(err) - t.equal(res.payload, 'added') + t.equal(res.payload, 'some test added') }) }) @@ -197,11 +230,6 @@ test('includes deprecated connection on request', (t) => { }) }) -const parseQuery = url => { - const parsedURL = parseURL(url) - return qs.parse(parsedURL.search.slice(1)) -} - test('passes query', (t) => { t.plan(2) @@ -673,13 +701,12 @@ test('adds a content-length header if none set when payload specified', (t) => { test('retains a content-length header when payload specified', (t) => { t.plan(2) const dispatch = function (req, res) { - res.writeHead(200, { 'Content-Type': 'text/plain' }) - res.end(req.headers['content-length']) + t.fail() } - inject(dispatch, { method: 'POST', url: '/test', payload: '', headers: { 'content-length': '10' } }, (err, res) => { - t.error(err) - t.equal(res.payload, '10') + inject(dispatch, { method: 'POST', url: '/test', payload: '1', headers: { 'content-length': '10' } }, (err, res) => { + t.equal(err instanceof inject.errors.ContentLength, true) + t.error(res) }) }) @@ -723,8 +750,8 @@ test('can override stream payload content-length header', (t) => { const headers = { 'content-length': '100' } inject(dispatch, { method: 'POST', url: '/', payload: getTestStream(), headers }, (err, res) => { - t.error(err) - t.equal(res.payload, '100') + t.equal(err instanceof inject.errors.ContentLength, true) + t.error(res) }) }) @@ -732,7 +759,7 @@ test('can override stream payload content-length header without request content- t.plan(1) const dispatch = function (req, res) { res.writeHead(200, { 'Content-Type': 'text/plain' }) - t.equal(req.headers['content-length'], '2') + t.error(req.headers['content-length']) } inject(dispatch, { method: 'POST', url: '/', payload: getTestStream() }, () => {}) @@ -768,7 +795,6 @@ test('_read() plays payload', (t) => { }) req.on('end', () => { - res.writeHead(200, { 'Content-Length': 0 }) res.end(buffer) req.destroy() }) @@ -793,7 +819,6 @@ test('simulates split', (t) => { }) req.on('end', () => { - res.writeHead(200, { 'Content-Length': 0 }) res.end(buffer) req.destroy() }) @@ -806,14 +831,14 @@ test('simulates split', (t) => { }) }) -test('simulates error', (t) => { +t.skip('simulates error', (t) => { t.plan(2) const dispatch = function (req, res) { req.on('readable', () => { }) req.on('error', () => { - res.writeHead(200, { 'Content-Length': 0 }) + res.writeHead(200, { 'Content-Length': 5 }) res.end('error') }) } @@ -876,7 +901,7 @@ test('simulates close', (t) => { }) req.on('close', () => { - res.writeHead(200, { 'Content-Length': 0 }) + res.writeHead(200, { 'Content-Length': 5 }) res.end('close') }) @@ -1127,6 +1152,19 @@ test('chainable api: backwards compatibility for promise (catch)', (t) => { .catch(err => t.ok(err)) }) +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()) +}) + test('chainable api: multiple call of then should return the same promise', (t) => { t.plan(2) let id = 0 @@ -1158,7 +1196,7 @@ test('chainable api: http methods should work correctly', (t) => { inject(dispatch)[method]('http://example.com:8080/hello') .end((err, res) => { t.error(err) - t.equal(res.body, method.toUpperCase()) + t.equal(res.body, method === 'head' ? '' : method.toUpperCase()) }) }) }) @@ -1492,33 +1530,6 @@ test('disabling autostart', (t) => { }) }) -function getTestStream (encoding) { - const word = 'hi' - let i = 0 - - const stream = new Readable({ - read (n) { - this.push(word[i] ? word[i++] : null) - } - }) - - if (encoding) { - stream.setEncoding(encoding) - } - - return stream -} - -function readStream (stream, callback) { - const chunks = [] - - stream.on('data', (chunk) => chunks.push(chunk)) - - stream.on('end', () => { - return callback(Buffer.concat(chunks)) - }) -} - test('send cookie', (t) => { t.plan(3) const dispatch = function (req, res) { @@ -1726,7 +1737,7 @@ test('no error for response destory', (t) => { } inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { - t.error(err) + t.equal(err instanceof inject.errors.SocketHangUpError, true) }) }) @@ -1738,8 +1749,8 @@ test('request destory without error', (t) => { } inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res, null) + t.equal(err instanceof inject.errors.SocketHangUpError, true) + t.error(res) }) }) @@ -1753,8 +1764,8 @@ test('request destory with error', (t) => { } inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { - t.equal(err, fakeError) - t.equal(res, null) + t.equal(err instanceof inject.errors.SocketHangUpError, true) + t.error(res) }) }) @@ -1770,8 +1781,8 @@ test('compatible with stream.finished', (t) => { } inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res, null) + t.equal(err instanceof inject.errors.SocketHangUpError, true) + t.error(res) }) }) @@ -1787,8 +1798,8 @@ test('compatible with eos', (t) => { } inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { - t.error(err) - t.equal(res, null) + t.equal(err instanceof inject.errors.SocketHangUpError, true) + t.error(res) }) }) @@ -1821,15 +1832,15 @@ test('compatible with eos, passes error correctly', (t) => { const dispatch = function (req, res) { eos(res, (err) => { - t.equal(err, fakeError) + t.equal(err.message, 'premature close') }) req.destroy(fakeError) } inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { - t.equal(err, fakeError) - t.equal(res, null) + t.equal(err instanceof inject.errors.SocketHangUpError, true) + t.error(res) }) }) @@ -1842,8 +1853,8 @@ test('multiple calls to req.destroy should not be called', (t) => { } inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { - t.equal(err) - t.equal(res, null) + t.equal(err instanceof inject.errors.SocketHangUpError, true) + t.error(res) }) }) @@ -2014,3 +2025,113 @@ test('request that is destroyed does not error', (t) => { t.equal(res.payload, 'hi') }) }) + +test('status 204', (t) => { + t.plan(5) + const dispatch = function (req, res) { + res.writeHead(204) + res.end('hello') + } + + inject(dispatch, { method: 'POST', url: '/' }, (err, res) => { + t.error(err) + t.equal(res.payload, '') + t.equal(Object.keys(res.headers).length, 2) + t.hasProp(res.headers, 'date') + t.equal(res.headers.connection, 'keep-alive') + }) +}) + +test('partly send body', (t) => { + t.plan(2) + const dispatch = function (req, res) { + res.writeHead(200, { 'content-length': 10 }) + res.write('') + res.write('1234') + res.write('123456') + res.end() + } + + inject(dispatch, { method: 'POST', url: '/' }, (err, res) => { + t.error(err) + t.equal(res.payload, '1234123456') + }) +}) + +test('retains a content-length header without payload', (t) => { + t.plan(2) + const dispatch = function (req, res) { + t.fail() + } + + inject(dispatch, { method: 'POST', url: '/test', headers: { 'content-length': '10' } }, (err, res) => { + t.equal(err instanceof inject.errors.ContentLength, true) + t.error(res) + }) +}) + +test('content-length correct with payload', (t) => { + t.plan(2) + const dispatch = function (req, res) { + res.end(req.headers['content-length']) + } + + inject(dispatch, { method: 'POST', url: '/test', payload: '1234', headers: { 'content-length': '4' } }, (err, res) => { + t.error(err) + t.equal(res.payload, '4') + }) +}) + +test('content-length slice if less', (t) => { + t.plan(2) + const dispatch = function (req, res) { + const chunks = [] + req.on('data', (chunk) => chunks.push(chunk)) + req.on('end', () => { + res.end(Buffer.concat(chunks).toString()) + }) + } + + inject(dispatch, { method: 'POST', url: '/test', payload: '123456', headers: { 'content-length': '4' } }, (err, res) => { + t.error(err) + t.equal(res.payload, '1234') + }) +}) + +test('content-length slice if less readable', (t) => { + t.plan(2) + const dispatch = function (req, res) { + readStream(req, (buff) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end(buff) + }) + } + + const payload = getTestStream() + + inject(dispatch, { method: 'POST', url: '/', payload, headers: { 'content-length': '1' } }, (err, res) => { + t.error(err) + t.equal(res.payload, 'h') + }) +}) + +test('simulates no end with payload Readable', (t) => { + t.plan(2) + let end = false + const dispatch = function (req, res) { + req.resume() + req.on('end', () => { + end = true + }) + } + + let replied = false + inject(dispatch, { method: 'GET', url: '/', payload: getTestStream(), simulate: { end: false } }, (notHandledErr, res) => { + replied = true + }) + + setTimeout(() => { + t.equal(end, false) + t.equal(replied, false) + }, 10) +}) diff --git a/test/response.test.js b/test/response.test.js index 4e1f50c..24ab362 100644 --- a/test/response.test.js +++ b/test/response.test.js @@ -1,13 +1,13 @@ const { test } = require('tap') -const Response = require('../lib/response') +const { Response } = require('../lib/response') test('multiple calls to res.destroy should not be called', (t) => { t.plan(1) const mockReq = {} const res = new Response(mockReq, (err, response) => { - t.error(err) + t.equal(err instanceof Response.errors.SocketHangUpError, true) }) res.destroy() diff --git a/types/index.d.ts b/types/index.d.ts index b267026..a8147b9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -14,7 +14,7 @@ declare namespace inject { export type DispatchFunc = http.RequestListener - export type CallbackFunc = (err: Error, response: Response) => void + export type CallbackFunc = (err: Error | undefined, response: Response | undefined) => void export type InjectPayload = string | object | Buffer | NodeJS.ReadableStream @@ -24,6 +24,11 @@ declare namespace inject { readonly aborted: boolean; } + export const errors: { + ContentLength: typeof Error, + SocketHangUpError: typeof Error, + } + export interface InjectOptions { url?: string | { pathname: string diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 72fa8a0..c6baaa0 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -1,5 +1,5 @@ import * as http from 'http' -import { inject, isInjection, Response, DispatchFunc, InjectOptions, Chain } from '..' +import { inject, isInjection, Response, DispatchFunc, InjectOptions, Chain, errors } from '..' import { expectType, expectAssignable, expectNotAssignable } from 'tsd' expectAssignable({ url: '/' }) @@ -15,7 +15,13 @@ const dispatch: http.RequestListener = function (req, res) { res.end(reply) } -const expectResponse = function (res: Response) { +expectType(errors.ContentLength) +expectType(errors.SocketHangUpError) + +const expectResponse = function (res: Response | undefined) { + if (!res) { + return; + } expectType(res) console.log(res.payload) expectAssignable(res.json) @@ -38,7 +44,7 @@ const expectResponse = function (res: Response) { expectType(dispatch) inject(dispatch, { method: 'get', url: '/' }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) }) @@ -52,37 +58,37 @@ const url = { } } inject(dispatch, { method: 'get', url }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) }) inject(dispatch, { method: 'get', url: '/', cookies: { name1: 'value1', value2: 'value2' } }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) }) inject(dispatch, { method: 'get', url: '/', query: { name1: 'value1', value2: 'value2' } }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) }) inject(dispatch, { method: 'get', url: '/', query: { name1: ['value1', 'value2'] } }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) }) inject(dispatch, { method: 'get', url: '/', query: 'name1=value1' }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) }) inject(dispatch, { method: 'post', url: '/', payload: { name1: 'value1', value2: 'value2' } }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) }) inject(dispatch, { method: 'post', url: '/', body: { name1: 'value1', value2: 'value2' } }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) }) @@ -90,9 +96,9 @@ expectType( inject(dispatch) .get('/') .end((err, res) => { - expectType(err) - expectType(res) - console.log(res.payload) + expectType(err) + expectType(res) + console.log(res?.payload) }) ) @@ -129,6 +135,6 @@ const httpDispatch = function (req: http.IncomingMessage, res: http.ServerRespon } inject(httpDispatch, { method: 'get', url: '/' }, (err, res) => { - expectType(err) + expectType(err) expectResponse(res) })