@mandujs/core 0.13.0 → 0.13.2

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.
Files changed (157) hide show
  1. package/README.ko.md +4 -4
  2. package/README.md +653 -653
  3. package/package.json +1 -1
  4. package/src/bundler/build.ts +91 -91
  5. package/src/bundler/css.ts +302 -302
  6. package/src/client/Link.tsx +227 -227
  7. package/src/client/globals.ts +44 -44
  8. package/src/client/hooks.ts +267 -267
  9. package/src/client/index.ts +5 -5
  10. package/src/client/island.ts +8 -8
  11. package/src/client/router.ts +435 -435
  12. package/src/client/runtime.ts +23 -23
  13. package/src/client/serialize.ts +404 -404
  14. package/src/client/window-state.ts +101 -101
  15. package/src/config/mandu.ts +9 -0
  16. package/src/config/validate.ts +12 -0
  17. package/src/config/watcher.ts +311 -311
  18. package/src/constants.ts +40 -40
  19. package/src/content/content-layer.ts +314 -314
  20. package/src/content/content.test.ts +433 -433
  21. package/src/content/data-store.ts +245 -245
  22. package/src/content/digest.ts +133 -133
  23. package/src/content/index.ts +164 -164
  24. package/src/content/loader-context.ts +172 -172
  25. package/src/content/loaders/api.ts +216 -216
  26. package/src/content/loaders/file.ts +169 -169
  27. package/src/content/loaders/glob.ts +252 -252
  28. package/src/content/loaders/index.ts +34 -34
  29. package/src/content/loaders/types.ts +137 -137
  30. package/src/content/meta-store.ts +209 -209
  31. package/src/content/types.ts +282 -282
  32. package/src/content/watcher.ts +135 -135
  33. package/src/contract/client-safe.test.ts +42 -42
  34. package/src/contract/client-safe.ts +114 -114
  35. package/src/contract/client.ts +16 -16
  36. package/src/contract/define.ts +459 -459
  37. package/src/contract/handler.ts +10 -10
  38. package/src/contract/normalize.test.ts +276 -276
  39. package/src/contract/normalize.ts +404 -404
  40. package/src/contract/registry.test.ts +206 -206
  41. package/src/contract/registry.ts +568 -568
  42. package/src/contract/schema.ts +48 -48
  43. package/src/contract/types.ts +58 -58
  44. package/src/contract/validator.ts +32 -32
  45. package/src/devtools/ai/context-builder.ts +375 -375
  46. package/src/devtools/ai/index.ts +25 -25
  47. package/src/devtools/ai/mcp-connector.ts +465 -465
  48. package/src/devtools/client/catchers/error-catcher.ts +327 -327
  49. package/src/devtools/client/catchers/index.ts +18 -18
  50. package/src/devtools/client/catchers/network-proxy.ts +363 -363
  51. package/src/devtools/client/components/index.ts +39 -39
  52. package/src/devtools/client/components/kitchen-root.tsx +362 -362
  53. package/src/devtools/client/components/mandu-character.tsx +241 -241
  54. package/src/devtools/client/components/overlay.tsx +368 -368
  55. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  56. package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
  57. package/src/devtools/client/components/panel/index.ts +32 -32
  58. package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
  59. package/src/devtools/client/components/panel/network-panel.tsx +292 -292
  60. package/src/devtools/client/components/panel/panel-container.tsx +259 -259
  61. package/src/devtools/client/filters/context-filters.ts +282 -282
  62. package/src/devtools/client/filters/index.ts +16 -16
  63. package/src/devtools/client/index.ts +63 -63
  64. package/src/devtools/client/persistence.ts +335 -335
  65. package/src/devtools/client/state-manager.ts +478 -478
  66. package/src/devtools/design-tokens.ts +263 -263
  67. package/src/devtools/hook/create-hook.ts +207 -207
  68. package/src/devtools/hook/index.ts +13 -13
  69. package/src/devtools/index.ts +439 -439
  70. package/src/devtools/init.ts +266 -266
  71. package/src/devtools/protocol.ts +237 -237
  72. package/src/devtools/server/index.ts +17 -17
  73. package/src/devtools/server/source-context.ts +444 -444
  74. package/src/devtools/types.ts +319 -319
  75. package/src/devtools/worker/index.ts +25 -25
  76. package/src/devtools/worker/redaction-worker.ts +222 -222
  77. package/src/devtools/worker/worker-manager.ts +409 -409
  78. package/src/error/domains.ts +265 -265
  79. package/src/error/result.ts +46 -46
  80. package/src/error/types.ts +6 -6
  81. package/src/errors/extractor.ts +409 -409
  82. package/src/errors/index.ts +19 -19
  83. package/src/filling/auth.ts +308 -308
  84. package/src/filling/context.ts +24 -1
  85. package/src/filling/deps.ts +238 -238
  86. package/src/filling/index.ts +4 -0
  87. package/src/filling/sse-catchup.test.ts +56 -0
  88. package/src/filling/sse-catchup.ts +67 -0
  89. package/src/filling/sse.test.ts +168 -0
  90. package/src/filling/sse.ts +162 -0
  91. package/src/generator/index.ts +3 -3
  92. package/src/guard/analyzer.ts +360 -360
  93. package/src/guard/ast-analyzer.ts +806 -806
  94. package/src/guard/contract-guard.ts +9 -9
  95. package/src/guard/file-type.test.ts +24 -24
  96. package/src/guard/presets/atomic.ts +70 -70
  97. package/src/guard/presets/clean.ts +77 -77
  98. package/src/guard/presets/fsd.ts +79 -79
  99. package/src/guard/presets/hexagonal.ts +68 -68
  100. package/src/guard/presets/index.ts +291 -291
  101. package/src/guard/reporter.ts +445 -445
  102. package/src/guard/rules.ts +12 -12
  103. package/src/guard/statistics.ts +578 -578
  104. package/src/guard/suggestions.ts +358 -358
  105. package/src/guard/types.ts +348 -348
  106. package/src/guard/validator.ts +834 -834
  107. package/src/guard/watcher.ts +404 -404
  108. package/src/index.ts +6 -1
  109. package/src/intent/index.ts +310 -310
  110. package/src/island/index.ts +304 -304
  111. package/src/logging/index.ts +22 -22
  112. package/src/logging/transports.ts +365 -365
  113. package/src/plugins/index.ts +38 -38
  114. package/src/plugins/registry.ts +377 -377
  115. package/src/plugins/types.ts +363 -363
  116. package/src/report/index.ts +1 -1
  117. package/src/router/fs-patterns.ts +387 -387
  118. package/src/router/fs-scanner.ts +497 -497
  119. package/src/runtime/boundary.tsx +232 -232
  120. package/src/runtime/compose.ts +222 -222
  121. package/src/runtime/escape.ts +44 -0
  122. package/src/runtime/lifecycle.ts +381 -381
  123. package/src/runtime/logger.test.ts +345 -345
  124. package/src/runtime/logger.ts +677 -677
  125. package/src/runtime/router.test.ts +476 -476
  126. package/src/runtime/router.ts +105 -105
  127. package/src/runtime/security.ts +155 -155
  128. package/src/runtime/server.ts +257 -0
  129. package/src/runtime/session-key.ts +328 -328
  130. package/src/runtime/ssr.ts +16 -21
  131. package/src/runtime/streaming-ssr.ts +24 -33
  132. package/src/runtime/trace.ts +144 -144
  133. package/src/seo/index.ts +214 -214
  134. package/src/seo/integration/ssr.ts +307 -307
  135. package/src/seo/render/basic.ts +427 -427
  136. package/src/seo/render/index.ts +143 -143
  137. package/src/seo/render/jsonld.ts +539 -539
  138. package/src/seo/render/opengraph.ts +191 -191
  139. package/src/seo/render/robots.ts +116 -116
  140. package/src/seo/render/sitemap.ts +137 -137
  141. package/src/seo/render/twitter.ts +126 -126
  142. package/src/seo/resolve/index.ts +353 -353
  143. package/src/seo/resolve/opengraph.ts +143 -143
  144. package/src/seo/resolve/robots.ts +73 -73
  145. package/src/seo/resolve/title.ts +94 -94
  146. package/src/seo/resolve/twitter.ts +73 -73
  147. package/src/seo/resolve/url.ts +97 -97
  148. package/src/seo/routes/index.ts +290 -290
  149. package/src/seo/types.ts +575 -575
  150. package/src/slot/validator.ts +39 -39
  151. package/src/spec/index.ts +3 -3
  152. package/src/spec/load.ts +76 -76
  153. package/src/spec/lock.ts +56 -56
  154. package/src/utils/bun.ts +8 -8
  155. package/src/utils/lru-cache.ts +75 -75
  156. package/src/utils/safe-io.ts +188 -188
  157. package/src/utils/string-safe.ts +298 -298
@@ -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
 
@@ -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
+ }