@mandujs/core 0.23.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/__tests__/reverse-import-graph.test.ts +519 -0
- package/src/bundler/build.ts +26 -3
- package/src/bundler/dev.ts +306 -4
- package/src/bundler/reverse-import-graph.ts +339 -0
- package/src/bundler/safe-build.test.ts +54 -0
- package/src/bundler/safe-build.ts +33 -7
- package/src/client/globals.ts +61 -44
- package/src/client/prefetch-helper.ts +55 -0
- package/src/client/router.ts +93 -15
- package/src/client/use-fetch.ts +243 -239
- package/src/config/mandu.ts +280 -172
- package/src/config/validate.ts +411 -357
- 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/adapter-bun.ts +64 -62
- package/src/runtime/index.ts +11 -0
- package/src/runtime/registry.ts +171 -0
- package/src/runtime/server.ts +214 -8
- package/src/runtime/ssr.ts +264 -8
- package/src/runtime/streaming-ssr.ts +100 -4
- package/src/utils/__tests__/lru-cache.test.ts +186 -0
- package/src/utils/lru-cache.ts +172 -75
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";
|
|
@@ -9,6 +9,21 @@ import { PORTS, TIMEOUTS } from "../constants";
|
|
|
9
9
|
import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
|
|
10
10
|
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
11
11
|
import { generateFastRefreshPreamble } from "../bundler/dev";
|
|
12
|
+
import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Issue #192 — `@view-transition` at-rule block.
|
|
16
|
+
* Inert in browsers without CSS View Transitions (Firefox, Safari < 18.0):
|
|
17
|
+
* the at-rule is simply ignored, so there is no regression. Supporting
|
|
18
|
+
* browsers (Chrome/Edge ≥ 111, Safari 18.2+) play the default crossfade
|
|
19
|
+
* between cross-document navigations.
|
|
20
|
+
*
|
|
21
|
+
* `navigation: auto` is the only value we need — selective transitions
|
|
22
|
+
* are a per-route concern reserved for a future `transitions` config
|
|
23
|
+
* sub-block.
|
|
24
|
+
*/
|
|
25
|
+
const VIEW_TRANSITION_STYLE_TAG =
|
|
26
|
+
"<style>@view-transition{navigation:auto}</style>";
|
|
12
27
|
|
|
13
28
|
// Re-export streaming SSR utilities
|
|
14
29
|
export {
|
|
@@ -69,6 +84,57 @@ export interface SSROptions {
|
|
|
69
84
|
* manifest entry — prod builds never emit the preamble at all.
|
|
70
85
|
*/
|
|
71
86
|
cspNonce?: string | boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Issue #192 — emit `<style>@view-transition{navigation:auto}</style>`
|
|
89
|
+
* into `<head>`. Supported browsers (Chrome/Edge ≥ 111, Safari 18.2+)
|
|
90
|
+
* show a default crossfade between cross-document navigations; others
|
|
91
|
+
* ignore the at-rule (no regression).
|
|
92
|
+
*
|
|
93
|
+
* Default: `true`. Pass `false` to suppress the injection — typically
|
|
94
|
+
* wired from `ManduConfig.transitions`.
|
|
95
|
+
*/
|
|
96
|
+
transitions?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Issue #192 — emit the ~500-byte hover prefetch helper (`<script>`)
|
|
99
|
+
* into `<head>`. Listens for `mouseover` on same-origin `<a href="/...">`
|
|
100
|
+
* anchors and issues `<link rel="prefetch" as="document">` once per
|
|
101
|
+
* unique target. Individual links can opt out via `data-no-prefetch`.
|
|
102
|
+
*
|
|
103
|
+
* Default: `true`. Pass `false` to suppress — typically wired from
|
|
104
|
+
* `ManduConfig.prefetch`.
|
|
105
|
+
*/
|
|
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;
|
|
119
|
+
/**
|
|
120
|
+
* Issue #191 — control dev-mode injection of the `_devtools.js` bundle
|
|
121
|
+
* (~1.15 MB React dev runtime + Mandu Kitchen panel).
|
|
122
|
+
*
|
|
123
|
+
* Three states:
|
|
124
|
+
* - `true` → force inject regardless of islands (explicit opt-in —
|
|
125
|
+
* use this for SSR-only projects that still want the
|
|
126
|
+
* Kitchen panel for local debugging).
|
|
127
|
+
* - `false` → force skip regardless of islands (explicit opt-out —
|
|
128
|
+
* disables Kitchen even for island projects).
|
|
129
|
+
* - unset → default. Inject iff the page renders at least one
|
|
130
|
+
* hydratable island. Pure-SSR pages (no islands) download
|
|
131
|
+
* zero devtools bytes.
|
|
132
|
+
*
|
|
133
|
+
* Wired from `ManduConfig.dev.devtools`. Only takes effect in dev mode
|
|
134
|
+
* (production builds omit the `_devtools.js` output entirely, so this
|
|
135
|
+
* flag is a no-op in prod regardless of value).
|
|
136
|
+
*/
|
|
137
|
+
devtools?: boolean;
|
|
72
138
|
}
|
|
73
139
|
|
|
74
140
|
let projectRenderToString: ((element: ReactElement) => string) | null | undefined;
|
|
@@ -378,6 +444,99 @@ export function _testOnly_getAttachedCspNonce(options: SSROptions): string | und
|
|
|
378
444
|
return OPTIONS_TO_NONCE.get(options as object);
|
|
379
445
|
}
|
|
380
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
|
+
|
|
381
540
|
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
382
541
|
const {
|
|
383
542
|
title = "Mandu App",
|
|
@@ -394,6 +553,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
394
553
|
routePattern,
|
|
395
554
|
cssPath,
|
|
396
555
|
islandPreWrapped,
|
|
556
|
+
transitions = true,
|
|
557
|
+
prefetch = true,
|
|
558
|
+
spa,
|
|
559
|
+
devtools,
|
|
397
560
|
} = options;
|
|
398
561
|
|
|
399
562
|
// CSS 링크 태그 생성
|
|
@@ -403,6 +566,20 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
403
566
|
? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
|
|
404
567
|
: "";
|
|
405
568
|
|
|
569
|
+
// Issue #192 — Smooth navigation primitives.
|
|
570
|
+
// `transitions`: CSS `@view-transition { navigation: auto }` — inert in
|
|
571
|
+
// non-supporting browsers (Firefox, older Safari), crossfade in
|
|
572
|
+
// Chrome/Edge ≥ 111 and Safari 18.2+. Zero layout impact, ~70 bytes.
|
|
573
|
+
// `prefetch`: ~500-byte IIFE that listens for `mouseover` on internal
|
|
574
|
+
// `<a href="/...">` anchors and issues `<link rel="prefetch">`. Honors
|
|
575
|
+
// per-link `data-no-prefetch` opt-out.
|
|
576
|
+
// Position: immediately after `cssLinkTag` so that (a) the at-rule
|
|
577
|
+
// parses alongside the user stylesheet, and (b) both blocks precede
|
|
578
|
+
// user-owned `headTags` / `collectedHeadTags`, letting users override
|
|
579
|
+
// or cancel with a later inline style. False disables each independently.
|
|
580
|
+
const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
|
|
581
|
+
const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
|
|
582
|
+
|
|
406
583
|
// useHead/useSeoMeta SSR 수집
|
|
407
584
|
let collectedHeadTags = "";
|
|
408
585
|
let headReset: (() => void) | undefined;
|
|
@@ -492,12 +669,26 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
492
669
|
? generateFastRefreshPreambleTag(isDev, bundleManifest, resolvedCspNonce)
|
|
493
670
|
: "";
|
|
494
671
|
|
|
495
|
-
// DevTools 번들
|
|
672
|
+
// Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
|
|
673
|
+
// - 기본: island 이 하나라도 있을 때만 주입. Pure-SSR 페이지는 0 bytes 다운로드.
|
|
674
|
+
// - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen panel 원할 때)
|
|
675
|
+
// - `devtools === false` → 강제 스킵 (island 프로젝트에서도 Kitchen 비활성화)
|
|
676
|
+
// Cache-bust 은 `manifest.buildTime` 우선, 없으면 `Date.now()`.
|
|
496
677
|
let devtoolsScript = "";
|
|
497
|
-
if (isDev) {
|
|
498
|
-
devtoolsScript = generateDevtoolsScript();
|
|
678
|
+
if (isDev && shouldInjectDevtools(devtools, bundleManifest)) {
|
|
679
|
+
devtoolsScript = generateDevtoolsScript(bundleManifest);
|
|
499
680
|
}
|
|
500
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
|
+
|
|
501
692
|
// #179: body 내 <link> 태그를 <head>로 호이스팅
|
|
502
693
|
// React 컴포넌트(layout.tsx 등)에서 <link>를 렌더링하면 body 안에 위치하게 되는데,
|
|
503
694
|
// 폰트/스타일시트는 <head>에 있어야 FOUT 없이 로드됨
|
|
@@ -516,6 +707,8 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
516
707
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
517
708
|
<title>${escapeHtmlText(title)}</title>
|
|
518
709
|
${cssLinkTag}
|
|
710
|
+
${viewTransitionTag}
|
|
711
|
+
${prefetchScriptTag}
|
|
519
712
|
${hoistedLinkTags}
|
|
520
713
|
${headTags}
|
|
521
714
|
${collectedHeadTags}
|
|
@@ -527,6 +720,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
527
720
|
${routeScript}
|
|
528
721
|
${hydrationScripts}
|
|
529
722
|
${needsHydration ? REACT_INTERNALS_SHIM_SCRIPT : ""}
|
|
723
|
+
${spaFlagScript}
|
|
530
724
|
${routerScript}
|
|
531
725
|
${hmrScript}
|
|
532
726
|
${devtoolsScript}
|
|
@@ -729,13 +923,75 @@ window.__MANDU_HMR_PORT__ = ${hmrPort};
|
|
|
729
923
|
}
|
|
730
924
|
|
|
731
925
|
/**
|
|
732
|
-
*
|
|
733
|
-
*
|
|
926
|
+
* Issue #191 — Determine whether the dev-only `_devtools.js` bundle
|
|
927
|
+
* (~1.15 MB React dev runtime + Kitchen panel) should be injected
|
|
928
|
+
* into the HTML response.
|
|
929
|
+
*
|
|
930
|
+
* Decision table (`devtools` option × manifest shape):
|
|
931
|
+
*
|
|
932
|
+
* | `devtools` | hasIslands | inject? | rationale |
|
|
933
|
+
* |-------------|------------|---------|-----------------------------|
|
|
934
|
+
* | `true` | any | YES | explicit opt-in |
|
|
935
|
+
* | `false` | any | NO | explicit opt-out |
|
|
936
|
+
* | `undefined` | true | YES | default, hydration runtime |
|
|
937
|
+
* | `undefined` | false | NO | pure-SSR — save 1.15 MB |
|
|
938
|
+
* | `undefined` | no manifest| NO | nothing to hydrate anyway |
|
|
939
|
+
*
|
|
940
|
+
* `hasIslands` is derived from the existing manifest shape rather than
|
|
941
|
+
* a new field, so no bundler-side change is required:
|
|
942
|
+
* - `manifest.islands` is populated only when per-island code
|
|
943
|
+
* splitting produced at least one bundle (build.ts:1654).
|
|
944
|
+
* - `manifest.bundles` entries exist only for routes where
|
|
945
|
+
* `needsHydration()` is true (build.ts:70 filter).
|
|
946
|
+
* Either non-empty ⇒ some route on this server hydrates ⇒ devtools useful.
|
|
947
|
+
*
|
|
948
|
+
* @internal Exported via `_testOnly_shouldInjectDevtools` below so
|
|
949
|
+
* `tests/runtime/devtools-inject.test.ts` can table-test the matrix
|
|
950
|
+
* without mounting React.
|
|
951
|
+
*/
|
|
952
|
+
function shouldInjectDevtools(
|
|
953
|
+
devtools: boolean | undefined,
|
|
954
|
+
manifest: BundleManifest | undefined,
|
|
955
|
+
): boolean {
|
|
956
|
+
// Explicit overrides take absolute precedence.
|
|
957
|
+
if (devtools === true) return true;
|
|
958
|
+
if (devtools === false) return false;
|
|
959
|
+
|
|
960
|
+
// Default behavior: inject only when there is at least one island.
|
|
961
|
+
if (!manifest) return false;
|
|
962
|
+
const hasIslandsMap =
|
|
963
|
+
manifest.islands && Object.keys(manifest.islands).length > 0;
|
|
964
|
+
const hasBundles =
|
|
965
|
+
manifest.bundles && Object.keys(manifest.bundles).length > 0;
|
|
966
|
+
return Boolean(hasIslandsMap || hasBundles);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Issue #191 — DevTools 번들 로드 스크립트 생성 (개발 모드 전용).
|
|
971
|
+
*
|
|
972
|
+
* `_devtools.js` 번들이 자체적으로 `initManduKitchen()` 을 호출한다.
|
|
973
|
+
*
|
|
974
|
+
* Cache-bust: `?v=${manifest.buildTime}` 을 우선 사용하고, manifest 가 없으면
|
|
975
|
+
* `?t=${Date.now()}` 로 fallback. `buildTime` 은 빌드별로 고정이라 브라우저
|
|
976
|
+
* 캐시 효율이 좋지만, 동일 빌드 안에서 HMR 이 발생하더라도 devtools 번들은
|
|
977
|
+
* dev 서버의 static 응답이 `Cache-Control: no-cache, no-store, must-revalidate`
|
|
978
|
+
* (server.ts:1104) 를 보내므로 stale 위험이 없다.
|
|
979
|
+
*
|
|
980
|
+
* @internal Exported via `_testOnly_generateDevtoolsScript` below so tests
|
|
981
|
+
* can verify the URL shape without needing a full render.
|
|
734
982
|
*/
|
|
735
|
-
function generateDevtoolsScript(): string {
|
|
736
|
-
|
|
983
|
+
function generateDevtoolsScript(manifest?: BundleManifest): string {
|
|
984
|
+
const cacheBust = manifest?.buildTime
|
|
985
|
+
? `?v=${encodeURIComponent(manifest.buildTime)}`
|
|
986
|
+
: `?t=${Date.now()}`;
|
|
987
|
+
return `<script type="module" src="/.mandu/client/_devtools.js${cacheBust}"></script>`;
|
|
737
988
|
}
|
|
738
989
|
|
|
990
|
+
/** @internal test helper — exposed only so unit tests can inspect the decision. */
|
|
991
|
+
export const _testOnly_shouldInjectDevtools = shouldInjectDevtools;
|
|
992
|
+
/** @internal test helper — exposed only so unit tests can inspect the script tag. */
|
|
993
|
+
export const _testOnly_generateDevtoolsScript = generateDevtoolsScript;
|
|
994
|
+
|
|
739
995
|
export function createHTMLResponse(
|
|
740
996
|
html: string,
|
|
741
997
|
status: number = 200,
|
|
@@ -24,6 +24,16 @@ import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
|
24
24
|
import { getRenderToString } from "./react-renderer";
|
|
25
25
|
import { mark, measure } from "../perf";
|
|
26
26
|
import { generateFastRefreshPreamble } from "../bundler/dev";
|
|
27
|
+
import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Issue #192 — `@view-transition` at-rule, mirror of the constant in
|
|
31
|
+
* `./ssr.ts`. Duplicated here to keep the streaming path self-contained
|
|
32
|
+
* without a cross-module runtime import cycle (ssr.ts already re-exports
|
|
33
|
+
* streaming-ssr.ts). Both constants MUST stay byte-identical.
|
|
34
|
+
*/
|
|
35
|
+
const VIEW_TRANSITION_STYLE_TAG =
|
|
36
|
+
"<style>@view-transition{navigation:auto}</style>";
|
|
27
37
|
|
|
28
38
|
// ========== Types ==========
|
|
29
39
|
|
|
@@ -154,8 +164,67 @@ export interface StreamingSSROptions {
|
|
|
154
164
|
* Dev + hydration + populated `shared.fastRefresh` only.
|
|
155
165
|
*/
|
|
156
166
|
cspNonce?: string | boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Issue #192 — emit `<style>@view-transition{navigation:auto}</style>`
|
|
169
|
+
* into the streaming shell `<head>`. Mirrors `SSROptions.transitions`.
|
|
170
|
+
* Default: `true`.
|
|
171
|
+
*/
|
|
172
|
+
transitions?: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* Issue #192 — emit the ~500-byte hover prefetch helper `<script>`
|
|
175
|
+
* into the streaming shell `<head>`. Mirrors `SSROptions.prefetch`.
|
|
176
|
+
* Default: `true`.
|
|
177
|
+
*/
|
|
178
|
+
prefetch?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Issue #191 — control dev-mode injection of the `_devtools.js`
|
|
181
|
+
* bundle. Mirrors `SSROptions.devtools`:
|
|
182
|
+
* - `true` → force inject (explicit opt-in)
|
|
183
|
+
* - `false` → force skip (explicit opt-out)
|
|
184
|
+
* - `undefined` → default; inject iff the manifest has islands.
|
|
185
|
+
*/
|
|
186
|
+
devtools?: boolean;
|
|
157
187
|
}
|
|
158
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Issue #191 — Streaming-SSR mirror of `ssr.ts:shouldInjectDevtools`.
|
|
191
|
+
* Kept in sync manually (tiny pure function, not worth a cross-module
|
|
192
|
+
* runtime import — the ssr.ts → streaming-ssr.ts re-export direction
|
|
193
|
+
* means a circular import here would force a refactor of the whole
|
|
194
|
+
* module graph). The unit test suite table-tests both implementations
|
|
195
|
+
* against the same matrix so drift is caught at CI time.
|
|
196
|
+
*/
|
|
197
|
+
function shouldInjectDevtoolsStreaming(
|
|
198
|
+
devtools: boolean | undefined,
|
|
199
|
+
manifest: BundleManifest | undefined,
|
|
200
|
+
): boolean {
|
|
201
|
+
if (devtools === true) return true;
|
|
202
|
+
if (devtools === false) return false;
|
|
203
|
+
if (!manifest) return false;
|
|
204
|
+
const hasIslandsMap =
|
|
205
|
+
manifest.islands && Object.keys(manifest.islands).length > 0;
|
|
206
|
+
const hasBundles =
|
|
207
|
+
manifest.bundles && Object.keys(manifest.bundles).length > 0;
|
|
208
|
+
return Boolean(hasIslandsMap || hasBundles);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Issue #191 — Streaming-SSR mirror of `ssr.ts:generateDevtoolsScript`.
|
|
213
|
+
* Emits `<script type="module" src="/.mandu/client/_devtools.js?v=BUILD">`
|
|
214
|
+
* with a `buildTime`-keyed cache-bust (or `Date.now()` fallback).
|
|
215
|
+
*/
|
|
216
|
+
function generateStreamingDevtoolsScript(manifest: BundleManifest | undefined): string {
|
|
217
|
+
const cacheBust = manifest?.buildTime
|
|
218
|
+
? `?v=${encodeURIComponent(manifest.buildTime)}`
|
|
219
|
+
: `?t=${Date.now()}`;
|
|
220
|
+
return `<script type="module" src="/.mandu/client/_devtools.js${cacheBust}"></script>`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @internal — surface for the shared test suite in tests/runtime/devtools-inject.test.ts. */
|
|
224
|
+
export const _testOnly_shouldInjectDevtoolsStreaming = shouldInjectDevtoolsStreaming;
|
|
225
|
+
/** @internal — surface for the shared test suite in tests/runtime/devtools-inject.test.ts. */
|
|
226
|
+
export const _testOnly_generateStreamingDevtoolsScript = generateStreamingDevtoolsScript;
|
|
227
|
+
|
|
159
228
|
export interface StreamingLoaderResult<T = unknown> {
|
|
160
229
|
/** 즉시 로드할 Critical 데이터 */
|
|
161
230
|
critical?: T;
|
|
@@ -462,6 +531,8 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
462
531
|
hydration,
|
|
463
532
|
cssPath,
|
|
464
533
|
isDev = false,
|
|
534
|
+
transitions = true,
|
|
535
|
+
prefetch = true,
|
|
465
536
|
} = options;
|
|
466
537
|
|
|
467
538
|
// CSS 링크 태그 생성
|
|
@@ -471,6 +542,14 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
471
542
|
? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
|
|
472
543
|
: "";
|
|
473
544
|
|
|
545
|
+
// Issue #192 — Smooth navigation primitives. Mirror of the block in
|
|
546
|
+
// `ssr.ts::renderToHTML`; see that call-site for the full rationale.
|
|
547
|
+
// Positioned right after the user stylesheet so the at-rule parses
|
|
548
|
+
// alongside it, and before user `headTags` so users can override with
|
|
549
|
+
// an inline style later in the document order.
|
|
550
|
+
const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
|
|
551
|
+
const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
|
|
552
|
+
|
|
474
553
|
// Island wrapper (hydration이 필요한 경우)
|
|
475
554
|
const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
476
555
|
|
|
@@ -549,6 +628,8 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
549
628
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
550
629
|
<title>${escapeHtmlText(title)}</title>
|
|
551
630
|
${cssLinkTag}
|
|
631
|
+
${viewTransitionTag}
|
|
632
|
+
${prefetchScriptTag}
|
|
552
633
|
${loadingStyles}
|
|
553
634
|
${importMapScript}
|
|
554
635
|
${headTags}
|
|
@@ -572,6 +653,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
572
653
|
hmrPort,
|
|
573
654
|
enableClientRouter = false,
|
|
574
655
|
hydration,
|
|
656
|
+
devtools,
|
|
575
657
|
} = options;
|
|
576
658
|
|
|
577
659
|
const scripts: string[] = [];
|
|
@@ -658,9 +740,13 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
658
740
|
scripts.push(generateHMRScript(hmrPort));
|
|
659
741
|
}
|
|
660
742
|
|
|
661
|
-
// 11. DevTools 번들
|
|
662
|
-
|
|
663
|
-
|
|
743
|
+
// 11. Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
|
|
744
|
+
// - 기본: manifest 에 island/bundle 이 있을 때만 주입 (pure-SSR 페이지는 스킵).
|
|
745
|
+
// - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen 원할 때).
|
|
746
|
+
// - `devtools === false` → 강제 스킵.
|
|
747
|
+
// - Cache-bust (`?v=buildTime`) 로 HMR 후 stale 방지.
|
|
748
|
+
if (isDev && shouldInjectDevtoolsStreaming(devtools, bundleManifest)) {
|
|
749
|
+
scripts.push(generateStreamingDevtoolsScript(bundleManifest));
|
|
664
750
|
}
|
|
665
751
|
|
|
666
752
|
// Island wrapper 닫기 (hydration이 필요한 경우)
|
|
@@ -926,7 +1012,17 @@ export async function renderToStream(
|
|
|
926
1012
|
let shellSent = false;
|
|
927
1013
|
let timedOut = false;
|
|
928
1014
|
|
|
929
|
-
//
|
|
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.
|
|
930
1026
|
// 실패 시 throw → renderStreamingResponse에서 500 처리
|
|
931
1027
|
const renderToReadableStream = getRenderToReadableStream();
|
|
932
1028
|
const reactStream = await renderToReadableStream(element, {
|