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

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
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/rate-limit.ts
21
+ var rate_limit_exports = {};
22
+ __export(rate_limit_exports, {
23
+ clientAddressOf: () => clientAddressOf,
24
+ memoryRateLimitStore: () => memoryRateLimitStore,
25
+ postgresRateLimitStore: () => postgresRateLimitStore,
26
+ rateLimit: () => rateLimit
27
+ });
28
+ module.exports = __toCommonJS(rate_limit_exports);
29
+ var import_rate_limiter_flexible = require("rate-limiter-flexible");
30
+
31
+ // src/errors.ts
32
+ var ApiError = class extends Error {
33
+ constructor(status, code, message, details) {
34
+ super(message);
35
+ this.name = "ApiError";
36
+ this.status = status;
37
+ this.code = code;
38
+ this.details = details;
39
+ }
40
+ };
41
+ function errorResponse(error) {
42
+ const body = { error: { code: error.code, message: error.message } };
43
+ if (error.details !== void 0) body.error.details = error.details;
44
+ return new Response(JSON.stringify(body), {
45
+ status: error.status,
46
+ headers: { "content-type": "application/json; charset=utf-8" }
47
+ });
48
+ }
49
+
50
+ // src/middleware.ts
51
+ function withHeaders(response, set) {
52
+ try {
53
+ set(response.headers);
54
+ return response;
55
+ } catch {
56
+ const copy = new Response(response.body, response);
57
+ set(copy.headers);
58
+ return copy;
59
+ }
60
+ }
61
+
62
+ // src/rate-limit.ts
63
+ function memoryRateLimitStore() {
64
+ return storeOf(async (limit, windowSeconds) => new import_rate_limiter_flexible.RateLimiterMemory({ points: limit, duration: windowSeconds }));
65
+ }
66
+ function postgresRateLimitStore(client, options = {}) {
67
+ const { table = "rate_limits", schema, createTable = true } = options;
68
+ const storeClient = { query: (q) => client.query(q.text, q.values) };
69
+ let hits = 0;
70
+ return storeOf(
71
+ (limit, windowSeconds) => new Promise((resolve, reject) => {
72
+ const limiter = new import_rate_limiter_flexible.RateLimiterPostgres(
73
+ {
74
+ storeClient,
75
+ storeType: "pool",
76
+ tableName: table,
77
+ schemaName: schema,
78
+ tableCreated: !createTable,
79
+ clearExpiredByTimeout: false,
80
+ points: limit,
81
+ duration: windowSeconds,
82
+ insuranceLimiter: new import_rate_limiter_flexible.RateLimiterMemory({ points: limit, duration: windowSeconds })
83
+ },
84
+ // Called inside the constructor when the table exists already: resolve once it returned.
85
+ (error) => error ? reject(error) : queueMicrotask(() => resolve(limiter))
86
+ );
87
+ }),
88
+ (limiter) => {
89
+ const sweep = limiter;
90
+ if (++hits % 1e3 === 0) void sweep.clearExpired(Date.now() - 36e5).catch(() => {
91
+ });
92
+ }
93
+ );
94
+ }
95
+ function storeOf(make, onHit) {
96
+ const limiters = /* @__PURE__ */ new Map();
97
+ return {
98
+ async hit(key, limit, windowSeconds) {
99
+ const id = `${limit}/${windowSeconds}`;
100
+ let pending = limiters.get(id);
101
+ if (!pending) {
102
+ limiters.set(id, pending = make(limit, windowSeconds));
103
+ pending.catch(() => limiters.delete(id));
104
+ }
105
+ const limiter = await pending;
106
+ onHit?.(limiter);
107
+ try {
108
+ const res = await limiter.consume(key);
109
+ return { allowed: true, remaining: res.remainingPoints, resetMs: res.msBeforeNext };
110
+ } catch (thrown) {
111
+ if (thrown instanceof import_rate_limiter_flexible.RateLimiterRes) return { allowed: false, remaining: 0, resetMs: thrown.msBeforeNext };
112
+ throw thrown;
113
+ }
114
+ }
115
+ };
116
+ }
117
+ var ADDRESSES = /* @__PURE__ */ Symbol.for("fluixi.clientAddress");
118
+ var sharedMemory = memoryRateLimitStore();
119
+ var created = 0;
120
+ function clientAddressOf(request) {
121
+ return globalThis[ADDRESSES]?.get(request);
122
+ }
123
+ function rateLimit(options) {
124
+ const { match, methods, limit, window, key = clientAddressOf, store = sharedMemory } = options;
125
+ const applies = matcher(match);
126
+ const methodSet = methods && new Set(methods.map((m) => m.toUpperCase()));
127
+ const bucket = options.name ?? `limit${++created}`;
128
+ return async (request, next) => {
129
+ if (methodSet && !methodSet.has(request.method)) return next();
130
+ if (!applies(request)) return next();
131
+ const who = await key(request);
132
+ if (!who) return next();
133
+ const { allowed, remaining, resetMs } = await store.hit(`${bucket}:${who}`, limit, window);
134
+ const reset = String(Math.ceil(resetMs / 1e3));
135
+ const set = (headers) => {
136
+ headers.set("ratelimit-limit", String(limit));
137
+ headers.set("ratelimit-remaining", String(remaining));
138
+ headers.set("ratelimit-reset", reset);
139
+ };
140
+ if (!allowed) {
141
+ const refused = errorResponse(new ApiError(429, "rate_limited", "Too many requests. Try again later."));
142
+ refused.headers.set("retry-after", reset);
143
+ set(refused.headers);
144
+ return refused;
145
+ }
146
+ return withHeaders(await next(), set);
147
+ };
148
+ }
149
+ function matcher(match) {
150
+ if (!match) return () => true;
151
+ if (typeof match === "function") return match;
152
+ const exact = new Set(match.filter((p) => !p.endsWith("/*")));
153
+ const prefixes = match.filter((p) => p.endsWith("/*")).map((p) => p.slice(0, -1));
154
+ return (request) => {
155
+ const { pathname } = new URL(request.url);
156
+ return exact.has(pathname) || prefixes.some((p) => pathname.startsWith(p));
157
+ };
158
+ }
@@ -0,0 +1,69 @@
1
+ import { type Middleware } from './middleware.js';
2
+ /** Where hits are counted. */
3
+ export interface RateLimitStore {
4
+ /** Counts one hit on `key` in a window of `windowSeconds`, allowing `limit` of them. */
5
+ hit(key: string, limit: number, windowSeconds: number): Promise<{
6
+ allowed: boolean;
7
+ remaining: number;
8
+ resetMs: number;
9
+ }>;
10
+ }
11
+ /** Counts in this process. Enough for one instance; several need a shared store. */
12
+ export declare function memoryRateLimitStore(): RateLimitStore;
13
+ /**
14
+ * Anything that runs a parameterised query and returns rows: a node-postgres `Pool` or
15
+ * `Client`, PGlite. No driver is imported here; the app passes the connection it has.
16
+ */
17
+ export interface SqlClient {
18
+ query(text: string, values?: unknown[]): Promise<{
19
+ rows: any[];
20
+ }>;
21
+ }
22
+ export interface PostgresRateLimitOptions {
23
+ /** `rate_limits` by default. */
24
+ table?: string;
25
+ schema?: string;
26
+ /**
27
+ * Create the table on first use (`key varchar(255) primary key, points integer not null
28
+ * default 0, expire bigint`). Off when a migration already made it.
29
+ */
30
+ createTable?: boolean;
31
+ }
32
+ /**
33
+ * Counts in Postgres, so every instance of the API shares them. If the database fails, hits are
34
+ * counted in memory until it is back rather than refusing or letting everyone through.
35
+ *
36
+ * Expired rows are cleared now and then on a hit rather than by a timer, so an idle database
37
+ * that scales to zero is left asleep.
38
+ */
39
+ export declare function postgresRateLimitStore(client: SqlClient, options?: PostgresRateLimitOptions): RateLimitStore;
40
+ export interface RateLimitOptions {
41
+ /** Paths it applies to, exactly or as a prefix ending in `/*`; or a test. Every request when not given. */
42
+ match?: readonly string[] | ((request: Request) => boolean);
43
+ /** Methods it applies to. Every method when not given. */
44
+ methods?: readonly string[];
45
+ /** Hits allowed per window. */
46
+ limit: number;
47
+ /** Window length, in seconds. */
48
+ window: number;
49
+ /**
50
+ * Who a hit belongs to. The client address by default (`clientAddress` from `@fluixi/start`);
51
+ * a request with no key is not counted.
52
+ */
53
+ key?: (request: Request) => string | undefined | Promise<string | undefined>;
54
+ /** Where hits are counted. In memory by default. */
55
+ store?: RateLimitStore;
56
+ /**
57
+ * Keeps this limit's counts apart from the others' in a shared store. Defaults to the order
58
+ * limits are created in, which holds across instances running the same code.
59
+ */
60
+ name?: string;
61
+ }
62
+ /** The address `@fluixi/start` recorded for the request, read without importing it. */
63
+ export declare function clientAddressOf(request: Request): string | undefined;
64
+ /**
65
+ * Refuses a caller who goes over `limit` requests in `window` seconds with a 429 and
66
+ * `Retry-After`. Every counted response carries `RateLimit-Limit`, `-Remaining` and `-Reset`.
67
+ */
68
+ export declare function rateLimit(options: RateLimitOptions): Middleware;
69
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAMA,OAAO,EAAe,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE/D,8BAA8B;AAC9B,MAAM,WAAW,cAAc;IAC7B,wFAAwF;IACxF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3H;AAED,oFAAoF;AACpF,wBAAgB,oBAAoB,IAAI,cAAc,CAErD;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,EAAE,CAAA;KAAE,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,wBAAwB;IACvC,gCAAgC;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,GAAE,wBAA6B,GAAG,cAAc,CA8BhH;AA4BD,MAAM,WAAW,gBAAgB;IAC/B,2GAA2G;IAC3G,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC;IAC5D,0DAA0D;IAC1D,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7E,oDAAoD;IACpD,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAOD,uFAAuF;AACvF,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAEpE;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,gBAAgB,GAAG,UAAU,CA2B/D"}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Rate limits: `rate-limiter-flexible` counts, behind a store interface so a shared backend
3
+ * (Postgres, Redis) replaces the in-memory one without touching the middleware.
4
+ */
5
+ import { RateLimiterMemory, RateLimiterPostgres, RateLimiterRes } from 'rate-limiter-flexible';
6
+ import { ApiError, errorResponse } from './errors.js';
7
+ import { withHeaders } from './middleware.js';
8
+ /** Counts in this process. Enough for one instance; several need a shared store. */
9
+ export function memoryRateLimitStore() {
10
+ return storeOf(async (limit, windowSeconds) => new RateLimiterMemory({ points: limit, duration: windowSeconds }));
11
+ }
12
+ /**
13
+ * Counts in Postgres, so every instance of the API shares them. If the database fails, hits are
14
+ * counted in memory until it is back rather than refusing or letting everyone through.
15
+ *
16
+ * Expired rows are cleared now and then on a hit rather than by a timer, so an idle database
17
+ * that scales to zero is left asleep.
18
+ */
19
+ export function postgresRateLimitStore(client, options = {}) {
20
+ const { table = 'rate_limits', schema, createTable = true } = options;
21
+ // The limiter sends node-postgres query objects; this takes any client with query(text, values).
22
+ const storeClient = { query: (q) => client.query(q.text, q.values) };
23
+ let hits = 0;
24
+ return storeOf((limit, windowSeconds) => new Promise((resolve, reject) => {
25
+ const limiter = new RateLimiterPostgres({
26
+ storeClient,
27
+ storeType: 'pool',
28
+ tableName: table,
29
+ schemaName: schema,
30
+ tableCreated: !createTable,
31
+ clearExpiredByTimeout: false,
32
+ points: limit,
33
+ duration: windowSeconds,
34
+ insuranceLimiter: new RateLimiterMemory({ points: limit, duration: windowSeconds }),
35
+ },
36
+ // Called inside the constructor when the table exists already: resolve once it returned.
37
+ (error) => (error ? reject(error) : queueMicrotask(() => resolve(limiter))));
38
+ }), (limiter) => {
39
+ // Public in the library, missing from its type declarations.
40
+ const sweep = limiter;
41
+ if (++hits % 1000 === 0)
42
+ void sweep.clearExpired(Date.now() - 3_600_000).catch(() => { });
43
+ });
44
+ }
45
+ /** One limiter per limit and window, made on first use; counting and refusal read the same way. */
46
+ function storeOf(make, onHit) {
47
+ const limiters = new Map();
48
+ return {
49
+ async hit(key, limit, windowSeconds) {
50
+ const id = `${limit}/${windowSeconds}`;
51
+ let pending = limiters.get(id);
52
+ if (!pending) {
53
+ limiters.set(id, (pending = make(limit, windowSeconds)));
54
+ pending.catch(() => limiters.delete(id));
55
+ }
56
+ const limiter = await pending;
57
+ onHit?.(limiter);
58
+ try {
59
+ const res = await limiter.consume(key);
60
+ return { allowed: true, remaining: res.remainingPoints, resetMs: res.msBeforeNext };
61
+ }
62
+ catch (thrown) {
63
+ if (thrown instanceof RateLimiterRes)
64
+ return { allowed: false, remaining: 0, resetMs: thrown.msBeforeNext };
65
+ throw thrown;
66
+ }
67
+ },
68
+ };
69
+ }
70
+ const ADDRESSES = Symbol.for('fluixi.clientAddress');
71
+ const sharedMemory = memoryRateLimitStore();
72
+ let created = 0;
73
+ /** The address `@fluixi/start` recorded for the request, read without importing it. */
74
+ export function clientAddressOf(request) {
75
+ return globalThis[ADDRESSES]?.get(request);
76
+ }
77
+ /**
78
+ * Refuses a caller who goes over `limit` requests in `window` seconds with a 429 and
79
+ * `Retry-After`. Every counted response carries `RateLimit-Limit`, `-Remaining` and `-Reset`.
80
+ */
81
+ export function rateLimit(options) {
82
+ const { match, methods, limit, window, key = clientAddressOf, store = sharedMemory } = options;
83
+ const applies = matcher(match);
84
+ const methodSet = methods && new Set(methods.map((m) => m.toUpperCase()));
85
+ const bucket = options.name ?? `limit${++created}`;
86
+ return async (request, next) => {
87
+ if (methodSet && !methodSet.has(request.method))
88
+ return next();
89
+ if (!applies(request))
90
+ return next();
91
+ const who = await key(request);
92
+ if (!who)
93
+ return next();
94
+ const { allowed, remaining, resetMs } = await store.hit(`${bucket}:${who}`, limit, window);
95
+ const reset = String(Math.ceil(resetMs / 1000));
96
+ const set = (headers) => {
97
+ headers.set('ratelimit-limit', String(limit));
98
+ headers.set('ratelimit-remaining', String(remaining));
99
+ headers.set('ratelimit-reset', reset);
100
+ };
101
+ if (!allowed) {
102
+ const refused = errorResponse(new ApiError(429, 'rate_limited', 'Too many requests. Try again later.'));
103
+ refused.headers.set('retry-after', reset);
104
+ set(refused.headers);
105
+ return refused;
106
+ }
107
+ return withHeaders(await next(), set);
108
+ };
109
+ }
110
+ function matcher(match) {
111
+ if (!match)
112
+ return () => true;
113
+ if (typeof match === 'function')
114
+ return match;
115
+ const exact = new Set(match.filter((p) => !p.endsWith('/*')));
116
+ const prefixes = match.filter((p) => p.endsWith('/*')).map((p) => p.slice(0, -1));
117
+ return (request) => {
118
+ const { pathname } = new URL(request.url);
119
+ return exact.has(pathname) || prefixes.some((p) => pathname.startsWith(p));
120
+ };
121
+ }