@spfn/core 0.2.0-beta.9 → 0.3.0-beta.10
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/LICENSE +1 -1
- package/README.md +693 -279
- package/dist/app-config/index.d.ts +96 -0
- package/dist/app-config/index.js +58 -0
- package/dist/app-config/index.js.map +1 -0
- package/dist/authz/index.d.ts +34 -0
- package/dist/authz/index.js +430 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/boss-BiDCbbAQ.d.ts +337 -0
- package/dist/cache/index.js +42 -30
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.d.ts +121 -13
- package/dist/codegen/index.js +212 -15
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +576 -30
- package/dist/config/index.js +136 -17
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +221 -0
- package/dist/contract/index.js +558 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/db/index.d.ts +704 -94
- package/dist/db/index.js +1105 -260
- package/dist/db/index.js.map +1 -1
- package/dist/define-middleware-CVKgqo8S.d.ts +167 -0
- package/dist/env/index.d.ts +26 -2
- package/dist/env/index.js +15 -5
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +26 -19
- package/dist/env/loader.js +55 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.d.ts +10 -0
- package/dist/errors/index.js +40 -7
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +33 -3
- package/dist/event/index.js +24 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +55 -3
- package/dist/event/sse/client.js +164 -45
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +12 -5
- package/dist/event/sse/index.js +280 -32
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +59 -0
- package/dist/event/ws/client.js +273 -0
- package/dist/event/ws/client.js.map +1 -0
- package/dist/event/ws/index.d.ts +94 -0
- package/dist/event/ws/index.js +272 -0
- package/dist/event/ws/index.js.map +1 -0
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +265 -74
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +14 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +352 -10
- package/dist/middleware/index.js +1079 -13
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +39 -3
- package/dist/nextjs/index.js +82 -29
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +69 -52
- package/dist/nextjs/server.js +140 -60
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +208 -0
- package/dist/ops/index.js +800 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/route/index.d.ts +10 -694
- package/dist/route/index.js +112 -23
- package/dist/route/index.js.map +1 -1
- package/dist/route-builder-2ani2jEI.d.ts +553 -0
- package/dist/router-DJdpwuB6.d.ts +127 -0
- package/dist/security/index.d.ts +83 -0
- package/dist/security/index.js +173 -0
- package/dist/security/index.js.map +1 -0
- package/dist/server/index.d.ts +619 -31
- package/dist/server/index.js +2583 -360
- package/dist/server/index.js.map +1 -1
- package/dist/token-manager-BT5EnUAR.d.ts +278 -0
- package/dist/types-2AbaW4Ie.d.ts +205 -0
- package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
- package/dist/types-ClQVomgV.d.ts +115 -0
- package/dist/types-ZQODsBft.d.ts +282 -0
- package/docs/file-upload.md +195 -333
- package/package.json +250 -208
- 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 +354 -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 +741 -0
- package/src/job/README.md +613 -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
- package/dist/boss-DI1r4kTS.d.ts +0 -244
- package/dist/router-Di7ENoah.d.ts +0 -151
- package/dist/types-B-e_f2dQ.d.ts +0 -121
- package/docs/cache.md +0 -133
- package/docs/codegen.md +0 -74
- package/docs/database.md +0 -346
- package/docs/entity.md +0 -539
- package/docs/env.md +0 -477
- package/docs/errors.md +0 -319
- package/docs/event.md +0 -116
- package/docs/job.md +0 -131
- package/docs/logger.md +0 -108
- package/docs/middleware.md +0 -337
- package/docs/nextjs.md +0 -241
- package/docs/repository.md +0 -496
- package/docs/route.md +0 -497
- package/docs/server.md +0 -307
package/README.md
CHANGED
|
@@ -1,393 +1,807 @@
|
|
|
1
1
|
# @spfn/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **The backend runtime for taking an AI-built app from prototype to production**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
`@spfn/core` is the package every SPFN app is built on. It decides the shape of a
|
|
6
|
+
feature — an entity, a repository, a route, a router registration — so that neither you
|
|
7
|
+
nor your coding agent has to invent one per feature. Everything else in SPFN
|
|
8
|
+
(`@spfn/auth`, `@spfn/mcp`, `@spfn/storage`, …) plugs into it.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
Fixing the shape is an answer to [architecture drift](https://superfunction.xyz/architecture-drift)
|
|
11
|
+
— a codebase acquiring several structures because each feature was arranged freshly. The
|
|
12
|
+
usual answers detect it after the fact; this one leaves nothing to decide. It removes
|
|
13
|
+
drift that comes from structural choice, and nothing else: two services can still
|
|
14
|
+
implement the same rule differently inside a correct shape.
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
📖 **[superfunction.xyz](https://superfunction.xyz)** — docs and the full-stack tutorial ·
|
|
17
|
+
[repository README](https://github.com/fxylabs/spfn) for the whole framework.
|
|
18
|
+
|
|
19
|
+
> **Status — Beta (`0.x`).** The public API is stabilizing but may still change between
|
|
20
|
+
> minor releases before `1.0`. Pin your version and install from the `@beta` tag.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## What is @spfn/core?
|
|
25
|
+
|
|
26
|
+
A TypeScript backend runtime with four parts, used together:
|
|
27
|
+
|
|
28
|
+
- **A route DSL** — `route.get(path).input({...}).handler(...)`, validated at runtime by
|
|
29
|
+
TypeBox and typed at compile time from the same schema.
|
|
30
|
+
- **A data layer** — PostgreSQL through Drizzle ORM, with `BaseRepository`, schema
|
|
31
|
+
helpers, and transactions that propagate automatically.
|
|
32
|
+
- **A server** — a Hono app you start as a long-lived process, or mount as serverless
|
|
33
|
+
functions on Vercel.
|
|
34
|
+
- **A Next.js bridge** — an RPC proxy and a typed client, so a route's input and output
|
|
35
|
+
types are the same object on both sides of the network.
|
|
36
|
+
|
|
37
|
+
What it is not: a frontend framework, an ORM, or a place to keep your business rules.
|
|
38
|
+
Next.js owns the frontend, Drizzle owns the SQL, and your services own the rules.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## How do I install it?
|
|
12
43
|
|
|
13
44
|
```bash
|
|
14
|
-
pnpm add @spfn/core
|
|
45
|
+
pnpm add @spfn/core@beta drizzle-orm@1.0.0-rc.4 postgres pg
|
|
46
|
+
# optional peer: next ^16.3.3 (only for the Next.js bridge)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Node `>=20.0.0`. ESM only.
|
|
50
|
+
|
|
51
|
+
> **Declare Drizzle and the Postgres drivers in your own app, not just in SPFN.**
|
|
52
|
+
> `@spfn/core` takes `drizzle-orm` as a peer dependency, and Drizzle changes how it
|
|
53
|
+
> resolves types depending on which driver packages are present. If your app does not
|
|
54
|
+
> pin the same ORM and drivers, pnpm can install a second copy of Drizzle — and then
|
|
55
|
+
> `BaseRepository` generics collapse to `unknown` and your RPC responses lose their
|
|
56
|
+
> types. `spfn create` adds these for you; add them by hand only when wiring SPFN into
|
|
57
|
+
> an app that already exists.
|
|
58
|
+
|
|
59
|
+
Optional dependencies, installed only if you use the feature: `ioredis` (cache) and
|
|
60
|
+
`ws` (WebSocket events). `pg-boss` ships as a direct dependency for background jobs.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## What does one feature look like?
|
|
65
|
+
|
|
66
|
+
Four files, always the same four, always in the same places:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
src/server/
|
|
70
|
+
entities/order.ts # the data shape (Drizzle table)
|
|
71
|
+
repositories/order.ts # persistence (extends BaseRepository)
|
|
72
|
+
routes/orders.ts # the validated API contract
|
|
73
|
+
router.ts # registration
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The point is not that this arrangement is uniquely correct. The point is that it is
|
|
77
|
+
decided. An agent asked for "add orders" twice produces the same code twice, because
|
|
78
|
+
there is nothing left to choose.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## How does a route's type reach the browser?
|
|
83
|
+
|
|
84
|
+
Through TypeScript inference, not generated client code. One artifact is generated —
|
|
85
|
+
the route map the RPC proxy needs — and nothing else.
|
|
86
|
+
|
|
15
87
|
```
|
|
88
|
+
① route DSL ② defineRouter ③ defineServerConfig → startServer
|
|
89
|
+
route.get('/users/:id') defineRouter({ getUser, defineServerConfig()
|
|
90
|
+
.input({ params }) createUser }) .routes(appRouter).build()
|
|
91
|
+
.handler(c => …) export type AppRouter startServer() → Hono on :8790
|
|
92
|
+
│ = typeof appRouter ▲
|
|
93
|
+
│ TypeBox = runtime validation + compile-time types │ registerRoutes mounts routes
|
|
94
|
+
▼ │
|
|
95
|
+
④ codegen (@spfn/core:route-map) ──► routeMap = { getUser: { method:'GET', path:'/users/:id' }, … }
|
|
96
|
+
│
|
|
97
|
+
▼
|
|
98
|
+
⑤ Next.js RPC proxy ⑥ typed client
|
|
99
|
+
app/api/rpc/[routeName]/route.ts lib/api.ts
|
|
100
|
+
createRpcProxy({ routeMap }) createApi<AppRouter>() (no codegen for the client)
|
|
101
|
+
GET/POST /api/rpc/{routeName} api.getUser.call({ params:{ id } })
|
|
102
|
+
resolves real method+path from routeMap └─ fully typed input + output
|
|
103
|
+
forwards to backend, runs interceptors
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
1. **Define a route** with `route.<method>(path).input({...}).handler(c => …)` from
|
|
107
|
+
`@spfn/core/route`. The TypeBox schemas in `.input()` do double duty: they validate
|
|
108
|
+
the request at runtime and give the handler (`await c.data()`) and the client their
|
|
109
|
+
compile-time types. The handler's **return type is inferred** — you never write a
|
|
110
|
+
response type.
|
|
111
|
+
2. **Compose routes** with `defineRouter({ … })` and export
|
|
112
|
+
`type AppRouter = typeof appRouter`. That type is the single source of truth for the
|
|
113
|
+
client.
|
|
114
|
+
3. **Boot the server** with `defineServerConfig().routes(appRouter).build()` and
|
|
115
|
+
`startServer()` from `@spfn/core/server`. It wires `ErrorHandler` and
|
|
116
|
+
`RequestLogger`, initializes the database and cache, mounts the routes, and starts
|
|
117
|
+
jobs and events. A request goes: Hono match → global middleware → route middleware
|
|
118
|
+
(`.use([...])`, e.g. `Transactional()`) → input validation → handler → response.
|
|
119
|
+
4. **Generate the route map** with `pnpm codegen`. It emits
|
|
120
|
+
`routeName → { method, path }` — the only thing the proxy needs to find the real
|
|
121
|
+
backend endpoint.
|
|
122
|
+
5. **Mount the RPC proxy** in Next.js as the `app/api/rpc/[routeName]/route.ts`
|
|
123
|
+
catch-all. The browser only ever sends `GET` (no body) or `POST` (body or formData)
|
|
124
|
+
to `/api/rpc/{routeName}`; the proxy looks up `routeMap[routeName]`, substitutes
|
|
125
|
+
`:params`, and forwards with the real method. Package route maps (`authRouteMap`,
|
|
126
|
+
`eventRouteMap`) merge into the same map.
|
|
127
|
+
6. **Call it** through `createApi<AppRouter>()`. The client is a `Proxy` over the
|
|
128
|
+
`AppRouter` type — typed in and out, with no runtime cost for the types. Errors
|
|
129
|
+
arrive as `ApiError`, or as the original error class when that class is registered in
|
|
130
|
+
the client's `errorRegistry`.
|
|
16
131
|
|
|
17
|
-
|
|
132
|
+
---
|
|
18
133
|
|
|
19
|
-
|
|
134
|
+
## Show me the whole thing in code
|
|
20
135
|
|
|
21
136
|
```typescript
|
|
22
|
-
//
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
137
|
+
// server/router.ts — ① + ②
|
|
138
|
+
import { defineRouter, route } from '@spfn/core/route';
|
|
139
|
+
import { Transactional } from '@spfn/core/db';
|
|
140
|
+
import { Type } from '@sinclair/typebox';
|
|
141
|
+
|
|
142
|
+
export const appRouter = defineRouter({
|
|
143
|
+
getUser: route.get('/users/:id')
|
|
144
|
+
.input({ params: Type.Object({ id: Type.String() }) })
|
|
145
|
+
.handler(async (c) =>
|
|
146
|
+
{
|
|
147
|
+
const { params } = await c.data(); // params.id: string
|
|
148
|
+
return { id: params.id, name: 'John' }; // return type inferred
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
createUser: route.post('/users')
|
|
152
|
+
.input({ body: Type.Object({ name: Type.String() }) })
|
|
153
|
+
.use([Transactional()]) // commit on return, rollback on throw
|
|
154
|
+
.handler(async (c) =>
|
|
155
|
+
{
|
|
156
|
+
const { body } = await c.data();
|
|
157
|
+
return { id: '2', name: body.name };
|
|
158
|
+
}),
|
|
32
159
|
});
|
|
33
160
|
|
|
34
|
-
export type
|
|
35
|
-
|
|
161
|
+
export type AppRouter = typeof appRouter;
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// server/index.ts — ③
|
|
166
|
+
import { defineServerConfig, startServer } from '@spfn/core/server';
|
|
167
|
+
import { appRouter } from './router';
|
|
168
|
+
|
|
169
|
+
export default defineServerConfig().port(8790).routes(appRouter).build();
|
|
170
|
+
await startServer(); // Hono on :8790
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
// app/api/rpc/[routeName]/route.ts — ⑤ (server-only)
|
|
175
|
+
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
176
|
+
import { routeMap } from '@/generated/route-map'; // ④ codegen output
|
|
177
|
+
|
|
178
|
+
export const { GET, POST } = createRpcProxy({ routeMap });
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// lib/api.ts — ⑥ (client-safe; no codegen)
|
|
183
|
+
import { createApi } from '@spfn/core/nextjs';
|
|
184
|
+
import type { AppRouter } from '@/server/router';
|
|
185
|
+
|
|
186
|
+
export const api = createApi<AppRouter>();
|
|
187
|
+
|
|
188
|
+
// the same client in a Server Component, a Client Component or a Server Action:
|
|
189
|
+
const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
|
|
190
|
+
const made = await api.createUser.call({ body: { name: 'A' } });
|
|
36
191
|
```
|
|
37
192
|
|
|
38
|
-
|
|
193
|
+
That the client is isomorphic does not make the three callers interchangeable. **A page's
|
|
194
|
+
initial data is awaited in the Server Component**, where
|
|
195
|
+
`api.getUser.fetchOptions({ next: { revalidate, tags } }).call(…)` participates in
|
|
196
|
+
Next.js caching and reaches the backend without a browser round trip. Fetching that same
|
|
197
|
+
first paint from a `'use client'` component inside a `useEffect` is the anti-pattern: it
|
|
198
|
+
ships a loading state and a second network hop for data the server already had. Client
|
|
199
|
+
Components and Server Actions are for what happens after the first paint — interaction
|
|
200
|
+
and mutation. Cache tags, revalidation and SSR cookie forwarding are in
|
|
201
|
+
[the Next.js bridge docs](https://superfunction.xyz/docs/packages/core/nextjs).
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## How does a repository talk to the database?
|
|
206
|
+
|
|
207
|
+
Through `BaseRepository`. Extending it gives a repository two transaction-aware
|
|
208
|
+
connections — `this.db` (write/primary) and `this.readDb` (the replica, when one is
|
|
209
|
+
configured) — plus the CRUD set as protected methods.
|
|
39
210
|
|
|
40
211
|
```typescript
|
|
41
|
-
//
|
|
212
|
+
// server/repositories/order.ts
|
|
42
213
|
import { BaseRepository } from '@spfn/core/db';
|
|
43
|
-
import {
|
|
214
|
+
import { desc } from 'drizzle-orm';
|
|
215
|
+
import { orders } from '../entities/order';
|
|
44
216
|
|
|
45
|
-
export class
|
|
217
|
+
export class OrderRepository extends BaseRepository
|
|
46
218
|
{
|
|
47
|
-
|
|
219
|
+
findRecentFor(userId: string)
|
|
48
220
|
{
|
|
49
|
-
return this.
|
|
221
|
+
return this._findMany(orders, { where: { userId }, orderBy: desc(orders.createdAt) });
|
|
50
222
|
}
|
|
51
223
|
|
|
52
|
-
|
|
224
|
+
place(data: { userId: string; total: number })
|
|
53
225
|
{
|
|
54
|
-
return this.
|
|
226
|
+
return this._create(orders, data);
|
|
55
227
|
}
|
|
228
|
+
}
|
|
56
229
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return this._findMany(users);
|
|
60
|
-
}
|
|
230
|
+
export const orderRepo = new OrderRepository();
|
|
231
|
+
```
|
|
61
232
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
233
|
+
Handlers never import drizzle query builders; repositories do. `_findMany` reads through
|
|
234
|
+
`this.readDb`, `_create` writes through `this.db`, and both getters resolve to the active
|
|
235
|
+
transaction's connection when there is one — so the same method is correct inside a
|
|
236
|
+
transaction and outside it. When a helper cannot express a query, drop to
|
|
237
|
+
`this.readDb.select()…` inside the repository rather than in the handler. The full
|
|
238
|
+
protected CRUD set is in [src/db](./src/db/README.md).
|
|
66
239
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Where do transactions and their side effects go?
|
|
243
|
+
|
|
244
|
+
`Transactional()` covers the route case — commit on return, rollback on throw. Two rules
|
|
245
|
+
decide the rest.
|
|
246
|
+
|
|
247
|
+
**Nothing takes a `tx` parameter.** The transaction travels in AsyncLocalStorage, so
|
|
248
|
+
`this.db` inside a repository already resolves to it. A service that accepts `tx` and
|
|
249
|
+
threads it downward re-implements propagation that already happened, and the first caller
|
|
250
|
+
that forgets to pass it writes outside the transaction. For a service, script or job with
|
|
251
|
+
no route around it, open one with `runInTransaction(fn, options?)`;
|
|
252
|
+
`runWithTransaction(tx, txId, fn)` is the lower-level primitive that binds an existing
|
|
253
|
+
Drizzle transaction into the context.
|
|
71
254
|
|
|
72
|
-
|
|
255
|
+
**Side effects go on the commit hooks, not inline.** An event emitted or a mail sent from
|
|
256
|
+
inside the transaction still went out when the transaction later rolls back.
|
|
257
|
+
|
|
258
|
+
| Hook | When it runs | What it is for |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| `onBeforeCommit(fn)` | Inside the still-open transaction, just before commit — a throw aborts and rolls back | Last-moment invariant checks, and statements that must land in the same commit |
|
|
261
|
+
| `onAfterCommit(fn)` | After the root transaction commits, outside the transaction context; errors are logged, never thrown | Events, mail, cache invalidation — anything the outside world observes |
|
|
262
|
+
| `onAfterRollback(fn)` | After the root transaction rolls back, before the causing error propagates; errors are logged, never thrown | Undoing external work that cannot roll itself back, such as an object already uploaded |
|
|
263
|
+
|
|
264
|
+
All three import from `@spfn/core/db`, can be registered anywhere inside the transaction,
|
|
265
|
+
and bubble to the **root** transaction — a nested block's callbacks fire on the outermost
|
|
266
|
+
outcome, not on a savepoint's.
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
import { runInTransaction, onAfterCommit } from '@spfn/core/db';
|
|
270
|
+
|
|
271
|
+
export async function placeOrder(input: { userId: string; total: number })
|
|
272
|
+
{
|
|
273
|
+
return runInTransaction(async () =>
|
|
73
274
|
{
|
|
74
|
-
|
|
75
|
-
|
|
275
|
+
const order = await orderRepo.place(input); // no tx argument, at any depth
|
|
276
|
+
onAfterCommit(() => orderPlacedEvent.emit({ orderId: order.id }));
|
|
277
|
+
|
|
278
|
+
return order;
|
|
279
|
+
});
|
|
76
280
|
}
|
|
77
281
|
```
|
|
78
282
|
|
|
79
|
-
|
|
283
|
+
---
|
|
80
284
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
285
|
+
## What else can defineServerConfig configure?
|
|
286
|
+
|
|
287
|
+
`.port()` and `.routes()` are the two every app calls. The rest of the builder is how an
|
|
288
|
+
app wires its infrastructure without touching the server's boot sequence. Every method
|
|
289
|
+
returns the builder; `.build()` ends the chain.
|
|
290
|
+
|
|
291
|
+
| Method | What it configures |
|
|
292
|
+
|---|---|
|
|
293
|
+
| `.port(n)` / `.host(s)` | Where the server listens |
|
|
294
|
+
| `.routes(router)` | The `defineRouter` router to mount, with its own `.use()` and `.packages()` |
|
|
295
|
+
| `.jobs(router, config?)` | Background jobs — a `defineJobRouter`, plus pg-boss options |
|
|
296
|
+
| `.events(router, config?)` | SSE streaming — a `defineEventRouter`, served at `GET /events/stream` |
|
|
297
|
+
| `.websockets(router, config?)` | Bidirectional WebSockets — a `defineWSRouter`, served at `WS /ws` |
|
|
298
|
+
| `.workflows(router, config?)` | `@spfn/workflow` orchestration; the engine starts once the database is ready |
|
|
299
|
+
| `.lifecycle(hooks)` | Boot and shutdown hooks. Callable more than once; hooks run in registration order |
|
|
300
|
+
| `.migrations(opts)` | The migration boot gate — `{ allowPending: true }` lets a server start behind its migrations |
|
|
301
|
+
| `.database(opts)` | Connection and pool settings |
|
|
302
|
+
| `.infrastructure(opts)` | Which infrastructure is initialized at boot |
|
|
303
|
+
| `.healthCheck(opts)` | The health endpoint |
|
|
304
|
+
| `.serverTime(clock)` | The clock behind `GET /_core/time`; normally left at the default |
|
|
305
|
+
| `.middleware(opts)` | The built-in middleware — `ErrorHandler`, `RequestLogger` |
|
|
306
|
+
| `.use(handlers)` | Additional global Hono middleware |
|
|
307
|
+
| `.middlewares(named)` | Global middleware under names, so a route can `.skip([...])` it |
|
|
308
|
+
| `.cors(opts)` | CORS |
|
|
309
|
+
| `.rateLimit(opts)` | A global default limiter plus the named policies routes resolve against |
|
|
310
|
+
| `.proxyGuard(opts)` | Trusted-proxy signature and origin verification, resolved to a `clientType` |
|
|
311
|
+
| `.outboundFetch(opts)` | The SSRF policy `safeFetch` applies to outbound calls |
|
|
312
|
+
| `.timeout(opts)` / `.shutdown(opts)` | Request timeouts and graceful shutdown |
|
|
313
|
+
| `.debug(bool)` | Debug logging |
|
|
314
|
+
|
|
315
|
+
The options each one takes are in [src/server](./src/server/README.md).
|
|
87
316
|
|
|
88
|
-
|
|
317
|
+
---
|
|
89
318
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
319
|
+
## Which import path do I use for what?
|
|
320
|
+
|
|
321
|
+
There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
|
|
322
|
+
comes from a subpath, and the table below is the complete public surface — one row per
|
|
323
|
+
entry in `package.json` `exports`, with a single exclusion: `./client` is still listed in
|
|
324
|
+
`exports` but the build no longer emits it, so it has no row (see [Pitfalls](#pitfalls)).
|
|
325
|
+
Each module has its own README with the API detail.
|
|
326
|
+
|
|
327
|
+
| Import path | Purpose | Doc |
|
|
328
|
+
|-------------|---------|-----|
|
|
329
|
+
| `@spfn/core/route` | The route DSL (`route.get(...).input(...).handler(...)`) plus `defineRouter`, `registerRoutes`, `defineMiddleware`. The core of the core. | [src/route](./src/route/README.md) |
|
|
330
|
+
| `@spfn/core/route/types` | Shared route types (`HttpMethod`, router type primitives). | [src/route](./src/route/README.md) |
|
|
331
|
+
| `@spfn/core/server` | Server entry: `defineServerConfig()` → `startServer()`, plus `createServerlessApp()` for Vercel. Middleware wiring, infra init, graceful shutdown. | [src/server](./src/server/README.md) |
|
|
332
|
+
| `@spfn/core/nextjs` | Client-safe: `createApi<AppRouter>()`, `ApiError`, client types. Never touches `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
|
|
333
|
+
| `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
|
|
334
|
+
| `@spfn/core/db` | PostgreSQL through Drizzle: CRUD helpers, `BaseRepository`, schema helpers, transactions, Postgres error mapping. One entry point for all of it. | [src/db](./src/db/README.md) |
|
|
335
|
+
| `@spfn/core/db` → manager | Connection lifecycle, pool, primary/replica, health check, reconnect (`initDatabase`, `getDatabase`). Re-exported from `@spfn/core/db`. | [src/db/manager](./src/db/manager/README.md) |
|
|
336
|
+
| `@spfn/core/db` → migrations | Which migrations each installed function package ships, and which the database has applied (`collectMigrationStatus`, `discoverFunctionMigrations`). What `spfn db status`, the boot gate and health all read. Re-exported from `@spfn/core/db`. | [src/db/migrations](https://github.com/fxylabs/spfn/blob/main/packages/core/src/db/migrations/index.ts) |
|
|
337
|
+
| `@spfn/core/db` → schema | Drizzle column helpers (`id`, `uuid`, `timestamps`, `foreignKey`, `enumText`, `typedJsonb`, `softDelete`, …). Re-exported from `@spfn/core/db`. | [src/db/schema](./src/db/schema/README.md) |
|
|
338
|
+
| `@spfn/core/db` → transaction | `Transactional()` middleware and `runInTransaction`; the transaction reaches every repository through AsyncLocalStorage. Re-exported from `@spfn/core/db`. | [src/db/transaction](./src/db/transaction/README.md) |
|
|
339
|
+
| `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
|
|
340
|
+
| `@spfn/core/errors` | Serializable HTTP and database error classes, plus `ErrorRegistry` so an error survives the trip to the client as its own class. | [src/errors](./src/errors/README.md) |
|
|
341
|
+
| `@spfn/core/security` | `safeFetch` — a drop-in `fetch` hardened against SSRF, including DNS rebinding, by pinning the connection to a validated IP. | [src/security](./src/security/README.md) |
|
|
342
|
+
| `@spfn/core/authz` | Ownership guards. `requireOwner(resource, userId)` makes "load it, then check it belongs to the requester" one call, so a handler cannot forget it. | [src/authz/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/authz/index.ts) |
|
|
343
|
+
| `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
|
|
344
|
+
| `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
|
|
345
|
+
| `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
|
|
346
|
+
| `@spfn/core/app-config` | Reads `spfn.config.js` — the one committed place that says which ports and host the app is served on (`loadAppConfig`, `resolvePorts`, `resolveHost`, `PORT_DEFAULTS`). Deliberately side-effect free, so the CLI can import it before an app's environment exists. | [src/app-config/index.ts](https://github.com/fxylabs/spfn/blob/main/packages/core/src/app-config/index.ts) |
|
|
347
|
+
| `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
|
|
348
|
+
| `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
|
|
349
|
+
| `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
|
|
350
|
+
| `@spfn/core/event` | Decoupled pub/sub (`defineEvent`, `defineEventRouter`, `eventRouteMap`). | [src/event](./src/event/README.md) |
|
|
351
|
+
| `@spfn/core/event/sse` | Server-side SSE handler and token manager. Server only. | [src/event](./src/event/README.md) |
|
|
352
|
+
| `@spfn/core/event/sse/client` | Browser SSE client (`EventSource`). | [src/event](./src/event/README.md) |
|
|
353
|
+
| `@spfn/core/event/ws` | Server-side WebSocket handler. Server only; needs the optional `ws` dependency. | [src/event](./src/event/README.md) |
|
|
354
|
+
| `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
|
|
355
|
+
| `@spfn/core/codegen` | The codegen orchestrator and the built-in generators: `@spfn/core:route-map` for the proxy's route map, `@spfn/core:contract` for the client contract. | [src/codegen](./src/codegen/README.md) |
|
|
356
|
+
| `@spfn/core/contract` | Route contracts for clients that ship separately: collect, snapshot, and the build gate that refuses a breaking change. | [src/contract](./src/contract/README.md) |
|
|
357
|
+
| `@spfn/core/ops` | The operations surface `spfn ops` drives: `opsRoute`, `createOpsRouter`, `defineOpsModule`, and the manifest the CLI discovers commands from. Structure only — the router is always authenticated, and token verification lives in `@spfn/auth`. | [How do I operate the app from the terminal?](#how-do-i-operate-the-app-from-the-terminal) |
|
|
358
|
+
|
|
359
|
+
`db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
|
|
360
|
+
They are internal modules re-exported by `@spfn/core/db` — import their symbols from
|
|
361
|
+
there.
|
|
94
362
|
|
|
95
|
-
|
|
96
|
-
.input({
|
|
97
|
-
params: Type.Object({ id: Type.String() })
|
|
98
|
-
})
|
|
99
|
-
.handler(async (c) => {
|
|
100
|
-
const { params } = await c.data();
|
|
101
|
-
const user = await userRepo.findById(params.id);
|
|
363
|
+
---
|
|
102
364
|
|
|
103
|
-
|
|
104
|
-
{
|
|
105
|
-
throw new Error('User not found');
|
|
106
|
-
}
|
|
365
|
+
## Do I have to run codegen?
|
|
107
366
|
|
|
108
|
-
|
|
109
|
-
});
|
|
367
|
+
Only for the RPC proxy, and only after you change routes.
|
|
110
368
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
.use([Transactional()])
|
|
119
|
-
.handler(async (c) => {
|
|
120
|
-
const { body } = await c.data();
|
|
121
|
-
return userRepo.create(body);
|
|
122
|
-
});
|
|
369
|
+
| Change | What to run |
|
|
370
|
+
|---|---|
|
|
371
|
+
| Added, renamed or removed a route | `pnpm codegen`, then commit the regenerated route map |
|
|
372
|
+
| Changed a route's input or output types | Nothing — the client infers from `AppRouter` |
|
|
373
|
+
| Changed an entity | `pnpm db:generate` for the migration |
|
|
123
374
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
params: Type.Object({ id: Type.String() }),
|
|
127
|
-
body: Type.Object({
|
|
128
|
-
email: Type.Optional(Type.String({ format: 'email' })),
|
|
129
|
-
name: Type.Optional(Type.String({ minLength: 1 }))
|
|
130
|
-
})
|
|
131
|
-
})
|
|
132
|
-
.use([Transactional()])
|
|
133
|
-
.handler(async (c) => {
|
|
134
|
-
const { params, body } = await c.data();
|
|
135
|
-
const user = await userRepo.update(params.id, body);
|
|
136
|
-
|
|
137
|
-
if (!user)
|
|
138
|
-
{
|
|
139
|
-
throw new Error('User not found');
|
|
140
|
-
}
|
|
375
|
+
A route name that is missing from the merged route map produces a **404 from the proxy**,
|
|
376
|
+
not from your backend. That is almost always a codegen you did not re-run.
|
|
141
377
|
|
|
142
|
-
|
|
143
|
-
|
|
378
|
+
Generated files are output. Never hand-edit them.
|
|
379
|
+
|
|
380
|
+
---
|
|
144
381
|
|
|
145
|
-
|
|
146
|
-
.input({
|
|
147
|
-
params: Type.Object({ id: Type.String() })
|
|
148
|
-
})
|
|
149
|
-
.use([Transactional()])
|
|
150
|
-
.handler(async (c) => {
|
|
151
|
-
const { params } = await c.data();
|
|
152
|
-
const user = await userRepo.delete(params.id);
|
|
382
|
+
## Why does the server refuse to start after a package upgrade?
|
|
153
383
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
384
|
+
Because the database is behind the code. A function package ships its own migrations, so
|
|
385
|
+
bumping `@spfn/auth` can add columns your database has never heard of. Before this check
|
|
386
|
+
existed, that server booted, passed its health check, and then failed every request
|
|
387
|
+
touching a new column with an opaque 500 — the error surfaced at the worst possible
|
|
388
|
+
moment, to whoever called first.
|
|
158
389
|
|
|
159
|
-
|
|
160
|
-
|
|
390
|
+
`startServer()` now compares what each installed function package ships (and
|
|
391
|
+
`src/server/drizzle`, where present) against what the database records as applied, and
|
|
392
|
+
stops:
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
Refusing to start: 1 pending migration(s) in @spfn/auth
|
|
396
|
+
@spfn/auth: 1 pending migration(s) (12/13 applied)
|
|
397
|
+
- 20260805143152_client_identity
|
|
398
|
+
Run: pnpm spfn db migrate
|
|
161
399
|
```
|
|
162
400
|
|
|
163
|
-
|
|
401
|
+
The check happens after the database connects and before anything is served, on the pool
|
|
402
|
+
the server already opened — no second connection, and no new failure mode. Three cases
|
|
403
|
+
never reach a refusal:
|
|
164
404
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
405
|
+
| Situation | What happens |
|
|
406
|
+
|---|---|
|
|
407
|
+
| The app initializes no database, or no package ships migrations | Skipped; boot proceeds as before |
|
|
408
|
+
| The database is configured but unreachable | `initDatabase()` already failed — the gate never runs, so an outage never reads as drift |
|
|
409
|
+
| The status query itself fails | Logged as "could not verify", boot proceeds |
|
|
169
410
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
411
|
+
To start anyway — a harness that migrates after boot, a rollout that must proceed —
|
|
412
|
+
set `SPFN_ALLOW_PENDING_MIGRATIONS=true`, pass `spfn dev --allow-pending-migrations`, or
|
|
413
|
+
declare it in config:
|
|
173
414
|
|
|
415
|
+
```typescript
|
|
174
416
|
export default defineServerConfig()
|
|
175
|
-
.
|
|
176
|
-
.routes(appRouter)
|
|
417
|
+
.migrations({ allowPending: true })
|
|
177
418
|
.build();
|
|
419
|
+
```
|
|
178
420
|
|
|
179
|
-
|
|
421
|
+
All three log the pending list as a warning rather than silently continuing.
|
|
422
|
+
|
|
423
|
+
**A readiness probe sees the same thing.** When detailed health is on,
|
|
424
|
+
`GET /_core/health` carries a `migrations` object beside `services`:
|
|
425
|
+
|
|
426
|
+
```json
|
|
427
|
+
{
|
|
428
|
+
"status": "ok",
|
|
429
|
+
"timestamp": "2026-08-06T09:00:00.000Z",
|
|
430
|
+
"services": { "database": { "status": "connected" }, "redis": { "status": "connected" } },
|
|
431
|
+
"migrations": {
|
|
432
|
+
"status": "up_to_date",
|
|
433
|
+
"pending": 0,
|
|
434
|
+
"checkedAt": "2026-08-06T09:00:00.000Z",
|
|
435
|
+
"targets": [{ "name": "@spfn/auth", "total": 13, "applied": 13, "pending": 0, "pendingTags": [] }]
|
|
436
|
+
}
|
|
437
|
+
}
|
|
180
438
|
```
|
|
181
439
|
|
|
182
|
-
|
|
440
|
+
`status` is `unknown` when there was nothing to check or the check failed — never
|
|
441
|
+
conflated with `up_to_date`. The snapshot is recomputed at most once every 30 seconds, so
|
|
442
|
+
a probe polling every few seconds costs no extra round-trips. The overall health `status`
|
|
443
|
+
is deliberately left alone: reporting drift must not, by itself, pull a running
|
|
444
|
+
deployment out of rotation. A probe that wants that asserts `migrations.pending === 0`.
|
|
445
|
+
|
|
446
|
+
The serverless path (`createServerlessApp`) has no boot to gate — run
|
|
447
|
+
`spfn db migrate` as a deploy step there, as you already do for seeding.
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
## Which path does the health endpoint answer on?
|
|
452
|
+
|
|
453
|
+
`/_core/health`. That is the whole answer — `@spfn/core` registers nothing on `/health`.
|
|
454
|
+
|
|
455
|
+
`/_core/` belongs to `@spfn/core` the way `/_auth/` belongs to `@spfn/auth`: the endpoints
|
|
456
|
+
in it are registered before your routes, so no route you declare can take one. That is
|
|
457
|
+
what makes `/_core/health` the right target for a readiness probe, a Dockerfile
|
|
458
|
+
`HEALTHCHECK` or an uptime monitor — the answer does not depend on what your app defines.
|
|
183
459
|
|
|
184
460
|
```typescript
|
|
185
|
-
//
|
|
186
|
-
|
|
461
|
+
// the endpoint answers at /_core/health, and nowhere else
|
|
462
|
+
export default defineServerConfig().build();
|
|
187
463
|
|
|
188
|
-
|
|
464
|
+
// an additional address, for a probe path you cannot change
|
|
465
|
+
export default defineServerConfig().healthCheck({ path: '/health' }).build();
|
|
466
|
+
|
|
467
|
+
// off → nothing answers anywhere
|
|
468
|
+
export default defineServerConfig().healthCheck({ enabled: false }).build();
|
|
189
469
|
```
|
|
190
470
|
|
|
191
|
-
|
|
471
|
+
`healthCheck.path` adds a second address. It never moves `/_core/health`, and it is
|
|
472
|
+
registered before your routes like the canonical one — so an app route on the same path
|
|
473
|
+
will not run, and the server says so at boot.
|
|
192
474
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
475
|
+
**`/health` is yours now.** Declare `GET /health` and it behaves like any other route of
|
|
476
|
+
yours. Declare nothing there and, for one release, a `GET` answers **410** naming
|
|
477
|
+
`/_core/health`, and the server warns once the first time it is hit — a readiness probe
|
|
478
|
+
failure shows an operator neither a response body nor a status text, so a bare 404 would
|
|
479
|
+
leave them nothing to search for. Upgrading an existing deployment?
|
|
480
|
+
[docs/guides/migration/health-endpoint.md](../../docs/guides/migration/health-endpoint.md).
|
|
481
|
+
|
|
482
|
+
Every built-in health address is registered before the `lifecycle.beforeRoutes` hook, and
|
|
483
|
+
a Hono middleware only wraps handlers registered after it. So a global guard your app adds
|
|
484
|
+
in that hook — which is what the hook is documented for — cannot close the endpoint a
|
|
485
|
+
probe depends on, and a probe reaches it unauthenticated. An app with `src/server/app.ts`
|
|
486
|
+
(level 3) wires its own server and gets no health endpoint at all.
|
|
198
487
|
|
|
199
488
|
---
|
|
200
489
|
|
|
201
|
-
##
|
|
490
|
+
## How does a client synchronize with the server clock?
|
|
491
|
+
|
|
492
|
+
Call `GET /_core/time` before minting a timestamped proof. It is a built-in,
|
|
493
|
+
unauthenticated and session-free operation registered before application middleware and
|
|
494
|
+
routes:
|
|
202
495
|
|
|
496
|
+
```json
|
|
497
|
+
{ "serverTimeMillis": 1750000000123 }
|
|
203
498
|
```
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
499
|
+
|
|
500
|
+
The response is a closed contract: `serverTimeMillis` is an integer Unix epoch in
|
|
501
|
+
milliseconds and no additional fields are declared. It always carries
|
|
502
|
+
`Cache-Control: no-store`; a cached clock reading is not a synchronization point.
|
|
503
|
+
|
|
504
|
+
The value is trustworthy only when the transport is trustworthy. Production clients must
|
|
505
|
+
call the endpoint over HTTPS and validate the server certificate. The endpoint does not
|
|
506
|
+
sign its response, compensate for network latency, choose an authentication skew margin,
|
|
507
|
+
or define client retry and persistence policy — those belong to the consuming protocol.
|
|
508
|
+
|
|
509
|
+
Tests can inject an exact clock without replacing global time:
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
const app = await createServer(defineServerConfig()
|
|
513
|
+
.serverTime({ clock: { now: () => 1750000000123 } })
|
|
514
|
+
.build());
|
|
218
515
|
```
|
|
219
516
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
-
|
|
223
|
-
- **Entity**: Schema definition only, no logic
|
|
517
|
+
`CORE_TIME_ROUTE`, `CORE_TIME_PATH`, `ServerTimeResponseSchema` and the
|
|
518
|
+
`ServerTimeResponse` type are exported from `@spfn/core/server` for separately deployed
|
|
519
|
+
client-contract exporters to consume from the same wire definition.
|
|
224
520
|
|
|
225
521
|
---
|
|
226
522
|
|
|
227
|
-
##
|
|
523
|
+
## Can I deploy this to Vercel?
|
|
524
|
+
|
|
525
|
+
Yes, and it is a first-class target rather than a workaround. From your app:
|
|
228
526
|
|
|
527
|
+
```bash
|
|
528
|
+
spfn add vercel
|
|
229
529
|
```
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
530
|
+
|
|
531
|
+
That scaffolds `src/app/api/backend/[[...route]]/route.ts` (a `hono/vercel` adapter) and
|
|
532
|
+
`vercel.json`. The SPFN app mounts under `/api/backend`, so the Next.js frontend and the
|
|
533
|
+
backend share one Vercel origin — point `SPFN_API_URL` at
|
|
534
|
+
`https://<your-domain>/api/backend`. It runs on the Node runtime, not edge, because SPFN
|
|
535
|
+
needs `pg` and native `bcrypt`.
|
|
536
|
+
|
|
537
|
+
The adapter is a thin wrapper around `createServerlessApp` from `@spfn/core/server`:
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
import { handle } from 'hono/vercel';
|
|
541
|
+
import { createServerlessApp } from '@spfn/core/server';
|
|
542
|
+
import serverConfig from '@/server/server.config';
|
|
543
|
+
|
|
544
|
+
const handler = async (req: Request) => handle(await createServerlessApp(serverConfig))(req);
|
|
545
|
+
export const GET = handler;
|
|
546
|
+
export const POST = handler;
|
|
242
547
|
```
|
|
243
548
|
|
|
549
|
+
It differs from `startServer()` in four ways, all forced by the platform:
|
|
550
|
+
|
|
551
|
+
| | Always-on (`startServer`) | Serverless (`createServerlessApp`) |
|
|
552
|
+
|---|---|---|
|
|
553
|
+
| Database init | welded to `serve()` | in the handler, once per warm container |
|
|
554
|
+
| Periodic DB health check | on | off — a timer only leaks across frozen invocations |
|
|
555
|
+
| Background job worker | runs in-process | **not started** — enqueuing works, nothing drains the queue |
|
|
556
|
+
| Seed and RBAC provisioning | per boot | a deploy-time step (`provisionInfrastructure`) |
|
|
557
|
+
|
|
558
|
+
The job worker is the one that bites. If your config declares jobs, the serverless path
|
|
559
|
+
logs a warning at startup: drain the queue from a scheduled endpoint (Vercel Cron calling
|
|
560
|
+
a route that processes a batch), or run jobs on an always-on target.
|
|
561
|
+
|
|
562
|
+
Background jobs, WebSocket events and the periodic health check all need the always-on
|
|
563
|
+
path: `spfn build && spfn start`, or the generated Docker files.
|
|
564
|
+
|
|
244
565
|
---
|
|
245
566
|
|
|
246
|
-
##
|
|
567
|
+
## How do I point Claude Code or another AI coding agent at this?
|
|
247
568
|
|
|
248
|
-
|
|
569
|
+
Put the contract in a file and let the agent read it, instead of describing the
|
|
570
|
+
architecture again in every prompt.
|
|
249
571
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
572
|
+
The SPFN repository states it in `CONTRIBUTING.md`: what the repo is, the commands, the
|
|
573
|
+
vertical-slice pattern, and the rules that are not negotiable — never hand-edit generated
|
|
574
|
+
files, migrations come from the schema. One file answers to people and agents alike, so
|
|
575
|
+
there is no second copy to drift apart from the first.
|
|
253
576
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
.input({
|
|
257
|
-
params: Type.Object({ id: Type.String() }),
|
|
258
|
-
query: Type.Object({ include: Type.Optional(Type.String()) })
|
|
259
|
-
})
|
|
260
|
-
.handler(async (c) => {
|
|
261
|
-
const { params, query } = await c.data();
|
|
262
|
-
// params.id, query.include are fully typed
|
|
263
|
-
});
|
|
577
|
+
Each module README under `src/` is written for the same reader. When an agent is working
|
|
578
|
+
on database code, `src/db/README.md` is the page to give it.
|
|
264
579
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
body: Type.Object({
|
|
269
|
-
email: Type.String(),
|
|
270
|
-
name: Type.String()
|
|
271
|
-
})
|
|
272
|
-
})
|
|
273
|
-
.handler(async (c) => {
|
|
274
|
-
const { body } = await c.data();
|
|
275
|
-
// body.email, body.name are fully typed
|
|
276
|
-
});
|
|
277
|
-
```
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
## How do I operate the app from the terminal?
|
|
278
583
|
|
|
279
|
-
|
|
584
|
+
Develop operations the way you develop features — as routes — and let the `spfn ops` CLI
|
|
585
|
+
discover and invoke them. No admin dashboard, and no extra vocabulary: an ops command is a
|
|
586
|
+
vertical slice whose path lives under `/_ops/`.
|
|
280
587
|
|
|
281
588
|
```typescript
|
|
282
|
-
|
|
589
|
+
// src/server/ops.ts
|
|
590
|
+
import { Type } from '@sinclair/typebox';
|
|
591
|
+
import { createOpsRouter, opsRoute } from '@spfn/core/ops';
|
|
592
|
+
import { opsTokenAuth, requireOpsScope } from '@spfn/auth/server';
|
|
593
|
+
|
|
594
|
+
export const opsRouter = createOpsRouter({
|
|
595
|
+
listSignups: opsRoute.get('/signups') // GET /_ops/signups
|
|
596
|
+
.use([requireOpsScope('waitlist:read')])
|
|
597
|
+
.input({ query: Type.Object({ limit: Type.Optional(Type.Number()) }) })
|
|
598
|
+
.handler(async (c) => signupsRepository.list((await c.data()).query.limit)),
|
|
599
|
+
}, { auth: opsTokenAuth });
|
|
600
|
+
|
|
601
|
+
// src/server/router.ts — mounted like any package router, invisible to client types
|
|
602
|
+
export const appRouter = defineRouter({ ... }).packages([opsRouter]);
|
|
603
|
+
```
|
|
283
604
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
605
|
+
`opsRoute` is `route` with the `/_ops` namespace applied, so a definition carries only the
|
|
606
|
+
path this app owns — what that path looks like, how it nests, which segments are
|
|
607
|
+
parameters are the app's decisions. It exists from `@spfn/core` **0.3.0-beta.2**.
|
|
608
|
+
`createOpsRouter` injects the auth middleware into every route (there is no
|
|
609
|
+
unauthenticated variant) and serves `GET /_ops/_manifest` — the self-description the CLI
|
|
610
|
+
reads, with each command's TypeBox schemas as JSON Schema.
|
|
290
611
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
limit: 10
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
```
|
|
612
|
+
The manifest is registered ahead of the ops routes, so none of them can take its URL even
|
|
613
|
+
when one is a pattern like `/_ops/:name`. That ordering does not reach outside the ops
|
|
614
|
+
router: routes an app declares in its own router are registered before any package router,
|
|
615
|
+
so a pattern there that covers `/_ops/_manifest` — a `/*` catch-all, say — shadows the
|
|
616
|
+
manifest, exactly as it shadows every other package route.
|
|
301
617
|
|
|
302
|
-
|
|
618
|
+
Routes may be grouped in nested `defineRouter`s, and a group's own `.use()` middlewares
|
|
619
|
+
apply to its routes — always after the injected auth, so a group-wide guard reads a
|
|
620
|
+
request that has already been authenticated. A group-level middleware must be a named one;
|
|
621
|
+
wrap a factory to give it a name:
|
|
303
622
|
|
|
304
623
|
```typescript
|
|
305
|
-
|
|
624
|
+
const requireAdmin = defineMiddleware('opsAdminScope', requireOpsScope('admin:read'));
|
|
306
625
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// Auto commit on success
|
|
312
|
-
// Auto rollback on error
|
|
313
|
-
});
|
|
626
|
+
export const opsRouter = createOpsRouter({
|
|
627
|
+
admin: defineRouter({ getStats, reindex }).use([requireAdmin]),
|
|
628
|
+
}, { auth: opsTokenAuth });
|
|
629
|
+
```
|
|
314
630
|
|
|
315
|
-
|
|
316
|
-
|
|
631
|
+
Note that a group's `.use()` middleware carries its `skips` into every route in the group,
|
|
632
|
+
suppressing that server-level middleware there.
|
|
317
633
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
634
|
+
Three things are refused when the surface is defined, rather than discovered in
|
|
635
|
+
production: a route built with `route` instead of `opsRoute` (it would carry no
|
|
636
|
+
namespace), two routes sharing a command name (the manifest flattens nested groups into
|
|
637
|
+
one list, so the CLI could not tell them apart), and a group mounting `.packages()` (those
|
|
638
|
+
routes register with neither the namespace nor the auth injection). The command name
|
|
639
|
+
`getOpsManifest` and the path `/_ops/_manifest` are reserved.
|
|
640
|
+
|
|
641
|
+
```bash
|
|
642
|
+
spfn ops list --app https://api.example.com # discover commands
|
|
643
|
+
spfn ops call listSignups --query limit=50 # invoke one
|
|
644
|
+
spfn ops call listSignups --describe # print its usage (--json for raw schemas)
|
|
322
645
|
```
|
|
323
646
|
|
|
324
|
-
###
|
|
647
|
+
### Can a package ship ops commands?
|
|
648
|
+
|
|
649
|
+
It can describe them. Whether they are reachable is your application's decision, made in
|
|
650
|
+
the `createOpsRouter` call — installing a package never adds anything to your ops surface.
|
|
651
|
+
Available from **0.3.0-beta.5**.
|
|
325
652
|
|
|
326
653
|
```typescript
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
654
|
+
// in the package
|
|
655
|
+
export const ledgerOpsModule = defineOpsModule({
|
|
656
|
+
id: 'ledger',
|
|
657
|
+
source: '@acme/ledger',
|
|
658
|
+
contractVersion: '1.0.0',
|
|
659
|
+
summary: 'Ledger diagnostics',
|
|
660
|
+
commands: {
|
|
661
|
+
verify: {
|
|
662
|
+
summary: 'Verify ledger invariants',
|
|
663
|
+
effect: 'read', // read | write | destructive
|
|
664
|
+
scopes: ['ledger:read'],
|
|
665
|
+
route: opsRoute.get('/ledger/verify').handler(verifyLedger),
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// in the application — nothing is mounted until this line names it
|
|
671
|
+
export const opsRouter = createOpsRouter({ listSignups }, {
|
|
672
|
+
auth: opsTokenAuth,
|
|
673
|
+
authorize: requireOpsScope,
|
|
674
|
+
modules: [ledgerOpsModule],
|
|
346
675
|
});
|
|
347
676
|
```
|
|
348
677
|
|
|
678
|
+
A module command is named `<module>.<command>` (`ledger.verify`) and its route must live
|
|
679
|
+
under `/_ops/<module>/`. The scopes it declares become a server-side guard, run after
|
|
680
|
+
authentication — `authorize` is required as soon as any module is mounted, and it is passed
|
|
681
|
+
in rather than imported so core stays independent of `@spfn/auth`.
|
|
682
|
+
|
|
683
|
+
What is refused at definition time: a path that could decode its way out of the module's
|
|
684
|
+
namespace, two commands in one module that could answer the same URL, and an app route that
|
|
685
|
+
overlaps a mounted module's command. That last one matters because the alternative is a
|
|
686
|
+
surface where which command answers depends on route registration order.
|
|
687
|
+
|
|
688
|
+
The manifest gains a `modules` array and per-command `module`, `summary`, `effect` and
|
|
689
|
+
`scopes`. All of it is additive — an app that mounts no modules serves exactly the v1
|
|
690
|
+
manifest it served before.
|
|
691
|
+
|
|
692
|
+
```bash
|
|
693
|
+
spfn ops modules # what is mounted, and from where
|
|
694
|
+
spfn ops list --module ledger # just that module's commands
|
|
695
|
+
spfn ops call ledger.compact --yes # effect=destructive needs this
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
Authentication is an ops token from [`@spfn/auth`](../auth/README.md#ops-tokens-spfn-ops):
|
|
699
|
+
scoped, revocable, hash-stored, issued with `spfn ops token issue` against the running app
|
|
700
|
+
— the CLI signs in as an administrator, so issuance needs no database access. On macOS the
|
|
701
|
+
CLI keeps the token in the keychain (`spfn ops token store`), and resolution order is
|
|
702
|
+
`--token` → `SPFN_OPS_TOKEN` → keychain. The `--app` URL must be https, since every command
|
|
703
|
+
carries a secret; `http` is accepted only against a loopback host.
|
|
704
|
+
|
|
349
705
|
---
|
|
350
706
|
|
|
351
|
-
##
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
| [Codegen](./docs/codegen.md) | API client generation |
|
|
364
|
-
| [Cache](./docs/cache.md) | Redis caching |
|
|
365
|
-
| [Event](./docs/event.md) | Event system |
|
|
366
|
-
| [Job](./docs/job.md) | Background jobs |
|
|
367
|
-
| [Logger](./docs/logger.md) | Logging |
|
|
368
|
-
| [Next.js](./docs/nextjs.md) | Next.js integration, RPC proxy |
|
|
707
|
+
## How is this different from NestJS or tRPC?
|
|
708
|
+
|
|
709
|
+
**NestJS** gives you a structured backend and leaves the structure to you: modules and
|
|
710
|
+
providers you design, validation and ORM of your choosing, DTOs you keep in sync with the
|
|
711
|
+
frontend by hand. It is the better fit when you need that freedom, or when your frontend
|
|
712
|
+
is not Next.js. `@spfn/core` fixes the shape instead — one vertical slice per feature,
|
|
713
|
+
TypeBox, Drizzle, and route types that reach the browser without a DTO layer. Its
|
|
714
|
+
ecosystem is small and young where NestJS's is large and mature.
|
|
715
|
+
|
|
716
|
+
**tRPC** solves a narrower problem, typed calls between client and server, and leaves
|
|
717
|
+
structure, persistence and auth to you. `@spfn/core` includes the typed-call layer as one
|
|
718
|
+
part of a whole backend.
|
|
369
719
|
|
|
370
720
|
---
|
|
371
721
|
|
|
372
|
-
##
|
|
722
|
+
## Pitfalls
|
|
723
|
+
|
|
724
|
+
- **There is no root barrel.** `import … from '@spfn/core'` does not resolve. Import from
|
|
725
|
+
a subpath. The module table above is the complete surface.
|
|
726
|
+
- **Use `@spfn/core/nextjs` for the client, not `@spfn/core/client`.** `package.json`
|
|
727
|
+
still lists a `./client` export, but the build does not emit it — the entry is disabled
|
|
728
|
+
and there is no `src/client`. `createApi`, `ApiError` and every client type ship from
|
|
729
|
+
`@spfn/core/nextjs`. Treat `@spfn/core/client` as non-functional.
|
|
730
|
+
- **The client/server boundary is load-bearing.** `@spfn/core/nextjs/server` pulls in
|
|
731
|
+
`next/headers` and `next/server`; importing it from a Client Component breaks the
|
|
732
|
+
build. `@spfn/core/env/loader`, `@spfn/core/event/sse` and `@spfn/core/event/ws` are
|
|
733
|
+
server-only too. Client code uses the `*/client` and isomorphic entry points.
|
|
734
|
+
- **`db/manager`, `db/schema` and `db/transaction` are not subpaths.** Importing
|
|
735
|
+
`@spfn/core/db/transaction` fails to resolve. Import those symbols from `@spfn/core/db`.
|
|
736
|
+
- **The client needs no codegen; the proxy does.** A missing route name is a proxy 404.
|
|
737
|
+
- **Contracts are for clients TypeScript cannot reach.** `.contract()` and the build gate
|
|
738
|
+
exist for a mobile app or an external consumer, compiled and shipped separately. A web
|
|
739
|
+
client takes its types from `AppRouter` in the same build, so a removed response field
|
|
740
|
+
already breaks the compile. Do not put `.contract()` on a route only the web app calls.
|
|
741
|
+
- **The proxy decides the real HTTP method.** The browser only sends GET or POST to
|
|
742
|
+
`/api/rpc/...`; a PUT, PATCH or DELETE route still works because the method comes from
|
|
743
|
+
the route map.
|
|
744
|
+
- **A package upgrade is not done until `spfn db migrate` has run.** The server refuses to
|
|
745
|
+
start while a function package has migrations the database has not applied. That is the
|
|
746
|
+
gate working, not a bug — see
|
|
747
|
+
[Why does the server refuse to start after a package upgrade?](#why-does-the-server-refuse-to-start-after-a-package-upgrade)
|
|
748
|
+
- **Cache, events and jobs degrade quietly.** `@spfn/core/cache` runs disabled — its
|
|
749
|
+
getters return `undefined` — when there is no cache config or no `ioredis`, and
|
|
750
|
+
WebSocket events need the optional `ws` dependency. Do not write code expecting them to
|
|
751
|
+
throw.
|
|
373
752
|
|
|
374
|
-
|
|
375
|
-
# Migration
|
|
376
|
-
npx spfn db generate # Generate migration
|
|
377
|
-
npx spfn db migrate # Apply migration
|
|
753
|
+
---
|
|
378
754
|
|
|
379
|
-
|
|
380
|
-
pnpm spfn:dev # Start dev server (auto codegen)
|
|
755
|
+
## FAQ
|
|
381
756
|
|
|
382
|
-
|
|
383
|
-
|
|
757
|
+
**Can I use @spfn/core without Next.js?**
|
|
758
|
+
Yes. `next` is an optional peer dependency and the Next.js bridge is two modules
|
|
759
|
+
(`@spfn/core/nextjs`, `@spfn/core/nextjs/server`). The server runs on Hono by itself. You
|
|
760
|
+
give up the typed client and the RPC proxy, which are the Next.js-side pieces.
|
|
384
761
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
762
|
+
**Can I use Prisma instead of Drizzle?**
|
|
763
|
+
No. `drizzle-orm` is a required peer dependency (`>=1.0.0-rc.4 <2`) and the repository
|
|
764
|
+
layer is built on it.
|
|
765
|
+
|
|
766
|
+
**Do I need Redis?**
|
|
767
|
+
Only for features that use it. `ioredis` is an optional dependency, and without cache
|
|
768
|
+
configuration the cache module reports itself disabled instead of failing. The same holds
|
|
769
|
+
for `ws` and WebSocket events.
|
|
770
|
+
|
|
771
|
+
**Does @spfn/core require PostgreSQL specifically?**
|
|
772
|
+
Yes, 14 or later. The data layer is Drizzle on Postgres, with `postgres.js` as the
|
|
773
|
+
default driver; the provider is injectable, which is how PGlite works in tests.
|
|
774
|
+
|
|
775
|
+
**Why can't I import from `@spfn/core` directly?**
|
|
776
|
+
There is no `.` entry in `exports` — by design. Subpaths keep server-only code out of
|
|
777
|
+
client bundles, which a single barrel file cannot do.
|
|
778
|
+
|
|
779
|
+
**Which Node version?**
|
|
780
|
+
`>=20.0.0`. The package is ESM only.
|
|
781
|
+
|
|
782
|
+
**Where do my business rules go?**
|
|
783
|
+
In services and repositories, not in route handlers. A handler validates, calls, and
|
|
784
|
+
returns. `@spfn/core/authz` covers the one rule that is easy to forget: `requireOwner`
|
|
785
|
+
returns the resource only if it belongs to the requester, and answers "not found" either
|
|
786
|
+
way so the endpoint never reveals that someone else's record exists.
|
|
787
|
+
|
|
788
|
+
---
|
|
789
|
+
|
|
790
|
+
## Related packages
|
|
791
|
+
|
|
792
|
+
Other SPFN packages build on `@spfn/core`:
|
|
793
|
+
|
|
794
|
+
- [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Exports
|
|
795
|
+
`authRouteMap` and registers proxy interceptors automatically; merge its route map into
|
|
796
|
+
`createRpcProxy`.
|
|
797
|
+
- [`@spfn/mcp`](../mcp/README.md) — exposes your operations as MCP tools, so an agent can
|
|
798
|
+
run them instead of you building an admin dashboard.
|
|
799
|
+
- `@spfn/i18n`, `@spfn/storage`, `@spfn/notification`, `@spfn/cms`, `@spfn/monitor`,
|
|
800
|
+
`@spfn/migrate`, `@spfn/workflow` — see each package's README under
|
|
801
|
+
[`packages/`](../).
|
|
388
802
|
|
|
389
803
|
---
|
|
390
804
|
|
|
391
805
|
## License
|
|
392
806
|
|
|
393
|
-
MIT
|
|
807
|
+
MIT © FXY Inc.
|