@mandujs/core 0.13.2 โ†’ 0.16.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.13.2",
3
+ "version": "0.16.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,227 +1,234 @@
1
- /**
2
- * Mandu Link Component ๐Ÿ”—
3
- * Client-side ๋„ค๋น„๊ฒŒ์ด์…˜์„ ์œ„ํ•œ Link ์ปดํฌ๋„ŒํŠธ
4
- */
5
-
6
- import React, {
7
- type AnchorHTMLAttributes,
8
- type MouseEvent,
9
- type ReactNode,
10
- useCallback,
11
- useEffect,
12
- useRef,
13
- } from "react";
14
- import { navigate, prefetch } from "./router";
15
-
16
- export interface LinkProps
17
- extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
18
- /** ์ด๋™ํ•  URL */
19
- href: string;
20
- /** history.replaceState ์‚ฌ์šฉ ์—ฌ๋ถ€ */
21
- replace?: boolean;
22
- /** ๋งˆ์šฐ์Šค hover ์‹œ prefetch ์—ฌ๋ถ€ */
23
- prefetch?: boolean;
24
- /** ์Šคํฌ๋กค ์œ„์น˜ ๋ณต์› ์—ฌ๋ถ€ (๊ธฐ๋ณธ: true) */
25
- scroll?: boolean;
26
- /** ์ž์‹ ์š”์†Œ */
27
- children?: ReactNode;
28
- }
29
-
30
- /**
31
- * Client-side ๋„ค๋น„๊ฒŒ์ด์…˜ Link ์ปดํฌ๋„ŒํŠธ
32
- *
33
- * @example
34
- * ```tsx
35
- * import { Link } from "@mandujs/core/client";
36
- *
37
- * // ๊ธฐ๋ณธ ์‚ฌ์šฉ
38
- * <Link href="/about">About</Link>
39
- *
40
- * // Prefetch ํ™œ์„ฑํ™”
41
- * <Link href="/users" prefetch>Users</Link>
42
- *
43
- * // Replace ๋ชจ๋“œ (๋’ค๋กœ๊ฐ€๊ธฐ ํžˆ์Šคํ† ๋ฆฌ ์—†์Œ)
44
- * <Link href="/login" replace>Login</Link>
45
- * ```
46
- */
47
- export function Link({
48
- href,
49
- replace = false,
50
- prefetch: shouldPrefetch = false,
51
- scroll = true,
52
- children,
53
- onClick,
54
- onMouseEnter,
55
- onFocus,
56
- ...rest
57
- }: LinkProps): React.ReactElement {
58
- const prefetchedRef = useRef(false);
59
-
60
- // ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ
61
- const handleClick = useCallback(
62
- (event: MouseEvent<HTMLAnchorElement>) => {
63
- // ์‚ฌ์šฉ์ž ์ •์˜ onClick ๋จผ์ € ์‹คํ–‰
64
- onClick?.(event);
65
-
66
- // ๊ธฐ๋ณธ ๋™์ž‘ ๋ฐฉ์ง€ ์กฐ๊ฑด
67
- if (
68
- event.defaultPrevented ||
69
- event.button !== 0 ||
70
- event.metaKey ||
71
- event.altKey ||
72
- event.ctrlKey ||
73
- event.shiftKey
74
- ) {
75
- return;
76
- }
77
-
78
- // ์™ธ๋ถ€ ๋งํฌ ์ฒดํฌ
79
- try {
80
- const url = new URL(href, window.location.origin);
81
- if (url.origin !== window.location.origin) {
82
- return; // ์™ธ๋ถ€ ๋งํฌ๋Š” ๊ธฐ๋ณธ ๋™์ž‘
83
- }
84
- } catch {
85
- return;
86
- }
87
-
88
- // Client-side ๋„ค๋น„๊ฒŒ์ด์…˜
89
- event.preventDefault();
90
- navigate(href, { replace, scroll });
91
- },
92
- [href, replace, scroll, onClick]
93
- );
94
-
95
- // Prefetch ์‹คํ–‰
96
- const doPrefetch = useCallback(() => {
97
- if (!shouldPrefetch || prefetchedRef.current) return;
98
-
99
- try {
100
- const url = new URL(href, window.location.origin);
101
- if (url.origin === window.location.origin) {
102
- prefetch(href);
103
- prefetchedRef.current = true;
104
- }
105
- } catch {
106
- // ๋ฌด์‹œ
107
- }
108
- }, [href, shouldPrefetch]);
109
-
110
- // ๋งˆ์šฐ์Šค hover ํ•ธ๋“ค๋Ÿฌ
111
- const handleMouseEnter = useCallback(
112
- (event: MouseEvent<HTMLAnchorElement>) => {
113
- onMouseEnter?.(event);
114
- doPrefetch();
115
- },
116
- [onMouseEnter, doPrefetch]
117
- );
118
-
119
- // ํฌ์ปค์Šค ํ•ธ๋“ค๋Ÿฌ (ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜)
120
- const handleFocus = useCallback(
121
- (event: React.FocusEvent<HTMLAnchorElement>) => {
122
- onFocus?.(event);
123
- doPrefetch();
124
- },
125
- [onFocus, doPrefetch]
126
- );
127
-
128
- // Viewport ์ง„์ž… ์‹œ prefetch (IntersectionObserver)
129
- useEffect(() => {
130
- if (!shouldPrefetch || typeof IntersectionObserver === "undefined") {
131
- return;
132
- }
133
-
134
- // ref๊ฐ€ ์—†์œผ๋ฉด ๋ฌด์‹œ (SSR)
135
- return;
136
- }, [shouldPrefetch]);
137
-
138
- return (
139
- <a
140
- href={href}
141
- onClick={handleClick}
142
- onMouseEnter={handleMouseEnter}
143
- onFocus={handleFocus}
144
- data-mandu-link=""
145
- {...rest}
146
- >
147
- {children}
148
- </a>
149
- );
150
- }
151
-
152
- /**
153
- * NavLink - ํ˜„์žฌ ๊ฒฝ๋กœ์™€ ์ผ์น˜ํ•  ๋•Œ ํ™œ์„ฑ ์Šคํƒ€์ผ ์ ์šฉ
154
- *
155
- * @example
156
- * ```tsx
157
- * import { NavLink } from "@mandujs/core/client";
158
- *
159
- * <NavLink
160
- * href="/about"
161
- * className={({ isActive }) => isActive ? "active" : ""}
162
- * >
163
- * About
164
- * </NavLink>
165
- * ```
166
- */
167
- export interface NavLinkProps extends Omit<LinkProps, "className" | "style"> {
168
- /** ํ™œ์„ฑ ์ƒํƒœ์— ๋”ฐ๋ฅธ className */
169
- className?: string | ((props: { isActive: boolean }) => string);
170
- /** ํ™œ์„ฑ ์ƒํƒœ์— ๋”ฐ๋ฅธ style */
171
- style?:
172
- | React.CSSProperties
173
- | ((props: { isActive: boolean }) => React.CSSProperties);
174
- /** ํ™œ์„ฑ ์ƒํƒœ์ผ ๋•Œ ์ ์šฉํ•  style (style๊ณผ ๋ณ‘ํ•ฉ๋จ) */
175
- activeStyle?: React.CSSProperties;
176
- /** ํ™œ์„ฑ ์ƒํƒœ์ผ ๋•Œ ์ถ”๊ฐ€ํ•  className */
177
- activeClassName?: string;
178
- /** ์ •ํ™•ํžˆ ์ผ์น˜ํ•ด์•ผ ํ™œ์„ฑํ™” (๊ธฐ๋ณธ: false) */
179
- exact?: boolean;
180
- }
181
-
182
- export function NavLink({
183
- href,
184
- className,
185
- style,
186
- activeStyle,
187
- activeClassName,
188
- exact = false,
189
- ...rest
190
- }: NavLinkProps): React.ReactElement {
191
- // ํ˜„์žฌ ๊ฒฝ๋กœ์™€ ๋น„๊ต
192
- const isActive =
193
- typeof window !== "undefined"
194
- ? exact
195
- ? window.location.pathname === href
196
- : window.location.pathname.startsWith(href)
197
- : false;
198
-
199
- // className ์ฒ˜๋ฆฌ
200
- let resolvedClassName =
201
- typeof className === "function" ? className({ isActive }) : className;
202
-
203
- if (isActive && activeClassName) {
204
- resolvedClassName = resolvedClassName
205
- ? `${resolvedClassName} ${activeClassName}`
206
- : activeClassName;
207
- }
208
-
209
- // style ์ฒ˜๋ฆฌ
210
- let resolvedStyle =
211
- typeof style === "function" ? style({ isActive }) : style;
212
-
213
- if (isActive && activeStyle) {
214
- resolvedStyle = { ...resolvedStyle, ...activeStyle };
215
- }
216
-
217
- return (
218
- <Link
219
- href={href}
220
- className={resolvedClassName}
221
- style={resolvedStyle}
222
- {...rest}
223
- />
224
- );
225
- }
226
-
227
- export default Link;
1
+ /**
2
+ * Mandu Link Component ๐Ÿ”—
3
+ * Client-side ๋„ค๋น„๊ฒŒ์ด์…˜์„ ์œ„ํ•œ Link ์ปดํฌ๋„ŒํŠธ
4
+ */
5
+
6
+ import React, {
7
+ type AnchorHTMLAttributes,
8
+ type MouseEvent,
9
+ type ReactNode,
10
+ useCallback,
11
+ useEffect,
12
+ useRef,
13
+ } from "react";
14
+ import { navigate, prefetch } from "./router";
15
+ import { autoStableManduId } from "../runtime/stable-selector";
16
+
17
+ export interface LinkProps
18
+ extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
19
+ /** ์ด๋™ํ•  URL */
20
+ href: string;
21
+ /** Stable selector id (optional). If omitted, core injects best-effort id. */
22
+ manduId?: string;
23
+ /** history.replaceState ์‚ฌ์šฉ ์—ฌ๋ถ€ */
24
+ replace?: boolean;
25
+ /** ๋งˆ์šฐ์Šค hover ์‹œ prefetch ์—ฌ๋ถ€ */
26
+ prefetch?: boolean;
27
+ /** ์Šคํฌ๋กค ์œ„์น˜ ๋ณต์› ์—ฌ๋ถ€ (๊ธฐ๋ณธ: true) */
28
+ scroll?: boolean;
29
+ /** ์ž์‹ ์š”์†Œ */
30
+ children?: ReactNode;
31
+ }
32
+
33
+ /**
34
+ * Client-side ๋„ค๋น„๊ฒŒ์ด์…˜ Link ์ปดํฌ๋„ŒํŠธ
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * import { Link } from "@mandujs/core/client";
39
+ *
40
+ * // ๊ธฐ๋ณธ ์‚ฌ์šฉ
41
+ * <Link href="/about">About</Link>
42
+ *
43
+ * // Prefetch ํ™œ์„ฑํ™”
44
+ * <Link href="/users" prefetch>Users</Link>
45
+ *
46
+ * // Replace ๋ชจ๋“œ (๋’ค๋กœ๊ฐ€๊ธฐ ํžˆ์Šคํ† ๋ฆฌ ์—†์Œ)
47
+ * <Link href="/login" replace>Login</Link>
48
+ * ```
49
+ */
50
+ export function Link({
51
+ href,
52
+ manduId,
53
+ replace = false,
54
+ prefetch: shouldPrefetch = false,
55
+ scroll = true,
56
+ children,
57
+ onClick,
58
+ onMouseEnter,
59
+ onFocus,
60
+ ...rest
61
+ }: LinkProps): React.ReactElement {
62
+ const prefetchedRef = useRef(false);
63
+
64
+ // ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ
65
+ const handleClick = useCallback(
66
+ (event: MouseEvent<HTMLAnchorElement>) => {
67
+ // ์‚ฌ์šฉ์ž ์ •์˜ onClick ๋จผ์ € ์‹คํ–‰
68
+ onClick?.(event);
69
+
70
+ // ๊ธฐ๋ณธ ๋™์ž‘ ๋ฐฉ์ง€ ์กฐ๊ฑด
71
+ if (
72
+ event.defaultPrevented ||
73
+ event.button !== 0 ||
74
+ event.metaKey ||
75
+ event.altKey ||
76
+ event.ctrlKey ||
77
+ event.shiftKey
78
+ ) {
79
+ return;
80
+ }
81
+
82
+ // ์™ธ๋ถ€ ๋งํฌ ์ฒดํฌ
83
+ try {
84
+ const url = new URL(href, window.location.origin);
85
+ if (url.origin !== window.location.origin) {
86
+ return; // ์™ธ๋ถ€ ๋งํฌ๋Š” ๊ธฐ๋ณธ ๋™์ž‘
87
+ }
88
+ } catch {
89
+ return;
90
+ }
91
+
92
+ // Client-side ๋„ค๋น„๊ฒŒ์ด์…˜
93
+ event.preventDefault();
94
+ navigate(href, { replace, scroll });
95
+ },
96
+ [href, replace, scroll, onClick]
97
+ );
98
+
99
+ // Prefetch ์‹คํ–‰
100
+ const doPrefetch = useCallback(() => {
101
+ if (!shouldPrefetch || prefetchedRef.current) return;
102
+
103
+ try {
104
+ const url = new URL(href, window.location.origin);
105
+ if (url.origin === window.location.origin) {
106
+ prefetch(href);
107
+ prefetchedRef.current = true;
108
+ }
109
+ } catch {
110
+ // ๋ฌด์‹œ
111
+ }
112
+ }, [href, shouldPrefetch]);
113
+
114
+ // ๋งˆ์šฐ์Šค hover ํ•ธ๋“ค๋Ÿฌ
115
+ const handleMouseEnter = useCallback(
116
+ (event: MouseEvent<HTMLAnchorElement>) => {
117
+ onMouseEnter?.(event);
118
+ doPrefetch();
119
+ },
120
+ [onMouseEnter, doPrefetch]
121
+ );
122
+
123
+ // ํฌ์ปค์Šค ํ•ธ๋“ค๋Ÿฌ (ํ‚ค๋ณด๋“œ ๋„ค๋น„๊ฒŒ์ด์…˜)
124
+ const handleFocus = useCallback(
125
+ (event: React.FocusEvent<HTMLAnchorElement>) => {
126
+ onFocus?.(event);
127
+ doPrefetch();
128
+ },
129
+ [onFocus, doPrefetch]
130
+ );
131
+
132
+ // Viewport ์ง„์ž… ์‹œ prefetch (IntersectionObserver)
133
+ useEffect(() => {
134
+ if (!shouldPrefetch || typeof IntersectionObserver === "undefined") {
135
+ return;
136
+ }
137
+
138
+ // ref๊ฐ€ ์—†์œผ๋ฉด ๋ฌด์‹œ (SSR)
139
+ return;
140
+ }, [shouldPrefetch]);
141
+
142
+ const stableId = manduId ?? autoStableManduId("Link");
143
+
144
+ return (
145
+ <a
146
+ href={href}
147
+ onClick={handleClick}
148
+ onMouseEnter={handleMouseEnter}
149
+ onFocus={handleFocus}
150
+ data-mandu-link=""
151
+ data-mandu-id={stableId}
152
+ {...rest}
153
+ >
154
+ {children}
155
+ </a>
156
+ );
157
+ }
158
+
159
+ /**
160
+ * NavLink - ํ˜„์žฌ ๊ฒฝ๋กœ์™€ ์ผ์น˜ํ•  ๋•Œ ํ™œ์„ฑ ์Šคํƒ€์ผ ์ ์šฉ
161
+ *
162
+ * @example
163
+ * ```tsx
164
+ * import { NavLink } from "@mandujs/core/client";
165
+ *
166
+ * <NavLink
167
+ * href="/about"
168
+ * className={({ isActive }) => isActive ? "active" : ""}
169
+ * >
170
+ * About
171
+ * </NavLink>
172
+ * ```
173
+ */
174
+ export interface NavLinkProps extends Omit<LinkProps, "className" | "style"> {
175
+ /** ํ™œ์„ฑ ์ƒํƒœ์— ๋”ฐ๋ฅธ className */
176
+ className?: string | ((props: { isActive: boolean }) => string);
177
+ /** ํ™œ์„ฑ ์ƒํƒœ์— ๋”ฐ๋ฅธ style */
178
+ style?:
179
+ | React.CSSProperties
180
+ | ((props: { isActive: boolean }) => React.CSSProperties);
181
+ /** ํ™œ์„ฑ ์ƒํƒœ์ผ ๋•Œ ์ ์šฉํ•  style (style๊ณผ ๋ณ‘ํ•ฉ๋จ) */
182
+ activeStyle?: React.CSSProperties;
183
+ /** ํ™œ์„ฑ ์ƒํƒœ์ผ ๋•Œ ์ถ”๊ฐ€ํ•  className */
184
+ activeClassName?: string;
185
+ /** ์ •ํ™•ํžˆ ์ผ์น˜ํ•ด์•ผ ํ™œ์„ฑํ™” (๊ธฐ๋ณธ: false) */
186
+ exact?: boolean;
187
+ }
188
+
189
+ export function NavLink({
190
+ href,
191
+ className,
192
+ style,
193
+ activeStyle,
194
+ activeClassName,
195
+ exact = false,
196
+ ...rest
197
+ }: NavLinkProps): React.ReactElement {
198
+ // ํ˜„์žฌ ๊ฒฝ๋กœ์™€ ๋น„๊ต
199
+ const isActive =
200
+ typeof window !== "undefined"
201
+ ? exact
202
+ ? window.location.pathname === href
203
+ : window.location.pathname.startsWith(href)
204
+ : false;
205
+
206
+ // className ์ฒ˜๋ฆฌ
207
+ let resolvedClassName =
208
+ typeof className === "function" ? className({ isActive }) : className;
209
+
210
+ if (isActive && activeClassName) {
211
+ resolvedClassName = resolvedClassName
212
+ ? `${resolvedClassName} ${activeClassName}`
213
+ : activeClassName;
214
+ }
215
+
216
+ // style ์ฒ˜๋ฆฌ
217
+ let resolvedStyle =
218
+ typeof style === "function" ? style({ isActive }) : style;
219
+
220
+ if (isActive && activeStyle) {
221
+ resolvedStyle = { ...resolvedStyle, ...activeStyle };
222
+ }
223
+
224
+ return (
225
+ <Link
226
+ href={href}
227
+ className={resolvedClassName}
228
+ style={resolvedStyle}
229
+ {...rest}
230
+ />
231
+ );
232
+ }
233
+
234
+ export default Link;
@@ -1,6 +1,6 @@
1
- /**
2
- * Mandu Client Module ๐Ÿ๏ธ
3
- * ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ hydration ๋ฐ ๋ผ์šฐํŒ…์„ ์œ„ํ•œ API
1
+ /**
2
+ * Mandu Client Module ๐Ÿ๏ธ
3
+ * ํด๋ผ์ด์–ธํŠธ ์‚ฌ์ด๋“œ hydration ๋ฐ ๋ผ์šฐํŒ…์„ ์œ„ํ•œ API
4
4
  *
5
5
  * @example
6
6
  * ```typescript
@@ -23,8 +23,8 @@
23
23
  * return <Link href="/about">About</Link>;
24
24
  * }
25
25
  * ```
26
- */
27
- import "./globals";
26
+ */
27
+ import "./globals";
28
28
 
29
29
  // Island API
30
30
  export {
@@ -83,6 +83,9 @@ export {
83
83
  // Link Components
84
84
  export { Link, NavLink, type LinkProps, type NavLinkProps } from "./Link";
85
85
 
86
+ // Stable interaction components
87
+ export { ManduButton, ManduModalTrigger } from "./interaction";
88
+
86
89
  // Router Hooks
87
90
  export {
88
91
  useRouter,
@@ -0,0 +1,29 @@
1
+ import React, { type ButtonHTMLAttributes } from "react";
2
+ import { autoStableManduId } from "../runtime/stable-selector";
3
+
4
+ export interface ManduButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
5
+ manduId?: string;
6
+ }
7
+
8
+ /**
9
+ * Mandu standard interaction Button.
10
+ * Core guarantees data-mandu-id injection (best-effort, stable rule).
11
+ */
12
+ export function ManduButton({ manduId, ...props }: ManduButtonProps) {
13
+ const id = manduId ?? autoStableManduId("ManduButton");
14
+ return <button data-mandu-id={id} {...props} />;
15
+ }
16
+
17
+ export interface ManduModalTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
18
+ manduId?: string;
19
+ modal?: string;
20
+ }
21
+
22
+ /**
23
+ * Skeleton modal trigger component.
24
+ * ATE extractor can later recognize this signal.
25
+ */
26
+ export function ManduModalTrigger({ manduId, modal, ...props }: ManduModalTriggerProps) {
27
+ const id = manduId ?? autoStableManduId("ManduModalTrigger");
28
+ return <button data-mandu-id={id} data-mandu-modal-trigger={modal ?? ""} {...props} />;
29
+ }
@@ -17,6 +17,7 @@ export * from "./normalize";
17
17
  export * from "./registry";
18
18
  export * from "./client-safe";
19
19
  export * from "./protection";
20
+ export * from "./route-helpers";
20
21
 
21
22
  import type { ContractDefinition, ContractInstance, ContractSchema } from "./schema";
22
23
  import type { ContractHandlers, RouteDefinition } from "./handler";
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { z } from "zod";
3
+ import { apiError, bodySchema, querySchema } from "./route-helpers";
4
+
5
+ describe("route helpers", () => {
6
+ it("querySchema parses/coerces values", () => {
7
+ const parseQuery = querySchema(
8
+ z.object({
9
+ sinceId: z.coerce.number().int().nonnegative().default(0),
10
+ limit: z.coerce.number().int().min(1).max(100).default(20),
11
+ })
12
+ );
13
+
14
+ const result = parseQuery("?sinceId=10&limit=5");
15
+ expect(result).toEqual({ sinceId: 10, limit: 5 });
16
+ });
17
+
18
+ it("querySchema supports defaults", () => {
19
+ const parseQuery = querySchema(
20
+ z.object({
21
+ limit: z.coerce.number().int().min(1).max(100).default(20),
22
+ })
23
+ );
24
+
25
+ const result = parseQuery(new URLSearchParams());
26
+ expect(result.limit).toBe(20);
27
+ });
28
+
29
+ it("bodySchema parses JSON body", async () => {
30
+ const parseBody = bodySchema(
31
+ z.object({
32
+ text: z.string().min(1),
33
+ })
34
+ );
35
+
36
+ const request = new Request("http://localhost/api/chat/send", {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json" },
39
+ body: JSON.stringify({ text: "mandu" }),
40
+ });
41
+
42
+ await expect(parseBody(request)).resolves.toEqual({ text: "mandu" });
43
+ });
44
+
45
+ it("bodySchema accepts +json content type", async () => {
46
+ const parseBody = bodySchema(z.object({ text: z.string() }));
47
+ const request = new Request("http://localhost/api/chat/send", {
48
+ method: "POST",
49
+ headers: { "content-type": "application/problem+json; charset=utf-8" },
50
+ body: JSON.stringify({ text: "mandu" }),
51
+ });
52
+
53
+ await expect(parseBody(request)).resolves.toEqual({ text: "mandu" });
54
+ });
55
+
56
+ it("bodySchema rejects non-json content type", async () => {
57
+ const parseBody = bodySchema(z.object({ text: z.string() }));
58
+ const request = new Request("http://localhost/api/chat/send", {
59
+ method: "POST",
60
+ headers: { "content-type": "text/plain" },
61
+ body: "text=mandu",
62
+ });
63
+
64
+ await expect(parseBody(request)).rejects.toThrow("application/json");
65
+ });
66
+
67
+ it("bodySchema normalizes invalid JSON parse failure", async () => {
68
+ const parseBody = bodySchema(z.object({ text: z.string() }));
69
+ const request = new Request("http://localhost/api/chat/send", {
70
+ method: "POST",
71
+ headers: { "content-type": "application/json" },
72
+ body: "{\"text\":" ,
73
+ });
74
+
75
+ await expect(parseBody(request)).rejects.toThrow("Request body contains invalid JSON");
76
+ });
77
+
78
+ it("apiError returns standardized payload", async () => {
79
+ const res = apiError("invalid input", "BAD_REQUEST", { status: 422 });
80
+
81
+ expect(res.status).toBe(422);
82
+ await expect(res.json()).resolves.toEqual({
83
+ error: "invalid input",
84
+ code: "BAD_REQUEST",
85
+ });
86
+ });
87
+ });
@@ -0,0 +1,156 @@
1
+ import { z, type ZodTypeAny } from "zod";
2
+
3
+ export type ApiErrorCode = string;
4
+
5
+ export interface ApiErrorBody {
6
+ error: string;
7
+ code: ApiErrorCode;
8
+ }
9
+
10
+ export interface ApiErrorOptions {
11
+ status?: number;
12
+ details?: unknown;
13
+ headers?: HeadersInit;
14
+ }
15
+
16
+ export type QuerySource =
17
+ | Request
18
+ | URL
19
+ | URLSearchParams
20
+ | string
21
+ | Record<string, string | number | boolean | null | undefined>;
22
+
23
+ function toSearchParams(source: QuerySource): URLSearchParams {
24
+ if (source instanceof Request) {
25
+ return new URL(source.url).searchParams;
26
+ }
27
+
28
+ if (source instanceof URL) {
29
+ return source.searchParams;
30
+ }
31
+
32
+ if (source instanceof URLSearchParams) {
33
+ return source;
34
+ }
35
+
36
+ if (typeof source === "string") {
37
+ const raw = source.startsWith("?") ? source.slice(1) : source;
38
+ return new URLSearchParams(raw);
39
+ }
40
+
41
+ const params = new URLSearchParams();
42
+ for (const [key, value] of Object.entries(source)) {
43
+ if (value === undefined || value === null) continue;
44
+ params.set(key, String(value));
45
+ }
46
+ return params;
47
+ }
48
+
49
+ function paramsToObject(params: URLSearchParams): Record<string, string | string[]> {
50
+ const out: Record<string, string | string[]> = {};
51
+ for (const [key, value] of params.entries()) {
52
+ const prev = out[key];
53
+ if (prev === undefined) {
54
+ out[key] = value;
55
+ continue;
56
+ }
57
+ if (Array.isArray(prev)) {
58
+ prev.push(value);
59
+ continue;
60
+ }
61
+ out[key] = [prev, value];
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Creates a parser for URL query parameters with Zod schema validation
68
+ *
69
+ * @param schema - Zod schema for validation
70
+ * @returns Parser function that accepts various query sources
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const parseQuery = querySchema(z.object({
75
+ * page: z.coerce.number().default(1),
76
+ * limit: z.coerce.number().max(100).default(20)
77
+ * }));
78
+ *
79
+ * const query = parseQuery(request);
80
+ * ```
81
+ */
82
+ export function querySchema<TSchema extends ZodTypeAny>(schema: TSchema) {
83
+ return (source: QuerySource): z.infer<TSchema> => {
84
+ const params = toSearchParams(source);
85
+ return schema.parse(paramsToObject(params));
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Creates a parser for JSON request body with Zod schema validation
91
+ *
92
+ * Validates Content-Type (application/json or application/*+json) and parses JSON.
93
+ * Throws TypeError for invalid content-type or malformed JSON.
94
+ *
95
+ * @param schema - Zod schema for validation
96
+ * @returns Async parser function that accepts Request
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const parseBody = bodySchema(z.object({
101
+ * text: z.string().min(1).max(500)
102
+ * }));
103
+ *
104
+ * const body = await parseBody(request);
105
+ * ```
106
+ */
107
+ export function bodySchema<TSchema extends ZodTypeAny>(schema: TSchema) {
108
+ return async (request: Request): Promise<z.infer<TSchema>> => {
109
+ const contentType = request.headers.get("content-type") ?? "";
110
+
111
+ if (!/^application\/(.+\+json|json)$/i.test(contentType.split(";")[0]?.trim() ?? "")) {
112
+ throw new TypeError("Body must be application/json");
113
+ }
114
+
115
+ let payload: unknown;
116
+ try {
117
+ payload = await request.clone().json();
118
+ } catch {
119
+ throw new TypeError("Request body contains invalid JSON");
120
+ }
121
+
122
+ return schema.parse(payload);
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Creates a standardized API error response
128
+ *
129
+ * Returns Response with JSON payload: { error, code, details? }
130
+ *
131
+ * @param error - Human-readable error message
132
+ * @param code - Machine-readable error code
133
+ * @param options - Optional status (default 400), details, and headers
134
+ * @returns Response with error payload
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * return apiError("Invalid input", "VALIDATION_ERROR", {
139
+ * status: 422,
140
+ * details: { field: "email", issue: "Invalid format" }
141
+ * });
142
+ * ```
143
+ */
144
+ export function apiError(error: string, code: ApiErrorCode, options: ApiErrorOptions = {}): Response {
145
+ const { status = 400, details, headers } = options;
146
+ const payload: ApiErrorBody & { details?: unknown } = { error, code };
147
+
148
+ if (details !== undefined) {
149
+ payload.details = details;
150
+ }
151
+
152
+ return Response.json(payload, {
153
+ status,
154
+ headers,
155
+ });
156
+ }
package/src/index.ts CHANGED
@@ -25,7 +25,7 @@ export * from "./paths";
25
25
 
26
26
  // Consolidated Mandu namespace
27
27
  import { ManduFilling, ManduContext, ManduFillingFactory, createSSEConnection } from "./filling";
28
- import { createContract, defineHandler, defineRoute, createClient, contractFetch, createClientContract } from "./contract";
28
+ import { createContract, defineHandler, defineRoute, createClient, contractFetch, createClientContract, querySchema, bodySchema, apiError } from "./contract";
29
29
  import { defineContract, generateAllFromContract, generateOpenAPISpec } from "./contract/define";
30
30
  import { island, isIsland, type IslandComponent, type HydrationStrategy } from "./island";
31
31
  import { intent, isIntent, getIntentDocs, generateOpenAPIFromIntent } from "./intent";
@@ -111,6 +111,21 @@ export const Mandu = {
111
111
  */
112
112
  fetch: contractFetch,
113
113
 
114
+ /**
115
+ * Build a typed query parser from zod schema
116
+ */
117
+ querySchema,
118
+
119
+ /**
120
+ * Build a typed JSON body parser from zod schema
121
+ */
122
+ bodySchema,
123
+
124
+ /**
125
+ * Build standard API error response ({ error, code })
126
+ */
127
+ apiError,
128
+
114
129
  // === AI-Native APIs ===
115
130
  /**
116
131
  * Define a Contract for code generation
@@ -9,3 +9,4 @@ export * from "./lifecycle";
9
9
  export * from "./trace";
10
10
  export * from "./logger";
11
11
  export * from "./boundary";
12
+ export * from "./stable-selector";
@@ -0,0 +1,67 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ /**
4
+ * React renderer integrity layer
5
+ *
6
+ * Goal: SSR renderer must use the same React graph as the app route modules.
7
+ * If @mandujs/core resolves react-dom from CLI's transient bunx cache,
8
+ * hooks can break with `resolveDispatcher() === null`.
9
+ */
10
+
11
+ type ReactDomServer = {
12
+ renderToString?: (element: unknown) => string;
13
+ renderToReadableStream?: (
14
+ element: unknown,
15
+ options?: Record<string, unknown>
16
+ ) => Promise<ReadableStream & { allReady: Promise<void> }>;
17
+ };
18
+
19
+ const requireFromCore = createRequire(import.meta.url);
20
+
21
+ let cachedServer: ReactDomServer | null = null;
22
+ let cachedServerBrowser: ReactDomServer | null = null;
23
+
24
+ function loadFromProjectOrCore(specifier: "react-dom/server" | "react-dom/server.browser"): ReactDomServer {
25
+ // 1) Prefer app-level dependency graph (process.cwd)
26
+ const projectRequire = createRequire(`${process.cwd()}/`);
27
+
28
+ try {
29
+ return projectRequire(specifier) as ReactDomServer;
30
+ } catch (error) {
31
+ // 2) Fallback to framework-local resolution (tests/isolated runtime)
32
+ if (process.env.NODE_ENV === "development") {
33
+ console.debug(`[Mandu] Note: "${specifier}"๋ฅผ ํ”„๋กœ์ ํŠธ์—์„œ ๋กœ๋“œํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ํ”„๋ ˆ์ž„์›Œํฌ ์˜์กด์„ฑ์œผ๋กœ ๋Œ€์ฒดํ•ฉ๋‹ˆ๋‹ค.`, error);
34
+ }
35
+ return requireFromCore(specifier) as ReactDomServer;
36
+ }
37
+ }
38
+
39
+ export function getRenderToString(): (element: unknown) => string {
40
+ if (!cachedServer) {
41
+ cachedServer = loadFromProjectOrCore("react-dom/server");
42
+ }
43
+
44
+ if (typeof cachedServer.renderToString !== "function") {
45
+ throw new Error("renderToString not found in react-dom/server");
46
+ }
47
+
48
+ return cachedServer.renderToString;
49
+ }
50
+
51
+ export function getRenderToReadableStream(): (
52
+ element: unknown,
53
+ options?: Record<string, unknown>
54
+ ) => Promise<ReadableStream & { allReady: Promise<void> }> {
55
+ if (!cachedServerBrowser) {
56
+ cachedServerBrowser = loadFromProjectOrCore("react-dom/server.browser");
57
+ }
58
+
59
+ if (typeof cachedServerBrowser.renderToReadableStream !== "function") {
60
+ throw new Error("renderToReadableStream not found in react-dom/server.browser");
61
+ }
62
+
63
+ return cachedServerBrowser.renderToReadableStream as (
64
+ element: unknown,
65
+ options?: Record<string, unknown>
66
+ ) => Promise<ReadableStream & { allReady: Promise<void> }>;
67
+ }
@@ -1,5 +1,6 @@
1
- import { renderToString } from "react-dom/server";
1
+ import { getRenderToString } from "./react-renderer";
2
2
  import { serializeProps } from "../client/serialize";
3
+ import { createRequire } from "module";
3
4
  import type { ReactElement } from "react";
4
5
  import type { BundleManifest } from "../bundler/types";
5
6
  import type { HydrationConfig, HydrationPriority } from "../spec/schema";
@@ -48,6 +49,32 @@ export interface SSROptions {
48
49
  cssPath?: string | false;
49
50
  }
50
51
 
52
+ let projectRenderToString: ((element: ReactElement) => string) | null | undefined;
53
+
54
+ function loadProjectRenderToString(): ((element: ReactElement) => string) | null {
55
+ if (projectRenderToString !== undefined) {
56
+ return projectRenderToString;
57
+ }
58
+
59
+ try {
60
+ const projectRequire = createRequire(`${process.cwd()}/package.json`);
61
+ const module = projectRequire("react-dom/server") as {
62
+ renderToString?: (element: ReactElement) => string;
63
+ default?: { renderToString?: (element: ReactElement) => string };
64
+ };
65
+ const renderToString = module.renderToString ?? module.default?.renderToString;
66
+ if (typeof renderToString === "function") {
67
+ projectRenderToString = renderToString;
68
+ return projectRenderToString;
69
+ }
70
+ } catch {
71
+ // fallback below
72
+ }
73
+
74
+ projectRenderToString = null;
75
+ return null;
76
+ }
77
+
51
78
  /**
52
79
  * SSR ๋ฐ์ดํ„ฐ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ์ง๋ ฌํ™” (Fresh ์Šคํƒ€์ผ ๊ณ ๊ธ‰ ์ง๋ ฌํ™”)
53
80
  * Date, Map, Set, URL, RegExp, BigInt, ์ˆœํ™˜์ฐธ์กฐ ์ง€์›
@@ -160,6 +187,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
160
187
  ? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
161
188
  : "";
162
189
 
190
+ const renderToString = getRenderToString();
163
191
  let content = renderToString(element);
164
192
 
165
193
  // Island ๋ž˜ํผ ์ ์šฉ (hydration ํ•„์š” ์‹œ)
@@ -0,0 +1,70 @@
1
+ function fnv1a64Hex(input: string): string {
2
+ // Browser-safe deterministic hash (FNV-1a 64-bit)
3
+ let hash = 0xcbf29ce484222325n;
4
+ const prime = 0x100000001b3n;
5
+ for (let i = 0; i < input.length; i++) {
6
+ hash ^= BigInt(input.charCodeAt(i));
7
+ hash = (hash * prime) & 0xffffffffffffffffn;
8
+ }
9
+ return hash.toString(16).padStart(16, "0");
10
+ }
11
+
12
+ function getBuildSaltFallback(): string {
13
+ try {
14
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
15
+ const salt = (typeof process !== "undefined" && (process as any)?.env?.MANDU_BUILD_SALT) as string | undefined;
16
+ return salt ?? "dev";
17
+ } catch {
18
+ return "dev";
19
+ }
20
+ }
21
+
22
+ export interface StableManduIdInput {
23
+ filePath: string;
24
+ line: number;
25
+ column: number;
26
+ symbolName: string;
27
+ buildSalt: string;
28
+ }
29
+
30
+ /**
31
+ * Stable selector id generator
32
+ * rule: hash(filePath + line + column + symbolName + buildSalt)
33
+ */
34
+ export function createStableManduId(input: StableManduIdInput): string {
35
+ const payload = `${input.filePath}:${input.line}:${input.column}:${input.symbolName}:${input.buildSalt}`;
36
+ const hex = fnv1a64Hex(payload);
37
+ return `mnd_${hex}`;
38
+ }
39
+
40
+ export function inferSourceLocationFromStack(stack: string | undefined): { filePath: string; line: number; column: number } | null {
41
+ if (!stack) return null;
42
+ // naive parser: find first "(file:line:col)" or "at file:line:col" or "fn@file:line:col" frame
43
+ const lines = stack.split("\n").map((l) => l.trim());
44
+ for (const l of lines) {
45
+ const m =
46
+ l.match(/\((.*?):(\d+):(\d+)\)/) ??
47
+ l.match(/\bat\s+(.*?):(\d+):(\d+)\b/) ??
48
+ l.match(/@(.*?):(\d+):(\d+)/);
49
+ if (!m) continue;
50
+ const filePath = m[1];
51
+ // skip internal/node/bun frames
52
+ if (filePath.includes("node:") || filePath.includes("bun:") || filePath.includes("internal")) continue;
53
+ return { filePath, line: Number(m[2]), column: Number(m[3]) };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Best-effort auto injection for Mandu standard interaction components.
60
+ * - NOTE: In the browser, stack traces may point to bundled files.
61
+ * - This is a minimum skeleton; ATE can build selector-map and improve fallbacks.
62
+ */
63
+ export function autoStableManduId(symbolName: string, buildSalt?: string): string {
64
+ const salt = buildSalt ?? getBuildSaltFallback();
65
+ const loc = inferSourceLocationFromStack(new Error().stack);
66
+ if (!loc) {
67
+ return createStableManduId({ filePath: "unknown", line: 0, column: 0, symbolName, buildSalt: salt });
68
+ }
69
+ return createStableManduId({ ...loc, symbolName, buildSalt: salt });
70
+ }
@@ -9,9 +9,7 @@
9
9
  * - Island Architecture์™€ ์™„๋ฒฝ ํ†ตํ•ฉ
10
10
  */
11
11
 
12
- // Bun์—์„œ๋Š” react-dom/server.browser์—์„œ renderToReadableStream์„ ๊ฐ€์ ธ์˜ด
13
- // Node.js ํ™˜๊ฒฝ์—์„œ๋Š” renderToPipeableStream ์‚ฌ์šฉ ํ•„์š”
14
- import { renderToReadableStream } from "react-dom/server.browser";
12
+ import { getRenderToReadableStream } from "./react-renderer";
15
13
  import type { ReactElement, ReactNode } from "react";
16
14
  import React, { Suspense } from "react";
17
15
  import type { BundleManifest } from "../bundler/types";
@@ -665,6 +663,7 @@ export async function renderToStream(
665
663
 
666
664
  // React renderToReadableStream ํ˜ธ์ถœ
667
665
  // ์‹คํŒจ ์‹œ throw โ†’ renderStreamingResponse์—์„œ 500 ์ฒ˜๋ฆฌ
666
+ const renderToReadableStream = getRenderToReadableStream();
668
667
  const reactStream = await renderToReadableStream(element, {
669
668
  onError: (error: Error) => {
670
669
  if (timedOut) return;