@mandujs/core 0.21.0 → 0.22.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.
Files changed (122) hide show
  1. package/package.json +94 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { ReactNode } from "react";
7
+ import { startTransition } from "react";
7
8
  import {
8
9
  getManduData,
9
10
  getManduRoute,
@@ -36,11 +37,11 @@ export interface RouterState {
36
37
  navigation: NavigationState;
37
38
  }
38
39
 
39
- export interface ActionResult {
40
- ok: boolean;
41
- actionData?: unknown;
42
- loaderData?: unknown;
43
- }
40
+ export interface ActionResult {
41
+ ok: boolean;
42
+ actionData?: unknown;
43
+ loaderData?: unknown;
44
+ }
44
45
 
45
46
  export interface NavigateOptions {
46
47
  /** history.replaceState 사용 여부 */
@@ -224,7 +225,7 @@ let activeNavigationController: AbortController | null = null;
224
225
  /**
225
226
  * 페이지 네비게이션
226
227
  */
227
- export async function navigate(
228
+ export async function navigate(
228
229
  to: string,
229
230
  options: NavigateOptions = {}
230
231
  ): Promise<void> {
@@ -251,36 +252,36 @@ export async function navigate(
251
252
  // shouldRevalidate 체크 — false면 fetch 없이 URL만 변경
252
253
  if (!skipRevalidation && globalShouldRevalidate) {
253
254
  const currentUrl = new URL(window.location.href);
254
- const shouldFetch = globalShouldRevalidate({
255
- currentUrl,
256
- nextUrl: url,
257
- defaultShouldRevalidate: true,
258
- });
259
- if (!shouldFetch) {
260
- const currentState = getRouterStateInternal();
261
- const nextRoute = currentState.currentRoute
262
- ? {
263
- ...currentState.currentRoute,
264
- params: extractParamsFromPath(currentState.currentRoute.pattern, url.pathname),
265
- }
266
- : null;
267
- const historyState = nextRoute
268
- ? { routeId: nextRoute.id, params: nextRoute.params }
269
- : null;
270
- if (replace) {
271
- history.replaceState(historyState, "", to);
272
- } else {
273
- history.pushState(historyState, "", to);
274
- }
275
- setRouterStateInternal({
276
- ...currentState,
277
- currentRoute: nextRoute,
278
- actionData: undefined,
279
- navigation: { state: "idle" },
280
- });
281
- notifyListeners();
282
- if (scroll) window.scrollTo(0, 0);
283
- return;
255
+ const shouldFetch = globalShouldRevalidate({
256
+ currentUrl,
257
+ nextUrl: url,
258
+ defaultShouldRevalidate: true,
259
+ });
260
+ if (!shouldFetch) {
261
+ const currentState = getRouterStateInternal();
262
+ const nextRoute = currentState.currentRoute
263
+ ? {
264
+ ...currentState.currentRoute,
265
+ params: extractParamsFromPath(currentState.currentRoute.pattern, url.pathname),
266
+ }
267
+ : null;
268
+ const historyState = nextRoute
269
+ ? { routeId: nextRoute.id, params: nextRoute.params }
270
+ : null;
271
+ if (replace) {
272
+ history.replaceState(historyState, "", to);
273
+ } else {
274
+ history.pushState(historyState, "", to);
275
+ }
276
+ setRouterStateInternal({
277
+ ...currentState,
278
+ currentRoute: nextRoute,
279
+ actionData: undefined,
280
+ navigation: { state: "idle" },
281
+ });
282
+ notifyListeners();
283
+ if (scroll) window.scrollTo(0, 0);
284
+ return;
284
285
  }
285
286
  }
286
287
 
@@ -565,34 +566,34 @@ export async function submitAction(
565
566
  if (controller.signal.aborted) return { ok: false };
566
567
 
567
568
  // Revalidation: 서버가 loader를 재실행해서 fresh data를 보내줬으면 갱신
568
- const nextActionData = result._revalidated ? result.actionData : result;
569
-
570
- if (result._revalidated && result.loaderData !== undefined) {
571
- setRouterStateInternal({
572
- ...getRouterStateInternal(),
573
- loaderData: result.loaderData,
574
- actionData: nextActionData,
575
- navigation: { state: "idle" },
576
- });
569
+ const nextActionData = result._revalidated ? result.actionData : result;
570
+
571
+ if (result._revalidated && result.loaderData !== undefined) {
572
+ setRouterStateInternal({
573
+ ...getRouterStateInternal(),
574
+ loaderData: result.loaderData,
575
+ actionData: nextActionData,
576
+ navigation: { state: "idle" },
577
+ });
577
578
 
578
579
  const currentRoute = getRouterStateInternal().currentRoute;
579
580
  if (currentRoute) {
580
581
  setServerData(currentRoute.id, result.loaderData);
581
582
  }
582
583
  } else {
583
- setRouterStateInternal({
584
- ...getRouterStateInternal(),
585
- actionData: nextActionData,
586
- navigation: { state: "idle" },
587
- });
584
+ setRouterStateInternal({
585
+ ...getRouterStateInternal(),
586
+ actionData: nextActionData,
587
+ navigation: { state: "idle" },
588
+ });
588
589
  }
589
590
 
590
- notifyListeners();
591
- return {
592
- ok: response.ok,
593
- actionData: nextActionData,
594
- loaderData: result._revalidated ? result.loaderData as unknown : undefined,
595
- };
591
+ notifyListeners();
592
+ return {
593
+ ok: response.ok,
594
+ actionData: nextActionData,
595
+ loaderData: result._revalidated ? result.loaderData as unknown : undefined,
596
+ };
596
597
  } catch (error) {
597
598
  if (controller.signal.aborted) return { ok: false };
598
599
 
@@ -620,6 +621,137 @@ export function getActionData<T = unknown>(): T | undefined {
620
621
 
621
622
  let initialized = false;
622
623
 
624
+ /**
625
+ * Phase 7.3 L-01 — HDR loader payload shape predicate.
626
+ *
627
+ * Exported as an escape hatch for unit tests. Accepts only:
628
+ * - `null` (loader explicitly returned "no data" — a valid outcome).
629
+ * - plain `object` (the typical loader return value shape).
630
+ * Rejects:
631
+ * - `undefined` (we require the server contract to emit `null` explicitly
632
+ * when there is no data, mirroring `Response.json` behaviour).
633
+ * - primitives (`string`, `number`, `boolean`, `symbol`, `bigint`).
634
+ * - `Array` — React loader data is always a bag-of-props, never a bare
635
+ * array. An array payload signals the server contract changed under us.
636
+ *
637
+ * This is DEFENSE-IN-DEPTH: the HDR response is always same-origin, so an
638
+ * attacker cannot inject here without already controlling the server. The
639
+ * predicate catches the real-world failure mode: a skew between server +
640
+ * client versions during a rolling deploy (cached old client fetches new
641
+ * `_data=1` response with a reshape), or a loader that drifted to return
642
+ * a primitive by mistake.
643
+ *
644
+ * We DO allow unknown object keys — loaders legitimately return arbitrary
645
+ * shapes. We only check the outer container.
646
+ *
647
+ * @internal exposed for `__tests__/hdr-l-fixes.test.ts`
648
+ */
649
+ export function isValidHDRLoaderData(value: unknown): value is object | null {
650
+ if (value === null) return true;
651
+ if (typeof value !== "object") return false;
652
+ // Arrays are `typeof === "object"` — reject them explicitly.
653
+ if (Array.isArray(value)) return false;
654
+ return true;
655
+ }
656
+
657
+ /**
658
+ * Phase 7.2 — HDR (Hot Data Revalidation) hook.
659
+ *
660
+ * The dev-time HMR client script (see `bundler/dev.ts`
661
+ * `generateHMRClientScript`) calls this when a `.slot.ts` file
662
+ * changes for the currently-rendered route. We update the router's
663
+ * loader data inside `React.startTransition` so the component tree
664
+ * re-renders with new props while form inputs, scroll position, and
665
+ * focused elements all survive.
666
+ *
667
+ * The global is installed by `initializeRouter()` so dev and prod
668
+ * share the same boot path; in prod builds the HMR script is not
669
+ * emitted and the global is simply never called.
670
+ *
671
+ * Exposed on `window` because the HMR client script (emitted as a
672
+ * raw string) has no module system. Typed via `window-state.ts` if
673
+ * we ever lift the typing, but inline here for now.
674
+ *
675
+ * Phase 7.3 L-01: the incoming `loaderData` is schema-checked via
676
+ * `isValidHDRLoaderData` before we commit it to router state. This is
677
+ * defense-in-depth — the HDR response is same-origin and already
678
+ * trusted, but a version skew between server + client (cached stale
679
+ * client fetching a reshape loader) would otherwise crash React with
680
+ * an unhelpful `undefined.x` inside a fiber. On rejection we log a
681
+ * descriptive warning and drop the update; the next navigation or
682
+ * full reload will resynchronize.
683
+ */
684
+ function applyHDRUpdate(routeId: string, loaderData: unknown): void {
685
+ const current = getRouterStateInternal();
686
+ const route = current.currentRoute;
687
+ if (!route || route.id !== routeId) {
688
+ // Route mismatch — the user navigated away between the
689
+ // slot-refetch broadcast and the fetch response. Safe to drop.
690
+ return;
691
+ }
692
+ // Phase 7.3 L-01: validate the loaderData shape. On failure keep the
693
+ // existing `loaderData` and warn so the developer notices the
694
+ // contract drift immediately (instead of a cryptic React crash).
695
+ if (!isValidHDRLoaderData(loaderData)) {
696
+ // eslint-disable-next-line no-console
697
+ console.warn(
698
+ "[Mandu HDR] applyHDRUpdate rejected a loader payload that was not a plain object or null. " +
699
+ "This usually signals a server/client version skew — a full reload will resynchronize. " +
700
+ `Received: ${Array.isArray(loaderData) ? "array" : typeof loaderData}`,
701
+ );
702
+ return;
703
+ }
704
+ const nextState: RouterState = {
705
+ currentRoute: route,
706
+ loaderData,
707
+ actionData: current.actionData,
708
+ navigation: current.navigation,
709
+ };
710
+ // startTransition wraps the update so React can interrupt it if
711
+ // more urgent updates come in, and (crucially for HDR) does NOT
712
+ // trigger the "input's state is inconsistent" tearing that a plain
713
+ // setState would. React 19 exports startTransition as a top-level
714
+ // API so it's always available when the router is on the page.
715
+ const apply = () => {
716
+ setRouterStateInternal(nextState);
717
+ setServerData(routeId, loaderData);
718
+ notifyListeners();
719
+ };
720
+ try {
721
+ startTransition(apply);
722
+ } catch {
723
+ // Defensive: if for any reason startTransition throws (e.g. a
724
+ // non-React 19 build ends up with the router on the page), fall
725
+ // back to a direct apply. Prop changes still propagate; only
726
+ // the tearing protection is lost.
727
+ apply();
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Phase 7.3 L-03 — dev-only detection for the HDR revalidate hook.
733
+ *
734
+ * The router module is bundled into both dev and prod client bundles.
735
+ * We want `window.__MANDU_ROUTER_REVALIDATE__` installed ONLY in dev,
736
+ * because in prod no HMR client script is present to call it — so the
737
+ * global is pure attack surface (an XSS payload could hijack it to
738
+ * coerce the router into rendering arbitrary loader shapes).
739
+ *
740
+ * Mandu's bundler (`packages/core/src/bundler/build.ts`) injects
741
+ * `define: { "process.env.NODE_ENV": JSON.stringify(NODE_ENV) }` into
742
+ * every client bundle, so `process.env.NODE_ENV` is a compile-time
743
+ * constant in the browser. When `NODE_ENV === "production"` the `if`
744
+ * body below is dead-code-eliminated by Bun's minifier.
745
+ *
746
+ * Tested in `__tests__/hdr-l-fixes.test.ts` against both mocked envs.
747
+ */
748
+ function shouldInstallHDRHook(): boolean {
749
+ // typeof-guard so tests that run without a `process` global (pure
750
+ // happy-dom) don't blow up.
751
+ if (typeof process === "undefined") return true;
752
+ return process.env?.NODE_ENV !== "production";
753
+ }
754
+
623
755
  /**
624
756
  * 라우터 초기화
625
757
  */
@@ -637,6 +769,18 @@ export function initializeRouter(): void {
637
769
  // 링크 클릭 이벤트 위임
638
770
  document.addEventListener("click", handleLinkClick);
639
771
 
772
+ // Phase 7.3 L-03 — expose HDR revalidate hook ONLY in dev builds.
773
+ // The prod client bundle has `process.env.NODE_ENV === "production"`
774
+ // inlined at build time (see `bundler/build.ts` define option), so
775
+ // the entire install block is eliminated from production output.
776
+ // This closes the XSS amplification vector described in the Phase
777
+ // 7.2 security audit (docs/security/phase-7-2-audit.md L-03).
778
+ if (shouldInstallHDRHook()) {
779
+ (window as unknown as {
780
+ __MANDU_ROUTER_REVALIDATE__?: (routeId: string, loaderData: unknown) => void;
781
+ }).__MANDU_ROUTER_REVALIDATE__ = applyHDRUpdate;
782
+ }
783
+
640
784
  console.log("[Mandu Router] Initialized");
641
785
  }
642
786
 
@@ -660,6 +804,15 @@ export function cleanupRouter(): void {
660
804
  document.removeEventListener("click", handleLinkClick);
661
805
  listeners.current.clear();
662
806
  initialized = false;
807
+ // Phase 7.3 L-03: also remove the HDR hook so subsequent
808
+ // re-initialization after a teardown starts from a clean slate.
809
+ if (typeof window !== "undefined") {
810
+ try {
811
+ delete (window as unknown as { __MANDU_ROUTER_REVALIDATE__?: unknown }).__MANDU_ROUTER_REVALIDATE__;
812
+ } catch {
813
+ // some browsers refuse `delete` on globals; best-effort is fine.
814
+ }
815
+ }
663
816
  }
664
817
 
665
818
  // 자동 초기화 (DOM 준비 시)
@@ -670,3 +823,8 @@ if (typeof window !== "undefined") {
670
823
  initializeRouter();
671
824
  }
672
825
  }
826
+
827
+ // Phase 7.3 L-01: export `applyHDRUpdate` itself for unit tests so we
828
+ // can exercise the schema-check path directly without round-tripping
829
+ // through `window.__MANDU_ROUTER_REVALIDATE__`.
830
+ export { applyHDRUpdate as _testOnly_applyHDRUpdate };