@luckystack/server 0.10.2 → 0.11.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/AGENTS.md +7 -0
- package/CHANGELOG.md +7 -1
- package/README.md +2 -200
- package/dist/afterListen.d.ts +2 -0
- package/dist/argv.d.ts +8 -0
- package/dist/bootstrap.d.ts +36 -0
- package/dist/capabilities.d.ts +10 -0
- package/dist/createServer.d.ts +4 -0
- package/dist/customRoutesRegistry.d.ts +9 -0
- package/dist/devServerInfo.d.ts +7 -0
- package/dist/devToolsStatus.d.ts +3 -0
- package/dist/errorFormatterRegistry.d.ts +2 -0
- package/dist/hookPayloads.d.ts +62 -0
- package/dist/httpHandler.d.ts +3 -0
- package/dist/httpRoutes/apiRoute.d.ts +2 -0
- package/dist/httpRoutes/authApiRoute.d.ts +2 -0
- package/dist/httpRoutes/authCallbackRoute.d.ts +2 -0
- package/dist/httpRoutes/authLogoutRoute.d.ts +2 -0
- package/dist/httpRoutes/authProvidersRoute.d.ts +2 -0
- package/dist/httpRoutes/authSecondFactorRoutes.d.ts +3 -0
- package/dist/httpRoutes/csrfMiddleware.d.ts +8 -0
- package/dist/httpRoutes/csrfRoute.d.ts +2 -0
- package/dist/httpRoutes/customRoutes.d.ts +3 -0
- package/dist/httpRoutes/faviconRoute.d.ts +2 -0
- package/dist/httpRoutes/healthRoutes.d.ts +4 -0
- package/dist/httpRoutes/resolveRequesterIp.d.ts +27 -0
- package/dist/httpRoutes/sessionCookie.d.ts +1 -0
- package/dist/httpRoutes/staticRoutes.d.ts +2 -0
- package/dist/httpRoutes/syncRoute.d.ts +2 -0
- package/dist/httpRoutes/testResetRoute.d.ts +2 -0
- package/dist/httpRoutes/timingSafeEqual.d.ts +1 -0
- package/dist/httpRoutes/types.d.ts +15 -0
- package/dist/httpRoutes/uploadsRoute.d.ts +2 -0
- package/dist/index.d.ts +21 -352
- package/dist/loadSocket.d.ts +21 -0
- package/dist/logSanitize.d.ts +1 -0
- package/dist/originExemptRegistry.d.ts +13 -0
- package/dist/parseArgv.d.ts +1 -2
- package/dist/portResolution.d.ts +7 -0
- package/dist/runtimeMapsLoader.d.ts +46 -0
- package/dist/securityHeadersRegistry.d.ts +13 -0
- package/dist/sse.d.ts +11 -0
- package/dist/stopServer.d.ts +28 -0
- package/dist/types.d.ts +138 -0
- package/dist/verifyBootstrap.d.ts +18 -0
- package/docs/argv-parsing.md +3 -244
- package/docs/create-server.md +3 -327
- package/docs/http-routes.md +3 -319
- package/docs/request-pipeline.md +3 -196
- package/docs/runtime-maps.md +3 -207
- package/docs/security-defaults.md +7 -238
- package/package.json +12 -12
- package/CLAUDE.md +0 -90
package/docs/request-pipeline.md
CHANGED
|
@@ -1,198 +1,5 @@
|
|
|
1
|
-
# Request
|
|
1
|
+
# Request pipeline
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Apply the origin policy and security headers before dispatching HTTP routes; apply cookie-mode CSRF protection before executing state-changing handlers. A missing browser origin on a write must not become a Host-derived same-origin assumption. Keep HTTP and Socket.io policies explicit rather than assuming the HTTP handler protects socket handshakes. Source: `../src/httpHandler.ts`, `../src/loadSocket.ts`, `../src/httpRoutes/csrfMiddleware.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
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`
|
|
5
|
+
Per-route error formatting must not change authorization outcomes. Streaming must preserve failure signalling after partial delivery; see `../src/sse.ts` and `../../sync/docs/streaming.md`. For route selection consult `../src/httpRoutes/` rather than a hand-maintained execution tour.
|
package/docs/runtime-maps.md
CHANGED
|
@@ -1,209 +1,5 @@
|
|
|
1
|
-
# Runtime
|
|
1
|
+
# Runtime maps
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Generated route maps are loaded in production so boot does not depend on a development watcher or scan. Bundles must not silently overwrite another bundle's API or sync route: conflicting ownership is a deployment error. Repeated equivalent shared functions are intentionally deduplicated because scoped builds can carry the same registry. Source: `../src/runtimeMapsLoader.ts`, `../src/verifyBootstrap.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
A `RuntimeMapsProvider` (defined in `@luckystack/core`) is the runtime source of truth for which `apis`, `syncs`, and `functions` exist in the running process. Without one, every `/api/*` and `/sync/*` request silently resolves to `notFound`.
|
|
8
|
-
|
|
9
|
-
`@luckystack/server` ships two helpers so consumers no longer need to hand-roll their own `server/prod/runtimeMaps.ts`:
|
|
10
|
-
|
|
11
|
-
- `createProdRuntimeMapsProvider(options)` — builds and returns the provider without registering.
|
|
12
|
-
- `registerProdRuntimeMapsProvider(options)` — convenience wrapper: builds AND calls `registerRuntimeMapsProvider(provider)`. Most consumers want this.
|
|
13
|
-
|
|
14
|
-
In production, the provider loads generated maps via the consumer-supplied `loadGenerated(preset)` callback. In dev it transparently delegates to `@luckystack/devkit`'s in-memory discovery (`devApis` / `devSyncs` / `devFunctions`), which is populated by the devkit watchers.
|
|
15
|
-
|
|
16
|
-
Why does the consumer supply `loadGenerated`? Dynamic-import path resolution in ESM is module-scoped — the framework cannot resolve a relative path on the consumer's behalf. Passing a function that calls `import(\`./prod/generatedApis.${preset}\`)` lets the consumer-side module own resolution.
|
|
17
|
-
|
|
18
|
-
Preset list resolution (in `resolvePresets`):
|
|
19
|
-
|
|
20
|
-
1. `options.preset` if it is a non-empty string -> `[preset]`.
|
|
21
|
-
2. `options.preset` if it is a non-empty array -> dedup via `Array.from(new Set(...))`.
|
|
22
|
-
3. `getParsedBundles()` from `argv.ts` when non-empty.
|
|
23
|
-
4. Final fallback: `['default']`.
|
|
24
|
-
|
|
25
|
-
Multiple presets are loaded in parallel, normalized to `{ apisObject, syncObject, functionsObject }`, then shallow-merged into a single view. API/sync key collisions throw at boot — services must own exactly one preset (see `docs/ARCHITECTURE_PACKAGING.md` §10). Because phase-1 generation intentionally repeats the complete function registry in every preset, equivalent function entries deduplicate; a repeated function key with different exports still throws.
|
|
26
|
-
|
|
27
|
-
## API Reference
|
|
28
|
-
|
|
29
|
-
### `createProdRuntimeMapsProvider(options: ProdRuntimeMapsLoaderOptions): RuntimeMapsProvider`
|
|
30
|
-
|
|
31
|
-
**Signature:**
|
|
32
|
-
|
|
33
|
-
```typescript
|
|
34
|
-
export interface ProdRuntimeMapsLoaderOptions {
|
|
35
|
-
loadGenerated: (preset: string) => Promise<unknown>;
|
|
36
|
-
preset?: string | string[];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export const createProdRuntimeMapsProvider = (
|
|
40
|
-
options: ProdRuntimeMapsLoaderOptions,
|
|
41
|
-
): RuntimeMapsProvider;
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
**Parameters:**
|
|
45
|
-
|
|
46
|
-
| Field | Type | Default | Purpose |
|
|
47
|
-
| --- | --- | --- | --- |
|
|
48
|
-
| `loadGenerated` | `(preset: string) => Promise<unknown>` | required | Dynamic-import callback that resolves the generated maps module for a given preset. The resolved module must have shape `{ apis, syncs, functions }` (what `scripts/generateServerRequests.ts` emits). Called once per preset per process lifetime; the result is cached. |
|
|
49
|
-
| `preset` | `string \| string[]` | argv -> `['default']` | Override for the preset list. String -> single-entry array; array -> deduplicated. Skips the argv lookup. |
|
|
50
|
-
|
|
51
|
-
**Returns:** a `RuntimeMapsProvider`:
|
|
52
|
-
|
|
53
|
-
```typescript
|
|
54
|
-
interface RuntimeMapsProvider {
|
|
55
|
-
getRuntimeApiMaps: () => Promise<{ apisObject: Record<string, unknown>; functionsObject: Record<string, unknown> }>;
|
|
56
|
-
getRuntimeSyncMaps: () => Promise<{ syncObject: Record<string, unknown>; functionsObject: Record<string, unknown> }>;
|
|
57
|
-
}
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
**Behavior:**
|
|
61
|
-
|
|
62
|
-
- Builds two getters (`getRuntimeApiMaps`, `getRuntimeSyncMaps`) that switch on `process.env.NODE_ENV === 'production'`:
|
|
63
|
-
- **Production:** lazy-loads + caches the prod maps via `loadProdRuntimeMaps`. Subsequent calls await the same `prodMapsPromise`.
|
|
64
|
-
- **Dev:** lazy-imports `@luckystack/devkit` and returns `{ devApis, devSyncs, devFunctions }`. Devkit is excluded from production bundles so this dynamic import is never reached when `NODE_ENV === 'production'`.
|
|
65
|
-
- `loadProdRuntimeMaps`:
|
|
66
|
-
1. Resolve presets via `resolvePresets(options)`.
|
|
67
|
-
2. Concurrently `loadGenerated(preset)` for each preset; failures resolve to `null` (caught with `.catch(() => null)`).
|
|
68
|
-
3. For each loaded module, run `normalizeGeneratedModule` (defends against missing or malformed export shape: anything other than an object becomes `{}`).
|
|
69
|
-
4. Merge `apis`, `syncs`, `functions` into the combined view with `mergeInto`, tracking origin per key in three `Map<string, string>`s. Equivalent repeated function entries are retained once.
|
|
70
|
-
5. Skipped presets emit `console.warn('[luckystack:runtimeMaps] preset "<name>" failed to load — skipping. ...')`.
|
|
71
|
-
6. If no presets loaded successfully, emit `console.warn('[luckystack:runtimeMaps] no presets resolved (tried: ...). Every api/sync request will return notFound until at least one generated module loads.')` and return empty maps.
|
|
72
|
-
- The dev branch caches the devkit module promise so repeated calls share one resolved module.
|
|
73
|
-
|
|
74
|
-
**Errors / Edge cases:**
|
|
75
|
-
|
|
76
|
-
- API/sync collisions, and repeated function keys whose exported values differ, throw synchronously inside `loadProdRuntimeMaps`:
|
|
77
|
-
|
|
78
|
-
```
|
|
79
|
-
[luckystack:runtimeMaps] <kind> key collision: "<key>" present in both preset "<previous>" and preset "<current>". Services must belong to exactly one preset (see docs/ARCHITECTURE_PACKAGING.md §10).
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
- Failed `import()` (e.g. preset module not built) is swallowed and logged as a warning; the provider continues with whatever did load.
|
|
83
|
-
- A preset module whose default export is not an object normalizes to `{}` for all three maps — no throw, just empty.
|
|
84
|
-
- The dev branch dynamic-imports `'@luckystack/devkit'` even when only one of the two getters is invoked; the module promise is cached.
|
|
85
|
-
|
|
86
|
-
**Example:**
|
|
87
|
-
|
|
88
|
-
```typescript
|
|
89
|
-
import { createProdRuntimeMapsProvider, registerRuntimeMapsProvider } from '@luckystack/server';
|
|
90
|
-
|
|
91
|
-
const provider = createProdRuntimeMapsProvider({
|
|
92
|
-
loadGenerated: (preset) => import(`./prod/generatedApis.${preset}`),
|
|
93
|
-
preset: 'billing',
|
|
94
|
-
});
|
|
95
|
-
registerRuntimeMapsProvider(provider);
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
---
|
|
99
|
-
|
|
100
|
-
### `registerProdRuntimeMapsProvider(options: ProdRuntimeMapsLoaderOptions): RuntimeMapsProvider`
|
|
101
|
-
|
|
102
|
-
**Signature:**
|
|
103
|
-
|
|
104
|
-
```typescript
|
|
105
|
-
export const registerProdRuntimeMapsProvider = (
|
|
106
|
-
options: ProdRuntimeMapsLoaderOptions,
|
|
107
|
-
): RuntimeMapsProvider;
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
**Parameters:** identical to `createProdRuntimeMapsProvider`.
|
|
111
|
-
|
|
112
|
-
**Returns:** the same `RuntimeMapsProvider` instance that was registered.
|
|
113
|
-
|
|
114
|
-
**Behavior:**
|
|
115
|
-
|
|
116
|
-
- Internally:
|
|
117
|
-
```typescript
|
|
118
|
-
const provider = createProdRuntimeMapsProvider(options);
|
|
119
|
-
registerRuntimeMapsProvider(provider);
|
|
120
|
-
return provider;
|
|
121
|
-
```
|
|
122
|
-
- Calling twice replaces the active provider (last-write-wins) — `registerRuntimeMapsProvider` is the registry function.
|
|
123
|
-
|
|
124
|
-
**Errors / Edge cases:** same as `createProdRuntimeMapsProvider`. `registerRuntimeMapsProvider` itself does not throw on duplicate calls.
|
|
125
|
-
|
|
126
|
-
**Example — direct use:**
|
|
127
|
-
|
|
128
|
-
```typescript
|
|
129
|
-
import { registerProdRuntimeMapsProvider } from '@luckystack/server';
|
|
130
|
-
|
|
131
|
-
registerProdRuntimeMapsProvider({
|
|
132
|
-
loadGenerated: (preset) => import(`./prod/generatedApis.${preset}`),
|
|
133
|
-
});
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
**Example — via `createLuckyStackServer.loadGeneratedMaps`:** when the option is supplied, `createLuckyStackServer` calls `registerProdRuntimeMapsProvider` for you before `verifyBootstrap` runs.
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
import { createLuckyStackServer } from '@luckystack/server';
|
|
140
|
-
|
|
141
|
-
const server = await createLuckyStackServer({
|
|
142
|
-
serveFile,
|
|
143
|
-
serveFavicon,
|
|
144
|
-
loadGeneratedMaps: (preset) => import(`./prod/generatedApis.${preset}`),
|
|
145
|
-
runtimeMapsPreset: ['billing', 'vehicles'],
|
|
146
|
-
});
|
|
147
|
-
await server.listen();
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
## Expected module shape
|
|
151
|
-
|
|
152
|
-
`loadGenerated(preset)` must resolve to a module that has these named exports:
|
|
153
|
-
|
|
154
|
-
```typescript
|
|
155
|
-
export const apis: Record<string, ApiRouteEntry>;
|
|
156
|
-
export const syncs: Record<string, SyncRouteEntry>;
|
|
157
|
-
export const functions: Record<string, FunctionEntry>;
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
This matches what `scripts/generateServerRequests.ts` emits. Missing exports default to `{}` — useful when a preset has no syncs or functions.
|
|
161
|
-
|
|
162
|
-
## Merge semantics
|
|
163
|
-
|
|
164
|
-
`mergeInto(target, source, kind, fromPreset, keyOrigin)`:
|
|
165
|
-
|
|
166
|
-
- Iterate every key in `source`.
|
|
167
|
-
- If an API or sync key already belongs to a different preset -> throw the collision error.
|
|
168
|
-
- If a function key already exists, compare both wrapper records: they must expose the same keys and every exported value must be identical through `Object.is`. Equivalent entries are skipped; different implementations throw.
|
|
169
|
-
- Otherwise record the origin and shallow-assign `target[key] = source[key]`.
|
|
170
|
-
|
|
171
|
-
Three independent origin maps are used so an api named `users/get` and a sync named `users/get` do not collide. ADR 0042 governs the shared-function exception.
|
|
172
|
-
|
|
173
|
-
## Failure modes
|
|
174
|
-
|
|
175
|
-
| Situation | Behavior |
|
|
176
|
-
| --- | --- |
|
|
177
|
-
| `loadGenerated(preset)` rejects | Warn (`preset "<name>" failed to load — skipping`); continue with other presets. |
|
|
178
|
-
| All presets failed | Warn (`no presets resolved (tried: ...)`); return empty maps; every request returns `notFound`. |
|
|
179
|
-
| Preset module has wrong shape | Normalize to `{}`. Same effect: requests for those routes return `notFound`. |
|
|
180
|
-
| API/sync key collision across presets | Throw at boot — services must own exactly one preset. |
|
|
181
|
-
| Equivalent repeated function entry | Deduplicate — phase-1 scoped builds intentionally carry the complete registry. |
|
|
182
|
-
| Repeated function key with different exports | Throw at boot — generated artifacts disagree. |
|
|
183
|
-
| Provider is never registered at all | `verifyBootstrap` hard-fails in production, loud-warns in dev. |
|
|
184
|
-
|
|
185
|
-
## Dev vs prod branch
|
|
186
|
-
|
|
187
|
-
| Environment | Source of maps |
|
|
188
|
-
| --- | --- |
|
|
189
|
-
| `NODE_ENV === 'production'` | `loadGenerated(preset)` for each resolved preset; cached `prodMapsPromise`. |
|
|
190
|
-
| Anything else | Dynamic import of `'@luckystack/devkit'` -> `{ devApis, devSyncs, devFunctions }`. Devkit watchers keep these maps live. |
|
|
191
|
-
|
|
192
|
-
The dynamic import to `'@luckystack/devkit'` is intentionally written as `import('@luckystack/devkit')` and not statically referenced, so bundlers tree-shake it out of production builds.
|
|
193
|
-
|
|
194
|
-
## Config keys
|
|
195
|
-
|
|
196
|
-
| Source | Key | Effect |
|
|
197
|
-
| --- | --- | --- |
|
|
198
|
-
| env | `NODE_ENV` | Switches dev (devkit) vs prod (`loadGenerated`) branch. |
|
|
199
|
-
| argv | `<bundles>` | Preset list when `options.preset` is omitted. |
|
|
200
|
-
| options | `options.preset` | Overrides argv-derived preset list. |
|
|
201
|
-
| options | `options.loadGenerated` | Required prod loader callback. |
|
|
202
|
-
|
|
203
|
-
## Related
|
|
204
|
-
|
|
205
|
-
- Function INDEX: `packages/server/CLAUDE.md`
|
|
206
|
-
- Argv parsing: `packages/server/docs/argv-parsing.md`
|
|
207
|
-
- Create server: `packages/server/docs/create-server.md`
|
|
208
|
-
- Architecture: `docs/ARCHITECTURE_PACKAGING.md` (§10 preset bundles + multi-service builds)
|
|
209
|
-
- README: `packages/server/README.md`
|
|
5
|
+
Failing to register a production provider must be visible at boot, not discovered as a missing route by the first user. Runtime map selection does not authorize a caller; each handler still applies its own access policy. See `./argv-parsing.md`, `../../api/docs/auth-flow.md`, `../../sync/docs/room-fanout.md`.
|