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/analyze.ts ADDED
@@ -0,0 +1,628 @@
1
+ import type {
2
+ AuditSummary,
3
+ AuditTarget,
4
+ ElementSignal,
5
+ Finding,
6
+ ProbeResult,
7
+ RobotsAudience,
8
+ RobotsSignal,
9
+ Severity,
10
+ TargetAuditResult,
11
+ } from "./types.js";
12
+
13
+ type MetadataKind = "title" | "description" | "canonical" | "robots";
14
+
15
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
16
+ const ROBOTS_OPPOSITES = [
17
+ ["index", "noindex"],
18
+ ["follow", "nofollow"],
19
+ ["archive", "noarchive"],
20
+ ["snippet", "nosnippet"],
21
+ ["translate", "notranslate"],
22
+ ["imageindex", "noimageindex"],
23
+ ] as const;
24
+
25
+ interface FindingInput {
26
+ readonly code: string;
27
+ readonly severity: Severity;
28
+ readonly message: string;
29
+ readonly agent?: string;
30
+ readonly evidence?: Readonly<Record<string, string | number | boolean>>;
31
+ }
32
+
33
+ function normalizeText(value: string): string {
34
+ return value.trim().replace(/\s+/g, " ");
35
+ }
36
+
37
+ function normalizeCanonical(value: string, baseUrl: string): string {
38
+ const trimmed = normalizeText(value);
39
+ if (trimmed.length === 0) {
40
+ return "";
41
+ }
42
+
43
+ try {
44
+ const url = new URL(trimmed, baseUrl);
45
+ url.hash = "";
46
+ return url.href;
47
+ } catch {
48
+ return trimmed;
49
+ }
50
+ }
51
+
52
+ function normalizeFinalUrl(value: string): string {
53
+ try {
54
+ const url = new URL(value);
55
+ url.hash = "";
56
+ return url.href;
57
+ } catch {
58
+ return value;
59
+ }
60
+ }
61
+
62
+ function normalizeRobots(value: string): string {
63
+ return normalizeText(value)
64
+ .toLowerCase()
65
+ .split(/[;,]/)
66
+ .map((directive) => directive.trim())
67
+ .filter((directive) => directive.length > 0)
68
+ .sort()
69
+ .join(",");
70
+ }
71
+
72
+ function normalizedValues(
73
+ kind: MetadataKind,
74
+ signals: readonly ElementSignal[],
75
+ baseUrl: string,
76
+ ): readonly string[] {
77
+ return signals
78
+ .map((signal) => {
79
+ if (kind === "canonical") {
80
+ return normalizeCanonical(signal.value, baseUrl);
81
+ }
82
+ if (kind === "robots") {
83
+ return normalizeRobots(signal.value);
84
+ }
85
+ return normalizeText(signal.value);
86
+ })
87
+ .filter((value) => value.length > 0);
88
+ }
89
+
90
+ function robotsAudienceForAgent(probe: ProbeResult): RobotsAudience {
91
+ const key = probe.agent.key.trim().toLowerCase();
92
+ if (key === "googlebot" || key === "bingbot") return key;
93
+ return "robots";
94
+ }
95
+
96
+ function effectiveRobotsSignals(probe: ProbeResult): readonly RobotsSignal[] {
97
+ const audience = robotsAudienceForAgent(probe);
98
+ const generic = probe.signals.robots.filter((signal) => signal.audience === "robots");
99
+ if (audience === "robots") return generic;
100
+
101
+ const specific = probe.signals.robots.filter(
102
+ (signal) => signal.audience === audience && normalizeRobots(signal.value).length > 0,
103
+ );
104
+ return [...generic, ...specific];
105
+ }
106
+
107
+ function normalizedRobotsDirectives(signals: readonly RobotsSignal[]): Set<string> {
108
+ const directives = new Set(
109
+ signals
110
+ .flatMap((signal) => normalizeRobots(signal.value).split(","))
111
+ .filter((directive) => directive.length > 0),
112
+ );
113
+ if (directives.delete("none")) {
114
+ directives.add("noindex");
115
+ directives.add("nofollow");
116
+ }
117
+ if (directives.delete("all")) {
118
+ directives.add("index");
119
+ directives.add("follow");
120
+ }
121
+
122
+ for (const [permissive, restrictive] of ROBOTS_OPPOSITES) {
123
+ if (directives.has(restrictive)) directives.delete(permissive);
124
+ }
125
+ return directives;
126
+ }
127
+
128
+ function effectiveRobotsValue(probe: ProbeResult): string {
129
+ return (
130
+ [...normalizedRobotsDirectives(effectiveRobotsSignals(probe))].sort().join(",") || "<missing>"
131
+ );
132
+ }
133
+
134
+ function addFinding(findings: Finding[], url: string, input: FindingInput): void {
135
+ findings.push({
136
+ code: input.code,
137
+ severity: input.severity,
138
+ message: input.message,
139
+ url,
140
+ ...(input.agent === undefined ? {} : { agent: input.agent }),
141
+ ...(input.evidence === undefined ? {} : { evidence: input.evidence }),
142
+ });
143
+ }
144
+
145
+ function checkRepeatedMetadata(
146
+ findings: Finding[],
147
+ targetUrl: string,
148
+ probe: ProbeResult,
149
+ kind: MetadataKind,
150
+ signals: readonly ElementSignal[],
151
+ ): void {
152
+ const values = normalizedValues(kind, signals, probe.finalUrl);
153
+ if (values.length < 2) {
154
+ return;
155
+ }
156
+
157
+ const distinct = new Set(values);
158
+ const conflicting = distinct.size > 1;
159
+ const label = kind === "robots" ? "meta robots directives" : `${kind} elements`;
160
+ addFinding(findings, targetUrl, {
161
+ code: conflicting ? `conflicting-${kind}` : `duplicate-${kind}`,
162
+ severity: "warning",
163
+ message: conflicting
164
+ ? `${probe.agent.label} received conflicting ${label}.`
165
+ : `${probe.agent.label} received duplicate ${label}.`,
166
+ agent: probe.agent.key,
167
+ evidence: {
168
+ count: values.length,
169
+ distinctValues: distinct.size,
170
+ values: [...distinct].join(" | "),
171
+ },
172
+ });
173
+ }
174
+
175
+ function checkRepeatedRobots(findings: Finding[], targetUrl: string, probe: ProbeResult): void {
176
+ const audience = robotsAudienceForAgent(probe);
177
+ const relevantAudiences: readonly RobotsAudience[] =
178
+ audience === "robots" ? ["robots"] : ["robots", audience];
179
+ let conflictReported = false;
180
+
181
+ for (const currentAudience of relevantAudiences) {
182
+ const signals = probe.signals.robots.filter(
183
+ (signal) => signal.audience === currentAudience && normalizeRobots(signal.value).length > 0,
184
+ );
185
+ if (signals.length < 2) continue;
186
+
187
+ const values = signals.map((signal) => normalizeRobots(signal.value));
188
+ const distinct = new Set(values);
189
+ const rawDirectives = new Set(values.flatMap((value) => value.split(",")));
190
+ if (rawDirectives.has("none")) {
191
+ rawDirectives.add("noindex");
192
+ rawDirectives.add("nofollow");
193
+ }
194
+ if (rawDirectives.has("all")) {
195
+ rawDirectives.add("index");
196
+ rawDirectives.add("follow");
197
+ }
198
+ const conflicting = ROBOTS_OPPOSITES.some(
199
+ ([left, right]) => rawDirectives.has(left) && rawDirectives.has(right),
200
+ );
201
+ if (conflicting) conflictReported = true;
202
+ const code = conflicting
203
+ ? "conflicting-robots"
204
+ : distinct.size === 1
205
+ ? "duplicate-robots"
206
+ : "multiple-robots";
207
+ addFinding(findings, targetUrl, {
208
+ code,
209
+ severity: "warning",
210
+ message: conflicting
211
+ ? `${probe.agent.label} received contradictory ${currentAudience} directives.`
212
+ : `${probe.agent.label} received multiple ${currentAudience} meta directives.`,
213
+ agent: probe.agent.key,
214
+ evidence: {
215
+ audience: currentAudience,
216
+ count: values.length,
217
+ distinctValues: distinct.size,
218
+ values: [...distinct].join(" | "),
219
+ },
220
+ });
221
+ }
222
+
223
+ const effectiveSignals = effectiveRobotsSignals(probe);
224
+ const genericDirectives = new Set(
225
+ probe.signals.robots
226
+ .filter((signal) => signal.audience === "robots")
227
+ .flatMap((signal) => normalizeRobots(signal.value).split(",")),
228
+ );
229
+ const specificAudience = robotsAudienceForAgent(probe);
230
+ const specificDirectives = new Set(
231
+ specificAudience === "robots"
232
+ ? []
233
+ : probe.signals.robots
234
+ .filter((signal) => signal.audience === specificAudience)
235
+ .flatMap((signal) => normalizeRobots(signal.value).split(",")),
236
+ );
237
+ if (genericDirectives.has("none")) {
238
+ genericDirectives.add("noindex");
239
+ genericDirectives.add("nofollow");
240
+ }
241
+ if (specificDirectives.has("all")) {
242
+ specificDirectives.add("index");
243
+ specificDirectives.add("follow");
244
+ }
245
+ // A crawler-specific restriction can intentionally tighten a generic rule. The inverse cannot
246
+ // relax an already-applicable generic restriction and is therefore worth flagging.
247
+ const effectiveConflict = ROBOTS_OPPOSITES.some(
248
+ ([permissive, restrictive]) =>
249
+ genericDirectives.has(restrictive) && specificDirectives.has(permissive),
250
+ );
251
+ if (effectiveConflict && !conflictReported) {
252
+ addFinding(findings, targetUrl, {
253
+ code: "conflicting-robots",
254
+ severity: "warning",
255
+ message: `${probe.agent.label} received contradictory effective robots directives.`,
256
+ agent: probe.agent.key,
257
+ evidence: {
258
+ audience: "effective",
259
+ count: effectiveSignals.length,
260
+ distinctValues: new Set([...genericDirectives, ...specificDirectives]).size,
261
+ values: [...new Set([...genericDirectives, ...specificDirectives])].sort().join(","),
262
+ },
263
+ });
264
+ }
265
+ }
266
+
267
+ function criticalArrivalMs(target: AuditTarget, probe: ProbeResult): number | undefined {
268
+ const marks: number[] = [];
269
+ const { expectations } = target;
270
+
271
+ if (expectations.requireTitle && normalizeText(probe.signals.title?.value ?? "").length > 0) {
272
+ marks.push(probe.signals.title?.atMs ?? 0);
273
+ }
274
+ if (expectations.requireDescription) {
275
+ const description = probe.signals.descriptions.find(
276
+ (signal) => normalizeText(signal.value).length > 0,
277
+ );
278
+ if (description !== undefined) {
279
+ marks.push(description.atMs);
280
+ }
281
+ }
282
+ if (expectations.requireCanonical) {
283
+ const canonical = probe.signals.canonicals.find(
284
+ (signal) => normalizeCanonical(signal.value, probe.finalUrl).length > 0,
285
+ );
286
+ if (canonical !== undefined) {
287
+ marks.push(canonical.atMs);
288
+ }
289
+ }
290
+ if (expectations.requireH1) {
291
+ const h1 = probe.signals.h1s.find((signal) => normalizeText(signal.value).length > 0);
292
+ if (h1 !== undefined) {
293
+ marks.push(h1.atMs);
294
+ }
295
+ }
296
+ if (
297
+ expectations.requireMainText &&
298
+ normalizeText(probe.signals.firstMainText?.value ?? "").length > 0
299
+ ) {
300
+ marks.push(probe.signals.firstMainText?.atMs ?? 0);
301
+ }
302
+
303
+ return marks.length === 0 ? undefined : Math.max(...marks);
304
+ }
305
+
306
+ function checkRequiredSignals(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
307
+ // An interrupted or deliberately truncated stream cannot prove that an element is absent.
308
+ if (probe.completion !== "complete") {
309
+ return;
310
+ }
311
+
312
+ const { expectations } = target;
313
+ const agent = probe.agent.key;
314
+ if (expectations.requireTitle && normalizeText(probe.signals.title?.value ?? "").length === 0) {
315
+ addFinding(findings, target.url, {
316
+ code: "missing-title",
317
+ severity: "error",
318
+ message: `${probe.agent.label} received no non-empty title.`,
319
+ agent,
320
+ });
321
+ }
322
+ if (
323
+ expectations.requireDescription &&
324
+ normalizedValues("description", probe.signals.descriptions, probe.finalUrl).length === 0
325
+ ) {
326
+ addFinding(findings, target.url, {
327
+ code: "missing-description",
328
+ severity: "warning",
329
+ message: `${probe.agent.label} received no non-empty meta description.`,
330
+ agent,
331
+ });
332
+ }
333
+ if (
334
+ expectations.requireCanonical &&
335
+ normalizedValues("canonical", probe.signals.canonicals, probe.finalUrl).length === 0
336
+ ) {
337
+ addFinding(findings, target.url, {
338
+ code: "missing-canonical",
339
+ severity: "warning",
340
+ message: `${probe.agent.label} received no non-empty canonical link.`,
341
+ agent,
342
+ });
343
+ }
344
+ if (
345
+ expectations.requireH1 &&
346
+ !probe.signals.h1s.some((signal) => normalizeText(signal.value).length > 0)
347
+ ) {
348
+ addFinding(findings, target.url, {
349
+ code: "missing-h1",
350
+ severity: "warning",
351
+ message: `${probe.agent.label} received no non-empty H1.`,
352
+ agent,
353
+ });
354
+ }
355
+ if (
356
+ expectations.requireMainText &&
357
+ normalizeText(probe.signals.firstMainText?.value ?? "").length === 0
358
+ ) {
359
+ addFinding(findings, target.url, {
360
+ code: "missing-main-text",
361
+ severity: "warning",
362
+ message: `${probe.agent.label} received no main-content text.`,
363
+ agent,
364
+ });
365
+ }
366
+ }
367
+
368
+ function checkHeadRequirements(findings: Finding[], targetUrl: string, probe: ProbeResult): void {
369
+ if (!probe.agent.requiresHeadMetadata) {
370
+ return;
371
+ }
372
+
373
+ const bodyFields = new Set<string>();
374
+ const titles = probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []);
375
+ if (titles.some((signal) => signal.location === "body")) {
376
+ bodyFields.add("title");
377
+ }
378
+ if (probe.signals.descriptions.some((signal) => signal.location === "body")) {
379
+ bodyFields.add("description");
380
+ }
381
+ if (probe.signals.canonicals.some((signal) => signal.location === "body")) {
382
+ bodyFields.add("canonical");
383
+ }
384
+ if (effectiveRobotsSignals(probe).some((signal) => signal.location === "body")) {
385
+ bodyFields.add("robots");
386
+ }
387
+
388
+ if (bodyFields.size === 0) {
389
+ return;
390
+ }
391
+
392
+ addFinding(findings, targetUrl, {
393
+ code: "head-metadata-in-body",
394
+ severity: "error",
395
+ message: `${probe.agent.label} requires head metadata but received ${[...bodyFields].join(
396
+ ", ",
397
+ )} in the body.`,
398
+ agent: probe.agent.key,
399
+ evidence: { fields: [...bodyFields].join(", ") },
400
+ });
401
+ }
402
+
403
+ function checkTimings(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
404
+ const { expectations } = target;
405
+ const firstByteMs = probe.timings.firstByteMs;
406
+ if (
407
+ expectations.maxFirstByteMs !== undefined &&
408
+ firstByteMs !== undefined &&
409
+ firstByteMs > expectations.maxFirstByteMs
410
+ ) {
411
+ addFinding(findings, target.url, {
412
+ code: "slow-first-byte",
413
+ severity: "warning",
414
+ message: `${probe.agent.label} first byte arrived after the configured limit.`,
415
+ agent: probe.agent.key,
416
+ evidence: { observedMs: firstByteMs, limitMs: expectations.maxFirstByteMs },
417
+ });
418
+ }
419
+
420
+ const arrivalMs = criticalArrivalMs(target, probe);
421
+ if (
422
+ expectations.maxCriticalMs !== undefined &&
423
+ arrivalMs !== undefined &&
424
+ arrivalMs > expectations.maxCriticalMs
425
+ ) {
426
+ addFinding(findings, target.url, {
427
+ code: "slow-critical-signals",
428
+ severity: "warning",
429
+ message: `${probe.agent.label} required signals arrived after the configured limit.`,
430
+ agent: probe.agent.key,
431
+ evidence: { observedMs: arrivalMs, limitMs: expectations.maxCriticalMs },
432
+ });
433
+ }
434
+ }
435
+
436
+ function agentValueSummary(
437
+ probes: readonly ProbeResult[],
438
+ valueFor: (probe: ProbeResult) => string,
439
+ ): string {
440
+ return probes.map((probe) => `${probe.agent.key}=${valueFor(probe)}`).join("; ");
441
+ }
442
+
443
+ function checkAgentDrift(
444
+ findings: Finding[],
445
+ targetUrl: string,
446
+ probes: readonly ProbeResult[],
447
+ ): void {
448
+ const complete = probes.filter((probe) => probe.completion === "complete");
449
+ if (complete.length < 2) {
450
+ return;
451
+ }
452
+
453
+ const comparisons: readonly {
454
+ readonly field: string;
455
+ readonly valueFor: (probe: ProbeResult) => string;
456
+ }[] = [
457
+ { field: "status", valueFor: (probe) => String(probe.status ?? "missing") },
458
+ { field: "final-url", valueFor: (probe) => normalizeFinalUrl(probe.finalUrl) },
459
+ {
460
+ field: "title",
461
+ valueFor: (probe) => normalizeText(probe.signals.title?.value ?? "<missing>"),
462
+ },
463
+ {
464
+ field: "canonical",
465
+ valueFor: (probe) =>
466
+ [...new Set(normalizedValues("canonical", probe.signals.canonicals, probe.finalUrl))]
467
+ .sort()
468
+ .join(" | ") || "<missing>",
469
+ },
470
+ {
471
+ field: "robots",
472
+ valueFor: effectiveRobotsValue,
473
+ },
474
+ ];
475
+
476
+ for (const comparison of comparisons) {
477
+ const values = complete.map(comparison.valueFor);
478
+ if (new Set(values).size < 2) {
479
+ continue;
480
+ }
481
+
482
+ addFinding(findings, targetUrl, {
483
+ code: `agent-${comparison.field}-drift`,
484
+ severity: "warning",
485
+ message: `Crawler profiles received different ${comparison.field.replace("-", " ")} values.`,
486
+ evidence: { values: agentValueSummary(complete, comparison.valueFor) },
487
+ });
488
+ }
489
+ }
490
+
491
+ export function analyzeTarget(
492
+ target: AuditTarget,
493
+ probes: readonly ProbeResult[],
494
+ ): readonly Finding[] {
495
+ const findings: Finding[] = [];
496
+
497
+ for (const probe of probes) {
498
+ // A status retained from the previous redirect is not evidence that a final response arrived.
499
+ const hasFinalResponse = probe.status !== undefined && !REDIRECT_STATUSES.has(probe.status);
500
+ if (probe.completion !== "complete") {
501
+ addFinding(findings, target.url, {
502
+ code: "incomplete-probe",
503
+ severity: "error",
504
+ message: `${probe.agent.label} probe did not complete (${probe.completion}).`,
505
+ agent: probe.agent.key,
506
+ evidence: {
507
+ completion: probe.completion,
508
+ bytesRead: probe.bytesRead,
509
+ ...(probe.error === undefined ? {} : { error: probe.error }),
510
+ },
511
+ });
512
+ }
513
+
514
+ if (
515
+ probe.status !== undefined &&
516
+ !REDIRECT_STATUSES.has(probe.status) &&
517
+ !target.expectations.statuses.includes(probe.status)
518
+ ) {
519
+ addFinding(findings, target.url, {
520
+ code: "status-mismatch",
521
+ severity: "error",
522
+ message: `${probe.agent.label} returned unexpected HTTP status ${probe.status}.`,
523
+ agent: probe.agent.key,
524
+ evidence: {
525
+ actual: probe.status,
526
+ expected: target.expectations.statuses.join(", "),
527
+ },
528
+ });
529
+ }
530
+
531
+ if (
532
+ hasFinalResponse &&
533
+ target.expectations.finalUrl !== undefined &&
534
+ normalizeFinalUrl(probe.finalUrl) !== normalizeFinalUrl(target.expectations.finalUrl)
535
+ ) {
536
+ addFinding(findings, target.url, {
537
+ code: "final-url-mismatch",
538
+ severity: "error",
539
+ message: `${probe.agent.label} finished at an unexpected URL.`,
540
+ agent: probe.agent.key,
541
+ evidence: {
542
+ actual: probe.finalUrl,
543
+ expected: target.expectations.finalUrl,
544
+ },
545
+ });
546
+ }
547
+
548
+ checkRequiredSignals(findings, target, probe);
549
+ checkRepeatedMetadata(
550
+ findings,
551
+ target.url,
552
+ probe,
553
+ "title",
554
+ probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []),
555
+ );
556
+ checkRepeatedMetadata(findings, target.url, probe, "description", probe.signals.descriptions);
557
+ checkRepeatedMetadata(findings, target.url, probe, "canonical", probe.signals.canonicals);
558
+ checkRepeatedRobots(findings, target.url, probe);
559
+
560
+ const invalidJsonLd = probe.signals.jsonLd.filter((signal) => signal.valid === false);
561
+ if (probe.completion === "complete" && invalidJsonLd.length > 0) {
562
+ addFinding(findings, target.url, {
563
+ code: "invalid-json-ld",
564
+ severity: "warning",
565
+ message: `${probe.agent.label} received invalid JSON-LD.`,
566
+ agent: probe.agent.key,
567
+ evidence: {
568
+ invalidBlocks: invalidJsonLd.length,
569
+ errors: invalidJsonLd.map((signal) => signal.error ?? "invalid JSON").join(" | "),
570
+ },
571
+ });
572
+ }
573
+
574
+ const limitedJsonLd = probe.signals.jsonLd.filter(
575
+ (signal) => signal.valid === undefined && signal.analysisLimit !== undefined,
576
+ );
577
+ if (limitedJsonLd.length > 0) {
578
+ addFinding(findings, target.url, {
579
+ code: "json-ld-analysis-limit",
580
+ severity: "warning",
581
+ message: `${probe.agent.label} exceeded an SSRWire JSON-LD analysis limit.`,
582
+ agent: probe.agent.key,
583
+ evidence: {
584
+ blocks: limitedJsonLd.length,
585
+ reasons: limitedJsonLd.map((signal) => signal.analysisLimit).join(" | "),
586
+ },
587
+ });
588
+ }
589
+
590
+ checkHeadRequirements(findings, target.url, probe);
591
+ checkTimings(findings, target, probe);
592
+ }
593
+
594
+ checkAgentDrift(findings, target.url, probes);
595
+
596
+ return findings;
597
+ }
598
+
599
+ export function summarizeAudit(results: readonly TargetAuditResult[]): AuditSummary {
600
+ let errors = 0;
601
+ let warnings = 0;
602
+ let info = 0;
603
+ let probes = 0;
604
+ let incomplete = 0;
605
+
606
+ for (const result of results) {
607
+ probes += result.probes.length;
608
+ incomplete += result.probes.filter((probe) => probe.completion !== "complete").length;
609
+ for (const finding of result.findings) {
610
+ if (finding.severity === "error") {
611
+ errors += 1;
612
+ } else if (finding.severity === "warning") {
613
+ warnings += 1;
614
+ } else {
615
+ info += 1;
616
+ }
617
+ }
618
+ }
619
+
620
+ return {
621
+ targets: results.length,
622
+ probes,
623
+ errors,
624
+ warnings,
625
+ info,
626
+ incomplete,
627
+ };
628
+ }
package/src/audit.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { analyzeTarget, summarizeAudit } from "./analyze.js";
2
+ import { probeUrl } from "./http-probe.js";
3
+ import { redactAudit } from "./redact.js";
4
+ import type { AuditResult, ProbeOptions, SsrWireConfig, TargetAuditResult } from "./types.js";
5
+ import { VERSION } from "./version.js";
6
+
7
+ const DEFAULT_CONCURRENCY = 4;
8
+
9
+ interface ProbeTask {
10
+ readonly targetIndex: number;
11
+ readonly agentIndex: number;
12
+ readonly options: ProbeOptions;
13
+ }
14
+
15
+ async function runPool<T, R>(
16
+ inputs: readonly T[],
17
+ limit: number,
18
+ worker: (input: T) => Promise<R>,
19
+ ): Promise<R[]> {
20
+ const results = new Array<R>(inputs.length);
21
+ let cursor = 0;
22
+
23
+ async function consume(): Promise<void> {
24
+ while (cursor < inputs.length) {
25
+ const index = cursor;
26
+ cursor += 1;
27
+ const input = inputs[index];
28
+ if (input === undefined) {
29
+ continue;
30
+ }
31
+ results[index] = await worker(input);
32
+ }
33
+ }
34
+
35
+ const workers = Array.from({ length: Math.min(limit, inputs.length) }, () => consume());
36
+ await Promise.all(workers);
37
+ return results;
38
+ }
39
+
40
+ export async function runAudit(config: SsrWireConfig): Promise<AuditResult> {
41
+ const started = performance.now();
42
+ const tasks: ProbeTask[] = [];
43
+
44
+ for (const [targetIndex, target] of config.targets.entries()) {
45
+ for (const [agentIndex, agent] of config.agents.entries()) {
46
+ tasks.push({
47
+ targetIndex,
48
+ agentIndex,
49
+ options: {
50
+ url: target.url,
51
+ agent,
52
+ headers: config.headers,
53
+ timeoutMs: config.timeoutMs,
54
+ maxBytes: config.maxBytes,
55
+ maxRedirects: config.maxRedirects,
56
+ redactHeaderValues: false,
57
+ },
58
+ });
59
+ }
60
+ }
61
+
62
+ const secrets = Object.values(config.headers);
63
+ const probes = await runPool(tasks, DEFAULT_CONCURRENCY, async (task) => {
64
+ const probe = await probeUrl(task.options);
65
+ return { task, probe };
66
+ });
67
+
68
+ const results: TargetAuditResult[] = config.targets.map((target, targetIndex) => {
69
+ const targetProbes = probes
70
+ .filter((item) => item.task.targetIndex === targetIndex)
71
+ .sort((a, b) => a.task.agentIndex - b.task.agentIndex)
72
+ .map((item) => item.probe);
73
+
74
+ return {
75
+ target,
76
+ probes: targetProbes,
77
+ findings: analyzeTarget(target, targetProbes),
78
+ };
79
+ });
80
+
81
+ const audit: AuditResult = {
82
+ version: VERSION,
83
+ generatedAt: new Date().toISOString(),
84
+ durationMs: Math.round(performance.now() - started),
85
+ results,
86
+ summary: summarizeAudit(results),
87
+ };
88
+ return redactAudit(audit, secrets);
89
+ }