From 220a391584098628c757ebba6ba138121e373eb0 Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Thu, 31 Aug 2023 01:38:42 +0300 Subject: [PATCH 1/9] class Chain --- index.js | 144 +----------------------------------------------- lib/Chain.js | 105 +++++++++++++++++++++++++++++++++++ lib/doInject.js | 66 ++++++++++++++++++++++ lib/response.js | 10 +--- 4 files changed, 174 insertions(+), 151 deletions(-) create mode 100644 lib/Chain.js create mode 100644 lib/doInject.js diff --git a/index.js b/index.js index e18da03..aa5fe43 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/configValidator') +const Chain = require('./lib/Chain') +const doInject = require('./lib/doInject') 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..68aa4b2 --- /dev/null +++ b/lib/Chain.js @@ -0,0 +1,105 @@ +const doInject = require('./doInject') + +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<Promise[T]>} 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/doInject.js b/lib/doInject.js new file mode 100644 index 0000000..fe3a8cb --- /dev/null +++ b/lib/doInject.js @@ -0,0 +1,66 @@ +const assert = require('assert') +const optsValidator = require('./configValidator') +const Request = require('./request') +const Response = require('./response') + +function promisify (fn) { + if (fn) { + return { ret: Promise.resolve(), cb: 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.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) + } + + const { ret, cb } = promisify(callback) + + const req = new RequestConstructor(options) + const res = new Response(req, cb) + + return Promise.resolve().then(() => makeRequest(dispatchFunc, server, req, res)).then(() => ret) +} + +module.exports = doInject diff --git a/lib/response.js b/lib/response.js index bcdf60b..b67de74 100644 --- a/lib/response.js +++ b/lib/response.js @@ -6,7 +6,7 @@ const util = require('util') const setCookie = require('set-cookie-parser') -function Response (req, onEnd, reject) { +function Response (req, onEnd) { http.ServerResponse.call(this, req) this._lightMyRequest = { headers: null, trailers: {}, payloadChunks: [] } @@ -15,24 +15,16 @@ function Response (req, onEnd, reject) { this.assignSocket(getNullSocket()) - this._promiseCallback = typeof reject === 'function' - 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) => { if (called) return called = true - if (this._promiseCallback) { - return process.nextTick(() => reject(err)) - } process.nextTick(() => onEnd(err, null)) } From 2d30a99ee7d9a4b5487ae94a9a6bd17eac4c52ed Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Thu, 31 Aug 2023 01:57:33 +0300 Subject: [PATCH 2/9] class response --- lib/response.js | 268 +++++++++++++++++++++++---------------------- test/index.test.js | 13 +++ 2 files changed, 150 insertions(+), 131 deletions(-) diff --git a/lib/response.js b/lib/response.js index b67de74..9db10f1 100644 --- a/lib/response.js +++ b/lib/response.js @@ -2,175 +2,181 @@ const http = require('http') const { Writable } = require('stream') -const util = require('util') const setCookie = require('set-cookie-parser') -function Response (req, onEnd) { - 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()) - - 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)) - } - - const onEndFailure = (err) => { - if (called) return - called = true - process.nextTick(() => onEnd(err, null)) - } - - this.once('finish', () => { - const res = generatePayload(this) - res.raw.req = req - onEndSuccess(res) +// Throws away all written data to prevent response from buffering payload +function getNullSocket () { + return new Writable({ + write (chunk, encoding, callback) { + setImmediate(callback) + } }) +} + +class Response extends http.ServerResponse { + constructor (req, onEnd) { + super(req) + this._lightMyRequest = { headers: null, trailers: {}, payloadChunks: [] } + this.setHeader('foo', 'bar'); this.removeHeader('foo') + 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 + process.nextTick(() => onEnd(null, payload)) + } - this.connection.once('error', onEndFailure) + const onEndFailure = (err) => { + if (called) return + called = true + process.nextTick(() => onEnd(err, null)) + } - this.once('error', onEndFailure) + this.once('finish', () => { + const res = this.generatePayload(req) + onEndSuccess(res) + }) - this.once('close', onEndFailure) -} + this.socket.once('error', onEndFailure) -util.inherits(Response, http.ServerResponse) + this.once('error', onEndFailure) -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) => { + const regex = new RegExp('\\r\\n' + name + ': ([^\\r]*)\\r\\n') + const field = this._header.match(regex) + if (field) { + this._lightMyRequest.headers[name.toLowerCase()] = field[1] + } + }) + } } module.exports = Response diff --git a/test/index.test.js b/test/index.test.js index ecb1ac7..43d3583 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1127,6 +1127,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 From 909950c09f9bfc5d939c01625e9d300ce565ae35 Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Thu, 31 Aug 2023 03:04:47 +0300 Subject: [PATCH 3/9] class request --- lib/doInject.js | 2 +- lib/request.js | 331 +++++++++++++++++++++++------------------------- 2 files changed, 160 insertions(+), 173 deletions(-) diff --git a/lib/doInject.js b/lib/doInject.js index fe3a8cb..925f8c3 100644 --- a/lib/doInject.js +++ b/lib/doInject.js @@ -46,7 +46,7 @@ function doInject (dispatchFunc, options, callback) { const server = options.server || {} const RequestConstructor = options.Request - ? Request.CustomRequest + ? Request.getCustomRequest(options.Request) : Request // Express.js detection diff --git a/lib/request.js b/lib/request.js index d5b8782..0ea7b65 100644 --- a/lib/request.js +++ b/lib/request.js @@ -3,8 +3,8 @@ /* eslint no-prototype-builtins: 0 */ const { Readable, addAbortSignal } = require('stream') -const util = require('util') const cookie = require('cookie') +const util = require('util') const assert = require('assert') const warning = require('process-warning')() @@ -39,221 +39,208 @@ 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) +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 + } - function _CustomLMRRequest (obj) { - Request.call(obj, { - ...options, - Request: undefined - }) - Object.assign(this, obj) + if (('user-agent' in this.headers) === false) { + this.headers['user-agent'] = 'lightMyRequest' + } + this.headers.host = this.headers.host || options.authority || hostHeaderFromURL(parsedURL) - for (const fn of Object.keys(Request.prototype)) { - this.constructor.prototype[fn] = Request.prototype[fn] + 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('; ') } - util.inherits(this.constructor, options.Request) - return this - } -} + this.socket = new MockSocket(options.remoteAddress || '127.0.0.1') -/** - * 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 - }) + Object.defineProperty(this, 'connection', { + get () { + warning.emit('FST_LIGHTMYREQUEST_DEP01') + return this.socket + }, + configurable: true + }) - const parsedURL = parseURL(options.url || options.path, options.query) + // we keep both payload and body for compatibility reasons + let payload = options.payload || options.body || null + const payloadResume = payload && typeof payload.resume === 'function' - this.url = parsedURL.pathname + parsedURL.search + if (payload && typeof payload !== 'string' && !payloadResume && !Buffer.isBuffer(payload)) { + payload = JSON.stringify(payload) - this.aborted = false - this.httpVersionMajor = 1 - this.httpVersionMinor = 1 - this.httpVersion = '1.1' - this.method = options.method ? options.method.toUpperCase() : 'GET' + if (('content-type' in this.headers) === false) { + this.headers['content-type'] = 'application/json' + } + } - this.headers = {} - this.rawHeaders = [] - const headers = options.headers || {} + // 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 field in headers) { - const fieldLowerCase = field.toLowerCase() - if ( - ( - fieldLowerCase === 'user-agent' || - fieldLowerCase === 'content-type' - ) && headers[field] === undefined - ) { - this.headers[fieldLowerCase] = undefined - continue + for (const header of Object.keys(this.headers)) { + this.rawHeaders.push(header, this.headers[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) + // Use _lightMyRequest namespace to avoid collision with Node + this._lightMyRequest = { + payload, + isDone: false, + simulate: options.simulate || {} + } - 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) + const signal = options.signal + /* istanbul ignore if */ + if (signal) { + addAbortSignal(signal, this) } - this.headers.cookie = cookieValues.join('; ') } - this.socket = new MockSocket(options.remoteAddress || '127.0.0.1') - - 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' + prepare (next) { + const payload = this._lightMyRequest.payload + if (!payload || typeof payload.resume !== 'function') { // does not quack like a stream + return next() } - } - // 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() - } + const chunks = [] - for (const header of Object.keys(this.headers)) { - this.rawHeaders.push(header, this.headers[header]) - } + payload.on('data', (chunk) => chunks.push(Buffer.from(chunk))) - // Use _lightMyRequest namespace to avoid collision with Node - this._lightMyRequest = { - payload, - isDone: false, - simulate: options.simulate || {} - } + payload.on('end', () => { + const payload = Buffer.concat(chunks) + this.headers['content-length'] = this.headers['content-length'] || ('' + payload.length) + this._lightMyRequest.payload = payload + return next() + }) - const signal = options.signal - /* istanbul ignore if */ - if (signal) { - addAbortSignal(signal, this) + // Force to resume the stream. Needed for Stream 1 + payload.resume() } - return this -} - -util.inherits(Request, Readable) -util.inherits(CustomRequest, Request) + _read (size) { + setImmediate(() => { + if (this._lightMyRequest.isDone) { + // 'end' defaults to true + if (this._lightMyRequest.simulate.end !== false) { + this.push(null) + } -Request.prototype.prepare = function (next) { - const payload = this._lightMyRequest.payload - if (!payload || typeof payload.resume !== 'function') { // does not quack like a stream - return next() - } + return + } - const chunks = [] + this._lightMyRequest.isDone = true - payload.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + 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) + } + } - payload.on('end', () => { - const payload = Buffer.concat(chunks) - this.headers['content-length'] = this.headers['content-length'] || ('' + payload.length) - this._lightMyRequest.payload = payload - return next() - }) + if (this._lightMyRequest.simulate.error) { + this.emit('error', new Error('Simulated')) + } - // Force to resume the stream. Needed for Stream 1 - payload.resume() -} + if (this._lightMyRequest.simulate.close) { + this.emit('close') + } -Request.prototype._read = function (size) { - setImmediate(() => { - if (this._lightMyRequest.isDone) { // 'end' defaults to true if (this._lightMyRequest.simulate.end !== false) { this.push(null) } + }) + } - return - } - - this._lightMyRequest.isDone = true + destroy (error) { + if (this.destroyed || this._lightMyRequest.isDone) return + this.destroyed = 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)) - } else { - this.push(this._lightMyRequest.payload) - } + if (error) { + this._error = true + process.nextTick(() => this.emit('error', error)) } - if (this._lightMyRequest.simulate.error) { - this.emit('error', new Error('Simulated')) - } + process.nextTick(() => this.emit('close')) + } +} - if (this._lightMyRequest.simulate.close) { - this.emit('close') - } +function Classes (bases) { + class _CustomLMRRequest { + constructor (...opt) { + bases.forEach(Base => { + Object.assign(this, new Base(...opt)) + }) - // 'end' defaults to true - if (this._lightMyRequest.simulate.end !== false) { - this.push(null) + util.inherits(this.constructor, bases[bases.length - 1]) } + } + bases.forEach(base => { + Object.getOwnPropertyNames(base.prototype) + .filter(prop => prop !== 'constructor') + .forEach(prop => { _CustomLMRRequest.prototype[prop] = base.prototype[prop] }) }) + return _CustomLMRRequest } -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)) - } - - process.nextTick(() => this.emit('close')) +/** + * @template T + * @param {new (opt: import('../types').InjectOptions) => T} CustomRequest + * @returns {new (opt: import('../types').InjectOptions) => T & Request} + */ +function getCustomRequest (CustomRequest) { + return Classes([Request, CustomRequest]) } module.exports = Request module.exports.Request = Request -module.exports.CustomRequest = CustomRequest +module.exports.getCustomRequest = getCustomRequest From 0fb0a1fd600b0e26cee4bb9746add8b2dfb7a72d Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Thu, 31 Aug 2023 04:33:17 +0300 Subject: [PATCH 4/9] use getHeader rather than _header --- lib/response.js | 7 +++---- test/index.test.js | 21 ++++----------------- types/index.d.ts | 2 +- types/index.test-d.ts | 29 ++++++++++++++++------------- 4 files changed, 24 insertions(+), 35 deletions(-) diff --git a/lib/response.js b/lib/response.js index 9db10f1..4a6945a 100644 --- a/lib/response.js +++ b/lib/response.js @@ -170,10 +170,9 @@ class Response extends http.ServerResponse { // Add raw headers ;['Date', 'Connection', 'Transfer-Encoding'].forEach((name) => { - const regex = new RegExp('\\r\\n' + name + ': ([^\\r]*)\\r\\n') - const field = this._header.match(regex) - if (field) { - this._lightMyRequest.headers[name.toLowerCase()] = field[1] + const value = this.getHeader(name) + if (value) { + this._lightMyRequest.headers[name.toLowerCase()] = value } }) } diff --git a/test/index.test.js b/test/index.test.js index 43d3583..8633501 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -28,7 +28,7 @@ const httpMethods = [ ] test('returns non-chunked payload', (t) => { - t.plan(7) + t.plan(6) const output = 'example.com:8080|/hello' const dispatch = function (req, res) { @@ -42,10 +42,7 @@ test('returns non-chunked payload', (t) => { t.error(err) t.equal(res.statusCode, 200) t.equal(res.statusMessage, 'Super') - t.ok(res.headers.date) t.strictSame(res.headers, { - date: res.headers.date, - connection: 'keep-alive', 'x-extra': 'hello', 'content-type': 'text/plain', 'content-length': output.length.toString() @@ -56,7 +53,7 @@ test('returns non-chunked payload', (t) => { }) test('returns single buffer payload', (t) => { - t.plan(6) + t.plan(3) const dispatch = function (req, res) { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end(req.headers.host + '|' + req.url) @@ -64,9 +61,6 @@ test('returns single buffer payload', (t) => { inject(dispatch, { url: 'http://example.com:8080/hello' }, (err, res) => { t.error(err) - t.ok(res.headers.date) - t.ok(res.headers.connection) - t.equal(res.headers['transfer-encoding'], 'chunked') t.equal(res.payload, 'example.com:8080|/hello') t.equal(res.rawPayload.toString(), 'example.com:8080|/hello') }) @@ -368,7 +362,7 @@ test('includes default https port in host header', (t) => { }) test('optionally accepts an object as url', (t) => { - t.plan(5) + t.plan(3) const output = 'example.com:8080|/hello?test=1234' const dispatch = function (req, res) { @@ -388,8 +382,6 @@ test('optionally accepts an object as url', (t) => { inject(dispatch, { url }, (err, res) => { t.error(err) - t.ok(res.headers.date) - t.ok(res.headers.connection) t.notOk(res.headers['transfer-encoding']) t.equal(res.payload, output) }) @@ -409,7 +401,7 @@ test('leaves user-agent unmodified', (t) => { }) test('returns chunked payload', (t) => { - t.plan(5) + t.plan(2) const dispatch = function (req, res) { res.writeHead(200, 'OK') res.write('a') @@ -419,9 +411,6 @@ test('returns chunked payload', (t) => { inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { t.error(err) - t.ok(res.headers.date) - t.ok(res.headers.connection) - t.equal(res.headers['transfer-encoding'], 'chunked') t.equal(res.payload, 'ab') }) }) @@ -1640,8 +1629,6 @@ test('correctly handles no string headers', (t) => { date: date.toString(), true: 'true', false: 'false', - connection: 'keep-alive', - 'transfer-encoding': 'chunked', 'content-type': 'application/json' }) diff --git a/types/index.d.ts b/types/index.d.ts index b267026..4abe499 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 diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 72fa8a0..4ff3b20 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -15,7 +15,10 @@ const dispatch: http.RequestListener = function (req, res) { res.end(reply) } -const expectResponse = function (res: Response) { +const expectResponse = function (res: Response | undefined) { + if (!res) { + return; + } expectType<Response>(res) console.log(res.payload) expectAssignable<Function>(res.json) @@ -38,7 +41,7 @@ const expectResponse = function (res: Response) { expectType<DispatchFunc>(dispatch) inject(dispatch, { method: 'get', url: '/' }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) @@ -52,37 +55,37 @@ const url = { } } inject(dispatch, { method: 'get', url }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) inject(dispatch, { method: 'get', url: '/', cookies: { name1: 'value1', value2: 'value2' } }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) inject(dispatch, { method: 'get', url: '/', query: { name1: 'value1', value2: 'value2' } }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) inject(dispatch, { method: 'get', url: '/', query: { name1: ['value1', 'value2'] } }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) inject(dispatch, { method: 'get', url: '/', query: 'name1=value1' }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) inject(dispatch, { method: 'post', url: '/', payload: { name1: 'value1', value2: 'value2' } }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) inject(dispatch, { method: 'post', url: '/', body: { name1: 'value1', value2: 'value2' } }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) @@ -90,9 +93,9 @@ expectType<void>( inject(dispatch) .get('/') .end((err, res) => { - expectType<Error>(err) - expectType<Response>(res) - console.log(res.payload) + expectType<Error | undefined>(err) + expectType<Response | undefined>(res) + console.log(res?.payload) }) ) @@ -129,6 +132,6 @@ const httpDispatch = function (req: http.IncomingMessage, res: http.ServerRespon } inject(httpDispatch, { method: 'get', url: '/' }, (err, res) => { - expectType<Error>(err) + expectType<Error | undefined>(err) expectResponse(res) }) From fe834341356fd68eac8a896a67abf1673ef08066 Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Thu, 31 Aug 2023 04:54:31 +0300 Subject: [PATCH 5/9] add 'use strict' --- lib/Chain.js | 2 ++ lib/doInject.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/Chain.js b/lib/Chain.js index 68aa4b2..854421a 100644 --- a/lib/Chain.js +++ b/lib/Chain.js @@ -1,3 +1,5 @@ +'use strict' + const doInject = require('./doInject') const errorMessage = 'The dispatch function has already been invoked' diff --git a/lib/doInject.js b/lib/doInject.js index 925f8c3..6a17279 100644 --- a/lib/doInject.js +++ b/lib/doInject.js @@ -1,3 +1,5 @@ +'use strict' + const assert = require('assert') const optsValidator = require('./configValidator') const Request = require('./request') From 700d3739a1632c649441a3325bb6065c5f6d86ff Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Thu, 31 Aug 2023 05:03:46 +0300 Subject: [PATCH 6/9] rename to kebab --- index.js | 4 ++-- lib/{Chain.js => chain.js} | 2 +- lib/{doInject.js => do-inject.js} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename lib/{Chain.js => chain.js} (98%) rename lib/{doInject.js => do-inject.js} (100%) diff --git a/index.js b/index.js index aa5fe43..ee8ec15 100644 --- a/index.js +++ b/index.js @@ -2,8 +2,8 @@ const Request = require('./lib/request') const Response = require('./lib/response') -const Chain = require('./lib/Chain') -const doInject = require('./lib/doInject') +const Chain = require('./lib/chain') +const doInject = require('./lib/do-inject') function inject (dispatchFunc, options, callback) { if (typeof callback === 'undefined') { diff --git a/lib/Chain.js b/lib/chain.js similarity index 98% rename from lib/Chain.js rename to lib/chain.js index 854421a..fd21335 100644 --- a/lib/Chain.js +++ b/lib/chain.js @@ -1,6 +1,6 @@ 'use strict' -const doInject = require('./doInject') +const doInject = require('./do-inject') const errorMessage = 'The dispatch function has already been invoked' diff --git a/lib/doInject.js b/lib/do-inject.js similarity index 100% rename from lib/doInject.js rename to lib/do-inject.js From 757708a4ebbc92fb432703da15460c539b697fb8 Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Thu, 31 Aug 2023 05:20:27 +0300 Subject: [PATCH 7/9] fix benchmark Custom Request --- lib/request.js | 30 ++++++++++-------------------- test/benchmark.js | 10 ++++++---- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/lib/request.js b/lib/request.js index 0ea7b65..aeaf474 100644 --- a/lib/request.js +++ b/lib/request.js @@ -4,7 +4,6 @@ const { Readable, addAbortSignal } = require('stream') const cookie = require('cookie') -const util = require('util') const assert = require('assert') const warning = require('process-warning')() @@ -214,31 +213,22 @@ class Request extends Readable { } } -function Classes (bases) { - class _CustomLMRRequest { - constructor (...opt) { - bases.forEach(Base => { - Object.assign(this, new Base(...opt)) - }) - - util.inherits(this.constructor, bases[bases.length - 1]) - } - } - bases.forEach(base => { - Object.getOwnPropertyNames(base.prototype) - .filter(prop => prop !== 'constructor') - .forEach(prop => { _CustomLMRRequest.prototype[prop] = base.prototype[prop] }) - }) - return _CustomLMRRequest -} - /** * @template T * @param {new (opt: import('../types').InjectOptions) => T} CustomRequest * @returns {new (opt: import('../types').InjectOptions) => T & Request} */ function getCustomRequest (CustomRequest) { - return Classes([Request, CustomRequest]) + class _CustomLMRRequest extends CustomRequest { + constructor (...opt) { + super(...opt) + Object.assign(this, new Request(...opt)) + } + } + Object.getOwnPropertyNames(Request.prototype) + .filter(prop => prop !== 'constructor') + .forEach(prop => { _CustomLMRRequest.prototype[prop] = Request.prototype[prop] }) + return _CustomLMRRequest } module.exports = Request diff --git a/test/benchmark.js b/test/benchmark.js index 6e52182..7f4d4b8 100644 --- a/test/benchmark.js +++ b/test/benchmark.js @@ -26,6 +26,7 @@ const mockCustomReq = { }, Request: http.IncomingMessage } + const mockReqCookies = { url: 'http://localhost', method: 'GET', @@ -52,11 +53,12 @@ const mockReqCookiesPayload = { } } -suite.add('Request', function () { - new Request(mockReq) -}) +suite + .add('Request', function () { + new Request(mockReq) + }) .add('Custom Request', function () { - new Request.CustomRequest(mockCustomReq) + new (Request.getCustomRequest(mockCustomReq.Request))(mockCustomReq) }) .add('Request With Cookies', function () { new Request(mockReqCookies) From e5d4b0cb382e7611fe2f87bc67c6d430ff3affde Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Wed, 13 Sep 2023 14:24:12 +0300 Subject: [PATCH 8/9] fix Custom Request --- benchmark/benchmark.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/benchmark.js b/benchmark/benchmark.js index 4267802..a0d6722 100644 --- a/benchmark/benchmark.js +++ b/benchmark/benchmark.js @@ -86,7 +86,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 From 2bda23474a8f65b23b61d6c9ec8ec8ca63202ef5 Mon Sep 17 00:00:00 2001 From: Vlad Sirenko <sirenkovladd@gmail.com> Date: Tue, 24 Oct 2023 02:15:40 -0700 Subject: [PATCH 9/9] return copy header from ServerResponse._header --- lib/response.js | 5 ++++- test/index.test.js | 21 +++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/response.js b/lib/response.js index c226bb5..3cbe5eb 100644 --- a/lib/response.js +++ b/lib/response.js @@ -170,7 +170,10 @@ class Response extends http.ServerResponse { // Add raw headers ;['Date', 'Connection', 'Transfer-Encoding'].forEach((name) => { - const value = this.getHeader(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 } diff --git a/test/index.test.js b/test/index.test.js index 04e9afd..35d61bb 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -28,7 +28,7 @@ const httpMethods = [ ] test('returns non-chunked payload', (t) => { - t.plan(6) + t.plan(7) const output = 'example.com:8080|/hello' const dispatch = function (req, res) { @@ -42,7 +42,10 @@ test('returns non-chunked payload', (t) => { t.error(err) t.equal(res.statusCode, 200) t.equal(res.statusMessage, 'Super') + t.ok(res.headers.date) t.strictSame(res.headers, { + date: res.headers.date, + connection: 'keep-alive', 'x-extra': 'hello', 'content-type': 'text/plain', 'content-length': output.length.toString() @@ -53,7 +56,7 @@ test('returns non-chunked payload', (t) => { }) test('returns single buffer payload', (t) => { - t.plan(3) + t.plan(6) const dispatch = function (req, res) { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end(req.headers.host + '|' + req.url) @@ -61,6 +64,9 @@ test('returns single buffer payload', (t) => { inject(dispatch, { url: 'http://example.com:8080/hello' }, (err, res) => { t.error(err) + t.ok(res.headers.date) + t.ok(res.headers.connection) + t.equal(res.headers['transfer-encoding'], 'chunked') t.equal(res.payload, 'example.com:8080|/hello') t.equal(res.rawPayload.toString(), 'example.com:8080|/hello') }) @@ -362,7 +368,7 @@ test('includes default https port in host header', (t) => { }) test('optionally accepts an object as url', (t) => { - t.plan(3) + t.plan(5) const output = 'example.com:8080|/hello?test=1234' const dispatch = function (req, res) { @@ -382,6 +388,8 @@ test('optionally accepts an object as url', (t) => { inject(dispatch, { url }, (err, res) => { t.error(err) + t.ok(res.headers.date) + t.ok(res.headers.connection) t.notOk(res.headers['transfer-encoding']) t.equal(res.payload, output) }) @@ -401,7 +409,7 @@ test('leaves user-agent unmodified', (t) => { }) test('returns chunked payload', (t) => { - t.plan(2) + t.plan(5) const dispatch = function (req, res) { res.writeHead(200, 'OK') res.write('a') @@ -411,6 +419,9 @@ test('returns chunked payload', (t) => { inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { t.error(err) + t.ok(res.headers.date) + t.ok(res.headers.connection) + t.equal(res.headers['transfer-encoding'], 'chunked') t.equal(res.payload, 'ab') }) }) @@ -1629,6 +1640,8 @@ test('correctly handles no string headers', (t) => { date: date.toString(), true: 'true', false: 'false', + connection: 'keep-alive', + 'transfer-encoding': 'chunked', 'content-type': 'application/json' })