@mandujs/core 0.9.42 → 0.9.44
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/bundler/css.ts +302 -0
- package/src/bundler/dev.ts +34 -16
- package/src/bundler/index.ts +1 -0
- package/src/runtime/server.ts +343 -265
- package/src/runtime/ssr.ts +11 -0
- package/src/runtime/streaming-ssr.ts +12 -0
package/README.ko.md
CHANGED
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu CSS Builder
|
|
3
|
+
* Tailwind CSS v4 CLI 기반 CSS 빌드 및 감시
|
|
4
|
+
*
|
|
5
|
+
* 특징:
|
|
6
|
+
* - Tailwind v4 Oxide Engine (Rust) 사용
|
|
7
|
+
* - Zero Config: @import "tailwindcss" 자동 감지
|
|
8
|
+
* - 출력: .mandu/client/globals.css
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn, type Subprocess } from "bun";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import fs from "fs/promises";
|
|
14
|
+
|
|
15
|
+
// ========== Types ==========
|
|
16
|
+
|
|
17
|
+
export interface CSSBuildOptions {
|
|
18
|
+
/** 프로젝트 루트 디렉토리 */
|
|
19
|
+
rootDir: string;
|
|
20
|
+
/** CSS 입력 파일 (기본: "app/globals.css") */
|
|
21
|
+
input?: string;
|
|
22
|
+
/** CSS 출력 파일 (기본: ".mandu/client/globals.css") */
|
|
23
|
+
output?: string;
|
|
24
|
+
/** Watch 모드 활성화 */
|
|
25
|
+
watch?: boolean;
|
|
26
|
+
/** Minify 활성화 (production) */
|
|
27
|
+
minify?: boolean;
|
|
28
|
+
/** 빌드 완료 콜백 */
|
|
29
|
+
onBuild?: (result: CSSBuildResult) => void;
|
|
30
|
+
/** 에러 콜백 */
|
|
31
|
+
onError?: (error: Error) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CSSBuildResult {
|
|
35
|
+
success: boolean;
|
|
36
|
+
outputPath: string;
|
|
37
|
+
buildTime?: number;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CSSWatcher {
|
|
42
|
+
/** Tailwind CLI 프로세스 */
|
|
43
|
+
process: Subprocess;
|
|
44
|
+
/** 출력 파일 경로 (절대 경로) */
|
|
45
|
+
outputPath: string;
|
|
46
|
+
/** 서버 경로 (/.mandu/client/globals.css) */
|
|
47
|
+
serverPath: string;
|
|
48
|
+
/** 프로세스 종료 */
|
|
49
|
+
close: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ========== Constants ==========
|
|
53
|
+
|
|
54
|
+
const DEFAULT_INPUT = "app/globals.css";
|
|
55
|
+
const DEFAULT_OUTPUT = ".mandu/client/globals.css";
|
|
56
|
+
const SERVER_CSS_PATH = "/.mandu/client/globals.css";
|
|
57
|
+
|
|
58
|
+
// ========== Detection ==========
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tailwind v4 프로젝트인지 감지
|
|
62
|
+
* app/globals.css에 @import "tailwindcss" 포함 여부 확인
|
|
63
|
+
*/
|
|
64
|
+
export async function isTailwindProject(rootDir: string): Promise<boolean> {
|
|
65
|
+
const cssPath = path.join(rootDir, DEFAULT_INPUT);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const content = await fs.readFile(cssPath, "utf-8");
|
|
69
|
+
// Tailwind v4: @import "tailwindcss"
|
|
70
|
+
// Tailwind v3: @tailwind base; @tailwind components; @tailwind utilities;
|
|
71
|
+
return (
|
|
72
|
+
content.includes('@import "tailwindcss"') ||
|
|
73
|
+
content.includes("@import 'tailwindcss'") ||
|
|
74
|
+
content.includes("@tailwind base")
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* CSS 입력 파일 존재 여부 확인
|
|
83
|
+
*/
|
|
84
|
+
export async function hasCSSEntry(rootDir: string, input?: string): Promise<boolean> {
|
|
85
|
+
const cssPath = path.join(rootDir, input || DEFAULT_INPUT);
|
|
86
|
+
try {
|
|
87
|
+
await fs.access(cssPath);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ========== Build ==========
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* CSS 일회성 빌드 (production용)
|
|
98
|
+
*/
|
|
99
|
+
export async function buildCSS(options: CSSBuildOptions): Promise<CSSBuildResult> {
|
|
100
|
+
const {
|
|
101
|
+
rootDir,
|
|
102
|
+
input = DEFAULT_INPUT,
|
|
103
|
+
output = DEFAULT_OUTPUT,
|
|
104
|
+
minify = true,
|
|
105
|
+
} = options;
|
|
106
|
+
|
|
107
|
+
const inputPath = path.join(rootDir, input);
|
|
108
|
+
const outputPath = path.join(rootDir, output);
|
|
109
|
+
const startTime = performance.now();
|
|
110
|
+
|
|
111
|
+
// 출력 디렉토리 생성
|
|
112
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
113
|
+
|
|
114
|
+
// Tailwind CLI 실행
|
|
115
|
+
const args = [
|
|
116
|
+
"@tailwindcss/cli",
|
|
117
|
+
"-i", inputPath,
|
|
118
|
+
"-o", outputPath,
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
if (minify) {
|
|
122
|
+
args.push("--minify");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const proc = spawn(["bunx", ...args], {
|
|
127
|
+
cwd: rootDir,
|
|
128
|
+
stdout: "pipe",
|
|
129
|
+
stderr: "pipe",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// 프로세스 완료 대기
|
|
133
|
+
const exitCode = await proc.exited;
|
|
134
|
+
|
|
135
|
+
if (exitCode !== 0) {
|
|
136
|
+
const stderr = await new Response(proc.stderr).text();
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
outputPath,
|
|
140
|
+
error: stderr || `Tailwind CLI exited with code ${exitCode}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const buildTime = performance.now() - startTime;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
success: true,
|
|
148
|
+
outputPath,
|
|
149
|
+
buildTime,
|
|
150
|
+
};
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return {
|
|
153
|
+
success: false,
|
|
154
|
+
outputPath,
|
|
155
|
+
error: error instanceof Error ? error.message : String(error),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ========== Watch ==========
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* CSS 감시 모드 시작 (development용)
|
|
164
|
+
* Tailwind CLI --watch 모드로 실행
|
|
165
|
+
*/
|
|
166
|
+
export async function startCSSWatch(options: CSSBuildOptions): Promise<CSSWatcher> {
|
|
167
|
+
const {
|
|
168
|
+
rootDir,
|
|
169
|
+
input = DEFAULT_INPUT,
|
|
170
|
+
output = DEFAULT_OUTPUT,
|
|
171
|
+
minify = false,
|
|
172
|
+
onBuild,
|
|
173
|
+
onError,
|
|
174
|
+
} = options;
|
|
175
|
+
|
|
176
|
+
const inputPath = path.join(rootDir, input);
|
|
177
|
+
const outputPath = path.join(rootDir, output);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
// 출력 디렉토리 생성
|
|
181
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const err = new Error(`CSS 출력 디렉토리 생성 실패: ${error instanceof Error ? error.message : error}`);
|
|
184
|
+
console.error(`❌ ${err.message}`);
|
|
185
|
+
onError?.(err);
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Tailwind CLI 인자 구성
|
|
190
|
+
const args = [
|
|
191
|
+
"@tailwindcss/cli",
|
|
192
|
+
"-i", inputPath,
|
|
193
|
+
"-o", outputPath,
|
|
194
|
+
"--watch",
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
if (minify) {
|
|
198
|
+
args.push("--minify");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(`🎨 Tailwind CSS v4 빌드 시작...`);
|
|
202
|
+
console.log(` 입력: ${input}`);
|
|
203
|
+
console.log(` 출력: ${output}`);
|
|
204
|
+
|
|
205
|
+
// Bun subprocess로 Tailwind CLI 실행
|
|
206
|
+
let proc;
|
|
207
|
+
try {
|
|
208
|
+
proc = spawn(["bunx", ...args], {
|
|
209
|
+
cwd: rootDir,
|
|
210
|
+
stdout: "pipe",
|
|
211
|
+
stderr: "pipe",
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const err = new Error(
|
|
215
|
+
`Tailwind CLI 실행 실패. @tailwindcss/cli가 설치되어 있는지 확인하세요.\n` +
|
|
216
|
+
`설치: bun add -d @tailwindcss/cli tailwindcss\n` +
|
|
217
|
+
`원인: ${error instanceof Error ? error.message : error}`
|
|
218
|
+
);
|
|
219
|
+
console.error(`❌ ${err.message}`);
|
|
220
|
+
onError?.(err);
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// stdout 모니터링 (빌드 완료 감지)
|
|
225
|
+
(async () => {
|
|
226
|
+
const reader = proc.stdout.getReader();
|
|
227
|
+
const decoder = new TextDecoder();
|
|
228
|
+
|
|
229
|
+
while (true) {
|
|
230
|
+
const { done, value } = await reader.read();
|
|
231
|
+
if (done) break;
|
|
232
|
+
|
|
233
|
+
const text = decoder.decode(value);
|
|
234
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
235
|
+
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
// Tailwind v4 출력 패턴: "Done in Xms" 또는 빌드 완료 메시지
|
|
238
|
+
if (line.includes("Done in") || line.includes("Rebuilt in")) {
|
|
239
|
+
console.log(` ✅ CSS ${line.trim()}`);
|
|
240
|
+
onBuild?.({
|
|
241
|
+
success: true,
|
|
242
|
+
outputPath,
|
|
243
|
+
});
|
|
244
|
+
} else if (line.includes("warn") || line.includes("Warning")) {
|
|
245
|
+
console.log(` ⚠️ CSS ${line.trim()}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
})();
|
|
250
|
+
|
|
251
|
+
// stderr 모니터링 (에러 감지)
|
|
252
|
+
(async () => {
|
|
253
|
+
const reader = proc.stderr.getReader();
|
|
254
|
+
const decoder = new TextDecoder();
|
|
255
|
+
|
|
256
|
+
while (true) {
|
|
257
|
+
const { done, value } = await reader.read();
|
|
258
|
+
if (done) break;
|
|
259
|
+
|
|
260
|
+
const text = decoder.decode(value).trim();
|
|
261
|
+
if (text) {
|
|
262
|
+
// bash_profile 경고는 무시
|
|
263
|
+
if (text.includes(".bash_profile") || text.includes("$'\\377")) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
console.error(` ❌ CSS Error: ${text}`);
|
|
267
|
+
onError?.(new Error(text));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
})();
|
|
271
|
+
|
|
272
|
+
// 프로세스 종료 감지
|
|
273
|
+
proc.exited.then((code) => {
|
|
274
|
+
if (code !== 0 && code !== null) {
|
|
275
|
+
console.error(` ❌ Tailwind CLI exited with code ${code}`);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
process: proc,
|
|
281
|
+
outputPath,
|
|
282
|
+
serverPath: SERVER_CSS_PATH,
|
|
283
|
+
close: () => {
|
|
284
|
+
proc.kill();
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* CSS 서버 경로 반환
|
|
291
|
+
*/
|
|
292
|
+
export function getCSSServerPath(): string {
|
|
293
|
+
return SERVER_CSS_PATH;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* CSS 링크 태그 생성
|
|
298
|
+
*/
|
|
299
|
+
export function generateCSSLinkTag(isDev: boolean = false): string {
|
|
300
|
+
const cacheBust = isDev ? `?t=${Date.now()}` : "";
|
|
301
|
+
return `<link rel="stylesheet" href="${SERVER_CSS_PATH}${cacheBust}">`;
|
|
302
|
+
}
|
package/src/bundler/dev.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* 개발 모드 번들링 + HMR (Hot Module Replacement)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type { RoutesManifest, RouteSpec } from "../spec/schema";
|
|
7
|
-
import { buildClientBundles } from "./build";
|
|
8
|
-
import type { BundleResult } from "./types";
|
|
9
|
-
import { PORTS, TIMEOUTS } from "../constants";
|
|
10
|
-
import path from "path";
|
|
11
|
-
import fs from "fs";
|
|
6
|
+
import type { RoutesManifest, RouteSpec } from "../spec/schema";
|
|
7
|
+
import { buildClientBundles } from "./build";
|
|
8
|
+
import type { BundleResult } from "./types";
|
|
9
|
+
import { PORTS, TIMEOUTS } from "../constants";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import fs from "fs";
|
|
12
12
|
|
|
13
13
|
export interface DevBundlerOptions {
|
|
14
14
|
/** 프로젝트 루트 */
|
|
@@ -274,7 +274,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
|
|
|
274
274
|
clearTimeout(debounceTimer);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
debounceTimer = setTimeout(() => handleFileChange(fullPath), TIMEOUTS.WATCHER_DEBOUNCE);
|
|
277
|
+
debounceTimer = setTimeout(() => handleFileChange(fullPath), TIMEOUTS.WATCHER_DEBOUNCE);
|
|
278
278
|
});
|
|
279
279
|
|
|
280
280
|
watchers.push(watcher);
|
|
@@ -319,12 +319,15 @@ export interface HMRServer {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
export interface HMRMessage {
|
|
322
|
-
type: "connected" | "reload" | "island-update" | "layout-update" | "error" | "ping";
|
|
322
|
+
type: "connected" | "reload" | "island-update" | "layout-update" | "css-update" | "error" | "ping" | "guard-violation";
|
|
323
323
|
data?: {
|
|
324
324
|
routeId?: string;
|
|
325
325
|
layoutPath?: string;
|
|
326
|
+
cssPath?: string;
|
|
326
327
|
message?: string;
|
|
327
328
|
timestamp?: number;
|
|
329
|
+
file?: string;
|
|
330
|
+
violations?: Array<{ line: number; message: string }>;
|
|
328
331
|
};
|
|
329
332
|
}
|
|
330
333
|
|
|
@@ -333,7 +336,7 @@ export interface HMRMessage {
|
|
|
333
336
|
*/
|
|
334
337
|
export function createHMRServer(port: number): HMRServer {
|
|
335
338
|
const clients = new Set<any>();
|
|
336
|
-
const hmrPort = port + PORTS.HMR_OFFSET;
|
|
339
|
+
const hmrPort = port + PORTS.HMR_OFFSET;
|
|
337
340
|
|
|
338
341
|
const server = Bun.serve({
|
|
339
342
|
port: hmrPort,
|
|
@@ -416,16 +419,16 @@ export function createHMRServer(port: number): HMRServer {
|
|
|
416
419
|
* HMR 클라이언트 스크립트 생성
|
|
417
420
|
* 브라우저에서 실행되어 HMR 서버와 연결
|
|
418
421
|
*/
|
|
419
|
-
export function generateHMRClientScript(port: number): string {
|
|
420
|
-
const hmrPort = port + PORTS.HMR_OFFSET;
|
|
422
|
+
export function generateHMRClientScript(port: number): string {
|
|
423
|
+
const hmrPort = port + PORTS.HMR_OFFSET;
|
|
421
424
|
|
|
422
425
|
return `
|
|
423
426
|
(function() {
|
|
424
|
-
const HMR_PORT = ${hmrPort};
|
|
425
|
-
let ws = null;
|
|
426
|
-
let reconnectAttempts = 0;
|
|
427
|
-
const maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT};
|
|
428
|
-
const reconnectDelay = ${TIMEOUTS.HMR_RECONNECT_DELAY};
|
|
427
|
+
const HMR_PORT = ${hmrPort};
|
|
428
|
+
let ws = null;
|
|
429
|
+
let reconnectAttempts = 0;
|
|
430
|
+
const maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT};
|
|
431
|
+
const reconnectDelay = ${TIMEOUTS.HMR_RECONNECT_DELAY};
|
|
429
432
|
|
|
430
433
|
function connect() {
|
|
431
434
|
try {
|
|
@@ -497,6 +500,21 @@ export function generateHMRClientScript(port: number): string {
|
|
|
497
500
|
location.reload();
|
|
498
501
|
break;
|
|
499
502
|
|
|
503
|
+
case 'css-update':
|
|
504
|
+
console.log('[Mandu HMR] CSS updated');
|
|
505
|
+
// CSS 핫 리로드 (페이지 새로고침 없이 스타일시트만 교체)
|
|
506
|
+
var targetCssPath = message.data?.cssPath || '/.mandu/client/globals.css';
|
|
507
|
+
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
508
|
+
links.forEach(function(link) {
|
|
509
|
+
var href = link.getAttribute('href') || '';
|
|
510
|
+
var baseHref = href.split('?')[0];
|
|
511
|
+
// 정확한 경로 매칭 우선, fallback으로 기존 패턴 매칭
|
|
512
|
+
if (baseHref === targetCssPath || href.includes('globals.css') || href.includes('.mandu/client')) {
|
|
513
|
+
link.setAttribute('href', baseHref + '?t=' + Date.now());
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
break;
|
|
517
|
+
|
|
500
518
|
case 'error':
|
|
501
519
|
console.error('[Mandu HMR] Build error:', message.data?.message);
|
|
502
520
|
showErrorOverlay(message.data?.message);
|
package/src/bundler/index.ts
CHANGED
package/src/runtime/server.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
-
import type { RoutesManifest } from "../spec/schema";
|
|
3
|
-
import type { BundleManifest } from "../bundler/types";
|
|
4
|
-
import type { ManduFilling } from "../filling/filling";
|
|
5
|
-
import { ManduContext } from "../filling/context";
|
|
6
|
-
import { Router } from "./router";
|
|
7
|
-
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
8
|
-
import { PageBoundary, DefaultLoading, DefaultError, type ErrorFallbackProps } from "./boundary";
|
|
9
|
-
import React, { type ReactNode } from "react";
|
|
10
|
-
import path from "path";
|
|
11
|
-
import fs from "fs/promises";
|
|
12
|
-
import { PORTS } from "../constants";
|
|
13
|
-
import {
|
|
14
|
-
createNotFoundResponse,
|
|
15
|
-
createHandlerNotFoundResponse,
|
|
16
|
-
createPageLoadErrorResponse,
|
|
17
|
-
createSSRErrorResponse,
|
|
18
|
-
errorToResponse,
|
|
19
|
-
err,
|
|
20
|
-
ok,
|
|
21
|
-
type Result,
|
|
22
|
-
} from "../error";
|
|
2
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
3
|
+
import type { BundleManifest } from "../bundler/types";
|
|
4
|
+
import type { ManduFilling } from "../filling/filling";
|
|
5
|
+
import { ManduContext } from "../filling/context";
|
|
6
|
+
import { Router } from "./router";
|
|
7
|
+
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
8
|
+
import { PageBoundary, DefaultLoading, DefaultError, type ErrorFallbackProps } from "./boundary";
|
|
9
|
+
import React, { type ReactNode } from "react";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import fs from "fs/promises";
|
|
12
|
+
import { PORTS } from "../constants";
|
|
13
|
+
import {
|
|
14
|
+
createNotFoundResponse,
|
|
15
|
+
createHandlerNotFoundResponse,
|
|
16
|
+
createPageLoadErrorResponse,
|
|
17
|
+
createSSRErrorResponse,
|
|
18
|
+
errorToResponse,
|
|
19
|
+
err,
|
|
20
|
+
ok,
|
|
21
|
+
type Result,
|
|
22
|
+
} from "../error";
|
|
23
23
|
import {
|
|
24
24
|
type CorsOptions,
|
|
25
25
|
isPreflightRequest,
|
|
@@ -106,6 +106,13 @@ export interface ServerOptions {
|
|
|
106
106
|
* - false: 기존 renderToString 사용 (기본값)
|
|
107
107
|
*/
|
|
108
108
|
streaming?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* CSS 파일 경로 (SSR 링크 주입용)
|
|
111
|
+
* - string: 해당 경로로 <link> 주입 (예: "/.mandu/client/globals.css")
|
|
112
|
+
* - false: CSS 링크 주입 비활성화 (Tailwind 미사용 시)
|
|
113
|
+
* - undefined: 자동 감지 (.mandu/client/globals.css 존재 시 주입)
|
|
114
|
+
*/
|
|
115
|
+
cssPath?: string | false;
|
|
109
116
|
/**
|
|
110
117
|
* 커스텀 레지스트리 (핸들러/설정 분리)
|
|
111
118
|
* - 제공하지 않으면 기본 전역 레지스트리 사용
|
|
@@ -195,6 +202,13 @@ export interface ServerRegistrySettings {
|
|
|
195
202
|
publicDir: string;
|
|
196
203
|
cors?: CorsOptions | false;
|
|
197
204
|
streaming: boolean;
|
|
205
|
+
/**
|
|
206
|
+
* CSS 파일 경로 (SSR 링크 주입용)
|
|
207
|
+
* - string: 해당 경로로 <link> 주입
|
|
208
|
+
* - false: CSS 링크 주입 비활성화
|
|
209
|
+
* - undefined: 자동 감지 (.mandu/client/globals.css 존재 시 주입)
|
|
210
|
+
*/
|
|
211
|
+
cssPath?: string | false;
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
export class ServerRegistry {
|
|
@@ -509,34 +523,34 @@ function createDefaultAppFactory(registry: ServerRegistry) {
|
|
|
509
523
|
* 경로가 허용된 디렉토리 내에 있는지 검증
|
|
510
524
|
* Path traversal 공격 방지
|
|
511
525
|
*/
|
|
512
|
-
async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean> {
|
|
513
|
-
try {
|
|
514
|
-
const resolvedPath = path.resolve(filePath);
|
|
515
|
-
const resolvedAllowedDir = path.resolve(allowedDir);
|
|
516
|
-
|
|
517
|
-
if (!resolvedPath.startsWith(resolvedAllowedDir + path.sep) &&
|
|
518
|
-
resolvedPath !== resolvedAllowedDir) {
|
|
519
|
-
return false;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// 파일이 없으면 안전 (존재하지 않는 경로)
|
|
523
|
-
try {
|
|
524
|
-
await fs.access(resolvedPath);
|
|
525
|
-
} catch {
|
|
526
|
-
return true;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Symlink 해결 후 재검증
|
|
530
|
-
const realPath = await fs.realpath(resolvedPath);
|
|
531
|
-
const realAllowedDir = await fs.realpath(resolvedAllowedDir);
|
|
532
|
-
|
|
533
|
-
return realPath.startsWith(realAllowedDir + path.sep) ||
|
|
534
|
-
realPath === realAllowedDir;
|
|
535
|
-
} catch (error) {
|
|
536
|
-
console.warn(`[Mandu Security] Path validation failed: ${filePath}`, error);
|
|
537
|
-
return false;
|
|
538
|
-
}
|
|
539
|
-
}
|
|
526
|
+
async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean> {
|
|
527
|
+
try {
|
|
528
|
+
const resolvedPath = path.resolve(filePath);
|
|
529
|
+
const resolvedAllowedDir = path.resolve(allowedDir);
|
|
530
|
+
|
|
531
|
+
if (!resolvedPath.startsWith(resolvedAllowedDir + path.sep) &&
|
|
532
|
+
resolvedPath !== resolvedAllowedDir) {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 파일이 없으면 안전 (존재하지 않는 경로)
|
|
537
|
+
try {
|
|
538
|
+
await fs.access(resolvedPath);
|
|
539
|
+
} catch {
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Symlink 해결 후 재검증
|
|
544
|
+
const realPath = await fs.realpath(resolvedPath);
|
|
545
|
+
const realAllowedDir = await fs.realpath(resolvedAllowedDir);
|
|
546
|
+
|
|
547
|
+
return realPath.startsWith(realAllowedDir + path.sep) ||
|
|
548
|
+
realPath === realAllowedDir;
|
|
549
|
+
} catch (error) {
|
|
550
|
+
console.warn(`[Mandu Security] Path validation failed: ${filePath}`, error);
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
540
554
|
|
|
541
555
|
/**
|
|
542
556
|
* 정적 파일 서빙
|
|
@@ -546,73 +560,73 @@ async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean
|
|
|
546
560
|
*
|
|
547
561
|
* 보안: Path traversal 공격 방지를 위해 모든 경로를 검증합니다.
|
|
548
562
|
*/
|
|
549
|
-
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings): Promise<Response | null> {
|
|
550
|
-
let filePath: string | null = null;
|
|
551
|
-
let isBundleFile = false;
|
|
552
|
-
let allowedBaseDir: string;
|
|
553
|
-
let relativePath: string;
|
|
554
|
-
|
|
555
|
-
// Path traversal 시도 조기 차단 (정규화 전 raw 체크)
|
|
556
|
-
if (pathname.includes("..")) {
|
|
557
|
-
return null;
|
|
558
|
-
}
|
|
563
|
+
async function serveStaticFile(pathname: string, settings: ServerRegistrySettings): Promise<Response | null> {
|
|
564
|
+
let filePath: string | null = null;
|
|
565
|
+
let isBundleFile = false;
|
|
566
|
+
let allowedBaseDir: string;
|
|
567
|
+
let relativePath: string;
|
|
568
|
+
|
|
569
|
+
// Path traversal 시도 조기 차단 (정규화 전 raw 체크)
|
|
570
|
+
if (pathname.includes("..")) {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
559
573
|
|
|
560
574
|
// 1. 클라이언트 번들 파일 (/.mandu/client/*)
|
|
561
575
|
if (pathname.startsWith("/.mandu/client/")) {
|
|
562
576
|
// pathname에서 prefix 제거 후 안전하게 조합
|
|
563
|
-
relativePath = pathname.slice("/.mandu/client/".length);
|
|
564
|
-
allowedBaseDir = path.join(settings.rootDir, ".mandu", "client");
|
|
565
|
-
isBundleFile = true;
|
|
566
|
-
}
|
|
567
|
-
// 2. Public 폴더 파일 (/public/*)
|
|
568
|
-
else if (pathname.startsWith("/public/")) {
|
|
569
|
-
relativePath = pathname.slice("/public/".length);
|
|
570
|
-
allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
|
|
571
|
-
}
|
|
572
|
-
// 3. Public 폴더의 루트 파일 (favicon.ico, robots.txt 등)
|
|
573
|
-
else if (
|
|
574
|
-
pathname === "/favicon.ico" ||
|
|
575
|
-
pathname === "/robots.txt" ||
|
|
577
|
+
relativePath = pathname.slice("/.mandu/client/".length);
|
|
578
|
+
allowedBaseDir = path.join(settings.rootDir, ".mandu", "client");
|
|
579
|
+
isBundleFile = true;
|
|
580
|
+
}
|
|
581
|
+
// 2. Public 폴더 파일 (/public/*)
|
|
582
|
+
else if (pathname.startsWith("/public/")) {
|
|
583
|
+
relativePath = pathname.slice("/public/".length);
|
|
584
|
+
allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
|
|
585
|
+
}
|
|
586
|
+
// 3. Public 폴더의 루트 파일 (favicon.ico, robots.txt 등)
|
|
587
|
+
else if (
|
|
588
|
+
pathname === "/favicon.ico" ||
|
|
589
|
+
pathname === "/robots.txt" ||
|
|
576
590
|
pathname === "/sitemap.xml" ||
|
|
577
591
|
pathname === "/manifest.json"
|
|
578
|
-
) {
|
|
579
|
-
// 고정된 파일명만 허용 (이미 위에서 정확히 매칭됨)
|
|
580
|
-
relativePath = path.basename(pathname);
|
|
581
|
-
allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
|
|
582
|
-
} else {
|
|
583
|
-
return null; // 정적 파일이 아님
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// URL 디코딩 (실패 시 차단)
|
|
587
|
-
let decodedPath: string;
|
|
588
|
-
try {
|
|
589
|
-
decodedPath = decodeURIComponent(relativePath);
|
|
590
|
-
} catch {
|
|
591
|
-
return null;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// 정규화 + Null byte 방지
|
|
595
|
-
const normalizedPath = path.posix.normalize(decodedPath);
|
|
596
|
-
if (normalizedPath.includes("\0")) {
|
|
597
|
-
console.warn(`[Mandu Security] Null byte attack detected: ${pathname}`);
|
|
598
|
-
return null;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// 선행 슬래시 제거 → path.join이 base를 무시하지 않도록 보장
|
|
602
|
-
const safeRelativePath = normalizedPath.replace(/^\/+/, "");
|
|
603
|
-
|
|
604
|
-
// 상대 경로 탈출 차단
|
|
605
|
-
if (safeRelativePath.startsWith("..")) {
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
filePath = path.join(allowedBaseDir, safeRelativePath);
|
|
610
|
-
|
|
611
|
-
// 최종 경로 검증: 허용된 디렉토리 내에 있는지 확인
|
|
612
|
-
if (!(await isPathSafe(filePath, allowedBaseDir!))) {
|
|
613
|
-
console.warn(`[Mandu Security] Path traversal attempt blocked: ${pathname}`);
|
|
614
|
-
return null;
|
|
615
|
-
}
|
|
592
|
+
) {
|
|
593
|
+
// 고정된 파일명만 허용 (이미 위에서 정확히 매칭됨)
|
|
594
|
+
relativePath = path.basename(pathname);
|
|
595
|
+
allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
|
|
596
|
+
} else {
|
|
597
|
+
return null; // 정적 파일이 아님
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// URL 디코딩 (실패 시 차단)
|
|
601
|
+
let decodedPath: string;
|
|
602
|
+
try {
|
|
603
|
+
decodedPath = decodeURIComponent(relativePath);
|
|
604
|
+
} catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 정규화 + Null byte 방지
|
|
609
|
+
const normalizedPath = path.posix.normalize(decodedPath);
|
|
610
|
+
if (normalizedPath.includes("\0")) {
|
|
611
|
+
console.warn(`[Mandu Security] Null byte attack detected: ${pathname}`);
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 선행 슬래시 제거 → path.join이 base를 무시하지 않도록 보장
|
|
616
|
+
const safeRelativePath = normalizedPath.replace(/^\/+/, "");
|
|
617
|
+
|
|
618
|
+
// 상대 경로 탈출 차단
|
|
619
|
+
if (safeRelativePath.startsWith("..")) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
filePath = path.join(allowedBaseDir, safeRelativePath);
|
|
624
|
+
|
|
625
|
+
// 최종 경로 검증: 허용된 디렉토리 내에 있는지 확인
|
|
626
|
+
if (!(await isPathSafe(filePath, allowedBaseDir!))) {
|
|
627
|
+
console.warn(`[Mandu Security] Path traversal attempt blocked: ${pathname}`);
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
616
630
|
|
|
617
631
|
try {
|
|
618
632
|
const file = Bun.file(filePath);
|
|
@@ -650,64 +664,64 @@ async function serveStaticFile(pathname: string, settings: ServerRegistrySetting
|
|
|
650
664
|
|
|
651
665
|
// ========== Request Handler ==========
|
|
652
666
|
|
|
653
|
-
async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
|
|
654
|
-
const result = await handleRequestInternal(req, router, registry);
|
|
655
|
-
|
|
656
|
-
if (!result.ok) {
|
|
657
|
-
return errorToResponse(result.error, registry.settings.isDev);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
return result.value;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
async function handleRequestInternal(
|
|
664
|
-
req: Request,
|
|
665
|
-
router: Router,
|
|
666
|
-
registry: ServerRegistry
|
|
667
|
-
): Promise<Result<Response>> {
|
|
668
|
-
const url = new URL(req.url);
|
|
669
|
-
const pathname = url.pathname;
|
|
670
|
-
const settings = registry.settings;
|
|
671
|
-
|
|
672
|
-
// 0. CORS Preflight 요청 처리
|
|
673
|
-
if (settings.cors && isPreflightRequest(req)) {
|
|
674
|
-
const corsOptions = settings.cors === true ? {} : settings.cors;
|
|
675
|
-
return ok(handlePreflightRequest(req, corsOptions));
|
|
676
|
-
}
|
|
667
|
+
async function handleRequest(req: Request, router: Router, registry: ServerRegistry): Promise<Response> {
|
|
668
|
+
const result = await handleRequestInternal(req, router, registry);
|
|
669
|
+
|
|
670
|
+
if (!result.ok) {
|
|
671
|
+
return errorToResponse(result.error, registry.settings.isDev);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return result.value;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function handleRequestInternal(
|
|
678
|
+
req: Request,
|
|
679
|
+
router: Router,
|
|
680
|
+
registry: ServerRegistry
|
|
681
|
+
): Promise<Result<Response>> {
|
|
682
|
+
const url = new URL(req.url);
|
|
683
|
+
const pathname = url.pathname;
|
|
684
|
+
const settings = registry.settings;
|
|
685
|
+
|
|
686
|
+
// 0. CORS Preflight 요청 처리
|
|
687
|
+
if (settings.cors && isPreflightRequest(req)) {
|
|
688
|
+
const corsOptions = settings.cors === true ? {} : settings.cors;
|
|
689
|
+
return ok(handlePreflightRequest(req, corsOptions));
|
|
690
|
+
}
|
|
677
691
|
|
|
678
692
|
// 1. 정적 파일 서빙 시도 (최우선)
|
|
679
|
-
const staticResponse = await serveStaticFile(pathname, settings);
|
|
680
|
-
if (staticResponse) {
|
|
681
|
-
// 정적 파일에도 CORS 헤더 적용
|
|
682
|
-
if (settings.cors && isCorsRequest(req)) {
|
|
683
|
-
const corsOptions = settings.cors === true ? {} : settings.cors;
|
|
684
|
-
return ok(applyCorsToResponse(staticResponse, req, corsOptions));
|
|
685
|
-
}
|
|
686
|
-
return ok(staticResponse);
|
|
687
|
-
}
|
|
693
|
+
const staticResponse = await serveStaticFile(pathname, settings);
|
|
694
|
+
if (staticResponse) {
|
|
695
|
+
// 정적 파일에도 CORS 헤더 적용
|
|
696
|
+
if (settings.cors && isCorsRequest(req)) {
|
|
697
|
+
const corsOptions = settings.cors === true ? {} : settings.cors;
|
|
698
|
+
return ok(applyCorsToResponse(staticResponse, req, corsOptions));
|
|
699
|
+
}
|
|
700
|
+
return ok(staticResponse);
|
|
701
|
+
}
|
|
688
702
|
|
|
689
703
|
// 2. 라우트 매칭
|
|
690
704
|
const match = router.match(pathname);
|
|
691
705
|
|
|
692
|
-
if (!match) {
|
|
693
|
-
return err(createNotFoundResponse(pathname));
|
|
694
|
-
}
|
|
706
|
+
if (!match) {
|
|
707
|
+
return err(createNotFoundResponse(pathname));
|
|
708
|
+
}
|
|
695
709
|
|
|
696
710
|
const { route, params } = match;
|
|
697
711
|
|
|
698
|
-
if (route.kind === "api") {
|
|
699
|
-
const handler = registry.apiHandlers.get(route.id);
|
|
700
|
-
if (!handler) {
|
|
701
|
-
return err(createHandlerNotFoundResponse(route.id, route.pattern));
|
|
702
|
-
}
|
|
703
|
-
try {
|
|
704
|
-
const response = await handler(req, params);
|
|
705
|
-
return ok(response);
|
|
706
|
-
} catch (errValue) {
|
|
707
|
-
const error = errValue instanceof Error ? errValue : new Error(String(errValue));
|
|
708
|
-
return err(createSSRErrorResponse(route.id, route.pattern, error));
|
|
709
|
-
}
|
|
710
|
-
}
|
|
712
|
+
if (route.kind === "api") {
|
|
713
|
+
const handler = registry.apiHandlers.get(route.id);
|
|
714
|
+
if (!handler) {
|
|
715
|
+
return err(createHandlerNotFoundResponse(route.id, route.pattern));
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
const response = await handler(req, params);
|
|
719
|
+
return ok(response);
|
|
720
|
+
} catch (errValue) {
|
|
721
|
+
const error = errValue instanceof Error ? errValue : new Error(String(errValue));
|
|
722
|
+
return err(createSSRErrorResponse(route.id, route.pattern, error));
|
|
723
|
+
}
|
|
724
|
+
}
|
|
711
725
|
|
|
712
726
|
if (route.kind === "page") {
|
|
713
727
|
let loaderData: unknown;
|
|
@@ -718,27 +732,27 @@ async function handleRequestInternal(
|
|
|
718
732
|
|
|
719
733
|
// 1. PageHandler 방식 (신규 - filling 포함)
|
|
720
734
|
const pageHandler = registry.pageHandlers.get(route.id);
|
|
721
|
-
if (pageHandler) {
|
|
722
|
-
try {
|
|
723
|
-
const registration = await pageHandler();
|
|
724
|
-
component = registration.component as RouteComponent;
|
|
725
|
-
registry.registerRouteComponent(route.id, component);
|
|
735
|
+
if (pageHandler) {
|
|
736
|
+
try {
|
|
737
|
+
const registration = await pageHandler();
|
|
738
|
+
component = registration.component as RouteComponent;
|
|
739
|
+
registry.registerRouteComponent(route.id, component);
|
|
726
740
|
|
|
727
741
|
// Filling의 loader 실행
|
|
728
742
|
if (registration.filling?.hasLoader()) {
|
|
729
743
|
const ctx = new ManduContext(req, params);
|
|
730
744
|
loaderData = await registration.filling.executeLoader(ctx);
|
|
731
745
|
}
|
|
732
|
-
} catch (error) {
|
|
733
|
-
const pageError = createPageLoadErrorResponse(
|
|
734
|
-
route.id,
|
|
735
|
-
route.pattern,
|
|
736
|
-
error instanceof Error ? error : new Error(String(error))
|
|
737
|
-
);
|
|
738
|
-
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
739
|
-
return err(pageError);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
746
|
+
} catch (error) {
|
|
747
|
+
const pageError = createPageLoadErrorResponse(
|
|
748
|
+
route.id,
|
|
749
|
+
route.pattern,
|
|
750
|
+
error instanceof Error ? error : new Error(String(error))
|
|
751
|
+
);
|
|
752
|
+
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
753
|
+
return err(pageError);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
742
756
|
// 2. PageLoader 방식 (레거시 호환)
|
|
743
757
|
else {
|
|
744
758
|
const loader = registry.pageLoaders.get(route.id);
|
|
@@ -758,28 +772,28 @@ async function handleRequestInternal(
|
|
|
758
772
|
const ctx = new ManduContext(req, params);
|
|
759
773
|
loaderData = await filling.executeLoader(ctx);
|
|
760
774
|
}
|
|
761
|
-
} catch (error) {
|
|
762
|
-
const pageError = createPageLoadErrorResponse(
|
|
763
|
-
route.id,
|
|
764
|
-
route.pattern,
|
|
765
|
-
error instanceof Error ? error : new Error(String(error))
|
|
766
|
-
);
|
|
767
|
-
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
768
|
-
return err(pageError);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
}
|
|
775
|
+
} catch (error) {
|
|
776
|
+
const pageError = createPageLoadErrorResponse(
|
|
777
|
+
route.id,
|
|
778
|
+
route.pattern,
|
|
779
|
+
error instanceof Error ? error : new Error(String(error))
|
|
780
|
+
);
|
|
781
|
+
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
782
|
+
return err(pageError);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
772
786
|
|
|
773
787
|
// Client-side Routing: 데이터만 반환 (JSON)
|
|
774
|
-
if (isDataRequest) {
|
|
775
|
-
return ok(Response.json({
|
|
776
|
-
routeId: route.id,
|
|
777
|
-
pattern: route.pattern,
|
|
778
|
-
params,
|
|
779
|
-
loaderData: loaderData ?? null,
|
|
780
|
-
timestamp: Date.now(),
|
|
781
|
-
}));
|
|
782
|
-
}
|
|
788
|
+
if (isDataRequest) {
|
|
789
|
+
return ok(Response.json({
|
|
790
|
+
routeId: route.id,
|
|
791
|
+
pattern: route.pattern,
|
|
792
|
+
params,
|
|
793
|
+
loaderData: loaderData ?? null,
|
|
794
|
+
timestamp: Date.now(),
|
|
795
|
+
}));
|
|
796
|
+
}
|
|
783
797
|
|
|
784
798
|
// SSR 렌더링
|
|
785
799
|
const defaultAppCreator = createDefaultAppFactory(registry);
|
|
@@ -808,17 +822,18 @@ async function handleRequestInternal(
|
|
|
808
822
|
? route.streaming
|
|
809
823
|
: settings.streaming;
|
|
810
824
|
|
|
811
|
-
if (useStreaming) {
|
|
812
|
-
return ok(await renderStreamingResponse(app, {
|
|
813
|
-
title: `${route.id} - Mandu`,
|
|
814
|
-
isDev: settings.isDev,
|
|
815
|
-
hmrPort: settings.hmrPort,
|
|
816
|
-
routeId: route.id,
|
|
825
|
+
if (useStreaming) {
|
|
826
|
+
return ok(await renderStreamingResponse(app, {
|
|
827
|
+
title: `${route.id} - Mandu`,
|
|
828
|
+
isDev: settings.isDev,
|
|
829
|
+
hmrPort: settings.hmrPort,
|
|
830
|
+
routeId: route.id,
|
|
817
831
|
routePattern: route.pattern,
|
|
818
832
|
hydration: route.hydration,
|
|
819
833
|
bundleManifest: settings.bundleManifest,
|
|
820
834
|
criticalData: loaderData as Record<string, unknown> | undefined,
|
|
821
835
|
enableClientRouter: true,
|
|
836
|
+
cssPath: settings.cssPath,
|
|
822
837
|
onShellReady: () => {
|
|
823
838
|
if (settings.isDev) {
|
|
824
839
|
console.log(`[Mandu Streaming] Shell ready: ${route.id}`);
|
|
@@ -831,80 +846,134 @@ async function handleRequestInternal(
|
|
|
831
846
|
allReadyTime: `${metrics.allReadyTime}ms`,
|
|
832
847
|
hasError: metrics.hasError,
|
|
833
848
|
});
|
|
834
|
-
}
|
|
835
|
-
},
|
|
836
|
-
}));
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// 기존 renderToString 방식
|
|
840
|
-
return ok(renderSSR(app, {
|
|
841
|
-
title: `${route.id} - Mandu`,
|
|
842
|
-
isDev: settings.isDev,
|
|
843
|
-
hmrPort: settings.hmrPort,
|
|
844
|
-
routeId: route.id,
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
}));
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// 기존 renderToString 방식
|
|
855
|
+
return ok(renderSSR(app, {
|
|
856
|
+
title: `${route.id} - Mandu`,
|
|
857
|
+
isDev: settings.isDev,
|
|
858
|
+
hmrPort: settings.hmrPort,
|
|
859
|
+
routeId: route.id,
|
|
845
860
|
hydration: route.hydration,
|
|
846
861
|
bundleManifest: settings.bundleManifest,
|
|
847
862
|
serverData,
|
|
848
|
-
// Client-side Routing 활성화 정보 전달
|
|
849
|
-
enableClientRouter: true,
|
|
850
|
-
routePattern: route.pattern,
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
route.
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
}
|
|
863
|
+
// Client-side Routing 활성화 정보 전달
|
|
864
|
+
enableClientRouter: true,
|
|
865
|
+
routePattern: route.pattern,
|
|
866
|
+
cssPath: settings.cssPath,
|
|
867
|
+
}));
|
|
868
|
+
} catch (error) {
|
|
869
|
+
const ssrError = createSSRErrorResponse(
|
|
870
|
+
route.id,
|
|
871
|
+
route.pattern,
|
|
872
|
+
error instanceof Error ? error : new Error(String(error))
|
|
873
|
+
);
|
|
874
|
+
console.error(`[Mandu] ${ssrError.errorType}:`, ssrError.message);
|
|
875
|
+
return err(ssrError);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return err({
|
|
880
|
+
errorType: "FRAMEWORK_BUG",
|
|
881
|
+
code: "MANDU_F003",
|
|
882
|
+
httpStatus: 500,
|
|
883
|
+
message: `Unknown route kind: ${route.kind}`,
|
|
884
|
+
summary: "알 수 없는 라우트 종류 - 프레임워크 버그",
|
|
885
|
+
fix: {
|
|
886
|
+
file: "spec/routes.manifest.json",
|
|
887
|
+
suggestion: "라우트의 kind는 'api' 또는 'page'여야 합니다",
|
|
888
|
+
},
|
|
889
|
+
route: {
|
|
890
|
+
id: route.id,
|
|
891
|
+
pattern: route.pattern,
|
|
892
|
+
},
|
|
893
|
+
timestamp: new Date().toISOString(),
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// ========== Port Selection ==========
|
|
898
|
+
|
|
899
|
+
const MAX_PORT_ATTEMPTS = 10;
|
|
900
|
+
|
|
901
|
+
function isPortInUseError(error: unknown): boolean {
|
|
902
|
+
if (!error || typeof error !== "object") return false;
|
|
903
|
+
const code = (error as { code?: string }).code;
|
|
904
|
+
const message = (error as { message?: string }).message ?? "";
|
|
905
|
+
return code === "EADDRINUSE" || message.includes("EADDRINUSE") || message.includes("address already in use");
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function startBunServerWithFallback(options: {
|
|
909
|
+
port: number;
|
|
910
|
+
hostname?: string;
|
|
911
|
+
fetch: (req: Request) => Promise<Response>;
|
|
912
|
+
}): { server: Server; port: number; attempts: number } {
|
|
913
|
+
const { port: startPort, hostname, fetch } = options;
|
|
914
|
+
let lastError: unknown = null;
|
|
915
|
+
|
|
916
|
+
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
|
|
917
|
+
const candidate = startPort + attempt;
|
|
918
|
+
if (candidate < 1 || candidate > 65535) {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
const server = Bun.serve({
|
|
923
|
+
port: candidate,
|
|
924
|
+
hostname,
|
|
925
|
+
fetch,
|
|
926
|
+
});
|
|
927
|
+
return { server, port: server.port ?? candidate, attempts: attempt };
|
|
928
|
+
} catch (error) {
|
|
929
|
+
if (!isPortInUseError(error)) {
|
|
930
|
+
throw error;
|
|
931
|
+
}
|
|
932
|
+
lastError = error;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
throw lastError ?? new Error(`No available port found starting at ${startPort}`);
|
|
937
|
+
}
|
|
880
938
|
|
|
881
939
|
// ========== Server Startup ==========
|
|
882
940
|
|
|
883
941
|
export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
|
|
884
|
-
const {
|
|
885
|
-
port = 3000,
|
|
886
|
-
hostname = "localhost",
|
|
887
|
-
rootDir = process.cwd(),
|
|
888
|
-
isDev = false,
|
|
942
|
+
const {
|
|
943
|
+
port = 3000,
|
|
944
|
+
hostname = "localhost",
|
|
945
|
+
rootDir = process.cwd(),
|
|
946
|
+
isDev = false,
|
|
889
947
|
hmrPort,
|
|
890
948
|
bundleManifest,
|
|
891
949
|
publicDir = "public",
|
|
892
950
|
cors = false,
|
|
893
951
|
streaming = false,
|
|
952
|
+
cssPath: cssPathOption,
|
|
894
953
|
registry = defaultRegistry,
|
|
895
954
|
} = options;
|
|
896
955
|
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
//
|
|
956
|
+
// cssPath 처리:
|
|
957
|
+
// - string: 해당 경로로 <link> 주입
|
|
958
|
+
// - false: CSS 링크 주입 비활성화
|
|
959
|
+
// - undefined: false로 처리 (기본적으로 링크 미삽입 - 404 방지)
|
|
960
|
+
//
|
|
961
|
+
// dev/build에서 Tailwind 감지 시 명시적으로 cssPath 전달 필요:
|
|
962
|
+
// - dev.ts: cssPath: hasTailwind ? cssWatcher?.serverPath : false
|
|
963
|
+
// - 프로덕션: 빌드 후 .mandu/client/globals.css 존재 시 경로 전달
|
|
964
|
+
const cssPath: string | false = cssPathOption ?? false;
|
|
965
|
+
|
|
966
|
+
// CORS 옵션 파싱
|
|
967
|
+
const corsOptions: CorsOptions | false = cors === true ? {} : cors;
|
|
968
|
+
|
|
969
|
+
if (!isDev && cors === true) {
|
|
970
|
+
console.warn("⚠️ [Security Warning] CORS is set to allow all origins.");
|
|
971
|
+
console.warn(" This is not recommended for production environments.");
|
|
972
|
+
console.warn(" Consider specifying allowed origins explicitly:");
|
|
973
|
+
console.warn(" cors: { origin: ['https://yourdomain.com'] }");
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Registry settings 저장 (초기값)
|
|
908
977
|
registry.settings = {
|
|
909
978
|
isDev,
|
|
910
979
|
hmrPort,
|
|
@@ -913,6 +982,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
913
982
|
publicDir,
|
|
914
983
|
cors: corsOptions,
|
|
915
984
|
streaming,
|
|
985
|
+
cssPath,
|
|
916
986
|
};
|
|
917
987
|
|
|
918
988
|
const router = new Router(manifest.routes);
|
|
@@ -929,17 +999,25 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
929
999
|
return response;
|
|
930
1000
|
};
|
|
931
1001
|
|
|
932
|
-
const server =
|
|
1002
|
+
const { server, port: actualPort, attempts } = startBunServerWithFallback({
|
|
933
1003
|
port,
|
|
934
1004
|
hostname,
|
|
935
1005
|
fetch: fetchHandler,
|
|
936
1006
|
});
|
|
937
1007
|
|
|
1008
|
+
if (attempts > 0) {
|
|
1009
|
+
console.warn(`⚠️ Port ${port} is in use. Using ${actualPort} instead.`);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (hmrPort !== undefined && hmrPort === port && actualPort !== port) {
|
|
1013
|
+
registry.settings = { ...registry.settings, hmrPort: actualPort };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
938
1016
|
if (isDev) {
|
|
939
|
-
console.log(`🥟 Mandu Dev Server running at http://${hostname}:${
|
|
940
|
-
if (hmrPort) {
|
|
941
|
-
console.log(`🔥 HMR enabled on port ${hmrPort + PORTS.HMR_OFFSET}`);
|
|
942
|
-
}
|
|
1017
|
+
console.log(`🥟 Mandu Dev Server running at http://${hostname}:${actualPort}`);
|
|
1018
|
+
if (registry.settings.hmrPort) {
|
|
1019
|
+
console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
|
|
1020
|
+
}
|
|
943
1021
|
console.log(`📂 Static files: /${publicDir}/, /.mandu/client/`);
|
|
944
1022
|
if (corsOptions) {
|
|
945
1023
|
console.log(`🌐 CORS enabled`);
|
|
@@ -948,7 +1026,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
948
1026
|
console.log(`🌊 Streaming SSR enabled`);
|
|
949
1027
|
}
|
|
950
1028
|
} else {
|
|
951
|
-
console.log(`🥟 Mandu server running at http://${hostname}:${
|
|
1029
|
+
console.log(`🥟 Mandu server running at http://${hostname}:${actualPort}`);
|
|
952
1030
|
if (streaming) {
|
|
953
1031
|
console.log(`🌊 Streaming SSR enabled`);
|
|
954
1032
|
}
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface SSROptions {
|
|
|
43
43
|
enableClientRouter?: boolean;
|
|
44
44
|
/** 라우트 패턴 (Client-side Routing용) */
|
|
45
45
|
routePattern?: string;
|
|
46
|
+
/** CSS 파일 경로 (자동 주입, 기본: /.mandu/client/globals.css) */
|
|
47
|
+
cssPath?: string | false;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/**
|
|
@@ -151,8 +153,16 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
151
153
|
hmrPort,
|
|
152
154
|
enableClientRouter = false,
|
|
153
155
|
routePattern,
|
|
156
|
+
cssPath,
|
|
154
157
|
} = options;
|
|
155
158
|
|
|
159
|
+
// CSS 링크 태그 생성
|
|
160
|
+
// - cssPath가 string이면 해당 경로 사용
|
|
161
|
+
// - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
|
|
162
|
+
const cssLinkTag = cssPath && cssPath !== false
|
|
163
|
+
? `<link rel="stylesheet" href="${cssPath}${isDev ? `?t=${Date.now()}` : ""}">`
|
|
164
|
+
: "";
|
|
165
|
+
|
|
156
166
|
let content = renderToString(element);
|
|
157
167
|
|
|
158
168
|
// Island 래퍼 적용 (hydration 필요 시)
|
|
@@ -208,6 +218,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
208
218
|
<meta charset="UTF-8">
|
|
209
219
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
210
220
|
<title>${title}</title>
|
|
221
|
+
${cssLinkTag}
|
|
211
222
|
${headTags}
|
|
212
223
|
</head>
|
|
213
224
|
<body>
|
|
@@ -138,6 +138,8 @@ export interface StreamingSSROptions {
|
|
|
138
138
|
* true이면 </body></html>을 생략하여 deferred 스크립트 삽입 지점 확보
|
|
139
139
|
*/
|
|
140
140
|
_skipHtmlClose?: boolean;
|
|
141
|
+
/** CSS 파일 경로 (자동 주입, 기본: /.mandu/client/globals.css) */
|
|
142
|
+
cssPath?: string | false;
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
export interface StreamingLoaderResult<T = unknown> {
|
|
@@ -367,8 +369,17 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
367
369
|
bundleManifest,
|
|
368
370
|
routeId,
|
|
369
371
|
hydration,
|
|
372
|
+
cssPath,
|
|
373
|
+
isDev = false,
|
|
370
374
|
} = options;
|
|
371
375
|
|
|
376
|
+
// CSS 링크 태그 생성
|
|
377
|
+
// - cssPath가 string이면 해당 경로 사용
|
|
378
|
+
// - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
|
|
379
|
+
const cssLinkTag = cssPath && cssPath !== false
|
|
380
|
+
? `<link rel="stylesheet" href="${cssPath}${isDev ? `?t=${Date.now()}` : ""}">`
|
|
381
|
+
: "";
|
|
382
|
+
|
|
372
383
|
// Import map (module scripts 전에 위치해야 함)
|
|
373
384
|
let importMapScript = "";
|
|
374
385
|
if (bundleManifest?.importMap && Object.keys(bundleManifest.importMap.imports).length > 0) {
|
|
@@ -414,6 +425,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
414
425
|
<meta charset="UTF-8">
|
|
415
426
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
416
427
|
<title>${title}</title>
|
|
428
|
+
${cssLinkTag}
|
|
417
429
|
${loadingStyles}
|
|
418
430
|
${importMapScript}
|
|
419
431
|
${headTags}
|