@spfn/core/server
The unified entry point for an SPFN backend process: build a config with
defineServerConfig(), then boot with startServer(). Handles middleware auto-wiring,
infrastructure init (DB/Redis), routes/jobs/events/websockets/workflows integration, and
AWS-drain-style graceful shutdown.
Import paths
There is one entry point:
import {
startServer,
createServer,
defineServerConfig,
getShutdownManager,
loadEnv,
} from '@spfn/core/server';
import type {
ServerConfig,
ServerInstance,
AppFactory,
ShutdownHookOptions,
} from '@spfn/core/server';
Routes/middleware come from a different module — don't look for them here:
import { defineRouter, route, defineMiddleware } from '@spfn/core/route';
Public API (complete)
Everything exported from @spfn/core/server:
- Boot:
startServer(config?)→Promise<ServerInstance>— loads env + config file, inits infrastructure, starts the HTTP server, registers shutdown handlers. - App only:
createServer(config?)→Promise<Hono>— builds the configured Hono app without listening (for tests / customserve()). - Config builder:
defineServerConfig()→ServerConfigBuilder(fluent,.build()returnsServerConfig). - Shutdown:
getShutdownManager()→ShutdownManagersingleton. - Env:
loadEnv(re-export of@spfn/core/env/loader).startServer()already calls it internally. - Deprecated:
loadEnvFiles()— alias forloadEnv(); useloadEnvinstead. - Types:
ServerConfig,ServerInstance,AppFactory,ShutdownHookOptions.
Not exported from
@spfn/core/server:validateServerConfig,printBanner,ShutdownManager(the class),WorkflowRouterLike. They exist internally but are not in the public barrel — do not import them from@spfn/core/server. UsegetShutdownManager()to obtain aShutdownManagerinstance.
The config-builder fluent methods (
.events(),.jobs(),.websockets(),.workflows(),.cors(),.middleware(),.use(),.infrastructure(), …) are methods on the object returned bydefineServerConfig()— they are not standalone exports.
Quick Start
// src/server/server.config.ts
import { defineServerConfig } from '@spfn/core/server';
import { defineRouter, route } from '@spfn/core/route';
import { Type } from '@sinclair/typebox';
const appRouter = defineRouter({
getUser: route.get('/users/:id')
.input({ params: Type.Object({ id: Type.String() }) })
.handler(async (c) =>
{
const { params } = await c.data();
return { id: params.id, name: 'John' };
}),
});
export default defineServerConfig()
.port(4000)
.routes(appRouter)
.build();
// Re-export the router type for the typed client
export type AppRouter = typeof appRouter;
// src/server/index.ts (process entry point)
import { startServer } from '@spfn/core/server';
await startServer();
startServer() with no argument auto-discovers server.config.ts (see file-loading order
below), so the entry point usually stays this small. Pass a config object to
startServer(config) only to override at runtime (highest priority).
Config builder (defineServerConfig)
Fluent builder; every method returns this; .build() returns a plain ServerConfig.
There is no validation in the builder — validation runs inside startServer().
| Method | Sets | Notes |
|---|---|---|
.port(number) |
port |
Default 4000 (env PORT) |
.host(string) |
host |
Default localhost (env HOST) |
.routes(router) |
routes |
Also auto-merges the router's .use() + .packages() global middlewares into middlewares |
.middlewares([named]) |
middlewares |
NamedMiddleware[] from defineMiddleware() (route-level .skip() targets these) |
.use([handlers]) |
use |
Raw MiddlewareHandler[], applied app.use('*', …) |
.middleware({...}) |
middleware |
Toggle built-ins: { logger?, cors?, errorHandler?, onError? } |
.cors(opts | false) |
cors |
hono/cors options, or false to disable |
.jobs(router, cfg?) |
jobs / jobsConfig |
pg-boss job router (@spfn/core/job); cfg is Omit<BossOptions, 'connectionString'> |
.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) |
.websockets(router, cfg?) |
websockets / websocketsConfig |
WS router; cfg.path default /ws, cfg.auth for token auth; same multiInstance/channelPrefix cross-pod knobs as .events() |
.workflows(router, cfg?) |
workflows / workflowsConfig |
@spfn/workflow router; inits engine after DB |
.database({...}) |
database |
Pool / healthCheck / monitoring overrides |
.timeout({...}) |
timeout |
{ request?, keepAlive?, headers? } (ms) |
.shutdown({...}) |
shutdown |
{ timeout? } (ms) |
.healthCheck({...}) |
healthCheck |
{ enabled?, path?, detailed? } |
.infrastructure({...}) |
infrastructure |
{ database?, redis? } — false disables auto-init |
.debug(boolean) |
debug |
Default NODE_ENV === 'development' |
.lifecycle({...}) |
merged | Mergeable — see below |
.build() |
— | Returns the final ServerConfig |
There is no
.fetchTimeout()builder method and no.beforeStart()/.afterStart()/.beforeShutdown()standalone builder methods. Fetch timeouts are set via thefetchTimeoutfield on aServerConfigobject (or env vars); lifecycle hooks go through.lifecycle({ ... }).
.routes() auto-merges global middlewares
Calling .routes(appRouter) pulls in middlewares the router registered via its own
.use() and .packages() — you usually do not also call .middlewares():
const appRouter = defineRouter({ getUser, createUser })
.packages([authRouter]) // package routers' globals included too
.use([authMiddleware]); // → auto-merged into server config.middlewares
export default defineServerConfig()
.routes(appRouter) // authMiddleware now active server-wide
.build();
.lifecycle() is mergeable (not last-wins)
Multiple .lifecycle() calls accumulate; for each hook name, the collected hooks run
sequentially in registration order. This is the one builder method that does not
overwrite on repeat.
defineServerConfig()
.lifecycle({ afterInfrastructure: async () => { await runMigrations(); } })
.lifecycle({ afterInfrastructure: async () => { await seed(); } }) // runs AFTER migrations
.build();
Hook signatures (ServerConfig['lifecycle']):
| Hook | Signature | When |
|---|---|---|
beforeInfrastructure |
(config) => Promise<void> |
before DB/Redis init |
afterInfrastructure |
() => Promise<void> |
after DB/Redis (and before jobs/workflows) |
beforeRoutes |
(app: Hono) => void | Promise<void> |
inside createServer, before routes |
afterRoutes |
(app: Hono) => void | Promise<void> |
inside createServer, after routes/SSE |
afterStart |
(instance: ServerInstance) => Promise<void> |
server listening; throwing is logged, not fatal |
beforeShutdown |
() => Promise<void> |
shutdown Phase 4 (DB/Redis still open) |
startServer vs createServer
startServer(config?) is the full boot path and returns a ServerInstance:
const instance = await startServer({ port: 3000 });
instance.server; // Node http.Server (ReturnType<typeof serve>)
instance.app; // Hono app
instance.config; // resolved ServerConfig
await instance.close(); // graceful shutdown (same path as SIGTERM)
Its startup sequence:
loadEnv()(env files →process.env)- Load + merge config file (see order below) with the runtime
configargument validateServerConfig()— throws on bad port/timeout/shutdown/healthCheck.pathlifecycle.beforeInfrastructure→ init DB (unless disabled) → init Redis (unless disabled) →lifecycle.afterInfrastructure→ init pg-boss + register jobs (if.jobs()) → init workflow engine (if.workflows())createServer(config)builds the Hono app (middleware pipeline below)serve()starts listening; WebSocket handler attached if.websockets()- Apply HTTP server timeouts + global
fetch()(undici) timeouts - Print banner, register process handlers (
SIGTERM,SIGINT,uncaughtException,unhandledRejection) lifecycle.afterStart(instance)
createServer(config?) only does step 5 — it returns a configured Hono app without
listening and without infrastructure/shutdown. Use it for integration tests
(app.request('/health')) or when you call @hono/node-server's serve() yourself.
File-config loading order
startServer() scans these paths (first found wins), each merged under the runtime
config argument:
.spfn/server/server.config.mjs (built, highest priority)
.spfn/server/server.config (built .js)
src/server/server.config (source .js)
src/server/server.config.ts (source .ts, lowest)
port/host resolve as runtime ?? file ?? env (PORT/HOST) ?? defaults (4000/localhost).
Level 3: full control with app.ts
If src/server/app.ts (or app.js) exists, createServer imports its default export (an
AppFactory = () => Promise<Hono> | Hono) and uses that app instead of the
auto-configured pipeline. Config routes are still registered onto your app, but the
automatic middleware/health-check/SSE wiring is skipped — you own it.
// src/server/app.ts
import { Hono } from 'hono';
import { compress } from 'hono/compress';
import type { AppFactory } from '@spfn/core/server';
export default (async () =>
{
const app = new Hono();
app.use('*', compress());
return app;
}) satisfies AppFactory;
Auto-configured middleware pipeline
When there is no app.ts, createServer builds the app in this fixed order:
1. errorHandlerEnabled flag (if middleware.errorHandler !== false)
2. RequestLogger() (if middleware.logger !== false)
3. cors(config.cors) (if middleware.cors !== false && cors !== false)
4. proxyGuard (if proxyGuard.mode !== 'off')
5. config.use[*] (raw custom middleware, in array order)
6. health-check route (GET config.healthCheck.path, default /health)
7. lifecycle.beforeRoutes(app)
8. registerRoutes(app, routes, middlewares)
9. SSE endpoint (if .events(): GET /events/stream [+ POST token])
10. lifecycle.afterRoutes(app)
11. app.onError(ErrorHandler(...)) (if middleware.errorHandler !== false)
- Each built-in is opt-out via
.middleware({ logger: false, cors: false, errorHandler: false }). proxyGuardis opt-in (mode: 'off'by default). When enabled via.proxyGuard({...})it verifies the trusted-proxy HMAC signature (method+path+query+body) + origin allowlist and tagsc.get('clientType').tagandstrictevaluate every gate; only enforcement differs. Health, SSE stream, and WS paths plus genuine CORS preflights are skipped automatically so probes/EventSource/preflight are never blocked. See@spfn/core/middlewareand the rootPROXY-BACKEND-AUTH-SPEC.md..middleware({ onError })forwards an error callback intoErrorHandler(e.g. Slack notifier) — it runs async and does not block the response.- Named
middlewares(from.middlewares()/.routes()) are applied per route insideregisterRoutes, respecting each route's.skip([...])/.skip('*'). Validation middleware is never skipped.
Infrastructure, jobs, events, websockets, workflows
DB/Redis auto-init during step 4 when their env vars exist. Disable selectively:
defineServerConfig()
.infrastructure({ database: false, redis: true })
.build();
Jobs (.jobs(jobRouter)) require a database — startServer throws
'Jobs require database connection.' if DATABASE_URL is unset. pg-boss is started and
jobs registered after afterInfrastructure.
Events (.events(eventRouter)) register an SSE stream at /events/stream (override via
{ path }). With { auth: { enabled: true } }, a POST /events/token endpoint is also
registered, guarded by config.middlewares; if a cache (Redis/Valkey) is available it's
used as the token store automatically (multi-instance safe), else in-memory.
WebSockets (.websockets(wsRouter)) attach a WS handler at /ws (override via
{ path }); { auth: { enabled: true } } adds POST /ws/token the same way as SSE.
Workflows (.workflows(workflowRouter)) require database enabled — throws otherwise —
and call the router's _init(getDatabase(), workflowsConfig) after infrastructure.
Graceful shutdown & ShutdownManager
SIGTERM/SIGINT (and instance.close()) trigger an outer timeout
(shutdown.timeout, env SHUTDOWN_TIMEOUT, default 280000ms) wrapping 5 phases:
beginShutdown() health → 503, trackOperation() now rejects
Phase 1 HTTP server.close() stop new connections (5s cap), drain in-flight requests
Phase 1.5 WS cleanup (if websockets)
Phase 2 stopBoss() (if jobs)
Phase 3 ShutdownManager.execute() drain tracked ops then run hooks (drainTimeout = 80% of shutdown.timeout)
Phase 4 lifecycle.beforeShutdown()
Phase 5 closeDatabase + closeCache (5s each)
process.exit(0)
uncaughtException / unhandledRejection are logged, not fatal — the server keeps
running.
Obtain the singleton with getShutdownManager():
import { getShutdownManager } from '@spfn/core/server';
const shutdown = getShutdownManager();
// Register an independent cleanup hook (runs in Phase 3, ordered)
shutdown.onShutdown('ai-client', async () => { await aiClient.close(); },
{ timeout: 5000, order: 10 });
// Track a long op so drain waits for it (rejects if already shutting down)
const result = await shutdown.trackOperation('ai-generate', aiService.generate(prompt));
// Reject new work early in a handler
if (shutdown.isShuttingDown())
{
return c.json({ error: 'shutting down' }, 503);
}
| Method | Description |
|---|---|
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. |
trackOperation(name, promise) |
Await + track an op; drain waits for it. Throws if shutdown already started. |
isShuttingDown() |
true once beginShutdown() ran (state ≠ running). |
getActiveOperationCount() |
Number of in-flight tracked operations. |
State machine: running → draining → closed. beginShutdown() / execute() are driven by
the server's shutdown sequence — application code uses the four methods above.
Health check
GET /health (path/enabled/detailed configurable). During shutdown it returns 503
{ status: 'shutting_down' } immediately (k8s readiness signal).
- Basic (
detailed: false, the production default):{ status, timestamp }, 200. - Detailed (
detailed: true, the dev default): addsservices.{database,redis}.status—connected/error/not_initialized/unknown. Any DBerror/not_initializedor Rediserror⇒status: 'degraded'and HTTP 503.
defineServerConfig()
.healthCheck({ path: '/api/health', detailed: true })
.build();
Timeouts (HTTP + outbound fetch)
HTTP server timeouts (.timeout({...}) or env), applied to the Node server after listen:
| Field | Env | Default | Purpose |
|---|---|---|---|
request |
SERVER_TIMEOUT |
120000 | whole request/response cycle |
keepAlive |
SERVER_KEEPALIVE_TIMEOUT |
65000 | idle connection reuse (keep > LB timeout) |
headers |
SERVER_HEADERS_TIMEOUT |
60000 | header receipt (Slowloris guard; must be ≤ request) |
Outbound fetch() (undici global dispatcher) — set via the fetchTimeout field on a
ServerConfig object or env (no builder method):
| Field | Env | Default |
|---|---|---|
connect |
FETCH_CONNECT_TIMEOUT |
10000 |
headers |
FETCH_HEADERS_TIMEOUT |
300000 |
body |
FETCH_BODY_TIMEOUT |
300000 |
Pitfalls & anti-patterns
- Builder methods are not exports.
events,jobs,websockets,cors, etc. are methods ondefineServerConfig(), not importable functions. Routes/middleware come from@spfn/core/route, not@spfn/core/server. - Middleware pipeline order is fixed and opt-out only. You cannot reorder built-ins;
you can only disable them via
.middleware({ logger:false, cors:false, errorHandler:false }). CORS / logger run before custom.use()middleware;ErrorHandleris registered viaapp.onErrorlast. .routes()already merges router middlewares. Calling.middlewares()and registering the same middleware via the router's.use()double-applies it. Prefer one..lifecycle()merges, every other method overwrites. A second.port()wins; a second.lifecycle()adds hooks (run in order). Don't expect last-wins for lifecycle.afterStarterrors are swallowed. They are logged but never thrown — the server is already listening. Don't rely onafterStartto abort startup; usebeforeInfrastructurefor fail-fast preconditions.createServer()does not init infrastructure or shutdown.getDatabase()/getCache()are not ready unless you initialized them yourself. For a real process usestartServer(); reservecreateServer()for tests / customserve().- Jobs/workflows require the database.
.jobs()throws withoutDATABASE_URL;.workflows()throws if.infrastructure({ database: false }). - Default port is 4000, not 8790. The 8790 default is the CLI dev wrapper's concern;
PORTenv /.port()always win. Older docs showing 8790 as the programmatic default are stale. - No
app.ts⇒ auto pipeline;app.tspresent ⇒ you own everything. Withapp.ts, built-in middleware, health check, and SSE wiring are not added — only configroutesare registered onto your app. headerstimeout must be ≤request.validateServerConfigthrowsheaders timeout (...) cannot exceed request timeout (...). Negative/non-finite port/timeout/shutdown values also throw atstartServer()time.- Don't import
validateServerConfig/printBanner/ShutdownManagerfrom@spfn/core/server— not in the public barrel. UsegetShutdownManager(). loadEnvFilesis deprecated (warns once).startServer()callsloadEnv()for you; only callloadEnvmanually outsidestartServer(e.g. a script).
Complete example
// src/server/server.config.ts
import { defineServerConfig } from '@spfn/core/server';
import { defineRouter, route, defineMiddleware } from '@spfn/core/route';
import { getDatabase } from '@spfn/core/db';
import { getShutdownManager } from '@spfn/core/server';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { Type } from '@sinclair/typebox';
const auth = defineMiddleware('auth', async (c, next) =>
{
if (!c.req.header('authorization')) return c.json({ error: 'Unauthorized' }, 401);
await next();
});
const appRouter = defineRouter({
getUser: route.get('/users/:id')
.input({ params: Type.Object({ id: Type.String() }) })
.handler(async (c) =>
{
const { params } = await c.data();
return { id: params.id };
}),
health: route.get('/ping').skip(['auth']).handler(async () => ({ ok: true })),
})
.use([auth]);
// Independent module cleanup — registered once, runs in shutdown Phase 3
getShutdownManager().onShutdown('message-queue', async () =>
{
await closeMessageQueue();
}, { order: 10 });
export default defineServerConfig()
.port(4000)
.host('0.0.0.0')
.routes(appRouter) // merges `auth` from .use()
.middleware({ logger: true, cors: true })
.cors({ origin: ['https://app.example.com'], credentials: true })
.timeout({ request: 60000 })
.healthCheck({ path: '/api/health', detailed: true })
.shutdown({ timeout: 280000 })
.lifecycle({
afterInfrastructure: async () =>
{
await migrate(getDatabase(), { migrationsFolder: './drizzle' });
},
})
.build();
export type AppRouter = typeof appRouter;
// src/server/index.ts
import { startServer } from '@spfn/core/server';
const instance = await startServer();
// instance.server / instance.app / instance.config / instance.close()
// integration test — no listen, no infra
import { createServer } from '@spfn/core/server';
import config from './server.config';
const app = await createServer(config);
const res = await app.request('/api/health');
Types reference
function startServer(config?: ServerConfig): Promise<ServerInstance>;
function createServer(config?: ServerConfig): Promise<Hono>;
function defineServerConfig(): ServerConfigBuilder;
function getShutdownManager(): ShutdownManager;
type AppFactory = () => Promise<Hono> | Hono;
interface ServerInstance
{
server: ReturnType<typeof import('@hono/node-server').serve>;
app: Hono;
config: ServerConfig;
close: () => Promise<void>;
}
interface ShutdownHookOptions
{
timeout?: number; // default 10000
order?: number; // default 100 (lower runs first)
}
// ServerConfig: see config-builder table above — port, host, cors, middleware, use,
// middlewares, routes, jobs/jobsConfig, events/eventsConfig, websockets/websocketsConfig,
// workflows/workflowsConfig, debug, database, timeout, fetchTimeout, shutdown, healthCheck,
// infrastructure, lifecycle.
Related
- @spfn/core/route —
defineRouter,route,defineMiddleware,.skip() - @spfn/core/env —
loadEnv, schema/registry (PORT,HOST, timeout vars) - @spfn/core/job —
job,defineJobRouter(pg-boss) - @spfn/core/event —
defineEvent,defineEventRouter(SSE), WS router - @spfn/core/middleware —
RequestLogger,ErrorHandler, CORS