@mandujs/core 0.18.1 → 0.18.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -44,14 +44,14 @@
44
44
  },
45
45
  "peerDependencies": {
46
46
  "react": "^19.0.0",
47
- "react-dom": "^19.0.0",
48
- "zod": ">=3.0.0"
47
+ "react-dom": "^19.0.0"
49
48
  },
50
49
  "dependencies": {
51
50
  "chokidar": "^5.0.0",
52
51
  "fast-glob": "^3.3.2",
53
52
  "glob": "^13.0.0",
54
53
  "minimatch": "^10.1.1",
55
- "ollama": "^0.6.3"
54
+ "ollama": "^0.6.3",
55
+ "zod": "^3.23.8"
56
56
  }
57
57
  }
@@ -3,18 +3,18 @@
3
3
  * Bun.build 기반 클라이언트 번들 빌드
4
4
  */
5
5
 
6
- import type { RoutesManifest, RouteSpec } from "../spec/schema";
7
- import { needsHydration, getRouteHydration } from "../spec/schema";
8
- import type {
9
- BundleResult,
10
- BundleOutput,
11
- BundleManifest,
12
- BundleStats,
13
- BundlerOptions,
14
- } from "./types";
15
- import { HYDRATION } from "../constants";
16
- import path from "path";
17
- import fs from "fs/promises";
6
+ import type { RoutesManifest, RouteSpec } from "../spec/schema";
7
+ import { needsHydration, getRouteHydration } from "../spec/schema";
8
+ import type {
9
+ BundleResult,
10
+ BundleOutput,
11
+ BundleManifest,
12
+ BundleStats,
13
+ BundlerOptions,
14
+ } from "./types";
15
+ import { HYDRATION } from "../constants";
16
+ import path from "path";
17
+ import fs from "fs/promises";
18
18
 
19
19
  /**
20
20
  * 빈 매니페스트 생성
@@ -205,61 +205,88 @@ async function loadAndHydrate(element, src) {
205
205
  // Dynamic import - 이 시점에 Island 모듈 로드
206
206
  const module = await import(src);
207
207
  const island = module.default;
208
+ const data = getServerData(id);
208
209
 
209
- // Island 유효성 검사
210
- if (!island || !island.__mandu_island) {
211
- throw new Error('[Mandu] Invalid island module: ' + id);
212
- }
210
+ // Mandu Island (preferred)
211
+ if (island && island.__mandu_island === true) {
212
+ const { definition } = island;
213
+
214
+ // Island 컴포넌트 (Error Boundary + Loading 지원)
215
+ function IslandComponent() {
216
+ const [isReady, setIsReady] = useState(false);
217
+
218
+ useEffect(() => {
219
+ setIsReady(true);
220
+ }, []);
221
+
222
+ // setup 호출 및 render
223
+ const setupResult = definition.setup(data);
224
+ const content = definition.render(setupResult);
225
+
226
+ // Loading wrapper 적용
227
+ const wrappedContent = definition.loading
228
+ ? React.createElement(IslandLoadingWrapper, {
229
+ loading: definition.loading,
230
+ isReady,
231
+ }, content)
232
+ : content;
233
+
234
+ // Error Boundary 적용
235
+ return React.createElement(IslandErrorBoundary, {
236
+ islandId: id,
237
+ errorBoundary: definition.errorBoundary,
238
+ }, wrappedContent);
239
+ }
213
240
 
214
- const { definition } = island;
215
- const data = getServerData(id);
241
+ // Hydrate (SSR DOM 재사용 + 이벤트 연결)
242
+ const root = hydrateRoot(element, React.createElement(IslandComponent));
243
+ hydratedRoots.set(id, root);
216
244
 
217
- // Island 컴포넌트 (Error Boundary + Loading 지원)
218
- function IslandComponent() {
219
- const [isReady, setIsReady] = useState(false);
220
-
221
- useEffect(() => {
222
- setIsReady(true);
223
- }, []);
224
-
225
- // setup 호출 및 render
226
- const setupResult = definition.setup(data);
227
- const content = definition.render(setupResult);
228
-
229
- // Loading wrapper 적용
230
- const wrappedContent = definition.loading
231
- ? React.createElement(IslandLoadingWrapper, {
232
- loading: definition.loading,
233
- isReady,
234
- }, content)
235
- : content;
236
-
237
- // Error Boundary 적용
238
- return React.createElement(IslandErrorBoundary, {
239
- islandId: id,
240
- errorBoundary: definition.errorBoundary,
241
- }, wrappedContent);
242
- }
245
+ // 완료 표시
246
+ element.setAttribute('data-mandu-hydrated', 'true');
243
247
 
244
- // Hydrate (SSR DOM 재사용 + 이벤트 연결)
245
- const root = hydrateRoot(element, React.createElement(IslandComponent));
246
- hydratedRoots.set(id, root);
248
+ // 성능 마커
249
+ if (performance.mark) {
250
+ performance.mark('mandu-hydrated-' + id);
251
+ }
247
252
 
248
- // 완료 표시
249
- element.setAttribute('data-mandu-hydrated', 'true');
253
+ // 이벤트 발송
254
+ element.dispatchEvent(new CustomEvent('mandu:hydrated', {
255
+ bubbles: true,
256
+ detail: { id, data }
257
+ }));
250
258
 
251
- // 성능 마커
252
- if (performance.mark) {
253
- performance.mark('mandu-hydrated-' + id);
259
+ console.log('[Mandu] Hydrated:', id);
254
260
  }
261
+ // Plain React component fallback (e.g. "use client" pages)
262
+ else if (typeof island === 'function' || React.isValidElement(island)) {
263
+ console.warn('[Mandu] Plain component hydration:', id);
255
264
 
256
- // 이벤트 발송
257
- element.dispatchEvent(new CustomEvent('mandu:hydrated', {
258
- bubbles: true,
259
- detail: { id, data }
260
- }));
265
+ const root = typeof island === 'function'
266
+ ? hydrateRoot(element, React.createElement(island, data))
267
+ : hydrateRoot(element, island);
268
+
269
+ hydratedRoots.set(id, root);
270
+
271
+ // 완료 표시
272
+ element.setAttribute('data-mandu-hydrated', 'true');
273
+
274
+ // 성능 마커
275
+ if (performance.mark) {
276
+ performance.mark('mandu-hydrated-' + id);
277
+ }
278
+
279
+ // 이벤트 발송
280
+ element.dispatchEvent(new CustomEvent('mandu:hydrated', {
281
+ bubbles: true,
282
+ detail: { id, data }
283
+ }));
261
284
 
262
- console.log('[Mandu] Hydrated:', id);
285
+ console.log('[Mandu] Plain component hydrated:', id);
286
+ }
287
+ else {
288
+ throw new Error('[Mandu] Invalid module: expected Mandu island or React component: ' + id);
289
+ }
263
290
  } catch (error) {
264
291
  console.error('[Mandu] Hydration failed for', id, error);
265
292
  element.setAttribute('data-mandu-error', 'true');
@@ -282,7 +309,7 @@ function hydrateIslands() {
282
309
  for (const el of islands) {
283
310
  const id = el.getAttribute('data-mandu-island');
284
311
  const src = el.getAttribute('data-mandu-src');
285
- const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
312
+ const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
286
313
 
287
314
  if (!id || !src) {
288
315
  console.warn('[Mandu] Island missing id or src:', el);
@@ -366,6 +393,8 @@ import React, {
366
393
  Suspense,
367
394
  StrictMode,
368
395
  Profiler,
396
+ // Misc
397
+ version,
369
398
  // Types
370
399
  Component,
371
400
  PureComponent,
@@ -377,7 +406,17 @@ import { jsx, jsxs } from 'react/jsx-runtime';
377
406
  import { jsxDEV } from 'react/jsx-dev-runtime';
378
407
 
379
408
  // React internals (ReactDOM이 내부적으로 접근 필요)
380
- const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
409
+ // React 19+: __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE
410
+ // React <=18: __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED
411
+ const __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE =
412
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE || {};
413
+ const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED =
414
+ React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED || {};
415
+
416
+ // Null safety for Playwright headless browsers (React 19)
417
+ if (__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.S == null) {
418
+ __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.S = function () {};
419
+ }
381
420
 
382
421
  // 전역 React 설정 (모든 모듈에서 동일 인스턴스 공유)
383
422
  if (typeof window !== 'undefined') {
@@ -414,9 +453,11 @@ export {
414
453
  Suspense,
415
454
  StrictMode,
416
455
  Profiler,
456
+ version,
417
457
  Component,
418
458
  PureComponent,
419
459
  Children,
460
+ __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
420
461
  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
421
462
  // JSX Runtime exports
422
463
  jsx,
@@ -812,10 +853,8 @@ async function buildRuntime(
812
853
  },
813
854
  });
814
855
 
815
- // 소스 파일 정리
816
- await fs.unlink(runtimePath).catch(() => {});
817
-
818
856
  if (!result.success) {
857
+ // 실패 시 디버깅을 위해 소스 파일을 남겨둠 (_runtime.src.js)
819
858
  return {
820
859
  success: false,
821
860
  outputPath: "",
@@ -823,17 +862,28 @@ async function buildRuntime(
823
862
  };
824
863
  }
825
864
 
865
+ // 성공 시에만 소스 파일 정리
866
+ await fs.unlink(runtimePath).catch(() => {});
867
+
826
868
  return {
827
869
  success: true,
828
870
  outputPath: `/.mandu/client/${outputName}`,
829
871
  errors: [],
830
872
  };
831
- } catch (error) {
832
- await fs.unlink(runtimePath).catch(() => {});
873
+ } catch (error: any) {
874
+ // 예외 발생 시에도 디버깅을 위해 소스 파일을 남겨둠
875
+ const extra: string[] = [];
876
+ if (error?.errors && Array.isArray(error.errors)) {
877
+ extra.push(...error.errors.map((e: any) => String(e?.message || e)));
878
+ }
879
+ if (error?.logs && Array.isArray(error.logs)) {
880
+ extra.push(...error.logs.map((l: any) => String(l?.message || l)));
881
+ }
882
+
833
883
  return {
834
884
  success: false,
835
885
  outputPath: "",
836
- errors: [String(error)],
886
+ errors: [String(error), ...extra].filter(Boolean),
837
887
  };
838
888
  }
839
889
  }
@@ -855,21 +905,21 @@ interface VendorBuildResult {
855
905
  * Vendor shim 번들 빌드
856
906
  * React, ReactDOM, ReactDOMClient를 각각의 shim으로 빌드
857
907
  */
858
- async function buildVendorShims(
859
- outDir: string,
860
- options: BundlerOptions
861
- ): Promise<VendorBuildResult> {
862
- const errors: string[] = [];
863
- type VendorShimKey = "react" | "reactDom" | "reactDomClient" | "jsxRuntime" | "jsxDevRuntime";
864
- const results: Record<VendorShimKey, string> = {
865
- react: "",
866
- reactDom: "",
867
- reactDomClient: "",
868
- jsxRuntime: "",
869
- jsxDevRuntime: "",
870
- };
871
-
872
- const shims: Array<{ name: string; source: string; key: VendorShimKey }> = [
908
+ async function buildVendorShims(
909
+ outDir: string,
910
+ options: BundlerOptions
911
+ ): Promise<VendorBuildResult> {
912
+ const errors: string[] = [];
913
+ type VendorShimKey = "react" | "reactDom" | "reactDomClient" | "jsxRuntime" | "jsxDevRuntime";
914
+ const results: Record<VendorShimKey, string> = {
915
+ react: "",
916
+ reactDom: "",
917
+ reactDomClient: "",
918
+ jsxRuntime: "",
919
+ jsxDevRuntime: "",
920
+ };
921
+
922
+ const shims: Array<{ name: string; source: string; key: VendorShimKey }> = [
873
923
  { name: "_react", source: generateReactShimSource(), key: "react" },
874
924
  { name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
875
925
  { name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
@@ -877,68 +927,68 @@ async function buildVendorShims(
877
927
  { name: "_jsx-dev-runtime", source: generateJsxDevRuntimeShimSource(), key: "jsxDevRuntime" },
878
928
  ];
879
929
 
880
- const buildShim = async (
881
- shim: { name: string; source: string; key: VendorShimKey }
882
- ): Promise<{ key: VendorShimKey; outputPath?: string; error?: string }> => {
883
- const srcPath = path.join(outDir, `${shim.name}.src.js`);
884
- const outputName = `${shim.name}.js`;
885
-
886
- try {
887
- await Bun.write(srcPath, shim.source);
888
-
889
- // _react.js는 external 없이 React 전체를 번들링
890
- // _react-dom*, jsx-runtime은 react를 external로 처리하여 동일한 React 인스턴스 공유
891
- let shimExternal: string[] = [];
892
- if (shim.name === "_react-dom" || shim.name === "_react-dom-client") {
893
- shimExternal = ["react"];
894
- } else if (shim.name === "_jsx-runtime" || shim.name === "_jsx-dev-runtime") {
895
- shimExternal = ["react"];
896
- }
897
-
898
- const result = await Bun.build({
899
- entrypoints: [srcPath],
900
- outdir: outDir,
901
- naming: outputName,
902
- minify: options.minify ?? process.env.NODE_ENV === "production",
903
- sourcemap: options.sourcemap ? "external" : "none",
904
- target: "browser",
905
- external: shimExternal,
906
- define: {
907
- "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
908
- ...options.define,
909
- },
910
- });
911
-
912
- await fs.unlink(srcPath).catch(() => {});
913
-
914
- if (!result.success) {
915
- return {
916
- key: shim.key,
917
- error: `[${shim.name}] ${result.logs.map((l) => l.message).join(", ")}`,
918
- };
919
- }
920
-
921
- return {
922
- key: shim.key,
923
- outputPath: `/.mandu/client/${outputName}`,
924
- };
925
- } catch (error) {
926
- await fs.unlink(srcPath).catch(() => {});
927
- return {
928
- key: shim.key,
929
- error: `[${shim.name}] ${String(error)}`,
930
- };
931
- }
932
- };
933
-
934
- const buildResults = await Promise.all(shims.map((shim) => buildShim(shim)));
935
- for (const result of buildResults) {
936
- if (result.error) {
937
- errors.push(result.error);
938
- } else if (result.outputPath) {
939
- results[result.key] = result.outputPath;
940
- }
941
- }
930
+ const buildShim = async (
931
+ shim: { name: string; source: string; key: VendorShimKey }
932
+ ): Promise<{ key: VendorShimKey; outputPath?: string; error?: string }> => {
933
+ const srcPath = path.join(outDir, `${shim.name}.src.js`);
934
+ const outputName = `${shim.name}.js`;
935
+
936
+ try {
937
+ await Bun.write(srcPath, shim.source);
938
+
939
+ // _react.js는 external 없이 React 전체를 번들링
940
+ // _react-dom*, jsx-runtime은 react를 external로 처리하여 동일한 React 인스턴스 공유
941
+ let shimExternal: string[] = [];
942
+ if (shim.name === "_react-dom" || shim.name === "_react-dom-client") {
943
+ shimExternal = ["react"];
944
+ } else if (shim.name === "_jsx-runtime" || shim.name === "_jsx-dev-runtime") {
945
+ shimExternal = ["react"];
946
+ }
947
+
948
+ const result = await Bun.build({
949
+ entrypoints: [srcPath],
950
+ outdir: outDir,
951
+ naming: outputName,
952
+ minify: options.minify ?? process.env.NODE_ENV === "production",
953
+ sourcemap: options.sourcemap ? "external" : "none",
954
+ target: "browser",
955
+ external: shimExternal,
956
+ define: {
957
+ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
958
+ ...options.define,
959
+ },
960
+ });
961
+
962
+ await fs.unlink(srcPath).catch(() => {});
963
+
964
+ if (!result.success) {
965
+ return {
966
+ key: shim.key,
967
+ error: `[${shim.name}] ${result.logs.map((l) => l.message).join(", ")}`,
968
+ };
969
+ }
970
+
971
+ return {
972
+ key: shim.key,
973
+ outputPath: `/.mandu/client/${outputName}`,
974
+ };
975
+ } catch (error) {
976
+ await fs.unlink(srcPath).catch(() => {});
977
+ return {
978
+ key: shim.key,
979
+ error: `[${shim.name}] ${String(error)}`,
980
+ };
981
+ }
982
+ };
983
+
984
+ const buildResults = await Promise.all(shims.map((shim) => buildShim(shim)));
985
+ for (const result of buildResults) {
986
+ if (result.error) {
987
+ errors.push(result.error);
988
+ } else if (result.outputPath) {
989
+ results[result.key] = result.outputPath;
990
+ }
991
+ }
942
992
 
943
993
  return {
944
994
  success: errors.length === 0,
@@ -1052,7 +1102,7 @@ function createBundleManifest(
1052
1102
  bundles[output.routeId] = {
1053
1103
  js: output.outputPath,
1054
1104
  dependencies: ["_runtime", "_react"],
1055
- priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1105
+ priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1056
1106
  };
1057
1107
  }
1058
1108
 
@@ -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>`;
@@ -6,6 +6,7 @@ import type { BundleManifest } from "../bundler/types";
6
6
  import type { HydrationConfig, HydrationPriority } from "../spec/schema";
7
7
  import { PORTS, TIMEOUTS } from "../constants";
8
8
  import { escapeHtmlAttr, escapeJsonForInlineScript } from "./escape";
9
+ import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
9
10
 
10
11
  // Re-export streaming SSR utilities
11
12
  export {
@@ -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}
@@ -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
 
@@ -507,6 +508,11 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
507
508
  scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.runtime)}"></script>`);
508
509
  }
509
510
 
511
+ // 7.5 React internals shim (must run before react-dom/client runs)
512
+ if (hydration && hydration.strategy !== "none") {
513
+ scripts.push(REACT_INTERNALS_SHIM_SCRIPT);
514
+ }
515
+
510
516
  // 8. Router 스크립트
511
517
  if (enableClientRouter && bundleManifest?.shared?.router) {
512
518
  scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.router)}"></script>`);