UNPKG

astro

Version:

Astro is a modern site builder with web best practices, performance, and DX front-of-mind.

401 lines (400 loc) • 14.4 kB
import nodeFs from "node:fs"; import os from "node:os"; import PLimit from "p-limit"; import PQueue from "p-queue"; import colors from "piccolore"; import { generateImagesForPath, getStaticImageList, prepareAssetsGenerationEnv } from "../../assets/build/generate.js"; import { appendForwardSlash, collapseDuplicateTrailingSlashes, hasFileExtension, joinPaths, removeLeadingForwardSlash, removeTrailingForwardSlash, trimSlashes } from "../../core/path.js"; import { runHookBuildGenerated, toIntegrationResolvedRoute } from "../../integrations/hooks.js"; import { AstroError, AstroErrorData } from "../errors/index.js"; import { getRedirectLocationOrThrow } from "../redirects/index.js"; import { createRequest } from "../request.js"; import { redirectTemplate } from "../routing/3xx.js"; import { routeIsRedirect } from "../routing/helpers.js"; import { matchRoute } from "../routing/match.js"; import { getOutputFilename } from "../util.js"; import { getOutFile, getOutFolder } from "./common.js"; import { createDefaultPrerenderer } from "./default-prerenderer.js"; import { hasPrerenderedPages } from "./internal.js"; import { getTimeStat, shouldAppendForwardSlash } from "./util.js"; async function generatePages(options, internals, prerenderOutputDir) { const generatePagesTimer = performance.now(); const ssr = options.settings.buildOutput === "server"; const logger = options.logger; const hasPagesToGenerate = hasPrerenderedPages(internals); if (ssr && !hasPagesToGenerate) { delete globalThis?.astroAsset?.addStaticImage; } if (!hasPagesToGenerate) { return; } let prerenderer; const settingsPrerenderer = options.settings.prerenderer; if (!settingsPrerenderer) { prerenderer = createDefaultPrerenderer({ internals, options, prerenderOutputDir }); } else if (typeof settingsPrerenderer === "function") { const defaultPrerenderer = createDefaultPrerenderer({ internals, options, prerenderOutputDir }); prerenderer = settingsPrerenderer(defaultPrerenderer); } else { prerenderer = settingsPrerenderer; } await prerenderer.setup?.(); const verb = ssr ? "prerendering" : "generating"; logger.info("SKIP_FORMAT", ` ${colors.bgGreen(colors.black(` ${verb} static routes `))}`); const routeToHeaders = /* @__PURE__ */ new Map(); let staticImageList = getStaticImageList(); try { const pathsWithRoutes = await prerenderer.getStaticPaths(); const hasI18nDomains = ssr && options.settings.config.i18n?.domains && Object.keys(options.settings.config.i18n.domains).length > 0; const { config } = options.settings; const builtPaths = /* @__PURE__ */ new Set(); const filteredPaths = pathsWithRoutes.filter(({ pathname, route }) => { if (hasI18nDomains && route.prerender) { throw new AstroError({ ...AstroErrorData.NoPrerenderedRoutesWithDomains, message: AstroErrorData.NoPrerenderedRoutesWithDomains.message(route.component) }); } const normalized = removeTrailingForwardSlash(pathname); if (!builtPaths.has(normalized)) { builtPaths.add(normalized); return true; } const matchedRoute = matchRoute(decodeURI(pathname), options.routesList); if (!matchedRoute) { return false; } if (matchedRoute === route) { return true; } if (config.prerenderConflictBehavior === "error") { throw new AstroError({ ...AstroErrorData.PrerenderRouteConflict, message: AstroErrorData.PrerenderRouteConflict.message( matchedRoute.route, route.route, normalized ), hint: AstroErrorData.PrerenderRouteConflict.hint(matchedRoute.route, route.route) }); } else if (config.prerenderConflictBehavior === "warn") { const msg = AstroErrorData.PrerenderRouteConflict.message( matchedRoute.route, route.route, normalized ); logger.warn("build", msg); } return false; }); if (config.build.concurrency > 1) { const limit = PLimit(config.build.concurrency); const BATCH_SIZE = 1e5; for (let i = 0; i < filteredPaths.length; i += BATCH_SIZE) { const batch = filteredPaths.slice(i, i + BATCH_SIZE); const promises = []; for (const { pathname, route } of batch) { promises.push( limit( () => generatePathWithPrerenderer( prerenderer, pathname, route, options, routeToHeaders, logger ) ) ); } await Promise.all(promises); } } else { for (const { pathname, route } of filteredPaths) { await generatePathWithPrerenderer( prerenderer, pathname, route, options, routeToHeaders, logger ); } } for (const { route: generatedRoute } of filteredPaths) { if (generatedRoute.distURL && generatedRoute.distURL.length > 0) { for (const pageData of Object.values(options.allPages)) { if (pageData.route.route === generatedRoute.route && pageData.route.component === generatedRoute.component) { pageData.route.distURL = generatedRoute.distURL; break; } } } } staticImageList = getStaticImageList(); if (prerenderer.collectStaticImages) { const adapterImages = await prerenderer.collectStaticImages(); for (const [path, entry] of adapterImages) { staticImageList.set(path, entry); } } } finally { await prerenderer.teardown?.(); } logger.info( null, colors.green(`\u2713 Completed in ${getTimeStat(generatePagesTimer, performance.now())}. `) ); if (options.settings.logLevel === "debug" && options.settings.config.experimental?.queuedRendering && prerenderer.app) { try { const stats = prerenderer.app.getQueueStats(); if (stats && (stats.acquireFromPool > 0 || stats.acquireNew > 0)) { logger.info( null, colors.dim( `[Queue Pool] ${stats.acquireFromPool.toLocaleString()} reused / ${stats.acquireNew.toLocaleString()} new nodes | Hit rate: ${stats.hitRate.toFixed(1)}% | Pool: ${stats.poolSize}/${stats.maxSize}` ) ); } } catch { } } if (staticImageList.size) { logger.info("SKIP_FORMAT", `${colors.bgGreen(colors.black(` generating optimized images `))}`); const totalCount = Array.from(staticImageList.values()).map((x) => x.transforms.size).reduce((a, b) => a + b, 0); const cpuCount = os.cpus().length; const assetsCreationPipeline = await prepareAssetsGenerationEnv(options, totalCount); const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) }); const errors = []; const assetsTimer = performance.now(); for (const [originalPath, transforms] of staticImageList) { queue.add(() => generateImagesForPath(originalPath, transforms, assetsCreationPipeline)).catch((e) => { logger.warn("build", `Unable to generate optimized image for ${originalPath}: ${e}`); errors.push(new Error(`Error generating image for ${originalPath}: ${e}`, { cause: e })); }); } await queue.onIdle(); if (errors.length === 1) { throw errors[0]; } else if (errors.length > 1) { throw new AggregateError(errors, `${errors.length} errors occurred during asset generation`); } const assetsTimeEnd = performance.now(); logger.info(null, colors.green(`\u2713 Completed in ${getTimeStat(assetsTimer, assetsTimeEnd)}. `)); delete globalThis?.astroAsset?.addStaticImage; } await runHookBuildGenerated({ settings: options.settings, logger, routeToHeaders }); } const THRESHOLD_SLOW_RENDER_TIME_MS = 500; async function renderPath({ prerenderer, pathname, route, options, routeToHeaders = /* @__PURE__ */ new Map(), logger }) { const { config } = options.settings; if (route.type === "fallback" && route.pathname !== "/") { if (options.routesList.routes.some((routeData) => { if (routeData.pattern.test(pathname)) { if (routeData.params && routeData.params.length !== 0) { if (routeData.distURL && !routeData.distURL.find( (url2) => url2.href.replace(config.outDir.toString(), "").replace(/(?:\/index\.html|\.html)$/, "") === trimSlashes(pathname) )) { return false; } } return true; } else { return false; } })) { return null; } } const url = getUrlForPath( pathname, config.base, options.origin, config.build.format, config.trailingSlash, route.type ); const request = createRequest({ url, headers: new Headers(), logger, isPrerendered: true, routePattern: route.component }); let response; try { response = await prerenderer.render(request, { routeData: route }); } catch (err) { logger.error("build", `Caught error rendering ${pathname}: ${err}`); if (err && !AstroError.is(err) && !err.id && typeof err === "object") { err.id = route.component; } throw err; } let body; const responseHeaders = response.headers; if (response.status >= 300 && response.status < 400) { if (routeIsRedirect(route) && !config.build.redirects) { return null; } const locationSite = getRedirectLocationOrThrow(responseHeaders); const siteURL = config.site; const location = siteURL ? new URL(locationSite, siteURL) : locationSite; const fromPath = new URL(request.url).pathname; body = redirectTemplate({ status: response.status, absoluteLocation: location, relativeLocation: locationSite, from: fromPath }); if (config.compressHTML === true) { body = body.replaceAll("\n", ""); } if (route.type !== "redirect") { route.redirect = location.toString(); } } else { if (!response.body) { return null; } body = Buffer.from(await response.arrayBuffer()); } const encodedPath = encodeURI(pathname); const outFolder = getOutFolder(options.settings, encodedPath, route); const outFile = getOutFile(config.build.format, outFolder, encodedPath, route); if (route.distURL) { route.distURL.push(outFile); } else { route.distURL = [outFile]; } const integrationRoute = toIntegrationResolvedRoute(route, config.trailingSlash); if (options.settings.adapter?.adapterFeatures?.staticHeaders) { routeToHeaders.set(pathname, { headers: responseHeaders, route: integrationRoute }); } if (checkPublicConflict(outFile, route, options.settings, logger)) return null; return { body, outFile, outFolder }; } async function generatePathWithPrerenderer(prerenderer, pathname, route, options, routeToHeaders, logger) { const timeStart = performance.now(); const { config } = options.settings; const filePath = getOutputFilename(config.build.format, pathname, route); logger.info(null, ` ${colors.blue("\u251C\u2500")} ${colors.dim(filePath)}`, false); if (route.type === "page") { addPageName(pathname, options); } const result = await renderPath({ prerenderer, pathname, route, options, routeToHeaders, logger }); if (!result) { logRenderTime(logger, timeStart, true); return; } await nodeFs.promises.mkdir(result.outFolder, { recursive: true }); await nodeFs.promises.writeFile(result.outFile, result.body); logRenderTime(logger, timeStart, false); } function logRenderTime(logger, timeStart, notCreated) { const timeEnd = performance.now(); const isSlow = timeEnd - timeStart > THRESHOLD_SLOW_RENDER_TIME_MS; const timeIncrease = (isSlow ? colors.red : colors.dim)(`(+${getTimeStat(timeStart, timeEnd)})`); const notCreatedMsg = notCreated ? colors.yellow("(file not created, response body was empty)") : ""; logger.info("SKIP_FORMAT", ` ${timeIncrease} ${notCreatedMsg}`); } function addPageName(pathname, opts) { const trailingSlash = opts.settings.config.trailingSlash; const buildFormat = opts.settings.config.build.format; const pageName = shouldAppendForwardSlash(trailingSlash, buildFormat) ? pathname.replace(/\/?$/, "/").replace(/^\//, "") : pathname.replace(/^\//, ""); opts.pageNames.push(pageName); } function getUrlForPath(pathname, base, origin, format, trailingSlash, routeType) { let ending; switch (format) { case "directory": case "preserve": { ending = trailingSlash === "never" ? "" : "/"; break; } case "file": default: { ending = ".html"; break; } } let buildPathname; if (pathname === "/" || pathname === "") { if (format === "file") { buildPathname = joinPaths(base, "index.html"); } else { buildPathname = collapseDuplicateTrailingSlashes(base + ending, trailingSlash !== "never"); } } else if (routeType === "endpoint") { const buildPathRelative = removeLeadingForwardSlash(pathname); let endpointPathname = joinPaths(base, buildPathRelative); if (trailingSlash === "always" && !hasFileExtension(pathname)) { endpointPathname = appendForwardSlash(endpointPathname); } else if (trailingSlash === "never") { endpointPathname = removeTrailingForwardSlash(endpointPathname); } buildPathname = endpointPathname; } else { const buildPathRelative = removeTrailingForwardSlash(removeLeadingForwardSlash(pathname)) + ending; buildPathname = joinPaths(base, buildPathRelative); } return new URL(buildPathname, origin); } function checkPublicConflict(outFile, route, settings, logger) { const outRoot = settings.buildOutput === "static" && !settings.adapter?.adapterFeatures?.preserveBuildClientDir ? settings.config.outDir : settings.config.build.client; const relativePath = outFile.href.slice(outRoot.href.length); const publicFileUrl = new URL(relativePath, settings.config.publicDir); if (nodeFs.existsSync(publicFileUrl)) { logger.warn( "build", `Skipping ${route.component} because a file with the same name exists in the public folder: ${relativePath}` ); return true; } return false; } export { generatePages, renderPath };