@agenthippo-ai/agenthippo 0.1.0 → 0.2.3-dataplane
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.
- package/out/agentpack/index.js +2 -1
- package/out/agentpack/paths.js +23 -0
- package/out/analytics/dashboard/fleetDisplayUtils.js +3 -0
- package/out/analytics/dashboard/webview/panel.js +290 -80
- package/out/analytics/sessionEvents.js +21 -4
- package/out/auth/brokerClient.js +1 -1
- package/out/auth/onboardingPrompt.js +112 -0
- package/out/auth/provider.js +1 -1
- package/out/auth-broker/config.js +8 -3
- package/out/auth-broker/pkce.js +3 -1
- package/out/auth-broker/server.js +43 -1
- package/out/auth-broker/store.js +6 -5
- package/out/cli/commands/ask.js +3 -0
- package/out/cli/commands/chat.js +3 -0
- package/out/cli/commands/deploy.js +49 -10
- package/out/cli/commands/gateway.js +54 -0
- package/out/cli/commands/idp.js +85 -0
- package/out/cli/commands/serve.js +401 -49
- package/out/cli/credential-context.js +47 -69
- package/out/cli/data-plane/broker.js +78 -0
- package/out/cli/data-plane/connector-host.js +121 -0
- package/out/cli/data-plane/egress.js +189 -0
- package/out/cli/data-plane/gateway.js +195 -0
- package/out/cli/data-plane/index.js +137 -0
- package/out/cli/data-plane/registry.js +80 -0
- package/out/cli/deploy/blueprints/registry.js +84 -0
- package/out/cli/deploy/layers/identity.js +91 -0
- package/out/cli/deploy-init.js +1156 -285
- package/out/cli/idp-init.js +303 -0
- package/out/cli/index.js +46 -8
- package/out/cli/pack-loader.js +37 -2
- package/out/cli/runtime.js +10 -3
- package/out/cli/serve-hardening.js +217 -12
- package/out/cli/state-sync.js +318 -29
- package/out/engines/claude.js +26 -5
- package/out/engines/codex.js +24 -5
- package/out/engines/custom.js +46 -12
- package/out/extension.js +2 -0
- package/out/gateway/outboundSend.js +6 -6
- package/out/headless/index.js +24 -6
- package/out/live-probe/connector.mjs +8 -0
- package/out/live-probe/env-report.mjs +8 -0
- package/out/mcp.js +88 -61
- package/out/remote/handleRemoteChat.js +7 -1
- package/out/remote/serveClient.js +27 -13
- package/out/scripts/analytics/agentide-litellm-otel.config.yaml +14 -0
- package/out/shared/models.js +1 -1
- package/out/shared/providers.js +5 -2
- package/out/test/analytics.test.js +93 -0
- package/out/test/auth-broker.test.js +59 -2
- package/out/test/blueprint-registry.test.js +105 -0
- package/out/test/credential-context.test.js +55 -0
- package/out/test/custom-env-layering.test.js +151 -0
- package/out/test/data-plane-engine-wiring.test.js +250 -0
- package/out/test/data-plane.test.js +244 -0
- package/out/test/deploy-init.test.js +287 -5
- package/out/test/egress-broker.test.js +172 -0
- package/out/test/fleetDisplayUtils.test.js +4 -0
- package/out/test/idp-init.test.js +151 -0
- package/out/test/mcp-obo.test.js +52 -27
- package/out/test/mcp.test.js +60 -4
- package/out/test/model-routing.test.js +133 -0
- package/out/test/serve-gateway-turn.test.js +267 -0
- package/out/test/serve-hardening.test.js +187 -0
- package/out/test/serveClient.test.js +37 -2
- package/out/test/state-sync-peer.test.js +83 -1
- package/out/utils.js +27 -11
- package/package.json +3 -4
|
@@ -36,14 +36,19 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
};
|
|
37
37
|
})();
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.TurnLimiter = exports.RateLimiter = exports.CONTAINER_SERVE_HOST = exports.SERVE_HOST_ENV = exports.DEFAULT_BIND_HOST = exports.DEFAULT_MAX_BODY_BYTES = void 0;
|
|
39
|
+
exports.TurnGate = exports.TURN_LIMIT_BUSY_MESSAGE = exports.RATE_LIMIT_BUSY_MESSAGE = exports.TURN_GATE_BUSY_MESSAGE = exports.TURN_GATE_RETRY_AFTER_SECONDS = exports.TURN_QUEUE_ENV = exports.MAX_CONCURRENT_TURNS_ENV = exports.TurnLimiter = exports.SESSION_WITHOUT_USER_REASON = exports.IDENTITY_HEADER_ENV = exports.RateLimiter = exports.XFF_TRUSTED_HOPS_ENV = exports.CONTAINER_SERVE_HOST = exports.SERVE_HOST_ENV = exports.DEFAULT_BIND_HOST = exports.DEFAULT_MAX_BODY_BYTES = void 0;
|
|
40
40
|
exports.resolveStateDir = resolveStateDir;
|
|
41
|
+
exports.resolveAnalyticsSessionsDir = resolveAnalyticsSessionsDir;
|
|
41
42
|
exports.resolveBindHost = resolveBindHost;
|
|
42
43
|
exports.clientIp = clientIp;
|
|
43
44
|
exports.resolveUserId = resolveUserId;
|
|
44
45
|
exports.resolveDataToken = resolveDataToken;
|
|
45
46
|
exports.peerToUserId = peerToUserId;
|
|
47
|
+
exports.userNamespaceHash = userNamespaceHash;
|
|
48
|
+
exports.resolveServeSessionKey = resolveServeSessionKey;
|
|
46
49
|
exports.corsHeaders = corsHeaders;
|
|
50
|
+
exports.createTurnGateFromEnv = createTurnGateFromEnv;
|
|
51
|
+
exports.createRejectionWarner = createRejectionWarner;
|
|
47
52
|
exports.readBodyWithCap = readBodyWithCap;
|
|
48
53
|
/**
|
|
49
54
|
* Serve helpers: state-dir resolution, identity header reading, CORS, turn limiting, body cap.
|
|
@@ -58,6 +63,8 @@ exports.readBodyWithCap = readBodyWithCap;
|
|
|
58
63
|
*/
|
|
59
64
|
const os = __importStar(require("os"));
|
|
60
65
|
const path = __importStar(require("path"));
|
|
66
|
+
const crypto = __importStar(require("crypto"));
|
|
67
|
+
const paths_1 = require("../utils/paths");
|
|
61
68
|
/** Default cap on a single request body (1 MiB). Chat messages are small; large bodies are abuse or bugs. */
|
|
62
69
|
exports.DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
63
70
|
/**
|
|
@@ -80,6 +87,26 @@ function resolveStateDir(stateDirOpt, env = process.env) {
|
|
|
80
87
|
}
|
|
81
88
|
return { dir: path.join(os.tmpdir(), 'agenthippo-server'), ephemeral: true };
|
|
82
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Where serve's Layer 2 analytics (tool-call/warrant JSONL) should live. Precedence:
|
|
92
|
+
* 1. AGENT_HOME_DIR / AGENTHIPPO_HOME set → getAgentideHome()/analytics/sessions — the
|
|
93
|
+
* exact location sessionJsonlFleet.ts and query-analytics.sh already scan by default,
|
|
94
|
+
* so this data stays discoverable to Spotlight/DuckDB.
|
|
95
|
+
* 2. Neither set, but the resolved state dir is non-ephemeral (an explicit --state-dir
|
|
96
|
+
* was given) → <stateDir>/analytics/sessions. Not the canonical scan location, but
|
|
97
|
+
* still durable — never regress below whatever durability the caller already
|
|
98
|
+
* established just to be discoverable.
|
|
99
|
+
* 3. Neither set and state is ephemeral (no --state-dir, no home-dir env var at all) →
|
|
100
|
+
* falls through to getAgentideHome()'s own os.homedir() default, matching the
|
|
101
|
+
* already-correct IDE-local behavior — nothing durable was configured either way.
|
|
102
|
+
*/
|
|
103
|
+
function resolveAnalyticsSessionsDir(stateDir, stateDirEphemeral, env = process.env) {
|
|
104
|
+
const homeOverride = env.AGENT_HOME_DIR?.trim() || env.AGENTHIPPO_HOME?.trim();
|
|
105
|
+
if (!homeOverride && !stateDirEphemeral) {
|
|
106
|
+
return path.join(stateDir, 'analytics', 'sessions');
|
|
107
|
+
}
|
|
108
|
+
return path.join((0, paths_1.getAgentideHome)(), 'analytics', 'sessions');
|
|
109
|
+
}
|
|
83
110
|
/** Loopback default — safe everywhere a human runs `serve` locally. */
|
|
84
111
|
exports.DEFAULT_BIND_HOST = '127.0.0.1';
|
|
85
112
|
/** Env var for the listen address (used by generated deploy Dockerfiles). */
|
|
@@ -96,21 +123,29 @@ exports.CONTAINER_SERVE_HOST = '0.0.0.0';
|
|
|
96
123
|
function resolveBindHost(hostOpt, env = process.env) {
|
|
97
124
|
return hostOpt?.trim() || env.AGENTHIPPO_SERVE_HOST?.trim() || exports.DEFAULT_BIND_HOST;
|
|
98
125
|
}
|
|
126
|
+
/** Env var: number of trusted proxy hops in front of serve (default 1 — the single Caddy). */
|
|
127
|
+
exports.XFF_TRUSTED_HOPS_ENV = 'AGENTHIPPO_XFF_TRUSTED_HOPS';
|
|
99
128
|
/**
|
|
100
129
|
* Best-effort client IP for rate-limiting keys.
|
|
101
130
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
131
|
+
* Each trusted proxy appends the IP it observed to `X-Forwarded-For`, so with N trusted hops
|
|
132
|
+
* the real client is the Nth entry from the right (a client-spoofed left-most entry is just
|
|
133
|
+
* prepended and never reached). Default is 1 (the documented single Caddy). Behind
|
|
134
|
+
* Cloudflare Tunnel → Caddy there are two hops — Caddy appends cloudflared's container IP,
|
|
135
|
+
* which is identical for every client, so leaving hops at 1 there collapses all users into one
|
|
136
|
+
* rate-limit bucket. Set AGENTHIPPO_XFF_TRUSTED_HOPS=2 for the tunnel profile.
|
|
137
|
+
* Falls back to the socket address for direct/local callers. This is a coarse abuse key, not
|
|
138
|
+
* an identity — never use it for authorization.
|
|
106
139
|
*/
|
|
107
|
-
function clientIp(req) {
|
|
140
|
+
function clientIp(req, env = process.env) {
|
|
108
141
|
const xff = req.headers['x-forwarded-for'];
|
|
109
142
|
const raw = Array.isArray(xff) ? xff[0] : xff;
|
|
110
143
|
if (raw) {
|
|
111
144
|
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
112
145
|
if (parts.length > 0) {
|
|
113
|
-
|
|
146
|
+
const hopsRaw = Number(env[exports.XFF_TRUSTED_HOPS_ENV]);
|
|
147
|
+
const hops = Number.isInteger(hopsRaw) && hopsRaw >= 1 ? hopsRaw : 1;
|
|
148
|
+
return parts[Math.max(0, parts.length - hops)];
|
|
114
149
|
}
|
|
115
150
|
}
|
|
116
151
|
return req.socket?.remoteAddress || 'unknown';
|
|
@@ -176,12 +211,25 @@ const HEADER_ALIASES = [
|
|
|
176
211
|
'x-goog-authenticated-user-email', // Cloud Run IAP (prefixed value, stripped below)
|
|
177
212
|
'cf-access-authenticated-user-email', // Cloudflare Access
|
|
178
213
|
];
|
|
214
|
+
/** Env var: pin the single identity header the deploy layer actually sets (and strips). */
|
|
215
|
+
exports.IDENTITY_HEADER_ENV = 'AGENTHIPPO_IDENTITY_HEADER';
|
|
179
216
|
/**
|
|
180
217
|
* Resolve the end-user identity for a request.
|
|
181
218
|
*
|
|
182
|
-
* Proxy headers win over the request body
|
|
183
|
-
*
|
|
184
|
-
*
|
|
219
|
+
* Proxy headers win over the request body. When AGENTHIPPO_IDENTITY_HEADER pins
|
|
220
|
+
* trust to one header, that pin is a hard boundary: if the pinned header is
|
|
221
|
+
* absent, identity resolves to undefined (never the request body) — a deployment
|
|
222
|
+
* that pins a header has declared it has a real proxy-verified identity, so an
|
|
223
|
+
* unauthenticated caller must not be able to name themselves via the client-
|
|
224
|
+
* controlled body. Body fallback is dev/internal convenience for the *unpinned*
|
|
225
|
+
* case only (e.g. direct curl against a local serve with no identity proxy).
|
|
226
|
+
*
|
|
227
|
+
* The alias-list default (used when unpinned) is only safe when the proxy strips
|
|
228
|
+
* or overwrites ALL of the aliases; platforms that strip only their own header
|
|
229
|
+
* (Cloud Run IAP strips `X-Goog-*` but forwards a client-supplied `x-agent-user`
|
|
230
|
+
* untouched, which the alias list checks first) MUST pin theirs — deploy
|
|
231
|
+
* artifacts set it (e.g. cloudrun-deploy.sh pins `x-goog-authenticated-user-email`)
|
|
232
|
+
* so an authenticated caller cannot impersonate another user.
|
|
185
233
|
*
|
|
186
234
|
* Resolves identity for `analyticsUserId` attribution and the request-scoped
|
|
187
235
|
* credential context (per-user data scoping). Not folded into the session key —
|
|
@@ -190,8 +238,10 @@ const HEADER_ALIASES = [
|
|
|
190
238
|
* Per-turn *data credentials* (OBO token, e.g. X-Forwarded-Access-Token) are
|
|
191
239
|
* handled by resolveDataToken — not read here.
|
|
192
240
|
*/
|
|
193
|
-
function resolveUserId(headers, body) {
|
|
194
|
-
|
|
241
|
+
function resolveUserId(headers, body, env = process.env) {
|
|
242
|
+
const pinned = env[exports.IDENTITY_HEADER_ENV]?.trim().toLowerCase();
|
|
243
|
+
const names = pinned ? [pinned] : HEADER_ALIASES;
|
|
244
|
+
for (const name of names) {
|
|
195
245
|
const v = headers[name];
|
|
196
246
|
const raw = (Array.isArray(v) ? v[0] : v)?.trim();
|
|
197
247
|
if (raw) {
|
|
@@ -201,6 +251,12 @@ function resolveUserId(headers, body) {
|
|
|
201
251
|
return value;
|
|
202
252
|
}
|
|
203
253
|
}
|
|
254
|
+
if (pinned) {
|
|
255
|
+
// Pinned but absent: fail closed. Falling back to the body here would let any
|
|
256
|
+
// caller that merely reaches this port (proxy misconfiguration, direct network
|
|
257
|
+
// access, gcloud run services proxy bypassing IAP) name an arbitrary victim.
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
204
260
|
const fromBody = body?.user_id?.trim();
|
|
205
261
|
return fromBody || undefined;
|
|
206
262
|
}
|
|
@@ -244,6 +300,36 @@ function peerToUserId(peer) {
|
|
|
244
300
|
const channel = peer.channel?.trim();
|
|
245
301
|
return channel ? `${channel}:${id}` : id;
|
|
246
302
|
}
|
|
303
|
+
/** Stable reason code when a session_id is presented without a verified user on a pinned-identity deployment. */
|
|
304
|
+
exports.SESSION_WITHOUT_USER_REASON = 'session_without_verified_user';
|
|
305
|
+
/** Filesystem/S3-safe per-user namespace segment (user ids are emails/subjects — never raw path segments). */
|
|
306
|
+
function userNamespaceHash(userId) {
|
|
307
|
+
return crypto.createHash('sha256').update(userId).digest('hex').slice(0, 16);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Namespace the session key by the verified user (secure-obo-design §3.4 / §8 Phase 0).
|
|
311
|
+
*
|
|
312
|
+
* `session_id` is client-supplied; un-namespaced, any authenticated user who guesses another
|
|
313
|
+
* user's session_id attaches to that session's conversation state (which holds the other
|
|
314
|
+
* user's row-filtered query results). Prefixing the key with a hash of the verified user makes
|
|
315
|
+
* cross-user attachment structurally impossible without changing client behavior.
|
|
316
|
+
*
|
|
317
|
+
* Fail-closed rule: when the deployment pins an identity header (it declared a real
|
|
318
|
+
* proxy-verified identity exists) and a request carries a session_id but no verified user,
|
|
319
|
+
* reject — never let an unauthenticated caller name a session. Unpinned deployments
|
|
320
|
+
* (local dev, no identity boundary at all) keep today's raw-key behavior.
|
|
321
|
+
*/
|
|
322
|
+
function resolveServeSessionKey(sessionId, userId, env = process.env) {
|
|
323
|
+
const sid = sessionId?.trim();
|
|
324
|
+
const uid = userId?.trim();
|
|
325
|
+
if (uid) {
|
|
326
|
+
return { sessionKey: `u${userNamespaceHash(uid)}--${sid || 'default'}` };
|
|
327
|
+
}
|
|
328
|
+
if (sid && env[exports.IDENTITY_HEADER_ENV]?.trim()) {
|
|
329
|
+
return { sessionKey: '', rejected: exports.SESSION_WITHOUT_USER_REASON };
|
|
330
|
+
}
|
|
331
|
+
return { sessionKey: sid || 'anon-unknown' };
|
|
332
|
+
}
|
|
247
333
|
/** Build CORS headers. Serve is always behind a proxy; CORS is wide-open here. */
|
|
248
334
|
function corsHeaders() {
|
|
249
335
|
return {
|
|
@@ -281,6 +367,125 @@ class TurnLimiter {
|
|
|
281
367
|
}
|
|
282
368
|
}
|
|
283
369
|
exports.TurnLimiter = TurnLimiter;
|
|
370
|
+
/** Env: max concurrent engine turns per process (default 10). `0` disables the gate. */
|
|
371
|
+
exports.MAX_CONCURRENT_TURNS_ENV = 'AGENTHIPPO_MAX_CONCURRENT_TURNS';
|
|
372
|
+
/** Env: bounded FIFO waiter depth when at inflight cap (default 20). */
|
|
373
|
+
exports.TURN_QUEUE_ENV = 'AGENTHIPPO_TURN_QUEUE';
|
|
374
|
+
/** Fixed Retry-After seconds when the turn gate rejects (matches default queue timeout). */
|
|
375
|
+
exports.TURN_GATE_RETRY_AFTER_SECONDS = 15;
|
|
376
|
+
/** User-visible capacity message for gateway channels (Slack/Telegram can't receive HTTP 429). */
|
|
377
|
+
exports.TURN_GATE_BUSY_MESSAGE = 'At capacity — please retry in a moment';
|
|
378
|
+
/** User-visible message for gateway channels when the per-identity RateLimiter rejects. */
|
|
379
|
+
exports.RATE_LIMIT_BUSY_MESSAGE = 'Rate limit exceeded — please retry in a moment';
|
|
380
|
+
/** User-visible message for gateway channels when the per-session TurnLimiter rejects. */
|
|
381
|
+
exports.TURN_LIMIT_BUSY_MESSAGE = 'Per-session turn limit reached';
|
|
382
|
+
/**
|
|
383
|
+
* Process-wide concurrency gate for engine turns (semaphore + bounded FIFO).
|
|
384
|
+
*
|
|
385
|
+
* Caps simultaneous engine subprocesses so a burst cannot OOM the container.
|
|
386
|
+
* `maxInflight <= 0` disables. Waiters time out after `queueTimeoutMs` (burst
|
|
387
|
+
* absorber, not a job queue). Rejected callers get `null` — HTTP maps that to
|
|
388
|
+
* 429 + Retry-After; the gateway path emits a short channel message instead.
|
|
389
|
+
*/
|
|
390
|
+
class TurnGate {
|
|
391
|
+
maxInflight;
|
|
392
|
+
maxQueue;
|
|
393
|
+
queueTimeoutMs;
|
|
394
|
+
inflightCount = 0;
|
|
395
|
+
waiters = [];
|
|
396
|
+
constructor(maxInflight, maxQueue, queueTimeoutMs = exports.TURN_GATE_RETRY_AFTER_SECONDS * 1000) {
|
|
397
|
+
this.maxInflight = maxInflight;
|
|
398
|
+
this.maxQueue = maxQueue;
|
|
399
|
+
this.queueTimeoutMs = queueTimeoutMs;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Acquire a turn slot. Resolves with a `release()` callback, or `null` when
|
|
403
|
+
* the queue is full or the wait timed out. Always call `release()` in a
|
|
404
|
+
* `finally` when non-null.
|
|
405
|
+
*/
|
|
406
|
+
async acquire() {
|
|
407
|
+
if (!Number.isFinite(this.maxInflight) || this.maxInflight <= 0) {
|
|
408
|
+
return () => { };
|
|
409
|
+
}
|
|
410
|
+
if (this.inflightCount < this.maxInflight) {
|
|
411
|
+
this.inflightCount += 1;
|
|
412
|
+
return () => this.releaseSlot();
|
|
413
|
+
}
|
|
414
|
+
if (this.waiters.length >= this.maxQueue) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
return new Promise((resolve) => {
|
|
418
|
+
let settled = false;
|
|
419
|
+
const settle = (release) => {
|
|
420
|
+
if (settled) {
|
|
421
|
+
// Timed out after a slot was already offered — give the slot back.
|
|
422
|
+
if (release) {
|
|
423
|
+
release();
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
settled = true;
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
resolve(release);
|
|
430
|
+
};
|
|
431
|
+
const entry = {
|
|
432
|
+
resolve: settle,
|
|
433
|
+
timer: null,
|
|
434
|
+
};
|
|
435
|
+
const timer = setTimeout(() => {
|
|
436
|
+
const idx = this.waiters.indexOf(entry);
|
|
437
|
+
if (idx >= 0) {
|
|
438
|
+
this.waiters.splice(idx, 1);
|
|
439
|
+
}
|
|
440
|
+
settle(null);
|
|
441
|
+
}, this.queueTimeoutMs);
|
|
442
|
+
entry.timer = timer;
|
|
443
|
+
this.waiters.push(entry);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
releaseSlot() {
|
|
447
|
+
const next = this.waiters.shift();
|
|
448
|
+
if (next) {
|
|
449
|
+
// Transfer the slot to the head waiter — do not decrement inflight.
|
|
450
|
+
next.resolve(() => this.releaseSlot());
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
this.inflightCount = Math.max(0, this.inflightCount - 1);
|
|
454
|
+
}
|
|
455
|
+
get inflight() {
|
|
456
|
+
return this.inflightCount;
|
|
457
|
+
}
|
|
458
|
+
get queued() {
|
|
459
|
+
return this.waiters.length;
|
|
460
|
+
}
|
|
461
|
+
get max() {
|
|
462
|
+
return this.maxInflight > 0 ? this.maxInflight : 0;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
exports.TurnGate = TurnGate;
|
|
466
|
+
/** Build a TurnGate from env (serve-v4 P0.1). Defaults: 10 inflight, 20 queued. */
|
|
467
|
+
function createTurnGateFromEnv(env = process.env) {
|
|
468
|
+
const inflightRaw = Number(env[exports.MAX_CONCURRENT_TURNS_ENV] ?? 10);
|
|
469
|
+
const queueRaw = Number(env[exports.TURN_QUEUE_ENV] ?? 20);
|
|
470
|
+
const maxInflight = Number.isFinite(inflightRaw) ? inflightRaw : 10;
|
|
471
|
+
const maxQueue = Number.isFinite(queueRaw) && queueRaw >= 0 ? queueRaw : 20;
|
|
472
|
+
return new TurnGate(maxInflight, maxQueue);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Rate-limited rejection logger — at most one warn per `intervalMs` so a flood
|
|
476
|
+
* of 429s does not spam the console.
|
|
477
|
+
*/
|
|
478
|
+
function createRejectionWarner(intervalMs = 5_000) {
|
|
479
|
+
let lastAt = 0;
|
|
480
|
+
return (msg) => {
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
if (now - lastAt < intervalMs) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
lastAt = now;
|
|
486
|
+
console.warn(msg);
|
|
487
|
+
};
|
|
488
|
+
}
|
|
284
489
|
/**
|
|
285
490
|
* Read a request body with a hard size cap. Past the cap it stops accumulating
|
|
286
491
|
* and rejects with a `tooLarge`-tagged error so the caller can respond 413.
|