@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- /** Default cache policy stamped on runtime prerender responses. */
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=31536000, immutable";
204
+ "public, max-age=0, must-revalidate";
187
205
 
188
206
  /**
189
207
  * Issue #213 — default denylist for the link crawler.
@@ -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 long
509
- * `Cache-Control` header.
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=31536000, immutable`).
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 (default: `public, max-age=31536000, immutable`) and an
3501
- * `X-Mandu-Cache: PRERENDERED` tag for observability / log parity
3502
- * with the ISR cache path.
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": p.cacheControl,
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 : {};