@lownoise-studio/rendershield 0.1.6 → 0.3.1
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 +32 -0
- package/README.md +6 -0
- package/dist/cli.js +35 -15
- package/dist/cli.js.map +1 -1
- package/dist/commands/build.js +96 -0
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/init.js +7 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/verify.js +132 -38
- package/dist/commands/verify.js.map +1 -1
- package/dist/core/generateWorker.js +85 -68
- package/dist/core/generateWorker.js.map +1 -1
- package/dist/core/loadConfig.js +74 -6
- package/dist/core/loadConfig.js.map +1 -1
- package/dist/core/loadMarkdown.js.map +1 -1
- package/dist/core/renderHtml.js +5 -1
- package/dist/core/renderHtml.js.map +1 -1
- package/dist/core/validateOutput.js +154 -29
- package/dist/core/validateOutput.js.map +1 -1
- package/package.json +7 -3
- package/src/cli.ts +75 -54
- package/src/commands/build.ts +185 -72
- package/src/commands/init.ts +7 -7
- package/src/commands/verify.ts +236 -114
- package/src/core/generateWorker.ts +97 -80
- package/src/core/loadConfig.ts +173 -74
- package/src/core/loadMarkdown.ts +2 -2
- package/src/core/renderHtml.ts +5 -1
- package/src/core/validateOutput.ts +217 -37
package/src/commands/verify.ts
CHANGED
|
@@ -1,114 +1,236 @@
|
|
|
1
|
-
import fs from "fs-extra";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { loadConfig } from "../core/loadConfig.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
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
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
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
|
-
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadConfig } from "../core/loadConfig.js";
|
|
4
|
+
import { checkPrerenderContract } from "../core/validateOutput.js";
|
|
5
|
+
|
|
6
|
+
function joinUrl(base: string, routePath: string): string {
|
|
7
|
+
const b = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
8
|
+
const p = routePath.startsWith("/") ? routePath : `/${routePath}`;
|
|
9
|
+
return b + p;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function findFirstIndexHtml(outDirAbs: string): Promise<string | null> {
|
|
13
|
+
const stack: string[] = [outDirAbs];
|
|
14
|
+
|
|
15
|
+
while (stack.length > 0) {
|
|
16
|
+
const current = stack.pop() as string;
|
|
17
|
+
|
|
18
|
+
let entries: fs.Dirent[];
|
|
19
|
+
try {
|
|
20
|
+
entries = await fs.readdir(current, { withFileTypes: true });
|
|
21
|
+
} catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
26
|
+
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const full = path.join(current, entry.name);
|
|
29
|
+
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
stack.push(full);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (entry.isFile() && entry.name.toLowerCase() === "index.html") {
|
|
36
|
+
// Ignore index.html at the output root; prefer a routed page like
|
|
37
|
+
// <section>/<slug>/index.html (or deeper).
|
|
38
|
+
const rel = path.relative(outDirAbs, full);
|
|
39
|
+
const parts = rel.split(path.sep).filter(Boolean);
|
|
40
|
+
if (parts.length >= 2) return full;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function indexHtmlPathToRoute(outDirAbs: string, indexPathAbs: string): string {
|
|
49
|
+
const rel = path.relative(outDirAbs, indexPathAbs);
|
|
50
|
+
// rel: <section>/<slug>/index.html
|
|
51
|
+
const noFile = rel.replace(/index\.html$/i, "");
|
|
52
|
+
const normalized = noFile.split(path.sep).join("/").replace(/\/+$/, "");
|
|
53
|
+
return "/" + normalized.replace(/^\/+/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const BOT_UA =
|
|
57
|
+
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
|
|
58
|
+
const HUMAN_UA =
|
|
59
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
60
|
+
|
|
61
|
+
/** Heuristic: likely SPA shell if body has almost no visible content and no article. */
|
|
62
|
+
function looksLikeSpaShell(html: string): { likely: boolean; reason?: string } {
|
|
63
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
64
|
+
const bodyHtml = bodyMatch ? bodyMatch[1] : html;
|
|
65
|
+
const noScript = bodyHtml
|
|
66
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
67
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
68
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ");
|
|
69
|
+
const text = noScript.replace(/<\/?[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
70
|
+
if (text.length < 150) {
|
|
71
|
+
return { likely: true, reason: `Body text very short (${text.length} chars); likely app shell.` };
|
|
72
|
+
}
|
|
73
|
+
if (!/<article\b/i.test(html)) {
|
|
74
|
+
return { likely: true, reason: "No <article> present; may be SPA shell." };
|
|
75
|
+
}
|
|
76
|
+
const rootOnly = /<body[^>]*>\s*<div[^>]*id=["'](?:root|app|__next)["'][^>]*>\s*<\/div>\s*<\/body>/i.test(
|
|
77
|
+
html.replace(/\s+/g, " ")
|
|
78
|
+
);
|
|
79
|
+
if (rootOnly) {
|
|
80
|
+
return { likely: true, reason: "Single root div (e.g. #root, #app) with no content." };
|
|
81
|
+
}
|
|
82
|
+
return { likely: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type VerifyProdOptions = { prodUrl: string };
|
|
86
|
+
|
|
87
|
+
export async function cmdVerify(
|
|
88
|
+
cwd = process.cwd(),
|
|
89
|
+
options?: VerifyProdOptions
|
|
90
|
+
) {
|
|
91
|
+
if (options?.prodUrl) {
|
|
92
|
+
await runVerifyProd(options.prodUrl);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cfg = await loadConfig(cwd);
|
|
97
|
+
|
|
98
|
+
const outDirAbs = path.join(cwd, cfg.output.outDir);
|
|
99
|
+
const exists = await fs.pathExists(outDirAbs);
|
|
100
|
+
|
|
101
|
+
if (!exists) {
|
|
102
|
+
console.log(`
|
|
103
|
+
RenderShield verify
|
|
104
|
+
|
|
105
|
+
No prerender output directory found: ${cfg.output.outDir}/
|
|
106
|
+
|
|
107
|
+
Run:
|
|
108
|
+
node dist/cli.js build
|
|
109
|
+
`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const firstIndex = await findFirstIndexHtml(outDirAbs);
|
|
114
|
+
|
|
115
|
+
if (!firstIndex) {
|
|
116
|
+
console.log(`
|
|
117
|
+
RenderShield verify
|
|
118
|
+
|
|
119
|
+
No prerendered pages found inside: ${cfg.output.outDir}/
|
|
120
|
+
|
|
121
|
+
Run:
|
|
122
|
+
node dist/cli.js build
|
|
123
|
+
`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const routePath = indexHtmlPathToRoute(outDirAbs, firstIndex);
|
|
128
|
+
const url = joinUrl(cfg.site.canonicalBase, routePath);
|
|
129
|
+
|
|
130
|
+
console.log(`
|
|
131
|
+
RenderShield verify
|
|
132
|
+
|
|
133
|
+
Using:
|
|
134
|
+
canonicalBase: ${cfg.site.canonicalBase}
|
|
135
|
+
routePath: ${routePath}
|
|
136
|
+
output file: ${path.relative(cwd, firstIndex)}
|
|
137
|
+
|
|
138
|
+
Smoke tests:
|
|
139
|
+
|
|
140
|
+
1) Human (usually SPA shell):
|
|
141
|
+
curl -s ${url} | grep -i "<title>"
|
|
142
|
+
|
|
143
|
+
2) Bot (should see prerendered, route-specific title):
|
|
144
|
+
curl -s -H "User-Agent: Googlebot" ${url} | grep -i "<title>"
|
|
145
|
+
|
|
146
|
+
3) Debug headers (Worker must be routed + proxy ON):
|
|
147
|
+
curl -I -H "User-Agent: GPTBot" ${url}
|
|
148
|
+
|
|
149
|
+
Expected: x-rendershield: bot-hit (proves Worker served prerender to bot).
|
|
150
|
+
If debugHeaders enabled: X-Bot-Detected, X-Prerender, X-Final-Path.
|
|
151
|
+
`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function runVerifyProd(url: string): Promise<void> {
|
|
155
|
+
const normalizedUrl = url.startsWith("http") ? url : `https://${url}`;
|
|
156
|
+
|
|
157
|
+
let botHtml: string;
|
|
158
|
+
let humanHtml: string;
|
|
159
|
+
let botStatus: number;
|
|
160
|
+
let humanStatus: number;
|
|
161
|
+
let xRenderShield: string | null;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const [botRes, humanRes] = await Promise.all([
|
|
165
|
+
fetch(normalizedUrl, {
|
|
166
|
+
headers: { "User-Agent": BOT_UA },
|
|
167
|
+
redirect: "follow",
|
|
168
|
+
}),
|
|
169
|
+
fetch(normalizedUrl, {
|
|
170
|
+
headers: { "User-Agent": HUMAN_UA },
|
|
171
|
+
redirect: "follow",
|
|
172
|
+
}),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
botStatus = botRes.status;
|
|
176
|
+
humanStatus = humanRes.status;
|
|
177
|
+
xRenderShield = botRes.headers.get("x-rendershield");
|
|
178
|
+
botHtml = await botRes.text();
|
|
179
|
+
humanHtml = await humanRes.text();
|
|
180
|
+
} catch (err: unknown) {
|
|
181
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
182
|
+
throw new Error(
|
|
183
|
+
`RenderShield verify --prod: failed to fetch ${normalizedUrl}. ${msg}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Prove routing: Worker must set x-rendershield: bot-hit for bot requests. No inference.
|
|
188
|
+
const routingOk = xRenderShield === "bot-hit";
|
|
189
|
+
if (xRenderShield === "bot-fallback") {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`RenderShield verify --prod: bot request received x-rendershield: bot-fallback. ` +
|
|
192
|
+
`Prerender origin returned non-200; Worker fell back to SPA. Fix deployment or origin so bots get prerendered HTML.`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (!routingOk) {
|
|
196
|
+
const hint = xRenderShield == null
|
|
197
|
+
? " If no Worker is deployed, use verify without --prod to check local/build output."
|
|
198
|
+
: "";
|
|
199
|
+
throw new Error(
|
|
200
|
+
`RenderShield verify --prod: expected x-rendershield: bot-hit (proving Worker routed bot to prerender). ` +
|
|
201
|
+
`Got: ${xRenderShield ?? "(missing)"}. Ensure the Worker is deployed and bound to this route.${hint}`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const contract = checkPrerenderContract(botHtml, {
|
|
206
|
+
routePath: normalizedUrl,
|
|
207
|
+
outFile: normalizedUrl,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const humanSpa = looksLikeSpaShell(humanHtml);
|
|
211
|
+
|
|
212
|
+
// Report
|
|
213
|
+
console.log(`
|
|
214
|
+
RenderShield verify --prod
|
|
215
|
+
URL: ${normalizedUrl}
|
|
216
|
+
|
|
217
|
+
Fetch:
|
|
218
|
+
Bot (Googlebot): ${botStatus} (${botHtml.length} bytes) x-rendershield: ${xRenderShield ?? "(none)"}
|
|
219
|
+
Human (Chrome): ${humanStatus} (${humanHtml.length} bytes)
|
|
220
|
+
|
|
221
|
+
Routing: x-rendershield: bot-hit (Worker served prerendered HTML to bot)
|
|
222
|
+
|
|
223
|
+
Bot contract (title, meta, canonical, OG, JSON-LD, article):
|
|
224
|
+
${contract.ok ? "PASS — all required fields present." : "FAIL — missing or invalid:"}
|
|
225
|
+
${contract.missing.length > 0 ? contract.missing.map((m) => ` - ${m}`).join("\n") : ""}
|
|
226
|
+
|
|
227
|
+
Human response:
|
|
228
|
+
${humanSpa.likely ? `Likely SPA shell: ${humanSpa.reason ?? "unknown"}` : "Has substantial content (not a minimal SPA shell)."}
|
|
229
|
+
`);
|
|
230
|
+
|
|
231
|
+
if (!contract.ok) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Production URL did not satisfy bot contract. Missing: ${contract.missing.join("; ")}`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -1,80 +1,97 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
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
|
-
}
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { RenderShieldConfig } from "../types.js";
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
let VERSION = "0.0.0";
|
|
6
|
+
try {
|
|
7
|
+
const pkg = require("../../package.json") as { version?: string };
|
|
8
|
+
VERSION = pkg.version ?? "0.0.0";
|
|
9
|
+
} catch {
|
|
10
|
+
VERSION = "unknown";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function jsStringArray(arr: string[]): string {
|
|
14
|
+
return `[${arr.map((s) => JSON.stringify(s)).join(", ")}]`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function generateWorkerJs(cfg: RenderShieldConfig): string {
|
|
18
|
+
const patterns = cfg.worker.botUserAgentPatterns.map((p) => p.toLowerCase());
|
|
19
|
+
const rewriteBases = cfg.worker.rewriteRouteBases;
|
|
20
|
+
|
|
21
|
+
return `/**
|
|
22
|
+
* RenderShield Worker (generated) v${VERSION}
|
|
23
|
+
* Serves prerendered /index.html to bots on selected route bases.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const BOT_SUBSTRINGS = ${jsStringArray(patterns)};
|
|
27
|
+
const REWRITE_BASES = ${jsStringArray(rewriteBases)};
|
|
28
|
+
|
|
29
|
+
function isBot(ua) {
|
|
30
|
+
const s = (ua || "").toLowerCase();
|
|
31
|
+
return BOT_SUBSTRINGS.some((sub) => s.includes(sub));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function shouldRewrite(pathname) {
|
|
35
|
+
return REWRITE_BASES.some((base) => pathname.startsWith(base));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toIndexHtml(pathname) {
|
|
39
|
+
let p = pathname;
|
|
40
|
+
if (!p.endsWith("/")) p += "/";
|
|
41
|
+
return p + "index.html";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
async fetch(request, env, ctx) {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
const ua = request.headers.get("User-Agent") || "";
|
|
48
|
+
const bot = isBot(ua);
|
|
49
|
+
|
|
50
|
+
const isGetLike = request.method === "GET" || request.method === "HEAD";
|
|
51
|
+
const rewrite = bot && isGetLike && shouldRewrite(url.pathname);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (!rewrite) {
|
|
55
|
+
const passResp = await fetch(request);
|
|
56
|
+
const out = new Response(passResp.body, passResp);
|
|
57
|
+
out.headers.set("x-rendershield", "pass-through");
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const origin = ${JSON.stringify(cfg.worker.lovableOrigin)};
|
|
62
|
+
const finalPath = toIndexHtml(url.pathname);
|
|
63
|
+
const originUrl = origin + finalPath + url.search;
|
|
64
|
+
|
|
65
|
+
const headers = new Headers();
|
|
66
|
+
headers.set("Accept", "text/html");
|
|
67
|
+
headers.set("User-Agent", ua);
|
|
68
|
+
|
|
69
|
+
const resp = await fetch(originUrl, { method: "GET", headers });
|
|
70
|
+
|
|
71
|
+
if (!resp.ok) {
|
|
72
|
+
const fallbackUrl = origin + url.pathname + url.search;
|
|
73
|
+
const fb = await fetch(fallbackUrl, { method: "GET", headers });
|
|
74
|
+
const out = new Response(fb.body, fb);
|
|
75
|
+
out.headers.set("x-rendershield", "bot-fallback");
|
|
76
|
+
${cfg.worker.debugHeaders ? `out.headers.set("X-Bot-Detected", "true");
|
|
77
|
+
out.headers.set("X-Prerender-Fallback", "true");
|
|
78
|
+
out.headers.set("X-Requested-Path", url.pathname);` : ""}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const out = new Response(resp.body, resp);
|
|
83
|
+
out.headers.set("x-rendershield", "bot-hit");
|
|
84
|
+
${cfg.worker.debugHeaders ? `out.headers.set("X-Bot-Detected", "true");
|
|
85
|
+
out.headers.set("X-Prerender", "true");
|
|
86
|
+
out.headers.set("X-Final-Path", finalPath);` : ""}
|
|
87
|
+
return out;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return new Response("Service temporarily unavailable.", {
|
|
90
|
+
status: 500,
|
|
91
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" }
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
`;
|
|
97
|
+
}
|