ssrwire 0.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +49 -0
  3. package/LICENSE +21 -0
  4. package/PUBLISHING.md +134 -0
  5. package/README.md +403 -0
  6. package/SECURITY.md +26 -0
  7. package/dist/agents.d.ts +14 -0
  8. package/dist/agents.d.ts.map +1 -0
  9. package/dist/agents.js +100 -0
  10. package/dist/agents.js.map +1 -0
  11. package/dist/analyze.d.ts +4 -0
  12. package/dist/analyze.d.ts.map +1 -0
  13. package/dist/analyze.js +494 -0
  14. package/dist/analyze.js.map +1 -0
  15. package/dist/audit.d.ts +3 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +69 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/bin.d.ts +3 -0
  20. package/dist/bin.d.ts.map +1 -0
  21. package/dist/bin.js +4 -0
  22. package/dist/bin.js.map +1 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +173 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +17 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +262 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/http-probe.d.ts +7 -0
  32. package/dist/http-probe.d.ts.map +1 -0
  33. package/dist/http-probe.js +406 -0
  34. package/dist/http-probe.js.map +1 -0
  35. package/dist/index.d.ts +11 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +10 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/redact.d.ts +10 -0
  40. package/dist/redact.d.ts.map +1 -0
  41. package/dist/redact.js +154 -0
  42. package/dist/redact.js.map +1 -0
  43. package/dist/reporters.d.ts +9 -0
  44. package/dist/reporters.d.ts.map +1 -0
  45. package/dist/reporters.js +220 -0
  46. package/dist/reporters.js.map +1 -0
  47. package/dist/stream-parser.d.ts +9 -0
  48. package/dist/stream-parser.d.ts.map +1 -0
  49. package/dist/stream-parser.js +366 -0
  50. package/dist/stream-parser.js.map +1 -0
  51. package/dist/types.d.ts +134 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/dist/version.d.ts +2 -0
  56. package/dist/version.d.ts.map +1 -0
  57. package/dist/version.js +14 -0
  58. package/dist/version.js.map +1 -0
  59. package/examples/github-actions.yml +73 -0
  60. package/examples/ssrwire.config.yml +37 -0
  61. package/package.json +91 -0
  62. package/src/agents.ts +129 -0
  63. package/src/analyze.ts +628 -0
  64. package/src/audit.ts +89 -0
  65. package/src/bin.ts +5 -0
  66. package/src/cli.ts +207 -0
  67. package/src/config.ts +313 -0
  68. package/src/http-probe.ts +461 -0
  69. package/src/index.ts +34 -0
  70. package/src/redact.ts +173 -0
  71. package/src/reporters.ts +274 -0
  72. package/src/stream-parser.ts +424 -0
  73. package/src/types.ts +160 -0
  74. package/src/version.ts +19 -0
package/src/bin.ts ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "./cli.js";
4
+
5
+ await main();
package/src/cli.ts ADDED
@@ -0,0 +1,207 @@
1
+ import { access, mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { Command, CommanderError, InvalidArgumentError } from "commander";
4
+ import { runAudit } from "./audit.js";
5
+ import { ConfigError, loadConfig } from "./config.js";
6
+ import { renderReport } from "./reporters.js";
7
+ import type { ReportFormat } from "./types.js";
8
+ import { VERSION } from "./version.js";
9
+
10
+ interface CliOptions {
11
+ readonly config?: string;
12
+ readonly agent: readonly string[];
13
+ readonly header: readonly string[];
14
+ readonly timeout?: number;
15
+ readonly maxBytes?: number;
16
+ readonly maxRedirects?: number;
17
+ readonly format: ReportFormat;
18
+ readonly output?: string;
19
+ readonly failOn: "error" | "warning" | "never";
20
+ readonly color: boolean;
21
+ }
22
+
23
+ const CONFIG_TEMPLATE = `# SSRWire configuration
24
+ targets:
25
+ - url: https://example.com/
26
+ expectedStatus: 200
27
+ require:
28
+ title: true
29
+ description: true
30
+ canonical: true
31
+ h1: true
32
+ mainText: true
33
+
34
+ agents:
35
+ - browser
36
+ - googlebot
37
+ - bingbot
38
+ - twitterbot
39
+
40
+ timeoutMs: 15000
41
+ maxBytes: 10485760
42
+ maxRedirects: 10
43
+
44
+ # Keep preview credentials in environment variables. SSRWire redacts configured values from reports.
45
+ # headers:
46
+ # Authorization: \${PREVIEW_TOKEN}
47
+ `;
48
+
49
+ function collect(value: string, previous: readonly string[]): string[] {
50
+ return [...previous, value];
51
+ }
52
+
53
+ function parseInteger(value: string): number {
54
+ const parsed = Number(value);
55
+ if (!Number.isInteger(parsed)) {
56
+ throw new InvalidArgumentError("Expected an integer.");
57
+ }
58
+ return parsed;
59
+ }
60
+
61
+ function parseFormat(value: string): ReportFormat {
62
+ if (value === "terminal" || value === "json" || value === "sarif") {
63
+ return value;
64
+ }
65
+ throw new InvalidArgumentError("Expected terminal, json, or sarif.");
66
+ }
67
+
68
+ function parseFailOn(value: string): CliOptions["failOn"] {
69
+ if (value === "error" || value === "warning" || value === "never") {
70
+ return value;
71
+ }
72
+ throw new InvalidArgumentError("Expected error, warning, or never.");
73
+ }
74
+
75
+ function addCheckOptions(command: Command): Command {
76
+ return command
77
+ .option("-c, --config <path>", "configuration file")
78
+ .option("-a, --agent <name>", "built-in crawler agent; repeatable", collect, [])
79
+ .option("-H, --header <header>", "same-origin request header; repeatable", collect, [])
80
+ .option("--timeout <ms>", "request timeout in milliseconds", parseInteger)
81
+ .option("--max-bytes <bytes>", "maximum response bytes", parseInteger)
82
+ .option("--max-redirects <count>", "maximum redirects", parseInteger)
83
+ .option("-f, --format <format>", "terminal, json, or sarif", parseFormat, "terminal")
84
+ .option("-o, --output <path>", "write the report to a file")
85
+ .option("--fail-on <level>", "error, warning, or never", parseFailOn, "error")
86
+ .option("--no-color", "disable terminal colors");
87
+ }
88
+
89
+ function optionsFrom(command: Command): CliOptions {
90
+ return command.optsWithGlobals<CliOptions>();
91
+ }
92
+
93
+ function reportExitCode(
94
+ summary: Awaited<ReturnType<typeof runAudit>>["summary"],
95
+ failOn: CliOptions["failOn"],
96
+ ): number {
97
+ if (summary.incomplete > 0) {
98
+ return 2;
99
+ }
100
+ if (failOn === "never") {
101
+ return 0;
102
+ }
103
+ if (summary.errors > 0) {
104
+ return 1;
105
+ }
106
+ if (failOn === "warning" && summary.warnings > 0) {
107
+ return 1;
108
+ }
109
+ return 0;
110
+ }
111
+
112
+ async function writeReport(path: string, report: string): Promise<void> {
113
+ const absolute = resolve(path);
114
+ await mkdir(dirname(absolute), { recursive: true });
115
+ await writeFile(absolute, report, "utf8");
116
+ }
117
+
118
+ async function check(urls: readonly string[], options: CliOptions): Promise<void> {
119
+ const config = await loadConfig({
120
+ ...(options.config ? { configPath: options.config } : {}),
121
+ urls,
122
+ agents: options.agent,
123
+ headers: options.header,
124
+ ...(options.timeout === undefined ? {} : { timeoutMs: options.timeout }),
125
+ ...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
126
+ ...(options.maxRedirects === undefined ? {} : { maxRedirects: options.maxRedirects }),
127
+ });
128
+ const audit = await runAudit(config);
129
+ const color =
130
+ options.color &&
131
+ !options.output &&
132
+ Boolean(process.stdout.isTTY) &&
133
+ !Reflect.has(process.env, "NO_COLOR");
134
+ const report = renderReport(audit, options.format, { color });
135
+
136
+ if (options.output) {
137
+ await writeReport(options.output, report);
138
+ process.stderr.write(`SSRWire wrote ${options.format} report to ${options.output}\n`);
139
+ } else {
140
+ process.stdout.write(report);
141
+ }
142
+
143
+ process.exitCode = reportExitCode(audit.summary, options.failOn);
144
+ }
145
+
146
+ async function initialize(path: string, force: boolean): Promise<void> {
147
+ const absolute = resolve(path);
148
+ if (!force) {
149
+ try {
150
+ await access(absolute);
151
+ throw new ConfigError(`${path} already exists. Use --force to replace it.`);
152
+ } catch (error) {
153
+ if (error instanceof ConfigError) {
154
+ throw error;
155
+ }
156
+ }
157
+ }
158
+ await mkdir(dirname(absolute), { recursive: true });
159
+ await writeFile(absolute, CONFIG_TEMPLATE, { encoding: "utf8", flag: force ? "w" : "wx" });
160
+ process.stdout.write(`Created ${path}\n`);
161
+ }
162
+
163
+ export async function main(argv: readonly string[] = process.argv): Promise<void> {
164
+ const program = new Command();
165
+ program
166
+ .name("ssrwire")
167
+ .description("Inspect streamed SSR HTML and crawler-specific metadata delivery.")
168
+ .version(VERSION)
169
+ .exitOverride()
170
+ .showHelpAfterError();
171
+
172
+ addCheckOptions(program)
173
+ .argument("[urls...]", "HTTP or HTTPS URLs to inspect")
174
+ .action(async (urls: string[], _options: CliOptions, command: Command) => {
175
+ await check(urls, optionsFrom(command));
176
+ });
177
+
178
+ addCheckOptions(
179
+ program
180
+ .command("check")
181
+ .description("inspect one or more SSR responses")
182
+ .argument("[urls...]", "HTTP or HTTPS URLs to inspect"),
183
+ ).action(async (urls: string[], _options: CliOptions, command: Command) => {
184
+ await check(urls, optionsFrom(command));
185
+ });
186
+
187
+ program
188
+ .command("init")
189
+ .description("create a documented starter configuration")
190
+ .argument("[path]", "configuration path", "ssrwire.config.yml")
191
+ .option("--force", "replace an existing file")
192
+ .action(async (path: string, options: { force?: boolean }) => {
193
+ await initialize(path, options.force ?? false);
194
+ });
195
+
196
+ try {
197
+ await program.parseAsync([...argv]);
198
+ } catch (error) {
199
+ if (error instanceof CommanderError) {
200
+ process.exitCode = error.exitCode === 0 ? 0 : 2;
201
+ return;
202
+ }
203
+ const message = error instanceof Error ? error.message : String(error);
204
+ process.stderr.write(`SSRWire: ${message}\n`);
205
+ process.exitCode = 2;
206
+ }
207
+ }
package/src/config.ts ADDED
@@ -0,0 +1,313 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { extname, resolve } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { z } from "zod";
5
+ import { type AgentInput, resolveAgents } from "./agents.js";
6
+ import type { AgentProfile, AuditTarget, SsrWireConfig, TargetExpectations } from "./types.js";
7
+
8
+ const DEFAULT_TIMEOUT_MS = 15_000;
9
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
10
+ const DEFAULT_MAX_REDIRECTS = 10;
11
+ const DEFAULT_AGENTS = ["browser", "googlebot", "bingbot", "twitterbot"] as const;
12
+ const DEFAULT_CONFIG_FILES = [
13
+ "ssrwire.config.yml",
14
+ "ssrwire.config.yaml",
15
+ "ssrwire.config.json",
16
+ ] as const;
17
+
18
+ const requireSchema = z
19
+ .object({
20
+ title: z.boolean().optional(),
21
+ description: z.boolean().optional(),
22
+ canonical: z.boolean().optional(),
23
+ h1: z.boolean().optional(),
24
+ mainText: z.boolean().optional(),
25
+ })
26
+ .strict();
27
+
28
+ const targetObjectSchema = z
29
+ .object({
30
+ url: z.string().min(1),
31
+ expectedStatus: z
32
+ .union([
33
+ z.number().int().min(100).max(599),
34
+ z.array(z.number().int().min(100).max(599)).min(1),
35
+ ])
36
+ .optional(),
37
+ expectedFinalUrl: z.string().min(1).optional(),
38
+ require: requireSchema.optional(),
39
+ maxFirstByteMs: z.number().positive().finite().optional(),
40
+ maxCriticalMs: z.number().positive().finite().optional(),
41
+ })
42
+ .strict();
43
+
44
+ const agentObjectSchema = z
45
+ .object({
46
+ key: z.string().regex(/^[a-z0-9][a-z0-9-]*$/i),
47
+ label: z.string().min(1).optional(),
48
+ userAgent: z.string().min(1),
49
+ requiresHeadMetadata: z.boolean().optional(),
50
+ })
51
+ .strict();
52
+
53
+ const fileConfigSchema = z
54
+ .object({
55
+ targets: z.array(z.union([z.string().min(1), targetObjectSchema])).optional(),
56
+ agents: z
57
+ .array(z.union([z.string().min(1), agentObjectSchema]))
58
+ .min(1)
59
+ .optional(),
60
+ headers: z.record(z.string(), z.string()).optional(),
61
+ timeoutMs: z.number().int().min(100).max(120_000).optional(),
62
+ maxBytes: z
63
+ .number()
64
+ .int()
65
+ .min(1_024)
66
+ .max(50 * 1024 * 1024)
67
+ .optional(),
68
+ maxRedirects: z.number().int().min(0).max(20).optional(),
69
+ })
70
+ .strict();
71
+
72
+ type FileConfig = z.infer<typeof fileConfigSchema>;
73
+
74
+ export interface LoadConfigOptions {
75
+ readonly configPath?: string;
76
+ readonly urls?: readonly string[];
77
+ readonly agents?: readonly string[];
78
+ readonly headers?: readonly string[];
79
+ readonly timeoutMs?: number;
80
+ readonly maxBytes?: number;
81
+ readonly maxRedirects?: number;
82
+ readonly cwd?: string;
83
+ }
84
+
85
+ export class ConfigError extends Error {
86
+ public constructor(message: string) {
87
+ super(message);
88
+ this.name = "ConfigError";
89
+ }
90
+ }
91
+
92
+ function validateHttpUrl(value: string, label: string): string {
93
+ let url: URL;
94
+ try {
95
+ url = new URL(value);
96
+ } catch {
97
+ throw new ConfigError(`${label} must be an absolute HTTP or HTTPS URL.`);
98
+ }
99
+
100
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
101
+ throw new ConfigError(`${label} must use HTTP or HTTPS.`);
102
+ }
103
+ if (url.username || url.password) {
104
+ throw new ConfigError(`${label} must not contain embedded credentials.`);
105
+ }
106
+
107
+ url.hash = "";
108
+ return url.href;
109
+ }
110
+
111
+ function normalizeStatuses(value: number | number[] | undefined): readonly number[] {
112
+ const statuses = value === undefined ? [200] : Array.isArray(value) ? value : [value];
113
+ return [...new Set(statuses)];
114
+ }
115
+
116
+ function normalizeTarget(value: string | z.infer<typeof targetObjectSchema>): AuditTarget {
117
+ const item = typeof value === "string" ? { url: value } : value;
118
+ const required = item.require;
119
+ const expectations: TargetExpectations = {
120
+ statuses: normalizeStatuses(item.expectedStatus),
121
+ ...(item.expectedFinalUrl
122
+ ? { finalUrl: validateHttpUrl(item.expectedFinalUrl, "expectedFinalUrl") }
123
+ : {}),
124
+ requireTitle: required?.title ?? true,
125
+ requireDescription: required?.description ?? true,
126
+ requireCanonical: required?.canonical ?? true,
127
+ requireH1: required?.h1 ?? true,
128
+ requireMainText: required?.mainText ?? true,
129
+ ...(item.maxFirstByteMs === undefined ? {} : { maxFirstByteMs: item.maxFirstByteMs }),
130
+ ...(item.maxCriticalMs === undefined ? {} : { maxCriticalMs: item.maxCriticalMs }),
131
+ };
132
+
133
+ return {
134
+ url: validateHttpUrl(item.url, "target URL"),
135
+ expectations,
136
+ };
137
+ }
138
+
139
+ function interpolateEnvironment(value: string): string {
140
+ return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/gi, (_match, name: string) => {
141
+ const resolved = process.env[name];
142
+ if (resolved === undefined) {
143
+ throw new ConfigError(`Environment variable ${name} is required by a configured header.`);
144
+ }
145
+ return resolved;
146
+ });
147
+ }
148
+
149
+ const FORBIDDEN_HEADERS = new Set([
150
+ "accept-encoding",
151
+ "connection",
152
+ "content-length",
153
+ "host",
154
+ "transfer-encoding",
155
+ "user-agent",
156
+ ]);
157
+
158
+ function validateHeader(name: string, value: string): readonly [string, string] {
159
+ const normalizedName = name.trim().toLowerCase();
160
+ if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(normalizedName)) {
161
+ throw new ConfigError(`Invalid header name: ${name.trim() || "(empty)"}.`);
162
+ }
163
+ if (FORBIDDEN_HEADERS.has(normalizedName)) {
164
+ throw new ConfigError(
165
+ `Header ${normalizedName} is managed by SSRWire and cannot be overridden.`,
166
+ );
167
+ }
168
+ if (/\r|\n/.test(value)) {
169
+ throw new ConfigError(`Header ${normalizedName} contains a forbidden line break.`);
170
+ }
171
+ return [normalizedName, interpolateEnvironment(value.trim())];
172
+ }
173
+
174
+ export function parseHeaderOption(value: string): readonly [string, string] {
175
+ const separator = value.indexOf(":");
176
+ if (separator < 1) {
177
+ throw new ConfigError("Headers must use the form 'Name: value'.");
178
+ }
179
+ return validateHeader(value.slice(0, separator), value.slice(separator + 1));
180
+ }
181
+
182
+ function normalizeHeaders(
183
+ configured: Readonly<Record<string, string>> | undefined,
184
+ commandLine: readonly string[] | undefined,
185
+ ): Readonly<Record<string, string>> {
186
+ const headers = new Map<string, string>();
187
+ for (const [name, value] of Object.entries(configured ?? {})) {
188
+ const [normalizedName, normalizedValue] = validateHeader(name, value);
189
+ headers.set(normalizedName, normalizedValue);
190
+ }
191
+ for (const value of commandLine ?? []) {
192
+ const [name, normalizedValue] = parseHeaderOption(value);
193
+ headers.set(name, normalizedValue);
194
+ }
195
+ return Object.fromEntries(headers);
196
+ }
197
+
198
+ async function fileExists(path: string): Promise<boolean> {
199
+ try {
200
+ await access(path);
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ async function resolveConfigPath(
208
+ explicitPath: string | undefined,
209
+ cwd: string,
210
+ ): Promise<string | undefined> {
211
+ if (explicitPath) {
212
+ const path = resolve(cwd, explicitPath);
213
+ if (!(await fileExists(path))) {
214
+ throw new ConfigError(`Configuration file not found: ${explicitPath}`);
215
+ }
216
+ return path;
217
+ }
218
+
219
+ for (const name of DEFAULT_CONFIG_FILES) {
220
+ const path = resolve(cwd, name);
221
+ if (await fileExists(path)) {
222
+ return path;
223
+ }
224
+ }
225
+ return undefined;
226
+ }
227
+
228
+ function parseConfigText(path: string, text: string): unknown {
229
+ try {
230
+ return extname(path).toLowerCase() === ".json" ? JSON.parse(text) : parseYaml(text);
231
+ } catch {
232
+ // Parser messages may quote the malformed source line, including a literal header secret.
233
+ throw new ConfigError("Could not parse configuration file.");
234
+ }
235
+ }
236
+
237
+ async function readConfig(path: string | undefined): Promise<FileConfig> {
238
+ if (!path) {
239
+ return {};
240
+ }
241
+ const raw = parseConfigText(path, await readFile(path, "utf8"));
242
+ const parsed = fileConfigSchema.safeParse(raw);
243
+ if (!parsed.success) {
244
+ const detail = parsed.error.issues
245
+ .slice(0, 5)
246
+ .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
247
+ .join("; ");
248
+ throw new ConfigError(`Invalid configuration: ${detail}`);
249
+ }
250
+ return parsed.data;
251
+ }
252
+
253
+ function uniqueTargets(targets: readonly AuditTarget[]): readonly AuditTarget[] {
254
+ const seen = new Set<string>();
255
+ return targets.filter((target) => {
256
+ if (seen.has(target.url)) {
257
+ return false;
258
+ }
259
+ seen.add(target.url);
260
+ return true;
261
+ });
262
+ }
263
+
264
+ export async function loadConfig(options: LoadConfigOptions = {}): Promise<SsrWireConfig> {
265
+ const cwd = options.cwd ?? process.cwd();
266
+ const configPath = await resolveConfigPath(options.configPath, cwd);
267
+ const file = await readConfig(configPath);
268
+ const fileTargets = (file.targets ?? []).map(normalizeTarget);
269
+ const cliTargets = (options.urls ?? []).map(normalizeTarget);
270
+ const targets = uniqueTargets([...fileTargets, ...cliTargets]);
271
+ if (targets.length === 0) {
272
+ throw new ConfigError(
273
+ "No target URL was provided. Pass a URL or add targets to ssrwire.config.yml.",
274
+ );
275
+ }
276
+
277
+ const agentInputs: readonly AgentInput[] =
278
+ options.agents && options.agents.length > 0
279
+ ? options.agents
280
+ : ((file.agents ?? DEFAULT_AGENTS) as readonly AgentInput[]);
281
+
282
+ let agents: AgentProfile[];
283
+ try {
284
+ agents = resolveAgents(agentInputs);
285
+ } catch (error) {
286
+ throw new ConfigError(
287
+ error instanceof Error ? error.message : "Invalid crawler agent configuration.",
288
+ );
289
+ }
290
+
291
+ const timeoutMs = options.timeoutMs ?? file.timeoutMs ?? DEFAULT_TIMEOUT_MS;
292
+ const maxBytes = options.maxBytes ?? file.maxBytes ?? DEFAULT_MAX_BYTES;
293
+ const maxRedirects = options.maxRedirects ?? file.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
294
+
295
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120_000) {
296
+ throw new ConfigError("timeoutMs must be an integer between 100 and 120000.");
297
+ }
298
+ if (!Number.isInteger(maxBytes) || maxBytes < 1_024 || maxBytes > 50 * 1024 * 1024) {
299
+ throw new ConfigError("maxBytes must be an integer between 1024 and 52428800.");
300
+ }
301
+ if (!Number.isInteger(maxRedirects) || maxRedirects < 0 || maxRedirects > 20) {
302
+ throw new ConfigError("maxRedirects must be an integer between 0 and 20.");
303
+ }
304
+
305
+ return {
306
+ targets,
307
+ agents,
308
+ headers: normalizeHeaders(file.headers, options.headers),
309
+ timeoutMs,
310
+ maxBytes,
311
+ maxRedirects,
312
+ };
313
+ }