@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,1968 @@
|
|
|
1
|
+
import { DEFAULT_RETRY_OPTIONS, RetryAbort, backoff, getDefaultRetryOptions, retry, setDefaultRetryOptions } from "./retry.js";
|
|
2
|
+
import { DEFAULT_BUCKETS, createMetrics, formatPrometheus } from "./stats.js";
|
|
3
|
+
import { a as createClientCache, c as ApiFailure, d as joinPattern, f as matchParts, h as splitPattern, i as createCallerContext, l as reject, m as path, n as client, o as stableStringify, p as paramKeys, r as CallerRateLimited, s as ApiError, t as wsTransport, u as buildParts } from "./ws-transport.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
//#region src/endpoint.ts
|
|
6
|
+
/**
|
|
7
|
+
* # Endpoint specs
|
|
8
|
+
*
|
|
9
|
+
* Endpoint configuration ({@link EndpointConfig}), the compile-time config
|
|
10
|
+
* validator ({@link CheckCfg}), event configuration, and the two entry points
|
|
11
|
+
* that turn declarations into a runtime spec: {@link endpoint} and {@link spec}.
|
|
12
|
+
*
|
|
13
|
+
* The central invariant is **disjoint kinds**: every path-param key is declared
|
|
14
|
+
* exactly once (loader XOR template XOR `params` schema) and positioned exactly
|
|
15
|
+
* once; query keys are disjoint from path; body keys from path∪query; files keys
|
|
16
|
+
* from all. That disjointness is what lets the four kinds merge losslessly into a
|
|
17
|
+
* single `data` payload. It is enforced both at compile time (via {@link CheckCfg})
|
|
18
|
+
* and at `spec()` time (via {@link normalizeEndpoint}).
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
function makeEndpoint(cfg, mws, prefixes) {
|
|
23
|
+
return {
|
|
24
|
+
kind: "endpoint",
|
|
25
|
+
cfg,
|
|
26
|
+
mws,
|
|
27
|
+
prefixes
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Define a bare endpoint with no middleware.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* const getReport = endpoint({
|
|
36
|
+
* method: 'GET',
|
|
37
|
+
* path: reportPath,
|
|
38
|
+
* response: z.object({ year: z.number(), slug: z.string() }),
|
|
39
|
+
* })
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
function endpoint(cfg) {
|
|
43
|
+
return makeEndpoint(cfg, [], []);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Finalize a set of endpoints + events into a spec, validating every endpoint at
|
|
47
|
+
* definition time.
|
|
48
|
+
*
|
|
49
|
+
* Beyond the compile-time {@link CheckCfg} guarantees, this performs runtime
|
|
50
|
+
* sanity checks (flag exclusivity, kind shapes) and full
|
|
51
|
+
* {@link normalizeEndpoint | path/coverage/disjointness} validation — throwing
|
|
52
|
+
* immediately on any violation so misconfiguration fails at module init.
|
|
53
|
+
*
|
|
54
|
+
* @returns the same spec object, now type-branded and validated.
|
|
55
|
+
*/
|
|
56
|
+
function spec(spec) {
|
|
57
|
+
for (const [name, def] of Object.entries(spec.endpoints)) {
|
|
58
|
+
const c = def.cfg;
|
|
59
|
+
if (c.streamIn && (c.body || c.files)) throw new Error(`endpoint "${name}": streamIn excludes body/files`);
|
|
60
|
+
if (c.files && "body" in c.files) throw new Error(`endpoint "${name}": "body" is reserved as the multipart JSON field name`);
|
|
61
|
+
if (c.streamOut && c.response) throw new Error(`endpoint "${name}": streamOut excludes response`);
|
|
62
|
+
if (c.responses && c.response) throw new Error(`endpoint "${name}": responses (multi-status) excludes response`);
|
|
63
|
+
if (c.responses && c.streamOut) throw new Error(`endpoint "${name}": responses excludes streamOut`);
|
|
64
|
+
if (c.streamEncoding && !(c.streamOut instanceof z.ZodType)) throw new Error(`endpoint "${name}": streamEncoding requires a typed (schema) streamOut`);
|
|
65
|
+
if (c.bodyEncoding === "urlencoded" && !(c.body instanceof z.ZodObject)) throw new Error(`endpoint "${name}": urlencoded bodies must be z.object(...)`);
|
|
66
|
+
if (c.download && typeof c.streamOut !== "string") throw new Error(`endpoint "${name}": download requires a raw (content-type) streamOut`);
|
|
67
|
+
for (const kind of [
|
|
68
|
+
"params",
|
|
69
|
+
"query",
|
|
70
|
+
"headers",
|
|
71
|
+
"cookies"
|
|
72
|
+
]) {
|
|
73
|
+
const s = c[kind];
|
|
74
|
+
if (s && !(s instanceof z.ZodObject)) throw new Error(`endpoint "${name}": ${kind} must be a z.object(...)`);
|
|
75
|
+
}
|
|
76
|
+
normalizeEndpoint(name, def);
|
|
77
|
+
}
|
|
78
|
+
let cached;
|
|
79
|
+
Object.defineProperty(spec, SPEC_MANIFEST, {
|
|
80
|
+
value: () => cached ??= manifestFromSpec(spec),
|
|
81
|
+
enumerable: false,
|
|
82
|
+
configurable: true
|
|
83
|
+
});
|
|
84
|
+
return spec;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Global-registry symbol under which {@link spec} stashes its lazy
|
|
88
|
+
* {@link manifestFromSpec} builder. Global (`Symbol.for`) so consumers — notably
|
|
89
|
+
* the zod-free `client` — can read it off a spec value **without importing this
|
|
90
|
+
* module**, so a manifest-only bundle never pulls in the deriver or zod.
|
|
91
|
+
*
|
|
92
|
+
* @internal
|
|
93
|
+
*/
|
|
94
|
+
const SPEC_MANIFEST = Symbol.for("ayepi.manifest");
|
|
95
|
+
/**
|
|
96
|
+
* Derive the zod-free {@link Manifest} from a spec — exactly the routing data
|
|
97
|
+
* `app.manifest()` returns, computed purely from the endpoint/event definitions.
|
|
98
|
+
* Used by {@link server} and stamped (cached) onto every spec by {@link spec}, so
|
|
99
|
+
* {@link client} can take a spec directly.
|
|
100
|
+
*
|
|
101
|
+
* This runs the zod-bearing {@link normalizeEndpoint}, so importing it — or
|
|
102
|
+
* handing a spec to the client — brings zod into the bundle. Pass a prebuilt
|
|
103
|
+
* manifest instead to keep a frontend schema-free.
|
|
104
|
+
*/
|
|
105
|
+
function manifestFromSpec(spec) {
|
|
106
|
+
const eps = Object.entries(spec.endpoints).map(([name, def]) => normalizeEndpoint(name, def));
|
|
107
|
+
return {
|
|
108
|
+
endpoints: Object.fromEntries(eps.map((e) => [e.name, {
|
|
109
|
+
method: e.method,
|
|
110
|
+
path: e.path,
|
|
111
|
+
ws: e.ws,
|
|
112
|
+
httpOnly: e.httpOnly,
|
|
113
|
+
streamIn: e.streamInCt,
|
|
114
|
+
itemsIn: e.itemsIn,
|
|
115
|
+
streamOut: e.streamOutCt,
|
|
116
|
+
items: e.items,
|
|
117
|
+
p: e.p,
|
|
118
|
+
q: e.q,
|
|
119
|
+
b: e.bRaw ? "raw" : e.b,
|
|
120
|
+
f: e.f,
|
|
121
|
+
hasBody: Boolean(e.def.cfg.body),
|
|
122
|
+
hasHeaders: Boolean(e.def.cfg.headers),
|
|
123
|
+
multi: e.multi,
|
|
124
|
+
bodyEnc: e.bodyEnc
|
|
125
|
+
}])),
|
|
126
|
+
events: Object.fromEntries(Object.entries(spec.events ?? {}).map(([name, cfg]) => [name, {
|
|
127
|
+
ws: cfg.ws ?? name,
|
|
128
|
+
hasParams: Boolean(cfg.params)
|
|
129
|
+
}]))
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Read the keys of a `z.object` schema, or `null` for any other (or absent) schema. @internal */
|
|
133
|
+
function objectKeys(s) {
|
|
134
|
+
if (!s) return null;
|
|
135
|
+
if (s instanceof z.ZodObject) return Object.keys(s.shape);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolve an endpoint declaration into a {@link NormalizedEp}: assemble its path
|
|
140
|
+
* parts (prefixes → own path → default), verify exact-once param declaration and
|
|
141
|
+
* positioning, and enforce kind disjointness. Throws on any violation.
|
|
142
|
+
*
|
|
143
|
+
* @internal
|
|
144
|
+
*/
|
|
145
|
+
function normalizeEndpoint(name, def) {
|
|
146
|
+
const c = def.cfg;
|
|
147
|
+
const chain = resolveChain(def.mws);
|
|
148
|
+
const fail = (msg) => {
|
|
149
|
+
throw new Error(`endpoint "${name}": ${msg}`);
|
|
150
|
+
};
|
|
151
|
+
const loaders = /* @__PURE__ */ new Map();
|
|
152
|
+
for (const m of chain) if (m.paramKey && m.paramSchema) loaders.set(m.paramKey, m.paramSchema);
|
|
153
|
+
const tplSchemas = /* @__PURE__ */ new Map();
|
|
154
|
+
const declareTpl = (tpl, where) => {
|
|
155
|
+
for (const k of tpl.keys) {
|
|
156
|
+
if (loaders.has(k) || tplSchemas.has(k)) fail(`param ":${k}" is declared more than once (${where})`);
|
|
157
|
+
tplSchemas.set(k, tpl.schemas[k]);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
for (const pre of def.prefixes) if (typeof pre !== "string") declareTpl(pre, "prefix path");
|
|
161
|
+
const ownTpl = typeof c.path === "object" && c.path !== null ? c.path : null;
|
|
162
|
+
if (ownTpl) declareTpl(ownTpl, "endpoint path");
|
|
163
|
+
const cfgP = objectKeys(c.params) ?? [];
|
|
164
|
+
for (const k of cfgP) if (loaders.has(k) || tplSchemas.has(k)) fail(`param ":${k}" is declared more than once (params schema)`);
|
|
165
|
+
const declared = new Set([
|
|
166
|
+
...loaders.keys(),
|
|
167
|
+
...tplSchemas.keys(),
|
|
168
|
+
...cfgP
|
|
169
|
+
]);
|
|
170
|
+
const parts = [];
|
|
171
|
+
for (const pre of def.prefixes) parts.push(...typeof pre === "string" ? splitPattern(pre) : pre.parts);
|
|
172
|
+
if (ownTpl) parts.push(...ownTpl.parts);
|
|
173
|
+
else if (typeof c.path === "string") parts.push(...splitPattern(c.path));
|
|
174
|
+
else {
|
|
175
|
+
parts.push({
|
|
176
|
+
t: "lit",
|
|
177
|
+
v: name
|
|
178
|
+
});
|
|
179
|
+
const positioned = new Set(paramKeys(parts));
|
|
180
|
+
for (const k of declared) if (!positioned.has(k)) parts.push({
|
|
181
|
+
t: "param",
|
|
182
|
+
k
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const positions = paramKeys(parts);
|
|
186
|
+
const posSet = new Set(positions);
|
|
187
|
+
if (positions.length !== posSet.size) fail(`path positions a param more than once: ${joinPattern(parts)}`);
|
|
188
|
+
for (const k of positions) if (!declared.has(k)) fail(`path references undeclared param ":${k}"`);
|
|
189
|
+
for (const k of declared) if (!posSet.has(k)) fail(`declared param ":${k}" has no position in path ${joinPattern(parts)}`);
|
|
190
|
+
const p = [...declared];
|
|
191
|
+
const q = objectKeys(c.query) ?? [];
|
|
192
|
+
const b = c.body ? objectKeys(c.body) : null;
|
|
193
|
+
const bRaw = Boolean(c.body) && b === null;
|
|
194
|
+
const f = Object.keys(c.files ?? {});
|
|
195
|
+
const taken = /* @__PURE__ */ new Map();
|
|
196
|
+
for (const [kind, keys] of [
|
|
197
|
+
["path", p],
|
|
198
|
+
["query", q],
|
|
199
|
+
["body", b ?? []],
|
|
200
|
+
["files", f]
|
|
201
|
+
]) for (const k of keys) {
|
|
202
|
+
const prev = taken.get(k);
|
|
203
|
+
if (prev) fail(`key "${k}" appears in both ${prev} and ${kind} — kinds must be disjoint`);
|
|
204
|
+
taken.set(k, kind);
|
|
205
|
+
}
|
|
206
|
+
if (bRaw && (p.length > 0 || q.length > 0 || f.length > 0)) fail("a non-object body is the entire data payload — params/query/files are not allowed alongside it");
|
|
207
|
+
const itemsIn = c.streamIn instanceof z.ZodType;
|
|
208
|
+
const streamInCt = typeof c.streamIn === "string" ? c.streamIn : itemsIn ? "application/x-ndjson" : null;
|
|
209
|
+
const items = c.streamOut instanceof z.ZodType;
|
|
210
|
+
const sse = items && c.streamEncoding === "sse";
|
|
211
|
+
const streamOutCt = typeof c.streamOut === "string" ? c.streamOut : items ? sse ? "text/event-stream" : "application/x-ndjson" : null;
|
|
212
|
+
const httpOnly = Boolean(c.httpOnly || typeof c.streamIn === "string" || typeof c.streamOut === "string" || f.length > 0);
|
|
213
|
+
return {
|
|
214
|
+
name,
|
|
215
|
+
def,
|
|
216
|
+
method: c.method ?? "POST",
|
|
217
|
+
parts,
|
|
218
|
+
path: joinPattern(parts),
|
|
219
|
+
ws: c.ws ?? null,
|
|
220
|
+
wsEligible: !httpOnly,
|
|
221
|
+
httpOnly,
|
|
222
|
+
streamInCt,
|
|
223
|
+
itemsIn,
|
|
224
|
+
streamOutCt,
|
|
225
|
+
items,
|
|
226
|
+
sse,
|
|
227
|
+
multi: Boolean(c.responses && Object.keys(c.responses).length > 0),
|
|
228
|
+
bodyEnc: c.body ? c.bodyEncoding ?? "json" : null,
|
|
229
|
+
p,
|
|
230
|
+
q,
|
|
231
|
+
b,
|
|
232
|
+
bRaw,
|
|
233
|
+
f,
|
|
234
|
+
loaders,
|
|
235
|
+
tplSchemas,
|
|
236
|
+
chain
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/middleware.ts
|
|
241
|
+
/**
|
|
242
|
+
* Declare the context a middleware def provides — `provides: ctx<{ user: User }>()`.
|
|
243
|
+
* Returns a type-only token; carries no runtime value.
|
|
244
|
+
*/
|
|
245
|
+
function ctx() {
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
function placeholderRun(name) {
|
|
249
|
+
return () => {
|
|
250
|
+
throw new Error(`middleware "${name}" has no implementation — bind it with implement(api).middleware(def, impl)`);
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function makeMiddleware(name, requires, optional, paramKey, paramSchema, doc) {
|
|
254
|
+
const self = {
|
|
255
|
+
kind: "middleware",
|
|
256
|
+
name,
|
|
257
|
+
requires,
|
|
258
|
+
optional,
|
|
259
|
+
paramKey,
|
|
260
|
+
paramSchema,
|
|
261
|
+
doc,
|
|
262
|
+
run: placeholderRun(name),
|
|
263
|
+
with(...mws) {
|
|
264
|
+
return makeStack([self, ...mws], []);
|
|
265
|
+
},
|
|
266
|
+
path(p) {
|
|
267
|
+
return makeStack([self], [p]);
|
|
268
|
+
},
|
|
269
|
+
endpoint(cfg) {
|
|
270
|
+
return makeStack([self], []).endpoint(cfg);
|
|
271
|
+
},
|
|
272
|
+
group(g) {
|
|
273
|
+
return makeStack([self], []).group(g);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
return self;
|
|
277
|
+
}
|
|
278
|
+
function makeStack(mws, prefixes) {
|
|
279
|
+
return {
|
|
280
|
+
kind: "stack",
|
|
281
|
+
mws,
|
|
282
|
+
prefixes,
|
|
283
|
+
with(...more) {
|
|
284
|
+
return makeStack([...mws, ...more], prefixes);
|
|
285
|
+
},
|
|
286
|
+
path(p) {
|
|
287
|
+
return makeStack(mws, [...prefixes, p]);
|
|
288
|
+
},
|
|
289
|
+
endpoint(cfg) {
|
|
290
|
+
return makeEndpoint(cfg, mws, prefixes);
|
|
291
|
+
},
|
|
292
|
+
group(g) {
|
|
293
|
+
const out = {};
|
|
294
|
+
for (const [k, cfg] of Object.entries(g)) out[k] = makeEndpoint(cfg, mws, prefixes);
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Compose one or more middleware into a {@link Stack} — the free-function form of
|
|
301
|
+
* {@link Middleware.with | mw.with(...)}, which reads more naturally when bundling
|
|
302
|
+
* several middleware at a group: `...use(auth, tel).group({ … })` instead of
|
|
303
|
+
* `...auth.with(tel).group({ … })`.
|
|
304
|
+
*
|
|
305
|
+
* The middleware run in the order given (subject to `requires`/`optional`
|
|
306
|
+
* resolution), exactly as with `.with()`.
|
|
307
|
+
*
|
|
308
|
+
* @typeParam M - the middleware list, in declared order (at least one).
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* ```ts
|
|
312
|
+
* spec({
|
|
313
|
+
* endpoints: {
|
|
314
|
+
* ...use(auth, tel).group({ me, createJob }),
|
|
315
|
+
* ...use(auth, jobLoader).path('/jobs/:jobId').group({ jobStatus }),
|
|
316
|
+
* },
|
|
317
|
+
* })
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
function use(...mws) {
|
|
321
|
+
return makeStack([...mws], []);
|
|
322
|
+
}
|
|
323
|
+
function middlewareImpl(name, opts) {
|
|
324
|
+
return makeMiddleware(name, opts?.requires ?? [], opts?.optional ?? [], void 0, void 0, opts?.doc);
|
|
325
|
+
}
|
|
326
|
+
function loaderImpl(key, schema, opts) {
|
|
327
|
+
return makeMiddleware(key, opts?.requires ?? [], opts?.optional ?? [], key, schema, opts?.doc);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Create a middleware **def** — a frontend-safe contract (name, contributed
|
|
331
|
+
* context, docs, dependencies) with **no** runtime fn. Bind the implementation
|
|
332
|
+
* with `implement(api).middleware(def, impl)` in your server entry.
|
|
333
|
+
*
|
|
334
|
+
* @example A plain def that provides `{ user }`:
|
|
335
|
+
* ```ts
|
|
336
|
+
* const auth = middleware('auth', { provides: ctx<{ user: User }>() })
|
|
337
|
+
* // server.ts:
|
|
338
|
+
* implement(api).middleware(auth, async (io) => io.next({ user: await authenticate(io.req) }))
|
|
339
|
+
* ```
|
|
340
|
+
*
|
|
341
|
+
* @example A def that requires `auth` (auto-included) and provides `{ org }`:
|
|
342
|
+
* ```ts
|
|
343
|
+
* const org = middleware('org', { provides: ctx<{ org: Org }>(), requires: [auth] })
|
|
344
|
+
* // server.ts:
|
|
345
|
+
* implement(api).middleware(org, async (io) => io.next({ org: await loadOrg(io.ctx.user) }))
|
|
346
|
+
* ```
|
|
347
|
+
*
|
|
348
|
+
* @example A no-context, purely-runtime def (e.g. logging/telemetry):
|
|
349
|
+
* ```ts
|
|
350
|
+
* const log = middleware('log')
|
|
351
|
+
* implement(api).middleware(log, async (io) => io.next())
|
|
352
|
+
* ```
|
|
353
|
+
*
|
|
354
|
+
* @example A loader def that owns the `:projectId` path param:
|
|
355
|
+
* ```ts
|
|
356
|
+
* const project = middleware.loader('projectId', z.uuid(), { provides: ctx<{ project: Project }>() })
|
|
357
|
+
* // server.ts:
|
|
358
|
+
* implement(api).middleware(project, async (io) => io.next({ project: await loadProject(io.value) }))
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
const middleware = Object.assign(middlewareImpl, { loader: loaderImpl });
|
|
362
|
+
/**
|
|
363
|
+
* Create a middleware that **injects a typed value** onto the handler context under
|
|
364
|
+
* `name` — the one-call form of `middleware(name, { provides: ctx<{ [name]: V }>() })`
|
|
365
|
+
* plus its impl. Hand it a function, a service object, config, or any data, and every
|
|
366
|
+
* endpoint whose chain includes it reads `io.ctx[name]`.
|
|
367
|
+
*
|
|
368
|
+
* The result is **both** the def (use it in the spec: `use(svc).group(…)` /
|
|
369
|
+
* `svc.endpoint(…)`) and the bound def+impl (bind it once:
|
|
370
|
+
* `implement(api).middleware(svc)`).
|
|
371
|
+
*
|
|
372
|
+
* @typeParam N - the context key (a string literal).
|
|
373
|
+
* @typeParam V - the injected value's type.
|
|
374
|
+
* @param name - the key the value is injected under (also the middleware name).
|
|
375
|
+
* @param value - the value to inject, or a factory `(io) => value` re-run per
|
|
376
|
+
* invocation (may be async). A **callable** `value` is treated as a factory — to
|
|
377
|
+
* inject a bare function as the value, wrap it: `provide('fn', () => myFn)`.
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* ```ts
|
|
381
|
+
* const services = provide('services', { db, mailer }); // shared.ts / spec
|
|
382
|
+
* export const api = spec({ endpoints: { ...services.group({ sendInvite }) } });
|
|
383
|
+
* implement(api).middleware(services); // server.ts (bind once)
|
|
384
|
+
* // handler: ({ services }) => services.mailer.send(…)
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
function provide(name, value) {
|
|
388
|
+
const def = middleware(name, { provides: ctx() });
|
|
389
|
+
const impl = async (io) => {
|
|
390
|
+
const v = typeof value === "function" ? await value(io) : value;
|
|
391
|
+
return io.next({ [name]: v });
|
|
392
|
+
};
|
|
393
|
+
return Object.assign(def, {
|
|
394
|
+
def,
|
|
395
|
+
impl
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Expand `requires` (auto-include) and topologically order a middleware list.
|
|
400
|
+
*
|
|
401
|
+
* `requires` edges pull dependencies in and force them earlier; `optional` edges
|
|
402
|
+
* only reorder middleware that are *already present*. Throws on a dependency
|
|
403
|
+
* cycle.
|
|
404
|
+
*
|
|
405
|
+
* @internal
|
|
406
|
+
*/
|
|
407
|
+
function resolveChain(mws) {
|
|
408
|
+
const present = /* @__PURE__ */ new Set();
|
|
409
|
+
const order = [];
|
|
410
|
+
const visit = (m, trail) => {
|
|
411
|
+
if (present.has(m)) return;
|
|
412
|
+
if (trail.has(m)) throw new Error(`middleware cycle involving "${m.name}"`);
|
|
413
|
+
trail.add(m);
|
|
414
|
+
for (const r of m.requires) visit(r, trail);
|
|
415
|
+
trail.delete(m);
|
|
416
|
+
present.add(m);
|
|
417
|
+
order.push(m);
|
|
418
|
+
};
|
|
419
|
+
for (const m of mws) visit(m, /* @__PURE__ */ new Set());
|
|
420
|
+
const idx = new Map(order.map((m, i) => [m, i]));
|
|
421
|
+
return [...order].sort((a, b) => {
|
|
422
|
+
if (a.optional.includes(b)) return 1;
|
|
423
|
+
if (b.optional.includes(a)) return -1;
|
|
424
|
+
return idx.get(a) - idx.get(b);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/broker.ts
|
|
429
|
+
/**
|
|
430
|
+
* In-process {@link Broker} — the default when no broker is supplied.
|
|
431
|
+
*
|
|
432
|
+
* Share a single instance between multiple {@link Server}s to simulate a
|
|
433
|
+
* multi-pod deployment in tests: an `emit` on one server is heard by
|
|
434
|
+
* subscribers on the other.
|
|
435
|
+
*
|
|
436
|
+
* @example
|
|
437
|
+
* ```ts
|
|
438
|
+
* const broker = localBroker()
|
|
439
|
+
* const a = server(api, handlers, { broker })
|
|
440
|
+
* const b = server(api, handlers, { broker })
|
|
441
|
+
* a.emit('systemNotice', { msg: 'hi' }) // delivered to b's subscribers too
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
function localBroker() {
|
|
445
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
446
|
+
return {
|
|
447
|
+
publish(m) {
|
|
448
|
+
for (const l of listeners) l(m);
|
|
449
|
+
},
|
|
450
|
+
subscribe(l) {
|
|
451
|
+
listeners.add(l);
|
|
452
|
+
return () => void listeners.delete(l);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/jsonschema.ts
|
|
458
|
+
/**
|
|
459
|
+
* # JSON Schema helpers
|
|
460
|
+
*
|
|
461
|
+
* Thin wrappers over `z.toJSONSchema` shared by the OpenAPI and AsyncAPI
|
|
462
|
+
* generators. Internal to the package.
|
|
463
|
+
*
|
|
464
|
+
* @module
|
|
465
|
+
*/
|
|
466
|
+
/**
|
|
467
|
+
* Convert a zod schema to a JSON Schema (input view), degrading gracefully to a
|
|
468
|
+
* placeholder for schemas zod can't represent.
|
|
469
|
+
*
|
|
470
|
+
* @internal
|
|
471
|
+
*/
|
|
472
|
+
function jsonSchema(s) {
|
|
473
|
+
try {
|
|
474
|
+
return z.toJSONSchema(s, { io: "input" });
|
|
475
|
+
} catch {
|
|
476
|
+
return {
|
|
477
|
+
type: "string",
|
|
478
|
+
description: "unrepresentable schema"
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Extract the `properties` map of an object schema's JSON Schema, or `{}` when
|
|
484
|
+
* the schema is absent or not object-shaped.
|
|
485
|
+
*
|
|
486
|
+
* @internal
|
|
487
|
+
*/
|
|
488
|
+
function propSchemas(s) {
|
|
489
|
+
const js = s ? jsonSchema(s) : null;
|
|
490
|
+
if (js && typeof js === "object" && !Array.isArray(js) && typeof js.properties === "object") return js.properties;
|
|
491
|
+
return {};
|
|
492
|
+
}
|
|
493
|
+
//#endregion
|
|
494
|
+
//#region src/openapi.ts
|
|
495
|
+
/**
|
|
496
|
+
* Build the OpenAPI 3.1 document for a set of normalized endpoints.
|
|
497
|
+
*
|
|
498
|
+
* @param eps - the spec's normalized endpoints.
|
|
499
|
+
* @param specDoc - spec-level doc patches (`doc.openapi`).
|
|
500
|
+
* @param info - optional document `info` (title/version).
|
|
501
|
+
* @internal
|
|
502
|
+
*/
|
|
503
|
+
function buildOpenapi(eps, specDoc, info) {
|
|
504
|
+
const paths = {};
|
|
505
|
+
const securitySchemes = {};
|
|
506
|
+
for (const e of eps) {
|
|
507
|
+
const c = e.def.cfg;
|
|
508
|
+
const oaPath = "/" + e.parts.map((part) => part.t === "param" ? `{${part.k}}` : part.v).join("/");
|
|
509
|
+
const pProps = {
|
|
510
|
+
...propSchemas(c.params),
|
|
511
|
+
...Object.fromEntries([...e.tplSchemas].map(([k, s]) => [k, jsonSchema(s)])),
|
|
512
|
+
...Object.fromEntries([...e.loaders].map(([k, s]) => [k, jsonSchema(s)]))
|
|
513
|
+
};
|
|
514
|
+
const qProps = propSchemas(c.query);
|
|
515
|
+
const hProps = propSchemas(c.headers);
|
|
516
|
+
const ckProps = propSchemas(c.cookies);
|
|
517
|
+
const parameters = [
|
|
518
|
+
...e.p.map((k) => ({
|
|
519
|
+
name: k,
|
|
520
|
+
in: "path",
|
|
521
|
+
required: true,
|
|
522
|
+
schema: pProps[k] ?? { type: "string" }
|
|
523
|
+
})),
|
|
524
|
+
...e.q.map((k) => ({
|
|
525
|
+
name: k,
|
|
526
|
+
in: "query",
|
|
527
|
+
required: false,
|
|
528
|
+
schema: qProps[k] ?? { type: "string" }
|
|
529
|
+
})),
|
|
530
|
+
...Object.entries(hProps).map(([k, schema]) => ({
|
|
531
|
+
name: k,
|
|
532
|
+
in: "header",
|
|
533
|
+
required: true,
|
|
534
|
+
schema
|
|
535
|
+
})),
|
|
536
|
+
...Object.entries(ckProps).map(([k, schema]) => ({
|
|
537
|
+
name: k,
|
|
538
|
+
in: "cookie",
|
|
539
|
+
required: false,
|
|
540
|
+
schema
|
|
541
|
+
}))
|
|
542
|
+
];
|
|
543
|
+
let requestBody;
|
|
544
|
+
if (c.streamIn) requestBody = e.itemsIn ? {
|
|
545
|
+
required: true,
|
|
546
|
+
content: { [e.streamInCt]: { schema: {
|
|
547
|
+
type: "array",
|
|
548
|
+
items: jsonSchema(c.streamIn)
|
|
549
|
+
} } }
|
|
550
|
+
} : {
|
|
551
|
+
required: true,
|
|
552
|
+
content: { [e.streamInCt]: { schema: {
|
|
553
|
+
type: "string",
|
|
554
|
+
format: "binary"
|
|
555
|
+
} } }
|
|
556
|
+
};
|
|
557
|
+
else if (e.f.length > 0) {
|
|
558
|
+
const props = { ...c.body ? { body: jsonSchema(c.body) } : {} };
|
|
559
|
+
for (const k of e.f) props[k] = {
|
|
560
|
+
type: "string",
|
|
561
|
+
format: "binary"
|
|
562
|
+
};
|
|
563
|
+
requestBody = {
|
|
564
|
+
required: true,
|
|
565
|
+
content: { "multipart/form-data": { schema: {
|
|
566
|
+
type: "object",
|
|
567
|
+
properties: props
|
|
568
|
+
} } }
|
|
569
|
+
};
|
|
570
|
+
} else if (c.body) requestBody = {
|
|
571
|
+
required: true,
|
|
572
|
+
content: { [e.bodyEnc === "urlencoded" ? "application/x-www-form-urlencoded" : "application/json"]: { schema: jsonSchema(c.body) } }
|
|
573
|
+
};
|
|
574
|
+
const responses = c.streamOut ? e.items ? { "200": {
|
|
575
|
+
description: "NDJSON item stream",
|
|
576
|
+
content: { [e.streamOutCt]: { schema: {
|
|
577
|
+
type: "array",
|
|
578
|
+
items: jsonSchema(c.streamOut)
|
|
579
|
+
} } }
|
|
580
|
+
} } : { "200": {
|
|
581
|
+
description: "stream",
|
|
582
|
+
content: { [e.streamOutCt]: { schema: {
|
|
583
|
+
type: "string",
|
|
584
|
+
format: "binary"
|
|
585
|
+
} } }
|
|
586
|
+
} } : c.responses ? Object.fromEntries(Object.entries(c.responses).map(([st, schema]) => [st, {
|
|
587
|
+
description: `status ${st}`,
|
|
588
|
+
content: { "application/json": { schema: jsonSchema(schema) } }
|
|
589
|
+
}])) : c.response ? { "200": {
|
|
590
|
+
description: "ok",
|
|
591
|
+
content: { "application/json": { schema: jsonSchema(c.response) } }
|
|
592
|
+
} } : { "204": { description: "no content" } };
|
|
593
|
+
for (const [status, schema] of Object.entries(c.errors ?? {})) responses[status] = {
|
|
594
|
+
description: `declared error ${status}`,
|
|
595
|
+
content: { "application/json": { schema: jsonSchema(schema) } }
|
|
596
|
+
};
|
|
597
|
+
let op = {
|
|
598
|
+
operationId: c.doc?.operationId ?? e.name,
|
|
599
|
+
parameters,
|
|
600
|
+
responses
|
|
601
|
+
};
|
|
602
|
+
if (requestBody) op.requestBody = requestBody;
|
|
603
|
+
if (c.doc?.summary) op.summary = c.doc.summary;
|
|
604
|
+
if (c.doc?.description) op.description = c.doc.description;
|
|
605
|
+
if (c.doc?.tags) op.tags = [...c.doc.tags];
|
|
606
|
+
if (c.doc?.deprecated) op.deprecated = true;
|
|
607
|
+
const security = [];
|
|
608
|
+
for (const m of e.chain) for (const [schemeName, scheme] of Object.entries(m.doc?.security ?? {})) {
|
|
609
|
+
securitySchemes[schemeName] = scheme;
|
|
610
|
+
security.push({ [schemeName]: [] });
|
|
611
|
+
}
|
|
612
|
+
if (security.length > 0) op.security = security;
|
|
613
|
+
for (const m of e.chain) if (m.doc?.openapi) op = m.doc.openapi(op);
|
|
614
|
+
if (c.doc?.openapi) op = c.doc.openapi(op);
|
|
615
|
+
const entry = paths[oaPath] ?? {};
|
|
616
|
+
entry[e.method.toLowerCase()] = op;
|
|
617
|
+
paths[oaPath] = entry;
|
|
618
|
+
}
|
|
619
|
+
let doc = {
|
|
620
|
+
openapi: "3.1.0",
|
|
621
|
+
info: {
|
|
622
|
+
title: info?.title ?? "api",
|
|
623
|
+
version: info?.version ?? "0.0.0"
|
|
624
|
+
},
|
|
625
|
+
paths,
|
|
626
|
+
...Object.keys(securitySchemes).length > 0 ? { components: { securitySchemes } } : {}
|
|
627
|
+
};
|
|
628
|
+
if (specDoc?.openapi) doc = specDoc.openapi(doc);
|
|
629
|
+
return doc;
|
|
630
|
+
}
|
|
631
|
+
//#endregion
|
|
632
|
+
//#region src/asyncapi.ts
|
|
633
|
+
/**
|
|
634
|
+
* Convert a params object's JSON-Schema `properties` into AsyncAPI 3.0 **Parameter
|
|
635
|
+
* Objects**. Channel parameters are string-valued and must NOT carry a JSON-Schema
|
|
636
|
+
* `type`/`format` (the 3.0 Parameter Object only allows `description`/`enum`/`default`/
|
|
637
|
+
* `examples`/`location`) — emitting `type` makes a validator reject the document.
|
|
638
|
+
*/
|
|
639
|
+
function paramObjects(props) {
|
|
640
|
+
const out = {};
|
|
641
|
+
for (const [k, v] of Object.entries(props)) {
|
|
642
|
+
const o = v;
|
|
643
|
+
const param = {};
|
|
644
|
+
if (typeof o.description === "string") param.description = o.description;
|
|
645
|
+
if (Array.isArray(o.enum)) param.enum = o.enum.map((e) => String(e));
|
|
646
|
+
if (o.default !== void 0) param.default = String(o.default);
|
|
647
|
+
out[k] = param;
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Build the AsyncAPI 3.0 document for a set of normalized endpoints and events.
|
|
653
|
+
*
|
|
654
|
+
* @param eps - the spec's normalized endpoints.
|
|
655
|
+
* @param events - the spec's normalized events (name + config + resolved ws id).
|
|
656
|
+
* @param specDoc - spec-level doc patches (`doc.asyncapi`).
|
|
657
|
+
* @param info - optional document `info` (title/version).
|
|
658
|
+
* @internal
|
|
659
|
+
*/
|
|
660
|
+
function buildAsyncapi(eps, events, specDoc, info) {
|
|
661
|
+
const channels = {};
|
|
662
|
+
const operations = {};
|
|
663
|
+
for (const ev of events) {
|
|
664
|
+
let channel = {
|
|
665
|
+
address: ev.ws,
|
|
666
|
+
...ev.cfg.doc?.summary ? { summary: ev.cfg.doc.summary } : {},
|
|
667
|
+
...ev.cfg.doc?.description ? { description: ev.cfg.doc.description } : {},
|
|
668
|
+
...ev.cfg.params ? { parameters: paramObjects(propSchemas(ev.cfg.params)) } : {},
|
|
669
|
+
messages: { event: { payload: jsonSchema(ev.cfg.data) } }
|
|
670
|
+
};
|
|
671
|
+
if (ev.cfg.doc?.asyncapi) channel = ev.cfg.doc.asyncapi(channel);
|
|
672
|
+
channels[ev.ws] = channel;
|
|
673
|
+
operations[`receive.${ev.name}`] = {
|
|
674
|
+
action: "receive",
|
|
675
|
+
channel: { $ref: `#/channels/${ev.ws}` }
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
for (const e of eps) {
|
|
679
|
+
if (!e.wsEligible) continue;
|
|
680
|
+
const c = e.def.cfg;
|
|
681
|
+
const address = e.ws ?? e.path;
|
|
682
|
+
const dataSchema = e.bRaw ? jsonSchema(c.body) : {
|
|
683
|
+
type: "object",
|
|
684
|
+
properties: {
|
|
685
|
+
...Object.fromEntries([...e.tplSchemas].map(([k, sch]) => [k, jsonSchema(sch)])),
|
|
686
|
+
...Object.fromEntries([...e.loaders].map(([k, sch]) => [k, jsonSchema(sch)])),
|
|
687
|
+
...propSchemas(c.params),
|
|
688
|
+
...propSchemas(c.query),
|
|
689
|
+
...propSchemas(c.body)
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
const ref = (key) => `#/channels/${key.replace(/\//g, "~1")}`;
|
|
693
|
+
channels[address] = {
|
|
694
|
+
address,
|
|
695
|
+
...e.ws ? {} : { description: `frame: { id, type: "${e.path}", method: "${e.method}", data }` },
|
|
696
|
+
messages: { request: { payload: {
|
|
697
|
+
type: "object",
|
|
698
|
+
properties: {
|
|
699
|
+
id: { type: "string" },
|
|
700
|
+
type: { const: address },
|
|
701
|
+
...e.ws ? {} : { method: { const: e.method } },
|
|
702
|
+
data: dataSchema
|
|
703
|
+
}
|
|
704
|
+
} } }
|
|
705
|
+
};
|
|
706
|
+
const replyKey = `${address}/reply`;
|
|
707
|
+
const declared = c.errors ? Object.keys(c.errors) : [];
|
|
708
|
+
const replyProps = {
|
|
709
|
+
id: { type: "string" },
|
|
710
|
+
$status: {
|
|
711
|
+
type: "integer",
|
|
712
|
+
description: "2xx on success"
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
if (c.response) replyProps.data = jsonSchema(c.response);
|
|
716
|
+
channels[replyKey] = {
|
|
717
|
+
address,
|
|
718
|
+
messages: {
|
|
719
|
+
reply: { payload: {
|
|
720
|
+
type: "object",
|
|
721
|
+
properties: replyProps
|
|
722
|
+
} },
|
|
723
|
+
error: {
|
|
724
|
+
...declared.length > 0 ? { description: `error frame — declared statuses: ${declared.join(", ")}` } : {},
|
|
725
|
+
payload: {
|
|
726
|
+
type: "object",
|
|
727
|
+
required: ["$status"],
|
|
728
|
+
properties: {
|
|
729
|
+
id: { type: "string" },
|
|
730
|
+
$status: {
|
|
731
|
+
type: "integer",
|
|
732
|
+
description: "non-2xx — the client throws an ApiError"
|
|
733
|
+
},
|
|
734
|
+
$error: {
|
|
735
|
+
type: "string",
|
|
736
|
+
description: "human-readable error message"
|
|
737
|
+
},
|
|
738
|
+
$code: {
|
|
739
|
+
type: "string",
|
|
740
|
+
description: "machine-readable error code (e.g. UNAUTHORIZED, VALIDATION)"
|
|
741
|
+
},
|
|
742
|
+
data: { description: "typed error body for declared errors" }
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
const op = {
|
|
749
|
+
action: "send",
|
|
750
|
+
channel: { $ref: ref(address) },
|
|
751
|
+
messages: [{ $ref: `${ref(address)}/messages/request` }],
|
|
752
|
+
reply: {
|
|
753
|
+
channel: { $ref: ref(replyKey) },
|
|
754
|
+
messages: [{ $ref: `${ref(replyKey)}/messages/reply` }, { $ref: `${ref(replyKey)}/messages/error` }]
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
operations[`call.${e.name}`] = op;
|
|
758
|
+
}
|
|
759
|
+
let doc = {
|
|
760
|
+
asyncapi: "3.0.0",
|
|
761
|
+
info: {
|
|
762
|
+
title: info?.title ?? "api",
|
|
763
|
+
version: info?.version ?? "0.0.0"
|
|
764
|
+
},
|
|
765
|
+
channels,
|
|
766
|
+
operations
|
|
767
|
+
};
|
|
768
|
+
if (specDoc?.asyncapi) doc = specDoc.asyncapi(doc);
|
|
769
|
+
return doc;
|
|
770
|
+
}
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/docs-ui.ts
|
|
773
|
+
const DEFAULTS = {
|
|
774
|
+
openapiJson: "/docs/openapi.json",
|
|
775
|
+
asyncapiJson: "/docs/asyncapi.json",
|
|
776
|
+
swagger: "/docs/swagger",
|
|
777
|
+
redoc: "/docs/redoc",
|
|
778
|
+
asyncapi: "/docs/asyncapi"
|
|
779
|
+
};
|
|
780
|
+
/** Resolve the `docs` option into concrete routes, or `null` when disabled. @internal */
|
|
781
|
+
function normalizeDocs(opt) {
|
|
782
|
+
if (!opt) return null;
|
|
783
|
+
const o = opt === true ? {} : opt;
|
|
784
|
+
const pick = (v, d) => v === false ? null : v ?? d;
|
|
785
|
+
return {
|
|
786
|
+
info: o.info,
|
|
787
|
+
openapiJson: pick(o.openapiJson, DEFAULTS.openapiJson),
|
|
788
|
+
asyncapiJson: pick(o.asyncapiJson, DEFAULTS.asyncapiJson),
|
|
789
|
+
swagger: pick(o.swagger, DEFAULTS.swagger),
|
|
790
|
+
redoc: pick(o.redoc, DEFAULTS.redoc),
|
|
791
|
+
asyncapi: pick(o.asyncapi, DEFAULTS.asyncapi)
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
/** Escape a string for safe inclusion in an HTML attribute. */
|
|
795
|
+
function attr(s) {
|
|
796
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
797
|
+
}
|
|
798
|
+
/** A self-contained Swagger UI page (loaded from unpkg) pointed at `specUrl`. */
|
|
799
|
+
function swaggerHtml(specUrl, title = "API — Swagger UI") {
|
|
800
|
+
return `<!doctype html>
|
|
801
|
+
<html lang="en">
|
|
802
|
+
<head>
|
|
803
|
+
<meta charset="utf-8" />
|
|
804
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
805
|
+
<title>${attr(title)}</title>
|
|
806
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
807
|
+
</head>
|
|
808
|
+
<body>
|
|
809
|
+
<div id="swagger-ui"></div>
|
|
810
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin><\/script>
|
|
811
|
+
<script>
|
|
812
|
+
window.ui = SwaggerUIBundle({ url: ${JSON.stringify(specUrl)}, dom_id: '#swagger-ui' })
|
|
813
|
+
<\/script>
|
|
814
|
+
</body>
|
|
815
|
+
</html>`;
|
|
816
|
+
}
|
|
817
|
+
/** A self-contained ReDoc page (loaded from the Redocly CDN) pointed at `specUrl`. */
|
|
818
|
+
function redocHtml(specUrl, title = "API — ReDoc") {
|
|
819
|
+
return `<!doctype html>
|
|
820
|
+
<html lang="en">
|
|
821
|
+
<head>
|
|
822
|
+
<meta charset="utf-8" />
|
|
823
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
824
|
+
<title>${attr(title)}</title>
|
|
825
|
+
</head>
|
|
826
|
+
<body style="margin: 0">
|
|
827
|
+
<redoc spec-url="${attr(specUrl)}"></redoc>
|
|
828
|
+
<script src="https://cdn.redocly.com/redoc/latest/bundles/redoc.standalone.js" crossorigin><\/script>
|
|
829
|
+
</body>
|
|
830
|
+
</html>`;
|
|
831
|
+
}
|
|
832
|
+
/** A self-contained AsyncAPI page (the `@asyncapi/web-component`, loaded from unpkg) pointed at `specUrl`. */
|
|
833
|
+
function asyncapiHtml(specUrl, title = "API — AsyncAPI") {
|
|
834
|
+
const css = "https://unpkg.com/@asyncapi/react-component@2/styles/default.min.css";
|
|
835
|
+
return `<!doctype html>
|
|
836
|
+
<html lang="en">
|
|
837
|
+
<head>
|
|
838
|
+
<meta charset="utf-8" />
|
|
839
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
840
|
+
<title>${attr(title)}</title>
|
|
841
|
+
<link rel="stylesheet" href="${css}" />
|
|
842
|
+
</head>
|
|
843
|
+
<body>
|
|
844
|
+
<asyncapi-component schemaUrl="${attr(specUrl)}" cssImportPath="${css}"></asyncapi-component>
|
|
845
|
+
<script src="https://unpkg.com/@asyncapi/web-component@2/lib/asyncapi-web-component.js" defer><\/script>
|
|
846
|
+
</body>
|
|
847
|
+
</html>`;
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/server.ts
|
|
851
|
+
/**
|
|
852
|
+
* # Server
|
|
853
|
+
*
|
|
854
|
+
* The fetch-native server. {@link server} takes a {@link AnySpec | spec} and
|
|
855
|
+
* handler bags and returns a {@link Server} whose `fetch(Request) => Response` is
|
|
856
|
+
* the whole HTTP surface, plus a `ws` object (`open`/`message`/`close`) that
|
|
857
|
+
* speaks the JSON frame protocol. Everything is web-standard `Request`/`Response`
|
|
858
|
+
* /streams — Node/Express/etc. live in adapters at the edge, never here.
|
|
859
|
+
*
|
|
860
|
+
* Responsibilities: middleware chain execution, payload assembly from the single
|
|
861
|
+
* `data` kind tables, HTTP body parsing (JSON/urlencoded/multipart/raw/items),
|
|
862
|
+
* raw + item + SSE streaming with Range/206/HEAD, CORS, the WebSocket call /
|
|
863
|
+
* stream / sub-unsub protocol, event fanout through the {@link Broker}, and
|
|
864
|
+
* OpenAPI/AsyncAPI generation (delegated to {@link buildOpenapi}/{@link buildAsyncapi}).
|
|
865
|
+
*
|
|
866
|
+
* @module
|
|
867
|
+
*/
|
|
868
|
+
/** Readable-side highWaterMark for the `$out` transform: accept the first chunk before a reader attaches so headers can commit. */
|
|
869
|
+
const OUT_HIGH_WATER_MARK = 1;
|
|
870
|
+
/** Synthetic status for an aborted/cancelled call (no HTTP response is produced). */
|
|
871
|
+
const ABORTED_STATUS = 0;
|
|
872
|
+
function parseCookieHeader(header) {
|
|
873
|
+
const out = {};
|
|
874
|
+
if (!header) return out;
|
|
875
|
+
for (const part of header.split(";")) {
|
|
876
|
+
const i = part.indexOf("=");
|
|
877
|
+
if (i < 0) continue;
|
|
878
|
+
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
879
|
+
}
|
|
880
|
+
return out;
|
|
881
|
+
}
|
|
882
|
+
function serializeCookie(name, value, o) {
|
|
883
|
+
let s = `${name}=${encodeURIComponent(value)}`;
|
|
884
|
+
if (o?.path) s += `; Path=${o.path}`;
|
|
885
|
+
if (o?.domain) s += `; Domain=${o.domain}`;
|
|
886
|
+
if (o?.maxAge !== void 0) s += `; Max-Age=${o.maxAge}`;
|
|
887
|
+
if (o?.expires) s += `; Expires=${o.expires.toUTCString()}`;
|
|
888
|
+
if (o?.httpOnly) s += "; HttpOnly";
|
|
889
|
+
if (o?.secure) s += "; Secure";
|
|
890
|
+
if (o?.sameSite) s += `; SameSite=${o.sameSite}`;
|
|
891
|
+
return s;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Begin implementing a spec. The returned {@link Implementor} is a chainable
|
|
895
|
+
* builder: bind middleware impls with `.middleware(def, impl)` and endpoint
|
|
896
|
+
* handlers with `.handlers({...})`/`.handle(name, fn)`, then hand it to
|
|
897
|
+
* {@link server}. Split work across multiple `implement()` builders if you like —
|
|
898
|
+
* `server()` merges them.
|
|
899
|
+
*
|
|
900
|
+
* @example
|
|
901
|
+
* ```ts
|
|
902
|
+
* const impl = implement(api)
|
|
903
|
+
* .middleware(auth, async (io) => io.next({ user: await authenticate(io.req) }))
|
|
904
|
+
* .handlers({ getUser: ({ data }) => loadUser(data.id) })
|
|
905
|
+
* ```
|
|
906
|
+
*/
|
|
907
|
+
function implement(spec) {
|
|
908
|
+
const bag = {
|
|
909
|
+
handlers: /* @__PURE__ */ new Map(),
|
|
910
|
+
middleware: /* @__PURE__ */ new Map()
|
|
911
|
+
};
|
|
912
|
+
const self = {
|
|
913
|
+
__bag: bag,
|
|
914
|
+
middleware(defOrBound, impl) {
|
|
915
|
+
if (impl) bag.middleware.set(defOrBound, impl);
|
|
916
|
+
else {
|
|
917
|
+
const b = defOrBound;
|
|
918
|
+
bag.middleware.set(b.def, b.impl);
|
|
919
|
+
}
|
|
920
|
+
return self;
|
|
921
|
+
},
|
|
922
|
+
handlers(h) {
|
|
923
|
+
for (const [k, fn] of Object.entries(h)) {
|
|
924
|
+
if (bag.handlers.has(k)) throw new Error(`duplicate handler for endpoint "${k}"`);
|
|
925
|
+
bag.handlers.set(k, fn);
|
|
926
|
+
}
|
|
927
|
+
return self;
|
|
928
|
+
},
|
|
929
|
+
handle(name, fn) {
|
|
930
|
+
if (bag.handlers.has(name)) throw new Error(`duplicate handler for endpoint "${name}"`);
|
|
931
|
+
bag.handlers.set(name, fn);
|
|
932
|
+
return self;
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
return self;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* View a running {@link Server} as a typed {@link LocalClient} for spec `S` — for
|
|
939
|
+
* calling endpoints in-process (full chain + validation, no HTTP) by name + data.
|
|
940
|
+
*
|
|
941
|
+
* @param app - the running server (its `call` is the in-process caller).
|
|
942
|
+
* @param spec - the spec to type against (its endpoints/`CallReturn` shape `S`).
|
|
943
|
+
*
|
|
944
|
+
* @example
|
|
945
|
+
* ```ts
|
|
946
|
+
* const users = localClient(app, usersSpec);
|
|
947
|
+
* const u = await users.call('getUser', { id: 'u1' }); // typed, in-process
|
|
948
|
+
* ```
|
|
949
|
+
*/
|
|
950
|
+
function localClient(app, spec) {
|
|
951
|
+
return app;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Assemble a {@link Server} from a spec and one or more {@link implement} builders.
|
|
955
|
+
*
|
|
956
|
+
* Every endpoint must have exactly one handler: a missing handler is a **compile
|
|
957
|
+
* error** that names the offending endpoints (via the final `error` argument), and
|
|
958
|
+
* a duplicate/unknown handler throws at startup. Every middleware in an endpoint or
|
|
959
|
+
* event-guard chain must be bound via `.middleware(def, impl)` — an unbound def
|
|
960
|
+
* throws at assembly.
|
|
961
|
+
*
|
|
962
|
+
* @param spec - the validated spec from {@link spec}.
|
|
963
|
+
* @param builders - one or more builders from {@link implement}.
|
|
964
|
+
* @param rest - `[options?]` when all handlers are present, else `[{ missingHandlers }]`.
|
|
965
|
+
*
|
|
966
|
+
* @example
|
|
967
|
+
* ```ts
|
|
968
|
+
* const app = server(api, [implement(api).middleware(auth, authImpl).handlers({ … })], { cors, broker })
|
|
969
|
+
* const res = await app.fetch(new Request('http://x/getUser/u1', { method: 'POST' }))
|
|
970
|
+
* ```
|
|
971
|
+
*/
|
|
972
|
+
function server(spec, builders, ...rest) {
|
|
973
|
+
const options = rest[0];
|
|
974
|
+
const table = /* @__PURE__ */ new Map();
|
|
975
|
+
const implMap = /* @__PURE__ */ new Map();
|
|
976
|
+
const eps = [];
|
|
977
|
+
const routes = [];
|
|
978
|
+
const events = [];
|
|
979
|
+
const byName = /* @__PURE__ */ new Map();
|
|
980
|
+
const byWs = /* @__PURE__ */ new Map();
|
|
981
|
+
const byRoute = /* @__PURE__ */ new Map();
|
|
982
|
+
const boundChains = /* @__PURE__ */ new Map();
|
|
983
|
+
const eventsByWs = /* @__PURE__ */ new Map();
|
|
984
|
+
const subscribers = /* @__PURE__ */ new Map();
|
|
985
|
+
const specDocs = [];
|
|
986
|
+
let manifestCache = null;
|
|
987
|
+
let openapiJsonCache = null;
|
|
988
|
+
let asyncapiJsonCache = null;
|
|
989
|
+
const invalidate = () => {
|
|
990
|
+
manifestCache = null;
|
|
991
|
+
openapiJsonCache = null;
|
|
992
|
+
asyncapiJsonCache = null;
|
|
993
|
+
};
|
|
994
|
+
const bind = (chain, where) => chain.map((m) => {
|
|
995
|
+
const impl = implMap.get(m);
|
|
996
|
+
if (!impl) throw new Error(`middleware "${m.name}" (${where}) has no implementation — bind it with implement(api).middleware(def, impl)`);
|
|
997
|
+
return {
|
|
998
|
+
...m,
|
|
999
|
+
run: impl
|
|
1000
|
+
};
|
|
1001
|
+
});
|
|
1002
|
+
function register(regSpec, regBuilders) {
|
|
1003
|
+
const newHandlers = [];
|
|
1004
|
+
const newImpls = [];
|
|
1005
|
+
for (const b of regBuilders) {
|
|
1006
|
+
for (const [k, fn] of b.__bag.handlers) {
|
|
1007
|
+
if (table.has(k) || newHandlers.some(([n]) => n === k)) throw new Error(`duplicate handler for endpoint "${k}"`);
|
|
1008
|
+
if (!(k in regSpec.endpoints)) throw new Error(`handler for unknown endpoint "${k}"`);
|
|
1009
|
+
newHandlers.push([k, fn]);
|
|
1010
|
+
}
|
|
1011
|
+
for (const [def, impl] of b.__bag.middleware) {
|
|
1012
|
+
if (implMap.has(def) || newImpls.some(([d]) => d === def)) throw new Error(`duplicate implementation for middleware "${def.name}"`);
|
|
1013
|
+
newImpls.push([def, impl]);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
for (const k of Object.keys(regSpec.endpoints)) if (!newHandlers.some(([n]) => n === k)) throw new Error(`missing handler for endpoint "${k}"`);
|
|
1017
|
+
const newEps = Object.entries(regSpec.endpoints).map(([name, def]) => normalizeEndpoint(name, def));
|
|
1018
|
+
for (const e of newEps) {
|
|
1019
|
+
if (e.wsEligible && byRoute.has(`${e.method} ${e.path}`)) throw new Error(`route "${e.method} ${e.path}" is already installed`);
|
|
1020
|
+
if (e.ws !== null && e.wsEligible && (byWs.has(e.ws) || eventsByWs.has(e.ws))) throw new Error(`ws id "${e.ws}" is already installed`);
|
|
1021
|
+
}
|
|
1022
|
+
const newEvents = Object.entries(regSpec.events ?? {}).map(([name, cfg]) => ({
|
|
1023
|
+
name,
|
|
1024
|
+
cfg,
|
|
1025
|
+
ws: cfg.ws ?? name
|
|
1026
|
+
}));
|
|
1027
|
+
for (const ev of newEvents) {
|
|
1028
|
+
if (events.some((x) => x.name === ev.name)) throw new Error(`event "${ev.name}" is already installed`);
|
|
1029
|
+
if (eventsByWs.has(ev.ws) || byWs.has(ev.ws) || newEps.some((e) => e.ws === ev.ws)) throw new Error(`event channel "${ev.ws}" collides with an existing channel or ws id`);
|
|
1030
|
+
}
|
|
1031
|
+
for (const [def, impl] of newImpls) implMap.set(def, impl);
|
|
1032
|
+
for (const [k, fn] of newHandlers) table.set(k, fn);
|
|
1033
|
+
for (const e of newEps) {
|
|
1034
|
+
boundChains.set(e, bind(e.chain, `endpoint "${e.name}"`));
|
|
1035
|
+
byName.set(e.name, e);
|
|
1036
|
+
eps.push(e);
|
|
1037
|
+
routes.push({
|
|
1038
|
+
e,
|
|
1039
|
+
parts: e.parts
|
|
1040
|
+
});
|
|
1041
|
+
if (e.wsEligible) byRoute.set(`${e.method} ${e.path}`, e);
|
|
1042
|
+
if (e.ws !== null && e.wsEligible) byWs.set(e.ws, e);
|
|
1043
|
+
}
|
|
1044
|
+
const liveEvents = newEvents.map((ev) => ({
|
|
1045
|
+
...ev,
|
|
1046
|
+
chain: bind(resolveChain(ev.cfg.guard ?? []), `event "${ev.name}"`)
|
|
1047
|
+
}));
|
|
1048
|
+
for (const ev of liveEvents) {
|
|
1049
|
+
events.push(ev);
|
|
1050
|
+
eventsByWs.set(ev.ws, ev);
|
|
1051
|
+
}
|
|
1052
|
+
if (regSpec.doc) specDocs.push(regSpec.doc);
|
|
1053
|
+
invalidate();
|
|
1054
|
+
return {
|
|
1055
|
+
eps: newEps,
|
|
1056
|
+
events: liveEvents,
|
|
1057
|
+
impls: newImpls.map(([d]) => d),
|
|
1058
|
+
doc: regSpec.doc
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
function unregister(handle) {
|
|
1062
|
+
for (const e of handle.eps) {
|
|
1063
|
+
boundChains.delete(e);
|
|
1064
|
+
byName.delete(e.name);
|
|
1065
|
+
table.delete(e.name);
|
|
1066
|
+
if (e.wsEligible) byRoute.delete(`${e.method} ${e.path}`);
|
|
1067
|
+
if (e.ws !== null && e.wsEligible) byWs.delete(e.ws);
|
|
1068
|
+
const ri = routes.findIndex((r) => r.e === e);
|
|
1069
|
+
if (ri >= 0) routes.splice(ri, 1);
|
|
1070
|
+
const ei = eps.indexOf(e);
|
|
1071
|
+
if (ei >= 0) eps.splice(ei, 1);
|
|
1072
|
+
}
|
|
1073
|
+
for (const ev of handle.events) {
|
|
1074
|
+
const i = events.indexOf(ev);
|
|
1075
|
+
if (i >= 0) events.splice(i, 1);
|
|
1076
|
+
eventsByWs.delete(ev.ws);
|
|
1077
|
+
for (const key of [...subscribers.keys()]) if (key === ev.ws || key.startsWith(`${ev.ws}|`)) subscribers.delete(key);
|
|
1078
|
+
}
|
|
1079
|
+
for (const def of handle.impls) implMap.delete(def);
|
|
1080
|
+
if (handle.doc) {
|
|
1081
|
+
const di = specDocs.indexOf(handle.doc);
|
|
1082
|
+
if (di >= 0) specDocs.splice(di, 1);
|
|
1083
|
+
}
|
|
1084
|
+
invalidate();
|
|
1085
|
+
}
|
|
1086
|
+
register(spec, builders);
|
|
1087
|
+
const buildManifest = () => ({
|
|
1088
|
+
endpoints: Object.fromEntries(eps.map((e) => [e.name, {
|
|
1089
|
+
method: e.method,
|
|
1090
|
+
path: e.path,
|
|
1091
|
+
ws: e.ws,
|
|
1092
|
+
httpOnly: e.httpOnly,
|
|
1093
|
+
streamIn: e.streamInCt,
|
|
1094
|
+
itemsIn: e.itemsIn,
|
|
1095
|
+
streamOut: e.streamOutCt,
|
|
1096
|
+
items: e.items,
|
|
1097
|
+
p: e.p,
|
|
1098
|
+
q: e.q,
|
|
1099
|
+
b: e.bRaw ? "raw" : e.b,
|
|
1100
|
+
f: e.f,
|
|
1101
|
+
hasBody: Boolean(e.def.cfg.body),
|
|
1102
|
+
hasHeaders: Boolean(e.def.cfg.headers),
|
|
1103
|
+
multi: e.multi,
|
|
1104
|
+
bodyEnc: e.bodyEnc
|
|
1105
|
+
}])),
|
|
1106
|
+
events: Object.fromEntries(events.map((ev) => [ev.name, {
|
|
1107
|
+
ws: ev.ws,
|
|
1108
|
+
hasParams: Boolean(ev.cfg.params)
|
|
1109
|
+
}]))
|
|
1110
|
+
});
|
|
1111
|
+
const getManifest = () => manifestCache ??= buildManifest();
|
|
1112
|
+
/** Compose every installed spec's doc patches into one (applied last, in install order). */
|
|
1113
|
+
const composeDoc = () => ({
|
|
1114
|
+
openapi: (doc) => specDocs.reduce((acc, d) => d.openapi ? d.openapi(acc) : acc, doc),
|
|
1115
|
+
asyncapi: (doc) => specDocs.reduce((acc, d) => d.asyncapi ? d.asyncapi(acc) : acc, doc)
|
|
1116
|
+
});
|
|
1117
|
+
const docs = normalizeDocs(options?.docs);
|
|
1118
|
+
const openapiJson = () => openapiJsonCache ??= JSON.stringify(buildOpenapi(eps, composeDoc(), docs?.info));
|
|
1119
|
+
const asyncapiJson = () => asyncapiJsonCache ??= JSON.stringify(buildAsyncapi(eps, events, composeDoc(), docs?.info));
|
|
1120
|
+
const jsonDoc = (body) => new Response(body, { headers: { "content-type": "application/json; charset=utf-8" } });
|
|
1121
|
+
const htmlDoc = (body) => new Response(body, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
1122
|
+
function docResponse(pathname) {
|
|
1123
|
+
if (!docs) return null;
|
|
1124
|
+
if (docs.openapiJson && pathname === docs.openapiJson) return jsonDoc(openapiJson());
|
|
1125
|
+
if (docs.asyncapiJson && pathname === docs.asyncapiJson) return jsonDoc(asyncapiJson());
|
|
1126
|
+
if (docs.swagger && docs.openapiJson && pathname === docs.swagger) return htmlDoc(swaggerHtml(docs.openapiJson));
|
|
1127
|
+
if (docs.redoc && docs.openapiJson && pathname === docs.redoc) return htmlDoc(redocHtml(docs.openapiJson));
|
|
1128
|
+
if (docs.asyncapi && docs.asyncapiJson && pathname === docs.asyncapi) return htmlDoc(asyncapiHtml(docs.asyncapiJson));
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
async function runChain(chain, req, rawParams, terminal, info) {
|
|
1132
|
+
const fns = metaFns(info.meta);
|
|
1133
|
+
let ctx = {};
|
|
1134
|
+
const loaderVals = {};
|
|
1135
|
+
const step = async (i) => {
|
|
1136
|
+
if (i >= chain.length) return terminal(ctx, loaderVals);
|
|
1137
|
+
const m = chain[i];
|
|
1138
|
+
let nextCalled = false;
|
|
1139
|
+
const io = {
|
|
1140
|
+
req,
|
|
1141
|
+
body: info.body,
|
|
1142
|
+
get ctx() {
|
|
1143
|
+
return ctx;
|
|
1144
|
+
},
|
|
1145
|
+
next: async (add) => {
|
|
1146
|
+
nextCalled = true;
|
|
1147
|
+
if (add) ctx = {
|
|
1148
|
+
...ctx,
|
|
1149
|
+
...add
|
|
1150
|
+
};
|
|
1151
|
+
return step(i + 1);
|
|
1152
|
+
},
|
|
1153
|
+
transport: info.transport,
|
|
1154
|
+
route: info.route,
|
|
1155
|
+
signal: info.signal,
|
|
1156
|
+
setHeader: fns.setHeader,
|
|
1157
|
+
status: fns.status
|
|
1158
|
+
};
|
|
1159
|
+
if (info.ws) io.ws = info.ws;
|
|
1160
|
+
if (m.paramKey && m.paramSchema) {
|
|
1161
|
+
if (!(m.paramKey in rawParams)) throw reject(400, "BAD_REQUEST", `missing path param "${m.paramKey}"`);
|
|
1162
|
+
const v = m.paramSchema.parse(rawParams[m.paramKey]);
|
|
1163
|
+
io.value = v;
|
|
1164
|
+
loaderVals[m.paramKey] = v;
|
|
1165
|
+
}
|
|
1166
|
+
const out = await m.run(io);
|
|
1167
|
+
if (out instanceof Response) return out;
|
|
1168
|
+
if (!nextCalled) throw new Error(`middleware "${m.name}" returned without calling next()`);
|
|
1169
|
+
return out;
|
|
1170
|
+
};
|
|
1171
|
+
return step(0);
|
|
1172
|
+
}
|
|
1173
|
+
const makeMeta = () => ({
|
|
1174
|
+
status: null,
|
|
1175
|
+
headers: [],
|
|
1176
|
+
length: null,
|
|
1177
|
+
committed: false
|
|
1178
|
+
});
|
|
1179
|
+
const metaFns = (meta) => ({
|
|
1180
|
+
status: (code) => {
|
|
1181
|
+
if (!meta) return;
|
|
1182
|
+
if (meta.committed) throw new Error("$status must be called before the response is committed");
|
|
1183
|
+
meta.status = code;
|
|
1184
|
+
},
|
|
1185
|
+
setHeader: (name, value) => {
|
|
1186
|
+
if (!meta) return;
|
|
1187
|
+
if (meta.committed) throw new Error("$setHeader must be called before the response is committed");
|
|
1188
|
+
meta.headers.push([name, value]);
|
|
1189
|
+
},
|
|
1190
|
+
setCookie: (name, value, opts) => {
|
|
1191
|
+
if (!meta) return;
|
|
1192
|
+
if (meta.committed) throw new Error("$setCookie must be called before the response is committed");
|
|
1193
|
+
meta.headers.push(["set-cookie", serializeCookie(name, value, opts)]);
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
/** disjoint kinds: split a flat data payload back into p/q/b by the endpoint's key tables */
|
|
1197
|
+
function kindsFromData(e, data) {
|
|
1198
|
+
if (e.bRaw) return {
|
|
1199
|
+
p: {},
|
|
1200
|
+
q: {},
|
|
1201
|
+
b: data
|
|
1202
|
+
};
|
|
1203
|
+
const p = {};
|
|
1204
|
+
const q = {};
|
|
1205
|
+
const b = {};
|
|
1206
|
+
const pSet = new Set(e.p);
|
|
1207
|
+
const qSet = new Set(e.q);
|
|
1208
|
+
const bSet = new Set(e.b ?? []);
|
|
1209
|
+
const fSet = new Set(e.f);
|
|
1210
|
+
for (const [k, v] of Object.entries(data ?? {})) if (pSet.has(k)) p[k] = v;
|
|
1211
|
+
else if (qSet.has(k)) q[k] = v;
|
|
1212
|
+
else if (bSet.has(k)) b[k] = v;
|
|
1213
|
+
else if (fSet.has(k)) continue;
|
|
1214
|
+
else throw reject(400, "VALIDATION", `key "${k}" does not belong to this endpoint`);
|
|
1215
|
+
return {
|
|
1216
|
+
p,
|
|
1217
|
+
q,
|
|
1218
|
+
b: e.b !== null ? b : void 0
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
/** root payload names owned by the framework — middleware ctx may not collide with them */
|
|
1222
|
+
const RESERVED_CTX = new Set([
|
|
1223
|
+
"data",
|
|
1224
|
+
"stream",
|
|
1225
|
+
"headers",
|
|
1226
|
+
"cookies",
|
|
1227
|
+
"out",
|
|
1228
|
+
"download",
|
|
1229
|
+
"length",
|
|
1230
|
+
"fail",
|
|
1231
|
+
"status",
|
|
1232
|
+
"header",
|
|
1233
|
+
"cookie",
|
|
1234
|
+
"req",
|
|
1235
|
+
"signal",
|
|
1236
|
+
"emit"
|
|
1237
|
+
]);
|
|
1238
|
+
function assemble(e, ctx, kinds, req, signal, streamCtl, meta) {
|
|
1239
|
+
const data = e.bRaw ? kinds.b : {
|
|
1240
|
+
...kinds.p,
|
|
1241
|
+
...kinds.q,
|
|
1242
|
+
...e.b !== null ? kinds.b : {},
|
|
1243
|
+
...kinds.f
|
|
1244
|
+
};
|
|
1245
|
+
const payload = {};
|
|
1246
|
+
for (const [k, v] of Object.entries(ctx)) {
|
|
1247
|
+
if (RESERVED_CTX.has(k)) throw new Error(`middleware ctx key "${k}" collides with a reserved payload name`);
|
|
1248
|
+
payload[k] = v;
|
|
1249
|
+
}
|
|
1250
|
+
if (e.bRaw || Object.keys(data).length > 0) payload.data = data;
|
|
1251
|
+
if (kinds.stream) payload.stream = kinds.stream;
|
|
1252
|
+
if (streamCtl) {
|
|
1253
|
+
payload.out = streamCtl.out;
|
|
1254
|
+
payload.download = streamCtl.download;
|
|
1255
|
+
payload.length = streamCtl.length;
|
|
1256
|
+
}
|
|
1257
|
+
if (e.def.cfg.headers) payload.headers = kinds.h;
|
|
1258
|
+
if (e.def.cfg.cookies) payload.cookies = kinds.ck;
|
|
1259
|
+
const errors = e.def.cfg.errors;
|
|
1260
|
+
if (errors && Object.keys(errors).length > 0) payload.fail = (status, data2) => {
|
|
1261
|
+
const schema = errors[status];
|
|
1262
|
+
if (!schema) throw new Error(`endpoint "${e.name}": fail(${status}) is not a declared error status`);
|
|
1263
|
+
throw new ApiFailure(status, schema.parse(data2));
|
|
1264
|
+
};
|
|
1265
|
+
const fns = metaFns(meta);
|
|
1266
|
+
payload.status = fns.status;
|
|
1267
|
+
payload.header = fns.setHeader;
|
|
1268
|
+
payload.cookie = fns.setCookie;
|
|
1269
|
+
payload.req = req;
|
|
1270
|
+
payload.signal = signal;
|
|
1271
|
+
payload.emit = emit;
|
|
1272
|
+
return payload;
|
|
1273
|
+
}
|
|
1274
|
+
async function parseFiles(e, form) {
|
|
1275
|
+
const files = {};
|
|
1276
|
+
for (const [key, schema] of Object.entries(e.def.cfg.files ?? {})) {
|
|
1277
|
+
const all = form.getAll(key).filter((v) => typeof v !== "string");
|
|
1278
|
+
const raw = schema instanceof z.ZodArray || schema instanceof z.ZodOptional && schema.unwrap() instanceof z.ZodArray ? all : all[0];
|
|
1279
|
+
files[key] = schema.parse(raw);
|
|
1280
|
+
}
|
|
1281
|
+
const rawBody = form.get("body");
|
|
1282
|
+
return {
|
|
1283
|
+
files,
|
|
1284
|
+
body: typeof rawBody === "string" ? JSON.parse(rawBody) : void 0
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
function queryToObject(e, sp) {
|
|
1288
|
+
const out = {};
|
|
1289
|
+
for (const k of e.q) {
|
|
1290
|
+
const vals = sp.getAll(k);
|
|
1291
|
+
if (vals.length === 0) continue;
|
|
1292
|
+
out[k] = vals.length === 1 ? vals[0] : vals;
|
|
1293
|
+
}
|
|
1294
|
+
return out;
|
|
1295
|
+
}
|
|
1296
|
+
async function invoke(e, req, rawParams, rawQuery, rawBody, rawFiles, stream, streamCtl, meta, signal, chainCtx) {
|
|
1297
|
+
const route = {
|
|
1298
|
+
kind: "endpoint",
|
|
1299
|
+
name: e.name,
|
|
1300
|
+
method: e.method,
|
|
1301
|
+
path: e.path,
|
|
1302
|
+
ws: e.ws
|
|
1303
|
+
};
|
|
1304
|
+
return runChain(boundChains.get(e), req, rawParams, async (ctx, loaderVals) => {
|
|
1305
|
+
const c = e.def.cfg;
|
|
1306
|
+
const cfgParamKeys = (objectKeys(c.params) ?? []).filter((k) => !e.loaders.has(k) && !e.tplSchemas.has(k));
|
|
1307
|
+
const picked = {};
|
|
1308
|
+
for (const k of cfgParamKeys) picked[k] = rawParams[k];
|
|
1309
|
+
const tplVals = {};
|
|
1310
|
+
for (const [k, schema] of e.tplSchemas) tplVals[k] = schema.parse(rawParams[k]);
|
|
1311
|
+
const p = {
|
|
1312
|
+
...loaderVals,
|
|
1313
|
+
...tplVals,
|
|
1314
|
+
...c.params ? c.params.parse(picked) : {}
|
|
1315
|
+
};
|
|
1316
|
+
const q = c.query ? c.query.parse(rawQuery) : {};
|
|
1317
|
+
const b = c.body ? c.body.parse(rawBody) : void 0;
|
|
1318
|
+
const f = {};
|
|
1319
|
+
if (rawFiles) for (const [k, schema] of Object.entries(c.files ?? {})) f[k] = rawFiles[k] !== void 0 ? rawFiles[k] : schema.parse(void 0);
|
|
1320
|
+
let h;
|
|
1321
|
+
if (c.headers) {
|
|
1322
|
+
const raw = {};
|
|
1323
|
+
for (const k of objectKeys(c.headers) ?? []) {
|
|
1324
|
+
const v = req.headers.get(k);
|
|
1325
|
+
if (v !== null) raw[k] = v;
|
|
1326
|
+
}
|
|
1327
|
+
h = c.headers.parse(raw);
|
|
1328
|
+
}
|
|
1329
|
+
let ck;
|
|
1330
|
+
if (c.cookies) ck = c.cookies.parse(parseCookieHeader(req.headers.get("cookie")));
|
|
1331
|
+
const result = await table.get(e.name)(assemble(e, ctx, {
|
|
1332
|
+
p,
|
|
1333
|
+
q,
|
|
1334
|
+
b,
|
|
1335
|
+
f: rawFiles ? f : {},
|
|
1336
|
+
stream,
|
|
1337
|
+
h,
|
|
1338
|
+
ck
|
|
1339
|
+
}, req, signal ?? req.signal, streamCtl, meta));
|
|
1340
|
+
if (c.streamOut) return result;
|
|
1341
|
+
if (c.responses) {
|
|
1342
|
+
const r = result;
|
|
1343
|
+
const schema = c.responses[r.status];
|
|
1344
|
+
if (!schema) throw new Error(`endpoint "${e.name}": status ${r.status} is not a declared response status`);
|
|
1345
|
+
return {
|
|
1346
|
+
status: r.status,
|
|
1347
|
+
data: schema.parse(r.data)
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
if (c.response) return c.response.parse(result);
|
|
1351
|
+
}, {
|
|
1352
|
+
route,
|
|
1353
|
+
transport: chainCtx.transport,
|
|
1354
|
+
signal: signal ?? req.signal,
|
|
1355
|
+
meta,
|
|
1356
|
+
ws: chainCtx.ws,
|
|
1357
|
+
body: rawBody
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
/** In-process call: route by name, split the flat data, run the full chain via {@link invoke} (transport `'local'`). */
|
|
1361
|
+
function callLocal(name, ...args) {
|
|
1362
|
+
const e = byName.get(name);
|
|
1363
|
+
if (!e) throw new Error(`unknown endpoint "${name}"`);
|
|
1364
|
+
const hasInput = e.bRaw || e.b !== null || e.p.length > 0 || e.q.length > 0;
|
|
1365
|
+
const data = hasInput ? args[0] : void 0;
|
|
1366
|
+
const opts = hasInput ? args[1] : args[0];
|
|
1367
|
+
const { p, q, b } = kindsFromData(e, data);
|
|
1368
|
+
return invoke(e, new Request(`http://local/${name}`, {
|
|
1369
|
+
method: e.method,
|
|
1370
|
+
headers: new Headers(opts?.headers ?? {})
|
|
1371
|
+
}), p, q, b, null, void 0, void 0, makeMeta(), opts?.signal, { transport: "local" });
|
|
1372
|
+
}
|
|
1373
|
+
function toStream(v) {
|
|
1374
|
+
if (v instanceof ReadableStream) return v;
|
|
1375
|
+
const iter = v;
|
|
1376
|
+
const enc = new TextEncoder();
|
|
1377
|
+
return new ReadableStream({ async start(controller) {
|
|
1378
|
+
for await (const chunk of iter) controller.enqueue(typeof chunk === "string" ? enc.encode(chunk) : chunk);
|
|
1379
|
+
controller.close();
|
|
1380
|
+
} });
|
|
1381
|
+
}
|
|
1382
|
+
function asyncQueue() {
|
|
1383
|
+
const buf = [];
|
|
1384
|
+
let done = false;
|
|
1385
|
+
let err;
|
|
1386
|
+
let wake = null;
|
|
1387
|
+
return {
|
|
1388
|
+
push(v) {
|
|
1389
|
+
buf.push(v);
|
|
1390
|
+
wake?.();
|
|
1391
|
+
},
|
|
1392
|
+
end() {
|
|
1393
|
+
done = true;
|
|
1394
|
+
wake?.();
|
|
1395
|
+
},
|
|
1396
|
+
fail(e) {
|
|
1397
|
+
err = e;
|
|
1398
|
+
done = true;
|
|
1399
|
+
wake?.();
|
|
1400
|
+
},
|
|
1401
|
+
async *[Symbol.asyncIterator]() {
|
|
1402
|
+
for (;;) {
|
|
1403
|
+
if (buf.length > 0) {
|
|
1404
|
+
yield buf.shift();
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
if (err !== void 0) throw err;
|
|
1408
|
+
if (done) return;
|
|
1409
|
+
await new Promise((r) => wake = r);
|
|
1410
|
+
wake = null;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
/** NDJSON request body → typed async iterable; each item validated as the handler pulls it. */
|
|
1416
|
+
async function* decodeItems(body, schema) {
|
|
1417
|
+
const reader = body.getReader();
|
|
1418
|
+
const dec = new TextDecoder();
|
|
1419
|
+
let buf = "";
|
|
1420
|
+
try {
|
|
1421
|
+
for (;;) {
|
|
1422
|
+
const { done, value } = await reader.read();
|
|
1423
|
+
if (done) break;
|
|
1424
|
+
buf += dec.decode(value, { stream: true });
|
|
1425
|
+
let nl;
|
|
1426
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1427
|
+
const line = buf.slice(0, nl);
|
|
1428
|
+
buf = buf.slice(nl + 1);
|
|
1429
|
+
if (line.trim()) yield schema.parse(JSON.parse(line));
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
buf += dec.decode();
|
|
1433
|
+
if (buf.trim()) yield schema.parse(JSON.parse(buf));
|
|
1434
|
+
} finally {
|
|
1435
|
+
await reader.cancel().catch(() => {});
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
/** Identity transform behind `out`: encodes strings, signals the first write, can error mid-stream. */
|
|
1439
|
+
function createOut() {
|
|
1440
|
+
const enc = new TextEncoder();
|
|
1441
|
+
let wrote = false;
|
|
1442
|
+
let resolveFirst;
|
|
1443
|
+
const firstWrite = new Promise((r) => resolveFirst = r);
|
|
1444
|
+
let ctl;
|
|
1445
|
+
const ts = new TransformStream({
|
|
1446
|
+
start(c) {
|
|
1447
|
+
ctl = c;
|
|
1448
|
+
},
|
|
1449
|
+
transform(chunk, c) {
|
|
1450
|
+
if (!wrote) {
|
|
1451
|
+
wrote = true;
|
|
1452
|
+
resolveFirst();
|
|
1453
|
+
}
|
|
1454
|
+
c.enqueue(typeof chunk === "string" ? enc.encode(chunk) : chunk);
|
|
1455
|
+
}
|
|
1456
|
+
}, void 0, { highWaterMark: OUT_HIGH_WATER_MARK });
|
|
1457
|
+
return {
|
|
1458
|
+
readable: ts.readable,
|
|
1459
|
+
writable: ts.writable,
|
|
1460
|
+
firstWrite,
|
|
1461
|
+
wrote: () => wrote,
|
|
1462
|
+
fail: (err) => {
|
|
1463
|
+
try {
|
|
1464
|
+
ctl.error(err);
|
|
1465
|
+
} catch {}
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
/** Typed item stream → NDJSON or SSE bytes; each item validated against the schema as it flows. */
|
|
1470
|
+
function itemStream(v, schema, sse) {
|
|
1471
|
+
const it = v[Symbol.asyncIterator]();
|
|
1472
|
+
const enc = new TextEncoder();
|
|
1473
|
+
return new ReadableStream({
|
|
1474
|
+
async pull(controller) {
|
|
1475
|
+
const { done, value } = await it.next();
|
|
1476
|
+
if (done) return controller.close();
|
|
1477
|
+
const json = JSON.stringify(schema.parse(value));
|
|
1478
|
+
controller.enqueue(enc.encode(sse ? `data: ${json}\n\n` : json + "\n"));
|
|
1479
|
+
},
|
|
1480
|
+
async cancel() {
|
|
1481
|
+
await it.return?.(void 0);
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
/** Byte-range slicer for resumable downloads: skip `start`, stop after `end` (inclusive). */
|
|
1486
|
+
function sliceStream(src, start, end) {
|
|
1487
|
+
let pos = 0;
|
|
1488
|
+
const reader = src.getReader();
|
|
1489
|
+
return new ReadableStream({
|
|
1490
|
+
async pull(controller) {
|
|
1491
|
+
for (;;) {
|
|
1492
|
+
const { done, value } = await reader.read();
|
|
1493
|
+
if (done) return controller.close();
|
|
1494
|
+
const from = pos;
|
|
1495
|
+
pos += value.byteLength;
|
|
1496
|
+
if (pos <= start) continue;
|
|
1497
|
+
const sliceFrom = Math.max(0, start - from);
|
|
1498
|
+
const sliceTo = Math.min(value.byteLength, end + 1 - from);
|
|
1499
|
+
controller.enqueue(value.subarray(sliceFrom, sliceTo));
|
|
1500
|
+
if (pos > end) {
|
|
1501
|
+
await reader.cancel().catch(() => {});
|
|
1502
|
+
return controller.close();
|
|
1503
|
+
}
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
},
|
|
1507
|
+
async cancel() {
|
|
1508
|
+
await reader.cancel().catch(() => {});
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
function parseRange(header) {
|
|
1513
|
+
const m = header ? /^bytes=(\d+)-(\d*)$/.exec(header) : null;
|
|
1514
|
+
if (!m) return null;
|
|
1515
|
+
return {
|
|
1516
|
+
start: Number(m[1]),
|
|
1517
|
+
end: m[2] ? Number(m[2]) : null
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
function errorResponse(err) {
|
|
1521
|
+
if (err instanceof ApiFailure) return Response.json(err.data, { status: err.status });
|
|
1522
|
+
if (err instanceof ApiError) return Response.json({ error: {
|
|
1523
|
+
code: err.code,
|
|
1524
|
+
message: err.message
|
|
1525
|
+
} }, { status: err.status });
|
|
1526
|
+
if (err instanceof z.ZodError) return Response.json({ error: {
|
|
1527
|
+
code: "VALIDATION",
|
|
1528
|
+
issues: err.issues
|
|
1529
|
+
} }, { status: 400 });
|
|
1530
|
+
return Response.json({ error: {
|
|
1531
|
+
code: "INTERNAL",
|
|
1532
|
+
message: err instanceof Error ? err.message : "internal error"
|
|
1533
|
+
} }, { status: 500 });
|
|
1534
|
+
}
|
|
1535
|
+
async function fetchHandler(req) {
|
|
1536
|
+
const url = new URL(req.url);
|
|
1537
|
+
if (req.method === "GET") {
|
|
1538
|
+
const doc = docResponse(url.pathname);
|
|
1539
|
+
if (doc) return doc;
|
|
1540
|
+
}
|
|
1541
|
+
const effMethod = req.method === "HEAD" ? "GET" : req.method;
|
|
1542
|
+
for (const { e, parts } of routes) {
|
|
1543
|
+
if (effMethod !== e.method) continue;
|
|
1544
|
+
const matched = matchParts(parts, url.pathname);
|
|
1545
|
+
if (!matched) continue;
|
|
1546
|
+
try {
|
|
1547
|
+
const rawParams = { ...matched };
|
|
1548
|
+
const c = e.def.cfg;
|
|
1549
|
+
let rawBody;
|
|
1550
|
+
let rawFiles = null;
|
|
1551
|
+
let stream;
|
|
1552
|
+
if (c.streamIn) {
|
|
1553
|
+
const body = req.body ?? new ReadableStream({ start: (ctl) => ctl.close() });
|
|
1554
|
+
stream = e.itemsIn ? decodeItems(body, c.streamIn) : body;
|
|
1555
|
+
} else if (e.f.length > 0) {
|
|
1556
|
+
const parsed = await parseFiles(e, await req.formData());
|
|
1557
|
+
rawFiles = parsed.files;
|
|
1558
|
+
rawBody = parsed.body;
|
|
1559
|
+
} else if (c.body) if (e.bodyEnc === "urlencoded") {
|
|
1560
|
+
const form = new URLSearchParams(await req.text());
|
|
1561
|
+
const obj = {};
|
|
1562
|
+
for (const k of new Set(form.keys())) {
|
|
1563
|
+
const all = form.getAll(k);
|
|
1564
|
+
obj[k] = all.length === 1 ? all[0] : all;
|
|
1565
|
+
}
|
|
1566
|
+
rawBody = obj;
|
|
1567
|
+
} else rawBody = await req.json();
|
|
1568
|
+
const rawQuery = queryToObject(e, url.searchParams);
|
|
1569
|
+
const meta = makeMeta();
|
|
1570
|
+
if (c.streamOut && !e.items) {
|
|
1571
|
+
const t = createOut();
|
|
1572
|
+
const dl = {
|
|
1573
|
+
filename: c.download ?? null,
|
|
1574
|
+
ct: e.streamOutCt,
|
|
1575
|
+
committed: false
|
|
1576
|
+
};
|
|
1577
|
+
const download = (filename, contentType) => {
|
|
1578
|
+
if (dl.committed) throw new Error("$download must be called before streaming starts");
|
|
1579
|
+
dl.filename = filename;
|
|
1580
|
+
if (contentType) dl.ct = contentType;
|
|
1581
|
+
};
|
|
1582
|
+
const length = (totalBytes) => {
|
|
1583
|
+
if (meta.committed) throw new Error("$length must be called before streaming starts");
|
|
1584
|
+
meta.length = totalBytes;
|
|
1585
|
+
};
|
|
1586
|
+
const handlerDone = invoke(e, req, rawParams, rawQuery, rawBody, rawFiles, stream, {
|
|
1587
|
+
out: t.writable,
|
|
1588
|
+
download,
|
|
1589
|
+
length
|
|
1590
|
+
}, meta, void 0, { transport: "http" });
|
|
1591
|
+
const winner = await Promise.race([t.firstWrite.then(() => ({ kind: "wrote" })), handlerDone.then((result) => ({
|
|
1592
|
+
kind: "done",
|
|
1593
|
+
result
|
|
1594
|
+
}), (err) => ({
|
|
1595
|
+
kind: "fail",
|
|
1596
|
+
err
|
|
1597
|
+
}))]);
|
|
1598
|
+
if (winner.kind === "fail" && !t.wrote()) return errorResponse(winner.err);
|
|
1599
|
+
if (winner.kind === "done" && winner.result instanceof Response) {
|
|
1600
|
+
if (!t.writable.locked) t.writable.close().catch(() => {});
|
|
1601
|
+
return winner.result;
|
|
1602
|
+
}
|
|
1603
|
+
/* v8 ignore next 3 */ if (winner.kind === "done" && winner.result != null && t.wrote()) return errorResponse(/* @__PURE__ */ new Error(`endpoint "${e.name}": handler both wrote to $out and returned a stream`));
|
|
1604
|
+
dl.committed = true;
|
|
1605
|
+
meta.committed = true;
|
|
1606
|
+
const headers = new Headers(meta.headers);
|
|
1607
|
+
headers.set("content-type", dl.ct);
|
|
1608
|
+
headers.set("accept-ranges", "bytes");
|
|
1609
|
+
if (dl.filename) headers.set("content-disposition", `attachment; filename="${dl.filename}"`);
|
|
1610
|
+
let body;
|
|
1611
|
+
if (winner.kind === "done" && winner.result != null) body = toStream(winner.result);
|
|
1612
|
+
else {
|
|
1613
|
+
if (winner.kind === "done" && !t.wrote() && !t.writable.locked) t.writable.close();
|
|
1614
|
+
else handlerDone.then((result) => {
|
|
1615
|
+
if (result != null) t.fail(/* @__PURE__ */ new Error(`endpoint "${e.name}": handler both wrote to $out and returned a stream`));
|
|
1616
|
+
}, (err) => t.fail(err));
|
|
1617
|
+
body = t.readable;
|
|
1618
|
+
}
|
|
1619
|
+
const range = e.method === "GET" ? parseRange(req.headers.get("range")) : null;
|
|
1620
|
+
if (meta.length !== null) {
|
|
1621
|
+
if (range) {
|
|
1622
|
+
const start = range.start;
|
|
1623
|
+
const end = Math.min(range.end ?? meta.length - 1, meta.length - 1);
|
|
1624
|
+
if (start >= meta.length || start > end) {
|
|
1625
|
+
body.cancel().catch(() => {});
|
|
1626
|
+
headers.set("content-range", `bytes */${meta.length}`);
|
|
1627
|
+
return new Response(null, {
|
|
1628
|
+
status: 416,
|
|
1629
|
+
headers
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
headers.set("content-range", `bytes ${start}-${end}/${meta.length}`);
|
|
1633
|
+
headers.set("content-length", String(end - start + 1));
|
|
1634
|
+
return new Response(sliceStream(body, start, end), {
|
|
1635
|
+
status: 206,
|
|
1636
|
+
headers
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
headers.set("content-length", String(meta.length));
|
|
1640
|
+
}
|
|
1641
|
+
return new Response(body, {
|
|
1642
|
+
status: meta.status ?? 200,
|
|
1643
|
+
headers
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
const result = await invoke(e, req, rawParams, rawQuery, rawBody, rawFiles, stream, void 0, meta, void 0, { transport: "http" });
|
|
1647
|
+
if (result instanceof Response) return result;
|
|
1648
|
+
meta.committed = true;
|
|
1649
|
+
const headers = new Headers(meta.headers);
|
|
1650
|
+
if (c.streamOut) {
|
|
1651
|
+
headers.set("content-type", e.streamOutCt);
|
|
1652
|
+
return new Response(itemStream(result, c.streamOut, e.sse), {
|
|
1653
|
+
status: meta.status ?? 200,
|
|
1654
|
+
headers
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
if (c.responses) {
|
|
1658
|
+
const r = result;
|
|
1659
|
+
return Response.json(r.data, {
|
|
1660
|
+
status: r.status,
|
|
1661
|
+
headers
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
if (c.response) return Response.json(result, {
|
|
1665
|
+
status: meta.status ?? 200,
|
|
1666
|
+
headers
|
|
1667
|
+
});
|
|
1668
|
+
return new Response(null, {
|
|
1669
|
+
status: meta.status ?? 204,
|
|
1670
|
+
headers
|
|
1671
|
+
});
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
return errorResponse(err);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return Response.json({ error: { code: "NOT_FOUND" } }, { status: 404 });
|
|
1677
|
+
}
|
|
1678
|
+
function corsHeadersFor(req) {
|
|
1679
|
+
const cors = options?.cors;
|
|
1680
|
+
if (!cors) return null;
|
|
1681
|
+
const origin = req.headers.get("origin");
|
|
1682
|
+
if (!origin) return null;
|
|
1683
|
+
const allowed = cors.origin === "*" ? "*" : typeof cors.origin === "string" ? cors.origin === origin ? origin : null : cors.origin.includes(origin) ? origin : null;
|
|
1684
|
+
if (!allowed) return null;
|
|
1685
|
+
const h = { "access-control-allow-origin": allowed };
|
|
1686
|
+
if (cors.credentials) h["access-control-allow-credentials"] = "true";
|
|
1687
|
+
if (allowed !== "*") h.vary = "origin";
|
|
1688
|
+
if (cors.exposeHeaders?.length) h["access-control-expose-headers"] = cors.exposeHeaders.join(", ");
|
|
1689
|
+
return h;
|
|
1690
|
+
}
|
|
1691
|
+
async function fetchEntry(req) {
|
|
1692
|
+
const ch = corsHeadersFor(req);
|
|
1693
|
+
if (options?.cors && req.method === "OPTIONS" && req.headers.get("access-control-request-method")) {
|
|
1694
|
+
const headers = new Headers(ch ?? {});
|
|
1695
|
+
headers.set("access-control-allow-methods", [...new Set(eps.map((e) => e.method))].join(", "));
|
|
1696
|
+
headers.set("access-control-allow-headers", req.headers.get("access-control-request-headers") ?? "*");
|
|
1697
|
+
if (options.cors.maxAge !== void 0) headers.set("access-control-max-age", String(options.cors.maxAge));
|
|
1698
|
+
return new Response(null, {
|
|
1699
|
+
status: 204,
|
|
1700
|
+
headers
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
let res = await fetchHandler(req);
|
|
1704
|
+
if (ch || req.method === "HEAD") {
|
|
1705
|
+
const headers = new Headers(res.headers);
|
|
1706
|
+
for (const [k, v] of Object.entries(ch ?? {})) headers.set(k, v);
|
|
1707
|
+
if (req.method === "HEAD") {
|
|
1708
|
+
res.body?.cancel().catch(() => {});
|
|
1709
|
+
return new Response(null, {
|
|
1710
|
+
status: res.status,
|
|
1711
|
+
headers
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
res = new Response(res.body, {
|
|
1715
|
+
status: res.status,
|
|
1716
|
+
headers
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
return res;
|
|
1720
|
+
}
|
|
1721
|
+
let connSeq = 0;
|
|
1722
|
+
const canon = (v) => JSON.stringify(v, Object.keys(v ?? {}).sort());
|
|
1723
|
+
const subKey = (ws, params) => `${ws}|${canon(params ?? {})}`;
|
|
1724
|
+
const broker = options?.broker ?? localBroker();
|
|
1725
|
+
function deliverLocal(msg) {
|
|
1726
|
+
const frame = JSON.stringify({
|
|
1727
|
+
type: msg.ch,
|
|
1728
|
+
params: msg.params,
|
|
1729
|
+
data: msg.data
|
|
1730
|
+
});
|
|
1731
|
+
const conns = subscribers.get(subKey(msg.ch, msg.params));
|
|
1732
|
+
if (conns) for (const c of conns) try {
|
|
1733
|
+
c.send(frame);
|
|
1734
|
+
} catch {}
|
|
1735
|
+
}
|
|
1736
|
+
broker.subscribe((raw) => {
|
|
1737
|
+
try {
|
|
1738
|
+
deliverLocal(JSON.parse(raw));
|
|
1739
|
+
} catch {}
|
|
1740
|
+
});
|
|
1741
|
+
function emitImpl(name, ...args) {
|
|
1742
|
+
const ev = events.find((x) => x.name === name);
|
|
1743
|
+
if (!ev) throw new Error(`unknown event "${name}"`);
|
|
1744
|
+
const [params, data] = ev.cfg.params ? [args[0], args[1]] : [void 0, args[0]];
|
|
1745
|
+
const pOut = ev.cfg.params ? ev.cfg.params.parse(params) : {};
|
|
1746
|
+
const dOut = ev.cfg.data.parse(data);
|
|
1747
|
+
try {
|
|
1748
|
+
const published = broker.publish(JSON.stringify({
|
|
1749
|
+
ch: ev.ws,
|
|
1750
|
+
params: pOut,
|
|
1751
|
+
data: dOut
|
|
1752
|
+
}));
|
|
1753
|
+
if (published) Promise.resolve(published).catch(() => {});
|
|
1754
|
+
} catch {}
|
|
1755
|
+
}
|
|
1756
|
+
const emit = emitImpl;
|
|
1757
|
+
/** Map a middleware short-circuit Response to a ws frame: JSON body → result frame, else error frame. */
|
|
1758
|
+
async function sendShortCircuit(conn, id, res) {
|
|
1759
|
+
const isJson = (res.headers.get("content-type") ?? "").includes("application/json");
|
|
1760
|
+
if (res.ok && isJson) {
|
|
1761
|
+
const data = await res.json().catch(() => null);
|
|
1762
|
+
conn.send(JSON.stringify({
|
|
1763
|
+
id,
|
|
1764
|
+
$status: res.status,
|
|
1765
|
+
data
|
|
1766
|
+
}));
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
let data = null;
|
|
1770
|
+
try {
|
|
1771
|
+
data = isJson ? await res.json() : await res.text();
|
|
1772
|
+
} catch {}
|
|
1773
|
+
conn.send(JSON.stringify({
|
|
1774
|
+
id,
|
|
1775
|
+
$status: res.status,
|
|
1776
|
+
$code: "SHORT_CIRCUIT",
|
|
1777
|
+
$error: typeof data === "string" ? data : void 0,
|
|
1778
|
+
data
|
|
1779
|
+
}));
|
|
1780
|
+
}
|
|
1781
|
+
return {
|
|
1782
|
+
spec,
|
|
1783
|
+
fetch: fetchEntry,
|
|
1784
|
+
manifest: getManifest,
|
|
1785
|
+
emit,
|
|
1786
|
+
openapi: (info) => buildOpenapi(eps, composeDoc(), info),
|
|
1787
|
+
asyncapi: (info) => buildAsyncapi(eps, events, composeDoc(), info),
|
|
1788
|
+
call: callLocal,
|
|
1789
|
+
install: ((regSpec, regBuilders) => register(regSpec, regBuilders)),
|
|
1790
|
+
uninstall: unregister,
|
|
1791
|
+
ws: {
|
|
1792
|
+
open(send, req) {
|
|
1793
|
+
return {
|
|
1794
|
+
id: ++connSeq,
|
|
1795
|
+
req,
|
|
1796
|
+
send,
|
|
1797
|
+
subs: /* @__PURE__ */ new Set(),
|
|
1798
|
+
streams: /* @__PURE__ */ new Map(),
|
|
1799
|
+
calls: /* @__PURE__ */ new Map()
|
|
1800
|
+
};
|
|
1801
|
+
},
|
|
1802
|
+
async message(conn, raw) {
|
|
1803
|
+
let frame;
|
|
1804
|
+
try {
|
|
1805
|
+
frame = JSON.parse(raw);
|
|
1806
|
+
} catch {
|
|
1807
|
+
conn.send(JSON.stringify({
|
|
1808
|
+
$status: 400,
|
|
1809
|
+
$code: "BAD_FRAME",
|
|
1810
|
+
$error: "bad frame"
|
|
1811
|
+
}));
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
const id = frame.id;
|
|
1815
|
+
const fail = (err) => {
|
|
1816
|
+
const out = err instanceof ApiFailure ? {
|
|
1817
|
+
id,
|
|
1818
|
+
$status: err.status,
|
|
1819
|
+
data: err.data
|
|
1820
|
+
} : err instanceof ApiError ? {
|
|
1821
|
+
id,
|
|
1822
|
+
$status: err.status,
|
|
1823
|
+
$code: err.code,
|
|
1824
|
+
$error: err.message
|
|
1825
|
+
} : err instanceof z.ZodError ? {
|
|
1826
|
+
id,
|
|
1827
|
+
$status: 400,
|
|
1828
|
+
$code: "VALIDATION",
|
|
1829
|
+
$error: "VALIDATION",
|
|
1830
|
+
data: { issues: err.issues }
|
|
1831
|
+
} : {
|
|
1832
|
+
id,
|
|
1833
|
+
$status: 500,
|
|
1834
|
+
$code: "INTERNAL",
|
|
1835
|
+
$error: err instanceof Error ? err.message : "internal error"
|
|
1836
|
+
};
|
|
1837
|
+
conn.send(JSON.stringify(out));
|
|
1838
|
+
};
|
|
1839
|
+
try {
|
|
1840
|
+
if (frame.ping === true) {
|
|
1841
|
+
conn.send(JSON.stringify({ pong: true }));
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
if (typeof frame.sub === "string") {
|
|
1845
|
+
const ev = eventsByWs.get(frame.sub);
|
|
1846
|
+
if (!ev) throw reject(404, "NOT_FOUND", `unknown channel "${frame.sub}"`);
|
|
1847
|
+
const pOut = ev.cfg.params ? ev.cfg.params.parse(frame.params) : {};
|
|
1848
|
+
const guard = await runChain(ev.chain, conn.req, {}, async () => void 0, {
|
|
1849
|
+
route: {
|
|
1850
|
+
kind: "event",
|
|
1851
|
+
name: ev.name,
|
|
1852
|
+
ws: ev.ws
|
|
1853
|
+
},
|
|
1854
|
+
transport: "ws",
|
|
1855
|
+
signal: conn.req.signal,
|
|
1856
|
+
meta: void 0,
|
|
1857
|
+
ws: {
|
|
1858
|
+
id: String(id),
|
|
1859
|
+
data: frame.params,
|
|
1860
|
+
conn
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
if (guard instanceof Response) {
|
|
1864
|
+
await sendShortCircuit(conn, id, guard);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const key = subKey(ev.ws, pOut);
|
|
1868
|
+
let set = subscribers.get(key);
|
|
1869
|
+
if (!set) subscribers.set(key, set = /* @__PURE__ */ new Set());
|
|
1870
|
+
set.add(conn);
|
|
1871
|
+
conn.subs.add(key);
|
|
1872
|
+
conn.send(JSON.stringify({
|
|
1873
|
+
id,
|
|
1874
|
+
$status: 200
|
|
1875
|
+
}));
|
|
1876
|
+
} else if (typeof frame.unsub === "string") {
|
|
1877
|
+
const ev = eventsByWs.get(frame.unsub);
|
|
1878
|
+
if (ev) {
|
|
1879
|
+
const pOut = ev.cfg.params ? ev.cfg.params.parse(frame.params) : {};
|
|
1880
|
+
const key = subKey(ev.ws, pOut);
|
|
1881
|
+
subscribers.get(key)?.delete(conn);
|
|
1882
|
+
conn.subs.delete(key);
|
|
1883
|
+
}
|
|
1884
|
+
conn.send(JSON.stringify({
|
|
1885
|
+
id,
|
|
1886
|
+
$status: 200
|
|
1887
|
+
}));
|
|
1888
|
+
} else if ("chunk" in frame) conn.streams.get(String(id))?.push(frame.chunk);
|
|
1889
|
+
else if (frame.end === true) conn.streams.get(String(id))?.end();
|
|
1890
|
+
else if (frame.abort === true) {
|
|
1891
|
+
conn.calls.get(String(id))?.abort();
|
|
1892
|
+
conn.streams.get(String(id))?.fail(new ApiError(ABORTED_STATUS, "ABORTED"));
|
|
1893
|
+
} else if (typeof frame.type === "string") {
|
|
1894
|
+
const e = typeof frame.method === "string" ? byRoute.get(`${frame.method} ${frame.type}`) : byWs.get(frame.type);
|
|
1895
|
+
if (!e) throw reject(404, "NOT_FOUND", `unknown ws endpoint "${frame.type}"`);
|
|
1896
|
+
const { p, q, b } = kindsFromData(e, frame.data);
|
|
1897
|
+
const ac = new AbortController();
|
|
1898
|
+
conn.calls.set(String(id), ac);
|
|
1899
|
+
const wsMeta = makeMeta();
|
|
1900
|
+
let stream;
|
|
1901
|
+
if (e.itemsIn) {
|
|
1902
|
+
const queue = asyncQueue();
|
|
1903
|
+
conn.streams.set(String(id), queue);
|
|
1904
|
+
const schema = e.def.cfg.streamIn;
|
|
1905
|
+
stream = (async function* () {
|
|
1906
|
+
for await (const raw of queue) yield schema.parse(raw);
|
|
1907
|
+
})();
|
|
1908
|
+
}
|
|
1909
|
+
try {
|
|
1910
|
+
const result = await invoke(e, conn.req, p, q, b, null, stream, void 0, wsMeta, ac.signal, {
|
|
1911
|
+
transport: "ws",
|
|
1912
|
+
ws: {
|
|
1913
|
+
id: String(id),
|
|
1914
|
+
data: frame.data,
|
|
1915
|
+
conn
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
if (result instanceof Response) await sendShortCircuit(conn, id, result);
|
|
1919
|
+
else if (e.items) {
|
|
1920
|
+
const schema = e.def.cfg.streamOut;
|
|
1921
|
+
for await (const item of result) {
|
|
1922
|
+
if (ac.signal.aborted) break;
|
|
1923
|
+
conn.send(JSON.stringify({
|
|
1924
|
+
id,
|
|
1925
|
+
chunk: schema.parse(item)
|
|
1926
|
+
}));
|
|
1927
|
+
}
|
|
1928
|
+
if (!ac.signal.aborted) conn.send(JSON.stringify({
|
|
1929
|
+
id,
|
|
1930
|
+
end: true
|
|
1931
|
+
}));
|
|
1932
|
+
} else if (e.multi) {
|
|
1933
|
+
const r = result;
|
|
1934
|
+
if (!ac.signal.aborted) conn.send(JSON.stringify({
|
|
1935
|
+
id,
|
|
1936
|
+
$status: r.status,
|
|
1937
|
+
data: {
|
|
1938
|
+
status: r.status,
|
|
1939
|
+
data: r.data
|
|
1940
|
+
}
|
|
1941
|
+
}));
|
|
1942
|
+
} else if (!ac.signal.aborted) conn.send(JSON.stringify({
|
|
1943
|
+
id,
|
|
1944
|
+
$status: wsMeta.status ?? 200,
|
|
1945
|
+
data: result
|
|
1946
|
+
}));
|
|
1947
|
+
} finally {
|
|
1948
|
+
conn.streams.delete(String(id));
|
|
1949
|
+
conn.calls.delete(String(id));
|
|
1950
|
+
}
|
|
1951
|
+
} else throw reject(400, "BAD_FRAME", "unrecognized frame");
|
|
1952
|
+
} catch (err) {
|
|
1953
|
+
fail(err);
|
|
1954
|
+
}
|
|
1955
|
+
},
|
|
1956
|
+
close(conn) {
|
|
1957
|
+
for (const key of conn.subs) subscribers.get(key)?.delete(conn);
|
|
1958
|
+
conn.subs.clear();
|
|
1959
|
+
for (const ac of conn.calls.values()) ac.abort();
|
|
1960
|
+
conn.calls.clear();
|
|
1961
|
+
for (const s of conn.streams.values()) s.fail(new ApiError(ABORTED_STATUS, "ABORTED"));
|
|
1962
|
+
conn.streams.clear();
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
}
|
|
1967
|
+
//#endregion
|
|
1968
|
+
export { ApiError, CallerRateLimited, DEFAULT_BUCKETS, DEFAULT_RETRY_OPTIONS, RetryAbort, asyncapiHtml, backoff, buildParts, client, createCallerContext, createClientCache, createMetrics, ctx, endpoint, formatPrometheus, getDefaultRetryOptions, implement, joinPattern, localBroker, localClient, manifestFromSpec, matchParts, middleware, path, provide, redocHtml, reject, retry, server, setDefaultRetryOptions, spec, splitPattern, stableStringify, swaggerHtml, use, wsTransport };
|