@ayepi/core 0.1.0 → 0.2.0

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,446 @@
1
+ <!--
2
+ ayepi-core-middleware.md — reference for `@ayepi/core`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/core` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/core` — middleware
11
+
12
+ Middleware is composable, strongly-typed request processing that runs **server-side**
13
+ before a handler. It is split into two halves, mirroring `spec()` ↔ `implement().handlers()`:
14
+
15
+ - a **def** — the contract, declared in the spec via `middleware(name, opts?)`. A def carries
16
+ the middleware's name, the context type it provides, its dependencies, and its docs — but
17
+ **no runtime code**. Defs are pure data, so a spec that uses them stays **frontend-safe**
18
+ (the spec file imports only `@ayepi/core` + `zod`, never secrets or node deps).
19
+ - an **impl** — the runtime function, bound separately at server-assembly time via
20
+ `implement(api).middleware(def, impl)`. The impl is where secrets, database calls, and node
21
+ imports live.
22
+
23
+ A middleware def can:
24
+
25
+ - **provide context** — declare it with `provides: ctx<P>()`; the impl supplies the actual
26
+ values via `io.next({ ... })`, and `P` is merged into the handler payload root. Omit
27
+ `provides` for a no-context (purely-runtime) middleware such as a logger;
28
+ - **declare dependencies** — `requires` middleware are auto-included and run first (their
29
+ context is guaranteed in `io.ctx`); `optional` middleware only affect *ordering* when present;
30
+ - **load a path param** — `middleware.loader` owns a `:key` + schema + context, parsing the
31
+ segment before the chain runs and exposing the typed `io.value` to its impl;
32
+ - **short-circuit** — its impl may return a `Response` instead of calling `io.next()` to skip
33
+ the rest of the chain and the handler.
34
+
35
+ Every middleware in an endpoint's (or event guard's) chain **must be bound** to an impl before
36
+ the server can run, or `server()` throws at assembly time (see "Binding defs to impls" below).
37
+
38
+ See `ayepi-core-endpoints.md` for `EndpointConfig`; `ayepi-core-types.md` for how context
39
+ flows into the handler payload.
40
+
41
+ ## `middleware(name, opts?)` — defining a def
42
+
43
+ ```ts
44
+ middleware<P, R, O>(
45
+ name: string,
46
+ opts?: {
47
+ provides?: Provide<P> // ctx<P>() — the context type this middleware contributes; omit for none
48
+ requires?: R // hard deps — auto-included, run first, their ctx guaranteed
49
+ optional?: O // soft deps — only reorder when independently present
50
+ doc?: MiddlewareDoc // security scheme / OpenAPI patches
51
+ },
52
+ ): Middleware<P, R, …>
53
+ ```
54
+
55
+ The factory returns a **def** — no function argument. The context type is declared with the
56
+ new `ctx<P>()` helper passed as `provides`; the impl is bound later. There is **no**
57
+ `middleware(name, fn)` / `middleware(name, opts, fn)` form anymore (removed — breaking).
58
+
59
+ ```ts
60
+ import { spec, middleware, ctx } from '@ayepi/core'
61
+
62
+ // def: provides { user } — declared via ctx<P>(), supplied later by the impl
63
+ const auth = middleware('auth', { provides: ctx<{ user: User }>() })
64
+
65
+ // def: provides nothing — a purely-runtime middleware (e.g. logging) is still a def in the spec
66
+ const log = middleware('log')
67
+ ```
68
+
69
+ `ctx<P>()` is a new export from `@ayepi/core`: a zero-runtime type-carrier whose return type is
70
+ `Provide<P>`. It only records the context type `P`; it produces no values.
71
+
72
+ ## Binding defs to impls: `implement(api)`
73
+
74
+ The impl is a normal async function: it receives `io` and must call `io.next()` (or throw, or
75
+ return a `Response`) exactly once. Bind it to its def with the chainable `implement()` builder:
76
+
77
+ ```ts
78
+ import { implement, server } from '@ayepi/core'
79
+
80
+ implement(api)
81
+ .middleware(auth, async (io) => io.next({ user: await authenticate(io.req) }))
82
+ .middleware(log, async (io) => io.next()) // no-context impl just calls next()
83
+ .handlers({ /* … */ })
84
+ ```
85
+
86
+ `implement(api)` is now a **chainable builder**. Every method returns the same builder:
87
+
88
+ - **`.middleware(def, impl)`** — bind one middleware def to its impl.
89
+ - **`.middleware(bound)`** — bind via a pre-made `{ def, impl }` pair (a `BoundMiddleware`).
90
+ - **`.handlers({ ... })`** — type each handler against its endpoint (as before).
91
+ - **`.handle(name, fn)`** — type a single handler.
92
+
93
+ The impl's signature is derived from the def: `io.ctx` carries the **requires'** context (plus
94
+ a `Partial` of the **optional** context), and the value it passes to `io.next({ ... })` must
95
+ match the def's declared `provides` type. A loader impl additionally receives a typed
96
+ `io.value`. The exported helper types `MiddlewareImplFor<M>`, `LoaderImplFor<M>`, and
97
+ `ImplFor<M>` give you the exact impl type for any def `M` (handy for declaring impls
98
+ out-of-line; see "Exported middleware symbols").
99
+
100
+ `MiddlewareIO` receives `io` and must call `io.next()` (or throw, or return a `Response`)
101
+ exactly once:
102
+
103
+ ```ts
104
+ interface MiddlewareIO<Req extends object> {
105
+ readonly req: Request // the incoming request (over ws: the connection's upgrade request, shared per socket)
106
+ readonly ctx: Simplify<Req> // context accumulated by earlier middleware (read-only)
107
+ readonly next: <T extends object = {}>(add?: T) => Promise<MiddlewareResult<T>>
108
+ readonly transport: 'http' | 'ws' // which transport this invocation arrived on
109
+ readonly route: // the matched route — transport-neutral identity
110
+ | { kind: 'endpoint'; name: string; method: HttpMethod; path: string; ws: string | null }
111
+ | { kind: 'event'; name: string; ws: string } // event-guard chains (on subscribe)
112
+ readonly signal: AbortSignal // the request / ws-call abort signal
113
+ readonly ws?: { id: string; data: unknown; conn: WsConn } // ws only: the frame id (per-call id), raw payload, connection
114
+ readonly setHeader: (name: string, value: string) => void // set a response header (HTTP)
115
+ readonly status: (code: number) => void // set the HTTP status, or the ws result-frame `$status`
116
+ }
117
+ // the impl's type — `MiddlewareImplFor<typeof someDef>` resolves to exactly this for that def
118
+ type MiddlewareImpl<Req, P> = (io: MiddlewareIO<Req>) => Promise<MiddlewareResult<P> | Response>
119
+ ```
120
+
121
+ Beyond `req`/`ctx`/`next`, `io` exposes the **invocation context**, identical over HTTP and
122
+ ws:
123
+
124
+ - `io.transport` — `'http'` or `'ws'`.
125
+ - `io.route` — the matched route. Use `io.route.method`/`io.route.path` for a
126
+ transport-neutral identity (correct over ws, where `io.req.url` is just the upgrade URL),
127
+ and `io.route.name` to key per-endpoint behavior (e.g. telemetry overrides). On an **event**
128
+ guard chain `io.route.kind === 'event'` (no method/path).
129
+ - `io.ws` — present only over ws: `io.ws.id` is the frame id (the real per-call request id),
130
+ `io.ws.data` the raw frame payload, `io.ws.conn` the connection.
131
+ - `io.body` — the raw, **pre-validation** body: the parsed JSON / urlencoded-form object (or a
132
+ multipart request's non-file fields), the ws call's data, or `undefined` when there's none.
133
+ Read it to derive idempotency/cache keys, sign or log the payload; the typed, validated body
134
+ still reaches the handler as `data`.
135
+ - `io.signal` — abort signal (HTTP request signal, or the ws call's per-frame signal).
136
+ - `io.setHeader(name, value)` / `io.status(code)` — set the response header/status. Over ws
137
+ `io.status` sets the result frame's `$status` (headers are collected but not applied). These
138
+ share the same response object the handler's `$status`/`$header` use, so middleware and
139
+ handler cooperate. Must run before the response commits.
140
+
141
+ ```ts
142
+ // shared.ts (frontend-safe) — defs only
143
+ import { middleware, ctx } from '@ayepi/core'
144
+
145
+ const auth = middleware('auth', { provides: ctx<{ user: User }>() }) // declares it provides { user }
146
+ const log = middleware('log') // provides nothing
147
+
148
+ // server.ts — bind the impls
149
+ import { implement, reject } from '@ayepi/core'
150
+
151
+ implement(api)
152
+ // provides { user } — the value passed to next() must match the def's ctx<{ user: User }>()
153
+ .middleware(auth, async (io) => {
154
+ if (io.req.headers.get('authorization') !== 'Bearer secret') throw reject(401, 'UNAUTHORIZED')
155
+ return io.next({ user: { id: 'u1', name: 'Phil', role: 'admin' as const } })
156
+ })
157
+ // plain wrapper — provides nothing, just times the request
158
+ .middleware(log, async (io) => {
159
+ const t = Date.now()
160
+ const r = await io.next()
161
+ console.log(`${io.req.method} ${Date.now() - t}ms`)
162
+ return r
163
+ })
164
+ ```
165
+
166
+ ## Dependencies: `requires` vs `optional`
167
+
168
+ Dependencies are part of the **def** (they shape the contract and the impl's `io.ctx` type):
169
+
170
+ ```ts
171
+ // defs (frontend-safe)
172
+ // hard dep — auth is auto-included; io.ctx.user is guaranteed in the impl
173
+ const org = middleware('org', { provides: ctx<{ org: Org }>(), requires: [auth] })
174
+ // optional dep — runs after auth IF auth is independently present, but does NOT pull it in
175
+ const cache = middleware('cache', { provides: ctx<{ cached: boolean }>(), optional: [auth] })
176
+
177
+ // impls
178
+ implement(api)
179
+ .middleware(org, async (io) =>
180
+ io.next({ org: { id: 'o1', owner: io.ctx.user.id } }), // io.ctx.user is typed and present (requires)
181
+ )
182
+ .middleware(cache, async (io) => {
183
+ const who: User | undefined = io.ctx.user // optional → possibly undefined (Partial)
184
+ return io.next({ cached: false })
185
+ })
186
+ ```
187
+
188
+ `requires` edges both **pull dependencies in** and force them earlier; `optional` edges only
189
+ **reorder** middleware already present. In an impl, `io.ctx` types `requires`' context as
190
+ present and `optional`'s as a `Partial`. Chains are resolved topologically (`resolveChain`)
191
+ at `spec()` / server time, and a dependency cycle throws.
192
+
193
+ ## `middleware.loader(paramKey, schema, opts?)`
194
+
195
+ A loader **owns a path param**: its def declares the `:key` + schema, the runtime parses the
196
+ matching segment, the impl receives the typed `value`, and the parsed param flows into the
197
+ handler's `data` (it's a path kind). The def takes the same `opts` shape (`provides` declares
198
+ its context, plus `requires`/`optional`/`doc`); there is **no** function argument — the
199
+ `middleware.loader(key, schema, fn)` form is removed (breaking). The impl, bound later, gets
200
+ `io.value` (the parsed param) on top of the usual `io`:
201
+
202
+ ```ts
203
+ // the loader impl's type — `LoaderImplFor<typeof project>` resolves to exactly this
204
+ type LoaderImpl<Req, Z, P> = (io: MiddlewareIO<Req> & { readonly value: z.output<Z> }) => Promise<MiddlewareResult<P> | Response>
205
+
206
+ // def (frontend-safe)
207
+ const project = middleware.loader('projectId', z.uuid(), {
208
+ provides: ctx<{ project: Project }>(),
209
+ requires: [auth],
210
+ })
211
+
212
+ // impl
213
+ implement(api).middleware(project, async (io) =>
214
+ io.next({ project: { id: io.value, ownerId: io.ctx.user.id } }), // io.value is the parsed :projectId
215
+ )
216
+ ```
217
+
218
+ The loader-owned key must be **positioned** in the path. A bare `.path('/projects/:projectId')`
219
+ string prefix gives it its position (see below). The schema must accept string input (it's a
220
+ path segment), exactly like `path`` template params.
221
+
222
+ ## The builders: `use(...)`, `.with()`, `.path()`, `.group()`, `.endpoint()`
223
+
224
+ Middleware and `Stack`s share a fluent builder. A `Middleware` is itself usable as a
225
+ single-middleware stack:
226
+
227
+ ```ts
228
+ interface Middleware<P, R, LP> {
229
+ with<M extends readonly AnyMiddleware[]>(...mws: M): Stack<...> // compose into a Stack
230
+ path<const T extends string | AnyPathTemplate>(p: T): Stack<...> // prepend a path prefix
231
+ endpoint<const C>(cfg: C): Endpoint<...> // one endpoint guarded by this
232
+ group<const G>(g: G): { [K in keyof G]: Endpoint<...> } // a named group, all guarded
233
+ }
234
+ interface Stack<Ms, PFX> {
235
+ with(...mws): Stack<...>
236
+ path(p): Stack<...>
237
+ endpoint(cfg): Endpoint<...>
238
+ group(g): { [K in keyof G]: Endpoint<...> }
239
+ }
240
+ ```
241
+
242
+ ### `use(...mws)` — the free-function composition helper
243
+
244
+ `use(...mws)` is a free function that bundles one or more middleware **defs** into a `Stack`,
245
+ then you chain `.path()` / `.group()` / `.endpoint()` as usual. It is the **function form** of
246
+ the `.with()` builder: `use(auth, tel)` is exactly `auth.with(tel)` — same ordering, same
247
+ `requires`/`optional` resolution, same merged context, same returned `Stack`.
248
+
249
+ ```ts
250
+ import { use } from '@ayepi/core'
251
+
252
+ use<M extends readonly [AnyMiddleware, ...AnyMiddleware[]]>(...mws: M): Stack<M, EmptyObject>
253
+ ```
254
+
255
+ It requires **at least one** middleware. Because it reads more naturally when bundling several
256
+ middleware at a group, `use(...)` is the **preferred** form in examples. `.with()` still exists
257
+ and is unchanged — it is the equivalent method-chain form.
258
+
259
+ ```ts
260
+ // preferred — bundle several middleware at a group
261
+ ...use(auth, log, cache).group({
262
+ getUser: { params: z.object({ id: z.string() }), response: UserOut },
263
+ updateUser: { method: 'PATCH', path: '/users/:id', params: z.object({ id: z.string() }), body: …, response: UserOut },
264
+ })
265
+
266
+ // works with a single middleware too
267
+ ...use(auth).endpoint({ response: UserOut })
268
+
269
+ // string prefix positions the loader-owned :projectId; final path /projects/:projectId/tasks
270
+ ...use(org, project).path('/projects/:projectId').group({
271
+ listTasks: { method: 'GET', path: '/tasks', response: z.array(z.object({ id: z.string() })) },
272
+ })
273
+ ```
274
+
275
+ - **`.with(...mws)`** — compose more middleware into the chain (the method-chain equivalent of
276
+ `use(...)`).
277
+ - **`.path(prefix)`** — prepend a path prefix to every endpoint defined under the stack. A
278
+ **string** prefix contributes positions only (e.g. positions a loader-owned key); a
279
+ **`` path`` `` template** prefix *declares + types* its params, which then merge into every
280
+ endpoint's `data`.
281
+ - **`.group({ name: cfg, ... })`** — produce a record of endpoints, all guarded by the chain.
282
+ Spread it into `spec({ endpoints: { ...stack.group({...}) } })`.
283
+ - **`.endpoint(cfg)`** — a single guarded endpoint.
284
+
285
+ ```ts
286
+ // group several endpoints under one auth chain (use(...) is the preferred form for bundling)
287
+ ...use(auth, log, cache).group({
288
+ getUser: { params: z.object({ id: z.string() }), response: UserOut },
289
+ updateUser: { method: 'PATCH', path: '/users/:id', params: z.object({ id: z.string() }), body: …, response: UserOut },
290
+ })
291
+
292
+ // string prefix positions the loader-owned :projectId; final path /projects/:projectId/tasks
293
+ ...use(org, project).path('/projects/:projectId').group({
294
+ listTasks: { method: 'GET', path: '/tasks', response: z.array(z.object({ id: z.string() })) },
295
+ })
296
+
297
+ // template prefix declares + positions :orgSlug; its type merges into every endpoint's data
298
+ ...auth.path(path`/orgs/${{ orgSlug: z.string() }}`).group({
299
+ orgInfo: { method: 'GET', path: '/info', response: z.object({ slug: z.string(), owner: z.string() }) },
300
+ })
301
+ ```
302
+
303
+ A stacked prefix must **not re-declare** a param key already owned by a loader or an earlier
304
+ prefix (compile error: "prefix re-declares param keys").
305
+
306
+ ## How the chain executes (server-side)
307
+
308
+ At **assembly** time, `server()` resolves each endpoint/event-guard chain and pairs every def
309
+ with the impl bound for it via `implement()`. **Any def left unbound throws** ("middleware
310
+ '<name>' has no impl") — there is no run-time fallback.
311
+
312
+ Then, for each request the server:
313
+
314
+ 1. Resolves the endpoint's middleware chain topologically (already paired with impls).
315
+ 2. Runs middleware in order. Each impl gets `io` with the request, the accumulated `ctx`, and
316
+ `next`. **Loaders parse their `:key` first** (a missing param → `400 BAD_REQUEST`) and
317
+ expose `io.value`.
318
+ 3. `io.next(add)` merges `add` into `ctx` and continues to the next link; the terminal step
319
+ parses the kinds, assembles the payload, and invokes the handler.
320
+ 4. The merged `ctx` spreads at the **root** of the handler payload (alongside `data`,
321
+ `req`, `signal`, `emit`, etc.). A ctx key that collides with a reserved payload name
322
+ (`data`, `stream`, `headers`, `cookies`, `out`, `download`, `length`, `fail`, `status`,
323
+ `header`, `cookie`, `req`, `signal`, `emit`) throws.
324
+ 5. A middleware that returns **without** calling `next()` (and without returning a `Response`)
325
+ throws "returned without calling next()".
326
+
327
+ The chain executes the same way for both transports. Over ws, `io.req` is the connection's
328
+ **upgrade** `Request` (shared by every frame on the socket) — for per-call identity use
329
+ `io.route` (method/path/name) and `io.ws.id` (the frame id), not `io.req`.
330
+
331
+ ## Auth pattern
332
+
333
+ ```ts
334
+ // shared.ts (frontend-safe) — def carries the context type + the security-scheme doc
335
+ const auth = middleware('auth', {
336
+ provides: ctx<{ user: User }>(),
337
+ doc: { security: { bearerAuth: { type: 'http', scheme: 'bearer' } } },
338
+ })
339
+
340
+ // server.ts — impl carries the secret and the user lookup
341
+ implement(api).middleware(auth, async (io) => {
342
+ const token = io.req.headers.get('authorization')
343
+ if (token !== 'Bearer secret') throw reject(401, 'UNAUTHORIZED')
344
+ return io.next({ user: await loadUser(token) })
345
+ })
346
+
347
+ // every endpoint under auth.group({...}) gets a typed `user` at the payload root,
348
+ // and contributes the bearerAuth security scheme to the OpenAPI docs
349
+ ```
350
+
351
+ ## Declaring errors from middleware
352
+
353
+ Middleware fail a request by **throwing** an `ApiError` via `reject(status, code, message?)`.
354
+ This produces the standard error envelope (`{ error: { code, message? } }`) over HTTP and a
355
+ `{ id, $status, $error, $code }` error frame over ws — the client throws an `ApiError` on the
356
+ non-2xx `$status` either way (see `ayepi-core-client.md`):
357
+
358
+ ```ts
359
+ import { reject } from '@ayepi/core'
360
+ // in the impl (bound via implement(api).middleware(auth, …))
361
+ async (io) => {
362
+ if (!io.req.headers.get('authorization')) throw reject(401, 'UNAUTHORIZED')
363
+ return io.next({ user })
364
+ }
365
+ ```
366
+
367
+ `reject` constructs (does not throw) the `ApiError` — you `throw reject(...)`. For
368
+ **declared, schema-typed** errors with a structured body, use the endpoint's `errors` config
369
+ + the handler's `fail()` instead (see `ayepi-core-endpoints.md`); `fail()` is gated to
370
+ handlers, not middleware.
371
+
372
+ ## Security-scheme docs (`MiddlewareDoc`)
373
+
374
+ ```ts
375
+ interface MiddlewareDoc {
376
+ readonly security?: Readonly<Record<string, Json>> // merged into components.securitySchemes + required on each op
377
+ readonly openapi?: (op: Record<string, Json>) => Record<string, Json> // patch every op whose chain includes this mw
378
+ }
379
+ ```
380
+
381
+ A middleware's `doc.security` is merged into `components.securitySchemes` and applied to
382
+ every operation whose chain includes that middleware. `doc.openapi` patches each such
383
+ operation object.
384
+
385
+ ## Short-circuit: blocking vs non-blocking
386
+
387
+ A middleware **blocks** the handler by returning a `Response` instead of calling `io.next()`
388
+ — the rest of the chain and the handler are skipped. Over HTTP the `Response` is sent as-is;
389
+ over ws it maps to the call frame's `$status` (a 2xx JSON `Response` → success frame, otherwise
390
+ an error frame the client throws on). This powers cache hits, redirects, auth denials, and
391
+ rate limiting:
392
+
393
+ ```ts
394
+ const cache = middleware('cache') // def (provides nothing)
395
+
396
+ implement(api).middleware(cache, async (io) => {
397
+ const hit = await cacheGet(io.req)
398
+ if (hit) return Response.json(hit) // skip the handler entirely — short-circuit
399
+ return io.next() // proceed
400
+ })
401
+ ```
402
+
403
+ A middleware that calls `io.next()` is **non-blocking** (the common case): it runs, lets the
404
+ chain continue, and may post-process the result it gets back from `next()` (like the `log`
405
+ example timing the request).
406
+
407
+ ## Guarding event subscriptions
408
+
409
+ Events accept a `guard: [auth, ...]` chain that must pass before a client may **subscribe**
410
+ to that channel (see `ayepi-core-endpoints.md`):
411
+
412
+ ```ts
413
+ events: {
414
+ roomMessage: { params: z.object({ roomId: z.string() }), data: z.object({ from: z.string(), text: z.string() }), guard: [auth] },
415
+ }
416
+ ```
417
+
418
+ ## Exported middleware symbols
419
+
420
+ Values:
421
+
422
+ - `middleware` — the def factory, with `.loader`.
423
+ - `ctx` — the `ctx<P>()` context-type helper passed as `provides` (new).
424
+ - `use` — the free-function composition helper `use(...mws)`; the function form of
425
+ `Middleware.with(...)` / `Stack.with(...)`. Returns a `Stack` to chain `.path()` /
426
+ `.group()` / `.endpoint()`. Preferred for bundling multiple middleware (new).
427
+ - `implement` — the chainable builder that binds defs to impls (`.middleware`/`.handlers`/
428
+ `.handle`) and feeds `server()`.
429
+ - `provide` — `provide(name, value | (io) => value)`: the one-call middleware that injects a
430
+ typed value onto `io.ctx[name]` (a def+impl in one). Use it in the spec (`use(svc).group(…)`)
431
+ and bind it once (`implement(api).middleware(svc)`) — one reference does both (new).
432
+
433
+ Types:
434
+
435
+ - `Middleware`, `Stack`, `StackCtx`, `StackLP`, `MiddlewareFactory`, `MiddlewareDoc`,
436
+ `MiddlewareIO`, `MiddlewareResult`, `AnyMiddleware`.
437
+ - `MiddlewareDef<P, R>` — the type a plain (non-loader) def has; give a def **factory** this as an
438
+ explicit return type so its inferred type stays portable across packages (avoids TS2742). (new)
439
+ - `Provide<P>` — the return type of `ctx<P>()`; what `provides` expects (new).
440
+ - `MiddlewareImplFor<M>`, `LoaderImplFor<M>`, `ImplFor<M>` — the exact impl type for a def `M`
441
+ (`ImplFor` resolves to whichever of the two applies), for declaring impls out-of-line (new).
442
+ - `BoundMiddleware<M>` — a `{ def, impl }` pair, accepted by `.middleware(bound)` (new).
443
+ - the `io`-context types `Transport`, `RouteInfo`, `WsFrameInfo`.
444
+
445
+ The old `MiddlewareFn` / `LoaderFn` aliases are replaced by `MiddlewareImplFor<M>` /
446
+ `LoaderImplFor<M>`, which derive the impl signature directly from a def.