@mandujs/cli 0.15.1 → 0.15.3

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 +33 -33
  2. package/README.md +354 -354
  3. package/package.json +2 -2
  4. package/src/commands/check.ts +71 -7
  5. package/src/commands/contract.ts +173 -173
  6. package/src/commands/dev.ts +9 -42
  7. package/src/commands/guard-arch.ts +303 -303
  8. package/src/commands/init.ts +50 -5
  9. package/src/commands/monitor.ts +300 -300
  10. package/src/commands/openapi.ts +107 -107
  11. package/src/commands/registry.ts +1 -0
  12. package/src/commands/start.ts +9 -42
  13. package/src/errors/codes.ts +35 -35
  14. package/src/errors/index.ts +2 -2
  15. package/src/errors/messages.ts +143 -143
  16. package/src/hooks/index.ts +17 -17
  17. package/src/hooks/preaction.ts +256 -256
  18. package/src/main.ts +9 -7
  19. package/src/terminal/banner.ts +166 -166
  20. package/src/terminal/help.ts +306 -306
  21. package/src/terminal/index.ts +71 -71
  22. package/src/terminal/output.ts +295 -295
  23. package/src/terminal/palette.ts +30 -30
  24. package/src/terminal/progress.ts +327 -327
  25. package/src/terminal/stream-writer.ts +214 -214
  26. package/src/terminal/table.ts +354 -354
  27. package/src/terminal/theme.ts +142 -142
  28. package/src/util/bun.ts +6 -6
  29. package/src/util/fs.ts +23 -23
  30. package/src/util/handlers.ts +49 -5
  31. package/src/util/lockfile.ts +66 -0
  32. package/src/util/output.ts +22 -22
  33. package/src/util/port.ts +71 -71
  34. package/templates/default/AGENTS.md +96 -96
  35. package/templates/default/app/api/health/route.ts +13 -13
  36. package/templates/default/app/globals.css +49 -49
  37. package/templates/default/app/layout.tsx +27 -27
  38. package/templates/default/app/page.tsx +38 -38
  39. package/templates/default/src/client/shared/lib/utils.ts +16 -16
  40. package/templates/default/src/client/shared/ui/button.tsx +57 -57
  41. package/templates/default/src/client/shared/ui/card.tsx +1 -1
  42. package/templates/default/src/client/shared/ui/index.ts +21 -21
  43. package/templates/default/src/client/shared/ui/input.tsx +5 -1
  44. package/templates/default/tests/example.test.ts +58 -58
  45. package/templates/default/tests/helpers.ts +52 -52
  46. package/templates/default/tests/setup.ts +9 -9
  47. package/templates/default/tsconfig.json +23 -23
  48. package/templates/realtime-chat/AGENTS.md +96 -0
  49. package/templates/realtime-chat/app/api/chat/messages/route.ts +63 -0
  50. package/templates/realtime-chat/app/api/chat/stream/route.ts +85 -0
  51. package/templates/realtime-chat/app/api/health/route.ts +13 -0
  52. package/templates/realtime-chat/app/globals.css +49 -0
  53. package/templates/realtime-chat/app/layout.tsx +27 -0
  54. package/templates/realtime-chat/app/page.tsx +16 -0
  55. package/templates/realtime-chat/package.json +34 -0
  56. package/templates/realtime-chat/src/client/app/index.ts +1 -0
  57. package/templates/realtime-chat/src/client/entities/index.ts +1 -0
  58. package/templates/realtime-chat/src/client/features/chat/chat-api.ts +209 -0
  59. package/templates/realtime-chat/src/client/features/chat/realtime-chat-starter.client.tsx +89 -0
  60. package/templates/realtime-chat/src/client/features/chat/use-realtime-chat.ts +65 -0
  61. package/templates/realtime-chat/src/client/features/index.ts +1 -0
  62. package/templates/realtime-chat/src/client/pages/index.ts +1 -0
  63. package/templates/realtime-chat/src/client/shared/index.ts +1 -0
  64. package/templates/realtime-chat/src/client/shared/lib/utils.ts +16 -0
  65. package/templates/realtime-chat/src/client/shared/ui/button.tsx +57 -0
  66. package/templates/realtime-chat/src/client/shared/ui/card.tsx +78 -0
  67. package/templates/realtime-chat/src/client/shared/ui/index.ts +21 -0
  68. package/templates/realtime-chat/src/client/shared/ui/input.tsx +28 -0
  69. package/templates/realtime-chat/src/client/widgets/index.ts +1 -0
  70. package/templates/realtime-chat/src/server/api/index.ts +1 -0
  71. package/templates/realtime-chat/src/server/application/ai-adapter.ts +24 -0
  72. package/templates/realtime-chat/src/server/application/chat-store.ts +158 -0
  73. package/templates/realtime-chat/src/server/application/index.ts +1 -0
  74. package/templates/realtime-chat/src/server/core/index.ts +1 -0
  75. package/templates/realtime-chat/src/server/domain/index.ts +1 -0
  76. package/templates/realtime-chat/src/server/infra/index.ts +1 -0
  77. package/templates/realtime-chat/src/shared/contracts/chat.ts +29 -0
  78. package/templates/realtime-chat/src/shared/contracts/index.ts +1 -0
  79. package/templates/realtime-chat/src/shared/env/index.ts +1 -0
  80. package/templates/realtime-chat/src/shared/schema/index.ts +1 -0
  81. package/templates/realtime-chat/src/shared/types/index.ts +1 -0
  82. package/templates/realtime-chat/src/shared/utils/client/index.ts +1 -0
  83. package/templates/realtime-chat/src/shared/utils/server/index.ts +1 -0
  84. package/templates/realtime-chat/tests/chat-api.sse.test.ts +188 -0
  85. package/templates/realtime-chat/tests/chat-starter.test.ts +200 -0
  86. package/templates/realtime-chat/tests/chat-store.concurrency.test.ts +39 -0
  87. package/templates/realtime-chat/tests/example.test.ts +58 -0
  88. package/templates/realtime-chat/tests/helpers.ts +52 -0
  89. package/templates/realtime-chat/tests/setup.ts +9 -0
  90. package/templates/realtime-chat/tsconfig.json +23 -0
@@ -5,10 +5,14 @@ export interface InputProps
5
5
  extends React.InputHTMLAttributes<HTMLInputElement> {}
6
6
 
7
7
  const Input = React.forwardRef<HTMLInputElement, InputProps>(
8
- ({ className, type, ...props }, ref) => {
8
+ ({ className, type, placeholder, "aria-label": ariaLabel, ...props }, ref) => {
9
+ const accessibleLabel = ariaLabel ?? (typeof placeholder === "string" ? placeholder : undefined);
10
+
9
11
  return (
10
12
  <input
11
13
  type={type}
14
+ placeholder={placeholder}
15
+ aria-label={accessibleLabel}
12
16
  className={cn(
13
17
  "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
14
18
  className
@@ -1,58 +1,58 @@
1
- // Mandu Example Test
2
- // 이 파일은 테스트 작성 방법을 보여주는 예제입니다.
3
-
4
- import { describe, it, expect } from "bun:test";
5
- import { createTestRequest, parseJsonResponse, assertStatus } from "./helpers";
6
-
7
- describe("Example Tests", () => {
8
- describe("Basic Assertions", () => {
9
- it("should pass basic equality test", () => {
10
- expect(1 + 1).toBe(2);
11
- });
12
-
13
- it("should pass object equality test", () => {
14
- const obj = { status: "ok", data: { message: "hello" } };
15
- expect(obj).toEqual({
16
- status: "ok",
17
- data: { message: "hello" },
18
- });
19
- });
20
- });
21
-
22
- describe("Test Helpers", () => {
23
- it("should create test request", () => {
24
- const req = createTestRequest("http://localhost:3000/api/test", {
25
- method: "POST",
26
- body: { name: "test" },
27
- });
28
-
29
- expect(req.method).toBe("POST");
30
- expect(req.url).toBe("http://localhost:3000/api/test");
31
- });
32
-
33
- it("should parse JSON response", async () => {
34
- const mockResponse = new Response(
35
- JSON.stringify({ status: "ok" }),
36
- { status: 200 }
37
- );
38
-
39
- const data = await parseJsonResponse<{ status: string }>(mockResponse);
40
- expect(data.status).toBe("ok");
41
- });
42
- });
43
- });
44
-
45
- // API 핸들러 테스트 예제 (실제 핸들러 import 후 사용)
46
- // import handler from "../.mandu/generated/server/routes/health.route";
47
- //
48
- // describe("API: GET /api/health", () => {
49
- // it("should return 200 with status ok", async () => {
50
- // const req = createTestRequest("http://localhost:3000/api/health");
51
- // const response = handler(req, {});
52
- //
53
- // assertStatus(response, 200);
54
- //
55
- // const data = await parseJsonResponse<{ status: string }>(response);
56
- // expect(data.status).toBe("ok");
57
- // });
58
- // });
1
+ // Mandu Example Test
2
+ // 이 파일은 테스트 작성 방법을 보여주는 예제입니다.
3
+
4
+ import { describe, it, expect } from "bun:test";
5
+ import { createTestRequest, parseJsonResponse, assertStatus } from "./helpers";
6
+
7
+ describe("Example Tests", () => {
8
+ describe("Basic Assertions", () => {
9
+ it("should pass basic equality test", () => {
10
+ expect(1 + 1).toBe(2);
11
+ });
12
+
13
+ it("should pass object equality test", () => {
14
+ const obj = { status: "ok", data: { message: "hello" } };
15
+ expect(obj).toEqual({
16
+ status: "ok",
17
+ data: { message: "hello" },
18
+ });
19
+ });
20
+ });
21
+
22
+ describe("Test Helpers", () => {
23
+ it("should create test request", () => {
24
+ const req = createTestRequest("http://localhost:3000/api/test", {
25
+ method: "POST",
26
+ body: { name: "test" },
27
+ });
28
+
29
+ expect(req.method).toBe("POST");
30
+ expect(req.url).toBe("http://localhost:3000/api/test");
31
+ });
32
+
33
+ it("should parse JSON response", async () => {
34
+ const mockResponse = new Response(
35
+ JSON.stringify({ status: "ok" }),
36
+ { status: 200 }
37
+ );
38
+
39
+ const data = await parseJsonResponse<{ status: string }>(mockResponse);
40
+ expect(data.status).toBe("ok");
41
+ });
42
+ });
43
+ });
44
+
45
+ // API 핸들러 테스트 예제 (실제 핸들러 import 후 사용)
46
+ // import handler from "../.mandu/generated/server/routes/health.route";
47
+ //
48
+ // describe("API: GET /api/health", () => {
49
+ // it("should return 200 with status ok", async () => {
50
+ // const req = createTestRequest("http://localhost:3000/api/health");
51
+ // const response = handler(req, {});
52
+ //
53
+ // assertStatus(response, 200);
54
+ //
55
+ // const data = await parseJsonResponse<{ status: string }>(response);
56
+ // expect(data.status).toBe("ok");
57
+ // });
58
+ // });
@@ -1,52 +1,52 @@
1
- // Mandu Test Helpers
2
- // 테스트에서 사용할 유틸리티 함수들
3
-
4
- import type { Request } from "bun";
5
-
6
- /**
7
- * API 핸들러 테스트용 Request 생성
8
- */
9
- export function createTestRequest(
10
- url: string,
11
- options?: {
12
- method?: string;
13
- body?: unknown;
14
- headers?: Record<string, string>;
15
- }
16
- ): Request {
17
- const { method = "GET", body, headers = {} } = options || {};
18
-
19
- return new Request(url, {
20
- method,
21
- headers: {
22
- "Content-Type": "application/json",
23
- ...headers,
24
- },
25
- body: body ? JSON.stringify(body) : undefined,
26
- });
27
- }
28
-
29
- /**
30
- * Response를 JSON으로 파싱
31
- */
32
- export async function parseJsonResponse<T = unknown>(response: Response): Promise<T> {
33
- return response.json() as Promise<T>;
34
- }
35
-
36
- /**
37
- * Response 상태 검증
38
- */
39
- export function assertStatus(response: Response, expectedStatus: number): void {
40
- if (response.status !== expectedStatus) {
41
- throw new Error(
42
- `Expected status ${expectedStatus}, got ${response.status}`
43
- );
44
- }
45
- }
46
-
47
- /**
48
- * 테스트용 라우트 파라미터 생성
49
- */
50
- export function createParams(params: Record<string, string>): Record<string, string> {
51
- return params;
52
- }
1
+ // Mandu Test Helpers
2
+ // 테스트에서 사용할 유틸리티 함수들
3
+
4
+ import type { Request } from "bun";
5
+
6
+ /**
7
+ * API 핸들러 테스트용 Request 생성
8
+ */
9
+ export function createTestRequest(
10
+ url: string,
11
+ options?: {
12
+ method?: string;
13
+ body?: unknown;
14
+ headers?: Record<string, string>;
15
+ }
16
+ ): Request {
17
+ const { method = "GET", body, headers = {} } = options || {};
18
+
19
+ return new Request(url, {
20
+ method,
21
+ headers: {
22
+ "Content-Type": "application/json",
23
+ ...headers,
24
+ },
25
+ body: body ? JSON.stringify(body) : undefined,
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Response를 JSON으로 파싱
31
+ */
32
+ export async function parseJsonResponse<T = unknown>(response: Response): Promise<T> {
33
+ return response.json() as Promise<T>;
34
+ }
35
+
36
+ /**
37
+ * Response 상태 검증
38
+ */
39
+ export function assertStatus(response: Response, expectedStatus: number): void {
40
+ if (response.status !== expectedStatus) {
41
+ throw new Error(
42
+ `Expected status ${expectedStatus}, got ${response.status}`
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 테스트용 라우트 파라미터 생성
49
+ */
50
+ export function createParams(params: Record<string, string>): Record<string, string> {
51
+ return params;
52
+ }
@@ -1,9 +1,9 @@
1
- // Mandu Test Setup
2
- // Bun 테스트 환경 설정
3
-
4
- // 테스트 타임아웃 설정 (필요 시)
5
- // import { setDefaultTimeout } from "bun:test";
6
- // setDefaultTimeout(10000);
7
-
8
- // 환경 변수 설정
9
- process.env.NODE_ENV = "test";
1
+ // Mandu Test Setup
2
+ // Bun 테스트 환경 설정
3
+
4
+ // 테스트 타임아웃 설정 (필요 시)
5
+ // import { setDefaultTimeout } from "bun:test";
6
+ // setDefaultTimeout(10000);
7
+
8
+ // 환경 변수 설정
9
+ process.env.NODE_ENV = "test";
@@ -1,23 +1,23 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "module": "ESNext",
5
- "moduleResolution": "bundler",
6
- "esModuleInterop": true,
7
- "strict": true,
8
- "skipLibCheck": true,
9
- "jsx": "react-jsx",
10
- "types": ["bun-types"],
11
- "baseUrl": ".",
12
- "paths": {
13
- "@/*": ["./src/*"]
14
- }
15
- },
16
- "include": [
17
- "app/**/*.ts",
18
- "app/**/*.tsx",
19
- "src/**/*.ts",
20
- "src/**/*.tsx"
21
- ],
22
- "exclude": ["node_modules"]
23
- }
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "jsx": "react-jsx",
10
+ "types": ["bun-types"],
11
+ "baseUrl": ".",
12
+ "paths": {
13
+ "@/*": ["./src/*"]
14
+ }
15
+ },
16
+ "include": [
17
+ "app/**/*.ts",
18
+ "app/**/*.tsx",
19
+ "src/**/*.ts",
20
+ "src/**/*.tsx"
21
+ ],
22
+ "exclude": ["node_modules"]
23
+ }
@@ -0,0 +1,96 @@
1
+ # AI Agent Instructions for Mandu Project
2
+
3
+ 이 프로젝트는 **Mandu Framework**로 구축되었습니다. AI 에이전트가 이 프로젝트를 다룰 때 아래 지침을 따라주세요.
4
+
5
+ ## 패키지 매니저: Bun (필수)
6
+
7
+ **⚠️ 중요: 이 프로젝트는 Bun만 사용합니다. npm/yarn/pnpm을 사용하지 마세요.**
8
+
9
+ ```bash
10
+ # ✅ 올바른 명령어
11
+ bun install # 의존성 설치
12
+ bun add <package> # 패키지 추가
13
+ bun remove <package> # 패키지 제거
14
+ bun run dev # 개발 서버 시작
15
+ bun run build # 프로덕션 빌드
16
+ bun test # 테스트 실행
17
+
18
+ # ❌ 사용 금지
19
+ npm install / yarn install / pnpm install
20
+ ```
21
+
22
+ ## 프로젝트 구조
23
+
24
+ ```
25
+ ├── app/ # FS 기반 라우팅 (페이지, API)
26
+ │ ├── page.tsx # / 라우트
27
+ │ ├── layout.tsx # 루트 레이아웃
28
+ │ ├── globals.css # Tailwind CSS (v4)
29
+ │ └── api/ # API 라우트
30
+ ├── src/
31
+ │ ├── client/ # 클라이언트 코드 (FSD 구조)
32
+ │ │ ├── shared/ # 공용 UI, 유틸리티
33
+ │ │ ├── entities/ # 엔티티 컴포넌트
34
+ │ │ ├── features/ # 기능 컴포넌트
35
+ │ │ └── widgets/ # 위젯/Island 컴포넌트
36
+ │ ├── server/ # 서버 코드 (Clean Architecture)
37
+ │ │ ├── domain/ # 도메인 모델
38
+ │ │ ├── application/ # 비즈니스 로직
39
+ │ │ └── infra/ # 인프라/DB
40
+ │ └── shared/ # 클라이언트-서버 공유 코드
41
+ │ ├── contracts/ # API 계약 타입
42
+ │ └── types/ # 공용 타입
43
+ └── mandu.config.ts # Mandu 설정 (선택)
44
+ ```
45
+
46
+ ## 주요 규칙
47
+
48
+ ### 1. Island 컴포넌트
49
+ 클라이언트 상호작용이 필요한 컴포넌트는 `*.island.tsx`로 명명:
50
+ ```tsx
51
+ // src/client/widgets/counter/Counter.island.tsx
52
+ "use client";
53
+ export function CounterIsland() { ... }
54
+ ```
55
+
56
+ ### 2. API 라우트
57
+ `app/api/` 폴더에 `route.ts` 파일로 정의:
58
+ ```typescript
59
+ // app/api/users/route.ts
60
+ import { Mandu } from "@mandujs/core";
61
+ export default Mandu.filling()
62
+ .get((ctx) => ctx.ok({ users: [] }))
63
+ .post(async (ctx) => { ... });
64
+ ```
65
+
66
+ ### 3. Tailwind CSS v4
67
+ CSS-first 설정 사용 (`tailwind.config.ts` 없음):
68
+ ```css
69
+ /* app/globals.css */
70
+ @import "tailwindcss";
71
+ @theme {
72
+ --color-primary: hsl(222.2 47.4% 11.2%);
73
+ }
74
+ ```
75
+
76
+ ### 4. Import Alias
77
+ `@/` = `src/` 경로:
78
+ ```typescript
79
+ import { Button } from "@/client/shared/ui/button";
80
+ ```
81
+
82
+ ## 실행 방법
83
+
84
+ ```bash
85
+ bun install # 최초 설치
86
+ bun run dev # 개발 서버 (http://localhost:4000)
87
+ bun run build # 프로덕션 빌드
88
+ bun run guard # 아키텍처 검증
89
+ ```
90
+
91
+ ## 기술 스택
92
+
93
+ - **Runtime**: Bun 1.x
94
+ - **Framework**: Mandu (React 19 + Bun native)
95
+ - **Styling**: Tailwind CSS v4
96
+ - **Language**: TypeScript 5.x
@@ -0,0 +1,63 @@
1
+ import { getAIAdapter } from "@/server/application/ai-adapter";
2
+ import { appendMessage, getMessages } from "@/server/application/chat-store";
3
+ import {
4
+ isChatMessagePayload,
5
+ type ChatHistoryResponse,
6
+ type ChatMessageResponse,
7
+ } from "@/shared/contracts/chat";
8
+ import { createRateLimiter } from "@mandujs/core/runtime/server.ts";
9
+
10
+ // Rate limiter: 1분당 10개 메시지로 제한
11
+ const limiter = createRateLimiter({ max: 10, windowMs: 60000 });
12
+
13
+ export function GET(request: Request): Response {
14
+ // GET 요청은 제한을 느슨하게 (1분당 30회)
15
+ const getDecision = createRateLimiter({ max: 30, windowMs: 60000 }).check(request, "chat-messages-get");
16
+ if (!getDecision.allowed) {
17
+ return limiter.createResponse(getDecision);
18
+ }
19
+
20
+ const body: ChatHistoryResponse = { messages: getMessages() };
21
+ const response = Response.json(body);
22
+ return limiter.addHeaders(response, getDecision);
23
+ }
24
+
25
+ export async function POST(request: Request): Promise<Response> {
26
+ // Rate limiting 체크
27
+ const decision = limiter.check(request, "chat-messages-post");
28
+ if (!decision.allowed) {
29
+ return limiter.createResponse(decision);
30
+ }
31
+
32
+ let payload: unknown;
33
+
34
+ try {
35
+ payload = (await request.json()) as unknown;
36
+ } catch {
37
+ return Response.json({ error: "Invalid JSON payload" }, { status: 400 });
38
+ }
39
+
40
+ if (!isChatMessagePayload(payload)) {
41
+ return Response.json({ error: "Invalid payload" }, { status: 400 });
42
+ }
43
+
44
+ const user = appendMessage("user", payload.text.trim());
45
+ const adapter = getAIAdapter();
46
+
47
+ try {
48
+ const completion = await adapter.complete({
49
+ userText: user.text,
50
+ history: getMessages(),
51
+ });
52
+
53
+ if (completion && completion.trim().length > 0) {
54
+ appendMessage("assistant", completion);
55
+ }
56
+ } catch {
57
+ // Keep user message committed even when assistant completion fails.
58
+ }
59
+
60
+ const body: ChatMessageResponse = { message: user };
61
+ const response = Response.json(body, { status: 201 });
62
+ return limiter.addHeaders(response, decision);
63
+ }
@@ -0,0 +1,85 @@
1
+ import {
2
+ planResumeFrom,
3
+ subscribeWithSnapshot,
4
+ } from "@/server/application/chat-store";
5
+ import type { ChatMessage, ChatStreamEvent } from "@/shared/contracts/chat";
6
+ import { createSSEConnection } from "@mandujs/core";
7
+ import { createRateLimiter } from "@mandujs/core/runtime/server.ts";
8
+
9
+ // Rate limiter: 1분당 5개 연결로 제한 (SSE는 장시간 유지되므로 보수적으로 설정)
10
+ const limiter = createRateLimiter({ max: 5, windowMs: 60000 });
11
+
12
+ function getLastEventId(request: Request): string | null {
13
+ const fromHeader = request.headers.get("last-event-id");
14
+ if (fromHeader && fromHeader.trim()) return fromHeader.trim();
15
+
16
+ const url = new URL(request.url);
17
+ const fromQuery = url.searchParams.get("lastEventId");
18
+ if (fromQuery && fromQuery.trim()) return fromQuery.trim();
19
+
20
+ return null;
21
+ }
22
+
23
+ export function GET(request: Request): Response {
24
+ // Rate limiting 체크
25
+ const decision = limiter.check(request, "chat-stream");
26
+ if (!decision.allowed) {
27
+ return limiter.createResponse(decision);
28
+ }
29
+
30
+ const sse = createSSEConnection(request.signal);
31
+ const lastEventId = getLastEventId(request);
32
+
33
+ const subscribeToLiveMessages = () => {
34
+ const subscription = subscribeWithSnapshot((event) => {
35
+ const streamEvent: ChatStreamEvent = {
36
+ type: "message",
37
+ data: event.message,
38
+ };
39
+ sse.send(streamEvent, { id: event.eventId });
40
+ });
41
+
42
+ return subscription;
43
+ };
44
+
45
+ const resume = planResumeFrom(lastEventId);
46
+
47
+ if (resume.mode === "catch-up") {
48
+ for (const event of resume.events) {
49
+ const streamEvent: ChatStreamEvent = {
50
+ type: "message",
51
+ data: event.message,
52
+ };
53
+ sse.send(streamEvent, { id: event.eventId });
54
+ }
55
+
56
+ const liveSubscription = subscribeToLiveMessages();
57
+ const unsubscribe = liveSubscription.commit();
58
+ sse.onClose(() => unsubscribe());
59
+ } else {
60
+ const snapshotSubscription = subscribeToLiveMessages();
61
+
62
+ const snapshot: ChatStreamEvent = {
63
+ type: "snapshot",
64
+ data: snapshotSubscription.snapshot,
65
+ };
66
+ sse.send(snapshot);
67
+
68
+ const unsubscribe = snapshotSubscription.commit();
69
+ sse.onClose(() => unsubscribe());
70
+ }
71
+
72
+ const interval = setInterval(() => {
73
+ const heartbeat: ChatStreamEvent = {
74
+ type: "heartbeat",
75
+ data: { ts: new Date().toISOString() },
76
+ };
77
+ sse.send(heartbeat);
78
+ }, 15000);
79
+
80
+ sse.onClose(() => {
81
+ clearInterval(interval);
82
+ });
83
+
84
+ return sse.response;
85
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Health Check API
3
+ *
4
+ * GET /api/health
5
+ */
6
+
7
+ export function GET() {
8
+ return Response.json({
9
+ status: "ok",
10
+ timestamp: new Date().toISOString(),
11
+ framework: "Mandu",
12
+ });
13
+ }
@@ -0,0 +1,49 @@
1
+ @import "tailwindcss";
2
+
3
+ /*
4
+ * Tailwind CSS v4 - CSS-first Configuration
5
+ * https://tailwindcss.com/docs/v4
6
+ */
7
+
8
+ @theme {
9
+ /* Colors - shadcn/ui compatible */
10
+ --color-background: hsl(0 0% 100%);
11
+ --color-foreground: hsl(222.2 84% 4.9%);
12
+ --color-card: hsl(0 0% 100%);
13
+ --color-card-foreground: hsl(222.2 84% 4.9%);
14
+ --color-popover: hsl(0 0% 100%);
15
+ --color-popover-foreground: hsl(222.2 84% 4.9%);
16
+ --color-primary: hsl(222.2 47.4% 11.2%);
17
+ --color-primary-foreground: hsl(210 40% 98%);
18
+ --color-secondary: hsl(210 40% 96.1%);
19
+ --color-secondary-foreground: hsl(222.2 47.4% 11.2%);
20
+ --color-muted: hsl(210 40% 96.1%);
21
+ --color-muted-foreground: hsl(215.4 16.3% 46.9%);
22
+ --color-accent: hsl(210 40% 96.1%);
23
+ --color-accent-foreground: hsl(222.2 47.4% 11.2%);
24
+ --color-destructive: hsl(0 84.2% 60.2%);
25
+ --color-destructive-foreground: hsl(210 40% 98%);
26
+ --color-border: hsl(214.3 31.8% 91.4%);
27
+ --color-input: hsl(214.3 31.8% 91.4%);
28
+ --color-ring: hsl(222.2 84% 4.9%);
29
+
30
+ /* Radius */
31
+ --radius-sm: 0.25rem;
32
+ --radius-md: 0.5rem;
33
+ --radius-lg: 0.75rem;
34
+ --radius-xl: 1rem;
35
+
36
+ /* Fonts */
37
+ --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
38
+ }
39
+
40
+ /* Base styles */
41
+ * {
42
+ border-color: var(--color-border);
43
+ }
44
+
45
+ body {
46
+ background-color: var(--color-background);
47
+ color: var(--color-foreground);
48
+ font-family: var(--font-sans);
49
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Root Layout
3
+ *
4
+ * 모든 페이지의 공통 레이아웃
5
+ * globals.css를 여기서 임포트
6
+ */
7
+
8
+ import "./globals.css";
9
+
10
+ interface RootLayoutProps {
11
+ children: React.ReactNode;
12
+ }
13
+
14
+ export default function RootLayout({ children }: RootLayoutProps) {
15
+ return (
16
+ <html lang="ko">
17
+ <head>
18
+ <meta charSet="UTF-8" />
19
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
20
+ <title>{{PROJECT_NAME}}</title>
21
+ </head>
22
+ <body className="min-h-screen bg-background font-sans antialiased">
23
+ {children}
24
+ </body>
25
+ </html>
26
+ );
27
+ }
@@ -0,0 +1,16 @@
1
+ import { RealtimeChatStarter } from "@/client/features/chat/realtime-chat-starter.client";
2
+
3
+ export default function HomePage() {
4
+ return (
5
+ <main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col p-6">
6
+ <header className="mb-6">
7
+ <h1 className="text-3xl font-bold">🥟 Mandu Realtime Chat Starter</h1>
8
+ <p className="mt-2 text-sm text-muted-foreground">
9
+ Official starter template with typed client hook, message API, and SSE stream.
10
+ </p>
11
+ </header>
12
+
13
+ <RealtimeChatStarter />
14
+ </main>
15
+ );
16
+ }