@mandujs/core 0.54.17 → 0.54.19

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.
Files changed (46) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +94 -25
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/agent/verify.ts +55 -24
  6. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  7. package/src/bundler/__tests__/build-runner.ts +130 -17
  8. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  9. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  10. package/src/bundler/build.test.ts +478 -9
  11. package/src/bundler/build.ts +424 -746
  12. package/src/bundler/client-boundary-transform.ts +977 -0
  13. package/src/bundler/dev.ts +39 -112
  14. package/src/bundler/fast-refresh-preamble.ts +47 -0
  15. package/src/bundler/index.ts +3 -2
  16. package/src/bundler/manifest-schema.ts +10 -0
  17. package/src/bundler/types.ts +20 -2
  18. package/src/client/__tests__/props-serialization.test.ts +37 -0
  19. package/src/client/hydrate.ts +2 -2
  20. package/src/client/index.ts +1 -1
  21. package/src/client/props-serialization.ts +233 -0
  22. package/src/client/runtime-entry.ts +567 -0
  23. package/src/client/runtime.ts +1 -1
  24. package/src/client/serialize.ts +50 -404
  25. package/src/diagnose/__tests__/checks.test.ts +132 -17
  26. package/src/diagnose/checks.ts +184 -3
  27. package/src/diagnose/run.ts +10 -8
  28. package/src/generator/templates.test.ts +48 -5
  29. package/src/generator/templates.ts +10 -1
  30. package/src/internal/client-boundary.ts +266 -0
  31. package/src/internal/index.ts +2 -1
  32. package/src/router/client-entry.test.ts +154 -29
  33. package/src/router/client-entry.ts +111 -313
  34. package/src/router/fs-routes.test.ts +443 -1
  35. package/src/router/fs-routes.ts +16 -3
  36. package/src/router/fs-scanner.ts +176 -57
  37. package/src/router/fs-types.ts +11 -2
  38. package/src/router/route-source-analyzer.ts +521 -0
  39. package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
  40. package/src/runtime/__tests__/page-render-response.test.ts +218 -0
  41. package/src/runtime/handlers.ts +50 -26
  42. package/src/runtime/page-render-response.ts +24 -1
  43. package/src/runtime/server.ts +14 -0
  44. package/src/runtime/ssr.ts +16 -5
  45. package/src/runtime/streaming-ssr.ts +119 -76
  46. package/src/spec/schema.ts +31 -5
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { stat } from "fs/promises";
10
- import { join, relative, basename, extname } from "path";
10
+ import { dirname, join, relative, basename, extname } from "path";
11
11
  import type {
12
12
  ScannedFile,
13
13
  FSScannerConfig,
@@ -33,46 +33,18 @@ import { METADATA_ROUTES } from "../routes/types";
33
33
  import type { HydrationConfig } from "../spec/schema";
34
34
  import {
35
35
  hasUseClientDirective,
36
+ resolveClientImportModulePath,
36
37
  resolveRouteLevelClientEntry,
37
38
  } from "./client-entry";
38
-
39
- const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
40
- const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
41
-
42
- function parsePageHydrationConfig(source: string): HydrationConfig | undefined {
43
- const stringMatch = source.match(
44
- /export\s+const\s+hydration\s*(?::[^=]+)?=\s*["'](none|island|full|progressive)["']/m,
45
- );
46
- if (stringMatch?.[1]) {
47
- return {
48
- strategy: stringMatch[1] as HydrationConfig["strategy"],
49
- priority: "visible",
50
- preload: false,
51
- };
52
- }
53
-
54
- const objectMatch = source.match(
55
- /export\s+const\s+hydration\s*(?::[^=]+)?=\s*\{([\s\S]*?)\}\s*;?/m,
56
- );
57
- const body = objectMatch?.[1];
58
- if (!body) return undefined;
59
-
60
- const strategyMatch = body.match(/\bstrategy\s*:\s*["']([^"']+)["']/);
61
- const strategy = strategyMatch?.[1];
62
- if (!strategy || !HYDRATION_STRATEGIES.has(strategy)) return undefined;
63
-
64
- const priorityMatch = body.match(/\bpriority\s*:\s*["']([^"']+)["']/);
65
- const priority = priorityMatch?.[1];
66
- const preloadMatch = body.match(/\bpreload\s*:\s*(true|false)\b/);
67
-
68
- return {
69
- strategy: strategy as HydrationConfig["strategy"],
70
- priority: HYDRATION_PRIORITIES.has(priority ?? "")
71
- ? (priority as HydrationConfig["priority"])
72
- : "visible",
73
- preload: preloadMatch?.[1] === "true",
74
- };
75
- }
39
+ import { analyzeRouteSource } from "./route-source-analyzer";
40
+ import {
41
+ assertNoClientBoundaryDiagnostics,
42
+ collectStaticImportSpecifiers,
43
+ isClientBoundaryModuleSpecifier,
44
+ transformClientBoundaries,
45
+ validateClientBoundaryExport,
46
+ validateClientBoundaryServerOnlyImports,
47
+ } from "../bundler/client-boundary-transform";
76
48
 
77
49
  // ═══════════════════════════════════════════════════════════════════════════
78
50
  // Scanner Class
@@ -406,8 +378,10 @@ export class FSScanner {
406
378
  // clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
407
379
  let clientModule: string | undefined;
408
380
  let clientExportName: string | undefined;
381
+ let boundaries: NonNullable<FSRouteConfig["boundaries"]> = [];
409
382
  let hydration: HydrationConfig | undefined;
410
383
  let pageFileContent: string | null = null;
384
+ let pageHasUseClient = false;
411
385
 
412
386
  if (file.type === "page") {
413
387
  try {
@@ -416,7 +390,32 @@ export class FSScanner {
416
390
  pageFileContent = null;
417
391
  }
418
392
  if (pageFileContent) {
419
- hydration = parsePageHydrationConfig(pageFileContent);
393
+ const analysis = analyzeRouteSource(pageFileContent, modulePath);
394
+ hydration = analysis.hydrationConfig;
395
+ pageHasUseClient = analysis.directives.useClient;
396
+ for (const diagnostic of analysis.diagnostics) {
397
+ routeErrors.push({
398
+ type: "route_source_diagnostic",
399
+ message: `${diagnostic.code}: ${diagnostic.message}`,
400
+ filePath: file.absolutePath,
401
+ });
402
+ }
403
+ if (!pageHasUseClient) {
404
+ boundaries = await this.resolveClientBoundaries(
405
+ rootDir,
406
+ modulePath,
407
+ routeId,
408
+ pageFileContent,
409
+ hydration?.priority ?? "visible",
410
+ );
411
+ }
412
+ if (boundaries.length > 0 && !hydration) {
413
+ hydration = {
414
+ strategy: "island",
415
+ priority: "visible",
416
+ preload: false,
417
+ };
418
+ }
420
419
  }
421
420
  }
422
421
 
@@ -436,12 +435,11 @@ export class FSScanner {
436
435
  conflictsWith: islands[0].absolutePath,
437
436
  });
438
437
  }
439
- } else if (file.type === "page" && pageFileContent) {
440
- // page 파일 자체에서 "use client" 확인
441
- const hasUseClient = hasUseClientDirective(pageFileContent);
442
- if (hasUseClient) {
438
+ } else if (file.type === "page" && pageFileContent) {
439
+ // page 파일 자체에서 "use client" 확인
440
+ if (pageHasUseClient) {
443
441
  clientModule = modulePath;
444
- } else {
442
+ } else if (boundaries.length === 0) {
445
443
  const routeClientEntry = await resolveRouteLevelClientEntry(rootDir, modulePath, pageFileContent);
446
444
  clientModule = routeClientEntry?.modulePath;
447
445
  clientExportName = routeClientEntry?.exportName;
@@ -465,6 +463,7 @@ export class FSScanner {
465
463
  componentModule: file.type === "page" ? modulePath : undefined,
466
464
  clientModule,
467
465
  clientExportName,
466
+ boundaries: boundaries && boundaries.length > 0 ? boundaries : undefined,
468
467
  hydration,
469
468
  layoutChain,
470
469
  loadingModule,
@@ -486,7 +485,7 @@ export class FSScanner {
486
485
  return { routes, routeErrors };
487
486
  }
488
487
 
489
- private hasHydrationShellMismatchRisk(pageContent: string, _islandRelativePath: string): boolean {
488
+ private hasHydrationShellMismatchRisk(pageContent: string, _islandRelativePath: string): boolean {
490
489
  // import문에서 island 모듈의 변수명을 직접 파싱
491
490
  const importMatch = pageContent.match(
492
491
  /import\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+from\s+["'][^"']*\.island(?:\.(?:tsx?|jsx?))?["']/
@@ -503,10 +502,116 @@ export class FSScanner {
503
502
  return new RegExp(
504
503
  `typeof\\s+${islandVarName}\\s*!==\\s*["']undefined["']\\s*&&\\s*null`
505
504
  ).test(pageContent);
506
- }
507
-
508
- /**
509
- * 레이아웃 체인 해결
505
+ }
506
+
507
+ private async resolveClientBoundaries(
508
+ rootDir: string,
509
+ routeModule: string,
510
+ routeId: string,
511
+ source: string,
512
+ hydrate: NonNullable<HydrationConfig["priority"]>,
513
+ ): Promise<NonNullable<FSRouteConfig["boundaries"]>> {
514
+ const modules = await this.collectRouteBoundaryModules(rootDir, routeModule, source);
515
+ const boundaries: NonNullable<FSRouteConfig["boundaries"]> = [];
516
+
517
+ for (const moduleInfo of modules) {
518
+ const result = transformClientBoundaries(moduleInfo.source, {
519
+ routeId,
520
+ fileName: moduleInfo.modulePath,
521
+ hydrate,
522
+ ordinalOffset: boundaries.length,
523
+ });
524
+ if (result.boundaries.length === 0) continue;
525
+
526
+ const diagnostics = [...result.diagnostics];
527
+ const resolved = await Promise.all(result.boundaries.map(async (boundary) => {
528
+ const modulePath = await resolveClientImportModulePath(rootDir, boundary.source.file, boundary.module);
529
+ const resolvedBoundary = {
530
+ ...boundary,
531
+ module: modulePath ?? boundary.module,
532
+ };
533
+ if (modulePath) {
534
+ const clientSource = await Bun.file(join(rootDir, modulePath)).text();
535
+ diagnostics.push(...validateClientBoundaryServerOnlyImports(clientSource, resolvedBoundary, modulePath));
536
+ const validation = validateClientBoundaryExport(clientSource, resolvedBoundary, modulePath);
537
+ if (validation.diagnostic) diagnostics.push(validation.diagnostic);
538
+ }
539
+ return resolvedBoundary;
540
+ }));
541
+ assertNoClientBoundaryDiagnostics(diagnostics);
542
+ boundaries.push(...resolved);
543
+ }
544
+
545
+ return boundaries;
546
+ }
547
+
548
+ private async collectRouteBoundaryModules(
549
+ rootDir: string,
550
+ routeModule: string,
551
+ routeSource: string,
552
+ ): Promise<Array<{ modulePath: string; source: string }>> {
553
+ const visited = new Set<string>();
554
+ const modules: Array<{ modulePath: string; source: string }> = [];
555
+
556
+ const visit = async (modulePath: string, source: string): Promise<void> => {
557
+ const normalizedModulePath = modulePath.replace(/\\/g, "/").replace(/^\.\//, "");
558
+ if (visited.has(normalizedModulePath)) return;
559
+ visited.add(normalizedModulePath);
560
+ modules.push({ modulePath: normalizedModulePath, source });
561
+
562
+ for (const specifier of collectStaticImportSpecifiers(source, normalizedModulePath)) {
563
+ if (isClientBoundaryModuleSpecifier(specifier)) continue;
564
+ const resolved = await this.resolveRouteOwnedImportModulePath(rootDir, normalizedModulePath, specifier);
565
+ if (!resolved || visited.has(resolved)) continue;
566
+
567
+ let childSource: string;
568
+ try {
569
+ childSource = await Bun.file(join(rootDir, resolved)).text();
570
+ } catch {
571
+ continue;
572
+ }
573
+ if (hasUseClientDirective(childSource)) continue;
574
+
575
+ await visit(resolved, childSource);
576
+ }
577
+ };
578
+
579
+ await visit(routeModule, routeSource);
580
+ return modules;
581
+ }
582
+
583
+ private async resolveRouteOwnedImportModulePath(
584
+ rootDir: string,
585
+ importerModule: string,
586
+ specifier: string,
587
+ ): Promise<string | null> {
588
+ const normalized = specifier.replace(/\\/g, "/");
589
+ let basePath: string | null = null;
590
+
591
+ if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
592
+ basePath = join(rootDir, "src", normalized.slice(2));
593
+ } else if (normalized.startsWith("./") || normalized.startsWith("../")) {
594
+ basePath = join(rootDir, dirname(importerModule), normalized);
595
+ }
596
+
597
+ if (!basePath) return null;
598
+
599
+ for (const candidate of expandRouteImportCandidates(basePath)) {
600
+ try {
601
+ const entry = await stat(candidate);
602
+ if (entry.isFile()) {
603
+ return relative(rootDir, candidate).replace(/\\/g, "/");
604
+ }
605
+ } catch {
606
+ // Try the next candidate.
607
+ }
608
+ }
609
+
610
+ return null;
611
+ }
612
+
613
+ /**
614
+ * 레이아웃 체인 해결
510
615
  */
511
616
  private resolveLayoutChain(
512
617
  segments: ScannedFile["segments"],
@@ -755,7 +860,7 @@ export function synthesizeLocaleRoutes(
755
860
  return out;
756
861
  }
757
862
 
758
- function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
863
+ function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
759
864
  const prefixedPattern = route.pattern === "/"
760
865
  ? `/${locale}`
761
866
  : `/${locale}${route.pattern.startsWith("/") ? route.pattern : `/${route.pattern}`}`;
@@ -771,12 +876,26 @@ function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteCon
771
876
  { raw: locale, type: "static" },
772
877
  ...route.segments,
773
878
  ],
774
- };
775
- }
776
-
777
- // ═══════════════════════════════════════════════════════════════════════════
778
- // Factory Function
779
- // ═══════════════════════════════════════════════════════════════════════════
879
+ };
880
+ }
881
+
882
+ function expandRouteImportCandidates(basePath: string): string[] {
883
+ if (/\.[cm]?[jt]sx?$/.test(basePath)) return [basePath];
884
+ return [
885
+ `${basePath}.tsx`,
886
+ `${basePath}.ts`,
887
+ `${basePath}.jsx`,
888
+ `${basePath}.js`,
889
+ join(basePath, "index.tsx"),
890
+ join(basePath, "index.ts"),
891
+ join(basePath, "index.jsx"),
892
+ join(basePath, "index.js"),
893
+ ];
894
+ }
895
+
896
+ // ═══════════════════════════════════════════════════════════════════════════
897
+ // Factory Function
898
+ // ═══════════════════════════════════════════════════════════════════════════
780
899
 
781
900
  /**
782
901
  * 스캐너 생성 팩토리 함수
@@ -6,7 +6,7 @@
6
6
  * @module router/fs-types
7
7
  */
8
8
 
9
- import type { RouteKind, HydrationConfig, SpecHttpMethod } from "../spec/schema";
9
+ import type { RouteKind, HydrationConfig, RouteClientBoundary, SpecHttpMethod } from "../spec/schema";
10
10
 
11
11
  // ═══════════════════════════════════════════════════════════════════════════
12
12
  // Segment Types
@@ -123,6 +123,9 @@ export interface FSRouteConfig {
123
123
  /** Named export used by the route-level client module, if not default. */
124
124
  clientExportName?: string;
125
125
 
126
+ /** Compiler-discovered client boundaries rendered by this route. */
127
+ boundaries?: RouteClientBoundary[];
128
+
126
129
  /** 적용할 레이아웃 체인 */
127
130
  layoutChain: string[];
128
131
 
@@ -223,7 +226,13 @@ export interface ScanResult {
223
226
  */
224
227
  export interface ScanError {
225
228
  /** 에러 타입 */
226
- type: "invalid_segment" | "duplicate_route" | "file_read_error" | "pattern_conflict" | "hydration_shell_mismatch_risk";
229
+ type:
230
+ | "invalid_segment"
231
+ | "duplicate_route"
232
+ | "file_read_error"
233
+ | "pattern_conflict"
234
+ | "hydration_shell_mismatch_risk"
235
+ | "route_source_diagnostic";
227
236
 
228
237
  /** 에러 메시지 */
229
238
  message: string;