@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31

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.
Files changed (104) hide show
  1. package/README.md +304 -12
  2. package/dist/app-config/index.d.ts +39 -1
  3. package/dist/app-config/index.js.map +1 -1
  4. package/dist/authz/index.js +47 -391
  5. package/dist/authz/index.js.map +1 -1
  6. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  7. package/dist/codegen/index.d.ts +157 -16
  8. package/dist/codegen/index.js +772 -166
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/config/index.js +4 -4
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/contract/index.d.ts +39 -16
  13. package/dist/contract/index.js +147 -23
  14. package/dist/contract/index.js.map +1 -1
  15. package/dist/db/index.d.ts +230 -35
  16. package/dist/db/index.js +316 -160
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/env/index.d.ts +6 -442
  19. package/dist/env/index.js +13 -16
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/loader.js +24 -1
  22. package/dist/env/loader.js.map +1 -1
  23. package/dist/error-CIZlYJlL.d.ts +157 -0
  24. package/dist/errors/index.d.ts +18 -1
  25. package/dist/errors/index.js +47 -391
  26. package/dist/errors/index.js.map +1 -1
  27. package/dist/event/index.d.ts +5 -5
  28. package/dist/event/index.js +202 -3
  29. package/dist/event/index.js.map +1 -1
  30. package/dist/event/sse/client.d.ts +18 -4
  31. package/dist/event/sse/client.js +94 -18
  32. package/dist/event/sse/client.js.map +1 -1
  33. package/dist/event/sse/index.d.ts +18 -5
  34. package/dist/event/sse/index.js +452 -114
  35. package/dist/event/sse/index.js.map +1 -1
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.js +52 -397
  44. package/dist/middleware/index.js.map +1 -1
  45. package/dist/nextjs/index.d.ts +75 -14
  46. package/dist/nextjs/index.js +200 -84
  47. package/dist/nextjs/index.js.map +1 -1
  48. package/dist/nextjs/server.d.ts +46 -8
  49. package/dist/nextjs/server.js +15 -1
  50. package/dist/nextjs/server.js.map +1 -1
  51. package/dist/ops/index.d.ts +61 -42
  52. package/dist/ops/index.js +648 -97
  53. package/dist/ops/index.js.map +1 -1
  54. package/dist/registry-C2KQCh0m.d.ts +473 -0
  55. package/dist/route/index.d.ts +5 -2
  56. package/dist/route/index.js +107 -28
  57. package/dist/route/index.js.map +1 -1
  58. package/dist/route/types.d.ts +7 -1
  59. package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
  60. package/dist/router-C57y-vz1.d.ts +184 -0
  61. package/dist/router-CX5uVjmU.d.ts +406 -0
  62. package/dist/server/index.d.ts +130 -7
  63. package/dist/server/index.js +1174 -164
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/state/client.d.ts +206 -0
  66. package/dist/state/client.js +678 -0
  67. package/dist/state/client.js.map +1 -0
  68. package/dist/state/index.d.ts +443 -0
  69. package/dist/state/index.js +644 -0
  70. package/dist/state/index.js.map +1 -0
  71. package/dist/state/react.d.ts +49 -0
  72. package/dist/state/react.js +50 -0
  73. package/dist/state/react.js.map +1 -0
  74. package/dist/surface/index.d.ts +115 -0
  75. package/dist/surface/index.js +1013 -0
  76. package/dist/surface/index.js.map +1 -0
  77. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  78. package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  79. package/dist/types-CKH0D3Dy.d.ts +441 -0
  80. package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
  81. package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
  82. package/docs/file-upload.md +195 -333
  83. package/package.json +41 -11
  84. package/src/cache/README.md +330 -0
  85. package/src/codegen/README.md +721 -0
  86. package/src/config/README.md +333 -0
  87. package/src/contract/README.md +442 -0
  88. package/src/db/README.md +589 -0
  89. package/src/db/manager/README.md +500 -0
  90. package/src/db/schema/README.md +354 -0
  91. package/src/db/transaction/README.md +845 -0
  92. package/src/env/README.md +787 -0
  93. package/src/errors/README.md +445 -0
  94. package/src/event/README.md +994 -0
  95. package/src/job/README.md +613 -0
  96. package/src/logger/README.md +321 -0
  97. package/src/middleware/README.md +634 -0
  98. package/src/nextjs/README.md +756 -0
  99. package/src/route/README.md +763 -0
  100. package/src/security/README.md +100 -0
  101. package/src/server/README.md +704 -0
  102. package/src/state/README.md +806 -0
  103. package/dist/token-manager-BT5EnUAR.d.ts +0 -278
  104. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -0,0 +1,763 @@
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
+ Nesting groups the source, not the name: a nested route is registered — and typed, through
439
+ `RouterOutput`/`RouterInput` and the `createApi` client — under its own key (`get`, `list`),
440
+ never under `users.get`. `api.get.call({...})` is the call; `api.users` is not a route and
441
+ does not compile. Two branches declaring one name is therefore a collision, which the contract
442
+ collector refuses and the client types leave out of the namespace.
443
+
444
+ ### `.packages([...])` — mount package routers
445
+
446
+ Attach routers from SPFN packages (`@spfn/auth`, `@spfn/cms`, …). Package routes **are**
447
+ registered/served, but are **excluded from `AppRouter`'s client types** — call them through
448
+ the package's own typed client (`authApi`, `cmsApi`) instead of `api`.
449
+
450
+ ```typescript
451
+ import { authRouter } from '@spfn/auth/server';
452
+
453
+ export const appRouter = defineRouter({ getRoot, getStatus })
454
+ .packages([authRouter]);
455
+ // api.getRoot.call({}) — app route
456
+ // authApi.login.call({}) — package route
457
+ ```
458
+
459
+ `.packages()` also flattens any nested package routers the given routers themselves declared.
460
+
461
+ A mounted package's routes are written into the app's generated route map, so the app merges
462
+ nothing by hand — `createRpcProxy({ routeMap })` resolves `authApi.login` as it resolves the
463
+ app's own names.
464
+
465
+ **A name a package route already uses is not available to an app route.** Both register, at
466
+ their own paths, but the map holds one entry per name: one of the two paths becomes
467
+ unreachable while the generated types still describe the app's route. `spfn codegen run`
468
+ refuses rather than generating that, naming both sides. Two package routers reaching one
469
+ name is refused the same way.
470
+
471
+ A package that publishes **no** route map is the exception, and declares itself one with
472
+ `defineUnmappedRouter`: its routes are reached by the URL something was handed rather than by
473
+ name, so nothing of theirs is written into the app's map and an app route may share a name
474
+ with one of them freely. `createOpsRouter` builds the ops surface that way — `spfn ops`
475
+ invokes a command over its URL — as do the tracking routes of `@spfn/notification`.
476
+ `defineRouter` is the default because the obligation runs the other way: a package whose
477
+ client names its own routes (`@spfn/auth`, `@spfn/monitor`, `@spfn/cms`) must not declare
478
+ itself unmapped, or those names land in nobody's map and resolve nowhere.
479
+
480
+ ### `.use([...])` — router-level global middleware
481
+
482
+ Named middleware applied to **every** route in the router (and package routers), unless a
483
+ route opts out with `.skip(...)`. The router carries them, and route registration merges them
484
+ with server-config middleware — each one attaching **once** per route.
485
+
486
+ ```typescript
487
+ export const appRouter = defineRouter({ getRoot, getStatus })
488
+ .packages([authRouter])
489
+ .use([loggingMiddleware]);
490
+ ```
491
+
492
+ ---
493
+
494
+ ## Middleware
495
+
496
+ ### Route-level (`.use` / `.middleware`)
497
+
498
+ Accepts plain Hono `MiddlewareHandler`s and `NamedMiddleware`s, mixed:
499
+
500
+ ```typescript
501
+ route.post('/posts')
502
+ .use([authenticate, rateLimit({ limit: 10 })])
503
+ .handler(async (c) => { ... });
504
+ ```
505
+
506
+ ### Named middleware (`defineMiddleware`)
507
+
508
+ A named middleware can be **skipped by name** at the route level and is **deduplicated** if
509
+ present both globally and route-level. `defineMiddleware` auto-detects form by parameter count:
510
+ a 2-arg function is a `(c, next)` handler; anything else is a factory.
511
+
512
+ ```typescript
513
+ import { defineMiddleware } from '@spfn/core/route';
514
+
515
+ // Regular handler — exactly (c, next)
516
+ export const authMiddleware = defineMiddleware('auth', async (c, next) =>
517
+ {
518
+ if (!c.req.header('authorization')) return c.json({ error: 'Unauthorized' }, 401);
519
+ c.set('user', await verifyToken(c.req.header('authorization')!));
520
+ await next();
521
+ });
522
+
523
+ // Factory — any arg count other than 2
524
+ export const requirePermissions = defineMiddleware('permission',
525
+ (...perms: string[]) => async (c, next) =>
526
+ {
527
+ if (!hasPermissions(c.get('user'), perms)) return c.json({ error: 'Forbidden' }, 403);
528
+ await next();
529
+ });
530
+
531
+ route.get('/admin').use([requirePermissions('admin:write')]).handler(...);
532
+ ```
533
+
534
+ ### `defineMiddlewareFactory` — for 2-arg factories
535
+
536
+ A factory whose own signature is exactly two args (e.g. `(limit, window) => handler`) would be
537
+ misread as a `(c, next)` handler by `defineMiddleware`. Use `defineMiddlewareFactory` to force
538
+ the factory interpretation:
539
+
540
+ ```typescript
541
+ import { defineMiddlewareFactory } from '@spfn/core/route';
542
+
543
+ export const rateLimiter = defineMiddlewareFactory('rateLimit',
544
+ (limit: number, window: number) => async (c, next) => { /* ... */ await next(); });
545
+
546
+ route.get('/api').use([rateLimiter(100, 60_000)]).handler(...);
547
+ ```
548
+
549
+ ### Skipping & auto-skip
550
+
551
+ ```typescript
552
+ route.get('/status').skip(['auth']).handler(...); // skip a specific server-level middleware
553
+ route.get('/status').skip('*').handler(...); // skip ALL server-level middleware
554
+ ```
555
+
556
+ `.skip(...)` only affects **server-level named middleware** — middleware added via `.use()`
557
+ on the same route is never skipped. A named middleware can declare auto-skips so callers don't
558
+ need an explicit `.skip`:
559
+
560
+ ```typescript
561
+ // optionalAuth auto-skips the global 'auth' whenever it is used
562
+ export const optionalAuth = defineMiddleware('optionalAuth', handler, { skips: ['auth'] });
563
+ route.get('/feed').use([optionalAuth]).handler(...); // 'auth' auto-skipped, no .skip needed
564
+ ```
565
+
566
+ ### Effective order at registration
567
+
568
+ ```
569
+ server-level named middleware (minus skipped / auto-skipped)
570
+ → route-level middleware (.use, deduped against the above)
571
+ → input validation
572
+ → handler
573
+ ```
574
+
575
+ Dedup rules: named middleware by `name`; plain middleware by handler reference. A name is
576
+ one middleware however many times it is registered — server-config and router-level `.use()`
577
+ naming the same middleware attach it **once**, which is what lets middleware hold one-shot
578
+ state (a nonce ledger) without rejecting its own request. An entry with no name is never
579
+ collapsed, since unrelated anonymous handlers share the empty name.
580
+
581
+ ---
582
+
583
+ ## Pitfalls & anti-patterns
584
+
585
+ - **`c.data()` is async — `await` it.** `const { params } = c.data()` (no `await`) yields a
586
+ Promise, not your input.
587
+ - **File schemas are functions: `FileSchema()`, not `FileSchema`.** Passing the function
588
+ reference (no `()`) produces an invalid schema. Same for `FileArraySchema`/`OptionalFileSchema`.
589
+ (Some old docs show `file: FileSchema` — wrong.)
590
+ - **Returning a `Response` erases response-type inference.** `return c.json(data, 418)` makes
591
+ the client type `unknown`/`Response`. Prefer a plain `return data` or a typed helper
592
+ (`c.created`, `c.paginated`) unless you genuinely need a custom status.
593
+ - **You must `return` the response helpers.** `c.created(user)` / `c.noContent()` set metadata
594
+ and return the value/void — calling without `return` does nothing useful.
595
+ - **`created`/`accepted`/`paginated` return data, not a `Response`.** Don't `await c.json(...)`
596
+ on their output or wrap them again — just `return` them.
597
+ - **`.skip(...)` does not skip `.use(...)` middleware.** It only filters **server-level named**
598
+ middleware. To not run a route-level middleware, don't add it.
599
+ - **`skip`/`skips` match by middleware *name*, not import identity.** Only `defineMiddleware`-
600
+ created middleware has a name; a plain `MiddlewareHandler` can't be targeted by `.skip`.
601
+ - **`body` vs `formData` is decided by `Content-Type`.** A JSON request won't populate
602
+ `formData` and a multipart request won't populate `body`. Don't expect both.
603
+ - **Package routes aren't in `AppRouter` types.** After `.packages([authRouter])`, call those
604
+ endpoints via the package client (`authApi`), not via `api` — `typeof appRouter` deliberately
605
+ hides them.
606
+ - **`.handler` is terminal.** No chaining after it (it returns a `RouteDef`, not a builder).
607
+ Put `.input/.interceptor/.use/.skip` before `.handler`.
608
+ - **Headers are lowercased.** Declare `headers: Type.Object({ authorization: ... })`, not
609
+ `Authorization`. Cookies are split from the `cookie` header and URL-decoded.
610
+ - **No contract-first API.** There is no `createApp`/`createContract`/`.bind()`/`.meta()`/
611
+ `RouteMeta`. A route is fully defined by `route.<m>(path).input(...).handler(...)`.
612
+
613
+ ---
614
+
615
+ ## Complete example
616
+
617
+ ```typescript
618
+ // routes/users.ts
619
+ import { route, FileSchema } from '@spfn/core/route';
620
+ import { Type } from '@sinclair/typebox';
621
+ import { NotFoundError } from '@spfn/core/errors';
622
+ import { userRepo } from '../repositories/user.repository';
623
+
624
+ export const listUsers = route.get('/users')
625
+ .input({
626
+ query: Type.Object({
627
+ page: Type.Number({ default: 1 }),
628
+ limit: Type.Number({ default: 20 }),
629
+ search: Type.Optional(Type.String()),
630
+ }),
631
+ })
632
+ .handler(async (c) =>
633
+ {
634
+ const { query } = await c.data();
635
+ const { items, total } = await userRepo.findPaginated(query);
636
+ return c.paginated(items, query.page, query.limit, total);
637
+ });
638
+
639
+ export const getUser = route.get('/users/:id')
640
+ .input({ params: Type.Object({ id: Type.String() }) })
641
+ .handler(async (c) =>
642
+ {
643
+ const user = await userRepo.findById((await c.data()).params.id);
644
+ if (!user)
645
+ {
646
+ throw new NotFoundError({ resource: 'User' });
647
+ }
648
+ return user;
649
+ });
650
+
651
+ export const createUser = route.post('/users')
652
+ .input({
653
+ body: Type.Object({
654
+ name: Type.String({ minLength: 1, maxLength: 100 }),
655
+ email: Type.String({ format: 'email' }),
656
+ }),
657
+ })
658
+ .handler(async (c) =>
659
+ {
660
+ const { body } = await c.data();
661
+ const user = await userRepo.create(body);
662
+ return c.created(user, `/users/${user.id}`);
663
+ });
664
+
665
+ export const uploadAvatar = route.post('/users/:id/avatar')
666
+ .input({
667
+ params: Type.Object({ id: Type.String() }),
668
+ formData: Type.Object({
669
+ avatar: FileSchema({ maxSize: 5 * 1024 * 1024, allowedTypes: ['image/jpeg', 'image/png'] }),
670
+ }),
671
+ })
672
+ .handler(async (c) =>
673
+ {
674
+ const { params, formData } = await c.data();
675
+ await userRepo.setAvatar(params.id, formData.avatar as File);
676
+ return c.noContent();
677
+ });
678
+
679
+ export const deleteUser = route.delete('/users/:id')
680
+ .skip(['rateLimit'])
681
+ .input({ params: Type.Object({ id: Type.String() }) })
682
+ .handler(async (c) =>
683
+ {
684
+ await userRepo.delete((await c.data()).params.id);
685
+ return c.noContent();
686
+ });
687
+ ```
688
+
689
+ ```typescript
690
+ // router.ts
691
+ import { defineRouter } from '@spfn/core/route';
692
+ import { authRouter } from '@spfn/auth/server';
693
+ import { loggingMiddleware } from './middlewares/logging';
694
+ import * as userRoutes from './routes/users';
695
+
696
+ export const appRouter = defineRouter({ ...userRoutes })
697
+ .packages([authRouter])
698
+ .use([loggingMiddleware]);
699
+
700
+ export type AppRouter = typeof appRouter;
701
+ ```
702
+
703
+ ```typescript
704
+ // server.config.ts — registration is done by @spfn/core/server
705
+ import { defineServerConfig } from '@spfn/core/server';
706
+ import { authMiddleware } from './middlewares/auth';
707
+ import { appRouter } from './router';
708
+
709
+ export default defineServerConfig()
710
+ .middlewares([authMiddleware]) // server-level named middleware (skippable per-route)
711
+ .routes(appRouter) // calls registerRoutes(app, appRouter, middlewares) internally
712
+ .build();
713
+ ```
714
+
715
+ ---
716
+
717
+ ## Types reference
718
+
719
+ ```typescript
720
+ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
721
+
722
+ type RouteInput = {
723
+ params?: TSchema; query?: TSchema; body?: TSchema;
724
+ formData?: TSchema; headers?: TSchema; cookies?: TSchema;
725
+ };
726
+
727
+ type RouteDef<TInput, TInterceptor, TResponse> = {
728
+ method?: HttpMethod; path?: string;
729
+ input?: TInput; interceptor?: TInterceptor;
730
+ middlewares?: (MiddlewareHandler | NamedMiddleware<string>)[];
731
+ skipMiddlewares?: string[] | '*';
732
+ handler: RouteHandlerFn<TInput, TInterceptor, TResponse>;
733
+ // _input / _interceptor / _response: compile-time inference helpers (never read at runtime)
734
+ };
735
+
736
+ type Router<TRoutes> = {
737
+ routes: TRoutes;
738
+ packages(routers: Router<any>[]): Router<TRoutes>;
739
+ use(middlewares: NamedMiddleware<string>[]): Router<TRoutes>;
740
+ // _routes / _packageRouters / _globalMiddlewares: internal
741
+ };
742
+
743
+ type PaginatedResult<T> = {
744
+ items: T[];
745
+ pagination: { page: number; limit: number; total: number; totalPages: number };
746
+ };
747
+
748
+ type NamedMiddleware<TName extends string = string> = {
749
+ name: TName; handler: MiddlewareHandler; _name: TName; skips?: string[];
750
+ };
751
+
752
+ type RegisteredRoute = { method: HttpMethod; path: string; name: string };
753
+ ```
754
+
755
+ `registerRoutes(app, router, namedMiddlewares?, collectedRoutes?)` returns the flattened
756
+ `RegisteredRoute[]` (including nested and package routers) — useful for logging mounted routes.
757
+
758
+ ## Related
759
+
760
+ - [@spfn/core/server](../server/README.md) — `defineServerConfig().routes(router)`, which mounts the router.
761
+ - [@spfn/core/errors](../errors/README.md) — error classes thrown from handlers and validation.
762
+ - [@sinclair/typebox](https://github.com/sinclairzx81/typebox) — schema builder (`Type`) used in `.input()`.
763
+ - [Hono](https://hono.dev) — underlying framework; `c.raw` is a Hono `Context`.