@fluixi/api 0.0.0-stage → 0.1.0-alpha.3

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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -2
  3. package/dist/client.cjs +103 -0
  4. package/dist/client.d.ts +95 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +87 -0
  7. package/dist/client.mjs +82 -0
  8. package/dist/errors.cjs +63 -0
  9. package/dist/errors.d.ts +47 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +55 -0
  12. package/dist/errors.mjs +42 -0
  13. package/dist/index.cjs +667 -0
  14. package/dist/index.d.ts +34 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +26 -0
  17. package/dist/index.mjs +5442 -0
  18. package/dist/input.cjs +91 -0
  19. package/dist/input.d.ts +15 -0
  20. package/dist/input.d.ts.map +1 -0
  21. package/dist/input.js +73 -0
  22. package/dist/input.mjs +68 -0
  23. package/dist/middleware.cjs +41 -0
  24. package/dist/middleware.d.ts +9 -0
  25. package/dist/middleware.d.ts.map +1 -0
  26. package/dist/middleware.js +19 -0
  27. package/dist/middleware.mjs +20 -0
  28. package/dist/openapi.cjs +282 -0
  29. package/dist/openapi.d.ts +60 -0
  30. package/dist/openapi.d.ts.map +1 -0
  31. package/dist/openapi.js +312 -0
  32. package/dist/openapi.mjs +259 -0
  33. package/dist/rate-limit.cjs +158 -0
  34. package/dist/rate-limit.d.ts +69 -0
  35. package/dist/rate-limit.d.ts.map +1 -0
  36. package/dist/rate-limit.js +121 -0
  37. package/dist/rate-limit.mjs +4935 -0
  38. package/dist/route.cjs +192 -0
  39. package/dist/route.d.ts +97 -0
  40. package/dist/route.d.ts.map +1 -0
  41. package/dist/route.js +98 -0
  42. package/dist/route.mjs +169 -0
  43. package/dist/security.cjs +222 -0
  44. package/dist/security.d.ts +64 -0
  45. package/dist/security.d.ts.map +1 -0
  46. package/dist/security.js +135 -0
  47. package/dist/security.mjs +4997 -0
  48. package/dist/standard-schema.cjs +18 -0
  49. package/dist/standard-schema.d.ts +39 -0
  50. package/dist/standard-schema.d.ts.map +1 -0
  51. package/dist/standard-schema.js +6 -0
  52. package/dist/standard-schema.mjs +0 -0
  53. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  54. package/package.json +54 -3
package/dist/index.cjs ADDED
@@ -0,0 +1,667 @@
1
+ /*! @fluixi/api v0.1.0-alpha.3 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/index.ts
22
+ var index_exports = {};
23
+ __export(index_exports, {
24
+ ApiError: () => ApiError,
25
+ CONTRACT: () => CONTRACT,
26
+ DEFAULT_BODY_LIMIT: () => DEFAULT_BODY_LIMIT,
27
+ DEFAULT_SECURITY_HEADERS: () => DEFAULT_SECURITY_HEADERS,
28
+ REVALIDATE_HEADER: () => REVALIDATE_HEADER,
29
+ apiSecurity: () => apiSecurity,
30
+ badRequest: () => badRequest,
31
+ conflict: () => conflict,
32
+ contractOf: () => contractOf,
33
+ cors: () => cors,
34
+ createOpenApiDocument: () => createOpenApiDocument,
35
+ describedBy: () => describedBy,
36
+ errorResponse: () => errorResponse,
37
+ forbidden: () => forbidden,
38
+ includeDocument: () => includeDocument,
39
+ memoryRateLimitStore: () => memoryRateLimitStore,
40
+ notFound: () => notFound,
41
+ openApiRoute: () => openApiRoute,
42
+ originCheck: () => originCheck,
43
+ postgresRateLimitStore: () => postgresRateLimitStore,
44
+ rateLimit: () => rateLimit,
45
+ readJson: () => readJson,
46
+ readQuery: () => readQuery,
47
+ route: () => route,
48
+ securityHeaders: () => securityHeaders,
49
+ setErrorReporter: () => setErrorReporter,
50
+ setJsonSchemaConverter: () => setJsonSchemaConverter,
51
+ toApiError: () => toApiError,
52
+ unauthorized: () => unauthorized
53
+ });
54
+ module.exports = __toCommonJS(index_exports);
55
+
56
+ // src/errors.ts
57
+ var ApiError = class extends Error {
58
+ constructor(status, code, message, details) {
59
+ super(message);
60
+ this.name = "ApiError";
61
+ this.status = status;
62
+ this.code = code;
63
+ this.details = details;
64
+ }
65
+ };
66
+ var badRequest = (message, details) => new ApiError(400, "bad_request", message, details);
67
+ var unauthorized = (message = "Sign in to continue.") => new ApiError(401, "unauthorized", message);
68
+ var forbidden = (message = "Not allowed.") => new ApiError(403, "forbidden", message);
69
+ var notFound = (message = "Not found.") => new ApiError(404, "not_found", message);
70
+ var conflict = (message, details) => new ApiError(409, "conflict", message, details);
71
+ function toApiError(error) {
72
+ if (error instanceof ApiError) return { error, internal: false };
73
+ const { status, code, message } = error ?? {};
74
+ if (status === 401 || status === 403) {
75
+ if (typeof code === "string" && typeof message === "string") return { error: new ApiError(status, code, message), internal: false };
76
+ return { error: status === 401 ? unauthorized() : forbidden(), internal: false };
77
+ }
78
+ return { error: new ApiError(500, "internal", "Something went wrong on our side."), internal: true };
79
+ }
80
+ function errorResponse(error) {
81
+ const body = { error: { code: error.code, message: error.message } };
82
+ if (error.details !== void 0) body.error.details = error.details;
83
+ return new Response(JSON.stringify(body), {
84
+ status: error.status,
85
+ headers: { "content-type": "application/json; charset=utf-8" }
86
+ });
87
+ }
88
+
89
+ // src/input.ts
90
+ var DEFAULT_BODY_LIMIT = 1024 * 1024;
91
+ function readQuery(url) {
92
+ const out = {};
93
+ for (const [key, value] of url.searchParams) {
94
+ const seen = out[key];
95
+ if (seen === void 0) out[key] = value;
96
+ else out[key] = Array.isArray(seen) ? [...seen, value] : [seen, value];
97
+ }
98
+ return out;
99
+ }
100
+ async function readJson(request, limit = DEFAULT_BODY_LIMIT) {
101
+ const declared = Number(request.headers.get("content-length"));
102
+ if (Number.isFinite(declared) && declared > limit) throw tooLarge(limit);
103
+ if (!request.body) return void 0;
104
+ const type = request.headers.get("content-type") ?? "";
105
+ const reader = request.body.getReader();
106
+ const chunks = [];
107
+ let size = 0;
108
+ for (; ; ) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ size += value.byteLength;
112
+ if (size > limit) {
113
+ await reader.cancel();
114
+ throw tooLarge(limit);
115
+ }
116
+ chunks.push(value);
117
+ }
118
+ if (size === 0) return void 0;
119
+ if (!isJson(type)) {
120
+ throw new ApiError(415, "unsupported_media_type", "Send the body as application/json.");
121
+ }
122
+ const bytes = new Uint8Array(size);
123
+ let offset = 0;
124
+ for (const chunk of chunks) {
125
+ bytes.set(chunk, offset);
126
+ offset += chunk.byteLength;
127
+ }
128
+ try {
129
+ return JSON.parse(new TextDecoder().decode(bytes));
130
+ } catch {
131
+ throw new ApiError(400, "invalid_json", "The body is not valid JSON.");
132
+ }
133
+ }
134
+ function isJson(type) {
135
+ const mime = type.split(";")[0].trim().toLowerCase();
136
+ return mime === "application/json" || mime.endsWith("+json");
137
+ }
138
+ function tooLarge(limit) {
139
+ return new ApiError(413, "payload_too_large", `The body is larger than ${limit} bytes.`);
140
+ }
141
+
142
+ // src/route.ts
143
+ var CONTRACT = /* @__PURE__ */ Symbol.for("fluixi.api.contract");
144
+ var report = (error, request) => {
145
+ console.error(`[fluixi/api] ${request.method} ${new URL(request.url).pathname}`, error);
146
+ };
147
+ function setErrorReporter(reporter) {
148
+ report = reporter;
149
+ }
150
+ function route(contract) {
151
+ const handle = async (request, context = {}) => {
152
+ try {
153
+ const auth = contract.guard ? await contract.guard.check(request) : void 0;
154
+ const issues = [];
155
+ const params = await check(contract.params, context.params ?? {}, "params", issues);
156
+ const query = await check(contract.query, readQuery(new URL(request.url)), "query", issues);
157
+ const body = contract.body ? await check(contract.body, await readJson(request, contract.bodyLimit), "body", issues) : void 0;
158
+ if (issues.length) throw new ApiError(400, "validation_failed", "The request is not valid.", issues);
159
+ const input = { params, query, body, auth, request };
160
+ const out = await contract.handler(input);
161
+ const stale = !contract.revalidates ? [] : typeof contract.revalidates === "function" ? contract.revalidates(out, input) : contract.revalidates;
162
+ if (out instanceof Response) return withRevalidate(out, stale);
163
+ if (out === void 0 && !contract.response) return withRevalidate(new Response(null, { status: contract.status ?? 204 }), stale);
164
+ let value = out;
165
+ if (contract.response && contract.validateResponse !== false) {
166
+ const result = await contract.response["~standard"].validate(out);
167
+ if (result.issues) {
168
+ throw Object.assign(new Error("Response does not match the route contract."), {
169
+ issues: result.issues.map((issue) => toIssue(issue, "response"))
170
+ });
171
+ }
172
+ value = result.value;
173
+ }
174
+ return withRevalidate(
175
+ new Response(JSON.stringify(value ?? null), {
176
+ status: contract.status ?? 200,
177
+ headers: { "content-type": "application/json; charset=utf-8" }
178
+ }),
179
+ stale
180
+ );
181
+ } catch (thrown) {
182
+ const { error, internal } = toApiError(thrown);
183
+ if (internal) report(thrown, request);
184
+ return errorResponse(error);
185
+ }
186
+ };
187
+ Object.defineProperty(handle, CONTRACT, { value: contract });
188
+ return handle;
189
+ }
190
+ var REVALIDATE_HEADER = "x-fx-revalidate";
191
+ function withRevalidate(response, names) {
192
+ if (!names.length || !response.ok) return response;
193
+ const value = names.join(", ");
194
+ try {
195
+ response.headers.append(REVALIDATE_HEADER, value);
196
+ return response;
197
+ } catch {
198
+ const headers = new Headers(response.headers);
199
+ headers.append(REVALIDATE_HEADER, value);
200
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
201
+ }
202
+ }
203
+ function contractOf(handler) {
204
+ return typeof handler === "function" ? handler[CONTRACT] : void 0;
205
+ }
206
+ async function check(schema, value, where, issues) {
207
+ if (!schema) return value;
208
+ const result = await schema["~standard"].validate(value);
209
+ if (result.issues) {
210
+ for (const issue of result.issues) issues.push(toIssue(issue, where));
211
+ return void 0;
212
+ }
213
+ return result.value;
214
+ }
215
+ function toIssue(issue, where) {
216
+ const path = (issue.path ?? []).map(
217
+ (segment) => String(typeof segment === "object" && segment !== null ? segment.key : segment)
218
+ );
219
+ return { path: [where, ...path].join("."), message: issue.message };
220
+ }
221
+
222
+ // src/openapi.ts
223
+ var convert;
224
+ function setJsonSchemaConverter(converter) {
225
+ convert = converter;
226
+ }
227
+ var METHODS = ["get", "post", "put", "patch", "delete"];
228
+ var DESCRIBES = /* @__PURE__ */ Symbol.for("fluixi.api.describes");
229
+ var DESCRIBED_BY = /* @__PURE__ */ Symbol.for("fluixi.api.described-by");
230
+ function describedBy(document, handler) {
231
+ Object.defineProperty(handler, DESCRIBED_BY, { value: document });
232
+ return handler;
233
+ }
234
+ var ERROR_SCHEMA = {
235
+ type: "object",
236
+ required: ["error"],
237
+ properties: {
238
+ error: {
239
+ type: "object",
240
+ required: ["code", "message"],
241
+ properties: {
242
+ code: { type: "string", description: "Machine-readable, stable: `validation_failed`, `not_found`, `internal`." },
243
+ message: { type: "string", description: "For people. May change between versions." },
244
+ details: { description: "For `validation_failed`, the list of `{ path, message }` issues." }
245
+ }
246
+ }
247
+ }
248
+ };
249
+ function createOpenApiDocument(routes, info) {
250
+ const paths = {};
251
+ const securitySchemes = {};
252
+ for (const route2 of routes) {
253
+ if (!route2.path) continue;
254
+ for (const method of METHODS) {
255
+ const handler = route2.mod[method.toUpperCase()];
256
+ if (typeof handler !== "function") continue;
257
+ const marks = handler;
258
+ if (marks[DESCRIBES] || marks[DESCRIBED_BY]) continue;
259
+ (paths[route2.path] ??= {})[method] = operation(method, route2.path, contractOf(handler), securitySchemes);
260
+ }
261
+ }
262
+ const { servers, ...rest } = info;
263
+ return {
264
+ openapi: "3.1.0",
265
+ info: rest,
266
+ ...servers?.length ? { servers } : {},
267
+ paths,
268
+ components: { schemas: { Error: ERROR_SCHEMA }, ...Object.keys(securitySchemes).length ? { securitySchemes } : {} }
269
+ };
270
+ }
271
+ function openApiRoute(routes, info) {
272
+ let cached;
273
+ const handler = async () => {
274
+ cached ??= describe(routes(), info).catch((error) => {
275
+ cached = void 0;
276
+ throw error;
277
+ });
278
+ return new Response(await cached, { headers: { "content-type": "application/json; charset=utf-8" } });
279
+ };
280
+ Object.defineProperty(handler, DESCRIBES, { value: true });
281
+ return handler;
282
+ }
283
+ async function describe(routes, info) {
284
+ const doc = createOpenApiDocument(routes, info);
285
+ const mounts = /* @__PURE__ */ new Map();
286
+ for (const route2 of routes) {
287
+ if (!route2.path) continue;
288
+ for (const handler of Object.values(route2.mod)) {
289
+ const source = typeof handler === "function" ? handler[DESCRIBED_BY] : void 0;
290
+ if (source && !mounts.has(source)) mounts.set(source, route2.path.replace(/\/\{[^}]+\}$/, ""));
291
+ }
292
+ }
293
+ for (const [source, mount] of mounts) includeDocument(doc, await source(), mount);
294
+ return JSON.stringify(doc, null, 2);
295
+ }
296
+ function includeDocument(target, source, mount) {
297
+ const prefix = pascal(mount.split("/").filter(Boolean).pop() ?? "included");
298
+ const components = target.components ??= {};
299
+ const prefixed = (name) => prefix + name[0].toUpperCase() + name.slice(1);
300
+ const renames = /* @__PURE__ */ new Map();
301
+ for (const [section, entries] of Object.entries(source.components ?? {})) {
302
+ for (const [name, value] of Object.entries(entries)) {
303
+ const existing = components[section]?.[name];
304
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(value)) {
305
+ renames.set(`#/components/${section}/${name}`, `#/components/${section}/${prefixed(name)}`);
306
+ }
307
+ }
308
+ }
309
+ const rewrite = (node) => {
310
+ if (Array.isArray(node)) return node.map(rewrite);
311
+ if (!node || typeof node !== "object") return node;
312
+ const out = Object.fromEntries(
313
+ Object.entries(node).map(([key, value]) => [key, key === "$ref" && typeof value === "string" ? renames.get(value) ?? value : rewrite(value)])
314
+ );
315
+ return "nullable" in out ? nullable(out) : out;
316
+ };
317
+ for (const [section, entries] of Object.entries(source.components ?? {})) {
318
+ const into = components[section] ??= {};
319
+ for (const [name, value] of Object.entries(entries)) {
320
+ const renamed = renames.has(`#/components/${section}/${name}`) ? prefixed(name) : name;
321
+ into[renamed] = rewrite(value);
322
+ }
323
+ }
324
+ const paths = target.paths ??= {};
325
+ const ids = new Set(Object.values(paths).flatMap((item) => Object.values(item).map((op) => op?.operationId)));
326
+ for (const [path, item] of Object.entries(source.paths ?? {})) {
327
+ const described = rewrite(item);
328
+ for (const [method, op] of Object.entries(described)) {
329
+ if (Array.isArray(op.security)) {
330
+ op.security = op.security.map(
331
+ (requirement) => Object.fromEntries(
332
+ Object.entries(requirement).map(([name, scopes]) => [
333
+ renames.has(`#/components/securitySchemes/${name}`) ? prefixed(name) : name,
334
+ scopes
335
+ ])
336
+ )
337
+ );
338
+ }
339
+ if (METHODS.includes(method) && !op.summary) op.summary = summaryOf(op, method, mount + path);
340
+ if (typeof op.operationId === "string" && ids.has(op.operationId)) {
341
+ op.operationId = prefix[0].toLowerCase() + prefix.slice(1) + op.operationId[0].toUpperCase() + op.operationId.slice(1);
342
+ }
343
+ if (typeof op.operationId === "string") ids.add(op.operationId);
344
+ }
345
+ paths[mount + path] = described;
346
+ }
347
+ const tags = source.tags ?? [];
348
+ if (tags.length) {
349
+ const known = target.tags ??= [];
350
+ for (const tag of tags) if (!known.some((t) => t.name === tag.name)) known.push(tag);
351
+ }
352
+ }
353
+ function nullable(schema) {
354
+ const { nullable: isNullable, ...rest } = schema;
355
+ if (isNullable !== true) return rest;
356
+ if (typeof rest.type === "string") return { ...rest, type: [rest.type, "null"] };
357
+ if (Array.isArray(rest.type)) return rest.type.includes("null") ? rest : { ...rest, type: [...rest.type, "null"] };
358
+ return { anyOf: [rest, { type: "null" }] };
359
+ }
360
+ function summaryOf(op, method, path) {
361
+ const description = typeof op.description === "string" ? op.description.split(/\.\s|\n/)[0].trim() : "";
362
+ if (description && description.length <= 60) return description.replace(/\.$/, "");
363
+ return typeof op.operationId === "string" ? op.operationId.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()) : summaryFor(method, path);
364
+ }
365
+ function pascal(word) {
366
+ return word.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
367
+ }
368
+ function operation(method, path, contract, schemes) {
369
+ const operationId = contract?.operationId ?? idFor(method, path);
370
+ if (!contract) {
371
+ return {
372
+ operationId,
373
+ tags: [tagFor(path)],
374
+ summary: `${method.toUpperCase()} ${path}`,
375
+ description: "This route declares no contract, so nothing about its input or output is known.",
376
+ "x-fluixi-contract": false,
377
+ responses: { default: { description: "Undocumented." } }
378
+ };
379
+ }
380
+ const parameters = [
381
+ ...parametersFrom(contract.params, "path", pathNames(path)),
382
+ ...parametersFrom(contract.query, "query", [])
383
+ ];
384
+ const success = contract.status ?? (contract.response ? 200 : 204);
385
+ const responses = {
386
+ [success]: contract.response ? { description: "Success.", content: { "application/json": { schema: schemaFor(contract.response, "output") } } } : { description: "Success, no content." }
387
+ };
388
+ const error = (description) => ({ description, content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } });
389
+ if (contract.params || contract.query || contract.body) responses[400] = error("The request is not valid: `validation_failed`, `invalid_json`.");
390
+ if (contract.body) {
391
+ responses[413] = error("The body is over the limit.");
392
+ responses[415] = error("The body is not JSON.");
393
+ }
394
+ const guard = contract.guard;
395
+ if (guard) {
396
+ const refuses = guard.refuses ?? [401];
397
+ if (refuses.includes(401)) responses[401] = error("Not signed in: `unauthorized`.");
398
+ if (refuses.includes(403)) responses[403] = error("Signed in, not allowed: `forbidden`, or a more precise code.");
399
+ if (refuses.includes(503)) responses[503] = error("A service the check depends on cannot answer now; try again.");
400
+ }
401
+ if (pathNames(path).length) responses[404] = error("Nothing at this path.");
402
+ responses[500] = error("A fault on the server. Its detail is logged, never sent.");
403
+ return {
404
+ operationId,
405
+ tags: contract.tags?.length ? contract.tags : [tagFor(path)],
406
+ // Tools name the request after it (Postman does), so there is always one.
407
+ summary: contract.summary ?? summaryFor(method, path),
408
+ ...contract.description ? { description: contract.description } : {},
409
+ ...contract.deprecated ? { deprecated: true } : {},
410
+ // Stated on every contracted route, so a public one reads as public rather than unknown.
411
+ security: security(guard, schemes),
412
+ ...parameters.length ? { parameters } : {},
413
+ ...contract.body ? { requestBody: { required: true, content: { "application/json": { schema: schemaFor(contract.body, "input") } } } } : {},
414
+ responses
415
+ };
416
+ }
417
+ function security(guard, schemes) {
418
+ if (!guard?.security) return [];
419
+ const { name, scheme, scopes } = guard.security;
420
+ schemes[name] = scheme;
421
+ return [{ [name]: scopes ?? [] }];
422
+ }
423
+ function parametersFrom(schema, where, names) {
424
+ if (!schema) return where === "path" ? names.map((name) => ({ name, in: "path", required: true, schema: { type: "string" } })) : [];
425
+ const json = schemaFor(schema, "input");
426
+ const properties = json.properties ?? {};
427
+ const required = new Set(json.required ?? []);
428
+ return Object.entries(properties).map(([name, property]) => ({
429
+ name,
430
+ in: where,
431
+ required: where === "path" ? true : required.has(name),
432
+ ...typeof property.description === "string" ? { description: property.description } : {},
433
+ schema: property,
434
+ ...where === "query" && property.type === "array" ? { style: "form", explode: true } : {}
435
+ }));
436
+ }
437
+ function schemaFor(schema, io) {
438
+ const standard = schema["~standard"].jsonSchema;
439
+ try {
440
+ const json = standard?.[io]({ target: "draft-2020-12", libraryOptions: { unrepresentable: "any" } }) ?? convert?.(schema, io);
441
+ if (json) {
442
+ const { $schema: _dialect, ...rest } = json;
443
+ return rest;
444
+ }
445
+ } catch {
446
+ }
447
+ return { "x-fluixi-unconverted": schema["~standard"].vendor };
448
+ }
449
+ function pathNames(path) {
450
+ return [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]);
451
+ }
452
+ function tagFor(path) {
453
+ const segments = path.split("/").filter((s) => s && !s.startsWith("{"));
454
+ const meaningful = segments.filter((s, i) => !(i === 0 && s === "api") && !/^v\d+$/.test(s));
455
+ return meaningful[0] ?? "default";
456
+ }
457
+ function summaryFor(method, path) {
458
+ const words = idFor(method, path).replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
459
+ return words[0].toUpperCase() + words.slice(1);
460
+ }
461
+ function idFor(method, path) {
462
+ const words = path.split("/").filter((s) => s && s !== "api" && !/^v\d+$/.test(s)).map((s) => s.startsWith("{") ? `By-${s.slice(1, -1)}` : s).flatMap((s) => s.split(/[^a-zA-Z0-9]+/)).filter(Boolean);
463
+ return method + words.map((w) => w[0].toUpperCase() + w.slice(1)).join("");
464
+ }
465
+
466
+ // src/middleware.ts
467
+ function withHeaders(response, set) {
468
+ try {
469
+ set(response.headers);
470
+ return response;
471
+ } catch {
472
+ const copy = new Response(response.body, response);
473
+ set(copy.headers);
474
+ return copy;
475
+ }
476
+ }
477
+ function appendVary(headers, name) {
478
+ const vary = headers.get("vary");
479
+ if (!vary) headers.set("vary", name);
480
+ else if (!vary.split(",").some((v) => v.trim().toLowerCase() === name.toLowerCase())) headers.set("vary", `${vary}, ${name}`);
481
+ }
482
+
483
+ // src/rate-limit.ts
484
+ var import_rate_limiter_flexible = require("rate-limiter-flexible");
485
+ function memoryRateLimitStore() {
486
+ return storeOf(async (limit, windowSeconds) => new import_rate_limiter_flexible.RateLimiterMemory({ points: limit, duration: windowSeconds }));
487
+ }
488
+ function postgresRateLimitStore(client, options = {}) {
489
+ const { table = "rate_limits", schema, createTable = true } = options;
490
+ const storeClient = { query: (q) => client.query(q.text, q.values) };
491
+ let hits = 0;
492
+ return storeOf(
493
+ (limit, windowSeconds) => new Promise((resolve, reject) => {
494
+ const limiter = new import_rate_limiter_flexible.RateLimiterPostgres(
495
+ {
496
+ storeClient,
497
+ storeType: "pool",
498
+ tableName: table,
499
+ schemaName: schema,
500
+ tableCreated: !createTable,
501
+ clearExpiredByTimeout: false,
502
+ points: limit,
503
+ duration: windowSeconds,
504
+ insuranceLimiter: new import_rate_limiter_flexible.RateLimiterMemory({ points: limit, duration: windowSeconds })
505
+ },
506
+ // Called inside the constructor when the table exists already: resolve once it returned.
507
+ (error) => error ? reject(error) : queueMicrotask(() => resolve(limiter))
508
+ );
509
+ }),
510
+ (limiter) => {
511
+ const sweep = limiter;
512
+ if (++hits % 1e3 === 0) void sweep.clearExpired(Date.now() - 36e5).catch(() => {
513
+ });
514
+ }
515
+ );
516
+ }
517
+ function storeOf(make, onHit) {
518
+ const limiters = /* @__PURE__ */ new Map();
519
+ return {
520
+ async hit(key, limit, windowSeconds) {
521
+ const id = `${limit}/${windowSeconds}`;
522
+ let pending = limiters.get(id);
523
+ if (!pending) {
524
+ limiters.set(id, pending = make(limit, windowSeconds));
525
+ pending.catch(() => limiters.delete(id));
526
+ }
527
+ const limiter = await pending;
528
+ onHit?.(limiter);
529
+ try {
530
+ const res = await limiter.consume(key);
531
+ return { allowed: true, remaining: res.remainingPoints, resetMs: res.msBeforeNext };
532
+ } catch (thrown) {
533
+ if (thrown instanceof import_rate_limiter_flexible.RateLimiterRes) return { allowed: false, remaining: 0, resetMs: thrown.msBeforeNext };
534
+ throw thrown;
535
+ }
536
+ }
537
+ };
538
+ }
539
+ var ADDRESSES = /* @__PURE__ */ Symbol.for("fluixi.clientAddress");
540
+ var sharedMemory = memoryRateLimitStore();
541
+ var created = 0;
542
+ function clientAddressOf(request) {
543
+ return globalThis[ADDRESSES]?.get(request);
544
+ }
545
+ function rateLimit(options) {
546
+ const { match, methods, limit, window, key = clientAddressOf, store = sharedMemory } = options;
547
+ const applies = matcher(match);
548
+ const methodSet = methods && new Set(methods.map((m) => m.toUpperCase()));
549
+ const bucket = options.name ?? `limit${++created}`;
550
+ return async (request, next) => {
551
+ if (methodSet && !methodSet.has(request.method)) return next();
552
+ if (!applies(request)) return next();
553
+ const who = await key(request);
554
+ if (!who) return next();
555
+ const { allowed, remaining, resetMs } = await store.hit(`${bucket}:${who}`, limit, window);
556
+ const reset = String(Math.ceil(resetMs / 1e3));
557
+ const set = (headers) => {
558
+ headers.set("ratelimit-limit", String(limit));
559
+ headers.set("ratelimit-remaining", String(remaining));
560
+ headers.set("ratelimit-reset", reset);
561
+ };
562
+ if (!allowed) {
563
+ const refused = errorResponse(new ApiError(429, "rate_limited", "Too many requests. Try again later."));
564
+ refused.headers.set("retry-after", reset);
565
+ set(refused.headers);
566
+ return refused;
567
+ }
568
+ return withHeaders(await next(), set);
569
+ };
570
+ }
571
+ function matcher(match) {
572
+ if (!match) return () => true;
573
+ if (typeof match === "function") return match;
574
+ const exact = new Set(match.filter((p) => !p.endsWith("/*")));
575
+ const prefixes = match.filter((p) => p.endsWith("/*")).map((p) => p.slice(0, -1));
576
+ return (request) => {
577
+ const { pathname } = new URL(request.url);
578
+ return exact.has(pathname) || prefixes.some((p) => pathname.startsWith(p));
579
+ };
580
+ }
581
+
582
+ // src/security.ts
583
+ var allows = (origins, origin) => typeof origins === "function" ? origins(origin) : origins.includes(origin);
584
+ var DEFAULT_SECURITY_HEADERS = {
585
+ "content-security-policy": "default-src 'none'; frame-ancestors 'none'",
586
+ "cross-origin-opener-policy": "same-origin",
587
+ "cross-origin-resource-policy": "same-origin",
588
+ "referrer-policy": "no-referrer",
589
+ "strict-transport-security": "max-age=31536000; includeSubDomains",
590
+ "x-content-type-options": "nosniff",
591
+ "x-frame-options": "DENY",
592
+ "x-permitted-cross-domain-policies": "none",
593
+ "cache-control": "no-store"
594
+ };
595
+ function securityHeaders(options = {}) {
596
+ const merged = { ...DEFAULT_SECURITY_HEADERS, ...options };
597
+ const entries = Object.entries(merged).filter((e) => e[1] !== false);
598
+ return async (request, next) => withHeaders(await next(), (headers) => {
599
+ for (const [name, value] of entries) if (!headers.has(name)) headers.set(name, value);
600
+ });
601
+ }
602
+ var METHODS2 = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"];
603
+ function cors(options) {
604
+ const { origins, credentials = true, methods = METHODS2, allowHeaders, exposeHeaders, maxAge = 600 } = options;
605
+ return async (request, next) => {
606
+ const origin = request.headers.get("origin");
607
+ if (!origin || origin === new URL(request.url).origin) return next();
608
+ const allowed = allows(origins, origin);
609
+ const preflight = request.method === "OPTIONS" && request.headers.has("access-control-request-method");
610
+ if (preflight) {
611
+ if (!allowed) return refusal(403, "origin_not_allowed", "This origin may not call the API.");
612
+ const headers = new Headers({
613
+ "access-control-allow-origin": origin,
614
+ "access-control-allow-methods": methods.join(", "),
615
+ "access-control-max-age": String(maxAge),
616
+ vary: "Origin, Access-Control-Request-Headers"
617
+ });
618
+ const asked = request.headers.get("access-control-request-headers");
619
+ const allowedHeaders = allowHeaders ? allowHeaders.join(", ") : asked;
620
+ if (allowedHeaders) headers.set("access-control-allow-headers", allowedHeaders);
621
+ if (credentials) headers.set("access-control-allow-credentials", "true");
622
+ return new Response(null, { status: 204, headers });
623
+ }
624
+ const response = await next();
625
+ return withHeaders(response, (headers) => {
626
+ appendVary(headers, "Origin");
627
+ if (!allowed) return;
628
+ headers.set("access-control-allow-origin", origin);
629
+ if (credentials) headers.set("access-control-allow-credentials", "true");
630
+ if (exposeHeaders?.length) headers.set("access-control-expose-headers", exposeHeaders.join(", "));
631
+ });
632
+ };
633
+ }
634
+ var SAFE = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
635
+ function originCheck(options) {
636
+ return (request, next) => {
637
+ if (SAFE.has(request.method) || !request.headers.has("cookie")) return next();
638
+ const origin = request.headers.get("origin");
639
+ const site = request.headers.get("sec-fetch-site");
640
+ const self = new URL(request.url).origin;
641
+ if (site === "same-origin" || site === "none") return next();
642
+ if (origin && (origin === self || allows(options.origins, origin))) return next();
643
+ if (!site && !origin) return next();
644
+ return refusal(403, "cross_origin_refused", "Requests from this origin are refused.");
645
+ };
646
+ }
647
+ function apiSecurity(options = {}) {
648
+ const origins = options.origins ?? [];
649
+ const prefix = (options.prefix ?? "/api").replace(/\/$/, "");
650
+ const chain = [];
651
+ if (options.headers !== false) chain.push(securityHeaders(options.headers));
652
+ if (options.cors !== false) chain.push(cors({ ...options.cors, origins }));
653
+ if (options.originCheck !== false) chain.push(originCheck({ origins }));
654
+ for (const limit of options.rateLimits ?? []) chain.push(rateLimit(limit));
655
+ return (request, next) => {
656
+ const { pathname } = new URL(request.url);
657
+ if (pathname !== prefix && !pathname.startsWith(prefix + "/")) return next();
658
+ const run = (i) => {
659
+ const middleware = chain[i];
660
+ return middleware ? Promise.resolve(middleware(request, () => run(i + 1))) : next();
661
+ };
662
+ return run(0);
663
+ };
664
+ }
665
+ function refusal(status, code, message) {
666
+ return errorResponse(new ApiError(status, code, message));
667
+ }