prestruct 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +159 -2
- package/bin/cli.mjs +212 -0
- package/engine/scripts/inject-brand.js +430 -0
- package/engine/scripts/prerender.js +726 -0
- package/engine/scripts/validate-config.js +117 -0
- package/engine/src/hooks/usePageMeta.js +78 -0
- package/engine/src/islands.js +129 -0
- package/engine/vite.config.js +18 -0
- package/init/VERSION +1 -0
- package/init/ecosystem.cjs +45 -0
- package/init/eslint.config.js +30 -0
- package/init/index.html +42 -0
- package/init/package-lock.json +3183 -0
- package/init/package.json +29 -0
- package/init/proxy.package.json +21 -0
- package/init/public/_headers +31 -0
- package/init/public/_redirects +16 -0
- package/init/scripts/inject-brand.js +430 -0
- package/init/scripts/prerender.js +726 -0
- package/init/scripts/prestruct-proxy.service +47 -0
- package/init/scripts/proxy.js +373 -0
- package/init/scripts/proxy.worker.js +217 -0
- package/init/scripts/validate-config.js +117 -0
- package/init/src/App.jsx +21 -0
- package/init/src/AppIslands.jsx +39 -0
- package/init/src/AppLayout.jsx +66 -0
- package/init/src/hooks/usePageMeta.js +78 -0
- package/init/src/islands.js +129 -0
- package/init/src/main.jsx +35 -0
- package/init/ssr.config.example.js +151 -0
- package/init/vite.config.js +18 -0
- package/init/vitest.config.js +14 -0
- package/init/wrangler.toml +32 -0
- package/package.json +70 -4
|
@@ -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
|
+
}
|