diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e4522cf --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs` +0df0c4f40687ceca4155f7bad6c30e418c5748a6 # prettier, 5 files diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b698663 --- /dev/null +++ b/.prettierignore @@ -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 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a2f11f0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d7ae36..cd6c54d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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 } }, }, -) +); diff --git a/package-lock.json b/package-lock.json index f615a39..1b832ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,24 @@ { "name": "limitkit", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "limitkit", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "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" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1308,6 +1309,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 946a7df..d1f1529 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/http.ts b/src/http.ts index 980eb7b..36568ff 100644 --- a/src/http.ts +++ b/src/http.ts @@ -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") ?? "") diff --git a/src/limit.ts b/src/limit.ts index c0cd98c..8eb9687 100644 --- a/src/limit.ts +++ b/src/limit.ts @@ -69,8 +69,7 @@ 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: { @@ -78,9 +77,7 @@ function decide( 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)), }, }; } diff --git a/test/limit.test.js b/test/limit.test.js index ffc5950..a8cf4b9 100644 --- a/test/limit.test.js +++ b/test/limit.test.js @@ -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); }); diff --git a/test/store-http.test.js b/test/store-http.test.js index 65ba383..2a46ddd 100644 --- a/test/store-http.test.js +++ b/test/store-http.test.js @@ -1,9 +1,9 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import { test } from "node:test"; +import assert from "node:assert/strict"; -import { MemoryStore, slidingWindow, toHeaders, clientIp } from 'limitkit'; +import { MemoryStore, slidingWindow, toHeaders, clientIp } from "limitkit"; -test('THE STORE IS BOUNDED — stranger keys cannot grow the process forever', () => { +test("THE STORE IS BOUNDED — stranger keys cannot grow the process forever", () => { // The bug this makes impossible: one replaced implementation kept a bare // Map keyed by client IP with no eviction, so every stranger who ever hit // the endpoint left an entry until the process died. Bounded means the @@ -15,74 +15,85 @@ test('THE STORE IS BOUNDED — stranger keys cannot grow the process forever', ( assert.ok(store.size <= 100, `store grew to ${store.size}`); }); -test('eviction is by recency of use, not insertion order', () => { +test("eviction is by recency of use, not insertion order", () => { const store = new MemoryStore(2); - store.set('a', { hits: [1] }); - store.set('b', { hits: [1] }); - store.get('a'); // touch a — b becomes the eviction candidate - store.set('c', { hits: [1] }); - assert.ok(store.get('a'), 'recently-used key evicted'); - assert.equal(store.get('b'), undefined, 'least-recently-used key kept'); + store.set("a", { hits: [1] }); + store.set("b", { hits: [1] }); + store.get("a"); // touch a — b becomes the eviction candidate + store.set("c", { hits: [1] }); + assert.ok(store.get("a"), "recently-used key evicted"); + assert.equal(store.get("b"), undefined, "least-recently-used key kept"); }); -test('an emptied window releases its key entirely', () => { +test("an emptied window releases its key entirely", () => { const store = new MemoryStore(); - store.set('k', { hits: [1, 2] }); - store.set('k', { hits: [] }); - assert.equal(store.size, 0, 'empty state must free the slot, not squat on it'); + store.set("k", { hits: [1, 2] }); + store.set("k", { hits: [] }); + assert.equal(store.size, 0, "empty state must free the slot, not squat on it"); }); -test('toHeaders emits the standard trio, Retry-After only on refusal', () => { +test("toHeaders emits the standard trio, Retry-After only on refusal", () => { const rl = slidingWindow({ limit: 1, windowMs: 60_000 }); - const ok = toHeaders(rl.check('k', 1_000_000)); - assert.equal(ok['X-RateLimit-Limit'], '1'); - assert.equal(ok['X-RateLimit-Remaining'], '0'); - assert.equal(ok['Retry-After'], undefined, 'an allowed response must not tell anyone to wait'); + const ok = toHeaders(rl.check("k", 1_000_000)); + assert.equal(ok["X-RateLimit-Limit"], "1"); + assert.equal(ok["X-RateLimit-Remaining"], "0"); + assert.equal(ok["Retry-After"], undefined, "an allowed response must not tell anyone to wait"); - const no = toHeaders(rl.check('k', 1_000_001)); - assert.equal(no['Retry-After'], '60'); - assert.equal(no['X-RateLimit-Reset'], String(Math.ceil((1_000_000 + 60_000) / 1000))); + const no = toHeaders(rl.check("k", 1_000_001)); + assert.equal(no["Retry-After"], "60"); + assert.equal(no["X-RateLimit-Reset"], String(Math.ceil((1_000_000 + 60_000) / 1000))); }); const h = (map) => ({ get: (k) => map[k.toLowerCase()] ?? null }); -test('clientIp: the hop the PROXY wrote, not the one the client sent', () => { +test("clientIp: the hop the PROXY wrote, not the one the client sent", () => { // A proxy APPENDS. So this header reads ", " // and only the last entry is unforgeable. The previous version returned // 9.9.9.9 here — the attacker's own value — and a test pinned it. - assert.equal(clientIp(h({ 'x-forwarded-for': '9.9.9.9, 10.0.0.1' })), '10.0.0.1'); + assert.equal(clientIp(h({ "x-forwarded-for": "9.9.9.9, 10.0.0.1" })), "10.0.0.1"); // The bypass this closes: vary the header per request and every request is a // new bucket, so no bucket ever fills. Same real client, one key. const keys = new Set( - ['1.1.1.1', '2.2.2.2', '3.3.3.3'].map((spoof) => - clientIp(h({ 'x-forwarded-for': `${spoof}, 203.0.113.7` })), + ["1.1.1.1", "2.2.2.2", "3.3.3.3"].map((spoof) => + clientIp(h({ "x-forwarded-for": `${spoof}, 203.0.113.7` })), ), ); - assert.deepEqual([...keys], ['203.0.113.7'], 'a spoofed prefix must not mint new buckets'); + assert.deepEqual([...keys], ["203.0.113.7"], "a spoofed prefix must not mint new buckets"); // A single hop is the normal case: nothing was forged, the proxy wrote it. - assert.equal(clientIp(h({ 'x-forwarded-for': '203.0.113.7' })), '203.0.113.7'); + assert.equal(clientIp(h({ "x-forwarded-for": "203.0.113.7" })), "203.0.113.7"); }); -test('clientIp: trustedProxies moves the hop, and 0 believes nothing', () => { +test("clientIp: trustedProxies moves the hop, and 0 believes nothing", () => { // Two of our own proxies (a CDN in front of Caddy) — the answer is one further left. assert.equal( - clientIp(h({ 'x-forwarded-for': 'evil, 203.0.113.7, 10.0.0.1' }), { trustedProxies: 2 }), - '203.0.113.7', + clientIp(h({ "x-forwarded-for": "evil, 203.0.113.7, 10.0.0.1" }), { trustedProxies: 2 }), + "203.0.113.7", ); // Directly exposed: every forwarded header is written by the client, so none // is evidence. "unknown" is honest; a number here would look like proof. - assert.equal(clientIp(h({ 'x-forwarded-for': 'evil' }), { trustedProxies: 0 }), 'unknown'); + assert.equal(clientIp(h({ "x-forwarded-for": "evil" }), { trustedProxies: 0 }), "unknown"); // Fewer hops than configured — still the entry our own proxy wrote. - assert.equal(clientIp(h({ 'x-forwarded-for': '203.0.113.7' }), { trustedProxies: 3 }), '203.0.113.7'); + assert.equal( + clientIp(h({ "x-forwarded-for": "203.0.113.7" }), { trustedProxies: 3 }), + "203.0.113.7", + ); }); -test('clientIp: absence degrades to a shared bucket, never a throw', () => { - assert.equal(clientIp(h({ 'x-real-ip': '8.8.8.8' })), '8.8.8.8'); +test("clientIp: absence degrades to a shared bucket, never a throw", () => { + assert.equal(clientIp(h({ "x-real-ip": "8.8.8.8" })), "8.8.8.8"); // "unknown" throttles anonymous traffic COLLECTIVELY — the right failure // mode for abuse-blunting, and it must never throw. - assert.equal(clientIp(h({})), 'unknown'); - assert.equal(clientIp(h({ 'x-forwarded-for': '' })), 'unknown', 'an empty header is not an identity'); - assert.equal(clientIp(h({ 'x-forwarded-for': ' , , ' })), 'unknown', 'separators alone are not an identity'); + assert.equal(clientIp(h({})), "unknown"); + assert.equal( + clientIp(h({ "x-forwarded-for": "" })), + "unknown", + "an empty header is not an identity", + ); + assert.equal( + clientIp(h({ "x-forwarded-for": " , , " })), + "unknown", + "separators alone are not an identity", + ); });