UNPKG

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
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) }; }