@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 +21 -0
- package/README.md +162 -0
- package/dist/cache-store.d.ts +18 -0
- package/dist/cache-store.d.ts.map +1 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/idempotency.d.ts +47 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +253 -0
- package/dist/index.js.map +13 -0
- package/dist/store.d.ts +17 -0
- package/dist/store.d.ts.map +1 -0
- package/docs/README.md +18 -0
- package/docs/guide/caches-and-locks.md +204 -0
- package/docs/guide/connecting.md +165 -0
- package/docs/guide/idempotency.md +255 -0
- package/docs/guide/rate-limits.md +198 -0
- package/docs/guide/response-cache.md +129 -0
- package/docs/guide/testing.md +135 -0
- package/docs/roadmap.md +46 -0
- package/docs/troubleshooting.md +613 -0
- package/package.json +70 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Rate limits
|
|
2
|
+
|
|
3
|
+
This page covers `redisStore`: an
|
|
4
|
+
[`@alxia/rate-limit`](https://www.npmjs.com/package/@alxia/rate-limit)
|
|
5
|
+
store that every process sharing a Redis counts in, and how it differs
|
|
6
|
+
from the memory store.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { alxia } from '@alxia/core';
|
|
10
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
11
|
+
import { redisStore } from '@alxia/redis';
|
|
12
|
+
import { connectRedis } from '@nxgt/redis';
|
|
13
|
+
|
|
14
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
15
|
+
|
|
16
|
+
const app = alxia()
|
|
17
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
|
|
18
|
+
.get('/search', ({ reply }) => reply(200, []));
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`@alxia/rate-limit` is an optional peer: install it beside this package to
|
|
22
|
+
use `redisStore`.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
bun add @alxia/rate-limit
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## The signature
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
function redisStore(client: RedisClient, options: RedisStoreOptions): RateLimitStore;
|
|
32
|
+
|
|
33
|
+
interface RedisStoreOptions {
|
|
34
|
+
/** Prepended to every key it counts: one name per limit, so two never share a count. */
|
|
35
|
+
readonly name: string;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Option | Type | Default | Effect |
|
|
40
|
+
| --- | --- | --- | --- |
|
|
41
|
+
| `name` | `string` | required | the prefix of every key it writes: `<name>:<limit>/<windowMs>:<key>` for a count, and `<name>:policies` for the policies counted under the name |
|
|
42
|
+
|
|
43
|
+
`limit`, `windowMs`, the `key` counted (the client's address by default),
|
|
44
|
+
`skip` and the headers are `rateLimit`'s options, not the store's: see
|
|
45
|
+
`@alxia/rate-limit`'s guide. The 429 and its type are unchanged — a typed
|
|
46
|
+
`429 { error: 'rate_limited', retryAfter }` with `Retry-After`, the same as
|
|
47
|
+
with the memory store.
|
|
48
|
+
|
|
49
|
+
## What changes with Redis
|
|
50
|
+
|
|
51
|
+
- **Every process counts together.** Behind a load balancer, a client gets
|
|
52
|
+
`limit` requests in all, not `limit` per process; a restart forgets
|
|
53
|
+
nothing.
|
|
54
|
+
- **It is GCRA, not a fixed window.** The memory store counts `limit`
|
|
55
|
+
requests, then refuses until its window ends. `redisStore` refills
|
|
56
|
+
continuously, at `limit` per `windowMs`, from a bucket that holds
|
|
57
|
+
`limit`. A client that has been idle can send `limit` at once, then one
|
|
58
|
+
more each `windowMs / limit`: in the first `windowMs` after an idle
|
|
59
|
+
spell, up to `2 × limit − 1` requests pass, never more than `limit` at
|
|
60
|
+
once.
|
|
61
|
+
- **The clock is the Redis server's**, not each process's, so processes
|
|
62
|
+
whose clocks differ still agree.
|
|
63
|
+
- **A refused request counts nothing**, as with the memory store.
|
|
64
|
+
|
|
65
|
+
Measured with `limit: 5, windowMs: 2_000`: seven requests at once answer
|
|
66
|
+
five `200` then two `429`; one second later, two more pass.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { expect, test } from 'bun:test';
|
|
70
|
+
import { alxia } from '@alxia/core';
|
|
71
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
72
|
+
import { redisStore } from '@alxia/redis';
|
|
73
|
+
import { connectRedis } from '@nxgt/redis';
|
|
74
|
+
|
|
75
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
76
|
+
|
|
77
|
+
test('a full bucket, then a steady refill', async () => {
|
|
78
|
+
await connection.client.send('FLUSHDB', []);
|
|
79
|
+
const app = alxia({ ip: () => '1.2.3.4' })
|
|
80
|
+
.use(rateLimit({ limit: 5, windowMs: 2_000, store: redisStore(connection.client, { name: 'demo' }) }))
|
|
81
|
+
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
82
|
+
|
|
83
|
+
const burst = [];
|
|
84
|
+
for (let i = 0; i < 7; i++) burst.push((await app.request('/')).status);
|
|
85
|
+
expect(burst).toEqual([200, 200, 200, 200, 200, 429, 429]);
|
|
86
|
+
|
|
87
|
+
await Bun.sleep(1_000); // half a window: 2.5 requests' worth
|
|
88
|
+
expect((await app.request('/')).status).toBe(200);
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If a hard ceiling per fixed window is a requirement, GCRA is not it: halve
|
|
93
|
+
`limit` and `windowMs` together to keep the rate and halve the burst.
|
|
94
|
+
|
|
95
|
+
## Choosing `name`
|
|
96
|
+
|
|
97
|
+
The key is `<name>:<limit>/<windowMs>:<key>`. Two `rateLimit`s with the
|
|
98
|
+
same `name` and the same `limit` and `windowMs` share one count; with a
|
|
99
|
+
different policy they do not. Give each limit its own name:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { alxia } from '@alxia/core';
|
|
103
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
104
|
+
import { redisStore } from '@alxia/redis';
|
|
105
|
+
import { connectRedis } from '@nxgt/redis';
|
|
106
|
+
|
|
107
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
108
|
+
|
|
109
|
+
const app = alxia()
|
|
110
|
+
.group('/auth', (auth) =>
|
|
111
|
+
auth
|
|
112
|
+
.use(rateLimit({ limit: 5, windowMs: 15 * 60_000, store: redisStore(connection.client, { name: 'login' }) }))
|
|
113
|
+
.post('/login', ({ reply }) => reply(200, 'ok')),
|
|
114
|
+
)
|
|
115
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
|
|
116
|
+
.get('/search', ({ reply }) => reply(200, []));
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Every process must agree on `name`, `limit` and `windowMs`: the same three
|
|
120
|
+
are the same count.
|
|
121
|
+
|
|
122
|
+
## Policies Redis refuses
|
|
123
|
+
|
|
124
|
+
`rateLimit` refuses a `limit` or a `windowMs` that is not a whole number of
|
|
125
|
+
1 or more when it is created, with
|
|
126
|
+
[`TypeError: rateLimit: … must be a whole number of 1 or more, not …`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-).
|
|
127
|
+
`redisStore` checks two more bounds, `@nxgt/redis-guard`'s, only when it
|
|
128
|
+
first counts under a policy, not when the app starts. A policy past either
|
|
129
|
+
makes the first request it counts, and every one after it, a
|
|
130
|
+
`500 {"error":"internal"}`, with the reason in the log — a refused policy
|
|
131
|
+
is not kept, so each request checks it again:
|
|
132
|
+
|
|
133
|
+
| Policy | Logged |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `limit × windowMs` above 9,007,199,254,740 (about 9e12) | `TypeError: defineRateLimit: "api:1000000/31536000000" has a burst of 1000000 and a per of 31536000000ms; burst × per must be at most 9007199254740 for the script to count exactly` |
|
|
136
|
+
| `windowMs` above ten 365-day years, 315,360,000,000, with `limit × windowMs` inside the bound above | `TypeError: defineRateLimit: "api:1/315360000001" would take longer than ten years to refill from empty (burst × per ÷ limit); check that per is in milliseconds` |
|
|
137
|
+
|
|
138
|
+
`limit: 1_000, windowMs: 86_400_000` — a thousand a day — is well inside
|
|
139
|
+
both; a million a year is not. Troubleshooting has the fix for each:
|
|
140
|
+
[burst × per](../troubleshooting.md#typeerror-defineratelimit--has-a-burst-of--and-a-per-of-ms-burst--per-must-be-at-most-9007199254740-for-the-script-to-count-exactly)
|
|
141
|
+
and [ten years](../troubleshooting.md#typeerror-defineratelimit--would-take-longer-than-ten-years-to-refill-from-empty-burst--per--limit-check-that-per-is-in-milliseconds).
|
|
142
|
+
|
|
143
|
+
## Resetting a key
|
|
144
|
+
|
|
145
|
+
`store.reset(key)` forgets a key — after a successful login, say:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { alxia } from '@alxia/core';
|
|
149
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
150
|
+
import { redisStore } from '@alxia/redis';
|
|
151
|
+
import { connectRedis } from '@nxgt/redis';
|
|
152
|
+
import { z } from 'zod';
|
|
153
|
+
|
|
154
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
155
|
+
const attempts = redisStore(connection.client, { name: 'login' });
|
|
156
|
+
const passwords = new Map([['ada', 'lovelace']]);
|
|
157
|
+
|
|
158
|
+
const app = alxia().group('/auth', (auth) =>
|
|
159
|
+
auth
|
|
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 }) => {
|
|
162
|
+
if (passwords.get(body.name) !== body.password) {
|
|
163
|
+
return reply(401, { error: 'invalid_credentials' as const });
|
|
164
|
+
}
|
|
165
|
+
if (ip !== undefined) await attempts.reset(ip);
|
|
166
|
+
return reply(200, { name: body.name });
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`reset` forgets the key under **every policy ever counted under the
|
|
172
|
+
store's `name`**, by any store object and any process: each policy is
|
|
173
|
+
recorded in a Redis set, `<name>:policies`, the first time a process counts
|
|
174
|
+
under it. So a process that only resets — an admin endpoint, a worker —
|
|
175
|
+
needs no count of its own:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { redisStore } from '@alxia/redis';
|
|
179
|
+
import { connectRedis } from '@nxgt/redis';
|
|
180
|
+
|
|
181
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
182
|
+
|
|
183
|
+
// Another process: the login limit above is counted elsewhere.
|
|
184
|
+
export async function unblock(ip: string) {
|
|
185
|
+
await redisStore(connection.client, { name: 'login' }).reset(ip);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The policies set holds one short member per `limit`/`windowMs` pair and is
|
|
190
|
+
kept without an expiry. A name used for several policies over time — a
|
|
191
|
+
limit you tuned — keeps the old ones in it; `reset` then also deletes the
|
|
192
|
+
key under them, which costs a command each and nothing else.
|
|
193
|
+
|
|
194
|
+
## Next
|
|
195
|
+
|
|
196
|
+
- [Idempotency](idempotency.md) — and where to declare the rate limit
|
|
197
|
+
relative to it.
|
|
198
|
+
- [Testing](testing.md) — specs against a real Redis.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Response cache
|
|
2
|
+
|
|
3
|
+
This page covers `redisCacheStore`: an
|
|
4
|
+
[`@alxia/cache`](https://www.npmjs.com/package/@alxia/cache) store that
|
|
5
|
+
every process sharing a Redis serves from, and invalidates together.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { cache } from '@alxia/cache';
|
|
9
|
+
import { alxia } from '@alxia/core';
|
|
10
|
+
import { redisCacheStore } from '@alxia/redis';
|
|
11
|
+
import { connectRedis } from '@nxgt/redis';
|
|
12
|
+
|
|
13
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
14
|
+
|
|
15
|
+
const products = cache({ ttl: 60, store: redisCacheStore(connection.client, { name: 'shop' }), tags: () => ['products'] });
|
|
16
|
+
|
|
17
|
+
const app = alxia()
|
|
18
|
+
.use(products)
|
|
19
|
+
.get('/products', ({ reply }) => reply(200, [{ id: '1', name: 'Kettle' }]));
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`@alxia/cache` is an optional peer: install it beside this package to use
|
|
23
|
+
`redisCacheStore`.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bun add @alxia/cache
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## The signature
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
function redisCacheStore(client: RedisClient, options: RedisCacheStoreOptions): CacheStore;
|
|
33
|
+
|
|
34
|
+
interface RedisCacheStoreOptions {
|
|
35
|
+
/** Prepended to every key it writes: one name per app or deployment. */
|
|
36
|
+
readonly name: string;
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Option | Type | Default | Effect |
|
|
41
|
+
| --- | --- | --- | --- |
|
|
42
|
+
| `name` | `string` | required | the prefix of every key: `<name>:response:<key>` for a response, `<name>:tag:<tag>` for a tag |
|
|
43
|
+
|
|
44
|
+
Everything else — `ttl`, `staleWhileRevalidate`, `key`, `vary`, `statuses`,
|
|
45
|
+
`tags` — is `cache()`'s, and behaves as with the memory store: see
|
|
46
|
+
`@alxia/cache`'s guide.
|
|
47
|
+
|
|
48
|
+
## What it stores
|
|
49
|
+
|
|
50
|
+
- **A response** is one Redis string at `<name>:response:<key>`: an
|
|
51
|
+
`@nxgt/redis` cache record holding the status, the headers, the body as
|
|
52
|
+
base64, and the plugin's `storedAt`, `ttl` and `stale`. Redis expires it
|
|
53
|
+
when `ttl + staleWhileRevalidate` has passed, rounded up to the second.
|
|
54
|
+
- **A tag** is a Redis set at `<name>:tag:<tag>`, of the response keys it
|
|
55
|
+
names. Besides your tags, every response carries `alxia:path:<path>` —
|
|
56
|
+
the tag `invalidate(path)` deletes. It expires with the longest-kept response it names: each `set`
|
|
57
|
+
gives a new tag set its expiry, and only ever lengthens it after that.
|
|
58
|
+
On a Redis older than 7, which cannot compare expiries, the last
|
|
59
|
+
response written sets it.
|
|
60
|
+
- **Reading** checks the record against its schema: a record that no
|
|
61
|
+
longer reads as a response — written by another version, or by hand — is
|
|
62
|
+
a miss, and is deleted.
|
|
63
|
+
|
|
64
|
+
Measured with `cache({ ttl: 2, tags: () => ['products'] })` after one
|
|
65
|
+
`GET /products`:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
shop:response:/products string TTL 2
|
|
69
|
+
shop:tag:alxia:path:/products set TTL 2
|
|
70
|
+
shop:tag:products set TTL 2
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Invalidating across processes
|
|
74
|
+
|
|
75
|
+
`invalidate(path)` and `invalidateTag(tag)` delete from Redis, so every
|
|
76
|
+
process sharing it misses on its next request:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { cache } from '@alxia/cache';
|
|
80
|
+
import { alxia } from '@alxia/core';
|
|
81
|
+
import { redisCacheStore } from '@alxia/redis';
|
|
82
|
+
import { connectRedis } from '@nxgt/redis';
|
|
83
|
+
import { z } from 'zod';
|
|
84
|
+
|
|
85
|
+
const connection = await connectRedis(Bun.env['REDIS_URL']!);
|
|
86
|
+
const Product = z.object({ id: z.string(), name: z.string() });
|
|
87
|
+
const catalogue = new Map<string, z.infer<typeof Product>>();
|
|
88
|
+
|
|
89
|
+
const store = redisCacheStore(connection.client, { name: 'shop' });
|
|
90
|
+
const products = cache({ ttl: 60, staleWhileRevalidate: 300, store, tags: () => ['products'] });
|
|
91
|
+
|
|
92
|
+
const app = alxia()
|
|
93
|
+
.post('/products', { body: Product }, async ({ body, reply }) => {
|
|
94
|
+
catalogue.set(body.id, body);
|
|
95
|
+
await products.invalidateTag('products'); // forgotten in every process
|
|
96
|
+
return reply(201, body);
|
|
97
|
+
})
|
|
98
|
+
.use(products)
|
|
99
|
+
.get('/products', ({ reply }) => reply(200, [...catalogue.values()]));
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`invalidateTag` reads the tag's set, deletes every key in it, then the set.
|
|
103
|
+
`invalidate(path)` does the same with the path's tag, so it forgets every
|
|
104
|
+
response kept for that path — each `vary` value, a `key` of your own:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
await products.invalidate('/products'); // every language, every key, in every process
|
|
108
|
+
await products.invalidate('/products?page=2'); // another path: its query is part of it
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Two `cache()` given stores with the same `name` share responses and tags;
|
|
112
|
+
give each app or deployment its own `name`.
|
|
113
|
+
|
|
114
|
+
## What changes with Redis
|
|
115
|
+
|
|
116
|
+
- **Concurrent misses** run the route once per process, not once overall:
|
|
117
|
+
`@alxia/cache` coalesces them in memory.
|
|
118
|
+
- **A Redis that does not answer costs the cache, not the response.** A
|
|
119
|
+
read that fails is a miss, so the route runs; a write that fails keeps
|
|
120
|
+
nothing; the first error of an outage is logged with `console.error`. `invalidate` and
|
|
121
|
+
`invalidateTag` do reject, so a write that empties the cache learns that
|
|
122
|
+
it could not — see `@alxia/cache`'s
|
|
123
|
+
[Stores](https://github.com/softistx/alxia/blob/develop/packages/cache/docs/guide/stores.md#when-the-store-cannot-answer).
|
|
124
|
+
|
|
125
|
+
## Next
|
|
126
|
+
|
|
127
|
+
- [Caches and locks](caches-and-locks.md) — `redis()` adds typed caches to
|
|
128
|
+
the context, as `caches`, beside this one's `cache`.
|
|
129
|
+
- [Testing](testing.md).
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
This page covers specs for routes that use `@alxia/redis`: against a real
|
|
4
|
+
Redis, emptied between tests, with the app built once the client is
|
|
5
|
+
connected.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// payments.spec.ts
|
|
9
|
+
import { beforeAll, beforeEach, expect, test } from 'bun:test';
|
|
10
|
+
import { alxia } from '@alxia/core';
|
|
11
|
+
import { idempotency } from '@alxia/redis';
|
|
12
|
+
import { connectRedis, type RedisConnection } from '@nxgt/redis';
|
|
13
|
+
|
|
14
|
+
let connection: RedisConnection;
|
|
15
|
+
let app: ReturnType<typeof makeApp>;
|
|
16
|
+
|
|
17
|
+
const makeApp = (client: RedisConnection['client']) =>
|
|
18
|
+
alxia({ ip: () => '1.2.3.4' })
|
|
19
|
+
.use(idempotency(client, { name: 'payments' }))
|
|
20
|
+
.post('/payments', ({ reply }) => reply(201, { id: crypto.randomUUID() }));
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
|
|
24
|
+
app = makeApp(connection.client);
|
|
25
|
+
});
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
await connection.client.send('FLUSHDB', []);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a repeat replays the first response', async () => {
|
|
31
|
+
const pay = () => app.request('/payments', { method: 'POST', headers: { 'idempotency-key': 'k-1' } });
|
|
32
|
+
const first = await pay();
|
|
33
|
+
const again = await pay();
|
|
34
|
+
expect(await again.json()).toEqual(await first.json());
|
|
35
|
+
expect(again.headers.get('idempotent-replayed')).toBe('true');
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## A Redis to test against
|
|
40
|
+
|
|
41
|
+
There is no in-memory fake: the stores and the guard run Lua scripts and
|
|
42
|
+
read the Redis server's clock, which only a Redis answers. Point
|
|
43
|
+
`REDIS_URL` at a disposable one:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
docker run --rm -d -p 6379:6379 redis:8-alpine
|
|
47
|
+
REDIS_URL=redis://127.0.0.1:6379 bun test
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
In CI, a Redis service container and `REDIS_URL` do the same. Use a
|
|
51
|
+
database nothing else uses: `FLUSHDB` empties all of it.
|
|
52
|
+
|
|
53
|
+
## Build the app after connecting
|
|
54
|
+
|
|
55
|
+
Each export binds the client it is given when it is called. An app built
|
|
56
|
+
at the top of the file, while the variable that `beforeAll` fills is still
|
|
57
|
+
unset, binds `undefined`, and its first request fails. Build it in
|
|
58
|
+
`beforeAll`, as above, or connect with a top-level `await` before building
|
|
59
|
+
it, as below.
|
|
60
|
+
|
|
61
|
+
## Give each test a client address
|
|
62
|
+
|
|
63
|
+
`app.request()` has no socket, so `ctx.ip` is `undefined`. A rate limit
|
|
64
|
+
then counts nothing, and idempotency scopes every key to `anyone`. Pass the
|
|
65
|
+
app an `ip` that answers, as above, or one per test to stand for two
|
|
66
|
+
clients:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { expect, test } from 'bun:test';
|
|
70
|
+
import { alxia } from '@alxia/core';
|
|
71
|
+
import { rateLimit } from '@alxia/rate-limit';
|
|
72
|
+
import { redisStore } from '@alxia/redis';
|
|
73
|
+
import { connectRedis } from '@nxgt/redis';
|
|
74
|
+
|
|
75
|
+
const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
|
|
76
|
+
|
|
77
|
+
test('two processes share one count', async () => {
|
|
78
|
+
await connection.client.send('FLUSHDB', []);
|
|
79
|
+
const make = () =>
|
|
80
|
+
alxia({ ip: () => '1.2.3.4' })
|
|
81
|
+
.use(rateLimit({ limit: 2, windowMs: 60_000, store: redisStore(connection.client, { name: 'api' }) }))
|
|
82
|
+
.get('/', ({ reply }) => reply(200, 'ok'));
|
|
83
|
+
const [one, two] = [make(), make()]; // two apps stand for two processes
|
|
84
|
+
expect((await one.request('/')).status).toBe(200);
|
|
85
|
+
expect((await two.request('/')).status).toBe(200);
|
|
86
|
+
expect((await one.request('/')).status).toBe(429);
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Typed refusals
|
|
91
|
+
|
|
92
|
+
The `409`, `422` and `400` of `idempotency` are part of each guarded route's
|
|
93
|
+
type. `@alxia/client` and `expectTypeOf` pin them, with no Redis call:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { expect, expectTypeOf, test } from 'bun:test';
|
|
97
|
+
import { client } from '@alxia/client';
|
|
98
|
+
import { alxia } from '@alxia/core';
|
|
99
|
+
import { idempotency } from '@alxia/redis';
|
|
100
|
+
import { connectRedis } from '@nxgt/redis';
|
|
101
|
+
|
|
102
|
+
const connection = await connectRedis(Bun.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379');
|
|
103
|
+
|
|
104
|
+
const app = alxia()
|
|
105
|
+
.post('/open', ({ reply }) => reply(201, 'ok'))
|
|
106
|
+
.use(idempotency(connection.client, { name: 'payments' }))
|
|
107
|
+
.post('/payments', ({ reply }) => reply(201, 'ok'));
|
|
108
|
+
|
|
109
|
+
test('only the routes after the plugin carry its refusals', () => {
|
|
110
|
+
const types = async () => {
|
|
111
|
+
const api = client(app);
|
|
112
|
+
expectTypeOf((await api.post('/payments')).status).toEqualTypeOf<201 | 400 | 409 | 422 | 500>();
|
|
113
|
+
expectTypeOf((await api.post('/open')).status).toEqualTypeOf<201 | 500>();
|
|
114
|
+
};
|
|
115
|
+
expect(types).toBeFunction();
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Close at the end
|
|
120
|
+
|
|
121
|
+
Close the connections once the file is done:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { afterAll } from 'bun:test';
|
|
125
|
+
import { closeRedis } from '@nxgt/redis';
|
|
126
|
+
|
|
127
|
+
afterAll(async () => {
|
|
128
|
+
await closeRedis();
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Next
|
|
133
|
+
|
|
134
|
+
- [Connecting](connecting.md).
|
|
135
|
+
- [Idempotency](idempotency.md) — what each request gets.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
What `@alxia/redis` gives an app, and what is coming. This page is a
|
|
4
|
+
direction, not a commitment: the version something shipped in is the only
|
|
5
|
+
number on it. Every release, with each change it made, is in
|
|
6
|
+
[`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/redis/CHANGELOG.md).
|
|
7
|
+
|
|
8
|
+
## Now
|
|
9
|
+
|
|
10
|
+
Nothing scheduled yet.
|
|
11
|
+
|
|
12
|
+
## Next
|
|
13
|
+
|
|
14
|
+
Nothing scheduled yet.
|
|
15
|
+
|
|
16
|
+
## Later
|
|
17
|
+
|
|
18
|
+
Nothing scheduled yet.
|
|
19
|
+
|
|
20
|
+
## Not planned
|
|
21
|
+
|
|
22
|
+
- **A second Redis implementation.** `@alxia/redis` is an adapter over
|
|
23
|
+
[`@nxgt/redis`](https://www.npmjs.com/package/@nxgt/redis) and
|
|
24
|
+
[`@nxgt/redis-guard`](https://www.npmjs.com/package/@nxgt/redis-guard),
|
|
25
|
+
never a rewrite of them: their scripts, keys and errors are what it runs.
|
|
26
|
+
They run on Bun's built-in `RedisClient`, so there is no driver to
|
|
27
|
+
install, and it does not run on Node.
|
|
28
|
+
|
|
29
|
+
## Shipped
|
|
30
|
+
|
|
31
|
+
### 0.1.0
|
|
32
|
+
|
|
33
|
+
- **A rate limit every process shares.** `redisStore` is an
|
|
34
|
+
`@alxia/rate-limit` store with GCRA in one atomic script, timed by the
|
|
35
|
+
Redis server's clock; a refused request counts nothing, and the 429
|
|
36
|
+
stays typed.
|
|
37
|
+
- **A response cache every process shares.** `redisCacheStore` is an
|
|
38
|
+
`@alxia/cache` store on `@nxgt/redis`'s typed caches; a record that no
|
|
39
|
+
longer reads as a response is a miss, and `invalidateTag` reaches every
|
|
40
|
+
process.
|
|
41
|
+
- **Idempotent routes.** `idempotency` runs a `POST` or `PATCH` once per
|
|
42
|
+
`Idempotency-Key`, replays its response with `Idempotent-Replayed: true`
|
|
43
|
+
from any process, and answers a typed `409`, `422` or `400` for the
|
|
44
|
+
repeats it cannot; a `5xx` or a stream is not kept.
|
|
45
|
+
- **Redis in the context.** `redis` gives routes the client, typed caches
|
|
46
|
+
bound once, and a lock every process respects.
|