@forkpoint/agent-lighthouse-core 4.0.0 → 4.1.0

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/dist/index.mjs CHANGED
@@ -5,13 +5,14 @@ var ORIGIN_EVIDENCE_VERSION = "v1";
5
5
  var DEFAULT_ORIGIN_CACHE_TTL_MS = 60 * 60 * 1e3;
6
6
  var DEFAULT_ORIGIN_CACHE_MAX_ENTRIES = 256;
7
7
  var REQUEST_TIMEOUT_MS = 1e4;
8
- var SCAN_TIMEOUT_MS = 6e4;
8
+ var SCAN_TIMEOUT_MS = 18e4;
9
9
  var MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
10
10
  var MAX_CONCURRENT_REQUESTS = 10;
11
11
  var SCANNER_USER_AGENT = "AgentLighthouse/1.0 (+https://github.com/ForkPoint/agent-lighthouse)";
12
12
  var TAG_SKIPPED_PAGE_TYPE = "skipped:page-type";
13
13
  var TAG_SCAN_ERROR = "scan-error";
14
14
  var TAG_SKIPPED_NO_EVIDENCE = "skipped:no-evidence";
15
+ var TAG_SKIPPED_SCAN_BUDGET = "skipped:scan-budget";
15
16
  var CATEGORY_NAMES = {
16
17
  "access-crawl-control": "Access & Crawl Control",
17
18
  "content-extraction": "Content Extraction",
@@ -1100,7 +1101,14 @@ var ScanConditionsSchema = z.object({
1100
1101
  informativeCount: z.number().int().nonnegative(),
1101
1102
  gatedCount: z.number().int().nonnegative(),
1102
1103
  reasons: z.record(z.string(), z.number().int().nonnegative())
1103
- })
1104
+ }),
1105
+ // Optional: reports written before the budget existed carry no block.
1106
+ budget: z.object({
1107
+ limitMs: z.number().nonnegative(),
1108
+ elapsedMs: z.number().nonnegative(),
1109
+ exhausted: z.boolean(),
1110
+ skippedCount: z.number().int().nonnegative()
1111
+ }).optional()
1104
1112
  });
1105
1113
 
1106
1114
  // src/audit.ts
@@ -1311,16 +1319,19 @@ function calculateOverallScore(categories) {
1311
1319
  }
1312
1320
  var GATED_MASS_UNSCORED_THRESHOLD = 0.35;
1313
1321
  function gatedMassShare(checks2) {
1314
- let gated = 0;
1322
+ return skippedMassShare(checks2, TAG_SKIPPED_NO_EVIDENCE);
1323
+ }
1324
+ function skippedMassShare(checks2, tag2) {
1325
+ let skipped = 0;
1315
1326
  let total = 0;
1316
1327
  for (const check of checks2) {
1317
1328
  if (isInformative(check)) continue;
1318
1329
  const mass = check.weight ?? 0;
1319
1330
  if (mass <= 0) continue;
1320
1331
  total += mass;
1321
- if (check.tags?.includes(TAG_SKIPPED_NO_EVIDENCE)) gated += mass;
1332
+ if (check.tags?.includes(tag2)) skipped += mass;
1322
1333
  }
1323
- return total === 0 ? 0 : gated / total;
1334
+ return total === 0 ? 0 : skipped / total;
1324
1335
  }
1325
1336
 
1326
1337
  // src/audits/access-crawl-control/no-nofollow.ts
@@ -40630,6 +40641,7 @@ function outcomeOf(check) {
40630
40641
  if (tags.includes(TAG_SCAN_ERROR)) return "error";
40631
40642
  if (tags.includes(TAG_SKIPPED_PAGE_TYPE)) return "skipped";
40632
40643
  if (tags.includes(TAG_SKIPPED_NO_EVIDENCE)) return "gated";
40644
+ if (tags.includes(TAG_SKIPPED_SCAN_BUDGET)) return "budget";
40633
40645
  return "ran";
40634
40646
  }
40635
40647
  function traceFromCheck(check, durationMs) {
@@ -40782,9 +40794,28 @@ function planAudits(ctx, config, options = {}) {
40782
40794
  }
40783
40795
  return { runnable, skipped };
40784
40796
  }
40785
- async function runAudits(ctx, config, onEvent, plan, onTrace) {
40797
+ function formatBudget(ms) {
40798
+ return ms >= 1e3 ? `${Math.round(ms / 1e3)} s` : `${ms} ms`;
40799
+ }
40800
+ function budgetReason(signal) {
40801
+ const reason = signal.reason;
40802
+ if (reason instanceof Error && !(reason instanceof DOMException) && reason.message)
40803
+ return reason.message;
40804
+ return "The scan budget ran out.";
40805
+ }
40806
+ async function runAudits(ctx, config, onEvent, plan, onTrace, budget) {
40786
40807
  const { runnable, skipped } = plan ?? planAudits(ctx, config);
40787
40808
  const allChecks = [...skipped];
40809
+ const budgetStub = (reg2) => {
40810
+ const label3 = `${reg2.meta.id} ${reg2.meta.title}`;
40811
+ const stub = stubCheck(
40812
+ reg2.meta,
40813
+ TAG_SKIPPED_SCAN_BUDGET,
40814
+ `Not assessed: ${budgetReason(budget)} This audit had not started.`
40815
+ );
40816
+ if (typeof onEvent === "function") onEvent({ type: "unit:done", label: label3 });
40817
+ return stub;
40818
+ };
40788
40819
  const tracing = Boolean(onTrace) || logger.level === "debug";
40789
40820
  const trace = (check, durationMs) => {
40790
40821
  if (!tracing) return;
@@ -40795,9 +40826,22 @@ async function runAudits(ctx, config, onEvent, plan, onTrace) {
40795
40826
  for (const stub of skipped) trace(stub, 0);
40796
40827
  const batchSize = 20;
40797
40828
  for (let i = 0; i < runnable.length; i += batchSize) {
40829
+ if (budget?.aborted) {
40830
+ for (const { reg: reg2 } of runnable.slice(i)) {
40831
+ const stub = budgetStub(reg2);
40832
+ trace(stub, 0);
40833
+ allChecks.push(stub);
40834
+ }
40835
+ break;
40836
+ }
40798
40837
  const batch = runnable.slice(i, i + batchSize);
40799
40838
  const batchResults = await Promise.all(
40800
40839
  batch.map(async ({ reg: reg2, scopedPages, scoreDisplayMode }) => {
40840
+ if (budget?.aborted) {
40841
+ const stub = budgetStub(reg2);
40842
+ trace(stub, 0);
40843
+ return stub;
40844
+ }
40801
40845
  const label3 = `${reg2.meta.id} ${reg2.meta.title}`;
40802
40846
  const startedAt = tracing ? performance.now() : 0;
40803
40847
  const elapsed = () => tracing ? Math.round(performance.now() - startedAt) : 0;
@@ -40805,6 +40849,17 @@ async function runAudits(ctx, config, onEvent, plan, onTrace) {
40805
40849
  const instance = reg2.create();
40806
40850
  const scopedCtx = scopedPages && scopedPages !== ctx.pages ? { ...ctx, pages: scopedPages, cacheOwner: cacheOwner(ctx) } : ctx;
40807
40851
  const result = await instance.audit(scopedCtx);
40852
+ if (budget?.aborted) {
40853
+ const stub = stubCheck(
40854
+ reg2.meta,
40855
+ TAG_SKIPPED_SCAN_BUDGET,
40856
+ `Not assessed: ${budgetReason(budget)} This audit was still running.`
40857
+ );
40858
+ if (typeof onEvent === "function")
40859
+ onEvent({ type: "unit:done", label: label3 });
40860
+ trace(stub, elapsed());
40861
+ return stub;
40862
+ }
40808
40863
  const check = instance.toCheckResult(result, scoreDisplayMode);
40809
40864
  if (typeof onEvent === "function")
40810
40865
  onEvent({ type: "unit:done", label: label3 });
@@ -41134,8 +41189,22 @@ var A11Y_MAX_PAGES = Math.max(
41134
41189
  );
41135
41190
  var RATE_LIMIT_BACKOFF_MS = 5e3;
41136
41191
  var MAX_RETRY_AFTER_MS = 3e4;
41137
- async function fetchPageWithRetry(fetcher, url, signal) {
41138
- const first5 = await fetcher.fetch({ url, signal });
41192
+ function unsentResult(url, error) {
41193
+ return {
41194
+ url,
41195
+ finalUrl: url,
41196
+ status: 0,
41197
+ headers: {},
41198
+ body: "",
41199
+ ttfbMs: 0,
41200
+ totalMs: 0,
41201
+ contentType: "",
41202
+ contentLength: 0,
41203
+ error
41204
+ };
41205
+ }
41206
+ async function fetchPageWithRetry(fetcher, url, fetchSignal, cancel) {
41207
+ const first5 = await fetcher.fetch({ url, signal: fetchSignal });
41139
41208
  if (first5.status !== 429) return first5;
41140
41209
  const header = Number(first5.headers["retry-after"]);
41141
41210
  const waitMs = Number.isFinite(header) && header > 0 ? Math.min(header * 1e3, MAX_RETRY_AFTER_MS) : RATE_LIMIT_BACKOFF_MS;
@@ -41143,14 +41212,46 @@ async function fetchPageWithRetry(fetcher, url, signal) {
41143
41212
  { url, waitMs },
41144
41213
  `[orchestrator] Page answered 429; retrying once in ${waitMs}ms`
41145
41214
  );
41146
- await new Promise((resolve4) => setTimeout(resolve4, waitMs));
41147
- signal?.throwIfAborted();
41148
- return fetcher.fetch({ url, signal });
41215
+ if (!fetchSignal?.aborted)
41216
+ await new Promise((resolve4) => {
41217
+ const timer = setTimeout(done, waitMs);
41218
+ function done() {
41219
+ clearTimeout(timer);
41220
+ fetchSignal?.removeEventListener("abort", done);
41221
+ resolve4();
41222
+ }
41223
+ fetchSignal?.addEventListener("abort", done, { once: true });
41224
+ });
41225
+ cancel?.throwIfAborted();
41226
+ if (fetchSignal?.aborted) return first5;
41227
+ return fetcher.fetch({ url, signal: fetchSignal });
41149
41228
  }
41150
41229
  async function runScan(url, options) {
41230
+ const limitMs = options?.timeoutMs ?? SCAN_TIMEOUT_MS;
41231
+ if (!Number.isFinite(limitMs) || limitMs < 0)
41232
+ throw new RangeError(
41233
+ `timeoutMs must be a non-negative number of milliseconds, got ${String(options?.timeoutMs)}`
41234
+ );
41235
+ const budget = new AbortController();
41236
+ const timer = limitMs > 0 ? setTimeout(() => {
41237
+ const message = `The scan budget of ${formatBudget(limitMs)} ran out.`;
41238
+ logger.warn(`[orchestrator] ${message} ${splitCredentials(url).url}`);
41239
+ budget.abort(new Error(message));
41240
+ }, limitMs) : void 0;
41241
+ try {
41242
+ return await scanWithinBudget(url, options, {
41243
+ signal: budget.signal,
41244
+ limitMs
41245
+ });
41246
+ } finally {
41247
+ if (timer !== void 0) clearTimeout(timer);
41248
+ }
41249
+ }
41250
+ async function scanWithinBudget(url, options, budget) {
41151
41251
  const onEvent = options?.onEvent;
41152
41252
  const pageOverrides = options?.pages;
41153
41253
  const signal = options?.signal;
41254
+ const fetchSignal = signal ? AbortSignal.any([signal, budget.signal]) : budget.signal;
41154
41255
  const tracker = new ProgressTracker((event) => onEvent?.(event));
41155
41256
  const start = performance.now();
41156
41257
  const fetcher = createFetcher({
@@ -41248,7 +41349,7 @@ async function runScan(url, options) {
41248
41349
  tracker.unitDone(path);
41249
41350
  return Promise.resolve(prefetchedRobots);
41250
41351
  }
41251
- return fetcher.fetch({ url: `${baseUrl}${path}`, signal }).then((result) => {
41352
+ return fetcher.fetch({ url: `${baseUrl}${path}`, signal: fetchSignal }).then((result) => {
41252
41353
  tracker.unitDone(path);
41253
41354
  return result;
41254
41355
  });
@@ -41259,7 +41360,7 @@ async function runScan(url, options) {
41259
41360
  rootFiles[path] = rootResults[i];
41260
41361
  });
41261
41362
  const isHomepageScan = url === `${baseUrl}/` || url === baseUrl;
41262
- originHomepageResult = isHomepageScan ? void 0 : await fetchPageWithRetry(fetcher, `${baseUrl}/`, signal);
41363
+ originHomepageResult = isHomepageScan ? void 0 : await fetchPageWithRetry(fetcher, `${baseUrl}/`, fetchSignal, signal);
41263
41364
  originReadAt = (/* @__PURE__ */ new Date()).toISOString();
41264
41365
  originFresh = true;
41265
41366
  tracker.phaseDone();
@@ -41268,7 +41369,12 @@ async function runScan(url, options) {
41268
41369
  signal?.throwIfAborted();
41269
41370
  logger.debug("[orchestrator] Phase 2: Fetching page");
41270
41371
  tracker.phaseStart("fetch-pages", 1 + overrideUrls.length);
41271
- const pageResult = await fetchPageWithRetry(fetcher, url, signal);
41372
+ const pageResult = await fetchPageWithRetry(
41373
+ fetcher,
41374
+ url,
41375
+ fetchSignal,
41376
+ signal
41377
+ );
41272
41378
  tracker.unitDone(displayUrl);
41273
41379
  if (!originHomepageResult && (url === `${baseUrl}/` || url === baseUrl)) {
41274
41380
  originHomepageResult = pageResult;
@@ -41284,7 +41390,7 @@ async function runScan(url, options) {
41284
41390
  }
41285
41391
  const extraResults = await Promise.all(
41286
41392
  overrideUrls.map(
41287
- (pageUrl) => fetcher.fetch({ url: pageUrl, signal }).then((result) => {
41393
+ (pageUrl) => fetcher.fetch({ url: pageUrl, signal: fetchSignal }).then((result) => {
41288
41394
  tracker.unitDone(pageUrl);
41289
41395
  return result;
41290
41396
  })
@@ -41362,7 +41468,11 @@ async function runScan(url, options) {
41362
41468
  pages,
41363
41469
  domain,
41364
41470
  baseUrl,
41365
- fetch: (options2) => fetcher.fetch({ ...options2, signal }),
41471
+ // Once the budget is gone no request leaves: the audit reads an error
41472
+ // result at once, the same shape a refused connection gives it.
41473
+ fetch: (options2) => budget.signal.aborted ? Promise.resolve(
41474
+ unsentResult(options2.url, budgetReason(budget.signal))
41475
+ ) : fetcher.fetch({ ...options2, signal: fetchSignal }),
41366
41476
  wafProtection: wafProtection ?? void 0,
41367
41477
  evidence,
41368
41478
  originEvidence: {
@@ -41393,9 +41503,12 @@ async function runScan(url, options) {
41393
41503
  else tracker.unitFail(event.label, event.error);
41394
41504
  },
41395
41505
  auditPlan,
41396
- options?.onAuditTrace
41506
+ options?.onAuditTrace,
41507
+ budget.signal
41397
41508
  );
41398
41509
  tracker.phaseDone();
41510
+ signal?.throwIfAborted();
41511
+ const budgetExhausted = budget.signal.aborted;
41399
41512
  logger.debug("[orchestrator] Phase 3 complete: Audits finished");
41400
41513
  tracker.phaseStart("report", 1);
41401
41514
  logger.debug("[orchestrator] Phase 4: Building final report");
@@ -41421,7 +41534,11 @@ async function runScan(url, options) {
41421
41534
  );
41422
41535
  const gatedShare = gatedMassShare(allChecks);
41423
41536
  const escalated = gatedShare > GATED_MASS_UNSCORED_THRESHOLD;
41424
- const unscoredReason = !evidence.judgeable ? unjudgeableReason(evidence) : escalated ? `The scan could not feed ${Math.round(gatedShare * 100)}% of the registry's evidence mass, so what remains is not a reading of this site.` : void 0;
41537
+ const budgetShare = skippedMassShare(allChecks, TAG_SKIPPED_SCAN_BUDGET);
41538
+ const unassessedShare = gatedShare + budgetShare;
41539
+ const cut = !escalated && budgetShare > 0 && unassessedShare > GATED_MASS_UNSCORED_THRESHOLD;
41540
+ const budgetSentence = budgetExhausted ? budgetReason(budget.signal) : "";
41541
+ const unscoredReason = !evidence.judgeable ? budgetExhausted ? `${budgetSentence} ${unjudgeableReason(evidence)}` : unjudgeableReason(evidence) : escalated ? `The scan could not feed ${Math.round(gatedShare * 100)}% of the registry's evidence mass, so what remains is not a reading of this site.` : cut ? `${budgetSentence} ${Math.round(unassessedShare * 100)}% of the registry's evidence mass was never assessed, so what remains is not a reading of this site.` : void 0;
41425
41542
  const scored = unscoredReason === void 0;
41426
41543
  const allScoredAudits = Object.values(config.audits).flat().filter((a) => a.meta.tier === "scored");
41427
41544
  let registryMass = 0;
@@ -41456,6 +41573,8 @@ async function runScan(url, options) {
41456
41573
  unscoredReasons["skipped-no-evidence"] = (unscoredReasons["skipped-no-evidence"] ?? 0) + 1;
41457
41574
  } else if (check.tags?.includes(TAG_SKIPPED_PAGE_TYPE)) {
41458
41575
  unscoredReasons["skipped-page-type"] = (unscoredReasons["skipped-page-type"] ?? 0) + 1;
41576
+ } else if (check.tags?.includes(TAG_SKIPPED_SCAN_BUDGET)) {
41577
+ unscoredReasons["skipped-scan-budget"] = (unscoredReasons["skipped-scan-budget"] ?? 0) + 1;
41459
41578
  } else {
41460
41579
  unscoredReasons["not-applicable"] = (unscoredReasons["not-applicable"] ?? 0) + 1;
41461
41580
  }
@@ -41493,6 +41612,17 @@ async function runScan(url, options) {
41493
41612
  informativeCount,
41494
41613
  gatedCount,
41495
41614
  reasons: unscoredReasons
41615
+ },
41616
+ // Counted over every check, advisory ones included: the reasons map
41617
+ // files an informative stub under `informative`, but the budget cut it
41618
+ // all the same.
41619
+ budget: {
41620
+ limitMs: budget.limitMs,
41621
+ elapsedMs: durationMs,
41622
+ exhausted: budgetExhausted,
41623
+ skippedCount: allChecks.filter(
41624
+ (c) => c.tags?.includes(TAG_SKIPPED_SCAN_BUDGET)
41625
+ ).length
41496
41626
  }
41497
41627
  };
41498
41628
  const report = {
@@ -41728,9 +41858,11 @@ export {
41728
41858
  TAG_SCAN_ERROR,
41729
41859
  TAG_SKIPPED_NO_EVIDENCE,
41730
41860
  TAG_SKIPPED_PAGE_TYPE,
41861
+ TAG_SKIPPED_SCAN_BUDGET,
41731
41862
  allEvidenceMet,
41732
41863
  allJsonLdNodes,
41733
41864
  boundedDispatcher,
41865
+ budgetReason,
41734
41866
  buildCategoryResult,
41735
41867
  buildScanEvidence,
41736
41868
  calculateCategoryScore,
@@ -41762,6 +41894,7 @@ export {
41762
41894
  extractStylesheetUrls,
41763
41895
  filterConfig,
41764
41896
  flattenJsonLd,
41897
+ formatBudget,
41765
41898
  formatTrace,
41766
41899
  getMainContentText,
41767
41900
  getPreset,
@@ -41799,6 +41932,7 @@ export {
41799
41932
  runScan,
41800
41933
  sampleEntries,
41801
41934
  shouldBypassOriginCache,
41935
+ skippedMassShare,
41802
41936
  stripBom,
41803
41937
  topLevelJsonLd,
41804
41938
  traceFromCheck,