@mandujs/core 0.34.0 → 0.34.1
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/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.
|
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 : {};
|