astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
368 lines (367 loc) • 16.4 kB
JavaScript
import { fileURLToPath } from "node:url";
import { glob } from "tinyglobby";
import { getAssetsPrefix } from "../../../assets/utils/getAssetsPrefix.js";
import { normalizeTheLocale } from "../../../i18n/path.js";
import { resolveMiddlewareMode } from "../../../integrations/adapter-utils.js";
import { runHookBuildSsr } from "../../../integrations/hooks.js";
import { SERIALIZED_MANIFEST_RESOLVED_ID } from "../../../manifest/serialized.js";
import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from "../../../vite-plugin-scripts/index.js";
import { toFallbackType } from "../../app/common.js";
import { serializeRouteData, toRoutingStrategy } from "../../app/entrypoints/index.js";
import { getAlgorithm, getDirectives, getScriptHashes, getScriptResources, getStrictDynamic, getStyleHashes, getStyleResources, shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from "../../csp/common.js";
import { partitionByKind } from "../../csp/runtime.js";
import { generateSpeculationRulesContent } from "../../../prefetch/speculation-rules.js";
import { encodeKey, generateCspDigest } from "../../encryption.js";
import { fileExtension, joinPaths, prependForwardSlash } from "../../path.js";
import { DEFAULT_COMPONENTS } from "../../routing/default.js";
import { getOutFile, getOutFolder } from "../common.js";
import { cssOrder, mergeInlineCss } from "../runtime.js";
import { makePageDataKey } from "./util.js";
import { cacheConfigToManifest } from "../../cache/utils.js";
import { sessionConfigToManifest } from "../../session/utils.js";
/**
* Unified manifest system architecture:
*
* The serialized manifest (virtual:astro:manifest) is now the single source of truth
* for both dev and production builds:
*
* - In dev: The serialized manifest is used directly (pre-computed manifest data)
* - In prod: Two-stage process:
* 1. serialized.ts emits a placeholder (MANIFEST_REPLACE token) during bundling
* 2. plugin-manifest injects the real build-specific data at the end
*
* This flow eliminates dual virtual modules and simplifies the architecture:
* - pluginManifestBuild: Registers SERIALIZED_MANIFEST_ID as Vite input
* - pluginManifestBuild.generateBundle: Tracks the serialized manifest chunk filename
* - manifestBuildPostHook: Finds the chunk, computes final manifest data, and replaces the token
*
* The placeholder mechanism allows serialized.ts to emit during vite build without knowing
* the final build-specific data (routes, assets, CSP hashes, etc) that's only available
* after bundling completes.
*/
export const MANIFEST_REPLACE = "@@ASTRO_MANIFEST_REPLACE@@";
// Backtick included: Rolldown's minifier may rewrite string literals as template literals.
const replaceExp = new RegExp(`['"\`]${MANIFEST_REPLACE}['"\`]`, "g");
/**
* Post-build hook that injects the computed manifest into bundled chunks.
* Finds the serialized manifest chunk and replaces the placeholder token with real data.
*/
export async function manifestBuildPostHook(options, internals, { chunks, mutate }) {
const manifest = await createManifest(options, internals);
// Find SSR manifest chunk (prerender: false)
const ssrManifestChunk = chunks.find((c) => !c.prerender && c.moduleIds.includes(SERIALIZED_MANIFEST_RESOLVED_ID));
if (ssrManifestChunk) {
const middlewareMode = resolveMiddlewareMode(options.settings.adapter?.adapterFeatures);
const shouldPassMiddlewareEntryPoint = middlewareMode === "edge";
await runHookBuildSsr({
config: options.settings.config,
manifest,
logger: options.logger,
middlewareEntryPoint: shouldPassMiddlewareEntryPoint ? internals.middlewareEntryPoint : undefined
});
// Prerendered routes' styles are dead weight in the SSR manifest: the static
// HTML on disk already has them inlined, and the SSR worker never renders
// these routes. Stripping keeps the entry chunk small on platforms like
// Cloudflare Workers that re-parse it on every cold isolate start.
let ssrManifest = stripPrerenderedRouteStyles(manifest);
ssrManifest = stripPrerenderOnlyEntryModules(ssrManifest, internals);
const code = injectManifest(ssrManifest, ssrManifestChunk.code);
mutate(ssrManifestChunk.fileName, code, false);
}
// Find prerender manifest chunk
const prerenderManifestChunk = chunks.find((c) => c.prerender && c.moduleIds.includes(SERIALIZED_MANIFEST_RESOLVED_ID));
if (prerenderManifestChunk) {
const code = injectManifest(manifest, prerenderManifestChunk.code);
mutate(prerenderManifestChunk.fileName, code, true);
}
}
async function createManifest(buildOpts, internals) {
// Add assets from the client build.
const clientStatics = new Set(await glob("**/*", { cwd: fileURLToPath(buildOpts.outputDirectories.client) }));
for (const file of clientStatics) {
internals.staticFiles.add(file);
}
// Also include SSR-emitted assets (CSS, fonts, images) tracked in ssrAssetsPerEnvironment.
// These assets are moved to the client directory by ssrMoveAssets() later in the pipeline,
// so they haven't landed on disk yet when we glob above. Without this, adapters in middleware
// mode won't recognize them as static files and will match them against catch-all routes instead.
// See: https://github.com/withastro/astro/issues/16039
for (const [, ssrAssets] of internals.ssrAssetsPerEnvironment) {
for (const asset of ssrAssets) {
internals.staticFiles.add(asset);
}
}
const staticFiles = internals.staticFiles;
const encodedKey = await encodeKey(await buildOpts.key);
const manifest = await buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey);
return manifest;
}
/**
* Injects the manifest into the given code string. Returns the new code.
*/
function injectManifest(manifest, code) {
return code.replace(replaceExp, () => {
return JSON.stringify(manifest);
});
}
/**
* Returns a copy of the manifest with `styles` cleared on every prerendered
* route. Inline CSS for prerendered routes is dead weight in the SSR manifest:
* the prerendered HTML on disk already contains the `<style>` tags, and the
* SSR worker never renders these routes.
*/
function stripPrerenderedRouteStyles(manifest) {
let stripped = false;
const routes = manifest.routes.map((route) => {
if (!route.routeData.prerender || route.styles.length === 0) return route;
stripped = true;
return {
...route,
styles: []
};
});
return stripped ? {
...manifest,
routes
} : manifest;
}
/**
* Returns a copy of the manifest with `entryModules` entries removed for
* specifiers that were only emitted by the prerender environment. Those
* chunks live in the prerender output directory, which is deleted after
* page generation, so referencing them from the SSR manifest would produce
* dangling asset URLs at runtime.
*/
function stripPrerenderOnlyEntryModules(manifest, internals) {
if (internals.prerenderOnlyEntrySpecifiers.size === 0) return manifest;
const filtered = Object.fromEntries(Object.entries(manifest.entryModules).filter(([key]) => !internals.prerenderOnlyEntrySpecifiers.has(key)));
return {
...manifest,
entryModules: filtered
};
}
async function buildManifest(opts, internals, staticFiles, encodedKey) {
const { settings } = opts;
const routes = [];
const domainLookupTable = {};
const rawEntryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries());
const assetQueryParams = settings.adapter?.client?.assetQueryParams;
const assetQueryString = assetQueryParams ? assetQueryParams.toString() : undefined;
const appendAssetQuery = (pth) => assetQueryString ? `${pth}?${assetQueryString}` : pth;
const entryModules = Object.fromEntries(Object.entries(rawEntryModules).map(([key, value]) => [key, value ? appendAssetQuery(value) : value]));
if (settings.scripts.some((script) => script.stage === "page")) {
staticFiles.push(rawEntryModules[PAGE_SCRIPT_ID]);
}
const prefixAssetPath = (pth) => {
let result = "";
if (settings.config.build.assetsPrefix) {
const pf = getAssetsPrefix(fileExtension(pth), settings.config.build.assetsPrefix);
result = joinPaths(pf, pth);
} else {
result = prependForwardSlash(joinPaths(settings.config.base, pth));
}
if (assetQueryString) {
result += "?" + assetQueryString;
}
return result;
};
// Default components follow a special flow during build. We prevent their processing earlier
// in the build. As a result, they are not present on `internals.pagesByKeys` and not serialized
// in the manifest file. But we need them in the manifest, so we handle them here
for (const route of opts.routesList.routes) {
if (!DEFAULT_COMPONENTS.find((component) => route.component === component)) {
continue;
}
routes.push({
file: "",
links: [],
scripts: [],
styles: [],
routeData: serializeRouteData(route, settings.config.trailingSlash)
});
}
for (const route of opts.routesList.routes) {
const pageData = internals.pagesByKeys.get(makePageDataKey(route.route, route.component));
if (!pageData) continue;
const scripts = [];
if (settings.scripts.some((script) => script.stage === "page")) {
const src = rawEntryModules[PAGE_SCRIPT_ID];
scripts.push({
type: "external",
value: appendAssetQuery(src)
});
}
// may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc.
const links = [];
const styles = pageData.styles.sort(cssOrder).map(({ sheet }) => sheet).map((s) => s.type === "external" ? {
...s,
src: appendAssetQuery(s.src)
} : s).reduce(mergeInlineCss, []);
routes.push({
file: "",
links,
scripts: [...scripts, ...settings.scripts.filter((script) => script.stage === "head-inline").map(({ stage, content }) => ({
stage,
children: content
}))],
styles,
routeData: serializeRouteData(route, settings.config.trailingSlash)
});
// Add the built .html file as a staticFile
if (route.prerender && route.pathname) {
const outFolder = getOutFolder({
outRoot: opts.outputDirectories.client,
buildFormat: opts.settings.config.build.format,
pathname: route.pathname,
routeData: route
});
const outFile = getOutFile(opts.settings.config.build.format, outFolder, route.pathname, route);
const file = outFile.toString().replace(opts.outputDirectories.client.toString(), "");
staticFiles.push(file);
}
}
/**
* logic meant for i18n domain support, where we fill the lookup table
*/
const i18n = settings.config.i18n;
if (i18n && i18n.domains) {
for (const [locale, domainValue] of Object.entries(i18n.domains)) {
domainLookupTable[domainValue] = normalizeTheLocale(locale);
}
}
// HACK! Patch this special one.
if (!(BEFORE_HYDRATION_SCRIPT_ID in entryModules)) {
// Set this to an empty string so that the runtime knows not to try and load this.
entryModules[BEFORE_HYDRATION_SCRIPT_ID] = "";
}
let i18nManifest = undefined;
if (settings.config.i18n) {
i18nManifest = {
fallback: settings.config.i18n.fallback,
fallbackType: toFallbackType(settings.config.i18n.routing),
strategy: toRoutingStrategy(settings.config.i18n.routing, settings.config.i18n.domains),
locales: settings.config.i18n.locales,
defaultLocale: settings.config.i18n.defaultLocale,
domainLookupTable,
domains: settings.config.i18n.domains
};
}
let csp = undefined;
if (shouldTrackCspHashes(settings.config.security.csp)) {
const cspConfig = settings.config.security.csp;
const algorithm = getAlgorithm(cspConfig);
// Astro's generated hashes are element hashes. They are appended to the directive's `hashes`
// as `default`-kind entries (bare strings), so they land on `script-src`/`style-src` and are
// folded into the `-elem` directives at render time.
const scriptHashes = [...getScriptHashes(cspConfig), ...await trackScriptHashes({
internals,
settings,
algorithm,
clientRoot: opts.outputDirectories.client
})];
const styleHashes = [
...getStyleHashes(cspConfig),
...settings.injectedCsp.styleHashes,
...await trackStyleHashes({
internals,
settings,
algorithm,
clientRoot: opts.outputDirectories.client
})
];
// When both CSP and clientPrerender are enabled, generate a static speculation rules
// script whose hash can be included in the CSP policy. Dynamic per-URL injection would
// produce unpredictable hashes that cannot be whitelisted at build time.
let speculationRulesContent;
if (settings.config.experimental.clientPrerender && settings.config.prefetch) {
const prefetchAll = typeof settings.config.prefetch === "object" ? settings.config.prefetch.prefetchAll ?? false : false;
speculationRulesContent = generateSpeculationRulesContent(prefetchAll);
const speculationRulesHash = await generateCspDigest(speculationRulesContent, algorithm);
scriptHashes.push(speculationRulesHash);
}
const scriptDirective = {
resources: getScriptResources(cspConfig),
hashes: scriptHashes,
strictDynamic: getStrictDynamic(cspConfig)
};
const styleDirective = {
resources: getStyleResources(cspConfig),
hashes: styleHashes
};
// Derive the deprecated flat fields from the `default`-kind entries for back-compat.
const scriptDefault = partitionByKind(scriptDirective).default;
const styleDefault = partitionByKind(styleDirective).default;
csp = {
cspDestination: settings.adapter?.adapterFeatures?.staticHeaders ? "adapter" : undefined,
algorithm,
directives: getDirectives(settings),
scriptHashes: scriptDefault.hashes,
scriptResources: scriptDefault.resources,
isStrictDynamic: scriptDirective.strictDynamic,
styleHashes: styleDefault.hashes,
styleResources: styleDefault.resources,
scriptDirective,
styleDirective,
speculationRulesContent
};
}
// Get internal fetch headers from adapter config
let internalFetchHeaders = undefined;
if (settings.adapter?.client?.internalFetchHeaders) {
const headers = typeof settings.adapter.client.internalFetchHeaders === "function" ? settings.adapter.client.internalFetchHeaders() : settings.adapter.client.internalFetchHeaders;
if (Object.keys(headers).length > 0) {
internalFetchHeaders = headers;
}
}
const middlewareMode = resolveMiddlewareMode(opts.settings.adapter?.adapterFeatures);
return {
rootDir: opts.settings.config.root.toString(),
cacheDir: opts.settings.config.cacheDir.toString(),
outDir: opts.settings.config.outDir.toString(),
srcDir: opts.settings.config.srcDir.toString(),
publicDir: opts.settings.config.publicDir.toString(),
buildClientDir: opts.outputDirectories.client.toString(),
buildServerDir: opts.outputDirectories.server.toString(),
adapterName: opts.settings.adapter?.name ?? "",
assetsDir: opts.settings.config.build.assets,
routes,
serverLike: opts.settings.buildOutput === "server",
middlewareMode,
site: settings.config.site,
base: settings.config.base,
userAssetsBase: settings.config?.vite?.base,
trailingSlash: settings.config.trailingSlash,
compressHTML: settings.config.compressHTML,
assetsPrefix: settings.config.build.assetsPrefix,
componentMetadata: Array.from(internals.componentMetadata),
renderers: [],
clientDirectives: Array.from(settings.clientDirectives),
entryModules,
inlinedScripts: Array.from(internals.inlinedScripts),
assets: staticFiles.map(prefixAssetPath).sort(),
i18n: i18nManifest,
buildFormat: settings.config.build.format,
checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === "server") ?? false,
actionBodySizeLimit: settings.config.security?.actionBodySizeLimit && settings.buildOutput === "server" ? settings.config.security.actionBodySizeLimit : 1024 * 1024,
serverIslandBodySizeLimit: settings.config.security?.serverIslandBodySizeLimit && settings.buildOutput === "server" ? settings.config.security.serverIslandBodySizeLimit : 1024 * 1024,
allowedDomains: settings.config.security?.allowedDomains,
key: encodedKey,
sessionConfig: sessionConfigToManifest(settings.config.session),
cacheConfig: cacheConfigToManifest(settings.config.cache, settings.config.routeRules),
csp,
image: {
objectFit: settings.config.image.objectFit,
objectPosition: settings.config.image.objectPosition,
layout: settings.config.image.layout
},
devToolbar: {
enabled: false,
latestAstroVersion: undefined,
debugInfoOutput: "",
placement: undefined
},
internalFetchHeaders,
logLevel: settings.logLevel,
shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config.security.csp)
};
}