From d2960984c59af7b60a3ea472c6c58fb00534a8e6 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 16 Dec 2022 11:38:37 -0500 Subject: [PATCH] Experimental Prerender API (#5297) * wip: hybrid output * wip: hybrid output mvp * refactor: move hybrid => server * wip: hybrid support for `output: 'server'` * feat(hybrid): overwrite static files * fix: update static build * feat(hybrid): skip page generation if no static entrypoints * feat: migrate from hybrid output => prerender flag * fix: appease typescript * fix: appease typescript * fix: appease typescript * fix: appease typescript * fix: improve static cleanup * attempt: avoid preprocess scanning * hack: force generated .js files to be treated as ESM * better handling for astro metadata * fix: update scanner plugin * fix: page name bug * fix: keep ssr false when generating pages * fix: force output to be treated as ESM * fix: client output should respect buildConfig * fix: ensure outDir is always created * fix: do not replace files with noop * fix(netlify): add support for `experimental_prerender` pages * feat: switch to `experimental_prerender` * chore: update es-module-lexer code in test * feat: improved code-splitting, cleanup * feat: move prerender behind flag * test: prerender * test: update prerender test * chore: update lockfile * fix: only match `.html` files when resolving assets * chore: update changeset * chore: remove ESM hack * chore: allow `--experimental-prerender` flag, move `--experimental-error-overlay` into subobject * chore: update changeset * test(vite-plugin-scanner): add proper unit tests for vite-plugin-scanner * chore: remove leftover code * chore: add comment on cleanup task * refactor: move manual chunks logic to vite-plugin-prerender * fix: do not support let declarations * test: add var test * refactor: prefer existing util * Update packages/astro/src/@types/astro.ts Co-authored-by: Chris Swithinbank * Update packages/astro/src/core/errors/errors-data.ts Co-authored-by: Chris Swithinbank * Update packages/astro/src/@types/astro.ts Co-authored-by: Chris Swithinbank Co-authored-by: Nate Moore Co-authored-by: Chris Swithinbank --- .changeset/funny-waves-worry.md | 17 ++ packages/astro/e2e/error-cyclic.test.js | 2 +- .../astro/e2e/error-react-spectrum.test.js | 2 +- packages/astro/e2e/error-sass.test.js | 2 +- packages/astro/e2e/errors.test.js | 2 +- packages/astro/package.json | 2 +- packages/astro/src/@types/astro.ts | 39 ++++- packages/astro/src/core/app/index.ts | 4 +- packages/astro/src/core/build/common.ts | 9 +- packages/astro/src/core/build/generate.ts | 31 +++- packages/astro/src/core/build/internal.ts | 40 ++++- packages/astro/src/core/build/static-build.ts | 154 +++++++++++++++--- packages/astro/src/core/build/types.ts | 2 + .../src/core/build/vite-plugin-analyzer.ts | 12 +- .../src/core/build/vite-plugin-internals.ts | 7 + .../astro/src/core/build/vite-plugin-pages.ts | 4 +- .../src/core/build/vite-plugin-prerender.ts | 43 +++++ .../astro/src/core/build/vite-plugin-ssr.ts | 20 ++- packages/astro/src/core/config/config.ts | 8 +- packages/astro/src/core/config/schema.ts | 13 +- packages/astro/src/core/create-vite.ts | 2 + packages/astro/src/core/errors/errors-data.ts | 16 ++ packages/astro/src/core/errors/overlay.ts | 2 +- packages/astro/src/core/routing/match.ts | 13 ++ packages/astro/src/core/util.ts | 6 + packages/astro/src/integrations/index.ts | 2 + packages/astro/src/jsx/babel.ts | 1 + packages/astro/src/vite-plugin-astro/index.ts | 1 + packages/astro/src/vite-plugin-astro/types.ts | 5 + .../src/vite-plugin-markdown-legacy/index.ts | 1 + .../astro/src/vite-plugin-markdown/index.ts | 1 + .../astro/src/vite-plugin-scanner/index.ts | 44 +++++ .../astro/src/vite-plugin-scanner/scan.ts | 48 ++++++ .../test/fixtures/ssr-prerender/package.json | 8 + .../ssr-prerender/src/pages/static.astro | 18 ++ .../ssr-prerender/src/pages/users/[id].astro | 12 ++ packages/astro/test/ssr-prerender.test.js | 52 ++++++ .../units/vite-plugin-astro/compile.test.js | 7 +- .../units/vite-plugin-scanner/scan.test.js | 79 +++++++++ packages/integrations/netlify/src/shared.ts | 18 +- pnpm-lock.yaml | 20 ++- 41 files changed, 701 insertions(+), 68 deletions(-) create mode 100644 .changeset/funny-waves-worry.md create mode 100644 packages/astro/src/core/build/vite-plugin-prerender.ts create mode 100644 packages/astro/src/vite-plugin-scanner/index.ts create mode 100644 packages/astro/src/vite-plugin-scanner/scan.ts create mode 100644 packages/astro/test/fixtures/ssr-prerender/package.json create mode 100644 packages/astro/test/fixtures/ssr-prerender/src/pages/static.astro create mode 100644 packages/astro/test/fixtures/ssr-prerender/src/pages/users/[id].astro create mode 100644 packages/astro/test/ssr-prerender.test.js create mode 100644 packages/astro/test/units/vite-plugin-scanner/scan.test.js diff --git a/.changeset/funny-waves-worry.md b/.changeset/funny-waves-worry.md new file mode 100644 index 000000000000..361fac880a93 --- /dev/null +++ b/.changeset/funny-waves-worry.md @@ -0,0 +1,17 @@ +--- +'astro': minor +'@astrojs/netlify': minor +--- + +Introduces the **experimental** Prerender API. + +> **Note** +> This API is not yet stable and is subject to possible breaking changes! + +- Deploy an Astro server without sacrificing the speed or cacheability of static HTML. +- The Prerender API allows you to statically prerender specific `pages/` at build time. + +**Usage** + +- First, run `astro build --experimental-prerender` or enable `experimental: { prerender: true }` in your `astro.config.mjs` file. +- Then, include `export const prerender = true` in any file in the `pages/` directory that you wish to prerender. diff --git a/packages/astro/e2e/error-cyclic.test.js b/packages/astro/e2e/error-cyclic.test.js index 98c3f19d5457..5bdef236e10a 100644 --- a/packages/astro/e2e/error-cyclic.test.js +++ b/packages/astro/e2e/error-cyclic.test.js @@ -2,7 +2,7 @@ import { expect } from '@playwright/test'; import { testFactory, getErrorOverlayContent } from './test-utils.js'; const test = testFactory({ - experimentalErrorOverlay: true, + experimental: { errorOverlay: true }, root: './fixtures/error-cyclic/', }); diff --git a/packages/astro/e2e/error-react-spectrum.test.js b/packages/astro/e2e/error-react-spectrum.test.js index eacaaadc264d..05b1cf2a22bb 100644 --- a/packages/astro/e2e/error-react-spectrum.test.js +++ b/packages/astro/e2e/error-react-spectrum.test.js @@ -2,7 +2,7 @@ import { expect } from '@playwright/test'; import { testFactory, getErrorOverlayContent } from './test-utils.js'; const test = testFactory({ - experimentalErrorOverlay: true, + experimental: { errorOverlay: true }, root: './fixtures/error-react-spectrum/', }); diff --git a/packages/astro/e2e/error-sass.test.js b/packages/astro/e2e/error-sass.test.js index 5fbd109761eb..ec8ab89f2fbd 100644 --- a/packages/astro/e2e/error-sass.test.js +++ b/packages/astro/e2e/error-sass.test.js @@ -2,7 +2,7 @@ import { expect } from '@playwright/test'; import { testFactory, getErrorOverlayContent } from './test-utils.js'; const test = testFactory({ - experimentalErrorOverlay: true, + experimental: { errorOverlay: true }, root: './fixtures/error-sass/', }); diff --git a/packages/astro/e2e/errors.test.js b/packages/astro/e2e/errors.test.js index ba43851b0237..0de23f7c19a3 100644 --- a/packages/astro/e2e/errors.test.js +++ b/packages/astro/e2e/errors.test.js @@ -2,7 +2,7 @@ import { expect } from '@playwright/test'; import { getErrorOverlayContent, testFactory } from './test-utils.js'; const test = testFactory({ - experimentalErrorOverlay: true, + experimental: { errorOverlay: true }, root: './fixtures/errors/', }); diff --git a/packages/astro/package.json b/packages/astro/package.json index 7b9d13a6dc4e..4e9e9dc91282 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -123,7 +123,7 @@ "debug": "^4.3.4", "deepmerge-ts": "^4.2.2", "diff": "^5.1.0", - "es-module-lexer": "^0.10.5", + "es-module-lexer": "^1.1.0", "execa": "^6.1.0", "fast-glob": "^3.2.11", "github-slugger": "^1.4.0", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 55620c7454f3..d5ff981d721f 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -83,6 +83,7 @@ export interface CLIFlags { config?: string; drafts?: boolean; experimentalErrorOverlay?: boolean; + experimentalPrerender?: boolean; } export interface BuildConfig { @@ -895,11 +896,41 @@ export interface AstroUserConfig { astroFlavoredMarkdown?: boolean; }; - /** - * @hidden - * Turn on experimental support for the new error overlay component. + /** + * @docs + * @kind heading + * @name Experimental Flags + * @description + * Astro offers experimental flags to give users early access to new features. + * These flags are not guaranteed to be stable. */ - experimentalErrorOverlay?: boolean; + experimental?: { + /** + * @hidden + * Turn on experimental support for the new error overlay component. + */ + errorOverlay?: boolean; + /** + * @docs + * @name experimental.prerender + * @type {boolean} + * @default `false` + * @version 1.7.0 + * @description + * Enable experimental support for prerendered pages when generating a server. + * + * To enable this feature, set `experimental.prerender` to `true` in your Astro config: + * + * ```js + * { + * experimental: { + * prerender: true, + * }, + * } + * ``` + */ + prerender?: boolean; + }; // Legacy options to be removed diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index c7b8616ef05c..529a450f9531 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -25,7 +25,7 @@ import { createLinkStylesheetElementSet, createModuleScriptElement, } from '../render/ssr-element.js'; -import { matchRoute } from '../routing/match.js'; +import { matchAssets, matchRoute } from '../routing/match.js'; export { deserializeManifest } from './common.js'; export const pagesVirtualModuleId = '@astrojs-pages-virtual-entry'; @@ -100,6 +100,8 @@ export class App { let routeData = matchRoute(pathname, this.#manifestData); if (routeData) { + const asset = matchAssets(routeData, this.#manifest.assets); + if (asset) return undefined; return routeData; } else if (matchNotFound) { return matchRoute('/404', this.#manifestData); diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts index e05ce6d9ebd4..642db2fe5e26 100644 --- a/packages/astro/src/core/build/common.ts +++ b/packages/astro/src/core/build/common.ts @@ -1,4 +1,5 @@ import npath from 'path'; +import { createHash } from 'crypto' import { fileURLToPath, pathToFileURL } from 'url'; import type { AstroConfig, RouteType } from '../../@types/astro'; import { appendForwardSlash } from '../../core/path.js'; @@ -7,7 +8,11 @@ const STATUS_CODE_PAGES = new Set(['/404', '/500']); const FALLBACK_OUT_DIR_NAME = './.astro/'; function getOutRoot(astroConfig: AstroConfig): URL { - return new URL('./', astroConfig.outDir); + if (astroConfig.output === 'static') { + return new URL('./', astroConfig.outDir); + } else { + return new URL('./', astroConfig.build.client); + } } export function getOutFolder( @@ -41,7 +46,7 @@ export function getOutFile( astroConfig: AstroConfig, outFolder: URL, pathname: string, - routeType: RouteType + routeType: RouteType, ): URL { switch (routeType) { case 'endpoint': diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index a92006fe1907..ba95faadc415 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -12,7 +12,7 @@ import type { RouteType, SSRLoadedRenderer, } from '../../@types/astro'; -import type { BuildInternals } from '../../core/build/internal.js'; +import { BuildInternals, hasPrerenderedPages } from '../../core/build/internal.js'; import { prependForwardSlash, removeLeadingForwardSlash, @@ -29,7 +29,12 @@ import { createRequest } from '../request.js'; import { matchRoute } from '../routing/match.js'; import { getOutputFilename } from '../util.js'; import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js'; -import { eachPageData, getPageDataByComponent, sortedCSS } from './internal.js'; +import { + eachPrerenderedPageData, + eachPageData, + getPageDataByComponent, + sortedCSS, +} from './internal.js'; import type { PageBuildData, SingleFileBuiltModule, StaticBuildOptions } from './types'; import { getTimeStat } from './util.js'; @@ -70,17 +75,27 @@ export function chunkIsPage( export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) { const timer = performance.now(); - info(opts.logging, null, `\n${bgGreen(black(' generating static routes '))}`); - const ssr = opts.settings.config.output === 'server'; const serverEntry = opts.buildConfig.serverEntry; const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir); + + if (opts.settings.config.experimental.prerender && opts.settings.config.output === 'server' && !hasPrerenderedPages(internals)) return; + + const verb = ssr ? 'prerendering' : 'generating'; + info(opts.logging, null, `\n${bgGreen(black(` ${verb} static routes `))}`); + const ssrEntryURL = new URL('./' + serverEntry + `?time=${Date.now()}`, outFolder); const ssrEntry = await import(ssrEntryURL.toString()); const builtPaths = new Set(); - for (const pageData of eachPageData(internals)) { - await generatePage(opts, internals, pageData, ssrEntry, builtPaths); + if (opts.settings.config.experimental.prerender && opts.settings.config.output === 'server') { + for (const pageData of eachPrerenderedPageData(internals)) { + await generatePage(opts, internals, pageData, ssrEntry, builtPaths); + } + } else { + for (const pageData of eachPageData(internals)) { + await generatePage(opts, internals, pageData, ssrEntry, builtPaths); + } } await runHookBuildGenerated({ @@ -106,7 +121,7 @@ async function generatePage( const linkIds: string[] = sortedCSS(pageData); const scripts = pageInfo?.hoistedScript ?? null; - const pageModule = ssrEntry.pageMap.get(pageData.component); + const pageModule = ssrEntry.pageMap?.get(pageData.component); if (!pageModule) { throw new Error( @@ -163,7 +178,7 @@ async function getPathsForRoute( route: pageData.route, isValidate: false, logging: opts.logging, - ssr: opts.settings.config.output === 'server', + ssr: false, }) .then((_result) => { const label = _result.staticPaths.length === 1 ? 'page' : 'pages'; diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index ffb7fb7b9ec2..9a7f973268d3 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -1,8 +1,9 @@ import type { OutputChunk, RenderedChunk } from 'rollup'; -import type { PageBuildData, ViteID } from './types'; +import type { PageBuildData, PageOutput, ViteID } from './types'; import { prependForwardSlash, removeFileExtension } from '../path.js'; import { viteID } from '../util.js'; +import { PageOptions } from '../../vite-plugin-astro/types'; export interface BuildInternals { /** @@ -20,11 +21,21 @@ export interface BuildInternals { // Used to render pages with the correct specifiers. entrySpecifierToBundleMap: Map; + /** + * A map to get a specific page's bundled output file. + */ + pageToBundleMap: Map; + /** * A map for page-specific information. */ pagesByComponent: Map; + /** + * A map for page-specific output. + */ + pageOptionsByPage: Map; + /** * A map for page-specific information by Vite ID (a path-like string) */ @@ -73,8 +84,10 @@ export function createBuildInternals(): BuildInternals { hoistedScriptIdToHoistedMap, hoistedScriptIdToPagesMap, entrySpecifierToBundleMap: new Map(), + pageToBundleMap: new Map(), pagesByComponent: new Map(), + pageOptionsByPage: new Map(), pagesByViteID: new Map(), pagesByClientOnly: new Map(), @@ -189,6 +202,31 @@ export function* eachPageData(internals: BuildInternals) { yield* internals.pagesByComponent.values(); } +export function hasPrerenderedPages(internals: BuildInternals) { + for (const id of internals.pagesByViteID.keys()) { + if (internals.pageOptionsByPage.get(id)?.prerender) { + return true + } + } + return false +} + +export function* eachPrerenderedPageData(internals: BuildInternals) { + for (const [id, pageData] of internals.pagesByViteID.entries()) { + if (internals.pageOptionsByPage.get(id)?.prerender) { + yield pageData; + } + } +} + +export function* eachServerPageData(internals: BuildInternals) { + for (const [id, pageData] of internals.pagesByViteID.entries()) { + if (!internals.pageOptionsByPage.get(id)?.prerender) { + yield pageData; + } + } +} + /** * Sort a page's CSS by depth. A higher depth means that the CSS comes from shared subcomponents. * A lower depth means it comes directly from the top-level page. diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 78c14973fc88..59941f089122 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -4,7 +4,11 @@ import { bgGreen, bgMagenta, black, dim } from 'kleur/colors'; import path from 'path'; import { fileURLToPath } from 'url'; import * as vite from 'vite'; -import { BuildInternals, createBuildInternals } from '../../core/build/internal.js'; +import { + BuildInternals, + createBuildInternals, + eachPrerenderedPageData, +} from '../../core/build/internal.js'; import { emptyDir, removeDir } from '../../core/fs/index.js'; import { prependForwardSlash } from '../../core/path.js'; import { isModeServerWithNoAdapter } from '../../core/util.js'; @@ -18,11 +22,13 @@ import { trackPageData } from './internal.js'; import type { PageBuildData, StaticBuildOptions } from './types'; import { getTimeStat } from './util.js'; import { vitePluginAnalyzer } from './vite-plugin-analyzer.js'; +import { vitePluginPrerender } from './vite-plugin-prerender.js'; import { rollupPluginAstroBuildCSS } from './vite-plugin-css.js'; import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js'; import { vitePluginInternals } from './vite-plugin-internals.js'; import { vitePluginPages } from './vite-plugin-pages.js'; import { injectManifest, vitePluginSSR } from './vite-plugin-ssr.js'; +import * as eslexer from 'es-module-lexer'; export async function staticBuild(opts: StaticBuildOptions) { const { allPages, settings } = opts; @@ -88,15 +94,33 @@ export async function staticBuild(opts: StaticBuildOptions) { await clientBuild(opts, internals, clientInput); timer.generate = performance.now(); - if (settings.config.output === 'static') { - await generatePages(opts, internals); - await cleanSsrOutput(opts); + if (!settings.config.experimental.prerender) { + if (settings.config.output === 'static') { + await generatePages(opts, internals); + await cleanServerOutput(opts); + } else { + // Inject the manifest + await injectManifest(opts, internals); + + info(opts.logging, null, `\n${bgMagenta(black(' finalizing server assets '))}\n`); + await ssrMoveAssets(opts); + } } else { - // Inject the manifest - await injectManifest(opts, internals); - - info(opts.logging, null, `\n${bgMagenta(black(' finalizing server assets '))}\n`); - await ssrMoveAssets(opts); + switch (settings.config.output) { + case 'static': { + await generatePages(opts, internals); + await cleanServerOutput(opts); + return; + } + case 'server': { + await injectManifest(opts, internals); + await generatePages(opts, internals); + await cleanStaticOutput(opts, internals); + info(opts.logging, null, `\n${bgMagenta(black(' finalizing server assets '))}\n`); + await ssrMoveAssets(opts); + return; + } + } } } @@ -134,6 +158,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp reportCompressedSize: false, }, plugins: [ + vitePluginAnalyzer(internals), vitePluginInternals(input, internals), vitePluginPages(opts, internals), rollupPluginAstroBuildCSS({ @@ -141,10 +166,10 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp internals, target: 'server', }), + vitePluginPrerender(opts, internals), ...(viteConfig.plugins || []), // SSR needs to be last - settings.config.output === 'server' && vitePluginSSR(internals, settings.adapter!), - vitePluginAnalyzer(internals), + ssr && vitePluginSSR(internals, settings.adapter!), ], envPrefix: 'PUBLIC_', base: settings.config.base, @@ -169,7 +194,12 @@ async function clientBuild( const { settings, viteConfig } = opts; const timer = performance.now(); const ssr = settings.config.output === 'server'; - const out = ssr ? opts.buildConfig.client : settings.config.outDir; + let out; + if (!opts.settings.config.experimental.prerender) { + out = ssr ? opts.buildConfig.client : settings.config.outDir; + } else { + out = ssr ? opts.buildConfig.client : getOutDirWithinCwd(settings.config.outDir); + } // Nothing to do if there is no client-side JS. if (!input.size) { @@ -232,7 +262,77 @@ async function clientBuild( return buildResult; } -async function cleanSsrOutput(opts: StaticBuildOptions) { +/** + * For each statically prerendered page, replace their SSR file with a noop. + * This allows us to run the SSR build only once, but still remove dependencies for statically rendered routes. + */ +async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInternals) { + const allStaticFiles = new Set(); + for (const pageData of eachPrerenderedPageData(internals)) { + allStaticFiles.add(internals.pageToBundleMap.get(pageData.moduleSpecifier)); + } + const ssr = opts.settings.config.output === 'server'; + const out = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir); + // The SSR output is all .mjs files, the client output is not. + const files = await glob('**/*.mjs', { + cwd: fileURLToPath(out), + }); + + if (files.length) { + await eslexer.init; + + // Cleanup prerendered chunks. + // This has to happen AFTER the SSR build runs as a final step, because we need the code in order to generate the pages. + // These chunks should only contain prerendering logic, so they are safe to modify. + await Promise.all( + files.map(async (filename) => { + if (!allStaticFiles.has(filename)) { + return; + } + const url = new URL(filename, out); + const text = await fs.promises.readFile(url, { encoding: 'utf8' }); + const [, exports] = eslexer.parse(text); + // Replace exports (only prerendered pages) with a noop + let value = 'const noop = () => {};'; + for (const e of exports) { + value += `\nexport const ${e.n} = noop;`; + } + await fs.promises.writeFile(url, value, { encoding: 'utf8' }); + }) + ); + // Map directories heads from the .mjs files + const directories: Set = new Set(); + files.forEach((i) => { + const splitFilePath = i.split(path.sep); + // If the path is more than just a .mjs filename itself + if (splitFilePath.length > 1) { + directories.add(splitFilePath[0]); + } + }); + // Attempt to remove only those folders which are empty + await Promise.all( + Array.from(directories).map(async (filename) => { + const url = new URL(filename, out); + const folder = await fs.promises.readdir(url); + if (!folder.length) { + await fs.promises.rm(url, { recursive: true, force: true }); + } + }) + ); + } + + if (!opts.settings.config.experimental.prerender) { + // Clean out directly if the outDir is outside of root + if (out.toString() !== opts.settings.config.outDir.toString()) { + // Copy assets before cleaning directory if outside root + copyFiles(out, opts.settings.config.outDir); + await fs.promises.rm(out, { recursive: true }); + return; + } + } +} + +async function cleanServerOutput(opts: StaticBuildOptions) { const out = getOutDirWithinCwd(opts.settings.config.outDir); // The SSR output is all .mjs files, the client output is not. const files = await glob('**/*.mjs', { @@ -259,8 +359,8 @@ async function cleanSsrOutput(opts: StaticBuildOptions) { await Promise.all( Array.from(directories).map(async (filename) => { const url = new URL(filename, out); - const folder = await fs.promises.readdir(url); - if (!folder.length) { + const dir = await glob(fileURLToPath(url), { absolute: true }); + if (!dir.length) { await fs.promises.rm(url, { recursive: true, force: true }); } }) @@ -303,16 +403,16 @@ async function ssrMoveAssets(opts: StaticBuildOptions) { cwd: fileURLToPath(serverRoot), }); - // Make the directory - await fs.promises.mkdir(clientAssets, { recursive: true }); - - await Promise.all( - files.map(async (filename) => { - const currentUrl = new URL(filename, serverRoot); - const clientUrl = new URL(filename, clientRoot); - return fs.promises.rename(currentUrl, clientUrl); - }) - ); - - removeDir(serverAssets); + if (files.length > 0) { + // Make the directory + await fs.promises.mkdir(clientAssets, { recursive: true }); + await Promise.all( + files.map(async (filename) => { + const currentUrl = new URL(filename, serverRoot); + const clientUrl = new URL(filename, clientRoot); + return fs.promises.rename(currentUrl, clientUrl); + }) + ); + removeDir(serverAssets); + } } diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts index 24b2b8f7b75c..fb586001534b 100644 --- a/packages/astro/src/core/build/types.ts +++ b/packages/astro/src/core/build/types.ts @@ -1,5 +1,6 @@ import type { InlineConfig } from 'vite'; import type { + AstroConfig, AstroSettings, BuildConfig, ComponentInstance, @@ -13,6 +14,7 @@ import type { RouteCache } from '../render/route-cache'; export type ComponentPath = string; export type ViteID = string; +export type PageOutput = AstroConfig['output'] export interface PageBuildData { component: ComponentPath; diff --git a/packages/astro/src/core/build/vite-plugin-analyzer.ts b/packages/astro/src/core/build/vite-plugin-analyzer.ts index 21aa1c2be00a..0e6a991bd652 100644 --- a/packages/astro/src/core/build/vite-plugin-analyzer.ts +++ b/packages/astro/src/core/build/vite-plugin-analyzer.ts @@ -74,12 +74,18 @@ export function vitePluginAnalyzer(internals: BuildInternals): VitePlugin { const hoistScanner = hoistedScriptScanner(); const ids = this.getModuleIds(); + for (const id of ids) { const info = this.getModuleInfo(id); if (!info || !info.meta?.astro) continue; const astro = info.meta.astro as AstroPluginMetadata['astro']; + const pageData = getPageDataByViteID(internals, id); + if (pageData) { + internals.pageOptionsByPage.set(id, astro.pageOptions); + } + for (const c of astro.hydratedComponents) { const rid = c.resolvedPath ? decodeURI(c.resolvedPath) : c.specifier; internals.discoveredHydratedComponents.add(rid); @@ -103,10 +109,10 @@ export function vitePluginAnalyzer(internals: BuildInternals): VitePlugin { } for (const [pageInfo] of getTopLevelPages(id, this)) { - const pageData = getPageDataByViteID(internals, pageInfo.id); - if (!pageData) continue; + const newPageData = getPageDataByViteID(internals, pageInfo.id); + if (!newPageData) continue; - trackClientOnlyPageDatas(internals, pageData, clientOnlys); + trackClientOnlyPageDatas(internals, newPageData, clientOnlys); } } } diff --git a/packages/astro/src/core/build/vite-plugin-internals.ts b/packages/astro/src/core/build/vite-plugin-internals.ts index b0f10f0fd156..6e6b1e90cb03 100644 --- a/packages/astro/src/core/build/vite-plugin-internals.ts +++ b/packages/astro/src/core/build/vite-plugin-internals.ts @@ -48,6 +48,13 @@ export function vitePluginInternals(input: Set, internals: BuildInternal if (chunk.type === 'chunk' && chunk.facadeModuleId) { const specifier = mapping.get(chunk.facadeModuleId) || chunk.facadeModuleId; internals.entrySpecifierToBundleMap.set(specifier, chunk.fileName); + } else if (chunk.type === 'chunk') { + for (const id of Object.keys(chunk.modules)) { + const pageData = internals.pagesByViteID.get(id); + if (pageData) { + internals.pageToBundleMap.set(pageData.moduleSpecifier, chunk.fileName) + } + } } } }, diff --git a/packages/astro/src/core/build/vite-plugin-pages.ts b/packages/astro/src/core/build/vite-plugin-pages.ts index 7b81ba39825c..83e9f4435597 100644 --- a/packages/astro/src/core/build/vite-plugin-pages.ts +++ b/packages/astro/src/core/build/vite-plugin-pages.ts @@ -1,7 +1,7 @@ import type { Plugin as VitePlugin } from 'vite'; import { pagesVirtualModuleId, resolvedPagesVirtualModuleId } from '../app/index.js'; import { addRollupInput } from './add-rollup-input.js'; -import type { BuildInternals } from './internal.js'; +import { BuildInternals, hasPrerenderedPages } from './internal.js'; import { eachPageData } from './internal.js'; import type { StaticBuildOptions } from './types'; @@ -10,7 +10,7 @@ export function vitePluginPages(opts: StaticBuildOptions, internals: BuildIntern name: '@astro/plugin-build-pages', options(options) { - if (opts.settings.config.output === 'static') { + if (opts.settings.config.output === 'static' || hasPrerenderedPages(internals)) { return addRollupInput(options, [pagesVirtualModuleId]); } }, diff --git a/packages/astro/src/core/build/vite-plugin-prerender.ts b/packages/astro/src/core/build/vite-plugin-prerender.ts new file mode 100644 index 000000000000..974bdf41f65c --- /dev/null +++ b/packages/astro/src/core/build/vite-plugin-prerender.ts @@ -0,0 +1,43 @@ +import type { Plugin as VitePlugin } from 'vite'; +import type { BuildInternals } from './internal.js'; +import type { StaticBuildOptions } from './types'; + +export function vitePluginPrerender(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin { + return { + name: 'astro:rollup-plugin-prerender', + + outputOptions(outputOptions) { + // No-op if `prerender` is not enabled + if (!opts.settings.config.experimental.prerender) return; + + const manualChunks = outputOptions.manualChunks || Function.prototype; + outputOptions.manualChunks = function (id, api, ...args) { + // Defer to user-provided `manualChunks`, if it was provided. + if (typeof manualChunks == 'object') { + if (id in manualChunks) { + return manualChunks[id]; + } + } else if (typeof manualChunks === 'function') { + const outid = manualChunks.call(this, id, api, ...args); + if (outid) { + return outid; + } + } + // Split the Astro runtime into a separate chunk for readability + if (id.includes('astro/dist')) { + return 'astro'; + } + const pageInfo = internals.pagesByViteID.get(id); + if (pageInfo) { + // prerendered pages should be split into their own chunk + // Important: this can't be in the `pages/` directory! + if (api.getModuleInfo(id)?.meta.astro?.pageOptions?.prerender) { + return `prerender`; + } + // pages should go in their own chunks/pages/* directory + return `pages${pageInfo.route.route.replace(/\/$/, '/index')}`; + } + }; + }, + } +} diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts index e0d6ca112e02..03a66e126b61 100644 --- a/packages/astro/src/core/build/vite-plugin-ssr.ts +++ b/packages/astro/src/core/build/vite-plugin-ssr.ts @@ -13,7 +13,8 @@ import { pagesVirtualModuleId } from '../app/index.js'; import { removeLeadingForwardSlash, removeTrailingForwardSlash } from '../path.js'; import { serializeRouteData } from '../routing/index.js'; import { addRollupInput } from './add-rollup-input.js'; -import { eachPageData, sortedCSS } from './internal.js'; +import { eachServerPageData, eachPrerenderedPageData, sortedCSS } from './internal.js'; +import { getOutFile, getOutFolder } from './common.js'; export const virtualModuleId = '@astrojs-ssr-virtual-entry'; const resolvedVirtualModuleId = '\0' + virtualModuleId; @@ -43,6 +44,8 @@ const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { }); const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'}; +export * from '${pagesVirtualModuleId}'; + ${ adapter.exports ? `const _exports = adapter.createExports(_manifest, _args); @@ -136,7 +139,20 @@ function buildManifest( const bareBase = removeTrailingForwardSlash(removeLeadingForwardSlash(settings.config.base)); const joinBase = (pth: string) => (bareBase ? bareBase + '/' + pth : pth); - for (const pageData of eachPageData(internals)) { + for (const pageData of eachPrerenderedPageData(internals)) { + const outFolder = getOutFolder(opts.settings.config, pageData.route.pathname!, pageData.route.type); + const outFile = getOutFile(opts.settings.config, outFolder, pageData.route.pathname!, pageData.route.type); + const file = outFile.toString().replace(opts.settings.config.build.client.toString(), ''); + routes.push({ + file, + links: [], + scripts: [], + routeData: serializeRouteData(pageData.route, settings.config.trailingSlash), + }); + staticFiles.push(file); + } + + for (const pageData of eachServerPageData(internals)) { const scripts: SerializedRouteInfo['scripts'] = []; if (pageData.hoistedScript) { scripts.unshift( diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts index 797b3f1ff780..3524455f6c38 100644 --- a/packages/astro/src/core/config/config.ts +++ b/packages/astro/src/core/config/config.ts @@ -104,6 +104,10 @@ export function resolveFlags(flags: Partial): CLIFlags { typeof flags.experimentalErrorOverlay === 'boolean' ? flags.experimentalErrorOverlay : undefined, + experimentalPrerender: + typeof flags.experimentalPrerender === 'boolean' + ? flags.experimentalPrerender + : undefined, }; } @@ -118,6 +122,7 @@ export function resolveRoot(cwd?: string | URL): string { function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags, cmd: string) { astroConfig.server = astroConfig.server || {}; astroConfig.markdown = astroConfig.markdown || {}; + astroConfig.experimental = astroConfig.experimental || {}; if (typeof flags.site === 'string') astroConfig.site = flags.site; if (typeof flags.base === 'string') astroConfig.base = flags.base; if (typeof flags.drafts === 'boolean') astroConfig.markdown.drafts = flags.drafts; @@ -131,7 +136,8 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags, cmd: strin // TODO: Come back here and refactor to remove this expected error. astroConfig.server.host = flags.host; } - astroConfig.experimentalErrorOverlay = flags.experimentalErrorOverlay ?? false; + if (flags.experimentalErrorOverlay) astroConfig.experimental.errorOverlay = true; + if (flags.experimentalPrerender) astroConfig.experimental.prerender = true; return astroConfig; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 33ef28aa4839..4c119a859c32 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -47,7 +47,10 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = { legacy: { astroFlavoredMarkdown: false, }, - experimentalErrorOverlay: false, + experimental: { + errorOverlay: false, + prerender: false, + }, }; export const AstroConfigSchema = z.object({ @@ -188,6 +191,13 @@ export const AstroConfigSchema = z.object({ vite: z .custom((data) => data instanceof Object && !Array.isArray(data)) .default(ASTRO_CONFIG_DEFAULTS.vite), + experimental: z + .object({ + errorOverlay: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.errorOverlay), + prerender: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.prerender), + }) + .optional() + .default({}), legacy: z .object({ astroFlavoredMarkdown: z @@ -197,7 +207,6 @@ export const AstroConfigSchema = z.object({ }) .optional() .default({}), - experimentalErrorOverlay: z.boolean().optional().default(false), }); interface PostCSSConfigResult { diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 5b8991f02725..9b6b9b3ab879 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -20,6 +20,7 @@ import markdownVitePlugin from '../vite-plugin-markdown/index.js'; import astroScriptsPlugin from '../vite-plugin-scripts/index.js'; import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js'; import { createCustomViteLogger } from './errors/dev/index.js'; +import astroScannerPlugin from '../vite-plugin-scanner/index.js'; import { resolveDependency } from './util.js'; interface CreateViteOptions { @@ -114,6 +115,7 @@ export async function createVite( astroIntegrationsContainerPlugin({ settings, logging }), astroScriptsPageSSRPlugin({ settings }), astroHeadPropagationPlugin({ settings }), + settings.config.experimental.prerender && astroScannerPlugin({ settings, logging }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 62ccbfd68333..fb219d876bd2 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -393,6 +393,22 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati `Could not render \`${componentName}\`. No matching import has been found for \`${componentName}\`.`, hint: 'Please make sure the component is properly imported.', }, + /** + * @docs + * @description + * A `prerender` export was detected, but the value was not statically analyzable. Values computed at runtime are not supported, so `export const prerender` can only be set to `true` or `false`. Variables are not supported. + */ + InvalidPrerenderExport: { + title: 'Invalid prerender export.', + code: 3019, + message: (prefix: string, suffix: string) => { + let msg = `A \`prerender\` export has been detected, but its value cannot be statically analyzed.`; + if (prefix !== 'const') msg += `\nExpected \`const\` declaration but got \`${prefix}\`.` + if (suffix !== 'true') msg += `\nExpected \`true\` value but got \`${suffix}\`.` + return msg; + }, + hint: 'Mutable values declared at runtime are not supported. Please make sure to use exactly `export const prerender = true`.', + }, // Vite Errors - 4xxx UnknownViteError: { title: 'Unknown Vite Error.', diff --git a/packages/astro/src/core/errors/overlay.ts b/packages/astro/src/core/errors/overlay.ts index 43165729274c..464255a97c0a 100644 --- a/packages/astro/src/core/errors/overlay.ts +++ b/packages/astro/src/core/errors/overlay.ts @@ -561,7 +561,7 @@ function getOverlayCode() { } export function patchOverlay(code: string, config: AstroConfig) { - if (config.experimentalErrorOverlay) { + if (config.experimental.errorOverlay) { return code.replace('class ErrorOverlay', getOverlayCode() + '\nclass ViteErrorOverlay'); } else { // Legacy overlay diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts index bc49879267ee..cb742a5aa6b5 100644 --- a/packages/astro/src/core/routing/match.ts +++ b/packages/astro/src/core/routing/match.ts @@ -5,6 +5,19 @@ export function matchRoute(pathname: string, manifest: ManifestData): RouteData return manifest.routes.find((route) => route.pattern.test(pathname)); } +/** Find matching static asset from pathname */ +export function matchAssets(route: RouteData, assets: Set): string | undefined { + for (const asset of assets) { + if (!asset.endsWith('.html')) continue; + if (route.pattern.test(asset)) { + return asset; + } + if (route.pattern.test(asset.replace(/index\.html$/, ''))) { + return asset; + } + } +} + /** Finds all matching routes from pathname */ export function matchAllRoutes(pathname: string, manifest: ManifestData): RouteData[] { return manifest.routes.filter((route) => route.pattern.test(pathname)); diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 01ed44237d06..d6f95062a958 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -140,6 +140,12 @@ export function isPage(file: URL, settings: AstroSettings): boolean { return endsWithPageExt(file, settings); } +export function isEndpoint(file: URL, settings: AstroSettings): boolean { + if (!isInPagesDir(file, settings.config)) return false; + if (!isPublicRoute(file, settings.config)) return false; + return !endsWithPageExt(file, settings); +} + export function isModeServerWithNoAdapter(settings: AstroSettings): boolean { return settings.config.output === 'server' && !settings.adapter; } diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index 7e826d48d9f2..1769cf90b9ae 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { bold } from 'kleur/colors'; import type { AddressInfo } from 'net'; import { fileURLToPath } from 'node:url'; @@ -345,6 +346,7 @@ export async function runHookBuildDone({ logging: LogOptions; }) { const dir = config.output === 'server' ? buildConfig.client : config.outDir; + await fs.promises.mkdir(dir, { recursive: true }); for (const integration of config.integrations) { if (integration?.hooks?.['astro:build:done']) { diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts index 8e8df454aca5..88b01ad8de8a 100644 --- a/packages/astro/src/jsx/babel.ts +++ b/packages/astro/src/jsx/babel.ts @@ -146,6 +146,7 @@ export default function astroJSX(): PluginObj { hydratedComponents: [], scripts: [], propagation: 'none', + pageOptions: {}, }; } path.node.body.splice( diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts index 0ecf912687f0..80b1aca829fe 100644 --- a/packages/astro/src/vite-plugin-astro/index.ts +++ b/packages/astro/src/vite-plugin-astro/index.ts @@ -220,6 +220,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P hydratedComponents: transformResult.hydratedComponents, scripts: transformResult.scripts, propagation: 'none', + pageOptions: {}, }; return { diff --git a/packages/astro/src/vite-plugin-astro/types.ts b/packages/astro/src/vite-plugin-astro/types.ts index c9ac8332ca99..ebb1c7d395fd 100644 --- a/packages/astro/src/vite-plugin-astro/types.ts +++ b/packages/astro/src/vite-plugin-astro/types.ts @@ -1,11 +1,16 @@ import type { TransformResult } from '@astrojs/compiler'; import type { PropagationHint } from '../@types/astro'; +export interface PageOptions { + prerender?: boolean; +} + export interface PluginMetadata { astro: { hydratedComponents: TransformResult['hydratedComponents']; clientOnlyComponents: TransformResult['clientOnlyComponents']; scripts: TransformResult['scripts']; propagation: PropagationHint; + pageOptions: PageOptions; }; } diff --git a/packages/astro/src/vite-plugin-markdown-legacy/index.ts b/packages/astro/src/vite-plugin-markdown-legacy/index.ts index e0d3f4d62a8d..b72418bdb08d 100644 --- a/packages/astro/src/vite-plugin-markdown-legacy/index.ts +++ b/packages/astro/src/vite-plugin-markdown-legacy/index.ts @@ -234,6 +234,7 @@ ${tsResult}`; hydratedComponents: transformResult.hydratedComponents, scripts: transformResult.scripts, propagation: 'none', + pageOptions: {}, }; return { diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 46d84fffc076..17d4595701f5 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -158,6 +158,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu clientOnlyComponents: [], scripts: [], propagation: 'none', + pageOptions: {}, } as PluginMetadata['astro'], vite: { lang: 'ts', diff --git a/packages/astro/src/vite-plugin-scanner/index.ts b/packages/astro/src/vite-plugin-scanner/index.ts new file mode 100644 index 000000000000..1213652a17a3 --- /dev/null +++ b/packages/astro/src/vite-plugin-scanner/index.ts @@ -0,0 +1,44 @@ +import { Plugin as VitePlugin } from 'vite'; +import { AstroSettings } from '../@types/astro.js'; +import { isPage, isEndpoint } from '../core/util.js'; +import type { LogOptions } from '../core/logger/core.js'; +import { normalizeFilename } from '../vite-plugin-utils/index.js'; + +import { scan } from './scan.js'; + +export default function astroScannerPlugin({ settings, logging }: { settings: AstroSettings, logging: LogOptions }): VitePlugin { + return { + name: 'astro:scanner', + enforce: 'post', + + async transform(this, code, id, options) { + if (!options?.ssr) return; + + const filename = normalizeFilename(id, settings.config); + let fileURL: URL; + try { + fileURL = new URL(`file://${filename}`); + } catch (e) { + // If we can't construct a valid URL, exit early + return; + } + + const fileIsPage = isPage(fileURL, settings); + const fileIsEndpoint = isEndpoint(fileURL, settings); + if (!(fileIsPage || fileIsEndpoint)) return; + const pageOptions = await scan(code, id) + + const { meta = {} } = this.getModuleInfo(id) ?? {}; + return { + code, + meta: { + ...meta, + astro: { + ...(meta.astro ?? { hydratedComponents: [], clientOnlyComponents: [], scripts: [] }), + pageOptions, + }, + }, + }; + }, + }; +} diff --git a/packages/astro/src/vite-plugin-scanner/scan.ts b/packages/astro/src/vite-plugin-scanner/scan.ts new file mode 100644 index 000000000000..51529e90445a --- /dev/null +++ b/packages/astro/src/vite-plugin-scanner/scan.ts @@ -0,0 +1,48 @@ +import * as eslexer from 'es-module-lexer'; +import { PageOptions } from '../vite-plugin-astro/types.js'; +import { AstroError, AstroErrorCodes, AstroErrorData } from '../core/errors/index.js' + +const BOOLEAN_EXPORTS = new Set(['prerender']); + +// Quick scan to determine if code includes recognized export +// False positives are not a problem, so be forgiving! +function includesExport(code: string) { + for (const name of BOOLEAN_EXPORTS) { + if (code.includes(name)) return true; + } + return false; +} + +let didInit = false; + +export async function scan(code: string, id: string): Promise { + if (!includesExport(code)) return {}; + if (!didInit) { + await eslexer.init; + didInit = true; + } + + const [_, exports] = eslexer.parse(code, id); + let pageOptions: PageOptions = {}; + for (const _export of exports) { + const { n: name, le: endOfLocalName } = _export; + if (BOOLEAN_EXPORTS.has(name)) { + // For a given export, check the value of the local declaration + // Basically extract the `const` from the statement `export const prerender = true` + const prefix = code.slice(0, endOfLocalName).split('export').pop()!.trim().replace('prerender', '').trim(); + // For a given export, check the value of the first non-whitespace token. + // Basically extract the `true` from the statement `export const prerender = true` + const suffix = code.slice(endOfLocalName).trim().replace(/\=/, '').trim().split(/[;\n]/)[0]; + if (prefix !== 'const' || !(suffix === 'true' || suffix === 'false')) { + throw new AstroError({ + ...AstroErrorData.InvalidPrerenderExport, + message: AstroErrorData.InvalidPrerenderExport.message(prefix, suffix), + location: { file: id } + }); + } else { + pageOptions[name as keyof PageOptions] = suffix === 'true'; + } + } + } + return pageOptions; +} diff --git a/packages/astro/test/fixtures/ssr-prerender/package.json b/packages/astro/test/fixtures/ssr-prerender/package.json new file mode 100644 index 000000000000..28c9554dbc0d --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/ssr-prerender", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/ssr-prerender/src/pages/static.astro b/packages/astro/test/fixtures/ssr-prerender/src/pages/static.astro new file mode 100644 index 000000000000..54680cfcb922 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender/src/pages/static.astro @@ -0,0 +1,18 @@ +--- +export const prerender = true; + +const { searchParams } = Astro.url; +--- + + + + Static Page + + + +

Hello world!

+
{searchParams.get('q')}
+ + diff --git a/packages/astro/test/fixtures/ssr-prerender/src/pages/users/[id].astro b/packages/astro/test/fixtures/ssr-prerender/src/pages/users/[id].astro new file mode 100644 index 000000000000..7f616356ef88 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-prerender/src/pages/users/[id].astro @@ -0,0 +1,12 @@ +--- +const { id } = Astro.params; +--- + + + Testing + + +

Testing

+

{ id }

+ + diff --git a/packages/astro/test/ssr-prerender.test.js b/packages/astro/test/ssr-prerender.test.js new file mode 100644 index 000000000000..6e5d854b6fa1 --- /dev/null +++ b/packages/astro/test/ssr-prerender.test.js @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; +import testAdapter from './test-adapter.js'; + +describe('SSR: prerender', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-prerender/', + output: 'server', + adapter: testAdapter(), + experimental: { + prerender: true, + }, + }); + await fixture.build(); + }); + + describe('Prerendering', () => { + // Prerendered assets are not served directly by `app`, + // they're served _in front of_ the app as static assets! + it('Does not render static page', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/static'); + const response = await app.render(request); + expect(response.status).to.equal(404); + }); + + it('includes prerendered pages in the asset manifest', async () => { + const app = await fixture.loadTestAdapterApp(); + /** @type {Set} */ + const assets = app.manifest.assets; + expect(assets.size).to.equal(1); + expect(Array.from(assets)[0].endsWith('static/index.html')).to.be.true; + }); + }); + + describe('Astro.params in SSR', () => { + it('Params are passed to component', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/users/houston'); + const response = await app.render(request); + expect(response.status).to.equal(200); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('.user').text()).to.equal('houston'); + }); + }); +}); diff --git a/packages/astro/test/units/vite-plugin-astro/compile.test.js b/packages/astro/test/units/vite-plugin-astro/compile.test.js index 49fedb3ed976..db3f4d666bbc 100644 --- a/packages/astro/test/units/vite-plugin-astro/compile.test.js +++ b/packages/astro/test/units/vite-plugin-astro/compile.test.js @@ -70,8 +70,9 @@ const name = 'world const result = await compile(`

Hello World

`, '/src/components/index.astro'); await init; const [, exports] = parse(result.code); - expect(exports).to.include('default'); - expect(exports).to.include('file'); - expect(exports).to.include('url'); + const names = exports.map(e => e.n); + expect(names).to.include('default'); + expect(names).to.include('file'); + expect(names).to.include('url'); }); }); diff --git a/packages/astro/test/units/vite-plugin-scanner/scan.test.js b/packages/astro/test/units/vite-plugin-scanner/scan.test.js new file mode 100644 index 000000000000..f27286da43ee --- /dev/null +++ b/packages/astro/test/units/vite-plugin-scanner/scan.test.js @@ -0,0 +1,79 @@ +import { expect } from 'chai'; +import { scan } from '../../../dist/vite-plugin-scanner/scan.js'; + +describe('astro scan', () => { + it('should return empty object', async () => { + const result = await scan(`export {}`, '/src/components/index.astro'); + expect(Object.keys(result).length).to.equal(0); + }); + + it('recognizes constant boolean literal (false)', async () => { + const result = await scan(`export const prerender = true;`, '/src/components/index.astro'); + expect(result.prerender).to.equal(true); + }); + + it('recognizes constant boolean literal (false)', async () => { + const result = await scan(`export const prerender = false;`, '/src/components/index.astro'); + expect(result.prerender).to.equal(false); + }); + + it('throws on let boolean literal', async () => { + try { + const result = await scan(`export let prerender = true;`, '/src/components/index.astro'); + expect(false).to.be.true; + } catch (e) { + expect(e.errorCode).to.equal(3019); + expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`); + } + }); + + it('throws on var boolean literal', async () => { + try { + const result = await scan(`export var prerender = true;`, '/src/components/index.astro'); + expect(false).to.be.true; + } catch (e) { + expect(e.errorCode).to.equal(3019); + expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`); + } + }); + + it('throws on unknown values I', async () => { + try { + const result = await scan(`export const prerender = !!value;`, '/src/components/index.astro'); + expect(false).to.be.true; + } catch (e) { + expect(e.errorCode).to.equal(3019); + expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`); + } + }); + + it('throws on unknown values II', async () => { + try { + const result = await scan(`export const prerender = value;`, '/src/components/index.astro'); + expect(false).to.be.true; + } catch (e) { + expect(e.errorCode).to.equal(3019); + expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`); + } + }); + + it('throws on unknown values III', async () => { + try { + const result = await scan(`export let prerender = undefined; prerender = true;`, '/src/components/index.astro'); + expect(false).to.be.true; + } catch (e) { + expect(e.errorCode).to.equal(3019); + expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`); + } + }); + + it('throws on unknown values IV', async () => { + try { + const result = await scan(`let prerender = true; export { prerender }`, '/src/components/index.astro'); + expect(false).to.be.true; + } catch (e) { + expect(e.errorCode).to.equal(3019); + expect(e.message).to.contain(`A \`prerender\` export has been detected, but its value cannot be statically analyzed.`); + } + }); +}); diff --git a/packages/integrations/netlify/src/shared.ts b/packages/integrations/netlify/src/shared.ts index c0d8a4ce523f..2c648984a2b6 100644 --- a/packages/integrations/netlify/src/shared.ts +++ b/packages/integrations/netlify/src/shared.ts @@ -14,18 +14,28 @@ export async function createRedirects( let _redirects = ''; for (const route of routes) { if (route.pathname) { - _redirects += ` + if (route.distURL) { + _redirects += ` + ${route.pathname} /${route.distURL.toString().replace(dir.toString(), '')} 200`; + } else { + _redirects += ` ${route.pathname} /.netlify/${kind}/${entryFile} 200`; - if (route.route === '/404') { - _redirects += ` + if (route.route === '/404') { + _redirects += ` /* /.netlify/${kind}/${entryFile} 404`; + } } } else { const pattern = '/' + route.segments.map(([part]) => (part.dynamic ? '*' : part.content)).join('/'); - _redirects += ` + if (route.distURL) { + _redirects += ` + ${pattern} /${route.distURL.toString().replace(dir.toString(), '')} 200`; + } else { + _redirects += ` ${pattern} /.netlify/${kind}/${entryFile} 200`; + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31afd68d4e5a..8e3446dc0aa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,7 +422,7 @@ importers: deepmerge-ts: ^4.2.2 diff: ^5.1.0 eol: ^0.9.1 - es-module-lexer: ^0.10.5 + es-module-lexer: ^1.1.0 execa: ^6.1.0 fast-glob: ^3.2.11 github-slugger: ^1.4.0 @@ -494,7 +494,7 @@ importers: debug: 4.3.4 deepmerge-ts: 4.2.2 diff: 5.1.0 - es-module-lexer: 0.10.5 + es-module-lexer: 1.1.0 execa: 6.1.0 fast-glob: 3.2.12 github-slugger: 1.5.0 @@ -1115,6 +1115,9 @@ importers: '@astrojs/node': link:../../../../integrations/node astro: link:../../.. + packages/astro/test/benchmark/simple/dist/server: + specifiers: {} + packages/astro/test/fixtures/0-css: specifiers: '@astrojs/react': workspace:* @@ -1655,6 +1658,9 @@ importers: dependencies: astro: link:../../.. + packages/astro/test/fixtures/config-vite/dist: + specifiers: {} + packages/astro/test/fixtures/css-assets: specifiers: '@astrojs/test-font-awesome-package': file:packages/font-awesome @@ -2308,6 +2314,12 @@ importers: '@astrojs/partytown': link:../../../../integrations/partytown astro: link:../../.. + packages/astro/test/fixtures/ssr-prerender: + specifiers: + astro: workspace:* + dependencies: + astro: link:../../.. + packages/astro/test/fixtures/ssr-preview: specifiers: astro: workspace:* @@ -11819,6 +11831,10 @@ packages: resolution: {integrity: sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==} dev: false + /es-module-lexer/1.1.0: + resolution: {integrity: sha512-fJg+1tiyEeS8figV+fPcPpm8WqJEflG3yPU0NOm5xMvrNkuiy7HzX/Ljng4Y0hAoiw4/3hQTCFYw+ub8+a2pRA==} + dev: false + /es-shim-unscopables/1.0.0: resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} dependencies: