@mandujs/core 0.27.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.
@@ -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
 
@@ -147,6 +148,22 @@ export interface ManduConfig {
147
148
  realtime?: boolean;
148
149
  rules?: Record<string, GuardRuleSeverity>;
149
150
  contractRequired?: GuardRuleSeverity;
151
+ /**
152
+ * Issue #207 — hard-fail on direct `__generated__/` imports at the
153
+ * bundler level. When `true` (default), every `mandu dev` /
154
+ * `mandu build` pass installs the
155
+ * `mandu:block-generated-imports` Bun plugin, which throws
156
+ * `ForbiddenGeneratedImportError` the moment any source file
157
+ * resolves an import whose specifier contains `__generated__`.
158
+ *
159
+ * Set to `false` only when a migration path literally requires
160
+ * the legacy barrel re-export pattern. User code should always
161
+ * prefer `getGenerated()` from `@mandujs/core/runtime`; see
162
+ * https://mandujs.com/docs/architect/generated-access.
163
+ *
164
+ * Default: `true`.
165
+ */
166
+ blockGeneratedImport?: boolean;
150
167
  };
151
168
  build?: {
152
169
  outDir?: string;
@@ -216,6 +233,29 @@ export interface ManduConfig {
216
233
  * to re-read this comment to recover.
217
234
  */
218
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;
219
259
  };
220
260
  fsRoutes?: {
221
261
  routesDir?: string;
@@ -249,6 +289,22 @@ export interface ManduConfig {
249
289
  };
250
290
  plugins?: ManduPlugin[];
251
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[];
252
308
  }
253
309
 
254
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
@@ -80,6 +81,12 @@ const GuardConfigSchema = z
80
81
  exclude: z.array(z.string()).default([]),
81
82
  realtime: z.boolean().default(true),
82
83
  rules: z.record(z.enum(["error", "warn", "warning", "off"])).optional(),
84
+ /**
85
+ * Issue #207 — bundler-level hard-fail on direct `__generated__/`
86
+ * imports. Default `true`. Set `false` to opt out of the
87
+ * `mandu:block-generated-imports` Bun plugin.
88
+ */
89
+ blockGeneratedImport: z.boolean().default(true),
83
90
  })
84
91
  .strict();
85
92
 
@@ -92,6 +99,12 @@ const BuildConfigSchema = z
92
99
  minify: z.boolean().default(true),
93
100
  sourcemap: z.boolean().default(false),
94
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),
95
108
  })
96
109
  .strict();
97
110
 
@@ -258,6 +271,28 @@ const ManduHooksSchema = z.custom<Partial<ManduHooks>>(
258
271
  { message: "hooks must be an object" }
259
272
  );
260
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
+
261
296
  export const ManduConfigSchema = z
262
297
  .object({
263
298
  adapter: AdapterConfigSchema.optional(),
@@ -289,6 +324,13 @@ export const ManduConfigSchema = z
289
324
  observability: ObservabilityConfigSchema.default({}),
290
325
  plugins: z.array(ManduPluginSchema).optional(),
291
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(),
292
334
  })
293
335
  .strict();
294
336