@luckystack/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/hooks.md ADDED
@@ -0,0 +1,298 @@
1
+ # Hooks
2
+
3
+ > Deep specs for the async + sync hook registries, the canonical core-owned payload shapes, `processUpload`, and the response normalizer family. Source: `packages/core/src/hooks/`, `processUpload.ts`, `responseNormalizer.ts`. Bijgewerkt: 2026-05-20.
4
+
5
+ ## Overview
6
+
7
+ LuckyStack ships two hook registries:
8
+
9
+ - **Async hook registry** (`registerHook` / `dispatchHook`) — used by lifecycle events that can tolerate await (`preApiExecute`, `postApiRespond`, `corsRejected`, etc.). Handlers can return a `HookStopSignal` to abort the framework's main flow.
10
+ - **Sync mutator registry** (`registerSyncHook` / `dispatchSyncHook`) — used by hot-path mutators that must remain synchronous (`preErrorNormalize`, `postErrorNormalize`). Handlers mutate the payload object in place; there is no stop signal.
11
+
12
+ Both registries swallow handler exceptions: errors are routed to `getLogger().error` + `captureException` so a misbehaving handler can never crash the framework. Multiple handlers per hook are supported and invoked in registration order.
13
+
14
+ Feature packages extend `HookPayloads` via TypeScript module augmentation. The augmentation file must be in a tsconfig `include` path AND side-effect imported from the package barrel (`import './hookPayloads';`) so TS merges the declarations.
15
+
16
+ `processUpload` is included in this topic because it's the public API that ties the `onUploadStart` / `onUploadComplete` hook contract together for consumer upload routes.
17
+
18
+ ## Lifecycle flow (api request)
19
+
20
+ ```
21
+ client emits apiRequest
22
+ ├─> preHttpRequest (async — read-only meta)
23
+ ├─> preApiValidate (async — can stop)
24
+ ├─> [framework validation]
25
+ ├─> postApiValidate (async — read-only)
26
+ ├─> preApiExecute (async — can stop)
27
+ ├─> [handler.main()]
28
+ ├─> postApiExecute (async — read-only + durationMs)
29
+ ├─> preApiRespond (async — mutate response in place)
30
+ ├─> transformApiResponse (async — mutate response in place)
31
+ └─> postApiRespond (async — read-only, after wire-send)
32
+
33
+ On error:
34
+ ├─> preErrorNormalize (sync mutator)
35
+ ├─> [responseNormalizer]
36
+ ├─> postErrorNormalize (sync mutator)
37
+ └─> apiError (async — observability)
38
+ ```
39
+
40
+ The sync flow has its own analogues (`preSyncAuthorize`, `preSyncFanout`, `postSyncFanout`, `preSyncStream`, `postSyncStream`, `syncError`).
41
+
42
+ ## Types
43
+
44
+ ```typescript
45
+ export interface HookSessionShape {
46
+ id: string;
47
+ token: string;
48
+ email?: string | null;
49
+ name?: string | null;
50
+ avatar?: string | null;
51
+ avatarFallback?: string | null;
52
+ admin?: boolean | null;
53
+ language?: string | null;
54
+ roomCodes?: string[];
55
+ }
56
+
57
+ export interface HookStopSignal {
58
+ stop: true;
59
+ errorCode: string;
60
+ httpStatus?: number;
61
+ }
62
+
63
+ export type HookResult = undefined | HookStopSignal;
64
+ export type HookHandler<TPayload> = (payload: TPayload) => Promise<HookResult> | HookResult;
65
+ export type SyncHookHandler<TPayload> = (payload: TPayload) => void;
66
+
67
+ export type DispatchResult =
68
+ | { stopped: false }
69
+ | { stopped: true; signal: HookStopSignal };
70
+
71
+ export type HookName = keyof HookPayloads;
72
+ export type SyncHookName = keyof SyncHookPayloads;
73
+ ```
74
+
75
+ `HookSessionShape` is defined in core (not imported from login) so the core hook layer stays free of feature-package deps. Any concrete `BaseSessionLayout` / project `SessionLayout` is structurally assignable.
76
+
77
+ ## API Reference — Async Hooks
78
+
79
+ ### `registerHook<TName>(name, handler): void`
80
+
81
+ **Signature:**
82
+ ```typescript
83
+ export const registerHook = <TName extends HookName>(
84
+ name: TName,
85
+ handler: HookHandler<HookPayloads[TName]>,
86
+ ): void
87
+ ```
88
+
89
+ **Behavior:** Appends the handler to the (string-keyed) list of handlers for `name`. Multiple registrations are allowed; order is preserved.
90
+
91
+ ### `dispatchHook<TName>(name, payload): Promise<DispatchResult>`
92
+
93
+ **Signature:**
94
+ ```typescript
95
+ export const dispatchHook = async <TName extends HookName>(
96
+ name: TName,
97
+ payload: HookPayloads[TName],
98
+ ): Promise<DispatchResult>
99
+ ```
100
+
101
+ **Behavior (in order):**
102
+ 1. Reads the handler list for `name` (empty array if none).
103
+ 2. For each handler:
104
+ - Awaits `handler(payload)` inside a try/catch.
105
+ - On thrown error → logs `hook: handler for "<name>" threw` via `getLogger().error` + `captureException(error, { hook: name })`. Continues with the next handler.
106
+ - On returned `HookStopSignal` (any non-undefined return value) → returns `{ stopped: true, signal: result }` immediately. Remaining handlers do NOT run.
107
+ 3. After all handlers complete without a stop → returns `{ stopped: false }`.
108
+
109
+ **Mutation contract:** Handlers receive the same payload object reference. For payloads that explicitly support mutation (`PreApiRespondPayload.response`, `transformApiResponse`), mutating in place is the documented mechanism to replace the outgoing response without a stop signal.
110
+
111
+ ### `clearAllHooks(): void`
112
+
113
+ Test-only. Clears both the async and sync hook registries. Never call from production paths — framework-internal hooks (presence cleanup, etc.) would be wiped.
114
+
115
+ ## API Reference — Sync Hooks
116
+
117
+ ### `registerSyncHook<TName>(name, handler): void`
118
+
119
+ **Behavior:** Same shape as `registerHook` but appends to the sync map.
120
+
121
+ ### `dispatchSyncHook<TName>(name, payload): void`
122
+
123
+ **Behavior:**
124
+ - Invokes every handler synchronously.
125
+ - Per-handler try/catch isolates failures (same logger + `captureException` path as async).
126
+ - Cannot stop the flow. Handlers mutate `payload` in place.
127
+
128
+ **When to use sync:** Only when the call site is on a hot path that genuinely cannot afford to `await`. The current usage is `normalizeErrorResponse` because it's invoked from many places in a single request (api error path, sync error path, transport boundaries).
129
+
130
+ ## Core-owned payload shapes
131
+
132
+ ### API lifecycle
133
+
134
+ | Hook | Payload (key fields) |
135
+ |---|---|
136
+ | `preHttpRequest` | `method`, `url`, `requestId`, `origin`, `headers` (sanitized — no auth/cookie). |
137
+ | `preApiValidate` | `routeName`, `data`, `user`. |
138
+ | `postApiValidate` | extends `preApiValidate` + `validation: { status: 'success' } \| { status: 'error', message }`. |
139
+ | `preApiExecute` | `routeName`, `data`, `user`. |
140
+ | `postApiExecute` | `routeName`, `data`, `user`, `result`, `error`, `durationMs`. |
141
+ | `preApiRespond` | `routeName`, `user`, `response: ApiResponseEnvelope` (mutable). |
142
+ | `transformApiResponse` | Same shape — separate hook so plugins can compose without ordering with `preApiRespond`. |
143
+ | `postApiRespond` | `routeName`, `user`, `response` (immutable after wire-send). |
144
+
145
+ ### Sync lifecycle
146
+
147
+ | Hook | Payload (key fields) |
148
+ |---|---|
149
+ | `preSyncAuthorize` | `routeName`, `data` (raw), `user`, `receiver`. |
150
+ | `preSyncFanout` | `routeName`, `data`, `user`, `receiver`, `serverOutput`. |
151
+ | `postSyncFanout` | extends `preSyncFanout` + `recipientCount`. |
152
+ | `preSyncStream` | `routeName`, `chunk`, `recipient`. |
153
+ | `postSyncStream` | extends `preSyncStream` + `chunkIndex`. |
154
+
155
+ ### Error / security signals
156
+
157
+ | Hook | Payload (key fields) |
158
+ |---|---|
159
+ | `apiError` | `route`, `method?`, `requestId?`, `user?`, `error`. |
160
+ | `syncError` | Same shape. |
161
+ | `rateLimitExceeded` | `scope`, `key` (sanitized), `limit`, `windowMs`, `count`, `route?`, `ip?`, `userId?`. |
162
+ | `corsRejected` | `origin`, `normalizedOrigin`, `allowedOrigins`, `allowLocalhost`, `route?`. |
163
+ | `csrfMismatch` | `route`, `method?`, `requestId?`, `userId?`, `providedToken: boolean`. |
164
+
165
+ ### Session refresh
166
+
167
+ | Hook | Payload (key fields) |
168
+ |---|---|
169
+ | `preSessionRefresh` | `token`, `userId`, `oldTtl`, `newTtl`. |
170
+ | `postSessionRefresh` | extends + `applied: boolean`. |
171
+
172
+ ### Uploads
173
+
174
+ | Hook | Payload (key fields) |
175
+ |---|---|
176
+ | `onUploadStart` | `userId`, `contentType`, `sizeBytes`, `uploadKind`. Can return `HookStopSignal` (e.g. content moderation reject). |
177
+ | `onUploadComplete` | `userId`, `fileName`, `sizeBytes`, `uploadKind`. Read-only — fires after disk write. |
178
+
179
+ ### Sync mutator hooks
180
+
181
+ | Hook | Payload (key fields) |
182
+ |---|---|
183
+ | `preErrorNormalize` | `response: ErrorResponseInput` (mutable), `preferredLocale?`, `userLanguage?`, `fallbackHttpStatus?`. |
184
+ | `postErrorNormalize` | `normalized: NormalizedErrorResponse` (mutable), `preferredLocale?`, `userLanguage?`. |
185
+
186
+ ## API Reference — processUpload
187
+
188
+ ### `processUpload(input: ProcessUploadInput): Promise<ProcessUploadResult>`
189
+
190
+ **Signature:**
191
+ ```typescript
192
+ export interface ProcessUploadInput {
193
+ userId: string;
194
+ contentType: string;
195
+ buffer: Buffer;
196
+ uploadKind?: string; // default 'avatar'
197
+ fileName: string;
198
+ encodeAndSave: (buffer: Buffer) => Promise<number>;
199
+ }
200
+
201
+ export type ProcessUploadResult =
202
+ | { status: 'success'; sizeBytes: number }
203
+ | { status: 'rejected'; errorCode: string }
204
+ | { status: 'error'; reason: string; cause?: unknown };
205
+ ```
206
+
207
+ **Behavior (in order):**
208
+ 1. Resolves `uploadKind` (defaults to `'avatar'`).
209
+ 2. Dispatches `onUploadStart` with `{ userId, contentType, sizeBytes: buffer.byteLength, uploadKind }`. If a handler returned a stop signal → returns `{ status: 'rejected', errorCode: signal.errorCode }`.
210
+ 3. Runs `input.encodeAndSave(buffer)` inside `tryCatch`. On error → returns `{ status: 'error', reason: error.message || 'encode-failed', cause: error }`.
211
+ 4. Dispatches `onUploadComplete` with `{ userId, fileName, sizeBytes: finalSize ?? buffer.byteLength, uploadKind }`.
212
+ 5. Returns `{ status: 'success', sizeBytes }`.
213
+
214
+ **Why a callback (not built-in encoder):** core stays free of heavy native deps (sharp, ffmpeg, S3 SDKs). The consumer plugs in whichever encoder it needs and core only brackets the call with the hook contract.
215
+
216
+ **Example:** see `src/settings/_api/updateUser_v1.ts` for the canonical avatar usage.
217
+
218
+ ## API Reference — Response normalizer family
219
+
220
+ ### Types
221
+ ```typescript
222
+ export interface ErrorParam {
223
+ key: string;
224
+ value: string | number | boolean;
225
+ }
226
+
227
+ export interface ErrorResponseInput {
228
+ status?: unknown;
229
+ errorCode?: unknown;
230
+ errorParams?: unknown;
231
+ httpStatus?: unknown;
232
+ }
233
+
234
+ export interface NormalizedErrorResponse {
235
+ status: 'error';
236
+ message: string;
237
+ errorCode: string;
238
+ errorParams?: ErrorParam[];
239
+ httpStatus?: number;
240
+ }
241
+
242
+ export const INVALID_ERROR_RESPONSE_CODE = 'error.invalidResponse';
243
+ ```
244
+
245
+ ### `isErrorParamArray(value: unknown): value is ErrorParam[]`
246
+
247
+ Type guard. Returns `true` when `value` is an array and every element has a string `key` plus a `string | number | boolean` `value`.
248
+
249
+ ### `normalizeErrorResponseCore({ response, fallbackHttpStatus?, fallbackErrorCode?, resolveMessage? })`
250
+
251
+ **Behavior:**
252
+ - Extracts `errorParams` (defaults to `undefined` when not an `ErrorParam[]`).
253
+ - Reads `errorCode` (trimmed string). Falls back to `fallbackErrorCode` then `INVALID_ERROR_RESPONSE_CODE`.
254
+ - Reads `httpStatus` only when `typeof === 'number'`; otherwise uses `fallbackHttpStatus`.
255
+ - Calls `resolveMessage?.({ errorCode, errorParams })` when provided; otherwise uses the raw `errorCode` as `message`.
256
+ - Returns `{ status: 'error', message, errorCode, errorParams?, httpStatus? }`.
257
+
258
+ ### `defaultHttpStatusForResponse({ status, explicitHttpStatus?, fallbackErrorStatus = 400 })`
259
+
260
+ Returns `explicitHttpStatus` when numeric, else `200` for `'success'` and `fallbackErrorStatus` for `'error'`.
261
+
262
+ The localized `normalizeErrorResponse` wrapper (in `localizedNormalizer.ts`) layers i18n + the `preErrorNormalize` / `postErrorNormalize` sync hooks on top of `normalizeErrorResponseCore` — see source for the full surface.
263
+
264
+ ## Extending HookPayloads (feature packages)
265
+
266
+ ```typescript
267
+ // packages/<feature>/src/hookPayloads.ts
268
+ import type { HookSessionShape } from '@luckystack/core';
269
+
270
+ declare module '@luckystack/core' {
271
+ interface HookPayloads {
272
+ myFeatureHook: { userId: string; data: { foo: number } };
273
+ }
274
+ }
275
+
276
+ export {};
277
+ ```
278
+
279
+ Then side-effect import from the package barrel so the augmentation is reachable:
280
+
281
+ ```typescript
282
+ // packages/<feature>/src/index.ts
283
+ import './hookPayloads';
284
+ export { /* ... */ };
285
+ ```
286
+
287
+ ## Edge cases
288
+
289
+ - A handler that returns `undefined` continues the chain; ANY non-`undefined` return value is treated as a stop signal (the type is `undefined | HookStopSignal`, so returning a plain string would be a type error in TS but a stop in JS — keep the typed signature).
290
+ - `clearAllHooks` clears BOTH registries; if you only need to drop async handlers, register fresh sync ones afterwards.
291
+ - `dispatchSyncHook` ignores handler return values entirely — mutate `payload` in place.
292
+
293
+ ## Related
294
+
295
+ - Function INDEX: `packages/core/CLAUDE.md`
296
+ - Architecture: `docs/ARCHITECTURE_EXTENSION_POINTS.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`
297
+ - README: `packages/core/README.md`
298
+ - Source: `packages/core/src/hooks/registry.ts`, `hooks/types.ts`, `processUpload.ts`, `responseNormalizer.ts`
@@ -0,0 +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`
@@ -0,0 +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_USERNAME` / `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_USERNAME` 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_USERNAME` | (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`