@luckystack/core 0.6.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +148 -107
  2. package/CLAUDE.md +245 -245
  3. package/LICENSE +21 -21
  4. package/README.md +93 -93
  5. package/dist/{chunk-WFABOO6J.js → chunk-AFG7KKMS.js} +3 -3
  6. package/dist/chunk-AFG7KKMS.js.map +1 -0
  7. package/dist/{chunk-3AWP2YEA.js → chunk-J3UOWYT4.js} +2 -28
  8. package/dist/chunk-J3UOWYT4.js.map +1 -0
  9. package/dist/{chunk-LU3IPFTR.js → chunk-LTCDQU5Z.js} +1 -1
  10. package/dist/chunk-LTCDQU5Z.js.map +1 -0
  11. package/dist/chunk-R7IRJFVC.js +67 -0
  12. package/dist/chunk-R7IRJFVC.js.map +1 -0
  13. package/dist/client.d.ts +3 -2
  14. package/dist/client.js +4 -4
  15. package/dist/client.js.map +1 -1
  16. package/dist/config-DZCzBNwP.d.ts +122 -0
  17. package/dist/config.d.ts +2 -0
  18. package/dist/config.js +31 -0
  19. package/dist/eslint/index.js.map +1 -1
  20. package/dist/index.d.ts +21 -149
  21. package/dist/index.js +66 -70
  22. package/dist/index.js.map +1 -1
  23. package/dist/pageRouteValidation-sJJCX-YV.d.ts +270 -0
  24. package/dist/{projectConfig-XL5VB76A.js → projectConfig-DBLSQ3NY.js} +2 -2
  25. package/dist/{sentrySetup-4NTWLN2A.js → sentrySetup-MF6JSVWA.js} +3 -3
  26. package/dist/sentrySetup-MF6JSVWA.js.map +1 -0
  27. package/dist/{pageRouteValidation-gqktrJci.d.ts → sessionTypes-Dzen_rPK.d.ts} +23 -265
  28. package/docs/app-bootstrap.md +418 -418
  29. package/docs/config-registry.md +348 -348
  30. package/docs/csrf-config.md +88 -88
  31. package/docs/error-tracker-registry.md +194 -174
  32. package/docs/hooks.md +298 -298
  33. package/docs/rate-limit-strategy.md +170 -170
  34. package/docs/redis-adapter.md +111 -111
  35. package/docs/session-types.md +180 -180
  36. package/docs/socket-bootstrap.md +366 -366
  37. package/package.json +6 -2
  38. package/dist/chunk-3AWP2YEA.js.map +0 -1
  39. package/dist/chunk-LU3IPFTR.js.map +0 -1
  40. package/dist/chunk-WFABOO6J.js.map +0 -1
  41. /package/dist/{projectConfig-XL5VB76A.js.map → config.js.map} +0 -0
  42. /package/dist/{sentrySetup-4NTWLN2A.js.map → projectConfig-DBLSQ3NY.js.map} +0 -0
@@ -1,366 +1,366 @@
1
- # Socket Bootstrap
2
-
3
- > Deep specs for socket transport bootstrap, token extraction, CORS check, offline queue, CSRF, cookies, service-route helpers and event constants. Source: `packages/core/src/`. Bijgewerkt: 2026-05-20.
4
-
5
- ## Overview
6
-
7
- This topic covers the wiring around the LuckyStack socket transport:
8
-
9
- - **Server-side socket slot** — `setIoInstance` / `getIoInstance` so framework code can broadcast.
10
- - **Client-side socket singleton** — live binding `socket`, `setSocket`, `waitForSocket`, `incrementResponseIndex`.
11
- - **Token extraction** — pull the session token from a Socket.io handshake (`extractTokenFromSocket`) or a Node `IncomingMessage` (`extractTokenFromRequest`).
12
- - **Origin check** — `allowedOrigin` for CORS-style allow-listing, with `corsRejected` hook fan-out.
13
- - **Offline queue** — `enqueueApiRequest`, `enqueueSyncRequest`, flushers, and per-item `dropPolicy` overrides.
14
- - **CSRF + httpFetch** — lazy `/auth/csrf` token cache + drop-in fetch wrapper.
15
- - **Wire-protocol constants** — `socketEventNames` map + per-prefix event-name builders.
16
- - **Route parsers** — `parseServiceRouteName`, `parseTransportRouteName`.
17
- - **Cookie helpers** — `getCookieValue`, `hasCookie`.
18
- - **HTTP helpers** — `inferHttpMethod`, `getEffectiveHttpMethod`, `isMethodAllowed`.
19
-
20
- Token extraction respects `projectConfig.session.basedToken`: when `true` (token-mode) the framework prefers `socket.handshake.auth.token` / `Authorization: Bearer ...` with the cookie as fallback. When `false` (cookie-mode) the configured session cookie is read; the bearer / handshake-auth fallback is IGNORED by default (CORE-O10 — a stolen token replayed through a header can't bypass the cookie/CSRF model) unless `projectConfig.http.acceptBearerInCookieMode` is set `true` to restore the legacy cookie-then-bearer fallback.
21
-
22
- ## API Reference — IO Instance
23
-
24
- ### `setIoInstance(io: SocketIOServer | null): void`
25
-
26
- **Signature:**
27
- ```typescript
28
- export const setIoInstance = (io: SocketIOServer | null): void
29
- ```
30
-
31
- **Behavior:** Stores the running Socket.io server in a module-level slot. Framework code (`@luckystack/sync`, presence package, etc.) reads via `getIoInstance()` for broadcast paths.
32
-
33
- ### `getIoInstance(): SocketIOServer | null`
34
-
35
- Returns the registered instance or `null` when no server has been set. Callers must null-check.
36
-
37
- ## API Reference — Client Socket Singleton
38
-
39
- ### `socket: Socket | null`
40
-
41
- A live `export let` binding — importers always see the current value after `setSocket(...)` replaces it.
42
-
43
- ### `setSocket(next: Socket | null): void`
44
-
45
- Stores the project's socket.io-client instance. Called from `src/_sockets/socketInitializer.ts`.
46
-
47
- ### `incrementResponseIndex(): number`
48
-
49
- **Returns:** The next monotonic response index (used to build per-request reply event names like `apiResponse-42`).
50
-
51
- **Behavior:**
52
- - Module-scoped counter, increments before returning so the first call returns `1`.
53
- - Used by `apiRequest` / `syncRequest` clients to disambiguate concurrent requests on the same socket.
54
-
55
- ### `waitForSocket(): Promise<Socket | null>`
56
-
57
- **Behavior:**
58
- - Polls every 10 ms until `socket !== null` or 500 iterations elapse (~5 seconds).
59
- - Returns the socket on success, `null` on timeout.
60
-
61
- **Example:**
62
- ```typescript
63
- import { waitForSocket } from '@luckystack/core/client';
64
-
65
- const s = await waitForSocket();
66
- if (!s) throw new Error('socket never connected');
67
- ```
68
-
69
- ## API Reference — Custom Socket Middlewares
70
-
71
- The framework lets consumers wedge their own Socket.io `io.use(...)` middlewares into the bootstrap without forking `loadSocket.ts`. Registered middlewares run in registration order, before any `connect` handler — same contract as a direct `io.use(...)` call.
72
-
73
- ### `registerSocketMiddleware(mw: SocketMiddleware): void`
74
-
75
- **Signature:**
76
- ```typescript
77
- export type SocketMiddleware = (socket: Socket, next: (err?: Error) => void) => void;
78
- export const registerSocketMiddleware = (mw: SocketMiddleware): void
79
- ```
80
-
81
- **Behavior:** Appends the middleware to a module-level list. `@luckystack/server`'s `loadSocket` calls `applySocketMiddlewares(io)` after constructing the `SocketIOServer` and before attaching the `connect` handler, so every registered middleware runs on the handshake of every incoming socket.
82
-
83
- **Example — license-key gate:**
84
- ```typescript
85
- import { registerSocketMiddleware } from '@luckystack/core';
86
-
87
- registerSocketMiddleware((socket, next) => {
88
- const licenseKey = socket.handshake.auth.licenseKey;
89
- if (typeof licenseKey !== 'string' || !isValidLicense(licenseKey)) {
90
- return next(new Error('invalid license'));
91
- }
92
- next();
93
- });
94
- ```
95
-
96
- **Example — observability tag (Datadog APM):**
97
- ```typescript
98
- import { registerSocketMiddleware } from '@luckystack/core';
99
- import tracer from 'dd-trace';
100
-
101
- registerSocketMiddleware((socket, next) => {
102
- const span = tracer.scope().active();
103
- if (span) span.setTag('socket.id', socket.id);
104
- next();
105
- });
106
- ```
107
-
108
- ### `getSocketMiddlewares(): readonly SocketMiddleware[]`
109
-
110
- Read the registered list (frozen view). Useful for diagnostics.
111
-
112
- ### `clearSocketMiddlewares(): void`
113
-
114
- Drops every registered middleware. Test/hot-reload helper.
115
-
116
- ### `applySocketMiddlewares(io: SocketIOServer): void`
117
-
118
- Framework-internal — wires every registered middleware via `io.use(...)`. Called by `@luckystack/server`'s `loadSocket`; consumers should not call this directly.
119
-
120
- ## API Reference — Token Extraction
121
-
122
- ### `extractTokenFromSocket(socket: Socket): string | null`
123
-
124
- **Signature:**
125
- ```typescript
126
- export const extractTokenFromSocket = (socket: Socket): string | null
127
- ```
128
-
129
- **Behavior:**
130
- - Reads `socket.handshake.auth.token` (when it is a string) and the configured cookie (`projectConfig.http.sessionCookieName`) from `socket.handshake.headers.cookie`.
131
- - When `session.basedToken === true`: returns the auth-token first, falls back to the cookie.
132
- - When `session.basedToken === false`: returns the cookie first, falls back to the auth-token.
133
-
134
- **Returns:** The token string or `null`.
135
-
136
- ### `extractTokenFromRequest(req: IncomingMessage): string | null`
137
-
138
- **Signature:**
139
- ```typescript
140
- export const extractTokenFromRequest = (req: IncomingMessage): string | null
141
- ```
142
-
143
- **Behavior:**
144
- - Reads `req.headers.authorization` and extracts the bearer (`'Bearer <token>'`).
145
- - Reads the configured cookie from `req.headers.cookie`.
146
- - Picks cookie-first vs bearer-first based on `session.basedToken`.
147
-
148
- ## API Reference — Origin Check
149
-
150
- ### `allowedOrigin(origin: string): boolean`
151
-
152
- **Signature:**
153
- ```typescript
154
- const allowedOrigin = (origin: string): boolean
155
- export default allowedOrigin
156
- ```
157
-
158
- **Behavior (in execution order):**
159
- 1. Normalizes the origin to `scheme://host[:port]`, strips default `:80` / `:443`, lowercases.
160
- 2. Computes the same-origin bind location from `getBindAddress()` and the `SECURE` env var.
161
- 3. When `cors.allowLocalhost` and the origin matches `/^https?:\/\/localhost(:\d+)?$/i` → returns `true`.
162
- 4. When `cors.allowedOrigins` is a function → calls it; if it returns true (or the origin equals the bind location) → returns `true`.
163
- 5. When `cors.allowedOrigins` is a string array → builds a normalized allowed set (bind location + configured list); returns `true` on match.
164
- 6. Otherwise: dispatches the `corsRejected` hook (with origin + normalized origin + allowedOrigins + allowLocalhost) and returns `false`. Also logs via `getLogger().warn` when `logging.devLogs` is on.
165
-
166
- **Edge cases:**
167
- - An empty `origin` is treated as not-allowed.
168
- - The resolver-function form is sync only — async predicates are not supported because Socket.io's CORS check is sync.
169
-
170
- ## API Reference — Offline Queue
171
-
172
- Per-queue items:
173
- ```typescript
174
- interface QueueItem {
175
- id: string;
176
- key: string;
177
- run: (socketInstance: Socket) => void;
178
- createdAt: number;
179
- dropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';
180
- }
181
- ```
182
-
183
- ### `isOnline(): boolean`
184
- Returns `true` in non-browser environments. In browsers, reads `navigator.onLine`.
185
-
186
- ### `enqueueApiRequest(item): boolean` / `enqueueSyncRequest(item): boolean`
187
-
188
- **Behavior:**
189
- 1. Evicts items older than `offlineQueue.maxAgeMs` from the relevant queue.
190
- 2. If `queue.length < offlineQueue.maxSize`, pushes the new item and returns `true`.
191
- 3. Otherwise applies the effective drop policy (per-item `dropPolicy` overrides the global default):
192
- - `'drop-oldest'` — shift + push, returns `true` (logs debug).
193
- - `'drop-newest'` — keep existing queue, returns `false` (logs debug).
194
- - `'reject'` — returns `false` (logs warn).
195
-
196
- ### `removeApiQueueItem(id)` / `removeSyncQueueItem(id)`
197
- Removes a single item by `id`. No-op if not found.
198
-
199
- ### `removeApiQueueItemsByKey(key)`
200
- Removes every API queue item matching `item.key`. Used to coalesce duplicate requests.
201
-
202
- ### `flushApiQueue(canRun, socketInstance)` / `flushSyncQueue(canRun, socketInstance)`
203
-
204
- **Behavior:**
205
- - Re-entrancy guard prevents nested flushes for the same queue.
206
- - Evicts expired items.
207
- - Drains FIFO while `canRun()` returns `true`, calling each item's `run(socketInstance)`.
208
-
209
- ### `getApiQueueSize(): number` / `getSyncQueueSize(): number`
210
-
211
- Read-only sizes (after eviction triggers from enqueue/flush; reads alone do not evict).
212
-
213
- **Example:**
214
- ```typescript
215
- import { enqueueApiRequest } from '@luckystack/core/client';
216
-
217
- enqueueApiRequest({
218
- id: requestId,
219
- key: `${routeName}:${userId}`,
220
- createdAt: Date.now(),
221
- dropPolicy: 'drop-newest',
222
- run: (s) => s.emit('apiRequest', message),
223
- });
224
- ```
225
-
226
- ## API Reference — CSRF + httpFetch
227
-
228
- ### `getCsrfToken(): Promise<string | null>`
229
-
230
- **Behavior:**
231
- - Returns `null` immediately in token-mode sessions (`session.basedToken === true`) — CSRF is unnecessary because cross-origin requests don't auto-attach sessionStorage.
232
- - Returns the cached token if previously fetched.
233
- - Otherwise fetches `/auth/csrf` with `credentials: 'include'`, caches and returns `body.csrfToken`.
234
- - Inflight de-duplication: concurrent callers share the same promise.
235
-
236
- ### `clearCsrfToken(): void`
237
-
238
- Drops the cached token + any inflight fetch. Call on logout or when a 403 `auth.csrfMismatch` is observed.
239
-
240
- ### `httpFetch(input, init?): Promise<Response>`
241
-
242
- Drop-in `fetch` replacement that:
243
- - Forces `credentials: 'include'` unless overridden.
244
- - Adds `Content-Type: application/json` when a string body is present and no content-type was set.
245
- - For state-changing methods (`POST`, `PUT`, `PATCH`, `DELETE`) in cookie mode: lazily fetches the CSRF token, attaches as `x-csrf-token`. On a 403 `auth.csrfMismatch` response, clears the cache and retries once with a refreshed token.
246
-
247
- **Example:**
248
- ```typescript
249
- import { httpFetch } from '@luckystack/core/client';
250
-
251
- const res = await httpFetch('/api/system/myRoute/v1', {
252
- method: 'POST',
253
- body: JSON.stringify({ foo: 1 }),
254
- });
255
- ```
256
-
257
- ## API Reference — Wire Protocol Constants
258
-
259
- ### `socketEventNames`
260
-
261
- A frozen record of event names:
262
-
263
- | Key | Value |
264
- |---|---|
265
- | `apiRequest` | `'apiRequest'` |
266
- | `sync` | `'sync'` |
267
- | `joinRoom` / `leaveRoom` / `getJoinedRooms` | (same literal) |
268
- | `updateLocation` / `updateSession` / `sessionReplaced` / `logout` | (same literal) |
269
- | `intentionalDisconnect` / `intentionalReconnect` / `userAfk` / `userBack` | (same literal) |
270
- | `connect` / `disconnect` / `reconnectAttempt` / `connectError` | underlying socket.io events |
271
- | `apiResponsePrefix` | `'apiResponse-'` |
272
- | `apiStreamPrefix` | `'apiStream-'` |
273
- | `syncResponsePrefix` | `'sync-'` |
274
- | `syncProgressPrefix` | `'sync-progress-'` |
275
- | `joinRoomResponsePrefix` / `leaveRoomResponsePrefix` / `getJoinedRoomsResponsePrefix` | (same prefix) |
276
-
277
- ### Event name builders
278
-
279
- | Function | Behavior |
280
- |---|---|
281
- | `buildApiResponseEventName(idx)` | `'apiResponse-<idx>'` |
282
- | `buildApiStreamEventName(idx)` | `'apiStream-<idx>'` |
283
- | `buildSyncResponseEventName(idx)` | `'sync-<idx>'` |
284
- | `buildSyncProgressEventName(idx)` | `'sync-progress-<idx>'` |
285
- | `buildJoinRoomResponseEventName(idx)` | `'joinRoom-<idx>'` |
286
- | `buildLeaveRoomResponseEventName(idx)` | `'leaveRoom-<idx>'` |
287
- | `buildGetJoinedRoomsResponseEventName(idx)` | `'getJoinedRooms-<idx>'` |
288
-
289
- ## API Reference — Service Route Parsers
290
-
291
- ### `parseServiceRouteName(value: string): ServiceRouteParseResult`
292
-
293
- Validates `<service>/<routeName...>` strings.
294
-
295
- **Returns:**
296
- - `{ status: 'success', normalizedRouteName, service, routeName }` — when at least two non-empty segments are present.
297
- - `{ status: 'error', reason }` — for empty input, empty segments, or fewer than two segments.
298
-
299
- Normalization rules: trim, replace `\` with `/`, strip leading/trailing slashes.
300
-
301
- ### `parseTransportRouteName({ value, prefix })`
302
-
303
- Validates a full transport route like `'api/system/foo/v1'`.
304
-
305
- **Parameters:**
306
- | Name | Type | Required | Description |
307
- |---|---|---|---|
308
- | `value` | `string` | yes | Raw route string. |
309
- | `prefix` | `'api' \| 'sync'` | yes | Determines whether the leading `'api'`/`'sync'` segment is stripped. |
310
-
311
- **Returns:**
312
- - `{ status: 'success', normalizedFullName, version, serviceRoute }` — when the trailing segment matches `/^v\d+$/` and the leading service/route part parses cleanly.
313
- - `{ status: 'error', reason }` — on length / version / segment violations.
314
-
315
- ## API Reference — Cookies
316
-
317
- ### `getCookieValue(cookieHeader: string | undefined, cookieName: string): string | null`
318
-
319
- Regex-escapes `cookieName`, searches for `(?:^|;\s*)<name>=<value>` in the header, URL-decodes the captured value (falls back to raw on decode failure). Returns `null` when input is missing or no match.
320
-
321
- ### `hasCookie(cookieHeader, cookieName): boolean`
322
-
323
- Convenience wrapper — true when `getCookieValue` returns a non-null value.
324
-
325
- ## API Reference — HTTP Method Inference
326
-
327
- ### `inferHttpMethod(apiName: string): HttpMethod`
328
-
329
- Naming-convention heuristic (used as fallback when no `apiMethodMap` is registered):
330
- - starts with `get` / `fetch` / `list` → `GET`
331
- - starts with `delete` / `remove` → `DELETE`
332
- - starts with `update` / `edit` / `patch` → `PUT`
333
- - otherwise → `POST`
334
-
335
- Strips a trailing `v\d+` segment before applying the rule, so `api/examples/getUser/v1` resolves to `'GET'`.
336
-
337
- ### `getEffectiveHttpMethod(apiConfig, apiName)`
338
-
339
- Prefers an explicit `apiConfig.httpMethod`; otherwise calls `inferHttpMethod(apiName)`.
340
-
341
- ### `isMethodAllowed(requestMethod, allowedMethod): boolean`
342
-
343
- Returns `true` when the request method matches the allowed method or is `'OPTIONS'` (CORS preflight passthrough).
344
-
345
- ## Hooks dispatched
346
-
347
- | Hook name | Payload type | When |
348
- |---|---|---|
349
- | `corsRejected` | `CorsRejectedPayload` | `allowedOrigin` is about to return `false`. |
350
-
351
- ## Config keys consumed
352
-
353
- | Key | Where |
354
- |---|---|
355
- | `session.basedToken` | token extractors, `httpFetch`, `getCsrfToken` |
356
- | `http.sessionCookieName` | token extractors |
357
- | `http.cors.allowedOrigins`, `http.cors.allowLocalhost` | `allowedOrigin` |
358
- | `offlineQueue.maxSize`, `maxAgeMs`, `dropPolicy` | offline queue |
359
- | `logging.devLogs` | `allowedOrigin` warn-logs |
360
-
361
- ## Related
362
-
363
- - Function INDEX: `packages/core/CLAUDE.md`
364
- - Architecture: `docs/ARCHITECTURE_SOCKET.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`, `docs/ARCHITECTURE_AUTH.md`
365
- - README: `packages/core/README.md`
366
- - Source: `packages/core/src/socketTypes.ts`, `socketState.ts`, `socketEvents.ts`, `extractToken.ts`, `extractTokenFromRequest.ts`, `checkOrigin.ts`, `offlineQueue.ts`, `csrf.ts`, `cookies.ts`, `httpApiUtils.ts`, `serviceRoute.ts`
1
+ # Socket Bootstrap
2
+
3
+ > Deep specs for socket transport bootstrap, token extraction, CORS check, offline queue, CSRF, cookies, service-route helpers and event constants. Source: `packages/core/src/`. Bijgewerkt: 2026-05-20.
4
+
5
+ ## Overview
6
+
7
+ This topic covers the wiring around the LuckyStack socket transport:
8
+
9
+ - **Server-side socket slot** — `setIoInstance` / `getIoInstance` so framework code can broadcast.
10
+ - **Client-side socket singleton** — live binding `socket`, `setSocket`, `waitForSocket`, `incrementResponseIndex`.
11
+ - **Token extraction** — pull the session token from a Socket.io handshake (`extractTokenFromSocket`) or a Node `IncomingMessage` (`extractTokenFromRequest`).
12
+ - **Origin check** — `allowedOrigin` for CORS-style allow-listing, with `corsRejected` hook fan-out.
13
+ - **Offline queue** — `enqueueApiRequest`, `enqueueSyncRequest`, flushers, and per-item `dropPolicy` overrides.
14
+ - **CSRF + httpFetch** — lazy `/auth/csrf` token cache + drop-in fetch wrapper.
15
+ - **Wire-protocol constants** — `socketEventNames` map + per-prefix event-name builders.
16
+ - **Route parsers** — `parseServiceRouteName`, `parseTransportRouteName`.
17
+ - **Cookie helpers** — `getCookieValue`, `hasCookie`.
18
+ - **HTTP helpers** — `inferHttpMethod`, `getEffectiveHttpMethod`, `isMethodAllowed`.
19
+
20
+ Token extraction respects `projectConfig.session.basedToken`: when `true` (token-mode) the framework prefers `socket.handshake.auth.token` / `Authorization: Bearer ...` with the cookie as fallback. When `false` (cookie-mode) the configured session cookie is read; the bearer / handshake-auth fallback is IGNORED by default (CORE-O10 — a stolen token replayed through a header can't bypass the cookie/CSRF model) unless `projectConfig.http.acceptBearerInCookieMode` is set `true` to restore the legacy cookie-then-bearer fallback.
21
+
22
+ ## API Reference — IO Instance
23
+
24
+ ### `setIoInstance(io: SocketIOServer | null): void`
25
+
26
+ **Signature:**
27
+ ```typescript
28
+ export const setIoInstance = (io: SocketIOServer | null): void
29
+ ```
30
+
31
+ **Behavior:** Stores the running Socket.io server in a module-level slot. Framework code (`@luckystack/sync`, presence package, etc.) reads via `getIoInstance()` for broadcast paths.
32
+
33
+ ### `getIoInstance(): SocketIOServer | null`
34
+
35
+ Returns the registered instance or `null` when no server has been set. Callers must null-check.
36
+
37
+ ## API Reference — Client Socket Singleton
38
+
39
+ ### `socket: Socket | null`
40
+
41
+ A live `export let` binding — importers always see the current value after `setSocket(...)` replaces it.
42
+
43
+ ### `setSocket(next: Socket | null): void`
44
+
45
+ Stores the project's socket.io-client instance. Called from `src/_sockets/socketInitializer.ts`.
46
+
47
+ ### `incrementResponseIndex(): number`
48
+
49
+ **Returns:** The next monotonic response index (used to build per-request reply event names like `apiResponse-42`).
50
+
51
+ **Behavior:**
52
+ - Module-scoped counter, increments before returning so the first call returns `1`.
53
+ - Used by `apiRequest` / `syncRequest` clients to disambiguate concurrent requests on the same socket.
54
+
55
+ ### `waitForSocket(): Promise<Socket | null>`
56
+
57
+ **Behavior:**
58
+ - Polls every 10 ms until `socket !== null` or 500 iterations elapse (~5 seconds).
59
+ - Returns the socket on success, `null` on timeout.
60
+
61
+ **Example:**
62
+ ```typescript
63
+ import { waitForSocket } from '@luckystack/core/client';
64
+
65
+ const s = await waitForSocket();
66
+ if (!s) throw new Error('socket never connected');
67
+ ```
68
+
69
+ ## API Reference — Custom Socket Middlewares
70
+
71
+ The framework lets consumers wedge their own Socket.io `io.use(...)` middlewares into the bootstrap without forking `loadSocket.ts`. Registered middlewares run in registration order, before any `connect` handler — same contract as a direct `io.use(...)` call.
72
+
73
+ ### `registerSocketMiddleware(mw: SocketMiddleware): void`
74
+
75
+ **Signature:**
76
+ ```typescript
77
+ export type SocketMiddleware = (socket: Socket, next: (err?: Error) => void) => void;
78
+ export const registerSocketMiddleware = (mw: SocketMiddleware): void
79
+ ```
80
+
81
+ **Behavior:** Appends the middleware to a module-level list. `@luckystack/server`'s `loadSocket` calls `applySocketMiddlewares(io)` after constructing the `SocketIOServer` and before attaching the `connect` handler, so every registered middleware runs on the handshake of every incoming socket.
82
+
83
+ **Example — license-key gate:**
84
+ ```typescript
85
+ import { registerSocketMiddleware } from '@luckystack/core';
86
+
87
+ registerSocketMiddleware((socket, next) => {
88
+ const licenseKey = socket.handshake.auth.licenseKey;
89
+ if (typeof licenseKey !== 'string' || !isValidLicense(licenseKey)) {
90
+ return next(new Error('invalid license'));
91
+ }
92
+ next();
93
+ });
94
+ ```
95
+
96
+ **Example — observability tag (Datadog APM):**
97
+ ```typescript
98
+ import { registerSocketMiddleware } from '@luckystack/core';
99
+ import tracer from 'dd-trace';
100
+
101
+ registerSocketMiddleware((socket, next) => {
102
+ const span = tracer.scope().active();
103
+ if (span) span.setTag('socket.id', socket.id);
104
+ next();
105
+ });
106
+ ```
107
+
108
+ ### `getSocketMiddlewares(): readonly SocketMiddleware[]`
109
+
110
+ Read the registered list (frozen view). Useful for diagnostics.
111
+
112
+ ### `clearSocketMiddlewares(): void`
113
+
114
+ Drops every registered middleware. Test/hot-reload helper.
115
+
116
+ ### `applySocketMiddlewares(io: SocketIOServer): void`
117
+
118
+ Framework-internal — wires every registered middleware via `io.use(...)`. Called by `@luckystack/server`'s `loadSocket`; consumers should not call this directly.
119
+
120
+ ## API Reference — Token Extraction
121
+
122
+ ### `extractTokenFromSocket(socket: Socket): string | null`
123
+
124
+ **Signature:**
125
+ ```typescript
126
+ export const extractTokenFromSocket = (socket: Socket): string | null
127
+ ```
128
+
129
+ **Behavior:**
130
+ - Reads `socket.handshake.auth.token` (when it is a string) and the configured cookie (`projectConfig.http.sessionCookieName`) from `socket.handshake.headers.cookie`.
131
+ - When `session.basedToken === true`: returns the auth-token first, falls back to the cookie.
132
+ - When `session.basedToken === false`: returns the cookie first, falls back to the auth-token.
133
+
134
+ **Returns:** The token string or `null`.
135
+
136
+ ### `extractTokenFromRequest(req: IncomingMessage): string | null`
137
+
138
+ **Signature:**
139
+ ```typescript
140
+ export const extractTokenFromRequest = (req: IncomingMessage): string | null
141
+ ```
142
+
143
+ **Behavior:**
144
+ - Reads `req.headers.authorization` and extracts the bearer (`'Bearer <token>'`).
145
+ - Reads the configured cookie from `req.headers.cookie`.
146
+ - Picks cookie-first vs bearer-first based on `session.basedToken`.
147
+
148
+ ## API Reference — Origin Check
149
+
150
+ ### `allowedOrigin(origin: string): boolean`
151
+
152
+ **Signature:**
153
+ ```typescript
154
+ const allowedOrigin = (origin: string): boolean
155
+ export default allowedOrigin
156
+ ```
157
+
158
+ **Behavior (in execution order):**
159
+ 1. Normalizes the origin to `scheme://host[:port]`, strips default `:80` / `:443`, lowercases.
160
+ 2. Computes the same-origin bind location from `getBindAddress()` and the `SECURE` env var.
161
+ 3. When `cors.allowLocalhost` and the origin matches `/^https?:\/\/localhost(:\d+)?$/i` → returns `true`.
162
+ 4. When `cors.allowedOrigins` is a function → calls it; if it returns true (or the origin equals the bind location) → returns `true`.
163
+ 5. When `cors.allowedOrigins` is a string array → builds a normalized allowed set (bind location + configured list); returns `true` on match.
164
+ 6. Otherwise: dispatches the `corsRejected` hook (with origin + normalized origin + allowedOrigins + allowLocalhost) and returns `false`. Also logs via `getLogger().warn` when `logging.devLogs` is on.
165
+
166
+ **Edge cases:**
167
+ - An empty `origin` is treated as not-allowed.
168
+ - The resolver-function form is sync only — async predicates are not supported because Socket.io's CORS check is sync.
169
+
170
+ ## API Reference — Offline Queue
171
+
172
+ Per-queue items:
173
+ ```typescript
174
+ interface QueueItem {
175
+ id: string;
176
+ key: string;
177
+ run: (socketInstance: Socket) => void;
178
+ createdAt: number;
179
+ dropPolicy?: 'drop-oldest' | 'drop-newest' | 'reject';
180
+ }
181
+ ```
182
+
183
+ ### `isOnline(): boolean`
184
+ Returns `true` in non-browser environments. In browsers, reads `navigator.onLine`.
185
+
186
+ ### `enqueueApiRequest(item): boolean` / `enqueueSyncRequest(item): boolean`
187
+
188
+ **Behavior:**
189
+ 1. Evicts items older than `offlineQueue.maxAgeMs` from the relevant queue.
190
+ 2. If `queue.length < offlineQueue.maxSize`, pushes the new item and returns `true`.
191
+ 3. Otherwise applies the effective drop policy (per-item `dropPolicy` overrides the global default):
192
+ - `'drop-oldest'` — shift + push, returns `true` (logs debug).
193
+ - `'drop-newest'` — keep existing queue, returns `false` (logs debug).
194
+ - `'reject'` — returns `false` (logs warn).
195
+
196
+ ### `removeApiQueueItem(id)` / `removeSyncQueueItem(id)`
197
+ Removes a single item by `id`. No-op if not found.
198
+
199
+ ### `removeApiQueueItemsByKey(key)`
200
+ Removes every API queue item matching `item.key`. Used to coalesce duplicate requests.
201
+
202
+ ### `flushApiQueue(canRun, socketInstance)` / `flushSyncQueue(canRun, socketInstance)`
203
+
204
+ **Behavior:**
205
+ - Re-entrancy guard prevents nested flushes for the same queue.
206
+ - Evicts expired items.
207
+ - Drains FIFO while `canRun()` returns `true`, calling each item's `run(socketInstance)`.
208
+
209
+ ### `getApiQueueSize(): number` / `getSyncQueueSize(): number`
210
+
211
+ Read-only sizes (after eviction triggers from enqueue/flush; reads alone do not evict).
212
+
213
+ **Example:**
214
+ ```typescript
215
+ import { enqueueApiRequest } from '@luckystack/core/client';
216
+
217
+ enqueueApiRequest({
218
+ id: requestId,
219
+ key: `${routeName}:${userId}`,
220
+ createdAt: Date.now(),
221
+ dropPolicy: 'drop-newest',
222
+ run: (s) => s.emit('apiRequest', message),
223
+ });
224
+ ```
225
+
226
+ ## API Reference — CSRF + httpFetch
227
+
228
+ ### `getCsrfToken(): Promise<string | null>`
229
+
230
+ **Behavior:**
231
+ - Returns `null` immediately in token-mode sessions (`session.basedToken === true`) — CSRF is unnecessary because cross-origin requests don't auto-attach sessionStorage.
232
+ - Returns the cached token if previously fetched.
233
+ - Otherwise fetches `/auth/csrf` with `credentials: 'include'`, caches and returns `body.csrfToken`.
234
+ - Inflight de-duplication: concurrent callers share the same promise.
235
+
236
+ ### `clearCsrfToken(): void`
237
+
238
+ Drops the cached token + any inflight fetch. Call on logout or when a 403 `auth.csrfMismatch` is observed.
239
+
240
+ ### `httpFetch(input, init?): Promise<Response>`
241
+
242
+ Drop-in `fetch` replacement that:
243
+ - Forces `credentials: 'include'` unless overridden.
244
+ - Adds `Content-Type: application/json` when a string body is present and no content-type was set.
245
+ - For state-changing methods (`POST`, `PUT`, `PATCH`, `DELETE`) in cookie mode: lazily fetches the CSRF token, attaches as `x-csrf-token`. On a 403 `auth.csrfMismatch` response, clears the cache and retries once with a refreshed token.
246
+
247
+ **Example:**
248
+ ```typescript
249
+ import { httpFetch } from '@luckystack/core/client';
250
+
251
+ const res = await httpFetch('/api/system/myRoute/v1', {
252
+ method: 'POST',
253
+ body: JSON.stringify({ foo: 1 }),
254
+ });
255
+ ```
256
+
257
+ ## API Reference — Wire Protocol Constants
258
+
259
+ ### `socketEventNames`
260
+
261
+ A frozen record of event names:
262
+
263
+ | Key | Value |
264
+ |---|---|
265
+ | `apiRequest` | `'apiRequest'` |
266
+ | `sync` | `'sync'` |
267
+ | `joinRoom` / `leaveRoom` / `getJoinedRooms` | (same literal) |
268
+ | `updateLocation` / `updateSession` / `sessionReplaced` / `logout` | (same literal) |
269
+ | `intentionalDisconnect` / `intentionalReconnect` / `userAfk` / `userBack` | (same literal) |
270
+ | `connect` / `disconnect` / `reconnectAttempt` / `connectError` | underlying socket.io events |
271
+ | `apiResponsePrefix` | `'apiResponse-'` |
272
+ | `apiStreamPrefix` | `'apiStream-'` |
273
+ | `syncResponsePrefix` | `'sync-'` |
274
+ | `syncProgressPrefix` | `'sync-progress-'` |
275
+ | `joinRoomResponsePrefix` / `leaveRoomResponsePrefix` / `getJoinedRoomsResponsePrefix` | (same prefix) |
276
+
277
+ ### Event name builders
278
+
279
+ | Function | Behavior |
280
+ |---|---|
281
+ | `buildApiResponseEventName(idx)` | `'apiResponse-<idx>'` |
282
+ | `buildApiStreamEventName(idx)` | `'apiStream-<idx>'` |
283
+ | `buildSyncResponseEventName(idx)` | `'sync-<idx>'` |
284
+ | `buildSyncProgressEventName(idx)` | `'sync-progress-<idx>'` |
285
+ | `buildJoinRoomResponseEventName(idx)` | `'joinRoom-<idx>'` |
286
+ | `buildLeaveRoomResponseEventName(idx)` | `'leaveRoom-<idx>'` |
287
+ | `buildGetJoinedRoomsResponseEventName(idx)` | `'getJoinedRooms-<idx>'` |
288
+
289
+ ## API Reference — Service Route Parsers
290
+
291
+ ### `parseServiceRouteName(value: string): ServiceRouteParseResult`
292
+
293
+ Validates `<service>/<routeName...>` strings.
294
+
295
+ **Returns:**
296
+ - `{ status: 'success', normalizedRouteName, service, routeName }` — when at least two non-empty segments are present.
297
+ - `{ status: 'error', reason }` — for empty input, empty segments, or fewer than two segments.
298
+
299
+ Normalization rules: trim, replace `\` with `/`, strip leading/trailing slashes.
300
+
301
+ ### `parseTransportRouteName({ value, prefix })`
302
+
303
+ Validates a full transport route like `'api/system/foo/v1'`.
304
+
305
+ **Parameters:**
306
+ | Name | Type | Required | Description |
307
+ |---|---|---|---|
308
+ | `value` | `string` | yes | Raw route string. |
309
+ | `prefix` | `'api' \| 'sync'` | yes | Determines whether the leading `'api'`/`'sync'` segment is stripped. |
310
+
311
+ **Returns:**
312
+ - `{ status: 'success', normalizedFullName, version, serviceRoute }` — when the trailing segment matches `/^v\d+$/` and the leading service/route part parses cleanly.
313
+ - `{ status: 'error', reason }` — on length / version / segment violations.
314
+
315
+ ## API Reference — Cookies
316
+
317
+ ### `getCookieValue(cookieHeader: string | undefined, cookieName: string): string | null`
318
+
319
+ Regex-escapes `cookieName`, searches for `(?:^|;\s*)<name>=<value>` in the header, URL-decodes the captured value (falls back to raw on decode failure). Returns `null` when input is missing or no match.
320
+
321
+ ### `hasCookie(cookieHeader, cookieName): boolean`
322
+
323
+ Convenience wrapper — true when `getCookieValue` returns a non-null value.
324
+
325
+ ## API Reference — HTTP Method Inference
326
+
327
+ ### `inferHttpMethod(apiName: string): HttpMethod`
328
+
329
+ Naming-convention heuristic (used as fallback when no `apiMethodMap` is registered):
330
+ - starts with `get` / `fetch` / `list` → `GET`
331
+ - starts with `delete` / `remove` → `DELETE`
332
+ - starts with `update` / `edit` / `patch` → `PUT`
333
+ - otherwise → `POST`
334
+
335
+ Strips a trailing `v\d+` segment before applying the rule, so `api/examples/getUser/v1` resolves to `'GET'`.
336
+
337
+ ### `getEffectiveHttpMethod(apiConfig, apiName)`
338
+
339
+ Prefers an explicit `apiConfig.httpMethod`; otherwise calls `inferHttpMethod(apiName)`.
340
+
341
+ ### `isMethodAllowed(requestMethod, allowedMethod): boolean`
342
+
343
+ Returns `true` when the request method matches the allowed method or is `'OPTIONS'` (CORS preflight passthrough).
344
+
345
+ ## Hooks dispatched
346
+
347
+ | Hook name | Payload type | When |
348
+ |---|---|---|
349
+ | `corsRejected` | `CorsRejectedPayload` | `allowedOrigin` is about to return `false`. |
350
+
351
+ ## Config keys consumed
352
+
353
+ | Key | Where |
354
+ |---|---|
355
+ | `session.basedToken` | token extractors, `httpFetch`, `getCsrfToken` |
356
+ | `http.sessionCookieName` | token extractors |
357
+ | `http.cors.allowedOrigins`, `http.cors.allowLocalhost` | `allowedOrigin` |
358
+ | `offlineQueue.maxSize`, `maxAgeMs`, `dropPolicy` | offline queue |
359
+ | `logging.devLogs` | `allowedOrigin` warn-logs |
360
+
361
+ ## Related
362
+
363
+ - Function INDEX: `packages/core/CLAUDE.md`
364
+ - Architecture: `docs/ARCHITECTURE_SOCKET.md`, `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SYNC.md`, `docs/ARCHITECTURE_AUTH.md`
365
+ - README: `packages/core/README.md`
366
+ - Source: `packages/core/src/socketTypes.ts`, `socketState.ts`, `socketEvents.ts`, `extractToken.ts`, `extractTokenFromRequest.ts`, `checkOrigin.ts`, `offlineQueue.ts`, `csrf.ts`, `cookies.ts`, `httpApiUtils.ts`, `serviceRoute.ts`