@mandujs/core 0.19.2 → 0.20.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/package.json +1 -1
- package/src/bundler/build.ts +94 -2
- package/src/bundler/css.ts +323 -353
- package/src/bundler/types.ts +20 -0
- package/src/devtools/client/components/mandu-character.tsx +77 -53
- package/src/devtools/client/components/panel/errors-panel.tsx +2 -2
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -31
- package/src/devtools/client/components/panel/islands-panel.tsx +30 -14
- package/src/devtools/client/components/panel/network-panel.tsx +2 -3
- package/src/devtools/client/components/panel/panel-container.tsx +485 -332
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -22
- package/src/devtools/init.ts +1 -1
- package/src/devtools/types.ts +35 -35
- package/src/filling/filling.ts +66 -66
- package/src/index.ts +1 -0
- package/src/kitchen/kitchen-handler.ts +80 -1
- package/src/observability/event-bus.ts +79 -0
- package/src/observability/index.ts +8 -0
- package/src/observability/logger-adapter.ts +36 -0
- package/src/runtime/index.ts +9 -8
- package/src/runtime/ppr.ts +74 -0
- package/src/runtime/server.ts +203 -147
- package/src/runtime/ssr.ts +17 -4
- package/src/runtime/streaming-ssr.ts +55 -36
- package/src/testing/index.ts +45 -0
|
@@ -67,10 +67,20 @@ const styles = {
|
|
|
67
67
|
transition: `all ${animation.duration.fast}`,
|
|
68
68
|
fontSize: typography.fontSize.sm,
|
|
69
69
|
},
|
|
70
|
-
changeIcon: {
|
|
71
|
-
flexShrink: 0,
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
changeIcon: {
|
|
71
|
+
flexShrink: 0,
|
|
72
|
+
display: 'inline-flex',
|
|
73
|
+
alignItems: 'center',
|
|
74
|
+
justifyContent: 'center',
|
|
75
|
+
minWidth: '42px',
|
|
76
|
+
height: '24px',
|
|
77
|
+
padding: '0 8px',
|
|
78
|
+
borderRadius: borderRadius.full,
|
|
79
|
+
fontSize: typography.fontSize.xs,
|
|
80
|
+
fontWeight: typography.fontWeight.semibold,
|
|
81
|
+
fontFamily: typography.fontFamily.mono,
|
|
82
|
+
letterSpacing: '0.08em',
|
|
83
|
+
},
|
|
74
84
|
changePath: {
|
|
75
85
|
flex: 1,
|
|
76
86
|
overflow: 'hidden',
|
|
@@ -87,11 +97,17 @@ const styles = {
|
|
|
87
97
|
},
|
|
88
98
|
};
|
|
89
99
|
|
|
90
|
-
const typeIcons: Record<string, string> = {
|
|
91
|
-
add: '
|
|
92
|
-
change: '
|
|
93
|
-
delete: '
|
|
94
|
-
};
|
|
100
|
+
const typeIcons: Record<string, string> = {
|
|
101
|
+
add: 'ADD',
|
|
102
|
+
change: 'EDIT',
|
|
103
|
+
delete: 'DEL',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const typeStyles: Record<string, { bg: string; color: string }> = {
|
|
107
|
+
add: { bg: `${colors.semantic.success}20`, color: colors.semantic.success },
|
|
108
|
+
change: { bg: `${colors.semantic.info}20`, color: colors.semantic.info },
|
|
109
|
+
delete: { bg: `${colors.semantic.error}20`, color: colors.semantic.error },
|
|
110
|
+
};
|
|
95
111
|
|
|
96
112
|
// ============================================================================
|
|
97
113
|
// Props
|
|
@@ -136,11 +152,10 @@ export function PreviewPanel({ recentChanges, onClearChanges }: PreviewPanelProp
|
|
|
136
152
|
if (recentChanges.length === 0) {
|
|
137
153
|
return (
|
|
138
154
|
<div style={styles.container}>
|
|
139
|
-
<div style={styles.emptyState}>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
</div>
|
|
155
|
+
<div style={styles.emptyState}>
|
|
156
|
+
<p>파일을 수정하면 여기에 최근 변경사항이 표시됩니다.</p>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
144
159
|
);
|
|
145
160
|
}
|
|
146
161
|
|
|
@@ -158,19 +173,28 @@ export function PreviewPanel({ recentChanges, onClearChanges }: PreviewPanelProp
|
|
|
158
173
|
</div>
|
|
159
174
|
|
|
160
175
|
<div style={styles.list}>
|
|
161
|
-
{recentChanges.map((change, i) => {
|
|
162
|
-
const isLoading = loadingDiff === change.filePath;
|
|
163
|
-
|
|
164
|
-
|
|
176
|
+
{recentChanges.map((change, i) => {
|
|
177
|
+
const isLoading = loadingDiff === change.filePath;
|
|
178
|
+
const changeStyle = typeStyles[change.type] ?? typeStyles.change;
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
165
181
|
key={`${change.filePath}-${change.timestamp}-${i}`}
|
|
166
182
|
style={{
|
|
167
183
|
...styles.changeItem,
|
|
168
184
|
opacity: isLoading ? 0.5 : 1,
|
|
169
185
|
}}
|
|
170
|
-
onClick={() => !isLoading && handleFileClick(change.filePath)}
|
|
171
|
-
>
|
|
172
|
-
<span
|
|
173
|
-
|
|
186
|
+
onClick={() => !isLoading && handleFileClick(change.filePath)}
|
|
187
|
+
>
|
|
188
|
+
<span
|
|
189
|
+
style={{
|
|
190
|
+
...styles.changeIcon,
|
|
191
|
+
backgroundColor: changeStyle.bg,
|
|
192
|
+
color: changeStyle.color,
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
{typeIcons[change.type] ?? 'EDIT'}
|
|
196
|
+
</span>
|
|
197
|
+
<span style={styles.changePath}>{change.filePath}</span>
|
|
174
198
|
<span style={styles.changeTime}>
|
|
175
199
|
{formatTime(change.timestamp)}
|
|
176
200
|
</span>
|
package/src/devtools/init.ts
CHANGED
|
@@ -141,7 +141,7 @@ export function initManduKitchen(config: DevToolsConfig = {}): KitchenInstance {
|
|
|
141
141
|
|
|
142
142
|
isInitialized = true;
|
|
143
143
|
|
|
144
|
-
console.log('[Mandu Kitchen] DevTools v1.1 initialized
|
|
144
|
+
console.log('[Mandu Kitchen] DevTools v1.1 initialized');
|
|
145
145
|
|
|
146
146
|
return createInstance();
|
|
147
147
|
} catch (error) {
|
package/src/devtools/types.ts
CHANGED
|
@@ -308,38 +308,38 @@ export interface KitchenMetaLog {
|
|
|
308
308
|
// Mandu Character Types
|
|
309
309
|
// ============================================================================
|
|
310
310
|
|
|
311
|
-
export type ManduState = 'normal' | 'warning' | 'error' | 'loading' | 'hmr';
|
|
312
|
-
|
|
313
|
-
export interface ManduCharacterData {
|
|
314
|
-
state: ManduState;
|
|
315
|
-
|
|
316
|
-
message: string;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export const MANDU_CHARACTERS: Record<ManduState, ManduCharacterData> = {
|
|
320
|
-
normal: {
|
|
321
|
-
state: 'normal',
|
|
322
|
-
|
|
323
|
-
message: '
|
|
324
|
-
},
|
|
325
|
-
warning: {
|
|
326
|
-
state: 'warning',
|
|
327
|
-
|
|
328
|
-
message: '
|
|
329
|
-
},
|
|
330
|
-
error: {
|
|
331
|
-
state: 'error',
|
|
332
|
-
|
|
333
|
-
message: '
|
|
334
|
-
},
|
|
335
|
-
loading: {
|
|
336
|
-
state: 'loading',
|
|
337
|
-
|
|
338
|
-
message: '
|
|
339
|
-
},
|
|
340
|
-
hmr: {
|
|
341
|
-
state: 'hmr',
|
|
342
|
-
|
|
343
|
-
message: '
|
|
344
|
-
},
|
|
345
|
-
};
|
|
311
|
+
export type ManduState = 'normal' | 'warning' | 'error' | 'loading' | 'hmr';
|
|
312
|
+
|
|
313
|
+
export interface ManduCharacterData {
|
|
314
|
+
state: ManduState;
|
|
315
|
+
mark: string;
|
|
316
|
+
message: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export const MANDU_CHARACTERS: Record<ManduState, ManduCharacterData> = {
|
|
320
|
+
normal: {
|
|
321
|
+
state: 'normal',
|
|
322
|
+
mark: 'OK',
|
|
323
|
+
message: '시스템이 안정적으로 동작 중입니다.',
|
|
324
|
+
},
|
|
325
|
+
warning: {
|
|
326
|
+
state: 'warning',
|
|
327
|
+
mark: 'WARN',
|
|
328
|
+
message: '확인이 필요한 항목이 있습니다.',
|
|
329
|
+
},
|
|
330
|
+
error: {
|
|
331
|
+
state: 'error',
|
|
332
|
+
mark: 'ERR',
|
|
333
|
+
message: '즉시 확인이 필요한 오류가 있습니다.',
|
|
334
|
+
},
|
|
335
|
+
loading: {
|
|
336
|
+
state: 'loading',
|
|
337
|
+
mark: 'SYNC',
|
|
338
|
+
message: '상태를 새로 불러오는 중입니다.',
|
|
339
|
+
},
|
|
340
|
+
hmr: {
|
|
341
|
+
state: 'hmr',
|
|
342
|
+
mark: 'HMR',
|
|
343
|
+
message: '코드 변경을 반영했습니다.',
|
|
344
|
+
},
|
|
345
|
+
};
|
package/src/filling/filling.ts
CHANGED
|
@@ -68,7 +68,7 @@ export interface LoaderCacheOptions {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** 렌더링 모드 */
|
|
71
|
-
export type RenderMode = "dynamic" | "isr" | "swr";
|
|
71
|
+
export type RenderMode = "dynamic" | "isr" | "swr" | "ppr";
|
|
72
72
|
|
|
73
73
|
/** Loader 타임아웃 에러 */
|
|
74
74
|
export class LoaderTimeoutError extends Error {
|
|
@@ -463,16 +463,16 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
463
463
|
return this;
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
async handle(
|
|
467
|
-
request: Request,
|
|
468
|
-
params: Record<string, string> = {},
|
|
469
|
-
routeContext?: { routeId: string; pattern: string },
|
|
470
|
-
options?: ExecuteOptions & { deps?: FillingDeps }
|
|
471
|
-
): Promise<Response> {
|
|
472
|
-
const deps = options?.deps ?? globalDeps.get();
|
|
473
|
-
const normalizedRequest = await applyMethodOverride(request);
|
|
474
|
-
const ctx = new ManduContext(normalizedRequest, params, deps);
|
|
475
|
-
const method = normalizedRequest.method.toUpperCase() as HttpMethod;
|
|
466
|
+
async handle(
|
|
467
|
+
request: Request,
|
|
468
|
+
params: Record<string, string> = {},
|
|
469
|
+
routeContext?: { routeId: string; pattern: string },
|
|
470
|
+
options?: ExecuteOptions & { deps?: FillingDeps }
|
|
471
|
+
): Promise<Response> {
|
|
472
|
+
const deps = options?.deps ?? globalDeps.get();
|
|
473
|
+
const normalizedRequest = await applyMethodOverride(request);
|
|
474
|
+
const ctx = new ManduContext(normalizedRequest, params, deps);
|
|
475
|
+
const method = normalizedRequest.method.toUpperCase() as HttpMethod;
|
|
476
476
|
|
|
477
477
|
// Action 디스패치: POST/PUT/PATCH/DELETE + 등록된 action이 있을 때
|
|
478
478
|
if (this.config.actions.size > 0 && method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
|
@@ -501,7 +501,7 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
501
501
|
return composed(ctx);
|
|
502
502
|
};
|
|
503
503
|
return executeLifecycle(lifecycleWithDefaults, ctx, runHandler, options);
|
|
504
|
-
}
|
|
504
|
+
}
|
|
505
505
|
|
|
506
506
|
/**
|
|
507
507
|
* Action 디스패치 시도
|
|
@@ -659,60 +659,60 @@ export class ManduFilling<TLoaderData = unknown> {
|
|
|
659
659
|
hasMethod(method: HttpMethod): boolean {
|
|
660
660
|
return this.config.handlers.has(method);
|
|
661
661
|
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
const OVERRIDABLE_METHODS = new Set<HttpMethod>(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
665
|
-
|
|
666
|
-
async function applyMethodOverride(request: Request): Promise<Request> {
|
|
667
|
-
if (request.method.toUpperCase() !== "POST") {
|
|
668
|
-
return request;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
const override = await detectMethodOverride(request);
|
|
672
|
-
if (!override || override === "POST") {
|
|
673
|
-
return request;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
return new Request(request, { method: override });
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
async function detectMethodOverride(request: Request): Promise<HttpMethod | null> {
|
|
680
|
-
const headerOverride = normalizeOverrideMethod(request.headers.get("X-HTTP-Method-Override"));
|
|
681
|
-
if (headerOverride) return headerOverride;
|
|
682
|
-
|
|
683
|
-
const url = new URL(request.url);
|
|
684
|
-
const queryOverride = normalizeOverrideMethod(url.searchParams.get("_method"));
|
|
685
|
-
if (queryOverride) return queryOverride;
|
|
686
|
-
|
|
687
|
-
const contentType = request.headers.get("content-type") ?? "";
|
|
688
|
-
const cloned = request.clone();
|
|
689
|
-
|
|
690
|
-
try {
|
|
691
|
-
if (contentType.includes("application/json")) {
|
|
692
|
-
const body = await cloned.json() as { _method?: unknown };
|
|
693
|
-
return normalizeOverrideMethod(typeof body?._method === "string" ? body._method : null);
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
if (
|
|
697
|
-
contentType.includes("application/x-www-form-urlencoded") ||
|
|
698
|
-
contentType.includes("multipart/form-data")
|
|
699
|
-
) {
|
|
700
|
-
const form = await cloned.formData();
|
|
701
|
-
const override = form.get("_method");
|
|
702
|
-
return normalizeOverrideMethod(typeof override === "string" ? override : null);
|
|
703
|
-
}
|
|
704
|
-
} catch {
|
|
705
|
-
return null;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
return null;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function normalizeOverrideMethod(value: string | null): HttpMethod | null {
|
|
712
|
-
if (!value) return null;
|
|
713
|
-
const method = value.toUpperCase() as HttpMethod;
|
|
714
|
-
return OVERRIDABLE_METHODS.has(method) ? method : null;
|
|
715
|
-
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const OVERRIDABLE_METHODS = new Set<HttpMethod>(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
665
|
+
|
|
666
|
+
async function applyMethodOverride(request: Request): Promise<Request> {
|
|
667
|
+
if (request.method.toUpperCase() !== "POST") {
|
|
668
|
+
return request;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const override = await detectMethodOverride(request);
|
|
672
|
+
if (!override || override === "POST") {
|
|
673
|
+
return request;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return new Request(request, { method: override });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function detectMethodOverride(request: Request): Promise<HttpMethod | null> {
|
|
680
|
+
const headerOverride = normalizeOverrideMethod(request.headers.get("X-HTTP-Method-Override"));
|
|
681
|
+
if (headerOverride) return headerOverride;
|
|
682
|
+
|
|
683
|
+
const url = new URL(request.url);
|
|
684
|
+
const queryOverride = normalizeOverrideMethod(url.searchParams.get("_method"));
|
|
685
|
+
if (queryOverride) return queryOverride;
|
|
686
|
+
|
|
687
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
688
|
+
const cloned = request.clone();
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
if (contentType.includes("application/json")) {
|
|
692
|
+
const body = await cloned.json() as { _method?: unknown };
|
|
693
|
+
return normalizeOverrideMethod(typeof body?._method === "string" ? body._method : null);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (
|
|
697
|
+
contentType.includes("application/x-www-form-urlencoded") ||
|
|
698
|
+
contentType.includes("multipart/form-data")
|
|
699
|
+
) {
|
|
700
|
+
const form = await cloned.formData();
|
|
701
|
+
const override = form.get("_method");
|
|
702
|
+
return normalizeOverrideMethod(typeof override === "string" ? override : null);
|
|
703
|
+
}
|
|
704
|
+
} catch {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function normalizeOverrideMethod(value: string | null): HttpMethod | null {
|
|
712
|
+
if (!value) return null;
|
|
713
|
+
const method = value.toUpperCase() as HttpMethod;
|
|
714
|
+
return OVERRIDABLE_METHODS.has(method) ? method : null;
|
|
715
|
+
}
|
|
716
716
|
|
|
717
717
|
/**
|
|
718
718
|
* Mandu Filling factory functions
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { RoutesManifest } from "../spec/schema";
|
|
9
9
|
import type { GuardConfig } from "../guard/types";
|
|
10
|
+
import { getGlobalCache, getCacheStoreStats } from "../runtime/cache";
|
|
10
11
|
import { ActivitySSEBroadcaster } from "./stream/activity-sse";
|
|
11
12
|
import { GuardAPI } from "./api/guard-api";
|
|
12
13
|
import { handleRoutesRequest } from "./api/routes-api";
|
|
@@ -14,6 +15,8 @@ import { FileAPI } from "./api/file-api";
|
|
|
14
15
|
import { GuardDecisionManager } from "./api/guard-decisions";
|
|
15
16
|
import { ContractPlaygroundAPI } from "./api/contract-api";
|
|
16
17
|
import { renderKitchenHTML } from "./kitchen-ui";
|
|
18
|
+
import fs from "fs/promises";
|
|
19
|
+
import path from "path";
|
|
17
20
|
|
|
18
21
|
export const KITCHEN_PREFIX = "/__kitchen";
|
|
19
22
|
|
|
@@ -50,6 +53,30 @@ export function clearKitchenErrors(): void {
|
|
|
50
53
|
storedErrors = [];
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
// ========== Request Ring Buffer ==========
|
|
57
|
+
|
|
58
|
+
export interface RequestEntry {
|
|
59
|
+
id: string;
|
|
60
|
+
method: string;
|
|
61
|
+
path: string;
|
|
62
|
+
status: number;
|
|
63
|
+
duration: number;
|
|
64
|
+
timestamp: number;
|
|
65
|
+
cacheStatus?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const MAX_REQUESTS = 100;
|
|
69
|
+
const recentRequests: RequestEntry[] = [];
|
|
70
|
+
|
|
71
|
+
export function recordRequest(entry: RequestEntry): void {
|
|
72
|
+
recentRequests.push(entry);
|
|
73
|
+
if (recentRequests.length > MAX_REQUESTS) recentRequests.shift();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getRecentRequests(): RequestEntry[] {
|
|
77
|
+
return [...recentRequests].reverse();
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
export class KitchenHandler {
|
|
54
81
|
private sse: ActivitySSEBroadcaster;
|
|
55
82
|
private guardAPI: GuardAPI;
|
|
@@ -67,8 +94,23 @@ export class KitchenHandler {
|
|
|
67
94
|
this.contractAPI = new ContractPlaygroundAPI(options.manifest, options.rootDir);
|
|
68
95
|
}
|
|
69
96
|
|
|
70
|
-
start(): void {
|
|
97
|
+
async start(): Promise<void> {
|
|
71
98
|
this.sse.start();
|
|
99
|
+
await this.loadPersistedErrors();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Load errors persisted from previous sessions */
|
|
103
|
+
private async loadPersistedErrors(): Promise<void> {
|
|
104
|
+
const errorsPath = path.join(this.options.rootDir, ".mandu", "errors.jsonl");
|
|
105
|
+
try {
|
|
106
|
+
const content = await fs.readFile(errorsPath, "utf-8");
|
|
107
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
108
|
+
for (const line of lines.slice(-MAX_STORED_ERRORS)) {
|
|
109
|
+
try {
|
|
110
|
+
storedErrors.push(JSON.parse(line));
|
|
111
|
+
} catch { /* skip malformed lines */ }
|
|
112
|
+
}
|
|
113
|
+
} catch { /* file doesn't exist yet — fine */ }
|
|
72
114
|
}
|
|
73
115
|
|
|
74
116
|
stop(): void {
|
|
@@ -180,6 +222,27 @@ export class KitchenHandler {
|
|
|
180
222
|
return Response.json({ removed: true });
|
|
181
223
|
}
|
|
182
224
|
|
|
225
|
+
// Requests API — recent HTTP request log
|
|
226
|
+
if (sub === "/api/requests" && req.method === "GET") {
|
|
227
|
+
return Response.json({ requests: getRecentRequests() });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Activity API — recent MCP tool calls from activity.jsonl
|
|
231
|
+
if (sub === "/api/activity" && req.method === "GET") {
|
|
232
|
+
const events = await this.readRecentActivity();
|
|
233
|
+
return Response.json({ events });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Cache API — cache store stats
|
|
237
|
+
if (sub === "/api/cache" && req.method === "GET") {
|
|
238
|
+
const store = getGlobalCache();
|
|
239
|
+
return Response.json({
|
|
240
|
+
enabled: !!store,
|
|
241
|
+
size: store?.size ?? 0,
|
|
242
|
+
stats: getCacheStoreStats(store),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
183
246
|
// Error API (Kitchen → MCP bridge)
|
|
184
247
|
if (sub === "/api/errors" && req.method === "POST") {
|
|
185
248
|
try {
|
|
@@ -193,6 +256,10 @@ export class KitchenHandler {
|
|
|
193
256
|
if (storedErrors.length > MAX_STORED_ERRORS) {
|
|
194
257
|
storedErrors.shift();
|
|
195
258
|
}
|
|
259
|
+
// Persist to disk
|
|
260
|
+
const errorLine = JSON.stringify(error) + "\n";
|
|
261
|
+
const errorsPath = path.join(this.options.rootDir, ".mandu", "errors.jsonl");
|
|
262
|
+
fs.appendFile(errorsPath, errorLine).catch(() => {});
|
|
196
263
|
}
|
|
197
264
|
return Response.json({ received: errors.length, total: storedErrors.length });
|
|
198
265
|
} catch {
|
|
@@ -253,4 +320,16 @@ export class KitchenHandler {
|
|
|
253
320
|
{ status: 404 },
|
|
254
321
|
);
|
|
255
322
|
}
|
|
323
|
+
|
|
324
|
+
/** Read last 50 entries from .mandu/activity.jsonl */
|
|
325
|
+
private async readRecentActivity(): Promise<unknown[]> {
|
|
326
|
+
const logPath = path.join(this.options.rootDir, ".mandu", "activity.jsonl");
|
|
327
|
+
try {
|
|
328
|
+
const content = await fs.readFile(logPath, "utf-8");
|
|
329
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
330
|
+
return lines.slice(-50).reverse().map((line) => {
|
|
331
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
332
|
+
}).filter(Boolean);
|
|
333
|
+
} catch { return []; }
|
|
334
|
+
}
|
|
256
335
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Mandu Unified EventBus -- foundation for the observability system. */
|
|
2
|
+
|
|
3
|
+
export type EventType = "http" | "mcp" | "guard" | "build" | "error" | "cache" | "ws";
|
|
4
|
+
export type ObservabilitySeverity = "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
export interface ObservabilityEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
correlationId?: string;
|
|
9
|
+
type: EventType;
|
|
10
|
+
severity: ObservabilitySeverity;
|
|
11
|
+
source: string;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
message: string;
|
|
14
|
+
data?: Record<string, unknown>;
|
|
15
|
+
duration?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type EventHandler = (event: ObservabilityEvent) => void;
|
|
19
|
+
|
|
20
|
+
class ManduEventBus {
|
|
21
|
+
private handlers = new Map<string, Set<EventHandler>>();
|
|
22
|
+
private recent: ObservabilityEvent[] = [];
|
|
23
|
+
private maxRecent = 200;
|
|
24
|
+
|
|
25
|
+
on(type: EventType | "*", handler: EventHandler): () => void {
|
|
26
|
+
let set = this.handlers.get(type);
|
|
27
|
+
if (!set) {
|
|
28
|
+
set = new Set();
|
|
29
|
+
this.handlers.set(type, set);
|
|
30
|
+
}
|
|
31
|
+
set.add(handler);
|
|
32
|
+
return () => { set!.delete(handler); };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
emit(event: Omit<ObservabilityEvent, "id" | "timestamp">): void {
|
|
36
|
+
const full: ObservabilityEvent = {
|
|
37
|
+
...event,
|
|
38
|
+
id: crypto.randomUUID(),
|
|
39
|
+
timestamp: Date.now(),
|
|
40
|
+
};
|
|
41
|
+
this.recent.push(full);
|
|
42
|
+
if (this.recent.length > this.maxRecent) {
|
|
43
|
+
this.recent = this.recent.slice(-this.maxRecent);
|
|
44
|
+
}
|
|
45
|
+
this.handlers.get(full.type)?.forEach((h) => h(full));
|
|
46
|
+
this.handlers.get("*")?.forEach((h) => h(full));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getRecent(
|
|
50
|
+
count?: number,
|
|
51
|
+
filter?: { type?: EventType; severity?: ObservabilitySeverity },
|
|
52
|
+
): ObservabilityEvent[] {
|
|
53
|
+
let result = this.recent;
|
|
54
|
+
if (filter?.type) result = result.filter((e) => e.type === filter.type);
|
|
55
|
+
if (filter?.severity) result = result.filter((e) => e.severity === filter.severity);
|
|
56
|
+
return count ? result.slice(-count) : result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getStats(windowMs?: number): Record<EventType, { count: number; errors: number; avgDuration: number }> {
|
|
60
|
+
const cutoff = windowMs ? Date.now() - windowMs : 0;
|
|
61
|
+
const ALL: EventType[] = ["http", "mcp", "guard", "build", "error", "cache", "ws"];
|
|
62
|
+
const stats = {} as Record<EventType, { count: number; errors: number; avgDuration: number }>;
|
|
63
|
+
const dur = {} as Record<EventType, number[]>;
|
|
64
|
+
for (const t of ALL) { stats[t] = { count: 0, errors: 0, avgDuration: 0 }; dur[t] = []; }
|
|
65
|
+
for (const e of this.recent) {
|
|
66
|
+
if (e.timestamp < cutoff) continue;
|
|
67
|
+
stats[e.type].count++;
|
|
68
|
+
if (e.severity === "error") stats[e.type].errors++;
|
|
69
|
+
if (e.duration !== undefined) dur[e.type].push(e.duration);
|
|
70
|
+
}
|
|
71
|
+
for (const t of ALL) {
|
|
72
|
+
const d = dur[t];
|
|
73
|
+
stats[t].avgDuration = d.length ? d.reduce((a, b) => a + b, 0) / d.length : 0;
|
|
74
|
+
}
|
|
75
|
+
return stats;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const eventBus = new ManduEventBus();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger -> EventBus adapter
|
|
3
|
+
* Bridges the existing logger sink pattern into the unified observability bus.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { eventBus } from "./event-bus";
|
|
7
|
+
import type { ObservabilitySeverity } from "./event-bus";
|
|
8
|
+
import type { LogEntry, LogLevel } from "../runtime/logger";
|
|
9
|
+
|
|
10
|
+
const LEVEL_MAP: Record<LogLevel, ObservabilitySeverity> = {
|
|
11
|
+
debug: "info",
|
|
12
|
+
info: "info",
|
|
13
|
+
warn: "warn",
|
|
14
|
+
error: "error",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function connectLoggerToEventBus(loggerInstance: { sink?: (entry: LogEntry) => void } & Record<string, unknown>): void {
|
|
18
|
+
const originalSink = loggerInstance.sink as ((entry: LogEntry) => void) | undefined;
|
|
19
|
+
loggerInstance.sink = (entry: LogEntry) => {
|
|
20
|
+
originalSink?.(entry);
|
|
21
|
+
eventBus.emit({
|
|
22
|
+
type: "http",
|
|
23
|
+
severity: LEVEL_MAP[entry.level] ?? "info",
|
|
24
|
+
source: "logger",
|
|
25
|
+
message: `${entry.method} ${entry.path} ${entry.status ?? ""}`.trim(),
|
|
26
|
+
duration: entry.duration,
|
|
27
|
+
data: {
|
|
28
|
+
requestId: entry.requestId,
|
|
29
|
+
method: entry.method,
|
|
30
|
+
path: entry.path,
|
|
31
|
+
status: entry.status,
|
|
32
|
+
slow: entry.slow,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from "./ssr";
|
|
2
2
|
export * from "./streaming-ssr";
|
|
3
|
+
export { extractShellHtml, createPPRResponse, PPR_SHELL_MARKER } from "./ppr";
|
|
3
4
|
export * from "./router";
|
|
4
5
|
export * from "./server";
|
|
5
6
|
export * from "./cors";
|
|
@@ -10,14 +11,14 @@ export * from "./trace";
|
|
|
10
11
|
export * from "./logger";
|
|
11
12
|
export * from "./boundary";
|
|
12
13
|
export * from "./stable-selector";
|
|
13
|
-
export {
|
|
14
|
-
revalidatePath,
|
|
15
|
-
revalidateTag,
|
|
16
|
-
getCacheStoreStats,
|
|
17
|
-
type CacheStore,
|
|
18
|
-
type CacheStoreStats,
|
|
19
|
-
MemoryCacheStore,
|
|
20
|
-
} from "./cache";
|
|
14
|
+
export {
|
|
15
|
+
revalidatePath,
|
|
16
|
+
revalidateTag,
|
|
17
|
+
getCacheStoreStats,
|
|
18
|
+
type CacheStore,
|
|
19
|
+
type CacheStoreStats,
|
|
20
|
+
MemoryCacheStore,
|
|
21
|
+
} from "./cache";
|
|
21
22
|
export { type MiddlewareContext, type MiddlewareNext, type MiddlewareFn, type MiddlewareConfig } from "./middleware";
|
|
22
23
|
export { type ManduAdapter, type AdapterOptions, type AdapterServer } from "./adapter";
|
|
23
24
|
export { adapterBun } from "./adapter-bun";
|