@ayepi/core 0.1.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/LICENSE +21 -0
- package/README.md +89 -0
- package/dist/client/index.cjs +5 -0
- package/dist/client/index.d.cts +2 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/doer.cjs +110 -0
- package/dist/doer.d.cts +75 -0
- package/dist/doer.d.ts +75 -0
- package/dist/doer.js +106 -0
- package/dist/errors.d.cts +1729 -0
- package/dist/errors.d.ts +1729 -0
- package/dist/index.cjs +2004 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1968 -0
- package/dist/retry.cjs +135 -0
- package/dist/retry.d.cts +90 -0
- package/dist/retry.d.ts +90 -0
- package/dist/retry.js +129 -0
- package/dist/stats.cjs +0 -0
- package/dist/stats.d.cts +150 -0
- package/dist/stats.d.ts +150 -0
- package/dist/stats.js +0 -0
- package/dist/types.d.cts +54 -0
- package/dist/types.d.ts +54 -0
- package/dist/ws-transport.cjs +1472 -0
- package/dist/ws-transport.js +1383 -0
- package/package.json +110 -0
|
@@ -0,0 +1,1729 @@
|
|
|
1
|
+
import { a as Simplify, i as MaybePromise, n as Get, o as UnionToIntersection, r as Json, t as EmptyObject } from "./types.cjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
//#region src/manifest.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* # Manifest
|
|
8
|
+
*
|
|
9
|
+
* The **zod-free runtime configuration** the client needs. It carries exactly
|
|
10
|
+
* enough structure (per-endpoint key tables, method, path, streaming flags) for
|
|
11
|
+
* the client to split a single `data` payload back into path/query/body/files
|
|
12
|
+
* and pick a transport — with no zod schemas, so the frontend bundle stays
|
|
13
|
+
* schema-free. Obtain it from `app.manifest()` or {@link manifestFromSpec}.
|
|
14
|
+
*
|
|
15
|
+
* Every field here is part of the **frozen v0 wire contract**.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
/** The HTTP methods ayepi endpoints may use. */
|
|
20
|
+
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
21
|
+
/**
|
|
22
|
+
* Runtime description of a single endpoint — everything the client must know to
|
|
23
|
+
* build a request and interpret a response without access to the zod schemas.
|
|
24
|
+
*/
|
|
25
|
+
interface ManifestEndpoint {
|
|
26
|
+
/** HTTP method (default `POST`). */
|
|
27
|
+
readonly method: HttpMethod;
|
|
28
|
+
/** Path pattern with `:key` segments, e.g. `/users/:id`. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
/** Explicit WebSocket id, or `null` to address the endpoint by `method + path`. */
|
|
31
|
+
readonly ws: string | null;
|
|
32
|
+
/** When `true`, the endpoint cannot be called over ws (raw streams / files). */
|
|
33
|
+
readonly httpOnly: boolean;
|
|
34
|
+
/** Content-type of the streamed request body (raw, or NDJSON for item streams); `null` if none. */
|
|
35
|
+
readonly streamIn: string | null;
|
|
36
|
+
/** `true` when `streamIn` is a typed NDJSON item stream (vs a raw byte stream). */
|
|
37
|
+
readonly itemsIn: boolean;
|
|
38
|
+
/** Content-type of the streamed response (raw, or NDJSON/SSE for item streams); `null` if none. */
|
|
39
|
+
readonly streamOut: string | null;
|
|
40
|
+
/** `true` when `streamOut` is a typed item stream (vs a raw byte stream). */
|
|
41
|
+
readonly items: boolean;
|
|
42
|
+
/** Path-param keys, in path order. */
|
|
43
|
+
readonly p: readonly string[];
|
|
44
|
+
/** Query-param keys. */
|
|
45
|
+
readonly q: readonly string[];
|
|
46
|
+
/** Body keys, `'raw'` when the body is the entire data payload, or `null` when there is no body. */
|
|
47
|
+
readonly b: readonly string[] | 'raw' | null;
|
|
48
|
+
/** Multipart file-field keys. */
|
|
49
|
+
readonly f: readonly string[];
|
|
50
|
+
/** Whether the endpoint declares a body at all. */
|
|
51
|
+
readonly hasBody: boolean;
|
|
52
|
+
/** Whether the endpoint declares typed request headers. */
|
|
53
|
+
readonly hasHeaders: boolean;
|
|
54
|
+
/** When `true`, `call()` resolves a `{ status, data }` discriminated union. */
|
|
55
|
+
readonly multi: boolean;
|
|
56
|
+
/** Body wire encoding, or `null` when there is no body. */
|
|
57
|
+
readonly bodyEnc: 'json' | 'urlencoded' | null;
|
|
58
|
+
}
|
|
59
|
+
/** Runtime description of a single server-pushed event channel. */
|
|
60
|
+
interface ManifestEvent {
|
|
61
|
+
/** WebSocket channel id. */
|
|
62
|
+
readonly ws: string;
|
|
63
|
+
/** Whether the channel is parameterized (subscriptions are keyed by params). */
|
|
64
|
+
readonly hasParams: boolean;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The complete zod-free runtime manifest consumed by {@link client} — obtained
|
|
68
|
+
* from `app.manifest()` or {@link manifestFromSpec}. Hand a client this manifest
|
|
69
|
+
* (instead of the spec) to talk to a server without shipping its schema code.
|
|
70
|
+
*/
|
|
71
|
+
interface Manifest {
|
|
72
|
+
readonly endpoints: Readonly<Record<string, ManifestEndpoint>>;
|
|
73
|
+
readonly events: Readonly<Record<string, ManifestEvent>>;
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/path.d.ts
|
|
77
|
+
/**
|
|
78
|
+
* A single path segment: either a string literal or a named parameter.
|
|
79
|
+
*
|
|
80
|
+
* The whole path-matching machinery operates on arrays of these, which is why
|
|
81
|
+
* no part of it needs regex or `String.replace` over user input.
|
|
82
|
+
*/
|
|
83
|
+
type PathPart = {
|
|
84
|
+
readonly t: 'lit';
|
|
85
|
+
readonly v: string;
|
|
86
|
+
} | {
|
|
87
|
+
readonly t: 'param';
|
|
88
|
+
readonly k: string;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The erased (non-generic) shape of a {@link PathTemplate}. Used wherever a
|
|
92
|
+
* template is accepted without caring about its specific param types.
|
|
93
|
+
*/
|
|
94
|
+
interface AnyPathTemplate {
|
|
95
|
+
readonly kind: 'path';
|
|
96
|
+
/** Segment array — never matched or built via string replacement. */
|
|
97
|
+
readonly parts: readonly PathPart[];
|
|
98
|
+
/** Display/wire form, derived from {@link parts} (e.g. `/users/:id`). */
|
|
99
|
+
readonly pattern: string;
|
|
100
|
+
/** Declared param keys, in path order. */
|
|
101
|
+
readonly keys: readonly string[];
|
|
102
|
+
/** Per-key zod schemas; each must accept string input. */
|
|
103
|
+
readonly schemas: Readonly<Record<string, z.ZodType>>;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A typed path template produced by the {@link path} tag. Carries its pattern
|
|
107
|
+
* and per-segment schemas, and can build a URL path from typed params or parse
|
|
108
|
+
* one back into typed params.
|
|
109
|
+
*
|
|
110
|
+
* @typeParam PS - a `{ key: ZodType }` record describing each param segment.
|
|
111
|
+
*/
|
|
112
|
+
interface PathTemplate<PS extends object> extends AnyPathTemplate {
|
|
113
|
+
/** @internal phantom carrier for the param-schema record */
|
|
114
|
+
readonly __ps: PS;
|
|
115
|
+
/**
|
|
116
|
+
* Build a concrete path from typed params; each value is validated against its
|
|
117
|
+
* segment schema and encoded per-segment.
|
|
118
|
+
*/
|
|
119
|
+
build(params: { -readonly [K in keyof PS]: PS[K] extends z.ZodType ? z.input<PS[K]> : never }): string;
|
|
120
|
+
/**
|
|
121
|
+
* Parse a concrete path back into typed, coerced params, or `null` when the
|
|
122
|
+
* path does not match this template.
|
|
123
|
+
*/
|
|
124
|
+
parse(input: string): { -readonly [K in keyof PS]: PS[K] extends z.ZodType ? z.output<PS[K]> : never } | null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Split a spec-author `:key` pattern string into {@link PathPart}s.
|
|
128
|
+
*
|
|
129
|
+
* The input is author-controlled (not user input), so a plain `split('/')` is
|
|
130
|
+
* appropriate here. The leading empty segment from a leading `/` is dropped.
|
|
131
|
+
*/
|
|
132
|
+
declare function splitPattern(pattern: string): PathPart[];
|
|
133
|
+
/** Render {@link PathPart}s back into a `:key` pattern string (the inverse of {@link splitPattern}). */
|
|
134
|
+
declare function joinPattern(parts: readonly PathPart[]): string;
|
|
135
|
+
/**
|
|
136
|
+
* Match a concrete pathname against {@link PathPart}s.
|
|
137
|
+
*
|
|
138
|
+
* Walks segment by segment: literals must equal, params must be non-empty and
|
|
139
|
+
* are individually `decodeURIComponent`-decoded. Returns the raw (decoded but
|
|
140
|
+
* un-coerced) param map, or `null` on any mismatch (literal differs, length
|
|
141
|
+
* differs, or an empty param segment).
|
|
142
|
+
*/
|
|
143
|
+
declare function matchParts(parts: readonly PathPart[], pathname: string): Record<string, string> | null;
|
|
144
|
+
/**
|
|
145
|
+
* Build a concrete pathname from {@link PathPart}s and a value map. Each param
|
|
146
|
+
* value is stringified and `encodeURIComponent`-encoded per-segment.
|
|
147
|
+
*
|
|
148
|
+
* @throws if a declared param has no value.
|
|
149
|
+
*/
|
|
150
|
+
declare function buildParts(parts: readonly PathPart[], values: Record<string, unknown>): string;
|
|
151
|
+
/** Extract the param keys (in order) from {@link PathPart}s. */
|
|
152
|
+
|
|
153
|
+
/** One interpolation of the {@link path} tag: a single `{ name: schema }` record. */
|
|
154
|
+
type PathSeg = Readonly<Record<string, z.ZodType>>;
|
|
155
|
+
/** Merge every interpolation's `{ key: schema }` record into one param-schema record. */
|
|
156
|
+
type MergeSegs<P extends readonly PathSeg[]> = [P[number]] extends [never] ? EmptyObject : Simplify<UnionToIntersection<P[number]>>;
|
|
157
|
+
/**
|
|
158
|
+
* Compile-time guard: every interpolated schema must accept **string** input
|
|
159
|
+
* (path segments arrive as strings). `z.number()` is rejected; `z.coerce.number()`
|
|
160
|
+
* is accepted because its input type widens to include strings.
|
|
161
|
+
*/
|
|
162
|
+
type CheckTplParts<P extends readonly PathSeg[]> = { [I in keyof P]: { [K in keyof P[I]]: string extends z.input<P[I][K] & z.ZodType> ? P[I][K] : readonly ['path param schema must accept string input:', K] } };
|
|
163
|
+
/**
|
|
164
|
+
* Tagged template for typed paths. Each interpolation is a single
|
|
165
|
+
* `{ name: schema }` object; the schema both **declares** and **types** that
|
|
166
|
+
* param and must accept string input.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* const userPost = path`/users/${{ id: z.uuid() }}/posts/${{ slug: z.string() }}`
|
|
171
|
+
* userPost.pattern // '/users/:id/posts/:slug'
|
|
172
|
+
* userPost.build({ id, slug }) // '/users/3f…/posts/intro'
|
|
173
|
+
* userPost.parse('/users/3f…/posts/intro') // { id, slug } | null
|
|
174
|
+
*
|
|
175
|
+
* path`/x/${{ n: z.number() }}` // ❌ compile error: schema must accept string input
|
|
176
|
+
* path`/x/${{ n: z.coerce.number() }}` // ✅ ok: coerces from string
|
|
177
|
+
* ```
|
|
178
|
+
*
|
|
179
|
+
* @throws at definition time if a param does not occupy a whole segment, an
|
|
180
|
+
* interpolation is not a single-key object, or a key is declared twice.
|
|
181
|
+
*/
|
|
182
|
+
declare function path<const P extends readonly PathSeg[]>(strings: TemplateStringsArray, ...interpolations: P & CheckTplParts<P>): PathTemplate<MergeSegs<P>>;
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/broker.d.ts
|
|
185
|
+
/**
|
|
186
|
+
* # Broker
|
|
187
|
+
*
|
|
188
|
+
* Cross-instance event fanout. Every {@link Server.emit} publishes to the broker;
|
|
189
|
+
* every server instance subscribes and delivers to its own local WebSocket
|
|
190
|
+
* connections — so an `emit` on one pod reaches subscribers on **all** pods.
|
|
191
|
+
*
|
|
192
|
+
* The message is an opaque string on purpose: the same interface carries any
|
|
193
|
+
* cross-server transport (Redis pub/sub, NATS, Postgres `LISTEN/NOTIFY`, …).
|
|
194
|
+
*
|
|
195
|
+
* @module
|
|
196
|
+
*/
|
|
197
|
+
/**
|
|
198
|
+
* Pluggable message bus for event fanout across server instances.
|
|
199
|
+
*
|
|
200
|
+
* A conforming implementation needs only two operations: publish an opaque
|
|
201
|
+
* string, and subscribe to receive every published string. Ordering and
|
|
202
|
+
* at-least-once vs at-most-once semantics follow the underlying transport;
|
|
203
|
+
* ayepi treats delivery as best-effort.
|
|
204
|
+
*
|
|
205
|
+
* @example A Redis implementation is ~10 lines:
|
|
206
|
+
* ```ts
|
|
207
|
+
* const redisBroker = (pub: Redis, sub: Redis): Broker => ({
|
|
208
|
+
* publish: (m) => void pub.publish('ayepi', m),
|
|
209
|
+
* subscribe: (l) => {
|
|
210
|
+
* void sub.subscribe('ayepi')
|
|
211
|
+
* sub.on('message', (_ch, m) => l(m))
|
|
212
|
+
* return () => void sub.unsubscribe('ayepi')
|
|
213
|
+
* },
|
|
214
|
+
* })
|
|
215
|
+
* ```
|
|
216
|
+
*
|
|
217
|
+
* @example A Postgres `LISTEN/NOTIFY` implementation:
|
|
218
|
+
* ```ts
|
|
219
|
+
* const pgBroker = (client: Client): Broker => ({
|
|
220
|
+
* publish: (m) => void client.query('SELECT pg_notify($1, $2)', ['ayepi', m]),
|
|
221
|
+
* subscribe: (l) => {
|
|
222
|
+
* void client.query('LISTEN ayepi')
|
|
223
|
+
* const h = (msg: { payload?: string }) => l(msg.payload ?? '')
|
|
224
|
+
* client.on('notification', h)
|
|
225
|
+
* return () => client.removeListener('notification', h)
|
|
226
|
+
* },
|
|
227
|
+
* })
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
interface Broker {
|
|
231
|
+
/** Publish an opaque message to every subscriber across all instances. */
|
|
232
|
+
publish(message: string): void | Promise<void>;
|
|
233
|
+
/**
|
|
234
|
+
* Register a listener for published messages.
|
|
235
|
+
* @returns an unsubscribe function that detaches the listener.
|
|
236
|
+
*/
|
|
237
|
+
subscribe(listener: (message: string) => void): () => void;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* In-process {@link Broker} — the default when no broker is supplied.
|
|
241
|
+
*
|
|
242
|
+
* Share a single instance between multiple {@link Server}s to simulate a
|
|
243
|
+
* multi-pod deployment in tests: an `emit` on one server is heard by
|
|
244
|
+
* subscribers on the other.
|
|
245
|
+
*
|
|
246
|
+
* @example
|
|
247
|
+
* ```ts
|
|
248
|
+
* const broker = localBroker()
|
|
249
|
+
* const a = server(api, handlers, { broker })
|
|
250
|
+
* const b = server(api, handlers, { broker })
|
|
251
|
+
* a.emit('systemNotice', { msg: 'hi' }) // delivered to b's subscribers too
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
declare function localBroker(): Broker;
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/payload.d.ts
|
|
257
|
+
/** Input vs output side of a schema (request parsing vs response/handler view). */
|
|
258
|
+
type IOMode = 'in' | 'out';
|
|
259
|
+
/** Pick `z.input` or `z.output` of a schema by {@link IOMode}. */
|
|
260
|
+
type ZIO<Z extends z.ZodType, M extends IOMode> = M extends 'in' ? z.input<Z> : z.output<Z>;
|
|
261
|
+
type CfgOf<E extends AnyEndpoint> = E['cfg'];
|
|
262
|
+
type CtxOf<E extends AnyEndpoint> = E['__ctx'];
|
|
263
|
+
type LPOf<E extends AnyEndpoint> = E['__lp'];
|
|
264
|
+
type LPShape<E extends AnyEndpoint, M extends IOMode> = { [K in keyof LPOf<E>]: LPOf<E>[K] extends z.ZodType ? ZIO<LPOf<E>[K], M> : never };
|
|
265
|
+
/** Params contributed by a `path` template on `cfg.path`. */
|
|
266
|
+
type TplOf<E extends AnyEndpoint> = Get<CfgOf<E>, 'path'> extends {
|
|
267
|
+
readonly __ps: infer PS extends object;
|
|
268
|
+
} ? PS : EmptyObject;
|
|
269
|
+
/** Params contributed by stacked path prefixes. */
|
|
270
|
+
type PfxOf<E extends AnyEndpoint> = E extends {
|
|
271
|
+
readonly __pfx: infer PFX extends object;
|
|
272
|
+
} ? PFX : EmptyObject;
|
|
273
|
+
type PfxShape<E extends AnyEndpoint, M extends IOMode> = { -readonly [K in keyof PfxOf<E>]: PfxOf<E>[K] extends z.ZodType ? ZIO<PfxOf<E>[K], M> : never };
|
|
274
|
+
type TplShape<E extends AnyEndpoint, M extends IOMode> = { -readonly [K in keyof TplOf<E>]: TplOf<E>[K] extends z.ZodType ? ZIO<TplOf<E>[K], M> : never };
|
|
275
|
+
type PShape<E extends AnyEndpoint, M extends IOMode> = Simplify<(Get<CfgOf<E>, 'params'> extends z.ZodType ? ZIO<Get<CfgOf<E>, 'params'> & z.ZodType, M> : EmptyObject) & LPShape<E, M> & TplShape<E, M> & PfxShape<E, M>>;
|
|
276
|
+
type QShape<E extends AnyEndpoint, M extends IOMode> = Get<CfgOf<E>, 'query'> extends z.ZodType ? ZIO<Get<CfgOf<E>, 'query'> & z.ZodType, M> : EmptyObject;
|
|
277
|
+
/** File fields map to data keys; a file schema whose input accepts `undefined` (e.g. `z.file().optional()`) becomes an optional key. */
|
|
278
|
+
type FShape<E extends AnyEndpoint, M extends IOMode> = Get<CfgOf<E>, 'files'> extends infer F extends Readonly<Record<string, z.ZodType>> ? Simplify<{ -readonly [K in keyof F as undefined extends z.input<F[K]> ? never : K]: ZIO<F[K], M> } & { -readonly [K in keyof F as undefined extends z.input<F[K]> ? K : never]?: ZIO<F[K], M> }> : EmptyObject;
|
|
279
|
+
type HShape<E extends AnyEndpoint, M extends IOMode> = Get<CfgOf<E>, 'headers'> extends z.ZodType ? ZIO<Get<CfgOf<E>, 'headers'> & z.ZodType, M> : EmptyObject;
|
|
280
|
+
type CShape<E extends AnyEndpoint, M extends IOMode> = Get<CfgOf<E>, 'cookies'> extends z.ZodType ? ZIO<Get<CfgOf<E>, 'cookies'> & z.ZodType, M> : EmptyObject;
|
|
281
|
+
type HasHeaders<E extends AnyEndpoint> = Get<CfgOf<E>, 'headers'> extends z.ZodType ? true : false;
|
|
282
|
+
type HasCookies<E extends AnyEndpoint> = Get<CfgOf<E>, 'cookies'> extends z.ZodType ? true : false;
|
|
283
|
+
type RespMap<E extends AnyEndpoint> = Get<CfgOf<E>, 'responses'> extends Readonly<Record<number, z.ZodType>> ? Get<CfgOf<E>, 'responses'> : EmptyObject;
|
|
284
|
+
type HasMulti<E extends AnyEndpoint> = [keyof RespMap<E>] extends [never] ? false : true;
|
|
285
|
+
type ErrorsOf<E extends AnyEndpoint> = Get<CfgOf<E>, 'errors'> extends Readonly<Record<number, z.ZodType>> ? Get<CfgOf<E>, 'errors'> : EmptyObject;
|
|
286
|
+
type HasErrors<E extends AnyEndpoint> = [keyof ErrorsOf<E>] extends [never] ? false : true;
|
|
287
|
+
/**
|
|
288
|
+
* The handler's `fail()` — throw a declared, schema-validated error response.
|
|
289
|
+
* Only declared statuses are accepted, and the data must match that status's
|
|
290
|
+
* schema.
|
|
291
|
+
*
|
|
292
|
+
* @typeParam Errors - the endpoint's `errors` record.
|
|
293
|
+
*/
|
|
294
|
+
type FailFn<Errors extends object> = <S extends keyof Errors & number>(status: S, data: Errors[S] extends z.ZodType ? z.input<Errors[S]> : never) => never;
|
|
295
|
+
type HasBody<E extends AnyEndpoint> = Get<CfgOf<E>, 'body'> extends z.ZodType ? true : false;
|
|
296
|
+
type HasFiles<E extends AnyEndpoint> = Get<CfgOf<E>, 'files'> extends Readonly<Record<string, z.ZodType>> ? true : false;
|
|
297
|
+
type HasRawStreamIn<E extends AnyEndpoint> = Get<CfgOf<E>, 'streamIn'> extends string ? true : false;
|
|
298
|
+
type HasItemStreamIn<E extends AnyEndpoint> = Get<CfgOf<E>, 'streamIn'> extends z.ZodType ? true : false;
|
|
299
|
+
type InItemSchema<E extends AnyEndpoint> = Get<CfgOf<E>, 'streamIn'> extends z.ZodType ? Get<CfgOf<E>, 'streamIn'> & z.ZodType : never;
|
|
300
|
+
type HasStreamIn<E extends AnyEndpoint> = Get<CfgOf<E>, 'streamIn'> extends string | z.ZodType ? true : false;
|
|
301
|
+
type HasRawStreamOut<E extends AnyEndpoint> = Get<CfgOf<E>, 'streamOut'> extends string ? true : false;
|
|
302
|
+
type HasItemStream<E extends AnyEndpoint> = Get<CfgOf<E>, 'streamOut'> extends z.ZodType ? true : false;
|
|
303
|
+
type ItemSchema<E extends AnyEndpoint> = Get<CfgOf<E>, 'streamOut'> extends z.ZodType ? Get<CfgOf<E>, 'streamOut'> & z.ZodType : never;
|
|
304
|
+
type BRaw<E extends AnyEndpoint, M extends IOMode> = Get<CfgOf<E>, 'body'> extends z.ZodType ? ZIO<Get<CfgOf<E>, 'body'> & z.ZodType, M> : never;
|
|
305
|
+
/** A body merges into the flat object only when it is a plain string-keyed record. */
|
|
306
|
+
type BMergeable<E extends AnyEndpoint, M extends IOMode> = HasBody<E> extends true ? ([BRaw<E, M>] extends [Record<string, unknown>] ? true : false) : false;
|
|
307
|
+
type BFlat<E extends AnyEndpoint, M extends IOMode> = BMergeable<E, M> extends true ? BRaw<E, M> : EmptyObject;
|
|
308
|
+
type NonMergeableBody<E extends AnyEndpoint> = HasBody<E> extends true ? (BMergeable<E, 'in'> extends true ? false : true) : false;
|
|
309
|
+
type ClientFlat<E extends AnyEndpoint> = Simplify<PShape<E, 'in'> & QShape<E, 'in'> & BFlat<E, 'in'> & FShape<E, 'in'>>;
|
|
310
|
+
/**
|
|
311
|
+
* The single `data` argument the client passes to `call()` — the merged
|
|
312
|
+
* path/query/body/files object, or the raw value when the body is a non-object
|
|
313
|
+
* (then it *is* the data).
|
|
314
|
+
*/
|
|
315
|
+
type ClientData<E extends AnyEndpoint> = NonMergeableBody<E> extends true ? BRaw<E, 'in'> : ClientFlat<E>;
|
|
316
|
+
/** Accepted shapes for a raw streaming request body. */
|
|
317
|
+
type StreamBody = ReadableStream<Uint8Array> | Blob | ArrayBuffer | string;
|
|
318
|
+
/**
|
|
319
|
+
* Whether an endpoint is HTTP-only (cannot use the `'ws'` transport): raw byte
|
|
320
|
+
* streams and file uploads are HTTP-only; typed item streams travel over ws too.
|
|
321
|
+
*/
|
|
322
|
+
type IsHttpOnly<E extends AnyEndpoint> = Get<CfgOf<E>, 'httpOnly'> extends true ? true : HasFiles<E> extends true ? true : HasRawStreamIn<E> extends true ? true : HasRawStreamOut<E> extends true ? true : false;
|
|
323
|
+
/** Upload progress for a request body, reported to {@link CallOptsBase.onUploadProgress}. */
|
|
324
|
+
interface UploadProgress {
|
|
325
|
+
/** Bytes uploaded so far. */
|
|
326
|
+
readonly loaded: number;
|
|
327
|
+
/** Total bytes to upload. */
|
|
328
|
+
readonly total: number;
|
|
329
|
+
}
|
|
330
|
+
/** Options common to every `call()`. */
|
|
331
|
+
interface CallOptsBase {
|
|
332
|
+
/** Abort signal — cancels the in-flight request (and, over ws, the call). */
|
|
333
|
+
readonly signal?: AbortSignal;
|
|
334
|
+
/** Extra request headers (also used to deliver typed request headers/cookies). */
|
|
335
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
336
|
+
/**
|
|
337
|
+
* Report request-upload progress (e.g. a file/multipart or body POST). When set, that request is
|
|
338
|
+
* sent via `XMLHttpRequest` (the only transport with upload-progress events) instead of `fetch`, so
|
|
339
|
+
* it bypasses a custom `fetchImpl`. Ignored for streaming endpoints, and a no-op where `XMLHttpRequest`
|
|
340
|
+
* is unavailable (e.g. server-side) — the call still completes via `fetch`. Browser-oriented.
|
|
341
|
+
*/
|
|
342
|
+
readonly onUploadProgress?: (progress: UploadProgress) => void;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The per-call options object. Adds a `transport` choice (narrowed to `'http'`
|
|
346
|
+
* for HTTP-only endpoints) and a **required** `stream` for streaming-input
|
|
347
|
+
* endpoints.
|
|
348
|
+
*/
|
|
349
|
+
type CallOpts<E extends AnyEndpoint> = CallOptsBase & (IsHttpOnly<E> extends true ? {
|
|
350
|
+
readonly transport?: 'http';
|
|
351
|
+
} : {
|
|
352
|
+
readonly transport?: 'http' | 'ws';
|
|
353
|
+
}) & (HasRawStreamIn<E> extends true ? {
|
|
354
|
+
readonly stream: StreamBody;
|
|
355
|
+
} : HasItemStreamIn<E> extends true ? {
|
|
356
|
+
readonly stream: AsyncIterable<z.input<InItemSchema<E>>> | (() => AsyncIterable<z.input<InItemSchema<E>>>);
|
|
357
|
+
} : EmptyObject);
|
|
358
|
+
/** What `call()` resolves to: an async iterable for item streams, a `ReadableStream` for raw streams, a `{ status, data }` union for multi-status, the response, or `void`. */
|
|
359
|
+
type CallReturn<E extends AnyEndpoint> = HasItemStream<E> extends true ? AsyncIterable<z.output<ItemSchema<E>>> : HasRawStreamOut<E> extends true ? Promise<ReadableStream<Uint8Array>> : HasMulti<E> extends true ? Promise<{ [St in keyof RespMap<E> & number]: {
|
|
360
|
+
status: St;
|
|
361
|
+
data: z.output<RespMap<E>[St] & z.ZodType>;
|
|
362
|
+
} }[keyof RespMap<E> & number]> : Get<CfgOf<E>, 'response'> extends z.ZodType ? Promise<z.output<Get<CfgOf<E>, 'response'> & z.ZodType>> : Promise<void>;
|
|
363
|
+
/**
|
|
364
|
+
* The positional arguments to `call(name, …)`, computed per endpoint: data-less
|
|
365
|
+
* endpoints take `opts?` first; streaming-input endpoints require `opts`; a
|
|
366
|
+
* non-object body is a required positional value; everything else takes the
|
|
367
|
+
* merged `data` (optional when every key is optional).
|
|
368
|
+
*/
|
|
369
|
+
type CallArgs<E extends AnyEndpoint> = HasStreamIn<E> extends true ? [keyof ClientData<E>] extends [never] ? [data: undefined, opts: CallOpts<E>] : EmptyObject extends ClientData<E> ? [data: ClientData<E> | undefined, opts: CallOpts<E>] : [data: ClientData<E>, opts: CallOpts<E>] : NonMergeableBody<E> extends true ? [data: ClientData<E>, opts?: CallOpts<E>] : [keyof ClientData<E>] extends [never] ? [opts?: CallOpts<E>] : EmptyObject extends ClientData<E> ? [data?: ClientData<E>, opts?: CallOpts<E>] : [data: ClientData<E>, opts?: CallOpts<E>];
|
|
370
|
+
type HandlerFlat<E extends AnyEndpoint> = Simplify<PShape<E, 'out'> & QShape<E, 'out'> & BFlat<E, 'out'> & FShape<E, 'out'>>;
|
|
371
|
+
type HandlerData<E extends AnyEndpoint> = NonMergeableBody<E> extends true ? BRaw<E, 'out'> : HandlerFlat<E>;
|
|
372
|
+
/** The positional arguments to `emit(name, …)`: `(params, data)` for parameterized events, `(data)` otherwise. */
|
|
373
|
+
type EmitArgs<Ev extends EventConfig> = Get<Ev, 'params'> extends z.ZodType ? [params: z.input<Get<Ev, 'params'> & z.ZodType>, data: z.input<Ev['data']>] : [data: z.input<Ev['data']>];
|
|
374
|
+
/** The typed `emit` function for a spec's events, available on the server and in handlers. */
|
|
375
|
+
type EmitFn<S extends AnySpec> = <K extends keyof EventsOf<S> & string>(name: K, ...args: EmitArgs<EventsOf<S>[K] & EventConfig>) => void;
|
|
376
|
+
/**
|
|
377
|
+
* The object a handler receives. The middleware context spreads at the root,
|
|
378
|
+
* alongside a single merged `data`, the declared kinds (`stream`, `headers`,
|
|
379
|
+
* `cookies`), and the framework toolkit (`req`, `signal`, `emit`, `status()`,
|
|
380
|
+
* `header()`, `cookie()`, and — gated by config — `out`/`download()`/`length()`/`fail()`).
|
|
381
|
+
*
|
|
382
|
+
* @typeParam S - the owning spec (for the typed `emit`).
|
|
383
|
+
* @typeParam E - the endpoint.
|
|
384
|
+
*/
|
|
385
|
+
type HandlerPayload<S extends AnySpec, E extends AnyEndpoint> = Simplify<CtxOf<E> & (NonMergeableBody<E> extends true ? {
|
|
386
|
+
readonly data: HandlerData<E>;
|
|
387
|
+
} : [keyof HandlerFlat<E>] extends [never] ? EmptyObject : {
|
|
388
|
+
readonly data: HandlerData<E>;
|
|
389
|
+
}) & (HasRawStreamIn<E> extends true ? {
|
|
390
|
+
readonly stream: ReadableStream<Uint8Array>;
|
|
391
|
+
} : EmptyObject) & (HasItemStreamIn<E> extends true ? {
|
|
392
|
+
readonly stream: AsyncIterable<z.output<InItemSchema<E>>>;
|
|
393
|
+
} : EmptyObject) & (HasRawStreamOut<E> extends true ? {
|
|
394
|
+
/** Pipe target — `await readable.pipeTo(out)` (or write via `getWriter()`); strings are utf-8 encoded. */
|
|
395
|
+
readonly out: WritableStream<Uint8Array | string>;
|
|
396
|
+
/** Set `Content-Disposition`/content-type dynamically; must be called before the first byte streams. */
|
|
397
|
+
readonly download: (filename: string, contentType?: string) => void;
|
|
398
|
+
/** Declare the total byte length — enables `Content-Length` and HTTP Range (resumable downloads). */
|
|
399
|
+
readonly length: (totalBytes: number) => void;
|
|
400
|
+
} : EmptyObject) & (HasHeaders<E> extends true ? {
|
|
401
|
+
readonly headers: HShape<E, 'out'>;
|
|
402
|
+
} : EmptyObject) & (HasCookies<E> extends true ? {
|
|
403
|
+
readonly cookies: CShape<E, 'out'>;
|
|
404
|
+
} : EmptyObject) & (HasErrors<E> extends true ? {
|
|
405
|
+
readonly fail: FailFn<ErrorsOf<E>>;
|
|
406
|
+
} : EmptyObject) & {
|
|
407
|
+
readonly req: Request;
|
|
408
|
+
readonly signal: AbortSignal;
|
|
409
|
+
readonly emit: EmitFn<S>;
|
|
410
|
+
/** Override the HTTP status (default 200/204/201…); HTTP transport only, before the first streamed byte. */
|
|
411
|
+
readonly status: (code: number) => void;
|
|
412
|
+
/** Set a response header; HTTP transport only, before the first streamed byte. */
|
|
413
|
+
readonly header: (name: string, value: string) => void;
|
|
414
|
+
/** Append a `Set-Cookie` header; HTTP transport only. */
|
|
415
|
+
readonly cookie: (name: string, value: string, opts?: CookieOptions) => void;
|
|
416
|
+
}>;
|
|
417
|
+
/** What a handler may return, per endpoint: an item-stream iterable, raw bytes, a `{ status, data }` union, the response, or `void`. */
|
|
418
|
+
type HandlerReturn<E extends AnyEndpoint> = HasItemStream<E> extends true ? MaybePromise<AsyncIterable<z.output<ItemSchema<E>>>> : HasRawStreamOut<E> extends true ? MaybePromise<ReadableStream<Uint8Array> | AsyncIterable<string | Uint8Array> | void> : HasMulti<E> extends true ? MaybePromise<{ [St in keyof RespMap<E> & number]: {
|
|
419
|
+
readonly status: St;
|
|
420
|
+
readonly data: z.input<RespMap<E>[St] & z.ZodType>;
|
|
421
|
+
} }[keyof RespMap<E> & number]> : Get<CfgOf<E>, 'response'> extends z.ZodType ? MaybePromise<z.output<Get<CfgOf<E>, 'response'> & z.ZodType>> : MaybePromise<void>;
|
|
422
|
+
/** A handler function for endpoint `E` of spec `S`. */
|
|
423
|
+
type HandlerFor<S extends AnySpec, E extends AnyEndpoint> = (payload: HandlerPayload<S, E>) => HandlerReturn<E>;
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/docs-ui.d.ts
|
|
426
|
+
/**
|
|
427
|
+
* # Docs UI
|
|
428
|
+
*
|
|
429
|
+
* Self-contained HTML pages that render the generated OpenAPI / AsyncAPI
|
|
430
|
+
* documents, pulling their viewer libraries from a CDN (no bundled dependency).
|
|
431
|
+
* Used by {@link server} when the `docs` option is enabled — it serves the spec
|
|
432
|
+
* JSON (computed once and cached in memory) plus a Swagger UI, a ReDoc, and an
|
|
433
|
+
* AsyncAPI page.
|
|
434
|
+
*
|
|
435
|
+
* @module
|
|
436
|
+
*/
|
|
437
|
+
/** Configuration for the built-in documentation routes (`ServerOptions.docs`). */
|
|
438
|
+
interface DocsOptions {
|
|
439
|
+
/** `info` (title/version) passed to the generated documents. */
|
|
440
|
+
readonly info?: {
|
|
441
|
+
title?: string;
|
|
442
|
+
version?: string;
|
|
443
|
+
};
|
|
444
|
+
/** Path serving the OpenAPI 3.1 JSON (default `/docs/openapi.json`); `false` to disable. */
|
|
445
|
+
readonly openapiJson?: string | false;
|
|
446
|
+
/** Path serving the AsyncAPI 3.0 JSON (default `/docs/asyncapi.json`); `false` to disable. */
|
|
447
|
+
readonly asyncapiJson?: string | false;
|
|
448
|
+
/** Path serving the Swagger UI page (default `/docs/swagger`); `false` to disable. */
|
|
449
|
+
readonly swagger?: string | false;
|
|
450
|
+
/** Path serving the ReDoc page (default `/docs/redoc`); `false` to disable. */
|
|
451
|
+
readonly redoc?: string | false;
|
|
452
|
+
/** Path serving the AsyncAPI viewer page (default `/docs/asyncapi`); `false` to disable. */
|
|
453
|
+
readonly asyncapi?: string | false;
|
|
454
|
+
}
|
|
455
|
+
/** Fully-resolved docs routes (internal). @internal */
|
|
456
|
+
|
|
457
|
+
/** A self-contained Swagger UI page (loaded from unpkg) pointed at `specUrl`. */
|
|
458
|
+
declare function swaggerHtml(specUrl: string, title?: string): string;
|
|
459
|
+
/** A self-contained ReDoc page (loaded from the Redocly CDN) pointed at `specUrl`. */
|
|
460
|
+
declare function redocHtml(specUrl: string, title?: string): string;
|
|
461
|
+
/** A self-contained AsyncAPI page (the `@asyncapi/web-component`, loaded from unpkg) pointed at `specUrl`. */
|
|
462
|
+
declare function asyncapiHtml(specUrl: string, title?: string): string;
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/server.d.ts
|
|
465
|
+
/** internal: the accumulated handler + middleware-impl bindings behind an {@link Implementor}. */
|
|
466
|
+
interface ImplBag {
|
|
467
|
+
readonly handlers: Map<string, (payload: never) => unknown>;
|
|
468
|
+
readonly middleware: Map<AnyMiddleware, AnyFn>;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Returned by {@link implement} — a chainable builder that accumulates endpoint
|
|
472
|
+
* handlers and middleware impls for a spec. Bind middleware defs to their runtime
|
|
473
|
+
* fns with {@link Implementor.middleware | .middleware}, and endpoint handlers with
|
|
474
|
+
* {@link Implementor.handlers | .handlers}/{@link Implementor.handle | .handle}.
|
|
475
|
+
* Hand the builder(s) to {@link server}, which requires every endpoint to have
|
|
476
|
+
* exactly one handler and every chain middleware to be bound.
|
|
477
|
+
*
|
|
478
|
+
* @typeParam HK - the union of endpoint names handled so far (drives {@link server}'s
|
|
479
|
+
* missing-handler compile check).
|
|
480
|
+
*/
|
|
481
|
+
interface Implementor<S extends AnySpec, HK extends keyof S['endpoints'] & string = never> {
|
|
482
|
+
/** Bind a middleware **def** to its runtime impl (or a loader def to its loader impl). */
|
|
483
|
+
middleware<M extends AnyMiddleware>(def: M, impl: ImplFor<M>): Implementor<S, HK>;
|
|
484
|
+
/** Bind a package-built `{ def, impl }` pair (e.g. `bearerAuth.server(def, cfg)`). */
|
|
485
|
+
middleware<M extends AnyMiddleware>(bound: BoundMiddleware<M>): Implementor<S, HK>;
|
|
486
|
+
/** Provide a bag of handlers (a partial map of endpoint name → handler). */
|
|
487
|
+
handlers<K extends keyof S['endpoints'] & string>(h: { [P in K]: HandlerFor<S, S['endpoints'][P]> }): Implementor<S, HK | K>;
|
|
488
|
+
/** Provide a single handler by name. */
|
|
489
|
+
handle<K extends keyof S['endpoints'] & string>(name: K, fn: HandlerFor<S, S['endpoints'][K]>): Implementor<S, HK | K>;
|
|
490
|
+
/** @internal the accumulated bindings — read by {@link server}. */
|
|
491
|
+
readonly __bag: ImplBag;
|
|
492
|
+
/** @internal type-only carrier of the handled-endpoint union — matched by {@link server} without expanding the methods. */
|
|
493
|
+
readonly __hk?: HK;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Begin implementing a spec. The returned {@link Implementor} is a chainable
|
|
497
|
+
* builder: bind middleware impls with `.middleware(def, impl)` and endpoint
|
|
498
|
+
* handlers with `.handlers({...})`/`.handle(name, fn)`, then hand it to
|
|
499
|
+
* {@link server}. Split work across multiple `implement()` builders if you like —
|
|
500
|
+
* `server()` merges them.
|
|
501
|
+
*
|
|
502
|
+
* @example
|
|
503
|
+
* ```ts
|
|
504
|
+
* const impl = implement(api)
|
|
505
|
+
* .middleware(auth, async (io) => io.next({ user: await authenticate(io.req) }))
|
|
506
|
+
* .handlers({ getUser: ({ data }) => loadUser(data.id) })
|
|
507
|
+
* ```
|
|
508
|
+
*/
|
|
509
|
+
declare function implement<S extends AnySpec>(spec: S): Implementor<S>;
|
|
510
|
+
/** A live WebSocket connection, as seen by the server's ws handler. */
|
|
511
|
+
interface WsConn {
|
|
512
|
+
readonly id: number;
|
|
513
|
+
readonly req: Request;
|
|
514
|
+
/** internal */
|
|
515
|
+
readonly send: (frame: string) => void;
|
|
516
|
+
/** internal */
|
|
517
|
+
readonly subs: Set<string>;
|
|
518
|
+
/** internal */
|
|
519
|
+
readonly streams: Map<string, {
|
|
520
|
+
push(v: unknown): void;
|
|
521
|
+
end(): void;
|
|
522
|
+
fail(err: unknown): void;
|
|
523
|
+
}>;
|
|
524
|
+
/** internal: per-call abort controllers, so an `{ id, abort: true }` frame can cancel a call */
|
|
525
|
+
readonly calls: Map<string, AbortController>;
|
|
526
|
+
}
|
|
527
|
+
/** Cross-origin resource sharing configuration. */
|
|
528
|
+
interface CorsOptions {
|
|
529
|
+
/** Allowed origin(s): `'*'`, a single origin, or an allow-list. */
|
|
530
|
+
readonly origin: '*' | string | readonly string[];
|
|
531
|
+
/** Whether to send `Access-Control-Allow-Credentials`. */
|
|
532
|
+
readonly credentials?: boolean;
|
|
533
|
+
/** Preflight cache duration (seconds). */
|
|
534
|
+
readonly maxAge?: number;
|
|
535
|
+
/** Response headers to expose to the browser. */
|
|
536
|
+
readonly exposeHeaders?: readonly string[];
|
|
537
|
+
}
|
|
538
|
+
/** Options for {@link server}. */
|
|
539
|
+
interface ServerOptions {
|
|
540
|
+
readonly cors?: CorsOptions;
|
|
541
|
+
/** Event fanout across server instances (default: in-process {@link localBroker}). */
|
|
542
|
+
readonly broker?: Broker;
|
|
543
|
+
/**
|
|
544
|
+
* Serve interactive API documentation. `true` mounts the defaults
|
|
545
|
+
* (`/openapi.json`, `/asyncapi.json`, `/docs` Swagger UI, `/redoc`, `/asyncapi`);
|
|
546
|
+
* pass a {@link DocsOptions} object to customize paths or disable pages. The
|
|
547
|
+
* spec JSON is generated once and cached in memory.
|
|
548
|
+
*/
|
|
549
|
+
readonly docs?: boolean | DocsOptions;
|
|
550
|
+
}
|
|
551
|
+
/** The assembled server: a fetch handler, a ws handler, the manifest, `emit`, and doc generators. */
|
|
552
|
+
interface Server<S extends AnySpec> {
|
|
553
|
+
readonly spec: S;
|
|
554
|
+
/** The entire HTTP surface — pass any `Request`, get a `Response`. */
|
|
555
|
+
fetch(req: Request): Promise<Response>;
|
|
556
|
+
/** The zod-free runtime manifest the client routes from (also derivable via {@link manifestFromSpec}). */
|
|
557
|
+
manifest(): Manifest;
|
|
558
|
+
/** Publish a typed event to all subscribers (across instances via the {@link Broker}). */
|
|
559
|
+
emit: EmitFn<S>;
|
|
560
|
+
/** Generate the OpenAPI 3.1 document. */
|
|
561
|
+
openapi(info?: {
|
|
562
|
+
title?: string;
|
|
563
|
+
version?: string;
|
|
564
|
+
}): Json;
|
|
565
|
+
/** Generate the AsyncAPI 3.0 document. */
|
|
566
|
+
asyncapi(info?: {
|
|
567
|
+
title?: string;
|
|
568
|
+
version?: string;
|
|
569
|
+
}): Json;
|
|
570
|
+
/**
|
|
571
|
+
* Call one of this server's endpoints **in-process** by name with just its data
|
|
572
|
+
* payload — runs the full middleware chain + validation, but skips HTTP
|
|
573
|
+
* serialization. The invocation's `io.transport` is `'local'`. Pass `headers` via
|
|
574
|
+
* `opts` to satisfy auth-style middleware. Streaming/file endpoints are best
|
|
575
|
+
* called over a {@link client} instead.
|
|
576
|
+
*
|
|
577
|
+
* The result is loosely typed here (a low-level escape hatch) — for the typed
|
|
578
|
+
* surface use {@link localClient}, which returns a {@link LocalClient}.
|
|
579
|
+
*/
|
|
580
|
+
call<K extends keyof S['endpoints'] & string>(name: K, ...args: LocalCallArgs<S['endpoints'][K]>): Promise<unknown>;
|
|
581
|
+
/**
|
|
582
|
+
* Mount another spec + its builders into this **running** server — its endpoints,
|
|
583
|
+
* events, routes, and middleware go live immediately and the manifest/docs caches
|
|
584
|
+
* refresh. Typed like {@link server}: every endpoint needs a handler and every
|
|
585
|
+
* chain middleware a binding (a shared middleware def already bound by an earlier
|
|
586
|
+
* mount is reused, not re-bound). Collisions (endpoint name / route / ws / event)
|
|
587
|
+
* throw. Returns a {@link MountHandle} for {@link Server.uninstall}.
|
|
588
|
+
*/
|
|
589
|
+
install<S2 extends AnySpec, const H2 extends readonly {
|
|
590
|
+
readonly __hk?: keyof S2['endpoints'] & string;
|
|
591
|
+
}[]>(spec: S2, builders: H2, ...rest: [MissingHandlers<S2, H2>] extends [never] ? [] : [error: {
|
|
592
|
+
readonly missingHandlers: MissingHandlers<S2, H2>;
|
|
593
|
+
}]): MountHandle;
|
|
594
|
+
/** Remove a previously {@link Server.install | installed} mount — deletes exactly its endpoints, events, routes, and bindings, and clears its subscriptions. */
|
|
595
|
+
uninstall(handle: MountHandle): void;
|
|
596
|
+
/** WebSocket lifecycle hooks for an adapter to drive. */
|
|
597
|
+
readonly ws: {
|
|
598
|
+
/** Register a new connection given its `send` and the upgrade `Request`. */
|
|
599
|
+
open(send: (frame: string) => void, req: Request): WsConn;
|
|
600
|
+
/** Handle one inbound text frame. */
|
|
601
|
+
message(conn: WsConn, raw: string): Promise<void>;
|
|
602
|
+
/** Tear down a connection (cleans up its subscriptions). */
|
|
603
|
+
close(conn: WsConn): void;
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
/** Per-call options for an in-process {@link Server.call} / {@link LocalClient}. */
|
|
607
|
+
interface LocalCallOptions {
|
|
608
|
+
/** Request headers visible to the chain (e.g. an `authorization` token for auth middleware). */
|
|
609
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
610
|
+
/** Abort signal for the call. */
|
|
611
|
+
readonly signal?: AbortSignal;
|
|
612
|
+
}
|
|
613
|
+
/** The arguments an in-process call takes: `[opts?]` when the endpoint has no input, else `[data, opts?]`. */
|
|
614
|
+
type LocalCallArgs<E extends AnyEndpoint> = [keyof ClientData<E>] extends [never] ? [opts?: LocalCallOptions] : [data: ClientData<E>, opts?: LocalCallOptions];
|
|
615
|
+
/**
|
|
616
|
+
* A typed in-process caller for a spec's endpoints — the no-serialization loopback
|
|
617
|
+
* over {@link Server.call}, retyped against `S`. Use it to invoke another spec's
|
|
618
|
+
* endpoints (e.g. a dependency's) from server-side code with just a data payload.
|
|
619
|
+
*/
|
|
620
|
+
interface LocalClient<S extends AnySpec> {
|
|
621
|
+
call<K extends keyof S['endpoints'] & string>(name: K, ...args: LocalCallArgs<S['endpoints'][K]>): CallReturn<S['endpoints'][K]>;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* View a running {@link Server} as a typed {@link LocalClient} for spec `S` — for
|
|
625
|
+
* calling endpoints in-process (full chain + validation, no HTTP) by name + data.
|
|
626
|
+
*
|
|
627
|
+
* @param app - the running server (its `call` is the in-process caller).
|
|
628
|
+
* @param spec - the spec to type against (its endpoints/`CallReturn` shape `S`).
|
|
629
|
+
*
|
|
630
|
+
* @example
|
|
631
|
+
* ```ts
|
|
632
|
+
* const users = localClient(app, usersSpec);
|
|
633
|
+
* const u = await users.call('getUser', { id: 'u1' }); // typed, in-process
|
|
634
|
+
* ```
|
|
635
|
+
*/
|
|
636
|
+
declare function localClient<S extends AnySpec>(app: Server<AnySpec>, spec?: S): LocalClient<S>;
|
|
637
|
+
/** The endpoint-name union a builder has handled — read off its `__hk` phantom (never expands the methods). */
|
|
638
|
+
type BuilderKeys<B> = B extends {
|
|
639
|
+
readonly __hk?: infer K;
|
|
640
|
+
} ? K : never;
|
|
641
|
+
/** Endpoint names with no handler across the supplied builders — surfaced as a compile error. */
|
|
642
|
+
type MissingHandlers<S extends AnySpec, H extends readonly unknown[]> = Exclude<keyof S['endpoints'] & string, BuilderKeys<H[number]>>;
|
|
643
|
+
/** Loosest internal function type — replaces the banned `Function` in plumbing signatures. */
|
|
644
|
+
type AnyFn = (...args: never[]) => unknown;
|
|
645
|
+
/** internal: a normalized event with its guard chain bound — held in the live `events` registry. */
|
|
646
|
+
interface LiveEvent {
|
|
647
|
+
readonly name: string;
|
|
648
|
+
readonly cfg: EventConfig;
|
|
649
|
+
readonly ws: string;
|
|
650
|
+
readonly chain: AnyMiddleware[];
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* An opaque handle to a mounted spec, returned by {@link Server.install} and passed
|
|
654
|
+
* back to {@link Server.uninstall} to remove exactly what that install added.
|
|
655
|
+
*/
|
|
656
|
+
interface MountHandle {
|
|
657
|
+
/** @internal the normalized endpoints this mount added. */
|
|
658
|
+
readonly eps: readonly NormalizedEp[];
|
|
659
|
+
/** @internal the live events this mount added. */
|
|
660
|
+
readonly events: readonly LiveEvent[];
|
|
661
|
+
/** @internal the middleware defs this mount bound (removed from the global impl map on uninstall). */
|
|
662
|
+
readonly impls: readonly AnyMiddleware[];
|
|
663
|
+
/** @internal this mount's spec-level doc patch, if any. */
|
|
664
|
+
readonly doc: SpecDoc | undefined;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Assemble a {@link Server} from a spec and one or more {@link implement} builders.
|
|
668
|
+
*
|
|
669
|
+
* Every endpoint must have exactly one handler: a missing handler is a **compile
|
|
670
|
+
* error** that names the offending endpoints (via the final `error` argument), and
|
|
671
|
+
* a duplicate/unknown handler throws at startup. Every middleware in an endpoint or
|
|
672
|
+
* event-guard chain must be bound via `.middleware(def, impl)` — an unbound def
|
|
673
|
+
* throws at assembly.
|
|
674
|
+
*
|
|
675
|
+
* @param spec - the validated spec from {@link spec}.
|
|
676
|
+
* @param builders - one or more builders from {@link implement}.
|
|
677
|
+
* @param rest - `[options?]` when all handlers are present, else `[{ missingHandlers }]`.
|
|
678
|
+
*
|
|
679
|
+
* @example
|
|
680
|
+
* ```ts
|
|
681
|
+
* const app = server(api, [implement(api).middleware(auth, authImpl).handlers({ … })], { cors, broker })
|
|
682
|
+
* const res = await app.fetch(new Request('http://x/getUser/u1', { method: 'POST' }))
|
|
683
|
+
* ```
|
|
684
|
+
*/
|
|
685
|
+
declare function server<S extends AnySpec, const H extends readonly {
|
|
686
|
+
readonly __hk?: keyof S['endpoints'] & string;
|
|
687
|
+
}[]>(spec: S, builders: H, ...rest: [MissingHandlers<S, H>] extends [never] ? [options?: ServerOptions] : [error: {
|
|
688
|
+
readonly missingHandlers: MissingHandlers<S, H>;
|
|
689
|
+
}]): Server<S>;
|
|
690
|
+
//#endregion
|
|
691
|
+
//#region src/middleware.d.ts
|
|
692
|
+
/** Which transport an invocation arrived on (`'local'` = an in-process {@link LocalClient} call). */
|
|
693
|
+
type Transport = 'http' | 'ws' | 'local';
|
|
694
|
+
/**
|
|
695
|
+
* The matched route a middleware is running for. A discriminated union: an
|
|
696
|
+
* **endpoint** (with `method`/`path`/`ws` id) or an **event** channel (guard
|
|
697
|
+
* chains run when a client subscribes), which has no method/path.
|
|
698
|
+
*/
|
|
699
|
+
type RouteInfo = {
|
|
700
|
+
readonly kind: 'endpoint';
|
|
701
|
+
readonly name: string;
|
|
702
|
+
readonly method: HttpMethod;
|
|
703
|
+
readonly path: string;
|
|
704
|
+
readonly ws: string | null;
|
|
705
|
+
} | {
|
|
706
|
+
readonly kind: 'event';
|
|
707
|
+
readonly name: string;
|
|
708
|
+
readonly ws: string;
|
|
709
|
+
};
|
|
710
|
+
/** WebSocket frame context, present on {@link MiddlewareIO.ws} only when `transport === 'ws'`. */
|
|
711
|
+
interface WsFrameInfo {
|
|
712
|
+
/** The frame id (the per-call correlation id) — the ws equivalent of a request id. */
|
|
713
|
+
readonly id: string;
|
|
714
|
+
/** The raw frame payload (`data` for calls, `params` for event subscriptions). Narrow it yourself. */
|
|
715
|
+
readonly data: unknown;
|
|
716
|
+
/** The WebSocket connection this frame arrived on. */
|
|
717
|
+
readonly conn: WsConn;
|
|
718
|
+
}
|
|
719
|
+
declare const MW_PROVIDES: unique symbol;
|
|
720
|
+
/**
|
|
721
|
+
* Opaque token returned by `next()`; carries (type-only) the context a
|
|
722
|
+
* middleware provides so the next link and the handler can see it.
|
|
723
|
+
*
|
|
724
|
+
* @typeParam P - the context shape this step contributes.
|
|
725
|
+
*/
|
|
726
|
+
interface MiddlewareResult<P extends object> {
|
|
727
|
+
readonly [MW_PROVIDES]?: P;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* The argument passed to a middleware function.
|
|
731
|
+
*
|
|
732
|
+
* @typeParam Req - the accumulated context from earlier middleware in the chain.
|
|
733
|
+
*/
|
|
734
|
+
interface MiddlewareIO<Req extends object> {
|
|
735
|
+
/** The incoming request. Over ws this is the connection's HTTP **upgrade** request (shared per socket), not the frame. */
|
|
736
|
+
readonly req: Request;
|
|
737
|
+
/** Context accumulated so far (read-only snapshot). */
|
|
738
|
+
readonly ctx: Simplify<Req>;
|
|
739
|
+
/**
|
|
740
|
+
* The raw, **pre-validation** request body — the parsed JSON / urlencoded-form object
|
|
741
|
+
* (or, for a multipart request, its non-file fields), the ws call's data, or `undefined`
|
|
742
|
+
* when the request declares no body. Read it to derive idempotency/cache keys, sign or
|
|
743
|
+
* log the payload, etc. The typed, validated body still reaches the handler as `data`.
|
|
744
|
+
*/
|
|
745
|
+
readonly body: unknown;
|
|
746
|
+
/**
|
|
747
|
+
* Continue the chain, optionally contributing context. The added shape is
|
|
748
|
+
* inferred and becomes visible to later middleware and the handler. Every
|
|
749
|
+
* middleware **must** call `next()` (or throw) exactly once.
|
|
750
|
+
*/
|
|
751
|
+
readonly next: <T extends object = EmptyObject>(add?: T) => Promise<MiddlewareResult<T>>;
|
|
752
|
+
/** Which transport this invocation arrived on (`'http'` or `'ws'`). */
|
|
753
|
+
readonly transport: Transport;
|
|
754
|
+
/** The matched route — an endpoint (with `method`/`path`/`ws`) or an event channel. Use `route.method`/`route.path` for transport-neutral identity. */
|
|
755
|
+
readonly route: RouteInfo;
|
|
756
|
+
/** The abort signal for this invocation (the HTTP request signal, or the ws call's per-frame signal). */
|
|
757
|
+
readonly signal: AbortSignal;
|
|
758
|
+
/** WebSocket frame context — present only when `transport === 'ws'` (carries the frame `id`, raw `data`, and `conn`). */
|
|
759
|
+
readonly ws?: WsFrameInfo;
|
|
760
|
+
/** Set a response header. Applies over HTTP (collected with the handler's headers); collected-but-unused over ws. Must run before the response commits. */
|
|
761
|
+
readonly setHeader: (name: string, value: string) => void;
|
|
762
|
+
/** Set the response status — the HTTP status code, or a ws call's result-frame `$status`. Must run before the response commits. */
|
|
763
|
+
readonly status: (code: number) => void;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* A plain middleware function. Either continue the chain via `io.next(…)` (the
|
|
767
|
+
* common case) or **short-circuit** by returning a `Response` directly — the rest
|
|
768
|
+
* of the chain and the handler are skipped. Over HTTP the `Response` is sent
|
|
769
|
+
* as-is; over ws a JSON body becomes a result frame and anything else an error
|
|
770
|
+
* frame.
|
|
771
|
+
*/
|
|
772
|
+
type MiddlewareFn<Req extends object, P extends object> = (io: MiddlewareIO<Req>) => Promise<MiddlewareResult<P> | Response>;
|
|
773
|
+
/** A loader middleware function — additionally receives the parsed, typed param `value`. May also short-circuit with a `Response`. */
|
|
774
|
+
type LoaderFn<Req extends object, Z extends z.ZodType, P extends object> = (io: MiddlewareIO<Req> & {
|
|
775
|
+
readonly value: z.output<Z>;
|
|
776
|
+
}) => Promise<MiddlewareResult<P> | Response>;
|
|
777
|
+
/**
|
|
778
|
+
* Erased middleware shape used by the runtime and by variance-friendly
|
|
779
|
+
* constraints. Carries phantom fields (`__p`/`__req`/`__lp`/`__opt`/`__isLoader`/
|
|
780
|
+
* `__lz`) that are type-only.
|
|
781
|
+
*
|
|
782
|
+
* A middleware value is a **def** (contract only): its {@link AnyMiddleware.run}
|
|
783
|
+
* is an unbound placeholder that throws if executed. The runtime fn is supplied
|
|
784
|
+
* separately via `implement(api).middleware(def, impl)` and bound at `server()`.
|
|
785
|
+
*
|
|
786
|
+
* @internal
|
|
787
|
+
*/
|
|
788
|
+
interface AnyMiddleware {
|
|
789
|
+
readonly kind: 'middleware';
|
|
790
|
+
readonly name: string;
|
|
791
|
+
readonly requires: readonly AnyMiddleware[];
|
|
792
|
+
readonly optional: readonly AnyMiddleware[];
|
|
793
|
+
readonly paramKey: string | undefined;
|
|
794
|
+
readonly paramSchema: z.ZodType | undefined;
|
|
795
|
+
readonly doc: MiddlewareDoc | undefined;
|
|
796
|
+
/** internal: loosest possible run signature; on a def it is an unbound placeholder that throws. */
|
|
797
|
+
readonly run: (io: {
|
|
798
|
+
req: Request;
|
|
799
|
+
ctx: never;
|
|
800
|
+
next: (add?: object) => Promise<MiddlewareResult<object>>;
|
|
801
|
+
value?: never;
|
|
802
|
+
}) => Promise<MiddlewareResult<object> | Response>;
|
|
803
|
+
readonly __p: object;
|
|
804
|
+
readonly __req: readonly AnyMiddleware[];
|
|
805
|
+
readonly __lp: object;
|
|
806
|
+
/** internal phantom: the `optional` list, so an impl's `io.ctx` can include `Partial<provides-of-optional>`. */
|
|
807
|
+
readonly __opt: readonly AnyMiddleware[];
|
|
808
|
+
/** internal phantom: `true` only for `middleware.loader` defs — selects the loader-shaped impl. */
|
|
809
|
+
readonly __isLoader: boolean;
|
|
810
|
+
/** internal phantom: a loader def's own param schema (the `value` an impl receives); `ZodNever` for non-loaders. */
|
|
811
|
+
readonly __lz: z.ZodType;
|
|
812
|
+
}
|
|
813
|
+
/** Everything a middleware provides, including (recursively) what its `requires` provide. */
|
|
814
|
+
type FullProvides<M extends AnyMiddleware> = M['__p'] & ListProvides<M['__req']>;
|
|
815
|
+
type DistFullProvides<M extends AnyMiddleware> = M extends AnyMiddleware ? FullProvides<M> : never;
|
|
816
|
+
type ListProvides<Ms extends readonly AnyMiddleware[]> = [Ms[number]] extends [never] ? EmptyObject : UnionToIntersection<DistFullProvides<Ms[number]>>;
|
|
817
|
+
/** Loader params contributed by a middleware, including its `requires` chain. */
|
|
818
|
+
type FullLP<M extends AnyMiddleware> = M['__lp'] & ListLP<M['__req']>;
|
|
819
|
+
type DistFullLP<M extends AnyMiddleware> = M extends AnyMiddleware ? FullLP<M> : never;
|
|
820
|
+
type ListLP<Ms extends readonly AnyMiddleware[]> = [Ms[number]] extends [never] ? EmptyObject : UnionToIntersection<DistFullLP<Ms[number]>>;
|
|
821
|
+
/** The merged context a stack of middleware contributes to the handler payload. */
|
|
822
|
+
type StackCtx<Ms extends readonly AnyMiddleware[]> = Simplify<ListProvides<Ms>>;
|
|
823
|
+
/** The merged loader-param schemas a stack of middleware declares. */
|
|
824
|
+
type StackLP<Ms extends readonly AnyMiddleware[]> = Simplify<ListLP<Ms>>;
|
|
825
|
+
/** Param schemas declared by a path template (position + schema); plain strings contribute positions only. */
|
|
826
|
+
type TplPS<T> = T extends {
|
|
827
|
+
readonly __ps: infer PS extends object;
|
|
828
|
+
} ? PS : EmptyObject;
|
|
829
|
+
/** Stacked prefixes must not re-declare param keys already owned by a loader or earlier prefix. */
|
|
830
|
+
type CheckPrefix<T, LP extends object, PFX extends object> = T extends {
|
|
831
|
+
readonly __ps: infer PS;
|
|
832
|
+
} ? [keyof PS & (keyof LP | keyof PFX)] extends [never] ? unknown : readonly ['prefix re-declares param keys:', keyof PS & (keyof LP | keyof PFX)] : unknown;
|
|
833
|
+
/**
|
|
834
|
+
* A single middleware value, with fluent builders for composing it with others
|
|
835
|
+
* ({@link Middleware.with | .with}), prepending a path prefix
|
|
836
|
+
* ({@link Middleware.path | .path}), or attaching endpoints
|
|
837
|
+
* ({@link Middleware.endpoint | .endpoint} / {@link Middleware.group | .group}).
|
|
838
|
+
*
|
|
839
|
+
* @typeParam P - context this middleware provides.
|
|
840
|
+
* @typeParam R - its `requires` chain.
|
|
841
|
+
* @typeParam LP - loader-param schemas it (and its requires) declare.
|
|
842
|
+
*/
|
|
843
|
+
interface Middleware<P extends object, R extends readonly AnyMiddleware[], LP extends object> extends AnyMiddleware {
|
|
844
|
+
readonly __p: P;
|
|
845
|
+
readonly __req: R;
|
|
846
|
+
readonly __lp: LP;
|
|
847
|
+
/** Compose this middleware with more, producing a {@link Stack}. */
|
|
848
|
+
with<M extends readonly AnyMiddleware[]>(...mws: M): Stack<readonly [Middleware<P, R, LP>, ...M], EmptyObject>;
|
|
849
|
+
/** Prepend a path prefix; template params merge into every endpoint defined under it. */
|
|
850
|
+
path<const T extends string | AnyPathTemplate>(p: T & CheckPrefix<T, LP, EmptyObject>): Stack<readonly [Middleware<P, R, LP>], Simplify<TplPS<T>>>;
|
|
851
|
+
/** Define a single endpoint guarded by this middleware. */
|
|
852
|
+
endpoint<const C extends EndpointConfig>(cfg: C & CheckCfg<C, StackLP<readonly [Middleware<P, R, LP>]>, EmptyObject>): Endpoint<C, StackCtx<readonly [Middleware<P, R, LP>]>, StackLP<readonly [Middleware<P, R, LP>]>, EmptyObject>;
|
|
853
|
+
/** Define a named group of endpoints, all guarded by this middleware. */
|
|
854
|
+
group<const G extends Record<string, EndpointConfig>>(g: G & { [K in keyof G]: CheckCfg<G[K], StackLP<readonly [Middleware<P, R, LP>]>, EmptyObject> }): { [K in keyof G]: Endpoint<G[K], StackCtx<readonly [Middleware<P, R, LP>]>, StackLP<readonly [Middleware<P, R, LP>]>, EmptyObject> };
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* A bundle of middleware plus optional path prefixes, ready to attach endpoints.
|
|
858
|
+
*
|
|
859
|
+
* @typeParam Ms - the middleware list, in declared order.
|
|
860
|
+
* @typeParam PFX - param schemas contributed by stacked path prefixes.
|
|
861
|
+
*/
|
|
862
|
+
interface Stack<Ms extends readonly AnyMiddleware[], PFX extends object> {
|
|
863
|
+
readonly kind: 'stack';
|
|
864
|
+
readonly mws: Ms;
|
|
865
|
+
readonly prefixes: ReadonlyArray<string | AnyPathTemplate>;
|
|
866
|
+
/** Append more middleware to the stack. */
|
|
867
|
+
with<M extends readonly AnyMiddleware[]>(...mws: M): Stack<readonly [...Ms, ...M], PFX>;
|
|
868
|
+
/** Prepend a path prefix; template params merge into every endpoint defined under it. */
|
|
869
|
+
path<const T extends string | AnyPathTemplate>(p: T & CheckPrefix<T, StackLP<Ms>, PFX>): Stack<Ms, Simplify<PFX & TplPS<T>>>;
|
|
870
|
+
/** Define a single endpoint under this stack. */
|
|
871
|
+
endpoint<const C extends EndpointConfig>(cfg: C & CheckCfg<C, StackLP<Ms>, PFX>): Endpoint<C, StackCtx<Ms>, StackLP<Ms>, PFX>;
|
|
872
|
+
/** Define a named group of endpoints under this stack. */
|
|
873
|
+
group<const G extends Record<string, EndpointConfig>>(g: G & { [K in keyof G]: CheckCfg<G[K], StackLP<Ms>, PFX> }): { [K in keyof G]: Endpoint<G[K], StackCtx<Ms>, StackLP<Ms>, PFX> };
|
|
874
|
+
}
|
|
875
|
+
declare const PROVIDE: unique symbol;
|
|
876
|
+
/**
|
|
877
|
+
* Phantom carrier for the context type a middleware **provides**. Pass
|
|
878
|
+
* `provides: ctx<{ user: User }>()` to a {@link middleware} def to declare what it
|
|
879
|
+
* contributes to the handler payload — the type the impl must produce and that
|
|
880
|
+
* later middleware and handlers can read. Frontend-safe: a type-only token.
|
|
881
|
+
*
|
|
882
|
+
* @typeParam P - the context shape contributed.
|
|
883
|
+
*/
|
|
884
|
+
interface Provide<P extends object> {
|
|
885
|
+
readonly [PROVIDE]?: P;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Declare the context a middleware def provides — `provides: ctx<{ user: User }>()`.
|
|
889
|
+
* Returns a type-only token; carries no runtime value.
|
|
890
|
+
*/
|
|
891
|
+
declare function ctx<P extends object>(): Provide<P>;
|
|
892
|
+
/** Options accepted by the {@link middleware} factory. */
|
|
893
|
+
interface MiddlewareOpts<P extends object, R extends readonly AnyMiddleware[], O extends readonly AnyMiddleware[]> {
|
|
894
|
+
/** The context this middleware contributes — `provides: ctx<{ user: User }>()`. Omit for a no-context (purely-runtime) def. */
|
|
895
|
+
readonly provides?: Provide<P>;
|
|
896
|
+
/** Middleware that are auto-included and guaranteed to run before this one. */
|
|
897
|
+
readonly requires?: R;
|
|
898
|
+
/** Middleware that, *if independently present*, run before this one — without being pulled in. */
|
|
899
|
+
readonly optional?: O;
|
|
900
|
+
/** OpenAPI contributions (security schemes + per-operation patches). */
|
|
901
|
+
readonly doc?: MiddlewareDoc;
|
|
902
|
+
}
|
|
903
|
+
/** A middleware **def**: the `Middleware` contract plus type-only phantoms an impl binds against. */
|
|
904
|
+
type Def<M extends AnyMiddleware, O extends readonly AnyMiddleware[], IsLoader extends boolean, Z extends z.ZodType> = M & {
|
|
905
|
+
readonly __opt: O;
|
|
906
|
+
readonly __isLoader: IsLoader;
|
|
907
|
+
readonly __lz: Z;
|
|
908
|
+
};
|
|
909
|
+
/**
|
|
910
|
+
* The type a plain (non-loader) {@link middleware} **def** has — the `Middleware` contract plus the
|
|
911
|
+
* phantoms its `.server` binding reads. Give a def factory this as an **explicit** return type so its
|
|
912
|
+
* inferred type stays portable across packages (avoids TS2742 from deep `@ayepi/core` type paths):
|
|
913
|
+
*
|
|
914
|
+
* ```ts
|
|
915
|
+
* export function myMw<const R extends readonly AnyMiddleware[] = readonly []>(): MiddlewareDef<MyCtx, R> {
|
|
916
|
+
* return middleware('myMw', { provides: ctx<MyCtx>(), requires: [] as R });
|
|
917
|
+
* }
|
|
918
|
+
* ```
|
|
919
|
+
*/
|
|
920
|
+
type MiddlewareDef<P extends object, R extends readonly AnyMiddleware[] = readonly []> = Def<Middleware<P, R, Simplify<ListLP<R>>>, readonly [], false, z.ZodNever>;
|
|
921
|
+
/**
|
|
922
|
+
* The {@link middleware} factory: callable to create a plain middleware **def**,
|
|
923
|
+
* with a {@link MiddlewareFactory.loader | .loader} method for param-loading defs.
|
|
924
|
+
*
|
|
925
|
+
* A def is a contract only — no runtime fn, no secrets, no node deps — so it is
|
|
926
|
+
* safe to declare in a frontend-importable spec. Bind the implementation later
|
|
927
|
+
* with `implement(api).middleware(def, impl)`.
|
|
928
|
+
*/
|
|
929
|
+
interface MiddlewareFactory {
|
|
930
|
+
/**
|
|
931
|
+
* Create a middleware def. Declare its contributed context via `provides`, its
|
|
932
|
+
* dependencies via `requires`/`optional`, and OpenAPI patches via `doc`.
|
|
933
|
+
*/
|
|
934
|
+
<P extends object = EmptyObject, const R extends readonly AnyMiddleware[] = readonly [], const O extends readonly AnyMiddleware[] = readonly []>(name: string, opts?: MiddlewareOpts<P, R, O>): Def<Middleware<P, R, Simplify<ListLP<R>>>, O, false, z.ZodNever>;
|
|
935
|
+
/**
|
|
936
|
+
* Create a **loader** def that owns a path param: it declares `key` + `schema`
|
|
937
|
+
* and parses the matching segment. Its impl receives the typed `value`, and the
|
|
938
|
+
* parsed param flows to the handler's `data`.
|
|
939
|
+
*/
|
|
940
|
+
loader: <P extends object = EmptyObject, K extends string = string, Z extends z.ZodType = z.ZodType, const R extends readonly AnyMiddleware[] = readonly [], const O extends readonly AnyMiddleware[] = readonly []>(key: K, schema: Z, opts?: MiddlewareOpts<P, R, O>) => Def<Middleware<P, R, Simplify<ListLP<R> & { [Q in K]: Z }>>, O, true, Z>;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Compose one or more middleware into a {@link Stack} — the free-function form of
|
|
944
|
+
* {@link Middleware.with | mw.with(...)}, which reads more naturally when bundling
|
|
945
|
+
* several middleware at a group: `...use(auth, tel).group({ … })` instead of
|
|
946
|
+
* `...auth.with(tel).group({ … })`.
|
|
947
|
+
*
|
|
948
|
+
* The middleware run in the order given (subject to `requires`/`optional`
|
|
949
|
+
* resolution), exactly as with `.with()`.
|
|
950
|
+
*
|
|
951
|
+
* @typeParam M - the middleware list, in declared order (at least one).
|
|
952
|
+
*
|
|
953
|
+
* @example
|
|
954
|
+
* ```ts
|
|
955
|
+
* spec({
|
|
956
|
+
* endpoints: {
|
|
957
|
+
* ...use(auth, tel).group({ me, createJob }),
|
|
958
|
+
* ...use(auth, jobLoader).path('/jobs/:jobId').group({ jobStatus }),
|
|
959
|
+
* },
|
|
960
|
+
* })
|
|
961
|
+
* ```
|
|
962
|
+
*/
|
|
963
|
+
declare function use<const M extends readonly [AnyMiddleware, ...AnyMiddleware[]]>(...mws: M): Stack<M, EmptyObject>;
|
|
964
|
+
/**
|
|
965
|
+
* Create a middleware **def** — a frontend-safe contract (name, contributed
|
|
966
|
+
* context, docs, dependencies) with **no** runtime fn. Bind the implementation
|
|
967
|
+
* with `implement(api).middleware(def, impl)` in your server entry.
|
|
968
|
+
*
|
|
969
|
+
* @example A plain def that provides `{ user }`:
|
|
970
|
+
* ```ts
|
|
971
|
+
* const auth = middleware('auth', { provides: ctx<{ user: User }>() })
|
|
972
|
+
* // server.ts:
|
|
973
|
+
* implement(api).middleware(auth, async (io) => io.next({ user: await authenticate(io.req) }))
|
|
974
|
+
* ```
|
|
975
|
+
*
|
|
976
|
+
* @example A def that requires `auth` (auto-included) and provides `{ org }`:
|
|
977
|
+
* ```ts
|
|
978
|
+
* const org = middleware('org', { provides: ctx<{ org: Org }>(), requires: [auth] })
|
|
979
|
+
* // server.ts:
|
|
980
|
+
* implement(api).middleware(org, async (io) => io.next({ org: await loadOrg(io.ctx.user) }))
|
|
981
|
+
* ```
|
|
982
|
+
*
|
|
983
|
+
* @example A no-context, purely-runtime def (e.g. logging/telemetry):
|
|
984
|
+
* ```ts
|
|
985
|
+
* const log = middleware('log')
|
|
986
|
+
* implement(api).middleware(log, async (io) => io.next())
|
|
987
|
+
* ```
|
|
988
|
+
*
|
|
989
|
+
* @example A loader def that owns the `:projectId` path param:
|
|
990
|
+
* ```ts
|
|
991
|
+
* const project = middleware.loader('projectId', z.uuid(), { provides: ctx<{ project: Project }>() })
|
|
992
|
+
* // server.ts:
|
|
993
|
+
* implement(api).middleware(project, async (io) => io.next({ project: await loadProject(io.value) }))
|
|
994
|
+
* ```
|
|
995
|
+
*/
|
|
996
|
+
declare const middleware: MiddlewareFactory;
|
|
997
|
+
/**
|
|
998
|
+
* The impl signature a plain middleware def expects — a {@link MiddlewareFn} whose
|
|
999
|
+
* `io.ctx` is the def's `requires` context plus `Partial<optional>` context, and
|
|
1000
|
+
* which must produce the def's provided context `M['__p']`.
|
|
1001
|
+
*
|
|
1002
|
+
* @typeParam M - the middleware def.
|
|
1003
|
+
*/
|
|
1004
|
+
type MiddlewareImplFor<M extends AnyMiddleware> = MiddlewareFn<Simplify<ListProvides<M['__req']> & Partial<ListProvides<M['__opt']>>>, M['__p']>;
|
|
1005
|
+
/**
|
|
1006
|
+
* The impl signature a loader def expects — a {@link LoaderFn} that additionally
|
|
1007
|
+
* receives the typed `value` parsed from the def's own param schema (`M['__lz']`).
|
|
1008
|
+
*
|
|
1009
|
+
* @typeParam M - the loader middleware def.
|
|
1010
|
+
*/
|
|
1011
|
+
type LoaderImplFor<M extends AnyMiddleware> = LoaderFn<Simplify<ListProvides<M['__req']> & Partial<ListProvides<M['__opt']>>>, M['__lz'], M['__p']>;
|
|
1012
|
+
/**
|
|
1013
|
+
* The impl signature for a def — {@link LoaderImplFor} for loader defs (those whose
|
|
1014
|
+
* `__isLoader` is `true`), otherwise {@link MiddlewareImplFor}.
|
|
1015
|
+
*
|
|
1016
|
+
* @typeParam M - the middleware def.
|
|
1017
|
+
*/
|
|
1018
|
+
type ImplFor<M extends AnyMiddleware> = M['__isLoader'] extends true ? LoaderImplFor<M> : MiddlewareImplFor<M>;
|
|
1019
|
+
/**
|
|
1020
|
+
* A def paired with its impl — what a package's `*.server(def, config)` binder
|
|
1021
|
+
* returns, ready to hand to `implement(api).middleware(bound)`.
|
|
1022
|
+
*
|
|
1023
|
+
* @typeParam M - the middleware def.
|
|
1024
|
+
*/
|
|
1025
|
+
interface BoundMiddleware<M extends AnyMiddleware> {
|
|
1026
|
+
readonly def: M;
|
|
1027
|
+
readonly impl: ImplFor<M>;
|
|
1028
|
+
}
|
|
1029
|
+
/** The single-key context a {@link provide} middleware contributes: `{ [name]: value }`. */
|
|
1030
|
+
type Provided<N extends string, V> = { readonly [K in N]: V };
|
|
1031
|
+
/**
|
|
1032
|
+
* The value {@link provide} returns: a middleware **def** that is also its own
|
|
1033
|
+
* {@link BoundMiddleware}. Use it directly in a spec chain (`use(svc).group(…)`,
|
|
1034
|
+
* `svc.endpoint(…)`) **and** bind it once with `implement(api).middleware(svc)`.
|
|
1035
|
+
*
|
|
1036
|
+
* The phantoms are pinned concretely (`__opt: []`, `__isLoader: false`) and `impl`
|
|
1037
|
+
* is written out (not via `ImplFor`) so the type stays finite.
|
|
1038
|
+
*
|
|
1039
|
+
* @typeParam N - the context key the value is injected under.
|
|
1040
|
+
* @typeParam V - the injected value's type (`io.ctx[name]`).
|
|
1041
|
+
*/
|
|
1042
|
+
interface ProvideMiddleware<N extends string, V> extends Middleware<Provided<N, V>, readonly [], EmptyObject> {
|
|
1043
|
+
readonly __opt: readonly [];
|
|
1044
|
+
readonly __isLoader: false;
|
|
1045
|
+
readonly __lz: z.ZodNever;
|
|
1046
|
+
readonly def: ProvideMiddleware<N, V>;
|
|
1047
|
+
readonly impl: MiddlewareFn<EmptyObject, Provided<N, V>>;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Create a middleware that **injects a typed value** onto the handler context under
|
|
1051
|
+
* `name` — the one-call form of `middleware(name, { provides: ctx<{ [name]: V }>() })`
|
|
1052
|
+
* plus its impl. Hand it a function, a service object, config, or any data, and every
|
|
1053
|
+
* endpoint whose chain includes it reads `io.ctx[name]`.
|
|
1054
|
+
*
|
|
1055
|
+
* The result is **both** the def (use it in the spec: `use(svc).group(…)` /
|
|
1056
|
+
* `svc.endpoint(…)`) and the bound def+impl (bind it once:
|
|
1057
|
+
* `implement(api).middleware(svc)`).
|
|
1058
|
+
*
|
|
1059
|
+
* @typeParam N - the context key (a string literal).
|
|
1060
|
+
* @typeParam V - the injected value's type.
|
|
1061
|
+
* @param name - the key the value is injected under (also the middleware name).
|
|
1062
|
+
* @param value - the value to inject, or a factory `(io) => value` re-run per
|
|
1063
|
+
* invocation (may be async). A **callable** `value` is treated as a factory — to
|
|
1064
|
+
* inject a bare function as the value, wrap it: `provide('fn', () => myFn)`.
|
|
1065
|
+
*
|
|
1066
|
+
* @example
|
|
1067
|
+
* ```ts
|
|
1068
|
+
* const services = provide('services', { db, mailer }); // shared.ts / spec
|
|
1069
|
+
* export const api = spec({ endpoints: { ...services.group({ sendInvite }) } });
|
|
1070
|
+
* implement(api).middleware(services); // server.ts (bind once)
|
|
1071
|
+
* // handler: ({ services }) => services.mailer.send(…)
|
|
1072
|
+
* ```
|
|
1073
|
+
*/
|
|
1074
|
+
declare function provide<const N extends string, V>(name: N, value: V | ((io: MiddlewareIO<EmptyObject>) => V | Promise<V>)): ProvideMiddleware<N, V>;
|
|
1075
|
+
/**
|
|
1076
|
+
* Middleware-level OpenAPI contributions, applied to every operation whose chain
|
|
1077
|
+
* includes the middleware.
|
|
1078
|
+
*/
|
|
1079
|
+
interface MiddlewareDoc {
|
|
1080
|
+
/** Named security schemes — merged into `components.securitySchemes` and required on each op. */
|
|
1081
|
+
readonly security?: Readonly<Record<string, Json>>;
|
|
1082
|
+
/** Patch applied to every operation whose chain includes this middleware. */
|
|
1083
|
+
readonly openapi?: (op: Record<string, Json>) => Record<string, Json>;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Expand `requires` (auto-include) and topologically order a middleware list.
|
|
1087
|
+
*
|
|
1088
|
+
* `requires` edges pull dependencies in and force them earlier; `optional` edges
|
|
1089
|
+
* only reorder middleware that are *already present*. Throws on a dependency
|
|
1090
|
+
* cycle.
|
|
1091
|
+
*
|
|
1092
|
+
* @internal
|
|
1093
|
+
*/
|
|
1094
|
+
//#endregion
|
|
1095
|
+
//#region src/endpoint.d.ts
|
|
1096
|
+
/** Endpoint-level OpenAPI metadata, plus an escape hatch over the generated operation. */
|
|
1097
|
+
interface EndpointDoc {
|
|
1098
|
+
readonly summary?: string;
|
|
1099
|
+
readonly description?: string;
|
|
1100
|
+
readonly tags?: readonly string[];
|
|
1101
|
+
readonly deprecated?: boolean;
|
|
1102
|
+
readonly operationId?: string;
|
|
1103
|
+
/** Final say over this endpoint's generated operation object. */
|
|
1104
|
+
readonly openapi?: (op: Record<string, Json>) => Record<string, Json>;
|
|
1105
|
+
}
|
|
1106
|
+
/** Event-level AsyncAPI metadata, plus an escape hatch over the generated channel. */
|
|
1107
|
+
interface EventDoc {
|
|
1108
|
+
readonly summary?: string;
|
|
1109
|
+
readonly description?: string;
|
|
1110
|
+
/** Final say over this event's generated channel object. */
|
|
1111
|
+
readonly asyncapi?: (channel: Record<string, Json>) => Record<string, Json>;
|
|
1112
|
+
}
|
|
1113
|
+
/** Spec-level final patches over the whole generated documents. */
|
|
1114
|
+
interface SpecDoc {
|
|
1115
|
+
readonly openapi?: (doc: Record<string, Json>) => Record<string, Json>;
|
|
1116
|
+
readonly asyncapi?: (doc: Record<string, Json>) => Record<string, Json>;
|
|
1117
|
+
}
|
|
1118
|
+
/** Options for a `Set-Cookie` written via the handler's `cookie()`. */
|
|
1119
|
+
interface CookieOptions {
|
|
1120
|
+
readonly path?: string;
|
|
1121
|
+
readonly domain?: string;
|
|
1122
|
+
readonly maxAge?: number;
|
|
1123
|
+
readonly expires?: Date;
|
|
1124
|
+
readonly httpOnly?: boolean;
|
|
1125
|
+
readonly secure?: boolean;
|
|
1126
|
+
readonly sameSite?: 'Strict' | 'Lax' | 'None';
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* The declarative configuration for one endpoint.
|
|
1130
|
+
*
|
|
1131
|
+
* Schemas are the single source of truth: each kind below contributes typed keys
|
|
1132
|
+
* to the endpoint's single `data` payload (path params, query, body, files), and
|
|
1133
|
+
* the kinds must be disjoint. Streaming, multi-status, typed errors, custom
|
|
1134
|
+
* method/path, and documentation are all declared here.
|
|
1135
|
+
*/
|
|
1136
|
+
interface EndpointConfig {
|
|
1137
|
+
/** Path params as a `z.object`; keys must be positioned in the path. */
|
|
1138
|
+
readonly params?: z.ZodType;
|
|
1139
|
+
/** Query params as a `z.object`. */
|
|
1140
|
+
readonly query?: z.ZodType;
|
|
1141
|
+
/** Request body — a `z.object` (merges into `data`) or any other schema (then it *is* `data`). */
|
|
1142
|
+
readonly body?: z.ZodType;
|
|
1143
|
+
/** Multipart file fields, keyed by form-field name. Declaring files forces `httpOnly`. */
|
|
1144
|
+
readonly files?: Readonly<Record<string, z.ZodType>>;
|
|
1145
|
+
/** Typed request headers (`z.object`, lowercase keys) — handler gets `headers`; never merged into `data`. */
|
|
1146
|
+
readonly headers?: z.ZodType;
|
|
1147
|
+
/** Typed request cookies (`z.object`) — handler gets `cookies`; server-side input only. */
|
|
1148
|
+
readonly cookies?: z.ZodType;
|
|
1149
|
+
/** Single success response schema. */
|
|
1150
|
+
readonly response?: z.ZodType;
|
|
1151
|
+
/** Multi-status success responses by code — handler returns `{ status, data }`, client gets a `{ status, data }` union. */
|
|
1152
|
+
readonly responses?: Readonly<Record<number, z.ZodType>>;
|
|
1153
|
+
/** Declared error responses by status — documents them and types the handler's `fail()`. */
|
|
1154
|
+
readonly errors?: Readonly<Record<number, z.ZodType>>;
|
|
1155
|
+
/** Body wire encoding (default `'json'`); `'urlencoded'` for `application/x-www-form-urlencoded` HTML forms. */
|
|
1156
|
+
readonly bodyEncoding?: 'json' | 'urlencoded';
|
|
1157
|
+
/** Typed item-stream output encoding (default `'ndjson'`); `'sse'` for `text/event-stream`. */
|
|
1158
|
+
readonly streamEncoding?: 'ndjson' | 'sse';
|
|
1159
|
+
readonly doc?: EndpointDoc;
|
|
1160
|
+
/** HTTP method (default `POST`). */
|
|
1161
|
+
readonly method?: HttpMethod;
|
|
1162
|
+
/** Custom path: a `:key` string, or a {@link path} template whose schemas join the params kind. */
|
|
1163
|
+
readonly path?: string | AnyPathTemplate;
|
|
1164
|
+
/** Explicit WebSocket id (default: the un-injected url pattern + method). */
|
|
1165
|
+
readonly ws?: string;
|
|
1166
|
+
/** Force the endpoint to be HTTP-only (no ws). */
|
|
1167
|
+
readonly httpOnly?: boolean;
|
|
1168
|
+
/**
|
|
1169
|
+
* Streaming request body. `string` → raw byte stream with that content-type
|
|
1170
|
+
* (`stream: ReadableStream<Uint8Array>`); zod schema → typed NDJSON item stream
|
|
1171
|
+
* (client passes an async iterable as `stream`, handler for-awaits validated items).
|
|
1172
|
+
*/
|
|
1173
|
+
readonly streamIn?: string | z.ZodType;
|
|
1174
|
+
/**
|
|
1175
|
+
* Streaming response. `string` → raw byte stream with that content-type
|
|
1176
|
+
* (handler returns/pipes bytes); zod schema → typed item stream over
|
|
1177
|
+
* NDJSON/SSE (handler is an async generator, client `for await`s typed items).
|
|
1178
|
+
*/
|
|
1179
|
+
readonly streamOut?: string | z.ZodType;
|
|
1180
|
+
/** Raw `streamOut` only: serve with `Content-Disposition: attachment; filename="…"`. */
|
|
1181
|
+
readonly download?: string;
|
|
1182
|
+
}
|
|
1183
|
+
/** Erased (non-generic) endpoint shape used by the runtime. @internal */
|
|
1184
|
+
interface AnyEndpoint {
|
|
1185
|
+
readonly kind: 'endpoint';
|
|
1186
|
+
readonly cfg: EndpointConfig;
|
|
1187
|
+
readonly mws: readonly AnyMiddleware[];
|
|
1188
|
+
readonly prefixes: ReadonlyArray<string | AnyPathTemplate>;
|
|
1189
|
+
readonly __ctx: object;
|
|
1190
|
+
readonly __lp: object;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* A fully-typed endpoint declaration.
|
|
1194
|
+
*
|
|
1195
|
+
* @typeParam C - the literal {@link EndpointConfig}.
|
|
1196
|
+
* @typeParam Ctx - middleware-provided context visible to the handler.
|
|
1197
|
+
* @typeParam LP - loader-param schemas in scope.
|
|
1198
|
+
* @typeParam PFX - prefix-param schemas in scope.
|
|
1199
|
+
*/
|
|
1200
|
+
interface Endpoint<C extends EndpointConfig, Ctx extends object, LP extends object, PFX extends object> extends AnyEndpoint {
|
|
1201
|
+
readonly cfg: C;
|
|
1202
|
+
readonly __ctx: Ctx;
|
|
1203
|
+
readonly __lp: LP;
|
|
1204
|
+
readonly __pfx: PFX;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Define a bare endpoint with no middleware.
|
|
1208
|
+
*
|
|
1209
|
+
* @example
|
|
1210
|
+
* ```ts
|
|
1211
|
+
* const getReport = endpoint({
|
|
1212
|
+
* method: 'GET',
|
|
1213
|
+
* path: reportPath,
|
|
1214
|
+
* response: z.object({ year: z.number(), slug: z.string() }),
|
|
1215
|
+
* })
|
|
1216
|
+
* ```
|
|
1217
|
+
*/
|
|
1218
|
+
declare function endpoint<const C extends EndpointConfig>(cfg: C & CheckCfg<C, EmptyObject, EmptyObject>): Endpoint<C, EmptyObject, EmptyObject, EmptyObject>;
|
|
1219
|
+
/** Extract `:key` param names from a literal path string. */
|
|
1220
|
+
type PathParamKeys<P extends string> = P extends `${string}:${infer R}` ? R extends `${infer K}/${infer Rest}` ? K | PathParamKeys<`/${Rest}`> : R : never;
|
|
1221
|
+
/** Keys of a `z.object` schema's input type. */
|
|
1222
|
+
type ZKeys<T> = T extends z.ZodType ? keyof z.input<T> & string : never;
|
|
1223
|
+
/** Param keys contributed by a `path` template attached to `cfg.path`. */
|
|
1224
|
+
type CfgTplKeys<C extends EndpointConfig> = Get<C, 'path'> extends {
|
|
1225
|
+
readonly __ps: infer PS;
|
|
1226
|
+
} ? keyof PS & string : never;
|
|
1227
|
+
/** Every path-param key, from every source: `cfg.params`, loaders, prefix templates, own template/string. */
|
|
1228
|
+
type AllParamKeys<C extends EndpointConfig, LP extends object, PFX extends object> = ZKeys<Get<C, 'params'>> | (keyof LP & string) | (keyof PFX & string) | CfgTplKeys<C> | (Get<C, 'path'> extends string ? PathParamKeys<Get<C, 'path'>> : never);
|
|
1229
|
+
/** Keys of an object body (empty for a non-object body). */
|
|
1230
|
+
type BodyKeys<C extends EndpointConfig> = Get<C, 'body'> extends z.ZodType ? z.input<Get<C, 'body'> & z.ZodType> extends Record<string, unknown> ? keyof z.input<Get<C, 'body'> & z.ZodType> & string : never : never;
|
|
1231
|
+
/** Whether the body is a non-object (so it *is* the data payload). */
|
|
1232
|
+
type CfgHasRawBody<C extends EndpointConfig> = Get<C, 'body'> extends z.ZodType ? z.input<Get<C, 'body'> & z.ZodType> extends Record<string, unknown> ? false : true : false;
|
|
1233
|
+
/**
|
|
1234
|
+
* Definition-time config validation, surfaced as compile errors that land on the
|
|
1235
|
+
* offending config property.
|
|
1236
|
+
*
|
|
1237
|
+
* Enforces:
|
|
1238
|
+
* - a custom path may only reference declared param keys;
|
|
1239
|
+
* - each param key is declared exactly once (own template vs prefix vs `params`);
|
|
1240
|
+
* - kinds are disjoint: query ∉ path, body ∉ path∪query, files ∉ path∪query∪body;
|
|
1241
|
+
* - a non-object body excludes params/query/files (it *is* the data).
|
|
1242
|
+
*
|
|
1243
|
+
* Errors are emitted as `readonly ['message', Keys]` tuples (not plain strings)
|
|
1244
|
+
* so that conflicting messages on the same property don't collapse to `never`.
|
|
1245
|
+
* Cross-prefix position coverage is additionally validated at `spec()` time.
|
|
1246
|
+
*/
|
|
1247
|
+
type CheckCfg<C extends EndpointConfig, LP extends object, PFX extends object> = (Get<C, 'path'> extends string ? [PathParamKeys<Get<C, 'path'>>] extends [ZKeys<Get<C, 'params'>> | (keyof LP & string) | (keyof PFX & string)] ? EmptyObject : {
|
|
1248
|
+
readonly path: readonly ['custom path references undeclared param keys:', Exclude<PathParamKeys<Get<C, 'path'>>, ZKeys<Get<C, 'params'>> | (keyof LP & string) | (keyof PFX & string)>];
|
|
1249
|
+
} : EmptyObject) & ([CfgTplKeys<C> & (keyof PFX | keyof LP | ZKeys<Get<C, 'params'>>)] extends [never] ? EmptyObject : {
|
|
1250
|
+
readonly path: readonly ['path template re-declares param keys:', CfgTplKeys<C> & (keyof PFX | keyof LP | ZKeys<Get<C, 'params'>>) & string];
|
|
1251
|
+
}) & ([ZKeys<Get<C, 'params'>> & (keyof PFX | keyof LP)] extends [never] ? EmptyObject : {
|
|
1252
|
+
readonly params: readonly ['params re-declares keys owned by a loader or prefix:', ZKeys<Get<C, 'params'>> & (keyof PFX | keyof LP) & string];
|
|
1253
|
+
}) & ([ZKeys<Get<C, 'query'>> & AllParamKeys<C, LP, PFX>] extends [never] ? EmptyObject : {
|
|
1254
|
+
readonly query: readonly ['query keys collide with path params:', ZKeys<Get<C, 'query'>> & AllParamKeys<C, LP, PFX>];
|
|
1255
|
+
}) & ([BodyKeys<C> & (AllParamKeys<C, LP, PFX> | ZKeys<Get<C, 'query'>>)] extends [never] ? EmptyObject : {
|
|
1256
|
+
readonly body: readonly ['body keys collide with path/query:', BodyKeys<C> & (AllParamKeys<C, LP, PFX> | ZKeys<Get<C, 'query'>>)];
|
|
1257
|
+
}) & ([(keyof Get<C, 'files'> & string) & (AllParamKeys<C, LP, PFX> | ZKeys<Get<C, 'query'>> | BodyKeys<C>)] extends [never] ? EmptyObject : {
|
|
1258
|
+
readonly files: readonly ['files keys collide with path/query/body:', (keyof Get<C, 'files'> & string) & (AllParamKeys<C, LP, PFX> | ZKeys<Get<C, 'query'>> | BodyKeys<C>)];
|
|
1259
|
+
}) & (CfgHasRawBody<C> extends true ? [AllParamKeys<C, LP, PFX> | ZKeys<Get<C, 'query'>> | (keyof Get<C, 'files'> & string)] extends [never] ? EmptyObject : {
|
|
1260
|
+
readonly body: readonly ['a non-object body is the entire data payload — params/query/files are not allowed alongside it'];
|
|
1261
|
+
} : EmptyObject);
|
|
1262
|
+
/** Configuration for a server-pushed event channel. */
|
|
1263
|
+
interface EventConfig {
|
|
1264
|
+
/** Channel params as a `z.object` — subscriptions are keyed by these. */
|
|
1265
|
+
readonly params?: z.ZodType;
|
|
1266
|
+
/** Event payload schema. */
|
|
1267
|
+
readonly data: z.ZodType;
|
|
1268
|
+
/** Middleware chain that must pass before a client may subscribe. */
|
|
1269
|
+
readonly guard?: readonly AnyMiddleware[];
|
|
1270
|
+
/** Explicit WebSocket channel id (default: the event name). */
|
|
1271
|
+
readonly ws?: string;
|
|
1272
|
+
readonly doc?: EventDoc;
|
|
1273
|
+
}
|
|
1274
|
+
/** The shape passed to {@link spec}. */
|
|
1275
|
+
interface SpecShape {
|
|
1276
|
+
readonly endpoints: Readonly<Record<string, AnyEndpoint>>;
|
|
1277
|
+
readonly events?: Readonly<Record<string, EventConfig>>;
|
|
1278
|
+
readonly doc?: SpecDoc;
|
|
1279
|
+
}
|
|
1280
|
+
/** Any normalized spec. */
|
|
1281
|
+
type AnySpec = SpecShape;
|
|
1282
|
+
/** Extract the events record of a spec (or `{}` when it has none). */
|
|
1283
|
+
type EventsOf<S extends AnySpec> = S['events'] extends Readonly<Record<string, EventConfig>> ? S['events'] : EmptyObject;
|
|
1284
|
+
/**
|
|
1285
|
+
* Finalize a set of endpoints + events into a spec, validating every endpoint at
|
|
1286
|
+
* definition time.
|
|
1287
|
+
*
|
|
1288
|
+
* Beyond the compile-time {@link CheckCfg} guarantees, this performs runtime
|
|
1289
|
+
* sanity checks (flag exclusivity, kind shapes) and full
|
|
1290
|
+
* {@link normalizeEndpoint | path/coverage/disjointness} validation — throwing
|
|
1291
|
+
* immediately on any violation so misconfiguration fails at module init.
|
|
1292
|
+
*
|
|
1293
|
+
* @returns the same spec object, now type-branded and validated.
|
|
1294
|
+
*/
|
|
1295
|
+
declare function spec<const S extends SpecShape>(spec: S): S;
|
|
1296
|
+
/**
|
|
1297
|
+
* Global-registry symbol under which {@link spec} stashes its lazy
|
|
1298
|
+
* {@link manifestFromSpec} builder. Global (`Symbol.for`) so consumers — notably
|
|
1299
|
+
* the zod-free `client` — can read it off a spec value **without importing this
|
|
1300
|
+
* module**, so a manifest-only bundle never pulls in the deriver or zod.
|
|
1301
|
+
*
|
|
1302
|
+
* @internal
|
|
1303
|
+
*/
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Derive the zod-free {@link Manifest} from a spec — exactly the routing data
|
|
1307
|
+
* `app.manifest()` returns, computed purely from the endpoint/event definitions.
|
|
1308
|
+
* Used by {@link server} and stamped (cached) onto every spec by {@link spec}, so
|
|
1309
|
+
* {@link client} can take a spec directly.
|
|
1310
|
+
*
|
|
1311
|
+
* This runs the zod-bearing {@link normalizeEndpoint}, so importing it — or
|
|
1312
|
+
* handing a spec to the client — brings zod into the bundle. Pass a prebuilt
|
|
1313
|
+
* manifest instead to keep a frontend schema-free.
|
|
1314
|
+
*/
|
|
1315
|
+
declare function manifestFromSpec(spec: AnySpec): Manifest;
|
|
1316
|
+
/** Read the keys of a `z.object` schema, or `null` for any other (or absent) schema. @internal */
|
|
1317
|
+
|
|
1318
|
+
/** The fully-resolved runtime description of one endpoint. @internal */
|
|
1319
|
+
interface NormalizedEp {
|
|
1320
|
+
readonly name: string;
|
|
1321
|
+
readonly def: AnyEndpoint;
|
|
1322
|
+
readonly method: HttpMethod;
|
|
1323
|
+
readonly parts: readonly PathPart[];
|
|
1324
|
+
readonly path: string;
|
|
1325
|
+
/** Explicit ws id only — the default ws identity is method + path pattern. */
|
|
1326
|
+
readonly ws: string | null;
|
|
1327
|
+
readonly wsEligible: boolean;
|
|
1328
|
+
readonly httpOnly: boolean;
|
|
1329
|
+
readonly streamInCt: string | null;
|
|
1330
|
+
readonly itemsIn: boolean;
|
|
1331
|
+
readonly streamOutCt: string | null;
|
|
1332
|
+
readonly items: boolean;
|
|
1333
|
+
readonly sse: boolean;
|
|
1334
|
+
readonly multi: boolean;
|
|
1335
|
+
readonly bodyEnc: 'json' | 'urlencoded' | null;
|
|
1336
|
+
readonly p: readonly string[];
|
|
1337
|
+
readonly q: readonly string[];
|
|
1338
|
+
readonly b: readonly string[] | null;
|
|
1339
|
+
readonly bRaw: boolean;
|
|
1340
|
+
readonly f: readonly string[];
|
|
1341
|
+
readonly loaders: ReadonlyMap<string, z.ZodType>;
|
|
1342
|
+
/** Per-key schemas from own + prefix templates. */
|
|
1343
|
+
readonly tplSchemas: ReadonlyMap<string, z.ZodType>;
|
|
1344
|
+
readonly chain: readonly AnyMiddleware[];
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Resolve an endpoint declaration into a {@link NormalizedEp}: assemble its path
|
|
1348
|
+
* parts (prefixes → own path → default), verify exact-once param declaration and
|
|
1349
|
+
* positioning, and enforce kind disjointness. Throws on any violation.
|
|
1350
|
+
*
|
|
1351
|
+
* @internal
|
|
1352
|
+
*/
|
|
1353
|
+
//#endregion
|
|
1354
|
+
//#region src/caller.d.ts
|
|
1355
|
+
/** Deterministic JSON (sorted keys) — the default cache key for a call's `data`. */
|
|
1356
|
+
declare const stableStringify: (value: unknown) => string;
|
|
1357
|
+
/** A synchronous key/value store backing a {@link ClientCache} (memory or a `Storage`). */
|
|
1358
|
+
interface KVStore {
|
|
1359
|
+
get(key: string): string | undefined;
|
|
1360
|
+
set(key: string, value: string): void;
|
|
1361
|
+
delete(key: string): boolean;
|
|
1362
|
+
keys(): Iterable<string>;
|
|
1363
|
+
}
|
|
1364
|
+
/** Where a cache persists: a built-in backend name or your own {@link KVStore}. */
|
|
1365
|
+
type CacheStoreSpec = 'memory' | 'session' | 'local' | KVStore;
|
|
1366
|
+
/** Options for {@link createClientCache}. */
|
|
1367
|
+
interface ClientCacheOptions {
|
|
1368
|
+
/** Backend (default `'memory'`). `'local'`/`'session'` fall back to memory where `Storage` is unavailable (SSR). */
|
|
1369
|
+
readonly store?: CacheStoreSpec;
|
|
1370
|
+
/** Max entries before LRU eviction (default 500). */
|
|
1371
|
+
readonly max?: number;
|
|
1372
|
+
/** Default time-to-live in ms (per-`set` `ttl` overrides; omitted ⇒ no expiry). */
|
|
1373
|
+
readonly ttl?: number;
|
|
1374
|
+
/** Key namespace for a `Storage` backend (default `'ayepi:cache:'`). */
|
|
1375
|
+
readonly prefix?: string;
|
|
1376
|
+
/** Clock injection (tests). */
|
|
1377
|
+
readonly now?: () => number;
|
|
1378
|
+
}
|
|
1379
|
+
/** What a {@link ClientCache.read} returns: the value plus whether it is fresh or stale (SWR). */
|
|
1380
|
+
interface CacheHit {
|
|
1381
|
+
readonly value: unknown;
|
|
1382
|
+
/** `true` when past `ttl` but within the stale-while-revalidate window. */
|
|
1383
|
+
readonly stale: boolean;
|
|
1384
|
+
}
|
|
1385
|
+
/** A tag-aware LRU cache shared by a client's callers. */
|
|
1386
|
+
interface ClientCache {
|
|
1387
|
+
/** Read a key — `undefined` when missing or fully expired (past the stale window). */
|
|
1388
|
+
read(key: string): CacheHit | undefined;
|
|
1389
|
+
/** Cache a JSON-serializable value (no-op for non-serializable values). */
|
|
1390
|
+
write(key: string, value: unknown, opts?: {
|
|
1391
|
+
ttl?: number;
|
|
1392
|
+
staleWhileRevalidate?: number;
|
|
1393
|
+
tags?: readonly string[];
|
|
1394
|
+
}): void;
|
|
1395
|
+
/** Remove one key. */
|
|
1396
|
+
remove(key: string): void;
|
|
1397
|
+
/** Remove every key matching `pred` (e.g. a caller's prefix). Returns the count removed. */
|
|
1398
|
+
removeWhere(pred: (key: string) => boolean): number;
|
|
1399
|
+
/** Remove every entry carrying any of `tags`. Returns the count removed. */
|
|
1400
|
+
invalidateTags(tags: readonly string[]): number;
|
|
1401
|
+
/** Drop everything. */
|
|
1402
|
+
clear(): void;
|
|
1403
|
+
}
|
|
1404
|
+
/** Create a tag-aware LRU cache over the chosen backend. */
|
|
1405
|
+
declare function createClientCache(opts?: ClientCacheOptions): ClientCache;
|
|
1406
|
+
/** Shared per-client caller state: a cache per distinct store, with cross-store tag invalidation. */
|
|
1407
|
+
interface CallerContext {
|
|
1408
|
+
/** The cache for a store spec (memoized; the default store when omitted). */
|
|
1409
|
+
cacheFor(store: CacheStoreSpec | undefined): ClientCache;
|
|
1410
|
+
/** Invalidate `tags` across **every** cache this client has created. */
|
|
1411
|
+
invalidateTags(tags: readonly string[]): void;
|
|
1412
|
+
}
|
|
1413
|
+
/** Create the shared caller context. `defaults` seed each store's cache (`max`/`ttl`/`store`). */
|
|
1414
|
+
declare function createCallerContext(defaults?: ClientCacheOptions): CallerContext;
|
|
1415
|
+
/** Tags for a caller — a static list, or derived from the call's data (+ result, for invalidation). */
|
|
1416
|
+
type Tagger<E extends AnyEndpoint> = readonly string[] | ((data: ClientData<E>, result: unknown) => readonly string[]);
|
|
1417
|
+
/** Per-caller caching config (or `true` for defaults). */
|
|
1418
|
+
interface CallerCacheConfig<E extends AnyEndpoint> {
|
|
1419
|
+
/** Entry time-to-live (ms); omitted ⇒ the client cache default (often no expiry). */
|
|
1420
|
+
readonly ttl?: number;
|
|
1421
|
+
/** Extra ms after `ttl` during which a stale value is served while it refetches in the background. */
|
|
1422
|
+
readonly staleWhileRevalidate?: number;
|
|
1423
|
+
/** Derive the cache key from the call's data (default: stable JSON of the data). */
|
|
1424
|
+
readonly key?: (data: ClientData<E>) => string;
|
|
1425
|
+
/** Tags attached to this caller's cached entries (for group invalidation). */
|
|
1426
|
+
readonly tags?: Tagger<E>;
|
|
1427
|
+
/** Which client cache to use (default the client's shared memory cache). */
|
|
1428
|
+
readonly store?: CacheStoreSpec;
|
|
1429
|
+
}
|
|
1430
|
+
/** Per-caller debounce config (or a plain `wait` in ms). */
|
|
1431
|
+
interface CallerDebounceConfig<E extends AnyEndpoint> {
|
|
1432
|
+
/** Quiet period before the trailing call fires (ms). */
|
|
1433
|
+
readonly wait: number;
|
|
1434
|
+
/** Force a call after at most this long since the first queued call (ms). */
|
|
1435
|
+
readonly maxWait?: number;
|
|
1436
|
+
/** Also fire immediately on the leading edge of a burst. */
|
|
1437
|
+
readonly leading?: boolean;
|
|
1438
|
+
/** Merge the data of every debounced call into one call's data. */
|
|
1439
|
+
readonly accumulate?: (dataList: ClientData<E>[]) => ClientData<E>;
|
|
1440
|
+
/** Fan the single accumulated result back to each queued caller (default: all get the same result). */
|
|
1441
|
+
readonly spread?: (result: unknown, dataList: ClientData<E>[]) => unknown[];
|
|
1442
|
+
}
|
|
1443
|
+
/** Per-caller rate-limit config (token bucket). */
|
|
1444
|
+
interface CallerRateLimitConfig {
|
|
1445
|
+
/** Bucket capacity (max calls per window). */
|
|
1446
|
+
readonly limit: number;
|
|
1447
|
+
/** Window/refill period (ms). */
|
|
1448
|
+
readonly window: number;
|
|
1449
|
+
/** Over budget: `'wait'` for a token (default), `'drop'` (reject), or `'throw'`. */
|
|
1450
|
+
readonly onLimit?: 'wait' | 'drop' | 'throw';
|
|
1451
|
+
}
|
|
1452
|
+
/** Per-caller retry config — a lightweight exponential backoff (a client-side subset of `@ayepi/core`'s retry). */
|
|
1453
|
+
interface CallerRetryConfig {
|
|
1454
|
+
/** Total tries including the first (default 3). */
|
|
1455
|
+
readonly attempts?: number;
|
|
1456
|
+
/** First backoff delay in ms (default 200). */
|
|
1457
|
+
readonly base?: number;
|
|
1458
|
+
/** Backoff growth multiplier (default 2). */
|
|
1459
|
+
readonly factor?: number;
|
|
1460
|
+
/** Maximum backoff delay in ms (default 30000). */
|
|
1461
|
+
readonly max?: number;
|
|
1462
|
+
/** Randomization fraction `0..1` subtracted from each delay (default 0.5). */
|
|
1463
|
+
readonly jitter?: number;
|
|
1464
|
+
}
|
|
1465
|
+
/** Options for {@link ApiClient.caller}. Each enabled feature is applied as its own wrapper layer. */
|
|
1466
|
+
interface CallerOptions<E extends AnyEndpoint> {
|
|
1467
|
+
/** Cache responses keyed by the call's data (TTL, tags, stale-while-revalidate, store). */
|
|
1468
|
+
readonly cache?: boolean | CallerCacheConfig<E>;
|
|
1469
|
+
/** Debounce rapid calls (with optional accumulation into one call). */
|
|
1470
|
+
readonly debounce?: number | CallerDebounceConfig<E>;
|
|
1471
|
+
/** Token-bucket rate limit. */
|
|
1472
|
+
readonly rateLimit?: CallerRateLimitConfig;
|
|
1473
|
+
/** Retry a failed call with exponential backoff ({@link CallerRetryConfig}). */
|
|
1474
|
+
readonly retry?: CallerRetryConfig;
|
|
1475
|
+
/** Only deliver the most recent call's response — supersede (abort) older in-flight calls. */
|
|
1476
|
+
readonly lastOnly?: boolean;
|
|
1477
|
+
/** Coalesce concurrent identical calls into one in-flight request. */
|
|
1478
|
+
readonly dedupe?: boolean;
|
|
1479
|
+
/** Tags this caller invalidates (clearing matching entries in **other** callers' caches). */
|
|
1480
|
+
readonly invalidates?: Tagger<E>;
|
|
1481
|
+
/** When to invalidate (`'success'` default). */
|
|
1482
|
+
readonly invalidateOn?: 'success' | 'start' | 'both';
|
|
1483
|
+
/** Fired when a call is admitted (before any wait). */
|
|
1484
|
+
readonly onStart?: (data: ClientData<E>) => void;
|
|
1485
|
+
/** Fired when a call resolves. */
|
|
1486
|
+
readonly onSuccess?: (result: unknown, data: ClientData<E>) => void;
|
|
1487
|
+
/** Fired when a call rejects. */
|
|
1488
|
+
readonly onError?: (error: unknown, data: ClientData<E>) => void;
|
|
1489
|
+
/** Fired when a call settles (either way). */
|
|
1490
|
+
readonly onSettled?: (data: ClientData<E>) => void;
|
|
1491
|
+
}
|
|
1492
|
+
/** A configured caller for one endpoint — `call` applies the policy; plus control + status. */
|
|
1493
|
+
interface Caller<E extends AnyEndpoint> {
|
|
1494
|
+
/** Invoke the endpoint through the policy layers (same arguments as `client.call`). */
|
|
1495
|
+
call(...args: CallArgs<E>): CallReturn<E>;
|
|
1496
|
+
/** Abort in-flight calls and drop any pending debounced calls. */
|
|
1497
|
+
cancel(): void;
|
|
1498
|
+
/** Clear this caller's own cached entries. */
|
|
1499
|
+
invalidate(): void;
|
|
1500
|
+
/** How many calls are currently in flight (awaiting a result, including debounce waits). */
|
|
1501
|
+
readonly pending: number;
|
|
1502
|
+
}
|
|
1503
|
+
/** Rejected when a rate-limited caller is over budget with `onLimit: 'drop'`/`'throw'`. */
|
|
1504
|
+
declare class CallerRateLimited extends Error {
|
|
1505
|
+
constructor(message?: string);
|
|
1506
|
+
}
|
|
1507
|
+
/** Loose view of the manifest endpoint the caller needs (kept structural to avoid a hard import). */
|
|
1508
|
+
//#endregion
|
|
1509
|
+
//#region src/client.d.ts
|
|
1510
|
+
/** A minimal ws transport the client drives: send a frame, receive frames. */
|
|
1511
|
+
interface ClientWs {
|
|
1512
|
+
/** Send a serialized frame to the server. */
|
|
1513
|
+
send(frame: string): void;
|
|
1514
|
+
/** Register the handler the transport calls for each inbound frame. */
|
|
1515
|
+
onMessage(cb: (frame: string) => void): void;
|
|
1516
|
+
}
|
|
1517
|
+
/** Options for {@link client}. */
|
|
1518
|
+
interface ClientOptions {
|
|
1519
|
+
/** Base URL for HTTP requests (with or without a trailing slash). */
|
|
1520
|
+
readonly baseUrl: string;
|
|
1521
|
+
/**
|
|
1522
|
+
* What the client routes from — either is accepted:
|
|
1523
|
+
*
|
|
1524
|
+
* - a zod-free {@link Manifest} (from `app.manifest()` / {@link manifestFromSpec}, e.g.
|
|
1525
|
+
* imported as a prebuilt value) — keeps a frontend bundle **schema-free**; or
|
|
1526
|
+
* - the **spec** itself — convenient when slimming the bundle isn't a concern; the
|
|
1527
|
+
* client derives the manifest from it. Because the spec holds zod, this pulls zod
|
|
1528
|
+
* into the bundle.
|
|
1529
|
+
*
|
|
1530
|
+
* The slim path stays zod-free purely by tree-shaking: a manifest value carries no
|
|
1531
|
+
* derivation code, and the spec's (zod-bearing) deriver is only reached when a spec is
|
|
1532
|
+
* actually passed.
|
|
1533
|
+
*/
|
|
1534
|
+
readonly manifest: Manifest | AnySpec;
|
|
1535
|
+
/** Default headers, static or computed per request (e.g. a fresh auth token). */
|
|
1536
|
+
readonly headers?: Readonly<Record<string, string>> | (() => Readonly<Record<string, string>>);
|
|
1537
|
+
/** Override `fetch` (tests / in-memory wiring). */
|
|
1538
|
+
readonly fetchImpl?: (req: Request) => Promise<Response>;
|
|
1539
|
+
/** Defaults for the per-client {@link caller} caches (`max`/`ttl`/default `store`). */
|
|
1540
|
+
readonly cache?: ClientCacheOptions;
|
|
1541
|
+
/** WebSocket transport; required for ws calls and event subscriptions. */
|
|
1542
|
+
readonly ws?: ClientWs;
|
|
1543
|
+
/** Preferred transport for dual endpoints (default `'http'`). */
|
|
1544
|
+
readonly prefer?: 'http' | 'ws';
|
|
1545
|
+
/**
|
|
1546
|
+
* Opt-in client-side validation: pass the spec to parse responses/items with
|
|
1547
|
+
* their zod schemas as they arrive. Omit to keep the frontend bundle zod-free
|
|
1548
|
+
* (types still assert shapes statically).
|
|
1549
|
+
*/
|
|
1550
|
+
readonly validate?: AnySpec;
|
|
1551
|
+
}
|
|
1552
|
+
/** Endpoint names addressable as a plain `GET` URL (browser navigation / `<a href>` / streamed downloads). */
|
|
1553
|
+
type GetUrlKeys<S extends AnySpec> = { [K in keyof S['endpoints']]: Get<S['endpoints'][K]['cfg'], 'method'> extends 'GET' ? K : never }[keyof S['endpoints']] & string;
|
|
1554
|
+
/** The typed client surface for a spec `S`. */
|
|
1555
|
+
interface ApiClient<S extends AnySpec> {
|
|
1556
|
+
/** Call an endpoint. Arguments and return type are derived per endpoint. */
|
|
1557
|
+
call<K extends keyof S['endpoints'] & string>(name: K, ...args: CallArgs<S['endpoints'][K]>): CallReturn<S['endpoints'][K]>;
|
|
1558
|
+
/**
|
|
1559
|
+
* A **caller**: `call` wrapped with stateful client-side policy — caching (TTL/tags/SWR/storage),
|
|
1560
|
+
* debounce (with accumulation), rate limiting, last-response-only, in-flight dedupe, retry, and
|
|
1561
|
+
* lifecycle hooks. Caches are shared across a client's callers, so a mutating caller's tag
|
|
1562
|
+
* invalidation clears other callers' cached reads.
|
|
1563
|
+
*/
|
|
1564
|
+
caller<K extends keyof S['endpoints'] & string>(name: K, options?: CallerOptions<S['endpoints'][K]>): Caller<S['endpoints'][K]>;
|
|
1565
|
+
/**
|
|
1566
|
+
* Build the full URL for a `GET` endpoint — hand it to the browser
|
|
1567
|
+
* (`location`, `<a href>`, `window.open`) for natively stream-downloaded
|
|
1568
|
+
* responses (e.g. zip exports declared with `download:`).
|
|
1569
|
+
*/
|
|
1570
|
+
url<K extends GetUrlKeys<S>>(name: K, ...args: [keyof ClientData<S['endpoints'][K]>] extends [never] ? [] : [data: ClientData<S['endpoints'][K]>]): string;
|
|
1571
|
+
/** Subscribe to a server-pushed event; returns an unsubscribe function. */
|
|
1572
|
+
on<K extends keyof EventsOf<S> & string>(name: K, ...args: Get<EventsOf<S>[K] & EventConfig, 'params'> extends z.ZodType ? [params: z.input<Get<EventsOf<S>[K] & EventConfig, 'params'> & z.ZodType>, cb: (data: z.output<(EventsOf<S>[K] & EventConfig)['data']>) => void] : [cb: (data: z.output<(EventsOf<S>[K] & EventConfig)['data']>) => void]): () => void;
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Create a typed client from a {@link Manifest}.
|
|
1576
|
+
*
|
|
1577
|
+
* @typeParam S - the spec type, used purely for inference (no runtime schemas).
|
|
1578
|
+
*
|
|
1579
|
+
* @example
|
|
1580
|
+
* ```ts
|
|
1581
|
+
* const sdk = client<typeof api>({ baseUrl, manifest, ws })
|
|
1582
|
+
* const user = await sdk.call('getUser', { id: 'u1' }) // fully typed
|
|
1583
|
+
* for await (const row of sdk.call('streamRows', { n: 4 })) … // typed item stream
|
|
1584
|
+
* const off = sdk.on('jobProgress', { jobId }, (d) => …) // typed event
|
|
1585
|
+
* ```
|
|
1586
|
+
*/
|
|
1587
|
+
declare function client<S extends AnySpec>(opts: ClientOptions): ApiClient<S>;
|
|
1588
|
+
//#endregion
|
|
1589
|
+
//#region src/ws-transport.d.ts
|
|
1590
|
+
/** A message-ish event from a `WebSocket` (only `data` is read). */
|
|
1591
|
+
interface WsMessageEvent {
|
|
1592
|
+
readonly data?: unknown;
|
|
1593
|
+
}
|
|
1594
|
+
/** The minimal `WebSocket` surface this transport drives. */
|
|
1595
|
+
interface WebSocketLike {
|
|
1596
|
+
send(data: string): void;
|
|
1597
|
+
close(code?: number, reason?: string): void;
|
|
1598
|
+
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: WsMessageEvent) => void): void;
|
|
1599
|
+
}
|
|
1600
|
+
/** A `WebSocket` constructor (the global one, or `ws` in Node). */
|
|
1601
|
+
type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
|
|
1602
|
+
/** Connection state surfaced by {@link WsTransport.state} and `onStateChange`. */
|
|
1603
|
+
type WsState = 'closed' | 'connecting' | 'open';
|
|
1604
|
+
/** Reconnect backoff tuning. Delay for attempt `n` is `min(max, initial * factor^n)`, optionally jittered. */
|
|
1605
|
+
interface BackoffOptions {
|
|
1606
|
+
/** First retry delay (default 500 ms). */
|
|
1607
|
+
readonly initial?: number;
|
|
1608
|
+
/** Maximum retry delay (default 30 000 ms). */
|
|
1609
|
+
readonly max?: number;
|
|
1610
|
+
/** Growth factor per attempt (default 2). */
|
|
1611
|
+
readonly factor?: number;
|
|
1612
|
+
/** Apply random jitter in `[delay/2, delay]` (default `true`). */
|
|
1613
|
+
readonly jitter?: boolean;
|
|
1614
|
+
}
|
|
1615
|
+
/** Heartbeat tuning. */
|
|
1616
|
+
interface HeartbeatOptions {
|
|
1617
|
+
/** How often to send `{ ping: true }` (default 30 000 ms). */
|
|
1618
|
+
readonly interval?: number;
|
|
1619
|
+
/** How long to wait for `{ pong: true }` before force-reconnecting (default 10 000 ms). */
|
|
1620
|
+
readonly timeout?: number;
|
|
1621
|
+
}
|
|
1622
|
+
/** Options for {@link wsTransport}. */
|
|
1623
|
+
interface WsTransportOptions {
|
|
1624
|
+
/**
|
|
1625
|
+
* Subprotocols passed to the `WebSocket` constructor — a fixed value, or a
|
|
1626
|
+
* function **resolved at each (re)connect**. Use the function form to carry a
|
|
1627
|
+
* value that changes over time (e.g. an auth token as a subprotocol).
|
|
1628
|
+
*/
|
|
1629
|
+
readonly protocols?: string | string[] | (() => string | string[] | undefined);
|
|
1630
|
+
/** `WebSocket` constructor to use (defaults to the global `WebSocket`; pass `ws` in Node). */
|
|
1631
|
+
readonly WebSocket?: WebSocketCtor;
|
|
1632
|
+
/** What to do with non-subscription frames sent while disconnected (default `'fail'`). */
|
|
1633
|
+
readonly whileDisconnected?: 'queue' | 'fail';
|
|
1634
|
+
/** Reconnect backoff tuning. */
|
|
1635
|
+
readonly backoff?: BackoffOptions;
|
|
1636
|
+
/** Heartbeat tuning, or `false` to disable (default enabled). */
|
|
1637
|
+
readonly heartbeat?: HeartbeatOptions | false;
|
|
1638
|
+
/** Give up after this many consecutive failed reconnects (default `Infinity`). */
|
|
1639
|
+
readonly maxRetries?: number;
|
|
1640
|
+
/** Notified on every connection-state change. */
|
|
1641
|
+
readonly onStateChange?: (state: WsState) => void;
|
|
1642
|
+
/** Notified on socket/construction errors. */
|
|
1643
|
+
readonly onError?: (error: unknown) => void;
|
|
1644
|
+
}
|
|
1645
|
+
/** A {@link ClientWs} with explicit lifecycle control. */
|
|
1646
|
+
interface WsTransport extends ClientWs {
|
|
1647
|
+
/** Open the connection now (otherwise it opens lazily on first `send`). */
|
|
1648
|
+
connect(): void;
|
|
1649
|
+
/** Close permanently and stop reconnecting. */
|
|
1650
|
+
close(): void;
|
|
1651
|
+
/** Current connection state. */
|
|
1652
|
+
readonly state: WsState;
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Create a resilient {@link WsTransport} for {@link client}'s `ws` option.
|
|
1656
|
+
*
|
|
1657
|
+
* @param url - the WebSocket URL (e.g. `wss://host/ws`), or a function returning
|
|
1658
|
+
* it. The function form is **resolved at each (re)connect**, so it's the place
|
|
1659
|
+
* to inject auth that isn't known up front — e.g. a token as a query param:
|
|
1660
|
+
* `wsTransport(() => \`wss://host/ws?access_token=${getToken()}\`)`. (Browsers
|
|
1661
|
+
* can't set headers on a ws handshake, so the token rides the URL or a subprotocol.)
|
|
1662
|
+
* @param opts - reconnect / heartbeat / policy tuning.
|
|
1663
|
+
*/
|
|
1664
|
+
declare function wsTransport(url: string | (() => string), opts?: WsTransportOptions): WsTransport;
|
|
1665
|
+
//#endregion
|
|
1666
|
+
//#region src/errors.d.ts
|
|
1667
|
+
/**
|
|
1668
|
+
* # Errors
|
|
1669
|
+
*
|
|
1670
|
+
* The error envelope used on both sides of the wire. This module is deliberately
|
|
1671
|
+
* **zod-free** so it can be imported by the browser client without pulling zod
|
|
1672
|
+
* into the bundle.
|
|
1673
|
+
*
|
|
1674
|
+
* @module
|
|
1675
|
+
*/
|
|
1676
|
+
/**
|
|
1677
|
+
* A transport-level API error.
|
|
1678
|
+
*
|
|
1679
|
+
* Thrown server-side to short-circuit a request with a status + machine code,
|
|
1680
|
+
* and re-constructed client-side from an HTTP error envelope (`{ error: { code,
|
|
1681
|
+
* message } }`) or a ws response frame whose `$status` is not 2xx (`$error`/`$code`
|
|
1682
|
+
* + an optional typed `data` body), so the same `instanceof ApiError` check works
|
|
1683
|
+
* everywhere.
|
|
1684
|
+
*
|
|
1685
|
+
* @example
|
|
1686
|
+
* ```ts
|
|
1687
|
+
* try {
|
|
1688
|
+
* await sdk.call('getUser', { id: 'nope' })
|
|
1689
|
+
* } catch (err) {
|
|
1690
|
+
* if (err instanceof ApiError && err.status === 404) showNotFound()
|
|
1691
|
+
* }
|
|
1692
|
+
* ```
|
|
1693
|
+
*/
|
|
1694
|
+
declare class ApiError extends Error {
|
|
1695
|
+
readonly status: number;
|
|
1696
|
+
readonly code: string;
|
|
1697
|
+
readonly data?: unknown | undefined;
|
|
1698
|
+
/**
|
|
1699
|
+
* @param status HTTP (or ws-mapped) status code.
|
|
1700
|
+
* @param code Stable machine-readable error code (e.g. `'UNAUTHORIZED'`).
|
|
1701
|
+
* @param message Optional human-readable message; defaults to `code`.
|
|
1702
|
+
* @param data Optional structured payload — the parsed error body for
|
|
1703
|
+
* declared typed errors, or the raw envelope otherwise.
|
|
1704
|
+
*/
|
|
1705
|
+
constructor(status: number, code: string, message?: string, data?: unknown | undefined);
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Internal control-flow signal thrown by a handler's `fail()`.
|
|
1709
|
+
*
|
|
1710
|
+
* Carries an **already-validated** declared-error body so the server can emit it
|
|
1711
|
+
* verbatim with the declared status. Not part of the public surface.
|
|
1712
|
+
*
|
|
1713
|
+
* @internal
|
|
1714
|
+
*/
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* Construct an {@link ApiError} to `throw` from a handler or middleware.
|
|
1718
|
+
*
|
|
1719
|
+
* @example
|
|
1720
|
+
* ```ts
|
|
1721
|
+
* const auth = middleware('auth', async (io) => {
|
|
1722
|
+
* if (!io.req.headers.get('authorization')) throw reject(401, 'UNAUTHORIZED')
|
|
1723
|
+
* return io.next()
|
|
1724
|
+
* })
|
|
1725
|
+
* ```
|
|
1726
|
+
*/
|
|
1727
|
+
declare function reject(status: number, code: string, message?: string): ApiError;
|
|
1728
|
+
//#endregion
|
|
1729
|
+
export { LoaderImplFor as $, PathTemplate as $t, Tagger as A, server as At, EndpointDoc as B, EmitArgs as Bt, CallerOptions as C, LocalClient as Ct, ClientCache as D, WsConn as Dt, CallerRetryConfig as E, ServerOptions as Et, AnySpec as F, CallArgs as Ft, SpecShape as G, HandlerReturn as Gt, EventDoc as H, FailFn as Ht, CheckCfg as I, CallOpts as It, spec as J, UploadProgress as Jt, endpoint as K, IsHttpOnly as Kt, CookieOptions as L, CallOptsBase as Lt, createClientCache as M, asyncapiHtml as Mt, stableStringify as N, redocHtml as Nt, ClientCacheOptions as O, implement as Ot, AnyEndpoint as P, swaggerHtml as Pt, LoaderFn as Q, PathPart as Qt, Endpoint as R, CallReturn as Rt, CallerDebounceConfig as S, LocalCallOptions as St, CallerRateLimited as T, Server as Tt, EventsOf as U, HandlerFor as Ut, EventConfig as V, EmitFn as Vt, SpecDoc as W, HandlerPayload as Wt, BoundMiddleware as X, localBroker as Xt, AnyMiddleware as Y, Broker as Yt, ImplFor as Z, AnyPathTemplate as Zt, CacheHit as _, middleware as _t, WebSocketCtor as a, HttpMethod as an, MiddlewareIO as at, CallerCacheConfig as b, CorsOptions as bt, WsState as c, ManifestEvent as cn, Provide as ct, wsTransport as d, Stack as dt, buildParts as en, Middleware as et, ApiClient as f, StackCtx as ft, client as g, ctx as gt, GetUrlKeys as h, WsFrameInfo as ht, HeartbeatOptions as i, splitPattern as in, MiddlewareFn as it, createCallerContext as j, DocsOptions as jt, KVStore as k, localClient as kt, WsTransport as l, ProvideMiddleware as lt, ClientWs as m, Transport as mt, reject as n, matchParts as nn, MiddlewareDoc as nt, WebSocketLike as o, Manifest as on, MiddlewareImplFor as ot, ClientOptions as p, StackLP as pt, manifestFromSpec as q, StreamBody as qt, BackoffOptions as r, path as rn, MiddlewareFactory as rt, WsMessageEvent as s, ManifestEndpoint as sn, MiddlewareResult as st, ApiError as t, joinPattern as tn, MiddlewareDef as tt, WsTransportOptions as u, RouteInfo as ut, CacheStoreSpec as v, provide as vt, CallerRateLimitConfig as w, MountHandle as wt, CallerContext as x, Implementor as xt, Caller as y, use as yt, EndpointConfig as z, ClientData as zt };
|