@mandujs/core 0.19.0 → 0.19.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 (90) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/filling.ts +336 -14
  38. package/src/filling/index.ts +5 -1
  39. package/src/filling/session.ts +216 -0
  40. package/src/filling/ws.ts +78 -0
  41. package/src/generator/generate.ts +2 -2
  42. package/src/guard/auto-correct.ts +0 -29
  43. package/src/guard/check.ts +14 -31
  44. package/src/guard/presets/index.ts +296 -294
  45. package/src/guard/rules.ts +15 -19
  46. package/src/guard/validator.ts +834 -834
  47. package/src/index.ts +5 -1
  48. package/src/island/index.ts +373 -304
  49. package/src/kitchen/api/contract-api.ts +225 -0
  50. package/src/kitchen/api/diff-parser.ts +108 -0
  51. package/src/kitchen/api/file-api.ts +273 -0
  52. package/src/kitchen/api/guard-api.ts +83 -0
  53. package/src/kitchen/api/guard-decisions.ts +100 -0
  54. package/src/kitchen/api/routes-api.ts +50 -0
  55. package/src/kitchen/index.ts +21 -0
  56. package/src/kitchen/kitchen-handler.ts +256 -0
  57. package/src/kitchen/kitchen-ui.ts +1732 -0
  58. package/src/kitchen/stream/activity-sse.ts +145 -0
  59. package/src/kitchen/stream/file-tailer.ts +99 -0
  60. package/src/middleware/compress.ts +62 -0
  61. package/src/middleware/cors.ts +47 -0
  62. package/src/middleware/index.ts +10 -0
  63. package/src/middleware/jwt.ts +134 -0
  64. package/src/middleware/logger.ts +58 -0
  65. package/src/middleware/timeout.ts +55 -0
  66. package/src/paths.ts +0 -4
  67. package/src/plugins/hooks.ts +64 -0
  68. package/src/plugins/index.ts +3 -0
  69. package/src/plugins/types.ts +5 -0
  70. package/src/report/build.ts +0 -6
  71. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  72. package/src/router/fs-patterns.ts +11 -1
  73. package/src/router/fs-routes.ts +78 -14
  74. package/src/router/fs-scanner.ts +2 -2
  75. package/src/router/fs-types.ts +2 -1
  76. package/src/runtime/adapter-bun.ts +62 -0
  77. package/src/runtime/adapter.ts +47 -0
  78. package/src/runtime/cache.ts +310 -0
  79. package/src/runtime/handler.ts +65 -0
  80. package/src/runtime/image-handler.ts +195 -0
  81. package/src/runtime/index.ts +12 -0
  82. package/src/runtime/middleware.ts +263 -0
  83. package/src/runtime/server.ts +662 -83
  84. package/src/runtime/ssr.ts +55 -29
  85. package/src/runtime/streaming-ssr.ts +106 -82
  86. package/src/spec/index.ts +0 -1
  87. package/src/spec/schema.ts +1 -0
  88. package/src/testing/index.ts +144 -0
  89. package/src/watcher/watcher.ts +27 -1
  90. package/src/spec/lock.ts +0 -56
@@ -0,0 +1,145 @@
1
+ /**
2
+ * ActivitySSEBroadcaster - Broadcasts MCP activity events to Kitchen UI via SSE.
3
+ *
4
+ * Watches .mandu/activity.jsonl (written by MCP server process) and pushes
5
+ * new events to connected browser clients using Server-Sent Events.
6
+ *
7
+ * Uses 500ms throttle (AionUI pattern) to avoid overwhelming the browser.
8
+ */
9
+
10
+ import path from "path";
11
+ import { FileTailer } from "./file-tailer";
12
+
13
+ interface SSEClient {
14
+ id: string;
15
+ controller: ReadableStreamDefaultController<Uint8Array>;
16
+ connectedAt: number;
17
+ }
18
+
19
+ const THROTTLE_MS = 500;
20
+ const HEARTBEAT_MS = 30_000;
21
+
22
+ export class ActivitySSEBroadcaster {
23
+ private clients = new Map<string, SSEClient>();
24
+ private tailer: FileTailer;
25
+ private pendingEvents: string[] = [];
26
+ private throttleTimer: ReturnType<typeof setTimeout> | null = null;
27
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
28
+ private started = false;
29
+
30
+ constructor(rootDir: string) {
31
+ const logPath = path.join(rootDir, ".mandu", "activity.jsonl");
32
+ this.tailer = new FileTailer(logPath, {
33
+ startAtEnd: true,
34
+ pollIntervalMs: 300,
35
+ });
36
+ }
37
+
38
+ start(): void {
39
+ if (this.started) return;
40
+ this.started = true;
41
+
42
+ this.tailer.on("line", (line: string) => {
43
+ this.pendingEvents.push(line);
44
+ this.scheduleFlush();
45
+ });
46
+ this.tailer.start();
47
+
48
+ // Heartbeat to keep connections alive and detect stale clients
49
+ this.heartbeatTimer = setInterval(() => {
50
+ this.broadcast(JSON.stringify({ type: "heartbeat", ts: new Date().toISOString() }));
51
+ }, HEARTBEAT_MS);
52
+ }
53
+
54
+ private scheduleFlush(): void {
55
+ if (this.throttleTimer) return;
56
+ this.throttleTimer = setTimeout(() => {
57
+ this.flush();
58
+ this.throttleTimer = null;
59
+ }, THROTTLE_MS);
60
+ }
61
+
62
+ private flush(): void {
63
+ const events = this.pendingEvents.splice(0);
64
+ for (const event of events) {
65
+ this.broadcast(event);
66
+ }
67
+ }
68
+
69
+ /** Broadcast a raw JSON string to all connected SSE clients */
70
+ broadcast(data: string): void {
71
+ const message = `data: ${data}\n\n`;
72
+ const encoded = new TextEncoder().encode(message);
73
+
74
+ for (const [id, client] of this.clients) {
75
+ try {
76
+ client.controller.enqueue(encoded);
77
+ } catch {
78
+ this.clients.delete(id);
79
+ }
80
+ }
81
+ }
82
+
83
+ /** Create an SSE Response for a new client connection */
84
+ createResponse(): Response {
85
+ const clientId = crypto.randomUUID();
86
+
87
+ const stream = new ReadableStream<Uint8Array>({
88
+ start: (controller) => {
89
+ this.clients.set(clientId, {
90
+ id: clientId,
91
+ controller,
92
+ connectedAt: Date.now(),
93
+ });
94
+
95
+ // Send connection confirmation
96
+ const welcome = `data: ${JSON.stringify({
97
+ type: "connected",
98
+ clientId,
99
+ ts: new Date().toISOString(),
100
+ })}\n\n`;
101
+ controller.enqueue(new TextEncoder().encode(welcome));
102
+ },
103
+ cancel: () => {
104
+ this.clients.delete(clientId);
105
+ },
106
+ });
107
+
108
+ return new Response(stream, {
109
+ headers: {
110
+ "Content-Type": "text/event-stream",
111
+ "Cache-Control": "no-cache",
112
+ Connection: "keep-alive",
113
+ "X-Kitchen-Version": "1",
114
+ },
115
+ });
116
+ }
117
+
118
+ /** Get the number of connected clients */
119
+ get clientCount(): number {
120
+ return this.clients.size;
121
+ }
122
+
123
+ stop(): void {
124
+ if (!this.started) return;
125
+ this.started = false;
126
+
127
+ this.tailer.stop();
128
+
129
+ if (this.throttleTimer) {
130
+ clearTimeout(this.throttleTimer);
131
+ this.throttleTimer = null;
132
+ }
133
+ if (this.heartbeatTimer) {
134
+ clearInterval(this.heartbeatTimer);
135
+ this.heartbeatTimer = null;
136
+ }
137
+
138
+ for (const [, client] of this.clients) {
139
+ try {
140
+ client.controller.close();
141
+ } catch { /* ignore */ }
142
+ }
143
+ this.clients.clear();
144
+ }
145
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * FileTailer - Tails a JSONL file and emits new lines as events.
3
+ *
4
+ * Used by Kitchen SSE to watch .mandu/activity.jsonl written by MCP server.
5
+ * Communication between MCP process and dev server is file-system based.
6
+ */
7
+
8
+ import { EventEmitter } from "events";
9
+ import fs from "fs";
10
+
11
+ export interface FileTailerOptions {
12
+ /** Start reading from end of file (skip existing content) */
13
+ startAtEnd: boolean;
14
+ /** Polling interval in ms for fs.watchFile */
15
+ pollIntervalMs: number;
16
+ }
17
+
18
+ const DEFAULT_OPTIONS: FileTailerOptions = {
19
+ startAtEnd: true,
20
+ pollIntervalMs: 300,
21
+ };
22
+
23
+ export class FileTailer extends EventEmitter {
24
+ private position = 0;
25
+ private buffer = "";
26
+ private watching = false;
27
+
28
+ constructor(
29
+ private filePath: string,
30
+ private options: FileTailerOptions = DEFAULT_OPTIONS
31
+ ) {
32
+ super();
33
+ }
34
+
35
+ start(): void {
36
+ if (this.watching) return;
37
+ this.watching = true;
38
+
39
+ try {
40
+ const stat = fs.statSync(this.filePath);
41
+ this.position = this.options.startAtEnd ? stat.size : 0;
42
+ } catch {
43
+ // File doesn't exist yet — start from 0
44
+ this.position = 0;
45
+ }
46
+
47
+ fs.watchFile(
48
+ this.filePath,
49
+ { interval: this.options.pollIntervalMs },
50
+ (curr) => {
51
+ if (curr.size > this.position) {
52
+ this.readNewContent(curr.size);
53
+ } else if (curr.size < this.position) {
54
+ // File was truncated/recreated (MCP server restart)
55
+ this.position = 0;
56
+ this.buffer = "";
57
+ if (curr.size > 0) {
58
+ this.readNewContent(curr.size);
59
+ }
60
+ }
61
+ }
62
+ );
63
+ }
64
+
65
+ private readNewContent(newSize: number): void {
66
+ let fd: number | undefined;
67
+ try {
68
+ fd = fs.openSync(this.filePath, "r");
69
+ const length = newSize - this.position;
70
+ const buf = Buffer.alloc(length);
71
+ fs.readSync(fd, buf, 0, length, this.position);
72
+ this.position = newSize;
73
+
74
+ this.buffer += buf.toString("utf-8");
75
+ const lines = this.buffer.split("\n");
76
+ this.buffer = lines.pop() ?? "";
77
+
78
+ for (const line of lines) {
79
+ const trimmed = line.trim();
80
+ if (trimmed) {
81
+ this.emit("line", trimmed);
82
+ }
83
+ }
84
+ } catch {
85
+ // File read error — will retry on next poll
86
+ } finally {
87
+ if (fd !== undefined) {
88
+ try { fs.closeSync(fd); } catch { /* ignore */ }
89
+ }
90
+ }
91
+ }
92
+
93
+ stop(): void {
94
+ if (!this.watching) return;
95
+ this.watching = false;
96
+ fs.unwatchFile(this.filePath);
97
+ this.removeAllListeners();
98
+ }
99
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Compress Middleware Plugin
3
+ * filling.use(compress())
4
+ */
5
+
6
+ import type { MiddlewarePlugin } from "../filling/filling";
7
+ import type { ManduContext } from "../filling/context";
8
+
9
+ export interface CompressMiddlewareOptions {
10
+ /** 압축 최소 크기 (바이트, 기본: 1024) */
11
+ threshold?: number;
12
+ /** 압축할 Content-Type (기본: text/*, application/json, application/xml) */
13
+ contentTypes?: string[];
14
+ }
15
+
16
+ const DEFAULT_COMPRESSIBLE = ["text/", "application/json", "application/xml", "application/javascript"];
17
+
18
+ /**
19
+ * Gzip 압축 미들웨어
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { compress } from "@mandujs/core/middleware";
24
+ *
25
+ * export default Mandu.filling()
26
+ * .use(compress({ threshold: 512 }))
27
+ * .get((ctx) => ctx.ok({ largeData: "..." }));
28
+ * ```
29
+ */
30
+ export function compress(options?: CompressMiddlewareOptions): MiddlewarePlugin {
31
+ const threshold = options?.threshold ?? 1024;
32
+ const types = options?.contentTypes ?? DEFAULT_COMPRESSIBLE;
33
+
34
+ return {
35
+ beforeHandle: async (ctx: ManduContext): Promise<void> => {
36
+ const acceptEncoding = ctx.headers.get("Accept-Encoding") ?? "";
37
+ if (acceptEncoding.includes("gzip")) {
38
+ ctx.set("_compress_enabled", true);
39
+ }
40
+ },
41
+ afterHandle: async (ctx: ManduContext, response: Response): Promise<Response> => {
42
+ if (!ctx.get<boolean>("_compress_enabled")) return response;
43
+
44
+ const contentType = response.headers.get("Content-Type") ?? "";
45
+ const isCompressible = types.some(t => contentType.includes(t));
46
+ if (!isCompressible) return response;
47
+
48
+ const body = await response.arrayBuffer();
49
+ if (body.byteLength < threshold) {
50
+ return new Response(body, { status: response.status, headers: response.headers });
51
+ }
52
+
53
+ const compressed = Bun.gzipSync(new Uint8Array(body));
54
+ const headers = new Headers(response.headers);
55
+ headers.set("Content-Encoding", "gzip");
56
+ headers.set("Vary", "Accept-Encoding");
57
+ headers.delete("Content-Length");
58
+
59
+ return new Response(compressed, { status: response.status, headers });
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CORS Middleware Plugin
3
+ * filling.use(cors({ origin: "https://example.com" }))
4
+ */
5
+
6
+ import type { MiddlewarePlugin } from "../filling/filling";
7
+ import type { ManduContext } from "../filling/context";
8
+ import {
9
+ type CorsOptions,
10
+ handlePreflightRequest,
11
+ applyCorsToResponse,
12
+ } from "../runtime/cors";
13
+
14
+ export type CorsMiddlewareOptions = CorsOptions;
15
+
16
+ /**
17
+ * CORS 미들웨어
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { cors } from "@mandujs/core/middleware";
22
+ *
23
+ * export default Mandu.filling()
24
+ * .use(cors({ origin: ["https://example.com"], credentials: true }))
25
+ * .get((ctx) => ctx.ok({ data: "protected" }));
26
+ * ```
27
+ */
28
+ export function cors(options?: CorsMiddlewareOptions): MiddlewarePlugin {
29
+ return {
30
+ beforeHandle: async (ctx: ManduContext): Promise<Response | void> => {
31
+ const origin = ctx.headers.get("Origin");
32
+ if (!origin) return;
33
+
34
+ // Preflight
35
+ if (ctx.request.method === "OPTIONS") {
36
+ return handlePreflightRequest(ctx.request, options);
37
+ }
38
+
39
+ // 실제 요청 — afterHandle에서 CORS 헤더 추가하기 위해 플래그 저장
40
+ ctx.set("_cors_apply", true);
41
+ },
42
+ afterHandle: async (ctx: ManduContext, response: Response): Promise<Response> => {
43
+ if (!ctx.get<boolean>("_cors_apply")) return response;
44
+ return applyCorsToResponse(response, ctx.request, options);
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Mandu Middleware Plugins
3
+ * filling.use()로 조합 가능한 재사용 미들웨어
4
+ */
5
+
6
+ export { cors, type CorsMiddlewareOptions } from "./cors";
7
+ export { jwt, type JwtMiddlewareOptions } from "./jwt";
8
+ export { compress, type CompressMiddlewareOptions } from "./compress";
9
+ export { logger, type LoggerMiddlewareOptions } from "./logger";
10
+ export { timeout, type TimeoutMiddlewareOptions } from "./timeout";
@@ -0,0 +1,134 @@
1
+ /**
2
+ * JWT Middleware Plugin
3
+ * filling.use(jwt({ secret: process.env.JWT_SECRET }))
4
+ */
5
+
6
+ import type { ManduContext } from "../filling/context";
7
+
8
+ export interface JwtMiddlewareOptions {
9
+ /** JWT 시크릿 키 */
10
+ secret: string;
11
+ /** 허용할 알고리즘 (기본: ["HS256"]) */
12
+ algorithms?: ("HS256" | "HS384" | "HS512")[];
13
+ /** 토큰 추출 위치 (기본: Authorization 헤더) */
14
+ extractFrom?: "header" | "cookie";
15
+ /** 쿠키 이름 (extractFrom: "cookie" 일 때) */
16
+ cookieName?: string;
17
+ /** ctx.set()에 저장할 키 (기본: "user") */
18
+ storeAs?: string;
19
+ }
20
+
21
+ /**
22
+ * JWT 인증 미들웨어
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * import { jwt } from "@mandujs/core/middleware";
27
+ *
28
+ * export default Mandu.filling()
29
+ * .use(jwt({ secret: process.env.JWT_SECRET! }))
30
+ * .get((ctx) => {
31
+ * const user = ctx.get("user");
32
+ * return ctx.ok({ user });
33
+ * });
34
+ * ```
35
+ */
36
+ export function jwt(options: JwtMiddlewareOptions) {
37
+ const { secret, algorithms = ["HS256"], extractFrom = "header", cookieName = "token", storeAs = "user" } = options;
38
+
39
+ return async (ctx: ManduContext): Promise<Response | void> => {
40
+ let token: string | null = null;
41
+
42
+ if (extractFrom === "header") {
43
+ const auth = ctx.headers.get("Authorization");
44
+ if (auth?.startsWith("Bearer ")) {
45
+ token = auth.slice(7);
46
+ }
47
+ } else {
48
+ token = ctx.cookies.get(cookieName) ?? null;
49
+ }
50
+
51
+ if (!token) {
52
+ return ctx.unauthorized("Missing authentication token");
53
+ }
54
+
55
+ // 토큰 크기 제한 (메모리 소모 공격 방지)
56
+ if (token.length > 8192) {
57
+ return ctx.unauthorized("Token too large");
58
+ }
59
+
60
+ try {
61
+ // Bun native JWT verification
62
+ const payload = await verifyJwtToken(token, secret, algorithms);
63
+ ctx.set(storeAs, payload);
64
+ } catch {
65
+ return ctx.unauthorized("Invalid or expired token");
66
+ }
67
+ };
68
+ }
69
+
70
+ const ALG_MAP: Record<string, string> = {
71
+ HS256: "SHA-256",
72
+ HS384: "SHA-384",
73
+ HS512: "SHA-512",
74
+ };
75
+
76
+ /** JWT 검증 (Bun crypto 기반, HS256/HS384/HS512 지원) */
77
+ async function verifyJwtToken(
78
+ token: string,
79
+ secret: string,
80
+ allowedAlgorithms: string[]
81
+ ): Promise<Record<string, unknown>> {
82
+ const parts = token.split(".");
83
+ if (parts.length !== 3) throw new Error("Invalid JWT format");
84
+
85
+ const [headerB64, payloadB64, signatureB64] = parts;
86
+
87
+ // 헤더에서 알고리즘 확인
88
+ const header = JSON.parse(atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")));
89
+ const alg = header.alg as string;
90
+ if (!allowedAlgorithms.includes(alg)) {
91
+ throw new Error(`Algorithm "${alg}" not allowed. Allowed: ${allowedAlgorithms.join(", ")}`);
92
+ }
93
+
94
+ const hash = ALG_MAP[alg];
95
+ if (!hash) throw new Error(`Unsupported algorithm: ${alg}`);
96
+
97
+ // 서명 검증
98
+ const encoder = new TextEncoder();
99
+ const key = await crypto.subtle.importKey(
100
+ "raw",
101
+ encoder.encode(secret),
102
+ { name: "HMAC", hash },
103
+ false,
104
+ ["verify"]
105
+ );
106
+
107
+ const data = encoder.encode(`${headerB64}.${payloadB64}`);
108
+ const signature = base64UrlDecode(signatureB64);
109
+
110
+ const valid = await crypto.subtle.verify("HMAC", key, signature as BufferSource, data);
111
+ if (!valid) throw new Error("Invalid signature");
112
+
113
+ // 페이로드 디코딩
114
+ const payload = JSON.parse(atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")));
115
+
116
+ // 만료 확인
117
+ if (payload.exp && payload.exp * 1000 < Date.now()) {
118
+ throw new Error("Token expired");
119
+ }
120
+
121
+ // nbf (not-before) 확인 — 아직 유효하지 않은 토큰 거부
122
+ if (payload.nbf && payload.nbf * 1000 > Date.now()) {
123
+ throw new Error("Token not yet valid");
124
+ }
125
+
126
+ return payload;
127
+ }
128
+
129
+ function base64UrlDecode(str: string): Uint8Array {
130
+ const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
131
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
132
+ const binary = atob(padded);
133
+ return Uint8Array.from(binary, c => c.charCodeAt(0));
134
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Logger Middleware Plugin
3
+ * filling.use(logger())
4
+ */
5
+
6
+ import type { MiddlewarePlugin } from "../filling/filling";
7
+ import type { ManduContext } from "../filling/context";
8
+
9
+ export interface LoggerMiddlewareOptions {
10
+ /** 로그 포맷 (기본: "short") */
11
+ format?: "short" | "detailed";
12
+ /** 커스텀 로거 (기본: console.log) */
13
+ log?: (message: string) => void;
14
+ /** 느린 요청 경고 임계값 (ms, 기본: 3000) */
15
+ slowThreshold?: number;
16
+ }
17
+
18
+ /**
19
+ * 요청/응답 로깅 미들웨어
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { logger } from "@mandujs/core/middleware";
24
+ *
25
+ * export default Mandu.filling()
26
+ * .use(logger({ format: "detailed", slowThreshold: 1000 }))
27
+ * .get((ctx) => ctx.ok({ data: "logged" }));
28
+ * ```
29
+ */
30
+ export function logger(options?: LoggerMiddlewareOptions): MiddlewarePlugin {
31
+ const format = options?.format ?? "short";
32
+ const log = options?.log ?? console.log;
33
+ const slowThreshold = options?.slowThreshold ?? 3000;
34
+
35
+ return {
36
+ beforeHandle: async (ctx: ManduContext): Promise<void> => {
37
+ ctx.set("_logger_start", Date.now());
38
+ },
39
+ afterHandle: async (ctx: ManduContext, response: Response): Promise<Response> => {
40
+ const start = ctx.get<number>("_logger_start");
41
+ if (start === undefined) return response;
42
+
43
+ const duration = Date.now() - start;
44
+ const method = ctx.request.method;
45
+ const pathname = new URL(ctx.request.url).pathname;
46
+ const status = response.status;
47
+ const slow = duration > slowThreshold ? " ⚠️ SLOW" : "";
48
+
49
+ if (format === "detailed") {
50
+ log(`${method} ${pathname} → ${status} (${duration}ms)${slow}`);
51
+ } else {
52
+ log(`${method} ${pathname} ${status} ${duration}ms${slow}`);
53
+ }
54
+
55
+ return response;
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Timeout Middleware Plugin
3
+ * filling.use(timeout(5000))
4
+ */
5
+
6
+ import type { MiddlewarePlugin } from "../filling/filling";
7
+ import type { ManduContext } from "../filling/context";
8
+
9
+ export interface TimeoutMiddlewareOptions {
10
+ /** 타임아웃 (ms) */
11
+ ms: number;
12
+ /** 타임아웃 시 응답 메시지 */
13
+ message?: string;
14
+ /** 타임아웃 시 HTTP 상태 코드 (기본: 408) */
15
+ status?: number;
16
+ }
17
+
18
+ /**
19
+ * 요청 타임아웃 미들웨어
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { timeout } from "@mandujs/core/middleware";
24
+ *
25
+ * export default Mandu.filling()
26
+ * .use(timeout({ ms: 5000 }))
27
+ * .get(async (ctx) => { ... });
28
+ * ```
29
+ */
30
+ export function timeout(options: TimeoutMiddlewareOptions | number): MiddlewarePlugin {
31
+ const config = typeof options === "number"
32
+ ? { ms: options, message: "Request Timeout", status: 408 }
33
+ : { message: "Request Timeout", status: 408, ...options };
34
+
35
+ return {
36
+ beforeHandle: async (ctx: ManduContext): Promise<Response | void> => {
37
+ // 타임아웃 타이머 설정 — 핸들러가 완료 전에 만료되면 408 반환
38
+ ctx.set("_timeout_timer", setTimeout(() => {
39
+ ctx.set("_timeout_expired", true);
40
+ }, config.ms));
41
+ },
42
+ afterHandle: async (ctx: ManduContext, response: Response): Promise<Response> => {
43
+ // 타이머 정리
44
+ const timer = ctx.get<ReturnType<typeof setTimeout>>("_timeout_timer");
45
+ if (timer) clearTimeout(timer);
46
+
47
+ // 타임아웃 만료 확인
48
+ if (ctx.get<boolean>("_timeout_expired")) {
49
+ return ctx.json({ error: config.message }, config.status);
50
+ }
51
+
52
+ return response;
53
+ },
54
+ };
55
+ }
package/src/paths.ts CHANGED
@@ -15,8 +15,6 @@ export interface GeneratedPaths {
15
15
  mapDir: string;
16
16
  /** 생성된 매니페스트 경로 */
17
17
  manifestPath: string;
18
- /** 생성된 lock 경로 */
19
- lockPath: string;
20
18
  /** Resource 관련 경로 */
21
19
  resourceContractsDir: string;
22
20
  resourceTypesDir: string;
@@ -35,7 +33,6 @@ export function resolveGeneratedPaths(rootDir: string): GeneratedPaths {
35
33
  typesDir: path.join(rootDir, ".mandu/generated/server/types"),
36
34
  mapDir: path.join(rootDir, ".mandu/generated"),
37
35
  manifestPath: path.join(rootDir, ".mandu/routes.manifest.json"),
38
- lockPath: path.join(rootDir, ".mandu/spec.lock.json"),
39
36
  resourceContractsDir: path.join(rootDir, ".mandu/generated/server/contracts"),
40
37
  resourceTypesDir: path.join(rootDir, ".mandu/generated/server/types"),
41
38
  resourceSlotsDir: path.join(rootDir, "spec/slots"),
@@ -53,7 +50,6 @@ export const GENERATED_RELATIVE_PATHS = {
53
50
  types: ".mandu/generated/server/types",
54
51
  map: ".mandu/generated",
55
52
  manifest: ".mandu/routes.manifest.json",
56
- lock: ".mandu/spec.lock.json",
57
53
  history: ".mandu/history",
58
54
  contracts: ".mandu/generated/server/contracts",
59
55
  resourceTypes: ".mandu/generated/server/types",