@mandujs/core 0.28.0 → 0.29.1

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,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;
@@ -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,
@@ -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 = [
@@ -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