ssrwire 0.1.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 (74) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +49 -0
  3. package/LICENSE +21 -0
  4. package/PUBLISHING.md +134 -0
  5. package/README.md +403 -0
  6. package/SECURITY.md +26 -0
  7. package/dist/agents.d.ts +14 -0
  8. package/dist/agents.d.ts.map +1 -0
  9. package/dist/agents.js +100 -0
  10. package/dist/agents.js.map +1 -0
  11. package/dist/analyze.d.ts +4 -0
  12. package/dist/analyze.d.ts.map +1 -0
  13. package/dist/analyze.js +494 -0
  14. package/dist/analyze.js.map +1 -0
  15. package/dist/audit.d.ts +3 -0
  16. package/dist/audit.d.ts.map +1 -0
  17. package/dist/audit.js +69 -0
  18. package/dist/audit.js.map +1 -0
  19. package/dist/bin.d.ts +3 -0
  20. package/dist/bin.d.ts.map +1 -0
  21. package/dist/bin.js +4 -0
  22. package/dist/bin.js.map +1 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +173 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config.d.ts +17 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +262 -0
  30. package/dist/config.js.map +1 -0
  31. package/dist/http-probe.d.ts +7 -0
  32. package/dist/http-probe.d.ts.map +1 -0
  33. package/dist/http-probe.js +406 -0
  34. package/dist/http-probe.js.map +1 -0
  35. package/dist/index.d.ts +11 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +10 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/redact.d.ts +10 -0
  40. package/dist/redact.d.ts.map +1 -0
  41. package/dist/redact.js +154 -0
  42. package/dist/redact.js.map +1 -0
  43. package/dist/reporters.d.ts +9 -0
  44. package/dist/reporters.d.ts.map +1 -0
  45. package/dist/reporters.js +220 -0
  46. package/dist/reporters.js.map +1 -0
  47. package/dist/stream-parser.d.ts +9 -0
  48. package/dist/stream-parser.d.ts.map +1 -0
  49. package/dist/stream-parser.js +366 -0
  50. package/dist/stream-parser.js.map +1 -0
  51. package/dist/types.d.ts +134 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/dist/version.d.ts +2 -0
  56. package/dist/version.d.ts.map +1 -0
  57. package/dist/version.js +14 -0
  58. package/dist/version.js.map +1 -0
  59. package/examples/github-actions.yml +73 -0
  60. package/examples/ssrwire.config.yml +37 -0
  61. package/package.json +91 -0
  62. package/src/agents.ts +129 -0
  63. package/src/analyze.ts +628 -0
  64. package/src/audit.ts +89 -0
  65. package/src/bin.ts +5 -0
  66. package/src/cli.ts +207 -0
  67. package/src/config.ts +313 -0
  68. package/src/http-probe.ts +461 -0
  69. package/src/index.ts +34 -0
  70. package/src/redact.ts +173 -0
  71. package/src/reporters.ts +274 -0
  72. package/src/stream-parser.ts +424 -0
  73. package/src/types.ts +160 -0
  74. package/src/version.ts +19 -0
@@ -0,0 +1,134 @@
1
+ export type Severity = "info" | "warning" | "error";
2
+ export type ElementLocation = "head" | "body" | "document";
3
+ export interface TimingMark {
4
+ readonly atMs: number;
5
+ readonly observedByByte: number;
6
+ }
7
+ export interface ElementSignal extends TimingMark {
8
+ readonly value: string;
9
+ readonly location: ElementLocation;
10
+ }
11
+ export type RobotsAudience = "robots" | "googlebot" | "bingbot";
12
+ export interface RobotsSignal extends ElementSignal {
13
+ readonly audience: RobotsAudience;
14
+ }
15
+ export interface JsonLdSignal extends TimingMark {
16
+ readonly location: ElementLocation;
17
+ readonly valid?: boolean;
18
+ readonly types: readonly string[];
19
+ readonly bytes: number;
20
+ readonly analysisLimit?: string;
21
+ readonly error?: string;
22
+ }
23
+ export interface DocumentSignals {
24
+ readonly title?: ElementSignal;
25
+ readonly titles?: readonly ElementSignal[];
26
+ readonly descriptions: readonly ElementSignal[];
27
+ readonly canonicals: readonly ElementSignal[];
28
+ readonly robots: readonly RobotsSignal[];
29
+ readonly h1s: readonly ElementSignal[];
30
+ readonly firstMainText?: ElementSignal;
31
+ readonly jsonLd: readonly JsonLdSignal[];
32
+ readonly headClosed?: TimingMark;
33
+ readonly bodyStarted?: TimingMark;
34
+ readonly documentClosed?: TimingMark;
35
+ }
36
+ export interface AgentProfile {
37
+ readonly key: string;
38
+ readonly label: string;
39
+ readonly userAgent: string;
40
+ readonly requiresHeadMetadata: boolean;
41
+ }
42
+ export interface RedirectHop {
43
+ readonly url: string;
44
+ readonly status: number;
45
+ readonly location: string;
46
+ readonly durationMs: number;
47
+ }
48
+ export interface HeaderSnapshot {
49
+ readonly values: Readonly<Record<string, string>>;
50
+ readonly setCookiePresent: boolean;
51
+ }
52
+ export interface ProbeTimings {
53
+ readonly headersMs: number;
54
+ readonly firstByteMs?: number;
55
+ readonly completeMs?: number;
56
+ }
57
+ export type ProbeCompletion = "complete" | "max-bytes-exceeded" | "timeout" | "network-error" | "invalid-response";
58
+ export interface ProbeOptions {
59
+ readonly url: string;
60
+ readonly agent: AgentProfile;
61
+ readonly headers?: Readonly<Record<string, string>>;
62
+ readonly timeoutMs: number;
63
+ readonly maxBytes: number;
64
+ readonly maxRedirects: number;
65
+ /** Keep reflected header values internal so callers can analyze raw evidence before redaction. */
66
+ readonly redactHeaderValues?: boolean;
67
+ }
68
+ export interface ProbeResult {
69
+ readonly requestedUrl: string;
70
+ readonly finalUrl: string;
71
+ readonly agent: AgentProfile;
72
+ readonly status?: number;
73
+ readonly redirects: readonly RedirectHop[];
74
+ readonly headers: HeaderSnapshot;
75
+ readonly timings: ProbeTimings;
76
+ readonly bytesRead: number;
77
+ readonly bodySha256?: string;
78
+ readonly signals: DocumentSignals;
79
+ readonly completion: ProbeCompletion;
80
+ readonly error?: string;
81
+ }
82
+ export interface TargetExpectations {
83
+ readonly statuses: readonly number[];
84
+ readonly finalUrl?: string;
85
+ readonly requireTitle: boolean;
86
+ readonly requireDescription: boolean;
87
+ readonly requireCanonical: boolean;
88
+ readonly requireH1: boolean;
89
+ readonly requireMainText: boolean;
90
+ readonly maxFirstByteMs?: number;
91
+ readonly maxCriticalMs?: number;
92
+ }
93
+ export interface AuditTarget {
94
+ readonly url: string;
95
+ readonly expectations: TargetExpectations;
96
+ }
97
+ export interface SsrWireConfig {
98
+ readonly targets: readonly AuditTarget[];
99
+ readonly agents: readonly AgentProfile[];
100
+ readonly headers: Readonly<Record<string, string>>;
101
+ readonly timeoutMs: number;
102
+ readonly maxBytes: number;
103
+ readonly maxRedirects: number;
104
+ }
105
+ export interface Finding {
106
+ readonly code: string;
107
+ readonly severity: Severity;
108
+ readonly message: string;
109
+ readonly url: string;
110
+ readonly agent?: string;
111
+ readonly evidence?: Readonly<Record<string, string | number | boolean>>;
112
+ }
113
+ export interface TargetAuditResult {
114
+ readonly target: AuditTarget;
115
+ readonly probes: readonly ProbeResult[];
116
+ readonly findings: readonly Finding[];
117
+ }
118
+ export interface AuditSummary {
119
+ readonly targets: number;
120
+ readonly probes: number;
121
+ readonly errors: number;
122
+ readonly warnings: number;
123
+ readonly info: number;
124
+ readonly incomplete: number;
125
+ }
126
+ export interface AuditResult {
127
+ readonly version: string;
128
+ readonly generatedAt: string;
129
+ readonly durationMs: number;
130
+ readonly results: readonly TargetAuditResult[];
131
+ readonly summary: AuditSummary;
132
+ }
133
+ export type ReportFormat = "terminal" | "json" | "sarif";
134
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export declare const VERSION: string;
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAkBA,eAAO,MAAM,OAAO,QAA2B,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ const packageJson = require("../package.json");
4
+ function readVersion(value) {
5
+ if (typeof value === "object" &&
6
+ value !== null &&
7
+ "version" in value &&
8
+ typeof value.version === "string") {
9
+ return value.version;
10
+ }
11
+ throw new Error("SSRWire could not read its package version.");
12
+ }
13
+ export const VERSION = readVersion(packageJson);
14
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,WAAW,GAAY,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAExD,SAAS,WAAW,CAAC,KAAc;IACjC,IACE,OAAO,KAAK,KAAK,QAAQ;QACzB,KAAK,KAAK,IAAI;QACd,SAAS,IAAI,KAAK;QAClB,OAAO,KAAK,CAAC,OAAO,KAAK,QAAQ,EACjC,CAAC;QACD,OAAO,KAAK,CAAC,OAAO,CAAC;IACvB,CAAC;IAED,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;AACjE,CAAC;AAED,MAAM,CAAC,MAAM,OAAO,GAAG,WAAW,CAAC,WAAW,CAAC,CAAC"}
@@ -0,0 +1,73 @@
1
+ name: SSRWire
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+ security-events: write
12
+
13
+ jobs:
14
+ audit:
15
+ runs-on: ubuntu-latest
16
+ timeout-minutes: 10
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+
20
+ - uses: actions/setup-node@v7
21
+ with:
22
+ node-version: 24
23
+ package-manager-cache: false
24
+
25
+ - name: Run SSRWire (pull request, no credentials)
26
+ id: ssrwire_pr
27
+ if: github.event_name == 'pull_request'
28
+ continue-on-error: true
29
+ run: >-
30
+ npx --yes ssrwire@0.1.0 check
31
+ --config ssrwire.config.yml
32
+ --format sarif
33
+ --output reports/ssrwire.sarif
34
+ --fail-on error
35
+
36
+ - name: Run SSRWire (trusted branch)
37
+ id: ssrwire_trusted
38
+ if: github.event_name != 'pull_request'
39
+ continue-on-error: true
40
+ run: >-
41
+ npx --yes ssrwire@0.1.0 check
42
+ --config ssrwire.config.yml
43
+ --format sarif
44
+ --output reports/ssrwire.sarif
45
+ --fail-on error
46
+ env:
47
+ PREVIEW_TOKEN: ${{ secrets.PREVIEW_TOKEN }}
48
+
49
+ - name: Upload SARIF
50
+ if: >-
51
+ always() &&
52
+ hashFiles('reports/ssrwire.sarif') != '' &&
53
+ github.event.repository.visibility == 'public' &&
54
+ (github.event_name != 'pull_request' ||
55
+ github.event.pull_request.head.repo.full_name == github.repository)
56
+ uses: github/codeql-action/upload-sarif@v4
57
+ with:
58
+ sarif_file: reports/ssrwire.sarif
59
+
60
+ - name: Keep the report as a workflow artifact
61
+ if: always() && hashFiles('reports/ssrwire.sarif') != ''
62
+ uses: actions/upload-artifact@v7
63
+ with:
64
+ name: ssrwire-sarif
65
+ path: reports/ssrwire.sarif
66
+ retention-days: 14
67
+
68
+ - name: Enforce SSRWire result
69
+ if: >-
70
+ always() &&
71
+ (steps.ssrwire_pr.outcome == 'failure' ||
72
+ steps.ssrwire_trusted.outcome == 'failure')
73
+ run: exit 1
@@ -0,0 +1,37 @@
1
+ # Replace these URLs with a small set of high-value SSR routes.
2
+ targets:
3
+ - url: https://example.com/
4
+ expectedStatus: 200
5
+ expectedFinalUrl: https://example.com/
6
+ require:
7
+ title: true
8
+ description: true
9
+ canonical: true
10
+ h1: true
11
+ mainText: true
12
+
13
+ - url: https://example.com/pricing/
14
+ expectedStatus: 200
15
+ require:
16
+ title: true
17
+ description: true
18
+ canonical: true
19
+ h1: true
20
+ mainText: true
21
+
22
+ # Add maxFirstByteMs or maxCriticalMs per target only when the runner location
23
+ # and cache state are stable enough for a meaningful threshold.
24
+
25
+ agents:
26
+ - browser
27
+ - googlebot
28
+ - bingbot
29
+ - twitterbot
30
+
31
+ timeoutMs: 15000
32
+ maxBytes: 10485760
33
+ maxRedirects: 10
34
+
35
+ # Keep preview credentials in a CI secret or exported environment variable.
36
+ # headers:
37
+ # x-preview-token: "${PREVIEW_TOKEN}"
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "ssrwire",
3
+ "version": "0.1.0",
4
+ "description": "Inspect streamed SSR HTML, metadata timing, and crawler-specific delivery from the command line.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ssrwire": "./dist/bin.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "examples",
21
+ "README.md",
22
+ "LICENSE",
23
+ "SECURITY.md",
24
+ "CHANGELOG.md",
25
+ "CONTRIBUTING.md",
26
+ "PUBLISHING.md"
27
+ ],
28
+ "scripts": {
29
+ "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json",
30
+ "check": "npm run lint && npm run typecheck && npm run test && npm run build && npm run package:check",
31
+ "dev": "tsx src/bin.ts",
32
+ "format": "biome check --write .",
33
+ "lint": "biome check .",
34
+ "package:check": "node scripts/package-check.mjs",
35
+ "prepack": "npm run build",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "typecheck": "tsc --noEmit -p tsconfig.json"
39
+ },
40
+ "engines": {
41
+ "node": ">=22.12.0"
42
+ },
43
+ "keywords": [
44
+ "ssr",
45
+ "seo",
46
+ "technical-seo",
47
+ "streaming-html",
48
+ "nextjs",
49
+ "nuxt",
50
+ "astro",
51
+ "crawler",
52
+ "cli",
53
+ "typescript",
54
+ "seo-tools",
55
+ "github-actions",
56
+ "sarif"
57
+ ],
58
+ "author": {
59
+ "name": "Niko M.",
60
+ "url": "https://nikom.work"
61
+ },
62
+ "homepage": "https://nikom.work",
63
+ "repository": {
64
+ "type": "git",
65
+ "url": "git+https://github.com/lame13/ssrwire.git"
66
+ },
67
+ "bugs": {
68
+ "url": "https://github.com/lame13/ssrwire/issues"
69
+ },
70
+ "license": "MIT",
71
+ "publishConfig": {
72
+ "access": "public"
73
+ },
74
+ "allowScripts": {
75
+ "esbuild@0.28.2": true,
76
+ "fsevents": false
77
+ },
78
+ "dependencies": {
79
+ "commander": "14.0.0",
80
+ "htmlparser2": "10.0.0",
81
+ "yaml": "2.9.0",
82
+ "zod": "4.4.3"
83
+ },
84
+ "devDependencies": {
85
+ "@biomejs/biome": "2.5.10",
86
+ "@types/node": "22.20.1",
87
+ "tsx": "4.23.12",
88
+ "typescript": "5.9.3",
89
+ "vitest": "3.2.7"
90
+ }
91
+ }
package/src/agents.ts ADDED
@@ -0,0 +1,129 @@
1
+ import type { AgentProfile } from "./types.js";
2
+
3
+ export const BUILTIN_AGENTS: Readonly<Record<string, AgentProfile>> = Object.freeze({
4
+ browser: Object.freeze({
5
+ key: "browser",
6
+ label: "Browser",
7
+ userAgent:
8
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
9
+ requiresHeadMetadata: false,
10
+ }),
11
+ googlebot: Object.freeze({
12
+ key: "googlebot",
13
+ label: "Googlebot",
14
+ userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
15
+ requiresHeadMetadata: false,
16
+ }),
17
+ bingbot: Object.freeze({
18
+ key: "bingbot",
19
+ label: "Bingbot",
20
+ userAgent: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
21
+ requiresHeadMetadata: true,
22
+ }),
23
+ twitterbot: Object.freeze({
24
+ key: "twitterbot",
25
+ label: "Twitterbot",
26
+ userAgent: "Twitterbot/1.0",
27
+ requiresHeadMetadata: true,
28
+ }),
29
+ facebook: Object.freeze({
30
+ key: "facebook",
31
+ label: "Facebook crawler",
32
+ userAgent: "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",
33
+ requiresHeadMetadata: true,
34
+ }),
35
+ });
36
+
37
+ export type BuiltinAgentKey = "browser" | "googlebot" | "bingbot" | "twitterbot" | "facebook";
38
+ export type AgentInput =
39
+ | string
40
+ | {
41
+ readonly key: string;
42
+ readonly label?: string;
43
+ readonly userAgent: string;
44
+ readonly requiresHeadMetadata?: boolean;
45
+ };
46
+
47
+ const ALIASES: Readonly<Record<string, BuiltinAgentKey>> = Object.freeze({
48
+ browser: "browser",
49
+ chrome: "browser",
50
+ google: "googlebot",
51
+ googlebot: "googlebot",
52
+ bing: "bingbot",
53
+ bingbot: "bingbot",
54
+ twitter: "twitterbot",
55
+ twitterbot: "twitterbot",
56
+ x: "twitterbot",
57
+ facebook: "facebook",
58
+ facebookbot: "facebook",
59
+ facebookexternalhit: "facebook",
60
+ });
61
+
62
+ function isCustomAgent(value: unknown): value is Exclude<AgentInput, string> {
63
+ if (typeof value !== "object" || value === null) return false;
64
+ const candidate = value as {
65
+ readonly key?: unknown;
66
+ readonly label?: unknown;
67
+ readonly userAgent?: unknown;
68
+ readonly requiresHeadMetadata?: unknown;
69
+ };
70
+ return (
71
+ typeof candidate.key === "string" &&
72
+ candidate.key.trim().length > 0 &&
73
+ typeof candidate.userAgent === "string" &&
74
+ candidate.userAgent.trim().length > 0 &&
75
+ (candidate.label === undefined ||
76
+ (typeof candidate.label === "string" && candidate.label.trim().length > 0)) &&
77
+ (candidate.requiresHeadMetadata === undefined ||
78
+ typeof candidate.requiresHeadMetadata === "boolean")
79
+ );
80
+ }
81
+
82
+ /** Resolve a built-in key/alias or validate a complete custom agent profile. */
83
+ export function resolveAgent(input: AgentInput): AgentProfile {
84
+ if (typeof input === "string") {
85
+ const normalized = input.trim().toLowerCase();
86
+ const key = ALIASES[normalized];
87
+ if (key === undefined) {
88
+ throw new Error(
89
+ `Unknown agent "${input}". Use browser, googlebot, bingbot, twitterbot, facebook, or a custom agent object.`,
90
+ );
91
+ }
92
+ const agent = BUILTIN_AGENTS[key];
93
+ if (agent === undefined) {
94
+ throw new Error(`Built-in agent "${key}" is unavailable.`);
95
+ }
96
+ return agent;
97
+ }
98
+
99
+ if (!isCustomAgent(input)) {
100
+ throw new TypeError(
101
+ "Custom agents require non-empty key and userAgent strings; label and requiresHeadMetadata are optional.",
102
+ );
103
+ }
104
+
105
+ if (/[\r\n]/u.test(input.userAgent)) {
106
+ throw new TypeError("Custom agent userAgent cannot contain a line break.");
107
+ }
108
+
109
+ return Object.freeze({
110
+ key: input.key.trim(),
111
+ label: input.label?.trim() ?? input.key.trim(),
112
+ userAgent: input.userAgent.trim(),
113
+ requiresHeadMetadata: input.requiresHeadMetadata ?? false,
114
+ });
115
+ }
116
+
117
+ /** Resolve an ordered list and reject duplicate keys, which would make reports ambiguous. */
118
+ export function resolveAgents(inputs: readonly AgentInput[]): AgentProfile[] {
119
+ const resolved = inputs.map(resolveAgent);
120
+ const seen = new Set<string>();
121
+ for (const agent of resolved) {
122
+ const normalized = agent.key.toLowerCase();
123
+ if (seen.has(normalized)) {
124
+ throw new Error(`Duplicate agent key "${agent.key}".`);
125
+ }
126
+ seen.add(normalized);
127
+ }
128
+ return resolved;
129
+ }