@mandujs/core 0.22.1 → 0.24.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.
@@ -175,6 +175,18 @@ export const HMR_PERF = {
175
175
  * prewarm before the user hits a file save. */
176
176
  JIT_PREWARM: "boot:jit-prewarm",
177
177
 
178
+ /** Phase 11 C — Deep-path JIT prewarm extension. Phase 7.3 A closed the
179
+ * first-iter gap from 41 ms to 25 ms by prewarming the React hot set;
180
+ * R0.3 diagnostics traced the remaining ~15 ms to the
181
+ * `registerManifestHandlers` deep-path (cli `util/handlers` +
182
+ * `util/bun` bundledImport + `@mandujs/core/bundler/safe-build`
183
+ * internals) which only execute on the FIRST SSR reload. This marker
184
+ * measures the settling time of the deep-import Promise — still
185
+ * fire-and-forget, still NOT on the critical path, so values are
186
+ * informational only. Target: first-iter ≤ 15 ms (hard) / ≤ 20 ms
187
+ * (soft). See `packages/cli/src/util/jit-prewarm.ts`. */
188
+ JIT_PREWARM_DEEP: "boot:jit-prewarm-deep",
189
+
178
190
  /** API route handler reload (`handleAPIChange`) — `.route.ts` /
179
191
  * `.route.tsx` change. Symmetric to `SSR_HANDLER_RELOAD` for page /
180
192
  * layout reloads. Phase 7.2 §7.4 flagged that `handleAPIChange` was
@@ -1,62 +1,64 @@
1
- /**
2
- * Mandu Bun Adapter (기본 어댑터)
3
- * Bun.serve() 기반 서버 생성
4
- */
5
-
6
- import type { ManduAdapter, AdapterOptions, AdapterServer } from "./adapter";
7
- import { startServer, type ManduServer } from "./server";
8
-
9
- /**
10
- * Bun 어댑터 (기본)
11
- *
12
- * @example
13
- * ```typescript
14
- * // mandu.config.ts
15
- * import { adapterBun } from "@mandujs/core";
16
- *
17
- * export default {
18
- * adapter: adapterBun(),
19
- * };
20
- * ```
21
- */
22
- export function adapterBun(): ManduAdapter {
23
- return {
24
- name: "adapter-bun",
25
-
26
- createServer(options: AdapterOptions): AdapterServer {
27
- let manduServer: ManduServer | null = null;
28
-
29
- return {
30
- async fetch(req: Request): Promise<Response> {
31
- if (!manduServer) {
32
- return new Response("Server not started", { status: 503 });
33
- }
34
- // 내부 서버로 프록시
35
- const url = new URL(req.url);
36
- const targetUrl = `http://localhost:${manduServer.server.port}${url.pathname}${url.search}`;
37
- return globalThis.fetch(new Request(targetUrl, req));
38
- },
39
-
40
- async listen(port: number, hostname?: string) {
41
- manduServer = startServer(options.manifest, {
42
- ...options.serverOptions,
43
- port,
44
- hostname,
45
- rootDir: options.rootDir,
46
- bundleManifest: options.bundleManifest,
47
- });
48
-
49
- return {
50
- port: manduServer.server.port ?? port,
51
- hostname: hostname ?? "localhost",
52
- };
53
- },
54
-
55
- async close() {
56
- manduServer?.stop();
57
- manduServer = null;
58
- },
59
- };
60
- },
61
- };
62
- }
1
+ /**
2
+ * Mandu Bun Adapter (기본 어댑터)
3
+ * Bun.serve() 기반 서버 생성
4
+ */
5
+
6
+ import type { ManduAdapter, AdapterOptions, AdapterServer } from "./adapter";
7
+ import { startServer, type ManduServer } from "./server";
8
+
9
+ /**
10
+ * Bun 어댑터 (기본)
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * // mandu.config.ts
15
+ * import { adapterBun } from "@mandujs/core";
16
+ *
17
+ * export default {
18
+ * adapter: adapterBun(),
19
+ * };
20
+ * ```
21
+ */
22
+ export function adapterBun(): ManduAdapter {
23
+ return {
24
+ name: "adapter-bun",
25
+
26
+ createServer(options: AdapterOptions): AdapterServer {
27
+ let manduServer: ManduServer | null = null;
28
+
29
+ return {
30
+ async fetch(req: Request): Promise<Response> {
31
+ if (!manduServer) {
32
+ return new Response("Server not started", { status: 503 });
33
+ }
34
+ // 내부 서버로 프록시
35
+ const url = new URL(req.url);
36
+ const targetUrl = `http://localhost:${manduServer.server.port}${url.pathname}${url.search}`;
37
+ return globalThis.fetch(new Request(targetUrl, req));
38
+ },
39
+
40
+ async listen(port: number, hostname?: string) {
41
+ manduServer = startServer(options.manifest, {
42
+ ...options.serverOptions,
43
+ port,
44
+ hostname,
45
+ rootDir: options.rootDir,
46
+ bundleManifest: options.bundleManifest,
47
+ });
48
+
49
+ return {
50
+ port: manduServer.server.port ?? port,
51
+ // Report the effective bind address. startServer() defaults to
52
+ // 0.0.0.0 when no hostname is supplied. See #190.
53
+ hostname: hostname ?? "0.0.0.0",
54
+ };
55
+ },
56
+
57
+ async close() {
58
+ manduServer?.stop();
59
+ manduServer = null;
60
+ },
61
+ };
62
+ },
63
+ };
64
+ }
@@ -350,6 +350,32 @@ export interface ServerOptions {
350
350
  * When set, token-protected endpoints such as `/_mandu/cache` become available.
351
351
  */
352
352
  managementToken?: string;
353
+ /**
354
+ * Issue #192 — enable CSS View Transitions auto-inject (default `true`).
355
+ * When `true`, every SSR response gets
356
+ * `<style>@view-transition{navigation:auto}</style>` in its `<head>`,
357
+ * giving supported browsers a default crossfade on cross-document
358
+ * navigation. Pass `false` to suppress (typically wired from
359
+ * `ManduConfig.transitions`).
360
+ */
361
+ transitions?: boolean;
362
+ /**
363
+ * Issue #192 — enable the hover prefetch helper (default `true`).
364
+ * When `true`, every SSR response gets a ~500-byte inline script that
365
+ * prefetches same-origin links on hover. Pass `false` to suppress
366
+ * (typically wired from `ManduConfig.prefetch`). Individual links can
367
+ * also opt out via `data-no-prefetch`.
368
+ */
369
+ prefetch?: boolean;
370
+ /**
371
+ * Issue #191 — override dev-mode `_devtools.js` injection.
372
+ * Wired from `ManduConfig.dev.devtools`.
373
+ * - `true` → force inject on every page (SSR-only + Kitchen).
374
+ * - `false` → force skip on every page.
375
+ * - `undefined` → default. Inject iff the page's route has at least
376
+ * one island. Pure-SSR pages download zero devtools.
377
+ */
378
+ devtools?: boolean;
353
379
  }
354
380
 
355
381
  export interface ManduServer {
@@ -449,6 +475,31 @@ export interface ServerRegistrySettings {
449
475
  cacheStore?: CacheStore;
450
476
  /** Internal management token for local runtime control */
451
477
  managementToken?: string;
478
+ /**
479
+ * Edge runtime flag — disables filesystem-dependent features (static file
480
+ * serving, Kitchen dashboard, image optimization, SSG fallback loaders).
481
+ * Set by `@mandujs/edge` adapters (Cloudflare Workers, Deno Deploy, Vercel Edge).
482
+ * Default: false (Bun/Node runtime with full FS access).
483
+ */
484
+ edge?: boolean;
485
+ /**
486
+ * Issue #192 — threaded from `ServerOptions.transitions`.
487
+ * `undefined` is treated as `true` at the SSR call-site (enabled by
488
+ * default); `false` suppresses the `<style>@view-transition>` injection.
489
+ */
490
+ transitions?: boolean;
491
+ /**
492
+ * Issue #192 — threaded from `ServerOptions.prefetch`.
493
+ * `undefined` is treated as `true` at the SSR call-site (enabled by
494
+ * default); `false` suppresses the hover prefetch `<script>` injection.
495
+ */
496
+ prefetch?: boolean;
497
+ /**
498
+ * Issue #191 — threaded from `ServerOptions.devtools`. `undefined`
499
+ * means "use default (islands → inject)"; `true` / `false` force the
500
+ * dev-mode `_devtools.js` `<script>` injection on / off. No-op in prod.
501
+ */
502
+ devtools?: boolean;
452
503
  }
453
504
 
454
505
  export class ServerRegistry {
@@ -1882,6 +1933,9 @@ async function renderPageSSR(
1882
1933
  criticalData: loaderData as Record<string, unknown> | undefined,
1883
1934
  enableClientRouter: true,
1884
1935
  cssPath: settings.cssPath,
1936
+ transitions: settings.transitions,
1937
+ prefetch: settings.prefetch,
1938
+ devtools: settings.devtools,
1885
1939
  onShellReady: () => {
1886
1940
  if (settings.isDev) {
1887
1941
  console.log(`[Mandu Streaming] Shell ready: ${route.id}`);
@@ -1917,6 +1971,9 @@ async function renderPageSSR(
1917
1971
  routePattern: route.pattern,
1918
1972
  cssPath: settings.cssPath,
1919
1973
  islandPreWrapped: !!needsIslandWrap,
1974
+ transitions: settings.transitions,
1975
+ prefetch: settings.prefetch,
1976
+ devtools: settings.devtools,
1920
1977
  });
1921
1978
  return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
1922
1979
  } catch (error) {
@@ -1953,6 +2010,9 @@ async function renderPageSSR(
1953
2010
  title: "Mandu App — Error",
1954
2011
  isDev: settings.isDev,
1955
2012
  cssPath: settings.cssPath,
2013
+ transitions: settings.transitions,
2014
+ prefetch: settings.prefetch,
2015
+ devtools: settings.devtools,
1956
2016
  });
1957
2017
  return ok(cookies ? cookies.applyToResponse(errorHtml) : errorHtml);
1958
2018
  }
@@ -2051,6 +2111,9 @@ async function renderNotFoundPage(
2051
2111
  title: "Not Found",
2052
2112
  isDev: settings.isDev,
2053
2113
  cssPath: settings.cssPath,
2114
+ transitions: settings.transitions,
2115
+ prefetch: settings.prefetch,
2116
+ devtools: settings.devtools,
2054
2117
  });
2055
2118
 
2056
2119
  // renderSSR returns a 200; override to 404 without losing headers.
@@ -2408,18 +2471,23 @@ async function handleRequestInternal(
2408
2471
  }
2409
2472
 
2410
2473
  // 1. 정적 파일 서빙 시도 (최우선)
2411
- const staticFileResult = await serveStaticFile(pathname, settings, req);
2412
- if (staticFileResult.handled) {
2413
- const staticResponse = staticFileResult.response!;
2414
- if (settings.cors && isCorsRequest(req)) {
2415
- const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
2416
- return ok(applyCorsToResponse(staticResponse, req, corsOptions));
2474
+ // Edge runtimes (Cloudflare Workers, etc.) have no filesystem — skip and
2475
+ // let the platform's asset pipeline (Wrangler [assets], Vercel _static, …)
2476
+ // handle static routing instead.
2477
+ if (!settings.edge) {
2478
+ const staticFileResult = await serveStaticFile(pathname, settings, req);
2479
+ if (staticFileResult.handled) {
2480
+ const staticResponse = staticFileResult.response!;
2481
+ if (settings.cors && isCorsRequest(req)) {
2482
+ const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
2483
+ return ok(applyCorsToResponse(staticResponse, req, corsOptions));
2484
+ }
2485
+ return ok(staticResponse);
2417
2486
  }
2418
- return ok(staticResponse);
2419
2487
  }
2420
2488
 
2421
2489
  // 1.5. Image optimization handler (/_mandu/image)
2422
- if (pathname === "/_mandu/image") {
2490
+ if (!settings.edge && pathname === "/_mandu/image") {
2423
2491
  const imageResponse = await handleImageRequest(req, settings.rootDir, settings.publicDir);
2424
2492
  if (imageResponse) return ok(imageResponse);
2425
2493
  }
@@ -2473,6 +2541,9 @@ async function handleRequestInternal(
2473
2541
  title: "Not Found",
2474
2542
  isDev: settings.isDev,
2475
2543
  cssPath: settings.cssPath,
2544
+ transitions: settings.transitions,
2545
+ prefetch: settings.prefetch,
2546
+ devtools: settings.devtools,
2476
2547
  });
2477
2548
  const headers = new Headers(html.headers);
2478
2549
  const body = await html.text();
@@ -2574,10 +2645,43 @@ function startBunServerWithFallback(options: {
2574
2645
 
2575
2646
  // ========== Server Startup ==========
2576
2647
 
2648
+ /**
2649
+ * Format a base URL for startup logging based on the bound hostname.
2650
+ *
2651
+ * When binding to wildcard addresses (`0.0.0.0`, `::`, or empty string),
2652
+ * the server listens on all interfaces — browsers must use `localhost`
2653
+ * or a specific loopback address to connect. We surface both IPv4 and IPv6
2654
+ * loopback URLs so the user can pick whichever their OS prefers.
2655
+ *
2656
+ * Returns `{ primary, additional }` where `primary` is the canonical URL
2657
+ * for UX (open-in-browser, runtime control) and `additional` are supplementary
2658
+ * URLs shown in the startup log.
2659
+ */
2660
+ export function formatServerAddresses(
2661
+ hostname: string | undefined,
2662
+ port: number
2663
+ ): { primary: string; additional: string[] } {
2664
+ const isWildcardV4 = hostname === "0.0.0.0" || hostname === undefined || hostname === "";
2665
+ const isWildcardV6 = hostname === "::" || hostname === "[::]";
2666
+ if (isWildcardV4 || isWildcardV6) {
2667
+ return {
2668
+ primary: `http://localhost:${port}`,
2669
+ additional: [`http://127.0.0.1:${port}`, `http://[::1]:${port}`],
2670
+ };
2671
+ }
2672
+ // Bracket IPv6 literals for URL syntax.
2673
+ const host = hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
2674
+ return { primary: `http://${host}:${port}`, additional: [] };
2675
+ }
2676
+
2577
2677
  export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
2578
2678
  const {
2579
2679
  port = 3000,
2580
- hostname = "localhost",
2680
+ // Default to 0.0.0.0 (dual-stack wildcard on IPv4) so `localhost` resolves
2681
+ // to 127.0.0.1 via OS-level IPv4-preferred lookups (e.g., Windows). Users
2682
+ // can still pin `hostname: "::1"` or `hostname: "127.0.0.1"` explicitly.
2683
+ // See issue #190.
2684
+ hostname = "0.0.0.0",
2581
2685
  rootDir = process.cwd(),
2582
2686
  isDev = false,
2583
2687
  hmrPort,
@@ -2591,6 +2695,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2591
2695
  guardConfig = null,
2592
2696
  cache: cacheOption,
2593
2697
  managementToken,
2698
+ transitions,
2699
+ prefetch,
2700
+ devtools,
2594
2701
  } = options;
2595
2702
 
2596
2703
  // cssPath 처리:
@@ -2626,6 +2733,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2626
2733
  rateLimit: rateLimitOptions,
2627
2734
  cssPath,
2628
2735
  managementToken,
2736
+ transitions,
2737
+ prefetch,
2738
+ devtools,
2629
2739
  };
2630
2740
 
2631
2741
  registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
@@ -2725,8 +2835,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2725
2835
  registry.settings = { ...registry.settings, hmrPort: actualPort };
2726
2836
  }
2727
2837
 
2838
+ const addresses = formatServerAddresses(hostname, actualPort);
2839
+
2728
2840
  if (isDev) {
2729
- console.log(`🥟 Mandu Dev Server running at http://${hostname}:${actualPort}`);
2841
+ console.log(`🥟 Mandu Dev Server listening at ${addresses.primary}`);
2842
+ if (addresses.additional.length > 0) {
2843
+ console.log(` (also reachable at ${addresses.additional.join(", ")})`);
2844
+ }
2730
2845
  if (registry.settings.hmrPort) {
2731
2846
  console.log(`🔥 HMR enabled on port ${registry.settings.hmrPort + PORTS.HMR_OFFSET}`);
2732
2847
  }
@@ -2738,10 +2853,13 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2738
2853
  console.log(`🌊 Streaming SSR enabled`);
2739
2854
  }
2740
2855
  if (registry.kitchen) {
2741
- console.log(`🍳 Kitchen dashboard at http://${hostname}:${actualPort}/__kitchen`);
2856
+ console.log(`🍳 Kitchen dashboard at ${addresses.primary}/__kitchen`);
2742
2857
  }
2743
2858
  } else {
2744
- console.log(`🥟 Mandu server running at http://${hostname}:${actualPort}`);
2859
+ console.log(`🥟 Mandu server listening at ${addresses.primary}`);
2860
+ if (addresses.additional.length > 0) {
2861
+ console.log(` (also reachable at ${addresses.additional.join(", ")})`);
2862
+ }
2745
2863
  if (streaming) {
2746
2864
  console.log(`🌊 Streaming SSR enabled`);
2747
2865
  }
@@ -2769,6 +2887,119 @@ export const pageLoaders = defaultRegistry.pageLoaders;
2769
2887
  export const pageHandlers = defaultRegistry.pageHandlers;
2770
2888
  export const routeComponents = defaultRegistry.routeComponents;
2771
2889
 
2890
+ // ========== Runtime-Neutral Fetch Handler Factory ==========
2891
+
2892
+ /**
2893
+ * Options for {@link createAppFetchHandler}. Subset of {@link ServerOptions}
2894
+ * that makes sense in edge/serverless runtimes — no listen/port/hmr fields.
2895
+ */
2896
+ export interface AppFetchHandlerOptions {
2897
+ /** Project root (used for module path validation). Required. */
2898
+ rootDir: string;
2899
+ /** Bundle manifest (Island hydration). Optional in pure-SSR apps. */
2900
+ bundleManifest?: BundleManifest;
2901
+ /** CORS config — `true` allows all origins, object for fine-grained rules. */
2902
+ cors?: boolean | CorsOptions;
2903
+ /** Streaming SSR toggle. Default: `false`. */
2904
+ streaming?: boolean;
2905
+ /** Rate limit policy. Memory-backed; edge runtimes should prefer durable stores. */
2906
+ rateLimit?: boolean | RateLimitOptions;
2907
+ /**
2908
+ * CSS link injection target for SSR. Typically `"/.mandu/client/globals.css"`
2909
+ * when Tailwind is in use. `false` disables injection.
2910
+ */
2911
+ cssPath?: string | false;
2912
+ /** Custom registry override (defaults to the global registry). */
2913
+ registry?: ServerRegistry;
2914
+ /**
2915
+ * Mark this handler as edge-hosted. Skips filesystem-dependent features
2916
+ * (static file serving, Kitchen dashboard, image optimization). Set to
2917
+ * `true` by `@mandujs/edge` adapters.
2918
+ */
2919
+ edge?: boolean;
2920
+ /**
2921
+ * Optional global middleware function. When omitted, the handler does not
2922
+ * attempt to auto-load `middleware.ts` from disk (important for edge
2923
+ * bundles where FS is unavailable). Adapters should pass pre-compiled
2924
+ * middleware at build time.
2925
+ */
2926
+ middleware?: {
2927
+ fn: MiddlewareFn;
2928
+ config?: MiddlewareConfig | null;
2929
+ };
2930
+ }
2931
+
2932
+ /**
2933
+ * Build a runtime-neutral `fetch(req) → Promise<Response>` handler from a
2934
+ * routes manifest. Reuses the same request pipeline as `startServer()`
2935
+ * (CORS, middleware, router, SSR, API handlers) but without binding to
2936
+ * `Bun.serve`. Suitable for Cloudflare Workers, Deno Deploy, Vercel Edge,
2937
+ * Netlify Edge, and any other Web-Fetch host.
2938
+ *
2939
+ * Handler registration (`registerApiHandler`, `registerPageHandler`, …) must
2940
+ * happen *before* calling this factory — same contract as `startServer`.
2941
+ *
2942
+ * @example
2943
+ * ```ts
2944
+ * // Cloudflare Workers entry
2945
+ * import { createAppFetchHandler } from "@mandujs/core";
2946
+ * import manifest from "./.mandu/routes.manifest.json";
2947
+ * import "./.mandu/edge-workers/register.js"; // populates registries
2948
+ *
2949
+ * const fetch = createAppFetchHandler(manifest, {
2950
+ * rootDir: "/",
2951
+ * edge: true,
2952
+ * cssPath: false,
2953
+ * });
2954
+ *
2955
+ * export default { fetch };
2956
+ * ```
2957
+ */
2958
+ export function createAppFetchHandler(
2959
+ manifest: RoutesManifest,
2960
+ options: AppFetchHandlerOptions
2961
+ ): (req: Request) => Promise<Response> {
2962
+ const {
2963
+ rootDir,
2964
+ bundleManifest,
2965
+ cors = false,
2966
+ streaming = false,
2967
+ rateLimit = false,
2968
+ cssPath = false,
2969
+ registry = defaultRegistry,
2970
+ edge = false,
2971
+ middleware,
2972
+ } = options;
2973
+
2974
+ const corsOptions: CorsOptions | false = cors === true ? {} : cors;
2975
+ const rateLimitOptions = normalizeRateLimitOptions(rateLimit);
2976
+
2977
+ registry.settings = {
2978
+ isDev: false,
2979
+ bundleManifest,
2980
+ rootDir,
2981
+ publicDir: "public",
2982
+ cors: corsOptions,
2983
+ streaming,
2984
+ rateLimit: rateLimitOptions,
2985
+ cssPath,
2986
+ edge,
2987
+ };
2988
+
2989
+ registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
2990
+
2991
+ const router = new Router(manifest.routes);
2992
+
2993
+ return createFetchHandler({
2994
+ router,
2995
+ registry,
2996
+ corsOptions,
2997
+ middlewareFn: middleware?.fn ?? null,
2998
+ middlewareConfig: middleware?.config ?? null,
2999
+ handleRequest,
3000
+ });
3001
+ }
3002
+
2772
3003
  // ========== Rate Limiting Public API ==========
2773
3004
 
2774
3005
  /**