@mandujs/core 0.31.0 → 0.33.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.
Files changed (41) hide show
  1. package/package.json +7 -1
  2. package/src/bundler/build.ts +29 -1
  3. package/src/bundler/generate-static-params.ts +302 -290
  4. package/src/bundler/prerender.ts +446 -368
  5. package/src/bundler/types.ts +20 -0
  6. package/src/config/mandu.ts +58 -1
  7. package/src/config/validate.ts +119 -1
  8. package/src/diagnose/__tests__/checks.test.ts +378 -0
  9. package/src/diagnose/checks.ts +599 -0
  10. package/src/diagnose/index.ts +15 -0
  11. package/src/diagnose/run.ts +87 -0
  12. package/src/diagnose/types.ts +53 -0
  13. package/src/filling/context.ts +60 -0
  14. package/src/guard/check.ts +225 -1
  15. package/src/guard/define-rule.ts +243 -0
  16. package/src/guard/graph.ts +898 -0
  17. package/src/guard/index.ts +40 -0
  18. package/src/guard/rule-presets.ts +379 -0
  19. package/src/i18n/define.ts +126 -0
  20. package/src/i18n/index.ts +52 -0
  21. package/src/i18n/locale-resolver.ts +214 -0
  22. package/src/i18n/message-registry.ts +173 -0
  23. package/src/i18n/types.ts +112 -0
  24. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
  25. package/src/plugins/__tests__/runner.test.ts +409 -0
  26. package/src/plugins/define.ts +124 -0
  27. package/src/plugins/examples/dep-check-plugin.ts +80 -0
  28. package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
  29. package/src/plugins/examples/sitemap-plugin.ts +65 -0
  30. package/src/plugins/hooks.ts +297 -64
  31. package/src/plugins/index.ts +80 -41
  32. package/src/plugins/runner.ts +361 -0
  33. package/src/router/fs-routes.ts +64 -1
  34. package/src/router/fs-scanner.ts +101 -0
  35. package/src/router/index.ts +7 -1
  36. package/src/runtime/server.ts +409 -10
  37. package/src/runtime/ssr.ts +9 -0
  38. package/src/spec/schema.ts +25 -0
  39. package/src/testing/__tests__/reporter.test.ts +454 -0
  40. package/src/testing/index.ts +29 -0
  41. package/src/testing/reporter.ts +676 -0
@@ -1,5 +1,5 @@
1
1
  import type { Server } from "bun";
2
- import type { RoutesManifest, RouteSpec, HydrationConfig } from "../spec/schema";
2
+ import type { RoutesManifest, RouteSpec, HydrationConfig, StaticParamSetSchema } from "../spec/schema";
3
3
  import type { BundleManifest } from "../bundler/types";
4
4
  import type { ManduFilling, RenderMode } from "../filling/filling";
5
5
  import { ManduContext, CookieManager } from "../filling/context";
@@ -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;
@@ -517,6 +531,14 @@ export interface ServerOptions {
517
531
  * `secureMiddleware`, `rateLimitMiddleware`).
518
532
  */
519
533
  middleware?: Middleware[];
534
+ /**
535
+ * Phase 18.τ — plugins contributing `defineMiddlewareChain()` +
536
+ * lifecycle observers. Plugin middleware are PREPENDED to
537
+ * `options.middleware` so they run BEFORE user-declared layers. Both
538
+ * fields are optional; omission is a zero-overhead passthrough.
539
+ */
540
+ plugins?: readonly import("../plugins/hooks").ManduPlugin[];
541
+ configHooks?: Partial<import("../plugins/hooks").ManduHooks>;
520
542
  /**
521
543
  * Phase 18.κ — tRPC-like typed RPC endpoints.
522
544
  *
@@ -548,6 +570,33 @@ export interface ServerOptions {
548
570
  jobs?: import("../scheduler").CronDef[];
549
571
  disabled?: boolean;
550
572
  };
573
+ /**
574
+ * Phase 18.μ — first-class i18n. Threaded from `ManduConfig.i18n`.
575
+ *
576
+ * When populated, the runtime dispatcher:
577
+ * 1. resolves the active locale BEFORE route dispatch (via
578
+ * {@link resolveLocale}) using the configured strategy;
579
+ * 2. attaches `ctx.locale` (ResolvedLocale) + `ctx.t` (typed
580
+ * translator) to every loader / handler invocation;
581
+ * 3. stamps `Vary: Accept-Language` + `Content-Language` on all
582
+ * page responses so upstream caches key on locale;
583
+ * 4. when `strategy === "path-prefix"` and the incoming URL
584
+ * carries no locale prefix AND no override cookie, redirects
585
+ * `/` → `/<defaultLocale>` so browsers reach a locale-scoped
586
+ * URL (Next.js parity).
587
+ *
588
+ * Omitting this block keeps the runtime branch-free — the hot path
589
+ * incurs zero overhead when i18n is disabled.
590
+ */
591
+ i18n?: I18nDefinition;
592
+ /**
593
+ * Phase 18.μ — optional message registry bound to `ctx.t`. Created via
594
+ * `defineMessages({ en: {...}, ko: {...} })`. When omitted but `i18n`
595
+ * is set, `ctx.locale` is populated but `ctx.t` stays `undefined` —
596
+ * projects that manage their own translation layer (e.g. react-intl)
597
+ * use `ctx.locale.code` and ignore `ctx.t`.
598
+ */
599
+ messages?: MessageRegistry;
551
600
  }
552
601
 
553
602
  export interface ManduServer {
@@ -736,6 +785,19 @@ export interface ServerRegistrySettings {
736
785
  * `ServerOptions.middleware` at {@link startServer} time.
737
786
  */
738
787
  middlewareChain?: ComposedHandler;
788
+ /**
789
+ * Phase 18.μ — resolved i18n definition. `undefined` means "no i18n
790
+ * configured at boot" (hot path is branch-free). Wired from
791
+ * `ServerOptions.i18n`.
792
+ */
793
+ i18n?: I18nDefinition;
794
+ /**
795
+ * Phase 18.μ — message registry bound to {@link i18n}. When both are
796
+ * set, the runtime builds a per-request `ctx.t` via
797
+ * `createTranslator()`. When only `i18n` is set, `ctx.t` stays
798
+ * `undefined` and user code is responsible for its own translations.
799
+ */
800
+ messages?: MessageRegistry;
739
801
  }
740
802
 
741
803
  export class ServerRegistry {
@@ -1609,6 +1671,15 @@ async function handleRequestWithTracing(
1609
1671
  });
1610
1672
  }
1611
1673
  }
1674
+ // Phase 18.μ — stamp locale hint on error responses too.
1675
+ const errI18nState = i18nRequestState.get(req);
1676
+ if (errI18nState && !errorResponse.headers.has("Content-Language")) {
1677
+ try {
1678
+ errorResponse.headers.set("Content-Language", errI18nState.locale.code);
1679
+ } catch {
1680
+ // Some adapters freeze headers on error responses — ignore.
1681
+ }
1682
+ }
1612
1683
  return errorResponse;
1613
1684
  }
1614
1685
 
@@ -1642,6 +1713,28 @@ async function handleRequestWithTracing(
1642
1713
  }
1643
1714
  }
1644
1715
 
1716
+ // Phase 18.μ — stamp locale-sensitive caching hints onto the final
1717
+ // response. `Vary: Accept-Language, Cookie` ensures upstream caches
1718
+ // key correctly per locale signal; `Content-Language` surfaces the
1719
+ // resolved locale for SEO / screen-reader parity. Only stamped when
1720
+ // i18n is configured AND the request was actually resolved — the hot
1721
+ // path is branch-free when `i18n` is absent.
1722
+ const i18nState = i18nRequestState.get(req);
1723
+ if (i18nState) {
1724
+ const existingVary = result.value.headers.get("Vary");
1725
+ const varyParts = new Set(
1726
+ (existingVary ? existingVary.split(",") : [])
1727
+ .map((s) => s.trim())
1728
+ .filter(Boolean)
1729
+ );
1730
+ varyParts.add("Accept-Language");
1731
+ varyParts.add("Cookie");
1732
+ result.value.headers.set("Vary", [...varyParts].join(", "));
1733
+ if (!result.value.headers.has("Content-Language")) {
1734
+ result.value.headers.set("Content-Language", i18nState.locale.code);
1735
+ }
1736
+ }
1737
+
1645
1738
  return result.value;
1646
1739
  }
1647
1740
 
@@ -1908,6 +2001,7 @@ async function loadPageData(
1908
2001
  // Filling의 loader 실행
1909
2002
  if (registration.filling?.hasLoader()) {
1910
2003
  const ctx = new ManduContext(req, params);
2004
+ attachI18nToContext(ctx, req);
1911
2005
  // DX-3: loader may return OR throw a redirect Response. Both are
1912
2006
  // short-circuits — if we detect one, skip SSR and hand the Response
1913
2007
  // to the caller with pending cookies merged in.
@@ -2028,6 +2122,7 @@ async function loadPageData(
2028
2122
  }
2029
2123
  if (filling?.hasLoader?.()) {
2030
2124
  const ctx = new ManduContext(req, params);
2125
+ attachI18nToContext(ctx, req);
2031
2126
  // DX-3 / Phase 6.3: same redirect + notFound handling as the
2032
2127
  // PageHandler path above. notFound is checked first so both
2033
2128
  // short-circuits remain symmetric.
@@ -2197,6 +2292,7 @@ async function loadLayoutData(
2197
2292
  const filling = exported as ManduFilling;
2198
2293
  if (filling.hasLoader()) {
2199
2294
  const ctx = new ManduContext(req, params);
2295
+ attachI18nToContext(ctx, req);
2200
2296
  const data = await filling.executeLoader(ctx);
2201
2297
  // DX-3: layout loaders are NOT allowed to redirect. They share
2202
2298
  // the pipeline with a page loader and we can only honor one
@@ -2430,15 +2526,27 @@ async function renderPageSSR(
2430
2526
  ? route.streaming
2431
2527
  : settings.streaming;
2432
2528
 
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;
2529
+ // Issue #198 / Phase 18.ξ Async server component resolution policy.
2530
+ //
2531
+ // Non-streaming path (`renderToString`) cannot handle async components,
2532
+ // so we MUST pre-resolve the tree up-front.
2533
+ //
2534
+ // Streaming path (`renderToReadableStream`, React 19) supports async
2535
+ // components natively and is designed around progressive flushing. If
2536
+ // we pre-resolve here, the caller blocks until every async component
2537
+ // settles before the shell can be emitted — defeating the entire point
2538
+ // of streaming (`TTFB` regresses from shell-ready to slowest-component).
2539
+ // So in streaming mode we hand React the raw async tree. Head tags
2540
+ // pushed from async components are captured by `renderToStream`'s
2541
+ // `buildHtmlTail` (late-head injection) via `use-head`. The streaming
2542
+ // shell-gen's `collectStreamingHeadTags` pre-pass uses `renderToString`
2543
+ // internally and will throw on async trees — its try/catch already
2544
+ // handles that case and returns an empty string, so the early shell
2545
+ // emission is still correct; the late-head script fills in any
2546
+ // metadata emitted during the async render.
2547
+ if (!useStreaming) {
2548
+ app = (await resolveAsyncElement(app)) as React.ReactElement;
2549
+ }
2442
2550
 
2443
2551
  if (useStreaming) {
2444
2552
  const streamingResponse = await renderStreamingResponse(app, {
@@ -2667,6 +2775,7 @@ async function renderNotFoundPage(
2667
2775
  let loaderData: unknown = { message };
2668
2776
  if (registration.filling?.hasLoader()) {
2669
2777
  const ctx = new ManduContext(req, params);
2778
+ attachI18nToContext(ctx, req);
2670
2779
  try {
2671
2780
  const returned = await registration.filling.executeLoader(ctx);
2672
2781
  loaderData = returned !== undefined ? returned : { message };
@@ -3243,6 +3352,117 @@ async function tryServePrerendered(
3243
3352
  return new Response(body, { status: 200, headers });
3244
3353
  }
3245
3354
 
3355
+ // ─── Phase 18.μ — request-scoped i18n state ──────────────────────────────
3356
+ /**
3357
+ * Per-request locale state keyed by the `Request` object. The runtime
3358
+ * dispatcher populates this right before `handleRequestInternal` returns
3359
+ * to route-specific paths; `handlePageRoute` / `handleApiRoute` read it
3360
+ * when creating `ManduContext` and attach via `ctx._setI18n(...)`.
3361
+ *
3362
+ * A WeakMap is used so completed requests release their entries
3363
+ * automatically — no explicit cleanup needed on the hot path.
3364
+ *
3365
+ * @internal
3366
+ */
3367
+ const i18nRequestState = new WeakMap<
3368
+ Request,
3369
+ { locale: ResolvedLocale; translator?: Translator }
3370
+ >();
3371
+
3372
+ /**
3373
+ * Phase 18.μ — expose the stashed locale state for `filling` /
3374
+ * `handlePageRoute` / `handleApiRoute`. Returns `undefined` when i18n
3375
+ * is disabled OR the request predates dispatch (internal callers).
3376
+ *
3377
+ * @internal
3378
+ */
3379
+ export function getRequestI18n(
3380
+ req: Request
3381
+ ): { locale: ResolvedLocale; translator?: Translator } | undefined {
3382
+ return i18nRequestState.get(req);
3383
+ }
3384
+
3385
+ /**
3386
+ * Phase 18.μ — attach i18n state to a freshly-constructed `ManduContext`.
3387
+ * No-op when i18n is disabled. Called by every callsite that instantiates
3388
+ * `new ManduContext(req, …)` so `ctx.locale` + `ctx.t` are populated
3389
+ * before loader / handler execution.
3390
+ *
3391
+ * @internal
3392
+ */
3393
+ function attachI18nToContext(ctx: ManduContext, req: Request): void {
3394
+ const state = i18nRequestState.get(req);
3395
+ if (state) {
3396
+ ctx._setI18n(state.locale, state.translator);
3397
+ }
3398
+ }
3399
+
3400
+ /**
3401
+ * Helper: returns the URL's first segment if it matches a known locale.
3402
+ * Extracted so the inline μ dispatch block stays readable.
3403
+ */
3404
+ function stripLocaleForRedirectCheck(
3405
+ pathname: string,
3406
+ locales: readonly string[]
3407
+ ): string | undefined {
3408
+ if (pathname.length < 2) return undefined;
3409
+ const slashIdx = pathname.indexOf("/", 1);
3410
+ const first = slashIdx === -1 ? pathname.slice(1) : pathname.slice(1, slashIdx);
3411
+ return locales.includes(first) ? first : undefined;
3412
+ }
3413
+ // ─── End Phase 18.μ ──────────────────────────────────────────────────────
3414
+
3415
+ // ─── Issue #214 — dynamicParams guard helper ────────────────────────────────
3416
+ /**
3417
+ * True when the incoming `params` from the router matches one of the
3418
+ * enumerated sets in `staticParams` (populated at build time from
3419
+ * `generateStaticParams`). Used by the runtime #214 guard to decide
3420
+ * whether a `dynamicParams: false` page must 404.
3421
+ *
3422
+ * Matching rules mirror `bundler/generate-static-params.ts`:
3423
+ * - Scalar segments compare as exact strings.
3424
+ * - Catch-all segments compare as slash-joined strings (the router
3425
+ * always materializes wildcards as a single string, whereas
3426
+ * `generateStaticParams` emits `string[]` — we normalize both
3427
+ * sides to the joined form for equality).
3428
+ *
3429
+ * When `staticParams` is undefined or empty, no URL can match — the
3430
+ * route effectively becomes "no dynamic URLs at all", which is the
3431
+ * documented behavior of `dynamicParams: false` + `generateStaticParams: []`.
3432
+ */
3433
+ function paramsInStaticSet(
3434
+ params: Record<string, string>,
3435
+ staticParams: StaticParamSetSchema[] | undefined
3436
+ ): boolean {
3437
+ if (!staticParams || staticParams.length === 0) return false;
3438
+ const paramKeys = Object.keys(params);
3439
+ for (const entry of staticParams) {
3440
+ if (!entry) continue;
3441
+ let allMatch = true;
3442
+ for (const key of paramKeys) {
3443
+ const requestValue = params[key];
3444
+ const declared = (entry as Record<string, string | string[] | undefined>)[key];
3445
+ if (declared === undefined) {
3446
+ // Optional catch-all that wasn't declared — router gives empty
3447
+ // string in that case; anything else is a miss.
3448
+ if (requestValue !== "") {
3449
+ allMatch = false;
3450
+ break;
3451
+ }
3452
+ continue;
3453
+ }
3454
+ const declaredJoined = Array.isArray(declared) ? declared.join("/") : declared;
3455
+ if (declaredJoined !== requestValue) {
3456
+ allMatch = false;
3457
+ break;
3458
+ }
3459
+ }
3460
+ if (allMatch) return true;
3461
+ }
3462
+ return false;
3463
+ }
3464
+ // ─── End Issue #214 ─────────────────────────────────────────────────────────
3465
+
3246
3466
  async function handleRequestInternal(
3247
3467
  req: Request,
3248
3468
  router: Router,
@@ -3272,6 +3492,92 @@ async function handleRequestInternal(
3272
3492
  return ok(prerendered);
3273
3493
  }
3274
3494
 
3495
+ // ─── Phase 18.μ — i18n dispatch ─────────────────────────────────────────
3496
+ // Runs AFTER γ's prerendered check (static HTML per-locale is already
3497
+ // handled by path-prefix synthesis at build time) and BEFORE ζ's cache
3498
+ // lookup (cache keys include the resolved locale via `Vary:
3499
+ // Accept-Language`).
3500
+ //
3501
+ // 1. Resolve the active locale via `resolveLocale()` — the strategy
3502
+ // switches determines URL / cookie / header precedence.
3503
+ // 2. For `strategy: 'path-prefix'`: when the incoming URL carries no
3504
+ // locale prefix (root or locale-less path) AND the resolution
3505
+ // came from `default` / `fallback`, we emit a 307 redirect to
3506
+ // `/<locale><rest>` so the user lands on a locale-scoped URL.
3507
+ // Cookie / header signals "win" over the default — no redirect
3508
+ // when the user explicitly preferred a non-default locale.
3509
+ // 3. Stash the resolved locale on the `registry.__perRequest` map
3510
+ // indexed by the request so downstream `handlePageRoute` /
3511
+ // `handleApiRoute` can mount it onto `ctx` via `_setI18n()`.
3512
+ //
3513
+ // Zero overhead when `settings.i18n` is undefined — the `if` falls
3514
+ // through and the hot path runs exactly as Phase 18.λ's baseline.
3515
+ let resolvedLocaleForRequest: ResolvedLocale | undefined;
3516
+ let translatorForRequest: Translator | undefined;
3517
+ if (settings.i18n) {
3518
+ resolvedLocaleForRequest = resolveLocale(req, settings.i18n);
3519
+
3520
+ // Path-prefix strategy: URL has no locale prefix AND resolver picked
3521
+ // a non-default locale from cookie/header → redirect to prefixed URL
3522
+ // so the user lands on a locale-scoped URL (Next.js parity).
3523
+ //
3524
+ // Excluded paths (never redirected, always locale-neutral):
3525
+ // - `/api/*` — API routes use `Accept-Language` header; JSON
3526
+ // clients rarely want an HTML redirect.
3527
+ // - `/_mandu/*`, `/.mandu/*` — framework internals.
3528
+ // - `/sitemap.xml`, `/robots.txt`, etc. — metadata routes live
3529
+ // at site root across all locales.
3530
+ if (
3531
+ settings.i18n.strategy === "path-prefix" &&
3532
+ (req.method === "GET" || req.method === "HEAD") &&
3533
+ !pathname.startsWith("/api/") &&
3534
+ !pathname.startsWith("/_mandu/") &&
3535
+ !pathname.startsWith("/.mandu/") &&
3536
+ !pathname.startsWith("/__kitchen") &&
3537
+ !pathname.startsWith("/__mandu/") &&
3538
+ pathname !== "/sitemap.xml" &&
3539
+ pathname !== "/robots.txt" &&
3540
+ pathname !== "/llms.txt" &&
3541
+ pathname !== "/manifest.webmanifest"
3542
+ ) {
3543
+ const urlLocale = stripLocaleForRedirectCheck(pathname, settings.i18n.locales);
3544
+ if (!urlLocale && resolvedLocaleForRequest.code !== settings.i18n.defaultLocale) {
3545
+ const targetPath = `/${resolvedLocaleForRequest.code}${pathname === "/" ? "" : pathname}`;
3546
+ const targetUrl = new URL(targetPath + url.search, req.url);
3547
+ const redirectRes = new Response(null, {
3548
+ status: 307,
3549
+ headers: {
3550
+ Location: targetUrl.toString(),
3551
+ Vary: "Accept-Language, Cookie",
3552
+ "Content-Language": resolvedLocaleForRequest.code,
3553
+ },
3554
+ });
3555
+ return ok(redirectRes);
3556
+ }
3557
+ }
3558
+
3559
+ // Build translator if a registry is wired up.
3560
+ if (settings.messages) {
3561
+ translatorForRequest = createTranslator(settings.messages, {
3562
+ activeLocale: resolvedLocaleForRequest.code,
3563
+ defaultLocale: settings.i18n.defaultLocale,
3564
+ fallbackLocale: settings.i18n.fallback,
3565
+ });
3566
+ }
3567
+
3568
+ // Stash on request-scoped map so downstream context creation (inside
3569
+ // `handlePageRoute` / `handleApiRoute`) can pick them up without
3570
+ // threading extra args through every call site. `i18nRequestState`
3571
+ // is a WeakMap — no memory retention after the request completes.
3572
+ if (resolvedLocaleForRequest) {
3573
+ i18nRequestState.set(req, {
3574
+ locale: resolvedLocaleForRequest,
3575
+ translator: translatorForRequest,
3576
+ });
3577
+ }
3578
+ }
3579
+ // ─── End Phase 18.μ ─────────────────────────────────────────────────────
3580
+
3275
3581
  // 1. 정적 파일 서빙 시도 (최우선)
3276
3582
  // Edge runtimes (Cloudflare Workers, etc.) have no filesystem — skip and
3277
3583
  // let the platform's asset pipeline (Wrangler [assets], Vercel _static, …)
@@ -3425,6 +3731,7 @@ async function handleRequestInternal(
3425
3731
  let loaderData: unknown = { message: "Not Found" };
3426
3732
  if (registration.filling?.hasLoader()) {
3427
3733
  const ctx = new ManduContext(req, {});
3734
+ attachI18nToContext(ctx, req);
3428
3735
  try {
3429
3736
  const returned = await registration.filling.executeLoader(ctx);
3430
3737
  loaderData = returned !== undefined ? returned : { message: "Not Found" };
@@ -3459,6 +3766,64 @@ async function handleRequestInternal(
3459
3766
 
3460
3767
  const { route, params } = match;
3461
3768
 
3769
+ // ─── Issue #214 — dynamicParams guard ─────────────────────────────────────
3770
+ // Runs AFTER γ's prerendered pass-through (step 0.5) and BEFORE ζ's
3771
+ // per-route ISR cache dispatch (which lives inside `handlePageRoute`).
3772
+ //
3773
+ // Contract (Next.js parity):
3774
+ // - Page route opted into `dynamicParams: false` AND has `staticParams`
3775
+ // populated from `generateStaticParams` at build time → the incoming
3776
+ // params MUST match one of the known sets. Otherwise: 404.
3777
+ // - `dynamicParams: true` (or undefined) → default behavior unchanged.
3778
+ // Any dynamic URL falls through to SSR just like before.
3779
+ // - API + metadata routes are never gated — `dynamicParams` is
3780
+ // page-only.
3781
+ //
3782
+ // The guard short-circuits with `renderNotFoundPage` so per-route
3783
+ // `not-found.tsx` / global `notFoundHandler` / built-in JSON 404 all
3784
+ // render correctly without recursing through SSR. Cookies are not
3785
+ // applied because no page loader has run yet — this check precedes
3786
+ // loader dispatch by design.
3787
+ if (
3788
+ route.kind === "page" &&
3789
+ (route as { dynamicParams?: boolean }).dynamicParams === false
3790
+ ) {
3791
+ const staticParams = (route as { staticParams?: StaticParamSetSchema[] })
3792
+ .staticParams;
3793
+ if (!paramsInStaticSet(params, staticParams)) {
3794
+ const pageRouteForNF = route as {
3795
+ id: string;
3796
+ pattern: string;
3797
+ layoutChain?: string[];
3798
+ hydration?: HydrationConfig;
3799
+ streaming?: boolean;
3800
+ notFoundModule?: string;
3801
+ };
3802
+ const nfResponse = await renderNotFoundPage(
3803
+ req,
3804
+ pageRouteForNF,
3805
+ params,
3806
+ registry,
3807
+ /* pageCookies */ undefined,
3808
+ /* layoutCookies */ undefined,
3809
+ /* layoutData */ undefined,
3810
+ new Response(
3811
+ JSON.stringify({
3812
+ message: `No static param match for ${pathname}`,
3813
+ }),
3814
+ { status: 404, headers: { "Content-Type": "application/json" } }
3815
+ )
3816
+ );
3817
+ if (settings.cors && isCorsRequest(req)) {
3818
+ const corsOptions: CorsOptions =
3819
+ typeof settings.cors === "object" ? settings.cors : {};
3820
+ return ok(applyCorsToResponse(nfResponse, req, corsOptions));
3821
+ }
3822
+ return ok(nfResponse);
3823
+ }
3824
+ }
3825
+ // ─── End Issue #214 ───────────────────────────────────────────────────────
3826
+
3462
3827
  // 3. 라우트 종류별 처리
3463
3828
  if (route.kind === "api") {
3464
3829
  const rateLimitOptions = settings.rateLimit;
@@ -3610,11 +3975,43 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3610
3975
  middleware: middlewareOption,
3611
3976
  rpc: rpcOption,
3612
3977
  scheduler: schedulerOption,
3978
+ i18n: i18nOption,
3979
+ messages: messagesOption,
3980
+ plugins: pluginsOption,
3981
+ configHooks: configHooksOption,
3613
3982
  } = options;
3614
3983
 
3984
+ // Phase 18.μ — validate i18n + messages shape. Both are branded via
3985
+ // `defineI18n()` / `defineMessages()`; reject any raw object so user
3986
+ // typos fail fast at boot instead of on first request.
3987
+ if (i18nOption !== undefined && !isI18nDefinition(i18nOption)) {
3988
+ throw new Error(
3989
+ "[mandu] ServerOptions.i18n must be the return value of defineI18n(). " +
3990
+ "Import { defineI18n } from '@mandujs/core/i18n'."
3991
+ );
3992
+ }
3993
+ if (messagesOption !== undefined && !isMessageRegistry(messagesOption)) {
3994
+ throw new Error(
3995
+ "[mandu] ServerOptions.messages must be the return value of defineMessages(). " +
3996
+ "Import { defineMessages } from '@mandujs/core/i18n'."
3997
+ );
3998
+ }
3999
+
3615
4000
  // Phase 18.ε — build the request-level middleware chain once at boot.
3616
4001
  // `compose()` returns a passthrough when the list is empty; storing
3617
4002
  // `undefined` for "no middleware" keeps the hot path branch-free.
4003
+ //
4004
+ // Phase 18.τ — plugin-contributed middleware via `defineMiddlewareChain()`
4005
+ // is resolved asynchronously OUTSIDE `startServer()` (which is sync).
4006
+ // Drivers call `resolvePluginMiddleware({ plugins, configHooks, rootDir,
4007
+ // mode })` and pass the resulting `Middleware[]` as a PREFIX of
4008
+ // `options.middleware` before calling `startServer()`.
4009
+ //
4010
+ // `pluginsOption` / `configHooksOption` are still carried here so that
4011
+ // lifecycle observers fired by drivers can reuse the same bundle; they
4012
+ // are NOT consulted for the middleware chain itself.
4013
+ void pluginsOption;
4014
+ void configHooksOption;
3618
4015
  const middlewareChain: ComposedHandler | undefined =
3619
4016
  middlewareOption && middlewareOption.length > 0
3620
4017
  ? composeMiddleware(...middlewareOption)
@@ -3692,6 +4089,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3692
4089
  tracer: tracerInstance.enabled ? tracerInstance : undefined,
3693
4090
  prerender: prerenderSettings,
3694
4091
  middlewareChain,
4092
+ i18n: i18nOption,
4093
+ messages: messagesOption,
3695
4094
  };
3696
4095
 
3697
4096
  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
 
@@ -80,6 +80,13 @@ const RouteSpecBase = {
80
80
  streaming: z.boolean().optional(),
81
81
  };
82
82
 
83
+ // ---- Static params (Issue #214) ----
84
+ // StaticParamSet values mirror `bundler/generate-static-params.ts` —
85
+ // scalar params are strings, catch-all params are `string[]`.
86
+ const StaticParamValue = z.union([z.string(), z.array(z.string())]);
87
+ const StaticParamSet = z.record(StaticParamValue);
88
+ export type StaticParamSetSchema = z.infer<typeof StaticParamSet>;
89
+
83
90
  // ---- Page 라우트 ----
84
91
  export const PageRouteSpec = z
85
92
  .object({
@@ -93,6 +100,20 @@ export const PageRouteSpec = z
93
100
  loadingModule: z.string().optional(),
94
101
  errorModule: z.string().optional(),
95
102
  notFoundModule: z.string().optional(),
103
+ /**
104
+ * Issue #214 — when `false`, the runtime rejects dynamic URLs
105
+ * whose params aren't in `staticParams` with a 404 instead of
106
+ * falling through to SSR. Undefined or `true` preserves the
107
+ * default "SSR on miss" behavior (Next.js parity).
108
+ */
109
+ dynamicParams: z.boolean().optional(),
110
+ /**
111
+ * Issue #214 — populated at build time from `generateStaticParams`.
112
+ * Consulted by the runtime #214 guard together with `dynamicParams`
113
+ * to decide whether an incoming param set is allowed. Scalar values
114
+ * are strings; catch-all values are string arrays.
115
+ */
116
+ staticParams: z.array(StaticParamSet).optional(),
96
117
  })
97
118
  .refine(
98
119
  (route) => {
@@ -159,6 +180,10 @@ export const RouteSpec = z.discriminatedUnion("kind", [
159
180
  loadingModule: z.string().optional(),
160
181
  errorModule: z.string().optional(),
161
182
  notFoundModule: z.string().optional(),
183
+ // Issue #214 — see PageRouteSpec for contract. Kept optional so
184
+ // existing manifests load unchanged (default behavior: dynamic SSR).
185
+ dynamicParams: z.boolean().optional(),
186
+ staticParams: z.array(StaticParamSet).optional(),
162
187
  }),
163
188
  z.object({
164
189
  ...RouteSpecBase,