@mandujs/core 0.24.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/bundler/build.test.ts +18 -8
- package/src/bundler/build.ts +26 -3
- package/src/bundler/dev.ts +18 -4
- package/src/bundler/safe-build.test.ts +22 -4
- package/src/client/globals.ts +61 -44
- package/src/client/router.ts +93 -15
- package/src/client/use-fetch.ts +243 -239
- package/src/config/mandu.ts +64 -0
- package/src/config/validate.ts +35 -0
- package/src/content/collection.ts +506 -0
- package/src/content/frontmatter.ts +189 -0
- package/src/content/generate-types.ts +168 -0
- package/src/content/index.ts +206 -168
- package/src/content/llms-txt.ts +196 -0
- package/src/content/prebuild.test.ts +249 -0
- package/src/content/prebuild.ts +400 -0
- package/src/content/schema.ts +20 -0
- package/src/content/sidebar.ts +212 -0
- package/src/content/slug.ts +110 -0
- package/src/guard/check.ts +5 -2
- package/src/observability/index.ts +37 -18
- package/src/observability/metrics.ts +334 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/registry.ts +171 -0
- package/src/runtime/server.ts +153 -6
- package/src/runtime/ssr.ts +118 -1
- package/src/runtime/streaming-ssr.ts +11 -1
- package/src/utils/__tests__/lru-cache.test.ts +186 -0
- package/src/utils/lru-cache.ts +172 -75
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime manifest registry.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the **official** accessor for generated content at
|
|
5
|
+
* runtime. User code must NEVER `import` anything under `.mandu/generated/`
|
|
6
|
+
* or any path containing `/generated/` — the guard rule
|
|
7
|
+
* `INVALID_GENERATED_IMPORT` catches that at build time.
|
|
8
|
+
*
|
|
9
|
+
* Why the indirection?
|
|
10
|
+
*
|
|
11
|
+
* - **Hot reload** — in dev, generated modules are rebuilt and re-imported.
|
|
12
|
+
* A direct ESM import caches the first version; the registry re-reads the
|
|
13
|
+
* current manifest on every access.
|
|
14
|
+
* - **Determinism** — compiled binaries (`bun build --compile`) embed a
|
|
15
|
+
* fixed manifest. Direct imports would bypass the embedded copy and fail.
|
|
16
|
+
* - **ESM cache invalidation** — see #184. Transitive generated modules get
|
|
17
|
+
* stuck on stale copies when hot-reload fires; the registry's `getManifest`
|
|
18
|
+
* is the single choke point that the bundled importer invalidates cleanly.
|
|
19
|
+
*
|
|
20
|
+
* @see https://mandujs.com/docs/architect/generated-access
|
|
21
|
+
*/
|
|
22
|
+
import type { RoutesManifest, RouteSpec } from "../spec/schema";
|
|
23
|
+
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
25
|
+
// Generated artifact map
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map of generated artifacts keyed by well-known names.
|
|
30
|
+
*
|
|
31
|
+
* Extend this interface (via module augmentation) in consumers that emit
|
|
32
|
+
* their own generated artifacts — collections, resources, db schemas, etc.
|
|
33
|
+
*
|
|
34
|
+
* @example Module augmentation
|
|
35
|
+
* ```ts
|
|
36
|
+
* declare module "@mandujs/core/runtime" {
|
|
37
|
+
* interface GeneratedRegistry {
|
|
38
|
+
* collections: Record<string, CollectionIndex>;
|
|
39
|
+
* }
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export interface GeneratedRegistry {
|
|
44
|
+
/** Route manifest — the single source of truth for page/API routes. */
|
|
45
|
+
routes: RoutesManifest;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Union of well-known generated artifact keys. */
|
|
49
|
+
export type GeneratedKey = keyof GeneratedRegistry;
|
|
50
|
+
|
|
51
|
+
/** Typed accessor — narrows the return shape from the key. */
|
|
52
|
+
export type GeneratedShape<K extends GeneratedKey> = GeneratedRegistry[K];
|
|
53
|
+
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
55
|
+
// Global registry state
|
|
56
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The live manifest, populated by `registerManifest()` (typically driven by
|
|
60
|
+
* `registerManifestHandlers()` from `@mandujs/cli`). Kept on `globalThis`
|
|
61
|
+
* so reloading the core module in dev does not lose registration.
|
|
62
|
+
*/
|
|
63
|
+
declare global {
|
|
64
|
+
// eslint-disable-next-line no-var
|
|
65
|
+
var __MANDU_MANIFEST__: Partial<GeneratedRegistry> | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ensureGlobalSlot(): Partial<GeneratedRegistry> {
|
|
69
|
+
if (!globalThis.__MANDU_MANIFEST__) {
|
|
70
|
+
globalThis.__MANDU_MANIFEST__ = {};
|
|
71
|
+
}
|
|
72
|
+
return globalThis.__MANDU_MANIFEST__;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// Public API
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Register a generated artifact under a well-known key.
|
|
81
|
+
*
|
|
82
|
+
* Called by the framework (typically from `registerManifestHandlers()` in
|
|
83
|
+
* `@mandujs/cli`) during server boot. User code normally does not call this
|
|
84
|
+
* directly — the only exception is tests that want to seed a manifest.
|
|
85
|
+
*
|
|
86
|
+
* @example Test setup
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { registerManifest, clearGeneratedRegistry } from "@mandujs/core/runtime";
|
|
89
|
+
*
|
|
90
|
+
* beforeEach(() => clearGeneratedRegistry());
|
|
91
|
+
* registerManifest("routes", { version: 1, routes: [] });
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function registerManifest<K extends GeneratedKey>(
|
|
95
|
+
key: K,
|
|
96
|
+
value: GeneratedShape<K>,
|
|
97
|
+
): void {
|
|
98
|
+
const slot = ensureGlobalSlot();
|
|
99
|
+
slot[key] = value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read a generated artifact by key. Throws a helpful error if the manifest
|
|
104
|
+
* has not been registered yet — this almost always means the server boot
|
|
105
|
+
* skipped `registerManifestHandlers()` or the test forgot to seed fixtures.
|
|
106
|
+
*
|
|
107
|
+
* @example Reading the route manifest
|
|
108
|
+
* ```ts
|
|
109
|
+
* import { getGenerated } from "@mandujs/core/runtime";
|
|
110
|
+
*
|
|
111
|
+
* const manifest = getGenerated("routes");
|
|
112
|
+
* for (const route of manifest.routes) {
|
|
113
|
+
* console.log(route.id, route.pattern);
|
|
114
|
+
* }
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* @throws {Error} when the key has not been registered
|
|
118
|
+
*/
|
|
119
|
+
export function getGenerated<K extends GeneratedKey>(key: K): GeneratedShape<K> {
|
|
120
|
+
const slot = globalThis.__MANDU_MANIFEST__;
|
|
121
|
+
if (!slot || !(key in slot) || slot[key] === undefined) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`[Mandu] Generated artifact "${String(key)}" not registered. ` +
|
|
124
|
+
`Call registerManifestHandlers() during server boot, or seed the ` +
|
|
125
|
+
`manifest with registerManifest("${String(key)}", …) in tests. ` +
|
|
126
|
+
`See https://mandujs.com/docs/architect/generated-access`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return slot[key] as GeneratedShape<K>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return the registered artifact, or `undefined` if absent. Prefer
|
|
134
|
+
* `getGenerated()` for the common case — this variant exists for hot paths
|
|
135
|
+
* where absence is not an error (e.g., optional collections).
|
|
136
|
+
*/
|
|
137
|
+
export function tryGetGenerated<K extends GeneratedKey>(
|
|
138
|
+
key: K,
|
|
139
|
+
): GeneratedShape<K> | undefined {
|
|
140
|
+
const slot = globalThis.__MANDU_MANIFEST__;
|
|
141
|
+
return slot?.[key] as GeneratedShape<K> | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Return the full routes manifest. Thin wrapper around `getGenerated("routes")`
|
|
146
|
+
* kept for call-site readability.
|
|
147
|
+
*
|
|
148
|
+
* @throws {Error} when no manifest has been registered yet
|
|
149
|
+
*/
|
|
150
|
+
export function getManifest(): RoutesManifest {
|
|
151
|
+
return getGenerated("routes");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Find a single route by its stable ID. Returns `undefined` if no match.
|
|
156
|
+
* Use this instead of `manifest.routes.find(…)` at call sites that already
|
|
157
|
+
* want the readability of a named helper.
|
|
158
|
+
*/
|
|
159
|
+
export function getRouteById(id: string): RouteSpec | undefined {
|
|
160
|
+
const manifest = tryGetGenerated("routes");
|
|
161
|
+
if (!manifest) return undefined;
|
|
162
|
+
return manifest.routes.find((route) => route.id === id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Clear all registered artifacts. Test-only — production code should never
|
|
167
|
+
* call this.
|
|
168
|
+
*/
|
|
169
|
+
export function clearGeneratedRegistry(): void {
|
|
170
|
+
globalThis.__MANDU_MANIFEST__ = {};
|
|
171
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { BundleManifest } from "../bundler/types";
|
|
|
4
4
|
import type { ManduFilling, RenderMode } from "../filling/filling";
|
|
5
5
|
import { ManduContext, CookieManager } from "../filling/context";
|
|
6
6
|
import { Router } from "./router";
|
|
7
|
-
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
7
|
+
import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
|
|
8
8
|
import {
|
|
9
9
|
resolveMetadata,
|
|
10
10
|
renderMetadata,
|
|
@@ -49,6 +49,14 @@ import {
|
|
|
49
49
|
import { validateImportPath } from "./security";
|
|
50
50
|
import { KITCHEN_PREFIX, KitchenHandler, recordRequest } from "../kitchen/kitchen-handler";
|
|
51
51
|
import { eventBus } from "../observability/event-bus";
|
|
52
|
+
import {
|
|
53
|
+
HEAP_ENDPOINT,
|
|
54
|
+
METRICS_ENDPOINT,
|
|
55
|
+
buildHeapResponse,
|
|
56
|
+
buildMetricsResponse,
|
|
57
|
+
isObservabilityExposed,
|
|
58
|
+
recordHttpRequest,
|
|
59
|
+
} from "../observability/metrics";
|
|
52
60
|
import {
|
|
53
61
|
type MiddlewareFn,
|
|
54
62
|
type MiddlewareConfig,
|
|
@@ -376,6 +384,21 @@ export interface ServerOptions {
|
|
|
376
384
|
* one island. Pure-SSR pages download zero devtools.
|
|
377
385
|
*/
|
|
378
386
|
devtools?: boolean;
|
|
387
|
+
/**
|
|
388
|
+
* Phase 17 — observability endpoints.
|
|
389
|
+
* - `heapEndpoint` → `/_mandu/heap` JSON dump of process.memoryUsage()
|
|
390
|
+
* + registered cache sizes. In dev this is on by
|
|
391
|
+
* default; in prod it requires `MANDU_DEBUG_HEAP=1`
|
|
392
|
+
* OR this flag set to `true`.
|
|
393
|
+
* - `metricsEndpoint` → `/_mandu/metrics` Prometheus text exposition.
|
|
394
|
+
* Same gating as `heapEndpoint`.
|
|
395
|
+
* Passing `false` for either in dev force-disables it (useful for
|
|
396
|
+
* isolating test environments that count listeners).
|
|
397
|
+
*/
|
|
398
|
+
observability?: {
|
|
399
|
+
heapEndpoint?: boolean;
|
|
400
|
+
metricsEndpoint?: boolean;
|
|
401
|
+
};
|
|
379
402
|
}
|
|
380
403
|
|
|
381
404
|
export interface ManduServer {
|
|
@@ -500,6 +523,16 @@ export interface ServerRegistrySettings {
|
|
|
500
523
|
* dev-mode `_devtools.js` `<script>` injection on / off. No-op in prod.
|
|
501
524
|
*/
|
|
502
525
|
devtools?: boolean;
|
|
526
|
+
/**
|
|
527
|
+
* Phase 17 — `/_mandu/heap` JSON exposure. `undefined` uses the
|
|
528
|
+
* default for the current mode (dev → on, prod → MANDU_DEBUG_HEAP).
|
|
529
|
+
*/
|
|
530
|
+
heapEndpoint?: boolean;
|
|
531
|
+
/**
|
|
532
|
+
* Phase 17 — `/_mandu/metrics` Prometheus exposure. Same defaulting
|
|
533
|
+
* as `heapEndpoint`.
|
|
534
|
+
*/
|
|
535
|
+
metricsEndpoint?: boolean;
|
|
503
536
|
}
|
|
504
537
|
|
|
505
538
|
export class ServerRegistry {
|
|
@@ -1514,7 +1547,27 @@ async function loadPageData(
|
|
|
1514
1547
|
const exportedObj = exported as Record<string, unknown> | null;
|
|
1515
1548
|
const component = typeof exported === "function"
|
|
1516
1549
|
? (exported as RouteComponent)
|
|
1517
|
-
: (exportedObj?.component ??
|
|
1550
|
+
: (exportedObj?.component ?? undefined);
|
|
1551
|
+
// DX-1: pageLoader 경로에서 malformed default export를 silent 404 로
|
|
1552
|
+
//보내지 않고 명시적 에러로 즉시 실패시킨다. 이전에는
|
|
1553
|
+
// `export default "hello"` / `export default undefined` / named-only
|
|
1554
|
+
// 같은 실수가 registerRouteComponent(undefined) → defaultCreateApp
|
|
1555
|
+
// 에서 404로 렌더되어 사용자가 원인을 추적하기 어려웠음. 여기서 throw
|
|
1556
|
+
// 하면 try/catch(아래) 의 createPageLoadErrorResponse 가 500 응답과
|
|
1557
|
+
// 함께 route.id + pattern 을 출력해주므로 개발자가 바로 인지한다.
|
|
1558
|
+
if (typeof component !== "function") {
|
|
1559
|
+
const defaultSummary =
|
|
1560
|
+
exported === undefined
|
|
1561
|
+
? "undefined (missing `export default`)"
|
|
1562
|
+
: exported === null
|
|
1563
|
+
? "null"
|
|
1564
|
+
: `type ${typeof exported}`;
|
|
1565
|
+
throw new Error(
|
|
1566
|
+
`[Mandu] Page module for '${route.id}' (pattern ${route.pattern}) has an invalid default export: ${defaultSummary}. ` +
|
|
1567
|
+
"Expected `export default function Page() {…}` or `export default { component, filling }`. " +
|
|
1568
|
+
"If the page file is empty or only has named exports, add a default-exported React component."
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1518
1571
|
registry.registerRouteComponent(route.id, component as RouteComponent);
|
|
1519
1572
|
|
|
1520
1573
|
// #186: page 모듈에서 metadata / generateMetadata export 캐싱
|
|
@@ -1920,6 +1973,16 @@ async function renderPageSSR(
|
|
|
1920
1973
|
? route.streaming
|
|
1921
1974
|
: settings.streaming;
|
|
1922
1975
|
|
|
1976
|
+
// Issue #198 — Pre-resolve async server components before handing off
|
|
1977
|
+
// to React's SSR engines. `renderToString` (non-streaming path) does
|
|
1978
|
+
// not support async components; `renderToReadableStream` does, but
|
|
1979
|
+
// the shell-gen step in streaming-ssr also falls through
|
|
1980
|
+
// `collectStreamingHeadTags` → `renderToString`. Resolving up-front
|
|
1981
|
+
// gives consistent, synchronous trees to both paths and keeps the
|
|
1982
|
+
// user-visible contract (`export default async function Page() {...}`
|
|
1983
|
+
// and `export default async function Layout() {...}`) working end to end.
|
|
1984
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
1985
|
+
|
|
1923
1986
|
if (useStreaming) {
|
|
1924
1987
|
const streamingResponse = await renderStreamingResponse(app, {
|
|
1925
1988
|
title: builtMeta.title,
|
|
@@ -2005,6 +2068,10 @@ async function renderPageSSR(
|
|
|
2005
2068
|
errorApp = await wrapWithLayouts(errorApp, route.layoutChain, registry, params, layoutData);
|
|
2006
2069
|
}
|
|
2007
2070
|
|
|
2071
|
+
// Issue #198 — resolve async layouts wrapping the error component
|
|
2072
|
+
// so `async function Layout()` still renders on the 500 surface.
|
|
2073
|
+
errorApp = (await resolveAsyncElement(errorApp)) as React.ReactElement;
|
|
2074
|
+
|
|
2008
2075
|
const errorHtml = renderSSR(errorApp, {
|
|
2009
2076
|
// 에러 상태에서는 resolveMetadata 결과를 신뢰할 수 없을 수 있으므로 리터럴 사용
|
|
2010
2077
|
title: "Mandu App — Error",
|
|
@@ -2107,6 +2174,11 @@ async function renderNotFoundPage(
|
|
|
2107
2174
|
app = await wrapWithLayouts(app, route.layoutChain, registry, params, layoutData);
|
|
2108
2175
|
}
|
|
2109
2176
|
|
|
2177
|
+
// Issue #198 — users may author `not-found.tsx` as an async server
|
|
2178
|
+
// component (e.g. to fetch copy from a CMS). Resolve the async tree
|
|
2179
|
+
// before the sync renderSSR path.
|
|
2180
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
2181
|
+
|
|
2110
2182
|
const html = renderSSR(app, {
|
|
2111
2183
|
title: "Not Found",
|
|
2112
2184
|
isDev: settings.isDev,
|
|
@@ -2149,8 +2221,22 @@ async function handlePageRoute(
|
|
|
2149
2221
|
const cache = settings.cacheStore;
|
|
2150
2222
|
// Only call ensurePageRouteMetadata when a pageHandler exists;
|
|
2151
2223
|
// routes registered via registerPageLoader are handled by loadPageData instead.
|
|
2224
|
+
// DX-1: if the pageHandler returns a malformed registration (component is
|
|
2225
|
+
// not a function), ensurePageRouteMetadata now throws with a descriptive
|
|
2226
|
+
// message. Catch it here so the request becomes a loud 500 instead of
|
|
2227
|
+
// bubbling up as an opaque "Internal Server Error".
|
|
2152
2228
|
if (registry.pageHandlers.has(route.id)) {
|
|
2153
|
-
|
|
2229
|
+
try {
|
|
2230
|
+
await ensurePageRouteMetadata(route.id, registry);
|
|
2231
|
+
} catch (error) {
|
|
2232
|
+
const pageError = createPageLoadErrorResponse(
|
|
2233
|
+
route.id,
|
|
2234
|
+
route.pattern,
|
|
2235
|
+
error instanceof Error ? error : new Error(String(error))
|
|
2236
|
+
);
|
|
2237
|
+
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
2238
|
+
return err(pageError);
|
|
2239
|
+
}
|
|
2154
2240
|
}
|
|
2155
2241
|
const renderMode = getRenderModeForRoute(route.id, registry);
|
|
2156
2242
|
|
|
@@ -2415,6 +2501,19 @@ async function ensurePageRouteMetadata(
|
|
|
2415
2501
|
}
|
|
2416
2502
|
|
|
2417
2503
|
const registration = await handler();
|
|
2504
|
+
// DX-1: pageHandler가 malformed registration을 반환해도 silent 404 대신
|
|
2505
|
+
// 명시적 에러로 실패. handlers.ts 의 auto-promote 블록이 function 기본값을
|
|
2506
|
+
// { component } 로 감싸주지만, 직접 registerPageHandler 를 쓰는 경우나
|
|
2507
|
+
// 사용자 코드가 이상한 값을 반환하는 경우를 방어한다.
|
|
2508
|
+
if (typeof registration?.component !== "function") {
|
|
2509
|
+
const t = registration === null || registration === undefined
|
|
2510
|
+
? String(registration)
|
|
2511
|
+
: typeof registration.component;
|
|
2512
|
+
throw new Error(
|
|
2513
|
+
`[Mandu] Page handler for '${routeId}' returned an invalid registration: component is ${t}. ` +
|
|
2514
|
+
"Expected `{ component: ReactComponent, filling? }` from the page module's default export."
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2418
2517
|
const component = registration.component as RouteComponent;
|
|
2419
2518
|
registry.registerRouteComponent(routeId, component);
|
|
2420
2519
|
|
|
@@ -2505,6 +2604,28 @@ async function handleRequestInternal(
|
|
|
2505
2604
|
return ok(handleEventsRecentRequest(req));
|
|
2506
2605
|
}
|
|
2507
2606
|
|
|
2607
|
+
// Phase 17 — heap snapshot + Prometheus metrics endpoints.
|
|
2608
|
+
//
|
|
2609
|
+
// Gating: dev mode exposes by default so the DX is zero-friction. Prod
|
|
2610
|
+
// requires either `MANDU_DEBUG_HEAP=1` or explicit `observability.heapEndpoint:
|
|
2611
|
+
// true` in `ServerOptions`. Operators can opt-out of even the dev exposure by
|
|
2612
|
+
// passing `observability.heapEndpoint: false` (useful in tests that count
|
|
2613
|
+
// listeners / assert route shape).
|
|
2614
|
+
//
|
|
2615
|
+
// Missing endpoints return 404 via the normal route-not-found path —
|
|
2616
|
+
// scrapers can't distinguish "disabled" from "never existed". See
|
|
2617
|
+
// `docs/ops/metrics.md` for the operator-facing guide.
|
|
2618
|
+
if (pathname === HEAP_ENDPOINT) {
|
|
2619
|
+
if (isObservabilityExposed(settings.isDev, settings.heapEndpoint)) {
|
|
2620
|
+
return ok(buildHeapResponse());
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
if (pathname === METRICS_ENDPOINT) {
|
|
2624
|
+
if (isObservabilityExposed(settings.isDev, settings.metricsEndpoint)) {
|
|
2625
|
+
return ok(buildMetricsResponse());
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2508
2629
|
// 2. Kitchen dev dashboard (dev mode only)
|
|
2509
2630
|
if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
|
|
2510
2631
|
const kitchenResponse = await registry.kitchen.handle(req, pathname);
|
|
@@ -2533,10 +2654,12 @@ async function handleRequestInternal(
|
|
|
2533
2654
|
console.warn(`[Mandu] not-found.tsx loader threw (unmatched URL):`, loaderError);
|
|
2534
2655
|
}
|
|
2535
2656
|
}
|
|
2536
|
-
const
|
|
2657
|
+
const rawApp = React.createElement(registration.component, {
|
|
2537
2658
|
params: {},
|
|
2538
2659
|
loaderData,
|
|
2539
2660
|
});
|
|
2661
|
+
// Issue #198 — pre-resolve in case the not-found component is async.
|
|
2662
|
+
const app = (await resolveAsyncElement(rawApp)) as React.ReactElement;
|
|
2540
2663
|
const html = renderSSR(app, {
|
|
2541
2664
|
title: "Not Found",
|
|
2542
2665
|
isDev: settings.isDev,
|
|
@@ -2698,6 +2821,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2698
2821
|
transitions,
|
|
2699
2822
|
prefetch,
|
|
2700
2823
|
devtools,
|
|
2824
|
+
observability: observabilityOption,
|
|
2701
2825
|
} = options;
|
|
2702
2826
|
|
|
2703
2827
|
// cssPath 처리:
|
|
@@ -2736,6 +2860,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2736
2860
|
transitions,
|
|
2737
2861
|
prefetch,
|
|
2738
2862
|
devtools,
|
|
2863
|
+
heapEndpoint: observabilityOption?.heapEndpoint,
|
|
2864
|
+
metricsEndpoint: observabilityOption?.metricsEndpoint,
|
|
2739
2865
|
};
|
|
2740
2866
|
|
|
2741
2867
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
@@ -2802,6 +2928,21 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2802
2928
|
},
|
|
2803
2929
|
} : undefined;
|
|
2804
2930
|
|
|
2931
|
+
// Phase 17 — bump the Prometheus request counter once per Response we
|
|
2932
|
+
// actually produce. WebSocket upgrades (return `undefined`) are
|
|
2933
|
+
// deliberately skipped so the counter only reflects plain HTTP traffic.
|
|
2934
|
+
// Errors from `recordHttpRequest` are impossible to surface here — the
|
|
2935
|
+
// Map update is synchronous and self-contained — but we still try/catch
|
|
2936
|
+
// as defence-in-depth.
|
|
2937
|
+
const bumpCounter = (req: Request, res: Response | undefined): void => {
|
|
2938
|
+
if (!res) return;
|
|
2939
|
+
try {
|
|
2940
|
+
recordHttpRequest(req.method, res.status);
|
|
2941
|
+
} catch {
|
|
2942
|
+
// Never let an observability hiccup break a request.
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2945
|
+
|
|
2805
2946
|
// fetch handler: WS upgrade 감지 추가
|
|
2806
2947
|
const wrappedFetch = hasWsRoutes
|
|
2807
2948
|
? async (req: Request, bunServer: Server<undefined>): Promise<Response | undefined> => {
|
|
@@ -2816,9 +2957,15 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2816
2957
|
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
2817
2958
|
}
|
|
2818
2959
|
}
|
|
2819
|
-
|
|
2960
|
+
const res = await fetchHandler(req);
|
|
2961
|
+
bumpCounter(req, res);
|
|
2962
|
+
return res;
|
|
2820
2963
|
}
|
|
2821
|
-
: async (req: Request): Promise<Response> =>
|
|
2964
|
+
: async (req: Request): Promise<Response> => {
|
|
2965
|
+
const res = await fetchHandler(req);
|
|
2966
|
+
bumpCounter(req, res);
|
|
2967
|
+
return res;
|
|
2968
|
+
};
|
|
2822
2969
|
|
|
2823
2970
|
const { server, port: actualPort, attempts } = startBunServerWithFallback({
|
|
2824
2971
|
port,
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getRenderToString } from "./react-renderer";
|
|
2
2
|
import { serializeProps } from "../client/serialize";
|
|
3
3
|
import { createRequire } from "module";
|
|
4
|
-
import
|
|
4
|
+
import React, { type ReactElement, type ReactNode } from "react";
|
|
5
5
|
import type { BundleManifest } from "../bundler/types";
|
|
6
6
|
import { isSafeManduUrl } from "../bundler/manifest-schema";
|
|
7
7
|
import type { HydrationConfig, HydrationPriority } from "../spec/schema";
|
|
@@ -104,6 +104,18 @@ export interface SSROptions {
|
|
|
104
104
|
* `ManduConfig.prefetch`.
|
|
105
105
|
*/
|
|
106
106
|
prefetch?: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Issue #193 — control opt-out SPA navigation. When `true` (default)
|
|
109
|
+
* the client-side router intercepts every internal same-origin `<a>`
|
|
110
|
+
* click; when `false` the router reverts to the legacy opt-in
|
|
111
|
+
* behavior (only `<a data-mandu-link>` is intercepted).
|
|
112
|
+
*
|
|
113
|
+
* Wired from `ManduConfig.spa`. Emitted to the client as
|
|
114
|
+
* `window.__MANDU_SPA__ = false` ONLY when explicitly `false` — the
|
|
115
|
+
* default case emits nothing so the typical response payload is
|
|
116
|
+
* unchanged.
|
|
117
|
+
*/
|
|
118
|
+
spa?: boolean;
|
|
107
119
|
/**
|
|
108
120
|
* Issue #191 — control dev-mode injection of the `_devtools.js` bundle
|
|
109
121
|
* (~1.15 MB React dev runtime + Mandu Kitchen panel).
|
|
@@ -432,6 +444,99 @@ export function _testOnly_getAttachedCspNonce(options: SSROptions): string | und
|
|
|
432
444
|
return OPTIONS_TO_NONCE.get(options as object);
|
|
433
445
|
}
|
|
434
446
|
|
|
447
|
+
/**
|
|
448
|
+
* Issue #198 — Pre-resolve async server components before handing the
|
|
449
|
+
* element tree to React's synchronous `renderToString`.
|
|
450
|
+
*
|
|
451
|
+
* React 19 supports async components natively in `renderToReadableStream`
|
|
452
|
+
* but NOT in `renderToString`. When a user writes:
|
|
453
|
+
*
|
|
454
|
+
* export default async function Page() {
|
|
455
|
+
* const data = await fetch(...).then(r => r.json());
|
|
456
|
+
* return <h1>{data.title}</h1>;
|
|
457
|
+
* }
|
|
458
|
+
*
|
|
459
|
+
* `React.createElement(Page)` returns an element whose `type` is an async
|
|
460
|
+
* function. Passing it to `renderToString` yields the opaque error
|
|
461
|
+
* "async/await is not yet supported in Client Components, only Server
|
|
462
|
+
* Components" (or silently renders the Promise as `[object Promise]`
|
|
463
|
+
* on older React builds). This helper walks the element tree, invokes
|
|
464
|
+
* each async component, awaits the resolved React tree, and recursively
|
|
465
|
+
* resolves nested async components — producing a fully-synchronous tree
|
|
466
|
+
* that `renderToString` can handle.
|
|
467
|
+
*
|
|
468
|
+
* Design notes:
|
|
469
|
+
* - Only `typeof type === "function"` elements whose constructor is
|
|
470
|
+
* `AsyncFunction` are invoked. Regular sync function components pass
|
|
471
|
+
* through unchanged so React's normal render lifecycle (hooks,
|
|
472
|
+
* Suspense, etc.) stays intact during `renderToString`.
|
|
473
|
+
* - We recurse into `children` AND invoke async components whose
|
|
474
|
+
* returned tree itself contains more async components — common in
|
|
475
|
+
* `async Layout → async Page` nesting.
|
|
476
|
+
* - Arrays, fragments, portals, and forward-refs are handled by the
|
|
477
|
+
* same recursion (fragments and arrays expose children via props;
|
|
478
|
+
* forwardRef/memo wrappers surface the underlying component via
|
|
479
|
+
* `type.render` / `type.type`, which we do NOT unwrap — those are
|
|
480
|
+
* opaque to us and sync by construction).
|
|
481
|
+
* - If an async component throws, the rejection propagates up — the
|
|
482
|
+
* caller (renderSSR / renderStreamingResponse) wraps it with the
|
|
483
|
+
* existing `createSSRErrorResponse` 500 path. No new error surface.
|
|
484
|
+
*
|
|
485
|
+
* The helper returns a `ReactNode` (not strictly `ReactElement`) because
|
|
486
|
+
* async components may legitimately return primitives, arrays, null, or
|
|
487
|
+
* fragments.
|
|
488
|
+
*/
|
|
489
|
+
export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
|
|
490
|
+
// null | undefined | boolean | string | number — pass through. React
|
|
491
|
+
// treats these as leaf content.
|
|
492
|
+
if (node == null || typeof node !== "object") return node;
|
|
493
|
+
|
|
494
|
+
// Arrays (iterables of children) — resolve each entry in parallel.
|
|
495
|
+
// `Promise.all` is safe because order is preserved and async components
|
|
496
|
+
// are independent within an array.
|
|
497
|
+
if (Array.isArray(node)) {
|
|
498
|
+
return Promise.all(node.map((child) => resolveAsyncElement(child))) as Promise<ReactNode>;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Non-element objects (Promises, iterables, etc.). React will handle
|
|
502
|
+
// Promises itself in streaming SSR, but for the sync path we forbid
|
|
503
|
+
// them — return as-is and let React error loudly.
|
|
504
|
+
if (!React.isValidElement(node)) return node;
|
|
505
|
+
|
|
506
|
+
const element = node as ReactElement;
|
|
507
|
+
const type = element.type;
|
|
508
|
+
const props = element.props as Record<string, unknown> | null | undefined;
|
|
509
|
+
|
|
510
|
+
// Async function component: invoke with props, await, recurse.
|
|
511
|
+
// `type.constructor.name === "AsyncFunction"` is the standard detection
|
|
512
|
+
// used throughout Mandu (see filling/filling.ts, runtime/compose.ts).
|
|
513
|
+
// We intentionally do NOT attempt to resolve generators or
|
|
514
|
+
// AsyncGeneratorFunctions — React has no semantics for those as
|
|
515
|
+
// components.
|
|
516
|
+
if (typeof type === "function" && (type as { constructor?: { name?: string } }).constructor?.name === "AsyncFunction") {
|
|
517
|
+
const resolved = await (type as (p: unknown) => Promise<ReactNode>)(props ?? {});
|
|
518
|
+
// Recurse — the resolved tree may itself contain more async components
|
|
519
|
+
// (e.g. async layout returning async page content).
|
|
520
|
+
return resolveAsyncElement(resolved);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Sync element — recurse into children only. React handles sync
|
|
524
|
+
// function/class components itself during renderToString, so we
|
|
525
|
+
// do NOT invoke them here (that would disable their hooks, Context,
|
|
526
|
+
// Suspense boundaries, etc.).
|
|
527
|
+
if (!props) return element;
|
|
528
|
+
const rawChildren = props.children as ReactNode | undefined;
|
|
529
|
+
if (rawChildren === undefined) return element;
|
|
530
|
+
|
|
531
|
+
const resolvedChildren = await resolveAsyncElement(rawChildren);
|
|
532
|
+
if (resolvedChildren === rawChildren) return element;
|
|
533
|
+
|
|
534
|
+
// Clone with the resolved children. React.cloneElement preserves the
|
|
535
|
+
// element's key, ref, and internal `$$typeof` markers — a plain spread
|
|
536
|
+
// does not.
|
|
537
|
+
return React.cloneElement(element, undefined, resolvedChildren);
|
|
538
|
+
}
|
|
539
|
+
|
|
435
540
|
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
436
541
|
const {
|
|
437
542
|
title = "Mandu App",
|
|
@@ -450,6 +555,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
450
555
|
islandPreWrapped,
|
|
451
556
|
transitions = true,
|
|
452
557
|
prefetch = true,
|
|
558
|
+
spa,
|
|
453
559
|
devtools,
|
|
454
560
|
} = options;
|
|
455
561
|
|
|
@@ -573,6 +679,16 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
573
679
|
devtoolsScript = generateDevtoolsScript(bundleManifest);
|
|
574
680
|
}
|
|
575
681
|
|
|
682
|
+
// Issue #193 — surface `spa: false` to the client.
|
|
683
|
+
// We emit the global ONLY when explicitly `false` because `true` is
|
|
684
|
+
// the router's default, and a missing global is indistinguishable
|
|
685
|
+
// from `true` in the router's `handleLinkClick` check. This keeps
|
|
686
|
+
// the typical response payload (where `spa` is unset or `true`)
|
|
687
|
+
// byte-identical to pre-#193 output.
|
|
688
|
+
const spaFlagScript = spa === false
|
|
689
|
+
? `<script>window.__MANDU_SPA__=false;</script>`
|
|
690
|
+
: "";
|
|
691
|
+
|
|
576
692
|
// #179: body 내 <link> 태그를 <head>로 호이스팅
|
|
577
693
|
// React 컴포넌트(layout.tsx 등)에서 <link>를 렌더링하면 body 안에 위치하게 되는데,
|
|
578
694
|
// 폰트/스타일시트는 <head>에 있어야 FOUT 없이 로드됨
|
|
@@ -604,6 +720,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
604
720
|
${routeScript}
|
|
605
721
|
${hydrationScripts}
|
|
606
722
|
${needsHydration ? REACT_INTERNALS_SHIM_SCRIPT : ""}
|
|
723
|
+
${spaFlagScript}
|
|
607
724
|
${routerScript}
|
|
608
725
|
${hmrScript}
|
|
609
726
|
${devtoolsScript}
|
|
@@ -1012,7 +1012,17 @@ export async function renderToStream(
|
|
|
1012
1012
|
let shellSent = false;
|
|
1013
1013
|
let timedOut = false;
|
|
1014
1014
|
|
|
1015
|
-
//
|
|
1015
|
+
// Issue #198 — `renderToReadableStream` natively supports async server
|
|
1016
|
+
// components in React 19. If `export default async function Page()`
|
|
1017
|
+
// lands here directly (without going through `server.ts`'s
|
|
1018
|
+
// `resolveAsyncElement` pre-pass), React's streaming pipeline
|
|
1019
|
+
// suspends on the awaiting component and flushes a fallback until
|
|
1020
|
+
// the promise resolves. `collectStreamingHeadTags` above uses the
|
|
1021
|
+
// sync `renderToString` and will throw on async trees — its
|
|
1022
|
+
// try/catch safely returns an empty string in that case, and any
|
|
1023
|
+
// `useHead`-pushed tags from async components are instead picked
|
|
1024
|
+
// up by `buildHtmlTail` on the way out. No additional wiring is
|
|
1025
|
+
// needed on this code path.
|
|
1016
1026
|
// 실패 시 throw → renderStreamingResponse에서 500 처리
|
|
1017
1027
|
const renderToReadableStream = getRenderToReadableStream();
|
|
1018
1028
|
const reactStream = await renderToReadableStream(element, {
|