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 <swithinbank@gmail.com>

* Update packages/astro/src/core/errors/errors-data.ts

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>

Co-authored-by: Nate Moore <nate@astro.build>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
This commit is contained in:
Nate Moore 2022-12-16 11:38:37 -05:00 committed by GitHub
parent 7cbe7f5623
commit d2960984c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 701 additions and 68 deletions

View file

@ -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.

View file

@ -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/',
});

View file

@ -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/',
});

View file

@ -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/',
});

View file

@ -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/',
});

View file

@ -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",

View file

@ -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

View file

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

View file

@ -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':

View file

@ -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<string>();
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';

View file

@ -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<string, string>;
/**
* A map to get a specific page's bundled output file.
*/
pageToBundleMap: Map<string, string>;
/**
* A map for page-specific information.
*/
pagesByComponent: Map<string, PageBuildData>;
/**
* A map for page-specific output.
*/
pageOptionsByPage: Map<string, PageOptions>;
/**
* 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<string, string>(),
pageToBundleMap: new Map<string, string>(),
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.

View file

@ -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);
} else {
// Inject the manifest
await injectManifest(opts, internals);
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);
info(opts.logging, null, `\n${bgMagenta(black(' finalizing server assets '))}\n`);
await ssrMoveAssets(opts);
}
} else {
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<string> = 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);
}
}

View file

@ -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;

View file

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

View file

@ -48,6 +48,13 @@ export function vitePluginInternals(input: Set<string>, 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)
}
}
}
}
},

View file

@ -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]);
}
},

View file

@ -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')}`;
}
};
},
}
}

View file

@ -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(

View file

@ -104,6 +104,10 @@ export function resolveFlags(flags: Partial<Flags>): 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;
}

View file

@ -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<ViteUserConfig>((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 {

View file

@ -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),

View file

@ -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.',

View file

@ -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

View file

@ -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>): 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));

View file

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

View file

@ -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']) {

View file

@ -146,6 +146,7 @@ export default function astroJSX(): PluginObj {
hydratedComponents: [],
scripts: [],
propagation: 'none',
pageOptions: {},
};
}
path.node.body.splice(

View file

@ -220,6 +220,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
hydratedComponents: transformResult.hydratedComponents,
scripts: transformResult.scripts,
propagation: 'none',
pageOptions: {},
};
return {

View file

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

View file

@ -234,6 +234,7 @@ ${tsResult}`;
hydratedComponents: transformResult.hydratedComponents,
scripts: transformResult.scripts,
propagation: 'none',
pageOptions: {},
};
return {

View file

@ -158,6 +158,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
clientOnlyComponents: [],
scripts: [],
propagation: 'none',
pageOptions: {},
} as PluginMetadata['astro'],
vite: {
lang: 'ts',

View file

@ -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,
},
},
};
},
};
}

View file

@ -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<PageOptions> {
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;
}

View file

@ -0,0 +1,8 @@
{
"name": "@test/ssr-prerender",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,18 @@
---
export const prerender = true;
const { searchParams } = Astro.url;
---
<html>
<head>
<title>Static Page</title>
<script>
console.log('hello world');
</script>
</head>
<body>
<h1 id="greeting">Hello world!</h1>
<div id="searchparams">{searchParams.get('q')}</div>
</body>
</html>

View file

@ -0,0 +1,12 @@
---
const { id } = Astro.params;
---
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
<h2 class="user">{ id }</h2>
</body>
</html>

View file

@ -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<string>} */
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');
});
});
});

View file

@ -70,8 +70,9 @@ const name = 'world
const result = await compile(`<h1>Hello World</h1>`, '/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');
});
});

View file

@ -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.`);
}
});
});

View file

@ -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`;
}
}
}

View file

@ -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: