@mandujs/core 0.12.2 → 0.13.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.
@@ -1,81 +1,81 @@
1
- /**
2
- * FS Routes Module
3
- *
4
- * 파일 시스템 기반 라우팅 시스템
5
- *
6
- * @module router
7
- *
8
- * @example
9
- * ```typescript
10
- * import { scanRoutes, generateManifest, watchFSRoutes } from "@mandujs/core/router";
11
- *
12
- * // 간편 스캔
13
- * const result = await scanRoutes("/path/to/project");
14
- * console.log(result.routes);
15
- *
16
- * // 매니페스트 생성
17
- * const { manifest } = await generateManifest("/path/to/project", {
18
- * outputPath: ".mandu/manifest.json"
19
- * });
20
- *
21
- * // 감시 모드
22
- * const watcher = await watchFSRoutes("/path/to/project", {
23
- * onChange: (result) => console.log("Routes updated!")
24
- * });
25
- * ```
26
- */
27
-
28
- // Types
29
- export type {
30
- // Segment types
31
- SegmentType,
32
- RouteSegment,
33
-
34
- // File types
35
- ScannedFileType,
36
- ScannedFile,
37
-
38
- // Route config
39
- FSRouteConfig,
40
-
41
- // Scanner config
42
- FSScannerConfig,
43
-
44
- // Results
45
- ScanResult,
46
- ScanError,
47
- ScanStats,
48
- } from "./fs-types";
49
-
50
- export { DEFAULT_SCANNER_CONFIG, FILE_PATTERNS, SEGMENT_PATTERNS } from "./fs-types";
51
-
52
- // Pattern utilities
53
- export {
54
- parseSegment,
55
- parseSegments,
56
- segmentsToPattern,
57
- pathToPattern,
58
- detectFileType,
59
- isPrivateFolder,
60
- isGroupFolder,
61
- generateRouteId,
62
- calculateRoutePriority,
63
- sortRoutesByPriority,
64
- validateSegments,
65
- patternsConflict,
66
- } from "./fs-patterns";
67
-
68
- // Scanner
69
- export { FSScanner, createFSScanner, scanRoutes } from "./fs-scanner";
70
-
71
- // Generator
72
- export type { GenerateResult, GenerateOptions, RouteChangeCallback, FSRoutesWatcher } from "./fs-routes";
73
-
74
- export {
75
- fsRouteToRouteSpec,
76
- scanResultToManifest,
77
- mergeManifests,
78
- generateManifest,
79
- watchFSRoutes,
80
- formatRoutesForCLI,
81
- } from "./fs-routes";
1
+ /**
2
+ * FS Routes Module
3
+ *
4
+ * 파일 시스템 기반 라우팅 시스템
5
+ *
6
+ * @module router
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { scanRoutes, generateManifest, watchFSRoutes } from "@mandujs/core/router";
11
+ *
12
+ * // 간편 스캔
13
+ * const result = await scanRoutes("/path/to/project");
14
+ * console.log(result.routes);
15
+ *
16
+ * // 매니페스트 생성
17
+ * const { manifest } = await generateManifest("/path/to/project", {
18
+ * outputPath: ".mandu/manifest.json"
19
+ * });
20
+ *
21
+ * // 감시 모드
22
+ * const watcher = await watchFSRoutes("/path/to/project", {
23
+ * onChange: (result) => console.log("Routes updated!")
24
+ * });
25
+ * ```
26
+ */
27
+
28
+ // Types
29
+ export type {
30
+ // Segment types
31
+ SegmentType,
32
+ RouteSegment,
33
+
34
+ // File types
35
+ ScannedFileType,
36
+ ScannedFile,
37
+
38
+ // Route config
39
+ FSRouteConfig,
40
+
41
+ // Scanner config
42
+ FSScannerConfig,
43
+
44
+ // Results
45
+ ScanResult,
46
+ ScanError,
47
+ ScanStats,
48
+ } from "./fs-types";
49
+
50
+ export { DEFAULT_SCANNER_CONFIG, FILE_PATTERNS, SEGMENT_PATTERNS } from "./fs-types";
51
+
52
+ // Pattern utilities
53
+ export {
54
+ parseSegment,
55
+ parseSegments,
56
+ segmentsToPattern,
57
+ pathToPattern,
58
+ detectFileType,
59
+ isPrivateFolder,
60
+ isGroupFolder,
61
+ generateRouteId,
62
+ calculateRoutePriority,
63
+ sortRoutesByPriority,
64
+ validateSegments,
65
+ patternsConflict,
66
+ } from "./fs-patterns";
67
+
68
+ // Scanner
69
+ export { FSScanner, createFSScanner, scanRoutes } from "./fs-scanner";
70
+
71
+ // Generator
72
+ export type { GenerateResult, GenerateOptions, RouteChangeCallback, FSRoutesWatcher } from "./fs-routes";
73
+
74
+ export {
75
+ fsRouteToRouteSpec,
76
+ scanResultToManifest,
77
+ resolveAutoLinks,
78
+ generateManifest,
79
+ watchFSRoutes,
80
+ formatRoutesForCLI,
81
+ } from "./fs-routes";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * HTML 속성값 이스케이프
3
+ * XSS 방지를 위해 HTML 속성값에 들어갈 문자열을 안전하게 처리
4
+ */
5
+ export function escapeHtmlAttr(value: string): string {
6
+ return value
7
+ .replace(/&/g, "&")
8
+ .replace(/</g, "&lt;")
9
+ .replace(/>/g, "&gt;")
10
+ .replace(/"/g, "&quot;")
11
+ .replace(/'/g, "&#39;");
12
+ }
13
+
14
+ /**
15
+ * Inline script의 JSON 데이터 이스케이프
16
+ * <script> 태그 내부의 JSON을 안전하게 처리
17
+ */
18
+ export function escapeJsonForInlineScript(value: string): string {
19
+ return value
20
+ .replace(/</g, "\\u003c")
21
+ .replace(/>/g, "\\u003e")
22
+ .replace(/&/g, "\\u0026")
23
+ .replace(/'/g, "\\u0027")
24
+ .replace(/\u2028/g, "\\u2028")
25
+ .replace(/\u2029/g, "\\u2029");
26
+ }
27
+
28
+ /**
29
+ * JavaScript 문자열 리터럴 이스케이프
30
+ * JS 코드 내부의 문자열 보간에 사용
31
+ */
32
+ export function escapeJsString(value: string): string {
33
+ return value
34
+ .replace(/\\/g, "\\\\")
35
+ .replace(/\n/g, "\\n")
36
+ .replace(/\r/g, "\\r")
37
+ .replace(/"/g, "\\u0022")
38
+ .replace(/'/g, "\\u0027")
39
+ .replace(/</g, "\\u003c")
40
+ .replace(/>/g, "\\u003e")
41
+ .replace(/&/g, "\\u0026")
42
+ .replace(/\u2028/g, "\\u2028")
43
+ .replace(/\u2029/g, "\\u2029");
44
+ }
@@ -29,6 +29,183 @@ import {
29
29
  } from "./cors";
30
30
  import { validateImportPath } from "./security";
31
31
 
32
+ export interface RateLimitOptions {
33
+ windowMs?: number;
34
+ max?: number;
35
+ message?: string;
36
+ statusCode?: number;
37
+ headers?: boolean;
38
+ /**
39
+ * Reverse proxy 헤더를 신뢰할지 여부
40
+ * - false(기본): X-Forwarded-For 등을 읽지만 spoofing 가능성을 표시
41
+ * - true: 전달된 클라이언트 IP를 완전히 신뢰
42
+ * 주의: trustProxy: false여도 클라이언트 구분을 위해 헤더를 사용하므로
43
+ * IP spoofing이 가능합니다. 신뢰할 수 있는 프록시 뒤에서만 사용하세요.
44
+ */
45
+ trustProxy?: boolean;
46
+ /**
47
+ * 메모리 보호를 위한 최대 key 수
48
+ * - 초과 시 오래된 key부터 제거
49
+ */
50
+ maxKeys?: number;
51
+ }
52
+
53
+ interface NormalizedRateLimitOptions {
54
+ windowMs: number;
55
+ max: number;
56
+ message: string;
57
+ statusCode: number;
58
+ headers: boolean;
59
+ trustProxy: boolean;
60
+ maxKeys: number;
61
+ }
62
+
63
+ interface RateLimitDecision {
64
+ allowed: boolean;
65
+ limit: number;
66
+ remaining: number;
67
+ resetAt: number;
68
+ }
69
+
70
+ class MemoryRateLimiter {
71
+ private readonly store = new Map<string, { count: number; resetAt: number }>();
72
+ private lastCleanupAt = 0;
73
+
74
+ consume(req: Request, routeId: string, options: NormalizedRateLimitOptions): RateLimitDecision {
75
+ const now = Date.now();
76
+ this.maybeCleanup(now, options);
77
+
78
+ const key = `${this.getClientKey(req, options)}:${routeId}`;
79
+ const current = this.store.get(key);
80
+
81
+ if (!current || current.resetAt <= now) {
82
+ const resetAt = now + options.windowMs;
83
+ this.store.set(key, { count: 1, resetAt });
84
+ this.enforceMaxKeys(options.maxKeys);
85
+ return { allowed: true, limit: options.max, remaining: Math.max(0, options.max - 1), resetAt };
86
+ }
87
+
88
+ current.count += 1;
89
+ this.store.set(key, current);
90
+
91
+ return {
92
+ allowed: current.count <= options.max,
93
+ limit: options.max,
94
+ remaining: Math.max(0, options.max - current.count),
95
+ resetAt: current.resetAt,
96
+ };
97
+ }
98
+
99
+ private maybeCleanup(now: number, options: NormalizedRateLimitOptions): void {
100
+ if (now - this.lastCleanupAt < Math.max(1_000, options.windowMs)) {
101
+ return;
102
+ }
103
+
104
+ this.lastCleanupAt = now;
105
+ for (const [key, entry] of this.store.entries()) {
106
+ if (entry.resetAt <= now) {
107
+ this.store.delete(key);
108
+ }
109
+ }
110
+ }
111
+
112
+ private enforceMaxKeys(maxKeys: number): void {
113
+ while (this.store.size > maxKeys) {
114
+ const oldestKey = this.store.keys().next().value;
115
+ if (!oldestKey) break;
116
+ this.store.delete(oldestKey);
117
+ }
118
+ }
119
+
120
+ private getClientKey(req: Request, options: NormalizedRateLimitOptions): string {
121
+ const candidates = [
122
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
123
+ req.headers.get("x-real-ip")?.trim(),
124
+ req.headers.get("cf-connecting-ip")?.trim(),
125
+ req.headers.get("true-client-ip")?.trim(),
126
+ req.headers.get("fly-client-ip")?.trim(),
127
+ ];
128
+
129
+ for (const candidate of candidates) {
130
+ if (candidate) {
131
+ const sanitized = candidate.slice(0, 64);
132
+ // trustProxy: false면 경고를 위해 prefix 추가 (spoofing 가능)
133
+ return options.trustProxy ? sanitized : `unverified:${sanitized}`;
134
+ }
135
+ }
136
+
137
+ // 헤더가 전혀 없는 경우만 fallback (로컬 개발 환경)
138
+ return "default";
139
+ }
140
+ }
141
+
142
+ function normalizeRateLimitOptions(options: boolean | RateLimitOptions | undefined): NormalizedRateLimitOptions | false {
143
+ if (!options) return false;
144
+ if (options === true) {
145
+ return {
146
+ windowMs: 60_000,
147
+ max: 100,
148
+ message: "Too Many Requests",
149
+ statusCode: 429,
150
+ headers: true,
151
+ trustProxy: false,
152
+ maxKeys: 10_000,
153
+ };
154
+ }
155
+
156
+ const windowMs = Number.isFinite(options.windowMs) ? Math.max(1_000, options.windowMs!) : 60_000;
157
+ const max = Number.isFinite(options.max) ? Math.max(1, Math.floor(options.max!)) : 100;
158
+ const statusCode = Number.isFinite(options.statusCode)
159
+ ? Math.min(599, Math.max(400, Math.floor(options.statusCode!)))
160
+ : 429;
161
+ const maxKeys = Number.isFinite(options.maxKeys)
162
+ ? Math.max(100, Math.floor(options.maxKeys!))
163
+ : 10_000;
164
+
165
+ return {
166
+ windowMs,
167
+ max,
168
+ message: options.message ?? "Too Many Requests",
169
+ statusCode,
170
+ headers: options.headers ?? true,
171
+ trustProxy: options.trustProxy ?? false,
172
+ maxKeys,
173
+ };
174
+ }
175
+
176
+ function appendRateLimitHeaders(response: Response, decision: RateLimitDecision, options: NormalizedRateLimitOptions): Response {
177
+ if (!options.headers) return response;
178
+
179
+ const headers = new Headers(response.headers);
180
+ const retryAfterSec = Math.max(1, Math.ceil((decision.resetAt - Date.now()) / 1000));
181
+
182
+ headers.set("X-RateLimit-Limit", String(decision.limit));
183
+ headers.set("X-RateLimit-Remaining", String(decision.remaining));
184
+ headers.set("X-RateLimit-Reset", String(Math.floor(decision.resetAt / 1000)));
185
+ headers.set("Retry-After", String(retryAfterSec));
186
+
187
+ return new Response(response.body, {
188
+ status: response.status,
189
+ statusText: response.statusText,
190
+ headers,
191
+ });
192
+ }
193
+
194
+ function createRateLimitResponse(decision: RateLimitDecision, options: NormalizedRateLimitOptions): Response {
195
+ const response = Response.json(
196
+ {
197
+ error: "rate_limit_exceeded",
198
+ message: options.message,
199
+ limit: decision.limit,
200
+ remaining: decision.remaining,
201
+ retryAfter: Math.max(1, Math.ceil((decision.resetAt - Date.now()) / 1000)),
202
+ },
203
+ { status: options.statusCode }
204
+ );
205
+
206
+ return appendRateLimitHeaders(response, decision, options);
207
+ }
208
+
32
209
  // ========== MIME Types ==========
33
210
  const MIME_TYPES: Record<string, string> = {
34
211
  // JavaScript
@@ -107,6 +284,10 @@ export interface ServerOptions {
107
284
  * - false: 기존 renderToString 사용 (기본값)
108
285
  */
109
286
  streaming?: boolean;
287
+ /**
288
+ * API 라우트 Rate Limit 설정
289
+ */
290
+ rateLimit?: boolean | RateLimitOptions;
110
291
  /**
111
292
  * CSS 파일 경로 (SSR 링크 주입용)
112
293
  * - string: 해당 경로로 <link> 주입 (예: "/.mandu/client/globals.css")
@@ -203,6 +384,7 @@ export interface ServerRegistrySettings {
203
384
  publicDir: string;
204
385
  cors?: CorsOptions | false;
205
386
  streaming: boolean;
387
+ rateLimit?: NormalizedRateLimitOptions | false;
206
388
  /**
207
389
  * CSS 파일 경로 (SSR 링크 주입용)
208
390
  * - string: 해당 경로로 <link> 주입
@@ -230,12 +412,14 @@ export class ServerRegistry {
230
412
  /** Error 로더 (모듈 경로 → 로더 함수) */
231
413
  readonly errorLoaders: Map<string, ErrorLoader> = new Map();
232
414
  createAppFn: CreateAppFn | null = null;
415
+ rateLimiter: MemoryRateLimiter | null = null;
233
416
  settings: ServerRegistrySettings = {
234
417
  isDev: false,
235
418
  rootDir: process.cwd(),
236
419
  publicDir: "public",
237
420
  cors: false,
238
421
  streaming: false,
422
+ rateLimit: false,
239
423
  };
240
424
 
241
425
  registerApiHandler(routeId: string, handler: ApiHandler): void {
@@ -375,6 +559,7 @@ export class ServerRegistry {
375
559
  this.errorComponents.clear();
376
560
  this.errorLoaders.clear();
377
561
  this.createAppFn = null;
562
+ this.rateLimiter = null;
378
563
  }
379
564
  }
380
565
 
@@ -923,6 +1108,18 @@ async function handleRequestInternal(
923
1108
 
924
1109
  // 3. 라우트 종류별 처리
925
1110
  if (route.kind === "api") {
1111
+ const rateLimitOptions = settings.rateLimit;
1112
+ if (rateLimitOptions && registry.rateLimiter) {
1113
+ const decision = registry.rateLimiter.consume(req, route.id, rateLimitOptions);
1114
+ if (!decision.allowed) {
1115
+ return ok(createRateLimitResponse(decision, rateLimitOptions));
1116
+ }
1117
+
1118
+ const apiResult = await handleApiRoute(req, route, params, registry);
1119
+ if (!apiResult.ok) return apiResult;
1120
+ return ok(appendRateLimitHeaders(apiResult.value, decision, rateLimitOptions));
1121
+ }
1122
+
926
1123
  return handleApiRoute(req, route, params, registry);
927
1124
  }
928
1125
 
@@ -938,7 +1135,7 @@ async function handleRequestInternal(
938
1135
  message: `Unknown route kind: ${route.kind}`,
939
1136
  summary: "알 수 없는 라우트 종류 - 프레임워크 버그",
940
1137
  fix: {
941
- file: "spec/routes.manifest.json",
1138
+ file: ".mandu/routes.manifest.json",
942
1139
  suggestion: "라우트의 kind는 'api' 또는 'page'여야 합니다",
943
1140
  },
944
1141
  route: { id: route.id, pattern: route.pattern },
@@ -957,29 +1154,29 @@ function isPortInUseError(error: unknown): boolean {
957
1154
  return code === "EADDRINUSE" || message.includes("EADDRINUSE") || message.includes("address already in use");
958
1155
  }
959
1156
 
960
- function startBunServerWithFallback(options: {
961
- port: number;
962
- hostname?: string;
963
- fetch: (req: Request) => Promise<Response>;
964
- }): { server: Server; port: number; attempts: number } {
965
- const { port: startPort, hostname, fetch } = options;
966
- let lastError: unknown = null;
967
-
968
- // Port 0: let Bun/OS pick an available ephemeral port.
969
- if (startPort === 0) {
970
- const server = Bun.serve({
971
- port: 0,
972
- hostname,
973
- fetch,
974
- });
975
- return { server, port: server.port ?? 0, attempts: 0 };
976
- }
977
-
978
- for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
979
- const candidate = startPort + attempt;
980
- if (candidate < 1 || candidate > 65535) {
981
- continue;
982
- }
1157
+ function startBunServerWithFallback(options: {
1158
+ port: number;
1159
+ hostname?: string;
1160
+ fetch: (req: Request) => Promise<Response>;
1161
+ }): { server: Server; port: number; attempts: number } {
1162
+ const { port: startPort, hostname, fetch } = options;
1163
+ let lastError: unknown = null;
1164
+
1165
+ // Port 0: let Bun/OS pick an available ephemeral port.
1166
+ if (startPort === 0) {
1167
+ const server = Bun.serve({
1168
+ port: 0,
1169
+ hostname,
1170
+ fetch,
1171
+ });
1172
+ return { server, port: server.port ?? 0, attempts: 0 };
1173
+ }
1174
+
1175
+ for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
1176
+ const candidate = startPort + attempt;
1177
+ if (candidate < 1 || candidate > 65535) {
1178
+ continue;
1179
+ }
983
1180
  try {
984
1181
  const server = Bun.serve({
985
1182
  port: candidate,
@@ -1011,6 +1208,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
1011
1208
  publicDir = "public",
1012
1209
  cors = false,
1013
1210
  streaming = false,
1211
+ rateLimit = false,
1014
1212
  cssPath: cssPathOption,
1015
1213
  registry = defaultRegistry,
1016
1214
  } = options;
@@ -1027,6 +1225,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
1027
1225
 
1028
1226
  // CORS 옵션 파싱
1029
1227
  const corsOptions: CorsOptions | false = cors === true ? {} : cors;
1228
+ const rateLimitOptions = normalizeRateLimitOptions(rateLimit);
1030
1229
 
1031
1230
  if (!isDev && cors === true) {
1032
1231
  console.warn("⚠️ [Security Warning] CORS is set to allow all origins.");
@@ -1044,9 +1243,12 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
1044
1243
  publicDir,
1045
1244
  cors: corsOptions,
1046
1245
  streaming,
1246
+ rateLimit: rateLimitOptions,
1047
1247
  cssPath,
1048
1248
  };
1049
1249
 
1250
+ registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
1251
+
1050
1252
  const router = new Router(manifest.routes);
1051
1253
 
1052
1254
  // Fetch handler with CORS support (registry를 클로저로 캡처)
@@ -1112,3 +1314,58 @@ export const apiHandlers = defaultRegistry.apiHandlers;
1112
1314
  export const pageLoaders = defaultRegistry.pageLoaders;
1113
1315
  export const pageHandlers = defaultRegistry.pageHandlers;
1114
1316
  export const routeComponents = defaultRegistry.routeComponents;
1317
+
1318
+ // ========== Rate Limiting Public API ==========
1319
+
1320
+ /**
1321
+ * Rate limiter 인스턴스 생성
1322
+ * API 핸들러에서 직접 사용 가능
1323
+ *
1324
+ * @example
1325
+ * ```typescript
1326
+ * import { createRateLimiter } from '@mandujs/core/runtime/server';
1327
+ *
1328
+ * const limiter = createRateLimiter({ max: 5, windowMs: 60000 });
1329
+ *
1330
+ * export async function POST(req: Request) {
1331
+ * const decision = limiter.check(req, 'my-api-route');
1332
+ * if (!decision.allowed) {
1333
+ * return limiter.createResponse(decision);
1334
+ * }
1335
+ * // ... 정상 로직
1336
+ * }
1337
+ * ```
1338
+ */
1339
+ export function createRateLimiter(options?: RateLimitOptions) {
1340
+ const normalized = normalizeRateLimitOptions(options || true);
1341
+ if (!normalized) {
1342
+ throw new Error('Rate limiter options cannot be false');
1343
+ }
1344
+
1345
+ const limiter = new MemoryRateLimiter();
1346
+
1347
+ return {
1348
+ /**
1349
+ * Rate limit 체크
1350
+ * @param req Request 객체 (IP 추출용)
1351
+ * @param routeId 라우트 식별자 (동일 IP라도 라우트별로 독립적인 limit)
1352
+ */
1353
+ check(req: Request, routeId: string): RateLimitDecision {
1354
+ return limiter.consume(req, routeId, normalized);
1355
+ },
1356
+
1357
+ /**
1358
+ * Rate limit 초과 시 429 응답 생성
1359
+ */
1360
+ createResponse(decision: RateLimitDecision): Response {
1361
+ return createRateLimitResponse(decision, normalized);
1362
+ },
1363
+
1364
+ /**
1365
+ * 정상 응답에 Rate limit 헤더 추가
1366
+ */
1367
+ addHeaders(response: Response, decision: RateLimitDecision): Response {
1368
+ return appendRateLimitHeaders(response, decision, normalized);
1369
+ },
1370
+ };
1371
+ }