@mandujs/core 0.54.12 → 0.54.14
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 +9 -1
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/run-audit.ts +15 -15
- package/src/agent/__tests__/context.test.ts +49 -1
- package/src/agent/context.ts +535 -535
- package/src/agent/index.ts +6 -6
- package/src/agent/plan.ts +282 -282
- package/src/agent/repair.ts +171 -171
- package/src/agent/sync.ts +200 -200
- package/src/agent/types.ts +8 -0
- package/src/agent/verify.ts +100 -2
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/build-runner.ts +36 -16
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +20 -20
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -17
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/build.test.ts +136 -48
- package/src/bundler/build.ts +193 -122
- package/src/bundler/css.ts +42 -42
- package/src/bundler/manifest-schema.ts +21 -21
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/block-generated-imports.ts +13 -13
- package/src/bundler/types.ts +31 -31
- package/src/client/island.ts +79 -79
- package/src/config/validate.ts +1 -1
- package/src/contract/schema.ts +7 -0
- package/src/deploy/inference/context.ts +82 -82
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/error/formatter.ts +10 -1
- package/src/experimental/index.ts +10 -0
- package/src/filling/context.ts +17 -17
- package/src/filling/filling.ts +22 -1
- package/src/filling/index.ts +15 -1
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +210 -210
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/index.ts +11 -11
- package/src/index.ts +0 -10
- package/src/internal/index.ts +25 -0
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +6 -6
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +84 -41
- package/src/router/client-entry.ts +156 -89
- package/src/router/fs-routes.test.ts +90 -0
- package/src/router/fs-routes.ts +37 -29
- package/src/router/fs-scanner.ts +81 -33
- package/src/router/fs-types.ts +8 -5
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +164 -103
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/image-feature.ts +15 -0
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/page-render-response.ts +208 -106
- package/src/runtime/rate-limit.ts +231 -0
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -12
- package/src/runtime/scheduler-lifecycle.ts +64 -0
- package/src/runtime/server.ts +148 -353
- package/src/runtime/ssr.ts +59 -59
- package/src/runtime/static-files.ts +289 -289
- package/src/runtime/streaming-ssr.ts +22 -22
- package/src/spec/schema.ts +4 -3
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
package/src/router/fs-scanner.ts
CHANGED
|
@@ -28,16 +28,55 @@ import {
|
|
|
28
28
|
sortRoutesByPriority,
|
|
29
29
|
getPatternShape,
|
|
30
30
|
} from "./fs-patterns";
|
|
31
|
-
import { mark, measure } from "../perf";
|
|
32
|
-
import { METADATA_ROUTES } from "../routes/types";
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
import { mark, measure } from "../perf";
|
|
32
|
+
import { METADATA_ROUTES } from "../routes/types";
|
|
33
|
+
import type { HydrationConfig } from "../spec/schema";
|
|
34
|
+
import {
|
|
35
|
+
hasUseClientDirective,
|
|
36
|
+
resolveRouteLevelClientEntry,
|
|
37
|
+
} from "./client-entry";
|
|
38
|
+
|
|
39
|
+
const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
|
|
40
|
+
const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
|
|
41
|
+
|
|
42
|
+
function parsePageHydrationConfig(source: string): HydrationConfig | undefined {
|
|
43
|
+
const stringMatch = source.match(
|
|
44
|
+
/export\s+const\s+hydration\s*(?::[^=]+)?=\s*["'](none|island|full|progressive)["']/m,
|
|
45
|
+
);
|
|
46
|
+
if (stringMatch?.[1]) {
|
|
47
|
+
return {
|
|
48
|
+
strategy: stringMatch[1] as HydrationConfig["strategy"],
|
|
49
|
+
priority: "visible",
|
|
50
|
+
preload: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const objectMatch = source.match(
|
|
55
|
+
/export\s+const\s+hydration\s*(?::[^=]+)?=\s*\{([\s\S]*?)\}\s*;?/m,
|
|
56
|
+
);
|
|
57
|
+
const body = objectMatch?.[1];
|
|
58
|
+
if (!body) return undefined;
|
|
59
|
+
|
|
60
|
+
const strategyMatch = body.match(/\bstrategy\s*:\s*["']([^"']+)["']/);
|
|
61
|
+
const strategy = strategyMatch?.[1];
|
|
62
|
+
if (!strategy || !HYDRATION_STRATEGIES.has(strategy)) return undefined;
|
|
63
|
+
|
|
64
|
+
const priorityMatch = body.match(/\bpriority\s*:\s*["']([^"']+)["']/);
|
|
65
|
+
const priority = priorityMatch?.[1];
|
|
66
|
+
const preloadMatch = body.match(/\bpreload\s*:\s*(true|false)\b/);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
strategy: strategy as HydrationConfig["strategy"],
|
|
70
|
+
priority: HYDRATION_PRIORITIES.has(priority ?? "")
|
|
71
|
+
? (priority as HydrationConfig["priority"])
|
|
72
|
+
: "visible",
|
|
73
|
+
preload: preloadMatch?.[1] === "true",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
// Scanner Class
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
41
80
|
|
|
42
81
|
/**
|
|
43
82
|
* FS Routes 스캐너
|
|
@@ -364,17 +403,22 @@ export class FSScanner {
|
|
|
364
403
|
const dirPath = this.getDirPath(file.relativePath);
|
|
365
404
|
const islands = islandMap.get(dirPath);
|
|
366
405
|
|
|
367
|
-
// clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
|
|
368
|
-
let clientModule: string | undefined;
|
|
369
|
-
let
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
pageFileContent =
|
|
376
|
-
}
|
|
377
|
-
|
|
406
|
+
// clientModule 결정: island 파일 또는 "use client"가 있는 page 자체
|
|
407
|
+
let clientModule: string | undefined;
|
|
408
|
+
let clientExportName: string | undefined;
|
|
409
|
+
let hydration: HydrationConfig | undefined;
|
|
410
|
+
let pageFileContent: string | null = null;
|
|
411
|
+
|
|
412
|
+
if (file.type === "page") {
|
|
413
|
+
try {
|
|
414
|
+
pageFileContent = await Bun.file(file.absolutePath).text();
|
|
415
|
+
} catch {
|
|
416
|
+
pageFileContent = null;
|
|
417
|
+
}
|
|
418
|
+
if (pageFileContent) {
|
|
419
|
+
hydration = parsePageHydrationConfig(pageFileContent);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
378
422
|
|
|
379
423
|
if (islands?.[0]) {
|
|
380
424
|
// 우선순위: 명시적 island 파일
|
|
@@ -395,12 +439,14 @@ export class FSScanner {
|
|
|
395
439
|
} else if (file.type === "page" && pageFileContent) {
|
|
396
440
|
// page 파일 자체에서 "use client" 확인
|
|
397
441
|
const hasUseClient = hasUseClientDirective(pageFileContent);
|
|
398
|
-
if (hasUseClient) {
|
|
399
|
-
clientModule = modulePath;
|
|
400
|
-
} else {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
442
|
+
if (hasUseClient) {
|
|
443
|
+
clientModule = modulePath;
|
|
444
|
+
} else {
|
|
445
|
+
const routeClientEntry = await resolveRouteLevelClientEntry(rootDir, modulePath, pageFileContent);
|
|
446
|
+
clientModule = routeClientEntry?.modulePath;
|
|
447
|
+
clientExportName = routeClientEntry?.exportName;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
404
450
|
|
|
405
451
|
// 로딩/에러/404 모듈 찾기 — nearest-ancestor resolution.
|
|
406
452
|
// Phase 18.β: `not-found.tsx` joins `loading.tsx`/`error.tsx` in
|
|
@@ -415,12 +461,14 @@ export class FSScanner {
|
|
|
415
461
|
segments: file.segments,
|
|
416
462
|
pattern,
|
|
417
463
|
kind: file.type === "page" ? "page" : "api",
|
|
418
|
-
module: modulePath,
|
|
419
|
-
componentModule: file.type === "page" ? modulePath : undefined,
|
|
420
|
-
clientModule,
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
464
|
+
module: modulePath,
|
|
465
|
+
componentModule: file.type === "page" ? modulePath : undefined,
|
|
466
|
+
clientModule,
|
|
467
|
+
clientExportName,
|
|
468
|
+
hydration,
|
|
469
|
+
layoutChain,
|
|
470
|
+
loadingModule,
|
|
471
|
+
errorModule,
|
|
424
472
|
notFoundModule,
|
|
425
473
|
sourceFile: file.absolutePath,
|
|
426
474
|
};
|
package/src/router/fs-types.ts
CHANGED
|
@@ -117,11 +117,14 @@ export interface FSRouteConfig {
|
|
|
117
117
|
/** API 핸들러 모듈 경로 */
|
|
118
118
|
module: string;
|
|
119
119
|
|
|
120
|
-
/** Island 컴포넌트 모듈 경로 */
|
|
121
|
-
clientModule?: string;
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
|
|
120
|
+
/** Island 컴포넌트 모듈 경로 */
|
|
121
|
+
clientModule?: string;
|
|
122
|
+
|
|
123
|
+
/** Named export used by the route-level client module, if not default. */
|
|
124
|
+
clientExportName?: string;
|
|
125
|
+
|
|
126
|
+
/** 적용할 레이아웃 체인 */
|
|
127
|
+
layoutChain: string[];
|
|
125
128
|
|
|
126
129
|
/** 로딩 UI 모듈 경로 */
|
|
127
130
|
loadingModule?: string;
|
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import type { RoutesManifest } from "../../spec/schema";
|
|
3
|
-
import {
|
|
4
|
-
createRuntimeDevtoolsAdapter,
|
|
5
|
-
shouldRecordRuntimeRequest,
|
|
6
|
-
type RuntimeDevtoolsAdapter,
|
|
7
|
-
} from "../devtools-adapter";
|
|
8
|
-
|
|
9
|
-
const manifest: RoutesManifest = {
|
|
10
|
-
version: 1,
|
|
11
|
-
routes: [],
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
describe("runtime devtools adapter", () => {
|
|
15
|
-
let adapter: RuntimeDevtoolsAdapter | null = null;
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
adapter?.stop();
|
|
19
|
-
adapter = null;
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("stays disabled outside dev mode", async () => {
|
|
23
|
-
adapter = createRuntimeDevtoolsAdapter({
|
|
24
|
-
isDev: false,
|
|
25
|
-
rootDir: process.cwd(),
|
|
26
|
-
manifest,
|
|
27
|
-
guardConfig: null,
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
expect(adapter.kitchen).toBeNull();
|
|
31
|
-
expect(adapter.dashboardPath).toBeNull();
|
|
32
|
-
const response = await adapter.handleRequest(
|
|
33
|
-
new Request("http://localhost:3000/__kitchen"),
|
|
34
|
-
"/__kitchen"
|
|
35
|
-
);
|
|
36
|
-
expect(response).toBeNull();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("dispatches Kitchen requests in dev mode", async () => {
|
|
40
|
-
adapter = createRuntimeDevtoolsAdapter({
|
|
41
|
-
isDev: true,
|
|
42
|
-
rootDir: process.cwd(),
|
|
43
|
-
manifest,
|
|
44
|
-
guardConfig: null,
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
expect(adapter.kitchen).not.toBeNull();
|
|
48
|
-
expect(adapter.dashboardPath).toBe("/__kitchen");
|
|
49
|
-
await expect(
|
|
50
|
-
adapter.handleRequest(new Request("http://localhost:3000/api/ping"), "/api/ping")
|
|
51
|
-
).resolves.toBeNull();
|
|
52
|
-
|
|
53
|
-
const response = await adapter.handleRequest(
|
|
54
|
-
new Request("http://localhost:3000/__kitchen"),
|
|
55
|
-
"/__kitchen"
|
|
56
|
-
);
|
|
57
|
-
expect(response?.status).toBe(200);
|
|
58
|
-
expect(await response?.text()).toContain("Mandu Kitchen");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("keeps framework-internal request paths out of the Kitchen request log", () => {
|
|
62
|
-
expect(shouldRecordRuntimeRequest("/api/ping")).toBe(true);
|
|
63
|
-
expect(shouldRecordRuntimeRequest("/_mandu/heap")).toBe(true);
|
|
64
|
-
expect(shouldRecordRuntimeRequest("/.mandu/client/runtime.js")).toBe(false);
|
|
65
|
-
expect(shouldRecordRuntimeRequest("/__kitchen/api/events")).toBe(false);
|
|
66
|
-
expect(shouldRecordRuntimeRequest("/__mandu/events/recent")).toBe(false);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import type { RoutesManifest } from "../../spec/schema";
|
|
3
|
+
import {
|
|
4
|
+
createRuntimeDevtoolsAdapter,
|
|
5
|
+
shouldRecordRuntimeRequest,
|
|
6
|
+
type RuntimeDevtoolsAdapter,
|
|
7
|
+
} from "../devtools-adapter";
|
|
8
|
+
|
|
9
|
+
const manifest: RoutesManifest = {
|
|
10
|
+
version: 1,
|
|
11
|
+
routes: [],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("runtime devtools adapter", () => {
|
|
15
|
+
let adapter: RuntimeDevtoolsAdapter | null = null;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
adapter?.stop();
|
|
19
|
+
adapter = null;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("stays disabled outside dev mode", async () => {
|
|
23
|
+
adapter = createRuntimeDevtoolsAdapter({
|
|
24
|
+
isDev: false,
|
|
25
|
+
rootDir: process.cwd(),
|
|
26
|
+
manifest,
|
|
27
|
+
guardConfig: null,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(adapter.kitchen).toBeNull();
|
|
31
|
+
expect(adapter.dashboardPath).toBeNull();
|
|
32
|
+
const response = await adapter.handleRequest(
|
|
33
|
+
new Request("http://localhost:3000/__kitchen"),
|
|
34
|
+
"/__kitchen"
|
|
35
|
+
);
|
|
36
|
+
expect(response).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("dispatches Kitchen requests in dev mode", async () => {
|
|
40
|
+
adapter = createRuntimeDevtoolsAdapter({
|
|
41
|
+
isDev: true,
|
|
42
|
+
rootDir: process.cwd(),
|
|
43
|
+
manifest,
|
|
44
|
+
guardConfig: null,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(adapter.kitchen).not.toBeNull();
|
|
48
|
+
expect(adapter.dashboardPath).toBe("/__kitchen");
|
|
49
|
+
await expect(
|
|
50
|
+
adapter.handleRequest(new Request("http://localhost:3000/api/ping"), "/api/ping")
|
|
51
|
+
).resolves.toBeNull();
|
|
52
|
+
|
|
53
|
+
const response = await adapter.handleRequest(
|
|
54
|
+
new Request("http://localhost:3000/__kitchen"),
|
|
55
|
+
"/__kitchen"
|
|
56
|
+
);
|
|
57
|
+
expect(response?.status).toBe(200);
|
|
58
|
+
expect(await response?.text()).toContain("Mandu Kitchen");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps framework-internal request paths out of the Kitchen request log", () => {
|
|
62
|
+
expect(shouldRecordRuntimeRequest("/api/ping")).toBe(true);
|
|
63
|
+
expect(shouldRecordRuntimeRequest("/_mandu/heap")).toBe(true);
|
|
64
|
+
expect(shouldRecordRuntimeRequest("/.mandu/client/runtime.js")).toBe(false);
|
|
65
|
+
expect(shouldRecordRuntimeRequest("/__kitchen/api/events")).toBe(false);
|
|
66
|
+
expect(shouldRecordRuntimeRequest("/__mandu/events/recent")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
INTERNAL_EVENTS_ENDPOINT,
|
|
4
|
-
createRuntimeObservabilityLifecycle,
|
|
5
|
-
} from "../observability-lifecycle";
|
|
6
|
-
import { eventBus } from "../../observability/event-bus";
|
|
7
|
-
import {
|
|
8
|
-
resetTracer,
|
|
9
|
-
type Span,
|
|
10
|
-
type SpanExporter,
|
|
11
|
-
} from "../../observability/tracing";
|
|
12
|
-
|
|
13
|
-
class CaptureExporter implements SpanExporter {
|
|
14
|
-
readonly spans: Span[] = [];
|
|
15
|
-
export(spans: Span[]): void {
|
|
16
|
-
this.spans.push(...spans);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
resetTracer();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
describe("runtime observability lifecycle", () => {
|
|
25
|
-
test("serves heap snapshots with perf data when exposed", async () => {
|
|
26
|
-
const lifecycle = createRuntimeObservabilityLifecycle({ isDev: true });
|
|
27
|
-
const response = lifecycle.handleEndpoint(
|
|
28
|
-
new Request("http://localhost/_mandu/heap"),
|
|
29
|
-
"/_mandu/heap"
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
expect(response?.status).toBe(200);
|
|
33
|
-
const body = await response!.json() as {
|
|
34
|
-
process: { heapUsed: number };
|
|
35
|
-
perf: unknown;
|
|
36
|
-
};
|
|
37
|
-
expect(body.process.heapUsed).toBeGreaterThan(0);
|
|
38
|
-
expect(body.perf).toBeDefined();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("keeps metrics hidden in production unless explicitly enabled", () => {
|
|
42
|
-
const hidden = createRuntimeObservabilityLifecycle({ isDev: false });
|
|
43
|
-
const shown = createRuntimeObservabilityLifecycle({
|
|
44
|
-
isDev: false,
|
|
45
|
-
metricsEndpoint: true,
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
expect(hidden.handleEndpoint(new Request("http://localhost/_mandu/metrics"), "/_mandu/metrics")).toBeNull();
|
|
49
|
-
expect(shown.handleEndpoint(new Request("http://localhost/_mandu/metrics"), "/_mandu/metrics")?.status).toBe(200);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("serves recent EventBus snapshots from the lifecycle endpoint", async () => {
|
|
53
|
-
const lifecycle = createRuntimeObservabilityLifecycle({ isDev: true });
|
|
54
|
-
const source = `observability-lifecycle-${Date.now()}`;
|
|
55
|
-
eventBus.emit({
|
|
56
|
-
type: "http",
|
|
57
|
-
severity: "info",
|
|
58
|
-
source,
|
|
59
|
-
message: "GET /observed 200",
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const response = lifecycle.handleEndpoint(
|
|
63
|
-
new Request(`http://localhost${INTERNAL_EVENTS_ENDPOINT}/recent?source=${source}`),
|
|
64
|
-
`${INTERNAL_EVENTS_ENDPOINT}/recent`
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
expect(response?.status).toBe(200);
|
|
68
|
-
const body = await response!.json() as {
|
|
69
|
-
events: Array<{ source: string; message: string }>;
|
|
70
|
-
};
|
|
71
|
-
expect(body.events.some((event) => event.source === source)).toBe(true);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("wraps requests in a root tracing span", async () => {
|
|
75
|
-
const exporter = new CaptureExporter();
|
|
76
|
-
const lifecycle = createRuntimeObservabilityLifecycle({
|
|
77
|
-
isDev: false,
|
|
78
|
-
tracing: {
|
|
79
|
-
enabled: true,
|
|
80
|
-
customExporter: exporter,
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
const incomingTraceId = "4bf92f3577b34da6a3ce929d0e0e4736";
|
|
84
|
-
const incomingSpanId = "00f067aa0ba902b7";
|
|
85
|
-
const response = await lifecycle.runRequest(
|
|
86
|
-
new Request("https://example.test/users", {
|
|
87
|
-
headers: {
|
|
88
|
-
traceparent: `00-${incomingTraceId}-${incomingSpanId}-01`,
|
|
89
|
-
},
|
|
90
|
-
}),
|
|
91
|
-
Date.now(),
|
|
92
|
-
"corr-1",
|
|
93
|
-
async () => new Response("created", { status: 201 })
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
expect(response.status).toBe(201);
|
|
97
|
-
expect(exporter.spans).toHaveLength(1);
|
|
98
|
-
expect(exporter.spans[0].traceId).toBe(incomingTraceId);
|
|
99
|
-
expect(exporter.spans[0].parentSpanId).toBe(incomingSpanId);
|
|
100
|
-
expect(exporter.spans[0].attributes["http.status_code"]).toBe(201);
|
|
101
|
-
expect(exporter.spans[0].status).toBe("ok");
|
|
102
|
-
});
|
|
103
|
-
});
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
INTERNAL_EVENTS_ENDPOINT,
|
|
4
|
+
createRuntimeObservabilityLifecycle,
|
|
5
|
+
} from "../observability-lifecycle";
|
|
6
|
+
import { eventBus } from "../../observability/event-bus";
|
|
7
|
+
import {
|
|
8
|
+
resetTracer,
|
|
9
|
+
type Span,
|
|
10
|
+
type SpanExporter,
|
|
11
|
+
} from "../../observability/tracing";
|
|
12
|
+
|
|
13
|
+
class CaptureExporter implements SpanExporter {
|
|
14
|
+
readonly spans: Span[] = [];
|
|
15
|
+
export(spans: Span[]): void {
|
|
16
|
+
this.spans.push(...spans);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
resetTracer();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("runtime observability lifecycle", () => {
|
|
25
|
+
test("serves heap snapshots with perf data when exposed", async () => {
|
|
26
|
+
const lifecycle = createRuntimeObservabilityLifecycle({ isDev: true });
|
|
27
|
+
const response = lifecycle.handleEndpoint(
|
|
28
|
+
new Request("http://localhost/_mandu/heap"),
|
|
29
|
+
"/_mandu/heap"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(response?.status).toBe(200);
|
|
33
|
+
const body = await response!.json() as {
|
|
34
|
+
process: { heapUsed: number };
|
|
35
|
+
perf: unknown;
|
|
36
|
+
};
|
|
37
|
+
expect(body.process.heapUsed).toBeGreaterThan(0);
|
|
38
|
+
expect(body.perf).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("keeps metrics hidden in production unless explicitly enabled", () => {
|
|
42
|
+
const hidden = createRuntimeObservabilityLifecycle({ isDev: false });
|
|
43
|
+
const shown = createRuntimeObservabilityLifecycle({
|
|
44
|
+
isDev: false,
|
|
45
|
+
metricsEndpoint: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(hidden.handleEndpoint(new Request("http://localhost/_mandu/metrics"), "/_mandu/metrics")).toBeNull();
|
|
49
|
+
expect(shown.handleEndpoint(new Request("http://localhost/_mandu/metrics"), "/_mandu/metrics")?.status).toBe(200);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("serves recent EventBus snapshots from the lifecycle endpoint", async () => {
|
|
53
|
+
const lifecycle = createRuntimeObservabilityLifecycle({ isDev: true });
|
|
54
|
+
const source = `observability-lifecycle-${Date.now()}`;
|
|
55
|
+
eventBus.emit({
|
|
56
|
+
type: "http",
|
|
57
|
+
severity: "info",
|
|
58
|
+
source,
|
|
59
|
+
message: "GET /observed 200",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const response = lifecycle.handleEndpoint(
|
|
63
|
+
new Request(`http://localhost${INTERNAL_EVENTS_ENDPOINT}/recent?source=${source}`),
|
|
64
|
+
`${INTERNAL_EVENTS_ENDPOINT}/recent`
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(response?.status).toBe(200);
|
|
68
|
+
const body = await response!.json() as {
|
|
69
|
+
events: Array<{ source: string; message: string }>;
|
|
70
|
+
};
|
|
71
|
+
expect(body.events.some((event) => event.source === source)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("wraps requests in a root tracing span", async () => {
|
|
75
|
+
const exporter = new CaptureExporter();
|
|
76
|
+
const lifecycle = createRuntimeObservabilityLifecycle({
|
|
77
|
+
isDev: false,
|
|
78
|
+
tracing: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
customExporter: exporter,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const incomingTraceId = "4bf92f3577b34da6a3ce929d0e0e4736";
|
|
84
|
+
const incomingSpanId = "00f067aa0ba902b7";
|
|
85
|
+
const response = await lifecycle.runRequest(
|
|
86
|
+
new Request("https://example.test/users", {
|
|
87
|
+
headers: {
|
|
88
|
+
traceparent: `00-${incomingTraceId}-${incomingSpanId}-01`,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
Date.now(),
|
|
92
|
+
"corr-1",
|
|
93
|
+
async () => new Response("created", { status: 201 })
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(response.status).toBe(201);
|
|
97
|
+
expect(exporter.spans).toHaveLength(1);
|
|
98
|
+
expect(exporter.spans[0].traceId).toBe(incomingTraceId);
|
|
99
|
+
expect(exporter.spans[0].parentSpanId).toBe(incomingSpanId);
|
|
100
|
+
expect(exporter.spans[0].attributes["http.status_code"]).toBe(201);
|
|
101
|
+
expect(exporter.spans[0].status).toBe("ok");
|
|
102
|
+
});
|
|
103
|
+
});
|