@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.
@@ -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;
@@ -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
+ }