@mandujs/core 0.20.10 → 0.22.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.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Phase 7.1 B-4 — Fast Refresh boundary injection plugin.
3
+ *
4
+ * This Bun.build plugin runs in the `onLoad` phase for every file whose
5
+ * path matches `.client.tsx?` / `.island.tsx?`. It reads the source,
6
+ * appends a boundary-registration epilogue that calls
7
+ * `window.__MANDU_HMR__.acceptFile(<module url>)`, and hands the
8
+ * augmented source back to Bun for transformation. No AST traversal is
9
+ * required: the appended code is syntactically isolated (semicolon-
10
+ * terminated inside a guard) and always parses regardless of the source
11
+ * it's being concatenated to.
12
+ *
13
+ * Why `onLoad` and not a define/transform hook?
14
+ * ─────────────────────────────────────────────
15
+ * Bun's plugin API (`onResolve`, `onLoad`, `onStart`) does not expose a
16
+ * post-transform AST pass. The `onLoad` hook is the only point where we
17
+ * can affect the source before `reactFastRefresh` runs; it receives the
18
+ * raw file bytes and returns `{ contents, loader }`. Vite does this via
19
+ * a Rollup `transform` hook for the same reason.
20
+ *
21
+ * The emitted epilogue is intentionally defensive:
22
+ *
23
+ * if (typeof window !== "undefined" && window.__MANDU_HMR__) {
24
+ * window.__MANDU_HMR__.acceptFile(<url>);
25
+ * }
26
+ *
27
+ * This survives three edge cases:
28
+ * 1. Module evaluated during SSR (no window) → guard short-circuits.
29
+ * 2. Preamble not yet loaded → `__MANDU_HMR__` absent,
30
+ * no throw.
31
+ * 3. Same module re-evaluated after hot swap → `acceptFile` is
32
+ * idempotent by design.
33
+ *
34
+ * Skipping mechanics:
35
+ * - In production (`disabled: true`) the plugin short-circuits so we
36
+ * don't ship refresh code to users.
37
+ * - Files whose path includes `node_modules` are never eligible.
38
+ * - A file that already contains a literal `window.__MANDU_HMR__` call
39
+ * is trusted — we don't append a second one (idempotent even across
40
+ * multiple `Bun.build` passes that bundle a pre-bundled module).
41
+ * - URLs exceeding `MAX_ACCEPT_FILE_URL_LEN` (2 KB) or containing
42
+ * characters that could escape the inline script context (`</`,
43
+ * newline, nul) are rejected with a warning — no boundary emitted.
44
+ * See `docs/security/phase-7-1-audit.md` §3 L-01.
45
+ *
46
+ * References:
47
+ * docs/bun/phase-7-1-diagnostics/fast-refresh-strategy.md §4 (4 phase breakdown)
48
+ * docs/bun/phase-7-1-team-plan.md §4 Agent B
49
+ * docs/bun/phase-7-2-team-plan.md §3 Agent C H3 (URL length cap)
50
+ * docs/security/phase-7-1-audit.md §3 L-01
51
+ * https://bun.com/docs/runtime/plugins
52
+ * packages/core/src/runtime/fast-refresh-types.ts
53
+ */
54
+
55
+ import fs from "fs/promises";
56
+ import type { BunPlugin } from "bun";
57
+
58
+ import type {
59
+ BoundaryDecision,
60
+ FastRefreshPluginOptions,
61
+ } from "../runtime/fast-refresh-types";
62
+
63
+ // ============================================
64
+ // Exported constants (shared with tests)
65
+ // ============================================
66
+
67
+ /**
68
+ * Default include filter. Matches `foo.client.tsx`, `foo.client.ts`,
69
+ * `foo.island.tsx`, `foo.island.ts`. Deliberately strict — we do NOT
70
+ * default to every `.tsx` to stay aligned with Mandu's component model
71
+ * (only explicit client / island files are HMR boundaries; pages and
72
+ * layouts full-reload to re-run their SSR slot).
73
+ */
74
+ export const DEFAULT_INCLUDE = /\.(client|island)\.tsx?$/;
75
+
76
+ /**
77
+ * Guard regex that detects whether a file already contains an
78
+ * `acceptFile` call, so multi-pass bundling (e.g. pre-bundled demo
79
+ * packages) doesn't stack registrations.
80
+ */
81
+ const ALREADY_INJECTED = /__MANDU_HMR__\s*(?:\?\.)?\s*\.acceptFile\s*\(/;
82
+
83
+ /**
84
+ * Phase 7.2 H3 (L-01 audit): cap the length of a URL we will accept into
85
+ * an `acceptFile()` call. 2 KB is ~8× larger than any realistic Mandu
86
+ * tmpdir fixture and matches `MAX_BOUNDARY_URL_LEN` suggested by the
87
+ * audit. Beyond this we refuse to append the boundary — the file simply
88
+ * falls back to full-reload HMR, which is the safe degradation path.
89
+ */
90
+ export const MAX_ACCEPT_FILE_URL_LEN = 2048;
91
+
92
+ /**
93
+ * Phase 7.2 H3 (L-01 audit): characters or substrings that, if present
94
+ * inside the URL string, could break out of the emitted inline `<script>`
95
+ * context. `</` is the classic HTML-in-JS escape; `\n` / `\r` / `\u2028`
96
+ * / `\u2029` are JS literal terminators; `\x00`-`\x1f` are control chars
97
+ * the parser will complain about anyway. We reject outright rather than
98
+ * try to re-escape — these paths are filesystem-authored so the
99
+ * legitimate case never produces them.
100
+ */
101
+ const UNSAFE_URL_SEQUENCES = ["</", "\u2028", "\u2029", "<script", "<!--"] as const;
102
+ const UNSAFE_URL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\r\n]/;
103
+
104
+ /**
105
+ * Comment-neutral detector for `node_modules`. Using a forward-slashed
106
+ * string match is sufficient because Mandu normalizes paths to posix
107
+ * internally (see `bundler/dev.ts` `normalizeFsPath`).
108
+ */
109
+ function isInNodeModules(filePath: string): boolean {
110
+ return (
111
+ filePath.includes("/node_modules/") || filePath.includes("\\node_modules\\")
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Runtime predicate that decides whether `moduleUrl` is safe to bake
117
+ * into the inline `<script>` that the plugin emits. Exported so tests
118
+ * and diagnostic callers can share the exact rule.
119
+ *
120
+ * The checks are ordered cheapest-first (length before char-regex before
121
+ * substring scan) so a successful path short-circuits early. Returns
122
+ * `{ ok: true }` for accepted URLs and `{ ok: false, reason }` for
123
+ * rejections — the reason string feeds into the warning printed at
124
+ * injection time so developers see exactly why their file went
125
+ * full-reload.
126
+ */
127
+ export function validateAcceptFileUrl(
128
+ moduleUrl: string,
129
+ ): { ok: true } | { ok: false; reason: string } {
130
+ if (typeof moduleUrl !== "string") {
131
+ return { ok: false, reason: "moduleUrl is not a string" };
132
+ }
133
+ if (moduleUrl.length === 0) {
134
+ return { ok: false, reason: "moduleUrl is empty" };
135
+ }
136
+ if (moduleUrl.length > MAX_ACCEPT_FILE_URL_LEN) {
137
+ return {
138
+ ok: false,
139
+ reason: `moduleUrl length ${moduleUrl.length} exceeds cap ${MAX_ACCEPT_FILE_URL_LEN}`,
140
+ };
141
+ }
142
+ if (UNSAFE_URL_CHARS.test(moduleUrl)) {
143
+ return { ok: false, reason: "moduleUrl contains control / newline chars" };
144
+ }
145
+ for (const seq of UNSAFE_URL_SEQUENCES) {
146
+ if (moduleUrl.includes(seq)) {
147
+ return {
148
+ ok: false,
149
+ reason: `moduleUrl contains unsafe substring "${seq}"`,
150
+ };
151
+ }
152
+ }
153
+ return { ok: true };
154
+ }
155
+
156
+ // ============================================
157
+ // Pure transform — exported for direct unit tests
158
+ // ============================================
159
+
160
+ /**
161
+ * Append the boundary epilogue to `source` unless a prior pass already
162
+ * did so. Pure function; no file I/O, no side effects. Takes a
163
+ * `moduleUrl` string the plugin has already resolved (usually a
164
+ * normalized form of the on-disk path).
165
+ *
166
+ * Phase 7.2 H3: hardened with `validateAcceptFileUrl`. When the URL
167
+ * fails validation we emit the source unchanged and log a single
168
+ * `console.warn`. The warning path is intentionally non-fatal — a
169
+ * pathological URL that somehow leaks through is a DX concern
170
+ * (full-reload HMR still works) not a correctness one.
171
+ *
172
+ * The returned string is always safe to pass to Bun's `tsx` loader —
173
+ * the epilogue is wrapped in a `typeof window` guard, so a single pass
174
+ * of semicolon insertion at the end of the user's source never produces
175
+ * an unclosed JSX expression or a dangling string.
176
+ */
177
+ export function appendBoundary(source: string, moduleUrl: string): string {
178
+ if (ALREADY_INJECTED.test(source)) return source;
179
+
180
+ // Phase 7.2 H3: URL length + escape cap. Rejected URLs fall through
181
+ // to full-reload HMR without a boundary, preserving correctness.
182
+ const check = validateAcceptFileUrl(moduleUrl);
183
+ if (!check.ok) {
184
+ // eslint-disable-next-line no-console
185
+ console.warn(
186
+ `[Mandu Fast Refresh] acceptFile URL rejected (${check.reason}); ` +
187
+ `falling back to full-reload for this module.`,
188
+ );
189
+ return source;
190
+ }
191
+
192
+ // JSON.stringify here does double duty: (1) it escapes any
193
+ // backslashes / quotes in the URL path (important on Windows), and
194
+ // (2) it produces a valid JS string literal even for pathological
195
+ // inputs (unicode, newline, etc.). We explicitly do NOT accept
196
+ // moduleUrls from user input — the plugin controls what gets passed.
197
+ const urlLiteral = JSON.stringify(moduleUrl);
198
+
199
+ // The newline before our block is required: if `source` ends with a
200
+ // `//` line comment, concatenation without `\n` would comment out
201
+ // our guard. Trailing newline after ensures well-formed EOF.
202
+ return (
203
+ source +
204
+ `\n;if (typeof window !== "undefined" && window.__MANDU_HMR__) {` +
205
+ ` window.__MANDU_HMR__.acceptFile(${urlLiteral}); }\n`
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Classify a file against the plugin's include filter without touching
211
+ * the filesystem. Surfaced so tests and dev-mode diagnostics can answer
212
+ * "would this file have been transformed?" without invoking Bun.build.
213
+ */
214
+ export function classifyBoundary(
215
+ filePath: string,
216
+ options: FastRefreshPluginOptions = {},
217
+ ): BoundaryDecision {
218
+ if (options.disabled === true) {
219
+ return { accepted: false, reason: "disabled" };
220
+ }
221
+ if (isInNodeModules(filePath)) {
222
+ return { accepted: false, reason: "non-react" };
223
+ }
224
+ const include = options.include ?? DEFAULT_INCLUDE;
225
+ if (!include.test(filePath)) {
226
+ return { accepted: false, reason: "excluded-by-include" };
227
+ }
228
+ return {
229
+ accepted: true,
230
+ reason: "matched-include",
231
+ source: filePath,
232
+ };
233
+ }
234
+
235
+ // ============================================
236
+ // Bun plugin factory
237
+ // ============================================
238
+
239
+ /**
240
+ * Build a `BunPlugin` that implements Fast Refresh boundary injection.
241
+ * Consumed by `bundler/build.ts`'s `buildIsland` / `buildPerIslandBundle`
242
+ * calls — those pass it in through `plugins: [...]` alongside whatever
243
+ * else the build configures.
244
+ *
245
+ * When `options.disabled` is true, a no-op plugin is returned; this is
246
+ * the production path, where we never want refresh injection on the
247
+ * wire.
248
+ */
249
+ export function fastRefreshPlugin(
250
+ options: FastRefreshPluginOptions = {},
251
+ ): BunPlugin {
252
+ const include = options.include ?? DEFAULT_INCLUDE;
253
+ const disabled = options.disabled === true;
254
+
255
+ return {
256
+ name: "mandu:fast-refresh-boundary",
257
+ setup(build) {
258
+ if (disabled) {
259
+ // Register a no-op onLoad so the plugin still appears in Bun's
260
+ // internal diagnostics, but it never matches anything.
261
+ return;
262
+ }
263
+
264
+ build.onLoad({ filter: include }, async (args) => {
265
+ // Skip anything under node_modules even if the filter would
266
+ // have caught it. Pre-bundled packages aren't our concern.
267
+ if (isInNodeModules(args.path)) {
268
+ return undefined;
269
+ }
270
+
271
+ let source: string;
272
+ try {
273
+ source = await fs.readFile(args.path, "utf-8");
274
+ } catch (err) {
275
+ // Bubble up — Bun will surface the read failure as a build
276
+ // error, which is the correct behavior (a file we matched
277
+ // but can't read is a genuine fault).
278
+ throw err;
279
+ }
280
+
281
+ // Normalize to forward slashes so the registered URL is
282
+ // consistent regardless of platform. `dispatchReplacement`
283
+ // uses the same normalization shape — this is load-bearing.
284
+ const normalizedUrl = args.path.replace(/\\/g, "/");
285
+
286
+ return {
287
+ contents: appendBoundary(source, normalizedUrl),
288
+ loader: (args.path.endsWith(".tsx") ? "tsx" : "ts") as
289
+ | "tsx"
290
+ | "ts",
291
+ };
292
+ });
293
+ },
294
+ };
295
+ }
296
+
297
+ // ============================================
298
+ // Test helpers
299
+ // ============================================
300
+
301
+ /**
302
+ * Expose the `ALREADY_INJECTED` regex so tests can assert that
303
+ * idempotency is governed by a single source of truth rather than
304
+ * re-deriving the pattern.
305
+ * @internal
306
+ */
307
+ export const _testOnly_ALREADY_INJECTED = ALREADY_INJECTED;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Phase 7.0 — Shared HMR types (Vite-compat wire format + `ManduHot` runtime API)
3
+ *
4
+ * This file is the CONTRACT between Agents A (reliability + #188), B
5
+ * (incremental bundled import), and C (Vite-compat `import.meta.hot` +
6
+ * HMR replay + layout-update). Do NOT add logic here — pure types only.
7
+ *
8
+ * Source of truth for:
9
+ * - `ViteHMRPayload` — wire format identical to Vite's HMR WebSocket
10
+ * payload, so external devtools / IDE extensions
11
+ * that speak Vite work against Mandu out of the box.
12
+ * - `HMREventName` — Vite built-in event names the client may `on`.
13
+ * - `ManduHot` — runtime-side `import.meta.hot` surface (subset).
14
+ * - `HMRReplayEnvelope` — server-side queue entry for replay after reconnect.
15
+ * - `CoalescedChange` — batched file change handed to the rebuild path.
16
+ *
17
+ * References:
18
+ * docs/bun/phase-7-team-plan.md §3.1
19
+ * docs/bun/phase-7-diagnostics/industry-benchmark.md §2 (Vite API spec)
20
+ * packages/core/src/bundler/dev.ts:453 (pre-existing `HMRMessage` — kept)
21
+ */
22
+
23
+ // ============================================
24
+ // Vite-compat wire format
25
+ // ============================================
26
+
27
+ /**
28
+ * Payload shapes identical to Vite 6 HMR WebSocket wire format.
29
+ *
30
+ * Mandu broadcasts these ALONGSIDE the internal `HMRMessage` shape
31
+ * (dev.ts:453) so a single WS connection serves both:
32
+ * - Mandu's own client (consumes the richer internal shape)
33
+ * - External devtools / IDE plugins (consume only Vite-compat events)
34
+ *
35
+ * The payload structure mirrors Vite's to guarantee compatibility. Do NOT
36
+ * reshape for "consistency" with the internal format — the value is that
37
+ * the wire bytes are byte-equivalent to what Vite emits.
38
+ */
39
+ export type ViteHMRPayload =
40
+ | { type: "connected" }
41
+ | {
42
+ type: "update";
43
+ updates: Array<{
44
+ type: "js-update" | "css-update";
45
+ path: string;
46
+ acceptedPath: string;
47
+ timestamp: number;
48
+ }>;
49
+ }
50
+ | { type: "full-reload"; path?: string }
51
+ | { type: "prune"; paths: string[] }
52
+ | {
53
+ type: "error";
54
+ err: {
55
+ message: string;
56
+ stack?: string;
57
+ id?: string;
58
+ frame?: string;
59
+ plugin?: string;
60
+ loc?: { file: string; line: number; column: number };
61
+ };
62
+ }
63
+ | { type: "custom"; event: string; data?: unknown };
64
+
65
+ /**
66
+ * Phase 7.2 — HDR (Hot Data Revalidation) payload.
67
+ *
68
+ * Emitted when a `.slot.ts` file changes. Unlike `full-reload`, the
69
+ * client receives this and re-invokes the route's loader to refetch
70
+ * props while the React tree stays mounted — form input, scroll,
71
+ * focused element all survive. Modeled after Remix's HDR.
72
+ *
73
+ * The client-side handler (runtime/hmr-client.ts `dispatchReplacement`
74
+ * in Phase 7.2) wraps the props update in `React.startTransition` so
75
+ * the browser doesn't flash an intermediate state.
76
+ */
77
+ export interface HDRPayload {
78
+ /** Discriminator — NOT a Vite-compat payload (Mandu internal only). */
79
+ type: "slot-refetch";
80
+ /** Route id whose loader must re-invoke. Matches manifest `route.id`. */
81
+ routeId: string;
82
+ /** Absolute path of the slot file that changed — for logging + dedup. */
83
+ slotPath: string;
84
+ /** Monotonic per-server-boot id — compatible with the replay buffer. */
85
+ rebuildId: number;
86
+ /** Unix ms — coalescing window for back-to-back edits. */
87
+ timestamp: number;
88
+ }
89
+
90
+ /**
91
+ * Vite built-in event names a user or plugin may listen for via
92
+ * `import.meta.hot.on()`. Phase 7.0 v0.1 emits the first 4; the rest are
93
+ * Phase 7.1+ additions.
94
+ */
95
+ export type HMREventName =
96
+ | "vite:beforeUpdate"
97
+ | "vite:afterUpdate"
98
+ | "vite:beforeFullReload"
99
+ | "vite:error"
100
+ | "vite:beforePrune" // 7.1
101
+ | "vite:invalidate" // 7.1
102
+ | "vite:ws:disconnect" // 7.1
103
+ | "vite:ws:connect"; // 7.1
104
+
105
+ // ============================================
106
+ // Runtime `import.meta.hot` — Mandu subset
107
+ // ============================================
108
+
109
+ /**
110
+ * Runtime surface exposed as `import.meta.hot` in user/framework code.
111
+ *
112
+ * Phase 7.0 v0.1 supports:
113
+ * - accept (self, self+cb, dep+cb)
114
+ * - dispose
115
+ * - data
116
+ * - invalidate
117
+ * - on (4 built-in events)
118
+ *
119
+ * Deferred to Phase 7.1+:
120
+ * - accept([deps], cb)
121
+ * - prune
122
+ * - send, off
123
+ * - custom events
124
+ * - decline (legacy — we treat it as accept() no-op if anyone calls it)
125
+ *
126
+ * Design constraint: the `import.meta.hot.accept(` string must appear in
127
+ * the user's source verbatim for the bundler to recognize it as an HMR
128
+ * boundary (same static-analysis rule Vite enforces).
129
+ */
130
+ export interface ManduHot {
131
+ /**
132
+ * Per-module state preserved across HMR updates. `data` is carried over
133
+ * when a module is hot-replaced; assign fields onto it, don't reassign
134
+ * the object.
135
+ */
136
+ readonly data: Record<string, unknown>;
137
+
138
+ /**
139
+ * Self-accept the module. Two overloads:
140
+ * - `accept()` — no callback. The importer's `accept` runs instead.
141
+ * - `accept(cb)` — receive the new module namespace.
142
+ */
143
+ accept(cb?: (newModule: unknown) => void): void;
144
+
145
+ /**
146
+ * Accept an update to a dependency path. The path must be a string
147
+ * literal at the call site (static analysis requirement).
148
+ */
149
+ accept(dep: string, cb: (newDep: unknown) => void): void;
150
+
151
+ /**
152
+ * Register a cleanup to run immediately before this module is replaced.
153
+ * The passed callback receives `data` so you can stash state.
154
+ */
155
+ dispose(cb: (data: Record<string, unknown>) => void): void;
156
+
157
+ /**
158
+ * Bail out of the current accept cycle and propagate the update to
159
+ * importers — used when the new module is incompatible with the old
160
+ * one (e.g. breaking API change).
161
+ */
162
+ invalidate(message?: string): void;
163
+
164
+ /**
165
+ * Subscribe to Vite-compatible lifecycle events.
166
+ */
167
+ on(event: HMREventName, cb: (payload: unknown) => void): void;
168
+ }
169
+
170
+ /**
171
+ * Per-module HMR context factory — what `createHMRClientScript` (Agent C)
172
+ * returns. The bundler rewrites `import.meta.hot` to a call that returns
173
+ * one of these.
174
+ */
175
+ export interface ManduHotContextFactory {
176
+ (moduleUrl: string): ManduHot;
177
+ }
178
+
179
+ // ============================================
180
+ // Replay (B8)
181
+ // ============================================
182
+
183
+ /**
184
+ * A broadcast envelope kept in the server's replay buffer. When a client
185
+ * reconnects with `?since=<id>` on the WS URL, the server re-sends every
186
+ * envelope with `id > since` so nothing is lost across short dropouts.
187
+ *
188
+ * The buffer is a bounded ring — `MAX_REPLAY_BUFFER` entries. Anything
189
+ * older is dropped, and reconnecting clients with a too-old `since` are
190
+ * forced to a `full-reload` (the safe fallback).
191
+ */
192
+ export interface HMRReplayEnvelope {
193
+ /** Monotonically increasing per-server-boot. Resets to 0 on restart. */
194
+ id: number;
195
+ /** Unix ms when broadcast was queued. */
196
+ timestamp: number;
197
+ /** The payload that was / will be sent. */
198
+ payload: ViteHMRPayload;
199
+ }
200
+
201
+ /** Bounded buffer size. Anything older than this is pruned. */
202
+ export const MAX_REPLAY_BUFFER = 128;
203
+
204
+ /** "Missed too much — full reload" threshold in ms. */
205
+ export const REPLAY_MAX_AGE_MS = 60_000;
206
+
207
+ // ============================================
208
+ // Coalesced file change (B2 + B6)
209
+ // ============================================
210
+
211
+ /**
212
+ * A batched change produced by Agent A's per-file debounce + Set-based
213
+ * `pendingBuildSet`. The rebuild path consumes one of these per tick, not
214
+ * one per raw fs event — this is the fix for B2 (single-slot drop) and B6
215
+ * (global single timer).
216
+ *
217
+ * `kind` categorizes the changes so the rebuild path can short-circuit
218
+ * ("mixed" → fall through to common-dir rebuild; "islands-only" → skip
219
+ * framework bundles).
220
+ */
221
+ export interface CoalescedChange {
222
+ /** Absolute, normalized paths. No duplicates. */
223
+ files: readonly string[];
224
+ /** Earliest raw fs event timestamp (Date.now()). */
225
+ firstSeenAt: number;
226
+ /** Latest raw fs event timestamp. */
227
+ lastSeenAt: number;
228
+ /**
229
+ * Categorical summary derived from path classification. Pre-computed so
230
+ * the rebuild path doesn't re-scan the file list.
231
+ */
232
+ kind:
233
+ | "islands-only" // *.client.tsx / *.island.tsx only
234
+ | "ssr-only" // page.tsx / layout.tsx / slot.ts only
235
+ | "common-dir" // src/** changes (may fan out to any route)
236
+ | "css-only" // *.css only
237
+ | "api-only" // route.ts (API)
238
+ | "config-reload" // mandu.config.ts / .env — server restart
239
+ | "resource-regen" // *.resource.ts / *.contract.ts — code-gen
240
+ | "mixed"; // more than one category
241
+ }
242
+
243
+ // ============================================
244
+ // Scenario matrix re-export for cross-module use
245
+ // ============================================
246
+
247
+ /**
248
+ * Re-exported so consumers can `import { ... } from "../bundler/hmr-types"`
249
+ * without a second import line. The canonical definitions live in
250
+ * `./scenario-matrix.ts` to keep this file logic-free.
251
+ */
252
+ export type { ProjectForm, ChangeKind, ScenarioCell } from "./scenario-matrix";