@mandujs/core 0.3.4 β†’ 0.4.1

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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Mandu Island - Client Slot API 🏝️
3
+ * Hydration을 μœ„ν•œ ν΄λΌμ΄μ–ΈνŠΈ μ‚¬μ΄λ“œ μ»΄ν¬λ„ŒνŠΈ μ •μ˜
4
+ */
5
+
6
+ import type { ReactNode } from "react";
7
+
8
+ /**
9
+ * Island μ •μ˜ νƒ€μž…
10
+ * @template TServerData - SSRμ—μ„œ μ „λ‹¬λ°›λŠ” μ„œλ²„ 데이터 νƒ€μž…
11
+ * @template TSetupResult - setup ν•¨μˆ˜κ°€ λ°˜ν™˜ν•˜λŠ” κ²°κ³Ό νƒ€μž…
12
+ */
13
+ export interface IslandDefinition<TServerData, TSetupResult> {
14
+ /**
15
+ * Setup Phase
16
+ * - μ„œλ²„ 데이터λ₯Ό λ°›μ•„ ν΄λΌμ΄μ–ΈνŠΈ μƒνƒœ μ΄ˆκΈ°ν™”
17
+ * - React hooks μ‚¬μš© κ°€λŠ₯
18
+ * - λ°˜ν™˜κ°’μ΄ render ν•¨μˆ˜μ— 전달됨
19
+ */
20
+ setup: (serverData: TServerData) => TSetupResult;
21
+
22
+ /**
23
+ * Render Phase
24
+ * - setupμ—μ„œ λ°˜ν™˜λœ 값을 props둜 λ°›μŒ
25
+ * - 순수 λ Œλ”λ§ 둜직만 포함
26
+ */
27
+ render: (props: TSetupResult) => ReactNode;
28
+
29
+ /**
30
+ * Optional: μ—λŸ¬ λ°œμƒ μ‹œ ν‘œμ‹œν•  fallback UI
31
+ */
32
+ errorBoundary?: (error: Error, reset: () => void) => ReactNode;
33
+
34
+ /**
35
+ * Optional: λ‘œλ”© 쀑 ν‘œμ‹œν•  UI (progressive hydration용)
36
+ */
37
+ loading?: () => ReactNode;
38
+ }
39
+
40
+ /**
41
+ * Island μ»΄ν¬λ„ŒνŠΈμ˜ 메타데이터
42
+ */
43
+ export interface IslandMetadata {
44
+ /** Island 고유 μ‹λ³„μž */
45
+ id: string;
46
+ /** SSR 데이터 ν‚€ */
47
+ dataKey: string;
48
+ /** Hydration μš°μ„ μˆœμœ„ */
49
+ priority: "immediate" | "visible" | "idle" | "interaction";
50
+ }
51
+
52
+ /**
53
+ * 컴파일된 Island μ»΄ν¬λ„ŒνŠΈ νƒ€μž…
54
+ */
55
+ export interface CompiledIsland<TServerData, TSetupResult> {
56
+ /** Island μ •μ˜ */
57
+ definition: IslandDefinition<TServerData, TSetupResult>;
58
+ /** Island 메타데이터 (λΉŒλ“œ μ‹œ μ£Όμž…) */
59
+ __mandu_island: true;
60
+ /** Island ID (λΉŒλ“œ μ‹œ μ£Όμž…) */
61
+ __mandu_island_id?: string;
62
+ }
63
+
64
+ /**
65
+ * Island μ»΄ν¬λ„ŒνŠΈ 생성
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * // spec/slots/todos.client.ts
70
+ * import { Mandu } from "@mandujs/core/client";
71
+ * import { useState, useCallback } from "react";
72
+ *
73
+ * interface TodosData {
74
+ * todos: Todo[];
75
+ * user: User | null;
76
+ * }
77
+ *
78
+ * export default Mandu.island<TodosData>({
79
+ * setup: (serverData) => {
80
+ * const [todos, setTodos] = useState(serverData.todos);
81
+ * const addTodo = useCallback(async (text: string) => {
82
+ * // ...
83
+ * }, []);
84
+ * return { todos, addTodo, user: serverData.user };
85
+ * },
86
+ * render: ({ todos, addTodo, user }) => (
87
+ * <div>
88
+ * {user && <span>Hello, {user.name}!</span>}
89
+ * <TodoList todos={todos} onAdd={addTodo} />
90
+ * </div>
91
+ * )
92
+ * });
93
+ * ```
94
+ */
95
+ export function island<TServerData, TSetupResult = TServerData>(
96
+ definition: IslandDefinition<TServerData, TSetupResult>
97
+ ): CompiledIsland<TServerData, TSetupResult> {
98
+ // Validate definition
99
+ if (typeof definition.setup !== "function") {
100
+ throw new Error("[Mandu Island] setup must be a function");
101
+ }
102
+ if (typeof definition.render !== "function") {
103
+ throw new Error("[Mandu Island] render must be a function");
104
+ }
105
+
106
+ return {
107
+ definition,
108
+ __mandu_island: true,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Islandμ—μ„œ μ‚¬μš©ν•  수 μžˆλŠ” 헬퍼 ν›…λ“€
114
+ */
115
+
116
+ /**
117
+ * SSR 데이터에 μ•ˆμ „ν•˜κ²Œ μ ‘κ·Όν•˜λŠ” ν›…
118
+ * μ„œλ²„ 데이터가 μ—†λŠ” 경우 fallback λ°˜ν™˜
119
+ */
120
+ export function useServerData<T>(key: string, fallback: T): T {
121
+ if (typeof window === "undefined") {
122
+ return fallback;
123
+ }
124
+
125
+ const manduData = (window as any).__MANDU_DATA__;
126
+ if (!manduData || !(key in manduData)) {
127
+ return fallback;
128
+ }
129
+
130
+ return manduData[key] as T;
131
+ }
132
+
133
+ /**
134
+ * Hydration μƒνƒœλ₯Ό μΆ”μ ν•˜λŠ” ν›…
135
+ */
136
+ export function useHydrated(): boolean {
137
+ if (typeof window === "undefined") {
138
+ return false;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ /**
144
+ * Island κ°„ 톡신을 μœ„ν•œ 이벀트 ν›…
145
+ */
146
+ export function useIslandEvent<T = unknown>(
147
+ eventName: string,
148
+ handler: (data: T) => void
149
+ ): (data: T) => void {
150
+ if (typeof window === "undefined") {
151
+ return () => {};
152
+ }
153
+
154
+ // 이벀트 λ¦¬μŠ€λ„ˆ 등둝
155
+ const customEventName = `mandu:island:${eventName}`;
156
+
157
+ const listener = (event: CustomEvent<T>) => {
158
+ handler(event.detail);
159
+ };
160
+
161
+ window.addEventListener(customEventName, listener as EventListener);
162
+
163
+ // 이벀트 λ°œμ†‘ ν•¨μˆ˜ λ°˜ν™˜
164
+ return (data: T) => {
165
+ window.dispatchEvent(new CustomEvent(customEventName, { detail: data }));
166
+ };
167
+ }
168
+
169
+ /**
170
+ * API 호좜 헬퍼
171
+ */
172
+ export interface FetchOptions extends Omit<RequestInit, "body"> {
173
+ body?: unknown;
174
+ }
175
+
176
+ export async function fetchApi<T>(
177
+ url: string,
178
+ options: FetchOptions = {}
179
+ ): Promise<T> {
180
+ const { body, headers = {}, ...rest } = options;
181
+
182
+ const response = await fetch(url, {
183
+ headers: {
184
+ "Content-Type": "application/json",
185
+ ...headers,
186
+ },
187
+ body: body ? JSON.stringify(body) : undefined,
188
+ ...rest,
189
+ });
190
+
191
+ if (!response.ok) {
192
+ const error = await response.json().catch(() => ({ message: response.statusText }));
193
+ throw new Error(error.message || `API Error: ${response.status}`);
194
+ }
195
+
196
+ return response.json();
197
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Mandu Hydration Runtime 🌊
3
+ * λΈŒλΌμš°μ €μ—μ„œ Islandλ₯Ό hydrateν•˜λŠ” λŸ°νƒ€μž„
4
+ */
5
+
6
+ import { hydrateRoot } from "react-dom/client";
7
+ import type { Root } from "react-dom/client";
8
+ import type { CompiledIsland } from "./island";
9
+ import type { ReactNode } from "react";
10
+ import React from "react";
11
+
12
+ /**
13
+ * Island λ‘œλ” νƒ€μž…
14
+ */
15
+ export type IslandLoader = () => Promise<CompiledIsland<any, any>> | CompiledIsland<any, any>;
16
+
17
+ /**
18
+ * Island λ ˆμ§€μŠ€νŠΈλ¦¬
19
+ */
20
+ const islandRegistry = new Map<string, IslandLoader>();
21
+
22
+ /**
23
+ * Hydrated roots 좔적
24
+ */
25
+ const hydratedRoots = new Map<string, Root>();
26
+
27
+ /**
28
+ * Hydration μƒνƒœ 좔적
29
+ */
30
+ interface HydrationState {
31
+ total: number;
32
+ hydrated: number;
33
+ failed: number;
34
+ pending: Set<string>;
35
+ }
36
+
37
+ const hydrationState: HydrationState = {
38
+ total: 0,
39
+ hydrated: 0,
40
+ failed: 0,
41
+ pending: new Set(),
42
+ };
43
+
44
+ /**
45
+ * Island 등둝
46
+ */
47
+ export function registerIsland(id: string, loader: IslandLoader): void {
48
+ islandRegistry.set(id, loader);
49
+ }
50
+
51
+ /**
52
+ * λ“±λ‘λœ λͺ¨λ“  Island κ°€μ Έμ˜€κΈ°
53
+ */
54
+ export function getRegisteredIslands(): string[] {
55
+ return Array.from(islandRegistry.keys());
56
+ }
57
+
58
+ /**
59
+ * μ„œλ²„ 데이터 κ°€μ Έμ˜€κΈ°
60
+ */
61
+ export function getServerData<T = unknown>(islandId: string): T | undefined {
62
+ if (typeof window === "undefined") {
63
+ return undefined;
64
+ }
65
+
66
+ const manduData = (window as any).__MANDU_DATA__;
67
+ if (!manduData) {
68
+ return undefined;
69
+ }
70
+
71
+ return manduData[islandId]?.serverData as T;
72
+ }
73
+
74
+ /**
75
+ * Priority-based hydration μŠ€μΌ€μ€„λŸ¬
76
+ */
77
+ type HydrationPriority = "immediate" | "visible" | "idle" | "interaction";
78
+
79
+ function scheduleHydration(
80
+ element: HTMLElement,
81
+ id: string,
82
+ priority: HydrationPriority,
83
+ serverData: unknown
84
+ ): void {
85
+ switch (priority) {
86
+ case "immediate":
87
+ hydrateIsland(element, id, serverData);
88
+ break;
89
+
90
+ case "visible":
91
+ if ("IntersectionObserver" in window) {
92
+ const observer = new IntersectionObserver(
93
+ (entries) => {
94
+ if (entries[0].isIntersecting) {
95
+ observer.disconnect();
96
+ hydrateIsland(element, id, serverData);
97
+ }
98
+ },
99
+ { rootMargin: "50px" }
100
+ );
101
+ observer.observe(element);
102
+ } else {
103
+ // Fallback for older browsers
104
+ hydrateIsland(element, id, serverData);
105
+ }
106
+ break;
107
+
108
+ case "idle":
109
+ if ("requestIdleCallback" in window) {
110
+ (window as any).requestIdleCallback(() => {
111
+ hydrateIsland(element, id, serverData);
112
+ });
113
+ } else {
114
+ // Fallback
115
+ setTimeout(() => hydrateIsland(element, id, serverData), 200);
116
+ }
117
+ break;
118
+
119
+ case "interaction": {
120
+ const hydrate = () => {
121
+ element.removeEventListener("mouseenter", hydrate);
122
+ element.removeEventListener("focusin", hydrate);
123
+ element.removeEventListener("touchstart", hydrate);
124
+ hydrateIsland(element, id, serverData);
125
+ };
126
+ element.addEventListener("mouseenter", hydrate, { once: true, passive: true });
127
+ element.addEventListener("focusin", hydrate, { once: true });
128
+ element.addEventListener("touchstart", hydrate, { once: true, passive: true });
129
+ break;
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 단일 Island hydrate
136
+ */
137
+ async function hydrateIsland(
138
+ element: HTMLElement,
139
+ id: string,
140
+ serverData: unknown
141
+ ): Promise<void> {
142
+ const loader = islandRegistry.get(id);
143
+ if (!loader) {
144
+ console.warn(`[Mandu] Island not registered: ${id}`);
145
+ hydrationState.failed++;
146
+ hydrationState.pending.delete(id);
147
+ return;
148
+ }
149
+
150
+ try {
151
+ // λ‘œλ” μ‹€ν–‰ (dynamic import λ˜λŠ” 직접 μ°Έμ‘°)
152
+ const island = await Promise.resolve(loader());
153
+
154
+ if (!island.__mandu_island) {
155
+ throw new Error(`[Mandu] Invalid island: ${id}`);
156
+ }
157
+
158
+ const { definition } = island;
159
+
160
+ // Island μ»΄ν¬λ„ŒνŠΈ 생성
161
+ function IslandComponent(): ReactNode {
162
+ const setupResult = definition.setup(serverData);
163
+ return definition.render(setupResult);
164
+ }
165
+
166
+ // ErrorBoundaryκ°€ 있으면 감싸기
167
+ let content: ReactNode;
168
+ if (definition.errorBoundary) {
169
+ content = React.createElement(
170
+ ManduErrorBoundary,
171
+ {
172
+ fallback: (error: Error, reset: () => void) => definition.errorBoundary!(error, reset),
173
+ },
174
+ React.createElement(IslandComponent)
175
+ );
176
+ } else {
177
+ content = React.createElement(IslandComponent);
178
+ }
179
+
180
+ // Hydrate
181
+ const root = hydrateRoot(element, content);
182
+ hydratedRoots.set(id, root);
183
+
184
+ // μƒνƒœ μ—…λ°μ΄νŠΈ
185
+ element.setAttribute("data-mandu-hydrated", "true");
186
+ hydrationState.hydrated++;
187
+ hydrationState.pending.delete(id);
188
+
189
+ // μ„±λŠ₯ 마컀
190
+ if (typeof performance !== "undefined" && performance.mark) {
191
+ performance.mark(`mandu-hydrated-${id}`);
192
+ }
193
+
194
+ // Hydration μ™„λ£Œ 이벀트
195
+ element.dispatchEvent(
196
+ new CustomEvent("mandu:hydrated", {
197
+ bubbles: true,
198
+ detail: { id, serverData },
199
+ })
200
+ );
201
+ } catch (error) {
202
+ console.error(`[Mandu] Hydration failed for island ${id}:`, error);
203
+ hydrationState.failed++;
204
+ hydrationState.pending.delete(id);
205
+
206
+ // μ—λŸ¬ μƒνƒœ ν‘œμ‹œ
207
+ element.setAttribute("data-mandu-error", "true");
208
+
209
+ // μ—λŸ¬ 이벀트
210
+ element.dispatchEvent(
211
+ new CustomEvent("mandu:hydration-error", {
212
+ bubbles: true,
213
+ detail: { id, error },
214
+ })
215
+ );
216
+ }
217
+ }
218
+
219
+ /**
220
+ * λͺ¨λ“  Island hydrate μ‹œμž‘
221
+ */
222
+ export async function hydrateIslands(): Promise<void> {
223
+ if (typeof document === "undefined") {
224
+ return;
225
+ }
226
+
227
+ const islands = document.querySelectorAll<HTMLElement>("[data-mandu-island]");
228
+ const manduData = (window as any).__MANDU_DATA__ || {};
229
+
230
+ hydrationState.total = islands.length;
231
+
232
+ for (const element of islands) {
233
+ const id = element.getAttribute("data-mandu-island");
234
+ if (!id) continue;
235
+
236
+ const priority = (element.getAttribute("data-mandu-priority") || "visible") as HydrationPriority;
237
+ const data = manduData[id]?.serverData || {};
238
+
239
+ hydrationState.pending.add(id);
240
+ scheduleHydration(element, id, priority, data);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Hydration μƒνƒœ 쑰회
246
+ */
247
+ export function getHydrationState(): Readonly<HydrationState> {
248
+ return { ...hydrationState };
249
+ }
250
+
251
+ /**
252
+ * νŠΉμ • Island unmount
253
+ */
254
+ export function unmountIsland(id: string): boolean {
255
+ const root = hydratedRoots.get(id);
256
+ if (!root) {
257
+ return false;
258
+ }
259
+
260
+ root.unmount();
261
+ hydratedRoots.delete(id);
262
+ return true;
263
+ }
264
+
265
+ /**
266
+ * λͺ¨λ“  Island unmount
267
+ */
268
+ export function unmountAllIslands(): void {
269
+ for (const [id, root] of hydratedRoots) {
270
+ root.unmount();
271
+ hydratedRoots.delete(id);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * κ°„λ‹¨ν•œ ErrorBoundary μ»΄ν¬λ„ŒνŠΈ
277
+ */
278
+ interface ErrorBoundaryProps {
279
+ children: ReactNode;
280
+ fallback: (error: Error, reset: () => void) => ReactNode;
281
+ }
282
+
283
+ interface ErrorBoundaryState {
284
+ error: Error | null;
285
+ }
286
+
287
+ class ManduErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
288
+ constructor(props: ErrorBoundaryProps) {
289
+ super(props);
290
+ this.state = { error: null };
291
+ }
292
+
293
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
294
+ return { error };
295
+ }
296
+
297
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
298
+ console.error("[Mandu] Island error:", error, errorInfo);
299
+ }
300
+
301
+ reset = (): void => {
302
+ this.setState({ error: null });
303
+ };
304
+
305
+ render(): ReactNode {
306
+ if (this.state.error) {
307
+ return this.props.fallback(this.state.error, this.reset);
308
+ }
309
+ return this.props.children;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * μžλ™ μ΄ˆκΈ°ν™” (슀크립트 λ‘œλ“œ μ‹œ)
315
+ */
316
+ export function initializeRuntime(): void {
317
+ if (typeof document === "undefined") {
318
+ return;
319
+ }
320
+
321
+ // DOM이 μ€€λΉ„λ˜λ©΄ hydration μ‹œμž‘
322
+ if (document.readyState === "loading") {
323
+ document.addEventListener("DOMContentLoaded", () => {
324
+ hydrateIslands();
325
+ });
326
+ } else {
327
+ // 이미 DOM이 μ€€λΉ„λœ 경우
328
+ hydrateIslands();
329
+ }
330
+ }
331
+
332
+ // μžλ™ μ΄ˆκΈ°ν™” μ—¬λΆ€ (λ²ˆλ“€ μ‹œ μ„€μ •)
333
+ if (typeof window !== "undefined" && (window as any).__MANDU_AUTO_INIT__ !== false) {
334
+ initializeRuntime();
335
+ }
@@ -15,10 +15,14 @@ export type Guard = (ctx: ManduContext) => symbol | Response | Promise<symbol |
15
15
  /** HTTP methods */
16
16
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
17
17
 
18
- interface FillingConfig {
18
+ /** Loader function type - SSR 데이터 λ‘œλ”© */
19
+ export type Loader<T = unknown> = (ctx: ManduContext) => T | Promise<T>;
20
+
21
+ interface FillingConfig<TLoaderData = unknown> {
19
22
  handlers: Map<HttpMethod, Handler>;
20
23
  guards: Guard[];
21
24
  methodGuards: Map<HttpMethod, Guard[]>;
25
+ loader?: Loader<TLoaderData>;
22
26
  }
23
27
 
24
28
  /**
@@ -30,14 +34,64 @@ interface FillingConfig {
30
34
  * .get(ctx => ctx.ok({ message: 'Hello!' }))
31
35
  * .post(ctx => ctx.created({ id: 1 }))
32
36
  * ```
37
+ *
38
+ * @example with loader
39
+ * ```typescript
40
+ * export default Mandu.filling<{ todos: Todo[] }>()
41
+ * .loader(async (ctx) => {
42
+ * const todos = await db.todos.findMany();
43
+ * return { todos };
44
+ * })
45
+ * .get(ctx => ctx.ok(ctx.get('loaderData')))
46
+ * ```
33
47
  */
34
- export class ManduFilling {
35
- private config: FillingConfig = {
48
+ export class ManduFilling<TLoaderData = unknown> {
49
+ private config: FillingConfig<TLoaderData> = {
36
50
  handlers: new Map(),
37
51
  guards: [],
38
52
  methodGuards: new Map(),
39
53
  };
40
54
 
55
+ // ============================================
56
+ // πŸ₯Ÿ SSR Loader
57
+ // ============================================
58
+
59
+ /**
60
+ * Define SSR data loader
61
+ * νŽ˜μ΄μ§€ λ Œλ”λ§ μ „ μ„œλ²„μ—μ„œ 데이터λ₯Ό λ‘œλ“œν•©λ‹ˆλ‹€.
62
+ * λ‘œλ“œλœ λ°μ΄ν„°λŠ” ν΄λΌμ΄μ–ΈνŠΈλ‘œ μ „λ‹¬λ˜μ–΄ hydration에 μ‚¬μš©λ©λ‹ˆλ‹€.
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * .loader(async (ctx) => {
67
+ * const todos = await db.todos.findMany();
68
+ * return { todos, user: ctx.get('user') };
69
+ * })
70
+ * ```
71
+ */
72
+ loader(loaderFn: Loader<TLoaderData>): this {
73
+ this.config.loader = loaderFn;
74
+ return this;
75
+ }
76
+
77
+ /**
78
+ * Execute loader and return data
79
+ * @internal Used by SSR runtime
80
+ */
81
+ async executeLoader(ctx: ManduContext): Promise<TLoaderData | undefined> {
82
+ if (!this.config.loader) {
83
+ return undefined;
84
+ }
85
+ return await this.config.loader(ctx);
86
+ }
87
+
88
+ /**
89
+ * Check if loader is defined
90
+ */
91
+ hasLoader(): boolean {
92
+ return !!this.config.loader;
93
+ }
94
+
41
95
  // ============================================
42
96
  // πŸ₯Ÿ HTTP Method Handlers
43
97
  // ============================================
@@ -242,9 +296,26 @@ export const Mandu = {
242
296
  * export default Mandu.filling()
243
297
  * .get(ctx => ctx.ok({ message: 'Hello!' }))
244
298
  * ```
299
+ *
300
+ * @example with loader data type
301
+ * ```typescript
302
+ * import { Mandu } from '@mandujs/core'
303
+ *
304
+ * interface LoaderData {
305
+ * todos: Todo[];
306
+ * user: User | null;
307
+ * }
308
+ *
309
+ * export default Mandu.filling<LoaderData>()
310
+ * .loader(async (ctx) => {
311
+ * const todos = await db.todos.findMany();
312
+ * return { todos, user: null };
313
+ * })
314
+ * .get(ctx => ctx.ok(ctx.get('loaderData')))
315
+ * ```
245
316
  */
246
- filling(): ManduFilling {
247
- return new ManduFilling();
317
+ filling<TLoaderData = unknown>(): ManduFilling<TLoaderData> {
318
+ return new ManduFilling<TLoaderData>();
248
319
  },
249
320
 
250
321
  /**
package/src/index.ts CHANGED
@@ -7,3 +7,4 @@ export * from "./filling";
7
7
  export * from "./change";
8
8
  export * from "./error";
9
9
  export * from "./slot";
10
+ export * from "./bundler";
@@ -14,6 +14,10 @@ import {
14
14
  export interface ServerOptions {
15
15
  port?: number;
16
16
  hostname?: string;
17
+ /** 개발 λͺ¨λ“œ μ—¬λΆ€ */
18
+ isDev?: boolean;
19
+ /** HMR 포트 (개발 λͺ¨λ“œμ—μ„œ μ‚¬μš©) */
20
+ hmrPort?: number;
17
21
  }
18
22
 
19
23
  export interface ManduServer {
@@ -40,6 +44,9 @@ const pageLoaders: Map<string, PageLoader> = new Map();
40
44
  const routeComponents: Map<string, RouteComponent> = new Map();
41
45
  let createAppFn: CreateAppFn | null = null;
42
46
 
47
+ // Dev mode settings (module-level for handleRequest access)
48
+ let devModeSettings: { isDev: boolean; hmrPort?: number } = { isDev: false };
49
+
43
50
  export function registerApiHandler(routeId: string, handler: ApiHandler): void {
44
51
  apiHandlers.set(routeId, handler);
45
52
  }
@@ -126,7 +133,11 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
126
133
  params,
127
134
  });
128
135
 
129
- return renderSSR(app, { title: `${route.id} - Mandu` });
136
+ return renderSSR(app, {
137
+ title: `${route.id} - Mandu`,
138
+ isDev: devModeSettings.isDev,
139
+ hmrPort: devModeSettings.hmrPort,
140
+ });
130
141
  } catch (err) {
131
142
  const ssrError = createSSRErrorResponse(
132
143
  route.id,
@@ -159,7 +170,10 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
159
170
  }
160
171
 
161
172
  export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
162
- const { port = 3000, hostname = "localhost" } = options;
173
+ const { port = 3000, hostname = "localhost", isDev = false, hmrPort } = options;
174
+
175
+ // Dev mode settings μ €μž₯
176
+ devModeSettings = { isDev, hmrPort };
163
177
 
164
178
  const router = new Router(manifest.routes);
165
179
 
@@ -169,7 +183,14 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
169
183
  fetch: (req) => handleRequest(req, router),
170
184
  });
171
185
 
172
- console.log(`πŸ₯Ÿ Mandu server running at http://${hostname}:${port}`);
186
+ if (isDev) {
187
+ console.log(`πŸ₯Ÿ Mandu Dev Server running at http://${hostname}:${port}`);
188
+ if (hmrPort) {
189
+ console.log(`πŸ”₯ HMR enabled on port ${hmrPort + 1}`);
190
+ }
191
+ } else {
192
+ console.log(`πŸ₯Ÿ Mandu server running at http://${hostname}:${port}`);
193
+ }
173
194
 
174
195
  return {
175
196
  server,