From ca4cf01100d7a8f56ad847a808fdebc20a1de924 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 16 Aug 2023 16:45:21 +0100 Subject: [PATCH] refactor: build pipeline (#8088) * refactor: build pipeline * refactor: build pipeline * fix: manifest not extensible and correct directory for renderers.mjs * fix: correctly resolve output directory * fix: correctly compute encoding and body * chore: update documentation * refactor: change how tests are run * refactor: fix test regressions!! --- packages/astro/src/cli/add/index.ts | 1 + packages/astro/src/core/app/ssrPipeline.ts | 4 +- .../astro/src/core/build/buildPipeline.ts | 211 +++++++++++++++ packages/astro/src/core/build/generate.ts | 231 ++++++++-------- packages/astro/src/core/build/internal.ts | 11 +- .../astro/src/core/build/plugins/README.md | 31 ++- .../astro/src/core/build/plugins/index.ts | 2 + .../src/core/build/plugins/plugin-manifest.ts | 251 ++++++++++++++++++ .../src/core/build/plugins/plugin-ssr.ts | 222 +--------------- packages/astro/src/core/build/static-build.ts | 11 +- packages/astro/src/core/logger/core.ts | 8 +- packages/astro/src/core/pipeline.ts | 23 +- packages/astro/src/prerender/utils.ts | 13 + .../astro/test/astro-assets-prefix.test.js | 6 +- .../astro/test/ssr-hoisted-script.test.js | 66 ++--- 15 files changed, 690 insertions(+), 401 deletions(-) create mode 100644 packages/astro/src/core/build/buildPipeline.ts create mode 100644 packages/astro/src/core/build/plugins/plugin-manifest.ts diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts index f09d74a08..fcaeb07c7 100644 --- a/packages/astro/src/cli/add/index.ts +++ b/packages/astro/src/cli/add/index.ts @@ -703,6 +703,7 @@ async function tryToInstallIntegrations({ } catch (err) { spinner.fail(); debug('add', 'Error installing dependencies', err); + // eslint-disable-next-line no-console console.error('\n', (err as any).stdout, '\n'); return UpdateResult.failure; } diff --git a/packages/astro/src/core/app/ssrPipeline.ts b/packages/astro/src/core/app/ssrPipeline.ts index cdb95ff7c..5f135e42d 100644 --- a/packages/astro/src/core/app/ssrPipeline.ts +++ b/packages/astro/src/core/app/ssrPipeline.ts @@ -16,7 +16,7 @@ export class EndpointNotFoundError extends Error { } export class SSRRoutePipeline extends Pipeline { - encoder = new TextEncoder(); + #encoder = new TextEncoder(); constructor(env: Environment) { super(env); @@ -40,7 +40,7 @@ export class SSRRoutePipeline extends Pipeline { headers.set('Content-Type', 'text/plain;charset=utf-8'); } const bytes = - response.encoding !== 'binary' ? this.encoder.encode(response.body) : response.body; + response.encoding !== 'binary' ? this.#encoder.encode(response.body) : response.body; headers.set('Content-Length', bytes.byteLength.toString()); const newResponse = new Response(bytes, { diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts new file mode 100644 index 000000000..4ebf48a9a --- /dev/null +++ b/packages/astro/src/core/build/buildPipeline.ts @@ -0,0 +1,211 @@ +import { Pipeline } from '../pipeline.js'; +import type { BuildInternals } from './internal'; +import type { PageBuildData, StaticBuildOptions } from './types'; +import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; +import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js'; +import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; +import type { SSRManifest } from '../app/types'; +import type { AstroConfig, AstroSettings, RouteType, SSRLoadedRenderer } from '../../@types/astro'; +import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; +import type { EndpointCallResult } from '../endpoint'; +import { createEnvironment } from '../render/index.js'; +import { BEFORE_HYDRATION_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; +import { createAssetLink } from '../render/ssr-element.js'; +import type { BufferEncoding } from 'vfile'; + +/** + * This pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. + */ +export class BuildPipeline extends Pipeline { + #internals: BuildInternals; + #staticBuildOptions: StaticBuildOptions; + #manifest: SSRManifest; + #currentEndpointBody?: { + body: string | Uint8Array; + encoding: BufferEncoding; + }; + + constructor( + staticBuildOptions: StaticBuildOptions, + internals: BuildInternals, + manifest: SSRManifest + ) { + const ssr = isServerLikeOutput(staticBuildOptions.settings.config); + super( + createEnvironment({ + adapterName: manifest.adapterName, + logging: staticBuildOptions.logging, + mode: staticBuildOptions.mode, + renderers: manifest.renderers, + clientDirectives: manifest.clientDirectives, + compressHTML: manifest.compressHTML, + async resolve(specifier: string) { + const hashedFilePath = manifest.entryModules[specifier]; + if (typeof hashedFilePath !== 'string') { + // If no "astro:scripts/before-hydration.js" script exists in the build, + // then we can assume that no before-hydration scripts are needed. + if (specifier === BEFORE_HYDRATION_SCRIPT_ID) { + return ''; + } + throw new Error(`Cannot find the built path for ${specifier}`); + } + return createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix); + }, + routeCache: staticBuildOptions.routeCache, + site: manifest.site, + ssr, + streaming: true, + }) + ); + this.#internals = internals; + this.#staticBuildOptions = staticBuildOptions; + this.#manifest = manifest; + this.setEndpointHandler(this.#handleEndpointResult); + } + + getInternals(): Readonly { + return this.#internals; + } + + getSettings(): Readonly { + return this.#staticBuildOptions.settings; + } + + getStaticBuildOptions(): Readonly { + return this.#staticBuildOptions; + } + + getConfig(): AstroConfig { + return this.#staticBuildOptions.settings.config; + } + + getManifest(): SSRManifest { + return this.#manifest; + } + + /** + * The SSR build emits two important files: + * - dist/server/manifest.mjs + * - dist/renderers.mjs + * + * These two files, put together, will be used to generate the pages. + * + * ## Errors + * + * It will throw errors if the previous files can't be found in the file system. + * + * @param staticBuildOptions + */ + static async retrieveManifest( + staticBuildOptions: StaticBuildOptions, + internals: BuildInternals + ): Promise { + const config = staticBuildOptions.settings.config; + const baseDirectory = getOutputDirectory(config); + const manifestEntryUrl = new URL( + `${internals.manifestFileName}?time=${Date.now()}`, + baseDirectory + ); + const { manifest } = await import(manifestEntryUrl.toString()); + if (!manifest) { + throw new Error( + "Astro couldn't find the emitted manifest. This is an internal error, please file an issue." + ); + } + + const renderersEntryUrl = new URL(`renderers.mjs?time=${Date.now()}`, baseDirectory); + const renderers = await import(renderersEntryUrl.toString()); + if (!renderers) { + throw new Error( + "Astro couldn't find the emitted renderers. This is an internal error, please file an issue." + ); + } + return { + ...manifest, + renderers: renderers.renderers as SSRLoadedRenderer[], + }; + } + + /** + * It collects the routes to generate during the build. + * + * It returns a map of page information and their relative entry point as a string. + */ + retrieveRoutesToGenerate(): Map { + const pages = new Map(); + + for (const [entryPoint, filePath] of this.#internals.entrySpecifierToBundleMap) { + // virtual pages can be emitted with different prefixes: + // - the classic way are pages emitted with prefix ASTRO_PAGE_RESOLVED_MODULE_ID -> plugin-pages + // - pages emitted using `build.split`, in this case pages are emitted with prefix RESOLVED_SPLIT_MODULE_ID + if ( + entryPoint.includes(ASTRO_PAGE_RESOLVED_MODULE_ID) || + entryPoint.includes(RESOLVED_SPLIT_MODULE_ID) + ) { + const [, pageName] = entryPoint.split(':'); + const pageData = this.#internals.pagesByComponent.get( + `${pageName.replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.')}` + ); + if (!pageData) { + throw new Error( + "Build failed. Astro couldn't find the emitted page from " + pageName + ' pattern' + ); + } + + pages.set(pageData, filePath); + } + } + for (const [path, pageData] of this.#internals.pagesByComponent.entries()) { + if (pageData.route.type === 'redirect') { + pages.set(pageData, path); + } + } + return pages; + } + + async #handleEndpointResult(request: Request, response: EndpointCallResult): Promise { + if (response.type === 'response') { + if (!response.response.body) { + return new Response(null); + } + const ab = await response.response.arrayBuffer(); + const body = new Uint8Array(ab); + this.#currentEndpointBody = { + body: body, + encoding: 'utf-8', + }; + return response.response; + } else { + if (response.encoding) { + this.#currentEndpointBody = { + body: response.body, + encoding: response.encoding, + }; + const headers = new Headers(); + headers.set('X-Astro-Encoding', response.encoding); + return new Response(response.body, { + headers, + }); + } else { + return new Response(response.body); + } + } + } + + async computeBodyAndEncoding( + routeType: RouteType, + response: Response + ): Promise<{ + body: string | Uint8Array; + encoding: BufferEncoding; + }> { + const encoding = response.headers.get('X-Astro-Encoding') ?? 'utf-8'; + if (this.#currentEndpointBody) { + const currentEndpointBody = this.#currentEndpointBody; + this.#currentEndpointBody = undefined; + return currentEndpointBody; + } else { + return { body: await response.text(), encoding: encoding as BufferEncoding }; + } + } +} diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index c99a8881b..00be46ea9 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -9,7 +9,7 @@ import type { ComponentInstance, GetStaticPathsItem, ImageTransform, - MiddlewareHandler, + MiddlewareEndpointHandler, RouteData, RouteType, SSRError, @@ -20,12 +20,7 @@ import { generateImage as generateImageInternal, getStaticImageList, } from '../../assets/generate.js'; -import { - eachPageDataFromEntryPoint, - eachRedirectPageData, - hasPrerenderedPages, - type BuildInternals, -} from '../../core/build/internal.js'; +import { hasPrerenderedPages, type BuildInternals } from '../../core/build/internal.js'; import { isRelativePath, joinPaths, @@ -34,13 +29,12 @@ import { removeTrailingForwardSlash, } from '../../core/path.js'; import { runHookBuildGenerated } from '../../integrations/index.js'; -import { isServerLikeOutput } from '../../prerender/utils.js'; -import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; +import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; +import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; -import { debug, info } from '../logger/core.js'; +import { debug, info, Logger } from '../logger/core.js'; import { RedirectSinglePageBuiltModule, getRedirectLocationOrThrow } from '../redirects/index.js'; -import { isEndpointResult } from '../render/core.js'; -import { createEnvironment, createRenderContext, tryRenderRoute } from '../render/index.js'; +import { createRenderContext } from '../render/index.js'; import { callGetStaticPaths } from '../render/route-cache.js'; import { createAssetLink, @@ -64,6 +58,8 @@ import type { StylesheetAsset, } from './types'; import { getTimeStat } from './util.js'; +import { BuildPipeline } from './buildPipeline.js'; +import type { BufferEncoding } from 'vfile'; function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); @@ -125,8 +121,23 @@ export function chunkIsPage( } export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) { + const logger = new Logger(opts.logging); const timer = performance.now(); const ssr = isServerLikeOutput(opts.settings.config); + let manifest: SSRManifest; + if (ssr) { + manifest = await BuildPipeline.retrieveManifest(opts, internals); + } else { + const baseDirectory = getOutputDirectory(opts.settings.config); + const renderersEntryUrl = new URL('renderers.mjs', baseDirectory); + const renderers = await import(renderersEntryUrl.toString()); + manifest = createBuildManifest( + opts.settings, + internals, + renderers.renderers as SSRLoadedRenderer[] + ); + } + const buildPipeline = new BuildPipeline(opts, internals, manifest); const outFolder = ssr ? opts.settings.config.build.server : getOutDirWithinCwd(opts.settings.config.outDir); @@ -140,20 +151,18 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn const verb = ssr ? 'prerendering' : 'generating'; info(opts.logging, null, `\n${bgGreen(black(` ${verb} static routes `))}`); - const builtPaths = new Set(); - + const pagesToGenerate = buildPipeline.retrieveRoutesToGenerate(); if (ssr) { - for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) { + for (const [pageData, filePath] of pagesToGenerate) { if (pageData.route.prerender) { const ssrEntryURLPage = createEntryURL(filePath, outFolder); const ssrEntryPage = await import(ssrEntryURLPage.toString()); if (opts.settings.config.build.split) { // forcing to use undefined, so we fail in an expected way if the module is not even there. - const manifest: SSRManifest | undefined = ssrEntryPage.manifest; - const ssrEntry = manifest?.pageModule; + const ssrEntry = ssrEntryPage?.manifest?.pageModule; if (ssrEntry) { - await generatePage(opts, internals, pageData, ssrEntry, builtPaths, manifest); + await generatePage(pageData, ssrEntry, builtPaths, buildPipeline, logger); } else { throw new Error( `Unable to find the manifest for the module ${ssrEntryURLPage.toString()}. This is unexpected and likely a bug in Astro, please report.` @@ -161,28 +170,25 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn } } else { const ssrEntry = ssrEntryPage as SinglePageBuiltModule; - const manifest = createBuildManifest(opts.settings, internals, ssrEntry.renderers); - await generatePage(opts, internals, pageData, ssrEntry, builtPaths, manifest); + await generatePage(pageData, ssrEntry, builtPaths, buildPipeline, logger); } } - } - for (const pageData of eachRedirectPageData(internals)) { - const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); - const manifest = createBuildManifest(opts.settings, internals, entry.renderers); - await generatePage(opts, internals, pageData, entry, builtPaths, manifest); + if (pageData.route.type === 'redirect') { + const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); + await generatePage(pageData, entry, builtPaths, buildPipeline, logger); + } } } else { - for (const [pageData, filePath] of eachPageDataFromEntryPoint(internals)) { - const ssrEntryURLPage = createEntryURL(filePath, outFolder); - const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString()); - const manifest = createBuildManifest(opts.settings, internals, entry.renderers); + for (const [pageData, filePath] of pagesToGenerate) { + if (pageData.route.type === 'redirect') { + const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); + await generatePage(pageData, entry, builtPaths, buildPipeline, logger); + } else { + const ssrEntryURLPage = createEntryURL(filePath, outFolder); + const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString()); - await generatePage(opts, internals, pageData, entry, builtPaths, manifest); - } - for (const pageData of eachRedirectPageData(internals)) { - const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); - const manifest = createBuildManifest(opts.settings, internals, entry.renderers); - await generatePage(opts, internals, pageData, entry, builtPaths, manifest); + await generatePage(pageData, entry, builtPaths, buildPipeline, logger); + } } } @@ -219,16 +225,15 @@ async function generateImage(opts: StaticBuildOptions, transform: ImageTransform } async function generatePage( - opts: StaticBuildOptions, - internals: BuildInternals, pageData: PageBuildData, ssrEntry: SinglePageBuiltModule, builtPaths: Set, - manifest: SSRManifest + pipeline: BuildPipeline, + logger: Logger ) { let timeStart = performance.now(); - const pageInfo = getPageDataByComponent(internals, pageData.route.component); + const pageInfo = getPageDataByComponent(pipeline.getInternals(), pageData.route.component); // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. const linkIds: [] = []; @@ -240,6 +245,9 @@ async function generatePage( const pageModulePromise = ssrEntry.page; const onRequest = ssrEntry.onRequest; + if (onRequest) { + pipeline.setMiddlewareFunction(onRequest as MiddlewareEndpointHandler); + } if (!pageModulePromise) { throw new Error( @@ -247,14 +255,13 @@ async function generatePage( ); } const pageModule = await pageModulePromise(); - if (shouldSkipDraft(pageModule, opts.settings)) { - info(opts.logging, null, `${magenta('⚠️')} Skipping draft ${pageData.route.component}`); + if (shouldSkipDraft(pageModule, pipeline.getSettings())) { + logger.info(null, `${magenta('⚠️')} Skipping draft ${pageData.route.component}`); return; } const generationOptions: Readonly = { pageData, - internals, linkIds, scripts, styles, @@ -263,23 +270,28 @@ async function generatePage( const icon = pageData.route.type === 'page' ? green('▶') : magenta('λ'); if (isRelativePath(pageData.route.component)) { - info(opts.logging, null, `${icon} ${pageData.route.route}`); + logger.info(null, `${icon} ${pageData.route.route}`); } else { - info(opts.logging, null, `${icon} ${pageData.route.component}`); + logger.info(null, `${icon} ${pageData.route.component}`); } // Get paths for the route, calling getStaticPaths if needed. - const paths = await getPathsForRoute(pageData, pageModule, opts, builtPaths); + const paths = await getPathsForRoute( + pageData, + pageModule, + pipeline.getStaticBuildOptions(), + builtPaths + ); for (let i = 0; i < paths.length; i++) { const path = paths[i]; - await generatePath(path, opts, generationOptions, manifest, onRequest); + await generatePath(path, generationOptions, pipeline); const timeEnd = performance.now(); const timeChange = getTimeStat(timeStart, timeEnd); const timeIncrease = `(+${timeChange})`; - const filePath = getOutputFilename(opts.settings.config, path, pageData.route.type); + const filePath = getOutputFilename(pipeline.getConfig(), path, pageData.route.type); const lineIcon = i === paths.length - 1 ? '└─' : '├─'; - info(opts.logging, null, ` ${cyan(lineIcon)} ${dim(filePath)} ${dim(timeIncrease)}`); + logger.info(null, ` ${cyan(lineIcon)} ${dim(filePath)} ${dim(timeIncrease)}`); } } @@ -382,7 +394,6 @@ function getInvalidRouteSegmentError( interface GeneratePathOptions { pageData: PageBuildData; - internals: BuildInternals; linkIds: string[]; scripts: { type: 'inline' | 'external'; value: string } | null; styles: StylesheetAsset[]; @@ -446,19 +457,13 @@ function getUrlForPath( return url; } -async function generatePath( - pathname: string, - opts: StaticBuildOptions, - gopts: GeneratePathOptions, - manifest: SSRManifest, - onRequest?: MiddlewareHandler -) { - const { settings, logging, origin, routeCache } = opts; - const { mod, internals, scripts: hoistedScripts, styles: _styles, pageData } = gopts; +async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeline: BuildPipeline) { + const manifest = pipeline.getManifest(); + const { mod, scripts: hoistedScripts, styles: _styles, pageData } = gopts; // This adds the page name to the array so it can be shown as part of stats. if (pageData.route.type === 'page') { - addPageName(pathname, opts); + addPageName(pathname, pipeline.getStaticBuildOptions()); } debug('build', `Generating: ${pathname}`); @@ -472,8 +477,8 @@ async function generatePath( ); const styles = createStylesheetElementSet(_styles, manifest.base, manifest.assetsPrefix); - if (settings.scripts.some((script) => script.stage === 'page')) { - const hashedFilePath = internals.entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID); + if (pipeline.getSettings().scripts.some((script) => script.stage === 'page')) { + const hashedFilePath = pipeline.getInternals().entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID); if (typeof hashedFilePath !== 'string') { throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`); } @@ -485,7 +490,7 @@ async function generatePath( } // Add all injected scripts to the page. - for (const script of settings.scripts) { + for (const script of pipeline.getSettings().scripts) { if (script.stage === 'head-inline') { scripts.add({ props: {}, @@ -494,58 +499,38 @@ async function generatePath( } } - const ssr = isServerLikeOutput(settings.config); + const ssr = isServerLikeOutput(pipeline.getConfig()); const url = getUrlForPath( pathname, - opts.settings.config.base, - origin, - opts.settings.config.build.format, + pipeline.getConfig().base, + pipeline.getStaticBuildOptions().origin, + pipeline.getConfig().build.format, pageData.route.type ); - const env = createEnvironment({ - adapterName: manifest.adapterName, - logging, - mode: opts.mode, - renderers: manifest.renderers, - clientDirectives: manifest.clientDirectives, - compressHTML: manifest.compressHTML, - async resolve(specifier: string) { - const hashedFilePath = manifest.entryModules[specifier]; - if (typeof hashedFilePath !== 'string') { - // If no "astro:scripts/before-hydration.js" script exists in the build, - // then we can assume that no before-hydration scripts are needed. - if (specifier === BEFORE_HYDRATION_SCRIPT_ID) { - return ''; - } - throw new Error(`Cannot find the built path for ${specifier}`); - } - return createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix); - }, - routeCache, - site: manifest.site, - ssr, - streaming: true, - }); - const renderContext = await createRenderContext({ pathname, - request: createRequest({ url, headers: new Headers(), logging, ssr }), + request: createRequest({ + url, + headers: new Headers(), + logging: pipeline.getStaticBuildOptions().logging, + ssr, + }), componentMetadata: manifest.componentMetadata, scripts, styles, links, route: pageData.route, - env, + env: pipeline.getEnvironment(), mod, }); let body: string | Uint8Array; let encoding: BufferEncoding | undefined; - let response; + let response: Response; try { - response = await tryRenderRoute(renderContext, env, mod, onRequest); + response = await pipeline.renderRoute(renderContext, mod); } catch (err) { if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') { (err as SSRError).id = pageData.component; @@ -553,28 +538,17 @@ async function generatePath( throw err; } - if (isEndpointResult(response, pageData.route.type)) { - if (response.type === 'response') { - // If there's no body, do nothing - if (!response.response.body) return; - const ab = await response.response.arrayBuffer(); - body = new Uint8Array(ab); - } else { - body = response.body; - encoding = response.encoding; + if (response.status >= 300 && response.status < 400) { + // If redirects is set to false, don't output the HTML + if (!pipeline.getConfig().build.redirects) { + return; } - } else { - if (response.status >= 300 && response.status < 400) { - // If redirects is set to false, don't output the HTML - if (!opts.settings.config.build.redirects) { - return; - } - const location = getRedirectLocationOrThrow(response.headers); - const fromPath = new URL(renderContext.request.url).pathname; - // A short delay causes Google to interpret the redirect as temporary. - // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh - const delay = response.status === 302 ? 2 : 0; - body = ` + const location = getRedirectLocationOrThrow(response.headers); + const fromPath = new URL(renderContext.request.url).pathname; + // A short delay causes Google to interpret the redirect as temporary. + // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh + const delay = response.status === 302 ? 2 : 0; + body = ` Redirecting to: ${location} @@ -582,20 +556,25 @@ async function generatePath( Redirecting from ${fromPath} to ${location} `; - // A dynamic redirect, set the location so that integrations know about it. - if (pageData.route.type !== 'redirect') { - pageData.route.redirect = location; - } - } else { - // If there's no body, do nothing - if (!response.body) return; - body = await response.text(); + // A dynamic redirect, set the location so that integrations know about it. + if (pageData.route.type !== 'redirect') { + pageData.route.redirect = location; } + } else { + // If there's no body, do nothing + if (!response.body) return; + const result = await pipeline.computeBodyAndEncoding(renderContext.route.type, response); + body = result.body; + encoding = result.encoding; } - const outFolder = getOutFolder(settings.config, pathname, pageData.route.type); - const outFile = getOutFile(settings.config, outFolder, pathname, pageData.route.type); + const outFolder = getOutFolder(pipeline.getConfig(), pathname, pageData.route.type); + const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, pageData.route.type); pageData.route.distURL = outFile; + const possibleEncoding = response.headers.get('X-Astro-Encoding'); + if (possibleEncoding) { + encoding = possibleEncoding as BufferEncoding; + } await fs.promises.mkdir(outFolder, { recursive: true }); await fs.promises.writeFile(outFile, body, encoding ?? 'utf-8'); } diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 5dff6f3dd..c1123e36b 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -85,6 +85,9 @@ export interface BuildInternals { staticFiles: Set; // The SSR entry chunk. Kept in internals to share between ssr/client build steps ssrEntryChunk?: Rollup.OutputChunk; + // The SSR manifest entry chunk. + manifestEntryChunk?: Rollup.OutputChunk; + manifestFileName?: string; entryPoints: Map; ssrSplitEntryChunks: Map; componentMetadata: SSRResult['componentMetadata']; @@ -227,14 +230,6 @@ export function* eachPageData(internals: BuildInternals) { yield* internals.pagesByComponent.values(); } -export function* eachRedirectPageData(internals: BuildInternals) { - for (const pageData of eachPageData(internals)) { - if (pageData.route.type === 'redirect') { - yield pageData; - } - } -} - export function* eachPageDataFromEntryPoint( internals: BuildInternals ): Generator<[PageBuildData, string]> { diff --git a/packages/astro/src/core/build/plugins/README.md b/packages/astro/src/core/build/plugins/README.md index 145158163..ef73b9e50 100644 --- a/packages/astro/src/core/build/plugins/README.md +++ b/packages/astro/src/core/build/plugins/README.md @@ -125,10 +125,13 @@ will look like this: Of course, all these files will be deleted by Astro at the end build. -## `plugin-ssr` (WIP) +## `plugin-ssr` -This plugin is responsible to create a single `entry.mjs` file that will be used -in SSR. +This plugin is responsible to create the JS files that will be executed in SSR. + +### Classic mode + +The plugin will emit a single entry point called `entry.mjs`. This plugin **will emit code** only when building an **SSR** site. @@ -146,4 +149,24 @@ const pageMap = new Map([ ``` It will also import the [`renderers`](#plugin-renderers) virtual module -and the [`middleware`](#plugin-middleware) virtual module. +and the [`manifest`](#plugin-manifest) virtual module. + +### Split mode + +The plugin will emit various entry points. Each route will be an entry point. + +Each entry point will contain the necessary code to **render one single route**. + +Each entry point will also import the [`renderers`](#plugin-renderers) virtual module +and the [`manifest`](#plugin-manifest) virtual module. + +## `plugin-manifest` + +This plugin is responsible to create a file called `manifest.mjs`. In SSG, the file is saved +in `config.outDir`, in SSR the file is saved in `config.build.server`. + +This file is important to do two things: +- generate the pages during the SSG; +- render the pages in SSR; + +The file contains all the information needed to Astro to accomplish the operations mentioned above. diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts index decfefd04..19c952660 100644 --- a/packages/astro/src/core/build/plugins/index.ts +++ b/packages/astro/src/core/build/plugins/index.ts @@ -12,12 +12,14 @@ import { pluginPages } from './plugin-pages.js'; import { pluginPrerender } from './plugin-prerender.js'; import { pluginRenderers } from './plugin-renderers.js'; import { pluginSSR, pluginSSRSplit } from './plugin-ssr.js'; +import { pluginManifest } from './plugin-manifest.js'; export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) { register(pluginComponentEntry(internals)); register(pluginAliasResolve(internals)); register(pluginAnalyzer(options, internals)); register(pluginInternals(internals)); + register(pluginManifest(options, internals)); register(pluginRenderers(options)); register(pluginMiddleware(options, internals)); register(pluginPages(options, internals)); diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts new file mode 100644 index 000000000..2c2ceb7e1 --- /dev/null +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -0,0 +1,251 @@ +import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js'; +import type { AstroBuildPlugin } from '../plugin'; +import { type Plugin as VitePlugin } from 'vite'; +import { runHookBuildSsr } from '../../../integrations/index.js'; +import { addRollupInput } from '../add-rollup-input.js'; +import glob from 'fast-glob'; +import { fileURLToPath } from 'node:url'; +import type { OutputChunk } from 'rollup'; +import { getOutFile, getOutFolder } from '../common.js'; +import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; +import { joinPaths, prependForwardSlash } from '../../path.js'; +import { serializeRouteData } from '../../routing/index.js'; +import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types'; +import type { StaticBuildOptions } from '../types'; + +const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; +const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); + +export const SSR_MANIFEST_VIRTUAL_MODULE_ID = '@astrojs-manifest'; +export const RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID = '\0' + SSR_MANIFEST_VIRTUAL_MODULE_ID; + +function vitePluginManifest(options: StaticBuildOptions, internals: BuildInternals): VitePlugin { + return { + name: '@astro/plugin-build-manifest', + enforce: 'post', + options(opts) { + return addRollupInput(opts, [SSR_MANIFEST_VIRTUAL_MODULE_ID]); + }, + resolveId(id) { + if (id === SSR_MANIFEST_VIRTUAL_MODULE_ID) { + return RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID; + } + }, + augmentChunkHash(chunkInfo) { + if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) { + return Date.now().toString(); + } + }, + async load(id) { + if (id === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) { + const imports = []; + const contents = []; + const exports = []; + imports.push( + `import { deserializeManifest as _deserializeManifest } from 'astro/app'`, + `import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'` + ); + + contents.push(` +const manifest = _deserializeManifest('${manifestReplace}'); +_privateSetManifestDontUseThis(manifest); +`); + + exports.push('export { manifest }'); + + return `${imports.join('\n')}${contents.join('\n')}${exports.join('\n')}`; + } + }, + + async generateBundle(_opts, bundle) { + for (const [chunkName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'asset') { + continue; + } + if (chunk.modules[RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID]) { + internals.manifestEntryChunk = chunk; + delete bundle[chunkName]; + } + if (chunkName.startsWith('manifest')) { + internals.manifestFileName = chunkName; + } + } + }, + }; +} + +export function pluginManifest( + options: StaticBuildOptions, + internals: BuildInternals +): AstroBuildPlugin { + return { + build: 'ssr', + hooks: { + 'build:before': () => { + return { + vitePlugin: vitePluginManifest(options, internals), + }; + }, + + 'build:post': async ({ mutate }) => { + if (!internals.manifestEntryChunk) { + throw new Error(`Did not generate an entry chunk for SSR`); + } + + const manifest = await createManifest(options, internals); + await runHookBuildSsr({ + config: options.settings.config, + manifest, + logging: options.logging, + entryPoints: internals.entryPoints, + middlewareEntryPoint: internals.middlewareEntryPoint, + }); + // TODO: use the manifest entry chunk instead + const code = injectManifest(manifest, internals.manifestEntryChunk); + mutate(internals.manifestEntryChunk, 'server', code); + }, + }, + }; +} + +export async function createManifest( + buildOpts: StaticBuildOptions, + internals: BuildInternals +): Promise { + if (!internals.manifestEntryChunk) { + throw new Error(`Did not generate an entry chunk for SSR`); + } + + // Add assets from the client build. + const clientStatics = new Set( + await glob('**/*', { + cwd: fileURLToPath(buildOpts.settings.config.build.client), + }) + ); + for (const file of clientStatics) { + internals.staticFiles.add(file); + } + + const staticFiles = internals.staticFiles; + return buildManifest(buildOpts, internals, Array.from(staticFiles)); +} + +/** + * It injects the manifest in the given output rollup chunk. It returns the new emitted code + * @param buildOpts + * @param internals + * @param chunk + */ +export function injectManifest(manifest: SerializedSSRManifest, chunk: Readonly) { + const code = chunk.code; + + return code.replace(replaceExp, () => { + return JSON.stringify(manifest); + }); +} + +function buildManifest( + opts: StaticBuildOptions, + internals: BuildInternals, + staticFiles: string[] +): SerializedSSRManifest { + const { settings } = opts; + + const routes: SerializedRouteInfo[] = []; + const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); + if (settings.scripts.some((script) => script.stage === 'page')) { + staticFiles.push(entryModules[PAGE_SCRIPT_ID]); + } + + const prefixAssetPath = (pth: string) => { + if (settings.config.build.assetsPrefix) { + return joinPaths(settings.config.build.assetsPrefix, pth); + } else { + return prependForwardSlash(joinPaths(settings.config.base, pth)); + } + }; + + for (const route of opts.manifest.routes) { + if (!route.prerender) continue; + if (!route.pathname) continue; + + const outFolder = getOutFolder(opts.settings.config, route.pathname, route.type); + const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route.type); + const file = outFile.toString().replace(opts.settings.config.build.client.toString(), ''); + routes.push({ + file, + links: [], + scripts: [], + styles: [], + routeData: serializeRouteData(route, settings.config.trailingSlash), + }); + staticFiles.push(file); + } + + for (const route of opts.manifest.routes) { + const pageData = internals.pagesByComponent.get(route.component); + if (route.prerender || !pageData) continue; + const scripts: SerializedRouteInfo['scripts'] = []; + if (pageData.hoistedScript) { + const hoistedValue = pageData.hoistedScript.value; + const value = hoistedValue.endsWith('.js') ? prefixAssetPath(hoistedValue) : hoistedValue; + scripts.unshift( + Object.assign({}, pageData.hoistedScript, { + value, + }) + ); + } + if (settings.scripts.some((script) => script.stage === 'page')) { + const src = entryModules[PAGE_SCRIPT_ID]; + + scripts.push({ + type: 'external', + value: prefixAssetPath(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: prefixAssetPath(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), + }); + } + + // 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] = ''; + } + + const ssrManifest: SerializedSSRManifest = { + adapterName: opts.settings.adapter?.name ?? '', + routes, + site: settings.config.site, + base: settings.config.base, + compressHTML: settings.config.compressHTML, + assetsPrefix: settings.config.build.assetsPrefix, + componentMetadata: Array.from(internals.componentMetadata), + renderers: [], + clientDirectives: Array.from(settings.clientDirectives), + entryModules, + assets: staticFiles.map(prefixAssetPath), + }; + + return ssrManifest; +} diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index ed4cd7b72..098b9dee8 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -1,28 +1,21 @@ -import glob from 'fast-glob'; import { join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import type { Plugin as VitePlugin } from 'vite'; import type { AstroAdapter, AstroConfig } from '../../../@types/astro'; -import { isFunctionPerRouteEnabled, runHookBuildSsr } from '../../../integrations/index.js'; +import { isFunctionPerRouteEnabled } from '../../../integrations/index.js'; import { isServerLikeOutput } from '../../../prerender/utils.js'; -import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; -import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types'; -import { joinPaths, prependForwardSlash } from '../../path.js'; import { routeIsRedirect } from '../../redirects/index.js'; -import { serializeRouteData } from '../../routing/index.js'; import { addRollupInput } from '../add-rollup-input.js'; -import { getOutFile, getOutFolder } from '../common.js'; -import { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js'; +import type { BuildInternals } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin'; -import type { OutputChunk, StaticBuildOptions } from '../types'; +import type { StaticBuildOptions } from '../types'; import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js'; import { getPathFromVirtualModulePageName, getVirtualModulePageNameFromPath } from './util.js'; +import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.js'; export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry'; -const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID; -const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; -const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); +export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID; function vitePluginSSR( internals: BuildInternals, @@ -85,13 +78,12 @@ function vitePluginSSR( } } - for (const [chunkName, chunk] of Object.entries(bundle)) { + for (const [, chunk] of Object.entries(bundle)) { if (chunk.type === 'asset') { continue; } if (chunk.modules[RESOLVED_SSR_VIRTUAL_MODULE_ID]) { internals.ssrEntryChunk = chunk; - delete bundle[chunkName]; } } }, @@ -121,7 +113,7 @@ export function pluginSSR( vitePlugin, }; }, - 'build:post': async ({ mutate }) => { + 'build:post': async () => { if (!ssr) { return; } @@ -135,17 +127,6 @@ export function pluginSSR( } // Mutate the filename internals.ssrEntryChunk.fileName = options.settings.config.build.serverEntry; - - const manifest = await createManifest(options, internals); - await runHookBuildSsr({ - config: options.settings.config, - manifest, - logging: options.logging, - entryPoints: internals.entryPoints, - middlewareEntryPoint: internals.middlewareEntryPoint, - }); - const code = injectManifest(manifest, internals.ssrEntryChunk); - mutate(internals.ssrEntryChunk, 'server', code); }, }, }; @@ -209,21 +190,16 @@ function vitePluginSSRSplit( } } - for (const [chunkName, chunk] of Object.entries(bundle)) { + for (const [, chunk] of Object.entries(bundle)) { if (chunk.type === 'asset') { continue; } - let shouldDeleteBundle = false; for (const moduleKey of Object.keys(chunk.modules)) { if (moduleKey.startsWith(RESOLVED_SPLIT_MODULE_ID)) { internals.ssrSplitEntryChunks.set(moduleKey, chunk); storeEntryPoint(moduleKey, options, internals, chunk.fileName); - shouldDeleteBundle = true; } } - if (shouldDeleteBundle) { - delete bundle[chunkName]; - } } }, }; @@ -250,31 +226,6 @@ export function pluginSSRSplit( vitePlugin, }; }, - 'build:post': async ({ mutate }) => { - if (!ssr) { - return; - } - if (!options.settings.config.build.split && !functionPerRouteEnabled) { - return; - } - - if (internals.ssrSplitEntryChunks.size === 0) { - throw new Error(`Did not generate an entry chunk for SSR serverless`); - } - - const manifest = await createManifest(options, internals); - await runHookBuildSsr({ - config: options.settings.config, - manifest, - logging: options.logging, - entryPoints: internals.entryPoints, - middlewareEntryPoint: internals.middlewareEntryPoint, - }); - for (const [, chunk] of internals.ssrSplitEntryChunks) { - const code = injectManifest(manifest, chunk); - mutate(chunk, 'server', code); - } - }, }, }; } @@ -291,13 +242,11 @@ function generateSSRCode(config: AstroConfig, adapter: AstroAdapter) { contents.push(`import * as adapter from '${adapter.serverEntrypoint}'; import { renderers } from '${RENDERERS_MODULE_ID}'; -import { deserializeManifest as _deserializeManifest } from 'astro/app'; -import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'; -const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { +import { manifest as defaultManifest} from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}'; +const _manifest = Object.assign(defaultManifest, { ${pageMap}, renderers, }); -_privateSetManifestDontUseThis(_manifest); const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'}; ${ @@ -326,51 +275,6 @@ if(_start in adapter) { }; } -/** - * It injects the manifest in the given output rollup chunk. It returns the new emitted code - * @param buildOpts - * @param internals - * @param chunk - */ -export function injectManifest(manifest: SerializedSSRManifest, chunk: Readonly) { - const code = chunk.code; - - return code.replace(replaceExp, () => { - return JSON.stringify(manifest); - }); -} - -export async function createManifest( - buildOpts: StaticBuildOptions, - internals: BuildInternals -): Promise { - if ( - buildOpts.settings.config.build.split || - isFunctionPerRouteEnabled(buildOpts.settings.adapter) - ) { - if (internals.ssrSplitEntryChunks.size === 0) { - throw new Error(`Did not generate an entry chunk for SSR in serverless mode`); - } - } else { - if (!internals.ssrEntryChunk) { - throw new Error(`Did not generate an entry chunk for SSR`); - } - } - - // Add assets from the client build. - const clientStatics = new Set( - await glob('**/*', { - cwd: fileURLToPath(buildOpts.settings.config.build.client), - }) - ); - for (const file of clientStatics) { - internals.staticFiles.add(file); - } - - const staticFiles = internals.staticFiles; - return buildManifest(buildOpts, internals, Array.from(staticFiles)); -} - /** * Because we delete the bundle from rollup at the end of this function, * we can't use `writeBundle` hook to get the final file name of the entry point written on disk. @@ -392,109 +296,3 @@ function storeEntryPoint( } } } - -function buildManifest( - opts: StaticBuildOptions, - internals: BuildInternals, - staticFiles: string[] -): SerializedSSRManifest { - const { settings } = opts; - - const routes: SerializedRouteInfo[] = []; - const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); - if (settings.scripts.some((script) => script.stage === 'page')) { - staticFiles.push(entryModules[PAGE_SCRIPT_ID]); - } - - const prefixAssetPath = (pth: string) => { - if (settings.config.build.assetsPrefix) { - return joinPaths(settings.config.build.assetsPrefix, pth); - } else { - return prependForwardSlash(joinPaths(settings.config.base, pth)); - } - }; - - for (const route of opts.manifest.routes) { - if (!route.prerender) continue; - if (!route.pathname) continue; - - const outFolder = getOutFolder(opts.settings.config, route.pathname, route.type); - const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route.type); - const file = outFile.toString().replace(opts.settings.config.build.client.toString(), ''); - routes.push({ - file, - links: [], - scripts: [], - styles: [], - routeData: serializeRouteData(route, settings.config.trailingSlash), - }); - staticFiles.push(file); - } - - for (const route of opts.manifest.routes) { - const pageData = internals.pagesByComponent.get(route.component); - if (route.prerender || !pageData) continue; - const scripts: SerializedRouteInfo['scripts'] = []; - if (pageData.hoistedScript) { - const hoistedValue = pageData.hoistedScript.value; - const value = hoistedValue.endsWith('.js') ? prefixAssetPath(hoistedValue) : hoistedValue; - scripts.unshift( - Object.assign({}, pageData.hoistedScript, { - value, - }) - ); - } - if (settings.scripts.some((script) => script.stage === 'page')) { - const src = entryModules[PAGE_SCRIPT_ID]; - - scripts.push({ - type: 'external', - value: prefixAssetPath(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: prefixAssetPath(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), - }); - } - - // 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] = ''; - } - - const ssrManifest: SerializedSSRManifest = { - adapterName: opts.settings.adapter!.name, - routes, - site: settings.config.site, - base: settings.config.base, - compressHTML: settings.config.compressHTML, - assetsPrefix: settings.config.build.assetsPrefix, - componentMetadata: Array.from(internals.componentMetadata), - renderers: [], - clientDirectives: Array.from(settings.clientDirectives), - entryModules, - assets: staticFiles.map(prefixAssetPath), - }; - - return ssrManifest; -} diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 28f496d91..cbb259e03 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -16,7 +16,7 @@ import { emptyDir, removeEmptyDirs } from '../../core/fs/index.js'; import { appendForwardSlash, prependForwardSlash } from '../../core/path.js'; import { isModeServerWithNoAdapter } from '../../core/util.js'; import { runHookBuildSetup } from '../../integrations/index.js'; -import { isServerLikeOutput } from '../../prerender/utils.js'; +import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { info } from '../logger/core.js'; @@ -28,10 +28,11 @@ import { createPluginContainer, type AstroBuildPluginContainer } from './plugin. import { registerAllPlugins } from './plugins/index.js'; import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js'; -import { RESOLVED_SPLIT_MODULE_ID, SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js'; +import { RESOLVED_SPLIT_MODULE_ID, RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js'; import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; import type { PageBuildData, StaticBuildOptions } from './types'; import { getTimeStat } from './util.js'; +import { RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugins/plugin-manifest.js'; export async function viteBuild(opts: StaticBuildOptions) { const { allPages, settings } = opts; @@ -147,7 +148,7 @@ async function ssrBuild( ) { const { allPages, settings, viteConfig } = opts; const ssr = isServerLikeOutput(settings.config); - const out = ssr ? settings.config.build.server : getOutDirWithinCwd(settings.config.outDir); + const out = getOutputDirectory(settings.config); const routes = Object.values(allPages).map((pd) => pd.route); const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input); @@ -184,10 +185,12 @@ async function ssrBuild( ); } else if (chunkInfo.facadeModuleId?.startsWith(RESOLVED_SPLIT_MODULE_ID)) { return makeSplitEntryPointFileName(chunkInfo.facadeModuleId, routes); - } else if (chunkInfo.facadeModuleId === SSR_VIRTUAL_MODULE_ID) { + } else if (chunkInfo.facadeModuleId === RESOLVED_SSR_VIRTUAL_MODULE_ID) { return opts.settings.config.build.serverEntry; } else if (chunkInfo.facadeModuleId === RESOLVED_RENDERERS_MODULE_ID) { return 'renderers.mjs'; + } else if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) { + return 'manifest.[hash].mjs'; } else { return '[name].mjs'; } diff --git a/packages/astro/src/core/logger/core.ts b/packages/astro/src/core/logger/core.ts index e5d0aee1f..c92cdbb24 100644 --- a/packages/astro/src/core/logger/core.ts +++ b/packages/astro/src/core/logger/core.ts @@ -133,16 +133,16 @@ export class Logger { this.options = options; } - info(label: string, message: string) { + info(label: string | null, message: string) { info(this.options, label, message); } - warn(label: string, message: string) { + warn(label: string | null, message: string) { warn(this.options, label, message); } - error(label: string, message: string) { + error(label: string | null, message: string) { error(this.options, label, message); } - debug(label: string, message: string) { + debug(label: string | null, message: string) { debug(this.options, label, message); } } diff --git a/packages/astro/src/core/pipeline.ts b/packages/astro/src/core/pipeline.ts index 66fa6bd07..b5c66517a 100644 --- a/packages/astro/src/core/pipeline.ts +++ b/packages/astro/src/core/pipeline.ts @@ -23,12 +23,12 @@ type EndpointResultHandler = ( */ export class Pipeline { env: Environment; - onRequest?: MiddlewareEndpointHandler; + #onRequest?: MiddlewareEndpointHandler; /** * The handler accepts the *original* `Request` and result returned by the endpoint. * It must return a `Response`. */ - endpointHandler?: EndpointResultHandler; + #endpointHandler?: EndpointResultHandler; /** * When creating a pipeline, an environment is mandatory. @@ -38,20 +38,29 @@ export class Pipeline { this.env = env; } + setEnvironment() {} + /** * When rendering a route, an "endpoint" will a type that needs to be handled and transformed into a `Response`. * * Each consumer might have different needs; use this function to set up the handler. */ setEndpointHandler(handler: EndpointResultHandler) { - this.endpointHandler = handler; + this.#endpointHandler = handler; } /** * A middleware function that will be called before each request. */ setMiddlewareFunction(onRequest: MiddlewareEndpointHandler) { - this.onRequest = onRequest; + this.#onRequest = onRequest; + } + + /** + * Returns the current environment + */ + getEnvironment() { + return this.env; } /** @@ -65,15 +74,15 @@ export class Pipeline { renderContext, this.env, componentInstance, - this.onRequest + this.#onRequest ); if (Pipeline.isEndpointResult(result, renderContext.route.type)) { - if (!this.endpointHandler) { + if (!this.#endpointHandler) { throw new Error( 'You created a pipeline that does not know how to handle the result coming from an endpoint.' ); } - return this.endpointHandler(renderContext.request, result); + return this.#endpointHandler(renderContext.request, result); } else { return result; } diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts index bd6e367ad..a3655eead 100644 --- a/packages/astro/src/prerender/utils.ts +++ b/packages/astro/src/prerender/utils.ts @@ -1,4 +1,5 @@ import type { AstroConfig } from '../@types/astro'; +import { getOutDirWithinCwd } from '../core/build/common.js'; export function isServerLikeOutput(config: AstroConfig) { return config.output === 'server' || config.output === 'hybrid'; @@ -7,3 +8,15 @@ export function isServerLikeOutput(config: AstroConfig) { export function getPrerenderDefault(config: AstroConfig) { return config.output === 'hybrid'; } + +/** + * Returns the correct output directory of hte SSR build based on the configuration + */ +export function getOutputDirectory(config: AstroConfig): URL { + const ssr = isServerLikeOutput(config); + if (ssr) { + return config.build.server; + } else { + return getOutDirWithinCwd(config.outDir); + } +} diff --git a/packages/astro/test/astro-assets-prefix.test.js b/packages/astro/test/astro-assets-prefix.test.js index 40562afd4..ab42439ae 100644 --- a/packages/astro/test/astro-assets-prefix.test.js +++ b/packages/astro/test/astro-assets-prefix.test.js @@ -63,7 +63,7 @@ describe('Assets Prefix - Static', () => { }); }); -describe('Assets Prefix - Static with path prefix', () => { +describe('Assets Prefix - with path prefix', () => { let fixture; before(async () => { @@ -86,7 +86,7 @@ describe('Assets Prefix - Static with path prefix', () => { }); }); -describe('Assets Prefix - Server', () => { +describe('Assets Prefix, server', () => { let app; before(async () => { @@ -143,7 +143,7 @@ describe('Assets Prefix - Server', () => { }); }); -describe('Assets Prefix - Server with path prefix', () => { +describe('Assets Prefix, with path prefix', () => { let app; before(async () => { diff --git a/packages/astro/test/ssr-hoisted-script.test.js b/packages/astro/test/ssr-hoisted-script.test.js index 49e1e7b2f..e9549151e 100644 --- a/packages/astro/test/ssr-hoisted-script.test.js +++ b/packages/astro/test/ssr-hoisted-script.test.js @@ -3,50 +3,54 @@ import { load as cheerioLoad } from 'cheerio'; import { loadFixture } from './test-utils.js'; import testAdapter from './test-adapter.js'; +async function fetchHTML(fixture, path) { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com' + path); + const response = await app.render(request); + const html = await response.text(); + return html; +} + describe('Hoisted scripts in SSR', () => { /** @type {import('./test-utils').Fixture} */ let fixture; + describe('without base path', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-hoisted-script/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + }); + + it('Inlined scripts get included', async () => { + const html = await fetchHTML(fixture, '/'); + const $ = cheerioLoad(html); + expect($('script').length).to.equal(1); + }); + }); +}); + +describe('Hoisted scripts in SSR with base path', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + const base = '/hello'; + before(async () => { fixture = await loadFixture({ root: './fixtures/ssr-hoisted-script/', output: 'server', adapter: testAdapter(), + base, }); await fixture.build(); }); - async function fetchHTML(path) { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com' + path); - const response = await app.render(request); - const html = await response.text(); - return html; - } - - it('Inlined scripts get included', async () => { - const html = await fetchHTML('/'); + it('Inlined scripts get included without base path in the script', async () => { + const html = await fetchHTML(fixture, '/hello/'); const $ = cheerioLoad(html); - expect($('script').length).to.equal(1); - }); - - describe('base path', () => { - const base = '/hello'; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/ssr-hoisted-script/', - output: 'server', - adapter: testAdapter(), - base, - }); - await fixture.build(); - }); - - it('Inlined scripts get included without base path in the script', async () => { - const html = await fetchHTML('/hello/'); - const $ = cheerioLoad(html); - expect($('script').html()).to.equal('console.log("hello world");\n'); - }); + expect($('script').html()).to.equal('console.log("hello world");\n'); }); });