@mandujs/core 0.10.0 → 0.12.0
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.md +79 -10
- package/package.json +2 -1
- package/src/config/index.ts +1 -0
- package/src/config/validate.ts +122 -65
- package/src/config/watcher.ts +311 -0
- package/src/constants.ts +15 -0
- package/src/content/content-layer.ts +314 -0
- package/src/content/content.test.ts +433 -0
- package/src/content/data-store.ts +245 -0
- package/src/content/digest.ts +133 -0
- package/src/content/index.ts +164 -0
- package/src/content/loader-context.ts +172 -0
- package/src/content/loaders/api.ts +216 -0
- package/src/content/loaders/file.ts +169 -0
- package/src/content/loaders/glob.ts +252 -0
- package/src/content/loaders/index.ts +34 -0
- package/src/content/loaders/types.ts +137 -0
- package/src/content/meta-store.ts +209 -0
- package/src/content/types.ts +282 -0
- package/src/content/watcher.ts +135 -0
- package/src/errors/extractor.ts +409 -0
- package/src/errors/index.ts +19 -0
- package/src/filling/context.ts +29 -1
- package/src/filling/deps.ts +238 -0
- package/src/filling/filling.ts +6 -2
- package/src/filling/index.ts +18 -0
- package/src/index.ts +2 -1
- package/src/logging/index.ts +22 -0
- package/src/logging/transports.ts +365 -0
- package/src/plugins/index.ts +38 -0
- package/src/plugins/registry.ts +377 -0
- package/src/plugins/types.ts +363 -0
- package/src/runtime/session-key.ts +328 -0
- package/src/utils/string-safe.ts +298 -0
package/README.md
CHANGED
|
@@ -72,16 +72,20 @@ const handlers = Mandu.handler(userContract, {
|
|
|
72
72
|
|
|
73
73
|
```
|
|
74
74
|
@mandujs/core
|
|
75
|
-
├── router/
|
|
76
|
-
├── guard/
|
|
77
|
-
├──
|
|
78
|
-
├──
|
|
79
|
-
├──
|
|
80
|
-
|
|
81
|
-
├──
|
|
82
|
-
├──
|
|
83
|
-
├──
|
|
84
|
-
|
|
75
|
+
├── router/ # FS Routes - file-system based routing
|
|
76
|
+
├── guard/ # Mandu Guard - architecture enforcement
|
|
77
|
+
│ ├── healing # Self-Healing Guard with auto-fix
|
|
78
|
+
│ ├── decision-memory # ADR storage (RFC-001)
|
|
79
|
+
│ ├── semantic-slots # Constraint validation (RFC-001)
|
|
80
|
+
│ └── negotiation # AI-Framework dialog (RFC-001)
|
|
81
|
+
├── runtime/ # Server, SSR, streaming
|
|
82
|
+
├── filling/ # Handler chain API (Mandu.filling())
|
|
83
|
+
├── contract/ # Type-safe API contracts
|
|
84
|
+
├── bundler/ # Client bundling, HMR
|
|
85
|
+
├── client/ # Island hydration, client router
|
|
86
|
+
├── brain/ # Doctor, Watcher, Architecture analyzer
|
|
87
|
+
├── change/ # Transaction & history
|
|
88
|
+
└── spec/ # Manifest schema & validation
|
|
85
89
|
```
|
|
86
90
|
|
|
87
91
|
---
|
|
@@ -201,6 +205,71 @@ console.log(trend.trend); // "improving" | "stable" | "degrading"
|
|
|
201
205
|
const markdown = generateGuardMarkdownReport(report, trend);
|
|
202
206
|
```
|
|
203
207
|
|
|
208
|
+
### Self-Healing Guard (RFC-001) 🆕
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import { checkWithHealing, healAll, explainRule } from "@mandujs/core/guard";
|
|
212
|
+
|
|
213
|
+
// Detect violations with fix suggestions
|
|
214
|
+
const result = await checkWithHealing({ preset: "mandu" }, process.cwd());
|
|
215
|
+
|
|
216
|
+
// Auto-fix all fixable violations
|
|
217
|
+
if (result.items.length > 0) {
|
|
218
|
+
const healResult = await healAll(result);
|
|
219
|
+
console.log(`Fixed: ${healResult.fixed}, Failed: ${healResult.failed}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Explain any rule
|
|
223
|
+
const explanation = explainRule("layer-dependency");
|
|
224
|
+
console.log(explanation.description, explanation.examples);
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Decision Memory (RFC-001) 🆕
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import {
|
|
231
|
+
searchDecisions,
|
|
232
|
+
saveDecision,
|
|
233
|
+
checkConsistency,
|
|
234
|
+
getCompactArchitecture
|
|
235
|
+
} from "@mandujs/core/guard";
|
|
236
|
+
|
|
237
|
+
// Search past decisions
|
|
238
|
+
const results = await searchDecisions(rootDir, ["auth", "jwt"]);
|
|
239
|
+
|
|
240
|
+
// Save new decision (ADR)
|
|
241
|
+
await saveDecision(rootDir, {
|
|
242
|
+
id: "ADR-002",
|
|
243
|
+
title: "Use PostgreSQL",
|
|
244
|
+
status: "accepted",
|
|
245
|
+
context: "Need relational database",
|
|
246
|
+
decision: "Use PostgreSQL with Drizzle ORM",
|
|
247
|
+
consequences: ["Need to manage migrations"],
|
|
248
|
+
tags: ["database", "orm"]
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Check implementation consistency
|
|
252
|
+
const consistency = await checkConsistency(rootDir);
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Architecture Negotiation (RFC-001) 🆕
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
import { negotiate, generateScaffold } from "@mandujs/core/guard";
|
|
259
|
+
|
|
260
|
+
// AI negotiates with framework before implementation
|
|
261
|
+
const plan = await negotiate({
|
|
262
|
+
intent: "Add user authentication",
|
|
263
|
+
requirements: ["JWT based", "Refresh tokens"],
|
|
264
|
+
constraints: ["Use existing User model"]
|
|
265
|
+
}, projectRoot);
|
|
266
|
+
|
|
267
|
+
if (plan.approved) {
|
|
268
|
+
// Generate scaffold files
|
|
269
|
+
await generateScaffold(plan.structure, projectRoot);
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
204
273
|
---
|
|
205
274
|
|
|
206
275
|
## Filling API
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"chokidar": "^5.0.0",
|
|
52
|
+
"fast-glob": "^3.3.2",
|
|
52
53
|
"glob": "^13.0.0",
|
|
53
54
|
"minimatch": "^10.1.1",
|
|
54
55
|
"ollama": "^0.6.3"
|
package/src/config/index.ts
CHANGED
package/src/config/validate.ts
CHANGED
|
@@ -1,78 +1,135 @@
|
|
|
1
|
-
import { z, ZodError } from "zod";
|
|
1
|
+
import { z, ZodError, ZodIssueCode } from "zod";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { pathToFileURL } from "url";
|
|
4
4
|
import { CONFIG_FILES, coerceConfig } from "./mandu";
|
|
5
5
|
import { readJsonFile } from "../utils/bun";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* DNA-003: Strict mode schema helper
|
|
9
|
+
*
|
|
10
|
+
* Creates a schema that warns about unknown keys instead of failing
|
|
11
|
+
* This provides the benefits of .strict() while maintaining compatibility
|
|
12
|
+
*/
|
|
13
|
+
function strictWithWarnings<T extends z.ZodRawShape>(
|
|
14
|
+
schema: z.ZodObject<T>,
|
|
15
|
+
schemaName: string
|
|
16
|
+
): z.ZodObject<T> {
|
|
17
|
+
return schema.superRefine((data, ctx) => {
|
|
18
|
+
if (typeof data !== "object" || data === null) return;
|
|
19
|
+
|
|
20
|
+
const knownKeys = new Set(Object.keys(schema.shape));
|
|
21
|
+
const unknownKeys = Object.keys(data).filter((key) => !knownKeys.has(key));
|
|
22
|
+
|
|
23
|
+
if (unknownKeys.length > 0 && process.env.MANDU_STRICT !== "0") {
|
|
24
|
+
// In strict mode (default), add warnings to issues
|
|
25
|
+
for (const key of unknownKeys) {
|
|
26
|
+
ctx.addIssue({
|
|
27
|
+
code: ZodIssueCode.unrecognized_keys,
|
|
28
|
+
keys: [key],
|
|
29
|
+
message: `Unknown key '${key}' in ${schemaName}. Did you mean one of: ${[...knownKeys].join(", ")}?`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Server 설정 스키마 (strict)
|
|
38
|
+
*/
|
|
39
|
+
const ServerConfigSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
port: z.number().min(1).max(65535).default(3000),
|
|
42
|
+
hostname: z.string().default("localhost"),
|
|
43
|
+
cors: z
|
|
44
|
+
.union([
|
|
45
|
+
z.boolean(),
|
|
46
|
+
z.object({
|
|
47
|
+
origin: z.union([z.string(), z.array(z.string())]).optional(),
|
|
48
|
+
methods: z.array(z.string()).optional(),
|
|
49
|
+
credentials: z.boolean().optional(),
|
|
50
|
+
}).strict(),
|
|
51
|
+
])
|
|
52
|
+
.default(false),
|
|
53
|
+
streaming: z.boolean().default(false),
|
|
54
|
+
})
|
|
55
|
+
.strict();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Guard 설정 스키마 (strict)
|
|
59
|
+
*/
|
|
60
|
+
const GuardConfigSchema = z
|
|
61
|
+
.object({
|
|
62
|
+
preset: z.enum(["mandu", "fsd", "clean", "hexagonal", "atomic"]).default("mandu"),
|
|
63
|
+
srcDir: z.string().default("src"),
|
|
64
|
+
exclude: z.array(z.string()).default([]),
|
|
65
|
+
realtime: z.boolean().default(true),
|
|
66
|
+
rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
|
|
67
|
+
})
|
|
68
|
+
.strict();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build 설정 스키마 (strict)
|
|
72
|
+
*/
|
|
73
|
+
const BuildConfigSchema = z
|
|
74
|
+
.object({
|
|
75
|
+
outDir: z.string().default(".mandu"),
|
|
76
|
+
minify: z.boolean().default(true),
|
|
77
|
+
sourcemap: z.boolean().default(false),
|
|
78
|
+
splitting: z.boolean().default(false),
|
|
79
|
+
})
|
|
80
|
+
.strict();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Dev 설정 스키마 (strict)
|
|
84
|
+
*/
|
|
85
|
+
const DevConfigSchema = z
|
|
86
|
+
.object({
|
|
87
|
+
hmr: z.boolean().default(true),
|
|
88
|
+
watchDirs: z.array(z.string()).default([]),
|
|
89
|
+
})
|
|
90
|
+
.strict();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* FS Routes 설정 스키마 (strict)
|
|
94
|
+
*/
|
|
95
|
+
const FsRoutesConfigSchema = z
|
|
96
|
+
.object({
|
|
97
|
+
routesDir: z.string().default("app"),
|
|
98
|
+
extensions: z.array(z.string()).default([".tsx", ".ts", ".jsx", ".js"]),
|
|
99
|
+
exclude: z.array(z.string()).default([]),
|
|
100
|
+
islandSuffix: z.string().default(".island"),
|
|
101
|
+
legacyManifestPath: z.string().optional(),
|
|
102
|
+
mergeWithLegacy: z.boolean().default(true),
|
|
103
|
+
})
|
|
104
|
+
.strict();
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* SEO 설정 스키마 (strict)
|
|
108
|
+
*/
|
|
109
|
+
const SeoConfigSchema = z
|
|
110
|
+
.object({
|
|
111
|
+
enabled: z.boolean().default(true),
|
|
112
|
+
defaultTitle: z.string().optional(),
|
|
113
|
+
titleTemplate: z.string().optional(),
|
|
114
|
+
})
|
|
115
|
+
.strict();
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Mandu 설정 스키마 (DNA-003: strict mode)
|
|
119
|
+
*
|
|
120
|
+
* 알 수 없는 키가 있으면 오류 발생 → 오타 즉시 감지
|
|
121
|
+
* MANDU_STRICT=0 으로 비활성화 가능
|
|
9
122
|
*/
|
|
10
123
|
export const ManduConfigSchema = z
|
|
11
124
|
.object({
|
|
12
|
-
server:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
z.boolean(),
|
|
19
|
-
z.object({
|
|
20
|
-
origin: z.union([z.string(), z.array(z.string())]).optional(),
|
|
21
|
-
methods: z.array(z.string()).optional(),
|
|
22
|
-
credentials: z.boolean().optional(),
|
|
23
|
-
}),
|
|
24
|
-
])
|
|
25
|
-
.default(false),
|
|
26
|
-
streaming: z.boolean().default(false),
|
|
27
|
-
})
|
|
28
|
-
.default({}),
|
|
29
|
-
|
|
30
|
-
guard: z
|
|
31
|
-
.object({
|
|
32
|
-
preset: z.enum(["mandu", "fsd", "clean", "hexagonal", "atomic"]).default("mandu"),
|
|
33
|
-
srcDir: z.string().default("src"),
|
|
34
|
-
exclude: z.array(z.string()).default([]),
|
|
35
|
-
realtime: z.boolean().default(true),
|
|
36
|
-
rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
|
|
37
|
-
})
|
|
38
|
-
.default({}),
|
|
39
|
-
|
|
40
|
-
build: z
|
|
41
|
-
.object({
|
|
42
|
-
outDir: z.string().default(".mandu"),
|
|
43
|
-
minify: z.boolean().default(true),
|
|
44
|
-
sourcemap: z.boolean().default(false),
|
|
45
|
-
splitting: z.boolean().default(false),
|
|
46
|
-
})
|
|
47
|
-
.default({}),
|
|
48
|
-
|
|
49
|
-
dev: z
|
|
50
|
-
.object({
|
|
51
|
-
hmr: z.boolean().default(true),
|
|
52
|
-
watchDirs: z.array(z.string()).default([]),
|
|
53
|
-
})
|
|
54
|
-
.default({}),
|
|
55
|
-
|
|
56
|
-
fsRoutes: z
|
|
57
|
-
.object({
|
|
58
|
-
routesDir: z.string().default("app"),
|
|
59
|
-
extensions: z.array(z.string()).default([".tsx", ".ts", ".jsx", ".js"]),
|
|
60
|
-
exclude: z.array(z.string()).default([]),
|
|
61
|
-
islandSuffix: z.string().default(".island"),
|
|
62
|
-
legacyManifestPath: z.string().optional(),
|
|
63
|
-
mergeWithLegacy: z.boolean().default(true),
|
|
64
|
-
})
|
|
65
|
-
.default({}),
|
|
66
|
-
|
|
67
|
-
seo: z
|
|
68
|
-
.object({
|
|
69
|
-
enabled: z.boolean().default(true),
|
|
70
|
-
defaultTitle: z.string().optional(),
|
|
71
|
-
titleTemplate: z.string().optional(),
|
|
72
|
-
})
|
|
73
|
-
.default({}),
|
|
125
|
+
server: ServerConfigSchema.default({}),
|
|
126
|
+
guard: GuardConfigSchema.default({}),
|
|
127
|
+
build: BuildConfigSchema.default({}),
|
|
128
|
+
dev: DevConfigSchema.default({}),
|
|
129
|
+
fsRoutes: FsRoutesConfigSchema.default({}),
|
|
130
|
+
seo: SeoConfigSchema.default({}),
|
|
74
131
|
})
|
|
75
|
-
.
|
|
132
|
+
.strict();
|
|
76
133
|
|
|
77
134
|
export type ValidatedManduConfig = z.infer<typeof ManduConfigSchema>;
|
|
78
135
|
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNA-006: Config Hot Reload
|
|
3
|
+
*
|
|
4
|
+
* 설정 파일 변경 감시 및 핫 리로드
|
|
5
|
+
* - 디바운스로 연속 변경 병합
|
|
6
|
+
* - 에러 발생 시 기존 설정 유지
|
|
7
|
+
* - 클린업 함수 반환
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { watch, type FSWatcher } from "fs";
|
|
11
|
+
import { loadManduConfig, type ManduConfig } from "./mandu.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 설정 변경 이벤트 타입
|
|
15
|
+
*/
|
|
16
|
+
export type ConfigChangeEvent = {
|
|
17
|
+
/** 이전 설정 */
|
|
18
|
+
previous: ManduConfig;
|
|
19
|
+
/** 새 설정 */
|
|
20
|
+
current: ManduConfig;
|
|
21
|
+
/** 변경된 파일 경로 */
|
|
22
|
+
path: string;
|
|
23
|
+
/** 변경 시간 */
|
|
24
|
+
timestamp: Date;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 설정 감시 옵션
|
|
29
|
+
*/
|
|
30
|
+
export interface WatchConfigOptions {
|
|
31
|
+
/** 디바운스 딜레이 (ms, 기본: 100) */
|
|
32
|
+
debounceMs?: number;
|
|
33
|
+
/** 초기 로드 시에도 콜백 호출 (기본: false) */
|
|
34
|
+
immediate?: boolean;
|
|
35
|
+
/** 에러 핸들러 */
|
|
36
|
+
onError?: (error: Error) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 설정 변경 콜백
|
|
41
|
+
*/
|
|
42
|
+
export type ConfigChangeCallback = (
|
|
43
|
+
newConfig: ManduConfig,
|
|
44
|
+
event: ConfigChangeEvent
|
|
45
|
+
) => void;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 설정 감시 결과
|
|
49
|
+
*/
|
|
50
|
+
export interface ConfigWatcher {
|
|
51
|
+
/** 감시 중지 */
|
|
52
|
+
stop: () => void;
|
|
53
|
+
/** 현재 설정 */
|
|
54
|
+
getConfig: () => ManduConfig;
|
|
55
|
+
/** 수동 리로드 */
|
|
56
|
+
reload: () => Promise<ManduConfig>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 설정 파일 감시 및 핫 리로드
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const watcher = await watchConfig(
|
|
65
|
+
* "/path/to/project",
|
|
66
|
+
* (newConfig, event) => {
|
|
67
|
+
* console.log("Config changed:", event.path);
|
|
68
|
+
* applyConfig(newConfig);
|
|
69
|
+
* },
|
|
70
|
+
* { debounceMs: 200 }
|
|
71
|
+
* );
|
|
72
|
+
*
|
|
73
|
+
* // 나중에 정리
|
|
74
|
+
* watcher.stop();
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export async function watchConfig(
|
|
78
|
+
rootDir: string,
|
|
79
|
+
onReload: ConfigChangeCallback,
|
|
80
|
+
options: WatchConfigOptions = {}
|
|
81
|
+
): Promise<ConfigWatcher> {
|
|
82
|
+
const { debounceMs = 100, immediate = false, onError } = options;
|
|
83
|
+
|
|
84
|
+
// 현재 설정 로드
|
|
85
|
+
let currentConfig = await loadManduConfig(rootDir);
|
|
86
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
87
|
+
let watchers: FSWatcher[] = [];
|
|
88
|
+
let isWatching = true;
|
|
89
|
+
|
|
90
|
+
// 감시 대상 파일들
|
|
91
|
+
const configFiles = [
|
|
92
|
+
"mandu.config.ts",
|
|
93
|
+
"mandu.config.js",
|
|
94
|
+
"mandu.config.json",
|
|
95
|
+
".mandu/guard.json",
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 설정 리로드 수행
|
|
100
|
+
*/
|
|
101
|
+
const doReload = async (changedPath: string): Promise<void> => {
|
|
102
|
+
if (!isWatching) return;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const previous = currentConfig;
|
|
106
|
+
const newConfig = await loadManduConfig(rootDir);
|
|
107
|
+
|
|
108
|
+
// 설정이 동일하면 무시
|
|
109
|
+
if (JSON.stringify(previous) === JSON.stringify(newConfig)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 이전에 설정이 있었는데 새 설정이 비어있으면 (파싱 에러 등)
|
|
114
|
+
// 기존 설정 유지하고 에러 핸들러 호출
|
|
115
|
+
const previousHasContent = Object.keys(previous).length > 0;
|
|
116
|
+
const newIsEmpty = Object.keys(newConfig).length === 0;
|
|
117
|
+
|
|
118
|
+
if (previousHasContent && newIsEmpty) {
|
|
119
|
+
if (onError) {
|
|
120
|
+
onError(new Error(`Failed to reload config from ${changedPath}`));
|
|
121
|
+
}
|
|
122
|
+
return; // 기존 설정 유지
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
currentConfig = newConfig;
|
|
126
|
+
|
|
127
|
+
const event: ConfigChangeEvent = {
|
|
128
|
+
previous,
|
|
129
|
+
current: newConfig,
|
|
130
|
+
path: changedPath,
|
|
131
|
+
timestamp: new Date(),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
onReload(newConfig, event);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (onError && error instanceof Error) {
|
|
137
|
+
onError(error);
|
|
138
|
+
}
|
|
139
|
+
// 에러 시 기존 설정 유지
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 디바운스된 리로드
|
|
145
|
+
*/
|
|
146
|
+
const scheduleReload = (changedPath: string): void => {
|
|
147
|
+
if (debounceTimer) {
|
|
148
|
+
clearTimeout(debounceTimer);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
debounceTimer = setTimeout(() => {
|
|
152
|
+
debounceTimer = null;
|
|
153
|
+
doReload(changedPath);
|
|
154
|
+
}, debounceMs);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// 각 설정 파일 감시 시작
|
|
158
|
+
for (const fileName of configFiles) {
|
|
159
|
+
const filePath = `${rootDir}/${fileName}`;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const watcher = watch(filePath, (eventType) => {
|
|
163
|
+
if (eventType === "change" && isWatching) {
|
|
164
|
+
scheduleReload(filePath);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
watcher.on("error", () => {
|
|
169
|
+
// 파일이 없거나 접근 불가 - 무시
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
watchers.push(watcher);
|
|
173
|
+
} catch {
|
|
174
|
+
// 파일이 없으면 감시 생략
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 초기 콜백 호출
|
|
179
|
+
if (immediate) {
|
|
180
|
+
const event: ConfigChangeEvent = {
|
|
181
|
+
previous: {},
|
|
182
|
+
current: currentConfig,
|
|
183
|
+
path: rootDir,
|
|
184
|
+
timestamp: new Date(),
|
|
185
|
+
};
|
|
186
|
+
onReload(currentConfig, event);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
stop: () => {
|
|
191
|
+
isWatching = false;
|
|
192
|
+
if (debounceTimer) {
|
|
193
|
+
clearTimeout(debounceTimer);
|
|
194
|
+
debounceTimer = null;
|
|
195
|
+
}
|
|
196
|
+
for (const watcher of watchers) {
|
|
197
|
+
watcher.close();
|
|
198
|
+
}
|
|
199
|
+
watchers = [];
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
getConfig: () => currentConfig,
|
|
203
|
+
|
|
204
|
+
reload: async () => {
|
|
205
|
+
const previous = currentConfig;
|
|
206
|
+
currentConfig = await loadManduConfig(rootDir);
|
|
207
|
+
|
|
208
|
+
if (JSON.stringify(previous) !== JSON.stringify(currentConfig)) {
|
|
209
|
+
const event: ConfigChangeEvent = {
|
|
210
|
+
previous,
|
|
211
|
+
current: currentConfig,
|
|
212
|
+
path: rootDir,
|
|
213
|
+
timestamp: new Date(),
|
|
214
|
+
};
|
|
215
|
+
onReload(currentConfig, event);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return currentConfig;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 간단한 단일 파일 감시
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* ```ts
|
|
228
|
+
* const stop = watchConfigFile(
|
|
229
|
+
* "/path/to/mandu.config.ts",
|
|
230
|
+
* async (path) => {
|
|
231
|
+
* const config = await loadManduConfig(dirname(path));
|
|
232
|
+
* applyConfig(config);
|
|
233
|
+
* }
|
|
234
|
+
* );
|
|
235
|
+
*
|
|
236
|
+
* // 정리
|
|
237
|
+
* stop();
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
export function watchConfigFile(
|
|
241
|
+
filePath: string,
|
|
242
|
+
onChange: (path: string) => void,
|
|
243
|
+
debounceMs = 100
|
|
244
|
+
): () => void {
|
|
245
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
246
|
+
let watcher: FSWatcher | null = null;
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
watcher = watch(filePath, (eventType) => {
|
|
250
|
+
if (eventType === "change") {
|
|
251
|
+
if (timer) clearTimeout(timer);
|
|
252
|
+
timer = setTimeout(() => {
|
|
253
|
+
timer = null;
|
|
254
|
+
onChange(filePath);
|
|
255
|
+
}, debounceMs);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
} catch {
|
|
259
|
+
// 파일 없음 - 빈 함수 반환
|
|
260
|
+
return () => {};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return () => {
|
|
264
|
+
if (timer) {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
timer = null;
|
|
267
|
+
}
|
|
268
|
+
if (watcher) {
|
|
269
|
+
watcher.close();
|
|
270
|
+
watcher = null;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 설정 변경 감지 헬퍼
|
|
277
|
+
*
|
|
278
|
+
* 특정 설정 섹션의 변경 여부 확인
|
|
279
|
+
*/
|
|
280
|
+
export function hasConfigChanged(
|
|
281
|
+
previous: ManduConfig,
|
|
282
|
+
current: ManduConfig,
|
|
283
|
+
section?: keyof ManduConfig
|
|
284
|
+
): boolean {
|
|
285
|
+
if (section) {
|
|
286
|
+
return JSON.stringify(previous[section]) !== JSON.stringify(current[section]);
|
|
287
|
+
}
|
|
288
|
+
return JSON.stringify(previous) !== JSON.stringify(current);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 변경된 설정 섹션 목록
|
|
293
|
+
*/
|
|
294
|
+
export function getChangedSections(
|
|
295
|
+
previous: ManduConfig,
|
|
296
|
+
current: ManduConfig
|
|
297
|
+
): (keyof ManduConfig)[] {
|
|
298
|
+
const sections: (keyof ManduConfig)[] = [
|
|
299
|
+
"server",
|
|
300
|
+
"guard",
|
|
301
|
+
"build",
|
|
302
|
+
"dev",
|
|
303
|
+
"fsRoutes",
|
|
304
|
+
"seo",
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
return sections.filter(
|
|
308
|
+
(section) =>
|
|
309
|
+
JSON.stringify(previous[section]) !== JSON.stringify(current[section])
|
|
310
|
+
);
|
|
311
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -23,3 +23,18 @@ export const LIMITS = {
|
|
|
23
23
|
ROUTER_PATTERN_CACHE: 200,
|
|
24
24
|
ROUTER_PREFETCH_CACHE: 500,
|
|
25
25
|
} as const;
|
|
26
|
+
|
|
27
|
+
export const CONTENT = {
|
|
28
|
+
/** 로더 기본 타임아웃 (ms) */
|
|
29
|
+
LOADER_TIMEOUT: 10000,
|
|
30
|
+
/** 데이터 스토어 파일 경로 */
|
|
31
|
+
STORE_FILE: ".mandu/content-store.json",
|
|
32
|
+
/** 메타 스토어 파일 경로 */
|
|
33
|
+
META_FILE: ".mandu/content-meta.json",
|
|
34
|
+
/** 저장 디바운스 (ms) */
|
|
35
|
+
DEBOUNCE_SAVE: 500,
|
|
36
|
+
/** 기본 콘텐츠 디렉토리 */
|
|
37
|
+
DEFAULT_DIR: "content",
|
|
38
|
+
/** API 로더 기본 캐시 TTL (초) */
|
|
39
|
+
API_CACHE_TTL: 3600,
|
|
40
|
+
} as const;
|