bazis 0.98.7 → 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
  }
@@ -154,6 +154,12 @@ export class HttpServer implements HostedService {
154
154
  if (this.options.securityHeaders !== false) {
155
155
  serverChain.push(securityHeaders(this.options.securityHeaders ?? {}));
156
156
  }
157
+ const serverMiddleware = this.collectServerMiddleware();
158
+ // A global correlation middleware wraps the error boundary, so error
159
+ // responses (401, 403, 500, a thrown NotFoundError) and the responses
160
+ // produced before routing (404, 405, 413, preflight, docs, health) carry
161
+ // the request id, and the error log line runs inside its request context.
162
+ serverChain.push(...serverMiddleware.filter(isCorrelationIdMiddleware));
157
163
  // Unexpected errors go to the application logger, like the access log.
158
164
  const errorLogger = this.resolver.tryResolve(LOGGER);
159
165
  serverChain.push(
@@ -164,13 +170,10 @@ export class HttpServer implements HostedService {
164
170
  onUnexpectedError: this.collectErrorHook(),
165
171
  }),
166
172
  );
167
- const serverMiddleware = this.collectServerMiddleware();
168
- // Responses produced before routing (404, 405, 413, preflight, docs,
169
- // health) still carry the correlation id of a global correlation middleware.
170
- this.shortCircuitChain = [...serverChain, ...serverMiddleware.filter(isCorrelationIdMiddleware)];
173
+ this.shortCircuitChain = [...serverChain];
171
174
  // A global `cors()` among the middleware answers preflights like `cors`.
172
175
  this.globalCors = this.options.cors || serverMiddleware.map(corsOptionsOf).find((options) => options !== undefined);
173
- serverChain.push(...serverMiddleware);
176
+ serverChain.push(...serverMiddleware.filter((middleware) => !isCorrelationIdMiddleware(middleware)));
174
177
 
175
178
  const builder = new RouterBuilder(
176
179
  serverChain,
@@ -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.7" }, 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.7",
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",