@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.
- package/README.ko.md +304 -304
- package/package.json +1 -1
- package/src/brain/architecture/analyzer.ts +28 -26
- package/src/brain/doctor/analyzer.ts +1 -1
- package/src/bundler/dev.ts +0 -1
- package/src/change/history.ts +3 -3
- package/src/change/snapshot.ts +10 -9
- package/src/change/transaction.ts +2 -2
- package/src/config/mandu.ts +103 -96
- package/src/config/validate.ts +225 -215
- package/src/error/classifier.ts +2 -2
- package/src/error/formatter.ts +32 -32
- package/src/error/stack-analyzer.ts +5 -0
- package/src/filling/context.ts +592 -569
- package/src/filling/index.ts +2 -0
- package/src/filling/sse.test.ts +168 -0
- package/src/filling/sse.ts +162 -0
- package/src/generator/contract-glue.ts +2 -1
- package/src/generator/generate.ts +12 -10
- package/src/generator/templates.ts +80 -79
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +128 -128
- package/src/guard/presets/cqrs.test.ts +35 -14
- package/src/index.ts +7 -1
- package/src/paths.test.ts +47 -0
- package/src/paths.ts +47 -0
- package/src/report/build.ts +1 -1
- package/src/router/fs-routes.ts +344 -401
- package/src/router/fs-types.ts +270 -278
- package/src/router/index.ts +81 -81
- package/src/runtime/escape.ts +44 -0
- package/src/runtime/server.ts +281 -24
- package/src/runtime/ssr.ts +362 -367
- package/src/runtime/streaming-ssr.ts +1236 -1245
- package/src/watcher/rules.ts +5 -5
package/src/filling/index.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
const
|
|
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: "
|
|
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: "
|
|
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,
|
|
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:
|
|
232
|
+
typeGluePath: `${GENERATED_RELATIVE_PATHS.types}/${typeFileName}`,
|
|
231
233
|
};
|
|
232
234
|
|
|
233
235
|
routesWithContracts.push(route.id);
|
|
234
236
|
}
|
|
235
237
|
|
|
236
|
-
generatedMap.files[
|
|
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[
|
|
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!,
|
|
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!,
|
|
53
|
-
const contractImportPath = computeSlotImportPath(route.contractModule!,
|
|
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: "
|
|
211
|
-
// fromDir: "
|
|
212
|
-
// result: "
|
|
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!,
|
|
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!,
|
|
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!,
|
|
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, "
|
|
175
|
+
const lockPath = path.join(rootDir, ".mandu/spec.lock.json");
|
|
176
176
|
await writeLock(lockPath, manifest);
|
|
177
177
|
|
|
178
178
|
return {
|