@lownoise-studio/rendershield 0.3.1 → 1.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.
- package/CHANGELOG.md +73 -32
- package/CONTRIBUTING.md +41 -0
- package/README.md +227 -144
- package/SECURITY.md +25 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +39 -26
- package/dist/cli.js.map +1 -1
- package/dist/cliArgs.d.ts +8 -0
- package/dist/cliArgs.d.ts.map +1 -0
- package/dist/cliArgs.js +56 -0
- package/dist/cliArgs.js.map +1 -0
- package/dist/commands/build.d.ts +3 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +12 -11
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +8 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/verify.d.ts +33 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +202 -117
- package/dist/commands/verify.js.map +1 -1
- package/dist/configPath.d.ts +7 -0
- package/dist/configPath.d.ts.map +1 -0
- package/dist/configPath.js +8 -0
- package/dist/configPath.js.map +1 -0
- package/dist/core/generateRobots.d.ts +3 -0
- package/dist/core/generateRobots.d.ts.map +1 -0
- package/dist/core/generateSitemap.d.ts +3 -0
- package/dist/core/generateSitemap.d.ts.map +1 -0
- package/dist/core/generateWorker.d.ts +3 -0
- package/dist/core/generateWorker.d.ts.map +1 -0
- package/dist/core/generateWorker.js +75 -75
- package/dist/core/generateWorker.js.map +1 -1
- package/dist/core/listOutputRoutes.d.ts +4 -0
- package/dist/core/listOutputRoutes.d.ts.map +1 -0
- package/dist/core/listOutputRoutes.js +40 -0
- package/dist/core/listOutputRoutes.js.map +1 -0
- package/dist/core/loadConfig.d.ts +5 -0
- package/dist/core/loadConfig.d.ts.map +1 -0
- package/dist/core/loadConfig.js +108 -58
- package/dist/core/loadConfig.js.map +1 -1
- package/dist/core/loadMarkdown.d.ts +3 -0
- package/dist/core/loadMarkdown.d.ts.map +1 -0
- package/dist/core/loadMarkdown.js +4 -3
- package/dist/core/loadMarkdown.js.map +1 -1
- package/dist/core/renderHtml.d.ts +3 -0
- package/dist/core/renderHtml.d.ts.map +1 -0
- package/dist/core/renderHtml.js +25 -10
- package/dist/core/renderHtml.js.map +1 -1
- package/dist/core/validateOutput.d.ts +28 -0
- package/dist/core/validateOutput.d.ts.map +1 -0
- package/dist/core/validateOutput.js +7 -1
- package/dist/core/validateOutput.js.map +1 -1
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/docs/CONFIG.md +70 -0
- package/docs/deploy-cloudflare.md +40 -14
- package/package.json +25 -2
- package/rendershield.config.schema.json +100 -0
- package/src/cli.ts +96 -75
- package/src/cliArgs.ts +71 -0
- package/src/commands/build.ts +200 -185
- package/src/commands/init.ts +8 -8
- package/src/commands/verify.ts +451 -236
- package/src/configPath.ts +17 -0
- package/src/core/generateWorker.ts +97 -97
- package/src/core/listOutputRoutes.ts +51 -0
- package/src/core/loadConfig.ts +282 -173
- package/src/core/loadMarkdown.ts +9 -3
- package/src/core/renderHtml.ts +36 -12
- package/src/core/validateOutput.ts +335 -328
- package/src/errors.ts +48 -0
- package/src/index.ts +43 -0
- package/src/types.ts +5 -2
package/src/commands/verify.ts
CHANGED
|
@@ -1,236 +1,451 @@
|
|
|
1
|
-
import fs from "fs-extra";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { loadConfig } from "../core/loadConfig.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "../core/loadConfig.js";
|
|
4
|
+
import {
|
|
5
|
+
listPrerenderIndexFiles,
|
|
6
|
+
indexHtmlPathToRoute,
|
|
7
|
+
} from "../core/listOutputRoutes.js";
|
|
8
|
+
import {
|
|
9
|
+
checkPrerenderContract,
|
|
10
|
+
type ContractCheckResult,
|
|
11
|
+
} from "../core/validateOutput.js";
|
|
12
|
+
import { renderShieldError } from "../errors.js";
|
|
13
|
+
import type { CommandOptions } from "../configPath.js";
|
|
14
|
+
|
|
15
|
+
function joinUrl(base: string, routePath: string): string {
|
|
16
|
+
const b = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
17
|
+
const p = routePath.startsWith("/") ? routePath : `/${routePath}`;
|
|
18
|
+
return b + p;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BOT_UA =
|
|
22
|
+
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
|
|
23
|
+
const HUMAN_UA =
|
|
24
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
25
|
+
|
|
26
|
+
function looksLikeSpaShell(html: string): { likely: boolean; reason?: string } {
|
|
27
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
28
|
+
const bodyHtml = bodyMatch ? bodyMatch[1] : html;
|
|
29
|
+
const noScript = bodyHtml
|
|
30
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
31
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
32
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ");
|
|
33
|
+
const text = noScript.replace(/<\/?[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
34
|
+
if (text.length < 150) {
|
|
35
|
+
return { likely: true, reason: `Body text very short (${text.length} chars); likely app shell.` };
|
|
36
|
+
}
|
|
37
|
+
if (!/<article\b/i.test(html)) {
|
|
38
|
+
return { likely: true, reason: "No <article> present; may be SPA shell." };
|
|
39
|
+
}
|
|
40
|
+
const rootOnly = /<body[^>]*>\s*<div[^>]*id=["'](?:root|app|__next)["'][^>]*>\s*<\/div>\s*<\/body>/i.test(
|
|
41
|
+
html.replace(/\s+/g, " ")
|
|
42
|
+
);
|
|
43
|
+
if (rootOnly) {
|
|
44
|
+
return { likely: true, reason: "Single root div (e.g. #root, #app) with no content." };
|
|
45
|
+
}
|
|
46
|
+
return { likely: false };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type VerifyPageResult = {
|
|
50
|
+
routePath: string;
|
|
51
|
+
outputFile: string;
|
|
52
|
+
url: string;
|
|
53
|
+
contract?: ContractCheckResult;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type VerifyOptions = CommandOptions & {
|
|
57
|
+
/** Set when --prod is passed (URL may be omitted with --all). */
|
|
58
|
+
prod?: boolean;
|
|
59
|
+
prodUrl?: string;
|
|
60
|
+
check?: boolean;
|
|
61
|
+
all?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type VerifyLocalResult = {
|
|
65
|
+
mode: "local";
|
|
66
|
+
canonicalBase: string;
|
|
67
|
+
checked: boolean;
|
|
68
|
+
ok: boolean;
|
|
69
|
+
pages: VerifyPageResult[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type VerifyProdResult = {
|
|
73
|
+
mode: "prod";
|
|
74
|
+
ok: boolean;
|
|
75
|
+
pages: Array<{ url: string; contract: ContractCheckResult }>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type VerifyResult = VerifyLocalResult | VerifyProdResult;
|
|
79
|
+
|
|
80
|
+
export async function cmdVerify(
|
|
81
|
+
cwd = process.cwd(),
|
|
82
|
+
options: VerifyOptions = {}
|
|
83
|
+
): Promise<VerifyResult> {
|
|
84
|
+
if (options.prod || options.prodUrl) {
|
|
85
|
+
return runVerifyProdMode(cwd, options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (options.check) {
|
|
89
|
+
return runLocalCheckMode(cwd, options);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (options.all) {
|
|
93
|
+
throw renderShieldError(
|
|
94
|
+
"CLI_INVALID_ARGS",
|
|
95
|
+
"verify --all requires --check (local) or --prod (production). Example: rendershield verify --all --check"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return runLocalSmoke(cwd, options);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runVerifyProdMode(
|
|
103
|
+
cwd: string,
|
|
104
|
+
options: VerifyOptions
|
|
105
|
+
): Promise<VerifyProdResult> {
|
|
106
|
+
const cfg = await loadConfig(cwd, options);
|
|
107
|
+
const outDirAbs = path.join(cwd, cfg.output.outDir);
|
|
108
|
+
|
|
109
|
+
if (options.all) {
|
|
110
|
+
await assertOutputExists(outDirAbs, cfg.output.outDir);
|
|
111
|
+
const indexFiles = await listPrerenderIndexFiles(outDirAbs);
|
|
112
|
+
if (indexFiles.length === 0) {
|
|
113
|
+
throw renderShieldError(
|
|
114
|
+
"VERIFY_FAILED",
|
|
115
|
+
`No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
|
|
116
|
+
{ outDir: cfg.output.outDir }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return runVerifyProdAll(cfg, cwd, outDirAbs, indexFiles);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!options.prodUrl) {
|
|
123
|
+
throw renderShieldError(
|
|
124
|
+
"CLI_INVALID_ARGS",
|
|
125
|
+
"verify --prod requires a URL, or use --prod --all to check every route from build output."
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const single = await fetchAndVerifyProd(
|
|
130
|
+
options.prodUrl.startsWith("http") ? options.prodUrl : `https://${options.prodUrl}`
|
|
131
|
+
);
|
|
132
|
+
return { mode: "prod", ok: true, pages: [single] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runLocalCheckMode(
|
|
136
|
+
cwd: string,
|
|
137
|
+
options: VerifyOptions
|
|
138
|
+
): Promise<VerifyLocalResult> {
|
|
139
|
+
const cfg = await loadConfig(cwd, options);
|
|
140
|
+
const outDirAbs = path.join(cwd, cfg.output.outDir);
|
|
141
|
+
await assertOutputExists(outDirAbs, cfg.output.outDir);
|
|
142
|
+
|
|
143
|
+
const indexFiles = await listPrerenderIndexFiles(outDirAbs);
|
|
144
|
+
if (indexFiles.length === 0) {
|
|
145
|
+
throw renderShieldError(
|
|
146
|
+
"VERIFY_FAILED",
|
|
147
|
+
`No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
|
|
148
|
+
{ outDir: cfg.output.outDir }
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (options.all) {
|
|
153
|
+
return runLocalCheckAll(cwd, cfg, outDirAbs, indexFiles);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return runLocalCheckOne(cwd, cfg, outDirAbs, indexFiles[0]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function assertOutputExists(outDirAbs: string, outDir: string): Promise<void> {
|
|
160
|
+
if (!(await fs.pathExists(outDirAbs))) {
|
|
161
|
+
throw renderShieldError(
|
|
162
|
+
"VERIFY_FAILED",
|
|
163
|
+
`No prerender output directory found: ${outDir}/. Run: rendershield build`,
|
|
164
|
+
{ outDir }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function runLocalSmoke(
|
|
170
|
+
cwd: string,
|
|
171
|
+
options: VerifyOptions
|
|
172
|
+
): Promise<VerifyLocalResult> {
|
|
173
|
+
const cfg = await loadConfig(cwd, options);
|
|
174
|
+
const outDirAbs = path.join(cwd, cfg.output.outDir);
|
|
175
|
+
await assertOutputExists(outDirAbs, cfg.output.outDir);
|
|
176
|
+
|
|
177
|
+
const indexFiles = await listPrerenderIndexFiles(outDirAbs);
|
|
178
|
+
if (indexFiles.length === 0) {
|
|
179
|
+
throw renderShieldError(
|
|
180
|
+
"VERIFY_FAILED",
|
|
181
|
+
`No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
|
|
182
|
+
{ outDir: cfg.output.outDir }
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const firstIndex = indexFiles[0];
|
|
187
|
+
const routePath = indexHtmlPathToRoute(outDirAbs, firstIndex);
|
|
188
|
+
const url = joinUrl(cfg.site.canonicalBase, routePath);
|
|
189
|
+
const outputFile = path.relative(cwd, firstIndex);
|
|
190
|
+
|
|
191
|
+
console.log(`
|
|
192
|
+
RenderShield verify
|
|
193
|
+
|
|
194
|
+
Using:
|
|
195
|
+
canonicalBase: ${cfg.site.canonicalBase}
|
|
196
|
+
routePath: ${routePath}
|
|
197
|
+
output file: ${outputFile}
|
|
198
|
+
pages in output: ${indexFiles.length} (showing first; use --all --check to validate all)
|
|
199
|
+
|
|
200
|
+
Smoke tests:
|
|
201
|
+
|
|
202
|
+
1) Human (usually SPA shell):
|
|
203
|
+
curl -s ${url} | grep -i "<title>"
|
|
204
|
+
|
|
205
|
+
2) Bot (should see prerendered, route-specific title):
|
|
206
|
+
curl -s -H "User-Agent: Googlebot" ${url} | grep -i "<title>"
|
|
207
|
+
|
|
208
|
+
3) Debug headers (Worker must be routed + proxy ON):
|
|
209
|
+
curl -I -H "User-Agent: GPTBot" ${url}
|
|
210
|
+
|
|
211
|
+
Expected: x-rendershield: bot-hit (proves Worker served prerender to bot).
|
|
212
|
+
If debugHeaders enabled: X-Bot-Detected, X-Prerender, X-Final-Path.
|
|
213
|
+
|
|
214
|
+
Tip: rendershield verify --check validates built HTML without fetching production.
|
|
215
|
+
`);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
mode: "local",
|
|
219
|
+
canonicalBase: cfg.site.canonicalBase,
|
|
220
|
+
checked: false,
|
|
221
|
+
ok: true,
|
|
222
|
+
pages: [{ routePath, outputFile, url }],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runLocalCheckOne(
|
|
227
|
+
cwd: string,
|
|
228
|
+
cfg: Awaited<ReturnType<typeof loadConfig>>,
|
|
229
|
+
outDirAbs: string,
|
|
230
|
+
indexPath: string
|
|
231
|
+
): Promise<VerifyLocalResult> {
|
|
232
|
+
const page = await checkLocalPage(cwd, outDirAbs, indexPath, cfg.site.canonicalBase);
|
|
233
|
+
const ok = page.contract?.ok ?? false;
|
|
234
|
+
|
|
235
|
+
console.log(`
|
|
236
|
+
RenderShield verify --check
|
|
237
|
+
Route: ${page.routePath}
|
|
238
|
+
File: ${page.outputFile}
|
|
239
|
+
Contract: ${ok ? "PASS" : "FAIL"}
|
|
240
|
+
${!ok && page.contract ? page.contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
|
|
241
|
+
`);
|
|
242
|
+
|
|
243
|
+
if (!ok) {
|
|
244
|
+
throw renderShieldError(
|
|
245
|
+
"VERIFY_FAILED",
|
|
246
|
+
`Built HTML failed contract for ${page.routePath}. Missing: ${page.contract?.missing.join("; ")}`,
|
|
247
|
+
{ routePath: page.routePath, missing: page.contract?.missing }
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
mode: "local",
|
|
253
|
+
canonicalBase: cfg.site.canonicalBase,
|
|
254
|
+
checked: true,
|
|
255
|
+
ok: true,
|
|
256
|
+
pages: [page],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function runLocalCheckAll(
|
|
261
|
+
cwd: string,
|
|
262
|
+
cfg: Awaited<ReturnType<typeof loadConfig>>,
|
|
263
|
+
outDirAbs: string,
|
|
264
|
+
indexFiles: string[]
|
|
265
|
+
): Promise<VerifyLocalResult> {
|
|
266
|
+
const pages: VerifyPageResult[] = [];
|
|
267
|
+
const failures: string[] = [];
|
|
268
|
+
|
|
269
|
+
for (const indexPath of indexFiles) {
|
|
270
|
+
const page = await checkLocalPage(cwd, outDirAbs, indexPath, cfg.site.canonicalBase);
|
|
271
|
+
pages.push(page);
|
|
272
|
+
if (!page.contract?.ok) {
|
|
273
|
+
failures.push(
|
|
274
|
+
`${page.routePath}: ${page.contract?.missing.join("; ") ?? "contract failed"}`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log(`
|
|
280
|
+
RenderShield verify --all --check
|
|
281
|
+
Pages: ${pages.length}
|
|
282
|
+
${pages
|
|
283
|
+
.map((p) => ` ${p.contract?.ok ? "PASS" : "FAIL"} ${p.routePath}`)
|
|
284
|
+
.join("\n")}
|
|
285
|
+
`);
|
|
286
|
+
|
|
287
|
+
if (failures.length > 0) {
|
|
288
|
+
throw renderShieldError(
|
|
289
|
+
"VERIFY_FAILED",
|
|
290
|
+
`Built HTML failed contract on ${failures.length} page(s). ${failures.join(" | ")}`,
|
|
291
|
+
{ failures }
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
mode: "local",
|
|
297
|
+
canonicalBase: cfg.site.canonicalBase,
|
|
298
|
+
checked: true,
|
|
299
|
+
ok: true,
|
|
300
|
+
pages,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function checkLocalPage(
|
|
305
|
+
cwd: string,
|
|
306
|
+
outDirAbs: string,
|
|
307
|
+
indexPath: string,
|
|
308
|
+
canonicalBase: string
|
|
309
|
+
): Promise<VerifyPageResult> {
|
|
310
|
+
const html = await fs.readFile(indexPath, "utf8");
|
|
311
|
+
const routePath = indexHtmlPathToRoute(outDirAbs, indexPath);
|
|
312
|
+
const outputFile = path.relative(cwd, indexPath);
|
|
313
|
+
const contract = checkPrerenderContract(html, {
|
|
314
|
+
routePath,
|
|
315
|
+
outFile: outputFile,
|
|
316
|
+
});
|
|
317
|
+
return {
|
|
318
|
+
routePath,
|
|
319
|
+
outputFile,
|
|
320
|
+
url: joinUrl(canonicalBase, routePath),
|
|
321
|
+
contract,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function runVerifyProdAll(
|
|
326
|
+
cfg: Awaited<ReturnType<typeof loadConfig>>,
|
|
327
|
+
_cwd: string,
|
|
328
|
+
outDirAbs: string,
|
|
329
|
+
indexFiles: string[]
|
|
330
|
+
): Promise<VerifyProdResult> {
|
|
331
|
+
const pages: VerifyProdResult["pages"] = [];
|
|
332
|
+
const failures: string[] = [];
|
|
333
|
+
|
|
334
|
+
for (const indexPath of indexFiles) {
|
|
335
|
+
const routePath = indexHtmlPathToRoute(outDirAbs, indexPath);
|
|
336
|
+
const prodUrl = joinUrl(cfg.site.canonicalBase, routePath);
|
|
337
|
+
try {
|
|
338
|
+
const result = await fetchAndVerifyProd(prodUrl);
|
|
339
|
+
pages.push(result);
|
|
340
|
+
} catch (err: unknown) {
|
|
341
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
342
|
+
failures.push(`${routePath}: ${msg}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
console.log(`
|
|
347
|
+
RenderShield verify --prod --all
|
|
348
|
+
Checked ${pages.length} URL(s) from build output.
|
|
349
|
+
`);
|
|
350
|
+
|
|
351
|
+
if (failures.length > 0) {
|
|
352
|
+
throw renderShieldError(
|
|
353
|
+
"VERIFY_FAILED",
|
|
354
|
+
`Production verify failed for ${failures.length} route(s). ${failures.join(" | ")}`,
|
|
355
|
+
{ failures }
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { mode: "prod", ok: true, pages };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function fetchAndVerifyProd(
|
|
363
|
+
normalizedUrl: string
|
|
364
|
+
): Promise<{ url: string; contract: ContractCheckResult }> {
|
|
365
|
+
let botHtml: string;
|
|
366
|
+
let humanHtml: string;
|
|
367
|
+
let botStatus: number;
|
|
368
|
+
let humanStatus: number;
|
|
369
|
+
let xRenderShield: string | null;
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
const [botRes, humanRes] = await Promise.all([
|
|
373
|
+
fetch(normalizedUrl, {
|
|
374
|
+
headers: { "User-Agent": BOT_UA },
|
|
375
|
+
redirect: "follow",
|
|
376
|
+
}),
|
|
377
|
+
fetch(normalizedUrl, {
|
|
378
|
+
headers: { "User-Agent": HUMAN_UA },
|
|
379
|
+
redirect: "follow",
|
|
380
|
+
}),
|
|
381
|
+
]);
|
|
382
|
+
|
|
383
|
+
botStatus = botRes.status;
|
|
384
|
+
humanStatus = humanRes.status;
|
|
385
|
+
xRenderShield = botRes.headers.get("x-rendershield");
|
|
386
|
+
botHtml = await botRes.text();
|
|
387
|
+
humanHtml = await humanRes.text();
|
|
388
|
+
} catch (err: unknown) {
|
|
389
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
390
|
+
throw renderShieldError(
|
|
391
|
+
"VERIFY_FAILED",
|
|
392
|
+
`verify --prod: failed to fetch ${normalizedUrl}. ${msg}`,
|
|
393
|
+
{ url: normalizedUrl }
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (xRenderShield === "bot-fallback") {
|
|
398
|
+
throw renderShieldError(
|
|
399
|
+
"VERIFY_FAILED",
|
|
400
|
+
`verify --prod: bot request received x-rendershield: bot-fallback for ${normalizedUrl}. ` +
|
|
401
|
+
`Prerender origin returned non-200; Worker fell back to SPA.`,
|
|
402
|
+
{ url: normalizedUrl, xRenderShield }
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (xRenderShield !== "bot-hit") {
|
|
406
|
+
const hint = xRenderShield == null
|
|
407
|
+
? " If no Worker is deployed, use verify --check for local/build output."
|
|
408
|
+
: "";
|
|
409
|
+
throw renderShieldError(
|
|
410
|
+
"VERIFY_FAILED",
|
|
411
|
+
`verify --prod: expected x-rendershield: bot-hit for ${normalizedUrl}. ` +
|
|
412
|
+
`Got: ${xRenderShield ?? "(missing)"}.${hint}`,
|
|
413
|
+
{ url: normalizedUrl, xRenderShield }
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const contract = checkPrerenderContract(botHtml, {
|
|
418
|
+
routePath: normalizedUrl,
|
|
419
|
+
outFile: normalizedUrl,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const humanSpa = looksLikeSpaShell(humanHtml);
|
|
423
|
+
|
|
424
|
+
console.log(`
|
|
425
|
+
RenderShield verify --prod
|
|
426
|
+
URL: ${normalizedUrl}
|
|
427
|
+
|
|
428
|
+
Fetch:
|
|
429
|
+
Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield}
|
|
430
|
+
Human (Chrome): ${humanStatus} (${humanHtml.length} bytes)
|
|
431
|
+
|
|
432
|
+
Routing: x-rendershield: bot-hit
|
|
433
|
+
|
|
434
|
+
Bot contract:
|
|
435
|
+
${contract.ok ? "PASS" : "FAIL"}
|
|
436
|
+
${contract.missing.length > 0 ? contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
|
|
437
|
+
|
|
438
|
+
Human response:
|
|
439
|
+
${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content."}
|
|
440
|
+
`);
|
|
441
|
+
|
|
442
|
+
if (!contract.ok) {
|
|
443
|
+
throw renderShieldError(
|
|
444
|
+
"VERIFY_FAILED",
|
|
445
|
+
`Production URL did not satisfy bot contract: ${normalizedUrl}. Missing: ${contract.missing.join("; ")}`,
|
|
446
|
+
{ url: normalizedUrl, missing: contract.missing }
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return { url: normalizedUrl, contract };
|
|
451
|
+
}
|