ssrwire 0.1.0 → 0.3.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 (58) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/CONTRIBUTING.md +8 -5
  3. package/PUBLISHING.md +92 -78
  4. package/README.md +117 -28
  5. package/SECURITY.md +8 -4
  6. package/dist/analyze.d.ts.map +1 -1
  7. package/dist/analyze.js +162 -6
  8. package/dist/analyze.js.map +1 -1
  9. package/dist/audit.d.ts.map +1 -1
  10. package/dist/audit.js +82 -7
  11. package/dist/audit.js.map +1 -1
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +6 -1
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.d.ts +1 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +11 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/http-probe.d.ts.map +1 -1
  20. package/dist/http-probe.js +4 -0
  21. package/dist/http-probe.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/redact.d.ts.map +1 -1
  26. package/dist/redact.js +17 -0
  27. package/dist/redact.js.map +1 -1
  28. package/dist/reporters.d.ts.map +1 -1
  29. package/dist/reporters.js +69 -2
  30. package/dist/reporters.js.map +1 -1
  31. package/dist/social.d.ts +14 -0
  32. package/dist/social.d.ts.map +1 -0
  33. package/dist/social.js +88 -0
  34. package/dist/social.js.map +1 -0
  35. package/dist/stability.d.ts +9 -0
  36. package/dist/stability.d.ts.map +1 -0
  37. package/dist/stability.js +326 -0
  38. package/dist/stability.js.map +1 -0
  39. package/dist/stream-parser.d.ts.map +1 -1
  40. package/dist/stream-parser.js +21 -1
  41. package/dist/stream-parser.js.map +1 -1
  42. package/dist/types.d.ts +47 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/examples/github-actions.yml +2 -2
  45. package/examples/ssrwire.config.yml +8 -0
  46. package/package.json +5 -2
  47. package/src/analyze.ts +208 -7
  48. package/src/audit.ts +116 -8
  49. package/src/cli.ts +7 -1
  50. package/src/config.ts +12 -0
  51. package/src/http-probe.ts +4 -0
  52. package/src/index.ts +6 -0
  53. package/src/redact.ts +17 -0
  54. package/src/reporters.ts +97 -2
  55. package/src/social.ts +116 -0
  56. package/src/stability.ts +475 -0
  57. package/src/stream-parser.ts +31 -1
  58. package/src/types.ts +62 -0
@@ -9,6 +9,8 @@ targets:
9
9
  canonical: true
10
10
  h1: true
11
11
  mainText: true
12
+ openGraph: true
13
+ twitterCard: true
12
14
 
13
15
  - url: https://example.com/pricing/
14
16
  expectedStatus: 200
@@ -18,6 +20,8 @@ targets:
18
20
  canonical: true
19
21
  h1: true
20
22
  mainText: true
23
+ openGraph: false
24
+ twitterCard: false
21
25
 
22
26
  # Add maxFirstByteMs or maxCriticalMs per target only when the runner location
23
27
  # and cache state are stable enough for a meaningful threshold.
@@ -32,6 +36,10 @@ timeoutMs: 15000
32
36
  maxBytes: 10485760
33
37
  maxRedirects: 10
34
38
 
39
+ # Run each target-agent pair sequentially three times to expose intermittent SSR output.
40
+ # Total requests are targets × agents × repeat, plus redirects.
41
+ repeat: 3
42
+
35
43
  # Keep preview credentials in a CI secret or exported environment variable.
36
44
  # headers:
37
45
  # x-preview-token: "${PREVIEW_TOKEN}"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssrwire",
3
- "version": "0.1.0",
4
- "description": "Inspect streamed SSR HTML, metadata timing, and crawler-specific delivery from the command line.",
3
+ "version": "0.3.0",
4
+ "description": "Inspect streamed SSR HTML, SEO and social metadata timing, and crawler-specific delivery from the command line.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ssrwire": "./dist/bin.js"
@@ -52,6 +52,9 @@
52
52
  "cli",
53
53
  "typescript",
54
54
  "seo-tools",
55
+ "open-graph",
56
+ "social-preview",
57
+ "twitter-cards",
55
58
  "github-actions",
56
59
  "sarif"
57
60
  ],
package/src/analyze.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import {
2
+ effectiveTwitterCardSignal,
3
+ firstSocialSignal,
4
+ isAbsoluteHttpSocialUrl,
5
+ normalizeSocialValue,
6
+ OPEN_GRAPH_PROPERTIES,
7
+ OPEN_GRAPH_REQUIRED_PROPERTIES,
8
+ socialSignals,
9
+ TWITTER_CARD_REQUIRED_FIELDS,
10
+ TWITTER_PROPERTIES,
11
+ } from "./social.js";
1
12
  import type {
2
13
  AuditSummary,
3
14
  AuditTarget,
@@ -7,6 +18,8 @@ import type {
7
18
  RobotsAudience,
8
19
  RobotsSignal,
9
20
  Severity,
21
+ SocialMetadataProperty,
22
+ SocialMetadataSignal,
10
23
  TargetAuditResult,
11
24
  } from "./types.js";
12
25
 
@@ -264,6 +277,146 @@ function checkRepeatedRobots(findings: Finding[], targetUrl: string, probe: Prob
264
277
  }
265
278
  }
266
279
 
280
+ function auditedSocialProperties(
281
+ target: AuditTarget,
282
+ probe: ProbeResult,
283
+ ): readonly SocialMetadataProperty[] {
284
+ const properties = new Set<SocialMetadataProperty>();
285
+ if (target.expectations.requireOpenGraph === true) {
286
+ for (const property of OPEN_GRAPH_PROPERTIES) properties.add(property);
287
+ }
288
+ if (target.expectations.requireTwitterCard === true) {
289
+ for (const property of TWITTER_PROPERTIES) properties.add(property);
290
+ if (firstSocialSignal(probe.signals, "twitter:title") === undefined) {
291
+ properties.add("og:title");
292
+ }
293
+ if (firstSocialSignal(probe.signals, "twitter:description") === undefined) {
294
+ properties.add("og:description");
295
+ }
296
+ if (firstSocialSignal(probe.signals, "twitter:image") === undefined) {
297
+ properties.add("og:image");
298
+ }
299
+ }
300
+ return [...properties];
301
+ }
302
+
303
+ function normalizedSocialValues(
304
+ property: SocialMetadataProperty,
305
+ signals: readonly SocialMetadataSignal[],
306
+ baseUrl: string,
307
+ ): readonly string[] {
308
+ return signals
309
+ .map((signal) => normalizeSocialValue(property, signal.value, baseUrl))
310
+ .filter((value) => value.length > 0);
311
+ }
312
+
313
+ function checkRepeatedSocialMetadata(
314
+ findings: Finding[],
315
+ target: AuditTarget,
316
+ probe: ProbeResult,
317
+ ): void {
318
+ for (const property of auditedSocialProperties(target, probe)) {
319
+ // Open Graph explicitly permits multiple images and gives the first one precedence.
320
+ if (property === "og:image") continue;
321
+ const values = normalizedSocialValues(
322
+ property,
323
+ socialSignals(probe.signals, property),
324
+ probe.finalUrl,
325
+ );
326
+ if (values.length < 2) continue;
327
+
328
+ const distinct = new Set(values);
329
+ const conflicting = distinct.size > 1;
330
+ addFinding(findings, target.url, {
331
+ code: conflicting ? "conflicting-social-metadata" : "duplicate-social-metadata",
332
+ severity: "warning",
333
+ message: conflicting
334
+ ? `${probe.agent.label} received conflicting ${property} values.`
335
+ : `${probe.agent.label} received duplicate ${property} values.`,
336
+ agent: probe.agent.key,
337
+ evidence: {
338
+ property,
339
+ count: values.length,
340
+ distinctValues: distinct.size,
341
+ values: [...distinct].join(" | "),
342
+ },
343
+ });
344
+ }
345
+ }
346
+
347
+ function checkSocialUrls(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
348
+ const candidates = new Set<SocialMetadataSignal>();
349
+ if (target.expectations.requireOpenGraph === true) {
350
+ for (const signal of socialSignals(probe.signals, "og:url")) candidates.add(signal);
351
+ for (const signal of socialSignals(probe.signals, "og:image")) candidates.add(signal);
352
+ }
353
+ if (target.expectations.requireTwitterCard === true) {
354
+ const image = effectiveTwitterCardSignal(probe.signals, "image");
355
+ if (image !== undefined) candidates.add(image);
356
+ }
357
+
358
+ for (const signal of candidates) {
359
+ if (signal.value.trim().length === 0 || isAbsoluteHttpSocialUrl(signal.value)) continue;
360
+ addFinding(findings, target.url, {
361
+ code: "invalid-social-metadata-url",
362
+ severity: "warning",
363
+ message: `${probe.agent.label} received a non-absolute HTTP(S) ${signal.property} URL.`,
364
+ agent: probe.agent.key,
365
+ evidence: { property: signal.property, value: signal.value },
366
+ });
367
+ }
368
+ }
369
+
370
+ function checkRequiredSocialMetadata(
371
+ findings: Finding[],
372
+ target: AuditTarget,
373
+ probe: ProbeResult,
374
+ ): void {
375
+ if (probe.completion !== "complete") return;
376
+
377
+ if (target.expectations.requireOpenGraph === true) {
378
+ const missing = OPEN_GRAPH_REQUIRED_PROPERTIES.filter(
379
+ (property) => firstSocialSignal(probe.signals, property) === undefined,
380
+ );
381
+ if (missing.length > 0) {
382
+ addFinding(findings, target.url, {
383
+ code: "missing-open-graph-metadata",
384
+ severity: "warning",
385
+ message: `${probe.agent.label} received an incomplete Open Graph metadata set.`,
386
+ agent: probe.agent.key,
387
+ evidence: { fields: missing.join(", ") },
388
+ });
389
+ }
390
+ }
391
+
392
+ if (target.expectations.requireTwitterCard === true) {
393
+ const missing = TWITTER_CARD_REQUIRED_FIELDS.filter(
394
+ (field) => effectiveTwitterCardSignal(probe.signals, field) === undefined,
395
+ );
396
+ if (missing.length > 0) {
397
+ addFinding(findings, target.url, {
398
+ code: "missing-twitter-card-metadata",
399
+ severity: "warning",
400
+ message: `${probe.agent.label} received an incomplete Twitter Card metadata set.`,
401
+ agent: probe.agent.key,
402
+ evidence: { fields: missing.join(", ") },
403
+ });
404
+ }
405
+ }
406
+ }
407
+
408
+ function checkSocialMetadata(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
409
+ if (
410
+ target.expectations.requireOpenGraph !== true &&
411
+ target.expectations.requireTwitterCard !== true
412
+ ) {
413
+ return;
414
+ }
415
+ checkRequiredSocialMetadata(findings, target, probe);
416
+ checkRepeatedSocialMetadata(findings, target, probe);
417
+ checkSocialUrls(findings, target, probe);
418
+ }
419
+
267
420
  function criticalArrivalMs(target: AuditTarget, probe: ProbeResult): number | undefined {
268
421
  const marks: number[] = [];
269
422
  const { expectations } = target;
@@ -299,6 +452,20 @@ function criticalArrivalMs(target: AuditTarget, probe: ProbeResult): number | un
299
452
  ) {
300
453
  marks.push(probe.signals.firstMainText?.atMs ?? 0);
301
454
  }
455
+ if (expectations.requireOpenGraph === true) {
456
+ const signals = OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) =>
457
+ firstSocialSignal(probe.signals, property),
458
+ );
459
+ if (!signals.every((signal) => signal !== undefined)) return undefined;
460
+ marks.push(...signals.map((signal) => signal.atMs));
461
+ }
462
+ if (expectations.requireTwitterCard === true) {
463
+ const signals = TWITTER_CARD_REQUIRED_FIELDS.map((field) =>
464
+ effectiveTwitterCardSignal(probe.signals, field),
465
+ );
466
+ if (!signals.every((signal) => signal !== undefined)) return undefined;
467
+ marks.push(...signals.map((signal) => signal.atMs));
468
+ }
302
469
 
303
470
  return marks.length === 0 ? undefined : Math.max(...marks);
304
471
  }
@@ -365,7 +532,7 @@ function checkRequiredSignals(findings: Finding[], target: AuditTarget, probe: P
365
532
  }
366
533
  }
367
534
 
368
- function checkHeadRequirements(findings: Finding[], targetUrl: string, probe: ProbeResult): void {
535
+ function checkHeadRequirements(findings: Finding[], target: AuditTarget, probe: ProbeResult): void {
369
536
  if (!probe.agent.requiresHeadMetadata) {
370
537
  return;
371
538
  }
@@ -384,12 +551,17 @@ function checkHeadRequirements(findings: Finding[], targetUrl: string, probe: Pr
384
551
  if (effectiveRobotsSignals(probe).some((signal) => signal.location === "body")) {
385
552
  bodyFields.add("robots");
386
553
  }
554
+ for (const property of auditedSocialProperties(target, probe)) {
555
+ if (socialSignals(probe.signals, property).some((signal) => signal.location === "body")) {
556
+ bodyFields.add(property);
557
+ }
558
+ }
387
559
 
388
560
  if (bodyFields.size === 0) {
389
561
  return;
390
562
  }
391
563
 
392
- addFinding(findings, targetUrl, {
564
+ addFinding(findings, target.url, {
393
565
  code: "head-metadata-in-body",
394
566
  severity: "error",
395
567
  message: `${probe.agent.label} requires head metadata but received ${[...bodyFields].join(
@@ -440,9 +612,31 @@ function agentValueSummary(
440
612
  return probes.map((probe) => `${probe.agent.key}=${valueFor(probe)}`).join("; ");
441
613
  }
442
614
 
615
+ function openGraphValue(probe: ProbeResult): string {
616
+ return OPEN_GRAPH_PROPERTIES.map((property) => {
617
+ const values = [
618
+ ...new Set(
619
+ normalizedSocialValues(property, socialSignals(probe.signals, property), probe.finalUrl),
620
+ ),
621
+ ];
622
+ return `${property}=${values.join(" | ") || "<missing>"}`;
623
+ }).join(", ");
624
+ }
625
+
626
+ function twitterCardValue(probe: ProbeResult): string {
627
+ return TWITTER_CARD_REQUIRED_FIELDS.map((field) => {
628
+ const signal = effectiveTwitterCardSignal(probe.signals, field);
629
+ const value =
630
+ signal === undefined
631
+ ? "<missing>"
632
+ : normalizeSocialValue(signal.property, signal.value, probe.finalUrl);
633
+ return `${field}=${value || "<missing>"}`;
634
+ }).join(", ");
635
+ }
636
+
443
637
  function checkAgentDrift(
444
638
  findings: Finding[],
445
- targetUrl: string,
639
+ target: AuditTarget,
446
640
  probes: readonly ProbeResult[],
447
641
  ): void {
448
642
  const complete = probes.filter((probe) => probe.completion === "complete");
@@ -450,7 +644,7 @@ function checkAgentDrift(
450
644
  return;
451
645
  }
452
646
 
453
- const comparisons: readonly {
647
+ const comparisons: {
454
648
  readonly field: string;
455
649
  readonly valueFor: (probe: ProbeResult) => string;
456
650
  }[] = [
@@ -472,6 +666,12 @@ function checkAgentDrift(
472
666
  valueFor: effectiveRobotsValue,
473
667
  },
474
668
  ];
669
+ if (target.expectations.requireOpenGraph === true) {
670
+ comparisons.push({ field: "open-graph", valueFor: openGraphValue });
671
+ }
672
+ if (target.expectations.requireTwitterCard === true) {
673
+ comparisons.push({ field: "twitter-card", valueFor: twitterCardValue });
674
+ }
475
675
 
476
676
  for (const comparison of comparisons) {
477
677
  const values = complete.map(comparison.valueFor);
@@ -479,7 +679,7 @@ function checkAgentDrift(
479
679
  continue;
480
680
  }
481
681
 
482
- addFinding(findings, targetUrl, {
682
+ addFinding(findings, target.url, {
483
683
  code: `agent-${comparison.field}-drift`,
484
684
  severity: "warning",
485
685
  message: `Crawler profiles received different ${comparison.field.replace("-", " ")} values.`,
@@ -556,6 +756,7 @@ export function analyzeTarget(
556
756
  checkRepeatedMetadata(findings, target.url, probe, "description", probe.signals.descriptions);
557
757
  checkRepeatedMetadata(findings, target.url, probe, "canonical", probe.signals.canonicals);
558
758
  checkRepeatedRobots(findings, target.url, probe);
759
+ checkSocialMetadata(findings, target, probe);
559
760
 
560
761
  const invalidJsonLd = probe.signals.jsonLd.filter((signal) => signal.valid === false);
561
762
  if (probe.completion === "complete" && invalidJsonLd.length > 0) {
@@ -587,11 +788,11 @@ export function analyzeTarget(
587
788
  });
588
789
  }
589
790
 
590
- checkHeadRequirements(findings, target.url, probe);
791
+ checkHeadRequirements(findings, target, probe);
591
792
  checkTimings(findings, target, probe);
592
793
  }
593
794
 
594
- checkAgentDrift(findings, target.url, probes);
795
+ checkAgentDrift(findings, target, probes);
595
796
 
596
797
  return findings;
597
798
  }
package/src/audit.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import { analyzeTarget, summarizeAudit } from "./analyze.js";
2
2
  import { probeUrl } from "./http-probe.js";
3
3
  import { redactAudit } from "./redact.js";
4
- import type { AuditResult, ProbeOptions, SsrWireConfig, TargetAuditResult } from "./types.js";
4
+ import { analyzeStability } from "./stability.js";
5
+ import type {
6
+ AuditResult,
7
+ Finding,
8
+ ProbeOptions,
9
+ ProbeResult,
10
+ SsrWireConfig,
11
+ TargetAuditResult,
12
+ } from "./types.js";
5
13
  import { VERSION } from "./version.js";
6
14
 
7
15
  const DEFAULT_CONCURRENCY = 4;
@@ -12,6 +20,16 @@ interface ProbeTask {
12
20
  readonly options: ProbeOptions;
13
21
  }
14
22
 
23
+ interface ProbeLane {
24
+ readonly task: ProbeTask;
25
+ readonly probes: readonly ProbeResult[];
26
+ }
27
+
28
+ interface SampleFinding {
29
+ readonly sample: number;
30
+ readonly finding: Finding;
31
+ }
32
+
15
33
  async function runPool<T, R>(
16
34
  inputs: readonly T[],
17
35
  limit: number,
@@ -37,8 +55,75 @@ async function runPool<T, R>(
37
55
  return results;
38
56
  }
39
57
 
58
+ function repeatCount(value: number | undefined): number {
59
+ const repeat = value ?? 1;
60
+ if (!Number.isInteger(repeat) || repeat < 1 || repeat > 10) {
61
+ throw new RangeError("repeat must be an integer between 1 and 10.");
62
+ }
63
+ return repeat;
64
+ }
65
+
66
+ function mergeEvidence(
67
+ sampled: readonly SampleFinding[],
68
+ repeat: number,
69
+ ): Readonly<Record<string, string | number | boolean>> {
70
+ const merged: Record<string, string | number | boolean> & {
71
+ occurrences?: number;
72
+ sampleNumbers?: string;
73
+ totalSamples?: number;
74
+ } = {};
75
+ const keys = [
76
+ ...new Set(sampled.flatMap(({ finding }) => Object.keys(finding.evidence ?? {}))),
77
+ ].sort();
78
+
79
+ for (const key of keys) {
80
+ const values = sampled.flatMap(({ finding }) => {
81
+ const value = finding.evidence?.[key];
82
+ return value === undefined ? [] : [value];
83
+ });
84
+ const unique = [
85
+ ...new Map(values.map((value) => [`${typeof value}:${String(value)}`, value])).values(),
86
+ ];
87
+ const first = unique[0];
88
+ if (first !== undefined) {
89
+ merged[key] = unique.length === 1 ? first : unique.map(String).join(" | ");
90
+ }
91
+ }
92
+
93
+ merged.sampleNumbers = [...new Set(sampled.map((item) => item.sample))]
94
+ .sort((a, b) => a - b)
95
+ .join(", ");
96
+ merged.occurrences = sampled.length;
97
+ merged.totalSamples = repeat;
98
+ return merged;
99
+ }
100
+
101
+ function coalesceFindings(sampled: readonly SampleFinding[], repeat: number): readonly Finding[] {
102
+ const groups = new Map<string, SampleFinding[]>();
103
+ for (const item of sampled) {
104
+ const { finding } = item;
105
+ const key = JSON.stringify([
106
+ finding.code,
107
+ finding.severity,
108
+ finding.message,
109
+ finding.url,
110
+ finding.agent ?? "",
111
+ ]);
112
+ const group = groups.get(key) ?? [];
113
+ group.push(item);
114
+ groups.set(key, group);
115
+ }
116
+
117
+ return [...groups.values()].map((group) => {
118
+ const first = group[0]?.finding;
119
+ if (first === undefined) throw new Error("Cannot coalesce an empty finding group.");
120
+ return { ...first, evidence: mergeEvidence(group, repeat) };
121
+ });
122
+ }
123
+
40
124
  export async function runAudit(config: SsrWireConfig): Promise<AuditResult> {
41
125
  const started = performance.now();
126
+ const repeat = repeatCount(config.repeat);
42
127
  const tasks: ProbeTask[] = [];
43
128
 
44
129
  for (const [targetIndex, target] of config.targets.entries()) {
@@ -60,21 +145,43 @@ export async function runAudit(config: SsrWireConfig): Promise<AuditResult> {
60
145
  }
61
146
 
62
147
  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 };
148
+ const lanes = await runPool<ProbeTask, ProbeLane>(tasks, DEFAULT_CONCURRENCY, async (task) => {
149
+ const probes: ProbeResult[] = [];
150
+ for (let sample = 1; sample <= repeat; sample += 1) {
151
+ const probe = await probeUrl(task.options);
152
+ probes.push(repeat === 1 ? probe : { ...probe, sample });
153
+ }
154
+ return { task, probes };
66
155
  });
67
156
 
68
157
  const results: TargetAuditResult[] = config.targets.map((target, targetIndex) => {
69
- const targetProbes = probes
70
- .filter((item) => item.task.targetIndex === targetIndex)
158
+ const targetProbes = lanes
159
+ .filter((lane) => lane.task.targetIndex === targetIndex)
71
160
  .sort((a, b) => a.task.agentIndex - b.task.agentIndex)
72
- .map((item) => item.probe);
161
+ .flatMap((lane) => lane.probes);
162
+
163
+ if (repeat === 1) {
164
+ return {
165
+ target,
166
+ probes: targetProbes,
167
+ findings: analyzeTarget(target, targetProbes),
168
+ };
169
+ }
170
+
171
+ const sampledFindings: SampleFinding[] = [];
172
+ for (let sample = 1; sample <= repeat; sample += 1) {
173
+ const sampleProbes = targetProbes.filter((probe) => probe.sample === sample);
174
+ sampledFindings.push(
175
+ ...analyzeTarget(target, sampleProbes).map((finding) => ({ sample, finding })),
176
+ );
177
+ }
178
+ const stability = analyzeStability(target, targetProbes);
73
179
 
74
180
  return {
75
181
  target,
76
182
  probes: targetProbes,
77
- findings: analyzeTarget(target, targetProbes),
183
+ findings: [...coalesceFindings(sampledFindings, repeat), ...stability.findings],
184
+ stability: stability.stability,
78
185
  };
79
186
  });
80
187
 
@@ -82,6 +189,7 @@ export async function runAudit(config: SsrWireConfig): Promise<AuditResult> {
82
189
  version: VERSION,
83
190
  generatedAt: new Date().toISOString(),
84
191
  durationMs: Math.round(performance.now() - started),
192
+ ...(repeat === 1 ? {} : { repeat }),
85
193
  results,
86
194
  summary: summarizeAudit(results),
87
195
  };
package/src/cli.ts CHANGED
@@ -14,6 +14,7 @@ interface CliOptions {
14
14
  readonly timeout?: number;
15
15
  readonly maxBytes?: number;
16
16
  readonly maxRedirects?: number;
17
+ readonly repeat?: number;
17
18
  readonly format: ReportFormat;
18
19
  readonly output?: string;
19
20
  readonly failOn: "error" | "warning" | "never";
@@ -30,6 +31,8 @@ targets:
30
31
  canonical: true
31
32
  h1: true
32
33
  mainText: true
34
+ openGraph: false
35
+ twitterCard: false
33
36
 
34
37
  agents:
35
38
  - browser
@@ -40,6 +43,7 @@ agents:
40
43
  timeoutMs: 15000
41
44
  maxBytes: 10485760
42
45
  maxRedirects: 10
46
+ repeat: 1
43
47
 
44
48
  # Keep preview credentials in environment variables. SSRWire redacts configured values from reports.
45
49
  # headers:
@@ -80,6 +84,7 @@ function addCheckOptions(command: Command): Command {
80
84
  .option("--timeout <ms>", "request timeout in milliseconds", parseInteger)
81
85
  .option("--max-bytes <bytes>", "maximum response bytes", parseInteger)
82
86
  .option("--max-redirects <count>", "maximum redirects", parseInteger)
87
+ .option("--repeat <count>", "sequential samples per URL and agent", parseInteger)
83
88
  .option("-f, --format <format>", "terminal, json, or sarif", parseFormat, "terminal")
84
89
  .option("-o, --output <path>", "write the report to a file")
85
90
  .option("--fail-on <level>", "error, warning, or never", parseFailOn, "error")
@@ -124,6 +129,7 @@ async function check(urls: readonly string[], options: CliOptions): Promise<void
124
129
  ...(options.timeout === undefined ? {} : { timeoutMs: options.timeout }),
125
130
  ...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
126
131
  ...(options.maxRedirects === undefined ? {} : { maxRedirects: options.maxRedirects }),
132
+ ...(options.repeat === undefined ? {} : { repeat: options.repeat }),
127
133
  });
128
134
  const audit = await runAudit(config);
129
135
  const color =
@@ -164,7 +170,7 @@ export async function main(argv: readonly string[] = process.argv): Promise<void
164
170
  const program = new Command();
165
171
  program
166
172
  .name("ssrwire")
167
- .description("Inspect streamed SSR HTML and crawler-specific metadata delivery.")
173
+ .description("Inspect streamed SSR HTML, SEO, and social metadata delivery.")
168
174
  .version(VERSION)
169
175
  .exitOverride()
170
176
  .showHelpAfterError();
package/src/config.ts CHANGED
@@ -8,6 +8,7 @@ import type { AgentProfile, AuditTarget, SsrWireConfig, TargetExpectations } fro
8
8
  const DEFAULT_TIMEOUT_MS = 15_000;
9
9
  const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
10
10
  const DEFAULT_MAX_REDIRECTS = 10;
11
+ const DEFAULT_REPEAT = 1;
11
12
  const DEFAULT_AGENTS = ["browser", "googlebot", "bingbot", "twitterbot"] as const;
12
13
  const DEFAULT_CONFIG_FILES = [
13
14
  "ssrwire.config.yml",
@@ -22,6 +23,8 @@ const requireSchema = z
22
23
  canonical: z.boolean().optional(),
23
24
  h1: z.boolean().optional(),
24
25
  mainText: z.boolean().optional(),
26
+ openGraph: z.boolean().optional(),
27
+ twitterCard: z.boolean().optional(),
25
28
  })
26
29
  .strict();
27
30
 
@@ -66,6 +69,7 @@ const fileConfigSchema = z
66
69
  .max(50 * 1024 * 1024)
67
70
  .optional(),
68
71
  maxRedirects: z.number().int().min(0).max(20).optional(),
72
+ repeat: z.number().int().min(1).max(10).optional(),
69
73
  })
70
74
  .strict();
71
75
 
@@ -79,6 +83,7 @@ export interface LoadConfigOptions {
79
83
  readonly timeoutMs?: number;
80
84
  readonly maxBytes?: number;
81
85
  readonly maxRedirects?: number;
86
+ readonly repeat?: number;
82
87
  readonly cwd?: string;
83
88
  }
84
89
 
@@ -126,6 +131,8 @@ function normalizeTarget(value: string | z.infer<typeof targetObjectSchema>): Au
126
131
  requireCanonical: required?.canonical ?? true,
127
132
  requireH1: required?.h1 ?? true,
128
133
  requireMainText: required?.mainText ?? true,
134
+ requireOpenGraph: required?.openGraph ?? false,
135
+ requireTwitterCard: required?.twitterCard ?? false,
129
136
  ...(item.maxFirstByteMs === undefined ? {} : { maxFirstByteMs: item.maxFirstByteMs }),
130
137
  ...(item.maxCriticalMs === undefined ? {} : { maxCriticalMs: item.maxCriticalMs }),
131
138
  };
@@ -291,6 +298,7 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<SsrWi
291
298
  const timeoutMs = options.timeoutMs ?? file.timeoutMs ?? DEFAULT_TIMEOUT_MS;
292
299
  const maxBytes = options.maxBytes ?? file.maxBytes ?? DEFAULT_MAX_BYTES;
293
300
  const maxRedirects = options.maxRedirects ?? file.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
301
+ const repeat = options.repeat ?? file.repeat ?? DEFAULT_REPEAT;
294
302
 
295
303
  if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120_000) {
296
304
  throw new ConfigError("timeoutMs must be an integer between 100 and 120000.");
@@ -301,6 +309,9 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<SsrWi
301
309
  if (!Number.isInteger(maxRedirects) || maxRedirects < 0 || maxRedirects > 20) {
302
310
  throw new ConfigError("maxRedirects must be an integer between 0 and 20.");
303
311
  }
312
+ if (!Number.isInteger(repeat) || repeat < 1 || repeat > 10) {
313
+ throw new ConfigError("repeat must be an integer between 1 and 10.");
314
+ }
304
315
 
305
316
  return {
306
317
  targets,
@@ -309,5 +320,6 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<SsrWi
309
320
  timeoutMs,
310
321
  maxBytes,
311
322
  maxRedirects,
323
+ repeat,
312
324
  };
313
325
  }
package/src/http-probe.ts CHANGED
@@ -55,6 +55,7 @@ function emptySignals(): DocumentSignals {
55
55
  descriptions: [],
56
56
  canonicals: [],
57
57
  robots: [],
58
+ socialMetadata: [],
58
59
  h1s: [],
59
60
  jsonLd: [],
60
61
  };
@@ -91,6 +92,9 @@ function redactSignals(signals: DocumentSignals, redaction: RedactionPlan): Docu
91
92
  descriptions: signals.descriptions.map(element),
92
93
  canonicals: signals.canonicals.map(element),
93
94
  robots: signals.robots.map(element),
95
+ ...(signals.socialMetadata === undefined
96
+ ? {}
97
+ : { socialMetadata: signals.socialMetadata.map(element) }),
94
98
  h1s: signals.h1s.map(element),
95
99
  ...(signals.firstMainText === undefined
96
100
  ? {}
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export { renderJson, renderReport, renderSarif, renderTerminal } from "./reporte
8
8
  export { createStreamInspector } from "./stream-parser.js";
9
9
  export type {
10
10
  AgentProfile,
11
+ AgentStability,
11
12
  AuditResult,
12
13
  AuditSummary,
13
14
  AuditTarget,
@@ -26,9 +27,14 @@ export type {
26
27
  RobotsAudience,
27
28
  RobotsSignal,
28
29
  Severity,
30
+ SocialMetadataProperty,
31
+ SocialMetadataSignal,
29
32
  SsrWireConfig,
33
+ StabilityTimings,
34
+ StabilityVariants,
30
35
  TargetAuditResult,
31
36
  TargetExpectations,
32
37
  TimingMark,
38
+ TimingStats,
33
39
  } from "./types.js";
34
40
  export { VERSION } from "./version.js";