@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,
CORE_TIME_ROUTE,
CORE_TIME_PATH,
ServerTimeResponseSchema,
} from '@spfn/core/server';
import type {
ServerConfig,
ServerInstance,
AppFactory,
ShutdownHookOptions,
ServerClock,
ServerTimeResponse,
} 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. - Server time:
CORE_TIME_ROUTE,CORE_TIME_PATH,CORE_TIME_OPERATION_ID,ServerTimeResponseSchema,createCoreTimeRoute()and theServerClock/ServerTimeResponsetypes — the built-in unproven clock-synchronization contract. - 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 |
External Drizzle provider, or postgres.js pool / healthCheck / monitoring overrides |
.timeout({...}) |
timeout |
{ request?, keepAlive?, headers? } (ms) |
.shutdown({...}) |
shutdown |
{ timeout? } (ms) |
.healthCheck({...}) |
healthCheck |
{ enabled?, path?, detailed? } |
.serverTime({...}) |
serverTime |
Inject { clock: { now() } } for deterministic tests; production defaults to Date.now() |
.infrastructure({...}) |
infrastructure |
{ database?, redis? } — false disables auto-init |
.migrations({...}) |
migrations |
{ allowPending? } — true boots with pending migrations (warn instead of refuse) |
.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() keeps the router's own middleware
Middleware a router registered with .use() travels with that router: .routes(appRouter)
records the router, and route registration applies its middleware to that router's routes
(package routers included). You usually do not also call .middlewares():
const appRouter = defineRouter({ getUser, createUser })
.packages([authRouter]) // package routers keep their own .use() middleware
.use([authMiddleware]); // applied to this router's routes
export default defineServerConfig()
.routes(appRouter) // authMiddleware active on every route above
.build();
A named middleware runs at most once per route, no matter how many registrations name
it — registering the same one at both levels (.middlewares([authMiddleware]) and
.use([authMiddleware])) is not an error and does not run it twice. Middleware holding
one-shot state, such as a nonce replay ledger, depends on that: a second run would reject
the very request the first run accepted.
.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())- Migration boot gate — refuses to go further when a function package (or
src/server/drizzle) has migrations the database has not applied (see below) 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 6 — 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. built-in server time (GET /_core/time — unproven and session-free)
6. config.use[*] (raw custom middleware, in array order)
7. built-in health (GET /_core/health — unclaimable, always here;
plus config.healthCheck.path when set)
8. lifecycle.beforeRoutes(app)
9. registerRoutes(app, routes, middlewares)
10. /health signpost (GET /health → 410 naming /_core/health, for one
release, and only if no app route declared GET on it)
11. SSE endpoint (if .events(): GET /events/stream [+ POST token])
12. lifecycle.afterRoutes(app)
13. 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. Server time, health, SSE stream, and WS paths plus genuine CORS preflights are skipped automatically so bootstrap calls/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 initialize during step 4 unless turned off. The env vars are not sniffed first, and the two behave differently when their env var is missing:
| env var absent | |
|---|---|
| Database | boot fails — No database configuration found |
| Redis | boots in disabled mode, logged, no cache |
So a server that uses no database must say so. Leaving DATABASE_URL unset is not how you
declare it:
defineServerConfig()
.infrastructure({ database: false }) // a server with no database declares it
.build();
The asymmetry is deliberate. A missing cache costs speed; a missing database means every
request that touches data fails, and failing at boot beats failing on the first query.
A component turned off here reports disabled to the health endpoint and never degrades it.
To use an externally owned PostgreSQL Drizzle driver such as PGlite, pass a provider. This
replaces environment-based postgres.js initialization; graceful shutdown invokes close
once. The driver remains an application dependency, not an @spfn/core runtime dependency.
const client = await PGlite.create('file://./data/app');
const db = drizzle(client, { schema });
defineServerConfig()
.database({
provider: {
kind: 'pglite',
write: db,
close: () => client.close(),
},
})
.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 your app's named middleware — both .middlewares([...]) and the
router's .use([...]); 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 a token endpoint the same way as SSE. The
token path replaces the WS path's last segment with token, so the default /ws
yields POST /token — not /ws/token. A custom { path: '/api/ws' } yields
POST /api/token.
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 /_core/health, always — that path belongs to @spfn/core, is registered before app
routes and cannot be claimed by one, which is what makes it the right target for a probe.
GET /health answers too (path configurable), unless the app declares a GET on it, in
which case the app's route wins and the built-in stays at /_core/health. enabled: false
turns off both. 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/disabled/unknown. Any DBerror/not_initializedor Rediserror⇒status: 'degraded'and HTTP 503. Also addsmigrations(below).
A component turned off with .infrastructure({ database: false }) reports disabled
and never degrades health — otherwise a server that legitimately has no database would
answer 503 forever and no readiness probe would ever let it into rotation.
The endpoint answers at /_core/health. path adds a second address for a probe path
you cannot change — it does not move the canonical one.
defineServerConfig()
.healthCheck({ path: '/api/health', detailed: true })
.build();
Both addresses are registered before app routes, so an app route on the configured path never runs. The server logs a warning naming the route when it sees one. Drop the
pathoption, or move the route to a path your app owns.
migrations in the detailed payload
"migrations": {
"status": "up_to_date",
"pending": 0,
"checkedAt": "2026-08-06T09:00:00.000Z",
"targets": [
{ "name": "@spfn/auth", "total": 13, "applied": 13, "pending": 0, "pendingTags": [] }
]
}
status—up_to_date/pending/unknown.unknownmeans there was nothing to check (no database, no migrations) or the check failed;reasonsays which. It is never conflated withup_to_date.- The snapshot is recomputed at most once every 30 seconds, so a readiness probe polling every few seconds adds no database round-trips.
- Migration state does not change the overall
status. Reporting drift must not, by itself, pull a running deployment out of rotation — a probe that wants that assertsmigrations.pending === 0.
Migration boot gate
A function package ships its own migrations, so upgrading @spfn/auth can add columns the
database has never heard of. Such a server boots, passes its health check, and then fails
every request touching a new column with an opaque 500.
Step 5 of the startup sequence stops that: it compares what each installed function
package ships (and src/server/drizzle, where present) against what the database records
as applied, logs the ones still waiting, and throws PendingMigrationsError.
The check runs on the pool initDatabase() just opened — no second connection. Three
situations never produce a refusal:
| Situation | What happens |
|---|---|
| No database initialized, or no migrations shipped | Skipped |
| Database configured but unreachable | initDatabase() already threw; the gate never runs |
| The status query itself fails | Logged as "could not verify"; boot proceeds |
Opt out — a harness that migrates after boot, a rollout that must proceed — with any of:
defineServerConfig().migrations({ allowPending: true }).build(); // config (wins)
SPFN_ALLOW_PENDING_MIGRATIONS=true # env — for containers, which take no CLI flag
spfn dev --allow-pending-migrations # CLI flag
All three log the pending list as a warning rather than continuing silently.
createServerlessApp() has no boot to gate — run spfn db migrate as a deploy step there.
Server time
GET /_core/time returns the server's current Unix epoch in milliseconds:
{ "serverTimeMillis": 1750000000123 }
The endpoint is always enabled in the auto-configured pipeline. It is registered before
config.use, lifecycle.beforeRoutes and application routes, and proxy-guard skips it,
so a client can call it without a proof or session. The response contract is closed,
declares serverTimeMillis as an integer, and carries Cache-Control: no-store.
Production clients trust this value only over HTTPS with certificate validation. It is an unsigned server fact, not an authentication policy: core does not define skew margins, replay windows, retries, latency compensation or client-side offset storage.
The default clock is Date.now(). A deterministic server test can inject one:
const config = defineServerConfig()
.serverTime({ clock: { now: () => 1750000000123 } })
.build();
Contract exporters should import CORE_TIME_ROUTE rather than restating its operation
identity, path, admission profile or response schema.
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,
// serverTime, 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