@lownoise-studio/rendershield 1.0.0 → 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 +15 -0
- package/README.md +32 -14
- package/dist/cli.js +28 -23
- 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 +2 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +2 -2
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/init.d.ts +2 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/verify.d.ts +22 -8
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +158 -66
- 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/generateWorker.js +1 -1
- 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 +3 -1
- package/dist/core/loadConfig.d.ts.map +1 -1
- package/dist/core/loadConfig.js +45 -38
- package/dist/core/loadConfig.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/docs/CONFIG.md +70 -0
- package/package.json +2 -1
- package/rendershield.config.schema.json +100 -0
- package/src/cli.ts +36 -26
- package/src/cliArgs.ts +71 -0
- package/src/commands/build.ts +3 -2
- package/src/commands/init.ts +8 -8
- package/src/commands/verify.ts +264 -79
- package/src/configPath.ts +17 -0
- package/src/core/generateWorker.ts +1 -1
- package/src/core/listOutputRoutes.ts +51 -0
- package/src/core/loadConfig.ts +59 -38
- package/src/index.ts +4 -1
- package/src/types.ts +1 -1
package/src/commands/verify.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import fs from "fs-extra";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { loadConfig } from "../core/loadConfig.js";
|
|
4
|
+
import {
|
|
5
|
+
listPrerenderIndexFiles,
|
|
6
|
+
indexHtmlPathToRoute,
|
|
7
|
+
} from "../core/listOutputRoutes.js";
|
|
4
8
|
import {
|
|
5
9
|
checkPrerenderContract,
|
|
6
10
|
type ContractCheckResult,
|
|
7
11
|
} from "../core/validateOutput.js";
|
|
8
12
|
import { renderShieldError } from "../errors.js";
|
|
13
|
+
import type { CommandOptions } from "../configPath.js";
|
|
9
14
|
|
|
10
15
|
function joinUrl(base: string, routePath: string): string {
|
|
11
16
|
const b = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
@@ -13,56 +18,11 @@ function joinUrl(base: string, routePath: string): string {
|
|
|
13
18
|
return b + p;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
async function findFirstIndexHtml(outDirAbs: string): Promise<string | null> {
|
|
17
|
-
const stack: string[] = [outDirAbs];
|
|
18
|
-
|
|
19
|
-
while (stack.length > 0) {
|
|
20
|
-
const current = stack.pop() as string;
|
|
21
|
-
|
|
22
|
-
let entries: fs.Dirent[];
|
|
23
|
-
try {
|
|
24
|
-
entries = await fs.readdir(current, { withFileTypes: true });
|
|
25
|
-
} catch {
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
30
|
-
|
|
31
|
-
for (const entry of entries) {
|
|
32
|
-
const full = path.join(current, entry.name);
|
|
33
|
-
|
|
34
|
-
if (entry.isDirectory()) {
|
|
35
|
-
stack.push(full);
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (entry.isFile() && entry.name.toLowerCase() === "index.html") {
|
|
40
|
-
// Ignore index.html at the output root; prefer a routed page like
|
|
41
|
-
// <section>/<slug>/index.html (or deeper).
|
|
42
|
-
const rel = path.relative(outDirAbs, full);
|
|
43
|
-
const parts = rel.split(path.sep).filter(Boolean);
|
|
44
|
-
if (parts.length >= 2) return full;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function indexHtmlPathToRoute(outDirAbs: string, indexPathAbs: string): string {
|
|
53
|
-
const rel = path.relative(outDirAbs, indexPathAbs);
|
|
54
|
-
// rel: <section>/<slug>/index.html
|
|
55
|
-
const noFile = rel.replace(/index\.html$/i, "");
|
|
56
|
-
const normalized = noFile.split(path.sep).join("/").replace(/\/+$/, "");
|
|
57
|
-
return "/" + normalized.replace(/^\/+/, "");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
21
|
const BOT_UA =
|
|
61
22
|
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
|
|
62
23
|
const HUMAN_UA =
|
|
63
24
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
64
25
|
|
|
65
|
-
/** Heuristic: likely SPA shell if body has almost no visible content and no article. */
|
|
66
26
|
function looksLikeSpaShell(html: string): { likely: boolean; reason?: string } {
|
|
67
27
|
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
68
28
|
const bodyHtml = bodyMatch ? bodyMatch[1] : html;
|
|
@@ -86,48 +46,136 @@ function looksLikeSpaShell(html: string): { likely: boolean; reason?: string } {
|
|
|
86
46
|
return { likely: false };
|
|
87
47
|
}
|
|
88
48
|
|
|
89
|
-
export type
|
|
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
|
+
};
|
|
90
63
|
|
|
91
64
|
export type VerifyLocalResult = {
|
|
92
65
|
mode: "local";
|
|
93
66
|
canonicalBase: string;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
67
|
+
checked: boolean;
|
|
68
|
+
ok: boolean;
|
|
69
|
+
pages: VerifyPageResult[];
|
|
97
70
|
};
|
|
98
71
|
|
|
99
72
|
export type VerifyProdResult = {
|
|
100
73
|
mode: "prod";
|
|
101
|
-
|
|
102
|
-
contract: ContractCheckResult
|
|
74
|
+
ok: boolean;
|
|
75
|
+
pages: Array<{ url: string; contract: ContractCheckResult }>;
|
|
103
76
|
};
|
|
104
77
|
|
|
105
78
|
export type VerifyResult = VerifyLocalResult | VerifyProdResult;
|
|
106
79
|
|
|
107
80
|
export async function cmdVerify(
|
|
108
81
|
cwd = process.cwd(),
|
|
109
|
-
options
|
|
82
|
+
options: VerifyOptions = {}
|
|
110
83
|
): Promise<VerifyResult> {
|
|
111
|
-
if (options
|
|
112
|
-
return
|
|
84
|
+
if (options.prod || options.prodUrl) {
|
|
85
|
+
return runVerifyProdMode(cwd, options);
|
|
113
86
|
}
|
|
114
87
|
|
|
115
|
-
|
|
88
|
+
if (options.check) {
|
|
89
|
+
return runLocalCheckMode(cwd, options);
|
|
90
|
+
}
|
|
116
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);
|
|
117
107
|
const outDirAbs = path.join(cwd, cfg.output.outDir);
|
|
118
|
-
const exists = await fs.pathExists(outDirAbs);
|
|
119
108
|
|
|
120
|
-
if (
|
|
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) {
|
|
121
145
|
throw renderShieldError(
|
|
122
146
|
"VERIFY_FAILED",
|
|
123
|
-
`No
|
|
147
|
+
`No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
|
|
124
148
|
{ outDir: cfg.output.outDir }
|
|
125
149
|
);
|
|
126
150
|
}
|
|
127
151
|
|
|
128
|
-
|
|
152
|
+
if (options.all) {
|
|
153
|
+
return runLocalCheckAll(cwd, cfg, outDirAbs, indexFiles);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return runLocalCheckOne(cwd, cfg, outDirAbs, indexFiles[0]);
|
|
157
|
+
}
|
|
129
158
|
|
|
130
|
-
|
|
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) {
|
|
131
179
|
throw renderShieldError(
|
|
132
180
|
"VERIFY_FAILED",
|
|
133
181
|
`No prerendered pages found inside: ${cfg.output.outDir}/. Run: rendershield build`,
|
|
@@ -135,6 +183,7 @@ export async function cmdVerify(
|
|
|
135
183
|
);
|
|
136
184
|
}
|
|
137
185
|
|
|
186
|
+
const firstIndex = indexFiles[0];
|
|
138
187
|
const routePath = indexHtmlPathToRoute(outDirAbs, firstIndex);
|
|
139
188
|
const url = joinUrl(cfg.site.canonicalBase, routePath);
|
|
140
189
|
const outputFile = path.relative(cwd, firstIndex);
|
|
@@ -146,6 +195,7 @@ Using:
|
|
|
146
195
|
canonicalBase: ${cfg.site.canonicalBase}
|
|
147
196
|
routePath: ${routePath}
|
|
148
197
|
output file: ${outputFile}
|
|
198
|
+
pages in output: ${indexFiles.length} (showing first; use --all --check to validate all)
|
|
149
199
|
|
|
150
200
|
Smoke tests:
|
|
151
201
|
|
|
@@ -160,20 +210,158 @@ Smoke tests:
|
|
|
160
210
|
|
|
161
211
|
Expected: x-rendershield: bot-hit (proves Worker served prerender to bot).
|
|
162
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")}
|
|
163
285
|
`);
|
|
164
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
|
+
|
|
165
295
|
return {
|
|
166
296
|
mode: "local",
|
|
167
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 {
|
|
168
318
|
routePath,
|
|
169
319
|
outputFile,
|
|
170
|
-
url,
|
|
320
|
+
url: joinUrl(canonicalBase, routePath),
|
|
321
|
+
contract,
|
|
171
322
|
};
|
|
172
323
|
}
|
|
173
324
|
|
|
174
|
-
async function
|
|
175
|
-
|
|
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
|
+
}
|
|
176
361
|
|
|
362
|
+
async function fetchAndVerifyProd(
|
|
363
|
+
normalizedUrl: string
|
|
364
|
+
): Promise<{ url: string; contract: ContractCheckResult }> {
|
|
177
365
|
let botHtml: string;
|
|
178
366
|
let humanHtml: string;
|
|
179
367
|
let botStatus: number;
|
|
@@ -206,24 +394,22 @@ async function runVerifyProd(url: string): Promise<VerifyProdResult> {
|
|
|
206
394
|
);
|
|
207
395
|
}
|
|
208
396
|
|
|
209
|
-
// Prove routing: Worker must set x-rendershield: bot-hit for bot requests. No inference.
|
|
210
|
-
const routingOk = xRenderShield === "bot-hit";
|
|
211
397
|
if (xRenderShield === "bot-fallback") {
|
|
212
398
|
throw renderShieldError(
|
|
213
399
|
"VERIFY_FAILED",
|
|
214
|
-
`verify --prod: bot request received x-rendershield: bot-fallback. ` +
|
|
215
|
-
`Prerender origin returned non-200; Worker fell back to SPA
|
|
400
|
+
`verify --prod: bot request received x-rendershield: bot-fallback for ${normalizedUrl}. ` +
|
|
401
|
+
`Prerender origin returned non-200; Worker fell back to SPA.`,
|
|
216
402
|
{ url: normalizedUrl, xRenderShield }
|
|
217
403
|
);
|
|
218
404
|
}
|
|
219
|
-
if (
|
|
405
|
+
if (xRenderShield !== "bot-hit") {
|
|
220
406
|
const hint = xRenderShield == null
|
|
221
|
-
? " If no Worker is deployed, use verify
|
|
407
|
+
? " If no Worker is deployed, use verify --check for local/build output."
|
|
222
408
|
: "";
|
|
223
409
|
throw renderShieldError(
|
|
224
410
|
"VERIFY_FAILED",
|
|
225
|
-
`verify --prod: expected x-rendershield: bot-hit
|
|
226
|
-
`Got: ${xRenderShield ?? "(missing)"}
|
|
411
|
+
`verify --prod: expected x-rendershield: bot-hit for ${normalizedUrl}. ` +
|
|
412
|
+
`Got: ${xRenderShield ?? "(missing)"}.${hint}`,
|
|
227
413
|
{ url: normalizedUrl, xRenderShield }
|
|
228
414
|
);
|
|
229
415
|
}
|
|
@@ -235,32 +421,31 @@ async function runVerifyProd(url: string): Promise<VerifyProdResult> {
|
|
|
235
421
|
|
|
236
422
|
const humanSpa = looksLikeSpaShell(humanHtml);
|
|
237
423
|
|
|
238
|
-
// Report
|
|
239
424
|
console.log(`
|
|
240
425
|
RenderShield verify --prod
|
|
241
426
|
URL: ${normalizedUrl}
|
|
242
427
|
|
|
243
428
|
Fetch:
|
|
244
|
-
Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield
|
|
429
|
+
Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield}
|
|
245
430
|
Human (Chrome): ${humanStatus} (${humanHtml.length} bytes)
|
|
246
431
|
|
|
247
|
-
Routing: x-rendershield: bot-hit
|
|
432
|
+
Routing: x-rendershield: bot-hit
|
|
248
433
|
|
|
249
|
-
Bot contract
|
|
250
|
-
${contract.ok ? "PASS
|
|
434
|
+
Bot contract:
|
|
435
|
+
${contract.ok ? "PASS" : "FAIL"}
|
|
251
436
|
${contract.missing.length > 0 ? contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
|
|
252
437
|
|
|
253
438
|
Human response:
|
|
254
|
-
${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content
|
|
439
|
+
${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content."}
|
|
255
440
|
`);
|
|
256
441
|
|
|
257
442
|
if (!contract.ok) {
|
|
258
443
|
throw renderShieldError(
|
|
259
444
|
"VERIFY_FAILED",
|
|
260
|
-
`Production URL did not satisfy bot contract. Missing: ${contract.missing.join("; ")}`,
|
|
445
|
+
`Production URL did not satisfy bot contract: ${normalizedUrl}. Missing: ${contract.missing.join("; ")}`,
|
|
261
446
|
{ url: normalizedUrl, missing: contract.missing }
|
|
262
447
|
);
|
|
263
448
|
}
|
|
264
449
|
|
|
265
|
-
return {
|
|
450
|
+
return { url: normalizedUrl, contract };
|
|
266
451
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_CONFIG_NAME = "rendershield.config.json";
|
|
4
|
+
|
|
5
|
+
export type CommandOptions = {
|
|
6
|
+
/** Relative to cwd, or absolute. Defaults to rendershield.config.json in cwd. */
|
|
7
|
+
configPath?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function resolveConfigFile(
|
|
11
|
+
cwd: string,
|
|
12
|
+
configPath = DEFAULT_CONFIG_NAME
|
|
13
|
+
): string {
|
|
14
|
+
return path.isAbsolute(configPath)
|
|
15
|
+
? configPath
|
|
16
|
+
: path.join(cwd, configPath);
|
|
17
|
+
}
|
|
@@ -58,7 +58,7 @@ export default {
|
|
|
58
58
|
return out;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const origin = ${JSON.stringify(cfg.worker.
|
|
61
|
+
const origin = ${JSON.stringify(cfg.worker.spaOrigin)};
|
|
62
62
|
const finalPath = toIndexHtml(url.pathname);
|
|
63
63
|
const originUrl = origin + finalPath + url.search;
|
|
64
64
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** All routed index.html files under outDir (excludes bare outDir/index.html). */
|
|
5
|
+
export async function listPrerenderIndexFiles(
|
|
6
|
+
outDirAbs: string
|
|
7
|
+
): Promise<string[]> {
|
|
8
|
+
const results: string[] = [];
|
|
9
|
+
const stack: string[] = [outDirAbs];
|
|
10
|
+
|
|
11
|
+
while (stack.length > 0) {
|
|
12
|
+
const current = stack.pop() as string;
|
|
13
|
+
|
|
14
|
+
let entries: fs.Dirent[];
|
|
15
|
+
try {
|
|
16
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
17
|
+
} catch {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
22
|
+
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const full = path.join(current, entry.name);
|
|
25
|
+
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
stack.push(full);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (entry.isFile() && entry.name.toLowerCase() === "index.html") {
|
|
32
|
+
const rel = path.relative(outDirAbs, full);
|
|
33
|
+
const parts = rel.split(path.sep).filter(Boolean);
|
|
34
|
+
if (parts.length >= 2) results.push(full);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
results.sort((a, b) => a.localeCompare(b));
|
|
40
|
+
return results;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function indexHtmlPathToRoute(
|
|
44
|
+
outDirAbs: string,
|
|
45
|
+
indexPathAbs: string
|
|
46
|
+
): string {
|
|
47
|
+
const rel = path.relative(outDirAbs, indexPathAbs);
|
|
48
|
+
const noFile = rel.replace(/index\.html$/i, "");
|
|
49
|
+
const normalized = noFile.split(path.sep).join("/").replace(/\/+$/, "");
|
|
50
|
+
return "/" + normalized.replace(/^\/+/, "");
|
|
51
|
+
}
|