@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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18.μ — locale resolution.
|
|
3
|
+
*
|
|
4
|
+
* Given a request + i18n config, return the active {@link ResolvedLocale}.
|
|
5
|
+
* Resolution order depends on `config.strategy`:
|
|
6
|
+
*
|
|
7
|
+
* path-prefix → URL path segment → cookie → Accept-Language → default
|
|
8
|
+
* domain → Host header → cookie → Accept-Language → default
|
|
9
|
+
* header → Accept-Language → cookie → default
|
|
10
|
+
* cookie → Cookie → Accept-Language → default
|
|
11
|
+
*
|
|
12
|
+
* In every mode, an invalid locale signal (e.g. `/fr/docs` when `fr`
|
|
13
|
+
* isn't configured) falls through to the next signal — invalid inputs
|
|
14
|
+
* never short-circuit to an error. The final tier is always the
|
|
15
|
+
* configured `defaultLocale` or `fallback`.
|
|
16
|
+
*
|
|
17
|
+
* This module is pure; it never touches the server registry or
|
|
18
|
+
* `ctx.cookies`. Callers assemble the result and bolt it onto the
|
|
19
|
+
* request via `ManduContext` — see `runtime/server.ts` μ dispatch
|
|
20
|
+
* section.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { I18nDefinition, LocaleCode, ResolvedLocale } from "./types";
|
|
24
|
+
import { DEFAULT_I18N_COOKIE } from "./define";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Remove a locale prefix from a URL pathname. Returns `{ locale, rest }`
|
|
28
|
+
* when the pathname starts with `/<locale>(/...)?` for a known locale;
|
|
29
|
+
* otherwise `{ locale: undefined, rest: pathname }`.
|
|
30
|
+
*
|
|
31
|
+
* Handles trailing + empty slashes so `/en`, `/en/`, `/en/docs` all
|
|
32
|
+
* match `locale="en"` with the rest normalized to `"/"`, `"/"`, `"/docs"`.
|
|
33
|
+
*/
|
|
34
|
+
export function stripLocalePrefix(
|
|
35
|
+
pathname: string,
|
|
36
|
+
locales: readonly LocaleCode[]
|
|
37
|
+
): { locale: LocaleCode | undefined; rest: string } {
|
|
38
|
+
if (!pathname.startsWith("/")) {
|
|
39
|
+
return { locale: undefined, rest: pathname };
|
|
40
|
+
}
|
|
41
|
+
// Fast path: root.
|
|
42
|
+
if (pathname === "/") return { locale: undefined, rest: "/" };
|
|
43
|
+
const slashIdx = pathname.indexOf("/", 1);
|
|
44
|
+
const first = slashIdx === -1 ? pathname.slice(1) : pathname.slice(1, slashIdx);
|
|
45
|
+
if (!locales.includes(first)) {
|
|
46
|
+
return { locale: undefined, rest: pathname };
|
|
47
|
+
}
|
|
48
|
+
const rest = slashIdx === -1 ? "/" : pathname.slice(slashIdx);
|
|
49
|
+
return { locale: first, rest };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse the `Accept-Language` header and pick the best supported
|
|
54
|
+
* locale. Honours q-weights; falls back to undefined when nothing
|
|
55
|
+
* matches.
|
|
56
|
+
*
|
|
57
|
+
* Comparison is case-insensitive, and we accept exact-match only
|
|
58
|
+
* (no Accept-Language `en-US` → `en` widening — userland can list
|
|
59
|
+
* both in `config.locales` when they want fallback behaviour).
|
|
60
|
+
*/
|
|
61
|
+
export function parseAcceptLanguage(
|
|
62
|
+
header: string | null | undefined,
|
|
63
|
+
locales: readonly LocaleCode[]
|
|
64
|
+
): LocaleCode | undefined {
|
|
65
|
+
if (!header) return undefined;
|
|
66
|
+
const want = new Map<string, number>();
|
|
67
|
+
for (const piece of header.split(",")) {
|
|
68
|
+
const [tagRaw, ...params] = piece.trim().split(";");
|
|
69
|
+
if (!tagRaw) continue;
|
|
70
|
+
const tag = tagRaw.trim().toLowerCase();
|
|
71
|
+
if (!tag || tag === "*") continue;
|
|
72
|
+
let q = 1.0;
|
|
73
|
+
for (const p of params) {
|
|
74
|
+
const m = /^\s*q\s*=\s*([0-9.]+)\s*$/i.exec(p);
|
|
75
|
+
if (m) q = parseFloat(m[1]!);
|
|
76
|
+
}
|
|
77
|
+
if (Number.isFinite(q) && q > 0 && !want.has(tag)) {
|
|
78
|
+
want.set(tag, q);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Highest-q first; stable within equal weights.
|
|
82
|
+
const sorted = [...want.entries()].sort((a, b) => b[1] - a[1]);
|
|
83
|
+
const lowerLocales = locales.map((l) => ({ raw: l, lower: l.toLowerCase() }));
|
|
84
|
+
for (const [tag] of sorted) {
|
|
85
|
+
for (const { raw, lower } of lowerLocales) {
|
|
86
|
+
if (lower === tag) return raw;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Widening fallback: `zh-TW` → try `zh` if only `zh` is configured.
|
|
90
|
+
for (const [tag] of sorted) {
|
|
91
|
+
const short = tag.split("-")[0]!;
|
|
92
|
+
for (const { raw, lower } of lowerLocales) {
|
|
93
|
+
if (lower === short) return raw;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the locale cookie from the raw `Cookie` header. A nano-parser
|
|
101
|
+
* so the resolver stays dependency-free (the `CookieManager` in
|
|
102
|
+
* `filling/context.ts` lives on the request-wrapping side, not here).
|
|
103
|
+
*/
|
|
104
|
+
export function readLocaleCookie(
|
|
105
|
+
header: string | null | undefined,
|
|
106
|
+
cookieName: string,
|
|
107
|
+
locales: readonly LocaleCode[]
|
|
108
|
+
): LocaleCode | undefined {
|
|
109
|
+
if (!header) return undefined;
|
|
110
|
+
const target = `${cookieName}=`;
|
|
111
|
+
for (const piece of header.split(";")) {
|
|
112
|
+
const trimmed = piece.trim();
|
|
113
|
+
if (!trimmed.startsWith(target)) continue;
|
|
114
|
+
const raw = trimmed.slice(target.length);
|
|
115
|
+
try {
|
|
116
|
+
const value = decodeURIComponent(raw);
|
|
117
|
+
if (locales.includes(value)) return value;
|
|
118
|
+
} catch {
|
|
119
|
+
// fallthrough
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the locale for this request.
|
|
127
|
+
*
|
|
128
|
+
* The returned {@link ResolvedLocale} is always valid w.r.t.
|
|
129
|
+
* `config.locales` — when every signal misses, we fall back to
|
|
130
|
+
* `fallback` then `defaultLocale` (in that order) and flag the
|
|
131
|
+
* result with `strategy: "fallback"` / `"default"`.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveLocale(
|
|
134
|
+
request: Request,
|
|
135
|
+
config: I18nDefinition
|
|
136
|
+
): ResolvedLocale {
|
|
137
|
+
const url = new URL(request.url);
|
|
138
|
+
const cookieName = config.cookieName ?? DEFAULT_I18N_COOKIE;
|
|
139
|
+
const cookieHeader = request.headers.get("cookie");
|
|
140
|
+
const acceptLanguage = request.headers.get("accept-language");
|
|
141
|
+
|
|
142
|
+
switch (config.strategy) {
|
|
143
|
+
case "path-prefix": {
|
|
144
|
+
const { locale } = stripLocalePrefix(url.pathname, config.locales);
|
|
145
|
+
if (locale) {
|
|
146
|
+
return { code: locale, isDefault: false, strategy: "path-prefix", source: url.pathname };
|
|
147
|
+
}
|
|
148
|
+
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
149
|
+
if (cookie) {
|
|
150
|
+
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
151
|
+
}
|
|
152
|
+
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
153
|
+
if (accept) {
|
|
154
|
+
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
155
|
+
}
|
|
156
|
+
return finalFallback(config);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "domain": {
|
|
160
|
+
const host = request.headers.get("host") || url.host;
|
|
161
|
+
const mapped = config.domains?.[host];
|
|
162
|
+
if (mapped) {
|
|
163
|
+
return { code: mapped, isDefault: false, strategy: "domain", source: host };
|
|
164
|
+
}
|
|
165
|
+
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
166
|
+
if (cookie) {
|
|
167
|
+
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
168
|
+
}
|
|
169
|
+
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
170
|
+
if (accept) {
|
|
171
|
+
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
172
|
+
}
|
|
173
|
+
return finalFallback(config);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case "cookie": {
|
|
177
|
+
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
178
|
+
if (cookie) {
|
|
179
|
+
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
180
|
+
}
|
|
181
|
+
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
182
|
+
if (accept) {
|
|
183
|
+
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
184
|
+
}
|
|
185
|
+
return finalFallback(config);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "header": {
|
|
189
|
+
const accept = parseAcceptLanguage(acceptLanguage, config.locales);
|
|
190
|
+
if (accept) {
|
|
191
|
+
return { code: accept, isDefault: false, strategy: "header", source: acceptLanguage ?? undefined };
|
|
192
|
+
}
|
|
193
|
+
const cookie = readLocaleCookie(cookieHeader, cookieName, config.locales);
|
|
194
|
+
if (cookie) {
|
|
195
|
+
return { code: cookie, isDefault: false, strategy: "cookie", source: cookieHeader ?? undefined };
|
|
196
|
+
}
|
|
197
|
+
return finalFallback(config);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Exhaustive check
|
|
202
|
+
return finalFallback(config);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function finalFallback(config: I18nDefinition): ResolvedLocale {
|
|
206
|
+
if (config.fallback && config.fallback !== config.defaultLocale) {
|
|
207
|
+
return {
|
|
208
|
+
code: config.fallback,
|
|
209
|
+
isDefault: config.fallback === config.defaultLocale,
|
|
210
|
+
strategy: "fallback",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return { code: config.defaultLocale, isDefault: true, strategy: "default" };
|
|
214
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18.μ — message registry + typed `t()` helper.
|
|
3
|
+
*
|
|
4
|
+
* `defineMessages()` creates a locale-keyed message bundle with its
|
|
5
|
+
* keys inferred at compile time (via `as const`). `createTranslator()`
|
|
6
|
+
* returns a `t(key, vars)` function bound to a specific active
|
|
7
|
+
* locale — misses fall back to `fallbackLocale` then raw `key`.
|
|
8
|
+
*
|
|
9
|
+
* Placeholder syntax is Next.js / react-intl-lite compatible:
|
|
10
|
+
* `"Hello, {{name}}!"` resolves `{ name: "Mandu" }` → `"Hello, Mandu!"`.
|
|
11
|
+
* Missing vars are left as `{{name}}` so template errors are visible
|
|
12
|
+
* in the rendered page instead of silently producing empty strings.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { LocaleCode, MessageBundle, Translator } from "./types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Brand `defineMessages()` output so downstream helpers can verify
|
|
19
|
+
* the caller actually went through the declared API. Runtime-only
|
|
20
|
+
* field; TS infers it without the caller writing it.
|
|
21
|
+
*/
|
|
22
|
+
export interface MessageRegistry<TKeys extends string = string> {
|
|
23
|
+
readonly __manduMessages: true;
|
|
24
|
+
readonly bundles: MessageBundle<TKeys>;
|
|
25
|
+
readonly locales: readonly LocaleCode[];
|
|
26
|
+
/**
|
|
27
|
+
* Look up a single message. Returns `undefined` when neither
|
|
28
|
+
* the requested locale nor the registry's configured fallbacks
|
|
29
|
+
* carry the key. The returned value is the raw template, NOT
|
|
30
|
+
* interpolated — use `createTranslator()` for the full pipeline.
|
|
31
|
+
*/
|
|
32
|
+
lookup(locale: LocaleCode, key: TKeys, fallback?: LocaleCode): string | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a message registry. The generic `TBundle` is inferred from
|
|
37
|
+
* the argument when passed `as const`, giving `t()` compile-time
|
|
38
|
+
* key checks.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* const messages = defineMessages({
|
|
43
|
+
* en: { welcome: "Welcome, {{name}}!" },
|
|
44
|
+
* ko: { welcome: "환영합니다, {{name}}님!" },
|
|
45
|
+
* } as const);
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function defineMessages<
|
|
49
|
+
TBundle extends Record<string, Record<string, string>>,
|
|
50
|
+
>(bundles: TBundle): MessageRegistry<
|
|
51
|
+
Extract<keyof TBundle[keyof TBundle], string>
|
|
52
|
+
> {
|
|
53
|
+
if (!bundles || typeof bundles !== "object") {
|
|
54
|
+
throw new Error("[mandu/i18n] defineMessages() requires an object");
|
|
55
|
+
}
|
|
56
|
+
const locales = Object.keys(bundles);
|
|
57
|
+
if (locales.length === 0) {
|
|
58
|
+
throw new Error("[mandu/i18n] defineMessages() requires at least one locale");
|
|
59
|
+
}
|
|
60
|
+
for (const [locale, bundle] of Object.entries(bundles)) {
|
|
61
|
+
if (!bundle || typeof bundle !== "object") {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[mandu/i18n] defineMessages(): bundle for "${locale}" must be an object`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
for (const [key, value] of Object.entries(bundle)) {
|
|
67
|
+
if (typeof value !== "string") {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`[mandu/i18n] defineMessages(): "${locale}.${key}" must be a string`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type TKey = Extract<keyof TBundle[keyof TBundle], string>;
|
|
76
|
+
|
|
77
|
+
const registry: MessageRegistry<TKey> = {
|
|
78
|
+
__manduMessages: true,
|
|
79
|
+
bundles: bundles as unknown as MessageBundle<TKey>,
|
|
80
|
+
locales,
|
|
81
|
+
lookup(locale: LocaleCode, key: TKey, fallback?: LocaleCode): string | undefined {
|
|
82
|
+
const primary = (bundles as Record<string, Record<string, string>>)[locale]?.[key];
|
|
83
|
+
if (typeof primary === "string") return primary;
|
|
84
|
+
if (fallback && fallback !== locale) {
|
|
85
|
+
const alt = (bundles as Record<string, Record<string, string>>)[fallback]?.[key];
|
|
86
|
+
if (typeof alt === "string") return alt;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
return registry;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Replace `{{var}}` placeholders in `template` using `vars`. Missing
|
|
96
|
+
* vars are preserved as `{{var}}` so templating bugs are visible in
|
|
97
|
+
* the rendered output (not silently stripped). `vars[...]` values
|
|
98
|
+
* that are numbers are coerced via `String(value)`.
|
|
99
|
+
*
|
|
100
|
+
* Whitespace inside braces is tolerated: `{{ name }}` and `{{name}}`
|
|
101
|
+
* both resolve to `vars.name`.
|
|
102
|
+
*/
|
|
103
|
+
export function interpolate(
|
|
104
|
+
template: string,
|
|
105
|
+
vars: Record<string, string | number> | undefined
|
|
106
|
+
): string {
|
|
107
|
+
if (!vars) return template;
|
|
108
|
+
return template.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g, (match, key) => {
|
|
109
|
+
if (Object.prototype.hasOwnProperty.call(vars, key)) {
|
|
110
|
+
const value = vars[key as keyof typeof vars];
|
|
111
|
+
if (value === undefined || value === null) return match;
|
|
112
|
+
return String(value);
|
|
113
|
+
}
|
|
114
|
+
return match;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a typed `t()` function bound to a specific active locale.
|
|
120
|
+
* Misses walk `activeLocale → fallbackLocale → defaultLocale → key`
|
|
121
|
+
* (skipping duplicates). The final fallback to raw `key` makes
|
|
122
|
+
* missing-translation bugs highly visible during QA without
|
|
123
|
+
* crashing the page.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* const messages = defineMessages({
|
|
128
|
+
* en: { greet: "Hi, {{name}}" },
|
|
129
|
+
* ko: { greet: "안녕, {{name}}" },
|
|
130
|
+
* } as const);
|
|
131
|
+
*
|
|
132
|
+
* const t = createTranslator(messages, { activeLocale: "ko", defaultLocale: "en" });
|
|
133
|
+
* t("greet", { name: "만두" }); // → "안녕, 만두"
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export function createTranslator<TKeys extends string>(
|
|
137
|
+
registry: MessageRegistry<TKeys>,
|
|
138
|
+
opts: {
|
|
139
|
+
activeLocale: LocaleCode;
|
|
140
|
+
defaultLocale: LocaleCode;
|
|
141
|
+
fallbackLocale?: LocaleCode;
|
|
142
|
+
}
|
|
143
|
+
): Translator<TKeys> {
|
|
144
|
+
const { activeLocale, defaultLocale, fallbackLocale } = opts;
|
|
145
|
+
return function t(key, vars) {
|
|
146
|
+
const candidates: LocaleCode[] = [activeLocale];
|
|
147
|
+
if (fallbackLocale && !candidates.includes(fallbackLocale)) candidates.push(fallbackLocale);
|
|
148
|
+
if (!candidates.includes(defaultLocale)) candidates.push(defaultLocale);
|
|
149
|
+
|
|
150
|
+
for (const locale of candidates) {
|
|
151
|
+
const value = registry.lookup(locale, key as TKeys);
|
|
152
|
+
if (typeof value === "string") {
|
|
153
|
+
return interpolate(value, vars);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Final fallback — raw key preserves "unresolved" signal instead
|
|
157
|
+
// of a blank string. `vars` is intentionally ignored here because
|
|
158
|
+
// the key itself isn't a template.
|
|
159
|
+
return key as unknown as string;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Type guard for {@link MessageRegistry}. Useful when the runtime
|
|
165
|
+
* receives `unknown` from user config (e.g. dynamic imports).
|
|
166
|
+
*/
|
|
167
|
+
export function isMessageRegistry(value: unknown): value is MessageRegistry {
|
|
168
|
+
return (
|
|
169
|
+
typeof value === "object" &&
|
|
170
|
+
value !== null &&
|
|
171
|
+
(value as { __manduMessages?: unknown }).__manduMessages === true
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 18.μ — i18n types.
|
|
3
|
+
*
|
|
4
|
+
* Public type surface for Mandu's first-class i18n. Separated from
|
|
5
|
+
* runtime so `import type` callers pay zero bundle cost.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A locale code. The string is opaque to the framework — we never
|
|
10
|
+
* parse it beyond "equality vs the configured allow-list", so
|
|
11
|
+
* `"en"`, `"en-US"`, `"zh-Hant"`, `"ko-KR"`, `"pt-BR"` are all valid
|
|
12
|
+
* and treated as distinct locales. Match case-sensitively to the
|
|
13
|
+
* entries of `I18nConfig.locales`.
|
|
14
|
+
*/
|
|
15
|
+
export type LocaleCode = string;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How the active locale is resolved from an incoming request.
|
|
19
|
+
*
|
|
20
|
+
* - `"path-prefix"` — Next.js default. `/en/docs` → `en`, `/ko/docs`
|
|
21
|
+
* → `ko`. Routes without a locale prefix fall through to
|
|
22
|
+
* `defaultLocale`. Path-prefix synthesis happens at manifest
|
|
23
|
+
* build time (see `router/fs-scanner.ts`).
|
|
24
|
+
* - `"domain"` — locale-per-subdomain. `en.example.com` → `en`.
|
|
25
|
+
* The mapping is supplied via {@link I18nConfig.domains}.
|
|
26
|
+
* - `"header"` — pure `Accept-Language` negotiation. Useful for
|
|
27
|
+
* APIs where URLs should stay locale-less.
|
|
28
|
+
* - `"cookie"` — explicit user choice is stored in a cookie
|
|
29
|
+
* (default name: `mandu_locale`). Server-side persistence
|
|
30
|
+
* across navigations without URL mutation.
|
|
31
|
+
*/
|
|
32
|
+
export type I18nStrategy = "path-prefix" | "domain" | "header" | "cookie";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolved locale state, attached to `ctx.locale` by the runtime
|
|
36
|
+
* dispatcher. Carries the canonical `code`, the `strategy` that
|
|
37
|
+
* produced it (for `Vary:` bookkeeping), and the raw `source` so
|
|
38
|
+
* debuggers can see whether the URL won over the cookie.
|
|
39
|
+
*/
|
|
40
|
+
export interface ResolvedLocale {
|
|
41
|
+
/** The resolved locale code (always one of `I18nConfig.locales`). */
|
|
42
|
+
code: LocaleCode;
|
|
43
|
+
/** Whether this request used the default locale (no explicit signal). */
|
|
44
|
+
isDefault: boolean;
|
|
45
|
+
/** Which strategy ultimately produced the locale. */
|
|
46
|
+
strategy: I18nStrategy | "default" | "fallback";
|
|
47
|
+
/** Raw input that produced the match, for debugging. */
|
|
48
|
+
source?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A map of translation strings keyed by locale. Typically declared
|
|
53
|
+
* `as const` so keys are inferred literally and the `t()` helper can
|
|
54
|
+
* reject typos at compile time.
|
|
55
|
+
*/
|
|
56
|
+
export type MessageBundle<TKeys extends string = string> = {
|
|
57
|
+
[locale: string]: Record<TKeys, string>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Configuration for `defineI18n()`. Kept shallow so it round-trips
|
|
62
|
+
* through JSON config files without loss.
|
|
63
|
+
*/
|
|
64
|
+
export interface I18nConfig {
|
|
65
|
+
/**
|
|
66
|
+
* Non-empty allow-list of supported locales. First entry is NOT
|
|
67
|
+
* automatically the default — `defaultLocale` is explicit.
|
|
68
|
+
*/
|
|
69
|
+
locales: readonly LocaleCode[];
|
|
70
|
+
/** Fallback when no resolver returns a known locale. MUST be in `locales`. */
|
|
71
|
+
defaultLocale: LocaleCode;
|
|
72
|
+
/**
|
|
73
|
+
* Optional fallback chain. When `t(key)` misses in the active
|
|
74
|
+
* locale, we look up `fallback` before `defaultLocale` before
|
|
75
|
+
* returning the raw key. Typical use: `{ defaultLocale: 'en',
|
|
76
|
+
* fallback: 'en-US' }` so `zh-Hant` falls through `en-US` to `en`.
|
|
77
|
+
*/
|
|
78
|
+
fallback?: LocaleCode;
|
|
79
|
+
/** Locale detection strategy. See {@link I18nStrategy}. */
|
|
80
|
+
strategy: I18nStrategy;
|
|
81
|
+
/**
|
|
82
|
+
* Cookie name for `strategy: 'cookie'` OR a cookie override for
|
|
83
|
+
* any other strategy (useful for "user explicitly picked a
|
|
84
|
+
* locale" persistence). Default: `"mandu_locale"`.
|
|
85
|
+
*/
|
|
86
|
+
cookieName?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Domain → locale map for `strategy: 'domain'`. Required when
|
|
89
|
+
* `strategy === "domain"`; ignored otherwise.
|
|
90
|
+
*/
|
|
91
|
+
domains?: Record<string, LocaleCode>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Result of `defineI18n()`. A frozen config object that downstream
|
|
96
|
+
* resolver + registry use. The brand field prevents accidental
|
|
97
|
+
* coercion from plain literals.
|
|
98
|
+
*/
|
|
99
|
+
export interface I18nDefinition extends I18nConfig {
|
|
100
|
+
readonly __manduI18n: true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Strongly-typed translator. Given a registry whose entries
|
|
105
|
+
* declared `as const`, `t()` rejects typos in `key`, and
|
|
106
|
+
* `vars` accepts any `{{name}}` placeholders present in the
|
|
107
|
+
* template.
|
|
108
|
+
*/
|
|
109
|
+
export type Translator<TKeys extends string = string> = (
|
|
110
|
+
key: TKeys,
|
|
111
|
+
vars?: Record<string, string | number>
|
|
112
|
+
) => string;
|
package/src/middleware/index.ts
CHANGED
|
@@ -29,6 +29,13 @@ export {
|
|
|
29
29
|
rateLimitMiddleware,
|
|
30
30
|
} from "./bridge";
|
|
31
31
|
|
|
32
|
+
export {
|
|
33
|
+
schedulerCron,
|
|
34
|
+
setActiveSchedulerRegistration,
|
|
35
|
+
getActiveSchedulerRegistration,
|
|
36
|
+
type SchedulerCronMiddlewareOptions,
|
|
37
|
+
} from "./scheduler-cron";
|
|
38
|
+
|
|
32
39
|
export { cors, type CorsMiddlewareOptions } from "./cors";
|
|
33
40
|
export { jwt, type JwtMiddlewareOptions } from "./jwt";
|
|
34
41
|
export { csrf, type CsrfMiddlewareOptions } from "./csrf";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scheduler-cron` — request-level middleware that exposes the running
|
|
3
|
+
* scheduler registration on `ctx` so handlers can inspect job status or
|
|
4
|
+
* trigger an ad-hoc tick for debugging.
|
|
5
|
+
*
|
|
6
|
+
* This is intentionally a thin bridge — the cron jobs themselves are
|
|
7
|
+
* defined with {@link import("../scheduler").defineCron} and started at
|
|
8
|
+
* `startServer()` boot time. The middleware does NOT start or stop the
|
|
9
|
+
* scheduler; its only job is to make the `CronRegistration` handle
|
|
10
|
+
* available to downstream request handlers (e.g., an observability
|
|
11
|
+
* dashboard API that wants to render `status()`).
|
|
12
|
+
*
|
|
13
|
+
* The middleware is opt-in — it's NOT part of the default chain. Add it
|
|
14
|
+
* to `mandu.config.ts` `middleware: [...]` only if you have a route that
|
|
15
|
+
* needs `ctx.scheduler`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* // mandu.config.ts
|
|
20
|
+
* import { defineConfig } from "@mandujs/core";
|
|
21
|
+
* import { schedulerCron } from "@mandujs/core/middleware";
|
|
22
|
+
* import { jobs } from "./jobs";
|
|
23
|
+
*
|
|
24
|
+
* export default defineConfig({
|
|
25
|
+
* scheduler: { jobs },
|
|
26
|
+
* middleware: [schedulerCron()],
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { defineMiddleware } from "./define";
|
|
32
|
+
import type { Middleware } from "./define";
|
|
33
|
+
import type { CronRegistration } from "../scheduler";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Global handle to the active {@link CronRegistration}, set by
|
|
37
|
+
* `startServer()` when it boots the scheduler. `scheduler-cron` middleware
|
|
38
|
+
* reads this slot on every request so the registration is always the one
|
|
39
|
+
* actually running.
|
|
40
|
+
*
|
|
41
|
+
* We store it on `globalThis` rather than module-scope so that multiple
|
|
42
|
+
* bundles (e.g., `@mandujs/core` loaded twice in a monorepo hot-reload)
|
|
43
|
+
* still see the same handle — matches the registry pattern in
|
|
44
|
+
* `runtime/server.ts`.
|
|
45
|
+
*/
|
|
46
|
+
const GLOBAL_KEY = "__MANDU_SCHEDULER_REGISTRATION__";
|
|
47
|
+
|
|
48
|
+
interface SchedulerGlobal {
|
|
49
|
+
[GLOBAL_KEY]?: CronRegistration | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function setActiveSchedulerRegistration(reg: CronRegistration | null): void {
|
|
53
|
+
(globalThis as unknown as SchedulerGlobal)[GLOBAL_KEY] = reg;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getActiveSchedulerRegistration(): CronRegistration | null {
|
|
57
|
+
return (globalThis as unknown as SchedulerGlobal)[GLOBAL_KEY] ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SchedulerCronMiddlewareOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Custom header to stamp on the response with the current scheduler job
|
|
63
|
+
* count. Useful for smoke-checking that the scheduler is running in a
|
|
64
|
+
* given environment. Default: no header is added.
|
|
65
|
+
*/
|
|
66
|
+
statusHeader?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Creates a middleware that exposes the scheduler registration via a response
|
|
71
|
+
* header and (optionally) stamps a status header. The registration itself is
|
|
72
|
+
* wired into `ctx` via the request-level composition chain — downstream
|
|
73
|
+
* code reads it with {@link getActiveSchedulerRegistration}.
|
|
74
|
+
*/
|
|
75
|
+
export function schedulerCron(options: SchedulerCronMiddlewareOptions = {}): Middleware {
|
|
76
|
+
return defineMiddleware({
|
|
77
|
+
name: "scheduler-cron",
|
|
78
|
+
async handler(_req, next) {
|
|
79
|
+
const response = await next();
|
|
80
|
+
const reg = getActiveSchedulerRegistration();
|
|
81
|
+
if (reg && options.statusHeader) {
|
|
82
|
+
const status = reg.status();
|
|
83
|
+
const jobCount = Object.keys(status).length;
|
|
84
|
+
// Clone headers to avoid mutating an immutable response body.
|
|
85
|
+
const headers = new Headers(response.headers);
|
|
86
|
+
headers.set(options.statusHeader, String(jobCount));
|
|
87
|
+
return new Response(response.body, {
|
|
88
|
+
status: response.status,
|
|
89
|
+
statusText: response.statusText,
|
|
90
|
+
headers,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return response;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|