@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6
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 +183 -4
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
# @spfn/core/route — Type-safe route DSL + router composition
|
|
2
|
+
|
|
3
|
+
A tRPC-style chainable DSL for defining HTTP routes with TypeBox-validated input,
|
|
4
|
+
end-to-end type inference into the handler, and composable routers. This is the core of
|
|
5
|
+
SPFN: routes defined here are registered onto Hono and consumed by the typed RPC client.
|
|
6
|
+
|
|
7
|
+
## Import paths
|
|
8
|
+
|
|
9
|
+
Everything is exported from a single entry point.
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {
|
|
13
|
+
route, // route builder entry (get/post/put/patch/delete)
|
|
14
|
+
defineRouter, // compose routes into a router
|
|
15
|
+
registerRoutes, // mount a router onto a Hono app (usually called by @spfn/core/server)
|
|
16
|
+
defineMiddleware, // named middleware (skippable by name)
|
|
17
|
+
defineMiddlewareFactory,
|
|
18
|
+
Nullable, OptionalNullable, isHttpMethod,
|
|
19
|
+
FileSchema, FileArraySchema, OptionalFileSchema,
|
|
20
|
+
} from '@spfn/core/route';
|
|
21
|
+
|
|
22
|
+
import { Type } from '@sinclair/typebox'; // schemas come from TypeBox, not from this package
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
There is **no** `@spfn/core/route/*` sub-path. Import the schema builder `Type` from
|
|
26
|
+
`@sinclair/typebox` directly.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Public API (complete)
|
|
31
|
+
|
|
32
|
+
Values:
|
|
33
|
+
|
|
34
|
+
- `route` — builder entry. Methods: `route.get/post/put/patch/delete(path)`. **No `head`/`options`.**
|
|
35
|
+
- `defineRouter(routes)` — build a `Router`. Returned router is chainable: `.packages([...])`, `.use([...])`.
|
|
36
|
+
- `registerRoutes(app, router, namedMiddlewares?, collectedRoutes?)` — mount onto Hono, returns `RegisteredRoute[]`.
|
|
37
|
+
- `defineMiddleware(name, handler | factory, options?)` — named middleware (param-count auto-detects handler vs factory).
|
|
38
|
+
- `defineMiddlewareFactory(name, factory)` — explicit factory form (use when the factory itself takes exactly 2 args).
|
|
39
|
+
- `Nullable(schema)` → `T | null`; `OptionalNullable(schema)` → `T | null | undefined`.
|
|
40
|
+
- `isHttpMethod(value)` — type guard for `'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'`.
|
|
41
|
+
- `.contract({ since, response, auth?, requiresSession?, deprecatedIn?, removedIn? })` — builder method; publishes
|
|
42
|
+
the route as a versioned promise. See [`../contract/README.md`](../contract/README.md).
|
|
43
|
+
- `defineRouter({...}).contractVersion('1.2.0')` — the version those promises are published under.
|
|
44
|
+
A released snapshot is named from it, and a running server announces it.
|
|
45
|
+
- File schemas: `FileSchema(opts?)`, `FileArraySchema(opts?)`, `OptionalFileSchema(opts?)` — **all are functions, call with `()`**.
|
|
46
|
+
- File helpers: `isFileSchema`, `isFileArraySchema`, `getFileOptions`, `formatFileSize`.
|
|
47
|
+
|
|
48
|
+
Types:
|
|
49
|
+
|
|
50
|
+
- `RouteInput`, `RouteDef`, `RouteHandlerFn`, `Router`, `RegisteredRoute`
|
|
51
|
+
- `RouteContract`, `RouteAuthProfile`
|
|
52
|
+
- `RouteBuilderContext`, `MergedInput`, `PaginatedResult`
|
|
53
|
+
- `HttpMethod`, `NamedMiddleware`, `NamedMiddlewareFactory`, `ExtractMiddlewareNames`
|
|
54
|
+
- `FileSchemaOptions`, `FileArraySchemaOptions`, `FileSchemaType`, `FileArraySchemaType`
|
|
55
|
+
|
|
56
|
+
### Removed API — do not use
|
|
57
|
+
|
|
58
|
+
The following contract-first / class-based APIs **do not exist** in this package. Older
|
|
59
|
+
docs and AI completions invent them — they will not compile:
|
|
60
|
+
|
|
61
|
+
- **`createApp(...)`, `createContract(...)`, `.bind(handler)`** — there is no contract-first
|
|
62
|
+
layer. A route is `route.<method>(path).input(...).handler(...)`, full stop. The handler
|
|
63
|
+
is attached inline via `.handler()`, never bound separately.
|
|
64
|
+
- **`RouteMeta`, `RouteMetadata`, `RouterMetadata`, `InferResponseData`** — not exported
|
|
65
|
+
from `types.ts` (which only exports `HttpMethod`). Do not import them.
|
|
66
|
+
- **`.meta(...)`, `.public()`, `.tags(...)`, `.description(...)` builder methods** — the
|
|
67
|
+
builder only has `input`, `interceptor`, `middleware`/`use`, `skip`, `contract`, `handler`.
|
|
68
|
+
- **`.output(schema)`** — does not exist. The response shape is declared inside `.contract({ response })`;
|
|
69
|
+
see [Contract](#contract).
|
|
70
|
+
- **`route.head(...)` / `route.options(...)`** — not defined.
|
|
71
|
+
- **`c.success(...)` / `c.error(...)`** — not context helpers (see the helper list below).
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Quick Start
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// routes/users.ts
|
|
79
|
+
import { route } from '@spfn/core/route';
|
|
80
|
+
import { Type } from '@sinclair/typebox';
|
|
81
|
+
|
|
82
|
+
export const getUser = route.get('/users/:id')
|
|
83
|
+
.input({
|
|
84
|
+
params: Type.Object({ id: Type.String() }),
|
|
85
|
+
})
|
|
86
|
+
.handler(async (c) =>
|
|
87
|
+
{
|
|
88
|
+
const { params } = await c.data(); // params: { id: string }
|
|
89
|
+
return await userRepo.findById(params.id); // plain return → JSON 200, type inferred
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const createUser = route.post('/users')
|
|
93
|
+
.input({
|
|
94
|
+
body: Type.Object({ name: Type.String(), email: Type.String({ format: 'email' }) }),
|
|
95
|
+
})
|
|
96
|
+
.handler(async (c) =>
|
|
97
|
+
{
|
|
98
|
+
const { body } = await c.data(); // body: { name: string; email: string }
|
|
99
|
+
return c.created(await userRepo.create(body), `/users/123`); // 201 + Location
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// router.ts
|
|
105
|
+
import { defineRouter } from '@spfn/core/route';
|
|
106
|
+
import { getUser, createUser } from './routes/users';
|
|
107
|
+
|
|
108
|
+
export const appRouter = defineRouter({ getUser, createUser });
|
|
109
|
+
export type AppRouter = typeof appRouter;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The router is then handed to `@spfn/core/server` via `defineServerConfig().routes(appRouter)`,
|
|
113
|
+
which calls `registerRoutes` internally. You rarely call `registerRoutes` by hand.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Route builder
|
|
118
|
+
|
|
119
|
+
`route.<method>(path)` returns a chainable, **immutable** `RouteBuilder` (each method
|
|
120
|
+
returns a fresh builder). `.handler(fn)` is the only terminal — it produces a `RouteDef`.
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
route.get('/users/:id') // method + path fixed here
|
|
124
|
+
.input({ ... }) // optional — TypeBox schemas per input source
|
|
125
|
+
.interceptor({ ... }) // optional — middleware-injected fields (see below)
|
|
126
|
+
.use([mwA, mwB]) // optional — route-level middleware (alias: .middleware)
|
|
127
|
+
.skip(['auth']) // optional — skip named server-level middleware
|
|
128
|
+
.contract({ ... }) // optional — publish as a versioned promise (see below)
|
|
129
|
+
.handler(async (c) => { ... }); // required — terminal
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| Method | Purpose |
|
|
133
|
+
|--------|---------|
|
|
134
|
+
| `route.get/post/put/patch/delete(path)` | Start a builder with method + path. |
|
|
135
|
+
| `.input(schemas)` | Define validated input (`params`/`query`/`body`/`formData`/`headers`/`cookies`). |
|
|
136
|
+
| `.interceptor(schemas)` | Declare fields injected by middleware — typed in handler, excluded from client types. |
|
|
137
|
+
| `.use(mws)` / `.middleware(mws)` | Attach route-level middleware (regular `MiddlewareHandler` or `NamedMiddleware`). Identical. |
|
|
138
|
+
| `.skip(names \| '*')` | Skip server-level named middleware for this route. |
|
|
139
|
+
| `.contract(contract)` | Publish the route as a versioned promise to separately deployed clients. |
|
|
140
|
+
| `.handler(fn)` | Terminal. Return value type becomes the response type. |
|
|
141
|
+
|
|
142
|
+
Chain order between `.input`/`.interceptor`/`.use`/`.skip`/`.contract` is free; only `.handler`
|
|
143
|
+
must be last.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Input
|
|
148
|
+
|
|
149
|
+
`.input(...)` takes a `RouteInput` — an object whose keys are TypeBox schemas, one per
|
|
150
|
+
HTTP input source. **All six keys are optional**; only declared sources are validated and typed.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
export type RouteInput = {
|
|
154
|
+
params?: TSchema; // path params (/users/:id)
|
|
155
|
+
query?: TSchema; // query string (?page=1)
|
|
156
|
+
body?: TSchema; // JSON request body
|
|
157
|
+
formData?: TSchema; // multipart/form-data (file uploads)
|
|
158
|
+
headers?: TSchema; // request headers (keys are lowercased)
|
|
159
|
+
cookies?: TSchema; // cookies
|
|
160
|
+
};
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
route.patch('/users/:id')
|
|
165
|
+
.input({
|
|
166
|
+
params: Type.Object({ id: Type.String() }),
|
|
167
|
+
query: Type.Object({ notify: Type.Optional(Type.Boolean()) }),
|
|
168
|
+
body: Type.Object({ name: Type.String() }),
|
|
169
|
+
headers: Type.Object({ authorization: Type.String() }),
|
|
170
|
+
cookies: Type.Object({ session: Type.String() }),
|
|
171
|
+
})
|
|
172
|
+
.handler(async (c) =>
|
|
173
|
+
{
|
|
174
|
+
const { params, query, body, headers, cookies } = await c.data();
|
|
175
|
+
});
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Type coercion
|
|
179
|
+
|
|
180
|
+
Input is run through TypeBox `Value.Convert` before validation, so URL/query/param strings
|
|
181
|
+
are coerced to their schema type:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
.input({
|
|
185
|
+
params: Type.Object({ id: Type.Number() }), // "123" → 123
|
|
186
|
+
query: Type.Object({ active: Type.Boolean(), // "true" → true
|
|
187
|
+
limit: Type.Number() }), // "10" → 10
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Validation errors
|
|
192
|
+
|
|
193
|
+
Validation throws `ValidationError` (from `@spfn/core/errors`), caught by the global error
|
|
194
|
+
handler → `400` with `{ error: { name, message, statusCode, fields: [{ path, message, value }] } }`.
|
|
195
|
+
Validation runs in this order: **params → query → headers → cookies → body/formData**.
|
|
196
|
+
|
|
197
|
+
### Built-in string formats
|
|
198
|
+
|
|
199
|
+
`@spfn/core/route` registers these TypeBox formats at import time: `email`, `uri` (http/https),
|
|
200
|
+
`uuid`, `date` (`YYYY-MM-DD`), `date-time`. Use via `Type.String({ format: 'email' })`.
|
|
201
|
+
Register your own with `FormatRegistry.Set(...)` from `@sinclair/typebox`.
|
|
202
|
+
|
|
203
|
+
### Nullable helpers
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
import { Nullable, OptionalNullable } from '@spfn/core/route';
|
|
207
|
+
|
|
208
|
+
Type.Optional(Type.String()) // string | undefined
|
|
209
|
+
Nullable(Type.String()) // string | null
|
|
210
|
+
OptionalNullable(Type.String()) // string | null | undefined
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### File uploads (formData)
|
|
214
|
+
|
|
215
|
+
`body` and `formData` are **mutually exclusive at runtime** — the request `Content-Type`
|
|
216
|
+
decides: `multipart/form-data` → `formData` is parsed/validated; otherwise `body`. Declaring
|
|
217
|
+
both is allowed but only one is populated per request.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { route, FileSchema, FileArraySchema, OptionalFileSchema } from '@spfn/core/route';
|
|
221
|
+
|
|
222
|
+
route.post('/upload')
|
|
223
|
+
.input({
|
|
224
|
+
formData: Type.Object({
|
|
225
|
+
avatar: FileSchema({ // call it — it is a function
|
|
226
|
+
maxSize: 5 * 1024 * 1024, // 5MB
|
|
227
|
+
allowedTypes: ['image/jpeg', 'image/png'],
|
|
228
|
+
}),
|
|
229
|
+
docs: FileArraySchema({ maxFiles: 5 }),
|
|
230
|
+
note: OptionalFileSchema(), // optional file
|
|
231
|
+
description: Type.Optional(Type.String()), // non-file fields validated by TypeBox
|
|
232
|
+
}),
|
|
233
|
+
})
|
|
234
|
+
.handler(async (c) =>
|
|
235
|
+
{
|
|
236
|
+
const { formData } = await c.data();
|
|
237
|
+
const avatar = formData.avatar as File; // file.name, file.size, file.type
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
File constraints (`maxSize`/`minSize`/`allowedTypes`/`maxFiles`/`minFiles`) are enforced
|
|
242
|
+
separately from TypeBox and also surface as `ValidationError`.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Contract
|
|
247
|
+
|
|
248
|
+
`.contract()` marks a route as a versioned promise to clients that are **compiled and deployed
|
|
249
|
+
separately from the server** — a mobile app in the store, an external API consumer. The build then
|
|
250
|
+
refuses a change that would break one of them.
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
export const getUser = route.get('/users/:id')
|
|
254
|
+
.input({ params: Type.Object({ id: Type.String() }) })
|
|
255
|
+
.contract({
|
|
256
|
+
since: '1.2.0',
|
|
257
|
+
auth: 'clientProofV1',
|
|
258
|
+
requiresSession: true,
|
|
259
|
+
response: Type.Object({
|
|
260
|
+
id: Type.String(),
|
|
261
|
+
name: Type.String(),
|
|
262
|
+
email: Type.Optional(Type.String()),
|
|
263
|
+
}),
|
|
264
|
+
})
|
|
265
|
+
.handler(async (c) => { ... });
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
| Field | Meaning |
|
|
269
|
+
|-------|---------|
|
|
270
|
+
| `response` | Response shape as a TypeBox schema — **declared, not inferred**. No body → `Type.Null()`. |
|
|
271
|
+
| `since` | Contract version this operation first appeared in. |
|
|
272
|
+
| `auth` | `'none'` or `'clientProofV1'`. Default `'none'`. |
|
|
273
|
+
| `requiresSession` | Whether the call carries a session. Default `false`. |
|
|
274
|
+
| `deprecatedIn` | Version the operation was announced for removal in. Optional. |
|
|
275
|
+
| `removedIn` | Version the operation was removed in. Optional — kept on a route that stays only to carry the record, so a client generated before the removal learns the operation went and when. |
|
|
276
|
+
|
|
277
|
+
**A web client needs none of this.** `createApi<AppRouter>()` derives its types from the router in
|
|
278
|
+
the same build, so a removed response field breaks the TypeScript compile. `.contract()` exists for
|
|
279
|
+
the clients TypeScript cannot reach.
|
|
280
|
+
|
|
281
|
+
`.contract()` is opt-in per route and changes nothing at runtime — no response validation, no
|
|
282
|
+
middleware. It only puts a value on `RouteDef` that the `@spfn/core:contract` generator reads.
|
|
283
|
+
|
|
284
|
+
**Not on a multipart route.** A route declaring `formData` cannot also carry `.contract()` — the
|
|
285
|
+
contract describes JSON values and a file part has no spelling among them, so the generator refuses
|
|
286
|
+
it rather than describing the operation incompletely. `formData` without `.contract()` is fine.
|
|
287
|
+
|
|
288
|
+
Full behaviour — the generator, the released snapshots, the compatibility case table and the
|
|
289
|
+
removal rules — is in [`../contract/README.md`](../contract/README.md).
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Handler & context
|
|
294
|
+
|
|
295
|
+
`.handler((c) => ...)` receives a `RouteBuilderContext`. Input is read via the **async**
|
|
296
|
+
`c.data()`; the return value of the handler becomes the response.
|
|
297
|
+
|
|
298
|
+
### `c.data()`
|
|
299
|
+
|
|
300
|
+
Returns `MergedInput` — the validated input merged with any `interceptor` fields. It is
|
|
301
|
+
**async** (body/formData parsing) and **cached** (safe to call once per source destructure):
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
const { params, query, body, formData, headers, cookies } = await c.data();
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Response: return value handling
|
|
308
|
+
|
|
309
|
+
The registration layer inspects what the handler returns:
|
|
310
|
+
|
|
311
|
+
- **Plain value** (object/array/primitive) → `c.json(value, 200)`. Type is inferred all the
|
|
312
|
+
way to the client. **This is the preferred path.**
|
|
313
|
+
- **A `Response`** (e.g. from `c.json(...)` / `c.redirect(...)`) → returned as-is. Note this
|
|
314
|
+
**erases the inferred response type** — only use when you need a status the helpers don't cover.
|
|
315
|
+
- Helper return values (`c.created`/`c.accepted`/`c.paginated`/...) carry status/headers via
|
|
316
|
+
internal metadata while still returning the **data** for inference.
|
|
317
|
+
|
|
318
|
+
### Context helpers (exhaustive)
|
|
319
|
+
|
|
320
|
+
| Helper | Returns | Effect |
|
|
321
|
+
|--------|---------|--------|
|
|
322
|
+
| `c.data()` | `Promise<MergedInput>` | Validated, merged, cached input. |
|
|
323
|
+
| `c.json(data, status?, headers?)` | `Response` | Raw JSON response — **loses response type inference**. |
|
|
324
|
+
| `c.created(data, location?)` | `T` (the data) | 201; sets `Location` header if given. |
|
|
325
|
+
| `c.accepted(data?)` | `T` or `void` | 202; empty body when called with no argument. |
|
|
326
|
+
| `c.noContent()` | `void` | 204, empty body. |
|
|
327
|
+
| `c.notModified()` | `void` | 304, empty body. |
|
|
328
|
+
| `c.paginated(items, page, limit, total)` | `PaginatedResult<T>` | `{ items, pagination: { page, limit, total, totalPages } }`. |
|
|
329
|
+
| `c.redirect(url, status?)` | `Response` | Redirect (default 302). |
|
|
330
|
+
| `c.raw` | Hono `Context` | Escape hatch for headers/streaming/`c.get()` set by middleware. |
|
|
331
|
+
|
|
332
|
+
> `created`/`accepted`/`paginated` return the **data** (not a `Response`) precisely so the
|
|
333
|
+
> response type is preserved — you must `return` them.
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
route.delete('/users/:id')
|
|
337
|
+
.input({ params: Type.Object({ id: Type.String() }) })
|
|
338
|
+
.handler(async (c) =>
|
|
339
|
+
{
|
|
340
|
+
await userRepo.delete((await c.data()).params.id);
|
|
341
|
+
return c.noContent(); // 204, type: void
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
route.get('/users')
|
|
345
|
+
.input({ query: Type.Object({ page: Type.Number(), limit: Type.Number() }) })
|
|
346
|
+
.handler(async (c) =>
|
|
347
|
+
{
|
|
348
|
+
const { query } = await c.data();
|
|
349
|
+
const { items, total } = await userRepo.findPaginated(query);
|
|
350
|
+
return c.paginated(items, query.page, query.limit, total); // PaginatedResult<User>
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Errors
|
|
355
|
+
|
|
356
|
+
Throw — don't return error objects. `@spfn/core/errors` provides
|
|
357
|
+
`BadRequestError`/`UnauthorizedError`/`ForbiddenError`/`NotFoundError`/`ConflictError`/
|
|
358
|
+
`TooManyRequestsError`/`ValidationError`/`InternalServerError`, plus the generic
|
|
359
|
+
`HttpError(status, message)`. The global handler serializes them.
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
import { NotFoundError } from '@spfn/core/errors';
|
|
363
|
+
|
|
364
|
+
route.get('/users/:id')
|
|
365
|
+
.input({ params: Type.Object({ id: Type.String() }) })
|
|
366
|
+
.handler(async (c) =>
|
|
367
|
+
{
|
|
368
|
+
const user = await userRepo.findById((await c.data()).params.id);
|
|
369
|
+
if (!user)
|
|
370
|
+
{
|
|
371
|
+
throw new NotFoundError({ resource: 'User' });
|
|
372
|
+
}
|
|
373
|
+
return user;
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### `.interceptor()` — middleware-injected fields
|
|
378
|
+
|
|
379
|
+
When a middleware injects fields into the request (e.g. auth crypto keys), declare them with
|
|
380
|
+
`.interceptor(...)`. They are merged into `c.data()` and **typed in the handler**, but
|
|
381
|
+
**excluded from generated client types** (clients never send them). They are **not** validated
|
|
382
|
+
by the route input schema (the middleware is responsible).
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
route.post('/_auth/login')
|
|
386
|
+
.input({ body: Type.Object({ email: Type.String(), password: Type.String() }) })
|
|
387
|
+
.interceptor({ body: Type.Object({ publicKey: Type.String(), keyId: Type.String() }) })
|
|
388
|
+
.handler(async (c) =>
|
|
389
|
+
{
|
|
390
|
+
const { body } = await c.data();
|
|
391
|
+
// handler sees: { email, password, publicKey, keyId }
|
|
392
|
+
// client sends only: { email, password }
|
|
393
|
+
return loginService(body);
|
|
394
|
+
});
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
An `interceptor` `body`/`formData` declaration also makes the request body be **parsed**, so a
|
|
398
|
+
route whose entire body comes from a middleware needs no `.input` to see it:
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
route.post('/_auth/keys/rotate')
|
|
402
|
+
.interceptor({ body: Type.Object({ publicKey: Type.String(), keyId: Type.String() }) })
|
|
403
|
+
.handler(async (c) =>
|
|
404
|
+
{
|
|
405
|
+
const { body } = await c.data();
|
|
406
|
+
// body = { publicKey, keyId } — parsed, not validated
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Parsing only: the values are passed through as they arrived, so a field the middleware failed
|
|
411
|
+
to inject reaches the handler as `undefined` rather than as a 400. A `GET`/`HEAD` route never
|
|
412
|
+
reads a body, whatever it declares.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## defineRouter — composition
|
|
417
|
+
|
|
418
|
+
`defineRouter(routes)` groups routes into a `Router`. Values may be `RouteDef`s **or** nested
|
|
419
|
+
`Router`s. The returned router is chainable.
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
// Flat — keys become RPC method names
|
|
423
|
+
export const appRouter = defineRouter({ getUser, createUser, updateUser });
|
|
424
|
+
|
|
425
|
+
// Nested namespaces
|
|
426
|
+
export const appRouter = defineRouter({
|
|
427
|
+
users: defineRouter({ get: getUser, create: createUser }),
|
|
428
|
+
posts: defineRouter({ list: listPosts, get: getPost }),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Spread route modules
|
|
432
|
+
import * as userRoutes from './routes/users';
|
|
433
|
+
export const appRouter = defineRouter({ ...userRoutes, ...postRoutes });
|
|
434
|
+
|
|
435
|
+
export type AppRouter = typeof appRouter;
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### `.packages([...])` — mount package routers
|
|
439
|
+
|
|
440
|
+
Attach routers from SPFN packages (`@spfn/auth`, `@spfn/cms`, …). Package routes **are**
|
|
441
|
+
registered/served, but are **excluded from `AppRouter`'s client types** — call them through
|
|
442
|
+
the package's own typed client (`authApi`, `cmsApi`) instead of `api`.
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
import { authRouter } from '@spfn/auth/server';
|
|
446
|
+
|
|
447
|
+
export const appRouter = defineRouter({ getRoot, getStatus })
|
|
448
|
+
.packages([authRouter]);
|
|
449
|
+
// api.getRoot.call({}) — app route
|
|
450
|
+
// authApi.login.call({}) — package route
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
`.packages()` also flattens any nested package routers the given routers themselves declared.
|
|
454
|
+
|
|
455
|
+
### `.use([...])` — router-level global middleware
|
|
456
|
+
|
|
457
|
+
Named middleware applied to **every** route in the router (and package routers), unless a
|
|
458
|
+
route opts out with `.skip(...)`. The router carries them, and route registration merges them
|
|
459
|
+
with server-config middleware — each one attaching **once** per route.
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
export const appRouter = defineRouter({ getRoot, getStatus })
|
|
463
|
+
.packages([authRouter])
|
|
464
|
+
.use([loggingMiddleware]);
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
## Middleware
|
|
470
|
+
|
|
471
|
+
### Route-level (`.use` / `.middleware`)
|
|
472
|
+
|
|
473
|
+
Accepts plain Hono `MiddlewareHandler`s and `NamedMiddleware`s, mixed:
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
route.post('/posts')
|
|
477
|
+
.use([authenticate, rateLimit({ limit: 10 })])
|
|
478
|
+
.handler(async (c) => { ... });
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Named middleware (`defineMiddleware`)
|
|
482
|
+
|
|
483
|
+
A named middleware can be **skipped by name** at the route level and is **deduplicated** if
|
|
484
|
+
present both globally and route-level. `defineMiddleware` auto-detects form by parameter count:
|
|
485
|
+
a 2-arg function is a `(c, next)` handler; anything else is a factory.
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
import { defineMiddleware } from '@spfn/core/route';
|
|
489
|
+
|
|
490
|
+
// Regular handler — exactly (c, next)
|
|
491
|
+
export const authMiddleware = defineMiddleware('auth', async (c, next) =>
|
|
492
|
+
{
|
|
493
|
+
if (!c.req.header('authorization')) return c.json({ error: 'Unauthorized' }, 401);
|
|
494
|
+
c.set('user', await verifyToken(c.req.header('authorization')!));
|
|
495
|
+
await next();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Factory — any arg count other than 2
|
|
499
|
+
export const requirePermissions = defineMiddleware('permission',
|
|
500
|
+
(...perms: string[]) => async (c, next) =>
|
|
501
|
+
{
|
|
502
|
+
if (!hasPermissions(c.get('user'), perms)) return c.json({ error: 'Forbidden' }, 403);
|
|
503
|
+
await next();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
route.get('/admin').use([requirePermissions('admin:write')]).handler(...);
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### `defineMiddlewareFactory` — for 2-arg factories
|
|
510
|
+
|
|
511
|
+
A factory whose own signature is exactly two args (e.g. `(limit, window) => handler`) would be
|
|
512
|
+
misread as a `(c, next)` handler by `defineMiddleware`. Use `defineMiddlewareFactory` to force
|
|
513
|
+
the factory interpretation:
|
|
514
|
+
|
|
515
|
+
```typescript
|
|
516
|
+
import { defineMiddlewareFactory } from '@spfn/core/route';
|
|
517
|
+
|
|
518
|
+
export const rateLimiter = defineMiddlewareFactory('rateLimit',
|
|
519
|
+
(limit: number, window: number) => async (c, next) => { /* ... */ await next(); });
|
|
520
|
+
|
|
521
|
+
route.get('/api').use([rateLimiter(100, 60_000)]).handler(...);
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Skipping & auto-skip
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
route.get('/status').skip(['auth']).handler(...); // skip a specific server-level middleware
|
|
528
|
+
route.get('/status').skip('*').handler(...); // skip ALL server-level middleware
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
`.skip(...)` only affects **server-level named middleware** — middleware added via `.use()`
|
|
532
|
+
on the same route is never skipped. A named middleware can declare auto-skips so callers don't
|
|
533
|
+
need an explicit `.skip`:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
// optionalAuth auto-skips the global 'auth' whenever it is used
|
|
537
|
+
export const optionalAuth = defineMiddleware('optionalAuth', handler, { skips: ['auth'] });
|
|
538
|
+
route.get('/feed').use([optionalAuth]).handler(...); // 'auth' auto-skipped, no .skip needed
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### Effective order at registration
|
|
542
|
+
|
|
543
|
+
```
|
|
544
|
+
server-level named middleware (minus skipped / auto-skipped)
|
|
545
|
+
→ route-level middleware (.use, deduped against the above)
|
|
546
|
+
→ input validation
|
|
547
|
+
→ handler
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Dedup rules: named middleware by `name`; plain middleware by handler reference. A name is
|
|
551
|
+
one middleware however many times it is registered — server-config and router-level `.use()`
|
|
552
|
+
naming the same middleware attach it **once**, which is what lets middleware hold one-shot
|
|
553
|
+
state (a nonce ledger) without rejecting its own request. An entry with no name is never
|
|
554
|
+
collapsed, since unrelated anonymous handlers share the empty name.
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## Pitfalls & anti-patterns
|
|
559
|
+
|
|
560
|
+
- **`c.data()` is async — `await` it.** `const { params } = c.data()` (no `await`) yields a
|
|
561
|
+
Promise, not your input.
|
|
562
|
+
- **File schemas are functions: `FileSchema()`, not `FileSchema`.** Passing the function
|
|
563
|
+
reference (no `()`) produces an invalid schema. Same for `FileArraySchema`/`OptionalFileSchema`.
|
|
564
|
+
(Some old docs show `file: FileSchema` — wrong.)
|
|
565
|
+
- **Returning a `Response` erases response-type inference.** `return c.json(data, 418)` makes
|
|
566
|
+
the client type `unknown`/`Response`. Prefer a plain `return data` or a typed helper
|
|
567
|
+
(`c.created`, `c.paginated`) unless you genuinely need a custom status.
|
|
568
|
+
- **You must `return` the response helpers.** `c.created(user)` / `c.noContent()` set metadata
|
|
569
|
+
and return the value/void — calling without `return` does nothing useful.
|
|
570
|
+
- **`created`/`accepted`/`paginated` return data, not a `Response`.** Don't `await c.json(...)`
|
|
571
|
+
on their output or wrap them again — just `return` them.
|
|
572
|
+
- **`.skip(...)` does not skip `.use(...)` middleware.** It only filters **server-level named**
|
|
573
|
+
middleware. To not run a route-level middleware, don't add it.
|
|
574
|
+
- **`skip`/`skips` match by middleware *name*, not import identity.** Only `defineMiddleware`-
|
|
575
|
+
created middleware has a name; a plain `MiddlewareHandler` can't be targeted by `.skip`.
|
|
576
|
+
- **`body` vs `formData` is decided by `Content-Type`.** A JSON request won't populate
|
|
577
|
+
`formData` and a multipart request won't populate `body`. Don't expect both.
|
|
578
|
+
- **Package routes aren't in `AppRouter` types.** After `.packages([authRouter])`, call those
|
|
579
|
+
endpoints via the package client (`authApi`), not via `api` — `typeof appRouter` deliberately
|
|
580
|
+
hides them.
|
|
581
|
+
- **`.handler` is terminal.** No chaining after it (it returns a `RouteDef`, not a builder).
|
|
582
|
+
Put `.input/.interceptor/.use/.skip` before `.handler`.
|
|
583
|
+
- **Headers are lowercased.** Declare `headers: Type.Object({ authorization: ... })`, not
|
|
584
|
+
`Authorization`. Cookies are split from the `cookie` header and URL-decoded.
|
|
585
|
+
- **No contract-first API.** There is no `createApp`/`createContract`/`.bind()`/`.meta()`/
|
|
586
|
+
`RouteMeta`. A route is fully defined by `route.<m>(path).input(...).handler(...)`.
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## Complete example
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
// routes/users.ts
|
|
594
|
+
import { route, FileSchema } from '@spfn/core/route';
|
|
595
|
+
import { Type } from '@sinclair/typebox';
|
|
596
|
+
import { NotFoundError } from '@spfn/core/errors';
|
|
597
|
+
import { userRepo } from '../repositories/user.repository';
|
|
598
|
+
|
|
599
|
+
export const listUsers = route.get('/users')
|
|
600
|
+
.input({
|
|
601
|
+
query: Type.Object({
|
|
602
|
+
page: Type.Number({ default: 1 }),
|
|
603
|
+
limit: Type.Number({ default: 20 }),
|
|
604
|
+
search: Type.Optional(Type.String()),
|
|
605
|
+
}),
|
|
606
|
+
})
|
|
607
|
+
.handler(async (c) =>
|
|
608
|
+
{
|
|
609
|
+
const { query } = await c.data();
|
|
610
|
+
const { items, total } = await userRepo.findPaginated(query);
|
|
611
|
+
return c.paginated(items, query.page, query.limit, total);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
export const getUser = route.get('/users/:id')
|
|
615
|
+
.input({ params: Type.Object({ id: Type.String() }) })
|
|
616
|
+
.handler(async (c) =>
|
|
617
|
+
{
|
|
618
|
+
const user = await userRepo.findById((await c.data()).params.id);
|
|
619
|
+
if (!user)
|
|
620
|
+
{
|
|
621
|
+
throw new NotFoundError({ resource: 'User' });
|
|
622
|
+
}
|
|
623
|
+
return user;
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
export const createUser = route.post('/users')
|
|
627
|
+
.input({
|
|
628
|
+
body: Type.Object({
|
|
629
|
+
name: Type.String({ minLength: 1, maxLength: 100 }),
|
|
630
|
+
email: Type.String({ format: 'email' }),
|
|
631
|
+
}),
|
|
632
|
+
})
|
|
633
|
+
.handler(async (c) =>
|
|
634
|
+
{
|
|
635
|
+
const { body } = await c.data();
|
|
636
|
+
const user = await userRepo.create(body);
|
|
637
|
+
return c.created(user, `/users/${user.id}`);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
export const uploadAvatar = route.post('/users/:id/avatar')
|
|
641
|
+
.input({
|
|
642
|
+
params: Type.Object({ id: Type.String() }),
|
|
643
|
+
formData: Type.Object({
|
|
644
|
+
avatar: FileSchema({ maxSize: 5 * 1024 * 1024, allowedTypes: ['image/jpeg', 'image/png'] }),
|
|
645
|
+
}),
|
|
646
|
+
})
|
|
647
|
+
.handler(async (c) =>
|
|
648
|
+
{
|
|
649
|
+
const { params, formData } = await c.data();
|
|
650
|
+
await userRepo.setAvatar(params.id, formData.avatar as File);
|
|
651
|
+
return c.noContent();
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
export const deleteUser = route.delete('/users/:id')
|
|
655
|
+
.skip(['rateLimit'])
|
|
656
|
+
.input({ params: Type.Object({ id: Type.String() }) })
|
|
657
|
+
.handler(async (c) =>
|
|
658
|
+
{
|
|
659
|
+
await userRepo.delete((await c.data()).params.id);
|
|
660
|
+
return c.noContent();
|
|
661
|
+
});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
```typescript
|
|
665
|
+
// router.ts
|
|
666
|
+
import { defineRouter } from '@spfn/core/route';
|
|
667
|
+
import { authRouter } from '@spfn/auth/server';
|
|
668
|
+
import { loggingMiddleware } from './middlewares/logging';
|
|
669
|
+
import * as userRoutes from './routes/users';
|
|
670
|
+
|
|
671
|
+
export const appRouter = defineRouter({ ...userRoutes })
|
|
672
|
+
.packages([authRouter])
|
|
673
|
+
.use([loggingMiddleware]);
|
|
674
|
+
|
|
675
|
+
export type AppRouter = typeof appRouter;
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
// server.config.ts — registration is done by @spfn/core/server
|
|
680
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
681
|
+
import { authMiddleware } from './middlewares/auth';
|
|
682
|
+
import { appRouter } from './router';
|
|
683
|
+
|
|
684
|
+
export default defineServerConfig()
|
|
685
|
+
.middlewares([authMiddleware]) // server-level named middleware (skippable per-route)
|
|
686
|
+
.routes(appRouter) // calls registerRoutes(app, appRouter, middlewares) internally
|
|
687
|
+
.build();
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
---
|
|
691
|
+
|
|
692
|
+
## Types reference
|
|
693
|
+
|
|
694
|
+
```typescript
|
|
695
|
+
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
696
|
+
|
|
697
|
+
type RouteInput = {
|
|
698
|
+
params?: TSchema; query?: TSchema; body?: TSchema;
|
|
699
|
+
formData?: TSchema; headers?: TSchema; cookies?: TSchema;
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
type RouteDef<TInput, TInterceptor, TResponse> = {
|
|
703
|
+
method?: HttpMethod; path?: string;
|
|
704
|
+
input?: TInput; interceptor?: TInterceptor;
|
|
705
|
+
middlewares?: (MiddlewareHandler | NamedMiddleware<string>)[];
|
|
706
|
+
skipMiddlewares?: string[] | '*';
|
|
707
|
+
handler: RouteHandlerFn<TInput, TInterceptor, TResponse>;
|
|
708
|
+
// _input / _interceptor / _response: compile-time inference helpers (never read at runtime)
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
type Router<TRoutes> = {
|
|
712
|
+
routes: TRoutes;
|
|
713
|
+
packages(routers: Router<any>[]): Router<TRoutes>;
|
|
714
|
+
use(middlewares: NamedMiddleware<string>[]): Router<TRoutes>;
|
|
715
|
+
// _routes / _packageRouters / _globalMiddlewares: internal
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
type PaginatedResult<T> = {
|
|
719
|
+
items: T[];
|
|
720
|
+
pagination: { page: number; limit: number; total: number; totalPages: number };
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
type NamedMiddleware<TName extends string = string> = {
|
|
724
|
+
name: TName; handler: MiddlewareHandler; _name: TName; skips?: string[];
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
type RegisteredRoute = { method: HttpMethod; path: string; name: string };
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
`registerRoutes(app, router, namedMiddlewares?, collectedRoutes?)` returns the flattened
|
|
731
|
+
`RegisteredRoute[]` (including nested and package routers) — useful for logging mounted routes.
|
|
732
|
+
|
|
733
|
+
## Related
|
|
734
|
+
|
|
735
|
+
- [@spfn/core/server](../server/README.md) — `defineServerConfig().routes(router)`, which mounts the router.
|
|
736
|
+
- [@spfn/core/errors](../errors/README.md) — error classes thrown from handlers and validation.
|
|
737
|
+
- [@sinclair/typebox](https://github.com/sinclairzx81/typebox) — schema builder (`Type`) used in `.input()`.
|
|
738
|
+
- [Hono](https://hono.dev) — underlying framework; `c.raw` is a Hono `Context`.
|