@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.
@@ -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
+ }
@@ -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 app = React.createElement(registration.component, {
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
- return fetchHandler(req);
2913
+ const res = await fetchHandler(req);
2914
+ bumpCounter(req, res);
2915
+ return res;
2820
2916
  }
2821
- : async (req: Request): Promise<Response> => fetchHandler(req);
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,
@@ -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 type { ReactElement } from "react";
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
- // React renderToReadableStream 호출
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, {