diff --git a/package-lock.json b/package-lock.json index f772bf7..d9f8360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@express-rate-limit/tsconfig": "1.0.2", "@jest/globals": "29.7.0", "@types/express": "4.17.18", - "@types/jest": "29.5.5", "@types/supertest": "2.0.12", "body-parser": "1.20.6", "del-cli": "5.1.0", @@ -30,7 +29,7 @@ "npm-run-all": "4.1.5", "prettier": "3.0.3", "supertest": "6.3.3", - "ts-jest": "29.1.1", + "ts-jest": "29.4.12", "typescript": "5.2.2", "xo": "0.56.0" }, @@ -1896,16 +1895,6 @@ "@types/istanbul-lib-report": "*" } }, - "node_modules/@types/jest": { - "version": "29.5.5", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz", - "integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==", - "dev": true, - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -6376,6 +6365,27 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -8785,8 +8795,7 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/nice-try": { "version": "1.0.5", @@ -11082,37 +11091,43 @@ } }, "node_modules/ts-jest": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", - "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "dependencies": { - "bs-logger": "0.x", - "fast-json-stable-stringify": "2.x", - "jest-util": "^29.0.0", + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" }, "bin": { "ts-jest": "cli.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", - "typescript": ">=4.3 <6" + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { "optional": true }, + "@jest/transform": { + "optional": true + }, "@jest/types": { "optional": true }, @@ -11121,29 +11136,17 @@ }, "esbuild": { "optional": true + }, + "jest-util": { + "optional": true } } }, - "node_modules/ts-jest/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -11151,11 +11154,17 @@ "node": ">=10" } }, - "node_modules/ts-jest/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/ts-jest/node_modules/yargs-parser": { "version": "21.1.1", @@ -11328,6 +11337,19 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -11653,6 +11675,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, "node_modules/wrap-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", diff --git a/package.json b/package.json index 026bb8a..30d0e3a 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "@express-rate-limit/tsconfig": "1.0.2", "@jest/globals": "29.7.0", "@types/express": "4.17.18", - "@types/jest": "29.5.5", "@types/supertest": "2.0.12", "body-parser": "1.20.6", "del-cli": "5.1.0", @@ -99,7 +98,7 @@ "npm-run-all": "4.1.5", "prettier": "3.0.3", "supertest": "6.3.3", - "ts-jest": "29.1.1", + "ts-jest": "29.4.12", "typescript": "5.2.2", "xo": "0.56.0" }, diff --git a/readme.md b/readme.md index b33db12..ef499f2 100644 --- a/readme.md +++ b/readme.md @@ -211,7 +211,11 @@ request timeout. Can be the number itself (in milliseconds) or a (sync/async) function that accepts the Express `req` and `res` objects and then returns a number. -Defaults to `Infinity`. +Defaults to `2147483647` (2^31 - 1 ms, which is ~24 days). + +**Note**: Providing a value higher than `2,147,483,647` will throw an error due +to +[Node.js `setTimeout` limits](https://nodejs.org/api/timers.html#settimeoutcallback-delay-args:~:text=specified%2E-,When%20delay%20is%20larger%20than%202147483647%20or%20less%20than%201%20or%20NaN%2C%20the%20delay%20will%20be%20set%20to%201%2E%20Non%2Dinteger%20delays%20are%20truncated%20to%20an%20integer). For example, for the following configuration: diff --git a/source/slow-down.ts b/source/slow-down.ts index 38576cc..7cea591 100644 --- a/source/slow-down.ts +++ b/source/slow-down.ts @@ -37,6 +37,12 @@ const filterUndefinedOptions = ( return filteredOptions } +/** + * The maximum value for a 32-bit signed integer, which is the maximum + * delay allowed by Node.js setTimeout. + */ +const max32BitSignedInt = 2 ** 31 - 1 + // Consider exporting then extending express-rate-limit's ValidationError class ExpressSlowDownWarning extends Error { name: string @@ -78,6 +84,15 @@ export const slowDown = ( 'The limit/max option is not supported by express-slow-down, please use delayAfter instead.', ) + if ( + typeof notUndefinedOptions.maxDelayMs === 'number' && + notUndefinedOptions.maxDelayMs > max32BitSignedInt + ) { + throw new Error( + `The 'maxDelayMs' option cannot be greater than ${max32BitSignedInt} due to Node.js setTimeout limits.`, + ) + } + // Consolidate the validation options that have been passed by the user, and // apply them later, along with `limit: false`. const validate = @@ -128,7 +143,7 @@ export const slowDown = ( const delayAfter = request[options.requestPropertyName!].limit return (used - delayAfter) * 1000 }, - maxDelayMs: Number.POSITIVE_INFINITY, + maxDelayMs: max32BitSignedInt, requestPropertyName: 'slowDown', // Disable the headers by default, but allow users to override legacyHeaders: false, diff --git a/test/integration/integration-test.ts b/test/integration/integration-test.ts index e9c1941..e2acbd0 100644 --- a/test/integration/integration-test.ts +++ b/test/integration/integration-test.ts @@ -2,6 +2,7 @@ // Tests the middleware with a real Express application. import EventEmitter from 'node:events' +import { jest, describe, expect, it } from '@jest/globals' // eslint-disable-next-line import/no-unassigned-import import 'jest-expect-message' import { type Application } from 'express' @@ -59,7 +60,7 @@ describe('integration', () => { const app = createServer(slowDown({ validate: false })) const delay = await makeTimedRequest(app) - expect(delay, `First resp took too long: ${delay} ms.`).toBeLessThan(100) + expect(delay).toBeLessThan(100) }) it('should apply a small delay to the second request', async () => { @@ -71,15 +72,12 @@ describe('integration', () => { ) let delay = await makeTimedRequest(app) - expect(delay, `First resp took too long: ${delay} ms.`).toBeLessThan(100) + expect(delay).toBeLessThan(100) delay = await makeTimedRequest(app) - expect( - delay, - `Second resp was served too quickly: ${delay} ms.`, - ).toBeGreaterThanOrEqual(100) + expect(delay).toBeGreaterThanOrEqual(100) // Macos CI server is slow, and can add a 100-200ms of extra delay. - expect(delay, `Second resp took too long: ${delay} ms.`).toBeLessThan(400) + expect(delay).toBeLessThan(400) }) it('should apply a larger delay to the subsequent request', async () => { @@ -100,10 +98,8 @@ describe('integration', () => { // Should be about 300ms delay on 4th request - because the multiplier starts at 0 // BUT, this test frequently fails with a delay in the 4-500ms range on CI. // So, loosening up the range a bit here. - expect( - delay >= 250 && delay <= 600, - `Fourth resp was served too fast or slow: ${delay} ms.`, - ).toBe(true) + expect(delay).toBeGreaterThanOrEqual(250) + expect(delay).toBeLessThanOrEqual(600) }) it('should apply a cap of maxDelayMs on the the delay', async () => { @@ -125,13 +121,8 @@ describe('integration', () => { // Should cap the delay so the 4th request delays about 200ms instead of 300ms // this one also likes to fail with too much delay on macOS in CI - expect( - delay, - `Fourth resp was served too fast: ${delay} ms.`, - ).toBeGreaterThanOrEqual(150) - expect(delay, `Fourth resp was served too slow: ${delay} ms.`).toBeLessThan( - 600, - ) + expect(delay).toBeGreaterThanOrEqual(150) + expect(delay).toBeLessThan(600) }) it('should allow delayAfter requests before delaying responses', async () => { @@ -144,20 +135,13 @@ describe('integration', () => { ) let delay = await makeTimedRequest(app) - expect(delay, `First resp was served too slow: ${delay} ms.`).toBeLessThan( - 50, - ) + expect(delay).toBeLessThan(50) delay = await makeTimedRequest(app) - expect(delay, `Second resp was served too slow: ${delay} ms.`).toBeLessThan( - 50, - ) + expect(delay).toBeLessThan(50) delay = await makeTimedRequest(app) - expect( - delay > 50 && delay < 150, - `Third request outside of range: ${delay} ms.`, - ).toBe(true) + expect(delay > 50 && delay < 150).toBe(true) }) it('should allow delayAfter to be a function', async () => { @@ -170,20 +154,13 @@ describe('integration', () => { ) let delay = await makeTimedRequest(app) - expect(delay, `First resp was served too slow: ${delay} ms.`).toBeLessThan( - 50, - ) + expect(delay).toBeLessThan(50) delay = await makeTimedRequest(app) - expect(delay, `Second resp was served too slow: ${delay} ms.`).toBeLessThan( - 50, - ) + expect(delay).toBeLessThan(50) delay = await makeTimedRequest(app) - expect( - delay > 50 && delay < 150, - `Third request outside of range: ${delay} ms.`, - ).toBe(true) + expect(delay > 50 && delay < 150).toBe(true) }) it('should (eventually) return to full speed', async () => { @@ -205,9 +182,7 @@ describe('integration', () => { await sleep(500) const delay = await makeTimedRequest(app) - expect(delay, `Fourth resp was served too slow: ${delay} ms.`).toBeLessThan( - 50, - ) + expect(delay).toBeLessThan(50) }) it('should work repeatedly (issues #2 & #3)', async () => { @@ -228,9 +203,7 @@ describe('integration', () => { await sleep(60) let delay = await makeTimedRequest(app) - expect(delay, `Fourth resp was served too slow: ${delay} ms.`).toBeLessThan( - 50, - ) + expect(delay).toBeLessThan(50) await Promise.all([ request(app).get('/'), // 1st - no delay @@ -239,10 +212,7 @@ describe('integration', () => { await sleep(60) delay = await makeTimedRequest(app) - expect( - delay, - `Eventual resp was served too slow: ${delay} ms.`, - ).toBeLessThan(50) + expect(delay).toBeLessThan(50) }) it('should allow individual IP to be reset', async () => { @@ -308,10 +278,7 @@ describe('integration', () => { ) await request(app).get('/') - expect( - store.decrementWasCalled, - '`decrement` was not called on the store', - ).toBeTruthy() + expect(store.decrementWasCalled).toBeTruthy() }) it('should decrement hits with failed response and skipFailedRequests', async () => { @@ -325,10 +292,7 @@ describe('integration', () => { ) await request(app).get('/error').expect(400) - expect( - store.decrementWasCalled, - '`decrement` was not called on the store', - ).toBeTruthy() + expect(store.decrementWasCalled).toBeTruthy() }) it('should decrement hits with closed response and skipFailedRequests', async () => { @@ -349,10 +313,7 @@ describe('integration', () => { // eslint-disable-next-line no-promise-executor-return await new Promise((resolve) => setTimeout(resolve, 200)) - expect( - store.decrementWasCalled, - '`decrement` was not called on the store', - ).toBeTruthy() + expect(store.decrementWasCalled).toBeTruthy() }) it('should decrement hits with response emitting error and skipFailedRequests', async () => { @@ -366,10 +327,7 @@ describe('integration', () => { ) await request(app).get('/crash') - expect( - store.decrementWasCalled, - '`decrement` was not called on the store', - ).toBeTruthy() + expect(store.decrementWasCalled).toBeTruthy() }) it('should not decrement hits with success response and skipFailedRequests', async () => { @@ -383,10 +341,7 @@ describe('integration', () => { ) await request(app).get('/') - expect( - store.decrementWasCalled, - '`decrement` was called on the store', - ).toBeFalsy() + expect(store.decrementWasCalled).toBeFalsy() }) it('should not execute slow down timer in case of req closed during delay', async () => { diff --git a/test/library/connection-test.ts b/test/library/connection-test.ts index 1dad943..f0ea580 100644 --- a/test/library/connection-test.ts +++ b/test/library/connection-test.ts @@ -2,7 +2,14 @@ // Tests the behaviour upon abrupt connection closure import EventEmitter from 'node:events' -import { jest } from '@jest/globals' +import { + jest, + describe, + beforeEach, + afterEach, + it, + expect, +} from '@jest/globals' import slowDown from '../../source/index.js' describe('connection', () => { diff --git a/test/library/delay-test.ts b/test/library/delay-test.ts index 79c2ccb..0240a8f 100644 --- a/test/library/delay-test.ts +++ b/test/library/delay-test.ts @@ -1,7 +1,14 @@ // /test/library/delay-test.ts // Tests the delaying mechanism -import { jest } from '@jest/globals' +import { + jest, + describe, + beforeEach, + afterEach, + it, + expect, +} from '@jest/globals' import slowDown from '../../source/index.js' import { expectDelay, expectNoDelay } from '../helpers/requests.js' @@ -111,4 +118,35 @@ describe('slowdown', () => { await expectNoDelay(instance) }) + + it('should throw an error if maxDelayMs is greater than 32-bit signed integer', async () => { + expect(() => { + slowDown({ + maxDelayMs: 2_147_483_648, + }) + }).toThrow( + "The 'maxDelayMs' option cannot be greater than 2147483647 due to Node.js setTimeout limits.", + ) + }) + + it('should cap the delay at max32BitSignedInt by default', async () => { + const instance = slowDown({ + validate: false, + delayAfter: 0, + delayMs: () => 3_000_000_000, + }) + + await expectDelay(instance, 2_147_483_647) + }) + + it('should allow setting maxDelayMs to exactly max32BitSignedInt', async () => { + const instance = slowDown({ + validate: false, + delayAfter: 0, + delayMs: () => 3_000_000_000, + maxDelayMs: 2_147_483_647, + }) + + await expectDelay(instance, 2_147_483_647) + }) }) diff --git a/test/library/instance-api-test.ts b/test/library/instance-api-test.ts index ba8cb77..78541b0 100644 --- a/test/library/instance-api-test.ts +++ b/test/library/instance-api-test.ts @@ -1,7 +1,7 @@ // /test/library/instance-api-test.ts // Tests the instance API -import { jest } from '@jest/globals' +import { jest, describe, beforeEach, afterEach, it } from '@jest/globals' import slowDown from '../../source/index.js' import { expectDelay, expectNoDelay } from '../helpers/requests.js' diff --git a/test/library/middleware-test.ts b/test/library/middleware-test.ts index 897aee7..2b29c70 100644 --- a/test/library/middleware-test.ts +++ b/test/library/middleware-test.ts @@ -3,7 +3,14 @@ import EventEmitter from 'node:events' import process from 'node:process' -import { jest, expect, it, beforeEach, afterEach } from '@jest/globals' +import { + jest, + describe, + beforeEach, + afterEach, + it, + expect, +} from '@jest/globals' import slowDown from '../../source/index.js' import { expectDelay, expectNoDelay } from '../helpers/requests.js' import { MockStore } from '../helpers/mock-stores.js' diff --git a/test/library/options-test.ts b/test/library/options-test.ts index 71bafbb..46cb463 100644 --- a/test/library/options-test.ts +++ b/test/library/options-test.ts @@ -1,6 +1,14 @@ // /test/library/options-test.ts // Tests the parsing/handling of options passed in by the user +import { + jest, + describe, + beforeEach, + afterEach, + it, + expect, +} from '@jest/globals' import slowDown from '../../source/index.js' import { expectNoDelay } from '../helpers/requests.js' diff --git a/test/library/store-test.ts b/test/library/store-test.ts index 3bc044b..78ee755 100644 --- a/test/library/store-test.ts +++ b/test/library/store-test.ts @@ -1,7 +1,14 @@ // /test/library/store-test.ts // Tests the store with the middleware -import { jest } from '@jest/globals' +import { + jest, + describe, + beforeEach, + afterEach, + it, + expect, +} from '@jest/globals' import slowDown from '../../source/index.js' import { MockStore,