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
package/src/redact.ts CHANGED
@@ -90,6 +90,11 @@ function redactSignals(signals: DocumentSignals, plan: RedactionPlan): DocumentS
90
90
  descriptions: signals.descriptions.map((signal) => redactElement(signal, plan)),
91
91
  canonicals: signals.canonicals.map((signal) => redactElement(signal, plan)),
92
92
  robots: signals.robots.map((signal) => redactElement(signal, plan)),
93
+ ...(signals.socialMetadata === undefined
94
+ ? {}
95
+ : {
96
+ socialMetadata: signals.socialMetadata.map((signal) => redactElement(signal, plan)),
97
+ }),
93
98
  h1s: signals.h1s.map((signal) => redactElement(signal, plan)),
94
99
  ...(signals.firstMainText === undefined
95
100
  ? {}
@@ -160,6 +165,18 @@ export function redactAudit(audit: AuditResult, secrets: readonly string[]): Aud
160
165
  },
161
166
  },
162
167
  probes: result.probes.map((probe) => redactProbeWithPlan(probe, plan)),
168
+ ...(result.stability === undefined
169
+ ? {}
170
+ : {
171
+ stability: result.stability.map((summary) => ({
172
+ ...summary,
173
+ agent: {
174
+ ...summary.agent,
175
+ label: redactText(summary.agent.label, plan),
176
+ userAgent: redactText(summary.agent.userAgent, plan),
177
+ },
178
+ })),
179
+ }),
163
180
  findings: result.findings.map((finding) => ({
164
181
  ...finding,
165
182
  message: redactText(finding.message, plan),
package/src/reporters.ts CHANGED
@@ -1,4 +1,11 @@
1
+ import {
2
+ effectiveTwitterCardSignal,
3
+ firstSocialSignal,
4
+ OPEN_GRAPH_REQUIRED_PROPERTIES,
5
+ TWITTER_CARD_REQUIRED_FIELDS,
6
+ } from "./social.js";
1
7
  import type {
8
+ AgentStability,
2
9
  AuditResult,
3
10
  ElementSignal,
4
11
  Finding,
@@ -70,8 +77,9 @@ function firstSignal(signals: readonly ElementSignal[]): ElementSignal | undefin
70
77
  return signals.find((signal) => signal.value.trim().length > 0) ?? signals[0];
71
78
  }
72
79
 
73
- function probeRow(probe: ProbeResult): readonly string[] {
80
+ function probeRow(probe: ProbeResult, showSample: boolean): readonly string[] {
74
81
  return [
82
+ ...(showSample ? [String(probe.sample ?? "—")] : []),
75
83
  truncate(probe.agent.label, 24),
76
84
  probe.status === undefined ? "—" : String(probe.status),
77
85
  probe.completion,
@@ -84,6 +92,59 @@ function probeRow(probe: ProbeResult): readonly string[] {
84
92
  ];
85
93
  }
86
94
 
95
+ function formatSignalSet(signals: readonly (ElementSignal | undefined)[]): string {
96
+ const present = signals.filter((signal): signal is ElementSignal => signal !== undefined);
97
+ if (present.length < signals.length) return `${present.length}/${signals.length}`;
98
+ const arrivalMs = Math.max(...present.map((signal) => signal.atMs));
99
+ const location = present.some((signal) => signal.location === "body")
100
+ ? "body"
101
+ : present.some((signal) => signal.location === "document")
102
+ ? "document"
103
+ : "head";
104
+ return `${Math.round(arrivalMs)} ms/${location}`;
105
+ }
106
+
107
+ function socialRow(probe: ProbeResult, showSample: boolean): readonly string[] {
108
+ return [
109
+ ...(showSample ? [String(probe.sample ?? "—")] : []),
110
+ truncate(probe.agent.label, 24),
111
+ formatSignalSet(
112
+ OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) => firstSocialSignal(probe.signals, property)),
113
+ ),
114
+ formatSignalSet(
115
+ TWITTER_CARD_REQUIRED_FIELDS.map((field) => effectiveTwitterCardSignal(probe.signals, field)),
116
+ ),
117
+ ];
118
+ }
119
+
120
+ function stabilityRows(stability: readonly AgentStability[]): readonly (readonly string[])[] {
121
+ const labels: Readonly<Record<keyof AgentStability["timings"], string>> = {
122
+ headers: "Headers",
123
+ firstByte: "First byte",
124
+ criticalSignals: "Critical signals",
125
+ complete: "Complete",
126
+ };
127
+ const rows: string[][] = [];
128
+ for (const summary of stability) {
129
+ for (const key of ["headers", "firstByte", "criticalSignals", "complete"] as const) {
130
+ const stats = summary.timings[key];
131
+ if (stats === undefined) continue;
132
+ rows.push([
133
+ truncate(summary.agent.label, 24),
134
+ `${summary.complete}/${summary.samples}`,
135
+ labels[key],
136
+ String(stats.samples),
137
+ formatMs(stats.minMs),
138
+ formatMs(stats.medianMs),
139
+ formatMs(stats.p95Ms),
140
+ formatMs(stats.maxMs),
141
+ formatMs(stats.spreadMs),
142
+ ]);
143
+ }
144
+ }
145
+ return rows;
146
+ }
147
+
87
148
  function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
88
149
  const widths = headers.map((header, column) =>
89
150
  Math.max(header.length, ...rows.map((row) => row[column]?.length ?? 0)),
@@ -116,12 +177,17 @@ export function renderTerminal(audit: AuditResult, options: ReporterOptions = {}
116
177
  paint(`SSRWire ${audit.version}`, ANSI.bold, color),
117
178
  paint(`Generated ${audit.generatedAt} in ${formatMs(audit.durationMs)}`, ANSI.dim, color),
118
179
  ];
180
+ if ((audit.repeat ?? 1) > 1) {
181
+ lines.push(paint(`${audit.repeat} sequential samples per URL and agent`, ANSI.dim, color));
182
+ }
119
183
 
120
184
  for (const result of audit.results) {
185
+ const showSample = (audit.repeat ?? 1) > 1;
121
186
  lines.push("", paint(terminalSafe(result.target.url), ANSI.bold, color));
122
187
  lines.push(
123
188
  renderTable(
124
189
  [
190
+ ...(showSample ? ["Sample"] : []),
125
191
  "Agent",
126
192
  "HTTP",
127
193
  "Result",
@@ -132,10 +198,39 @@ export function renderTerminal(audit: AuditResult, options: ReporterOptions = {}
132
198
  "Canonical",
133
199
  "Main",
134
200
  ],
135
- result.probes.map(probeRow),
201
+ result.probes.map((probe) => probeRow(probe, showSample)),
136
202
  ),
137
203
  );
138
204
 
205
+ const showSocial =
206
+ result.target.expectations.requireOpenGraph === true ||
207
+ result.target.expectations.requireTwitterCard === true ||
208
+ result.probes.some((probe) => (probe.signals.socialMetadata?.length ?? 0) > 0);
209
+ if (showSocial) {
210
+ lines.push(
211
+ "",
212
+ "Social preview readiness",
213
+ renderTable(
214
+ [...(showSample ? ["Sample"] : []), "Agent", "Open Graph", "Twitter Card"],
215
+ result.probes.map((probe) => socialRow(probe, showSample)),
216
+ ),
217
+ );
218
+ }
219
+
220
+ if (result.stability !== undefined) {
221
+ const rows = stabilityRows(result.stability);
222
+ if (rows.length > 0) {
223
+ lines.push(
224
+ "",
225
+ "Stability timings (nearest-rank p95)",
226
+ renderTable(
227
+ ["Agent", "Complete", "Metric", "N", "Min", "Median", "P95", "Max", "Spread"],
228
+ rows,
229
+ ),
230
+ );
231
+ }
232
+ }
233
+
139
234
  if (result.findings.length === 0) {
140
235
  lines.push("Findings: none");
141
236
  } else {
package/src/social.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { DocumentSignals, SocialMetadataProperty, SocialMetadataSignal } from "./types.js";
2
+
3
+ export const SOCIAL_METADATA_PROPERTIES = [
4
+ "og:title",
5
+ "og:type",
6
+ "og:url",
7
+ "og:image",
8
+ "og:description",
9
+ "twitter:card",
10
+ "twitter:title",
11
+ "twitter:description",
12
+ "twitter:image",
13
+ ] as const satisfies readonly SocialMetadataProperty[];
14
+
15
+ export const OPEN_GRAPH_PROPERTIES = [
16
+ "og:title",
17
+ "og:type",
18
+ "og:url",
19
+ "og:image",
20
+ "og:description",
21
+ ] as const satisfies readonly SocialMetadataProperty[];
22
+
23
+ export const OPEN_GRAPH_REQUIRED_PROPERTIES = [
24
+ "og:title",
25
+ "og:type",
26
+ "og:url",
27
+ "og:image",
28
+ ] as const satisfies readonly SocialMetadataProperty[];
29
+
30
+ export const TWITTER_PROPERTIES = [
31
+ "twitter:card",
32
+ "twitter:title",
33
+ "twitter:description",
34
+ "twitter:image",
35
+ ] as const satisfies readonly SocialMetadataProperty[];
36
+
37
+ export const TWITTER_CARD_REQUIRED_FIELDS = ["card", "title", "description", "image"] as const;
38
+ export type TwitterCardField = (typeof TWITTER_CARD_REQUIRED_FIELDS)[number];
39
+
40
+ const SOCIAL_METADATA_PROPERTY_SET = new Set<string>(SOCIAL_METADATA_PROPERTIES);
41
+ const SOCIAL_URL_PROPERTY_SET = new Set<SocialMetadataProperty>([
42
+ "og:url",
43
+ "og:image",
44
+ "twitter:image",
45
+ ]);
46
+
47
+ export function isSocialMetadataProperty(
48
+ value: string | undefined,
49
+ ): value is SocialMetadataProperty {
50
+ return value !== undefined && SOCIAL_METADATA_PROPERTY_SET.has(value);
51
+ }
52
+
53
+ export function socialSignals(
54
+ signals: DocumentSignals,
55
+ property: SocialMetadataProperty,
56
+ ): readonly SocialMetadataSignal[] {
57
+ return (signals.socialMetadata ?? []).filter((signal) => signal.property === property);
58
+ }
59
+
60
+ export function firstSocialSignal(
61
+ signals: DocumentSignals,
62
+ property: SocialMetadataProperty,
63
+ ): SocialMetadataSignal | undefined {
64
+ return socialSignals(signals, property).find((signal) => signal.value.trim().length > 0);
65
+ }
66
+
67
+ export function effectiveTwitterCardSignal(
68
+ signals: DocumentSignals,
69
+ field: TwitterCardField,
70
+ ): SocialMetadataSignal | undefined {
71
+ if (field === "card") return firstSocialSignal(signals, "twitter:card");
72
+ if (field === "title") {
73
+ return firstSocialSignal(signals, "twitter:title") ?? firstSocialSignal(signals, "og:title");
74
+ }
75
+ if (field === "description") {
76
+ return (
77
+ firstSocialSignal(signals, "twitter:description") ??
78
+ firstSocialSignal(signals, "og:description")
79
+ );
80
+ }
81
+ return firstSocialSignal(signals, "twitter:image") ?? firstSocialSignal(signals, "og:image");
82
+ }
83
+
84
+ export function normalizeSocialValue(
85
+ property: SocialMetadataProperty,
86
+ value: string,
87
+ baseUrl: string,
88
+ ): string {
89
+ const normalized = value.trim().replace(/\s+/gu, " ");
90
+ if (normalized.length === 0) return "";
91
+ if (SOCIAL_URL_PROPERTY_SET.has(property)) {
92
+ try {
93
+ const url = new URL(normalized, baseUrl);
94
+ url.hash = "";
95
+ return url.href;
96
+ } catch {
97
+ return normalized;
98
+ }
99
+ }
100
+ return property === "og:type" || property === "twitter:card"
101
+ ? normalized.toLowerCase()
102
+ : normalized;
103
+ }
104
+
105
+ export function isAbsoluteHttpSocialUrl(value: string): boolean {
106
+ try {
107
+ const url = new URL(value.trim());
108
+ return (
109
+ (url.protocol === "http:" || url.protocol === "https:") &&
110
+ url.username.length === 0 &&
111
+ url.password.length === 0
112
+ );
113
+ } catch {
114
+ return false;
115
+ }
116
+ }