spiderlint 0.0.0 → 0.143.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/LICENSE +235 -0
- package/README.md +244 -0
- package/bin/spiderlint.js +11 -0
- package/dist/agent.js +46 -0
- package/dist/cache/extractors.js +37 -0
- package/dist/cache/http.js +99 -0
- package/dist/cache/index.js +101 -0
- package/dist/cache/purge.js +63 -0
- package/dist/cache/status.js +52 -0
- package/dist/cli.js +602 -0
- package/dist/color.js +10 -0
- package/dist/config/environment.js +88 -0
- package/dist/config/index.js +104 -0
- package/dist/config/policy.js +170 -0
- package/dist/config/schema.js +127 -0
- package/dist/crawl/body.js +92 -0
- package/dist/crawl/browser.js +498 -0
- package/dist/crawl/crawl.js +58 -0
- package/dist/crawl/diversity.js +153 -0
- package/dist/crawl/dns.js +194 -0
- package/dist/crawl/fetch.js +48 -0
- package/dist/crawl/frontier.js +370 -0
- package/dist/crawl/guard.js +69 -0
- package/dist/crawl/http.js +171 -0
- package/dist/crawl/links.js +89 -0
- package/dist/crawl/log.js +27 -0
- package/dist/crawl/network.js +88 -0
- package/dist/crawl/probe.js +107 -0
- package/dist/crawl/profile.js +33 -0
- package/dist/crawl/rdap.js +86 -0
- package/dist/crawl/resolve.js +132 -0
- package/dist/crawl/resources.js +233 -0
- package/dist/crawl/robots.js +100 -0
- package/dist/crawl/route.js +92 -0
- package/dist/crawl/scope.js +32 -0
- package/dist/crawl/scratch.js +15 -0
- package/dist/crawl/sitemap.js +187 -0
- package/dist/crawl/svcb.js +59 -0
- package/dist/crawl/tls-hello.js +297 -0
- package/dist/crawl/tls-probe.js +71 -0
- package/dist/facts/browser.js +138 -0
- package/dist/facts/byline.js +17 -0
- package/dist/facts/co2.js +27 -0
- package/dist/facts/csp.js +71 -0
- package/dist/facts/export.js +79 -0
- package/dist/facts/flatten.js +33 -0
- package/dist/facts/graph.js +73 -0
- package/dist/facts/headers.js +330 -0
- package/dist/facts/html.js +230 -0
- package/dist/facts/labels.js +89 -0
- package/dist/facts/language.js +32 -0
- package/dist/facts/parity.js +38 -0
- package/dist/facts/read-note.js +11 -0
- package/dist/facts/resources.js +46 -0
- package/dist/facts/robots.js +13 -0
- package/dist/facts/sites.js +140 -0
- package/dist/facts/transport.js +200 -0
- package/dist/facts/types.js +4 -0
- package/dist/facts/vendors.js +76 -0
- package/dist/fold/index.js +94 -0
- package/dist/groups/assign.js +45 -0
- package/dist/groups/sample.js +60 -0
- package/dist/i18n.js +92 -0
- package/dist/index.js +607 -0
- package/dist/interrupt.js +37 -0
- package/dist/logger.js +95 -0
- package/dist/plugins/axe.js +104 -0
- package/dist/plugins/carbon-txt.js +96 -0
- package/dist/plugins/cookies-registry.js +25 -0
- package/dist/plugins/cookies.js +168 -0
- package/dist/plugins/css.js +328 -0
- package/dist/plugins/dns.js +864 -0
- package/dist/plugins/feed-content.js +132 -0
- package/dist/plugins/feed-model.js +282 -0
- package/dist/plugins/feeds.js +836 -0
- package/dist/plugins/html-validate.js +191 -0
- package/dist/plugins/htmlhint.js +95 -0
- package/dist/plugins/icons.js +302 -0
- package/dist/plugins/images.js +269 -0
- package/dist/plugins/index.js +286 -0
- package/dist/plugins/keyboard.js +194 -0
- package/dist/plugins/lighthouse.js +110 -0
- package/dist/plugins/link-text.js +73 -0
- package/dist/plugins/list.js +22 -0
- package/dist/plugins/live.js +254 -0
- package/dist/plugins/mail.js +985 -0
- package/dist/plugins/manifest.js +97 -0
- package/dist/plugins/markup.js +98 -0
- package/dist/plugins/messages.js +25 -0
- package/dist/plugins/network.js +192 -0
- package/dist/plugins/origin.js +488 -0
- package/dist/plugins/profile-links.js +102 -0
- package/dist/plugins/schema-registry.js +101 -0
- package/dist/plugins/sshfp.js +159 -0
- package/dist/plugins/stack.js +161 -0
- package/dist/plugins/structured-data.js +358 -0
- package/dist/plugins/tls-probe.js +298 -0
- package/dist/plugins/tls-registry.js +418 -0
- package/dist/plugins/tls-scan.js +280 -0
- package/dist/plugins/trackers-registry.js +27 -0
- package/dist/plugins/trackers.js +56 -0
- package/dist/plugins/types.js +7 -0
- package/dist/plugins/visit.js +50 -0
- package/dist/plugins/well-known-registry.js +111 -0
- package/dist/plugins/well-known.js +732 -0
- package/dist/progress.js +162 -0
- package/dist/report/agent.js +122 -0
- package/dist/report/checkstyle.js +22 -0
- package/dist/report/csv.js +18 -0
- package/dist/report/html.js +266 -0
- package/dist/report/human.js +220 -0
- package/dist/report/index.js +16 -0
- package/dist/report/json.js +8 -0
- package/dist/report/printable.js +29 -0
- package/dist/report/rating.js +26 -0
- package/dist/report/sarif.js +100 -0
- package/dist/report/stats.js +46 -0
- package/dist/rules/builtin.js +465 -0
- package/dist/rules/caching.js +103 -0
- package/dist/rules/catalog.js +139 -0
- package/dist/rules/clock.js +48 -0
- package/dist/rules/declarative.js +234 -0
- package/dist/rules/deprecated.js +45 -0
- package/dist/rules/disclosure.js +99 -0
- package/dist/rules/fix.js +21 -0
- package/dist/rules/i18n.js +131 -0
- package/dist/rules/insights.js +100 -0
- package/dist/rules/length.js +52 -0
- package/dist/rules/message.js +48 -0
- package/dist/rules/relations.js +88 -0
- package/dist/rules/robots.js +75 -0
- package/dist/rules/rulesets.js +134 -0
- package/dist/rules/run.js +75 -0
- package/dist/rules/score.js +61 -0
- package/dist/rules/types.js +7 -0
- package/dist/rules/url.js +90 -0
- package/dist/server/analytics.js +49 -0
- package/dist/server/api.js +134 -0
- package/dist/server/clients.js +54 -0
- package/dist/server/clock.js +33 -0
- package/dist/server/compress.js +58 -0
- package/dist/server/health.js +14 -0
- package/dist/server/jobs.js +82 -0
- package/dist/server/main.js +83 -0
- package/dist/server/page.js +161 -0
- package/dist/server/policy.js +145 -0
- package/dist/server/providers.js +60 -0
- package/dist/server/queue.js +50 -0
- package/dist/server/scan.js +39 -0
- package/dist/server/settings.js +197 -0
- package/dist/server/web.js +353 -0
- package/dist/server/worker.js +129 -0
- package/dist/store/disk.js +215 -0
- package/dist/store/memory.js +18 -0
- package/dist/telemetry-environment.js +9 -0
- package/dist/telemetry.js +104 -0
- package/locales/es/LC_MESSAGES/messages.po +1864 -0
- package/locales/uk/LC_MESSAGES/messages.po +1864 -0
- package/package.json +134 -2
- package/presets/browser.yaml +26 -0
- package/presets/graph.yaml +35 -0
- package/presets/i18n.yaml +18 -0
- package/presets/insights.yaml +12 -0
- package/presets/links.yaml +22 -0
- package/presets/parity.yaml +59 -0
- package/presets/performance.yaml +271 -0
- package/presets/recommended.yaml +50 -0
- package/presets/redirects.yaml +23 -0
- package/presets/resources.yaml +29 -0
- package/presets/robots.yaml +13 -0
- package/presets/security-headers.yaml +257 -0
- package/presets/seo.yaml +380 -0
- package/presets/sitemap.yaml +34 -0
- package/presets/suggestions.yaml +97 -0
- package/presets/sustainability.yaml +16 -0
- package/presets/tls.yaml +72 -0
- package/presets/vendor.yaml +14 -0
- package/presets/web-comprehensive.yaml +17 -0
- package/presets/web-quick.yaml +15 -0
- package/src/server/apple-touch-icon.png +0 -0
- package/src/server/logo-36.png +0 -0
- package/src/server/logo-72.png +0 -0
- package/src/server/logo.png +0 -0
- package/vendors/dns.yaml +21 -0
- package/vendors/paths.yaml +55 -0
- package/vendors/stack.yaml +57 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { parseResolver } from "../crawl/dns.js";
|
|
5
|
+
import { parsePin } from "../crawl/resolve.js";
|
|
6
|
+
import { normalizeVia } from "../agent.js";
|
|
7
|
+
import { ConfigError, PROFILES, ROLES, originOf, proxyOf } from "./index.js";
|
|
8
|
+
export const FETCH_MODES = ["auto", "http", "browser", "adaptive"];
|
|
9
|
+
export const BROWSERS = ["chromium", "firefox", "webkit"];
|
|
10
|
+
export const SCOPES = ["origin", "host", "domain"];
|
|
11
|
+
export const FAIL_ONS = ["error", "warning", "info", "never"];
|
|
12
|
+
const CACHE_MODES = ["use", "off", "refresh", "offline"];
|
|
13
|
+
function isTruthy(name, raw) {
|
|
14
|
+
const value = raw.trim().toLowerCase();
|
|
15
|
+
if (["1", "true", "yes", "y", "on"].includes(value))
|
|
16
|
+
return true;
|
|
17
|
+
if (["0", "false", "no", "n", "off"].includes(value))
|
|
18
|
+
return false;
|
|
19
|
+
throw new ConfigError(`${name}: invalid value ${raw} (expected a boolean)`);
|
|
20
|
+
}
|
|
21
|
+
function list(raw) {
|
|
22
|
+
return raw.split(/[\s,]+/).filter((entry) => entry.length > 0);
|
|
23
|
+
}
|
|
24
|
+
export function parseInteger(name, raw) {
|
|
25
|
+
const value = Number(raw);
|
|
26
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
27
|
+
throw new ConfigError(`${name}: invalid value ${raw} (expected a non-negative integer)`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
export function pick(name, raw, valid) {
|
|
31
|
+
if (!valid.includes(raw))
|
|
32
|
+
throw new ConfigError(`${name}: invalid value ${raw} (expected: ${valid.join("|")})`);
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
// A level or `never`, or the lowest score, 0.1 to 9.9, that fails the run.
|
|
36
|
+
export function parseFailOn(name, raw) {
|
|
37
|
+
const score = Number(raw);
|
|
38
|
+
if (raw.trim() === "" || Number.isNaN(score))
|
|
39
|
+
return pick(name, raw, FAIL_ONS);
|
|
40
|
+
if (score < 0.1 || score > 9.9)
|
|
41
|
+
throw new ConfigError(`${name}: invalid score ${raw} (expected 0.1 to 9.9)`);
|
|
42
|
+
return score;
|
|
43
|
+
}
|
|
44
|
+
// One severity bucket of rule IDs, as `SPIDERLINT_OVERRIDE_ERROR` etc. carry it.
|
|
45
|
+
function overrideBucket(raw, severity) {
|
|
46
|
+
return raw === undefined ? {} : Object.fromEntries(list(raw).map((id) => [id, severity]));
|
|
47
|
+
}
|
|
48
|
+
// SPIDERLINT_* mirrors each flag (AGENTS.md ## Configuration, ## Rules); only set variables apply.
|
|
49
|
+
export function environmentSettings(environment) {
|
|
50
|
+
const overrideVariables = [environment.SPIDERLINT_OVERRIDE_ERROR, environment.SPIDERLINT_OVERRIDE_WARNING, environment.SPIDERLINT_OVERRIDE_INFO, environment.SPIDERLINT_OVERRIDE_HINT];
|
|
51
|
+
return {
|
|
52
|
+
...(environment.SPIDERLINT_TARGETS !== undefined && { seeds: list(environment.SPIDERLINT_TARGETS) }),
|
|
53
|
+
...(environment.SPIDERLINT_CANONICAL_ORIGIN !== undefined && { canonicalOrigin: originOf("SPIDERLINT_CANONICAL_ORIGIN", environment.SPIDERLINT_CANONICAL_ORIGIN) }),
|
|
54
|
+
...(environment.SPIDERLINT_ROLE !== undefined && { role: pick("SPIDERLINT_ROLE", environment.SPIDERLINT_ROLE, ROLES) }),
|
|
55
|
+
...(environment.SPIDERLINT_RESOLVER !== undefined && { resolver: parseResolver(environment.SPIDERLINT_RESOLVER) }),
|
|
56
|
+
...(environment.SPIDERLINT_RESOLVE !== undefined && { resolve: list(environment.SPIDERLINT_RESOLVE).map((pin) => parsePin(pin)) }),
|
|
57
|
+
...(environment.SPIDERLINT_FETCH !== undefined && { fetch: pick("SPIDERLINT_FETCH", environment.SPIDERLINT_FETCH, FETCH_MODES) }),
|
|
58
|
+
...(environment.SPIDERLINT_BROWSER !== undefined && { browser: pick("SPIDERLINT_BROWSER", environment.SPIDERLINT_BROWSER, BROWSERS) }),
|
|
59
|
+
...(environment.SPIDERLINT_BROWSER_INSTALL !== undefined && { browserInstall: isTruthy("SPIDERLINT_BROWSER_INSTALL", environment.SPIDERLINT_BROWSER_INSTALL) }),
|
|
60
|
+
...(environment.SPIDERLINT_SCOPE !== undefined && { scope: pick("SPIDERLINT_SCOPE", environment.SPIDERLINT_SCOPE, SCOPES) }),
|
|
61
|
+
...(environment.SPIDERLINT_CONCURRENCY !== undefined && { concurrency: parseInteger("SPIDERLINT_CONCURRENCY", environment.SPIDERLINT_CONCURRENCY) }),
|
|
62
|
+
...(environment.SPIDERLINT_RATE !== undefined && { rate: parseInteger("SPIDERLINT_RATE", environment.SPIDERLINT_RATE) }),
|
|
63
|
+
...(environment.SPIDERLINT_TIMEOUT !== undefined && { timeout: parseInteger("SPIDERLINT_TIMEOUT", environment.SPIDERLINT_TIMEOUT) }),
|
|
64
|
+
...(environment.SPIDERLINT_PROFILE !== undefined && { profile: pick("SPIDERLINT_PROFILE", environment.SPIDERLINT_PROFILE, Object.keys(PROFILES)) }),
|
|
65
|
+
...(environment.SPIDERLINT_PROXY !== undefined && { proxy: proxyOf("SPIDERLINT_PROXY", environment.SPIDERLINT_PROXY) }),
|
|
66
|
+
...(environment.SPIDERLINT_VIA !== undefined && { via: normalizeVia(environment.SPIDERLINT_VIA) }),
|
|
67
|
+
...(environment.SPIDERLINT_MAX_PAGES !== undefined && { maxPages: parseInteger("SPIDERLINT_MAX_PAGES", environment.SPIDERLINT_MAX_PAGES) }),
|
|
68
|
+
...(environment.SPIDERLINT_MAX_DEPTH !== undefined && { maxDepth: parseInteger("SPIDERLINT_MAX_DEPTH", environment.SPIDERLINT_MAX_DEPTH) }),
|
|
69
|
+
...(environment.SPIDERLINT_MAX_BODY_SIZE !== undefined && { maxBodySize: parseInteger("SPIDERLINT_MAX_BODY_SIZE", environment.SPIDERLINT_MAX_BODY_SIZE) }),
|
|
70
|
+
...(environment.SPIDERLINT_KEEPALIVE !== undefined && { keepalive: isTruthy("SPIDERLINT_KEEPALIVE", environment.SPIDERLINT_KEEPALIVE) }),
|
|
71
|
+
...(environment.SPIDERLINT_ALLOW_PRIVATE !== undefined && { allowPrivate: isTruthy("SPIDERLINT_ALLOW_PRIVATE", environment.SPIDERLINT_ALLOW_PRIVATE) }),
|
|
72
|
+
...(environment.SPIDERLINT_RESOURCES !== undefined && { fetchResources: isTruthy("SPIDERLINT_RESOURCES", environment.SPIDERLINT_RESOURCES) }),
|
|
73
|
+
...(environment.SPIDERLINT_RULES !== undefined && { rules: list(environment.SPIDERLINT_RULES) }),
|
|
74
|
+
...(environment.SPIDERLINT_SOURCES !== undefined && { sources: list(environment.SPIDERLINT_SOURCES) }),
|
|
75
|
+
...(environment.SPIDERLINT_INCLUDE_URLS !== undefined && { includeUrls: list(environment.SPIDERLINT_INCLUDE_URLS) }),
|
|
76
|
+
...(environment.SPIDERLINT_EXCLUDE_URLS !== undefined && { excludeUrls: list(environment.SPIDERLINT_EXCLUDE_URLS) }),
|
|
77
|
+
...(environment.SPIDERLINT_ROBOTS !== undefined && { robots: isTruthy("SPIDERLINT_ROBOTS", environment.SPIDERLINT_ROBOTS) }),
|
|
78
|
+
...(environment.SPIDERLINT_SITEMAP !== undefined && { sitemap: isTruthy("SPIDERLINT_SITEMAP", environment.SPIDERLINT_SITEMAP) }),
|
|
79
|
+
...(environment.SPIDERLINT_FOLD !== undefined && { fold: isTruthy("SPIDERLINT_FOLD", environment.SPIDERLINT_FOLD) && { threshold: 0.8, min: 3 } }),
|
|
80
|
+
...(environment.SPIDERLINT_FAIL_ON !== undefined && { failOn: parseFailOn("SPIDERLINT_FAIL_ON", environment.SPIDERLINT_FAIL_ON) }),
|
|
81
|
+
...(environment.SPIDERLINT_FORMAT !== undefined && { format: environment.SPIDERLINT_FORMAT }),
|
|
82
|
+
...(environment.SPIDERLINT_CACHE !== undefined && { cacheMode: pick("SPIDERLINT_CACHE", environment.SPIDERLINT_CACHE, CACHE_MODES) }),
|
|
83
|
+
...(environment.SPIDERLINT_EXCLUDE_RULES !== undefined && { excludeRules: list(environment.SPIDERLINT_EXCLUDE_RULES) }),
|
|
84
|
+
...(overrideVariables.some((value) => value !== undefined) && {
|
|
85
|
+
overrides: { ...overrideBucket(environment.SPIDERLINT_OVERRIDE_ERROR, "error"), ...overrideBucket(environment.SPIDERLINT_OVERRIDE_WARNING, "warning"), ...overrideBucket(environment.SPIDERLINT_OVERRIDE_INFO, "info"), ...overrideBucket(environment.SPIDERLINT_OVERRIDE_HINT, "hint") },
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
export const ROLES = ["production", "staging", "development"];
|
|
6
|
+
export function defaults() {
|
|
7
|
+
return {
|
|
8
|
+
seeds: [],
|
|
9
|
+
role: "production",
|
|
10
|
+
fetch: "auto",
|
|
11
|
+
browser: "chromium",
|
|
12
|
+
browserInstall: true,
|
|
13
|
+
scope: "origin",
|
|
14
|
+
concurrency: 0,
|
|
15
|
+
rate: 0,
|
|
16
|
+
timeout: 60,
|
|
17
|
+
crawlDeadline: 0,
|
|
18
|
+
profile: "",
|
|
19
|
+
proxy: "",
|
|
20
|
+
via: "",
|
|
21
|
+
maxPages: 0,
|
|
22
|
+
maxDepth: 0,
|
|
23
|
+
maxBodySize: 10_000_000,
|
|
24
|
+
keepalive: true,
|
|
25
|
+
fetchResources: true,
|
|
26
|
+
maxResourcesPerPage: 200,
|
|
27
|
+
linkExclude: [],
|
|
28
|
+
includeUrls: [],
|
|
29
|
+
excludeUrls: [],
|
|
30
|
+
vendorPaths: true,
|
|
31
|
+
diversify: true,
|
|
32
|
+
robots: true,
|
|
33
|
+
sitemap: true,
|
|
34
|
+
fold: { threshold: 0.8, min: 3 },
|
|
35
|
+
failOn: "error",
|
|
36
|
+
format: "human",
|
|
37
|
+
excludeRules: [],
|
|
38
|
+
documentTypes: [],
|
|
39
|
+
denyRules: [],
|
|
40
|
+
overrides: {},
|
|
41
|
+
groups: {},
|
|
42
|
+
rulesets: {},
|
|
43
|
+
plugins: [],
|
|
44
|
+
sources: [],
|
|
45
|
+
follow: true,
|
|
46
|
+
cacheMode: "use",
|
|
47
|
+
cacheTtl: {},
|
|
48
|
+
cacheFailureTtl: 600,
|
|
49
|
+
allowPrivate: true,
|
|
50
|
+
resolver: "system",
|
|
51
|
+
resolve: [],
|
|
52
|
+
pluginSettings: {},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Settings a profile lays over the defaults; `adaptive: false` resolves an adaptive group as `auto`.
|
|
56
|
+
export const PROFILES = {
|
|
57
|
+
tor: { concurrency: 4, timeout: 240, adaptive: false, proxy: "socks5h://127.0.0.1:9050" },
|
|
58
|
+
i2p: { concurrency: 4, timeout: 240, adaptive: false, proxy: "http://127.0.0.1:4444" },
|
|
59
|
+
};
|
|
60
|
+
// Thrown for anything that maps to exit code 2.
|
|
61
|
+
export class ConfigError extends Error {
|
|
62
|
+
}
|
|
63
|
+
// The origin of an absolute http(s) URL; anything else names `name` in the error.
|
|
64
|
+
export function originOf(name, raw) {
|
|
65
|
+
const url = URL.canParse(raw) ? new URL(raw) : undefined;
|
|
66
|
+
if (!url || !["http:", "https:"].includes(url.protocol))
|
|
67
|
+
throw new ConfigError(`${name}: invalid value ${raw} (expected an absolute http or https URL)`);
|
|
68
|
+
return url.origin;
|
|
69
|
+
}
|
|
70
|
+
// A seed with https:// prepended when it names no scheme, so example.com and localhost:8080 stay domains.
|
|
71
|
+
export function seedOf(raw) {
|
|
72
|
+
return raw.includes("://") ? raw : `https://${raw}`;
|
|
73
|
+
}
|
|
74
|
+
// A proxy URL of a scheme every client can use, or empty for none; anything else names `name` in the error.
|
|
75
|
+
export function proxyOf(name, raw) {
|
|
76
|
+
const url = URL.canParse(raw) ? new URL(raw) : undefined;
|
|
77
|
+
const isValid = raw === "" || ["http:", "https:", "socks:", "socks4:", "socks4a:", "socks5:", "socks5h:"].includes(url?.protocol ?? "");
|
|
78
|
+
if (!isValid)
|
|
79
|
+
throw new ConfigError(`${name}: invalid value ${raw} (expected an http, https, socks4, socks4a, socks5 or socks5h URL)`);
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
82
|
+
// Applies every defined key of `patch` over `base`; undefined keys leave `base` untouched.
|
|
83
|
+
// This is the merge step of the flags > env > file > defaults precedence ladder.
|
|
84
|
+
export function overlay(base, patch) {
|
|
85
|
+
const merged = { ...base };
|
|
86
|
+
for (const key of Object.keys(patch)) {
|
|
87
|
+
if (patch[key] !== undefined)
|
|
88
|
+
merged[key] = patch[key];
|
|
89
|
+
}
|
|
90
|
+
return merged;
|
|
91
|
+
}
|
|
92
|
+
// `patches` over the defaults, lowest first, with the profile they name laid in between.
|
|
93
|
+
export function layered(patches) {
|
|
94
|
+
const merge = (base) => {
|
|
95
|
+
let merged = base;
|
|
96
|
+
for (const patch of patches)
|
|
97
|
+
merged = overlay(merged, patch);
|
|
98
|
+
return merged;
|
|
99
|
+
};
|
|
100
|
+
const { profile } = merge(defaults());
|
|
101
|
+
const { adaptive, ...bundle } = PROFILES[profile] ?? {};
|
|
102
|
+
log.debug({ profile, bundle, adaptive }, "profile applied");
|
|
103
|
+
return merge(overlay(defaults(), bundle));
|
|
104
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { parse as parseYaml } from "yaml";
|
|
7
|
+
import { normalizeVia } from "../agent.js";
|
|
8
|
+
import { parseDuration } from "../cache/index.js";
|
|
9
|
+
import { parseResolver } from "../crawl/dns.js";
|
|
10
|
+
import { parsePin } from "../crawl/resolve.js";
|
|
11
|
+
import { log } from "../logger.js";
|
|
12
|
+
import { ConfigError, originOf, proxyOf } from "./index.js";
|
|
13
|
+
import { CORE_KEYS, validateSubtree } from "./schema.js";
|
|
14
|
+
const SUBTREE = "org.spiderlint";
|
|
15
|
+
const LEVELS = ["error", "warning", "info", "hint"];
|
|
16
|
+
const DISCOVER_NAMES = ["projectfile.yaml", "projectfile.toml", "projectfile.json"];
|
|
17
|
+
// [subtree key, Settings field] — kebab-case document keys to the camelCase Config shape.
|
|
18
|
+
// `override` is excluded: its three severity buckets flatten into one field, below.
|
|
19
|
+
const KEYS = [
|
|
20
|
+
["targets", "seeds"],
|
|
21
|
+
["role", "role"],
|
|
22
|
+
["fetch", "fetch"],
|
|
23
|
+
["browser", "browser"],
|
|
24
|
+
["browser-install", "browserInstall"],
|
|
25
|
+
["scope", "scope"],
|
|
26
|
+
["concurrency", "concurrency"],
|
|
27
|
+
["rate", "rate"],
|
|
28
|
+
["timeout", "timeout"],
|
|
29
|
+
["profile", "profile"],
|
|
30
|
+
["max-pages", "maxPages"],
|
|
31
|
+
["max-depth", "maxDepth"],
|
|
32
|
+
["max-body-size", "maxBodySize"],
|
|
33
|
+
["keepalive", "keepalive"],
|
|
34
|
+
["include-urls", "includeUrls"],
|
|
35
|
+
["exclude-urls", "excludeUrls"],
|
|
36
|
+
["vendor-paths", "vendorPaths"],
|
|
37
|
+
["diversify", "diversify"],
|
|
38
|
+
["robots", "robots"],
|
|
39
|
+
["sitemap", "sitemap"],
|
|
40
|
+
["fold", "fold"],
|
|
41
|
+
["fail-on", "failOn"],
|
|
42
|
+
["format", "format"],
|
|
43
|
+
["exclude-rules", "excludeRules"],
|
|
44
|
+
["document-types", "documentTypes"],
|
|
45
|
+
["rules", "rules"],
|
|
46
|
+
["groups", "groups"],
|
|
47
|
+
["rulesets", "rulesets"],
|
|
48
|
+
["plugins", "plugins"],
|
|
49
|
+
["sources", "sources"],
|
|
50
|
+
["via", "via"],
|
|
51
|
+
["allow-private", "allowPrivate"],
|
|
52
|
+
];
|
|
53
|
+
// error, then warning, then info, then hint — a rule ID named in a later bucket wins (AGENTS.md ## Rules).
|
|
54
|
+
function flattenOverride(bucket) {
|
|
55
|
+
const overrides = {};
|
|
56
|
+
for (const level of LEVELS) {
|
|
57
|
+
const ids = bucket[level] ?? [];
|
|
58
|
+
for (const id of ids)
|
|
59
|
+
overrides[id] = level;
|
|
60
|
+
}
|
|
61
|
+
return overrides;
|
|
62
|
+
}
|
|
63
|
+
// A duration to seconds; a malformed one names its path.
|
|
64
|
+
function seconds(field, raw) {
|
|
65
|
+
const parsed = parseDuration(raw);
|
|
66
|
+
if (parsed === undefined)
|
|
67
|
+
throw new ConfigError(`org.spiderlint/cache/${field}: invalid duration ${String(raw)} (expected seconds or 45s, 30m, 24h, 7d)`);
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
// `cache.<bucket>.ttl` durations to seconds.
|
|
71
|
+
function cacheTtl(cache) {
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [bucket, { ttl }] of Object.entries(cache)) {
|
|
74
|
+
if (ttl !== undefined)
|
|
75
|
+
out[bucket] = seconds(`${bucket}/ttl`, ttl);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
// One subtree’s keys as the camelCase Settings shape.
|
|
80
|
+
export function fromSubtree(subtree) {
|
|
81
|
+
const settings = {};
|
|
82
|
+
for (const [key, field] of KEYS) {
|
|
83
|
+
if (subtree[key] !== undefined)
|
|
84
|
+
settings[field] = subtree[key];
|
|
85
|
+
}
|
|
86
|
+
const resources = subtree.resources;
|
|
87
|
+
if (resources?.fetch !== undefined)
|
|
88
|
+
settings.fetchResources = resources.fetch;
|
|
89
|
+
if (resources?.["max-per-page"] !== undefined)
|
|
90
|
+
settings.maxResourcesPerPage = resources["max-per-page"];
|
|
91
|
+
const links = subtree.links;
|
|
92
|
+
if (links?.exclude !== undefined)
|
|
93
|
+
settings.linkExclude = links.exclude.map((host) => host.toLowerCase());
|
|
94
|
+
if (subtree["canonical-origin"] !== undefined)
|
|
95
|
+
settings.canonicalOrigin = originOf("org.spiderlint/canonical-origin", subtree["canonical-origin"]);
|
|
96
|
+
if (subtree.proxy !== undefined)
|
|
97
|
+
settings.proxy = proxyOf("org.spiderlint/proxy", subtree.proxy);
|
|
98
|
+
if (settings.via !== undefined)
|
|
99
|
+
settings.via = normalizeVia(settings.via);
|
|
100
|
+
if (subtree.resolver !== undefined)
|
|
101
|
+
settings.resolver = parseResolver(subtree.resolver);
|
|
102
|
+
if (subtree.resolve !== undefined)
|
|
103
|
+
settings.resolve = subtree.resolve.map((pin) => parsePin(pin));
|
|
104
|
+
if (subtree.cache !== undefined)
|
|
105
|
+
settings.cacheTtl = cacheTtl(subtree.cache);
|
|
106
|
+
const failureTtl = subtree.cache?.resources?.["failure-ttl"];
|
|
107
|
+
if (failureTtl !== undefined)
|
|
108
|
+
settings.cacheFailureTtl = seconds("resources/failure-ttl", failureTtl);
|
|
109
|
+
if (subtree.override !== undefined)
|
|
110
|
+
settings.overrides = flattenOverride(subtree.override);
|
|
111
|
+
const plugins = Object.entries(subtree).filter(([key]) => !CORE_KEYS.has(key));
|
|
112
|
+
if (plugins.length > 0)
|
|
113
|
+
settings.pluginSettings = Object.fromEntries(plugins);
|
|
114
|
+
return settings;
|
|
115
|
+
}
|
|
116
|
+
// Shared settings plus one patch per `sites.<name>`; a site key replaces the shared one whole.
|
|
117
|
+
function resolve(raw) {
|
|
118
|
+
const { sites = {}, ...shared } = validateSubtree(raw);
|
|
119
|
+
if (Object.keys(sites).length > 0 && shared.targets !== undefined)
|
|
120
|
+
throw new ConfigError("org.spiderlint/targets: with sites, every target belongs to a sites.<name>.targets");
|
|
121
|
+
log.debug({ sites: Object.keys(sites) }, "sites declared");
|
|
122
|
+
const settings = fromSubtree(shared);
|
|
123
|
+
// A site’s plugin key replaces the shared one of the same plugin, not every plugin’s.
|
|
124
|
+
const patch = (site) => (site.pluginSettings ? { ...site, pluginSettings: { ...settings.pluginSettings, ...site.pluginSettings } } : site);
|
|
125
|
+
return { settings, sites: Object.fromEntries(Object.entries(sites).map(([name, site]) => [name, patch(fromSubtree(site))])) };
|
|
126
|
+
}
|
|
127
|
+
// First recognised projectfile encoding present in the working directory.
|
|
128
|
+
function discover() {
|
|
129
|
+
return DISCOVER_NAMES.find((name) => existsSync(name));
|
|
130
|
+
}
|
|
131
|
+
// null when pf-cli is not on PATH; otherwise the subtree (possibly an error).
|
|
132
|
+
function runPfCli(document) {
|
|
133
|
+
const result = spawnSync("pf-cli", ["get", "--file", document, SUBTREE, "--format", "json", "--quiet"], { encoding: "utf8" });
|
|
134
|
+
if (result.error)
|
|
135
|
+
return undefined;
|
|
136
|
+
// pf-cli answers a path the document lacks with `null` and exit 1.
|
|
137
|
+
if (result.stdout.trim() === "null" && result.stderr.trim() === "")
|
|
138
|
+
return { raw: {} };
|
|
139
|
+
return result.status === 0 ? { raw: (JSON.parse(result.stdout || "null") ?? {}) } : { error: (result.stderr || result.stdout).trim() };
|
|
140
|
+
}
|
|
141
|
+
// No pf-cli: the file itself is parsed and its org.spiderlint key read directly.
|
|
142
|
+
function readPlainYaml(document) {
|
|
143
|
+
const parsed = parseYaml(readFileSync(document, "utf8"));
|
|
144
|
+
return parsed?.org?.spiderlint ?? {};
|
|
145
|
+
}
|
|
146
|
+
// Resolves the org.spiderlint subtree: explicit --config, else cwd discovery, else no file.
|
|
147
|
+
// pf-cli reads the document when it is on PATH; an explicit --config file falls back to a
|
|
148
|
+
// direct YAML parse otherwise, so outsiders need no pf-cli install (AGENTS.md ## Configuration).
|
|
149
|
+
export function loadSettings(explicit) {
|
|
150
|
+
if (explicit !== undefined && !existsSync(explicit))
|
|
151
|
+
throw new ConfigError(`config file not found: ${explicit}`);
|
|
152
|
+
const document = explicit ?? discover();
|
|
153
|
+
if (document === undefined) {
|
|
154
|
+
log.debug({}, "no projectfile found, using defaults");
|
|
155
|
+
return { settings: {}, sites: {}, document: undefined };
|
|
156
|
+
}
|
|
157
|
+
const pfResult = runPfCli(document);
|
|
158
|
+
if (pfResult === undefined) {
|
|
159
|
+
if (explicit === undefined) {
|
|
160
|
+
log.info({ document }, "pf-cli not found, skipping projectfile config");
|
|
161
|
+
return { settings: {}, sites: {}, document };
|
|
162
|
+
}
|
|
163
|
+
log.debug({ document }, "pf-cli not found, parsing config file directly");
|
|
164
|
+
return { ...resolve(readPlainYaml(document)), document };
|
|
165
|
+
}
|
|
166
|
+
if (pfResult.error !== undefined)
|
|
167
|
+
throw new ConfigError(`pf-cli: cannot read ${SUBTREE} from ${document}: ${pfResult.error}`);
|
|
168
|
+
log.debug({ document }, "config subtree loaded via pf-cli");
|
|
169
|
+
return { ...resolve(pfResult.raw), document };
|
|
170
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
5
|
+
import { ConfigError, PROFILES, ROLES } from "./index.js";
|
|
6
|
+
const ajv = new Ajv2020({ strictTypes: false, allErrors: true });
|
|
7
|
+
const ttl = { oneOf: [{ type: "string" }, { type: "integer", minimum: 0 }] };
|
|
8
|
+
const fetchMode = { enum: ["auto", "http", "browser", "adaptive"] };
|
|
9
|
+
const severity = { enum: ["error", "warning", "info", "hint", "off"] };
|
|
10
|
+
const score = { type: "number", minimum: 0, maximum: 9.9 };
|
|
11
|
+
const ruleSpec = {
|
|
12
|
+
type: "object",
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
fact: { type: "string" },
|
|
16
|
+
expect: { type: "object" },
|
|
17
|
+
when: { type: "object" },
|
|
18
|
+
unique: { type: "string" },
|
|
19
|
+
scope: { enum: ["page", "group", "site"] },
|
|
20
|
+
severity,
|
|
21
|
+
score: { oneOf: [score, { type: "array", minItems: 2, items: { type: "array", minItems: 2, maxItems: 2, items: { type: "number" } } }] },
|
|
22
|
+
docs: { type: "string" },
|
|
23
|
+
fix: { type: "string" },
|
|
24
|
+
hints: { type: "object", additionalProperties: { type: "string" } },
|
|
25
|
+
message: { type: "string" },
|
|
26
|
+
linked: { type: "boolean" },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
// The org.spiderlint subtree, as documented in AGENTS.md ## Configuration.
|
|
30
|
+
// additionalProperties: false below the top, where an object key is a plugin’s until `configurePlugins` finds none claiming it.
|
|
31
|
+
const site = {
|
|
32
|
+
type: "object",
|
|
33
|
+
additionalProperties: { type: "object" },
|
|
34
|
+
properties: {
|
|
35
|
+
targets: { type: "array", items: { type: "string" } },
|
|
36
|
+
"canonical-origin": { type: "string" },
|
|
37
|
+
role: { enum: ROLES },
|
|
38
|
+
resolver: { type: "string" },
|
|
39
|
+
resolve: { type: "array", items: { type: "string" } },
|
|
40
|
+
rules: { type: "array", items: { type: "string" } },
|
|
41
|
+
fetch: fetchMode,
|
|
42
|
+
browser: { enum: ["chromium", "firefox", "webkit"] },
|
|
43
|
+
"browser-install": { type: "boolean" },
|
|
44
|
+
scope: { enum: ["origin", "host", "domain"] },
|
|
45
|
+
concurrency: { type: "integer", minimum: 0 },
|
|
46
|
+
rate: { type: "integer", minimum: 0 },
|
|
47
|
+
timeout: { type: "integer", minimum: 1 },
|
|
48
|
+
profile: { enum: Object.keys(PROFILES) },
|
|
49
|
+
"max-pages": { type: "integer", minimum: 0 },
|
|
50
|
+
"max-depth": { type: "integer", minimum: 0 },
|
|
51
|
+
"max-body-size": { type: "integer", minimum: 0 },
|
|
52
|
+
keepalive: { type: "boolean" },
|
|
53
|
+
"allow-private": { type: "boolean" },
|
|
54
|
+
"include-urls": { type: "array", items: { type: "string" } },
|
|
55
|
+
"exclude-urls": { type: "array", items: { type: "string" } },
|
|
56
|
+
"vendor-paths": { type: "boolean" },
|
|
57
|
+
diversify: { type: "boolean" },
|
|
58
|
+
resources: {
|
|
59
|
+
type: "object",
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
properties: { fetch: { type: "boolean" }, "max-per-page": { type: "integer", minimum: 0 } },
|
|
62
|
+
},
|
|
63
|
+
links: { type: "object", additionalProperties: false, properties: { exclude: { type: "array", items: { type: "string", minLength: 1 } } } },
|
|
64
|
+
proxy: { type: "string" },
|
|
65
|
+
via: { type: "string" },
|
|
66
|
+
robots: { type: "boolean" },
|
|
67
|
+
sitemap: { type: "boolean" },
|
|
68
|
+
fold: { oneOf: [{ const: false }, { type: "object", additionalProperties: false, properties: { threshold: { type: "number", minimum: 0, maximum: 1 }, min: { type: "integer", minimum: 1 } } }] },
|
|
69
|
+
cache: {
|
|
70
|
+
type: "object",
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
properties: Object.fromEntries(["pages", "probes", "profiles", "lists", "resources", "robots", "sitemaps", "origins", "dns", "extractors"].map((bucket) => [bucket, { type: "object", additionalProperties: false, properties: { ttl, ...(bucket === "resources" && { "failure-ttl": ttl }) } }])),
|
|
73
|
+
},
|
|
74
|
+
"fail-on": { oneOf: [{ enum: ["error", "warning", "info", "never"] }, { type: "number", minimum: 0.1, maximum: 9.9 }] },
|
|
75
|
+
format: { type: "string" },
|
|
76
|
+
plugins: { type: "array", items: { type: "string" } },
|
|
77
|
+
sources: { type: "array", items: { type: "string" } },
|
|
78
|
+
"exclude-rules": { type: "array", items: { type: "string" } },
|
|
79
|
+
"document-types": { type: "array", items: { type: "string", pattern: "^[!#$%&'*+.^_`|~0-9A-Za-z-]+/[!#$%&'*+.^_`|~0-9A-Za-z-]+$" } },
|
|
80
|
+
override: {
|
|
81
|
+
type: "object",
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
properties: Object.fromEntries(["error", "warning", "info", "hint"].map((level) => [level, { type: "array", items: { type: "string" } }])),
|
|
84
|
+
},
|
|
85
|
+
groups: {
|
|
86
|
+
type: "object",
|
|
87
|
+
additionalProperties: { type: "object", additionalProperties: false, properties: { match: { type: "array", items: { type: "string" } }, rules: { type: "array", items: { type: "string" } }, fetch: fetchMode, sample: { oneOf: [{ type: "integer", minimum: 1 }, { const: "all" }] } } },
|
|
88
|
+
},
|
|
89
|
+
rulesets: {
|
|
90
|
+
type: "object",
|
|
91
|
+
additionalProperties: {
|
|
92
|
+
type: "object",
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
properties: {
|
|
95
|
+
description: { type: "string" },
|
|
96
|
+
extends: { type: "array", items: { type: "string" } },
|
|
97
|
+
when: { type: "object" },
|
|
98
|
+
rules: { type: "object", additionalProperties: { oneOf: [severity, score, ruleSpec] } },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
// Keys the core reads; any other top-level key belongs to a plugin.
|
|
105
|
+
export const CORE_KEYS = new Set([...Object.keys(site.properties), "sites"]);
|
|
106
|
+
// Each `sites.<name>` entry carries the same keys as the subtree, minus `sites`.
|
|
107
|
+
const schema = { ...site, properties: { ...site.properties, sites: { type: "object", additionalProperties: site } } };
|
|
108
|
+
const validate = ajv.compile(schema);
|
|
109
|
+
// Schema paths where a top-level key that is no object can only be a misspelt core key.
|
|
110
|
+
const TOP_LEVEL = new Set(["#/additionalProperties/type", "#/properties/sites/additionalProperties/additionalProperties/type"]);
|
|
111
|
+
// A misspelt or misplaced key names its own path under `prefix`, per AGENTS.md ## Configuration.
|
|
112
|
+
export function describe(error, prefix = "org.spiderlint") {
|
|
113
|
+
if (error.keyword === "additionalProperties") {
|
|
114
|
+
const key = error.params.additionalProperty;
|
|
115
|
+
return `${prefix}${error.instancePath}: unknown key "${key}"`;
|
|
116
|
+
}
|
|
117
|
+
const cut = error.instancePath.lastIndexOf("/");
|
|
118
|
+
return TOP_LEVEL.has(error.schemaPath) ? `${prefix}${error.instancePath.slice(0, cut)}: unknown key "${error.instancePath.slice(cut + 1)}"` : `${prefix}${error.instancePath}: ${error.message}`;
|
|
119
|
+
}
|
|
120
|
+
// Throws ConfigError (exit 2) listing every violation found in the subtree.
|
|
121
|
+
// `value` must already be an object (an absent subtree is the caller's `{}`, not `undefined`).
|
|
122
|
+
export function validateSubtree(value, prefix = "org.spiderlint") {
|
|
123
|
+
if (!validate(value)) {
|
|
124
|
+
throw new ConfigError((validate.errors ?? []).map((error) => describe(error, prefix)).join("; "));
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Damián Búho <damian.buho@proton.me>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import { PassThrough } from "node:stream";
|
|
5
|
+
import zlib from "node:zlib";
|
|
6
|
+
import { log } from "../logger.js";
|
|
7
|
+
// Response fields Crawlee reads off the stream it parses.
|
|
8
|
+
const FIELDS = ["statusCode", "statusMessage", "headers", "httpVersion", "rawHeaders", "url", "request", "complete"];
|
|
9
|
+
// Types whose body becomes facts; any other type is judged by its headers alone.
|
|
10
|
+
const PARSED = /^(?:text\/html|application\/xhtml\+xml|(?:text|application)\/xml|application\/json|application\/[\w.-]+\+(?:xml|json))$/;
|
|
11
|
+
// A missing content type is parsed, as Crawlee then guesses from the URL.
|
|
12
|
+
export function isParsed(contentType) {
|
|
13
|
+
const type = contentType?.split(";", 1)[0]?.trim().toLowerCase();
|
|
14
|
+
return !type || PARSED.test(type);
|
|
15
|
+
}
|
|
16
|
+
// `body` under `contentType` in place of `source`’s own, its other response fields kept.
|
|
17
|
+
export function replayed(source, body, contentType) {
|
|
18
|
+
const stream = new PassThrough();
|
|
19
|
+
for (const field of FIELDS)
|
|
20
|
+
Object.assign(stream, { [field]: source[field] });
|
|
21
|
+
Object.assign(stream, { headers: { ...source.headers, "content-type": contentType } });
|
|
22
|
+
source.resume();
|
|
23
|
+
stream.end(body);
|
|
24
|
+
return stream;
|
|
25
|
+
}
|
|
26
|
+
// The codings a page request advertises, each one `decoder` undoes.
|
|
27
|
+
export const ACCEPT_ENCODING = "gzip, deflate, br, zstd";
|
|
28
|
+
// Each known coding’s decoder, tolerant of a stream cut short.
|
|
29
|
+
const DECODERS = {
|
|
30
|
+
gzip: () => zlib.createGunzip({ finishFlush: zlib.constants.Z_SYNC_FLUSH }),
|
|
31
|
+
"x-gzip": () => zlib.createGunzip({ finishFlush: zlib.constants.Z_SYNC_FLUSH }),
|
|
32
|
+
deflate: () => zlib.createInflate({ finishFlush: zlib.constants.Z_SYNC_FLUSH }),
|
|
33
|
+
br: () => zlib.createBrotliDecompress({ finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH }),
|
|
34
|
+
zstd: () => zlib.createZstdDecompress(),
|
|
35
|
+
};
|
|
36
|
+
// A stream undoing one `Content-Encoding`; undefined for identity or a coding it does not know.
|
|
37
|
+
export function decoder(encoding) {
|
|
38
|
+
return DECODERS[encoding?.trim().toLowerCase() ?? ""]?.();
|
|
39
|
+
}
|
|
40
|
+
// The response got wraps once it read every declared byte, which a parse error on bytes past them leaves intact.
|
|
41
|
+
function completed(source) {
|
|
42
|
+
const response = source.response;
|
|
43
|
+
return response?.complete ? response : undefined;
|
|
44
|
+
}
|
|
45
|
+
// At most `max` decoded bytes of `source`, which is destroyed once the cap is reached.
|
|
46
|
+
export function capped(source, max) {
|
|
47
|
+
const stream = new PassThrough();
|
|
48
|
+
for (const field of FIELDS)
|
|
49
|
+
Object.assign(stream, { [field]: source[field] });
|
|
50
|
+
let isCut = false;
|
|
51
|
+
const unzip = max === 0 ? undefined : decoder([source.headers?.["content-encoding"]].flat()[0]);
|
|
52
|
+
const body = unzip ? source.pipe(unzip) : source;
|
|
53
|
+
const cut = () => {
|
|
54
|
+
isCut = true;
|
|
55
|
+
source.destroy();
|
|
56
|
+
unzip?.destroy();
|
|
57
|
+
stream.end();
|
|
58
|
+
};
|
|
59
|
+
if (max === 0) {
|
|
60
|
+
cut();
|
|
61
|
+
return { stream, isTruncated: () => isCut };
|
|
62
|
+
}
|
|
63
|
+
let seen = 0;
|
|
64
|
+
const take = (chunk) => {
|
|
65
|
+
if (isCut)
|
|
66
|
+
return;
|
|
67
|
+
const room = max - seen;
|
|
68
|
+
seen += chunk.length;
|
|
69
|
+
stream.write(chunk.subarray(0, room));
|
|
70
|
+
if (seen >= max)
|
|
71
|
+
cut();
|
|
72
|
+
};
|
|
73
|
+
// A failure after every declared byte arrived ends the body with the bytes got never passed on; any other destroys it.
|
|
74
|
+
const fail = (error) => {
|
|
75
|
+
const response = completed(source);
|
|
76
|
+
log.debug({ error: error.message, isComplete: response !== undefined, seen }, "body stream failed");
|
|
77
|
+
if (!response)
|
|
78
|
+
return stream.destroy(error);
|
|
79
|
+
const rest = response.read() ?? Buffer.alloc(0);
|
|
80
|
+
if (unzip)
|
|
81
|
+
return void unzip.end(rest);
|
|
82
|
+
take(rest);
|
|
83
|
+
stream.end();
|
|
84
|
+
};
|
|
85
|
+
body.on("data", take);
|
|
86
|
+
body.on("end", () => stream.end());
|
|
87
|
+
source.on("error", fail);
|
|
88
|
+
unzip?.on("error", (error) => stream.destroy(error));
|
|
89
|
+
if (source.errored)
|
|
90
|
+
fail(source.errored);
|
|
91
|
+
return { stream, isTruncated: () => isCut };
|
|
92
|
+
}
|