@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.
- package/package.json +17 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -0
- package/src/a11y/fix-hints.ts +76 -0
- package/src/a11y/index.ts +18 -0
- package/src/a11y/run-audit.ts +394 -0
- package/src/a11y/types.ts +125 -0
- package/src/bundler/analyzer.ts +119 -4
- package/src/bundler/budget.ts +404 -0
- package/src/bundler/prerender.ts +432 -20
- package/src/client/spa-nav-helper.ts +92 -82
- package/src/config/mandu.ts +88 -0
- package/src/config/validate.ts +72 -0
- package/src/diagnose/__tests__/checks.test.ts +4 -2
- package/src/diagnose/checks.ts +121 -0
- package/src/diagnose/index.ts +1 -0
- package/src/diagnose/run.ts +7 -3
- package/src/perf/__tests__/user-marks.test.ts +354 -0
- package/src/perf/index.ts +29 -0
- package/src/perf/user-marks.ts +553 -0
- package/src/runtime/server.ts +257 -37
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
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
|
|