@luckystack/server 0.10.1 → 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 +11 -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/index.js +32 -7
- package/dist/index.js.map +1 -1
- 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 -236
- package/package.json +12 -12
- package/CLAUDE.md +0 -89
package/docs/create-server.md
CHANGED
|
@@ -1,329 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Server creation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The high-level bootstrap supplies a single place to verify registrations and connect HTTP, sockets and optional feature packages. A lower-level server factory exists for integrations that knowingly take over this responsibility. Starting to listen before critical config and security registrations are ready creates request-time failures; check boot prerequisites first. Source: `../src/bootstrap.ts`, `../src/createServer.ts`, `../src/verifyBootstrap.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
`@luckystack/server` exposes two boot entries:
|
|
8
|
-
|
|
9
|
-
- `bootstrapLuckyStack(options)` — high-level, auto-imports the project's `luckystack/<package>/*.ts` overlay folder in topological order, then delegates to `createLuckyStackServer`. This is what `create-luckystack-app` scaffolds and what most apps should call.
|
|
10
|
-
- `createLuckyStackServer(options)` — low-level factory. Skips overlay loading; the caller is responsible for getting every framework registry populated (project config, deploy config, runtime maps, OAuth providers, etc.) before it runs. Returns `{ httpServer, ioServer, listen }`.
|
|
11
|
-
|
|
12
|
-
Both honor an explicit pre-flight check via `verifyBootstrap(requirements?)`, which fails fast when a critical registration was forgotten. `createLuckyStackServer` invokes it internally so direct callers don't have to.
|
|
13
|
-
|
|
14
|
-
Boot order (effective for both entries):
|
|
15
|
-
|
|
16
|
-
1. `bootstrapLuckyStack` only: load overlay files (`core` -> `deploy` -> `login` -> `email` -> `sentry` -> `presence` -> `cron` -> `docs-ui` -> `server`, each folder topologically followed by alphabetical `*.ts`).
|
|
17
|
-
2. If `options.loadGeneratedMaps` was supplied, register the framework-shipped runtime-maps provider before `verifyBootstrap` so the boot check sees it.
|
|
18
|
-
3. `verifyBootstrap` runs (using the per-call `requireDeployConfig` / `requireServicesConfig` / `requireOAuthProviders` flags). Throws a single descriptive `Error` if anything is missing.
|
|
19
|
-
4. Resolve `port` (`options.port` -> argv-registry override -> `options.defaultPort` -> `80`) and `ip` (`options.ip` -> `SERVER_IP` -> `127.0.0.1`); validate the port in `0..65535` and register the intended address plus configured-default metadata. After listen succeeds, register `httpServer.address().port` as the actually-bound address.
|
|
20
|
-
5. In dev mode (`enableDevTools !== false` and `NODE_ENV !== 'production'`): `initConsolelog()` + dynamic-import `@luckystack/devkit` -> `initializeAll()` + `setupWatchers()`. Initialization status is shared with API/sync dispatchers and `/readyz`; a fatal devkit error keeps all three fail-closed with 503. Install `SIGINT` / `SIGTERM` handlers that force-exit.
|
|
21
|
-
6. `writeBootUuid()` writes a fresh boot UUID to Redis so `/_health` becomes truthful and the router can detect rolling restarts.
|
|
22
|
-
7. Construct `http.createServer(handleHttpRequest)` and `loadSocket(httpServer, { maxHttpBufferSize })`.
|
|
23
|
-
8. Return `{ httpServer, ioServer, listen }`. The HTTP server has NOT started listening yet; after `listen()` binds successfully it starts a non-overlapping boot-UUID heartbeat at one third of the configured TTL. `stop()` / `close()` cancels that heartbeat before graceful shutdown.
|
|
24
|
-
|
|
25
|
-
## API Reference
|
|
26
|
-
|
|
27
|
-
### `createLuckyStackServer(options?: CreateLuckyStackServerOptions): Promise<RunningLuckyStackServer>`
|
|
28
|
-
|
|
29
|
-
**Signature:**
|
|
30
|
-
|
|
31
|
-
```typescript
|
|
32
|
-
export const createLuckyStackServer = async (
|
|
33
|
-
options: CreateLuckyStackServerOptions = {}
|
|
34
|
-
): Promise<RunningLuckyStackServer>;
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
**Parameters:**
|
|
38
|
-
|
|
39
|
-
| Field | Type | Default | Purpose |
|
|
40
|
-
| --- | --- | --- | --- |
|
|
41
|
-
| `port` | `number \| string` | `getParsedPort()` -> `defaultPort` -> `80` | Explicit HTTP listen port. Numeric strings are accepted; partial/non-integer/out-of-range values fail before `listen`. |
|
|
42
|
-
| `defaultPort` | `number` | `80` | Config-level fallback below explicit `port` + argv; the scaffold passes `config.ports.backend`. It is also recorded so a programmatic `port` can update a callback that still names this default without overriding an unrelated local router ingress. |
|
|
43
|
-
| `ip` | `string` | `process.env.SERVER_IP ?? '127.0.0.1'` | Bind address. Registered so `checkOrigin` and other framework code see the resolved value. |
|
|
44
|
-
| `serveFile` | `StaticFileHandler` | none | Catch-all GET handler (Vite output, SPA `index.html`). Called for `/assets/*`, known static extensions, and the final SPA fallback. Without it the static fallback returns `404`. |
|
|
45
|
-
| `serveFavicon` | `FaviconHandler` | none | Handler for `/favicon.ico`. Without it the route returns `404`. |
|
|
46
|
-
| `customRoutes` | `CustomRouteHandler` | none | Inline custom-route hook. Composed after the registry handlers from `registerCustomRoute(...)`; first one to return `true` wins. |
|
|
47
|
-
| `enableDevTools` | `boolean` | `NODE_ENV !== 'production'` | Toggles `initConsolelog()` + devkit hot reload + REPL. Pass `false` to opt out. |
|
|
48
|
-
| `maxHttpBufferSize` | `number` | `5 * 1024 * 1024` | Forwarded to Socket.io. Raise for large payloads. |
|
|
49
|
-
| `requireDeployConfig` | `boolean` | `false` | When `true`, `verifyBootstrap` fails if no `DeployConfig` was registered. |
|
|
50
|
-
| `requireServicesConfig` | `boolean` | `false` | When `true`, `verifyBootstrap` fails if no `ServicesConfig` was registered (router topology). |
|
|
51
|
-
| `requireOAuthProviders` | `boolean` | `false` | When `true`, `verifyBootstrap` fails if only the default `credentials` provider is registered. |
|
|
52
|
-
| `loadGeneratedMaps` | `(preset: string) => Promise<unknown>` | none | Callback that resolves a generated runtime-maps module per preset. Triggers `registerProdRuntimeMapsProvider` internally. Required because dynamic-import resolution is module-scoped — the framework cannot resolve a relative path on the consumer's behalf. |
|
|
53
|
-
| `runtimeMapsPreset` | `string \| string[]` | argv -> `'default'` | Overrides the argv-derived preset list. |
|
|
54
|
-
|
|
55
|
-
**Returns:** `RunningLuckyStackServer`:
|
|
56
|
-
|
|
57
|
-
```typescript
|
|
58
|
-
interface RunningLuckyStackServer {
|
|
59
|
-
httpServer: http.Server;
|
|
60
|
-
ioServer: socket.io.Server;
|
|
61
|
-
listen: (callback?: () => void) => Promise<http.Server>;
|
|
62
|
-
}
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
`listen()` resolves when the HTTP server is bound. The successful `httpServer.address().port` is registered, advertised to dev tooling, and logged, so `port: 0` reports the OS-assigned port. A busy port auto-increments by default in development (disable with `SERVER_PORT_AUTO_INCREMENT=0`) and fails by default in production. It never retries beyond `65535`. It is safe to call once; calling twice yields a Node `ERR_SERVER_ALREADY_LISTEN` from the underlying `httpServer.listen`.
|
|
66
|
-
|
|
67
|
-
**Behavior (execution order):**
|
|
68
|
-
|
|
69
|
-
- Auto-register prod runtime-maps provider if `loadGeneratedMaps` is set.
|
|
70
|
-
- Run `verifyBootstrap` (may throw).
|
|
71
|
-
- Resolve + validate port precedence and ip; call `registerBindAddress({ ip, port })` for the intended address.
|
|
72
|
-
- If dev-tools enabled: load console initializer + devkit (`initializeAll`, `setupWatchers`); attach SIGINT / SIGTERM force-exit handlers.
|
|
73
|
-
- `await writeBootUuid()`.
|
|
74
|
-
- Create `http.Server` whose request handler is `handleHttpRequest(req, res, options)` (see `request-pipeline.md`).
|
|
75
|
-
- `loadSocket(httpServer, { maxHttpBufferSize })` attaches the Socket.io server.
|
|
76
|
-
- Return `{ httpServer, ioServer, listen }` without listening.
|
|
77
|
-
|
|
78
|
-
**Errors / Edge cases:**
|
|
79
|
-
|
|
80
|
-
- Throws (from `verifyBootstrap`) when a required registry is missing — single multi-line `Error`.
|
|
81
|
-
- Dev-only branch dynamically imports `@luckystack/devkit`; production bundles exclude it so the import is never reached when `NODE_ENV === 'production'`.
|
|
82
|
-
- `enableDevTools: true` in production loads devkit; this is supported but unusual.
|
|
83
|
-
- SIGINT / SIGTERM handlers are installed only when dev-tools branch runs.
|
|
84
|
-
- `writeBootUuid` failures bubble — boot will abort if Redis is unreachable at start.
|
|
85
|
-
|
|
86
|
-
**Example — direct boot (skip overlay):**
|
|
87
|
-
|
|
88
|
-
```typescript
|
|
89
|
-
import 'dotenv/config';
|
|
90
|
-
import './config';
|
|
91
|
-
import './deploy.config';
|
|
92
|
-
import { initializeSentry } from '@luckystack/error-tracking';
|
|
93
|
-
import { registerPresenceHooks } from '@luckystack/presence';
|
|
94
|
-
import { createLuckyStackServer } from '@luckystack/server';
|
|
95
|
-
import { serveFile, serveFavicon } from './prod/serveFile';
|
|
96
|
-
|
|
97
|
-
initializeSentry();
|
|
98
|
-
registerPresenceHooks();
|
|
99
|
-
|
|
100
|
-
const server = await createLuckyStackServer({
|
|
101
|
-
serveFile,
|
|
102
|
-
serveFavicon,
|
|
103
|
-
loadGeneratedMaps: (preset) => import(`./prod/generatedApis.${preset}`),
|
|
104
|
-
requireDeployConfig: true,
|
|
105
|
-
});
|
|
106
|
-
await server.listen();
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
---
|
|
110
|
-
|
|
111
|
-
### `afterListen(task, options?): Promise<void>`
|
|
112
|
-
|
|
113
|
-
Runs work that belongs **after** the server is accepting traffic — durable queue
|
|
114
|
-
workers, warm-up passes, reconciliation jobs.
|
|
115
|
-
|
|
116
|
-
```typescript
|
|
117
|
-
await server.listen();
|
|
118
|
-
|
|
119
|
-
await server.afterListen(async () => {
|
|
120
|
-
const { startWorkers } = await import('../src/jobs/workers');
|
|
121
|
-
await startWorkers();
|
|
122
|
-
}, { label: 'job workers' });
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
**Why it exists.** The scaffolded `server/server.ts` wraps its boot in an IIFE
|
|
126
|
-
whose `.catch` calls `process.exit(1)`. That is right for everything *before*
|
|
127
|
-
`listen()` — a failure there means there is no server. Appending post-listen work
|
|
128
|
-
to the same chain silently extends "fatal" to tasks that run while the server is
|
|
129
|
-
already serving, so one unreachable dependency (a queue's database, say) kills a
|
|
130
|
-
healthy process. Behind a supervisor that becomes a crash-loop: restart,
|
|
131
|
-
dependency still down, repeat — while the log claims `failed to start` about a
|
|
132
|
-
server that started fine.
|
|
133
|
-
|
|
134
|
-
| Option | Default | Effect |
|
|
135
|
-
|---|---|---|
|
|
136
|
-
| `fatal` | `false` | `true` re-throws instead of swallowing. Use only when the process genuinely has no purpose without the task. |
|
|
137
|
-
| `label` | `'post-listen task'` | Names the task in the failure log. |
|
|
138
|
-
|
|
139
|
-
Never resolves to the task's value — it is fire-and-report, not a pipeline stage.
|
|
140
|
-
Read whatever you need inside the callback.
|
|
141
|
-
|
|
142
|
-
---
|
|
143
|
-
|
|
144
|
-
### `bootstrapLuckyStack(options?: BootstrapLuckyStackOptions): Promise<RunningLuckyStackServer>`
|
|
145
|
-
|
|
146
|
-
**Signature:**
|
|
147
|
-
|
|
148
|
-
```typescript
|
|
149
|
-
export const bootstrapLuckyStack = async (
|
|
150
|
-
options: BootstrapLuckyStackOptions = {}
|
|
151
|
-
): Promise<RunningLuckyStackServer>;
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
**Parameters:** extends `CreateLuckyStackServerOptions` with:
|
|
155
|
-
|
|
156
|
-
| Field | Type | Default | Purpose |
|
|
157
|
-
| --- | --- | --- | --- |
|
|
158
|
-
| `overlayRoot` | `string` | `'luckystack'` | Folder name (relative to `ROOT_DIR` from `@luckystack/core`) that holds the per-package overlay files. Absolute paths are honored as-is. |
|
|
159
|
-
| `skipOverlayLoad` | `boolean` | `false` | Skip auto-loading the overlay folder. Useful for tests or hand-built registries. |
|
|
160
|
-
|
|
161
|
-
**Returns:** identical to `createLuckyStackServer`.
|
|
162
|
-
|
|
163
|
-
**Behavior:**
|
|
164
|
-
|
|
165
|
-
- If `skipOverlayLoad !== true` and `<ROOT_DIR>/<overlayRoot>` exists, walk the canonical package order (`core`, `deploy`, `login`, `email`, `sentry`, `presence`, `cron`, `docs-ui`, `server`).
|
|
166
|
-
- Inside each package folder: import `index.ts`/`index.js` first if present, then every remaining `*.ts` / `*.js` file in alphabetical order. Each file is responsible for its own side-effect registration.
|
|
167
|
-
- Then delegate to `createLuckyStackServer(options)`.
|
|
168
|
-
|
|
169
|
-
**Errors / Edge cases:**
|
|
170
|
-
|
|
171
|
-
- Missing `<overlayRoot>` folder is non-fatal — the function returns silently so projects can use the legacy single-file `config.ts` layout.
|
|
172
|
-
- Missing per-package subfolders (e.g. no `luckystack/docs-ui/`) are silently skipped.
|
|
173
|
-
- Import errors from overlay files bubble up unchanged; fix the failing module.
|
|
174
|
-
|
|
175
|
-
**Example — recommended scaffolded boot:**
|
|
176
|
-
|
|
177
|
-
```typescript
|
|
178
|
-
import '@luckystack/server/parseArgv';
|
|
179
|
-
import 'dotenv/config';
|
|
180
|
-
import './config';
|
|
181
|
-
import './deploy.config';
|
|
182
|
-
import { bootstrapLuckyStack, verifyBootstrap } from '@luckystack/server';
|
|
183
|
-
import { serveFile, serveFavicon } from './prod/serveFile';
|
|
184
|
-
|
|
185
|
-
const server = await bootstrapLuckyStack({
|
|
186
|
-
serveFile,
|
|
187
|
-
serveFavicon,
|
|
188
|
-
loadGeneratedMaps: (preset) => import(`./prod/generatedApis.${preset}`),
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
await verifyBootstrap({
|
|
192
|
-
requireDeployConfig: true,
|
|
193
|
-
requireOAuthProviders: true,
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
await server.listen();
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
---
|
|
200
|
-
|
|
201
|
-
### `verifyBootstrap(requirements?: BootstrapRequirements): Promise<void>`
|
|
202
|
-
|
|
203
|
-
**Signature:**
|
|
204
|
-
|
|
205
|
-
```typescript
|
|
206
|
-
export const verifyBootstrap = async (
|
|
207
|
-
requirements: BootstrapRequirements = {},
|
|
208
|
-
): Promise<void>;
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
**Parameters:**
|
|
212
|
-
|
|
213
|
-
| Field | Type | Default | Purpose |
|
|
214
|
-
| --- | --- | --- | --- |
|
|
215
|
-
| `requireDeployConfig` | `boolean` | `false` | Fail unless `registerDeployConfig` has been called. |
|
|
216
|
-
| `requireServicesConfig` | `boolean` | `false` | Fail unless `registerServicesConfig` has been called. |
|
|
217
|
-
| `requireOAuthProviders` | `boolean` | `false` | Fail unless an OAuth provider beyond the default `credentials` entry has been registered. |
|
|
218
|
-
|
|
219
|
-
**Returns:** `Promise<void>` — resolves silently when every required registry is in place.
|
|
220
|
-
|
|
221
|
-
**Behavior:**
|
|
222
|
-
|
|
223
|
-
- Builds a `missing[]` array. Checks in this order:
|
|
224
|
-
1. `isProjectConfigRegistered()` — always required.
|
|
225
|
-
2. `isDeployConfigRegistered()` — only when `requireDeployConfig`.
|
|
226
|
-
3. `isServicesConfigRegistered()` (lazy import) — only when `requireServicesConfig`.
|
|
227
|
-
4. `getOAuthProviders().length > 1` (lazy import from `@luckystack/login`) — only when `requireOAuthProviders`. Default registry length is `1` (`credentials`).
|
|
228
|
-
5. `isRuntimeMapsProviderRegistered()` — hard-fail in production, loud `getLogger().warn(...)` in dev/test.
|
|
229
|
-
6. `isLocalizedNormalizerRegistered()` — hard-fail in production, warn in dev/test.
|
|
230
|
-
- If `missing.length === 0`: return. Otherwise throw a single `Error` whose message lists every missing registration with a one-line remediation hint and points at `docs/ARCHITECTURE_PACKAGING.md`.
|
|
231
|
-
|
|
232
|
-
**Errors / Edge cases:**
|
|
233
|
-
|
|
234
|
-
- Throws synchronously after building the missing list. No partial-state recovery.
|
|
235
|
-
- The `RuntimeMapsProvider` warning in dev is intentional: devkit hot-reload normally registers one, so missing it during a bare boot is informational, not fatal.
|
|
236
|
-
- The `LocalizedNormalizer` warning in dev means error responses will surface the raw `errorCode` string instead of localized copy.
|
|
237
|
-
|
|
238
|
-
**Example:**
|
|
239
|
-
|
|
240
|
-
```typescript
|
|
241
|
-
import { verifyBootstrap } from '@luckystack/server';
|
|
242
|
-
|
|
243
|
-
await verifyBootstrap({
|
|
244
|
-
requireDeployConfig: true,
|
|
245
|
-
requireServicesConfig: false,
|
|
246
|
-
requireOAuthProviders: true,
|
|
247
|
-
});
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
## Hook payload types
|
|
251
|
-
|
|
252
|
-
Module augmentation in `hookPayloads.ts` extends `@luckystack/core`'s `HookPayloads` interface, so `dispatchHook` / `registerHook` accept these names with the correct payload typing.
|
|
253
|
-
|
|
254
|
-
| Hook name | Payload type | When it fires |
|
|
255
|
-
| --- | --- | --- |
|
|
256
|
-
| `onSocketConnect` | `OnSocketConnectPayload` (`{ socketId, token, ip }`) | Notification: every successful Socket.io connection. |
|
|
257
|
-
| `onSocketDisconnect` | `OnSocketDisconnectPayload` (`{ socketId, token, reason }`) | Notification: every Socket.io disconnect (with reason string). |
|
|
258
|
-
| `preRoomJoin` | `PreRoomJoinPayload` (`{ token, room }`) | Before the user joins a room. May stop with `HookStopSignal`. |
|
|
259
|
-
| `postRoomJoin` | `PostRoomJoinPayload` (`{ token, room, allRooms }`) | After a successful join. |
|
|
260
|
-
| `preRoomLeave` | `PreRoomLeavePayload` (`{ token, room }`) | Before the user leaves a room. May stop. |
|
|
261
|
-
| `postRoomLeave` | `PostRoomLeavePayload` (`{ token, room, allRooms }`) | After a successful leave. |
|
|
262
|
-
| `onLocationUpdate` | `OnLocationUpdatePayload` (`{ token, oldLocation?, newLocation }`) | When the presence layer reports a location change. |
|
|
263
|
-
|
|
264
|
-
Naming convention (also documented in `hookPayloads.ts`):
|
|
265
|
-
|
|
266
|
-
- `on*` — pure notifications; return values are ignored.
|
|
267
|
-
- `pre*` — may return a `HookStopSignal` (`{ errorCode: string; httpStatus?: number }`) to abort the main flow.
|
|
268
|
-
- `post*` — fire after the side-effect succeeds.
|
|
269
|
-
|
|
270
|
-
## Types
|
|
271
|
-
|
|
272
|
-
```typescript
|
|
273
|
-
export interface CreateLuckyStackServerOptions { /* see table above */ }
|
|
274
|
-
|
|
275
|
-
export interface BootstrapLuckyStackOptions extends CreateLuckyStackServerOptions {
|
|
276
|
-
overlayRoot?: string;
|
|
277
|
-
skipOverlayLoad?: boolean;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export interface BootstrapRequirements {
|
|
281
|
-
requireDeployConfig?: boolean;
|
|
282
|
-
requireServicesConfig?: boolean;
|
|
283
|
-
requireOAuthProviders?: boolean;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
export interface RunningLuckyStackServer {
|
|
287
|
-
httpServer: http.Server;
|
|
288
|
-
ioServer: socket.io.Server;
|
|
289
|
-
listen: (callback?: () => void) => Promise<http.Server>;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export interface RouteContext {
|
|
293
|
-
routePath: string;
|
|
294
|
-
method: string;
|
|
295
|
-
queryString: string | undefined;
|
|
296
|
-
token: string | null;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
export type StaticFileHandler = (req: IncomingMessage, res: ServerResponse) => unknown;
|
|
300
|
-
export type FaviconHandler = (res: ServerResponse) => unknown;
|
|
301
|
-
export type CustomRouteHandler = (
|
|
302
|
-
req: IncomingMessage,
|
|
303
|
-
res: ServerResponse,
|
|
304
|
-
ctx: RouteContext,
|
|
305
|
-
) => Promise<boolean> | boolean;
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
## Config keys consumed at boot
|
|
309
|
-
|
|
310
|
-
| Source | Key | Effect |
|
|
311
|
-
| --- | --- | --- |
|
|
312
|
-
| config/argv | `config.ports.ts` backend / positional `[port]` | Static consumer default and per-boot override; no environment port bridge. |
|
|
313
|
-
| env | `SERVER_IP` | IP fallback. |
|
|
314
|
-
| env | `NODE_ENV` | Switches `enableDevTools` default, dynamic-import branch for devkit, and `verifyBootstrap` warn-vs-throw for runtime-maps / localized normalizer. |
|
|
315
|
-
| env | `SECURE` | When `'true'`, session cookies are emitted with the `Secure;` flag (see `request-pipeline.md`). |
|
|
316
|
-
| config | `projectConfig.logging.socketStartup` / `projectConfig.logging.devLogs` | Gates the "Server is running on ..." log line emitted by `listen()`. |
|
|
317
|
-
| config | `projectConfig.http.*` | Route paths + cookie + CORS + stream settings consumed at request time (not at boot). |
|
|
318
|
-
| argv | `<bundles> [port]` | Parsed by `applyServerArgv()` and surfaced via `getParsedBundles()` / `getParsedPort()`. |
|
|
319
|
-
|
|
320
|
-
## Related
|
|
321
|
-
|
|
322
|
-
- Function INDEX: `packages/server/CLAUDE.md`
|
|
323
|
-
- README (consumer quickstart): `packages/server/README.md`
|
|
324
|
-
- Argv parsing: `packages/server/docs/argv-parsing.md`
|
|
325
|
-
- Runtime maps: `packages/server/docs/runtime-maps.md`
|
|
326
|
-
- Request pipeline: `packages/server/docs/request-pipeline.md`
|
|
327
|
-
- HTTP routes: `packages/server/docs/http-routes.md`
|
|
328
|
-
- Security: `packages/server/docs/security-defaults.md`
|
|
329
|
-
- Architecture: `docs/ARCHITECTURE_PACKAGING.md`, `docs/ARCHITECTURE_SOCKET.md`, `docs/HOSTING.md`
|
|
5
|
+
Shutdown hooks are best-effort opportunities to flush or drain dependencies, not a veto of termination. Production route maps must not depend on development-only tooling. For types, options and lifecycle implementation see `../src/types.ts`, `../src/afterListen.ts`, `../src/stopServer.ts` and `./runtime-maps.md`.
|
package/docs/http-routes.md
CHANGED
|
@@ -1,321 +1,5 @@
|
|
|
1
|
-
# HTTP
|
|
1
|
+
# HTTP route ownership
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The server coordinates framework auth, API/sync, health, uploads and consumer routes behind one origin and CSRF boundary. A custom route must not silently bypass those protections; an origin-exempt signed webhook must validate its own signature. A static/SPA fallback must not mask errors from state-changing routes. Source: `../src/httpHandler.ts`, `../src/httpRoutes/`, `../src/customRoutesRegistry.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
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. `handleAuthEmailCodeRoute` — `/auth/api/email-code/request|verify` (ADR 0024; BEFORE the `/auth/api/*` catch-all)
|
|
22
|
-
3. `handleAuthTwoFactorRoute` — `/auth/api/2fa` + `/auth/api/2fa/*` (ADR 0024; BEFORE the `/auth/api/*` catch-all)
|
|
23
|
-
4. `handleAuthApiRoute` — `/auth/api/*`
|
|
24
|
-
5. `handleAuthCallbackRoute` — `/auth/callback/*`
|
|
25
|
-
6. `handleApiRoute` — `/api/*` (delegates to `@luckystack/api`)
|
|
26
|
-
7. `handleSyncRoute` — `/sync/*` (delegates to `@luckystack/sync`)
|
|
27
|
-
8. `handleCustomRoutes` — every handler from `registerCustomRoute(...)` + `options.customRoutes`
|
|
28
|
-
9. `handleStaticAndSpaFallback` — `/assets/*`, known extensions, SPA `index.html`
|
|
29
|
-
|
|
30
|
-
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.
|
|
31
|
-
|
|
32
|
-
## Framework route reference
|
|
33
|
-
|
|
34
|
-
| Route | Method(s) | Handler | Response (success) | Response (failure) |
|
|
35
|
-
| --- | --- | --- | --- | --- |
|
|
36
|
-
| `/auth/csrf` | GET | `handleCsrfRoute` | `200 { status: 'success', csrfToken }`; without login it also sets the double-submit cookie | `401 auth.unauthenticated` only when login is installed and no valid session exists |
|
|
37
|
-
| `/favicon.ico` | GET | `handleFaviconRoute` | Whatever `options.serveFavicon(res)` writes | `404` empty when no handler supplied |
|
|
38
|
-
| `/livez` | GET | `handleLivezRoute` | `200 { status: 'live' }` | Always 200 when reachable |
|
|
39
|
-
| `/readyz` | GET | `handleReadyzRoute` | `200 { status: 'ready', checks: {...} }` | `503 { status: 'not-ready', checks: {...} }` |
|
|
40
|
-
| `/_health` | GET | `handleHealthRoute` | `200 { status: 'ok', bootUuid, envKey, synchronizedHashes }` | `503 { status: 'degraded', ... }` when boot UUID is missing |
|
|
41
|
-
| `/_test/reset` | POST | `handleTestResetRoute` | `200 { status: 'success', cleared: string[] }` | `404 notFound` or `403 auth.forbidden` — see `security-defaults.md` |
|
|
42
|
-
| `/uploads/*` | GET | `handleUploadsRoute` | Avatar bytes via `@luckystack/core` `serveAvatar` | `404` from `serveAvatar` when missing |
|
|
43
|
-
| `/auth/api/email-code/request` | POST | `handleAuthEmailCodeRoute` | `200 { status: true }` (anti-enumeration: also for unknown addresses) | `200 { status:false, reason }`, `429` per-IP |
|
|
44
|
-
| `/auth/api/email-code/verify` | POST | `handleAuthEmailCodeRoute` | Login envelope + session transport, or 2FA-challenge envelope | `200 { status:false, reason }`, `429` per-IP |
|
|
45
|
-
| `/auth/api/2fa` | POST | `handleAuthTwoFactorRoute` | Login envelope + session transport (completes the challenge) | `200 { status:false, reason }`, `429` per-IP |
|
|
46
|
-
| `/auth/api/2fa/email-code` | POST | `handleAuthTwoFactorRoute` | `200 { status: true }` (fallback code sent) | `200 { status:false, reason }`, `429` per-IP |
|
|
47
|
-
| `/auth/api/2fa/setup\|enable\|disable\|recovery-codes` | POST (authed) | `handleAuthTwoFactorRoute` | `200 { status:true, secret/otpauthUri \| recoveryCodes[] }` | `401 api.unauthorized` without session; `200 { status:false, reason }` |
|
|
48
|
-
| `/auth/api/*` | POST | `handleAuthApiRoute` | OAuth redirect (`302`) or credentials login envelope (may carry `requiresTwoFactor` + `challengeToken`) | Rate-limited envelope, `200` envelope with `status:false, reason` |
|
|
49
|
-
| `/auth/callback/*` | GET | `handleAuthCallbackRoute` | `302` to `redirectUrl` with session cookie or token | `401 'Login failed'` plain text |
|
|
50
|
-
| `/api/*` | GET / POST / PUT / DELETE | `handleApiRoute` | JSON envelope from `@luckystack/api` `handleHttpApiRequest`, or SSE stream | `400 api.invalidName`, `500 api.invalidRequestFormat` |
|
|
51
|
-
| `/sync/*` | POST | `handleSyncRoute` | JSON envelope from `@luckystack/sync` `handleHttpSyncRequest`, or SSE stream | `405 sync.methodNotAllowed`, `400 sync.invalidName`, `500 sync.invalidRequestFormat` |
|
|
52
|
-
| `/assets/*`, `*.{png,jpg,jpeg,gif,svg,html,css,js}` | GET | `handleStaticAndSpaFallback` | Whatever `options.serveFile` writes (URL temporarily rewritten) | `404` when no `serveFile` is wired |
|
|
53
|
-
| Any other extension-less path | GET | `handleStaticAndSpaFallback` | SPA fallback (rewrite URL to `/`, call `serveFile`) | `404` when no `serveFile` |
|
|
54
|
-
|
|
55
|
-
> `GET /auth/csrf` issues the token; state-changing requests send it using `getCsrfConfig().headerName` (default `x-csrf-token`). `registerCsrfConfig(...)` also controls the login-absent cookie name/options and token length.
|
|
56
|
-
>
|
|
57
|
-
> `/_test/reset` clears rate limits and only the framework's default Redis session/active-user namespaces. It does not clear application database rows or invoke a custom `SessionAdapter`; custom-storage tests own that cleanup.
|
|
58
|
-
|
|
59
|
-
## API Reference — handlers
|
|
60
|
-
|
|
61
|
-
All handlers conform to:
|
|
62
|
-
|
|
63
|
-
```typescript
|
|
64
|
-
type HttpRouteHandler = (ctx: HttpRouteContext) => Promise<boolean>;
|
|
65
|
-
|
|
66
|
-
interface HttpRouteContext {
|
|
67
|
-
req: IncomingMessage;
|
|
68
|
-
res: ServerResponse;
|
|
69
|
-
options: CreateLuckyStackServerOptions;
|
|
70
|
-
routePath: string;
|
|
71
|
-
queryString: string | undefined;
|
|
72
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
73
|
-
token: string | null;
|
|
74
|
-
requestId: string;
|
|
75
|
-
sessionCookieOptions: string;
|
|
76
|
-
params: object;
|
|
77
|
-
}
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
A handler returns `true` (or simply ends `res`) to short-circuit dispatch; returning `false` falls through to the next handler.
|
|
81
|
-
|
|
82
|
-
### `handleLivezRoute(ctx)`
|
|
83
|
-
|
|
84
|
-
**Behavior:**
|
|
85
|
-
|
|
86
|
-
- Matches when `routePath === projectConfig.http.liveEndpoint` (default `/livez`).
|
|
87
|
-
- Always emits `200 application/json { status: 'live' }`.
|
|
88
|
-
|
|
89
|
-
**Edge cases:** no downstream dependencies; succeeds as long as the Node event loop is responsive.
|
|
90
|
-
|
|
91
|
-
### `handleReadyzRoute(ctx)`
|
|
92
|
-
|
|
93
|
-
**Behavior:**
|
|
94
|
-
|
|
95
|
-
- Matches when `routePath === projectConfig.http.readyEndpoint` (default `/readyz`).
|
|
96
|
-
- Reads the shared `DevToolsStatus`, `readBootUuid()` (Redis), `redis.ping()`, and a database check (ADR 0020):
|
|
97
|
-
1. a consumer-registered `registerDbHealthCheck(...)` probe (from `@luckystack/core`) wins;
|
|
98
|
-
2. otherwise the built-in Prisma probe runs when Prisma is part of the install (`isPrismaClientRegistered()` or a resolvable `@prisma/client`) — provider-agnostic: tries `$queryRaw\`SELECT 1\`` first, then `$runCommandRaw({ ping: 1 })` (detecting the active provider via private fields drifts between Prisma majors, so it probes by capability);
|
|
99
|
-
3. otherwise the check reports `'skipped'` — a deliberately DB-less project (`orm: 'none'`) can still go ready.
|
|
100
|
-
- Returns `200 { status: 'ready', checks }` when devkit did not fail, boot UUID + Redis pass, and the database check is not `false`; `503 { status: 'not-ready', ... }` otherwise. `checks.devTools` is `true` before/after a successful initialization and `false` after a recorded fatal failure. `checks.database` carries the tri-state (`true | false | 'skipped'`); `checks.prisma` is kept for backward compatibility (`true` when the database check passed or was skipped).
|
|
101
|
-
|
|
102
|
-
**Edge cases:**
|
|
103
|
-
|
|
104
|
-
- A recorded devkit error forces 503 even when Redis/database probes pass, so an incomplete runtime cannot advertise readiness just because infrastructure is green.
|
|
105
|
-
- `redis.ping()` errors are swallowed and surface as `redis: false`.
|
|
106
|
-
- A Prisma client with neither raw method available results in `prismaOk = false`.
|
|
107
|
-
|
|
108
|
-
### `handleHealthRoute(ctx)`
|
|
109
|
-
|
|
110
|
-
**Behavior:**
|
|
111
|
-
|
|
112
|
-
- Matches when `routePath === projectConfig.http.healthEndpoint` (default `/_health`).
|
|
113
|
-
- Reads `bootUuid`, `resolveEnvKey()`, and `computeSynchronizedEnvHashes()` from `@luckystack/core`.
|
|
114
|
-
- Returns `200 { status: 'ok', bootUuid, envKey, synchronizedHashes }` when boot UUID is present, otherwise `503 { status: 'degraded', ... }`.
|
|
115
|
-
- Consumed by `@luckystack/router` to verify shared-Redis topology after a rolling restart.
|
|
116
|
-
|
|
117
|
-
### `handleCsrfRoute(ctx)`
|
|
118
|
-
|
|
119
|
-
**Behavior:**
|
|
120
|
-
|
|
121
|
-
- Matches `routePath === '/auth/csrf'`.
|
|
122
|
-
- Requires a session token (cookie or `Authorization`). Returns `401 auth.unauthenticated` when missing or when `getSession(token)` does not resolve a valid session.
|
|
123
|
-
- On success returns `200 { status: 'success', csrfToken }`.
|
|
124
|
-
|
|
125
|
-
**Note:** the matching middleware is `enforceCsrfOnStateChangingRequest` (see `request-pipeline.md`). This route only issues tokens.
|
|
126
|
-
|
|
127
|
-
### `handleFaviconRoute(ctx)`
|
|
128
|
-
|
|
129
|
-
**Behavior:**
|
|
130
|
-
|
|
131
|
-
- Matches `routePath === '/favicon.ico'`.
|
|
132
|
-
- Delegates to `options.serveFavicon(res)`. Without that option, returns an empty `404`.
|
|
133
|
-
|
|
134
|
-
### `handleUploadsRoute(ctx)`
|
|
135
|
-
|
|
136
|
-
**Behavior:**
|
|
137
|
-
|
|
138
|
-
- Matches every path starting with `/uploads/`.
|
|
139
|
-
- Delegates to `serveAvatar({ routePath, res })` from `@luckystack/core`. Response (and `404` on miss) is owned by `serveAvatar`.
|
|
140
|
-
|
|
141
|
-
### `handleAuthApiRoute(ctx)`
|
|
142
|
-
|
|
143
|
-
**Behavior:**
|
|
144
|
-
|
|
145
|
-
- Matches `routePath` starting with `/auth/api`.
|
|
146
|
-
- Looks up the provider via `getOAuthProviders().find((p) => p.name === routePath.split('/')[3])`.
|
|
147
|
-
- For full OAuth providers (`isFullOAuthProvider(provider)`):
|
|
148
|
-
- `createOAuthState(provider.name)` produces a CSRF state token; failure returns `500 login.oauthStateInitFailed`.
|
|
149
|
-
- Builds the provider's authorization URL with `client_id`, `redirect_uri`, `scope`, `response_type=code`, `prompt=select_account`, `state`, and emits a `302`.
|
|
150
|
-
- For credentials provider:
|
|
151
|
-
- 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 }] }`.
|
|
152
|
-
- 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`.
|
|
153
|
-
- Final envelope: `{ status, reason, session, authenticated }`.
|
|
154
|
-
|
|
155
|
-
**Errors:** unknown provider -> `{ status: false, reason: 'login.providerNotFound' }`; thrown errors bubble to the outer `httpHandler` error path.
|
|
156
|
-
|
|
157
|
-
### `handleAuthCallbackRoute(ctx)`
|
|
158
|
-
|
|
159
|
-
**Behavior:**
|
|
160
|
-
|
|
161
|
-
- Matches `routePath` starting with `/auth/callback`.
|
|
162
|
-
- Computes `baseLocation = projectConfig.app.publicUrl || '/'` (the public origin where users browse; the OAuth callback is handled on the backend origin but redirects the browser back here). `||` so an empty `publicUrl` falls through to `/`.
|
|
163
|
-
- Delegates to `loginCallback(routePath, req, res, { defaultRedirectUrl })` from `@luckystack/login`.
|
|
164
|
-
- On failure: `401 'Login failed'`.
|
|
165
|
-
- 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.
|
|
166
|
-
|
|
167
|
-
### `handleApiRoute(ctx)`
|
|
168
|
-
|
|
169
|
-
**Behavior:**
|
|
170
|
-
|
|
171
|
-
- Matches every path starting with `/api/`.
|
|
172
|
-
- 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', ...)`.
|
|
173
|
-
- Strips `/api/` prefix; empty name -> `400 api.invalidName` (or SSE `final` event with same payload).
|
|
174
|
-
- 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.
|
|
175
|
-
- Emits the result (`event: final` for SSE, or `JSON.stringify(result)` + `res.writeHead(result.httpStatus)` for non-SSE).
|
|
176
|
-
|
|
177
|
-
**Errors:** any thrown error is logged + captured by the registered error tracker(s), `apiError` hook dispatched, and the envelope `500 api.invalidRequestFormat` is returned (over SSE or JSON).
|
|
178
|
-
|
|
179
|
-
### `handleSyncRoute(ctx)`
|
|
180
|
-
|
|
181
|
-
**Behavior:**
|
|
182
|
-
|
|
183
|
-
- Matches every path starting with `/sync/`.
|
|
184
|
-
- Only `POST` is allowed; other methods return `405 sync.methodNotAllowed`.
|
|
185
|
-
- Normalizes params into `{ data, receiver, ignoreSelf?, cb? }`. Empty `receiver` is allowed (`''`).
|
|
186
|
-
- Calls `handleHttpSyncRequest({ name: 'sync/<rest>', cb, data, receiver, ignoreSelf, token, requesterIp, xLanguageHeader, acceptLanguageHeader, stream? })` from `@luckystack/sync`.
|
|
187
|
-
- SSE handling mirrors `handleApiRoute`.
|
|
188
|
-
|
|
189
|
-
**Errors:** mirrors `handleApiRoute` — logs, registered error tracker(s), `syncError` hook, returns `500 sync.invalidRequestFormat`.
|
|
190
|
-
|
|
191
|
-
### `handleCustomRoutes(ctx)`
|
|
192
|
-
|
|
193
|
-
**Behavior:**
|
|
194
|
-
|
|
195
|
-
- Iterates every handler registered via `registerCustomRoute(...)` in registration order; first one to return `true` or end the response wins.
|
|
196
|
-
- If none handled, falls back to the inline `options.customRoutes` if provided.
|
|
197
|
-
- 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).
|
|
198
|
-
|
|
199
|
-
### `handleStaticAndSpaFallback(ctx)`
|
|
200
|
-
|
|
201
|
-
**Behavior:**
|
|
202
|
-
|
|
203
|
-
- `/assets/*` paths: matched via `startsWith('/assets/')`; the (percent-decoded) routePath is rejected with `404` when it contains `..` (path-traversal guard at the framework boundary), then `req.url` is temporarily rewritten to the routePath, `options.serveFile` is called, and `req.url` restored. Without `serveFile`, returns `404 Not Found`.
|
|
204
|
-
- 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.
|
|
205
|
-
- Other paths that have an extension `path.extname(routePath)`: `404 Not Found` (text/plain).
|
|
206
|
-
- Extensionless paths (SPA routes): rewrite `req.url` to `/`, call `serveFile`. Without `serveFile`, returns `404 Not Found`.
|
|
207
|
-
|
|
208
|
-
**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.
|
|
209
|
-
|
|
210
|
-
## Custom-route registry
|
|
211
|
-
|
|
212
|
-
```typescript
|
|
213
|
-
export const registerCustomRoute: (handler: CustomRouteHandler) => void;
|
|
214
|
-
export const getCustomRoutes: () => readonly CustomRouteHandler[];
|
|
215
|
-
export const clearCustomRoutes: () => void;
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
- `registerCustomRoute(handler)` — appends to an in-memory array. Called by overlay packages (`@luckystack/docs-ui` mounts `/_docs` this way).
|
|
219
|
-
- `getCustomRoutes()` — read snapshot; iterated by `handleCustomRoutes`.
|
|
220
|
-
- `clearCustomRoutes()` — empty the array; used by `@luckystack/test-runner`'s reset flow.
|
|
221
|
-
|
|
222
|
-
The handler signature:
|
|
223
|
-
|
|
224
|
-
```typescript
|
|
225
|
-
type CustomRouteHandler = (
|
|
226
|
-
req: IncomingMessage,
|
|
227
|
-
res: ServerResponse,
|
|
228
|
-
ctx: RouteContext,
|
|
229
|
-
) => Promise<boolean> | boolean;
|
|
230
|
-
|
|
231
|
-
interface RouteContext {
|
|
232
|
-
routePath: string;
|
|
233
|
-
method: string;
|
|
234
|
-
queryString: string | undefined;
|
|
235
|
-
token: string | null;
|
|
236
|
-
}
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
Return `true` (or end the response) when handled. Throws are caught and emit `500 server.customRouteFailed`.
|
|
240
|
-
|
|
241
|
-
**Example — webhook receiver:**
|
|
242
|
-
|
|
243
|
-
```typescript
|
|
244
|
-
import { registerCustomRoute } from '@luckystack/server';
|
|
245
|
-
|
|
246
|
-
registerCustomRoute(async (req, res, ctx) => {
|
|
247
|
-
if (ctx.routePath !== '/webhooks/stripe') return false;
|
|
248
|
-
if (ctx.method !== 'POST') {
|
|
249
|
-
res.writeHead(405);
|
|
250
|
-
res.end();
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
// verify signature, process body ...
|
|
254
|
-
res.writeHead(200);
|
|
255
|
-
res.end('ok');
|
|
256
|
-
return true;
|
|
257
|
-
});
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
**Example — health alias for an external monitor:**
|
|
261
|
-
|
|
262
|
-
```typescript
|
|
263
|
-
registerCustomRoute(async (_req, res, ctx) => {
|
|
264
|
-
if (ctx.routePath !== '/healthz') return false;
|
|
265
|
-
res.statusCode = 200;
|
|
266
|
-
res.end('ok');
|
|
267
|
-
return true;
|
|
268
|
-
});
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
## SSE streaming
|
|
272
|
-
|
|
273
|
-
`/api/*` and `/sync/*` both support Server-Sent Events when the client opts in. Helpers live in `packages/server/src/sse.ts`:
|
|
274
|
-
|
|
275
|
-
| Helper | Behavior |
|
|
276
|
-
| --- | --- |
|
|
277
|
-
| `shouldUseHttpStream({ acceptHeader, queryString })` | Returns `true` if `Accept` includes `text/event-stream` OR the query string contains `<projectConfig.http.stream.queryParam>=<enabledValue>` (or `=1`). |
|
|
278
|
-
| `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. |
|
|
279
|
-
| `sendSseEvent({ res, event, data })` | Writes `event: <event>\ndata: <json>\n\n`. No-op once `res.writableEnded`. |
|
|
280
|
-
|
|
281
|
-
Lifecycle in `handleApiRoute` / `handleSyncRoute`:
|
|
282
|
-
|
|
283
|
-
1. Decide SSE vs JSON via `shouldUseHttpStream`.
|
|
284
|
-
2. If SSE, call `initSseResponse(res)` and `req.on('close', () => { streamClosed = true; })`.
|
|
285
|
-
3. Pass a `stream` callback to `handleHttpApiRequest` / `handleHttpSyncRequest` that emits `event: stream` frames while the request is processing.
|
|
286
|
-
4. On completion, emit `event: final` with the result envelope and call `res.end()`.
|
|
287
|
-
5. On error, emit `event: error` with the `500 *.invalidRequestFormat` envelope.
|
|
288
|
-
|
|
289
|
-
## Hooks dispatched
|
|
290
|
-
|
|
291
|
-
| Hook | Payload | When |
|
|
292
|
-
| --- | --- | --- |
|
|
293
|
-
| `rateLimitExceeded` | `{ scope, key, limit, windowMs, count, route, ip }` | `handleAuthApiRoute` credentials path when IP limit is exceeded. |
|
|
294
|
-
| `apiError` | `{ route, method, requestId, error }` | `handleApiRoute` outer error path. |
|
|
295
|
-
| `syncError` | `{ route, method, requestId, error }` | `handleSyncRoute` outer error path. |
|
|
296
|
-
| `csrfMismatch` | `{ route, method, requestId, userId, providedToken }` | `enforceCsrfOnStateChangingRequest` (see `request-pipeline.md`). |
|
|
297
|
-
| `preHttpRequest` | `{ method, url, requestId, origin, headers }` | Every incoming request before dispatch (see `request-pipeline.md`). |
|
|
298
|
-
|
|
299
|
-
## Config keys consumed
|
|
300
|
-
|
|
301
|
-
| Source | Key | Effect |
|
|
302
|
-
| --- | --- | --- |
|
|
303
|
-
| config | `projectConfig.http.healthEndpoint` | Default `/_health`. |
|
|
304
|
-
| config | `projectConfig.http.liveEndpoint` | Default `/livez`. |
|
|
305
|
-
| config | `projectConfig.http.readyEndpoint` | Default `/readyz`. |
|
|
306
|
-
| config | `projectConfig.http.testResetEndpoint` | Default `/_test/reset`. |
|
|
307
|
-
| config | `projectConfig.http.stream.queryParam` / `enabledValue` / `connectedComment` | SSE opt-in query parameter + initial keepalive comment. |
|
|
308
|
-
| config | `projectConfig.http.sessionCookieName` | Cookie name written by `/auth/api` / `/auth/callback`. |
|
|
309
|
-
| config | `projectConfig.session.basedToken` | Cookie vs header token transport. |
|
|
310
|
-
| config | `projectConfig.rateLimiting.defaultApiLimit` / `windowMs` | Credentials login rate limit. |
|
|
311
|
-
| config | `projectConfig.app.publicUrl` | Public origin the OAuth callback redirects the browser back to. |
|
|
312
|
-
| env | `TEST_RESET_TOKEN` | Consumed by `/_test/reset` — see `security-defaults.md`. |
|
|
313
|
-
|
|
314
|
-
## Related
|
|
315
|
-
|
|
316
|
-
- Function INDEX: `packages/server/CLAUDE.md`
|
|
317
|
-
- Request pipeline: `packages/server/docs/request-pipeline.md`
|
|
318
|
-
- Security defaults: `packages/server/docs/security-defaults.md`
|
|
319
|
-
- API delegate: `docs/ARCHITECTURE_API.md`
|
|
320
|
-
- Sync delegate: `docs/ARCHITECTURE_SYNC.md`
|
|
321
|
-
- README: `packages/server/README.md`
|
|
5
|
+
Health and test routes have different trust models: health probes need to work without browser headers, while the test reset route must fail closed in production and without its token. The reset can be scoped to named targets so an authenticated test sweep can drain rate-limit buckets without deleting its own session; the unscoped reset keeps its original meaning (ADR 0064, details in `./security-defaults.md`). Streaming requires an HTTP response lifecycle distinct from ordinary JSON, but keeps the same auth gates. See `./security-defaults.md`, `../src/httpRoutes/healthRoutes.ts`, `../src/httpRoutes/testResetRoute.ts`, `../src/sse.ts`.
|