@mandujs/core 0.30.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.
@@ -84,6 +84,11 @@ import {
84
84
  type ComposedHandler,
85
85
  } from "../middleware/compose";
86
86
  import type { Middleware } from "../middleware/define";
87
+ // Phase 18.λ — scheduler wiring (statically imported so `startServer` stays
88
+ // synchronous; the cost of unused code is trivial — `defineCron` is a thin
89
+ // wrapper around `Bun.cron`).
90
+ import { defineCron as schedulerDefineCron } from "../scheduler";
91
+ import { setActiveSchedulerRegistration } from "../middleware/scheduler-cron";
87
92
  import { createFetchHandler } from "./handler";
88
93
  import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
89
94
  import { handleImageRequest } from "./image-handler";
@@ -91,6 +96,14 @@ import { extractShellHtml, createPPRResponse } from "./ppr";
91
96
  import { isRedirectResponse } from "./redirect";
92
97
  import { isNotFoundResponse } from "./not-found";
93
98
  import { newId } from "../id";
99
+ // Phase 18.κ — typed RPC dispatch (tRPC-like). See
100
+ // `packages/core/src/contract/rpc.ts` + `docs/architect/typed-rpc.md`.
101
+ import {
102
+ matchRpcPath,
103
+ dispatchRpc,
104
+ registerRpc,
105
+ clearRpcRegistry,
106
+ } from "../contract/rpc";
94
107
  import { handleMetadataRoute as dispatchMetadataRoute } from "../routes/metadata-routes";
95
108
  import {
96
109
  DEFAULT_PRERENDER_DIR,
@@ -104,6 +117,20 @@ import {
104
117
  buildPayloadFromError,
105
118
  shouldInjectOverlay,
106
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";
107
134
 
108
135
  export interface RateLimitOptions {
109
136
  windowMs?: number;
@@ -504,6 +531,64 @@ export interface ServerOptions {
504
531
  * `secureMiddleware`, `rateLimitMiddleware`).
505
532
  */
506
533
  middleware?: Middleware[];
534
+ /**
535
+ * Phase 18.κ — tRPC-like typed RPC endpoints.
536
+ *
537
+ * Keys map to `/api/rpc/<name>/<method>` routes. Each value is a
538
+ * `defineRpc()` result (see `@mandujs/core/contract/rpc`). Populating
539
+ * this field at `startServer()` time registers every endpoint with
540
+ * the global RPC registry; the dispatcher runs BEFORE β's route
541
+ * matcher so RPC routes never collide with file-system API routes.
542
+ *
543
+ * Typically threaded from `ManduConfig.rpc.endpoints`.
544
+ */
545
+ rpc?: {
546
+ endpoints?: Record<string, import("../contract/rpc").RpcDefinition<import("../contract/rpc").RpcProcedureRecord>>;
547
+ };
548
+ /**
549
+ * Phase 18.λ — declarative cron scheduler.
550
+ *
551
+ * When `jobs` is non-empty and `disabled !== true`, `startServer()`
552
+ * instantiates a `CronRegistration` via `defineCron(jobs)`, calls
553
+ * `.start()` after the HTTP listener is bound, and wires the handle
554
+ * into `stop()` so the returned `ManduServer.stop()` also drains any
555
+ * in-flight cron tick before returning. Jobs whose `runOn` omits
556
+ * `"bun"` are registered but never fire on the local Bun host — they
557
+ * still appear in `status()` so dashboards can render their existence.
558
+ *
559
+ * Typically threaded from `ManduConfig.scheduler`.
560
+ */
561
+ scheduler?: {
562
+ jobs?: import("../scheduler").CronDef[];
563
+ disabled?: boolean;
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;
507
592
  }
508
593
 
509
594
  export interface ManduServer {
@@ -692,6 +777,19 @@ export interface ServerRegistrySettings {
692
777
  * `ServerOptions.middleware` at {@link startServer} time.
693
778
  */
694
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;
695
793
  }
696
794
 
697
795
  export class ServerRegistry {
@@ -1565,6 +1663,15 @@ async function handleRequestWithTracing(
1565
1663
  });
1566
1664
  }
1567
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
+ }
1568
1675
  return errorResponse;
1569
1676
  }
1570
1677
 
@@ -1598,6 +1705,28 @@ async function handleRequestWithTracing(
1598
1705
  }
1599
1706
  }
1600
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
+
1601
1730
  return result.value;
1602
1731
  }
1603
1732
 
@@ -1864,6 +1993,7 @@ async function loadPageData(
1864
1993
  // Filling의 loader 실행
1865
1994
  if (registration.filling?.hasLoader()) {
1866
1995
  const ctx = new ManduContext(req, params);
1996
+ attachI18nToContext(ctx, req);
1867
1997
  // DX-3: loader may return OR throw a redirect Response. Both are
1868
1998
  // short-circuits — if we detect one, skip SSR and hand the Response
1869
1999
  // to the caller with pending cookies merged in.
@@ -1984,6 +2114,7 @@ async function loadPageData(
1984
2114
  }
1985
2115
  if (filling?.hasLoader?.()) {
1986
2116
  const ctx = new ManduContext(req, params);
2117
+ attachI18nToContext(ctx, req);
1987
2118
  // DX-3 / Phase 6.3: same redirect + notFound handling as the
1988
2119
  // PageHandler path above. notFound is checked first so both
1989
2120
  // short-circuits remain symmetric.
@@ -2153,6 +2284,7 @@ async function loadLayoutData(
2153
2284
  const filling = exported as ManduFilling;
2154
2285
  if (filling.hasLoader()) {
2155
2286
  const ctx = new ManduContext(req, params);
2287
+ attachI18nToContext(ctx, req);
2156
2288
  const data = await filling.executeLoader(ctx);
2157
2289
  // DX-3: layout loaders are NOT allowed to redirect. They share
2158
2290
  // the pipeline with a page loader and we can only honor one
@@ -2386,15 +2518,27 @@ async function renderPageSSR(
2386
2518
  ? route.streaming
2387
2519
  : settings.streaming;
2388
2520
 
2389
- // Issue #198 — Pre-resolve async server components before handing off
2390
- // to React's SSR engines. `renderToString` (non-streaming path) does
2391
- // not support async components; `renderToReadableStream` does, but
2392
- // the shell-gen step in streaming-ssr also falls through
2393
- // `collectStreamingHeadTags` → `renderToString`. Resolving up-front
2394
- // gives consistent, synchronous trees to both paths and keeps the
2395
- // user-visible contract (`export default async function Page() {...}`
2396
- // and `export default async function Layout() {...}`) working end to end.
2397
- 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
+ }
2398
2542
 
2399
2543
  if (useStreaming) {
2400
2544
  const streamingResponse = await renderStreamingResponse(app, {
@@ -2623,6 +2767,7 @@ async function renderNotFoundPage(
2623
2767
  let loaderData: unknown = { message };
2624
2768
  if (registration.filling?.hasLoader()) {
2625
2769
  const ctx = new ManduContext(req, params);
2770
+ attachI18nToContext(ctx, req);
2626
2771
  try {
2627
2772
  const returned = await registration.filling.executeLoader(ctx);
2628
2773
  loaderData = returned !== undefined ? returned : { message };
@@ -3199,6 +3344,66 @@ async function tryServePrerendered(
3199
3344
  return new Response(body, { status: 200, headers });
3200
3345
  }
3201
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
+
3202
3407
  async function handleRequestInternal(
3203
3408
  req: Request,
3204
3409
  router: Router,
@@ -3228,6 +3433,92 @@ async function handleRequestInternal(
3228
3433
  return ok(prerendered);
3229
3434
  }
3230
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
+
3231
3522
  // 1. 정적 파일 서빙 시도 (최우선)
3232
3523
  // Edge runtimes (Cloudflare Workers, etc.) have no filesystem — skip and
3233
3524
  // let the platform's asset pipeline (Wrangler [assets], Vercel _static, …)
@@ -3325,6 +3616,47 @@ async function handleRequestInternal(
3325
3616
  }
3326
3617
  // ─── End Phase 18.ε ──────────────────────────────────────────────────────
3327
3618
 
3619
+ // ─── Phase 18.κ — typed RPC dispatch ──────────────────────────────────────
3620
+ // Runs AFTER γ's prerendered pass-through (handled earlier at step 0.5),
3621
+ // AFTER ζ's ISR/SWR cache check (per-route, inside handlePageRoute), and
3622
+ // BEFORE β's file-system route dispatch below. The canonical URL shape is
3623
+ //
3624
+ // POST /api/rpc/<endpoint>/<method>
3625
+ //
3626
+ // with JSON body `{ input: <value> }`. The dispatcher:
3627
+ // 1. matches `/api/rpc/<name>/<method>` via `matchRpcPath()` (returns
3628
+ // `null` for any other path — then we fall through to β),
3629
+ // 2. looks up the registered `RpcDefinition` in the module-level
3630
+ // `rpcRegistry` (populated by `registerRpc` at boot from
3631
+ // `ServerOptions.rpc.endpoints`),
3632
+ // 3. validates the request body against `procedure.input` (Zod),
3633
+ // invokes `procedure.handler`, validates the return value against
3634
+ // `procedure.output` (Zod), and ships a
3635
+ // `{ ok: true, data } | { ok: false, error }` JSON envelope.
3636
+ //
3637
+ // All failure paths return structured envelopes — never throws — so the
3638
+ // outer request handler's 5xx catch is unreachable on the happy path.
3639
+ // See `packages/core/src/contract/rpc.ts` and
3640
+ // `docs/architect/typed-rpc.md`.
3641
+ {
3642
+ const rpcMatch = matchRpcPath(pathname);
3643
+ if (rpcMatch) {
3644
+ const rpcResponse = await dispatchRpc(
3645
+ req,
3646
+ rpcMatch.endpoint,
3647
+ rpcMatch.method,
3648
+ { isDev: settings.isDev }
3649
+ );
3650
+ if (settings.cors && isCorsRequest(req)) {
3651
+ const corsOptions: CorsOptions =
3652
+ typeof settings.cors === "object" ? settings.cors : {};
3653
+ return ok(applyCorsToResponse(rpcResponse, req, corsOptions));
3654
+ }
3655
+ return ok(rpcResponse);
3656
+ }
3657
+ }
3658
+ // ─── End Phase 18.κ ───────────────────────────────────────────────────────
3659
+
3328
3660
  // 3. 라우트 매칭
3329
3661
  const match = router.match(pathname);
3330
3662
  if (!match) {
@@ -3340,6 +3672,7 @@ async function handleRequestInternal(
3340
3672
  let loaderData: unknown = { message: "Not Found" };
3341
3673
  if (registration.filling?.hasLoader()) {
3342
3674
  const ctx = new ManduContext(req, {});
3675
+ attachI18nToContext(ctx, req);
3343
3676
  try {
3344
3677
  const returned = await registration.filling.executeLoader(ctx);
3345
3678
  loaderData = returned !== undefined ? returned : { message: "Not Found" };
@@ -3523,8 +3856,28 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3523
3856
  observability: observabilityOption,
3524
3857
  prerender: prerenderOption,
3525
3858
  middleware: middlewareOption,
3859
+ rpc: rpcOption,
3860
+ scheduler: schedulerOption,
3861
+ i18n: i18nOption,
3862
+ messages: messagesOption,
3526
3863
  } = options;
3527
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
+
3528
3881
  // Phase 18.ε — build the request-level middleware chain once at boot.
3529
3882
  // `compose()` returns a passthrough when the list is empty; storing
3530
3883
  // `undefined` for "no middleware" keeps the hot path branch-free.
@@ -3605,10 +3958,28 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3605
3958
  tracer: tracerInstance.enabled ? tracerInstance : undefined,
3606
3959
  prerender: prerenderSettings,
3607
3960
  middlewareChain,
3961
+ i18n: i18nOption,
3962
+ messages: messagesOption,
3608
3963
  };
3609
3964
 
3610
3965
  registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
3611
3966
 
3967
+ // ─── Phase 18.κ — register RPC endpoints from options ──────────────────
3968
+ // The RPC registry is module-scoped (shared across all server
3969
+ // instances in this process). Clearing first keeps repeated
3970
+ // `startServer()` calls in tests deterministic — otherwise a stale
3971
+ // endpoint from a prior run could answer a later instance's requests.
3972
+ //
3973
+ // This runs once at boot; HMR-time re-registration goes through the
3974
+ // exported `registerRpc()` from `@mandujs/core/contract/rpc`.
3975
+ clearRpcRegistry();
3976
+ if (rpcOption?.endpoints) {
3977
+ for (const [name, definition] of Object.entries(rpcOption.endpoints)) {
3978
+ registerRpc(name, definition);
3979
+ }
3980
+ }
3981
+ // ─── End Phase 18.κ ────────────────────────────────────────────────────
3982
+
3612
3983
  // ─── Phase 18.ζ — ISR/SWR 캐시 초기화 ──────────────────────────────────
3613
3984
  // `cacheOption` 는 `true` | `false` | `CacheStore` | `CacheConfig` 를 받는다:
3614
3985
  // - `true` → MemoryCacheStore(1000) 를 기본값으로 생성.
@@ -3788,12 +4159,64 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3788
4159
  }
3789
4160
  }
3790
4161
 
4162
+ // ─── Phase 18.λ — declarative cron scheduler ────────────────────────────
4163
+ // Boot the scheduler AFTER the HTTP listener is live so a malformed cron
4164
+ // expression (caught by validateCronExpression) surfaces alongside the
4165
+ // other boot errors rather than aborting the server. `startServer` is
4166
+ // synchronous by contract, so we use the already-imported scheduler
4167
+ // module rather than `await import`.
4168
+ let schedulerRegistration: import("../scheduler").CronRegistration | null = null;
4169
+ const jobDefs = schedulerOption?.jobs ?? [];
4170
+ const schedulerDisabled = schedulerOption?.disabled === true;
4171
+ if (jobDefs.length > 0 && !schedulerDisabled) {
4172
+ try {
4173
+ schedulerRegistration = schedulerDefineCron(jobDefs);
4174
+ schedulerRegistration.start();
4175
+ setActiveSchedulerRegistration(schedulerRegistration);
4176
+ const bunJobCount = Object.keys(schedulerRegistration.status()).filter(
4177
+ (name) => {
4178
+ const def = jobDefs.find((j) => j.name === name);
4179
+ const runOn = def?.runOn && def.runOn.length > 0 ? def.runOn : ["bun", "workers"];
4180
+ return runOn.includes("bun");
4181
+ },
4182
+ ).length;
4183
+ console.log(
4184
+ `⏰ Scheduler: ${bunJobCount} cron job(s) registered on Bun runtime` +
4185
+ (jobDefs.length !== bunJobCount
4186
+ ? ` (${jobDefs.length - bunJobCount} workers-only — see wrangler.toml)`
4187
+ : ""),
4188
+ );
4189
+ } catch (err) {
4190
+ // Scheduler failures MUST NOT crash the server — a bad cron string is
4191
+ // a developer error, but the HTTP surface should keep serving. Log
4192
+ // loudly and leave the registration null.
4193
+ console.error(
4194
+ "❌ [scheduler] failed to start — HTTP server continues without cron jobs:",
4195
+ err instanceof Error ? err.message : err,
4196
+ );
4197
+ }
4198
+ }
4199
+
3791
4200
  return {
3792
4201
  server,
3793
4202
  router,
3794
4203
  registry,
3795
4204
  stop: () => {
3796
4205
  registry.kitchen?.stop();
4206
+ // Fire-and-forget the async scheduler drain so `stop()` stays
4207
+ // synchronous for backwards compatibility with existing consumers.
4208
+ // Tests that need to await drain can reach for `registration.stop()`
4209
+ // directly; `server.stop()` triggers shutdown but doesn't block on
4210
+ // in-flight cron handler completion here.
4211
+ if (schedulerRegistration) {
4212
+ const reg = schedulerRegistration;
4213
+ schedulerRegistration = null;
4214
+ void reg.stop()
4215
+ .then(() => setActiveSchedulerRegistration(null))
4216
+ .catch((err) => {
4217
+ console.error("[scheduler] shutdown error:", err);
4218
+ });
4219
+ }
3797
4220
  server.stop();
3798
4221
  },
3799
4222
  };
@@ -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