@mandujs/core 0.54.1 → 0.54.3
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 +4 -2
- package/scripts/postinstall-lock.ts +153 -0
- package/src/a11y/run-audit.ts +15 -15
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/cold-start.test.ts +35 -7
- package/src/bundler/analyzer.ts +15 -7
- package/src/bundler/build.test.ts +13 -6
- package/src/bundler/build.ts +429 -182
- package/src/bundler/manifest-schema.ts +21 -14
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
- package/src/bundler/plugins/block-generated-imports.ts +13 -12
- package/src/bundler/types.ts +31 -14
- package/src/client/island.ts +79 -29
- package/src/config/validate.ts +1 -1
- package/src/deploy/inference/context.ts +82 -15
- package/src/filling/context.ts +17 -4
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -7
- package/src/guard/fs-routes-policy.ts +51 -0
- package/src/guard/index.ts +11 -6
- package/src/kitchen/api/file-api.ts +11 -8
- package/src/resource/__tests__/schema.test.ts +14 -9
- package/src/resource/generators/slot.ts +72 -71
- package/src/resource/schema.ts +21 -13
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
- package/src/runtime/__tests__/page-render-response.test.ts +103 -0
- package/src/runtime/__tests__/request-middleware.test.ts +70 -0
- package/src/runtime/devtools-adapter.ts +68 -0
- package/src/runtime/escape.ts +34 -6
- package/src/runtime/observability-lifecycle.ts +290 -0
- package/src/runtime/page-render-response.ts +106 -0
- package/src/runtime/request-middleware.ts +31 -0
- package/src/runtime/server.ts +228 -944
- package/src/runtime/ssr.ts +59 -37
- package/src/runtime/static-files.ts +289 -0
- package/src/runtime/streaming-ssr.ts +22 -13
package/src/runtime/server.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
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,
|
|
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 {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
type
|
|
58
|
-
type
|
|
59
|
-
} from "
|
|
60
|
-
import {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
type
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 {
|
|
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
|
-
// ==========
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
912
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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 (
|
|
1989
|
-
const elapsed = Date.now() - requestStart;
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
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 (
|
|
2027
|
-
const elapsed = Date.now() - requestStart;
|
|
2028
|
-
const status = result.value.status;
|
|
2029
|
-
const cacheHdr = result.value.headers.get("X-Mandu-Cache") ?? "";
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
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
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
}
|
|
2880
|
-
|
|
2881
|
-
|
|
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
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
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
|
|
4096
|
-
if (
|
|
4097
|
-
const
|
|
4098
|
-
if (
|
|
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
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
if (
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
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
|
-
|
|
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
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
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
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
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.
|
|
4853
|
-
console.log(`🍳 Kitchen dashboard at ${addresses.primary}
|
|
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
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
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
|