@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.d.mts +82 -4
- package/dist/index.d.ts +82 -4
- package/dist/index.js +156 -18
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +152 -18
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
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(
|
|
1332
|
+
if (check.tags?.includes(tag2)) skipped += mass;
|
|
1322
1333
|
}
|
|
1323
|
-
return total === 0 ? 0 :
|
|
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
|
-
|
|
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
|
-
|
|
41138
|
-
|
|
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
|
-
|
|
41147
|
-
|
|
41148
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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,
|