@mandujs/core 0.23.0 → 0.25.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/package.json +3 -1
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -0
- package/src/bundler/build.ts +26 -3
- package/src/bundler/dev.ts +306 -4
- package/src/bundler/reverse-import-graph.ts +339 -0
- package/src/bundler/safe-build.test.ts +54 -0
- package/src/bundler/safe-build.ts +33 -7
- package/src/client/globals.ts +61 -44
- package/src/client/prefetch-helper.ts +55 -0
- package/src/client/router.ts +93 -15
- package/src/client/use-fetch.ts +243 -239
- package/src/config/mandu.ts +280 -172
- package/src/config/validate.ts +411 -357
- package/src/content/collection.ts +506 -0
- package/src/content/frontmatter.ts +189 -0
- package/src/content/generate-types.ts +168 -0
- package/src/content/index.ts +206 -168
- package/src/content/llms-txt.ts +196 -0
- package/src/content/prebuild.test.ts +249 -0
- package/src/content/prebuild.ts +400 -0
- package/src/content/schema.ts +20 -0
- package/src/content/sidebar.ts +212 -0
- package/src/content/slug.ts +110 -0
- package/src/guard/check.ts +5 -2
- package/src/observability/index.ts +37 -18
- package/src/observability/metrics.ts +334 -0
- package/src/runtime/adapter-bun.ts +64 -62
- package/src/runtime/index.ts +11 -0
- package/src/runtime/registry.ts +171 -0
- package/src/runtime/server.ts +214 -8
- package/src/runtime/ssr.ts +264 -8
- package/src/runtime/streaming-ssr.ts +100 -4
- package/src/utils/__tests__/lru-cache.test.ts +186 -0
- package/src/utils/lru-cache.ts +172 -75
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime manifest registry.
|
|
3
|
+
*
|
|
4
|
+
* This module provides the **official** accessor for generated content at
|
|
5
|
+
* runtime. User code must NEVER `import` anything under `.mandu/generated/`
|
|
6
|
+
* or any path containing `/generated/` — the guard rule
|
|
7
|
+
* `INVALID_GENERATED_IMPORT` catches that at build time.
|
|
8
|
+
*
|
|
9
|
+
* Why the indirection?
|
|
10
|
+
*
|
|
11
|
+
* - **Hot reload** — in dev, generated modules are rebuilt and re-imported.
|
|
12
|
+
* A direct ESM import caches the first version; the registry re-reads the
|
|
13
|
+
* current manifest on every access.
|
|
14
|
+
* - **Determinism** — compiled binaries (`bun build --compile`) embed a
|
|
15
|
+
* fixed manifest. Direct imports would bypass the embedded copy and fail.
|
|
16
|
+
* - **ESM cache invalidation** — see #184. Transitive generated modules get
|
|
17
|
+
* stuck on stale copies when hot-reload fires; the registry's `getManifest`
|
|
18
|
+
* is the single choke point that the bundled importer invalidates cleanly.
|
|
19
|
+
*
|
|
20
|
+
* @see https://mandujs.com/docs/architect/generated-access
|
|
21
|
+
*/
|
|
22
|
+
import type { RoutesManifest, RouteSpec } from "../spec/schema";
|
|
23
|
+
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
25
|
+
// Generated artifact map
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map of generated artifacts keyed by well-known names.
|
|
30
|
+
*
|
|
31
|
+
* Extend this interface (via module augmentation) in consumers that emit
|
|
32
|
+
* their own generated artifacts — collections, resources, db schemas, etc.
|
|
33
|
+
*
|
|
34
|
+
* @example Module augmentation
|
|
35
|
+
* ```ts
|
|
36
|
+
* declare module "@mandujs/core/runtime" {
|
|
37
|
+
* interface GeneratedRegistry {
|
|
38
|
+
* collections: Record<string, CollectionIndex>;
|
|
39
|
+
* }
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export interface GeneratedRegistry {
|
|
44
|
+
/** Route manifest — the single source of truth for page/API routes. */
|
|
45
|
+
routes: RoutesManifest;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Union of well-known generated artifact keys. */
|
|
49
|
+
export type GeneratedKey = keyof GeneratedRegistry;
|
|
50
|
+
|
|
51
|
+
/** Typed accessor — narrows the return shape from the key. */
|
|
52
|
+
export type GeneratedShape<K extends GeneratedKey> = GeneratedRegistry[K];
|
|
53
|
+
|
|
54
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
55
|
+
// Global registry state
|
|
56
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The live manifest, populated by `registerManifest()` (typically driven by
|
|
60
|
+
* `registerManifestHandlers()` from `@mandujs/cli`). Kept on `globalThis`
|
|
61
|
+
* so reloading the core module in dev does not lose registration.
|
|
62
|
+
*/
|
|
63
|
+
declare global {
|
|
64
|
+
// eslint-disable-next-line no-var
|
|
65
|
+
var __MANDU_MANIFEST__: Partial<GeneratedRegistry> | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ensureGlobalSlot(): Partial<GeneratedRegistry> {
|
|
69
|
+
if (!globalThis.__MANDU_MANIFEST__) {
|
|
70
|
+
globalThis.__MANDU_MANIFEST__ = {};
|
|
71
|
+
}
|
|
72
|
+
return globalThis.__MANDU_MANIFEST__;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// Public API
|
|
77
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Register a generated artifact under a well-known key.
|
|
81
|
+
*
|
|
82
|
+
* Called by the framework (typically from `registerManifestHandlers()` in
|
|
83
|
+
* `@mandujs/cli`) during server boot. User code normally does not call this
|
|
84
|
+
* directly — the only exception is tests that want to seed a manifest.
|
|
85
|
+
*
|
|
86
|
+
* @example Test setup
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { registerManifest, clearGeneratedRegistry } from "@mandujs/core/runtime";
|
|
89
|
+
*
|
|
90
|
+
* beforeEach(() => clearGeneratedRegistry());
|
|
91
|
+
* registerManifest("routes", { version: 1, routes: [] });
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function registerManifest<K extends GeneratedKey>(
|
|
95
|
+
key: K,
|
|
96
|
+
value: GeneratedShape<K>,
|
|
97
|
+
): void {
|
|
98
|
+
const slot = ensureGlobalSlot();
|
|
99
|
+
slot[key] = value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read a generated artifact by key. Throws a helpful error if the manifest
|
|
104
|
+
* has not been registered yet — this almost always means the server boot
|
|
105
|
+
* skipped `registerManifestHandlers()` or the test forgot to seed fixtures.
|
|
106
|
+
*
|
|
107
|
+
* @example Reading the route manifest
|
|
108
|
+
* ```ts
|
|
109
|
+
* import { getGenerated } from "@mandujs/core/runtime";
|
|
110
|
+
*
|
|
111
|
+
* const manifest = getGenerated("routes");
|
|
112
|
+
* for (const route of manifest.routes) {
|
|
113
|
+
* console.log(route.id, route.pattern);
|
|
114
|
+
* }
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* @throws {Error} when the key has not been registered
|
|
118
|
+
*/
|
|
119
|
+
export function getGenerated<K extends GeneratedKey>(key: K): GeneratedShape<K> {
|
|
120
|
+
const slot = globalThis.__MANDU_MANIFEST__;
|
|
121
|
+
if (!slot || !(key in slot) || slot[key] === undefined) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`[Mandu] Generated artifact "${String(key)}" not registered. ` +
|
|
124
|
+
`Call registerManifestHandlers() during server boot, or seed the ` +
|
|
125
|
+
`manifest with registerManifest("${String(key)}", …) in tests. ` +
|
|
126
|
+
`See https://mandujs.com/docs/architect/generated-access`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return slot[key] as GeneratedShape<K>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return the registered artifact, or `undefined` if absent. Prefer
|
|
134
|
+
* `getGenerated()` for the common case — this variant exists for hot paths
|
|
135
|
+
* where absence is not an error (e.g., optional collections).
|
|
136
|
+
*/
|
|
137
|
+
export function tryGetGenerated<K extends GeneratedKey>(
|
|
138
|
+
key: K,
|
|
139
|
+
): GeneratedShape<K> | undefined {
|
|
140
|
+
const slot = globalThis.__MANDU_MANIFEST__;
|
|
141
|
+
return slot?.[key] as GeneratedShape<K> | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Return the full routes manifest. Thin wrapper around `getGenerated("routes")`
|
|
146
|
+
* kept for call-site readability.
|
|
147
|
+
*
|
|
148
|
+
* @throws {Error} when no manifest has been registered yet
|
|
149
|
+
*/
|
|
150
|
+
export function getManifest(): RoutesManifest {
|
|
151
|
+
return getGenerated("routes");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Find a single route by its stable ID. Returns `undefined` if no match.
|
|
156
|
+
* Use this instead of `manifest.routes.find(…)` at call sites that already
|
|
157
|
+
* want the readability of a named helper.
|
|
158
|
+
*/
|
|
159
|
+
export function getRouteById(id: string): RouteSpec | undefined {
|
|
160
|
+
const manifest = tryGetGenerated("routes");
|
|
161
|
+
if (!manifest) return undefined;
|
|
162
|
+
return manifest.routes.find((route) => route.id === id);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Clear all registered artifacts. Test-only — production code should never
|
|
167
|
+
* call this.
|
|
168
|
+
*/
|
|
169
|
+
export function clearGeneratedRegistry(): void {
|
|
170
|
+
globalThis.__MANDU_MANIFEST__ = {};
|
|
171
|
+
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { BundleManifest } from "../bundler/types";
|
|
|
4
4
|
import type { ManduFilling, RenderMode } from "../filling/filling";
|
|
5
5
|
import { ManduContext, CookieManager } from "../filling/context";
|
|
6
6
|
import { Router } from "./router";
|
|
7
|
-
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
7
|
+
import { renderSSR, renderStreamingResponse, resolveAsyncElement } from "./ssr";
|
|
8
8
|
import {
|
|
9
9
|
resolveMetadata,
|
|
10
10
|
renderMetadata,
|
|
@@ -49,6 +49,14 @@ import {
|
|
|
49
49
|
import { validateImportPath } from "./security";
|
|
50
50
|
import { KITCHEN_PREFIX, KitchenHandler, recordRequest } from "../kitchen/kitchen-handler";
|
|
51
51
|
import { eventBus } from "../observability/event-bus";
|
|
52
|
+
import {
|
|
53
|
+
HEAP_ENDPOINT,
|
|
54
|
+
METRICS_ENDPOINT,
|
|
55
|
+
buildHeapResponse,
|
|
56
|
+
buildMetricsResponse,
|
|
57
|
+
isObservabilityExposed,
|
|
58
|
+
recordHttpRequest,
|
|
59
|
+
} from "../observability/metrics";
|
|
52
60
|
import {
|
|
53
61
|
type MiddlewareFn,
|
|
54
62
|
type MiddlewareConfig,
|
|
@@ -350,6 +358,47 @@ export interface ServerOptions {
|
|
|
350
358
|
* When set, token-protected endpoints such as `/_mandu/cache` become available.
|
|
351
359
|
*/
|
|
352
360
|
managementToken?: string;
|
|
361
|
+
/**
|
|
362
|
+
* Issue #192 — enable CSS View Transitions auto-inject (default `true`).
|
|
363
|
+
* When `true`, every SSR response gets
|
|
364
|
+
* `<style>@view-transition{navigation:auto}</style>` in its `<head>`,
|
|
365
|
+
* giving supported browsers a default crossfade on cross-document
|
|
366
|
+
* navigation. Pass `false` to suppress (typically wired from
|
|
367
|
+
* `ManduConfig.transitions`).
|
|
368
|
+
*/
|
|
369
|
+
transitions?: boolean;
|
|
370
|
+
/**
|
|
371
|
+
* Issue #192 — enable the hover prefetch helper (default `true`).
|
|
372
|
+
* When `true`, every SSR response gets a ~500-byte inline script that
|
|
373
|
+
* prefetches same-origin links on hover. Pass `false` to suppress
|
|
374
|
+
* (typically wired from `ManduConfig.prefetch`). Individual links can
|
|
375
|
+
* also opt out via `data-no-prefetch`.
|
|
376
|
+
*/
|
|
377
|
+
prefetch?: boolean;
|
|
378
|
+
/**
|
|
379
|
+
* Issue #191 — override dev-mode `_devtools.js` injection.
|
|
380
|
+
* Wired from `ManduConfig.dev.devtools`.
|
|
381
|
+
* - `true` → force inject on every page (SSR-only + Kitchen).
|
|
382
|
+
* - `false` → force skip on every page.
|
|
383
|
+
* - `undefined` → default. Inject iff the page's route has at least
|
|
384
|
+
* one island. Pure-SSR pages download zero devtools.
|
|
385
|
+
*/
|
|
386
|
+
devtools?: boolean;
|
|
387
|
+
/**
|
|
388
|
+
* Phase 17 — observability endpoints.
|
|
389
|
+
* - `heapEndpoint` → `/_mandu/heap` JSON dump of process.memoryUsage()
|
|
390
|
+
* + registered cache sizes. In dev this is on by
|
|
391
|
+
* default; in prod it requires `MANDU_DEBUG_HEAP=1`
|
|
392
|
+
* OR this flag set to `true`.
|
|
393
|
+
* - `metricsEndpoint` → `/_mandu/metrics` Prometheus text exposition.
|
|
394
|
+
* Same gating as `heapEndpoint`.
|
|
395
|
+
* Passing `false` for either in dev force-disables it (useful for
|
|
396
|
+
* isolating test environments that count listeners).
|
|
397
|
+
*/
|
|
398
|
+
observability?: {
|
|
399
|
+
heapEndpoint?: boolean;
|
|
400
|
+
metricsEndpoint?: boolean;
|
|
401
|
+
};
|
|
353
402
|
}
|
|
354
403
|
|
|
355
404
|
export interface ManduServer {
|
|
@@ -456,6 +505,34 @@ export interface ServerRegistrySettings {
|
|
|
456
505
|
* Default: false (Bun/Node runtime with full FS access).
|
|
457
506
|
*/
|
|
458
507
|
edge?: boolean;
|
|
508
|
+
/**
|
|
509
|
+
* Issue #192 — threaded from `ServerOptions.transitions`.
|
|
510
|
+
* `undefined` is treated as `true` at the SSR call-site (enabled by
|
|
511
|
+
* default); `false` suppresses the `<style>@view-transition>` injection.
|
|
512
|
+
*/
|
|
513
|
+
transitions?: boolean;
|
|
514
|
+
/**
|
|
515
|
+
* Issue #192 — threaded from `ServerOptions.prefetch`.
|
|
516
|
+
* `undefined` is treated as `true` at the SSR call-site (enabled by
|
|
517
|
+
* default); `false` suppresses the hover prefetch `<script>` injection.
|
|
518
|
+
*/
|
|
519
|
+
prefetch?: boolean;
|
|
520
|
+
/**
|
|
521
|
+
* Issue #191 — threaded from `ServerOptions.devtools`. `undefined`
|
|
522
|
+
* means "use default (islands → inject)"; `true` / `false` force the
|
|
523
|
+
* dev-mode `_devtools.js` `<script>` injection on / off. No-op in prod.
|
|
524
|
+
*/
|
|
525
|
+
devtools?: boolean;
|
|
526
|
+
/**
|
|
527
|
+
* Phase 17 — `/_mandu/heap` JSON exposure. `undefined` uses the
|
|
528
|
+
* default for the current mode (dev → on, prod → MANDU_DEBUG_HEAP).
|
|
529
|
+
*/
|
|
530
|
+
heapEndpoint?: boolean;
|
|
531
|
+
/**
|
|
532
|
+
* Phase 17 — `/_mandu/metrics` Prometheus exposure. Same defaulting
|
|
533
|
+
* as `heapEndpoint`.
|
|
534
|
+
*/
|
|
535
|
+
metricsEndpoint?: boolean;
|
|
459
536
|
}
|
|
460
537
|
|
|
461
538
|
export class ServerRegistry {
|
|
@@ -1876,6 +1953,16 @@ async function renderPageSSR(
|
|
|
1876
1953
|
? route.streaming
|
|
1877
1954
|
: settings.streaming;
|
|
1878
1955
|
|
|
1956
|
+
// Issue #198 — Pre-resolve async server components before handing off
|
|
1957
|
+
// to React's SSR engines. `renderToString` (non-streaming path) does
|
|
1958
|
+
// not support async components; `renderToReadableStream` does, but
|
|
1959
|
+
// the shell-gen step in streaming-ssr also falls through
|
|
1960
|
+
// `collectStreamingHeadTags` → `renderToString`. Resolving up-front
|
|
1961
|
+
// gives consistent, synchronous trees to both paths and keeps the
|
|
1962
|
+
// user-visible contract (`export default async function Page() {...}`
|
|
1963
|
+
// and `export default async function Layout() {...}`) working end to end.
|
|
1964
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
1965
|
+
|
|
1879
1966
|
if (useStreaming) {
|
|
1880
1967
|
const streamingResponse = await renderStreamingResponse(app, {
|
|
1881
1968
|
title: builtMeta.title,
|
|
@@ -1889,6 +1976,9 @@ async function renderPageSSR(
|
|
|
1889
1976
|
criticalData: loaderData as Record<string, unknown> | undefined,
|
|
1890
1977
|
enableClientRouter: true,
|
|
1891
1978
|
cssPath: settings.cssPath,
|
|
1979
|
+
transitions: settings.transitions,
|
|
1980
|
+
prefetch: settings.prefetch,
|
|
1981
|
+
devtools: settings.devtools,
|
|
1892
1982
|
onShellReady: () => {
|
|
1893
1983
|
if (settings.isDev) {
|
|
1894
1984
|
console.log(`[Mandu Streaming] Shell ready: ${route.id}`);
|
|
@@ -1924,6 +2014,9 @@ async function renderPageSSR(
|
|
|
1924
2014
|
routePattern: route.pattern,
|
|
1925
2015
|
cssPath: settings.cssPath,
|
|
1926
2016
|
islandPreWrapped: !!needsIslandWrap,
|
|
2017
|
+
transitions: settings.transitions,
|
|
2018
|
+
prefetch: settings.prefetch,
|
|
2019
|
+
devtools: settings.devtools,
|
|
1927
2020
|
});
|
|
1928
2021
|
return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
|
|
1929
2022
|
} catch (error) {
|
|
@@ -1955,11 +2048,18 @@ async function renderPageSSR(
|
|
|
1955
2048
|
errorApp = await wrapWithLayouts(errorApp, route.layoutChain, registry, params, layoutData);
|
|
1956
2049
|
}
|
|
1957
2050
|
|
|
2051
|
+
// Issue #198 — resolve async layouts wrapping the error component
|
|
2052
|
+
// so `async function Layout()` still renders on the 500 surface.
|
|
2053
|
+
errorApp = (await resolveAsyncElement(errorApp)) as React.ReactElement;
|
|
2054
|
+
|
|
1958
2055
|
const errorHtml = renderSSR(errorApp, {
|
|
1959
2056
|
// 에러 상태에서는 resolveMetadata 결과를 신뢰할 수 없을 수 있으므로 리터럴 사용
|
|
1960
2057
|
title: "Mandu App — Error",
|
|
1961
2058
|
isDev: settings.isDev,
|
|
1962
2059
|
cssPath: settings.cssPath,
|
|
2060
|
+
transitions: settings.transitions,
|
|
2061
|
+
prefetch: settings.prefetch,
|
|
2062
|
+
devtools: settings.devtools,
|
|
1963
2063
|
});
|
|
1964
2064
|
return ok(cookies ? cookies.applyToResponse(errorHtml) : errorHtml);
|
|
1965
2065
|
}
|
|
@@ -2054,10 +2154,18 @@ async function renderNotFoundPage(
|
|
|
2054
2154
|
app = await wrapWithLayouts(app, route.layoutChain, registry, params, layoutData);
|
|
2055
2155
|
}
|
|
2056
2156
|
|
|
2157
|
+
// Issue #198 — users may author `not-found.tsx` as an async server
|
|
2158
|
+
// component (e.g. to fetch copy from a CMS). Resolve the async tree
|
|
2159
|
+
// before the sync renderSSR path.
|
|
2160
|
+
app = (await resolveAsyncElement(app)) as React.ReactElement;
|
|
2161
|
+
|
|
2057
2162
|
const html = renderSSR(app, {
|
|
2058
2163
|
title: "Not Found",
|
|
2059
2164
|
isDev: settings.isDev,
|
|
2060
2165
|
cssPath: settings.cssPath,
|
|
2166
|
+
transitions: settings.transitions,
|
|
2167
|
+
prefetch: settings.prefetch,
|
|
2168
|
+
devtools: settings.devtools,
|
|
2061
2169
|
});
|
|
2062
2170
|
|
|
2063
2171
|
// renderSSR returns a 200; override to 404 without losing headers.
|
|
@@ -2449,6 +2557,28 @@ async function handleRequestInternal(
|
|
|
2449
2557
|
return ok(handleEventsRecentRequest(req));
|
|
2450
2558
|
}
|
|
2451
2559
|
|
|
2560
|
+
// Phase 17 — heap snapshot + Prometheus metrics endpoints.
|
|
2561
|
+
//
|
|
2562
|
+
// Gating: dev mode exposes by default so the DX is zero-friction. Prod
|
|
2563
|
+
// requires either `MANDU_DEBUG_HEAP=1` or explicit `observability.heapEndpoint:
|
|
2564
|
+
// true` in `ServerOptions`. Operators can opt-out of even the dev exposure by
|
|
2565
|
+
// passing `observability.heapEndpoint: false` (useful in tests that count
|
|
2566
|
+
// listeners / assert route shape).
|
|
2567
|
+
//
|
|
2568
|
+
// Missing endpoints return 404 via the normal route-not-found path —
|
|
2569
|
+
// scrapers can't distinguish "disabled" from "never existed". See
|
|
2570
|
+
// `docs/ops/metrics.md` for the operator-facing guide.
|
|
2571
|
+
if (pathname === HEAP_ENDPOINT) {
|
|
2572
|
+
if (isObservabilityExposed(settings.isDev, settings.heapEndpoint)) {
|
|
2573
|
+
return ok(buildHeapResponse());
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
if (pathname === METRICS_ENDPOINT) {
|
|
2577
|
+
if (isObservabilityExposed(settings.isDev, settings.metricsEndpoint)) {
|
|
2578
|
+
return ok(buildMetricsResponse());
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2452
2582
|
// 2. Kitchen dev dashboard (dev mode only)
|
|
2453
2583
|
if (settings.isDev && pathname.startsWith(KITCHEN_PREFIX) && registry.kitchen) {
|
|
2454
2584
|
const kitchenResponse = await registry.kitchen.handle(req, pathname);
|
|
@@ -2477,14 +2607,19 @@ async function handleRequestInternal(
|
|
|
2477
2607
|
console.warn(`[Mandu] not-found.tsx loader threw (unmatched URL):`, loaderError);
|
|
2478
2608
|
}
|
|
2479
2609
|
}
|
|
2480
|
-
const
|
|
2610
|
+
const rawApp = React.createElement(registration.component, {
|
|
2481
2611
|
params: {},
|
|
2482
2612
|
loaderData,
|
|
2483
2613
|
});
|
|
2614
|
+
// Issue #198 — pre-resolve in case the not-found component is async.
|
|
2615
|
+
const app = (await resolveAsyncElement(rawApp)) as React.ReactElement;
|
|
2484
2616
|
const html = renderSSR(app, {
|
|
2485
2617
|
title: "Not Found",
|
|
2486
2618
|
isDev: settings.isDev,
|
|
2487
2619
|
cssPath: settings.cssPath,
|
|
2620
|
+
transitions: settings.transitions,
|
|
2621
|
+
prefetch: settings.prefetch,
|
|
2622
|
+
devtools: settings.devtools,
|
|
2488
2623
|
});
|
|
2489
2624
|
const headers = new Headers(html.headers);
|
|
2490
2625
|
const body = await html.text();
|
|
@@ -2586,10 +2721,43 @@ function startBunServerWithFallback(options: {
|
|
|
2586
2721
|
|
|
2587
2722
|
// ========== Server Startup ==========
|
|
2588
2723
|
|
|
2724
|
+
/**
|
|
2725
|
+
* Format a base URL for startup logging based on the bound hostname.
|
|
2726
|
+
*
|
|
2727
|
+
* When binding to wildcard addresses (`0.0.0.0`, `::`, or empty string),
|
|
2728
|
+
* the server listens on all interfaces — browsers must use `localhost`
|
|
2729
|
+
* or a specific loopback address to connect. We surface both IPv4 and IPv6
|
|
2730
|
+
* loopback URLs so the user can pick whichever their OS prefers.
|
|
2731
|
+
*
|
|
2732
|
+
* Returns `{ primary, additional }` where `primary` is the canonical URL
|
|
2733
|
+
* for UX (open-in-browser, runtime control) and `additional` are supplementary
|
|
2734
|
+
* URLs shown in the startup log.
|
|
2735
|
+
*/
|
|
2736
|
+
export function formatServerAddresses(
|
|
2737
|
+
hostname: string | undefined,
|
|
2738
|
+
port: number
|
|
2739
|
+
): { primary: string; additional: string[] } {
|
|
2740
|
+
const isWildcardV4 = hostname === "0.0.0.0" || hostname === undefined || hostname === "";
|
|
2741
|
+
const isWildcardV6 = hostname === "::" || hostname === "[::]";
|
|
2742
|
+
if (isWildcardV4 || isWildcardV6) {
|
|
2743
|
+
return {
|
|
2744
|
+
primary: `http://localhost:${port}`,
|
|
2745
|
+
additional: [`http://127.0.0.1:${port}`, `http://[::1]:${port}`],
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
// Bracket IPv6 literals for URL syntax.
|
|
2749
|
+
const host = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
2750
|
+
return { primary: `http://${host}:${port}`, additional: [] };
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2589
2753
|
export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
|
|
2590
2754
|
const {
|
|
2591
2755
|
port = 3000,
|
|
2592
|
-
|
|
2756
|
+
// Default to 0.0.0.0 (dual-stack wildcard on IPv4) so `localhost` resolves
|
|
2757
|
+
// to 127.0.0.1 via OS-level IPv4-preferred lookups (e.g., Windows). Users
|
|
2758
|
+
// can still pin `hostname: "::1"` or `hostname: "127.0.0.1"` explicitly.
|
|
2759
|
+
// See issue #190.
|
|
2760
|
+
hostname = "0.0.0.0",
|
|
2593
2761
|
rootDir = process.cwd(),
|
|
2594
2762
|
isDev = false,
|
|
2595
2763
|
hmrPort,
|
|
@@ -2603,6 +2771,10 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2603
2771
|
guardConfig = null,
|
|
2604
2772
|
cache: cacheOption,
|
|
2605
2773
|
managementToken,
|
|
2774
|
+
transitions,
|
|
2775
|
+
prefetch,
|
|
2776
|
+
devtools,
|
|
2777
|
+
observability: observabilityOption,
|
|
2606
2778
|
} = options;
|
|
2607
2779
|
|
|
2608
2780
|
// cssPath 처리:
|
|
@@ -2638,6 +2810,11 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2638
2810
|
rateLimit: rateLimitOptions,
|
|
2639
2811
|
cssPath,
|
|
2640
2812
|
managementToken,
|
|
2813
|
+
transitions,
|
|
2814
|
+
prefetch,
|
|
2815
|
+
devtools,
|
|
2816
|
+
heapEndpoint: observabilityOption?.heapEndpoint,
|
|
2817
|
+
metricsEndpoint: observabilityOption?.metricsEndpoint,
|
|
2641
2818
|
};
|
|
2642
2819
|
|
|
2643
2820
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
@@ -2704,6 +2881,21 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2704
2881
|
},
|
|
2705
2882
|
} : undefined;
|
|
2706
2883
|
|
|
2884
|
+
// Phase 17 — bump the Prometheus request counter once per Response we
|
|
2885
|
+
// actually produce. WebSocket upgrades (return `undefined`) are
|
|
2886
|
+
// deliberately skipped so the counter only reflects plain HTTP traffic.
|
|
2887
|
+
// Errors from `recordHttpRequest` are impossible to surface here — the
|
|
2888
|
+
// Map update is synchronous and self-contained — but we still try/catch
|
|
2889
|
+
// as defence-in-depth.
|
|
2890
|
+
const bumpCounter = (req: Request, res: Response | undefined): void => {
|
|
2891
|
+
if (!res) return;
|
|
2892
|
+
try {
|
|
2893
|
+
recordHttpRequest(req.method, res.status);
|
|
2894
|
+
} catch {
|
|
2895
|
+
// Never let an observability hiccup break a request.
|
|
2896
|
+
}
|
|
2897
|
+
};
|
|
2898
|
+
|
|
2707
2899
|
// fetch handler: WS upgrade 감지 추가
|
|
2708
2900
|
const wrappedFetch = hasWsRoutes
|
|
2709
2901
|
? async (req: Request, bunServer: Server<undefined>): Promise<Response | undefined> => {
|
|
@@ -2718,9 +2910,15 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2718
2910
|
return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
|
|
2719
2911
|
}
|
|
2720
2912
|
}
|
|
2721
|
-
|
|
2913
|
+
const res = await fetchHandler(req);
|
|
2914
|
+
bumpCounter(req, res);
|
|
2915
|
+
return res;
|
|
2722
2916
|
}
|
|
2723
|
-
: async (req: Request): Promise<Response> =>
|
|
2917
|
+
: async (req: Request): Promise<Response> => {
|
|
2918
|
+
const res = await fetchHandler(req);
|
|
2919
|
+
bumpCounter(req, res);
|
|
2920
|
+
return res;
|
|
2921
|
+
};
|
|
2724
2922
|
|
|
2725
2923
|
const { server, port: actualPort, attempts } = startBunServerWithFallback({
|
|
2726
2924
|
port,
|
|
@@ -2737,8 +2935,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2737
2935
|
registry.settings = { ...registry.settings, hmrPort: actualPort };
|
|
2738
2936
|
}
|
|
2739
2937
|
|
|
2938
|
+
const addresses = formatServerAddresses(hostname, actualPort);
|
|
2939
|
+
|
|
2740
2940
|
if (isDev) {
|
|
2741
|
-
console.log(`🥟 Mandu Dev Server
|
|
2941
|
+
console.log(`🥟 Mandu Dev Server listening at ${addresses.primary}`);
|
|
2942
|
+
if (addresses.additional.length > 0) {
|
|
2943
|
+
console.log(` (also reachable at ${addresses.additional.join(", ")})`);
|
|
2944
|
+
}
|
|
2742
2945
|
if (registry.settings.hmrPort) {
|
|
2743
2946
|
console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
|
|
2744
2947
|
}
|
|
@@ -2750,10 +2953,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2750
2953
|
console.log(`🌊 Streaming SSR enabled`);
|
|
2751
2954
|
}
|
|
2752
2955
|
if (registry.kitchen) {
|
|
2753
|
-
console.log(`🍳 Kitchen dashboard at
|
|
2956
|
+
console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
|
|
2754
2957
|
}
|
|
2755
2958
|
} else {
|
|
2756
|
-
console.log(`🥟 Mandu server
|
|
2959
|
+
console.log(`🥟 Mandu server listening at ${addresses.primary}`);
|
|
2960
|
+
if (addresses.additional.length > 0) {
|
|
2961
|
+
console.log(` (also reachable at ${addresses.additional.join(", ")})`);
|
|
2962
|
+
}
|
|
2757
2963
|
if (streaming) {
|
|
2758
2964
|
console.log(`🌊 Streaming SSR enabled`);
|
|
2759
2965
|
}
|