@mandujs/core 0.54.1 → 0.54.2

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.
@@ -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,110 @@
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
+ const serverData = options.loaderData
88
+ ? { [options.routeId]: { serverData: options.loaderData } }
89
+ : undefined;
90
+
91
+ return renderSSR(app, {
92
+ title: options.title,
93
+ headTags: options.headTags,
94
+ isDev: options.isDev,
95
+ hmrPort: options.hmrPort,
96
+ routeId: options.routeId,
97
+ hydration: options.hydration,
98
+ bundleManifest: options.bundleManifest,
99
+ serverData,
100
+ enableClientRouter: true,
101
+ routePattern: options.routePattern,
102
+ cssPath: options.cssPath,
103
+ islandPreWrapped: !!options.islandPreWrapped,
104
+ transitions: options.transitions,
105
+ prefetch: options.prefetch,
106
+ spa: options.spa,
107
+ devtools: options.devtools,
108
+ layoutChain: options.layoutChain,
109
+ });
110
+ }
@@ -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
+ }