@mandujs/core 0.23.0 → 0.25.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.
@@ -15,8 +15,15 @@ import type {
15
15
  HMRReplayEnvelope,
16
16
  } from "./hmr-types";
17
17
  import { MAX_REPLAY_BUFFER, REPLAY_MAX_AGE_MS } from "./hmr-types";
18
+ import {
19
+ ReverseImportGraph,
20
+ scanFileImports,
21
+ DEFAULT_MAX_CLOSURE_DEPTH,
22
+ } from "./reverse-import-graph";
18
23
  import path from "path";
19
24
  import fs from "fs";
25
+ import { LRUCache } from "../utils/lru-cache";
26
+ import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
20
27
 
21
28
  /**
22
29
  * #184: 공통 디렉토리 변경 시 사용하는 sentinel.
@@ -312,6 +319,9 @@ export const _testOnly_DEFAULT_COMMON_DIRS = DEFAULT_COMMON_DIRS;
312
319
  /** Test-only accessor for the watch exclude segments (B1 coverage). */
313
320
  export const _testOnly_WATCH_EXCLUDE_SEGMENTS = WATCH_EXCLUDE_SEGMENTS;
314
321
 
322
+ /** Test-only re-export for #189 reverse import-graph coverage. */
323
+ export { ReverseImportGraph, scanFileImports } from "./reverse-import-graph";
324
+
315
325
  /**
316
326
  * Phase 7.0 R2 Agent D — classification helper mirroring the in-bundler
317
327
  * `classifyBatch` priority rules WITHOUT the project-specific maps
@@ -570,6 +580,99 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
570
580
  await addCommonDir(dir);
571
581
  }
572
582
 
583
+ // #189 — reverse import-graph for transitive invalidation.
584
+ //
585
+ // Built-time index of `importee -> Set<importer>` edges for every
586
+ // known SSR / API / client / common-dir root. On an unknown-file
587
+ // change (a leaf utility that none of our sets recognize) we walk
588
+ // the transitive importer closure and re-dispatch the change
589
+ // against every ancestor that IS known — so a deep edit to
590
+ // `app/_utils/translations/ko.ts` still re-evaluates the barrel
591
+ // in `app/_utils/translations/index.ts` that imported it.
592
+ //
593
+ // The initial population is best-effort (filesystem scan may
594
+ // surface typed-only stubs that have no source yet); misses simply
595
+ // keep the legacy "silent drop" behavior so no project is made
596
+ // worse by turning this on.
597
+ const reverseGraph = new ReverseImportGraph();
598
+
599
+ /**
600
+ * Re-scan a single file's imports and update its row in the
601
+ * reverse graph. Called on startup for every known root, and
602
+ * again each time a file change is dispatched so the graph
603
+ * tracks refactors (an import being added / removed) in real
604
+ * time.
605
+ *
606
+ * Errors are swallowed — a missing file / permission issue must
607
+ * not break the HMR dispatch for the rest of the project.
608
+ */
609
+ const refreshReverseGraphEdges = async (filePath: string): Promise<void> => {
610
+ try {
611
+ const imports = await scanFileImports(filePath);
612
+ reverseGraph.update(filePath, imports);
613
+ } catch {
614
+ // scanFileImports already swallows fs errors; this extra guard
615
+ // catches anything pathological (e.g. a symlink loop).
616
+ }
617
+ };
618
+
619
+ /**
620
+ * Populate the reverse graph transitively from every known root.
621
+ *
622
+ * The BFS walks each root's outgoing imports, scans every
623
+ * discovered intermediate, and keeps going until the frontier is
624
+ * empty or we hit the depth cap. Without this transitive seed,
625
+ * a deep edit (root -> barrel -> leaf) would see
626
+ * `reverseGraph.directImporters(leaf) === {}` because only the
627
+ * root's edges got recorded — exactly the bug #189 describes.
628
+ *
629
+ * The walk is I/O-bound but bounded: each node is visited at most
630
+ * once (`visited` set) and we cap the seeding at
631
+ * `DEFAULT_MAX_CLOSURE_DEPTH` hops to keep startup cost predictable
632
+ * on projects with dense import graphs. Subsequent edits still
633
+ * extend the graph via `refreshReverseGraphEdges` in `_doBuild`.
634
+ */
635
+ const seedReverseGraph = async (): Promise<void> => {
636
+ const roots = new Set<string>();
637
+ for (const rootPath of serverModuleSet) roots.add(rootPath);
638
+ for (const rootPath of apiModuleSet) roots.add(rootPath);
639
+ for (const rootPath of clientModuleToRoute.keys()) roots.add(rootPath);
640
+
641
+ const visited = new Set<string>();
642
+ let frontier = Array.from(roots);
643
+
644
+ for (
645
+ let depth = 0;
646
+ depth < DEFAULT_MAX_CLOSURE_DEPTH && frontier.length > 0;
647
+ depth++
648
+ ) {
649
+ // Scan the current frontier in parallel — these are independent
650
+ // file reads. The scan returns the RESOLVED absolute importee
651
+ // paths, which become the next frontier.
652
+ const scans = await Promise.all(
653
+ frontier.map(async (filePath) => {
654
+ if (visited.has(filePath)) return [];
655
+ visited.add(filePath);
656
+ try {
657
+ const imports = await scanFileImports(filePath);
658
+ reverseGraph.update(filePath, imports);
659
+ return imports;
660
+ } catch {
661
+ return [];
662
+ }
663
+ }),
664
+ );
665
+
666
+ const next: string[] = [];
667
+ for (const group of scans) {
668
+ for (const dep of group) {
669
+ if (!visited.has(dep)) next.push(dep);
670
+ }
671
+ }
672
+ frontier = next;
673
+ }
674
+ };
675
+
573
676
  // 파일 감시 설정
574
677
  const watchers: fs.FSWatcher[] = [];
575
678
 
@@ -584,8 +687,19 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
584
687
  * Lifecycle: timers are created by `scheduleFileChange`, cleared on flush or
585
688
  * on `close()`. We call `.delete(key)` on flush to keep the Map bounded —
586
689
  * no leak from editing a single file repeatedly.
690
+ *
691
+ * Phase 17 — upgraded to `LRUCache` with an `onEvict` hook so
692
+ * (a) a pathological watcher (millions of distinct files) cannot leak
693
+ * (b) evicted entries still get `clearTimeout` called (no dangling refs)
694
+ * (c) the size is registered with `/_mandu/metrics`
695
+ * Max 2000 entries is generous for even large monorepos (each entry is a
696
+ * single in-flight debounce token; flush cycle is 100 ms).
587
697
  */
588
- const perFileTimers = new Map<string, ReturnType<typeof setTimeout>>();
698
+ const perFileTimers = new LRUCache<string, ReturnType<typeof setTimeout>>({
699
+ maxSize: 2000,
700
+ onEvict: (_key, timer) => clearTimeout(timer),
701
+ });
702
+ registerCacheSize("perFileTimers", () => perFileTimers.size);
589
703
 
590
704
  /**
591
705
  * B2 fix — multi-file pending build queue (Phase 7.0 R1 Agent A).
@@ -974,9 +1088,157 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
974
1088
  perFileTimers.set(key, timer);
975
1089
  };
976
1090
 
1091
+ /**
1092
+ * #189 — dispatch an unknown-file change against every known root
1093
+ * that transitively imports it.
1094
+ *
1095
+ * The reverse graph answers "which SSR / API / client modules end
1096
+ * up depending on this file?" via a bounded BFS closure. Each
1097
+ * matched root is re-dispatched through the SAME callbacks a
1098
+ * direct change would fire (`onSSRChange`, `onAPIChange`, or an
1099
+ * island rebuild), so downstream behavior is identical to a
1100
+ * direct edit. This closes the "transitive ESM cache" gap
1101
+ * described in issue #189 by making sure every ancestor that
1102
+ * consumed the leaf gets its bundle rebuilt / handler re-
1103
+ * registered.
1104
+ *
1105
+ * Returns the number of roots actually dispatched so the caller
1106
+ * can decide whether to log a "no transitive importers" line or
1107
+ * stay silent. Dispatch is idempotent — each root is touched at
1108
+ * most once per change via the `dispatched` set.
1109
+ */
1110
+ const dispatchByTransitiveImporters = async (
1111
+ changedFile: string,
1112
+ ): Promise<number> => {
1113
+ const absChanged = path.resolve(rootDir, changedFile);
1114
+ // Refresh the changed file's OWN imports first. A leaf edit can
1115
+ // legitimately add or remove imports (e.g. switching a barrel
1116
+ // from `./en` to `./ko`); the reverse graph must track that so
1117
+ // the NEXT unknown-file change uses the correct set.
1118
+ await refreshReverseGraphEdges(absChanged);
1119
+
1120
+ const importers = reverseGraph.transitiveImporters(
1121
+ absChanged,
1122
+ DEFAULT_MAX_CLOSURE_DEPTH,
1123
+ );
1124
+ if (importers.size === 0) return 0;
1125
+
1126
+ // Partition importers by which known-root bucket they belong
1127
+ // to. A single file can be in multiple buckets (e.g. a shared
1128
+ // `.client.tsx` that is also referenced by a server module) —
1129
+ // iterate in priority order: SSR first (heaviest signal), then
1130
+ // API, then client islands.
1131
+ const ssrRoots: string[] = [];
1132
+ const apiRoots: string[] = [];
1133
+ const islandRoots: Array<{ routeId: string; path: string }> = [];
1134
+
1135
+ for (const importerAbs of importers) {
1136
+ if (serverModuleSet.has(importerAbs)) {
1137
+ ssrRoots.push(importerAbs);
1138
+ continue;
1139
+ }
1140
+ if (apiModuleSet.has(importerAbs)) {
1141
+ apiRoots.push(importerAbs);
1142
+ continue;
1143
+ }
1144
+ const islandRouteId = clientModuleToRoute.get(importerAbs);
1145
+ if (islandRouteId) {
1146
+ islandRoots.push({ routeId: islandRouteId, path: importerAbs });
1147
+ }
1148
+ }
1149
+
1150
+ const totalMatched = ssrRoots.length + apiRoots.length + islandRoots.length;
1151
+ if (totalMatched === 0) return 0;
1152
+
1153
+ const relFile = path.relative(rootDir, absChanged).replace(/\\/g, "/");
1154
+ console.log(
1155
+ `\n🔄 ${path.basename(changedFile)} changed — invalidating ${totalMatched} transitive importer(s) (${relFile})`,
1156
+ );
1157
+
1158
+ // SSR: fire once per distinct importer path.
1159
+ if (onSSRChange) {
1160
+ for (const ssrRoot of ssrRoots) {
1161
+ try {
1162
+ await Promise.resolve(onSSRChange(ssrRoot));
1163
+ } catch (err) {
1164
+ console.error(
1165
+ "[Mandu HMR] transitive onSSRChange threw:",
1166
+ err instanceof Error ? err.message : String(err),
1167
+ );
1168
+ }
1169
+ }
1170
+ }
1171
+
1172
+ // API: same pattern.
1173
+ if (onAPIChange) {
1174
+ for (const apiRoot of apiRoots) {
1175
+ try {
1176
+ await Promise.resolve(onAPIChange(apiRoot));
1177
+ } catch (err) {
1178
+ console.error(
1179
+ "[Mandu HMR] transitive onAPIChange threw:",
1180
+ err instanceof Error ? err.message : String(err),
1181
+ );
1182
+ }
1183
+ }
1184
+ }
1185
+
1186
+ // Islands: coalesce to a single build call when multiple routes
1187
+ // share the changed file. `buildClientBundles` with a
1188
+ // `targetRouteIds` list already dedupes internally.
1189
+ if (islandRoots.length > 0) {
1190
+ const targetIds = Array.from(new Set(islandRoots.map((r) => r.routeId)));
1191
+ const startTime = performance.now();
1192
+ try {
1193
+ const result = await buildClientBundles(manifest, rootDir, {
1194
+ minify: false,
1195
+ sourcemap: true,
1196
+ targetRouteIds: targetIds,
1197
+ });
1198
+ const buildTime = performance.now() - startTime;
1199
+ if (result.success) {
1200
+ console.log(
1201
+ `✅ Rebuilt ${targetIds.length} island(s) in ${buildTime.toFixed(0)}ms`,
1202
+ );
1203
+ for (const targetId of targetIds) {
1204
+ onRebuild?.({
1205
+ routeId: targetId,
1206
+ success: true,
1207
+ buildTime,
1208
+ });
1209
+ }
1210
+ } else {
1211
+ console.error("❌ Transitive island rebuild failed:", result.errors);
1212
+ for (const targetId of targetIds) {
1213
+ onRebuild?.({
1214
+ routeId: targetId,
1215
+ success: false,
1216
+ buildTime,
1217
+ error: result.errors.join(", "),
1218
+ });
1219
+ }
1220
+ }
1221
+ } catch (error) {
1222
+ const err = error instanceof Error ? error : new Error(String(error));
1223
+ console.error("❌ Transitive island rebuild error:", err.message);
1224
+ for (const { routeId: targetId } of islandRoots) {
1225
+ onError?.(err, targetId);
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ return totalMatched;
1231
+ };
1232
+
977
1233
  const _doBuild = async (changedFile: string) => {
978
1234
  const normalizedPath = normalizeFsPath(changedFile);
979
1235
 
1236
+ // #189 — refresh the changed file's imports so the reverse graph
1237
+ // tracks refactors regardless of which dispatch path fires below.
1238
+ // Fire-and-forget because the scan is I/O-bound and the dispatch
1239
+ // path cannot stall on it.
1240
+ void refreshReverseGraphEdges(path.resolve(rootDir, changedFile));
1241
+
980
1242
  // 공통 컴포넌트 디렉토리 변경 → Island만 재빌드 + SSR 레지스트리 invalidate (#184, #185)
981
1243
  if (isInCommonDir(changedFile)) {
982
1244
  console.log(`\n🔄 Common file changed: ${path.basename(changedFile)}`);
@@ -1053,12 +1315,16 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1053
1315
  // SSR 모듈 변경 감지 (page.tsx, layout.tsx) — #151
1054
1316
  if (onSSRChange && serverModuleSet.has(normalizedPath)) {
1055
1317
  console.log(`\n🔄 SSR file changed: ${path.basename(changedFile)}`);
1318
+ // Refresh the changed file's imports so the reverse graph
1319
+ // tracks refactors (a new import added to an SSR module).
1320
+ await refreshReverseGraphEdges(path.resolve(rootDir, changedFile));
1056
1321
  onSSRChange(normalizedPath);
1057
1322
  return;
1058
1323
  }
1059
1324
  // API 모듈 변경 감지 (route.ts)
1060
1325
  if (onAPIChange && apiModuleSet.has(normalizedPath)) {
1061
1326
  console.log(`\n🔄 API route changed: ${path.basename(changedFile)}`);
1327
+ await refreshReverseGraphEdges(path.resolve(rootDir, changedFile));
1062
1328
  onAPIChange(normalizedPath);
1063
1329
  return;
1064
1330
  }
@@ -1070,7 +1336,23 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1070
1336
  // reuse its existing `handleAPIChange` plumbing.
1071
1337
  if (onAPIChange && isRouteMiddlewareFile(normalizedPath)) {
1072
1338
  console.log(`\n🔄 Middleware changed: ${path.basename(changedFile)}`);
1339
+ await refreshReverseGraphEdges(path.resolve(rootDir, changedFile));
1073
1340
  onAPIChange(normalizedPath);
1341
+ return;
1342
+ }
1343
+ // #189 — reverse import-graph fallback.
1344
+ //
1345
+ // The changed file matched none of our direct dispatch sets.
1346
+ // Before silently dropping (legacy behavior), walk the
1347
+ // reverse graph to see which known roots transitively import
1348
+ // this file and re-dispatch against each one. This is what
1349
+ // makes a deep leaf edit (e.g. a barrel's static-map entry)
1350
+ // propagate without a manual restart.
1351
+ const dispatched = await dispatchByTransitiveImporters(changedFile);
1352
+ if (dispatched === 0) {
1353
+ // No known importer — preserve the legacy silent-drop path
1354
+ // so truly unrelated file changes (editor backups, tmp
1355
+ // files that slipped through the filter) stay cheap.
1074
1356
  }
1075
1357
  return;
1076
1358
  }
@@ -1078,6 +1360,12 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1078
1360
  const route = manifest.routes.find((r) => r.id === routeId);
1079
1361
  if (!route || !route.clientModule) return;
1080
1362
 
1363
+ // #189 — refresh the client island's outgoing imports so the
1364
+ // reverse graph tracks newly added / removed imports in the
1365
+ // island file itself. Fire-and-forget so it cannot stall the
1366
+ // rebuild.
1367
+ void refreshReverseGraphEdges(path.resolve(rootDir, changedFile));
1368
+
1081
1369
  console.log(`\n🔄 Rebuilding island: ${routeId}`);
1082
1370
  const startTime = performance.now();
1083
1371
 
@@ -1223,14 +1511,28 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1223
1511
  }
1224
1512
  }
1225
1513
 
1514
+ // #189 — seed the reverse import-graph AFTER watchers are wired so
1515
+ // any edit that lands during the scan still triggers `handleFileChange`.
1516
+ // The scan is fire-and-forget; the initial build is already complete
1517
+ // and the first user edit can't race the seed (first event goes
1518
+ // through the 100 ms debounce, by which point the scan has finished
1519
+ // for any realistic project size).
1520
+ seedReverseGraph().catch((err) => {
1521
+ console.warn(
1522
+ "[Mandu HMR] reverse import-graph seed skipped:",
1523
+ err instanceof Error ? err.message : String(err),
1524
+ );
1525
+ });
1526
+
1226
1527
  return {
1227
1528
  initialBuild,
1228
1529
  close: () => {
1229
1530
  // B6: clear all per-file timers to release event-loop refs.
1230
- for (const timer of perFileTimers.values()) {
1231
- clearTimeout(timer);
1232
- }
1531
+ // Phase 17 `LRUCache.clear()` fires the registered `onEvict`
1532
+ // (`clearTimeout(timer)`) for every entry before dropping them,
1533
+ // so we no longer need an explicit loop.
1233
1534
  perFileTimers.clear();
1535
+ unregisterCacheSize("perFileTimers");
1234
1536
  for (const watcher of watchers) {
1235
1537
  watcher.close();
1236
1538
  }