@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.
- package/README.md +1 -1
- package/ayepi-core-client.md +441 -0
- package/ayepi-core-endpoints.md +363 -0
- package/ayepi-core-middleware.md +446 -0
- package/ayepi-core-types.md +253 -0
- package/ayepi-core.md +235 -0
- package/package.json +3 -2
|
@@ -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.
|