@mandujs/core 0.54.1 → 0.54.2

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.
@@ -1,10 +1,10 @@
1
- import type { BunFile, Server, ServerWebSocket } from "bun";
1
+ import type { Server, ServerWebSocket } from "bun";
2
2
  import type { RoutesManifest, RouteSpec, HydrationConfig, StaticParamSetSchema } from "../spec/schema";
3
3
  import type { BundleManifest } from "../bundler/types";
4
- import type { ManduFilling, RenderMode } from "../filling/filling";
5
- import { ManduContext, CookieManager } from "../filling/context";
6
- import { Router } from "./router";
7
- import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
4
+ import type { ManduFilling, RenderMode } from "../filling/filling";
5
+ import { ManduContext, CookieManager } from "../filling/context";
6
+ import { Router } from "./router";
7
+ import { renderSSR, resolveAsyncElement } from "./ssr";
8
8
  import {
9
9
  resolveMetadata,
10
10
  renderMetadata,
@@ -50,55 +50,34 @@ import {
50
50
  isCorsRequest,
51
51
  } from "./cors";
52
52
  import { validateImportPath } from "./security";
53
- import { KITCHEN_PREFIX, KitchenHandler, recordRequest } from "../kitchen/kitchen-handler";
54
- import {
55
- eventBus,
56
- type EventType,
57
- type ObservabilityEvent,
58
- type ObservabilitySeverity,
59
- } from "../observability/event-bus";
60
- import {
61
- HEAP_ENDPOINT,
62
- METRICS_ENDPOINT,
63
- buildMetricsResponse,
64
- collectHeapSnapshot,
65
- isObservabilityExposed,
66
- recordHttpRequest,
67
- } from "../observability/metrics";
68
- import {
69
- handleOpenAPIRequest,
70
- isOpenAPIEndpointEnabled,
71
- resolveOpenAPIEndpointSettings,
72
- type OpenAPIEndpointSettings,
73
- } from "./openapi-endpoint";
74
- // Phase 18.ψ — user-facing perf marks dashboard append. We include a
75
- // `perf` block on the `/_mandu/heap` payload when the feature is active
76
- // so operators see a unified view (heap + caches + perf histogram).
77
- // Importing the snapshot collector (rather than the whole module) keeps
78
- // the tree-shake footprint tight when perf is gated off.
79
- import { collectPerfSnapshot } from "../perf/user-marks";
80
- // Phase 18.θ request tracing. Tracer lifecycle is owned by
81
- // `startServer()`; `runWithSpan` is used at the absolute TOP of the
82
- // request handler so every downstream await (middleware, filling
83
- // loader, SSR render) inherits the active span via AsyncLocalStorage.
84
- import type { Tracer } from "../observability/tracing";
85
- import {
86
- createTracerFromConfig,
87
- runWithSpan,
88
- setTracer,
89
- } from "../observability/tracing";
90
- import {
91
- type MiddlewareFn,
92
- type MiddlewareConfig,
93
- loadMiddlewareSync,
94
- } from "./middleware";
95
- // Phase 18.ε — canonical request-level middleware composition API.
96
- // See `packages/core/src/middleware/{define,compose,bridge}.ts`.
97
- import {
98
- compose as composeMiddleware,
99
- type ComposedHandler,
100
- } from "../middleware/compose";
101
- import type { Middleware } from "../middleware/define";
53
+ import {
54
+ createRuntimeDevtoolsAdapter,
55
+ recordRuntimeRequest,
56
+ shouldRecordRuntimeRequest,
57
+ type RuntimeDevtoolsAdapter,
58
+ type RuntimeKitchenHandler,
59
+ } from "./devtools-adapter";
60
+ import {
61
+ createRuntimeObservabilityLifecycle,
62
+ type RuntimeObservabilityLifecycle,
63
+ } from "./observability-lifecycle";
64
+ import {
65
+ handleOpenAPIRequest,
66
+ isOpenAPIEndpointEnabled,
67
+ resolveOpenAPIEndpointSettings,
68
+ type OpenAPIEndpointSettings,
69
+ } from "./openapi-endpoint";
70
+ import {
71
+ type MiddlewareFn,
72
+ type MiddlewareConfig,
73
+ loadMiddlewareSync,
74
+ } from "./middleware";
75
+ import {
76
+ buildRequestMiddlewareChain,
77
+ runRequestMiddleware,
78
+ type ComposedHandler,
79
+ } from "./request-middleware";
80
+ import type { Middleware } from "../middleware/define";
102
81
  // Phase 18.λ — scheduler wiring (statically imported so `startServer` stays
103
82
  // synchronous; the cost of unused code is trivial — `defineCron` is a thin
104
83
  // wrapper around `Bun.cron`).
@@ -106,8 +85,16 @@ import { defineCron as schedulerDefineCron, type CronDef, type CronRegistration
106
85
  import { setActiveSchedulerRegistration } from "../middleware/scheduler-cron";
107
86
  import { createFetchHandler } from "./handler";
108
87
  import { wrapBunWebSocket, type WSHandlers, type WSUpgradeData } from "../filling/ws";
109
- import { handleImageRequest } from "./image-handler";
110
- import { extractShellHtml, createPPRResponse } from "./ppr";
88
+ import { handleImageRequest } from "./image-handler";
89
+ import {
90
+ serveStaticFile,
91
+ computeStrongEtag,
92
+ computeStaticCacheControl,
93
+ matchesEtag,
94
+ } from "./static-files";
95
+ export { __clearStaticEtagCacheForTests } from "./static-files";
96
+ import { extractShellHtml, createPPRResponse } from "./ppr";
97
+ import { renderPageResponse } from "./page-render-response";
111
98
  import { isRedirectResponse } from "./redirect";
112
99
  import { isNotFoundResponse } from "./not-found";
113
100
  import { newId } from "../id";
@@ -328,59 +315,8 @@ function createRateLimitResponse(decision: RateLimitDecision, options: Normalize
328
315
  return appendRateLimitHeaders(response, decision, options);
329
316
  }
330
317
 
331
- // ========== MIME Types ==========
332
- const MIME_TYPES: Record<string, string> = {
333
- // JavaScript
334
- ".js": "application/javascript",
335
- ".mjs": "application/javascript",
336
- ".ts": "application/typescript",
337
- // CSS
338
- ".css": "text/css",
339
- // HTML
340
- ".html": "text/html",
341
- ".htm": "text/html",
342
- // JSON
343
- ".json": "application/json",
344
- // Images
345
- ".png": "image/png",
346
- ".jpg": "image/jpeg",
347
- ".jpeg": "image/jpeg",
348
- ".gif": "image/gif",
349
- ".svg": "image/svg+xml",
350
- ".ico": "image/x-icon",
351
- ".webp": "image/webp",
352
- ".avif": "image/avif",
353
- // Fonts
354
- ".woff": "font/woff",
355
- ".woff2": "font/woff2",
356
- ".ttf": "font/ttf",
357
- ".otf": "font/otf",
358
- ".eot": "application/vnd.ms-fontobject",
359
- // Documents
360
- ".pdf": "application/pdf",
361
- ".txt": "text/plain",
362
- ".xml": "application/xml",
363
- // Media
364
- ".mp3": "audio/mpeg",
365
- ".mp4": "video/mp4",
366
- ".webm": "video/webm",
367
- ".ogg": "audio/ogg",
368
- // Archives
369
- ".zip": "application/zip",
370
- ".gz": "application/gzip",
371
- // WebAssembly
372
- ".wasm": "application/wasm",
373
- // Source maps
374
- ".map": "application/json",
375
- };
376
-
377
- function getMimeType(filePath: string): string {
378
- const ext = path.extname(filePath).toLowerCase();
379
- return MIME_TYPES[ext] || "application/octet-stream";
380
- }
381
-
382
- // ========== Server Options ==========
383
- export interface ServerOptions {
318
+ // ========== Server Options ==========
319
+ export interface ServerOptions {
384
320
  port?: number;
385
321
  hostname?: string;
386
322
  /** 프로젝트 루트 디렉토리 */
@@ -663,13 +599,15 @@ export interface ServerOptions {
663
599
  silent?: boolean;
664
600
  }
665
601
 
666
- export interface ManduServer {
667
- server: Server<undefined>;
668
- router: Router;
669
- /** 이 서버 인스턴스의 레지스트리 */
670
- registry: ServerRegistry;
671
- stop: () => void;
672
- }
602
+ export interface ManduServer {
603
+ server: Server<undefined>;
604
+ router: Router;
605
+ /** 이 서버 인스턴스의 레지스트리 */
606
+ registry: ServerRegistry;
607
+ /** Replace the live dispatch table after FS routes change in dev mode. */
608
+ updateManifest: (nextManifest: RoutesManifest) => void;
609
+ stop: () => void;
610
+ }
673
611
 
674
612
  export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
675
613
  export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
@@ -808,22 +746,8 @@ export interface ServerRegistrySettings {
808
746
  * in-head `<script>` and the 500-response HTML overlay. No-op in prod.
809
747
  */
810
748
  errorOverlay?: boolean;
811
- /**
812
- * Phase 17 — `/_mandu/heap` JSON exposure. `undefined` uses the
813
- * default for the current mode (dev → on, prod → MANDU_DEBUG_HEAP).
814
- */
815
- heapEndpoint?: boolean;
816
- /**
817
- * Phase 17 — `/_mandu/metrics` Prometheus exposure. Same defaulting
818
- * as `heapEndpoint`.
819
- */
820
- metricsEndpoint?: boolean;
821
- /**
822
- * Phase 18.θ — resolved request-tracing state. `undefined` means
823
- * tracing is disabled (the hot path is branch-free). When set, every
824
- * request opens a root span via `tracer.startSpanFromRequest()`.
825
- */
826
- tracer?: Tracer;
749
+ /** Runtime observability lifecycle: endpoints, tracing, perf, counters. */
750
+ observability?: RuntimeObservabilityLifecycle;
827
751
  /**
828
752
  * Production OpenAPI endpoint — resolved from `ServerOptions.openapi`.
829
753
  * `undefined` means the endpoint is disabled (hot path branch-free).
@@ -908,8 +832,10 @@ export class ServerRegistry {
908
832
  * to the framework's built-in 404 JSON error.
909
833
  */
910
834
  notFoundHandler: PageHandler | null = null;
911
- /** Kitchen dev dashboard handler (dev mode only) */
912
- kitchen: KitchenHandler | null = null;
835
+ /** Runtime devtools adapter (dev mode only) */
836
+ devtoolsAdapter: RuntimeDevtoolsAdapter | null = null;
837
+ /** Kitchen dev dashboard handler (dev mode only, kept for compatibility) */
838
+ kitchen: RuntimeKitchenHandler | null = null;
913
839
  /** 라우트별 캐시 옵션 (filling.loader()의 cacheOptions에서 등록) */
914
840
  readonly cacheOptions: Map<string, { revalidate?: number; staleWhileRevalidate?: number; tags?: string[] }> = new Map();
915
841
  /** 라우트별 렌더 모드 */
@@ -1346,476 +1272,9 @@ function createDefaultAppFactory(registry: ServerRegistry) {
1346
1272
  };
1347
1273
  }
1348
1274
 
1349
- // ========== Static File Serving ==========
1350
-
1351
- interface StaticFileResult {
1352
- handled: boolean;
1353
- response?: Response;
1354
- }
1355
-
1356
- const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
1357
- const INTERNAL_EVENTS_ENDPOINT = "/__mandu/events";
1358
-
1359
- function handleEventsStreamRequest(req: Request): Response {
1360
- const url = new URL(req.url);
1361
- const filterType = url.searchParams.get("type") || undefined;
1362
- const filterSeverity = url.searchParams.get("severity") || undefined;
1363
- const filterSource = url.searchParams.get("source") || undefined;
1364
- const filterTrace = url.searchParams.get("trace") || undefined;
1365
-
1366
- const matches = (e: ObservabilityEvent): boolean => {
1367
- if (filterType && e.type !== filterType) return false;
1368
- if (filterSeverity && e.severity !== filterSeverity) return false;
1369
- if (filterSource && e.source !== filterSource) return false;
1370
- if (filterTrace && e.correlationId !== filterTrace) return false;
1371
- return true;
1372
- };
1373
-
1374
- let unsubscribe: (() => void) | null = null;
1375
- let heartbeat: ReturnType<typeof setInterval> | null = null;
1376
-
1377
- const stream = new ReadableStream<Uint8Array>({
1378
- start(controller) {
1379
- const encoder = new TextEncoder();
1380
- const send = (data: string, eventName?: string) => {
1381
- try {
1382
- const prefix = eventName ? `event: ${eventName}\n` : "";
1383
- controller.enqueue(encoder.encode(`${prefix}data: ${data}\n\n`));
1384
- } catch {
1385
- // Stream closed
1386
- }
1387
- };
1388
-
1389
- // Replay recent events that match filters
1390
- const recent = eventBus.getRecent();
1391
- for (const e of recent) {
1392
- if (matches(e)) send(JSON.stringify(e));
1393
- }
1394
-
1395
- // Subscribe to live events
1396
- unsubscribe = eventBus.on("*", (event) => {
1397
- if (matches(event)) send(JSON.stringify(event));
1398
- });
1399
-
1400
- // Heartbeat (comment line) every 15s to keep connection alive
1401
- heartbeat = setInterval(() => {
1402
- try {
1403
- controller.enqueue(encoder.encode(`: heartbeat\n\n`));
1404
- } catch {
1405
- // ignore
1406
- }
1407
- }, 15000);
1408
-
1409
- // Tear down when client disconnects
1410
- const signal = req.signal;
1411
- if (signal) {
1412
- signal.addEventListener("abort", () => {
1413
- if (unsubscribe) { unsubscribe(); unsubscribe = null; }
1414
- if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
1415
- try { controller.close(); } catch { /* noop */ }
1416
- });
1417
- }
1418
- },
1419
- cancel() {
1420
- if (unsubscribe) { unsubscribe(); unsubscribe = null; }
1421
- if (heartbeat) { clearInterval(heartbeat); heartbeat = null; }
1422
- },
1423
- });
1424
-
1425
- return new Response(stream, {
1426
- status: 200,
1427
- headers: {
1428
- "Content-Type": "text/event-stream",
1429
- "Cache-Control": "no-cache, no-store, must-revalidate",
1430
- "Connection": "keep-alive",
1431
- "X-Accel-Buffering": "no",
1432
- },
1433
- });
1434
- }
1435
-
1436
- function handleEventsRecentRequest(req: Request): Response {
1437
- const url = new URL(req.url);
1438
- const count = url.searchParams.get("count");
1439
- const type = url.searchParams.get("type") || undefined;
1440
- const severity = url.searchParams.get("severity") || undefined;
1441
- const windowParam = url.searchParams.get("windowMs");
1442
- const windowMs = windowParam ? Number(windowParam) : undefined;
1443
-
1444
- const events = eventBus.getRecent(
1445
- count ? Number(count) : undefined,
1446
- {
1447
- type: type as EventType | undefined,
1448
- severity: severity as ObservabilitySeverity | undefined,
1449
- },
1450
- );
1451
- const stats = eventBus.getStats(windowMs);
1452
- return Response.json({ events, stats });
1453
- }
1454
-
1455
- function createStaticErrorResponse(status: 400 | 403 | 404 | 500): Response {
1456
- const body = {
1457
- 400: "Bad Request",
1458
- 403: "Forbidden",
1459
- 404: "Not Found",
1460
- 500: "Internal Server Error",
1461
- }[status];
1462
-
1463
- return new Response(body, { status });
1464
- }
1465
-
1466
- // ═══════════════════════════════════════════════════════════════════════════
1467
- // Static asset cache policy — Issue #218
1468
- //
1469
- // The `immutable` Cache-Control directive is a *contract* with browsers:
1470
- // "the bytes at this URL will never change." Violating it (by overwriting a
1471
- // stable-name file between builds) means users keep stale CSS/JS until they
1472
- // hard-refresh. Mandu historically emitted `/.mandu/client/globals.css`
1473
- // and `/.mandu/client/runtime*.js` with fixed URLs but stamped the response
1474
- // with `immutable`, which is the exact failure mode.
1475
- //
1476
- // Policy:
1477
- // - Hashed URL (e.g. `.../chunk.a1b2c3d4.js`) → `immutable` is safe,
1478
- // 1-year max-age.
1479
- // - Stable URL (no hash in filename) → `max-age=0, must-revalidate`.
1480
- // The client revalidates on every request; a matching `If-None-Match`
1481
- // short-circuits to 304 with no body, so the cost is one HEAD-sized
1482
- // round-trip, not a full re-download.
1483
- //
1484
- // Strong ETag (content-hash) is emitted for every `/.mandu/client/*`
1485
- // response so conditional GETs are cheap. We use `Bun.hash` (wyhash, ~5GB/s)
1486
- // for the digest and cache results keyed by `path + size + mtime` to avoid
1487
- // re-hashing on every hit.
1488
- // ═══════════════════════════════════════════════════════════════════════════
1489
-
1490
- /**
1491
- * Heuristic: does the filename look like it carries a content hash?
1492
- *
1493
- * Matches:
1494
- * - `name.<hash>.ext` where hash is >=8 hex chars (e.g. `chunk.a1b2c3d4.js`)
1495
- * - `name-<hash>.ext` (e.g. `vendor-8f3a2b9c.js`)
1496
- * - `name.<hash>.chunk.ext` common bundler shape
1497
- *
1498
- * A hash segment is 8+ lowercase hex chars. Longer digests (16, 20, 32) also
1499
- * match. We deliberately avoid matching ALL-hex short names like `abc.js`
1500
- * (requires min length 8).
1501
- */
1502
- function hasContentHashInFilename(filename: string): boolean {
1503
- // `.` or `-` separator, 8+ hex chars, then `.` before extension
1504
- // Examples that match: chunk.a1b2c3d4.js, vendor-8f3a2b9c.js, app.1234567890abcdef.css
1505
- // Examples that DON'T match: globals.css, runtime.js, chunk.js
1506
- return /[.\-][a-f0-9]{8,}\.[a-z0-9]+$/i.test(filename);
1507
- }
1508
-
1509
- /**
1510
- * Compute Cache-Control for a static asset.
1511
- *
1512
- * - Dev: no caching (always refetch).
1513
- * - Prod, hashed filename: `public, max-age=31536000, immutable` (1 year).
1514
- * - Prod, stable filename: `public, max-age=0, must-revalidate` (force
1515
- * revalidation; 304 via `If-None-Match` keeps it cheap).
1516
- */
1517
- function computeStaticCacheControl(filename: string, isDev: boolean): string {
1518
- if (isDev) return "no-cache, no-store, must-revalidate";
1519
- if (hasContentHashInFilename(filename)) {
1520
- return "public, max-age=31536000, immutable";
1521
- }
1522
- return "public, max-age=0, must-revalidate";
1523
- }
1524
-
1525
- /**
1526
- * In-process ETag cache keyed by absolute filePath. Entry is invalidated
1527
- * when `size` or `mtime` changes. Avoids re-hashing hot files on every
1528
- * request. The hot path is a single lookup + two scalar compares.
1529
- */
1530
- interface EtagCacheEntry {
1531
- size: number;
1532
- mtime: number;
1533
- etag: string;
1534
- }
1535
- const etagCache = new Map<string, EtagCacheEntry>();
1536
- const ETAG_CACHE_MAX = 2048;
1537
-
1538
- /** Cheap LRU eviction: drop oldest insert when over cap. */
1539
- function evictEtagCacheIfNeeded(): void {
1540
- if (etagCache.size <= ETAG_CACHE_MAX) return;
1541
- const oldestKey = etagCache.keys().next().value;
1542
- if (oldestKey !== undefined) etagCache.delete(oldestKey);
1543
- }
1544
-
1545
- /**
1546
- * Compute a strong ETag from file bytes (Bun.hash / wyhash). Cached by
1547
- * `path + size + mtime` so we only re-hash when the file changes.
1548
- *
1549
- * Strong (not weak `W/`) because we actually hashed the payload — this
1550
- * preserves byte-range / delta semantics per RFC 7232.
1551
- */
1552
- async function computeStrongEtag(
1553
- filePath: string,
1554
- file: BunFile,
1555
- ): Promise<string> {
1556
- const size = file.size;
1557
- const mtime = file.lastModified;
1558
-
1559
- const cached = etagCache.get(filePath);
1560
- if (cached && cached.size === size && cached.mtime === mtime) {
1561
- return cached.etag;
1562
- }
1563
-
1564
- // Bun.hash returns a number/bigint; stringify in base36 for compact ETag.
1565
- // Fall back to size+mtime-derived ETag if hashing ever throws (edge-runtime
1566
- // polyfills etc. — `Bun.hash` is a Bun-native primitive).
1567
- let digest: string;
1568
- try {
1569
- const bytes = await file.arrayBuffer();
1570
- const h = Bun.hash(bytes);
1571
- digest = typeof h === "bigint" ? h.toString(36) : Number(h).toString(36);
1572
- } catch {
1573
- digest = `${size.toString(36)}-${mtime.toString(36)}`;
1574
- }
1575
-
1576
- const etag = `"${digest}"`;
1577
- etagCache.set(filePath, { size, mtime, etag });
1578
- evictEtagCacheIfNeeded();
1579
- return etag;
1580
- }
1581
-
1582
- /** Exposed for tests — allows clearing the ETag cache between cases. */
1583
- export function __clearStaticEtagCacheForTests(): void {
1584
- etagCache.clear();
1585
- }
1586
-
1587
- /**
1588
- * RFC 7232 §3.2 — `If-None-Match` comparison.
1589
- *
1590
- * Accepts:
1591
- * - `*` wildcard (matches any current representation)
1592
- * - a single ETag (`"abc"` or `W/"abc"`)
1593
- * - a comma-separated list
1594
- *
1595
- * Uses weak-comparison semantics (strip leading `W/`) because that is the
1596
- * RFC-prescribed form for `If-None-Match`; a strong server-side ETag still
1597
- * matches a weak client token if the opaque-string portion is equal.
1598
- */
1599
- function matchesEtag(ifNoneMatch: string, currentEtag: string): boolean {
1600
- const trimmed = ifNoneMatch.trim();
1601
- if (trimmed === "*") return true;
1602
-
1603
- const normalize = (tag: string): string => {
1604
- const t = tag.trim();
1605
- return t.startsWith("W/") ? t.slice(2) : t;
1606
- };
1607
-
1608
- const currentNormalized = normalize(currentEtag);
1609
- for (const part of trimmed.split(",")) {
1610
- if (normalize(part) === currentNormalized) return true;
1611
- }
1612
- return false;
1613
- }
1614
-
1615
- /**
1616
- * 경로가 허용된 디렉토리 내에 있는지 검증
1617
- * Path traversal 공격 방지
1618
- */
1619
- async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean> {
1620
- try {
1621
- const resolvedPath = path.resolve(filePath);
1622
- const resolvedAllowedDir = path.resolve(allowedDir);
1623
-
1624
- if (!resolvedPath.startsWith(resolvedAllowedDir + path.sep) &&
1625
- resolvedPath !== resolvedAllowedDir) {
1626
- return false;
1627
- }
1628
-
1629
- // 파일이 없으면 안전 (존재하지 않는 경로)
1630
- try {
1631
- await fs.access(resolvedPath);
1632
- } catch {
1633
- return true;
1634
- }
1635
-
1636
- // Symlink 해결 후 재검증
1637
- const realPath = await fs.realpath(resolvedPath);
1638
- const realAllowedDir = await fs.realpath(resolvedAllowedDir);
1639
-
1640
- return realPath.startsWith(realAllowedDir + path.sep) ||
1641
- realPath === realAllowedDir;
1642
- } catch (error) {
1643
- console.warn(`[Mandu Security] Path validation failed: ${filePath}`, error);
1644
- return false;
1645
- }
1646
- }
1647
-
1648
- /**
1649
- * Issue #251 — public 폴더의 자산을 root URL로도 서빙하기 위한 화이트리스트.
1650
- *
1651
- * `mandu build --static` 은 `public/*` 을 dist 루트로 평탄화하므로 prod 에서는
1652
- * `/images/foo.webp` 가 동작한다. dev 에서는 평탄화가 없어서 같은 URL 이 404
1653
- * 였다 — 작성자는 `/public/...` (dev OK, prod 도 vercel rewrite 로 OK) 또는
1654
- * `/...` (dev 깨짐, prod OK) 중 하나를 골라야 했다. 이제 dev 도 자산 확장자가
1655
- * 있는 경로에 한해 `public/<path>` 를 fallback 으로 시도한다.
1656
- *
1657
- * 자산 확장자만 fallback 하므로 `/api/foo` 같은 라우트가 가려질 위험은 없다.
1658
- * 파일이 없으면 `{ handled: false }` 를 반환해 라우터가 정상 매칭하도록 한다.
1659
- */
1660
- const PUBLIC_FLAT_ASSET_EXTENSIONS = new Set<string>([
1661
- ".webp", ".avif", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
1662
- ".pdf", ".zip", ".mp4", ".webm", ".mp3", ".wav",
1663
- ".woff", ".woff2", ".ttf", ".otf", ".eot",
1664
- ".css", ".js", ".map",
1665
- ]);
1666
-
1667
- /**
1668
- * 정적 파일 서빙
1669
- * - /.mandu/client/* : 클라이언트 번들 (Island hydration)
1670
- * - /public/* : 정적 에셋 (이미지, CSS 등)
1671
- * - /favicon.ico : 파비콘
1672
- * - /<asset>.<ext> : public/<asset>.<ext> fallback (issue #251)
1673
- *
1674
- * 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
1675
- */
1676
- async function serveStaticFile(pathname: string, settings: ServerRegistrySettings, request?: Request): Promise<StaticFileResult> {
1677
- let filePath: string | null = null;
1678
- let isBundleFile = false;
1679
- let isPublicFlatFallback = false;
1680
- let allowedBaseDir: string;
1681
- let relativePath: string;
1682
-
1683
- // 1. 클라이언트 번들 파일 (/.mandu/client/*)
1684
- if (pathname.startsWith("/.mandu/client/")) {
1685
- // pathname에서 prefix 제거 후 안전하게 조합
1686
- relativePath = pathname.slice("/.mandu/client/".length);
1687
- allowedBaseDir = path.join(settings.rootDir, ".mandu", "client");
1688
- isBundleFile = true;
1689
- }
1690
- // 2. Public 폴더 파일 (/public/*)
1691
- else if (pathname.startsWith("/public/")) {
1692
- relativePath = pathname.slice("/public/".length);
1693
- allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
1694
- }
1695
- // 3. .well-known/ 디렉토리 (#178: RFC 8615 표준 — Chrome DevTools, ACME, etc.)
1696
- else if (pathname.startsWith("/.well-known/")) {
1697
- relativePath = pathname.slice(1); // ".well-known/..."
1698
- allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
1699
- }
1700
- // 4. Public 폴더의 루트 파일 (favicon.ico, robots.txt 등)
1701
- else if (
1702
- pathname === "/favicon.ico" ||
1703
- pathname === "/robots.txt" ||
1704
- pathname === "/sitemap.xml" ||
1705
- pathname === "/manifest.json"
1706
- ) {
1707
- relativePath = path.basename(pathname);
1708
- allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
1709
- }
1710
- // 5. Public flat fallback (#251) — `mandu build --static` 의 평탄화와 dev 패리티
1711
- else if (PUBLIC_FLAT_ASSET_EXTENSIONS.has(path.extname(pathname).toLowerCase())) {
1712
- relativePath = pathname.slice(1);
1713
- allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
1714
- isPublicFlatFallback = true;
1715
- } else {
1716
- return { handled: false }; // 정적 파일이 아님
1717
- }
1718
-
1719
- // URL 디코딩 (실패 시 차단)
1720
- let decodedPath: string;
1721
- try {
1722
- decodedPath = decodeURIComponent(relativePath);
1723
- } catch {
1724
- return { handled: true, response: createStaticErrorResponse(400) };
1725
- }
1726
-
1727
- // 정규화 + Null byte 방지
1728
- const normalizedPath = path.posix.normalize(decodedPath);
1729
- if (normalizedPath.includes("\0")) {
1730
- console.warn(`[Mandu Security] Null byte attack detected: ${pathname}`);
1731
- return { handled: true, response: createStaticErrorResponse(400) };
1732
- }
1733
-
1734
- const normalizedSegments = normalizedPath.split("/");
1735
- if (normalizedSegments.some((segment) => segment === "..")) {
1736
- return { handled: true, response: createStaticErrorResponse(403) };
1737
- }
1738
-
1739
- // 선행 슬래시 제거 → path.join이 base를 무시하지 않도록 보장
1740
- const safeRelativePath = normalizedPath.replace(/^\/+/, "");
1741
- filePath = path.join(allowedBaseDir, safeRelativePath);
1742
-
1743
- // 최종 경로 검증: 허용된 디렉토리 내에 있는지 확인
1744
- if (!(await isPathSafe(filePath, allowedBaseDir!))) {
1745
- console.warn(`[Mandu Security] Path traversal attempt blocked: ${pathname}`);
1746
- return { handled: true, response: createStaticErrorResponse(403) };
1747
- }
1748
-
1749
- try {
1750
- const file = Bun.file(filePath);
1751
- const exists = await file.exists();
1752
-
1753
- if (!exists) {
1754
- // #251 — flat fallback 은 라우트와 path 충돌이 가능하므로 미존재 시
1755
- // 404 대신 라우터로 흘려보낸다 (e.g. `/foo.json` 라우트가 가려지지 않도록).
1756
- if (isPublicFlatFallback) return { handled: false };
1757
- return { handled: true, response: createStaticErrorResponse(404) };
1758
- }
1759
-
1760
- const mimeType = getMimeType(filePath);
1761
- const filename = path.basename(filePath);
1762
-
1763
- // Cache-Control — Issue #218: `immutable` is only safe when the URL
1764
- // contains a content hash. Stable-name bundles (globals.css, runtime.js)
1765
- // must use `max-age=0, must-revalidate` or clients will serve stale
1766
- // bytes until a hard refresh.
1767
- //
1768
- // Bundle files go through the hash-aware policy; non-bundle assets
1769
- // (public/*, favicon, etc.) keep the conservative 1-day cache they had
1770
- // before — they're user-controlled and unlikely to change per deploy.
1771
- let cacheControl: string;
1772
- if (settings.isDev) {
1773
- cacheControl = "no-cache, no-store, must-revalidate";
1774
- } else if (isBundleFile) {
1775
- cacheControl = computeStaticCacheControl(filename, /* isDev */ false);
1776
- } else {
1777
- cacheControl = "public, max-age=86400";
1778
- }
1779
-
1780
- // Strong ETag from content hash for bundle files — enables cheap 304
1781
- // round-trips when the client revalidates (`If-None-Match`).
1782
- // Non-bundle static files keep a weak size+mtime validator (same as
1783
- // pre-#218 behaviour) — we don't pay the hash cost for user-owned
1784
- // `public/*` content the framework doesn't control.
1785
- const etag = isBundleFile
1786
- ? await computeStrongEtag(filePath, file)
1787
- : `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
1788
-
1789
- // 304 Not Modified — unnecessary transfer avoidance. We compare the
1790
- // full `If-None-Match` string; RFC 7232 also allows a comma-separated
1791
- // list and `*`, so handle those two forms explicitly.
1792
- const ifNoneMatch = request?.headers.get("If-None-Match");
1793
- if (ifNoneMatch && matchesEtag(ifNoneMatch, etag)) {
1794
- return {
1795
- handled: true,
1796
- response: new Response(null, {
1797
- status: 304,
1798
- headers: { "ETag": etag, "Cache-Control": cacheControl },
1799
- }),
1800
- };
1801
- }
1802
-
1803
- return {
1804
- handled: true,
1805
- response: new Response(file, {
1806
- headers: {
1807
- "Content-Type": mimeType,
1808
- "Cache-Control": cacheControl,
1809
- "ETag": etag,
1810
- },
1811
- }),
1812
- };
1813
- } catch {
1814
- return { handled: true, response: createStaticErrorResponse(500) };
1815
- }
1816
- }
1817
-
1818
- // ========== Request Handler ==========
1275
+ const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
1276
+
1277
+ // ========== Request Handler ==========
1819
1278
 
1820
1279
  function unauthorizedControlResponse(): Response {
1821
1280
  return Response.json({ error: "Unauthorized runtime control request" }, { status: 401 });
@@ -1903,71 +1362,29 @@ async function handleInternalCacheControlRequest(
1903
1362
  return new Response(JSON.stringify({ error: "Method not allowed", allowed: ["GET", "POST", "DELETE"], hint: `Received '${req.method}'. This endpoint accepts GET (read stats), POST (clear by path/tag), and DELETE (clear all).` }), { status: 405, headers: { "Content-Type": "application/json", "Allow": "GET, POST, DELETE" } });
1904
1363
  }
1905
1364
 
1906
- async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
1907
- const requestStart = Date.now();
1908
- // Phase 1-4: Correlation ID — 한 요청에서 발생하는 모든 이벤트를 추적
1909
- const correlationId = req.headers.get("x-mandu-request-id") ?? newId();
1910
-
1911
- // ─── Phase 18.θ — TRACING wrap (absolute TOP of request handler) ─────────
1912
- // Opens a root server span BEFORE γ's prerendered check, BEFORE ζ's
1913
- // cache check, BEFORE any other Phase 18 logic. The span is bound to
1914
- // AsyncLocalStorage so every downstream `await` (middleware chain,
1915
- // filling loader, SSR render, sandbox exec) inherits it transparently
1916
- // via `getActiveSpan()` / `ctx.span`.
1917
- //
1918
- // Parent context: honours an incoming W3C `traceparent` header so
1919
- // upstream gateways / load balancers can correlate traces across
1920
- // service boundaries. Missing / malformed header → a new root trace-id.
1921
- //
1922
- // Zero overhead when tracing is disabled: `settings.tracer` is
1923
- // `undefined`, the `if` falls through, and we call the uninstrumented
1924
- // path exactly as before.
1925
- const tracer = registry.settings.tracer;
1926
- if (tracer && tracer.enabled) {
1927
- const url = new URL(req.url);
1928
- const rootSpan = tracer.startSpanFromRequest("http.request", req, {
1929
- kind: "server",
1930
- attributes: {
1931
- "http.method": req.method,
1932
- "http.url": req.url,
1933
- "http.target": url.pathname,
1934
- "http.scheme": url.protocol.replace(":", ""),
1935
- "http.host": url.host,
1936
- "mandu.correlation_id": correlationId,
1937
- },
1938
- });
1939
- try {
1940
- const response = await runWithSpan(rootSpan, () =>
1941
- handleRequestWithTracing(req, router, registry, requestStart, correlationId)
1942
- );
1943
- rootSpan.setAttribute("http.status_code", response.status);
1944
- if (response.status >= 500) {
1945
- rootSpan.setStatus("error", `HTTP ${response.status}`);
1946
- } else if (rootSpan.status === "unset") {
1947
- rootSpan.setStatus("ok");
1948
- }
1949
- return response;
1950
- } catch (err) {
1951
- const msg = err instanceof Error ? err.message : String(err);
1952
- rootSpan.setStatus("error", msg);
1953
- throw err;
1954
- } finally {
1955
- rootSpan.end();
1956
- }
1957
- }
1958
- // ─── End Phase 18.θ ──────────────────────────────────────────────────────
1959
-
1960
- return await handleRequestWithTracing(req, router, registry, requestStart, correlationId);
1961
- }
1962
-
1963
- /**
1964
- * Phase 18.θ — inner request handler. Extracted from {@link handleRequest}
1965
- * so the tracing wrap at the top can run the body inside a
1966
- * `runWithSpan()` scope without a giant indent level. Preserves the
1967
- * exact pre-tracing semantics (correlation-id logging, Cache-Control
1968
- * stamping, eventBus emission).
1969
- */
1970
- async function handleRequestWithTracing(
1365
+ async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
1366
+ const requestStart = Date.now();
1367
+ // Phase 1-4: Correlation ID — 한 요청에서 발생하는 모든 이벤트를 추적
1368
+ const correlationId = req.headers.get("x-mandu-request-id") ?? newId();
1369
+
1370
+ const observability = registry.settings.observability;
1371
+ if (observability) {
1372
+ return await observability.runRequest(
1373
+ req,
1374
+ requestStart,
1375
+ correlationId,
1376
+ () => handleRequestObserved(req, router, registry, requestStart, correlationId)
1377
+ );
1378
+ }
1379
+ return await handleRequestObserved(req, router, registry, requestStart, correlationId);
1380
+ }
1381
+
1382
+ /**
1383
+ * Inner request handler. Observability lifecycle wrapping happens at
1384
+ * {@link handleRequest}; this function keeps response post-processing in one
1385
+ * place without owning tracing or metrics wiring directly.
1386
+ */
1387
+ async function handleRequestObserved(
1971
1388
  req: Request,
1972
1389
  router: Router,
1973
1390
  registry: ServerRegistry,
@@ -1979,27 +1396,24 @@ async function handleRequestWithTracing(
1979
1396
  if (!result.ok) {
1980
1397
  const errorResponse = errorToResponse(result.error, registry.settings.isDev);
1981
1398
  if (registry.settings.isDev) {
1982
- // #177: dev 모드 에러 응답도 캐시 방지
1983
- if (!errorResponse.headers.has("Cache-Control")) {
1984
- errorResponse.headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
1985
- }
1986
- const url = new URL(req.url);
1987
- const p = url.pathname;
1988
- if (!p.startsWith("/.mandu/") && !p.startsWith("/__kitchen") && !p.startsWith("/__mandu/")) {
1989
- const elapsed = Date.now() - requestStart;
1990
- console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${p} ${errorResponse.status} ${elapsed}ms`);
1991
- recordRequest({ id: correlationId, method: req.method, path: p, status: errorResponse.status, duration: elapsed, timestamp: Date.now() });
1992
- // Phase 1-2: HTTP 요청 → EventBus
1993
- eventBus.emit({
1994
- type: "http",
1995
- severity: errorResponse.status >= 500 ? "error" : errorResponse.status >= 400 ? "warn" : "info",
1996
- source: "server",
1997
- correlationId,
1998
- message: `${req.method} ${p} ${errorResponse.status}`,
1999
- duration: elapsed,
2000
- data: { method: req.method, path: p, status: errorResponse.status, error: true },
2001
- });
2002
- }
1399
+ // #177: dev 모드 에러 응답도 캐시 방지
1400
+ if (!errorResponse.headers.has("Cache-Control")) {
1401
+ errorResponse.headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
1402
+ }
1403
+ const url = new URL(req.url);
1404
+ const p = url.pathname;
1405
+ if (shouldRecordRuntimeRequest(p)) {
1406
+ const elapsed = Date.now() - requestStart;
1407
+ registry.settings.observability?.recordDevRequest({
1408
+ req,
1409
+ path: p,
1410
+ status: errorResponse.status,
1411
+ duration: elapsed,
1412
+ correlationId,
1413
+ error: true,
1414
+ recordRequest: recordRuntimeRequest,
1415
+ });
1416
+ }
2003
1417
  }
2004
1418
  // Phase 18.μ — stamp locale hint on error responses too.
2005
1419
  const errI18nState = i18nRequestState.get(req);
@@ -2023,25 +1437,21 @@ async function handleRequestWithTracing(
2023
1437
  result.value.headers.set("Cache-Control", "no-cache, no-store, must-revalidate");
2024
1438
  }
2025
1439
 
2026
- if (!p.startsWith("/.mandu/") && !p.startsWith("/__kitchen") && !p.startsWith("/__mandu/")) {
2027
- const elapsed = Date.now() - requestStart;
2028
- const status = result.value.status;
2029
- const cacheHdr = result.value.headers.get("X-Mandu-Cache") ?? "";
2030
- const cacheTag = cacheHdr ? ` ${cacheHdr}` : "";
2031
- console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${p} ${status} ${elapsed}ms${cacheTag}`);
2032
- recordRequest({ id: correlationId, method: req.method, path: p, status, duration: elapsed, timestamp: Date.now(), cacheStatus: cacheHdr || undefined });
2033
- // Phase 1-2: HTTP 요청 → EventBus
2034
- eventBus.emit({
2035
- type: "http",
2036
- severity: status >= 500 ? "error" : status >= 400 ? "warn" : "info",
2037
- source: "server",
2038
- correlationId,
2039
- message: `${req.method} ${p} ${status}${cacheTag}`,
2040
- duration: elapsed,
2041
- data: { method: req.method, path: p, status, cache: cacheHdr || undefined },
2042
- });
2043
- }
2044
- }
1440
+ if (shouldRecordRuntimeRequest(p)) {
1441
+ const elapsed = Date.now() - requestStart;
1442
+ const status = result.value.status;
1443
+ const cacheHdr = result.value.headers.get("X-Mandu-Cache") ?? "";
1444
+ registry.settings.observability?.recordDevRequest({
1445
+ req,
1446
+ path: p,
1447
+ status,
1448
+ duration: elapsed,
1449
+ correlationId,
1450
+ cacheStatus: cacheHdr || undefined,
1451
+ recordRequest: recordRuntimeRequest,
1452
+ });
1453
+ }
1454
+ }
2045
1455
 
2046
1456
  // Phase 18.μ — stamp locale-sensitive caching hints onto the final
2047
1457
  // response. `Vary: Accept-Language, Cookie` ensures upstream caches
@@ -2822,10 +2232,11 @@ async function renderPageSSR(
2822
2232
 
2823
2233
  // Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
2824
2234
  // 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
2825
- const needsIslandWrap =
2826
- route.hydration &&
2827
- route.hydration.strategy !== "none" &&
2828
- settings.bundleManifest;
2235
+ const needsIslandWrap = !!(
2236
+ route.hydration &&
2237
+ route.hydration.strategy !== "none" &&
2238
+ settings.bundleManifest
2239
+ );
2829
2240
 
2830
2241
  if (needsIslandWrap) {
2831
2242
  const bundle = settings.bundleManifest?.bundles[route.id];
@@ -2844,105 +2255,37 @@ async function renderPageSSR(
2844
2255
  app = await wrapWithLayouts(app, route.layoutChain, registry, params, layoutData);
2845
2256
  }
2846
2257
 
2847
- const serverData = loaderData
2848
- ? { [route.id]: { serverData: loaderData } }
2849
- : undefined;
2850
-
2851
- // #186: layout chain + page metadata 병합
2852
- const builtMeta = await buildSSRMetadata(route, params, url, registry);
2853
-
2258
+ // #186: layout chain + page metadata 병합
2259
+ const builtMeta = await buildSSRMetadata(route, params, url, registry);
2260
+
2854
2261
  // Streaming SSR 모드 결정
2855
2262
  const useStreaming = route.streaming !== undefined
2856
2263
  ? route.streaming
2857
2264
  : settings.streaming;
2858
2265
 
2859
- // Issue #198 / Phase 18.ξ — Async server component resolution policy.
2860
- //
2861
- // Non-streaming path (`renderToString`) cannot handle async components,
2862
- // so we MUST pre-resolve the tree up-front.
2863
- //
2864
- // Streaming path (`renderToReadableStream`, React 19) supports async
2865
- // components natively and is designed around progressive flushing. If
2866
- // we pre-resolve here, the caller blocks until every async component
2867
- // settles before the shell can be emitted — defeating the entire point
2868
- // of streaming (`TTFB` regresses from shell-ready to slowest-component).
2869
- // So in streaming mode we hand React the raw async tree. Head tags
2870
- // pushed from async components are captured by `renderToStream`'s
2871
- // `buildHtmlTail` (late-head injection) via `use-head`. The streaming
2872
- // shell-gen's `collectStreamingHeadTags` pre-pass uses `renderToString`
2873
- // internally and will throw on async trees — its try/catch already
2874
- // handles that case and returns an empty string, so the early shell
2875
- // emission is still correct; the late-head script fills in any
2876
- // metadata emitted during the async render.
2877
- if (!useStreaming) {
2878
- app = (await resolveAsyncElement(app)) as React.ReactElement;
2879
- }
2880
-
2881
- if (useStreaming) {
2882
- const streamingResponse = await renderStreamingResponse(app, {
2883
- title: builtMeta.title,
2884
- headTags: builtMeta.headTags,
2885
- isDev: settings.isDev,
2886
- hmrPort: settings.hmrPort,
2887
- routeId: route.id,
2888
- routePattern: route.pattern,
2889
- // Issue #233 — emit data-mandu-layout so SPA nav can detect
2890
- // cross-layout transitions and hard-nav instead of half-swapping.
2891
- layoutChain: route.layoutChain,
2892
- hydration: route.hydration,
2893
- bundleManifest: settings.bundleManifest,
2894
- criticalData: loaderData as Record<string, unknown> | undefined,
2895
- enableClientRouter: true,
2896
- cssPath: settings.cssPath,
2897
- transitions: settings.transitions,
2898
- prefetch: settings.prefetch,
2899
- spa: settings.spa,
2900
- devtools: settings.devtools,
2901
- onShellReady: () => {
2902
- if (settings.isDev) {
2903
- console.log(`[Mandu Streaming] Shell ready: ${route.id}`);
2904
- }
2905
- },
2906
- onMetrics: (metrics) => {
2907
- if (settings.isDev) {
2908
- console.log(`[Mandu Streaming] Metrics for ${route.id}:`, {
2909
- shellReadyTime: `${metrics.shellReadyTime}ms`,
2910
- allReadyTime: `${metrics.allReadyTime}ms`,
2911
- hasError: metrics.hasError,
2912
- });
2913
- }
2914
- },
2915
- });
2916
- return ok(cookies ? cookies.applyToResponse(streamingResponse) : streamingResponse);
2917
- }
2918
-
2919
- // 기존 renderToString 방식
2920
- // Note: hydration 래핑은 위에서 React 엘리먼트 레벨로 이미 처리됨
2921
- // renderToHTML에서 중복 래핑하지 않도록 hydration을 전달하되 strategy를 "none"으로 설정
2922
- // 단, hydration 스크립트(importmap, runtime 등)는 여전히 필요하므로 bundleManifest는 유지
2923
- const ssrResponse = renderSSR(app, {
2924
- title: builtMeta.title,
2925
- headTags: builtMeta.headTags,
2926
- isDev: settings.isDev,
2927
- hmrPort: settings.hmrPort,
2928
- routeId: route.id,
2929
- hydration: route.hydration,
2930
- bundleManifest: settings.bundleManifest,
2931
- serverData,
2932
- enableClientRouter: true,
2933
- routePattern: route.pattern,
2934
- cssPath: settings.cssPath,
2935
- islandPreWrapped: !!needsIslandWrap,
2936
- transitions: settings.transitions,
2937
- prefetch: settings.prefetch,
2938
- spa: settings.spa,
2939
- devtools: settings.devtools,
2940
- // Issue #233 — SPA nav uses this to detect cross-layout transitions
2941
- // and fall back to a hard navigation.
2942
- layoutChain: route.layoutChain,
2943
- });
2944
- return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
2945
- } catch (error) {
2266
+ const pageResponse = await renderPageResponse({
2267
+ app,
2268
+ useStreaming,
2269
+ title: builtMeta.title,
2270
+ headTags: builtMeta.headTags,
2271
+ isDev: settings.isDev,
2272
+ hmrPort: settings.hmrPort,
2273
+ routeId: route.id,
2274
+ routePattern: route.pattern,
2275
+ layoutChain: route.layoutChain,
2276
+ hydration: route.hydration,
2277
+ bundleManifest: settings.bundleManifest,
2278
+ loaderData,
2279
+ cssPath: settings.cssPath,
2280
+ islandPreWrapped: needsIslandWrap,
2281
+ transitions: settings.transitions,
2282
+ prefetch: settings.prefetch,
2283
+ spa: settings.spa,
2284
+ devtools: settings.devtools,
2285
+ cookies,
2286
+ });
2287
+ return ok(pageResponse);
2288
+ } catch (error) {
2946
2289
  const renderError = error instanceof Error ? error : new Error(String(error));
2947
2290
 
2948
2291
  // Route-level ErrorBoundary: errorModule이 있으면 해당 컴포넌트로 에러 렌더링
@@ -4026,53 +3369,14 @@ async function handleRequestInternal(
4026
3369
  return ok(await handleInternalCacheControlRequest(req, settings));
4027
3370
  }
4028
3371
 
4029
- // 1.7. Internal observability EventBus stream + recent snapshot
4030
- if (pathname === INTERNAL_EVENTS_ENDPOINT) {
4031
- return ok(handleEventsStreamRequest(req));
4032
- }
4033
- if (pathname === `${INTERNAL_EVENTS_ENDPOINT}/recent`) {
4034
- return ok(handleEventsRecentRequest(req));
4035
- }
4036
-
4037
- // Phase 17 — heap snapshot + Prometheus metrics endpoints.
4038
- //
4039
- // Gating: dev mode exposes by default so the DX is zero-friction. Prod
4040
- // requires either `MANDU_DEBUG_HEAP=1` or explicit `observability.heapEndpoint:
4041
- // true` in `ServerOptions`. Operators can opt-out of even the dev exposure by
4042
- // passing `observability.heapEndpoint: false` (useful in tests that count
4043
- // listeners / assert route shape).
4044
- //
4045
- // Missing endpoints return 404 via the normal route-not-found path —
4046
- // scrapers can't distinguish "disabled" from "never existed". See
4047
- // `docs/ops/metrics.md` for the operator-facing guide.
4048
- if (pathname === HEAP_ENDPOINT) {
4049
- if (isObservabilityExposed(settings.isDev, settings.heapEndpoint)) {
4050
- // Phase 18.ψ — augment the Phase 17 payload with user-perf data.
4051
- // We append (never restructure) the `perf` key so consumers that
4052
- // rely on `.process`, `.caches`, `.bun` continue to parse. Keeping
4053
- // the composition here (not in metrics.ts) avoids a metrics→perf
4054
- // module dep — metrics stays a pure exposition layer.
4055
- const base = collectHeapSnapshot();
4056
- const perf = collectPerfSnapshot();
4057
- const body = { ...base, perf };
4058
- return ok(
4059
- new Response(JSON.stringify(body, null, 2), {
4060
- status: 200,
4061
- headers: {
4062
- "Content-Type": "application/json; charset=utf-8",
4063
- "Cache-Control": "no-store",
4064
- },
4065
- }),
4066
- );
4067
- }
4068
- }
4069
- if (pathname === METRICS_ENDPOINT) {
4070
- if (isObservabilityExposed(settings.isDev, settings.metricsEndpoint)) {
4071
- return ok(buildMetricsResponse());
4072
- }
4073
- }
4074
-
4075
- // Production OpenAPI endpoint — `/__mandu/openapi.json` + `.yaml`.
3372
+ // 1.7. Internal observability endpoints: EventBus stream/recent, heap,
3373
+ // Prometheus metrics, and user perf snapshot composition.
3374
+ const observabilityResponse = settings.observability?.handleEndpoint(req, pathname);
3375
+ if (observabilityResponse) {
3376
+ return ok(observabilityResponse);
3377
+ }
3378
+
3379
+ // Production OpenAPI endpoint — `/__mandu/openapi.json` + `.yaml`.
4076
3380
  // Gated behind `ManduConfig.openapi.enabled` (or MANDU_OPENAPI_ENABLED=1).
4077
3381
  // Every response carries an ETag so CDNs / browsers revalidate cheaply
4078
3382
  // after a deploy. Falls through to route dispatch if disabled OR the
@@ -4092,45 +3396,35 @@ async function handleRequestInternal(
4092
3396
  if (openapiResponse) return ok(openapiResponse);
4093
3397
  }
4094
3398
 
4095
- // 2. Kitchen dev dashboard (dev mode only)
4096
- if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
4097
- const kitchenResponse = await registry.kitchen.handle(req, pathname);
4098
- if (kitchenResponse) return ok(kitchenResponse);
4099
- }
4100
-
4101
- // ─── Phase 18.ε — canonical request-level middleware chain ───────────────
4102
- // Runs BEFORE route dispatch, AFTER infrastructure fast-paths (static
4103
- // files, CORS preflight, internal endpoints, γ's prerendered pass-through,
4104
- // Kitchen). Each composed layer can short-circuit with its own Response
4105
- // or wrap the downstream Response after `next()` returns. Zero overhead
4106
- // when no middleware is configured (settings.middlewareChain === undefined).
4107
- //
4108
- // Errors inside middleware propagate to the outer `handleRequest` catch,
4109
- // which converts them to 5xx via `errorToResponse`. Middleware authors
4110
- // don't need their own top-level try/catch.
4111
- //
4112
- // Re-entry: the chain's `finalHandler` recurses into this same function
4113
- // with `skipMiddleware=true` so the chain is not executed twice. The
4114
- // `next(rewrittenReq)` rewrite pattern flows through transparently
4115
- // `finalReq` is whatever the innermost middleware asked us to dispatch.
4116
- //
4117
- // NOTE: intentionally contained. Does NOT alter route dispatch semantics.
4118
- // Coexists with α's 500-path, β's dispatch, γ's prerendered check,
4119
- // δ's island section. See `docs/architect/middleware-composition.md`.
4120
- if (!skipMiddleware && settings.middlewareChain) {
4121
- const composed = settings.middlewareChain;
4122
- const dispatchRoute = async (finalReq: Request): Promise<Response> => {
4123
- const result = await handleRequestInternal(finalReq, router, registry, true);
4124
- if (result.ok) return result.value;
4125
- // Surface error-path responses to the chain so logging / metrics
4126
- // layers see the final status. The outer `handleRequest` still owns
4127
- // dev-mode Cache-Control stamping + eventBus emission.
4128
- return errorToResponse(result.error, settings.isDev);
4129
- };
4130
- const composedResponse = await composed(req, dispatchRoute);
4131
- return ok(composedResponse);
4132
- }
4133
- // ─── End Phase 18.ε ──────────────────────────────────────────────────────
3399
+ // 2. Runtime devtools / Kitchen dashboard (dev mode only)
3400
+ if (registry.devtoolsAdapter) {
3401
+ const devtoolsResponse = await registry.devtoolsAdapter.handleRequest(req, pathname);
3402
+ if (devtoolsResponse) return ok(devtoolsResponse);
3403
+ }
3404
+
3405
+ // ─── Phase 18.ε — canonical request-level middleware chain ───────────────
3406
+ // Runs BEFORE route dispatch, AFTER infrastructure fast-paths (static
3407
+ // files, CORS preflight, internal endpoints, γ's prerendered pass-through,
3408
+ // Kitchen). Each composed layer can short-circuit with its own Response
3409
+ // or wrap the downstream Response after `next()` returns. Zero overhead
3410
+ // when no middleware is configured (settings.middlewareChain === undefined).
3411
+ const middlewareResponse = await runRequestMiddleware({
3412
+ req,
3413
+ middlewareChain: settings.middlewareChain,
3414
+ skipMiddleware,
3415
+ finalHandler: async (finalReq) => {
3416
+ const result = await handleRequestInternal(finalReq, router, registry, true);
3417
+ if (result.ok) return result.value;
3418
+ // Surface error-path responses to the chain so logging / metrics
3419
+ // layers see the final status. The outer `handleRequest` still owns
3420
+ // dev-mode Cache-Control stamping + observability lifecycle emission.
3421
+ return errorToResponse(result.error, settings.isDev);
3422
+ },
3423
+ });
3424
+ if (middlewareResponse) {
3425
+ return ok(middlewareResponse);
3426
+ }
3427
+ // ─── End Phase 18.ε ──────────────────────────────────────────────────────
4134
3428
 
4135
3429
  // ─── Phase 18.κ — typed RPC dispatch ──────────────────────────────────────
4136
3430
  // Runs AFTER γ's prerendered pass-through (handled earlier at step 0.5),
@@ -4452,9 +3746,9 @@ export function formatServerAddresses(
4452
3746
  return { primary: `http://${hosts[0]}:${port}`, additional: [] };
4453
3747
  }
4454
3748
 
4455
- export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
4456
- const {
4457
- port = 3000,
3749
+ export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
3750
+ const {
3751
+ port = 3333,
4458
3752
  // Default to `"::"` (IPv6 wildcard, dual-stack). Bun leaves IPV6_V6ONLY
4459
3753
  // off, so this single socket accepts both IPv4 (as IPv4-mapped IPv6)
4460
3754
  // and IPv6 clients — covering `127.0.0.1`, `[::1]`, and LAN addresses
@@ -4536,10 +3830,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4536
3830
  // are NOT consulted for the middleware chain itself.
4537
3831
  void pluginsOption;
4538
3832
  void configHooksOption;
4539
- const middlewareChain: ComposedHandler | undefined =
4540
- middlewareOption && middlewareOption.length > 0
4541
- ? composeMiddleware(...middlewareOption)
4542
- : undefined;
3833
+ const middlewareChain: ComposedHandler | undefined =
3834
+ buildRequestMiddlewareChain(middlewareOption);
4543
3835
 
4544
3836
  // Phase 18 — normalize prerender pass-through settings. `undefined`
4545
3837
  // defaults to enabled (Next.js parity); explicit `false` opts out.
@@ -4580,17 +3872,12 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4580
3872
  console.warn(" cors: { origin: ['https://yourdomain.com'] }");
4581
3873
  }
4582
3874
 
4583
- // Phase 18.θ — build a tracer once at boot. Honours
4584
- // `observability.tracing` in options AND `MANDU_OTEL_ENDPOINT` env var.
4585
- // When both are absent, `createTracerFromConfig({})` returns a disabled
4586
- // tracer (no allocations, no per-request overhead).
4587
- const tracerInstance: Tracer = createTracerFromConfig(
4588
- observabilityOption?.tracing
4589
- );
4590
- // Install as process-global so `@mandujs/core/observability`
4591
- // `getTracer()` returns the same instance user code sees through
4592
- // `ctx.startSpan(...)`.
4593
- setTracer(tracerInstance);
3875
+ const observabilityLifecycle = createRuntimeObservabilityLifecycle({
3876
+ isDev,
3877
+ heapEndpoint: observabilityOption?.heapEndpoint,
3878
+ metricsEndpoint: observabilityOption?.metricsEndpoint,
3879
+ tracing: observabilityOption?.tracing,
3880
+ });
4594
3881
 
4595
3882
  // Registry settings 저장 (초기값)
4596
3883
  registry.settings = {
@@ -4608,9 +3895,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4608
3895
  prefetch,
4609
3896
  spa,
4610
3897
  devtools,
4611
- heapEndpoint: observabilityOption?.heapEndpoint,
4612
- metricsEndpoint: observabilityOption?.metricsEndpoint,
4613
- tracer: tracerInstance.enabled ? tracerInstance : undefined,
3898
+ observability: observabilityLifecycle,
4614
3899
  // Production OpenAPI endpoint — default OFF so an internet-facing
4615
3900
  // deployment does not leak its API surface without explicit opt-in.
4616
3901
  // `MANDU_OPENAPI_ENABLED=1` in the environment forces-on without a
@@ -4682,12 +3967,16 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4682
3967
  }
4683
3968
  // ─── End Phase 18.ζ ────────────────────────────────────────────────────
4684
3969
 
4685
- // Kitchen dev dashboard (dev mode only)
4686
- if (isDev) {
4687
- const kitchen = new KitchenHandler({ rootDir, manifest, guardConfig });
4688
- void kitchen.start();
4689
- registry.kitchen = kitchen;
4690
- }
3970
+ // Runtime devtools / Kitchen dashboard (dev mode only)
3971
+ const devtoolsAdapter = createRuntimeDevtoolsAdapter({
3972
+ isDev,
3973
+ rootDir,
3974
+ manifest,
3975
+ guardConfig,
3976
+ });
3977
+ devtoolsAdapter.start();
3978
+ registry.devtoolsAdapter = devtoolsAdapter;
3979
+ registry.kitchen = devtoolsAdapter.kitchen;
4691
3980
 
4692
3981
  const router = new Router(manifest.routes);
4693
3982
 
@@ -4736,20 +4025,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4736
4025
  },
4737
4026
  } : undefined;
4738
4027
 
4739
- // Phase 17 bump the Prometheus request counter once per Response we
4740
- // actually produce. WebSocket upgrades (return `undefined`) are
4741
- // deliberately skipped so the counter only reflects plain HTTP traffic.
4742
- // Errors from `recordHttpRequest` are impossible to surface here — the
4743
- // Map update is synchronous and self-contained — but we still try/catch
4744
- // as defence-in-depth.
4745
- const bumpCounter = (req: Request, res: Response | undefined): void => {
4746
- if (!res) return;
4747
- try {
4748
- recordHttpRequest(req.method, res.status);
4749
- } catch {
4750
- // Never let an observability hiccup break a request.
4751
- }
4752
- };
4028
+ const bumpCounter = (req: Request, res: Response | undefined): void => {
4029
+ registry.settings.observability?.recordHttpResponse(req, res);
4030
+ };
4753
4031
 
4754
4032
  // fetch handler: WS upgrade 감지 추가
4755
4033
  //
@@ -4849,9 +4127,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4849
4127
  if (streaming) {
4850
4128
  console.log(`🌊 Streaming SSR enabled`);
4851
4129
  }
4852
- if (registry.kitchen) {
4853
- console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
4854
- }
4130
+ if (registry.devtoolsAdapter?.dashboardPath) {
4131
+ console.log(`🍳 Kitchen dashboard at ${addresses.primary}${registry.devtoolsAdapter.dashboardPath}`);
4132
+ }
4855
4133
  } else {
4856
4134
  console.log(`🥟 Mandu server listening at ${addresses.primary}`);
4857
4135
  if (addresses.additional.length > 0) {
@@ -4901,13 +4179,19 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
4901
4179
  }
4902
4180
  }
4903
4181
 
4904
- return {
4905
- server,
4906
- router,
4907
- registry,
4908
- stop: () => {
4909
- registry.kitchen?.stop();
4910
- // Fire-and-forget the async scheduler drain so `stop()` stays
4182
+ return {
4183
+ server,
4184
+ router,
4185
+ registry,
4186
+ updateManifest: (nextManifest: RoutesManifest) => {
4187
+ router.setRoutes(nextManifest.routes);
4188
+ registry.devtoolsAdapter?.updateManifest(nextManifest);
4189
+ },
4190
+ stop: () => {
4191
+ registry.devtoolsAdapter?.stop();
4192
+ registry.devtoolsAdapter = null;
4193
+ registry.kitchen = null;
4194
+ // Fire-and-forget the async scheduler drain so `stop()` stays
4911
4195
  // synchronous for backwards compatibility with existing consumers.
4912
4196
  // Tests that need to await drain can reach for `registration.stop()`
4913
4197
  // directly; `server.stop()` triggers shutdown but doesn't block on