@bbradar/mcp 0.2.5 → 0.2.6

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.
@@ -1,7 +1,75 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import { TtlCache } from "./cache.js";
3
4
  import { stableStringify } from "./json.js";
4
5
  import { encodeProgramIdPath } from "./url.js";
6
+ function publicPayload(payload) {
7
+ const { cacheAllowed: _cacheAllowed, ...publicFields } = payload;
8
+ return publicFields;
9
+ }
10
+ function subscribeToRequest(promise, requestId, signal, shared) {
11
+ if (shared)
12
+ shared.consumers += 1;
13
+ return new Promise((resolve, reject) => {
14
+ let settled = false;
15
+ const finish = (callback) => {
16
+ if (settled)
17
+ return;
18
+ settled = true;
19
+ signal?.removeEventListener("abort", cancel);
20
+ if (shared)
21
+ shared.consumers -= 1;
22
+ callback();
23
+ };
24
+ const cancel = () => finish(() => {
25
+ if (shared?.consumers === 0)
26
+ shared.controller.abort(signal?.reason);
27
+ reject(new BBRadarApiError({ message: "BBRadar API request was cancelled or exceeded its tool deadline.", requestId, status: 408 }));
28
+ });
29
+ signal?.addEventListener("abort", cancel, { once: true });
30
+ promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
31
+ if (signal?.aborted)
32
+ cancel();
33
+ });
34
+ }
35
+ function waitForRequestSlot(queue, deadline, requestId, signal) {
36
+ if (queue.length >= MAX_QUEUED_REQUESTS) {
37
+ throw new BBRadarApiError({ message: "BBRadar API request queue is full.", requestId, status: 503 });
38
+ }
39
+ if (deadline <= Date.now() || signal?.aborted) {
40
+ throw new BBRadarApiError({ message: "BBRadar API request timed out or was cancelled while queued.", requestId, status: 408 });
41
+ }
42
+ return new Promise((resolve, reject) => {
43
+ let settled = false;
44
+ const remove = () => {
45
+ const index = queue.indexOf(grant);
46
+ if (index >= 0)
47
+ queue.splice(index, 1);
48
+ };
49
+ const cleanup = () => {
50
+ clearTimeout(timer);
51
+ signal?.removeEventListener("abort", cancel);
52
+ };
53
+ const grant = () => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ cleanup();
58
+ resolve();
59
+ };
60
+ const cancel = () => {
61
+ if (settled)
62
+ return;
63
+ settled = true;
64
+ remove();
65
+ cleanup();
66
+ reject(new BBRadarApiError({ message: "BBRadar API request timed out or was cancelled while queued.", requestId, status: 408 }));
67
+ };
68
+ const timer = setTimeout(cancel, Math.max(1, deadline - Date.now()));
69
+ signal?.addEventListener("abort", cancel, { once: true });
70
+ queue.push(grant);
71
+ });
72
+ }
5
73
  const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
6
74
  const MAX_UPSTREAM_RATE_LIMIT_DELAY_MS = 10 * 60_000;
7
75
  const CHANGE_FEED_CONCURRENCY_LIMIT = 2;
@@ -14,6 +82,7 @@ const DEFAULT_PROGRAM_SWR_MS = 5 * 60_000;
14
82
  const DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 3;
15
83
  const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000;
16
84
  const SNAPSHOT_START_MIN_TIMEOUT_MS = 60_000;
85
+ const MAX_QUEUED_REQUESTS = 100;
17
86
  export class BBRadarApiError extends Error {
18
87
  requestId;
19
88
  upstreamRequestId;
@@ -23,6 +92,7 @@ export class BBRadarApiError extends Error {
23
92
  cached;
24
93
  coalesced;
25
94
  cacheExpiresAt;
95
+ cacheAllowed;
26
96
  constructor(params) {
27
97
  super(params.message, { cause: params.cause });
28
98
  this.name = "BBRadarApiError";
@@ -34,6 +104,7 @@ export class BBRadarApiError extends Error {
34
104
  this.cached = params.cached ?? false;
35
105
  this.coalesced = params.coalesced ?? false;
36
106
  this.cacheExpiresAt = params.cacheExpiresAt;
107
+ this.cacheAllowed = params.cacheAllowed ?? true;
37
108
  }
38
109
  }
39
110
  export class BBRadarClient {
@@ -41,6 +112,8 @@ export class BBRadarClient {
41
112
  cache;
42
113
  negativeCache;
43
114
  inFlight = new Map();
115
+ sharedRequests = new Map();
116
+ requestContext = new AsyncLocalStorage();
44
117
  endpointMetrics = new Map();
45
118
  requestQueue = [];
46
119
  endpointRequestQueues = new Map();
@@ -57,6 +130,24 @@ export class BBRadarClient {
57
130
  this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs, config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS);
58
131
  this.negativeCache = new TtlCache(config.cacheMaxEntries, config.negativeCacheTtlMs);
59
132
  }
133
+ async withRequestContext(signal, callback, snapshotStart = false) {
134
+ const controller = new AbortController();
135
+ const onAbort = () => controller.abort(signal.reason);
136
+ const timeout = setTimeout(() => controller.abort(new DOMException("Tool deadline exceeded.", "TimeoutError")), snapshotStart
137
+ ? Math.max(this.config.requestTimeoutMs, SNAPSHOT_START_MIN_TIMEOUT_MS)
138
+ : this.config.requestTimeoutMs);
139
+ signal.addEventListener("abort", onAbort, { once: true });
140
+ if (signal.aborted)
141
+ onAbort();
142
+ try {
143
+ return await this.requestContext.run(controller.signal, callback);
144
+ }
145
+ finally {
146
+ clearTimeout(timeout);
147
+ signal.removeEventListener("abort", onAbort);
148
+ controller.abort();
149
+ }
150
+ }
60
151
  diagnostics() {
61
152
  const endpointEntries = [...this.endpointMetrics.entries()];
62
153
  const totalRequests = endpointEntries.reduce((total, [, metric]) => total + metric.requests, 0);
@@ -268,6 +359,27 @@ export class BBRadarClient {
268
359
  query
269
360
  });
270
361
  }
362
+ getHistoryChanges(query) {
363
+ return this.request({ path: "/pro/history/changes", query, cache: false });
364
+ }
365
+ getHistoryChangeDetail(changeId, query) {
366
+ return this.request({ path: `/pro/history/changes/${encodeURIComponent(changeId)}`, query, cache: false });
367
+ }
368
+ getProgramHistory(programId, query) {
369
+ return this.request({ path: `/pro/history/programs/${encodeProgramIdPath(programId)}`, query, cache: false });
370
+ }
371
+ getProgramActivity(programId) {
372
+ return this.request({ path: `/pro/activity/programs/${encodeProgramIdPath(programId)}`, cache: false });
373
+ }
374
+ getAnalyticsPlatformSeries(platform, query) {
375
+ return this.request({ path: `/pro/analytics/platforms/${encodeURIComponent(platform)}/series`, query, cache: false });
376
+ }
377
+ getAnalyticsProgramActivity(query) {
378
+ return this.request({ path: "/pro/analytics/programs/activity", query, cache: false });
379
+ }
380
+ getAnalyticsProgramDetail(programId, query) {
381
+ return this.request({ path: `/pro/analytics/programs/${encodeProgramIdPath(programId)}`, query, cache: false });
382
+ }
271
383
  exportTargets(body) {
272
384
  return this.request({
273
385
  method: "POST",
@@ -278,8 +390,33 @@ export class BBRadarClient {
278
390
  });
279
391
  }
280
392
  async request(options) {
393
+ const contextSignal = this.requestContext.getStore();
394
+ if (!options.signal || !contextSignal || options.signal === contextSignal) {
395
+ return this.requestWithSignal(options, options.signal ?? contextSignal);
396
+ }
397
+ // AbortSignal.any is unavailable on the earliest supported Node 20 releases.
398
+ // Remove both listeners after this caller settles, even for coalesced reads.
399
+ const controller = new AbortController();
400
+ const signals = [options.signal, contextSignal];
401
+ const listeners = signals.map((signal) => {
402
+ const abort = () => controller.abort(signal.reason);
403
+ signal.addEventListener("abort", abort, { once: true });
404
+ if (signal.aborted)
405
+ abort();
406
+ return abort;
407
+ });
408
+ try {
409
+ return await this.requestWithSignal(options, controller.signal);
410
+ }
411
+ finally {
412
+ signals.forEach((signal, index) => signal.removeEventListener("abort", listeners[index]));
413
+ }
414
+ }
415
+ async requestWithSignal(options, signal) {
281
416
  const method = options.method ?? "GET";
282
417
  const requestId = randomUUID();
418
+ if (signal?.aborted)
419
+ throw new BBRadarApiError({ message: "BBRadar API request was cancelled or exceeded its tool deadline.", requestId, status: 408 });
283
420
  const url = this.buildUrl(options.path, options.query);
284
421
  const requestKey = stableStringify({
285
422
  method,
@@ -299,7 +436,7 @@ export class BBRadarClient {
299
436
  this.refreshInBackground(options, method, url, requestKey);
300
437
  }
301
438
  return {
302
- ...cached.value,
439
+ ...publicPayload(cached.value),
303
440
  requestId,
304
441
  cached: true,
305
442
  stale: cached.stale,
@@ -320,11 +457,11 @@ export class BBRadarClient {
320
457
  }
321
458
  if (coalesceable) {
322
459
  const pending = this.inFlight.get(requestKey);
323
- if (pending) {
460
+ if (pending && !this.sharedRequests.get(requestKey)?.controller.signal.aborted) {
324
461
  try {
325
- const result = (await pending);
462
+ const result = (await subscribeToRequest(pending, requestId, signal, this.sharedRequests.get(requestKey)));
326
463
  return {
327
- ...result,
464
+ ...publicPayload(result),
328
465
  requestId,
329
466
  cached: false,
330
467
  coalesced: true
@@ -335,27 +472,20 @@ export class BBRadarClient {
335
472
  }
336
473
  }
337
474
  }
338
- const request = this.fetchFromApi(options, method, url, requestId);
339
- if (coalesceable) {
340
- this.inFlight.set(requestKey, request);
341
- }
342
- try {
343
- const result = await request;
344
- if (cacheable) {
475
+ const shared = coalesceable ? { controller: new AbortController(), consumers: 0 } : undefined;
476
+ const requestOptions = { ...options, ...(shared ? { signal: shared.controller.signal } : signal ? { signal } : {}) };
477
+ const request = this.fetchFromApi(requestOptions, method, url, requestId).then((result) => {
478
+ if (cacheable && result.cacheAllowed !== false) {
345
479
  this.cache.set(requestKey, result);
346
480
  }
347
481
  if (negativeCacheable) {
348
482
  this.negativeCache.delete(requestKey);
349
483
  }
350
- return {
351
- ...result,
352
- requestId,
353
- cached: false
354
- };
355
- }
356
- catch (error) {
484
+ return result;
485
+ }).catch((error) => {
357
486
  if (negativeCacheable &&
358
487
  error instanceof BBRadarApiError &&
488
+ error.cacheAllowed &&
359
489
  error.status !== undefined &&
360
490
  isNegativeCacheStatus(error.status)) {
361
491
  this.negativeCache.set(requestKey, {
@@ -367,14 +497,21 @@ export class BBRadarClient {
367
497
  });
368
498
  }
369
499
  throw error;
370
- }
371
- finally {
372
- if (coalesceable) {
500
+ }).finally(() => {
501
+ if (coalesceable && this.inFlight.get(requestKey) === request) {
373
502
  this.inFlight.delete(requestKey);
503
+ this.sharedRequests.delete(requestKey);
374
504
  }
505
+ });
506
+ if (coalesceable) {
507
+ this.inFlight.set(requestKey, request);
508
+ this.sharedRequests.set(requestKey, shared);
375
509
  }
510
+ const result = await subscribeToRequest(request, requestId, signal, shared);
511
+ return { ...publicPayload(result), requestId, cached: false };
376
512
  }
377
513
  async fetchFromApi(options, method, url, requestId) {
514
+ const deadline = Date.now() + (options.timeoutMs ?? this.config.requestTimeoutMs);
378
515
  const serializedBody = options.body === undefined ? undefined : JSON.stringify(options.body);
379
516
  if (serializedBody !== undefined) {
380
517
  const bodyBytes = Buffer.byteLength(serializedBody, "utf8");
@@ -389,11 +526,20 @@ export class BBRadarClient {
389
526
  }
390
527
  return this.withRequestSlot(options.path, async () => {
391
528
  const rateLimitBucket = rateLimitEndpointGroup(options.path);
529
+ if (options.signal?.aborted)
530
+ throw new BBRadarApiError({ message: "BBRadar API request was cancelled.", requestId, status: 408 });
392
531
  this.assertCircuitClosed(requestId, rateLimitBucket);
393
532
  this.reserveUpstreamRequest(requestId, rateLimitBucket);
394
533
  const startedAt = Date.now();
395
534
  const controller = new AbortController();
396
- const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? this.config.requestTimeoutMs);
535
+ const remainingMs = deadline - Date.now();
536
+ if (remainingMs <= 0)
537
+ throw new BBRadarApiError({ message: "BBRadar API request timed out while queued.", requestId, status: 408 });
538
+ const timeout = setTimeout(() => controller.abort(), remainingMs);
539
+ const onAbort = () => controller.abort();
540
+ options.signal?.addEventListener("abort", onAbort, { once: true });
541
+ if (options.signal?.aborted)
542
+ controller.abort();
397
543
  let status;
398
544
  let failed = true;
399
545
  try {
@@ -407,6 +553,7 @@ export class BBRadarClient {
407
553
  }
408
554
  const response = await fetch(url, init);
409
555
  status = response.status;
556
+ const noStore = /(?:^|,)\s*(?:no-store|no-cache|private)\b/i.test(response.headers.get("cache-control") ?? "");
410
557
  this.observeCircuitResponse(rateLimitBucket, response.status);
411
558
  this.observeUpstreamRateLimit(rateLimitBucket, response.headers, response.status);
412
559
  const upstreamRequestId = extractRequestId(response.headers);
@@ -418,7 +565,8 @@ export class BBRadarClient {
418
565
  requestId,
419
566
  status: response.status,
420
567
  detail: extractObjectField(data, "detail"),
421
- errors: extractObjectField(data, "errors")
568
+ errors: extractObjectField(data, "errors"),
569
+ cacheAllowed: !noStore
422
570
  };
423
571
  if (upstreamRequestId) {
424
572
  params.upstreamRequestId = upstreamRequestId;
@@ -428,7 +576,9 @@ export class BBRadarClient {
428
576
  const result = {
429
577
  data: data,
430
578
  status: response.status,
431
- fetchedAt: new Date().toISOString()
579
+ fetchedAt: new Date().toISOString(),
580
+ noStore,
581
+ cacheAllowed: !noStore
432
582
  };
433
583
  if (upstreamRequestId) {
434
584
  result.upstreamRequestId = upstreamRequestId;
@@ -440,6 +590,12 @@ export class BBRadarClient {
440
590
  if (error instanceof BBRadarApiError) {
441
591
  throw error;
442
592
  }
593
+ if (options.signal?.aborted) {
594
+ const timedOut = options.signal.reason instanceof Error && options.signal.reason.name === "TimeoutError";
595
+ if (timedOut)
596
+ this.observeCircuitFailure(rateLimitBucket);
597
+ throw new BBRadarApiError({ message: timedOut ? "BBRadar API tool deadline exceeded." : "BBRadar API request was cancelled.", requestId, status: 408 });
598
+ }
443
599
  this.observeCircuitFailure(rateLimitBucket);
444
600
  throw new BBRadarApiError({
445
601
  message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
@@ -449,9 +605,10 @@ export class BBRadarClient {
449
605
  }
450
606
  finally {
451
607
  clearTimeout(timeout);
608
+ options.signal?.removeEventListener("abort", onAbort);
452
609
  this.recordEndpointMetric(options.path, Date.now() - startedAt, status, failed);
453
610
  }
454
- });
611
+ }, deadline, requestId, options.signal);
455
612
  }
456
613
  refreshInBackground(options, method, url, requestKey) {
457
614
  if (this.inFlight.has(requestKey)) {
@@ -461,7 +618,12 @@ export class BBRadarClient {
461
618
  this.inFlight.set(requestKey, refresh);
462
619
  void refresh
463
620
  .then((result) => {
464
- this.cache.set(requestKey, result);
621
+ if (result.cacheAllowed !== false) {
622
+ this.cache.set(requestKey, result);
623
+ }
624
+ else {
625
+ this.cache.delete(requestKey);
626
+ }
465
627
  this.negativeCache.delete(requestKey);
466
628
  })
467
629
  .catch(() => undefined)
@@ -471,21 +633,21 @@ export class BBRadarClient {
471
633
  }
472
634
  });
473
635
  }
474
- async withRequestSlot(path, callback) {
636
+ async withRequestSlot(path, callback, deadline, requestId, signal) {
475
637
  const endpointGroup = concurrencyEndpointGroup(path);
476
638
  if (endpointGroup === undefined) {
477
- return this.withGlobalRequestSlot(callback);
639
+ return this.withGlobalRequestSlot(callback, deadline, requestId, signal);
478
640
  }
479
641
  const endpointLimit = endpointConcurrencyLimit(endpointGroup);
480
642
  if (endpointLimit === undefined) {
481
- return this.withGlobalRequestSlot(callback);
643
+ return this.withGlobalRequestSlot(callback, deadline, requestId, signal);
482
644
  }
483
- return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback));
645
+ return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback, deadline, requestId, signal), deadline, requestId, signal);
484
646
  }
485
- async withGlobalRequestSlot(callback) {
647
+ async withGlobalRequestSlot(callback, deadline, requestId, signal) {
486
648
  const limit = Math.max(1, this.config.maxConcurrentRequests);
487
649
  if (this.activeRequests >= limit) {
488
- await new Promise((resolve) => this.requestQueue.push(resolve));
650
+ await waitForRequestSlot(this.requestQueue, deadline, requestId, signal);
489
651
  }
490
652
  else {
491
653
  this.activeRequests += 1;
@@ -503,14 +665,12 @@ export class BBRadarClient {
503
665
  }
504
666
  }
505
667
  }
506
- async withEndpointRequestSlot(endpointGroup, limit, callback) {
668
+ async withEndpointRequestSlot(endpointGroup, limit, callback, deadline, requestId, signal) {
507
669
  const active = this.endpointActiveRequests.get(endpointGroup) ?? 0;
508
670
  if (active >= limit) {
509
671
  const queue = this.endpointRequestQueues.get(endpointGroup) ?? [];
510
- await new Promise((resolve) => {
511
- queue.push(resolve);
512
- this.endpointRequestQueues.set(endpointGroup, queue);
513
- });
672
+ this.endpointRequestQueues.set(endpointGroup, queue);
673
+ await waitForRequestSlot(queue, deadline, requestId, signal);
514
674
  }
515
675
  else {
516
676
  this.endpointActiveRequests.set(endpointGroup, active + 1);
@@ -800,7 +960,9 @@ export class BBRadarClient {
800
960
  };
801
961
  }
802
962
  buildUrl(path, query) {
803
- const url = new URL(`${this.config.apiBaseUrl}${path}`);
963
+ const base = this.config.apiBaseUrl.replace(/\/+$/, "");
964
+ const prefix = /\/v1$/i.test(base) ? base : `${base}/v1`;
965
+ const url = new URL(`${prefix}${path}`);
804
966
  for (const [key, value] of Object.entries(query ?? {})) {
805
967
  if (value === undefined || value === null) {
806
968
  continue;
@@ -905,6 +1067,16 @@ function toIsoString(timestamp) {
905
1067
  return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
906
1068
  }
907
1069
  function metricEndpoint(path) {
1070
+ if (/^\/pro\/history\/changes\/.+/.test(path))
1071
+ return "/pro/history/changes/:change_id";
1072
+ if (/^\/pro\/history\/programs\/.+/.test(path))
1073
+ return "/pro/history/programs/:program_id";
1074
+ if (/^\/pro\/activity\/programs\/.+/.test(path))
1075
+ return "/pro/activity/programs/:program_id";
1076
+ if (/^\/pro\/analytics\/platforms\/.+\/series$/.test(path))
1077
+ return "/pro/analytics/platforms/:platform/series";
1078
+ if (/^\/pro\/analytics\/programs\/.+/.test(path) && path !== "/pro/analytics/programs/activity")
1079
+ return "/pro/analytics/programs/:program_id";
908
1080
  if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
909
1081
  return "/pro/programs/:program_id/targets";
910
1082
  }
@@ -917,7 +1089,7 @@ function metricEndpoint(path) {
917
1089
  return path;
918
1090
  }
919
1091
  function rateLimitEndpointGroup(path) {
920
- if (path === "/pro/targets/changes") {
1092
+ if (path === "/pro/targets/changes" || /^\/pro\/(?:history|activity|analytics)\//.test(path)) {
921
1093
  return "change_feeds";
922
1094
  }
923
1095
  if (path === "/pro/repositories/changes") {
@@ -941,7 +1113,7 @@ function rateLimitEndpointGroup(path) {
941
1113
  return "other";
942
1114
  }
943
1115
  function concurrencyEndpointGroup(path) {
944
- if (path === "/pro/targets/changes" || path === "/pro/repositories/changes") {
1116
+ if (path === "/pro/targets/changes" || path === "/pro/repositories/changes" || /^\/pro\/(?:history|activity|analytics)\//.test(path)) {
945
1117
  return "change_feeds";
946
1118
  }
947
1119
  if (path === "/pro/targets/export") {
@@ -1073,6 +1245,7 @@ function cloneApiError(error, requestId, coalesced = error instanceof BBRadarApi
1073
1245
  cached: error.cached,
1074
1246
  coalesced,
1075
1247
  cacheExpiresAt: error.cacheExpiresAt,
1248
+ cacheAllowed: error.cacheAllowed,
1076
1249
  cause: error
1077
1250
  });
1078
1251
  }