Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs`
0df0c4f40687ceca4155f7bad6c30e418c5748a6 # prettier, 5 files
31 changes: 31 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Build output and vendored trees — formatting these is noise.
node_modules
.next
dist
build
out
coverage
.turbo
.vercel
*.min.js
*.min.css

# Generated during a build, so it is absent locally and present in CI — which
# makes a clean local --check no evidence at all. Contentlayer's output also
# uses import assertions, which prettier's parser rejects outright.
.contentlayer
.astro
.svelte-kit
storybook-static
test-results
playwright-report

# Lockfiles are generated; prettier would rewrite them wholesale.
package-lock.json
pnpm-lock.yaml
yarn.lock

# Markdown is deliberately out of scope for now. Prettier rewraps prose, which
# is where it is most opinionated and least useful, and it would bury the real
# diff. Remove this line when you want docs formatted too.
*.md
9 changes: 9 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf"
}
14 changes: 7 additions & 7 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@
// library code, and a bespoke rule set would be a second opinion to maintain
// for no benefit. The floor is "lint runs and can fail", not "lint encodes
// taste".
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";

export default tseslint.config(
{
// dist/ is generated by `tsc`.
ignores: ['dist/**', 'node_modules/**'],
ignores: ["dist/**", "node_modules/**"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.ts'],
files: ["**/*.ts"],
languageOptions: { globals: globals.node },
},
{
// Tests are plain Node ESM running under `node --test`.
files: ['test/**/*.js', 'scripts/**/*.{js,mjs}'],
files: ["test/**/*.js", "scripts/**/*.{js,mjs}"],
languageOptions: { globals: { ...globals.node, ...globals.nodeBuiltin } },
},
)
);
23 changes: 20 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,17 @@
"lint": "eslint .",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --test test/*.test.js",
"verify": "npm run lint && npm run typecheck && npm run build && npm test",
"prepare": "npm run build"
"verify": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm test",
"prepare": "npm run build",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/node": "^22.10.2",
"eslint": "^9.39.5",
"globals": "^15.15.0",
"prettier": "3.9.6",
"typescript": "^5.8.2",
"typescript-eslint": "^8.67.0"
}
Expand Down
5 changes: 1 addition & 4 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,7 @@ export type HeadersLike = { get(name: string): string | null };
* throttles the anonymous bucket collectively, which is the right failure mode
* for the abuse this exists to blunt.
*/
export function clientIp(
headers: HeadersLike,
opts: { trustedProxies?: number } = {},
): string {
export function clientIp(headers: HeadersLike, opts: { trustedProxies?: number } = {}): string {
const trusted = opts.trustedProxies ?? 1;
if (trusted > 0) {
const hops = (headers.get("x-forwarded-for") ?? "")
Expand Down
7 changes: 2 additions & 5 deletions src/limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,15 @@ function decide(
const live = hits.filter((t) => t > now - rule.windowMs);
const allowed = live.length < rule.limit;
const oldest = live[0];
const resetAt =
live.length === 0 ? now : (oldest ?? now) + rule.windowMs;
const resetAt = live.length === 0 ? now : (oldest ?? now) + rule.windowMs;
return {
allowed,
result: {
allowed,
limit: rule.limit,
remaining: Math.max(0, rule.limit - live.length - (allowed ? 1 : 0)),
resetAt,
retryAfterSeconds: allowed
? 0
: Math.max(1, Math.ceil((resetAt - now) / 1000)),
retryAfterSeconds: allowed ? 0 : Math.max(1, Math.ceil((resetAt - now) / 1000)),
},
};
}
Expand Down
86 changes: 49 additions & 37 deletions test/limit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,87 +3,99 @@
* without sleeping — the thing none of the twelve replaced implementations
* could do, which is why none of them had tests.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { test } from "node:test";
import assert from "node:assert/strict";

import { slidingWindow, fixedWindow, MemoryStore } from 'limitkit';
import { slidingWindow, fixedWindow, MemoryStore } from "limitkit";

const RULE = { limit: 3, windowMs: 60_000 };
const T0 = 1_000_000;

test('sliding: allows up to the limit, then refuses', () => {
test("sliding: allows up to the limit, then refuses", () => {
const rl = slidingWindow(RULE);
assert.equal(rl.check('k', T0).allowed, true);
assert.equal(rl.check('k', T0 + 1).allowed, true);
assert.equal(rl.check('k', T0 + 2).allowed, true);
const refused = rl.check('k', T0 + 3);
assert.equal(rl.check("k", T0).allowed, true);
assert.equal(rl.check("k", T0 + 1).allowed, true);
assert.equal(rl.check("k", T0 + 2).allowed, true);
const refused = rl.check("k", T0 + 3);
assert.equal(refused.allowed, false);
assert.equal(refused.remaining, 0);
});

test('sliding: the refusal names WHEN, derived from the oldest hit', () => {
test("sliding: the refusal names WHEN, derived from the oldest hit", () => {
const rl = slidingWindow(RULE);
rl.check('k', T0); rl.check('k', T0 + 10_000); rl.check('k', T0 + 20_000);
const refused = rl.check('k', T0 + 30_000);
rl.check("k", T0);
rl.check("k", T0 + 10_000);
rl.check("k", T0 + 20_000);
const refused = rl.check("k", T0 + 30_000);
// The oldest hit (T0) ages out at T0+60s; from T0+30s that is 30s away.
assert.equal(refused.retryAfterSeconds, 30);
assert.equal(refused.resetAt, T0 + 60_000);
});

test('sliding: the window actually slides — old hits age out one by one', () => {
test("sliding: the window actually slides — old hits age out one by one", () => {
const rl = slidingWindow(RULE);
rl.check('k', T0); rl.check('k', T0 + 10_000); rl.check('k', T0 + 20_000);
rl.check("k", T0);
rl.check("k", T0 + 10_000);
rl.check("k", T0 + 20_000);
// At T0+61s the first hit has aged out: exactly one slot free.
assert.equal(rl.check('k', T0 + 61_000).allowed, true);
assert.equal(rl.check('k', T0 + 61_001).allowed, false);
assert.equal(rl.check("k", T0 + 61_000).allowed, true);
assert.equal(rl.check("k", T0 + 61_001).allowed, false);
});

test('A REFUSAL COUNTS NOTHING — hammering cannot extend the lockout', () => {
test("A REFUSAL COUNTS NOTHING — hammering cannot extend the lockout", () => {
// The failure this prevents: an attacker retrying in a loop keeps the
// window eternally full, and the legitimate user behind the same NAT never
// gets back in. Refusals must not feed the counter.
const rl = slidingWindow(RULE);
rl.check('k', T0); rl.check('k', T0 + 1); rl.check('k', T0 + 2);
for (let i = 0; i < 50; i++) rl.check('k', T0 + 10_000 + i);
rl.check("k", T0);
rl.check("k", T0 + 1);
rl.check("k", T0 + 2);
for (let i = 0; i < 50; i++) rl.check("k", T0 + 10_000 + i);
// All three real hits age out at T0+60_002 regardless of the hammering.
assert.equal(rl.check('k', T0 + 61_000).allowed, true);
assert.equal(rl.check("k", T0 + 61_000).allowed, true);
});

test('keys are independent', () => {
test("keys are independent", () => {
const rl = slidingWindow(RULE);
rl.check('a', T0); rl.check('a', T0); rl.check('a', T0);
assert.equal(rl.check('a', T0 + 1).allowed, false);
assert.equal(rl.check('b', T0 + 1).allowed, true);
rl.check("a", T0);
rl.check("a", T0);
rl.check("a", T0);
assert.equal(rl.check("a", T0 + 1).allowed, false);
assert.equal(rl.check("b", T0 + 1).allowed, true);
});

test('peek decides without counting', () => {
test("peek decides without counting", () => {
const rl = slidingWindow(RULE);
for (let i = 0; i < 10; i++) rl.peek('k', T0 + i);
assert.equal(rl.check('k', T0 + 11).allowed, true, 'peeks must not consume the allowance');
for (let i = 0; i < 10; i++) rl.peek("k", T0 + i);
assert.equal(rl.check("k", T0 + 11).allowed, true, "peeks must not consume the allowance");
});

test('fixed: the whole allowance returns when the bucket rolls', () => {
test("fixed: the whole allowance returns when the bucket rolls", () => {
const rl = fixedWindow(RULE);
const start = 1_200_000; // aligned: divisible by 60_000
rl.check('k', start); rl.check('k', start + 1); rl.check('k', start + 2);
assert.equal(rl.check('k', start + 3).allowed, false);
assert.equal(rl.check('k', start + 60_000).allowed, true, 'new bucket, fresh allowance');
rl.check("k", start);
rl.check("k", start + 1);
rl.check("k", start + 2);
assert.equal(rl.check("k", start + 3).allowed, false);
assert.equal(rl.check("k", start + 60_000).allowed, true, "new bucket, fresh allowance");
});

test('fixed: refusal points at the bucket boundary', () => {
test("fixed: refusal points at the bucket boundary", () => {
const rl = fixedWindow(RULE);
const start = 1_200_000;
rl.check('k', start); rl.check('k', start); rl.check('k', start);
const refused = rl.check('k', start + 45_000);
rl.check("k", start);
rl.check("k", start);
rl.check("k", start);
const refused = rl.check("k", start + 45_000);
assert.equal(refused.retryAfterSeconds, 15);
});

test('a shared store serves multiple limiters without cross-talk', () => {
test("a shared store serves multiple limiters without cross-talk", () => {
const store = new MemoryStore();
const login = slidingWindow({ limit: 1, windowMs: 60_000 }, store);
const search = slidingWindow({ limit: 5, windowMs: 60_000 }, store);
// Same underlying store, DIFFERENT keys per concern — the app namespaces.
assert.equal(login.check('login:1.2.3.4', T0).allowed, true);
assert.equal(login.check('login:1.2.3.4', T0 + 1).allowed, false);
assert.equal(search.check('search:1.2.3.4', T0 + 1).allowed, true);
assert.equal(login.check("login:1.2.3.4", T0).allowed, true);
assert.equal(login.check("login:1.2.3.4", T0 + 1).allowed, false);
assert.equal(search.check("search:1.2.3.4", T0 + 1).allowed, true);
});
Loading