@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.
@@ -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
+ }
@@ -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 app = React.createElement(registration.component, {
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
- hostname = "localhost",
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
- return fetchHandler(req);
2913
+ const res = await fetchHandler(req);
2914
+ bumpCounter(req, res);
2915
+ return res;
2722
2916
  }
2723
- : async (req: Request): Promise<Response> => fetchHandler(req);
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 running at http://${hostname}:${actualPort}`);
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 http://${hostname}:${actualPort}/__kitchen`);
2956
+ console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
2754
2957
  }
2755
2958
  } else {
2756
- console.log(`🥟 Mandu server running at http://${hostname}:${actualPort}`);
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
  }