@luckystack/core 0.4.1 → 0.5.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 +23 -0
- package/CLAUDE.md +245 -243
- package/LICENSE +21 -21
- package/README.md +93 -93
- package/dist/{chunk-H64EC7Q6.js → chunk-A3VMC576.js} +3 -3
- package/dist/chunk-A3VMC576.js.map +1 -0
- package/dist/{chunk-6BBJ2Q6I.js → chunk-ECWDUWBG.js} +1 -1
- package/dist/chunk-ECWDUWBG.js.map +1 -0
- package/dist/{chunk-TGYBUT4N.js → chunk-ELHTLIEX.js} +1 -1
- package/dist/chunk-ELHTLIEX.js.map +1 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +38 -6
- package/dist/index.js.map +1 -1
- package/dist/{projectConfig-FITPOAMC.js → projectConfig-P2AHHQVW.js} +2 -2
- package/dist/{sentrySetup-MHAHHITT.js → sentrySetup-552JVLHJ.js} +2 -2
- package/docs/app-bootstrap.md +418 -418
- package/docs/config-registry.md +348 -348
- package/docs/csrf-config.md +88 -88
- package/docs/error-tracker-registry.md +174 -174
- package/docs/hooks.md +298 -298
- package/docs/rate-limit-strategy.md +170 -170
- package/docs/redis-adapter.md +111 -111
- package/docs/session-types.md +180 -180
- package/docs/socket-bootstrap.md +366 -366
- package/package.json +4 -1
- package/dist/chunk-6BBJ2Q6I.js.map +0 -1
- package/dist/chunk-H64EC7Q6.js.map +0 -1
- package/dist/chunk-TGYBUT4N.js.map +0 -1
- /package/dist/{projectConfig-FITPOAMC.js.map → projectConfig-P2AHHQVW.js.map} +0 -0
- /package/dist/{sentrySetup-MHAHHITT.js.map → sentrySetup-552JVLHJ.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`
|
package/docs/redis-adapter.md
CHANGED
|
@@ -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`
|