@luckystack/core 0.6.6 → 0.7.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +148 -107
  2. package/CLAUDE.md +245 -245
  3. package/LICENSE +21 -21
  4. package/README.md +93 -93
  5. package/dist/{chunk-WFABOO6J.js → chunk-AFG7KKMS.js} +3 -3
  6. package/dist/chunk-AFG7KKMS.js.map +1 -0
  7. package/dist/{chunk-3AWP2YEA.js → chunk-J3UOWYT4.js} +2 -28
  8. package/dist/chunk-J3UOWYT4.js.map +1 -0
  9. package/dist/{chunk-LU3IPFTR.js → chunk-LTCDQU5Z.js} +1 -1
  10. package/dist/chunk-LTCDQU5Z.js.map +1 -0
  11. package/dist/chunk-R7IRJFVC.js +67 -0
  12. package/dist/chunk-R7IRJFVC.js.map +1 -0
  13. package/dist/client.d.ts +3 -2
  14. package/dist/client.js +4 -4
  15. package/dist/client.js.map +1 -1
  16. package/dist/config-DZCzBNwP.d.ts +122 -0
  17. package/dist/config.d.ts +2 -0
  18. package/dist/config.js +31 -0
  19. package/dist/eslint/index.js.map +1 -1
  20. package/dist/index.d.ts +21 -149
  21. package/dist/index.js +66 -70
  22. package/dist/index.js.map +1 -1
  23. package/dist/pageRouteValidation-sJJCX-YV.d.ts +270 -0
  24. package/dist/{projectConfig-XL5VB76A.js → projectConfig-DBLSQ3NY.js} +2 -2
  25. package/dist/{sentrySetup-4NTWLN2A.js → sentrySetup-MF6JSVWA.js} +3 -3
  26. package/dist/sentrySetup-MF6JSVWA.js.map +1 -0
  27. package/dist/{pageRouteValidation-gqktrJci.d.ts → sessionTypes-Dzen_rPK.d.ts} +23 -265
  28. package/docs/app-bootstrap.md +418 -418
  29. package/docs/config-registry.md +348 -348
  30. package/docs/csrf-config.md +88 -88
  31. package/docs/error-tracker-registry.md +194 -174
  32. package/docs/hooks.md +298 -298
  33. package/docs/rate-limit-strategy.md +170 -170
  34. package/docs/redis-adapter.md +111 -111
  35. package/docs/session-types.md +180 -180
  36. package/docs/socket-bootstrap.md +366 -366
  37. package/package.json +6 -2
  38. package/dist/chunk-3AWP2YEA.js.map +0 -1
  39. package/dist/chunk-LU3IPFTR.js.map +0 -1
  40. package/dist/chunk-WFABOO6J.js.map +0 -1
  41. /package/dist/{projectConfig-XL5VB76A.js.map → config.js.map} +0 -0
  42. /package/dist/{sentrySetup-4NTWLN2A.js.map → projectConfig-DBLSQ3NY.js.map} +0 -0
@@ -1,170 +1,170 @@
1
- # Rate Limit Strategy
2
-
3
- > Deep specs for the pluggable rate-limit dispatcher and built-in memory/Redis strategy. Source: `packages/core/src/rateLimiter.ts`. Bijgewerkt: 2026-05-20.
4
-
5
- ## Overview
6
-
7
- Rate limiting in LuckyStack is a thin dispatcher backed by a swappable strategy. Framework code calls `checkRateLimit({ key, limit, windowMs })`; the active `RateLimitStrategy` decides how to count. The default strategy implements in-memory counters with optional Redis backing for multi-instance deployments and silently degrades to memory mode on Redis failure (with a one-shot warn log).
8
-
9
- Consumers can plug in their own backend (token-bucket, sliding-window, per-user tier limits, Cloudflare KV, no-op) by implementing the four `RateLimitStrategy` methods and calling `registerRateLimitStrategy(strategy)` at boot.
10
-
11
- The dispatcher also runs an internal cleanup timer that drops expired in-memory entries every `rateLimiting.cleanupIntervalMs`. The timer is `unref`'d so it never holds the process open.
12
-
13
- ## Types
14
-
15
- ```typescript
16
- export interface CheckRateLimitParams {
17
- key: string;
18
- limit: number;
19
- windowMs?: number; // default 60_000
20
- }
21
-
22
- export interface RateLimitResult {
23
- allowed: boolean;
24
- remaining: number;
25
- resetIn: number; // seconds until reset
26
- }
27
-
28
- export interface RateLimitStrategy {
29
- name: string;
30
- check(params: CheckRateLimitParams): Promise<RateLimitResult>;
31
- getStatus(key: string, limit: number): Promise<RateLimitResult>;
32
- clear(key: string): Promise<void>;
33
- clearAll(): Promise<void>;
34
- }
35
- ```
36
-
37
- ## API Reference — Dispatcher
38
-
39
- ### `checkRateLimit(params: CheckRateLimitParams): Promise<RateLimitResult>`
40
-
41
- **Behavior:** Delegates to `getRateLimitStrategy().check(params)`. Increments the counter as a side effect when allowed.
42
-
43
- **Example:**
44
- ```typescript
45
- import { checkRateLimit } from '@luckystack/core';
46
-
47
- const { allowed, remaining, resetIn } = await checkRateLimit({
48
- key: `user:${userId}:api:getData`,
49
- limit: 60,
50
- });
51
-
52
- if (!allowed) {
53
- return { status: 'error', errorCode: 'rateLimit.exceeded', httpStatus: 429 };
54
- }
55
- ```
56
-
57
- ### `getRateLimitStatus(key, limit): Promise<RateLimitResult>`
58
-
59
- **Behavior:** Read-only — does NOT increment. Useful for `X-RateLimit-Remaining` response headers.
60
-
61
- ### `clearRateLimit(key): Promise<void>`
62
-
63
- Resets a single key. Useful for admin overrides or post-test cleanup.
64
-
65
- ### `clearAllRateLimits(): Promise<void>`
66
-
67
- Wipes every key under the active strategy's namespace. Useful for tests / server restart.
68
-
69
- ### `registerRateLimitStrategy(strategy: RateLimitStrategy): void`
70
-
71
- **Behavior:**
72
- - Replaces the active strategy (last-write-wins).
73
- - Logs `[RateLimiter] active strategy → <name>` at debug level.
74
- - Common use cases: token-bucket, sliding-window, per-tier user limits, edge-KV, no-op (tests).
75
-
76
- ### `getRateLimitStrategy(): RateLimitStrategy`
77
-
78
- Returns the currently active strategy. Defaults to `defaultRateLimitStrategy`.
79
-
80
- ## API Reference — Built-in Default Strategy
81
-
82
- ### `defaultRateLimitStrategy: RateLimitStrategy`
83
-
84
- `name === 'default-memory-redis'`. Reads `projectConfig.rateLimiting.{enabled,store,redisKeyPrefix,windowMs}` at call time.
85
-
86
- #### `check(params)` flow
87
-
88
- 1. If `rateLimiting.enabled === false` → returns `{ allowed: true, remaining: limit, resetIn: 0 }` without touching counters.
89
- 2. If `rateLimiting.store === 'redis'`:
90
- - Runs a Lua `INCR` + `PEXPIRE` script atomically against `<projectName>:<redisKeyPrefix>:<key>`.
91
- - Returns `{ allowed: count <= limit, remaining: max(0, limit - count), resetIn: ceil(ttl / 1000) }` when the script returns a valid pair.
92
- - On any Redis error, logs a one-shot warn `[RateLimiter] Redis mode unavailable, falling back to in-memory mode` and falls through to the memory path.
93
- 3. Memory path:
94
- - First request in the window → create entry, return `allowed: true, remaining: limit - 1`.
95
- - Subsequent requests → increment, return `{ allowed: count <= limit, remaining: max(0, limit - count), resetIn: ceil((resetAt - now) / 1000) }`.
96
-
97
- #### `getStatus(key, limit)` flow
98
-
99
- Same dual-path shape but does NOT increment. The Redis path uses `MULTI` (`GET` + `PTTL`) to read current count + remaining TTL.
100
-
101
- #### `clear(key)` flow
102
-
103
- - Redis mode: `DEL <prefix>:<key>` (wrapped in `tryCatch`).
104
- - Always: deletes the in-memory entry as well.
105
-
106
- #### `clearAll()` flow
107
-
108
- - Redis mode: `SCAN` with `MATCH <prefix>:*`, `DEL` matched keys, repeat until cursor returns to `'0'`.
109
- - Always: clears the in-memory map.
110
-
111
- ### Lua script (atomic increment)
112
-
113
- ```lua
114
- local current = redis.call('INCR', KEYS[1])
115
- if current == 1 then
116
- redis.call('PEXPIRE', KEYS[1], ARGV[1])
117
- end
118
- local ttl = redis.call('PTTL', KEYS[1])
119
- return { current, ttl }
120
- ```
121
-
122
- ### Cleanup timer
123
-
124
- A self-rescheduling `setTimeout(unref)` deletes expired in-memory entries every `rateLimiting.cleanupIntervalMs`. Re-reads the config each tick so a late `registerProjectConfig` is picked up. Logs `[RateLimiter] Cleaned N expired entries` at debug level when any are removed.
125
-
126
- ## Edge cases
127
-
128
- - Negative or non-finite `windowMs` is normalized to `projectConfig.rateLimiting.windowMs`.
129
- - Concurrent strategy switches are allowed but the cleanup timer keeps running against the in-memory map regardless of the active strategy — fine because the map is only touched by the default strategy.
130
- - The default Redis script + scan path swallow errors via `tryCatch`; a network blip degrades to memory mode instead of throwing.
131
- - The disabled mode (`rateLimiting.enabled = false`) still calls `getStatus` happily — useful for showing meaningful headers in dev.
132
-
133
- ## Hooks dispatched
134
-
135
- Rate limiting itself does not dispatch hooks from core. The api/sync packages dispatch `rateLimitExceeded` when their handlers translate a `RateLimitResult` rejection into the response envelope.
136
-
137
- ## Config keys consumed
138
-
139
- | Key | Type | Default | Description |
140
- |---|---|---|---|
141
- | `rateLimiting.enabled` | `boolean` | `true` | Global kill-switch. |
142
- | `rateLimiting.store` | `'memory' \| 'redis'` | `'memory'` | Backend. |
143
- | `rateLimiting.redisKeyPrefix` | `string` | `'rate-limit'` | Prefixed with `<projectName>:`. |
144
- | `rateLimiting.defaultApiLimit` | `number \| false` | `60` | Default per-route cap (consumed by api package). |
145
- | `rateLimiting.defaultIpLimit` | `number \| false` | `100` | Default per-IP cap. |
146
- | `rateLimiting.windowMs` | `number` | `60_000` | Sliding window. |
147
- | `rateLimiting.cleanupIntervalMs` | `number` | `60_000` | Memory eviction cadence. |
148
-
149
- ## Example — register a no-op strategy for tests
150
-
151
- ```typescript
152
- import { registerRateLimitStrategy, type RateLimitStrategy } from '@luckystack/core';
153
-
154
- const noopStrategy: RateLimitStrategy = {
155
- name: 'noop',
156
- check: async ({ limit }) => ({ allowed: true, remaining: limit, resetIn: 0 }),
157
- getStatus: async (_key, limit) => ({ allowed: true, remaining: limit, resetIn: 0 }),
158
- clear: async () => undefined,
159
- clearAll: async () => undefined,
160
- };
161
-
162
- registerRateLimitStrategy(noopStrategy);
163
- ```
164
-
165
- ## Related
166
-
167
- - Function INDEX: `packages/core/CLAUDE.md`
168
- - Architecture: `docs/ARCHITECTURE_EXTENSION_POINTS.md`, `docs/ARCHITECTURE_API.md`
169
- - README: `packages/core/README.md`
170
- - Source: `packages/core/src/rateLimiter.ts`
1
+ # Rate Limit Strategy
2
+
3
+ > Deep specs for the pluggable rate-limit dispatcher and built-in memory/Redis strategy. Source: `packages/core/src/rateLimiter.ts`. Bijgewerkt: 2026-05-20.
4
+
5
+ ## Overview
6
+
7
+ Rate limiting in LuckyStack is a thin dispatcher backed by a swappable strategy. Framework code calls `checkRateLimit({ key, limit, windowMs })`; the active `RateLimitStrategy` decides how to count. The default strategy implements in-memory counters with optional Redis backing for multi-instance deployments and silently degrades to memory mode on Redis failure (with a one-shot warn log).
8
+
9
+ Consumers can plug in their own backend (token-bucket, sliding-window, per-user tier limits, Cloudflare KV, no-op) by implementing the four `RateLimitStrategy` methods and calling `registerRateLimitStrategy(strategy)` at boot.
10
+
11
+ The dispatcher also runs an internal cleanup timer that drops expired in-memory entries every `rateLimiting.cleanupIntervalMs`. The timer is `unref`'d so it never holds the process open.
12
+
13
+ ## Types
14
+
15
+ ```typescript
16
+ export interface CheckRateLimitParams {
17
+ key: string;
18
+ limit: number;
19
+ windowMs?: number; // default 60_000
20
+ }
21
+
22
+ export interface RateLimitResult {
23
+ allowed: boolean;
24
+ remaining: number;
25
+ resetIn: number; // seconds until reset
26
+ }
27
+
28
+ export interface RateLimitStrategy {
29
+ name: string;
30
+ check(params: CheckRateLimitParams): Promise<RateLimitResult>;
31
+ getStatus(key: string, limit: number): Promise<RateLimitResult>;
32
+ clear(key: string): Promise<void>;
33
+ clearAll(): Promise<void>;
34
+ }
35
+ ```
36
+
37
+ ## API Reference — Dispatcher
38
+
39
+ ### `checkRateLimit(params: CheckRateLimitParams): Promise<RateLimitResult>`
40
+
41
+ **Behavior:** Delegates to `getRateLimitStrategy().check(params)`. Increments the counter as a side effect when allowed.
42
+
43
+ **Example:**
44
+ ```typescript
45
+ import { checkRateLimit } from '@luckystack/core';
46
+
47
+ const { allowed, remaining, resetIn } = await checkRateLimit({
48
+ key: `user:${userId}:api:getData`,
49
+ limit: 60,
50
+ });
51
+
52
+ if (!allowed) {
53
+ return { status: 'error', errorCode: 'rateLimit.exceeded', httpStatus: 429 };
54
+ }
55
+ ```
56
+
57
+ ### `getRateLimitStatus(key, limit): Promise<RateLimitResult>`
58
+
59
+ **Behavior:** Read-only — does NOT increment. Useful for `X-RateLimit-Remaining` response headers.
60
+
61
+ ### `clearRateLimit(key): Promise<void>`
62
+
63
+ Resets a single key. Useful for admin overrides or post-test cleanup.
64
+
65
+ ### `clearAllRateLimits(): Promise<void>`
66
+
67
+ Wipes every key under the active strategy's namespace. Useful for tests / server restart.
68
+
69
+ ### `registerRateLimitStrategy(strategy: RateLimitStrategy): void`
70
+
71
+ **Behavior:**
72
+ - Replaces the active strategy (last-write-wins).
73
+ - Logs `[RateLimiter] active strategy → <name>` at debug level.
74
+ - Common use cases: token-bucket, sliding-window, per-tier user limits, edge-KV, no-op (tests).
75
+
76
+ ### `getRateLimitStrategy(): RateLimitStrategy`
77
+
78
+ Returns the currently active strategy. Defaults to `defaultRateLimitStrategy`.
79
+
80
+ ## API Reference — Built-in Default Strategy
81
+
82
+ ### `defaultRateLimitStrategy: RateLimitStrategy`
83
+
84
+ `name === 'default-memory-redis'`. Reads `projectConfig.rateLimiting.{enabled,store,redisKeyPrefix,windowMs}` at call time.
85
+
86
+ #### `check(params)` flow
87
+
88
+ 1. If `rateLimiting.enabled === false` → returns `{ allowed: true, remaining: limit, resetIn: 0 }` without touching counters.
89
+ 2. If `rateLimiting.store === 'redis'`:
90
+ - Runs a Lua `INCR` + `PEXPIRE` script atomically against `<projectName>:<redisKeyPrefix>:<key>`.
91
+ - Returns `{ allowed: count <= limit, remaining: max(0, limit - count), resetIn: ceil(ttl / 1000) }` when the script returns a valid pair.
92
+ - On any Redis error, logs a one-shot warn `[RateLimiter] Redis mode unavailable, falling back to in-memory mode` and falls through to the memory path.
93
+ 3. Memory path:
94
+ - First request in the window → create entry, return `allowed: true, remaining: limit - 1`.
95
+ - Subsequent requests → increment, return `{ allowed: count <= limit, remaining: max(0, limit - count), resetIn: ceil((resetAt - now) / 1000) }`.
96
+
97
+ #### `getStatus(key, limit)` flow
98
+
99
+ Same dual-path shape but does NOT increment. The Redis path uses `MULTI` (`GET` + `PTTL`) to read current count + remaining TTL.
100
+
101
+ #### `clear(key)` flow
102
+
103
+ - Redis mode: `DEL <prefix>:<key>` (wrapped in `tryCatch`).
104
+ - Always: deletes the in-memory entry as well.
105
+
106
+ #### `clearAll()` flow
107
+
108
+ - Redis mode: `SCAN` with `MATCH <prefix>:*`, `DEL` matched keys, repeat until cursor returns to `'0'`.
109
+ - Always: clears the in-memory map.
110
+
111
+ ### Lua script (atomic increment)
112
+
113
+ ```lua
114
+ local current = redis.call('INCR', KEYS[1])
115
+ if current == 1 then
116
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
117
+ end
118
+ local ttl = redis.call('PTTL', KEYS[1])
119
+ return { current, ttl }
120
+ ```
121
+
122
+ ### Cleanup timer
123
+
124
+ A self-rescheduling `setTimeout(unref)` deletes expired in-memory entries every `rateLimiting.cleanupIntervalMs`. Re-reads the config each tick so a late `registerProjectConfig` is picked up. Logs `[RateLimiter] Cleaned N expired entries` at debug level when any are removed.
125
+
126
+ ## Edge cases
127
+
128
+ - Negative or non-finite `windowMs` is normalized to `projectConfig.rateLimiting.windowMs`.
129
+ - Concurrent strategy switches are allowed but the cleanup timer keeps running against the in-memory map regardless of the active strategy — fine because the map is only touched by the default strategy.
130
+ - The default Redis script + scan path swallow errors via `tryCatch`; a network blip degrades to memory mode instead of throwing.
131
+ - The disabled mode (`rateLimiting.enabled = false`) still calls `getStatus` happily — useful for showing meaningful headers in dev.
132
+
133
+ ## Hooks dispatched
134
+
135
+ Rate limiting itself does not dispatch hooks from core. The api/sync packages dispatch `rateLimitExceeded` when their handlers translate a `RateLimitResult` rejection into the response envelope.
136
+
137
+ ## Config keys consumed
138
+
139
+ | Key | Type | Default | Description |
140
+ |---|---|---|---|
141
+ | `rateLimiting.enabled` | `boolean` | `true` | Global kill-switch. |
142
+ | `rateLimiting.store` | `'memory' \| 'redis'` | `'memory'` | Backend. |
143
+ | `rateLimiting.redisKeyPrefix` | `string` | `'rate-limit'` | Prefixed with `<projectName>:`. |
144
+ | `rateLimiting.defaultApiLimit` | `number \| false` | `60` | Default per-route cap (consumed by api package). |
145
+ | `rateLimiting.defaultIpLimit` | `number \| false` | `100` | Default per-IP cap. |
146
+ | `rateLimiting.windowMs` | `number` | `60_000` | Sliding window. |
147
+ | `rateLimiting.cleanupIntervalMs` | `number` | `60_000` | Memory eviction cadence. |
148
+
149
+ ## Example — register a no-op strategy for tests
150
+
151
+ ```typescript
152
+ import { registerRateLimitStrategy, type RateLimitStrategy } from '@luckystack/core';
153
+
154
+ const noopStrategy: RateLimitStrategy = {
155
+ name: 'noop',
156
+ check: async ({ limit }) => ({ allowed: true, remaining: limit, resetIn: 0 }),
157
+ getStatus: async (_key, limit) => ({ allowed: true, remaining: limit, resetIn: 0 }),
158
+ clear: async () => undefined,
159
+ clearAll: async () => undefined,
160
+ };
161
+
162
+ registerRateLimitStrategy(noopStrategy);
163
+ ```
164
+
165
+ ## Related
166
+
167
+ - Function INDEX: `packages/core/CLAUDE.md`
168
+ - Architecture: `docs/ARCHITECTURE_EXTENSION_POINTS.md`, `docs/ARCHITECTURE_API.md`
169
+ - README: `packages/core/README.md`
170
+ - Source: `packages/core/src/rateLimiter.ts`
@@ -1,111 +1,111 @@
1
- # Redis Adapter
2
-
3
- > Deep specs for the Redis client proxy, connection options helper, and the Socket.io cross-instance adapter. Source: `packages/core/src/redis.ts`, `socketRedisAdapter.ts`. Bijgewerkt: 2026-05-20.
4
-
5
- ## Overview
6
-
7
- Core ships a single Redis client that the entire framework shares for session storage, rate limiting, OAuth state, password-reset tokens, boot UUIDs, and the Socket.io pub/sub adapter. The `redis` export is a `Proxy` that defers resolution to call time so a consumer can `registerRedisClient(customClient)` after this module is imported and still win.
8
-
9
- `attachSocketRedisAdapter(io)` wires `@socket.io/redis-adapter` with two duplicated pub/sub connections so room broadcasts span multiple Node processes — without it, `io.to(room).emit(...)` only reaches clients connected to the same instance.
10
-
11
- `getRedisConnectionOptions()` exposes the same `{ host, port, username?, password? }` shape that the framework's default ioredis client uses. `@luckystack/router` reuses it for short-lived cross-env probes so a config rename never drifts between writer and reader.
12
-
13
- ## API Reference
14
-
15
- ### `redis: RedisClient` (proxy)
16
-
17
- **Signature:**
18
- ```typescript
19
- export const redis: RedisClient
20
- export default redis
21
- ```
22
-
23
- **Behavior:**
24
- - Every method/property access is forwarded to whatever `getRedisClient()` currently returns.
25
- - When the consumer has called `registerRedisClient(...)`, that client wins.
26
- - Otherwise the proxy lazily constructs a default ioredis instance from `env.REDIS_HOST` / `env.REDIS_PORT` (plus optional `REDIS_USER` / `REDIS_PASSWORD`) on first access.
27
- - The default client installs a `connect` listener that logs `"Connected to Redis"` via `getLogger().info` and an `error` listener that routes to `getLogger().error`.
28
- - The default client uses an exponential `retryStrategy(times => Math.min(times * 50, 2000))`.
29
-
30
- **Edge cases:**
31
- - Method results are auto-bound to the underlying client to keep `this` correct for chained calls (`redis.multi().get(...).exec()` works).
32
- - The `has` trap checks against the live client so `'eval' in redis` reports whatever the registered client supports.
33
-
34
- **Example:**
35
- ```typescript
36
- import { redis } from '@luckystack/core';
37
-
38
- await redis.set(`${prefix}:foo`, 'bar', 'EX', 60);
39
- const value = await redis.get(`${prefix}:foo`);
40
- ```
41
-
42
- ### `getRedisConnectionOptions(): RedisConnectionOptions`
43
-
44
- **Signature:**
45
- ```typescript
46
- export interface RedisConnectionOptions {
47
- host: string;
48
- port: number;
49
- username?: string;
50
- password?: string;
51
- }
52
-
53
- export const getRedisConnectionOptions = (): RedisConnectionOptions
54
- ```
55
-
56
- **Behavior:**
57
- - Reads `env.REDIS_HOST` and parses `env.REDIS_PORT` to a number.
58
- - Includes `username` only when `process.env.REDIS_USER` is non-empty.
59
- - Includes `password` only when `process.env.REDIS_PASSWORD` is non-empty.
60
- - Read at call time so dotenv timing doesn't capture stale values.
61
-
62
- **Example:**
63
- ```typescript
64
- import Redis from 'ioredis';
65
- import { getRedisConnectionOptions } from '@luckystack/core';
66
-
67
- // router-side: open a short-lived probe with the same params
68
- const probe = new Redis({ ...getRedisConnectionOptions(), lazyConnect: true });
69
- ```
70
-
71
- ### `attachSocketRedisAdapter(io: SocketIOServer): void`
72
-
73
- **Signature:**
74
- ```typescript
75
- export const attachSocketRedisAdapter = (io: SocketIOServer): void
76
- ```
77
-
78
- **Behavior (in order):**
79
- 1. Calls `redis.duplicate()` twice to obtain dedicated pub + sub clients (ioredis blocks non-pub/sub commands on a connection in subscribe mode, so duplicating is required).
80
- 2. Installs `error` listeners on both clients that log to `console.error` with the `[socket-redis-adapter]` prefix.
81
- 3. Calls `io.adapter(createAdapter(pubClient, subClient))` to wire `@socket.io/redis-adapter`.
82
-
83
- **Edge cases:**
84
- - Calling this after `io` has already accepted connections is allowed but races with in-flight broadcasts — wire it before `io.listen` / connection handlers in the boot sequence.
85
- - The duplicated clients inherit the active registered client (or the default).
86
-
87
- **Example:**
88
- ```typescript
89
- import { Server } from 'socket.io';
90
- import { attachSocketRedisAdapter, setIoInstance } from '@luckystack/core';
91
-
92
- const io = new Server(httpServer, { /* ... */ });
93
- attachSocketRedisAdapter(io);
94
- setIoInstance(io);
95
- ```
96
-
97
- ## Config keys consumed
98
-
99
- | Env var | Default | Purpose |
100
- |---|---|---|
101
- | `REDIS_HOST` | `'127.0.0.1'` | Default client + `getRedisConnectionOptions`. |
102
- | `REDIS_PORT` | `'6379'` | Same. |
103
- | `REDIS_USER` | (unset) | Optional ioredis auth. |
104
- | `REDIS_PASSWORD` | (unset) | Optional ioredis auth. |
105
-
106
- ## Related
107
-
108
- - Function INDEX: `packages/core/CLAUDE.md`
109
- - Architecture: `docs/ARCHITECTURE_SOCKET.md`, `docs/HOSTING.md`
110
- - README: `packages/core/README.md`
111
- - Source: `packages/core/src/redis.ts`, `socketRedisAdapter.ts`, `clients.ts`
1
+ # Redis Adapter
2
+
3
+ > Deep specs for the Redis client proxy, connection options helper, and the Socket.io cross-instance adapter. Source: `packages/core/src/redis.ts`, `socketRedisAdapter.ts`. Bijgewerkt: 2026-05-20.
4
+
5
+ ## Overview
6
+
7
+ Core ships a single Redis client that the entire framework shares for session storage, rate limiting, OAuth state, password-reset tokens, boot UUIDs, and the Socket.io pub/sub adapter. The `redis` export is a `Proxy` that defers resolution to call time so a consumer can `registerRedisClient(customClient)` after this module is imported and still win.
8
+
9
+ `attachSocketRedisAdapter(io)` wires `@socket.io/redis-adapter` with two duplicated pub/sub connections so room broadcasts span multiple Node processes — without it, `io.to(room).emit(...)` only reaches clients connected to the same instance.
10
+
11
+ `getRedisConnectionOptions()` exposes the same `{ host, port, username?, password? }` shape that the framework's default ioredis client uses. `@luckystack/router` reuses it for short-lived cross-env probes so a config rename never drifts between writer and reader.
12
+
13
+ ## API Reference
14
+
15
+ ### `redis: RedisClient` (proxy)
16
+
17
+ **Signature:**
18
+ ```typescript
19
+ export const redis: RedisClient
20
+ export default redis
21
+ ```
22
+
23
+ **Behavior:**
24
+ - Every method/property access is forwarded to whatever `getRedisClient()` currently returns.
25
+ - When the consumer has called `registerRedisClient(...)`, that client wins.
26
+ - Otherwise the proxy lazily constructs a default ioredis instance from `env.REDIS_HOST` / `env.REDIS_PORT` (plus optional `REDIS_USER` / `REDIS_PASSWORD`) on first access.
27
+ - The default client installs a `connect` listener that logs `"Connected to Redis"` via `getLogger().info` and an `error` listener that routes to `getLogger().error`.
28
+ - The default client uses an exponential `retryStrategy(times => Math.min(times * 50, 2000))`.
29
+
30
+ **Edge cases:**
31
+ - Method results are auto-bound to the underlying client to keep `this` correct for chained calls (`redis.multi().get(...).exec()` works).
32
+ - The `has` trap checks against the live client so `'eval' in redis` reports whatever the registered client supports.
33
+
34
+ **Example:**
35
+ ```typescript
36
+ import { redis } from '@luckystack/core';
37
+
38
+ await redis.set(`${prefix}:foo`, 'bar', 'EX', 60);
39
+ const value = await redis.get(`${prefix}:foo`);
40
+ ```
41
+
42
+ ### `getRedisConnectionOptions(): RedisConnectionOptions`
43
+
44
+ **Signature:**
45
+ ```typescript
46
+ export interface RedisConnectionOptions {
47
+ host: string;
48
+ port: number;
49
+ username?: string;
50
+ password?: string;
51
+ }
52
+
53
+ export const getRedisConnectionOptions = (): RedisConnectionOptions
54
+ ```
55
+
56
+ **Behavior:**
57
+ - Reads `env.REDIS_HOST` and parses `env.REDIS_PORT` to a number.
58
+ - Includes `username` only when `process.env.REDIS_USER` is non-empty.
59
+ - Includes `password` only when `process.env.REDIS_PASSWORD` is non-empty.
60
+ - Read at call time so dotenv timing doesn't capture stale values.
61
+
62
+ **Example:**
63
+ ```typescript
64
+ import Redis from 'ioredis';
65
+ import { getRedisConnectionOptions } from '@luckystack/core';
66
+
67
+ // router-side: open a short-lived probe with the same params
68
+ const probe = new Redis({ ...getRedisConnectionOptions(), lazyConnect: true });
69
+ ```
70
+
71
+ ### `attachSocketRedisAdapter(io: SocketIOServer): void`
72
+
73
+ **Signature:**
74
+ ```typescript
75
+ export const attachSocketRedisAdapter = (io: SocketIOServer): void
76
+ ```
77
+
78
+ **Behavior (in order):**
79
+ 1. Calls `redis.duplicate()` twice to obtain dedicated pub + sub clients (ioredis blocks non-pub/sub commands on a connection in subscribe mode, so duplicating is required).
80
+ 2. Installs `error` listeners on both clients that log to `console.error` with the `[socket-redis-adapter]` prefix.
81
+ 3. Calls `io.adapter(createAdapter(pubClient, subClient))` to wire `@socket.io/redis-adapter`.
82
+
83
+ **Edge cases:**
84
+ - Calling this after `io` has already accepted connections is allowed but races with in-flight broadcasts — wire it before `io.listen` / connection handlers in the boot sequence.
85
+ - The duplicated clients inherit the active registered client (or the default).
86
+
87
+ **Example:**
88
+ ```typescript
89
+ import { Server } from 'socket.io';
90
+ import { attachSocketRedisAdapter, setIoInstance } from '@luckystack/core';
91
+
92
+ const io = new Server(httpServer, { /* ... */ });
93
+ attachSocketRedisAdapter(io);
94
+ setIoInstance(io);
95
+ ```
96
+
97
+ ## Config keys consumed
98
+
99
+ | Env var | Default | Purpose |
100
+ |---|---|---|
101
+ | `REDIS_HOST` | `'127.0.0.1'` | Default client + `getRedisConnectionOptions`. |
102
+ | `REDIS_PORT` | `'6379'` | Same. |
103
+ | `REDIS_USER` | (unset) | Optional ioredis auth. |
104
+ | `REDIS_PASSWORD` | (unset) | Optional ioredis auth. |
105
+
106
+ ## Related
107
+
108
+ - Function INDEX: `packages/core/CLAUDE.md`
109
+ - Architecture: `docs/ARCHITECTURE_SOCKET.md`, `docs/HOSTING.md`
110
+ - README: `packages/core/README.md`
111
+ - Source: `packages/core/src/redis.ts`, `socketRedisAdapter.ts`, `clients.ts`