@mandujs/core 0.31.0 → 0.32.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.
@@ -619,6 +619,107 @@ function escapeRegex(char: string): string {
619
619
  return /[\\^$.*+?()[\]{}|]/.test(char) ? `\\${char}` : char;
620
620
  }
621
621
 
622
+ // ═══════════════════════════════════════════════════════════════════════════
623
+ // Phase 18.μ — i18n path-prefix route synthesis
624
+ // ═══════════════════════════════════════════════════════════════════════════
625
+
626
+ /**
627
+ * Options for {@link synthesizeLocaleRoutes}. Mirrors the relevant subset
628
+ * of `I18nDefinition` so callers don't need to pull the whole
629
+ * `@mandujs/core/i18n` surface into pure-router code paths.
630
+ */
631
+ export interface LocaleSynthesisOptions {
632
+ /** Allow-list of locale codes to materialize. */
633
+ locales: readonly string[];
634
+ /** Default locale — its routes stay unprefixed (Next.js parity). */
635
+ defaultLocale: string;
636
+ }
637
+
638
+ /**
639
+ * Phase 18.μ — synthesize per-locale route variants at manifest-build
640
+ * time. Given a set of scanned routes, produces `locales.length` copies
641
+ * for every `page` / `api` route with a locale prefix baked into
642
+ * `id` + `pattern`. The default locale's routes are emitted unprefixed
643
+ * (so legacy links keep working and SEO stays intact).
644
+ *
645
+ * The synthesis is pure: it re-uses existing `FSRouteConfig` objects as
646
+ * source of truth, producing *new* objects with:
647
+ *
648
+ * - `pattern` : `/en/blog/:slug`
649
+ * - `id` : `en::<original-id>`
650
+ * - `module` : unchanged (same loader on disk)
651
+ * - everything else: shallow-copied
652
+ *
653
+ * Metadata routes (sitemap/robots/llms-txt/manifest) are NOT duplicated —
654
+ * they always sit at site root regardless of locale (same SEO rule as
655
+ * Next.js).
656
+ *
657
+ * The caller is responsible for passing the output through
658
+ * {@link sortRoutesByPriority} before writing the manifest.
659
+ *
660
+ * @example
661
+ * ```ts
662
+ * const scan = await scanRoutes(rootDir);
663
+ * const prefixed = synthesizeLocaleRoutes(scan.routes, {
664
+ * locales: ["en", "ko"],
665
+ * defaultLocale: "en",
666
+ * });
667
+ * // scan.routes : [/, /blog, /blog/:slug]
668
+ * // prefixed : [/, /blog, /blog/:slug, /ko, /ko/blog, /ko/blog/:slug]
669
+ * ```
670
+ */
671
+ export function synthesizeLocaleRoutes(
672
+ routes: FSRouteConfig[],
673
+ options: LocaleSynthesisOptions
674
+ ): FSRouteConfig[] {
675
+ const { locales, defaultLocale } = options;
676
+ if (!Array.isArray(locales) || locales.length === 0) return [...routes];
677
+ if (!locales.includes(defaultLocale)) {
678
+ throw new Error(
679
+ `[router] synthesizeLocaleRoutes: defaultLocale "${defaultLocale}" not in locales [${locales.join(", ")}]`
680
+ );
681
+ }
682
+
683
+ const out: FSRouteConfig[] = [];
684
+ for (const route of routes) {
685
+ // Metadata routes live at site root; never prefix them.
686
+ if (route.kind === "metadata") {
687
+ out.push(route);
688
+ continue;
689
+ }
690
+
691
+ // Default locale: unprefixed copy preserved verbatim (legacy +
692
+ // SEO neutral).
693
+ out.push(route);
694
+
695
+ for (const locale of locales) {
696
+ if (locale === defaultLocale) continue;
697
+ const prefixed = prefixRouteWithLocale(route, locale);
698
+ out.push(prefixed);
699
+ }
700
+ }
701
+ return out;
702
+ }
703
+
704
+ function prefixRouteWithLocale(route: FSRouteConfig, locale: string): FSRouteConfig {
705
+ const prefixedPattern = route.pattern === "/"
706
+ ? `/${locale}`
707
+ : `/${locale}${route.pattern.startsWith("/") ? route.pattern : `/${route.pattern}`}`;
708
+
709
+ return {
710
+ ...route,
711
+ id: `${locale}::${route.id}`,
712
+ pattern: prefixedPattern,
713
+ // `segments` is used for priority calculation + layout resolution;
714
+ // prepending a static locale segment keeps priority sensible and
715
+ // avoids collisions with real `[param]` segments.
716
+ segments: [
717
+ { raw: locale, type: "static" },
718
+ ...route.segments,
719
+ ],
720
+ };
721
+ }
722
+
622
723
  // ═══════════════════════════════════════════════════════════════════════════
623
724
  // Factory Function
624
725
  // ═══════════════════════════════════════════════════════════════════════════
@@ -68,7 +68,13 @@ export {
68
68
  } from "./fs-patterns";
69
69
 
70
70
  // Scanner
71
- export { FSScanner, createFSScanner, scanRoutes } from "./fs-scanner";
71
+ export {
72
+ FSScanner,
73
+ createFSScanner,
74
+ scanRoutes,
75
+ synthesizeLocaleRoutes,
76
+ type LocaleSynthesisOptions,
77
+ } from "./fs-scanner";
72
78
 
73
79
  // Generator
74
80
  export type { FSGenerateResult, GenerateOptions, RouteChangeCallback, FSRoutesWatcher } from "./fs-routes";
@@ -117,6 +117,20 @@ import {
117
117
  buildPayloadFromError,
118
118
  shouldInjectOverlay,
119
119
  } from "../dev-error-overlay";
120
+ // Phase 18.μ — i18n dispatch. `resolveLocale()` is pure (no side effects),
121
+ // `createTranslator()` binds a per-request `t()` to the registry.
122
+ import {
123
+ resolveLocale,
124
+ createTranslator,
125
+ isI18nDefinition,
126
+ isMessageRegistry,
127
+ } from "../i18n";
128
+ import type {
129
+ I18nDefinition,
130
+ ResolvedLocale,
131
+ Translator,
132
+ } from "../i18n/types";
133
+ import type { MessageRegistry } from "../i18n/message-registry";
120
134
 
121
135
  export interface RateLimitOptions {
122
136
  windowMs?: number;
@@ -548,6 +562,33 @@ export interface ServerOptions {
548
562
  jobs?: import("../scheduler").CronDef[];
549
563
  disabled?: boolean;
550
564
  };
565
+ /**
566
+ * Phase 18.μ — first-class i18n. Threaded from `ManduConfig.i18n`.
567
+ *
568
+ * When populated, the runtime dispatcher:
569
+ * 1. resolves the active locale BEFORE route dispatch (via
570
+ * {@link resolveLocale}) using the configured strategy;
571
+ * 2. attaches `ctx.locale` (ResolvedLocale) + `ctx.t` (typed
572
+ * translator) to every loader / handler invocation;
573
+ * 3. stamps `Vary: Accept-Language` + `Content-Language` on all
574
+ * page responses so upstream caches key on locale;
575
+ * 4. when `strategy === "path-prefix"` and the incoming URL
576
+ * carries no locale prefix AND no override cookie, redirects
577
+ * `/` → `/<defaultLocale>` so browsers reach a locale-scoped
578
+ * URL (Next.js parity).
579
+ *
580
+ * Omitting this block keeps the runtime branch-free — the hot path
581
+ * incurs zero overhead when i18n is disabled.
582
+ */
583
+ i18n?: I18nDefinition;
584
+ /**
585
+ * Phase 18.μ — optional message registry bound to `ctx.t`. Created via
586
+ * `defineMessages({ en: {...}, ko: {...} })`. When omitted but `i18n`
587
+ * is set, `ctx.locale` is populated but `ctx.t` stays `undefined` —
588
+ * projects that manage their own translation layer (e.g. react-intl)
589
+ * use `ctx.locale.code` and ignore `ctx.t`.
590
+ */
591
+ messages?: MessageRegistry;
551
592
  }
552
593
 
553
594
  export interface ManduServer {
@@ -736,6 +777,19 @@ export interface ServerRegistrySettings {
736
777
  * `ServerOptions.middleware` at {@link startServer} time.
737
778
  */
738
779
  middlewareChain?: ComposedHandler;
780
+ /**
781
+ * Phase 18.μ — resolved i18n definition. `undefined` means "no i18n
782
+ * configured at boot" (hot path is branch-free). Wired from
783
+ * `ServerOptions.i18n`.
784
+ */
785
+ i18n?: I18nDefinition;
786
+ /**
787
+ * Phase 18.μ — message registry bound to {@link i18n}. When both are
788
+ * set, the runtime builds a per-request `ctx.t` via
789
+ * `createTranslator()`. When only `i18n` is set, `ctx.t` stays
790
+ * `undefined` and user code is responsible for its own translations.
791
+ */
792
+ messages?: MessageRegistry;
739
793
  }
740
794
 
741
795
  export class ServerRegistry {
@@ -1609,6 +1663,15 @@ async function handleRequestWithTracing(
1609
1663
  });
1610
1664
  }
1611
1665
  }
1666
+ // Phase 18.μ — stamp locale hint on error responses too.
1667
+ const errI18nState = i18nRequestState.get(req);
1668
+ if (errI18nState && !errorResponse.headers.has("Content-Language")) {
1669
+ try {
1670
+ errorResponse.headers.set("Content-Language", errI18nState.locale.code);
1671
+ } catch {
1672
+ // Some adapters freeze headers on error responses — ignore.
1673
+ }
1674
+ }
1612
1675
  return errorResponse;
1613
1676
  }
1614
1677
 
@@ -1642,6 +1705,28 @@ async function handleRequestWithTracing(
1642
1705
  }
1643
1706
  }
1644
1707
 
1708
+ // Phase 18.μ — stamp locale-sensitive caching hints onto the final
1709
+ // response. `Vary: Accept-Language, Cookie` ensures upstream caches
1710
+ // key correctly per locale signal; `Content-Language` surfaces the
1711
+ // resolved locale for SEO / screen-reader parity. Only stamped when
1712
+ // i18n is configured AND the request was actually resolved — the hot
1713
+ // path is branch-free when `i18n` is absent.
1714
+ const i18nState = i18nRequestState.get(req);
1715
+ if (i18nState) {
1716
+ const existingVary = result.value.headers.get("Vary");
1717
+ const varyParts = new Set(
1718
+ (existingVary ? existingVary.split(",") : [])
1719
+ .map((s) => s.trim())
1720
+ .filter(Boolean)
1721
+ );
1722
+ varyParts.add("Accept-Language");
1723
+ varyParts.add("Cookie");
1724
+ result.value.headers.set("Vary", [...varyParts].join(", "));
1725
+ if (!result.value.headers.has("Content-Language")) {
1726
+ result.value.headers.set("Content-Language", i18nState.locale.code);
1727
+ }
1728
+ }
1729
+
1645
1730
  return result.value;
1646
1731
  }
1647
1732
 
@@ -1908,6 +1993,7 @@ async function loadPageData(
1908
1993
  // Filling의 loader 실행
1909
1994
  if (registration.filling?.hasLoader()) {
1910
1995
  const ctx = new ManduContext(req, params);
1996
+ attachI18nToContext(ctx, req);
1911
1997
  // DX-3: loader may return OR throw a redirect Response. Both are
1912
1998
  // short-circuits — if we detect one, skip SSR and hand the Response
1913
1999
  // to the caller with pending cookies merged in.
@@ -2028,6 +2114,7 @@ async function loadPageData(
2028
2114
  }
2029
2115
  if (filling?.hasLoader?.()) {
2030
2116
  const ctx = new ManduContext(req, params);
2117
+ attachI18nToContext(ctx, req);
2031
2118
  // DX-3 / Phase 6.3: same redirect + notFound handling as the
2032
2119
  // PageHandler path above. notFound is checked first so both
2033
2120
  // short-circuits remain symmetric.
@@ -2197,6 +2284,7 @@ async function loadLayoutData(
2197
2284
  const filling = exported as ManduFilling;
2198
2285
  if (filling.hasLoader()) {
2199
2286
  const ctx = new ManduContext(req, params);
2287
+ attachI18nToContext(ctx, req);
2200
2288
  const data = await filling.executeLoader(ctx);
2201
2289
  // DX-3: layout loaders are NOT allowed to redirect. They share
2202
2290
  // the pipeline with a page loader and we can only honor one
@@ -2430,15 +2518,27 @@ async function renderPageSSR(
2430
2518
  ? route.streaming
2431
2519
  : settings.streaming;
2432
2520
 
2433
- // Issue #198 — Pre-resolve async server components before handing off
2434
- // to React's SSR engines. `renderToString` (non-streaming path) does
2435
- // not support async components; `renderToReadableStream` does, but
2436
- // the shell-gen step in streaming-ssr also falls through
2437
- // `collectStreamingHeadTags` → `renderToString`. Resolving up-front
2438
- // gives consistent, synchronous trees to both paths and keeps the
2439
- // user-visible contract (`export default async function Page() {...}`
2440
- // and `export default async function Layout() {...}`) working end to end.
2441
- app = (await resolveAsyncElement(app)) as React.ReactElement;
2521
+ // Issue #198 / Phase 18.ξ Async server component resolution policy.
2522
+ //
2523
+ // Non-streaming path (`renderToString`) cannot handle async components,
2524
+ // so we MUST pre-resolve the tree up-front.
2525
+ //
2526
+ // Streaming path (`renderToReadableStream`, React 19) supports async
2527
+ // components natively and is designed around progressive flushing. If
2528
+ // we pre-resolve here, the caller blocks until every async component
2529
+ // settles before the shell can be emitted — defeating the entire point
2530
+ // of streaming (`TTFB` regresses from shell-ready to slowest-component).
2531
+ // So in streaming mode we hand React the raw async tree. Head tags
2532
+ // pushed from async components are captured by `renderToStream`'s
2533
+ // `buildHtmlTail` (late-head injection) via `use-head`. The streaming
2534
+ // shell-gen's `collectStreamingHeadTags` pre-pass uses `renderToString`
2535
+ // internally and will throw on async trees — its try/catch already
2536
+ // handles that case and returns an empty string, so the early shell
2537
+ // emission is still correct; the late-head script fills in any
2538
+ // metadata emitted during the async render.
2539
+ if (!useStreaming) {
2540
+ app = (await resolveAsyncElement(app)) as React.ReactElement;
2541
+ }
2442
2542
 
2443
2543
  if (useStreaming) {
2444
2544
  const streamingResponse = await renderStreamingResponse(app, {
@@ -2667,6 +2767,7 @@ async function renderNotFoundPage(
2667
2767
  let loaderData: unknown = { message };
2668
2768
  if (registration.filling?.hasLoader()) {
2669
2769
  const ctx = new ManduContext(req, params);
2770
+ attachI18nToContext(ctx, req);
2670
2771
  try {
2671
2772
  const returned = await registration.filling.executeLoader(ctx);
2672
2773
  loaderData = returned !== undefined ? returned : { message };
@@ -3243,6 +3344,66 @@ async function tryServePrerendered(
3243
3344
  return new Response(body, { status: 200, headers });
3244
3345
  }
3245
3346
 
3347
+ // ─── Phase 18.μ — request-scoped i18n state ──────────────────────────────
3348
+ /**
3349
+ * Per-request locale state keyed by the `Request` object. The runtime
3350
+ * dispatcher populates this right before `handleRequestInternal` returns
3351
+ * to route-specific paths; `handlePageRoute` / `handleApiRoute` read it
3352
+ * when creating `ManduContext` and attach via `ctx._setI18n(...)`.
3353
+ *
3354
+ * A WeakMap is used so completed requests release their entries
3355
+ * automatically — no explicit cleanup needed on the hot path.
3356
+ *
3357
+ * @internal
3358
+ */
3359
+ const i18nRequestState = new WeakMap<
3360
+ Request,
3361
+ { locale: ResolvedLocale; translator?: Translator }
3362
+ >();
3363
+
3364
+ /**
3365
+ * Phase 18.μ — expose the stashed locale state for `filling` /
3366
+ * `handlePageRoute` / `handleApiRoute`. Returns `undefined` when i18n
3367
+ * is disabled OR the request predates dispatch (internal callers).
3368
+ *
3369
+ * @internal
3370
+ */
3371
+ export function getRequestI18n(
3372
+ req: Request
3373
+ ): { locale: ResolvedLocale; translator?: Translator } | undefined {
3374
+ return i18nRequestState.get(req);
3375
+ }
3376
+
3377
+ /**
3378
+ * Phase 18.μ — attach i18n state to a freshly-constructed `ManduContext`.
3379
+ * No-op when i18n is disabled. Called by every callsite that instantiates
3380
+ * `new ManduContext(req, …)` so `ctx.locale` + `ctx.t` are populated
3381
+ * before loader / handler execution.
3382
+ *
3383
+ * @internal
3384
+ */
3385
+ function attachI18nToContext(ctx: ManduContext, req: Request): void {
3386
+ const state = i18nRequestState.get(req);
3387
+ if (state) {
3388
+ ctx._setI18n(state.locale, state.translator);
3389
+ }
3390
+ }
3391
+
3392
+ /**
3393
+ * Helper: returns the URL's first segment if it matches a known locale.
3394
+ * Extracted so the inline μ dispatch block stays readable.
3395
+ */
3396
+ function stripLocaleForRedirectCheck(
3397
+ pathname: string,
3398
+ locales: readonly string[]
3399
+ ): string | undefined {
3400
+ if (pathname.length < 2) return undefined;
3401
+ const slashIdx = pathname.indexOf("/", 1);
3402
+ const first = slashIdx === -1 ? pathname.slice(1) : pathname.slice(1, slashIdx);
3403
+ return locales.includes(first) ? first : undefined;
3404
+ }
3405
+ // ─── End Phase 18.μ ──────────────────────────────────────────────────────
3406
+
3246
3407
  async function handleRequestInternal(
3247
3408
  req: Request,
3248
3409
  router: Router,
@@ -3272,6 +3433,92 @@ async function handleRequestInternal(
3272
3433
  return ok(prerendered);
3273
3434
  }
3274
3435
 
3436
+ // ─── Phase 18.μ — i18n dispatch ─────────────────────────────────────────
3437
+ // Runs AFTER γ's prerendered check (static HTML per-locale is already
3438
+ // handled by path-prefix synthesis at build time) and BEFORE ζ's cache
3439
+ // lookup (cache keys include the resolved locale via `Vary:
3440
+ // Accept-Language`).
3441
+ //
3442
+ // 1. Resolve the active locale via `resolveLocale()` — the strategy
3443
+ // switches determines URL / cookie / header precedence.
3444
+ // 2. For `strategy: 'path-prefix'`: when the incoming URL carries no
3445
+ // locale prefix (root or locale-less path) AND the resolution
3446
+ // came from `default` / `fallback`, we emit a 307 redirect to
3447
+ // `/<locale><rest>` so the user lands on a locale-scoped URL.
3448
+ // Cookie / header signals "win" over the default — no redirect
3449
+ // when the user explicitly preferred a non-default locale.
3450
+ // 3. Stash the resolved locale on the `registry.__perRequest` map
3451
+ // indexed by the request so downstream `handlePageRoute` /
3452
+ // `handleApiRoute` can mount it onto `ctx` via `_setI18n()`.
3453
+ //
3454
+ // Zero overhead when `settings.i18n` is undefined — the `if` falls
3455
+ // through and the hot path runs exactly as Phase 18.λ's baseline.
3456
+ let resolvedLocaleForRequest: ResolvedLocale | undefined;
3457
+ let translatorForRequest: Translator | undefined;
3458
+ if (settings.i18n) {
3459
+ resolvedLocaleForRequest = resolveLocale(req, settings.i18n);
3460
+
3461
+ // Path-prefix strategy: URL has no locale prefix AND resolver picked
3462
+ // a non-default locale from cookie/header → redirect to prefixed URL
3463
+ // so the user lands on a locale-scoped URL (Next.js parity).
3464
+ //
3465
+ // Excluded paths (never redirected, always locale-neutral):
3466
+ // - `/api/*` — API routes use `Accept-Language` header; JSON
3467
+ // clients rarely want an HTML redirect.
3468
+ // - `/_mandu/*`, `/.mandu/*` — framework internals.
3469
+ // - `/sitemap.xml`, `/robots.txt`, etc. — metadata routes live
3470
+ // at site root across all locales.
3471
+ if (
3472
+ settings.i18n.strategy === "path-prefix" &&
3473
+ (req.method === "GET" || req.method === "HEAD") &&
3474
+ !pathname.startsWith("/api/") &&
3475
+ !pathname.startsWith("/_mandu/") &&
3476
+ !pathname.startsWith("/.mandu/") &&
3477
+ !pathname.startsWith("/__kitchen") &&
3478
+ !pathname.startsWith("/__mandu/") &&
3479
+ pathname !== "/sitemap.xml" &&
3480
+ pathname !== "/robots.txt" &&
3481
+ pathname !== "/llms.txt" &&
3482
+ pathname !== "/manifest.webmanifest"
3483
+ ) {
3484
+ const urlLocale = stripLocaleForRedirectCheck(pathname, settings.i18n.locales);
3485
+ if (!urlLocale && resolvedLocaleForRequest.code !== settings.i18n.defaultLocale) {
3486
+ const targetPath = `/${resolvedLocaleForRequest.code}${pathname === "/" ? "" : pathname}`;
3487
+ const targetUrl = new URL(targetPath + url.search, req.url);
3488
+ const redirectRes = new Response(null, {
3489
+ status: 307,
3490
+ headers: {
3491
+ Location: targetUrl.toString(),
3492
+ Vary: "Accept-Language, Cookie",
3493
+ "Content-Language": resolvedLocaleForRequest.code,
3494
+ },
3495
+ });
3496
+ return ok(redirectRes);
3497
+ }
3498
+ }
3499
+
3500
+ // Build translator if a registry is wired up.
3501
+ if (settings.messages) {
3502
+ translatorForRequest = createTranslator(settings.messages, {
3503
+ activeLocale: resolvedLocaleForRequest.code,
3504
+ defaultLocale: settings.i18n.defaultLocale,
3505
+ fallbackLocale: settings.i18n.fallback,
3506
+ });
3507
+ }
3508
+
3509
+ // Stash on request-scoped map so downstream context creation (inside
3510
+ // `handlePageRoute` / `handleApiRoute`) can pick them up without
3511
+ // threading extra args through every call site. `i18nRequestState`
3512
+ // is a WeakMap — no memory retention after the request completes.
3513
+ if (resolvedLocaleForRequest) {
3514
+ i18nRequestState.set(req, {
3515
+ locale: resolvedLocaleForRequest,
3516
+ translator: translatorForRequest,
3517
+ });
3518
+ }
3519
+ }
3520
+ // ─── End Phase 18.μ ─────────────────────────────────────────────────────
3521
+
3275
3522
  // 1. 정적 파일 서빙 시도 (최우선)
3276
3523
  // Edge runtimes (Cloudflare Workers, etc.) have no filesystem — skip and
3277
3524
  // let the platform's asset pipeline (Wrangler [assets], Vercel _static, …)
@@ -3425,6 +3672,7 @@ async function handleRequestInternal(
3425
3672
  let loaderData: unknown = { message: "Not Found" };
3426
3673
  if (registration.filling?.hasLoader()) {
3427
3674
  const ctx = new ManduContext(req, {});
3675
+ attachI18nToContext(ctx, req);
3428
3676
  try {
3429
3677
  const returned = await registration.filling.executeLoader(ctx);
3430
3678
  loaderData = returned !== undefined ? returned : { message: "Not Found" };
@@ -3610,8 +3858,26 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3610
3858
  middleware: middlewareOption,
3611
3859
  rpc: rpcOption,
3612
3860
  scheduler: schedulerOption,
3861
+ i18n: i18nOption,
3862
+ messages: messagesOption,
3613
3863
  } = options;
3614
3864
 
3865
+ // Phase 18.μ — validate i18n + messages shape. Both are branded via
3866
+ // `defineI18n()` / `defineMessages()`; reject any raw object so user
3867
+ // typos fail fast at boot instead of on first request.
3868
+ if (i18nOption !== undefined && !isI18nDefinition(i18nOption)) {
3869
+ throw new Error(
3870
+ "[mandu] ServerOptions.i18n must be the return value of defineI18n(). " +
3871
+ "Import { defineI18n } from '@mandujs/core/i18n'."
3872
+ );
3873
+ }
3874
+ if (messagesOption !== undefined && !isMessageRegistry(messagesOption)) {
3875
+ throw new Error(
3876
+ "[mandu] ServerOptions.messages must be the return value of defineMessages(). " +
3877
+ "Import { defineMessages } from '@mandujs/core/i18n'."
3878
+ );
3879
+ }
3880
+
3615
3881
  // Phase 18.ε — build the request-level middleware chain once at boot.
3616
3882
  // `compose()` returns a passthrough when the list is empty; storing
3617
3883
  // `undefined` for "no middleware" keeps the hot path branch-free.
@@ -3692,6 +3958,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3692
3958
  tracer: tracerInstance.enabled ? tracerInstance : undefined,
3693
3959
  prerender: prerenderSettings,
3694
3960
  middlewareChain,
3961
+ i18n: i18nOption,
3962
+ messages: messagesOption,
3695
3963
  };
3696
3964
 
3697
3965
  registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
@@ -590,6 +590,15 @@ export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
590
590
  // Clone with the resolved children. React.cloneElement preserves the
591
591
  // element's key, ref, and internal `$$typeof` markers — a plain spread
592
592
  // does not.
593
+ //
594
+ // #212 — when resolvedChildren is an array of siblings, spread it back
595
+ // to the variadic form so React's variadic-children heuristic kicks in
596
+ // and does NOT demand keys (the original JSX was variadic too — no key
597
+ // was required). Passing a single array argument would trigger spurious
598
+ // "missing key" warnings for every sibling.
599
+ if (Array.isArray(resolvedChildren)) {
600
+ return React.cloneElement(element, undefined, ...resolvedChildren);
601
+ }
593
602
  return React.cloneElement(element, undefined, resolvedChildren);
594
603
  }
595
604