@mandujs/core 0.34.0 → 0.34.2
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/package.json +1 -1
- package/src/bundler/prerender.ts +20 -2
- package/src/client/spa-nav-helper.ts +12 -1
- package/src/runtime/server.ts +87 -9
package/package.json
CHANGED
package/src/bundler/prerender.ts
CHANGED
|
@@ -181,9 +181,27 @@ export const DEFAULT_PRERENDER_DIR = ".mandu/prerendered";
|
|
|
181
181
|
/** Default output directory (legacy `prerenderRoutes` callers). */
|
|
182
182
|
export const LEGACY_PRERENDER_DIR = ".mandu/static";
|
|
183
183
|
|
|
184
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* Default cache policy stamped on runtime prerender responses.
|
|
186
|
+
*
|
|
187
|
+
* Issue #221 — prerendered HTML lives at a **stable URL** (route → file,
|
|
188
|
+
* no content hash in the path). Serving it with `immutable` is the same
|
|
189
|
+
* trap Issue #218 closed for `/.mandu/client/*`: browsers honour
|
|
190
|
+
* `immutable` as a year-long contract and users see stale HTML until a
|
|
191
|
+
* hard refresh, even after a fresh deploy.
|
|
192
|
+
*
|
|
193
|
+
* The runtime default is therefore `public, max-age=0, must-revalidate`,
|
|
194
|
+
* which forces a conditional `If-None-Match` round-trip on every
|
|
195
|
+
* navigation. Because the runtime also emits a strong ETag (`Bun.hash`
|
|
196
|
+
* over the HTML bytes) the steady-state response is a ~300-byte
|
|
197
|
+
* `304 Not Modified` — cheap compared to re-downloading the HTML.
|
|
198
|
+
*
|
|
199
|
+
* Adapters that front the runtime with a CDN capable of per-deploy
|
|
200
|
+
* invalidation can still override this via
|
|
201
|
+
* `PrerenderSettings.cacheControl` at `startServer` call site.
|
|
202
|
+
*/
|
|
185
203
|
export const DEFAULT_PRERENDER_CACHE_CONTROL =
|
|
186
|
-
"public, max-age=
|
|
204
|
+
"public, max-age=0, must-revalidate";
|
|
187
205
|
|
|
188
206
|
/**
|
|
189
207
|
* Issue #213 — default denylist for the link crawler.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Issue #208 — Minimal inline SPA-navigation helper.
|
|
3
3
|
* Issue #220 — body-swap observability + fallback + script re-execution.
|
|
4
|
+
* Issue #222 — hash-anchor preservation across SPA swaps.
|
|
4
5
|
*
|
|
5
6
|
* Self-contained IIFE injected into the SSR `<head>` that upgrades plain
|
|
6
7
|
* full-page navigations into client-side `history.pushState` +
|
|
@@ -64,6 +65,16 @@
|
|
|
64
65
|
* `detail: { url, durationMs, container }` so islands and
|
|
65
66
|
* integrations can re-hydrate if needed.
|
|
66
67
|
*
|
|
68
|
+
* 6b. **Hash anchor preservation** (issue #222): after a successful
|
|
69
|
+
* swap, if the target URL carries a `#hash`, the helper resolves
|
|
70
|
+
* `document.getElementById(hash)` (with a fallback to
|
|
71
|
+
* `[name="<hash>"]`) and calls `scrollIntoView({block:"start"})`
|
|
72
|
+
* so `<a href="/docs#intro">` lands on `<h1 id="intro">` instead
|
|
73
|
+
* of the top of the page. Missing targets fall back to `scrollTo(0,0)`.
|
|
74
|
+
* Same-page hash navigation (identical pathname + search) skips the
|
|
75
|
+
* body swap entirely — only pushState + scrollIntoView fire.
|
|
76
|
+
* `CSS.escape` is used when available to handle punctuation in ids.
|
|
77
|
+
*
|
|
67
78
|
* 7. **Inline, not external**: same rationale as #192's prefetch
|
|
68
79
|
* helper — inline removes the extra round-trip on every SSR
|
|
69
80
|
* response, keeps the CSP posture simple, and sidesteps the
|
|
@@ -86,7 +97,7 @@
|
|
|
86
97
|
* flow is documented in this file's JSDoc; anyone editing this string
|
|
87
98
|
* MUST update the exclusion-matrix test and the body-swap test to match.
|
|
88
99
|
*/
|
|
89
|
-
export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h
|
|
100
|
+
export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function esc(h){try{if(typeof CSS!=="undefined"&&CSS&&typeof CSS.escape==="function")return CSS.escape(h);}catch(_){}return String(h).replace(/([^a-zA-Z0-9_-])/g,"\\\\$1");}function extractHash(u){var i=u.indexOf("#");return i>=0?u.slice(i+1):"";}function scrollToHash(hash,url){if(!hash){try{window.scrollTo(0,0);}catch(_){}return;}var el=null;try{el=document.getElementById?document.getElementById(hash):null;}catch(_){}if(!el){try{el=document.querySelector?document.querySelector('[name="'+esc(hash)+'"]'):null;}catch(_){}}if(el&&typeof el.scrollIntoView==="function"){try{el.scrollIntoView({behavior:"instant",block:"start"});}catch(e1){try{el.scrollIntoView();}catch(_){}}try{if(L.hash!=="#"+hash)L.hash="#"+hash;}catch(_){}info("scrolled to #"+hash,url==null?"":url);}else{info("hash target #"+hash+" not found, scrolling to top",url==null?"":url);try{window.scrollTo(0,0);}catch(_){}}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h)return null;var u;try{u=new URL(h,L.href);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;if(u.pathname===L.pathname&&u.search===L.search&&!u.hash)return null;return u;}function pickContainer(doc){var main=doc.querySelector("main");if(main)return{src:main,dst:document.querySelector("main"),kind:"main"};var root=doc.getElementById&&doc.getElementById("root");if(root){var dstR=document.getElementById?document.getElementById("root"):null;if(dstR)return{src:root,dst:dstR,kind:"#root"};}if(doc.body)return{src:doc.body,dst:document.body,kind:"body"};return null;}function mergeHead(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(!nh||!ch)return;var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}catch(e){warn("head merge failed",e&&e.message||e);}}function runScripts(container){try{var scripts=container.querySelectorAll("script");for(var i=0;i<scripts.length;i++){var old=scripts[i];var s=document.createElement("script");for(var j=0;j<old.attributes.length;j++){var a=old.attributes[j];try{s.setAttribute(a.name,a.value);}catch(_){}}if(!old.src)s.text=old.textContent||"";old.parentNode&&old.parentNode.removeChild(old);(document.head||document.body||document.documentElement).appendChild(s);}}catch(e){warn("script re-exec failed",e&&e.message||e);}}function doSwap(doc,url,startedAt){var perr=doc.querySelector&&doc.querySelector("parsererror");if(perr){hardNav(url,"DOMParser returned parsererror");return false;}var pick=pickContainer(doc);if(!pick||!pick.dst){hardNav(url,"no swap container matched (main/#root/body)");return false;}info("swap target container: "+pick.kind);try{pick.dst.innerHTML=pick.src.innerHTML;}catch(e){hardNav(url,"innerHTML assignment threw: "+(e&&e.message||e));return false;}mergeHead(doc);runScripts(pick.dst);scrollToHash(extractHash(url),url);var dur=0;try{dur=Math.round((performance&&performance.now?performance.now():Date.now())-startedAt);}catch(_){}info("swapped to "+url+" in "+dur+"ms (container="+pick.kind+")");try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:dur,container:pick.kind}}));}catch(_){}try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}return true;}function nav(url,push){var startedAt=0;try{startedAt=performance&&performance.now?performance.now():Date.now();}catch(_){startedAt=Date.now();}fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok){hardNav(url,"fetch responded "+r.status);return null;}var ct=r.headers.get("content-type");if(!ct||ct.indexOf("text/html")<0){hardNav(url,"non-HTML response ("+(ct||"no content-type")+")");return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url,"DOMParser unavailable");return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(e){hardNav(url,"DOMParser threw: "+(e&&e.message||e));return;}if(push){try{H.pushState({mandu:1},"",url);}catch(e){hardNav(url,"pushState threw: "+(e&&e.message||e));return;}}var run=function(){doSwap(doc,url,startedAt);};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(e){warn("startViewTransition threw, running swap directly",e&&e.message||e);run();}}else{run();}}).catch(function(e){hardNav(url,"fetch rejected: "+(e&&e.message||e));});}function samePageHashNav(u,push){var url=u.pathname+u.search+u.hash;if(push){try{H.pushState({mandu:1},"",url);}catch(e){warn("pushState threw on same-page hash nav",e&&e.message||e);}}info("same-page hash nav "+url);scrollToHash(u.hash?u.hash.slice(1):"",url);try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:0,container:"hash"}}));}catch(_){}}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();if(url.pathname===L.pathname&&url.search===L.search&&url.hash){samePageHashNav(url,true);return;}nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
|
|
90
101
|
|
|
91
102
|
/** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
|
|
92
103
|
export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
|
package/src/runtime/server.ts
CHANGED
|
@@ -505,11 +505,13 @@ export interface ServerOptions {
|
|
|
505
505
|
* When enabled (default), the server looks for a prerender index
|
|
506
506
|
* under `<rootDir>/<dir>/_manifest.json` (written by `mandu build`)
|
|
507
507
|
* and, for every request whose pathname maps to a prerendered file,
|
|
508
|
-
* serves that HTML directly, bypassing SSR entirely, with a
|
|
509
|
-
* `Cache-Control`
|
|
508
|
+
* serves that HTML directly, bypassing SSR entirely, with a
|
|
509
|
+
* conditional-GET friendly `Cache-Control` + strong `ETag` pair.
|
|
510
510
|
*
|
|
511
511
|
* - `true` enabled with defaults (dir `.mandu/prerendered`,
|
|
512
|
-
* Cache-Control `public, max-age=
|
|
512
|
+
* Cache-Control `public, max-age=0, must-revalidate`
|
|
513
|
+
* — Issue #221; prerendered URLs are stable, so
|
|
514
|
+
* `immutable` would pin stale HTML across deploys).
|
|
513
515
|
* - `false` disabled. Every request goes through SSR.
|
|
514
516
|
* - object overrides. `dir` chooses a different output;
|
|
515
517
|
* `cacheControl` lets adapters tune the CDN hint.
|
|
@@ -3497,14 +3499,28 @@ function buildRouteCacheKey(routeId: string, url: URL): string {
|
|
|
3497
3499
|
* surface as a 500).
|
|
3498
3500
|
*
|
|
3499
3501
|
* Responses are stamped with `Cache-Control` from the registry
|
|
3500
|
-
* settings
|
|
3501
|
-
*
|
|
3502
|
-
*
|
|
3502
|
+
* settings and an `X-Mandu-Cache: PRERENDERED` tag for observability /
|
|
3503
|
+
* log parity with the ISR cache path.
|
|
3504
|
+
*
|
|
3505
|
+
* Issue #221 — prerendered HTML lives at a **stable URL** (route →
|
|
3506
|
+
* file, no content hash in the path). Serving it with `immutable`
|
|
3507
|
+
* breaks new-deploy rollout exactly like #218: browsers pin the
|
|
3508
|
+
* stale HTML for up to a year. The fix mirrors #218's static-file
|
|
3509
|
+
* policy:
|
|
3510
|
+
*
|
|
3511
|
+
* 1. Default `Cache-Control` → `public, max-age=0, must-revalidate`
|
|
3512
|
+
* (via `computeStaticCacheControl` — no hash in filename ⇒
|
|
3513
|
+
* must-revalidate). User-supplied `PrerenderSettings.cacheControl`
|
|
3514
|
+
* still wins so adapters can tune for their CDN.
|
|
3515
|
+
* 2. Emit a strong ETag (`Bun.hash` over the HTML bytes).
|
|
3516
|
+
* 3. On `If-None-Match` match → `304 Not Modified` with empty body,
|
|
3517
|
+
* `Cache-Control` + `ETag` preserved for intermediaries.
|
|
3503
3518
|
*/
|
|
3504
3519
|
async function tryServePrerendered(
|
|
3505
3520
|
pathname: string,
|
|
3506
3521
|
settings: ServerRegistrySettings,
|
|
3507
|
-
method: string
|
|
3522
|
+
method: string,
|
|
3523
|
+
request?: Request
|
|
3508
3524
|
): Promise<Response | null> {
|
|
3509
3525
|
const p = settings.prerender;
|
|
3510
3526
|
if (!p) return null;
|
|
@@ -3523,6 +3539,67 @@ async function tryServePrerendered(
|
|
|
3523
3539
|
const filePath = resolvePrerenderedFile(p.index, settings.rootDir, p.dir, pathname);
|
|
3524
3540
|
if (!filePath) return null;
|
|
3525
3541
|
|
|
3542
|
+
// Load via `Bun.file` so we can reuse the #218 ETag helper (which
|
|
3543
|
+
// keys the hash cache on absolute path + size + mtime). `exists()`
|
|
3544
|
+
// guards the rare race where the index points at a file that was
|
|
3545
|
+
// removed after load.
|
|
3546
|
+
const file = Bun.file(filePath);
|
|
3547
|
+
let exists = false;
|
|
3548
|
+
try {
|
|
3549
|
+
exists = await file.exists();
|
|
3550
|
+
} catch {
|
|
3551
|
+
return null;
|
|
3552
|
+
}
|
|
3553
|
+
if (!exists) return null;
|
|
3554
|
+
|
|
3555
|
+
// Strong ETag derived from HTML bytes — same wyhash primitive the
|
|
3556
|
+
// static-asset dispatch uses, sharing the same LRU cache.
|
|
3557
|
+
let etag: string;
|
|
3558
|
+
try {
|
|
3559
|
+
etag = await computeStrongEtag(filePath, file);
|
|
3560
|
+
} catch {
|
|
3561
|
+
return null;
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
// Cache-Control resolution (Issue #221):
|
|
3565
|
+
//
|
|
3566
|
+
// - When `p.cacheControl` is framework-chosen (either the current
|
|
3567
|
+
// must-revalidate default or the pre-#221 `immutable` default,
|
|
3568
|
+
// which we treat as "caller never opted out"), delegate to
|
|
3569
|
+
// `computeStaticCacheControl` so dev-mode gets `no-cache,
|
|
3570
|
+
// no-store, must-revalidate` and prod gets must-revalidate
|
|
3571
|
+
// (prerendered filenames never carry a content hash, so the
|
|
3572
|
+
// hash-aware policy always lands on the revalidating form).
|
|
3573
|
+
// - Otherwise honour the override verbatim — adapters in front of
|
|
3574
|
+
// a CDN with per-deploy invalidation may legitimately want
|
|
3575
|
+
// aggressive caching.
|
|
3576
|
+
//
|
|
3577
|
+
// The pre-#221 `immutable` string is treated as a framework default
|
|
3578
|
+
// so projects upgrading from a persisted registry state get the fix
|
|
3579
|
+
// automatically rather than staying on the broken policy.
|
|
3580
|
+
const isFrameworkDefault =
|
|
3581
|
+
p.cacheControl === DEFAULT_PRERENDER_CACHE_CONTROL ||
|
|
3582
|
+
p.cacheControl === "public, max-age=31536000, immutable" ||
|
|
3583
|
+
p.cacheControl === "";
|
|
3584
|
+
const cacheControl = isFrameworkDefault
|
|
3585
|
+
? computeStaticCacheControl(path.basename(filePath), settings.isDev)
|
|
3586
|
+
: p.cacheControl;
|
|
3587
|
+
|
|
3588
|
+
// Conditional GET — RFC 7232 §3.2. Covers `"<etag>"`, `W/"<etag>"`,
|
|
3589
|
+
// comma-separated lists, and `*`. 304 keeps ETag + Cache-Control so
|
|
3590
|
+
// downstream caches update their freshness state.
|
|
3591
|
+
const ifNoneMatch = request?.headers.get("If-None-Match");
|
|
3592
|
+
if (ifNoneMatch && matchesEtag(ifNoneMatch, etag)) {
|
|
3593
|
+
return new Response(null, {
|
|
3594
|
+
status: 304,
|
|
3595
|
+
headers: {
|
|
3596
|
+
"ETag": etag,
|
|
3597
|
+
"Cache-Control": cacheControl,
|
|
3598
|
+
"X-Mandu-Cache": "PRERENDERED",
|
|
3599
|
+
},
|
|
3600
|
+
});
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3526
3603
|
let html: string;
|
|
3527
3604
|
try {
|
|
3528
3605
|
html = await fs.readFile(filePath, "utf-8");
|
|
@@ -3532,7 +3609,8 @@ async function tryServePrerendered(
|
|
|
3532
3609
|
|
|
3533
3610
|
const headers = new Headers({
|
|
3534
3611
|
"Content-Type": "text/html; charset=utf-8",
|
|
3535
|
-
"Cache-Control":
|
|
3612
|
+
"Cache-Control": cacheControl,
|
|
3613
|
+
"ETag": etag,
|
|
3536
3614
|
"X-Mandu-Cache": "PRERENDERED",
|
|
3537
3615
|
});
|
|
3538
3616
|
const body = method === "HEAD" ? null : html;
|
|
@@ -3670,7 +3748,7 @@ async function handleRequestInternal(
|
|
|
3670
3748
|
// Must run BEFORE static-file serving and route dispatch so that
|
|
3671
3749
|
// `mandu build`-emitted HTML short-circuits SSR. No-op if the
|
|
3672
3750
|
// feature is disabled or the path wasn't prerendered.
|
|
3673
|
-
const prerendered = await tryServePrerendered(pathname, settings, req.method);
|
|
3751
|
+
const prerendered = await tryServePrerendered(pathname, settings, req.method, req);
|
|
3674
3752
|
if (prerendered) {
|
|
3675
3753
|
if (settings.cors && isCorsRequest(req)) {
|
|
3676
3754
|
const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
|