@spfn/core 0.3.0-beta.5 → 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.
@@ -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`.