@luckystack/core 0.6.7 → 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.
- package/CHANGELOG.md +148 -107
- package/CLAUDE.md +245 -245
- package/LICENSE +21 -21
- package/README.md +93 -93
- package/dist/{chunk-WFABOO6J.js → chunk-AFG7KKMS.js} +3 -3
- package/dist/chunk-AFG7KKMS.js.map +1 -0
- package/dist/{chunk-3AWP2YEA.js → chunk-J3UOWYT4.js} +2 -28
- package/dist/chunk-J3UOWYT4.js.map +1 -0
- package/dist/{chunk-LU3IPFTR.js → chunk-LTCDQU5Z.js} +1 -1
- package/dist/chunk-LTCDQU5Z.js.map +1 -0
- package/dist/chunk-R7IRJFVC.js +67 -0
- package/dist/chunk-R7IRJFVC.js.map +1 -0
- package/dist/client.d.ts +3 -2
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/config-DZCzBNwP.d.ts +122 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +31 -0
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.d.ts +21 -149
- package/dist/index.js +66 -70
- package/dist/index.js.map +1 -1
- package/dist/pageRouteValidation-sJJCX-YV.d.ts +270 -0
- package/dist/{projectConfig-XL5VB76A.js → projectConfig-DBLSQ3NY.js} +2 -2
- package/dist/{sentrySetup-4NTWLN2A.js → sentrySetup-MF6JSVWA.js} +3 -3
- package/dist/sentrySetup-MF6JSVWA.js.map +1 -0
- package/dist/{pageRouteValidation-gqktrJci.d.ts → sessionTypes-Dzen_rPK.d.ts} +23 -265
- 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 +194 -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 +6 -2
- package/dist/chunk-3AWP2YEA.js.map +0 -1
- package/dist/chunk-LU3IPFTR.js.map +0 -1
- package/dist/chunk-WFABOO6J.js.map +0 -1
- /package/dist/{projectConfig-XL5VB76A.js.map → config.js.map} +0 -0
- /package/dist/{sentrySetup-4NTWLN2A.js.map → projectConfig-DBLSQ3NY.js.map} +0 -0
package/docs/hooks.md
CHANGED
|
@@ -1,298 +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`
|
|
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`
|