ssrwire 0.1.0 → 0.2.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.
@@ -0,0 +1,356 @@
1
+ import type {
2
+ AgentProfile,
3
+ AgentStability,
4
+ AuditTarget,
5
+ ElementSignal,
6
+ Finding,
7
+ ProbeResult,
8
+ RobotsAudience,
9
+ RobotsSignal,
10
+ TimingStats,
11
+ } from "./types.js";
12
+
13
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
14
+ const ROBOTS_OPPOSITES = [
15
+ ["index", "noindex"],
16
+ ["follow", "nofollow"],
17
+ ["archive", "noarchive"],
18
+ ["snippet", "nosnippet"],
19
+ ["translate", "notranslate"],
20
+ ["imageindex", "noimageindex"],
21
+ ] as const;
22
+
23
+ export interface StabilityAnalysis {
24
+ readonly stability: readonly AgentStability[];
25
+ readonly findings: readonly Finding[];
26
+ }
27
+
28
+ function normalizeText(value: string): string {
29
+ return value.trim().replace(/\s+/g, " ");
30
+ }
31
+
32
+ function normalizeUrl(value: string, baseUrl?: string): string {
33
+ try {
34
+ const url = baseUrl === undefined ? new URL(value) : new URL(value, baseUrl);
35
+ url.hash = "";
36
+ return url.href;
37
+ } catch {
38
+ return normalizeText(value);
39
+ }
40
+ }
41
+
42
+ function normalizeCanonical(value: string, baseUrl: string): string {
43
+ const normalized = normalizeText(value);
44
+ return normalized.length === 0 ? "" : normalizeUrl(normalized, baseUrl);
45
+ }
46
+
47
+ function normalizedSignalValues(
48
+ signals: readonly ElementSignal[],
49
+ normalize: (value: string) => string = normalizeText,
50
+ ): readonly string[] {
51
+ return [
52
+ ...new Set(
53
+ signals.map((signal) => normalize(signal.value)).filter((value) => value.length > 0),
54
+ ),
55
+ ].sort();
56
+ }
57
+
58
+ function robotsAudienceForAgent(agent: AgentProfile): RobotsAudience {
59
+ const key = agent.key.trim().toLowerCase();
60
+ if (key === "googlebot" || key === "bingbot") return key;
61
+ return "robots";
62
+ }
63
+
64
+ function effectiveRobotsSignals(probe: ProbeResult): readonly RobotsSignal[] {
65
+ const audience = robotsAudienceForAgent(probe.agent);
66
+ const generic = probe.signals.robots.filter((signal) => signal.audience === "robots");
67
+ if (audience === "robots") return generic;
68
+ return [...generic, ...probe.signals.robots.filter((signal) => signal.audience === audience)];
69
+ }
70
+
71
+ function normalizedRobotsValue(signals: readonly RobotsSignal[]): string {
72
+ const directives = new Set(
73
+ signals
74
+ .flatMap((signal) => normalizeText(signal.value).toLowerCase().split(/[;,]/))
75
+ .map((directive) => directive.trim())
76
+ .filter((directive) => directive.length > 0),
77
+ );
78
+ if (directives.delete("none")) {
79
+ directives.add("noindex");
80
+ directives.add("nofollow");
81
+ }
82
+ if (directives.delete("all")) {
83
+ directives.add("index");
84
+ directives.add("follow");
85
+ }
86
+ for (const [permissive, restrictive] of ROBOTS_OPPOSITES) {
87
+ if (directives.has(restrictive)) directives.delete(permissive);
88
+ }
89
+ return [...directives].sort().join(",") || "<missing>";
90
+ }
91
+
92
+ function effectiveRobotsValue(probe: ProbeResult): string {
93
+ return normalizedRobotsValue(effectiveRobotsSignals(probe));
94
+ }
95
+
96
+ function signalValueLocations(
97
+ signals: readonly ElementSignal[],
98
+ normalize: (value: string) => string = normalizeText,
99
+ ): readonly (readonly [string, string])[] {
100
+ const entries = signals
101
+ .map((signal) => [normalize(signal.value), signal.location] as const)
102
+ .filter(([value]) => value.length > 0);
103
+ return [...new Map(entries.map((entry) => [JSON.stringify(entry), entry])).values()].sort(
104
+ ([leftValue, leftLocation], [rightValue, rightLocation]) => {
105
+ const valueOrder = leftValue.localeCompare(rightValue);
106
+ return valueOrder === 0 ? leftLocation.localeCompare(rightLocation) : valueOrder;
107
+ },
108
+ );
109
+ }
110
+
111
+ function robotsValueLocations(probe: ProbeResult): readonly (readonly [string, string])[] {
112
+ const entries = effectiveRobotsSignals(probe)
113
+ .map((signal) => [normalizedRobotsValue([signal]), signal.location] as const)
114
+ .filter(([value]) => value !== "<missing>");
115
+ return [...new Map(entries.map((entry) => [JSON.stringify(entry), entry])).values()].sort(
116
+ (left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)),
117
+ );
118
+ }
119
+
120
+ function metadataValueSignature(probe: ProbeResult): string {
121
+ const titles = probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []);
122
+ return JSON.stringify({
123
+ title: normalizedSignalValues(titles),
124
+ description: normalizedSignalValues(probe.signals.descriptions),
125
+ canonical: normalizedSignalValues(probe.signals.canonicals, (value) =>
126
+ normalizeCanonical(value, probe.finalUrl),
127
+ ),
128
+ robots: effectiveRobotsValue(probe),
129
+ });
130
+ }
131
+
132
+ function metadataLocationSignature(probe: ProbeResult): string {
133
+ const titles = probe.signals.titles ?? (probe.signals.title ? [probe.signals.title] : []);
134
+ return JSON.stringify({
135
+ title: signalValueLocations(titles),
136
+ description: signalValueLocations(probe.signals.descriptions),
137
+ canonical: signalValueLocations(probe.signals.canonicals, (value) =>
138
+ normalizeCanonical(value, probe.finalUrl),
139
+ ),
140
+ robots: robotsValueLocations(probe),
141
+ });
142
+ }
143
+
144
+ function redirectChainSignature(probe: ProbeResult): string {
145
+ return JSON.stringify(
146
+ probe.redirects.map((redirect) => ({
147
+ status: redirect.status,
148
+ url: normalizeUrl(redirect.url),
149
+ location: normalizeUrl(redirect.location, redirect.url),
150
+ })),
151
+ );
152
+ }
153
+
154
+ function uniqueCount(values: readonly string[]): number {
155
+ return new Set(values).size;
156
+ }
157
+
158
+ export function calculateTimingStats(values: readonly number[]): TimingStats | undefined {
159
+ const sorted = values
160
+ .filter((value) => Number.isFinite(value) && value >= 0)
161
+ .sort((a, b) => a - b);
162
+ if (sorted.length === 0) return undefined;
163
+
164
+ const middle = Math.floor(sorted.length / 2);
165
+ const lower = sorted[middle - 1];
166
+ const upper = sorted[middle];
167
+ const medianMs =
168
+ sorted.length % 2 === 0 && lower !== undefined && upper !== undefined
169
+ ? (lower + upper) / 2
170
+ : (upper ?? sorted[0] ?? 0);
171
+ const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
172
+ const minMs = sorted[0] ?? 0;
173
+ const maxMs = sorted[sorted.length - 1] ?? minMs;
174
+
175
+ return {
176
+ samples: sorted.length,
177
+ minMs,
178
+ medianMs,
179
+ p95Ms: sorted[p95Index] ?? maxMs,
180
+ maxMs,
181
+ spreadMs: maxMs - minMs,
182
+ };
183
+ }
184
+
185
+ function firstNonEmpty(signals: readonly ElementSignal[]): ElementSignal | undefined {
186
+ return signals.find((signal) => normalizeText(signal.value).length > 0);
187
+ }
188
+
189
+ export function criticalSignalsArrivalMs(
190
+ target: AuditTarget,
191
+ probe: ProbeResult,
192
+ ): number | undefined {
193
+ const marks: number[] = [];
194
+ const { expectations } = target;
195
+ let required = 0;
196
+
197
+ const addRequired = (signal: ElementSignal | undefined): boolean => {
198
+ required += 1;
199
+ if (signal === undefined || normalizeText(signal.value).length === 0) return false;
200
+ marks.push(signal.atMs);
201
+ return true;
202
+ };
203
+
204
+ if (expectations.requireTitle && !addRequired(probe.signals.title)) return undefined;
205
+ if (expectations.requireDescription && !addRequired(firstNonEmpty(probe.signals.descriptions))) {
206
+ return undefined;
207
+ }
208
+ if (expectations.requireCanonical) {
209
+ const canonical = probe.signals.canonicals.find(
210
+ (signal) => normalizeCanonical(signal.value, probe.finalUrl).length > 0,
211
+ );
212
+ if (!addRequired(canonical)) return undefined;
213
+ }
214
+ if (expectations.requireH1 && !addRequired(firstNonEmpty(probe.signals.h1s))) return undefined;
215
+ if (expectations.requireMainText && !addRequired(probe.signals.firstMainText)) return undefined;
216
+
217
+ return required === 0 ? undefined : Math.max(...marks);
218
+ }
219
+
220
+ function addStats(
221
+ values: readonly number[],
222
+ key: "headers" | "firstByte" | "criticalSignals" | "complete",
223
+ target: Record<string, TimingStats>,
224
+ ): void {
225
+ const stats = calculateTimingStats(values);
226
+ if (stats !== undefined) target[key] = stats;
227
+ }
228
+
229
+ function summarizeAgent(target: AuditTarget, probes: readonly ProbeResult[]): AgentStability {
230
+ const agent = probes[0]?.agent;
231
+ if (agent === undefined) throw new Error("Cannot summarize an empty probe group.");
232
+ const complete = probes.filter((probe) => probe.completion === "complete");
233
+ const finalResponses = probes.filter(
234
+ (probe) => probe.status !== undefined && !REDIRECT_STATUSES.has(probe.status),
235
+ );
236
+ const timings: Record<string, TimingStats> = {};
237
+
238
+ addStats(
239
+ finalResponses.map((probe) => probe.timings.headersMs),
240
+ "headers",
241
+ timings,
242
+ );
243
+ addStats(
244
+ probes.flatMap((probe) =>
245
+ probe.timings.firstByteMs === undefined ? [] : [probe.timings.firstByteMs],
246
+ ),
247
+ "firstByte",
248
+ timings,
249
+ );
250
+ addStats(
251
+ complete.flatMap((probe) => {
252
+ const value = criticalSignalsArrivalMs(target, probe);
253
+ return value === undefined ? [] : [value];
254
+ }),
255
+ "criticalSignals",
256
+ timings,
257
+ );
258
+ addStats(
259
+ complete.flatMap((probe) =>
260
+ probe.timings.completeMs === undefined ? [] : [probe.timings.completeMs],
261
+ ),
262
+ "complete",
263
+ timings,
264
+ );
265
+
266
+ return {
267
+ agent,
268
+ samples: probes.length,
269
+ complete: complete.length,
270
+ incomplete: probes.length - complete.length,
271
+ timings,
272
+ variants: {
273
+ completion: uniqueCount(probes.map((probe) => probe.completion)),
274
+ status: uniqueCount(finalResponses.map((probe) => String(probe.status))),
275
+ finalUrl: uniqueCount(finalResponses.map((probe) => normalizeUrl(probe.finalUrl))),
276
+ redirectChain: uniqueCount(probes.map(redirectChainSignature)),
277
+ bodySha256: uniqueCount(complete.map((probe) => probe.bodySha256 ?? "<missing>")),
278
+ metadataValues: uniqueCount(complete.map(metadataValueSignature)),
279
+ metadataLocations: uniqueCount(complete.map(metadataLocationSignature)),
280
+ },
281
+ };
282
+ }
283
+
284
+ function finding(
285
+ target: AuditTarget,
286
+ summary: AgentStability,
287
+ code: "response-instability" | "stream-instability",
288
+ severity: "info" | "warning",
289
+ fields: readonly string[],
290
+ ): Finding {
291
+ const variants = summary.variants;
292
+ return {
293
+ code,
294
+ severity,
295
+ message:
296
+ code === "response-instability"
297
+ ? `${summary.agent.label} returned inconsistent HTTP response evidence across samples.`
298
+ : `${summary.agent.label} returned inconsistent streamed HTML evidence across samples.`,
299
+ url: target.url,
300
+ agent: summary.agent.key,
301
+ evidence: {
302
+ samples: summary.samples,
303
+ completeSamples: summary.complete,
304
+ fields: fields.join(", "),
305
+ variantCounts: fields
306
+ .map((field) => `${field}=${variants[field as keyof typeof variants]}`)
307
+ .join("; "),
308
+ },
309
+ };
310
+ }
311
+
312
+ function findingsFor(target: AuditTarget, summary: AgentStability): readonly Finding[] {
313
+ const findings: Finding[] = [];
314
+ const responseFields = (["completion", "status", "finalUrl", "redirectChain"] as const).filter(
315
+ (field) => summary.variants[field] > 1,
316
+ );
317
+ if (responseFields.length > 0) {
318
+ findings.push(finding(target, summary, "response-instability", "warning", responseFields));
319
+ }
320
+
321
+ const streamFields = (["bodySha256", "metadataValues", "metadataLocations"] as const).filter(
322
+ (field) => summary.variants[field] > 1,
323
+ );
324
+ if (streamFields.length > 0) {
325
+ const metadataChanged = streamFields.some((field) => field !== "bodySha256");
326
+ findings.push(
327
+ finding(
328
+ target,
329
+ summary,
330
+ "stream-instability",
331
+ metadataChanged ? "warning" : "info",
332
+ streamFields,
333
+ ),
334
+ );
335
+ }
336
+
337
+ return findings;
338
+ }
339
+
340
+ export function analyzeStability(
341
+ target: AuditTarget,
342
+ probes: readonly ProbeResult[],
343
+ ): StabilityAnalysis {
344
+ const groups = new Map<string, ProbeResult[]>();
345
+ for (const probe of probes) {
346
+ const group = groups.get(probe.agent.key) ?? [];
347
+ group.push(probe);
348
+ groups.set(probe.agent.key, group);
349
+ }
350
+
351
+ const stability = [...groups.values()].map((group) => summarizeAgent(target, group));
352
+ return {
353
+ stability,
354
+ findings: stability.flatMap((summary) => findingsFor(target, summary)),
355
+ };
356
+ }
package/src/types.ts CHANGED
@@ -97,6 +97,8 @@ export interface ProbeResult {
97
97
  readonly signals: DocumentSignals;
98
98
  readonly completion: ProbeCompletion;
99
99
  readonly error?: string;
100
+ /** One-based audit sample number. Low-level probeUrl() calls leave this unset. */
101
+ readonly sample?: number;
100
102
  }
101
103
 
102
104
  export interface TargetExpectations {
@@ -123,6 +125,43 @@ export interface SsrWireConfig {
123
125
  readonly timeoutMs: number;
124
126
  readonly maxBytes: number;
125
127
  readonly maxRedirects: number;
128
+ /** Total samples per target and agent. Defaults to one for programmatic callers. */
129
+ readonly repeat?: number;
130
+ }
131
+
132
+ export interface TimingStats {
133
+ readonly samples: number;
134
+ readonly minMs: number;
135
+ readonly medianMs: number;
136
+ readonly p95Ms: number;
137
+ readonly maxMs: number;
138
+ readonly spreadMs: number;
139
+ }
140
+
141
+ export interface StabilityTimings {
142
+ readonly headers?: TimingStats;
143
+ readonly firstByte?: TimingStats;
144
+ readonly criticalSignals?: TimingStats;
145
+ readonly complete?: TimingStats;
146
+ }
147
+
148
+ export interface StabilityVariants {
149
+ readonly completion: number;
150
+ readonly status: number;
151
+ readonly finalUrl: number;
152
+ readonly redirectChain: number;
153
+ readonly bodySha256: number;
154
+ readonly metadataValues: number;
155
+ readonly metadataLocations: number;
156
+ }
157
+
158
+ export interface AgentStability {
159
+ readonly agent: AgentProfile;
160
+ readonly samples: number;
161
+ readonly complete: number;
162
+ readonly incomplete: number;
163
+ readonly timings: StabilityTimings;
164
+ readonly variants: StabilityVariants;
126
165
  }
127
166
 
128
167
  export interface Finding {
@@ -138,6 +177,7 @@ export interface TargetAuditResult {
138
177
  readonly target: AuditTarget;
139
178
  readonly probes: readonly ProbeResult[];
140
179
  readonly findings: readonly Finding[];
180
+ readonly stability?: readonly AgentStability[];
141
181
  }
142
182
 
143
183
  export interface AuditSummary {
@@ -153,6 +193,7 @@ export interface AuditResult {
153
193
  readonly version: string;
154
194
  readonly generatedAt: string;
155
195
  readonly durationMs: number;
196
+ readonly repeat?: number;
156
197
  readonly results: readonly TargetAuditResult[];
157
198
  readonly summary: AuditSummary;
158
199
  }