/** * Phase 7.2 — Tier 2 vendor shim disk cache (Agent A / S2). * * # Why this exists * * `buildVendorShims` produces four small-but-deterministic JS bundles: * - `_vendor-react.js` / `_vendor-react-dom.js` / `_vendor-react-refresh.js` * / `_fast-refresh-runtime.js` (+ two JSX runtimes). * * The inputs are the upstream npm versions of `react` / `react-dom` / * `react-refresh` plus the shim source strings we hard-code in `build.ts`. * If none of those change, re-running Bun.build yields byte-identical output * — but it still costs ~100-160 ms (Windows) because `Bun.build` has to parse * and resolve each shim fresh every boot. * * Cold-start breakdown (`docs/bun/phase-7-1-diagnostics/cold-start-breakdown.md` * §3.B and §5 Tier 2) attributes ~80-120 ms of the 910 ms tmpdir cold start * to these rebuilds. Cache hit on warm workflow eliminates that cost. * * # Design * * - **Single atomic manifest** at `.mandu/vendor-cache/vendor-cache.json`. * Any mismatch → *every* cached output is treated as invalid and * `buildVendorShims` runs its full parallel build path. We do not support * per-entry invalidation because the outputs always move together (they * share the same React version). * * - **Five-part hash input** (order-preserved): * 1. `Bun.version` (runtime semantics may change between minor bumps) * 2. resolved `react/package.json.version` * 3. resolved `react-dom/package.json.version` * 4. resolved `react-refresh/package.json.version` * 5. `@mandujs/core/package.json.version` (shim source evolves with Mandu) * * Any of the five differing triggers a cold path. See the §6 risk section * of `docs/bun/phase-7-2-team-plan.md`. * * - **SHA-256 per entry** for tamper detection. If an output file was * truncated / edited / corrupted, the cached-hit path refuses to use it * and falls through to the full rebuild. `size` is a cheaper first-pass * check so we avoid hashing every byte for the common case. * * - **NO concurrency**: dev servers are single-process, so write-after-read * races don't happen. Two concurrent `mandu dev` invocations on the same * project WILL step on each other — this is the same story as the pre- * existing `.mandu/dev-cache/ssr` directory used by the SSR bundled * importer, which is also single-writer. * * - **Gitignored location**: `.mandu/` is already in the starter's * `.gitignore`, so branch switches preserve the cache but a hard clean * (`git clean -fdx`) triggers a rebuild — intentional because hard-cleaned * trees should not trust stale artifacts. * * # Error handling * * Every I/O path swallows errors by falling back to miss: * - manifest read fails → miss "no-manifest" * - JSON parse fails → miss "no-manifest" (treat as absent) * - any `fs.stat` / hash mismatch → miss "missing-entry" / "hash-mismatch" * * The writer is best-effort: if writing the manifest or a cached entry * fails, we log via perf (opt-in) but never block the build. Next boot * will either find a complete cache or hit a miss and rebuild. * * References: * docs/bun/phase-7-2-team-plan.md §2.2 * docs/bun/phase-7-1-diagnostics/cold-start-breakdown.md §§3.B, 5 * packages/core/src/bundler/vendor-cache-types.ts (the contract) */ import fs from "fs/promises"; import path from "path"; import { createHash } from "node:crypto"; import { type VendorCacheManifest, type VendorCacheResult, type VendorCacheEntry, VENDOR_CACHE_DIR, VENDOR_CACHE_FILENAME, VENDOR_CACHE_MAX_BYTES, } from "./vendor-cache-types"; // ============================================ // Public API // ============================================ /** * Input to {@link readVendorCache} and {@link writeVendorCache}. Callers * resolve these versions once (at the start of `buildVendorShims`) and * pass them to both functions so the hash input is stable. */ export interface VendorCacheKeyInput { bunVersion: string; reactVersion: string; reactDomVersion: string; reactRefreshVersion: string; manduCoreVersion: string; } /** * Logical id → on-disk filename map. Must stay in lock-step with * `buildVendorShims` in `build.ts`: any new shim added there needs its * logical id registered here so the cache knows which file to * preserve/restore. */ export const VENDOR_SHIM_FILES: Record = { "react": "_react.js", "react-dom": "_react-dom.js", "react-dom-client": "_react-dom-client.js", "jsx-runtime": "_jsx-runtime.js", "jsx-dev-runtime": "_jsx-dev-runtime.js", "react-refresh-runtime": "_vendor-react-refresh.js", "fast-refresh-glue": "_fast-refresh-runtime.js", } as const; /** Keys of {@link VENDOR_SHIM_FILES}. */ export type VendorShimId = keyof typeof VENDOR_SHIM_FILES; // ============================================ // Read path // ============================================ /** * Try to load the vendor cache for `rootDir`. Returns `hit` only when: * 1. The manifest file exists and parses cleanly. * 2. Every version field matches the requested inputs. * 3. Every entry's file exists, has the recorded size, and hashes back * to the stored SHA-256. * * All other cases return a typed `miss` so the caller can decide what to * log (perf markers fire here so a diagnostic pass doesn't need to read * the result type). * * The manifest is consulted at boot only — no lazy / partial reads. A * "size" field > {@link VENDOR_CACHE_MAX_BYTES} is treated as a miss so * pathological disk corruption can't feed a nonsensical 10 GB file into * a hash round-trip. */ export async function readVendorCache( rootDir: string, keys: VendorCacheKeyInput, ): Promise { const cacheDir = path.resolve(rootDir, VENDOR_CACHE_DIR); const manifestPath = path.join(cacheDir, VENDOR_CACHE_FILENAME); let rawManifest: string; try { rawManifest = await fs.readFile(manifestPath, "utf-8"); } catch { return { kind: "miss", reason: "no-manifest" }; } let manifest: VendorCacheManifest; try { manifest = JSON.parse(rawManifest) as VendorCacheManifest; } catch { // Corrupt / partial write — treat as absent. return { kind: "miss", reason: "no-manifest" }; } // Format version — reject unknown schemas (future-proofing). if (manifest.version !== 1) { return { kind: "miss", reason: "format-version" }; } // Version comparison — any mismatch invalidates everything. const versionCheck = compareVersions(manifest, keys); if (versionCheck !== null) { return { kind: "miss", reason: "version-mismatch", mismatchedField: versionCheck, }; } // Size check (cheap) before hash check (expensive). for (const [logicalId, entry] of Object.entries(manifest.entries)) { const entryPath = path.join(cacheDir, entry.path); let stat; try { stat = await fs.stat(entryPath); } catch { return { kind: "miss", reason: "missing-entry" }; } if (stat.size !== entry.size) { return { kind: "miss", reason: "size-mismatch" }; } if (stat.size > VENDOR_CACHE_MAX_BYTES) { // Pathological — refuse to read. return { kind: "miss", reason: "size-mismatch" }; } // Hash check — full read. Entries are small (~25 KB total today). let buf; try { buf = await fs.readFile(entryPath); } catch { return { kind: "miss", reason: "missing-entry" }; } const actualHash = sha256(buf); if (actualHash !== entry.hash) { return { kind: "miss", reason: "hash-mismatch" }; } // Swallow unused loop variable to satisfy no-unused-vars. void logicalId; } return { kind: "hit", manifest }; } // ============================================ // Write path // ============================================ /** * Input for {@link writeVendorCache} — one entry per shim produced by * `buildVendorShims`. The `absPath` points at the freshly-built file in * `.mandu/client/` which we copy into the cache dir to avoid depending on * downstream `buildClientBundles` side-effects. */ export interface VendorCacheWriteEntry { logicalId: string; /** Absolute path to the built shim in `.mandu/client/`. */ absPath: string; } /** * Persist the freshly-built shims + manifest to `.mandu/vendor-cache/`. * * - Creates the cache dir if missing. * - Copies each entry's file by bytes (not a symlink — Windows junction * semantics differ between filesystems, and a hard-copy sidesteps the * "rebuild deletes the original" GC hazard). * - Writes the manifest LAST so a crashed write leaves the cache in a * safe state (missing-entry on next read → miss). * * Best-effort: returns `false` on any write failure but does not throw, * so the build continues even if the disk is read-only. */ export async function writeVendorCache( rootDir: string, keys: VendorCacheKeyInput, entries: VendorCacheWriteEntry[], ): Promise { const cacheDir = path.resolve(rootDir, VENDOR_CACHE_DIR); try { await fs.mkdir(cacheDir, { recursive: true }); } catch { return false; } const manifestEntries: Record = {}; for (const entry of entries) { let buf: Buffer; try { buf = await fs.readFile(entry.absPath); } catch { // Source file missing (build must have failed earlier) — skip // this entry. An incomplete cache manifest is still safe because // the read path checks every referenced entry. continue; } if (buf.byteLength > VENDOR_CACHE_MAX_BYTES) { continue; } const fileName = path.basename(entry.absPath); const destPath = path.join(cacheDir, fileName); try { await fs.writeFile(destPath, buf); } catch { continue; } manifestEntries[entry.logicalId] = { path: fileName, size: buf.byteLength, hash: sha256(buf), }; } // Nothing to cache → skip writing a stub manifest (would cause miss on // next boot). Callers should interpret this as a best-effort success. if (Object.keys(manifestEntries).length === 0) { return false; } const manifest: VendorCacheManifest = { version: 1, bunVersion: keys.bunVersion, reactVersion: keys.reactVersion, reactDomVersion: keys.reactDomVersion, reactRefreshVersion: keys.reactRefreshVersion, manduCoreVersion: keys.manduCoreVersion, entries: manifestEntries, generatedAt: new Date().toISOString(), }; try { await fs.writeFile( path.join(cacheDir, VENDOR_CACHE_FILENAME), JSON.stringify(manifest, null, 2), ); } catch { return false; } return true; } // ============================================ // Restore path — used on cache hit to re-populate `.mandu/client/` // ============================================ /** * Copy every manifest-referenced file from the cache dir into `outDir` * (`.mandu/client/`). Returns the list of absolute output paths, in the * same order as the manifest's `entries` object iteration. * * Called on cache hit from `buildVendorShims` — skips re-running the * Bun.build calls entirely. If a copy fails (partial disk read, for * instance) the caller should fall back to rebuild — we return `null` * from this function in that case to make the degraded path explicit. */ export async function restoreVendorCache( rootDir: string, manifest: VendorCacheManifest, outDir: string, ): Promise | null> { const cacheDir = path.resolve(rootDir, VENDOR_CACHE_DIR); try { await fs.mkdir(outDir, { recursive: true }); } catch { return null; } const result = new Map(); for (const [logicalId, entry] of Object.entries(manifest.entries)) { const src = path.join(cacheDir, entry.path); const dst = path.join(outDir, path.basename(entry.path)); try { const buf = await fs.readFile(src); // Phase 7.2.R3 M-02 — TOCTOU hardening. `readVendorCache` already // verified SHA-256/size, but an attacker with filesystem write // access could swap `_vendor-*.js` between the read and this // copy. Re-hash the buffer we are about to copy — if it diverges // from the manifest entry, bail out and force rebuild instead of // shipping attacker-controlled JS into `.mandu/client/`. const actualSize = buf.byteLength; if (actualSize !== entry.size) return null; const actualHash = createHash("sha256").update(buf).digest("hex"); if (actualHash !== entry.hash) return null; await fs.writeFile(dst, buf); result.set(logicalId, dst); } catch { // Any partial failure invalidates the restore — caller rebuilds. return null; } } return result; } // ============================================ // Key resolution — package.json probing // ============================================ /** * Resolve the key input from a project root. Reads the requested npm * packages' `package.json` via Node resolution from `rootDir`, with a * fallback lookup through Bun's resolution if the standard path does * not apply (eg. monorepo hoisting). * * We do NOT use `require.resolve` — it isn't available under pure ESM * in Bun. Instead we read `node_modules//package.json` directly * and walk up the tree if needed. * * The `manduCoreVersion` is sourced from our own package.json at build * time (it's read through `import.meta.url`). This guards against the * project's own React/React-DOM being rebuilt against a newer Mandu * shim source. */ export async function resolveVendorCacheKeys( rootDir: string, ): Promise { const bunVersion = typeof Bun !== "undefined" ? Bun.version : "0.0.0"; const [reactVersion, reactDomVersion, reactRefreshVersion, manduCoreVersion] = await Promise.all([ resolvePackageVersion(rootDir, "react"), resolvePackageVersion(rootDir, "react-dom"), resolvePackageVersion(rootDir, "react-refresh"), resolveManduCoreVersion(rootDir), ]); return { bunVersion, reactVersion, reactDomVersion, reactRefreshVersion, manduCoreVersion, }; } /** * Walk up from `rootDir` looking for * `node_modules//package.json`, return its `version` field. Falls * back to `"unknown"` if nothing is found — intentional so missing a * dependency (eg. `react-refresh` in pure-SSR projects) doesn't * throw. A stable `"unknown"` value still permits cache reuse as long * as the project isn't mutated. */ async function resolvePackageVersion( rootDir: string, pkgName: string, ): Promise { let current = path.resolve(rootDir); const { root } = path.parse(current); // Cap the walk at 10 levels to avoid pathological symlink loops. for (let i = 0; i < 10; i++) { const candidate = path.join( current, "node_modules", pkgName, "package.json", ); try { const raw = await fs.readFile(candidate, "utf-8"); const parsed = JSON.parse(raw) as { version?: string }; if (typeof parsed.version === "string" && parsed.version.length > 0) { return parsed.version; } return "unknown"; } catch { // Not here — keep walking. } if (current === root) break; const next = path.dirname(current); if (next === current) break; current = next; } return "unknown"; } /** * Resolve `@mandujs/core` own version from the repo relative to this * source file. We can't reuse `resolvePackageVersion(rootDir, "@mandujs/core")` * because during local dev the core is linked through `workspace:*` and * doesn't exist in `/node_modules` — it lives in the monorepo's * `packages/core/package.json`. */ async function resolveManduCoreVersion(rootDir: string): Promise { // Try the project's own node_modules first (published install path). const published = await resolvePackageVersion(rootDir, "@mandujs/core"); if (published !== "unknown") { return published; } // Fallback: walk up from this source file. `import.meta.dir` is the // directory containing this module. `/../../package.json` is // `packages/core/package.json` in the monorepo layout. try { const corePackagePath = path.resolve(import.meta.dir, "..", "..", "package.json"); const raw = await fs.readFile(corePackagePath, "utf-8"); const parsed = JSON.parse(raw) as { version?: string; name?: string }; if ( parsed.name === "@mandujs/core" && typeof parsed.version === "string" ) { return parsed.version; } } catch { // Fall through. } return "unknown"; } // ============================================ // Internal helpers // ============================================ /** * Compare the manifest's version fields against the requested input. * Returns `null` on match, or the mismatched field name on miss. The * field names are restricted to the keys actually gated by the cache * key — `generatedAt` is NOT part of the hash input. */ function compareVersions( manifest: VendorCacheManifest, keys: VendorCacheKeyInput, ): | null | "bunVersion" | "reactVersion" | "reactDomVersion" | "reactRefreshVersion" | "manduCoreVersion" { if (manifest.bunVersion !== keys.bunVersion) return "bunVersion"; if (manifest.reactVersion !== keys.reactVersion) return "reactVersion"; if (manifest.reactDomVersion !== keys.reactDomVersion) return "reactDomVersion"; if (manifest.reactRefreshVersion !== keys.reactRefreshVersion) return "reactRefreshVersion"; if (manifest.manduCoreVersion !== keys.manduCoreVersion) return "manduCoreVersion"; return null; } /** SHA-256 of a buffer, returned as a hex string. */ function sha256(buf: Buffer | Uint8Array): string { const h = createHash("sha256"); h.update(buf); return h.digest("hex"); }