@spfn/core 0.3.0-beta.4 → 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.
- package/README.md +183 -4
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -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
|