@mandujs/core 0.5.2 → 0.5.4
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/build.ts +121 -61
- package/src/bundler/types.ts +5 -1
- package/src/client/index.ts +9 -1
- package/src/client/island.ts +59 -0
- package/src/runtime/ssr.ts +18 -5
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -28,6 +28,9 @@ function createEmptyManifest(env: "development" | "production"): BundleManifest
|
|
|
28
28
|
runtime: "",
|
|
29
29
|
vendor: "",
|
|
30
30
|
},
|
|
31
|
+
importMap: {
|
|
32
|
+
imports: {},
|
|
33
|
+
},
|
|
31
34
|
};
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -119,7 +122,8 @@ function scheduleHydration(element, id, priority, data) {
|
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
/**
|
|
122
|
-
* 단일 Island hydrate
|
|
125
|
+
* 단일 Island hydrate (또는 mount)
|
|
126
|
+
* SSR 플레이스홀더를 Island 컴포넌트로 교체
|
|
123
127
|
*/
|
|
124
128
|
async function hydrateIsland(element, id, data) {
|
|
125
129
|
const loader = islandRegistry.get(id);
|
|
@@ -138,7 +142,7 @@ async function hydrateIsland(element, id, data) {
|
|
|
138
142
|
}
|
|
139
143
|
|
|
140
144
|
const { definition } = islandDef;
|
|
141
|
-
const {
|
|
145
|
+
const { createRoot } = await import('react-dom/client');
|
|
142
146
|
const React = await import('react');
|
|
143
147
|
|
|
144
148
|
// Island 컴포넌트
|
|
@@ -147,8 +151,10 @@ async function hydrateIsland(element, id, data) {
|
|
|
147
151
|
return definition.render(setupResult);
|
|
148
152
|
}
|
|
149
153
|
|
|
150
|
-
//
|
|
151
|
-
|
|
154
|
+
// Mount (createRoot 사용 - SSR 플레이스홀더 교체)
|
|
155
|
+
// hydrateRoot 대신 createRoot 사용: Island는 SSR과 다른 컨텐츠를 렌더링할 수 있음
|
|
156
|
+
const root = createRoot(element);
|
|
157
|
+
root.render(React.createElement(IslandComponent));
|
|
152
158
|
hydratedRoots.set(id, root);
|
|
153
159
|
|
|
154
160
|
// 완료 표시
|
|
@@ -201,18 +207,45 @@ export { islandRegistry, hydratedRoots };
|
|
|
201
207
|
}
|
|
202
208
|
|
|
203
209
|
/**
|
|
204
|
-
*
|
|
210
|
+
* React shim 소스 생성 (import map용)
|
|
205
211
|
*/
|
|
206
|
-
function
|
|
212
|
+
function generateReactShimSource(): string {
|
|
207
213
|
return `
|
|
208
214
|
/**
|
|
209
|
-
* Mandu
|
|
210
|
-
*
|
|
215
|
+
* Mandu React Shim (Generated)
|
|
216
|
+
* import map을 통해 bare specifier 해결
|
|
211
217
|
*/
|
|
218
|
+
import * as React from 'react';
|
|
219
|
+
export * from 'react';
|
|
220
|
+
export default React;
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
212
223
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
224
|
+
/**
|
|
225
|
+
* React DOM shim 소스 생성
|
|
226
|
+
*/
|
|
227
|
+
function generateReactDOMShimSource(): string {
|
|
228
|
+
return `
|
|
229
|
+
/**
|
|
230
|
+
* Mandu React DOM Shim (Generated)
|
|
231
|
+
*/
|
|
232
|
+
import * as ReactDOM from 'react-dom';
|
|
233
|
+
export * from 'react-dom';
|
|
234
|
+
export default ReactDOM;
|
|
235
|
+
`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* React DOM Client shim 소스 생성
|
|
240
|
+
*/
|
|
241
|
+
function generateReactDOMClientShimSource(): string {
|
|
242
|
+
return `
|
|
243
|
+
/**
|
|
244
|
+
* Mandu React DOM Client Shim (Generated)
|
|
245
|
+
*/
|
|
246
|
+
import * as ReactDOMClient from 'react-dom/client';
|
|
247
|
+
export * from 'react-dom/client';
|
|
248
|
+
export default ReactDOMClient;
|
|
216
249
|
`;
|
|
217
250
|
}
|
|
218
251
|
|
|
@@ -292,57 +325,77 @@ async function buildRuntime(
|
|
|
292
325
|
}
|
|
293
326
|
|
|
294
327
|
/**
|
|
295
|
-
* Vendor 번들 빌드
|
|
328
|
+
* Vendor shim 번들 빌드 결과
|
|
329
|
+
*/
|
|
330
|
+
interface VendorBuildResult {
|
|
331
|
+
success: boolean;
|
|
332
|
+
react: string;
|
|
333
|
+
reactDom: string;
|
|
334
|
+
reactDomClient: string;
|
|
335
|
+
errors: string[];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Vendor shim 번들 빌드
|
|
340
|
+
* React, ReactDOM, ReactDOMClient를 각각의 shim으로 빌드
|
|
296
341
|
*/
|
|
297
|
-
async function
|
|
342
|
+
async function buildVendorShims(
|
|
298
343
|
outDir: string,
|
|
299
344
|
options: BundlerOptions
|
|
300
|
-
): Promise<
|
|
301
|
-
const
|
|
302
|
-
const
|
|
345
|
+
): Promise<VendorBuildResult> {
|
|
346
|
+
const errors: string[] = [];
|
|
347
|
+
const results: Record<string, string> = {
|
|
348
|
+
react: "",
|
|
349
|
+
reactDom: "",
|
|
350
|
+
reactDomClient: "",
|
|
351
|
+
};
|
|
303
352
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
353
|
+
const shims = [
|
|
354
|
+
{ name: "_react", source: generateReactShimSource(), key: "react" },
|
|
355
|
+
{ name: "_react-dom", source: generateReactDOMShimSource(), key: "reactDom" },
|
|
356
|
+
{ name: "_react-dom-client", source: generateReactDOMClientShimSource(), key: "reactDomClient" },
|
|
357
|
+
];
|
|
307
358
|
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
outdir: outDir,
|
|
312
|
-
naming: outputName,
|
|
313
|
-
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
314
|
-
sourcemap: options.sourcemap ? "external" : "none",
|
|
315
|
-
target: "browser",
|
|
316
|
-
define: {
|
|
317
|
-
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
318
|
-
...options.define,
|
|
319
|
-
},
|
|
320
|
-
});
|
|
359
|
+
for (const shim of shims) {
|
|
360
|
+
const srcPath = path.join(outDir, `${shim.name}.src.js`);
|
|
361
|
+
const outputName = `${shim.name}.js`;
|
|
321
362
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
363
|
+
try {
|
|
364
|
+
await Bun.write(srcPath, shim.source);
|
|
365
|
+
|
|
366
|
+
const result = await Bun.build({
|
|
367
|
+
entrypoints: [srcPath],
|
|
368
|
+
outdir: outDir,
|
|
369
|
+
naming: outputName,
|
|
370
|
+
minify: options.minify ?? process.env.NODE_ENV === "production",
|
|
371
|
+
sourcemap: options.sourcemap ? "external" : "none",
|
|
372
|
+
target: "browser",
|
|
373
|
+
define: {
|
|
374
|
+
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
|
|
375
|
+
...options.define,
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await fs.unlink(srcPath).catch(() => {});
|
|
380
|
+
|
|
381
|
+
if (!result.success) {
|
|
382
|
+
errors.push(`[${shim.name}] ${result.logs.map((l) => l.message).join(", ")}`);
|
|
383
|
+
} else {
|
|
384
|
+
results[shim.key] = `/.mandu/client/${outputName}`;
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
await fs.unlink(srcPath).catch(() => {});
|
|
388
|
+
errors.push(`[${shim.name}] ${String(error)}`);
|
|
331
389
|
}
|
|
332
|
-
|
|
333
|
-
return {
|
|
334
|
-
success: true,
|
|
335
|
-
outputPath: `/.mandu/client/${outputName}`,
|
|
336
|
-
errors: [],
|
|
337
|
-
};
|
|
338
|
-
} catch (error) {
|
|
339
|
-
await fs.unlink(vendorPath).catch(() => {});
|
|
340
|
-
return {
|
|
341
|
-
success: false,
|
|
342
|
-
outputPath: "",
|
|
343
|
-
errors: [String(error)],
|
|
344
|
-
};
|
|
345
390
|
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
success: errors.length === 0,
|
|
394
|
+
react: results.react,
|
|
395
|
+
reactDom: results.reactDom,
|
|
396
|
+
reactDomClient: results.reactDomClient,
|
|
397
|
+
errors,
|
|
398
|
+
};
|
|
346
399
|
}
|
|
347
400
|
|
|
348
401
|
/**
|
|
@@ -411,7 +464,7 @@ function createBundleManifest(
|
|
|
411
464
|
outputs: BundleOutput[],
|
|
412
465
|
routes: RouteSpec[],
|
|
413
466
|
runtimePath: string,
|
|
414
|
-
|
|
467
|
+
vendorResult: VendorBuildResult,
|
|
415
468
|
env: "development" | "production"
|
|
416
469
|
): BundleManifest {
|
|
417
470
|
const bundles: BundleManifest["bundles"] = {};
|
|
@@ -422,7 +475,7 @@ function createBundleManifest(
|
|
|
422
475
|
|
|
423
476
|
bundles[output.routeId] = {
|
|
424
477
|
js: output.outputPath,
|
|
425
|
-
dependencies: ["_runtime", "
|
|
478
|
+
dependencies: ["_runtime", "_react"],
|
|
426
479
|
priority: hydration?.priority || "visible",
|
|
427
480
|
};
|
|
428
481
|
}
|
|
@@ -434,7 +487,14 @@ function createBundleManifest(
|
|
|
434
487
|
bundles,
|
|
435
488
|
shared: {
|
|
436
489
|
runtime: runtimePath,
|
|
437
|
-
vendor:
|
|
490
|
+
vendor: vendorResult.react, // primary vendor for backwards compatibility
|
|
491
|
+
},
|
|
492
|
+
importMap: {
|
|
493
|
+
imports: {
|
|
494
|
+
"react": vendorResult.react,
|
|
495
|
+
"react-dom": vendorResult.reactDom,
|
|
496
|
+
"react-dom/client": vendorResult.reactDomClient,
|
|
497
|
+
},
|
|
438
498
|
},
|
|
439
499
|
};
|
|
440
500
|
}
|
|
@@ -523,10 +583,10 @@ export async function buildClientBundles(
|
|
|
523
583
|
errors.push(...runtimeResult.errors.map((e) => `[Runtime] ${e}`));
|
|
524
584
|
}
|
|
525
585
|
|
|
526
|
-
// 4. Vendor 번들 빌드
|
|
527
|
-
const vendorResult = await
|
|
586
|
+
// 4. Vendor shim 번들 빌드 (React, ReactDOM, ReactDOMClient)
|
|
587
|
+
const vendorResult = await buildVendorShims(outDir, options);
|
|
528
588
|
if (!vendorResult.success) {
|
|
529
|
-
errors.push(...vendorResult.errors
|
|
589
|
+
errors.push(...vendorResult.errors);
|
|
530
590
|
}
|
|
531
591
|
|
|
532
592
|
// 5. 각 Island 번들 빌드
|
|
@@ -544,7 +604,7 @@ export async function buildClientBundles(
|
|
|
544
604
|
outputs,
|
|
545
605
|
hydratedRoutes,
|
|
546
606
|
runtimeResult.outputPath,
|
|
547
|
-
vendorResult
|
|
607
|
+
vendorResult,
|
|
548
608
|
env
|
|
549
609
|
);
|
|
550
610
|
|
package/src/bundler/types.ts
CHANGED
|
@@ -57,9 +57,13 @@ export interface BundleManifest {
|
|
|
57
57
|
shared: {
|
|
58
58
|
/** Hydration 런타임 */
|
|
59
59
|
runtime: string;
|
|
60
|
-
/**
|
|
60
|
+
/** React 번들 경로 */
|
|
61
61
|
vendor: string;
|
|
62
62
|
};
|
|
63
|
+
/** Import map for bare specifiers (react, react-dom, etc.) */
|
|
64
|
+
importMap?: {
|
|
65
|
+
imports: Record<string, string>;
|
|
66
|
+
};
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
/**
|
package/src/client/index.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// Island API
|
|
18
18
|
export {
|
|
19
19
|
island,
|
|
20
|
+
wrapComponent,
|
|
20
21
|
useServerData,
|
|
21
22
|
useHydrated,
|
|
22
23
|
useIslandEvent,
|
|
@@ -25,6 +26,7 @@ export {
|
|
|
25
26
|
type IslandMetadata,
|
|
26
27
|
type CompiledIsland,
|
|
27
28
|
type FetchOptions,
|
|
29
|
+
type WrapComponentOptions,
|
|
28
30
|
} from "./island";
|
|
29
31
|
|
|
30
32
|
// Runtime API
|
|
@@ -41,7 +43,7 @@ export {
|
|
|
41
43
|
} from "./runtime";
|
|
42
44
|
|
|
43
45
|
// Re-export as Mandu namespace for consistent API
|
|
44
|
-
import { island } from "./island";
|
|
46
|
+
import { island, wrapComponent } from "./island";
|
|
45
47
|
import { hydrateIslands, initializeRuntime } from "./runtime";
|
|
46
48
|
|
|
47
49
|
/**
|
|
@@ -54,6 +56,12 @@ export const Mandu = {
|
|
|
54
56
|
*/
|
|
55
57
|
island,
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Wrap existing React component as island
|
|
61
|
+
* @see wrapComponent
|
|
62
|
+
*/
|
|
63
|
+
wrapComponent,
|
|
64
|
+
|
|
57
65
|
/**
|
|
58
66
|
* Hydrate all islands on the page
|
|
59
67
|
* @see hydrateIslands
|
package/src/client/island.ts
CHANGED
|
@@ -166,6 +166,65 @@ export function useIslandEvent<T = unknown>(
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/**
|
|
170
|
+
* 기존 React 컴포넌트를 Island로 래핑
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* // 기존 React 컴포넌트
|
|
175
|
+
* import DatePicker from 'react-datepicker';
|
|
176
|
+
*
|
|
177
|
+
* // Island로 래핑 (serverData가 그대로 props로 전달됨)
|
|
178
|
+
* export default Mandu.wrapComponent(DatePicker);
|
|
179
|
+
*
|
|
180
|
+
* // 또는 props 변환이 필요한 경우
|
|
181
|
+
* export default Mandu.wrapComponent(DatePicker, {
|
|
182
|
+
* transformProps: (serverData) => ({
|
|
183
|
+
* selected: new Date(serverData.selectedDate),
|
|
184
|
+
* onChange: (date) => console.log(date),
|
|
185
|
+
* })
|
|
186
|
+
* });
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export interface WrapComponentOptions<TServerData, TProps> {
|
|
190
|
+
/** 서버 데이터를 컴포넌트 props로 변환 */
|
|
191
|
+
transformProps?: (serverData: TServerData) => TProps;
|
|
192
|
+
/** 에러 시 표시할 UI */
|
|
193
|
+
errorBoundary?: (error: Error, reset: () => void) => ReactNode;
|
|
194
|
+
/** 로딩 중 표시할 UI */
|
|
195
|
+
loading?: () => ReactNode;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function wrapComponent<TProps extends Record<string, any>>(
|
|
199
|
+
Component: React.ComponentType<TProps>,
|
|
200
|
+
options?: WrapComponentOptions<TProps, TProps>
|
|
201
|
+
): CompiledIsland<TProps, TProps>;
|
|
202
|
+
|
|
203
|
+
export function wrapComponent<TServerData, TProps>(
|
|
204
|
+
Component: React.ComponentType<TProps>,
|
|
205
|
+
options: WrapComponentOptions<TServerData, TProps> & { transformProps: (serverData: TServerData) => TProps }
|
|
206
|
+
): CompiledIsland<TServerData, TProps>;
|
|
207
|
+
|
|
208
|
+
export function wrapComponent<TServerData, TProps>(
|
|
209
|
+
Component: React.ComponentType<TProps>,
|
|
210
|
+
options?: WrapComponentOptions<TServerData, TProps>
|
|
211
|
+
): CompiledIsland<TServerData, TProps> {
|
|
212
|
+
const { transformProps, errorBoundary, loading } = options || {};
|
|
213
|
+
|
|
214
|
+
return island({
|
|
215
|
+
setup: (serverData: TServerData) => {
|
|
216
|
+
return transformProps ? transformProps(serverData) : (serverData as unknown as TProps);
|
|
217
|
+
},
|
|
218
|
+
render: (props: TProps) => {
|
|
219
|
+
// React.createElement를 사용하여 Component 렌더링
|
|
220
|
+
const React = require("react");
|
|
221
|
+
return React.createElement(Component, props);
|
|
222
|
+
},
|
|
223
|
+
errorBoundary,
|
|
224
|
+
loading,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
169
228
|
/**
|
|
170
229
|
* API 호출 헬퍼
|
|
171
230
|
*/
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -39,6 +39,18 @@ function serializeServerData(data: Record<string, unknown>): string {
|
|
|
39
39
|
<script>window.__MANDU_DATA__ = JSON.parse(document.getElementById('__MANDU_DATA__').textContent);</script>`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Import map 생성 (bare specifier 해결용)
|
|
44
|
+
*/
|
|
45
|
+
function generateImportMap(manifest: BundleManifest): string {
|
|
46
|
+
if (!manifest.importMap || Object.keys(manifest.importMap.imports).length === 0) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const importMapJson = JSON.stringify(manifest.importMap, null, 2);
|
|
51
|
+
return `<script type="importmap">${importMapJson}</script>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
/**
|
|
43
55
|
* Hydration 스크립트 태그 생성
|
|
44
56
|
*/
|
|
@@ -48,16 +60,17 @@ function generateHydrationScripts(
|
|
|
48
60
|
): string {
|
|
49
61
|
const scripts: string[] = [];
|
|
50
62
|
|
|
63
|
+
// Import map 먼저 (반드시 module scripts 전에 위치해야 함)
|
|
64
|
+
const importMap = generateImportMap(manifest);
|
|
65
|
+
if (importMap) {
|
|
66
|
+
scripts.push(importMap);
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
// Runtime 로드
|
|
52
70
|
if (manifest.shared.runtime) {
|
|
53
71
|
scripts.push(`<script type="module" src="${manifest.shared.runtime}"></script>`);
|
|
54
72
|
}
|
|
55
73
|
|
|
56
|
-
// Vendor 로드
|
|
57
|
-
if (manifest.shared.vendor) {
|
|
58
|
-
scripts.push(`<script type="module" src="${manifest.shared.vendor}"></script>`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
74
|
// Island 번들 로드
|
|
62
75
|
const bundle = manifest.bundles[routeId];
|
|
63
76
|
if (bundle) {
|