├── .changeset ├── README.md └── config.json ├── .commitlintrc.json ├── .czrc ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── Bug_report.yml │ ├── Feature_request.yml │ ├── Regression.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .lintstagedrc ├── .npmrc ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── src ├── hash.ts ├── index.ts ├── throttler-module-options.interface.ts ├── throttler-storage-options.interface.ts ├── throttler-storage-record.interface.ts ├── throttler-storage.interface.ts ├── throttler.constants.ts ├── throttler.decorator.ts ├── throttler.exception.ts ├── throttler.guard.interface.ts ├── throttler.guard.spec.ts ├── throttler.guard.ts ├── throttler.module.ts ├── throttler.providers.ts ├── throttler.service.ts └── utilities.ts ├── test ├── app │ ├── app.module.ts │ ├── app.service.ts │ ├── controllers │ │ ├── app.controller.ts │ │ ├── controller.module.ts │ │ ├── default.controller.ts │ │ └── limit.controller.ts │ └── main.ts ├── controller.e2e-spec.ts ├── error-message │ ├── app.module.ts │ ├── custom-error-message.controller.ts │ └── custom-error-message.e2e-spec.ts ├── function-overrides │ ├── app.module.ts │ ├── function-overrides-throttler.controller.ts │ └── function-overrides-throttler.e2e-spec.ts ├── jest-e2e.json ├── multi │ ├── app.module.ts │ ├── multi-throttler.controller.ts │ └── multi-throttler.e2e-spec.ts └── utility │ ├── hash.ts │ └── httpromise.ts ├── tsconfig.build.json └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "restricted", 7 | "baseBranch": "master", 8 | "updateInternalDependencies": "patch", 9 | "ignore": [] 10 | } 11 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-angular"], 3 | "rules": { 4 | "subject-case": [ 5 | 2, 6 | "always", 7 | ["sentence-case", "start-case", "pascal-case", "upper-case", "lower-case"] 8 | ], 9 | "type-enum": [ 10 | 2, 11 | "always", 12 | [ 13 | "build", 14 | "chore", 15 | "ci", 16 | "docs", 17 | "feat", 18 | "fix", 19 | "perf", 20 | "refactor", 21 | "revert", 22 | "style", 23 | "test", 24 | "sample" 25 | ] 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | sourceType: 'module', 5 | }, 6 | plugins: ['@typescript-eslint/eslint-plugin'], 7 | extends: [ 8 | 'plugin:@typescript-eslint/eslint-recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | rules: { 18 | '@typescript-eslint/no-unused-vars': [ 19 | 'error', 20 | { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, 21 | ], 22 | '@typescript-eslint/interface-name-prefix': 'off', 23 | '@typescript-eslint/explicit-function-return-type': 'off', 24 | '@typescript-eslint/explicit-module-boundary-types': 'off', 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | 'comma-dangle': ['warn', 'always-multiline'], 27 | 'max-len': [ 28 | 'warn', 29 | { 30 | code: 100, 31 | tabWidth: 2, 32 | ignoreComments: false, 33 | ignoreTrailingComments: true, 34 | ignoreUrls: true, 35 | ignoreStrings: true, 36 | ignoreTemplateLiterals: true, 37 | ignoreRegExpLiterals: true, 38 | }, 39 | ], 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: "If something isn't working as expected \U0001F914" 3 | labels: ["needs triage", "bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ## :warning: We use GitHub Issues to track bug reports, feature requests and regressions 9 | 10 | If you are not sure that your issue is a bug, you could: 11 | 12 | - use our [Discord community](https://discord.gg/NestJS) 13 | - use [StackOverflow using the tag `nestjs`](https://stackoverflow.com/questions/tagged/nestjs) 14 | - If it's just a quick question you can ping [our Twitter](https://twitter.com/nestframework) 15 | 16 | **NOTE:** You don't need to answer questions that you know that aren't relevant. 17 | 18 | --- 19 | 20 | - type: checkboxes 21 | attributes: 22 | label: "Is there an existing issue for this?" 23 | description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the bug you encountered" 24 | options: 25 | - label: "I have searched the existing issues" 26 | required: true 27 | 28 | - type: textarea 29 | validations: 30 | required: true 31 | attributes: 32 | label: "Current behavior" 33 | description: "How the issue manifests?" 34 | 35 | - type: input 36 | validations: 37 | required: true 38 | attributes: 39 | label: "Minimum reproduction code" 40 | description: "An URL to some git repository or gist that reproduces this issue. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction)" 41 | placeholder: "https://github.com/..." 42 | 43 | - type: textarea 44 | attributes: 45 | label: "Steps to reproduce" 46 | description: | 47 | How the issue manifests? 48 | You could leave this blank if you alread write this in your reproduction code/repo 49 | placeholder: | 50 | 1. `npm i` 51 | 2. `npm start:dev` 52 | 3. See error... 53 | 54 | - type: textarea 55 | validations: 56 | required: true 57 | attributes: 58 | label: "Expected behavior" 59 | description: "A clear and concise description of what you expected to happend (or code)" 60 | 61 | - type: markdown 62 | attributes: 63 | value: | 64 | --- 65 | 66 | - type: input 67 | validations: 68 | required: true 69 | attributes: 70 | label: "Package version" 71 | description: | 72 | Which version of `@nestjs/throttler` are you using? 73 | **Tip**: Make sure that all of yours `@nestjs/*` dependencies are in sync! 74 | placeholder: "8.1.3" 75 | 76 | - type: input 77 | attributes: 78 | label: "NestJS version" 79 | description: "Which version of `@nestjs/core` are you using?" 80 | placeholder: "8.1.3" 81 | 82 | - type: input 83 | attributes: 84 | label: "Node.js version" 85 | description: "Which version of Node.js are you using?" 86 | placeholder: "14.17.6" 87 | 88 | - type: checkboxes 89 | attributes: 90 | label: "In which operating systems have you tested?" 91 | options: 92 | - label: macOS 93 | - label: Windows 94 | - label: Linux 95 | 96 | - type: markdown 97 | attributes: 98 | value: | 99 | --- 100 | 101 | - type: textarea 102 | attributes: 103 | label: "Other" 104 | description: | 105 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc. 106 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in 107 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 Feature Request" 2 | description: "I have a suggestion \U0001F63B!" 3 | labels: ["feature"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ## :warning: We use GitHub Issues to track bug reports, feature requests and regressions 9 | 10 | If you are not sure that your issue is a bug, you could: 11 | 12 | - use our [Discord community](https://discord.gg/NestJS) 13 | - use [StackOverflow using the tag `nestjs`](https://stackoverflow.com/questions/tagged/nestjs) 14 | - If it's just a quick question you can ping [our Twitter](https://twitter.com/nestframework) 15 | 16 | --- 17 | 18 | - type: checkboxes 19 | attributes: 20 | label: "Is there an existing issue that is already proposing this?" 21 | description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the feature you are requesting" 22 | options: 23 | - label: "I have searched the existing issues" 24 | required: true 25 | 26 | - type: textarea 27 | validations: 28 | required: true 29 | attributes: 30 | label: "Is your feature request related to a problem? Please describe it" 31 | description: "A clear and concise description of what the problem is" 32 | placeholder: | 33 | I have an issue when ... 34 | 35 | - type: textarea 36 | validations: 37 | required: true 38 | attributes: 39 | label: "Describe the solution you'd like" 40 | description: "A clear and concise description of what you want to happen. Add any considered drawbacks" 41 | 42 | - type: textarea 43 | attributes: 44 | label: "Teachability, documentation, adoption, migration strategy" 45 | description: "If you can, explain how users will be able to use this and possibly write out a version the docs. Maybe a screenshot or design?" 46 | 47 | - type: textarea 48 | validations: 49 | required: true 50 | attributes: 51 | label: "What is the motivation / use case for changing the behavior?" 52 | description: "Describe the motivation or the concrete use case" 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Regression.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F4A5 Regression" 2 | description: "Report an unexpected behavior while upgrading your Nest application!" 3 | labels: ["needs triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | ## :warning: We use GitHub Issues to track bug reports, feature requests and regressions 9 | 10 | If you are not sure that your issue is a bug, you could: 11 | 12 | - use our [Discord community](https://discord.gg/NestJS) 13 | - use [StackOverflow using the tag `nestjs`](https://stackoverflow.com/questions/tagged/nestjs) 14 | - If it's just a quick question you can ping [our Twitter](https://twitter.com/nestframework) 15 | 16 | **NOTE:** You don't need to answer questions that you know that aren't relevant. 17 | 18 | --- 19 | 20 | - type: checkboxes 21 | attributes: 22 | label: "Did you read the migration guide?" 23 | description: "Check out the [migration guide here](https://docs.nestjs.com/migration-guide)!" 24 | options: 25 | - label: "I have read the whole migration guide" 26 | required: false 27 | 28 | - type: checkboxes 29 | attributes: 30 | label: "Is there an existing issue that is already proposing this?" 31 | description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the feature you are requesting" 32 | options: 33 | - label: "I have searched the existing issues" 34 | required: true 35 | 36 | - type: input 37 | attributes: 38 | label: "Potential Commit/PR that introduced the regression" 39 | description: "If you have time to investigate, what PR/date/version introduced this issue" 40 | placeholder: "PR #123 or commit 5b3c4a4" 41 | 42 | - type: input 43 | attributes: 44 | label: "Versions" 45 | description: "From which version of `@nestjs/throttler` to which version you are upgrading" 46 | placeholder: "8.1.0 -> 8.1.3" 47 | 48 | - type: textarea 49 | validations: 50 | required: true 51 | attributes: 52 | label: "Describe the regression" 53 | description: "A clear and concise description of what the regression is" 54 | 55 | - type: textarea 56 | attributes: 57 | label: "Minimum reproduction code" 58 | description: | 59 | Please share a git repo, a gist, or step-by-step instructions. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction) 60 | **Tip:** If you leave a minimum repository, we will understand your issue faster! 61 | value: | 62 | ```ts 63 | 64 | ``` 65 | 66 | - type: textarea 67 | validations: 68 | required: true 69 | attributes: 70 | label: "Expected behavior" 71 | description: "A clear and concise description of what you expected to happend (or code)" 72 | 73 | - type: textarea 74 | attributes: 75 | label: "Other" 76 | description: | 77 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc. 78 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in 79 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | ## To encourage contributors to use issue templates, we don't allow blank issues 2 | blank_issues_enabled: false 3 | 4 | contact_links: 5 | - name: "\u2753 Discord Community of NestJS" 6 | url: "https://discord.gg/NestJS" 7 | about: "Please ask support questions or discuss suggestions/enhancements here." 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | Please check if your PR fulfills the following requirements: 3 | 4 | - [ ] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md 5 | - [ ] Tests for the changes have been added (for bug fixes / features) 6 | - [ ] Docs have been added / updated (for bug fixes / features) 7 | 8 | 9 | ## PR Type 10 | What kind of change does this PR introduce? 11 | 12 | 13 | - [ ] Bugfix 14 | - [ ] Feature 15 | - [ ] Code style update (formatting, local variables) 16 | - [ ] Refactoring (no functional changes, no api changes) 17 | - [ ] Build related changes 18 | - [ ] CI related changes 19 | - [ ] Other... Please describe: 20 | 21 | ## What is the current behavior? 22 | 23 | 24 | Issue Number: N/A 25 | 26 | 27 | ## What is the new behavior? 28 | 29 | 30 | ## Does this PR introduce a breaking change? 31 | - [ ] Yes 32 | - [ ] No 33 | 34 | 35 | 36 | 37 | ## Other information 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'master' 7 | push: 8 | branches: 9 | - '*' 10 | schedule: 11 | - cron: '0 0 * * *' 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: install pnpm 28 | run: npm i -g pnpm@^9 29 | - name: install deps 30 | run: pnpm i 31 | - name: lint 32 | run: pnpm lint 33 | - name: build 34 | run: pnpm build 35 | - name: test 36 | run: pnpm test:cov 37 | - name: E2E test 38 | run: pnpm test:e2e 39 | 40 | auto-merge: 41 | needs: test 42 | if: contains(github.event.pull_request.user.login, 'dependabot') || contains(github.event.pull_request.user.login, 'renovate') 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: automerge 46 | uses: pascalgn/automerge-action@v0.16.4 47 | env: 48 | GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' 49 | MERGE_LABELS: '' 50 | MERGE_METHOD: rebase 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@master 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | 19 | - name: Setup Node.js 20.x 20 | uses: actions/setup-node@master 21 | with: 22 | node-version: 22.x 23 | 24 | - name: Install PNPM 25 | run: npm i -g pnpm 26 | 27 | - name: Install Dependencies 28 | run: pnpm i 29 | 30 | - name: Build Projects 31 | run: pnpm build 32 | 33 | - name: Create Release Pull Request or Publish to npm 34 | id: changesets 35 | uses: changesets/action@master 36 | with: 37 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 38 | publish: yarn release 39 | commit: "chore: version packages" 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | dist 3 | node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # VIM 37 | *.swp 38 | *.swo 39 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint -g .commitlintrc.json --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.ts": [ 3 | "prettier --write", 4 | "eslint --ext ts" 5 | ], 6 | "*.{md,html,json,js}": [ 7 | "prettier --write" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | message="chore(release): %s :tada:" -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach", 9 | "port": 9229, 10 | "request": "attach", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "type": "pwa-node" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "ratelimit" 4 | ] 5 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 6.4.0 2 | 3 | ### Minor Changes 4 | 5 | - 5cb4254: Update to allow for support for Nest version 11 6 | 7 | ## 6.3.0 8 | 9 | ### Minor Changes 10 | 11 | - fc93f3a: pass context to getTraker as a second arg 12 | 13 | ## 6.2.1 14 | 15 | ### Patch Changes 16 | 17 | - fbf27c6: Add the guard interfaces for export for public use 18 | 19 | ## 6.2.0 20 | 21 | ### Minor Changes 22 | 23 | - 3d1a9a5: Swap MD5 hash for SHA256 to better support OpenSSL 3.0 and future iterations 24 | 25 | ## 6.1.1 26 | 27 | ### Patch Changes 28 | 29 | - ef69348: Update the readme for websockets 30 | 31 | ## 6.1.0 32 | 33 | ### Minor Changes 34 | 35 | - e058d50: Use ceil instead of floor while calculating expire and block expire at to properly account for rounding up instead of down and accidentally allowing for early continued requests. Related to #2074 36 | 37 | ## 6.0.0 38 | 39 | ### Major Changes 40 | 41 | - 93b62d2: A time will be provided to block the request separately from the ttl. There is a breaking change at the library level. Storage library owners will be affected by this breaking change 42 | - 9b3f9cd: - e17a5dc: The storage has been updated to utilize Map instead of a simple object for key-value storage. This enhancement offers improved performance, especially for scenarios involving frequent additions and deletions of keys. There is a breaking change at the library level. Storage library owners will be affected by this breaking change 43 | 44 | ## 5.2.0 45 | 46 | ### Minor Changes 47 | 48 | - 16467c1: Add dynamic error messages based on context and ThrottlerLimitDetail 49 | 50 | ## 5.1.2 51 | 52 | ### Patch Changes 53 | 54 | - 7a431e5: Improve performance by replacing md5 npm package with Node.js crypto module. 55 | 56 | ## 5.1.1 57 | 58 | ### Patch Changes 59 | 60 | - b06a208: Resolves a bug that cause 'this' to be undefined in the 'getTracker' and 'generateKey' methods of the custom ThrottlerGuard 61 | 62 | ## 5.1.0 63 | 64 | ### Minor Changes 65 | 66 | - 903d187: Allow for throttler definitions to define their own trackers and key generators to allow for more customization of the rate limit process 67 | 68 | ## 5.0.1 69 | 70 | ### Patch Changes 71 | 72 | - bc9e6b2: Correctly assign metadata for multiple throttlers passed to `@SkipThrottle()` 73 | 74 | ### Major Changes 75 | 76 | - 2f4f2a7: # FEATURES 77 | 78 | - allow for multiple Throttler Contexts 79 | - allow for conditionally skipping based on `ThrottleGuard#shouldSkip` method 80 | - allow for easily overriding throttler message based on guard method 81 | - extra context passed to throw method for better customization of message 82 | - `ThrottlerStorage` no longer needs a `storage` property` 83 | - `getTracker` can now be async 84 | 85 | # BREAKING CHANGES 86 | 87 | - ttl is now in milliseconds, not seconds, but there are time helper exposed to 88 | ease the migration to that 89 | - the module options is now either an array or an object with a `throttlers` 90 | array property 91 | - `@Throttle()` now takes in an object instead of two parameters, to allow for 92 | setting multiple throttle contexts at once in a more readable manner 93 | - `@ThrottleSkip()` now takes in an object with string boolean to say which 94 | throttler should be skipped 95 | - `ttl` and `limit` are no longer optional in the module's options. If an option 96 | object is passed, it **must** define the defaults for that throttler 97 | 98 | # HOW TO MIGRATE 99 | 100 | For most people, wrapping your options in an array will be enough. 101 | 102 | If you are using a custom storage, you should wrap you `ttl` and `limit` in an 103 | array and assign it to the `throttlers` property of the options object. 104 | 105 | Any `@ThrottleSkip()` should now take in an object with `string: boolean` props. 106 | The strings are the names of the throttlers. If you do not have a name, pass the 107 | string `'default'`, as this is what will be used under the hood otherwise. 108 | 109 | Any `@Throttle()` decorators should also now take in an object with string keys, 110 | relating to the names of the throttler contexts (again, `'default'` if no name) 111 | and values of objects that have `limit` and `ttl` keys. 112 | 113 | **IMPORTANT**: The `ttl` is now in **miliseconds**. If you want to keep your ttl 114 | in seconds for readability, usethe `seconds` helper from this package. It just 115 | multiplies the ttl by 1000 to make it in milliseconds. 116 | 117 | ## 4.2.1 118 | 119 | ### Patch Changes 120 | 121 | - b72c9cb: Revert resolvable properties for ttl and limit 122 | 123 | The resolvable properties made a breaking change for custom guards that was 124 | unforseen. This reverts it and schedules the changes for 5.0.0 instead 125 | 126 | ## 4.2.0 127 | 128 | ### Minor Changes 129 | 130 | - d8d8c93: Allow for ttl and limit to be set based on the execution context, instead of statically assigned for the entire application 131 | 132 | ## 4.1.0 133 | 134 | ### Minor Changes 135 | 136 | - 527d51c: Support Nest v10 137 | 138 | ## 4.0.0 139 | 140 | ### Major Changes 141 | 142 | - 4803dda: Rewrite the storage service to better handle large numbers of operations 143 | 144 | ## Why 145 | 146 | The initial behavior was that `getRecord()` returned an list of sorted TTL 147 | timestamps, then if it didn't reach the limit, it will call `addRecord()`. 148 | This change was made based on the use of the Redis storage community package 149 | where it was found how to prevent this issue. It was found out that 150 | [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) 151 | is incrementing a single number and returning the information in a single 152 | roundtrip, which is significantly faster than how NestJS throttler works by 153 | called `getRecord()`, then `addRecord`. 154 | 155 | ## Breaking Changes 156 | 157 | - removed `getRecord` 158 | - `addRecord(key: string, ttl: number): Promise;` changes to `increment(key: string, ttl: number): Promise;` 159 | 160 | ## How to Migrate 161 | 162 | If you are just _using_ the throttler library, you're already covered. No 163 | changes necessary to your code, version 4.0.0 will work as is. 164 | 165 | If you are providing a custom storage, you will need to remove your current 166 | service's `getRecord` method and rename `addRecord` to `incremenet` while 167 | adhering to the new interface and returning an `ThrottlerStorageRecord` object 168 | 169 | ## 3.1.0 170 | 171 | ### Minor Changes 172 | 173 | - da3c950: Add `skipIf` option to throttler module options 174 | 175 | With the new option, you can pass a factory to `skipIf` and determine if the throttler guard should be used in the first palce or not. This acts just like applying `@SkipThrottle()` to every route, but can be customized to work off of the `process.env` or `ExecutionContext` object to provide better support for dev and QA environments. 176 | 177 | ## 3.0.0 178 | 179 | ### Major Changes 180 | 181 | - c9fcd51: Upgrade nest version to v9. No breaking changes in direct code, but in nest v9 upgrade 182 | 183 | ## 2.0.1 184 | 185 | ### Patch Changes 186 | 187 | - cf50808: fix memory leak for timeoutIds array. Before this, the timeoutIds array would not be trimmed and would grow until out of memory. Now ids are properly removed on timeout. 188 | 189 | ### Features 190 | 191 | - adding in a comment about version ([b13bf53](https://github.com/nestjs/throttler/commit/b13bf53542236ba6b05ac537b7a677e1644a0407)) 192 | 193 | ### BREAKING CHANGES 194 | 195 | - v2 and above is now being developed specificially for 196 | nest v8 and could have some unforseen side effects with Nest v7. use with 197 | v7 at your own risk. 198 | 199 | ## [1.2.1](https://github.com/nestjs/throttler/compare/v1.2.0...v1.2.1) (2021-07-09) 200 | 201 | ### Performance Improvements 202 | 203 | - upgrade to nest v8 ([cb5dd91](https://github.com/nestjs/throttler/commit/cb5dd913e9fcc482cd74f2d49085b98dac630215)) 204 | 205 | # [0.3.0](https://github.com/jmcdo29/nestjs-throttler/compare/0.2.3...0.3.0) (2020-11-10) 206 | 207 | ### Bug Fixes 208 | 209 | - **module:** async register is now `forRootAsync` ([a1c6ace](https://github.com/jmcdo29/nestjs-throttler/commit/a1c6acef472e9d2368f2139e6b789ef184a7d952)) 210 | 211 | ## [0.2.3](https://github.com/jmcdo29/nestjs-throttler/compare/0.2.2...0.2.3) (2020-08-06) 212 | 213 | ### Features 214 | 215 | - **ws:** allows for optional use of @nestjs/websocket ([f437614](https://github.com/jmcdo29/nestjs-throttler/commit/f437614cab5aebfdfdb4d5884f45b58b16d5a140)) 216 | 217 | ## [0.2.2](https://github.com/jmcdo29/nestjs-throttler/compare/0.2.1...0.2.2) (2020-06-12) 218 | 219 | ### Bug Fixes 220 | 221 | - moves userAgent check to http handler ([87183af](https://github.com/jmcdo29/nestjs-throttler/commit/87183af8fc189d7d5c8237832089138a0b40589b)) 222 | 223 | ### Features 224 | 225 | - **decorator:** add setThrottlerMetadata() function back ([ea31a9c](https://github.com/jmcdo29/nestjs-throttler/commit/ea31a9c86b82550e2d43f3433ec618785cf2b34a)) 226 | - **graphql:** implements graphql limiter ([40eaff1](https://github.com/jmcdo29/nestjs-throttler/commit/40eaff16dae5c0279001e56ff64a2b540d82a3c7)) 227 | - Add support for ws (websockets) ([a745295](https://github.com/jmcdo29/nestjs-throttler/commit/a74529517f989c43d77c9a63712e82244ebeefcd)) 228 | - Add support for ws (websockets) ([8103a5a](https://github.com/jmcdo29/nestjs-throttler/commit/8103a5a11c1916f05f8c44e302ba93a98d7cb77d)) 229 | - Make storage methods async ([92cd4eb](https://github.com/jmcdo29/nestjs-throttler/commit/92cd4ebf507b3bed4efbaeb7bb47bd1738a62dc3)) 230 | - **exception:** Use const instead of duplicated string ([f95da2c](https://github.com/jmcdo29/nestjs-throttler/commit/f95da2c4fc787c7c5e525672d668745bc1f2301d)) 231 | - **guard:** Add default case for context.getType() switch ([ff46d57](https://github.com/jmcdo29/nestjs-throttler/commit/ff46d57508c4b446918ccd75f704d0eed1ae352f)) 232 | - Implement basic support for websocket ([3a0cf2e](https://github.com/jmcdo29/nestjs-throttler/commit/3a0cf2ed70c7abbe02e9d96f26ab2c81b3c7bb2f)) 233 | 234 | ## [0.2.1](https://github.com/jmcdo29/nestjs-throttler/compare/0.2.0...0.2.1) (2020-06-09) 235 | 236 | ### Features 237 | 238 | - add support for ignoreUserAgents option ([1ab5e17](https://github.com/jmcdo29/nestjs-throttler/commit/1ab5e17a25a95ec14910e199726eac07f66f4475)) 239 | 240 | # [0.2.0](https://github.com/jmcdo29/nestjs-throttler/compare/0.1.1...0.2.0) (2020-06-09) 241 | 242 | ### Bug Fixes 243 | 244 | - make core module global and export core module inside ThrottlerModule ([1f4df42](https://github.com/jmcdo29/nestjs-throttler/commit/1f4df42a5fc9a6f75c398bbb6a3f9ebaec6bc80f)) 245 | 246 | ### Features 247 | 248 | - makes options required in forRoot and forRootAsync ([14e272a](https://github.com/jmcdo29/nestjs-throttler/commit/14e272a842a90db93dd9e8c60c936fbcf0bcd3b7)) 249 | - remove global guard and require user to implement it manually ([840eae4](https://github.com/jmcdo29/nestjs-throttler/commit/840eae4643867390bc598937b20e132257e9b018)) 250 | 251 | ## [0.1.1](https://github.com/jmcdo29/nestjs-throttler/compare/0.1.0...0.1.1) (2020-06-07) 252 | 253 | ### Bug Fixes 254 | 255 | - **interface:** fixes the storage interface to be async ([f7565d9](https://github.com/jmcdo29/nestjs-throttler/commit/f7565d9029baf4d7687f0913046f555d17cde44b)) 256 | 257 | # 0.1.0 (2020-06-07) 258 | 259 | ### Bug Fixes 260 | 261 | - adds back AppModule to allow for running server for tests ([5af229b](https://github.com/jmcdo29/nestjs-throttler/commit/5af229ba69527daf3662b1899ed985fa9404251b)) 262 | - updates some types ([b26fc06](https://github.com/jmcdo29/nestjs-throttler/commit/b26fc06841a430e5728cde6276515130b89a7289)) 263 | - updates storage interface to use number ([339f29c](https://github.com/jmcdo29/nestjs-throttler/commit/339f29c12b4720a7376ec042988f73460172b32e)) 264 | - updates tests and resolves comments from pr ([ee87e05](https://github.com/jmcdo29/nestjs-throttler/commit/ee87e05e2f5eb61b00b423d6394be9a131f84f8a)) 265 | - **.gitignore:** Ignore all dist and node_modules rather than root-level only ([d9609af](https://github.com/jmcdo29/nestjs-throttler/commit/d9609afb9cf3561b84082ac9a3e2e26ddcbb2117)) 266 | - **guard:** Change RateLimit header prefix to X-RateLimit ([328c0a3](https://github.com/jmcdo29/nestjs-throttler/commit/328c0a3c1009fdc65820125c2145de65aebd3fee)) 267 | - **guard:** Change RateLimit header prefix to X-RateLimit ([3903885](https://github.com/jmcdo29/nestjs-throttler/commit/3903885df9eaac0d966c5b8207fae26b62f337f3)) 268 | - **guard:** guard now binds globally without the use of @UseGuards() ([4022447](https://github.com/jmcdo29/nestjs-throttler/commit/40224475d27f1ec0cf792225bbc18df33ab14cc2)) 269 | - **guard:** guard now binds globally without the use of @UseGuards() ([3ca146d](https://github.com/jmcdo29/nestjs-throttler/commit/3ca146d41afa71e3c68b73d8706e7431f929a85a)) 270 | - **guard:** Prevent RateLimit-Remaining from going below 0 ([25e33c8](https://github.com/jmcdo29/nestjs-throttler/commit/25e33c882007892a3285c92449aa5bc0840a8909)) 271 | - **guard:** Prevent RateLimit-Remaining from going below 0 ([74b1668](https://github.com/jmcdo29/nestjs-throttler/commit/74b166888ab283281a964d6c64b94224e2f96ba4)) 272 | - **guard:** Use the correct approach to check for excluded routes ([38eac3c](https://github.com/jmcdo29/nestjs-throttler/commit/38eac3ca3bdad0b4b266587bc4b0287f3f69f640)) 273 | - **guard:** Use the correct approach to check for excluded routes ([912813f](https://github.com/jmcdo29/nestjs-throttler/commit/912813f49cc98e8fbd2643650d22ea8cc88c77ae)) 274 | - req.method value in httpPromise ([b9ee26e](https://github.com/jmcdo29/nestjs-throttler/commit/b9ee26e5e888e4d4f220e91adc996ade764f7002)) 275 | 276 | ### Features 277 | 278 | - Swap excludeRoutes for @SkipThrottle() decorator ([16d6fac](https://github.com/jmcdo29/nestjs-throttler/commit/16d6facd5e8f648620fa47e372078db37472f619)) 279 | - **fastify:** updates guard to work for fastify ([bc678a3](https://github.com/jmcdo29/nestjs-throttler/commit/bc678a363c367d132a90a2a4282e3f033f526e00)) 280 | - Implement ignoreRoutes functionality ([7b8ab42](https://github.com/jmcdo29/nestjs-throttler/commit/7b8ab4273fffafc0dd0571393d8c0faf89afc42f)) 281 | - **package.json:** Add --watch to start:dev script ([3c4c28a](https://github.com/jmcdo29/nestjs-throttler/commit/3c4c28abbb324e064f65b284f1a99683cd02030b)) 282 | - Implement ignoreRoutes functionality ([75f870c](https://github.com/jmcdo29/nestjs-throttler/commit/75f870c5b49e4d22c70519d28f8efffc1da288eb)) 283 | - **module:** implements start of limiter module ([35dbff5](https://github.com/jmcdo29/nestjs-throttler/commit/35dbff5d30e7a1385a4f4cf688992017eb7e0566)) 284 | - **package.json:** Add --watch to start:dev script ([a6b441c](https://github.com/jmcdo29/nestjs-throttler/commit/a6b441cad221b7eee52be0ba81c66fca81853c4f)) 285 | - Add global ThrottlerGuard ([9a84aff](https://github.com/jmcdo29/nestjs-throttler/commit/9a84afff5d57a16731d021cb47d60c2b4d02eb02)) 286 | - adds httpromise for async/await http calls in tests ([70210c7](https://github.com/jmcdo29/nestjs-throttler/commit/70210c76173aabfd5f85f5a24e624e7c4c010ae2)) 287 | - Rename certain variables to use the THROTTLER prefix ([6a21b21](https://github.com/jmcdo29/nestjs-throttler/commit/6a21b216a2738aa470e2138d44053ba8413ce117)) 288 | - Setup example app ([df6b5f6](https://github.com/jmcdo29/nestjs-throttler/commit/df6b5f633ebbb4770d3eb9e72e8075cbe6b2f78a)) 289 | - Setup example app ([30c7576](https://github.com/jmcdo29/nestjs-throttler/commit/30c75764fd20f3afe7a3f7533a3f4f08d275a741)) 290 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | - Demonstrating empathy and kindness toward other people 14 | - Being respectful of differing opinions, viewpoints, and experiences 15 | - Giving and gracefully accepting constructive feedback 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | - Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | - Trolling, insulting or derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others' private information, such as a physical or email address, without their explicit permission 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [me@jaymcdoniel.dev](mailto:me@jaymcdoniel.dev). All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 76 | 77 | [homepage]: https://www.contributor-covenant.org 78 | 79 | For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 80 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | Any and all contributions are welcome! This is a decently sized project with a good scoped of functionality. 4 | 5 | ## How to Contribute 6 | 7 | 1. Create a fork of the repository 8 | 2. Clone the code to your local machine 9 | 3. Create a new branch with the feature you are working on (e.g. WebSocket-Interceptor) or with the issue number (e.g. issue/42) 10 | 4. Run `pnpm i` 11 | 5. Implement your changes, ensure tests are still passing, or add tests if it is a new feature 12 | 6. Push back to your version on GitHub 13 | 7. Raise a Pull Request to the main repository 14 | 15 | ## Development 16 | 17 | All the source code is in `src` as expected. Most of the code should be rather self documented. 18 | 19 | ## Testing 20 | 21 | To run a basic dev server you can use `start:dev` to run `nodemon` and `ts-node`. All tests should be running through `jest` using `test:e2e` otherwise. 22 | 23 | If you need to run tests for a specific context, use `pnpm test:e2e ` (one of: controller, ws, gql) e.g. `pnpm test:e2e controller` will run the e2e tests for the HTTP guard. 24 | 25 | ## Commits 26 | 27 | We are using [Conventional Commit](https://github.com/conventional-changelog/commitlint) to help keep commit messages aligned as development continues. The easiest way to get acquainted with what the commit should look like is to run `yarn commit` which will use the `git-cz` cli and walk you through the steps of committing. Once you've made your commit, prettier and eslint will run and ensure that the new code is up to the standards we have in place. 28 | 29 | ## Issues 30 | 31 | Please raise an issue, or discuss with me [via email](mailto:me@jaymcdoniel.dev) or [Discord](https://discordapp.com) (PerfectOrphan31#6003) before opening a Pull Request so we can see if they align with the goals of the project. 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2019-2024 Jay McDoniel, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 |

A progressive Node.js framework for building efficient and scalable server-side applications.

6 |

7 | NPM Version 8 | Package License 9 | NPM Downloads 10 | Coverage 11 | Discord 12 | Backers on Open Collective 13 | Sponsors on Open Collective 14 | 15 |

16 | 17 | ## Description 18 | 19 | A Rate-Limiter for NestJS, regardless of the context. 20 | 21 | Throttler ensures that users can only make `limit` requests per `ttl` to each endpoint. By default, users are identified by their IP address. This behavior can be customized by providing your own `getTracker` function. See [Proxies](#proxies) for an example where this is useful. 22 | 23 | Throttler comes with a built-in in-memory cache to keep track of the requests. It supports alternate storage providers. For an overview, see [Community Storage Providers](#community-storage-providers). 24 | 25 | ## Installation 26 | 27 | ```bash 28 | $ npm i --save @nestjs/throttler 29 | ``` 30 | 31 | ## Versions 32 | 33 | `@nestjs/throttler@^1` is compatible with Nest v7 while `@nestjs/throttler@^2` is compatible with Nest v7 and Nest v8, but it is suggested to be used with only v8 in case of breaking changes against v7 that are unseen. 34 | 35 | For NestJS v10, please use version 4.1.0 or above. 36 | 37 | ## Usage 38 | 39 | ### ThrottlerModule 40 | 41 | Once the installation is complete, the `ThrottlerModule` can be configured as any other Nest package with `forRoot` or `forRootAsync` methods. 42 | 43 | ```typescript 44 | @@filename(app.module) 45 | @Module({ 46 | imports: [ 47 | ThrottlerModule.forRoot([{ 48 | ttl: 60000, 49 | limit: 10, 50 | }]), 51 | ], 52 | }) 53 | export class AppModule {} 54 | ``` 55 | 56 | The above will set the global options for the `ttl`, the time to live in milliseconds, and the `limit`, the maximum number of requests within the ttl, for the routes of your application that are guarded. 57 | 58 | Once the module has been imported, you can then choose how you would like to bind the `ThrottlerGuard`. Any kind of binding as mentioned in the [guards](https://docs.nestjs.com/guards) section is fine. If you wanted to bind the guard globally, for example, you could do so by adding this provider to any module: 59 | 60 | ```typescript 61 | { 62 | provide: APP_GUARD, 63 | useClass: ThrottlerGuard 64 | } 65 | ``` 66 | 67 | #### Multiple Throttler Definitions 68 | 69 | There may come upon times where you want to set up multiple throttling definitions, like no more than 3 calls in a second, 20 calls in 10 seconds, and 100 calls in a minute. To do so, you can set up your definitions in the array with named options, that can later be referenced in the `@SkipThrottle()` and `@Throttle()` decorators to change the options again. 70 | 71 | ```typescript 72 | @@filename(app.module) 73 | @Module({ 74 | imports: [ 75 | ThrottlerModule.forRoot([ 76 | { 77 | name: 'short', 78 | ttl: 1000, 79 | limit: 3, 80 | }, 81 | { 82 | name: 'medium', 83 | ttl: 10000, 84 | limit: 20 85 | }, 86 | { 87 | name: 'long', 88 | ttl: 60000, 89 | limit: 100 90 | } 91 | ]), 92 | ], 93 | }) 94 | export class AppModule {} 95 | ``` 96 | 97 | ### Customization 98 | 99 | There may be a time where you want to bind the guard to a controller or globally, but want to disable rate limiting for one or more of your endpoints. For that, you can use the `@SkipThrottle()` decorator, to negate the throttler for an entire class or a single route. The `@SkipThrottle()` decorator can also take in an object of string keys with boolean values, if you have more than one throttler set. If you do not pass an object, the default is to use `{ default: true }` 100 | 101 | ```typescript 102 | @SkipThrottle() 103 | @Controller('users') 104 | export class UsersController {} 105 | ``` 106 | 107 | This `@SkipThrottle()` decorator can be used to skip a route or a class or to negate the skipping of a route in a class that is skipped. 108 | 109 | ```typescript 110 | @SkipThrottle() 111 | @Controller('users') 112 | export class UsersController { 113 | // Rate limiting is applied to this route. 114 | @SkipThrottle({ default: false }) 115 | dontSkip() { 116 | return 'List users work with Rate limiting.'; 117 | } 118 | // This route will skip rate limiting. 119 | doSkip() { 120 | return 'List users work without Rate limiting.'; 121 | } 122 | } 123 | ``` 124 | 125 | There is also the `@Throttle()` decorator which can be used to override the `limit` and `ttl` set in the global module, to give tighter or looser security options. This decorator can be used on a class or a function as well. With version 5 and onwards, the decorator takes in an object with the string relating to the name of the throttler set, and an object with the limit and ttl keys and integer values, similar to the options passed to the root module. If you do not have a name set in your original options, use the string `default` You have to configure it like this: 126 | 127 | ```typescript 128 | // Override default configuration for Rate limiting and duration. 129 | @Throttle({ default: { limit: 3, ttl: 60000 } }) 130 | @Get() 131 | findAll() { 132 | return "List users works with custom rate limiting."; 133 | } 134 | ``` 135 | 136 | ### Proxies 137 | 138 | If your application runs behind a proxy server, check the specific HTTP adapter options ([express](http://expressjs.com/en/guide/behind-proxies.html) and [fastify](https://www.fastify.io/docs/latest/Reference/Server/#trustproxy)) for the `trust proxy` option and enable it. Doing so will allow you to get the original IP address from the `X-Forwarded-For` header. 139 | 140 | For express, no further configuration is needed because express sets `req.ip` to the client IP if `trust proxy` is enabled. For fastify, you need to read the client IP from `req.ips` instead. The following example is only needed for fastify, but works with both engines: 141 | 142 | ```typescript 143 | // throttler-behind-proxy.guard.ts 144 | import { ThrottlerGuard } from '@nestjs/throttler'; 145 | import { Injectable } from '@nestjs/common'; 146 | 147 | @Injectable() 148 | export class ThrottlerBehindProxyGuard extends ThrottlerGuard { 149 | protected getTracker(req: Record): Promise { 150 | // The client IP is the leftmost IP in req.ips. You can individualize IP 151 | // extraction to meet your own needs. 152 | const tracker = req.ips.length > 0 ? req.ips[0] : req.ip; 153 | return Promise.resolve(tracker); 154 | } 155 | } 156 | 157 | // app.controller.ts 158 | import { ThrottlerBehindProxyGuard } from './throttler-behind-proxy.guard'; 159 | 160 | @UseGuards(ThrottlerBehindProxyGuard) 161 | ``` 162 | 163 | > **Hint:** You can find the API of the `req` Request object for express [here](https://expressjs.com/en/api.html#req.ips) and for fastify [here](https://www.fastify.io/docs/latest/Reference/Request/). 164 | 165 | ### Websockets 166 | 167 | This module can work with websockets, but it requires some class extension. You can extend the `ThrottlerGuard` and override the `handleRequest` method like so: 168 | 169 | ```typescript 170 | @Injectable() 171 | export class WsThrottlerGuard extends ThrottlerGuard { 172 | async handleRequest(requestProps: ThrottlerRequest): Promise { 173 | const { context, limit, ttl, throttler, blockDuration, generateKey } = requestProps; 174 | 175 | const client = context.switchToWs().getClient(); 176 | const tracker = client._socket.remoteAddress; 177 | const key = generateKey(context, tracker, throttler.name); 178 | const { totalHits, timeToExpire, isBlocked, timeToBlockExpire } = 179 | await this.storageService.increment(key, ttl, limit, blockDuration, throttler.name); 180 | 181 | // Throw an error when the user reached their limit. 182 | if (isBlocked) { 183 | await this.throwThrottlingException(context, { 184 | limit, 185 | ttl, 186 | key, 187 | tracker, 188 | totalHits, 189 | timeToExpire, 190 | isBlocked, 191 | timeToBlockExpire, 192 | }); 193 | } 194 | 195 | return true; 196 | } 197 | } 198 | ``` 199 | 200 | > **Hint:** If you are using ws, it is necessary to replace the `_socket` with `conn`. 201 | 202 | There's a few things to keep in mind when working with WebSockets: 203 | 204 | - Guard cannot be registered with the `APP_GUARD` or `app.useGlobalGuards()` 205 | - When a limit is reached, Nest will emit an `exception` event, so make sure there is a listener ready for this 206 | 207 | > **Hint:** If you are using the `@nestjs/platform-ws` package you can use `client._socket.remoteAddress` instead. 208 | 209 | ### GraphQL 210 | 211 | The `ThrottlerGuard` can also be used to work with GraphQL requests. Again, the guard can be extended, but this time the `getRequestResponse` method will be overridden: 212 | 213 | ```typescript 214 | @Injectable() 215 | export class GqlThrottlerGuard extends ThrottlerGuard { 216 | getRequestResponse(context: ExecutionContext) { 217 | const gqlCtx = GqlExecutionContext.create(context); 218 | const ctx = gqlCtx.getContext(); 219 | return { req: ctx.req, res: ctx.res }; 220 | } 221 | } 222 | ``` 223 | 224 | However, when using Apollo Express/Fastify or Mercurius, it's important to configure the context correctly in the GraphQLModule to avoid any problems. 225 | 226 | #### Apollo Server (for Express): 227 | 228 | For Apollo Server running on Express, you can set up the context in your GraphQLModule configuration as follows: 229 | 230 | ```typescript 231 | GraphQLModule.forRoot({ 232 | // ... other GraphQL module options 233 | context: ({ req, res }) => ({ req, res }), 234 | }); 235 | ``` 236 | 237 | #### Apollo Server (for Fastify) & Mercurius: 238 | 239 | When using Apollo Server with Fastify or Mercurius, you need to configure the context differently. You should use request and reply objects. Here's an example: 240 | 241 | ```typescript 242 | GraphQLModule.forRoot({ 243 | // ... other GraphQL module options 244 | context: (request, reply) => ({ request, reply }), 245 | }); 246 | ``` 247 | 248 | ### Configuration 249 | 250 | The following options are valid for the object passed to the array of the `ThrottlerModule`'s options: 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 |
namethe name for internal tracking of which throttler set is being used. Defaults to `default` if not passed
ttlthe number of milliseconds that each request will last in storage
limitthe maximum number of requests within the TTL limit
blockDurationthe number of milliseconds the request will be blocked
ignoreUserAgentsan array of regular expressions of user-agents to ignore when it comes to throttling requests
skipIfa function that takes in the ExecutionContext and returns a boolean to short circuit the throttler logic. Like @SkipThrottler(), but based on the request
getTrackera function that takes in the Request and ExecutionContext, and returns a string to override the default logic of the getTracker method
generateKeya function that takes in the ExecutionContext, the tacker string and the throttler name as a string and returns a string to override the final key which will be used to store the rate limit value. This overrides the default logic of the generateKey method
286 | 287 | If you need to set up storages instead, or want to use a some of the above options in a more global sense, applying to each throttler set, you can pass the options above via the `throttlers` option key and use the below table 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 |
storagea custom storage service for where the throttling should be kept track. See Storages below.
ignoreUserAgentsan array of regular expressions of user-agents to ignore when it comes to throttling requests
skipIfa function that takes in the ExecutionContext and returns a boolean to short circuit the throttler logic. Like @SkipThrottler(), but based on the request
throttlersan array of throttler sets, defined using the table above
errorMessagea string OR a function that takes in the ExecutionContext and the ThrottlerLimitDetail and returns a string which overrides the default throttler error message
getTrackera function that takes in the Request and ExecutionContext, and returns a string to override the default logic of the getTracker method
generateKeya function that takes in the ExecutionContext, the tacker string and the throttler name as a string and returns a string to override the final key which will be used to store the rate limit value. This overrides the default logic of the generateKey method
319 | 320 | #### Async Configuration 321 | 322 | You may want to get your rate-limiting configuration asynchronously instead of synchronously. You can use the `forRootAsync()` method, which allows for dependency injection and `async` methods. 323 | 324 | One approach would be to use a factory function: 325 | 326 | ```typescript 327 | @Module({ 328 | imports: [ 329 | ThrottlerModule.forRootAsync({ 330 | imports: [ConfigModule], 331 | inject: [ConfigService], 332 | useFactory: (config: ConfigService) => [ 333 | { 334 | ttl: config.get('THROTTLE_TTL'), 335 | limit: config.get('THROTTLE_LIMIT'), 336 | }, 337 | ], 338 | }), 339 | ], 340 | }) 341 | export class AppModule {} 342 | ``` 343 | 344 | You can also use the `useClass` syntax: 345 | 346 | ```typescript 347 | @Module({ 348 | imports: [ 349 | ThrottlerModule.forRootAsync({ 350 | imports: [ConfigModule], 351 | useClass: ThrottlerConfigService, 352 | }), 353 | ], 354 | }) 355 | export class AppModule {} 356 | ``` 357 | 358 | This is doable, as long as `ThrottlerConfigService` implements the interface `ThrottlerOptionsFactory`. 359 | 360 | ### Storages 361 | 362 | The built in storage is an in memory cache that keeps track of the requests made until they have passed the TTL set by the global options. You can drop in your own storage option to the `storage` option of the `ThrottlerModule` so long as the class implements the `ThrottlerStorage` interface. 363 | 364 | > **Note:** `ThrottlerStorage` can be imported from `@nestjs/throttler`. 365 | 366 | ### Time Helpers 367 | 368 | There are a couple of helper methods to make the timings more readable if you prefer to use them over the direct definition. `@nestjs/throttler` exports five different helpers, `seconds`, `minutes`, `hours`, `days`, and `weeks`. To use them, simply call `seconds(5)` or any of the other helpers, and the correct number of milliseconds will be returned. 369 | 370 | ### Migrating to v5 from earlier versions 371 | 372 | If you migrate to v5 from earlier versions, you need to wrap your options in an array. 373 | 374 | If you are using a custom storage, you should wrap you `ttl` and `limit` in an array and assign it to the `throttlers` property of the options object. 375 | 376 | Any `@ThrottleSkip()` should now take in an object with `string: boolean` props. The strings are the names of the throttlers. If you do not have a name, pass the string `'default'`, as this is what will be used under the hood otherwise. 377 | 378 | Any `@Throttle()` decorators should also now take in an object with string keys, relating to the names of the throttler contexts (again, `'default'` if no name) and values of objects that have `limit` and `ttl` keys. 379 | 380 | > **Important:** The `ttl` is now in **milliseconds**. If you want to keep your ttl in seconds for readability, use the `seconds` helper from this package. It just multiplies the ttl by 1000 to make it in milliseconds. 381 | 382 | For more info, see the [Changelog](https://github.com/nestjs/throttler/blob/master/CHANGELOG.md#500) 383 | 384 | ## Community Storage Providers 385 | 386 | - [Redis](https://github.com/jmcdo29/nest-lab/tree/main/packages/throttler-storage-redis) 387 | - [Mongo](https://www.npmjs.com/package/nestjs-throttler-storage-mongo) 388 | 389 | Feel free to submit a PR with your custom storage provider being added to this list. 390 | 391 | ## License 392 | 393 | Nest is [MIT licensed](LICENSE). 394 | 395 |

🔼 Back to TOC

396 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nestjs/throttler", 3 | "version": "6.4.0", 4 | "description": "A Rate-Limiting module for NestJS to work on Express, Fastify, Websockets, Socket.IO, and GraphQL, all rolled up into a simple package.", 5 | "author": "Jay McDoniel ", 6 | "contributors": [], 7 | "keywords": [ 8 | "nestjs", 9 | "rate-limit", 10 | "throttle", 11 | "express", 12 | "fastify", 13 | "ws", 14 | "gql", 15 | "nest" 16 | ], 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "private": false, 21 | "license": "MIT", 22 | "main": "dist/index.js", 23 | "types": "dist/index.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "prebuild": "rimraf dist", 29 | "preversion": "yarn run format && yarn run lint && yarn build", 30 | "build": "nest build", 31 | "commit": "git-cz", 32 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 33 | "start:dev": "nodemon --watch '{src,test/app}/**/*.ts' --ignore '**/*.spec.ts' --exec 'ts-node' test/app/main.ts", 34 | "lint": "eslint \"{src,test}/**/*.ts\" --fix", 35 | "test": "jest", 36 | "test:watch": "jest --watch", 37 | "test:cov": "jest --coverage", 38 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 39 | "test:e2e": "jest --config ./test/jest-e2e.json --detectOpenHandles", 40 | "test:e2e:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --config test/jest-e2e.json --runInBand", 41 | "test:e2e:dev": "yarn test:e2e --watchAll", 42 | "prepare": "husky", 43 | "postpublish": "pinst --enable", 44 | "prepublishOnly": "pinst --disable", 45 | "release": "changeset publish" 46 | }, 47 | "devDependencies": { 48 | "@apollo/server": "4.12.1", 49 | "@changesets/cli": "2.29.4", 50 | "@commitlint/cli": "19.8.1", 51 | "@commitlint/config-angular": "19.8.1", 52 | "@nestjs/cli": "11.0.7", 53 | "@nestjs/common": "11.1.2", 54 | "@nestjs/core": "11.1.2", 55 | "@nestjs/graphql": "13.1.0", 56 | "@nestjs/platform-express": "11.1.2", 57 | "@nestjs/platform-fastify": "11.1.2", 58 | "@nestjs/platform-socket.io": "11.1.2", 59 | "@nestjs/platform-ws": "11.1.2", 60 | "@nestjs/schematics": "11.0.5", 61 | "@nestjs/testing": "11.1.2", 62 | "@nestjs/websockets": "11.1.2", 63 | "@semantic-release/git": "10.0.1", 64 | "@types/express": "5.0.2", 65 | "@types/express-serve-static-core": "5.0.6", 66 | "@types/jest": "29.5.14", 67 | "@types/node": "22.15.29", 68 | "@types/supertest": "6.0.3", 69 | "@typescript-eslint/eslint-plugin": "7.18.0", 70 | "@typescript-eslint/parser": "7.18.0", 71 | "apollo-server-fastify": "3.13.0", 72 | "conventional-changelog-cli": "5.0.0", 73 | "cz-conventional-changelog": "3.3.0", 74 | "eslint": "8.57.1", 75 | "eslint-config-prettier": "10.1.5", 76 | "eslint-plugin-import": "2.31.0", 77 | "graphql": "16.11.0", 78 | "graphql-tools": "9.0.18", 79 | "husky": "9.1.7", 80 | "jest": "29.7.0", 81 | "lint-staged": "16.1.0", 82 | "nodemon": "3.1.10", 83 | "pactum": "^3.4.1", 84 | "pinst": "3.0.0", 85 | "prettier": "3.5.3", 86 | "reflect-metadata": "0.2.2", 87 | "rimraf": "6.0.1", 88 | "rxjs": "7.8.2", 89 | "socket.io": "4.8.1", 90 | "supertest": "7.1.1", 91 | "ts-jest": "29.3.4", 92 | "ts-loader": "9.5.2", 93 | "ts-node": "10.9.2", 94 | "tsconfig-paths": "4.2.0", 95 | "typescript": "5.8.3", 96 | "ws": "8.18.2" 97 | }, 98 | "peerDependencies": { 99 | "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", 100 | "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", 101 | "reflect-metadata": "^0.1.13 || ^0.2.0" 102 | }, 103 | "jest": { 104 | "moduleFileExtensions": [ 105 | "js", 106 | "json", 107 | "ts" 108 | ], 109 | "rootDir": "src", 110 | "testRegex": ".spec.ts$", 111 | "transform": { 112 | "^.+\\.(t|j)s$": "ts-jest" 113 | }, 114 | "coverageDirectory": "../coverage", 115 | "testEnvironment": "node" 116 | }, 117 | "repository": { 118 | "type": "git", 119 | "url": "git+https://github.com/nestjs/throttler.git" 120 | }, 121 | "bugs": { 122 | "url": "https://github.com/nestjs/throttler/issues" 123 | }, 124 | "homepage": "https://github.com/nestjs/throttler#readme" 125 | } 126 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/hash.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'node:crypto'; 2 | 3 | export function sha256(text: string): string { 4 | return crypto.createHash('sha256').update(text).digest('hex'); 5 | } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './throttler-module-options.interface'; 2 | export * from './throttler-storage.interface'; 3 | export * from './throttler.decorator'; 4 | export * from './throttler.exception'; 5 | export * from './throttler.guard'; 6 | export * from './throttler.guard.interface'; 7 | export * from './throttler.module'; 8 | export { getOptionsToken, getStorageToken } from './throttler.providers'; 9 | export * from './throttler.service'; 10 | export * from './utilities'; 11 | -------------------------------------------------------------------------------- /src/throttler-module-options.interface.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, ModuleMetadata, Type } from '@nestjs/common/interfaces'; 2 | import { ThrottlerStorage } from './throttler-storage.interface'; 3 | import { ThrottlerLimitDetail } from './throttler.guard.interface'; 4 | 5 | export type Resolvable = 6 | | T 7 | | ((context: ExecutionContext) => T | Promise); 8 | 9 | /** 10 | * @publicApi 11 | */ 12 | export interface ThrottlerOptions { 13 | /** 14 | * The name for the rate limit to be used. 15 | * This can be left blank and it will be tracked as "default" internally. 16 | * If this is set, it will be added to the return headers. 17 | * e.g. x-ratelimit-remaining-long: 5 18 | */ 19 | name?: string; 20 | 21 | /** 22 | * The amount of requests that are allowed within the ttl's time window. 23 | */ 24 | limit: Resolvable; 25 | 26 | /** 27 | * The number of milliseconds the limit of requests are allowed 28 | */ 29 | ttl: Resolvable; 30 | 31 | /** 32 | * The number of milliseconds the request will be blocked. 33 | */ 34 | blockDuration?: Resolvable; 35 | 36 | /** 37 | * The user agents that should be ignored (checked against the User-Agent header). 38 | */ 39 | ignoreUserAgents?: RegExp[]; 40 | 41 | /** 42 | * A factory method to determine if throttling should be skipped. 43 | * This can be based on the incoming context, or something like an env value. 44 | */ 45 | skipIf?: (context: ExecutionContext) => boolean; 46 | /** 47 | * A method to override the default tracker string. 48 | */ 49 | getTracker?: ThrottlerGetTrackerFunction; 50 | /** 51 | * A method to override the default key generator. 52 | */ 53 | generateKey?: ThrottlerGenerateKeyFunction; 54 | } 55 | 56 | /** 57 | * @publicApi 58 | */ 59 | export type ThrottlerModuleOptions = 60 | | Array 61 | | { 62 | /** 63 | * The user agents that should be ignored (checked against the User-Agent header). 64 | */ 65 | ignoreUserAgents?: RegExp[]; 66 | /** 67 | * A factory method to determine if throttling should be skipped. 68 | * This can be based on the incoming context, or something like an env value. 69 | */ 70 | skipIf?: (context: ExecutionContext) => boolean; 71 | /** 72 | * A method to override the default tracker string. 73 | */ 74 | getTracker?: ThrottlerGetTrackerFunction; 75 | /** 76 | * A method to override the default key generator. 77 | */ 78 | generateKey?: ThrottlerGenerateKeyFunction; 79 | /** 80 | * An optional message to override the default error message. 81 | */ 82 | errorMessage?: 83 | | string 84 | | ((context: ExecutionContext, throttlerLimitDetail: ThrottlerLimitDetail) => string); 85 | 86 | /** 87 | * The storage class to use where all the record will be stored in. 88 | */ 89 | storage?: ThrottlerStorage; 90 | /** 91 | * The named throttlers to use 92 | */ 93 | throttlers: Array; 94 | }; 95 | 96 | /** 97 | * @publicApi 98 | */ 99 | export interface ThrottlerOptionsFactory { 100 | createThrottlerOptions(): Promise | ThrottlerModuleOptions; 101 | } 102 | 103 | /** 104 | * @publicApi 105 | */ 106 | export interface ThrottlerAsyncOptions extends Pick { 107 | /** 108 | * The `useExisting` syntax allows you to create aliases for existing providers. 109 | */ 110 | useExisting?: Type; 111 | /** 112 | * The `useClass` syntax allows you to dynamically determine a class 113 | * that a token should resolve to. 114 | */ 115 | useClass?: Type; 116 | /** 117 | * The `useFactory` syntax allows for creating providers dynamically. 118 | */ 119 | useFactory?: (...args: any[]) => Promise | ThrottlerModuleOptions; 120 | /** 121 | * Optional list of providers to be injected into the context of the Factory function. 122 | */ 123 | inject?: any[]; 124 | } 125 | 126 | /** 127 | * @publicApi 128 | */ 129 | export type ThrottlerGetTrackerFunction = ( 130 | req: Record, 131 | context: ExecutionContext, 132 | ) => Promise | string; 133 | 134 | /** 135 | * @publicApi 136 | */ 137 | export type ThrottlerGenerateKeyFunction = ( 138 | context: ExecutionContext, 139 | trackerString: string, 140 | throttlerName: string, 141 | ) => string; 142 | -------------------------------------------------------------------------------- /src/throttler-storage-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ThrottlerStorageOptions { 2 | /** 3 | * Amount of requests done by a specific user (partially based on IP). 4 | */ 5 | totalHits: Map; 6 | 7 | /** 8 | * Unix timestamp in milliseconds that indicates `ttl` lifetime. 9 | */ 10 | expiresAt: number; 11 | 12 | /** 13 | * Define whether the request is blocked or not. 14 | */ 15 | isBlocked: boolean; 16 | 17 | /** 18 | * Unix timestamp in milliseconds when the `totalHits` expire. 19 | */ 20 | blockExpiresAt: number; 21 | } 22 | 23 | export const ThrottlerStorageOptions = Symbol('ThrottlerStorageOptions'); 24 | -------------------------------------------------------------------------------- /src/throttler-storage-record.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ThrottlerStorageRecord { 2 | /** 3 | * Amount of requests done by a specific user (partially based on IP). 4 | */ 5 | totalHits: number; 6 | 7 | /** 8 | * Amount of seconds when the `ttl` should expire. 9 | */ 10 | timeToExpire: number; 11 | 12 | /** 13 | * Define whether the request is blocked or not. 14 | */ 15 | isBlocked: boolean; 16 | 17 | /** 18 | * Amount of seconds when the `totalHits` should expire. 19 | */ 20 | timeToBlockExpire: number; 21 | } 22 | 23 | export const ThrottlerStorageRecord = Symbol('ThrottlerStorageRecord'); 24 | -------------------------------------------------------------------------------- /src/throttler-storage.interface.ts: -------------------------------------------------------------------------------- 1 | import { ThrottlerStorageRecord } from './throttler-storage-record.interface'; 2 | 3 | export interface ThrottlerStorage { 4 | /** 5 | * Increment the amount of requests for a given record. The record will 6 | * automatically be removed from the storage once its TTL has been reached. 7 | */ 8 | increment( 9 | key: string, 10 | ttl: number, 11 | limit: number, 12 | blockDuration: number, 13 | throttlerName: string, 14 | ): Promise; 15 | } 16 | 17 | export const ThrottlerStorage = Symbol('ThrottlerStorage'); 18 | -------------------------------------------------------------------------------- /src/throttler.constants.ts: -------------------------------------------------------------------------------- 1 | export const THROTTLER_LIMIT = 'THROTTLER:LIMIT'; 2 | export const THROTTLER_TTL = 'THROTTLER:TTL'; 3 | export const THROTTLER_TRACKER = 'THROTTLER:TRACKER'; 4 | export const THROTTLER_BLOCK_DURATION = 'THROTTLER:BLOCK_DURATION'; 5 | export const THROTTLER_KEY_GENERATOR = 'THROTTLER:KEY_GENERATOR'; 6 | export const THROTTLER_OPTIONS = 'THROTTLER:MODULE_OPTIONS'; 7 | export const THROTTLER_SKIP = 'THROTTLER:SKIP'; 8 | -------------------------------------------------------------------------------- /src/throttler.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from '@nestjs/common'; 2 | import { 3 | Resolvable, 4 | ThrottlerGenerateKeyFunction, 5 | ThrottlerGetTrackerFunction, 6 | } from './throttler-module-options.interface'; 7 | import { 8 | THROTTLER_BLOCK_DURATION, 9 | THROTTLER_KEY_GENERATOR, 10 | THROTTLER_LIMIT, 11 | THROTTLER_SKIP, 12 | THROTTLER_TRACKER, 13 | THROTTLER_TTL, 14 | } from './throttler.constants'; 15 | import { getOptionsToken, getStorageToken } from './throttler.providers'; 16 | 17 | interface ThrottlerMethodOrControllerOptions { 18 | limit?: Resolvable; 19 | ttl?: Resolvable; 20 | blockDuration?: Resolvable; 21 | getTracker?: ThrottlerGetTrackerFunction; 22 | generateKey?: ThrottlerGenerateKeyFunction; 23 | } 24 | 25 | function setThrottlerMetadata( 26 | target: any, 27 | options: Record, 28 | ): void { 29 | for (const name in options) { 30 | Reflect.defineMetadata(THROTTLER_TTL + name, options[name].ttl, target); 31 | Reflect.defineMetadata(THROTTLER_LIMIT + name, options[name].limit, target); 32 | Reflect.defineMetadata(THROTTLER_BLOCK_DURATION + name, options[name].blockDuration, target); 33 | Reflect.defineMetadata(THROTTLER_TRACKER + name, options[name].getTracker, target); 34 | Reflect.defineMetadata(THROTTLER_KEY_GENERATOR + name, options[name].generateKey, target); 35 | } 36 | } 37 | 38 | /** 39 | * Adds metadata to the target which will be handled by the ThrottlerGuard to 40 | * handle incoming requests based on the given metadata. 41 | * @example @Throttle({ default: { limit: 2, ttl: 10 }}) 42 | * @example @Throttle({default: { limit: () => 20, ttl: () => 60 }}) 43 | * @publicApi 44 | */ 45 | export const Throttle = ( 46 | options: Record, 47 | ): MethodDecorator & ClassDecorator => { 48 | return ( 49 | target: any, 50 | propertyKey?: string | symbol, 51 | descriptor?: TypedPropertyDescriptor, 52 | ) => { 53 | if (descriptor) { 54 | setThrottlerMetadata(descriptor.value, options); 55 | return descriptor; 56 | } 57 | setThrottlerMetadata(target, options); 58 | return target; 59 | }; 60 | }; 61 | 62 | /** 63 | * Adds metadata to the target which will be handled by the ThrottlerGuard 64 | * whether or not to skip throttling for this context. 65 | * @example @SkipThrottle() 66 | * @example @SkipThrottle(false) 67 | * @publicApi 68 | */ 69 | export const SkipThrottle = ( 70 | skip: Record = { default: true }, 71 | ): MethodDecorator & ClassDecorator => { 72 | return ( 73 | target: any, 74 | propertyKey?: string | symbol, 75 | descriptor?: TypedPropertyDescriptor, 76 | ) => { 77 | const reflectionTarget = descriptor?.value ?? target; 78 | for (const key in skip) { 79 | Reflect.defineMetadata(THROTTLER_SKIP + key, skip[key], reflectionTarget); 80 | } 81 | return descriptor ?? target; 82 | }; 83 | }; 84 | 85 | /** 86 | * Sets the proper injection token for the `THROTTLER_OPTIONS` 87 | * @example @InjectThrottlerOptions() 88 | * @publicApi 89 | */ 90 | export const InjectThrottlerOptions = () => Inject(getOptionsToken()); 91 | 92 | /** 93 | * Sets the proper injection token for the `ThrottlerStorage` 94 | * @example @InjectThrottlerStorage() 95 | * @publicApi 96 | */ 97 | export const InjectThrottlerStorage = () => Inject(getStorageToken()); 98 | -------------------------------------------------------------------------------- /src/throttler.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | export const throttlerMessage = 'ThrottlerException: Too Many Requests'; 4 | 5 | /** 6 | * Throws a HttpException with a 429 status code, indicating that too many 7 | * requests were being fired within a certain time window. 8 | * @publicApi 9 | */ 10 | export class ThrottlerException extends HttpException { 11 | constructor(message?: string) { 12 | super(`${message || throttlerMessage}`, HttpStatus.TOO_MANY_REQUESTS); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/throttler.guard.interface.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { ThrottlerStorageRecord } from './throttler-storage-record.interface'; 3 | import { 4 | ThrottlerGenerateKeyFunction, 5 | ThrottlerGetTrackerFunction, 6 | ThrottlerOptions, 7 | } from './throttler-module-options.interface'; 8 | 9 | /** 10 | * Interface describing the details of a rate limit applied by the ThrottlerGuard. 11 | */ 12 | export interface ThrottlerLimitDetail extends ThrottlerStorageRecord { 13 | /** 14 | * Time to live for the rate limit, in seconds. After this time has elapsed, the rate limit is removed. 15 | */ 16 | ttl: number; 17 | 18 | /** 19 | * Maximum number of requests allowed within the time period defined by `ttl`. 20 | */ 21 | limit: number; 22 | 23 | /** 24 | * Unique identifier for the rate limit. This field is used to group requests that share the same rate limit. 25 | */ 26 | key: string; 27 | 28 | /** 29 | * A string representation of the tracker object used to keep track of the incoming requests and apply the rate limit. 30 | */ 31 | tracker: string; 32 | } 33 | 34 | export interface ThrottlerRequest { 35 | /** 36 | * Interface describing details about the current request pipeline. 37 | */ 38 | context: ExecutionContext; 39 | 40 | /** 41 | * The amount of requests that are allowed within the ttl's time window. 42 | */ 43 | limit: number; 44 | 45 | /** 46 | * The number of milliseconds that each request will last in storage. 47 | */ 48 | ttl: number; 49 | 50 | /** 51 | * Incoming options of the throttler. 52 | */ 53 | throttler: ThrottlerOptions; 54 | 55 | /** 56 | * The number of milliseconds the request will be blocked. 57 | */ 58 | blockDuration: number; 59 | 60 | /** 61 | * A method to override the default tracker string. 62 | */ 63 | getTracker: ThrottlerGetTrackerFunction; 64 | 65 | /** 66 | * A method to override the default key generator. 67 | */ 68 | generateKey: ThrottlerGenerateKeyFunction; 69 | } 70 | -------------------------------------------------------------------------------- /src/throttler.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { Test } from '@nestjs/testing'; 4 | import { ThrottlerStorageOptions } from './throttler-storage-options.interface'; 5 | import { ThrottlerStorageRecord } from './throttler-storage-record.interface'; 6 | import { ThrottlerStorage } from './throttler-storage.interface'; 7 | import { THROTTLER_OPTIONS } from './throttler.constants'; 8 | import { ThrottlerException } from './throttler.exception'; 9 | import { ThrottlerGuard } from './throttler.guard'; 10 | 11 | class ThrottlerStorageServiceMock implements ThrottlerStorage { 12 | private _storage: Map = new Map(); 13 | get storage(): Map { 14 | return this._storage; 15 | } 16 | 17 | private getExpirationTime(key: string): number { 18 | return Math.floor((this.storage[key].expiresAt - Date.now()) / 1000); 19 | } 20 | 21 | private getBlockExpirationTime(key: string): number { 22 | return Math.floor((this.storage[key].blockExpiresAt - Date.now()) / 1000); 23 | } 24 | 25 | private fireHitCount(key: string, throttlerName: string) { 26 | this.storage[key].totalHits[throttlerName]++; 27 | } 28 | 29 | async increment( 30 | key: string, 31 | ttl: number, 32 | limit: number, 33 | blockDuration: number, 34 | throttlerName: string, 35 | ): Promise { 36 | const ttlMilliseconds = ttl; 37 | const blockDurationMilliseconds = blockDuration; 38 | if (!this.storage[key]) { 39 | this.storage[key] = { 40 | totalHits: { 41 | [throttlerName]: 0, 42 | }, 43 | expiresAt: Date.now() + ttlMilliseconds, 44 | blockExpiresAt: 0, 45 | isBlocked: false, 46 | }; 47 | } 48 | 49 | let timeToExpire = this.getExpirationTime(key); 50 | 51 | // Reset the `expiresAt` once it has been expired. 52 | if (timeToExpire <= 0) { 53 | this.storage[key].expiresAt = Date.now() + ttlMilliseconds; 54 | timeToExpire = this.getExpirationTime(key); 55 | } 56 | 57 | if (!this.storage[key].isBlocked) { 58 | this.fireHitCount(key, throttlerName); 59 | } 60 | 61 | // Reset the blockExpiresAt once it gets blocked 62 | if (this.storage[key].totalHits[throttlerName] > limit && !this.storage[key].isBlocked) { 63 | this.storage[key].isBlocked = true; 64 | this.storage[key].blockExpiresAt = Date.now() + blockDurationMilliseconds; 65 | } 66 | 67 | const timeToBlockExpire = this.getBlockExpirationTime(key); 68 | 69 | if (timeToBlockExpire <= 0 && this.storage[key].isBlocked) { 70 | this.fireHitCount(key, throttlerName); 71 | } 72 | 73 | return { 74 | totalHits: this.storage[key].totalHits[throttlerName], 75 | timeToExpire, 76 | isBlocked: this.storage[key].isBlocked, 77 | timeToBlockExpire: timeToBlockExpire, 78 | }; 79 | } 80 | } 81 | 82 | function contextMockFactory( 83 | type: 'http' | 'ws' | 'graphql', 84 | handler: () => any, 85 | mockFunc: Record, 86 | ): ExecutionContext { 87 | const executionPartial: Partial = { 88 | getClass: () => ThrottlerStorageServiceMock as any, 89 | getHandler: () => handler, 90 | switchToRpc: () => ({ 91 | getContext: () => ({}) as any, 92 | getData: () => ({}) as any, 93 | }), 94 | getArgs: () => [] as any, 95 | getArgByIndex: () => ({}) as any, 96 | getType: () => type as any, 97 | }; 98 | switch (type) { 99 | case 'ws': 100 | executionPartial.switchToHttp = () => ({}) as any; 101 | executionPartial.switchToWs = () => mockFunc as any; 102 | break; 103 | case 'http': 104 | executionPartial.switchToWs = () => ({}) as any; 105 | executionPartial.switchToHttp = () => mockFunc as any; 106 | break; 107 | case 'graphql': 108 | executionPartial.switchToWs = () => ({}) as any; 109 | executionPartial.switchToHttp = () => 110 | ({ 111 | getNext: () => ({}) as any, 112 | }) as any; 113 | executionPartial.getArgByIndex = () => mockFunc as any; 114 | break; 115 | } 116 | return executionPartial as ExecutionContext; 117 | } 118 | 119 | describe('ThrottlerGuard', () => { 120 | let guard: ThrottlerGuard; 121 | let reflector: Reflector; 122 | let service: ThrottlerStorageServiceMock; 123 | let handler: () => any; 124 | 125 | beforeEach(async () => { 126 | const modRef = await Test.createTestingModule({ 127 | providers: [ 128 | ThrottlerGuard, 129 | { 130 | provide: THROTTLER_OPTIONS, 131 | useValue: [ 132 | { 133 | limit: 5, 134 | ttl: 60, 135 | ignoreUserAgents: [/userAgentIgnore/], 136 | }, 137 | ], 138 | }, 139 | { 140 | provide: ThrottlerStorage, 141 | useClass: ThrottlerStorageServiceMock, 142 | }, 143 | { 144 | provide: Reflector, 145 | useValue: { 146 | getAllAndOverride: jest.fn(), 147 | }, 148 | }, 149 | ], 150 | }).compile(); 151 | guard = modRef.get(ThrottlerGuard); 152 | await guard.onModuleInit(); 153 | reflector = modRef.get(Reflector); 154 | service = modRef.get(ThrottlerStorage); 155 | }); 156 | 157 | it('should have all of the providers defined', () => { 158 | expect(guard).toBeDefined(); 159 | expect(reflector).toBeDefined(); 160 | expect(service).toBeDefined(); 161 | }); 162 | describe('HTTP Context', () => { 163 | let reqMock; 164 | let resMock; 165 | let headerSettingMock: jest.Mock; 166 | 167 | beforeEach(() => { 168 | headerSettingMock = jest.fn(); 169 | resMock = { 170 | header: headerSettingMock, 171 | }; 172 | reqMock = { 173 | headers: {}, 174 | }; 175 | }); 176 | afterEach(() => { 177 | headerSettingMock.mockClear(); 178 | }); 179 | it('should add headers to the res', async () => { 180 | handler = function addHeaders() { 181 | return 'string'; 182 | }; 183 | const ctxMock = contextMockFactory('http', handler, { 184 | getResponse: () => resMock, 185 | getRequest: () => reqMock, 186 | }); 187 | const canActivate = await guard.canActivate(ctxMock); 188 | expect(canActivate).toBe(true); 189 | expect(headerSettingMock).toBeCalledTimes(3); 190 | expect(headerSettingMock).toHaveBeenNthCalledWith(1, 'X-RateLimit-Limit', 5); 191 | expect(headerSettingMock).toHaveBeenNthCalledWith(2, 'X-RateLimit-Remaining', 4); 192 | expect(headerSettingMock).toHaveBeenNthCalledWith(3, 'X-RateLimit-Reset', expect.any(Number)); 193 | }); 194 | it('should return an error after passing the limit', async () => { 195 | handler = function returnError() { 196 | return 'string'; 197 | }; 198 | const ctxMock = contextMockFactory('http', handler, { 199 | getResponse: () => resMock, 200 | getRequest: () => reqMock, 201 | }); 202 | for (let i = 0; i < 5; i++) { 203 | await guard.canActivate(ctxMock); 204 | } 205 | await expect(guard.canActivate(ctxMock)).rejects.toThrowError(ThrottlerException); 206 | expect(headerSettingMock).toBeCalledTimes(16); 207 | expect(headerSettingMock).toHaveBeenLastCalledWith('Retry-After', expect.any(Number)); 208 | }); 209 | it('should pull values from the reflector instead of options', async () => { 210 | handler = function useReflector() { 211 | return 'string'; 212 | }; 213 | reflector.getAllAndOverride = jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(2); 214 | const ctxMock = contextMockFactory('http', handler, { 215 | getResponse: () => resMock, 216 | getRequest: () => reqMock, 217 | }); 218 | const canActivate = await guard.canActivate(ctxMock); 219 | expect(canActivate).toBe(true); 220 | expect(headerSettingMock).toBeCalledTimes(3); 221 | expect(headerSettingMock).toHaveBeenNthCalledWith(1, 'X-RateLimit-Limit', 2); 222 | expect(headerSettingMock).toHaveBeenNthCalledWith(2, 'X-RateLimit-Remaining', 1); 223 | expect(headerSettingMock).toHaveBeenNthCalledWith(3, 'X-RateLimit-Reset', expect.any(Number)); 224 | }); 225 | it('should skip due to the user-agent header', async () => { 226 | handler = function userAgentSkip() { 227 | return 'string'; 228 | }; 229 | reqMock['headers'] = { 230 | 'user-agent': 'userAgentIgnore', 231 | }; 232 | const ctxMock = contextMockFactory('http', handler, { 233 | getResponse: () => resMock, 234 | getRequest: () => reqMock, 235 | }); 236 | const canActivate = await guard.canActivate(ctxMock); 237 | expect(canActivate).toBe(true); 238 | expect(headerSettingMock).toBeCalledTimes(0); 239 | }); 240 | it('should accept callback options for ttl and limit', async () => { 241 | const modRef = await Test.createTestingModule({ 242 | providers: [ 243 | ThrottlerGuard, 244 | { 245 | provide: THROTTLER_OPTIONS, 246 | useValue: [ 247 | { 248 | limit: () => 5, 249 | ttl: () => 60, 250 | ignoreUserAgents: [/userAgentIgnore/], 251 | }, 252 | ], 253 | }, 254 | { 255 | provide: ThrottlerStorage, 256 | useClass: ThrottlerStorageServiceMock, 257 | }, 258 | { 259 | provide: Reflector, 260 | useValue: { 261 | getAllAndOverride: jest.fn(), 262 | }, 263 | }, 264 | ], 265 | }).compile(); 266 | const guard = modRef.get(ThrottlerGuard); 267 | await guard.onModuleInit(); 268 | handler = function addHeaders() { 269 | return 'string'; 270 | }; 271 | const ctxMock = contextMockFactory('http', handler, { 272 | getResponse: () => resMock, 273 | getRequest: () => reqMock, 274 | }); 275 | const canActivate = await guard.canActivate(ctxMock); 276 | expect(canActivate).toBe(true); 277 | expect(headerSettingMock).toBeCalledTimes(3); 278 | expect(headerSettingMock).toHaveBeenNthCalledWith(1, 'X-RateLimit-Limit', 5); 279 | expect(headerSettingMock).toHaveBeenNthCalledWith(2, 'X-RateLimit-Remaining', 4); 280 | expect(headerSettingMock).toHaveBeenNthCalledWith(3, 'X-RateLimit-Reset', expect.any(Number)); 281 | }); 282 | }); 283 | }); 284 | -------------------------------------------------------------------------------- /src/throttler.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { sha256 } from './hash'; 4 | import { 5 | Resolvable, 6 | ThrottlerGenerateKeyFunction, 7 | ThrottlerGetTrackerFunction, 8 | ThrottlerModuleOptions, 9 | ThrottlerOptions, 10 | } from './throttler-module-options.interface'; 11 | import { ThrottlerStorage } from './throttler-storage.interface'; 12 | import { 13 | THROTTLER_BLOCK_DURATION, 14 | THROTTLER_KEY_GENERATOR, 15 | THROTTLER_LIMIT, 16 | THROTTLER_SKIP, 17 | THROTTLER_TRACKER, 18 | THROTTLER_TTL, 19 | } from './throttler.constants'; 20 | import { InjectThrottlerOptions, InjectThrottlerStorage } from './throttler.decorator'; 21 | import { ThrottlerException, throttlerMessage } from './throttler.exception'; 22 | import { ThrottlerLimitDetail, ThrottlerRequest } from './throttler.guard.interface'; 23 | 24 | /** 25 | * @publicApi 26 | */ 27 | @Injectable() 28 | export class ThrottlerGuard implements CanActivate { 29 | protected headerPrefix = 'X-RateLimit'; 30 | protected errorMessage = throttlerMessage; 31 | protected throttlers: Array; 32 | protected commonOptions: Pick< 33 | ThrottlerOptions, 34 | 'skipIf' | 'ignoreUserAgents' | 'getTracker' | 'generateKey' 35 | >; 36 | 37 | constructor( 38 | @InjectThrottlerOptions() protected readonly options: ThrottlerModuleOptions, 39 | @InjectThrottlerStorage() protected readonly storageService: ThrottlerStorage, 40 | protected readonly reflector: Reflector, 41 | ) {} 42 | 43 | async onModuleInit() { 44 | this.throttlers = (Array.isArray(this.options) ? this.options : this.options.throttlers) 45 | .sort((first, second) => { 46 | if (typeof first.ttl === 'function') { 47 | return 1; 48 | } 49 | if (typeof second.ttl === 'function') { 50 | return 0; 51 | } 52 | return first.ttl - second.ttl; 53 | }) 54 | .map((opt) => ({ ...opt, name: opt.name ?? 'default' })); 55 | if (Array.isArray(this.options)) { 56 | this.commonOptions = {}; 57 | } else { 58 | this.commonOptions = { 59 | skipIf: this.options.skipIf, 60 | ignoreUserAgents: this.options.ignoreUserAgents, 61 | getTracker: this.options.getTracker, 62 | generateKey: this.options.generateKey, 63 | }; 64 | } 65 | this.commonOptions.getTracker ??= this.getTracker.bind(this); 66 | this.commonOptions.generateKey ??= this.generateKey.bind(this); 67 | } 68 | 69 | /** 70 | * Throttle requests against their TTL limit and whether to allow or deny it. 71 | * Based on the context type different handlers will be called. 72 | * @throws {ThrottlerException} 73 | */ 74 | async canActivate(context: ExecutionContext): Promise { 75 | const handler = context.getHandler(); 76 | const classRef = context.getClass(); 77 | 78 | if (await this.shouldSkip(context)) { 79 | return true; 80 | } 81 | const continues: boolean[] = []; 82 | 83 | for (const namedThrottler of this.throttlers) { 84 | // Return early if the current route should be skipped. 85 | const skip = this.reflector.getAllAndOverride(THROTTLER_SKIP + namedThrottler.name, [ 86 | handler, 87 | classRef, 88 | ]); 89 | const skipIf = namedThrottler.skipIf || this.commonOptions.skipIf; 90 | if (skip || skipIf?.(context)) { 91 | continues.push(true); 92 | continue; 93 | } 94 | 95 | // Return early when we have no limit or ttl data. 96 | const routeOrClassLimit = this.reflector.getAllAndOverride>( 97 | THROTTLER_LIMIT + namedThrottler.name, 98 | [handler, classRef], 99 | ); 100 | const routeOrClassTtl = this.reflector.getAllAndOverride>( 101 | THROTTLER_TTL + namedThrottler.name, 102 | [handler, classRef], 103 | ); 104 | const routeOrClassBlockDuration = this.reflector.getAllAndOverride>( 105 | THROTTLER_BLOCK_DURATION + namedThrottler.name, 106 | [handler, classRef], 107 | ); 108 | const routeOrClassGetTracker = this.reflector.getAllAndOverride( 109 | THROTTLER_TRACKER + namedThrottler.name, 110 | [handler, classRef], 111 | ); 112 | const routeOrClassGetKeyGenerator = 113 | this.reflector.getAllAndOverride( 114 | THROTTLER_KEY_GENERATOR + namedThrottler.name, 115 | [handler, classRef], 116 | ); 117 | 118 | // Check if specific limits are set at class or route level, otherwise use global options. 119 | const limit = await this.resolveValue(context, routeOrClassLimit || namedThrottler.limit); 120 | const ttl = await this.resolveValue(context, routeOrClassTtl || namedThrottler.ttl); 121 | const blockDuration = await this.resolveValue( 122 | context, 123 | routeOrClassBlockDuration || namedThrottler.blockDuration || ttl, 124 | ); 125 | const getTracker = 126 | routeOrClassGetTracker || namedThrottler.getTracker || this.commonOptions.getTracker; 127 | const generateKey = 128 | routeOrClassGetKeyGenerator || namedThrottler.generateKey || this.commonOptions.generateKey; 129 | continues.push( 130 | await this.handleRequest({ 131 | context, 132 | limit, 133 | ttl, 134 | throttler: namedThrottler, 135 | blockDuration, 136 | getTracker, 137 | generateKey, 138 | }), 139 | ); 140 | } 141 | return continues.every((cont) => cont); 142 | } 143 | 144 | protected async shouldSkip(_context: ExecutionContext): Promise { 145 | return false; 146 | } 147 | 148 | /** 149 | * Throttles incoming HTTP requests. 150 | * All the outgoing requests will contain RFC-compatible RateLimit headers. 151 | * @see https://tools.ietf.org/id/draft-polli-ratelimit-headers-00.html#header-specifications 152 | * @throws {ThrottlerException} 153 | */ 154 | protected async handleRequest(requestProps: ThrottlerRequest): Promise { 155 | const { context, limit, ttl, throttler, blockDuration, getTracker, generateKey } = requestProps; 156 | 157 | // Here we start to check the amount of requests being done against the ttl. 158 | const { req, res } = this.getRequestResponse(context); 159 | const ignoreUserAgents = throttler.ignoreUserAgents ?? this.commonOptions.ignoreUserAgents; 160 | // Return early if the current user agent should be ignored. 161 | if (Array.isArray(ignoreUserAgents)) { 162 | for (const pattern of ignoreUserAgents) { 163 | if (pattern.test(req.headers['user-agent'])) { 164 | return true; 165 | } 166 | } 167 | } 168 | const tracker = await getTracker(req, context); 169 | const key = generateKey(context, tracker, throttler.name); 170 | const { totalHits, timeToExpire, isBlocked, timeToBlockExpire } = 171 | await this.storageService.increment(key, ttl, limit, blockDuration, throttler.name); 172 | 173 | const getThrottlerSuffix = (name: string) => (name === 'default' ? '' : `-${name}`); 174 | 175 | // Throw an error when the user reached their limit. 176 | if (isBlocked) { 177 | res.header(`Retry-After${getThrottlerSuffix(throttler.name)}`, timeToBlockExpire); 178 | await this.throwThrottlingException(context, { 179 | limit, 180 | ttl, 181 | key, 182 | tracker, 183 | totalHits, 184 | timeToExpire, 185 | isBlocked, 186 | timeToBlockExpire, 187 | }); 188 | } 189 | 190 | res.header(`${this.headerPrefix}-Limit${getThrottlerSuffix(throttler.name)}`, limit); 191 | // We're about to add a record so we need to take that into account here. 192 | // Otherwise the header says we have a request left when there are none. 193 | res.header( 194 | `${this.headerPrefix}-Remaining${getThrottlerSuffix(throttler.name)}`, 195 | Math.max(0, limit - totalHits), 196 | ); 197 | res.header(`${this.headerPrefix}-Reset${getThrottlerSuffix(throttler.name)}`, timeToExpire); 198 | 199 | return true; 200 | } 201 | 202 | protected async getTracker(req: Record): Promise { 203 | return req.ip; 204 | } 205 | 206 | protected getRequestResponse(context: ExecutionContext): { 207 | req: Record; 208 | res: Record; 209 | } { 210 | const http = context.switchToHttp(); 211 | return { req: http.getRequest(), res: http.getResponse() }; 212 | } 213 | 214 | /** 215 | * Generate a hashed key that will be used as a storage key. 216 | * The key will always be a combination of the current context and IP. 217 | */ 218 | protected generateKey(context: ExecutionContext, suffix: string, name: string): string { 219 | const prefix = `${context.getClass().name}-${context.getHandler().name}-${name}`; 220 | return sha256(`${prefix}-${suffix}`); 221 | } 222 | 223 | /** 224 | * Throws an exception for the event that the rate limit has been exceeded. 225 | * 226 | * The context parameter allows to access the context when overwriting 227 | * the method. 228 | * @throws {ThrottlerException} 229 | */ 230 | protected async throwThrottlingException( 231 | context: ExecutionContext, 232 | throttlerLimitDetail: ThrottlerLimitDetail, 233 | ): Promise { 234 | throw new ThrottlerException(await this.getErrorMessage(context, throttlerLimitDetail)); 235 | } 236 | 237 | protected async getErrorMessage( 238 | context: ExecutionContext, 239 | throttlerLimitDetail: ThrottlerLimitDetail, 240 | ): Promise { 241 | if (!Array.isArray(this.options)) { 242 | if (!this.options.errorMessage) return this.errorMessage; 243 | 244 | return typeof this.options.errorMessage === 'function' 245 | ? this.options.errorMessage(context, throttlerLimitDetail) 246 | : this.options.errorMessage; 247 | } 248 | return this.errorMessage; 249 | } 250 | 251 | private async resolveValue( 252 | context: ExecutionContext, 253 | resolvableValue: Resolvable, 254 | ): Promise { 255 | return typeof resolvableValue === 'function' ? resolvableValue(context) : resolvableValue; 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/throttler.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, Provider, Global } from '@nestjs/common'; 2 | import { 3 | ThrottlerModuleOptions, 4 | ThrottlerAsyncOptions, 5 | ThrottlerOptionsFactory, 6 | } from './throttler-module-options.interface'; 7 | import { THROTTLER_OPTIONS } from './throttler.constants'; 8 | import { createThrottlerProviders, ThrottlerStorageProvider } from './throttler.providers'; 9 | 10 | /** 11 | * @publicApi 12 | */ 13 | @Global() 14 | @Module({}) 15 | export class ThrottlerModule { 16 | /** 17 | * Register the module synchronously. 18 | */ 19 | static forRoot(options: ThrottlerModuleOptions = []): DynamicModule { 20 | const providers = [...createThrottlerProviders(options), ThrottlerStorageProvider]; 21 | return { 22 | module: ThrottlerModule, 23 | providers, 24 | exports: providers, 25 | }; 26 | } 27 | 28 | /** 29 | * Register the module asynchronously. 30 | */ 31 | static forRootAsync(options: ThrottlerAsyncOptions): DynamicModule { 32 | const providers = [...this.createAsyncProviders(options), ThrottlerStorageProvider]; 33 | return { 34 | module: ThrottlerModule, 35 | imports: options.imports || [], 36 | providers, 37 | exports: providers, 38 | }; 39 | } 40 | 41 | private static createAsyncProviders(options: ThrottlerAsyncOptions): Provider[] { 42 | if (options.useExisting || options.useFactory) { 43 | return [this.createAsyncOptionsProvider(options)]; 44 | } 45 | return [ 46 | this.createAsyncOptionsProvider(options), 47 | { 48 | provide: options.useClass, 49 | useClass: options.useClass, 50 | }, 51 | ]; 52 | } 53 | 54 | private static createAsyncOptionsProvider(options: ThrottlerAsyncOptions): Provider { 55 | if (options.useFactory) { 56 | return { 57 | provide: THROTTLER_OPTIONS, 58 | useFactory: options.useFactory, 59 | inject: options.inject || [], 60 | }; 61 | } 62 | return { 63 | provide: THROTTLER_OPTIONS, 64 | useFactory: async (optionsFactory: ThrottlerOptionsFactory) => 65 | await optionsFactory.createThrottlerOptions(), 66 | inject: [options.useExisting || options.useClass], 67 | }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/throttler.providers.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@nestjs/common'; 2 | import { ThrottlerModuleOptions } from './throttler-module-options.interface'; 3 | import { ThrottlerStorage } from './throttler-storage.interface'; 4 | import { THROTTLER_OPTIONS } from './throttler.constants'; 5 | import { ThrottlerStorageService } from './throttler.service'; 6 | 7 | export function createThrottlerProviders(options: ThrottlerModuleOptions): Provider[] { 8 | return [ 9 | { 10 | provide: THROTTLER_OPTIONS, 11 | useValue: options, 12 | }, 13 | ]; 14 | } 15 | 16 | export const ThrottlerStorageProvider = { 17 | provide: ThrottlerStorage, 18 | useFactory: (options: ThrottlerModuleOptions) => { 19 | return !Array.isArray(options) && options.storage 20 | ? options.storage 21 | : new ThrottlerStorageService(); 22 | }, 23 | inject: [THROTTLER_OPTIONS], 24 | }; 25 | 26 | /** 27 | * A utility function for getting the options injection token 28 | * @publicApi 29 | */ 30 | export const getOptionsToken = () => THROTTLER_OPTIONS; 31 | 32 | /** 33 | * A utility function for getting the storage injection token 34 | * @publicApi 35 | */ 36 | export const getStorageToken = () => ThrottlerStorage; 37 | -------------------------------------------------------------------------------- /src/throttler.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnApplicationShutdown } from '@nestjs/common'; 2 | import { ThrottlerStorageOptions } from './throttler-storage-options.interface'; 3 | import { ThrottlerStorageRecord } from './throttler-storage-record.interface'; 4 | import { ThrottlerStorage } from './throttler-storage.interface'; 5 | 6 | /** 7 | * @publicApi 8 | */ 9 | @Injectable() 10 | export class ThrottlerStorageService implements ThrottlerStorage, OnApplicationShutdown { 11 | private _storage: Map = new Map(); 12 | private timeoutIds: Map = new Map(); 13 | 14 | get storage(): Map { 15 | return this._storage; 16 | } 17 | 18 | /** 19 | * Get the expiration time in seconds from a single record. 20 | */ 21 | private getExpirationTime(key: string): number { 22 | return Math.ceil((this.storage.get(key).expiresAt - Date.now()) / 1000); 23 | } 24 | 25 | /** 26 | * Get the block expiration time in seconds from a single record. 27 | */ 28 | private getBlockExpirationTime(key: string): number { 29 | return Math.ceil((this.storage.get(key).blockExpiresAt - Date.now()) / 1000); 30 | } 31 | 32 | /** 33 | * Set the expiration time for a given key. 34 | */ 35 | private setExpirationTime(key: string, ttlMilliseconds: number, throttlerName: string): void { 36 | const timeoutId = setTimeout(() => { 37 | const { totalHits } = this.storage.get(key); 38 | totalHits.set(throttlerName, totalHits.get(throttlerName) - 1); 39 | clearTimeout(timeoutId); 40 | this.timeoutIds.set( 41 | throttlerName, 42 | this.timeoutIds.get(throttlerName).filter((id) => id !== timeoutId), 43 | ); 44 | }, ttlMilliseconds); 45 | this.timeoutIds.get(throttlerName).push(timeoutId); 46 | } 47 | 48 | /** 49 | * Clear the expiration time related to the throttle 50 | */ 51 | private clearExpirationTimes(throttlerName: string) { 52 | this.timeoutIds.get(throttlerName).forEach(clearTimeout); 53 | this.timeoutIds.set(throttlerName, []); 54 | } 55 | 56 | /** 57 | * Reset the request blockage 58 | */ 59 | private resetBlockdRequest(key: string, throttlerName: string) { 60 | this.storage.get(key).isBlocked = false; 61 | this.storage.get(key).totalHits.set(throttlerName, 0); 62 | this.clearExpirationTimes(throttlerName); 63 | } 64 | 65 | /** 66 | * Increase the `totalHit` count and sent it to decrease queue 67 | */ 68 | private fireHitCount(key: string, throttlerName: string, ttl: number) { 69 | const { totalHits } = this.storage.get(key); 70 | totalHits.set(throttlerName, totalHits.get(throttlerName) + 1); 71 | this.setExpirationTime(key, ttl, throttlerName); 72 | } 73 | 74 | async increment( 75 | key: string, 76 | ttl: number, 77 | limit: number, 78 | blockDuration: number, 79 | throttlerName: string, 80 | ): Promise { 81 | const ttlMilliseconds = ttl; 82 | const blockDurationMilliseconds = blockDuration; 83 | 84 | if (!this.timeoutIds.has(throttlerName)) { 85 | this.timeoutIds.set(throttlerName, []); 86 | } 87 | 88 | if (!this.storage.has(key)) { 89 | this.storage.set(key, { 90 | totalHits: new Map([[throttlerName, 0]]), 91 | expiresAt: Date.now() + ttlMilliseconds, 92 | blockExpiresAt: 0, 93 | isBlocked: false, 94 | }); 95 | } 96 | 97 | let timeToExpire = this.getExpirationTime(key); 98 | 99 | // Reset the timeToExpire once it has been expired. 100 | if (timeToExpire <= 0) { 101 | this.storage.get(key).expiresAt = Date.now() + ttlMilliseconds; 102 | timeToExpire = this.getExpirationTime(key); 103 | } 104 | 105 | if (!this.storage.get(key).isBlocked) { 106 | this.fireHitCount(key, throttlerName, ttlMilliseconds); 107 | } 108 | 109 | // Reset the blockExpiresAt once it gets blocked 110 | if ( 111 | this.storage.get(key).totalHits.get(throttlerName) > limit && 112 | !this.storage.get(key).isBlocked 113 | ) { 114 | this.storage.get(key).isBlocked = true; 115 | this.storage.get(key).blockExpiresAt = Date.now() + blockDurationMilliseconds; 116 | } 117 | 118 | const timeToBlockExpire = this.getBlockExpirationTime(key); 119 | 120 | // Reset time blocked request 121 | if (timeToBlockExpire <= 0 && this.storage.get(key).isBlocked) { 122 | this.resetBlockdRequest(key, throttlerName); 123 | this.fireHitCount(key, throttlerName, ttlMilliseconds); 124 | } 125 | 126 | return { 127 | totalHits: this.storage.get(key).totalHits.get(throttlerName), 128 | timeToExpire, 129 | isBlocked: this.storage.get(key).isBlocked, 130 | timeToBlockExpire: timeToBlockExpire, 131 | }; 132 | } 133 | 134 | onApplicationShutdown() { 135 | this.timeoutIds.forEach((timeouts) => timeouts.forEach(clearTimeout)); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/utilities.ts: -------------------------------------------------------------------------------- 1 | export const seconds = (howMany: number) => howMany * 1000; 2 | export const minutes = (howMany: number) => 60 * howMany * seconds(1); 3 | export const hours = (howMany: number) => 60 * howMany * minutes(1); 4 | export const days = (howMany: number) => 24 * howMany * hours(1); 5 | export const weeks = (howMany: number) => 7 * howMany * days(1); 6 | -------------------------------------------------------------------------------- /test/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { ThrottlerGuard } from '../../src'; 4 | import { ControllerModule } from './controllers/controller.module'; 5 | 6 | @Module({ 7 | imports: [ControllerModule], 8 | providers: [ 9 | { 10 | provide: APP_GUARD, 11 | useClass: ThrottlerGuard, 12 | }, 13 | ], 14 | }) 15 | export class AppModule {} 16 | -------------------------------------------------------------------------------- /test/app/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | success() { 6 | return { success: true }; 7 | } 8 | 9 | ignored() { 10 | return { ignored: true }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/app/controllers/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { SkipThrottle, Throttle, seconds } from '../../../src'; 3 | import { AppService } from '../app.service'; 4 | 5 | @Controller() 6 | @Throttle({ default: { limit: 2, ttl: seconds(10) } }) 7 | export class AppController { 8 | constructor(private readonly appService: AppService) {} 9 | 10 | @Get() 11 | async test() { 12 | return this.appService.success(); 13 | } 14 | 15 | @Get('ignored') 16 | @SkipThrottle() 17 | async ignored() { 18 | return this.appService.ignored(); 19 | } 20 | 21 | @Get('ignore-user-agents') 22 | async ignoreUserAgents() { 23 | return this.appService.ignored(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/app/controllers/controller.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ThrottlerModule, seconds } from '../../../src'; 3 | import { AppService } from '../app.service'; 4 | import { AppController } from './app.controller'; 5 | import { DefaultController } from './default.controller'; 6 | import { LimitController } from './limit.controller'; 7 | 8 | @Module({ 9 | imports: [ 10 | ThrottlerModule.forRoot([ 11 | { 12 | limit: 5, 13 | ttl: seconds(60), 14 | blockDuration: seconds(20), 15 | ignoreUserAgents: [/throttler-test/g], 16 | }, 17 | ]), 18 | ], 19 | controllers: [AppController, DefaultController, LimitController], 20 | providers: [AppService], 21 | }) 22 | export class ControllerModule {} 23 | -------------------------------------------------------------------------------- /test/app/controllers/default.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from '../app.service'; 3 | 4 | @Controller('default') 5 | export class DefaultController { 6 | constructor(private readonly appService: AppService) {} 7 | @Get() 8 | getDefault() { 9 | return this.appService.success(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/app/controllers/limit.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { Throttle, seconds } from '../../../src'; 3 | import { AppService } from '../app.service'; 4 | 5 | @Throttle({ default: { limit: 2, ttl: seconds(10), blockDuration: seconds(5) } }) 6 | @Controller('limit') 7 | export class LimitController { 8 | constructor(private readonly appService: AppService) {} 9 | @Get() 10 | getThrottled() { 11 | return this.appService.success(); 12 | } 13 | 14 | @Throttle({ default: { limit: 5, ttl: seconds(10), blockDuration: seconds(15) } }) 15 | @Get('higher') 16 | getHigher() { 17 | return this.appService.success(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/app/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { ExpressAdapter } from '@nestjs/platform-express'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create( 7 | AppModule, 8 | new ExpressAdapter(), 9 | // new FastifyAdapter(), 10 | ); 11 | await app.listen(3000); 12 | } 13 | bootstrap(); 14 | -------------------------------------------------------------------------------- /test/controller.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Controller, INestApplication, Post } from '@nestjs/common'; 2 | import { AbstractHttpAdapter, APP_GUARD } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 5 | import { Test, TestingModule } from '@nestjs/testing'; 6 | import { setTimeout } from 'node:timers/promises'; 7 | import { Throttle, ThrottlerGuard } from '../src'; 8 | import { THROTTLER_OPTIONS } from '../src/throttler.constants'; 9 | import { ControllerModule } from './app/controllers/controller.module'; 10 | import { httPromise } from './utility/httpromise'; 11 | 12 | jest.setTimeout(45000); 13 | 14 | describe.each` 15 | adapter | adapterName 16 | ${new ExpressAdapter()} | ${'Express'} 17 | ${new FastifyAdapter()} | ${'Fastify'} 18 | `( 19 | '$adapterName Throttler', 20 | ({ adapter }: { adapter: AbstractHttpAdapter; adapterName: string }) => { 21 | @Controller('test/throttle') 22 | class ThrottleTestController { 23 | @Throttle({ default: { limit: 1, ttl: 1000, blockDuration: 1000 } }) 24 | @Post() 25 | async testThrottle() { 26 | return { 27 | code: 'THROTTLE_TEST', 28 | }; 29 | } 30 | } 31 | let app: INestApplication; 32 | 33 | beforeAll(async () => { 34 | const moduleFixture: TestingModule = await Test.createTestingModule({ 35 | imports: [ControllerModule], 36 | controllers: [ThrottleTestController], 37 | providers: [ 38 | { 39 | provide: APP_GUARD, 40 | useClass: ThrottlerGuard, 41 | }, 42 | ], 43 | }).compile(); 44 | 45 | app = moduleFixture.createNestApplication(adapter); 46 | await app.listen(0); 47 | }); 48 | 49 | afterAll(async () => { 50 | await app.close(); 51 | }); 52 | 53 | describe('controllers', () => { 54 | let appUrl: string; 55 | beforeAll(async () => { 56 | appUrl = await app.getUrl(); 57 | }); 58 | 59 | /** 60 | * Tests for setting `@Throttle()` at the method level and for ignore routes 61 | */ 62 | describe('AppController', () => { 63 | it('GET /ignored', async () => { 64 | const response = await httPromise(appUrl + '/ignored'); 65 | expect(response.data).toEqual({ ignored: true }); 66 | expect(response.headers).not.toMatchObject({ 67 | 'x-ratelimit-limit': '2', 68 | 'x-ratelimit-remaining': '1', 69 | 'x-ratelimit-reset': /^\d+$/, 70 | }); 71 | }); 72 | it('GET /ignore-user-agents', async () => { 73 | const response = await httPromise(appUrl + '/ignore-user-agents', 'GET', { 74 | 'user-agent': 'throttler-test/0.0.0', 75 | }); 76 | expect(response.data).toEqual({ ignored: true }); 77 | expect(response.headers).not.toMatchObject({ 78 | 'x-ratelimit-limit': '2', 79 | 'x-ratelimit-remaining': '1', 80 | 'x-ratelimit-reset': /^\d+$/, 81 | }); 82 | }); 83 | it('GET /', async () => { 84 | const response = await httPromise(appUrl + '/'); 85 | expect(response.data).toEqual({ success: true }); 86 | expect(response.headers).toMatchObject({ 87 | 'x-ratelimit-limit': '2', 88 | 'x-ratelimit-remaining': '1', 89 | 'x-ratelimit-reset': /^\d+$/, 90 | }); 91 | }); 92 | }); 93 | /** 94 | * Tests for setting `@Throttle()` at the class level and overriding at the method level 95 | */ 96 | describe('LimitController', () => { 97 | it.each` 98 | method | url | limit | blockDuration 99 | ${'GET'} | ${''} | ${2} | ${5000} 100 | ${'GET'} | ${'/higher'} | ${5} | ${15000} 101 | `( 102 | '$method $url', 103 | async ({ 104 | method, 105 | url, 106 | limit, 107 | blockDuration, 108 | }: { 109 | method: 'GET'; 110 | url: string; 111 | limit: number; 112 | blockDuration: number; 113 | }) => { 114 | for (let i = 0; i < limit; i++) { 115 | const response = await httPromise(appUrl + '/limit' + url, method); 116 | expect(response.data).toEqual({ success: true }); 117 | expect(response.headers).toMatchObject({ 118 | 'x-ratelimit-limit': limit.toString(), 119 | 'x-ratelimit-remaining': (limit - (i + 1)).toString(), 120 | 'x-ratelimit-reset': /^\d+$/, 121 | }); 122 | } 123 | const errRes = await httPromise(appUrl + '/limit' + url, method); 124 | expect(errRes.data).toMatchObject({ statusCode: 429, message: /ThrottlerException/ }); 125 | expect(errRes.headers).toMatchObject({ 126 | 'retry-after': /^\d+$/, 127 | }); 128 | expect(errRes.status).toBe(429); 129 | await setTimeout(blockDuration); 130 | const response = await httPromise(appUrl + '/limit' + url, method); 131 | expect(response.data).toEqual({ success: true }); 132 | expect(response.headers).toMatchObject({ 133 | 'x-ratelimit-limit': limit.toString(), 134 | 'x-ratelimit-remaining': (limit - 1).toString(), 135 | 'x-ratelimit-reset': /^\d+$/, 136 | }); 137 | }, 138 | ); 139 | }); 140 | /** 141 | * Tests for setting throttle values at the `forRoot` level 142 | */ 143 | describe('DefaultController', () => { 144 | it('GET /default', async () => { 145 | const limit = 5; 146 | const blockDuration = 20000; // 20 second 147 | for (let i = 0; i < limit; i++) { 148 | const response = await httPromise(appUrl + '/default'); 149 | expect(response.data).toEqual({ success: true }); 150 | expect(response.headers).toMatchObject({ 151 | 'x-ratelimit-limit': limit.toString(), 152 | 'x-ratelimit-remaining': (limit - (i + 1)).toString(), 153 | 'x-ratelimit-reset': /^\d+$/, 154 | }); 155 | } 156 | const errRes = await httPromise(appUrl + '/default'); 157 | expect(errRes.data).toMatchObject({ statusCode: 429, message: /ThrottlerException/ }); 158 | expect(errRes.headers).toMatchObject({ 159 | 'retry-after': /^\d+$/, 160 | }); 161 | expect(errRes.status).toBe(429); 162 | await setTimeout(blockDuration); 163 | const response = await httPromise(appUrl + '/default'); 164 | expect(response.data).toEqual({ success: true }); 165 | expect(response.headers).toMatchObject({ 166 | 'x-ratelimit-limit': limit.toString(), 167 | 'x-ratelimit-remaining': (limit - 1).toString(), 168 | 'x-ratelimit-reset': /^\d+$/, 169 | }); 170 | }); 171 | }); 172 | 173 | describe('ThrottlerTestController', () => { 174 | it('GET /test/throttle', async () => { 175 | const makeRequest = async () => httPromise(appUrl + '/test/throttle', 'POST', {}, {}); 176 | const res1 = await makeRequest(); 177 | expect(res1.status).toBe(201); 178 | await setTimeout(1000); 179 | const res2 = await makeRequest(); 180 | expect(res2.status).toBe(201); 181 | const res3 = await makeRequest(); 182 | expect(res3.status).toBe(429); 183 | const res4 = await makeRequest(); 184 | expect(res4.status).toBe(429); 185 | }); 186 | }); 187 | }); 188 | }, 189 | ); 190 | describe('SkipIf suite', () => { 191 | it('should skip throttling if skipIf returns true', async () => { 192 | const moduleFixture: TestingModule = await Test.createTestingModule({ 193 | imports: [ControllerModule], 194 | providers: [ 195 | { 196 | provide: APP_GUARD, 197 | useClass: ThrottlerGuard, 198 | }, 199 | ], 200 | }) 201 | .overrideProvider(THROTTLER_OPTIONS) 202 | .useValue([ 203 | { 204 | skipIf: () => true, 205 | limit: 5, 206 | }, 207 | ]) 208 | .compile(); 209 | 210 | const app = moduleFixture.createNestApplication(); 211 | await app.listen(0); 212 | const appUrl = await app.getUrl(); 213 | for (let i = 0; i < 15; i++) { 214 | const response = await httPromise(appUrl + '/'); 215 | expect(response.status).toBe(200); 216 | expect(response.data).toEqual({ success: true }); 217 | expect(response.headers).not.toMatchObject({ 218 | 'x-ratelimit-limit': '5', 219 | 'x-ratelimit-remaining': '4', 220 | 'x-ratelimit-reset': /^\d+$/, 221 | }); 222 | } 223 | await app.close(); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /test/error-message/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { seconds, ThrottlerGuard, ThrottlerModule } from '../../src'; 4 | import { CustomErrorMessageController } from './custom-error-message.controller'; 5 | 6 | @Module({ 7 | imports: [ 8 | ThrottlerModule.forRoot({ 9 | errorMessage: (context, throttlerLimitDetail) => 10 | `${context.getClass().name}-${ 11 | context.getHandler().name 12 | } ${throttlerLimitDetail.tracker} ${throttlerLimitDetail.totalHits}`, 13 | throttlers: [ 14 | { 15 | name: 'default', 16 | ttl: seconds(3), 17 | limit: 2, 18 | }, 19 | { 20 | name: 'other', 21 | ttl: seconds(3), 22 | limit: 2, 23 | }, 24 | ], 25 | }), 26 | ], 27 | controllers: [CustomErrorMessageController], 28 | providers: [ 29 | { 30 | provide: APP_GUARD, 31 | useClass: ThrottlerGuard, 32 | }, 33 | ], 34 | }) 35 | export class CustomErrorMessageThrottlerModule {} 36 | -------------------------------------------------------------------------------- /test/error-message/custom-error-message.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { SkipThrottle } from '../../src'; 3 | 4 | @Controller() 5 | export class CustomErrorMessageController { 6 | @SkipThrottle({ other: true }) 7 | @Get('default') 8 | defaultRoute() { 9 | return { success: true }; 10 | } 11 | 12 | @SkipThrottle({ default: true }) 13 | @Get('other') 14 | otherRoute() { 15 | return { success: true }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/error-message/custom-error-message.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Type } from '@nestjs/common'; 2 | import { AbstractHttpAdapter } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 5 | import { Test } from '@nestjs/testing'; 6 | import { request, spec } from 'pactum'; 7 | import { CustomErrorMessageThrottlerModule } from './app.module'; 8 | 9 | jest.setTimeout(10000); 10 | 11 | describe.each` 12 | adapter | name 13 | ${ExpressAdapter} | ${'express'} 14 | ${FastifyAdapter} | ${'fastify'} 15 | `( 16 | 'Function-Overrides-Throttler Named Usage - $name', 17 | ({ adapter }: { adapter: Type }) => { 18 | let app: INestApplication; 19 | beforeAll(async () => { 20 | const modRef = await Test.createTestingModule({ 21 | imports: [CustomErrorMessageThrottlerModule], 22 | }).compile(); 23 | app = modRef.createNestApplication(new adapter()); 24 | await app.listen(0); 25 | request.setBaseUrl(await app.getUrl()); 26 | }); 27 | afterAll(async () => { 28 | await app.close(); 29 | }); 30 | 31 | describe.each` 32 | route | errorMessage 33 | ${'default'} | ${'CustomErrorMessageController-defaultRoute ::1 3'} 34 | ${'other'} | ${'CustomErrorMessageController-otherRoute ::1 3'} 35 | `( 36 | 'Custom-error-message Route - $route', 37 | ({ route, errorMessage }: { route: string; errorMessage: string }) => { 38 | it('should receive a custom exception', async () => { 39 | const limit = 2; 40 | for (let i = 0; i < limit; i++) { 41 | await spec().get(`/${route}`).expectStatus(200); 42 | } 43 | 44 | await spec().get(`/${route}`).expectStatus(429).expectBodyContains(errorMessage); 45 | }); 46 | }, 47 | ); 48 | }, 49 | ); 50 | -------------------------------------------------------------------------------- /test/function-overrides/app.module.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { ThrottlerGuard, ThrottlerModule, seconds } from '../../src'; 4 | import { FunctionOverridesThrottlerController } from './function-overrides-throttler.controller'; 5 | import { md5 } from '../utility/hash'; 6 | import assert = require('assert'); 7 | 8 | @Module({ 9 | imports: [ 10 | ThrottlerModule.forRoot([ 11 | { 12 | name: 'default', 13 | ttl: seconds(3), 14 | limit: 2, 15 | }, 16 | { 17 | name: 'custom', 18 | ttl: seconds(3), 19 | limit: 2, 20 | getTracker: () => 'customTrackerString', 21 | generateKey: (context: ExecutionContext, trackerString: string, throttlerName: string) => { 22 | // check if tracker string is passed correctly 23 | assert(trackerString === 'customTrackerString'); 24 | // use the same key for all endpoints 25 | return md5(`${throttlerName}-${trackerString}`); 26 | }, 27 | }, 28 | ]), 29 | ], 30 | controllers: [FunctionOverridesThrottlerController], 31 | providers: [ 32 | { 33 | provide: APP_GUARD, 34 | useClass: ThrottlerGuard, 35 | }, 36 | ], 37 | }) 38 | export class FunctionOverridesThrottlerModule {} 39 | -------------------------------------------------------------------------------- /test/function-overrides/function-overrides-throttler.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { SkipThrottle } from '../../src'; 3 | 4 | @Controller() 5 | export class FunctionOverridesThrottlerController { 6 | @SkipThrottle({ custom: true }) 7 | @Get() 8 | simpleRoute() { 9 | return { success: true }; 10 | } 11 | 12 | @SkipThrottle({ custom: true }) 13 | @Get('1') 14 | simpleRouteOne() { 15 | return { success: true }; 16 | } 17 | 18 | @SkipThrottle({ default: true }) 19 | @Get('custom') 20 | simpleRouteTwo() { 21 | return { success: true }; 22 | } 23 | 24 | @SkipThrottle({ default: true }) 25 | @Get('custom/1') 26 | simpleRouteThrww() { 27 | return { success: true }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/function-overrides/function-overrides-throttler.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Type } from '@nestjs/common'; 2 | import { AbstractHttpAdapter } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 5 | import { Test } from '@nestjs/testing'; 6 | import { setTimeout } from 'node:timers/promises'; 7 | import { request, spec } from 'pactum'; 8 | import { FunctionOverridesThrottlerModule } from './app.module'; 9 | 10 | jest.setTimeout(10000); 11 | 12 | const commonHeader = (prefix: string, name?: string) => `${prefix}${name ? '-' + name : ''}`; 13 | 14 | const remainingHeader = (name?: string) => commonHeader('x-ratelimit-remaining', name); 15 | const limitHeader = (name?: string) => commonHeader('x-ratelimit-limit', name); 16 | const retryHeader = (name?: string) => commonHeader('retry-after', name); 17 | 18 | const custom = 'custom'; 19 | 20 | describe.each` 21 | adapter | name 22 | ${ExpressAdapter} | ${'express'} 23 | ${FastifyAdapter} | ${'fastify'} 24 | `( 25 | 'Function-Overrides-Throttler Named Usage - $name', 26 | ({ adapter }: { adapter: Type }) => { 27 | let app: INestApplication; 28 | beforeAll(async () => { 29 | const modRef = await Test.createTestingModule({ 30 | imports: [FunctionOverridesThrottlerModule], 31 | }).compile(); 32 | app = modRef.createNestApplication(new adapter()); 33 | await app.listen(0); 34 | request.setBaseUrl(await app.getUrl()); 35 | }); 36 | afterAll(async () => { 37 | await app.close(); 38 | }); 39 | 40 | describe('Default Routes', () => { 41 | it('should receive an exception when firing 3 requests within 3 seconds to the same endpoint', async () => { 42 | await spec() 43 | .get('/') 44 | .expectStatus(200) 45 | .expectHeader(remainingHeader(), '1') 46 | .expectHeader(limitHeader(), '2'); 47 | await spec() 48 | .get('/1') 49 | .expectStatus(200) 50 | .expectHeader(remainingHeader(), '1') 51 | .expectHeader(limitHeader(), '2'); 52 | await spec().get('/').expectStatus(200).expectHeaderContains(remainingHeader(), '0'); 53 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(), /^\d+$/); 54 | await spec().get('/1').expectStatus(200).expectHeaderContains(remainingHeader(), '0'); 55 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(), /^\d+$/); 56 | await setTimeout(3000); 57 | await spec() 58 | .get('/') 59 | .expectStatus(200) 60 | .expectHeader(remainingHeader(), '1') 61 | .expectHeader(limitHeader(), '2'); 62 | }); 63 | }); 64 | 65 | describe('Custom Routes', () => { 66 | it('should receive an exception when firing 3 requests within 3 seconds to any endpoint', async () => { 67 | await spec() 68 | .get('/custom') 69 | .expectStatus(200) 70 | .expectHeader(remainingHeader(custom), '1') 71 | .expectHeader(limitHeader(custom), '2'); 72 | await spec() 73 | .get('/custom/1') 74 | .expectStatus(200) 75 | .expectHeader(remainingHeader(custom), '0') 76 | .expectHeader(limitHeader(custom), '2'); 77 | await spec() 78 | .get('/custom') 79 | .expectStatus(429) 80 | .expectHeaderContains(retryHeader(custom), /^\d+$/); 81 | await spec() 82 | .get('/custom/1') 83 | .expectStatus(429) 84 | .expectHeaderContains(retryHeader(custom), /^\d+$/); 85 | await setTimeout(3000); 86 | await spec() 87 | .get('/custom') 88 | .expectStatus(200) 89 | .expectHeader(remainingHeader(custom), '1') 90 | .expectHeader(limitHeader(custom), '2'); 91 | await spec() 92 | .get('/custom/1') 93 | .expectStatus(200) 94 | .expectHeader(remainingHeader(custom), '0') 95 | .expectHeader(limitHeader(custom), '2'); 96 | }); 97 | }); 98 | }, 99 | ); 100 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "..", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/multi/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { ThrottlerGuard, ThrottlerModule, seconds, minutes } from '../../src'; 4 | import { MultiThrottlerController } from './multi-throttler.controller'; 5 | 6 | @Module({ 7 | imports: [ 8 | ThrottlerModule.forRoot([ 9 | { 10 | ttl: seconds(5), 11 | limit: 2, 12 | }, 13 | { 14 | name: 'long', 15 | ttl: minutes(1), 16 | limit: 5, 17 | }, 18 | { 19 | name: 'short', 20 | limit: 1, 21 | ttl: seconds(1), 22 | }, 23 | ]), 24 | ], 25 | controllers: [MultiThrottlerController], 26 | providers: [ 27 | { 28 | provide: APP_GUARD, 29 | useClass: ThrottlerGuard, 30 | }, 31 | ], 32 | }) 33 | export class MultiThrottlerAppModule {} 34 | -------------------------------------------------------------------------------- /test/multi/multi-throttler.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { SkipThrottle } from '../../src'; 3 | 4 | @Controller() 5 | export class MultiThrottlerController { 6 | @Get() 7 | simpleRoute() { 8 | return { success: true }; 9 | } 10 | 11 | @SkipThrottle({ short: true }) 12 | @Get('skip-short') 13 | skipShort() { 14 | return { success: true }; 15 | } 16 | 17 | @SkipThrottle({ default: true, long: true }) 18 | @Get('skip-default-and-long') 19 | skipDefAndLong() { 20 | return { success: true }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/multi/multi-throttler.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, Type } from '@nestjs/common'; 2 | import { AbstractHttpAdapter } from '@nestjs/core'; 3 | import { ExpressAdapter } from '@nestjs/platform-express'; 4 | import { FastifyAdapter } from '@nestjs/platform-fastify'; 5 | import { Test } from '@nestjs/testing'; 6 | import { setTimeout } from 'node:timers/promises'; 7 | import { request, spec } from 'pactum'; 8 | import { MultiThrottlerAppModule } from './app.module'; 9 | 10 | jest.setTimeout(10000); 11 | 12 | const commonHeader = (prefix: string, name?: string) => `${prefix}${name ? '-' + name : ''}`; 13 | 14 | const remainingHeader = (name?: string) => commonHeader('x-ratelimit-remaining', name); 15 | const limitHeader = (name?: string) => commonHeader('x-ratelimit-limit', name); 16 | const retryHeader = (name?: string) => commonHeader('retry-after', name); 17 | 18 | const short = 'short'; 19 | const long = 'long'; 20 | 21 | describe.each` 22 | adapter | name 23 | ${ExpressAdapter} | ${'express'} 24 | ${FastifyAdapter} | ${'fastify'} 25 | `('Multi-Throttler Named Usage - $name', ({ adapter }: { adapter: Type }) => { 26 | let app: INestApplication; 27 | beforeAll(async () => { 28 | const modRef = await Test.createTestingModule({ 29 | imports: [MultiThrottlerAppModule], 30 | }).compile(); 31 | app = modRef.createNestApplication(new adapter()); 32 | await app.listen(0); 33 | request.setBaseUrl(await app.getUrl()); 34 | }); 35 | afterAll(async () => { 36 | await app.close(); 37 | }); 38 | 39 | describe('Default Route: 1/s, 2/5s, 5/min', () => { 40 | it('should receive an exception when firing 2 requests within a second', async () => { 41 | await spec() 42 | .get('/') 43 | .expectStatus(200) 44 | .expectHeader(remainingHeader(short), '0') 45 | .expectHeader(limitHeader(short), '1'); 46 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(short), /^\d+$/); 47 | await setTimeout(1000); 48 | }); 49 | it('should get an error if we send two more requests within the first five seconds', async () => { 50 | await spec() 51 | .get('/') 52 | .expectStatus(200) 53 | .expectHeader(remainingHeader(), '0') 54 | .expectHeader(limitHeader(), '2'); 55 | await setTimeout(1000); 56 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(), /^\d+$/); 57 | await setTimeout(5000); 58 | }); 59 | it('should get an error if we smartly send 4 more requests within the minute', async () => { 60 | await spec() 61 | .get('/') 62 | .expectStatus(200) 63 | .expectHeader(limitHeader(long), '5') 64 | .expectHeader(remainingHeader(long), '2') 65 | .expectHeader(remainingHeader(short), '0'); 66 | await setTimeout(1000); 67 | await spec().get('/').expectStatus(200).expectHeader(remainingHeader(), '0'); 68 | console.log('waiting 5 seconds'); 69 | await setTimeout(5000); 70 | await spec() 71 | .get('/') 72 | .expectStatus(200) 73 | .expectHeader(remainingHeader(long), '0') 74 | .expectHeader(remainingHeader(short), '0') 75 | .expectHeader(remainingHeader(), '1'); 76 | await setTimeout(1000); 77 | await spec().get('/').expectStatus(429).expectHeaderContains(retryHeader(long), /^\d+$/); 78 | }); 79 | }); 80 | describe('skips', () => { 81 | it('should skip the short throttler', async () => { 82 | await spec().get('/skip-short').expectStatus(200).expectHeader(remainingHeader(), '1'); 83 | await spec().get('/skip-short').expectStatus(200).expectHeader(remainingHeader(), '0'); 84 | }); 85 | it('should skip the default and long trackers', async () => { 86 | await spec() 87 | .get('/skip-default-and-long') 88 | .expectStatus(200) 89 | .expectHeader(remainingHeader(short), '0') 90 | .expect((ctx) => { 91 | const { headers } = ctx.res; 92 | expect(headers[remainingHeader('default')]).toBeUndefined(); 93 | expect(headers[remainingHeader('long')]).toBeUndefined(); 94 | }); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /test/utility/hash.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'node:crypto'; 2 | 3 | export function md5(text: string): string { 4 | return crypto.createHash('md5').update(text).digest('hex'); 5 | } 6 | -------------------------------------------------------------------------------- /test/utility/httpromise.ts: -------------------------------------------------------------------------------- 1 | import { request } from 'http'; 2 | 3 | type HttpMethods = 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'; 4 | 5 | export function httPromise( 6 | url: string, 7 | method: HttpMethods = 'GET', 8 | headers: Record = {}, 9 | body?: Record, 10 | ): Promise<{ data: any; headers: Record; status: number }> { 11 | return new Promise((resolve, reject) => { 12 | const req = request(url, (res) => { 13 | res.setEncoding('utf-8'); 14 | let data = ''; 15 | res.on('data', (chunk) => { 16 | data += chunk; 17 | }); 18 | res.on('end', () => { 19 | return resolve({ 20 | data: JSON.parse(data), 21 | headers: res.headers, 22 | status: res.statusCode, 23 | }); 24 | }); 25 | res.on('error', (err) => { 26 | return reject({ 27 | data: err, 28 | headers: res.headers, 29 | status: res.statusCode, 30 | }); 31 | }); 32 | }); 33 | req.method = method; 34 | 35 | Object.keys(headers).forEach((key) => { 36 | req.setHeader(key, headers[key]); 37 | }); 38 | 39 | switch (method) { 40 | case 'GET': 41 | break; 42 | case 'POST': 43 | case 'PUT': 44 | case 'PATCH': 45 | req.setHeader('Content-Type', 'application/json'); 46 | req.setHeader('Content-Length', Buffer.byteLength(Buffer.from(JSON.stringify(body)))); 47 | req.write(Buffer.from(JSON.stringify(body))); 48 | break; 49 | case 'DELETE': 50 | break; 51 | default: 52 | reject(new Error('Invalid HTTP method')); 53 | break; 54 | } 55 | req.end(); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "incremental": true 12 | }, 13 | "exclude": ["node_modules", "dist"] 14 | } 15 | --------------------------------------------------------------------------------