├── config └── husky │ └── pre-commit ├── .gitattributes ├── tsconfig.json ├── test ├── types.ts ├── options-test.ts └── store-test.ts ├── .npmrc ├── source ├── index.ts ├── types.ts └── memcached-store.ts ├── .gitignore ├── .prettierignore ├── jest.config.json ├── .editorconfig ├── license.md ├── changelog.md ├── .github └── workflows │ └── ci.yaml ├── package.json ├── readme.md └── contributing.md /config/husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run pre-commit 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # /.gitattributes 2 | # Makes sure all line endings are LF 3 | 4 | * text=auto eol=lf 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["source/"], 3 | "exclude": ["node_modules/"], 4 | "extends": "@express-rate-limit/tsconfig" 5 | } 6 | -------------------------------------------------------------------------------- /test/types.ts: -------------------------------------------------------------------------------- 1 | // /test/types.ts 2 | // Setup the types for `memcached-mock` 3 | 4 | declare module 'memcached-mock' { 5 | export { default } from 'memcached' 6 | } 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # .npmrc 2 | # Configuration for npm and pnpm 3 | 4 | # Uses the exact version instead of any within-patch-range version of an 5 | # installed package 6 | save-exact=true 7 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | // /source/index.ts 2 | // Export away! 3 | 4 | // Export the store, as well as all the types as named exports. 5 | export * from './types.js' 6 | export { default as MemcachedStore } from './memcached-store.js' 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # /.gitignore 2 | # Tells Git to ignore these files 3 | 4 | node_modules/ 5 | dist/ 6 | coverage/ 7 | test/external/**/package-lock.json 8 | 9 | .vscode/ 10 | .idea/ 11 | 12 | *.log 13 | *.tmp 14 | *.bak 15 | *.tgz 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # /.prettierignore 2 | # Tells Prettier to ignore these files 3 | 4 | package.json 5 | package-lock.json 6 | pnpm-lock.yaml 7 | 8 | # The following is copied over from .gitignore 9 | 10 | node_modules/ 11 | dist/ 12 | coverage/ 13 | test/external/**/package-lock.json 14 | 15 | .vscode/ 16 | .idea/ 17 | 18 | *.log 19 | *.tmp 20 | *.bak 21 | *.tgz 22 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "preset": "ts-jest/presets/default-esm", 4 | "collectCoverage": true, 5 | "collectCoverageFrom": ["source/**/*.ts"], 6 | "testTimeout": 30000, 7 | "testMatch": ["**/test/**/*-test.[jt]s?(x)"], 8 | "moduleFileExtensions": ["js", "jsx", "json", "ts", "tsx"], 9 | "moduleNameMapper": { 10 | "^(\\.{1,2}/.*)\\.js$": "$1" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # /.editorconfig 2 | # Tells most editors what our style preferences are 3 | # https://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | max_line_length = 80 13 | indent_size = tab 14 | tab_width = 2 15 | 16 | [*.{ts,json,md}] 17 | indent_style = tab 18 | 19 | [*.yaml] 20 | indent_style = spaces 21 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright 2023 Tomohisa Oda, Nathan Friedly, and Vedant K 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to 7 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 8 | 9 | ## [v1.0.1](https://github.com/express-rate-limit/rate-limit-redis/releases/tag/v1.0.1) 10 | 11 | ### Added 12 | 13 | - Enabled provenance statement generation, see 14 | https://github.com/express-rate-limit/express-rate-limit#406. 15 | 16 | ## [1.0.0](https://github.com/express-rate-limit/rate-limit-memcached/releases/tag/v1.0.0) 17 | 18 | ### Breaking 19 | 20 | - Rewrite the store to implement the new `Store` interface, introduced in 21 | `express-rate-limit` v6.0.0. 22 | - Require the `del`, `get`, `set`, `add`, `incr` and `decr` functions on any 23 | `memcached` client passed to the store. 24 | - `MemcachedStore` is now a named export, not a default export, to make it play 25 | nice with the dual cjs-esm package. 26 | 27 | ### Added 28 | 29 | - Add the `locations` and `config` options. 30 | - Store expiry time for a client in a new key, named `${prefix}expiry:${key}`. 31 | -------------------------------------------------------------------------------- /test/options-test.ts: -------------------------------------------------------------------------------- 1 | // /test/options-test.ts 2 | // The tests for option handling. 3 | 4 | import MockMemcached from 'memcached-mock' 5 | import DefaultMemcached from 'memcached' 6 | import { it, expect } from '@jest/globals' 7 | import { MemcachedStore } from '../source/index.js' 8 | import './types.js' // eslint-disable-line import/no-unassigned-import 9 | 10 | it('should set default values when no arguments are passed to constructor', () => { 11 | const options = {} 12 | 13 | let store = new MemcachedStore(options) 14 | expect(store.prefix).toBe('rl:') 15 | expect(store.client instanceof DefaultMemcached).toBe(true) 16 | 17 | store = new MemcachedStore() 18 | expect(store.prefix).toBe('rl:') 19 | expect(store.client instanceof DefaultMemcached).toBe(true) 20 | }) 21 | 22 | it('should accept arguments over default values', () => { 23 | const options = { prefix: '42-', client: new MockMemcached('foo.bar') } 24 | const store = new MemcachedStore(options) 25 | 26 | expect(store.prefix).toBe(options.prefix) 27 | expect(store.client).toBe(options.client) 28 | }) 29 | 30 | it('should throw when a client is invalid', () => { 31 | const client = { set: (error: any) => false, get: 24 } 32 | // @ts-expect-error Catch the error without TSC. 33 | const createInvalidStore = () => new MemcachedStore({ client }) 34 | 35 | expect(createInvalidStore).toThrow() 36 | }) 37 | -------------------------------------------------------------------------------- /source/types.ts: -------------------------------------------------------------------------------- 1 | // /source/types.ts 2 | // The type definitions for this package. 3 | 4 | import type Memcached from 'memcached' 5 | 6 | /** 7 | * A memcached client. 8 | */ 9 | export type MemcachedClient = { 10 | get: (key: string, callback: (error: any, data: any) => void) => void 11 | set: ( 12 | key: string, 13 | value: any, 14 | time: number, 15 | callback: (error: any) => void, 16 | ) => void 17 | add: ( 18 | key: string, 19 | value: any, 20 | time: number, 21 | callback: (error: any) => void, 22 | ) => void 23 | del: (key: string, callback: (error: any) => void) => void 24 | incr: (key: string, amount: number, callback: (error: any) => void) => void 25 | decr: (key: string, amount: number, callback: (error: any) => void) => void 26 | } 27 | 28 | /** 29 | * The configuration options for the store. 30 | */ 31 | export type Options = { 32 | /** 33 | * The text to prepend to the key. 34 | */ 35 | prefix: string 36 | 37 | /** 38 | * The `memcached` client to use. 39 | */ 40 | client: MemcachedClient 41 | 42 | /** 43 | * A list of memcached server URLs to store the keys in, passed to the default 44 | * memcached client. 45 | * 46 | * Note that the default client is only used if another client is not passed 47 | * to the store. 48 | */ 49 | locations: string[] 50 | 51 | /** 52 | * The configuration to pass to the default client, along with the `locations`. 53 | */ 54 | config: Memcached.options 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # /.github/workflows/ci.yaml 2 | # GitHub actions workflow 3 | 4 | name: CI 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | strategy: 12 | matrix: 13 | node-version: [lts/*] 14 | os: [ubuntu-latest] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - name: Checkout the repository 18 | uses: actions/checkout@v3 19 | - name: Use Node ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - name: Lint code 24 | run: | 25 | npm ci 26 | npm run lint 27 | test: 28 | name: Test 29 | strategy: 30 | matrix: 31 | node-version: [lts/*, latest] 32 | os: [ubuntu-latest, windows-latest, macos-latest] 33 | runs-on: ${{ matrix.os }} 34 | steps: 35 | - name: Checkout the repository 36 | uses: actions/checkout@v3 37 | - name: Use Node ${{ matrix.node-version }} 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | - name: Run tests 42 | run: | 43 | npm ci 44 | npm run test:lib 45 | publish: 46 | name: Publish 47 | needs: [lint, test] 48 | if: startsWith(github.ref, 'refs/tags/v') 49 | runs-on: ubuntu-latest 50 | permissions: 51 | contents: write 52 | id-token: write 53 | steps: 54 | - name: Checkout the repository 55 | uses: actions/checkout@v3 56 | - uses: actions/setup-node@v3 57 | with: 58 | node-version: lts/* 59 | registry-url: https://registry.npmjs.org/ 60 | - name: Install dependencies 61 | run: npm ci 62 | - name: Publish to npm 63 | run: npm publish --provenance 64 | env: 65 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 66 | - name: Build package to upload to GitHub releases 67 | run: | 68 | npm pack 69 | mv rate-limit-memcached-*.tgz rate-limit-memcached.tgz 70 | - name: Create a Github release 71 | uses: softprops/action-gh-release@v1 72 | with: 73 | files: rate-limit-memcached.tgz 74 | body: 75 | You can view the changelog 76 | [here](https://github.com/express-rate-limit/rate-limit-memcached/blob/main/changelog.md). 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rate-limit-memcached", 3 | "version": "1.0.1", 4 | "description": "A memcached store for the express-rate-limit middleware.", 5 | "author": "linyows", 6 | "license": "MIT", 7 | "homepage": "https://github.com/express-rate-limit/rate-limit-memcached", 8 | "repository": "express-rate-limit/rate-limit-memcached", 9 | "keywords": [ 10 | "express", 11 | "api", 12 | "memcached", 13 | "rate-limit" 14 | ], 15 | "type": "module", 16 | "exports": { 17 | ".": { 18 | "import": { 19 | "types": "./dist/index.d.mts", 20 | "default": "./dist/index.mjs" 21 | }, 22 | "require": { 23 | "types": "./dist/index.d.cts", 24 | "default": "./dist/index.cjs" 25 | } 26 | } 27 | }, 28 | "main": "./dist/index.cjs", 29 | "module": "./dist/index.mjs", 30 | "types": "./dist/index.d.ts", 31 | "files": [ 32 | "dist/", 33 | "tsconfig.json", 34 | "package.json", 35 | "readme.md", 36 | "license.md", 37 | "changelog.md" 38 | ], 39 | "engines": { 40 | "node": ">= 16" 41 | }, 42 | "scripts": { 43 | "clean": "del-cli dist/ coverage/ *.log *.tmp *.bak *.tgz", 44 | "build": "pkgroll --target=es2020 --src source/", 45 | "compile": "run-s clean build", 46 | "lint:code": "xo", 47 | "lint:rest": "prettier --check .", 48 | "lint": "run-s lint:*", 49 | "format:code": "xo --fix", 50 | "format:rest": "prettier --write .", 51 | "format": "run-s format:*", 52 | "test:lib": "jest", 53 | "test": "run-s lint test:lib", 54 | "pre-commit": "lint-staged", 55 | "prepare": "run-s compile && husky install config/husky" 56 | }, 57 | "dependencies": { 58 | "@types/memcached": "2.2.10", 59 | "memcached": "2.2.2" 60 | }, 61 | "devDependencies": { 62 | "@express-rate-limit/prettier": "1.1.1", 63 | "@express-rate-limit/tsconfig": "1.0.2", 64 | "@jest/globals": "30.2.0", 65 | "@types/jest": "30.0.0", 66 | "@types/node": "24.9.2", 67 | "cross-env": "10.1.0", 68 | "del-cli": "7.0.0", 69 | "husky": "9.1.7", 70 | "jest": "30.2.0", 71 | "lint-staged": "16.2.6", 72 | "memcached-mock": "0.1.0", 73 | "npm-run-all": "4.1.5", 74 | "pkgroll": "2.20.1", 75 | "ts-jest": "29.4.5", 76 | "ts-node": "10.9.2", 77 | "tsx": "4.20.6", 78 | "typescript": "5.9.3", 79 | "xo": "0.60.0" 80 | }, 81 | "peerDependencies": { 82 | "express-rate-limit": ">= 6" 83 | }, 84 | "xo": { 85 | "prettier": true 86 | }, 87 | "prettier": "@express-rate-limit/prettier", 88 | "lint-staged": { 89 | "{source,test}/**/*.ts": "xo --fix", 90 | "**/*.{json,yaml,md}": "prettier --write " 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/store-test.ts: -------------------------------------------------------------------------------- 1 | // /test/store-test.ts 2 | // The tests for the increment, decrement and resetKey functions. 3 | 4 | import Memcached from 'memcached-mock' 5 | import { it, expect, jest } from '@jest/globals' 6 | import { MemcachedStore } from '../source/index.js' 7 | import './types.js' // eslint-disable-line import/no-unassigned-import 8 | 9 | // Return the same spied-on instance of the store for the tests. 10 | const getStore = async (): Promise => { 11 | // Create a new client instance, and clear its cache. Somehow the cache from 12 | // the previous test carries over unless we call flush here. 13 | const client = new Memcached('localhost:11211') 14 | await new Promise((resolve, reject) => { 15 | client.flush((error) => { 16 | if (error) { 17 | reject(error as Error) 18 | return 19 | } 20 | 21 | resolve() 22 | }) 23 | }) 24 | 25 | // Spy on all the functions so we can make sure they are called. 26 | jest.spyOn(client, 'get') 27 | jest.spyOn(client, 'set') 28 | jest.spyOn(client, 'add') 29 | jest.spyOn(client, 'incr') 30 | jest.spyOn(client, 'decr') 31 | jest.spyOn(client, 'del') 32 | 33 | // Create an new store and initialise it. 34 | const store = new MemcachedStore({ client }) 35 | // @ts-expect-error We only need to pass the `windowMs` option. 36 | store.init({ windowMs: 2 * 1000 }) 37 | return store 38 | } 39 | 40 | it('should work when `increment` is called for new key', async () => { 41 | const store = await getStore() 42 | const data = await store.increment('1.2.3.4') 43 | 44 | expect(data.totalHits).toBe(1) 45 | expect(data.resetTime instanceof Date).toBe(true) 46 | 47 | expect(store.client.incr).toHaveBeenCalled() 48 | expect(store.client.add).toHaveBeenCalledTimes(2) 49 | }) 50 | 51 | it('should work when `increment` is called for existing key', async () => { 52 | const store = await getStore() 53 | 54 | await store.increment('1.2.3.4') 55 | await store.increment('1.2.3.4') 56 | 57 | const data = await store.increment('1.2.3.4') 58 | 59 | expect(data.totalHits).toBe(3) 60 | expect(data.resetTime instanceof Date).toBe(true) 61 | }) 62 | 63 | it('should count all hits when `increment` is called simultaneously', async () => { 64 | const store = await getStore() 65 | 66 | await Promise.all([ 67 | store.increment('1.2.3.4'), 68 | store.increment('1.2.3.4'), 69 | store.increment('1.2.3.4'), 70 | ]) 71 | 72 | const data = await store.increment('1.2.3.4') 73 | 74 | expect(data.totalHits).toBe(4) 75 | expect(data.resetTime instanceof Date).toBe(true) 76 | }) 77 | 78 | it('should still call `decr` when `decrement` is called for non-existent key', async () => { 79 | const store = await getStore() 80 | 81 | await store.decrement('1.2.3.4') 82 | 83 | expect(store.client.decr).toHaveBeenCalled() 84 | }) 85 | 86 | it('should work when `decrement` is called for existing key', async () => { 87 | const store = await getStore() 88 | 89 | await store.increment('1.2.3.4') 90 | await store.decrement('1.2.3.4') 91 | 92 | expect(store.client.decr).toHaveBeenCalled() 93 | }) 94 | 95 | it('should work when `resetKey` is called for existing key', async () => { 96 | const store = await getStore() 97 | 98 | await store.increment('1.2.3.4') 99 | await store.resetKey('1.2.3.4') 100 | 101 | expect(store.client.del).toHaveBeenCalledTimes(2) // Once for the key, once for the `key__expiry`. 102 | }) 103 | 104 | it('should still call `del` when `resetKey` is called for non-existent key', async () => { 105 | const store = await getStore() 106 | 107 | await store.resetKey('1.2.3.4') 108 | 109 | expect(store.client.del).toHaveBeenCalledTimes(2) 110 | }) 111 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | #
`rate-limit-memcached`
2 | 3 |
4 | 5 | [![tests](https://github.com/express-rate-limit/rate-limit-memcached/actions/workflows/ci.yaml/badge.svg)](https://github.com/express-rate-limit/rate-limit-memcached/actions/workflows/ci.yaml) 6 | [![npm version](https://img.shields.io/npm/v/rate-limit-memcached.svg)](https://npmjs.org/package/rate-limit-memcached 'View this project on NPM') 7 | [![npm downloads](https://img.shields.io/npm/dm/rate-limit-memcached)](https://www.npmjs.com/package/rate-limit-memcached) 8 | 9 | A [`memcached`](https://memcached.org) store for the 10 | [`express-rate-limit`](https://github.com/express-rate-limit/express-rate-limit) 11 | middleware. 12 | 13 |
14 | 15 | ## Installation 16 | 17 | From the npm registry: 18 | 19 | ```sh 20 | # Using npm 21 | > npm install express-rate-limit 22 | # Using yarn or pnpm 23 | > yarn/pnpm add express-rate-limit 24 | ``` 25 | 26 | From Github Releases: 27 | 28 | ```sh 29 | # Using npm 30 | > npm install https://github.com/express-rate-limit/rate-limit-memcached/releases/download/v{version}/rate-limit-memcached.tgz 31 | # Using yarn or pnpm 32 | > yarn/pnpm add https://github.com/express-rate-limit/rate-limit-memcached/releases/download/v{version}/rate-limit-memcached.tgz 33 | ``` 34 | 35 | Replace `{version}` with the version of the package that you want to your, e.g.: 36 | `1.0.0`. 37 | 38 | ## Usage 39 | 40 | **This package requires you to use Node 16 or above.** 41 | 42 | An example of its usage is as follows: 43 | 44 | ```ts 45 | import { rateLimit } from 'express-rate-limit' 46 | import { MemcachedStore } from 'rate-limit-memcached' 47 | 48 | const limiter = rateLimit({ 49 | windowMs: 15 * 60 * 1000, // 15 minutes. 50 | max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes). 51 | standardHeaders: 'draft-7', // Return rate limit info in the `RateLimit` header. 52 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers. 53 | store: new MemcachedStore({ 54 | // prefix: 'rl:', // The prefix attached to all keys stored in the cache. 55 | locations: ['localhost:11211'], // The server location(s), passed directly to Memcached. 56 | }), 57 | }) 58 | 59 | // Apply the rate limiting middleware to all requests 60 | app.use(limiter) 61 | ``` 62 | 63 | ## Configuration 64 | 65 | ### `prefix` 66 | 67 | > `string` 68 | 69 | The text to prepend to all keys stored by this package in Memcached. 70 | 71 | Defaults to `rl:`. 72 | 73 | ### `client` 74 | 75 | > `MemcachedClient` 76 | 77 | The client used to make requests to the Memcached server. Must have the 78 | following methods: 79 | 80 | - `get: (key, callback)` 81 | - `del: (key, callback)` 82 | - `set: (key, value, lifetime, callback)` 83 | - `add: (key, value, lifetime, callback)` 84 | - `incr: (key, delta, callback)` 85 | - `decr: (key, delta, callback)` 86 | 87 | > Here, `key` is a string, `value` and `delta` are numbers, and `lifetime` is 88 | > the time in seconds until it expires. 89 | 90 | Defaults to an instance of [`memcached`](https://github.com/3rd-Eden/memcached), 91 | created with the `locations` and `config` options (see below for details) passed 92 | to it. 93 | 94 | ### `locations` 95 | 96 | A list of memcached servers to store the keys in, passed to the default 97 | Memcached client. 98 | 99 | Note that the default client is only used if an alternative `client` is not 100 | passed to the store. 101 | 102 | Defaults to `['localhost:11211']`. 103 | 104 | ### `config` 105 | 106 | > `object` 107 | 108 | The configuration passed to the default Memcached client. 109 | 110 | Defaults to `{}`. 111 | 112 | ## Issues and Contributing 113 | 114 | If you encounter a bug or want to see something added/changed, please go ahead 115 | and 116 | [open an issue](https://github.com/express-rate-limitedly/rate-limit-memcached/issues/new)! 117 | If you need help with something, feel free to 118 | [start a discussion](https://github.com/express-rate-limit/rate-limit-memcached/discussions/new)! 119 | 120 | If you wish to contribute to the library, thanks! First, please read 121 | [the contributing guide](contributing.md). Then you can pick up any issue and 122 | fix/implement it! 123 | 124 | ## License 125 | 126 | MIT © [Tomohisa Oda](http://github.com/linyows), 127 | [Nathan Friedly](http://nfriedly.com) and 128 | [Vedant K](https://github.com/gamemaker1) 129 | -------------------------------------------------------------------------------- /source/memcached-store.ts: -------------------------------------------------------------------------------- 1 | // /source/memcached-store.ts 2 | // A `memcached` store for the `express-rate-limit` middleware. 3 | 4 | import { promisify } from 'node:util' 5 | import type { 6 | Store, 7 | Options as RateLimitConfiguration, 8 | IncrementResponse, 9 | } from 'express-rate-limit' 10 | import Memcached from 'memcached' 11 | import type { Options, MemcachedClient } from './types.js' 12 | 13 | // A list of methods that should be present on a client object. 14 | const methods: Array = [ 15 | 'del', 16 | 'get', 17 | 'set', 18 | 'add', 19 | 'incr', 20 | 'decr', 21 | ] 22 | 23 | /** 24 | * The promisifed version of the `MemcachedClient`. 25 | */ 26 | type PromisifiedMemcachedClient = { 27 | get: (key: string) => Promise 28 | set: (key: string, value: any, time: number) => Promise 29 | add: (key: string, value: any, time: number) => Promise 30 | del: (key: string) => Promise 31 | incr: (key: string, amount: number) => Promise 32 | decr: (key: string, amount: number) => Promise 33 | } 34 | 35 | /** 36 | * A `Store` for the `express-rate-limit` package that stores hit counts in 37 | * Memcached. 38 | */ 39 | class MemcachedStore implements Store { 40 | /** 41 | * The number of seconds to remember a client's requests. 42 | */ 43 | expiration!: number 44 | 45 | /** 46 | * The text to prepend to the key. 47 | */ 48 | prefix!: string 49 | 50 | /** 51 | * The `memcached` client to use. 52 | */ 53 | client!: MemcachedClient 54 | 55 | /** 56 | * The promisifed functions from the `client` object. 57 | */ 58 | fns!: PromisifiedMemcachedClient 59 | 60 | /** 61 | * @constructor for `MemcachedStore`. 62 | * 63 | * @param options {Options} - The options used to configure the store's behaviour. 64 | */ 65 | constructor(options?: Partial) { 66 | this.prefix = options?.prefix ?? 'rl:' 67 | 68 | if (options?.client) { 69 | for (const function_ of methods) { 70 | if (typeof options.client[function_] !== 'function') 71 | throw new Error('An invalid memcached client was passed to store.') 72 | } 73 | 74 | this.client = options.client 75 | } else { 76 | this.client = new Memcached( 77 | options?.locations ?? ['localhost:11211'], 78 | options?.config ?? {}, 79 | ) 80 | } 81 | 82 | // Promisify the functions. 83 | // @ts-expect-error This line simply initialises the object, calm down lol. 84 | this.fns = {} 85 | for (const function_ of methods) { 86 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 87 | this.fns[function_] = promisify(this.client[function_]).bind(this.client) 88 | } 89 | } 90 | 91 | /** 92 | * Method that actually initializes the store. 93 | * 94 | * @param options {RateLimitConfiguration} - The options used to setup the middleware. 95 | * 96 | * @impl 97 | */ 98 | init(options: RateLimitConfiguration) { 99 | this.expiration = options.windowMs / 1000 // [in seconds] 100 | } 101 | 102 | /** 103 | * Method to prefix the keys with the given text. 104 | * 105 | * @param key {string} - The key. 106 | * 107 | * @returns {string} - The text + the key. 108 | */ 109 | prefixKey(key: string): string { 110 | return `${this.prefix}${key}` 111 | } 112 | 113 | /** 114 | * Method that returns the name of the key used to store the reset timestamp 115 | * for the given key. 116 | * 117 | * @param key {string} - The key. 118 | * 119 | * @returns {string} - The expiry key's name. 120 | */ 121 | expiryKey(key: string): string { 122 | return `${this.prefix}expiry:${key}` 123 | } 124 | 125 | /** 126 | * Method to increment a client's hit counter. 127 | * 128 | * @param key {string} - The identifier for a client. 129 | * 130 | * @returns {IncrementResponse} - The number of hits and reset time for that client. 131 | */ 132 | async increment(key: string): Promise { 133 | const prefixedKey = this.prefixKey(key) 134 | 135 | // Try incrementing the given key. If the key exists, it will increment it 136 | // and return the updated hit count. 137 | let totalHits = await this.fns.incr(prefixedKey, 1) 138 | let expiresAt 139 | 140 | if (totalHits === false) { 141 | try { 142 | // The increment command failed since the key does not exist. In which case, set the 143 | // hit count for that key to 1, and make sure it expires after `window` seconds. 144 | await this.fns.add(prefixedKey, 1, this.expiration) 145 | 146 | // If it is added successfully, set `totalHits` to 1. 147 | totalHits = 1 148 | 149 | // Also store the expiration time in a separate key. 150 | expiresAt = Date.now() + this.expiration * 1000 // [seconds -> milliseconds] 151 | await this.fns.add( 152 | this.expiryKey(key), // The name of the key. 153 | expiresAt, // The value - the time at which the key expires. 154 | this.expiration, // The key should be deleted by memcached after `window` seconds. 155 | ) 156 | } catch (caughtError: any) { 157 | const error = caughtError as Error 158 | 159 | // If the `add` operation fails because the key already exists, it was 160 | // created sometime in between, call `increment` again, and fetch its 161 | // expiry time. 162 | if (/not(\s)?stored/i.test(error.message)) { 163 | totalHits = await this.fns.incr(prefixedKey, 1) 164 | expiresAt = await this.fns.get(this.expiryKey(key)) 165 | } else { 166 | // Otherwise, throw the error. 167 | throw error 168 | } 169 | } 170 | } else { 171 | // If the key exists and has been incremented succesfully, retrieve its expiry. 172 | expiresAt = await this.fns.get(this.expiryKey(key)) 173 | } 174 | 175 | // Make sure `totalHits` is a number. 176 | if (typeof totalHits !== 'number') 177 | throw new Error( 178 | `Expected 'totalHits' to be a number, got ${totalHits} instead.`, 179 | ) 180 | 181 | // Return the total number of hits, as well as the reset timestamp. 182 | return { 183 | totalHits, 184 | // If `expiresAt` is undefined, assume the key expired sometime in between 185 | // reading the hits and expiry keys from memcached. 186 | resetTime: expiresAt ? new Date(expiresAt) : new Date(), 187 | } 188 | } 189 | 190 | /** 191 | * Method to decrement a client's hit counter. 192 | * 193 | * @param key {string} - The identifier for a client 194 | */ 195 | async decrement(key: string): Promise { 196 | // Decrement the key, and do nothing if it doesn't exist. 197 | await this.fns.decr(this.prefixKey(key), 1) 198 | } 199 | 200 | /** 201 | * Method to reset a client's hit counter. 202 | * 203 | * @param key {string} - The identifier for a client. 204 | */ 205 | async resetKey(key: string): Promise { 206 | // Delete the the key, as well as its expiration counterpart. 207 | await this.fns.del(this.prefixKey(key)) 208 | await this.fns.del(this.expiryKey(key)) 209 | } 210 | } 211 | 212 | // Export it to the world! 213 | export default MemcachedStore 214 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | Thanks for your interest in contributing to `rate-limit-memcached`! This guide 4 | will show you how to set up your environment and contribute to this library. 5 | 6 | ## Set Up 7 | 8 | First, you need to install and be familiar the following: 9 | 10 | - `git`: [Here](https://github.com/git-guides) is a great guide by GitHub on 11 | installing and getting started with Git. 12 | - `node` and `npm`: 13 | [This guide](https://nodejs.org/en/download/package-manager/) will help you 14 | install Node and npm. The recommended method is using the `n` version manager 15 | if you are on MacOS or Linux. Make sure you are using the 16 | [active LTS version](https://github.com/nodejs/Release#release-schedule) of 17 | Node. 18 | 19 | Once you have installed the above, follow 20 | [these instructions](https://docs.github.com/en/get-started/quickstart/fork-a-repo) 21 | to 22 | [`fork`](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks) 23 | and [`clone`](https://github.com/git-guides/git-clone) the repository 24 | (`express-rate-limit/rate-limit-memcached`). 25 | 26 | Once you have forked and cloned the repository, you can 27 | [pick out an issue](https://github.com/express-rate-limit/rate-limit-memcached/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) 28 | you want to fix/implement! 29 | 30 | ## Making Changes 31 | 32 | Once you have cloned the repository to your computer (say, in 33 | `~/Code/rate-limit-memcached`) and picked the issue you want to tackle, create a 34 | branch based off the `next` branch: 35 | 36 | ```sh 37 | > git switch next 38 | > git switch --create branch-name 39 | ``` 40 | 41 | While naming your branch, make sure the name is short and self explanatory. 42 | 43 | Once you have created a branch, you can start coding! 44 | 45 | The library is written in 46 | [Typescript](https://github.com/microsoft/TypeScript#readme) and supports Node 47 | 16, 18 and 20. The code is arranged as follows: 48 | 49 | ```sh 50 | . 51 | ├── config 52 | │ └── husky 53 | │ └── pre-commit 54 | ├── source 55 | │ ├── index.ts 56 | │ ├── memcached-store.ts 57 | │ └── types.ts 58 | ├── test 59 | │ ├── options-test.ts 60 | │ ├── store-test.ts 61 | │ └── types.ts 62 | ├── changelog.md 63 | ├── contributing.md 64 | ├── jest.config.json 65 | ├── license.md 66 | ├── package-lock.json 67 | ├── package.json 68 | ├── readme.md 69 | └── tsconfig.json 70 | ``` 71 | 72 | > Most files have a little description of what they do at the top. 73 | 74 | #### `./` 75 | 76 | - `package.json`: Node package information. 77 | - `package-lock.json`: npm lock file, please do not modify manually. 78 | - `tsconfig.json`: The TSC configuration for this project. 79 | - `changelog.md`: A list of changes that have been made in each version. 80 | - `contributing.md`: This file, helps contributors get started. 81 | - `license.md`: Tells people how they can use this package. 82 | - `readme.md`: The file everyone should read before using the package. Contains 83 | installation and usage instructions and the API reference. 84 | 85 | #### `source/` 86 | 87 | - `source/index.ts`: Exports the `MemcachedStore` function as a named export 88 | from `source/memcached-store.ts`, and types from `source/types.ts`. 89 | - `source/memcached-store.ts`: The store itself. 90 | - `source/types.ts`: Typescript types for the library. 91 | 92 | #### `test/` 93 | 94 | - `test/options-test.ts`: Ensures the library can parse options correctly. 95 | - `test/store-test.ts`: Ensures the store works correctly with in various 96 | different situations. 97 | - `test/types.ts`: Declares types for the tests. 98 | 99 | #### `config/` 100 | 101 | - `config/husky/pre-commit`: The bash script to run just before someone runs 102 | `git commit`. 103 | 104 | ### Documentation and testing 105 | 106 | When adding a new feature/fixing a bug, please add/update the readme and 107 | changelog as well as add tests for the same. Also make sure the codebase passes 108 | the linter and library tests by running `npm test`. Note that running 109 | `npm run format` will automatically resolve most style/lint issues. 110 | 111 | Once you have made changes to the code, you will want to 112 | [`commit`](https://github.com/git-guides/git-commit) (basically, Git's version 113 | of save) the changes. To commit the changes you have made locally: 114 | 115 | ```sh 116 | > git add this/folder that-file.js 117 | > git commit --message 'commit-message' 118 | ``` 119 | 120 | While writing the `commit-message`, try to follow the below guidelines: 121 | 122 | 1. Prefix the message with `type:`, where `type` is one of the following 123 | dependending on what the commit does: 124 | - `fix`: Introduces a bug fix. 125 | - `feat`: Adds a new feature. 126 | - `test`: Any change related to tests. 127 | - `perf`: Any performance related change. 128 | - `meta`: Any change related to the build process, workflows, issue 129 | templates, etc. 130 | - `refc`: Any refactoring work. 131 | - `docs`: Any documentation related changes. 132 | 2. Keep the first line brief, and less than 60 characters. 133 | 3. Try describing the change in detail in a new paragraph (double newline after 134 | the first line). 135 | 136 | When you commit files, `husky` and `lint-staged` will automatically lint the 137 | code and fix most issues. In case an error is not automatically fixable, they 138 | will cancel the commit. Please fix the errors before committing the changes. If 139 | you still wish to commit the changes, prefix the `git commit` command with 140 | `HUSKY=0`, like so: 141 | 142 | ```sh 143 | > HUSKY=0 git commit --message 'commit-message' 144 | ``` 145 | 146 | ## Contributing Changes 147 | 148 | Once you have committed your changes, you will want to 149 | [`push`](https://github.com/git-guides/git-push) (basically, publish your 150 | changes to GitHub) your commits. To push your changes to your fork: 151 | 152 | ```sh 153 | > git push origin branch-name 154 | ``` 155 | 156 | If there are changes made to the `next` branch of the 157 | `express-rate-limit/rate-limit-memcached` repository, you may wish to merge 158 | those changes into your branch. To do so, you can run the following commands: 159 | 160 | ``` 161 | > git fetch upstream next 162 | > git merge upstream/next 163 | ``` 164 | 165 | This will automatically add the changes from `next` branch of the 166 | `express-rate-limit/rate-limit-memcached` repository to the current branch. If 167 | you encounter any merge conflicts, follow 168 | [this guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line) 169 | to resolve them. 170 | 171 | Once you have pushed your changes to your fork, follow 172 | [these instructions](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) 173 | to open a 174 | [`pull request`](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests): 175 | 176 | Once you have submitted a pull request, the maintainers of the repository will 177 | review your pull requests. Whenever a maintainer reviews a pull request they may 178 | request changes. These may be small, such as fixing a typo, or may involve 179 | substantive changes. Such requests are intended to be helpful, but at times may 180 | come across as abrupt or unhelpful, especially if they do not include concrete 181 | suggestions on how to change them. Try not to be discouraged. If you feel that a 182 | review is unfair, say so or seek the input of another project contributor. Often 183 | such comments are the result of a reviewer having taken insufficient time to 184 | review and are not ill-intended. Such difficulties can often be resolved with a 185 | bit of patience. That said, reviewers should be expected to provide helpful 186 | feedback. 187 | 188 | In order to land, a pull request needs to be reviewed and approved by at least 189 | one maintainer and pass CI. After that, if there are no objections from other 190 | contributors, the pull request can be merged. 191 | 192 | #### Congratulations and thanks for your contribution! 193 | 194 | 195 | --------------------------------------------------------------------------------