@spfn/core
@spfn/core is the backend runtime for SPFN: a tRPC-style route DSL (TypeBox-validated,
end-to-end typed), a Drizzle/postgres.js data layer, an HTTP server entry point, and a
Next.js RPC proxy + typed client that wires a browser/RSC app to that backend with
compile-time-only type inference.
There is no root barrel — @spfn/core itself imports nothing. Everything is reached
through subpath exports (@spfn/core/route, @spfn/core/db, @spfn/core/nextjs, …). Each
module is canonically documented in its own src/<mod>/README.md; this file is the index +
end-to-end flow. Follow the links for API detail.
Install
pnpm add @spfn/core
# peer (optional): next ^15 || ^16
# optional deps: ioredis (cache), ws (websocket events); pg-boss ships as a direct dep for jobs
Node >=18.18.0. ESM-only.
Apps that define entities must declare the Postgres driver directly: add
postgres(andpg) to your app'sdependencies.@spfn/coreuses postgres.js internally, and Drizzle branches its type resolution on thepostgres/pgpeer. If the app doesn't pin the same driver, pnpm resolvesdrizzle-ormto a second instance,BaseRepositorygenerics collapse tounknown, and RPC responses lose their types.spfn createadds these automatically; declare them by hand only when wiring SPFN into an existing app.pnpm add postgres pg
Modules
Each entry below is exactly one subpath from package.json exports. Import path → one-line
purpose → canonical README.
| Import path | Purpose | Doc |
|---|---|---|
@spfn/core/route |
tRPC-style route DSL (route.get(...).input(...).handler(...)) + defineRouter / registerRoutes / defineMiddleware. The core. |
src/route/README.md |
@spfn/core/route/types |
Shared route types (HttpMethod, route/router type primitives). |
src/route/README.md |
@spfn/core/server |
HTTP server entry: defineServerConfig() → startServer(); middleware auto-wiring, infra init, graceful shutdown. |
src/server/README.md |
@spfn/core/nextjs |
Client-safe: createApi<AppRouter>(), ApiError, client types. No next/headers. |
src/nextjs/README.md |
@spfn/core/nextjs/server |
Server-only: createRpcProxy({ routeMap }), registerInterceptors. Uses next/headers. |
src/nextjs/README.md |
@spfn/core/db |
Type-safe PostgreSQL (Drizzle): CRUD helpers, BaseRepository, schema helpers, transactions, PG error mapping. Single entry point. |
src/db/README.md |
@spfn/core/db → manager |
Connection lifecycle, pool, primary/replica, health-check, reconnect (initDatabase, getDatabase). Re-exported from @spfn/core/db. |
src/db/manager/README.md |
@spfn/core/db → schema |
Drizzle column helpers (id, uuid, timestamps, foreignKey, enumText, typedJsonb, softDelete, …). Re-exported from @spfn/core/db. |
src/db/schema/README.md |
@spfn/core/db → transaction |
Transactional middleware + runInTransaction; AsyncLocalStorage propagation to every repo. Re-exported from @spfn/core/db. |
src/db/transaction/README.md |
@spfn/core/middleware |
Built-in Hono middleware: ErrorHandler, RequestLogger (+ masking helper). |
src/middleware/README.md |
@spfn/core/errors |
Serializable HTTP/DB error classes + ErrorRegistry for cross-boundary deserialization. |
src/errors/README.md |
@spfn/core/env |
Schema-based env validation/parsing (isomorphic). @spfn/core/env/loader is the server-only file loader (node:fs). |
src/env/README.md |
@spfn/core/config |
@spfn/core's own validated env config (env, envSchema, registry) built on @spfn/core/env. |
src/config/README.md |
@spfn/core/logger |
Zero-dependency structured singleton logger + child loggers, level masking. |
src/logger/README.md |
@spfn/core/cache |
Singleton Valkey/Redis (ioredis) manager, graceful-degrading (getCache, getCacheRead). |
src/cache/README.md |
@spfn/core/job |
pg-boss background jobs: fluent job() builder, cron/run-once/event-driven, defineJobRouter. |
src/job/README.md |
@spfn/core/event |
Decoupled pub/sub (defineEvent, defineEventRouter, eventRouteMap); SSE/WS variants below. |
src/event/README.md |
@spfn/core/event/sse |
Server SSE handler + token manager (SERVER ONLY). | src/event/README.md |
@spfn/core/event/sse/client |
Browser SSE (EventSource) client. |
src/event/README.md |
@spfn/core/event/ws |
Server WebSocket handler (SERVER ONLY; ws optional dep). |
src/event/README.md |
@spfn/core/event/ws/client |
Browser WebSocket client. | src/event/README.md |
@spfn/core/codegen |
Pluggable codegen orchestrator + the built-in @spfn/core:route-map generator that produces the proxy's routeMap. |
src/codegen/README.md |
db/manager, db/schema, db/transaction have no package subpath of their own — they are
internal modules re-exported by @spfn/core/db. Import them from @spfn/core/db. (See Pitfalls
for the ./client subpath.)
How it works
End-to-end, a SPFN backend is defined once on the server and consumed type-safely from the
Next.js app. Types flow purely through TypeScript inference; the only generated artifact is the
proxy's routeMap.
① route DSL ② defineRouter ③ defineServerConfig → startServer
route.get('/users/:id') defineRouter({ getUser, defineServerConfig()
.input({ params }) createUser }) .routes(appRouter).build()
.handler(c => …) export type AppRouter startServer() → Hono on :8790
│ = typeof appRouter ▲
│ TypeBox = runtime validation + compile-time types │ registerRoutes mounts routes
▼ │
④ codegen (@spfn/core:route-map) ──► routeMap = { getUser: { method:'GET', path:'/users/:id' }, … }
│
▼
⑤ Next.js RPC proxy ⑥ typed client
app/api/rpc/[routeName]/route.ts lib/api.ts
createRpcProxy({ routeMap }) createApi<AppRouter>() (no codegen for the client)
GET/POST /api/rpc/{routeName} api.getUser.call({ params:{ id } })
resolves real method+path from routeMap └─ fully typed input + output
forwards to backend, runs interceptors
- Define a route with
route.<method>(path).input({...}).handler(c => …)from@spfn/core/route. TypeBox schemas in.input()give both runtime validation and the compile-time types the handler (await c.data()) and the client see. The handler's return type is inferred — no manual response typing. - Compose routes with
defineRouter({ … })and exporttype AppRouter = typeof appRouter. That type is the single source of truth for the client; no client codegen reads it. - Boot the server with
defineServerConfig().routes(appRouter).build()thenstartServer()(@spfn/core/server). It auto-wiresErrorHandler/RequestLogger, inits DB/cache, mounts routes viaregisterRoutes, and runs jobs/events. A request is matched by Hono → global middleware → route middleware (.use([...]), e.g.Transactional()) → input validation/type conversion → handler → serialized response. - Generate the route-map with codegen (
@spfn/core:route-map, e.g.pnpm codegen). It emitsrouteName → { method, path }— the only thing the proxy needs to resolve the real backend method/path. - Mount the RPC proxy in Next.js:
createRpcProxy({ routeMap })from@spfn/core/nextjs/serveras theapp/api/rpc/[routeName]/route.tscatch-all. The client only ever sendsGET(no body) orPOST(body/formData) to/api/rpc/{routeName}; the proxy looks uprouteMap[routeName], substitutes:params, and forwards to the backend with the resolved method. Package route maps (authRouteMap,eventRouteMap) merge into the samerouteMap. - Call it through
createApi<AppRouter>()from@spfn/core/nextjs. The client is aProxyoverAppRouter—api.getUser.call({ params })is fully typed in and out, with zero runtime type cost. Errors come back asApiError, or as the original typed error when its class is in the client'serrorRegistry(the core registry is always merged in).
Quick example
// server/router.ts — ① + ②
import { defineRouter, route } from '@spfn/core/route';
import { Transactional } from '@spfn/core/db';
import { Type } from '@sinclair/typebox';
export const appRouter = defineRouter({
getUser: route.get('/users/:id')
.input({ params: Type.Object({ id: Type.String() }) })
.handler(async (c) =>
{
const { params } = await c.data(); // params.id: string
return { id: params.id, name: 'John' }; // return type inferred
}),
createUser: route.post('/users')
.input({ body: Type.Object({ name: Type.String() }) })
.use([Transactional()]) // auto commit/rollback
.handler(async (c) =>
{
const { body } = await c.data();
return { id: '2', name: body.name };
}),
});
export type AppRouter = typeof appRouter;
// server/index.ts — ③
import { defineServerConfig, startServer } from '@spfn/core/server';
import { appRouter } from './router';
export default defineServerConfig().port(8790).routes(appRouter).build();
await startServer(); // Hono on :8790
// app/api/rpc/[routeName]/route.ts — ⑤ (server-only)
import { createRpcProxy } from '@spfn/core/nextjs/server';
import { routeMap } from '@/generated/route-map'; // ④ codegen output
export const { GET, POST } = createRpcProxy({ routeMap });
// lib/api.ts — ⑥ (client-safe; no codegen)
import { createApi } from '@spfn/core/nextjs';
import type { AppRouter } from '@/server/router';
export const api = createApi<AppRouter>();
// anywhere (RSC / client / server action):
const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
const made = await api.createUser.call({ body: { name: 'A' } });
Pitfalls
- No root barrel.
import … from '@spfn/core'does not resolve. Import from a subpath (@spfn/core/route,@spfn/core/db, …). The list above is the complete public surface. - Use
@spfn/core/nextjsfor the client, not@spfn/core/client.package.jsonstill lists a./clientexport, but the build does not emit it (nosrc/client; the tsupcliententry is disabled).createApi/ApiErrorand all client types ship from@spfn/core/nextjs. Treat@spfn/core/clientas non-functional. - Client vs. server boundary is load-bearing.
@spfn/core/nextjs/serverpulls innext/headers+next/server; never import it from a Client Component.@spfn/core/env/loader,@spfn/core/event/sse,@spfn/core/event/wsare server-only (node:fs/ Hono /node:crypto). Client code uses the*/clientand isomorphic entry points only. db/manager,db/schema,db/transactionare not package subpaths. Importing@spfn/core/db/transactionfails to resolve — import those symbols from@spfn/core/db.- The client needs no codegen; the proxy does.
createApi<AppRouter>()is metadata-free and driven by theAppRoutertype. The generatedrouteMapis consumed bycreateRpcProxy. ArouteNamemissing from the mergedrouteMapis a proxy 404, not a backend 404 — re-run codegen after adding routes. - The proxy decides the real HTTP method. The client only sends GET/POST to
/api/rpc/...; a PUT/PATCH/DELETE route still works because the backend method comes fromrouteMap[routeName]. - Cache/event/job degrade or depend on optional deps.
@spfn/core/cacheruns disabled (getters returnundefined) withoutCACHE_*config orioredis; WebSocket events need the optionalwsdep. Don't assume they throw.
Related packages
Other SPFN packages build on @spfn/core:
@spfn/auth— auth/session; exportsauthRouteMapand auto-registers proxy interceptors (merge its route map intocreateRpcProxy).@spfn/cms,@spfn/workflow,@spfn/notification,@spfn/monitor,@spfn/cli— see each package's README underpackages/.