@alxia/redis 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, 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,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # @alxia/redis
2
+
3
+ Redis for [alxia](https://www.npmjs.com/package/@alxia/core), on
4
+ [`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) and
5
+ [`@nxgt/redis-guard`](https://www.npmjs.com/package/@nxgt/redis-guard) —
6
+ Bun's own Redis client, no driver, no dependency:
7
+
8
+ - `redisStore`: a rate-limit store every process shares;
9
+ - `idempotency`: routes that run once per `Idempotency-Key`;
10
+ - `redisCacheStore`: an `@alxia/cache` store every process shares;
11
+ - `redis`: the client, typed caches and a lock in the context.
12
+
13
+ ```sh
14
+ bun add @alxia/redis @nxgt/redis @nxgt/redis-guard zod @alxia/core
15
+ bun add -d typescript
16
+ ```
17
+
18
+ They are peers, with `@alxia/rate-limit` for `redisStore` and `@alxia/cache`
19
+ for `redisCacheStore`. **Bun 1.4 or
20
+ later**: Bun's `RedisClient` is what the nxgt packages run on.
21
+
22
+ ## A shared rate limit
23
+
24
+ ```ts
25
+ import { alxia } from '@alxia/core';
26
+ import { rateLimit } from '@alxia/rate-limit';
27
+ import { redisStore } from '@alxia/redis';
28
+ import { connectRedis } from '@nxgt/redis';
29
+
30
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
31
+
32
+ const app = alxia()
33
+ .use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
34
+ .get('/search', ({ reply }) => reply(200, []));
35
+ ```
36
+
37
+ GCRA, in one atomic script, timed by the Redis server's clock: every
38
+ process behind the load balancer counts together, and a refused request
39
+ counts nothing. The 429 stays typed, as `@alxia/rate-limit` types it.
40
+ GCRA refills continuously: an idle client may send `limit` at once, then
41
+ one more every `windowMs / limit`.
42
+
43
+ ## A shared response cache
44
+
45
+ ```ts
46
+ import { cache } from '@alxia/cache';
47
+ import { alxia } from '@alxia/core';
48
+ import { redisCacheStore } from '@alxia/redis';
49
+ import { connectRedis } from '@nxgt/redis';
50
+
51
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
52
+ const products = cache({ ttl: 60, store: redisCacheStore(connection.client, { name: 'shop' }), tags: () => ['products'] });
53
+
54
+ const app = alxia()
55
+ .post('/products', async ({ reply }) => {
56
+ await products.invalidateTag('products'); // forgotten in every process
57
+ return reply(201, { ok: true });
58
+ })
59
+ .use(products)
60
+ .get('/products', ({ reply }) => reply(200, [{ id: '1', name: 'Kettle' }]));
61
+ ```
62
+
63
+ Responses are `@nxgt/redis` cache records, checked by their schema when
64
+ read: one that no longer reads as a response is a miss. A tag is a Redis
65
+ set of the keys it names. A Redis that does not answer costs the cache, not
66
+ the response: the route runs, and the error is logged.
67
+
68
+ ## Idempotent routes
69
+
70
+ ```ts
71
+ import { alxia } from '@alxia/core';
72
+ import { idempotency } from '@alxia/redis';
73
+ import { connectRedis } from '@nxgt/redis';
74
+ import { z } from 'zod';
75
+
76
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
77
+ const Payment = z.object({ amount: z.number().int().positive() });
78
+
79
+ const app = alxia()
80
+ .use(idempotency(connection.client, { name: 'payments', required: true }))
81
+ .post('/payments', { body: Payment }, ({ body, reply }) =>
82
+ reply(201, { id: crypto.randomUUID(), amount: body.amount }),
83
+ );
84
+ ```
85
+
86
+ A `POST` or `PATCH` with an `Idempotency-Key` runs once; every repeat gets
87
+ the first response back — status, headers, body — with
88
+ `Idempotent-Replayed: true`, from any process.
89
+
90
+ | case | answer |
91
+ | --- | --- |
92
+ | a repeat while the first runs | `409 { error: 'idempotency_in_progress', retryAfter }`, `Retry-After` |
93
+ | the same key, another request (method, path or body) | `422 { error: 'idempotency_key_reused' }` |
94
+ | a key that is not 1 to 255 printable ASCII characters | `400 { error: 'idempotency_key_invalid' }` |
95
+ | no key, with `required` | `400 { error: 'idempotency_key_missing' }` |
96
+ | the route answers a 5xx, or streams | answered, not kept: the key is free again |
97
+
98
+ Every one is part of the guarded routes' types. Keys are scoped by the
99
+ route and by `scope(ctx)` — the client's address by default, a user id
100
+ when there is one — so two clients choosing the same key never see each
101
+ other's response. A replay never repeats `Set-Cookie`. Every response
102
+ below 500 is kept, a 4xx included: declare a rate limit or an auth check
103
+ **before** `idempotency`, or its refusal is replayed.
104
+
105
+ | option | default | |
106
+ | --- | --- | --- |
107
+ | `name` | required | names the stored keys |
108
+ | `ttl` | a day | seconds a response is replayed |
109
+ | `lease` | 10 s | milliseconds a running request holds its key, renewed while it runs |
110
+ | `wait` | 0 | milliseconds a repeat waits for the first before a 409 |
111
+ | `methods` | `POST`, `PATCH` | |
112
+ | `header` | `Idempotency-Key` | |
113
+ | `required` | `false` | |
114
+ | `scope` | the client's address | `(ctx) => string` |
115
+
116
+ ## Caches and locks in the context
117
+
118
+ ```ts
119
+ import { alxia } from '@alxia/core';
120
+ import { redis } from '@alxia/redis';
121
+ import { connectRedis, defineCache } from '@nxgt/redis';
122
+ import { z } from 'zod';
123
+
124
+ const connection = await connectRedis(Bun.env['REDIS_URL']!);
125
+ const User = z.object({ id: z.string(), name: z.string() });
126
+ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });
127
+ const loadUser = async (id: string) => ({ id, name: 'Ada' }); // your database
128
+ const touch = async (user: z.infer<typeof User>) => user;
129
+
130
+ const app = alxia()
131
+ .use(redis(connection.client, { caches: { users } }))
132
+ .get('/users/:id', async ({ caches, lock, params, reply }) => {
133
+ const user = await caches.users.remember(params.id, () => loadUser(params.id)); // typed by User
134
+ await lock(`user:${params.id}`, () => touch(user));
135
+ return reply.ok(user);
136
+ });
137
+ ```
138
+
139
+ `caches.<name>` is `@nxgt/redis`'s bound cache; `redis` the client itself,
140
+ for everything else. It sits beside `@alxia/cache`'s `ctx.cache` — the
141
+ response cache's `{ tag, skip }` — without touching it, in either order.
142
+
143
+ ## Testing
144
+
145
+ The package's specs run against `$REDIS_URL`, or a `redis-server` on
146
+ `$PATH` they start on a free port.
147
+
148
+ ## API
149
+
150
+ | export | |
151
+ | --- | --- |
152
+ | `redisStore(client, { name })`, `RedisStoreOptions` | an `@alxia/rate-limit` store |
153
+ | `redisCacheStore(client, { name })`, `RedisCacheStoreOptions` | an `@alxia/cache` store |
154
+ | `idempotency(client, options)` | the plugin |
155
+ | `redis(client, { caches? })`, `RedisContextOptions` | the plugin: `redis`, `caches`, `lock` in the context |
156
+ | `IdempotencyOptions`, `IdempotencyErrorBody`, `RedisContext`, `BoundCaches` | its types |
157
+
158
+ ## Documentation
159
+
160
+ - [Guide](https://github.com/softistx/alxia/tree/develop/packages/redis/docs): a page per area — connecting, rate limits, the response cache, idempotency, caches and locks, and testing against a real Redis.
161
+ - [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/redis/docs/troubleshooting.md): an error message, a refusal a client got, or a limit, cache or replay that does not behave as expected, and what to do about it.
162
+ - [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/redis/docs/roadmap.md): what is coming, and what is not planned.
@@ -0,0 +1,18 @@
1
+ import type { CacheStore } from '@alxia/cache';
2
+ import type { RedisClient } from 'bun';
3
+ export interface RedisCacheStoreOptions {
4
+ /** Prepended to every key it writes: one name per app or deployment. */
5
+ readonly name: string;
6
+ }
7
+ /**
8
+ * An `@alxia/cache` store in Redis, on `@nxgt/redis`'s typed caches: every
9
+ * process sharing the Redis serves what one of them kept. A record that no
10
+ * longer reads as a response is a miss, and is dropped. Tags are Redis sets
11
+ * of the keys they name.
12
+ *
13
+ * ```ts
14
+ * app.use(cache({ ttl: 60, store: redisCacheStore(connection.client, { name: 'shop' }) }));
15
+ * ```
16
+ */
17
+ export declare function redisCacheStore(client: RedisClient, options: RedisCacheStoreOptions): CacheStore;
18
+ //# sourceMappingURL=cache-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache-store.d.ts","sourceRoot":"","sources":["../src/cache-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AAGvC,MAAM,WAAW,sBAAsB;IACtC,wEAAwE;IACxE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAeD;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC9B,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,sBAAsB,GAC7B,UAAU,CA4EZ"}
@@ -0,0 +1,34 @@
1
+ import { type BoundCache, type CacheDefinition, type LockOptions } from '@nxgt/redis';
2
+ import type { RedisClient } from 'bun';
3
+ import type { z } from 'zod';
4
+ type AnyCache = CacheDefinition<any, z.ZodType>;
5
+ /** The caches of `Caches`, each bound to the client. */
6
+ export type BoundCaches<Caches extends Record<string, AnyCache>> = {
7
+ readonly [Name in keyof Caches]: Caches[Name] extends CacheDefinition<infer Params, infer Schema> ? BoundCache<Params, z.output<Schema>, z.input<Schema>> : never;
8
+ };
9
+ export interface RedisContextOptions<Caches extends Record<string, AnyCache>> {
10
+ /** `@nxgt/redis` cache definitions, by the name routes read them under. */
11
+ readonly caches?: Caches;
12
+ }
13
+ /** What routes after `redis()` read. */
14
+ export interface RedisContext<Caches extends Record<string, AnyCache>> {
15
+ /** Bun's own client, untouched. */
16
+ readonly redis: RedisClient;
17
+ /** Each cache, bound and typed by its schema: `caches.users.remember(…)`. */
18
+ readonly caches: BoundCaches<Caches>;
19
+ /** `work` under a lock every process sharing the Redis respects: `@nxgt/redis`'s `withLock`. */
20
+ lock<T>(key: string, work: () => Promise<T> | T, options?: LockOptions): Promise<T>;
21
+ }
22
+ /**
23
+ * Redis in the context, as a plugin: the client, the caches bound once, and
24
+ * a lock — typed, for every route declared after it.
25
+ *
26
+ * ```ts
27
+ * const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });
28
+ * app.use(redis(connection.client, { caches: { users } }))
29
+ * .get('/users/:id', async ({ caches, params, reply }) => reply.ok(await caches.users.remember(params.id, load)));
30
+ * ```
31
+ */
32
+ export declare function redis<const Caches extends Record<string, AnyCache> = Record<never, never>>(client: RedisClient, options?: RedisContextOptions<Caches>): import("@alxia/core").Alxia<import("@alxia/core").Empty & RedisContext<Caches>, import("@alxia/core").Empty, "", never>;
33
+ export {};
34
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AACA,OAAO,EACN,KAAK,UAAU,EAEf,KAAK,eAAe,EACpB,KAAK,WAAW,EAEhB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AACvC,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,KAAK,QAAQ,GAAG,eAAe,CAAC,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;AAEhD,wDAAwD;AACxD,MAAM,MAAM,WAAW,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI;IAClE,QAAQ,EAAE,IAAI,IAAI,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,eAAe,CACpE,MAAM,MAAM,EACZ,MAAM,MAAM,CACZ,GACE,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,GACrD,KAAK;CACR,CAAC;AAEF,MAAM,WAAW,mBAAmB,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC3E,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,wCAAwC;AACxC,MAAM,WAAW,YAAY,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC;IACpE,mCAAmC;IACnC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACrC,gGAAgG;IAChG,IAAI,CAAC,CAAC,EACL,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EAC1B,OAAO,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,CAAC,CAAC,CAAC;CACd;AAED;;;;;;;;;GASG;AACH,wBAAgB,KAAK,CACpB,KAAK,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,EACnE,MAAM,EAAE,WAAW,EAAE,OAAO,GAAE,mBAAmB,CAAC,MAAM,CAAM,2HAa/D"}
@@ -0,0 +1,47 @@
1
+ import { type BaseContext } from '@alxia/core';
2
+ import type { RedisClient } from 'bun';
3
+ export interface IdempotencyOptions {
4
+ /** Names the keys it stores. */
5
+ readonly name: string;
6
+ /** Seconds a finished response is kept and replayed. A day by default. */
7
+ readonly ttl?: number;
8
+ /** Milliseconds a running request holds its key unless renewed. `@nxgt/redis-guard`'s 10 s by default. */
9
+ readonly lease?: number;
10
+ /** Milliseconds a repeat waits for the first to finish before a 409. None by default. */
11
+ readonly wait?: number;
12
+ /** The methods it guards. `POST` and `PATCH` by default: the others are idempotent already. */
13
+ readonly methods?: readonly string[];
14
+ /** The header the key is read from. `Idempotency-Key` by default. */
15
+ readonly header?: string;
16
+ /** Whether a guarded request without a key is refused, with a 400. Off by default. */
17
+ readonly required?: boolean;
18
+ /**
19
+ * Whose key it is: keys are scoped by the route and by this, so two
20
+ * clients choosing the same key never see each other's response. The
21
+ * client's address by default; a user id when there is one.
22
+ */
23
+ readonly scope?: (ctx: BaseContext) => string | undefined;
24
+ }
25
+ /** The body of a refusal. */
26
+ export interface IdempotencyErrorBody {
27
+ readonly error: 'idempotency_key_missing' | 'idempotency_key_invalid' | 'idempotency_in_progress' | 'idempotency_key_reused';
28
+ /** Seconds until a running request should be over: with `idempotency_in_progress`. */
29
+ readonly retryAfter?: number;
30
+ }
31
+ /**
32
+ * Idempotent routes, as a plugin, with `@nxgt/redis-guard`: a `POST` or
33
+ * `PATCH` carrying an `Idempotency-Key` runs once per key, and every repeat
34
+ * gets the first response back, marked `Idempotent-Replayed: true` — across
35
+ * every process sharing the Redis. Routes declared after it are guarded.
36
+ *
37
+ * A repeat while the first still runs is a 409, and the same key with
38
+ * another request — method, path or body — a 422: both are part of every
39
+ * guarded route's type. A 5xx, or a stream, is answered and not kept: the
40
+ * key is free again.
41
+ *
42
+ * ```ts
43
+ * app.use(idempotency(redis.client, { name: 'payments' })).post('/payments', ...);
44
+ * ```
45
+ */
46
+ export declare function idempotency(client: RedisClient, options: IdempotencyOptions): import("@alxia/core").Alxia<import("@alxia/core").Empty, import("@alxia/core").Empty, "", import("@alxia/core").Reply<400, IdempotencyErrorBody> | import("@alxia/core").Reply<409, IdempotencyErrorBody> | import("@alxia/core").Reply<422, IdempotencyErrorBody>>;
47
+ //# sourceMappingURL=idempotency.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,WAAW,EAAE,MAAM,aAAa,CAAC;AAMtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AAGvC,MAAM,WAAW,kBAAkB;IAClC,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,0EAA0E;IAC1E,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,0GAA0G;IAC1G,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,yFAAyF;IACzF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,+FAA+F;IAC/F,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,qEAAqE;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,KAAK,MAAM,GAAG,SAAS,CAAC;CAC1D;AAED,6BAA6B;AAC7B,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,KAAK,EACX,yBAAyB,GACzB,yBAAyB,GACzB,yBAAyB,GACzB,wBAAwB,CAAC;IAC5B,sFAAsF;IACtF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAuBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,kBAAkB,uQAsE3E"}
@@ -0,0 +1,5 @@
1
+ export { type RedisCacheStoreOptions, redisCacheStore } from './cache-store';
2
+ export { type BoundCaches, type RedisContext, type RedisContextOptions, redis, } from './context';
3
+ export { type IdempotencyErrorBody, type IdempotencyOptions, idempotency, } from './idempotency';
4
+ export { type RedisStoreOptions, redisStore } from './store';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,sBAAsB,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC7E,OAAO,EACN,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,GACL,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,EACvB,WAAW,GACX,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,253 @@
1
+ // src/cache-store.ts
2
+ import { bindCache, defineCache } from "@nxgt/redis";
3
+ import { z } from "zod";
4
+ var Stored = z.object({
5
+ status: z.number().int(),
6
+ headers: z.array(z.tuple([z.string(), z.string()])),
7
+ body: z.string(),
8
+ storedAt: z.number(),
9
+ ttl: z.number(),
10
+ stale: z.number(),
11
+ tags: z.array(z.string())
12
+ });
13
+ var REFUSED_ARGUMENTS = /wrong number of arguments|syntax error/i;
14
+ function redisCacheStore(client, options) {
15
+ const records = bindCache(client, defineCache({
16
+ name: `${options.name}:response`,
17
+ key: (key) => key,
18
+ ttl: 60,
19
+ schema: Stored
20
+ }));
21
+ const tagKey = (tag) => `${options.name}:tag:${tag}`;
22
+ let flagsKnown = true;
23
+ const keepTag = async (key, seconds) => {
24
+ const ttl = String(seconds);
25
+ if (flagsKnown) {
26
+ try {
27
+ await client.send("EXPIRE", [key, ttl, "NX"]);
28
+ await client.send("EXPIRE", [key, ttl, "GT"]);
29
+ return;
30
+ } catch (error) {
31
+ if (!REFUSED_ARGUMENTS.test(String(error)))
32
+ throw error;
33
+ flagsKnown = false;
34
+ }
35
+ }
36
+ await client.send("EXPIRE", [key, ttl]);
37
+ };
38
+ return {
39
+ async get(key) {
40
+ const record = await records.get(key);
41
+ if (record === undefined)
42
+ return;
43
+ const response = {
44
+ ...record,
45
+ body: new Uint8Array(Buffer.from(record.body, "base64"))
46
+ };
47
+ return response;
48
+ },
49
+ async set(key, value, keepFor) {
50
+ const seconds = Math.max(1, Math.ceil(keepFor / 1000));
51
+ await records.set(key, {
52
+ ...value,
53
+ headers: value.headers.map(([name, header]) => [name, header]),
54
+ tags: [...value.tags],
55
+ body: Buffer.from(value.body).toString("base64")
56
+ }, { ttl: seconds });
57
+ await Promise.all(value.tags.map(async (tag) => {
58
+ await client.send("SADD", [tagKey(tag), records.keyFor(key)]);
59
+ await keepTag(tagKey(tag), seconds);
60
+ }));
61
+ },
62
+ async delete(key) {
63
+ await records.delete(key);
64
+ },
65
+ async deleteTag(tag) {
66
+ const keys = await client.send("SMEMBERS", [tagKey(tag)]);
67
+ if (keys.length > 0)
68
+ await client.send("DEL", keys);
69
+ await client.send("DEL", [tagKey(tag)]);
70
+ }
71
+ };
72
+ }
73
+ // src/context.ts
74
+ import { alxia } from "@alxia/core";
75
+ import {
76
+ bindCache as bindCache2,
77
+ withLock
78
+ } from "@nxgt/redis";
79
+ function redis(client, options = {}) {
80
+ const caches = Object.fromEntries(Object.entries(options.caches ?? {}).map(([name, definition]) => [
81
+ name,
82
+ bindCache2(client, definition)
83
+ ]));
84
+ const context = {
85
+ redis: client,
86
+ caches,
87
+ lock: (key, work, lockOptions) => withLock(client, key, work, lockOptions)
88
+ };
89
+ return alxia().decorate(context);
90
+ }
91
+ // src/idempotency.ts
92
+ import { alxia as alxia2 } from "@alxia/core";
93
+ import {
94
+ bindIdempotency,
95
+ defineIdempotency,
96
+ GuardError
97
+ } from "@nxgt/redis-guard";
98
+ import { z as z2 } from "zod";
99
+ var Stored2 = z2.object({
100
+ status: z2.number().int(),
101
+ headers: z2.array(z2.tuple([z2.string(), z2.string()])),
102
+ body: z2.string()
103
+ });
104
+ var UNSTORED_HEADERS = new Set(["set-cookie", "date", "content-length"]);
105
+ var KEY = /^[\x21-\x7e]{1,255}$/;
106
+
107
+ class Unstored extends Error {
108
+ response;
109
+ constructor(response) {
110
+ super("unstored");
111
+ this.response = response;
112
+ }
113
+ }
114
+ function idempotency(client, options) {
115
+ const methods = new Set(options.methods ?? ["POST", "PATCH"]);
116
+ const header = options.header ?? "idempotency-key";
117
+ const scope = options.scope ?? ((ctx) => ctx.ip);
118
+ const bound = bindIdempotency(client, defineIdempotency({
119
+ name: options.name,
120
+ key: (key) => key,
121
+ ttl: options.ttl ?? 86400,
122
+ ...options.lease === undefined ? {} : { lease: options.lease },
123
+ schema: Stored2
124
+ }));
125
+ const refuse = (error, retryAfter) => {
126
+ const body = retryAfter === undefined ? { error } : { error, retryAfter };
127
+ return body;
128
+ };
129
+ return alxia2().wrap(async (ctx, next) => {
130
+ const { request, reply } = ctx;
131
+ if (!methods.has(request.method))
132
+ return next();
133
+ const key = request.headers.get(header);
134
+ if (key === null) {
135
+ return options.required ? reply(400, refuse("idempotency_key_missing")) : next();
136
+ }
137
+ if (!KEY.test(key))
138
+ return reply(400, refuse("idempotency_key_invalid"));
139
+ const body = new Uint8Array(await request.clone().arrayBuffer());
140
+ const head = new TextEncoder().encode(`${request.method} ${ctx.url.pathname}${ctx.url.search}
141
+ `);
142
+ const fingerprint = new Uint8Array(head.length + body.length);
143
+ fingerprint.set(head);
144
+ fingerprint.set(body, head.length);
145
+ const id = `${ctx.route}:${scope(ctx) ?? "anyone"}:${key}`;
146
+ try {
147
+ const { value, replayed } = await bound.run(id, async () => store(await next()), {
148
+ fingerprint,
149
+ ...options.wait === undefined ? {} : { wait: options.wait }
150
+ });
151
+ return restore(value, replayed);
152
+ } catch (error) {
153
+ if (error instanceof Unstored)
154
+ return error.response;
155
+ if (error instanceof GuardError && error.code === "IN_PROGRESS") {
156
+ const retryAfter = Math.max(1, Math.ceil((error.retryAfter ?? 0) / 1000));
157
+ return reply(409, refuse("idempotency_in_progress", retryAfter), {
158
+ headers: { "retry-after": String(retryAfter) }
159
+ });
160
+ }
161
+ if (error instanceof GuardError && error.code === "MISMATCH") {
162
+ return reply(422, refuse("idempotency_key_reused"));
163
+ }
164
+ throw error;
165
+ }
166
+ });
167
+ }
168
+ async function store(response) {
169
+ if (response.status >= 500 || response.headers.get("content-type")?.startsWith("text/event-stream")) {
170
+ throw new Unstored(response);
171
+ }
172
+ const headers = [];
173
+ for (const [name, value] of response.headers) {
174
+ if (!UNSTORED_HEADERS.has(name))
175
+ headers.push([name, value]);
176
+ }
177
+ const bytes = new Uint8Array(await response.arrayBuffer());
178
+ return {
179
+ status: response.status,
180
+ headers,
181
+ body: Buffer.from(bytes).toString("base64")
182
+ };
183
+ }
184
+ function restore(stored, replayed) {
185
+ const headers = new Headers(stored.headers);
186
+ if (replayed)
187
+ headers.set("idempotent-replayed", "true");
188
+ const body = Buffer.from(stored.body, "base64");
189
+ return new Response(stored.status === 204 || stored.status === 304 ? null : body, { status: stored.status, headers });
190
+ }
191
+ // src/store.ts
192
+ import {
193
+ bindRateLimit,
194
+ defineRateLimit
195
+ } from "@nxgt/redis-guard";
196
+ function redisStore(client, options) {
197
+ const limits = new Map;
198
+ const policies = `${options.name}:policies`;
199
+ const bind = (limit, windowMs) => bindRateLimit(client, defineRateLimit({
200
+ name: `${options.name}:${limit}/${windowMs}`,
201
+ key: (key) => key,
202
+ limit,
203
+ per: windowMs
204
+ }));
205
+ const limitFor = (policy) => {
206
+ const id = `${policy.limit}/${policy.windowMs}`;
207
+ let bound = limits.get(id);
208
+ if (bound === undefined) {
209
+ const limit = bind(policy.limit, policy.windowMs);
210
+ const recorded = client.send("SADD", [policies, id]).then(() => limit);
211
+ recorded.catch(() => {
212
+ if (limits.get(id) === recorded)
213
+ limits.delete(id);
214
+ });
215
+ limits.set(id, recorded);
216
+ bound = recorded;
217
+ }
218
+ return bound;
219
+ };
220
+ return {
221
+ async consume(key, policy) {
222
+ const result = await (await limitFor(policy)).consume(key);
223
+ return {
224
+ allowed: result.allowed,
225
+ remaining: result.remaining,
226
+ resetAfter: result.resetAfter,
227
+ retryAfter: result.retryAfter
228
+ };
229
+ },
230
+ async reset(key) {
231
+ const ids = await client.send("SMEMBERS", [policies]);
232
+ await Promise.all(ids.map(async (id) => {
233
+ const policy = /^(\d+)\/(\d+)$/.exec(id);
234
+ if (policy === null)
235
+ return;
236
+ const limit = Number(policy[1]);
237
+ const windowMs = Number(policy[2]);
238
+ if (limit < 1 || windowMs < 1)
239
+ return;
240
+ await bind(limit, windowMs).reset(key);
241
+ }));
242
+ }
243
+ };
244
+ }
245
+ export {
246
+ idempotency,
247
+ redis,
248
+ redisCacheStore,
249
+ redisStore
250
+ };
251
+
252
+ //# debugId=04C8078B823357BA64756E2164756E21
253
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/cache-store.ts", "../src/context.ts", "../src/idempotency.ts", "../src/store.ts"],
4
+ "sourcesContent": [
5
+ "import type { CachedResponse, CacheStore } from '@alxia/cache';\nimport { bindCache, defineCache } from '@nxgt/redis';\nimport type { RedisClient } from 'bun';\nimport { z } from 'zod';\n\nexport interface RedisCacheStoreOptions {\n\t/** Prepended to every key it writes: one name per app or deployment. */\n\treadonly name: string;\n}\n\nconst Stored = z.object({\n\tstatus: z.number().int(),\n\theaders: z.array(z.tuple([z.string(), z.string()])),\n\tbody: z.string(),\n\tstoredAt: z.number(),\n\tttl: z.number(),\n\tstale: z.number(),\n\ttags: z.array(z.string()),\n});\n\n/** What a Redis older than 7 answers to `EXPIRE … NX`. */\nconst REFUSED_ARGUMENTS = /wrong number of arguments|syntax error/i;\n\n/**\n * An `@alxia/cache` store in Redis, on `@nxgt/redis`'s typed caches: every\n * process sharing the Redis serves what one of them kept. A record that no\n * longer reads as a response is a miss, and is dropped. Tags are Redis sets\n * of the keys they name.\n *\n * ```ts\n * app.use(cache({ ttl: 60, store: redisCacheStore(connection.client, { name: 'shop' }) }));\n * ```\n */\nexport function redisCacheStore(\n\tclient: RedisClient,\n\toptions: RedisCacheStoreOptions,\n): CacheStore {\n\t// `@nxgt/redis` keeps a record for whole seconds; the store's own `ttl`\n\t// and `stale` decide freshness to the millisecond.\n\tconst records = bindCache(\n\t\tclient,\n\t\tdefineCache({\n\t\t\tname: `${options.name}:response`,\n\t\t\tkey: (key: string) => key,\n\t\t\tttl: 60,\n\t\t\tschema: Stored,\n\t\t}),\n\t);\n\tconst tagKey = (tag: string) => `${options.name}:tag:${tag}`;\n\t/**\n\t * Keeps a tag's set as long as its longest-kept response: `NX` gives a\n\t * new set its first expiry — `GT` alone never would, a key without one\n\t * counting as kept forever — and `GT` then only ever lengthens it. A\n\t * Redis older than 7 refuses both arguments: it gets the plain `EXPIRE`,\n\t * from then on. Any other error is the caller's.\n\t */\n\tlet flagsKnown = true;\n\tconst keepTag = async (key: string, seconds: number) => {\n\t\tconst ttl = String(seconds);\n\t\tif (flagsKnown) {\n\t\t\ttry {\n\t\t\t\tawait client.send('EXPIRE', [key, ttl, 'NX']);\n\t\t\t\tawait client.send('EXPIRE', [key, ttl, 'GT']);\n\t\t\t\treturn;\n\t\t\t} catch (error) {\n\t\t\t\tif (!REFUSED_ARGUMENTS.test(String(error))) throw error;\n\t\t\t\tflagsKnown = false;\n\t\t\t}\n\t\t}\n\t\tawait client.send('EXPIRE', [key, ttl]);\n\t};\n\n\treturn {\n\t\tasync get(key) {\n\t\t\tconst record = await records.get(key);\n\t\t\tif (record === undefined) return undefined;\n\t\t\tconst response: CachedResponse = {\n\t\t\t\t...record,\n\t\t\t\tbody: new Uint8Array(Buffer.from(record.body, 'base64')),\n\t\t\t};\n\t\t\treturn response;\n\t\t},\n\t\tasync set(key, value, keepFor) {\n\t\t\tconst seconds = Math.max(1, Math.ceil(keepFor / 1000));\n\t\t\tawait records.set(\n\t\t\t\tkey,\n\t\t\t\t{\n\t\t\t\t\t...value,\n\t\t\t\t\theaders: value.headers.map(\n\t\t\t\t\t\t([name, header]) => [name, header] as [string, string],\n\t\t\t\t\t),\n\t\t\t\t\ttags: [...value.tags],\n\t\t\t\t\tbody: Buffer.from(value.body).toString('base64'),\n\t\t\t\t},\n\t\t\t\t{ ttl: seconds },\n\t\t\t);\n\t\t\tawait Promise.all(\n\t\t\t\tvalue.tags.map(async (tag) => {\n\t\t\t\t\tawait client.send('SADD', [tagKey(tag), records.keyFor(key)]);\n\t\t\t\t\tawait keepTag(tagKey(tag), seconds);\n\t\t\t\t}),\n\t\t\t);\n\t\t},\n\t\tasync delete(key) {\n\t\t\tawait records.delete(key);\n\t\t},\n\t\tasync deleteTag(tag) {\n\t\t\tconst keys = (await client.send('SMEMBERS', [tagKey(tag)])) as string[];\n\t\t\tif (keys.length > 0) await client.send('DEL', keys);\n\t\t\tawait client.send('DEL', [tagKey(tag)]);\n\t\t},\n\t};\n}\n",
6
+ "import { alxia } from '@alxia/core';\nimport {\n\ttype BoundCache,\n\tbindCache,\n\ttype CacheDefinition,\n\ttype LockOptions,\n\twithLock,\n} from '@nxgt/redis';\nimport type { RedisClient } from 'bun';\nimport type { z } from 'zod';\n\ntype AnyCache = CacheDefinition<any, z.ZodType>;\n\n/** The caches of `Caches`, each bound to the client. */\nexport type BoundCaches<Caches extends Record<string, AnyCache>> = {\n\treadonly [Name in keyof Caches]: Caches[Name] extends CacheDefinition<\n\t\tinfer Params,\n\t\tinfer Schema\n\t>\n\t\t? BoundCache<Params, z.output<Schema>, z.input<Schema>>\n\t\t: never;\n};\n\nexport interface RedisContextOptions<Caches extends Record<string, AnyCache>> {\n\t/** `@nxgt/redis` cache definitions, by the name routes read them under. */\n\treadonly caches?: Caches;\n}\n\n/** What routes after `redis()` read. */\nexport interface RedisContext<Caches extends Record<string, AnyCache>> {\n\t/** Bun's own client, untouched. */\n\treadonly redis: RedisClient;\n\t/** Each cache, bound and typed by its schema: `caches.users.remember(…)`. */\n\treadonly caches: BoundCaches<Caches>;\n\t/** `work` under a lock every process sharing the Redis respects: `@nxgt/redis`'s `withLock`. */\n\tlock<T>(\n\t\tkey: string,\n\t\twork: () => Promise<T> | T,\n\t\toptions?: LockOptions,\n\t): Promise<T>;\n}\n\n/**\n * Redis in the context, as a plugin: the client, the caches bound once, and\n * a lock — typed, for every route declared after it.\n *\n * ```ts\n * const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });\n * app.use(redis(connection.client, { caches: { users } }))\n * .get('/users/:id', async ({ caches, params, reply }) => reply.ok(await caches.users.remember(params.id, load)));\n * ```\n */\nexport function redis<\n\tconst Caches extends Record<string, AnyCache> = Record<never, never>,\n>(client: RedisClient, options: RedisContextOptions<Caches> = {}) {\n\tconst caches = Object.fromEntries(\n\t\tObject.entries(options.caches ?? {}).map(([name, definition]) => [\n\t\t\tname,\n\t\t\tbindCache(client, definition),\n\t\t]),\n\t) as BoundCaches<Caches>;\n\tconst context: RedisContext<Caches> = {\n\t\tredis: client,\n\t\tcaches,\n\t\tlock: (key, work, lockOptions) => withLock(client, key, work, lockOptions),\n\t};\n\treturn alxia().decorate(context);\n}\n",
7
+ "import { alxia, type BaseContext } from '@alxia/core';\nimport {\n\tbindIdempotency,\n\tdefineIdempotency,\n\tGuardError,\n} from '@nxgt/redis-guard';\nimport type { RedisClient } from 'bun';\nimport { z } from 'zod';\n\nexport interface IdempotencyOptions {\n\t/** Names the keys it stores. */\n\treadonly name: string;\n\t/** Seconds a finished response is kept and replayed. A day by default. */\n\treadonly ttl?: number;\n\t/** Milliseconds a running request holds its key unless renewed. `@nxgt/redis-guard`'s 10 s by default. */\n\treadonly lease?: number;\n\t/** Milliseconds a repeat waits for the first to finish before a 409. None by default. */\n\treadonly wait?: number;\n\t/** The methods it guards. `POST` and `PATCH` by default: the others are idempotent already. */\n\treadonly methods?: readonly string[];\n\t/** The header the key is read from. `Idempotency-Key` by default. */\n\treadonly header?: string;\n\t/** Whether a guarded request without a key is refused, with a 400. Off by default. */\n\treadonly required?: boolean;\n\t/**\n\t * Whose key it is: keys are scoped by the route and by this, so two\n\t * clients choosing the same key never see each other's response. The\n\t * client's address by default; a user id when there is one.\n\t */\n\treadonly scope?: (ctx: BaseContext) => string | undefined;\n}\n\n/** The body of a refusal. */\nexport interface IdempotencyErrorBody {\n\treadonly error:\n\t\t| 'idempotency_key_missing'\n\t\t| 'idempotency_key_invalid'\n\t\t| 'idempotency_in_progress'\n\t\t| 'idempotency_key_reused';\n\t/** Seconds until a running request should be over: with `idempotency_in_progress`. */\n\treadonly retryAfter?: number;\n}\n\nconst Stored = z.object({\n\tstatus: z.number().int(),\n\theaders: z.array(z.tuple([z.string(), z.string()])),\n\tbody: z.string(),\n});\ntype Stored = z.infer<typeof Stored>;\n\n/** Headers a replay never repeats: a session cookie belongs to one response. */\nconst UNSTORED_HEADERS = new Set(['set-cookie', 'date', 'content-length']);\n\nconst KEY = /^[\\x21-\\x7e]{1,255}$/;\n\n/** A response that is answered and not kept: a 5xx, a stream. */\nclass Unstored extends Error {\n\treadonly response: Response;\n\tconstructor(response: Response) {\n\t\tsuper('unstored');\n\t\tthis.response = response;\n\t}\n}\n\n/**\n * Idempotent routes, as a plugin, with `@nxgt/redis-guard`: a `POST` or\n * `PATCH` carrying an `Idempotency-Key` runs once per key, and every repeat\n * gets the first response back, marked `Idempotent-Replayed: true` — across\n * every process sharing the Redis. Routes declared after it are guarded.\n *\n * A repeat while the first still runs is a 409, and the same key with\n * another request — method, path or body — a 422: both are part of every\n * guarded route's type. A 5xx, or a stream, is answered and not kept: the\n * key is free again.\n *\n * ```ts\n * app.use(idempotency(redis.client, { name: 'payments' })).post('/payments', ...);\n * ```\n */\nexport function idempotency(client: RedisClient, options: IdempotencyOptions) {\n\tconst methods = new Set(options.methods ?? ['POST', 'PATCH']);\n\tconst header = options.header ?? 'idempotency-key';\n\tconst scope = options.scope ?? ((ctx: BaseContext) => ctx.ip);\n\tconst bound = bindIdempotency(\n\t\tclient,\n\t\tdefineIdempotency({\n\t\t\tname: options.name,\n\t\t\tkey: (key: string) => key,\n\t\t\tttl: options.ttl ?? 86_400,\n\t\t\t...(options.lease === undefined ? {} : { lease: options.lease }),\n\t\t\tschema: Stored,\n\t\t}),\n\t);\n\tconst refuse = (\n\t\terror: IdempotencyErrorBody['error'],\n\t\tretryAfter?: number,\n\t) => {\n\t\tconst body: IdempotencyErrorBody =\n\t\t\tretryAfter === undefined ? { error } : { error, retryAfter };\n\t\treturn body;\n\t};\n\n\treturn alxia().wrap(async (ctx, next) => {\n\t\tconst { request, reply } = ctx;\n\t\tif (!methods.has(request.method)) return next();\n\t\tconst key = request.headers.get(header);\n\t\tif (key === null) {\n\t\t\treturn options.required\n\t\t\t\t? reply(400, refuse('idempotency_key_missing'))\n\t\t\t\t: next();\n\t\t}\n\t\tif (!KEY.test(key)) return reply(400, refuse('idempotency_key_invalid'));\n\n\t\tconst body = new Uint8Array(await request.clone().arrayBuffer());\n\t\tconst head = new TextEncoder().encode(\n\t\t\t`${request.method} ${ctx.url.pathname}${ctx.url.search}\\n`,\n\t\t);\n\t\tconst fingerprint = new Uint8Array(head.length + body.length);\n\t\tfingerprint.set(head);\n\t\tfingerprint.set(body, head.length);\n\t\tconst id = `${ctx.route}:${scope(ctx) ?? 'anyone'}:${key}`;\n\n\t\ttry {\n\t\t\tconst { value, replayed } = await bound.run(\n\t\t\t\tid,\n\t\t\t\tasync () => store(await next()),\n\t\t\t\t{\n\t\t\t\t\tfingerprint,\n\t\t\t\t\t...(options.wait === undefined ? {} : { wait: options.wait }),\n\t\t\t\t},\n\t\t\t);\n\t\t\treturn restore(value, replayed);\n\t\t} catch (error) {\n\t\t\tif (error instanceof Unstored) return error.response;\n\t\t\tif (error instanceof GuardError && error.code === 'IN_PROGRESS') {\n\t\t\t\tconst retryAfter = Math.max(\n\t\t\t\t\t1,\n\t\t\t\t\tMath.ceil((error.retryAfter ?? 0) / 1000),\n\t\t\t\t);\n\t\t\t\treturn reply(409, refuse('idempotency_in_progress', retryAfter), {\n\t\t\t\t\theaders: { 'retry-after': String(retryAfter) },\n\t\t\t\t});\n\t\t\t}\n\t\t\tif (error instanceof GuardError && error.code === 'MISMATCH') {\n\t\t\t\treturn reply(422, refuse('idempotency_key_reused'));\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\t});\n}\n\nasync function store(response: Response): Promise<Stored> {\n\tif (\n\t\tresponse.status >= 500 ||\n\t\tresponse.headers.get('content-type')?.startsWith('text/event-stream')\n\t) {\n\t\tthrow new Unstored(response);\n\t}\n\tconst headers: [string, string][] = [];\n\tfor (const [name, value] of response.headers) {\n\t\tif (!UNSTORED_HEADERS.has(name)) headers.push([name, value]);\n\t}\n\tconst bytes = new Uint8Array(await response.arrayBuffer());\n\treturn {\n\t\tstatus: response.status,\n\t\theaders,\n\t\tbody: Buffer.from(bytes).toString('base64'),\n\t};\n}\n\nfunction restore(stored: Stored, replayed: boolean): Response {\n\tconst headers = new Headers(stored.headers);\n\tif (replayed) headers.set('idempotent-replayed', 'true');\n\tconst body = Buffer.from(stored.body, 'base64');\n\treturn new Response(\n\t\tstored.status === 204 || stored.status === 304 ? null : body,\n\t\t{ status: stored.status, headers },\n\t);\n}\n",
8
+ "import type { Decision, Policy, RateLimitStore } from '@alxia/rate-limit';\nimport {\n\ttype BoundRateLimit,\n\tbindRateLimit,\n\tdefineRateLimit,\n} from '@nxgt/redis-guard';\nimport type { RedisClient } from 'bun';\n\nexport interface RedisStoreOptions {\n\t/** Prepended to every key it counts: one name per limit, so two never share a count. */\n\treadonly name: string;\n}\n\n/**\n * An `@alxia/rate-limit` store in Redis, with `@nxgt/redis-guard`'s GCRA:\n * every process sharing the Redis counts together, timed by the Redis\n * server's clock, and a refused request counts nothing.\n *\n * ```ts\n * app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis.client, { name: 'api' }) }));\n * ```\n */\nexport function redisStore(\n\tclient: RedisClient,\n\toptions: RedisStoreOptions,\n): RateLimitStore {\n\tconst limits = new Map<string, Promise<BoundRateLimit<string>>>();\n\t// Every policy counted under this name, by any process: what `reset` forgets.\n\tconst policies = `${options.name}:policies`;\n\tconst bind = (limit: number, windowMs: number) =>\n\t\tbindRateLimit(\n\t\t\tclient,\n\t\t\tdefineRateLimit({\n\t\t\t\tname: `${options.name}:${limit}/${windowMs}`,\n\t\t\t\tkey: (key: string) => key,\n\t\t\t\tlimit,\n\t\t\t\tper: windowMs,\n\t\t\t}),\n\t\t);\n\t/** The bound limit of a policy, recorded in `policies` the first time this process counts under it. */\n\tconst limitFor = (policy: Policy) => {\n\t\tconst id = `${policy.limit}/${policy.windowMs}`;\n\t\tlet bound = limits.get(id);\n\t\tif (bound === undefined) {\n\t\t\tconst limit = bind(policy.limit, policy.windowMs);\n\t\t\tconst recorded = client.send('SADD', [policies, id]).then(() => limit);\n\t\t\trecorded.catch(() => {\n\t\t\t\tif (limits.get(id) === recorded) limits.delete(id);\n\t\t\t});\n\t\t\tlimits.set(id, recorded);\n\t\t\tbound = recorded;\n\t\t}\n\t\treturn bound;\n\t};\n\treturn {\n\t\tasync consume(key, policy): Promise<Decision> {\n\t\t\tconst result = await (await limitFor(policy)).consume(key);\n\t\t\treturn {\n\t\t\t\tallowed: result.allowed,\n\t\t\t\tremaining: result.remaining,\n\t\t\t\tresetAfter: result.resetAfter,\n\t\t\t\tretryAfter: result.retryAfter,\n\t\t\t};\n\t\t},\n\t\tasync reset(key) {\n\t\t\tconst ids = (await client.send('SMEMBERS', [policies])) as string[];\n\t\t\tawait Promise.all(\n\t\t\t\tids.map(async (id) => {\n\t\t\t\t\t// Only what `limitFor` writes: anything else in the set is not ours.\n\t\t\t\t\tconst policy = /^(\\d+)\\/(\\d+)$/.exec(id);\n\t\t\t\t\tif (policy === null) return;\n\t\t\t\t\tconst limit = Number(policy[1]);\n\t\t\t\t\tconst windowMs = Number(policy[2]);\n\t\t\t\t\tif (limit < 1 || windowMs < 1) return;\n\t\t\t\t\tawait bind(limit, windowMs).reset(key);\n\t\t\t\t}),\n\t\t\t);\n\t\t},\n\t};\n}\n"
9
+ ],
10
+ "mappings": "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",
11
+ "debugId": "04C8078B823357BA64756E2164756E21",
12
+ "names": []
13
+ }
@@ -0,0 +1,17 @@
1
+ import type { RateLimitStore } from '@alxia/rate-limit';
2
+ import type { RedisClient } from 'bun';
3
+ export interface RedisStoreOptions {
4
+ /** Prepended to every key it counts: one name per limit, so two never share a count. */
5
+ readonly name: string;
6
+ }
7
+ /**
8
+ * An `@alxia/rate-limit` store in Redis, with `@nxgt/redis-guard`'s GCRA:
9
+ * every process sharing the Redis counts together, timed by the Redis
10
+ * server's clock, and a refused request counts nothing.
11
+ *
12
+ * ```ts
13
+ * app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis.client, { name: 'api' }) }));
14
+ * ```
15
+ */
16
+ export declare function redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore;
17
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAoB,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAM1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,KAAK,CAAC;AAEvC,MAAM,WAAW,iBAAiB;IACjC,wFAAwF;IACxF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CACzB,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,iBAAiB,GACxB,cAAc,CAsDhB"}