├── .gitattributes ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ ├── benchmark.yml │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── benchmark └── benchmark.js ├── build └── build-validation.js ├── eslint.config.js ├── index.js ├── lib ├── config-validator.js ├── form-data.js ├── parse-url.js ├── request.js └── response.js ├── package.json ├── test ├── async-await.test.js ├── index.test.js ├── request.test.js ├── response.test.js └── stream.test.js └── types ├── index.d.ts └── index.test-d.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 15 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - "discussion" 8 | - "feature request" 9 | - "bug" 10 | - "help wanted" 11 | - "plugin suggestion" 12 | - "good first issue" 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity. It will be closed if no further activity occurs. Thank you 19 | for your contributions. 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /.github/workflows/benchmark.yml: -------------------------------------------------------------------------------- 1 | name: Benchmark PR 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - labeled 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | benchmark: 13 | if: ${{ github.event.label.name == 'benchmark' }} 14 | uses: fastify/workflows/.github/workflows/plugins-benchmark-pr.yml@v5 15 | with: 16 | npm-script: benchmark 17 | 18 | remove-label: 19 | if: "always()" 20 | needs: 21 | - benchmark 22 | runs-on: ubuntu-latest 23 | permissions: 24 | pull-requests: write 25 | steps: 26 | - name: Remove benchmark label 27 | uses: octokit/request-action@v2.x 28 | id: remove-label 29 | with: 30 | route: DELETE /repos/{repo}/issues/{issue_number}/labels/{name} 31 | repo: ${{ github.event.pull_request.head.repo.full_name }} 32 | issue_number: ${{ github.event.pull_request.number }} 33 | name: benchmark 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | test: 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 26 | with: 27 | fastify-dependency-integration: true 28 | license-check: true 29 | lint: true 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | 154 | # flamegraphs 155 | profile* 156 | 157 | # generated code 158 | examples/typescript-server.js 159 | test/type/index.js 160 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 The Fastify Team 2 | Copyright (c) 2012-2017, Project contributors 3 | Copyright (c) 2012-2014, Walmart 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | * The names of any contributors may not be used to endorse or promote 14 | products derived from this software without specific prior written 15 | permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY 21 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | * * * 29 | 30 | The complete list of contributors can be found at: 31 | - https://github.com/hapijs/shot/graphs/contributors 32 | - https://github.com/fastify/light-my-request/graphs/contributors 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Light my Request 2 | 3 | [![CI](https://github.com/fastify/light-my-request/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/light-my-request/actions/workflows/ci.yml) 4 | [![NPM version](https://img.shields.io/npm/v/light-my-request.svg?style=flat)](https://www.npmjs.com/package/light-my-request) 5 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 6 | 7 | Injects a fake HTTP request/response into a node HTTP server for simulating server logic, writing tests, or debugging. 8 | Does not use a socket connection so can be run against an inactive server (server not in listen mode). 9 | 10 | ## Example 11 | 12 | ```javascript 13 | const http = require('node:http') 14 | const inject = require('light-my-request') 15 | 16 | const dispatch = function (req, res) { 17 | const reply = 'Hello World' 18 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': reply.length }) 19 | res.end(reply) 20 | } 21 | 22 | const server = http.createServer(dispatch) 23 | 24 | inject(dispatch, { method: 'get', url: '/' }, (err, res) => { 25 | console.log(res.payload) 26 | }) 27 | ``` 28 | Note how `server.listen` is never called. 29 | 30 | Async await and promises are supported as well! 31 | ```javascript 32 | // promises 33 | inject(dispatch, { method: 'get', url: '/' }) 34 | .then(res => console.log(res.payload)) 35 | .catch(console.log) 36 | 37 | // async-await 38 | try { 39 | const res = await inject(dispatch, { method: 'get', url: '/' }) 40 | console.log(res.payload) 41 | } catch (err) { 42 | console.log(err) 43 | } 44 | ``` 45 | 46 | You can also use chaining methods if you do not pass the callback function. Check [here](#method-chaining) for details. 47 | 48 | ```js 49 | // chaining methods 50 | inject(dispatch) 51 | .get('/') // set the request method to GET, and request URL to '/' 52 | .headers({ foo: 'bar' }) // set the request headers 53 | .query({ foo: 'bar' }) // set the query parameters 54 | .end((err, res) => { 55 | console.log(res.payload) 56 | }) 57 | 58 | inject(dispatch) 59 | .post('/') // set the request method to POST, and request URL to '/' 60 | .payload('request payload') // set the request payload 61 | .body('request body') // alias for payload 62 | .end((err, res) => { 63 | console.log(res.payload) 64 | }) 65 | 66 | // async-await is also supported 67 | try { 68 | const chain = inject(dispatch).get('/') 69 | const res = await chain.end() 70 | console.log(res.payload) 71 | } catch (err) { 72 | console.log(err) 73 | } 74 | ``` 75 | 76 | File uploads (`multipart/form-data`) or form submit (`x-www-form-urlencoded`) can be achieved by using [form-auto-content](https://github.com/Eomm/form-auto-content) package as shown below: 77 | 78 | ```js 79 | const formAutoContent = require('form-auto-content') 80 | const fs = require('node:fs') 81 | 82 | try { 83 | const form = formAutoContent({ 84 | myField: 'hello', 85 | myFile: fs.createReadStream(`./path/to/file`) 86 | }) 87 | 88 | const res = await inject(dispatch, { 89 | method: 'post', 90 | url: '/upload', 91 | ...form 92 | }) 93 | console.log(res.payload) 94 | } catch (err) { 95 | console.log(err) 96 | } 97 | ``` 98 | 99 | This module ships with a handwritten TypeScript declaration file for TS support. The declaration exports a single namespace `LightMyRequest`. You can import it one of two ways: 100 | ```typescript 101 | import * as LightMyRequest from 'light-my-request' 102 | 103 | const dispatch: LightMyRequest.DispatchFunc = function (req, res) { 104 | const reply = 'Hello World' 105 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': reply.length }) 106 | res.end(reply) 107 | } 108 | 109 | LightMyRequest.inject(dispatch, { method: 'get', url: '/' }, (err, res) => { 110 | console.log(res.payload) 111 | }) 112 | 113 | // or 114 | import { inject, DispatchFunc } from 'light-my-request' 115 | 116 | const dispatch: DispatchFunc = function (req, res) { 117 | const reply = 'Hello World' 118 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': reply.length }) 119 | res.end(reply) 120 | } 121 | 122 | inject(dispatch, { method: 'get', url: '/' }, (err, res) => { 123 | console.log(res.payload) 124 | }) 125 | ``` 126 | The declaration file exports types for the following parts of the API: 127 | - `inject` - standard light-my-request `inject` method 128 | - `DispatchFunc` - the fake HTTP dispatch function 129 | - `InjectPayload` - a union type for valid payload types 130 | - `isInjection` - standard light-my-request `isInjection` method 131 | - `InjectOptions` - options object for `inject` method 132 | - `Request` - custom light-my-request `request` object interface. Extends 133 | Node.js `stream.Readable` type by default. This behavior can be changed by 134 | setting the `Request` option in the `inject` method's options 135 | - `Response` - custom light-my-request `response` object interface. Extends Node.js `http.ServerResponse` type 136 | 137 | ## API 138 | 139 | #### `inject(dispatchFunc[, options, callback])` 140 | 141 | Injects a fake request into an HTTP server. 142 | 143 | - `dispatchFunc` - listener function. The same as you would pass to `Http.createServer` when making a node HTTP server. Has the signature `function (req, res)` where: 144 | - `req` - a simulated request object. Inherits from `Stream.Readable` by 145 | default. Optionally inherits from another class, set in 146 | `options.Request` 147 | - `res` - a simulated response object. Inherits from node's `Http.ServerResponse`. 148 | - `options` - request options object where: 149 | - `url` | `path` - a string specifying the request URL. 150 | - `method` - a string specifying the HTTP request method, defaulting to `'GET'`. 151 | - `authority` - a string specifying the HTTP HOST header value to be used if no header is provided, and the `url` 152 | does not include an authority component. Defaults to `'localhost'`. 153 | - `headers` - an optional object containing request headers. 154 | - `cookies` - an optional object containing key-value pairs that will be encoded and added to `cookie` header. If the header is already set, the data will be appended. 155 | - `remoteAddress` - an optional string specifying the client remote address. Defaults to `'127.0.0.1'`. 156 | - `payload` - an optional request payload. Can be a string, Buffer, Stream, or object. If the payload is string, Buffer or Stream is used as is as the request payload. Otherwise, it is serialized with `JSON.stringify` forcing the request to have the `Content-type` equal to `application/json` 157 | - `query` - an optional object or string containing query parameters. 158 | - `body` - alias for payload. 159 | - `simulate` - an object containing flags to simulate various conditions: 160 | - `end` - indicates whether the request will fire an `end` event. Defaults to `undefined`, meaning an `end` event will fire. 161 | - `split` - indicates whether the request payload will be split into chunks. Defaults to `undefined`, meaning payload will not be chunked. 162 | - `error` - whether the request will emit an `error` event. Defaults to `undefined`, meaning no `error` event will be emitted. If set to `true`, the emitted error will have a message of `'Simulated'`. 163 | - `close` - whether the request will emit a `close` event. Defaults to `undefined`, meaning no `close` event will be emitted. 164 | - `validate` - Optional flag to validate this options object. Defaults to `true`. 165 | - `server` - Optional http server. It is used for binding the `dispatchFunc`. 166 | - `autoStart` - Automatically start the request as soon as the method 167 | is called. It is only valid when not passing a callback. Defaults to `true`. 168 | - `signal` - An `AbortSignal` that may be used to abort an ongoing request. Requires Node v16+. 169 | - `Request` - Optional type from which the `request` object should inherit 170 | instead of `stream.Readable` 171 | - `payloadAsStream` - if set to `true`, the response will be streamed and not accumulated; in this case `res.payload`, `res.rawPayload` will be undefined. 172 | - `callback` - the callback function using the signature `function (err, res)` where: 173 | - `err` - error object 174 | - `res` - a response object where: 175 | - `raw` - an object containing the raw request and response objects where: 176 | - `req` - the simulated request object. 177 | - `res` - the simulated response object. 178 | - `headers` - an object containing the response headers. 179 | - `statusCode` - the HTTP status code. 180 | - `statusMessage` - the HTTP status message. 181 | - `payload` - the payload as a UTF-8 encoded string. 182 | - `body` - alias for payload. 183 | - `rawPayload` - the raw payload as a Buffer. 184 | - `trailers` - an object containing the response trailers. 185 | - `json` - a function that parses a json response payload and returns an object. 186 | - `stream` - a function that provides a `Readable` stream of the response payload. 187 | - `cookies` - a getter that parses the `set-cookie` response header and returns an array with all the cookies and their metadata. 188 | 189 | Notes: 190 | 191 | - You can also pass a string in place of the `options` object as a shorthand 192 | for `{url: string, method: 'GET'}`. 193 | - Beware when using the `Request` option. That might make _light-my-request_ 194 | slower. Sample benchmark result run on an i5-8600K CPU with `Request` set to 195 | `http.IncomingMessage`: 196 | 197 | ``` 198 | Request x 155,018 ops/sec ±0.47% (94 runs sampled) 199 | Custom Request x 30,373 ops/sec ±0.64% (90 runs sampled) 200 | Request With Cookies x 125,696 ops/sec ±0.29% (96 runs sampled) 201 | Request With Cookies n payload x 114,391 ops/sec ±0.33% (97 runs sampled) 202 | ParseUrl x 255,790 ops/sec ±0.23% (99 runs sampled) 203 | ParseUrl and query x 194,479 ops/sec ±0.16% (99 runs sampled) 204 | ``` 205 | 206 | #### `inject.isInjection(obj)` 207 | 208 | Checks if given object `obj` is a *light-my-request* `Request` object. 209 | 210 | #### Method chaining 211 | 212 | The following methods can be used in chaining: 213 | - `delete`, `get`, `head`, `options`, `patch`, `post`, `put`, `trace`. They will set the HTTP request method and the request URL. 214 | - `body`, `headers`, `payload`, `query`, `cookies`. They can be used to set the request options object. 215 | 216 | And finally, you need to call `end`. It has the signature `function (callback)`. 217 | If you invoke `end` without a callback function, the method will return a promise, thus you can: 218 | 219 | ```js 220 | const chain = inject(dispatch).get('/') 221 | 222 | try { 223 | const res = await chain.end() 224 | console.log(res.payload) 225 | } catch (err) { 226 | // handle error 227 | } 228 | 229 | // or 230 | chain.end() 231 | .then(res => { 232 | console.log(res.payload) 233 | }) 234 | .catch(err => { 235 | // handle error 236 | }) 237 | ``` 238 | 239 | By the way, you can also use promises without calling `end`! 240 | 241 | ```js 242 | inject(dispatch) 243 | .get('/') 244 | .then(res => { 245 | console.log(res.payload) 246 | }) 247 | .catch(err => { 248 | // handle error 249 | }) 250 | ``` 251 | 252 | Note: The application would not respond multiple times. If you try to invoke any method after the application has responded, the application would throw an error. 253 | 254 | ## Acknowledgments 255 | This project has been forked from [`hapi/shot`](https://github.com/hapijs/shot) because we wanted to support *Node ≥ v4* and not only *Node ≥ v8*. 256 | All credits prior to commit [00a2a82](https://github.com/fastify/light-my-request/commit/00a2a82eb773b765003b6085788cc3564cd08326) go to the `hapi/shot` project [contributors](https://github.com/hapijs/shot/graphs/contributors). 257 | Since commit [db8bced](https://github.com/fastify/light-my-request/commit/db8bced10b4367731688c8738621d42f39680efc) the project will be maintained by the Fastify team. 258 | 259 | ## License 260 | 261 | Licensed under [BSD-3-Clause](./LICENSE). 262 | -------------------------------------------------------------------------------- /benchmark/benchmark.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('node:http') 4 | const Request = require('../lib/request') 5 | const Response = require('../lib/response') 6 | const inject = require('..') 7 | const parseURL = require('../lib/parse-url') 8 | const { Readable } = require('node:stream') 9 | const { assert } = require('node:console') 10 | const { Bench } = require('tinybench') 11 | 12 | const suite = new Bench() 13 | 14 | const mockReq = { 15 | url: 'http://localhost', 16 | method: 'GET', 17 | headers: { 18 | foo: 'bar', 19 | 'content-type': 'html', 20 | accepts: 'json', 21 | authorization: 'granted' 22 | } 23 | } 24 | const mockCustomReq = { 25 | url: 'http://localhost', 26 | method: 'GET', 27 | headers: { 28 | foo: 'bar', 29 | 'content-type': 'html', 30 | accepts: 'json', 31 | authorization: 'granted' 32 | }, 33 | Request: http.IncomingMessage 34 | } 35 | const mockReqCookies = { 36 | url: 'http://localhost', 37 | method: 'GET', 38 | cookies: { foo: 'bar', grass: 'àìùòlé' }, 39 | headers: { 40 | foo: 'bar', 41 | 'content-type': 'html', 42 | accepts: 'json', 43 | authorization: 'granted' 44 | } 45 | } 46 | const mockReqCookiesPayload = { 47 | url: 'http://localhost', 48 | method: 'GET', 49 | headers: { 50 | foo: 'bar', 51 | 'content-type': 'html', 52 | accepts: 'json', 53 | authorization: 'granted' 54 | }, 55 | payload: { 56 | foo: { bar: 'fiz' }, 57 | bim: { bar: { boom: 'paf' } } 58 | } 59 | } 60 | const mockReqCookiesPayloadBuffer = { 61 | url: 'http://localhost', 62 | method: 'GET', 63 | headers: { 64 | foo: 'bar', 65 | 'content-type': 'html', 66 | accepts: 'json', 67 | authorization: 'granted' 68 | }, 69 | payload: Buffer.from('foo') 70 | } 71 | const mockReqCookiesPayloadReadable = () => ({ 72 | url: 'http://localhost', 73 | method: 'GET', 74 | headers: { 75 | foo: 'bar', 76 | 'content-type': 'html', 77 | accepts: 'json', 78 | authorization: 'granted' 79 | }, 80 | payload: Readable.from(['foo', 'bar', 'baz']) 81 | }) 82 | 83 | suite 84 | .add('Request', function () { 85 | new Request(mockReq) // eslint-disable-line no-new 86 | }) 87 | .add('Custom Request', function () { 88 | new Request.CustomRequest(mockCustomReq) // eslint-disable-line no-new 89 | }) 90 | .add('Request With Cookies', function () { 91 | new Request(mockReqCookies) // eslint-disable-line no-new 92 | }) 93 | .add('Request With Cookies n payload', function () { 94 | new Request(mockReqCookiesPayload) // eslint-disable-line no-new 95 | }) 96 | .add('ParseUrl', function () { 97 | parseURL('http://example.com:8080/hello') 98 | }) 99 | .add('ParseUrl and query', function () { 100 | parseURL('http://example.com:8080/hello', { 101 | foo: 'bar', 102 | message: 'OK', 103 | xs: ['foo', 'bar'] 104 | }) 105 | }) 106 | .add('read request body JSON', function () { 107 | return new Promise((resolve) => { 108 | const req = new Request(mockReqCookiesPayload) 109 | req.prepare(() => { 110 | req.on('data', () => {}) 111 | req.on('end', resolve) 112 | }) 113 | }) 114 | }) 115 | .add('read request body buffer', function () { 116 | return new Promise((resolve) => { 117 | const req = new Request(mockReqCookiesPayloadBuffer) 118 | req.prepare(() => { 119 | req.on('data', () => {}) 120 | req.on('end', resolve) 121 | }) 122 | }) 123 | }) 124 | .add('read request body readable', function () { 125 | return new Promise((resolve) => { 126 | const req = new Request(mockReqCookiesPayloadReadable()) 127 | req.prepare(() => { 128 | req.on('data', () => {}) 129 | req.on('end', resolve) 130 | }) 131 | }) 132 | }) 133 | .add('Response write end', function () { 134 | const req = new Request(mockReq) 135 | return new Promise((resolve) => { 136 | const res = new Response(req, resolve) 137 | res.write('foo') 138 | res.end() 139 | }) 140 | }) 141 | .add('Response writeHead end', function () { 142 | const req = new Request(mockReq) 143 | return new Promise((resolve) => { 144 | const res = new Response(req, resolve) 145 | res.writeHead(400, { 'content-length': 200 }) 146 | res.end() 147 | }) 148 | }) 149 | .add('base inject', async function () { 150 | const d = await inject((req, res) => { 151 | req.on('data', () => {}) 152 | req.on('end', () => { res.end('1') }) 153 | }, mockReqCookiesPayload) 154 | assert(d.payload === '1') 155 | }) 156 | .run() 157 | .then((tasks) => { 158 | const errors = tasks.map(t => t.result?.error).filter((t) => t) 159 | if (errors.length) { 160 | errors.map((e) => console.error(e)) 161 | } else { 162 | console.table(suite.table()) 163 | } 164 | }) 165 | -------------------------------------------------------------------------------- /build/build-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('node:http') 4 | const AjvStandaloneCompiler = require('@fastify/ajv-compiler/standalone') 5 | const fs = require('node:fs') 6 | const path = require('node:path') 7 | 8 | const urlSchema = { 9 | oneOf: [ 10 | { type: 'string' }, 11 | { 12 | type: 'object', 13 | properties: { 14 | protocol: { type: 'string' }, 15 | hostname: { type: 'string' }, 16 | pathname: { type: 'string' } 17 | // port type => any 18 | // query type => any 19 | }, 20 | additionalProperties: true, 21 | required: ['pathname'] 22 | } 23 | ] 24 | } 25 | 26 | const schema = { 27 | type: 'object', 28 | properties: { 29 | url: urlSchema, 30 | path: urlSchema, 31 | cookies: { 32 | type: 'object', 33 | additionalProperties: true 34 | }, 35 | headers: { 36 | type: 'object', 37 | additionalProperties: true 38 | }, 39 | query: { 40 | anyOf: [ 41 | { 42 | type: 'object', 43 | additionalProperties: true 44 | }, 45 | { 46 | type: 'string' 47 | } 48 | ] 49 | }, 50 | simulate: { 51 | type: 'object', 52 | properties: { 53 | end: { type: 'boolean' }, 54 | split: { type: 'boolean' }, 55 | error: { type: 'boolean' }, 56 | close: { type: 'boolean' } 57 | } 58 | }, 59 | authority: { type: 'string' }, 60 | remoteAddress: { type: 'string' }, 61 | method: { type: 'string', enum: http.METHODS.concat(http.METHODS.map(toLowerCase)) }, 62 | validate: { type: 'boolean' } 63 | // payload type => any 64 | }, 65 | additionalProperties: true, 66 | oneOf: [ 67 | { required: ['url'] }, 68 | { required: ['path'] } 69 | ] 70 | } 71 | 72 | function toLowerCase (m) { return m.toLowerCase() } 73 | 74 | const factory = AjvStandaloneCompiler({ 75 | readMode: false, 76 | storeFunction (routeOpts, schemaValidationCode) { 77 | const moduleCode = `// This file is autogenerated by ${__filename.replace(__dirname, 'build')}, do not edit 78 | /* c8 ignore start */ 79 | /* eslint-disable */ 80 | ${schemaValidationCode} 81 | ` 82 | const file = path.join(__dirname, '..', 'lib', 'config-validator.js') 83 | fs.writeFileSync(file, moduleCode) 84 | console.log(`Saved ${file} file successfully`) 85 | } 86 | }) 87 | 88 | const compiler = factory({}, { 89 | customOptions: { 90 | code: { 91 | source: true, 92 | lines: true, 93 | optimize: 3 94 | }, 95 | removeAdditional: true, 96 | coerceTypes: true 97 | } 98 | }) 99 | 100 | compiler({ schema }) 101 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: [ 5 | ...require('neostandard').resolveIgnoresFromGitignore(), 6 | 'test/benchmark.js' 7 | ], 8 | ts: true 9 | }) 10 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const assert = require('node:assert') 4 | const Request = require('./lib/request') 5 | const Response = require('./lib/response') 6 | 7 | const errorMessage = 'The dispatch function has already been invoked' 8 | 9 | const optsValidator = require('./lib/config-validator') 10 | 11 | function inject (dispatchFunc, options, callback) { 12 | if (callback === undefined) { 13 | return new Chain(dispatchFunc, options) 14 | } else { 15 | return doInject(dispatchFunc, options, callback) 16 | } 17 | } 18 | 19 | function supportStream1 (req, next) { 20 | const payload = req._lightMyRequest.payload 21 | if (!payload || payload._readableState || typeof payload.resume !== 'function') { // does quack like a modern stream 22 | return next() 23 | } 24 | 25 | // This is a non-compliant stream 26 | const chunks = [] 27 | 28 | // We are accumulating because Readable.wrap() does not really work as expected 29 | // in this case. 30 | payload.on('data', (chunk) => chunks.push(Buffer.from(chunk))) 31 | 32 | payload.on('end', () => { 33 | const payload = Buffer.concat(chunks) 34 | req.headers['content-length'] = req.headers['content-length'] || ('' + payload.length) 35 | delete req.headers['transfer-encoding'] 36 | req._lightMyRequest.payload = payload 37 | return next() 38 | }) 39 | 40 | // Force to resume the stream. Needed for Stream 1 41 | payload.resume() 42 | } 43 | 44 | function makeRequest (dispatchFunc, server, req, res) { 45 | req.once('error', function (err) { 46 | if (this.destroyed) res.destroy(err) 47 | }) 48 | 49 | req.once('close', function () { 50 | if (this.destroyed && !this._error) { 51 | res.destroy() 52 | } 53 | }) 54 | 55 | return supportStream1(req, () => dispatchFunc.call(server, req, res)) 56 | } 57 | 58 | function doInject (dispatchFunc, options, callback) { 59 | options = (typeof options === 'string' ? { url: options } : options) 60 | 61 | if (options.validate !== false) { 62 | assert(typeof dispatchFunc === 'function', 'dispatchFunc should be a function') 63 | const isOptionValid = optsValidator(options) 64 | if (!isOptionValid) { 65 | throw new Error(optsValidator.errors.map(e => e.message)) 66 | } 67 | } 68 | 69 | const server = options.server || {} 70 | 71 | const RequestConstructor = options.Request 72 | ? Request.CustomRequest 73 | : Request 74 | 75 | // Express.js detection 76 | if (dispatchFunc.request && dispatchFunc.request.app === dispatchFunc) { 77 | Object.setPrototypeOf(Object.getPrototypeOf(dispatchFunc.request), RequestConstructor.prototype) 78 | Object.setPrototypeOf(Object.getPrototypeOf(dispatchFunc.response), Response.prototype) 79 | } 80 | 81 | if (typeof callback === 'function') { 82 | const req = new RequestConstructor(options) 83 | const res = new Response(req, callback) 84 | 85 | return makeRequest(dispatchFunc, server, req, res) 86 | } else { 87 | return new Promise((resolve, reject) => { 88 | const req = new RequestConstructor(options) 89 | const res = new Response(req, resolve, reject) 90 | 91 | makeRequest(dispatchFunc, server, req, res) 92 | }) 93 | } 94 | } 95 | 96 | function Chain (dispatch, option) { 97 | if (typeof option === 'string') { 98 | this.option = { url: option } 99 | } else { 100 | this.option = Object.assign({}, option) 101 | } 102 | 103 | this.dispatch = dispatch 104 | this._hasInvoked = false 105 | this._promise = null 106 | 107 | if (this.option.autoStart !== false) { 108 | process.nextTick(() => { 109 | if (!this._hasInvoked) { 110 | this.end() 111 | } 112 | }) 113 | } 114 | } 115 | 116 | const httpMethods = [ 117 | 'delete', 118 | 'get', 119 | 'head', 120 | 'options', 121 | 'patch', 122 | 'post', 123 | 'put', 124 | 'trace' 125 | ] 126 | 127 | httpMethods.forEach(method => { 128 | Chain.prototype[method] = function (url) { 129 | if (this._hasInvoked === true || this._promise) { 130 | throw new Error(errorMessage) 131 | } 132 | this.option.url = url 133 | this.option.method = method.toUpperCase() 134 | return this 135 | } 136 | }) 137 | 138 | const chainMethods = [ 139 | 'body', 140 | 'cookies', 141 | 'headers', 142 | 'payload', 143 | 'query' 144 | ] 145 | 146 | chainMethods.forEach(method => { 147 | Chain.prototype[method] = function (value) { 148 | if (this._hasInvoked === true || this._promise) { 149 | throw new Error(errorMessage) 150 | } 151 | this.option[method] = value 152 | return this 153 | } 154 | }) 155 | 156 | Chain.prototype.end = function (callback) { 157 | if (this._hasInvoked === true || this._promise) { 158 | throw new Error(errorMessage) 159 | } 160 | this._hasInvoked = true 161 | if (typeof callback === 'function') { 162 | doInject(this.dispatch, this.option, callback) 163 | } else { 164 | this._promise = doInject(this.dispatch, this.option) 165 | return this._promise 166 | } 167 | } 168 | 169 | Object.getOwnPropertyNames(Promise.prototype).forEach(method => { 170 | if (method === 'constructor') return 171 | Chain.prototype[method] = function (...args) { 172 | if (!this._promise) { 173 | if (this._hasInvoked === true) { 174 | throw new Error(errorMessage) 175 | } 176 | this._hasInvoked = true 177 | this._promise = doInject(this.dispatch, this.option) 178 | } 179 | return this._promise[method](...args) 180 | } 181 | }) 182 | 183 | function isInjection (obj) { 184 | return ( 185 | obj instanceof Request || 186 | obj instanceof Response || 187 | obj?.constructor?.name === '_CustomLMRRequest' 188 | ) 189 | } 190 | 191 | module.exports = inject 192 | module.exports.default = inject 193 | module.exports.inject = inject 194 | module.exports.isInjection = isInjection 195 | -------------------------------------------------------------------------------- /lib/config-validator.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated by build/build-validation.js, do not edit 2 | /* c8 ignore start */ 3 | /* eslint-disable */ 4 | "use strict"; 5 | module.exports = validate10; 6 | module.exports.default = validate10; 7 | const schema11 = {"type":"object","properties":{"url":{"oneOf":[{"type":"string"},{"type":"object","properties":{"protocol":{"type":"string"},"hostname":{"type":"string"},"pathname":{"type":"string"}},"additionalProperties":true,"required":["pathname"]}]},"path":{"oneOf":[{"type":"string"},{"type":"object","properties":{"protocol":{"type":"string"},"hostname":{"type":"string"},"pathname":{"type":"string"}},"additionalProperties":true,"required":["pathname"]}]},"cookies":{"type":"object","additionalProperties":true},"headers":{"type":"object","additionalProperties":true},"query":{"anyOf":[{"type":"object","additionalProperties":true},{"type":"string"}]},"simulate":{"type":"object","properties":{"end":{"type":"boolean"},"split":{"type":"boolean"},"error":{"type":"boolean"},"close":{"type":"boolean"}}},"authority":{"type":"string"},"remoteAddress":{"type":"string"},"method":{"type":"string","enum":["ACL","BIND","CHECKOUT","CONNECT","COPY","DELETE","GET","HEAD","LINK","LOCK","M-SEARCH","MERGE","MKACTIVITY","MKCALENDAR","MKCOL","MOVE","NOTIFY","OPTIONS","PATCH","POST","PROPFIND","PROPPATCH","PURGE","PUT","QUERY","REBIND","REPORT","SEARCH","SOURCE","SUBSCRIBE","TRACE","UNBIND","UNLINK","UNLOCK","UNSUBSCRIBE","acl","bind","checkout","connect","copy","delete","get","head","link","lock","m-search","merge","mkactivity","mkcalendar","mkcol","move","notify","options","patch","post","propfind","proppatch","purge","put","query","rebind","report","search","source","subscribe","trace","unbind","unlink","unlock","unsubscribe"]},"validate":{"type":"boolean"}},"additionalProperties":true,"oneOf":[{"required":["url"]},{"required":["path"]}]}; 8 | 9 | function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){ 10 | let vErrors = null; 11 | let errors = 0; 12 | const _errs1 = errors; 13 | let valid0 = false; 14 | let passing0 = null; 15 | const _errs2 = errors; 16 | if(data && typeof data == "object" && !Array.isArray(data)){ 17 | let missing0; 18 | if((data.url === undefined) && (missing0 = "url")){ 19 | const err0 = {instancePath,schemaPath:"#/oneOf/0/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}; 20 | if(vErrors === null){ 21 | vErrors = [err0]; 22 | } 23 | else { 24 | vErrors.push(err0); 25 | } 26 | errors++; 27 | } 28 | } 29 | var _valid0 = _errs2 === errors; 30 | if(_valid0){ 31 | valid0 = true; 32 | passing0 = 0; 33 | } 34 | const _errs3 = errors; 35 | if(data && typeof data == "object" && !Array.isArray(data)){ 36 | let missing1; 37 | if((data.path === undefined) && (missing1 = "path")){ 38 | const err1 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}; 39 | if(vErrors === null){ 40 | vErrors = [err1]; 41 | } 42 | else { 43 | vErrors.push(err1); 44 | } 45 | errors++; 46 | } 47 | } 48 | var _valid0 = _errs3 === errors; 49 | if(_valid0 && valid0){ 50 | valid0 = false; 51 | passing0 = [passing0, 1]; 52 | } 53 | else { 54 | if(_valid0){ 55 | valid0 = true; 56 | passing0 = 1; 57 | } 58 | } 59 | if(!valid0){ 60 | const err2 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0},message:"must match exactly one schema in oneOf"}; 61 | if(vErrors === null){ 62 | vErrors = [err2]; 63 | } 64 | else { 65 | vErrors.push(err2); 66 | } 67 | errors++; 68 | validate10.errors = vErrors; 69 | return false; 70 | } 71 | else { 72 | errors = _errs1; 73 | if(vErrors !== null){ 74 | if(_errs1){ 75 | vErrors.length = _errs1; 76 | } 77 | else { 78 | vErrors = null; 79 | } 80 | } 81 | } 82 | if(errors === 0){ 83 | if(data && typeof data == "object" && !Array.isArray(data)){ 84 | if(data.url !== undefined){ 85 | let data0 = data.url; 86 | const _errs5 = errors; 87 | const _errs6 = errors; 88 | let valid2 = false; 89 | let passing1 = null; 90 | const _errs7 = errors; 91 | if(typeof data0 !== "string"){ 92 | let dataType0 = typeof data0; 93 | let coerced0 = undefined; 94 | if(!(coerced0 !== undefined)){ 95 | if(dataType0 == "number" || dataType0 == "boolean"){ 96 | coerced0 = "" + data0; 97 | } 98 | else if(data0 === null){ 99 | coerced0 = ""; 100 | } 101 | else { 102 | const err3 = {instancePath:instancePath+"/url",schemaPath:"#/properties/url/oneOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"}; 103 | if(vErrors === null){ 104 | vErrors = [err3]; 105 | } 106 | else { 107 | vErrors.push(err3); 108 | } 109 | errors++; 110 | } 111 | } 112 | if(coerced0 !== undefined){ 113 | data0 = coerced0; 114 | if(data !== undefined){ 115 | data["url"] = coerced0; 116 | } 117 | } 118 | } 119 | var _valid1 = _errs7 === errors; 120 | if(_valid1){ 121 | valid2 = true; 122 | passing1 = 0; 123 | } 124 | const _errs9 = errors; 125 | if(errors === _errs9){ 126 | if(data0 && typeof data0 == "object" && !Array.isArray(data0)){ 127 | let missing2; 128 | if((data0.pathname === undefined) && (missing2 = "pathname")){ 129 | const err4 = {instancePath:instancePath+"/url",schemaPath:"#/properties/url/oneOf/1/required",keyword:"required",params:{missingProperty: missing2},message:"must have required property '"+missing2+"'"}; 130 | if(vErrors === null){ 131 | vErrors = [err4]; 132 | } 133 | else { 134 | vErrors.push(err4); 135 | } 136 | errors++; 137 | } 138 | else { 139 | if(data0.protocol !== undefined){ 140 | let data1 = data0.protocol; 141 | const _errs12 = errors; 142 | if(typeof data1 !== "string"){ 143 | let dataType1 = typeof data1; 144 | let coerced1 = undefined; 145 | if(!(coerced1 !== undefined)){ 146 | if(dataType1 == "number" || dataType1 == "boolean"){ 147 | coerced1 = "" + data1; 148 | } 149 | else if(data1 === null){ 150 | coerced1 = ""; 151 | } 152 | else { 153 | const err5 = {instancePath:instancePath+"/url/protocol",schemaPath:"#/properties/url/oneOf/1/properties/protocol/type",keyword:"type",params:{type: "string"},message:"must be string"}; 154 | if(vErrors === null){ 155 | vErrors = [err5]; 156 | } 157 | else { 158 | vErrors.push(err5); 159 | } 160 | errors++; 161 | } 162 | } 163 | if(coerced1 !== undefined){ 164 | data1 = coerced1; 165 | if(data0 !== undefined){ 166 | data0["protocol"] = coerced1; 167 | } 168 | } 169 | } 170 | var valid3 = _errs12 === errors; 171 | } 172 | else { 173 | var valid3 = true; 174 | } 175 | if(valid3){ 176 | if(data0.hostname !== undefined){ 177 | let data2 = data0.hostname; 178 | const _errs14 = errors; 179 | if(typeof data2 !== "string"){ 180 | let dataType2 = typeof data2; 181 | let coerced2 = undefined; 182 | if(!(coerced2 !== undefined)){ 183 | if(dataType2 == "number" || dataType2 == "boolean"){ 184 | coerced2 = "" + data2; 185 | } 186 | else if(data2 === null){ 187 | coerced2 = ""; 188 | } 189 | else { 190 | const err6 = {instancePath:instancePath+"/url/hostname",schemaPath:"#/properties/url/oneOf/1/properties/hostname/type",keyword:"type",params:{type: "string"},message:"must be string"}; 191 | if(vErrors === null){ 192 | vErrors = [err6]; 193 | } 194 | else { 195 | vErrors.push(err6); 196 | } 197 | errors++; 198 | } 199 | } 200 | if(coerced2 !== undefined){ 201 | data2 = coerced2; 202 | if(data0 !== undefined){ 203 | data0["hostname"] = coerced2; 204 | } 205 | } 206 | } 207 | var valid3 = _errs14 === errors; 208 | } 209 | else { 210 | var valid3 = true; 211 | } 212 | if(valid3){ 213 | if(data0.pathname !== undefined){ 214 | let data3 = data0.pathname; 215 | const _errs16 = errors; 216 | if(typeof data3 !== "string"){ 217 | let dataType3 = typeof data3; 218 | let coerced3 = undefined; 219 | if(!(coerced3 !== undefined)){ 220 | if(dataType3 == "number" || dataType3 == "boolean"){ 221 | coerced3 = "" + data3; 222 | } 223 | else if(data3 === null){ 224 | coerced3 = ""; 225 | } 226 | else { 227 | const err7 = {instancePath:instancePath+"/url/pathname",schemaPath:"#/properties/url/oneOf/1/properties/pathname/type",keyword:"type",params:{type: "string"},message:"must be string"}; 228 | if(vErrors === null){ 229 | vErrors = [err7]; 230 | } 231 | else { 232 | vErrors.push(err7); 233 | } 234 | errors++; 235 | } 236 | } 237 | if(coerced3 !== undefined){ 238 | data3 = coerced3; 239 | if(data0 !== undefined){ 240 | data0["pathname"] = coerced3; 241 | } 242 | } 243 | } 244 | var valid3 = _errs16 === errors; 245 | } 246 | else { 247 | var valid3 = true; 248 | } 249 | } 250 | } 251 | } 252 | } 253 | else { 254 | const err8 = {instancePath:instancePath+"/url",schemaPath:"#/properties/url/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"}; 255 | if(vErrors === null){ 256 | vErrors = [err8]; 257 | } 258 | else { 259 | vErrors.push(err8); 260 | } 261 | errors++; 262 | } 263 | } 264 | var _valid1 = _errs9 === errors; 265 | if(_valid1 && valid2){ 266 | valid2 = false; 267 | passing1 = [passing1, 1]; 268 | } 269 | else { 270 | if(_valid1){ 271 | valid2 = true; 272 | passing1 = 1; 273 | } 274 | } 275 | if(!valid2){ 276 | const err9 = {instancePath:instancePath+"/url",schemaPath:"#/properties/url/oneOf",keyword:"oneOf",params:{passingSchemas: passing1},message:"must match exactly one schema in oneOf"}; 277 | if(vErrors === null){ 278 | vErrors = [err9]; 279 | } 280 | else { 281 | vErrors.push(err9); 282 | } 283 | errors++; 284 | validate10.errors = vErrors; 285 | return false; 286 | } 287 | else { 288 | errors = _errs6; 289 | if(vErrors !== null){ 290 | if(_errs6){ 291 | vErrors.length = _errs6; 292 | } 293 | else { 294 | vErrors = null; 295 | } 296 | } 297 | } 298 | var valid1 = _errs5 === errors; 299 | } 300 | else { 301 | var valid1 = true; 302 | } 303 | if(valid1){ 304 | if(data.path !== undefined){ 305 | let data4 = data.path; 306 | const _errs18 = errors; 307 | const _errs19 = errors; 308 | let valid4 = false; 309 | let passing2 = null; 310 | const _errs20 = errors; 311 | if(typeof data4 !== "string"){ 312 | let dataType4 = typeof data4; 313 | let coerced4 = undefined; 314 | if(!(coerced4 !== undefined)){ 315 | if(dataType4 == "number" || dataType4 == "boolean"){ 316 | coerced4 = "" + data4; 317 | } 318 | else if(data4 === null){ 319 | coerced4 = ""; 320 | } 321 | else { 322 | const err10 = {instancePath:instancePath+"/path",schemaPath:"#/properties/path/oneOf/0/type",keyword:"type",params:{type: "string"},message:"must be string"}; 323 | if(vErrors === null){ 324 | vErrors = [err10]; 325 | } 326 | else { 327 | vErrors.push(err10); 328 | } 329 | errors++; 330 | } 331 | } 332 | if(coerced4 !== undefined){ 333 | data4 = coerced4; 334 | if(data !== undefined){ 335 | data["path"] = coerced4; 336 | } 337 | } 338 | } 339 | var _valid2 = _errs20 === errors; 340 | if(_valid2){ 341 | valid4 = true; 342 | passing2 = 0; 343 | } 344 | const _errs22 = errors; 345 | if(errors === _errs22){ 346 | if(data4 && typeof data4 == "object" && !Array.isArray(data4)){ 347 | let missing3; 348 | if((data4.pathname === undefined) && (missing3 = "pathname")){ 349 | const err11 = {instancePath:instancePath+"/path",schemaPath:"#/properties/path/oneOf/1/required",keyword:"required",params:{missingProperty: missing3},message:"must have required property '"+missing3+"'"}; 350 | if(vErrors === null){ 351 | vErrors = [err11]; 352 | } 353 | else { 354 | vErrors.push(err11); 355 | } 356 | errors++; 357 | } 358 | else { 359 | if(data4.protocol !== undefined){ 360 | let data5 = data4.protocol; 361 | const _errs25 = errors; 362 | if(typeof data5 !== "string"){ 363 | let dataType5 = typeof data5; 364 | let coerced5 = undefined; 365 | if(!(coerced5 !== undefined)){ 366 | if(dataType5 == "number" || dataType5 == "boolean"){ 367 | coerced5 = "" + data5; 368 | } 369 | else if(data5 === null){ 370 | coerced5 = ""; 371 | } 372 | else { 373 | const err12 = {instancePath:instancePath+"/path/protocol",schemaPath:"#/properties/path/oneOf/1/properties/protocol/type",keyword:"type",params:{type: "string"},message:"must be string"}; 374 | if(vErrors === null){ 375 | vErrors = [err12]; 376 | } 377 | else { 378 | vErrors.push(err12); 379 | } 380 | errors++; 381 | } 382 | } 383 | if(coerced5 !== undefined){ 384 | data5 = coerced5; 385 | if(data4 !== undefined){ 386 | data4["protocol"] = coerced5; 387 | } 388 | } 389 | } 390 | var valid5 = _errs25 === errors; 391 | } 392 | else { 393 | var valid5 = true; 394 | } 395 | if(valid5){ 396 | if(data4.hostname !== undefined){ 397 | let data6 = data4.hostname; 398 | const _errs27 = errors; 399 | if(typeof data6 !== "string"){ 400 | let dataType6 = typeof data6; 401 | let coerced6 = undefined; 402 | if(!(coerced6 !== undefined)){ 403 | if(dataType6 == "number" || dataType6 == "boolean"){ 404 | coerced6 = "" + data6; 405 | } 406 | else if(data6 === null){ 407 | coerced6 = ""; 408 | } 409 | else { 410 | const err13 = {instancePath:instancePath+"/path/hostname",schemaPath:"#/properties/path/oneOf/1/properties/hostname/type",keyword:"type",params:{type: "string"},message:"must be string"}; 411 | if(vErrors === null){ 412 | vErrors = [err13]; 413 | } 414 | else { 415 | vErrors.push(err13); 416 | } 417 | errors++; 418 | } 419 | } 420 | if(coerced6 !== undefined){ 421 | data6 = coerced6; 422 | if(data4 !== undefined){ 423 | data4["hostname"] = coerced6; 424 | } 425 | } 426 | } 427 | var valid5 = _errs27 === errors; 428 | } 429 | else { 430 | var valid5 = true; 431 | } 432 | if(valid5){ 433 | if(data4.pathname !== undefined){ 434 | let data7 = data4.pathname; 435 | const _errs29 = errors; 436 | if(typeof data7 !== "string"){ 437 | let dataType7 = typeof data7; 438 | let coerced7 = undefined; 439 | if(!(coerced7 !== undefined)){ 440 | if(dataType7 == "number" || dataType7 == "boolean"){ 441 | coerced7 = "" + data7; 442 | } 443 | else if(data7 === null){ 444 | coerced7 = ""; 445 | } 446 | else { 447 | const err14 = {instancePath:instancePath+"/path/pathname",schemaPath:"#/properties/path/oneOf/1/properties/pathname/type",keyword:"type",params:{type: "string"},message:"must be string"}; 448 | if(vErrors === null){ 449 | vErrors = [err14]; 450 | } 451 | else { 452 | vErrors.push(err14); 453 | } 454 | errors++; 455 | } 456 | } 457 | if(coerced7 !== undefined){ 458 | data7 = coerced7; 459 | if(data4 !== undefined){ 460 | data4["pathname"] = coerced7; 461 | } 462 | } 463 | } 464 | var valid5 = _errs29 === errors; 465 | } 466 | else { 467 | var valid5 = true; 468 | } 469 | } 470 | } 471 | } 472 | } 473 | else { 474 | const err15 = {instancePath:instancePath+"/path",schemaPath:"#/properties/path/oneOf/1/type",keyword:"type",params:{type: "object"},message:"must be object"}; 475 | if(vErrors === null){ 476 | vErrors = [err15]; 477 | } 478 | else { 479 | vErrors.push(err15); 480 | } 481 | errors++; 482 | } 483 | } 484 | var _valid2 = _errs22 === errors; 485 | if(_valid2 && valid4){ 486 | valid4 = false; 487 | passing2 = [passing2, 1]; 488 | } 489 | else { 490 | if(_valid2){ 491 | valid4 = true; 492 | passing2 = 1; 493 | } 494 | } 495 | if(!valid4){ 496 | const err16 = {instancePath:instancePath+"/path",schemaPath:"#/properties/path/oneOf",keyword:"oneOf",params:{passingSchemas: passing2},message:"must match exactly one schema in oneOf"}; 497 | if(vErrors === null){ 498 | vErrors = [err16]; 499 | } 500 | else { 501 | vErrors.push(err16); 502 | } 503 | errors++; 504 | validate10.errors = vErrors; 505 | return false; 506 | } 507 | else { 508 | errors = _errs19; 509 | if(vErrors !== null){ 510 | if(_errs19){ 511 | vErrors.length = _errs19; 512 | } 513 | else { 514 | vErrors = null; 515 | } 516 | } 517 | } 518 | var valid1 = _errs18 === errors; 519 | } 520 | else { 521 | var valid1 = true; 522 | } 523 | if(valid1){ 524 | if(data.cookies !== undefined){ 525 | let data8 = data.cookies; 526 | const _errs31 = errors; 527 | if(errors === _errs31){ 528 | if(!(data8 && typeof data8 == "object" && !Array.isArray(data8))){ 529 | validate10.errors = [{instancePath:instancePath+"/cookies",schemaPath:"#/properties/cookies/type",keyword:"type",params:{type: "object"},message:"must be object"}]; 530 | return false; 531 | } 532 | } 533 | var valid1 = _errs31 === errors; 534 | } 535 | else { 536 | var valid1 = true; 537 | } 538 | if(valid1){ 539 | if(data.headers !== undefined){ 540 | let data9 = data.headers; 541 | const _errs34 = errors; 542 | if(errors === _errs34){ 543 | if(!(data9 && typeof data9 == "object" && !Array.isArray(data9))){ 544 | validate10.errors = [{instancePath:instancePath+"/headers",schemaPath:"#/properties/headers/type",keyword:"type",params:{type: "object"},message:"must be object"}]; 545 | return false; 546 | } 547 | } 548 | var valid1 = _errs34 === errors; 549 | } 550 | else { 551 | var valid1 = true; 552 | } 553 | if(valid1){ 554 | if(data.query !== undefined){ 555 | let data10 = data.query; 556 | const _errs37 = errors; 557 | const _errs38 = errors; 558 | let valid6 = false; 559 | const _errs39 = errors; 560 | if(errors === _errs39){ 561 | if(!(data10 && typeof data10 == "object" && !Array.isArray(data10))){ 562 | const err17 = {instancePath:instancePath+"/query",schemaPath:"#/properties/query/anyOf/0/type",keyword:"type",params:{type: "object"},message:"must be object"}; 563 | if(vErrors === null){ 564 | vErrors = [err17]; 565 | } 566 | else { 567 | vErrors.push(err17); 568 | } 569 | errors++; 570 | } 571 | } 572 | var _valid3 = _errs39 === errors; 573 | valid6 = valid6 || _valid3; 574 | if(!valid6){ 575 | const _errs42 = errors; 576 | if(typeof data10 !== "string"){ 577 | let dataType8 = typeof data10; 578 | let coerced8 = undefined; 579 | if(!(coerced8 !== undefined)){ 580 | if(dataType8 == "number" || dataType8 == "boolean"){ 581 | coerced8 = "" + data10; 582 | } 583 | else if(data10 === null){ 584 | coerced8 = ""; 585 | } 586 | else { 587 | const err18 = {instancePath:instancePath+"/query",schemaPath:"#/properties/query/anyOf/1/type",keyword:"type",params:{type: "string"},message:"must be string"}; 588 | if(vErrors === null){ 589 | vErrors = [err18]; 590 | } 591 | else { 592 | vErrors.push(err18); 593 | } 594 | errors++; 595 | } 596 | } 597 | if(coerced8 !== undefined){ 598 | data10 = coerced8; 599 | if(data !== undefined){ 600 | data["query"] = coerced8; 601 | } 602 | } 603 | } 604 | var _valid3 = _errs42 === errors; 605 | valid6 = valid6 || _valid3; 606 | } 607 | if(!valid6){ 608 | const err19 = {instancePath:instancePath+"/query",schemaPath:"#/properties/query/anyOf",keyword:"anyOf",params:{},message:"must match a schema in anyOf"}; 609 | if(vErrors === null){ 610 | vErrors = [err19]; 611 | } 612 | else { 613 | vErrors.push(err19); 614 | } 615 | errors++; 616 | validate10.errors = vErrors; 617 | return false; 618 | } 619 | else { 620 | errors = _errs38; 621 | if(vErrors !== null){ 622 | if(_errs38){ 623 | vErrors.length = _errs38; 624 | } 625 | else { 626 | vErrors = null; 627 | } 628 | } 629 | } 630 | var valid1 = _errs37 === errors; 631 | } 632 | else { 633 | var valid1 = true; 634 | } 635 | if(valid1){ 636 | if(data.simulate !== undefined){ 637 | let data11 = data.simulate; 638 | const _errs44 = errors; 639 | if(errors === _errs44){ 640 | if(data11 && typeof data11 == "object" && !Array.isArray(data11)){ 641 | if(data11.end !== undefined){ 642 | let data12 = data11.end; 643 | const _errs46 = errors; 644 | if(typeof data12 !== "boolean"){ 645 | let coerced9 = undefined; 646 | if(!(coerced9 !== undefined)){ 647 | if(data12 === "false" || data12 === 0 || data12 === null){ 648 | coerced9 = false; 649 | } 650 | else if(data12 === "true" || data12 === 1){ 651 | coerced9 = true; 652 | } 653 | else { 654 | validate10.errors = [{instancePath:instancePath+"/simulate/end",schemaPath:"#/properties/simulate/properties/end/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; 655 | return false; 656 | } 657 | } 658 | if(coerced9 !== undefined){ 659 | data12 = coerced9; 660 | if(data11 !== undefined){ 661 | data11["end"] = coerced9; 662 | } 663 | } 664 | } 665 | var valid7 = _errs46 === errors; 666 | } 667 | else { 668 | var valid7 = true; 669 | } 670 | if(valid7){ 671 | if(data11.split !== undefined){ 672 | let data13 = data11.split; 673 | const _errs48 = errors; 674 | if(typeof data13 !== "boolean"){ 675 | let coerced10 = undefined; 676 | if(!(coerced10 !== undefined)){ 677 | if(data13 === "false" || data13 === 0 || data13 === null){ 678 | coerced10 = false; 679 | } 680 | else if(data13 === "true" || data13 === 1){ 681 | coerced10 = true; 682 | } 683 | else { 684 | validate10.errors = [{instancePath:instancePath+"/simulate/split",schemaPath:"#/properties/simulate/properties/split/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; 685 | return false; 686 | } 687 | } 688 | if(coerced10 !== undefined){ 689 | data13 = coerced10; 690 | if(data11 !== undefined){ 691 | data11["split"] = coerced10; 692 | } 693 | } 694 | } 695 | var valid7 = _errs48 === errors; 696 | } 697 | else { 698 | var valid7 = true; 699 | } 700 | if(valid7){ 701 | if(data11.error !== undefined){ 702 | let data14 = data11.error; 703 | const _errs50 = errors; 704 | if(typeof data14 !== "boolean"){ 705 | let coerced11 = undefined; 706 | if(!(coerced11 !== undefined)){ 707 | if(data14 === "false" || data14 === 0 || data14 === null){ 708 | coerced11 = false; 709 | } 710 | else if(data14 === "true" || data14 === 1){ 711 | coerced11 = true; 712 | } 713 | else { 714 | validate10.errors = [{instancePath:instancePath+"/simulate/error",schemaPath:"#/properties/simulate/properties/error/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; 715 | return false; 716 | } 717 | } 718 | if(coerced11 !== undefined){ 719 | data14 = coerced11; 720 | if(data11 !== undefined){ 721 | data11["error"] = coerced11; 722 | } 723 | } 724 | } 725 | var valid7 = _errs50 === errors; 726 | } 727 | else { 728 | var valid7 = true; 729 | } 730 | if(valid7){ 731 | if(data11.close !== undefined){ 732 | let data15 = data11.close; 733 | const _errs52 = errors; 734 | if(typeof data15 !== "boolean"){ 735 | let coerced12 = undefined; 736 | if(!(coerced12 !== undefined)){ 737 | if(data15 === "false" || data15 === 0 || data15 === null){ 738 | coerced12 = false; 739 | } 740 | else if(data15 === "true" || data15 === 1){ 741 | coerced12 = true; 742 | } 743 | else { 744 | validate10.errors = [{instancePath:instancePath+"/simulate/close",schemaPath:"#/properties/simulate/properties/close/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; 745 | return false; 746 | } 747 | } 748 | if(coerced12 !== undefined){ 749 | data15 = coerced12; 750 | if(data11 !== undefined){ 751 | data11["close"] = coerced12; 752 | } 753 | } 754 | } 755 | var valid7 = _errs52 === errors; 756 | } 757 | else { 758 | var valid7 = true; 759 | } 760 | } 761 | } 762 | } 763 | } 764 | else { 765 | validate10.errors = [{instancePath:instancePath+"/simulate",schemaPath:"#/properties/simulate/type",keyword:"type",params:{type: "object"},message:"must be object"}]; 766 | return false; 767 | } 768 | } 769 | var valid1 = _errs44 === errors; 770 | } 771 | else { 772 | var valid1 = true; 773 | } 774 | if(valid1){ 775 | if(data.authority !== undefined){ 776 | let data16 = data.authority; 777 | const _errs54 = errors; 778 | if(typeof data16 !== "string"){ 779 | let dataType13 = typeof data16; 780 | let coerced13 = undefined; 781 | if(!(coerced13 !== undefined)){ 782 | if(dataType13 == "number" || dataType13 == "boolean"){ 783 | coerced13 = "" + data16; 784 | } 785 | else if(data16 === null){ 786 | coerced13 = ""; 787 | } 788 | else { 789 | validate10.errors = [{instancePath:instancePath+"/authority",schemaPath:"#/properties/authority/type",keyword:"type",params:{type: "string"},message:"must be string"}]; 790 | return false; 791 | } 792 | } 793 | if(coerced13 !== undefined){ 794 | data16 = coerced13; 795 | if(data !== undefined){ 796 | data["authority"] = coerced13; 797 | } 798 | } 799 | } 800 | var valid1 = _errs54 === errors; 801 | } 802 | else { 803 | var valid1 = true; 804 | } 805 | if(valid1){ 806 | if(data.remoteAddress !== undefined){ 807 | let data17 = data.remoteAddress; 808 | const _errs56 = errors; 809 | if(typeof data17 !== "string"){ 810 | let dataType14 = typeof data17; 811 | let coerced14 = undefined; 812 | if(!(coerced14 !== undefined)){ 813 | if(dataType14 == "number" || dataType14 == "boolean"){ 814 | coerced14 = "" + data17; 815 | } 816 | else if(data17 === null){ 817 | coerced14 = ""; 818 | } 819 | else { 820 | validate10.errors = [{instancePath:instancePath+"/remoteAddress",schemaPath:"#/properties/remoteAddress/type",keyword:"type",params:{type: "string"},message:"must be string"}]; 821 | return false; 822 | } 823 | } 824 | if(coerced14 !== undefined){ 825 | data17 = coerced14; 826 | if(data !== undefined){ 827 | data["remoteAddress"] = coerced14; 828 | } 829 | } 830 | } 831 | var valid1 = _errs56 === errors; 832 | } 833 | else { 834 | var valid1 = true; 835 | } 836 | if(valid1){ 837 | if(data.method !== undefined){ 838 | let data18 = data.method; 839 | const _errs58 = errors; 840 | if(typeof data18 !== "string"){ 841 | let dataType15 = typeof data18; 842 | let coerced15 = undefined; 843 | if(!(coerced15 !== undefined)){ 844 | if(dataType15 == "number" || dataType15 == "boolean"){ 845 | coerced15 = "" + data18; 846 | } 847 | else if(data18 === null){ 848 | coerced15 = ""; 849 | } 850 | else { 851 | validate10.errors = [{instancePath:instancePath+"/method",schemaPath:"#/properties/method/type",keyword:"type",params:{type: "string"},message:"must be string"}]; 852 | return false; 853 | } 854 | } 855 | if(coerced15 !== undefined){ 856 | data18 = coerced15; 857 | if(data !== undefined){ 858 | data["method"] = coerced15; 859 | } 860 | } 861 | } 862 | if(!((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((((data18 === "ACL") || (data18 === "BIND")) || (data18 === "CHECKOUT")) || (data18 === "CONNECT")) || (data18 === "COPY")) || (data18 === "DELETE")) || (data18 === "GET")) || (data18 === "HEAD")) || (data18 === "LINK")) || (data18 === "LOCK")) || (data18 === "M-SEARCH")) || (data18 === "MERGE")) || (data18 === "MKACTIVITY")) || (data18 === "MKCALENDAR")) || (data18 === "MKCOL")) || (data18 === "MOVE")) || (data18 === "NOTIFY")) || (data18 === "OPTIONS")) || (data18 === "PATCH")) || (data18 === "POST")) || (data18 === "PROPFIND")) || (data18 === "PROPPATCH")) || (data18 === "PURGE")) || (data18 === "PUT")) || (data18 === "QUERY")) || (data18 === "REBIND")) || (data18 === "REPORT")) || (data18 === "SEARCH")) || (data18 === "SOURCE")) || (data18 === "SUBSCRIBE")) || (data18 === "TRACE")) || (data18 === "UNBIND")) || (data18 === "UNLINK")) || (data18 === "UNLOCK")) || (data18 === "UNSUBSCRIBE")) || (data18 === "acl")) || (data18 === "bind")) || (data18 === "checkout")) || (data18 === "connect")) || (data18 === "copy")) || (data18 === "delete")) || (data18 === "get")) || (data18 === "head")) || (data18 === "link")) || (data18 === "lock")) || (data18 === "m-search")) || (data18 === "merge")) || (data18 === "mkactivity")) || (data18 === "mkcalendar")) || (data18 === "mkcol")) || (data18 === "move")) || (data18 === "notify")) || (data18 === "options")) || (data18 === "patch")) || (data18 === "post")) || (data18 === "propfind")) || (data18 === "proppatch")) || (data18 === "purge")) || (data18 === "put")) || (data18 === "query")) || (data18 === "rebind")) || (data18 === "report")) || (data18 === "search")) || (data18 === "source")) || (data18 === "subscribe")) || (data18 === "trace")) || (data18 === "unbind")) || (data18 === "unlink")) || (data18 === "unlock")) || (data18 === "unsubscribe"))){ 863 | validate10.errors = [{instancePath:instancePath+"/method",schemaPath:"#/properties/method/enum",keyword:"enum",params:{allowedValues: schema11.properties.method.enum},message:"must be equal to one of the allowed values"}]; 864 | return false; 865 | } 866 | var valid1 = _errs58 === errors; 867 | } 868 | else { 869 | var valid1 = true; 870 | } 871 | if(valid1){ 872 | if(data.validate !== undefined){ 873 | let data19 = data.validate; 874 | const _errs60 = errors; 875 | if(typeof data19 !== "boolean"){ 876 | let coerced16 = undefined; 877 | if(!(coerced16 !== undefined)){ 878 | if(data19 === "false" || data19 === 0 || data19 === null){ 879 | coerced16 = false; 880 | } 881 | else if(data19 === "true" || data19 === 1){ 882 | coerced16 = true; 883 | } 884 | else { 885 | validate10.errors = [{instancePath:instancePath+"/validate",schemaPath:"#/properties/validate/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}]; 886 | return false; 887 | } 888 | } 889 | if(coerced16 !== undefined){ 890 | data19 = coerced16; 891 | if(data !== undefined){ 892 | data["validate"] = coerced16; 893 | } 894 | } 895 | } 896 | var valid1 = _errs60 === errors; 897 | } 898 | else { 899 | var valid1 = true; 900 | } 901 | } 902 | } 903 | } 904 | } 905 | } 906 | } 907 | } 908 | } 909 | } 910 | } 911 | else { 912 | validate10.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"}]; 913 | return false; 914 | } 915 | } 916 | validate10.errors = vErrors; 917 | return errors === 0; 918 | } 919 | 920 | -------------------------------------------------------------------------------- /lib/form-data.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { randomUUID } = require('node:crypto') 4 | const { Readable } = require('node:stream') 5 | 6 | let textEncoder 7 | 8 | function isFormDataLike (payload) { 9 | return ( 10 | payload && 11 | typeof payload === 'object' && 12 | typeof payload.append === 'function' && 13 | typeof payload.delete === 'function' && 14 | typeof payload.get === 'function' && 15 | typeof payload.getAll === 'function' && 16 | typeof payload.has === 'function' && 17 | typeof payload.set === 'function' && 18 | payload[Symbol.toStringTag] === 'FormData' 19 | ) 20 | } 21 | 22 | /* 23 | partial code extraction and refactoring of `undici`. 24 | MIT License. https://github.com/nodejs/undici/blob/043d8f1a89f606b1db259fc71f4c9bc8eb2aa1e6/lib/web/fetch/LICENSE 25 | Reference https://github.com/nodejs/undici/blob/043d8f1a89f606b1db259fc71f4c9bc8eb2aa1e6/lib/web/fetch/body.js#L102-L168 26 | */ 27 | function formDataToStream (formdata) { 28 | // lazy creation of TextEncoder 29 | textEncoder = textEncoder ?? new TextEncoder() 30 | 31 | // we expect the function argument must be FormData 32 | const boundary = `----formdata-${randomUUID()}` 33 | const prefix = `--${boundary}\r\nContent-Disposition: form-data` 34 | 35 | /*! formdata-polyfill. MIT License. Jimmy Wärting */ 36 | const escape = (str) => 37 | str.replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/"/g, '%22') 38 | const normalizeLinefeeds = (value) => value.replace(/\r?\n|\r/g, '\r\n') 39 | 40 | const linebreak = new Uint8Array([13, 10]) // '\r\n' 41 | 42 | async function * asyncIterator () { 43 | for (const [name, value] of formdata) { 44 | if (typeof value === 'string') { 45 | // header 46 | yield textEncoder.encode(`${prefix}; name="${escape(normalizeLinefeeds(name))}"\r\n\r\n`) 47 | // body 48 | yield textEncoder.encode(`${normalizeLinefeeds(value)}\r\n`) 49 | } else { 50 | let header = `${prefix}; name="${escape(normalizeLinefeeds(name))}"` 51 | value.name && (header += `; filename="${escape(value.name)}"`) 52 | header += `\r\nContent-Type: ${value.type || 'application/octet-stream'}\r\n\r\n` 53 | // header 54 | yield textEncoder.encode(header) 55 | // body 56 | if (value.stream) { 57 | yield * value.stream() 58 | } /* c8 ignore start */ else { 59 | // shouldn't be here since Blob / File should provide .stream 60 | // and FormData always convert to USVString 61 | yield value 62 | } /* c8 ignore stop */ 63 | yield linebreak 64 | } 65 | } 66 | // end 67 | yield textEncoder.encode(`--${boundary}--`) 68 | } 69 | 70 | const stream = Readable.from(asyncIterator()) 71 | 72 | return { 73 | stream, 74 | contentType: `multipart/form-data; boundary=${boundary}` 75 | } 76 | } 77 | 78 | module.exports.isFormDataLike = isFormDataLike 79 | module.exports.formDataToStream = formDataToStream 80 | -------------------------------------------------------------------------------- /lib/parse-url.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { URL } = require('node:url') 4 | 5 | const BASE_URL = 'http://localhost' 6 | 7 | /** 8 | * Parse URL 9 | * 10 | * @param {(Object|String)} url 11 | * @param {Object} [query] 12 | * @return {URL} 13 | */ 14 | module.exports = function parseURL (url, query) { 15 | if ((typeof url === 'string' || Object.prototype.toString.call(url) === '[object String]') && url.startsWith('//')) { 16 | url = BASE_URL + url 17 | } 18 | const result = typeof url === 'object' 19 | ? Object.assign(new URL(BASE_URL), url) 20 | : new URL(url, BASE_URL) 21 | 22 | if (typeof query === 'string') { 23 | query = new URLSearchParams(query) 24 | for (const key of query.keys()) { 25 | result.searchParams.delete(key) 26 | for (const value of query.getAll(key)) { 27 | result.searchParams.append(key, value) 28 | } 29 | } 30 | } else { 31 | const merged = Object.assign({}, url.query, query) 32 | for (const key in merged) { 33 | const value = merged[key] 34 | 35 | if (Array.isArray(value)) { 36 | result.searchParams.delete(key) 37 | for (const param of value) { 38 | result.searchParams.append(key, param) 39 | } 40 | } else { 41 | result.searchParams.set(key, value) 42 | } 43 | } 44 | } 45 | 46 | return result 47 | } 48 | -------------------------------------------------------------------------------- /lib/request.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* eslint no-prototype-builtins: 0 */ 4 | 5 | const { Readable, addAbortSignal } = require('node:stream') 6 | const util = require('node:util') 7 | const cookie = require('cookie') 8 | const assert = require('node:assert') 9 | const { createDeprecation } = require('process-warning') 10 | 11 | const parseURL = require('./parse-url') 12 | const { isFormDataLike, formDataToStream } = require('./form-data') 13 | const { EventEmitter } = require('node:events') 14 | 15 | // request.connectin deprecation https://nodejs.org/api/http.html#http_request_connection 16 | const FST_LIGHTMYREQUEST_DEP01 = createDeprecation({ 17 | name: 'FastifyDeprecationLightMyRequest', 18 | code: 'FST_LIGHTMYREQUEST_DEP01', 19 | message: 'You are accessing "request.connection", use "request.socket" instead.' 20 | }) 21 | 22 | /** 23 | * Get hostname:port 24 | * 25 | * @param {URL} parsedURL 26 | * @return {String} 27 | */ 28 | function hostHeaderFromURL (parsedURL) { 29 | return parsedURL.port 30 | ? parsedURL.host 31 | : parsedURL.hostname + (parsedURL.protocol === 'https:' ? ':443' : ':80') 32 | } 33 | 34 | /** 35 | * Mock socket object used to fake access to a socket for a request 36 | * 37 | * @constructor 38 | * @param {String} remoteAddress the fake address to show consumers of the socket 39 | */ 40 | class MockSocket extends EventEmitter { 41 | constructor (remoteAddress) { 42 | super() 43 | this.remoteAddress = remoteAddress 44 | } 45 | } 46 | 47 | /** 48 | * CustomRequest 49 | * 50 | * @constructor 51 | * @param {Object} options 52 | * @param {(Object|String)} options.url || options.path 53 | * @param {String} [options.method='GET'] 54 | * @param {String} [options.remoteAddress] 55 | * @param {Object} [options.cookies] 56 | * @param {Object} [options.headers] 57 | * @param {Object} [options.query] 58 | * @param {Object} [options.Request] 59 | * @param {any} [options.payload] 60 | */ 61 | function CustomRequest (options) { 62 | return new _CustomLMRRequest(this) 63 | 64 | function _CustomLMRRequest (obj) { 65 | Request.call(obj, { 66 | ...options, 67 | Request: undefined 68 | }) 69 | Object.assign(this, obj) 70 | 71 | for (const fn of Object.keys(Request.prototype)) { 72 | this.constructor.prototype[fn] = Request.prototype[fn] 73 | } 74 | 75 | util.inherits(this.constructor, options.Request) 76 | return this 77 | } 78 | } 79 | 80 | /** 81 | * Request 82 | * 83 | * @constructor 84 | * @param {Object} options 85 | * @param {(Object|String)} options.url || options.path 86 | * @param {String} [options.method='GET'] 87 | * @param {String} [options.remoteAddress] 88 | * @param {Object} [options.cookies] 89 | * @param {Object} [options.headers] 90 | * @param {Object} [options.query] 91 | * @param {any} [options.payload] 92 | */ 93 | function Request (options) { 94 | Readable.call(this, { 95 | autoDestroy: false 96 | }) 97 | 98 | const parsedURL = parseURL(options.url || options.path, options.query) 99 | 100 | this.url = parsedURL.pathname + parsedURL.search 101 | 102 | this.aborted = false 103 | this.httpVersionMajor = 1 104 | this.httpVersionMinor = 1 105 | this.httpVersion = '1.1' 106 | this.method = options.method ? options.method.toUpperCase() : 'GET' 107 | 108 | this.headers = {} 109 | this.rawHeaders = [] 110 | 111 | const headers = options.headers || {} 112 | 113 | for (const field in headers) { 114 | const fieldLowerCase = field.toLowerCase() 115 | if ( 116 | ( 117 | fieldLowerCase === 'user-agent' || 118 | fieldLowerCase === 'content-type' 119 | ) && headers[field] === undefined 120 | ) { 121 | this.headers[fieldLowerCase] = undefined 122 | continue 123 | } 124 | const value = headers[field] 125 | assert(value !== undefined, 'invalid value "undefined" for header ' + field) 126 | this.headers[fieldLowerCase] = '' + value 127 | } 128 | 129 | if (('user-agent' in this.headers) === false) { 130 | this.headers['user-agent'] = 'lightMyRequest' 131 | } 132 | this.headers.host = this.headers.host || options.authority || hostHeaderFromURL(parsedURL) 133 | 134 | if (options.cookies) { 135 | const { cookies } = options 136 | const cookieValues = Object.keys(cookies).map(key => cookie.serialize(key, cookies[key])) 137 | if (this.headers.cookie) { 138 | cookieValues.unshift(this.headers.cookie) 139 | } 140 | this.headers.cookie = cookieValues.join('; ') 141 | } 142 | 143 | this.socket = new MockSocket(options.remoteAddress || '127.0.0.1') 144 | 145 | Object.defineProperty(this, 'connection', { 146 | get () { 147 | FST_LIGHTMYREQUEST_DEP01() 148 | return this.socket 149 | }, 150 | configurable: true 151 | }) 152 | 153 | // we keep both payload and body for compatibility reasons 154 | let payload = options.payload || options.body || null 155 | let payloadResume = payload && typeof payload.resume === 'function' 156 | 157 | if (isFormDataLike(payload)) { 158 | const stream = formDataToStream(payload) 159 | payload = stream.stream 160 | payloadResume = true 161 | // we override the content-type 162 | this.headers['content-type'] = stream.contentType 163 | this.headers['transfer-encoding'] = 'chunked' 164 | } 165 | 166 | if (payload && typeof payload !== 'string' && !payloadResume && !Buffer.isBuffer(payload)) { 167 | payload = JSON.stringify(payload) 168 | 169 | if (('content-type' in this.headers) === false) { 170 | this.headers['content-type'] = 'application/json' 171 | } 172 | } 173 | 174 | // Set the content-length for the corresponding payload if none set 175 | if (payload && !payloadResume && !Object.hasOwn(this.headers, 'content-length')) { 176 | this.headers['content-length'] = (Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(payload)).toString() 177 | } 178 | 179 | for (const header of Object.keys(this.headers)) { 180 | this.rawHeaders.push(header, this.headers[header]) 181 | } 182 | 183 | // Use _lightMyRequest namespace to avoid collision with Node 184 | this._lightMyRequest = { 185 | payload, 186 | isDone: false, 187 | simulate: options.simulate || {}, 188 | payloadAsStream: options.payloadAsStream, 189 | signal: options.signal 190 | } 191 | 192 | const signal = options.signal 193 | /* c8 ignore next 3 */ 194 | if (signal) { 195 | addAbortSignal(signal, this) 196 | } 197 | 198 | { 199 | const payload = this._lightMyRequest.payload 200 | if (payload?._readableState) { // does quack like a modern stream 201 | this._read = readStream 202 | 203 | payload.on('error', (err) => { 204 | this.destroy(err) 205 | }) 206 | 207 | payload.on('end', () => { 208 | this.push(null) 209 | }) 210 | } else { 211 | // Stream v1 are handled in index.js synchronously 212 | this._read = readEverythingElse 213 | } 214 | } 215 | 216 | return this 217 | } 218 | 219 | function readStream () { 220 | const payload = this._lightMyRequest.payload 221 | 222 | let more = true 223 | let pushed = false 224 | let chunk 225 | while (more && (chunk = payload.read())) { 226 | pushed = true 227 | more = this.push(chunk) 228 | } 229 | 230 | // We set up a recursive 'readable' event only if we didn't read anything. 231 | // Otheriwse, the stream machinery will call _read() for us. 232 | if (more && !pushed) { 233 | this._lightMyRequest.payload.once('readable', this._read.bind(this)) 234 | } 235 | } 236 | 237 | function readEverythingElse () { 238 | setImmediate(() => { 239 | if (this._lightMyRequest.isDone) { 240 | // 'end' defaults to true 241 | if (this._lightMyRequest.simulate.end !== false) { 242 | this.push(null) 243 | } 244 | return 245 | } 246 | 247 | this._lightMyRequest.isDone = true 248 | 249 | if (this._lightMyRequest.payload) { 250 | if (this._lightMyRequest.simulate.split) { 251 | this.push(this._lightMyRequest.payload.slice(0, 1)) 252 | this.push(this._lightMyRequest.payload.slice(1)) 253 | } else { 254 | this.push(this._lightMyRequest.payload) 255 | } 256 | } 257 | 258 | if (this._lightMyRequest.simulate.error) { 259 | this.emit('error', new Error('Simulated')) 260 | } 261 | 262 | if (this._lightMyRequest.simulate.close) { 263 | this.emit('close') 264 | } 265 | 266 | // 'end' defaults to true 267 | if (this._lightMyRequest.simulate.end !== false) { 268 | this.push(null) 269 | } 270 | }) 271 | } 272 | 273 | util.inherits(Request, Readable) 274 | util.inherits(CustomRequest, Request) 275 | 276 | Request.prototype.destroy = function (error) { 277 | if (this.destroyed || this._lightMyRequest.isDone) return 278 | this.destroyed = true 279 | 280 | if (error) { 281 | this._error = true 282 | process.nextTick(() => this.emit('error', error)) 283 | } 284 | 285 | process.nextTick(() => this.emit('close')) 286 | } 287 | 288 | module.exports = Request 289 | module.exports.Request = Request 290 | module.exports.CustomRequest = CustomRequest 291 | -------------------------------------------------------------------------------- /lib/response.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const http = require('node:http') 4 | const { Writable, Readable, addAbortSignal } = require('node:stream') 5 | const util = require('node:util') 6 | 7 | const setCookie = require('set-cookie-parser') 8 | 9 | function Response (req, onEnd, reject) { 10 | http.ServerResponse.call(this, req) 11 | 12 | if (req._lightMyRequest?.payloadAsStream) { 13 | const read = this.emit.bind(this, 'drain') 14 | this._lightMyRequest = { headers: null, trailers: {}, stream: new Readable({ read }) } 15 | const signal = req._lightMyRequest.signal 16 | 17 | if (signal) { 18 | addAbortSignal(signal, this._lightMyRequest.stream) 19 | } 20 | } else { 21 | this._lightMyRequest = { headers: null, trailers: {}, payloadChunks: [] } 22 | } 23 | // This forces node@8 to always render the headers 24 | this.setHeader('foo', 'bar'); this.removeHeader('foo') 25 | 26 | this.assignSocket(getNullSocket()) 27 | 28 | this._promiseCallback = typeof reject === 'function' 29 | 30 | let called = false 31 | const onEndSuccess = (payload) => { 32 | if (called) return 33 | called = true 34 | if (this._promiseCallback) { 35 | return process.nextTick(() => onEnd(payload)) 36 | } 37 | process.nextTick(() => onEnd(null, payload)) 38 | } 39 | this._lightMyRequest.onEndSuccess = onEndSuccess 40 | 41 | let finished = false 42 | const onEndFailure = (err) => { 43 | if (called) { 44 | if (this._lightMyRequest.stream && !finished) { 45 | if (!err) { 46 | err = new Error('response destroyed before completion') 47 | err.code = 'LIGHT_ECONNRESET' 48 | } 49 | this._lightMyRequest.stream.destroy(err) 50 | this._lightMyRequest.stream.on('error', () => {}) 51 | } 52 | return 53 | } 54 | called = true 55 | if (!err) { 56 | err = new Error('response destroyed before completion') 57 | err.code = 'LIGHT_ECONNRESET' 58 | } 59 | if (this._promiseCallback) { 60 | return process.nextTick(() => reject(err)) 61 | } 62 | process.nextTick(() => onEnd(err, null)) 63 | } 64 | 65 | if (this._lightMyRequest.stream) { 66 | this.once('finish', () => { 67 | finished = true 68 | this._lightMyRequest.stream.push(null) 69 | }) 70 | } else { 71 | this.once('finish', () => { 72 | const res = generatePayload(this) 73 | res.raw.req = req 74 | onEndSuccess(res) 75 | }) 76 | } 77 | 78 | this.connection.once('error', onEndFailure) 79 | 80 | this.once('error', onEndFailure) 81 | 82 | this.once('close', onEndFailure) 83 | } 84 | 85 | util.inherits(Response, http.ServerResponse) 86 | 87 | Response.prototype.setTimeout = function (msecs, callback) { 88 | this.timeoutHandle = setTimeout(() => { 89 | this.emit('timeout') 90 | }, msecs) 91 | this.on('timeout', callback) 92 | return this 93 | } 94 | 95 | Response.prototype.writeHead = function () { 96 | const result = http.ServerResponse.prototype.writeHead.apply(this, arguments) 97 | 98 | copyHeaders(this) 99 | 100 | if (this._lightMyRequest.stream) { 101 | this._lightMyRequest.onEndSuccess(generatePayload(this)) 102 | } 103 | 104 | return result 105 | } 106 | 107 | Response.prototype.write = function (data, encoding, callback) { 108 | if (this.timeoutHandle) { 109 | clearTimeout(this.timeoutHandle) 110 | } 111 | http.ServerResponse.prototype.write.call(this, data, encoding, callback) 112 | if (this._lightMyRequest.stream) { 113 | return this._lightMyRequest.stream.push(Buffer.from(data, encoding)) 114 | } else { 115 | this._lightMyRequest.payloadChunks.push(Buffer.from(data, encoding)) 116 | return true 117 | } 118 | } 119 | 120 | Response.prototype.end = function (data, encoding, callback) { 121 | if (data) { 122 | this.write(data, encoding) 123 | } 124 | 125 | http.ServerResponse.prototype.end.call(this, callback) 126 | 127 | this.emit('finish') 128 | 129 | // We need to emit 'close' otherwise stream.finished() would 130 | // not pick it up on Node v16 131 | 132 | this.destroy() 133 | } 134 | 135 | Response.prototype.destroy = function (error) { 136 | if (this.destroyed) return 137 | this.destroyed = true 138 | 139 | if (error) { 140 | process.nextTick(() => this.emit('error', error)) 141 | } 142 | 143 | process.nextTick(() => this.emit('close')) 144 | } 145 | 146 | Response.prototype.addTrailers = function (trailers) { 147 | for (const key in trailers) { 148 | this._lightMyRequest.trailers[key.toLowerCase().trim()] = trailers[key].toString().trim() 149 | } 150 | } 151 | 152 | function generatePayload (response) { 153 | // This seems only to happen when using `fastify-express` - see https://github.com/fastify/fastify-express/issues/47 154 | /* c8 ignore next 3 */ 155 | if (response._lightMyRequest.headers === null) { 156 | copyHeaders(response) 157 | } 158 | serializeHeaders(response) 159 | // Prepare response object 160 | const res = { 161 | raw: { 162 | res: response 163 | }, 164 | headers: response._lightMyRequest.headers, 165 | statusCode: response.statusCode, 166 | statusMessage: response.statusMessage, 167 | trailers: {}, 168 | get cookies () { 169 | return setCookie.parse(this) 170 | } 171 | } 172 | 173 | res.trailers = response._lightMyRequest.trailers 174 | 175 | if (response._lightMyRequest.payloadChunks) { 176 | // Prepare payload and trailers 177 | const rawBuffer = Buffer.concat(response._lightMyRequest.payloadChunks) 178 | res.rawPayload = rawBuffer 179 | 180 | // we keep both of them for compatibility reasons 181 | res.payload = rawBuffer.toString() 182 | res.body = res.payload 183 | 184 | // Prepare payload parsers 185 | res.json = function parseJsonPayload () { 186 | return JSON.parse(res.payload) 187 | } 188 | } else { 189 | res.json = function () { 190 | throw new Error('Response payload is not available with payloadAsStream: true') 191 | } 192 | } 193 | 194 | // Provide stream Readable for advanced user 195 | res.stream = function streamPayload () { 196 | if (response._lightMyRequest.stream) { 197 | return response._lightMyRequest.stream 198 | } 199 | return Readable.from(response._lightMyRequest.payloadChunks) 200 | } 201 | 202 | return res 203 | } 204 | 205 | // Throws away all written data to prevent response from buffering payload 206 | function getNullSocket () { 207 | return new Writable({ 208 | write (_chunk, _encoding, callback) { 209 | setImmediate(callback) 210 | } 211 | }) 212 | } 213 | 214 | function serializeHeaders (response) { 215 | const headers = response._lightMyRequest.headers 216 | 217 | for (const headerName of Object.keys(headers)) { 218 | const headerValue = headers[headerName] 219 | if (Array.isArray(headerValue)) { 220 | headers[headerName] = headerValue.map(value => '' + value) 221 | } else { 222 | headers[headerName] = '' + headerValue 223 | } 224 | } 225 | } 226 | 227 | function copyHeaders (response) { 228 | response._lightMyRequest.headers = Object.assign({}, response.getHeaders()) 229 | 230 | // Add raw headers 231 | ;['Date', 'Connection', 'Transfer-Encoding'].forEach((name) => { 232 | const regex = new RegExp('\\r\\n' + name + ': ([^\\r]*)\\r\\n') 233 | const field = response._header?.match(regex) 234 | if (field) { 235 | response._lightMyRequest.headers[name.toLowerCase()] = field[1] 236 | } 237 | }) 238 | } 239 | 240 | module.exports = Response 241 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "light-my-request", 3 | "version": "6.6.0", 4 | "description": "Fake HTTP injection library", 5 | "main": "index.js", 6 | "type": "commonjs", 7 | "types": "types/index.d.ts", 8 | "dependencies": { 9 | "cookie": "^1.0.1", 10 | "process-warning": "^5.0.0", 11 | "set-cookie-parser": "^2.6.0" 12 | }, 13 | "devDependencies": { 14 | "@fastify/ajv-compiler": "^4.0.0", 15 | "@fastify/pre-commit": "^2.1.0", 16 | "@types/node": "^22.7.7", 17 | "c8": "^10.1.2", 18 | "end-of-stream": "^1.4.4", 19 | "eslint": "^9.17.0", 20 | "express": "^5.1.0", 21 | "form-auto-content": "^3.2.1", 22 | "form-data": "^4.0.0", 23 | "formdata-node": "^6.0.3", 24 | "multer": "^1.4.5-lts.1", 25 | "neostandard": "^0.12.0", 26 | "tinybench": "^4.0.1", 27 | "tsd": "^0.32.0", 28 | "undici": "^7.0.0" 29 | }, 30 | "scripts": { 31 | "benchmark": "node benchmark/benchmark.js", 32 | "coverage": "npm run unit -- --cov --coverage-report=html", 33 | "lint": "eslint", 34 | "lint:fix": "eslint --fix", 35 | "test": "npm run lint && npm run test:unit && npm run test:typescript", 36 | "test:typescript": "tsd", 37 | "test:unit": "c8 --100 node --test" 38 | }, 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/fastify/light-my-request.git" 42 | }, 43 | "keywords": [ 44 | "http", 45 | "inject", 46 | "fake", 47 | "request", 48 | "server" 49 | ], 50 | "author": "Tomas Della Vedova - @delvedor (http://delved.org)", 51 | "contributors": [ 52 | { 53 | "name": "Matteo Collina", 54 | "email": "hello@matteocollina.com" 55 | }, 56 | { 57 | "name": "Manuel Spigolon", 58 | "email": "behemoth89@gmail.com" 59 | }, 60 | { 61 | "name": "Aras Abbasi", 62 | "email": "aras.abbasi@gmail.com" 63 | }, 64 | { 65 | "name": "Frazer Smith", 66 | "email": "frazer.dev@icloud.com", 67 | "url": "https://github.com/fdawgs" 68 | } 69 | ], 70 | "license": "BSD-3-Clause", 71 | "bugs": { 72 | "url": "https://github.com/fastify/light-my-request/issues" 73 | }, 74 | "homepage": "https://github.com/fastify/light-my-request#readme", 75 | "funding": [ 76 | { 77 | "type": "github", 78 | "url": "https://github.com/sponsors/fastify" 79 | }, 80 | { 81 | "type": "opencollective", 82 | "url": "https://opencollective.com/fastify" 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /test/async-await.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const inject = require('../index') 5 | 6 | test('basic async await', async t => { 7 | const dispatch = function (_req, res) { 8 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 9 | res.end('hello') 10 | } 11 | 12 | try { 13 | const res = await inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello' }) 14 | t.assert.strictEqual(res.payload, 'hello') 15 | } catch (err) { 16 | t.assert.fail(err) 17 | } 18 | }) 19 | 20 | test('basic async await (errored)', async t => { 21 | const dispatch = function (_req, res) { 22 | res.connection.destroy(new Error('kaboom')) 23 | } 24 | 25 | await t.assert.rejects(() => inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello' }), Error) 26 | }) 27 | 28 | test('chainable api with async await', async t => { 29 | const dispatch = function (_req, res) { 30 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 31 | res.end('hello') 32 | } 33 | 34 | try { 35 | const chain = inject(dispatch).get('http://example.com:8080/hello') 36 | const res = await chain.end() 37 | t.assert.strictEqual(res.payload, 'hello') 38 | } catch (err) { 39 | t.assert.fail(err) 40 | } 41 | }) 42 | 43 | test('chainable api with async await without end()', async t => { 44 | const dispatch = function (_req, res) { 45 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 46 | res.end('hello') 47 | } 48 | 49 | try { 50 | const res = await inject(dispatch).get('http://example.com:8080/hello') 51 | t.assert.strictEqual(res.payload, 'hello') 52 | } catch (err) { 53 | t.assert.fail(err) 54 | } 55 | }) 56 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | const { Readable, finished, pipeline } = require('node:stream') 5 | const qs = require('node:querystring') 6 | const fs = require('node:fs') 7 | const zlib = require('node:zlib') 8 | const http = require('node:http') 9 | const eos = require('end-of-stream') 10 | const express = require('express') 11 | const multer = require('multer') 12 | 13 | const inject = require('../index') 14 | const parseURL = require('../lib/parse-url') 15 | 16 | const NpmFormData = require('form-data') 17 | const formAutoContent = require('form-auto-content') 18 | const httpMethods = [ 19 | 'delete', 20 | 'get', 21 | 'head', 22 | 'options', 23 | 'patch', 24 | 'post', 25 | 'put', 26 | 'trace' 27 | ] 28 | 29 | test('returns non-chunked payload', (t, done) => { 30 | t.plan(7) 31 | const output = 'example.com:8080|/hello' 32 | 33 | const dispatch = function (req, res) { 34 | res.statusMessage = 'Super' 35 | res.setHeader('x-extra', 'hello') 36 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': output.length }) 37 | res.end(req.headers.host + '|' + req.url) 38 | } 39 | 40 | inject(dispatch, 'http://example.com:8080/hello', (err, res) => { 41 | t.assert.ifError(err) 42 | t.assert.strictEqual(res.statusCode, 200) 43 | t.assert.strictEqual(res.statusMessage, 'Super') 44 | t.assert.ok(res.headers.date) 45 | t.assert.deepStrictEqual(res.headers, { 46 | date: res.headers.date, 47 | connection: 'keep-alive', 48 | 'x-extra': 'hello', 49 | 'content-type': 'text/plain', 50 | 'content-length': output.length.toString() 51 | }) 52 | t.assert.strictEqual(res.payload, output) 53 | t.assert.strictEqual(res.rawPayload.toString(), 'example.com:8080|/hello') 54 | done() 55 | }) 56 | }) 57 | 58 | test('returns single buffer payload', (t, done) => { 59 | t.plan(6) 60 | const dispatch = function (req, res) { 61 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 62 | res.end(req.headers.host + '|' + req.url) 63 | } 64 | 65 | inject(dispatch, { url: 'http://example.com:8080/hello' }, (err, res) => { 66 | t.assert.ifError(err) 67 | t.assert.ok(res.headers.date) 68 | t.assert.ok(res.headers.connection) 69 | t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') 70 | t.assert.strictEqual(res.payload, 'example.com:8080|/hello') 71 | t.assert.strictEqual(res.rawPayload.toString(), 'example.com:8080|/hello') 72 | done() 73 | }) 74 | }) 75 | 76 | test('passes headers', (t, done) => { 77 | t.plan(2) 78 | const dispatch = function (req, res) { 79 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 80 | res.end(req.headers.super) 81 | } 82 | 83 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', headers: { Super: 'duper' } }, (err, res) => { 84 | t.assert.ifError(err) 85 | t.assert.strictEqual(res.payload, 'duper') 86 | done() 87 | }) 88 | }) 89 | 90 | test('request has rawHeaders', (t, done) => { 91 | t.plan(3) 92 | const dispatch = function (req, res) { 93 | t.assert.ok(Array.isArray(req.rawHeaders)) 94 | t.assert.deepStrictEqual(req.rawHeaders, ['super', 'duper', 'user-agent', 'lightMyRequest', 'host', 'example.com:8080']) 95 | res.writeHead(200) 96 | res.end() 97 | } 98 | 99 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', headers: { Super: 'duper' } }, (err) => { 100 | t.assert.ifError(err) 101 | done() 102 | }) 103 | }) 104 | 105 | test('request inherits from custom class', (t, done) => { 106 | t.plan(2) 107 | const dispatch = function (req, res) { 108 | t.assert.ok(req instanceof http.IncomingMessage) 109 | res.writeHead(200) 110 | res.end() 111 | } 112 | 113 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', Request: http.IncomingMessage }, (err) => { 114 | t.assert.ifError(err) 115 | done() 116 | }) 117 | }) 118 | 119 | test('request with custom class preserves stream data', (t, done) => { 120 | t.plan(2) 121 | const dispatch = function (req, res) { 122 | t.assert.ok(req._readableState) 123 | res.writeHead(200) 124 | res.end() 125 | } 126 | 127 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', Request: http.IncomingMessage }, (err) => { 128 | t.assert.ifError(err) 129 | done() 130 | }) 131 | }) 132 | 133 | test('assert Request option has a valid prototype', (t) => { 134 | t.plan(2) 135 | const dispatch = function (_req, res) { 136 | t.assert.ifError('should not get here') 137 | res.writeHead(500) 138 | res.end() 139 | } 140 | 141 | const MyInvalidRequest = {} 142 | 143 | t.assert.throws(() => { 144 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', Request: MyInvalidRequest }, () => {}) 145 | }, Error) 146 | 147 | t.assert.throws(() => { 148 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', Request: 'InvalidRequest' }, () => {}) 149 | }, Error) 150 | }) 151 | 152 | test('passes remote address', (t, done) => { 153 | t.plan(2) 154 | const dispatch = function (req, res) { 155 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 156 | res.end(req.socket.remoteAddress) 157 | } 158 | 159 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', remoteAddress: '1.2.3.4' }, (err, res) => { 160 | t.assert.ifError(err) 161 | t.assert.strictEqual(res.payload, '1.2.3.4') 162 | done() 163 | }) 164 | }) 165 | 166 | test('passes a socket which emits events like a normal one does', (t, done) => { 167 | t.plan(2) 168 | const dispatch = function (req, res) { 169 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 170 | req.socket.on('timeout', () => {}) 171 | res.end('added') 172 | } 173 | 174 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello' }, (err, res) => { 175 | t.assert.ifError(err) 176 | t.assert.strictEqual(res.payload, 'added') 177 | done() 178 | }) 179 | }) 180 | 181 | test('includes deprecated connection on request', (t, done) => { 182 | t.plan(3) 183 | const warnings = process.listeners('warning') 184 | process.removeAllListeners('warning') 185 | function onWarning (err) { 186 | t.assert.strictEqual(err.code, 'FST_LIGHTMYREQUEST_DEP01') 187 | return false 188 | } 189 | process.on('warning', onWarning) 190 | t.after(() => { 191 | process.removeListener('warning', onWarning) 192 | for (const fn of warnings) { 193 | process.on('warning', fn) 194 | } 195 | }) 196 | const dispatch = function (req, res) { 197 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 198 | res.end(req.connection.remoteAddress) 199 | } 200 | 201 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', remoteAddress: '1.2.3.4' }, (err, res) => { 202 | t.assert.ifError(err) 203 | t.assert.strictEqual(res.payload, '1.2.3.4') 204 | done() 205 | }) 206 | }) 207 | 208 | const parseQuery = url => { 209 | const parsedURL = parseURL(url) 210 | return qs.parse(parsedURL.search.slice(1)) 211 | } 212 | 213 | test('passes query', (t, done) => { 214 | t.plan(2) 215 | 216 | const query = { 217 | message: 'OK', 218 | xs: ['foo', 'bar'] 219 | } 220 | 221 | const dispatch = function (req, res) { 222 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 223 | res.end(req.url) 224 | } 225 | 226 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', query }, (err, res) => { 227 | t.assert.ifError(err) 228 | t.assert.deepEqual(parseQuery(res.payload), query) 229 | done() 230 | }) 231 | }) 232 | 233 | test('query will be merged into that in url', (t, done) => { 234 | t.plan(2) 235 | 236 | const query = { 237 | xs: ['foo', 'bar'] 238 | } 239 | 240 | const dispatch = function (req, res) { 241 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 242 | res.end(req.url) 243 | } 244 | 245 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello?message=OK', query }, (err, res) => { 246 | t.assert.ifError(err) 247 | t.assert.deepEqual(parseQuery(res.payload), Object.assign({ message: 'OK' }, query)) 248 | done() 249 | }) 250 | }) 251 | 252 | test('passes query as a string', (t, done) => { 253 | t.plan(2) 254 | 255 | const query = 'message=OK&xs=foo&xs=bar' 256 | 257 | const dispatch = function (req, res) { 258 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 259 | res.end(req.url) 260 | } 261 | 262 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', query }, (err, res) => { 263 | t.assert.ifError(err) 264 | t.assert.deepEqual(parseQuery(res.payload), { 265 | message: 'OK', 266 | xs: ['foo', 'bar'] 267 | }) 268 | done() 269 | }) 270 | }) 271 | 272 | test('query as a string will be merged into that in url', (t, done) => { 273 | t.plan(2) 274 | 275 | const query = 'xs=foo&xs=bar' 276 | 277 | const dispatch = function (req, res) { 278 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 279 | res.end(req.url) 280 | } 281 | 282 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello?message=OK', query }, (err, res) => { 283 | t.assert.ifError(err) 284 | t.assert.deepEqual(parseQuery(res.payload), Object.assign({ message: 'OK' }, { 285 | xs: ['foo', 'bar'] 286 | })) 287 | done() 288 | }) 289 | }) 290 | 291 | test('passes localhost as default remote address', (t, done) => { 292 | t.plan(2) 293 | const dispatch = function (req, res) { 294 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 295 | res.end(req.socket.remoteAddress) 296 | } 297 | 298 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello' }, (err, res) => { 299 | t.assert.ifError(err) 300 | t.assert.strictEqual(res.payload, '127.0.0.1') 301 | done() 302 | }) 303 | }) 304 | 305 | test('passes host option as host header', (t, done) => { 306 | t.plan(2) 307 | const dispatch = function (req, res) { 308 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 309 | res.end(req.headers.host) 310 | } 311 | 312 | inject(dispatch, { method: 'GET', url: '/hello', headers: { host: 'test.example.com' } }, (err, res) => { 313 | t.assert.ifError(err) 314 | t.assert.strictEqual(res.payload, 'test.example.com') 315 | done() 316 | }) 317 | }) 318 | 319 | test('passes localhost as default host header', (t, done) => { 320 | t.plan(2) 321 | const dispatch = function (req, res) { 322 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 323 | res.end(req.headers.host) 324 | } 325 | 326 | inject(dispatch, { method: 'GET', url: '/hello' }, (err, res) => { 327 | t.assert.ifError(err) 328 | t.assert.strictEqual(res.payload, 'localhost:80') 329 | done() 330 | }) 331 | }) 332 | 333 | test('passes authority as host header', (t, done) => { 334 | t.plan(2) 335 | const dispatch = function (req, res) { 336 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 337 | res.end(req.headers.host) 338 | } 339 | 340 | inject(dispatch, { method: 'GET', url: '/hello', authority: 'something' }, (err, res) => { 341 | t.assert.ifError(err) 342 | t.assert.strictEqual(res.payload, 'something') 343 | done() 344 | }) 345 | }) 346 | 347 | test('passes uri host as host header', (t, done) => { 348 | t.plan(2) 349 | const dispatch = function (req, res) { 350 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 351 | res.end(req.headers.host) 352 | } 353 | 354 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello' }, (err, res) => { 355 | t.assert.ifError(err) 356 | t.assert.strictEqual(res.payload, 'example.com:8080') 357 | done() 358 | }) 359 | }) 360 | 361 | test('includes default http port in host header', (t, done) => { 362 | t.plan(2) 363 | const dispatch = function (req, res) { 364 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 365 | res.end(req.headers.host) 366 | } 367 | 368 | inject(dispatch, 'http://example.com', (err, res) => { 369 | t.assert.ifError(err) 370 | t.assert.strictEqual(res.payload, 'example.com:80') 371 | done() 372 | }) 373 | }) 374 | 375 | test('includes default https port in host header', (t, done) => { 376 | t.plan(2) 377 | const dispatch = function (req, res) { 378 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 379 | res.end(req.headers.host) 380 | } 381 | 382 | inject(dispatch, 'https://example.com', (err, res) => { 383 | t.assert.ifError(err) 384 | t.assert.strictEqual(res.payload, 'example.com:443') 385 | done() 386 | }) 387 | }) 388 | 389 | test('optionally accepts an object as url', (t, done) => { 390 | t.plan(5) 391 | const output = 'example.com:8080|/hello?test=1234' 392 | 393 | const dispatch = function (req, res) { 394 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': output.length }) 395 | res.end(req.headers.host + '|' + req.url) 396 | } 397 | 398 | const url = { 399 | protocol: 'http', 400 | hostname: 'example.com', 401 | port: '8080', 402 | pathname: 'hello', 403 | query: { 404 | test: '1234' 405 | } 406 | } 407 | 408 | inject(dispatch, { url }, (err, res) => { 409 | t.assert.ifError(err) 410 | t.assert.ok(res.headers.date) 411 | t.assert.ok(res.headers.connection) 412 | t.assert.ifError(res.headers['transfer-encoding']) 413 | t.assert.strictEqual(res.payload, output) 414 | done() 415 | }) 416 | }) 417 | 418 | test('leaves user-agent unmodified', (t, done) => { 419 | t.plan(2) 420 | const dispatch = function (req, res) { 421 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 422 | res.end(req.headers['user-agent']) 423 | } 424 | 425 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', headers: { 'user-agent': 'duper' } }, (err, res) => { 426 | t.assert.ifError(err) 427 | t.assert.strictEqual(res.payload, 'duper') 428 | done() 429 | }) 430 | }) 431 | 432 | test('returns chunked payload', (t, done) => { 433 | t.plan(5) 434 | const dispatch = function (_req, res) { 435 | res.writeHead(200, 'OK') 436 | res.write('a') 437 | res.write('b') 438 | res.end() 439 | } 440 | 441 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 442 | t.assert.ifError(err) 443 | t.assert.ok(res.headers.date) 444 | t.assert.ok(res.headers.connection) 445 | t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') 446 | t.assert.strictEqual(res.payload, 'ab') 447 | done() 448 | }) 449 | }) 450 | 451 | test('sets trailers in response object', (t, done) => { 452 | t.plan(4) 453 | const dispatch = function (_req, res) { 454 | res.setHeader('Trailer', 'Test') 455 | res.addTrailers({ Test: 123 }) 456 | res.end() 457 | } 458 | 459 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 460 | t.assert.ifError(err) 461 | t.assert.strictEqual(res.headers.trailer, 'Test') 462 | t.assert.strictEqual(res.headers.test, undefined) 463 | t.assert.strictEqual(res.trailers.test, '123') 464 | done() 465 | }) 466 | }) 467 | 468 | test('parses zipped payload', (t, done) => { 469 | t.plan(4) 470 | const dispatch = function (_req, res) { 471 | res.writeHead(200, 'OK') 472 | const stream = fs.createReadStream('./package.json') 473 | stream.pipe(zlib.createGzip()).pipe(res) 474 | } 475 | 476 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 477 | t.assert.ifError(err) 478 | fs.readFile('./package.json', { encoding: 'utf-8' }, (err, file) => { 479 | t.assert.ifError(err) 480 | 481 | zlib.unzip(res.rawPayload, (err, unzipped) => { 482 | t.assert.ifError(err) 483 | t.assert.strictEqual(unzipped.toString('utf-8'), file) 484 | done() 485 | }) 486 | }) 487 | }) 488 | }) 489 | 490 | test('returns multi buffer payload', (t, done) => { 491 | t.plan(2) 492 | const dispatch = function (_req, res) { 493 | res.writeHead(200) 494 | res.write('a') 495 | res.write(Buffer.from('b')) 496 | res.end() 497 | } 498 | 499 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 500 | t.assert.ifError(err) 501 | t.assert.strictEqual(res.payload, 'ab') 502 | done() 503 | }) 504 | }) 505 | 506 | test('returns null payload', (t, done) => { 507 | t.plan(2) 508 | const dispatch = function (_req, res) { 509 | res.writeHead(200, { 'Content-Length': 0 }) 510 | res.end() 511 | } 512 | 513 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 514 | t.assert.ifError(err) 515 | t.assert.strictEqual(res.payload, '') 516 | done() 517 | }) 518 | }) 519 | 520 | test('allows ending twice', (t, done) => { 521 | t.plan(2) 522 | const dispatch = function (_req, res) { 523 | res.writeHead(200, { 'Content-Length': 0 }) 524 | res.end() 525 | res.end() 526 | } 527 | 528 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 529 | t.assert.ifError(err) 530 | t.assert.strictEqual(res.payload, '') 531 | done() 532 | }) 533 | }) 534 | 535 | test('identifies injection object', (t, done) => { 536 | t.plan(6) 537 | const dispatchRequest = function (req, res) { 538 | t.assert.strictEqual(inject.isInjection(req), true) 539 | t.assert.strictEqual(inject.isInjection(res), true) 540 | 541 | res.writeHead(200, { 'Content-Length': 0 }) 542 | res.end() 543 | } 544 | 545 | const dispatchCustomRequest = function (req, res) { 546 | t.assert.strictEqual(inject.isInjection(req), true) 547 | t.assert.strictEqual(inject.isInjection(res), true) 548 | 549 | res.writeHead(200, { 'Content-Length': 0 }) 550 | res.end() 551 | } 552 | 553 | const options = { method: 'GET', url: '/' } 554 | const cb = (err) => { t.assert.ifError(err) } 555 | const cbDone = (err) => { 556 | t.assert.ifError(err) 557 | done() 558 | } 559 | 560 | inject(dispatchRequest, options, cb) 561 | inject(dispatchCustomRequest, { ...options, Request: http.IncomingMessage }, cbDone) 562 | }) 563 | 564 | test('pipes response', (t, done) => { 565 | t.plan(3) 566 | let finished = false 567 | const dispatch = function (_req, res) { 568 | res.writeHead(200) 569 | const stream = getTestStream() 570 | 571 | res.on('finish', () => { 572 | finished = true 573 | }) 574 | 575 | stream.pipe(res) 576 | } 577 | 578 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 579 | t.assert.ifError(err) 580 | t.assert.strictEqual(finished, true) 581 | t.assert.strictEqual(res.payload, 'hi') 582 | done() 583 | }) 584 | }) 585 | 586 | test('pipes response with old stream', (t, done) => { 587 | t.plan(3) 588 | let finished = false 589 | const dispatch = function (_req, res) { 590 | res.writeHead(200) 591 | const stream = getTestStream() 592 | stream.pause() 593 | const stream2 = new Readable().wrap(stream) 594 | stream.resume() 595 | 596 | res.on('finish', () => { 597 | finished = true 598 | }) 599 | 600 | stream2.pipe(res) 601 | } 602 | 603 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 604 | t.assert.ifError(err) 605 | t.assert.strictEqual(finished, true) 606 | t.assert.strictEqual(res.payload, 'hi') 607 | done() 608 | }) 609 | }) 610 | 611 | test('echos object payload', (t, done) => { 612 | t.plan(3) 613 | const dispatch = function (req, res) { 614 | res.writeHead(200, { 'content-type': req.headers['content-type'] }) 615 | req.pipe(res) 616 | } 617 | 618 | inject(dispatch, { method: 'POST', url: '/test', payload: { a: 1 } }, (err, res) => { 619 | t.assert.ifError(err) 620 | t.assert.strictEqual(res.headers['content-type'], 'application/json') 621 | t.assert.strictEqual(res.payload, '{"a":1}') 622 | done() 623 | }) 624 | }) 625 | 626 | test('supports body option in Request and property in Response', (t, done) => { 627 | t.plan(3) 628 | const dispatch = function (req, res) { 629 | res.writeHead(200, { 'content-type': req.headers['content-type'] }) 630 | req.pipe(res) 631 | } 632 | 633 | inject(dispatch, { method: 'POST', url: '/test', body: { a: 1 } }, (err, res) => { 634 | t.assert.ifError(err) 635 | t.assert.strictEqual(res.headers['content-type'], 'application/json') 636 | t.assert.strictEqual(res.body, '{"a":1}') 637 | done() 638 | }) 639 | }) 640 | 641 | test('echos buffer payload', (t, done) => { 642 | t.plan(2) 643 | const dispatch = function (req, res) { 644 | res.writeHead(200) 645 | req.pipe(res) 646 | } 647 | 648 | inject(dispatch, { method: 'POST', url: '/test', payload: Buffer.from('test!') }, (err, res) => { 649 | t.assert.ifError(err) 650 | t.assert.strictEqual(res.payload, 'test!') 651 | done() 652 | }) 653 | }) 654 | 655 | test('echos object payload with non-english utf-8 string', (t, done) => { 656 | t.plan(3) 657 | const dispatch = function (req, res) { 658 | res.writeHead(200, { 'content-type': req.headers['content-type'] }) 659 | req.pipe(res) 660 | } 661 | 662 | inject(dispatch, { method: 'POST', url: '/test', payload: { a: '½½א' } }, (err, res) => { 663 | t.assert.ifError(err) 664 | t.assert.strictEqual(res.headers['content-type'], 'application/json') 665 | t.assert.strictEqual(res.payload, '{"a":"½½א"}') 666 | done() 667 | }) 668 | }) 669 | 670 | test('echos object payload without payload', (t, done) => { 671 | t.plan(2) 672 | const dispatch = function (req, res) { 673 | res.writeHead(200) 674 | req.pipe(res) 675 | } 676 | 677 | inject(dispatch, { method: 'POST', url: '/test' }, (err, res) => { 678 | t.assert.ifError(err) 679 | t.assert.strictEqual(res.payload, '') 680 | done() 681 | }) 682 | }) 683 | 684 | test('retains content-type header', (t, done) => { 685 | t.plan(3) 686 | const dispatch = function (req, res) { 687 | res.writeHead(200, { 'content-type': req.headers['content-type'] }) 688 | req.pipe(res) 689 | } 690 | 691 | inject(dispatch, { method: 'POST', url: '/test', payload: { a: 1 }, headers: { 'content-type': 'something' } }, (err, res) => { 692 | t.assert.ifError(err) 693 | t.assert.strictEqual(res.headers['content-type'], 'something') 694 | t.assert.strictEqual(res.payload, '{"a":1}') 695 | done() 696 | }) 697 | }) 698 | 699 | test('adds a content-length header if none set when payload specified', (t, done) => { 700 | t.plan(2) 701 | const dispatch = function (req, res) { 702 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 703 | res.end(req.headers['content-length']) 704 | } 705 | 706 | inject(dispatch, { method: 'POST', url: '/test', payload: { a: 1 } }, (err, res) => { 707 | t.assert.ifError(err) 708 | t.assert.strictEqual(res.payload, '{"a":1}'.length.toString()) 709 | done() 710 | }) 711 | }) 712 | 713 | test('retains a content-length header when payload specified', (t, done) => { 714 | t.plan(2) 715 | const dispatch = function (req, res) { 716 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 717 | res.end(req.headers['content-length']) 718 | } 719 | 720 | inject(dispatch, { method: 'POST', url: '/test', payload: '', headers: { 'content-length': '10' } }, (err, res) => { 721 | t.assert.ifError(err) 722 | t.assert.strictEqual(res.payload, '10') 723 | done() 724 | }) 725 | }) 726 | 727 | test('can handle a stream payload', (t, done) => { 728 | t.plan(2) 729 | const dispatch = function (req, res) { 730 | readStream(req, (buff) => { 731 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 732 | res.end(buff) 733 | }) 734 | } 735 | 736 | inject(dispatch, { method: 'POST', url: '/', payload: getTestStream() }, (err, res) => { 737 | t.assert.ifError(err) 738 | t.assert.strictEqual(res.payload, 'hi') 739 | done() 740 | }) 741 | }) 742 | 743 | test('can handle a stream payload that errors', (t, done) => { 744 | t.plan(2) 745 | const dispatch = function (req) { 746 | req.resume() 747 | } 748 | 749 | const payload = new Readable({ 750 | read () { 751 | this.destroy(new Error('kaboom')) 752 | } 753 | }) 754 | 755 | inject(dispatch, { method: 'POST', url: '/', payload }, (err) => { 756 | t.assert.ok(err) 757 | t.assert.equal(err.message, 'kaboom') 758 | done() 759 | }) 760 | }) 761 | 762 | test('can handle a stream payload of utf-8 strings', (t, done) => { 763 | t.plan(2) 764 | const dispatch = function (req, res) { 765 | readStream(req, (buff) => { 766 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 767 | res.end(buff) 768 | }) 769 | } 770 | 771 | inject(dispatch, { method: 'POST', url: '/', payload: getTestStream('utf8') }, (err, res) => { 772 | t.assert.ifError(err) 773 | t.assert.strictEqual(res.payload, 'hi') 774 | done() 775 | }) 776 | }) 777 | 778 | test('can override stream payload content-length header', (t, done) => { 779 | t.plan(2) 780 | const dispatch = function (req, res) { 781 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 782 | res.end(req.headers['content-length']) 783 | } 784 | 785 | const headers = { 'content-length': '100' } 786 | 787 | inject(dispatch, { method: 'POST', url: '/', payload: getTestStream(), headers }, (err, res) => { 788 | t.assert.ifError(err) 789 | t.assert.strictEqual(res.payload, '100') 790 | done() 791 | }) 792 | }) 793 | 794 | test('writeHead returns single buffer payload', (t, done) => { 795 | t.plan(4) 796 | const reply = 'Hello World' 797 | const statusCode = 200 798 | const statusMessage = 'OK' 799 | const dispatch = function (_req, res) { 800 | res.writeHead(statusCode, statusMessage, { 'Content-Type': 'text/plain', 'Content-Length': reply.length }) 801 | res.end(reply) 802 | } 803 | 804 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 805 | t.assert.ifError(err) 806 | t.assert.strictEqual(res.statusCode, statusCode) 807 | t.assert.strictEqual(res.statusMessage, statusMessage) 808 | t.assert.strictEqual(res.payload, reply) 809 | done() 810 | }) 811 | }) 812 | 813 | test('_read() plays payload', (t, done) => { 814 | t.plan(2) 815 | const dispatch = function (req, res) { 816 | let buffer = '' 817 | req.on('readable', () => { 818 | buffer = buffer + (req.read() || '') 819 | }) 820 | 821 | req.on('close', () => { 822 | }) 823 | 824 | req.on('end', () => { 825 | res.writeHead(200, { 'Content-Length': 0 }) 826 | res.end(buffer) 827 | req.destroy() 828 | }) 829 | } 830 | 831 | const body = 'something special just for you' 832 | inject(dispatch, { method: 'GET', url: '/', payload: body }, (err, res) => { 833 | t.assert.ifError(err) 834 | t.assert.strictEqual(res.payload, body) 835 | done() 836 | }) 837 | }) 838 | 839 | test('simulates split', (t, done) => { 840 | t.plan(2) 841 | const dispatch = function (req, res) { 842 | let buffer = '' 843 | req.on('readable', () => { 844 | buffer = buffer + (req.read() || '') 845 | }) 846 | 847 | req.on('close', () => { 848 | }) 849 | 850 | req.on('end', () => { 851 | res.writeHead(200, { 'Content-Length': 0 }) 852 | res.end(buffer) 853 | req.destroy() 854 | }) 855 | } 856 | 857 | const body = 'something special just for you' 858 | inject(dispatch, { method: 'GET', url: '/', payload: body, simulate: { split: true } }, (err, res) => { 859 | t.assert.ifError(err) 860 | t.assert.strictEqual(res.payload, body) 861 | done() 862 | }) 863 | }) 864 | 865 | test('simulates error', (t, done) => { 866 | t.plan(2) 867 | const dispatch = function (req, res) { 868 | req.on('readable', () => { 869 | }) 870 | 871 | req.on('error', () => { 872 | res.writeHead(200, { 'Content-Length': 0 }) 873 | res.end('error') 874 | }) 875 | } 876 | 877 | const body = 'something special just for you' 878 | inject(dispatch, { method: 'GET', url: '/', payload: body, simulate: { error: true } }, (err, res) => { 879 | t.assert.ifError(err) 880 | t.assert.strictEqual(res.payload, 'error') 881 | done() 882 | }) 883 | }) 884 | 885 | test('simulates no end without payload', (t, done) => { 886 | t.plan(2) 887 | let end = false 888 | const dispatch = function (req) { 889 | req.resume() 890 | req.on('end', () => { 891 | end = true 892 | }) 893 | } 894 | 895 | let replied = false 896 | inject(dispatch, { method: 'GET', url: '/', simulate: { end: false } }, () => { 897 | replied = true 898 | }) 899 | 900 | setTimeout(() => { 901 | t.assert.strictEqual(end, false) 902 | t.assert.strictEqual(replied, false) 903 | done() 904 | }, 10) 905 | }) 906 | 907 | test('simulates no end with payload', (t, done) => { 908 | t.plan(2) 909 | let end = false 910 | const dispatch = function (req) { 911 | req.resume() 912 | req.on('end', () => { 913 | end = true 914 | }) 915 | } 916 | 917 | let replied = false 918 | inject(dispatch, { method: 'GET', url: '/', payload: '1234567', simulate: { end: false } }, () => { 919 | replied = true 920 | }) 921 | 922 | setTimeout(() => { 923 | t.assert.strictEqual(end, false) 924 | t.assert.strictEqual(replied, false) 925 | done() 926 | }, 10) 927 | }) 928 | 929 | test('simulates close', (t, done) => { 930 | t.plan(2) 931 | const dispatch = function (req, res) { 932 | let buffer = '' 933 | req.on('readable', () => { 934 | buffer = buffer + (req.read() || '') 935 | }) 936 | 937 | req.on('close', () => { 938 | res.writeHead(200, { 'Content-Length': 0 }) 939 | res.end('close') 940 | }) 941 | 942 | req.on('end', () => { 943 | }) 944 | } 945 | 946 | const body = 'something special just for you' 947 | inject(dispatch, { method: 'GET', url: '/', payload: body, simulate: { close: true } }, (err, res) => { 948 | t.assert.ifError(err) 949 | t.assert.strictEqual(res.payload, 'close') 950 | done() 951 | }) 952 | }) 953 | 954 | test('errors for invalid input options', (t) => { 955 | t.plan(1) 956 | 957 | t.assert.throws( 958 | () => inject({}, {}, () => {}), 959 | { name: 'AssertionError', message: 'dispatchFunc should be a function' } 960 | ) 961 | }) 962 | 963 | test('errors for missing url', (t) => { 964 | t.plan(1) 965 | 966 | t.assert.throws( 967 | () => inject(() => {}, {}, () => {}), 968 | { message: /must have required property 'url'/ } 969 | ) 970 | }) 971 | 972 | test('errors for an incorrect simulation object', (t) => { 973 | t.plan(1) 974 | 975 | t.assert.throws( 976 | () => inject(() => {}, { url: '/', simulate: 'sample string' }, () => {}), 977 | { message: /^must be object$/ } 978 | ) 979 | }) 980 | 981 | test('ignores incorrect simulation object', (t) => { 982 | t.plan(1) 983 | 984 | t.assert.doesNotThrow(() => inject(() => { }, { url: '/', simulate: 'sample string', validate: false }, () => { })) 985 | }) 986 | 987 | test('errors for an incorrect simulation object values', (t) => { 988 | t.plan(1) 989 | 990 | t.assert.throws( 991 | () => inject(() => {}, { url: '/', simulate: { end: 'wrong input' } }, () => {}), 992 | { message: /^must be boolean$/ } 993 | ) 994 | }) 995 | 996 | test('promises support', (t, done) => { 997 | t.plan(1) 998 | const dispatch = function (_req, res) { 999 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1000 | res.end('hello') 1001 | } 1002 | 1003 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello' }) 1004 | .then(res => { 1005 | t.assert.strictEqual(res.payload, 'hello') 1006 | done() 1007 | }) 1008 | .catch(t.assert.fail) 1009 | }) 1010 | 1011 | test('this should be the server instance', (t, done) => { 1012 | t.plan(2) 1013 | 1014 | const server = http.createServer() 1015 | 1016 | const dispatch = function (_req, res) { 1017 | t.assert.strictEqual(this, server) 1018 | res.end('hello') 1019 | } 1020 | 1021 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', server }) 1022 | .then(res => t.assert.strictEqual(res.statusCode, 200)) 1023 | .catch(t.assert.fail) 1024 | .finally(done) 1025 | }) 1026 | 1027 | test('should handle response errors', (t, done) => { 1028 | t.plan(1) 1029 | const dispatch = function (_req, res) { 1030 | res.connection.destroy(new Error('kaboom')) 1031 | } 1032 | 1033 | inject(dispatch, 'http://example.com:8080/hello', (err) => { 1034 | t.assert.ok(err) 1035 | done() 1036 | }) 1037 | }) 1038 | 1039 | test('should handle response errors (promises)', async (t) => { 1040 | t.plan(1) 1041 | const dispatch = function (_req, res) { 1042 | res.connection.destroy(new Error('kaboom')) 1043 | } 1044 | 1045 | await t.assert.rejects( 1046 | () => inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello' }), 1047 | { name: 'Error', message: 'kaboom' } 1048 | ) 1049 | }) 1050 | 1051 | test('should handle response timeout handler', (t, done) => { 1052 | t.plan(3) 1053 | const dispatch = function (_req, res) { 1054 | const handle = setTimeout(() => { 1055 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1056 | res.end('incorrect') 1057 | }, 200) 1058 | res.setTimeout(100, () => { 1059 | clearTimeout(handle) 1060 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1061 | res.end('correct') 1062 | }) 1063 | res.on('timeout', () => { 1064 | t.assert.ok(true, 'Response timeout event not emitted') 1065 | }) 1066 | } 1067 | inject(dispatch, { method: 'GET', url: '/test' }, (err, res) => { 1068 | t.assert.ifError(err) 1069 | t.assert.strictEqual(res.payload, 'correct') 1070 | done() 1071 | }) 1072 | }) 1073 | 1074 | test('should throw on unknown HTTP method', (t) => { 1075 | t.plan(1) 1076 | const dispatch = function () { } 1077 | 1078 | t.assert.throws(() => inject(dispatch, { method: 'UNKNOWN_METHOD', url: 'http://example.com:8080/hello' }, (err, _res) => { 1079 | t.assert.ok(err) 1080 | }), Error) 1081 | }) 1082 | 1083 | test('should throw on unknown HTTP method (promises)', (t) => { 1084 | t.plan(1) 1085 | const dispatch = function () { } 1086 | 1087 | t.assert.throws(() => inject(dispatch, { method: 'UNKNOWN_METHOD', url: 'http://example.com:8080/hello' }) 1088 | .then(() => {}), Error) 1089 | }) 1090 | 1091 | test('HTTP method is case insensitive', (t, done) => { 1092 | t.plan(3) 1093 | 1094 | const dispatch = function (_req, res) { 1095 | res.end('Hi!') 1096 | } 1097 | 1098 | inject(dispatch, { method: 'get', url: 'http://example.com:8080/hello' }, (err, res) => { 1099 | t.assert.ifError(err) 1100 | t.assert.strictEqual(res.statusCode, 200) 1101 | t.assert.strictEqual(res.payload, 'Hi!') 1102 | done() 1103 | }) 1104 | }) 1105 | 1106 | test('form-data should be handled correctly', (t, done) => { 1107 | t.plan(4) 1108 | 1109 | const dispatch = function (req, res) { 1110 | t.assert.strictEqual(req.headers['transfer-encoding'], undefined) 1111 | let body = '' 1112 | req.on('data', d => { 1113 | body += d 1114 | }) 1115 | req.on('end', () => { 1116 | res.end(body) 1117 | }) 1118 | } 1119 | 1120 | const form = new NpmFormData() 1121 | form.append('my_field', 'my value') 1122 | 1123 | inject(dispatch, { 1124 | method: 'POST', 1125 | url: 'http://example.com:8080/hello', 1126 | headers: { 1127 | // Transfer-encoding is automatically deleted if Stream1 is used 1128 | 'transfer-encoding': 'chunked' 1129 | }, 1130 | payload: form 1131 | }, (err, res) => { 1132 | t.assert.ifError(err) 1133 | t.assert.strictEqual(res.statusCode, 200) 1134 | t.assert.ok(/--.+\r\nContent-Disposition: form-data; name="my_field"\r\n\r\nmy value\r\n--.+--\r\n/.test(res.payload)) 1135 | done() 1136 | }) 1137 | }) 1138 | 1139 | test('path as alias to url', (t, done) => { 1140 | t.plan(2) 1141 | 1142 | const dispatch = function (req, res) { 1143 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1144 | res.end(req.url) 1145 | } 1146 | 1147 | inject(dispatch, { method: 'GET', path: 'http://example.com:8080/hello' }, (err, res) => { 1148 | t.assert.ifError(err) 1149 | t.assert.strictEqual(res.payload, '/hello') 1150 | done() 1151 | }) 1152 | }) 1153 | 1154 | test('Should throw if both path and url are missing', (t) => { 1155 | t.plan(1) 1156 | 1157 | t.assert.throws( 1158 | () => inject(() => {}, { method: 'GET' }, () => {}), 1159 | { message: /must have required property 'url',must have required property 'path'/ } 1160 | ) 1161 | }) 1162 | 1163 | test('chainable api: backwards compatibility for promise (then)', (t, done) => { 1164 | t.plan(1) 1165 | 1166 | const dispatch = function (_req, res) { 1167 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1168 | res.end('hello') 1169 | } 1170 | 1171 | inject(dispatch) 1172 | .get('/') 1173 | .then(res => t.assert.strictEqual(res.payload, 'hello')) 1174 | .catch(t.assert.fail) 1175 | .finally(done) 1176 | }) 1177 | 1178 | test('chainable api: backwards compatibility for promise (catch)', (t, done) => { 1179 | t.plan(1) 1180 | 1181 | function dispatch () { 1182 | throw Error 1183 | } 1184 | 1185 | inject(dispatch) 1186 | .get('/') 1187 | .catch(err => t.assert.ok(err)) 1188 | .finally(done) 1189 | }) 1190 | 1191 | test('chainable api: multiple call of then should return the same promise', (t, done) => { 1192 | t.plan(2) 1193 | let id = 0 1194 | 1195 | function dispatch (_req, res) { 1196 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Request-Id': id }) 1197 | ++id 1198 | t.assert.ok('request id incremented') 1199 | res.end('hello') 1200 | } 1201 | 1202 | const chain = inject(dispatch).get('/') 1203 | chain.then(res => { 1204 | chain.then(rep => { 1205 | t.assert.strictEqual(res.headers['request-id'], rep.headers['request-id']) 1206 | done() 1207 | }) 1208 | }) 1209 | }) 1210 | 1211 | test('chainable api: http methods should work correctly', (t, done) => { 1212 | t.plan(16) 1213 | 1214 | function dispatch (req, res) { 1215 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1216 | res.end(req.method) 1217 | } 1218 | 1219 | httpMethods.forEach((method, index) => { 1220 | inject(dispatch)[method]('http://example.com:8080/hello') 1221 | .end((err, res) => { 1222 | t.assert.ifError(err) 1223 | t.assert.strictEqual(res.body, method.toUpperCase()) 1224 | if (index === httpMethods.length - 1) { 1225 | done() 1226 | } 1227 | }) 1228 | }) 1229 | }) 1230 | 1231 | test('chainable api: http methods should throw if already invoked', (t, done) => { 1232 | t.plan(8) 1233 | 1234 | function dispatch (_req, res) { 1235 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1236 | res.end() 1237 | } 1238 | 1239 | httpMethods.forEach((method, index) => { 1240 | const chain = inject(dispatch)[method]('http://example.com:8080/hello') 1241 | chain.end() 1242 | t.assert.throws(() => chain[method]('/'), Error) 1243 | if (index === httpMethods.length - 1) { 1244 | done() 1245 | } 1246 | }) 1247 | }) 1248 | 1249 | test('chainable api: body method should work correctly', (t, done) => { 1250 | t.plan(2) 1251 | 1252 | function dispatch (req, res) { 1253 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1254 | req.pipe(res) 1255 | } 1256 | 1257 | inject(dispatch) 1258 | .get('http://example.com:8080/hello') 1259 | .body('test') 1260 | .end((err, res) => { 1261 | t.assert.ifError(err) 1262 | t.assert.strictEqual(res.body, 'test') 1263 | done() 1264 | }) 1265 | }) 1266 | 1267 | test('chainable api: cookie', (t, done) => { 1268 | t.plan(2) 1269 | 1270 | function dispatch (req, res) { 1271 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1272 | res.end(req.headers.cookie) 1273 | } 1274 | 1275 | inject(dispatch) 1276 | .get('http://example.com:8080/hello') 1277 | .body('test') 1278 | .cookies({ hello: 'world', fastify: 'rulez' }) 1279 | .end((err, res) => { 1280 | t.assert.ifError(err) 1281 | t.assert.strictEqual(res.body, 'hello=world; fastify=rulez') 1282 | done() 1283 | }) 1284 | }) 1285 | 1286 | test('chainable api: body method should throw if already invoked', (t) => { 1287 | t.plan(1) 1288 | 1289 | function dispatch (_req, res) { 1290 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1291 | res.end() 1292 | } 1293 | 1294 | const chain = inject(dispatch) 1295 | chain 1296 | .get('http://example.com:8080/hello') 1297 | .end() 1298 | t.assert.throws(() => chain.body('test'), Error) 1299 | }) 1300 | 1301 | test('chainable api: headers method should work correctly', (t, done) => { 1302 | t.plan(2) 1303 | 1304 | function dispatch (req, res) { 1305 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1306 | res.end(req.headers.foo) 1307 | } 1308 | 1309 | inject(dispatch) 1310 | .get('http://example.com:8080/hello') 1311 | .headers({ foo: 'bar' }) 1312 | .end((err, res) => { 1313 | t.assert.ifError(err) 1314 | t.assert.strictEqual(res.payload, 'bar') 1315 | done() 1316 | }) 1317 | }) 1318 | 1319 | test('chainable api: headers method should throw if already invoked', (t) => { 1320 | t.plan(1) 1321 | 1322 | function dispatch (_req, res) { 1323 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1324 | res.end() 1325 | } 1326 | 1327 | const chain = inject(dispatch) 1328 | chain 1329 | .get('http://example.com:8080/hello') 1330 | .end() 1331 | t.assert.throws(() => chain.headers({ foo: 'bar' }), Error) 1332 | }) 1333 | 1334 | test('chainable api: payload method should work correctly', (t, done) => { 1335 | t.plan(2) 1336 | 1337 | function dispatch (req, res) { 1338 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1339 | req.pipe(res) 1340 | } 1341 | 1342 | inject(dispatch) 1343 | .get('http://example.com:8080/hello') 1344 | .payload('payload') 1345 | .end((err, res) => { 1346 | t.assert.ifError(err) 1347 | t.assert.strictEqual(res.payload, 'payload') 1348 | done() 1349 | }) 1350 | }) 1351 | 1352 | test('chainable api: payload method should throw if already invoked', (t) => { 1353 | t.plan(1) 1354 | 1355 | function dispatch (_req, res) { 1356 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1357 | res.end() 1358 | } 1359 | 1360 | const chain = inject(dispatch) 1361 | chain 1362 | .get('http://example.com:8080/hello') 1363 | .end() 1364 | t.assert.throws(() => chain.payload('payload'), Error) 1365 | }) 1366 | 1367 | test('chainable api: query method should work correctly', (t, done) => { 1368 | t.plan(2) 1369 | 1370 | const query = { 1371 | message: 'OK', 1372 | xs: ['foo', 'bar'] 1373 | } 1374 | 1375 | function dispatch (req, res) { 1376 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1377 | res.end(req.url) 1378 | } 1379 | 1380 | inject(dispatch) 1381 | .get('http://example.com:8080/hello') 1382 | .query(query) 1383 | .end((err, res) => { 1384 | t.assert.ifError(err) 1385 | t.assert.deepEqual(parseQuery(res.payload), query) 1386 | done() 1387 | }) 1388 | }) 1389 | 1390 | test('chainable api: query method should throw if already invoked', (t) => { 1391 | t.plan(1) 1392 | 1393 | function dispatch (_req, res) { 1394 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1395 | res.end() 1396 | } 1397 | 1398 | const chain = inject(dispatch) 1399 | chain 1400 | .get('http://example.com:8080/hello') 1401 | .end() 1402 | t.assert.throws(() => chain.query({ foo: 'bar' }), Error) 1403 | }) 1404 | 1405 | test('chainable api: invoking end method after promise method should throw', (t) => { 1406 | t.plan(1) 1407 | 1408 | function dispatch (_req, res) { 1409 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1410 | res.end() 1411 | } 1412 | 1413 | const chain = inject(dispatch).get('http://example.com:8080/hello') 1414 | 1415 | chain.then() 1416 | t.assert.throws(() => chain.end(), Error) 1417 | }) 1418 | 1419 | test('chainable api: invoking promise method after end method with a callback function should throw', (t, done) => { 1420 | t.plan(2) 1421 | 1422 | function dispatch (_req, res) { 1423 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1424 | res.end() 1425 | } 1426 | 1427 | const chain = inject(dispatch).get('http://example.com:8080/hello') 1428 | 1429 | chain.end((err) => { 1430 | t.assert.ifError(err) 1431 | done() 1432 | }) 1433 | t.assert.throws(() => chain.then(), Error) 1434 | }) 1435 | 1436 | test('chainable api: invoking promise method after end method without a callback function should work properly', (t, done) => { 1437 | t.plan(1) 1438 | 1439 | function dispatch (_req, res) { 1440 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1441 | res.end('hello') 1442 | } 1443 | 1444 | inject(dispatch) 1445 | .get('http://example.com:8080/hello') 1446 | .end() 1447 | .then(res => t.assert.strictEqual(res.payload, 'hello')) 1448 | .finally(done) 1449 | }) 1450 | 1451 | test('chainable api: invoking end method multiple times should throw', (t) => { 1452 | t.plan(1) 1453 | 1454 | function dispatch (_req, res) { 1455 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1456 | res.end() 1457 | } 1458 | 1459 | const chain = inject(dispatch).get('http://example.com:8080/hello') 1460 | 1461 | chain.end() 1462 | t.assert.throws(() => chain.end(), Error) 1463 | }) 1464 | 1465 | test('chainable api: string url', (t, done) => { 1466 | t.plan(2) 1467 | 1468 | function dispatch (_req, res) { 1469 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1470 | res.end() 1471 | t.assert.ok('pass') 1472 | } 1473 | 1474 | const chain = inject(dispatch, 'http://example.com:8080/hello') 1475 | 1476 | chain.then(() => t.assert.ok('pass')).finally(done) 1477 | }) 1478 | 1479 | test('Response.json() should parse the JSON payload', (t, done) => { 1480 | t.plan(2) 1481 | 1482 | const jsonData = { 1483 | a: 1, 1484 | b: '2' 1485 | } 1486 | 1487 | const dispatch = function (_req, res) { 1488 | res.writeHead(200, { 'Content-Type': 'application/json' }) 1489 | res.end(JSON.stringify(jsonData)) 1490 | } 1491 | 1492 | inject(dispatch, { method: 'GET', path: 'http://example.com:8080/hello' }, (err, res) => { 1493 | t.assert.ifError(err) 1494 | const { json } = res 1495 | t.assert.deepStrictEqual(json(), jsonData) 1496 | done() 1497 | }) 1498 | }) 1499 | 1500 | test('Response.json() should not throw an error if content-type is not application/json', (t, done) => { 1501 | t.plan(2) 1502 | 1503 | const jsonData = { 1504 | a: 1, 1505 | b: '2' 1506 | } 1507 | 1508 | const dispatch = function (_req, res) { 1509 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1510 | res.end(JSON.stringify(jsonData)) 1511 | } 1512 | 1513 | inject(dispatch, { method: 'GET', path: 'http://example.com:8080/hello' }, (err, res) => { 1514 | t.assert.ifError(err) 1515 | const { json } = res 1516 | t.assert.deepStrictEqual(json(), jsonData) 1517 | done() 1518 | }) 1519 | }) 1520 | 1521 | test('Response.json() should throw an error if the payload is not of valid JSON format', (t, done) => { 1522 | t.plan(2) 1523 | 1524 | const dispatch = function (_req, res) { 1525 | res.writeHead(200, { 'Content-Type': 'application/json' }) 1526 | res.end('notAJSON') 1527 | } 1528 | 1529 | inject(dispatch, { method: 'GET', path: 'http://example.com:8080/hello' }, (err, res) => { 1530 | t.assert.ifError(err) 1531 | t.assert.throws(res.json, Error) 1532 | done() 1533 | }) 1534 | }) 1535 | 1536 | test('Response.stream() should provide a Readable stream', (t, done) => { 1537 | const lines = [ 1538 | JSON.stringify({ foo: 'bar' }), 1539 | JSON.stringify({ hello: 'world' }) 1540 | ] 1541 | 1542 | t.plan(2 + lines.length) 1543 | 1544 | const dispatch = function (_req, res) { 1545 | res.writeHead(200, { 'Content-Type': 'multiple/json' }) 1546 | for (const line of lines) { 1547 | res.write(line) 1548 | } 1549 | res.end() 1550 | } 1551 | 1552 | inject(dispatch, { method: 'GET', path: 'http://example.com:8080/hello' }, (err, res) => { 1553 | t.assert.ifError(err) 1554 | const readable = res.stream() 1555 | const payload = [] 1556 | t.assert.strictEqual(readable instanceof Readable, true) 1557 | readable.on('data', function (chunk) { 1558 | payload.push(chunk) 1559 | }) 1560 | readable.on('end', function () { 1561 | for (let i = 0; i < lines.length; i++) { 1562 | t.assert.strictEqual(lines[i], payload[i].toString()) 1563 | } 1564 | done() 1565 | }) 1566 | }) 1567 | }) 1568 | 1569 | test('promise api should auto start (fire and forget)', (t, done) => { 1570 | t.plan(1) 1571 | 1572 | function dispatch (_req, res) { 1573 | t.assert.ok('dispatch called') 1574 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1575 | res.end() 1576 | } 1577 | 1578 | inject(dispatch, 'http://example.com:8080/hello') 1579 | process.nextTick(done) 1580 | }) 1581 | 1582 | test('disabling autostart', (t, done) => { 1583 | t.plan(3) 1584 | 1585 | let called = false 1586 | 1587 | function dispatch (_req, res) { 1588 | t.assert.ok('dispatch called') 1589 | called = true 1590 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1591 | res.end() 1592 | done() 1593 | } 1594 | 1595 | const p = inject(dispatch, { 1596 | url: 'http://example.com:8080/hello', 1597 | autoStart: false 1598 | }) 1599 | 1600 | setImmediate(() => { 1601 | t.assert.strictEqual(called, false) 1602 | p.then(() => { 1603 | t.assert.strictEqual(called, true) 1604 | }) 1605 | }) 1606 | }) 1607 | 1608 | function getTestStream (encoding) { 1609 | const word = 'hi' 1610 | let i = 0 1611 | 1612 | const stream = new Readable({ 1613 | read () { 1614 | this.push(word[i] ? word[i++] : null) 1615 | } 1616 | }) 1617 | 1618 | if (encoding) { 1619 | stream.setEncoding(encoding) 1620 | } 1621 | 1622 | return stream 1623 | } 1624 | 1625 | function readStream (stream, callback) { 1626 | const chunks = [] 1627 | 1628 | stream.on('data', (chunk) => chunks.push(chunk)) 1629 | 1630 | stream.on('end', () => { 1631 | return callback(Buffer.concat(chunks)) 1632 | }) 1633 | } 1634 | 1635 | test('send cookie', (t, done) => { 1636 | t.plan(3) 1637 | const dispatch = function (req, res) { 1638 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1639 | res.end(req.headers.host + '|' + req.headers.cookie) 1640 | } 1641 | 1642 | inject(dispatch, { url: 'http://example.com:8080/hello', cookies: { foo: 'bar', grass: 'àìùòlé' } }, (err, res) => { 1643 | t.assert.ifError(err) 1644 | t.assert.strictEqual(res.payload, 'example.com:8080|foo=bar; grass=%C3%A0%C3%AC%C3%B9%C3%B2l%C3%A9') 1645 | t.assert.strictEqual(res.rawPayload.toString(), 'example.com:8080|foo=bar; grass=%C3%A0%C3%AC%C3%B9%C3%B2l%C3%A9') 1646 | done() 1647 | }) 1648 | }) 1649 | 1650 | test('send cookie with header already set', (t, done) => { 1651 | t.plan(3) 1652 | const dispatch = function (req, res) { 1653 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1654 | res.end(req.headers.host + '|' + req.headers.cookie) 1655 | } 1656 | 1657 | inject(dispatch, { 1658 | url: 'http://example.com:8080/hello', 1659 | headers: { cookie: 'custom=one' }, 1660 | cookies: { foo: 'bar', grass: 'àìùòlé' } 1661 | }, (err, res) => { 1662 | t.assert.ifError(err) 1663 | t.assert.strictEqual(res.payload, 'example.com:8080|custom=one; foo=bar; grass=%C3%A0%C3%AC%C3%B9%C3%B2l%C3%A9') 1664 | t.assert.strictEqual(res.rawPayload.toString(), 'example.com:8080|custom=one; foo=bar; grass=%C3%A0%C3%AC%C3%B9%C3%B2l%C3%A9') 1665 | done() 1666 | }) 1667 | }) 1668 | 1669 | test('read cookie', (t, done) => { 1670 | t.plan(3) 1671 | const dispatch = function (req, res) { 1672 | res.setHeader('Set-Cookie', [ 1673 | 'type=ninja', 1674 | 'dev=me; Expires=Fri, 17 Jan 2020 20:26:08 -0000; Max-Age=1234; Domain=.home.com; Path=/wow; Secure; HttpOnly; SameSite=Strict' 1675 | ]) 1676 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 1677 | res.end(req.headers.host + '|' + req.headers.cookie) 1678 | } 1679 | 1680 | inject(dispatch, { url: 'http://example.com:8080/hello', cookies: { foo: 'bar' } }, (err, res) => { 1681 | t.assert.ifError(err) 1682 | t.assert.strictEqual(res.payload, 'example.com:8080|foo=bar') 1683 | t.assert.deepStrictEqual(res.cookies, [ 1684 | { name: 'type', value: 'ninja' }, 1685 | { 1686 | name: 'dev', 1687 | value: 'me', 1688 | expires: new Date('Fri, 17 Jan 2020 20:26:08 -0000'), 1689 | maxAge: 1234, 1690 | domain: '.home.com', 1691 | path: '/wow', 1692 | secure: true, 1693 | httpOnly: true, 1694 | sameSite: 'Strict' 1695 | } 1696 | ]) 1697 | done() 1698 | }) 1699 | }) 1700 | 1701 | test('correctly handles no string headers', (t, done) => { 1702 | t.plan(3) 1703 | const dispatch = function (req, res) { 1704 | const payload = JSON.stringify(req.headers) 1705 | res.writeHead(200, { 1706 | 'Content-Type': 'application/json', 1707 | integer: 12, 1708 | float: 3.14, 1709 | null: null, 1710 | string: 'string', 1711 | object: { foo: 'bar' }, 1712 | array: [1, 'two', 3], 1713 | date, 1714 | true: true, 1715 | false: false 1716 | }) 1717 | res.end(payload) 1718 | } 1719 | 1720 | const date = new Date(0) 1721 | const headers = { 1722 | integer: 12, 1723 | float: 3.14, 1724 | null: null, 1725 | string: 'string', 1726 | object: { foo: 'bar' }, 1727 | array: [1, 'two', 3], 1728 | date, 1729 | true: true, 1730 | false: false 1731 | } 1732 | 1733 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', headers }, (err, res) => { 1734 | t.assert.ifError(err) 1735 | 1736 | t.assert.deepStrictEqual(res.headers, { 1737 | integer: '12', 1738 | float: '3.14', 1739 | null: 'null', 1740 | string: 'string', 1741 | object: '[object Object]', 1742 | array: ['1', 'two', '3'], 1743 | date: date.toString(), 1744 | true: 'true', 1745 | false: 'false', 1746 | connection: 'keep-alive', 1747 | 'transfer-encoding': 'chunked', 1748 | 'content-type': 'application/json' 1749 | }) 1750 | 1751 | t.assert.deepStrictEqual(JSON.parse(res.payload), { 1752 | integer: '12', 1753 | float: '3.14', 1754 | null: 'null', 1755 | string: 'string', 1756 | object: '[object Object]', 1757 | array: '1,two,3', 1758 | date: date.toString(), 1759 | true: 'true', 1760 | false: 'false', 1761 | host: 'example.com:8080', 1762 | 'user-agent': 'lightMyRequest' 1763 | }) 1764 | done() 1765 | }) 1766 | }) 1767 | 1768 | test('errors for invalid undefined header value', (t, done) => { 1769 | t.plan(1) 1770 | try { 1771 | inject(() => {}, { url: '/', headers: { 'header-key': undefined } }, () => {}) 1772 | } catch (err) { 1773 | t.assert.ok(err) 1774 | done() 1775 | } 1776 | }) 1777 | 1778 | test('example with form-auto-content', (t, done) => { 1779 | t.plan(4) 1780 | const dispatch = function (req, res) { 1781 | let body = '' 1782 | req.on('data', d => { 1783 | body += d 1784 | }) 1785 | req.on('end', () => { 1786 | res.end(body) 1787 | }) 1788 | } 1789 | 1790 | const form = formAutoContent({ 1791 | myField: 'my value', 1792 | myFile: fs.createReadStream('./LICENSE') 1793 | }) 1794 | 1795 | inject(dispatch, { 1796 | method: 'POST', 1797 | url: 'http://example.com:8080/hello', 1798 | payload: form.payload, 1799 | headers: form.headers 1800 | }, (err, res) => { 1801 | t.assert.ifError(err) 1802 | t.assert.strictEqual(res.statusCode, 200) 1803 | t.assert.ok(/--.+\r\nContent-Disposition: form-data; name="myField"\r\n\r\nmy value\r\n--.*/.test(res.payload)) 1804 | t.assert.ok(/--.+\r\nContent-Disposition: form-data; name="myFile"; filename="LICENSE"\r\n.*/.test(res.payload)) 1805 | done() 1806 | }) 1807 | }) 1808 | 1809 | test('simulate invalid alter _lightMyRequest.isDone with end', (t, done) => { 1810 | const dispatch = function (req) { 1811 | req.resume() 1812 | req._lightMyRequest.isDone = true 1813 | req.on('end', () => { 1814 | t.assert.ok('should have end event') 1815 | done() 1816 | }) 1817 | } 1818 | 1819 | inject(dispatch, { method: 'GET', url: '/', simulate: { end: true } }, () => { 1820 | t.assert.fail('should not have reply') 1821 | }) 1822 | }) 1823 | 1824 | test('simulate invalid alter _lightMyRequest.isDone without end', (t, done) => { 1825 | const dispatch = function (req) { 1826 | req.resume() 1827 | req._lightMyRequest.isDone = true 1828 | req.on('end', () => { 1829 | t.assert.fail('should not have end event') 1830 | }) 1831 | done() 1832 | } 1833 | 1834 | inject(dispatch, { method: 'GET', url: '/', simulate: { end: false } }, () => { 1835 | t.assert.fail('should not have reply') 1836 | }) 1837 | }) 1838 | 1839 | test('no error for response destroy', (t, done) => { 1840 | t.plan(2) 1841 | 1842 | const dispatch = function (_req, res) { 1843 | res.destroy() 1844 | } 1845 | 1846 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 1847 | t.assert.equal(res, null) 1848 | t.assert.equal(err.code, 'LIGHT_ECONNRESET') 1849 | done() 1850 | }) 1851 | }) 1852 | 1853 | test('request destory without.assert.ifError', (t, done) => { 1854 | t.plan(2) 1855 | 1856 | const dispatch = function (req) { 1857 | req.destroy() 1858 | } 1859 | 1860 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 1861 | t.assert.equal(err.code, 'LIGHT_ECONNRESET') 1862 | t.assert.equal(res, null) 1863 | done() 1864 | }) 1865 | }) 1866 | 1867 | test('request destory with error', (t, done) => { 1868 | t.plan(2) 1869 | 1870 | const fakeError = new Error('some-err') 1871 | 1872 | const dispatch = function (req) { 1873 | req.destroy(fakeError) 1874 | } 1875 | 1876 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 1877 | t.assert.strictEqual(err, fakeError) 1878 | t.assert.strictEqual(res, null) 1879 | done() 1880 | }) 1881 | }) 1882 | 1883 | test('compatible with stream.finished', (t, done) => { 1884 | t.plan(3) 1885 | 1886 | const dispatch = function (req, res) { 1887 | finished(res, (err) => { 1888 | t.assert.ok(err instanceof Error) 1889 | }) 1890 | 1891 | req.destroy() 1892 | } 1893 | 1894 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 1895 | t.assert.equal(err.code, 'LIGHT_ECONNRESET') 1896 | t.assert.equal(res, null) 1897 | done() 1898 | }) 1899 | }) 1900 | 1901 | test('compatible with eos', (t, done) => { 1902 | t.plan(4) 1903 | 1904 | const dispatch = function (req, res) { 1905 | eos(res, (err) => { 1906 | t.assert.ok(err instanceof Error) 1907 | }) 1908 | 1909 | req.destroy() 1910 | } 1911 | 1912 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 1913 | t.assert.ok(err) 1914 | t.assert.equal(err.code, 'LIGHT_ECONNRESET') 1915 | t.assert.equal(res, null) 1916 | done() 1917 | }) 1918 | }) 1919 | 1920 | test('compatible with stream.finished pipe a Stream', (t, done) => { 1921 | t.plan(3) 1922 | 1923 | const dispatch = function (_req, res) { 1924 | finished(res, (err) => { 1925 | t.assert.ifError(err) 1926 | }) 1927 | 1928 | new Readable({ 1929 | read () { 1930 | this.push('hello world') 1931 | this.push(null) 1932 | } 1933 | }).pipe(res) 1934 | } 1935 | 1936 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 1937 | t.assert.ifError(err) 1938 | t.assert.strictEqual(res.body, 'hello world') 1939 | done() 1940 | }) 1941 | }) 1942 | 1943 | test('compatible with eos, passes error correctly', (t, done) => { 1944 | t.plan(3) 1945 | 1946 | const fakeError = new Error('some-error') 1947 | 1948 | const dispatch = function (req, res) { 1949 | eos(res, (err) => { 1950 | t.assert.strictEqual(err, fakeError) 1951 | }) 1952 | 1953 | req.destroy(fakeError) 1954 | } 1955 | 1956 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 1957 | t.assert.strictEqual(err, fakeError) 1958 | t.assert.strictEqual(res, null) 1959 | done() 1960 | }) 1961 | }) 1962 | 1963 | test('multiple calls to req.destroy should not be called', (t, done) => { 1964 | t.plan(2) 1965 | 1966 | const dispatch = function (req) { 1967 | req.destroy() 1968 | req.destroy() // twice 1969 | } 1970 | 1971 | inject(dispatch, { method: 'GET', url: '/' }, (err, res) => { 1972 | t.assert.equal(res, null) 1973 | t.assert.equal(err.code, 'LIGHT_ECONNRESET') 1974 | done() 1975 | }) 1976 | }) 1977 | 1978 | test('passes headers when using an express app', (t, done) => { 1979 | t.plan(2) 1980 | 1981 | const app = express() 1982 | 1983 | app.get('/hello', (_req, res) => { 1984 | res.setHeader('Some-Fancy-Header', 'a very cool value') 1985 | res.end() 1986 | }) 1987 | 1988 | inject(app, { method: 'GET', url: 'http://example.com:8080/hello' }, (err, res) => { 1989 | t.assert.ifError(err) 1990 | t.assert.strictEqual(res.headers['some-fancy-header'], 'a very cool value') 1991 | done() 1992 | }) 1993 | }) 1994 | 1995 | test('value of request url when using inject should not differ', (t, done) => { 1996 | t.plan(1) 1997 | 1998 | const server = http.createServer() 1999 | 2000 | const dispatch = function (req, res) { 2001 | res.end(req.url) 2002 | } 2003 | 2004 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080//hello', server }) 2005 | .then(res => { t.assert.strictEqual(res.body, '//hello') }) 2006 | .catch(err => t.assert.ifError(err)) 2007 | .finally(done) 2008 | }) 2009 | 2010 | test('Can parse paths with single leading slash', (t) => { 2011 | t.plan(1) 2012 | const parsedURL = parseURL('/test', undefined) 2013 | t.assert.strictEqual(parsedURL.href, 'http://localhost/test') 2014 | }) 2015 | 2016 | test('Can parse paths with two leading slashes', (t) => { 2017 | t.plan(1) 2018 | const parsedURL = parseURL('//test', undefined) 2019 | t.assert.strictEqual(parsedURL.href, 'http://localhost//test') 2020 | }) 2021 | 2022 | test('Can parse URLs with two leading slashes', (t) => { 2023 | t.plan(1) 2024 | const parsedURL = parseURL('https://example.com//test', undefined) 2025 | t.assert.strictEqual(parsedURL.href, 'https://example.com//test') 2026 | }) 2027 | 2028 | test('Can parse URLs with single leading slash', (t) => { 2029 | t.plan(1) 2030 | const parsedURL = parseURL('https://example.com/test', undefined) 2031 | t.assert.strictEqual(parsedURL.href, 'https://example.com/test') 2032 | }) 2033 | 2034 | test('Can abort a request using AbortController/AbortSignal', (t) => { 2035 | t.plan(1) 2036 | 2037 | const dispatch = function () {} 2038 | 2039 | const controller = new AbortController() 2040 | const promise = inject(dispatch, { 2041 | method: 'GET', 2042 | url: 'http://example.com:8080/hello', 2043 | signal: controller.signal 2044 | }) 2045 | controller.abort() 2046 | const wanted = new Error('The operation was aborted') 2047 | wanted.name = 'AbortError' 2048 | t.assert.rejects(promise, wanted) 2049 | }, { skip: globalThis.AbortController == null }) 2050 | 2051 | test('should pass req to ServerResponse', (t, done) => { 2052 | if (parseInt(process.versions.node.split('.', 1)[0], 10) < 16) { 2053 | t.assert.ok('Skip because Node version < 16') 2054 | t.end() 2055 | return 2056 | } 2057 | 2058 | t.plan(5) 2059 | const dispatch = function (req, res) { 2060 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 2061 | res.end(req.headers.host + '|' + req.url) 2062 | } 2063 | 2064 | inject(dispatch, 'http://example.com:8080/hello', (err, res) => { 2065 | t.assert.ifError(err) 2066 | t.assert.ok(res.raw.req === res.raw.res.req) 2067 | t.assert.ok(res.raw.res.req.removeListener) 2068 | t.assert.strictEqual(res.payload, 'example.com:8080|/hello') 2069 | t.assert.strictEqual(res.rawPayload.toString(), 'example.com:8080|/hello') 2070 | done() 2071 | }) 2072 | }) 2073 | 2074 | test('should work with pipeline', (t, done) => { 2075 | t.plan(3) 2076 | const dispatch = function (req, res) { 2077 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 2078 | pipeline(req.headers.host + '|' + req.url, res, () => res.end()) 2079 | } 2080 | 2081 | inject(dispatch, 'http://example.com:8080/hello', (err, res) => { 2082 | t.assert.ifError(err) 2083 | t.assert.strictEqual(res.payload, 'example.com:8080|/hello') 2084 | t.assert.strictEqual(res.rawPayload.toString(), 'example.com:8080|/hello') 2085 | done() 2086 | }) 2087 | }) 2088 | 2089 | test('should leave the headers user-agent and content-type undefined when the headers are explicitly set to undefined in the inject', (t, done) => { 2090 | t.plan(5) 2091 | const dispatch = function (req, res) { 2092 | t.assert.ok(Array.isArray(req.rawHeaders)) 2093 | t.assert.strictEqual(req.headers['user-agent'], undefined) 2094 | t.assert.strictEqual(req.headers['content-type'], undefined) 2095 | t.assert.strictEqual(req.headers['x-foo'], 'bar') 2096 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 2097 | res.end('Ok') 2098 | } 2099 | 2100 | inject(dispatch, { 2101 | url: 'http://example.com:8080/hello', 2102 | method: 'POST', 2103 | headers: { 2104 | 'x-foo': 'bar', 2105 | 'user-agent': undefined, 2106 | 'content-type': undefined 2107 | }, 2108 | body: {} 2109 | }, (err) => { 2110 | t.assert.ifError(err) 2111 | done() 2112 | }) 2113 | }) 2114 | 2115 | test("passes payload when using express' send", (t, done) => { 2116 | t.plan(3) 2117 | 2118 | const app = express() 2119 | 2120 | app.get('/hello', (_req, res) => { 2121 | res.send('some text') 2122 | }) 2123 | 2124 | inject(app, { method: 'GET', url: 'http://example.com:8080/hello' }, (err, res) => { 2125 | t.assert.ifError(err) 2126 | t.assert.strictEqual(res.headers['content-length'], '9') 2127 | t.assert.strictEqual(res.payload, 'some text') 2128 | done() 2129 | }) 2130 | }) 2131 | 2132 | test('request that is destroyed errors', (t, done) => { 2133 | t.plan(2) 2134 | const dispatch = function (req, res) { 2135 | readStream(req, () => { 2136 | req.destroy() // this should be a no-op 2137 | setImmediate(() => { 2138 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 2139 | res.end('hi') 2140 | }) 2141 | }) 2142 | } 2143 | 2144 | const payload = getTestStream() 2145 | 2146 | inject(dispatch, { method: 'POST', url: '/', payload }, (err, res) => { 2147 | t.assert.equal(res, null) 2148 | t.assert.equal(err.code, 'LIGHT_ECONNRESET') 2149 | done() 2150 | }) 2151 | }) 2152 | 2153 | function runFormDataUnitTest (name, { FormData, Blob }) { 2154 | test(`${name} - form-data should be handled correctly`, (t, done) => { 2155 | t.plan(23) 2156 | 2157 | const dispatch = function (req, res) { 2158 | let body = '' 2159 | t.assert.ok(/multipart\/form-data; boundary=----formdata-[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}(--)?$/.test(req.headers['content-type']), 'proper Content-Type provided') 2160 | req.on('data', d => { 2161 | body += d 2162 | }) 2163 | req.on('end', () => { 2164 | res.end(body) 2165 | }) 2166 | } 2167 | 2168 | const form = new FormData() 2169 | form.append('field', 'value') 2170 | form.append('blob', new Blob(['value']), '') 2171 | form.append('blob-with-type', new Blob(['value'], { type: 'text/plain' }), '') 2172 | form.append('blob-with-name', new Blob(['value']), 'file.txt') 2173 | form.append('number', 1) 2174 | 2175 | inject(dispatch, { 2176 | method: 'POST', 2177 | url: 'http://example.com:8080/hello', 2178 | payload: form 2179 | }, (err, res) => { 2180 | t.assert.ifError(err) 2181 | t.assert.strictEqual(res.statusCode, 200) 2182 | 2183 | const regexp = [ 2184 | // header 2185 | /^------formdata-[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}(--)?$/, 2186 | // content-disposition 2187 | /^Content-Disposition: form-data; name="(.*)"(; filename="(.*)")?$/, 2188 | // content-type 2189 | /^Content-Type: (.*)$/ 2190 | ] 2191 | const readable = Readable.from(res.body.split('\r\n')) 2192 | let i = 1 2193 | readable.on('data', function (chunk) { 2194 | switch (i) { 2195 | case 1: 2196 | case 5: 2197 | case 10: 2198 | case 15: 2199 | case 20: { 2200 | // header 2201 | t.assert.ok(regexp[0].test(chunk), 'correct header') 2202 | break 2203 | } 2204 | case 2: 2205 | case 6: 2206 | case 11: 2207 | case 16: { 2208 | // content-disposition 2209 | t.assert.ok(regexp[1].test(chunk), 'correct content-disposition') 2210 | break 2211 | } 2212 | case 7: 2213 | case 12: 2214 | case 17: { 2215 | // content-type 2216 | t.assert.ok(regexp[2].test(chunk), 'correct content-type') 2217 | break 2218 | } 2219 | case 3: 2220 | case 8: 2221 | case 13: 2222 | case 18: { 2223 | // empty 2224 | t.assert.strictEqual(chunk, '', 'correct space') 2225 | break 2226 | } 2227 | case 4: 2228 | case 9: 2229 | case 14: 2230 | case 19: { 2231 | // value 2232 | t.assert.strictEqual(chunk, 'value', 'correct value') 2233 | break 2234 | } 2235 | } 2236 | i++ 2237 | }) 2238 | done() 2239 | }) 2240 | }, { skip: FormData == null || Blob == null }) 2241 | } 2242 | 2243 | // supports >= node@18 2244 | runFormDataUnitTest('native', { FormData: globalThis.FormData, Blob: globalThis.Blob }) 2245 | // supports >= node@16 2246 | runFormDataUnitTest('undici', { FormData: require('undici').FormData, Blob: require('node:buffer').Blob }) 2247 | // supports >= node@14 2248 | runFormDataUnitTest('formdata-node', { FormData: require('formdata-node').FormData, Blob: require('formdata-node').Blob }) 2249 | 2250 | test('QUERY method works', (t, done) => { 2251 | t.plan(3) 2252 | const dispatch = function (req, res) { 2253 | res.writeHead(200, { 'content-type': req.headers['content-type'] }) 2254 | req.pipe(res) 2255 | } 2256 | 2257 | inject(dispatch, { method: 'QUERY', url: '/test', payload: { a: 1 } }, (err, res) => { 2258 | t.assert.ifError(err) 2259 | t.assert.strictEqual(res.headers['content-type'], 'application/json') 2260 | t.assert.strictEqual(res.payload, '{"a":1}') 2261 | done() 2262 | }) 2263 | }) 2264 | 2265 | test('query method works', (t, done) => { 2266 | t.plan(3) 2267 | const dispatch = function (req, res) { 2268 | res.writeHead(200, { 'content-type': req.headers['content-type'] }) 2269 | req.pipe(res) 2270 | } 2271 | 2272 | inject(dispatch, { method: 'query', url: '/test', payload: { a: 1 } }, (err, res) => { 2273 | t.assert.ifError(err) 2274 | t.assert.strictEqual(res.headers['content-type'], 'application/json') 2275 | t.assert.strictEqual(res.payload, '{"a":1}') 2276 | done() 2277 | }) 2278 | }) 2279 | 2280 | test('should return the file content', async (t) => { 2281 | const multerMiddleware = multer({ 2282 | storage: multer.memoryStorage(), 2283 | limits: { 2284 | fileSize: 1024 2285 | } 2286 | }) 2287 | 2288 | const app = express() 2289 | 2290 | app.use((req, res, next) => { 2291 | if (req.headers['content-type'].indexOf('multipart/form-data') === 0) { 2292 | req.multipart = true 2293 | } 2294 | next() 2295 | }) 2296 | 2297 | app.post('/hello', multerMiddleware.single('textFile'), (req, res) => { 2298 | res.send(req.file.buffer.toString('utf8')) 2299 | }) 2300 | app.use((err, req, res, next) => { 2301 | console.warn(err) 2302 | res.status(500).send('Something was wrong') 2303 | }) 2304 | 2305 | const formData = new FormData() 2306 | formData.append('textFile', new Blob(['some data']), 'sample.txt') 2307 | 2308 | const response = await inject(app, { 2309 | method: 'POST', 2310 | url: 'http://example.com:8080/hello', 2311 | payload: formData 2312 | }) 2313 | 2314 | t.assert.equal(response.statusCode, 200) 2315 | t.assert.equal(response.payload, 'some data') 2316 | }) 2317 | -------------------------------------------------------------------------------- /test/request.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | 5 | const Request = require('../lib/request') 6 | 7 | test('aborted property should be false', async (t) => { 8 | const mockReq = { 9 | url: 'http://localhost', 10 | method: 'GET', 11 | headers: {} 12 | } 13 | const req = new Request(mockReq) 14 | 15 | t.assert.strictEqual(req.aborted, false) 16 | }) 17 | -------------------------------------------------------------------------------- /test/response.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | 5 | const Response = require('../lib/response') 6 | 7 | test('multiple calls to res.destroy should not be called', (t, done) => { 8 | t.plan(2) 9 | 10 | const mockReq = {} 11 | const res = new Response(mockReq, (err) => { 12 | t.assert.ok(err) 13 | t.assert.strictEqual(err.code, 'LIGHT_ECONNRESET') 14 | done() 15 | }) 16 | 17 | res.destroy() 18 | res.destroy() 19 | }) 20 | -------------------------------------------------------------------------------- /test/stream.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('node:test') 4 | const fs = require('node:fs') 5 | const test = t.test 6 | const zlib = require('node:zlib') 7 | const express = require('express') 8 | 9 | const inject = require('../index') 10 | 11 | function accumulate (stream, cb) { 12 | const chunks = [] 13 | stream.on('error', cb) 14 | stream.on('data', (chunk) => { 15 | chunks.push(chunk) 16 | }) 17 | stream.on('end', () => { 18 | cb(null, Buffer.concat(chunks)) 19 | }) 20 | } 21 | 22 | test('stream mode - non-chunked payload', (t, done) => { 23 | t.plan(9) 24 | const output = 'example.com:8080|/hello' 25 | 26 | const dispatch = function (req, res) { 27 | res.statusMessage = 'Super' 28 | res.setHeader('x-extra', 'hello') 29 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': output.length }) 30 | res.end(req.headers.host + '|' + req.url) 31 | } 32 | 33 | inject(dispatch, { 34 | url: 'http://example.com:8080/hello', 35 | payloadAsStream: true 36 | }, (err, res) => { 37 | t.assert.ifError(err) 38 | t.assert.strictEqual(res.statusCode, 200) 39 | t.assert.strictEqual(res.statusMessage, 'Super') 40 | t.assert.ok(res.headers.date) 41 | t.assert.deepStrictEqual(res.headers, { 42 | date: res.headers.date, 43 | connection: 'keep-alive', 44 | 'x-extra': 'hello', 45 | 'content-type': 'text/plain', 46 | 'content-length': output.length.toString() 47 | }) 48 | t.assert.strictEqual(res.payload, undefined) 49 | t.assert.strictEqual(res.rawPayload, undefined) 50 | 51 | accumulate(res.stream(), (err, payload) => { 52 | t.assert.ifError(err) 53 | t.assert.strictEqual(payload.toString(), 'example.com:8080|/hello') 54 | done() 55 | }) 56 | }) 57 | }) 58 | 59 | test('stream mode - passes headers', (t, done) => { 60 | t.plan(3) 61 | const dispatch = function (req, res) { 62 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 63 | res.end(req.headers.super) 64 | } 65 | 66 | inject(dispatch, { 67 | method: 'GET', 68 | url: 'http://example.com:8080/hello', 69 | headers: { Super: 'duper' }, 70 | payloadAsStream: true 71 | }, (err, res) => { 72 | t.assert.ifError(err) 73 | accumulate(res.stream(), (err, payload) => { 74 | t.assert.ifError(err) 75 | t.assert.strictEqual(payload.toString(), 'duper') 76 | done() 77 | }) 78 | }) 79 | }) 80 | 81 | test('stream mode - returns chunked payload', (t, done) => { 82 | t.plan(6) 83 | const dispatch = function (_req, res) { 84 | res.writeHead(200, 'OK') 85 | res.write('a') 86 | res.write('b') 87 | res.end() 88 | } 89 | 90 | inject(dispatch, { method: 'GET', url: '/', payloadAsStream: true }, (err, res) => { 91 | t.assert.ifError(err) 92 | t.assert.ok(res.headers.date) 93 | t.assert.ok(res.headers.connection) 94 | t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') 95 | accumulate(res.stream(), (err, payload) => { 96 | t.assert.ifError(err) 97 | t.assert.strictEqual(payload.toString(), 'ab') 98 | done() 99 | }) 100 | }) 101 | }) 102 | 103 | test('stream mode - backpressure', (t, done) => { 104 | t.plan(7) 105 | let expected 106 | const dispatch = function (_req, res) { 107 | res.writeHead(200, 'OK') 108 | res.write('a') 109 | const buf = Buffer.alloc(1024 * 1024).fill('b') 110 | t.assert.strictEqual(res.write(buf), false) 111 | expected = 'a' + buf.toString() 112 | res.on('drain', () => { 113 | res.end() 114 | }) 115 | } 116 | 117 | inject(dispatch, { method: 'GET', url: '/', payloadAsStream: true }, (err, res) => { 118 | t.assert.ifError(err) 119 | t.assert.ok(res.headers.date) 120 | t.assert.ok(res.headers.connection) 121 | t.assert.strictEqual(res.headers['transfer-encoding'], 'chunked') 122 | accumulate(res.stream(), (err, payload) => { 123 | t.assert.ifError(err) 124 | t.assert.strictEqual(payload.toString(), expected) 125 | done() 126 | }) 127 | }) 128 | }) 129 | 130 | test('stream mode - sets trailers in response object', (t, done) => { 131 | t.plan(4) 132 | const dispatch = function (_req, res) { 133 | res.setHeader('Trailer', 'Test') 134 | res.addTrailers({ Test: 123 }) 135 | res.end() 136 | } 137 | 138 | inject(dispatch, { method: 'GET', url: '/', payloadAsStream: true }, (err, res) => { 139 | t.assert.ifError(err) 140 | t.assert.strictEqual(res.headers.trailer, 'Test') 141 | t.assert.strictEqual(res.headers.test, undefined) 142 | t.assert.strictEqual(res.trailers.test, '123') 143 | done() 144 | }) 145 | }) 146 | 147 | test('stream mode - parses zipped payload', (t, done) => { 148 | t.plan(5) 149 | const dispatch = function (_req, res) { 150 | res.writeHead(200, 'OK') 151 | const stream = fs.createReadStream('./package.json') 152 | stream.pipe(zlib.createGzip()).pipe(res) 153 | } 154 | 155 | inject(dispatch, { method: 'GET', url: '/', payloadAsStream: true }, (err, res) => { 156 | t.assert.ifError(err) 157 | fs.readFile('./package.json', { encoding: 'utf-8' }, (err, file) => { 158 | t.assert.ifError(err) 159 | 160 | accumulate(res.stream(), (err, payload) => { 161 | t.assert.ifError(err) 162 | 163 | zlib.unzip(payload, (err, unzipped) => { 164 | t.assert.ifError(err) 165 | t.assert.strictEqual(unzipped.toString('utf-8'), file) 166 | done() 167 | }) 168 | }) 169 | }) 170 | }) 171 | }) 172 | 173 | test('stream mode - returns multi buffer payload', (t, done) => { 174 | t.plan(3) 175 | const dispatch = function (_req, res) { 176 | res.writeHead(200) 177 | res.write('a') 178 | res.write(Buffer.from('b')) 179 | res.end() 180 | } 181 | 182 | inject(dispatch, { method: 'GET', url: '/', payloadAsStream: true }, (err, res) => { 183 | t.assert.ifError(err) 184 | 185 | const chunks = [] 186 | const stream = res.stream() 187 | stream.on('data', (chunk) => { 188 | chunks.push(chunk) 189 | }) 190 | 191 | stream.on('end', () => { 192 | t.assert.strictEqual(chunks.length, 2) 193 | t.assert.strictEqual(Buffer.concat(chunks).toString(), 'ab') 194 | done() 195 | }) 196 | }) 197 | }) 198 | 199 | test('stream mode - returns null payload', (t, done) => { 200 | t.plan(4) 201 | const dispatch = function (_req, res) { 202 | res.writeHead(200, { 'Content-Length': 0 }) 203 | res.end() 204 | } 205 | 206 | inject(dispatch, { method: 'GET', url: '/', payloadAsStream: true }, (err, res) => { 207 | t.assert.ifError(err) 208 | t.assert.strictEqual(res.payload, undefined) 209 | accumulate(res.stream(), (err, payload) => { 210 | t.assert.ifError(err) 211 | t.assert.strictEqual(payload.toString(), '') 212 | done() 213 | }) 214 | }) 215 | }) 216 | 217 | test('stream mode - simulates error', (t, done) => { 218 | t.plan(3) 219 | const dispatch = function (req, res) { 220 | req.on('readable', () => { 221 | }) 222 | 223 | req.on('error', () => { 224 | res.writeHead(200, { 'Content-Length': 0 }) 225 | res.end('error') 226 | }) 227 | } 228 | 229 | const body = 'something special just for you' 230 | inject(dispatch, { method: 'GET', url: '/', payload: body, simulate: { error: true }, payloadAsStream: true }, (err, res) => { 231 | t.assert.ifError(err) 232 | accumulate(res.stream(), (err, payload) => { 233 | t.assert.ifError(err) 234 | t.assert.strictEqual(payload.toString(), 'error') 235 | done() 236 | }) 237 | }) 238 | }) 239 | 240 | test('stream mode - promises support', (t, done) => { 241 | t.plan(1) 242 | const dispatch = function (_req, res) { 243 | res.writeHead(200, { 'Content-Type': 'text/plain' }) 244 | res.end('hello') 245 | } 246 | 247 | inject(dispatch, { method: 'GET', url: 'http://example.com:8080/hello', payloadAsStream: true }) 248 | .then((res) => { 249 | return new Promise((resolve, reject) => { 250 | accumulate(res.stream(), (err, payload) => { 251 | if (err) { 252 | return reject(err) 253 | } 254 | resolve(payload) 255 | }) 256 | }) 257 | }) 258 | .then(payload => t.assert.strictEqual(payload.toString(), 'hello')) 259 | .catch(t.assert.fail) 260 | .finally(done) 261 | }) 262 | 263 | test('stream mode - Response.json() should throw', (t, done) => { 264 | t.plan(2) 265 | 266 | const jsonData = { 267 | a: 1, 268 | b: '2' 269 | } 270 | 271 | const dispatch = function (_req, res) { 272 | res.writeHead(200, { 'Content-Type': 'application/json' }) 273 | res.end(JSON.stringify(jsonData)) 274 | } 275 | 276 | inject(dispatch, { method: 'GET', path: 'http://example.com:8080/hello', payloadAsStream: true }, (err, res) => { 277 | t.assert.ifError(err) 278 | const { json } = res 279 | t.assert.throws(json, Error) 280 | done() 281 | }) 282 | }) 283 | 284 | test('stream mode - error for response destroy', (t, done) => { 285 | t.plan(2) 286 | 287 | const dispatch = function (_req, res) { 288 | res.writeHead(200) 289 | setImmediate(() => { 290 | res.destroy() 291 | }) 292 | } 293 | 294 | inject(dispatch, { method: 'GET', url: '/', payloadAsStream: true }, (err, res) => { 295 | t.assert.ifError(err) 296 | accumulate(res.stream(), (err) => { 297 | t.assert.ok(err) 298 | done() 299 | }) 300 | }) 301 | }) 302 | 303 | test('stream mode - request destroy with error', (t, done) => { 304 | t.plan(3) 305 | 306 | const fakeError = new Error('some-err') 307 | 308 | const dispatch = function (req) { 309 | req.destroy(fakeError) 310 | } 311 | 312 | inject(dispatch, { method: 'GET', url: '/', payloadAsStream: true }, (err, res) => { 313 | t.assert.ok(err) 314 | t.assert.strictEqual(err, fakeError) 315 | t.assert.strictEqual(res, null) 316 | done() 317 | }) 318 | }) 319 | 320 | test('stream mode - Can abort a request using AbortController/AbortSignal', async (t) => { 321 | const dispatch = function (_req, res) { 322 | res.writeHead(200) 323 | } 324 | 325 | const controller = new AbortController() 326 | const res = await inject(dispatch, { 327 | method: 'GET', 328 | url: 'http://example.com:8080/hello', 329 | signal: controller.signal, 330 | payloadAsStream: true 331 | }) 332 | controller.abort() 333 | 334 | await t.assert.rejects(async () => { 335 | for await (const c of res.stream()) { 336 | t.assert.fail(`should not loop, got ${c.toString()}`) 337 | } 338 | }, Error) 339 | }, { skip: globalThis.AbortController == null }) 340 | 341 | test("stream mode - passes payload when using express' send", (t, done) => { 342 | t.plan(4) 343 | 344 | const app = express() 345 | 346 | app.get('/hello', (_req, res) => { 347 | res.send('some text') 348 | }) 349 | 350 | inject(app, { method: 'GET', url: 'http://example.com:8080/hello', payloadAsStream: true }, (err, res) => { 351 | t.assert.ifError(err) 352 | t.assert.strictEqual(res.headers['content-length'], '9') 353 | accumulate(res.stream(), function (err, payload) { 354 | t.assert.ifError(err) 355 | t.assert.strictEqual(payload.toString(), 'some text') 356 | done() 357 | }) 358 | }) 359 | }) 360 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'node:http' 2 | import { Readable } from 'node:stream' 3 | 4 | type HTTPMethods = 'DELETE' | 'delete' | 5 | 'GET' | 'get' | 6 | 'HEAD' | 'head' | 7 | 'PATCH' | 'patch' | 8 | 'POST' | 'post' | 9 | 'PUT' | 'put' | 10 | 'OPTIONS' | 'options' 11 | 12 | type Inject = typeof inject 13 | 14 | declare namespace inject { 15 | 16 | export type DispatchFunc = http.RequestListener 17 | 18 | export type CallbackFunc = (err: Error | undefined, response: Response | undefined) => void 19 | 20 | export type InjectPayload = string | object | Buffer | NodeJS.ReadableStream 21 | 22 | export function isInjection (obj: http.IncomingMessage | http.ServerResponse): boolean 23 | 24 | export interface AbortSignal { 25 | readonly aborted: boolean; 26 | } 27 | 28 | export interface InjectOptions { 29 | url?: string | { 30 | pathname: string 31 | protocol?: string 32 | hostname?: string 33 | port?: string | number 34 | query?: string | { [k: string]: string | string[] } 35 | } 36 | path?: string | { 37 | pathname: string 38 | protocol?: string 39 | hostname?: string 40 | port?: string | number 41 | query?: string | { [k: string]: string | string[] } 42 | } 43 | headers?: http.IncomingHttpHeaders | http.OutgoingHttpHeaders 44 | query?: string | { [k: string]: string | string[] } 45 | simulate?: { 46 | end: boolean, 47 | split: boolean, 48 | error: boolean, 49 | close: boolean 50 | } 51 | authority?: string 52 | remoteAddress?: string 53 | method?: HTTPMethods 54 | validate?: boolean 55 | payload?: InjectPayload 56 | body?: InjectPayload 57 | server?: http.Server 58 | autoStart?: boolean 59 | cookies?: { [k: string]: string }, 60 | signal?: AbortSignal, 61 | Request?: object, 62 | payloadAsStream?: boolean 63 | } 64 | 65 | /** 66 | * https://github.com/nfriedly/set-cookie-parser/blob/3eab8b7d5d12c8ed87832532861c1a35520cf5b3/lib/set-cookie.js#L41 67 | */ 68 | interface Cookie { 69 | name: string; 70 | value: string; 71 | expires?: Date; 72 | maxAge?: number; 73 | secure?: boolean; 74 | httpOnly?: boolean; 75 | sameSite?: string; 76 | [name: string]: unknown 77 | } 78 | 79 | export interface Response { 80 | raw: { 81 | res: http.ServerResponse, 82 | req: http.IncomingMessage 83 | } 84 | rawPayload: Buffer 85 | headers: http.OutgoingHttpHeaders 86 | statusCode: number 87 | statusMessage: string 88 | trailers: { [key: string]: string } 89 | payload: string 90 | body: string 91 | json: () => T 92 | stream: () => Readable 93 | cookies: Array 94 | } 95 | 96 | export interface Chain extends Promise { 97 | delete: (url: string) => Chain 98 | get: (url: string) => Chain 99 | head: (url: string) => Chain 100 | options: (url: string) => Chain 101 | patch: (url: string) => Chain 102 | post: (url: string) => Chain 103 | put: (url: string) => Chain 104 | trace: (url: string) => Chain 105 | body: (body: InjectPayload) => Chain 106 | headers: (headers: http.IncomingHttpHeaders | http.OutgoingHttpHeaders) => Chain 107 | payload: (payload: InjectPayload) => Chain 108 | query: (query: string | { [k: string]: string | string[] }) => Chain 109 | cookies: (query: object) => Chain 110 | end(): Promise 111 | end(callback: CallbackFunc): void 112 | } 113 | 114 | export const inject: Inject 115 | export { inject as default } 116 | } 117 | 118 | declare function inject ( 119 | dispatchFunc: inject.DispatchFunc, 120 | options?: string | inject.InjectOptions 121 | ): inject.Chain 122 | declare function inject ( 123 | dispatchFunc: inject.DispatchFunc, 124 | options: string | inject.InjectOptions, 125 | callback: inject.CallbackFunc 126 | ): void 127 | 128 | export = inject 129 | -------------------------------------------------------------------------------- /types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'node:http' 2 | import { inject, isInjection, Response, DispatchFunc, InjectOptions, Chain } from '..' 3 | import { expectType, expectAssignable, expectNotAssignable } from 'tsd' 4 | import { Readable } from 'node:stream' 5 | 6 | expectAssignable({ url: '/' }) 7 | expectAssignable({ autoStart: true }) 8 | expectAssignable({ autoStart: false }) 9 | expectAssignable({ validate: true }) 10 | expectAssignable({ validate: false }) 11 | 12 | const dispatch: http.RequestListener = function (req, res) { 13 | expectAssignable(req) 14 | expectAssignable(res) 15 | expectType(isInjection(req)) 16 | expectType(isInjection(res)) 17 | 18 | const reply = 'Hello World' 19 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': reply.length }) 20 | res.end(reply) 21 | } 22 | 23 | const expectResponse = function (res: Response | undefined) { 24 | if (!res) { 25 | return 26 | } 27 | expectType(res) 28 | console.log(res.payload) 29 | expectAssignable(res.json) 30 | expectAssignable(res.stream) 31 | expectAssignable(res.raw.res) 32 | expectAssignable(res.raw.req) 33 | expectType(res.stream()) 34 | expectType(res.payload) 35 | expectType(res.body) 36 | expectAssignable>(res.cookies) 37 | const cookie = res.cookies[0] 38 | expectType(cookie.name) 39 | expectType(cookie.value) 40 | expectType(cookie.expires) 41 | expectType(cookie.maxAge) 42 | expectType(cookie.httpOnly) 43 | expectType(cookie.secure) 44 | expectType(cookie.sameSite) 45 | expectType(cookie.additional) 46 | } 47 | 48 | expectType(dispatch) 49 | 50 | inject(dispatch, { method: 'get', url: '/' }, (err, res) => { 51 | expectType(err) 52 | expectResponse(res) 53 | }) 54 | 55 | const url = { 56 | protocol: 'http', 57 | hostname: 'example.com', 58 | port: '8080', 59 | pathname: 'hello', 60 | query: { 61 | test: '1234' 62 | } 63 | } 64 | inject(dispatch, { method: 'get', url }, (err, res) => { 65 | expectType(err) 66 | expectResponse(res) 67 | }) 68 | 69 | inject(dispatch, { method: 'get', url: '/', cookies: { name1: 'value1', value2: 'value2' } }, (err, res) => { 70 | expectType(err) 71 | expectResponse(res) 72 | }) 73 | 74 | inject(dispatch, { method: 'get', url: '/', query: { name1: 'value1', value2: 'value2' } }, (err, res) => { 75 | expectType(err) 76 | expectResponse(res) 77 | }) 78 | 79 | inject(dispatch, { method: 'get', url: '/', query: { name1: ['value1', 'value2'] } }, (err, res) => { 80 | expectType(err) 81 | expectResponse(res) 82 | }) 83 | 84 | inject(dispatch, { method: 'get', url: '/', query: 'name1=value1' }, (err, res) => { 85 | expectType(err) 86 | expectResponse(res) 87 | }) 88 | 89 | inject(dispatch, { method: 'post', url: '/', payload: { name1: 'value1', value2: 'value2' } }, (err, res) => { 90 | expectType(err) 91 | expectResponse(res) 92 | }) 93 | 94 | inject(dispatch, { method: 'post', url: '/', body: { name1: 'value1', value2: 'value2' } }, (err, res) => { 95 | expectType(err) 96 | expectResponse(res) 97 | }) 98 | 99 | expectType( 100 | inject(dispatch) 101 | .get('/') 102 | .end((err, res) => { 103 | expectType(err) 104 | expectType(res) 105 | console.log(res?.payload) 106 | }) 107 | ) 108 | 109 | inject(dispatch) 110 | .get('/') 111 | .then((value) => { 112 | expectType(value) 113 | }) 114 | 115 | expectType(inject(dispatch)) 116 | expectType>(inject(dispatch).end()) 117 | expectType(inject(dispatch, { method: 'get', url: '/' })) 118 | // @ts-ignore tsd supports top-level await, but normal ts does not so ignore 119 | expectType(await inject(dispatch, { method: 'get', url: '/' })) 120 | 121 | type ParsedValue = { field: string } 122 | // @ts-ignore tsd supports top-level await, but normal ts does not so ignore 123 | const response: Response = await inject(dispatch) 124 | const parsedValue: ParsedValue = response.json() 125 | expectType(parsedValue) 126 | 127 | const parsedValueUsingGeneric = response.json() 128 | expectType(parsedValueUsingGeneric) 129 | 130 | expectNotAssignable(response) 131 | 132 | const httpDispatch = function (req: http.IncomingMessage, res: http.ServerResponse) { 133 | expectType(isInjection(req)) 134 | expectType(isInjection(res)) 135 | 136 | const reply = 'Hello World' 137 | res.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Length': reply.length }) 138 | res.end(reply) 139 | } 140 | 141 | inject(httpDispatch, { method: 'get', url: '/' }, (err, res) => { 142 | expectType(err) 143 | expectResponse(res) 144 | }) 145 | 146 | inject(httpDispatch, { method: 'get', url: '/', payloadAsStream: true }, (err, res) => { 147 | expectType(err) 148 | expectResponse(res) 149 | }) 150 | --------------------------------------------------------------------------------