@usehenri/redis 0.0.0 → 1.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/CHANGELOG.md ADDED
@@ -0,0 +1,88 @@
1
+ # @usehenri/redis
2
+
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#419](https://github.com/usehenri/henri/pull/419) [`43a0e1a`](https://github.com/usehenri/henri/commit/43a0e1a6a320baa43298391e1c1e0334d6cd28d5) Thanks [@reel](https://github.com/reel)! - `henri.flags`: feature flags declared in one file, on for everyone, for a named set, for a stable share of them, or for a group the application writes in code.
8
+
9
+ `config/flags.js` is the declaration — `checkout: false` is a whole flag, and the long form adds `description`, `expose` and `group`. `await req.flag('checkout')` and `await henri.flags.enabled('checkout', user)` are the read; `henri flags`, `flags:on`, `flags:off`, `flags:percentage` and `flags:reset` are what an operator has. `flipper` is what this learns from: the gates and the `enable`/`disable` vocabulary are kept, `percentage_of_time` is not (a feature that flickers within one page load is a bug henri would have caused), and neither are the expression gates, the web UI or the metrics.
10
+
11
+ **A name nothing declares is a failure, not a `false`** — the position `req.permit()`, `params`, `answers` and `filters` already take. A typo that answers `false` forever is a feature that silently never ships; a typo that throws is a stack trace in development, in the suite, and on the first request that reaches it. Both messages name the closest declared flag, and the cost is stated out loud in the guide: removing a flag is two deploys.
12
+
13
+ **The percentage is stable and it is hashed carefully.** The bucket is `sha256(flag + actor)`, computed and never stored, so the same person keeps the same answer across processes and restarts and a rollout only ever adds people. The flag name is in the hash so two features at ten percent are not on for the same ten percent, and the identifier — the `externalId`, never the primary key — is hashed **whole**: a uuid v7 is time-ordered, so bucketing on any prefix of one would roll a feature out by signup date rather than at random.
14
+
15
+ **The state is where every process can read it.** `config.shared` when there is a backend, `.henri/flags.json` otherwise, this process's memory under `NODE_ENV=test`, and the boot line says which and says its limit. Reads come from an in-memory snapshot re-read on a timer (`flags.refresh`, ten seconds, which is the whole staleness window), not through `henri.cache` — a cache's answer to a backend that is down is a miss, and a miss here would revert every flag to its default mid-incident. **A store that cannot be read flips nothing**: the snapshot stands and it is reported at most once a minute.
16
+
17
+ `henri flags` boots to runlevel 2 and no further, so a kill switch can be flipped while the database is unreachable. There is **no HTTP surface** in any environment: an application that wants a page writes the controller and puts a policy on it.
18
+
19
+ New configuration key `flags` (`store`, `refresh`, `enabled`), four `HENRI_FLAGS_*` codes, and `@usehenri/redis` gains a key-value write with no expiry, for the state that must outlive a restart. See the new [Feature flags](https://usehenri.io/guides/feature-flags/) guide.
20
+
21
+ - [#373](https://github.com/usehenri/henri/pull/373) [`2c8a826`](https://github.com/usehenri/henri/commit/2c8a8265262dbf6ea5c3e73e8e7892a230d4d0f0) Thanks [@reel](https://github.com/reel)! - `config.shared`: one backend for the counters that only worked with one process
22
+
23
+ The rate limit, the sign-in lockout and the idempotency keys each keep a
24
+ number per key, and all three were kept in the process's memory unless the
25
+ application named a store in three separate configuration keys. Two processes
26
+ therefore meant two sets of counters: a rate limit that is twice what it says,
27
+ a lockout an attacker escapes by being routed elsewhere, and an idempotency
28
+ key that stops being idempotent.
29
+
30
+ `config.shared` is the one place to say where they live instead:
31
+
32
+ ```json
33
+ {
34
+ "shared": {
35
+ "adapter": "redis",
36
+ "url": "redis://127.0.0.1:6379",
37
+ "prefix": "lineup:",
38
+ "onError": "closed"
39
+ }
40
+ }
41
+ ```
42
+
43
+ `@usehenri/redis` is the backend, a package an application installs
44
+ (`pnpm add @usehenri/redis`), resolved from the application the way a store
45
+ adapter is; nothing is added to an application that does not name it. It talks
46
+ to Redis through node-redis and counts the rate limits with `rate-limit-redis`.
47
+ `rateLimit.store`, `user.lockout.store` and `api.idempotency.store` keep
48
+ working and still win, key by key.
49
+
50
+ When the backend does not answer, `shared.onError` decides: `closed` (the
51
+ default) refuses the request with a `503` and a `Retry-After`, `open` serves it
52
+ uncounted; either is logged, at most once every ten seconds per counter. The
53
+ idempotency keys are always closed, whatever `onError` says. A backend that is
54
+ unreachable at boot does not fail the boot: the client keeps reconnecting and
55
+ `GET /readyz` reports it (`"shared": { "ok": false }`), so the process leaves
56
+ the load balancer instead of the fleet.
57
+
58
+ The boot says which it is on every application -- `counted in redis (fail
59
+ closed)` or `counted in this process` -- and warns outright when the
60
+ environment says this process is one of several (a cluster worker, a numbered
61
+ pm2 instance, `WEB_CONCURRENCY`, a Heroku dyno past the first) and no shared
62
+ backend is configured. `henri doctor` reports a shared store that does not
63
+ answer (`shared.unreachable`) and asks for the adapter package when the
64
+ configuration names one; `--no-reach` skips the connection.
65
+
66
+ Sessions are not part of this: they already go through the database adapter,
67
+ which every process shares.
68
+
69
+ - [#374](https://github.com/usehenri/henri/pull/374) [`0b32fbd`](https://github.com/usehenri/henri/commit/0b32fbde19c95da8fe07fab76933840a4242c71c) Thanks [@reel](https://github.com/reel)! - `henri.cache`: a cache store with `fetch`, on this process's memory or on the backend `config.shared` already names.
70
+
71
+ `henri.cache.fetch(key, [options], fn)` answers from the cache or runs the function and keeps what it returned; `get`, `set`, `delete`, `clear` and `scope(name)` are the store underneath, and `stats()` says what it has been doing. Every entry has a TTL (`config.cache.ttl`, five minutes by default) — there is no way to keep a value forever, by accident or on purpose.
72
+
73
+ **The stampede.** A key that expires under load is missed by every request at once. `fetch` keeps one promise per key while the function runs and hands it to everyone who missed it, so a hundred concurrent misses of one key in one process run the function once. Across processes the bound is the number of processes, deliberately: a cross-process lock needs a lease, and a lease means guessing how long the function may take — guess short and it runs twice anyway, guess long and one crashed process blocks every reader of that key.
74
+
75
+ **Two backends, named once.** Without `config.shared` the cache is this process's memory, bounded twice (`maxEntries`, 1000, and `maxSize`, 32mb) and evicting the least recently used, so it cannot become a leak. With `config.shared` — the block that already says where the rate limit, the sign-in lockout and the idempotency keys are counted — the cache is on that backend, in a key space of its own, with nothing else to configure. `config.cache.store` still names a module of its own for whoever wants the cache somewhere the counters are not.
76
+
77
+ **What a value may be.** JSON, plus `Date` (which comes back a `Date`). A model instance, any other class instance, `undefined`, `NaN`, `Infinity`, a `Map`, a `Set`, a `Buffer`, a `RegExp`, a function, a symbol, a bigint or anything circular is refused with `HENRI_CACHE_VALUE_UNSUPPORTED`, naming where it sat and what it was but never what it held — rather than stored to come back wrong. A value bigger than `maxEntrySize` (256kb) is not stored at all: `set` answers `false` and says so once, and nothing is ever truncated.
78
+
79
+ **A backend that is down is a miss**, whatever `config.shared.onError` says. The counters block because a guard that cannot count is not a guard; a cache holds no truth, so refusing a request over a copy would turn an optimization into an outage. Every fallthrough is logged at most once every ten seconds, like the counters'. Keys reaching a log line are masked by `config.filterParameters`, and values never reach one.
80
+
81
+ **henri invalidates nothing for you**: no model callback, no query cache, no route. `delete` is yours to call, and with the memory backend it reaches one process — which is the reason a deployment running several of them wants `config.shared`.
82
+
83
+ New configuration key `cache` (`ttl`, `maxEntries`, `maxSize`, `maxEntrySize`, `store`, `enabled`; `false` turns the cache off). `@usehenri/redis` gains a raw mode on its key-value store, so the cache's already-encoded entry is not wrapped in JSON a second time, and a `clear(prefix)` that walks its own key space with `SCAN` and `UNLINK` — never `FLUSHDB`. See the new [Caching](https://usehenri.io/guides/caching/) guide.
84
+
85
+ ### Patch Changes
86
+
87
+ - Updated dependencies [[`792a15a`](https://github.com/usehenri/henri/commit/792a15ade614cf8b920d9197586f9866700d458e), [`1e23664`](https://github.com/usehenri/henri/commit/1e23664829bd1a356de28f404cfb21c9ae211388), [`5b627ad`](https://github.com/usehenri/henri/commit/5b627adfa37e9f16bc75af96cc8ff5308a91f688), [`4dff51e`](https://github.com/usehenri/henri/commit/4dff51edc050e29398c793a5aedb48776b7b7119), [`60dbf33`](https://github.com/usehenri/henri/commit/60dbf33c2a4f14e328a0df1cb44be061e15431e5), [`1b316c6`](https://github.com/usehenri/henri/commit/1b316c6f3c5d5eb5752c70b534092d1052956cc6), [`d074e8b`](https://github.com/usehenri/henri/commit/d074e8b482582e25f80d8a14b4735e69a2b7821e), [`7fd13f6`](https://github.com/usehenri/henri/commit/7fd13f631b75f7aa152b73046b50c6902ae3ca93), [`b559fb7`](https://github.com/usehenri/henri/commit/b559fb72b391eeb21a3f6a0cda1515e01ecbfafc), [`1c0dfe8`](https://github.com/usehenri/henri/commit/1c0dfe84a98eff2122512256c4f42ec7ccde4212), [`93060a8`](https://github.com/usehenri/henri/commit/93060a86df795dbbd99bf1895beb0cf14c4b86de), [`e031900`](https://github.com/usehenri/henri/commit/e031900082f28aec72af4cda9cd959f932e2ebc7), [`9173000`](https://github.com/usehenri/henri/commit/91730005efa88f073bfaaf67078c3ec0e137b459), [`62fac46`](https://github.com/usehenri/henri/commit/62fac46fd6cae5581979b73daf99700fd246e0ea), [`b7f33e2`](https://github.com/usehenri/henri/commit/b7f33e28a5e4844391befd75d08e42c4cf6212ed), [`3d6f3fc`](https://github.com/usehenri/henri/commit/3d6f3fc048d05db41be86069608d342e437408cb), [`b278119`](https://github.com/usehenri/henri/commit/b2781190de436eb5838e866446c9a0c8210bb6ca), [`b161e1b`](https://github.com/usehenri/henri/commit/b161e1b8fad94af2d2afc351dc1bc07dabbb1379), [`7cb0b04`](https://github.com/usehenri/henri/commit/7cb0b04b29b61dedaa82fcd1972646fb3765acfc), [`1616e34`](https://github.com/usehenri/henri/commit/1616e343a612be2bffffcfa5b23bfa8ad191bbe3), [`c8f5367`](https://github.com/usehenri/henri/commit/c8f53678b33341d086b467f801e959314afc7860), [`bcf4ce2`](https://github.com/usehenri/henri/commit/bcf4ce22bcd294844504164fac1fa4aef1ffec41), [`ab5a8e4`](https://github.com/usehenri/henri/commit/ab5a8e4a88c80cca070a2b8ad398c80babdaff11), [`43d267f`](https://github.com/usehenri/henri/commit/43d267f0f9d192b2c01e89c3925b7daf5000041b), [`9f868f3`](https://github.com/usehenri/henri/commit/9f868f3d9162fa218e34304110210e6949f97d5c), [`d88bf7f`](https://github.com/usehenri/henri/commit/d88bf7fe038a6b58e7bed02ff4c90755f6c0e65e), [`49398a6`](https://github.com/usehenri/henri/commit/49398a6308f0760f01c6ff2ec98aaa35f484474d), [`89dda62`](https://github.com/usehenri/henri/commit/89dda62da456a0a55600e79cfb65ce89f11258e2), [`62fac46`](https://github.com/usehenri/henri/commit/62fac46fd6cae5581979b73daf99700fd246e0ea), [`a93d6cc`](https://github.com/usehenri/henri/commit/a93d6cc39b33b261089e91f3e757b54fefc9fe15), [`d9f3be4`](https://github.com/usehenri/henri/commit/d9f3be49c5929d929a220220bf6e72fdcb135595), [`c44f025`](https://github.com/usehenri/henri/commit/c44f025acec3d5bbbb57e2310d02184a1053a10d), [`67cfb20`](https://github.com/usehenri/henri/commit/67cfb200ea0e0b31bacf2af183db6467b0fa011d), [`e661f98`](https://github.com/usehenri/henri/commit/e661f98fe8f8acce15aa10ce2dc320c5a2cb006f), [`43a0e1a`](https://github.com/usehenri/henri/commit/43a0e1a6a320baa43298391e1c1e0334d6cd28d5), [`cee57b9`](https://github.com/usehenri/henri/commit/cee57b9d3521a4a70c715222eae1f18ff4a6c128), [`61bf75c`](https://github.com/usehenri/henri/commit/61bf75cbaccda1aecff34408a175b2d85447d7a8), [`2c8a826`](https://github.com/usehenri/henri/commit/2c8a8265262dbf6ea5c3e73e8e7892a230d4d0f0), [`46d5dbc`](https://github.com/usehenri/henri/commit/46d5dbcc983c03e96ae5a87d7288c5d8a5adbc24), [`ba97ea9`](https://github.com/usehenri/henri/commit/ba97ea968f0b34cd67b7a3e803ecd34543b8aaaf), [`5ccd537`](https://github.com/usehenri/henri/commit/5ccd537b3621b54d11e7f24ccca39643ae7d5cf7), [`d88bf7f`](https://github.com/usehenri/henri/commit/d88bf7fe038a6b58e7bed02ff4c90755f6c0e65e), [`9895cbf`](https://github.com/usehenri/henri/commit/9895cbf4be85b476e341a5be915e3049e5a027de), [`61bf75c`](https://github.com/usehenri/henri/commit/61bf75cbaccda1aecff34408a175b2d85447d7a8), [`5a150d5`](https://github.com/usehenri/henri/commit/5a150d576208571c32b9cd12827d035e31ed4313), [`3c1c5b8`](https://github.com/usehenri/henri/commit/3c1c5b83ea135b000ddd6ffbfd05457b000f2f7c), [`2625067`](https://github.com/usehenri/henri/commit/26250673d91ab70ad024739d02b647754f75267d), [`aa3f90b`](https://github.com/usehenri/henri/commit/aa3f90bd6f42bb05431c33f3e4bf2202cb6bb7c6), [`a1c6769`](https://github.com/usehenri/henri/commit/a1c6769099e3dc28b22b5338a2a57b13bdf69f7a), [`0a8bb41`](https://github.com/usehenri/henri/commit/0a8bb415d352cd75b12d07e591c8ec7c16774a99), [`ec1c8c4`](https://github.com/usehenri/henri/commit/ec1c8c419f4d9063a7617472b2970fdb8a929fa1), [`dd2731d`](https://github.com/usehenri/henri/commit/dd2731d6a20fd96aa1be1aeb5e6ec0155001326b), [`ab52e18`](https://github.com/usehenri/henri/commit/ab52e187c420dfe381f03ed51c5c141fda525acb), [`b7b56e1`](https://github.com/usehenri/henri/commit/b7b56e190ae774abc0096fe2aebaf91f823115af), [`b7038ce`](https://github.com/usehenri/henri/commit/b7038ceaa430f4a0b9eaf7e983fc2844421bf636), [`1ea0f85`](https://github.com/usehenri/henri/commit/1ea0f85066b86fba31f58937cc10abb6359e6a26), [`762062a`](https://github.com/usehenri/henri/commit/762062aadc450d49b1a2d15524f9d579ab4f60e7), [`01a561a`](https://github.com/usehenri/henri/commit/01a561aa58650ec15df1c2659795a5e4c5bbfd53), [`bd1b630`](https://github.com/usehenri/henri/commit/bd1b63083b3817b8c47b8a187de76027458a1b32), [`27b5513`](https://github.com/usehenri/henri/commit/27b5513d1cae8aba734fe27da0ace2a82423e2f4), [`e31a3f7`](https://github.com/usehenri/henri/commit/e31a3f73e7e8facf3cedf7460f115e57995f32c3), [`2689779`](https://github.com/usehenri/henri/commit/26897798b840fd28a4bc091c050a83457b36905d), [`72cd1d3`](https://github.com/usehenri/henri/commit/72cd1d35ffb99311bbca815c1f6ab41ee3682f64), [`a2e1ec2`](https://github.com/usehenri/henri/commit/a2e1ec29df52462f12ebaae9bfbc1ad4f427b27f), [`afead74`](https://github.com/usehenri/henri/commit/afead7489498ed42e1893a25123ea772cac2ca09), [`a4ecba5`](https://github.com/usehenri/henri/commit/a4ecba50c663f4d5c741adbb6cd9bc0eefe0e5cc), [`baec3fd`](https://github.com/usehenri/henri/commit/baec3fd22be92bf8ffbaeb251b0b6c2771f8347a), [`1103628`](https://github.com/usehenri/henri/commit/110362808f8ec6d73a75ff7fc89a77f3e943d773), [`e865d94`](https://github.com/usehenri/henri/commit/e865d945d65419ac676f5a2fe3ba3b6114a1e53d), [`4274567`](https://github.com/usehenri/henri/commit/4274567e20a980657f07df9ec7db25296c7d55f5), [`aea429c`](https://github.com/usehenri/henri/commit/aea429ca99338a62370ab3e3d94bdc6b8c227601), [`8a8e3b3`](https://github.com/usehenri/henri/commit/8a8e3b33d7967b81f66633aa25c3075318f01d60), [`18715f9`](https://github.com/usehenri/henri/commit/18715f90ea8958dc57da1bb029b8209c36b84cc6), [`0d2ebc3`](https://github.com/usehenri/henri/commit/0d2ebc344bfcd80533ef900638083e4c105407bb), [`49398a6`](https://github.com/usehenri/henri/commit/49398a6308f0760f01c6ff2ec98aaa35f484474d), [`ec64e44`](https://github.com/usehenri/henri/commit/ec64e44e7b79a02da5fc587a72a9a6c900836982), [`831aa5c`](https://github.com/usehenri/henri/commit/831aa5c011f3432630c68b8d26755d2582f82f74), [`16824e8`](https://github.com/usehenri/henri/commit/16824e8fe9ccc6a04dab5d9b2481c29ff4f6b64b), [`fda9366`](https://github.com/usehenri/henri/commit/fda9366e9ed2b072764a995c5aa60205ca7a4725), [`1a86acb`](https://github.com/usehenri/henri/commit/1a86acbf15e4a43e5fb81277bb22e101c06e77a4), [`808d824`](https://github.com/usehenri/henri/commit/808d82471d59e64ccc735f617bab293eb572c46b), [`0b32fbd`](https://github.com/usehenri/henri/commit/0b32fbde19c95da8fe07fab76933840a4242c71c), [`c0c16e8`](https://github.com/usehenri/henri/commit/c0c16e873ba440aee9832160553cbb12ab81bd2c), [`41470bf`](https://github.com/usehenri/henri/commit/41470bf378d83ca3d35d00e8c31796fea5eb15e0), [`8e44e7e`](https://github.com/usehenri/henri/commit/8e44e7e882dd8741b3ac632651b453389d76bf2c), [`de1c1e0`](https://github.com/usehenri/henri/commit/de1c1e02ed83d13dcfeb8e44012f309eb663f03e), [`4b4677d`](https://github.com/usehenri/henri/commit/4b4677d4a09d39fe50b1fa4af577600342578daf)]:
88
+ - @usehenri/core@1.2.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016-present, Félix-Antoine Paradis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
+ <!-- generated by scripts/prepublish.js -->
2
+
1
3
  # @usehenri/redis
2
4
 
3
- Placeholder release. Install a real version: see https://usehenri.io.
5
+ Henri shared store: the rate limit, the sign-in lockout and the idempotency keys counted in Redis instead of one process.
6
+
7
+ Part of [henri](https://usehenri.io), the Rails-like React framework for
8
+ Node.js: [documentation](https://usehenri.io),
9
+ [source and issues](https://github.com/usehenri/henri),
10
+ [changelog](https://github.com/usehenri/henri/blob/master/packages/redis/CHANGELOG.md).
package/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@usehenri/redis`: the shared store `config.shared` names.
3
+ *
4
+ * ```json
5
+ * { "shared": { "adapter": "redis", "url": "redis://127.0.0.1:6379" } }
6
+ * ```
7
+ *
8
+ * Core resolves this package from the application (`utils.resolveFrom`) and
9
+ * constructs the default export with the normalized block, the way it loads
10
+ * a database adapter. See `packages/core/src/base/shared.js`.
11
+ */
12
+ module.exports = require('./src/backend');
package/package.json CHANGED
@@ -1,15 +1,50 @@
1
1
  {
2
- "description": "Placeholder creating @usehenri/redis on npm; the first real version is published by the henri release workflow",
3
- "homepage": "https://usehenri.io",
4
- "license": "MIT",
5
2
  "name": "@usehenri/redis",
6
- "publishConfig": {
7
- "access": "public"
8
- },
3
+ "version": "1.2.0",
4
+ "description": "henri shared store: the rate limit, the sign-in lockout and the idempotency keys counted in Redis instead of one process",
5
+ "license": "MIT",
6
+ "author": "Felix-Antoine Paradis",
7
+ "homepage": "https://usehenri.io",
9
8
  "repository": {
10
- "directory": "packages/redis",
11
9
  "type": "git",
12
- "url": "git+https://github.com/usehenri/henri.git"
10
+ "url": "git+https://github.com/usehenri/henri.git",
11
+ "directory": "packages/redis"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/usehenri/henri/issues"
15
+ },
16
+ "keywords": [
17
+ "henri",
18
+ "redis",
19
+ "rate-limit",
20
+ "idempotency",
21
+ "lockout",
22
+ "shared"
23
+ ],
24
+ "main": "index.js",
25
+ "files": [
26
+ "index.js",
27
+ "src",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "provenance": true
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "dependencies": {
38
+ "debug": "^4.4.3",
39
+ "rate-limit-redis": "^6.0.1",
40
+ "redis": "^6.2.1"
41
+ },
42
+ "peerDependencies": {
43
+ "@usehenri/core": "^1.2.0"
13
44
  },
14
- "version": "0.0.0"
15
- }
45
+ "devDependencies": {
46
+ "@usehenri/core": "^1.2.0",
47
+ "express": "^5.2.1",
48
+ "supertest": "^7.2.2"
49
+ }
50
+ }
package/src/backend.js ADDED
@@ -0,0 +1,335 @@
1
+ const { RedisStore } = require('rate-limit-redis');
2
+ const debug = require('debug')('henri:redis');
3
+ const { createClient } = require('redis');
4
+
5
+ const KeyValueStore = require('./kv');
6
+
7
+ /**
8
+ * The Redis backend of `config.shared`.
9
+ *
10
+ * henri's rate limit, sign-in lockout and idempotency keys are numbers per
11
+ * key, and all three were per process. `config.shared` names where they are
12
+ * counted instead; this is what that name resolves to:
13
+ *
14
+ * ```json
15
+ * {
16
+ * "shared": {
17
+ * "adapter": "redis",
18
+ * "url": "redis://127.0.0.1:6379",
19
+ * "prefix": "lineup:"
20
+ * }
21
+ * }
22
+ * ```
23
+ *
24
+ * Core loads it from the application with `utils.resolveFrom`, the way it
25
+ * loads a store adapter, so an application that does not name it never
26
+ * installs it. Nothing here is a henri module: there is no runlevel to sit
27
+ * at, because the server needs the counters at runlevel 2 and the sign-in
28
+ * lockout at 4.
29
+ *
30
+ * ## Why node-redis
31
+ *
32
+ * `redis` (node-redis), not `ioredis`. ioredis' own README now sends new
33
+ * projects to node-redis and calls its own maintenance best-effort, and
34
+ * node-redis is the client Redis develops. Two of its properties are the
35
+ * reason it fits here rather than merely being acceptable:
36
+ *
37
+ * - `connect()` is explicit, so a deployment learns at boot that the server
38
+ * is unreachable instead of at the first request that needed a counter;
39
+ * - `disableOfflineQueue` turns a command sent while disconnected into an
40
+ * immediate rejection rather than a queued promise. A fail-closed guard
41
+ * has to answer 503 in milliseconds; a queued command would hang the
42
+ * request until the reconnect, which is the worst of both answers.
43
+ *
44
+ * `rate-limit-redis` (the store express-rate-limit's own author maintains)
45
+ * takes `sendCommand`, so the limiter behaves exactly as it does in every
46
+ * other express-rate-limit deployment.
47
+ *
48
+ * ## Connecting
49
+ *
50
+ * The reconnect never gives up and never storms: the delay doubles from
51
+ * 100ms to ten seconds. `start()` waits `connectTimeout` for the first
52
+ * connection and then stops waiting, without failing -- the client keeps
53
+ * reconnecting, `GET /readyz` reports it, and requests meanwhile follow
54
+ * `config.shared.onError`.
55
+ */
56
+
57
+ /** How long `start()` waits for the first connection (ms) */
58
+ const CONNECT_TIMEOUT = 5000;
59
+
60
+ /** The longest the reconnect ever waits between attempts (ms) */
61
+ const MAX_BACKOFF = 10000;
62
+
63
+ /**
64
+ * How long to wait before the next connection attempt: 100ms doubling to
65
+ * ten seconds, so a server that is down for an hour costs a handful of
66
+ * connections a minute instead of a storm
67
+ *
68
+ * @param {number} retries how many attempts have failed
69
+ * @returns {number} the delay (ms)
70
+ */
71
+ const backoff = (retries) =>
72
+ Math.min(100 * 2 ** Math.min(Number(retries) || 0, 7), MAX_BACKOFF);
73
+
74
+ /**
75
+ * The url with its password replaced, so it can be printed
76
+ *
77
+ * @param {string} url a connection string
78
+ * @returns {string} the url, without the password
79
+ */
80
+ function redact(url) {
81
+ return String(url).replace(/^(\w+:\/\/[^:/@]*:)[^@/]*@/u, '$1[FILTERED]@');
82
+ }
83
+
84
+ /**
85
+ * A promise that rejects after a delay
86
+ *
87
+ * @param {Promise} promise the promise to bound
88
+ * @param {number} ms how long it may take
89
+ * @param {string} message what to say when it does not
90
+ * @returns {Promise} the promise, bounded
91
+ */
92
+ function withTimeout(promise, ms, message) {
93
+ let timer = null;
94
+
95
+ return Promise.race([
96
+ promise,
97
+ new Promise((resolve, reject) => {
98
+ timer = setTimeout(() => reject(new Error(message)), ms);
99
+ timer.unref();
100
+ }),
101
+ ]).finally(() => clearTimeout(timer));
102
+ }
103
+
104
+ /**
105
+ * The keys of `config.shared` henri owns; everything else is a node-redis
106
+ * option and reaches `createClient()`
107
+ */
108
+ const OWN = ['adapter', 'connectTimeout', 'enabled', 'onError', 'prefix'];
109
+
110
+ /**
111
+ * The Redis shared store
112
+ *
113
+ * @class RedisBackend
114
+ */
115
+ class RedisBackend {
116
+ /**
117
+ * Creates an instance of RedisBackend.
118
+ *
119
+ * @param {object} [settings={}] the normalized `config.shared`
120
+ * @param {?object} [henri=null] the henri instance, when there is one
121
+ * (`henri doctor` builds one without booting)
122
+ * @memberof RedisBackend
123
+ */
124
+ constructor(settings = {}, henri = null) {
125
+ const options = {};
126
+
127
+ for (const key of Object.keys(settings)) {
128
+ if (!OWN.includes(key)) {
129
+ options[key] = settings[key];
130
+ }
131
+ }
132
+
133
+ this.name = 'redis';
134
+ this.henri = henri;
135
+ this.prefix = settings.prefix || 'henri:';
136
+ this.connectTimeout =
137
+ Number(settings.connectTimeout) > 0
138
+ ? Number(settings.connectTimeout)
139
+ : CONNECT_TIMEOUT;
140
+ this.url = options.url || 'redis://127.0.0.1:6379';
141
+ this.options = Object.assign({ url: this.url }, options, {
142
+ // A command sent while the connection is down fails now instead of
143
+ // waiting for a reconnect that may be minutes away
144
+ disableOfflineQueue: true,
145
+ socket: Object.assign(
146
+ { connectTimeout: this.connectTimeout, reconnectStrategy: backoff },
147
+ options.socket
148
+ ),
149
+ });
150
+
151
+ this.client = null;
152
+ this.connecting = null;
153
+ this.lastError = null;
154
+ this.sendCommand = this.sendCommand.bind(this);
155
+ this.connected = this.connected.bind(this);
156
+ }
157
+
158
+ /**
159
+ * What this is talking to, with the password taken out
160
+ *
161
+ * @returns {string} the redacted url
162
+ * @memberof RedisBackend
163
+ */
164
+ describe() {
165
+ return redact(this.url);
166
+ }
167
+
168
+ /**
169
+ * Opens the connection, at most once at a time.
170
+ *
171
+ * It resolves when the client is ready and rejects when the first attempt
172
+ * has not landed within `connectTimeout` -- which does not stop the
173
+ * client: it keeps reconnecting on its own, and the next call finds it
174
+ * ready.
175
+ *
176
+ * @returns {Promise<object>} the client
177
+ * @throws when the first connection does not land in time
178
+ * @memberof RedisBackend
179
+ */
180
+ async start() {
181
+ if (this.client && this.client.isReady) {
182
+ return this.client;
183
+ }
184
+
185
+ if (!this.client) {
186
+ this.client = createClient(this.options);
187
+ // An unhandled `error` event throws, and there is one on every failed
188
+ // reconnect. The log is not the place for them: what a request does
189
+ // about it is core's business (`config.shared.onError`), and the boot
190
+ // line and `GET /readyz` already say whether the server is up.
191
+ this.client.on('error', (error) => {
192
+ this.lastError = error;
193
+ debug('client error: %s', error.message);
194
+ });
195
+ }
196
+
197
+ if (!this.connecting) {
198
+ this.connecting = Promise.resolve()
199
+ .then(() => (this.client.isOpen ? null : this.client.connect()))
200
+ .finally(() => {
201
+ this.connecting = null;
202
+ });
203
+ }
204
+
205
+ await withTimeout(
206
+ this.connecting,
207
+ this.connectTimeout,
208
+ `redis did not answer within ${this.connectTimeout}ms${
209
+ this.lastError ? ` (${this.lastError.message})` : ''
210
+ }`
211
+ );
212
+
213
+ return this.client;
214
+ }
215
+
216
+ /**
217
+ * The connected client, connecting when it is not.
218
+ *
219
+ * Every store call goes through this: a client that is up answers with no
220
+ * added work, and one that is not fails now rather than queueing.
221
+ *
222
+ * @returns {Promise<object>} the client
223
+ * @throws when it is not connected
224
+ * @memberof RedisBackend
225
+ */
226
+ async connected() {
227
+ if (this.client && this.client.isReady) {
228
+ return this.client;
229
+ }
230
+
231
+ if (this.client && this.client.isOpen) {
232
+ // Open but not ready: reconnecting. Fail now; the guard decides.
233
+ throw new Error(
234
+ `redis is not connected${this.lastError ? ` (${this.lastError.message})` : ''}`
235
+ );
236
+ }
237
+
238
+ return this.start();
239
+ }
240
+
241
+ /**
242
+ * Sends a raw command, which is all `rate-limit-redis` needs
243
+ *
244
+ * @param {...string} args the command and its arguments
245
+ * @returns {Promise<*>} what Redis answered
246
+ * @memberof RedisBackend
247
+ */
248
+ async sendCommand(...args) {
249
+ const client = await this.connected();
250
+
251
+ return client.sendCommand(args.map(String));
252
+ }
253
+
254
+ /**
255
+ * Whether the server answers
256
+ *
257
+ * @returns {Promise<boolean>} true when it did
258
+ * @throws when it did not
259
+ * @memberof RedisBackend
260
+ */
261
+ async ping() {
262
+ const client = await this.connected();
263
+
264
+ await withTimeout(
265
+ client.ping(),
266
+ this.connectTimeout,
267
+ `redis did not answer PING within ${this.connectTimeout}ms`
268
+ );
269
+
270
+ return true;
271
+ }
272
+
273
+ /**
274
+ * An express-rate-limit store, one key space per limiter
275
+ *
276
+ * @param {string} feature the limiter name (`global`, `auth`, `lockout`)
277
+ * @returns {object} the store
278
+ * @memberof RedisBackend
279
+ */
280
+ rateLimitStore(feature) {
281
+ return new RedisStore({
282
+ prefix: `${this.prefix}rl:${feature}:`,
283
+ sendCommand: this.sendCommand,
284
+ });
285
+ }
286
+
287
+ /**
288
+ * A `{ get, set, add, delete, clear }` store, one key space per feature
289
+ *
290
+ * @param {string} feature what the keys are for (`idempotency`, `cache`)
291
+ * @param {object} [options={}] `raw` stores the string it is handed as it
292
+ * is, for a caller that has serialized already (the cache)
293
+ * @returns {KeyValueStore} the store
294
+ * @memberof RedisBackend
295
+ */
296
+ keyValueStore(feature, options = {}) {
297
+ return new KeyValueStore({
298
+ client: this.connected,
299
+ prefix: `${this.prefix}kv:${feature}:`,
300
+ raw: options.raw === true,
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Closes the connection
306
+ *
307
+ * @returns {Promise<boolean>} done
308
+ * @memberof RedisBackend
309
+ */
310
+ async stop() {
311
+ const { client } = this;
312
+
313
+ this.client = null;
314
+ this.connecting = null;
315
+
316
+ if (!client) {
317
+ return true;
318
+ }
319
+
320
+ try {
321
+ await withTimeout(client.close(), 1000, 'redis did not close in time');
322
+ } catch (error) {
323
+ debug('closing: %s', error.message);
324
+ client.destroy();
325
+ }
326
+
327
+ return true;
328
+ }
329
+ }
330
+
331
+ module.exports = RedisBackend;
332
+ module.exports.CONNECT_TIMEOUT = CONNECT_TIMEOUT;
333
+ module.exports.MAX_BACKOFF = MAX_BACKOFF;
334
+ module.exports.backoff = backoff;
335
+ module.exports.redact = redact;
package/src/kv.js ADDED
@@ -0,0 +1,200 @@
1
+ const debug = require('debug')('henri:redis');
2
+
3
+ /** How many keys a `clear()` asks Redis for per round trip */
4
+ const SCAN_COUNT = 250;
5
+
6
+ /**
7
+ * The `{ get, set, add, delete }` store the idempotency keys use, on Redis.
8
+ *
9
+ * Values are JSON, the expiry is Redis' own (`PX`), and `add()` is one
10
+ * `SET ... NX PX` -- an atomic claim, which is what makes two processes
11
+ * racing on the same `Idempotency-Key` end with one execution and one 409
12
+ * rather than two executions.
13
+ *
14
+ * `raw` turns the JSON off, for a caller that has already serialized: the
15
+ * cache hands over the string it encoded and measured, and encoding it a
16
+ * second time would only escape every quote in it.
17
+ *
18
+ * Nothing here catches: a command that fails rejects, and `base/shared.js`
19
+ * in core decides what that means (for these keys, always a 503).
20
+ *
21
+ * @class KeyValueStore
22
+ */
23
+ class KeyValueStore {
24
+ /**
25
+ * Creates an instance of KeyValueStore.
26
+ *
27
+ * @param {object} options options
28
+ * @param {function(): Promise<object>} options.client resolves the connected client
29
+ * @param {string} options.prefix what every key is prefixed with
30
+ * @param {boolean} [options.raw=false] store the string as it arrives
31
+ * @memberof KeyValueStore
32
+ */
33
+ constructor({ client, prefix, raw = false }) {
34
+ this.client = client;
35
+ this.prefix = prefix;
36
+ this.raw = Boolean(raw);
37
+ }
38
+
39
+ /**
40
+ * The Redis key of an entry
41
+ *
42
+ * @param {string} key the key the caller used
43
+ * @returns {string} the prefixed key
44
+ * @memberof KeyValueStore
45
+ */
46
+ key(key) {
47
+ return `${this.prefix}${key}`;
48
+ }
49
+
50
+ /**
51
+ * Reads an entry
52
+ *
53
+ * @param {string} key the key
54
+ * @returns {Promise<*>} the value, or undefined when there is none
55
+ * @memberof KeyValueStore
56
+ */
57
+ async get(key) {
58
+ const client = await this.client();
59
+ const raw = await client.get(this.key(key));
60
+
61
+ if (raw === null || typeof raw === 'undefined') {
62
+ return undefined;
63
+ }
64
+
65
+ if (this.raw) {
66
+ return raw;
67
+ }
68
+
69
+ try {
70
+ return JSON.parse(raw);
71
+ } catch (error) {
72
+ // Something else wrote this key: forget it rather than fail every
73
+ // retry of a request that can never replay
74
+ debug('unreadable entry at %s: %s', key, error.message);
75
+ await client.del(this.key(key));
76
+
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * What is written down for a value: JSON, or the string itself in `raw`
83
+ *
84
+ * @param {*} value what the caller is storing
85
+ * @returns {string} what Redis is handed
86
+ * @memberof KeyValueStore
87
+ */
88
+ payload(value) {
89
+ return this.raw ? String(value) : JSON.stringify(value);
90
+ }
91
+
92
+ /**
93
+ * How long an entry lives, as Redis takes it, or nothing at all.
94
+ *
95
+ * A ttl is what every counter here has, but not everything on this
96
+ * backend is a counter: `henri.flags` keeps the state of a feature
97
+ * switch, and a switch that turned itself back on after a fortnight
98
+ * would be the worst failure that module could have. So a call that
99
+ * names no expiry gets none, rather than a very long one that is a bug
100
+ * with a date on it.
101
+ *
102
+ * @param {*} ttl how long it lives (ms), or nothing
103
+ * @returns {?object} the options for `SET`, or null for no expiry
104
+ * @memberof KeyValueStore
105
+ */
106
+ expiry(ttl) {
107
+ const found = Number(ttl);
108
+
109
+ return Number.isFinite(found) && found > 0
110
+ ? { PX: Math.max(1, Math.round(found)) }
111
+ : null;
112
+ }
113
+
114
+ /**
115
+ * Writes an entry, replacing whatever was there
116
+ *
117
+ * @param {string} key the key
118
+ * @param {*} value anything JSON-serializable (a string in `raw`)
119
+ * @param {number} [ttl] how long it lives (ms); forever without one
120
+ * @returns {Promise<void>} done
121
+ * @memberof KeyValueStore
122
+ */
123
+ async set(key, value, ttl) {
124
+ const client = await this.client();
125
+ const expiry = this.expiry(ttl);
126
+
127
+ await client.set(this.key(key), this.payload(value), expiry || undefined);
128
+ }
129
+
130
+ /**
131
+ * Writes an entry unless the key is taken. One round trip, atomic across
132
+ * every process talking to this server.
133
+ *
134
+ * @param {string} key the key
135
+ * @param {*} value anything JSON-serializable
136
+ * @param {number} [ttl] how long it lives (ms); forever without one
137
+ * @returns {Promise<boolean>} true when this call wrote it
138
+ * @memberof KeyValueStore
139
+ */
140
+ async add(key, value, ttl) {
141
+ const client = await this.client();
142
+ const answer = await client.set(this.key(key), this.payload(value), {
143
+ NX: true,
144
+ ...this.expiry(ttl),
145
+ });
146
+
147
+ return answer !== null;
148
+ }
149
+
150
+ /**
151
+ * Removes an entry
152
+ *
153
+ * @param {string} key the key
154
+ * @returns {Promise<void>} done
155
+ * @memberof KeyValueStore
156
+ */
157
+ async delete(key) {
158
+ const client = await this.client();
159
+
160
+ await client.del(this.key(key));
161
+ }
162
+
163
+ /**
164
+ * Removes every key of this store, or of a prefix inside it.
165
+ *
166
+ * `SCAN` and `UNLINK`, never `KEYS` and never `FLUSHDB`: the server may
167
+ * be holding somebody else's data and this only ever touches the store's
168
+ * own key space. It walks the whole keyspace to do it, so it belongs in a
169
+ * teardown or a console rather than in a request.
170
+ *
171
+ * @param {string} [prefix=''] a prefix inside the store (all of it when empty)
172
+ * @returns {Promise<number>} how many keys were removed
173
+ * @memberof KeyValueStore
174
+ */
175
+ async clear(prefix = '') {
176
+ const client = await this.client();
177
+ const match = `${this.key(prefix)}*`;
178
+ let cursor = '0';
179
+ let removed = 0;
180
+
181
+ do {
182
+ const page = await client.scan(cursor, {
183
+ COUNT: SCAN_COUNT,
184
+ MATCH: match,
185
+ });
186
+
187
+ cursor = String(page.cursor);
188
+
189
+ if (page.keys.length > 0) {
190
+ removed += await client.unlink(page.keys);
191
+ }
192
+ } while (cursor !== '0');
193
+
194
+ debug('cleared %d keys matching %s', removed, match);
195
+
196
+ return removed;
197
+ }
198
+ }
199
+
200
+ module.exports = KeyValueStore;