@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.
- package/package.json +7 -1
- package/src/bundler/build.ts +29 -1
- package/src/bundler/generate-static-params.ts +302 -290
- package/src/bundler/prerender.ts +446 -368
- package/src/bundler/types.ts +20 -0
- package/src/config/mandu.ts +58 -1
- package/src/config/validate.ts +119 -1
- package/src/diagnose/__tests__/checks.test.ts +378 -0
- package/src/diagnose/checks.ts +599 -0
- package/src/diagnose/index.ts +15 -0
- package/src/diagnose/run.ts +87 -0
- package/src/diagnose/types.ts +53 -0
- package/src/filling/context.ts +60 -0
- package/src/guard/check.ts +225 -1
- package/src/guard/define-rule.ts +243 -0
- package/src/guard/graph.ts +898 -0
- package/src/guard/index.ts +40 -0
- package/src/guard/rule-presets.ts +379 -0
- package/src/i18n/define.ts +126 -0
- package/src/i18n/index.ts +52 -0
- package/src/i18n/locale-resolver.ts +214 -0
- package/src/i18n/message-registry.ts +173 -0
- package/src/i18n/types.ts +112 -0
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -0
- package/src/plugins/__tests__/runner.test.ts +409 -0
- package/src/plugins/define.ts +124 -0
- package/src/plugins/examples/dep-check-plugin.ts +80 -0
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -0
- package/src/plugins/examples/sitemap-plugin.ts +65 -0
- package/src/plugins/hooks.ts +297 -64
- package/src/plugins/index.ts +80 -41
- package/src/plugins/runner.ts +361 -0
- package/src/router/fs-routes.ts +64 -1
- package/src/router/fs-scanner.ts +101 -0
- package/src/router/index.ts +7 -1
- package/src/runtime/server.ts +409 -10
- package/src/runtime/ssr.ts +9 -0
- package/src/spec/schema.ts +25 -0
- package/src/testing/__tests__/reporter.test.ts +454 -0
- package/src/testing/index.ts +29 -0
- package/src/testing/reporter.ts +676 -0
package/src/runtime/server.ts
CHANGED
|
@@ -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 —
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
//
|
|
2437
|
-
//
|
|
2438
|
-
//
|
|
2439
|
-
//
|
|
2440
|
-
//
|
|
2441
|
-
|
|
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;
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -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
|
|
package/src/spec/schema.ts
CHANGED
|
@@ -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,
|