@luckystack/server 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.
@@ -0,0 +1,309 @@
1
+ # HTTP Routes
2
+
3
+ > Deep specs. Bron: `packages/server/src/httpHandler.ts`, `packages/server/src/httpRoutes/*.ts`, `packages/server/src/customRoutesRegistry.ts`. Bijgewerkt: 2026-05-20.
4
+
5
+ ## Overview
6
+
7
+ `@luckystack/server` ships every HTTP route the framework needs: liveness / readiness / health probes, CSRF token issue + middleware, the destructive test-reset endpoint, favicon, uploads, OAuth (`/auth/api` + `/auth/callback`), the dispatcher to `/api/*` and `/sync/*`, custom routes, and a static-file SPA fallback. The orchestrator in `httpHandler.ts` runs them as two ordered tables (PRE_PARAMS and POST_PARAMS). Each handler matches its own route path and returns `boolean`. First handler to return `true` (or end the response) terminates dispatch.
8
+
9
+ Pre-params handlers (no body parse) run first so probes, favicon, and CSRF stay cheap:
10
+
11
+ 1. `handleCsrfRoute` — `/auth/csrf`
12
+ 2. `handleFaviconRoute` — `/favicon.ico`
13
+ 3. `handleLivezRoute` — `projectConfig.http.liveEndpoint` (default `/livez`)
14
+ 4. `handleReadyzRoute` — `projectConfig.http.readyEndpoint` (default `/readyz`)
15
+ 5. `handleHealthRoute` — `projectConfig.http.healthEndpoint` (default `/_health`)
16
+ 6. `handleTestResetRoute` — `projectConfig.http.testResetEndpoint` (default `/_test/reset`)
17
+
18
+ Post-params handlers (after body parsing) run next:
19
+
20
+ 1. `handleUploadsRoute` — `/uploads/*`
21
+ 2. `handleAuthApiRoute` — `/auth/api/*`
22
+ 3. `handleAuthCallbackRoute` — `/auth/callback/*`
23
+ 4. `handleApiRoute` — `/api/*` (delegates to `@luckystack/api`)
24
+ 5. `handleSyncRoute` — `/sync/*` (delegates to `@luckystack/sync`)
25
+ 6. `handleCustomRoutes` — every handler from `registerCustomRoute(...)` + `options.customRoutes`
26
+ 7. `handleStaticAndSpaFallback` — `/assets/*`, known extensions, SPA `index.html`
27
+
28
+ A custom-route registry sits in front of the static fallback. Both the legacy `customRoutes` option on `CreateLuckyStackServerOptions` and the global `registerCustomRoute(...)` registry are honored. Errors in custom handlers are caught and reported as `500 server.customRouteFailed`; the request loop is never crashed by a misbehaving handler.
29
+
30
+ ## Framework route reference
31
+
32
+ | Route | Method(s) | Handler | Response (success) | Response (failure) |
33
+ | --- | --- | --- | --- | --- |
34
+ | `/auth/csrf` | GET | `handleCsrfRoute` | `200 { status: 'success', csrfToken }` | `401 auth.unauthenticated` |
35
+ | `/favicon.ico` | GET | `handleFaviconRoute` | Whatever `options.serveFavicon(res)` writes | `404` empty when no handler supplied |
36
+ | `/livez` | GET | `handleLivezRoute` | `200 { status: 'live' }` | Always 200 when reachable |
37
+ | `/readyz` | GET | `handleReadyzRoute` | `200 { status: 'ready', checks: {...} }` | `503 { status: 'not-ready', checks: {...} }` |
38
+ | `/_health` | GET | `handleHealthRoute` | `200 { status: 'ok', bootUuid, envKey, synchronizedHashes }` | `503 { status: 'degraded', ... }` when boot UUID is missing |
39
+ | `/_test/reset` | POST | `handleTestResetRoute` | `200 { status: 'success', cleared: string[] }` | `404 notFound` or `403 auth.forbidden` — see `security-defaults.md` |
40
+ | `/uploads/*` | GET | `handleUploadsRoute` | Avatar bytes via `@luckystack/core` `serveAvatar` | `404` from `serveAvatar` when missing |
41
+ | `/auth/api/*` | POST | `handleAuthApiRoute` | OAuth redirect (`302`) or credentials login envelope | Rate-limited envelope, `200` envelope with `status:false, reason` |
42
+ | `/auth/callback/*` | GET | `handleAuthCallbackRoute` | `302` to `redirectUrl` with session cookie or token | `401 'Login failed'` plain text |
43
+ | `/api/*` | GET / POST / PUT / DELETE | `handleApiRoute` | JSON envelope from `@luckystack/api` `handleHttpApiRequest`, or SSE stream | `400 api.invalidName`, `500 api.invalidRequestFormat` |
44
+ | `/sync/*` | POST | `handleSyncRoute` | JSON envelope from `@luckystack/sync` `handleHttpSyncRequest`, or SSE stream | `405 sync.methodNotAllowed`, `400 sync.invalidName`, `500 sync.invalidRequestFormat` |
45
+ | `/assets/*`, `*.{png,jpg,jpeg,gif,svg,html,css,js}` | GET | `handleStaticAndSpaFallback` | Whatever `options.serveFile` writes (URL temporarily rewritten) | `404` when no `serveFile` is wired |
46
+ | Any other extension-less path | GET | `handleStaticAndSpaFallback` | SPA fallback (rewrite URL to `/`, call `serveFile`) | `404` when no `serveFile` |
47
+
48
+ > The CSRF header name clients must send (for the `/auth/csrf` route above) is configurable: it comes from `getCsrfConfig().headerName` (default `x-csrf-token`), renamable via `registerCsrfConfig({ headerName })` from `@luckystack/core`.
49
+
50
+ ## API Reference — handlers
51
+
52
+ All handlers conform to:
53
+
54
+ ```typescript
55
+ type HttpRouteHandler = (ctx: HttpRouteContext) => Promise<boolean>;
56
+
57
+ interface HttpRouteContext {
58
+ req: IncomingMessage;
59
+ res: ServerResponse;
60
+ options: CreateLuckyStackServerOptions;
61
+ routePath: string;
62
+ queryString: string | undefined;
63
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
64
+ token: string | null;
65
+ requestId: string;
66
+ sessionCookieOptions: string;
67
+ params: object;
68
+ }
69
+ ```
70
+
71
+ A handler returns `true` (or simply ends `res`) to short-circuit dispatch; returning `false` falls through to the next handler.
72
+
73
+ ### `handleLivezRoute(ctx)`
74
+
75
+ **Behavior:**
76
+
77
+ - Matches when `routePath === projectConfig.http.liveEndpoint` (default `/livez`).
78
+ - Always emits `200 application/json { status: 'live' }`.
79
+
80
+ **Edge cases:** no downstream dependencies; succeeds as long as the Node event loop is responsive.
81
+
82
+ ### `handleReadyzRoute(ctx)`
83
+
84
+ **Behavior:**
85
+
86
+ - Matches when `routePath === projectConfig.http.readyEndpoint` (default `/readyz`).
87
+ - Sequentially checks: `readBootUuid()` (Redis), `redis.ping()`, and a Prisma probe.
88
+ - Prisma probe is provider-agnostic: tries `$queryRaw\`SELECT 1\`` first, then `$runCommandRaw({ ping: 1 })`. Detecting the active provider via private fields drifts between Prisma majors, so the handler probes by capability.
89
+ - Returns `200 { status: 'ready', checks }` only when all three pass; `503 { status: 'not-ready', checks: { bootUuid, redis, prisma } }` otherwise.
90
+
91
+ **Edge cases:**
92
+
93
+ - `redis.ping()` errors are swallowed and surface as `redis: false`.
94
+ - A Prisma client with neither raw method available results in `prismaOk = false`.
95
+
96
+ ### `handleHealthRoute(ctx)`
97
+
98
+ **Behavior:**
99
+
100
+ - Matches when `routePath === projectConfig.http.healthEndpoint` (default `/_health`).
101
+ - Reads `bootUuid`, `resolveEnvKey()`, and `computeSynchronizedEnvHashes()` from `@luckystack/core`.
102
+ - Returns `200 { status: 'ok', bootUuid, envKey, synchronizedHashes }` when boot UUID is present, otherwise `503 { status: 'degraded', ... }`.
103
+ - Consumed by `@luckystack/router` to verify shared-Redis topology after a rolling restart.
104
+
105
+ ### `handleCsrfRoute(ctx)`
106
+
107
+ **Behavior:**
108
+
109
+ - Matches `routePath === '/auth/csrf'`.
110
+ - Requires a session token (cookie or `Authorization`). Returns `401 auth.unauthenticated` when missing or when `getSession(token)` does not resolve a valid session.
111
+ - On success returns `200 { status: 'success', csrfToken }`.
112
+
113
+ **Note:** the matching middleware is `enforceCsrfOnStateChangingRequest` (see `request-pipeline.md`). This route only issues tokens.
114
+
115
+ ### `handleFaviconRoute(ctx)`
116
+
117
+ **Behavior:**
118
+
119
+ - Matches `routePath === '/favicon.ico'`.
120
+ - Delegates to `options.serveFavicon(res)`. Without that option, returns an empty `404`.
121
+
122
+ ### `handleUploadsRoute(ctx)`
123
+
124
+ **Behavior:**
125
+
126
+ - Matches every path starting with `/uploads/`.
127
+ - Delegates to `serveAvatar({ routePath, res })` from `@luckystack/core`. Response (and `404` on miss) is owned by `serveAvatar`.
128
+
129
+ ### `handleAuthApiRoute(ctx)`
130
+
131
+ **Behavior:**
132
+
133
+ - Matches `routePath` starting with `/auth/api`.
134
+ - Looks up the provider via `getOAuthProviders().find((p) => p.name === routePath.split('/')[3])`.
135
+ - For full OAuth providers (`isFullOAuthProvider(provider)`):
136
+ - `createOAuthState(provider.name)` produces a CSRF state token; failure returns `500 login.oauthStateInitFailed`.
137
+ - Builds the provider's authorization URL with `client_id`, `redirect_uri`, `scope`, `response_type=code`, `prompt=select_account`, `state`, and emits a `302`.
138
+ - For credentials provider:
139
+ - Applies an IP-keyed rate limit (`key: ip:<remote>:auth:credentials`) using `projectConfig.rateLimiting.defaultApiLimit` + `windowMs`. On hit, dispatches `rateLimitExceeded` hook and returns `{ status: false, reason: 'api.rateLimitExceeded', errorParams: [{ key: 'seconds', value: resetIn }] }`.
140
+ - Calls `loginWithCredentials(params)`. On success: delete existing session, choose cookie-based vs header-based token via `x-session-based-token` header (overrides `projectConfig.session.basedToken`), then emit `Set-Cookie` or `X-Session-Token`.
141
+ - Final envelope: `{ status, reason, session, authenticated }`.
142
+
143
+ **Errors:** unknown provider -> `{ status: false, reason: 'login.providerNotFound' }`; thrown errors bubble to the outer `httpHandler` error path.
144
+
145
+ ### `handleAuthCallbackRoute(ctx)`
146
+
147
+ **Behavior:**
148
+
149
+ - Matches `routePath` starting with `/auth/callback`.
150
+ - Computes `baseLocation = process.env.DNS || projectConfig.app.publicUrl || '/'` (the `||` is intentional — empty `DNS` falls through; `??` would shadow `app.publicUrl`).
151
+ - Delegates to `loginCallback(routePath, req, res, { defaultRedirectUrl })` from `@luckystack/login`.
152
+ - On failure: `401 'Login failed'`.
153
+ - On success: delete existing session, then redirect (`302`). With `projectConfig.session.basedToken: true` the token rides as `?token=...` in the query; otherwise it's emitted as a session cookie.
154
+
155
+ ### `handleApiRoute(ctx)`
156
+
157
+ **Behavior:**
158
+
159
+ - Matches every path starting with `/api/`.
160
+ - Detects SSE intent via `shouldUseHttpStream({ acceptHeader, queryString })`: returns true if `Accept` contains `text/event-stream` or `?<projectConfig.http.stream.queryParam>=<enabledValue>` (or `=1`). When true, initializes an SSE response header and tracks client disconnect via `req.on('close', ...)`.
161
+ - Strips `/api/` prefix; empty name -> `400 api.invalidName` (or SSE `final` event with same payload).
162
+ - Calls `handleHttpApiRequest({ name, data, token, requesterIp, xLanguageHeader, acceptLanguageHeader, method, stream? })` from `@luckystack/api`. The `stream` callback emits `event: stream` SSE frames during long-running calls.
163
+ - Emits the result (`event: final` for SSE, or `JSON.stringify(result)` + `res.writeHead(result.httpStatus)` for non-SSE).
164
+
165
+ **Errors:** any thrown error is logged + captured to Sentry, `apiError` hook dispatched, and the envelope `500 api.invalidRequestFormat` is returned (over SSE or JSON).
166
+
167
+ ### `handleSyncRoute(ctx)`
168
+
169
+ **Behavior:**
170
+
171
+ - Matches every path starting with `/sync/`.
172
+ - Only `POST` is allowed; other methods return `405 sync.methodNotAllowed`.
173
+ - Normalizes params into `{ data, receiver, ignoreSelf?, cb? }`. Empty `receiver` is allowed (`''`).
174
+ - Calls `handleHttpSyncRequest({ name: 'sync/<rest>', cb, data, receiver, ignoreSelf, token, requesterIp, xLanguageHeader, acceptLanguageHeader, stream? })` from `@luckystack/sync`.
175
+ - SSE handling mirrors `handleApiRoute`.
176
+
177
+ **Errors:** mirrors `handleApiRoute` — logs, Sentry, `syncError` hook, returns `500 sync.invalidRequestFormat`.
178
+
179
+ ### `handleCustomRoutes(ctx)`
180
+
181
+ **Behavior:**
182
+
183
+ - Iterates every handler registered via `registerCustomRoute(...)` in registration order; first one to return `true` or end the response wins.
184
+ - If none handled, falls back to the inline `options.customRoutes` if provided.
185
+ - Each handler is wrapped in `tryCatch` — on throw it logs via `getLogger().error`, calls `captureException`, and emits `500 server.customRouteFailed` (returning `true` so dispatch stops).
186
+
187
+ ### `handleStaticAndSpaFallback(ctx)`
188
+
189
+ **Behavior:**
190
+
191
+ - `/assets/*` paths: slice from the first `/assets/` occurrence, temporarily rewrite `req.url` to the asset path, call `options.serveFile`, restore `req.url`. Without `serveFile`, returns `404 Not Found`.
192
+ - Paths matching `KNOWN_STATIC_FILE_REGEX` (`/^\/(assets\/[a-zA-Z0-9_\-/]+|[a-zA-Z0-9_\-]+)\.(png|jpg|jpeg|gif|svg|html|css|js)$/`): call `serveFile` directly without URL rewrite.
193
+ - Other paths that have an extension `path.extname(routePath)`: `404 Not Found` (text/plain).
194
+ - Extensionless paths (SPA routes): rewrite `req.url` to `/`, call `serveFile`. Without `serveFile`, returns `404 Not Found`.
195
+
196
+ **Why the rewrite around `serveFile`:** consumers (Vite, custom static middleware) read `req.url`. We swap then restore so downstream loggers / metrics see the original URL.
197
+
198
+ ## Custom-route registry
199
+
200
+ ```typescript
201
+ export const registerCustomRoute: (handler: CustomRouteHandler) => void;
202
+ export const getCustomRoutes: () => readonly CustomRouteHandler[];
203
+ export const clearCustomRoutes: () => void;
204
+ ```
205
+
206
+ - `registerCustomRoute(handler)` — appends to an in-memory array. Called by overlay packages (`@luckystack/docs-ui` mounts `/_docs` this way).
207
+ - `getCustomRoutes()` — read snapshot; iterated by `handleCustomRoutes`.
208
+ - `clearCustomRoutes()` — empty the array; used by `@luckystack/test-runner`'s reset flow.
209
+
210
+ The handler signature:
211
+
212
+ ```typescript
213
+ type CustomRouteHandler = (
214
+ req: IncomingMessage,
215
+ res: ServerResponse,
216
+ ctx: RouteContext,
217
+ ) => Promise<boolean> | boolean;
218
+
219
+ interface RouteContext {
220
+ routePath: string;
221
+ method: string;
222
+ queryString: string | undefined;
223
+ token: string | null;
224
+ }
225
+ ```
226
+
227
+ Return `true` (or end the response) when handled. Throws are caught and emit `500 server.customRouteFailed`.
228
+
229
+ **Example — webhook receiver:**
230
+
231
+ ```typescript
232
+ import { registerCustomRoute } from '@luckystack/server';
233
+
234
+ registerCustomRoute(async (req, res, ctx) => {
235
+ if (ctx.routePath !== '/webhooks/stripe') return false;
236
+ if (ctx.method !== 'POST') {
237
+ res.writeHead(405);
238
+ res.end();
239
+ return true;
240
+ }
241
+ // verify signature, process body ...
242
+ res.writeHead(200);
243
+ res.end('ok');
244
+ return true;
245
+ });
246
+ ```
247
+
248
+ **Example — health alias for an external monitor:**
249
+
250
+ ```typescript
251
+ registerCustomRoute(async (_req, res, ctx) => {
252
+ if (ctx.routePath !== '/healthz') return false;
253
+ res.statusCode = 200;
254
+ res.end('ok');
255
+ return true;
256
+ });
257
+ ```
258
+
259
+ ## SSE streaming
260
+
261
+ `/api/*` and `/sync/*` both support Server-Sent Events when the client opts in. Helpers live in `packages/server/src/sse.ts`:
262
+
263
+ | Helper | Behavior |
264
+ | --- | --- |
265
+ | `shouldUseHttpStream({ acceptHeader, queryString })` | Returns `true` if `Accept` includes `text/event-stream` OR the query string contains `<projectConfig.http.stream.queryParam>=<enabledValue>` (or `=1`). |
266
+ | `initSseResponse(res)` | Writes `200` with `Content-Type: text/event-stream`, `Cache-Control: no-cache, no-transform`, `Connection: keep-alive`, `X-Accel-Buffering: no`. Emits `projectConfig.http.stream.connectedComment` as a keepalive comment if configured. |
267
+ | `sendSseEvent({ res, event, data })` | Writes `event: <event>\ndata: <json>\n\n`. No-op once `res.writableEnded`. |
268
+
269
+ Lifecycle in `handleApiRoute` / `handleSyncRoute`:
270
+
271
+ 1. Decide SSE vs JSON via `shouldUseHttpStream`.
272
+ 2. If SSE, call `initSseResponse(res)` and `req.on('close', () => { streamClosed = true; })`.
273
+ 3. Pass a `stream` callback to `handleHttpApiRequest` / `handleHttpSyncRequest` that emits `event: stream` frames while the request is processing.
274
+ 4. On completion, emit `event: final` with the result envelope and call `res.end()`.
275
+ 5. On error, emit `event: error` with the `500 *.invalidRequestFormat` envelope.
276
+
277
+ ## Hooks dispatched
278
+
279
+ | Hook | Payload | When |
280
+ | --- | --- | --- |
281
+ | `rateLimitExceeded` | `{ scope, key, limit, windowMs, count, route, ip }` | `handleAuthApiRoute` credentials path when IP limit is exceeded. |
282
+ | `apiError` | `{ route, method, requestId, error }` | `handleApiRoute` outer error path. |
283
+ | `syncError` | `{ route, method, requestId, error }` | `handleSyncRoute` outer error path. |
284
+ | `csrfMismatch` | `{ route, method, requestId, userId, providedToken }` | `enforceCsrfOnStateChangingRequest` (see `request-pipeline.md`). |
285
+ | `preHttpRequest` | `{ method, url, requestId, origin, headers }` | Every incoming request before dispatch (see `request-pipeline.md`). |
286
+
287
+ ## Config keys consumed
288
+
289
+ | Source | Key | Effect |
290
+ | --- | --- | --- |
291
+ | config | `projectConfig.http.healthEndpoint` | Default `/_health`. |
292
+ | config | `projectConfig.http.liveEndpoint` | Default `/livez`. |
293
+ | config | `projectConfig.http.readyEndpoint` | Default `/readyz`. |
294
+ | config | `projectConfig.http.testResetEndpoint` | Default `/_test/reset`. |
295
+ | config | `projectConfig.http.stream.queryParam` / `enabledValue` / `connectedComment` | SSE opt-in query parameter + initial keepalive comment. |
296
+ | config | `projectConfig.http.sessionCookieName` | Cookie name written by `/auth/api` / `/auth/callback`. |
297
+ | config | `projectConfig.session.basedToken` | Cookie vs header token transport. |
298
+ | config | `projectConfig.rateLimiting.defaultApiLimit` / `windowMs` | Credentials login rate limit. |
299
+ | env | `DNS` | Legacy public origin override for callback redirect. |
300
+ | env | `TEST_RESET_TOKEN` | Consumed by `/_test/reset` — see `security-defaults.md`. |
301
+
302
+ ## Related
303
+
304
+ - Function INDEX: `packages/server/CLAUDE.md`
305
+ - Request pipeline: `packages/server/docs/request-pipeline.md`
306
+ - Security defaults: `packages/server/docs/security-defaults.md`
307
+ - API delegate: `docs/ARCHITECTURE_API.md`
308
+ - Sync delegate: `docs/ARCHITECTURE_SYNC.md`
309
+ - README: `packages/server/README.md`
@@ -0,0 +1,198 @@
1
+ # Request Pipeline
2
+
3
+ > Deep specs. Bron: `packages/server/src/httpHandler.ts`, `packages/server/src/loadSocket.ts`, `packages/server/src/sse.ts`. Bijgewerkt: 2026-05-20.
4
+
5
+ ## Overview
6
+
7
+ `handleHttpRequest` is the orchestrator that every HTTP request flows through. Its job is fixed and intentionally narrow: enforce origin policy, set headers, parse session + CSRF, run pre-request hook, decide method validity, parse params, then walk two ordered handler tables. Each handler in those tables (see `http-routes.md`) is single-purpose and returns `boolean`.
8
+
9
+ Top-level flow:
10
+
11
+ 1. Build session-cookie option string (`HttpOnly; SameSite; Path; Max-Age; Secure?`).
12
+ 2. `enforceOriginPolicy(req, res)` — origin/referer gate. Read-only methods without origin pass; state-changing methods without origin are 403.
13
+ 3. `setSecurityHeaders(req, res, origin)` — framework defaults + consumer builder (see `security-defaults.md`).
14
+ 4. Resolve `requestId` from `x-request-id` (echoed back) or `randomUUID()`. Set `X-Request-Id` response header.
15
+ 5. Dispatch `preHttpRequest` hook with a sanitized header subset. A `HookStopSignal` returns `JSON.stringify({ status: 'error', errorCode })` at `signal.httpStatus ?? 403`.
16
+ 6. `OPTIONS` -> `204` and return.
17
+ 7. Method allowlist: `GET / POST / PUT / DELETE`. Anything else -> `404` plain text.
18
+ 8. Extract session token via `extractTokenFromRequest`. Sliding-cookie refresh when the cookie + session both exist.
19
+ 9. `enforceCsrfOnStateChangingRequest(...)` — see `security-defaults.md`.
20
+ 10. Dispatch `PRE_PARAMS_ROUTES` (`csrf`, `favicon`, `livez`, `readyz`, `_health`, `_test/reset`) — fast paths that should not consume the body.
21
+ 11. `parseRequestParams(...)` — `await getParams({ method, req, res, queryString })`. Streams the body when applicable; bails out if `res.writableEnded`.
22
+ 12. Dispatch `POST_PARAMS_ROUTES` (`uploads`, `auth/api`, `auth/callback`, `api`, `sync`, `customRoutes`, `staticAndSpaFallback`).
23
+
24
+ Socket.io runs on a parallel lifecycle — attached to the same `http.Server` via `loadSocket(httpServer, { maxHttpBufferSize })` but with its own event-driven dispatch.
25
+
26
+ ## API Reference
27
+
28
+ ### `handleHttpRequest(req, res, options): Promise<void>`
29
+
30
+ **Signature:**
31
+
32
+ ```typescript
33
+ export const handleHttpRequest = async (
34
+ req: IncomingMessage,
35
+ res: ServerResponse,
36
+ options: CreateLuckyStackServerOptions,
37
+ ): Promise<void>;
38
+ ```
39
+
40
+ **Parameters:**
41
+
42
+ | Field | Type | Purpose |
43
+ | --- | --- | --- |
44
+ | `req` | `IncomingMessage` | Raw Node request. |
45
+ | `res` | `ServerResponse` | Raw Node response. |
46
+ | `options` | `CreateLuckyStackServerOptions` | Forwarded to the route handler context (used by `staticAndSpaFallback` / `customRoutes`). |
47
+
48
+ **Returns:** `Promise<void>`. The handler resolves once it has ended the response (or short-circuited).
49
+
50
+ **Behavior (execution order):**
51
+
52
+ - Read `projectConfig` once and snapshot `shouldLogDev`, `sessionCookieName`, `sessionCookieOptions`.
53
+ - `enforceOriginPolicy`: state-changing requests without `Origin` / `Referer` are rejected with `403 'Forbidden'`. A present-but-disallowed origin is also `403`. Read-only methods without origin pass to keep health probes and asset fetches working.
54
+ - `setSecurityHeaders`: see `security-defaults.md`. Consumer builder runs after defaults; errors fall through to defaults.
55
+ - Compute `requestId`. Honor `x-request-id` (idempotent for retrying proxies); otherwise mint a `randomUUID()`. Echo as `X-Request-Id` response header.
56
+ - Build a sanitized `safeHeaders` map (drops `authorization`, `cookie`, `set-cookie`, `x-csrf-token`).
57
+ - Dispatch `preHttpRequest` hook. On `result.stopped`: respond with `signal.httpStatus ?? 403` and `application/json { status: 'error', errorCode: signal.errorCode }`.
58
+ - `OPTIONS` -> `writeHead(204)` + `end()`.
59
+ - Reject methods outside `GET / POST / PUT / DELETE` with `404 text/plain`.
60
+ - Split `req.url` into `[routePath, queryString]`.
61
+ - `extractTokenFromRequest(req)` — pulls token from cookie or `Authorization`.
62
+ - `refreshSessionCookieIfPresent` — sliding expiration in cookie mode. Re-emits `Set-Cookie` with refreshed `Max-Age` when the session exists.
63
+ - `enforceCsrfOnStateChangingRequest` — returns `true` when it has ended the response; the request loop bails.
64
+ - `dispatchRoutes(PRE_PARAMS_ROUTES, { ...baseCtx, params: {} })` — first handler to return `true` (or end `res`) wins; otherwise the chain falls through.
65
+ - `parseRequestParams` — pulls body when method needs one. If `res.writableEnded` (because the body parser ended with an error), bail out.
66
+ - `dispatchRoutes(POST_PARAMS_ROUTES, { ...baseCtx, params })`.
67
+
68
+ **Errors / Edge cases:**
69
+
70
+ - The orchestrator itself never wraps the table in `tryCatch`. Per-route handlers wrap their work; `customRoutes` handlers are caught and reported as `500 server.customRouteFailed`.
71
+ - Handler order is fixed; mutating it requires editing `PRE_PARAMS_ROUTES` / `POST_PARAMS_ROUTES`.
72
+ - A handler that ends `res` but returns `false` still terminates dispatch — `dispatchRoutes` checks `res.writableEnded` after each call.
73
+
74
+ ---
75
+
76
+ ### `dispatchRoutes(handlers, ctx): Promise<boolean>`
77
+
78
+ **Signature:**
79
+
80
+ ```typescript
81
+ const dispatchRoutes = async (
82
+ handlers: HttpRouteHandler[],
83
+ ctx: HttpRouteContext,
84
+ ): Promise<boolean>;
85
+ ```
86
+
87
+ **Behavior:**
88
+
89
+ - Iterates `handlers` in array order.
90
+ - After each call, if `handled === true` OR `ctx.res.writableEnded` -> return `true` (stop).
91
+ - Otherwise continue. Return `false` if none handled.
92
+
93
+ **Edge case:** a handler that writes a partial response without ending (`res.write(...)`) without setting `writableEnded` still falls through unless it returns `true`. Always end the response or return `true`.
94
+
95
+ ---
96
+
97
+ ### `loadSocket(httpServer, options?): SocketIOServer`
98
+
99
+ **Signature:**
100
+
101
+ ```typescript
102
+ export interface LoadSocketOptions {
103
+ maxHttpBufferSize?: number;
104
+ }
105
+
106
+ export const loadSocket = (
107
+ httpServer: HttpServer,
108
+ options: LoadSocketOptions = {},
109
+ ): SocketIOServer;
110
+ ```
111
+
112
+ **Parameters:**
113
+
114
+ | Field | Type | Default | Purpose |
115
+ | --- | --- | --- | --- |
116
+ | `httpServer` | `http.Server` | required | The HTTP server returned by `createLuckyStackServer`. Socket.io upgrades on it. |
117
+ | `options.maxHttpBufferSize` | `number` | `projectConfig.socket.maxHttpBufferSize` | Forwarded to Socket.io. |
118
+
119
+ **Returns:** the `SocketIOServer` instance. Also registered globally via `setIoInstance(io)` so framework code (presence, broadcast helpers) can reach it without a parameter.
120
+
121
+ **Behavior:**
122
+
123
+ - Construct `SocketIOServer` with:
124
+ - CORS: methods `GET / POST / PUT / DELETE / OPTIONS`, origin callback delegates to `allowedOrigin`, `credentials: true`. Empty origin is allowed (Socket.io polling without an origin header).
125
+ - `maxHttpBufferSize`, `pingTimeout`, `pingInterval` from `projectConfig.socket`.
126
+ - `setIoInstance(io)` and `attachSocketRedisAdapter(io)` — required for room broadcasts to fan out across instances.
127
+ - `io.on('connect', ...)` per-socket lifecycle:
128
+ - Extract token, cache `activityBroadcasterEnabled` + `locationProviderEnabled` flags + `preferredLocale`.
129
+ - When token present: `socketConnected({ token, io })` (presence).
130
+ - Dispatch `onSocketConnect` hook with `{ socketId, token, ip }`.
131
+ - Wire event handlers (delegates):
132
+ - `apiRequest` -> `handleApiRequest({ msg, socket, token })` from `@luckystack/api`.
133
+ - `sync` -> `handleSyncRequest({ msg, socket, token })` from `@luckystack/sync`.
134
+ - `joinRoom` -> validate, `getSession(token)`, dispatch `preRoomJoin` (may stop), `socket.join(group)`, persist `roomCodes` via `saveSession`, emit `buildJoinRoomResponseEventName(responseIndex)`, dispatch `postRoomJoin`. Serialized per-token via `withSessionLock`.
135
+ - `leaveRoom` -> symmetric to `joinRoom`. Dispatches `preRoomLeave` / `postRoomLeave`.
136
+ - `getJoinedRooms` -> emits visible rooms (filters out `socket.id` and the token room).
137
+ - `disconnect` -> dispatch `onSocketDisconnect`, then either `socketDisconnecting` (if presence is enabled) or a dev log line.
138
+ - `updateLocation` -> when location provider is enabled, mutate session location, dispatch `onLocationUpdate`. Coordinates with `socketLeaveRoom` from presence.
139
+ - When `activityBroadcasterEnabled && token`: `initActivityBroadcaster({ socket, token })`.
140
+ - When token present: `socket.join(token)` — every authenticated socket joins its own token room so server-side code can target a user by token.
141
+
142
+ **Errors / Edge cases:**
143
+
144
+ - `withSessionLock(token, fn)` serializes per-token mutations to prevent read-modify-write races on `session.roomCodes`. The lock's `then(fn, fn)` ensures a failed prior call doesn't block the next caller.
145
+ - Legacy session shapes that still carry `code` / `codes` are stripped on save via `sanitizeSessionRoomKeys`.
146
+ - Room validation: empty / non-string `group` -> emit `room.invalid` error frame.
147
+ - `responseIndex` is required for join/leave/getJoinedRooms; non-number values silently no-op (the client never gets a response — Socket.io's response indexing is purely opt-in).
148
+
149
+ ## SSE streaming inside the pipeline
150
+
151
+ `/api/*` and `/sync/*` opt into SSE via `shouldUseHttpStream({ acceptHeader, queryString })`. When enabled:
152
+
153
+ - `initSseResponse(res)` writes `200 text/event-stream` + `Cache-Control: no-cache, no-transform` + `Connection: keep-alive` + `X-Accel-Buffering: no`. Optional `projectConfig.http.stream.connectedComment` is sent as the initial keepalive comment.
154
+ - The route handler passes a `stream` callback to its delegate. The delegate emits per-event payloads; the handler forwards each as `event: stream\ndata: <json>\n\n`.
155
+ - On completion, emit `event: final` with the result envelope. On error, `event: error`. Then `res.end()`.
156
+ - `req.on('close', ...)` flips a `streamClosed` flag so post-disconnect writes are suppressed.
157
+
158
+ ## Hooks dispatched
159
+
160
+ | Hook | Payload | When |
161
+ | --- | --- | --- |
162
+ | `preHttpRequest` | `{ method, url, requestId, origin, headers }` | Before route dispatch. May stop with `HookStopSignal` to short-circuit. `headers` omits sensitive entries. |
163
+ | `csrfMismatch` | `{ route, method, requestId, userId, providedToken: boolean }` | Cookie-mode CSRF check rejects a write. Token VALUE is never in payload. |
164
+ | `apiError` / `syncError` | `{ route, method, requestId, error }` | Outer error path of `/api/*` / `/sync/*` handlers (see `http-routes.md`). |
165
+ | `rateLimitExceeded` | `{ scope, key, limit, windowMs, count, route, ip }` | Credentials login. |
166
+ | `onSocketConnect` / `onSocketDisconnect` | `OnSocketConnectPayload` / `OnSocketDisconnectPayload` | Socket lifecycle (see `create-server.md`). |
167
+ | `preRoomJoin` / `postRoomJoin` | `PreRoomJoinPayload` / `PostRoomJoinPayload` | Room-join lifecycle inside `loadSocket`. `pre*` may stop. |
168
+ | `preRoomLeave` / `postRoomLeave` | `PreRoomLeavePayload` / `PostRoomLeavePayload` | Room-leave lifecycle inside `loadSocket`. `pre*` may stop. |
169
+ | `onLocationUpdate` | `OnLocationUpdatePayload` | When the presence layer reports a path change. |
170
+
171
+ ## Config keys consumed
172
+
173
+ | Source | Key | Effect |
174
+ | --- | --- | --- |
175
+ | env | `SECURE` | `'true'` -> session cookies get the `Secure;` flag. |
176
+ | config | `projectConfig.logging.devLogs` | Gates per-request debug logs (sanitized params + method/route). |
177
+ | config | `projectConfig.http.cors.*` / `securityHeaders.*` | Response headers (see `security-defaults.md`). |
178
+ | config | `projectConfig.http.sessionCookieName` / `sessionCookiePath` / `sessionCookieSameSite` | Session cookie shape. |
179
+ | config | `projectConfig.session.expiryDays` / `basedToken` | Cookie `Max-Age` + CSRF middleware activation predicate. |
180
+ | config | `projectConfig.http.stream.queryParam` / `enabledValue` / `connectedComment` | SSE opt-in + keepalive. |
181
+ | config | `projectConfig.socket.maxHttpBufferSize` / `pingTimeout` / `pingInterval` | Socket.io transport options. |
182
+ | config | `projectConfig.socketActivityBroadcaster` / `locationProviderEnabled` | Gate presence-related socket handlers. |
183
+
184
+ ## Error fall-through
185
+
186
+ - The outer `handleHttpRequest` does NOT wrap the table in a global `tryCatch`. Each handler is responsible for its own errors.
187
+ - `handleApiRoute` / `handleSyncRoute` wrap their body and emit `500 *.invalidRequestFormat` after dispatching `apiError` / `syncError`.
188
+ - `handleCustomRoutes` wraps each handler and emits `500 server.customRouteFailed` on throw.
189
+ - Throws from inside a `dispatchHook` handler propagate per `@luckystack/core`'s `dispatchHook` semantics — generally they are caught and logged inside the hook system.
190
+
191
+ ## Related
192
+
193
+ - Function INDEX: `packages/server/CLAUDE.md`
194
+ - HTTP routes: `packages/server/docs/http-routes.md`
195
+ - Security defaults: `packages/server/docs/security-defaults.md`
196
+ - Create server: `packages/server/docs/create-server.md`
197
+ - Architecture: `docs/ARCHITECTURE_API.md`, `docs/ARCHITECTURE_SOCKET.md`, `docs/ARCHITECTURE_SYNC.md`, `docs/ARCHITECTURE_SESSION.md`
198
+ - README: `packages/server/README.md`