express-rate-limit 6.9.0 → 6.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.md CHANGED
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to
7
7
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [6.10.0](https://github.com/express-rate-limit/express-rate-limit/releases/tag/v6.10.0)
10
+
11
+ ### Added
12
+
13
+ - Support for combined `RateLimit` header from the
14
+ [RateLimit header fields for HTTP standardization draft](https://github.com/ietf-wg-httpapi/ratelimit-headers)
15
+ adopted by the IETF. Enable by setting `standardHeaders: 'draft-7'`
16
+ - New `standardHeaders: 'draft-6'` option, treated equivalent to
17
+ `standardHeaders: true` from previous releases. (`true` and `false` are still
18
+ supported.)
19
+ - New `RateLimit-Policy` header added when `standardHeaders` is set to
20
+ `'draft-6'`, `'draft-7'`, or `true`
21
+ - Warning when using deprecated `draft_polli_ratelimit_headers` option
22
+ - Warning when using deprecated `onLimitReached` option
23
+ - Warning when `totalHits` value returned from Store is invalid
24
+
9
25
  ## [6.9.0](https://github.com/express-rate-limit/express-rate-limit/releases/tag/v6.9.0)
10
26
 
11
27
  ### Added
package/dist/index.cjs CHANGED
@@ -31,6 +31,59 @@ __export(source_exports, {
31
31
  });
32
32
  module.exports = __toCommonJS(source_exports);
33
33
 
34
+ // source/headers.ts
35
+ var getResetSeconds = (resetTime, windowMs) => {
36
+ let resetSeconds = void 0;
37
+ if (resetTime) {
38
+ const deltaSeconds = Math.ceil((resetTime.getTime() - Date.now()) / 1e3);
39
+ resetSeconds = Math.max(0, deltaSeconds);
40
+ } else if (windowMs) {
41
+ resetSeconds = Math.ceil(windowMs / 1e3);
42
+ }
43
+ return resetSeconds;
44
+ };
45
+ var setLegacyHeaders = (response, info) => {
46
+ if (response.headersSent)
47
+ return;
48
+ response.setHeader("X-RateLimit-Limit", info.limit);
49
+ response.setHeader("X-RateLimit-Remaining", info.remaining);
50
+ if (info.resetTime instanceof Date) {
51
+ response.setHeader("Date", (/* @__PURE__ */ new Date()).toUTCString());
52
+ response.setHeader(
53
+ "X-RateLimit-Reset",
54
+ Math.ceil(info.resetTime.getTime() / 1e3)
55
+ );
56
+ }
57
+ };
58
+ var setDraft6Headers = (response, info, windowMs) => {
59
+ if (response.headersSent)
60
+ return;
61
+ const windowSeconds = Math.ceil(windowMs / 1e3);
62
+ const resetSeconds = getResetSeconds(info.resetTime);
63
+ response.setHeader("RateLimit-Policy", `${info.limit};w=${windowSeconds}`);
64
+ response.setHeader("RateLimit-Limit", info.limit);
65
+ response.setHeader("RateLimit-Remaining", info.remaining);
66
+ if (resetSeconds)
67
+ response.setHeader("RateLimit-Reset", resetSeconds);
68
+ };
69
+ var setDraft7Headers = (response, info, windowMs) => {
70
+ if (response.headersSent)
71
+ return;
72
+ const windowSeconds = Math.ceil(windowMs / 1e3);
73
+ const resetSeconds = getResetSeconds(info.resetTime, windowMs);
74
+ response.setHeader("RateLimit-Policy", `${info.limit};w=${windowSeconds}`);
75
+ response.setHeader(
76
+ "RateLimit",
77
+ `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`
78
+ );
79
+ };
80
+ var setRetryAfterHeader = (response, info, windowMs) => {
81
+ if (response.headersSent)
82
+ return;
83
+ const resetSeconds = getResetSeconds(info.resetTime, windowMs);
84
+ response.setHeader("Retry-After", resetSeconds);
85
+ };
86
+
34
87
  // source/validations.ts
35
88
  var import_node_net = require("net");
36
89
  var ValidationError = class extends Error {
@@ -38,12 +91,12 @@ var ValidationError = class extends Error {
38
91
  * The code must be a string, in snake case and all capital, that starts with
39
92
  * the substring `ERR_ERL_`.
40
93
  *
41
- * The message must be a string, starting with a lowercase character,
94
+ * The message must be a string, starting with an uppercase character,
42
95
  * describing the issue in detail.
43
96
  */
44
97
  constructor(code, message) {
45
98
  const url = `https://express-rate-limit.github.io/${code}/`;
46
- super(`${message} See ${url} for more information on this error.`);
99
+ super(`${message} See ${url} for more information.`);
47
100
  __publicField(this, "name");
48
101
  __publicField(this, "code");
49
102
  __publicField(this, "help");
@@ -52,6 +105,8 @@ var ValidationError = class extends Error {
52
105
  this.help = url;
53
106
  }
54
107
  };
108
+ var ChangeWarning = class extends ValidationError {
109
+ };
55
110
  var _Validations = class _Validations {
56
111
  constructor(enabled) {
57
112
  // eslint-disable-next-line @typescript-eslint/parameter-properties
@@ -129,6 +184,22 @@ var _Validations = class _Validations {
129
184
  }
130
185
  });
131
186
  }
187
+ /**
188
+ * Ensures totalHits value from store is a positive integer.
189
+ *
190
+ * @param hits {any} - The `totalHits` returned by the store.
191
+ */
192
+ positiveHits(hits) {
193
+ this.wrap(() => {
194
+ if (typeof hits !== "number" || hits < 1 || hits !== Math.round(hits)) {
195
+ throw new ValidationError(
196
+ "ERR_ERL_INVALID_HITS",
197
+ `The totalHits value returned from the store must be a positive integer, got ${hits}`
198
+ // eslint-disable-line @typescript-eslint/restrict-template-expressions
199
+ );
200
+ }
201
+ });
202
+ }
132
203
  /**
133
204
  * Ensures a given key is incremented only once per request.
134
205
  *
@@ -160,6 +231,78 @@ var _Validations = class _Validations {
160
231
  keys.push(key);
161
232
  });
162
233
  }
234
+ /**
235
+ * Warns the user that the behaviour for `max: 0` is changing in the next
236
+ * major release.
237
+ *
238
+ * @param max {number} - The maximum number of hits per client.
239
+ *
240
+ * @returns {void}
241
+ */
242
+ max(max) {
243
+ this.wrap(() => {
244
+ if (max === 0) {
245
+ throw new ChangeWarning(
246
+ "WRN_ERL_MAX_ZERO",
247
+ `Setting max to 0 disables rate limiting in express-rate-limit v6 and older, but will cause all requests to be blocked in v7`
248
+ );
249
+ }
250
+ });
251
+ }
252
+ /**
253
+ * Warns the user that the `draft_polli_ratelimit_headers` option is deprecated
254
+ * and will be removed in the next major release.
255
+ *
256
+ * @param draft_polli_ratelimit_headers {boolean|undefined} - The now-deprecated setting that was used to enable standard headers.
257
+ *
258
+ * @returns {void}
259
+ */
260
+ draftPolliHeaders(draft_polli_ratelimit_headers) {
261
+ this.wrap(() => {
262
+ if (draft_polli_ratelimit_headers) {
263
+ throw new ChangeWarning(
264
+ "WRN_ERL_DEPRECATED_DRAFT_POLLI_HEADERS",
265
+ `The draft_polli_ratelimit_headers configuration option is deprecated and will be removed in express-rate-limit v7, please set standardHeaders: 'draft-6' instead.`
266
+ );
267
+ }
268
+ });
269
+ }
270
+ /**
271
+ * Warns the user that the `onLimitReached` option is deprecated and will be removed in the next
272
+ * major release.
273
+ *
274
+ * @param onLimitReached {function|undefined} - The maximum number of hits per client.
275
+ *
276
+ * @returns {void}
277
+ */
278
+ onLimitReached(onLimitReached) {
279
+ this.wrap(() => {
280
+ if (onLimitReached) {
281
+ throw new ChangeWarning(
282
+ "WRN_ERL_DEPRECATED_ON_LIMIT_REACHED",
283
+ `The onLimitReached configuration option is deprecated and will be removed in express-rate-limit v7.`
284
+ );
285
+ }
286
+ });
287
+ }
288
+ /**
289
+ * Warns the user when the selected headers option requires a reset time but
290
+ * the store does not provide one.
291
+ *
292
+ * @param resetTime {Date | undefined} - The timestamp when the client's hit count will be reset.
293
+ *
294
+ * @returns {void}
295
+ */
296
+ headersResetTime(resetTime) {
297
+ this.wrap(() => {
298
+ if (!resetTime) {
299
+ throw new ValidationError(
300
+ "ERR_ERL_HEADERS_NO_RESET",
301
+ `standardHeaders: 'draft-7' requires a 'resetTime', but the store did not provide one. The 'windowMs' value will be used instead, which may cause clients to wait longer than necessary.`
302
+ );
303
+ }
304
+ });
305
+ }
163
306
  wrap(validation) {
164
307
  if (!this.enabled) {
165
308
  return;
@@ -167,7 +310,10 @@ var _Validations = class _Validations {
167
310
  try {
168
311
  validation.call(this);
169
312
  } catch (error) {
170
- console.error(error);
313
+ if (error instanceof ChangeWarning)
314
+ console.warn(error);
315
+ else
316
+ console.error(error);
171
317
  }
172
318
  }
173
319
  };
@@ -319,6 +465,7 @@ var promisifyStore = (passedStore) => {
319
465
  async resetKey(key) {
320
466
  return legacyStore.resetKey(key);
321
467
  }
468
+ /* istanbul ignore next */
322
469
  async resetAll() {
323
470
  if (typeof legacyStore.resetAll === "function")
324
471
  return legacyStore.resetAll();
@@ -347,13 +494,20 @@ var parseOptions = (passedOptions) => {
347
494
  var _a, _b, _c, _d;
348
495
  const notUndefinedOptions = omitUndefinedOptions(passedOptions);
349
496
  const validations = new Validations((_a = notUndefinedOptions == null ? void 0 : notUndefinedOptions.validate) != null ? _a : true);
497
+ validations.draftPolliHeaders(
498
+ notUndefinedOptions.draft_polli_ratelimit_headers
499
+ );
500
+ validations.onLimitReached(notUndefinedOptions.onLimitReached);
501
+ let standardHeaders = (_b = notUndefinedOptions.standardHeaders) != null ? _b : false;
502
+ if (standardHeaders === true || standardHeaders === void 0 && notUndefinedOptions.draft_polli_ratelimit_headers) {
503
+ standardHeaders = "draft-6";
504
+ }
350
505
  const config = {
351
506
  windowMs: 60 * 1e3,
352
507
  max: 5,
353
508
  message: "Too many requests, please try again later.",
354
509
  statusCode: 429,
355
- legacyHeaders: (_b = passedOptions.headers) != null ? _b : true,
356
- standardHeaders: (_c = passedOptions.draft_polli_ratelimit_headers) != null ? _c : false,
510
+ legacyHeaders: (_c = passedOptions.headers) != null ? _c : true,
357
511
  requestPropertyName: "rateLimit",
358
512
  skipFailedRequests: false,
359
513
  skipSuccessfulRequests: false,
@@ -372,13 +526,15 @@ var parseOptions = (passedOptions) => {
372
526
  response
373
527
  ) : config.message;
374
528
  if (!response.writableEnded) {
375
- response.send(message != null ? message : "Too many requests, please try again later.");
529
+ response.send(message);
376
530
  }
377
531
  },
378
532
  onLimitReached(_request, _response, _optionsUsed) {
379
533
  },
380
- // Allow the options object to be overriden by the options passed to the middleware.
534
+ // Allow the default options to be overriden by the options passed to the middleware.
381
535
  ...notUndefinedOptions,
536
+ // `standardHeaders` is resolved into a draft version above, use that.
537
+ standardHeaders,
382
538
  // Note that this field is declared after the user's options are spread in,
383
539
  // so that this field doesn't get overriden with an un-promisified store!
384
540
  store: promisifyStore((_d = notUndefinedOptions.store) != null ? _d : new MemoryStore()),
@@ -414,40 +570,27 @@ var rateLimit = (passedOptions) => {
414
570
  const augmentedRequest = request;
415
571
  const key = await config.keyGenerator(request, response);
416
572
  const { totalHits, resetTime } = await config.store.increment(key);
573
+ config.validations.positiveHits(totalHits);
417
574
  config.validations.singleCount(request, config.store, key);
418
575
  const retrieveQuota = typeof config.max === "function" ? config.max(request, response) : config.max;
419
576
  const maxHits = await retrieveQuota;
420
- augmentedRequest[config.requestPropertyName] = {
577
+ config.validations.max(maxHits);
578
+ const info = {
421
579
  limit: maxHits,
422
580
  current: totalHits,
423
581
  remaining: Math.max(maxHits - totalHits, 0),
424
582
  resetTime
425
583
  };
584
+ augmentedRequest[config.requestPropertyName] = info;
426
585
  if (config.legacyHeaders && !response.headersSent) {
427
- response.setHeader("X-RateLimit-Limit", maxHits);
428
- response.setHeader(
429
- "X-RateLimit-Remaining",
430
- augmentedRequest[config.requestPropertyName].remaining
431
- );
432
- if (resetTime instanceof Date) {
433
- response.setHeader("Date", (/* @__PURE__ */ new Date()).toUTCString());
434
- response.setHeader(
435
- "X-RateLimit-Reset",
436
- Math.ceil(resetTime.getTime() / 1e3)
437
- );
438
- }
586
+ setLegacyHeaders(response, info);
439
587
  }
440
588
  if (config.standardHeaders && !response.headersSent) {
441
- response.setHeader("RateLimit-Limit", maxHits);
442
- response.setHeader(
443
- "RateLimit-Remaining",
444
- augmentedRequest[config.requestPropertyName].remaining
445
- );
446
- if (resetTime) {
447
- const deltaSeconds = Math.ceil(
448
- (resetTime.getTime() - Date.now()) / 1e3
449
- );
450
- response.setHeader("RateLimit-Reset", Math.max(0, deltaSeconds));
589
+ if (config.standardHeaders === "draft-6") {
590
+ setDraft6Headers(response, info, config.windowMs);
591
+ } else if (config.standardHeaders === "draft-7") {
592
+ config.validations.headersResetTime(info.resetTime);
593
+ setDraft7Headers(response, info, config.windowMs);
451
594
  }
452
595
  }
453
596
  if (config.skipFailedRequests || config.skipSuccessfulRequests) {
@@ -483,8 +626,8 @@ var rateLimit = (passedOptions) => {
483
626
  }
484
627
  config.validations.disable();
485
628
  if (maxHits && totalHits > maxHits) {
486
- if ((config.legacyHeaders || config.standardHeaders) && !response.headersSent) {
487
- response.setHeader("Retry-After", Math.ceil(config.windowMs / 1e3));
629
+ if (config.legacyHeaders || config.standardHeaders) {
630
+ setRetryAfterHeader(response, info, config.windowMs);
488
631
  }
489
632
  config.handler(request, response, next, options);
490
633
  return;
package/dist/index.d.cts CHANGED
@@ -139,6 +139,7 @@ export type Store = {
139
139
  */
140
140
  localKeys?: boolean;
141
141
  };
142
+ export type DraftHeadersVersion = "draft-6" | "draft-7";
142
143
  /**
143
144
  * The configuration options for the rate limiter.
144
145
  */
@@ -183,7 +184,7 @@ export type Options = {
183
184
  *
184
185
  * Defaults to `false` (for backward compatibility, but its use is recommended).
185
186
  */
186
- standardHeaders: boolean;
187
+ standardHeaders: boolean | DraftHeadersVersion;
187
188
  /**
188
189
  * The name of the property on the request object to store the rate limit info.
189
190
  *
package/dist/index.d.mts CHANGED
@@ -139,6 +139,7 @@ export type Store = {
139
139
  */
140
140
  localKeys?: boolean;
141
141
  };
142
+ export type DraftHeadersVersion = "draft-6" | "draft-7";
142
143
  /**
143
144
  * The configuration options for the rate limiter.
144
145
  */
@@ -183,7 +184,7 @@ export type Options = {
183
184
  *
184
185
  * Defaults to `false` (for backward compatibility, but its use is recommended).
185
186
  */
186
- standardHeaders: boolean;
187
+ standardHeaders: boolean | DraftHeadersVersion;
187
188
  /**
188
189
  * The name of the property on the request object to store the rate limit info.
189
190
  *
package/dist/index.d.ts CHANGED
@@ -139,6 +139,7 @@ export type Store = {
139
139
  */
140
140
  localKeys?: boolean;
141
141
  };
142
+ export type DraftHeadersVersion = "draft-6" | "draft-7";
142
143
  /**
143
144
  * The configuration options for the rate limiter.
144
145
  */
@@ -183,7 +184,7 @@ export type Options = {
183
184
  *
184
185
  * Defaults to `false` (for backward compatibility, but its use is recommended).
185
186
  */
186
- standardHeaders: boolean;
187
+ standardHeaders: boolean | DraftHeadersVersion;
187
188
  /**
188
189
  * The name of the property on the request object to store the rate limit info.
189
190
  *
package/dist/index.mjs CHANGED
@@ -5,6 +5,59 @@ var __publicField = (obj, key, value) => {
5
5
  return value;
6
6
  };
7
7
 
8
+ // source/headers.ts
9
+ var getResetSeconds = (resetTime, windowMs) => {
10
+ let resetSeconds = void 0;
11
+ if (resetTime) {
12
+ const deltaSeconds = Math.ceil((resetTime.getTime() - Date.now()) / 1e3);
13
+ resetSeconds = Math.max(0, deltaSeconds);
14
+ } else if (windowMs) {
15
+ resetSeconds = Math.ceil(windowMs / 1e3);
16
+ }
17
+ return resetSeconds;
18
+ };
19
+ var setLegacyHeaders = (response, info) => {
20
+ if (response.headersSent)
21
+ return;
22
+ response.setHeader("X-RateLimit-Limit", info.limit);
23
+ response.setHeader("X-RateLimit-Remaining", info.remaining);
24
+ if (info.resetTime instanceof Date) {
25
+ response.setHeader("Date", (/* @__PURE__ */ new Date()).toUTCString());
26
+ response.setHeader(
27
+ "X-RateLimit-Reset",
28
+ Math.ceil(info.resetTime.getTime() / 1e3)
29
+ );
30
+ }
31
+ };
32
+ var setDraft6Headers = (response, info, windowMs) => {
33
+ if (response.headersSent)
34
+ return;
35
+ const windowSeconds = Math.ceil(windowMs / 1e3);
36
+ const resetSeconds = getResetSeconds(info.resetTime);
37
+ response.setHeader("RateLimit-Policy", `${info.limit};w=${windowSeconds}`);
38
+ response.setHeader("RateLimit-Limit", info.limit);
39
+ response.setHeader("RateLimit-Remaining", info.remaining);
40
+ if (resetSeconds)
41
+ response.setHeader("RateLimit-Reset", resetSeconds);
42
+ };
43
+ var setDraft7Headers = (response, info, windowMs) => {
44
+ if (response.headersSent)
45
+ return;
46
+ const windowSeconds = Math.ceil(windowMs / 1e3);
47
+ const resetSeconds = getResetSeconds(info.resetTime, windowMs);
48
+ response.setHeader("RateLimit-Policy", `${info.limit};w=${windowSeconds}`);
49
+ response.setHeader(
50
+ "RateLimit",
51
+ `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`
52
+ );
53
+ };
54
+ var setRetryAfterHeader = (response, info, windowMs) => {
55
+ if (response.headersSent)
56
+ return;
57
+ const resetSeconds = getResetSeconds(info.resetTime, windowMs);
58
+ response.setHeader("Retry-After", resetSeconds);
59
+ };
60
+
8
61
  // source/validations.ts
9
62
  import { isIP } from "net";
10
63
  var ValidationError = class extends Error {
@@ -12,12 +65,12 @@ var ValidationError = class extends Error {
12
65
  * The code must be a string, in snake case and all capital, that starts with
13
66
  * the substring `ERR_ERL_`.
14
67
  *
15
- * The message must be a string, starting with a lowercase character,
68
+ * The message must be a string, starting with an uppercase character,
16
69
  * describing the issue in detail.
17
70
  */
18
71
  constructor(code, message) {
19
72
  const url = `https://express-rate-limit.github.io/${code}/`;
20
- super(`${message} See ${url} for more information on this error.`);
73
+ super(`${message} See ${url} for more information.`);
21
74
  __publicField(this, "name");
22
75
  __publicField(this, "code");
23
76
  __publicField(this, "help");
@@ -26,6 +79,8 @@ var ValidationError = class extends Error {
26
79
  this.help = url;
27
80
  }
28
81
  };
82
+ var ChangeWarning = class extends ValidationError {
83
+ };
29
84
  var _Validations = class _Validations {
30
85
  constructor(enabled) {
31
86
  // eslint-disable-next-line @typescript-eslint/parameter-properties
@@ -103,6 +158,22 @@ var _Validations = class _Validations {
103
158
  }
104
159
  });
105
160
  }
161
+ /**
162
+ * Ensures totalHits value from store is a positive integer.
163
+ *
164
+ * @param hits {any} - The `totalHits` returned by the store.
165
+ */
166
+ positiveHits(hits) {
167
+ this.wrap(() => {
168
+ if (typeof hits !== "number" || hits < 1 || hits !== Math.round(hits)) {
169
+ throw new ValidationError(
170
+ "ERR_ERL_INVALID_HITS",
171
+ `The totalHits value returned from the store must be a positive integer, got ${hits}`
172
+ // eslint-disable-line @typescript-eslint/restrict-template-expressions
173
+ );
174
+ }
175
+ });
176
+ }
106
177
  /**
107
178
  * Ensures a given key is incremented only once per request.
108
179
  *
@@ -134,6 +205,78 @@ var _Validations = class _Validations {
134
205
  keys.push(key);
135
206
  });
136
207
  }
208
+ /**
209
+ * Warns the user that the behaviour for `max: 0` is changing in the next
210
+ * major release.
211
+ *
212
+ * @param max {number} - The maximum number of hits per client.
213
+ *
214
+ * @returns {void}
215
+ */
216
+ max(max) {
217
+ this.wrap(() => {
218
+ if (max === 0) {
219
+ throw new ChangeWarning(
220
+ "WRN_ERL_MAX_ZERO",
221
+ `Setting max to 0 disables rate limiting in express-rate-limit v6 and older, but will cause all requests to be blocked in v7`
222
+ );
223
+ }
224
+ });
225
+ }
226
+ /**
227
+ * Warns the user that the `draft_polli_ratelimit_headers` option is deprecated
228
+ * and will be removed in the next major release.
229
+ *
230
+ * @param draft_polli_ratelimit_headers {boolean|undefined} - The now-deprecated setting that was used to enable standard headers.
231
+ *
232
+ * @returns {void}
233
+ */
234
+ draftPolliHeaders(draft_polli_ratelimit_headers) {
235
+ this.wrap(() => {
236
+ if (draft_polli_ratelimit_headers) {
237
+ throw new ChangeWarning(
238
+ "WRN_ERL_DEPRECATED_DRAFT_POLLI_HEADERS",
239
+ `The draft_polli_ratelimit_headers configuration option is deprecated and will be removed in express-rate-limit v7, please set standardHeaders: 'draft-6' instead.`
240
+ );
241
+ }
242
+ });
243
+ }
244
+ /**
245
+ * Warns the user that the `onLimitReached` option is deprecated and will be removed in the next
246
+ * major release.
247
+ *
248
+ * @param onLimitReached {function|undefined} - The maximum number of hits per client.
249
+ *
250
+ * @returns {void}
251
+ */
252
+ onLimitReached(onLimitReached) {
253
+ this.wrap(() => {
254
+ if (onLimitReached) {
255
+ throw new ChangeWarning(
256
+ "WRN_ERL_DEPRECATED_ON_LIMIT_REACHED",
257
+ `The onLimitReached configuration option is deprecated and will be removed in express-rate-limit v7.`
258
+ );
259
+ }
260
+ });
261
+ }
262
+ /**
263
+ * Warns the user when the selected headers option requires a reset time but
264
+ * the store does not provide one.
265
+ *
266
+ * @param resetTime {Date | undefined} - The timestamp when the client's hit count will be reset.
267
+ *
268
+ * @returns {void}
269
+ */
270
+ headersResetTime(resetTime) {
271
+ this.wrap(() => {
272
+ if (!resetTime) {
273
+ throw new ValidationError(
274
+ "ERR_ERL_HEADERS_NO_RESET",
275
+ `standardHeaders: 'draft-7' requires a 'resetTime', but the store did not provide one. The 'windowMs' value will be used instead, which may cause clients to wait longer than necessary.`
276
+ );
277
+ }
278
+ });
279
+ }
137
280
  wrap(validation) {
138
281
  if (!this.enabled) {
139
282
  return;
@@ -141,7 +284,10 @@ var _Validations = class _Validations {
141
284
  try {
142
285
  validation.call(this);
143
286
  } catch (error) {
144
- console.error(error);
287
+ if (error instanceof ChangeWarning)
288
+ console.warn(error);
289
+ else
290
+ console.error(error);
145
291
  }
146
292
  }
147
293
  };
@@ -293,6 +439,7 @@ var promisifyStore = (passedStore) => {
293
439
  async resetKey(key) {
294
440
  return legacyStore.resetKey(key);
295
441
  }
442
+ /* istanbul ignore next */
296
443
  async resetAll() {
297
444
  if (typeof legacyStore.resetAll === "function")
298
445
  return legacyStore.resetAll();
@@ -321,13 +468,20 @@ var parseOptions = (passedOptions) => {
321
468
  var _a, _b, _c, _d;
322
469
  const notUndefinedOptions = omitUndefinedOptions(passedOptions);
323
470
  const validations = new Validations((_a = notUndefinedOptions == null ? void 0 : notUndefinedOptions.validate) != null ? _a : true);
471
+ validations.draftPolliHeaders(
472
+ notUndefinedOptions.draft_polli_ratelimit_headers
473
+ );
474
+ validations.onLimitReached(notUndefinedOptions.onLimitReached);
475
+ let standardHeaders = (_b = notUndefinedOptions.standardHeaders) != null ? _b : false;
476
+ if (standardHeaders === true || standardHeaders === void 0 && notUndefinedOptions.draft_polli_ratelimit_headers) {
477
+ standardHeaders = "draft-6";
478
+ }
324
479
  const config = {
325
480
  windowMs: 60 * 1e3,
326
481
  max: 5,
327
482
  message: "Too many requests, please try again later.",
328
483
  statusCode: 429,
329
- legacyHeaders: (_b = passedOptions.headers) != null ? _b : true,
330
- standardHeaders: (_c = passedOptions.draft_polli_ratelimit_headers) != null ? _c : false,
484
+ legacyHeaders: (_c = passedOptions.headers) != null ? _c : true,
331
485
  requestPropertyName: "rateLimit",
332
486
  skipFailedRequests: false,
333
487
  skipSuccessfulRequests: false,
@@ -346,13 +500,15 @@ var parseOptions = (passedOptions) => {
346
500
  response
347
501
  ) : config.message;
348
502
  if (!response.writableEnded) {
349
- response.send(message != null ? message : "Too many requests, please try again later.");
503
+ response.send(message);
350
504
  }
351
505
  },
352
506
  onLimitReached(_request, _response, _optionsUsed) {
353
507
  },
354
- // Allow the options object to be overriden by the options passed to the middleware.
508
+ // Allow the default options to be overriden by the options passed to the middleware.
355
509
  ...notUndefinedOptions,
510
+ // `standardHeaders` is resolved into a draft version above, use that.
511
+ standardHeaders,
356
512
  // Note that this field is declared after the user's options are spread in,
357
513
  // so that this field doesn't get overriden with an un-promisified store!
358
514
  store: promisifyStore((_d = notUndefinedOptions.store) != null ? _d : new MemoryStore()),
@@ -388,40 +544,27 @@ var rateLimit = (passedOptions) => {
388
544
  const augmentedRequest = request;
389
545
  const key = await config.keyGenerator(request, response);
390
546
  const { totalHits, resetTime } = await config.store.increment(key);
547
+ config.validations.positiveHits(totalHits);
391
548
  config.validations.singleCount(request, config.store, key);
392
549
  const retrieveQuota = typeof config.max === "function" ? config.max(request, response) : config.max;
393
550
  const maxHits = await retrieveQuota;
394
- augmentedRequest[config.requestPropertyName] = {
551
+ config.validations.max(maxHits);
552
+ const info = {
395
553
  limit: maxHits,
396
554
  current: totalHits,
397
555
  remaining: Math.max(maxHits - totalHits, 0),
398
556
  resetTime
399
557
  };
558
+ augmentedRequest[config.requestPropertyName] = info;
400
559
  if (config.legacyHeaders && !response.headersSent) {
401
- response.setHeader("X-RateLimit-Limit", maxHits);
402
- response.setHeader(
403
- "X-RateLimit-Remaining",
404
- augmentedRequest[config.requestPropertyName].remaining
405
- );
406
- if (resetTime instanceof Date) {
407
- response.setHeader("Date", (/* @__PURE__ */ new Date()).toUTCString());
408
- response.setHeader(
409
- "X-RateLimit-Reset",
410
- Math.ceil(resetTime.getTime() / 1e3)
411
- );
412
- }
560
+ setLegacyHeaders(response, info);
413
561
  }
414
562
  if (config.standardHeaders && !response.headersSent) {
415
- response.setHeader("RateLimit-Limit", maxHits);
416
- response.setHeader(
417
- "RateLimit-Remaining",
418
- augmentedRequest[config.requestPropertyName].remaining
419
- );
420
- if (resetTime) {
421
- const deltaSeconds = Math.ceil(
422
- (resetTime.getTime() - Date.now()) / 1e3
423
- );
424
- response.setHeader("RateLimit-Reset", Math.max(0, deltaSeconds));
563
+ if (config.standardHeaders === "draft-6") {
564
+ setDraft6Headers(response, info, config.windowMs);
565
+ } else if (config.standardHeaders === "draft-7") {
566
+ config.validations.headersResetTime(info.resetTime);
567
+ setDraft7Headers(response, info, config.windowMs);
425
568
  }
426
569
  }
427
570
  if (config.skipFailedRequests || config.skipSuccessfulRequests) {
@@ -457,8 +600,8 @@ var rateLimit = (passedOptions) => {
457
600
  }
458
601
  config.validations.disable();
459
602
  if (maxHits && totalHits > maxHits) {
460
- if ((config.legacyHeaders || config.standardHeaders) && !response.headersSent) {
461
- response.setHeader("Retry-After", Math.ceil(config.windowMs / 1e3));
603
+ if (config.legacyHeaders || config.standardHeaders) {
604
+ setRetryAfterHeader(response, info, config.windowMs);
462
605
  }
463
606
  config.handler(request, response, next, options);
464
607
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "express-rate-limit",
3
- "version": "6.9.0",
3
+ "version": "6.10.0",
4
4
  "description": "Basic IP rate-limiting middleware for Express. Use to limit repeated requests to public APIs and/or endpoints such as password reset.",
5
5
  "author": {
6
6
  "name": "Nathan Friedly",
@@ -52,7 +52,7 @@
52
52
  "changelog.md"
53
53
  ],
54
54
  "engines": {
55
- "node": ">= 14.0.0"
55
+ "node": ">= 14"
56
56
  },
57
57
  "scripts": {
58
58
  "clean": "del-cli dist/ coverage/ *.log *.tmp *.bak *.tgz",
@@ -66,7 +66,7 @@
66
66
  "format:code": "npm run lint:code -- --fix",
67
67
  "format:rest": "npm run lint:rest -- --write .",
68
68
  "format": "run-s format:*",
69
- "test:lib": "cross-env NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-vm-modules jest",
69
+ "test:lib": "cross-env NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-vm-modules jest --config config/jest.json",
70
70
  "test:ext": "cd test/external/ && bash run-all-tests",
71
71
  "test": "run-s lint test:lib",
72
72
  "pre-commit": "lint-staged",
@@ -76,9 +76,11 @@
76
76
  "express": "^4 || ^5"
77
77
  },
78
78
  "devDependencies": {
79
- "@jest/globals": "29.6.1",
79
+ "@express-rate-limit/prettier": "1.0.0",
80
+ "@express-rate-limit/tsconfig": "1.0.0",
81
+ "@jest/globals": "29.6.2",
80
82
  "@types/express": "4.17.17",
81
- "@types/jest": "29.5.2",
83
+ "@types/jest": "29.5.3",
82
84
  "@types/node": "20.4.0",
83
85
  "@types/supertest": "2.0.12",
84
86
  "cross-env": "7.0.3",
@@ -87,9 +89,10 @@
87
89
  "esbuild": "0.18.11",
88
90
  "express": "4.18.2",
89
91
  "husky": "8.0.3",
90
- "jest": "29.6.1",
92
+ "jest": "29.6.2",
91
93
  "lint-staged": "13.2.3",
92
94
  "npm-run-all": "4.1.5",
95
+ "ratelimit-header-parser": "0.1.0",
93
96
  "supertest": "6.3.3",
94
97
  "ts-jest": "29.1.1",
95
98
  "ts-node": "10.9.1",
@@ -112,40 +115,13 @@
112
115
  {
113
116
  "files": "test/library/*.ts",
114
117
  "rules": {
115
- "@typescript-eslint/no-unsafe-argument": 0
118
+ "@typescript-eslint/no-unsafe-argument": 0,
119
+ "@typescript-eslint/no-unsafe-assignment": 0
116
120
  }
117
121
  }
118
122
  ]
119
123
  },
120
- "prettier": {
121
- "semi": false,
122
- "useTabs": true,
123
- "singleQuote": true,
124
- "bracketSpacing": true,
125
- "trailingComma": "all",
126
- "proseWrap": "always"
127
- },
128
- "jest": {
129
- "preset": "ts-jest/presets/default-esm",
130
- "collectCoverage": true,
131
- "collectCoverageFrom": [
132
- "source/**/*.ts"
133
- ],
134
- "testTimeout": 30000,
135
- "testMatch": [
136
- "**/test/library/**/*-test.[jt]s?(x)"
137
- ],
138
- "moduleFileExtensions": [
139
- "js",
140
- "jsx",
141
- "json",
142
- "ts",
143
- "tsx"
144
- ],
145
- "moduleNameMapper": {
146
- "^(\\.{1,2}/.*)\\.js$": "$1"
147
- }
148
- },
124
+ "prettier": "@express-rate-limit/prettier",
149
125
  "lint-staged": {
150
126
  "{source,test}/**/*.ts": "xo --ignore test/external/ --fix",
151
127
  "**/*.{json,yaml,md}": "prettier --ignore-path .gitignore --ignore-unknown --write "
package/readme.md CHANGED
@@ -12,7 +12,7 @@ authentication and more to any API in minutes. Learn more at
12
12
 
13
13
  <div align="center">
14
14
 
15
- [![Tests](https://github.com/express-rate-limit/express-rate-limit/workflows/Test/badge.svg)](https://github.com/express-rate-limit/express-rate-limit/actions)
15
+ [![tests](https://github.com/express-rate-limit/express-rate-limit/actions/workflows/ci.yaml/badge.svg)](https://github.com/express-rate-limit/express-rate-limit/actions/workflows/ci.yaml)
16
16
  [![npm version](https://img.shields.io/npm/v/express-rate-limit.svg)](https://npmjs.org/package/express-rate-limit 'View this project on NPM')
17
17
  [![npm downloads](https://img.shields.io/npm/dm/express-rate-limit)](https://www.npmjs.com/package/express-rate-limit)
18
18
 
@@ -97,13 +97,13 @@ Import it in a CommonJS project (`type: commonjs` or no `type` field in
97
97
  `package.json`) as follows:
98
98
 
99
99
  ```ts
100
- const rateLimit = require('express-rate-limit')
100
+ const { rateLimit } = require('express-rate-limit')
101
101
  ```
102
102
 
103
103
  Import it in a ESM project (`type: module` in `package.json`) as follows:
104
104
 
105
105
  ```ts
106
- import rateLimit from 'express-rate-limit'
106
+ import { rateLimit } from 'express-rate-limit'
107
107
  ```
108
108
 
109
109
  ### Examples
@@ -112,13 +112,13 @@ To use it in an API-only server where the rate-limiter should be applied to all
112
112
  requests:
113
113
 
114
114
  ```ts
115
- import rateLimit from 'express-rate-limit'
115
+ import { rateLimit } from 'express-rate-limit'
116
116
 
117
117
  const limiter = rateLimit({
118
118
  windowMs: 15 * 60 * 1000, // 15 minutes
119
119
  max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
120
- standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
121
- legacyHeaders: false, // Disable the `X-RateLimit-*` headers
120
+ standardHeaders: 'draft-7', // draft-6: RateLimit-* headers; draft-7: combined RateLimit header
121
+ legacyHeaders: false, // X-RateLimit-* headers
122
122
  // store: ... , // Use an external store for more precise rate limiting
123
123
  })
124
124
 
@@ -131,12 +131,12 @@ To use it in a 'regular' web server (e.g. anything that uses
131
131
  requests:
132
132
 
133
133
  ```ts
134
- import rateLimit from 'express-rate-limit'
134
+ import { rateLimit } from 'express-rate-limit'
135
135
 
136
136
  const apiLimiter = rateLimit({
137
137
  windowMs: 15 * 60 * 1000, // 15 minutes
138
138
  max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
139
- standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
139
+ standardHeaders: 'draft-7', // Set `RateLimit` and `RateLimit-Policy`` headers
140
140
  legacyHeaders: false, // Disable the `X-RateLimit-*` headers
141
141
  // store: ... , // Use an external store for more precise rate limiting
142
142
  })
@@ -148,13 +148,13 @@ app.use('/api', apiLimiter)
148
148
  To create multiple instances to apply different rules to different endpoints:
149
149
 
150
150
  ```ts
151
- import rateLimit from 'express-rate-limit'
151
+ import { rateLimit } from 'express-rate-limit'
152
152
 
153
153
  const apiLimiter = rateLimit({
154
154
  windowMs: 15 * 60 * 1000, // 15 minutes
155
155
  max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
156
- standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
157
- legacyHeaders: false, // Disable the `X-RateLimit-*` headers
156
+ standardHeaders: 'draft-7', // draft-6: RateLimit-* headers; draft-7: combined RateLimit header
157
+ legacyHeaders: false, // X-RateLimit-* headers
158
158
  // store: ... , // Use an external store for more precise rate limiting
159
159
  })
160
160
 
@@ -165,8 +165,8 @@ const createAccountLimiter = rateLimit({
165
165
  max: 5, // Limit each IP to 5 create account requests per `window` (here, per hour)
166
166
  message:
167
167
  'Too many accounts created from this IP, please try again after an hour',
168
- standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
169
- legacyHeaders: false, // Disable the `X-RateLimit-*` headers
168
+ standardHeaders: 'draft-7', // draft-6: RateLimit-* headers; draft-7: combined RateLimit header
169
+ legacyHeaders: false, // X-RateLimit-* headers
170
170
  })
171
171
 
172
172
  app.post('/create-account', createAccountLimiter, (request, response) => {
@@ -177,7 +177,7 @@ app.post('/create-account', createAccountLimiter, (request, response) => {
177
177
  To use a custom store:
178
178
 
179
179
  ```ts
180
- import rateLimit from 'express-rate-limit'
180
+ import { rateLimit } from 'express-rate-limit'
181
181
  import RedisStore from 'rate-limit-redis'
182
182
  import RedisClient from 'ioredis'
183
183
 
@@ -185,7 +185,8 @@ const redisClient = new RedisClient()
185
185
  const rateLimiter = rateLimit({
186
186
  windowMs: 15 * 60 * 1000, // 15 minutes
187
187
  max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
188
- standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
188
+ standardHeaders: 'draft-7', // draft-6: RateLimit-* headers; draft-7: combined RateLimit header
189
+ legacyHeaders: false, // X-RateLimit-* headers
189
190
  store: new RedisStore({
190
191
  /* ... */
191
192
  }), // Use the external store
@@ -329,16 +330,38 @@ Defaults to `true` (for backward compatibility).
329
330
 
330
331
  ### `standardHeaders`
331
332
 
332
- > `boolean`
333
+ > `boolean` | `'draft-6'` | `'draft-7'`
333
334
 
334
335
  Whether to enable support for headers conforming to the
335
- [ratelimit standardization draft](https://github.com/ietf-wg-httpapi/ratelimit-headers/blob/main/draft-ietf-httpapi-ratelimit-headers.md)
336
- adopted by the IETF (`RateLimit-Limit`, `RateLimit-Remaining`, and, if the store
337
- supports it, `RateLimit-Reset`). If set to `true`, the middleware also sends the
338
- `Retry-After` header on all blocked requests. May be used in conjunction with,
339
- or instead of the `legacyHeaders` option.
336
+ [RateLimit header fields for HTTP standardization draft](https://github.com/ietf-wg-httpapi/ratelimit-headers)
337
+ adopted by the IETF.
338
+
339
+ If set to `draft-6`, separate `RateLimit-Policy` `RateLimit-Limit`,
340
+ `RateLimit-Remaining`, and, if the store supports it, `RateLimit-Reset` headers
341
+ are set on the response, in accordance with
342
+ [draft-ietf-httpapi-ratelimit-headers-06](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-06).
343
+
344
+ If set to `draft-7`, a combined `RateLimit` header is set containing limit,
345
+ remaining, and reset values, and a `RateLimit-Policy` header is set, in
346
+ accordiance with
347
+ [draft-ietf-httpapi-ratelimit-headers-07](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-07).
348
+ `windowMs` is used for the reset value if the store does not provide a reset
349
+ time.
340
350
 
341
- Defaults to `false` (for backward compatibility, but its use is recommended).
351
+ If set to `true`, it is treated as `draft-6`, however this behavior may change
352
+ in a future semver major release.
353
+
354
+ If set to any truthy value, the middleware also sends the `Retry-After` header
355
+ on all blocked requests.
356
+
357
+ The `standardHeaders` option may be used in conjunction with, or instead of the
358
+ `legacyHeaders` option.
359
+
360
+ ℹ️ Tip: use
361
+ [ratelimit-header-parser](https://www.npmjs.com/package/ratelimit-header-parser)
362
+ in clients to read/parse any form of express-rate-limit's headers.
363
+
364
+ Defaults to `false`.
342
365
 
343
366
  > Renamed in `6.x` from `draft_polli_ratelimit_headers` to `standardHeaders`.
344
367
 
@@ -430,8 +453,8 @@ const limiter = rateLimit({
430
453
  > `function`
431
454
 
432
455
  A (sync/async) function that accepts the Express `request` and `response`
433
- objects that is called when a client has reached their rate limit, and will be
434
- rate limited on their next request.
456
+ objects that is called the on the request where a client has just exceeded their
457
+ rate limit.
435
458
 
436
459
  This method was
437
460
  [deprecated in v6](https://github.com/express-rate-limit/express-rate-limit/releases/v6.0.0) -
@@ -515,7 +538,7 @@ Here is a list of external stores:
515
538
  | [`rate-limit-redis`](https://npmjs.com/package/rate-limit-redis) | A [Redis](http://redis.io/)-backed store, more suitable for large or demanding deployments. | Modern as of v3.0.0 |
516
539
  | [`rate-limit-memcached`](https://npmjs.org/package/rate-limit-memcached) | A [Memcached](https://memcached.org/)-backed store. | Legacy |
517
540
  | [`rate-limit-mongo`](https://www.npmjs.com/package/rate-limit-mongo) | A [MongoDB](https://www.mongodb.com/)-backed store. | Legacy |
518
- | [`precise-memory-rate-limit`](https://www.npmjs.com/package/precise-memory-rate-limit) | A memory store similar to the built-in one, except that it stores a distinct timestamp for each key. | Legacy |
541
+ | [`precise-memory-rate-limit`](https://www.npmjs.com/package/precise-memory-rate-limit) | A memory store similar to the built-in one, except that it stores a distinct timestamp for each key. | Modern as of v2.0.0 |
519
542
 
520
543
  Take a look at
521
544
  [this guide](https://github.com/express-rate-limit/express-rate-limit/wiki/Creating-Your-Own-Store)
package/tsconfig.json CHANGED
@@ -1,13 +1,5 @@
1
1
  {
2
2
  "include": ["source/"],
3
3
  "exclude": ["node_modules/"],
4
- "compilerOptions": {
5
- "declaration": true,
6
- "strict": true,
7
- "noUnusedLocals": true,
8
- "noImplicitReturns": true,
9
- "noFallthroughCasesInSwitch": true,
10
- "esModuleInterop": true,
11
- "moduleResolution": "node"
12
- }
4
+ "extends": "@express-rate-limit/tsconfig"
13
5
  }