@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.
@@ -8,6 +8,8 @@ export { ManduContext, ValidationError, CookieManager } from "./context";
8
8
  export type { CookieOptions } from "./context";
9
9
  export { ManduFilling, ManduFillingFactory, LoaderTimeoutError } from "./filling";
10
10
  export type { Handler, Guard, HttpMethod, Loader, LoaderOptions } from "./filling";
11
+ export { SSEConnection, createSSEConnection } from "./sse";
12
+ export type { SSEOptions, SSESendOptions, SSECleanup } from "./sse";
11
13
 
12
14
  // Auth Guards
13
15
  export {
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { createSSEConnection } from "./sse";
3
+ import { ManduContext } from "./context";
4
+
5
+ async function readChunk(response: Response): Promise<string> {
6
+ const reader = response.body?.getReader();
7
+ if (!reader) throw new Error("Missing response body");
8
+
9
+ const { value, done } = await reader.read();
10
+ if (done || !value) return "";
11
+ return new TextDecoder().decode(value);
12
+ }
13
+
14
+ describe("SSEConnection", () => {
15
+ it("streams real-time chunks and finishes with done=true after close", async () => {
16
+ const server = Bun.serve({
17
+ port: 0,
18
+ fetch(req) {
19
+ const ctx = new ManduContext(req);
20
+ return ctx.sse(async (sse) => {
21
+ sse.event("tick", { step: 1 }, { id: "1" });
22
+ await new Promise((resolve) => setTimeout(resolve, 20));
23
+ sse.event("tick", { step: 2 }, { id: "2" });
24
+ await sse.close();
25
+ });
26
+ },
27
+ });
28
+
29
+ try {
30
+ const response = await fetch(`http://127.0.0.1:${server.port}/stream`);
31
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
32
+
33
+ const reader = response.body?.getReader();
34
+ if (!reader) throw new Error("Missing response body");
35
+
36
+ const firstRead = await reader.read();
37
+ expect(firstRead.done).toBe(false);
38
+ const firstChunk = new TextDecoder().decode(firstRead.value);
39
+ expect(firstChunk).toContain("event: tick");
40
+ expect(firstChunk).toContain('data: {"step":1}');
41
+
42
+ const secondRead = await reader.read();
43
+ expect(secondRead.done).toBe(false);
44
+ const secondChunk = new TextDecoder().decode(secondRead.value);
45
+ expect(secondChunk).toContain('data: {"step":2}');
46
+
47
+ const finalRead = await reader.read();
48
+ expect(finalRead.done).toBe(true);
49
+ } finally {
50
+ server.stop(true);
51
+ }
52
+ });
53
+
54
+ it("closes stream when context-level SSE setup throws (error path)", async () => {
55
+ const ctx = new ManduContext(new Request("http://localhost/realtime-error"));
56
+
57
+ const response = ctx.sse(async () => {
58
+ throw new Error("setup failed");
59
+ });
60
+
61
+ const reader = response.body?.getReader();
62
+ if (!reader) throw new Error("Missing response body");
63
+
64
+ // setup error is swallowed intentionally, but stream must close cleanly.
65
+ await Promise.resolve();
66
+ const read = await reader.read();
67
+ expect(read.done).toBe(true);
68
+ });
69
+ it("sends SSE event payload with metadata", async () => {
70
+ const sse = createSSEConnection();
71
+
72
+ sse.send({ ok: true }, { event: "ready", id: "1", retry: 3000 });
73
+ const chunk = await readChunk(sse.response);
74
+
75
+ expect(chunk).toContain("event: ready");
76
+ expect(chunk).toContain("id: 1");
77
+ expect(chunk).toContain("retry: 3000");
78
+ expect(chunk).toContain('data: {"ok":true}');
79
+ });
80
+
81
+ it("sanitizes event/id fields to prevent SSE injection", async () => {
82
+ const sse = createSSEConnection();
83
+
84
+ sse.send("payload", {
85
+ event: "update\nretry:0",
86
+ id: "abc\r\ndata: injected",
87
+ });
88
+
89
+ const chunk = await readChunk(sse.response);
90
+ expect(chunk).toContain("event: update retry:0");
91
+ expect(chunk).toContain("id: abc data: injected");
92
+ expect(chunk).not.toContain("\nevent: update\nretry:0\n");
93
+ });
94
+
95
+ it("normalizes payload lines across CR/LF variants", async () => {
96
+ const sse = createSSEConnection();
97
+
98
+ sse.send("a\rb\nc\r\nd");
99
+ const chunk = await readChunk(sse.response);
100
+
101
+ expect(chunk).toContain("data: a");
102
+ expect(chunk).toContain("data: b");
103
+ expect(chunk).toContain("data: c");
104
+ expect(chunk).toContain("data: d");
105
+ });
106
+
107
+ it("registers heartbeat and cleanup on close", async () => {
108
+ const sse = createSSEConnection();
109
+ const cleanup = mock(() => {});
110
+
111
+ const stop = sse.heartbeat(1000, "ping");
112
+ sse.onClose(cleanup);
113
+
114
+ await sse.close();
115
+
116
+ expect(cleanup).toHaveBeenCalledTimes(1);
117
+ expect(typeof stop).toBe("function");
118
+ });
119
+
120
+ it("continues closing when cleanup handlers throw", async () => {
121
+ const sse = createSSEConnection();
122
+ const badCleanup = mock(() => {
123
+ throw new Error("cleanup failed");
124
+ });
125
+ const goodCleanup = mock(() => {});
126
+
127
+ sse.onClose(badCleanup);
128
+ sse.onClose(goodCleanup);
129
+
130
+ await expect(sse.close()).resolves.toBeUndefined();
131
+ expect(badCleanup).toHaveBeenCalledTimes(1);
132
+ expect(goodCleanup).toHaveBeenCalledTimes(1);
133
+ });
134
+
135
+ it("does not throw when registering onClose after already closed", async () => {
136
+ const sse = createSSEConnection();
137
+ await sse.close();
138
+
139
+ const badCleanup = () => Promise.reject(new Error("late cleanup failed"));
140
+ expect(() => sse.onClose(badCleanup)).not.toThrow();
141
+ });
142
+
143
+ it("closes automatically when request signal aborts", async () => {
144
+ const controller = new AbortController();
145
+ const sse = createSSEConnection(controller.signal);
146
+ const cleanup = mock(() => {});
147
+
148
+ sse.onClose(cleanup);
149
+ controller.abort();
150
+
151
+ await Promise.resolve();
152
+
153
+ expect(cleanup).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ it("supports context-level SSE response helper", async () => {
157
+ const ctx = new ManduContext(new Request("http://localhost/realtime"));
158
+
159
+ const response = ctx.sse((sse) => {
160
+ sse.event("message", "hello");
161
+ });
162
+
163
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
164
+ const chunk = await readChunk(response);
165
+ expect(chunk).toContain("event: message");
166
+ expect(chunk).toContain("data: hello");
167
+ });
168
+ });
@@ -0,0 +1,162 @@
1
+ export interface SSEOptions {
2
+ /** Additional headers merged into default SSE headers */
3
+ headers?: HeadersInit;
4
+ /** HTTP status code (default: 200) */
5
+ status?: number;
6
+ }
7
+
8
+ export interface SSESendOptions {
9
+ event?: string;
10
+ id?: string;
11
+ retry?: number;
12
+ }
13
+
14
+ export type SSECleanup = () => void | Promise<void>;
15
+
16
+ function sanitizeSingleLineField(value: string): string {
17
+ return value.replace(/[\r\n]+/g, " ").trim();
18
+ }
19
+
20
+ function splitSSELines(value: string): string[] {
21
+ return value.split(/\r\n|[\r\n]/);
22
+ }
23
+
24
+ const DEFAULT_SSE_HEADERS: HeadersInit = {
25
+ "Content-Type": "text/event-stream; charset=utf-8",
26
+ "Cache-Control": "no-cache, no-transform",
27
+ Connection: "keep-alive",
28
+ "X-Accel-Buffering": "no",
29
+ };
30
+
31
+ export class SSEConnection {
32
+ readonly response: Response;
33
+
34
+ private controller: ReadableStreamDefaultController<Uint8Array> | null = null;
35
+ private encoder = new TextEncoder();
36
+ private pending: string[] = [];
37
+ private closed = false;
38
+ private cleanupHandlers: SSECleanup[] = [];
39
+
40
+ constructor(signal?: AbortSignal, options: SSEOptions = {}) {
41
+ const stream = new ReadableStream<Uint8Array>({
42
+ start: (controller) => {
43
+ this.controller = controller;
44
+ for (const chunk of this.pending) {
45
+ controller.enqueue(this.encoder.encode(chunk));
46
+ }
47
+ this.pending = [];
48
+ },
49
+ cancel: () => {
50
+ this.close();
51
+ },
52
+ });
53
+
54
+ const headers = new Headers(DEFAULT_SSE_HEADERS);
55
+ if (options.headers) {
56
+ const extra = new Headers(options.headers);
57
+ extra.forEach((value, key) => headers.set(key, value));
58
+ }
59
+
60
+ this.response = new Response(stream, {
61
+ status: options.status ?? 200,
62
+ headers,
63
+ });
64
+
65
+ signal?.addEventListener("abort", () => this.close(), { once: true });
66
+ }
67
+
68
+ send(data: unknown, options: SSESendOptions = {}): void {
69
+ if (this.closed) return;
70
+
71
+ const lines: string[] = [];
72
+
73
+ if (options.event) {
74
+ const safeEvent = sanitizeSingleLineField(options.event);
75
+ if (safeEvent) lines.push(`event: ${safeEvent}`);
76
+ }
77
+ if (options.id) {
78
+ const safeId = sanitizeSingleLineField(options.id);
79
+ if (safeId) lines.push(`id: ${safeId}`);
80
+ }
81
+ if (typeof options.retry === "number") lines.push(`retry: ${Math.max(0, Math.floor(options.retry))}`);
82
+
83
+ const payload = typeof data === "string" ? data : JSON.stringify(data);
84
+ const payloadLines = splitSSELines(payload);
85
+ for (const line of payloadLines) {
86
+ lines.push(`data: ${line}`);
87
+ }
88
+
89
+ this.enqueue(`${lines.join("\n")}\n\n`);
90
+ }
91
+
92
+ event(name: string, data: unknown, options: Omit<SSESendOptions, "event"> = {}): void {
93
+ this.send(data, { ...options, event: name });
94
+ }
95
+
96
+ comment(text: string): void {
97
+ if (this.closed) return;
98
+ const lines = splitSSELines(text).map((line) => `: ${line}`);
99
+ this.enqueue(`${lines.join("\n")}\n\n`);
100
+ }
101
+
102
+ heartbeat(intervalMs = 15000, comment = "heartbeat"): () => void {
103
+ const timer = setInterval(() => {
104
+ if (this.closed) return;
105
+ this.comment(comment);
106
+ }, Math.max(1000, intervalMs));
107
+
108
+ const stop = () => clearInterval(timer);
109
+ this.onClose(stop);
110
+ return stop;
111
+ }
112
+
113
+ onClose(handler: SSECleanup): void {
114
+ if (this.closed) {
115
+ void Promise.resolve(handler()).catch(() => {
116
+ // ignore cleanup errors after close
117
+ });
118
+ return;
119
+ }
120
+ this.cleanupHandlers.push(handler);
121
+ }
122
+
123
+ async close(): Promise<void> {
124
+ if (this.closed) return;
125
+ this.closed = true;
126
+
127
+ try {
128
+ this.controller?.close();
129
+ } catch {
130
+ // no-op
131
+ }
132
+
133
+ const handlers = [...this.cleanupHandlers];
134
+ this.cleanupHandlers = [];
135
+ for (const handler of handlers) {
136
+ try {
137
+ await handler();
138
+ } catch {
139
+ // continue running remaining cleanup handlers
140
+ }
141
+ }
142
+ }
143
+
144
+ private enqueue(chunk: string): void {
145
+ if (this.controller) {
146
+ try {
147
+ this.controller.enqueue(this.encoder.encode(chunk));
148
+ } catch {
149
+ void this.close();
150
+ }
151
+ return;
152
+ }
153
+ this.pending.push(chunk);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Create a production-safe Server-Sent Events connection helper.
159
+ */
160
+ export function createSSEConnection(signal?: AbortSignal, options: SSEOptions = {}): SSEConnection {
161
+ return new SSEConnection(signal, options);
162
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { RouteSpec } from "../spec/schema";
7
+ import { GENERATED_RELATIVE_PATHS } from "../paths";
7
8
 
8
9
  /**
9
10
  * Convert string to PascalCase
@@ -63,7 +64,7 @@ function computeRelativePath(fromDir: string, toPath: string): string {
63
64
  */
64
65
  export function generateContractTypeGlue(
65
66
  route: RouteSpec,
66
- typesDir: string = "apps/server/generated/types"
67
+ typesDir: string = GENERATED_RELATIVE_PATHS.types
67
68
  ): string {
68
69
  if (!route.contractModule) {
69
70
  return "";
@@ -3,6 +3,7 @@ import { generateApiHandler, generatePageComponent, generateSlotLogic } from "./
3
3
  import { generateContractTypeGlue, generateContractTemplate, generateContractTypesIndex } from "./contract-glue";
4
4
  import { computeHash } from "../spec/lock";
5
5
  import { getWatcher } from "../watcher/watcher";
6
+ import { resolveGeneratedPaths, GENERATED_RELATIVE_PATHS } from "../paths";
6
7
  import path from "path";
7
8
  import fs from "fs/promises";
8
9
  import fsSync from "fs";
@@ -141,10 +142,11 @@ export async function generateRoutes(
141
142
  const watcher = getWatcher();
142
143
  watcher?.suppress();
143
144
 
144
- const serverRoutesDir = path.join(rootDir, "apps/server/generated/routes");
145
- const webRoutesDir = path.join(rootDir, "apps/web/generated/routes");
146
- const typesDir = path.join(rootDir, "apps/server/generated/types");
147
- const mapDir = path.join(rootDir, "packages/core/map");
145
+ const generatedPaths = resolveGeneratedPaths(rootDir);
146
+ const serverRoutesDir = generatedPaths.serverRoutesDir;
147
+ const webRoutesDir = generatedPaths.webRoutesDir;
148
+ const typesDir = generatedPaths.typesDir;
149
+ const mapDir = generatedPaths.mapDir;
148
150
 
149
151
  await ensureDir(serverRoutesDir);
150
152
  await ensureDir(webRoutesDir);
@@ -155,7 +157,7 @@ export async function generateRoutes(
155
157
  version: manifest.version,
156
158
  generatedAt: new Date().toISOString(),
157
159
  specSource: {
158
- path: "spec/routes.manifest.json",
160
+ path: ".mandu/routes.manifest.json",
159
161
  hash: computeHash(manifest),
160
162
  },
161
163
  files: {},
@@ -177,7 +179,7 @@ export async function generateRoutes(
177
179
  try {
178
180
  // Spec 위치 정보
179
181
  const specLocation: SpecLocation = {
180
- file: "spec/routes.manifest.json",
182
+ file: ".mandu/routes.manifest.json",
181
183
  routeIndex,
182
184
  jsonPath: `routes[${routeIndex}]`,
183
185
  };
@@ -221,19 +223,19 @@ export async function generateRoutes(
221
223
  const typeFilePath = path.join(typesDir, typeFileName);
222
224
  expectedTypeFiles.add(typeFileName);
223
225
 
224
- const typeGlueContent = generateContractTypeGlue(route, "apps/server/generated/types");
226
+ const typeGlueContent = generateContractTypeGlue(route, GENERATED_RELATIVE_PATHS.types);
225
227
  await Bun.write(typeFilePath, typeGlueContent);
226
228
  result.created.push(typeFilePath);
227
229
 
228
230
  contractMapping = {
229
231
  contractPath: route.contractModule,
230
- typeGluePath: `apps/server/generated/types/${typeFileName}`,
232
+ typeGluePath: `${GENERATED_RELATIVE_PATHS.types}/${typeFileName}`,
231
233
  };
232
234
 
233
235
  routesWithContracts.push(route.id);
234
236
  }
235
237
 
236
- generatedMap.files[`apps/server/generated/routes/${serverFileName}`] = {
238
+ generatedMap.files[`${GENERATED_RELATIVE_PATHS.serverRoutes}/${serverFileName}`] = {
237
239
  routeId: route.id,
238
240
  kind: route.kind as "api" | "page",
239
241
  specLocation,
@@ -269,7 +271,7 @@ export async function generateRoutes(
269
271
  await Bun.write(webFilePath, componentContent);
270
272
  result.created.push(webFilePath);
271
273
 
272
- generatedMap.files[`apps/web/generated/routes/${webFileName}`] = {
274
+ generatedMap.files[`${GENERATED_RELATIVE_PATHS.webRoutes}/${webFileName}`] = {
273
275
  routeId: route.id,
274
276
  kind: route.kind,
275
277
  specLocation,
@@ -1,4 +1,5 @@
1
1
  import type { RouteSpec } from "../spec/schema";
2
+ import { GENERATED_RELATIVE_PATHS } from "../paths";
2
3
 
3
4
  export function generateApiHandler(route: RouteSpec): string {
4
5
  // contractModule + slotModule이 있으면 contract 검증 버전 생성
@@ -29,7 +30,7 @@ export default function handler(req: Request, params: Record<string, string>): R
29
30
  }
30
31
 
31
32
  export function generateApiHandlerWithSlot(route: RouteSpec): string {
32
- const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/server/generated/routes");
33
+ const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
33
34
 
34
35
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
35
36
  // Route ID: ${route.id}
@@ -49,8 +50,8 @@ export default async function handler(
49
50
  }
50
51
 
51
52
  export function generateApiHandlerWithContract(route: RouteSpec): string {
52
- const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/server/generated/routes");
53
- const contractImportPath = computeSlotImportPath(route.contractModule!, "apps/server/generated/routes");
53
+ const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
54
+ const contractImportPath = computeSlotImportPath(route.contractModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
54
55
 
55
56
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
56
57
  // Route ID: ${route.id}
@@ -110,14 +111,14 @@ export default async function handler(
110
111
  `;
111
112
  }
112
113
 
113
- export function generateSlotLogic(route: RouteSpec): string {
114
- if (route.contractModule) {
115
- return generateSlotLogicWithContract(route);
116
- }
117
-
118
- return `// 🥟 Mandu Filling - ${route.id}
119
- // Pattern: ${route.pattern}
120
- // 이 파일에서 비즈니스 로직을 구현하세요.
114
+ export function generateSlotLogic(route: RouteSpec): string {
115
+ if (route.contractModule) {
116
+ return generateSlotLogicWithContract(route);
117
+ }
118
+
119
+ return `// 🥟 Mandu Filling - ${route.id}
120
+ // Pattern: ${route.pattern}
121
+ // 이 파일에서 비즈니스 로직을 구현하세요.
121
122
 
122
123
  import { Mandu } from "@mandujs/core";
123
124
 
@@ -149,67 +150,67 @@ export default Mandu.filling()
149
150
  // 💡 Context (ctx) API:
150
151
  // ctx.query - Query parameters { name: 'value' }
151
152
  // ctx.params - Path parameters { id: '123' }
152
- // ctx.body() - Request body (자동 파싱)
153
- // ctx.body(zodSchema) - Body with validation
154
- // ctx.headers - Request headers
155
- // ctx.ok(data) - 200 OK
156
- // ctx.created(data) - 201 Created
157
- // ctx.error(msg) - 400 Bad Request
158
- // ctx.notFound(msg) - 404 Not Found
159
- // ctx.set(key, value) - Guard에서 Handler로 데이터 전달
160
- // ctx.get(key) - Guard에서 설정한 데이터 읽기
161
- `;
162
- }
163
-
164
- export function generateSlotLogicWithContract(route: RouteSpec): string {
165
- const contractImportPath = computeSlotImportPath(
166
- route.contractModule!,
167
- pathDirname(route.slotModule ?? "spec/slots")
168
- );
169
-
170
- return `// 🥟 Mandu Filling - ${route.id}
171
- // Pattern: ${route.pattern}
172
- // Contract Module: ${route.contractModule}
173
- // 이 파일에서 비즈니스 로직을 구현하세요.
174
-
175
- import { Mandu } from "@mandujs/core";
176
- import contract from "${contractImportPath}";
177
-
178
- export default Mandu.filling()
179
- // 📋 GET ${route.pattern}
180
- .get(async (ctx) => {
181
- const input = await ctx.input(contract, "GET", ctx.params);
182
- // TODO: 계약의 응답 코드에 맞게 status를 조정하세요
183
- return ctx.output(contract, 200, {
184
- message: "Hello from ${route.id}!",
185
- input,
186
- timestamp: new Date().toISOString(),
187
- });
188
- })
189
-
190
- // ➕ POST ${route.pattern}
191
- .post(async (ctx) => {
192
- const input = await ctx.input(contract, "POST", ctx.params);
193
- // TODO: 계약의 응답 코드에 맞게 status를 조정하세요
194
- return ctx.output(contract, 201, {
195
- message: "Created!",
196
- input,
197
- timestamp: new Date().toISOString(),
198
- });
199
- });
200
-
201
- // 💡 Contract 기반 사용법:
202
- // ctx.input(contract, "GET") - Contract로 요청 검증 + 정규화
203
- // ctx.output(contract, 200, data) - Contract로 응답 검증
204
- // ctx.okContract(contract, data) - 200 OK (Contract 검증)
205
- // ctx.createdContract(contract, data) - 201 Created (Contract 검증)
206
- `;
207
- }
153
+ // ctx.body() - Request body (자동 파싱)
154
+ // ctx.body(zodSchema) - Body with validation
155
+ // ctx.headers - Request headers
156
+ // ctx.ok(data) - 200 OK
157
+ // ctx.created(data) - 201 Created
158
+ // ctx.error(msg) - 400 Bad Request
159
+ // ctx.notFound(msg) - 404 Not Found
160
+ // ctx.set(key, value) - Guard에서 Handler로 데이터 전달
161
+ // ctx.get(key) - Guard에서 설정한 데이터 읽기
162
+ `;
163
+ }
164
+
165
+ export function generateSlotLogicWithContract(route: RouteSpec): string {
166
+ const contractImportPath = computeSlotImportPath(
167
+ route.contractModule!,
168
+ pathDirname(route.slotModule ?? "spec/slots")
169
+ );
170
+
171
+ return `// 🥟 Mandu Filling - ${route.id}
172
+ // Pattern: ${route.pattern}
173
+ // Contract Module: ${route.contractModule}
174
+ // 이 파일에서 비즈니스 로직을 구현하세요.
175
+
176
+ import { Mandu } from "@mandujs/core";
177
+ import contract from "${contractImportPath}";
178
+
179
+ export default Mandu.filling()
180
+ // 📋 GET ${route.pattern}
181
+ .get(async (ctx) => {
182
+ const input = await ctx.input(contract, "GET", ctx.params);
183
+ // TODO: 계약의 응답 코드에 맞게 status를 조정하세요
184
+ return ctx.output(contract, 200, {
185
+ message: "Hello from ${route.id}!",
186
+ input,
187
+ timestamp: new Date().toISOString(),
188
+ });
189
+ })
190
+
191
+ // ➕ POST ${route.pattern}
192
+ .post(async (ctx) => {
193
+ const input = await ctx.input(contract, "POST", ctx.params);
194
+ // TODO: 계약의 응답 코드에 맞게 status를 조정하세요
195
+ return ctx.output(contract, 201, {
196
+ message: "Created!",
197
+ input,
198
+ timestamp: new Date().toISOString(),
199
+ });
200
+ });
201
+
202
+ // 💡 Contract 기반 사용법:
203
+ // ctx.input(contract, "GET") - Contract로 요청 검증 + 정규화
204
+ // ctx.output(contract, 200, data) - Contract로 응답 검증
205
+ // ctx.okContract(contract, data) - 200 OK (Contract 검증)
206
+ // ctx.createdContract(contract, data) - 201 Created (Contract 검증)
207
+ `;
208
+ }
208
209
 
209
210
  function computeSlotImportPath(slotModule: string, fromDir: string): string {
210
- // slotModule: "apps/server/slots/users.logic.ts"
211
- // fromDir: "apps/server/generated/routes"
212
- // result: "../../slots/users.logic"
211
+ // slotModule: "spec/slots/users.slot.ts"
212
+ // fromDir: ".mandu/generated/server/routes"
213
+ // result: "../../../../spec/slots/users.slot"
213
214
 
214
215
  const slotParts = slotModule.replace(/\\/g, "/").split("/");
215
216
  const fromParts = fromDir.replace(/\\/g, "/").split("/");
@@ -280,11 +281,11 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
280
281
  */
281
282
  export function generatePageComponentWithIsland(route: RouteSpec): string {
282
283
  const pageName = toPascalCase(route.id);
283
- const clientImportPath = computeSlotImportPath(route.clientModule!, "apps/web/generated/routes");
284
+ const clientImportPath = computeSlotImportPath(route.clientModule!, GENERATED_RELATIVE_PATHS.webRoutes);
284
285
 
285
286
  // clientModule + slotModule → PageRegistration 형식
286
287
  if (route.slotModule) {
287
- const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/web/generated/routes");
288
+ const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.webRoutes);
288
289
 
289
290
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
290
291
  // Island-First Rendering + Slot Module
@@ -346,7 +347,7 @@ export default function ${pageName}Page({ params, loaderData }: Props): React.Re
346
347
  */
347
348
  export function generatePageHandlerWithSlot(route: RouteSpec): string {
348
349
  const pageName = toPascalCase(route.id);
349
- const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/server/generated/routes");
350
+ const slotImportPath = computeSlotImportPath(route.slotModule!, GENERATED_RELATIVE_PATHS.serverRoutes);
350
351
 
351
352
  return `// Generated by Mandu - DO NOT EDIT DIRECTLY
352
353
  // Route ID: ${route.id}
@@ -383,13 +384,13 @@ export default {
383
384
  * "todo-page" → "TodoPage"
384
385
  * "user_profile" → "UserProfile"
385
386
  */
386
- function toPascalCase(str: string): string {
387
+ function toPascalCase(str: string): string {
387
388
  return str
388
389
  .split(/[-_]/)
389
390
  .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
390
391
  .join("");
391
- }
392
-
393
- function pathDirname(filePath: string): string {
394
- return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
395
- }
392
+ }
393
+
394
+ function pathDirname(filePath: string): string {
395
+ return filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/");
396
+ }
@@ -172,7 +172,7 @@ async function correctSpecHashMismatch(
172
172
  rootDir: string
173
173
  ): Promise<AutoCorrectStep> {
174
174
  try {
175
- const lockPath = path.join(rootDir, "spec/spec.lock.json");
175
+ const lockPath = path.join(rootDir, ".mandu/spec.lock.json");
176
176
  await writeLock(lockPath, manifest);
177
177
 
178
178
  return {