@mandujs/core 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/bundler/build.ts +26 -3
- package/src/bundler/dev.ts +18 -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 +104 -4
- 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 {
|
|
@@ -1920,6 +1953,16 @@ async function renderPageSSR(
|
|
|
1920
1953
|
? route.streaming
|
|
1921
1954
|
: settings.streaming;
|
|
1922
1955
|
|
|
1956
|
+
// Issue #198 — Pre-resolve async server components before handing off
|
|
1957
|
+
// to React's SSR engines. `renderToString` (non-streaming path) does
|
|
1958
|
+
// not support async components; `renderToReadableStream` does, but
|
|
1959
|
+
// the shell-gen step in streaming-ssr also falls through
|
|
1960
|
+
// `collectStreamingHeadTags` → `renderToString`. Resolving up-front
|
|
1961
|
+
// gives consistent, synchronous trees to both paths and keeps the
|
|
1962
|
+
// user-visible contract (`export default async function Page() {...}`
|
|
1963
|
+
// and `export default async function Layout() {...}`) working end to end.
|
|
1964
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
1965
|
+
|
|
1923
1966
|
if (useStreaming) {
|
|
1924
1967
|
const streamingResponse = await renderStreamingResponse(app, {
|
|
1925
1968
|
title: builtMeta.title,
|
|
@@ -2005,6 +2048,10 @@ async function renderPageSSR(
|
|
|
2005
2048
|
errorApp = await wrapWithLayouts(errorApp, route.layoutChain, registry, params, layoutData);
|
|
2006
2049
|
}
|
|
2007
2050
|
|
|
2051
|
+
// Issue #198 — resolve async layouts wrapping the error component
|
|
2052
|
+
// so `async function Layout()` still renders on the 500 surface.
|
|
2053
|
+
errorApp = (await resolveAsyncElement(errorApp)) as React.ReactElement;
|
|
2054
|
+
|
|
2008
2055
|
const errorHtml = renderSSR(errorApp, {
|
|
2009
2056
|
// 에러 상태에서는 resolveMetadata 결과를 신뢰할 수 없을 수 있으므로 리터럴 사용
|
|
2010
2057
|
title: "Mandu App — Error",
|
|
@@ -2107,6 +2154,11 @@ async function renderNotFoundPage(
|
|
|
2107
2154
|
app = await wrapWithLayouts(app, route.layoutChain, registry, params, layoutData);
|
|
2108
2155
|
}
|
|
2109
2156
|
|
|
2157
|
+
// Issue #198 — users may author `not-found.tsx` as an async server
|
|
2158
|
+
// component (e.g. to fetch copy from a CMS). Resolve the async tree
|
|
2159
|
+
// before the sync renderSSR path.
|
|
2160
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
2161
|
+
|
|
2110
2162
|
const html = renderSSR(app, {
|
|
2111
2163
|
title: "Not Found",
|
|
2112
2164
|
isDev: settings.isDev,
|
|
@@ -2505,6 +2557,28 @@ async function handleRequestInternal(
|
|
|
2505
2557
|
return ok(handleEventsRecentRequest(req));
|
|
2506
2558
|
}
|
|
2507
2559
|
|
|
2560
|
+
// Phase 17 — heap snapshot + Prometheus metrics endpoints.
|
|
2561
|
+
//
|
|
2562
|
+
// Gating: dev mode exposes by default so the DX is zero-friction. Prod
|
|
2563
|
+
// requires either `MANDU_DEBUG_HEAP=1` or explicit `observability.heapEndpoint:
|
|
2564
|
+
// true` in `ServerOptions`. Operators can opt-out of even the dev exposure by
|
|
2565
|
+
// passing `observability.heapEndpoint: false` (useful in tests that count
|
|
2566
|
+
// listeners / assert route shape).
|
|
2567
|
+
//
|
|
2568
|
+
// Missing endpoints return 404 via the normal route-not-found path —
|
|
2569
|
+
// scrapers can't distinguish "disabled" from "never existed". See
|
|
2570
|
+
// `docs/ops/metrics.md` for the operator-facing guide.
|
|
2571
|
+
if (pathname === HEAP_ENDPOINT) {
|
|
2572
|
+
if (isObservabilityExposed(settings.isDev, settings.heapEndpoint)) {
|
|
2573
|
+
return ok(buildHeapResponse());
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
if (pathname === METRICS_ENDPOINT) {
|
|
2577
|
+
if (isObservabilityExposed(settings.isDev, settings.metricsEndpoint)) {
|
|
2578
|
+
return ok(buildMetricsResponse());
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2508
2582
|
// 2. Kitchen dev dashboard (dev mode only)
|
|
2509
2583
|
if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
|
|
2510
2584
|
const kitchenResponse = await registry.kitchen.handle(req, pathname);
|
|
@@ -2533,10 +2607,12 @@ async function handleRequestInternal(
|
|
|
2533
2607
|
console.warn(`[Mandu] not-found.tsx loader threw (unmatched URL):`, loaderError);
|
|
2534
2608
|
}
|
|
2535
2609
|
}
|
|
2536
|
-
const
|
|
2610
|
+
const rawApp = React.createElement(registration.component, {
|
|
2537
2611
|
params: {},
|
|
2538
2612
|
loaderData,
|
|
2539
2613
|
});
|
|
2614
|
+
// Issue #198 — pre-resolve in case the not-found component is async.
|
|
2615
|
+
const app = (await resolveAsyncElement(rawApp)) as React.ReactElement;
|
|
2540
2616
|
const html = renderSSR(app, {
|
|
2541
2617
|
title: "Not Found",
|
|
2542
2618
|
isDev: settings.isDev,
|
|
@@ -2698,6 +2774,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2698
2774
|
transitions,
|
|
2699
2775
|
prefetch,
|
|
2700
2776
|
devtools,
|
|
2777
|
+
observability: observabilityOption,
|
|
2701
2778
|
} = options;
|
|
2702
2779
|
|
|
2703
2780
|
// cssPath 처리:
|
|
@@ -2736,6 +2813,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2736
2813
|
transitions,
|
|
2737
2814
|
prefetch,
|
|
2738
2815
|
devtools,
|
|
2816
|
+
heapEndpoint: observabilityOption?.heapEndpoint,
|
|
2817
|
+
metricsEndpoint: observabilityOption?.metricsEndpoint,
|
|
2739
2818
|
};
|
|
2740
2819
|
|
|
2741
2820
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
@@ -2802,6 +2881,21 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2802
2881
|
},
|
|
2803
2882
|
} : undefined;
|
|
2804
2883
|
|
|
2884
|
+
// Phase 17 — bump the Prometheus request counter once per Response we
|
|
2885
|
+
// actually produce. WebSocket upgrades (return `undefined`) are
|
|
2886
|
+
// deliberately skipped so the counter only reflects plain HTTP traffic.
|
|
2887
|
+
// Errors from `recordHttpRequest` are impossible to surface here — the
|
|
2888
|
+
// Map update is synchronous and self-contained — but we still try/catch
|
|
2889
|
+
// as defence-in-depth.
|
|
2890
|
+
const bumpCounter = (req: Request, res: Response | undefined): void => {
|
|
2891
|
+
if (!res) return;
|
|
2892
|
+
try {
|
|
2893
|
+
recordHttpRequest(req.method, res.status);
|
|
2894
|
+
} catch {
|
|
2895
|
+
// Never let an observability hiccup break a request.
|
|
2896
|
+
}
|
|
2897
|
+
};
|
|
2898
|
+
|
|
2805
2899
|
// fetch handler: WS upgrade 감지 추가
|
|
2806
2900
|
const wrappedFetch = hasWsRoutes
|
|
2807
2901
|
? async (req: Request, bunServer: Server<undefined>): Promise<Response | undefined> => {
|
|
@@ -2816,9 +2910,15 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2816
2910
|
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
2817
2911
|
}
|
|
2818
2912
|
}
|
|
2819
|
-
|
|
2913
|
+
const res = await fetchHandler(req);
|
|
2914
|
+
bumpCounter(req, res);
|
|
2915
|
+
return res;
|
|
2820
2916
|
}
|
|
2821
|
-
: async (req: Request): Promise<Response> =>
|
|
2917
|
+
: async (req: Request): Promise<Response> => {
|
|
2918
|
+
const res = await fetchHandler(req);
|
|
2919
|
+
bumpCounter(req, res);
|
|
2920
|
+
return res;
|
|
2921
|
+
};
|
|
2822
2922
|
|
|
2823
2923
|
const { server, port: actualPort, attempts } = startBunServerWithFallback({
|
|
2824
2924
|
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, {
|