@mandujs/core 0.19.0 → 0.20.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/README.ko.md +0 -14
- package/package.json +4 -1
- package/src/brain/architecture/analyzer.ts +4 -4
- package/src/brain/doctor/analyzer.ts +18 -14
- package/src/bundler/build.test.ts +127 -0
- package/src/bundler/build.ts +385 -115
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -0
- package/src/bundler/types.ts +20 -0
- package/src/change/snapshot.ts +4 -23
- package/src/change/types.ts +2 -3
- package/src/client/Form.tsx +105 -0
- package/src/client/__tests__/use-sse.test.ts +153 -0
- package/src/client/hooks.ts +105 -6
- package/src/client/index.ts +35 -6
- package/src/client/router.ts +670 -433
- package/src/client/rpc.ts +140 -0
- package/src/client/runtime.ts +24 -21
- package/src/client/use-fetch.ts +239 -0
- package/src/client/use-head.ts +197 -0
- package/src/client/use-sse.ts +378 -0
- package/src/components/Image.tsx +162 -0
- package/src/config/mandu.ts +5 -0
- package/src/config/validate.ts +34 -0
- package/src/content/index.ts +5 -1
- package/src/devtools/client/catchers/error-catcher.ts +17 -0
- package/src/devtools/client/catchers/network-proxy.ts +390 -367
- package/src/devtools/client/components/kitchen-root.tsx +479 -467
- package/src/devtools/client/components/mandu-character.tsx +77 -53
- package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/errors-panel.tsx +2 -2
- package/src/devtools/client/components/panel/guard-panel.tsx +363 -234
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +30 -14
- package/src/devtools/client/components/panel/network-panel.tsx +2 -3
- package/src/devtools/client/components/panel/panel-container.tsx +273 -100
- package/src/devtools/client/components/panel/preview-panel.tsx +212 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/init.ts +1 -1
- package/src/devtools/types.ts +321 -295
- package/src/filling/filling.ts +328 -6
- package/src/filling/index.ts +5 -1
- package/src/filling/session.ts +216 -0
- package/src/filling/ws.ts +78 -0
- package/src/generator/generate.ts +2 -2
- package/src/guard/auto-correct.ts +0 -29
- package/src/guard/check.ts +14 -31
- package/src/guard/presets/index.ts +296 -294
- package/src/guard/rules.ts +15 -19
- package/src/guard/validator.ts +834 -834
- package/src/index.ts +6 -1
- package/src/island/index.ts +373 -304
- package/src/kitchen/api/contract-api.ts +225 -0
- package/src/kitchen/api/diff-parser.ts +108 -0
- package/src/kitchen/api/file-api.ts +273 -0
- package/src/kitchen/api/guard-api.ts +83 -0
- package/src/kitchen/api/guard-decisions.ts +100 -0
- package/src/kitchen/api/routes-api.ts +50 -0
- package/src/kitchen/index.ts +21 -0
- package/src/kitchen/kitchen-handler.ts +335 -0
- package/src/kitchen/kitchen-ui.ts +1732 -0
- package/src/kitchen/stream/activity-sse.ts +145 -0
- package/src/kitchen/stream/file-tailer.ts +99 -0
- package/src/middleware/compress.ts +62 -0
- package/src/middleware/cors.ts +47 -0
- package/src/middleware/index.ts +10 -0
- package/src/middleware/jwt.ts +134 -0
- package/src/middleware/logger.ts +58 -0
- package/src/middleware/timeout.ts +55 -0
- package/src/observability/event-bus.ts +79 -0
- package/src/observability/index.ts +8 -0
- package/src/observability/logger-adapter.ts +36 -0
- package/src/paths.ts +0 -4
- package/src/plugins/hooks.ts +64 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/types.ts +5 -0
- package/src/report/build.ts +0 -6
- package/src/resource/__tests__/backward-compat.test.ts +0 -1
- package/src/router/fs-patterns.ts +11 -1
- package/src/router/fs-routes.ts +78 -14
- package/src/router/fs-scanner.ts +2 -2
- package/src/router/fs-types.ts +2 -1
- package/src/runtime/adapter-bun.ts +62 -0
- package/src/runtime/adapter.ts +47 -0
- package/src/runtime/cache.ts +310 -0
- package/src/runtime/handler.ts +65 -0
- package/src/runtime/image-handler.ts +195 -0
- package/src/runtime/index.ts +13 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/ppr.ts +74 -0
- package/src/runtime/server.ts +706 -71
- package/src/runtime/ssr.ts +70 -31
- package/src/runtime/streaming-ssr.ts +121 -78
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +189 -0
- package/src/watcher/watcher.ts +27 -1
- package/src/spec/lock.ts +0 -56
package/src/bundler/build.ts
CHANGED
|
@@ -11,11 +11,67 @@ import type {
|
|
|
11
11
|
BundleManifest,
|
|
12
12
|
BundleStats,
|
|
13
13
|
BundlerOptions,
|
|
14
|
+
IslandFileEntry,
|
|
14
15
|
} from "./types";
|
|
15
16
|
import { HYDRATION } from "../constants";
|
|
16
17
|
import path from "path";
|
|
17
18
|
import fs from "fs/promises";
|
|
18
19
|
|
|
20
|
+
/** Scan for *.island.tsx / *.island.ts files across hydrated route directories. */
|
|
21
|
+
async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<IslandFileEntry[]> {
|
|
22
|
+
const entries: IslandFileEntry[] = [];
|
|
23
|
+
const seenDirs = new Set<string>();
|
|
24
|
+
|
|
25
|
+
for (const route of routes) {
|
|
26
|
+
const dir = path.dirname(path.join(rootDir, route.componentModule ?? route.module));
|
|
27
|
+
if (seenDirs.has(dir)) continue;
|
|
28
|
+
seenDirs.add(dir);
|
|
29
|
+
|
|
30
|
+
let files: string[];
|
|
31
|
+
try { files = await fs.readdir(dir); } catch { continue; }
|
|
32
|
+
|
|
33
|
+
const priority = getRouteHydration(route)?.priority || HYDRATION.DEFAULT_PRIORITY;
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
if (/\.island\.tsx?$/.test(file)) {
|
|
36
|
+
entries.push({
|
|
37
|
+
name: file.replace(/\.island\.tsx?$/, ""),
|
|
38
|
+
filePath: path.join(dir, file),
|
|
39
|
+
routeId: route.id,
|
|
40
|
+
priority,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build a single per-island bundle. */
|
|
49
|
+
async function buildPerIslandBundle(
|
|
50
|
+
entry: IslandFileEntry, outDir: string, options: BundlerOptions
|
|
51
|
+
): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
|
|
52
|
+
const entryPath = path.join(outDir, `_entry_island_${entry.name}.js`);
|
|
53
|
+
const outputName = `${entry.name}.island.js`;
|
|
54
|
+
try {
|
|
55
|
+
await Bun.write(entryPath, generateIslandEntry(entry.name, entry.filePath));
|
|
56
|
+
const result = await Bun.build({
|
|
57
|
+
entrypoints: [entryPath],
|
|
58
|
+
outdir: outDir,
|
|
59
|
+
naming: outputName,
|
|
60
|
+
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
61
|
+
sourcemap: options.sourcemap ? "external" : "none",
|
|
62
|
+
target: "browser",
|
|
63
|
+
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
64
|
+
define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
|
|
65
|
+
});
|
|
66
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
67
|
+
if (!result.success) throw new Error(result.logs.map((l) => l.message).join("\n"));
|
|
68
|
+
return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
19
75
|
/**
|
|
20
76
|
* 빈 매니페스트 생성
|
|
21
77
|
*/
|
|
@@ -47,6 +103,78 @@ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
|
|
|
47
103
|
);
|
|
48
104
|
}
|
|
49
105
|
|
|
106
|
+
const REACT_SHIM_EXPORTS = [
|
|
107
|
+
"Activity",
|
|
108
|
+
"Children",
|
|
109
|
+
"Component",
|
|
110
|
+
"Fragment",
|
|
111
|
+
"Profiler",
|
|
112
|
+
"PureComponent",
|
|
113
|
+
"StrictMode",
|
|
114
|
+
"Suspense",
|
|
115
|
+
"__COMPILER_RUNTIME",
|
|
116
|
+
"act",
|
|
117
|
+
"cache",
|
|
118
|
+
"cacheSignal",
|
|
119
|
+
"captureOwnerStack",
|
|
120
|
+
"cloneElement",
|
|
121
|
+
"createContext",
|
|
122
|
+
"createElement",
|
|
123
|
+
"createRef",
|
|
124
|
+
"forwardRef",
|
|
125
|
+
"isValidElement",
|
|
126
|
+
"lazy",
|
|
127
|
+
"memo",
|
|
128
|
+
"startTransition",
|
|
129
|
+
"unstable_useCacheRefresh",
|
|
130
|
+
"use",
|
|
131
|
+
"useActionState",
|
|
132
|
+
"useCallback",
|
|
133
|
+
"useContext",
|
|
134
|
+
"useDebugValue",
|
|
135
|
+
"useDeferredValue",
|
|
136
|
+
"useEffect",
|
|
137
|
+
"useEffectEvent",
|
|
138
|
+
"useId",
|
|
139
|
+
"useImperativeHandle",
|
|
140
|
+
"useInsertionEffect",
|
|
141
|
+
"useLayoutEffect",
|
|
142
|
+
"useMemo",
|
|
143
|
+
"useOptimistic",
|
|
144
|
+
"useReducer",
|
|
145
|
+
"useRef",
|
|
146
|
+
"useState",
|
|
147
|
+
"useSyncExternalStore",
|
|
148
|
+
"useTransition",
|
|
149
|
+
"version",
|
|
150
|
+
] as const;
|
|
151
|
+
|
|
152
|
+
const REACT_DOM_SHIM_EXPORTS = [
|
|
153
|
+
"createPortal",
|
|
154
|
+
"flushSync",
|
|
155
|
+
"preconnect",
|
|
156
|
+
"prefetchDNS",
|
|
157
|
+
"preinit",
|
|
158
|
+
"preinitModule",
|
|
159
|
+
"preload",
|
|
160
|
+
"preloadModule",
|
|
161
|
+
"requestFormReset",
|
|
162
|
+
"unstable_batchedUpdates",
|
|
163
|
+
"useFormState",
|
|
164
|
+
"useFormStatus",
|
|
165
|
+
"version",
|
|
166
|
+
] as const;
|
|
167
|
+
|
|
168
|
+
const REACT_DOM_CLIENT_SHIM_EXPORTS = [
|
|
169
|
+
"createRoot",
|
|
170
|
+
"hydrateRoot",
|
|
171
|
+
"version",
|
|
172
|
+
] as const;
|
|
173
|
+
|
|
174
|
+
function formatShimBindings(names: readonly string[], indent = " "): string {
|
|
175
|
+
return names.map((name) => `${indent}${name},`).join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
50
178
|
/**
|
|
51
179
|
* Runtime 번들 소스 생성 (v0.8.0 재설계)
|
|
52
180
|
*
|
|
@@ -66,7 +194,7 @@ function generateRuntimeSource(): string {
|
|
|
66
194
|
|
|
67
195
|
// React 정적 import (Island와 같은 인스턴스 공유)
|
|
68
196
|
import React, { useState, useEffect, Component } from 'react';
|
|
69
|
-
import { hydrateRoot } from 'react-dom/client';
|
|
197
|
+
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
70
198
|
|
|
71
199
|
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
72
200
|
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
@@ -147,6 +275,67 @@ function IslandLoadingWrapper({ children, loading, isReady }) {
|
|
|
147
275
|
return children;
|
|
148
276
|
}
|
|
149
277
|
|
|
278
|
+
function resolveHydrationTarget(element) {
|
|
279
|
+
if (!(element instanceof HTMLElement)) {
|
|
280
|
+
return element;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (getComputedStyle(element).display !== 'contents') {
|
|
284
|
+
return element;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const queue = Array.from(element.children);
|
|
288
|
+
while (queue.length > 0) {
|
|
289
|
+
const candidate = queue.shift();
|
|
290
|
+
if (candidate instanceof HTMLElement) {
|
|
291
|
+
return candidate;
|
|
292
|
+
}
|
|
293
|
+
if (candidate) {
|
|
294
|
+
queue.push(...candidate.children);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return element.parentElement || element;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function hasHydratableMarkup(element) {
|
|
302
|
+
for (const node of element.childNodes) {
|
|
303
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== '') {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function shouldHydrateCompiledIsland(element) {
|
|
316
|
+
return (
|
|
317
|
+
element.getAttribute('data-mandu-loading') !== 'true' &&
|
|
318
|
+
hasHydratableMarkup(element)
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function createHydrationOptions(element, id, mode) {
|
|
323
|
+
return {
|
|
324
|
+
onRecoverableError(error) {
|
|
325
|
+
element.setAttribute('data-mandu-recoverable-error', 'true');
|
|
326
|
+
console.warn('[Mandu] Recoverable hydration error:', id, mode, error);
|
|
327
|
+
element.dispatchEvent(new CustomEvent('mandu:recoverable-hydration-error', {
|
|
328
|
+
bubbles: true,
|
|
329
|
+
detail: {
|
|
330
|
+
id,
|
|
331
|
+
mode,
|
|
332
|
+
error: error instanceof Error ? error.message : String(error),
|
|
333
|
+
},
|
|
334
|
+
}));
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
150
339
|
/**
|
|
151
340
|
* Hydration 스케줄러
|
|
152
341
|
*/
|
|
@@ -164,7 +353,8 @@ function scheduleHydration(element, src, priority) {
|
|
|
164
353
|
loadAndHydrate(element, src);
|
|
165
354
|
}
|
|
166
355
|
}, { rootMargin: '50px' });
|
|
167
|
-
|
|
356
|
+
const target = resolveHydrationTarget(element);
|
|
357
|
+
observer.observe(target);
|
|
168
358
|
} else {
|
|
169
359
|
loadAndHydrate(element, src);
|
|
170
360
|
}
|
|
@@ -179,15 +369,20 @@ function scheduleHydration(element, src, priority) {
|
|
|
179
369
|
break;
|
|
180
370
|
|
|
181
371
|
case 'interaction': {
|
|
372
|
+
const target = resolveHydrationTarget(element);
|
|
182
373
|
const hydrate = () => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
374
|
+
target.removeEventListener('mouseenter', hydrate);
|
|
375
|
+
target.removeEventListener('focusin', hydrate);
|
|
376
|
+
target.removeEventListener('touchstart', hydrate);
|
|
377
|
+
target.removeEventListener('pointerdown', hydrate);
|
|
378
|
+
target.removeEventListener('keydown', hydrate);
|
|
186
379
|
loadAndHydrate(element, src);
|
|
187
380
|
};
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
381
|
+
target.addEventListener('mouseenter', hydrate, { once: true, passive: true });
|
|
382
|
+
target.addEventListener('focusin', hydrate, { once: true });
|
|
383
|
+
target.addEventListener('touchstart', hydrate, { once: true, passive: true });
|
|
384
|
+
target.addEventListener('pointerdown', hydrate, { once: true, passive: true });
|
|
385
|
+
target.addEventListener('keydown', hydrate, { once: true });
|
|
191
386
|
break;
|
|
192
387
|
}
|
|
193
388
|
}
|
|
@@ -200,20 +395,47 @@ function scheduleHydration(element, src, priority) {
|
|
|
200
395
|
*/
|
|
201
396
|
async function loadAndHydrate(element, src) {
|
|
202
397
|
const id = element.getAttribute('data-mandu-island');
|
|
398
|
+
if (!id) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (
|
|
403
|
+
hydratedRoots.has(id) ||
|
|
404
|
+
element.hasAttribute('data-mandu-hydrated') ||
|
|
405
|
+
element.getAttribute('data-mandu-hydrating') === 'true'
|
|
406
|
+
) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
element.setAttribute('data-mandu-hydrating', 'true');
|
|
203
411
|
|
|
204
412
|
try {
|
|
205
413
|
// Dynamic import - 이 시점에 Island 모듈 로드
|
|
206
414
|
const module = await import(src);
|
|
207
415
|
const island = module.default;
|
|
208
|
-
|
|
416
|
+
let data = getServerData(id);
|
|
417
|
+
|
|
418
|
+
// Fallback: read data-props from child element if __MANDU_DATA__ is empty
|
|
419
|
+
if (!data || Object.keys(data).length === 0) {
|
|
420
|
+
const propsEl = element.querySelector('[data-props]');
|
|
421
|
+
if (propsEl) {
|
|
422
|
+
try {
|
|
423
|
+
data = JSON.parse(propsEl.getAttribute('data-props'));
|
|
424
|
+
} catch (e) {
|
|
425
|
+
console.warn('[Mandu] Failed to parse data-props fallback:', e);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
209
429
|
|
|
210
430
|
// Mandu Island (preferred)
|
|
211
431
|
if (island && island.__mandu_island === true) {
|
|
212
432
|
const { definition } = island;
|
|
433
|
+
const shouldHydrate = shouldHydrateCompiledIsland(element);
|
|
434
|
+
const renderMode = shouldHydrate ? 'hydrate' : 'mount';
|
|
213
435
|
|
|
214
436
|
// Island 컴포넌트 (Error Boundary + Loading 지원)
|
|
215
|
-
function IslandComponent() {
|
|
216
|
-
const [isReady, setIsReady] = useState(
|
|
437
|
+
function IslandComponent({ initialReady }) {
|
|
438
|
+
const [isReady, setIsReady] = useState(initialReady);
|
|
217
439
|
|
|
218
440
|
useEffect(() => {
|
|
219
441
|
setIsReady(true);
|
|
@@ -238,11 +460,22 @@ async function loadAndHydrate(element, src) {
|
|
|
238
460
|
}, wrappedContent);
|
|
239
461
|
}
|
|
240
462
|
|
|
241
|
-
|
|
242
|
-
|
|
463
|
+
const root = shouldHydrate
|
|
464
|
+
? hydrateRoot(
|
|
465
|
+
element,
|
|
466
|
+
React.createElement(IslandComponent, { initialReady: true }),
|
|
467
|
+
createHydrationOptions(element, id, renderMode)
|
|
468
|
+
)
|
|
469
|
+
: createRoot(element);
|
|
470
|
+
|
|
471
|
+
if (!shouldHydrate) {
|
|
472
|
+
root.render(React.createElement(IslandComponent, { initialReady: false }));
|
|
473
|
+
}
|
|
474
|
+
|
|
243
475
|
hydratedRoots.set(id, root);
|
|
244
476
|
|
|
245
477
|
// 완료 표시
|
|
478
|
+
element.setAttribute('data-mandu-render-mode', renderMode);
|
|
246
479
|
element.setAttribute('data-mandu-hydrated', 'true');
|
|
247
480
|
|
|
248
481
|
// 성능 마커
|
|
@@ -253,22 +486,47 @@ async function loadAndHydrate(element, src) {
|
|
|
253
486
|
// 이벤트 발송
|
|
254
487
|
element.dispatchEvent(new CustomEvent('mandu:hydrated', {
|
|
255
488
|
bubbles: true,
|
|
256
|
-
detail: { id, data }
|
|
489
|
+
detail: { id, data, mode: renderMode }
|
|
257
490
|
}));
|
|
258
491
|
|
|
259
|
-
|
|
492
|
+
// Kitchen DevTools에 island 등록
|
|
493
|
+
if (window.__MANDU_DEVTOOLS_HOOK__) {
|
|
494
|
+
const hydrateTime = performance.now ? performance.now() : Date.now();
|
|
495
|
+
window.__MANDU_DEVTOOLS_HOOK__.emit({
|
|
496
|
+
type: 'island:register',
|
|
497
|
+
timestamp: Date.now(),
|
|
498
|
+
data: {
|
|
499
|
+
id,
|
|
500
|
+
name: id,
|
|
501
|
+
strategy: element.getAttribute('data-mandu-priority') || 'visible',
|
|
502
|
+
status: 'hydrated',
|
|
503
|
+
renderMode,
|
|
504
|
+
hydrateStartTime: hydrateTime - 10,
|
|
505
|
+
hydrateEndTime: hydrateTime,
|
|
506
|
+
propsSize: JSON.stringify(data).length,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
console.log('[Mandu] Hydrated:', id, '(' + renderMode + ')');
|
|
260
512
|
}
|
|
261
513
|
// Plain React component fallback (e.g. "use client" pages)
|
|
262
514
|
else if (typeof island === 'function' || React.isValidElement(island)) {
|
|
263
515
|
console.warn('[Mandu] Plain component hydration:', id);
|
|
516
|
+
const renderMode = 'hydrate';
|
|
264
517
|
|
|
265
518
|
const root = typeof island === 'function'
|
|
266
|
-
? hydrateRoot(
|
|
267
|
-
|
|
519
|
+
? hydrateRoot(
|
|
520
|
+
element,
|
|
521
|
+
React.createElement(island, data),
|
|
522
|
+
createHydrationOptions(element, id, renderMode)
|
|
523
|
+
)
|
|
524
|
+
: hydrateRoot(element, island, createHydrationOptions(element, id, renderMode));
|
|
268
525
|
|
|
269
526
|
hydratedRoots.set(id, root);
|
|
270
527
|
|
|
271
528
|
// 완료 표시
|
|
529
|
+
element.setAttribute('data-mandu-render-mode', renderMode);
|
|
272
530
|
element.setAttribute('data-mandu-hydrated', 'true');
|
|
273
531
|
|
|
274
532
|
// 성능 마커
|
|
@@ -279,10 +537,10 @@ async function loadAndHydrate(element, src) {
|
|
|
279
537
|
// 이벤트 발송
|
|
280
538
|
element.dispatchEvent(new CustomEvent('mandu:hydrated', {
|
|
281
539
|
bubbles: true,
|
|
282
|
-
detail: { id, data }
|
|
540
|
+
detail: { id, data, mode: renderMode }
|
|
283
541
|
}));
|
|
284
542
|
|
|
285
|
-
console.log('[Mandu] Plain component hydrated:', id);
|
|
543
|
+
console.log('[Mandu] Plain component hydrated:', id, '(' + renderMode + ')');
|
|
286
544
|
}
|
|
287
545
|
else {
|
|
288
546
|
throw new Error('[Mandu] Invalid module: expected Mandu island or React component: ' + id);
|
|
@@ -296,6 +554,8 @@ async function loadAndHydrate(element, src) {
|
|
|
296
554
|
bubbles: true,
|
|
297
555
|
detail: { id, error: error.message }
|
|
298
556
|
}));
|
|
557
|
+
} finally {
|
|
558
|
+
element.removeAttribute('data-mandu-hydrating');
|
|
299
559
|
}
|
|
300
560
|
}
|
|
301
561
|
|
|
@@ -363,42 +623,7 @@ function generateReactShimSource(): string {
|
|
|
363
623
|
* import map을 통해 bare specifier 해결
|
|
364
624
|
*/
|
|
365
625
|
import React, {
|
|
366
|
-
|
|
367
|
-
createElement,
|
|
368
|
-
cloneElement,
|
|
369
|
-
createContext,
|
|
370
|
-
createRef,
|
|
371
|
-
forwardRef,
|
|
372
|
-
isValidElement,
|
|
373
|
-
memo,
|
|
374
|
-
lazy,
|
|
375
|
-
// Hooks
|
|
376
|
-
useState,
|
|
377
|
-
useEffect,
|
|
378
|
-
useContext,
|
|
379
|
-
useReducer,
|
|
380
|
-
useCallback,
|
|
381
|
-
useMemo,
|
|
382
|
-
useRef,
|
|
383
|
-
useLayoutEffect,
|
|
384
|
-
useImperativeHandle,
|
|
385
|
-
useDebugValue,
|
|
386
|
-
useDeferredValue,
|
|
387
|
-
useTransition,
|
|
388
|
-
useId,
|
|
389
|
-
useSyncExternalStore,
|
|
390
|
-
useInsertionEffect,
|
|
391
|
-
// Components
|
|
392
|
-
Fragment,
|
|
393
|
-
Suspense,
|
|
394
|
-
StrictMode,
|
|
395
|
-
Profiler,
|
|
396
|
-
// Misc
|
|
397
|
-
version,
|
|
398
|
-
// Types
|
|
399
|
-
Component,
|
|
400
|
-
PureComponent,
|
|
401
|
-
Children,
|
|
626
|
+
${formatShimBindings(REACT_SHIM_EXPORTS)}
|
|
402
627
|
} from 'react';
|
|
403
628
|
|
|
404
629
|
// JSX Runtime functions (JSX 변환에 필요)
|
|
@@ -426,37 +651,7 @@ if (typeof window !== 'undefined') {
|
|
|
426
651
|
|
|
427
652
|
// Named exports
|
|
428
653
|
export {
|
|
429
|
-
|
|
430
|
-
cloneElement,
|
|
431
|
-
createContext,
|
|
432
|
-
createRef,
|
|
433
|
-
forwardRef,
|
|
434
|
-
isValidElement,
|
|
435
|
-
memo,
|
|
436
|
-
lazy,
|
|
437
|
-
useState,
|
|
438
|
-
useEffect,
|
|
439
|
-
useContext,
|
|
440
|
-
useReducer,
|
|
441
|
-
useCallback,
|
|
442
|
-
useMemo,
|
|
443
|
-
useRef,
|
|
444
|
-
useLayoutEffect,
|
|
445
|
-
useImperativeHandle,
|
|
446
|
-
useDebugValue,
|
|
447
|
-
useDeferredValue,
|
|
448
|
-
useTransition,
|
|
449
|
-
useId,
|
|
450
|
-
useSyncExternalStore,
|
|
451
|
-
useInsertionEffect,
|
|
452
|
-
Fragment,
|
|
453
|
-
Suspense,
|
|
454
|
-
StrictMode,
|
|
455
|
-
Profiler,
|
|
456
|
-
version,
|
|
457
|
-
Component,
|
|
458
|
-
PureComponent,
|
|
459
|
-
Children,
|
|
654
|
+
${formatShimBindings(REACT_SHIM_EXPORTS)}
|
|
460
655
|
__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,
|
|
461
656
|
__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
|
|
462
657
|
// JSX Runtime exports
|
|
@@ -480,16 +675,12 @@ function generateReactDOMShimSource(): string {
|
|
|
480
675
|
* Mandu React DOM Shim (Generated)
|
|
481
676
|
*/
|
|
482
677
|
import ReactDOM, {
|
|
483
|
-
|
|
484
|
-
flushSync,
|
|
485
|
-
version,
|
|
678
|
+
${formatShimBindings(REACT_DOM_SHIM_EXPORTS)}
|
|
486
679
|
} from 'react-dom';
|
|
487
680
|
|
|
488
681
|
// Named exports
|
|
489
682
|
export {
|
|
490
|
-
|
|
491
|
-
flushSync,
|
|
492
|
-
version,
|
|
683
|
+
${formatShimBindings(REACT_DOM_SHIM_EXPORTS)}
|
|
493
684
|
};
|
|
494
685
|
|
|
495
686
|
// Default export
|
|
@@ -506,13 +697,19 @@ function generateReactDOMClientShimSource(): string {
|
|
|
506
697
|
/**
|
|
507
698
|
* Mandu React DOM Client Shim (Generated)
|
|
508
699
|
*/
|
|
509
|
-
import {
|
|
700
|
+
import {
|
|
701
|
+
${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
|
|
702
|
+
} from 'react-dom/client';
|
|
510
703
|
|
|
511
704
|
// Named exports (명시적으로 re-export)
|
|
512
|
-
export {
|
|
705
|
+
export {
|
|
706
|
+
${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
|
|
707
|
+
};
|
|
513
708
|
|
|
514
709
|
// Default export
|
|
515
|
-
export default {
|
|
710
|
+
export default {
|
|
711
|
+
${formatShimBindings(REACT_DOM_CLIENT_SHIM_EXPORTS)}
|
|
712
|
+
};
|
|
516
713
|
`;
|
|
517
714
|
}
|
|
518
715
|
|
|
@@ -1087,7 +1284,7 @@ async function buildIsland(
|
|
|
1087
1284
|
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
1088
1285
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
1089
1286
|
target: "browser",
|
|
1090
|
-
splitting: options.splitting ??
|
|
1287
|
+
splitting: options.splitting ?? (process.env.NODE_ENV === "production"),
|
|
1091
1288
|
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
1092
1289
|
define: {
|
|
1093
1290
|
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
@@ -1151,7 +1348,8 @@ function createBundleManifest(
|
|
|
1151
1348
|
runtimePath: string,
|
|
1152
1349
|
vendorResult: VendorBuildResult,
|
|
1153
1350
|
routerPath: string,
|
|
1154
|
-
env: "development" | "production"
|
|
1351
|
+
env: "development" | "production",
|
|
1352
|
+
islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>
|
|
1155
1353
|
): BundleManifest {
|
|
1156
1354
|
const bundles: BundleManifest["bundles"] = {};
|
|
1157
1355
|
|
|
@@ -1166,11 +1364,25 @@ function createBundleManifest(
|
|
|
1166
1364
|
};
|
|
1167
1365
|
}
|
|
1168
1366
|
|
|
1367
|
+
// Per-island bundles (code splitting)
|
|
1368
|
+
let islands: BundleManifest["islands"];
|
|
1369
|
+
if (islandBundles && islandBundles.length > 0) {
|
|
1370
|
+
islands = {};
|
|
1371
|
+
for (const ib of islandBundles) {
|
|
1372
|
+
islands[ib.name] = {
|
|
1373
|
+
js: ib.js,
|
|
1374
|
+
route: ib.route,
|
|
1375
|
+
priority: ib.priority,
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1169
1380
|
return {
|
|
1170
1381
|
version: 1,
|
|
1171
1382
|
buildTime: new Date().toISOString(),
|
|
1172
1383
|
env,
|
|
1173
1384
|
bundles,
|
|
1385
|
+
...(islands ? { islands } : {}),
|
|
1174
1386
|
shared: {
|
|
1175
1387
|
runtime: runtimePath,
|
|
1176
1388
|
vendor: vendorResult.react, // primary vendor for backwards compatibility
|
|
@@ -1305,24 +1517,28 @@ export async function buildClientBundles(
|
|
|
1305
1517
|
return buildClientBundles(manifest, rootDir, { ...options, targetRouteIds: undefined });
|
|
1306
1518
|
}
|
|
1307
1519
|
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1520
|
+
// Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
|
|
1521
|
+
if (outputs.length > 0) {
|
|
1522
|
+
for (const output of outputs) {
|
|
1523
|
+
if (existingManifest.bundles[output.routeId]) {
|
|
1524
|
+
existingManifest.bundles[output.routeId].js = output.outputPath;
|
|
1525
|
+
} else {
|
|
1526
|
+
const route = targetRoutes.find((r) => r.id === output.routeId);
|
|
1527
|
+
const hydration = route ? getRouteHydration(route) : null;
|
|
1528
|
+
existingManifest.bundles[output.routeId] = {
|
|
1529
|
+
js: output.outputPath,
|
|
1530
|
+
dependencies: ["_runtime", "_react"],
|
|
1531
|
+
priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1319
1534
|
}
|
|
1320
|
-
}
|
|
1321
1535
|
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1536
|
+
await fs.writeFile(
|
|
1537
|
+
path.join(rootDir, ".mandu/manifest.json"),
|
|
1538
|
+
JSON.stringify(existingManifest, null, 2)
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
// When all builds failed, do NOT overwrite manifest — keep previous good state
|
|
1326
1542
|
|
|
1327
1543
|
const stats = calculateStats(outputs, startTime);
|
|
1328
1544
|
return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
|
|
@@ -1356,13 +1572,66 @@ export async function buildClientBundles(
|
|
|
1356
1572
|
console.warn("[Mandu] DevTools bundle build failed:", devtoolsResult.errors.join(", "));
|
|
1357
1573
|
}
|
|
1358
1574
|
|
|
1575
|
+
// 4.5. Pre-build validation: detect wrong import paths in island files
|
|
1576
|
+
for (const route of hydratedRoutes) {
|
|
1577
|
+
if (route.clientModule) {
|
|
1578
|
+
const clientModulePath = path.join(rootDir, route.clientModule);
|
|
1579
|
+
try {
|
|
1580
|
+
const source = await fs.readFile(clientModulePath, "utf-8");
|
|
1581
|
+
// Match imports from "@mandujs/core" but NOT "@mandujs/core/client" or other subpaths
|
|
1582
|
+
const wrongImportPattern = /(?:import|from)\s+['"]@mandujs\/core['"]|require\s*\(\s*['"]@mandujs\/core['"]\s*\)/;
|
|
1583
|
+
if (wrongImportPattern.test(source)) {
|
|
1584
|
+
const errMsg =
|
|
1585
|
+
`[${route.id}] Island file "${route.clientModule}" imports from "@mandujs/core" which is a server-side module.\n` +
|
|
1586
|
+
` Fix: Change the import to "@mandujs/core/client".\n` +
|
|
1587
|
+
` Client islands cannot use server-side modules.`;
|
|
1588
|
+
console.error(`\n\x1b[31mERROR: ${errMsg}\x1b[0m\n`);
|
|
1589
|
+
errors.push(errMsg);
|
|
1590
|
+
}
|
|
1591
|
+
} catch {
|
|
1592
|
+
// File read failure will be caught later during build
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1359
1597
|
// 5. 각 Island 번들 빌드
|
|
1360
1598
|
for (const route of hydratedRoutes) {
|
|
1361
1599
|
try {
|
|
1362
1600
|
const result = await buildIsland(route, rootDir, outDir, options);
|
|
1363
1601
|
outputs.push(result);
|
|
1364
1602
|
} catch (error) {
|
|
1365
|
-
|
|
1603
|
+
const errorStr = String(error);
|
|
1604
|
+
// Detect common mistake: importing @mandujs/core (server module) in client island
|
|
1605
|
+
if (errorStr.includes("AggregateError") || errorStr.includes("Could not resolve")) {
|
|
1606
|
+
const clientModule = route.clientModule || "";
|
|
1607
|
+
errors.push(
|
|
1608
|
+
`[${route.id}] ${errorStr}\n` +
|
|
1609
|
+
` 💡 Hint: If your island imports from "@mandujs/core", change it to "@mandujs/core/client".\n` +
|
|
1610
|
+
` Client islands cannot use server-side modules. File: ${clientModule}`
|
|
1611
|
+
);
|
|
1612
|
+
} else {
|
|
1613
|
+
errors.push(`[${route.id}] ${errorStr}`);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// 5.5. Per-island code splitting: scan and build individual island bundles
|
|
1619
|
+
const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
|
|
1620
|
+
const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
|
|
1621
|
+
|
|
1622
|
+
if (islandFiles.length > 0) {
|
|
1623
|
+
const islandResults = await Promise.all(
|
|
1624
|
+
islandFiles.map(async (entry) => {
|
|
1625
|
+
try {
|
|
1626
|
+
return await buildPerIslandBundle(entry, outDir, options);
|
|
1627
|
+
} catch (error) {
|
|
1628
|
+
errors.push(`[island:${entry.name}] ${String(error)}`);
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
})
|
|
1632
|
+
);
|
|
1633
|
+
for (const result of islandResults) {
|
|
1634
|
+
if (result) islandBundles.push(result);
|
|
1366
1635
|
}
|
|
1367
1636
|
}
|
|
1368
1637
|
|
|
@@ -1373,7 +1642,8 @@ export async function buildClientBundles(
|
|
|
1373
1642
|
runtimeResult.outputPath,
|
|
1374
1643
|
vendorResult,
|
|
1375
1644
|
routerResult.outputPath,
|
|
1376
|
-
env
|
|
1645
|
+
env,
|
|
1646
|
+
islandBundles
|
|
1377
1647
|
);
|
|
1378
1648
|
|
|
1379
1649
|
await fs.writeFile(
|