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