prestruct 1.0.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.
@@ -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 = parseInt(process.env.PORT || '3000', 10)
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
- function cleanupCache() {
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 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
- })
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 > MAX_CACHE_SIZE) {
172
+ if (totalSize > maxCacheSize) {
145
173
  files.sort((a, b) => a.mtime - b.mtime)
146
- while (totalSize > MAX_CACHE_SIZE * 0.8 && files.length > 0) {
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
- console.log('[proxy] Cache cleaned up, removed', files.length, 'files')
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
- // -- Express app ---------------------------------------------------------------
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. Reject anything that produces a non-HTTP scheme.
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)) throw new Error('Invalid 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 withRenderLimit(() => render(targetFullUrl))
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
- * Defaults to the `x-forwarded-host` of the incoming request if not set.
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)) throw new Error('Invalid 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 = parseInt(env.CACHE_TTL_SECONDS || '86400', 10)
155
- const key = cacheKey(requestUrl.pathname + requestUrl.search)
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
- // 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.
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 browser
178
- let page
302
+ let joined = false
179
303
  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()
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
- await env.CACHE.put(key, JSON.stringify({ html, cachedAt: new Date().toISOString() }), { expirationTtl: TTL })
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
- const CHANGEFREQS = ['daily', 'weekly', 'monthly', 'yearly']
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
 
package/init/src/App.jsx CHANGED
@@ -9,7 +9,13 @@
9
9
  * homepage with no error message. See AGENTS.md for the full explanation.
10
10
  */
11
11
 
12
- import { BrowserRouter } from 'react-router-dom'
12
+ import * as rrdNS from 'react-router-dom'
13
+ // Neither plain named imports nor a bare namespace destructure survive
14
+ // both majors (see AppLayout.jsx). Destructure from whichever side of
15
+ // the interop carries the exports object.
16
+ const rrd = rrdNS.default ?? rrdNS
17
+ const { BrowserRouter } = rrd
18
+
13
19
  import AppLayout from './AppLayout.jsx'
14
20
 
15
21
  export default function App() {
@@ -20,7 +20,14 @@
20
20
  * runs in Node where these globals don't exist.
21
21
  */
22
22
 
23
- import { Routes, Route, useLocation } from 'react-router-dom'
23
+ import * as rrdNS from 'react-router-dom'
24
+ // Neither plain named imports nor a bare namespace destructure survive
25
+ // both majors: v7 ships CJS (named imports rejected under vite SSR; the
26
+ // node interop namespace only reliably carries `default`), v6 is ESM
27
+ // with possibly no default. Destructure from whichever has the exports.
28
+ const rrd = rrdNS.default ?? rrdNS
29
+ const { Routes, Route, useLocation } = rrd
30
+
24
31
  import { useEffect } from 'react'
25
32
  import { mountIslands } from './islands.js'
26
33
  import { islands } from './AppIslands.jsx'
@@ -43,11 +43,20 @@
43
43
  */
44
44
 
45
45
  import { useEffect } from 'react'
46
- import { useLocation } from 'react-router-dom'
46
+ import * as rrdNS from 'react-router-dom'
47
+ // Neither plain named imports nor a bare namespace destructure survive
48
+ // both majors: v7 ships CJS (vite SSR rejects named imports; node interop
49
+ // gives a namespace whose only reliable export is `default` -- the exports
50
+ // object), while v6 is ESM with possibly no default. Destructure from
51
+ // whichever carries the exports.
52
+ const rrd = rrdNS.default ?? rrdNS
53
+ const { useLocation } = rrd
47
54
 
48
55
  /** Update the SPA head tags on every route change (see the module head doc). */
49
56
  export default function usePageMeta({ title, description, path, siteUrl }) {
50
- const base = siteUrl || import.meta.env?.VITE_SITE_URL || ''
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(/\/$/, '')
51
60
  const location = useLocation()
52
61
 
53
62
  useEffect(() => {
@@ -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
- const io = new IntersectionObserver((entries, observer) => {
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
- observer.unobserve(el)
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