@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 CHANGED
@@ -215,7 +215,7 @@ import type {
215
215
  ## 요구 사항
216
216
 
217
217
  - Bun >= 1.0.0
218
- - React >= 18.0.0
218
+ - React >= 19.0.0
219
219
  - Zod >= 3.0.0
220
220
 
221
221
  ## 관련 패키지
package/README.md CHANGED
@@ -490,7 +490,7 @@ import type {
490
490
  ## Requirements
491
491
 
492
492
  - Bun >= 1.0.0
493
- - React >= 18.0.0
493
+ - React >= 19.0.0
494
494
  - Zod >= 3.0.0
495
495
 
496
496
  ## Related Packages
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.9.42",
3
+ "version": "0.9.44",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
+ }
@@ -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);
@@ -6,3 +6,4 @@
6
6
  export * from "./types";
7
7
  export * from "./build";
8
8
  export * from "./dev";
9
+ export * from "./css";
@@ -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
- } catch (error) {
853
- const ssrError = createSSRErrorResponse(
854
- route.id,
855
- route.pattern,
856
- error instanceof Error ? error : new Error(String(error))
857
- );
858
- console.error(`[Mandu] ${ssrError.errorType}:`, ssrError.message);
859
- return err(ssrError);
860
- }
861
- }
862
-
863
- return err({
864
- errorType: "FRAMEWORK_BUG",
865
- code: "MANDU_F003",
866
- httpStatus: 500,
867
- message: `Unknown route kind: ${route.kind}`,
868
- summary: "알 없는 라우트 종류 - 프레임워크 버그",
869
- fix: {
870
- file: "spec/routes.manifest.json",
871
- suggestion: "라우트의 kind는 'api' 또는 'page'여야 합니다",
872
- },
873
- route: {
874
- id: route.id,
875
- pattern: route.pattern,
876
- },
877
- timestamp: new Date().toISOString(),
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
- // CORS 옵션 파싱
898
- const corsOptions: CorsOptions | false = cors === true ? {} : cors;
899
-
900
- if (!isDev && cors === true) {
901
- console.warn("⚠️ [Security Warning] CORS is set to allow all origins.");
902
- console.warn(" This is not recommended for production environments.");
903
- console.warn(" Consider specifying allowed origins explicitly:");
904
- console.warn(" cors: { origin: ['https://yourdomain.com'] }");
905
- }
906
-
907
- // Registry settings 저장
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 = Bun.serve({
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}:${port}`);
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}:${port}`);
1029
+ console.log(`🥟 Mandu server running at http://${hostname}:${actualPort}`);
952
1030
  if (streaming) {
953
1031
  console.log(`🌊 Streaming SSR enabled`);
954
1032
  }
@@ -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}