prestruct 0.0.0-stage → 0.9.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.
@@ -0,0 +1,47 @@
1
+ # prestruct-proxy.service - systemd unit for the Prestruct render proxy
2
+ #
3
+ # Ships with `prestruct init`. Copy to /etc/systemd/system/, adjust the
4
+ # [Service] section to your install path, then:
5
+ #
6
+ # sudo cp scripts/prestruct-proxy.service /etc/systemd/system/
7
+ # sudo systemctl daemon-reload
8
+ # sudo systemctl enable --now prestruct-proxy
9
+ #
10
+ # Secrets live in /etc/prestruct/config.env, NOT in this file:
11
+ #
12
+ # sudo install -d -m 700 -o prestruct /etc/prestruct
13
+ # echo 'PRESTRUCT_SECRET=your-shared-secret' | \
14
+ # sudo tee /etc/prestruct/config.env >/dev/null
15
+ #
16
+ # One proxy process, one warm Chromium browser pool -- do not run multiple
17
+ # instances of the service on the same box (each pool costs ~100MB+).
18
+ #
19
+ # Prefer pm2? See ecosystem.cjs at the project root instead.
20
+
21
+ [Unit]
22
+ Description=Prestruct bot-render proxy (browsers get rendered snapshots, humans get redirected)
23
+ After=network-online.target
24
+ Wants=network-online.target
25
+
26
+ [Service]
27
+ Type=simple
28
+ # --- edit these two to your VPS paths --------------------------------------
29
+ User=www-data
30
+ WorkingDirectory=/srv/example
31
+ # ---------------------------------------------------------------------------
32
+ ExecStart=/usr/bin/node scripts/proxy.js
33
+ Environment=NODE_ENV=production
34
+ # PORT defaults to 3000 inside proxy.js; set it here to run behind a reverse
35
+ # proxy on a different local port.
36
+ #Environment=PORT=3000
37
+ EnvironmentFile=-/etc/prestruct/config.env
38
+ Restart=always
39
+ RestartSec=3
40
+ KillSignal=SIGTERM
41
+ TimeoutStopSec=10
42
+ # Puppeteer + Chromium need a /dev/shm large enough, or must not use it
43
+ PrivateTmp=true
44
+ NoNewPrivileges=true
45
+
46
+ [Install]
47
+ WantedBy=multi-user.target
@@ -0,0 +1,373 @@
1
+ /**
2
+ * scripts/proxy.js
3
+ * ================
4
+ * Runtime bot-render proxy for VPS / Node.js deployment.
5
+ *
6
+ * What it does:
7
+ * - Receives all incoming requests.
8
+ * - Non-bots get a 302 redirect to the live site (siteUrl or targetUrl).
9
+ * - Bots get a Puppeteer-rendered snapshot of the target page.
10
+ * - Rendered HTML is cached to disk. Cache entries expire after cacheDuration ms.
11
+ * - A valid x-prestruct-refresh header (matching PRESTRUCT_SECRET) busts the
12
+ * cache for a specific path on demand.
13
+ *
14
+ * Deployment:
15
+ * 1. Copy this file and ssr.config.js to your VPS.
16
+ * 2. `npm install express puppeteer` (puppeteer downloads Chromium automatically).
17
+ * 3. Set PRESTRUCT_SECRET env var to the shared secret. It is an env var
18
+ * only -- no config key feeds it. Unset secret = refresh feature off.
19
+ * 4. `node scripts/proxy.js` -- or use pm2 / systemd to keep it alive
20
+ * (ecosystem.cjs and scripts/prestruct-proxy.service ship alongside).
21
+ * 5. Reverse-proxy port 3000 (or $PORT) with nginx/caddy for TLS.
22
+ *
23
+ * Browser pooling:
24
+ * A single Chromium instance is shared across all requests. Pages are opened
25
+ * and closed per request, but the browser process stays warm. This avoids the
26
+ * cold-start cost and OOM risk of launching a browser per request.
27
+ * On crash or unhandled error the browser reference is cleared and reopened
28
+ * on the next request.
29
+ *
30
+ * Cache key:
31
+ * SHA-256 of the full URL path + query string. Using a hash avoids any
32
+ * filesystem-unsafe characters from raw URL segments.
33
+ *
34
+ * Security:
35
+ * - Cache refresh requires a matching PRESTRUCT_SECRET. Without it, the header
36
+ * is ignored. An empty or null secret disables refresh entirely.
37
+ * - The compare is constant-time (SHA-256 + timingSafeEqual) so response
38
+ * timing cannot leak the secret one byte at a time.
39
+ * - Only GET requests are processed. All others return 405.
40
+ * - Request URL is validated before being passed to Puppeteer.
41
+ *
42
+ * targetUrl vs siteUrl:
43
+ * Set config.proxy.targetUrl to point the proxy at a different origin than
44
+ * siteUrl. Useful when the proxy sits in front of a separate backend (WordPress,
45
+ * a staging URL, or localhost:5173 for local dev). Defaults to siteUrl.
46
+ *
47
+ * Import safety (for tests):
48
+ * The module exports createProxyApp(config) and is inert at import time --
49
+ * config binding, server listening, and browser pre-warm only happen when
50
+ * executed directly (same gate pattern as engine files). `puppeteer` is a
51
+ * dynamic import inside getBrowser() so importing the module never pays the
52
+ * cost of (or requires) Chromium.
53
+ */
54
+
55
+ import fs from 'fs'
56
+ import path from 'path'
57
+ import crypto from 'crypto'
58
+ import express from 'express'
59
+ import { pathToFileURL } from 'url'
60
+
61
+ // -- Runtime state (set by createProxyApp) ------------------------------------
62
+
63
+ let TARGET_URL = null
64
+ let BOT_LIST = []
65
+ let SECRET_SHA256 = null
66
+
67
+ export const PORT = parseInt(process.env.PORT || '3000', 10)
68
+
69
+ // -- Secret compare (constant time) -------------------------------------------
70
+ // Only honoured when a secret is configured and matches. Hash both sides to a
71
+ // fixed length and use timingSafeEqual: response timing cannot distinguish a
72
+ // matching prefix of the secret from a mismatching one.
73
+
74
+ export function secretMatches(headerValue) {
75
+ if (!SECRET_SHA256 || headerValue == null) return false
76
+ const provided = crypto.createHash('sha256').update(String(headerValue)).digest()
77
+ return crypto.timingSafeEqual(SECRET_SHA256, provided)
78
+ }
79
+
80
+ // -- Bot detection -------------------------------------------------------------
81
+
82
+ export function isBot(ua) {
83
+ if (!ua) return false
84
+ const lower = String(ua).toLowerCase()
85
+ // Live-fire fix (2026-10): tokens were matched against the lowercased UA
86
+ // but kept their own case -- "Googlebot" never matched "googlebot" inside a
87
+ // real UA string, so every configured bot got a 302. Lowercase both sides.
88
+ return BOT_LIST.some(token => lower.includes(String(token).toLowerCase()))
89
+ }
90
+
91
+ // -- Cache helpers -------------------------------------------------------------
92
+
93
+ // Location override exists so CI/tests (and VPS layouts that mount cache
94
+ // on a tmpfs or data disk) don't litter the process CWD. Without the override
95
+ // the import-time mkdirSync left a directory behind in whatever directory
96
+ // the module was imported from.
97
+ const CACHE_DIR = path.resolve(process.env.PRESTRUCT_CACHE_DIR || '.prestruct_cache')
98
+ const CACHE_TTL = 1000 * 60 * 60 * 24 // 24 hours -- adjust if needed
99
+ const MAX_CACHE_SIZE = 500 * 1024 * 1024 // 500MB max cache size
100
+
101
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true })
102
+
103
+ function cacheKey(url) {
104
+ return crypto.createHash('sha256').update(url).digest('hex')
105
+ }
106
+
107
+ function cachePath(key) {
108
+ return path.join(CACHE_DIR, `${key}.html`)
109
+ }
110
+
111
+ function cacheRead(key) {
112
+ const p = cachePath(key)
113
+ if (!fs.existsSync(p)) return null
114
+ const stat = fs.statSync(p)
115
+ if (Date.now() - stat.mtimeMs > CACHE_TTL) return null
116
+ return { html: fs.readFileSync(p, 'utf8'), cachedAt: stat.mtime.toISOString() }
117
+ }
118
+
119
+ function cacheWrite(key, html) {
120
+ fs.writeFileSync(cachePath(key), html, 'utf8')
121
+ // Cleanup if cache exceeds limit
122
+ cleanupCache()
123
+ }
124
+
125
+ // Cleanup old/large cache files
126
+ let lastCleanup = 0
127
+ function cleanupCache() {
128
+ const now = Date.now()
129
+ // Only run cleanup once per minute max
130
+ if (now - lastCleanup < 60000) return
131
+ lastCleanup = now
132
+
133
+ try {
134
+ const files = fs.readdirSync(CACHE_DIR).map(f => {
135
+ const p = path.join(CACHE_DIR, f)
136
+ const stat = fs.statSync(p)
137
+ return { path: p, size: stat.size, mtime: stat.mtimeMs }
138
+ })
139
+
140
+ // Calculate total size
141
+ let totalSize = files.reduce((sum, f) => sum + f.size, 0)
142
+
143
+ // If over limit, remove oldest files
144
+ if (totalSize > MAX_CACHE_SIZE) {
145
+ files.sort((a, b) => a.mtime - b.mtime)
146
+ while (totalSize > MAX_CACHE_SIZE * 0.8 && files.length > 0) {
147
+ const oldest = files.shift()
148
+ fs.unlinkSync(oldest.path)
149
+ totalSize -= oldest.size
150
+ }
151
+ console.log('[proxy] Cache cleaned up, removed', files.length, 'files')
152
+ }
153
+ } catch (err) {
154
+ console.warn('[proxy] Cache cleanup failed:', err.message)
155
+ }
156
+ }
157
+
158
+ // -- Browser pool (single shared instance) ------------------------------------
159
+
160
+ let browser = null
161
+
162
+ async function getBrowser() {
163
+ if (browser) return browser
164
+ // Dynamic import: only the render path ever needs Puppeteer/Chromium, and
165
+ // importing the module must not require it (unit tests, CI shells).
166
+ const { default: puppeteer } = await import('puppeteer')
167
+ browser = await puppeteer.launch({
168
+ args: [
169
+ '--no-sandbox',
170
+ '--disable-setuid-sandbox',
171
+ '--disable-dev-shm-usage', // avoids /dev/shm OOM on Linux containers
172
+ '--disable-gpu',
173
+ ],
174
+ headless: true,
175
+ })
176
+ browser.on('disconnected', () => {
177
+ // Clear the reference so the next request spawns a fresh instance
178
+ console.warn('[proxy] Browser disconnected -- will reopen on next request')
179
+ browser = null
180
+ })
181
+ return browser
182
+ }
183
+
184
+ // -- Render --------------------------------------------------------------------
185
+
186
+ async function render(targetFullUrl) {
187
+ const b = await getBrowser()
188
+ const page = await b.newPage()
189
+ try {
190
+ await page.setViewport({ width: 1280, height: 800 })
191
+ // Tag the request so origin servers can identify proxy traffic
192
+ await page.setExtraHTTPHeaders({ 'X-Prestruct-Proxy': 'true' })
193
+ await page.goto(targetFullUrl, { waitUntil: 'networkidle0', timeout: 30000 })
194
+ return await page.content()
195
+ } finally {
196
+ // Always close the page to free memory, even on error
197
+ await page.close().catch(() => {})
198
+ }
199
+ }
200
+
201
+ // -- Render concurrency limit ------------------------------------------------
202
+ // Max concurrent renders to prevent overwhelming the system.
203
+
204
+ export const MAX_CONCURRENT = 5
205
+ let activeRenders = 0
206
+ const renderQueue = []
207
+
208
+ export async function withRenderLimit(fn) {
209
+ if (activeRenders < MAX_CONCURRENT) {
210
+ activeRenders++
211
+ try {
212
+ return await fn()
213
+ } finally {
214
+ activeRenders--
215
+ // Process next in queue. The queued callback recurses into
216
+ // withRenderLimit rather than calling fn() directly: the old form
217
+ // skipped the acquire, so after the first overflow renders ran with
218
+ // zero active accounting -- i.e. UNBOUNDED concurrency again under
219
+ // sustained bot load, which is exactly the queue's job to prevent.
220
+ // Recursion is safe here: we just freed a slot, so the queue branch
221
+ // cannot be reached again.
222
+ if (renderQueue.length > 0) {
223
+ const next = renderQueue.shift()
224
+ next()
225
+ }
226
+ }
227
+ }
228
+ // Queue the request; rejection must propagate to the waiting caller
229
+ // (the old form swallowed rejections and left the caller's promise forever
230
+ // pending).
231
+ return new Promise((resolve, reject) => {
232
+ renderQueue.push(() => { withRenderLimit(fn).then(resolve, reject) })
233
+ })
234
+ }
235
+
236
+ // -- Express app ---------------------------------------------------------------
237
+
238
+ const app = express()
239
+
240
+ // Live-fire fix (2026-10): the wildcard routes (`app.all('*')` /
241
+ // `app.get('*')`) belong to Express 4 only -- Express 5 (what a bare
242
+ // `npm install express` resolves today) rejects `*` at app construction
243
+ // with path-to-regexp's "Missing parameter name" PathError, so the proxy
244
+ // crashed before serving anything. A pathless app.use() middleware gives
245
+ // identical catch-all behavior on both majors.
246
+ export async function proxyHandler(req, res, next) {
247
+ // Only GET -- proxying other methods through Puppeteer is meaningless
248
+ if (req.method !== 'GET') return res.status(405).send('Method not allowed.')
249
+
250
+ const ua = req.headers['user-agent'] || ''
251
+ const bot = isBot(ua)
252
+
253
+ // Validate and build the target URL. Reject anything that produces a non-HTTP scheme.
254
+ let targetFullUrl
255
+ try {
256
+ const parsed = new URL(`${TARGET_URL}${req.url}`)
257
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid protocol')
258
+ targetFullUrl = parsed.toString()
259
+ } catch {
260
+ return res.status(400).send('Invalid request URL.')
261
+ }
262
+
263
+ // Non-bots redirect to the live site -- no rendering needed
264
+ if (!bot) {
265
+ return res.redirect(302, targetFullUrl)
266
+ }
267
+
268
+ // Cache refresh -- only honoured when a secret is configured and matches
269
+ const refreshHeader = req.headers['x-prestruct-refresh']
270
+ const forceRefresh = secretMatches(refreshHeader)
271
+
272
+ const key = cacheKey(req.url)
273
+ const cached = forceRefresh ? null : cacheRead(key)
274
+
275
+ if (cached) {
276
+ res.setHeader('X-Prestruct-Cache', 'HIT')
277
+ res.setHeader('X-Prestruct-Rendered-At', cached.cachedAt)
278
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
279
+ return res.send(cached.html)
280
+ }
281
+
282
+ try {
283
+ const html = await withRenderLimit(() => render(targetFullUrl))
284
+ cacheWrite(key, html)
285
+ res.setHeader('X-Prestruct-Cache', 'MISS')
286
+ res.setHeader('X-Prestruct-Rendered-At', new Date().toISOString())
287
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
288
+ return res.send(html)
289
+ } catch (err) {
290
+ console.error('[proxy] Render failed:', err.message)
291
+ // Fall through to the live site rather than serving an error page to the bot
292
+ return res.redirect(302, targetFullUrl)
293
+ }
294
+ }
295
+
296
+ app.use(proxyHandler)
297
+
298
+ // -- Configuration + startup --------------------------------------------------
299
+ // createProxyApp binds a validated config to the shared app and returns it, so
300
+ // tests (or embedding hosts) can drive the pipeline without opening a port.
301
+ // Startup validation applies the same rules the old top-level code enforced.
302
+
303
+ export function createProxyApp(config) {
304
+ const proxyConfig = (config && config.proxy) || {}
305
+
306
+ if (!config || !config.siteUrl) {
307
+ throw new Error('[proxy] siteUrl is required in ssr.config.js')
308
+ }
309
+
310
+ TARGET_URL = (proxyConfig.targetUrl || config.siteUrl).replace(/\/$/, '')
311
+
312
+ // Validate TARGET_URL is a valid URL
313
+ try {
314
+ new URL(TARGET_URL)
315
+ } catch (err) {
316
+ throw new Error(`[proxy] Invalid targetUrl: ${TARGET_URL}`)
317
+ }
318
+
319
+ BOT_LIST = proxyConfig.botList || []
320
+
321
+ const secret = process.env.PRESTRUCT_SECRET || null
322
+ SECRET_SHA256 = secret
323
+ ? crypto.createHash('sha256').update(secret).digest()
324
+ : null
325
+
326
+ return app
327
+ }
328
+
329
+ // -- CLI entry ----------------------------------------------------------------
330
+ // Only runs when executed directly (node scripts/proxy.js) -- never on import.
331
+ // Same gate pattern as engine files.
332
+
333
+ export function isMainExecute() {
334
+ try {
335
+ return import.meta.url === pathToFileURL(process.argv[1] || '').href
336
+ } catch {
337
+ return false
338
+ }
339
+ }
340
+
341
+ async function main() {
342
+ let config = null
343
+ try {
344
+ config = (await import('../ssr.config.js')).default
345
+ } catch (err) {
346
+ console.error('[proxy] Could not load ssr.config.js:', err.message)
347
+ process.exit(1)
348
+ }
349
+
350
+ const server = createProxyApp(config)
351
+
352
+ server.listen(PORT, () => {
353
+ console.log(`[proxy] Prestruct proxy listening on port ${PORT}`)
354
+ console.log(`[proxy] Target: ${TARGET_URL}`)
355
+ if (!SECRET_SHA256) {
356
+ console.warn('[proxy] PRESTRUCT_SECRET not set -- cache refresh disabled')
357
+ }
358
+ })
359
+
360
+ // Pre-warm the browser so the first bot request doesn't cold-start Chromium
361
+ getBrowser().then(() => {
362
+ console.log('[proxy] Browser ready')
363
+ }).catch(err => {
364
+ console.error('[proxy] Browser pre-warm failed:', err.message)
365
+ })
366
+ }
367
+
368
+ if (isMainExecute()) {
369
+ main().catch(err => {
370
+ console.error('[proxy] Fatal:', err.message)
371
+ process.exit(1)
372
+ })
373
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * scripts/proxy.worker.js
3
+ * =======================
4
+ * Cloudflare Worker version of the Prestruct render proxy.
5
+ *
6
+ * Uses Cloudflare's Browser Rendering API (@cloudflare/puppeteer) instead of
7
+ * a local Chromium install. No VPS, no process management -- deploy as a Worker.
8
+ *
9
+ * Requirements:
10
+ * - Workers Paid plan (Browser Rendering is not available on the free tier).
11
+ * - `@cloudflare/puppeteer` bound as a Browser binding in wrangler.toml.
12
+ * - KV namespace bound as `CACHE` in wrangler.toml (for HTML cache storage).
13
+ * - PRESTRUCT_SECRET set as a Worker secret via `wrangler secret put`.
14
+ *
15
+ * wrangler.toml additions needed:
16
+ *
17
+ * [[browser]]
18
+ * binding = "BROWSER"
19
+ *
20
+ * [[kv_namespaces]]
21
+ * binding = "CACHE"
22
+ * id = "your-kv-namespace-id"
23
+ *
24
+ * [vars]
25
+ * PRESTRUCT_TARGET_URL = "https://yoursite.com"
26
+ * CACHE_TTL_SECONDS = "86400"
27
+ *
28
+ * Deployment:
29
+ * 1. Copy this file to your Worker project root (or a standalone Worker repo).
30
+ * 2. `npm install @cloudflare/puppeteer wrangler`
31
+ * 3. Update wrangler.toml with the bindings above.
32
+ * 4. `wrangler secret put PRESTRUCT_SECRET`
33
+ * 5. `wrangler deploy`
34
+ *
35
+ * Cache:
36
+ * Rendered HTML is stored in KV with a TTL. KV is eventually consistent --
37
+ * cache busts may take a few seconds to propagate globally.
38
+ *
39
+ * targetUrl vs siteUrl:
40
+ * Set PRESTRUCT_TARGET_URL in wrangler.toml [vars] to point the proxy at a
41
+ * different origin (WordPress, staging, localhost tunnel, etc.).
42
+ * Defaults to the `x-forwarded-host` of the incoming request if not set.
43
+ *
44
+ * Bot list:
45
+ * Maintained inline here. Sync with config.proxy.botList in ssr.config.js
46
+ * if you customise either. The Worker has no access to your build-time config.
47
+ *
48
+ * Security:
49
+ * - Cache refresh requires the PRESTRUCT_SECRET value in x-prestruct-refresh,
50
+ * compared with a full-length constant-time XOR (see refreshSecretMatches).
51
+ * - Only GET requests are processed. Others return 405.
52
+ * - Target URL is validated before passing to the browser.
53
+ *
54
+ * Import safety (for tests):
55
+ * `@cloudflare/puppeteer` is loaded lazily inside the render step, so
56
+ * importing this module -- in unit tests, CI, or a bundler -- never requires
57
+ * the package. Only requests that actually need a rendered page pay for it.
58
+ * The exported helpers (isBot, cacheKey, refreshSecretMatches) are pure and
59
+ * run in any JS runtime, not just Workers.
60
+ */
61
+
62
+ // Keep this in sync with config.proxy.botList in ssr.config.js
63
+ const BOT_LIST = [
64
+ 'googlebot',
65
+ 'bingbot',
66
+ 'slurp',
67
+ 'duckduckbot',
68
+ 'baiduspider',
69
+ 'yandexbot',
70
+ 'twitterbot',
71
+ 'facebookexternalhit',
72
+ 'discordbot',
73
+ 'linkedinbot',
74
+ 'slackbot',
75
+ 'telegrambot',
76
+ 'whatsapp',
77
+ ]
78
+
79
+ export function isBot(ua) {
80
+ if (!ua) return false
81
+ const lower = String(ua).toLowerCase()
82
+ // Live-fire fix (2026-10): tokens were matched against the lowercased UA
83
+ // but kept their own case -- "Googlebot" never matched "googlebot" inside a
84
+ // real UA string, so every configured bot got a redirect. Lowercase both
85
+ // sides (mirrors the same fix in proxy.js).
86
+ return BOT_LIST.some(token => lower.includes(String(token).toLowerCase()))
87
+ }
88
+
89
+ export function cacheKey(url) {
90
+ // KV key: simple prefix + encoded path. Max 512 bytes, URL-safe.
91
+ return `prestruct:${encodeURIComponent(url)}`
92
+ }
93
+
94
+ // -- Secret compare (constant time) -------------------------------------------
95
+ // Workers cannot import node:crypto, so there is no timingSafeEqual here.
96
+ // The XOR form is the standard constant-time comparator available in any
97
+ // runtime: every byte of both inputs is read and folded into one accumulator,
98
+ // regardless of where (or whether) they differ. The result depends only on
99
+ // whether the strings are equal -- never on how far in they diverge.
100
+ //
101
+ // Note: the return value requires BOTH equal content and equal byte length.
102
+ // A differing length never matches, but its comparison still burns the same
103
+ // work as an equal-length mismatch (full loop over the longer string).
104
+ export function refreshSecretMatches(secret, headerValue) {
105
+ if (secret == null || secret === '' || headerValue == null) return false
106
+ const a = new TextEncoder().encode(String(secret))
107
+ const b = new TextEncoder().encode(String(headerValue))
108
+ let diff = a.length ^ b.length
109
+ const len = Math.max(a.length, b.length)
110
+ for (let i = 0; i < len; i++) {
111
+ diff |= (a[i % a.length] || 0) ^ (b[i % b.length] || 0)
112
+ }
113
+ return diff === 0
114
+ }
115
+
116
+ export default {
117
+ async fetch(request, env) {
118
+ // Only GET
119
+ if (request.method !== 'GET') {
120
+ return new Response('Method not allowed.', { status: 405 })
121
+ }
122
+
123
+ const ua = request.headers.get('user-agent') || ''
124
+ const bot = isBot(ua)
125
+
126
+ // Build target URL
127
+ const TARGET_URL = (env.PRESTRUCT_TARGET_URL || '').replace(/\/$/, '')
128
+ if (!TARGET_URL) {
129
+ return new Response('[proxy] PRESTRUCT_TARGET_URL not configured.', { status: 500 })
130
+ }
131
+
132
+ const requestUrl = new URL(request.url)
133
+ let targetFullUrl
134
+ try {
135
+ const parsed = new URL(`${TARGET_URL}${requestUrl.pathname}${requestUrl.search}`)
136
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Invalid protocol')
137
+ targetFullUrl = parsed.toString()
138
+ } catch {
139
+ return new Response('Invalid request URL.', { status: 400 })
140
+ }
141
+
142
+ // Non-bots redirect to the live site
143
+ if (!bot) {
144
+ return Response.redirect(targetFullUrl, 302)
145
+ }
146
+
147
+ // Cache refresh check. Constant-time compare (see refreshSecretMatches):
148
+ // response timing reveals nothing about where the submitted token diverges
149
+ // from the secret.
150
+ const SECRET = env.PRESTRUCT_SECRET || null
151
+ const refreshToken = request.headers.get('x-prestruct-refresh')
152
+ const forceRefresh = refreshSecretMatches(SECRET, refreshToken)
153
+
154
+ const TTL = parseInt(env.CACHE_TTL_SECONDS || '86400', 10)
155
+ const key = cacheKey(requestUrl.pathname + requestUrl.search)
156
+
157
+ // Try KV cache first (payload is the {html, cachedAt} JSON we now store)
158
+ if (!forceRefresh) {
159
+ const cachedRaw = await env.CACHE.get(key, { type: 'text' })
160
+ if (cachedRaw) {
161
+ let cached = { html: cachedRaw, cachedAt: 'unknown' }
162
+ try { cached = JSON.parse(cachedRaw) } catch {}
163
+ return new Response(cached.html, {
164
+ headers: {
165
+ 'Content-Type': 'text/html; charset=utf-8',
166
+ 'X-Prestruct-Cache': 'HIT',
167
+ 'X-Prestruct-Rendered-At': String(cached.cachedAt || 'unknown'),
168
+ },
169
+ })
170
+ }
171
+ }
172
+
173
+ // Render via Browser Rendering API. The binding is imported lazily so the
174
+ // module (and its unit tests) never require the @cloudflare/puppeteer
175
+ // package -- only the render path does.
176
+ let html
177
+ let browser
178
+ let page
179
+ try {
180
+ const { default: workerPuppeteer } = await import('@cloudflare/puppeteer')
181
+ browser = await workerPuppeteer.launch(env.BROWSER)
182
+ page = await browser.newPage()
183
+ await page.setViewport({ width: 1280, height: 800 })
184
+ await page.setExtraHTTPHeaders({ 'X-Prestruct-Proxy': 'true' })
185
+ await page.goto(targetFullUrl, { waitUntil: 'networkidle0', timeout: 30000 })
186
+ html = await page.content()
187
+ } catch (err) {
188
+ console.error('[proxy:worker] Render failed:', err.message)
189
+ // Fall through to live site on render failure
190
+ return Response.redirect(targetFullUrl, 302)
191
+ } finally {
192
+ if (page) {
193
+ try {
194
+ await page.close()
195
+ } catch {}
196
+ }
197
+ if (browser) {
198
+ try {
199
+ await browser.close()
200
+ } catch {}
201
+ }
202
+ }
203
+
204
+ // Store in KV with TTL. JSON-encode with the render timestamp so the hit
205
+ // path can report a real X-Prestruct-Rendered-At (the old form cached the
206
+ // bare HTML and reported a stale/absent date on every HIT).
207
+ await env.CACHE.put(key, JSON.stringify({ html, cachedAt: new Date().toISOString() }), { expirationTtl: TTL })
208
+
209
+ return new Response(html, {
210
+ headers: {
211
+ 'Content-Type': 'text/html; charset=utf-8',
212
+ 'X-Prestruct-Cache': 'MISS',
213
+ 'X-Prestruct-Rendered-At': new Date().toISOString(),
214
+ },
215
+ })
216
+ },
217
+ }