@spfn/core 0.2.0-beta.66 → 0.2.0-beta.68
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/README.md +309 -116
- package/dist/authz/index.js +398 -3
- package/dist/authz/index.js.map +1 -1
- package/dist/codegen/index.d.ts +114 -8
- package/dist/codegen/index.js +162 -3
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +288 -0
- package/dist/contract/index.js +534 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/db/index.js +13 -10
- package/dist/db/index.js.map +1 -1
- package/dist/{define-middleware-DuXD8Hvu.d.ts → define-middleware-B9bFuXVU.d.ts} +1 -1
- package/dist/errors/index.js +398 -3
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +3 -3
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/index.d.ts +4 -4
- package/dist/event/sse/index.js +9 -0
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/middleware/index.d.ts +108 -11
- package/dist/middleware/index.js +769 -632
- package/dist/middleware/index.js.map +1 -1
- package/dist/route/index.d.ts +8 -552
- package/dist/route/index.js +36 -0
- package/dist/route/index.js.map +1 -1
- package/dist/router-DhvbMhef.d.ts +641 -0
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +9 -0
- package/dist/server/index.js.map +1 -1
- package/dist/{token-manager-jKD_EsSE.d.ts → token-manager-vZeqBbtA.d.ts} +7 -0
- package/dist/{types-DVjf37yO.d.ts → types-CF-37KAG.d.ts} +1 -1
- package/dist/{types-BFB72jbM.d.ts → types-D9uMxeQS.d.ts} +1 -1
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -1,78 +1,82 @@
|
|
|
1
|
-
# @spfn/core
|
|
1
|
+
# @spfn/core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
end-to-end typed), a PostgreSQL Drizzle **data layer** (postgres.js by default, injectable
|
|
5
|
-
providers such as PGlite), an HTTP **server** entry point, and a
|
|
6
|
-
Next.js **RPC proxy + typed client** that wires a browser/RSC app to that backend with
|
|
7
|
-
compile-time-only type inference.
|
|
3
|
+
> **The backend runtime for taking an AI-built app from prototype to production**
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
13
9
|
|
|
14
|
-
|
|
10
|
+
📖 **[superfunction.xyz](https://superfunction.xyz)** — docs and the full-stack tutorial ·
|
|
11
|
+
[repository README](https://github.com/fxylabs/spfn) for the whole framework.
|
|
12
|
+
|
|
13
|
+
> **Status — Beta (`0.x`).** The public API is stabilizing but may still change between
|
|
14
|
+
> minor releases before `1.0`. Pin your version and install from the `@beta` tag.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## What is @spfn/core?
|
|
19
|
+
|
|
20
|
+
A TypeScript backend runtime with four parts, used together:
|
|
21
|
+
|
|
22
|
+
- **A route DSL** — `route.get(path).input({...}).handler(...)`, validated at runtime by
|
|
23
|
+
TypeBox and typed at compile time from the same schema.
|
|
24
|
+
- **A data layer** — PostgreSQL through Drizzle ORM, with `BaseRepository`, schema
|
|
25
|
+
helpers, and transactions that propagate automatically.
|
|
26
|
+
- **A server** — a Hono app you start as a long-lived process, or mount as serverless
|
|
27
|
+
functions on Vercel.
|
|
28
|
+
- **A Next.js bridge** — an RPC proxy and a typed client, so a route's input and output
|
|
29
|
+
types are the same object on both sides of the network.
|
|
30
|
+
|
|
31
|
+
What it is not: a frontend framework, an ORM, or a place to keep your business rules.
|
|
32
|
+
Next.js owns the frontend, Drizzle owns the SQL, and your services own the rules.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## How do I install it?
|
|
15
37
|
|
|
16
38
|
```bash
|
|
17
|
-
pnpm add @spfn/core drizzle-orm@1.0.0-rc.4
|
|
18
|
-
# peer
|
|
19
|
-
# optional deps: ioredis (cache), ws (websocket events); pg-boss ships as a direct dep for jobs
|
|
39
|
+
pnpm add @spfn/core@beta drizzle-orm@1.0.0-rc.4 postgres pg
|
|
40
|
+
# optional peer: next ^16.2.11 (only for the Next.js bridge)
|
|
20
41
|
```
|
|
21
42
|
|
|
22
|
-
Node `>=18.18.0`. ESM
|
|
43
|
+
Node `>=18.18.0`. ESM only.
|
|
23
44
|
|
|
24
|
-
> **
|
|
25
|
-
> `drizzle-orm
|
|
26
|
-
>
|
|
27
|
-
>
|
|
28
|
-
>
|
|
29
|
-
>
|
|
30
|
-
>
|
|
31
|
-
> an existing app.
|
|
32
|
-
>
|
|
33
|
-
> ```bash
|
|
34
|
-
> pnpm add drizzle-orm@1.0.0-rc.4 postgres pg
|
|
35
|
-
> ```
|
|
45
|
+
> **Declare Drizzle and the Postgres drivers in your own app, not just in SPFN.**
|
|
46
|
+
> `@spfn/core` takes `drizzle-orm` as a peer dependency, and Drizzle changes how it
|
|
47
|
+
> resolves types depending on which driver packages are present. If your app does not
|
|
48
|
+
> pin the same ORM and drivers, pnpm can install a second copy of Drizzle — and then
|
|
49
|
+
> `BaseRepository` generics collapse to `unknown` and your RPC responses lose their
|
|
50
|
+
> types. `spfn create` adds these for you; add them by hand only when wiring SPFN into
|
|
51
|
+
> an app that already exists.
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
Optional dependencies, installed only if you use the feature: `ioredis` (cache) and
|
|
54
|
+
`ws` (WebSocket events). `pg-boss` ships as a direct dependency for background jobs.
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
purpose → canonical README.
|
|
56
|
+
---
|
|
41
57
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
| `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event/README.md](./src/event/README.md) |
|
|
65
|
-
| `@spfn/core/codegen` | Pluggable codegen orchestrator + the built-in `@spfn/core:route-map` generator that produces the proxy's `routeMap`. | [src/codegen/README.md](./src/codegen/README.md) |
|
|
66
|
-
|
|
67
|
-
`db/manager`, `db/schema`, `db/transaction` have **no package subpath** of their own — they are
|
|
68
|
-
internal modules re-exported by `@spfn/core/db`. Import them from `@spfn/core/db`. (See Pitfalls
|
|
69
|
-
for the `./client` subpath.)
|
|
70
|
-
|
|
71
|
-
## How it works
|
|
72
|
-
|
|
73
|
-
End-to-end, a SPFN backend is **defined once** on the server and consumed type-safely from the
|
|
74
|
-
Next.js app. Types flow purely through TypeScript inference; the only generated artifact is the
|
|
75
|
-
proxy's `routeMap`.
|
|
58
|
+
## What does one feature look like?
|
|
59
|
+
|
|
60
|
+
Four files, always the same four, always in the same places:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
src/server/
|
|
64
|
+
entities/order.ts # the data shape (Drizzle table)
|
|
65
|
+
repositories/order.ts # persistence (extends BaseRepository)
|
|
66
|
+
routes/orders.ts # the validated API contract
|
|
67
|
+
router.ts # registration
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The point is not that this arrangement is uniquely correct. The point is that it is
|
|
71
|
+
decided. An agent asked for "add orders" twice produces the same code twice, because
|
|
72
|
+
there is nothing left to choose.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## How does a route's type reach the browser?
|
|
77
|
+
|
|
78
|
+
Through TypeScript inference, not generated client code. One artifact is generated —
|
|
79
|
+
the route map the RPC proxy needs — and nothing else.
|
|
76
80
|
|
|
77
81
|
```
|
|
78
82
|
① route DSL ② defineRouter ③ defineServerConfig → startServer
|
|
@@ -81,7 +85,7 @@ proxy's `routeMap`.
|
|
|
81
85
|
.handler(c => …) export type AppRouter startServer() → Hono on :8790
|
|
82
86
|
│ = typeof appRouter ▲
|
|
83
87
|
│ TypeBox = runtime validation + compile-time types │ registerRoutes mounts routes
|
|
84
|
-
▼
|
|
88
|
+
▼ │
|
|
85
89
|
④ codegen (@spfn/core:route-map) ──► routeMap = { getUser: { method:'GET', path:'/users/:id' }, … }
|
|
86
90
|
│
|
|
87
91
|
▼
|
|
@@ -94,30 +98,34 @@ proxy's `routeMap`.
|
|
|
94
98
|
```
|
|
95
99
|
|
|
96
100
|
1. **Define a route** with `route.<method>(path).input({...}).handler(c => …)` from
|
|
97
|
-
`@spfn/core/route`. TypeBox schemas in `.input()`
|
|
98
|
-
|
|
99
|
-
type is inferred** —
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
101
|
+
`@spfn/core/route`. The TypeBox schemas in `.input()` do double duty: they validate
|
|
102
|
+
the request at runtime and give the handler (`await c.data()`) and the client their
|
|
103
|
+
compile-time types. The handler's **return type is inferred** — you never write a
|
|
104
|
+
response type.
|
|
105
|
+
2. **Compose routes** with `defineRouter({ … })` and export
|
|
106
|
+
`type AppRouter = typeof appRouter`. That type is the single source of truth for the
|
|
107
|
+
client.
|
|
108
|
+
3. **Boot the server** with `defineServerConfig().routes(appRouter).build()` and
|
|
109
|
+
`startServer()` from `@spfn/core/server`. It wires `ErrorHandler` and
|
|
110
|
+
`RequestLogger`, initializes the database and cache, mounts the routes, and starts
|
|
111
|
+
jobs and events. A request goes: Hono match → global middleware → route middleware
|
|
112
|
+
(`.use([...])`, e.g. `Transactional()`) → input validation → handler → response.
|
|
113
|
+
4. **Generate the route map** with `pnpm codegen`. It emits
|
|
114
|
+
`routeName → { method, path }` — the only thing the proxy needs to find the real
|
|
115
|
+
backend endpoint.
|
|
116
|
+
5. **Mount the RPC proxy** in Next.js as the `app/api/rpc/[routeName]/route.ts`
|
|
117
|
+
catch-all. The browser only ever sends `GET` (no body) or `POST` (body or formData)
|
|
118
|
+
to `/api/rpc/{routeName}`; the proxy looks up `routeMap[routeName]`, substitutes
|
|
119
|
+
`:params`, and forwards with the real method. Package route maps (`authRouteMap`,
|
|
120
|
+
`eventRouteMap`) merge into the same map.
|
|
121
|
+
6. **Call it** through `createApi<AppRouter>()`. The client is a `Proxy` over the
|
|
122
|
+
`AppRouter` type — typed in and out, with no runtime cost for the types. Errors
|
|
123
|
+
arrive as `ApiError`, or as the original error class when that class is registered in
|
|
124
|
+
the client's `errorRegistry`.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Show me the whole thing in code
|
|
121
129
|
|
|
122
130
|
```typescript
|
|
123
131
|
// server/router.ts — ① + ②
|
|
@@ -136,7 +144,7 @@ export const appRouter = defineRouter({
|
|
|
136
144
|
|
|
137
145
|
createUser: route.post('/users')
|
|
138
146
|
.input({ body: Type.Object({ name: Type.String() }) })
|
|
139
|
-
.use([Transactional()]) //
|
|
147
|
+
.use([Transactional()]) // commit on return, rollback on throw
|
|
140
148
|
.handler(async (c) =>
|
|
141
149
|
{
|
|
142
150
|
const { body } = await c.data();
|
|
@@ -171,40 +179,225 @@ import type { AppRouter } from '@/server/router';
|
|
|
171
179
|
|
|
172
180
|
export const api = createApi<AppRouter>();
|
|
173
181
|
|
|
174
|
-
// anywhere
|
|
182
|
+
// anywhere — server component, client component, or server action:
|
|
175
183
|
const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
|
|
176
184
|
const made = await api.createUser.call({ body: { name: 'A' } });
|
|
177
185
|
```
|
|
178
186
|
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Which import path do I use for what?
|
|
190
|
+
|
|
191
|
+
There is **no root barrel**: `import … from '@spfn/core'` does not resolve. Every symbol
|
|
192
|
+
comes from a subpath, and the table below is the complete public surface — one row per
|
|
193
|
+
entry in `package.json` `exports`. Each module has its own README with the API detail.
|
|
194
|
+
|
|
195
|
+
| Import path | Purpose | Doc |
|
|
196
|
+
|-------------|---------|-----|
|
|
197
|
+
| `@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) |
|
|
198
|
+
| `@spfn/core/route/types` | Shared route types (`HttpMethod`, router type primitives). | [src/route](./src/route/README.md) |
|
|
199
|
+
| `@spfn/core/server` | Server entry: `defineServerConfig()` → `startServer()`, plus `createServerlessApp()` for Vercel. Middleware wiring, infra init, graceful shutdown. | [src/server](./src/server/README.md) |
|
|
200
|
+
| `@spfn/core/nextjs` | Client-safe: `createApi<AppRouter>()`, `ApiError`, client types. Never touches `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
|
|
201
|
+
| `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs](./src/nextjs/README.md) |
|
|
202
|
+
| `@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) |
|
|
203
|
+
| `@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) |
|
|
204
|
+
| `@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) |
|
|
205
|
+
| `@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) |
|
|
206
|
+
| `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` and its masking helper. | [src/middleware](./src/middleware/README.md) |
|
|
207
|
+
| `@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) |
|
|
208
|
+
| `@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) |
|
|
209
|
+
| `@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) |
|
|
210
|
+
| `@spfn/core/env` | Schema-based environment validation, isomorphic. | [src/env](./src/env/README.md) |
|
|
211
|
+
| `@spfn/core/env/loader` | The **server-only** `.env` file loader (uses `node:fs`). | [src/env](./src/env/README.md) |
|
|
212
|
+
| `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`), built on `@spfn/core/env`. | [src/config](./src/config/README.md) |
|
|
213
|
+
| `@spfn/core/logger` | Structured singleton `logger` with child loggers and level masking. No dependencies. | [src/logger](./src/logger/README.md) |
|
|
214
|
+
| `@spfn/core/cache` | Valkey/Redis singleton over ioredis (`getCache`, `getCacheRead`). Degrades to disabled rather than throwing. | [src/cache](./src/cache/README.md) |
|
|
215
|
+
| `@spfn/core/job` | Background jobs on pg-boss: a fluent `job()` builder, cron, run-once, event-driven, `defineJobRouter`. | [src/job](./src/job/README.md) |
|
|
216
|
+
| `@spfn/core/event` | Decoupled pub/sub (`defineEvent`, `defineEventRouter`, `eventRouteMap`). | [src/event](./src/event/README.md) |
|
|
217
|
+
| `@spfn/core/event/sse` | Server-side SSE handler and token manager. Server only. | [src/event](./src/event/README.md) |
|
|
218
|
+
| `@spfn/core/event/sse/client` | Browser SSE client (`EventSource`). | [src/event](./src/event/README.md) |
|
|
219
|
+
| `@spfn/core/event/ws` | Server-side WebSocket handler. Server only; needs the optional `ws` dependency. | [src/event](./src/event/README.md) |
|
|
220
|
+
| `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event](./src/event/README.md) |
|
|
221
|
+
| `@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) |
|
|
222
|
+
| `@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) |
|
|
223
|
+
|
|
224
|
+
`db/manager`, `db/schema` and `db/transaction` are **not** package subpaths of their own.
|
|
225
|
+
They are internal modules re-exported by `@spfn/core/db` — import their symbols from
|
|
226
|
+
there.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Do I have to run codegen?
|
|
231
|
+
|
|
232
|
+
Only for the RPC proxy, and only after you change routes.
|
|
233
|
+
|
|
234
|
+
| Change | What to run |
|
|
235
|
+
|---|---|
|
|
236
|
+
| Added, renamed or removed a route | `pnpm codegen`, then commit the regenerated route map |
|
|
237
|
+
| Changed a route's input or output types | Nothing — the client infers from `AppRouter` |
|
|
238
|
+
| Changed an entity | `pnpm db:generate` for the migration |
|
|
239
|
+
|
|
240
|
+
A route name that is missing from the merged route map produces a **404 from the proxy**,
|
|
241
|
+
not from your backend. That is almost always a codegen you did not re-run.
|
|
242
|
+
|
|
243
|
+
Generated files are output. Never hand-edit them.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Can I deploy this to Vercel?
|
|
248
|
+
|
|
249
|
+
Yes, and it is a first-class target rather than a workaround. From your app:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
spfn add vercel
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
That scaffolds `src/app/api/backend/[[...route]]/route.ts` (a `hono/vercel` adapter) and
|
|
256
|
+
`vercel.json`. The SPFN app mounts under `/api/backend`, so the Next.js frontend and the
|
|
257
|
+
backend share one Vercel origin — point `SPFN_API_URL` at
|
|
258
|
+
`https://<your-domain>/api/backend`. It runs on the Node runtime, not edge, because SPFN
|
|
259
|
+
needs `pg` and native `bcrypt`.
|
|
260
|
+
|
|
261
|
+
The adapter is a thin wrapper around `createServerlessApp` from `@spfn/core/server`:
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import { handle } from 'hono/vercel';
|
|
265
|
+
import { createServerlessApp } from '@spfn/core/server';
|
|
266
|
+
import serverConfig from '@/server/server.config';
|
|
267
|
+
|
|
268
|
+
const handler = async (req: Request) => handle(await createServerlessApp(serverConfig))(req);
|
|
269
|
+
export const GET = handler;
|
|
270
|
+
export const POST = handler;
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
It differs from `startServer()` in four ways, all forced by the platform:
|
|
274
|
+
|
|
275
|
+
| | Always-on (`startServer`) | Serverless (`createServerlessApp`) |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| Database init | welded to `serve()` | in the handler, once per warm container |
|
|
278
|
+
| Periodic DB health check | on | off — a timer only leaks across frozen invocations |
|
|
279
|
+
| Background job worker | runs in-process | **not started** — enqueuing works, nothing drains the queue |
|
|
280
|
+
| Seed and RBAC provisioning | per boot | a deploy-time step (`provisionInfrastructure`) |
|
|
281
|
+
|
|
282
|
+
The job worker is the one that bites. If your config declares jobs, the serverless path
|
|
283
|
+
logs a warning at startup: drain the queue from a scheduled endpoint (Vercel Cron calling
|
|
284
|
+
a route that processes a batch), or run jobs on an always-on target.
|
|
285
|
+
|
|
286
|
+
Background jobs, WebSocket events and the periodic health check all need the always-on
|
|
287
|
+
path: `spfn build && spfn start`, or the generated Docker files.
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## How do I point Claude Code or another AI coding agent at this?
|
|
292
|
+
|
|
293
|
+
Put the contract in a file and let the agent read it, instead of describing the
|
|
294
|
+
architecture again in every prompt.
|
|
295
|
+
|
|
296
|
+
An SPFN repository ships an `AGENTS.md` stating what the repo is, the commands, the
|
|
297
|
+
vertical-slice pattern, and the rules that are not negotiable — never hand-edit generated
|
|
298
|
+
files, migrations come from the schema. Tool-specific files (`CLAUDE.md`,
|
|
299
|
+
`.cursorrules`) point at it in one line rather than duplicating it, so they cannot drift
|
|
300
|
+
apart. Projects created by `spfn create` get the same arrangement.
|
|
301
|
+
|
|
302
|
+
Each module README under `src/` is written for the same reader. When an agent is working
|
|
303
|
+
on database code, `src/db/README.md` is the page to give it.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## How is this different from NestJS or tRPC?
|
|
308
|
+
|
|
309
|
+
**NestJS** gives you a structured backend and leaves the structure to you: modules and
|
|
310
|
+
providers you design, validation and ORM of your choosing, DTOs you keep in sync with the
|
|
311
|
+
frontend by hand. It is the better fit when you need that freedom, or when your frontend
|
|
312
|
+
is not Next.js. `@spfn/core` fixes the shape instead — one vertical slice per feature,
|
|
313
|
+
TypeBox, Drizzle, and route types that reach the browser without a DTO layer. Its
|
|
314
|
+
ecosystem is small and young where NestJS's is large and mature.
|
|
315
|
+
|
|
316
|
+
**tRPC** solves a narrower problem, typed calls between client and server, and leaves
|
|
317
|
+
structure, persistence and auth to you. `@spfn/core` includes the typed-call layer as one
|
|
318
|
+
part of a whole backend.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
179
322
|
## Pitfalls
|
|
180
323
|
|
|
181
|
-
- **
|
|
182
|
-
|
|
183
|
-
- **Use `@spfn/core/nextjs` for the client, not `@spfn/core/client`.** `package.json`
|
|
184
|
-
`./client` export, but the build does not emit it
|
|
185
|
-
|
|
186
|
-
`@spfn/core/client` as non-functional.
|
|
187
|
-
- **
|
|
188
|
-
`next/headers`
|
|
189
|
-
`@spfn/core/
|
|
190
|
-
Client code uses the `*/client` and isomorphic entry points
|
|
191
|
-
- **`db/manager`, `db/schema
|
|
192
|
-
`@spfn/core/db/transaction` fails to resolve
|
|
193
|
-
- **The client needs no codegen; the proxy does.**
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
324
|
+
- **There is no root barrel.** `import … from '@spfn/core'` does not resolve. Import from
|
|
325
|
+
a subpath. The module table above is the complete surface.
|
|
326
|
+
- **Use `@spfn/core/nextjs` for the client, not `@spfn/core/client`.** `package.json`
|
|
327
|
+
still lists a `./client` export, but the build does not emit it — the entry is disabled
|
|
328
|
+
and there is no `src/client`. `createApi`, `ApiError` and every client type ship from
|
|
329
|
+
`@spfn/core/nextjs`. Treat `@spfn/core/client` as non-functional.
|
|
330
|
+
- **The client/server boundary is load-bearing.** `@spfn/core/nextjs/server` pulls in
|
|
331
|
+
`next/headers` and `next/server`; importing it from a Client Component breaks the
|
|
332
|
+
build. `@spfn/core/env/loader`, `@spfn/core/event/sse` and `@spfn/core/event/ws` are
|
|
333
|
+
server-only too. Client code uses the `*/client` and isomorphic entry points.
|
|
334
|
+
- **`db/manager`, `db/schema` and `db/transaction` are not subpaths.** Importing
|
|
335
|
+
`@spfn/core/db/transaction` fails to resolve. Import those symbols from `@spfn/core/db`.
|
|
336
|
+
- **The client needs no codegen; the proxy does.** A missing route name is a proxy 404.
|
|
337
|
+
- **Contracts are for clients TypeScript cannot reach.** `.contract()` and the build gate
|
|
338
|
+
exist for a mobile app or an external consumer, compiled and shipped separately. A web
|
|
339
|
+
client takes its types from `AppRouter` in the same build, so a removed response field
|
|
340
|
+
already breaks the compile. Do not put `.contract()` on a route only the web app calls.
|
|
341
|
+
- **The proxy decides the real HTTP method.** The browser only sends GET or POST to
|
|
342
|
+
`/api/rpc/...`; a PUT, PATCH or DELETE route still works because the method comes from
|
|
343
|
+
the route map.
|
|
344
|
+
- **Cache, events and jobs degrade quietly.** `@spfn/core/cache` runs disabled — its
|
|
345
|
+
getters return `undefined` — when there is no cache config or no `ioredis`, and
|
|
346
|
+
WebSocket events need the optional `ws` dependency. Do not write code expecting them to
|
|
347
|
+
throw.
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## FAQ
|
|
352
|
+
|
|
353
|
+
**Can I use @spfn/core without Next.js?**
|
|
354
|
+
Yes. `next` is an optional peer dependency and the Next.js bridge is two modules
|
|
355
|
+
(`@spfn/core/nextjs`, `@spfn/core/nextjs/server`). The server runs on Hono by itself. You
|
|
356
|
+
give up the typed client and the RPC proxy, which are the Next.js-side pieces.
|
|
357
|
+
|
|
358
|
+
**Can I use Prisma instead of Drizzle?**
|
|
359
|
+
No. `drizzle-orm` is a required peer dependency (`>=1.0.0-rc.4 <2`) and the repository
|
|
360
|
+
layer is built on it.
|
|
361
|
+
|
|
362
|
+
**Do I need Redis?**
|
|
363
|
+
Only for features that use it. `ioredis` is an optional dependency, and without cache
|
|
364
|
+
configuration the cache module reports itself disabled instead of failing. The same holds
|
|
365
|
+
for `ws` and WebSocket events.
|
|
366
|
+
|
|
367
|
+
**Does @spfn/core require PostgreSQL specifically?**
|
|
368
|
+
Yes, 14 or later. The data layer is Drizzle on Postgres, with `postgres.js` as the
|
|
369
|
+
default driver; the provider is injectable, which is how PGlite works in tests.
|
|
370
|
+
|
|
371
|
+
**Why can't I import from `@spfn/core` directly?**
|
|
372
|
+
There is no `.` entry in `exports` — by design. Subpaths keep server-only code out of
|
|
373
|
+
client bundles, which a single barrel file cannot do.
|
|
374
|
+
|
|
375
|
+
**Which Node version?**
|
|
376
|
+
`>=18.18.0`. The package is ESM only.
|
|
377
|
+
|
|
378
|
+
**Where do my business rules go?**
|
|
379
|
+
In services and repositories, not in route handlers. A handler validates, calls, and
|
|
380
|
+
returns. `@spfn/core/authz` covers the one rule that is easy to forget: `requireOwner`
|
|
381
|
+
returns the resource only if it belongs to the requester, and answers "not found" either
|
|
382
|
+
way so the endpoint never reveals that someone else's record exists.
|
|
383
|
+
|
|
384
|
+
---
|
|
202
385
|
|
|
203
386
|
## Related packages
|
|
204
387
|
|
|
205
388
|
Other SPFN packages build on `@spfn/core`:
|
|
206
389
|
|
|
207
|
-
- [`@spfn/auth`](../auth/README.md) —
|
|
208
|
-
interceptors
|
|
209
|
-
|
|
210
|
-
|
|
390
|
+
- [`@spfn/auth`](../auth/README.md) — sessions, social login and RBAC. Exports
|
|
391
|
+
`authRouteMap` and registers proxy interceptors automatically; merge its route map into
|
|
392
|
+
`createRpcProxy`.
|
|
393
|
+
- [`@spfn/mcp`](../mcp/README.md) — exposes your operations as MCP tools, so an agent can
|
|
394
|
+
run them instead of you building an admin dashboard.
|
|
395
|
+
- `@spfn/i18n`, `@spfn/storage`, `@spfn/notification`, `@spfn/cms`, `@spfn/monitor`,
|
|
396
|
+
`@spfn/migrate`, `@spfn/workflow` — see each package's README under
|
|
397
|
+
[`packages/`](../).
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## License
|
|
402
|
+
|
|
403
|
+
MIT © FXY Inc.
|