hono-rate-limiter 0.5.2 → 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.
@@ -525,15 +578,23 @@ class UnstorageStore {
525
578
  * @returns {ClientRateLimitInfo | undefined} - The number of hits and reset time for that client.
526
579
  */
527
580
  async get(key) {
528
- const result = await this.storage.get(this.prefixKey(key)).then(
529
- (value) => value ? JSON.parse(String(value)) : void 0
530
- );
581
+ const result = await this.storage.get(this.prefixKey(key)).then((value) => {
582
+ if (typeof value === "object") {
583
+ return value;
584
+ }
585
+ return value ? JSON.parse(String(value)) : void 0;
586
+ });
531
587
  return result;
532
588
  }
533
589
  /**
534
590
  * Method to increment a client's hit counter. If the current time is within an active window,
535
591
  * it increments the existing hit count. Otherwise, it starts a new window with a hit count of 1.
536
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
+ *
537
598
  * @param key {string} - The identifier for a client
538
599
  *
539
600
  * @returns {ClientRateLimitInfo} - An object containing:
@@ -656,26 +717,18 @@ function webSocketLimiter(config) {
656
717
  decremented = true;
657
718
  }
658
719
  };
659
- const shouldSkipRequest = async () => {
660
- if (skipSuccessfulRequests) await decrementKey();
661
- };
662
720
  if (totalHits > _limit) {
663
- await shouldSkipRequest();
664
721
  return handler(event, ws, options);
665
722
  }
666
723
  try {
667
724
  await events.onMessage?.(event, ws);
668
- await shouldSkipRequest();
725
+ if (skipSuccessfulRequests) await decrementKey();
669
726
  } catch (error) {
670
727
  if (skipFailedRequests) await decrementKey();
671
728
  throw error;
672
729
  }
673
730
  },
674
731
  onError: async (event, ws) => {
675
- if (skipFailedRequests) {
676
- const key = await keyGenerator(c);
677
- await store.decrement(key);
678
- }
679
732
  events.onError?.(event, ws);
680
733
  }
681
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.
@@ -523,15 +576,23 @@ class UnstorageStore {
523
576
  * @returns {ClientRateLimitInfo | undefined} - The number of hits and reset time for that client.
524
577
  */
525
578
  async get(key) {
526
- const result = await this.storage.get(this.prefixKey(key)).then(
527
- (value) => value ? JSON.parse(String(value)) : void 0
528
- );
579
+ const result = await this.storage.get(this.prefixKey(key)).then((value) => {
580
+ if (typeof value === "object") {
581
+ return value;
582
+ }
583
+ return value ? JSON.parse(String(value)) : void 0;
584
+ });
529
585
  return result;
530
586
  }
531
587
  /**
532
588
  * Method to increment a client's hit counter. If the current time is within an active window,
533
589
  * it increments the existing hit count. Otherwise, it starts a new window with a hit count of 1.
534
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
+ *
535
596
  * @param key {string} - The identifier for a client
536
597
  *
537
598
  * @returns {ClientRateLimitInfo} - An object containing:
@@ -654,26 +715,18 @@ function webSocketLimiter(config) {
654
715
  decremented = true;
655
716
  }
656
717
  };
657
- const shouldSkipRequest = async () => {
658
- if (skipSuccessfulRequests) await decrementKey();
659
- };
660
718
  if (totalHits > _limit) {
661
- await shouldSkipRequest();
662
719
  return handler(event, ws, options);
663
720
  }
664
721
  try {
665
722
  await events.onMessage?.(event, ws);
666
- await shouldSkipRequest();
723
+ if (skipSuccessfulRequests) await decrementKey();
667
724
  } catch (error) {
668
725
  if (skipFailedRequests) await decrementKey();
669
726
  throw error;
670
727
  }
671
728
  },
672
729
  onError: async (event, ws) => {
673
- if (skipFailedRequests) {
674
- const key = await keyGenerator(c);
675
- await store.decrement(key);
676
- }
677
730
  events.onError?.(event, ws);
678
731
  }
679
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.2",
4
+ "version": "0.5.4",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "keywords": [