@mandujs/core 0.28.0 → 0.29.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 +1 -1
- package/src/bundler/generate-static-params.ts +290 -0
- package/src/bundler/prerender.ts +242 -69
- package/src/client/hydrate.ts +340 -0
- package/src/client/index.ts +11 -0
- package/src/config/mandu.ts +40 -0
- package/src/config/validate.ts +36 -0
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -0
- package/src/dev-error-overlay/index.ts +30 -0
- package/src/dev-error-overlay/overlay-client.ts +300 -0
- package/src/dev-error-overlay/overlay-injector.ts +243 -0
- package/src/dev-error-overlay/overlay-styles.ts +52 -0
- package/src/dev-error-overlay/types.ts +66 -0
- package/src/middleware/bridge.ts +147 -0
- package/src/middleware/compose.ts +134 -0
- package/src/middleware/define.ts +132 -0
- package/src/middleware/index.ts +76 -50
- package/src/router/fs-patterns.test.ts +96 -0
- package/src/router/fs-routes.ts +1 -0
- package/src/router/fs-scanner.ts +10 -1
- package/src/router/fs-types.ts +8 -0
- package/src/runtime/server.ts +310 -10
- package/src/runtime/ssr.ts +70 -2
- package/src/spec/schema.ts +6 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Per-Island Hydration Scheduler
|
|
3
|
+
*
|
|
4
|
+
* Phase 18.δ — Formal hydration strategy spec for Islands.
|
|
5
|
+
*
|
|
6
|
+
* Strategies (Astro-compatible naming):
|
|
7
|
+
* - `load` — hydrate immediately (current default; equiv. to Astro `client:load`)
|
|
8
|
+
* - `idle` — `requestIdleCallback` (Astro `client:idle`)
|
|
9
|
+
* - `visible` — `IntersectionObserver` rootMargin 200px (Astro `client:visible`)
|
|
10
|
+
* - `interaction` — hydrate on first click/touchstart/keydown (Astro `client:only` equivalent for events)
|
|
11
|
+
* - `media(<query>)` — hydrate only when `matchMedia(query).matches` (Astro `client:media`)
|
|
12
|
+
*
|
|
13
|
+
* This module is the **single source of truth** for hydration scheduling on
|
|
14
|
+
* the client. It is intentionally side-effect-free on import so that:
|
|
15
|
+
* 1. Bundlers can tree-shake unused strategies.
|
|
16
|
+
* 2. Unit tests can import it under happy-dom/JSDOM without triggering
|
|
17
|
+
* global mutation (no `document.querySelectorAll` on module eval).
|
|
18
|
+
*
|
|
19
|
+
* The bundler-generated runtime (`bundler/build.ts::generateRuntimeSource`)
|
|
20
|
+
* delegates strategy selection to `scheduleHydration()` here — SSR emits the
|
|
21
|
+
* `data-hydrate` attribute, the runtime reads it and dispatches.
|
|
22
|
+
*
|
|
23
|
+
* Design contract:
|
|
24
|
+
* - Every strategy invokes its `hydrate` callback at most ONCE.
|
|
25
|
+
* - Every strategy returns a `dispose()` cleanup that detaches observers
|
|
26
|
+
* and event listeners (no memory leaks on route change / unmount).
|
|
27
|
+
* - Unknown / malformed strategies fall back to `load` with a console
|
|
28
|
+
* warning so broken islands never silently stay un-interactive.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type HydrationStrategyName =
|
|
32
|
+
| "load"
|
|
33
|
+
| "idle"
|
|
34
|
+
| "visible"
|
|
35
|
+
| "interaction"
|
|
36
|
+
| "media";
|
|
37
|
+
|
|
38
|
+
export interface ParsedStrategy {
|
|
39
|
+
name: HydrationStrategyName;
|
|
40
|
+
/** media query string when `name === "media"`, else `undefined`. */
|
|
41
|
+
media?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `rootMargin` used by the `visible` IntersectionObserver. */
|
|
45
|
+
export const VISIBLE_ROOT_MARGIN = "200px";
|
|
46
|
+
|
|
47
|
+
/** Events that trigger `interaction` hydration. */
|
|
48
|
+
export const INTERACTION_EVENTS = ["click", "touchstart", "keydown"] as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a `data-hydrate` attribute value into a normalized strategy.
|
|
52
|
+
*
|
|
53
|
+
* Accepts:
|
|
54
|
+
* - `"load"` | `"idle"` | `"visible"` | `"interaction"`
|
|
55
|
+
* - `"media(min-width: 768px)"` — media query in parentheses
|
|
56
|
+
*
|
|
57
|
+
* Returns `{ name: "load" }` with a `console.warn` for unknown/malformed
|
|
58
|
+
* inputs (fail-open default — never leave an island dead).
|
|
59
|
+
*/
|
|
60
|
+
export function parseHydrateStrategy(
|
|
61
|
+
attr: string | null | undefined,
|
|
62
|
+
): ParsedStrategy {
|
|
63
|
+
if (!attr) return { name: "load" };
|
|
64
|
+
|
|
65
|
+
const trimmed = attr.trim();
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
trimmed === "load" ||
|
|
69
|
+
trimmed === "idle" ||
|
|
70
|
+
trimmed === "visible" ||
|
|
71
|
+
trimmed === "interaction"
|
|
72
|
+
) {
|
|
73
|
+
return { name: trimmed };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// media(<query>) — extract inner query
|
|
77
|
+
const mediaMatch = /^media\(\s*(.+?)\s*\)$/.exec(trimmed);
|
|
78
|
+
if (mediaMatch && mediaMatch[1]) {
|
|
79
|
+
return { name: "media", media: mediaMatch[1] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fallback: legacy `immediate` alias → `load`
|
|
83
|
+
if (trimmed === "immediate") return { name: "load" };
|
|
84
|
+
|
|
85
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
86
|
+
console.warn(
|
|
87
|
+
`[Mandu] Unknown hydrate strategy "${attr}", falling back to "load".`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return { name: "load" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A function returned by each strategy scheduler. Invoke to detach any
|
|
95
|
+
* observers / listeners BEFORE the actual hydration kicks in. Safe to call
|
|
96
|
+
* multiple times (idempotent).
|
|
97
|
+
*/
|
|
98
|
+
export type Disposer = () => void;
|
|
99
|
+
|
|
100
|
+
function noopDispose(): Disposer {
|
|
101
|
+
return () => {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `load` — hydrate synchronously on next microtask.
|
|
106
|
+
*
|
|
107
|
+
* We use a microtask (Promise.resolve().then) rather than calling `hydrate`
|
|
108
|
+
* directly so that callers can install listeners on `mandu:hydrated` events
|
|
109
|
+
* after `scheduleHydration` returns. Matches Astro `client:load` semantics.
|
|
110
|
+
*/
|
|
111
|
+
function scheduleLoad(hydrate: () => void): Disposer {
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
Promise.resolve().then(() => {
|
|
114
|
+
if (!cancelled) hydrate();
|
|
115
|
+
});
|
|
116
|
+
return () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `idle` — hydrate when the browser is idle.
|
|
123
|
+
* Falls back to `setTimeout(200)` when `requestIdleCallback` is unavailable
|
|
124
|
+
* (Safari < 16.4, some WebViews).
|
|
125
|
+
*/
|
|
126
|
+
function scheduleIdle(hydrate: () => void): Disposer {
|
|
127
|
+
const w = typeof window !== "undefined" ? window : undefined;
|
|
128
|
+
if (!w) return noopDispose();
|
|
129
|
+
|
|
130
|
+
if (typeof w.requestIdleCallback === "function") {
|
|
131
|
+
const handle = w.requestIdleCallback(() => hydrate());
|
|
132
|
+
return () => {
|
|
133
|
+
if (typeof w.cancelIdleCallback === "function") {
|
|
134
|
+
w.cancelIdleCallback(handle);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const timer = setTimeout(hydrate, 200);
|
|
140
|
+
return () => clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `visible` — hydrate when the element enters the viewport (+200px margin).
|
|
145
|
+
* Falls back to immediate hydration when `IntersectionObserver` is missing.
|
|
146
|
+
*/
|
|
147
|
+
function scheduleVisible(
|
|
148
|
+
element: Element,
|
|
149
|
+
hydrate: () => void,
|
|
150
|
+
): Disposer {
|
|
151
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
152
|
+
hydrate();
|
|
153
|
+
return noopDispose();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let disposed = false;
|
|
157
|
+
const observer = new IntersectionObserver(
|
|
158
|
+
(entries) => {
|
|
159
|
+
if (disposed) return;
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (entry.isIntersecting) {
|
|
162
|
+
disposed = true;
|
|
163
|
+
observer.disconnect();
|
|
164
|
+
hydrate();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
{ rootMargin: VISIBLE_ROOT_MARGIN },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// `display:contents` wrappers have zero layout size → observe the first
|
|
173
|
+
// element child when available so IntersectionObserver sees real geometry.
|
|
174
|
+
const target = resolveObservationTarget(element);
|
|
175
|
+
observer.observe(target);
|
|
176
|
+
|
|
177
|
+
return () => {
|
|
178
|
+
disposed = true;
|
|
179
|
+
observer.disconnect();
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `interaction` — hydrate on the first click / touchstart / keydown within
|
|
185
|
+
* the island. Matches the task spec (click/touch/keydown only; no
|
|
186
|
+
* mouseenter or pointerdown to keep scroll & hover passive).
|
|
187
|
+
*/
|
|
188
|
+
function scheduleInteraction(
|
|
189
|
+
element: Element,
|
|
190
|
+
hydrate: () => void,
|
|
191
|
+
): Disposer {
|
|
192
|
+
let fired = false;
|
|
193
|
+
const target = resolveObservationTarget(element);
|
|
194
|
+
|
|
195
|
+
const onEvent = () => {
|
|
196
|
+
if (fired) return;
|
|
197
|
+
fired = true;
|
|
198
|
+
dispose();
|
|
199
|
+
hydrate();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const dispose: Disposer = () => {
|
|
203
|
+
for (const evt of INTERACTION_EVENTS) {
|
|
204
|
+
target.removeEventListener(evt, onEvent, true);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
for (const evt of INTERACTION_EVENTS) {
|
|
209
|
+
// Capture phase so the island hydrates BEFORE the user's click bubbles
|
|
210
|
+
// to their still-dehydrated event handler.
|
|
211
|
+
target.addEventListener(evt, onEvent, { capture: true, once: false });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return dispose;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `media(<query>)` — hydrate only when `matchMedia(query).matches` becomes
|
|
219
|
+
* true. If already matching on mount, hydrate immediately; otherwise wait
|
|
220
|
+
* for the first `change` event.
|
|
221
|
+
*/
|
|
222
|
+
function scheduleMedia(query: string, hydrate: () => void): Disposer {
|
|
223
|
+
if (
|
|
224
|
+
typeof window === "undefined" ||
|
|
225
|
+
typeof window.matchMedia !== "function"
|
|
226
|
+
) {
|
|
227
|
+
// No matchMedia support → fall back to `load` so the island is at least
|
|
228
|
+
// interactive. Conservative: users can't debug silent no-ops.
|
|
229
|
+
hydrate();
|
|
230
|
+
return noopDispose();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const mql = window.matchMedia(query);
|
|
234
|
+
if (mql.matches) {
|
|
235
|
+
hydrate();
|
|
236
|
+
return noopDispose();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let disposed = false;
|
|
240
|
+
const onChange = (e: MediaQueryListEvent) => {
|
|
241
|
+
if (disposed) return;
|
|
242
|
+
if (e.matches) {
|
|
243
|
+
disposed = true;
|
|
244
|
+
detach();
|
|
245
|
+
hydrate();
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const detach: Disposer = () => {
|
|
250
|
+
if (typeof mql.removeEventListener === "function") {
|
|
251
|
+
mql.removeEventListener("change", onChange);
|
|
252
|
+
} else if (typeof (mql as unknown as { removeListener?: (l: unknown) => void }).removeListener === "function") {
|
|
253
|
+
// Safari < 14 legacy API
|
|
254
|
+
(mql as unknown as { removeListener: (l: (e: MediaQueryListEvent) => void) => void }).removeListener(onChange);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (typeof mql.addEventListener === "function") {
|
|
259
|
+
mql.addEventListener("change", onChange);
|
|
260
|
+
} else if (typeof (mql as unknown as { addListener?: (l: unknown) => void }).addListener === "function") {
|
|
261
|
+
(mql as unknown as { addListener: (l: (e: MediaQueryListEvent) => void) => void }).addListener(onChange);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
disposed = true;
|
|
266
|
+
detach();
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolve the DOM node to actually observe / listen on. Island wrappers use
|
|
272
|
+
* `style="display:contents"` which has zero layout box — IntersectionObserver
|
|
273
|
+
* refuses to fire for such elements. Promote to the first element child when
|
|
274
|
+
* available, else fall back to the wrapper itself.
|
|
275
|
+
*/
|
|
276
|
+
function resolveObservationTarget(element: Element): Element {
|
|
277
|
+
if (typeof window === "undefined" || typeof getComputedStyle !== "function") {
|
|
278
|
+
return element;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const cs = getComputedStyle(element);
|
|
282
|
+
if (cs.display === "contents" && element.firstElementChild) {
|
|
283
|
+
return element.firstElementChild;
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
/* happy-dom may reject partial stylesheet queries — treat as layout element */
|
|
287
|
+
}
|
|
288
|
+
return element;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Public entry — schedule hydration of `element` according to `strategy`,
|
|
293
|
+
* invoking `hydrate` at most once. Returns a disposer for cleanup.
|
|
294
|
+
*
|
|
295
|
+
* Unknown strategies degrade to `load` with a console warning.
|
|
296
|
+
*
|
|
297
|
+
* @param element — the island root (typically `[data-mandu-island]`)
|
|
298
|
+
* @param strategy — either a `ParsedStrategy` from `parseHydrateStrategy`
|
|
299
|
+
* OR a raw attribute string (e.g. `"media(min-width: 768px)"`)
|
|
300
|
+
* @param hydrate — callback invoked once when the strategy trigger fires
|
|
301
|
+
*/
|
|
302
|
+
export function scheduleHydration(
|
|
303
|
+
element: Element,
|
|
304
|
+
strategy: ParsedStrategy | string | null | undefined,
|
|
305
|
+
hydrate: () => void,
|
|
306
|
+
): Disposer {
|
|
307
|
+
const parsed =
|
|
308
|
+
typeof strategy === "string" || strategy == null
|
|
309
|
+
? parseHydrateStrategy(strategy as string | null | undefined)
|
|
310
|
+
: strategy;
|
|
311
|
+
|
|
312
|
+
switch (parsed.name) {
|
|
313
|
+
case "load":
|
|
314
|
+
return scheduleLoad(hydrate);
|
|
315
|
+
case "idle":
|
|
316
|
+
return scheduleIdle(hydrate);
|
|
317
|
+
case "visible":
|
|
318
|
+
return scheduleVisible(element, hydrate);
|
|
319
|
+
case "interaction":
|
|
320
|
+
return scheduleInteraction(element, hydrate);
|
|
321
|
+
case "media":
|
|
322
|
+
return parsed.media
|
|
323
|
+
? scheduleMedia(parsed.media, hydrate)
|
|
324
|
+
: scheduleLoad(hydrate);
|
|
325
|
+
default:
|
|
326
|
+
// Exhaustiveness: if a new strategy is added to the union without a
|
|
327
|
+
// handler, TypeScript flags the `never` check below at compile time.
|
|
328
|
+
((_x: never) => _x)(parsed.name);
|
|
329
|
+
return scheduleLoad(hydrate);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Internal test helpers — expose resolveObservationTarget so that unit
|
|
335
|
+
* tests can verify the `display:contents` promotion rule without relying
|
|
336
|
+
* on public API side-effects.
|
|
337
|
+
*
|
|
338
|
+
* @internal
|
|
339
|
+
*/
|
|
340
|
+
export const _testOnly_resolveObservationTarget = resolveObservationTarget;
|
package/src/client/index.ts
CHANGED
|
@@ -63,6 +63,17 @@ export {
|
|
|
63
63
|
type HydrationPriority,
|
|
64
64
|
} from "./runtime";
|
|
65
65
|
|
|
66
|
+
// Phase 18.δ — Per-Island hydration scheduler (Astro-grade)
|
|
67
|
+
export {
|
|
68
|
+
scheduleHydration,
|
|
69
|
+
parseHydrateStrategy,
|
|
70
|
+
VISIBLE_ROOT_MARGIN,
|
|
71
|
+
INTERACTION_EVENTS,
|
|
72
|
+
type HydrationStrategyName,
|
|
73
|
+
type ParsedStrategy,
|
|
74
|
+
type Disposer,
|
|
75
|
+
} from "./hydrate";
|
|
76
|
+
|
|
66
77
|
// SSE / ReadableStream API (microtask-starvation-safe)
|
|
67
78
|
export {
|
|
68
79
|
useSSE,
|
package/src/config/mandu.ts
CHANGED
|
@@ -2,6 +2,7 @@ import path from "path";
|
|
|
2
2
|
import { readJsonFile } from "../utils/bun";
|
|
3
3
|
import type { ManduAdapter } from "../runtime/adapter";
|
|
4
4
|
import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
|
|
5
|
+
import type { Middleware } from "../middleware/define";
|
|
5
6
|
|
|
6
7
|
export type GuardRuleSeverity = "error" | "warn" | "warning" | "off";
|
|
7
8
|
|
|
@@ -232,6 +233,29 @@ export interface ManduConfig {
|
|
|
232
233
|
* to re-read this comment to recover.
|
|
233
234
|
*/
|
|
234
235
|
prebuildTimeoutMs?: number;
|
|
236
|
+
/**
|
|
237
|
+
* Phase 18.α — Dev-only full-screen error overlay (Next.js / Astro
|
|
238
|
+
* style). When `true` (default) Mandu injects a ~10 KB inline
|
|
239
|
+
* `<style>` + `<script>` block into every dev SSR response that
|
|
240
|
+
* renders a modal on:
|
|
241
|
+
*
|
|
242
|
+
* - `window.onerror` — uncaught script errors
|
|
243
|
+
* - `unhandledrejection` — unhandled Promise rejections
|
|
244
|
+
* - custom `__MANDU_ERROR__` CustomEvent — used by the server's
|
|
245
|
+
* 500 path to surface SSR render failures directly in the
|
|
246
|
+
* browser instead of only in the terminal
|
|
247
|
+
*
|
|
248
|
+
* The overlay ships a "Copy for AI" button that formats a markdown
|
|
249
|
+
* snapshot of the error + stack for paste-into-Claude triage.
|
|
250
|
+
*
|
|
251
|
+
* Production builds NEVER emit the overlay regardless of this flag:
|
|
252
|
+
* `shouldInjectOverlay()` triple-gates against `isDev`,
|
|
253
|
+
* `NODE_ENV=production`, and explicit opt-out.
|
|
254
|
+
*
|
|
255
|
+
* Set `false` to disable in dev (e.g. when capturing screenshots
|
|
256
|
+
* for docs). Default: `true`.
|
|
257
|
+
*/
|
|
258
|
+
errorOverlay?: boolean;
|
|
235
259
|
};
|
|
236
260
|
fsRoutes?: {
|
|
237
261
|
routesDir?: string;
|
|
@@ -265,6 +289,22 @@ export interface ManduConfig {
|
|
|
265
289
|
};
|
|
266
290
|
plugins?: ManduPlugin[];
|
|
267
291
|
hooks?: Partial<ManduHooks>;
|
|
292
|
+
/**
|
|
293
|
+
* Phase 18.ε — canonical request-level middleware chain.
|
|
294
|
+
*
|
|
295
|
+
* Array of {@link Middleware} executed in declaration order (outermost
|
|
296
|
+
* first) BEFORE route dispatch. Each middleware may short-circuit by
|
|
297
|
+
* returning a Response without calling `next()`, or mutate the
|
|
298
|
+
* downstream Response after `next()` returns. This is the Next.js
|
|
299
|
+
* `middleware.ts` / SvelteKit `handle` sequence analogue.
|
|
300
|
+
*
|
|
301
|
+
* Compose with {@link defineMiddleware} + bridge wrappers
|
|
302
|
+
* (`csrfMiddleware`, `sessionMiddleware`, `secureMiddleware`,
|
|
303
|
+
* `rateLimitMiddleware`) from `@mandujs/core/middleware`.
|
|
304
|
+
*
|
|
305
|
+
* @see `docs/architect/middleware-composition.md`
|
|
306
|
+
*/
|
|
307
|
+
middleware?: Middleware[];
|
|
268
308
|
}
|
|
269
309
|
|
|
270
310
|
export const CONFIG_FILES = [
|
package/src/config/validate.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { CONFIG_FILES, coerceConfig } from "./mandu";
|
|
|
5
5
|
import { readJsonFile } from "../utils/bun";
|
|
6
6
|
import type { ManduAdapter } from "../runtime/adapter";
|
|
7
7
|
import type { ManduPlugin, ManduHooks } from "../plugins/hooks";
|
|
8
|
+
import type { Middleware } from "../middleware/define";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* DNA-003: Strict mode schema helper
|
|
@@ -98,6 +99,12 @@ const BuildConfigSchema = z
|
|
|
98
99
|
minify: z.boolean().default(true),
|
|
99
100
|
sourcemap: z.boolean().default(false),
|
|
100
101
|
splitting: z.boolean().default(false),
|
|
102
|
+
/**
|
|
103
|
+
* Phase 18 — prerender static HTML for pages during `mandu build`.
|
|
104
|
+
* Default: `true` (every static page + every dynamic page whose
|
|
105
|
+
* module exports `generateStaticParams` is prerendered).
|
|
106
|
+
*/
|
|
107
|
+
prerender: z.boolean().default(true),
|
|
101
108
|
})
|
|
102
109
|
.strict();
|
|
103
110
|
|
|
@@ -264,6 +271,28 @@ const ManduHooksSchema = z.custom<Partial<ManduHooks>>(
|
|
|
264
271
|
{ message: "hooks must be an object" }
|
|
265
272
|
);
|
|
266
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Phase 18.ε — canonical request-level middleware. Each entry must be an
|
|
276
|
+
* object with a non-empty `name` string and a `handler` function.
|
|
277
|
+
* `match` is optional but must be a function when present. Zod cannot
|
|
278
|
+
* introspect closures, so this is a structural check; `defineMiddleware`
|
|
279
|
+
* enforces the same shape at definition time for the clearest DX error.
|
|
280
|
+
*/
|
|
281
|
+
const MiddlewareSchema = z.custom<Middleware>(
|
|
282
|
+
(v) => {
|
|
283
|
+
if (typeof v !== "object" || v === null) return false;
|
|
284
|
+
const obj = v as { name?: unknown; handler?: unknown; match?: unknown };
|
|
285
|
+
if (typeof obj.name !== "string" || obj.name.length === 0) return false;
|
|
286
|
+
if (typeof obj.handler !== "function") return false;
|
|
287
|
+
if (obj.match !== undefined && typeof obj.match !== "function") return false;
|
|
288
|
+
return true;
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
message:
|
|
292
|
+
"Each middleware must be an object with a non-empty `name` string, a `handler` function, and (optionally) a `match` function. Use `defineMiddleware({...})` to construct.",
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
|
|
267
296
|
export const ManduConfigSchema = z
|
|
268
297
|
.object({
|
|
269
298
|
adapter: AdapterConfigSchema.optional(),
|
|
@@ -295,6 +324,13 @@ export const ManduConfigSchema = z
|
|
|
295
324
|
observability: ObservabilityConfigSchema.default({}),
|
|
296
325
|
plugins: z.array(ManduPluginSchema).optional(),
|
|
297
326
|
hooks: ManduHooksSchema.optional(),
|
|
327
|
+
/**
|
|
328
|
+
* Phase 18.ε — request-level middleware chain. Array validated
|
|
329
|
+
* structurally (see {@link MiddlewareSchema}); no default, so
|
|
330
|
+
* omitting the field leaves the chain empty (zero-overhead
|
|
331
|
+
* passthrough at runtime).
|
|
332
|
+
*/
|
|
333
|
+
middleware: z.array(MiddlewareSchema).optional(),
|
|
298
334
|
})
|
|
299
335
|
.strict();
|
|
300
336
|
|