@mandujs/core 0.21.0 → 0.22.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.
- package/package.json +101 -69
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +310 -18
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1611 -59
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +11 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +518 -20
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +58 -0
|
@@ -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";
|