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.
- package/CHANGELOG.md +45 -1
- package/CONTRIBUTING.md +8 -5
- package/PUBLISHING.md +92 -78
- package/README.md +117 -28
- package/SECURITY.md +8 -4
- package/dist/analyze.d.ts.map +1 -1
- package/dist/analyze.js +162 -6
- package/dist/analyze.js.map +1 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +82 -7
- package/dist/audit.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/http-probe.d.ts.map +1 -1
- package/dist/http-probe.js +4 -0
- package/dist/http-probe.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +17 -0
- package/dist/redact.js.map +1 -1
- package/dist/reporters.d.ts.map +1 -1
- package/dist/reporters.js +69 -2
- package/dist/reporters.js.map +1 -1
- package/dist/social.d.ts +14 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +88 -0
- package/dist/social.js.map +1 -0
- package/dist/stability.d.ts +9 -0
- package/dist/stability.d.ts.map +1 -0
- package/dist/stability.js +326 -0
- package/dist/stability.js.map +1 -0
- package/dist/stream-parser.d.ts.map +1 -1
- package/dist/stream-parser.js +21 -1
- package/dist/stream-parser.js.map +1 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/examples/github-actions.yml +2 -2
- package/examples/ssrwire.config.yml +8 -0
- package/package.json +5 -2
- package/src/analyze.ts +208 -7
- package/src/audit.ts +116 -8
- package/src/cli.ts +7 -1
- package/src/config.ts +12 -0
- package/src/http-probe.ts +4 -0
- package/src/index.ts +6 -0
- package/src/redact.ts +17 -0
- package/src/reporters.ts +97 -2
- package/src/social.ts +116 -0
- package/src/stability.ts +475 -0
- package/src/stream-parser.ts +31 -1
- 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.
|
|
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[],
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|
791
|
+
checkHeadRequirements(findings, target, probe);
|
|
591
792
|
checkTimings(findings, target, probe);
|
|
592
793
|
}
|
|
593
794
|
|
|
594
|
-
checkAgentDrift(findings, target
|
|
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
|
|
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
|
|
64
|
-
const
|
|
65
|
-
|
|
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 =
|
|
70
|
-
.filter((
|
|
158
|
+
const targetProbes = lanes
|
|
159
|
+
.filter((lane) => lane.task.targetIndex === targetIndex)
|
|
71
160
|
.sort((a, b) => a.task.agentIndex - b.task.agentIndex)
|
|
72
|
-
.
|
|
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:
|
|
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
|
|
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";
|