ssrwire 0.2.0 → 0.4.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 (71) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/CONTRIBUTING.md +3 -1
  3. package/PUBLISHING.md +15 -15
  4. package/README.md +179 -29
  5. package/dist/analyze.d.ts.map +1 -1
  6. package/dist/analyze.js +162 -6
  7. package/dist/analyze.js.map +1 -1
  8. package/dist/audit-report.d.ts +8 -0
  9. package/dist/audit-report.d.ts.map +1 -0
  10. package/dist/audit-report.js +243 -0
  11. package/dist/audit-report.js.map +1 -0
  12. package/dist/audit.d.ts.map +1 -1
  13. package/dist/audit.js +2 -0
  14. package/dist/audit.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +77 -10
  17. package/dist/cli.js.map +1 -1
  18. package/dist/compare.d.ts +6 -0
  19. package/dist/compare.d.ts.map +1 -0
  20. package/dist/compare.js +720 -0
  21. package/dist/compare.js.map +1 -0
  22. package/dist/comparison-reporters.d.ts +9 -0
  23. package/dist/comparison-reporters.d.ts.map +1 -0
  24. package/dist/comparison-reporters.js +223 -0
  25. package/dist/comparison-reporters.js.map +1 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +20 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/http-probe.d.ts.map +1 -1
  30. package/dist/http-probe.js +4 -0
  31. package/dist/http-probe.js.map +1 -1
  32. package/dist/index.d.ts +4 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/redact.d.ts.map +1 -1
  37. package/dist/redact.js +6 -0
  38. package/dist/redact.js.map +1 -1
  39. package/dist/reporters.d.ts.map +1 -1
  40. package/dist/reporters.js +27 -0
  41. package/dist/reporters.js.map +1 -1
  42. package/dist/social.d.ts +14 -0
  43. package/dist/social.d.ts.map +1 -0
  44. package/dist/social.js +88 -0
  45. package/dist/social.js.map +1 -0
  46. package/dist/stability.d.ts.map +1 -1
  47. package/dist/stability.js +84 -4
  48. package/dist/stability.js.map +1 -1
  49. package/dist/stream-parser.d.ts.map +1 -1
  50. package/dist/stream-parser.js +21 -1
  51. package/dist/stream-parser.js.map +1 -1
  52. package/dist/types.d.ts +92 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/examples/github-actions.yml +2 -2
  55. package/examples/ssrwire.config.yml +8 -2
  56. package/package.json +5 -2
  57. package/src/analyze.ts +208 -7
  58. package/src/audit-report.ts +269 -0
  59. package/src/audit.ts +2 -0
  60. package/src/cli.ts +106 -12
  61. package/src/compare.ts +949 -0
  62. package/src/comparison-reporters.ts +276 -0
  63. package/src/config.ts +19 -1
  64. package/src/http-probe.ts +4 -0
  65. package/src/index.ts +29 -0
  66. package/src/redact.ts +6 -0
  67. package/src/reporters.ts +46 -0
  68. package/src/social.ts +116 -0
  69. package/src/stability.ts +123 -4
  70. package/src/stream-parser.ts +31 -1
  71. package/src/types.ts +117 -0
@@ -0,0 +1,269 @@
1
+ import { z } from "zod";
2
+ import type { AuditResult } from "./types.js";
3
+
4
+ export const AUDIT_SCHEMA_VERSION = 1 as const;
5
+
6
+ const nonNegativeNumber = z.number().finite().nonnegative();
7
+ const nonNegativeInteger = z.number().int().nonnegative();
8
+ const elementLocation = z.enum(["head", "body", "document"]);
9
+ const severity = z.enum(["info", "warning", "error"]);
10
+
11
+ const timingMark = z
12
+ .object({
13
+ atMs: nonNegativeNumber,
14
+ observedByByte: nonNegativeInteger,
15
+ })
16
+ .strict();
17
+
18
+ const elementSignal = timingMark
19
+ .extend({
20
+ value: z.string(),
21
+ location: elementLocation,
22
+ })
23
+ .strict();
24
+
25
+ const robotsSignal = elementSignal
26
+ .extend({ audience: z.enum(["robots", "googlebot", "bingbot"]) })
27
+ .strict();
28
+
29
+ const socialMetadataSignal = elementSignal
30
+ .extend({
31
+ property: z.enum([
32
+ "og:title",
33
+ "og:type",
34
+ "og:url",
35
+ "og:image",
36
+ "og:description",
37
+ "twitter:card",
38
+ "twitter:title",
39
+ "twitter:description",
40
+ "twitter:image",
41
+ ]),
42
+ })
43
+ .strict();
44
+
45
+ const jsonLdSignal = timingMark
46
+ .extend({
47
+ location: elementLocation,
48
+ valid: z.boolean().optional(),
49
+ types: z.array(z.string()),
50
+ bytes: nonNegativeInteger,
51
+ analysisLimit: z.string().optional(),
52
+ error: z.string().optional(),
53
+ })
54
+ .strict();
55
+
56
+ const documentSignals = z
57
+ .object({
58
+ title: elementSignal.optional(),
59
+ titles: z.array(elementSignal).optional(),
60
+ descriptions: z.array(elementSignal),
61
+ canonicals: z.array(elementSignal),
62
+ robots: z.array(robotsSignal),
63
+ socialMetadata: z.array(socialMetadataSignal).optional(),
64
+ h1s: z.array(elementSignal),
65
+ firstMainText: elementSignal.optional(),
66
+ jsonLd: z.array(jsonLdSignal),
67
+ headClosed: timingMark.optional(),
68
+ bodyStarted: timingMark.optional(),
69
+ documentClosed: timingMark.optional(),
70
+ })
71
+ .strict();
72
+
73
+ const agentProfile = z
74
+ .object({
75
+ key: z.string().min(1),
76
+ label: z.string().min(1),
77
+ userAgent: z.string(),
78
+ requiresHeadMetadata: z.boolean(),
79
+ })
80
+ .strict();
81
+
82
+ const redirectHop = z
83
+ .object({
84
+ url: z.string(),
85
+ status: z.number().int().min(100).max(599),
86
+ location: z.string(),
87
+ durationMs: nonNegativeNumber,
88
+ })
89
+ .strict();
90
+
91
+ const headerSnapshot = z
92
+ .object({
93
+ values: z.record(z.string(), z.string()),
94
+ setCookiePresent: z.boolean(),
95
+ })
96
+ .strict();
97
+
98
+ const probeTimings = z
99
+ .object({
100
+ headersMs: nonNegativeNumber,
101
+ firstByteMs: nonNegativeNumber.optional(),
102
+ completeMs: nonNegativeNumber.optional(),
103
+ })
104
+ .strict();
105
+
106
+ const probeResult = z
107
+ .object({
108
+ requestedUrl: z.string(),
109
+ finalUrl: z.string(),
110
+ agent: agentProfile,
111
+ status: z.number().int().min(100).max(599).optional(),
112
+ redirects: z.array(redirectHop),
113
+ headers: headerSnapshot,
114
+ timings: probeTimings,
115
+ bytesRead: nonNegativeInteger,
116
+ bodySha256: z.string().optional(),
117
+ signals: documentSignals,
118
+ completion: z.enum([
119
+ "complete",
120
+ "max-bytes-exceeded",
121
+ "timeout",
122
+ "network-error",
123
+ "invalid-response",
124
+ ]),
125
+ error: z.string().optional(),
126
+ sample: z.number().int().positive().optional(),
127
+ })
128
+ .strict();
129
+
130
+ const targetExpectations = z
131
+ .object({
132
+ statuses: z.array(z.number().int().min(100).max(599)).min(1),
133
+ finalUrl: z.string().optional(),
134
+ requireTitle: z.boolean(),
135
+ requireDescription: z.boolean(),
136
+ requireCanonical: z.boolean(),
137
+ requireH1: z.boolean(),
138
+ requireMainText: z.boolean(),
139
+ requireOpenGraph: z.boolean().optional(),
140
+ requireTwitterCard: z.boolean().optional(),
141
+ maxFirstByteMs: z.number().positive().finite().optional(),
142
+ maxCriticalMs: z.number().positive().finite().optional(),
143
+ })
144
+ .strict();
145
+
146
+ const auditTarget = z
147
+ .object({
148
+ id: z.string().min(1).optional(),
149
+ url: z.string(),
150
+ expectations: targetExpectations,
151
+ })
152
+ .strict();
153
+
154
+ const timingStats = z
155
+ .object({
156
+ samples: nonNegativeInteger,
157
+ minMs: nonNegativeNumber,
158
+ medianMs: nonNegativeNumber,
159
+ p95Ms: nonNegativeNumber,
160
+ maxMs: nonNegativeNumber,
161
+ spreadMs: nonNegativeNumber,
162
+ })
163
+ .strict();
164
+
165
+ const stabilityTimings = z
166
+ .object({
167
+ headers: timingStats.optional(),
168
+ firstByte: timingStats.optional(),
169
+ criticalSignals: timingStats.optional(),
170
+ complete: timingStats.optional(),
171
+ })
172
+ .strict();
173
+
174
+ const stabilityVariants = z
175
+ .object({
176
+ completion: nonNegativeInteger,
177
+ status: nonNegativeInteger,
178
+ finalUrl: nonNegativeInteger,
179
+ redirectChain: nonNegativeInteger,
180
+ bodySha256: nonNegativeInteger,
181
+ metadataValues: nonNegativeInteger,
182
+ metadataLocations: nonNegativeInteger,
183
+ })
184
+ .strict();
185
+
186
+ const agentStability = z
187
+ .object({
188
+ agent: agentProfile,
189
+ samples: nonNegativeInteger,
190
+ complete: nonNegativeInteger,
191
+ incomplete: nonNegativeInteger,
192
+ timings: stabilityTimings,
193
+ variants: stabilityVariants,
194
+ })
195
+ .strict();
196
+
197
+ const finding = z
198
+ .object({
199
+ code: z.string().min(1),
200
+ severity,
201
+ message: z.string(),
202
+ url: z.string(),
203
+ agent: z.string().optional(),
204
+ evidence: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
205
+ })
206
+ .strict();
207
+
208
+ const targetAuditResult = z
209
+ .object({
210
+ target: auditTarget,
211
+ probes: z.array(probeResult),
212
+ findings: z.array(finding),
213
+ stability: z.array(agentStability).optional(),
214
+ })
215
+ .strict();
216
+
217
+ const auditSummary = z
218
+ .object({
219
+ targets: nonNegativeInteger,
220
+ probes: nonNegativeInteger,
221
+ errors: nonNegativeInteger,
222
+ warnings: nonNegativeInteger,
223
+ info: nonNegativeInteger,
224
+ incomplete: nonNegativeInteger,
225
+ })
226
+ .strict();
227
+
228
+ const auditReportSchema = z
229
+ .object({
230
+ schemaVersion: z.literal(AUDIT_SCHEMA_VERSION),
231
+ version: z.string().min(1),
232
+ generatedAt: z.string().min(1),
233
+ durationMs: nonNegativeNumber,
234
+ repeat: z.number().int().min(1).max(10).optional(),
235
+ results: z.array(targetAuditResult),
236
+ summary: auditSummary,
237
+ })
238
+ .strict();
239
+
240
+ export class AuditReportError extends Error {
241
+ public constructor(message: string) {
242
+ super(message);
243
+ this.name = "AuditReportError";
244
+ }
245
+ }
246
+
247
+ export function parseAuditReport(value: unknown, source = "audit report"): AuditResult {
248
+ const parsed = auditReportSchema.safeParse(value);
249
+ if (!parsed.success) {
250
+ const detail = parsed.error.issues
251
+ .slice(0, 5)
252
+ .map((issue) => `${issue.path.join(".") || "report"}: ${issue.message}`)
253
+ .join("; ");
254
+ throw new AuditReportError(`Invalid ${source}: ${detail}`);
255
+ }
256
+
257
+ // The complete runtime schema above is the persisted counterpart of AuditResult.
258
+ return parsed.data as AuditResult;
259
+ }
260
+
261
+ export function parseAuditReportText(text: string, source = "audit report"): AuditResult {
262
+ let value: unknown;
263
+ try {
264
+ value = JSON.parse(text) as unknown;
265
+ } catch {
266
+ throw new AuditReportError(`Could not parse ${source} as JSON.`);
267
+ }
268
+ return parseAuditReport(value, source);
269
+ }
package/src/audit.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { analyzeTarget, summarizeAudit } from "./analyze.js";
2
+ import { AUDIT_SCHEMA_VERSION } from "./audit-report.js";
2
3
  import { probeUrl } from "./http-probe.js";
3
4
  import { redactAudit } from "./redact.js";
4
5
  import { analyzeStability } from "./stability.js";
@@ -186,6 +187,7 @@ export async function runAudit(config: SsrWireConfig): Promise<AuditResult> {
186
187
  });
187
188
 
188
189
  const audit: AuditResult = {
190
+ schemaVersion: AUDIT_SCHEMA_VERSION,
189
191
  version: VERSION,
190
192
  generatedAt: new Date().toISOString(),
191
193
  durationMs: Math.round(performance.now() - started),
package/src/cli.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { access, mkdir, writeFile } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, resolve } from "node:path";
3
3
  import { Command, CommanderError, InvalidArgumentError } from "commander";
4
4
  import { runAudit } from "./audit.js";
5
+ import { AuditReportError, parseAuditReportText } from "./audit-report.js";
6
+ import { compareAudits } from "./compare.js";
7
+ import { renderComparisonReport } from "./comparison-reporters.js";
5
8
  import { ConfigError, loadConfig } from "./config.js";
6
9
  import { renderReport } from "./reporters.js";
7
- import type { ReportFormat } from "./types.js";
10
+ import type { AuditResult, ComparisonReportFormat, ReportFormat } from "./types.js";
8
11
  import { VERSION } from "./version.js";
9
12
 
10
13
  interface CliOptions {
@@ -21,9 +24,19 @@ interface CliOptions {
21
24
  readonly color: boolean;
22
25
  }
23
26
 
27
+ interface CompareCliOptions {
28
+ readonly format: ComparisonReportFormat;
29
+ readonly output?: string;
30
+ readonly failOn: "regression" | "never";
31
+ readonly timingRegressionMs: number;
32
+ readonly timingRegressionPercent: number;
33
+ readonly color: boolean;
34
+ }
35
+
24
36
  const CONFIG_TEMPLATE = `# SSRWire configuration
25
37
  targets:
26
- - url: https://example.com/
38
+ - id: home
39
+ url: https://example.com/
27
40
  expectedStatus: 200
28
41
  require:
29
42
  title: true
@@ -31,6 +44,8 @@ targets:
31
44
  canonical: true
32
45
  h1: true
33
46
  mainText: true
47
+ openGraph: false
48
+ twitterCard: false
34
49
 
35
50
  agents:
36
51
  - browser
@@ -74,6 +89,24 @@ function parseFailOn(value: string): CliOptions["failOn"] {
74
89
  throw new InvalidArgumentError("Expected error, warning, or never.");
75
90
  }
76
91
 
92
+ function parseComparisonFormat(value: string): ComparisonReportFormat {
93
+ if (value === "terminal" || value === "json" || value === "html") return value;
94
+ throw new InvalidArgumentError("Expected terminal, json, or html.");
95
+ }
96
+
97
+ function parseComparisonFailOn(value: string): CompareCliOptions["failOn"] {
98
+ if (value === "regression" || value === "never") return value;
99
+ throw new InvalidArgumentError("Expected regression or never.");
100
+ }
101
+
102
+ function parseNonNegativeNumber(value: string): number {
103
+ const parsed = Number(value);
104
+ if (!Number.isFinite(parsed) || parsed < 0) {
105
+ throw new InvalidArgumentError("Expected a finite non-negative number.");
106
+ }
107
+ return parsed;
108
+ }
109
+
77
110
  function addCheckOptions(command: Command): Command {
78
111
  return command
79
112
  .option("-c, --config <path>", "configuration file")
@@ -118,6 +151,16 @@ async function writeReport(path: string, report: string): Promise<void> {
118
151
  await writeFile(absolute, report, "utf8");
119
152
  }
120
153
 
154
+ async function readAuditFile(path: string, label: string): Promise<AuditResult> {
155
+ let text: string;
156
+ try {
157
+ text = await readFile(resolve(path), "utf8");
158
+ } catch {
159
+ throw new AuditReportError(`Could not read ${label} audit report: ${path}`);
160
+ }
161
+ return parseAuditReportText(text, `${label} audit report`);
162
+ }
163
+
121
164
  async function check(urls: readonly string[], options: CliOptions): Promise<void> {
122
165
  const config = await loadConfig({
123
166
  ...(options.config ? { configPath: options.config } : {}),
@@ -147,6 +190,38 @@ async function check(urls: readonly string[], options: CliOptions): Promise<void
147
190
  process.exitCode = reportExitCode(audit.summary, options.failOn);
148
191
  }
149
192
 
193
+ async function compareReports(
194
+ baselinePath: string,
195
+ candidatePath: string,
196
+ options: CompareCliOptions,
197
+ ): Promise<void> {
198
+ const [baseline, candidate] = await Promise.all([
199
+ readAuditFile(baselinePath, "baseline"),
200
+ readAuditFile(candidatePath, "candidate"),
201
+ ]);
202
+ const comparison = compareAudits(baseline, candidate, {
203
+ baselineLabel: basename(baselinePath),
204
+ candidateLabel: basename(candidatePath),
205
+ timingRegressionMs: options.timingRegressionMs,
206
+ timingRegressionPercent: options.timingRegressionPercent,
207
+ });
208
+ const color =
209
+ options.color &&
210
+ !options.output &&
211
+ Boolean(process.stdout.isTTY) &&
212
+ !Reflect.has(process.env, "NO_COLOR");
213
+ const report = renderComparisonReport(comparison, options.format, { color });
214
+
215
+ if (options.output) {
216
+ await writeReport(options.output, report);
217
+ process.stderr.write(`SSRWire wrote ${options.format} comparison to ${options.output}\n`);
218
+ } else {
219
+ process.stdout.write(report);
220
+ }
221
+
222
+ process.exitCode = options.failOn === "regression" && comparison.summary.regressions > 0 ? 1 : 0;
223
+ }
224
+
150
225
  async function initialize(path: string, force: boolean): Promise<void> {
151
226
  const absolute = resolve(path);
152
227
  if (!force) {
@@ -168,26 +243,45 @@ export async function main(argv: readonly string[] = process.argv): Promise<void
168
243
  const program = new Command();
169
244
  program
170
245
  .name("ssrwire")
171
- .description("Inspect streamed SSR HTML and crawler-specific metadata delivery.")
246
+ .description("Inspect streamed SSR HTML, SEO, and social metadata delivery.")
172
247
  .version(VERSION)
173
248
  .exitOverride()
174
249
  .showHelpAfterError();
175
250
 
176
- addCheckOptions(program)
177
- .argument("[urls...]", "HTTP or HTTPS URLs to inspect")
178
- .action(async (urls: string[], _options: CliOptions, command: Command) => {
179
- await check(urls, optionsFrom(command));
180
- });
181
-
182
251
  addCheckOptions(
183
252
  program
184
- .command("check")
253
+ .command("check", { isDefault: true })
185
254
  .description("inspect one or more SSR responses")
186
255
  .argument("[urls...]", "HTTP or HTTPS URLs to inspect"),
187
256
  ).action(async (urls: string[], _options: CliOptions, command: Command) => {
188
257
  await check(urls, optionsFrom(command));
189
258
  });
190
259
 
260
+ program
261
+ .command("compare")
262
+ .description("compare two SSRWire JSON audit reports")
263
+ .argument("<baseline>", "baseline JSON audit report")
264
+ .argument("<candidate>", "candidate JSON audit report")
265
+ .option("-f, --format <format>", "terminal, json, or html", parseComparisonFormat, "terminal")
266
+ .option("-o, --output <path>", "write the comparison to a file")
267
+ .option("--fail-on <level>", "regression or never", parseComparisonFailOn, "regression")
268
+ .option(
269
+ "--timing-regression-ms <ms>",
270
+ "minimum absolute median slowdown",
271
+ parseNonNegativeNumber,
272
+ 250,
273
+ )
274
+ .option(
275
+ "--timing-regression-percent <percent>",
276
+ "minimum relative median slowdown",
277
+ parseNonNegativeNumber,
278
+ 25,
279
+ )
280
+ .option("--no-color", "disable terminal colors")
281
+ .action(async (baselinePath: string, candidatePath: string, options: CompareCliOptions) => {
282
+ await compareReports(baselinePath, candidatePath, options);
283
+ });
284
+
191
285
  program
192
286
  .command("init")
193
287
  .description("create a documented starter configuration")