@upstash/ratelimit 1.0.1 → 1.1.0-canary-1

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.js CHANGED
@@ -122,6 +122,12 @@ var Cache = class {
122
122
  this.cache.set(key, value);
123
123
  return value;
124
124
  }
125
+ pop(key) {
126
+ this.cache.delete(key);
127
+ }
128
+ empty() {
129
+ this.cache.clear();
130
+ }
125
131
  };
126
132
 
127
133
  // src/duration.ts
@@ -130,7 +136,7 @@ function ms(d) {
130
136
  if (!match) {
131
137
  throw new Error(`Unable to parse window size: ${d}`);
132
138
  }
133
- const time = parseInt(match[1]);
139
+ const time = Number.parseInt(match[1]);
134
140
  const unit = match[2];
135
141
  switch (unit) {
136
142
  case "ms":
@@ -148,6 +154,115 @@ function ms(d) {
148
154
  }
149
155
  }
150
156
 
157
+ // src/lua-scripts/multi.ts
158
+ var fixedWindowLimitScript = `
159
+ local key = KEYS[1]
160
+ local id = ARGV[1]
161
+ local window = ARGV[2]
162
+ local incrementBy = tonumber(ARGV[3])
163
+
164
+ redis.call("HSET", key, id, incrementBy)
165
+ local fields = redis.call("HGETALL", key)
166
+ if #fields == 1 and tonumber(fields[1])==incrementBy then
167
+ -- The first time this key is set, and the value will be equal to incrementBy.
168
+ -- So we only need the expire command once
169
+ redis.call("PEXPIRE", key, window)
170
+ end
171
+
172
+ return fields
173
+ `;
174
+ var fixedWindowRemainingTokensScript = `
175
+ local key = KEYS[1]
176
+ local tokens = 0
177
+
178
+ local fields = redis.call("HGETALL", key)
179
+
180
+ return fields
181
+ `;
182
+ var slidingWindowLimitScript = `
183
+ local currentKey = KEYS[1] -- identifier including prefixes
184
+ local previousKey = KEYS[2] -- key of the previous bucket
185
+ local tokens = tonumber(ARGV[1]) -- tokens per window
186
+ local now = ARGV[2] -- current timestamp in milliseconds
187
+ local window = ARGV[3] -- interval in milliseconds
188
+ local requestId = ARGV[4] -- uuid for this request
189
+ local incrementBy = tonumber(ARGV[5]) -- custom rate, default is 1
190
+
191
+ local currentFields = redis.call("HGETALL", currentKey)
192
+ local requestsInCurrentWindow = 0
193
+ for i = 2, #currentFields, 2 do
194
+ requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i])
195
+ end
196
+
197
+ local previousFields = redis.call("HGETALL", previousKey)
198
+ local requestsInPreviousWindow = 0
199
+ for i = 2, #previousFields, 2 do
200
+ requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i])
201
+ end
202
+
203
+ local percentageInCurrent = ( now % window) / window
204
+ if requestsInPreviousWindow * (1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then
205
+ return {currentFields, previousFields, false}
206
+ end
207
+
208
+ redis.call("HSET", currentKey, requestId, incrementBy)
209
+
210
+ if requestsInCurrentWindow == 0 then
211
+ -- The first time this key is set, the value will be equal to incrementBy.
212
+ -- So we only need the expire command once
213
+ redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
214
+ end
215
+ return {currentFields, previousFields, true}
216
+ `;
217
+ var slidingWindowRemainingTokensScript = `
218
+ local currentKey = KEYS[1] -- identifier including prefixes
219
+ local previousKey = KEYS[2] -- key of the previous bucket
220
+ local now = ARGV[1] -- current timestamp in milliseconds
221
+ local window = ARGV[2] -- interval in milliseconds
222
+
223
+ local currentFields = redis.call("HGETALL", currentKey)
224
+ local requestsInCurrentWindow = 0
225
+ for i = 2, #currentFields, 2 do
226
+ requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i])
227
+ end
228
+
229
+ local previousFields = redis.call("HGETALL", previousKey)
230
+ local requestsInPreviousWindow = 0
231
+ for i = 2, #previousFields, 2 do
232
+ requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i])
233
+ end
234
+
235
+ local percentageInCurrent = ( now % window) / window
236
+ requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
237
+
238
+ return requestsInCurrentWindow + requestsInPreviousWindow
239
+ `;
240
+
241
+ // src/lua-scripts/reset.ts
242
+ var resetScript = `
243
+ local pattern = KEYS[1]
244
+
245
+ -- Initialize cursor to start from 0
246
+ local cursor = "0"
247
+
248
+ repeat
249
+ -- Scan for keys matching the pattern
250
+ local scan_result = redis.call('SCAN', cursor, 'MATCH', pattern)
251
+
252
+ -- Extract cursor for the next iteration
253
+ cursor = scan_result[1]
254
+
255
+ -- Extract keys from the scan result
256
+ local keys = scan_result[2]
257
+
258
+ for i=1, #keys do
259
+ redis.call('DEL', keys[i])
260
+ end
261
+
262
+ -- Continue scanning until cursor is 0 (end of keyspace)
263
+ until cursor == "0"
264
+ `;
265
+
151
266
  // src/ratelimit.ts
152
267
  var Ratelimit = class {
153
268
  limiter;
@@ -188,12 +303,29 @@ var Ratelimit = class {
188
303
  * }
189
304
  * return "Yes"
190
305
  * ```
306
+ *
307
+ * @param req.rate - The rate at which tokens will be added or consumed from the token bucket. A higher rate allows for more requests to be processed. Defaults to 1 token per interval if not specified.
308
+ *
309
+ * Usage with `req.rate`
310
+ * @example
311
+ * ```ts
312
+ * const ratelimit = new Ratelimit({
313
+ * redis: Redis.fromEnv(),
314
+ * limiter: Ratelimit.slidingWindow(100, "10 s")
315
+ * })
316
+ *
317
+ * const { success } = await ratelimit.limit(id, {rate: 10})
318
+ * if (!success){
319
+ * return "Nope"
320
+ * }
321
+ * return "Yes"
322
+ * ```
191
323
  */
192
324
  limit = async (identifier, req) => {
193
325
  const key = [this.prefix, identifier].join(":");
194
326
  let timeoutId = null;
195
327
  try {
196
- const arr = [this.limiter(this.ctx, key)];
328
+ const arr = [this.limiter().limit(this.ctx, key, req?.rate)];
197
329
  if (this.timeout > 0) {
198
330
  arr.push(
199
331
  new Promise((resolve) => {
@@ -277,6 +409,14 @@ var Ratelimit = class {
277
409
  }
278
410
  return res;
279
411
  };
412
+ resetUsedTokens = async (identifier) => {
413
+ const pattern = [this.prefix, identifier].join(":");
414
+ await this.limiter().resetTokens(this.ctx, pattern);
415
+ };
416
+ getRemaining = async (identifier) => {
417
+ const pattern = [this.prefix, identifier].join(":");
418
+ return await this.limiter().getRemaining(this.ctx, pattern);
419
+ };
280
420
  };
281
421
 
282
422
  // src/multi.ts
@@ -325,72 +465,122 @@ var MultiRegionRatelimit = class extends Ratelimit {
325
465
  */
326
466
  static fixedWindow(tokens, window) {
327
467
  const windowDuration = ms(window);
328
- const script = `
329
- local key = KEYS[1]
330
- local id = ARGV[1]
331
- local window = ARGV[2]
332
-
333
- redis.call("SADD", key, id)
334
- local members = redis.call("SMEMBERS", key)
335
- if #members == 1 then
336
- -- The first time this key is set, the value will be 1.
337
- -- So we only need the expire command once
338
- redis.call("PEXPIRE", key, window)
339
- end
340
-
341
- return members
342
- `;
343
- return async function(ctx, identifier) {
344
- if (ctx.cache) {
345
- const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
346
- if (blocked) {
347
- return {
348
- success: false,
349
- limit: tokens,
350
- remaining: 0,
351
- reset: reset2,
352
- pending: Promise.resolve()
353
- };
468
+ return () => ({
469
+ async limit(ctx, identifier, rate) {
470
+ if (ctx.cache) {
471
+ const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
472
+ if (blocked) {
473
+ return {
474
+ success: false,
475
+ limit: tokens,
476
+ remaining: 0,
477
+ reset: reset2,
478
+ pending: Promise.resolve()
479
+ };
480
+ }
354
481
  }
355
- }
356
- const requestId = randomId();
357
- const bucket = Math.floor(Date.now() / windowDuration);
358
- const key = [identifier, bucket].join(":");
359
- const dbs = ctx.redis.map((redis) => ({
360
- redis,
361
- request: redis.eval(script, [key], [requestId, windowDuration])
362
- }));
363
- const firstResponse = await Promise.any(dbs.map((s) => s.request));
364
- const usedTokens = firstResponse.length;
365
- const remaining = tokens - usedTokens - 1;
366
- async function sync() {
367
- const individualIDs = await Promise.all(dbs.map((s) => s.request));
368
- const allIDs = Array.from(new Set(individualIDs.flatMap((_) => _)).values());
369
- for (const db of dbs) {
370
- const ids = await db.request;
371
- if (ids.length >= tokens) {
372
- continue;
482
+ const requestId = randomId();
483
+ const bucket = Math.floor(Date.now() / windowDuration);
484
+ const key = [identifier, bucket].join(":");
485
+ const incrementBy = rate ? Math.max(1, rate) : 1;
486
+ const dbs = ctx.redis.map((redis) => ({
487
+ redis,
488
+ request: redis.eval(
489
+ fixedWindowLimitScript,
490
+ [key],
491
+ [requestId, windowDuration, incrementBy]
492
+ )
493
+ }));
494
+ const firstResponse = await Promise.any(dbs.map((s) => s.request));
495
+ const usedTokens = firstResponse.reduce((accTokens, usedToken, index) => {
496
+ let parsedToken = 0;
497
+ if (index % 2) {
498
+ parsedToken = Number.parseInt(usedToken);
373
499
  }
374
- const diff = allIDs.filter((id) => !ids.includes(id));
375
- if (diff.length === 0) {
376
- continue;
500
+ return accTokens + parsedToken;
501
+ }, 0);
502
+ const remaining = tokens - usedTokens;
503
+ async function sync() {
504
+ const individualIDs = await Promise.all(dbs.map((s) => s.request));
505
+ const allIDs = Array.from(
506
+ new Set(
507
+ individualIDs.flatMap((_) => _).reduce((acc, curr, index) => {
508
+ if (index % 2 === 0) {
509
+ acc.push(curr);
510
+ }
511
+ return acc;
512
+ }, [])
513
+ ).values()
514
+ );
515
+ for (const db of dbs) {
516
+ const usedDbTokens = (await db.request).reduce(
517
+ (accTokens, usedToken, index) => {
518
+ let parsedToken = 0;
519
+ if (index % 2) {
520
+ parsedToken = Number.parseInt(usedToken);
521
+ }
522
+ return accTokens + parsedToken;
523
+ },
524
+ 0
525
+ );
526
+ const dbIds = (await db.request).reduce((ids, currentId, index) => {
527
+ if (index % 2 === 0) {
528
+ ids.push(currentId);
529
+ }
530
+ return ids;
531
+ }, []);
532
+ if (usedDbTokens >= tokens) {
533
+ continue;
534
+ }
535
+ const diff = allIDs.filter((id) => !dbIds.includes(id));
536
+ if (diff.length === 0) {
537
+ continue;
538
+ }
539
+ for (const requestId2 of diff) {
540
+ await db.redis.hset(key, { [requestId2]: incrementBy });
541
+ }
377
542
  }
378
- await db.redis.sadd(key, ...allIDs);
543
+ }
544
+ const success = remaining > 0;
545
+ const reset = (bucket + 1) * windowDuration;
546
+ if (ctx.cache && !success) {
547
+ ctx.cache.blockUntil(identifier, reset);
548
+ }
549
+ return {
550
+ success,
551
+ limit: tokens,
552
+ remaining,
553
+ reset,
554
+ pending: sync()
555
+ };
556
+ },
557
+ async getRemaining(ctx, identifier) {
558
+ const bucket = Math.floor(Date.now() / windowDuration);
559
+ const key = [identifier, bucket].join(":");
560
+ const dbs = ctx.redis.map((redis) => ({
561
+ redis,
562
+ request: redis.eval(fixedWindowRemainingTokensScript, [key], [null])
563
+ }));
564
+ const firstResponse = await Promise.any(dbs.map((s) => s.request));
565
+ const usedTokens = firstResponse.reduce((accTokens, usedToken, index) => {
566
+ let parsedToken = 0;
567
+ if (index % 2) {
568
+ parsedToken = Number.parseInt(usedToken);
569
+ }
570
+ return accTokens + parsedToken;
571
+ }, 0);
572
+ return Math.max(0, tokens - usedTokens);
573
+ },
574
+ async resetTokens(ctx, identifier) {
575
+ const pattern = [identifier, "*"].join(":");
576
+ if (ctx.cache) {
577
+ ctx.cache.pop(identifier);
578
+ }
579
+ for (const db of ctx.redis) {
580
+ await db.eval(resetScript, [pattern], [null]);
379
581
  }
380
582
  }
381
- const success = remaining > 0;
382
- const reset = (bucket + 1) * windowDuration;
383
- if (ctx.cache && !success) {
384
- ctx.cache.blockUntil(identifier, reset);
385
- }
386
- return {
387
- success,
388
- limit: tokens,
389
- remaining,
390
- reset,
391
- pending: sync()
392
- };
393
- };
583
+ });
394
584
  }
395
585
  /**
396
586
  * Combined approach of `slidingLogs` and `fixedWindow` with lower storage
@@ -410,86 +600,277 @@ var MultiRegionRatelimit = class extends Ratelimit {
410
600
  */
411
601
  static slidingWindow(tokens, window) {
412
602
  const windowSize = ms(window);
413
- const script = `
414
- local currentKey = KEYS[1] -- identifier including prefixes
415
- local previousKey = KEYS[2] -- key of the previous bucket
416
- local tokens = tonumber(ARGV[1]) -- tokens per window
417
- local now = ARGV[2] -- current timestamp in milliseconds
418
- local window = ARGV[3] -- interval in milliseconds
419
- local requestId = ARGV[4] -- uuid for this request
420
-
421
-
422
- local currentMembers = redis.call("SMEMBERS", currentKey)
423
- local requestsInCurrentWindow = #currentMembers
424
- local previousMembers = redis.call("SMEMBERS", previousKey)
425
- local requestsInPreviousWindow = #previousMembers
426
-
427
- local percentageInCurrent = ( now % window) / window
428
- if requestsInPreviousWindow * ( 1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then
429
- return {currentMembers, previousMembers, false}
430
- end
431
-
432
- redis.call("SADD", currentKey, requestId)
433
- table.insert(currentMembers, requestId)
434
- if requestsInCurrentWindow == 0 then
435
- -- The first time this key is set, the value will be 1.
436
- -- So we only need the expire command once
437
- redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
438
- end
439
- return {currentMembers, previousMembers, true}
440
- `;
441
603
  const windowDuration = ms(window);
442
- return async function(ctx, identifier) {
443
- const requestId = randomId();
444
- const now = Date.now();
445
- const currentWindow = Math.floor(now / windowSize);
446
- const currentKey = [identifier, currentWindow].join(":");
447
- const previousWindow = currentWindow - 1;
448
- const previousKey = [identifier, previousWindow].join(":");
449
- const dbs = ctx.redis.map((redis) => ({
450
- redis,
451
- request: redis.eval(
452
- script,
453
- [currentKey, previousKey],
454
- [tokens, now, windowDuration, requestId]
455
- // lua seems to return `1` for true and `null` for false
456
- )
457
- }));
458
- const percentageInCurrent = now % windowDuration / windowDuration;
459
- const [current, previous, success] = await Promise.any(dbs.map((s) => s.request));
460
- const previousPartialUsed = previous.length * (1 - percentageInCurrent);
461
- const usedTokens = previousPartialUsed + current.length;
462
- const remaining = tokens - usedTokens;
463
- async function sync() {
464
- const res = await Promise.all(dbs.map((s) => s.request));
465
- const allCurrentIds = res.flatMap(([current2]) => current2);
466
- for (const db of dbs) {
467
- const [ids] = await db.request;
468
- if (ids.length >= tokens) {
469
- continue;
604
+ return () => ({
605
+ async limit(ctx, identifier, rate) {
606
+ const requestId = randomId();
607
+ const now = Date.now();
608
+ const currentWindow = Math.floor(now / windowSize);
609
+ const currentKey = [identifier, currentWindow].join(":");
610
+ const previousWindow = currentWindow - 1;
611
+ const previousKey = [identifier, previousWindow].join(":");
612
+ const incrementBy = rate ? Math.max(1, rate) : 1;
613
+ const dbs = ctx.redis.map((redis) => ({
614
+ redis,
615
+ request: redis.eval(
616
+ slidingWindowLimitScript,
617
+ [currentKey, previousKey],
618
+ [tokens, now, windowDuration, requestId, incrementBy]
619
+ // lua seems to return `1` for true and `null` for false
620
+ )
621
+ }));
622
+ const percentageInCurrent = now % windowDuration / windowDuration;
623
+ const [current, previous, success] = await Promise.any(dbs.map((s) => s.request));
624
+ const previousUsedTokens = previous.reduce((accTokens, usedToken, index) => {
625
+ let parsedToken = 0;
626
+ if (index % 2) {
627
+ parsedToken = Number.parseInt(usedToken);
470
628
  }
471
- const diff = allCurrentIds.filter((id) => !ids.includes(id));
472
- if (diff.length === 0) {
473
- continue;
629
+ return accTokens + parsedToken;
630
+ }, 0);
631
+ const currentUsedTokens = current.reduce((accTokens, usedToken, index) => {
632
+ let parsedToken = 0;
633
+ if (index % 2) {
634
+ parsedToken = Number.parseInt(usedToken);
635
+ }
636
+ return accTokens + parsedToken;
637
+ }, 0);
638
+ const previousPartialUsed = previousUsedTokens * (1 - percentageInCurrent);
639
+ const usedTokens = previousPartialUsed + currentUsedTokens;
640
+ const remaining = tokens - usedTokens;
641
+ async function sync() {
642
+ const res = await Promise.all(dbs.map((s) => s.request));
643
+ const allCurrentIds = res.flatMap(([current2]) => current2).reduce((accCurrentIds, curr, index) => {
644
+ if (index % 2 === 0) {
645
+ accCurrentIds.push(curr);
646
+ }
647
+ return accCurrentIds;
648
+ }, []);
649
+ for (const db of dbs) {
650
+ const [_current, previous2, _success] = await db.request;
651
+ const dbIds = previous2.reduce((ids, currentId, index) => {
652
+ if (index % 2 === 0) {
653
+ ids.push(currentId);
654
+ }
655
+ return ids;
656
+ }, []);
657
+ const usedDbTokens = previous2.reduce((accTokens, usedToken, index) => {
658
+ let parsedToken = 0;
659
+ if (index % 2) {
660
+ parsedToken = Number.parseInt(usedToken);
661
+ }
662
+ return accTokens + parsedToken;
663
+ }, 0);
664
+ if (usedDbTokens >= tokens) {
665
+ continue;
666
+ }
667
+ const diff = allCurrentIds.filter((id) => !dbIds.includes(id));
668
+ if (diff.length === 0) {
669
+ continue;
670
+ }
671
+ for (const requestId2 of diff) {
672
+ await db.redis.hset(currentKey, { [requestId2]: incrementBy });
673
+ }
474
674
  }
475
- await db.redis.sadd(currentKey, ...diff);
675
+ }
676
+ const reset = (currentWindow + 1) * windowDuration;
677
+ if (ctx.cache && !success) {
678
+ ctx.cache.blockUntil(identifier, reset);
679
+ }
680
+ return {
681
+ success: Boolean(success),
682
+ limit: tokens,
683
+ remaining: Math.max(0, remaining),
684
+ reset,
685
+ pending: sync()
686
+ };
687
+ },
688
+ async getRemaining(ctx, identifier) {
689
+ const now = Date.now();
690
+ const currentWindow = Math.floor(now / windowSize);
691
+ const currentKey = [identifier, currentWindow].join(":");
692
+ const previousWindow = currentWindow - 1;
693
+ const previousKey = [identifier, previousWindow].join(":");
694
+ const dbs = ctx.redis.map((redis) => ({
695
+ redis,
696
+ request: redis.eval(
697
+ slidingWindowRemainingTokensScript,
698
+ [currentKey, previousKey],
699
+ [now, windowSize]
700
+ // lua seems to return `1` for true and `null` for false
701
+ )
702
+ }));
703
+ const usedTokens = await Promise.any(dbs.map((s) => s.request));
704
+ return Math.max(0, tokens - usedTokens);
705
+ },
706
+ async resetTokens(ctx, identifier) {
707
+ const pattern = [identifier, "*"].join(":");
708
+ if (ctx.cache) {
709
+ ctx.cache.pop(identifier);
710
+ }
711
+ for (const db of ctx.redis) {
712
+ await db.eval(resetScript, [pattern], [null]);
476
713
  }
477
714
  }
478
- const reset = (currentWindow + 1) * windowDuration;
479
- if (ctx.cache && !success) {
480
- ctx.cache.blockUntil(identifier, reset);
481
- }
482
- return {
483
- success: Boolean(success),
484
- limit: tokens,
485
- remaining,
486
- reset,
487
- pending: sync()
488
- };
489
- };
715
+ });
490
716
  }
491
717
  };
492
718
 
719
+ // src/lua-scripts/single.ts
720
+ var fixedWindowLimitScript2 = `
721
+ local key = KEYS[1]
722
+ local window = ARGV[1]
723
+ local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1
724
+
725
+ local r = redis.call("INCRBY", key, incrementBy)
726
+ if r == tonumber(incrementBy) then
727
+ -- The first time this key is set, the value will be equal to incrementBy.
728
+ -- So we only need the expire command once
729
+ redis.call("PEXPIRE", key, window)
730
+ end
731
+
732
+ return r
733
+ `;
734
+ var fixedWindowRemainingTokensScript2 = `
735
+ local key = KEYS[1]
736
+ local tokens = 0
737
+
738
+ local value = redis.call('GET', key)
739
+ if value then
740
+ tokens = value
741
+ end
742
+ return tokens
743
+ `;
744
+ var slidingWindowLimitScript2 = `
745
+ local currentKey = KEYS[1] -- identifier including prefixes
746
+ local previousKey = KEYS[2] -- key of the previous bucket
747
+ local tokens = tonumber(ARGV[1]) -- tokens per window
748
+ local now = ARGV[2] -- current timestamp in milliseconds
749
+ local window = ARGV[3] -- interval in milliseconds
750
+ local incrementBy = ARGV[4] -- increment rate per request at a given value, default is 1
751
+
752
+ local requestsInCurrentWindow = redis.call("GET", currentKey)
753
+ if requestsInCurrentWindow == false then
754
+ requestsInCurrentWindow = 0
755
+ end
756
+
757
+ local requestsInPreviousWindow = redis.call("GET", previousKey)
758
+ if requestsInPreviousWindow == false then
759
+ requestsInPreviousWindow = 0
760
+ end
761
+ local percentageInCurrent = ( now % window ) / window
762
+ -- weighted requests to consider from the previous window
763
+ requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
764
+ if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then
765
+ return -1
766
+ end
767
+
768
+ local newValue = redis.call("INCRBY", currentKey, incrementBy)
769
+ if newValue == tonumber(incrementBy) then
770
+ -- The first time this key is set, the value will be equal to incrementBy.
771
+ -- So we only need the expire command once
772
+ redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
773
+ end
774
+ return tokens - ( newValue + requestsInPreviousWindow )
775
+ `;
776
+ var slidingWindowRemainingTokensScript2 = `
777
+ local currentKey = KEYS[1] -- identifier including prefixes
778
+ local previousKey = KEYS[2] -- key of the previous bucket
779
+ local now = ARGV[1] -- current timestamp in milliseconds
780
+ local window = ARGV[2] -- interval in milliseconds
781
+
782
+ local requestsInCurrentWindow = redis.call("GET", currentKey)
783
+ if requestsInCurrentWindow == false then
784
+ requestsInCurrentWindow = 0
785
+ end
786
+
787
+ local requestsInPreviousWindow = redis.call("GET", previousKey)
788
+ if requestsInPreviousWindow == false then
789
+ requestsInPreviousWindow = 0
790
+ end
791
+
792
+ local percentageInCurrent = ( now % window ) / window
793
+ -- weighted requests to consider from the previous window
794
+ requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
795
+
796
+ return requestsInPreviousWindow + requestsInCurrentWindow
797
+ `;
798
+ var tokenBucketLimitScript = `
799
+ local key = KEYS[1] -- identifier including prefixes
800
+ local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
801
+ local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
802
+ local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval
803
+ local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
804
+ local incrementBy = tonumber(ARGV[5]) -- how many tokens to consume, default is 1
805
+
806
+ local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
807
+
808
+ local refilledAt
809
+ local tokens
810
+
811
+ if bucket[1] == false then
812
+ refilledAt = now
813
+ tokens = maxTokens
814
+ else
815
+ refilledAt = tonumber(bucket[1])
816
+ tokens = tonumber(bucket[2])
817
+ end
818
+
819
+ if now >= refilledAt + interval then
820
+ local numRefills = math.floor((now - refilledAt) / interval)
821
+ tokens = math.min(maxTokens, tokens + numRefills * refillRate)
822
+
823
+ refilledAt = refilledAt + numRefills * interval
824
+ end
825
+
826
+ if tokens == 0 then
827
+ return {-1, refilledAt + interval}
828
+ end
829
+
830
+ local remaining = tokens - incrementBy
831
+ local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval
832
+
833
+ redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)
834
+ redis.call("PEXPIRE", key, expireAt)
835
+ return {remaining, refilledAt + interval}
836
+ `;
837
+ var tokenBucketRemainingTokensScript = `
838
+ local key = KEYS[1]
839
+ local maxTokens = tonumber(ARGV[1])
840
+
841
+ local bucket = redis.call("HMGET", key, "tokens")
842
+
843
+ if bucket[1] == false then
844
+ return maxTokens
845
+ end
846
+
847
+ return tonumber(bucket[1])
848
+ `;
849
+ var cachedFixedWindowLimitScript = `
850
+ local key = KEYS[1]
851
+ local window = ARGV[1]
852
+ local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1
853
+
854
+ local r = redis.call("INCRBY", key, incrementBy)
855
+ if r == incrementBy then
856
+ -- The first time this key is set, the value will be equal to incrementBy.
857
+ -- So we only need the expire command once
858
+ redis.call("PEXPIRE", key, window)
859
+ end
860
+
861
+ return r
862
+ `;
863
+ var cachedFixedWindowRemainingTokenScript = `
864
+ local key = KEYS[1]
865
+ local tokens = 0
866
+
867
+ local value = redis.call('GET', key)
868
+ if value then
869
+ tokens = value
870
+ end
871
+ return tokens
872
+ `;
873
+
493
874
  // src/single.ts
494
875
  var RegionRatelimit = class extends Ratelimit {
495
876
  /**
@@ -527,52 +908,60 @@ var RegionRatelimit = class extends Ratelimit {
527
908
  */
528
909
  static fixedWindow(tokens, window) {
529
910
  const windowDuration = ms(window);
530
- const script = `
531
- local key = KEYS[1]
532
- local window = ARGV[1]
533
-
534
- local r = redis.call("INCR", key)
535
- if r == 1 then
536
- -- The first time this key is set, the value will be 1.
537
- -- So we only need the expire command once
538
- redis.call("PEXPIRE", key, window)
539
- end
540
-
541
- return r
542
- `;
543
- return async function(ctx, identifier) {
544
- const bucket = Math.floor(Date.now() / windowDuration);
545
- const key = [identifier, bucket].join(":");
546
- if (ctx.cache) {
547
- const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
548
- if (blocked) {
549
- return {
550
- success: false,
551
- limit: tokens,
552
- remaining: 0,
553
- reset: reset2,
554
- pending: Promise.resolve()
555
- };
911
+ return () => ({
912
+ async limit(ctx, identifier, rate) {
913
+ const bucket = Math.floor(Date.now() / windowDuration);
914
+ const key = [identifier, bucket].join(":");
915
+ if (ctx.cache) {
916
+ const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
917
+ if (blocked) {
918
+ return {
919
+ success: false,
920
+ limit: tokens,
921
+ remaining: 0,
922
+ reset: reset2,
923
+ pending: Promise.resolve()
924
+ };
925
+ }
556
926
  }
927
+ const incrementBy = rate ? Math.max(1, rate) : 1;
928
+ const usedTokensAfterUpdate = await ctx.redis.eval(
929
+ fixedWindowLimitScript2,
930
+ [key],
931
+ [windowDuration, incrementBy]
932
+ );
933
+ const success = usedTokensAfterUpdate <= tokens;
934
+ const remainingTokens = Math.max(0, tokens - usedTokensAfterUpdate);
935
+ const reset = (bucket + 1) * windowDuration;
936
+ if (ctx.cache && !success) {
937
+ ctx.cache.blockUntil(identifier, reset);
938
+ }
939
+ return {
940
+ success,
941
+ limit: tokens,
942
+ remaining: remainingTokens,
943
+ reset,
944
+ pending: Promise.resolve()
945
+ };
946
+ },
947
+ async getRemaining(ctx, identifier) {
948
+ const bucket = Math.floor(Date.now() / windowDuration);
949
+ const key = [identifier, bucket].join(":");
950
+ const usedTokens = await ctx.redis.eval(
951
+ fixedWindowRemainingTokensScript2,
952
+ [key],
953
+ [null]
954
+ );
955
+ return Math.max(0, tokens - usedTokens);
956
+ },
957
+ async resetTokens(ctx, identifier) {
958
+ const pattern = [identifier, "*"].join(":");
959
+ if (ctx.cache) {
960
+ ctx.cache.pop(identifier);
961
+ }
962
+ await ctx.redis.eval(resetScript, [pattern], [null]);
557
963
  }
558
- const usedTokensAfterUpdate = await ctx.redis.eval(
559
- script,
560
- [key],
561
- [windowDuration]
562
- );
563
- const success = usedTokensAfterUpdate <= tokens;
564
- const reset = (bucket + 1) * windowDuration;
565
- if (ctx.cache && !success) {
566
- ctx.cache.blockUntil(identifier, reset);
567
- }
568
- return {
569
- success,
570
- limit: tokens,
571
- remaining: Math.max(0, tokens - usedTokensAfterUpdate),
572
- reset,
573
- pending: Promise.resolve()
574
- };
575
- };
964
+ });
576
965
  }
577
966
  /**
578
967
  * Combined approach of `slidingLogs` and `fixedWindow` with lower storage
@@ -591,74 +980,66 @@ var RegionRatelimit = class extends Ratelimit {
591
980
  * @param window - The duration in which the user can max X requests.
592
981
  */
593
982
  static slidingWindow(tokens, window) {
594
- const script = `
595
- local currentKey = KEYS[1] -- identifier including prefixes
596
- local previousKey = KEYS[2] -- key of the previous bucket
597
- local tokens = tonumber(ARGV[1]) -- tokens per window
598
- local now = ARGV[2] -- current timestamp in milliseconds
599
- local window = ARGV[3] -- interval in milliseconds
600
-
601
- local requestsInCurrentWindow = redis.call("GET", currentKey)
602
- if requestsInCurrentWindow == false then
603
- requestsInCurrentWindow = 0
604
- end
605
-
606
- local requestsInPreviousWindow = redis.call("GET", previousKey)
607
- if requestsInPreviousWindow == false then
608
- requestsInPreviousWindow = 0
609
- end
610
- local percentageInCurrent = ( now % window ) / window
611
- -- weighted requests to consider from the previous window
612
- requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
613
- if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then
614
- return -1
615
- end
616
-
617
- local newValue = redis.call("INCR", currentKey)
618
- if newValue == 1 then
619
- -- The first time this key is set, the value will be 1.
620
- -- So we only need the expire command once
621
- redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
622
- end
623
- return tokens - ( newValue + requestsInPreviousWindow )
624
- `;
625
983
  const windowSize = ms(window);
626
- return async function(ctx, identifier) {
627
- const now = Date.now();
628
- const currentWindow = Math.floor(now / windowSize);
629
- const currentKey = [identifier, currentWindow].join(":");
630
- const previousWindow = currentWindow - 1;
631
- const previousKey = [identifier, previousWindow].join(":");
632
- if (ctx.cache) {
633
- const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
634
- if (blocked) {
635
- return {
636
- success: false,
637
- limit: tokens,
638
- remaining: 0,
639
- reset: reset2,
640
- pending: Promise.resolve()
641
- };
984
+ return () => ({
985
+ async limit(ctx, identifier, rate) {
986
+ const now = Date.now();
987
+ const currentWindow = Math.floor(now / windowSize);
988
+ const currentKey = [identifier, currentWindow].join(":");
989
+ const previousWindow = currentWindow - 1;
990
+ const previousKey = [identifier, previousWindow].join(":");
991
+ if (ctx.cache) {
992
+ const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
993
+ if (blocked) {
994
+ return {
995
+ success: false,
996
+ limit: tokens,
997
+ remaining: 0,
998
+ reset: reset2,
999
+ pending: Promise.resolve()
1000
+ };
1001
+ }
642
1002
  }
1003
+ const incrementBy = rate ? Math.max(1, rate) : 1;
1004
+ const remainingTokens = await ctx.redis.eval(
1005
+ slidingWindowLimitScript2,
1006
+ [currentKey, previousKey],
1007
+ [tokens, now, windowSize, incrementBy]
1008
+ );
1009
+ const success = remainingTokens >= 0;
1010
+ const reset = (currentWindow + 1) * windowSize;
1011
+ if (ctx.cache && !success) {
1012
+ ctx.cache.blockUntil(identifier, reset);
1013
+ }
1014
+ return {
1015
+ success,
1016
+ limit: tokens,
1017
+ remaining: Math.max(0, remainingTokens),
1018
+ reset,
1019
+ pending: Promise.resolve()
1020
+ };
1021
+ },
1022
+ async getRemaining(ctx, identifier) {
1023
+ const now = Date.now();
1024
+ const currentWindow = Math.floor(now / windowSize);
1025
+ const currentKey = [identifier, currentWindow].join(":");
1026
+ const previousWindow = currentWindow - 1;
1027
+ const previousKey = [identifier, previousWindow].join(":");
1028
+ const usedTokens = await ctx.redis.eval(
1029
+ slidingWindowRemainingTokensScript2,
1030
+ [currentKey, previousKey],
1031
+ [now, windowSize]
1032
+ );
1033
+ return Math.max(0, tokens - usedTokens);
1034
+ },
1035
+ async resetTokens(ctx, identifier) {
1036
+ const pattern = [identifier, "*"].join(":");
1037
+ if (ctx.cache) {
1038
+ ctx.cache.pop(identifier);
1039
+ }
1040
+ await ctx.redis.eval(resetScript, [pattern], [null]);
643
1041
  }
644
- const remaining = await ctx.redis.eval(
645
- script,
646
- [currentKey, previousKey],
647
- [tokens, now, windowSize]
648
- );
649
- const success = remaining >= 0;
650
- const reset = (currentWindow + 1) * windowSize;
651
- if (ctx.cache && !success) {
652
- ctx.cache.blockUntil(identifier, reset);
653
- }
654
- return {
655
- success,
656
- limit: tokens,
657
- remaining: Math.max(0, remaining),
658
- reset,
659
- pending: Promise.resolve()
660
- };
661
- };
1042
+ });
662
1043
  }
663
1044
  /**
664
1045
  * You have a bucket filled with `{maxTokens}` tokens that refills constantly
@@ -674,76 +1055,56 @@ var RegionRatelimit = class extends Ratelimit {
674
1055
  * than `refillRate`
675
1056
  */
676
1057
  static tokenBucket(refillRate, interval, maxTokens) {
677
- const script = `
678
- local key = KEYS[1] -- identifier including prefixes
679
- local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
680
- local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
681
- local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval
682
- local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
683
-
684
- local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
685
-
686
- local refilledAt
687
- local tokens
688
-
689
- if bucket[1] == false then
690
- refilledAt = now
691
- tokens = maxTokens
692
- else
693
- refilledAt = tonumber(bucket[1])
694
- tokens = tonumber(bucket[2])
695
- end
696
-
697
- if now >= refilledAt + interval then
698
- local numRefills = math.floor((now - refilledAt) / interval)
699
- tokens = math.min(maxTokens, tokens + numRefills * refillRate)
700
-
701
- refilledAt = refilledAt + numRefills * interval
702
- end
703
-
704
- if tokens == 0 then
705
- return {-1, refilledAt + interval}
706
- end
707
-
708
- local remaining = tokens - 1
709
- local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval
710
-
711
- redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)
712
- redis.call("PEXPIRE", key, expireAt)
713
- return {remaining, refilledAt + interval}
714
- `;
715
1058
  const intervalDuration = ms(interval);
716
- return async function(ctx, identifier) {
717
- if (ctx.cache) {
718
- const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
719
- if (blocked) {
720
- return {
721
- success: false,
722
- limit: maxTokens,
723
- remaining: 0,
724
- reset: reset2,
725
- pending: Promise.resolve()
726
- };
1059
+ return () => ({
1060
+ async limit(ctx, identifier, rate) {
1061
+ if (ctx.cache) {
1062
+ const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier);
1063
+ if (blocked) {
1064
+ return {
1065
+ success: false,
1066
+ limit: maxTokens,
1067
+ remaining: 0,
1068
+ reset: reset2,
1069
+ pending: Promise.resolve()
1070
+ };
1071
+ }
727
1072
  }
1073
+ const now = Date.now();
1074
+ const incrementBy = rate ? Math.max(1, rate) : 1;
1075
+ const [remaining, reset] = await ctx.redis.eval(
1076
+ tokenBucketLimitScript,
1077
+ [identifier],
1078
+ [maxTokens, intervalDuration, refillRate, now, incrementBy]
1079
+ );
1080
+ const success = remaining >= 0;
1081
+ if (ctx.cache && !success) {
1082
+ ctx.cache.blockUntil(identifier, reset);
1083
+ }
1084
+ return {
1085
+ success,
1086
+ limit: maxTokens,
1087
+ remaining,
1088
+ reset,
1089
+ pending: Promise.resolve()
1090
+ };
1091
+ },
1092
+ async getRemaining(ctx, identifier) {
1093
+ const remainingTokens = await ctx.redis.eval(
1094
+ tokenBucketRemainingTokensScript,
1095
+ [identifier],
1096
+ [maxTokens]
1097
+ );
1098
+ return remainingTokens;
1099
+ },
1100
+ async resetTokens(ctx, identifier) {
1101
+ const pattern = identifier;
1102
+ if (ctx.cache) {
1103
+ ctx.cache.pop(identifier);
1104
+ }
1105
+ await ctx.redis.eval(resetScript, [pattern], [null]);
728
1106
  }
729
- const now = Date.now();
730
- const [remaining, reset] = await ctx.redis.eval(
731
- script,
732
- [identifier],
733
- [maxTokens, intervalDuration, refillRate, now]
734
- );
735
- const success = remaining >= 0;
736
- if (ctx.cache && !success) {
737
- ctx.cache.blockUntil(identifier, reset);
738
- }
739
- return {
740
- success,
741
- limit: maxTokens,
742
- remaining,
743
- reset,
744
- pending: Promise.resolve()
745
- };
746
- };
1107
+ });
747
1108
  }
748
1109
  /**
749
1110
  * cachedFixedWindow first uses the local cache to decide if a request may pass and then updates
@@ -771,56 +1132,72 @@ var RegionRatelimit = class extends Ratelimit {
771
1132
  */
772
1133
  static cachedFixedWindow(tokens, window) {
773
1134
  const windowDuration = ms(window);
774
- const script = `
775
- local key = KEYS[1]
776
- local window = ARGV[1]
777
-
778
- local r = redis.call("INCR", key)
779
- if r == 1 then
780
- -- The first time this key is set, the value will be 1.
781
- -- So we only need the expire command once
782
- redis.call("PEXPIRE", key, window)
783
- end
784
-
785
- return r
786
- `;
787
- return async function(ctx, identifier) {
788
- if (!ctx.cache) {
789
- throw new Error("This algorithm requires a cache");
790
- }
791
- const bucket = Math.floor(Date.now() / windowDuration);
792
- const key = [identifier, bucket].join(":");
793
- const reset = (bucket + 1) * windowDuration;
794
- const hit = typeof ctx.cache.get(key) === "number";
795
- if (hit) {
796
- const cachedTokensAfterUpdate = ctx.cache.incr(key);
797
- const success = cachedTokensAfterUpdate < tokens;
798
- const pending = success ? ctx.redis.eval(script, [key], [windowDuration]).then((t) => {
799
- ctx.cache.set(key, t);
800
- }) : Promise.resolve();
1135
+ return () => ({
1136
+ async limit(ctx, identifier, rate) {
1137
+ if (!ctx.cache) {
1138
+ throw new Error("This algorithm requires a cache");
1139
+ }
1140
+ const bucket = Math.floor(Date.now() / windowDuration);
1141
+ const key = [identifier, bucket].join(":");
1142
+ const reset = (bucket + 1) * windowDuration;
1143
+ const incrementBy = rate ? Math.max(1, rate) : 1;
1144
+ const hit = typeof ctx.cache.get(key) === "number";
1145
+ if (hit) {
1146
+ const cachedTokensAfterUpdate = ctx.cache.incr(key);
1147
+ const success = cachedTokensAfterUpdate < tokens;
1148
+ const pending = success ? ctx.redis.eval(cachedFixedWindowLimitScript, [key], [windowDuration, incrementBy]).then((t) => {
1149
+ ctx.cache.set(key, t);
1150
+ }) : Promise.resolve();
1151
+ return {
1152
+ success,
1153
+ limit: tokens,
1154
+ remaining: tokens - cachedTokensAfterUpdate,
1155
+ reset,
1156
+ pending
1157
+ };
1158
+ }
1159
+ const usedTokensAfterUpdate = await ctx.redis.eval(
1160
+ cachedFixedWindowLimitScript,
1161
+ [key],
1162
+ [windowDuration, incrementBy]
1163
+ );
1164
+ ctx.cache.set(key, usedTokensAfterUpdate);
1165
+ const remaining = tokens - usedTokensAfterUpdate;
801
1166
  return {
802
- success,
1167
+ success: remaining >= 0,
803
1168
  limit: tokens,
804
- remaining: tokens - cachedTokensAfterUpdate,
1169
+ remaining,
805
1170
  reset,
806
- pending
1171
+ pending: Promise.resolve()
807
1172
  };
1173
+ },
1174
+ async getRemaining(ctx, identifier) {
1175
+ if (!ctx.cache) {
1176
+ throw new Error("This algorithm requires a cache");
1177
+ }
1178
+ const bucket = Math.floor(Date.now() / windowDuration);
1179
+ const key = [identifier, bucket].join(":");
1180
+ const hit = typeof ctx.cache.get(key) === "number";
1181
+ if (hit) {
1182
+ const cachedUsedTokens = ctx.cache.get(key) ?? 0;
1183
+ return Math.max(0, tokens - cachedUsedTokens);
1184
+ }
1185
+ const usedTokens = await ctx.redis.eval(
1186
+ cachedFixedWindowRemainingTokenScript,
1187
+ [key],
1188
+ [null]
1189
+ );
1190
+ return Math.max(0, tokens - usedTokens);
1191
+ },
1192
+ async resetTokens(ctx, identifier) {
1193
+ const pattern = [identifier, "*"].join(":");
1194
+ if (!ctx.cache) {
1195
+ throw new Error("This algorithm requires a cache");
1196
+ }
1197
+ ctx.cache.pop(identifier);
1198
+ await ctx.redis.eval(resetScript, [pattern], [null]);
808
1199
  }
809
- const usedTokensAfterUpdate = await ctx.redis.eval(
810
- script,
811
- [key],
812
- [windowDuration]
813
- );
814
- ctx.cache.set(key, usedTokensAfterUpdate);
815
- const remaining = tokens - usedTokensAfterUpdate;
816
- return {
817
- success: remaining >= 0,
818
- limit: tokens,
819
- remaining,
820
- reset,
821
- pending: Promise.resolve()
822
- };
823
- };
1200
+ });
824
1201
  }
825
1202
  };
826
1203
  // Annotate the CommonJS export names for ESM import in node: