prestruct 1.1.0 → 1.2.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/README.md +28 -64
- package/bin/cli.mjs +217 -9
- package/engine/scripts/audit.js +530 -0
- package/engine/scripts/hooks.js +93 -0
- package/engine/scripts/indexnow.js +139 -0
- package/engine/scripts/inject-brand.js +112 -10
- package/engine/scripts/ogimage.js +165 -0
- package/engine/scripts/prerender.js +585 -92
- package/engine/scripts/validate-config.js +103 -1
- package/engine/src/hooks/usePageMeta.js +3 -1
- package/engine/src/islands.js +23 -4
- package/engine/ssr.config.example.js +196 -0
- package/init/VERSION +1 -1
- package/init/ecosystem.cjs +1 -1
- package/init/package-lock.json +6 -6
- package/init/package.json +6 -6
- package/init/scripts/audit-if-configured.cjs +26 -0
- package/init/scripts/audit.js +530 -0
- package/init/scripts/hooks.js +93 -0
- package/init/scripts/indexnow.js +139 -0
- package/init/scripts/inject-brand.js +112 -10
- package/init/scripts/ogimage.js +165 -0
- package/init/scripts/prerender.js +585 -92
- package/init/scripts/proxy.js +100 -17
- package/init/scripts/proxy.worker.js +150 -33
- package/init/scripts/validate-config.js +103 -1
- package/init/src/hooks/usePageMeta.js +3 -1
- package/init/src/islands.js +23 -4
- package/init/ssr.config.example.js +37 -0
- package/package.json +15 -6
package/init/scripts/proxy.js
CHANGED
|
@@ -64,7 +64,22 @@ let TARGET_URL = null
|
|
|
64
64
|
let BOT_LIST = []
|
|
65
65
|
let SECRET_SHA256 = null
|
|
66
66
|
|
|
67
|
-
export const PORT =
|
|
67
|
+
export const PORT = normalizePort(process.env.PORT)
|
|
68
|
+
|
|
69
|
+
// Port guard (walk 2026-10): a junk PORT env ("", "3000x", "port") made
|
|
70
|
+
// parseInt() produce NaN and server.listen(NaN) died at startup with a
|
|
71
|
+
// runtime error far from its cause. Validate and fall back loudly.
|
|
72
|
+
export function normalizePort(raw) {
|
|
73
|
+
// Number(), not parseInt(): parseInt("3000x") silently returns 3000, so a
|
|
74
|
+
// junk PORT env with a valid prefix ("3000abc") passed validation and the
|
|
75
|
+
// server bound an unintended port instead of falling back loudly.
|
|
76
|
+
const n = Number(raw ?? 3000)
|
|
77
|
+
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
|
|
78
|
+
console.warn(`[proxy] PORT "${raw}" is not a valid port -- falling back to 3000`)
|
|
79
|
+
return 3000
|
|
80
|
+
}
|
|
81
|
+
return n
|
|
82
|
+
}
|
|
68
83
|
|
|
69
84
|
// -- Secret compare (constant time) -------------------------------------------
|
|
70
85
|
// Only honoured when a secret is configured and matches. Hash both sides to a
|
|
@@ -96,7 +111,6 @@ export function isBot(ua) {
|
|
|
96
111
|
// the module was imported from.
|
|
97
112
|
const CACHE_DIR = path.resolve(process.env.PRESTRUCT_CACHE_DIR || '.prestruct_cache')
|
|
98
113
|
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
114
|
|
|
101
115
|
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true })
|
|
102
116
|
|
|
@@ -124,31 +138,49 @@ function cacheWrite(key, html) {
|
|
|
124
138
|
|
|
125
139
|
// Cleanup old/large cache files
|
|
126
140
|
let lastCleanup = 0
|
|
127
|
-
|
|
141
|
+
|
|
142
|
+
// Eviction only ever touches cache-shaped files: a 64-hex `.html` written by
|
|
143
|
+
// this proxy's cacheWrite. Walk 2026-10 finding: the old implementation
|
|
144
|
+
// readdirSync'd the WHOLE cache dir and evicted the OLDEST FILES whatever
|
|
145
|
+
// they were -- with the documented PRESTRUCT_CACHE_DIR override, an operator
|
|
146
|
+
// mounting a shared/large directory as the cache could have their non-proxy
|
|
147
|
+
// files deleted once the directory crossed the size limit. Decoys stay put.
|
|
148
|
+
// The size cap is also an env knob now (PRESTRUCT_CACHE_MAX_MB, default 500)
|
|
149
|
+
// so operators on small disks can turn eviction on before it matters -- and
|
|
150
|
+
// so tests can exercise real eviction without 500MB of fixtures.
|
|
151
|
+
const CACHE_FILE_SHAPE = /^[0-9a-f]{64}\.html$/
|
|
152
|
+
export function cleanupCache({ force = false } = {}) {
|
|
128
153
|
const now = Date.now()
|
|
129
|
-
// Only run cleanup once per minute max
|
|
130
|
-
if (now - lastCleanup < 60000) return
|
|
154
|
+
// Only run cleanup once per minute max (force bypasses for tests)
|
|
155
|
+
if (!force && now - lastCleanup < 60000) return
|
|
131
156
|
lastCleanup = now
|
|
132
157
|
|
|
133
158
|
try {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
159
|
+
const maxCacheSize = Math.max(1, parseInt(process.env.PRESTRUCT_CACHE_MAX_MB || '500', 10) || 500) * 1024 * 1024
|
|
160
|
+
const files = fs.readdirSync(CACHE_DIR)
|
|
161
|
+
.filter(f => CACHE_FILE_SHAPE.test(f))
|
|
162
|
+
.map(f => {
|
|
163
|
+
const p = path.join(CACHE_DIR, f)
|
|
164
|
+
const stat = fs.statSync(p)
|
|
165
|
+
return { path: p, size: stat.size, mtime: stat.mtimeMs }
|
|
166
|
+
})
|
|
139
167
|
|
|
140
168
|
// Calculate total size
|
|
141
169
|
let totalSize = files.reduce((sum, f) => sum + f.size, 0)
|
|
142
170
|
|
|
143
171
|
// If over limit, remove oldest files
|
|
144
|
-
if (totalSize >
|
|
172
|
+
if (totalSize > maxCacheSize) {
|
|
145
173
|
files.sort((a, b) => a.mtime - b.mtime)
|
|
146
|
-
|
|
174
|
+
const evicted = []
|
|
175
|
+
while (totalSize > maxCacheSize * 0.8 && files.length > 0) {
|
|
147
176
|
const oldest = files.shift()
|
|
148
177
|
fs.unlinkSync(oldest.path)
|
|
149
178
|
totalSize -= oldest.size
|
|
179
|
+
evicted.push(oldest.path)
|
|
150
180
|
}
|
|
151
|
-
|
|
181
|
+
// The old log printed the REMAINING count as "removed" -- misleading ops
|
|
182
|
+
// signal during incidents.
|
|
183
|
+
console.log('[proxy] Cache cleaned up, removed', evicted.length, 'files')
|
|
152
184
|
}
|
|
153
185
|
} catch (err) {
|
|
154
186
|
console.warn('[proxy] Cache cleanup failed:', err.message)
|
|
@@ -233,8 +265,44 @@ export async function withRenderLimit(fn) {
|
|
|
233
265
|
})
|
|
234
266
|
}
|
|
235
267
|
|
|
236
|
-
// --
|
|
268
|
+
// -- Single-flight renders ---------------------------------------------------
|
|
269
|
+
// (2026-10 walk): the concurrency limiter bounds how many renders run at once,
|
|
270
|
+
// but a 50-bot burst on the same uncached URL still queued TEN sequential
|
|
271
|
+
// renders of the identical page -- one full wave of 5 before the first HIT
|
|
272
|
+
// could serve. renderOnce collapses a burst to one render: the first miss
|
|
273
|
+
// opens a flight keyed by the cache key, every other identical miss awaits
|
|
274
|
+
// the same promise and is served its bytes. Forced refreshes skip the flight
|
|
275
|
+
// (their contract is bypass-the-cache, so they must not ride one) and own no
|
|
276
|
+
// map entry, so a flight can never be deleted by a request that is not its
|
|
277
|
+
// owner.
|
|
278
|
+
const inflightRenders = new Map()
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Render a URL exactly once per burst: concurrent identical misses share the
|
|
282
|
+
* one render; forced refreshes bypass the flight entirely.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} key - sha256 cache key of the request URL
|
|
285
|
+
* @param {string} targetFullUrl - the origin URL page.goto renders
|
|
286
|
+
* @param {boolean} [forceRefresh] - true = render fresh, bypass the flight
|
|
287
|
+
* @returns {Promise<string>} rendered HTML (shared instance across joiners)
|
|
288
|
+
*/
|
|
289
|
+
export async function renderOnce(key, targetFullUrl, forceRefresh = false, renderFn = render) {
|
|
290
|
+
if (!forceRefresh && inflightRenders.has(key)) {
|
|
291
|
+
return inflightRenders.get(key)
|
|
292
|
+
}
|
|
293
|
+
const own = !forceRefresh
|
|
294
|
+
const promise = withRenderLimit(() => renderFn(targetFullUrl))
|
|
295
|
+
if (own) {
|
|
296
|
+
inflightRenders.set(key, promise)
|
|
297
|
+
promise.finally(function () {
|
|
298
|
+
if (inflightRenders.get(key) === promise) inflightRenders.delete(key)
|
|
299
|
+
}).catch(function () {})
|
|
300
|
+
}
|
|
301
|
+
return promise
|
|
302
|
+
}
|
|
237
303
|
|
|
304
|
+
// -- Express app ---------------------------------------------------------------
|
|
305
|
+
// The app is a module-level singleton: one browser, one queue, one flight map.
|
|
238
306
|
const app = express()
|
|
239
307
|
|
|
240
308
|
// Live-fire fix (2026-10): the wildcard routes (`app.all('*')` /
|
|
@@ -250,11 +318,19 @@ export async function proxyHandler(req, res, next) {
|
|
|
250
318
|
const ua = req.headers['user-agent'] || ''
|
|
251
319
|
const bot = isBot(ua)
|
|
252
320
|
|
|
253
|
-
// Validate and build the target URL.
|
|
321
|
+
// Validate and build the target URL. Build it by concatenation (preserving
|
|
322
|
+
// any path prefix on targetUrl) and then require the resolved origin to
|
|
323
|
+
// equal the configured target origin, so a request target like
|
|
324
|
+
// `//evil.com/x` or `@evil.com/` can never swap the host. The old form
|
|
325
|
+
// relied on Node's HTTP parser rejecting `@`-prefixed targets -- correct
|
|
326
|
+
// today, but the SSRF guard should not hinge on parser behavior.
|
|
254
327
|
let targetFullUrl
|
|
255
328
|
try {
|
|
329
|
+
const base = new URL(TARGET_URL)
|
|
256
330
|
const parsed = new URL(`${TARGET_URL}${req.url}`)
|
|
257
|
-
if (!['http:', 'https:'].includes(parsed.protocol)
|
|
331
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== base.origin) {
|
|
332
|
+
throw new Error('Invalid target origin')
|
|
333
|
+
}
|
|
258
334
|
targetFullUrl = parsed.toString()
|
|
259
335
|
} catch {
|
|
260
336
|
return res.status(400).send('Invalid request URL.')
|
|
@@ -280,7 +356,7 @@ export async function proxyHandler(req, res, next) {
|
|
|
280
356
|
}
|
|
281
357
|
|
|
282
358
|
try {
|
|
283
|
-
const html = await
|
|
359
|
+
const html = await renderOnce(key, targetFullUrl, forceRefresh)
|
|
284
360
|
cacheWrite(key, html)
|
|
285
361
|
res.setHeader('X-Prestruct-Cache', 'MISS')
|
|
286
362
|
res.setHeader('X-Prestruct-Rendered-At', new Date().toISOString())
|
|
@@ -317,6 +393,13 @@ export function createProxyApp(config) {
|
|
|
317
393
|
}
|
|
318
394
|
|
|
319
395
|
BOT_LIST = proxyConfig.botList || []
|
|
396
|
+
if (!Array.isArray(proxyConfig.botList) || proxyConfig.botList.length === 0) {
|
|
397
|
+
// Loud config diagnostic (#52 form): a proxy with an empty bot list
|
|
398
|
+
// renders nothing -- every request, bot or not, gets the 302 and the
|
|
399
|
+
// operator sees a green process doing nothing. Config is author-owned,
|
|
400
|
+
// so this warns instead of failing.
|
|
401
|
+
console.warn('[proxy] config.proxy.botList is empty or missing -- no bot will ever be rendered (every request 302s). Set the browser token list.')
|
|
402
|
+
}
|
|
320
403
|
|
|
321
404
|
const secret = process.env.PRESTRUCT_SECRET || null
|
|
322
405
|
SECRET_SHA256 = secret
|
|
@@ -38,13 +38,20 @@
|
|
|
38
38
|
*
|
|
39
39
|
* targetUrl vs siteUrl:
|
|
40
40
|
* Set PRESTRUCT_TARGET_URL in wrangler.toml [vars] to point the proxy at a
|
|
41
|
-
* different origin (WordPress, staging, localhost tunnel, etc.).
|
|
42
|
-
*
|
|
41
|
+
* different origin (WordPress, staging, localhost tunnel, etc.). It is
|
|
42
|
+
* required: an unset value fails the request with a 500 rather than falling
|
|
43
|
+
* back to a guessed origin.
|
|
43
44
|
*
|
|
44
45
|
* Bot list:
|
|
45
46
|
* Maintained inline here. Sync with config.proxy.botList in ssr.config.js
|
|
46
47
|
* if you customise either. The Worker has no access to your build-time config.
|
|
47
48
|
*
|
|
49
|
+
* Render load:
|
|
50
|
+
* A per-isolate flight map collapses a burst of identical uncached misses
|
|
51
|
+
* into one render, and a concurrency limiter (MAX_CONCURRENT) bounds how
|
|
52
|
+
* many Browser Rendering sessions one isolate opens at once -- parity with
|
|
53
|
+
* the VPS proxy's renderOnce + withRenderLimit.
|
|
54
|
+
*
|
|
48
55
|
* Security:
|
|
49
56
|
* - Cache refresh requires the PRESTRUCT_SECRET value in x-prestruct-refresh,
|
|
50
57
|
* compared with a full-length constant-time XOR (see refreshSecretMatches).
|
|
@@ -91,6 +98,38 @@ export function cacheKey(url) {
|
|
|
91
98
|
return `prestruct:${encodeURIComponent(url)}`
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
// Walk 2026-10 finding: KV keys are capped at 512 bytes -- a long query
|
|
102
|
+
// string (tracked urls, exported search params) blows past the cap and the
|
|
103
|
+
// KV put/get REJECTS, turning an already-successful render into a 500 on
|
|
104
|
+
// the response path. The legacy encoding stays the key for every normal
|
|
105
|
+
// URL (byte-identical existing cache entries); only oversized keys fall
|
|
106
|
+
// back to SHA-256 hex (collision-proof per the shared cache-key lesson)
|
|
107
|
+
// under a shape-tagged prefix. crypto.subtle is available in Workers AND any
|
|
108
|
+
// Web-Crypto runtime; Node >=20 ships it as a global.
|
|
109
|
+
export async function cacheKeyFor(url) {
|
|
110
|
+
const legacy = cacheKey(url)
|
|
111
|
+
if (legacy.length <= 500) return legacy
|
|
112
|
+
const bytes = new TextEncoder().encode(String(url))
|
|
113
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
|
114
|
+
const hex = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
115
|
+
return `prestruct:sha256:${hex}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* TTL guard (walk 2026-10): KV's expirationTtl rejects non-numbers, values
|
|
120
|
+
* below the protocol minimum of 60s, and NaN -- and the put happens AFTER a
|
|
121
|
+
* successful, already-paid-for render, so a junk env value turned a working
|
|
122
|
+
* MISS into a 500 on the response path. Validate loudly at the seam.
|
|
123
|
+
*/
|
|
124
|
+
export function ttlFromEnv(raw) {
|
|
125
|
+
const n = parseInt(raw ?? '86400', 10)
|
|
126
|
+
if (!Number.isFinite(n)) {
|
|
127
|
+
console.warn(`[proxy:worker] CACHE_TTL_SECONDS "${raw}" is not a number -- falling back to 86400`)
|
|
128
|
+
return 86400
|
|
129
|
+
}
|
|
130
|
+
return Math.max(60, n) // KV minimum TTL; a shorter wish cannot be honored
|
|
131
|
+
}
|
|
132
|
+
|
|
94
133
|
// -- Secret compare (constant time) -------------------------------------------
|
|
95
134
|
// Workers cannot import node:crypto, so there is no timingSafeEqual here.
|
|
96
135
|
// The XOR form is the standard constant-time comparator available in any
|
|
@@ -109,13 +148,93 @@ export function refreshSecretMatches(secret, headerValue) {
|
|
|
109
148
|
const len = Math.max(a.length, b.length)
|
|
110
149
|
for (let i = 0; i < len; i++) {
|
|
111
150
|
diff |= (a[i % a.length] || 0) ^ (b[i % b.length] || 0)
|
|
151
|
+
} return diff === 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Single-flight render map, per isolate (module scope): a same-isolate burst
|
|
155
|
+
// of identical uncached bots collapses into one render instead of one per
|
|
156
|
+
// concurrent request; forced refreshes bypass it. KV is eventually
|
|
157
|
+
// consistent, so this shares only within the isolate that owns the miss.
|
|
158
|
+
const inflightRenders = new Map()
|
|
159
|
+
|
|
160
|
+
// Render concurrency limit (parity with scripts/proxy.js): bounds how many
|
|
161
|
+
// Browser Rendering sessions one isolate opens at once. Without it a bot
|
|
162
|
+
// burst that misses the flight map launches unbounded sessions.
|
|
163
|
+
export const MAX_CONCURRENT = 5
|
|
164
|
+
let activeRenders = 0
|
|
165
|
+
const renderQueue = []
|
|
166
|
+
|
|
167
|
+
export async function withRenderLimit(fn) {
|
|
168
|
+
if (activeRenders < MAX_CONCURRENT) {
|
|
169
|
+
activeRenders++
|
|
170
|
+
try {
|
|
171
|
+
return await fn()
|
|
172
|
+
} finally {
|
|
173
|
+
activeRenders--
|
|
174
|
+
// Recurse into withRenderLimit (not fn directly) so the queued task
|
|
175
|
+
// re-acquires a slot; calling fn() directly skips the acquire and
|
|
176
|
+
// returns to unbounded concurrency under sustained load.
|
|
177
|
+
if (renderQueue.length > 0) {
|
|
178
|
+
const next = renderQueue.shift()
|
|
179
|
+
next()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
renderQueue.push(() => { withRenderLimit(fn).then(resolve, reject) })
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Single-flight: the entry is set BEFORE the render is awaited, so a
|
|
189
|
+
// same-isolate burst of identical misses joins the open flight instead of
|
|
190
|
+
// each starting its own render. Returns { html, joined } so the caller can
|
|
191
|
+
// label a joiner's response (FLIGHT) apart from the owner's (MISS). The
|
|
192
|
+
// previous shape read the map before rendering and wrote it after, so the
|
|
193
|
+
// flight was never observable and the FLIGHT branch was dead code.
|
|
194
|
+
export async function renderOnce(key, targetFullUrl, forceRefresh, renderFn) {
|
|
195
|
+
if (!forceRefresh && inflightRenders.has(key)) {
|
|
196
|
+
const html = await inflightRenders.get(key)
|
|
197
|
+
return { html, joined: true }
|
|
198
|
+
}
|
|
199
|
+
const own = !forceRefresh
|
|
200
|
+
const promise = withRenderLimit(() => renderFn(targetFullUrl))
|
|
201
|
+
if (own) {
|
|
202
|
+
inflightRenders.set(key, promise)
|
|
203
|
+
promise.finally(function () {
|
|
204
|
+
if (inflightRenders.get(key) === promise) inflightRenders.delete(key)
|
|
205
|
+
}).catch(function () {})
|
|
206
|
+
}
|
|
207
|
+
const html = await promise
|
|
208
|
+
return { html, joined: false }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Render via Browser Rendering API. The binding is imported lazily so the
|
|
212
|
+
// module (and its unit tests) never require the @cloudflare/puppeteer
|
|
213
|
+
// package -- only the render path does.
|
|
214
|
+
async function renderViaBrowser(env, targetFullUrl) {
|
|
215
|
+
const { default: workerPuppeteer } = await import('@cloudflare/puppeteer')
|
|
216
|
+
const browser = await workerPuppeteer.launch(env.BROWSER)
|
|
217
|
+
let page
|
|
218
|
+
try {
|
|
219
|
+
page = await browser.newPage()
|
|
220
|
+
await page.setViewport({ width: 1280, height: 800 })
|
|
221
|
+
await page.setExtraHTTPHeaders({ 'X-Prestruct-Proxy': 'true' })
|
|
222
|
+
await page.goto(targetFullUrl, { waitUntil: 'networkidle0', timeout: 30000 })
|
|
223
|
+
return await page.content()
|
|
224
|
+
} finally {
|
|
225
|
+
if (page) {
|
|
226
|
+
try {
|
|
227
|
+
await page.close()
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
await browser.close()
|
|
232
|
+
} catch {}
|
|
112
233
|
}
|
|
113
|
-
return diff === 0
|
|
114
234
|
}
|
|
115
235
|
|
|
116
236
|
export default {
|
|
117
237
|
async fetch(request, env) {
|
|
118
|
-
// Only GET
|
|
119
238
|
if (request.method !== 'GET') {
|
|
120
239
|
return new Response('Method not allowed.', { status: 405 })
|
|
121
240
|
}
|
|
@@ -132,8 +251,11 @@ export default {
|
|
|
132
251
|
const requestUrl = new URL(request.url)
|
|
133
252
|
let targetFullUrl
|
|
134
253
|
try {
|
|
254
|
+
const base = new URL(TARGET_URL)
|
|
135
255
|
const parsed = new URL(`${TARGET_URL}${requestUrl.pathname}${requestUrl.search}`)
|
|
136
|
-
if (!['http:', 'https:'].includes(parsed.protocol)
|
|
256
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== base.origin) {
|
|
257
|
+
throw new Error('Invalid target origin')
|
|
258
|
+
}
|
|
137
259
|
targetFullUrl = parsed.toString()
|
|
138
260
|
} catch {
|
|
139
261
|
return new Response('Invalid request URL.', { status: 400 })
|
|
@@ -151,8 +273,8 @@ export default {
|
|
|
151
273
|
const refreshToken = request.headers.get('x-prestruct-refresh')
|
|
152
274
|
const forceRefresh = refreshSecretMatches(SECRET, refreshToken)
|
|
153
275
|
|
|
154
|
-
const TTL =
|
|
155
|
-
const key =
|
|
276
|
+
const TTL = ttlFromEnv(env.CACHE_TTL_SECONDS)
|
|
277
|
+
const key = await cacheKeyFor(requestUrl.pathname + requestUrl.search)
|
|
156
278
|
|
|
157
279
|
// Try KV cache first (payload is the {html, cachedAt} JSON we now store)
|
|
158
280
|
if (!forceRefresh) {
|
|
@@ -170,46 +292,41 @@ export default {
|
|
|
170
292
|
}
|
|
171
293
|
}
|
|
172
294
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
295
|
+
// Single-flight renders (2026-10 walk, parity with scripts/proxy.js): a
|
|
296
|
+
// same-isolate burst of identical misses collapses into one render (the
|
|
297
|
+
// flight entry is set before the render is awaited). Forced refreshes
|
|
298
|
+
// bypass the flight (their contract is bypass-the-cache). KV is eventually
|
|
299
|
+
// consistent, so no flight guarantees across isolates; this only removes
|
|
300
|
+
// the same-isolate duplication the burst case actually creates.
|
|
176
301
|
let html
|
|
177
|
-
let
|
|
178
|
-
let page
|
|
302
|
+
let joined = false
|
|
179
303
|
try {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
html =
|
|
304
|
+
const result = await renderOnce(
|
|
305
|
+
key,
|
|
306
|
+
targetFullUrl,
|
|
307
|
+
forceRefresh,
|
|
308
|
+
(url) => renderViaBrowser(env, url),
|
|
309
|
+
)
|
|
310
|
+
html = result.html
|
|
311
|
+
joined = result.joined
|
|
187
312
|
} catch (err) {
|
|
188
313
|
console.error('[proxy:worker] Render failed:', err.message)
|
|
189
314
|
// Fall through to live site on render failure
|
|
190
315
|
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
316
|
}
|
|
203
317
|
|
|
204
318
|
// Store in KV with TTL. JSON-encode with the render timestamp so the hit
|
|
205
319
|
// 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
|
-
|
|
320
|
+
// bare HTML and reported a stale/absent date on every HIT). A joiner does
|
|
321
|
+
// not re-put: the flight owner's put already covers the key.
|
|
322
|
+
if (!joined) {
|
|
323
|
+
await env.CACHE.put(key, JSON.stringify({ html, cachedAt: new Date().toISOString() }), { expirationTtl: TTL })
|
|
324
|
+
}
|
|
208
325
|
|
|
209
326
|
return new Response(html, {
|
|
210
327
|
headers: {
|
|
211
328
|
'Content-Type': 'text/html; charset=utf-8',
|
|
212
|
-
'X-Prestruct-Cache': 'MISS',
|
|
329
|
+
'X-Prestruct-Cache': joined ? 'FLIGHT' : 'MISS',
|
|
213
330
|
'X-Prestruct-Rendered-At': new Date().toISOString(),
|
|
214
331
|
},
|
|
215
332
|
})
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const PLATFORMS = ['cloudflare', 'vercel', 'netlify']
|
|
13
|
-
|
|
13
|
+
// The full sitemap-protocol set, not the common four: 'hourly', 'always'
|
|
14
|
+
// and 'never' are legal <changefreq> values, and correctly refusing nothing
|
|
15
|
+
// the protocol accepts is the validator's job (a false "not in" error is
|
|
16
|
+
// just a hard-to-name refusal of a host's honest config).
|
|
17
|
+
const CHANGEFREQS = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
|
|
14
18
|
|
|
15
19
|
// Identity helper: `import { defineConfig } from 'prestruct'` for type-aware
|
|
16
20
|
// ssr.config.js files. No preprocessing -- config is passed through.
|
|
@@ -26,6 +30,60 @@ export function defineConfig(config) {
|
|
|
26
30
|
return config
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
// Known config keys (#52): the load-time diagnostics list. A key that is
|
|
34
|
+
// neither known nor retired does NOTHING in the engine -- a typo'd seam
|
|
35
|
+
// (`ogimg` instead of `ogImage`) must say so loudly instead of passing a
|
|
36
|
+
// green build that silently loses SEO surface.
|
|
37
|
+
export const KNOWN_CONFIG_KEYS = [
|
|
38
|
+
'siteUrl', 'siteName', 'author', 'tagline', 'ogImage', 'keywords',
|
|
39
|
+
'appLayoutPath', 'routes', 'notFound', 'robots', 'proxy', 'faqs',
|
|
40
|
+
'deployId', 'deployIdFooter', 'buildJsonLd', 'fetchRoutes', 'platform',
|
|
41
|
+
// The family ride-along set (2026-10 issue walk): structured-data
|
|
42
|
+
// defaults and per-route nodes, the verification/discovery head set,
|
|
43
|
+
// IndexNow, the postBuild hook seam, and the llms.txt opt-out.
|
|
44
|
+
'jsonLdDefaults',
|
|
45
|
+
'alternates', 'verification', 'feed', 'searchUrl',
|
|
46
|
+
'indexNow', 'hooks', 'runHooks', 'ogImageGeneration',
|
|
47
|
+
'llms',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
// Retired keys: each entry names what owns the behavior now. Empty today --
|
|
51
|
+
// no shipped key has ever been removed without a replacement seam. Entries:
|
|
52
|
+
// { key: string, note: string }
|
|
53
|
+
export const RETIRED_CONFIG_KEYS = []
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Collect unknown/retired ssr.config.js keys as warning strings (#52).
|
|
57
|
+
* Diagnostics, not gates: warnings never fail validation.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} config - full ssr.config.js export
|
|
60
|
+
* @returns {string[]} one warning per unknown/retired key
|
|
61
|
+
*/
|
|
62
|
+
export function configKeyWarnings(config) {
|
|
63
|
+
if (!config || typeof config !== 'object') return []
|
|
64
|
+
const warnings = []
|
|
65
|
+
for (const key of Object.keys(config)) {
|
|
66
|
+
if (KNOWN_CONFIG_KEYS.includes(key)) continue
|
|
67
|
+
const retired = RETIRED_CONFIG_KEYS.find(r => r.key === key)
|
|
68
|
+
if (retired) {
|
|
69
|
+
warnings.push(`retired config key "${key}" has no effect: ${retired.note} - remove this key`)
|
|
70
|
+
} else {
|
|
71
|
+
warnings.push(`unknown config key "${key}" (typo? see the config reference)`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return warnings
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Format key warnings as printed one-liners (`[prestruct] config warning: ..`).
|
|
79
|
+
*
|
|
80
|
+
* @param {string[]} warnings - raw warning strings from configKeyWarnings()
|
|
81
|
+
* @returns {string[]} prefixed one-liner strings
|
|
82
|
+
*/
|
|
83
|
+
export function formatConfigWarnings(warnings) {
|
|
84
|
+
return warnings.map(w => `[prestruct] config warning: ${w}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
29
87
|
/**
|
|
30
88
|
* Validate the ssr.config.js shape and emit structured diagnostics.
|
|
31
89
|
* Shape checks: siteUrl form, route paths (leading slash, traversal guard),
|
|
@@ -94,6 +152,37 @@ export function validateConfig(config) {
|
|
|
94
152
|
})
|
|
95
153
|
}
|
|
96
154
|
|
|
155
|
+
// ogImage: legacy string shorthand AND the structured object form
|
|
156
|
+
// (freestruct proposal #38 commentary): { url, width, height, alt, type }.
|
|
157
|
+
// The object form is what lets og:image carry its width/height/alt
|
|
158
|
+
// companions -- the properties social crawlers use to size previews.
|
|
159
|
+
// Never throws. Bad shapes surface as errors, not as silently-dropped
|
|
160
|
+
// metadata. Companion content validation lives at the injection seam
|
|
161
|
+
// (non-numeric width/height / non-string alt or type are simply not
|
|
162
|
+
// emitted, so a typo'd field can never corrupt the head)
|
|
163
|
+
if (config.ogImage !== undefined && config.ogImage !== null) {
|
|
164
|
+
const isObj = typeof config.ogImage === 'object' && !Array.isArray(config.ogImage)
|
|
165
|
+
if (isObj) {
|
|
166
|
+
if (typeof config.ogImage.url !== 'string') {
|
|
167
|
+
errors.push('ogImage: object form requires a string "url" field')
|
|
168
|
+
}
|
|
169
|
+
for (const k of ['width', 'height']) {
|
|
170
|
+
const v = config.ogImage[k]
|
|
171
|
+
if (v !== undefined && !/^[0-9]+$/.test(String(v))) {
|
|
172
|
+
errors.push(`ogImage: "${k}" should be a number (pixels), got ${JSON.stringify(v)}`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const k of ['alt', 'type']) {
|
|
176
|
+
const v = config.ogImage[k]
|
|
177
|
+
if (v !== undefined && typeof v !== 'string') {
|
|
178
|
+
errors.push(`ogImage: "${k}" must be a string, got ${JSON.stringify(v)}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else if (typeof config.ogImage !== 'string') {
|
|
182
|
+
errors.push('ogImage: must be a string URL or an object { url, width, height, alt, type }')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
97
186
|
// Optional known-value fields
|
|
98
187
|
if (config.platform !== undefined && !PLATFORMS.includes(config.platform)) {
|
|
99
188
|
errors.push(`platform: "${config.platform}" not in ${PLATFORMS.join(', ')}`)
|
|
@@ -102,6 +191,19 @@ export function validateConfig(config) {
|
|
|
102
191
|
errors.push('appLayoutPath: must be a Vite-rooted path starting with "/"')
|
|
103
192
|
}
|
|
104
193
|
|
|
194
|
+
// indexNow (#36): the key must be 32 lowercase hex, or the boolean forms
|
|
195
|
+
// (true = persisted auto-key, false = off). resolveIndexNowKey RETURNS NULL
|
|
196
|
+
// on an invalid key ("diagnostics already warned") -- so without this check
|
|
197
|
+
// a mistyped/placeholder key silently disables IndexNow with no diagnostic
|
|
198
|
+
// anywhere: the example's own placeholder is 36 chars, and uncommenting it
|
|
199
|
+
// would drop the feature green. Surface it here, where the claim is made.
|
|
200
|
+
if (config.indexNow !== undefined && config.indexNow !== null && config.indexNow !== false && config.indexNow !== true) {
|
|
201
|
+
const key = typeof config.indexNow === 'string' ? config.indexNow : config.indexNow.key
|
|
202
|
+
if (typeof key !== 'string' || !/^[a-f0-9]{32}$/.test(key)) {
|
|
203
|
+
errors.push('indexNow: must be true, a 32-char lowercase hex key string, or { key: "<32-hex>" }')
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
105
207
|
return { ok: errors.length === 0, errors }
|
|
106
208
|
}
|
|
107
209
|
|
|
@@ -54,7 +54,9 @@ const { useLocation } = rrd
|
|
|
54
54
|
|
|
55
55
|
/** Update the SPA head tags on every route change (see the module head doc). */
|
|
56
56
|
export default function usePageMeta({ title, description, path, siteUrl }) {
|
|
57
|
-
|
|
57
|
+
// Trailing-slash normalization (#53): a configured base with its trailing
|
|
58
|
+
// slash would join into `https://site//about` on every navigation
|
|
59
|
+
const base = (siteUrl || import.meta.env?.VITE_SITE_URL || '').replace(/\/$/, '')
|
|
58
60
|
const location = useLocation()
|
|
59
61
|
|
|
60
62
|
useEffect(() => {
|
package/init/src/islands.js
CHANGED
|
@@ -55,25 +55,44 @@ import React from 'react'
|
|
|
55
55
|
// Tracks elements that have already been mounted to prevent double createRoot
|
|
56
56
|
// calls when mountIslands is called again on SPA route change.
|
|
57
57
|
const mounted = new WeakSet()
|
|
58
|
+
// Tracks elements that already have a live IntersectionObserver. mountIslands
|
|
59
|
+
// is contractually called after EVERY navigation, and a never-intersected
|
|
60
|
+
// 'visible' island survives each scan as the same element: without this set,
|
|
61
|
+
// a duplicate scan stacks another observer on it (each with its own callback
|
|
62
|
+
// and its own disconnect lifecycle).
|
|
63
|
+
const observing = new WeakSet()
|
|
64
|
+
// The live observer per watched element, so mount() can disconnect exactly
|
|
65
|
+
// the observer that got it here instead of leaving the one-shot entry firing.
|
|
66
|
+
const observers = new WeakMap()
|
|
58
67
|
|
|
59
68
|
function mount(el, Component) {
|
|
60
69
|
if (mounted.has(el)) return
|
|
61
70
|
mounted.add(el)
|
|
71
|
+
// Disconnect the 'visible' island's observer exactly once, at the moment
|
|
72
|
+
// of the mount it was waiting for (instead of inside its own callback,
|
|
73
|
+
// which a duplicate observer would race with).
|
|
74
|
+
const io = observers.get(el)
|
|
75
|
+
if (io) {
|
|
76
|
+
observers.delete(el)
|
|
77
|
+
observing.delete(el)
|
|
78
|
+
io.disconnect()
|
|
79
|
+
}
|
|
62
80
|
el.innerHTML = ''
|
|
63
81
|
ReactDOM.createRoot(el).render(React.createElement(Component))
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
function observe(el, Component) {
|
|
67
85
|
if (mounted.has(el)) return
|
|
68
|
-
|
|
86
|
+
if (observing.has(el)) return // already waiting on one observer
|
|
87
|
+
observing.add(el)
|
|
88
|
+
const io = new IntersectionObserver((entries) => {
|
|
69
89
|
for (const entry of entries) {
|
|
70
90
|
if (entry.isIntersecting) {
|
|
71
|
-
|
|
72
|
-
io.disconnect() // Clean up the observer after use
|
|
73
|
-
mount(el, Component)
|
|
91
|
+
mount(el, Component) // mount() does the disconnect now
|
|
74
92
|
}
|
|
75
93
|
}
|
|
76
94
|
}, { rootMargin: '0px', threshold: 0.1 })
|
|
95
|
+
observers.set(el, io)
|
|
77
96
|
io.observe(el)
|
|
78
97
|
}
|
|
79
98
|
|
|
@@ -127,6 +127,43 @@ export default {
|
|
|
127
127
|
// ]
|
|
128
128
|
// },
|
|
129
129
|
|
|
130
|
+
// === Family ride-alongs (2026-10 issue walk) ===
|
|
131
|
+
// All optional; every block is missing-only and config-gated. Full
|
|
132
|
+
// semantics: docs/guides/config.md#family-ride-alongs.
|
|
133
|
+
|
|
134
|
+
// Config-driven structured-data defaults (#30): without buildJsonLd the
|
|
135
|
+
// engine emits Organization + WebSite from siteName/siteUrl/author.
|
|
136
|
+
// jsonLdDefaults: false, // skip the defaults
|
|
137
|
+
|
|
138
|
+
// Per-route schema: route.jsonLd (object or array) ships on that page only
|
|
139
|
+
// (FAQPage on the FAQ page, Article on the article). Feed discovery,
|
|
140
|
+
// hreflang alternates (route.alternates overrides the site-wide set),
|
|
141
|
+
// search-console verification metas, and a SearchAction on the home route:
|
|
142
|
+
// feed: { url: 'https://example.com/feed.xml', title: 'Feed' },
|
|
143
|
+
// searchUrl: 'https://example.com/search?q={query}',
|
|
144
|
+
// alternates: [{ hreflang: 'en', href: 'https://example.com' }],
|
|
145
|
+
// verification: { 'google-site-verification': 'token-here' },
|
|
146
|
+
|
|
147
|
+
// llms.txt (AI-consumable site summary from the baked route set),
|
|
148
|
+
// generated-only (a hand-rolled file is never touched). Opt-out:
|
|
149
|
+
// llms: false,
|
|
150
|
+
|
|
151
|
+
// IndexNow (#36): a 32-char lowercase-hex key string (or true = persisted
|
|
152
|
+
// auto-key in .prestruct/). build writes <key>.txt into dist/; `prestruct
|
|
153
|
+
// indexnow --dry-run` prints the submission payload.
|
|
154
|
+
// indexNow: 'replacewithyour32lowercasehexkey',
|
|
155
|
+
|
|
156
|
+
// postBuild hooks (#41): shell commands run BY the build after prerender
|
|
157
|
+
// (npm-chain scripts can be retired). Opt-in execution gate:
|
|
158
|
+
// runHooks: true,
|
|
159
|
+
// hooks: { postBuild: ['node scripts/inject-faq-schema.mjs'] },
|
|
160
|
+
|
|
161
|
+
// Branded per-route OG images (#43): render a 1200x630 PNG per route from
|
|
162
|
+
// a 1200x630 SVG template ({{ogTitle}}/{{ogDescription}}/{{ogUrl}} slots).
|
|
163
|
+
// Rasterizer is the optional dev dep @resvg/resvg-js - without it the
|
|
164
|
+
// generation skips loudly (build stays green, audit flags the head).
|
|
165
|
+
// ogImageGeneration: { template: 'og-template.svg', font: './brand/bold.ttf' },
|
|
166
|
+
|
|
130
167
|
// === 404 Page ===
|
|
131
168
|
// Custom 404 page content. Prerendered with noindex meta.
|
|
132
169
|
notFound: {
|