@spfn/core 0.2.0-beta.9 → 0.3.0-beta.2
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 +467 -305
- package/dist/authz/index.d.ts +34 -0
- package/dist/authz/index.js +810 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/{boss-DI1r4kTS.d.ts → boss-D16fO2oG.d.ts} +41 -1
- 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 +615 -6
- package/dist/config/index.js +124 -5
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +220 -0
- package/dist/contract/index.js +558 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/db/index.d.ts +528 -85
- package/dist/db/index.js +831 -122
- package/dist/db/index.js.map +1 -1
- package/dist/define-middleware-DfDP39Nq.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 +32 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.d.ts +10 -0
- package/dist/errors/index.js +418 -5
- 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 +42 -3
- package/dist/event/sse/client.js +128 -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 +2 -2
- package/dist/job/index.js +155 -42
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +14 -0
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +347 -9
- package/dist/middleware/index.js +1462 -15
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +42 -28
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +35 -51
- package/dist/nextjs/server.js +126 -60
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +152 -0
- package/dist/ops/index.js +500 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/route/index.d.ts +8 -694
- package/dist/route/index.js +111 -22
- package/dist/route/index.js.map +1 -1
- package/dist/router-Qbssr11H.d.ts +676 -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 +491 -22
- package/dist/server/index.js +1887 -308
- 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-D1c57Ko-.d.ts +115 -0
- package/dist/types-ZQODsBft.d.ts +282 -0
- package/package.json +244 -208
- 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,555 @@
|
|
|
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.2.11 (only for the Next.js bridge)
|
|
15
47
|
```
|
|
16
48
|
|
|
17
|
-
|
|
49
|
+
Node `>=20.0.0`. ESM only.
|
|
18
50
|
|
|
19
|
-
|
|
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.
|
|
20
58
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import { pgTable, text, boolean } from 'drizzle-orm/pg-core';
|
|
24
|
-
import { id, timestamps } from '@spfn/core/db';
|
|
25
|
-
|
|
26
|
-
export const users = pgTable('users', {
|
|
27
|
-
id: id(),
|
|
28
|
-
email: text('email').notNull().unique(),
|
|
29
|
-
name: text('name').notNull(),
|
|
30
|
-
isActive: boolean('is_active').notNull().default(true),
|
|
31
|
-
...timestamps()
|
|
32
|
-
});
|
|
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.
|
|
33
61
|
|
|
34
|
-
|
|
35
|
-
export type NewUser = typeof users.$inferInsert;
|
|
36
|
-
```
|
|
62
|
+
---
|
|
37
63
|
|
|
38
|
-
|
|
64
|
+
## What does one feature look like?
|
|
39
65
|
|
|
40
|
-
|
|
41
|
-
// src/server/repositories/user.repository.ts
|
|
42
|
-
import { BaseRepository } from '@spfn/core/db';
|
|
43
|
-
import { users, type User, type NewUser } from '../entities/users';
|
|
66
|
+
Four files, always the same four, always in the same places:
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
async findByEmail(email: string): Promise<User | null>
|
|
53
|
-
{
|
|
54
|
-
return this._findOne(users, { email });
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async findAll(): Promise<User[]>
|
|
58
|
-
{
|
|
59
|
-
return this._findMany(users);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async create(data: NewUser): Promise<User>
|
|
63
|
-
{
|
|
64
|
-
return this._create(users, data);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async update(id: string, data: Partial<NewUser>): Promise<User | null>
|
|
68
|
-
{
|
|
69
|
-
return this._updateOne(users, { id }, data);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async delete(id: string): Promise<User | null>
|
|
73
|
-
{
|
|
74
|
-
return this._deleteOne(users, { id });
|
|
75
|
-
}
|
|
76
|
-
}
|
|
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
|
|
77
74
|
```
|
|
78
75
|
|
|
79
|
-
|
|
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.
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
// src/server/routes/users.ts
|
|
83
|
-
import { route } from '@spfn/core/route';
|
|
84
|
-
import { Transactional } from '@spfn/core/db';
|
|
85
|
-
import { Type } from '@sinclair/typebox';
|
|
86
|
-
import { UserRepository } from '../repositories/user.repository';
|
|
80
|
+
---
|
|
87
81
|
|
|
88
|
-
|
|
82
|
+
## How does a route's type reach the browser?
|
|
89
83
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return userRepo.findAll();
|
|
93
|
-
});
|
|
84
|
+
Through TypeScript inference, not generated client code. One artifact is generated —
|
|
85
|
+
the route map the RPC proxy needs — and nothing else.
|
|
94
86
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
```
|
|
102
105
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
}
|
|
141
|
-
|
|
142
|
-
return user;
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
export const deleteUser = route.delete('/users/:id')
|
|
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);
|
|
153
|
-
|
|
154
|
-
if (!user)
|
|
155
|
-
{
|
|
156
|
-
throw new Error('User not found');
|
|
157
|
-
}
|
|
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`.
|
|
158
131
|
|
|
159
|
-
|
|
160
|
-
});
|
|
161
|
-
```
|
|
132
|
+
---
|
|
162
133
|
|
|
163
|
-
|
|
134
|
+
## Show me the whole thing in code
|
|
164
135
|
|
|
165
136
|
```typescript
|
|
166
|
-
//
|
|
167
|
-
import {
|
|
168
|
-
import
|
|
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';
|
|
169
141
|
|
|
170
|
-
const appRouter = defineRouter({
|
|
171
|
-
|
|
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
|
+
}),
|
|
172
159
|
});
|
|
173
160
|
|
|
174
|
-
export default defineServerConfig()
|
|
175
|
-
.port(8790)
|
|
176
|
-
.routes(appRouter)
|
|
177
|
-
.build();
|
|
178
|
-
|
|
179
161
|
export type AppRouter = typeof appRouter;
|
|
180
162
|
```
|
|
181
163
|
|
|
182
|
-
|
|
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
|
+
```
|
|
183
172
|
|
|
184
173
|
```typescript
|
|
185
|
-
//
|
|
186
|
-
import {
|
|
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
|
|
187
177
|
|
|
188
|
-
|
|
178
|
+
export const { GET, POST } = createRpcProxy({ routeMap });
|
|
189
179
|
```
|
|
190
180
|
|
|
191
|
-
|
|
181
|
+
```typescript
|
|
182
|
+
// lib/api.ts — ⑥ (client-safe; no codegen)
|
|
183
|
+
import { createApi } from '@spfn/core/nextjs';
|
|
184
|
+
import type { AppRouter } from '@/server/router';
|
|
192
185
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
186
|
+
export const api = createApi<AppRouter>();
|
|
187
|
+
|
|
188
|
+
// anywhere — server component, client component, or 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' } });
|
|
197
191
|
```
|
|
198
192
|
|
|
199
193
|
---
|
|
200
194
|
|
|
201
|
-
##
|
|
195
|
+
## Which import path do I use for what?
|
|
196
|
+
|
|
197
|
+
There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
|
|
198
|
+
comes from a subpath, and the table below is the complete public surface — one row per
|
|
199
|
+
entry in `package.json` `exports`. Each module has its own README with the API detail.
|
|
200
|
+
|
|
201
|
+
| Import path | Purpose | Doc |
|
|
202
|
+
|-------------|---------|-----|
|
|
203
|
+
| `@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) |
|
|
204
|
+
| `@spfn/core/route/types` | Shared route types (`HttpMethod`, router type primitives). | [src/route](./src/route/README.md) |
|
|
205
|
+
| `@spfn/core/server` | Server entry: `defineServerConfig()` → `startServer()`, plus `createServerlessApp()` for Vercel. Middleware wiring, infra init, graceful shutdown. | [src/server](./src/server/README.md) |
|
|
206
|
+
| `@spfn/core/nextjs` | Client-safe: `createApi<AppRouter>()`, `ApiError`, client types. Never touches `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
|
|
207
|
+
| `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
|
|
208
|
+
| `@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) |
|
|
209
|
+
| `@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) |
|
|
210
|
+
| `@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](./src/db/migrations/index.ts) |
|
|
211
|
+
| `@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) |
|
|
212
|
+
| `@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) |
|
|
213
|
+
| `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
|
|
214
|
+
| `@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) |
|
|
215
|
+
| `@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) |
|
|
216
|
+
| `@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](./src/authz/index.ts) |
|
|
217
|
+
| `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
|
|
218
|
+
| `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
|
|
219
|
+
| `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
|
|
220
|
+
| `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
|
|
221
|
+
| `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
|
|
222
|
+
| `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
|
|
223
|
+
| `@spfn/core/event` | Decoupled pub/sub (`defineEvent`, `defineEventRouter`, `eventRouteMap`). | [src/event](./src/event/README.md) |
|
|
224
|
+
| `@spfn/core/event/sse` | Server-side SSE handler and token manager. Server only. | [src/event](./src/event/README.md) |
|
|
225
|
+
| `@spfn/core/event/sse/client` | Browser SSE client (`EventSource`). | [src/event](./src/event/README.md) |
|
|
226
|
+
| `@spfn/core/event/ws` | Server-side WebSocket handler. Server only; needs the optional `ws` dependency. | [src/event](./src/event/README.md) |
|
|
227
|
+
| `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
|
|
228
|
+
| `@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) |
|
|
229
|
+
| `@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) |
|
|
230
|
+
|
|
231
|
+
`db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
|
|
232
|
+
They are internal modules re-exported by `@spfn/core/db` — import their symbols from
|
|
233
|
+
there.
|
|
202
234
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
┌──────────────▼──────────────────────────┐
|
|
215
|
-
│ Entity Layer │ Database schema (Drizzle)
|
|
216
|
-
│ pgTable('users', { id, email, ... }) │
|
|
217
|
-
└─────────────────────────────────────────┘
|
|
218
|
-
```
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Do I have to run codegen?
|
|
238
|
+
|
|
239
|
+
Only for the RPC proxy, and only after you change routes.
|
|
240
|
+
|
|
241
|
+
| Change | What to run |
|
|
242
|
+
|---|---|
|
|
243
|
+
| Added, renamed or removed a route | `pnpm codegen`, then commit the regenerated route map |
|
|
244
|
+
| Changed a route's input or output types | Nothing — the client infers from `AppRouter` |
|
|
245
|
+
| Changed an entity | `pnpm db:generate` for the migration |
|
|
219
246
|
|
|
220
|
-
**
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
247
|
+
A route name that is missing from the merged route map produces a **404 from the proxy**,
|
|
248
|
+
not from your backend. That is almost always a codegen you did not re-run.
|
|
249
|
+
|
|
250
|
+
Generated files are output. Never hand-edit them.
|
|
224
251
|
|
|
225
252
|
---
|
|
226
253
|
|
|
227
|
-
##
|
|
254
|
+
## Why does the server refuse to start after a package upgrade?
|
|
255
|
+
|
|
256
|
+
Because the database is behind the code. A function package ships its own migrations, so
|
|
257
|
+
bumping `@spfn/auth` can add columns your database has never heard of. Before this check
|
|
258
|
+
existed, that server booted, passed its health check, and then failed every request
|
|
259
|
+
touching a new column with an opaque 500 — the error surfaced at the worst possible
|
|
260
|
+
moment, to whoever called first.
|
|
261
|
+
|
|
262
|
+
`startServer()` now compares what each installed function package ships (and
|
|
263
|
+
`src/server/drizzle`, where present) against what the database records as applied, and
|
|
264
|
+
stops:
|
|
228
265
|
|
|
229
266
|
```
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
├── repositories/ # Data access + business logic
|
|
235
|
-
│ ├── user.repository.ts
|
|
236
|
-
│ └── index.ts
|
|
237
|
-
├── routes/ # API routes
|
|
238
|
-
│ ├── users.ts
|
|
239
|
-
│ └── index.ts
|
|
240
|
-
├── server.config.ts # Server configuration
|
|
241
|
-
└── index.ts # Entry point
|
|
267
|
+
Refusing to start: 1 pending migration(s) in @spfn/auth
|
|
268
|
+
@spfn/auth: 1 pending migration(s) (12/13 applied)
|
|
269
|
+
- 20260805143152_client_identity
|
|
270
|
+
Run: pnpm spfn db migrate
|
|
242
271
|
```
|
|
243
272
|
|
|
244
|
-
|
|
273
|
+
The check happens after the database connects and before anything is served, on the pool
|
|
274
|
+
the server already opened — no second connection, and no new failure mode. Three cases
|
|
275
|
+
never reach a refusal:
|
|
245
276
|
|
|
246
|
-
|
|
277
|
+
| Situation | What happens |
|
|
278
|
+
|---|---|
|
|
279
|
+
| The app initializes no database, or no package ships migrations | Skipped; boot proceeds as before |
|
|
280
|
+
| The database is configured but unreachable | `initDatabase()` already failed — the gate never runs, so an outage never reads as drift |
|
|
281
|
+
| The status query itself fails | Logged as "could not verify", boot proceeds |
|
|
247
282
|
|
|
248
|
-
|
|
283
|
+
To start anyway — a harness that migrates after boot, a rollout that must proceed —
|
|
284
|
+
set `SPFN_ALLOW_PENDING_MIGRATIONS=true`, pass `spfn dev --allow-pending-migrations`, or
|
|
285
|
+
declare it in config:
|
|
249
286
|
|
|
250
287
|
```typescript
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
// GET with params and query
|
|
255
|
-
route.get('/users/:id')
|
|
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
|
-
});
|
|
264
|
-
|
|
265
|
-
// POST with body
|
|
266
|
-
route.post('/users')
|
|
267
|
-
.input({
|
|
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
|
-
});
|
|
288
|
+
export default defineServerConfig()
|
|
289
|
+
.migrations({ allowPending: true })
|
|
290
|
+
.build();
|
|
277
291
|
```
|
|
278
292
|
|
|
279
|
-
|
|
293
|
+
All three log the pending list as a warning rather than silently continuing.
|
|
280
294
|
|
|
281
|
-
|
|
282
|
-
|
|
295
|
+
**A readiness probe sees the same thing.** When detailed health is on, `GET /health`
|
|
296
|
+
carries a `migrations` object beside `services`:
|
|
283
297
|
|
|
284
|
-
|
|
298
|
+
```json
|
|
285
299
|
{
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
orderBy: desc(users.createdAt),
|
|
296
|
-
limit: 10
|
|
297
|
-
});
|
|
298
|
-
}
|
|
300
|
+
"status": "ok",
|
|
301
|
+
"timestamp": "2026-08-06T09:00:00.000Z",
|
|
302
|
+
"services": { "database": { "status": "connected" }, "redis": { "status": "connected" } },
|
|
303
|
+
"migrations": {
|
|
304
|
+
"status": "up_to_date",
|
|
305
|
+
"pending": 0,
|
|
306
|
+
"checkedAt": "2026-08-06T09:00:00.000Z",
|
|
307
|
+
"targets": [{ "name": "@spfn/auth", "total": 13, "applied": 13, "pending": 0, "pendingTags": [] }]
|
|
308
|
+
}
|
|
299
309
|
}
|
|
300
310
|
```
|
|
301
311
|
|
|
302
|
-
|
|
312
|
+
`status` is `unknown` when there was nothing to check or the check failed — never
|
|
313
|
+
conflated with `up_to_date`. The snapshot is recomputed at most once every 30 seconds, so
|
|
314
|
+
a probe polling every few seconds costs no extra round-trips. The overall health `status`
|
|
315
|
+
is deliberately left alone: reporting drift must not, by itself, pull a running
|
|
316
|
+
deployment out of rotation. A probe that wants that asserts `migrations.pending === 0`.
|
|
303
317
|
|
|
304
|
-
|
|
305
|
-
|
|
318
|
+
The serverless path (`createServerlessApp`) has no boot to gate — run
|
|
319
|
+
`spfn db migrate` as a deploy step there, as you already do for seeding.
|
|
306
320
|
|
|
307
|
-
|
|
308
|
-
route.post('/users')
|
|
309
|
-
.use([Transactional()])
|
|
310
|
-
.handler(async (c) => {
|
|
311
|
-
// Auto commit on success
|
|
312
|
-
// Auto rollback on error
|
|
313
|
-
});
|
|
321
|
+
---
|
|
314
322
|
|
|
315
|
-
|
|
316
|
-
import { runWithTransaction } from '@spfn/core/db';
|
|
323
|
+
## Can I deploy this to Vercel?
|
|
317
324
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
325
|
+
Yes, and it is a first-class target rather than a workaround. From your app:
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
spfn add vercel
|
|
322
329
|
```
|
|
323
330
|
|
|
324
|
-
|
|
331
|
+
That scaffolds `src/app/api/backend/[[...route]]/route.ts` (a `hono/vercel` adapter) and
|
|
332
|
+
`vercel.json`. The SPFN app mounts under `/api/backend`, so the Next.js frontend and the
|
|
333
|
+
backend share one Vercel origin — point `SPFN_API_URL` at
|
|
334
|
+
`https://<your-domain>/api/backend`. It runs on the Node runtime, not edge, because SPFN
|
|
335
|
+
needs `pg` and native `bcrypt`.
|
|
336
|
+
|
|
337
|
+
The adapter is a thin wrapper around `createServerlessApp` from `@spfn/core/server`:
|
|
325
338
|
|
|
326
339
|
```typescript
|
|
327
|
-
import {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
enumText, // type-safe enum
|
|
335
|
-
typedJsonb // type-safe JSONB
|
|
336
|
-
} from '@spfn/core/db';
|
|
337
|
-
|
|
338
|
-
export const posts = pgTable('posts', {
|
|
339
|
-
id: id(),
|
|
340
|
-
title: text('title').notNull(),
|
|
341
|
-
authorId: foreignKey('author', () => users.id),
|
|
342
|
-
status: enumText('status', ['draft', 'published']).default('draft'),
|
|
343
|
-
metadata: typedJsonb<{ views: number }>('metadata'),
|
|
344
|
-
...timestamps(),
|
|
345
|
-
...softDelete()
|
|
346
|
-
});
|
|
340
|
+
import { handle } from 'hono/vercel';
|
|
341
|
+
import { createServerlessApp } from '@spfn/core/server';
|
|
342
|
+
import serverConfig from '@/server/server.config';
|
|
343
|
+
|
|
344
|
+
const handler = async (req: Request) => handle(await createServerlessApp(serverConfig))(req);
|
|
345
|
+
export const GET = handler;
|
|
346
|
+
export const POST = handler;
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
+
It differs from `startServer()` in four ways, all forced by the platform:
|
|
350
|
+
|
|
351
|
+
| | Always-on (`startServer`) | Serverless (`createServerlessApp`) |
|
|
352
|
+
|---|---|---|
|
|
353
|
+
| Database init | welded to `serve()` | in the handler, once per warm container |
|
|
354
|
+
| Periodic DB health check | on | off — a timer only leaks across frozen invocations |
|
|
355
|
+
| Background job worker | runs in-process | **not started** — enqueuing works, nothing drains the queue |
|
|
356
|
+
| Seed and RBAC provisioning | per boot | a deploy-time step (`provisionInfrastructure`) |
|
|
357
|
+
|
|
358
|
+
The job worker is the one that bites. If your config declares jobs, the serverless path
|
|
359
|
+
logs a warning at startup: drain the queue from a scheduled endpoint (Vercel Cron calling
|
|
360
|
+
a route that processes a batch), or run jobs on an always-on target.
|
|
361
|
+
|
|
362
|
+
Background jobs, WebSocket events and the periodic health check all need the always-on
|
|
363
|
+
path: `spfn build && spfn start`, or the generated Docker files.
|
|
364
|
+
|
|
349
365
|
---
|
|
350
366
|
|
|
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 |
|
|
367
|
+
## How do I point Claude Code or another AI coding agent at this?
|
|
368
|
+
|
|
369
|
+
Put the contract in a file and let the agent read it, instead of describing the
|
|
370
|
+
architecture again in every prompt.
|
|
371
|
+
|
|
372
|
+
The SPFN repository states it in `CONTRIBUTING.md`: what the repo is, the commands, the
|
|
373
|
+
vertical-slice pattern, and the rules that are not negotiable — never hand-edit generated
|
|
374
|
+
files, migrations come from the schema. One file answers to people and agents alike, so
|
|
375
|
+
there is no second copy to drift apart from the first.
|
|
376
|
+
|
|
377
|
+
Each module README under `src/` is written for the same reader. When an agent is working
|
|
378
|
+
on database code, `src/db/README.md` is the page to give it.
|
|
369
379
|
|
|
370
380
|
---
|
|
371
381
|
|
|
372
|
-
##
|
|
382
|
+
## How do I operate the app from the terminal?
|
|
373
383
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
384
|
+
Develop operations the way you develop features — as routes — and let the `spfn ops` CLI
|
|
385
|
+
discover and invoke them. No admin dashboard, and no extra vocabulary: an ops command is a
|
|
386
|
+
vertical slice whose path lives under `/_ops/`.
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
// src/server/ops.ts
|
|
390
|
+
import { Type } from '@sinclair/typebox';
|
|
391
|
+
import { createOpsRouter, opsRoute } from '@spfn/core/ops';
|
|
392
|
+
import { opsTokenAuth, requireOpsScope } from '@spfn/auth/server';
|
|
393
|
+
|
|
394
|
+
export const opsRouter = createOpsRouter({
|
|
395
|
+
listSignups: opsRoute.get('/signups') // GET /_ops/signups
|
|
396
|
+
.use([requireOpsScope('waitlist:read')])
|
|
397
|
+
.input({ query: Type.Object({ limit: Type.Optional(Type.Number()) }) })
|
|
398
|
+
.handler(async (c) => signupsRepository.list((await c.data()).query.limit)),
|
|
399
|
+
}, { auth: opsTokenAuth });
|
|
400
|
+
|
|
401
|
+
// src/server/router.ts — mounted like any package router, invisible to client types
|
|
402
|
+
export const appRouter = defineRouter({ ... }).packages([opsRouter]);
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`opsRoute` is `route` with the `/_ops` namespace applied, so a definition carries only the
|
|
406
|
+
path this app owns — what that path looks like, how it nests, which segments are
|
|
407
|
+
parameters are the app's decisions. It exists from `@spfn/core` **0.3.0-beta.2**.
|
|
408
|
+
`createOpsRouter` injects the auth middleware into every route (there is no
|
|
409
|
+
unauthenticated variant) and serves `GET /_ops/_manifest` — the self-description the CLI
|
|
410
|
+
reads, with each command's TypeBox schemas as JSON Schema.
|
|
411
|
+
|
|
412
|
+
The manifest is registered ahead of the ops routes, so none of them can take its URL even
|
|
413
|
+
when one is a pattern like `/_ops/:name`. That ordering does not reach outside the ops
|
|
414
|
+
router: routes an app declares in its own router are registered before any package router,
|
|
415
|
+
so a pattern there that covers `/_ops/_manifest` — a `/*` catch-all, say — shadows the
|
|
416
|
+
manifest, exactly as it shadows every other package route.
|
|
417
|
+
|
|
418
|
+
Routes may be grouped in nested `defineRouter`s, and a group's own `.use()` middlewares
|
|
419
|
+
apply to its routes — always after the injected auth, so a group-wide guard reads a
|
|
420
|
+
request that has already been authenticated. A group-level middleware must be a named one;
|
|
421
|
+
wrap a factory to give it a name:
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
const requireAdmin = defineMiddleware('opsAdminScope', requireOpsScope('admin:read'));
|
|
425
|
+
|
|
426
|
+
export const opsRouter = createOpsRouter({
|
|
427
|
+
admin: defineRouter({ getStats, reindex }).use([requireAdmin]),
|
|
428
|
+
}, { auth: opsTokenAuth });
|
|
429
|
+
```
|
|
378
430
|
|
|
379
|
-
|
|
380
|
-
|
|
431
|
+
Note that a group's `.use()` middleware carries its `skips` into every route in the group,
|
|
432
|
+
suppressing that server-level middleware there.
|
|
381
433
|
|
|
382
|
-
|
|
383
|
-
|
|
434
|
+
Three things are refused when the surface is defined, rather than discovered in
|
|
435
|
+
production: a route built with `route` instead of `opsRoute` (it would carry no
|
|
436
|
+
namespace), two routes sharing a command name (the manifest flattens nested groups into
|
|
437
|
+
one list, so the CLI could not tell them apart), and a group mounting `.packages()` (those
|
|
438
|
+
routes register with neither the namespace nor the auth injection). The command name
|
|
439
|
+
`getOpsManifest` and the path `/_ops/_manifest` are reserved.
|
|
384
440
|
|
|
385
|
-
|
|
386
|
-
|
|
441
|
+
```bash
|
|
442
|
+
spfn ops list --app https://api.example.com # discover commands
|
|
443
|
+
spfn ops call listSignups --query limit=50 # invoke one
|
|
444
|
+
spfn ops call listSignups --describe # print its usage (--json for raw schemas)
|
|
387
445
|
```
|
|
388
446
|
|
|
447
|
+
Authentication is an ops token from [`@spfn/auth`](../auth/README.md#ops-tokens-spfn-ops):
|
|
448
|
+
scoped, revocable, hash-stored, issued with `spfn ops token issue` where database access
|
|
449
|
+
already exists — a deployed app has no token-creation endpoint. On macOS the CLI keeps the
|
|
450
|
+
token in the keychain (`spfn ops token store`), and resolution order is `--token` →
|
|
451
|
+
`SPFN_OPS_TOKEN` → keychain.
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## How is this different from NestJS or tRPC?
|
|
456
|
+
|
|
457
|
+
**NestJS** gives you a structured backend and leaves the structure to you: modules and
|
|
458
|
+
providers you design, validation and ORM of your choosing, DTOs you keep in sync with the
|
|
459
|
+
frontend by hand. It is the better fit when you need that freedom, or when your frontend
|
|
460
|
+
is not Next.js. `@spfn/core` fixes the shape instead — one vertical slice per feature,
|
|
461
|
+
TypeBox, Drizzle, and route types that reach the browser without a DTO layer. Its
|
|
462
|
+
ecosystem is small and young where NestJS's is large and mature.
|
|
463
|
+
|
|
464
|
+
**tRPC** solves a narrower problem, typed calls between client and server, and leaves
|
|
465
|
+
structure, persistence and auth to you. `@spfn/core` includes the typed-call layer as one
|
|
466
|
+
part of a whole backend.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## Pitfalls
|
|
471
|
+
|
|
472
|
+
- **There is no root barrel.** `import … from '@spfn/core'` does not resolve. Import from
|
|
473
|
+
a subpath. The module table above is the complete surface.
|
|
474
|
+
- **Use `@spfn/core/nextjs` for the client, not `@spfn/core/client`.** `package.json`
|
|
475
|
+
still lists a `./client` export, but the build does not emit it — the entry is disabled
|
|
476
|
+
and there is no `src/client`. `createApi`, `ApiError` and every client type ship from
|
|
477
|
+
`@spfn/core/nextjs`. Treat `@spfn/core/client` as non-functional.
|
|
478
|
+
- **The client/server boundary is load-bearing.** `@spfn/core/nextjs/server` pulls in
|
|
479
|
+
`next/headers` and `next/server`; importing it from a Client Component breaks the
|
|
480
|
+
build. `@spfn/core/env/loader`, `@spfn/core/event/sse` and `@spfn/core/event/ws` are
|
|
481
|
+
server-only too. Client code uses the `*/client` and isomorphic entry points.
|
|
482
|
+
- **`db/manager`, `db/schema` and `db/transaction` are not subpaths.** Importing
|
|
483
|
+
`@spfn/core/db/transaction` fails to resolve. Import those symbols from `@spfn/core/db`.
|
|
484
|
+
- **The client needs no codegen; the proxy does.** A missing route name is a proxy 404.
|
|
485
|
+
- **Contracts are for clients TypeScript cannot reach.** `.contract()` and the build gate
|
|
486
|
+
exist for a mobile app or an external consumer, compiled and shipped separately. A web
|
|
487
|
+
client takes its types from `AppRouter` in the same build, so a removed response field
|
|
488
|
+
already breaks the compile. Do not put `.contract()` on a route only the web app calls.
|
|
489
|
+
- **The proxy decides the real HTTP method.** The browser only sends GET or POST to
|
|
490
|
+
`/api/rpc/...`; a PUT, PATCH or DELETE route still works because the method comes from
|
|
491
|
+
the route map.
|
|
492
|
+
- **A package upgrade is not done until `spfn db migrate` has run.** The server refuses to
|
|
493
|
+
start while a function package has migrations the database has not applied. That is the
|
|
494
|
+
gate working, not a bug — see
|
|
495
|
+
[Why does the server refuse to start after a package upgrade?](#why-does-the-server-refuse-to-start-after-a-package-upgrade)
|
|
496
|
+
- **Cache, events and jobs degrade quietly.** `@spfn/core/cache` runs disabled — its
|
|
497
|
+
getters return `undefined` — when there is no cache config or no `ioredis`, and
|
|
498
|
+
WebSocket events need the optional `ws` dependency. Do not write code expecting them to
|
|
499
|
+
throw.
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
## FAQ
|
|
504
|
+
|
|
505
|
+
**Can I use @spfn/core without Next.js?**
|
|
506
|
+
Yes. `next` is an optional peer dependency and the Next.js bridge is two modules
|
|
507
|
+
(`@spfn/core/nextjs`, `@spfn/core/nextjs/server`). The server runs on Hono by itself. You
|
|
508
|
+
give up the typed client and the RPC proxy, which are the Next.js-side pieces.
|
|
509
|
+
|
|
510
|
+
**Can I use Prisma instead of Drizzle?**
|
|
511
|
+
No. `drizzle-orm` is a required peer dependency (`>=1.0.0-rc.4 <2`) and the repository
|
|
512
|
+
layer is built on it.
|
|
513
|
+
|
|
514
|
+
**Do I need Redis?**
|
|
515
|
+
Only for features that use it. `ioredis` is an optional dependency, and without cache
|
|
516
|
+
configuration the cache module reports itself disabled instead of failing. The same holds
|
|
517
|
+
for `ws` and WebSocket events.
|
|
518
|
+
|
|
519
|
+
**Does @spfn/core require PostgreSQL specifically?**
|
|
520
|
+
Yes, 14 or later. The data layer is Drizzle on Postgres, with `postgres.js` as the
|
|
521
|
+
default driver; the provider is injectable, which is how PGlite works in tests.
|
|
522
|
+
|
|
523
|
+
**Why can't I import from `@spfn/core` directly?**
|
|
524
|
+
There is no `.` entry in `exports` — by design. Subpaths keep server-only code out of
|
|
525
|
+
client bundles, which a single barrel file cannot do.
|
|
526
|
+
|
|
527
|
+
**Which Node version?**
|
|
528
|
+
`>=20.0.0`. The package is ESM only.
|
|
529
|
+
|
|
530
|
+
**Where do my business rules go?**
|
|
531
|
+
In services and repositories, not in route handlers. A handler validates, calls, and
|
|
532
|
+
returns. `@spfn/core/authz` covers the one rule that is easy to forget: `requireOwner`
|
|
533
|
+
returns the resource only if it belongs to the requester, and answers "not found" either
|
|
534
|
+
way so the endpoint never reveals that someone else's record exists.
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Related packages
|
|
539
|
+
|
|
540
|
+
Other SPFN packages build on `@spfn/core`:
|
|
541
|
+
|
|
542
|
+
- [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Exports
|
|
543
|
+
`authRouteMap` and registers proxy interceptors automatically; merge its route map into
|
|
544
|
+
`createRpcProxy`.
|
|
545
|
+
- [`@spfn/mcp`](../mcp/README.md) — exposes your operations as MCP tools, so an agent can
|
|
546
|
+
run them instead of you building an admin dashboard.
|
|
547
|
+
- `@spfn/i18n`, `@spfn/storage`, `@spfn/notification`, `@spfn/cms`, `@spfn/monitor`,
|
|
548
|
+
`@spfn/migrate`, `@spfn/workflow` — see each package's README under
|
|
549
|
+
[`packages/`](../).
|
|
550
|
+
|
|
389
551
|
---
|
|
390
552
|
|
|
391
553
|
## License
|
|
392
554
|
|
|
393
|
-
MIT
|
|
555
|
+
MIT © FXY Inc.
|