├── .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 | Borrow Logo 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 | 2 | 3 | 4 | 5 | 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 | 2 | 3 | 4 | 5 | 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 =
MIT {new Date().getFullYear()} © Borrow
; 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 | NPM version 5 | JSR version 6 | License 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 | --------------------------------------------------------------------------------