@mandujs/core 0.54.13 → 0.54.15
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/__tests__/build-runner.ts +31 -12
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/__tests__/hdr.test.ts +10 -3
- package/src/bundler/__tests__/hmr-client.test.ts +16 -11
- package/src/bundler/build.test.ts +101 -49
- package/src/bundler/build.ts +244 -115
- package/src/client/router.ts +47 -40
- package/src/router/client-entry.test.ts +15 -8
- package/src/router/client-entry.ts +22 -15
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +13 -7
- package/src/router/fs-scanner.ts +60 -16
- package/src/runtime/__tests__/page-render-response.test.ts +194 -0
- package/src/runtime/page-render-response.ts +106 -4
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/server.ts +121 -58
- package/src/runtime/streaming-ssr.ts +26 -18
package/src/bundler/build.ts
CHANGED
|
@@ -522,25 +522,103 @@ function generateRuntimeSource(): string {
|
|
|
522
522
|
import React, { useState, useEffect, Component } from 'react';
|
|
523
523
|
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
524
524
|
|
|
525
|
-
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
526
|
-
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
527
|
-
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
525
|
+
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
526
|
+
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
527
|
+
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
528
|
+
|
|
529
|
+
const TYPE_MARKERS = {
|
|
530
|
+
UNDEFINED: "\\u0000_",
|
|
531
|
+
DATE: "\\u0000D",
|
|
532
|
+
URL: "\\u0000U",
|
|
533
|
+
REGEXP: "\\u0000R",
|
|
534
|
+
MAP: "\\u0000M",
|
|
535
|
+
SET: "\\u0000S",
|
|
536
|
+
REF: "\\u0000$",
|
|
537
|
+
BIGINT: "\\u0000B",
|
|
538
|
+
SYMBOL: "\\u0000Y",
|
|
539
|
+
ERROR: "\\u0000E",
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
function deserializeManduProps(json) {
|
|
543
|
+
const ctx = { refs: [] };
|
|
544
|
+
return deserializeManduValue(JSON.parse(json), ctx);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function deserializeManduValue(value, ctx) {
|
|
548
|
+
if (value === null) return null;
|
|
549
|
+
if (typeof value === 'string') {
|
|
550
|
+
if (value === TYPE_MARKERS.UNDEFINED) return undefined;
|
|
551
|
+
if (value.startsWith("\\u0000\\u0000")) return value.slice(2);
|
|
552
|
+
if (value.startsWith(TYPE_MARKERS.DATE)) return new Date(value.slice(2));
|
|
553
|
+
if (value.startsWith(TYPE_MARKERS.URL)) return new URL(value.slice(2));
|
|
554
|
+
if (value.startsWith(TYPE_MARKERS.REGEXP)) {
|
|
555
|
+
const str = value.slice(2);
|
|
556
|
+
const match = str.match(/^\\/(.*)\\/([gimsuy]*)$/);
|
|
557
|
+
return match ? new RegExp(match[1], match[2]) : str;
|
|
558
|
+
}
|
|
559
|
+
if (value.startsWith(TYPE_MARKERS.BIGINT)) return BigInt(value.slice(2));
|
|
560
|
+
if (value.startsWith(TYPE_MARKERS.SYMBOL)) return Symbol(value.slice(2));
|
|
561
|
+
if (value.startsWith(TYPE_MARKERS.REF)) return ctx.refs[parseInt(value.slice(2), 10)];
|
|
562
|
+
return value;
|
|
563
|
+
}
|
|
564
|
+
if (typeof value === 'boolean' || typeof value === 'number') return value;
|
|
565
|
+
if (Array.isArray(value)) {
|
|
566
|
+
const marker = value[0];
|
|
567
|
+
if (marker === TYPE_MARKERS.ERROR) {
|
|
568
|
+
const error = new Error(value[2]);
|
|
569
|
+
error.name = value[1];
|
|
570
|
+
if (value[3]) error.stack = value[3];
|
|
571
|
+
ctx.refs.push(error);
|
|
572
|
+
return error;
|
|
573
|
+
}
|
|
574
|
+
if (marker === TYPE_MARKERS.MAP) {
|
|
575
|
+
const map = new Map();
|
|
576
|
+
ctx.refs.push(map);
|
|
577
|
+
for (let i = 1; i < value.length; i++) {
|
|
578
|
+
const entry = value[i];
|
|
579
|
+
map.set(deserializeManduValue(entry[0], ctx), deserializeManduValue(entry[1], ctx));
|
|
580
|
+
}
|
|
581
|
+
return map;
|
|
582
|
+
}
|
|
583
|
+
if (marker === TYPE_MARKERS.SET) {
|
|
584
|
+
const set = new Set();
|
|
585
|
+
ctx.refs.push(set);
|
|
586
|
+
for (let i = 1; i < value.length; i++) {
|
|
587
|
+
set.add(deserializeManduValue(value[i], ctx));
|
|
588
|
+
}
|
|
589
|
+
return set;
|
|
590
|
+
}
|
|
591
|
+
const arr = [];
|
|
592
|
+
ctx.refs.push(arr);
|
|
593
|
+
for (const item of value) arr.push(deserializeManduValue(item, ctx));
|
|
594
|
+
return arr;
|
|
595
|
+
}
|
|
596
|
+
if (typeof value === 'object') {
|
|
597
|
+
const obj = {};
|
|
598
|
+
ctx.refs.push(obj);
|
|
599
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
600
|
+
obj[key] = deserializeManduValue(nested, ctx);
|
|
601
|
+
}
|
|
602
|
+
return obj;
|
|
603
|
+
}
|
|
604
|
+
return value;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 서버 데이터
|
|
608
|
+
function readManduData() {
|
|
609
|
+
if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
|
|
532
610
|
|
|
533
611
|
const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
|
|
534
612
|
if (!raw) {
|
|
535
613
|
window.__MANDU_DATA__ = {};
|
|
536
614
|
return window.__MANDU_DATA__;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
try {
|
|
540
|
-
window.__MANDU_DATA__ =
|
|
541
|
-
} catch (error) {
|
|
542
|
-
console.warn('[Mandu] Failed to parse server data:', error);
|
|
543
|
-
window.__MANDU_DATA__ = {};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
window.__MANDU_DATA__ = deserializeManduProps(raw);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
console.warn('[Mandu] Failed to parse server data:', error);
|
|
621
|
+
window.__MANDU_DATA__ = {};
|
|
544
622
|
}
|
|
545
623
|
|
|
546
624
|
return window.__MANDU_DATA__;
|
|
@@ -681,26 +759,62 @@ function createHydrationOptions(element, id, mode) {
|
|
|
681
759
|
};
|
|
682
760
|
}
|
|
683
761
|
|
|
684
|
-
/**
|
|
685
|
-
* Hydration 스케줄러
|
|
686
|
-
*/
|
|
687
|
-
function
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
762
|
+
/**
|
|
763
|
+
* Hydration 스케줄러
|
|
764
|
+
*/
|
|
765
|
+
function priorityToHydrateStrategy(priority) {
|
|
766
|
+
return priority === 'immediate' ? 'load' : priority;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function scheduleHydration(element, src, strategy) {
|
|
770
|
+
if (!strategy) strategy = 'load';
|
|
771
|
+
if (strategy === 'immediate') strategy = 'load';
|
|
772
|
+
|
|
773
|
+
if (strategy.startsWith('media(') && strategy.endsWith(')')) {
|
|
774
|
+
const query = strategy.slice('media('.length, -1).trim();
|
|
775
|
+
if (!query || !window.matchMedia) {
|
|
776
|
+
loadAndHydrate(element, src);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const mql = window.matchMedia(query);
|
|
780
|
+
if (mql.matches) {
|
|
781
|
+
loadAndHydrate(element, src);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const onChange = (event) => {
|
|
785
|
+
if (!event.matches) return;
|
|
786
|
+
if (mql.removeEventListener) {
|
|
787
|
+
mql.removeEventListener('change', onChange);
|
|
788
|
+
} else if (mql.removeListener) {
|
|
789
|
+
mql.removeListener(onChange);
|
|
790
|
+
}
|
|
791
|
+
loadAndHydrate(element, src);
|
|
792
|
+
};
|
|
793
|
+
if (mql.addEventListener) {
|
|
794
|
+
mql.addEventListener('change', onChange);
|
|
795
|
+
} else if (mql.addListener) {
|
|
796
|
+
mql.addListener(onChange);
|
|
797
|
+
}
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
switch (strategy) {
|
|
802
|
+
case 'load':
|
|
803
|
+
case 'immediate':
|
|
804
|
+
loadAndHydrate(element, src);
|
|
805
|
+
break;
|
|
692
806
|
|
|
693
807
|
case 'visible':
|
|
694
808
|
if ('IntersectionObserver' in window) {
|
|
695
809
|
const observer = new IntersectionObserver((entries) => {
|
|
696
|
-
if (entries[0].isIntersecting) {
|
|
697
|
-
observer.disconnect();
|
|
698
|
-
loadAndHydrate(element, src);
|
|
699
|
-
}
|
|
700
|
-
}, { rootMargin: '
|
|
701
|
-
const target = resolveHydrationTarget(element);
|
|
702
|
-
observer.observe(target);
|
|
703
|
-
} else {
|
|
810
|
+
if (entries[0].isIntersecting) {
|
|
811
|
+
observer.disconnect();
|
|
812
|
+
loadAndHydrate(element, src);
|
|
813
|
+
}
|
|
814
|
+
}, { rootMargin: '200px' });
|
|
815
|
+
const target = resolveHydrationTarget(element);
|
|
816
|
+
observer.observe(target);
|
|
817
|
+
} else {
|
|
704
818
|
loadAndHydrate(element, src);
|
|
705
819
|
}
|
|
706
820
|
break;
|
|
@@ -712,26 +826,26 @@ function scheduleHydration(element, src, priority) {
|
|
|
712
826
|
setTimeout(() => loadAndHydrate(element, src), 200);
|
|
713
827
|
}
|
|
714
828
|
break;
|
|
715
|
-
|
|
716
|
-
case 'interaction': {
|
|
717
|
-
const target = resolveHydrationTarget(element);
|
|
718
|
-
const hydrate = () => {
|
|
719
|
-
target.removeEventListener('
|
|
720
|
-
target.removeEventListener('
|
|
721
|
-
target.removeEventListener('
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
};
|
|
726
|
-
target.addEventListener('
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}
|
|
734
|
-
}
|
|
829
|
+
|
|
830
|
+
case 'interaction': {
|
|
831
|
+
const target = resolveHydrationTarget(element);
|
|
832
|
+
const hydrate = () => {
|
|
833
|
+
target.removeEventListener('touchstart', hydrate);
|
|
834
|
+
target.removeEventListener('click', hydrate);
|
|
835
|
+
target.removeEventListener('keydown', hydrate);
|
|
836
|
+
loadAndHydrate(element, src);
|
|
837
|
+
};
|
|
838
|
+
target.addEventListener('touchstart', hydrate, { once: true, passive: true });
|
|
839
|
+
target.addEventListener('click', hydrate, { once: true });
|
|
840
|
+
target.addEventListener('keydown', hydrate, { once: true });
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
default:
|
|
845
|
+
console.warn('[Mandu] Unknown hydrate strategy "' + strategy + '", falling back to load.');
|
|
846
|
+
loadAndHydrate(element, src);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
735
849
|
|
|
736
850
|
/**
|
|
737
851
|
* Island 로드 및 hydrate (핵심 함수)
|
|
@@ -767,12 +881,12 @@ async function loadAndHydrate(element, src) {
|
|
|
767
881
|
const propsEl = element.hasAttribute('data-props')
|
|
768
882
|
? element
|
|
769
883
|
: element.querySelector('[data-props]');
|
|
770
|
-
if (propsEl) {
|
|
771
|
-
try {
|
|
772
|
-
data =
|
|
773
|
-
} catch (e) {
|
|
774
|
-
console.warn('[Mandu] Failed to parse data-props fallback:', e);
|
|
775
|
-
}
|
|
884
|
+
if (propsEl) {
|
|
885
|
+
try {
|
|
886
|
+
data = deserializeManduProps(propsEl.getAttribute('data-props') || '{}');
|
|
887
|
+
} catch (e) {
|
|
888
|
+
console.warn('[Mandu] Failed to parse data-props fallback:', e);
|
|
889
|
+
}
|
|
776
890
|
}
|
|
777
891
|
}
|
|
778
892
|
|
|
@@ -916,12 +1030,13 @@ function hydrateIslands() {
|
|
|
916
1030
|
const seenIds = new Set();
|
|
917
1031
|
|
|
918
1032
|
for (const el of islands) {
|
|
919
|
-
const id = el.getAttribute('data-mandu-island');
|
|
920
|
-
const src = el.getAttribute('data-mandu-src');
|
|
921
|
-
const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1033
|
+
const id = el.getAttribute('data-mandu-island');
|
|
1034
|
+
const src = el.getAttribute('data-mandu-src');
|
|
1035
|
+
const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
|
|
1036
|
+
const hydrateStrategy = el.getAttribute('data-hydrate') || priorityToHydrateStrategy(priority);
|
|
1037
|
+
|
|
1038
|
+
if (!id || !src) {
|
|
1039
|
+
console.warn('[Mandu] Island missing id or src:', el);
|
|
925
1040
|
continue;
|
|
926
1041
|
}
|
|
927
1042
|
|
|
@@ -929,12 +1044,12 @@ function hydrateIslands() {
|
|
|
929
1044
|
if (seenIds.has(id)) {
|
|
930
1045
|
console.warn('[Mandu] Duplicate island id detected:', id, '- skipping');
|
|
931
1046
|
continue;
|
|
932
|
-
}
|
|
933
|
-
seenIds.add(id);
|
|
934
|
-
|
|
935
|
-
scheduleHydration(el, src,
|
|
936
|
-
}
|
|
937
|
-
}
|
|
1047
|
+
}
|
|
1048
|
+
seenIds.add(id);
|
|
1049
|
+
|
|
1050
|
+
scheduleHydration(el, src, hydrateStrategy);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
938
1053
|
|
|
939
1054
|
/**
|
|
940
1055
|
* Island unmount
|
|
@@ -1168,39 +1283,44 @@ function patternCacheSet(key, value) {
|
|
|
1168
1283
|
patternCache.set(key, value);
|
|
1169
1284
|
}
|
|
1170
1285
|
|
|
1171
|
-
function compilePattern(pattern) {
|
|
1172
|
-
var cached = patternCacheGet(pattern);
|
|
1173
|
-
if (cached) return cached;
|
|
1174
|
-
|
|
1175
|
-
const paramNames = [];
|
|
1176
|
-
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
}
|
|
1286
|
+
function compilePattern(pattern) {
|
|
1287
|
+
var cached = patternCacheGet(pattern);
|
|
1288
|
+
if (cached) return cached;
|
|
1289
|
+
|
|
1290
|
+
const paramNames = [];
|
|
1291
|
+
const normalized = pattern === '/' ? '/' : pattern.replace(/\\/+$/, '') || '/';
|
|
1292
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
1293
|
+
const regexStr = segments.length === 0
|
|
1294
|
+
? '/'
|
|
1295
|
+
: segments.map((segment) => {
|
|
1296
|
+
if (segment === '*') return '/.+';
|
|
1297
|
+
const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\\*(\\?)?$/);
|
|
1298
|
+
if (wildcardMatch) {
|
|
1299
|
+
paramNames.push(wildcardMatch[1]);
|
|
1300
|
+
return wildcardMatch[2] === '?' ? '(?:/(.*))?' : '/(.+)';
|
|
1301
|
+
}
|
|
1302
|
+
const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
1303
|
+
if (paramMatch) {
|
|
1304
|
+
paramNames.push(paramMatch[1]);
|
|
1305
|
+
return '/([^/]+)';
|
|
1306
|
+
}
|
|
1307
|
+
return '/' + segment.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
|
|
1308
|
+
}).join('');
|
|
1309
|
+
|
|
1310
|
+
const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
|
|
1311
|
+
patternCacheSet(pattern, compiled);
|
|
1312
|
+
return compiled;
|
|
1313
|
+
}
|
|
1194
1314
|
|
|
1195
1315
|
function extractParams(pattern, pathname) {
|
|
1196
1316
|
const compiled = compilePattern(pattern);
|
|
1197
1317
|
const match = pathname.match(compiled.regex);
|
|
1198
1318
|
if (!match) return {};
|
|
1199
|
-
|
|
1200
|
-
const params = {};
|
|
1201
|
-
compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1]; });
|
|
1202
|
-
return params;
|
|
1203
|
-
}
|
|
1319
|
+
|
|
1320
|
+
const params = {};
|
|
1321
|
+
compiled.paramNames.forEach((name, i) => { params[name] = match[i + 1] || ''; });
|
|
1322
|
+
return params;
|
|
1323
|
+
}
|
|
1204
1324
|
|
|
1205
1325
|
function notifyListeners() {
|
|
1206
1326
|
const state = getGlobalState();
|
|
@@ -1929,25 +2049,34 @@ async function buildVendorShims(
|
|
|
1929
2049
|
};
|
|
1930
2050
|
}
|
|
1931
2051
|
|
|
1932
|
-
function vendorShimFailureHint(shimName: string): string {
|
|
1933
|
-
if (shimName.includes("react-refresh")) {
|
|
1934
|
-
return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
|
|
1935
|
-
}
|
|
1936
|
-
return "Hint: check the import paths and ensure the vendor package is installed.";
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
2052
|
+
function vendorShimFailureHint(shimName: string): string {
|
|
2053
|
+
if (shimName.includes("react-refresh")) {
|
|
2054
|
+
return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
|
|
2055
|
+
}
|
|
2056
|
+
return "Hint: check the import paths and ensure the vendor package is installed.";
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function routeIdToAssetStem(routeId: string): string {
|
|
2060
|
+
const safe = routeId.replace(/[<>:"/\\|?*\x00-\x1F]/g, (ch) =>
|
|
2061
|
+
`_${ch.codePointAt(0)!.toString(16)}_`
|
|
2062
|
+
);
|
|
2063
|
+
return safe.replace(/[. ]+$/g, "") || "route";
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* 단일 Island 번들 빌드
|
|
2068
|
+
*/
|
|
1942
2069
|
async function buildIsland(
|
|
1943
2070
|
route: RouteSpec,
|
|
1944
2071
|
rootDir: string,
|
|
1945
2072
|
outDir: string,
|
|
1946
2073
|
options: BundlerOptions
|
|
1947
|
-
): Promise<BundleOutput> {
|
|
1948
|
-
const clientModulePath = path.join(rootDir, route.clientModule!);
|
|
1949
|
-
const
|
|
1950
|
-
const
|
|
2074
|
+
): Promise<BundleOutput> {
|
|
2075
|
+
const clientModulePath = path.join(rootDir, route.clientModule!);
|
|
2076
|
+
const assetStem = routeIdToAssetStem(route.id);
|
|
2077
|
+
const entryStem = `_entry_${assetStem}`;
|
|
2078
|
+
const entryPath = path.join(outDir, `${entryStem}.js`);
|
|
2079
|
+
const outputName = `${assetStem}.island.js`;
|
|
1951
2080
|
|
|
1952
2081
|
// Phase 7.1 B-1/B-4: wire native Fast Refresh transform + Mandu's
|
|
1953
2082
|
// boundary injection plugin. Dev-only; prod bundles remain clean.
|
|
@@ -1988,11 +2117,11 @@ async function buildIsland(
|
|
|
1988
2117
|
let actualOutputPath: string;
|
|
1989
2118
|
let actualOutputName: string;
|
|
1990
2119
|
|
|
1991
|
-
if (options.splitting && result.outputs.length > 0) {
|
|
1992
|
-
// splitting 모드: 결과에서 엔트리 파일 찾기
|
|
1993
|
-
const entryOutput = result.outputs.find(
|
|
1994
|
-
(o) => o.kind === "entry-point" || o.path.includes(
|
|
1995
|
-
);
|
|
2120
|
+
if (options.splitting && result.outputs.length > 0) {
|
|
2121
|
+
// splitting 모드: 결과에서 엔트리 파일 찾기
|
|
2122
|
+
const entryOutput = result.outputs.find(
|
|
2123
|
+
(o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem)
|
|
2124
|
+
);
|
|
1996
2125
|
if (entryOutput) {
|
|
1997
2126
|
actualOutputPath = entryOutput.path;
|
|
1998
2127
|
actualOutputName = path.basename(entryOutput.path);
|
package/src/client/router.ts
CHANGED
|
@@ -165,39 +165,42 @@ const patternCache = new LRUCache<string, CompiledPattern>(LIMITS.ROUTER_PATTERN
|
|
|
165
165
|
// because `registerCacheSize` replaces any prior reporter under the same key.
|
|
166
166
|
registerCacheSize("patternCache", () => patternCache.size);
|
|
167
167
|
|
|
168
|
-
/**
|
|
169
|
-
* 패턴을 정규식으로 컴파일
|
|
170
|
-
*/
|
|
171
|
-
function compilePattern(pattern: string): CompiledPattern {
|
|
172
|
-
const cached = patternCache.get(pattern);
|
|
173
|
-
if (cached) return cached;
|
|
174
|
-
|
|
175
|
-
const paramNames: string[] = [];
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
168
|
+
/**
|
|
169
|
+
* 패턴을 정규식으로 컴파일
|
|
170
|
+
*/
|
|
171
|
+
function compilePattern(pattern: string): CompiledPattern {
|
|
172
|
+
const cached = patternCache.get(pattern);
|
|
173
|
+
if (cached) return cached;
|
|
174
|
+
|
|
175
|
+
const paramNames: string[] = [];
|
|
176
|
+
const normalized = pattern === "/" ? "/" : pattern.replace(/\/+$/, "") || "/";
|
|
177
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
178
|
+
|
|
179
|
+
const regexStr = segments.length === 0
|
|
180
|
+
? "/"
|
|
181
|
+
: segments.map((segment) => {
|
|
182
|
+
if (segment === "*") {
|
|
183
|
+
return "/.+";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\*(\?)?$/);
|
|
187
|
+
if (wildcardMatch) {
|
|
188
|
+
paramNames.push(wildcardMatch[1]);
|
|
189
|
+
return wildcardMatch[2] === "?" ? "(?:/(.*))?" : "/(.+)";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
193
|
+
if (paramMatch) {
|
|
194
|
+
paramNames.push(paramMatch[1]);
|
|
195
|
+
return "/([^/]+)";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return `/${escapePatternSegment(segment)}`;
|
|
199
|
+
}).join("");
|
|
200
|
+
|
|
201
|
+
const compiled = {
|
|
202
|
+
regex: new RegExp(`^${regexStr}$`),
|
|
203
|
+
paramNames,
|
|
201
204
|
};
|
|
202
205
|
|
|
203
206
|
patternCache.set(pattern, compiled);
|
|
@@ -216,13 +219,17 @@ function extractParamsFromPath(
|
|
|
216
219
|
|
|
217
220
|
if (!match) return {};
|
|
218
221
|
|
|
219
|
-
const params: Record<string, string> = {};
|
|
220
|
-
compiled.paramNames.forEach((name, index) => {
|
|
221
|
-
params[name] = match[index + 1];
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
return params;
|
|
225
|
-
}
|
|
222
|
+
const params: Record<string, string> = {};
|
|
223
|
+
compiled.paramNames.forEach((name, index) => {
|
|
224
|
+
params[name] = match[index + 1] ?? "";
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return params;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function escapePatternSegment(segment: string): string {
|
|
231
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
232
|
+
}
|
|
226
233
|
|
|
227
234
|
// ========== Navigation ==========
|
|
228
235
|
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
-
import path from "path";
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
4
|
import {
|
|
5
5
|
findClientComponentImports,
|
|
6
6
|
findRouteLevelClientComponentImport,
|
|
7
7
|
findRouteLevelClientComponentImports,
|
|
8
8
|
resolveRouteLevelClientEntryPath,
|
|
9
|
-
} from "./client-entry";
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
} from "./client-entry";
|
|
10
|
+
|
|
11
|
+
const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
|
|
12
|
+
|
|
13
|
+
async function mkRepoTempDir(prefix: string): Promise<string> {
|
|
14
|
+
await mkdir(repoTempRoot, { recursive: true });
|
|
15
|
+
return mkdtemp(path.join(repoTempRoot, prefix));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("findClientComponentImports", () => {
|
|
12
19
|
it("detects named .client imports for diagnostics", () => {
|
|
13
20
|
const imports = findClientComponentImports(`
|
|
14
21
|
import { LoginForm, SubmitButton as Button } from "@/client/widgets/login-form/LoginForm.client";
|
|
@@ -192,8 +199,8 @@ describe("findClientComponentImports", () => {
|
|
|
192
199
|
expect(routeClient).toBeNull();
|
|
193
200
|
});
|
|
194
201
|
|
|
195
|
-
it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
|
|
196
|
-
const rootDir = await
|
|
202
|
+
it("resolves a route-level client entry by reading a use client target without .client in the path", async () => {
|
|
203
|
+
const rootDir = await mkRepoTempDir("client-entry-");
|
|
197
204
|
try {
|
|
198
205
|
await mkdir(path.join(rootDir, "app", "pledges", "new"), { recursive: true });
|
|
199
206
|
await mkdir(path.join(rootDir, "src", "client", "widgets", "pledge-form"), { recursive: true });
|
|
@@ -216,21 +216,28 @@ export async function resolveRouteLevelClientEntry(
|
|
|
216
216
|
return null;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
export async function shouldPreserveExistingClientModule(
|
|
220
|
-
route: RouteSpec,
|
|
221
|
-
clientModule: string,
|
|
222
|
-
rootDir: string,
|
|
223
|
-
): Promise<boolean> {
|
|
224
|
-
if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
|
|
225
|
-
const source = await readRouteModule(rootDir, clientModule);
|
|
226
|
-
if (source === null) return false;
|
|
227
|
-
if (hasUseServerDirective(source)) return false;
|
|
228
|
-
if (clientModuleIsRouteComponent(route, clientModule)) {
|
|
229
|
-
if (hasUseClientDirective(source)) return true;
|
|
230
|
-
return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
219
|
+
export async function shouldPreserveExistingClientModule(
|
|
220
|
+
route: RouteSpec,
|
|
221
|
+
clientModule: string,
|
|
222
|
+
rootDir: string,
|
|
223
|
+
): Promise<boolean> {
|
|
224
|
+
if (!isRouteLevelClientEntrySpecifier(clientModule)) return false;
|
|
225
|
+
const source = await readRouteModule(rootDir, clientModule);
|
|
226
|
+
if (source === null) return false;
|
|
227
|
+
if (hasUseServerDirective(source)) return false;
|
|
228
|
+
if (clientModuleIsRouteComponent(route, clientModule)) {
|
|
229
|
+
if (hasUseClientDirective(source)) return true;
|
|
230
|
+
return await routeComponentHasResolvableClientEntry(rootDir, clientModule, source);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (route.kind !== "page" || !route.componentModule) return false;
|
|
234
|
+
|
|
235
|
+
const routeSource = await readRouteModule(rootDir, route.componentModule);
|
|
236
|
+
if (routeSource === null) return false;
|
|
237
|
+
|
|
238
|
+
const currentEntry = await resolveRouteLevelClientEntry(rootDir, route.componentModule, routeSource);
|
|
239
|
+
return normalizeRouteModulePath(currentEntry?.modulePath) === normalizeRouteModulePath(clientModule);
|
|
240
|
+
}
|
|
234
241
|
|
|
235
242
|
function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
|
|
236
243
|
const normalized = specifier.replace(/\\/g, "/");
|