halt-rate 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/adapters/fastify.d.mts +29 -0
- package/dist/adapters/fastify.d.ts +29 -0
- package/dist/adapters/fastify.js +38 -0
- package/dist/adapters/fastify.js.map +1 -0
- package/dist/adapters/fastify.mjs +35 -0
- package/dist/adapters/fastify.mjs.map +1 -0
- package/dist/adapters/graphql.d.mts +22 -0
- package/dist/adapters/graphql.d.ts +22 -0
- package/dist/adapters/graphql.js +51 -0
- package/dist/adapters/graphql.js.map +1 -0
- package/dist/adapters/graphql.mjs +49 -0
- package/dist/adapters/graphql.mjs.map +1 -0
- package/dist/adapters/hono.d.mts +30 -0
- package/dist/adapters/hono.d.ts +30 -0
- package/dist/adapters/hono.js +56 -0
- package/dist/adapters/hono.js.map +1 -0
- package/dist/adapters/hono.mjs +54 -0
- package/dist/adapters/hono.mjs.map +1 -0
- package/dist/adapters/next.js +3 -2
- package/dist/adapters/next.js.map +1 -1
- package/dist/adapters/next.mjs +3 -2
- package/dist/adapters/next.mjs.map +1 -1
- package/dist/index.d.mts +49 -1
- package/dist/index.d.ts +49 -1
- package/dist/index.js +69 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +68 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +66 -11
package/README.md
CHANGED
|
@@ -39,9 +39,17 @@ observability are covered in the docs → **[halt.afroawi.com/docs](https://halt
|
|
|
39
39
|
- Algorithms: token bucket, fixed/sliding window, leaky bucket
|
|
40
40
|
- Keys: IP, user, API key, composite, or custom
|
|
41
41
|
- Atomic **Redis** store (Lua, cluster-safe, fail-open) + in-memory dev store
|
|
42
|
+
- **Dynamic limits**: change limits at runtime with `PolicyRegistry` / cached loaders — no restart
|
|
42
43
|
- SaaS: per-plan limits, quotas, weighted endpoints, abuse penalties
|
|
43
44
|
- Observability: `StatsCollector` + OpenTelemetry metrics
|
|
44
|
-
- Adapters: Express, Next.js
|
|
45
|
+
- Adapters: Express, Next.js, **Hono**, **Fastify**, GraphQL (Apollo)
|
|
46
|
+
|
|
47
|
+
## Runtime support
|
|
48
|
+
|
|
49
|
+
Ships dual **ESM + CJS** with types. The core (limiter, algorithms, in-memory store) is
|
|
50
|
+
**edge-safe** — runs on Cloudflare Workers, Deno, Bun, and Vercel Edge. `RedisStore` is Node-only
|
|
51
|
+
(TCP); for edge distributed limits, inject any `RedisClientLike` (e.g. a fetch/REST client).
|
|
52
|
+
Full guidance: **[halt.afroawi.com/docs](https://halt.afroawi.com/docs)**.
|
|
45
53
|
|
|
46
54
|
## Links
|
|
47
55
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { o as RateLimiter } from '../limiter-DqVVE0Kl.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fastify adapter for Halt rate limiting.
|
|
6
|
+
*
|
|
7
|
+
* import Fastify from 'fastify';
|
|
8
|
+
* import { RateLimiter, InMemoryStore, presets } from 'halt-rate';
|
|
9
|
+
* import { haltHook } from 'halt-rate/fastify';
|
|
10
|
+
*
|
|
11
|
+
* const limiter = new RateLimiter({ store: new InMemoryStore(), policy: presets.PUBLIC_API });
|
|
12
|
+
* const app = Fastify();
|
|
13
|
+
* app.addHook('preHandler', haltHook({ limiter }));
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface FastifyHaltOptions {
|
|
17
|
+
limiter: RateLimiter;
|
|
18
|
+
/** Custom blocked response. */
|
|
19
|
+
onBlocked?: (request: FastifyRequest, reply: FastifyReply) => unknown;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Create a Fastify `preHandler` hook for rate limiting. Fastify's request already
|
|
23
|
+
* exposes `ip`, `headers`, and `url`, which Halt's extractors understand directly.
|
|
24
|
+
*/
|
|
25
|
+
declare function haltHook(options: FastifyHaltOptions): (request: FastifyRequest, reply: FastifyReply) => Promise<unknown>;
|
|
26
|
+
/** Alias for symmetry with the other adapters. */
|
|
27
|
+
declare const haltMiddleware: typeof haltHook;
|
|
28
|
+
|
|
29
|
+
export { type FastifyHaltOptions, haltHook, haltMiddleware };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { o as RateLimiter } from '../limiter-DqVVE0Kl.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fastify adapter for Halt rate limiting.
|
|
6
|
+
*
|
|
7
|
+
* import Fastify from 'fastify';
|
|
8
|
+
* import { RateLimiter, InMemoryStore, presets } from 'halt-rate';
|
|
9
|
+
* import { haltHook } from 'halt-rate/fastify';
|
|
10
|
+
*
|
|
11
|
+
* const limiter = new RateLimiter({ store: new InMemoryStore(), policy: presets.PUBLIC_API });
|
|
12
|
+
* const app = Fastify();
|
|
13
|
+
* app.addHook('preHandler', haltHook({ limiter }));
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface FastifyHaltOptions {
|
|
17
|
+
limiter: RateLimiter;
|
|
18
|
+
/** Custom blocked response. */
|
|
19
|
+
onBlocked?: (request: FastifyRequest, reply: FastifyReply) => unknown;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Create a Fastify `preHandler` hook for rate limiting. Fastify's request already
|
|
23
|
+
* exposes `ip`, `headers`, and `url`, which Halt's extractors understand directly.
|
|
24
|
+
*/
|
|
25
|
+
declare function haltHook(options: FastifyHaltOptions): (request: FastifyRequest, reply: FastifyReply) => Promise<unknown>;
|
|
26
|
+
/** Alias for symmetry with the other adapters. */
|
|
27
|
+
declare const haltMiddleware: typeof haltHook;
|
|
28
|
+
|
|
29
|
+
export { type FastifyHaltOptions, haltHook, haltMiddleware };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/core/decision.ts
|
|
4
|
+
function toHeaders(decision) {
|
|
5
|
+
const headers = {
|
|
6
|
+
"RateLimit-Limit": String(decision.limit),
|
|
7
|
+
"RateLimit-Remaining": String(Math.max(0, decision.remaining)),
|
|
8
|
+
"RateLimit-Reset": String(decision.resetAt)
|
|
9
|
+
};
|
|
10
|
+
if (!decision.allowed && decision.retryAfter !== void 0) {
|
|
11
|
+
headers["Retry-After"] = String(decision.retryAfter);
|
|
12
|
+
}
|
|
13
|
+
return headers;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/adapters/fastify.ts
|
|
17
|
+
function haltHook(options) {
|
|
18
|
+
const { limiter, onBlocked } = options;
|
|
19
|
+
return async (request, reply) => {
|
|
20
|
+
const decision = await limiter.check(request);
|
|
21
|
+
for (const [key, value] of Object.entries(toHeaders(decision))) {
|
|
22
|
+
reply.header(key, value);
|
|
23
|
+
}
|
|
24
|
+
if (decision.allowed) return;
|
|
25
|
+
if (onBlocked) return onBlocked(request, reply);
|
|
26
|
+
return reply.code(429).send({
|
|
27
|
+
error: "rate_limit_exceeded",
|
|
28
|
+
message: "Too many requests. Please try again later.",
|
|
29
|
+
retryAfter: decision.retryAfter
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
var haltMiddleware = haltHook;
|
|
34
|
+
|
|
35
|
+
exports.haltHook = haltHook;
|
|
36
|
+
exports.haltMiddleware = haltMiddleware;
|
|
37
|
+
//# sourceMappingURL=fastify.js.map
|
|
38
|
+
//# sourceMappingURL=fastify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/decision.ts","../../src/adapters/fastify.ts"],"names":[],"mappings":";;;AAoBO,SAAS,UAAU,QAAA,EAA4C;AAClE,EAAA,MAAM,OAAA,GAAkC;AAAA,IACpC,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA;AAAA,IACxC,uBAAuB,MAAA,CAAO,IAAA,CAAK,IAAI,CAAA,EAAG,QAAA,CAAS,SAAS,CAAC,CAAA;AAAA,IAC7D,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,OAAO;AAAA,GAC9C;AAEA,EAAA,IAAI,CAAC,QAAA,CAAS,OAAA,IAAW,QAAA,CAAS,eAAe,MAAA,EAAW;AACxD,IAAA,OAAA,CAAQ,aAAa,CAAA,GAAI,MAAA,CAAO,QAAA,CAAS,UAAU,CAAA;AAAA,EACvD;AAEA,EAAA,OAAO,OAAA;AACX;;;ACNO,SAAS,SAAS,OAAA,EAA6B;AAClD,EAAA,MAAM,EAAE,OAAA,EAAS,SAAA,EAAU,GAAI,OAAA;AAE/B,EAAA,OAAO,OAAO,SAAyB,KAAA,KAAwB;AAC3D,IAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,KAAA,CAAM,OAAO,CAAA;AAE5C,IAAA,KAAA,MAAW,CAAC,KAAK,KAAK,CAAA,IAAK,OAAO,OAAA,CAAQ,SAAA,CAAU,QAAQ,CAAC,CAAA,EAAG;AAC5D,MAAA,KAAA,CAAM,MAAA,CAAO,KAAK,KAAK,CAAA;AAAA,IAC3B;AAEA,IAAA,IAAI,SAAS,OAAA,EAAS;AAEtB,IAAA,IAAI,SAAA,EAAW,OAAO,SAAA,CAAU,OAAA,EAAS,KAAK,CAAA;AAC9C,IAAA,OAAO,KAAA,CAAM,IAAA,CAAK,GAAG,CAAA,CAAE,IAAA,CAAK;AAAA,MACxB,KAAA,EAAO,qBAAA;AAAA,MACP,OAAA,EAAS,4CAAA;AAAA,MACT,YAAY,QAAA,CAAS;AAAA,KACxB,CAAA;AAAA,EACL,CAAA;AACJ;AAGO,IAAM,cAAA,GAAiB","file":"fastify.js","sourcesContent":["/**\n * Decision model for rate limiting results.\n */\n\nexport interface Decision {\n /** Whether the request is allowed */\n allowed: boolean;\n /** Maximum number of requests allowed in the window */\n limit: number;\n /** Number of requests remaining in the current window */\n remaining: number;\n /** Unix timestamp when the limit resets */\n resetAt: number;\n /** Seconds to wait before retrying (only set when blocked) */\n retryAfter?: number;\n}\n\n/**\n * Convert decision to standard rate limit headers.\n */\nexport function toHeaders(decision: Decision): Record<string, string> {\n const headers: Record<string, string> = {\n 'RateLimit-Limit': String(decision.limit),\n 'RateLimit-Remaining': String(Math.max(0, decision.remaining)),\n 'RateLimit-Reset': String(decision.resetAt),\n };\n\n if (!decision.allowed && decision.retryAfter !== undefined) {\n headers['Retry-After'] = String(decision.retryAfter);\n }\n\n return headers;\n}\n","/**\n * Fastify adapter for Halt rate limiting.\n *\n * import Fastify from 'fastify';\n * import { RateLimiter, InMemoryStore, presets } from 'halt-rate';\n * import { haltHook } from 'halt-rate/fastify';\n *\n * const limiter = new RateLimiter({ store: new InMemoryStore(), policy: presets.PUBLIC_API });\n * const app = Fastify();\n * app.addHook('preHandler', haltHook({ limiter }));\n */\n\nimport type { FastifyReply, FastifyRequest } from 'fastify';\nimport { RateLimiter } from '../core/limiter';\nimport { toHeaders } from '../core/decision';\n\nexport interface FastifyHaltOptions {\n limiter: RateLimiter;\n /** Custom blocked response. */\n onBlocked?: (request: FastifyRequest, reply: FastifyReply) => unknown;\n}\n\n/**\n * Create a Fastify `preHandler` hook for rate limiting. Fastify's request already\n * exposes `ip`, `headers`, and `url`, which Halt's extractors understand directly.\n */\nexport function haltHook(options: FastifyHaltOptions) {\n const { limiter, onBlocked } = options;\n\n return async (request: FastifyRequest, reply: FastifyReply) => {\n const decision = await limiter.check(request);\n\n for (const [key, value] of Object.entries(toHeaders(decision))) {\n reply.header(key, value);\n }\n\n if (decision.allowed) return;\n\n if (onBlocked) return onBlocked(request, reply);\n return reply.code(429).send({\n error: 'rate_limit_exceeded',\n message: 'Too many requests. Please try again later.',\n retryAfter: decision.retryAfter,\n });\n };\n}\n\n/** Alias for symmetry with the other adapters. */\nexport const haltMiddleware = haltHook;\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/core/decision.ts
|
|
2
|
+
function toHeaders(decision) {
|
|
3
|
+
const headers = {
|
|
4
|
+
"RateLimit-Limit": String(decision.limit),
|
|
5
|
+
"RateLimit-Remaining": String(Math.max(0, decision.remaining)),
|
|
6
|
+
"RateLimit-Reset": String(decision.resetAt)
|
|
7
|
+
};
|
|
8
|
+
if (!decision.allowed && decision.retryAfter !== void 0) {
|
|
9
|
+
headers["Retry-After"] = String(decision.retryAfter);
|
|
10
|
+
}
|
|
11
|
+
return headers;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/adapters/fastify.ts
|
|
15
|
+
function haltHook(options) {
|
|
16
|
+
const { limiter, onBlocked } = options;
|
|
17
|
+
return async (request, reply) => {
|
|
18
|
+
const decision = await limiter.check(request);
|
|
19
|
+
for (const [key, value] of Object.entries(toHeaders(decision))) {
|
|
20
|
+
reply.header(key, value);
|
|
21
|
+
}
|
|
22
|
+
if (decision.allowed) return;
|
|
23
|
+
if (onBlocked) return onBlocked(request, reply);
|
|
24
|
+
return reply.code(429).send({
|
|
25
|
+
error: "rate_limit_exceeded",
|
|
26
|
+
message: "Too many requests. Please try again later.",
|
|
27
|
+
retryAfter: decision.retryAfter
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
var haltMiddleware = haltHook;
|
|
32
|
+
|
|
33
|
+
export { haltHook, haltMiddleware };
|
|
34
|
+
//# sourceMappingURL=fastify.mjs.map
|
|
35
|
+
//# sourceMappingURL=fastify.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/decision.ts","../../src/adapters/fastify.ts"],"names":[],"mappings":";AAoBO,SAAS,UAAU,QAAA,EAA4C;AAClE,EAAA,MAAM,OAAA,GAAkC;AAAA,IACpC,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA;AAAA,IACxC,uBAAuB,MAAA,CAAO,IAAA,CAAK,IAAI,CAAA,EAAG,QAAA,CAAS,SAAS,CAAC,CAAA;AAAA,IAC7D,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,OAAO;AAAA,GAC9C;AAEA,EAAA,IAAI,CAAC,QAAA,CAAS,OAAA,IAAW,QAAA,CAAS,eAAe,MAAA,EAAW;AACxD,IAAA,OAAA,CAAQ,aAAa,CAAA,GAAI,MAAA,CAAO,QAAA,CAAS,UAAU,CAAA;AAAA,EACvD;AAEA,EAAA,OAAO,OAAA;AACX;;;ACNO,SAAS,SAAS,OAAA,EAA6B;AAClD,EAAA,MAAM,EAAE,OAAA,EAAS,SAAA,EAAU,GAAI,OAAA;AAE/B,EAAA,OAAO,OAAO,SAAyB,KAAA,KAAwB;AAC3D,IAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,KAAA,CAAM,OAAO,CAAA;AAE5C,IAAA,KAAA,MAAW,CAAC,KAAK,KAAK,CAAA,IAAK,OAAO,OAAA,CAAQ,SAAA,CAAU,QAAQ,CAAC,CAAA,EAAG;AAC5D,MAAA,KAAA,CAAM,MAAA,CAAO,KAAK,KAAK,CAAA;AAAA,IAC3B;AAEA,IAAA,IAAI,SAAS,OAAA,EAAS;AAEtB,IAAA,IAAI,SAAA,EAAW,OAAO,SAAA,CAAU,OAAA,EAAS,KAAK,CAAA;AAC9C,IAAA,OAAO,KAAA,CAAM,IAAA,CAAK,GAAG,CAAA,CAAE,IAAA,CAAK;AAAA,MACxB,KAAA,EAAO,qBAAA;AAAA,MACP,OAAA,EAAS,4CAAA;AAAA,MACT,YAAY,QAAA,CAAS;AAAA,KACxB,CAAA;AAAA,EACL,CAAA;AACJ;AAGO,IAAM,cAAA,GAAiB","file":"fastify.mjs","sourcesContent":["/**\n * Decision model for rate limiting results.\n */\n\nexport interface Decision {\n /** Whether the request is allowed */\n allowed: boolean;\n /** Maximum number of requests allowed in the window */\n limit: number;\n /** Number of requests remaining in the current window */\n remaining: number;\n /** Unix timestamp when the limit resets */\n resetAt: number;\n /** Seconds to wait before retrying (only set when blocked) */\n retryAfter?: number;\n}\n\n/**\n * Convert decision to standard rate limit headers.\n */\nexport function toHeaders(decision: Decision): Record<string, string> {\n const headers: Record<string, string> = {\n 'RateLimit-Limit': String(decision.limit),\n 'RateLimit-Remaining': String(Math.max(0, decision.remaining)),\n 'RateLimit-Reset': String(decision.resetAt),\n };\n\n if (!decision.allowed && decision.retryAfter !== undefined) {\n headers['Retry-After'] = String(decision.retryAfter);\n }\n\n return headers;\n}\n","/**\n * Fastify adapter for Halt rate limiting.\n *\n * import Fastify from 'fastify';\n * import { RateLimiter, InMemoryStore, presets } from 'halt-rate';\n * import { haltHook } from 'halt-rate/fastify';\n *\n * const limiter = new RateLimiter({ store: new InMemoryStore(), policy: presets.PUBLIC_API });\n * const app = Fastify();\n * app.addHook('preHandler', haltHook({ limiter }));\n */\n\nimport type { FastifyReply, FastifyRequest } from 'fastify';\nimport { RateLimiter } from '../core/limiter';\nimport { toHeaders } from '../core/decision';\n\nexport interface FastifyHaltOptions {\n limiter: RateLimiter;\n /** Custom blocked response. */\n onBlocked?: (request: FastifyRequest, reply: FastifyReply) => unknown;\n}\n\n/**\n * Create a Fastify `preHandler` hook for rate limiting. Fastify's request already\n * exposes `ip`, `headers`, and `url`, which Halt's extractors understand directly.\n */\nexport function haltHook(options: FastifyHaltOptions) {\n const { limiter, onBlocked } = options;\n\n return async (request: FastifyRequest, reply: FastifyReply) => {\n const decision = await limiter.check(request);\n\n for (const [key, value] of Object.entries(toHeaders(decision))) {\n reply.header(key, value);\n }\n\n if (decision.allowed) return;\n\n if (onBlocked) return onBlocked(request, reply);\n return reply.code(429).send({\n error: 'rate_limit_exceeded',\n message: 'Too many requests. Please try again later.',\n retryAfter: decision.retryAfter,\n });\n };\n}\n\n/** Alias for symmetry with the other adapters. */\nexport const haltMiddleware = haltHook;\n"]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { o as RateLimiter } from '../limiter-DqVVE0Kl.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Apollo Server plugin adapter for Halt rate limiting.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const plugin = haltApolloPlugin({ limiter });
|
|
8
|
+
* const server = new ApolloServer({ typeDefs, resolvers, plugins: [plugin] });
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface ApolloHaltOptions {
|
|
12
|
+
limiter: RateLimiter;
|
|
13
|
+
/** Optional function to extract a resource name for per-field limits */
|
|
14
|
+
resourceExtractor?: (request: any) => string | null;
|
|
15
|
+
}
|
|
16
|
+
declare function haltApolloPlugin(options: ApolloHaltOptions): {
|
|
17
|
+
requestDidStart(): Promise<{
|
|
18
|
+
didResolveOperation(requestContext: any): Promise<void>;
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export { type ApolloHaltOptions, haltApolloPlugin };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { o as RateLimiter } from '../limiter-DqVVE0Kl.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Apollo Server plugin adapter for Halt rate limiting.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const plugin = haltApolloPlugin({ limiter });
|
|
8
|
+
* const server = new ApolloServer({ typeDefs, resolvers, plugins: [plugin] });
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface ApolloHaltOptions {
|
|
12
|
+
limiter: RateLimiter;
|
|
13
|
+
/** Optional function to extract a resource name for per-field limits */
|
|
14
|
+
resourceExtractor?: (request: any) => string | null;
|
|
15
|
+
}
|
|
16
|
+
declare function haltApolloPlugin(options: ApolloHaltOptions): {
|
|
17
|
+
requestDidStart(): Promise<{
|
|
18
|
+
didResolveOperation(requestContext: any): Promise<void>;
|
|
19
|
+
}>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export { type ApolloHaltOptions, haltApolloPlugin };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/adapters/graphql.ts
|
|
4
|
+
function haltApolloPlugin(options) {
|
|
5
|
+
const { limiter, resourceExtractor } = options;
|
|
6
|
+
return {
|
|
7
|
+
async requestDidStart() {
|
|
8
|
+
return {
|
|
9
|
+
async didResolveOperation(requestContext) {
|
|
10
|
+
try {
|
|
11
|
+
const req = requestContext.request?.http || {};
|
|
12
|
+
if (resourceExtractor) {
|
|
13
|
+
req.resource = resourceExtractor(requestContext.request);
|
|
14
|
+
}
|
|
15
|
+
const decision = await limiter.check(req);
|
|
16
|
+
if (!decision.allowed) {
|
|
17
|
+
requestContext.response = {
|
|
18
|
+
http: {
|
|
19
|
+
status: 429,
|
|
20
|
+
headers: {
|
|
21
|
+
...requestContext.response?.http?.headers,
|
|
22
|
+
"Retry-After": String(decision.retryAfter ?? 0)
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
errors: [
|
|
26
|
+
new Error("rate_limit_exceeded: Too many requests. Please try again later.")
|
|
27
|
+
]
|
|
28
|
+
};
|
|
29
|
+
} else {
|
|
30
|
+
requestContext.response = requestContext.response || {};
|
|
31
|
+
requestContext.response.http = requestContext.response.http || {};
|
|
32
|
+
requestContext.response.http.headers = {
|
|
33
|
+
...requestContext.response.http.headers,
|
|
34
|
+
"RateLimit-Limit": String(decision.limit),
|
|
35
|
+
"RateLimit-Remaining": String(Math.max(0, decision.remaining)),
|
|
36
|
+
"RateLimit-Reset": String(decision.resetAt)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
requestContext.errors = requestContext.errors || [];
|
|
41
|
+
requestContext.errors.push(err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
exports.haltApolloPlugin = haltApolloPlugin;
|
|
50
|
+
//# sourceMappingURL=graphql.js.map
|
|
51
|
+
//# sourceMappingURL=graphql.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/graphql.ts"],"names":[],"mappings":";;;AAgBO,SAAS,iBAAiB,OAAA,EAA4B;AACzD,EAAA,MAAM,EAAE,OAAA,EAAS,iBAAA,EAAkB,GAAI,OAAA;AAEvC,EAAA,OAAO;AAAA,IACH,MAAM,eAAA,GAAkB;AACpB,MAAA,OAAO;AAAA,QACH,MAAM,oBAAoB,cAAA,EAAqB;AAC3C,UAAA,IAAI;AAEA,YAAA,MAAM,GAAA,GAAM,cAAA,CAAe,OAAA,EAAS,IAAA,IAAQ,EAAC;AAC7C,YAAA,IAAI,iBAAA,EAAmB;AAEnB,cAAC,GAAA,CAAY,QAAA,GAAW,iBAAA,CAAkB,cAAA,CAAe,OAAO,CAAA;AAAA,YACpE;AAEA,YAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,KAAA,CAAM,GAAG,CAAA;AAExC,YAAA,IAAI,CAAC,SAAS,OAAA,EAAS;AAEnB,cAAA,cAAA,CAAe,QAAA,GAAW;AAAA,gBACtB,IAAA,EAAM;AAAA,kBACF,MAAA,EAAQ,GAAA;AAAA,kBACR,OAAA,EAAS;AAAA,oBACL,GAAG,cAAA,CAAe,QAAA,EAAU,IAAA,EAAM,OAAA;AAAA,oBAClC,aAAA,EAAe,MAAA,CAAO,QAAA,CAAS,UAAA,IAAc,CAAC;AAAA;AAClD,iBACJ;AAAA,gBACA,MAAA,EAAQ;AAAA,kBACJ,IAAI,MAAM,iEAAiE;AAAA;AAC/E,eACJ;AAAA,YACJ,CAAA,MAAO;AAEH,cAAA,cAAA,CAAe,QAAA,GAAW,cAAA,CAAe,QAAA,IAAY,EAAC;AACtD,cAAA,cAAA,CAAe,QAAA,CAAS,IAAA,GAAO,cAAA,CAAe,QAAA,CAAS,QAAQ,EAAC;AAChE,cAAA,cAAA,CAAe,QAAA,CAAS,KAAK,OAAA,GAAU;AAAA,gBACnC,GAAG,cAAA,CAAe,QAAA,CAAS,IAAA,CAAK,OAAA;AAAA,gBAChC,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA;AAAA,gBACxC,uBAAuB,MAAA,CAAO,IAAA,CAAK,IAAI,CAAA,EAAG,QAAA,CAAS,SAAS,CAAC,CAAA;AAAA,gBAC7D,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,OAAO;AAAA,eAC9C;AAAA,YACJ;AAAA,UACJ,SAAS,GAAA,EAAK;AAEV,YAAA,cAAA,CAAe,MAAA,GAAS,cAAA,CAAe,MAAA,IAAU,EAAC;AAClD,YAAA,cAAA,CAAe,MAAA,CAAO,KAAK,GAAY,CAAA;AAAA,UAC3C;AAAA,QACJ;AAAA,OACJ;AAAA,IACJ;AAAA,GACJ;AACJ","file":"graphql.js","sourcesContent":["/**\n * Apollo Server plugin adapter for Halt rate limiting.\n *\n * Usage:\n * const plugin = haltApolloPlugin({ limiter });\n * const server = new ApolloServer({ typeDefs, resolvers, plugins: [plugin] });\n */\n\nimport { RateLimiter } from '../core/limiter';\n\nexport interface ApolloHaltOptions {\n limiter: RateLimiter;\n /** Optional function to extract a resource name for per-field limits */\n resourceExtractor?: (request: any) => string | null;\n}\n\nexport function haltApolloPlugin(options: ApolloHaltOptions) {\n const { limiter, resourceExtractor } = options;\n\n return {\n async requestDidStart() {\n return {\n async didResolveOperation(requestContext: any) {\n try {\n // Provide request object compatible with limiter.extractors\n const req = requestContext.request?.http || {};\n if (resourceExtractor) {\n // Allow users to attach custom resource info\n (req as any).resource = resourceExtractor(requestContext.request);\n }\n\n const decision = await limiter.check(req);\n\n if (!decision.allowed) {\n // Short-circuit the request by adding an error\n requestContext.response = {\n http: {\n status: 429,\n headers: {\n ...requestContext.response?.http?.headers,\n 'Retry-After': String(decision.retryAfter ?? 0),\n },\n },\n errors: [\n new Error('rate_limit_exceeded: Too many requests. Please try again later.'),\n ],\n };\n } else {\n // Attach rate limit headers for downstream\n requestContext.response = requestContext.response || {};\n requestContext.response.http = requestContext.response.http || {};\n requestContext.response.http.headers = {\n ...requestContext.response.http.headers,\n 'RateLimit-Limit': String(decision.limit),\n 'RateLimit-Remaining': String(Math.max(0, decision.remaining)),\n 'RateLimit-Reset': String(decision.resetAt),\n };\n }\n } catch (err) {\n // Don't block on errors from limiter; surface as server error\n requestContext.errors = requestContext.errors || [];\n requestContext.errors.push(err as Error);\n }\n },\n };\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/adapters/graphql.ts
|
|
2
|
+
function haltApolloPlugin(options) {
|
|
3
|
+
const { limiter, resourceExtractor } = options;
|
|
4
|
+
return {
|
|
5
|
+
async requestDidStart() {
|
|
6
|
+
return {
|
|
7
|
+
async didResolveOperation(requestContext) {
|
|
8
|
+
try {
|
|
9
|
+
const req = requestContext.request?.http || {};
|
|
10
|
+
if (resourceExtractor) {
|
|
11
|
+
req.resource = resourceExtractor(requestContext.request);
|
|
12
|
+
}
|
|
13
|
+
const decision = await limiter.check(req);
|
|
14
|
+
if (!decision.allowed) {
|
|
15
|
+
requestContext.response = {
|
|
16
|
+
http: {
|
|
17
|
+
status: 429,
|
|
18
|
+
headers: {
|
|
19
|
+
...requestContext.response?.http?.headers,
|
|
20
|
+
"Retry-After": String(decision.retryAfter ?? 0)
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
errors: [
|
|
24
|
+
new Error("rate_limit_exceeded: Too many requests. Please try again later.")
|
|
25
|
+
]
|
|
26
|
+
};
|
|
27
|
+
} else {
|
|
28
|
+
requestContext.response = requestContext.response || {};
|
|
29
|
+
requestContext.response.http = requestContext.response.http || {};
|
|
30
|
+
requestContext.response.http.headers = {
|
|
31
|
+
...requestContext.response.http.headers,
|
|
32
|
+
"RateLimit-Limit": String(decision.limit),
|
|
33
|
+
"RateLimit-Remaining": String(Math.max(0, decision.remaining)),
|
|
34
|
+
"RateLimit-Reset": String(decision.resetAt)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
requestContext.errors = requestContext.errors || [];
|
|
39
|
+
requestContext.errors.push(err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { haltApolloPlugin };
|
|
48
|
+
//# sourceMappingURL=graphql.mjs.map
|
|
49
|
+
//# sourceMappingURL=graphql.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/graphql.ts"],"names":[],"mappings":";AAgBO,SAAS,iBAAiB,OAAA,EAA4B;AACzD,EAAA,MAAM,EAAE,OAAA,EAAS,iBAAA,EAAkB,GAAI,OAAA;AAEvC,EAAA,OAAO;AAAA,IACH,MAAM,eAAA,GAAkB;AACpB,MAAA,OAAO;AAAA,QACH,MAAM,oBAAoB,cAAA,EAAqB;AAC3C,UAAA,IAAI;AAEA,YAAA,MAAM,GAAA,GAAM,cAAA,CAAe,OAAA,EAAS,IAAA,IAAQ,EAAC;AAC7C,YAAA,IAAI,iBAAA,EAAmB;AAEnB,cAAC,GAAA,CAAY,QAAA,GAAW,iBAAA,CAAkB,cAAA,CAAe,OAAO,CAAA;AAAA,YACpE;AAEA,YAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,KAAA,CAAM,GAAG,CAAA;AAExC,YAAA,IAAI,CAAC,SAAS,OAAA,EAAS;AAEnB,cAAA,cAAA,CAAe,QAAA,GAAW;AAAA,gBACtB,IAAA,EAAM;AAAA,kBACF,MAAA,EAAQ,GAAA;AAAA,kBACR,OAAA,EAAS;AAAA,oBACL,GAAG,cAAA,CAAe,QAAA,EAAU,IAAA,EAAM,OAAA;AAAA,oBAClC,aAAA,EAAe,MAAA,CAAO,QAAA,CAAS,UAAA,IAAc,CAAC;AAAA;AAClD,iBACJ;AAAA,gBACA,MAAA,EAAQ;AAAA,kBACJ,IAAI,MAAM,iEAAiE;AAAA;AAC/E,eACJ;AAAA,YACJ,CAAA,MAAO;AAEH,cAAA,cAAA,CAAe,QAAA,GAAW,cAAA,CAAe,QAAA,IAAY,EAAC;AACtD,cAAA,cAAA,CAAe,QAAA,CAAS,IAAA,GAAO,cAAA,CAAe,QAAA,CAAS,QAAQ,EAAC;AAChE,cAAA,cAAA,CAAe,QAAA,CAAS,KAAK,OAAA,GAAU;AAAA,gBACnC,GAAG,cAAA,CAAe,QAAA,CAAS,IAAA,CAAK,OAAA;AAAA,gBAChC,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA;AAAA,gBACxC,uBAAuB,MAAA,CAAO,IAAA,CAAK,IAAI,CAAA,EAAG,QAAA,CAAS,SAAS,CAAC,CAAA;AAAA,gBAC7D,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,OAAO;AAAA,eAC9C;AAAA,YACJ;AAAA,UACJ,SAAS,GAAA,EAAK;AAEV,YAAA,cAAA,CAAe,MAAA,GAAS,cAAA,CAAe,MAAA,IAAU,EAAC;AAClD,YAAA,cAAA,CAAe,MAAA,CAAO,KAAK,GAAY,CAAA;AAAA,UAC3C;AAAA,QACJ;AAAA,OACJ;AAAA,IACJ;AAAA,GACJ;AACJ","file":"graphql.mjs","sourcesContent":["/**\n * Apollo Server plugin adapter for Halt rate limiting.\n *\n * Usage:\n * const plugin = haltApolloPlugin({ limiter });\n * const server = new ApolloServer({ typeDefs, resolvers, plugins: [plugin] });\n */\n\nimport { RateLimiter } from '../core/limiter';\n\nexport interface ApolloHaltOptions {\n limiter: RateLimiter;\n /** Optional function to extract a resource name for per-field limits */\n resourceExtractor?: (request: any) => string | null;\n}\n\nexport function haltApolloPlugin(options: ApolloHaltOptions) {\n const { limiter, resourceExtractor } = options;\n\n return {\n async requestDidStart() {\n return {\n async didResolveOperation(requestContext: any) {\n try {\n // Provide request object compatible with limiter.extractors\n const req = requestContext.request?.http || {};\n if (resourceExtractor) {\n // Allow users to attach custom resource info\n (req as any).resource = resourceExtractor(requestContext.request);\n }\n\n const decision = await limiter.check(req);\n\n if (!decision.allowed) {\n // Short-circuit the request by adding an error\n requestContext.response = {\n http: {\n status: 429,\n headers: {\n ...requestContext.response?.http?.headers,\n 'Retry-After': String(decision.retryAfter ?? 0),\n },\n },\n errors: [\n new Error('rate_limit_exceeded: Too many requests. Please try again later.'),\n ],\n };\n } else {\n // Attach rate limit headers for downstream\n requestContext.response = requestContext.response || {};\n requestContext.response.http = requestContext.response.http || {};\n requestContext.response.http.headers = {\n ...requestContext.response.http.headers,\n 'RateLimit-Limit': String(decision.limit),\n 'RateLimit-Remaining': String(Math.max(0, decision.remaining)),\n 'RateLimit-Reset': String(decision.resetAt),\n };\n }\n } catch (err) {\n // Don't block on errors from limiter; surface as server error\n requestContext.errors = requestContext.errors || [];\n requestContext.errors.push(err as Error);\n }\n },\n };\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Context, Next } from 'hono';
|
|
2
|
+
import { o as RateLimiter } from '../limiter-DqVVE0Kl.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hono adapter for Halt rate limiting.
|
|
6
|
+
*
|
|
7
|
+
* Works on Node and on edge runtimes (Cloudflare Workers, Deno, Bun, Vercel Edge)
|
|
8
|
+
* when paired with an edge-compatible store (the in-memory store, or a Redis client
|
|
9
|
+
* satisfying `RedisClientLike`).
|
|
10
|
+
*
|
|
11
|
+
* import { Hono } from 'hono';
|
|
12
|
+
* import { RateLimiter, InMemoryStore, presets } from 'halt-rate';
|
|
13
|
+
* import { haltMiddleware } from 'halt-rate/hono';
|
|
14
|
+
*
|
|
15
|
+
* const limiter = new RateLimiter({ store: new InMemoryStore(), policy: presets.PUBLIC_API });
|
|
16
|
+
* const app = new Hono();
|
|
17
|
+
* app.use('*', haltMiddleware({ limiter }));
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface HonoHaltOptions {
|
|
21
|
+
limiter: RateLimiter;
|
|
22
|
+
/** Custom blocked response; return a Response. */
|
|
23
|
+
onBlocked?: (c: Context) => Response | Promise<Response>;
|
|
24
|
+
/** Resolve the client IP (edge runtimes have no socket). Defaults to common headers. */
|
|
25
|
+
getClientIp?: (c: Context) => string | undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Create a Hono middleware for rate limiting. */
|
|
28
|
+
declare function haltMiddleware(options: HonoHaltOptions): (c: Context, next: Next) => Promise<Response | undefined>;
|
|
29
|
+
|
|
30
|
+
export { type HonoHaltOptions, haltMiddleware };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Context, Next } from 'hono';
|
|
2
|
+
import { o as RateLimiter } from '../limiter-DqVVE0Kl.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hono adapter for Halt rate limiting.
|
|
6
|
+
*
|
|
7
|
+
* Works on Node and on edge runtimes (Cloudflare Workers, Deno, Bun, Vercel Edge)
|
|
8
|
+
* when paired with an edge-compatible store (the in-memory store, or a Redis client
|
|
9
|
+
* satisfying `RedisClientLike`).
|
|
10
|
+
*
|
|
11
|
+
* import { Hono } from 'hono';
|
|
12
|
+
* import { RateLimiter, InMemoryStore, presets } from 'halt-rate';
|
|
13
|
+
* import { haltMiddleware } from 'halt-rate/hono';
|
|
14
|
+
*
|
|
15
|
+
* const limiter = new RateLimiter({ store: new InMemoryStore(), policy: presets.PUBLIC_API });
|
|
16
|
+
* const app = new Hono();
|
|
17
|
+
* app.use('*', haltMiddleware({ limiter }));
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface HonoHaltOptions {
|
|
21
|
+
limiter: RateLimiter;
|
|
22
|
+
/** Custom blocked response; return a Response. */
|
|
23
|
+
onBlocked?: (c: Context) => Response | Promise<Response>;
|
|
24
|
+
/** Resolve the client IP (edge runtimes have no socket). Defaults to common headers. */
|
|
25
|
+
getClientIp?: (c: Context) => string | undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Create a Hono middleware for rate limiting. */
|
|
28
|
+
declare function haltMiddleware(options: HonoHaltOptions): (c: Context, next: Next) => Promise<Response | undefined>;
|
|
29
|
+
|
|
30
|
+
export { type HonoHaltOptions, haltMiddleware };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/core/decision.ts
|
|
4
|
+
function toHeaders(decision) {
|
|
5
|
+
const headers = {
|
|
6
|
+
"RateLimit-Limit": String(decision.limit),
|
|
7
|
+
"RateLimit-Remaining": String(Math.max(0, decision.remaining)),
|
|
8
|
+
"RateLimit-Reset": String(decision.resetAt)
|
|
9
|
+
};
|
|
10
|
+
if (!decision.allowed && decision.retryAfter !== void 0) {
|
|
11
|
+
headers["Retry-After"] = String(decision.retryAfter);
|
|
12
|
+
}
|
|
13
|
+
return headers;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/adapters/hono.ts
|
|
17
|
+
function defaultClientIp(c) {
|
|
18
|
+
const h = (name) => c.req.header(name);
|
|
19
|
+
const xff = h("x-forwarded-for");
|
|
20
|
+
if (xff) return xff.split(",")[0].trim();
|
|
21
|
+
return h("cf-connecting-ip") || h("x-real-ip") || void 0;
|
|
22
|
+
}
|
|
23
|
+
function haltMiddleware(options) {
|
|
24
|
+
const { limiter, onBlocked, getClientIp = defaultClientIp } = options;
|
|
25
|
+
return async (c, next) => {
|
|
26
|
+
const request = {
|
|
27
|
+
headers: c.req.header(),
|
|
28
|
+
// Record<string, string>, lowercased keys
|
|
29
|
+
ip: getClientIp(c),
|
|
30
|
+
path: c.req.path,
|
|
31
|
+
url: c.req.url,
|
|
32
|
+
user: c.get("user")
|
|
33
|
+
};
|
|
34
|
+
const decision = await limiter.check(request);
|
|
35
|
+
for (const [key, value] of Object.entries(toHeaders(decision))) {
|
|
36
|
+
c.header(key, value);
|
|
37
|
+
}
|
|
38
|
+
if (decision.allowed) {
|
|
39
|
+
await next();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (onBlocked) return onBlocked(c);
|
|
43
|
+
return c.json(
|
|
44
|
+
{
|
|
45
|
+
error: "rate_limit_exceeded",
|
|
46
|
+
message: "Too many requests. Please try again later.",
|
|
47
|
+
retryAfter: decision.retryAfter
|
|
48
|
+
},
|
|
49
|
+
429
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
exports.haltMiddleware = haltMiddleware;
|
|
55
|
+
//# sourceMappingURL=hono.js.map
|
|
56
|
+
//# sourceMappingURL=hono.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/decision.ts","../../src/adapters/hono.ts"],"names":[],"mappings":";;;AAoBO,SAAS,UAAU,QAAA,EAA4C;AAClE,EAAA,MAAM,OAAA,GAAkC;AAAA,IACpC,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA;AAAA,IACxC,uBAAuB,MAAA,CAAO,IAAA,CAAK,IAAI,CAAA,EAAG,QAAA,CAAS,SAAS,CAAC,CAAA;AAAA,IAC7D,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,OAAO;AAAA,GAC9C;AAEA,EAAA,IAAI,CAAC,QAAA,CAAS,OAAA,IAAW,QAAA,CAAS,eAAe,MAAA,EAAW;AACxD,IAAA,OAAA,CAAQ,aAAa,CAAA,GAAI,MAAA,CAAO,QAAA,CAAS,UAAU,CAAA;AAAA,EACvD;AAEA,EAAA,OAAO,OAAA;AACX;;;ACJA,SAAS,gBAAgB,CAAA,EAAgC;AACrD,EAAA,MAAM,IAAI,CAAC,IAAA,KAAiB,CAAA,CAAE,GAAA,CAAI,OAAO,IAAI,CAAA;AAC7C,EAAA,MAAM,GAAA,GAAM,EAAE,iBAAiB,CAAA;AAC/B,EAAA,IAAI,GAAA,SAAY,GAAA,CAAI,KAAA,CAAM,GAAG,CAAA,CAAE,CAAC,EAAE,IAAA,EAAK;AACvC,EAAA,OAAO,CAAA,CAAE,kBAAkB,CAAA,IAAK,CAAA,CAAE,WAAW,CAAA,IAAK,MAAA;AACtD;AAGO,SAAS,eAAe,OAAA,EAA0B;AACrD,EAAA,MAAM,EAAE,OAAA,EAAS,SAAA,EAAW,WAAA,GAAc,iBAAgB,GAAI,OAAA;AAE9D,EAAA,OAAO,OAAO,GAAY,IAAA,KAAe;AACrC,IAAA,MAAM,OAAA,GAAU;AAAA,MACZ,OAAA,EAAS,CAAA,CAAE,GAAA,CAAI,MAAA,EAAO;AAAA;AAAA,MACtB,EAAA,EAAI,YAAY,CAAC,CAAA;AAAA,MACjB,IAAA,EAAM,EAAE,GAAA,CAAI,IAAA;AAAA,MACZ,GAAA,EAAK,EAAE,GAAA,CAAI,GAAA;AAAA,MACX,IAAA,EAAM,CAAA,CAAE,GAAA,CAAI,MAAM;AAAA,KACtB;AAEA,IAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,KAAA,CAAM,OAAO,CAAA;AAE5C,IAAA,KAAA,MAAW,CAAC,KAAK,KAAK,CAAA,IAAK,OAAO,OAAA,CAAQ,SAAA,CAAU,QAAQ,CAAC,CAAA,EAAG;AAC5D,MAAA,CAAA,CAAE,MAAA,CAAO,KAAK,KAAK,CAAA;AAAA,IACvB;AAEA,IAAA,IAAI,SAAS,OAAA,EAAS;AAClB,MAAA,MAAM,IAAA,EAAK;AACX,MAAA;AAAA,IACJ;AAEA,IAAA,IAAI,SAAA,EAAW,OAAO,SAAA,CAAU,CAAC,CAAA;AACjC,IAAA,OAAO,CAAA,CAAE,IAAA;AAAA,MACL;AAAA,QACI,KAAA,EAAO,qBAAA;AAAA,QACP,OAAA,EAAS,4CAAA;AAAA,QACT,YAAY,QAAA,CAAS;AAAA,OACzB;AAAA,MACA;AAAA,KACJ;AAAA,EACJ,CAAA;AACJ","file":"hono.js","sourcesContent":["/**\n * Decision model for rate limiting results.\n */\n\nexport interface Decision {\n /** Whether the request is allowed */\n allowed: boolean;\n /** Maximum number of requests allowed in the window */\n limit: number;\n /** Number of requests remaining in the current window */\n remaining: number;\n /** Unix timestamp when the limit resets */\n resetAt: number;\n /** Seconds to wait before retrying (only set when blocked) */\n retryAfter?: number;\n}\n\n/**\n * Convert decision to standard rate limit headers.\n */\nexport function toHeaders(decision: Decision): Record<string, string> {\n const headers: Record<string, string> = {\n 'RateLimit-Limit': String(decision.limit),\n 'RateLimit-Remaining': String(Math.max(0, decision.remaining)),\n 'RateLimit-Reset': String(decision.resetAt),\n };\n\n if (!decision.allowed && decision.retryAfter !== undefined) {\n headers['Retry-After'] = String(decision.retryAfter);\n }\n\n return headers;\n}\n","/**\n * Hono adapter for Halt rate limiting.\n *\n * Works on Node and on edge runtimes (Cloudflare Workers, Deno, Bun, Vercel Edge)\n * when paired with an edge-compatible store (the in-memory store, or a Redis client\n * satisfying `RedisClientLike`).\n *\n * import { Hono } from 'hono';\n * import { RateLimiter, InMemoryStore, presets } from 'halt-rate';\n * import { haltMiddleware } from 'halt-rate/hono';\n *\n * const limiter = new RateLimiter({ store: new InMemoryStore(), policy: presets.PUBLIC_API });\n * const app = new Hono();\n * app.use('*', haltMiddleware({ limiter }));\n */\n\nimport type { Context, Next } from 'hono';\nimport { RateLimiter } from '../core/limiter';\nimport { toHeaders } from '../core/decision';\n\nexport interface HonoHaltOptions {\n limiter: RateLimiter;\n /** Custom blocked response; return a Response. */\n onBlocked?: (c: Context) => Response | Promise<Response>;\n /** Resolve the client IP (edge runtimes have no socket). Defaults to common headers. */\n getClientIp?: (c: Context) => string | undefined;\n}\n\nfunction defaultClientIp(c: Context): string | undefined {\n const h = (name: string) => c.req.header(name);\n const xff = h('x-forwarded-for');\n if (xff) return xff.split(',')[0].trim();\n return h('cf-connecting-ip') || h('x-real-ip') || undefined;\n}\n\n/** Create a Hono middleware for rate limiting. */\nexport function haltMiddleware(options: HonoHaltOptions) {\n const { limiter, onBlocked, getClientIp = defaultClientIp } = options;\n\n return async (c: Context, next: Next) => {\n const request = {\n headers: c.req.header(), // Record<string, string>, lowercased keys\n ip: getClientIp(c),\n path: c.req.path,\n url: c.req.url,\n user: c.get('user'),\n };\n\n const decision = await limiter.check(request);\n\n for (const [key, value] of Object.entries(toHeaders(decision))) {\n c.header(key, value);\n }\n\n if (decision.allowed) {\n await next();\n return;\n }\n\n if (onBlocked) return onBlocked(c);\n return c.json(\n {\n error: 'rate_limit_exceeded',\n message: 'Too many requests. Please try again later.',\n retryAfter: decision.retryAfter,\n },\n 429\n );\n };\n}\n"]}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/core/decision.ts
|
|
2
|
+
function toHeaders(decision) {
|
|
3
|
+
const headers = {
|
|
4
|
+
"RateLimit-Limit": String(decision.limit),
|
|
5
|
+
"RateLimit-Remaining": String(Math.max(0, decision.remaining)),
|
|
6
|
+
"RateLimit-Reset": String(decision.resetAt)
|
|
7
|
+
};
|
|
8
|
+
if (!decision.allowed && decision.retryAfter !== void 0) {
|
|
9
|
+
headers["Retry-After"] = String(decision.retryAfter);
|
|
10
|
+
}
|
|
11
|
+
return headers;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// src/adapters/hono.ts
|
|
15
|
+
function defaultClientIp(c) {
|
|
16
|
+
const h = (name) => c.req.header(name);
|
|
17
|
+
const xff = h("x-forwarded-for");
|
|
18
|
+
if (xff) return xff.split(",")[0].trim();
|
|
19
|
+
return h("cf-connecting-ip") || h("x-real-ip") || void 0;
|
|
20
|
+
}
|
|
21
|
+
function haltMiddleware(options) {
|
|
22
|
+
const { limiter, onBlocked, getClientIp = defaultClientIp } = options;
|
|
23
|
+
return async (c, next) => {
|
|
24
|
+
const request = {
|
|
25
|
+
headers: c.req.header(),
|
|
26
|
+
// Record<string, string>, lowercased keys
|
|
27
|
+
ip: getClientIp(c),
|
|
28
|
+
path: c.req.path,
|
|
29
|
+
url: c.req.url,
|
|
30
|
+
user: c.get("user")
|
|
31
|
+
};
|
|
32
|
+
const decision = await limiter.check(request);
|
|
33
|
+
for (const [key, value] of Object.entries(toHeaders(decision))) {
|
|
34
|
+
c.header(key, value);
|
|
35
|
+
}
|
|
36
|
+
if (decision.allowed) {
|
|
37
|
+
await next();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (onBlocked) return onBlocked(c);
|
|
41
|
+
return c.json(
|
|
42
|
+
{
|
|
43
|
+
error: "rate_limit_exceeded",
|
|
44
|
+
message: "Too many requests. Please try again later.",
|
|
45
|
+
retryAfter: decision.retryAfter
|
|
46
|
+
},
|
|
47
|
+
429
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { haltMiddleware };
|
|
53
|
+
//# sourceMappingURL=hono.mjs.map
|
|
54
|
+
//# sourceMappingURL=hono.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/decision.ts","../../src/adapters/hono.ts"],"names":[],"mappings":";AAoBO,SAAS,UAAU,QAAA,EAA4C;AAClE,EAAA,MAAM,OAAA,GAAkC;AAAA,IACpC,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA;AAAA,IACxC,uBAAuB,MAAA,CAAO,IAAA,CAAK,IAAI,CAAA,EAAG,QAAA,CAAS,SAAS,CAAC,CAAA;AAAA,IAC7D,iBAAA,EAAmB,MAAA,CAAO,QAAA,CAAS,OAAO;AAAA,GAC9C;AAEA,EAAA,IAAI,CAAC,QAAA,CAAS,OAAA,IAAW,QAAA,CAAS,eAAe,MAAA,EAAW;AACxD,IAAA,OAAA,CAAQ,aAAa,CAAA,GAAI,MAAA,CAAO,QAAA,CAAS,UAAU,CAAA;AAAA,EACvD;AAEA,EAAA,OAAO,OAAA;AACX;;;ACJA,SAAS,gBAAgB,CAAA,EAAgC;AACrD,EAAA,MAAM,IAAI,CAAC,IAAA,KAAiB,CAAA,CAAE,GAAA,CAAI,OAAO,IAAI,CAAA;AAC7C,EAAA,MAAM,GAAA,GAAM,EAAE,iBAAiB,CAAA;AAC/B,EAAA,IAAI,GAAA,SAAY,GAAA,CAAI,KAAA,CAAM,GAAG,CAAA,CAAE,CAAC,EAAE,IAAA,EAAK;AACvC,EAAA,OAAO,CAAA,CAAE,kBAAkB,CAAA,IAAK,CAAA,CAAE,WAAW,CAAA,IAAK,MAAA;AACtD;AAGO,SAAS,eAAe,OAAA,EAA0B;AACrD,EAAA,MAAM,EAAE,OAAA,EAAS,SAAA,EAAW,WAAA,GAAc,iBAAgB,GAAI,OAAA;AAE9D,EAAA,OAAO,OAAO,GAAY,IAAA,KAAe;AACrC,IAAA,MAAM,OAAA,GAAU;AAAA,MACZ,OAAA,EAAS,CAAA,CAAE,GAAA,CAAI,MAAA,EAAO;AAAA;AAAA,MACtB,EAAA,EAAI,YAAY,CAAC,CAAA;AAAA,MACjB,IAAA,EAAM,EAAE,GAAA,CAAI,IAAA;AAAA,MACZ,GAAA,EAAK,EAAE,GAAA,CAAI,GAAA;AAAA,MACX,IAAA,EAAM,CAAA,CAAE,GAAA,CAAI,MAAM;AAAA,KACtB;AAEA,IAAA,MAAM,QAAA,GAAW,MAAM,OAAA,CAAQ,KAAA,CAAM,OAAO,CAAA;AAE5C,IAAA,KAAA,MAAW,CAAC,KAAK,KAAK,CAAA,IAAK,OAAO,OAAA,CAAQ,SAAA,CAAU,QAAQ,CAAC,CAAA,EAAG;AAC5D,MAAA,CAAA,CAAE,MAAA,CAAO,KAAK,KAAK,CAAA;AAAA,IACvB;AAEA,IAAA,IAAI,SAAS,OAAA,EAAS;AAClB,MAAA,MAAM,IAAA,EAAK;AACX,MAAA;AAAA,IACJ;AAEA,IAAA,IAAI,SAAA,EAAW,OAAO,SAAA,CAAU,CAAC,CAAA;AACjC,IAAA,OAAO,CAAA,CAAE,IAAA;AAAA,MACL;AAAA,QACI,KAAA,EAAO,qBAAA;AAAA,QACP,OAAA,EAAS,4CAAA;AAAA,QACT,YAAY,QAAA,CAAS;AAAA,OACzB;AAAA,MACA;AAAA,KACJ;AAAA,EACJ,CAAA;AACJ","file":"hono.mjs","sourcesContent":["/**\n * Decision model for rate limiting results.\n */\n\nexport interface Decision {\n /** Whether the request is allowed */\n allowed: boolean;\n /** Maximum number of requests allowed in the window */\n limit: number;\n /** Number of requests remaining in the current window */\n remaining: number;\n /** Unix timestamp when the limit resets */\n resetAt: number;\n /** Seconds to wait before retrying (only set when blocked) */\n retryAfter?: number;\n}\n\n/**\n * Convert decision to standard rate limit headers.\n */\nexport function toHeaders(decision: Decision): Record<string, string> {\n const headers: Record<string, string> = {\n 'RateLimit-Limit': String(decision.limit),\n 'RateLimit-Remaining': String(Math.max(0, decision.remaining)),\n 'RateLimit-Reset': String(decision.resetAt),\n };\n\n if (!decision.allowed && decision.retryAfter !== undefined) {\n headers['Retry-After'] = String(decision.retryAfter);\n }\n\n return headers;\n}\n","/**\n * Hono adapter for Halt rate limiting.\n *\n * Works on Node and on edge runtimes (Cloudflare Workers, Deno, Bun, Vercel Edge)\n * when paired with an edge-compatible store (the in-memory store, or a Redis client\n * satisfying `RedisClientLike`).\n *\n * import { Hono } from 'hono';\n * import { RateLimiter, InMemoryStore, presets } from 'halt-rate';\n * import { haltMiddleware } from 'halt-rate/hono';\n *\n * const limiter = new RateLimiter({ store: new InMemoryStore(), policy: presets.PUBLIC_API });\n * const app = new Hono();\n * app.use('*', haltMiddleware({ limiter }));\n */\n\nimport type { Context, Next } from 'hono';\nimport { RateLimiter } from '../core/limiter';\nimport { toHeaders } from '../core/decision';\n\nexport interface HonoHaltOptions {\n limiter: RateLimiter;\n /** Custom blocked response; return a Response. */\n onBlocked?: (c: Context) => Response | Promise<Response>;\n /** Resolve the client IP (edge runtimes have no socket). Defaults to common headers. */\n getClientIp?: (c: Context) => string | undefined;\n}\n\nfunction defaultClientIp(c: Context): string | undefined {\n const h = (name: string) => c.req.header(name);\n const xff = h('x-forwarded-for');\n if (xff) return xff.split(',')[0].trim();\n return h('cf-connecting-ip') || h('x-real-ip') || undefined;\n}\n\n/** Create a Hono middleware for rate limiting. */\nexport function haltMiddleware(options: HonoHaltOptions) {\n const { limiter, onBlocked, getClientIp = defaultClientIp } = options;\n\n return async (c: Context, next: Next) => {\n const request = {\n headers: c.req.header(), // Record<string, string>, lowercased keys\n ip: getClientIp(c),\n path: c.req.path,\n url: c.req.url,\n user: c.get('user'),\n };\n\n const decision = await limiter.check(request);\n\n for (const [key, value] of Object.entries(toHeaders(decision))) {\n c.header(key, value);\n }\n\n if (decision.allowed) {\n await next();\n return;\n }\n\n if (onBlocked) return onBlocked(c);\n return c.json(\n {\n error: 'rate_limit_exceeded',\n message: 'Too many requests. Please try again later.',\n retryAfter: decision.retryAfter,\n },\n 429\n );\n };\n}\n"]}
|
package/dist/adapters/next.js
CHANGED
|
@@ -459,7 +459,8 @@ var RateLimiter = class {
|
|
|
459
459
|
return decision2;
|
|
460
460
|
}
|
|
461
461
|
const store = this.store;
|
|
462
|
-
|
|
462
|
+
const cacheKey = `${policy.name}|${policy.algorithm}|${policy.limit}|${policy.window}|${policy.burst}`;
|
|
463
|
+
let algorithm = this.algorithmCache.get(cacheKey);
|
|
463
464
|
if (!algorithm) {
|
|
464
465
|
if (policy.algorithm === "token_bucket" /* TOKEN_BUCKET */) {
|
|
465
466
|
algorithm = new TokenBucket(policy.burst, policy.limit, policy.window);
|
|
@@ -473,7 +474,7 @@ var RateLimiter = class {
|
|
|
473
474
|
} else {
|
|
474
475
|
throw new Error(`Algorithm ${policy.algorithm} not implemented`);
|
|
475
476
|
}
|
|
476
|
-
this.algorithmCache.set(
|
|
477
|
+
this.algorithmCache.set(cacheKey, algorithm);
|
|
477
478
|
}
|
|
478
479
|
const span = this.otelTracer?.startSpan?.("halt.check", { attributes: { policy: policy.name, key } });
|
|
479
480
|
const state = store.get(storageKey);
|