@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,704 @@
1
+ # @spfn/core/server — HTTP server entry point (config builder + lifecycle)
2
+
3
+ The unified entry point for an SPFN backend process: build a config with
4
+ `defineServerConfig()`, then boot with `startServer()`. Handles middleware auto-wiring,
5
+ infrastructure init (DB/Redis), routes/jobs/events/websockets/workflows integration, and
6
+ AWS-drain-style graceful shutdown.
7
+
8
+ ## Import paths
9
+
10
+ There is **one** entry point:
11
+
12
+ ```typescript
13
+ import {
14
+ startServer,
15
+ createServer,
16
+ defineServerConfig,
17
+ getShutdownManager,
18
+ loadEnv,
19
+ CORE_TIME_ROUTE,
20
+ CORE_TIME_PATH,
21
+ ServerTimeResponseSchema,
22
+ } from '@spfn/core/server';
23
+
24
+ import type {
25
+ ServerConfig,
26
+ ServerInstance,
27
+ AppFactory,
28
+ ShutdownHookOptions,
29
+ ServerClock,
30
+ ServerTimeResponse,
31
+ } from '@spfn/core/server';
32
+ ```
33
+
34
+ Routes/middleware come from a **different** module — don't look for them here:
35
+
36
+ ```typescript
37
+ import { defineRouter, route, defineMiddleware } from '@spfn/core/route';
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Public API (complete)
43
+
44
+ Everything exported from `@spfn/core/server`:
45
+
46
+ - **Boot**: `startServer(config?)` → `Promise<ServerInstance>` — loads env + config file,
47
+ inits infrastructure, starts the HTTP server, registers shutdown handlers.
48
+ - **App only**: `createServer(config?)` → `Promise<Hono>` — builds the configured Hono app
49
+ without listening (for tests / custom `serve()`).
50
+ - **Config builder**: `defineServerConfig()` → `ServerConfigBuilder` (fluent, `.build()`
51
+ returns `ServerConfig`).
52
+ - **Shutdown**: `getShutdownManager()` → `ShutdownManager` singleton.
53
+ - **Env**: `loadEnv` (re-export of `@spfn/core/env/loader`). `startServer()` already calls
54
+ it internally.
55
+ - **Server time**: `CORE_TIME_ROUTE`, `CORE_TIME_PATH`, `CORE_TIME_OPERATION_ID`,
56
+ `ServerTimeResponseSchema`, `createCoreTimeRoute()` and the `ServerClock` /
57
+ `ServerTimeResponse` types — the built-in unproven clock-synchronization contract.
58
+ - **Deprecated**: `loadEnvFiles()` — alias for `loadEnv()`; use `loadEnv` instead.
59
+ - **Types**: `ServerConfig`, `ServerInstance`, `AppFactory`, `ShutdownHookOptions`.
60
+
61
+ > **Not exported from `@spfn/core/server`:** `validateServerConfig`, `printBanner`,
62
+ > `ShutdownManager` (the class), `WorkflowRouterLike`. They exist internally but are not in
63
+ > the public barrel — do not import them from `@spfn/core/server`. Use
64
+ > `getShutdownManager()` to obtain a `ShutdownManager` instance.
65
+
66
+ > The config-builder fluent methods (`.events()`, `.jobs()`, `.websockets()`,
67
+ > `.workflows()`, `.cors()`, `.middleware()`, `.use()`, `.infrastructure()`, …) are methods
68
+ > on the object returned by `defineServerConfig()` — they are **not** standalone exports.
69
+
70
+ ---
71
+
72
+ ## Quick Start
73
+
74
+ ```typescript
75
+ // src/server/server.config.ts
76
+ import { defineServerConfig } from '@spfn/core/server';
77
+ import { defineRouter, route } from '@spfn/core/route';
78
+ import { Type } from '@sinclair/typebox';
79
+
80
+ const appRouter = defineRouter({
81
+ getUser: route.get('/users/:id')
82
+ .input({ params: Type.Object({ id: Type.String() }) })
83
+ .handler(async (c) =>
84
+ {
85
+ const { params } = await c.data();
86
+ return { id: params.id, name: 'John' };
87
+ }),
88
+ });
89
+
90
+ export default defineServerConfig()
91
+ .port(4000)
92
+ .routes(appRouter)
93
+ .build();
94
+
95
+ // Re-export the router type for the typed client
96
+ export type AppRouter = typeof appRouter;
97
+ ```
98
+
99
+ ```typescript
100
+ // src/server/index.ts (process entry point)
101
+ import { startServer } from '@spfn/core/server';
102
+
103
+ await startServer();
104
+ ```
105
+
106
+ `startServer()` with no argument auto-discovers `server.config.ts` (see file-loading order
107
+ below), so the entry point usually stays this small. Pass a config object to
108
+ `startServer(config)` only to override at runtime (highest priority).
109
+
110
+ ---
111
+
112
+ ## Config builder (`defineServerConfig`)
113
+
114
+ Fluent builder; every method returns `this`; `.build()` returns a plain `ServerConfig`.
115
+ There is **no validation in the builder** — validation runs inside `startServer()`.
116
+
117
+ | Method | Sets | Notes |
118
+ |--------|------|-------|
119
+ | `.port(number)` | `port` | Default `4000` (env `PORT`) |
120
+ | `.host(string)` | `host` | Default `localhost` (env `HOST`) |
121
+ | `.routes(router)` | `routes` | Also auto-merges the router's `.use()` + `.packages()` global middlewares into `middlewares` |
122
+ | `.middlewares([named])` | `middlewares` | `NamedMiddleware[]` from `defineMiddleware()` (route-level `.skip()` targets these) |
123
+ | `.use([handlers])` | `use` | Raw `MiddlewareHandler[]`, applied `app.use('*', …)` |
124
+ | `.middleware({...})` | `middleware` | Toggle built-ins: `{ logger?, cors?, errorHandler?, onError? }` |
125
+ | `.cors(opts \| false)` | `cors` | hono/cors options, or `false` to disable |
126
+ | `.jobs(router, cfg?)` | `jobs` / `jobsConfig` | pg-boss job router (`@spfn/core/job`); `cfg` is `Omit<BossOptions, 'connectionString'>` |
127
+ | `.events(router, cfg?)` | `events` / `eventsConfig` | SSE router (`@spfn/core/event`); `cfg.path` default `/events/stream`, `cfg.auth` for token-gated streams; cross-pod fan-out auto-wires when a cache is set (`cfg.multiInstance`/`cfg.channelPrefix`) |
128
+ | `.websockets(router, cfg?)` | `websockets` / `websocketsConfig` | WS router; `cfg.path` default `/ws`, `cfg.auth` for token auth; same `multiInstance`/`channelPrefix` cross-pod knobs as `.events()` |
129
+ | `.workflows(router, cfg?)` | `workflows` / `workflowsConfig` | `@spfn/workflow` router; inits engine after DB |
130
+ | `.database({...})` | `database` | External Drizzle `provider`, or postgres.js pool / healthCheck / monitoring overrides |
131
+ | `.timeout({...})` | `timeout` | `{ request?, keepAlive?, headers? }` (ms) |
132
+ | `.shutdown({...})` | `shutdown` | `{ timeout? }` (ms) |
133
+ | `.healthCheck({...})` | `healthCheck` | `{ enabled?, path?, detailed? }` |
134
+ | `.serverTime({...})` | `serverTime` | Inject `{ clock: { now() } }` for deterministic tests; production defaults to `Date.now()` |
135
+ | `.infrastructure({...})` | `infrastructure` | `{ database?, redis? }` — `false` disables auto-init |
136
+ | `.migrations({...})` | `migrations` | `{ allowPending? }` — `true` boots with pending migrations (warn instead of refuse) |
137
+ | `.debug(boolean)` | `debug` | Default `NODE_ENV === 'development'` |
138
+ | `.lifecycle({...})` | merged | **Mergeable** — see below |
139
+ | `.build()` | — | Returns the final `ServerConfig` |
140
+
141
+ > There is **no** `.fetchTimeout()` builder method and **no** `.beforeStart()` /
142
+ > `.afterStart()` / `.beforeShutdown()` standalone builder methods. Fetch timeouts are set
143
+ > via the `fetchTimeout` field on a `ServerConfig` object (or env vars); lifecycle hooks go
144
+ > through `.lifecycle({ ... })`.
145
+
146
+ ### `.routes()` keeps the router's own middleware
147
+
148
+ Middleware a router registered with `.use()` travels with that router: `.routes(appRouter)`
149
+ records the router, and route registration applies its middleware to that router's routes
150
+ (package routers included). You usually do **not** also call `.middlewares()`:
151
+
152
+ ```typescript
153
+ const appRouter = defineRouter({ getUser, createUser })
154
+ .packages([authRouter]) // package routers keep their own .use() middleware
155
+ .use([authMiddleware]); // applied to this router's routes
156
+
157
+ export default defineServerConfig()
158
+ .routes(appRouter) // authMiddleware active on every route above
159
+ .build();
160
+ ```
161
+
162
+ A named middleware runs **at most once per route**, no matter how many registrations name
163
+ it — registering the same one at both levels (`.middlewares([authMiddleware])` *and*
164
+ `.use([authMiddleware])`) is not an error and does not run it twice. Middleware holding
165
+ one-shot state, such as a nonce replay ledger, depends on that: a second run would reject
166
+ the very request the first run accepted.
167
+
168
+ ### `.lifecycle()` is mergeable (not last-wins)
169
+
170
+ Multiple `.lifecycle()` calls accumulate; for each hook name, the collected hooks run
171
+ **sequentially in registration order**. This is the one builder method that does not
172
+ overwrite on repeat.
173
+
174
+ ```typescript
175
+ defineServerConfig()
176
+ .lifecycle({ afterInfrastructure: async () => { await runMigrations(); } })
177
+ .lifecycle({ afterInfrastructure: async () => { await seed(); } }) // runs AFTER migrations
178
+ .build();
179
+ ```
180
+
181
+ Hook signatures (`ServerConfig['lifecycle']`):
182
+
183
+ | Hook | Signature | When |
184
+ |------|-----------|------|
185
+ | `beforeInfrastructure` | `(config) => Promise<void>` | before DB/Redis init |
186
+ | `afterInfrastructure` | `() => Promise<void>` | after DB/Redis (and before jobs/workflows) |
187
+ | `beforeRoutes` | `(app: Hono) => void \| Promise<void>` | inside `createServer`, before routes |
188
+ | `afterRoutes` | `(app: Hono) => void \| Promise<void>` | inside `createServer`, after routes/SSE |
189
+ | `afterStart` | `(instance: ServerInstance) => Promise<void>` | server listening; throwing is logged, not fatal |
190
+ | `beforeShutdown` | `() => Promise<void>` | shutdown Phase 4 (DB/Redis still open) |
191
+
192
+ ---
193
+
194
+ ## `startServer` vs `createServer`
195
+
196
+ `startServer(config?)` is the full boot path and returns a `ServerInstance`:
197
+
198
+ ```typescript
199
+ const instance = await startServer({ port: 3000 });
200
+ instance.server; // Node http.Server (ReturnType<typeof serve>)
201
+ instance.app; // Hono app
202
+ instance.config; // resolved ServerConfig
203
+ await instance.close(); // graceful shutdown (same path as SIGTERM)
204
+ ```
205
+
206
+ Its startup sequence:
207
+
208
+ 1. `loadEnv()` (env files → `process.env`)
209
+ 2. Load + merge config file (see order below) with the runtime `config` argument
210
+ 3. `validateServerConfig()` — throws on bad port/timeout/shutdown/healthCheck.path
211
+ 4. `lifecycle.beforeInfrastructure` → init DB (unless disabled) → init Redis (unless
212
+ disabled) → `lifecycle.afterInfrastructure` → init pg-boss + register jobs (if `.jobs()`)
213
+ → init workflow engine (if `.workflows()`)
214
+ 5. **Migration boot gate** — refuses to go further when a function package (or
215
+ `src/server/drizzle`) has migrations the database has not applied (see below)
216
+ 6. `createServer(config)` builds the Hono app (middleware pipeline below)
217
+ 7. `serve()` starts listening; WebSocket handler attached if `.websockets()`
218
+ 8. Apply HTTP server timeouts + global `fetch()` (undici) timeouts
219
+ 9. Print banner, register process handlers (`SIGTERM`, `SIGINT`, `uncaughtException`,
220
+ `unhandledRejection`)
221
+ 10. `lifecycle.afterStart(instance)`
222
+
223
+ `createServer(config?)` only does step 6 — it returns a configured `Hono` app **without
224
+ listening** and without infrastructure/shutdown. Use it for integration tests
225
+ (`app.request('/health')`) or when you call `@hono/node-server`'s `serve()` yourself.
226
+
227
+ ### File-config loading order
228
+
229
+ `startServer()` scans these paths (first found wins), each merged **under** the runtime
230
+ `config` argument:
231
+
232
+ ```
233
+ .spfn/server/server.config.mjs (built, highest priority)
234
+ .spfn/server/server.config (built .js)
235
+ src/server/server.config (source .js)
236
+ src/server/server.config.ts (source .ts, lowest)
237
+ ```
238
+
239
+ `port`/`host` resolve as `runtime ?? file ?? env (PORT/HOST) ?? defaults (4000/localhost)`.
240
+
241
+ ### Level 3: full control with `app.ts`
242
+
243
+ If `src/server/app.ts` (or `app.js`) exists, `createServer` imports its default export (an
244
+ `AppFactory = () => Promise<Hono> | Hono`) and uses **that** app instead of the
245
+ auto-configured pipeline. Config `routes` are still registered onto your app, but the
246
+ automatic middleware/health-check/SSE wiring is **skipped** — you own it.
247
+
248
+ ```typescript
249
+ // src/server/app.ts
250
+ import { Hono } from 'hono';
251
+ import { compress } from 'hono/compress';
252
+ import type { AppFactory } from '@spfn/core/server';
253
+
254
+ export default (async () =>
255
+ {
256
+ const app = new Hono();
257
+ app.use('*', compress());
258
+ return app;
259
+ }) satisfies AppFactory;
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Auto-configured middleware pipeline
265
+
266
+ When there is no `app.ts`, `createServer` builds the app in this **fixed order**:
267
+
268
+ ```
269
+ 1. errorHandlerEnabled flag (if middleware.errorHandler !== false)
270
+ 2. RequestLogger() (if middleware.logger !== false)
271
+ 3. cors(config.cors) (if middleware.cors !== false && cors !== false)
272
+ 4. proxyGuard (if proxyGuard.mode !== 'off')
273
+ 5. built-in server time (GET /_core/time — unproven and session-free)
274
+ 6. config.use[*] (raw custom middleware, in array order)
275
+ 7. built-in health (GET /_core/health — unclaimable, always here;
276
+ plus config.healthCheck.path when set)
277
+ 8. lifecycle.beforeRoutes(app)
278
+ 9. registerRoutes(app, routes, middlewares)
279
+ 10. /health signpost (GET /health → 410 naming /_core/health, for one
280
+ release, and only if no app route declared GET on it)
281
+ 11. SSE endpoint (if .events(): GET /events/stream [+ POST token])
282
+ 12. lifecycle.afterRoutes(app)
283
+ 13. app.onError(ErrorHandler(...)) (if middleware.errorHandler !== false)
284
+ ```
285
+
286
+ - Each built-in is opt-out via `.middleware({ logger: false, cors: false, errorHandler: false })`.
287
+ - **`proxyGuard`** is opt-in (`mode: 'off'` by default). When enabled via `.proxyGuard({...})`
288
+ it verifies the trusted-proxy HMAC signature (`method+path+query+body`) + origin allowlist and
289
+ tags `c.get('clientType')`. `tag` and `strict` evaluate every gate; only enforcement differs.
290
+ Server time, health, SSE stream, and WS paths plus genuine CORS preflights are skipped
291
+ automatically so bootstrap calls/probes/EventSource/preflight are never blocked. See
292
+ `@spfn/core/middleware` and the root
293
+ `PROXY-BACKEND-AUTH-SPEC.md`.
294
+ - `.middleware({ onError })` forwards an error callback into `ErrorHandler` (e.g. Slack
295
+ notifier) — it runs async and does not block the response.
296
+ - Named `middlewares` (from `.middlewares()` / `.routes()`) are applied **per route** inside
297
+ `registerRoutes`, respecting each route's `.skip([...])` / `.skip('*')`. Validation
298
+ middleware is never skipped.
299
+
300
+ ---
301
+
302
+ ## Infrastructure, jobs, events, websockets, workflows
303
+
304
+ **DB/Redis** initialize during step 4 unless turned off. The env vars are **not** sniffed
305
+ first, and the two behave differently when their env var is missing:
306
+
307
+ | | env var absent |
308
+ |---|---|
309
+ | Database | **boot fails** — `No database configuration found` |
310
+ | Redis | boots in disabled mode, logged, no cache |
311
+
312
+ So a server that uses no database must say so. Leaving `DATABASE_URL` unset is not how you
313
+ declare it:
314
+
315
+ ```typescript
316
+ defineServerConfig()
317
+ .infrastructure({ database: false }) // a server with no database declares it
318
+ .build();
319
+ ```
320
+
321
+ The asymmetry is deliberate. A missing cache costs speed; a missing database means every
322
+ request that touches data fails, and failing at boot beats failing on the first query.
323
+ A component turned off here reports `disabled` to the health endpoint and never degrades it.
324
+
325
+ To use an externally owned PostgreSQL Drizzle driver such as PGlite, pass a provider. This
326
+ replaces environment-based postgres.js initialization; graceful shutdown invokes `close`
327
+ once. The driver remains an application dependency, not an `@spfn/core` runtime dependency.
328
+
329
+ ```typescript
330
+ const client = await PGlite.create('file://./data/app');
331
+ const db = drizzle(client, { schema });
332
+
333
+ defineServerConfig()
334
+ .database({
335
+ provider: {
336
+ kind: 'pglite',
337
+ write: db,
338
+ close: () => client.close(),
339
+ },
340
+ })
341
+ .build();
342
+ ```
343
+
344
+ **Jobs** (`.jobs(jobRouter)`) require a database — `startServer` throws
345
+ `'Jobs require database connection.'` if `DATABASE_URL` is unset. pg-boss is started and
346
+ jobs registered after `afterInfrastructure`.
347
+
348
+ **Events** (`.events(eventRouter)`) register an SSE stream at `/events/stream` (override via
349
+ `{ path }`). With `{ auth: { enabled: true } }`, a `POST /events/token` endpoint is also
350
+ registered, guarded by your app's named middleware — both `.middlewares([...])` and the
351
+ router's `.use([...])`; if a cache (Redis/Valkey) is available it's
352
+ used as the token store automatically (multi-instance safe), else in-memory.
353
+
354
+ **WebSockets** (`.websockets(wsRouter)`) attach a WS handler at `/ws` (override via
355
+ `{ path }`); `{ auth: { enabled: true } }` adds a token endpoint the same way as SSE. The
356
+ token path replaces the WS path's **last segment** with `token`, so the default `/ws`
357
+ yields **`POST /token`** — not `/ws/token`. A custom `{ path: '/api/ws' }` yields
358
+ `POST /api/token`.
359
+
360
+ **Workflows** (`.workflows(workflowRouter)`) require database enabled — throws otherwise —
361
+ and call the router's `_init(getDatabase(), workflowsConfig)` after infrastructure.
362
+
363
+ ---
364
+
365
+ ## Graceful shutdown & `ShutdownManager`
366
+
367
+ `SIGTERM`/`SIGINT` (and `instance.close()`) trigger an outer timeout
368
+ (`shutdown.timeout`, env `SHUTDOWN_TIMEOUT`, default 280000ms) wrapping 5 phases:
369
+
370
+ ```
371
+ beginShutdown() health → 503, trackOperation() now rejects
372
+ Phase 1 HTTP server.close() stop new connections (5s cap), drain in-flight requests
373
+ Phase 1.5 WS cleanup (if websockets)
374
+ Phase 2 stopBoss() (if jobs)
375
+ Phase 3 ShutdownManager.execute() drain tracked ops then run hooks (drainTimeout = 80% of shutdown.timeout)
376
+ Phase 4 lifecycle.beforeShutdown()
377
+ Phase 5 closeDatabase + closeCache (5s each)
378
+ process.exit(0)
379
+ ```
380
+
381
+ `uncaughtException` / `unhandledRejection` are **logged, not fatal** — the server keeps
382
+ running.
383
+
384
+ Obtain the singleton with `getShutdownManager()`:
385
+
386
+ ```typescript
387
+ import { getShutdownManager } from '@spfn/core/server';
388
+
389
+ const shutdown = getShutdownManager();
390
+
391
+ // Register an independent cleanup hook (runs in Phase 3, ordered)
392
+ shutdown.onShutdown('ai-client', async () => { await aiClient.close(); },
393
+ { timeout: 5000, order: 10 });
394
+
395
+ // Track a long op so drain waits for it (rejects if already shutting down)
396
+ const result = await shutdown.trackOperation('ai-generate', aiService.generate(prompt));
397
+
398
+ // Reject new work early in a handler
399
+ if (shutdown.isShuttingDown())
400
+ {
401
+ return c.json({ error: 'shutting down' }, 503);
402
+ }
403
+ ```
404
+
405
+ | Method | Description |
406
+ |--------|-------------|
407
+ | `onShutdown(name, handler, opts?)` | Register cleanup hook. `opts.timeout` default 10000ms, `opts.order` default 100 (lower runs first). Hook failure/timeout does not block later hooks. |
408
+ | `trackOperation(name, promise)` | Await + track an op; drain waits for it. **Throws** if shutdown already started. |
409
+ | `isShuttingDown()` | `true` once `beginShutdown()` ran (state ≠ `running`). |
410
+ | `getActiveOperationCount()` | Number of in-flight tracked operations. |
411
+
412
+ State machine: `running → draining → closed`. `beginShutdown()` / `execute()` are driven by
413
+ the server's shutdown sequence — application code uses the four methods above.
414
+
415
+ ---
416
+
417
+ ## Health check
418
+
419
+ `GET /_core/health`, always — that path belongs to `@spfn/core`, is registered before app
420
+ routes and cannot be claimed by one, which is what makes it the right target for a probe.
421
+ `GET /health` answers too (path configurable), unless the app declares a `GET` on it, in
422
+ which case the app's route wins and the built-in stays at `/_core/health`. `enabled: false`
423
+ turns off both. During shutdown it returns 503 `{ status: 'shutting_down' }` immediately
424
+ (k8s readiness signal).
425
+
426
+ - **Basic** (`detailed: false`, the production default): `{ status, timestamp }`, 200.
427
+ - **Detailed** (`detailed: true`, the dev default): adds
428
+ `services.{database,redis}.status` — `connected` / `error` / `not_initialized` /
429
+ `disabled` / `unknown`. Any DB `error`/`not_initialized` or Redis `error` ⇒
430
+ `status: 'degraded'` and HTTP **503**. Also adds `migrations` (below).
431
+
432
+ A component turned off with `.infrastructure({ database: false })` reports `disabled`
433
+ and never degrades health — otherwise a server that legitimately has no database would
434
+ answer 503 forever and no readiness probe would ever let it into rotation.
435
+
436
+ The endpoint answers at `/_core/health`. `path` adds a second address for a probe path
437
+ you cannot change — it does not move the canonical one.
438
+
439
+ ```typescript
440
+ defineServerConfig()
441
+ .healthCheck({ path: '/api/health', detailed: true })
442
+ .build();
443
+ ```
444
+
445
+ > Both addresses are registered **before** app routes, so an app route on the configured
446
+ > path never runs. The server logs a warning naming the route when it sees one. Drop the
447
+ > `path` option, or move the route to a path your app owns.
448
+
449
+ ### `migrations` in the detailed payload
450
+
451
+ ```json
452
+ "migrations": {
453
+ "status": "up_to_date",
454
+ "pending": 0,
455
+ "checkedAt": "2026-08-06T09:00:00.000Z",
456
+ "targets": [
457
+ { "name": "@spfn/auth", "total": 13, "applied": 13, "pending": 0, "pendingTags": [] }
458
+ ]
459
+ }
460
+ ```
461
+
462
+ - `status` — `up_to_date` / `pending` / `unknown`. `unknown` means there was nothing to
463
+ check (no database, no migrations) or the check failed; `reason` says which. It is never
464
+ conflated with `up_to_date`.
465
+ - The snapshot is recomputed at most once every **30 seconds**, so a readiness probe
466
+ polling every few seconds adds no database round-trips.
467
+ - Migration state does **not** change the overall `status`. Reporting drift must not, by
468
+ itself, pull a running deployment out of rotation — a probe that wants that asserts
469
+ `migrations.pending === 0`.
470
+
471
+ ---
472
+
473
+ ## Migration boot gate
474
+
475
+ A function package ships its own migrations, so upgrading `@spfn/auth` can add columns the
476
+ database has never heard of. Such a server boots, passes its health check, and then fails
477
+ every request touching a new column with an opaque 500.
478
+
479
+ Step 5 of the startup sequence stops that: it compares what each installed function
480
+ package ships (and `src/server/drizzle`, where present) against what the database records
481
+ as applied, logs the ones still waiting, and throws `PendingMigrationsError`.
482
+
483
+ The check runs on the pool `initDatabase()` just opened — no second connection. Three
484
+ situations never produce a refusal:
485
+
486
+ | Situation | What happens |
487
+ |---|---|
488
+ | No database initialized, or no migrations shipped | Skipped |
489
+ | Database configured but unreachable | `initDatabase()` already threw; the gate never runs |
490
+ | The status query itself fails | Logged as "could not verify"; boot proceeds |
491
+
492
+ Opt out — a harness that migrates after boot, a rollout that must proceed — with any of:
493
+
494
+ ```typescript
495
+ defineServerConfig().migrations({ allowPending: true }).build(); // config (wins)
496
+ ```
497
+ ```bash
498
+ SPFN_ALLOW_PENDING_MIGRATIONS=true # env — for containers, which take no CLI flag
499
+ spfn dev --allow-pending-migrations # CLI flag
500
+ ```
501
+
502
+ All three log the pending list as a warning rather than continuing silently.
503
+ `createServerlessApp()` has no boot to gate — run `spfn db migrate` as a deploy step there.
504
+
505
+ ---
506
+
507
+ ## Server time
508
+
509
+ `GET /_core/time` returns the server's current Unix epoch in milliseconds:
510
+
511
+ ```json
512
+ { "serverTimeMillis": 1750000000123 }
513
+ ```
514
+
515
+ The endpoint is always enabled in the auto-configured pipeline. It is registered before
516
+ `config.use`, `lifecycle.beforeRoutes` and application routes, and proxy-guard skips it,
517
+ so a client can call it without a proof or session. The response contract is closed,
518
+ declares `serverTimeMillis` as an integer, and carries `Cache-Control: no-store`.
519
+
520
+ Production clients trust this value only over HTTPS with certificate validation. It is an
521
+ unsigned server fact, not an authentication policy: core does not define skew margins,
522
+ replay windows, retries, latency compensation or client-side offset storage.
523
+
524
+ The default clock is `Date.now()`. A deterministic server test can inject one:
525
+
526
+ ```typescript
527
+ const config = defineServerConfig()
528
+ .serverTime({ clock: { now: () => 1750000000123 } })
529
+ .build();
530
+ ```
531
+
532
+ Contract exporters should import `CORE_TIME_ROUTE` rather than restating its operation
533
+ identity, path, admission profile or response schema.
534
+
535
+ ---
536
+
537
+ ## Timeouts (HTTP + outbound fetch)
538
+
539
+ HTTP server timeouts (`.timeout({...})` or env), applied to the Node server after listen:
540
+
541
+ | Field | Env | Default | Purpose |
542
+ |-------|-----|---------|---------|
543
+ | `request` | `SERVER_TIMEOUT` | 120000 | whole request/response cycle |
544
+ | `keepAlive` | `SERVER_KEEPALIVE_TIMEOUT` | 65000 | idle connection reuse (keep > LB timeout) |
545
+ | `headers` | `SERVER_HEADERS_TIMEOUT` | 60000 | header receipt (Slowloris guard; must be ≤ `request`) |
546
+
547
+ Outbound `fetch()` (undici global dispatcher) — set via the `fetchTimeout` field on a
548
+ `ServerConfig` object or env (no builder method):
549
+
550
+ | Field | Env | Default |
551
+ |-------|-----|---------|
552
+ | `connect` | `FETCH_CONNECT_TIMEOUT` | 10000 |
553
+ | `headers` | `FETCH_HEADERS_TIMEOUT` | 300000 |
554
+ | `body` | `FETCH_BODY_TIMEOUT` | 300000 |
555
+
556
+ ---
557
+
558
+ ## Pitfalls & anti-patterns
559
+
560
+ - **Builder methods are not exports.** `events`, `jobs`, `websockets`, `cors`, etc. are
561
+ methods on `defineServerConfig()`, not importable functions. Routes/middleware come from
562
+ `@spfn/core/route`, not `@spfn/core/server`.
563
+ - **Middleware pipeline order is fixed and opt-out only.** You cannot reorder built-ins;
564
+ you can only disable them via `.middleware({ logger:false, cors:false, errorHandler:false })`.
565
+ CORS / logger run **before** custom `.use()` middleware; `ErrorHandler` is registered via
566
+ `app.onError` **last**.
567
+ - **`.routes()` already merges router middlewares.** Calling `.middlewares()` *and*
568
+ registering the same middleware via the router's `.use()` double-applies it. Prefer one.
569
+ - **`.lifecycle()` merges, every other method overwrites.** A second `.port()` wins; a
570
+ second `.lifecycle()` *adds* hooks (run in order). Don't expect last-wins for lifecycle.
571
+ - **`afterStart` errors are swallowed.** They are logged but never thrown — the server is
572
+ already listening. Don't rely on `afterStart` to abort startup; use
573
+ `beforeInfrastructure` for fail-fast preconditions.
574
+ - **`createServer()` does not init infrastructure or shutdown.** `getDatabase()` /
575
+ `getCache()` are not ready unless you initialized them yourself. For a real process use
576
+ `startServer()`; reserve `createServer()` for tests / custom `serve()`.
577
+ - **Jobs/workflows require the database.** `.jobs()` throws without `DATABASE_URL`;
578
+ `.workflows()` throws if `.infrastructure({ database: false })`.
579
+ - **Default port is 4000, not 8790.** The 8790 default is the CLI dev wrapper's concern;
580
+ `PORT` env / `.port()` always win. Older docs showing 8790 as the programmatic default are
581
+ stale.
582
+ - **No `app.ts` ⇒ auto pipeline; `app.ts` present ⇒ you own everything.** With `app.ts`,
583
+ built-in middleware, health check, and SSE wiring are **not** added — only config `routes`
584
+ are registered onto your app.
585
+ - **`headers` timeout must be ≤ `request`.** `validateServerConfig` throws
586
+ `headers timeout (...) cannot exceed request timeout (...)`. Negative/non-finite
587
+ port/timeout/shutdown values also throw at `startServer()` time.
588
+ - **Don't import `validateServerConfig` / `printBanner` / `ShutdownManager` from
589
+ `@spfn/core/server`** — not in the public barrel. Use `getShutdownManager()`.
590
+ - **`loadEnvFiles` is deprecated** (warns once). `startServer()` calls `loadEnv()` for you;
591
+ only call `loadEnv` manually outside `startServer` (e.g. a script).
592
+
593
+ ---
594
+
595
+ ## Complete example
596
+
597
+ ```typescript
598
+ // src/server/server.config.ts
599
+ import { defineServerConfig } from '@spfn/core/server';
600
+ import { defineRouter, route, defineMiddleware } from '@spfn/core/route';
601
+ import { getDatabase } from '@spfn/core/db';
602
+ import { getShutdownManager } from '@spfn/core/server';
603
+ import { migrate } from 'drizzle-orm/postgres-js/migrator';
604
+ import { Type } from '@sinclair/typebox';
605
+
606
+ const auth = defineMiddleware('auth', async (c, next) =>
607
+ {
608
+ if (!c.req.header('authorization')) return c.json({ error: 'Unauthorized' }, 401);
609
+ await next();
610
+ });
611
+
612
+ const appRouter = defineRouter({
613
+ getUser: route.get('/users/:id')
614
+ .input({ params: Type.Object({ id: Type.String() }) })
615
+ .handler(async (c) =>
616
+ {
617
+ const { params } = await c.data();
618
+ return { id: params.id };
619
+ }),
620
+ health: route.get('/ping').skip(['auth']).handler(async () => ({ ok: true })),
621
+ })
622
+ .use([auth]);
623
+
624
+ // Independent module cleanup — registered once, runs in shutdown Phase 3
625
+ getShutdownManager().onShutdown('message-queue', async () =>
626
+ {
627
+ await closeMessageQueue();
628
+ }, { order: 10 });
629
+
630
+ export default defineServerConfig()
631
+ .port(4000)
632
+ .host('0.0.0.0')
633
+ .routes(appRouter) // merges `auth` from .use()
634
+ .middleware({ logger: true, cors: true })
635
+ .cors({ origin: ['https://app.example.com'], credentials: true })
636
+ .timeout({ request: 60000 })
637
+ .healthCheck({ path: '/api/health', detailed: true })
638
+ .shutdown({ timeout: 280000 })
639
+ .lifecycle({
640
+ afterInfrastructure: async () =>
641
+ {
642
+ await migrate(getDatabase(), { migrationsFolder: './drizzle' });
643
+ },
644
+ })
645
+ .build();
646
+
647
+ export type AppRouter = typeof appRouter;
648
+ ```
649
+
650
+ ```typescript
651
+ // src/server/index.ts
652
+ import { startServer } from '@spfn/core/server';
653
+
654
+ const instance = await startServer();
655
+ // instance.server / instance.app / instance.config / instance.close()
656
+ ```
657
+
658
+ ```typescript
659
+ // integration test — no listen, no infra
660
+ import { createServer } from '@spfn/core/server';
661
+ import config from './server.config';
662
+
663
+ const app = await createServer(config);
664
+ const res = await app.request('/api/health');
665
+ ```
666
+
667
+ ---
668
+
669
+ ## Types reference
670
+
671
+ ```typescript
672
+ function startServer(config?: ServerConfig): Promise<ServerInstance>;
673
+ function createServer(config?: ServerConfig): Promise<Hono>;
674
+ function defineServerConfig(): ServerConfigBuilder;
675
+ function getShutdownManager(): ShutdownManager;
676
+
677
+ type AppFactory = () => Promise<Hono> | Hono;
678
+
679
+ interface ServerInstance
680
+ {
681
+ server: ReturnType<typeof import('@hono/node-server').serve>;
682
+ app: Hono;
683
+ config: ServerConfig;
684
+ close: () => Promise<void>;
685
+ }
686
+
687
+ interface ShutdownHookOptions
688
+ {
689
+ timeout?: number; // default 10000
690
+ order?: number; // default 100 (lower runs first)
691
+ }
692
+ // ServerConfig: see config-builder table above — port, host, cors, middleware, use,
693
+ // middlewares, routes, jobs/jobsConfig, events/eventsConfig, websockets/websocketsConfig,
694
+ // workflows/workflowsConfig, debug, database, timeout, fetchTimeout, shutdown, healthCheck,
695
+ // serverTime, infrastructure, lifecycle.
696
+ ```
697
+
698
+ ## Related
699
+
700
+ - [@spfn/core/route](../route/README.md) — `defineRouter`, `route`, `defineMiddleware`, `.skip()`
701
+ - [@spfn/core/env](../env/README.md) — `loadEnv`, schema/registry (`PORT`, `HOST`, timeout vars)
702
+ - [@spfn/core/job](../job/README.md) — `job`, `defineJobRouter` (pg-boss)
703
+ - [@spfn/core/event](../event/README.md) — `defineEvent`, `defineEventRouter` (SSE), WS router
704
+ - [@spfn/core/middleware](../middleware/README.md) — `RequestLogger`, `ErrorHandler`, CORS