@alxia/redis 0.1.3 → 0.2.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 +15 -10
- package/dist/context.d.ts +3 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/idempotency.d.ts +15 -5
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -13
- package/dist/index.js.map +4 -4
- package/docs/README.md +2 -2
- package/docs/guide/caches-and-locks.md +8 -8
- package/docs/guide/connecting.md +5 -5
- package/docs/guide/idempotency.md +41 -25
- package/docs/guide/rate-limits.md +2 -2
- package/docs/guide/response-cache.md +3 -3
- package/docs/guide/testing.md +11 -14
- package/docs/roadmap.md +4 -1
- package/docs/troubleshooting.md +45 -6
- package/package.json +7 -8
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Redis for [alxia](https://www.npmjs.com/package/@alxia/core), on
|
|
|
6
6
|
Bun's own Redis client, no driver, no dependency:
|
|
7
7
|
|
|
8
8
|
- `redisStore`: a rate-limit store every process shares;
|
|
9
|
-
- `idempotency`: routes
|
|
9
|
+
- `idempotency`: a middleware, so routes run once per `Idempotency-Key`;
|
|
10
10
|
- `redisCacheStore`: an `@alxia/cache` store every process shares;
|
|
11
11
|
- `redis`: the client, typed caches and a lock in the context.
|
|
12
12
|
|
|
@@ -68,7 +68,7 @@ the response: the route runs, and the error is logged.
|
|
|
68
68
|
## Idempotent routes
|
|
69
69
|
|
|
70
70
|
```ts
|
|
71
|
-
import { alxia } from '@alxia/core';
|
|
71
|
+
import { alxia, validate } from '@alxia/core';
|
|
72
72
|
import { idempotency } from '@alxia/redis';
|
|
73
73
|
import { connectRedis } from '@nxgt/redis';
|
|
74
74
|
import { z } from 'zod';
|
|
@@ -78,7 +78,7 @@ const Payment = z.object({ amount: z.number().int().positive() });
|
|
|
78
78
|
|
|
79
79
|
const app = alxia()
|
|
80
80
|
.use(idempotency(connection.client, { name: 'payments', required: true }))
|
|
81
|
-
.post('/payments', { body: Payment }, ({ body, reply }) =>
|
|
81
|
+
.post('/payments', validate({ body: Payment }), ({ body, reply }) =>
|
|
82
82
|
reply(201, { id: crypto.randomUUID(), amount: body.amount }),
|
|
83
83
|
);
|
|
84
84
|
```
|
|
@@ -95,12 +95,16 @@ the first response back — status, headers, body — with
|
|
|
95
95
|
| no key, with `required` | `400 { error: 'idempotency_key_missing' }` |
|
|
96
96
|
| the route answers a 5xx, or streams | answered, not kept: the key is free again |
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
Only the routes after the middleware answer them; a request no route matches
|
|
99
|
+
is not guarded: there is no route to scope its key by. What is kept is what the
|
|
100
|
+
route answers, an error's answer included. Keys are scoped by the
|
|
99
101
|
route and by `scope(ctx)` — the client's address by default, a user id
|
|
100
102
|
when there is one — so two clients choosing the same key never see each
|
|
101
|
-
other's response. A
|
|
103
|
+
other's response. A request with no scope — no address, no `scope` — runs
|
|
104
|
+
unguarded, nothing stored or replayed, and the middleware warns once. A replay never repeats `Set-Cookie`. Every response
|
|
102
105
|
below 500 is kept, a 4xx included: declare a rate limit or an auth check
|
|
103
|
-
**before** `idempotency`, or its refusal is replayed.
|
|
106
|
+
**before** `idempotency`, or its refusal is replayed (`app.use` in
|
|
107
|
+
declaration order: the guard first, then `idempotency`).
|
|
104
108
|
|
|
105
109
|
| option | default | |
|
|
106
110
|
| --- | --- | --- |
|
|
@@ -111,7 +115,7 @@ below 500 is kept, a 4xx included: declare a rate limit or an auth check
|
|
|
111
115
|
| `methods` | `POST`, `PATCH` | |
|
|
112
116
|
| `header` | `Idempotency-Key` | |
|
|
113
117
|
| `required` | `false` | |
|
|
114
|
-
| `scope` | the client's address | `(ctx) => string` |
|
|
118
|
+
| `scope` | the client's address | `(ctx) => string \| undefined`; `undefined` runs the request unguarded |
|
|
115
119
|
|
|
116
120
|
## Caches and locks in the context
|
|
117
121
|
|
|
@@ -128,7 +132,7 @@ const loadUser = async (id: string) => ({ id, name: 'Ada' }); // your database
|
|
|
128
132
|
const touch = async (user: z.infer<typeof User>) => user;
|
|
129
133
|
|
|
130
134
|
const app = alxia()
|
|
131
|
-
.
|
|
135
|
+
.plugin(redis(connection.client, { caches: { users } }))
|
|
132
136
|
.get('/users/:id', async ({ caches, lock, params, reply }) => {
|
|
133
137
|
const user = await caches.users.remember(params.id, () => loadUser(params.id)); // typed by User
|
|
134
138
|
await lock(`user:${params.id}`, () => touch(user));
|
|
@@ -151,9 +155,10 @@ The package's specs run against `$REDIS_URL`, or a `redis-server` on
|
|
|
151
155
|
| --- | --- |
|
|
152
156
|
| `redisStore(client, { name })`, `RedisStoreOptions` | an `@alxia/rate-limit` store |
|
|
153
157
|
| `redisCacheStore(client, { name })`, `RedisCacheStoreOptions` | an `@alxia/cache` store |
|
|
154
|
-
| `idempotency(client, options)` | the
|
|
155
|
-
| `redis(client, { caches? })`, `RedisContextOptions` |
|
|
158
|
+
| `idempotency(client, options)` | the middleware, given to `app.use` |
|
|
159
|
+
| `redis(client, { caches? })`, `RedisContextOptions` | a plugin, given to `app.plugin`: `redis`, `caches`, `lock` in the context |
|
|
156
160
|
| `IdempotencyOptions`, `IdempotencyErrorBody`, `RedisContext`, `BoundCaches` | its types |
|
|
161
|
+
| `IdempotencyMiddleware` | what `idempotency()` returns: a middleware that adds nothing, and may answer a 400, a 409 or a 422 |
|
|
157
162
|
| `AnyCache` | any cache definition: the constraint of a function generic over the caches it hands to `redis()` |
|
|
158
163
|
|
|
159
164
|
## Documentation
|
package/dist/context.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { z } from 'zod';
|
|
|
8
8
|
*
|
|
9
9
|
* ```ts
|
|
10
10
|
* function withCaches<const Caches extends Record<string, AnyCache>>(caches: Caches) {
|
|
11
|
-
* return alxia().
|
|
11
|
+
* return alxia().plugin(redis(client, { caches }));
|
|
12
12
|
* }
|
|
13
13
|
* ```
|
|
14
14
|
*/
|
|
@@ -36,9 +36,9 @@ export interface RedisContext<Caches extends Record<string, AnyCache>> {
|
|
|
36
36
|
*
|
|
37
37
|
* ```ts
|
|
38
38
|
* const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, schema: User });
|
|
39
|
-
* app.
|
|
39
|
+
* app.plugin(redis(connection.client, { caches: { users } }))
|
|
40
40
|
* .get('/users/:id', async ({ caches, params, reply }) => reply.ok(await caches.users.remember(params.id, load)));
|
|
41
41
|
* ```
|
|
42
42
|
*/
|
|
43
|
-
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>,
|
|
43
|
+
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>, "", never>;
|
|
44
44
|
//# sourceMappingURL=context.d.ts.map
|
package/dist/context.d.ts.map
CHANGED
|
@@ -1 +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;;;;;;;;;;GAUG;AACH,MAAM,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;AAEvD,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,
|
|
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;;;;;;;;;;GAUG;AACH,MAAM,MAAM,QAAQ,GAAG,eAAe,CAAC,GAAG,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;AAEvD,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,8FAa/D"}
|
package/dist/idempotency.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BaseContext } from '@alxia/core';
|
|
1
|
+
import { type BaseContext, type Empty, type Middleware, type MiddlewareMark, type Reply } from '@alxia/core';
|
|
2
2
|
import type { RedisClient } from 'bun';
|
|
3
3
|
export interface IdempotencyOptions {
|
|
4
4
|
/** Names the keys it stores. */
|
|
@@ -18,7 +18,9 @@ export interface IdempotencyOptions {
|
|
|
18
18
|
/**
|
|
19
19
|
* Whose key it is: keys are scoped by the route and by this, so two
|
|
20
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.
|
|
21
|
+
* client's address by default; a user id when there is one. A request
|
|
22
|
+
* it gives no scope for runs unguarded — nothing stored, nothing
|
|
23
|
+
* replayed — with a warning, once.
|
|
22
24
|
*/
|
|
23
25
|
readonly scope?: (ctx: BaseContext) => string | undefined;
|
|
24
26
|
}
|
|
@@ -29,10 +31,18 @@ export interface IdempotencyErrorBody {
|
|
|
29
31
|
readonly retryAfter?: number;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
|
-
*
|
|
34
|
+
* What `idempotency()` makes: a middleware that adds nothing to the
|
|
35
|
+
* context, and may answer a 400, a 409 or a 422.
|
|
36
|
+
*/
|
|
37
|
+
export type IdempotencyMiddleware = Middleware<Empty, Promise<Response | Reply<400, IdempotencyErrorBody> | Reply<409, IdempotencyErrorBody> | Reply<422, IdempotencyErrorBody>>> & MiddlewareMark;
|
|
38
|
+
/**
|
|
39
|
+
* Idempotent routes, as a middleware, with `@nxgt/redis-guard`: a `POST` or
|
|
33
40
|
* `PATCH` carrying an `Idempotency-Key` runs once per key, and every repeat
|
|
34
41
|
* gets the first response back, marked `Idempotent-Replayed: true` — across
|
|
35
|
-
* every process sharing the Redis. Routes declared after it are guarded
|
|
42
|
+
* every process sharing the Redis. Routes declared after it are guarded; a
|
|
43
|
+
* request no route matches is not: there is no route to scope its key by.
|
|
44
|
+
* What is kept is the response the route answers, an error's answer
|
|
45
|
+
* included.
|
|
36
46
|
*
|
|
37
47
|
* A repeat while the first still runs is a 409, and the same key with
|
|
38
48
|
* another request — method, path or body — a 422: both are part of every
|
|
@@ -43,5 +53,5 @@ export interface IdempotencyErrorBody {
|
|
|
43
53
|
* app.use(idempotency(redis.client, { name: 'payments' })).post('/payments', ...);
|
|
44
54
|
* ```
|
|
45
55
|
*/
|
|
46
|
-
export declare function idempotency(client: RedisClient, options: IdempotencyOptions):
|
|
56
|
+
export declare function idempotency(client: RedisClient, options: IdempotencyOptions): IdempotencyMiddleware;
|
|
47
57
|
//# sourceMappingURL=idempotency.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,WAAW,EAEhB,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,KAAK,EAEV,MAAM,aAAa,CAAC;AAMrB,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;;;;;;OAMG;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;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAAG,UAAU,CAC7C,KAAK,EACL,OAAO,CACJ,QAAQ,GACR,KAAK,CAAC,GAAG,EAAE,oBAAoB,CAAC,GAChC,KAAK,CAAC,GAAG,EAAE,oBAAoB,CAAC,GAChC,KAAK,CAAC,GAAG,EAAE,oBAAoB,CAAC,CAClC,CACD,GACA,cAAc,CAAC;AAuBhB;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAC1B,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,kBAAkB,GACzB,qBAAqB,CA2EvB"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { type RedisCacheStoreOptions, redisCacheStore } from './cache-store';
|
|
2
2
|
export { type AnyCache, type BoundCaches, type RedisContext, type RedisContextOptions, redis, } from './context';
|
|
3
|
-
export { type IdempotencyErrorBody, type IdempotencyOptions, idempotency, } from './idempotency';
|
|
3
|
+
export { type IdempotencyErrorBody, type IdempotencyMiddleware, type IdempotencyOptions, idempotency, } from './idempotency';
|
|
4
4
|
export { type RedisStoreOptions, redisStore } from './store';
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +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,QAAQ,EACb,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"}
|
|
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,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,GACL,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,WAAW,GACX,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -89,7 +89,10 @@ function redis(client, options = {}) {
|
|
|
89
89
|
return alxia().decorate(context);
|
|
90
90
|
}
|
|
91
91
|
// src/idempotency.ts
|
|
92
|
-
import {
|
|
92
|
+
import {
|
|
93
|
+
defineMiddleware,
|
|
94
|
+
settle
|
|
95
|
+
} from "@alxia/core";
|
|
93
96
|
import {
|
|
94
97
|
bindIdempotency,
|
|
95
98
|
defineIdempotency,
|
|
@@ -122,13 +125,20 @@ function idempotency(client, options) {
|
|
|
122
125
|
...options.lease === undefined ? {} : { lease: options.lease },
|
|
123
126
|
schema: Stored2
|
|
124
127
|
}));
|
|
128
|
+
let warned = false;
|
|
129
|
+
const warnUnscoped = () => {
|
|
130
|
+
if (warned)
|
|
131
|
+
return;
|
|
132
|
+
warned = true;
|
|
133
|
+
console.warn(unscopedWarning(options.name));
|
|
134
|
+
};
|
|
125
135
|
const refuse = (error, retryAfter) => {
|
|
126
136
|
const body = retryAfter === undefined ? { error } : { error, retryAfter };
|
|
127
137
|
return body;
|
|
128
138
|
};
|
|
129
|
-
return
|
|
130
|
-
const { request, reply } = ctx;
|
|
131
|
-
if (!methods.has(request.method))
|
|
139
|
+
return defineMiddleware(async (ctx, next) => {
|
|
140
|
+
const { request, reply, route } = ctx;
|
|
141
|
+
if (route === undefined || !methods.has(request.method))
|
|
132
142
|
return next();
|
|
133
143
|
const key = request.headers.get(header);
|
|
134
144
|
if (key === null) {
|
|
@@ -136,15 +146,15 @@ function idempotency(client, options) {
|
|
|
136
146
|
}
|
|
137
147
|
if (!KEY.test(key))
|
|
138
148
|
return reply(400, refuse("idempotency_key_invalid"));
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
149
|
+
const scoped = scope(ctx);
|
|
150
|
+
if (scoped === undefined) {
|
|
151
|
+
warnUnscoped();
|
|
152
|
+
return next();
|
|
153
|
+
}
|
|
154
|
+
const id = `${route}:${scoped}:${key}`;
|
|
155
|
+
const fingerprint = await fingerprintOf(request, ctx.url);
|
|
146
156
|
try {
|
|
147
|
-
const { value, replayed } = await bound.run(id, async () => store(await next()), {
|
|
157
|
+
const { value, replayed } = await bound.run(id, async () => store(await settle(ctx, next())), {
|
|
148
158
|
fingerprint,
|
|
149
159
|
...options.wait === undefined ? {} : { wait: options.wait }
|
|
150
160
|
});
|
|
@@ -165,6 +175,18 @@ function idempotency(client, options) {
|
|
|
165
175
|
}
|
|
166
176
|
});
|
|
167
177
|
}
|
|
178
|
+
function unscopedWarning(name) {
|
|
179
|
+
return `idempotency "${name}": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().`;
|
|
180
|
+
}
|
|
181
|
+
async function fingerprintOf(request, url) {
|
|
182
|
+
const body = new Uint8Array(await request.clone().arrayBuffer());
|
|
183
|
+
const head = new TextEncoder().encode(`${request.method} ${url.pathname}${url.search}
|
|
184
|
+
`);
|
|
185
|
+
const fingerprint = new Uint8Array(head.length + body.length);
|
|
186
|
+
fingerprint.set(head);
|
|
187
|
+
fingerprint.set(body, head.length);
|
|
188
|
+
return fingerprint;
|
|
189
|
+
}
|
|
168
190
|
async function store(response) {
|
|
169
191
|
if (response.status >= 500 || response.headers.get("content-type")?.startsWith("text/event-stream")) {
|
|
170
192
|
throw new Unstored(response);
|
|
@@ -249,5 +271,5 @@ export {
|
|
|
249
271
|
redisStore
|
|
250
272
|
};
|
|
251
273
|
|
|
252
|
-
//# debugId=
|
|
274
|
+
//# debugId=FF8A722FABD88B9264756E2164756E21
|
|
253
275
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
"sources": ["../src/cache-store.ts", "../src/context.ts", "../src/idempotency.ts", "../src/store.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
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\n/**\n * Any `@nxgt/redis` cache definition, whatever its key parameters and\n * schema: what `redis()`'s `caches` hold. The constraint a function generic\n * over the caches it hands to `redis()` takes:\n *\n * ```ts\n * function withCaches<const Caches extends Record<string, AnyCache>>(caches: Caches) {\n * return alxia().
|
|
7
|
-
"import {
|
|
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\n/**\n * Any `@nxgt/redis` cache definition, whatever its key parameters and\n * schema: what `redis()`'s `caches` hold. The constraint a function generic\n * over the caches it hands to `redis()` takes:\n *\n * ```ts\n * function withCaches<const Caches extends Record<string, AnyCache>>(caches: Caches) {\n * return alxia().plugin(redis(client, { caches }));\n * }\n * ```\n */\nexport type 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.plugin(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 {\n\ttype BaseContext,\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Reply,\n\tsettle,\n} 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. A request\n\t * it gives no scope for runs unguarded — nothing stored, nothing\n\t * replayed — with a warning, once.\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\n/**\n * What `idempotency()` makes: a middleware that adds nothing to the\n * context, and may answer a 400, a 409 or a 422.\n */\nexport type IdempotencyMiddleware = Middleware<\n\tEmpty,\n\tPromise<\n\t\t| Response\n\t\t| Reply<400, IdempotencyErrorBody>\n\t\t| Reply<409, IdempotencyErrorBody>\n\t\t| Reply<422, IdempotencyErrorBody>\n\t>\n> &\n\tMiddlewareMark;\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 middleware, 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; a\n * request no route matches is not: there is no route to scope its key by.\n * What is kept is the response the route answers, an error's answer\n * included.\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(\n\tclient: RedisClient,\n\toptions: IdempotencyOptions,\n): IdempotencyMiddleware {\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\tlet warned = false;\n\tconst warnUnscoped = () => {\n\t\tif (warned) return;\n\t\twarned = true;\n\t\tconsole.warn(unscopedWarning(options.name));\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 defineMiddleware(async (ctx, next) => {\n\t\tconst { request, reply, route } = ctx;\n\t\tif (route === undefined || !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 scoped = scope(ctx);\n\t\tif (scoped === undefined) {\n\t\t\twarnUnscoped();\n\t\t\treturn next();\n\t\t}\n\t\tconst id = `${route}:${scoped}:${key}`;\n\t\tconst fingerprint = await fingerprintOf(request, ctx.url);\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 settle(ctx, 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\n/** The warning a request with no scope prints, once per middleware. */\nfunction unscopedWarning(name: string): string {\n\treturn `idempotency \"${name}\": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().`;\n}\n\n/** What a key is bound to: the method, the path and query, the body. */\nasync function fingerprintOf(request: Request, url: URL): Promise<Uint8Array> {\n\tconst body = new Uint8Array(await request.clone().arrayBuffer());\n\tconst head = new TextEncoder().encode(\n\t\t`${request.method} ${url.pathname}${url.search}\\n`,\n\t);\n\tconst fingerprint = new Uint8Array(head.length + body.length);\n\tfingerprint.set(head);\n\tfingerprint.set(body, head.length);\n\treturn fingerprint;\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
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
9
|
],
|
|
10
|
-
"mappings": ";AACA;AAEA;AAOA,IAAM,SAAS,EAAE,OAAO;AAAA,EACvB,QAAQ,EAAE,OAAO,EAAE,IAAI;AAAA,EACvB,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC;AAAA,EAClD,MAAM,EAAE,OAAO;AAAA,EACf,UAAU,EAAE,OAAO;AAAA,EACnB,KAAK,EAAE,OAAO;AAAA,EACd,OAAO,EAAE,OAAO;AAAA,EAChB,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC;AACzB,CAAC;AAGD,IAAM,oBAAoB;AAYnB,SAAS,eAAe,CAC9B,QACA,SACa;AAAA,EAGb,MAAM,UAAU,UACf,QACA,YAAY;AAAA,IACX,MAAM,GAAG,QAAQ;AAAA,IACjB,KAAK,CAAC,QAAgB;AAAA,IACtB,KAAK;AAAA,IACL,QAAQ;AAAA,EACT,CAAC,CACF;AAAA,EACA,MAAM,SAAS,CAAC,QAAgB,GAAG,QAAQ,YAAY;AAAA,EAQvD,IAAI,aAAa;AAAA,EACjB,MAAM,UAAU,OAAO,KAAa,YAAoB;AAAA,IACvD,MAAM,MAAM,OAAO,OAAO;AAAA,IAC1B,IAAI,YAAY;AAAA,MACf,IAAI;AAAA,QACH,MAAM,OAAO,KAAK,UAAU,CAAC,KAAK,KAAK,IAAI,CAAC;AAAA,QAC5C,MAAM,OAAO,KAAK,UAAU,CAAC,KAAK,KAAK,IAAI,CAAC;AAAA,QAC5C;AAAA,QACC,OAAO,OAAO;AAAA,QACf,IAAI,CAAC,kBAAkB,KAAK,OAAO,KAAK,CAAC;AAAA,UAAG,MAAM;AAAA,QAClD,aAAa;AAAA;AAAA,IAEf;AAAA,IACA,MAAM,OAAO,KAAK,UAAU,CAAC,KAAK,GAAG,CAAC;AAAA;AAAA,EAGvC,OAAO;AAAA,SACA,IAAG,CAAC,KAAK;AAAA,MACd,MAAM,SAAS,MAAM,QAAQ,IAAI,GAAG;AAAA,MACpC,IAAI,WAAW;AAAA,QAAW;AAAA,MAC1B,MAAM,WAA2B;AAAA,WAC7B;AAAA,QACH,MAAM,IAAI,WAAW,OAAO,KAAK,OAAO,MAAM,QAAQ,CAAC;AAAA,MACxD;AAAA,MACA,OAAO;AAAA;AAAA,SAEF,IAAG,CAAC,KAAK,OAAO,SAAS;AAAA,MAC9B,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,KAAK,UAAU,IAAI,CAAC;AAAA,MACrD,MAAM,QAAQ,IACb,KACA;AAAA,WACI;AAAA,QACH,SAAS,MAAM,QAAQ,IACtB,EAAE,MAAM,YAAY,CAAC,MAAM,MAAM,CAClC;AAAA,QACA,MAAM,CAAC,GAAG,MAAM,IAAI;AAAA,QACpB,MAAM,OAAO,KAAK,MAAM,IAAI,EAAE,SAAS,QAAQ;AAAA,MAChD,GACA,EAAE,KAAK,QAAQ,CAChB;AAAA,MACA,MAAM,QAAQ,IACb,MAAM,KAAK,IAAI,OAAO,QAAQ;AAAA,QAC7B,MAAM,OAAO,KAAK,QAAQ,CAAC,OAAO,GAAG,GAAG,QAAQ,OAAO,GAAG,CAAC,CAAC;AAAA,QAC5D,MAAM,QAAQ,OAAO,GAAG,GAAG,OAAO;AAAA,OAClC,CACF;AAAA;AAAA,SAEK,OAAM,CAAC,KAAK;AAAA,MACjB,MAAM,QAAQ,OAAO,GAAG;AAAA;AAAA,SAEnB,UAAS,CAAC,KAAK;AAAA,MACpB,MAAM,OAAQ,MAAM,OAAO,KAAK,YAAY,CAAC,OAAO,GAAG,CAAC,CAAC;AAAA,MACzD,IAAI,KAAK,SAAS;AAAA,QAAG,MAAM,OAAO,KAAK,OAAO,IAAI;AAAA,MAClD,MAAM,OAAO,KAAK,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC;AAAA;AAAA,EAExC;AAAA;;AC/GD;AACA;AAAA,eAEC;AAAA;AAAA;AA4DM,SAAS,KAEf,CAAC,QAAqB,UAAuC,CAAC,GAAG;AAAA,EACjE,MAAM,SAAS,OAAO,YACrB,OAAO,QAAQ,QAAQ,UAAU,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,gBAAgB;AAAA,IAChE;AAAA,IACA,WAAU,QAAQ,UAAU;AAAA,EAC7B,CAAC,CACF;AAAA,EACA,MAAM,UAAgC;AAAA,IACrC,OAAO;AAAA,IACP;AAAA,IACA,MAAM,CAAC,KAAK,MAAM,gBAAgB,SAAS,QAAQ,KAAK,MAAM,WAAW;AAAA,EAC1E;AAAA,EACA,OAAO,MAAM,EAAE,SAAS,OAAO;AAAA;;AC7EhC
|
|
11
|
-
"debugId": "
|
|
10
|
+
"mappings": "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",
|
|
11
|
+
"debugId": "FF8A722FABD88B9264756E2164756E21",
|
|
12
12
|
"names": []
|
|
13
13
|
}
|
package/docs/README.md
CHANGED
|
@@ -8,11 +8,11 @@ a realistic example for each.
|
|
|
8
8
|
|
|
9
9
|
| Page | Read it when |
|
|
10
10
|
| --- | --- |
|
|
11
|
-
| [Connecting](guide/connecting.md) | opening the client every export takes, deciding when to build
|
|
11
|
+
| [Connecting](guide/connecting.md) | opening the client every export takes, deciding when to build what takes it, failing fast when Redis is down, closing it, or naming keys so features never share them |
|
|
12
12
|
| [Rate limits](guide/rate-limits.md) | sharing an `@alxia/rate-limit` count across processes, understanding why GCRA lets a burst through, choosing `name`, or resetting a key |
|
|
13
13
|
| [Response cache](guide/response-cache.md) | sharing `@alxia/cache` responses across processes, invalidating them everywhere, or knowing what is stored in Redis |
|
|
14
14
|
| [Idempotency](guide/idempotency.md) | making a `POST` run once per `Idempotency-Key`, reading the `409`, `422` and `400`, scoping keys by user, or ordering it with a rate limit or an auth check |
|
|
15
15
|
| [Caches and locks](guide/caches-and-locks.md) | reading typed caches and a lock from the context, catching `LOCK_HELD`, or using `redis()` beside `@alxia/cache` |
|
|
16
|
-
| [Testing](guide/testing.md) | writing specs against a real Redis, giving `app.request()` a client address, or
|
|
16
|
+
| [Testing](guide/testing.md) | writing specs against a real Redis, giving `app.request()` a client address, or checking which routes the refusals reach |
|
|
17
17
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a limit, a cache or a replay does not behave as you expected |
|
|
18
18
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
|
@@ -17,7 +17,7 @@ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, sch
|
|
|
17
17
|
const loadUser = async (id: string) => ({ id, name: 'Ada' }); // your database
|
|
18
18
|
|
|
19
19
|
const app = alxia()
|
|
20
|
-
.
|
|
20
|
+
.plugin(redis(connection.client, { caches: { users } }))
|
|
21
21
|
.get('/users/:id', async ({ caches, params, reply }) => {
|
|
22
22
|
const user = await caches.users.remember(params.id, () => loadUser(params.id)); // typed by User
|
|
23
23
|
return reply.ok(user);
|
|
@@ -66,7 +66,7 @@ import type { RedisClient } from 'bun';
|
|
|
66
66
|
|
|
67
67
|
export function withCaches<const Caches extends Record<string, AnyCache>>(client: RedisClient, caches: Caches) {
|
|
68
68
|
return alxia()
|
|
69
|
-
.
|
|
69
|
+
.plugin(redis(client, { caches }))
|
|
70
70
|
.get('/caches', ({ caches: bound, reply }) => reply(200, Object.keys(bound)));
|
|
71
71
|
}
|
|
72
72
|
```
|
|
@@ -88,7 +88,7 @@ the name you give it in `caches`:
|
|
|
88
88
|
A realistic case, a profile read through the cache and forgotten on write:
|
|
89
89
|
|
|
90
90
|
```ts
|
|
91
|
-
import { alxia } from '@alxia/core';
|
|
91
|
+
import { alxia, validate } from '@alxia/core';
|
|
92
92
|
import { redis } from '@alxia/redis';
|
|
93
93
|
import { connectRedis, defineCache } from '@nxgt/redis';
|
|
94
94
|
import { z } from 'zod';
|
|
@@ -100,12 +100,12 @@ const profiles = defineCache({ name: 'profile', key: (id: string) => id, ttl: 60
|
|
|
100
100
|
const table = new Map<string, z.input<typeof Profile>>([['1', { id: '1', name: 'Ada' }]]);
|
|
101
101
|
|
|
102
102
|
const app = alxia()
|
|
103
|
-
.
|
|
103
|
+
.plugin(redis(connection.client, { caches: { profiles } }))
|
|
104
104
|
.get('/profiles/:id', async ({ caches, params, reply }) => {
|
|
105
105
|
const profile = await caches.profiles.remember(params.id, async () => table.get(params.id) ?? { id: params.id, name: '?' });
|
|
106
106
|
return reply.ok(profile); // plan is filled in: 'free'
|
|
107
107
|
})
|
|
108
|
-
.put('/profiles/:id', { body: Profile.omit({ id: true }) }, async ({ caches, params, body, reply }) => {
|
|
108
|
+
.put('/profiles/:id', validate({ body: Profile.omit({ id: true }) }), async ({ caches, params, body, reply }) => {
|
|
109
109
|
table.set(params.id, { id: params.id, ...body });
|
|
110
110
|
await caches.profiles.delete(params.id); // the next read loads it again
|
|
111
111
|
return reply(204, undefined);
|
|
@@ -142,7 +142,7 @@ const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
|
142
142
|
const sendInvoices = async () => 12; // the work that must not run twice at once
|
|
143
143
|
|
|
144
144
|
const app = alxia()
|
|
145
|
-
.
|
|
145
|
+
.plugin(redis(connection.client))
|
|
146
146
|
.post('/invoices/run', async ({ lock, reply }) => {
|
|
147
147
|
try {
|
|
148
148
|
const sent = await lock('invoices', sendInvoices, { ttl: 60_000 });
|
|
@@ -176,7 +176,7 @@ import { connectRedis } from '@nxgt/redis';
|
|
|
176
176
|
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
177
177
|
|
|
178
178
|
const app = alxia()
|
|
179
|
-
.
|
|
179
|
+
.plugin(redis(connection.client))
|
|
180
180
|
.post('/articles/:id/views', async ({ redis, params, reply }) => {
|
|
181
181
|
const views = await redis.incr(`views:${params.id}`);
|
|
182
182
|
return reply(200, { views });
|
|
@@ -201,7 +201,7 @@ const users = defineCache({ name: 'user', key: (id: string) => id, ttl: 300, sch
|
|
|
201
201
|
const loadUser = async (id: string) => ({ id, name: 'Ada' });
|
|
202
202
|
|
|
203
203
|
const app = alxia()
|
|
204
|
-
.
|
|
204
|
+
.plugin(redis(connection.client, { caches: { users } }))
|
|
205
205
|
.use(cache({ ttl: 60 }))
|
|
206
206
|
.get('/users/:id', async ({ caches, cache, params, reply }) => {
|
|
207
207
|
cache.tag(`user:${params.id}`); // the response cache
|
package/docs/guide/connecting.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Connecting
|
|
2
2
|
|
|
3
3
|
This page covers the one thing every export of `@alxia/redis` takes first:
|
|
4
|
-
a Bun `RedisClient`, how to open it, when to
|
|
4
|
+
a Bun `RedisClient`, how to open it, when to build what takes it, and
|
|
5
5
|
how to close it.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
@@ -12,7 +12,7 @@ import { connectRedis } from '@nxgt/redis';
|
|
|
12
12
|
const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
|
|
13
13
|
|
|
14
14
|
const app = alxia()
|
|
15
|
-
.
|
|
15
|
+
.plugin(redis(connection.client))
|
|
16
16
|
.get('/ping', async ({ redis, reply }) => reply(200, await redis.ping()));
|
|
17
17
|
|
|
18
18
|
app.listen({ port: 3000 });
|
|
@@ -25,8 +25,8 @@ Every export takes Bun's own `RedisClient` as its first argument:
|
|
|
25
25
|
```ts
|
|
26
26
|
redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore
|
|
27
27
|
redisCacheStore(client: RedisClient, options: RedisCacheStoreOptions): CacheStore
|
|
28
|
-
idempotency(client: RedisClient, options: IdempotencyOptions) // a
|
|
29
|
-
redis(client: RedisClient, options?: RedisContextOptions) // a plugin
|
|
28
|
+
idempotency(client: RedisClient, options: IdempotencyOptions) // a middleware
|
|
29
|
+
redis(client: RedisClient, options?: RedisContextOptions) // a plugin, given to app.plugin
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Any `RedisClient` will do. `connectRedis` from
|
|
@@ -48,7 +48,7 @@ One client is enough for the whole app: the rate-limit store, the cache
|
|
|
48
48
|
store, idempotency and `redis()` all send ordinary commands over it. None
|
|
49
49
|
of them subscribes, so none needs a connection of its own.
|
|
50
50
|
|
|
51
|
-
##
|
|
51
|
+
## Build them after you connect
|
|
52
52
|
|
|
53
53
|
Each export binds the client it is given when it is called. Open the
|
|
54
54
|
connection first, then build the app:
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Idempotency
|
|
2
2
|
|
|
3
|
-
This page covers `idempotency`: a
|
|
3
|
+
This page covers `idempotency`: a middleware, given to `app.use`, that runs a `POST` or `PATCH`
|
|
4
4
|
once per `Idempotency-Key`, replays its response to every repeat, and
|
|
5
5
|
refuses the repeats it cannot answer — across every process sharing a
|
|
6
6
|
Redis.
|
|
7
7
|
|
|
8
8
|
```ts
|
|
9
|
-
import { alxia } from '@alxia/core';
|
|
10
|
-
import { idempotency } from '@alxia/redis';
|
|
9
|
+
import { alxia, validate } from '@alxia/core';
|
|
10
|
+
import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
|
|
11
11
|
import { connectRedis } from '@nxgt/redis';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
|
|
@@ -16,7 +16,7 @@ const Payment = z.object({ amount: z.number().int().positive() });
|
|
|
16
16
|
|
|
17
17
|
const app = alxia()
|
|
18
18
|
.use(idempotency(connection.client, { name: 'payments' }))
|
|
19
|
-
.post('/payments', { body: Payment }, ({ body, reply }) =>
|
|
19
|
+
.post('/payments', validate({ body: Payment }), ({ body, reply }) =>
|
|
20
20
|
reply(201, { id: crypto.randomUUID(), amount: body.amount }),
|
|
21
21
|
);
|
|
22
22
|
```
|
|
@@ -28,12 +28,14 @@ curl -X POST localhost:3000/payments -H 'idempotency-key: 4f1c' -H 'content-type
|
|
|
28
28
|
# 201 {"id":"9a…","amount":10} Idempotent-Replayed: true — the route did not run
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
Only the routes declared **after** `use(idempotency(…))` are guarded.
|
|
31
|
+
Only the routes declared **after** `use(idempotency(…))` are guarded. A request
|
|
32
|
+
no route matches is not: there is no route to scope its key by, so it passes
|
|
33
|
+
to the 404.
|
|
32
34
|
|
|
33
35
|
## The signature
|
|
34
36
|
|
|
35
37
|
```ts
|
|
36
|
-
function idempotency(client: RedisClient, options: IdempotencyOptions); // a
|
|
38
|
+
function idempotency(client: RedisClient, options: IdempotencyOptions); // a middleware
|
|
37
39
|
|
|
38
40
|
interface IdempotencyOptions {
|
|
39
41
|
readonly name: string;
|
|
@@ -56,7 +58,7 @@ interface IdempotencyOptions {
|
|
|
56
58
|
| `methods` | `readonly string[]` | `['POST', 'PATCH']` | the methods it guards; the others pass through |
|
|
57
59
|
| `header` | `string` | `'idempotency-key'` | the request header the key is read from (any case) |
|
|
58
60
|
| `required` | `boolean` | `false` | refuse a guarded request with no key, with a `400` |
|
|
59
|
-
| `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined`
|
|
61
|
+
| `scope` | `(ctx: BaseContext) => string \| undefined` | `ctx.ip` | whose key it is; `undefined` leaves the request unguarded, with a warning |
|
|
60
62
|
|
|
61
63
|
`name`, `ttl` and `lease` are checked when `idempotency(…)` is called, so a
|
|
62
64
|
wrong one throws at startup:
|
|
@@ -84,8 +86,9 @@ guarded request a `500`, logging
|
|
|
84
86
|
| no key, without `required` | the route runs, unguarded |
|
|
85
87
|
| a method not in `methods` | the route runs, unguarded |
|
|
86
88
|
|
|
87
|
-
Every refusal is
|
|
88
|
-
|
|
89
|
+
Every refusal's body is an `IdempotencyErrorBody`; declare the statuses in
|
|
90
|
+
your OpenAPI document, and the client you generate from it (with
|
|
91
|
+
`@nxgt/openapi-codegen`, say) reads them typed:
|
|
89
92
|
|
|
90
93
|
```ts
|
|
91
94
|
interface IdempotencyErrorBody {
|
|
@@ -100,9 +103,8 @@ interface IdempotencyErrorBody {
|
|
|
100
103
|
```
|
|
101
104
|
|
|
102
105
|
```ts
|
|
103
|
-
import {
|
|
104
|
-
import {
|
|
105
|
-
import { idempotency } from '@alxia/redis';
|
|
106
|
+
import { alxia, validate } from '@alxia/core';
|
|
107
|
+
import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
|
|
106
108
|
import { connectRedis } from '@nxgt/redis';
|
|
107
109
|
import { z } from 'zod';
|
|
108
110
|
|
|
@@ -110,18 +112,19 @@ const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
|
110
112
|
|
|
111
113
|
const app = alxia()
|
|
112
114
|
.use(idempotency(connection.client, { name: 'payments', required: true }))
|
|
113
|
-
.post('/payments', { body: z.object({ amount: z.number() }) }, ({ body, reply }) =>
|
|
115
|
+
.post('/payments', validate({ body: z.object({ amount: z.number() }) }), ({ body, reply }) =>
|
|
114
116
|
reply(201, { id: crypto.randomUUID(), amount: body.amount }),
|
|
115
117
|
);
|
|
116
118
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const result = await app.request('/payments', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'content-type': 'application/json', 'idempotency-key': crypto.randomUUID() },
|
|
122
|
+
body: JSON.stringify({ amount: 10 }),
|
|
121
123
|
});
|
|
122
|
-
// result.status: 201
|
|
124
|
+
// result.status: 201, or 400, 409, 422 from the guard, or 500
|
|
123
125
|
if (result.status === 409) {
|
|
124
|
-
|
|
126
|
+
const { retryAfter } = (await result.json()) as IdempotencyErrorBody;
|
|
127
|
+
await Bun.sleep(retryAfter! * 1000); // then send the same request again
|
|
125
128
|
}
|
|
126
129
|
```
|
|
127
130
|
|
|
@@ -150,7 +153,7 @@ gets the replay rather than the `409`:
|
|
|
150
153
|
|
|
151
154
|
```ts
|
|
152
155
|
import { alxia } from '@alxia/core';
|
|
153
|
-
import { idempotency } from '@alxia/redis';
|
|
156
|
+
import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
|
|
154
157
|
import { connectRedis } from '@nxgt/redis';
|
|
155
158
|
|
|
156
159
|
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
@@ -165,15 +168,22 @@ const app = alxia()
|
|
|
165
168
|
Clients choose their keys, so two clients can choose the same one. The
|
|
166
169
|
stored key is `<name>:<route>:<scope>:<key>`, where `scope(ctx)` is the
|
|
167
170
|
client's address by default. When it is `undefined` — no address, as with
|
|
168
|
-
`app.request()` in a test or a server that cannot see one
|
|
169
|
-
|
|
171
|
+
`app.request()` in a test or a server that cannot see one, and no `scope`
|
|
172
|
+
option, or one that returns `undefined` — the request runs unguarded:
|
|
173
|
+
nothing is stored, nothing replayed, and each repeat runs the route again.
|
|
174
|
+
Sharing one key space between every client would replay one client's
|
|
175
|
+
response to another. The middleware warns once:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
idempotency "payments": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().
|
|
179
|
+
```
|
|
170
180
|
|
|
171
181
|
Behind a proxy, the address is the proxy's unless the app's `ip` option
|
|
172
182
|
reads the forwarded header. Where requests carry a user, scope by the user:
|
|
173
183
|
|
|
174
184
|
```ts
|
|
175
185
|
import { alxia } from '@alxia/core';
|
|
176
|
-
import { idempotency } from '@alxia/redis';
|
|
186
|
+
import { idempotency, type IdempotencyErrorBody } from '@alxia/redis';
|
|
177
187
|
import { connectRedis } from '@nxgt/redis';
|
|
178
188
|
|
|
179
189
|
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
@@ -197,9 +207,11 @@ is two keys.
|
|
|
197
207
|
|
|
198
208
|
## Order: what runs inside the guard
|
|
199
209
|
|
|
200
|
-
The
|
|
210
|
+
The middleware wraps the routes declared after it, and every middleware
|
|
201
211
|
declared after it too. Whatever those answer is kept like the route's
|
|
202
|
-
answer
|
|
212
|
+
answer: so is what an `onError` hook, an `HttpError` or a validation
|
|
213
|
+
refusal answers, because `idempotency` settles the rest of the request before
|
|
214
|
+
it keeps it. A rate limit or an authentication check declared **after**
|
|
203
215
|
`idempotency` has its `429` or `401` kept and replayed — even once the
|
|
204
216
|
client is allowed through. Declare them **before**:
|
|
205
217
|
|
|
@@ -222,6 +234,10 @@ Measured: with the rate limit after `idempotency`, a key refused with a
|
|
|
222
234
|
had passed; with it before, the same repeat ran the route and answered
|
|
223
235
|
`201`.
|
|
224
236
|
|
|
237
|
+
Declared on the app, a rate limit or a guard also runs on a request no route
|
|
238
|
+
matches, and answers it before `idempotency` is reached; that is not a
|
|
239
|
+
concern here, since `idempotency` skips that request anyway.
|
|
240
|
+
|
|
225
241
|
A route that refuses a request it might accept later — a `401` before the
|
|
226
242
|
client signs in again, a `409` on a state that changes — should answer it
|
|
227
243
|
before the guard, or the client should send a new key when it retries.
|
|
@@ -145,7 +145,7 @@ and [ten years](../troubleshooting.md#typeerror-defineratelimit--would-take-long
|
|
|
145
145
|
`store.reset(key)` forgets a key — after a successful login, say:
|
|
146
146
|
|
|
147
147
|
```ts
|
|
148
|
-
import { alxia } from '@alxia/core';
|
|
148
|
+
import { alxia, validate } from '@alxia/core';
|
|
149
149
|
import { rateLimit } from '@alxia/rate-limit';
|
|
150
150
|
import { redisStore } from '@alxia/redis';
|
|
151
151
|
import { connectRedis } from '@nxgt/redis';
|
|
@@ -158,7 +158,7 @@ const passwords = new Map([['ada', 'lovelace']]);
|
|
|
158
158
|
const app = alxia().group('/auth', (auth) =>
|
|
159
159
|
auth
|
|
160
160
|
.use(rateLimit({ limit: 5, windowMs: 15 * 60_000, store: attempts }))
|
|
161
|
-
.post('/login', { body: z.object({ name: z.string(), password: z.string() }) }, async ({ body, ip, reply }) => {
|
|
161
|
+
.post('/login', validate({ body: z.object({ name: z.string(), password: z.string() }) }), async ({ body, ip, reply }) => {
|
|
162
162
|
if (passwords.get(body.name) !== body.password) {
|
|
163
163
|
return reply(401, { error: 'invalid_credentials' as const });
|
|
164
164
|
}
|
|
@@ -49,7 +49,7 @@ Everything else — `ttl`, `staleWhileRevalidate`, `key`, `vary`, `statuses`,
|
|
|
49
49
|
|
|
50
50
|
- **A response** is one Redis string at `<name>:response:<key>`: an
|
|
51
51
|
`@nxgt/redis` cache record holding the status, the headers, the body as
|
|
52
|
-
base64, and the
|
|
52
|
+
base64, and the cache's `storedAt`, `ttl` and `stale`. Redis expires it
|
|
53
53
|
when `ttl + staleWhileRevalidate` has passed, rounded up to the second.
|
|
54
54
|
- **A tag** is a Redis set at `<name>:tag:<tag>`, of the response keys it
|
|
55
55
|
names. Besides your tags, every response carries `alxia:path:<path>` —
|
|
@@ -77,7 +77,7 @@ process sharing it misses on its next request:
|
|
|
77
77
|
|
|
78
78
|
```ts
|
|
79
79
|
import { cache } from '@alxia/cache';
|
|
80
|
-
import { alxia } from '@alxia/core';
|
|
80
|
+
import { alxia, validate } from '@alxia/core';
|
|
81
81
|
import { redisCacheStore } from '@alxia/redis';
|
|
82
82
|
import { connectRedis } from '@nxgt/redis';
|
|
83
83
|
import { z } from 'zod';
|
|
@@ -90,7 +90,7 @@ const store = redisCacheStore(connection.client, { name: 'shop' });
|
|
|
90
90
|
const products = cache({ ttl: 60, staleWhileRevalidate: 300, store, tags: () => ['products'] });
|
|
91
91
|
|
|
92
92
|
const app = alxia()
|
|
93
|
-
.post('/products', { body: Product }, async ({ body, reply }) => {
|
|
93
|
+
.post('/products', validate({ body: Product }), async ({ body, reply }) => {
|
|
94
94
|
catalogue.set(body.id, body);
|
|
95
95
|
await products.invalidateTag('products'); // forgotten in every process
|
|
96
96
|
return reply(201, body);
|
package/docs/guide/testing.md
CHANGED
|
@@ -61,8 +61,9 @@ it, as below.
|
|
|
61
61
|
## Give each test a client address
|
|
62
62
|
|
|
63
63
|
`app.request()` has no socket, so `ctx.ip` is `undefined`. A rate limit
|
|
64
|
-
then counts nothing, and idempotency
|
|
65
|
-
|
|
64
|
+
then counts nothing, and idempotency guards nothing: each request runs, and
|
|
65
|
+
it warns once that no client scope could be derived. Pass the app an `ip`
|
|
66
|
+
that answers, as above, or one per test to stand for two
|
|
66
67
|
clients:
|
|
67
68
|
|
|
68
69
|
```ts
|
|
@@ -87,14 +88,13 @@ test('two processes share one count', async () => {
|
|
|
87
88
|
});
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
##
|
|
91
|
+
## Refusals behind the middleware only
|
|
91
92
|
|
|
92
|
-
The `409`, `422` and `400` of `idempotency` are
|
|
93
|
-
|
|
93
|
+
The `409`, `422` and `400` of `idempotency` are answered by the routes
|
|
94
|
+
declared after it alone, never by a request no route matches. Send a key it refuses to both:
|
|
94
95
|
|
|
95
96
|
```ts
|
|
96
|
-
import { expect,
|
|
97
|
-
import { client } from '@alxia/client';
|
|
97
|
+
import { expect, test } from 'bun:test';
|
|
98
98
|
import { alxia } from '@alxia/core';
|
|
99
99
|
import { idempotency } from '@alxia/redis';
|
|
100
100
|
import { connectRedis } from '@nxgt/redis';
|
|
@@ -106,13 +106,10 @@ const app = alxia()
|
|
|
106
106
|
.use(idempotency(connection.client, { name: 'payments' }))
|
|
107
107
|
.post('/payments', ({ reply }) => reply(201, 'ok'));
|
|
108
108
|
|
|
109
|
-
test('only the routes after the
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
expectTypeOf((await api.post('/open')).status).toEqualTypeOf<201 | 500>();
|
|
114
|
-
};
|
|
115
|
-
expect(types).toBeFunction();
|
|
109
|
+
test('only the routes after the middleware refuse a bad key', async () => {
|
|
110
|
+
const init = { method: 'POST', headers: { 'idempotency-key': 'not a key' } };
|
|
111
|
+
expect((await app.request('/payments', init)).status).toBe(400);
|
|
112
|
+
expect((await app.request('/open', init)).status).toBe(201);
|
|
116
113
|
});
|
|
117
114
|
```
|
|
118
115
|
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,10 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **`idempotency` as a middleware.** `app.use(idempotency(client, { name }))` is
|
|
11
|
+
the form; `app.plugin(idempotency(…))` keeps working, deprecated. It skips a
|
|
12
|
+
request no route matches, and keeps what the route answers, an error's
|
|
13
|
+
answer included. `redis()` stays a plugin.
|
|
11
14
|
|
|
12
15
|
## Next
|
|
13
16
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -4,7 +4,8 @@ Each entry is headed by the text you see: a TypeScript error, an exception
|
|
|
4
4
|
at startup, an exception in the log beside a `500 {"error":"internal"}`, or
|
|
5
5
|
the response a client got. `@alxia/redis` throws nothing of its own: the
|
|
6
6
|
messages are `@nxgt/redis`'s, `@nxgt/redis-guard`'s and Bun's, and it lets
|
|
7
|
-
each through.
|
|
7
|
+
each through. It prints one warning of its own, under
|
|
8
|
+
[Runtime: a warning in the log](#runtime-a-warning-in-the-log). What prints nothing is under [Traps](#traps), by symptom.
|
|
8
9
|
|
|
9
10
|
**Install and types**
|
|
10
11
|
|
|
@@ -32,6 +33,10 @@ each through. What prints nothing is under [Traps](#traps), by symptom.
|
|
|
32
33
|
- [`RedisError: The lock "…" expired before its work finished: it ran longer than the …ms ttl, so it may have run beside another holder`](#rediserror-the-lock--expired-before-its-work-finished-it-ran-longer-than-the-ms-ttl-so-it-may-have-run-beside-another-holder)
|
|
33
34
|
- [`TypeError: undefined is not an object (evaluating 'cache.…')`](#typeerror-undefined-is-not-an-object-evaluating-cache)
|
|
34
35
|
|
|
36
|
+
**Runtime: a warning in the log**
|
|
37
|
+
|
|
38
|
+
- [`idempotency "…": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().`](#idempotency--no-client-scope-could-be-derived-ctxip-is-undefined-and-no-scope-option-returned-one-so-these-requests-run-unguarded-nothing-stored-or-replayed-pass-a-scope-option-ctx--a-user-id-or-an-ip-option-to-alxia)
|
|
39
|
+
|
|
35
40
|
**Calling the store yourself**
|
|
36
41
|
|
|
37
42
|
- [`TypeError: defineRateLimit: "…" has a per of …; it is a whole number of milliseconds, and must be at least 1`](#typeerror-defineratelimit--has-a-per-of--it-is-a-whole-number-of-milliseconds-and-must-be-at-least-1)
|
|
@@ -106,7 +111,7 @@ never meet `@alxia/cache`'s `cache`.
|
|
|
106
111
|
|
|
107
112
|
```ts
|
|
108
113
|
alxia()
|
|
109
|
-
.
|
|
114
|
+
.plugin(redis(connection.client, { caches: { users } }))
|
|
110
115
|
.get('/users/:id', async ({ caches, params, reply }) =>
|
|
111
116
|
reply.ok(await caches.users.remember(params.id, () => loadUser(params.id))),
|
|
112
117
|
);
|
|
@@ -277,7 +282,7 @@ with a fraction or below 0.
|
|
|
277
282
|
TypeError: run on "payments": wait is a whole number of milliseconds, 0 or more
|
|
278
283
|
```
|
|
279
284
|
|
|
280
|
-
**Why:** `wait` is checked when it is used, not when the
|
|
285
|
+
**Why:** `wait` is checked when it is used, not when the middleware is made.
|
|
281
286
|
|
|
282
287
|
**Fix:**
|
|
283
288
|
|
|
@@ -398,6 +403,38 @@ with `undefined`.
|
|
|
398
403
|
**Fix:** read `caches.<name>`, drop the `derive`, and typecheck:
|
|
399
404
|
`tsc --noEmit` finds every place.
|
|
400
405
|
|
|
406
|
+
## Runtime: a warning in the log
|
|
407
|
+
|
|
408
|
+
### `idempotency "…": no client scope could be derived (ctx.ip is undefined and no scope option returned one), so these requests run unguarded, nothing stored or replayed. Pass a scope option, (ctx) => a user id, or an ip option to alxia().`
|
|
409
|
+
|
|
410
|
+
**When:** a guarded request carries an `Idempotency-Key`, and the
|
|
411
|
+
middleware has no client to scope it by: `ctx.ip` is `undefined` — under
|
|
412
|
+
`app.request()` in a test, or a server that cannot see the address — and
|
|
413
|
+
there is no `scope` option, or it returned `undefined`. Printed once per
|
|
414
|
+
`idempotency(…)`, with its `name`.
|
|
415
|
+
|
|
416
|
+
**Why:** keys are scoped by the client, so two clients choosing the same key
|
|
417
|
+
never see each other's response. With no client, the request runs
|
|
418
|
+
unguarded: the route runs, nothing is stored, a repeat runs it again.
|
|
419
|
+
Sharing one key space between every client would replay one client's
|
|
420
|
+
response to another.
|
|
421
|
+
|
|
422
|
+
**Fix:** scope by the user where there is one, or give `alxia()` an `ip`
|
|
423
|
+
option that reads the address:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
idempotency(connection.client, {
|
|
427
|
+
name: 'payments',
|
|
428
|
+
scope: ({ request }) => request.headers.get('x-user-id') ?? undefined,
|
|
429
|
+
});
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
or, behind a proxy you trust:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
alxia({ ip: (request) => request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() });
|
|
436
|
+
```
|
|
437
|
+
|
|
401
438
|
## Calling the store yourself
|
|
402
439
|
|
|
403
440
|
A policy `rateLimit` would refuse at startup still reaches the store when
|
|
@@ -567,7 +604,8 @@ rateLimit({ limit: 5, windowMs: 60_000, store: redisStore(connection.client, { n
|
|
|
567
604
|
### A `401` or a `429` is replayed, with `Idempotent-Replayed: true`, after the client fixed it
|
|
568
605
|
|
|
569
606
|
**When:** a request under a key was refused with a `4xx` — by the route, or
|
|
570
|
-
by a rate limit or an authentication check declared after `idempotency
|
|
607
|
+
by a rate limit or an authentication check declared after `idempotency`,
|
|
608
|
+
or by an error handler —
|
|
571
609
|
and the client, once allowed, retries with the same key and body.
|
|
572
610
|
|
|
573
611
|
**Why:** every response below `500` is kept and replayed for `ttl`, not
|
|
@@ -596,8 +634,9 @@ behind a proxy, or with keys that are not random — `1`, `order-1`.
|
|
|
596
634
|
|
|
597
635
|
**Why:** keys are scoped by `scope(ctx)`, the client's address by default.
|
|
598
636
|
Behind a proxy that the app's `ip` option does not see through, every
|
|
599
|
-
client has the proxy's address
|
|
600
|
-
`
|
|
637
|
+
client has the proxy's address, and they then share one key space. (With
|
|
638
|
+
no address at all and no `scope`, the request runs unguarded instead, with
|
|
639
|
+
a [warning](#runtime-a-warning-in-the-log).)
|
|
601
640
|
|
|
602
641
|
**Fix:** scope by the user where there is one, and have clients send
|
|
603
642
|
random keys:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/redis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Redis for alxia on @nxgt/redis and @nxgt/redis-guard: a rate-limit store every process shares, idempotent routes, caches and locks in the context",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,19 +41,18 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/cache": "^0.
|
|
45
|
-
"@alxia/
|
|
46
|
-
"@alxia/
|
|
47
|
-
"@alxia/rate-limit": "^0.1.2",
|
|
44
|
+
"@alxia/cache": "^0.2.0",
|
|
45
|
+
"@alxia/core": "^0.4.0",
|
|
46
|
+
"@alxia/rate-limit": "^0.2.0",
|
|
48
47
|
"@nxgt/redis": "^0.3.1",
|
|
49
48
|
"@nxgt/redis-guard": "^0.3.1",
|
|
50
49
|
"@types/bun": "^1.4.2",
|
|
51
50
|
"zod": "^4.6.5"
|
|
52
51
|
},
|
|
53
52
|
"peerDependencies": {
|
|
54
|
-
"@alxia/cache": "^0.
|
|
55
|
-
"@alxia/core": "^0.
|
|
56
|
-
"@alxia/rate-limit": "^0.
|
|
53
|
+
"@alxia/cache": "^0.2.0",
|
|
54
|
+
"@alxia/core": "^0.4.0",
|
|
55
|
+
"@alxia/rate-limit": "^0.2.0",
|
|
57
56
|
"@nxgt/redis": "^0.3.1",
|
|
58
57
|
"@nxgt/redis-guard": "^0.3.1",
|
|
59
58
|
"typescript": "^6.0.3 || ^7.0.0",
|