hono-rate-limiter 0.5.3 → 0.5.4

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/dist/index.cjs CHANGED
@@ -360,6 +360,19 @@ const scripts = {
360
360
  local timeToExpire = redis.call("PTTL", KEYS[1])
361
361
 
362
362
  return { totalHits, timeToExpire }
363
+ `.replaceAll(/^\s+/gm, "").trim(),
364
+ // Mirrors the `MemoryStore` behaviour: never go below zero, and never
365
+ // disturb the expiry. A raw DECR on a missing key (e.g. the window expired
366
+ // while a slow handler ran) would create it at -1 with no TTL, and that
367
+ // negative value would leak into the next window, letting a client exceed
368
+ // the configured limit. Reading the value first and only decrementing when
369
+ // it is a positive number avoids that. A nil GET means the window already
370
+ // ended, so the correct action is to do nothing.
371
+ decrement: `
372
+ local current = tonumber(redis.call("GET", KEYS[1]))
373
+ if current and current > 0 then
374
+ redis.call("DECR", KEYS[1])
375
+ end
363
376
  `.replaceAll(/^\s+/gm, "").trim()
364
377
  };
365
378
 
@@ -389,6 +402,13 @@ class RedisStore {
389
402
  this.resetExpiryOnChange = options.resetExpiryOnChange ?? false;
390
403
  this.incrementScriptSha = this.loadIncrementScript();
391
404
  this.getScriptSha = this.loadGetScript();
405
+ this.decrementScriptSha = this.loadDecrementScript();
406
+ this.incrementScriptSha.catch(() => {
407
+ });
408
+ this.getScriptSha.catch(() => {
409
+ });
410
+ this.decrementScriptSha.catch(() => {
411
+ });
392
412
  }
393
413
  /**
394
414
  * Loads the script used to increment a client's hit count.
@@ -410,6 +430,16 @@ class RedisStore {
410
430
  }
411
431
  return result;
412
432
  }
433
+ /**
434
+ * Loads the script used to decrement a client's hit count.
435
+ */
436
+ async loadDecrementScript() {
437
+ const result = await this.client.scriptLoad(scripts.decrement);
438
+ if (typeof result !== "string") {
439
+ throw new TypeError("unexpected reply from redis client");
440
+ }
441
+ return result;
442
+ }
413
443
  /**
414
444
  * Runs the increment command, and retries it if the script is not loaded.
415
445
  */
@@ -427,6 +457,23 @@ class RedisStore {
427
457
  return evalCommand();
428
458
  }
429
459
  }
460
+ /**
461
+ * Runs the decrement command, and retries it if the script is not loaded.
462
+ */
463
+ async retryableDecrement(key) {
464
+ const evalCommand = async () => this.client.evalsha(
465
+ await this.decrementScriptSha,
466
+ [this.prefixKey(key)],
467
+ []
468
+ );
469
+ try {
470
+ const result = await evalCommand();
471
+ return result;
472
+ } catch {
473
+ this.decrementScriptSha = this.loadDecrementScript();
474
+ return evalCommand();
475
+ }
476
+ }
430
477
  /**
431
478
  * Method to prefix the keys with the given text.
432
479
  *
@@ -474,10 +521,16 @@ class RedisStore {
474
521
  /**
475
522
  * Method to decrement a client's hit counter.
476
523
  *
524
+ * Uses a Lua script so the read-and-decrement is atomic. It never takes the
525
+ * counter below zero and never touches the key's expiry, mirroring the
526
+ * `MemoryStore`. This prevents a decrement that lands after the window has
527
+ * expired from creating a negative, TTL-less key that would leak extra
528
+ * requests into the next window.
529
+ *
477
530
  * @param key {string} - The identifier for a client
478
531
  */
479
532
  async decrement(key) {
480
- await this.client.decr(this.prefixKey(key));
533
+ await this.retryableDecrement(key);
481
534
  }
482
535
  /**
483
536
  * Method to reset a client's hit counter.
@@ -537,6 +590,11 @@ class UnstorageStore {
537
590
  * Method to increment a client's hit counter. If the current time is within an active window,
538
591
  * it increments the existing hit count. Otherwise, it starts a new window with a hit count of 1.
539
592
  *
593
+ * @remarks
594
+ * This read-modify-write is not atomic. Concurrent calls for the same key
595
+ * may under-count and let a client exceed the limit. See the class-level
596
+ * remarks for details and alternatives.
597
+ *
540
598
  * @param key {string} - The identifier for a client
541
599
  *
542
600
  * @returns {ClientRateLimitInfo} - An object containing:
@@ -659,26 +717,18 @@ function webSocketLimiter(config) {
659
717
  decremented = true;
660
718
  }
661
719
  };
662
- const shouldSkipRequest = async () => {
663
- if (skipSuccessfulRequests) await decrementKey();
664
- };
665
720
  if (totalHits > _limit) {
666
- await shouldSkipRequest();
667
721
  return handler(event, ws, options);
668
722
  }
669
723
  try {
670
724
  await events.onMessage?.(event, ws);
671
- await shouldSkipRequest();
725
+ if (skipSuccessfulRequests) await decrementKey();
672
726
  } catch (error) {
673
727
  if (skipFailedRequests) await decrementKey();
674
728
  throw error;
675
729
  }
676
730
  },
677
731
  onError: async (event, ws) => {
678
- if (skipFailedRequests) {
679
- const key = await keyGenerator(c);
680
- await store.decrement(key);
681
- }
682
732
  events.onError?.(event, ws);
683
733
  }
684
734
  };
package/dist/index.d.cts CHANGED
@@ -143,7 +143,7 @@ type HonoConfigType<E extends Env = Env, P extends string = string, I extends In
143
143
  */
144
144
  skipSuccessfulRequests: boolean;
145
145
  /**
146
- * Method to determine whether or not the request counts as 'succesful'. Used
146
+ * Method to determine whether or not the request counts as 'successful'. Used
147
147
  * when either `skipSuccessfulRequests` or `skipFailedRequests` is set to true.
148
148
  *
149
149
  * By default, requests with a response status code less than 400 are considered
@@ -439,6 +439,7 @@ declare class RedisStore<E extends Env$1 = Env$1, P extends string = string, I e
439
439
  */
440
440
  incrementScriptSha: Promise<string>;
441
441
  getScriptSha: Promise<string>;
442
+ decrementScriptSha: Promise<string>;
442
443
  /**
443
444
  * @constructor for `RedisStore`.
444
445
  *
@@ -453,10 +454,18 @@ declare class RedisStore<E extends Env$1 = Env$1, P extends string = string, I e
453
454
  * Loads the script used to fetch a client's hit count and expiry time.
454
455
  */
455
456
  loadGetScript(): Promise<string>;
457
+ /**
458
+ * Loads the script used to decrement a client's hit count.
459
+ */
460
+ loadDecrementScript(): Promise<string>;
456
461
  /**
457
462
  * Runs the increment command, and retries it if the script is not loaded.
458
463
  */
459
464
  retryableIncrement(key: string): Promise<RedisReply>;
465
+ /**
466
+ * Runs the decrement command, and retries it if the script is not loaded.
467
+ */
468
+ retryableDecrement(key: string): Promise<RedisReply>;
460
469
  /**
461
470
  * Method to prefix the keys with the given text.
462
471
  *
@@ -490,6 +499,12 @@ declare class RedisStore<E extends Env$1 = Env$1, P extends string = string, I e
490
499
  /**
491
500
  * Method to decrement a client's hit counter.
492
501
  *
502
+ * Uses a Lua script so the read-and-decrement is atomic. It never takes the
503
+ * counter below zero and never touches the key's expiry, mirroring the
504
+ * `MemoryStore`. This prevents a decrement that lands after the window has
505
+ * expired from creating a negative, TTL-less key that would leak extra
506
+ * requests into the next window.
507
+ *
493
508
  * @param key {string} - The identifier for a client
494
509
  */
495
510
  decrement(key: string): Promise<void>;
@@ -510,6 +525,22 @@ type UnstorageInstance = {
510
525
  * A `Store` that stores the hit count for each client using Unstorage
511
526
  *
512
527
  * {@link https://unstorage.unjs.io/}
528
+ *
529
+ * @remarks
530
+ * **Not safe under high concurrency.** Unstorage exposes no atomic
531
+ * increment or compare-and-swap primitive, so `increment()` and
532
+ * `decrement()` perform a non-atomic read-modify-write against the backing
533
+ * driver. When many requests for the same key arrive at once, they can all
534
+ * read the same hit count before any of them writes it back, so the counter
535
+ * under-counts and a client can exceed the configured limit. The wider the
536
+ * driver's read/write latency (e.g. Vercel KV, Cloudflare KV, S3), the wider
537
+ * this window. The same applies across multiple server instances sharing one
538
+ * backend.
539
+ *
540
+ * If you need correct counting under concurrent load, use a store backed by
541
+ * an atomic counter such as `RedisStore` (which does all its work inside a
542
+ * Lua script). `MemoryStore` is also race-free within a single process, but
543
+ * does not share state across instances.
513
544
  */
514
545
  declare class UnstorageStore<E extends Env$1 = Env$1, P extends string = string, I extends Input$1 = Input$1> implements Store<E, P, I> {
515
546
  /**
@@ -559,6 +590,11 @@ declare class UnstorageStore<E extends Env$1 = Env$1, P extends string = string,
559
590
  * Method to increment a client's hit counter. If the current time is within an active window,
560
591
  * it increments the existing hit count. Otherwise, it starts a new window with a hit count of 1.
561
592
  *
593
+ * @remarks
594
+ * This read-modify-write is not atomic. Concurrent calls for the same key
595
+ * may under-count and let a client exceed the limit. See the class-level
596
+ * remarks for details and alternatives.
597
+ *
562
598
  * @param key {string} - The identifier for a client
563
599
  *
564
600
  * @returns {ClientRateLimitInfo} - An object containing:
package/dist/index.d.ts CHANGED
@@ -143,7 +143,7 @@ type HonoConfigType<E extends Env = Env, P extends string = string, I extends In
143
143
  */
144
144
  skipSuccessfulRequests: boolean;
145
145
  /**
146
- * Method to determine whether or not the request counts as 'succesful'. Used
146
+ * Method to determine whether or not the request counts as 'successful'. Used
147
147
  * when either `skipSuccessfulRequests` or `skipFailedRequests` is set to true.
148
148
  *
149
149
  * By default, requests with a response status code less than 400 are considered
@@ -439,6 +439,7 @@ declare class RedisStore<E extends Env$1 = Env$1, P extends string = string, I e
439
439
  */
440
440
  incrementScriptSha: Promise<string>;
441
441
  getScriptSha: Promise<string>;
442
+ decrementScriptSha: Promise<string>;
442
443
  /**
443
444
  * @constructor for `RedisStore`.
444
445
  *
@@ -453,10 +454,18 @@ declare class RedisStore<E extends Env$1 = Env$1, P extends string = string, I e
453
454
  * Loads the script used to fetch a client's hit count and expiry time.
454
455
  */
455
456
  loadGetScript(): Promise<string>;
457
+ /**
458
+ * Loads the script used to decrement a client's hit count.
459
+ */
460
+ loadDecrementScript(): Promise<string>;
456
461
  /**
457
462
  * Runs the increment command, and retries it if the script is not loaded.
458
463
  */
459
464
  retryableIncrement(key: string): Promise<RedisReply>;
465
+ /**
466
+ * Runs the decrement command, and retries it if the script is not loaded.
467
+ */
468
+ retryableDecrement(key: string): Promise<RedisReply>;
460
469
  /**
461
470
  * Method to prefix the keys with the given text.
462
471
  *
@@ -490,6 +499,12 @@ declare class RedisStore<E extends Env$1 = Env$1, P extends string = string, I e
490
499
  /**
491
500
  * Method to decrement a client's hit counter.
492
501
  *
502
+ * Uses a Lua script so the read-and-decrement is atomic. It never takes the
503
+ * counter below zero and never touches the key's expiry, mirroring the
504
+ * `MemoryStore`. This prevents a decrement that lands after the window has
505
+ * expired from creating a negative, TTL-less key that would leak extra
506
+ * requests into the next window.
507
+ *
493
508
  * @param key {string} - The identifier for a client
494
509
  */
495
510
  decrement(key: string): Promise<void>;
@@ -510,6 +525,22 @@ type UnstorageInstance = {
510
525
  * A `Store` that stores the hit count for each client using Unstorage
511
526
  *
512
527
  * {@link https://unstorage.unjs.io/}
528
+ *
529
+ * @remarks
530
+ * **Not safe under high concurrency.** Unstorage exposes no atomic
531
+ * increment or compare-and-swap primitive, so `increment()` and
532
+ * `decrement()` perform a non-atomic read-modify-write against the backing
533
+ * driver. When many requests for the same key arrive at once, they can all
534
+ * read the same hit count before any of them writes it back, so the counter
535
+ * under-counts and a client can exceed the configured limit. The wider the
536
+ * driver's read/write latency (e.g. Vercel KV, Cloudflare KV, S3), the wider
537
+ * this window. The same applies across multiple server instances sharing one
538
+ * backend.
539
+ *
540
+ * If you need correct counting under concurrent load, use a store backed by
541
+ * an atomic counter such as `RedisStore` (which does all its work inside a
542
+ * Lua script). `MemoryStore` is also race-free within a single process, but
543
+ * does not share state across instances.
513
544
  */
514
545
  declare class UnstorageStore<E extends Env$1 = Env$1, P extends string = string, I extends Input$1 = Input$1> implements Store<E, P, I> {
515
546
  /**
@@ -559,6 +590,11 @@ declare class UnstorageStore<E extends Env$1 = Env$1, P extends string = string,
559
590
  * Method to increment a client's hit counter. If the current time is within an active window,
560
591
  * it increments the existing hit count. Otherwise, it starts a new window with a hit count of 1.
561
592
  *
593
+ * @remarks
594
+ * This read-modify-write is not atomic. Concurrent calls for the same key
595
+ * may under-count and let a client exceed the limit. See the class-level
596
+ * remarks for details and alternatives.
597
+ *
562
598
  * @param key {string} - The identifier for a client
563
599
  *
564
600
  * @returns {ClientRateLimitInfo} - An object containing:
package/dist/index.js CHANGED
@@ -358,6 +358,19 @@ const scripts = {
358
358
  local timeToExpire = redis.call("PTTL", KEYS[1])
359
359
 
360
360
  return { totalHits, timeToExpire }
361
+ `.replaceAll(/^\s+/gm, "").trim(),
362
+ // Mirrors the `MemoryStore` behaviour: never go below zero, and never
363
+ // disturb the expiry. A raw DECR on a missing key (e.g. the window expired
364
+ // while a slow handler ran) would create it at -1 with no TTL, and that
365
+ // negative value would leak into the next window, letting a client exceed
366
+ // the configured limit. Reading the value first and only decrementing when
367
+ // it is a positive number avoids that. A nil GET means the window already
368
+ // ended, so the correct action is to do nothing.
369
+ decrement: `
370
+ local current = tonumber(redis.call("GET", KEYS[1]))
371
+ if current and current > 0 then
372
+ redis.call("DECR", KEYS[1])
373
+ end
361
374
  `.replaceAll(/^\s+/gm, "").trim()
362
375
  };
363
376
 
@@ -387,6 +400,13 @@ class RedisStore {
387
400
  this.resetExpiryOnChange = options.resetExpiryOnChange ?? false;
388
401
  this.incrementScriptSha = this.loadIncrementScript();
389
402
  this.getScriptSha = this.loadGetScript();
403
+ this.decrementScriptSha = this.loadDecrementScript();
404
+ this.incrementScriptSha.catch(() => {
405
+ });
406
+ this.getScriptSha.catch(() => {
407
+ });
408
+ this.decrementScriptSha.catch(() => {
409
+ });
390
410
  }
391
411
  /**
392
412
  * Loads the script used to increment a client's hit count.
@@ -408,6 +428,16 @@ class RedisStore {
408
428
  }
409
429
  return result;
410
430
  }
431
+ /**
432
+ * Loads the script used to decrement a client's hit count.
433
+ */
434
+ async loadDecrementScript() {
435
+ const result = await this.client.scriptLoad(scripts.decrement);
436
+ if (typeof result !== "string") {
437
+ throw new TypeError("unexpected reply from redis client");
438
+ }
439
+ return result;
440
+ }
411
441
  /**
412
442
  * Runs the increment command, and retries it if the script is not loaded.
413
443
  */
@@ -425,6 +455,23 @@ class RedisStore {
425
455
  return evalCommand();
426
456
  }
427
457
  }
458
+ /**
459
+ * Runs the decrement command, and retries it if the script is not loaded.
460
+ */
461
+ async retryableDecrement(key) {
462
+ const evalCommand = async () => this.client.evalsha(
463
+ await this.decrementScriptSha,
464
+ [this.prefixKey(key)],
465
+ []
466
+ );
467
+ try {
468
+ const result = await evalCommand();
469
+ return result;
470
+ } catch {
471
+ this.decrementScriptSha = this.loadDecrementScript();
472
+ return evalCommand();
473
+ }
474
+ }
428
475
  /**
429
476
  * Method to prefix the keys with the given text.
430
477
  *
@@ -472,10 +519,16 @@ class RedisStore {
472
519
  /**
473
520
  * Method to decrement a client's hit counter.
474
521
  *
522
+ * Uses a Lua script so the read-and-decrement is atomic. It never takes the
523
+ * counter below zero and never touches the key's expiry, mirroring the
524
+ * `MemoryStore`. This prevents a decrement that lands after the window has
525
+ * expired from creating a negative, TTL-less key that would leak extra
526
+ * requests into the next window.
527
+ *
475
528
  * @param key {string} - The identifier for a client
476
529
  */
477
530
  async decrement(key) {
478
- await this.client.decr(this.prefixKey(key));
531
+ await this.retryableDecrement(key);
479
532
  }
480
533
  /**
481
534
  * Method to reset a client's hit counter.
@@ -535,6 +588,11 @@ class UnstorageStore {
535
588
  * Method to increment a client's hit counter. If the current time is within an active window,
536
589
  * it increments the existing hit count. Otherwise, it starts a new window with a hit count of 1.
537
590
  *
591
+ * @remarks
592
+ * This read-modify-write is not atomic. Concurrent calls for the same key
593
+ * may under-count and let a client exceed the limit. See the class-level
594
+ * remarks for details and alternatives.
595
+ *
538
596
  * @param key {string} - The identifier for a client
539
597
  *
540
598
  * @returns {ClientRateLimitInfo} - An object containing:
@@ -657,26 +715,18 @@ function webSocketLimiter(config) {
657
715
  decremented = true;
658
716
  }
659
717
  };
660
- const shouldSkipRequest = async () => {
661
- if (skipSuccessfulRequests) await decrementKey();
662
- };
663
718
  if (totalHits > _limit) {
664
- await shouldSkipRequest();
665
719
  return handler(event, ws, options);
666
720
  }
667
721
  try {
668
722
  await events.onMessage?.(event, ws);
669
- await shouldSkipRequest();
723
+ if (skipSuccessfulRequests) await decrementKey();
670
724
  } catch (error) {
671
725
  if (skipFailedRequests) await decrementKey();
672
726
  throw error;
673
727
  }
674
728
  },
675
729
  onError: async (event, ws) => {
676
- if (skipFailedRequests) {
677
- const key = await keyGenerator(c);
678
- await store.decrement(key);
679
- }
680
730
  events.onError?.(event, ws);
681
731
  }
682
732
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hono-rate-limiter",
3
3
  "description": "Rate limit middleware for Hono.",
4
- "version": "0.5.3",
4
+ "version": "0.5.4",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [