astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
434 lines (433 loc) • 19.3 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import colors from "piccolore";
import * as vite from "vite";
import { LINKS_PLACEHOLDER } from "../../content/consts.js";
import { contentAssetsBuildPostHook } from "../../content/vite-plugin-content-assets.js";
import { createBuildInternals } from "../../core/build/internal.js";
import { emptyDir, removeEmptyDirs } from "../../core/fs/index.js";
import { prependForwardSlash } from "../../core/path.js";
import { runHookBuildSetup } from "../../integrations/hooks.js";
import { SERIALIZED_MANIFEST_RESOLVED_ID } from "../../manifest/serialized.js";
import { PAGE_SCRIPT_ID } from "../../vite-plugin-scripts/index.js";
import { generatePages } from "./generate.js";
import { pathToDirectoryURL } from "./common.js";
import { trackPageData } from "./internal.js";
import { getAllBuildPlugins } from "./plugins/index.js";
import { manifestBuildPostHook } from "./plugins/plugin-manifest.js";
import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from "./plugins/util.js";
import { getTimeStat, viteBuildReturnToRolldownOutputs } from "./util.js";
import { NOOP_MODULE_ID } from "./plugins/plugin-noop.js";
import { ASTRO_VITE_ENVIRONMENT_NAMES } from "../constants.js";
import { getSSRAssets } from "./internal.js";
import { SERVER_ISLAND_MAP_MARKER, hasServerIslands } from "../server-islands/vite-plugin-server-islands.js";
import { buildPhase, buildTimingsVitePlugins } from "./timings/index.js";
import { createViteBuildConfig } from "./vite-build-config.js";
import { removeViteManifestDirectories } from "./vite-plugin-ssr-assets.js";
const PRERENDER_ENTRY_FILENAME_PREFIX = "prerender-entry";
/**
* Extracts only the chunks that need post-build injection from RolldownOutput.
* This allows releasing the full RolldownOutput to reduce memory usage.
*/
function extractRelevantChunks(outputs, prerender) {
const extracted = [];
for (const output of outputs) {
for (const chunk of output.output) {
if (chunk.type === "asset") continue;
const needsContentInjection = chunk.code.includes(LINKS_PLACEHOLDER);
const needsManifestInjection = chunk.moduleIds.includes(SERIALIZED_MANIFEST_RESOLVED_ID);
const needsServerIslandInjection = chunk.code.includes(SERVER_ISLAND_MAP_MARKER);
if (needsContentInjection || needsManifestInjection || needsServerIslandInjection) {
extracted.push({
fileName: chunk.fileName,
code: chunk.code,
moduleIds: [...chunk.moduleIds],
prerender
});
}
}
}
return extracted;
}
export async function viteBuild(opts) {
const { allPages, settings } = opts;
// Build internals needed by the CSS plugin
const internals = createBuildInternals();
for (const pageData of Object.values(allPages)) {
const astroModuleURL = new URL("./" + pageData.component, settings.config.root);
const astroModuleId = prependForwardSlash(pageData.component);
// Track the page data in internals
trackPageData(internals, pageData.component, pageData, astroModuleId, astroModuleURL);
}
// Empty out the dist folder, if needed. Vite has a config for doing this
// but because we are running 2 vite builds in parallel, that would cause a race
// condition, so we are doing it ourselves.
if (settings.config?.vite?.build?.emptyOutDir !== false) {
emptyDir(settings.config.outDir, new Set(".git"));
}
// Build your project (SSR application code, assets, client JS, etc.)
const ssrTime = performance.now();
opts.logger.info("build", `Building ${settings.buildOutput} entrypoints...`);
const outputDirectories = await buildEnvironments(opts, internals);
opts.logger.info("build", colors.green(`✓ Completed in ${getTimeStat(ssrTime, performance.now())}.`));
return {
internals,
outputDirectories
};
}
/**
* Builds all Vite environments (SSR, prerender, client) in sequence.
*
* This is the core build function that orchestrates Astro's multi-environment build process.
* Environments are built sequentially because they have dependencies on each other.
*
* ## Build Order & Dependencies
*
* 1. **Prerender Environment** (built first)
* - Generates code for static prerenderable routes
* - Entry: `astro/entrypoints/prerender`
* - Outputs to `.prerender/` in server directory
*
* 2. **SSR Environment** (built second)
* - Generates the server runtime entry point
* - Outputs to server directory
*
* 3. **Client Environment** (built last)
* - MUST be built after SSR/prerender because client inputs are discovered during those builds
* - During SSR/prerender, Astro discovers:
* - Components with hydration directives (client:*)
* - Client-only components
* - Page scripts
* - These discoveries populate `internals.clientInput` which becomes the rolldown input
* - Config is mutated after builder creation to set dynamic inputs
* - If no client scripts exist, uses a "noop" entrypoint to satisfy Rolldown's input requirement
* - public/ folder is copied during this build
*
* Returns outputs from each environment for post-build processing (manifest injection, etc).
*/
async function buildEnvironments(opts, internals) {
const { allPages, settings, viteConfig } = opts;
const routes = Object.values(allPages).flatMap((pageData) => pageData.route);
const buildPlugins = getAllBuildPlugins(internals, opts);
const flatPlugins = buildPlugins.flat().filter(Boolean);
const plugins = [
...flatPlugins,
...viteConfig.plugins || [],
...buildTimingsVitePlugins()
];
let currentRolldownInput = undefined;
let buildPostHooks = [];
plugins.push({
name: "astro:resolve-input",
// When the rolldown input is safe to update, we normalize it to always be an object
// so we can reliably identify which entrypoint corresponds to the adapter
enforce: "post",
config(config) {
if (typeof config.build?.rolldownOptions?.input === "string") {
config.build.rolldownOptions.input = { index: config.build.rolldownOptions.input };
} else if (Array.isArray(config.build?.rolldownOptions?.input)) {
config.build.rolldownOptions.input = Object.fromEntries(config.build.rolldownOptions.input.map((v, i) => [`index_${i}`, v]));
}
},
// We save the rolldown input to be able to check later on
configResolved(config) {
currentRolldownInput = config.build.rolldownOptions.input;
}
});
// Post plugin for manifest injection, page generation, and cleanup
// This runs after all other buildApp hooks (including platform plugins like Cloudflare)
plugins.push({
name: "astro:build-generate",
enforce: "post",
buildApp: {
order: "post",
async handler(builder) {
const resolvedOpts = {
...opts,
outputDirectories: getResolvedOutputDirectories(builder)
};
try {
// Inject manifest and content placeholders into extracted chunks
await buildPhase("manifest injection", () => runManifestInjection(resolvedOpts, internals, internals.extractedChunks ?? [], buildPostHooks));
// Generation and cleanup
const prerenderOutputDir = resolvedOpts.outputDirectories.prerender;
// TODO: The `static` and `server` branches below are nearly identical now.
// Consider refactoring to remove the else-if and unify the logic.
if (settings.buildOutput === "static") {
settings.timer.start("Static generate");
// Move prerender and SSR assets to client directory before cleaning up
await buildPhase("rearrange assets", () => ssrMoveAssets(resolvedOpts, internals));
// Generate the pages
await buildPhase("generate pages", () => generatePages(resolvedOpts, internals, prerenderOutputDir, builder));
// Clean up prerender directory after generation
await fs.promises.rm(prerenderOutputDir, {
recursive: true,
force: true
});
settings.timer.end("Static generate");
} else if (settings.buildOutput === "server") {
settings.timer.start("Server generate");
await buildPhase("generate pages", () => generatePages(resolvedOpts, internals, prerenderOutputDir, builder));
// Move prerender and SSR assets to client directory before cleaning up
await buildPhase("rearrange assets", () => ssrMoveAssets(resolvedOpts, internals));
// Clean up prerender directory after generation
await fs.promises.rm(prerenderOutputDir, {
recursive: true,
force: true
});
settings.timer.end("Server generate");
}
} finally {
await removeViteManifestDirectories(Object.values(resolvedOpts.outputDirectories));
}
}
}
});
function isRolldownInput(moduleName) {
if (!currentRolldownInput || !moduleName) {
return false;
}
if (typeof currentRolldownInput === "string") {
return currentRolldownInput === moduleName;
} else if (Array.isArray(currentRolldownInput)) {
return currentRolldownInput.includes(moduleName);
} else {
return Object.keys(currentRolldownInput).includes(moduleName);
}
}
const viteBuildConfig = createViteBuildConfig({
settings,
viteConfig,
routes,
plugins,
// Top-level buildApp for framework build orchestration
// This takes precedence over platform plugin fallbacks (e.g., Cloudflare)
builder: { async buildApp(builder) {
// Build prerender environment for static generation
settings.timer.start("Prerender build");
let prerenderOutput = await buildPhase("prerender bundle", () => builder.build(builder.environments.prerender));
settings.timer.end("Prerender build");
// Extract prerender entry filename and store in internals
extractPrerenderEntryFileName(internals, prerenderOutput);
// Extract chunks needing injection, then release output for GC
const prerenderOutputs = viteBuildReturnToRolldownOutputs(prerenderOutput);
const prerenderChunks = extractRelevantChunks(prerenderOutputs, true);
prerenderOutput = undefined;
// Build ssr environment for server output
let ssrChunks = [];
if (needsServerBuild(settings, builder)) {
settings.timer.start("SSR build");
let ssrOutput = await buildPhase("ssr bundle", () => builder.build(builder.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]));
settings.timer.end("SSR build");
// Extract chunks needing injection, then release output for GC
const ssrOutputs = viteBuildReturnToRolldownOutputs(ssrOutput);
ssrChunks = extractRelevantChunks(ssrOutputs, false);
ssrOutput = undefined;
}
const ssrPlugins = builder.environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr]?.config.plugins ?? [];
buildPostHooks = ssrPlugins.map((plugin) => typeof plugin.api?.buildPostHook === "function" ? plugin.api.buildPostHook : undefined).filter(Boolean);
// Build client environment
// We must discover client inputs after SSR build because hydration/client-only directives
// are only detected during SSR. We mutate the config here since the builder was already created
// and this is the only way to update the input after instantiation.
internals.clientInput = getClientInput(internals, settings);
if (!internals.clientInput.size) {
// At least 1 input is required to do a build; otherwise, Vite throws.
// We need the client build to happen in order to copy over the `public/` folder
// So using the noop plugin here which will give us an input that just gets thrown away.
internals.clientInput.add(NOOP_MODULE_ID);
}
// Sort the client input to ensure deterministic Rollup entry point ordering.
// `internals.clientInput` is a Set whose iteration order depends on async module resolution
// timing during prerendering. Without sorting, consecutive builds of the same
// source code can produce different output filenames, breaking CDN caching.
const sortedClientInput = Array.from(internals.clientInput).sort();
builder.environments.client.config.build.rolldownOptions.input = sortedClientInput;
settings.timer.start("Client build");
await buildPhase("client bundle", () => builder.build(builder.environments.client));
settings.timer.end("Client build");
// Store extracted chunks on internals for post plugin to consume
internals.extractedChunks = [...ssrChunks, ...prerenderChunks];
} },
isRolldownInput
});
const updatedViteBuildConfig = await runHookBuildSetup({
config: settings.config,
pages: internals.pagesByKeys,
vite: viteBuildConfig,
target: "server",
logger: opts.logger
});
const builder = await vite.createBuilder(updatedViteBuildConfig);
await builder.buildApp();
return getResolvedOutputDirectories(builder);
}
function getResolvedOutputDirectories(builder) {
const environments = builder.environments;
return {
client: pathToDirectoryURL(environments[ASTRO_VITE_ENVIRONMENT_NAMES.client].config.build.outDir),
server: pathToDirectoryURL(environments[ASTRO_VITE_ENVIRONMENT_NAMES.ssr].config.build.outDir),
prerender: pathToDirectoryURL(environments[ASTRO_VITE_ENVIRONMENT_NAMES.prerender].config.build.outDir)
};
}
/**
* Finds and returns the prerender entry filename from the build output.
* Throws an error if no prerender entry file is found.
*/
function getPrerenderEntryFileName(prerenderOutput) {
const outputs = viteBuildReturnToRolldownOutputs(prerenderOutput);
for (const output of outputs) {
for (const chunk of output.output) {
if (chunk.type !== "asset" && "fileName" in chunk) {
const fileName = chunk.fileName;
if (fileName.startsWith(PRERENDER_ENTRY_FILENAME_PREFIX)) {
return fileName;
}
}
}
}
throw new Error("Could not find the prerender entry point in the build output. This is likely a bug in Astro.");
}
/**
* Extracts the prerender entry filename from the build output
* and stores it in internals for later retrieval in generatePages.
*/
function extractPrerenderEntryFileName(internals, prerenderOutput) {
internals.prerenderEntryFileName = getPrerenderEntryFileName(prerenderOutput);
}
async function runManifestInjection(opts, internals, chunks, buildPostHooks) {
const mutations = new Map();
const mutate = (fileName, newCode, prerender) => {
mutations.set(fileName, {
code: newCode,
prerender
});
};
await manifestBuildPostHook(opts, internals, {
chunks,
mutate
});
await contentAssetsBuildPostHook(opts.settings.config.base, opts.settings.config.build.assetsPrefix, internals, {
chunks,
mutate
});
for (const buildPostHook of buildPostHooks) {
await buildPostHook({
chunks,
mutate
});
}
await writeMutatedChunks(opts, mutations);
}
/**
* Writes chunks that were modified by post-build hooks (e.g., manifest injection).
* Mutations are collected during the manifest hook and persisted here to the
* appropriate output directories (server or prerender).
*/
async function writeMutatedChunks(opts, mutations) {
for (const [fileName, mutation] of mutations) {
let root;
if (mutation.prerender) {
// Write to prerender directory
root = opts.outputDirectories.prerender;
} else {
root = opts.outputDirectories.server;
}
const fullPath = path.join(fileURLToPath(root), fileName);
const fileURL = pathToFileURL(fullPath);
await fs.promises.mkdir(new URL("./", fileURL), { recursive: true });
await fs.promises.writeFile(fileURL, mutation.code, "utf-8");
}
}
/**
* Moves prerender and SSR assets to the client directory.
* In server mode, assets are initially scattered across server and prerender
* directories but need to be consolidated in the client directory for serving.
* Reads asset filenames from internals.ssrAssetsPerEnvironment which is populated
* by vitePluginSSRAssets during the build.
*/
async function ssrMoveAssets(opts, internals) {
opts.logger.info("build", "Rearranging server assets...");
const isFullyStaticSite = opts.settings.buildOutput === "static";
const serverRoot = opts.outputDirectories.server;
const clientRoot = opts.outputDirectories.client;
const prerenderOutputDir = opts.outputDirectories.prerender;
// Move prerender assets
const prerenderAssetsToMove = getSSRAssets(internals, ASTRO_VITE_ENVIRONMENT_NAMES.prerender);
if (prerenderAssetsToMove.size > 0) {
await Promise.all(Array.from(prerenderAssetsToMove).map(async function moveAsset(filename) {
const currentUrl = new URL(filename, prerenderOutputDir);
const clientUrl = new URL(filename, clientRoot);
if (!fs.existsSync(currentUrl)) return;
const dir = new URL(path.parse(clientUrl.href).dir);
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true });
return fs.promises.rename(currentUrl, clientUrl);
}));
}
// If this is fully static site, we don't need to do the next parts at all.
if (isFullyStaticSite) {
return;
}
// Move SSR assets
const ssrAssetsToMove = getSSRAssets(internals, ASTRO_VITE_ENVIRONMENT_NAMES.ssr);
if (ssrAssetsToMove.size > 0) {
await Promise.all(Array.from(ssrAssetsToMove).map(async function moveAsset(filename) {
const currentUrl = new URL(filename, serverRoot);
const clientUrl = new URL(filename, clientRoot);
if (!fs.existsSync(currentUrl)) return;
const dir = new URL(path.parse(clientUrl.href).dir);
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true });
return fs.promises.rename(currentUrl, clientUrl);
}));
removeEmptyDirs(fileURLToPath(serverRoot));
}
}
function getClientInput(internals, settings) {
const rendererClientEntrypoints = settings.renderers.map((r) => r.clientEntrypoint).filter((a) => typeof a === "string");
const clientInput = new Set([
...internals.discoveredHydratedComponents.keys(),
...internals.discoveredClientOnlyComponents.keys(),
...rendererClientEntrypoints,
...internals.discoveredScripts
]);
if (settings.scripts.some((script) => script.stage === "page")) {
clientInput.add(PAGE_SCRIPT_ID);
}
return clientInput;
}
/**
* Determines whether the SSR build environment needs to be built.
* This is true when the build output is 'server', or when server islands
* were discovered during the prerender build (even for static sites).
*/
function needsServerBuild(settings, builder) {
if (settings.buildOutput === "server") {
return true;
}
return hasServerIslands(builder.environments.prerender);
}
/**
* This function takes the virtual module name of any page entrypoint and
* transforms it to generate a final `.mjs` output file.
*
* Input: `@astro-page:src/pages/index@_@astro`
* Output: `pages/index.astro.mjs`
* Input: `@astro-page:../node_modules/my-dep/injected@_@astro`
* Output: `pages/injected.mjs`
*
* 1. We clean the `facadeModuleId` by removing the `ASTRO_PAGE_MODULE_ID` prefix and `ASTRO_PAGE_EXTENSION_POST_PATTERN`.
* 2. We find the matching route pattern in the manifest (or fall back to the cleaned module id)
* 3. We replace square brackets with underscore (`[slug]` => `_slug_`) and `...` with `` (`[...slug]` => `_---slug_`).
* 4. We append the `.mjs` extension, so the file will always be an ESM module
*
* @param prefix string
* @param facadeModuleId string
* @param pages AllPagesData
*/
export function makeAstroPageEntryPointFileName(prefix, facadeModuleId, routes) {
const pageModuleId = facadeModuleId.replace(prefix, "").replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, ".");
const route = routes.find((routeData) => routeData.component === pageModuleId);
const name = route?.route ?? pageModuleId;
return `pages${name.replace(/\/$/, "/index").replaceAll(/[[\]]/g, "_").replaceAll("...", "---")}.astro.mjs`;
}