@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.
- package/package.json +3 -1
- package/src/agent/__tests__/context.test.ts +94 -25
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/agent/verify.ts +55 -24
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +478 -9
- package/src/bundler/build.ts +424 -746
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/client/__tests__/props-serialization.test.ts +37 -0
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/props-serialization.ts +233 -0
- package/src/client/runtime-entry.ts +567 -0
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -404
- package/src/diagnose/__tests__/checks.test.ts +132 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +154 -29
- package/src/router/client-entry.ts +111 -313
- package/src/router/fs-routes.test.ts +443 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +176 -57
- package/src/router/fs-types.ts +11 -2
- package/src/router/route-source-analyzer.ts +521 -0
- package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
- package/src/runtime/__tests__/page-render-response.test.ts +218 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +24 -1
- package/src/runtime/server.ts +14 -0
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
package/src/router/fs-scanner.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
* 스캐너 생성 팩토리 함수
|
package/src/router/fs-types.ts
CHANGED
|
@@ -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:
|
|
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;
|