bazis 0.98.8 → 0.98.9

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.
@@ -61,7 +61,11 @@ export class PayloadTooLargeError extends HttpError {
61
61
  }
62
62
 
63
63
  export class TooManyRequestsError extends HttpError {
64
- constructor(readonly retryAfterSeconds: number) {
64
+ constructor(
65
+ readonly retryAfterSeconds: number,
66
+ /** Extra response headers, for example `RateLimit-*` from {@link rateLimit}. */
67
+ readonly headers?: Readonly<Record<string, string>>,
68
+ ) {
65
69
  super(429, "Too Many Requests");
66
70
  }
67
71
  }
@@ -112,6 +112,10 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
112
112
  if (challenge !== undefined) {
113
113
  extra.push(["www-authenticate", challenge]);
114
114
  }
115
+ const headers = (error as { headers?: Readonly<Record<string, string>> }).headers;
116
+ if (headers !== undefined) {
117
+ extra.push(...Object.entries(headers));
118
+ }
115
119
  ctx.response = safeJsonResponse(
116
120
  error.status,
117
121
  error.details === undefined ? { error: error.message } : { error: error.message, details: error.details },
@@ -13,12 +13,19 @@ export interface RateLimitOptions {
13
13
  * bucket when unavailable). Plug your own: user id, API key, etc.
14
14
  */
15
15
  keyOf?: (ctx: HttpContext) => string;
16
- /** Trust the first `X-Forwarded-For` hop. Off by default. */
17
- trustProxy?: boolean;
18
- /** Forwarded client-address header used when `trustProxy` is true. */
16
+ /**
17
+ * Read the client address from `X-Forwarded-For` behind trusted proxies.
18
+ * `true` — one proxy: the last address, the one the proxy saw; a number —
19
+ * that many proxies: the address that many hops from the end. Earlier
20
+ * addresses are written by the client and are never used. Off by default.
21
+ */
22
+ trustProxy?: boolean | number;
23
+ /** Forwarded client-address header used when `trustProxy` is set. */
19
24
  proxyHeader?: string;
20
25
  /** Hard cap for in-process buckets. New keys receive 429 when full. Default: 10,000. */
21
26
  maxBuckets?: number;
27
+ /** Add `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` to responses. Default: true. */
28
+ headers?: boolean;
22
29
  }
23
30
 
24
31
  interface Bucket {
@@ -30,7 +37,8 @@ interface Bucket {
30
37
  * Fixed-window in-memory rate limiter. Per-process state (no external
31
38
  * store) — suitable for a single binary; swap `keyOf` or wrap your own
32
39
  * middleware for distributed setups. Exceeding the limit throws 429 with
33
- * Retry-After. At capacity, new keys are rejected until a bucket expires;
40
+ * Retry-After; every response carries `RateLimit-Limit`, `RateLimit-Remaining`
41
+ * and `RateLimit-Reset` unless `headers: false`. At capacity, new keys are rejected until a bucket expires;
34
42
  * existing keys retain their quota and live buckets are never evicted.
35
43
  */
36
44
  export function rateLimit(options: RateLimitOptions): HttpMiddleware {
@@ -39,15 +47,22 @@ export function rateLimit(options: RateLimitOptions): HttpMiddleware {
39
47
  const maxBuckets = Math.floor(options.maxBuckets ?? 10_000);
40
48
  let nextExpiry = Number.POSITIVE_INFINITY;
41
49
  const proxyHeader = options.proxyHeader ?? "x-forwarded-for";
50
+ const trustedHops = options.trustProxy === true ? 1 : options.trustProxy || 0;
51
+ const withHeaders = options.headers !== false;
42
52
  const keyOf = options.keyOf ?? ((ctx: HttpContext) => {
43
- if (options.trustProxy) {
44
- const forwarded = ctx.header(proxyHeader)?.split(",", 1)[0]?.trim();
53
+ if (trustedHops > 0) {
54
+ const forwarded = forwardedClient(ctx.header(proxyHeader), trustedHops);
45
55
  if (forwarded) {
46
56
  return forwarded;
47
57
  }
48
58
  }
49
59
  return ctx.clientIp ?? "*";
50
60
  });
61
+ const limitHeaders = (remaining: number, resetSeconds: number): Record<string, string> => ({
62
+ "ratelimit-limit": String(options.max),
63
+ "ratelimit-remaining": String(remaining),
64
+ "ratelimit-reset": String(resetSeconds),
65
+ });
51
66
 
52
67
  return async (ctx, next) => {
53
68
  const now = Date.now();
@@ -65,20 +80,45 @@ export function rateLimit(options: RateLimitOptions): HttpMiddleware {
65
80
  nextExpiry = purgeExpired(buckets, now);
66
81
  }
67
82
  if (buckets.size >= maxBuckets) {
68
- throw new TooManyRequestsError(Math.ceil((nextExpiry - now) / 1000));
83
+ const retryAfter = Math.ceil((nextExpiry - now) / 1000);
84
+ throw new TooManyRequestsError(retryAfter, withHeaders ? limitHeaders(0, retryAfter) : undefined);
69
85
  }
70
86
  bucket = { count: 0, resetAt: now + options.windowMs };
71
87
  buckets.set(key, bucket);
72
88
  nextExpiry = Math.min(nextExpiry, bucket.resetAt);
73
89
  }
90
+ const resetSeconds = Math.ceil((bucket.resetAt - now) / 1000);
74
91
  if (bucket.count >= options.max) {
75
- throw new TooManyRequestsError(Math.ceil((bucket.resetAt - now) / 1000));
92
+ throw new TooManyRequestsError(resetSeconds, withHeaders ? limitHeaders(0, resetSeconds) : undefined);
76
93
  }
77
94
  bucket.count += 1;
78
95
  await next();
96
+ if (withHeaders && ctx.response) {
97
+ setIfAbsent(ctx.response, limitHeaders(options.max - bucket.count, resetSeconds));
98
+ }
79
99
  };
80
100
  }
81
101
 
102
+ /**
103
+ * The client address `hops` entries from the end of `X-Forwarded-For`: each
104
+ * trusted proxy appends the address it saw, so entries before them are
105
+ * whatever the client sent.
106
+ */
107
+ function forwardedClient(header: string | undefined, hops: number): string | undefined {
108
+ const addresses = header?.split(",").map((address) => address.trim()).filter((address) => address.length > 0) ?? [];
109
+ return addresses[Math.max(0, addresses.length - hops)];
110
+ }
111
+
112
+ function setIfAbsent(response: Response, headers: Record<string, string>): void {
113
+ try {
114
+ for (const [name, value] of Object.entries(headers)) {
115
+ if (!response.headers.has(name)) response.headers.set(name, value);
116
+ }
117
+ } catch {
118
+ // A raw Response may have immutable headers; the limit itself still applies.
119
+ }
120
+ }
121
+
82
122
  function assertRateLimitOptions(options: RateLimitOptions): void {
83
123
  if (!Number.isFinite(options.windowMs) || options.windowMs <= 0) {
84
124
  throw new HttpSetupError("rateLimit windowMs must be a positive finite number.");
@@ -89,6 +129,9 @@ function assertRateLimitOptions(options: RateLimitOptions): void {
89
129
  if (options.maxBuckets !== undefined && (!Number.isSafeInteger(options.maxBuckets) || options.maxBuckets <= 0)) {
90
130
  throw new HttpSetupError("rateLimit maxBuckets must be a positive integer.");
91
131
  }
132
+ if (typeof options.trustProxy === "number" && (!Number.isSafeInteger(options.trustProxy) || options.trustProxy <= 0)) {
133
+ throw new HttpSetupError("rateLimit trustProxy must be true, false or the number of trusted proxies (a positive integer).");
134
+ }
92
135
  }
93
136
 
94
137
  function purgeExpired(buckets: Map<string, Bucket>, now: number): number {
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.98.8" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.9" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.8",
3
+ "version": "0.98.9",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",