@mandujs/core 0.54.1 → 0.54.3

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.
Files changed (37) hide show
  1. package/package.json +4 -2
  2. package/scripts/postinstall-lock.ts +153 -0
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/brain/doctor/analyzer.ts +7 -7
  5. package/src/bundler/__tests__/cold-start.test.ts +35 -7
  6. package/src/bundler/analyzer.ts +15 -7
  7. package/src/bundler/build.test.ts +13 -6
  8. package/src/bundler/build.ts +429 -182
  9. package/src/bundler/manifest-schema.ts +21 -14
  10. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
  11. package/src/bundler/plugins/block-generated-imports.ts +13 -12
  12. package/src/bundler/types.ts +31 -14
  13. package/src/client/island.ts +79 -29
  14. package/src/config/validate.ts +1 -1
  15. package/src/deploy/inference/context.ts +82 -15
  16. package/src/filling/context.ts +17 -4
  17. package/src/guard/check.ts +9 -9
  18. package/src/guard/config-guard.ts +13 -7
  19. package/src/guard/fs-routes-policy.ts +51 -0
  20. package/src/guard/index.ts +11 -6
  21. package/src/kitchen/api/file-api.ts +11 -8
  22. package/src/resource/__tests__/schema.test.ts +14 -9
  23. package/src/resource/generators/slot.ts +72 -71
  24. package/src/resource/schema.ts +21 -13
  25. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
  26. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
  27. package/src/runtime/__tests__/page-render-response.test.ts +103 -0
  28. package/src/runtime/__tests__/request-middleware.test.ts +70 -0
  29. package/src/runtime/devtools-adapter.ts +68 -0
  30. package/src/runtime/escape.ts +34 -6
  31. package/src/runtime/observability-lifecycle.ts +290 -0
  32. package/src/runtime/page-render-response.ts +106 -0
  33. package/src/runtime/request-middleware.ts +31 -0
  34. package/src/runtime/server.ts +228 -944
  35. package/src/runtime/ssr.ts +59 -37
  36. package/src/runtime/static-files.ts +289 -0
  37. package/src/runtime/streaming-ssr.ts +22 -13
@@ -0,0 +1,68 @@
1
+ import type { RoutesManifest } from "../spec/schema";
2
+ import type { GuardConfig } from "../guard/types";
3
+ import {
4
+ KITCHEN_PREFIX,
5
+ KitchenHandler,
6
+ recordRequest,
7
+ type RequestEntry,
8
+ } from "../kitchen/kitchen-handler";
9
+
10
+ export type RuntimeKitchenHandler = KitchenHandler;
11
+
12
+ export interface RuntimeDevtoolsAdapter {
13
+ readonly kitchen: RuntimeKitchenHandler | null;
14
+ readonly dashboardPath: string | null;
15
+ start(): void;
16
+ stop(): void;
17
+ updateManifest(manifest: RoutesManifest): void;
18
+ handleRequest(req: Request, pathname: string): Promise<Response | null>;
19
+ }
20
+
21
+ export interface CreateRuntimeDevtoolsAdapterOptions {
22
+ isDev: boolean;
23
+ rootDir: string;
24
+ manifest: RoutesManifest;
25
+ guardConfig: GuardConfig | null;
26
+ }
27
+
28
+ export function createRuntimeDevtoolsAdapter(
29
+ options: CreateRuntimeDevtoolsAdapterOptions
30
+ ): RuntimeDevtoolsAdapter {
31
+ const kitchen = options.isDev
32
+ ? new KitchenHandler({
33
+ rootDir: options.rootDir,
34
+ manifest: options.manifest,
35
+ guardConfig: options.guardConfig,
36
+ })
37
+ : null;
38
+
39
+ return {
40
+ kitchen,
41
+ dashboardPath: kitchen ? KITCHEN_PREFIX : null,
42
+ start() {
43
+ if (kitchen) void kitchen.start();
44
+ },
45
+ stop() {
46
+ kitchen?.stop();
47
+ },
48
+ updateManifest(manifest: RoutesManifest) {
49
+ kitchen?.updateManifest(manifest);
50
+ },
51
+ async handleRequest(req: Request, pathname: string): Promise<Response | null> {
52
+ if (!kitchen || !pathname.startsWith(KITCHEN_PREFIX)) return null;
53
+ return await kitchen.handle(req, pathname);
54
+ },
55
+ };
56
+ }
57
+
58
+ export function shouldRecordRuntimeRequest(pathname: string): boolean {
59
+ return (
60
+ !pathname.startsWith("/.mandu/") &&
61
+ !pathname.startsWith(KITCHEN_PREFIX) &&
62
+ !pathname.startsWith("/__mandu/")
63
+ );
64
+ }
65
+
66
+ export function recordRuntimeRequest(entry: RequestEntry): void {
67
+ recordRequest(entry);
68
+ }
@@ -3,12 +3,40 @@
3
3
  * <title>, <p> 등 텍스트 노드에 들어갈 문자열을 안전하게 처리.
4
4
  * 속성값과 달리 " ' 는 이스케이프 불필요.
5
5
  */
6
- export function escapeHtmlText(value: string): string {
7
- return value
8
- .replace(/&/g, "&amp;")
9
- .replace(/</g, "&lt;")
10
- .replace(/>/g, "&gt;");
11
- }
6
+ export function escapeHtmlText(value: string): string {
7
+ return value
8
+ .replace(/&/g, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;");
11
+ }
12
+
13
+ /**
14
+ * Decode the small HTML entity set React can emit inside text metadata.
15
+ * Callers should still escape the returned string before writing HTML.
16
+ */
17
+ export function decodeHtmlText(value: string): string {
18
+ return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|#39);/gi, (match, entity: string) => {
19
+ const normalized = entity.toLowerCase();
20
+ if (normalized === "amp") return "&";
21
+ if (normalized === "lt") return "<";
22
+ if (normalized === "gt") return ">";
23
+ if (normalized === "quot") return '"';
24
+ if (normalized === "apos" || normalized === "#39") return "'";
25
+ if (normalized.startsWith("#x")) {
26
+ const codePoint = Number.parseInt(normalized.slice(2), 16);
27
+ return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : match;
28
+ }
29
+ if (normalized.startsWith("#")) {
30
+ const codePoint = Number.parseInt(normalized.slice(1), 10);
31
+ return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : match;
32
+ }
33
+ return match;
34
+ });
35
+ }
36
+
37
+ function isValidCodePoint(value: number): boolean {
38
+ return Number.isInteger(value) && value >= 0 && value <= 0x10ffff;
39
+ }
12
40
 
13
41
  /**
14
42
  * HTML 속성값 이스케이프
@@ -0,0 +1,290 @@
1
+ import {
2
+ eventBus,
3
+ type EventType,
4
+ type ObservabilityEvent,
5
+ type ObservabilitySeverity,
6
+ } from "../observability/event-bus";
7
+ import {
8
+ HEAP_ENDPOINT,
9
+ METRICS_ENDPOINT,
10
+ buildMetricsResponse,
11
+ collectHeapSnapshot,
12
+ isObservabilityExposed,
13
+ recordHttpRequest,
14
+ } from "../observability/metrics";
15
+ import {
16
+ createTracerFromConfig,
17
+ runWithSpan,
18
+ setTracer,
19
+ type Tracer,
20
+ type TracerConfig,
21
+ } from "../observability/tracing";
22
+ import { collectPerfSnapshot } from "../perf/user-marks";
23
+
24
+ export const INTERNAL_EVENTS_ENDPOINT = "/__mandu/events";
25
+
26
+ export interface RuntimeRequestRecord {
27
+ id: string;
28
+ method: string;
29
+ path: string;
30
+ status: number;
31
+ duration: number;
32
+ timestamp: number;
33
+ cacheStatus?: string;
34
+ }
35
+
36
+ export interface RuntimeRequestObservation {
37
+ req: Request;
38
+ path: string;
39
+ status: number;
40
+ duration: number;
41
+ correlationId: string;
42
+ cacheStatus?: string;
43
+ error?: boolean;
44
+ recordRequest?: (entry: RuntimeRequestRecord) => void;
45
+ }
46
+
47
+ export interface RuntimeObservabilityLifecycle {
48
+ readonly tracer: Tracer | undefined;
49
+ handleEndpoint(req: Request, pathname: string): Response | null;
50
+ runRequest<T extends Response>(
51
+ req: Request,
52
+ requestStart: number,
53
+ correlationId: string,
54
+ handler: () => Promise<T>
55
+ ): Promise<T>;
56
+ recordHttpResponse(req: Request, response: Response | undefined): void;
57
+ recordDevRequest(observation: RuntimeRequestObservation): void;
58
+ }
59
+
60
+ export interface CreateRuntimeObservabilityLifecycleOptions {
61
+ isDev: boolean;
62
+ heapEndpoint?: boolean;
63
+ metricsEndpoint?: boolean;
64
+ tracing?: TracerConfig;
65
+ }
66
+
67
+ export function createRuntimeObservabilityLifecycle(
68
+ options: CreateRuntimeObservabilityLifecycleOptions
69
+ ): RuntimeObservabilityLifecycle {
70
+ const tracerInstance = createTracerFromConfig(options.tracing);
71
+ setTracer(tracerInstance);
72
+ const tracer = tracerInstance.enabled ? tracerInstance : undefined;
73
+
74
+ return {
75
+ tracer,
76
+ handleEndpoint(req, pathname) {
77
+ return handleRuntimeObservabilityEndpoint(req, pathname, options);
78
+ },
79
+ async runRequest(req, requestStart, correlationId, handler) {
80
+ if (!tracer || !tracer.enabled) {
81
+ return await handler();
82
+ }
83
+
84
+ const url = new URL(req.url);
85
+ const rootSpan = tracer.startSpanFromRequest("http.request", req, {
86
+ kind: "server",
87
+ attributes: {
88
+ "http.method": req.method,
89
+ "http.url": req.url,
90
+ "http.target": url.pathname,
91
+ "http.scheme": url.protocol.replace(":", ""),
92
+ "http.host": url.host,
93
+ "mandu.correlation_id": correlationId,
94
+ "mandu.request_start_ms": requestStart,
95
+ },
96
+ });
97
+
98
+ try {
99
+ const response = await runWithSpan(rootSpan, handler);
100
+ rootSpan.setAttribute("http.status_code", response.status);
101
+ if (response.status >= 500) {
102
+ rootSpan.setStatus("error", `HTTP ${response.status}`);
103
+ } else if (rootSpan.status === "unset") {
104
+ rootSpan.setStatus("ok");
105
+ }
106
+ return response;
107
+ } catch (err) {
108
+ const message = err instanceof Error ? err.message : String(err);
109
+ rootSpan.setStatus("error", message);
110
+ throw err;
111
+ } finally {
112
+ rootSpan.end();
113
+ }
114
+ },
115
+ recordHttpResponse(req, response) {
116
+ if (!response) return;
117
+ try {
118
+ recordHttpRequest(req.method, response.status);
119
+ } catch {
120
+ // Observability must never break the request path.
121
+ }
122
+ },
123
+ recordDevRequest(observation) {
124
+ recordRuntimeObservation(observation);
125
+ },
126
+ };
127
+ }
128
+
129
+ function handleRuntimeObservabilityEndpoint(
130
+ req: Request,
131
+ pathname: string,
132
+ options: CreateRuntimeObservabilityLifecycleOptions
133
+ ): Response | null {
134
+ if (pathname === INTERNAL_EVENTS_ENDPOINT) {
135
+ return handleEventsStreamRequest(req);
136
+ }
137
+ if (pathname === `${INTERNAL_EVENTS_ENDPOINT}/recent`) {
138
+ return handleEventsRecentRequest(req);
139
+ }
140
+ if (pathname === HEAP_ENDPOINT) {
141
+ if (!isObservabilityExposed(options.isDev, options.heapEndpoint)) return null;
142
+ const base = collectHeapSnapshot();
143
+ const perf = collectPerfSnapshot();
144
+ const body = { ...base, perf };
145
+ return new Response(JSON.stringify(body, null, 2), {
146
+ status: 200,
147
+ headers: {
148
+ "Content-Type": "application/json; charset=utf-8",
149
+ "Cache-Control": "no-store",
150
+ },
151
+ });
152
+ }
153
+ if (pathname === METRICS_ENDPOINT) {
154
+ if (!isObservabilityExposed(options.isDev, options.metricsEndpoint)) return null;
155
+ return buildMetricsResponse();
156
+ }
157
+ return null;
158
+ }
159
+
160
+ function handleEventsStreamRequest(req: Request): Response {
161
+ const url = new URL(req.url);
162
+ const filterType = url.searchParams.get("type") || undefined;
163
+ const filterSeverity = url.searchParams.get("severity") || undefined;
164
+ const filterSource = url.searchParams.get("source") || undefined;
165
+ const filterTrace = url.searchParams.get("trace") || undefined;
166
+
167
+ const matches = (event: ObservabilityEvent): boolean => {
168
+ if (filterType && event.type !== filterType) return false;
169
+ if (filterSeverity && event.severity !== filterSeverity) return false;
170
+ if (filterSource && event.source !== filterSource) return false;
171
+ if (filterTrace && event.correlationId !== filterTrace) return false;
172
+ return true;
173
+ };
174
+
175
+ let unsubscribe: (() => void) | null = null;
176
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
177
+
178
+ const stream = new ReadableStream<Uint8Array>({
179
+ start(controller) {
180
+ const encoder = new TextEncoder();
181
+ const send = (data: string, eventName?: string) => {
182
+ try {
183
+ const prefix = eventName ? `event: ${eventName}\n` : "";
184
+ controller.enqueue(encoder.encode(`${prefix}data: ${data}\n\n`));
185
+ } catch {
186
+ // Stream closed.
187
+ }
188
+ };
189
+
190
+ for (const event of eventBus.getRecent()) {
191
+ if (matches(event)) send(JSON.stringify(event));
192
+ }
193
+
194
+ unsubscribe = eventBus.on("*", (event) => {
195
+ if (matches(event)) send(JSON.stringify(event));
196
+ });
197
+
198
+ heartbeat = setInterval(() => {
199
+ try {
200
+ controller.enqueue(encoder.encode(": heartbeat\n\n"));
201
+ } catch {
202
+ // Ignore closed streams.
203
+ }
204
+ }, 15000);
205
+
206
+ req.signal.addEventListener("abort", () => {
207
+ if (unsubscribe) {
208
+ unsubscribe();
209
+ unsubscribe = null;
210
+ }
211
+ if (heartbeat) {
212
+ clearInterval(heartbeat);
213
+ heartbeat = null;
214
+ }
215
+ try {
216
+ controller.close();
217
+ } catch {
218
+ // No-op when already closed.
219
+ }
220
+ });
221
+ },
222
+ cancel() {
223
+ if (unsubscribe) {
224
+ unsubscribe();
225
+ unsubscribe = null;
226
+ }
227
+ if (heartbeat) {
228
+ clearInterval(heartbeat);
229
+ heartbeat = null;
230
+ }
231
+ },
232
+ });
233
+
234
+ return new Response(stream, {
235
+ status: 200,
236
+ headers: {
237
+ "Content-Type": "text/event-stream",
238
+ "Cache-Control": "no-cache, no-store, must-revalidate",
239
+ "Connection": "keep-alive",
240
+ "X-Accel-Buffering": "no",
241
+ },
242
+ });
243
+ }
244
+
245
+ function handleEventsRecentRequest(req: Request): Response {
246
+ const url = new URL(req.url);
247
+ const count = url.searchParams.get("count");
248
+ const type = url.searchParams.get("type") || undefined;
249
+ const severity = url.searchParams.get("severity") || undefined;
250
+ const windowParam = url.searchParams.get("windowMs");
251
+ const windowMs = windowParam ? Number(windowParam) : undefined;
252
+
253
+ const events = eventBus.getRecent(count ? Number(count) : undefined, {
254
+ type: type as EventType | undefined,
255
+ severity: severity as ObservabilitySeverity | undefined,
256
+ });
257
+ const stats = eventBus.getStats(windowMs);
258
+ return Response.json({ events, stats });
259
+ }
260
+
261
+ function recordRuntimeObservation(observation: RuntimeRequestObservation): void {
262
+ const cacheTag = observation.cacheStatus ? ` ${observation.cacheStatus}` : "";
263
+ console.log(
264
+ `[${new Date().toLocaleTimeString()}] ${observation.req.method} ${observation.path} ${observation.status} ${observation.duration}ms${cacheTag}`
265
+ );
266
+ observation.recordRequest?.({
267
+ id: observation.correlationId,
268
+ method: observation.req.method,
269
+ path: observation.path,
270
+ status: observation.status,
271
+ duration: observation.duration,
272
+ timestamp: Date.now(),
273
+ cacheStatus: observation.cacheStatus,
274
+ });
275
+ eventBus.emit({
276
+ type: "http",
277
+ severity: observation.status >= 500 ? "error" : observation.status >= 400 ? "warn" : "info",
278
+ source: "server",
279
+ correlationId: observation.correlationId,
280
+ message: `${observation.req.method} ${observation.path} ${observation.status}${cacheTag}`,
281
+ duration: observation.duration,
282
+ data: {
283
+ method: observation.req.method,
284
+ path: observation.path,
285
+ status: observation.status,
286
+ cache: observation.cacheStatus,
287
+ error: observation.error || undefined,
288
+ },
289
+ });
290
+ }
@@ -0,0 +1,106 @@
1
+ import type React from "react";
2
+ import type { BundleManifest } from "../bundler/types";
3
+ import type { HydrationConfig } from "../spec/schema";
4
+ import type { CookieManager } from "../filling/context";
5
+ import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
6
+
7
+ export interface PageRenderResponseOptions {
8
+ app: React.ReactElement;
9
+ useStreaming: boolean;
10
+ title: string;
11
+ headTags: string;
12
+ isDev: boolean;
13
+ hmrPort?: number;
14
+ routeId: string;
15
+ routePattern: string;
16
+ layoutChain?: string[];
17
+ hydration?: HydrationConfig;
18
+ bundleManifest?: BundleManifest;
19
+ loaderData: unknown;
20
+ cssPath?: string | false;
21
+ transitions?: boolean;
22
+ prefetch?: boolean;
23
+ spa?: boolean;
24
+ devtools?: boolean;
25
+ islandPreWrapped?: boolean;
26
+ cookies?: CookieManager;
27
+ }
28
+
29
+ export async function renderPageResponse(
30
+ options: PageRenderResponseOptions
31
+ ): Promise<Response> {
32
+ let app = options.app;
33
+
34
+ if (!options.useStreaming) {
35
+ app = (await resolveAsyncElement(app)) as React.ReactElement;
36
+ }
37
+
38
+ const response = options.useStreaming
39
+ ? await renderStreamingPageResponse(app, options)
40
+ : renderNonStreamingPageResponse(app, options);
41
+
42
+ return options.cookies ? options.cookies.applyToResponse(response) : response;
43
+ }
44
+
45
+ async function renderStreamingPageResponse(
46
+ app: React.ReactElement,
47
+ options: PageRenderResponseOptions
48
+ ): Promise<Response> {
49
+ return renderStreamingResponse(app, {
50
+ title: options.title,
51
+ headTags: options.headTags,
52
+ isDev: options.isDev,
53
+ hmrPort: options.hmrPort,
54
+ routeId: options.routeId,
55
+ routePattern: options.routePattern,
56
+ layoutChain: options.layoutChain,
57
+ hydration: options.hydration,
58
+ bundleManifest: options.bundleManifest,
59
+ criticalData: options.loaderData as Record<string, unknown> | undefined,
60
+ enableClientRouter: true,
61
+ cssPath: options.cssPath,
62
+ transitions: options.transitions,
63
+ prefetch: options.prefetch,
64
+ spa: options.spa,
65
+ devtools: options.devtools,
66
+ onShellReady: () => {
67
+ if (options.isDev) {
68
+ console.log(`[Mandu Streaming] Shell ready: ${options.routeId}`);
69
+ }
70
+ },
71
+ onMetrics: (metrics) => {
72
+ if (options.isDev) {
73
+ console.log(`[Mandu Streaming] Metrics for ${options.routeId}:`, {
74
+ shellReadyTime: `${metrics.shellReadyTime}ms`,
75
+ allReadyTime: `${metrics.allReadyTime}ms`,
76
+ hasError: metrics.hasError,
77
+ });
78
+ }
79
+ },
80
+ });
81
+ }
82
+
83
+ function renderNonStreamingPageResponse(
84
+ app: React.ReactElement,
85
+ options: PageRenderResponseOptions
86
+ ): Response {
87
+ return renderSSR(app, {
88
+ title: options.title,
89
+ headTags: options.headTags,
90
+ isDev: options.isDev,
91
+ hmrPort: options.hmrPort,
92
+ routeId: options.routeId,
93
+ hydration: options.hydration,
94
+ bundleManifest: options.bundleManifest,
95
+ serverData: options.loaderData,
96
+ enableClientRouter: true,
97
+ routePattern: options.routePattern,
98
+ cssPath: options.cssPath,
99
+ islandPreWrapped: !!options.islandPreWrapped,
100
+ transitions: options.transitions,
101
+ prefetch: options.prefetch,
102
+ spa: options.spa,
103
+ devtools: options.devtools,
104
+ layoutChain: options.layoutChain,
105
+ });
106
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ compose as composeMiddleware,
3
+ type ComposedHandler,
4
+ type FinalHandler,
5
+ } from "../middleware/compose";
6
+ import type { Middleware } from "../middleware/define";
7
+
8
+ export type { ComposedHandler, FinalHandler };
9
+
10
+ export function buildRequestMiddlewareChain(
11
+ middleware: Middleware[] | undefined
12
+ ): ComposedHandler | undefined {
13
+ return middleware && middleware.length > 0
14
+ ? composeMiddleware(...middleware)
15
+ : undefined;
16
+ }
17
+
18
+ export interface RunRequestMiddlewareOptions {
19
+ req: Request;
20
+ middlewareChain: ComposedHandler | undefined;
21
+ finalHandler: FinalHandler;
22
+ skipMiddleware?: boolean;
23
+ }
24
+
25
+ export async function runRequestMiddleware(
26
+ options: RunRequestMiddlewareOptions
27
+ ): Promise<Response | undefined> {
28
+ const { req, middlewareChain, finalHandler, skipMiddleware = false } = options;
29
+ if (skipMiddleware || !middlewareChain) return undefined;
30
+ return middlewareChain(req, finalHandler);
31
+ }