Skip to content

Instantly share code, notes, and snippets.

@ADTC
Last active May 11, 2026 19:11
Show Gist options
  • Select an option

  • Save ADTC/56bdcd863ff388cd9409dcc3a89ed41b to your computer and use it in GitHub Desktop.

Select an option

Save ADTC/56bdcd863ff388cd9409dcc3a89ed41b to your computer and use it in GitHub Desktop.
Add an Astro integration to inject modulepreload hints

Add an Astro integration to inject modulepreload hints

Astro doesn't emit <link rel="modulepreload"> tags, so browsers discover JS dependencies one hop at a time. A Vite plugin now walks the client chunk graph at build time and injects a dependency manifest; an Astro middleware reads it and injects modulepreload hints into every HTML response, letting the browser fetch all modules in parallel from the first HTML parse.

For context, see withastro/roadmap#561

Note

Path separators in file name is __ instead of / as Gists don't allow folders.

Example: src__integrations__modulepreload.ts is actually src/integrations/modulepreload.ts.

// ...
import modulePreload from "./src/integrations/modulepreload";
// ...
export default defineConfig({
// ...
integrations: [
modulePreload(),
// ...
],
});
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
// ...
declare module "virtual:modulepreload-manifest" {
const manifest: Record<string, string[]>;
export default manifest;
}
// ...
// Middleware that injects <link rel="modulepreload"> hints into HTML
// responses. See modulepreload.ts for the full design.
import { defineMiddleware } from "astro:middleware";
import manifest from "virtual:modulepreload-manifest";
const SCRIPT_SRC_RE = /<script[^>]+src=["']([^"']*\/_astro\/[^"']+)["'][^>]*>/g;
const hasManifest = Object.keys(manifest).length > 0;
export const onRequest = defineMiddleware(async (_context, next) => {
if (!hasManifest) return next();
const response = await next();
const contentType = response.headers.get("content-type");
if (!contentType?.includes("text/html")) return response;
const html = await response.text();
const scriptSrcs = new Set<string>();
SCRIPT_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = SCRIPT_SRC_RE.exec(html))) {
const idx = match[1].indexOf("_astro/");
if (idx !== -1) scriptSrcs.add(match[1].slice(idx));
}
const allDeps = new Set<string>();
for (const src of scriptSrcs) {
const deps = manifest[src];
if (deps) for (const dep of deps) allDeps.add(dep);
}
for (const src of scriptSrcs) allDeps.delete(src);
const headers = new Headers(response.headers);
headers.delete("content-length");
if (allDeps.size === 0) {
return new Response(html, {
status: response.status,
statusText: response.statusText,
headers,
});
}
const links = [...allDeps]
.map((dep) => `<link rel="modulepreload" href="/${dep}" crossorigin>`)
.join("");
// Try </head>, then <body>, then prepend to the document. Handles
// well-formed HTML, partial responses, and broken markup alike.
let modifiedHtml: string;
const headEnd = html.indexOf("</head>");
if (headEnd !== -1) {
modifiedHtml = html.slice(0, headEnd) + links + html.slice(headEnd);
} else {
const bodyStart = html.search(/<body[\s>]/i);
if (bodyStart !== -1) {
modifiedHtml = html.slice(0, bodyStart) + links + html.slice(bodyStart);
} else {
modifiedHtml = links + html;
}
}
return new Response(modifiedHtml, {
status: response.status,
statusText: response.statusText,
headers,
});
});
// 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",
});
},
},
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment