@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.
@@ -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: any,
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: any,
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: any,
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: any,
199
+ definition: ResourceDefinition,
199
200
  resourceName: string,
200
201
  clientDir: string,
201
202
  result: GeneratorResult
@@ -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, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;");
11
+ }
12
+
1
13
  /**
2
14
  * HTML 속성값 이스케이프
3
15
  * XSS 방지를 위해 HTML 속성값에 들어갈 문자열을 안전하게 처리
@@ -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 { PageBoundary, DefaultLoading, DefaultError, type ErrorFallbackProps } from "./boundary";
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>`;
@@ -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>${escapeHtmlAttr(title)}</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?.message);
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
- setTimeout(connect, ${TIMEOUTS.HMR_RECONNECT_DELAY});
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>`);