@mandujs/core 0.24.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -13,11 +13,13 @@
13
13
  "./auth/reset": "./src/auth/reset.ts",
14
14
  "./auth/verification": "./src/auth/verification.ts",
15
15
  "./client": "./src/client/index.ts",
16
+ "./content": "./src/content/index.ts",
16
17
  "./db": "./src/db/index.ts",
17
18
  "./desktop": "./src/desktop/index.ts",
18
19
  "./desktop/worker": "./src/desktop/worker.ts",
19
20
  "./email": "./src/email/index.ts",
20
21
  "./filling/session-sqlite": "./src/filling/session-sqlite.ts",
22
+ "./kitchen": "./src/kitchen/index.ts",
21
23
  "./middleware": "./src/middleware/index.ts",
22
24
  "./middleware/oauth": "./src/middleware/oauth/index.ts",
23
25
  "./middleware/secure": "./src/middleware/secure/index.ts",
@@ -859,11 +859,34 @@ function getListeners() {
859
859
  return window.__MANDU_ROUTER_LISTENERS__;
860
860
  }
861
861
 
862
- // 패턴 매칭 캐시
862
+ // 패턴 매칭 캐시 (Phase 17 — bounded LRU, inlined to keep the runtime
863
+ // bundle self-contained. Same semantics as packages/core/src/utils/lru-cache.ts
864
+ // but hand-written here so the client-side shim has no server imports.)
865
+ var PATTERN_CACHE_MAX = 200;
863
866
  var patternCache = new Map();
864
867
 
868
+ function patternCacheGet(key) {
869
+ if (!patternCache.has(key)) return undefined;
870
+ var value = patternCache.get(key);
871
+ // Promote to MRU.
872
+ patternCache.delete(key);
873
+ patternCache.set(key, value);
874
+ return value;
875
+ }
876
+
877
+ function patternCacheSet(key, value) {
878
+ if (patternCache.has(key)) {
879
+ patternCache.delete(key);
880
+ } else if (patternCache.size >= PATTERN_CACHE_MAX) {
881
+ var oldest = patternCache.keys().next().value;
882
+ if (oldest !== undefined) patternCache.delete(oldest);
883
+ }
884
+ patternCache.set(key, value);
885
+ }
886
+
865
887
  function compilePattern(pattern) {
866
- if (patternCache.has(pattern)) return patternCache.get(pattern);
888
+ var cached = patternCacheGet(pattern);
889
+ if (cached) return cached;
867
890
 
868
891
  const paramNames = [];
869
892
  let paramIndex = 0;
@@ -881,7 +904,7 @@ function compilePattern(pattern) {
881
904
  });
882
905
 
883
906
  const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
884
- patternCache.set(pattern, compiled);
907
+ patternCacheSet(pattern, compiled);
885
908
  return compiled;
886
909
  }
887
910
 
@@ -22,6 +22,8 @@ import {
22
22
  } from "./reverse-import-graph";
23
23
  import path from "path";
24
24
  import fs from "fs";
25
+ import { LRUCache } from "../utils/lru-cache";
26
+ import { registerCacheSize, unregisterCacheSize } from "../observability/metrics";
25
27
 
26
28
  /**
27
29
  * #184: 공통 디렉토리 변경 시 사용하는 sentinel.
@@ -685,8 +687,19 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
685
687
  * Lifecycle: timers are created by `scheduleFileChange`, cleared on flush or
686
688
  * on `close()`. We call `.delete(key)` on flush to keep the Map bounded —
687
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).
688
697
  */
689
- 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);
690
703
 
691
704
  /**
692
705
  * B2 fix — multi-file pending build queue (Phase 7.0 R1 Agent A).
@@ -1515,10 +1528,11 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
1515
1528
  initialBuild,
1516
1529
  close: () => {
1517
1530
  // B6: clear all per-file timers to release event-loop refs.
1518
- for (const timer of perFileTimers.values()) {
1519
- clearTimeout(timer);
1520
- }
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.
1521
1534
  perFileTimers.clear();
1535
+ unregisterCacheSize("perFileTimers");
1522
1536
  for (const watcher of watchers) {
1523
1537
  watcher.close();
1524
1538
  }
@@ -1,44 +1,61 @@
1
- /**
2
- * Mandu 전역 타입 선언
3
- * 클라이언트 측 전역 상태의 타입 정의
4
- */
5
- import type { Root } from "react-dom/client";
6
- import type { RouterState } from "./router";
7
-
8
- interface ManduRouteInfo {
9
- id: string;
10
- pattern: string;
11
- params: Record<string, string>;
12
- }
13
-
14
- interface ManduDataEntry {
15
- serverData: unknown;
16
- timestamp?: number;
17
- }
18
-
19
- declare global {
20
- interface Window {
21
- /** 서버에서 전달된 데이터 (routeId → data) */
22
- __MANDU_DATA__?: Record<string, ManduDataEntry>;
23
-
24
- /** 직렬화된 서버 데이터 (raw JSON) */
25
- __MANDU_DATA_RAW__?: string;
26
-
27
- /** 현재 라우트 정보 */
28
- __MANDU_ROUTE__?: ManduRouteInfo;
29
-
30
- /** 클라이언트 라우터 상태 */
31
- __MANDU_ROUTER_STATE__?: RouterState;
32
-
33
- /** 라우터 상태 변경 리스너 */
34
- __MANDU_ROUTER_LISTENERS__?: Set<(state: RouterState) => void>;
35
-
36
- /** Hydrated roots 추적 (unmount용) */
37
- __MANDU_ROOTS__?: Map<string, Root>;
38
-
39
- /** React 인스턴스 공유 */
40
- __MANDU_REACT__?: typeof import("react");
41
- }
42
- }
43
-
44
- export {};
1
+ /**
2
+ * Mandu 전역 타입 선언
3
+ * 클라이언트 측 전역 상태의 타입 정의
4
+ */
5
+ import type { Root } from "react-dom/client";
6
+ import type { RouterState } from "./router";
7
+
8
+ interface ManduRouteInfo {
9
+ id: string;
10
+ pattern: string;
11
+ params: Record<string, string>;
12
+ }
13
+
14
+ interface ManduDataEntry {
15
+ serverData: unknown;
16
+ timestamp?: number;
17
+ }
18
+
19
+ declare global {
20
+ interface Window {
21
+ /** 서버에서 전달된 데이터 (routeId → data) */
22
+ __MANDU_DATA__?: Record<string, ManduDataEntry>;
23
+
24
+ /** 직렬화된 서버 데이터 (raw JSON) */
25
+ __MANDU_DATA_RAW__?: string;
26
+
27
+ /** 현재 라우트 정보 */
28
+ __MANDU_ROUTE__?: ManduRouteInfo;
29
+
30
+ /** 클라이언트 라우터 상태 */
31
+ __MANDU_ROUTER_STATE__?: RouterState;
32
+
33
+ /** 라우터 상태 변경 리스너 */
34
+ __MANDU_ROUTER_LISTENERS__?: Set<(state: RouterState) => void>;
35
+
36
+ /** Hydrated roots 추적 (unmount용) */
37
+ __MANDU_ROOTS__?: Map<string, Root>;
38
+
39
+ /** React 인스턴스 공유 */
40
+ __MANDU_REACT__?: typeof import("react");
41
+
42
+ /**
43
+ * Issue #193 — global SPA navigation toggle.
44
+ *
45
+ * - `undefined` (not set) → default. Plain `<a href="/about">`
46
+ * is intercepted and routed through the client-side router.
47
+ * - `false` → legacy opt-in behavior. Only `<a>`
48
+ * tags with `data-mandu-link` are intercepted; all other
49
+ * internal links perform a full browser navigation.
50
+ * - `true` → same as undefined; present only for
51
+ * symmetry and forward compat.
52
+ *
53
+ * SSR injects this global only when `mandu.config.ts` sets
54
+ * `spa: false` — the default case emits nothing to keep the
55
+ * typical response payload unchanged.
56
+ */
57
+ __MANDU_SPA__?: boolean;
58
+ }
59
+ }
60
+
61
+ export {};
@@ -15,6 +15,7 @@ import {
15
15
  } from "./window-state";
16
16
  import { LRUCache } from "../utils/lru-cache";
17
17
  import { LIMITS } from "../constants";
18
+ import { registerCacheSize } from "../observability/metrics";
18
19
 
19
20
  // ========== Types ==========
20
21
 
@@ -158,6 +159,12 @@ interface CompiledPattern {
158
159
 
159
160
  const patternCache = new LRUCache<string, CompiledPattern>(LIMITS.ROUTER_PATTERN_CACHE);
160
161
 
162
+ // Phase 17 — expose the cache size to the /_mandu/heap + /_mandu/metrics
163
+ // endpoints so long-running processes can detect runaway growth.
164
+ // The registration happens at module init — safe to call once per process
165
+ // because `registerCacheSize` replaces any prior reporter under the same key.
166
+ registerCacheSize("patternCache", () => patternCache.size);
167
+
161
168
  /**
162
169
  * 패턴을 정규식으로 컴파일
163
170
  */
@@ -435,40 +442,105 @@ export function getNavigationState(): NavigationState {
435
442
  // ========== Link Click Handler ==========
436
443
 
437
444
  /**
438
- * 링크 클릭 이벤트 핸들러 (이벤트 위임용)
445
+ * Issue #193 Link click handler (event delegation).
446
+ *
447
+ * Mandu v0.22+ reversed the default from opt-in to opt-out SPA navigation.
448
+ * Every internal same-origin `<a href="/...">` click is intercepted and
449
+ * routed through the client-side router unless one of the explicit escape
450
+ * hatches fires:
451
+ *
452
+ * - `data-no-spa` → per-link opt-out (always skip).
453
+ * - `<a>` without `href` → degenerate anchor, let the browser decide.
454
+ * - `href="#fragment"` → same-page anchor, browser handles scroll.
455
+ * - `href="mailto:"` / `tel:` / → non-http schemes the browser owns.
456
+ * `javascript:` / `data:` / …
457
+ * - `target="_blank" / "_top" / → any target other than `_self` means the
458
+ * "_parent" / "framename" user wants a new browsing context.
459
+ * - `download` attribute present → file download, never a navigation.
460
+ * - Modifier keys (Ctrl / Cmd / → browser shortcut for new tab, bookmark,
461
+ * Shift / Alt) save, or save-as.
462
+ * - Non-left click → middle-click opens a new tab, right-click
463
+ * opens context menu.
464
+ * - `event.defaultPrevented` → another listener already handled it.
465
+ * - Cross-origin href → full document navigation required.
466
+ *
467
+ * The legacy opt-in attribute `data-mandu-link` still works for
468
+ * backward compatibility — it is simply a no-op under the new default
469
+ * because we already intercept by default. Teams that want the old
470
+ * opt-in behavior back can set `spa: false` in `mandu.config.ts`, which
471
+ * surfaces as `window.__MANDU_SPA__ === false` and re-introduces the
472
+ * requirement that `<a>` tags carry `data-mandu-link`.
439
473
  */
440
474
  function handleLinkClick(event: MouseEvent): void {
441
- // 기본 동작 조건 체크
475
+ // Pre-filter: obvious browser-owned events.
442
476
  if (
443
477
  event.defaultPrevented ||
444
- event.button !== 0 ||
445
- event.metaKey ||
446
- event.altKey ||
447
- event.ctrlKey ||
448
- event.shiftKey
478
+ event.button !== 0 || // middle-click / right-click — browser decides.
479
+ event.metaKey || // Cmd (macOS) — new tab.
480
+ event.altKey || // Alt — "save-as" in most browsers.
481
+ event.ctrlKey || // Ctrl (Windows/Linux) — new tab.
482
+ event.shiftKey // Shift — new window / bookmark.
449
483
  ) {
450
484
  return;
451
485
  }
452
486
 
453
- // 가장 가까운 앵커 태그 찾기
454
- const anchor = (event.target as HTMLElement).closest("a");
487
+ // Find the closest anchor ancestor — users commonly nest `<span>` /
488
+ // `<img>` inside `<a>` and the event target is the inner element.
489
+ const anchor = (event.target as HTMLElement | null)?.closest("a");
455
490
  if (!anchor) return;
456
491
 
457
- // data-mandu-link 속성이 있는 링크만 처리
458
- if (!anchor.hasAttribute("data-mandu-link")) return;
492
+ // Escape hatch 1: explicit per-link opt-out always wins.
493
+ if (anchor.hasAttribute("data-no-spa")) return;
494
+
495
+ // Escape hatch 2: global config `spa: false` — reverts to the legacy
496
+ // opt-in behavior (only `data-mandu-link` intercepts). SSR injects
497
+ // `window.__MANDU_SPA__ = false` when the user sets `spa: false`.
498
+ const spaGlobal = (globalThis as { window?: { __MANDU_SPA__?: boolean } }).window?.__MANDU_SPA__;
499
+ if (spaGlobal === false && !anchor.hasAttribute("data-mandu-link")) return;
459
500
 
501
+ // `<a>` without `href` is a degenerate anchor — the browser will not
502
+ // navigate, but a listener somewhere might. Don't intercept.
460
503
  const href = anchor.getAttribute("href");
461
504
  if (!href) return;
462
505
 
463
- // 외부 링크 체크
506
+ // Same-page fragment link — let the browser handle scroll / focus.
507
+ if (href.startsWith("#")) return;
508
+
509
+ // `target` other than `_self` (or absent) signals the user wants a
510
+ // new browsing context. `target="_blank"` is the common case but we
511
+ // also pass through `_top`, `_parent`, and framed targets.
512
+ const target = anchor.getAttribute("target");
513
+ if (target && target !== "_self") return;
514
+
515
+ // `download` attribute means the user wants to save the resource,
516
+ // never navigate to it.
517
+ if (anchor.hasAttribute("download")) return;
518
+
519
+ // URL parsing — catches both cross-origin and non-http schemes.
520
+ let url: URL;
464
521
  try {
465
- const url = new URL(href, window.location.origin);
466
- if (url.origin !== window.location.origin) return;
522
+ url = new URL(href, window.location.origin);
467
523
  } catch {
524
+ // Malformed href — let the browser produce its own error.
468
525
  return;
469
526
  }
470
527
 
471
- // 기본 동작 방지 Client-side 네비게이션
528
+ // Only same-origin http(s) navigations are eligible for SPA handling.
529
+ // `mailto:`, `tel:`, `javascript:`, `data:`, `blob:`, chrome-extension,
530
+ // etc. all fail this check because `new URL("mailto:foo@bar").origin`
531
+ // is the string `"null"` (spec-defined), never equal to
532
+ // `window.location.origin`.
533
+ if (url.origin !== window.location.origin) return;
534
+
535
+ // Only intercept http / https schemes. Defense-in-depth against any
536
+ // exotic same-origin scheme we haven't considered (e.g. a custom
537
+ // protocol handler installed by a browser extension).
538
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
539
+
540
+ // All clear — prevent the default full-page navigation and hand off
541
+ // to the client-side router. `href` preserves the user's original
542
+ // string (relative paths, fragments, query strings) so the router
543
+ // can normalize as needed.
472
544
  event.preventDefault();
473
545
  navigate(href);
474
546
  }
@@ -828,3 +900,9 @@ if (typeof window !== "undefined") {
828
900
  // can exercise the schema-check path directly without round-tripping
829
901
  // through `window.__MANDU_ROUTER_REVALIDATE__`.
830
902
  export { applyHDRUpdate as _testOnly_applyHDRUpdate };
903
+
904
+ // Issue #193: export the link-click handler for unit tests so we can
905
+ // drive every exclusion case without installing a real DOM click
906
+ // listener. Keeping this under a `_testOnly_` prefix to signal it is
907
+ // not part of the public API.
908
+ export { handleLinkClick as _testOnly_handleLinkClick };