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(
|
|
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
|
}
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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 (
|
|
44
|
-
const forwarded = ctx.header(proxyHeader)
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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