@mandujs/core 0.33.0 → 0.34.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.
@@ -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
@@ -597,6 +603,25 @@ export interface ServerOptions {
597
603
  * use `ctx.locale.code` and ignore `ctx.t`.
598
604
  */
599
605
  messages?: MessageRegistry;
606
+ /**
607
+ * Issue #217 — suppress the "🥟 Mandu server listening"/"🥟 Mandu Dev
608
+ * Server listening" banner printed at boot. The HTTP listener still
609
+ * binds and `ManduServer.server.port` still reports the chosen port;
610
+ * only the stdout banner (plus its auxiliary lines — HMR hint, CORS
611
+ * hint, static-file hint, streaming hint, Kitchen hint, "also
612
+ * reachable at" hint) is gated.
613
+ *
614
+ * Intended for internal callers such as the build-time prerender
615
+ * orchestrator that spin up a transient listener on an ephemeral
616
+ * port (`port: 0`) and tear it down seconds later — the banner's
617
+ * URL is always wrong by the time a human (or an LLM reading build
618
+ * logs) tries to curl it, so printing it causes confusion.
619
+ *
620
+ * User-facing commands (`mandu dev`, `mandu start`) leave this
621
+ * `undefined` / `false` to preserve the normal banner. Default:
622
+ * `false`.
623
+ */
624
+ silent?: boolean;
600
625
  }
601
626
 
602
627
  export interface ManduServer {
@@ -1325,6 +1350,155 @@ function createStaticErrorResponse(status: 400 | 403 | 404 | 500): Response {
1325
1350
  return new Response(body, { status });
1326
1351
  }
1327
1352
 
1353
+ // ═══════════════════════════════════════════════════════════════════════════
1354
+ // Static asset cache policy — Issue #218
1355
+ //
1356
+ // The `immutable` Cache-Control directive is a *contract* with browsers:
1357
+ // "the bytes at this URL will never change." Violating it (by overwriting a
1358
+ // stable-name file between builds) means users keep stale CSS/JS until they
1359
+ // hard-refresh. Mandu historically emitted `/.mandu/client/globals.css`
1360
+ // and `/.mandu/client/runtime*.js` with fixed URLs but stamped the response
1361
+ // with `immutable`, which is the exact failure mode.
1362
+ //
1363
+ // Policy:
1364
+ // - Hashed URL (e.g. `.../chunk.a1b2c3d4.js`) → `immutable` is safe,
1365
+ // 1-year max-age.
1366
+ // - Stable URL (no hash in filename) → `max-age=0, must-revalidate`.
1367
+ // The client revalidates on every request; a matching `If-None-Match`
1368
+ // short-circuits to 304 with no body, so the cost is one HEAD-sized
1369
+ // round-trip, not a full re-download.
1370
+ //
1371
+ // Strong ETag (content-hash) is emitted for every `/.mandu/client/*`
1372
+ // response so conditional GETs are cheap. We use `Bun.hash` (wyhash, ~5GB/s)
1373
+ // for the digest and cache results keyed by `path + size + mtime` to avoid
1374
+ // re-hashing on every hit.
1375
+ // ═══════════════════════════════════════════════════════════════════════════
1376
+
1377
+ /**
1378
+ * Heuristic: does the filename look like it carries a content hash?
1379
+ *
1380
+ * Matches:
1381
+ * - `name.<hash>.ext` where hash is >=8 hex chars (e.g. `chunk.a1b2c3d4.js`)
1382
+ * - `name-<hash>.ext` (e.g. `vendor-8f3a2b9c.js`)
1383
+ * - `name.<hash>.chunk.ext` common bundler shape
1384
+ *
1385
+ * A hash segment is 8+ lowercase hex chars. Longer digests (16, 20, 32) also
1386
+ * match. We deliberately avoid matching ALL-hex short names like `abc.js`
1387
+ * (requires min length 8).
1388
+ */
1389
+ function hasContentHashInFilename(filename: string): boolean {
1390
+ // `.` or `-` separator, 8+ hex chars, then `.` before extension
1391
+ // Examples that match: chunk.a1b2c3d4.js, vendor-8f3a2b9c.js, app.1234567890abcdef.css
1392
+ // Examples that DON'T match: globals.css, runtime.js, chunk.js
1393
+ return /[.\-][a-f0-9]{8,}\.[a-z0-9]+$/i.test(filename);
1394
+ }
1395
+
1396
+ /**
1397
+ * Compute Cache-Control for a static asset.
1398
+ *
1399
+ * - Dev: no caching (always refetch).
1400
+ * - Prod, hashed filename: `public, max-age=31536000, immutable` (1 year).
1401
+ * - Prod, stable filename: `public, max-age=0, must-revalidate` (force
1402
+ * revalidation; 304 via `If-None-Match` keeps it cheap).
1403
+ */
1404
+ function computeStaticCacheControl(filename: string, isDev: boolean): string {
1405
+ if (isDev) return "no-cache, no-store, must-revalidate";
1406
+ if (hasContentHashInFilename(filename)) {
1407
+ return "public, max-age=31536000, immutable";
1408
+ }
1409
+ return "public, max-age=0, must-revalidate";
1410
+ }
1411
+
1412
+ /**
1413
+ * In-process ETag cache keyed by absolute filePath. Entry is invalidated
1414
+ * when `size` or `mtime` changes. Avoids re-hashing hot files on every
1415
+ * request. The hot path is a single lookup + two scalar compares.
1416
+ */
1417
+ interface EtagCacheEntry {
1418
+ size: number;
1419
+ mtime: number;
1420
+ etag: string;
1421
+ }
1422
+ const etagCache = new Map<string, EtagCacheEntry>();
1423
+ const ETAG_CACHE_MAX = 2048;
1424
+
1425
+ /** Cheap LRU eviction: drop oldest insert when over cap. */
1426
+ function evictEtagCacheIfNeeded(): void {
1427
+ if (etagCache.size <= ETAG_CACHE_MAX) return;
1428
+ const oldestKey = etagCache.keys().next().value;
1429
+ if (oldestKey !== undefined) etagCache.delete(oldestKey);
1430
+ }
1431
+
1432
+ /**
1433
+ * Compute a strong ETag from file bytes (Bun.hash / wyhash). Cached by
1434
+ * `path + size + mtime` so we only re-hash when the file changes.
1435
+ *
1436
+ * Strong (not weak `W/`) because we actually hashed the payload — this
1437
+ * preserves byte-range / delta semantics per RFC 7232.
1438
+ */
1439
+ async function computeStrongEtag(
1440
+ filePath: string,
1441
+ file: import("bun").BunFile,
1442
+ ): Promise<string> {
1443
+ const size = file.size;
1444
+ const mtime = file.lastModified;
1445
+
1446
+ const cached = etagCache.get(filePath);
1447
+ if (cached && cached.size === size && cached.mtime === mtime) {
1448
+ return cached.etag;
1449
+ }
1450
+
1451
+ // Bun.hash returns a number/bigint; stringify in base36 for compact ETag.
1452
+ // Fall back to size+mtime-derived ETag if hashing ever throws (edge-runtime
1453
+ // polyfills etc. — `Bun.hash` is a Bun-native primitive).
1454
+ let digest: string;
1455
+ try {
1456
+ const bytes = await file.arrayBuffer();
1457
+ const h = Bun.hash(bytes);
1458
+ digest = typeof h === "bigint" ? h.toString(36) : Number(h).toString(36);
1459
+ } catch {
1460
+ digest = `${size.toString(36)}-${mtime.toString(36)}`;
1461
+ }
1462
+
1463
+ const etag = `"${digest}"`;
1464
+ etagCache.set(filePath, { size, mtime, etag });
1465
+ evictEtagCacheIfNeeded();
1466
+ return etag;
1467
+ }
1468
+
1469
+ /** Exposed for tests — allows clearing the ETag cache between cases. */
1470
+ export function __clearStaticEtagCacheForTests(): void {
1471
+ etagCache.clear();
1472
+ }
1473
+
1474
+ /**
1475
+ * RFC 7232 §3.2 — `If-None-Match` comparison.
1476
+ *
1477
+ * Accepts:
1478
+ * - `*` wildcard (matches any current representation)
1479
+ * - a single ETag (`"abc"` or `W/"abc"`)
1480
+ * - a comma-separated list
1481
+ *
1482
+ * Uses weak-comparison semantics (strip leading `W/`) because that is the
1483
+ * RFC-prescribed form for `If-None-Match`; a strong server-side ETag still
1484
+ * matches a weak client token if the opaque-string portion is equal.
1485
+ */
1486
+ function matchesEtag(ifNoneMatch: string, currentEtag: string): boolean {
1487
+ const trimmed = ifNoneMatch.trim();
1488
+ if (trimmed === "*") return true;
1489
+
1490
+ const normalize = (tag: string): string => {
1491
+ const t = tag.trim();
1492
+ return t.startsWith("W/") ? t.slice(2) : t;
1493
+ };
1494
+
1495
+ const currentNormalized = normalize(currentEtag);
1496
+ for (const part of trimmed.split(",")) {
1497
+ if (normalize(part) === currentNormalized) return true;
1498
+ }
1499
+ return false;
1500
+ }
1501
+
1328
1502
  /**
1329
1503
  * 경로가 허용된 디렉토리 내에 있는지 검증
1330
1504
  * Path traversal 공격 방지
@@ -1441,26 +1615,39 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
1441
1615
  }
1442
1616
 
1443
1617
  const mimeType = getMimeType(filePath);
1618
+ const filename = path.basename(filePath);
1444
1619
 
1445
- // Cache-Control 헤더 설정
1620
+ // Cache-Control Issue #218: `immutable` is only safe when the URL
1621
+ // contains a content hash. Stable-name bundles (globals.css, runtime.js)
1622
+ // must use `max-age=0, must-revalidate` or clients will serve stale
1623
+ // bytes until a hard refresh.
1624
+ //
1625
+ // Bundle files go through the hash-aware policy; non-bundle assets
1626
+ // (public/*, favicon, etc.) keep the conservative 1-day cache they had
1627
+ // before — they're user-controlled and unlikely to change per deploy.
1446
1628
  let cacheControl: string;
1447
1629
  if (settings.isDev) {
1448
- // 개발 모드: 캐시 없음
1449
1630
  cacheControl = "no-cache, no-store, must-revalidate";
1450
1631
  } else if (isBundleFile) {
1451
- // 프로덕션 번들: 1년 캐시 (파일명에 해시 포함 가정)
1452
- cacheControl = "public, max-age=31536000, immutable";
1632
+ cacheControl = computeStaticCacheControl(filename, /* isDev */ false);
1453
1633
  } else {
1454
- // 프로덕션 일반 정적 파일: 1일 캐시
1455
1634
  cacheControl = "public, max-age=86400";
1456
1635
  }
1457
1636
 
1458
- // ETag: weak validator (파일 크기 + 최종 수정 시간)
1459
- const etag = `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
1460
-
1461
- // 304 Not Modified 불필요한 전송 방지
1637
+ // Strong ETag from content hash for bundle files enables cheap 304
1638
+ // round-trips when the client revalidates (`If-None-Match`).
1639
+ // Non-bundle static files keep a weak size+mtime validator (same as
1640
+ // pre-#218 behaviour)we don't pay the hash cost for user-owned
1641
+ // `public/*` content the framework doesn't control.
1642
+ const etag = isBundleFile
1643
+ ? await computeStrongEtag(filePath, file)
1644
+ : `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
1645
+
1646
+ // 304 Not Modified — unnecessary transfer avoidance. We compare the
1647
+ // full `If-None-Match` string; RFC 7232 also allows a comma-separated
1648
+ // list and `*`, so handle those two forms explicitly.
1462
1649
  const ifNoneMatch = request?.headers.get("If-None-Match");
1463
- if (ifNoneMatch === etag) {
1650
+ if (ifNoneMatch && matchesEtag(ifNoneMatch, etag)) {
1464
1651
  return {
1465
1652
  handled: true,
1466
1653
  response: new Response(null, {
@@ -3626,7 +3813,23 @@ async function handleRequestInternal(
3626
3813
  // `docs/ops/metrics.md` for the operator-facing guide.
3627
3814
  if (pathname === HEAP_ENDPOINT) {
3628
3815
  if (isObservabilityExposed(settings.isDev, settings.heapEndpoint)) {
3629
- return ok(buildHeapResponse());
3816
+ // Phase 18.ψ — augment the Phase 17 payload with user-perf data.
3817
+ // We append (never restructure) the `perf` key so consumers that
3818
+ // rely on `.process`, `.caches`, `.bun` continue to parse. Keeping
3819
+ // the composition here (not in metrics.ts) avoids a metrics→perf
3820
+ // module dep — metrics stays a pure exposition layer.
3821
+ const base = collectHeapSnapshot();
3822
+ const perf = collectPerfSnapshot();
3823
+ const body = { ...base, perf };
3824
+ return ok(
3825
+ new Response(JSON.stringify(body, null, 2), {
3826
+ status: 200,
3827
+ headers: {
3828
+ "Content-Type": "application/json; charset=utf-8",
3829
+ "Cache-Control": "no-store",
3830
+ },
3831
+ }),
3832
+ );
3630
3833
  }
3631
3834
  }
3632
3835
  if (pathname === METRICS_ENDPOINT) {
@@ -3979,6 +4182,11 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3979
4182
  messages: messagesOption,
3980
4183
  plugins: pluginsOption,
3981
4184
  configHooks: configHooksOption,
4185
+ // #217 — internal flag: suppress the "listening" banner. Threaded
4186
+ // through by `mandu build`'s transient prerender server so that
4187
+ // ephemeral `port: 0` listeners don't confuse humans/LLMs with a
4188
+ // URL that's already torn down by the time they curl it.
4189
+ silent = false,
3982
4190
  } = options;
3983
4191
 
3984
4192
  // Phase 18.μ — validate i18n + messages shape. Both are branded via
@@ -4262,31 +4470,43 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4262
4470
 
4263
4471
  const addresses = formatServerAddresses(hostname, actualPort);
4264
4472
 
4265
- if (isDev) {
4266
- console.log(`🥟 Mandu Dev Server listening at ${addresses.primary}`);
4267
- if (addresses.additional.length > 0) {
4268
- console.log(` (also reachable at ${addresses.additional.join(", ")})`);
4269
- }
4270
- if (registry.settings.hmrPort) {
4271
- console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
4272
- }
4273
- console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
4274
- if (corsOptions) {
4275
- console.log(`🌐 CORS enabled`);
4276
- }
4277
- if (streaming) {
4278
- console.log(`🌊 Streaming SSR enabled`);
4279
- }
4280
- if (registry.kitchen) {
4281
- console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
4282
- }
4283
- } else {
4284
- console.log(`🥟 Mandu server listening at ${addresses.primary}`);
4285
- if (addresses.additional.length > 0) {
4286
- console.log(` (also reachable at ${addresses.additional.join(", ")})`);
4287
- }
4288
- if (streaming) {
4289
- console.log(`🌊 Streaming SSR enabled`);
4473
+ // ─── #217 — gate the boot banner on `!silent` ─────────────────────────
4474
+ // `silent: true` is passed by internal callers that spawn a transient
4475
+ // listener on an ephemeral port (e.g. the build-time prerender
4476
+ // orchestrator). The HTTP listener still binds; only the stdout banner
4477
+ // — "🥟 Mandu server listening" / "🥟 Mandu Dev Server listening" plus
4478
+ // its auxiliary lines (additional addresses, HMR, static-file hint,
4479
+ // CORS, streaming, Kitchen) is suppressed. User-facing commands
4480
+ // (`mandu dev`, `mandu start`) leave `silent` undefined/false and
4481
+ // therefore see the banner unchanged.
4482
+ // ─── End #217 ─────────────────────────────────────────────────────────
4483
+ if (!silent) {
4484
+ if (isDev) {
4485
+ console.log(`🥟 Mandu Dev Server listening at ${addresses.primary}`);
4486
+ if (addresses.additional.length > 0) {
4487
+ console.log(` (also reachable at ${addresses.additional.join(", ")})`);
4488
+ }
4489
+ if (registry.settings.hmrPort) {
4490
+ console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
4491
+ }
4492
+ console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
4493
+ if (corsOptions) {
4494
+ console.log(`🌐 CORS enabled`);
4495
+ }
4496
+ if (streaming) {
4497
+ console.log(`🌊 Streaming SSR enabled`);
4498
+ }
4499
+ if (registry.kitchen) {
4500
+ console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
4501
+ }
4502
+ } else {
4503
+ console.log(`🥟 Mandu server listening at ${addresses.primary}`);
4504
+ if (addresses.additional.length > 0) {
4505
+ console.log(` (also reachable at ${addresses.additional.join(", ")})`);
4506
+ }
4507
+ if (streaming) {
4508
+ console.log(`🌊 Streaming SSR enabled`);
4509
+ }
4290
4510
  }
4291
4511
  }
4292
4512