├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── .vscode
├── extensions.json
└── settings.json
├── LICENSE
├── README.md
├── apps
└── docs
│ ├── .gitignore
│ ├── .swcrc
│ ├── index.d.ts
│ ├── mdx-components.js
│ ├── next-env.d.ts
│ ├── next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ └── logo.svg
│ ├── src
│ └── app
│ │ ├── _meta.ts
│ │ ├── favicon.ico
│ │ ├── favicon.png
│ │ ├── favicon.svg
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── limiter
│ │ ├── _meta.ts
│ │ ├── algorithms
│ │ │ └── page.mdx
│ │ ├── integrations
│ │ │ ├── _meta.ts
│ │ │ ├── page.mdx
│ │ │ └── supabase
│ │ │ │ └── page.mdx
│ │ ├── page.mdx
│ │ ├── quick-start
│ │ │ └── page.mdx
│ │ └── self-hosting
│ │ │ └── page.mdx
│ │ ├── opengraph-image.png
│ │ ├── page.mdx
│ │ └── twitter-image.png
│ └── tsconfig.json
├── nx.json
├── package-lock.json
├── package.json
├── packages
├── .gitkeep
└── javascript
│ └── node
│ ├── README.md
│ ├── jsr.json
│ ├── package.json
│ ├── src
│ ├── index.ts
│ └── lib
│ │ ├── client.spec.ts
│ │ ├── client.ts
│ │ ├── limiter
│ │ ├── host
│ │ │ ├── adapters
│ │ │ │ └── storage
│ │ │ │ │ ├── StorageAdapter.ts
│ │ │ │ │ └── redis
│ │ │ │ │ └── UpstashRedisAdapter.ts
│ │ │ ├── algorithms.ts
│ │ │ ├── index.ts
│ │ │ ├── limiter.spec.ts
│ │ │ └── limiter.ts
│ │ ├── limiter.ts
│ │ ├── tokens.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ │ └── utils.ts
│ ├── tsconfig.json
│ ├── tsconfig.lib.json
│ ├── tsconfig.spec.json
│ └── vite.config.ts
├── tsconfig.base.json
├── tsconfig.json
├── vercel.json
└── vitest.workspace.ts
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | permissions:
10 | actions: read
11 | contents: read
12 |
13 | jobs:
14 | main:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 | with:
19 | fetch-depth: 0
20 |
21 | # This enables task distribution via Nx Cloud
22 | # Run this command as early as possible, before dependencies are installed
23 | # Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun
24 | # Uncomment this line to enable task distribution
25 | # - run: npx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="build"
26 |
27 | # Cache node_modules
28 | - uses: actions/setup-node@v4
29 | with:
30 | node-version: 20
31 | cache: 'npm'
32 |
33 | - run: npm ci --legacy-peer-deps
34 | - uses: nrwl/nx-set-shas@v4
35 |
36 | # Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
37 | # - run: npx nx-cloud record -- echo Hello World
38 | # Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
39 | - run: npx nx affected -t lint test build
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
2 |
3 | # compiled output
4 | dist
5 | tmp
6 | out-tsc
7 |
8 | # dependencies
9 | node_modules
10 |
11 | # IDEs and editors
12 | /.idea
13 | .project
14 | .classpath
15 | .c9/
16 | *.launch
17 | .settings/
18 | *.sublime-workspace
19 |
20 | # IDE - VSCode
21 | .vscode/*
22 | !.vscode/settings.json
23 | !.vscode/tasks.json
24 | !.vscode/launch.json
25 | !.vscode/extensions.json
26 |
27 | # misc
28 | /.sass-cache
29 | /connect.lock
30 | /coverage
31 | /libpeerconnection.log
32 | npm-debug.log
33 | yarn-error.log
34 | testem.log
35 | /typings
36 |
37 | # System Files
38 | .DS_Store
39 | Thumbs.db
40 |
41 | .nx/cache
42 | .nx/workspace-data
43 |
44 | vite.config.*.timestamp*
45 | vitest.config.*.timestamp*
46 | **/.env.local
47 |
48 | # Next.js
49 | .next
50 | out
51 | .vercel
52 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22.14.0
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /dist
2 | /coverage
3 | /.nx/cache
4 | /.nx/workspace-data
5 |
6 | package-lock.json
7 | **/*.mdx
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": false
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["esbenp.prettier-vscode", "denoland.vscode-deno"]
3 | }
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "prettier.prettierPath": "node_modules/prettier",
3 | "prettier.ignorePath": ".prettierignore"
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Lorenzo Bloedow
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | We automate the boring stuff for you
6 |
7 |
8 | Simple, open-source, powerful tools for modern serverless applications
9 |
10 |
11 |
12 | All tools are easily self-hostable, fully typed, and designed for serverless-first environments.
13 |
14 |
15 | ## Authentication
16 | [Follow this guide to authenticate if using our managed service.](https://borrow.dev/docs/limiter/quick-start#authentication)
17 |
18 | ## Documentation
19 | [Read the full documentation for Borrow.](https://borrow.dev/docs)
20 |
21 | ## Limiter
22 | Self-hostable rate limiting API for protecting regular service usage.
23 |
24 | ### Usage
25 |
26 | Let's use the [fixed window](https://borrow.dev/docs/limiter/algorithms#fixed-window) algorithm to rate limit our login endpoint to 10 requests per minute.
27 |
28 | ```javascript
29 | import { borrow } from "@borrowdev/node";
30 |
31 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
32 | limiters: [{
33 | maxRequests: 10,
34 | interval: "minute",
35 | type: "fixed",
36 | }]
37 | });
38 | if (!success) {
39 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
40 | }
41 | ```
42 |
43 | ### Self host
44 | To self-host the Limiter API, follow the [self-hosting guide](https://borrow.dev/docs/limiter/self-hosting).
--------------------------------------------------------------------------------
/apps/docs/.gitignore:
--------------------------------------------------------------------------------
1 | _pagefind/
--------------------------------------------------------------------------------
/apps/docs/.swcrc:
--------------------------------------------------------------------------------
1 | {
2 | "jsc": {
3 | "target": "es2017",
4 | "parser": {
5 | "syntax": "typescript",
6 | "decorators": true,
7 | "dynamicImport": true
8 | },
9 | "transform": {
10 | "decoratorMetadata": true,
11 | "legacyDecorator": true
12 | },
13 | "keepClassNames": true,
14 | "externalHelpers": true,
15 | "loose": true
16 | },
17 | "module": {
18 | "type": "commonjs"
19 | },
20 | "sourceMaps": true,
21 | "exclude": [
22 | "jest.config.ts",
23 | ".*\\.spec.tsx?$",
24 | ".*\\.test.tsx?$",
25 | "./src/jest-setup.ts$",
26 | "./**/jest-setup.ts$",
27 | ".*.js$",
28 | ".*.d.ts$"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/apps/docs/index.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | declare module "*.svg" {
3 | const content: any;
4 | export const ReactComponent: any;
5 | export default content;
6 | }
7 |
--------------------------------------------------------------------------------
/apps/docs/mdx-components.js:
--------------------------------------------------------------------------------
1 | import { useMDXComponents as getThemeComponents } from "nextra-theme-docs"; // nextra-theme-blog or your custom theme
2 |
3 | // Get the default MDX components
4 | const themeComponents = getThemeComponents();
5 |
6 | // Merge components
7 | export function useMDXComponents(components) {
8 | return {
9 | ...themeComponents,
10 | ...components,
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/apps/docs/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/apps/docs/next.config.ts:
--------------------------------------------------------------------------------
1 | import { composePlugins, withNx } from "@nx/next";
2 | import nextra from "nextra";
3 | import type { WithNxOptions } from "@nx/next/plugins/with-nx";
4 |
5 | const nextConfig: WithNxOptions = {
6 | nx: {
7 | svgr: false,
8 | },
9 | basePath: "/docs",
10 | assetPrefix: "/docs",
11 | };
12 |
13 | const withNextra = nextra({
14 | codeHighlight: true,
15 | defaultShowCopyCode: true,
16 | readingTime: false,
17 | search: {
18 | codeblocks: false,
19 | },
20 | });
21 |
22 | const plugins = [withNextra, withNx];
23 |
24 | module.exports = composePlugins(...plugins)(nextConfig);
25 |
--------------------------------------------------------------------------------
/apps/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@borrowdev/docs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind"
7 | },
8 | "dependencies": {
9 | "@tailwindcss/postcss": "^4.1.4",
10 | "next": "^15",
11 | "nextra": "^4.2.17",
12 | "nextra-theme-docs": "^4.2.17",
13 | "postcss": "^8.5.3",
14 | "react": "19.0.0",
15 | "react-dom": "19.0.0",
16 | "tailwindcss": "^4.1.4"
17 | },
18 | "devDependencies": {
19 | "pagefind": "^1.3.0"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/apps/docs/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | "@tailwindcss/postcss": {},
4 | },
5 | };
6 | export default config;
7 |
--------------------------------------------------------------------------------
/apps/docs/public/logo.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/apps/docs/src/app/_meta.ts:
--------------------------------------------------------------------------------
1 | import type { MetaRecord } from "nextra";
2 | const meta: MetaRecord = {
3 | index: {
4 | href: "/",
5 | title: "Introduction",
6 | },
7 | limiter: {
8 | href: "/limiter",
9 | title: "Limiter",
10 | },
11 | };
12 | export default meta;
13 |
--------------------------------------------------------------------------------
/apps/docs/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/borrowdev/borrow/dbd929524b2eda3e2f052ec62aafcd8c5a804015/apps/docs/src/app/favicon.ico
--------------------------------------------------------------------------------
/apps/docs/src/app/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/borrowdev/borrow/dbd929524b2eda3e2f052ec62aafcd8c5a804015/apps/docs/src/app/favicon.png
--------------------------------------------------------------------------------
/apps/docs/src/app/favicon.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/apps/docs/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
--------------------------------------------------------------------------------
/apps/docs/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Footer, Layout, Navbar } from "nextra-theme-docs";
2 | import { Head } from "nextra/components";
3 | import { getPageMap } from "nextra/page-map";
4 | import "nextra-theme-docs/style.css";
5 | import Image from "next/image";
6 | import { Metadata } from "next";
7 | import { Inter } from "next/font/google";
8 | import { ReactNode } from "react";
9 |
10 | const inter = Inter({ subsets: ["latin"] });
11 |
12 | export const metadata: Metadata = {
13 | title: "Borrow | Documentation",
14 | };
15 |
16 | const footer = ;
17 |
18 | export default async function RootLayout({
19 | children,
20 | }: Readonly<{
21 | children: ReactNode;
22 | }>) {
23 | return (
24 |
30 |
31 |
32 |
33 |
39 |
51 | }
52 | className="bg-background/90 rounded-t-xl"
53 | />
54 | }
55 | pageMap={await getPageMap()}
56 | feedback={{
57 | content: "Give us feedback",
58 | labels: "feedback",
59 | }}
60 | sidebar={{
61 | autoCollapse: true,
62 | toggleButton: false,
63 | }}
64 | darkMode={false}
65 | docsRepositoryBase="https://github.com/borrowdev/borrow/tree/main/apps/docs"
66 | footer={footer}
67 | >
68 | {children}
69 |
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/apps/docs/src/app/limiter/_meta.ts:
--------------------------------------------------------------------------------
1 | import type { MetaRecord } from "nextra";
2 | const meta: MetaRecord = {
3 | index: {
4 | href: "/limiter",
5 | title: "Overview",
6 | },
7 | "quick-start": {
8 | href: "/limiter/quick-start",
9 | title: "Quick Start",
10 | },
11 | "self-hosting": {
12 | href: "/limiter/self-hosting",
13 | title: "Self-Hosting",
14 | },
15 | algorithms: {
16 | href: "/limiter/algorithms",
17 | title: "Algorithms",
18 | },
19 | integrations: {
20 | href: "/limiter/integrations",
21 | title: "Integrations",
22 | },
23 | api: {
24 | href: "https://borrow.apidocumentation.com/reference",
25 | title: "API Reference",
26 | },
27 | };
28 | export default meta;
29 |
--------------------------------------------------------------------------------
/apps/docs/src/app/limiter/algorithms/page.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Borrow | Limiter Algorithms
3 | description: Overview for the Limiter API algorithms
4 | ---
5 |
6 | # Rate limiting algorithms
7 | In this section, we will explore the different algorithms available for rate limiting using the Borrow API.
8 | If you're more of a visual learner we recommend visiting [this article.](https://smudge.ai/blog/ratelimit-algorithms)
9 | > The previously mentioned article only covers 3 of our 4 algorithms, that's because we've developed the **borrow** algorithm, even though it's less of an algorithm, more of just synchronous rate limiting.
10 |
11 | ## Fixed Window
12 | The fixed limiter is simply an algorithm that limits the number of requests to a fixed number on the **clock**.
13 |
14 | For example, if you set the limit to 10 requests per minute, the first 10 requests will be allowed, and the next requests will be blocked until the next **clock** minute.
15 | In the fixed window algorithm, it doesn't matter if the 10 requests were made 1 second before the next minute or however long before the next minute. In either scenario,
16 | it will reset in the next minute on the **clock**.
17 |
18 | You can use the fixed window algorithm by specifying the limiter `type` to `"fixed"`.
19 |
20 | ## Sliding Window
21 | The sliding window algorithm is a more sophisticated approach to rate limiting that allows for a more fair distribution of requests over time.
22 | In this algorithm, the limit is applied over the time window at the time of the request rather than a fixed clock interval.
23 |
24 | For example, if you set the limit to 10 requests per minute, the first 10 requests made within any 60-second window will be allowed.\
25 | Let's say the user makes a request at 00:00:30, that means their 10-request limit will reset at 00:01:30, rather than at 00:01:00, as in the fixed window algorithm.
26 |
27 | You can use the sliding window algorithm by specifying the limiter `type` to `"sliding"`.
28 |
29 | ## Token Bucket
30 | The token bucket algorithm is the most flexible of our rate limiting algorithms.\
31 | In this algorithm, tokens are added to a bucket at a rate you specify (`interval` and `tokensPerReplenish`), and each request consumes a specified amount of tokens from the bucket (`tokensCost`).
32 |
33 | After the maximum amount of tokens is reached (`maxTokens`), the requests will be blocked until the user has enough tokens.
34 | Likewise, if the user only has 70 tokens, and the request consumes 80 tokens, the request will be blocked until enough tokens are replenished.
35 |
36 | ## Borrow
37 | The borrow algorithm is a synchronous rate limiting algorithm that allows you to limit requests without the need for a time window.\
38 | In this algorithm, you call the API when a user has made a request, and then you call it again when the user has finished the request.
39 |
40 | In the time between the two calls the user is blocked from making any requests. You'll also need to specify a timeout parameter to ensure that
41 | if you forget or are unable to call the API again the user will be automatically unblocked after the timeout has expired.
--------------------------------------------------------------------------------
/apps/docs/src/app/limiter/integrations/_meta.ts:
--------------------------------------------------------------------------------
1 | import type { MetaRecord } from "nextra";
2 | const meta: MetaRecord = {
3 | index: {
4 | href: "/limiter/integrations",
5 | title: "Overview",
6 | },
7 | supabase: {
8 | href: "/limiter/integrations/supabase",
9 | title: "Supabase",
10 | },
11 | };
12 | export default meta;
13 |
--------------------------------------------------------------------------------
/apps/docs/src/app/limiter/integrations/page.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Borrow | Integrations Overview
3 | description: Overview for Borrow integrations
4 | ---
5 |
6 | # Integrations
7 |
8 | Hi there! You're probably looking for one of the more [specific](/limiter/integrations/supabase) integration pages.
--------------------------------------------------------------------------------
/apps/docs/src/app/limiter/integrations/supabase/page.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Borrow | Supabase Integration
3 | description: Overview for the Supabase integration
4 | ---
5 |
6 | import { Tabs } from "nextra/components";
7 |
8 | # Supabase Integration
9 |
10 | The TS/JS client library for limiter is integrated with Supabase to make your life easier as a developer.
11 |
12 | The way it works is if you want to rate limit a user ID, instead of getting the environment variables, creating a Supabase instance, and parsing the authentication token,
13 | you can simply pass the request object to the limiter function and we'll handle the rest for you!
14 |
15 | You can also pass only the request object to the limiter function and it'll automatically create a unique key for the request based on the request URL.
16 |
17 | This might have diminishing returns for when you already need the user object anyway, but for simple use cases where you just want to protect an endpoint and return a response
18 | without touching the user object, this greatly simplifies the whole process!
19 |
20 | Here's how it works:
21 |
22 |
23 |
24 | ```ts copy
25 | import { borrow } from "@borrowdev/node";
26 | // ... Your Supabase Edge Function handler
27 |
28 | const { success, timeLeft } = await borrow.limiter(req, {
29 | limiters: [{
30 | interval: 20,
31 | maxRequests: 10,
32 | type: "fixed",
33 | }]
34 | });
35 |
36 | // ... Your expensive business logic
37 | ```
38 |
39 |
40 | ```js copy
41 | import { borrow } from "@borrowdev/node";
42 | // ... Your Supabase Edge Function handler
43 |
44 | const { success, timeLeft } = await borrow.limiter(req, {
45 | limiters: [{
46 | interval: 20,
47 | maxRequests: 10,
48 | type: "fixed",
49 | }]
50 | });
51 |
52 | // ... Your expensive business logic
53 | ```
54 |
55 |
--------------------------------------------------------------------------------
/apps/docs/src/app/limiter/page.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Borrow | Limiter Overview
3 | description: Overview for the Limiter API
4 | ---
5 |
6 | # Limiter
7 |
8 | Limiter is a serverless **rate limiting API**.
9 | You'll usually call Limiter before you start executing your function logic, especially
10 | expensive logic such as database scans or AI inferences.\
11 | Using a rate limiter can ensure your services don't get overwhelmed, which can potentially save you thousands of dollars.
12 |
13 | ## Infrastructure
14 |
15 | Behind the scenes we use [Redis](https://redis.io/) with data persistence to provide an accurate and fast atomic counter.
16 |
17 | Imagine you have an AI inference endpoint that effectively costs you around $0.01 per request and allows
18 | signed up users to use it for free. If you allow them to make however many requests they want, the request rate
19 | will be impossible to predict and you'll have no way to control your costs.
20 |
21 | > [!WARNING]
22 | >
23 | > Limiter is currently not suitable for DoS protection, you should use it only for rate limiting regular usage.
24 | > It should be seen as a tool to help you have more predictable costs and to prevent your services from being overwhelmed by too many requests under normal circumstances.
25 | >
26 | > In its current state, it should NOT be used as a tool to prevent malicious attacks, such as [DoS](https://en.wikipedia.org/wiki/Denial-of-service_attack).
27 | > We highly recommend you take a holistic approach to cost control and security, using Limiter as only part of your overall strategy.
28 |
29 | Implementing a rate limiter from scratch can be a complex task, the pitfalls are many, and the cost of mistakes is usually high if things go south.\
30 | It becomes even more complex when you want to support multiple rate limiting algorithms at the same time.
31 |
32 | We made Limiter so you don't have to worry about these complexities.\
33 | As a bonus, since [our philosophy](/#philosophy) is to provide the best developer experience possible, you'll most likely find using our APIs intuitive and easy.
34 |
--------------------------------------------------------------------------------
/apps/docs/src/app/limiter/quick-start/page.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Borrow | Limiter Quick Start
3 | description: Quick Start guide for the Limiter API
4 | ---
5 |
6 | import { Tabs } from "nextra/components";
7 |
8 | # Quick Start
9 |
10 | ## Authentication
11 |
12 | Follow these steps to get your API key and start using Borrow Limiter:
13 |
14 | 1. [Create a Borrow account.](https://borrow.dev/sign-up)
15 | 2. [Create a new project.](https://borrow.dev/dashboard)
16 | 3. Inside your project, go to the Settings tab.
17 | 4. In the Authentication section, click Show API Key.
18 | 5. Wait for the API key to be decrypted and copy it.
19 | 6. Set the ``BORROW_API_KEY`` environment variable in your application to the copied API key.
20 |
21 | ## Installation
22 |
23 | Install the Borrow Node.js package using your preferred package manager:
24 |
25 |
26 |
27 | ```bash copy
28 | npm install @borrowdev/node
29 | ```
30 |
31 |
32 | ```bash copy
33 | pnpm add @borrowdev/node
34 | ```
35 |
36 |
37 | ```bash copy
38 | yarn add @borrowdev/node
39 | ```
40 |
41 |
42 | ```bash copy
43 | bun add @borrowdev/node
44 | ```
45 |
46 |
47 |
48 | ## Usage
49 |
50 | ### Limiting with the fixed window algorithm
51 | Let's use the [fixed window](/limiter/algorithms#fixed-window) algorithm to rate limit our login endpoint to 10 requests per minute.
52 |
53 |
54 |
55 | ```ts copy
56 | import { borrow } from "@borrowdev/node";
57 |
58 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
59 | limiters: [{
60 | maxRequests: 10,
61 | interval: "minute",
62 | type: "fixed",
63 | }]
64 | });
65 | if (!success) {
66 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
67 | }
68 |
69 | // ... Your expensive business logic
70 | ```
71 |
72 |
73 | ```js copy
74 | import { borrow } from "@borrowdev/node";
75 |
76 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
77 | limiters: [{
78 | maxRequests: 10,
79 | interval: "minute",
80 | type: "fixed",
81 | }]
82 | });
83 | if (!success) {
84 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
85 | }
86 |
87 | // ... Your expensive business logic
88 | ```
89 |
90 |
91 | ```bash copy
92 | curl https://api.borrow.dev/v1/limiter \
93 | --request POST \
94 | --header 'Content-Type: application/json' \
95 | --data '{
96 | "userId": "current-user-id",
97 | "key": "login",
98 | "limiters": [
99 | {
100 | "type": "fixed",
101 | "maxRequests": 10,
102 | "interval": "minute"
103 | }
104 | ]
105 | }'
106 | ```
107 |
108 |
109 |
110 | ### Rate limiting with the sliding window algorithm
111 | Let's use the [sliding window](/limiter/algorithms#sliding-window) algorithm to rate limit our login endpoint to 10 requests per minute.
112 |
113 |
114 |
115 | ```ts copy
116 | import { borrow } from "@borrowdev/node";
117 |
118 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
119 | limiters: [{
120 | maxRequests: 10,
121 | interval: "minute",
122 | type: "sliding",
123 | }]
124 | });
125 | if (!success) {
126 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
127 | }
128 | // ... Your expensive business logic
129 | ```
130 |
131 |
132 | ```js copy
133 | import { borrow } from "@borrowdev/node";
134 |
135 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
136 | limiters: [{
137 | maxRequests: 10,
138 | interval: "minute",
139 | type: "sliding",
140 | }]
141 | });
142 | if (!success) {
143 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
144 | }
145 | // ... Your expensive business logic
146 | ```
147 |
148 |
149 | ```bash copy
150 | curl https://api.borrow.dev/v1/limiter \
151 | --request POST \
152 | --header 'Content-Type: application/json' \
153 | --data '{
154 | "userId": "current-user-id",
155 | "key": "login",
156 | "limiters": [
157 | {
158 | "type": "sliding",
159 | "maxRequests": 10,
160 | "interval": "minute"
161 | }
162 | ]
163 | }'
164 | ```
165 |
166 |
167 |
168 | ### Rate limiting with the token bucket algorithm
169 | Let's use the [token bucket](/limiter/algorithms#token-bucket) algorithm to rate limit requests to 10 tokens per minute, with a maximum of 20 tokens.
170 |
171 |
172 |
173 | ```ts copy
174 | import { borrow } from "@borrowdev/node";
175 |
176 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
177 | limiters: [{
178 | maxTokens: 20,
179 | tokensCost: 5,
180 | tokensPerReplenish: 10,
181 | interval: "minute",
182 | type: "token",
183 | }]
184 | });
185 | if (!success) {
186 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
187 | }
188 | // ... Your expensive business logic
189 | ```
190 |
191 |
192 | ```js copy
193 | import { borrow } from "@borrowdev/node";
194 |
195 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
196 | limiters: [{
197 | maxTokens: 20,
198 | tokensCost: 5,
199 | tokensPerReplenish: 10,
200 | interval: "minute",
201 | type: "token",
202 | }]
203 | });
204 | if (!success) {
205 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
206 | }
207 | // ... Your expensive business logic
208 | ```
209 |
210 |
211 | ```bash copy
212 | curl https://api.borrow.dev/v1/limiter \
213 | --request POST \
214 | --header 'Content-Type: application/json' \
215 | --header 'x-borrow-api-key: YOUR_API_KEY' \
216 | --data '{
217 | "userId": "current-user-id",
218 | "key": "my-limiter-id",
219 | "limiters": [
220 | {
221 | "maxTokens": 20,
222 | "tokensCost": 5,
223 | "tokensPerReplenish": 10,
224 | "interval": "minute",
225 | "type": "token"
226 | }
227 | ]
228 | }'
229 | ```
230 |
231 |
232 |
233 | ### Limiting with the borrow algorithm
234 | Let's use the [borrow](/limiter/algorithms#borrow) algorithm to rate limit requests to one request at a time.
235 |
236 |
237 |
238 | ```ts copy
239 | import { borrow } from "@borrowdev/node";
240 |
241 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
242 | limiters: [{
243 | borrowAction: "start",
244 | type: "borrow",
245 | timeout: 10,
246 | }]
247 | });
248 | if (!success) {
249 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
250 | }
251 | // ... Your expensive business logic
252 | const { success: endSuccess } = await borrow.limiter("my-limiter-id", "current-user-id", {
253 | limiters: [{
254 | borrowAction: "end",
255 | type: "borrow",
256 | timeout: 10,
257 | }]
258 | });
259 | if (!endSuccess) {
260 | return { message: "Failed to end borrow." };
261 | }
262 | ```
263 |
264 |
265 | ```js copy
266 | import { borrow } from "@borrowdev/node";
267 |
268 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
269 | limiters: [{
270 | borrowAction: "start",
271 | type: "borrow",
272 | timeout: 10,
273 | }]
274 | });
275 | if (!success) {
276 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
277 | }
278 | // ... Your expensive business logic
279 | const { success: endSuccess } = await borrow.limiter("my-limiter-id", "current-user-id", {
280 | limiters: [{
281 | borrowAction: "end",
282 | type: "borrow",
283 | timeout: 10,
284 | }]
285 | });
286 | if (!endSuccess) {
287 | return { message: "Failed to end borrow." };
288 | }
289 | ```
290 |
291 |
292 | ```bash copy
293 | # Start the borrow
294 | curl https://api.borrow.dev/v1/limiter \
295 | --request POST \
296 | --header 'Content-Type: application/json' \
297 | --header 'x-borrow-api-key: YOUR_API_KEY' \
298 | --data '{
299 | "userId": "current-user-id",
300 | "key": "my-limiter-id",
301 | "limiters": [
302 | {
303 | "borrowAction": "start",
304 | "type": "borrow",
305 | "timeout": 10
306 | }
307 | ]
308 | }'
309 |
310 | # End the borrow when finished
311 | curl https://api.borrow.dev/v1/limiter \
312 | --request POST \
313 | --header 'Content-Type: application/json' \
314 | --header 'x-borrow-api-key: YOUR_API_KEY' \
315 | --data '{
316 | "userId": "current-user-id",
317 | "key": "my-limiter-id",
318 | "limiters": [
319 | {
320 | "type": "borrow",
321 | "borrowAction": "end",
322 | "timeout": 10
323 | }
324 | ]
325 | }'
326 | ```
327 |
328 |
329 |
330 | ### Conclusion
331 |
332 | In this Quick Start guide, we've barely scratched the surface of what Borrow Limiter can do.
333 | You can do so much more such as refilling tokens on demand and creating different limiter combinations with up to 4 limiters of unique types.
334 |
335 | If you want to learn more, the best way to do so is to just [install Borrow](/limiter/quick-start#installation) and read the in-editor comments (JSDoc for TS/JS).
336 |
--------------------------------------------------------------------------------
/apps/docs/src/app/limiter/self-hosting/page.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Borrow | Limiter Self-Hosting
3 | description: Guide to self-host the Borrow Limiter
4 | ---
5 |
6 | import { Tabs } from "nextra/components";
7 |
8 | ## Self-Hosting on Cloudflare Workers
9 |
10 | Follow these steps to run Limiter on Cloudflare Workers.\
11 | While we work on the documentation, you can use this example as the basis for running on other serverless platforms such as AWS Lambda, Vercel, or Supabase.
12 |
13 | ### Prerequisites
14 | - A Cloudflare [worker environment](https://developers.cloudflare.com/workers/get-started/guide/).
15 | - An [Upstash](https://upstash.com/) Redis instance (or any other database if you plan on writing an adapter).
16 |
17 | ### 1. Install Dependencies
18 | ```bash copy
19 | npm install @borrowdev/node @upstash/redis
20 | ```
21 |
22 | ### 2. Configure `wrangler.jsonc`
23 |
24 |
25 |
26 | ```jsonc copy
27 | {
28 | "$schema": "node_modules/wrangler/config-schema.json",
29 | "name": "borrow-limiter",
30 | "compatibility_date": "2025-04-25",
31 | "main": "src/index.ts",
32 | "vars": {
33 | // Use this if you're using Upstash Redis.
34 | "UPSTASH_REDIS_REST_URL": "https://your-upstash-redis-url",
35 | }
36 | }
37 | ```
38 |
39 |
40 |
41 | ### 3. Configure your secrets
42 | For local development, you should store your secrets in a `.dev.vars` file in the root of your project.\
43 | **Do not commit this file to your version control system.**\
44 | For more information, check out the [official secrets documentation](https://developers.cloudflare.com/workers/configuration/secrets/).
45 |
46 |
47 | ```dotenv copy
48 | BORROW_LIMITER_INVOKE_SECRET="random-secure-string"
49 | UPSTASH_REDIS_REST_TOKEN="your-upstash-redis-token"
50 | UPSTASH_REDIS_REST_URL="https://your-upstash-redis-url"
51 | ```
52 |
53 |
54 |
55 |
56 | ### 4. Create the `src/index.ts` file
57 |
58 |
59 |
60 | ```ts copy
61 | import { limiter, UpstashRedisAdapter } from "@borrowdev/node/limiter/host";
62 | import { Redis } from "@upstash/redis/cloudflare";
63 |
64 | export default {
65 | async fetch(request, env, ctx) {
66 | const redis = Redis.fromEnv(env);
67 | const adapters = { storage: new UpstashRedisAdapter(redis) };
68 | const req = await request.json();
69 |
70 | // Execute the limiter function, passing:
71 | // - invokeSecret from env for authentication
72 | // - the Redis-backed storage adapter
73 | // - ctx.waitUntil for background updates (or false if you want to execute synchronously)
74 | const response = await limiter({
75 | env,
76 | req: {
77 | ...req || {},
78 | invokeSecret: request.headers.get("X-Borrow-Api-Key") || "",
79 | } as any,
80 | adapters,
81 | backgroundExecute: ctx.waitUntil.bind(ctx),
82 | hooks: {
83 | beforeResponse: async (r) => console.log("Limiter result: ", r),
84 | },
85 | });
86 |
87 | if (response.error) {
88 | console.error("Limiter error: ", response.message);
89 | }
90 |
91 | // Return JSON result
92 | return new Response(
93 | JSON.stringify({
94 | result: response.result,
95 | timeLeft: response.timeLeft,
96 | tokensLeft: response.tokensLeft,
97 | }),
98 | {
99 | status: response.status,
100 | headers: { "Content-Type": "application/json" },
101 | },
102 | );
103 | },
104 | } satisfies ExportedHandler;
105 | ```
106 |
107 |
108 |
109 | ### 5. Call the Limiter API from your application server
110 |
111 | You can now call the Limiter API by using our client library or by making an HTTP request.
112 |
113 |
114 | ```ts copy
115 | import { borrow } from "@borrowdev/node";
116 |
117 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
118 | limiters: [{
119 | maxTokens: 20,
120 | tokensCost: 5,
121 | tokensPerReplenish: 10,
122 | interval: "minute",
123 | type: "token",
124 | }],
125 | options: {
126 | // Use for testing or if you want to fail closed.
127 | failBehavior: "fail",
128 | apiKey: "random-secure-string",
129 | endpoint: {
130 | baseUrl: "http://localhost:8787",
131 | }
132 | }
133 | });
134 | if (!success) {
135 | return { message: "Rate limit exceeded." + (timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "") };
136 | }
137 | // ... Your expensive business logic
138 | ```
139 |
140 |
141 | ```bash copy
142 | curl http://localhost:8787 \
143 | --request POST \
144 | --header 'Content-Type: application/json' \
145 | --header 'x-borrow-api-key: YOUR_INVOKE_SECRET' \
146 | --data '{
147 | "action": "check",
148 | "userId": "current-user-id",
149 | "key": "my-limiter-id",
150 | "limiters": [
151 | {
152 | "maxTokens": 20,
153 | "tokensCost": 5,
154 | "tokensPerReplenish": 10,
155 | "interval": "minute",
156 | "type": "token"
157 | }
158 | ]
159 | }'
160 | ```
161 |
162 |
163 |
164 | ### 6. Use another database (optional)
165 |
166 | If you want to use a different database than Upstash Redis, you can write your own adapter.\
167 | You just need to extend the `StorageAdapter` class and implement the required methods.
168 |
169 |
170 |
171 | ```ts copy
172 | import { StorageAdapter } from "@borrowdev/node/limiter/host";
173 |
174 | class MyCustomAdapter extends StorageAdapter {
175 | private db: DbType;
176 |
177 | constructor(db: DbType) {
178 | super();
179 | this.db = db;
180 | }
181 |
182 | override get = (key: string) => {
183 | // Implement your logic to get a value using the given input. Must not throw.
184 | }
185 |
186 | override set = async (key: string, value: any) => {
187 | // Implement your logic to set a value using the given inputs. May throw.
188 | }
189 |
190 | override relative = async (key: string, field: string, amount: number) => {
191 | // Implement your logic to increment/decrement an integer using the given inputs. May throw.
192 | }
193 |
194 | // (optional)
195 | override getStorageKey = (
196 | params: {
197 | limiterType: LimiterType;
198 | userId: string | null;
199 | key: string | null;
200 | }
201 | ) => {
202 | // Implement your logic to generate a storage key using the given parameters. May throw.
203 | }
204 | }
205 | ```
206 |
207 |
208 | ```js copy
209 | import { StorageAdapter } from "@borrowdev/node/limiter/host";
210 |
211 | class MyCustomAdapter extends StorageAdapter {
212 | constructor(db) {
213 | super();
214 | this.db = db;
215 | }
216 |
217 | get(key) {
218 | // Implement your logic to get a value using the given input. Must not throw.
219 | }
220 |
221 | async set(key, value) {
222 | // Implement your logic to set a value using the given inputs. May throw.
223 | }
224 |
225 | async relative(key, field, amount) {
226 | // Implement your logic to increment/decrement an integer using the given inputs. May throw.
227 | }
228 |
229 | // (optional)
230 | getStorageKey(params) {
231 | // Implement your logic to generate a storage key using the given parameters. May throw.
232 | }
233 | }
234 | ```
235 |
236 |
--------------------------------------------------------------------------------
/apps/docs/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/borrowdev/borrow/dbd929524b2eda3e2f052ec62aafcd8c5a804015/apps/docs/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/apps/docs/src/app/page.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Borrow | Overview
3 | description: Overview for Borrow
4 | ---
5 |
6 | # Introduction
7 |
8 | Borrow is an open-source serverless toolkit for developers.\
9 | We aim to make it easier for developers to perform common but noticeably time-consuming tasks.
10 |
11 | ## REST API
12 |
13 | While we recommend you use our client libraries when possible, we also provide a [REST API](https://borrow.apidocumentation.com/reference) which our libraries are built on top of.
14 |
15 | ## Philosophy
16 |
17 | Borrow is built on the principle that the most important thing when creating APIs is Developer Experience (DX).\
18 | We believe that a great DX is the key to building successful APIs.
19 |
20 | Of course, there are many other factors that contribute to a successful developer _product_, a few of them are security, scalability, community, support, etc.\
21 | We're not saying that these factors aren't important, but rather that as we develop Borrow, developer experience is our top priority.
22 |
23 | Here are some of the practical ways we make sure our APIs have a great developer experience:
24 |
25 | - **In-Editor Comments**: We provide comprehensive in-editor comments (JSDoc on TS/JS) for all our APIs, so you can easily understand how to use them without ever leaving your code editor.
26 | - **Handmade Documentation**: We do **not** use auto-generated documentation. This makes sure we can provide more extensive and tailored documentation depending on the context and environment.
27 | - **Progressive Disclosure**: We've adopted the [Progressive Disclousure](https://en.wikipedia.org/wiki/Progressive_disclosure) UX principle to developer tools, this means we only show the most basic
28 | information upfront to get the job done, and provide more advanced features and options as you dig deeper into the documentation. This reflects even in the types of the client APIs we write.
29 | - **Integrations**: We're constantly extending support for integrations with popular libraries, frameworks, and environments, so you can write less code and accomplish the same goal.
30 |
31 | ## Getting Started
32 |
33 | Borrow only offers one API at the moment, a serverless and self-hostable/fully-managed rate limiter.\
34 | To get started, you can check out the [Limiter Quick Start Guide](/limiter).
35 |
--------------------------------------------------------------------------------
/apps/docs/src/app/twitter-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/borrowdev/borrow/dbd929524b2eda3e2f052ec62aafcd8c5a804015/apps/docs/src/app/twitter-image.png
--------------------------------------------------------------------------------
/apps/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "jsx": "preserve",
5 | "noEmit": true,
6 | "emitDeclarationOnly": false,
7 | "esModuleInterop": true,
8 | "module": "esnext",
9 | "resolveJsonModule": true,
10 | "lib": [
11 | "dom",
12 | "dom.iterable",
13 | "esnext"
14 | ],
15 | "allowJs": true,
16 | "allowSyntheticDefaultImports": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "incremental": true,
19 | "plugins": [
20 | {
21 | "name": "next"
22 | }
23 | ],
24 | "paths": {
25 | "@/*": [
26 | "./src/*"
27 | ]
28 | },
29 | "outDir": "dist",
30 | "rootDir": "src",
31 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
32 | },
33 | "include": [
34 | "../../apps/docs/.next/types/**/*.ts",
35 | "../../dist/apps/docs/.next/types/**/*.ts",
36 | "next-env.d.ts",
37 | "src/**/*.js",
38 | "src/**/*.jsx",
39 | "src/**/*.ts",
40 | "src/**/*.tsx",
41 | ".next/types/**/*.ts"
42 | ],
43 | "exclude": [
44 | "out-tsc",
45 | "dist",
46 | "node_modules",
47 | "jest.config.ts",
48 | "src/**/*.spec.ts",
49 | "src/**/*.test.ts",
50 | ".next"
51 | ]
52 | }
53 |
--------------------------------------------------------------------------------
/nx.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/nx/schemas/nx-schema.json",
3 | "namedInputs": {
4 | "default": ["{projectRoot}/**/*", "sharedGlobals"],
5 | "production": [
6 | "default",
7 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
8 | "!{projectRoot}/tsconfig.spec.json",
9 | "!{projectRoot}/src/test-setup.[jt]s"
10 | ],
11 | "sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"]
12 | },
13 | "nxCloudId": "67f837f2fe18ab53d8b613bb",
14 | "plugins": [
15 | {
16 | "plugin": "@nx/vite/plugin",
17 | "options": {
18 | "buildTargetName": "build",
19 | "testTargetName": "test",
20 | "serveTargetName": "serve",
21 | "devTargetName": "dev",
22 | "previewTargetName": "preview",
23 | "serveStaticTargetName": "serve-static",
24 | "typecheckTargetName": "typecheck",
25 | "buildDepsTargetName": "build-deps",
26 | "watchDepsTargetName": "watch-deps"
27 | }
28 | },
29 | {
30 | "plugin": "@nx/next/plugin",
31 | "options": {
32 | "startTargetName": "start",
33 | "buildTargetName": "build",
34 | "devTargetName": "dev",
35 | "serveStaticTargetName": "serve-static",
36 | "buildDepsTargetName": "build-deps",
37 | "watchDepsTargetName": "watch-deps"
38 | }
39 | },
40 | {
41 | "plugin": "@nx/js/typescript",
42 | "options": {
43 | "typecheck": {
44 | "targetName": "tsc:typecheck"
45 | },
46 | "build": {
47 | "targetName": "tsc:build",
48 | "configName": "tsconfig.lib.json",
49 | "buildDepsName": "tsc:build-deps",
50 | "watchDepsName": "tsc:watch-deps"
51 | }
52 | }
53 | }
54 | ],
55 | "targetDefaults": {
56 | "test": {
57 | "dependsOn": ["^build"]
58 | }
59 | },
60 | "generators": {
61 | "@nx/next": {
62 | "application": {
63 | "style": "tailwind",
64 | "linter": "none"
65 | }
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@borrowdev/source",
3 | "version": "0.1.0",
4 | "license": "MIT",
5 | "scripts": {},
6 | "private": true,
7 | "devDependencies": {
8 | "@nx/js": "20.8.1",
9 | "@nx/next": "20.8.1",
10 | "@nx/vite": "20.8.1",
11 | "@nx/web": "20.8.1",
12 | "@swc-node/register": "~1.9.1",
13 | "@swc/cli": "~0.6.0",
14 | "@swc/core": "~1.5.7",
15 | "@swc/helpers": "~0.5.11",
16 | "@types/node": "18.16.9",
17 | "@types/react": "19.0.0",
18 | "@types/react-dom": "19.0.0",
19 | "@vitest/coverage-v8": "^3.0.5",
20 | "@vitest/ui": "^3.0.0",
21 | "autoprefixer": "10.4.13",
22 | "jiti": "2.4.2",
23 | "nx": "20.8.1",
24 | "postcss": "8.4.38",
25 | "prettier": "^2.6.2",
26 | "tailwindcss": "^4",
27 | "tslib": "^2.3.0",
28 | "typescript": "~5.7.2",
29 | "vite": "^6.0.0",
30 | "vitest": "^3.0.0"
31 | },
32 | "engines": {
33 | "node": "22.14.0"
34 | },
35 | "workspaces": [
36 | "packages/*",
37 | "packages/javascript/*",
38 | "apps/*"
39 | ],
40 | "dependencies": {
41 | "next": "~15.2.4",
42 | "react": "19.0.0",
43 | "react-dom": "19.0.0"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/borrowdev/borrow/dbd929524b2eda3e2f052ec62aafcd8c5a804015/packages/.gitkeep
--------------------------------------------------------------------------------
/packages/javascript/node/README.md:
--------------------------------------------------------------------------------
1 | # Borrow Node.js SDK
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | > [!WARNING]
10 | > This package is NOT stable yet. There may be breaking changes with every minor release.
11 | >
12 | > It is recommended to wait for the 1.0.0 release before using this package in production.
13 |
14 | ## Features
15 |
16 | - **Self-hostable** - Easily deploy on your own infrastructure
17 | - **Minimal dependencies** - Lightweight and secure
18 | - **Fully typed** - Complete TypeScript + JSDoc support
19 | - **Simple API** - Intuitive interfaces for all tools
20 | - **Serverless-first** - [Integration](https://borrow.dev/docs/limiter/integrations/supabase) with modern cloud environments
21 |
22 | ## Installation
23 |
24 | ```bash
25 | # npm
26 | npm install @borrowdev/node
27 |
28 | # pnpm
29 | pnpm add @borrowdev/node
30 |
31 | # yarn
32 | yarn add @borrowdev/node
33 |
34 | # bun
35 | bun add @borrowdev/node
36 | ```
37 |
38 | ## Authentication
39 | [Follow this guide.](https://borrow.dev/docs/limiter/quick-start#authentication)
40 |
41 | ## Usage
42 |
43 | Let's use the [fixed window](https://borrow.dev/docs/limiter/algorithms#fixed-window) algorithm to rate limit our login endpoint to 10 requests per minute.
44 |
45 | ```javascript
46 | import { borrow } from "@borrowdev/node";
47 |
48 | const { success, timeLeft } = await borrow.limiter("my-limiter-id", "current-user-id", {
49 | limiters: [{
50 | maxRequests: 10,
51 | interval: "minute",
52 | type: "fixed",
53 | }]
54 | });
55 | if (!success) {
56 | return { message: "Rate limit exceeded." + timeLeft !== null ? ` You can try again in ${timeLeft} seconds.` : "" };
57 | }
58 | ```
59 |
60 | ## Documentation
61 | [Read the full documentation for Limiter](https://borrow.dev/docs/limiter)
--------------------------------------------------------------------------------
/packages/javascript/node/jsr.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@borrow/node",
3 | "version": "0.2.0",
4 | "exports": "./dist/index.d.ts",
5 | "license": "MIT",
6 | "include": ["jsr.json", "./dist/**/*", "README.md"],
7 | "publish": {
8 | "exclude": ["!dist"]
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/javascript/node/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@borrowdev/node",
3 | "version": "0.2.0",
4 | "private": false,
5 | "type": "module",
6 | "main": "./dist/index.js",
7 | "module": "./dist/index.js",
8 | "types": "./dist/index.d.ts",
9 | "scripts": {
10 | "build": "tsc -p tsconfig.lib.json && resolve-tspaths -p tsconfig.lib.json"
11 | },
12 | "exports": {
13 | "./package.json": "./package.json",
14 | "./limiter/host": "./dist/lib/limiter/host/index.js",
15 | ".": {
16 | "development": "./src/index.ts",
17 | "types": "./dist/index.d.ts",
18 | "import": "./dist/index.js",
19 | "node": "./dist/index.js",
20 | "default": "./dist/index.js"
21 | }
22 | },
23 | "files": [
24 | "dist",
25 | "package.json",
26 | "README.md"
27 | ],
28 | "devDependencies": {
29 | "@types/node": "^22.14.1",
30 | "fast-check": "^4.1.1",
31 | "resolve-tspaths": "^0.8.23",
32 | "type-fest": "^4.40.0"
33 | },
34 | "dependencies": {
35 | "zod": "^3.24.3",
36 | "@upstash/redis": "^1.34.8",
37 | "@supabase/supabase-js": "^2.49.4"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/index.ts:
--------------------------------------------------------------------------------
1 | import { limiter } from "@lib/limiter/limiter.js";
2 | import { refillTokens } from "@lib/limiter/tokens.js";
3 | import { LimiterError } from "@lib/limiter/utils.js";
4 |
5 | /**
6 | *
7 | * @param fn - The function to call when this property is being called.
8 | * @param obj - An object with the properties to attach to the function when it's not being called.
9 | */
10 | function createPolymorphicObject<
11 | F extends (...args: any[]) => any,
12 | O extends object
13 | >(fn: F, obj: O): F & O {
14 | const newFn = fn;
15 | for (const prop in obj) {
16 | if (!fn.hasOwnProperty(prop)) {
17 | // @ts-expect-error It seems TypeScript doesn't currently have a way of telling we're safely attaching O to F.
18 | newFn[prop] = obj[prop];
19 | }
20 | }
21 |
22 | return newFn as F & O;
23 | }
24 |
25 | export const borrow = {
26 | limiter: createPolymorphicObject(limiter, {
27 | tokens: {
28 | refill: refillTokens,
29 | },
30 | }),
31 | };
32 |
33 | export { LimiterError };
34 | export default borrow;
35 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/client.spec.ts:
--------------------------------------------------------------------------------
1 | import { BorrowClient, borrow } from "./client.js";
2 |
3 | describe("BorrowClient", () => {
4 | const secret = "test_secret";
5 |
6 | beforeEach(() => {
7 | vi.stubEnv("BORROW_API_KEY", secret);
8 | });
9 |
10 | test("should export a singleton", () => {
11 | const client = new BorrowClient();
12 | expect(borrow).toBeInstanceOf(BorrowClient);
13 | expect(borrow).not.toBe(client);
14 | });
15 |
16 | test("should create a new instance every time the class is instantiated", () => {
17 | const newClient = new BorrowClient();
18 | const client = new BorrowClient();
19 | expect(newClient).toBeInstanceOf(BorrowClient);
20 | expect(newClient).not.toBe(client);
21 | });
22 |
23 | test("should detect secret from environment variable", () => {
24 | const client = new BorrowClient();
25 | expect(client.apiKey).toEqual(secret);
26 | expect(borrow.apiKey).toEqual(secret);
27 | });
28 |
29 | test("should detect endpoint from constructor", () => {
30 | const customEndpoint = "https://custom-endpoint.com/api/v1";
31 | const client = new BorrowClient(undefined, customEndpoint);
32 | expect(client.endpoint).toEqual(customEndpoint);
33 | });
34 |
35 | test("should prioritize secret from constructor", () => {
36 | const constructorSecret = "constructor_secret";
37 | const client = new BorrowClient(constructorSecret);
38 | expect(client.apiKey).toEqual(constructorSecret);
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/client.ts:
--------------------------------------------------------------------------------
1 | type ManagedApiPath = "/limiter";
2 | import process from "node:process";
3 |
4 | class BorrowClient {
5 | endpoint: string | undefined;
6 |
7 | #apiKey: string | undefined;
8 | get apiKey(): string | undefined {
9 | return this.#apiKey || process.env.BORROW_API_KEY;
10 | }
11 | set apiKey(apiKey: string | undefined) {
12 | this.#apiKey = apiKey;
13 | }
14 |
15 | constructor(apiKey?: string, endpoint?: string, isSingleton?: boolean) {
16 | if (
17 | // @ts-expect-error We're checking for the browser environment, so it makes sense TypeScript would complain since this is a Node.js package
18 | typeof window === "object" &&
19 | typeof process !== "object" &&
20 | // @ts-expect-error We're checking for the Deno environment, so it makes sense TypeScript would complain since this is a Node.js package
21 | typeof Deno !== "object"
22 | ) {
23 | console.warn(
24 | "@borrowdev/node should NOT be used in a browser environment, you might end up exposing your secret token!"
25 | );
26 | }
27 |
28 | const finalSecret = apiKey || process.env.BORROW_API_KEY;
29 |
30 | // Sometimes the borrow singleton might be imported before the environment variables are loaded
31 | // so we just issue a warning and check for them when calling the API
32 | if (!finalSecret && !isSingleton) {
33 | console.warn(
34 | "BORROW_API_KEY environment variable not set or 'apiKey' not provided at instantiation time, Borrow will attempt to get it when calling the API..."
35 | );
36 | }
37 |
38 | this.apiKey = finalSecret;
39 | if (endpoint) {
40 | this.endpoint = endpoint;
41 | } else {
42 | this.endpoint = "https://api.borrow.dev/v1";
43 | }
44 | }
45 |
46 | #call(method: string, path: string, options: RequestInit = {}) {
47 | const url = new URL(this.endpoint + path);
48 |
49 | const headers: Record = {
50 | "Content-Type": "application/json",
51 | };
52 |
53 | if (!this.apiKey) {
54 | throw new Error("Couldn't find secret when calling " + this.endpoint);
55 | }
56 | headers["X-Borrow-Api-Key"] = this.apiKey;
57 |
58 | return fetch(url.toString(), {
59 | ...options,
60 | method,
61 | headers,
62 | });
63 | }
64 |
65 | async get(path: ManagedApiPath | string, options: RequestInit = {}) {
66 | return this.#call("GET", path, options);
67 | }
68 | async post(path: ManagedApiPath | string, options: RequestInit = {}) {
69 | return this.#call("POST", path, options);
70 | }
71 | }
72 |
73 | export const borrow = new BorrowClient(undefined, undefined, true);
74 | export { BorrowClient };
75 | export default borrow;
76 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/host/adapters/storage/StorageAdapter.ts:
--------------------------------------------------------------------------------
1 | import { LimiterType } from "@lib/limiter/host/limiter.js";
2 |
3 | type StorageAdapterType = {
4 | getStorageKey: (params: {
5 | limiterType: LimiterType;
6 | userId: string | null;
7 | key: string | null;
8 | }) => string;
9 |
10 | get: (key: string) => Promise | null>;
11 | set: (key: string, value: Record) => Promise;
12 | relative: (key: string, field: string, amount: number) => Promise;
13 | };
14 |
15 | export const getStorageKey = (params: {
16 | limiterType: LimiterType;
17 | userId: string | null;
18 | key: string | null;
19 | }) => {
20 | if (!params.userId && !params.key) {
21 | return `count:global:${params.limiterType || "unknown"}`;
22 | }
23 |
24 | if (params.userId && !params.key) {
25 | return `count:user:${params.limiterType || "unknown"}:${params.userId}`;
26 | }
27 |
28 | if (params.userId && params.key) {
29 | return `count:user-key:${params.key}:${params.limiterType || "unknown"}:${
30 | params.userId
31 | }`;
32 | }
33 |
34 | if (params.key) {
35 | return `count:key:${params.key}:${params.limiterType || "unknown"}`;
36 | }
37 |
38 | throw new Error("Invalid count key combination");
39 | };
40 |
41 | export class StorageAdapter implements StorageAdapterType {
42 | getStorageKey = getStorageKey;
43 | get = async (
44 | ...args: Parameters
45 | ): Promise | null> => {
46 | throw new Error("Method not implemented.");
47 | };
48 | set = async (
49 | ...args: Parameters
50 | ): Promise => {
51 | throw new Error("Method not implemented.");
52 | };
53 | relative = async (
54 | ...args: Parameters
55 | ): Promise => {
56 | throw new Error("Method not implemented.");
57 | };
58 | }
59 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/host/adapters/storage/redis/UpstashRedisAdapter.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 | import { StorageAdapter } from "../StorageAdapter.js";
3 |
4 | export class UpstashRedisAdapter extends StorageAdapter {
5 | private redis: Redis;
6 |
7 | constructor(redis: Redis) {
8 | super();
9 | this.redis = redis;
10 | }
11 |
12 | override relative = async (key: string, field: string, amount: number) => {
13 | await this.redis.hincrby(key, field, amount);
14 | };
15 |
16 | override get = (key: string) => {
17 | return this.redis.hgetall(key);
18 | };
19 |
20 | override set = async (key: string, value: any) => {
21 | await this.redis.hset(key, value);
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/host/algorithms.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Adapters,
3 | getCurrentWindow,
4 | isNewWindow,
5 | isomorphicExecute,
6 | ParsedLimiterParams,
7 | RequestCheckSchema,
8 | UserData,
9 | Storage,
10 | } from "./limiter.js";
11 |
12 | export async function fixed(params: {
13 | backgroundExecute: ParsedLimiterParams["backgroundExecute"];
14 | userId: RequestCheckSchema["userId"];
15 | key: RequestCheckSchema["key"];
16 | storage: Storage;
17 | adapters: Adapters;
18 | userData: UserData;
19 | limiter: {
20 | maxRequests: number;
21 | interval: number;
22 | type: "fixed";
23 | };
24 | }): Promise<{
25 | success: boolean;
26 | timeLeft: number | null;
27 | }> {
28 | const promises = [];
29 | const currentWindow = getCurrentWindow(
30 | Date.now() / 1000,
31 | params.limiter.interval
32 | );
33 |
34 | let success = false;
35 | let timeLeft: number | null = null;
36 |
37 | let { requests: userRequests, ...userDataNoRequest } = params.userData;
38 |
39 | if (isNewWindow(params.userData.lastWindow!, currentWindow, NaN)) {
40 | promises.push(
41 | params.storage.storeUserData({
42 | key: params.key,
43 | userId: params.userId,
44 | amount: 0,
45 | type: "exact",
46 | limiterType: params.limiter.type,
47 | adapters: params.adapters,
48 | userData: {
49 | ...userDataNoRequest,
50 | lastWindow: currentWindow,
51 | },
52 | })
53 | );
54 | // So we don't have to fetch the user again
55 | userRequests = 0;
56 | }
57 |
58 | if (userRequests! >= params.limiter.maxRequests) {
59 | // Fixed time window doesn't need the last window for timeLeft math
60 | // because it's calculated against the clock time.
61 | timeLeft =
62 | params.limiter.interval -
63 | (currentWindow - Math.trunc(currentWindow)) * params.limiter.interval;
64 | } else {
65 | promises.push(
66 | params.storage.storeUserData({
67 | key: params.key,
68 | userId: params.userId,
69 | amount: 1,
70 | type: "relative",
71 | limiterType: params.limiter.type,
72 | adapters: params.adapters,
73 | })
74 | );
75 |
76 | success = true;
77 | }
78 |
79 | await isomorphicExecute(Promise.all(promises), params.backgroundExecute);
80 |
81 | return {
82 | success,
83 | timeLeft,
84 | };
85 | }
86 |
87 | export async function sliding(params: {
88 | backgroundExecute: ParsedLimiterParams["backgroundExecute"];
89 | userId: RequestCheckSchema["userId"];
90 | key: RequestCheckSchema["key"];
91 | storage: Storage;
92 | adapters: Adapters;
93 | userData: UserData;
94 | limiter: {
95 | maxRequests: number;
96 | interval: number;
97 | type: "sliding";
98 | };
99 | }): Promise<{
100 | success: boolean;
101 | timeLeft: number | null;
102 | }> {
103 | const promises = [];
104 | const currentWindow = getCurrentWindow();
105 |
106 | let success = false;
107 | let timeLeft: number | null = null;
108 |
109 | let { requests: userRequests, ...userDataNoRequest } = params.userData;
110 |
111 | if (
112 | isNewWindow(
113 | userDataNoRequest.lastWindow!,
114 | currentWindow,
115 | NaN,
116 | params.limiter.interval
117 | )
118 | ) {
119 | promises.push(
120 | params.storage.storeUserData({
121 | key: params.key,
122 | userId: params.userId,
123 | amount: 0,
124 | type: "exact",
125 | limiterType: params.limiter.type,
126 | adapters: params.adapters,
127 | userData: {
128 | ...userDataNoRequest,
129 | lastWindow: currentWindow,
130 | },
131 | })
132 | );
133 |
134 | userRequests = 0;
135 | }
136 |
137 | if (userRequests! >= (params.limiter.maxRequests as number)) {
138 | timeLeft =
139 | params.limiter.interval - (currentWindow - userDataNoRequest.lastWindow!);
140 | timeLeft = timeLeft < 0 ? 0 : timeLeft;
141 | } else {
142 | promises.push(
143 | params.storage.storeUserData({
144 | key: params.key,
145 | userId: params.userId,
146 | amount: 1,
147 | type: "relative",
148 | limiterType: params.limiter.type,
149 | adapters: params.adapters,
150 | })
151 | );
152 | success = true;
153 | }
154 |
155 | await isomorphicExecute(Promise.all(promises), params.backgroundExecute);
156 |
157 | return {
158 | success,
159 | timeLeft,
160 | };
161 | }
162 |
163 | export async function token(params: {
164 | backgroundExecute: ParsedLimiterParams["backgroundExecute"];
165 | userId: RequestCheckSchema["userId"];
166 | key: RequestCheckSchema["key"];
167 | storage: Storage;
168 | adapters: Adapters;
169 | userData: UserData;
170 | limiter: {
171 | maxTokens: number;
172 | tokensPerReplenish: number;
173 | tokensCost: number;
174 | interval: number;
175 | type: "token";
176 | };
177 | }): Promise<{
178 | success: boolean;
179 | timeLeft: number | null;
180 | tokensLeft: number | null;
181 | }> {
182 | const promises = [];
183 | const currentWindow = getCurrentWindow(
184 | Date.now() / 1000,
185 | params.limiter.interval
186 | );
187 |
188 | const { requests: userRequests, ...userDataNoRequest } = params.userData;
189 |
190 | const tokensCost = params.limiter.tokensCost;
191 | const tokensPerReplenish = params.limiter.tokensPerReplenish;
192 | const maxTokens = params.limiter.maxTokens;
193 |
194 | // Elapsing happens in intervals, you can't replenish tokens in between tokensPerReplenish
195 | const intervals = Math.abs(
196 | Math.trunc(currentWindow - userDataNoRequest.lastWindow!)
197 | );
198 | const elapsed = currentWindow - userDataNoRequest.lastWindow!;
199 | const tokensToReplenish = Math.max(
200 | 0,
201 | Math.floor(intervals * tokensPerReplenish)
202 | );
203 |
204 | let success = false;
205 | let newRequests = userRequests;
206 | let newLastWindow = userDataNoRequest.lastWindow;
207 | let timeLeft: number | null = null;
208 | let tokensLeft: number | null = null;
209 |
210 | if (tokensToReplenish > 0) {
211 | newRequests = Math.max(userRequests! - tokensToReplenish, 0);
212 | newLastWindow = currentWindow;
213 | }
214 |
215 | // Check if there are enough available tokens in the bucket.
216 | // In this table design, "newRequests" represents how many tokens have been consumed,
217 | // so available tokens = maxTokens - newRequests.
218 | if (newRequests! + tokensCost > maxTokens) {
219 | tokensLeft = maxTokens - newRequests!;
220 | timeLeft = params.limiter.interval - elapsed * params.limiter.interval;
221 |
222 | timeLeft = timeLeft < 0 ? 0 : timeLeft;
223 | } else {
224 | const currentTokens = newRequests! + tokensCost;
225 | promises.push(
226 | params.storage.storeUserData({
227 | key: params.key,
228 | userId: params.userId,
229 | amount: currentTokens,
230 | type: "exact",
231 | limiterType: params.limiter.type,
232 | adapters: params.adapters,
233 | userData: {
234 | ...userDataNoRequest,
235 | lastWindow: newLastWindow,
236 | maxTokens: maxTokens,
237 | },
238 | })
239 | );
240 |
241 | timeLeft = maxTokens - Math.abs(currentTokens);
242 | tokensLeft = timeLeft;
243 |
244 | timeLeft =
245 | timeLeft > 0
246 | ? params.limiter.interval - elapsed * params.limiter.interval
247 | : 0;
248 | // Time left until next replenish
249 | timeLeft = timeLeft < 0 ? 0 : timeLeft;
250 | success = true;
251 | }
252 |
253 | await isomorphicExecute(Promise.all(promises), params.backgroundExecute);
254 |
255 | return {
256 | success,
257 | timeLeft,
258 | tokensLeft,
259 | };
260 | }
261 |
262 | export async function borrow(params: {
263 | backgroundExecute: ParsedLimiterParams["backgroundExecute"];
264 | userId: RequestCheckSchema["userId"];
265 | key: RequestCheckSchema["key"];
266 | storage: Storage;
267 | adapters: Adapters;
268 | limiter: {
269 | type: "borrow";
270 | timeout?: number;
271 | borrowAction?: "start" | "end";
272 | };
273 | userData: UserData;
274 | }): Promise<{
275 | success: boolean;
276 | }> {
277 | const promises = [];
278 | const currentWindow = getCurrentWindow();
279 | let success = false;
280 |
281 | const { requests: userRequests, ...userDataNoRequest } = params.userData;
282 | const borrowAction: "start" | "end" = params.limiter.borrowAction || "start";
283 |
284 | if (borrowAction === "start") {
285 | const newRequests = userRequests! + 1;
286 | if (
287 | newRequests <= 1 ||
288 | currentWindow - userDataNoRequest.lastWindow! >= params.limiter.timeout!
289 | ) {
290 | promises.push(
291 | params.storage.storeUserData({
292 | key: params.key,
293 | userId: params.userId,
294 | amount: 1,
295 | type: "exact",
296 | limiterType: params.limiter.type,
297 | adapters: params.adapters,
298 | userData: {
299 | ...userDataNoRequest,
300 | lastWindow: currentWindow,
301 | },
302 | })
303 | );
304 | success = true;
305 | }
306 | } else {
307 | promises.push(
308 | params.storage.storeUserData({
309 | key: params.key,
310 | userId: params.userId,
311 | amount: 0,
312 | type: "exact",
313 | limiterType: params.limiter.type,
314 | adapters: params.adapters,
315 | userData: {
316 | ...userDataNoRequest,
317 | },
318 | })
319 | );
320 | success = true;
321 | }
322 |
323 | await isomorphicExecute(Promise.all(promises), params.backgroundExecute);
324 |
325 | return {
326 | success,
327 | };
328 | }
329 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/host/index.ts:
--------------------------------------------------------------------------------
1 | import { UpstashRedisAdapter } from "./adapters/storage/redis/UpstashRedisAdapter.js";
2 | import { StorageAdapter } from "./adapters/storage/StorageAdapter.js";
3 | import { limiter } from "./limiter.js";
4 |
5 | export { limiter, StorageAdapter, UpstashRedisAdapter };
6 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/host/limiter.spec.ts:
--------------------------------------------------------------------------------
1 | import { limiter } from "./limiter.js";
2 | import { Redis } from "@upstash/redis";
3 | import fc from "fast-check";
4 | import { UpstashRedisAdapter } from "./adapters/storage/redis/UpstashRedisAdapter.js";
5 |
6 | const redis = new Redis({
7 | url: process.env.UPSTASH_REDIS_REST_URL_BORROW_LIMITER!,
8 | token: process.env.UPSTASH_REDIS_REST_TOKEN_BORROW_LIMITER!,
9 | });
10 |
11 | // Note: Most of the schema is guaranteed by zod, so as a rule of thumb, TypeScript typings generated by zod are enough.
12 | // When testing the schema, we'll only test custom zod logic that doesn't show up on typings such as `refine`, even though it's testing implementation details.
13 | describe(
14 | "limiter function",
15 | {
16 | timeout: 20 * 1000,
17 | },
18 | () => {
19 | const invokeSecret = "valid-secret";
20 | const commonParams = {
21 | req: {
22 | invokeSecret,
23 | action: "check",
24 | userId: "test-user-id",
25 | key: "test-key",
26 | limiters: [
27 | {
28 | type: "fixed",
29 | interval: 60,
30 | maxRequests: 10,
31 | },
32 | ],
33 | },
34 | adapters: {
35 | storage: new UpstashRedisAdapter(redis),
36 | },
37 | } satisfies Parameters[0];
38 |
39 | beforeAll(() => {
40 | vi.stubEnv("BORROW_LIMITER_INVOKE_SECRET", invokeSecret);
41 | vi.useFakeTimers();
42 | });
43 | afterAll(async () => {
44 | vi.useRealTimers();
45 | await redis.flushdb();
46 | });
47 |
48 | it("should return 401 if the invoke secret is invalid", async () => {
49 | await fc.assert(
50 | fc.asyncProperty(fc.string(), async (invalidSecret) => {
51 | const result = await limiter({
52 | ...commonParams,
53 | req: {
54 | ...commonParams.req,
55 | invokeSecret: invalidSecret,
56 | },
57 | });
58 | expect(result).toMatchObject({
59 | result: "error",
60 | error: "UNAUTHORIZED",
61 | status: 401,
62 | timeLeft: null,
63 | });
64 | expect(result.message).toBeTypeOf("string");
65 | expect(result).not.toHaveProperty("tokensLeft");
66 | }),
67 | { numRuns: 3 }
68 | );
69 | });
70 |
71 | it("should refill tokens globally if 'keys' is null", async () => {
72 | const tokenLimiter = {
73 | type: "token",
74 | maxTokens: 157,
75 | tokensPerReplenish: 1,
76 | tokensCost: 5,
77 | interval: "day",
78 | } as const;
79 |
80 | // Simulate the tokens being used globally
81 | const { tokensLeft } = await limiter({
82 | ...commonParams,
83 | req: {
84 | invokeSecret,
85 | action: "check",
86 | key: null,
87 | userId: null,
88 | limiters: [
89 | {
90 | ...tokenLimiter,
91 | tokensCost: tokenLimiter.tokensCost * 3,
92 | },
93 | ],
94 | },
95 | });
96 | expect(tokensLeft).toBe(
97 | tokenLimiter.maxTokens - tokenLimiter.tokensCost * 3
98 | );
99 |
100 | const result = await limiter({
101 | ...commonParams,
102 | req: {
103 | invokeSecret,
104 | action: "refillTokens",
105 | keys: null,
106 | },
107 | });
108 | expect(result).toMatchObject({
109 | result: "success",
110 | status: 200,
111 | timeLeft: null,
112 | });
113 | expect(result.message).toBeTypeOf("string");
114 | expect(result).not.toHaveProperty("tokensLeft");
115 |
116 | const afterResult = await limiter({
117 | ...commonParams,
118 | req: {
119 | invokeSecret,
120 | action: "check",
121 | key: null,
122 | userId: null,
123 | limiters: [tokenLimiter],
124 | },
125 | });
126 | expect(afterResult).toMatchObject({
127 | result: "success",
128 | status: 200,
129 | timeLeft: null,
130 | tokensLeft: tokenLimiter.maxTokens - tokenLimiter.tokensCost,
131 | });
132 | });
133 |
134 | it(
135 | "should refill tokens for the specified 'keys' if the action is 'refillTokens'",
136 | { timeout: 60 * 1000 },
137 | async () => {
138 | const tokenLimiter = {
139 | type: "token",
140 | maxTokens: 157,
141 | tokensPerReplenish: 1,
142 | tokensCost: 5,
143 | interval: "day",
144 | } as const;
145 |
146 | const keys = [
147 | {
148 | key: "test-key",
149 | userId: null,
150 | },
151 | {
152 | key: "test-key-2",
153 | userId: "test-userid-1",
154 | },
155 | {
156 | key: null,
157 | userId: "test-userid-2",
158 | },
159 |
160 | {
161 | key: "test-key-3",
162 | userId: null,
163 | },
164 | {
165 | key: "test-key-4",
166 | userId: "test-userid-3",
167 | },
168 | {
169 | key: null,
170 | userId: "test-userid-4",
171 | },
172 | ];
173 |
174 | // Simulate the tokens being used
175 | for (const key of keys) {
176 | await limiter({
177 | ...commonParams,
178 | req: {
179 | invokeSecret,
180 | action: "check",
181 | key: key.key,
182 | userId: key.userId,
183 | limiters: [tokenLimiter],
184 | },
185 | });
186 | }
187 |
188 | const afterResult = await limiter({
189 | ...commonParams,
190 | req: {
191 | invokeSecret,
192 | action: "refillTokens",
193 | keys,
194 | },
195 | });
196 | expect(afterResult).toMatchObject({
197 | result: "success",
198 | status: 200,
199 | timeLeft: null,
200 | });
201 | expect(afterResult.message).toBeTypeOf("string");
202 | expect(afterResult).not.toHaveProperty("tokensLeft");
203 |
204 | // Check the tokens were actually refilled
205 | for (const k of keys) {
206 | const result = await limiter({
207 | ...commonParams,
208 | req: {
209 | invokeSecret,
210 | action: "check",
211 | key: k.key,
212 | userId: k.userId,
213 | limiters: [tokenLimiter],
214 | },
215 | });
216 | expect(result).toMatchObject({
217 | result: "success",
218 | status: 200,
219 | timeLeft: null,
220 | tokensLeft: tokenLimiter.maxTokens - tokenLimiter.tokensCost,
221 | });
222 | }
223 | }
224 | );
225 |
226 | it("should store usage globally if 'userId' and 'key' is not provided", async () => {
227 | const fixedLimiter = {
228 | type: "fixed",
229 | interval: 60,
230 | maxRequests: 10,
231 | } as const;
232 |
233 | // Store globally
234 | const globalResult = await limiter({
235 | ...commonParams,
236 | req: {
237 | invokeSecret,
238 | action: "check",
239 | key: null,
240 | userId: null,
241 | limiters: [fixedLimiter],
242 | },
243 | });
244 | expect(globalResult).toMatchObject({
245 | result: "success",
246 | status: 200,
247 | });
248 |
249 | // Store per userId + key
250 | const userKeyResult = await limiter({
251 | ...commonParams,
252 | req: {
253 | invokeSecret,
254 | action: "check",
255 | key: "specific-key",
256 | userId: "specific-user",
257 | limiters: [fixedLimiter],
258 | },
259 | });
260 | expect(userKeyResult).toMatchObject({
261 | result: "success",
262 | status: 200,
263 | });
264 |
265 | // Check the globally stored usage is still the same (should increment once more)
266 | const secondGlobalResult = await limiter({
267 | ...commonParams,
268 | req: {
269 | invokeSecret,
270 | action: "check",
271 | key: null,
272 | userId: null,
273 | limiters: [fixedLimiter],
274 | },
275 | });
276 | expect(secondGlobalResult).toMatchObject({
277 | result: "success",
278 | status: 200,
279 | });
280 | });
281 |
282 | it("should store usage per user if 'userId' is provided and 'key' is not provided", async () => {
283 | const fixedLimiter = {
284 | type: "fixed",
285 | interval: 60,
286 | maxRequests: 10,
287 | } as const;
288 | const userId = "test-user-specific";
289 |
290 | // Store per userId
291 | const userResult = await limiter({
292 | ...commonParams,
293 | req: {
294 | invokeSecret,
295 | action: "check",
296 | key: null,
297 | userId: userId,
298 | limiters: [fixedLimiter],
299 | },
300 | });
301 | expect(userResult).toMatchObject({
302 | result: "success",
303 | status: 200,
304 | });
305 |
306 | // Store per userId + key
307 | const userKeyResult = await limiter({
308 | ...commonParams,
309 | req: {
310 | invokeSecret,
311 | action: "check",
312 | key: "specific-key",
313 | userId: userId,
314 | limiters: [fixedLimiter],
315 | },
316 | });
317 | expect(userKeyResult).toMatchObject({
318 | result: "success",
319 | status: 200,
320 | });
321 |
322 | // Check the per userId stored usage is still the same (should increment once more)
323 | const secondUserResult = await limiter({
324 | ...commonParams,
325 | req: {
326 | invokeSecret,
327 | action: "check",
328 | key: null,
329 | userId: userId,
330 | limiters: [fixedLimiter],
331 | },
332 | });
333 | expect(secondUserResult).toMatchObject({
334 | result: "success",
335 | status: 200,
336 | });
337 | });
338 |
339 | it("should store usage only by 'key' if it's the only identifier provided", async () => {
340 | const fixedLimiter = {
341 | type: "fixed",
342 | interval: 60,
343 | maxRequests: 10,
344 | } as const;
345 | const testKey = "test-unique-key";
346 |
347 | // Store per key
348 | const keyResult = await limiter({
349 | ...commonParams,
350 | req: {
351 | invokeSecret,
352 | action: "check",
353 | key: testKey,
354 | userId: null,
355 | limiters: [fixedLimiter],
356 | },
357 | });
358 | expect(keyResult).toMatchObject({
359 | result: "success",
360 | status: 200,
361 | });
362 |
363 | // Store per userId + key (different userId)
364 | const userKeyResult = await limiter({
365 | ...commonParams,
366 | req: {
367 | invokeSecret,
368 | action: "check",
369 | key: testKey,
370 | userId: "some-random-user",
371 | limiters: [fixedLimiter],
372 | },
373 | });
374 | expect(userKeyResult).toMatchObject({
375 | result: "success",
376 | status: 200,
377 | });
378 |
379 | // Check the per key stored usage is still the same (should increment once more)
380 | const secondKeyResult = await limiter({
381 | ...commonParams,
382 | req: {
383 | invokeSecret,
384 | action: "check",
385 | key: testKey,
386 | userId: null,
387 | limiters: [fixedLimiter],
388 | },
389 | });
390 | expect(secondKeyResult).toMatchObject({
391 | result: "success",
392 | status: 200,
393 | });
394 | });
395 |
396 | it("should return 400 if 'limiters' has non unique types", async () => {
397 | await fc.assert(
398 | fc.asyncProperty(fc.boolean(), async (includeValidLimiter) => {
399 | const duplicateLimiters: {
400 | type: "fixed" | "sliding";
401 | interval: number;
402 | maxRequests: number;
403 | }[] = [
404 | {
405 | type: "fixed",
406 | interval: 60,
407 | maxRequests: 10,
408 | },
409 | {
410 | type: "fixed", // Duplicate type
411 | interval: 120,
412 | maxRequests: 5,
413 | },
414 | ];
415 |
416 | if (includeValidLimiter) {
417 | duplicateLimiters.push({
418 | type: "sliding", // Different type
419 | interval: 60,
420 | maxRequests: 15,
421 | });
422 | }
423 |
424 | const result = await limiter({
425 | ...commonParams,
426 | req: {
427 | invokeSecret,
428 | action: "check",
429 | key: "test-key-duplicate",
430 | userId: "test-user-duplicate",
431 | limiters: duplicateLimiters,
432 | },
433 | });
434 |
435 | expect(result).toMatchObject({
436 | result: "error",
437 | error: "INVALID_PARAMS",
438 | status: 400,
439 | timeLeft: null,
440 | });
441 | expect(result.message).toContain("unique");
442 | }),
443 | { numRuns: 2 }
444 | );
445 | });
446 |
447 | describe("fixed limiter type", () => {
448 | it("should let requests pass if under the limit", async () => {
449 | const fixedLimiter = {
450 | type: "fixed",
451 | interval: 60,
452 | maxRequests: 3,
453 | } as const;
454 |
455 | const testKey = "fixed-under-limit";
456 | const testUserId = "user-fixed-under";
457 |
458 | // First request
459 | const firstResult = await limiter({
460 | ...commonParams,
461 | req: {
462 | invokeSecret,
463 | action: "check",
464 | key: testKey,
465 | userId: testUserId,
466 | limiters: [fixedLimiter],
467 | },
468 | });
469 | expect(firstResult).toMatchObject({
470 | result: "success",
471 | status: 200,
472 | });
473 |
474 | // Second request - still under limit
475 | const secondResult = await limiter({
476 | ...commonParams,
477 | req: {
478 | invokeSecret,
479 | action: "check",
480 | key: testKey,
481 | userId: testUserId,
482 | limiters: [fixedLimiter],
483 | },
484 | });
485 | expect(secondResult).toMatchObject({
486 | result: "success",
487 | status: 200,
488 | });
489 | });
490 |
491 | it("should block requests if over the limit", async () => {
492 | const fixedLimiter = {
493 | type: "fixed",
494 | interval: 60,
495 | maxRequests: 2,
496 | } as const;
497 |
498 | const testKey = "fixed-over-limit";
499 | const testUserId = "user-fixed-over";
500 |
501 | // First request
502 | const firstResult = await limiter({
503 | ...commonParams,
504 | req: {
505 | invokeSecret,
506 | action: "check",
507 | key: testKey,
508 | userId: testUserId,
509 | limiters: [fixedLimiter],
510 | },
511 | });
512 | expect(firstResult).toMatchObject({
513 | result: "success",
514 | status: 200,
515 | });
516 |
517 | // Second request - reaching limit
518 | const secondResult = await limiter({
519 | ...commonParams,
520 | req: {
521 | invokeSecret,
522 | action: "check",
523 | key: testKey,
524 | userId: testUserId,
525 | limiters: [fixedLimiter],
526 | },
527 | });
528 | expect(secondResult).toMatchObject({
529 | result: "success",
530 | status: 200,
531 | });
532 |
533 | // Third request - over limit
534 | const thirdResult = await limiter({
535 | ...commonParams,
536 | req: {
537 | invokeSecret,
538 | action: "check",
539 | key: testKey,
540 | userId: testUserId,
541 | limiters: [fixedLimiter],
542 | },
543 | });
544 | expect(thirdResult).toMatchObject({
545 | result: "limited",
546 | status: 200,
547 | });
548 | expect(thirdResult.timeLeft).toBeTypeOf("number");
549 | });
550 |
551 | it("should reset the counter based on clock time", async () => {
552 | // Use minute as interval
553 | const fixedLimiter = {
554 | type: "fixed",
555 | interval: "minute",
556 | maxRequests: 1,
557 | } as const;
558 |
559 | const testKey = "fixed-reset";
560 | const testUserId = "user-fixed-reset";
561 |
562 | // Set the time to 1 second before next minute
563 | const now = new Date();
564 | const nearlyNextMinute = new Date(
565 | now.getFullYear(),
566 | now.getMonth(),
567 | now.getDate(),
568 | now.getHours(),
569 | now.getMinutes(),
570 | 59 // 1 second before next minute
571 | );
572 | vi.setSystemTime(nearlyNextMinute);
573 |
574 | // First request - should pass
575 | const firstResult = await limiter({
576 | ...commonParams,
577 | req: {
578 | invokeSecret,
579 | action: "check",
580 | key: testKey,
581 | userId: testUserId,
582 | limiters: [fixedLimiter],
583 | },
584 | });
585 | expect(firstResult).toMatchObject({
586 | result: "success",
587 | status: 200,
588 | });
589 |
590 | // Second request - should block (limit reached)
591 | const secondResult = await limiter({
592 | ...commonParams,
593 | req: {
594 | invokeSecret,
595 | action: "check",
596 | key: testKey,
597 | userId: testUserId,
598 | limiters: [fixedLimiter],
599 | },
600 | });
601 | expect(secondResult).toMatchObject({
602 | result: "limited",
603 | status: 200,
604 | });
605 |
606 | // Advance to next minute
607 | const nextMinute = new Date(
608 | nearlyNextMinute.getFullYear(),
609 | nearlyNextMinute.getMonth(),
610 | nearlyNextMinute.getDate(),
611 | nearlyNextMinute.getHours(),
612 | nearlyNextMinute.getMinutes() + 1,
613 | 1 // 1 second into next minute
614 | );
615 | vi.setSystemTime(nextMinute);
616 |
617 | // Third request - should pass (counter reset)
618 | const thirdResult = await limiter({
619 | ...commonParams,
620 | req: {
621 | invokeSecret,
622 | action: "check",
623 | key: testKey,
624 | userId: testUserId,
625 | limiters: [fixedLimiter],
626 | },
627 | });
628 |
629 | expect(thirdResult).toMatchObject({
630 | result: "success",
631 | status: 200,
632 | });
633 | });
634 | });
635 |
636 | describe("sliding limiter type", () => {
637 | it("should let requests pass if under the limit", async () => {
638 | const slidingLimiter = {
639 | type: "sliding",
640 | interval: 60,
641 | maxRequests: 3,
642 | } as const;
643 |
644 | const testKey = "sliding-under-limit";
645 | const testUserId = "user-sliding-under";
646 |
647 | // First request
648 | const firstResult = await limiter({
649 | ...commonParams,
650 | req: {
651 | invokeSecret,
652 | action: "check",
653 | key: testKey,
654 | userId: testUserId,
655 | limiters: [slidingLimiter],
656 | },
657 | });
658 | expect(firstResult).toMatchObject({
659 | result: "success",
660 | status: 200,
661 | });
662 |
663 | // Second request - still under limit
664 | const secondResult = await limiter({
665 | ...commonParams,
666 | req: {
667 | invokeSecret,
668 | action: "check",
669 | key: testKey,
670 | userId: testUserId,
671 | limiters: [slidingLimiter],
672 | },
673 | });
674 | expect(secondResult).toMatchObject({
675 | result: "success",
676 | status: 200,
677 | });
678 | });
679 |
680 | it("should block requests if over the limit", async () => {
681 | const slidingLimiter = {
682 | type: "sliding",
683 | interval: 60,
684 | maxRequests: 2,
685 | } as const;
686 |
687 | const testKey = "sliding-over-limit";
688 | const testUserId = "user-sliding-over";
689 |
690 | // First request
691 | const firstResult = await limiter({
692 | ...commonParams,
693 | req: {
694 | invokeSecret,
695 | action: "check",
696 | key: testKey,
697 | userId: testUserId,
698 | limiters: [slidingLimiter],
699 | },
700 | });
701 | expect(firstResult).toMatchObject({
702 | result: "success",
703 | status: 200,
704 | });
705 |
706 | // Second request - reaching limit
707 | const secondResult = await limiter({
708 | ...commonParams,
709 | req: {
710 | invokeSecret,
711 | action: "check",
712 | key: testKey,
713 | userId: testUserId,
714 | limiters: [slidingLimiter],
715 | },
716 | });
717 | expect(secondResult).toMatchObject({
718 | result: "success",
719 | status: 200,
720 | });
721 |
722 | // Third request - over limit
723 | const thirdResult = await limiter({
724 | ...commonParams,
725 | req: {
726 | invokeSecret,
727 | action: "check",
728 | key: testKey,
729 | userId: testUserId,
730 | limiters: [slidingLimiter],
731 | },
732 | });
733 | expect(thirdResult).toMatchObject({
734 | result: "limited",
735 | status: 200,
736 | });
737 | expect(thirdResult.timeLeft).toBeTypeOf("number");
738 | });
739 |
740 | it("should reset the counter based on the sliding window", async () => {
741 | const slidingLimiter = {
742 | type: "sliding",
743 | interval: 60, // 1 minute interval
744 | maxRequests: 1,
745 | } as const;
746 |
747 | const testKey = "sliding-reset";
748 | const testUserId = "user-sliding-reset";
749 |
750 | // Set initial time
751 | const initialTime = new Date(2025, 0, 1, 12, 0, 0); // Jan 1, 2025, 12:00:00
752 | vi.setSystemTime(initialTime);
753 |
754 | // First request - should pass
755 | const firstResult = await limiter({
756 | ...commonParams,
757 | req: {
758 | invokeSecret,
759 | action: "check",
760 | key: testKey,
761 | userId: testUserId,
762 | limiters: [slidingLimiter],
763 | },
764 | });
765 | expect(firstResult).toMatchObject({
766 | result: "success",
767 | status: 200,
768 | });
769 |
770 | // Advance time by 30 seconds (half the interval)
771 | const halfwayTime = new Date(2025, 0, 1, 12, 0, 30); // Jan 1, 2025, 12:00:30
772 | vi.setSystemTime(halfwayTime);
773 |
774 | // Second request - should be blocked (within sliding window)
775 | const secondResult = await limiter({
776 | ...commonParams,
777 | req: {
778 | invokeSecret,
779 | action: "check",
780 | key: testKey,
781 | userId: testUserId,
782 | limiters: [slidingLimiter],
783 | },
784 | });
785 | expect(secondResult).toMatchObject({
786 | result: "limited",
787 | status: 200,
788 | });
789 |
790 | // Advance time by another 30 seconds (full interval has passed)
791 | const fullIntervalTime = new Date(2025, 0, 1, 12, 1, 1); // Jan 1, 2025, 12:01:01
792 | vi.setSystemTime(fullIntervalTime);
793 |
794 | // Third request - should pass (sliding window has moved past first request)
795 | const thirdResult = await limiter({
796 | ...commonParams,
797 | req: {
798 | invokeSecret,
799 | action: "check",
800 | key: testKey,
801 | userId: testUserId,
802 | limiters: [slidingLimiter],
803 | },
804 | });
805 |
806 | expect(thirdResult).toMatchObject({
807 | result: "success",
808 | status: 200,
809 | });
810 | });
811 | });
812 |
813 | describe("token limiter type", () => {
814 | it("should let requests pass if under token limit", async () => {
815 | const tokenLimiter = {
816 | type: "token",
817 | maxTokens: 10,
818 | tokensPerReplenish: 1,
819 | tokensCost: 3,
820 | interval: 60,
821 | } as const;
822 |
823 | const testKey = "token-under-limit";
824 | const testUserId = "user-token-under";
825 |
826 | // Request costs 3 tokens out of 10 available
827 | const result = await limiter({
828 | ...commonParams,
829 | req: {
830 | invokeSecret,
831 | action: "check",
832 | key: testKey,
833 | userId: testUserId,
834 | limiters: [tokenLimiter],
835 | },
836 | });
837 |
838 | expect(result).toMatchObject({
839 | result: "success",
840 | status: 200,
841 | tokensLeft: tokenLimiter.maxTokens - tokenLimiter.tokensCost,
842 | });
843 | });
844 |
845 | it("should block requests if not enough tokens", async () => {
846 | const tokenLimiter = {
847 | type: "token",
848 | maxTokens: 10,
849 | tokensPerReplenish: 1,
850 | tokensCost: 12, // More than available
851 | interval: 60,
852 | } as const;
853 |
854 | const testKey = "token-over-limit";
855 | const testUserId = "user-token-over";
856 |
857 | // Request costs 12 tokens but only 10 available
858 | const result = await limiter({
859 | ...commonParams,
860 | req: {
861 | invokeSecret,
862 | action: "check",
863 | key: testKey,
864 | userId: testUserId,
865 | limiters: [tokenLimiter],
866 | },
867 | });
868 |
869 | expect(result).toMatchObject({
870 | result: "limited",
871 | status: 200,
872 | });
873 | expect(result.tokensLeft).toBeTypeOf("number");
874 | });
875 |
876 | it("should replenish tokens based on the interval", async () => {
877 | const tokenLimiter = {
878 | type: "token",
879 | maxTokens: 10,
880 | tokensPerReplenish: 5,
881 | tokensCost: 2,
882 | interval: 60, // 1 minute
883 | } as const;
884 |
885 | const testKey = "token-replenish";
886 | const testUserId = "user-token-replenish";
887 |
888 | // Set initial time
889 | const initialTime = new Date(2025, 0, 1, 12, 0, 0);
890 | vi.setSystemTime(initialTime);
891 |
892 | // First request - costs full tokens (10)
893 | const firstResult = await limiter({
894 | ...commonParams,
895 | req: {
896 | invokeSecret,
897 | action: "check",
898 | key: testKey,
899 | userId: testUserId,
900 | limiters: [
901 | {
902 | ...tokenLimiter,
903 | tokensCost: 10, // Use all available tokens
904 | },
905 | ],
906 | },
907 | });
908 | expect(firstResult).toMatchObject({
909 | result: "success",
910 | status: 200,
911 | tokensLeft: 0,
912 | });
913 |
914 | // Second request - should be blocked (no tokens left)
915 | const secondResult = await limiter({
916 | ...commonParams,
917 | req: {
918 | invokeSecret,
919 | action: "check",
920 | key: testKey,
921 | userId: testUserId,
922 | limiters: [tokenLimiter],
923 | },
924 | });
925 | expect(secondResult).toMatchObject({
926 | result: "limited",
927 | status: 200,
928 | });
929 |
930 | // Advance time past the interval to trigger token replenishment
931 | const replenishTime = new Date(2025, 0, 1, 12, 1, 1); // 1 minute and 1 second later
932 | vi.setSystemTime(replenishTime);
933 |
934 | // Third request - should pass with replenished tokens
935 | const thirdResult = await limiter({
936 | ...commonParams,
937 | req: {
938 | invokeSecret,
939 | action: "check",
940 | key: testKey,
941 | userId: testUserId,
942 | limiters: [tokenLimiter],
943 | },
944 | });
945 |
946 | expect(thirdResult).toMatchObject({
947 | result: "success",
948 | status: 200,
949 | tokensLeft: tokenLimiter.tokensPerReplenish - tokenLimiter.tokensCost,
950 | });
951 | });
952 | });
953 |
954 | describe("borrow limiter type", () => {
955 | it("should let a borrow start and end if no active borrow exists", async () => {
956 | const borrowStartLimiter = {
957 | type: "borrow",
958 | timeout: 60,
959 | borrowAction: "start",
960 | } as const;
961 |
962 | const borrowEndLimiter = {
963 | type: "borrow",
964 | timeout: 60,
965 | borrowAction: "end",
966 | } as const;
967 |
968 | const testKey = "borrow-start-end";
969 | const testUserId = "user-borrow-start-end";
970 |
971 | // Start a borrow - should pass
972 | const startResult = await limiter({
973 | ...commonParams,
974 | req: {
975 | invokeSecret,
976 | action: "check",
977 | key: testKey,
978 | userId: testUserId,
979 | limiters: [borrowStartLimiter],
980 | },
981 | });
982 | expect(startResult).toMatchObject({
983 | result: "success",
984 | status: 200,
985 | });
986 |
987 | // End the borrow - should pass
988 | const endResult = await limiter({
989 | ...commonParams,
990 | req: {
991 | invokeSecret,
992 | action: "check",
993 | key: testKey,
994 | userId: testUserId,
995 | limiters: [borrowEndLimiter],
996 | },
997 | });
998 | expect(endResult).toMatchObject({
999 | result: "success",
1000 | status: 200,
1001 | });
1002 |
1003 | // Start another borrow - should pass
1004 | const secondStartResult = await limiter({
1005 | ...commonParams,
1006 | req: {
1007 | invokeSecret,
1008 | action: "check",
1009 | key: testKey,
1010 | userId: testUserId,
1011 | limiters: [borrowStartLimiter],
1012 | },
1013 | });
1014 | expect(secondStartResult).toMatchObject({
1015 | result: "success",
1016 | status: 200,
1017 | });
1018 | });
1019 |
1020 | it("should not accumulate multiple borrows for the same key", async () => {
1021 | const borrowStartLimiter = {
1022 | type: "borrow",
1023 | timeout: 60,
1024 | borrowAction: "start",
1025 | } as const;
1026 |
1027 | const borrowEndLimiter = {
1028 | type: "borrow",
1029 | timeout: 60,
1030 | borrowAction: "end",
1031 | } as const;
1032 |
1033 | const testKey = "borrow-multiple";
1034 | const testUserId = "user-borrow-multiple";
1035 |
1036 | // Start a borrow - should pass
1037 | const startResult = await limiter({
1038 | ...commonParams,
1039 | req: {
1040 | invokeSecret,
1041 | action: "check",
1042 | key: testKey,
1043 | userId: testUserId,
1044 | limiters: [borrowStartLimiter],
1045 | },
1046 | });
1047 | expect(startResult).toMatchObject({
1048 | result: "success",
1049 | status: 200,
1050 | });
1051 |
1052 | // Try to start another borrow - should be limited
1053 | const secondStartResult = await limiter({
1054 | ...commonParams,
1055 | req: {
1056 | invokeSecret,
1057 | action: "check",
1058 | key: testKey,
1059 | userId: testUserId,
1060 | limiters: [borrowStartLimiter],
1061 | },
1062 | });
1063 | expect(secondStartResult).toMatchObject({
1064 | result: "limited",
1065 | status: 200,
1066 | });
1067 |
1068 | // End the borrow - should pass
1069 | const endResult = await limiter({
1070 | ...commonParams,
1071 | req: {
1072 | invokeSecret,
1073 | action: "check",
1074 | key: testKey,
1075 | userId: testUserId,
1076 | limiters: [borrowEndLimiter],
1077 | },
1078 | });
1079 | expect(endResult).toMatchObject({
1080 | result: "success",
1081 | status: 200,
1082 | });
1083 |
1084 | // Start a new borrow - should pass
1085 | const thirdStartResult = await limiter({
1086 | ...commonParams,
1087 | req: {
1088 | invokeSecret,
1089 | action: "check",
1090 | key: testKey,
1091 | userId: testUserId,
1092 | limiters: [borrowStartLimiter],
1093 | },
1094 | });
1095 | expect(thirdStartResult).toMatchObject({
1096 | result: "success",
1097 | status: 200,
1098 | });
1099 |
1100 | // End the borrow multiple times - should pass
1101 | for (let i = 0; i < 3; i++) {
1102 | const multiEndResult = await limiter({
1103 | ...commonParams,
1104 | req: {
1105 | invokeSecret,
1106 | action: "check",
1107 | key: testKey,
1108 | userId: testUserId,
1109 | limiters: [borrowEndLimiter],
1110 | },
1111 | });
1112 | expect(multiEndResult).toMatchObject({
1113 | result: "success",
1114 | status: 200,
1115 | });
1116 | }
1117 |
1118 | // Start a new borrow - should pass
1119 | const finalStartResult = await limiter({
1120 | ...commonParams,
1121 | req: {
1122 | invokeSecret,
1123 | action: "check",
1124 | key: testKey,
1125 | userId: testUserId,
1126 | limiters: [borrowStartLimiter],
1127 | },
1128 | });
1129 | expect(finalStartResult).toMatchObject({
1130 | result: "success",
1131 | status: 200,
1132 | });
1133 |
1134 | // Try to start another borrow - should be limited
1135 | const finalDuplicateStartResult = await limiter({
1136 | ...commonParams,
1137 | req: {
1138 | invokeSecret,
1139 | action: "check",
1140 | key: testKey,
1141 | userId: testUserId,
1142 | limiters: [borrowStartLimiter],
1143 | },
1144 | });
1145 | expect(finalDuplicateStartResult).toMatchObject({
1146 | result: "limited",
1147 | status: 200,
1148 | });
1149 | });
1150 | });
1151 |
1152 | describe("interval shortcuts", () => {
1153 | it("should correctly translate interval shortcuts", async () => {
1154 | const shortcuts = [
1155 | { input: "minute", seconds: 60 },
1156 | { input: "hour", seconds: 60 * 60 },
1157 | { input: "day", seconds: 60 * 60 * 24 },
1158 | ] as const;
1159 |
1160 | // Use fixed date for consistent results
1161 | const fixedDate = new Date("2025-01-01T00:00:00Z");
1162 | vi.setSystemTime(fixedDate);
1163 |
1164 | for (const shortcut of shortcuts) {
1165 | const fixedLimiter = {
1166 | type: "fixed" as const,
1167 | interval: shortcut.input,
1168 | maxRequests: 2,
1169 | };
1170 |
1171 | const testKey = `shortcut-${shortcut.input}`;
1172 | const testUserId = `user-shortcut-${shortcut.input}`;
1173 |
1174 | // Exhaust the limit with requests
1175 | for (let j = 0; j < fixedLimiter.maxRequests; j++) {
1176 | const passResult = await limiter({
1177 | ...commonParams,
1178 | req: {
1179 | invokeSecret,
1180 | action: "check",
1181 | key: testKey,
1182 | userId: testUserId,
1183 | limiters: [fixedLimiter],
1184 | },
1185 | });
1186 |
1187 | expect(passResult).toMatchObject({
1188 | result: "success",
1189 | status: 200,
1190 | });
1191 | }
1192 |
1193 | // Next request should be limited
1194 | const limitedResult = await limiter({
1195 | ...commonParams,
1196 | req: {
1197 | invokeSecret,
1198 | action: "check",
1199 | key: testKey,
1200 | userId: testUserId,
1201 | limiters: [fixedLimiter],
1202 | },
1203 | });
1204 |
1205 | expect(limitedResult).toMatchObject({
1206 | result: "limited",
1207 | status: 200,
1208 | });
1209 | expect(limitedResult.timeLeft).toBeTypeOf("number");
1210 |
1211 | const expectedTime = shortcut.seconds;
1212 | const actualTime = limitedResult.timeLeft;
1213 |
1214 | expect(actualTime).toBe(expectedTime);
1215 | }
1216 | });
1217 | });
1218 | }
1219 | );
1220 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/host/limiter.ts:
--------------------------------------------------------------------------------
1 | import { StorageAdapter } from "./adapters/storage/StorageAdapter.js";
2 | import { borrow, fixed, sliding, token } from "./algorithms.js";
3 | import { z } from "zod";
4 |
5 | type LimiterParams = z.infer;
6 | export type ParsedLimiterParams = z.infer;
7 |
8 | export function getUsageKey(userUuid: string, projectId: string) {
9 | return `limiter:usage:${userUuid}:${projectId}`;
10 | }
11 |
12 | export function isomorphicExecute(
13 | promise: Promise,
14 | backgroundExecute: ParsedLimiterParams["backgroundExecute"]
15 | ) {
16 | if (typeof backgroundExecute === "function") {
17 | backgroundExecute(promise);
18 | }
19 |
20 | return promise;
21 | }
22 |
23 | function getBiggest(numbers: number[]) {
24 | return Math.max(...numbers);
25 | }
26 |
27 | export function getCurrentWindow(
28 | dateInSeconds = Date.now() / 1000,
29 | // Only fixed windows need this
30 | interval?: number
31 | ) {
32 | return typeof interval === "number"
33 | ? dateInSeconds / interval
34 | : dateInSeconds;
35 | }
36 |
37 | export function isNewWindow(
38 | lastWindow: number,
39 | currentWindow: number,
40 | // TODO: Remove or implement timeout
41 | _timeout: number,
42 | // Only sliding windows need this, fixed windows have
43 | // their interval calculated differently
44 | interval?: number
45 | ) {
46 | const cleanLastWindow = Math.trunc(lastWindow);
47 | const cleanCurrentWindow = Math.trunc(currentWindow);
48 | return interval
49 | ? cleanCurrentWindow - cleanLastWindow > interval
50 | : cleanCurrentWindow > cleanLastWindow;
51 | }
52 |
53 | const asyncNoop = () => Promise.resolve();
54 | export type UserData = z.infer;
55 |
56 | type LimiterHandlerResponse = {
57 | result: "success" | "error" | "limited";
58 | status: number;
59 | error?: "INVALID_PARAMS" | "UNAUTHORIZED";
60 | message: string;
61 | timeLeft: number | null;
62 | tokensLeft?: number | null;
63 | };
64 |
65 | export type Storage = typeof storage;
66 | const storage = {
67 | storeUserData: async (params: {
68 | key: string | null;
69 | userId: string | null;
70 | amount: number;
71 | type: "exact" | "relative";
72 | limiterType: LimiterType;
73 | adapters: z.infer;
74 | userData?: UserData | null;
75 | }) => {
76 | const key = params.adapters.storage.getStorageKey({
77 | key: params.key,
78 | userId: params.userId,
79 | limiterType: params.limiterType,
80 | });
81 |
82 | if (params.type === "exact") {
83 | const { key: _key, ...userData } = params.userData || {};
84 | await params.adapters.storage.set(key, {
85 | ...userData,
86 | requests: params.amount,
87 | });
88 | } else if (params.type === "relative") {
89 | await params.adapters.storage.relative(key, "requests", params.amount);
90 | }
91 | },
92 |
93 | getUserData: async (params: z.infer) => {
94 | if (!params.adapters) {
95 | throw new Error("You must provide storage adapters");
96 | }
97 |
98 | const key = params.adapters.storage.getStorageKey({
99 | key: params.key,
100 | userId: params.userId,
101 | limiterType: params.limiterType,
102 | });
103 |
104 | const userData = (await params.adapters.storage.get(
105 | key
106 | )) as UserData | null;
107 |
108 | // If the user doesn't exist yet
109 | if (!userData) {
110 | const fallbackUserData: z.infer = {
111 | key,
112 | type: params.limiterType,
113 | requests: 0,
114 | lastWindow: getCurrentWindow(
115 | Date.now() / 1000,
116 | params.limiterType === "fixed" ? params.interval : undefined
117 | ),
118 | ...(params.interval ? { interval: params.interval } : {}),
119 | } as const;
120 |
121 | await storage.storeUserData({
122 | key: params.key,
123 | userId: params.userId,
124 | amount: 0,
125 | type: "exact",
126 | adapters: params.adapters,
127 | limiterType: params.limiterType,
128 | userData: fallbackUserData,
129 | });
130 |
131 | return fallbackUserData;
132 | }
133 |
134 | if (typeof userData.lastWindow === "string") {
135 | userData.lastWindow = parseFloat(userData.lastWindow);
136 | }
137 |
138 | return userData;
139 | },
140 |
141 | beforeResponse: asyncNoop,
142 | };
143 |
144 | const positiveIntSchema = z.number().positive().int();
145 |
146 | export type LimiterType = z.infer;
147 | const limiterTypeSchema = z.enum(["fixed", "sliding", "token", "borrow"]);
148 |
149 | const identifierSchema = z.object({
150 | userId: z.string().nullable(),
151 | key: z.string().nullable(),
152 | });
153 |
154 | // TODO: Discriminated union for limiter types instead of using `optional`
155 | const userDataSchema = z.object({
156 | key: z.string(),
157 | type: limiterTypeSchema,
158 | requests: positiveIntSchema.optional(),
159 | lastWindow: positiveIntSchema.optional(),
160 | interval: positiveIntSchema.optional(),
161 | maxTokens: positiveIntSchema.optional(),
162 | });
163 |
164 | const limiterIntervalSchema = z.union([
165 | z.enum(["minute", "hour", "day"]),
166 | positiveIntSchema,
167 | ]);
168 |
169 | const limitersSchema = z
170 | .array(
171 | z.discriminatedUnion("type", [
172 | z.object({
173 | type: z.literal("fixed"),
174 | maxRequests: positiveIntSchema,
175 | interval: limiterIntervalSchema,
176 | }),
177 | z.object({
178 | type: z.literal("sliding"),
179 | maxRequests: positiveIntSchema,
180 | interval: limiterIntervalSchema,
181 | }),
182 | z.object({
183 | type: z.literal("token"),
184 | maxRequests: positiveIntSchema.optional(),
185 | interval: limiterIntervalSchema,
186 | tokensCost: positiveIntSchema,
187 | tokensPerReplenish: positiveIntSchema,
188 | maxTokens: positiveIntSchema,
189 | }),
190 | z.object({
191 | type: z.literal("borrow"),
192 | borrowAction: z.enum(["start", "end"]).optional(),
193 | timeout: positiveIntSchema,
194 | }),
195 | ])
196 | )
197 | .min(1, "You must provide at least 1 limiter configuration")
198 | .max(4, "You can provide at most 4 limiter configurations")
199 | .refine((limiters) => {
200 | const types = limiters.map((l) => l.type);
201 | return new Set(types).size === types.length;
202 | }, "All limiters must have a unique 'type'");
203 |
204 | const requestCommonSchema = z.object({
205 | /**
206 | * If provided, will check if `invokeSecret` matches the secret stored in the environment variable `BORROW_LIMITER_INVOKE_SECRET`
207 | * before allowing the request to proceed. Only use this along with `BORROW_LIMITER_INVOKE_SECRET` when you want your servers to be able to call this function, such as when using this API
208 | * to rate limit your own servers.
209 | */
210 | invokeSecret: z.string().optional(),
211 | });
212 |
213 | export type RequestCheckSchema = z.infer;
214 | const requestCheckSchema = requestCommonSchema.extend({
215 | ...identifierSchema.shape,
216 | action: z.literal("check"),
217 | limiters: limitersSchema,
218 | });
219 | export type RequestRefillTokensSchema = z.infer<
220 | typeof requestRefillTokensSchema
221 | >;
222 | const requestRefillTokensSchema = requestCommonSchema.extend({
223 | action: z.literal("refillTokens"),
224 | keys: z
225 | .array(
226 | z.object({
227 | userId: z.string().nullable(),
228 | key: z.string().nullable(),
229 | })
230 | )
231 | .min(1)
232 | .max(100)
233 | .nullable(),
234 | });
235 |
236 | const requestSchema = z.discriminatedUnion("action", [
237 | requestCheckSchema,
238 | requestRefillTokensSchema,
239 | ]);
240 |
241 | export type Adapters = z.infer;
242 | const adaptersSchema = z.object({
243 | /**
244 | * The storage adapter to use for keeping track of requests to the rate limiter.
245 | * We highly recommend using Redis or other atomic and high-throughput database for production environments, as the amount of requests
246 | * Limiter can handle is highly limited by the database performance.
247 | */
248 | storage: z.instanceof(StorageAdapter),
249 | });
250 |
251 | const retrievalAdapterParamsSchema = z.object({
252 | key: z.string().nullable(),
253 | userId: z.string().nullable(),
254 | limiterType: limiterTypeSchema,
255 | interval: z.number().optional(),
256 | adapters: adaptersSchema,
257 | });
258 |
259 | const inputLimiterParamsSchema = z.object({
260 | req: requestSchema,
261 | /**
262 | * By default, Borrow updates the request counter in the background to avoid high latency, this is the function to use to perform operations in the background.
263 | * When using serverless functions, this is usually a variation of `waitUntil`, but may be called something else. For example, in Supabase Edge Functions, this is `EdgeRuntime.waitUntil`.
264 | * If you want to update the request counter synchronously, you can provide this parameter with `false`.
265 | * @default false
266 | */
267 | backgroundExecute: z
268 | .union([
269 | z.function().args(z.promise(z.any())).returns(z.void()),
270 | z.literal(false),
271 | ])
272 | .optional(),
273 | adapters: adaptersSchema,
274 | /**
275 | * Environment variables. Use this when deploying to a serverless environment such as Cloudflare Workers.
276 | */
277 | env: z.record(z.string(), z.any()).default({}).optional(),
278 | /**
279 | * Optional hooks to get notified when certain actions happen.
280 | */
281 | hooks: z
282 | .object({
283 | /**
284 | * Execute a function before the response is sent. This gets executed in the background, unless `backgroundExecute` is set to `false`.
285 | */
286 | beforeResponse: z
287 | .function(
288 | z.tuple([
289 | z.object({
290 | result: z.enum(["success", "error", "limited"]),
291 | error: z.enum(["INVALID_PARAMS", "UNAUTHORIZED"]).optional(),
292 | status: positiveIntSchema,
293 | message: z.string(),
294 | timeLeft: positiveIntSchema.nullable().optional().default(null),
295 | tokensLeft: positiveIntSchema.nullable().optional().default(null),
296 | }),
297 | ]),
298 | z.promise(z.void())
299 | )
300 | .optional(),
301 | })
302 | .optional(),
303 | });
304 |
305 | const outputLimiterParamsSchema = inputLimiterParamsSchema.transform((data) => {
306 | // Transform intervals to seconds
307 | return {
308 | ...data,
309 | req: {
310 | ...(data.req.action === "check"
311 | ? {
312 | // For some reason we need to repeat this so TypeScript can infer the discriminated union type correctly
313 | ...data.req,
314 | key: data.req.key?.trim?.() || null,
315 | limiters: data.req.limiters.map((l) =>
316 | l.type === "borrow"
317 | ? l
318 | : {
319 | ...l,
320 | interval:
321 | typeof l.interval === "string"
322 | ? l.interval === "minute"
323 | ? 60
324 | : l.interval === "hour"
325 | ? 60 * 60
326 | : l.interval === "day"
327 | ? 60 * 60 * 24
328 | : l.interval
329 | : l.interval,
330 | }
331 | ),
332 | }
333 | : {
334 | // For some reason we need to repeat this so TypeScript can infer the discriminated union type correctly
335 | ...data.req,
336 | keys: data.req.keys
337 | ? data.req.keys.map((k) => ({
338 | userId: k.userId?.trim?.() || null,
339 | key: k.key?.trim?.() || null,
340 | }))
341 | : null,
342 | }),
343 | },
344 | hooks: {
345 | beforeResponse: data.hooks?.beforeResponse || asyncNoop,
346 | },
347 | backgroundExecute: data.backgroundExecute || (false as const),
348 | };
349 | });
350 |
351 | async function refillTokens(params: {
352 | backgroundExecute: ParsedLimiterParams["backgroundExecute"];
353 | keys: { userId: string | null; key: string | null }[] | null;
354 | adapters: z.infer;
355 | storage: Storage; // Optional for testing purposes
356 | }) {
357 | const finalKeys: {
358 | userId: string | null;
359 | key: string | null;
360 | }[] = [];
361 |
362 | // Refill global key
363 | if (params.keys === null) {
364 | finalKeys.push({
365 | userId: null,
366 | key: null,
367 | });
368 | } else {
369 | params.keys.forEach((key) =>
370 | finalKeys.push({
371 | userId: key.userId,
372 | key: key.key,
373 | })
374 | );
375 | }
376 |
377 | await Promise.all(
378 | finalKeys.map((key) =>
379 | params.storage.storeUserData({
380 | key: key.key,
381 | userId: key.userId,
382 | limiterType: "token",
383 | amount: 0,
384 | type: "exact",
385 | adapters: params.adapters,
386 | })
387 | )
388 | );
389 | }
390 |
391 | function getIsomorphicEnvVariable(
392 | variableName: string,
393 | env: any
394 | ): string | undefined {
395 | if (env) {
396 | return env[variableName];
397 | }
398 |
399 | // @ts-expect-error We're checking for Deno env
400 | if (typeof Deno !== "undefined" && Deno.env?.get) {
401 | // @ts-expect-error We're checking for Deno env
402 | return Deno.env.get(variableName);
403 | } else if (typeof process !== "undefined" && process.env) {
404 | return process.env[variableName];
405 | }
406 | return undefined;
407 | }
408 |
409 | export async function limiter(
410 | params: LimiterParams
411 | ): Promise {
412 | const {
413 | success,
414 | data: parsedParams,
415 | error,
416 | } = outputLimiterParamsSchema.safeParse(params);
417 |
418 | if (!success) {
419 | const commonParams = {
420 | result: "error",
421 | status: 400,
422 | error: "INVALID_PARAMS",
423 | message: error.message,
424 | timeLeft: null,
425 | } as const;
426 |
427 | return commonParams;
428 | }
429 |
430 | if (
431 | typeof getIsomorphicEnvVariable(
432 | "BORROW_LIMITER_INVOKE_SECRET",
433 | parsedParams.env
434 | ) === "string" &&
435 | parsedParams.req.invokeSecret !==
436 | getIsomorphicEnvVariable("BORROW_LIMITER_INVOKE_SECRET", parsedParams.env)
437 | ) {
438 | const commonParams = {
439 | result: "error",
440 | status: 401,
441 | error: "UNAUTHORIZED",
442 | message: "Invalid invoke secret.",
443 | timeLeft: null,
444 | } as const;
445 |
446 | const beforeResponse = parsedParams.hooks.beforeResponse;
447 | await isomorphicExecute(
448 | beforeResponse(commonParams),
449 | parsedParams.backgroundExecute
450 | );
451 | return commonParams;
452 | }
453 |
454 | if (parsedParams.req.action === "refillTokens") {
455 | await refillTokens({
456 | backgroundExecute: parsedParams.backgroundExecute,
457 | keys: parsedParams.req.keys,
458 | adapters: parsedParams.adapters,
459 | storage,
460 | });
461 |
462 | const commonParams = {
463 | result: "success",
464 | status: 200,
465 | message: "Tokens refilled successfully.",
466 | timeLeft: null,
467 | } as const;
468 |
469 | const beforeResponse = parsedParams.hooks.beforeResponse;
470 | await isomorphicExecute(
471 | beforeResponse(commonParams),
472 | parsedParams.backgroundExecute
473 | );
474 |
475 | return commonParams;
476 | }
477 |
478 | const result: (
479 | | {
480 | success: boolean;
481 | }
482 | | {
483 | success: boolean;
484 | timeLeft: number | null;
485 | }
486 | | {
487 | success: boolean;
488 | timeLeft: number | null;
489 | tokensLeft: number | null;
490 | }
491 | | null
492 | )[] = await Promise.all(
493 | parsedParams.req.limiters.map(async (limiter) => {
494 | // For some reason TypeScript can't currently infer the type of `action` from previous code, so we need this.
495 | if (parsedParams.req.action !== "check") {
496 | throw new Error(
497 | `Invalid action: ${parsedParams.req.action}. Only 'check' action is supported.`
498 | );
499 | }
500 |
501 | const userData = await storage.getUserData({
502 | key: parsedParams.req.key,
503 | ...(limiter.type === "fixed" ? { interval: limiter.interval } : {}),
504 | userId: parsedParams.req?.userId || null,
505 | limiterType: limiter.type,
506 | adapters: parsedParams.adapters,
507 | });
508 |
509 | if (!userData) {
510 | throw new Error(
511 | `User data not found for key: ${parsedParams.req.key}, userId: ${parsedParams.req.userId}, limiterType: ${limiter.type}.`
512 | );
513 | }
514 |
515 | // Update algorithm functions to use adapters
516 | const result =
517 | limiter.type === "fixed"
518 | ? fixed({
519 | backgroundExecute: parsedParams.backgroundExecute,
520 | limiter,
521 | userData,
522 | userId: parsedParams.req.userId,
523 | key: parsedParams.req.key,
524 | adapters: parsedParams.adapters,
525 | storage,
526 | })
527 | : limiter.type === "sliding"
528 | ? sliding({
529 | backgroundExecute: parsedParams.backgroundExecute,
530 | limiter,
531 | userData,
532 | userId: parsedParams.req.userId,
533 | key: parsedParams.req.key,
534 | adapters: parsedParams.adapters,
535 | storage,
536 | })
537 | : limiter.type === "token"
538 | ? token({
539 | backgroundExecute: parsedParams.backgroundExecute,
540 | limiter,
541 | userData,
542 | userId: parsedParams.req.userId,
543 | key: parsedParams.req.key,
544 | adapters: parsedParams.adapters,
545 | storage,
546 | })
547 | : limiter.type === "borrow"
548 | ? borrow({
549 | backgroundExecute: parsedParams.backgroundExecute,
550 | limiter,
551 | userData,
552 | userId: parsedParams.req.userId,
553 | key: parsedParams.req.key,
554 | adapters: parsedParams.adapters,
555 | storage,
556 | })
557 | : null;
558 |
559 | return result;
560 | })
561 | );
562 |
563 | // Currently only gets the greatest time left
564 | const timeLeftArray = result.flatMap((r) =>
565 | r && "timeLeft" in r && typeof r.timeLeft === "number" ? r.timeLeft : []
566 | );
567 | const timeLeft =
568 | timeLeftArray.length > 0
569 | ? parseInt(getBiggest(timeLeftArray).toFixed(2))
570 | : null;
571 | // Currently only gets the greatest tokens left
572 | const tokensLeftArray = result.flatMap((r) =>
573 | r && "tokensLeft" in r && typeof r.tokensLeft === "number"
574 | ? r.tokensLeft
575 | : []
576 | );
577 | const tokensLeft =
578 | tokensLeftArray.length > 0
579 | ? parseInt(getBiggest(tokensLeftArray).toFixed(2))
580 | : null;
581 | const passedLimiters = result.filter((r) => r && r.success).length;
582 |
583 | if (passedLimiters < parsedParams.req.limiters.length) {
584 | const failedLimiters = parsedParams.req.limiters.length - passedLimiters;
585 | const message = `${failedLimiters} Limiter${
586 | failedLimiters === 1 ? "" : "s"
587 | } did not pass.`;
588 | const commonParams = {
589 | result: "limited",
590 | message,
591 | timeLeft,
592 | status: 200,
593 | ...(typeof tokensLeft === "number" ? { tokensLeft } : {}),
594 | } as const;
595 |
596 | const beforeResponse = parsedParams.hooks.beforeResponse;
597 | await isomorphicExecute(
598 | beforeResponse(commonParams),
599 | parsedParams.backgroundExecute
600 | );
601 | return commonParams;
602 | }
603 |
604 | const message = `Every limiter passed (${parsedParams.req.limiters.length}).`;
605 | const commonParams = {
606 | result: "success",
607 | status: 200,
608 | message,
609 | timeLeft: null,
610 | tokensLeft,
611 | } as const;
612 |
613 | const beforeResponse = parsedParams.hooks.beforeResponse;
614 | await isomorphicExecute(
615 | beforeResponse(commonParams),
616 | parsedParams.backgroundExecute
617 | );
618 | return commonParams;
619 | }
620 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/limiter.ts:
--------------------------------------------------------------------------------
1 | import borrow, { BorrowClient } from "@lib/client.js";
2 | import {
3 | Limiters,
4 | LimiterResult,
5 | Params,
6 | LimiterParams,
7 | AnyLimiter,
8 | } from "@lib/limiter/types.js";
9 | import { getSupabaseRequestInfo } from "../utils.js";
10 | import {
11 | handleErrorResponse,
12 | isTokenLimiterArray,
13 | // @ts-expect-error It's being used by JSDoc
14 | LimiterError,
15 | } from "@lib/limiter/utils.js";
16 |
17 | const failBehaviorString = {
18 | bypass: "Bypassing this error since failBehavior is set to 'bypass'.",
19 | };
20 |
21 | // Helper function to check if an object is a params object
22 | function isParamsObject(obj: any): boolean {
23 | // Check if it's a Request object first
24 | if (obj instanceof Request) return false;
25 |
26 | // Check if it has typical Params object properties
27 | return (
28 | typeof obj === "object" &&
29 | obj !== null &&
30 | ("limiters" in obj || "options" in obj)
31 | );
32 | }
33 |
34 | /**
35 | * @typedef {Object} Params
36 | * This type represents the parameters object for the limiter function,
37 | * including:
38 | * - id: The unique identifier for the rate limiter.
39 | * - userIdentifier: Either a unique user identifier (string) or a Supabase Request object.
40 | * - limiters: A limiter object (or array of them) where each includes a 'type' field and type-specific properties:
41 | * - For 'fixed' and 'sliding' limiters:
42 | * - maxRequests: The maximum number of requests allowed
43 | * - interval: Either one of "minute", "hour", "day", or a number (treated as seconds)
44 | * - For 'token' limiters:
45 | * - maxTokens: The maximum number of tokens allowed
46 | * - tokensCost: The cost of the request in tokens
47 | * - tokensPerReplenish: The number of tokens to replenish
48 | * - interval: Either one of "minute", "hour", "day", or a number (treated as seconds)
49 | * - For 'borrow' limiters:
50 | * - borrowAction: The action to take when borrowing
51 | * - timeout: The timeout duration
52 | * - options: Optional common limiter options (apiKey, failBehavior, debug).
53 | */
54 |
55 | /**
56 | * Checks the global rate limit.
57 | *
58 | * @param {Params} params - The parameters object containing all limiter configuration.
59 | * @throws {LimiterError} - If the request fails and failBehavior is set to "fail".
60 | * @returns {LimiterResultPromise}
61 | */
62 | export function limiter(
63 | params: Params
64 | ): Promise>;
65 |
66 | /**
67 | * Checks the rate limit with an ID.
68 | *
69 | * @param {string} id - The unique identifier used to scope the limiter.
70 | * @param {Params} params - The parameters object containing all limiter configuration.
71 | * @throws {LimiterError} - If the request fails and failBehavior is set to "fail".
72 | * @returns {LimiterResultPromise}
73 | */
74 | export function limiter(
75 | id: string,
76 | params: Params
77 | ): Promise>;
78 |
79 | /**
80 | * Checks the rate limit with a userIdentifier.
81 | *
82 | * @param {string|Request} userIdentifier - A unique user identifier (e.g.: user ID, email).
83 | * @param {Params} params - The parameters object containing all limiter configuration.
84 | * @throws {LimiterError} - If the request fails and failBehavior is set to "fail".
85 | * @returns {LimiterResultPromise}
86 | */
87 | export function limiter(
88 | userIdentifier: string,
89 | params: Params
90 | ): Promise>;
91 |
92 | /**
93 | * Checks the rate limit linked to this Supabase edge function endpoint and user identifier who made the request.
94 | *
95 | * @param {string|Request} request - A Supabase Request object.
96 | * @param {Params} params - The parameters object containing all limiter configuration.
97 | * @throws {LimiterError} - If the request fails and failBehavior is set to "fail".
98 | * @returns {LimiterResultPromise}
99 | */
100 | export function limiter(
101 | supabaseRequest: Request,
102 | params: Params
103 | ): Promise>;
104 |
105 | /**
106 | * Checks the rate limit with an ID and userIdentifier.
107 | *
108 | * @param {string} id - The unique identifier used to scope the limiter.
109 | * @param {string|Request} userIdentifier - A unique user identifier (e.g., user ID, email) or a Supabase Request object.
110 | * @param {Params} params - The parameters object containing all limiter configuration.
111 | * @throws {LimiterError} - If the request fails and failBehavior is set to "fail".
112 | * @returns {LimiterResultPromise}
113 | */
114 | export function limiter(
115 | id: string,
116 | userIdentifier: string | Request,
117 | params: Params
118 | ): Promise>;
119 |
120 | export async function limiter(
121 | arg0: string | Request | Params,
122 | arg1?: string | Request | Params,
123 | arg2?: Params
124 | ): Promise> {
125 | // Initialize the params object that we'll build based on the arguments
126 | let finalParams: any = {};
127 |
128 | // Case 1: limiter(params)
129 | // Single params object
130 | if (typeof arg0 === "object" && isParamsObject(arg0)) {
131 | finalParams = arg0;
132 | }
133 | // Case 2: limiter(id, params)
134 | else if (
135 | typeof arg0 === "string" &&
136 | typeof arg1 === "object" &&
137 | isParamsObject(arg1)
138 | ) {
139 | finalParams = { ...arg1, id: arg0 };
140 | }
141 | // Case 3: limiter(userIdentifier, params)
142 | else if (
143 | (typeof arg0 === "string" || arg0 instanceof Request) &&
144 | typeof arg1 === "object" &&
145 | isParamsObject(arg1)
146 | ) {
147 | finalParams = { ...arg1, userIdentifier: arg0 };
148 | }
149 | // Case 4: limiter(id, userIdentifier, params)
150 | else if (
151 | typeof arg0 === "string" &&
152 | (typeof arg1 === "string" || arg1 instanceof Request) &&
153 | typeof arg2 === "object"
154 | ) {
155 | finalParams = { ...arg2, id: arg0, userIdentifier: arg1 };
156 | } else {
157 | throw new Error("Invalid arguments provided to limiter function");
158 | }
159 |
160 | const params: LimiterParams = finalParams;
161 |
162 | // Resolve the final user identifier and possibly extract URL for id
163 | let finalUserIdentifier: string | null = null;
164 | let urlAsId: string | undefined;
165 |
166 | if (params.userIdentifier instanceof Request) {
167 | const requestInfo = await getSupabaseRequestInfo(
168 | params.userIdentifier,
169 | params.options?.debug
170 | );
171 |
172 | // If we got a user ID from the request, use that as the user identifier
173 | if (requestInfo.userId) {
174 | finalUserIdentifier = requestInfo.userId;
175 | } else {
176 | // If no user ID is found and we're in debug mode, log a warning
177 | if (params.options?.debug) {
178 | console.warn("No user identifier found in Supabase Request object.");
179 | }
180 | }
181 |
182 | // If we got a URL from the request and no explicit ID was provided, use the URL as the ID
183 | if (requestInfo.url && !params.id) {
184 | urlAsId = requestInfo.url;
185 | if (params.options?.debug) {
186 | console.info(`Using request URL as limiter ID: ${urlAsId}`);
187 | }
188 | }
189 | }
190 |
191 | // Use request URL as ID if no explicit ID was provided and URL is available
192 | const limiterKey = params.id || urlAsId;
193 |
194 | // If we don't have a user identifier or a limiter ID, we can't proceed
195 | if (!finalUserIdentifier && !limiterKey) {
196 | if (params.options?.debug) {
197 | console.warn("No user identifier or limiter ID available.");
198 | }
199 | const bypassErrors = params.options?.failBehavior !== "fail";
200 |
201 | return handleErrorResponse(
202 | {
203 | message:
204 | "No user identifier or limiter ID found. " +
205 | failBehaviorString.bypass,
206 | code: "MISSING_PARAMETERS",
207 | },
208 | params.limiters as T,
209 | bypassErrors
210 | );
211 | }
212 |
213 | // Choose the correct Borrow client.
214 | const borrowClient =
215 | params.options?.apiKey || params.options?.endpoint
216 | ? new BorrowClient(
217 | params.options.apiKey,
218 | params.options.endpoint?.baseUrl
219 | )
220 | : borrow;
221 |
222 | try {
223 | // Format limiters according to API spec with 'type' field
224 | // Convert the Limiters type to an array for mapping
225 | const limitersArray = Array.isArray(params.limiters)
226 | ? params.limiters
227 | : [params.limiters];
228 |
229 | // Use type assertion to tell TypeScript this is an array of AnyLimiter
230 | const formattedLimiters = (limitersArray as AnyLimiter[]).map((limiter) => {
231 | const type = limiter.type;
232 |
233 | // Base limiter with type
234 | const formattedLimiter: Record = { type };
235 |
236 | switch (type) {
237 | case "fixed":
238 | case "sliding":
239 | // Fixed and sliding limiters have the same structure
240 | return {
241 | ...formattedLimiter,
242 | maxRequests: limiter.maxRequests,
243 | interval: limiter.interval,
244 | };
245 | case "token":
246 | return {
247 | ...formattedLimiter,
248 | maxTokens: limiter.maxTokens,
249 | tokensCost: limiter.tokensCost,
250 | tokensPerReplenish: limiter.tokensPerReplenish,
251 | interval: limiter.interval,
252 | };
253 | case "borrow":
254 | return {
255 | ...formattedLimiter,
256 | borrowAction: limiter.borrowAction,
257 | timeout: limiter.timeout,
258 | };
259 | default:
260 | // Unknown limiter type, pass through as is
261 | return limiter;
262 | }
263 | });
264 |
265 | const response = await borrowClient.post(
266 | params.options?.endpoint?.path || "/limiter",
267 | {
268 | body: JSON.stringify({
269 | key: limiterKey,
270 | userId: finalUserIdentifier,
271 | limiters: formattedLimiters,
272 | action: "check",
273 | }),
274 | }
275 | );
276 |
277 | const data = (await response.json()) as {
278 | result: "success" | "limited" | "error";
279 | message: string;
280 | timeLeft?: number | null;
281 | tokensLeft?: number | null;
282 | };
283 |
284 | if (data.result === "limited") {
285 | if (params.options?.debug) {
286 | console.warn(
287 | `Rate limit exceeded for id: ${limiterKey} with userIdentifier: ${finalUserIdentifier}. Message: ${data.message}`
288 | );
289 | }
290 |
291 | // Use Array.isArray to check if params.limiters is an array before passing to isTokenLimiterArray
292 | if (
293 | Array.isArray(params.limiters) &&
294 | isTokenLimiterArray(params.limiters)
295 | ) {
296 | // For limiters with token type, include tokensLeft
297 | return {
298 | success: false,
299 | timeLeft: data.timeLeft as number,
300 | message: data.message,
301 | tokensLeft: data.tokensLeft as number,
302 | } as LimiterResult;
303 | } else {
304 | // For limiters without token type, do NOT include tokensLeft
305 | return {
306 | success: false,
307 | timeLeft: data.timeLeft as number,
308 | message: data.message,
309 | } as LimiterResult;
310 | }
311 | }
312 |
313 | if (!response.ok || data.result === "error") {
314 | const errorMessage = `Limiter returned an error for id: ${
315 | limiterKey || "[not provided]"
316 | } with userIdentifier: ${
317 | finalUserIdentifier || "[not provided]"
318 | }. Message: ${data.message}`;
319 | const bypassErrors = params.options?.failBehavior !== "fail";
320 |
321 | if (params.options?.debug) {
322 | console.warn(errorMessage);
323 | }
324 |
325 | return handleErrorResponse(
326 | {
327 | message: errorMessage,
328 | code: "LIMITER_ERROR",
329 | },
330 | params.limiters as T,
331 | bypassErrors
332 | );
333 | }
334 |
335 | if (params.options?.debug) {
336 | console.info(
337 | `Limiter passed for id: ${limiterKey} with userIdentifier: ${finalUserIdentifier}. Message: ${data.message}`
338 | );
339 | }
340 |
341 | // Use Array.isArray to check if params.limiters is an array before passing to isTokenLimiterArray
342 | if (
343 | Array.isArray(params.limiters) &&
344 | isTokenLimiterArray(params.limiters)
345 | ) {
346 | return {
347 | success: true,
348 | timeLeft: null,
349 | message: data.message,
350 | tokensLeft: data.tokensLeft as number,
351 | } as LimiterResult;
352 | } else {
353 | return {
354 | success: true,
355 | timeLeft: null,
356 | message: data.message,
357 | } as LimiterResult;
358 | }
359 | } catch (err: any) {
360 | if (params.options?.debug) {
361 | console.error(
362 | `Error calling Borrow API for id: ${limiterKey} with userIdentifier: ${finalUserIdentifier}. Error: `,
363 | err
364 | );
365 | }
366 |
367 | const bypassErrors = params.options?.failBehavior !== "fail";
368 | // Use Array.isArray to check if params.limiters is an array
369 | return handleErrorResponse(
370 | err,
371 | Array.isArray(params.limiters)
372 | ? params.limiters
373 | : ([params.limiters] as unknown as T),
374 | bypassErrors
375 | );
376 | }
377 | }
378 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/tokens.ts:
--------------------------------------------------------------------------------
1 | import borrow, { BorrowClient } from "../client.js";
2 | import { CommonLimiterOptions } from "./types.js";
3 |
4 | /**
5 | * Key interface for refill tokens
6 | */
7 | export interface Key {
8 | /**
9 | * The unique identifier used to scope the limiter.
10 | */
11 | key?: string;
12 |
13 | /**
14 | * A unique user identifier (e.g., user ID or email).
15 | */
16 | userId?: string;
17 | }
18 |
19 | // Function to validate a key
20 | function validateKey(key: Key): boolean {
21 | if (!key.key && !key.userId) {
22 | throw new Error("At least one of 'id' or 'userId' must be provided");
23 | }
24 | return true;
25 | }
26 |
27 | // Options for refillTokens
28 | export type RefillTokensOptions = CommonLimiterOptions;
29 |
30 | // Response type for refill tokens operation
31 | type RefillTokensResponse = {
32 | success: boolean;
33 | message: string;
34 | };
35 |
36 | /**
37 | * Refills tokens for a global limiter.
38 | *
39 | * @param {boolean} isGlobal - If true, refills tokens for the global token limiter.
40 | * @param {RefillTokensOptions} [options] - Optional settings.
41 | * @returns {Promise} - The result of the refill operation.
42 | */
43 | export function refillTokens(
44 | isGlobal: boolean,
45 | options?: RefillTokensOptions
46 | ): Promise;
47 |
48 | /**
49 | * Refills tokens for a specific key.
50 | *
51 | * @param {Key} key - Object containing either id or userId, or both.
52 | * @param {RefillTokensOptions} [options] - Optional settings.
53 | * @returns {Promise} - The result of the refill operation.
54 | */
55 | export function refillTokens(
56 | key: Key,
57 | options?: RefillTokensOptions
58 | ): Promise;
59 |
60 | /**
61 | * Refills tokens for multiple keys.
62 | *
63 | * @param {Key[]} keys - Array of objects containing either id or userId, or both.
64 | * @param {RefillTokensOptions} [options] - Optional settings.
65 | * @returns {Promise} - The result of the refill operation.
66 | */
67 | export function refillTokens(
68 | keys: Key[],
69 | options?: RefillTokensOptions
70 | ): Promise;
71 |
72 | export async function refillTokens(
73 | arg0: boolean | Key | Key[],
74 | arg1?: RefillTokensOptions
75 | ): Promise {
76 | // Parse options
77 | const options = arg1 || {};
78 |
79 | // Choose the correct Borrow client
80 | const borrowClient = options?.apiKey
81 | ? new BorrowClient(options.apiKey)
82 | : borrow;
83 |
84 | try {
85 | // Prepare request body based on input
86 | let requestBody: any = {
87 | action: "refillTokens",
88 | };
89 |
90 | // Handle different argument patterns
91 | if (typeof arg0 === "boolean") {
92 | // Global refill case, no keys needed
93 | requestBody.keys = null;
94 | } else if (Array.isArray(arg0)) {
95 | // Array of keys
96 | const validatedKeys = arg0.map((key) => {
97 | validateKey(key);
98 | return key;
99 | });
100 | requestBody.keys = validatedKeys;
101 | } else {
102 | // Single key object
103 | validateKey(arg0);
104 | requestBody.keys = [arg0];
105 | }
106 |
107 | if (options?.debug) {
108 | console.info(`Refilling tokens with params:`, requestBody);
109 | }
110 |
111 | // Make the API call
112 | const response = await borrowClient.post("/limiter", {
113 | body: JSON.stringify(requestBody),
114 | });
115 |
116 | const data = (await response.json()) as any;
117 |
118 | if (!response.ok) {
119 | const errorMessage = `Failed to refill tokens: ${
120 | data.message || response.statusText
121 | }`;
122 |
123 | if (options?.debug) {
124 | console.warn(errorMessage);
125 | }
126 |
127 | const bypassErrors = options?.failBehavior !== "fail";
128 |
129 | if (!bypassErrors) {
130 | throw new Error(errorMessage);
131 | }
132 |
133 | return {
134 | success: false,
135 | message: errorMessage,
136 | };
137 | }
138 |
139 | if (options?.debug) {
140 | console.info(`Successfully refilled tokens: ${data.message}`);
141 | }
142 |
143 | return {
144 | success: true,
145 | message: data.message || "Tokens refilled successfully",
146 | };
147 | } catch (err: any) {
148 | if (options?.debug) {
149 | console.error(`Error refilling tokens:`, err);
150 | }
151 |
152 | const bypassErrors = options?.failBehavior !== "fail";
153 |
154 | if (!bypassErrors) {
155 | throw err;
156 | }
157 |
158 | return {
159 | success: false,
160 | message: err.message || "Failed to refill tokens",
161 | };
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/types.ts:
--------------------------------------------------------------------------------
1 | import type { LiteralUnion, TaggedUnion, SimplifyDeep } from "type-fest";
2 |
3 | /**
4 | * The interval in which requests are counted. If a number, treated as seconds.
5 | */
6 | export type Interval = LiteralUnion<"minute" | "hour" | "day", number>;
7 |
8 | /**
9 | * Configuration schema for a fixed-window limiter.
10 | */
11 | export interface FixedLimiter extends Record {
12 | /**
13 | * The type of limiter.
14 | */
15 | type: "fixed";
16 |
17 | /**
18 | * The maximum number of requests allowed within the clock interval.
19 | */
20 | maxRequests: number;
21 |
22 | /**
23 | * The interval in which requests are counted. If a number, treated as seconds.
24 | */
25 | interval: Interval;
26 | }
27 |
28 | /**
29 | * Configuration schema for a sliding-window limiter.
30 | */
31 | export interface SlidingLimiter extends Record {
32 | /**
33 | * The type of limiter.
34 | */
35 | type: "sliding";
36 |
37 | /**
38 | * The maximum number of requests allowed within the interval since the first request.
39 | */
40 | maxRequests: number;
41 |
42 | /**
43 | * The interval in which requests are counted. If a number, treated as seconds.
44 | */
45 | interval: Interval;
46 | }
47 |
48 | /**
49 | * Configuration schema for a token-bucket limiter.
50 | */
51 | export interface TokenLimiter extends Record {
52 | /**
53 | * The type of limiter.
54 | */
55 | type: "token";
56 |
57 | /**
58 | * The maximum number of tokens in the bucket.
59 | */
60 | maxTokens: number;
61 |
62 | /**
63 | * The number of tokens to add to the bucket per interval.
64 | */
65 | tokensPerReplenish: number;
66 |
67 | /**
68 | * The amount of tokens this request will consume if available.
69 | */
70 | tokensCost: number;
71 |
72 | /**
73 | * The interval in which tokens are added to the bucket. If a number, treated as seconds.
74 | */
75 | interval: Interval;
76 | }
77 |
78 | /**
79 | * Configuration schema for a borrow limiter.
80 | */
81 | export interface BorrowLimiter extends Record {
82 | /**
83 | * The type of limiter.
84 | */
85 | type: "borrow";
86 |
87 | /**
88 | * Whether this is the start or end of the borrow.
89 | */
90 | borrowAction: "start" | "end";
91 |
92 | /**
93 | * The timeout in seconds until the borrow expires. This is necessary to prevent a borrow from being open indefinitely.
94 | */
95 | timeout: number;
96 | }
97 |
98 | /**
99 | * Union type that accepts any type of limiter.
100 | */
101 | export type AnyLimiter = TaggedUnion<
102 | "type",
103 | {
104 | fixed: FixedLimiter;
105 | sliding: SlidingLimiter;
106 | token: TokenLimiter;
107 | borrow: BorrowLimiter;
108 | }
109 | >;
110 |
111 | /**
112 | * An array of up to 4 limiter objects of unique types.
113 | * (TS can enforce minimum length via tuple; maximum length remains in documentation.)
114 | */
115 | export type Limiters = [AnyLimiter, ...AnyLimiter[]];
116 |
117 | /**
118 | * Common options used by limiter functions.
119 | */
120 | export interface CommonLimiterOptions {
121 | /**
122 | * Your Borrow API key for the current project.
123 | * @default process.env.BORROW_API_KEY
124 | */
125 | apiKey?: string;
126 |
127 | /**
128 | * The endpoint to use for the Borrow API.
129 | * Use this option when self-hosting.
130 | */
131 | endpoint?: {
132 | /**
133 | * The base URL of the Borrow API.
134 | * @example "https://api.borrow.dev/v1"
135 | */
136 | baseUrl: string;
137 | /**
138 | * The path to the specific API endpoint.
139 | * @example "/limiter"
140 | */
141 | path?: string;
142 | };
143 |
144 | /**
145 | * Determines what happens when the API call fails (e.g: network failure, quota reached, incorrect parameters, etc): "fail" treats it as a failed check, "bypass" treats it as a successful check.
146 | * @default "bypass"
147 | */
148 | failBehavior?: "fail" | "bypass";
149 |
150 | /**
151 | * Whether to log debug information.
152 | * @default process.env.NODE_ENV === "development"
153 | */
154 | debug?: boolean;
155 | }
156 |
157 | /**
158 | * Parameters accepted by the `limiter` function.
159 | *
160 | * Note: Because of overloads, the user identifier may be provided as either a string
161 | * (explicit identifier), a Supabase Request object (to auto-extract the user), or omitted.
162 | * In the two-parameter overload, the limiter object is passed as the second argument.
163 | */
164 | export type LimiterParams = {
165 | /**
166 | * The unique identifier used to scope the limiter (e.g., 'download_file').
167 | */
168 | id: string;
169 |
170 | /**
171 | * A unique user identifier (e.g., user ID or email) or a Supabase Request object to extract the user ID from.
172 | */
173 | userIdentifier?: string | Request;
174 |
175 | /**
176 | * An array of up to 4 limiter objects of unique types.
177 | */
178 | limiters: T;
179 |
180 | /**
181 | * Optional settings for the limiter, including API key, failure behavior, and debug options.
182 | */
183 | options?: CommonLimiterOptions;
184 | };
185 |
186 | /** Common error codes */
187 | export type ErrorCode =
188 | | "UNAUTHORIZED"
189 | | "QUOTA_REACHED"
190 | | "INVALID_PARAMETERS"
191 | | "MISSING_PARAMETERS";
192 |
193 | interface BaseLimiterResult {
194 | success: boolean;
195 | message: string;
196 | }
197 |
198 | interface SuccessLimiterResult extends BaseLimiterResult {
199 | success: true;
200 | timeLeft: null;
201 | }
202 |
203 | interface FailureLimiterResult extends BaseLimiterResult {
204 | success: false;
205 | timeLeft: number;
206 | }
207 |
208 | interface TokenLimiterResultFields {
209 | tokensLeft: number;
210 | }
211 |
212 | /**
213 | * Extracts whether the limiters array contains token limiters
214 | */
215 | export type ContainsTokenLimiter = Extract<
216 | T[number],
217 | { type: "token" }
218 | > extends never
219 | ? false
220 | : true;
221 |
222 | /**
223 | * Creates the appropriate result type based on success state and limiter types
224 | */
225 | export type LimiterResult = SimplifyDeep<
226 | R extends true
227 | ? SuccessLimiterResult &
228 | (ContainsTokenLimiter extends true ? TokenLimiterResultFields : {})
229 | : FailureLimiterResult &
230 | (ContainsTokenLimiter extends true ? TokenLimiterResultFields : {})
231 | >;
232 |
233 | export type Params = {
234 | /**
235 | * An array of up to 4 limiter objects of unique types.
236 | */
237 | limiters: T;
238 | options?: CommonLimiterOptions;
239 | };
240 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/limiter/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Limiters,
3 | LimiterResult,
4 | TokenLimiter,
5 | ErrorCode,
6 | AnyLimiter,
7 | } from "./types.js";
8 |
9 | /**
10 | * Custom error class for limiter
11 | * @property {string} message - Error message
12 | * @property {ErrorCode} code - Error code
13 | */
14 | export class LimiterError extends Error {
15 | code: string;
16 |
17 | constructor(message: string, code: ErrorCode) {
18 | super(message);
19 | this.name = "LimiterError";
20 | this.code = code;
21 | }
22 | }
23 |
24 | /**
25 | * Type guard to check if the limiters array contains at least one token limiter.
26 | */
27 | export function isTokenLimiterArray(
28 | limiters: T
29 | ): limiters is T & ReadonlyArray {
30 | return limiters.some((limiter) => limiter.type === "token");
31 | }
32 |
33 | /**
34 | * Handles error responses based on limiter types and bypass setting.
35 | * Throws errors when bypass is false, otherwise returns a success response.
36 | */
37 | export function handleErrorResponse(
38 | error: any,
39 | limiters: T,
40 | bypass = true
41 | ): LimiterResult {
42 | const errorCode = error?.error || error?.code || "UNKNOWN_ERROR";
43 | const errorMessage = error?.message || "An unknown error occurred";
44 |
45 | // Throw error if bypass is false
46 | if (!bypass) {
47 | throw new LimiterError(errorMessage, errorCode);
48 | }
49 |
50 | // Create a success response for error bypass case
51 | const baseResponse = {
52 | success: true,
53 | timeLeft: null,
54 | message: errorMessage || "Error bypassed",
55 | };
56 |
57 | // Add tokensLeft property if we have token limiters
58 | // Convert limiters to array and check if any are token limiters
59 | const limitersArray = Array.isArray(limiters) ? limiters : [limiters];
60 | if (isTokenLimiterArray(limitersArray as readonly AnyLimiter[])) {
61 | return {
62 | ...baseResponse,
63 | tokensLeft: 0, // Use 0 as default when an error occurs
64 | } as LimiterResult;
65 | }
66 |
67 | return baseResponse as LimiterResult;
68 | }
69 |
--------------------------------------------------------------------------------
/packages/javascript/node/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import type { User } from "@supabase/supabase-js";
2 | import process from "node:process";
3 |
4 | export function isBorrowEnv(): boolean {
5 | return process.env.BORROW_ENV === "borrow";
6 | }
7 |
8 | export function isSupabaseEdgeFunction(): boolean {
9 | const requiredEnvVars = [
10 | "SUPABASE_URL",
11 | "SUPABASE_ANON_KEY",
12 | "SUPABASE_SERVICE_ROLE_KEY",
13 | "SUPABASE_DB_URL",
14 | ];
15 |
16 | for (const envVar of requiredEnvVars) {
17 | if (!process.env[envVar]) {
18 | return false;
19 | }
20 | }
21 |
22 | return true;
23 | }
24 |
25 | // TODO: Find a way to import the Deno Request type (only for this function) without all the declaration types
26 | export async function getSupabaseUser(
27 | req: Request,
28 | debug?: boolean
29 | ): Promise {
30 | if (!isSupabaseEdgeFunction()) {
31 | return null;
32 | }
33 | const supabase = await import("@supabase/supabase-js").then((mod) =>
34 | mod.createClient(
35 | process.env.SUPABASE_URL!,
36 | process.env.SUPABASE_SERVICE_ROLE_KEY!
37 | )
38 | );
39 | const authHeader = req.headers.get("Authorization")!;
40 | const token = authHeader.replace("Bearer ", "");
41 | const { error, data } = await supabase.auth.getUser(token);
42 |
43 | if (error || !data?.user) {
44 | return null;
45 | }
46 |
47 | if (debug) {
48 | console.log("Found Supabase user with ID: ", data.user?.id);
49 | }
50 |
51 | return data.user || null;
52 | }
53 |
54 | /**
55 | * Extracts relevant information from a Supabase request object.
56 | * Returns both the user ID (if available) and the request URL (if available).
57 | *
58 | * @param {Request} req - The Supabase request object
59 | * @param {boolean} debug - Whether to log debug information
60 | * @returns {Promise<{userId: string | null, url: string | null}>}
61 | */
62 | export async function getSupabaseRequestInfo(
63 | req: Request,
64 | debug?: boolean
65 | ): Promise<{userId: string | null, url: string | null}> {
66 | const result = {
67 | userId: null as string | null,
68 | url: null as string | null
69 | };
70 |
71 | // Extract user ID if in a Supabase environment
72 | if (isSupabaseEdgeFunction()) {
73 | const supabase = await import("@supabase/supabase-js").then((mod) =>
74 | mod.createClient(
75 | process.env.SUPABASE_URL!,
76 | process.env.SUPABASE_SERVICE_ROLE_KEY!
77 | )
78 | );
79 | const authHeader = req.headers.get("Authorization")!;
80 | if (authHeader) {
81 | const token = authHeader.replace("Bearer ", "");
82 | const { error, data } = await supabase.auth.getUser(token);
83 |
84 | if (!error && data?.user?.id) {
85 | result.userId = data.user.id;
86 |
87 | if (debug) {
88 | console.log("Found Supabase user with ID: ", result.userId);
89 | }
90 | } else {
91 | if (debug) {
92 | console.warn("Failed to get Supabase user: ", error);
93 | }
94 | }
95 | }
96 |
97 | // Extract URL from request if available
98 | if (req.url) {
99 | try {
100 | const urlObj = new URL(req.url);
101 | result.url = `${urlObj.hostname}${urlObj.pathname}`;
102 |
103 | if (debug) {
104 | console.log("Extracted URL from request: ", result.url);
105 | }
106 | } catch (error) {
107 | if (debug) {
108 | console.warn("Failed to parse URL from request: ", req.url);
109 | }
110 | }
111 | }
112 | } else {
113 | throw new Error("You passed a Request object but we're not in a Supabase Edge Function environment");
114 | }
115 |
116 | return result;
117 | }
118 |
--------------------------------------------------------------------------------
/packages/javascript/node/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.base.json",
3 | "files": [],
4 | "include": [],
5 | "references": [
6 | {
7 | "path": "./tsconfig.lib.json"
8 | },
9 | {
10 | "path": "./tsconfig.spec.json"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/packages/javascript/node/tsconfig.lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@lib/*": ["./src/lib/*"]
7 | },
8 | "rootDir": "src",
9 | "outDir": "dist",
10 | "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
11 | "emitDeclarationOnly": false,
12 | "module": "nodenext",
13 | "moduleResolution": "nodenext",
14 | "forceConsistentCasingInFileNames": true,
15 | "types": ["node"]
16 | },
17 | "include": ["src/**/*.ts"],
18 | "references": [],
19 | "exclude": [
20 | "vite.config.ts",
21 | "vite.config.mts",
22 | "vitest.config.ts",
23 | "vitest.config.mts",
24 | "src/**/*.test.ts",
25 | "src/**/*.spec.ts",
26 | "src/**/*.test.tsx",
27 | "src/**/*.spec.tsx",
28 | "src/**/*.test.js",
29 | "src/**/*.spec.js",
30 | "src/**/*.test.jsx",
31 | "src/**/*.spec.jsx"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/packages/javascript/node/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "./out-tsc/vitest",
5 | "types": [
6 | "vitest/globals",
7 | "vitest/importMeta",
8 | "vite/client",
9 | "node",
10 | "vitest"
11 | ],
12 | "module": "nodenext",
13 | "moduleResolution": "nodenext",
14 | "forceConsistentCasingInFileNames": true
15 | },
16 | "include": [
17 | "vite.config.ts",
18 | "vite.config.mts",
19 | "vitest.config.ts",
20 | "vitest.config.mts",
21 | "src/**/*.test.ts",
22 | "src/**/*.spec.ts",
23 | "src/**/*.test.tsx",
24 | "src/**/*.spec.tsx",
25 | "src/**/*.test.js",
26 | "src/**/*.spec.js",
27 | "src/**/*.test.jsx",
28 | "src/**/*.spec.jsx",
29 | "src/**/*.d.ts",
30 | "src/globals.d.ts"
31 | ],
32 | "references": [
33 | {
34 | "path": "./tsconfig.lib.json"
35 | }
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/packages/javascript/node/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 |
3 | export default defineConfig(() => ({
4 | root: __dirname,
5 | cacheDir: "../../../node_modules/.vite/packages/javascript/node",
6 | plugins: [],
7 | // Uncomment this if you are using workers.
8 | // worker: {
9 | // plugins: [ nxViteTsPaths() ],
10 | // },
11 | test: {
12 | watch: false,
13 | globals: true,
14 | environment: "node",
15 | include: ["src/**/*.spec.ts"],
16 | reporters: ["default"],
17 | coverage: {
18 | reportsDirectory: "./test-output/vitest/coverage",
19 | provider: "v8" as const,
20 | },
21 | },
22 | }));
23 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "declarationMap": true,
5 | "emitDeclarationOnly": true,
6 | "importHelpers": true,
7 | "isolatedModules": true,
8 | "lib": ["es2022"],
9 | "module": "ES6",
10 | "moduleResolution": "bundler",
11 | "noEmitOnError": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "noImplicitOverride": true,
14 | "noImplicitReturns": true,
15 | "noUnusedLocals": true,
16 | "skipLibCheck": true,
17 | "strict": true,
18 | "target": "es2022",
19 | "customConditions": ["development"]
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base.json",
3 | "compileOnSave": false,
4 | "files": [],
5 | "references": [
6 | {
7 | "path": "./packages/javascript/node"
8 | },
9 | {
10 | "path": "./apps/docs"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://openapi.vercel.sh/vercel.json",
3 | "buildCommand": "npx nx build docs --prod && npx nx run docs:postbuild",
4 | "outputDirectory": "apps/docs/.next",
5 | "framework": "nextjs"
6 | }
7 |
--------------------------------------------------------------------------------
/vitest.workspace.ts:
--------------------------------------------------------------------------------
1 | export default [
2 | "**/vite.config.{mjs,js,ts,mts}",
3 | "**/vitest.config.{mjs,js,ts,mts}",
4 | ];
5 |
--------------------------------------------------------------------------------