@luckystack/server 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +87 -85
- package/LICENSE +21 -21
- package/README.md +198 -198
- package/dist/{chunk-X3OSC5W3.js → chunk-RIVFTOTI.js} +1 -1
- package/dist/chunk-RIVFTOTI.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +61 -10
- package/dist/index.js.map +1 -1
- package/dist/parseArgv.js +1 -1
- package/dist/parseArgv.js.map +1 -1
- package/docs/argv-parsing.md +256 -256
- package/docs/create-server.md +295 -295
- package/docs/http-routes.md +311 -309
- package/docs/request-pipeline.md +198 -198
- package/docs/runtime-maps.md +206 -206
- package/docs/security-defaults.md +236 -236
- package/package.json +13 -10
- package/dist/chunk-X3OSC5W3.js.map +0 -1
package/docs/request-pipeline.md
CHANGED
|
@@ -1,198 +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`
|
|
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`
|