@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.
- package/package.json +7 -1
- package/src/client/index.ts +11 -1
- package/src/client/rpc.ts +293 -140
- package/src/config/mandu.ts +107 -1
- package/src/config/validate.ts +145 -1
- package/src/contract/index.ts +18 -0
- package/src/contract/rpc.ts +443 -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/index.ts +26 -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/middleware/index.ts +7 -0
- package/src/middleware/scheduler-cron.ts +96 -0
- package/src/router/fs-scanner.ts +101 -0
- package/src/router/index.ts +7 -1
- package/src/runtime/server.ts +432 -9
- package/src/runtime/ssr.ts +9 -0
- package/src/scheduler/index.ts +547 -343
- package/src/scheduler/validate.ts +169 -0
package/src/runtime/server.ts
CHANGED
|
@@ -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 —
|
|
2390
|
-
//
|
|
2391
|
-
//
|
|
2392
|
-
//
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
//
|
|
2396
|
-
//
|
|
2397
|
-
|
|
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
|
};
|
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
|
|