@upstash/ratelimit 0.4.5-canary.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.d.mts +556 -0
- package/dist/index.d.ts +556 -0
- package/dist/index.js +832 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +803 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +1 -22
- package/.github/actions/redis/action.yaml +0 -58
- package/.github/img/dashboard.png +0 -0
- package/.github/workflows/release.yml +0 -46
- package/.github/workflows/stale.yaml +0 -31
- package/.github/workflows/tests.yaml +0 -79
- package/biome.json +0 -37
- package/bun.lockb +0 -0
- package/cmd/set-version.js +0 -14
- package/examples/cloudflare-workers/package.json +0 -18
- package/examples/cloudflare-workers/src/index.ts +0 -35
- package/examples/cloudflare-workers/tsconfig.json +0 -105
- package/examples/cloudflare-workers/wrangler.toml +0 -3
- package/examples/nextjs/LICENSE +0 -21
- package/examples/nextjs/README.md +0 -17
- package/examples/nextjs/components/Breadcrumb.tsx +0 -67
- package/examples/nextjs/components/Header.tsx +0 -18
- package/examples/nextjs/components/ReadBlogPost.tsx +0 -9
- package/examples/nextjs/components/StarButton.tsx +0 -27
- package/examples/nextjs/middleware.ts +0 -35
- package/examples/nextjs/next-env.d.ts +0 -5
- package/examples/nextjs/package.json +0 -27
- package/examples/nextjs/pages/_app.tsx +0 -47
- package/examples/nextjs/pages/api/blocked.ts +0 -6
- package/examples/nextjs/pages/api/hello.ts +0 -5
- package/examples/nextjs/pages/index.tsx +0 -62
- package/examples/nextjs/postcss.config.js +0 -6
- package/examples/nextjs/public/favicon.ico +0 -0
- package/examples/nextjs/public/github.svg +0 -11
- package/examples/nextjs/public/upstash.svg +0 -27
- package/examples/nextjs/styles/globals.css +0 -76
- package/examples/nextjs/tailwind.config.js +0 -19
- package/examples/nextjs/tsconfig.json +0 -21
- package/examples/nextjs13/README.md +0 -38
- package/examples/nextjs13/app/favicon.ico +0 -0
- package/examples/nextjs13/app/globals.css +0 -107
- package/examples/nextjs13/app/layout.tsx +0 -18
- package/examples/nextjs13/app/page.module.css +0 -271
- package/examples/nextjs13/app/route.tsx +0 -14
- package/examples/nextjs13/next.config.js +0 -8
- package/examples/nextjs13/package.json +0 -22
- package/examples/nextjs13/public/next.svg +0 -1
- package/examples/nextjs13/public/thirteen.svg +0 -1
- package/examples/nextjs13/public/vercel.svg +0 -1
- package/examples/nextjs13/tsconfig.json +0 -28
- package/examples/remix/.env.example +0 -2
- package/examples/remix/.eslintrc.js +0 -4
- package/examples/remix/README.md +0 -59
- package/examples/remix/app/root.tsx +0 -25
- package/examples/remix/app/routes/index.tsx +0 -47
- package/examples/remix/package.json +0 -32
- package/examples/remix/public/favicon.ico +0 -0
- package/examples/remix/remix.config.js +0 -12
- package/examples/remix/remix.env.d.ts +0 -2
- package/examples/remix/server.js +0 -4
- package/examples/remix/tsconfig.json +0 -22
- package/examples/with-vercel-kv/README.md +0 -51
- package/examples/with-vercel-kv/app/favicon.ico +0 -0
- package/examples/with-vercel-kv/app/globals.css +0 -27
- package/examples/with-vercel-kv/app/layout.tsx +0 -21
- package/examples/with-vercel-kv/app/page.tsx +0 -71
- package/examples/with-vercel-kv/next.config.js +0 -8
- package/examples/with-vercel-kv/package.json +0 -25
- package/examples/with-vercel-kv/postcss.config.js +0 -6
- package/examples/with-vercel-kv/public/next.svg +0 -1
- package/examples/with-vercel-kv/public/vercel.svg +0 -1
- package/examples/with-vercel-kv/tailwind.config.js +0 -17
- package/examples/with-vercel-kv/tsconfig.json +0 -28
- package/src/analytics.test.ts +0 -23
- package/src/analytics.ts +0 -92
- package/src/blockUntilReady.test.ts +0 -56
- package/src/cache.test.ts +0 -41
- package/src/cache.ts +0 -43
- package/src/duration.test.ts +0 -23
- package/src/duration.ts +0 -30
- package/src/index.ts +0 -17
- package/src/multi.ts +0 -365
- package/src/ratelimit.test.ts +0 -155
- package/src/ratelimit.ts +0 -238
- package/src/single.ts +0 -487
- package/src/test_utils.ts +0 -65
- package/src/tools/seed.ts +0 -37
- package/src/types.ts +0 -78
- package/src/version.ts +0 -1
- package/tsconfig.json +0 -103
- package/tsup.config.js +0 -11
- package/turbo.json +0 -16
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { Ratelimit } from "@upstash/ratelimit";
|
|
2
|
-
import kv from "@vercel/kv";
|
|
3
|
-
import { Inter } from "next/font/google";
|
|
4
|
-
import { headers } from "next/headers";
|
|
5
|
-
import Image from "next/image";
|
|
6
|
-
import Link from "next/link";
|
|
7
|
-
|
|
8
|
-
const ratelimit = new Ratelimit({
|
|
9
|
-
redis: kv,
|
|
10
|
-
limiter: Ratelimit.fixedWindow(10, "60s"),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
export default async function Home() {
|
|
14
|
-
const ip = headers().get("x-forwarded-for");
|
|
15
|
-
const { success, limit, remaining, reset } = await ratelimit.limit(ip ?? "anonymous");
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<main className="flex flex-col items-center justify-between min-h-screen p-24">
|
|
19
|
-
<div className="z-10 items-center justify-between w-full max-w-5xl font-mono text-sm lg:flex">
|
|
20
|
-
<p className="fixed top-0 left-0 flex justify-center w-full pt-8 pb-6 border-b border-gray-300 bg-gradient-to-b from-zinc-200 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
|
|
21
|
-
Check out the source at
|
|
22
|
-
<Link
|
|
23
|
-
href="https://github.com/upstash/ratelimit/tree/main/examples/with-vercel-kv"
|
|
24
|
-
className="font-mono font-bold"
|
|
25
|
-
>
|
|
26
|
-
github.com/upstash/ratelimit
|
|
27
|
-
</Link>
|
|
28
|
-
</p>
|
|
29
|
-
</div>
|
|
30
|
-
|
|
31
|
-
<div className="relative text-4xl lg:text-7xl font-semibold text-center flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 ">
|
|
32
|
-
{success ? (
|
|
33
|
-
<>
|
|
34
|
-
@upstash/ratelimit
|
|
35
|
-
<br />+
|
|
36
|
-
<br />
|
|
37
|
-
Vercel KV
|
|
38
|
-
</>
|
|
39
|
-
) : (
|
|
40
|
-
<>
|
|
41
|
-
You have reached the limit,
|
|
42
|
-
<br />
|
|
43
|
-
please come back later
|
|
44
|
-
</>
|
|
45
|
-
)}
|
|
46
|
-
</div>
|
|
47
|
-
|
|
48
|
-
<div className="grid mb-32 text-center lg:mb-0 lg:grid-cols-4 lg:text-left">
|
|
49
|
-
<div className="px-5 py-4 transition-colors border border-transparent rounded-lg group hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30">
|
|
50
|
-
<h2 className={"mb-3 text-2xl font-semibold"}>Success</h2>
|
|
51
|
-
<p className={"m-0 max-w-[30ch] text-sm opacity-50"}>{success.toString()}</p>
|
|
52
|
-
</div>
|
|
53
|
-
|
|
54
|
-
<div className="px-5 py-4 transition-colors border border-transparent rounded-lg group hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800 hover:dark:bg-opacity-30">
|
|
55
|
-
<h2 className={"mb-3 text-2xl font-semibold"}>Limit </h2>
|
|
56
|
-
<p className={"m-0 max-w-[30ch] text-sm opacity-50"}>{limit}</p>
|
|
57
|
-
</div>
|
|
58
|
-
|
|
59
|
-
<div className="px-5 py-4 transition-colors border border-transparent rounded-lg group hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30">
|
|
60
|
-
<h2 className={"mb-3 text-2xl font-semibold"}>Remaining</h2>
|
|
61
|
-
<p className={"m-0 max-w-[30ch] text-sm opacity-50"}>{remaining}</p>
|
|
62
|
-
</div>
|
|
63
|
-
|
|
64
|
-
<div className="px-5 py-4 transition-colors border border-transparent rounded-lg group hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30">
|
|
65
|
-
<h2 className={"mb-3 text-2xl font-semibold"}>Reset</h2>
|
|
66
|
-
<p className={"m-0 max-w-[30ch] text-sm opacity-50"}>{new Date(reset).toUTCString()}</p>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
69
|
-
</main>
|
|
70
|
-
);
|
|
71
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "with-vercel-kv",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"scripts": {
|
|
6
|
-
"dev": "next dev",
|
|
7
|
-
"build": "next build",
|
|
8
|
-
"start": "next start",
|
|
9
|
-
"lint": "next lint"
|
|
10
|
-
},
|
|
11
|
-
"dependencies": {
|
|
12
|
-
"@types/node": "20.1.2",
|
|
13
|
-
"@types/react": "18.2.6",
|
|
14
|
-
"@types/react-dom": "18.2.4",
|
|
15
|
-
"@upstash/ratelimit": "^0.4.3",
|
|
16
|
-
"@vercel/kv": "^0.1.2",
|
|
17
|
-
"autoprefixer": "10.4.14",
|
|
18
|
-
"next": "13.4.1",
|
|
19
|
-
"postcss": "8.4.23",
|
|
20
|
-
"react": "18.2.0",
|
|
21
|
-
"react-dom": "18.2.0",
|
|
22
|
-
"tailwindcss": "3.3.2",
|
|
23
|
-
"typescript": "5.0.4"
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/** @type {import('tailwindcss').Config} */
|
|
2
|
-
module.exports = {
|
|
3
|
-
content: [
|
|
4
|
-
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
5
|
-
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
6
|
-
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
7
|
-
],
|
|
8
|
-
theme: {
|
|
9
|
-
extend: {
|
|
10
|
-
backgroundImage: {
|
|
11
|
-
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
|
12
|
-
"gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
plugins: [],
|
|
17
|
-
};
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "es5",
|
|
4
|
-
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
-
"allowJs": true,
|
|
6
|
-
"skipLibCheck": true,
|
|
7
|
-
"strict": true,
|
|
8
|
-
"forceConsistentCasingInFileNames": true,
|
|
9
|
-
"noEmit": true,
|
|
10
|
-
"esModuleInterop": true,
|
|
11
|
-
"module": "esnext",
|
|
12
|
-
"moduleResolution": "node",
|
|
13
|
-
"resolveJsonModule": true,
|
|
14
|
-
"isolatedModules": true,
|
|
15
|
-
"jsx": "preserve",
|
|
16
|
-
"incremental": true,
|
|
17
|
-
"plugins": [
|
|
18
|
-
{
|
|
19
|
-
"name": "next"
|
|
20
|
-
}
|
|
21
|
-
],
|
|
22
|
-
"paths": {
|
|
23
|
-
"@/*": ["./*"]
|
|
24
|
-
}
|
|
25
|
-
},
|
|
26
|
-
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
27
|
-
"exclude": ["node_modules"]
|
|
28
|
-
}
|
package/src/analytics.test.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import { Redis } from "@upstash/redis";
|
|
4
|
-
import { Analytics } from "./analytics";
|
|
5
|
-
|
|
6
|
-
test("analytics", async () => {
|
|
7
|
-
const redis = Redis.fromEnv();
|
|
8
|
-
const a = new Analytics({ redis, prefix: crypto.randomUUID() });
|
|
9
|
-
const time = Date.now();
|
|
10
|
-
for (let i = 0; i < 20; i++) {
|
|
11
|
-
await a.record({
|
|
12
|
-
identifier: "id",
|
|
13
|
-
success: true,
|
|
14
|
-
time,
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const usage = await a.getUsage(Date.now() - 1000 * 60 * 60 * 24);
|
|
19
|
-
expect(Object.entries(usage).length).toBe(1);
|
|
20
|
-
expect(Object.keys(usage)).toContain("id");
|
|
21
|
-
expect(usage.id.success).toBe(20);
|
|
22
|
-
expect(usage.id.blocked).toBe(0);
|
|
23
|
-
});
|
package/src/analytics.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { Analytics as CoreAnalytics } from "@upstash/core-analytics";
|
|
2
|
-
import type { Redis } from "./types";
|
|
3
|
-
|
|
4
|
-
export type Geo = {
|
|
5
|
-
country?: string;
|
|
6
|
-
city?: string;
|
|
7
|
-
region?: string;
|
|
8
|
-
ip?: string;
|
|
9
|
-
};
|
|
10
|
-
export type Event = Geo & {
|
|
11
|
-
identifier: string;
|
|
12
|
-
time: number;
|
|
13
|
-
success: boolean;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export type AnalyticsConfig = {
|
|
17
|
-
redis: Redis;
|
|
18
|
-
prefix?: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The Analytics package is experimental and can change at any time.
|
|
23
|
-
*/
|
|
24
|
-
export class Analytics {
|
|
25
|
-
private readonly analytics: CoreAnalytics;
|
|
26
|
-
private readonly table = "events";
|
|
27
|
-
|
|
28
|
-
constructor(config: AnalyticsConfig) {
|
|
29
|
-
this.analytics = new CoreAnalytics({
|
|
30
|
-
// @ts-expect-error we need to fix the types in core-analytics, it should only require the methods it needs, not the whole sdk
|
|
31
|
-
redis: config.redis,
|
|
32
|
-
window: "1h",
|
|
33
|
-
prefix: config.prefix ?? "@upstash/ratelimit",
|
|
34
|
-
retention: "90d",
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Try to extract the geo information from the request
|
|
40
|
-
*
|
|
41
|
-
* This handles Vercel's `req.geo` and and Cloudflare's `request.cf` properties
|
|
42
|
-
* @param req
|
|
43
|
-
* @returns
|
|
44
|
-
*/
|
|
45
|
-
public extractGeo(req: { geo?: Geo; cf?: Geo }): Geo {
|
|
46
|
-
if (typeof req.geo !== "undefined") {
|
|
47
|
-
return req.geo;
|
|
48
|
-
}
|
|
49
|
-
if (typeof req.cf !== "undefined") {
|
|
50
|
-
return req.cf;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return {};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
public async record(event: Event): Promise<void> {
|
|
57
|
-
await this.analytics.ingest(this.table, event);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async series<TFilter extends keyof Omit<Event, "time">>(
|
|
61
|
-
filter: TFilter,
|
|
62
|
-
cutoff: number,
|
|
63
|
-
): Promise<({ time: number } & Record<string, number>)[]> {
|
|
64
|
-
const records = await this.analytics.query(this.table, {
|
|
65
|
-
filter: [filter],
|
|
66
|
-
range: [cutoff, Date.now()],
|
|
67
|
-
});
|
|
68
|
-
return records;
|
|
69
|
-
}
|
|
70
|
-
public async getUsage(cutoff = 0): Promise<Record<string, { success: number; blocked: number }>> {
|
|
71
|
-
const records = await this.analytics.aggregateBy(this.table, "identifier", {
|
|
72
|
-
range: [cutoff, Date.now()],
|
|
73
|
-
});
|
|
74
|
-
const usage = {} as Record<string, { success: number; blocked: number }>;
|
|
75
|
-
for (const bucket of records) {
|
|
76
|
-
for (const [k, v] of Object.entries(bucket)) {
|
|
77
|
-
if (k === "time") {
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (!usage[k]) {
|
|
82
|
-
usage[k] = { success: 0, blocked: 0 };
|
|
83
|
-
}
|
|
84
|
-
// @ts-ignore
|
|
85
|
-
usage[k].success += v.true ?? 0;
|
|
86
|
-
// @ts-ignore
|
|
87
|
-
usage[k].blocked += v.false ?? 0;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return usage;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import { Redis } from "@upstash/redis";
|
|
4
|
-
import { Ratelimit } from "./index";
|
|
5
|
-
|
|
6
|
-
const redis = Redis.fromEnv();
|
|
7
|
-
|
|
8
|
-
const metrics: Record<string | symbol, number> = {};
|
|
9
|
-
|
|
10
|
-
const spy = new Proxy(redis, {
|
|
11
|
-
get: (target, prop) => {
|
|
12
|
-
if (typeof metrics[prop] === "undefined") {
|
|
13
|
-
metrics[prop] = 0;
|
|
14
|
-
}
|
|
15
|
-
metrics[prop]++;
|
|
16
|
-
// @ts-ignore - we don't care about the types here
|
|
17
|
-
return target[prop];
|
|
18
|
-
},
|
|
19
|
-
});
|
|
20
|
-
const limiter = new Ratelimit({
|
|
21
|
-
redis: spy,
|
|
22
|
-
limiter: Ratelimit.fixedWindow(5, "5 s"),
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe("blockUntilReady", () => {
|
|
26
|
-
test("reaching the timeout", async () => {
|
|
27
|
-
const id = crypto.randomUUID();
|
|
28
|
-
|
|
29
|
-
// Use up all tokens in the current window
|
|
30
|
-
for (let i = 0; i < 15; i++) {
|
|
31
|
-
await limiter.limit(id);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const start = Date.now();
|
|
35
|
-
const res = await limiter.blockUntilReady(id, 1200);
|
|
36
|
-
expect(res.success).toBe(false);
|
|
37
|
-
expect(start + 1000).toBeLessThanOrEqual(Date.now());
|
|
38
|
-
await res.pending;
|
|
39
|
-
}, 20000);
|
|
40
|
-
|
|
41
|
-
test("resolving before the timeout", async () => {
|
|
42
|
-
const id = crypto.randomUUID();
|
|
43
|
-
|
|
44
|
-
// Use up all tokens in the current window
|
|
45
|
-
// for (let i = 0; i < 4; i++) {
|
|
46
|
-
// await limiter.limit(id);
|
|
47
|
-
// }
|
|
48
|
-
|
|
49
|
-
const start = Date.now();
|
|
50
|
-
const res = await limiter.blockUntilReady(id, 1000);
|
|
51
|
-
expect(res.success).toBe(true);
|
|
52
|
-
expect(start + 1000).toBeGreaterThanOrEqual(Date.now());
|
|
53
|
-
|
|
54
|
-
await res.pending;
|
|
55
|
-
}, 20000);
|
|
56
|
-
});
|
package/src/cache.test.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import { Redis } from "@upstash/redis";
|
|
3
|
-
import { Ratelimit } from "./index";
|
|
4
|
-
|
|
5
|
-
test("ephemeral cache", async () => {
|
|
6
|
-
const maxTokens = 10;
|
|
7
|
-
const redis = Redis.fromEnv();
|
|
8
|
-
|
|
9
|
-
const metrics: Record<string | symbol, number> = {};
|
|
10
|
-
|
|
11
|
-
const spy = new Proxy(redis, {
|
|
12
|
-
get: (target, prop) => {
|
|
13
|
-
if (typeof metrics[prop] === "undefined") {
|
|
14
|
-
metrics[prop] = 0;
|
|
15
|
-
}
|
|
16
|
-
metrics[prop]++;
|
|
17
|
-
// @ts-ignore - we don't care about the types here
|
|
18
|
-
return target[prop];
|
|
19
|
-
},
|
|
20
|
-
});
|
|
21
|
-
const ratelimit = new Ratelimit({
|
|
22
|
-
// @ts-ignore - we don't care about the types here
|
|
23
|
-
redis: spy,
|
|
24
|
-
limiter: Ratelimit.tokenBucket(maxTokens, "5 s", maxTokens),
|
|
25
|
-
ephemeralCache: new Map(),
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
let passes = 0;
|
|
29
|
-
|
|
30
|
-
for (let i = 0; i <= 20; i++) {
|
|
31
|
-
const { success } = await ratelimit.limit("id");
|
|
32
|
-
if (success) {
|
|
33
|
-
passes++;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
expect(passes).toBeLessThanOrEqual(10);
|
|
38
|
-
expect(metrics.eval).toBeLessThanOrEqual(10);
|
|
39
|
-
|
|
40
|
-
await new Promise((r) => setTimeout(r, 5000));
|
|
41
|
-
}, 10000);
|
package/src/cache.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { EphemeralCache } from "./types";
|
|
2
|
-
|
|
3
|
-
export class Cache implements EphemeralCache {
|
|
4
|
-
/**
|
|
5
|
-
* Stores identifier -> reset (in milliseconds)
|
|
6
|
-
*/
|
|
7
|
-
private readonly cache: Map<string, number>;
|
|
8
|
-
|
|
9
|
-
constructor(cache: Map<string, number>) {
|
|
10
|
-
this.cache = cache;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
public isBlocked(identifier: string): { blocked: boolean; reset: number } {
|
|
14
|
-
if (!this.cache.has(identifier)) {
|
|
15
|
-
return { blocked: false, reset: 0 };
|
|
16
|
-
}
|
|
17
|
-
const reset = this.cache.get(identifier)!;
|
|
18
|
-
if (reset < Date.now()) {
|
|
19
|
-
this.cache.delete(identifier);
|
|
20
|
-
return { blocked: false, reset: 0 };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return { blocked: true, reset: reset };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
public blockUntil(identifier: string, reset: number): void {
|
|
27
|
-
this.cache.set(identifier, reset);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
public set(key: string, value: number): void {
|
|
31
|
-
this.cache.set(key, value);
|
|
32
|
-
}
|
|
33
|
-
public get(key: string): number | null {
|
|
34
|
-
return this.cache.get(key) || null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
public incr(key: string): number {
|
|
38
|
-
let value = this.cache.get(key) ?? 0;
|
|
39
|
-
value += 1;
|
|
40
|
-
this.cache.set(key, value);
|
|
41
|
-
return value;
|
|
42
|
-
}
|
|
43
|
-
}
|
package/src/duration.test.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { ms } from "./duration";
|
|
3
|
-
|
|
4
|
-
describe("ms", () => {
|
|
5
|
-
it("should return the correct number of milliseconds for a given duration", () => {
|
|
6
|
-
expect(ms("100ms")).toBe(100);
|
|
7
|
-
expect(ms("2s")).toBe(2000);
|
|
8
|
-
expect(ms("3m")).toBe(180000);
|
|
9
|
-
expect(ms("4h")).toBe(14400000);
|
|
10
|
-
expect(ms("5d")).toBe(432000000);
|
|
11
|
-
expect(ms("10ms")).toBe(10);
|
|
12
|
-
});
|
|
13
|
-
describe("with space", () => {
|
|
14
|
-
it("should return the correct number of milliseconds for a given duration", () => {
|
|
15
|
-
expect(ms("100 ms")).toBe(100);
|
|
16
|
-
expect(ms("2 s")).toBe(2000);
|
|
17
|
-
expect(ms("3 m")).toBe(180000);
|
|
18
|
-
expect(ms("4 h")).toBe(14400000);
|
|
19
|
-
expect(ms("5 d")).toBe(432000000);
|
|
20
|
-
expect(ms("10 ms")).toBe(10);
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
});
|
package/src/duration.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
type Unit = "ms" | "s" | "m" | "h" | "d";
|
|
2
|
-
export type Duration = `${number} ${Unit}` | `${number}${Unit}`;
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Convert a human readable duration to milliseconds
|
|
6
|
-
*/
|
|
7
|
-
export function ms(d: Duration): number {
|
|
8
|
-
const match = d.match(/^(\d+)\s?(ms|s|m|h|d)$/);
|
|
9
|
-
if (!match) {
|
|
10
|
-
throw new Error(`Unable to parse window size: ${d}`);
|
|
11
|
-
}
|
|
12
|
-
const time = parseInt(match[1]);
|
|
13
|
-
const unit = match[2] as Unit;
|
|
14
|
-
|
|
15
|
-
switch (unit) {
|
|
16
|
-
case "ms":
|
|
17
|
-
return time;
|
|
18
|
-
case "s":
|
|
19
|
-
return time * 1000;
|
|
20
|
-
case "m":
|
|
21
|
-
return time * 1000 * 60;
|
|
22
|
-
case "h":
|
|
23
|
-
return time * 1000 * 60 * 60;
|
|
24
|
-
case "d":
|
|
25
|
-
return time * 1000 * 60 * 60 * 24;
|
|
26
|
-
|
|
27
|
-
default:
|
|
28
|
-
throw new Error(`Unable to parse window size: ${d}`);
|
|
29
|
-
}
|
|
30
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { Analytics } from "./analytics";
|
|
2
|
-
import type { AnalyticsConfig } from "./analytics";
|
|
3
|
-
import { MultiRegionRatelimit } from "./multi";
|
|
4
|
-
import type { MultiRegionRatelimitConfig } from "./multi";
|
|
5
|
-
import { RegionRatelimit as Ratelimit } from "./single";
|
|
6
|
-
import type { RegionRatelimitConfig as RatelimitConfig } from "./single";
|
|
7
|
-
import type { Algorithm } from "./types";
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
Ratelimit,
|
|
11
|
-
RatelimitConfig,
|
|
12
|
-
MultiRegionRatelimit,
|
|
13
|
-
MultiRegionRatelimitConfig,
|
|
14
|
-
Algorithm,
|
|
15
|
-
Analytics,
|
|
16
|
-
AnalyticsConfig,
|
|
17
|
-
};
|