@mandujs/core 0.18.2 → 0.18.5
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 +8 -2
- package/src/bundler/build.ts +39 -35
- package/src/bundler/css.ts +332 -302
- package/src/bundler/dev.ts +34 -3
- package/src/config/mandu.ts +1 -1
- package/src/config/validate.ts +1 -1
- package/src/contract/registry.ts +591 -568
- package/src/resource/generator.ts +5 -4
- package/src/runtime/escape.ts +12 -0
- package/src/runtime/server.ts +1 -1
- package/src/runtime/shims.ts +48 -0
- package/src/runtime/ssr.ts +29 -10
- package/src/runtime/streaming-ssr.ts +13 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ParsedResource } from "./parser";
|
|
7
|
+
import type { ResourceDefinition } from "./schema";
|
|
7
8
|
import { generateResourceContract } from "./generators/contract";
|
|
8
9
|
import { generateResourceTypes } from "./generators/types";
|
|
9
10
|
import { generateResourceSlot } from "./generators/slot";
|
|
@@ -129,7 +130,7 @@ export async function generateResourceArtifacts(
|
|
|
129
130
|
* Generate contract file
|
|
130
131
|
*/
|
|
131
132
|
async function generateContract(
|
|
132
|
-
definition:
|
|
133
|
+
definition: ResourceDefinition,
|
|
133
134
|
resourceName: string,
|
|
134
135
|
contractsDir: string,
|
|
135
136
|
result: GeneratorResult
|
|
@@ -147,7 +148,7 @@ async function generateContract(
|
|
|
147
148
|
* Generate types file
|
|
148
149
|
*/
|
|
149
150
|
async function generateTypes(
|
|
150
|
-
definition:
|
|
151
|
+
definition: ResourceDefinition,
|
|
151
152
|
resourceName: string,
|
|
152
153
|
typesDir: string,
|
|
153
154
|
result: GeneratorResult
|
|
@@ -165,7 +166,7 @@ async function generateTypes(
|
|
|
165
166
|
* Generate slot file (PRESERVE if exists!)
|
|
166
167
|
*/
|
|
167
168
|
async function generateSlot(
|
|
168
|
-
definition:
|
|
169
|
+
definition: ResourceDefinition,
|
|
169
170
|
resourceName: string,
|
|
170
171
|
slotsDir: string,
|
|
171
172
|
force: boolean,
|
|
@@ -195,7 +196,7 @@ async function generateSlot(
|
|
|
195
196
|
* Generate client file
|
|
196
197
|
*/
|
|
197
198
|
async function generateClient(
|
|
198
|
-
definition:
|
|
199
|
+
definition: ResourceDefinition,
|
|
199
200
|
resourceName: string,
|
|
200
201
|
clientDir: string,
|
|
201
202
|
result: GeneratorResult
|
package/src/runtime/escape.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML 텍스트 콘텐츠 이스케이프
|
|
3
|
+
* <title>, <p> 등 텍스트 노드에 들어갈 문자열을 안전하게 처리.
|
|
4
|
+
* 속성값과 달리 " ' 는 이스케이프 불필요.
|
|
5
|
+
*/
|
|
6
|
+
export function escapeHtmlText(value: string): string {
|
|
7
|
+
return value
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">");
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
/**
|
|
2
14
|
* HTML 속성값 이스케이프
|
|
3
15
|
* XSS 방지를 위해 HTML 속성값에 들어갈 문자열을 안전하게 처리
|
package/src/runtime/server.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { ManduFilling } from "../filling/filling";
|
|
|
5
5
|
import { ManduContext } from "../filling/context";
|
|
6
6
|
import { Router } from "./router";
|
|
7
7
|
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
8
|
-
import {
|
|
8
|
+
import { type ErrorFallbackProps } from "./boundary";
|
|
9
9
|
import React, { type ReactNode } from "react";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import fs from "fs/promises";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Shims for React Compatibility
|
|
3
|
+
*
|
|
4
|
+
* React 19+ 호환성을 위한 런타임 shim 스크립트들을 제공합니다.
|
|
5
|
+
*
|
|
6
|
+
* @module runtime/shims
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* React 19 Client Internals Shim Script
|
|
11
|
+
*
|
|
12
|
+
* React 19에서 react-dom/client가 실행되기 전에 ReactSharedInternals.S가
|
|
13
|
+
* 존재하는지 확인하고 필요시 초기화하는 inline 스크립트입니다.
|
|
14
|
+
*
|
|
15
|
+
* **사용 목적**:
|
|
16
|
+
* - Playwright headless 환경에서 hydration 실패 방지
|
|
17
|
+
* - React 19의 __CLIENT_INTERNALS.S가 null일 수 있는 문제 해결
|
|
18
|
+
* - SSR HTML에 삽입하여 번들 로드 전에 실행
|
|
19
|
+
*
|
|
20
|
+
* **안전성**:
|
|
21
|
+
* - try-catch로 감싸져 있어 오류 발생 시에도 안전
|
|
22
|
+
* - 기존 값이 있으면 덮어쓰지 않음
|
|
23
|
+
* - React가 없거나 internals가 없어도 실패하지 않음
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* // SSR HTML에 삽입
|
|
28
|
+
* const html = `
|
|
29
|
+
* ${hydrationScripts}
|
|
30
|
+
* ${REACT_INTERNALS_SHIM_SCRIPT}
|
|
31
|
+
* ${routerScript}
|
|
32
|
+
* `;
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export const REACT_INTERNALS_SHIM_SCRIPT = `<script>
|
|
36
|
+
// React 19 internals shim: ensure ReactSharedInternals.S exists before react-dom/client runs.
|
|
37
|
+
// Some builds expect React.__CLIENT_INTERNALS... .S to be a function, but it may be null.
|
|
38
|
+
// This shim is safe: it only fills the slot if missing.
|
|
39
|
+
(function(){
|
|
40
|
+
try {
|
|
41
|
+
var React = window.React;
|
|
42
|
+
var i = React && React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
|
43
|
+
if (i && i.S == null) {
|
|
44
|
+
i.S = function(){};
|
|
45
|
+
}
|
|
46
|
+
} catch(e) {}
|
|
47
|
+
})();
|
|
48
|
+
</script>`;
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { ReactElement } from "react";
|
|
|
5
5
|
import type { BundleManifest } from "../bundler/types";
|
|
6
6
|
import type { HydrationConfig, HydrationPriority } from "../spec/schema";
|
|
7
7
|
import { PORTS, TIMEOUTS } from "../constants";
|
|
8
|
-
import { escapeHtmlAttr, escapeJsonForInlineScript } from "./escape";
|
|
8
|
+
import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
|
|
9
|
+
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
9
10
|
|
|
10
11
|
// Re-export streaming SSR utilities
|
|
11
12
|
export {
|
|
@@ -242,7 +243,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
242
243
|
<head>
|
|
243
244
|
<meta charset="UTF-8">
|
|
244
245
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
245
|
-
<title>${
|
|
246
|
+
<title>${escapeHtmlText(title)}</title>
|
|
246
247
|
${cssLinkTag}
|
|
247
248
|
${headTags}
|
|
248
249
|
</head>
|
|
@@ -251,6 +252,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
251
252
|
${dataScript}
|
|
252
253
|
${routeScript}
|
|
253
254
|
${hydrationScripts}
|
|
255
|
+
${needsHydration ? REACT_INTERNALS_SHIM_SCRIPT : ""}
|
|
254
256
|
${routerScript}
|
|
255
257
|
${hmrScript}
|
|
256
258
|
${bodyEndTags}
|
|
@@ -311,6 +313,15 @@ function generateHMRScript(port: number): string {
|
|
|
311
313
|
var ws = null;
|
|
312
314
|
var reconnectAttempts = 0;
|
|
313
315
|
var maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT};
|
|
316
|
+
var baseDelay = ${TIMEOUTS.HMR_RECONNECT_DELAY};
|
|
317
|
+
|
|
318
|
+
function scheduleReconnect() {
|
|
319
|
+
if (reconnectAttempts < maxReconnectAttempts) {
|
|
320
|
+
reconnectAttempts++;
|
|
321
|
+
var delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
322
|
+
setTimeout(connect, delay);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
314
325
|
|
|
315
326
|
function connect() {
|
|
316
327
|
try {
|
|
@@ -325,19 +336,27 @@ function generateHMRScript(port: number): string {
|
|
|
325
336
|
if (msg.type === 'reload' || msg.type === 'island-update') {
|
|
326
337
|
console.log('[Mandu HMR] Reloading...');
|
|
327
338
|
location.reload();
|
|
339
|
+
} else if (msg.type === 'css-update') {
|
|
340
|
+
var cssPath = (msg.data && msg.data.cssPath) || '/.mandu/client/globals.css';
|
|
341
|
+
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
342
|
+
var updated = false;
|
|
343
|
+
for (var i = 0; i < links.length; i++) {
|
|
344
|
+
var href = links[i].getAttribute('href') || '';
|
|
345
|
+
var base = href.split('?')[0];
|
|
346
|
+
if (base === cssPath || href.includes('globals.css') || href.includes('.mandu/client')) {
|
|
347
|
+
links[i].setAttribute('href', base + '?t=' + Date.now());
|
|
348
|
+
updated = true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (!updated) location.reload();
|
|
328
352
|
} else if (msg.type === 'error') {
|
|
329
|
-
console.error('[Mandu HMR] Build error:', msg.data
|
|
353
|
+
console.error('[Mandu HMR] Build error:', msg.data && msg.data.message);
|
|
330
354
|
}
|
|
331
355
|
} catch(err) {}
|
|
332
356
|
};
|
|
333
|
-
ws.onclose = function() {
|
|
334
|
-
if (reconnectAttempts < maxReconnectAttempts) {
|
|
335
|
-
reconnectAttempts++;
|
|
336
|
-
setTimeout(connect, ${TIMEOUTS.HMR_RECONNECT_DELAY} * reconnectAttempts);
|
|
337
|
-
}
|
|
338
|
-
};
|
|
357
|
+
ws.onclose = function() { scheduleReconnect(); };
|
|
339
358
|
} catch(err) {
|
|
340
|
-
|
|
359
|
+
scheduleReconnect();
|
|
341
360
|
}
|
|
342
361
|
}
|
|
343
362
|
connect();
|
|
@@ -19,6 +19,7 @@ import type { Metadata, MetadataItem } from "../seo/types";
|
|
|
19
19
|
import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
|
|
20
20
|
import { PORTS, TIMEOUTS } from "../constants";
|
|
21
21
|
import { escapeHtmlAttr, escapeJsonForInlineScript, escapeJsString } from "./escape";
|
|
22
|
+
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
22
23
|
|
|
23
24
|
// ========== Types ==========
|
|
24
25
|
|
|
@@ -157,6 +158,7 @@ export interface StreamingLoaderResult<T = unknown> {
|
|
|
157
158
|
*/
|
|
158
159
|
function isJSONSerializable(value: unknown, path: string = "root", isDev: boolean = false): { valid: boolean; issues: string[] } {
|
|
159
160
|
const issues: string[] = [];
|
|
161
|
+
const seen = new WeakSet<object>();
|
|
160
162
|
|
|
161
163
|
function check(val: unknown, currentPath: string): void {
|
|
162
164
|
if (val === undefined) {
|
|
@@ -197,6 +199,12 @@ function isJSONSerializable(value: unknown, path: string = "root", isDev: boolea
|
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
if (type === "object") {
|
|
202
|
+
// 순환 참조 감지 — 무한 재귀 방지
|
|
203
|
+
if (seen.has(val as object)) {
|
|
204
|
+
issues.push(`${currentPath}: 순환 참조가 감지되었습니다 (JSON 직렬화 불가)`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
seen.add(val as object);
|
|
200
208
|
for (const [key, v] of Object.entries(val as Record<string, unknown>)) {
|
|
201
209
|
check(v, `${currentPath}.${key}`);
|
|
202
210
|
}
|
|
@@ -507,6 +515,11 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
507
515
|
scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.runtime)}"></script>`);
|
|
508
516
|
}
|
|
509
517
|
|
|
518
|
+
// 7.5 React internals shim (must run before react-dom/client runs)
|
|
519
|
+
if (hydration && hydration.strategy !== "none") {
|
|
520
|
+
scripts.push(REACT_INTERNALS_SHIM_SCRIPT);
|
|
521
|
+
}
|
|
522
|
+
|
|
510
523
|
// 8. Router 스크립트
|
|
511
524
|
if (enableClientRouter && bundleManifest?.shared?.router) {
|
|
512
525
|
scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.router)}"></script>`);
|