@mandujs/core 0.23.0 → 0.24.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 +1 -1
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -0
- package/src/bundler/dev.ts +288 -0
- 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/prefetch-helper.ts +55 -0
- package/src/config/mandu.ts +216 -172
- package/src/config/validate.ts +376 -357
- package/src/runtime/adapter-bun.ts +64 -62
- package/src/runtime/server.ts +110 -4
- package/src/runtime/ssr.ts +146 -7
- package/src/runtime/streaming-ssr.ts +89 -3
|
@@ -1,62 +1,64 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu Bun Adapter (기본 어댑터)
|
|
3
|
-
* Bun.serve() 기반 서버 생성
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { ManduAdapter, AdapterOptions, AdapterServer } from "./adapter";
|
|
7
|
-
import { startServer, type ManduServer } from "./server";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Bun 어댑터 (기본)
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* ```typescript
|
|
14
|
-
* // mandu.config.ts
|
|
15
|
-
* import { adapterBun } from "@mandujs/core";
|
|
16
|
-
*
|
|
17
|
-
* export default {
|
|
18
|
-
* adapter: adapterBun(),
|
|
19
|
-
* };
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
|
-
export function adapterBun(): ManduAdapter {
|
|
23
|
-
return {
|
|
24
|
-
name: "adapter-bun",
|
|
25
|
-
|
|
26
|
-
createServer(options: AdapterOptions): AdapterServer {
|
|
27
|
-
let manduServer: ManduServer | null = null;
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
async fetch(req: Request): Promise<Response> {
|
|
31
|
-
if (!manduServer) {
|
|
32
|
-
return new Response("Server not started", { status: 503 });
|
|
33
|
-
}
|
|
34
|
-
// 내부 서버로 프록시
|
|
35
|
-
const url = new URL(req.url);
|
|
36
|
-
const targetUrl = `http://localhost:${manduServer.server.port}${url.pathname}${url.search}`;
|
|
37
|
-
return globalThis.fetch(new Request(targetUrl, req));
|
|
38
|
-
},
|
|
39
|
-
|
|
40
|
-
async listen(port: number, hostname?: string) {
|
|
41
|
-
manduServer = startServer(options.manifest, {
|
|
42
|
-
...options.serverOptions,
|
|
43
|
-
port,
|
|
44
|
-
hostname,
|
|
45
|
-
rootDir: options.rootDir,
|
|
46
|
-
bundleManifest: options.bundleManifest,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
port: manduServer.server.port ?? port,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Bun Adapter (기본 어댑터)
|
|
3
|
+
* Bun.serve() 기반 서버 생성
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ManduAdapter, AdapterOptions, AdapterServer } from "./adapter";
|
|
7
|
+
import { startServer, type ManduServer } from "./server";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bun 어댑터 (기본)
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* // mandu.config.ts
|
|
15
|
+
* import { adapterBun } from "@mandujs/core";
|
|
16
|
+
*
|
|
17
|
+
* export default {
|
|
18
|
+
* adapter: adapterBun(),
|
|
19
|
+
* };
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function adapterBun(): ManduAdapter {
|
|
23
|
+
return {
|
|
24
|
+
name: "adapter-bun",
|
|
25
|
+
|
|
26
|
+
createServer(options: AdapterOptions): AdapterServer {
|
|
27
|
+
let manduServer: ManduServer | null = null;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async fetch(req: Request): Promise<Response> {
|
|
31
|
+
if (!manduServer) {
|
|
32
|
+
return new Response("Server not started", { status: 503 });
|
|
33
|
+
}
|
|
34
|
+
// 내부 서버로 프록시
|
|
35
|
+
const url = new URL(req.url);
|
|
36
|
+
const targetUrl = `http://localhost:${manduServer.server.port}${url.pathname}${url.search}`;
|
|
37
|
+
return globalThis.fetch(new Request(targetUrl, req));
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async listen(port: number, hostname?: string) {
|
|
41
|
+
manduServer = startServer(options.manifest, {
|
|
42
|
+
...options.serverOptions,
|
|
43
|
+
port,
|
|
44
|
+
hostname,
|
|
45
|
+
rootDir: options.rootDir,
|
|
46
|
+
bundleManifest: options.bundleManifest,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
port: manduServer.server.port ?? port,
|
|
51
|
+
// Report the effective bind address. startServer() defaults to
|
|
52
|
+
// 0.0.0.0 when no hostname is supplied. See #190.
|
|
53
|
+
hostname: hostname ?? "0.0.0.0",
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async close() {
|
|
58
|
+
manduServer?.stop();
|
|
59
|
+
manduServer = null;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -350,6 +350,32 @@ export interface ServerOptions {
|
|
|
350
350
|
* When set, token-protected endpoints such as `/_mandu/cache` become available.
|
|
351
351
|
*/
|
|
352
352
|
managementToken?: string;
|
|
353
|
+
/**
|
|
354
|
+
* Issue #192 — enable CSS View Transitions auto-inject (default `true`).
|
|
355
|
+
* When `true`, every SSR response gets
|
|
356
|
+
* `<style>@view-transition{navigation:auto}</style>` in its `<head>`,
|
|
357
|
+
* giving supported browsers a default crossfade on cross-document
|
|
358
|
+
* navigation. Pass `false` to suppress (typically wired from
|
|
359
|
+
* `ManduConfig.transitions`).
|
|
360
|
+
*/
|
|
361
|
+
transitions?: boolean;
|
|
362
|
+
/**
|
|
363
|
+
* Issue #192 — enable the hover prefetch helper (default `true`).
|
|
364
|
+
* When `true`, every SSR response gets a ~500-byte inline script that
|
|
365
|
+
* prefetches same-origin links on hover. Pass `false` to suppress
|
|
366
|
+
* (typically wired from `ManduConfig.prefetch`). Individual links can
|
|
367
|
+
* also opt out via `data-no-prefetch`.
|
|
368
|
+
*/
|
|
369
|
+
prefetch?: boolean;
|
|
370
|
+
/**
|
|
371
|
+
* Issue #191 — override dev-mode `_devtools.js` injection.
|
|
372
|
+
* Wired from `ManduConfig.dev.devtools`.
|
|
373
|
+
* - `true` → force inject on every page (SSR-only + Kitchen).
|
|
374
|
+
* - `false` → force skip on every page.
|
|
375
|
+
* - `undefined` → default. Inject iff the page's route has at least
|
|
376
|
+
* one island. Pure-SSR pages download zero devtools.
|
|
377
|
+
*/
|
|
378
|
+
devtools?: boolean;
|
|
353
379
|
}
|
|
354
380
|
|
|
355
381
|
export interface ManduServer {
|
|
@@ -456,6 +482,24 @@ export interface ServerRegistrySettings {
|
|
|
456
482
|
* Default: false (Bun/Node runtime with full FS access).
|
|
457
483
|
*/
|
|
458
484
|
edge?: boolean;
|
|
485
|
+
/**
|
|
486
|
+
* Issue #192 — threaded from `ServerOptions.transitions`.
|
|
487
|
+
* `undefined` is treated as `true` at the SSR call-site (enabled by
|
|
488
|
+
* default); `false` suppresses the `<style>@view-transition>` injection.
|
|
489
|
+
*/
|
|
490
|
+
transitions?: boolean;
|
|
491
|
+
/**
|
|
492
|
+
* Issue #192 — threaded from `ServerOptions.prefetch`.
|
|
493
|
+
* `undefined` is treated as `true` at the SSR call-site (enabled by
|
|
494
|
+
* default); `false` suppresses the hover prefetch `<script>` injection.
|
|
495
|
+
*/
|
|
496
|
+
prefetch?: boolean;
|
|
497
|
+
/**
|
|
498
|
+
* Issue #191 — threaded from `ServerOptions.devtools`. `undefined`
|
|
499
|
+
* means "use default (islands → inject)"; `true` / `false` force the
|
|
500
|
+
* dev-mode `_devtools.js` `<script>` injection on / off. No-op in prod.
|
|
501
|
+
*/
|
|
502
|
+
devtools?: boolean;
|
|
459
503
|
}
|
|
460
504
|
|
|
461
505
|
export class ServerRegistry {
|
|
@@ -1889,6 +1933,9 @@ async function renderPageSSR(
|
|
|
1889
1933
|
criticalData: loaderData as Record<string, unknown> | undefined,
|
|
1890
1934
|
enableClientRouter: true,
|
|
1891
1935
|
cssPath: settings.cssPath,
|
|
1936
|
+
transitions: settings.transitions,
|
|
1937
|
+
prefetch: settings.prefetch,
|
|
1938
|
+
devtools: settings.devtools,
|
|
1892
1939
|
onShellReady: () => {
|
|
1893
1940
|
if (settings.isDev) {
|
|
1894
1941
|
console.log(`[Mandu Streaming] Shell ready: ${route.id}`);
|
|
@@ -1924,6 +1971,9 @@ async function renderPageSSR(
|
|
|
1924
1971
|
routePattern: route.pattern,
|
|
1925
1972
|
cssPath: settings.cssPath,
|
|
1926
1973
|
islandPreWrapped: !!needsIslandWrap,
|
|
1974
|
+
transitions: settings.transitions,
|
|
1975
|
+
prefetch: settings.prefetch,
|
|
1976
|
+
devtools: settings.devtools,
|
|
1927
1977
|
});
|
|
1928
1978
|
return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
|
|
1929
1979
|
} catch (error) {
|
|
@@ -1960,6 +2010,9 @@ async function renderPageSSR(
|
|
|
1960
2010
|
title: "Mandu App — Error",
|
|
1961
2011
|
isDev: settings.isDev,
|
|
1962
2012
|
cssPath: settings.cssPath,
|
|
2013
|
+
transitions: settings.transitions,
|
|
2014
|
+
prefetch: settings.prefetch,
|
|
2015
|
+
devtools: settings.devtools,
|
|
1963
2016
|
});
|
|
1964
2017
|
return ok(cookies ? cookies.applyToResponse(errorHtml) : errorHtml);
|
|
1965
2018
|
}
|
|
@@ -2058,6 +2111,9 @@ async function renderNotFoundPage(
|
|
|
2058
2111
|
title: "Not Found",
|
|
2059
2112
|
isDev: settings.isDev,
|
|
2060
2113
|
cssPath: settings.cssPath,
|
|
2114
|
+
transitions: settings.transitions,
|
|
2115
|
+
prefetch: settings.prefetch,
|
|
2116
|
+
devtools: settings.devtools,
|
|
2061
2117
|
});
|
|
2062
2118
|
|
|
2063
2119
|
// renderSSR returns a 200; override to 404 without losing headers.
|
|
@@ -2485,6 +2541,9 @@ async function handleRequestInternal(
|
|
|
2485
2541
|
title: "Not Found",
|
|
2486
2542
|
isDev: settings.isDev,
|
|
2487
2543
|
cssPath: settings.cssPath,
|
|
2544
|
+
transitions: settings.transitions,
|
|
2545
|
+
prefetch: settings.prefetch,
|
|
2546
|
+
devtools: settings.devtools,
|
|
2488
2547
|
});
|
|
2489
2548
|
const headers = new Headers(html.headers);
|
|
2490
2549
|
const body = await html.text();
|
|
@@ -2586,10 +2645,43 @@ function startBunServerWithFallback(options: {
|
|
|
2586
2645
|
|
|
2587
2646
|
// ========== Server Startup ==========
|
|
2588
2647
|
|
|
2648
|
+
/**
|
|
2649
|
+
* Format a base URL for startup logging based on the bound hostname.
|
|
2650
|
+
*
|
|
2651
|
+
* When binding to wildcard addresses (`0.0.0.0`, `::`, or empty string),
|
|
2652
|
+
* the server listens on all interfaces — browsers must use `localhost`
|
|
2653
|
+
* or a specific loopback address to connect. We surface both IPv4 and IPv6
|
|
2654
|
+
* loopback URLs so the user can pick whichever their OS prefers.
|
|
2655
|
+
*
|
|
2656
|
+
* Returns `{ primary, additional }` where `primary` is the canonical URL
|
|
2657
|
+
* for UX (open-in-browser, runtime control) and `additional` are supplementary
|
|
2658
|
+
* URLs shown in the startup log.
|
|
2659
|
+
*/
|
|
2660
|
+
export function formatServerAddresses(
|
|
2661
|
+
hostname: string | undefined,
|
|
2662
|
+
port: number
|
|
2663
|
+
): { primary: string; additional: string[] } {
|
|
2664
|
+
const isWildcardV4 = hostname === "0.0.0.0" || hostname === undefined || hostname === "";
|
|
2665
|
+
const isWildcardV6 = hostname === "::" || hostname === "[::]";
|
|
2666
|
+
if (isWildcardV4 || isWildcardV6) {
|
|
2667
|
+
return {
|
|
2668
|
+
primary: `http://localhost:${port}`,
|
|
2669
|
+
additional: [`http://127.0.0.1:${port}`, `http://[::1]:${port}`],
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
// Bracket IPv6 literals for URL syntax.
|
|
2673
|
+
const host = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
2674
|
+
return { primary: `http://${host}:${port}`, additional: [] };
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2589
2677
|
export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
|
|
2590
2678
|
const {
|
|
2591
2679
|
port = 3000,
|
|
2592
|
-
|
|
2680
|
+
// Default to 0.0.0.0 (dual-stack wildcard on IPv4) so `localhost` resolves
|
|
2681
|
+
// to 127.0.0.1 via OS-level IPv4-preferred lookups (e.g., Windows). Users
|
|
2682
|
+
// can still pin `hostname: "::1"` or `hostname: "127.0.0.1"` explicitly.
|
|
2683
|
+
// See issue #190.
|
|
2684
|
+
hostname = "0.0.0.0",
|
|
2593
2685
|
rootDir = process.cwd(),
|
|
2594
2686
|
isDev = false,
|
|
2595
2687
|
hmrPort,
|
|
@@ -2603,6 +2695,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2603
2695
|
guardConfig = null,
|
|
2604
2696
|
cache: cacheOption,
|
|
2605
2697
|
managementToken,
|
|
2698
|
+
transitions,
|
|
2699
|
+
prefetch,
|
|
2700
|
+
devtools,
|
|
2606
2701
|
} = options;
|
|
2607
2702
|
|
|
2608
2703
|
// cssPath 처리:
|
|
@@ -2638,6 +2733,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2638
2733
|
rateLimit: rateLimitOptions,
|
|
2639
2734
|
cssPath,
|
|
2640
2735
|
managementToken,
|
|
2736
|
+
transitions,
|
|
2737
|
+
prefetch,
|
|
2738
|
+
devtools,
|
|
2641
2739
|
};
|
|
2642
2740
|
|
|
2643
2741
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
@@ -2737,8 +2835,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2737
2835
|
registry.settings = { ...registry.settings, hmrPort: actualPort };
|
|
2738
2836
|
}
|
|
2739
2837
|
|
|
2838
|
+
const addresses = formatServerAddresses(hostname, actualPort);
|
|
2839
|
+
|
|
2740
2840
|
if (isDev) {
|
|
2741
|
-
console.log(`🥟 Mandu Dev Server
|
|
2841
|
+
console.log(`🥟 Mandu Dev Server listening at ${addresses.primary}`);
|
|
2842
|
+
if (addresses.additional.length > 0) {
|
|
2843
|
+
console.log(` (also reachable at ${addresses.additional.join(", ")})`);
|
|
2844
|
+
}
|
|
2742
2845
|
if (registry.settings.hmrPort) {
|
|
2743
2846
|
console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
|
|
2744
2847
|
}
|
|
@@ -2750,10 +2853,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2750
2853
|
console.log(`🌊 Streaming SSR enabled`);
|
|
2751
2854
|
}
|
|
2752
2855
|
if (registry.kitchen) {
|
|
2753
|
-
console.log(`🍳 Kitchen dashboard at
|
|
2856
|
+
console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
|
|
2754
2857
|
}
|
|
2755
2858
|
} else {
|
|
2756
|
-
console.log(`🥟 Mandu server
|
|
2859
|
+
console.log(`🥟 Mandu server listening at ${addresses.primary}`);
|
|
2860
|
+
if (addresses.additional.length > 0) {
|
|
2861
|
+
console.log(` (also reachable at ${addresses.additional.join(", ")})`);
|
|
2862
|
+
}
|
|
2757
2863
|
if (streaming) {
|
|
2758
2864
|
console.log(`🌊 Streaming SSR enabled`);
|
|
2759
2865
|
}
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -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,45 @@ 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 #191 — control dev-mode injection of the `_devtools.js` bundle
|
|
109
|
+
* (~1.15 MB React dev runtime + Mandu Kitchen panel).
|
|
110
|
+
*
|
|
111
|
+
* Three states:
|
|
112
|
+
* - `true` → force inject regardless of islands (explicit opt-in —
|
|
113
|
+
* use this for SSR-only projects that still want the
|
|
114
|
+
* Kitchen panel for local debugging).
|
|
115
|
+
* - `false` → force skip regardless of islands (explicit opt-out —
|
|
116
|
+
* disables Kitchen even for island projects).
|
|
117
|
+
* - unset → default. Inject iff the page renders at least one
|
|
118
|
+
* hydratable island. Pure-SSR pages (no islands) download
|
|
119
|
+
* zero devtools bytes.
|
|
120
|
+
*
|
|
121
|
+
* Wired from `ManduConfig.dev.devtools`. Only takes effect in dev mode
|
|
122
|
+
* (production builds omit the `_devtools.js` output entirely, so this
|
|
123
|
+
* flag is a no-op in prod regardless of value).
|
|
124
|
+
*/
|
|
125
|
+
devtools?: boolean;
|
|
72
126
|
}
|
|
73
127
|
|
|
74
128
|
let projectRenderToString: ((element: ReactElement) => string) | null | undefined;
|
|
@@ -394,6 +448,9 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
394
448
|
routePattern,
|
|
395
449
|
cssPath,
|
|
396
450
|
islandPreWrapped,
|
|
451
|
+
transitions = true,
|
|
452
|
+
prefetch = true,
|
|
453
|
+
devtools,
|
|
397
454
|
} = options;
|
|
398
455
|
|
|
399
456
|
// CSS 링크 태그 생성
|
|
@@ -403,6 +460,20 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
403
460
|
? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
|
|
404
461
|
: "";
|
|
405
462
|
|
|
463
|
+
// Issue #192 — Smooth navigation primitives.
|
|
464
|
+
// `transitions`: CSS `@view-transition { navigation: auto }` — inert in
|
|
465
|
+
// non-supporting browsers (Firefox, older Safari), crossfade in
|
|
466
|
+
// Chrome/Edge ≥ 111 and Safari 18.2+. Zero layout impact, ~70 bytes.
|
|
467
|
+
// `prefetch`: ~500-byte IIFE that listens for `mouseover` on internal
|
|
468
|
+
// `<a href="/...">` anchors and issues `<link rel="prefetch">`. Honors
|
|
469
|
+
// per-link `data-no-prefetch` opt-out.
|
|
470
|
+
// Position: immediately after `cssLinkTag` so that (a) the at-rule
|
|
471
|
+
// parses alongside the user stylesheet, and (b) both blocks precede
|
|
472
|
+
// user-owned `headTags` / `collectedHeadTags`, letting users override
|
|
473
|
+
// or cancel with a later inline style. False disables each independently.
|
|
474
|
+
const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
|
|
475
|
+
const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
|
|
476
|
+
|
|
406
477
|
// useHead/useSeoMeta SSR 수집
|
|
407
478
|
let collectedHeadTags = "";
|
|
408
479
|
let headReset: (() => void) | undefined;
|
|
@@ -492,10 +563,14 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
492
563
|
? generateFastRefreshPreambleTag(isDev, bundleManifest, resolvedCspNonce)
|
|
493
564
|
: "";
|
|
494
565
|
|
|
495
|
-
// DevTools 번들
|
|
566
|
+
// Issue #191 — DevTools 번들 (~1.15 MB) 주입 결정.
|
|
567
|
+
// - 기본: island 이 하나라도 있을 때만 주입. Pure-SSR 페이지는 0 bytes 다운로드.
|
|
568
|
+
// - `devtools === true` → 강제 주입 (SSR-only 프로젝트에서 Kitchen panel 원할 때)
|
|
569
|
+
// - `devtools === false` → 강제 스킵 (island 프로젝트에서도 Kitchen 비활성화)
|
|
570
|
+
// Cache-bust 은 `manifest.buildTime` 우선, 없으면 `Date.now()`.
|
|
496
571
|
let devtoolsScript = "";
|
|
497
|
-
if (isDev) {
|
|
498
|
-
devtoolsScript = generateDevtoolsScript();
|
|
572
|
+
if (isDev && shouldInjectDevtools(devtools, bundleManifest)) {
|
|
573
|
+
devtoolsScript = generateDevtoolsScript(bundleManifest);
|
|
499
574
|
}
|
|
500
575
|
|
|
501
576
|
// #179: body 내 <link> 태그를 <head>로 호이스팅
|
|
@@ -516,6 +591,8 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
516
591
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
517
592
|
<title>${escapeHtmlText(title)}</title>
|
|
518
593
|
${cssLinkTag}
|
|
594
|
+
${viewTransitionTag}
|
|
595
|
+
${prefetchScriptTag}
|
|
519
596
|
${hoistedLinkTags}
|
|
520
597
|
${headTags}
|
|
521
598
|
${collectedHeadTags}
|
|
@@ -729,13 +806,75 @@ window.__MANDU_HMR_PORT__ = ${hmrPort};
|
|
|
729
806
|
}
|
|
730
807
|
|
|
731
808
|
/**
|
|
732
|
-
*
|
|
733
|
-
*
|
|
809
|
+
* Issue #191 — Determine whether the dev-only `_devtools.js` bundle
|
|
810
|
+
* (~1.15 MB React dev runtime + Kitchen panel) should be injected
|
|
811
|
+
* into the HTML response.
|
|
812
|
+
*
|
|
813
|
+
* Decision table (`devtools` option × manifest shape):
|
|
814
|
+
*
|
|
815
|
+
* | `devtools` | hasIslands | inject? | rationale |
|
|
816
|
+
* |-------------|------------|---------|-----------------------------|
|
|
817
|
+
* | `true` | any | YES | explicit opt-in |
|
|
818
|
+
* | `false` | any | NO | explicit opt-out |
|
|
819
|
+
* | `undefined` | true | YES | default, hydration runtime |
|
|
820
|
+
* | `undefined` | false | NO | pure-SSR — save 1.15 MB |
|
|
821
|
+
* | `undefined` | no manifest| NO | nothing to hydrate anyway |
|
|
822
|
+
*
|
|
823
|
+
* `hasIslands` is derived from the existing manifest shape rather than
|
|
824
|
+
* a new field, so no bundler-side change is required:
|
|
825
|
+
* - `manifest.islands` is populated only when per-island code
|
|
826
|
+
* splitting produced at least one bundle (build.ts:1654).
|
|
827
|
+
* - `manifest.bundles` entries exist only for routes where
|
|
828
|
+
* `needsHydration()` is true (build.ts:70 filter).
|
|
829
|
+
* Either non-empty ⇒ some route on this server hydrates ⇒ devtools useful.
|
|
830
|
+
*
|
|
831
|
+
* @internal Exported via `_testOnly_shouldInjectDevtools` below so
|
|
832
|
+
* `tests/runtime/devtools-inject.test.ts` can table-test the matrix
|
|
833
|
+
* without mounting React.
|
|
834
|
+
*/
|
|
835
|
+
function shouldInjectDevtools(
|
|
836
|
+
devtools: boolean | undefined,
|
|
837
|
+
manifest: BundleManifest | undefined,
|
|
838
|
+
): boolean {
|
|
839
|
+
// Explicit overrides take absolute precedence.
|
|
840
|
+
if (devtools === true) return true;
|
|
841
|
+
if (devtools === false) return false;
|
|
842
|
+
|
|
843
|
+
// Default behavior: inject only when there is at least one island.
|
|
844
|
+
if (!manifest) return false;
|
|
845
|
+
const hasIslandsMap =
|
|
846
|
+
manifest.islands && Object.keys(manifest.islands).length > 0;
|
|
847
|
+
const hasBundles =
|
|
848
|
+
manifest.bundles && Object.keys(manifest.bundles).length > 0;
|
|
849
|
+
return Boolean(hasIslandsMap || hasBundles);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Issue #191 — DevTools 번들 로드 스크립트 생성 (개발 모드 전용).
|
|
854
|
+
*
|
|
855
|
+
* `_devtools.js` 번들이 자체적으로 `initManduKitchen()` 을 호출한다.
|
|
856
|
+
*
|
|
857
|
+
* Cache-bust: `?v=${manifest.buildTime}` 을 우선 사용하고, manifest 가 없으면
|
|
858
|
+
* `?t=${Date.now()}` 로 fallback. `buildTime` 은 빌드별로 고정이라 브라우저
|
|
859
|
+
* 캐시 효율이 좋지만, 동일 빌드 안에서 HMR 이 발생하더라도 devtools 번들은
|
|
860
|
+
* dev 서버의 static 응답이 `Cache-Control: no-cache, no-store, must-revalidate`
|
|
861
|
+
* (server.ts:1104) 를 보내므로 stale 위험이 없다.
|
|
862
|
+
*
|
|
863
|
+
* @internal Exported via `_testOnly_generateDevtoolsScript` below so tests
|
|
864
|
+
* can verify the URL shape without needing a full render.
|
|
734
865
|
*/
|
|
735
|
-
function generateDevtoolsScript(): string {
|
|
736
|
-
|
|
866
|
+
function generateDevtoolsScript(manifest?: BundleManifest): string {
|
|
867
|
+
const cacheBust = manifest?.buildTime
|
|
868
|
+
? `?v=${encodeURIComponent(manifest.buildTime)}`
|
|
869
|
+
: `?t=${Date.now()}`;
|
|
870
|
+
return `<script type="module" src="/.mandu/client/_devtools.js${cacheBust}"></script>`;
|
|
737
871
|
}
|
|
738
872
|
|
|
873
|
+
/** @internal test helper — exposed only so unit tests can inspect the decision. */
|
|
874
|
+
export const _testOnly_shouldInjectDevtools = shouldInjectDevtools;
|
|
875
|
+
/** @internal test helper — exposed only so unit tests can inspect the script tag. */
|
|
876
|
+
export const _testOnly_generateDevtoolsScript = generateDevtoolsScript;
|
|
877
|
+
|
|
739
878
|
export function createHTMLResponse(
|
|
740
879
|
html: string,
|
|
741
880
|
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이 필요한 경우)
|