|
// Astro integration that injects <link rel="modulepreload"> tags into every |
|
// HTML response. Astro (as of v5) doesn't emit these hints, so the browser |
|
// discovers JS dependencies one hop at a time — each static import adds a |
|
// network round-trip. By walking the Vite chunk graph at build time and |
|
// injecting the full dependency set as modulepreload hints, the browser can |
|
// start fetching every module in parallel from the very first HTML parse. |
|
// |
|
// Architecture: |
|
// 1. Astro builds the server BEFORE the client. The middleware (which |
|
// lives in the server bundle) needs the client chunk graph — but it |
|
// doesn't exist yet when the server is compiled. We solve this with a |
|
// placeholder + post-patch strategy: |
|
// |
|
// 2. During the server build, the virtual module |
|
// ("virtual:modulepreload-manifest") returns |
|
// JSON.parse('{"__empty__":1}') — a runtime call the bundler can't |
|
// evaluate, preventing tree-shaking of the middleware body. |
|
// |
|
// 3. During the client build's generateBundle hook, the Vite plugin walks |
|
// each chunk's transitive dependencies (static + dynamic) and records |
|
// the manifest. Then in writeBundle (after the client output is on |
|
// disk), the plugin patches the *server* output files — replacing the |
|
// JSON.parse placeholder with the real manifest object literal. |
|
// |
|
// 4. The Vercel adapter (or other adapters) re-bundle the server output |
|
// AFTER this patch, via astro:build:done, so the final deployed |
|
// function always contains the correct manifest. |
|
// |
|
// Approaches that didn't work: |
|
// |
|
// - File-based manifest (node_modules/.cache/modulepreload-manifest.json): |
|
// generateBundle wrote the manifest to disk; the virtual module's load |
|
// hook read it back. Failed because the server build runs FIRST — load |
|
// always read a stale file from the previous build. On Vercel, busting |
|
// the build cache lost the manifest entirely, and subsequent deploys |
|
// were always one build behind. |
|
// |
|
// - Promise-based handoff: generateBundle resolved a Promise that the |
|
// virtual module's async load hook awaited. Deadlocked — the server |
|
// build's load blocked waiting for a Promise that only the client |
|
// build's generateBundle could resolve, but the client build hadn't |
|
// started yet. |
|
// |
|
// - Module-level variable (no file, no Promise): generateBundle wrote to |
|
// a module-scoped variable; load read it synchronously. The variable |
|
// WAS shared (same Node process), but because the server build runs |
|
// first, load always read the initial empty value. |
|
// |
|
// - String placeholder ("$$MODULEPRELOAD_MANIFEST$$"): the virtual |
|
// module exported a literal string to be patched later. Rollup |
|
// constant-folded `typeof manifest === "object"` to false, then |
|
// tree-shook the entire middleware body as dead code. |
|
|
|
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; |
|
import { join } from "node:path"; |
|
import type { AstroIntegration } from "astro"; |
|
|
|
// Minimal Vite types — avoids a direct `vite` dependency (Vite is a |
|
// transitive dep through Astro and pnpm doesn't hoist it). |
|
interface BundleChunk { |
|
type: "chunk"; |
|
imports: readonly string[]; |
|
dynamicImports: readonly string[]; |
|
} |
|
interface BundleAsset { |
|
type: "asset"; |
|
} |
|
type BundleEntry = BundleChunk | BundleAsset; |
|
|
|
const VIRTUAL_ID = "virtual:modulepreload-manifest"; |
|
const RESOLVED_ID = `\0${VIRTUAL_ID}`; |
|
const SENTINEL = "__MODULE_PRELOAD_PLACEHOLDER__"; |
|
|
|
// The placeholder expression used in the virtual module. JSON.parse() |
|
// is opaque to the bundler, so it can't constant-fold or tree-shake |
|
// code that depends on the manifest. |
|
const PLACEHOLDER_EXPR = `JSON.parse('{"${SENTINEL}":1}')`; |
|
|
|
// Module-level shared state — both the server and client Vite builds run |
|
// in the same Node process, so module-scoped variables are the simplest |
|
// (and most reliable) way to pass data between build phases. |
|
let _manifestJson = "{}"; |
|
let _serverOutDir = ""; |
|
|
|
const PATCH_RE = new RegExp( |
|
`JSON\\.parse\\(['"]\\{[^}]*${SENTINEL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^}]*\\}['"]\\)`, |
|
"g", |
|
); |
|
|
|
function patchServerFiles(dir: string): number { |
|
let patched = 0; |
|
let entries: string[]; |
|
try { |
|
entries = readdirSync(dir); |
|
} catch { |
|
return 0; |
|
} |
|
for (const name of entries) { |
|
const full = join(dir, name); |
|
try { |
|
if (statSync(full).isDirectory()) { |
|
patched += patchServerFiles(full); |
|
} else if (name.endsWith(".mjs") || name.endsWith(".js")) { |
|
const src = readFileSync(full, "utf-8"); |
|
if (src.includes(SENTINEL)) { |
|
writeFileSync(full, src.replace(PATCH_RE, () => _manifestJson)); |
|
patched++; |
|
} |
|
} |
|
} catch { |
|
// Skip unreadable entries. |
|
} |
|
} |
|
return patched; |
|
} |
|
|
|
export default function modulePreload(): AstroIntegration { |
|
return { |
|
name: "astro-module-preload", |
|
hooks: { |
|
"astro:config:setup"({ updateConfig, addMiddleware }) { |
|
let isClientBuild = false; |
|
let isDevServer = false; |
|
|
|
updateConfig({ |
|
vite: { |
|
plugins: [ |
|
{ |
|
name: "vite-plugin-module-preload-manifest", |
|
|
|
configResolved(resolved: { |
|
command: string; |
|
root: string; |
|
build?: { ssr?: string | boolean; outDir?: string }; |
|
}) { |
|
isDevServer = resolved.command === "serve"; |
|
isClientBuild = resolved.command === "build" && !resolved.build?.ssr; |
|
|
|
if (resolved.command === "build" && resolved.build?.ssr) { |
|
const outDir = resolved.build.outDir || "dist"; |
|
_serverOutDir = outDir.startsWith("/") |
|
? outDir |
|
: join(resolved.root, outDir); |
|
} |
|
}, |
|
|
|
resolveId(id: string) { |
|
if (id === VIRTUAL_ID) return RESOLVED_ID; |
|
}, |
|
|
|
load(id: string) { |
|
if (id !== RESOLVED_ID) return; |
|
if (isDevServer) return "export default {};"; |
|
return `export default ${PLACEHOLDER_EXPR};`; |
|
}, |
|
|
|
generateBundle(_: unknown, bundle: Record<string, BundleEntry>) { |
|
if (!isClientBuild) return; |
|
|
|
const chunks: Record<string, BundleChunk> = {}; |
|
for (const [fileName, chunk] of Object.entries(bundle)) { |
|
if (chunk.type === "chunk" && fileName.startsWith("_astro/")) { |
|
chunks[fileName] = chunk; |
|
} |
|
} |
|
|
|
if (Object.keys(chunks).length === 0) return; |
|
|
|
// Walk both static and dynamic imports so the browser |
|
// can fetch the entire dependency tree in parallel. |
|
// Dynamic imports like motion (used immediately by the |
|
// hero) benefit the most; lazy ones like Splide get a |
|
// harmless early prefetch on pages that need them anyway. |
|
function collectDeps(fileName: string, visited: Set<string>): void { |
|
if (visited.has(fileName)) return; |
|
visited.add(fileName); |
|
const chunk = chunks[fileName]; |
|
if (!chunk) return; |
|
for (const dep of chunk.imports) collectDeps(dep, visited); |
|
for (const dep of chunk.dynamicImports) collectDeps(dep, visited); |
|
} |
|
|
|
const manifest: Record<string, string[]> = {}; |
|
for (const fileName of Object.keys(chunks)) { |
|
const deps = new Set<string>(); |
|
collectDeps(fileName, deps); |
|
deps.delete(fileName); |
|
if (deps.size > 0) { |
|
manifest[fileName] = [...deps]; |
|
} |
|
} |
|
|
|
_manifestJson = JSON.stringify(manifest); |
|
}, |
|
|
|
writeBundle() { |
|
if (!isClientBuild || _manifestJson === "{}" || !_serverOutDir) return; |
|
patchServerFiles(_serverOutDir); |
|
}, |
|
}, |
|
], |
|
}, |
|
}); |
|
|
|
addMiddleware({ |
|
entrypoint: new URL("./modulepreload-middleware.ts", import.meta.url).pathname, |
|
order: "post", |
|
}); |
|
}, |
|
}, |
|
}; |
|
} |