@mandujs/core 0.3.3 โ†’ 0.4.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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
+ "./client": "./src/client/index.ts",
10
11
  "./*": "./src/*"
11
12
  },
12
13
  "files": [
@@ -0,0 +1,609 @@
1
+ /**
2
+ * Mandu Client Bundler ๐Ÿ“ฆ
3
+ * Bun.build ๊ธฐ๋ฐ˜ ํด๋ผ์ด์–ธํŠธ ๋ฒˆ๋“ค ๋นŒ๋“œ
4
+ */
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 path from "path";
16
+ import fs from "fs/promises";
17
+
18
+ /**
19
+ * ๋นˆ ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์ƒ์„ฑ
20
+ */
21
+ function createEmptyManifest(env: "development" | "production"): BundleManifest {
22
+ return {
23
+ version: 1,
24
+ buildTime: new Date().toISOString(),
25
+ env,
26
+ bundles: {},
27
+ shared: {
28
+ runtime: "",
29
+ vendor: "",
30
+ },
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Hydration์ด ํ•„์š”ํ•œ ๋ผ์šฐํŠธ ํ•„ํ„ฐ๋ง
36
+ */
37
+ function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
38
+ return manifest.routes.filter(
39
+ (route) =>
40
+ route.kind === "page" &&
41
+ route.clientModule &&
42
+ needsHydration(route)
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Runtime ๋ฒˆ๋“ค ์†Œ์Šค ์ƒ์„ฑ
48
+ */
49
+ function generateRuntimeSource(): string {
50
+ return `
51
+ /**
52
+ * Mandu Hydration Runtime (Generated)
53
+ */
54
+
55
+ const islandRegistry = new Map();
56
+ const hydratedRoots = new Map();
57
+
58
+ // ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ
59
+ const serverData = window.__MANDU_DATA__ || {};
60
+
61
+ /**
62
+ * Island ๋“ฑ๋ก
63
+ */
64
+ export function registerIsland(id, loader) {
65
+ islandRegistry.set(id, loader);
66
+ }
67
+
68
+ /**
69
+ * ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ
70
+ */
71
+ export function getServerData(id) {
72
+ return serverData[id]?.serverData;
73
+ }
74
+
75
+ /**
76
+ * Hydration ์Šค์ผ€์ค„๋Ÿฌ
77
+ */
78
+ function scheduleHydration(element, id, priority, data) {
79
+ switch (priority) {
80
+ case 'immediate':
81
+ hydrateIsland(element, id, data);
82
+ break;
83
+
84
+ case 'visible':
85
+ if ('IntersectionObserver' in window) {
86
+ const observer = new IntersectionObserver((entries) => {
87
+ if (entries[0].isIntersecting) {
88
+ observer.disconnect();
89
+ hydrateIsland(element, id, data);
90
+ }
91
+ }, { rootMargin: '50px' });
92
+ observer.observe(element);
93
+ } else {
94
+ hydrateIsland(element, id, data);
95
+ }
96
+ break;
97
+
98
+ case 'idle':
99
+ if ('requestIdleCallback' in window) {
100
+ requestIdleCallback(() => hydrateIsland(element, id, data));
101
+ } else {
102
+ setTimeout(() => hydrateIsland(element, id, data), 200);
103
+ }
104
+ break;
105
+
106
+ case 'interaction': {
107
+ const hydrate = () => {
108
+ element.removeEventListener('mouseenter', hydrate);
109
+ element.removeEventListener('focusin', hydrate);
110
+ element.removeEventListener('touchstart', hydrate);
111
+ hydrateIsland(element, id, data);
112
+ };
113
+ element.addEventListener('mouseenter', hydrate, { once: true, passive: true });
114
+ element.addEventListener('focusin', hydrate, { once: true });
115
+ element.addEventListener('touchstart', hydrate, { once: true, passive: true });
116
+ break;
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * ๋‹จ์ผ Island hydrate
123
+ */
124
+ async function hydrateIsland(element, id, data) {
125
+ const loader = islandRegistry.get(id);
126
+ if (!loader) {
127
+ console.warn('[Mandu] Island not found:', id);
128
+ return;
129
+ }
130
+
131
+ try {
132
+ const island = await Promise.resolve(loader());
133
+
134
+ // Island ์ปดํฌ๋„ŒํŠธ ๊ฐ€์ ธ์˜ค๊ธฐ
135
+ const islandDef = island.default || island;
136
+ if (!islandDef.__mandu_island) {
137
+ throw new Error('[Mandu] Invalid island: ' + id);
138
+ }
139
+
140
+ const { definition } = islandDef;
141
+ const { hydrateRoot } = await import('react-dom/client');
142
+ const React = await import('react');
143
+
144
+ // Island ์ปดํฌ๋„ŒํŠธ
145
+ function IslandComponent() {
146
+ const setupResult = definition.setup(data);
147
+ return definition.render(setupResult);
148
+ }
149
+
150
+ // Hydrate
151
+ const root = hydrateRoot(element, React.createElement(IslandComponent));
152
+ hydratedRoots.set(id, root);
153
+
154
+ // ์™„๋ฃŒ ํ‘œ์‹œ
155
+ element.setAttribute('data-mandu-hydrated', 'true');
156
+
157
+ // ์„ฑ๋Šฅ ๋งˆ์ปค
158
+ if (performance.mark) {
159
+ performance.mark('mandu-hydrated-' + id);
160
+ }
161
+
162
+ // ์ด๋ฒคํŠธ ๋ฐœ์†ก
163
+ element.dispatchEvent(new CustomEvent('mandu:hydrated', {
164
+ bubbles: true,
165
+ detail: { id, data }
166
+ }));
167
+ } catch (error) {
168
+ console.error('[Mandu] Hydration failed for', id, error);
169
+ element.setAttribute('data-mandu-error', 'true');
170
+ }
171
+ }
172
+
173
+ /**
174
+ * ๋ชจ๋“  Island hydrate
175
+ */
176
+ export async function hydrateIslands() {
177
+ const islands = document.querySelectorAll('[data-mandu-island]');
178
+
179
+ for (const el of islands) {
180
+ const id = el.getAttribute('data-mandu-island');
181
+ if (!id) continue;
182
+
183
+ const priority = el.getAttribute('data-mandu-priority') || 'visible';
184
+ const data = serverData[id]?.serverData || {};
185
+
186
+ scheduleHydration(el, id, priority, data);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * ์ž๋™ ์ดˆ๊ธฐํ™”
192
+ */
193
+ if (document.readyState === 'loading') {
194
+ document.addEventListener('DOMContentLoaded', hydrateIslands);
195
+ } else {
196
+ hydrateIslands();
197
+ }
198
+
199
+ export { islandRegistry, hydratedRoots };
200
+ `;
201
+ }
202
+
203
+ /**
204
+ * Vendor ๋ฒˆ๋“ค ์†Œ์Šค ์ƒ์„ฑ (React ๋“ฑ ๊ณต์œ  ์˜์กด์„ฑ)
205
+ */
206
+ function generateVendorSource(): string {
207
+ return `
208
+ /**
209
+ * Mandu Vendor Bundle (Generated)
210
+ * ๊ณต์œ  ์˜์กด์„ฑ
211
+ */
212
+
213
+ export * as React from 'react';
214
+ export * as ReactDOM from 'react-dom';
215
+ export * as ReactDOMClient from 'react-dom/client';
216
+ `;
217
+ }
218
+
219
+ /**
220
+ * Island ์—”ํŠธ๋ฆฌ ๋ž˜ํผ ์ƒ์„ฑ
221
+ */
222
+ function generateIslandEntry(routeId: string, clientModulePath: string): string {
223
+ return `
224
+ /**
225
+ * Mandu Island Entry: ${routeId} (Generated)
226
+ */
227
+
228
+ import island from "${clientModulePath}";
229
+ import { registerIsland } from "./_runtime.js";
230
+
231
+ registerIsland("${routeId}", () => island);
232
+
233
+ export default island;
234
+ `;
235
+ }
236
+
237
+ /**
238
+ * Runtime ๋ฒˆ๋“ค ๋นŒ๋“œ
239
+ */
240
+ async function buildRuntime(
241
+ outDir: string,
242
+ options: BundlerOptions
243
+ ): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
244
+ const runtimePath = path.join(outDir, "_runtime.src.js");
245
+ const outputName = "_runtime.js";
246
+
247
+ try {
248
+ // ๋Ÿฐํƒ€์ž„ ์†Œ์Šค ์ž‘์„ฑ
249
+ await Bun.write(runtimePath, generateRuntimeSource());
250
+
251
+ // ๋นŒ๋“œ
252
+ const result = await Bun.build({
253
+ entrypoints: [runtimePath],
254
+ outdir: outDir,
255
+ naming: outputName,
256
+ minify: options.minify ?? process.env.NODE_ENV === "production",
257
+ sourcemap: options.sourcemap ? "external" : "none",
258
+ target: "browser",
259
+ external: ["react", "react-dom", "react-dom/client"],
260
+ define: {
261
+ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
262
+ ...options.define,
263
+ },
264
+ });
265
+
266
+ // ์†Œ์Šค ํŒŒ์ผ ์ •๋ฆฌ
267
+ await fs.unlink(runtimePath).catch(() => {});
268
+
269
+ if (!result.success) {
270
+ return {
271
+ success: false,
272
+ outputPath: "",
273
+ errors: result.logs.map((l) => l.message),
274
+ };
275
+ }
276
+
277
+ return {
278
+ success: true,
279
+ outputPath: `/.mandu/client/${outputName}`,
280
+ errors: [],
281
+ };
282
+ } catch (error) {
283
+ await fs.unlink(runtimePath).catch(() => {});
284
+ return {
285
+ success: false,
286
+ outputPath: "",
287
+ errors: [String(error)],
288
+ };
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Vendor ๋ฒˆ๋“ค ๋นŒ๋“œ
294
+ */
295
+ async function buildVendor(
296
+ outDir: string,
297
+ options: BundlerOptions
298
+ ): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
299
+ const vendorPath = path.join(outDir, "_vendor.src.js");
300
+ const outputName = "_vendor.js";
301
+
302
+ try {
303
+ // ๋ฒค๋” ์†Œ์Šค ์ž‘์„ฑ
304
+ await Bun.write(vendorPath, generateVendorSource());
305
+
306
+ // ๋นŒ๋“œ
307
+ const result = await Bun.build({
308
+ entrypoints: [vendorPath],
309
+ outdir: outDir,
310
+ naming: outputName,
311
+ minify: options.minify ?? process.env.NODE_ENV === "production",
312
+ sourcemap: options.sourcemap ? "external" : "none",
313
+ target: "browser",
314
+ define: {
315
+ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
316
+ ...options.define,
317
+ },
318
+ });
319
+
320
+ // ์†Œ์Šค ํŒŒ์ผ ์ •๋ฆฌ
321
+ await fs.unlink(vendorPath).catch(() => {});
322
+
323
+ if (!result.success) {
324
+ return {
325
+ success: false,
326
+ outputPath: "",
327
+ errors: result.logs.map((l) => l.message),
328
+ };
329
+ }
330
+
331
+ return {
332
+ success: true,
333
+ outputPath: `/.mandu/client/${outputName}`,
334
+ errors: [],
335
+ };
336
+ } catch (error) {
337
+ await fs.unlink(vendorPath).catch(() => {});
338
+ return {
339
+ success: false,
340
+ outputPath: "",
341
+ errors: [String(error)],
342
+ };
343
+ }
344
+ }
345
+
346
+ /**
347
+ * ๋‹จ์ผ Island ๋ฒˆ๋“ค ๋นŒ๋“œ
348
+ */
349
+ async function buildIsland(
350
+ route: RouteSpec,
351
+ rootDir: string,
352
+ outDir: string,
353
+ options: BundlerOptions
354
+ ): Promise<BundleOutput> {
355
+ const clientModulePath = path.join(rootDir, route.clientModule!);
356
+ const entryPath = path.join(outDir, `_entry_${route.id}.js`);
357
+ const outputName = `${route.id}.island.js`;
358
+
359
+ try {
360
+ // ์—”ํŠธ๋ฆฌ ๋ž˜ํผ ์ƒ์„ฑ
361
+ await Bun.write(entryPath, generateIslandEntry(route.id, clientModulePath));
362
+
363
+ // ๋นŒ๋“œ
364
+ const result = await Bun.build({
365
+ entrypoints: [entryPath],
366
+ outdir: outDir,
367
+ naming: outputName,
368
+ minify: options.minify ?? process.env.NODE_ENV === "production",
369
+ sourcemap: options.sourcemap ? "external" : "none",
370
+ target: "browser",
371
+ splitting: false, // Island ๋‹จ์œ„๋กœ ์ด๋ฏธ ๋ถ„๋ฆฌ๋จ
372
+ external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
373
+ define: {
374
+ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"),
375
+ ...options.define,
376
+ },
377
+ });
378
+
379
+ // ์—”ํŠธ๋ฆฌ ํŒŒ์ผ ์ •๋ฆฌ
380
+ await fs.unlink(entryPath).catch(() => {});
381
+
382
+ if (!result.success) {
383
+ throw new Error(result.logs.map((l) => l.message).join("\n"));
384
+ }
385
+
386
+ // ์ถœ๋ ฅ ํŒŒ์ผ ์ •๋ณด
387
+ const outputPath = path.join(outDir, outputName);
388
+ const outputFile = Bun.file(outputPath);
389
+ const content = await outputFile.text();
390
+ const gzipped = Bun.gzipSync(Buffer.from(content));
391
+
392
+ return {
393
+ routeId: route.id,
394
+ entrypoint: route.clientModule!,
395
+ outputPath: `/.mandu/client/${outputName}`,
396
+ size: outputFile.size,
397
+ gzipSize: gzipped.length,
398
+ };
399
+ } catch (error) {
400
+ await fs.unlink(entryPath).catch(() => {});
401
+ throw error;
402
+ }
403
+ }
404
+
405
+ /**
406
+ * ๋ฒˆ๋“ค ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์ƒ์„ฑ
407
+ */
408
+ function createBundleManifest(
409
+ outputs: BundleOutput[],
410
+ routes: RouteSpec[],
411
+ runtimePath: string,
412
+ vendorPath: string,
413
+ env: "development" | "production"
414
+ ): BundleManifest {
415
+ const bundles: BundleManifest["bundles"] = {};
416
+
417
+ for (const output of outputs) {
418
+ const route = routes.find((r) => r.id === output.routeId);
419
+ const hydration = route ? getRouteHydration(route) : null;
420
+
421
+ bundles[output.routeId] = {
422
+ js: output.outputPath,
423
+ dependencies: ["_runtime", "_vendor"],
424
+ priority: hydration?.priority || "visible",
425
+ };
426
+ }
427
+
428
+ return {
429
+ version: 1,
430
+ buildTime: new Date().toISOString(),
431
+ env,
432
+ bundles,
433
+ shared: {
434
+ runtime: runtimePath,
435
+ vendor: vendorPath,
436
+ },
437
+ };
438
+ }
439
+
440
+ /**
441
+ * ๋ฒˆ๋“ค ํ†ต๊ณ„ ๊ณ„์‚ฐ
442
+ */
443
+ function calculateStats(outputs: BundleOutput[], startTime: number): BundleStats {
444
+ let totalSize = 0;
445
+ let totalGzipSize = 0;
446
+ let largestBundle = { routeId: "", size: 0 };
447
+
448
+ for (const output of outputs) {
449
+ totalSize += output.size;
450
+ totalGzipSize += output.gzipSize;
451
+
452
+ if (output.size > largestBundle.size) {
453
+ largestBundle = { routeId: output.routeId, size: output.size };
454
+ }
455
+ }
456
+
457
+ return {
458
+ totalSize,
459
+ totalGzipSize,
460
+ largestBundle,
461
+ buildTime: performance.now() - startTime,
462
+ bundleCount: outputs.length,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * ํด๋ผ์ด์–ธํŠธ ๋ฒˆ๋“ค ๋นŒ๋“œ
468
+ *
469
+ * @example
470
+ * ```typescript
471
+ * import { buildClientBundles } from "@mandujs/core/bundler";
472
+ *
473
+ * const result = await buildClientBundles(manifest, "./my-app", {
474
+ * minify: true,
475
+ * sourcemap: true,
476
+ * });
477
+ *
478
+ * if (result.success) {
479
+ * console.log("Built", result.stats.bundleCount, "bundles");
480
+ * }
481
+ * ```
482
+ */
483
+ export async function buildClientBundles(
484
+ manifest: RoutesManifest,
485
+ rootDir: string,
486
+ options: BundlerOptions = {}
487
+ ): Promise<BundleResult> {
488
+ const startTime = performance.now();
489
+ const outputs: BundleOutput[] = [];
490
+ const errors: string[] = [];
491
+ const env = (process.env.NODE_ENV === "production" ? "production" : "development") as
492
+ | "development"
493
+ | "production";
494
+
495
+ // 1. Hydration์ด ํ•„์š”ํ•œ ๋ผ์šฐํŠธ ํ•„ํ„ฐ๋ง
496
+ const hydratedRoutes = getHydratedRoutes(manifest);
497
+
498
+ if (hydratedRoutes.length === 0) {
499
+ return {
500
+ success: true,
501
+ outputs: [],
502
+ errors: [],
503
+ manifest: createEmptyManifest(env),
504
+ stats: {
505
+ totalSize: 0,
506
+ totalGzipSize: 0,
507
+ largestBundle: { routeId: "", size: 0 },
508
+ buildTime: 0,
509
+ bundleCount: 0,
510
+ },
511
+ };
512
+ }
513
+
514
+ // 2. ์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ
515
+ const outDir = options.outDir || path.join(rootDir, ".mandu/client");
516
+ await fs.mkdir(outDir, { recursive: true });
517
+
518
+ // 3. Runtime ๋ฒˆ๋“ค ๋นŒ๋“œ
519
+ const runtimeResult = await buildRuntime(outDir, options);
520
+ if (!runtimeResult.success) {
521
+ errors.push(...runtimeResult.errors.map((e) => `[Runtime] ${e}`));
522
+ }
523
+
524
+ // 4. Vendor ๋ฒˆ๋“ค ๋นŒ๋“œ
525
+ const vendorResult = await buildVendor(outDir, options);
526
+ if (!vendorResult.success) {
527
+ errors.push(...vendorResult.errors.map((e) => `[Vendor] ${e}`));
528
+ }
529
+
530
+ // 5. ๊ฐ Island ๋ฒˆ๋“ค ๋นŒ๋“œ
531
+ for (const route of hydratedRoutes) {
532
+ try {
533
+ const result = await buildIsland(route, rootDir, outDir, options);
534
+ outputs.push(result);
535
+ } catch (error) {
536
+ errors.push(`[${route.id}] ${String(error)}`);
537
+ }
538
+ }
539
+
540
+ // 6. ๋ฒˆ๋“ค ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์ƒ์„ฑ
541
+ const bundleManifest = createBundleManifest(
542
+ outputs,
543
+ hydratedRoutes,
544
+ runtimeResult.outputPath,
545
+ vendorResult.outputPath,
546
+ env
547
+ );
548
+
549
+ await fs.writeFile(
550
+ path.join(rootDir, ".mandu/manifest.json"),
551
+ JSON.stringify(bundleManifest, null, 2)
552
+ );
553
+
554
+ // 7. ํ†ต๊ณ„ ๊ณ„์‚ฐ
555
+ const stats = calculateStats(outputs, startTime);
556
+
557
+ return {
558
+ success: errors.length === 0,
559
+ outputs,
560
+ errors,
561
+ manifest: bundleManifest,
562
+ stats,
563
+ };
564
+ }
565
+
566
+ /**
567
+ * ๋ฒˆ๋“ค ์‚ฌ์ด์ฆˆ ํฌ๋งทํŒ…
568
+ */
569
+ export function formatSize(bytes: number): string {
570
+ if (bytes < 1024) return `${bytes} B`;
571
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
572
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
573
+ }
574
+
575
+ /**
576
+ * ๋ฒˆ๋“ค ๊ฒฐ๊ณผ ์š”์•ฝ ์ถœ๋ ฅ
577
+ */
578
+ export function printBundleStats(result: BundleResult): void {
579
+ console.log("\n๐Ÿ“ฆ Mandu Client Bundles");
580
+ console.log("=".repeat(50));
581
+
582
+ if (result.outputs.length === 0) {
583
+ console.log("No islands to bundle (hydration: none or no clientModule)");
584
+ return;
585
+ }
586
+
587
+ console.log(`Environment: ${result.manifest.env}`);
588
+ console.log(`Bundles: ${result.stats.bundleCount}`);
589
+ console.log(`Total Size: ${formatSize(result.stats.totalSize)}`);
590
+ console.log(`Total Gzip: ${formatSize(result.stats.totalGzipSize)}`);
591
+ console.log(`Build Time: ${result.stats.buildTime.toFixed(0)}ms`);
592
+ console.log("");
593
+
594
+ // ๊ฐ ๋ฒˆ๋“ค ์ •๋ณด
595
+ for (const output of result.outputs) {
596
+ console.log(
597
+ ` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
598
+ );
599
+ }
600
+
601
+ if (result.errors.length > 0) {
602
+ console.log("\nโš ๏ธ Errors:");
603
+ for (const error of result.errors) {
604
+ console.log(` ${error}`);
605
+ }
606
+ }
607
+
608
+ console.log("");
609
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Mandu Bundler Module ๐Ÿ“ฆ
3
+ * Bun.build ๊ธฐ๋ฐ˜ ํด๋ผ์ด์–ธํŠธ ๋ฒˆ๋“ค๋ง
4
+ */
5
+
6
+ export * from "./types";
7
+ export * from "./build";