@spfn/core 0.2.0-beta.49 → 0.2.0-beta.50

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 INFLIKE Inc.
3
+ Copyright (c) 2025 FXY Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,393 +1,208 @@
1
- # @spfn/core
1
+ # @spfn/core — Type-safe Next.js + Hono backend framework (route DSL → RPC proxy → typed client)
2
2
 
3
- Type-safe Node.js backend framework built on Hono + Drizzle ORM.
3
+ `@spfn/core` is the backend runtime for SPFN: a tRPC-style **route DSL** (TypeBox-validated,
4
+ end-to-end typed), a Drizzle/postgres.js **data layer**, an HTTP **server** entry point, and a
5
+ Next.js **RPC proxy + typed client** that wires a browser/RSC app to that backend with
6
+ compile-time-only type inference.
4
7
 
5
- [![npm version](https://badge.fury.io/js/@spfn%2Fcore.svg)](https://www.npmjs.com/package/@spfn/core)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue)](https://www.typescriptlang.org/)
8
+ There is **no root barrel** — `@spfn/core` itself imports nothing. Everything is reached
9
+ through subpath exports (`@spfn/core/route`, `@spfn/core/db`, `@spfn/core/nextjs`, …). Each
10
+ module is canonically documented in its own `src/<mod>/README.md`; this file is the index +
11
+ end-to-end flow. Follow the links for API detail.
8
12
 
9
- > **Beta Release**: Core APIs are stable but may have minor changes before 1.0.
10
-
11
- ## Installation
13
+ ## Install
12
14
 
13
15
  ```bash
14
16
  pnpm add @spfn/core
15
- ```
16
-
17
- ## Quick Start
18
-
19
- ### 1. Define Entity
20
-
21
- ```typescript
22
- // src/server/entities/users.ts
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
- });
33
-
34
- export type User = typeof users.$inferSelect;
35
- export type NewUser = typeof users.$inferInsert;
36
- ```
37
-
38
- ### 2. Create Repository
39
-
40
- ```typescript
41
- // src/server/repositories/user.repository.ts
42
- import { BaseRepository } from '@spfn/core/db';
43
- import { users, type User, type NewUser } from '../entities/users';
44
-
45
- export class UserRepository extends BaseRepository
46
- {
47
- async findById(id: string): Promise<User | null>
48
- {
49
- return this._findOne(users, { id });
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
- }
77
- ```
78
-
79
- ### 3. Define Routes
17
+ # peer (optional): next ^15 || ^16
18
+ # optional deps: ioredis (cache), ws (websocket events); pg-boss ships as a direct dep for jobs
19
+ ```
20
+
21
+ Node `>=18.18.0`. ESM-only.
22
+
23
+ > **Apps that define entities must declare the Postgres driver directly:** add
24
+ > `postgres` (and `pg`) to your app's `dependencies`. `@spfn/core` uses postgres.js
25
+ > internally, and Drizzle branches its type resolution on the `postgres`/`pg` peer. If
26
+ > the app doesn't pin the same driver, pnpm resolves `drizzle-orm` to a second instance,
27
+ > `BaseRepository` generics collapse to `unknown`, and RPC responses lose their types.
28
+ > `spfn create` adds these automatically; declare them by hand only when wiring SPFN into
29
+ > an existing app.
30
+ >
31
+ > ```bash
32
+ > pnpm add postgres pg
33
+ > ```
34
+
35
+ ## Modules
36
+
37
+ Each entry below is exactly one subpath from `package.json` `exports`. Import path → one-line
38
+ purpose → canonical README.
39
+
40
+ | Import path | Purpose | Doc |
41
+ |-------------|---------|-----|
42
+ | `@spfn/core/route` | tRPC-style route DSL (`route.get(...).input(...).handler(...)`) + `defineRouter` / `registerRoutes` / `defineMiddleware`. The core. | [src/route/README.md](./src/route/README.md) |
43
+ | `@spfn/core/route/types` | Shared route types (`HttpMethod`, route/router type primitives). | [src/route/README.md](./src/route/README.md) |
44
+ | `@spfn/core/server` | HTTP server entry: `defineServerConfig()` → `startServer()`; middleware auto-wiring, infra init, graceful shutdown. | [src/server/README.md](./src/server/README.md) |
45
+ | `@spfn/core/nextjs` | Client-safe: `createApi<AppRouter>()`, `ApiError`, client types. No `next/headers`. | [src/nextjs/README.md](./src/nextjs/README.md) |
46
+ | `@spfn/core/nextjs/server` | Server-only: `createRpcProxy({ routeMap })`, `registerInterceptors`. Uses `next/headers`. | [src/nextjs/README.md](./src/nextjs/README.md) |
47
+ | `@spfn/core/db` | Type-safe PostgreSQL (Drizzle): CRUD helpers, `BaseRepository`, schema helpers, transactions, PG error mapping. Single entry point. | [src/db/README.md](./src/db/README.md) |
48
+ | `@spfn/core/db` → manager | Connection lifecycle, pool, primary/replica, health-check, reconnect (`initDatabase`, `getDatabase`). Re-exported from `@spfn/core/db`. | [src/db/manager/README.md](./src/db/manager/README.md) |
49
+ | `@spfn/core/db` → schema | Drizzle column helpers (`id`, `uuid`, `timestamps`, `foreignKey`, `enumText`, `typedJsonb`, `softDelete`, …). Re-exported from `@spfn/core/db`. | [src/db/schema/README.md](./src/db/schema/README.md) |
50
+ | `@spfn/core/db` → transaction | `Transactional` middleware + `runInTransaction`; AsyncLocalStorage propagation to every repo. Re-exported from `@spfn/core/db`. | [src/db/transaction/README.md](./src/db/transaction/README.md) |
51
+ | `@spfn/core/middleware` | Built-in Hono middleware: `ErrorHandler`, `RequestLogger` (+ masking helper). | [src/middleware/README.md](./src/middleware/README.md) |
52
+ | `@spfn/core/errors` | Serializable HTTP/DB error classes + `ErrorRegistry` for cross-boundary deserialization. | [src/errors/README.md](./src/errors/README.md) |
53
+ | `@spfn/core/env` | Schema-based env validation/parsing (isomorphic). `@spfn/core/env/loader` is the **server-only** file loader (`node:fs`). | [src/env/README.md](./src/env/README.md) |
54
+ | `@spfn/core/config` | `@spfn/core`'s own validated env config (`env`, `envSchema`, `registry`) built on `@spfn/core/env`. | [src/config/README.md](./src/config/README.md) |
55
+ | `@spfn/core/logger` | Zero-dependency structured singleton `logger` + child loggers, level masking. | [src/logger/README.md](./src/logger/README.md) |
56
+ | `@spfn/core/cache` | Singleton Valkey/Redis (ioredis) manager, graceful-degrading (`getCache`, `getCacheRead`). | [src/cache/README.md](./src/cache/README.md) |
57
+ | `@spfn/core/job` | pg-boss background jobs: fluent `job()` builder, cron/run-once/event-driven, `defineJobRouter`. | [src/job/README.md](./src/job/README.md) |
58
+ | `@spfn/core/event` | Decoupled pub/sub (`defineEvent`, `defineEventRouter`, `eventRouteMap`); SSE/WS variants below. | [src/event/README.md](./src/event/README.md) |
59
+ | `@spfn/core/event/sse` | Server SSE handler + token manager (SERVER ONLY). | [src/event/README.md](./src/event/README.md) |
60
+ | `@spfn/core/event/sse/client` | Browser SSE (`EventSource`) client. | [src/event/README.md](./src/event/README.md) |
61
+ | `@spfn/core/event/ws` | Server WebSocket handler (SERVER ONLY; `ws` optional dep). | [src/event/README.md](./src/event/README.md) |
62
+ | `@spfn/core/event/ws/client` | Browser WebSocket client. | [src/event/README.md](./src/event/README.md) |
63
+ | `@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) |
64
+
65
+ `db/manager`, `db/schema`, `db/transaction` have **no package subpath** of their own — they are
66
+ internal modules re-exported by `@spfn/core/db`. Import them from `@spfn/core/db`. (See Pitfalls
67
+ for the `./client` subpath.)
68
+
69
+ ## How it works
70
+
71
+ End-to-end, a SPFN backend is **defined once** on the server and consumed type-safely from the
72
+ Next.js app. Types flow purely through TypeScript inference; the only generated artifact is the
73
+ proxy's `routeMap`.
74
+
75
+ ```
76
+ route DSL ② defineRouter ③ defineServerConfig → startServer
77
+ route.get('/users/:id') defineRouter({ getUser, defineServerConfig()
78
+ .input({ params }) createUser }) .routes(appRouter).build()
79
+ .handler(c => …) export type AppRouter startServer() → Hono on :8790
80
+ │ = typeof appRouter ▲
81
+ TypeBox = runtime validation + compile-time types │ registerRoutes mounts routes
82
+ ▼ │
83
+ ④ codegen (@spfn/core:route-map) ──► routeMap = { getUser: { method:'GET', path:'/users/:id' }, … }
84
+
85
+
86
+ ⑤ Next.js RPC proxy ⑥ typed client
87
+ app/api/rpc/[routeName]/route.ts lib/api.ts
88
+ createRpcProxy({ routeMap }) createApi<AppRouter>() (no codegen for the client)
89
+ GET/POST /api/rpc/{routeName} api.getUser.call({ params:{ id } })
90
+ resolves real method+path from routeMap └─ fully typed input + output
91
+ forwards to backend, runs interceptors
92
+ ```
93
+
94
+ 1. **Define a route** with `route.<method>(path).input({...}).handler(c => …)` from
95
+ `@spfn/core/route`. TypeBox schemas in `.input()` give both runtime validation and the
96
+ compile-time types the handler (`await c.data()`) and the client see. The handler's **return
97
+ type is inferred** — no manual response typing.
98
+ 2. **Compose routes** with `defineRouter({ … })` and export `type AppRouter = typeof appRouter`.
99
+ That type is the single source of truth for the client; no client codegen reads it.
100
+ 3. **Boot the server** with `defineServerConfig().routes(appRouter).build()` then `startServer()`
101
+ (`@spfn/core/server`). It auto-wires `ErrorHandler`/`RequestLogger`, inits DB/cache, mounts
102
+ routes via `registerRoutes`, and runs jobs/events. A request is matched by Hono → global
103
+ middleware → route middleware (`.use([...])`, e.g. `Transactional()`) → input validation/type
104
+ conversion → handler → serialized response.
105
+ 4. **Generate the route-map** with codegen (`@spfn/core:route-map`, e.g. `pnpm codegen`). It emits
106
+ `routeName → { method, path }` — the only thing the proxy needs to resolve the *real* backend
107
+ method/path.
108
+ 5. **Mount the RPC proxy** in Next.js: `createRpcProxy({ routeMap })` from
109
+ `@spfn/core/nextjs/server` as the `app/api/rpc/[routeName]/route.ts` catch-all. The client only
110
+ ever sends `GET` (no body) or `POST` (body/formData) to `/api/rpc/{routeName}`; the proxy looks
111
+ up `routeMap[routeName]`, substitutes `:params`, and forwards to the backend with the resolved
112
+ method. Package route maps (`authRouteMap`, `eventRouteMap`) merge into the same `routeMap`.
113
+ 6. **Call it** through `createApi<AppRouter>()` from `@spfn/core/nextjs`. The client is a `Proxy`
114
+ over `AppRouter` — `api.getUser.call({ params })` is fully typed in and out, with zero runtime
115
+ type cost. Errors come back as `ApiError`, or as the original typed error when its class is in
116
+ the client's `errorRegistry` (the core registry is always merged in).
117
+
118
+ ## Quick example
80
119
 
81
120
  ```typescript
82
- // src/server/routes/users.ts
83
- import { route } from '@spfn/core/route';
121
+ // server/router.ts — ① + ②
122
+ import { defineRouter, route } from '@spfn/core/route';
84
123
  import { Transactional } from '@spfn/core/db';
85
124
  import { Type } from '@sinclair/typebox';
86
- import { UserRepository } from '../repositories/user.repository';
87
-
88
- const userRepo = new UserRepository();
89
-
90
- export const getUsers = route.get('/users')
91
- .handler(async () => {
92
- return userRepo.findAll();
93
- });
94
-
95
- export const getUser = route.get('/users/:id')
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);
102
125
 
103
- if (!user)
126
+ export const appRouter = defineRouter({
127
+ getUser: route.get('/users/:id')
128
+ .input({ params: Type.Object({ id: Type.String() }) })
129
+ .handler(async (c) =>
104
130
  {
105
- throw new Error('User not found');
106
- }
107
-
108
- return user;
109
- });
110
-
111
- export const createUser = route.post('/users')
112
- .input({
113
- body: Type.Object({
114
- email: Type.String({ format: 'email' }),
115
- name: Type.String({ minLength: 1 })
116
- })
117
- })
118
- .use([Transactional()])
119
- .handler(async (c) => {
120
- const { body } = await c.data();
121
- return userRepo.create(body);
122
- });
123
-
124
- export const updateUser = route.patch('/users/:id')
125
- .input({
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
- }
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)
131
+ const { params } = await c.data(); // params.id: string
132
+ return { id: params.id, name: 'John' }; // return type inferred
133
+ }),
134
+
135
+ createUser: route.post('/users')
136
+ .input({ body: Type.Object({ name: Type.String() }) })
137
+ .use([Transactional()]) // auto commit/rollback
138
+ .handler(async (c) =>
155
139
  {
156
- throw new Error('User not found');
157
- }
158
-
159
- return { success: true };
160
- });
161
- ```
162
-
163
- ### 4. Configure Server
164
-
165
- ```typescript
166
- // src/server/server.config.ts
167
- import { defineServerConfig, defineRouter } from '@spfn/core/server';
168
- import * as userRoutes from './routes/users';
169
-
170
- const appRouter = defineRouter({
171
- ...userRoutes
140
+ const { body } = await c.data();
141
+ return { id: '2', name: body.name };
142
+ }),
172
143
  });
173
144
 
174
- export default defineServerConfig()
175
- .port(8790)
176
- .routes(appRouter)
177
- .build();
178
-
179
145
  export type AppRouter = typeof appRouter;
180
146
  ```
181
147
 
182
- ### 5. Start Server
183
-
184
148
  ```typescript
185
- // src/server/index.ts
186
- import { startServer } from '@spfn/core/server';
149
+ // server/index.ts — ③
150
+ import { defineServerConfig, startServer } from '@spfn/core/server';
151
+ import { appRouter } from './router';
187
152
 
188
- await startServer();
153
+ export default defineServerConfig().port(8790).routes(appRouter).build();
154
+ await startServer(); // Hono on :8790
189
155
  ```
190
156
 
191
- ### 6. Environment Variables
192
-
193
- ```bash
194
- # .env
195
- DATABASE_URL=postgresql://localhost:5432/mydb
196
- PORT=8790
197
- ```
198
-
199
- ---
200
-
201
- ## Architecture Overview
202
-
203
- ```
204
- ┌─────────────────────────────────────────┐
205
- │ Routes Layer │ API endpoints + validation
206
- │ route.get('/users/:id').handler(...) │
207
- └──────────────┬──────────────────────────┘
208
-
209
- ┌──────────────▼──────────────────────────┐
210
- │ Repository Layer │ Business logic + data access
211
- │ class UserRepository extends Base... │
212
- └──────────────┬──────────────────────────┘
213
-
214
- ┌──────────────▼──────────────────────────┐
215
- │ Entity Layer │ Database schema (Drizzle)
216
- │ pgTable('users', { id, email, ... }) │
217
- └─────────────────────────────────────────┘
218
- ```
219
-
220
- **Key Principles:**
221
- - **Route**: Thin layer, input validation only
222
- - **Repository**: All business logic and DB access
223
- - **Entity**: Schema definition only, no logic
224
-
225
- ---
226
-
227
- ## Directory Structure
228
-
229
- ```
230
- src/server/
231
- ├── entities/ # Database schema
232
- │ ├── users.ts
233
- │ └── index.ts
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
242
- ```
243
-
244
- ---
245
-
246
- ## Core Concepts
247
-
248
- ### Route Definition
249
-
250
- ```typescript
251
- import { route } from '@spfn/core/route';
252
- import { Type } from '@sinclair/typebox';
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
- });
277
- ```
278
-
279
- ### Repository Pattern
280
-
281
- ```typescript
282
- import { BaseRepository } from '@spfn/core/db';
283
-
284
- export class UserRepository extends BaseRepository
285
- {
286
- // Protected helpers available:
287
- // _findOne, _findMany, _create, _createMany
288
- // _updateOne, _updateMany, _deleteOne, _deleteMany
289
- // _count, _upsert
290
-
291
- async findActive()
292
- {
293
- return this._findMany(users, {
294
- where: { isActive: true },
295
- orderBy: desc(users.createdAt),
296
- limit: 10
297
- });
298
- }
299
- }
300
- ```
301
-
302
- ### Transaction
303
-
304
157
  ```typescript
305
- import { Transactional } from '@spfn/core/db';
306
-
307
- // Middleware-based (recommended)
308
- route.post('/users')
309
- .use([Transactional()])
310
- .handler(async (c) => {
311
- // Auto commit on success
312
- // Auto rollback on error
313
- });
158
+ // app/api/rpc/[routeName]/route.ts ⑤ (server-only)
159
+ import { createRpcProxy } from '@spfn/core/nextjs/server';
160
+ import { routeMap } from '@/generated/route-map'; // ④ codegen output
314
161
 
315
- // Manual control
316
- import { runWithTransaction } from '@spfn/core/db';
317
-
318
- await runWithTransaction(async () => {
319
- await userRepo.create(userData);
320
- await profileRepo.create(profileData);
321
- });
162
+ export const { GET, POST } = createRpcProxy({ routeMap });
322
163
  ```
323
164
 
324
- ### Schema Helpers
325
-
326
165
  ```typescript
327
- import {
328
- id, // bigserial primary key
329
- uuid, // uuid primary key
330
- timestamps, // createdAt, updatedAt
331
- foreignKey, // required FK with cascade
332
- optionalForeignKey, // nullable FK
333
- softDelete, // deletedAt, deletedBy
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
- });
347
- ```
348
-
349
- ---
350
-
351
- ## Module Documentation
352
-
353
- | Module | Description |
354
- |--------|-------------|
355
- | [Route](./docs/route.md) | Route definition, validation, response patterns |
356
- | [Database](./docs/database.md) | Connection, helpers, transactions |
357
- | [Entity](./docs/entity.md) | Schema definition, column helpers |
358
- | [Repository](./docs/repository.md) | BaseRepository, CRUD patterns |
359
- | [Middleware](./docs/middleware.md) | Named middleware, skip control |
360
- | [Server](./docs/server.md) | Configuration, lifecycle hooks |
361
- | [Errors](./docs/errors.md) | Error types, handling patterns |
362
- | [Environment](./docs/env.md) | Type-safe environment variables |
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 |
369
-
370
- ---
371
-
372
- ## CLI Commands
373
-
374
- ```bash
375
- # Migration
376
- npx spfn db generate # Generate migration
377
- npx spfn db migrate # Apply migration
378
-
379
- # Development
380
- pnpm spfn:dev # Start dev server (auto codegen)
381
-
382
- # API Client Generation
383
- pnpm spfn codegen run
384
-
385
- # Database Studio
386
- pnpm drizzle-kit studio
387
- ```
388
-
389
- ---
390
-
391
- ## License
392
-
393
- MIT
166
+ // lib/api.ts — ⑥ (client-safe; no codegen)
167
+ import { createApi } from '@spfn/core/nextjs';
168
+ import type { AppRouter } from '@/server/router';
169
+
170
+ export const api = createApi<AppRouter>();
171
+
172
+ // anywhere (RSC / client / server action):
173
+ const user = await api.getUser.call({ params: { id: '123' } }); // typed { id, name }
174
+ const made = await api.createUser.call({ body: { name: 'A' } });
175
+ ```
176
+
177
+ ## Pitfalls
178
+
179
+ - **No root barrel.** `import … from '@spfn/core'` does not resolve. Import from a subpath
180
+ (`@spfn/core/route`, `@spfn/core/db`, …). The list above *is* the complete public surface.
181
+ - **Use `@spfn/core/nextjs` for the client, not `@spfn/core/client`.** `package.json` still lists a
182
+ `./client` export, but the build does not emit it (no `src/client`; the tsup `client` entry is
183
+ disabled). `createApi` / `ApiError` and all client types ship from **`@spfn/core/nextjs`**. Treat
184
+ `@spfn/core/client` as non-functional.
185
+ - **Client vs. server boundary is load-bearing.** `@spfn/core/nextjs/server` pulls in
186
+ `next/headers` + `next/server`; never import it from a Client Component. `@spfn/core/env/loader`,
187
+ `@spfn/core/event/sse`, `@spfn/core/event/ws` are server-only (`node:fs` / Hono / `node:crypto`).
188
+ Client code uses the `*/client` and isomorphic entry points only.
189
+ - **`db/manager`, `db/schema`, `db/transaction` are not package subpaths.** Importing
190
+ `@spfn/core/db/transaction` fails to resolve — import those symbols from `@spfn/core/db`.
191
+ - **The client needs no codegen; the proxy does.** `createApi<AppRouter>()` is metadata-free and
192
+ driven by the `AppRouter` *type*. The generated `routeMap` is consumed by `createRpcProxy`. A
193
+ `routeName` missing from the merged `routeMap` is a **proxy 404**, not a backend 404 — re-run
194
+ codegen after adding routes.
195
+ - **The proxy decides the real HTTP method.** The client only sends GET/POST to `/api/rpc/...`; a
196
+ PUT/PATCH/DELETE route still works because the backend method comes from `routeMap[routeName]`.
197
+ - **Cache/event/job degrade or depend on optional deps.** `@spfn/core/cache` runs disabled (getters
198
+ return `undefined`) without `CACHE_*` config or `ioredis`; WebSocket events need the optional `ws`
199
+ dep. Don't assume they throw.
200
+
201
+ ## Related packages
202
+
203
+ Other SPFN packages build on `@spfn/core`:
204
+
205
+ - [`@spfn/auth`](../auth/README.md) auth/session; exports `authRouteMap` and auto-registers proxy
206
+ interceptors (merge its route map into `createRpcProxy`).
207
+ - `@spfn/cms`, `@spfn/workflow`, `@spfn/notification`, `@spfn/monitor`, `@spfn/cli`
208
+ — see each package's README under `packages/`.