@mandujs/core 0.33.1 → 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.
@@ -57,11 +57,17 @@ import { eventBus } from "../observability/event-bus";
57
57
  import {
58
58
  HEAP_ENDPOINT,
59
59
  METRICS_ENDPOINT,
60
- buildHeapResponse,
61
60
  buildMetricsResponse,
61
+ collectHeapSnapshot,
62
62
  isObservabilityExposed,
63
63
  recordHttpRequest,
64
64
  } from "../observability/metrics";
65
+ // Phase 18.ψ — user-facing perf marks dashboard append. We include a
66
+ // `perf` block on the `/_mandu/heap` payload when the feature is active
67
+ // so operators see a unified view (heap + caches + perf histogram).
68
+ // Importing the snapshot collector (rather than the whole module) keeps
69
+ // the tree-shake footprint tight when perf is gated off.
70
+ import { collectPerfSnapshot } from "../perf/user-marks";
65
71
  // Phase 18.θ — request tracing. Tracer lifecycle is owned by
66
72
  // `startServer()`; `runWithSpan` is used at the absolute TOP of the
67
73
  // request handler so every downstream await (middleware, filling
@@ -499,11 +505,13 @@ export interface ServerOptions {
499
505
  * When enabled (default), the server looks for a prerender index
500
506
  * under `<rootDir>/<dir>/_manifest.json` (written by `mandu build`)
501
507
  * and, for every request whose pathname maps to a prerendered file,
502
- * serves that HTML directly, bypassing SSR entirely, with a long
503
- * `Cache-Control` header.
508
+ * serves that HTML directly, bypassing SSR entirely, with a
509
+ * conditional-GET friendly `Cache-Control` + strong `ETag` pair.
504
510
  *
505
511
  * - `true` enabled with defaults (dir `.mandu/prerendered`,
506
- * 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).
507
515
  * - `false` disabled. Every request goes through SSR.
508
516
  * - object overrides. `dir` chooses a different output;
509
517
  * `cacheControl` lets adapters tune the CDN hint.
@@ -3491,14 +3499,28 @@ function buildRouteCacheKey(routeId: string, url: URL): string {
3491
3499
  * surface as a 500).
3492
3500
  *
3493
3501
  * Responses are stamped with `Cache-Control` from the registry
3494
- * settings (default: `public, max-age=31536000, immutable`) and an
3495
- * `X-Mandu-Cache: PRERENDERED` tag for observability / log parity
3496
- * 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.
3497
3518
  */
3498
3519
  async function tryServePrerendered(
3499
3520
  pathname: string,
3500
3521
  settings: ServerRegistrySettings,
3501
- method: string
3522
+ method: string,
3523
+ request?: Request
3502
3524
  ): Promise<Response | null> {
3503
3525
  const p = settings.prerender;
3504
3526
  if (!p) return null;
@@ -3517,6 +3539,67 @@ async function tryServePrerendered(
3517
3539
  const filePath = resolvePrerenderedFile(p.index, settings.rootDir, p.dir, pathname);
3518
3540
  if (!filePath) return null;
3519
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
+
3520
3603
  let html: string;
3521
3604
  try {
3522
3605
  html = await fs.readFile(filePath, "utf-8");
@@ -3526,7 +3609,8 @@ async function tryServePrerendered(
3526
3609
 
3527
3610
  const headers = new Headers({
3528
3611
  "Content-Type": "text/html; charset=utf-8",
3529
- "Cache-Control": p.cacheControl,
3612
+ "Cache-Control": cacheControl,
3613
+ "ETag": etag,
3530
3614
  "X-Mandu-Cache": "PRERENDERED",
3531
3615
  });
3532
3616
  const body = method === "HEAD" ? null : html;
@@ -3664,7 +3748,7 @@ async function handleRequestInternal(
3664
3748
  // Must run BEFORE static-file serving and route dispatch so that
3665
3749
  // `mandu build`-emitted HTML short-circuits SSR. No-op if the
3666
3750
  // feature is disabled or the path wasn't prerendered.
3667
- const prerendered = await tryServePrerendered(pathname, settings, req.method);
3751
+ const prerendered = await tryServePrerendered(pathname, settings, req.method, req);
3668
3752
  if (prerendered) {
3669
3753
  if (settings.cors && isCorsRequest(req)) {
3670
3754
  const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
@@ -3807,7 +3891,23 @@ async function handleRequestInternal(
3807
3891
  // `docs/ops/metrics.md` for the operator-facing guide.
3808
3892
  if (pathname === HEAP_ENDPOINT) {
3809
3893
  if (isObservabilityExposed(settings.isDev, settings.heapEndpoint)) {
3810
- return ok(buildHeapResponse());
3894
+ // Phase 18.ψ — augment the Phase 17 payload with user-perf data.
3895
+ // We append (never restructure) the `perf` key so consumers that
3896
+ // rely on `.process`, `.caches`, `.bun` continue to parse. Keeping
3897
+ // the composition here (not in metrics.ts) avoids a metrics→perf
3898
+ // module dep — metrics stays a pure exposition layer.
3899
+ const base = collectHeapSnapshot();
3900
+ const perf = collectPerfSnapshot();
3901
+ const body = { ...base, perf };
3902
+ return ok(
3903
+ new Response(JSON.stringify(body, null, 2), {
3904
+ status: 200,
3905
+ headers: {
3906
+ "Content-Type": "application/json; charset=utf-8",
3907
+ "Cache-Control": "no-store",
3908
+ },
3909
+ }),
3910
+ );
3811
3911
  }
3812
3912
  }
3813
3913
  if (pathname === METRICS_ENDPOINT) {