diff --git a/.changeset/mighty-shoes-scream.md b/.changeset/mighty-shoes-scream.md new file mode 100644 index 000000000..adacd6eca --- /dev/null +++ b/.changeset/mighty-shoes-scream.md @@ -0,0 +1,39 @@ +--- +'astro': minor +'@astrojs/cloudflare': patch +'@astrojs/netlify': patch +'@astrojs/vercel': patch +'@astrojs/image': patch +'@astrojs/deno': patch +'@astrojs/node': patch +--- + +Enable experimental support for hybrid SSR with pre-rendering enabled by default + +__astro.config.mjs__ + ```js +import { defineConfig } from 'astro/config'; +export defaultdefineConfig({ + output: 'hybrid', + experimental: { + hybridOutput: true, + }, +}) + ``` +Then add `export const prerender = false` to any page or endpoint you want to opt-out of pre-rendering. + +__src/pages/contact.astro__ +```astro +--- +export const prerender = false + +if (Astro.request.method === 'POST') { + // handle form submission +} +--- +
+ + + +
+``` diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index e20e0e5a8..19606b070 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -557,7 +557,7 @@ export interface AstroUserConfig { /** * @docs * @name output - * @type {('static' | 'server')} + * @type {('static' | 'server' | 'hybrid')} * @default `'static'` * @see adapter * @description @@ -566,6 +566,7 @@ export interface AstroUserConfig { * * - 'static' - Building a static site to be deploy to any static host. * - 'server' - Building an app to be deployed to a host supporting SSR (server-side rendering). + * - 'hybrid' - Building a static site with a few server-side rendered pages. * * ```js * import { defineConfig } from 'astro/config'; @@ -575,7 +576,7 @@ export interface AstroUserConfig { * }) * ``` */ - output?: 'static' | 'server'; + output?: 'static' | 'server' | 'hybrid'; /** * @docs @@ -616,14 +617,14 @@ export interface AstroUserConfig { * @type {string} * @default `'./dist/client'` * @description - * Controls the output directory of your client-side CSS and JavaScript when `output: 'server'` only. + * Controls the output directory of your client-side CSS and JavaScript when `output: 'server'` or `output: 'hybrid'` only. * `outDir` controls where the code is built to. * * This value is relative to the `outDir`. * * ```js * { - * output: 'server', + * output: 'server', // or 'hybrid' * build: { * client: './client' * } @@ -1121,6 +1122,44 @@ export interface AstroUserConfig { * ``` */ middleware?: boolean; + + /** + * @docs + * @name experimental.hybridOutput + * @type {boolean} + * @default `false` + * @version 2.5.0 + * @description + * Enable experimental support for hybrid SSR with pre-rendering enabled by default. + * + * To enable this feature, first set `experimental.hybridOutput` to `true` in your Astro config, and set `output` to `hybrid`. + * + * ```js + * { + * output: 'hybrid', + * experimental: { + * hybridOutput: true, + * }, + * } + * ``` + * Then add `export const prerender = false` to any page or endpoint you want to opt-out of pre-rendering. + * ```astro + * --- + * // pages/contact.astro + * export const prerender = false + * + * if (Astro.request.method === 'POST') { + * // handle form submission + * } + * --- + *
+ * + * + * + *
+ * ``` + */ + hybridOutput?: boolean; }; // Legacy options to be removed diff --git a/packages/astro/src/assets/generate.ts b/packages/astro/src/assets/generate.ts index 25493753a..da602e80f 100644 --- a/packages/astro/src/assets/generate.ts +++ b/packages/astro/src/assets/generate.ts @@ -6,6 +6,7 @@ import { prependForwardSlash } from '../core/path.js'; import { getConfiguredImageService, isESMImportedImage } from './internal.js'; import type { LocalImageService } from './services/service.js'; import type { ImageTransform } from './types.js'; +import { isHybridOutput } from '../prerender/utils.js'; interface GenerationDataUncached { cached: false; @@ -46,7 +47,7 @@ export async function generateImage( } let serverRoot: URL, clientRoot: URL; - if (buildOpts.settings.config.output === 'server') { + if (buildOpts.settings.config.output === 'server' || isHybridOutput(buildOpts.settings.config)) { serverRoot = buildOpts.settings.config.build.server; clientRoot = buildOpts.settings.config.build.client; } else { diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index 2d4d18ea7..aedb3f2fb 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -1,6 +1,7 @@ import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { isLocalService, type ImageService } from './services/service.js'; import type { GetImageResult, ImageMetadata, ImageTransform } from './types.js'; +import { isHybridOutput } from '../prerender/utils.js'; export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata { return typeof src === 'object'; diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 5d2cb09ca..2c5921893 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -51,6 +51,7 @@ import type { StylesheetAsset, } from './types'; import { getTimeStat } from './util.js'; +import { isHybridOutput } from '../../prerender/utils.js'; function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean { return ( @@ -89,7 +90,7 @@ export function chunkIsPage( export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) { const timer = performance.now(); - const ssr = opts.settings.config.output === 'server'; + const ssr = opts.settings.config.output === 'server' || isHybridOutput(opts.settings.config); // hybrid mode is essentially SSR with prerender by default const serverEntry = opts.buildConfig.serverEntry; const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir); @@ -227,7 +228,7 @@ async function getPathsForRoute( route: pageData.route, isValidate: false, logging: opts.logging, - ssr: opts.settings.config.output === 'server', + ssr: opts.settings.config.output === 'server' || isHybridOutput(opts.settings.config), }) .then((_result) => { const label = _result.staticPaths.length === 1 ? 'page' : 'pages'; @@ -403,7 +404,7 @@ async function generatePath( } } - const ssr = settings.config.output === 'server'; + const ssr = settings.config.output === 'server' || isHybridOutput(settings.config); const url = getUrlForPath( pathname, opts.settings.config.base, diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 4123b71d9..5cd33a73c 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -1,6 +1,11 @@ import type { AstroTelemetry } from '@astrojs/telemetry'; -import type { AstroSettings, BuildConfig, ManifestData, RuntimeMode } from '../../@types/astro'; -import type { LogOptions } from '../logger/core'; +import type { + AstroConfig, + AstroSettings, + BuildConfig, + ManifestData, + RuntimeMode, +} from '../../@types/astro'; import fs from 'fs'; import * as colors from 'kleur/colors'; @@ -14,7 +19,7 @@ import { runHookConfigSetup, } from '../../integrations/index.js'; import { createVite } from '../create-vite.js'; -import { debug, info, levels, timerMessage } from '../logger/core.js'; +import { debug, info, levels, timerMessage, warn, type LogOptions } from '../logger/core.js'; import { printHelp } from '../messages.js'; import { apply as applyPolyfill } from '../polyfill.js'; import { RouteCache } from '../render/route-cache.js'; @@ -233,7 +238,7 @@ class AstroBuilder { logging: LogOptions; timeStart: number; pageCount: number; - buildMode: 'static' | 'server'; + buildMode: AstroConfig['output']; }) { const total = getTimeStat(timeStart, performance.now()); diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts index 132d03cf8..1162a902b 100644 --- a/packages/astro/src/core/build/plugins/plugin-pages.ts +++ b/packages/astro/src/core/build/plugins/plugin-pages.ts @@ -6,12 +6,12 @@ import { eachPageData, hasPrerenderedPages, type BuildInternals } from '../inter import type { AstroBuildPlugin } from '../plugin'; import type { StaticBuildOptions } from '../types'; -export function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin { +function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin { return { name: '@astro/plugin-build-pages', options(options) { - if (opts.settings.config.output === 'static' || hasPrerenderedPages(internals)) { + if (opts.settings.config.output === 'static') { return addRollupInput(options, [pagesVirtualModuleId]); } }, diff --git a/packages/astro/src/core/build/plugins/plugin-prerender.ts b/packages/astro/src/core/build/plugins/plugin-prerender.ts index 449fc2bc5..7c9f3f784 100644 --- a/packages/astro/src/core/build/plugins/plugin-prerender.ts +++ b/packages/astro/src/core/build/plugins/plugin-prerender.ts @@ -4,10 +4,7 @@ import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types'; import { extendManualChunks } from './util.js'; -export function vitePluginPrerender( - opts: StaticBuildOptions, - internals: BuildInternals -): VitePlugin { +function vitePluginPrerender(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin { return { name: 'astro:rollup-plugin-prerender', @@ -26,6 +23,7 @@ export function vitePluginPrerender( pageInfo.route.prerender = true; return 'prerender'; } + pageInfo.route.prerender = false; // dynamic pages should all go in their own chunk in the pages/* directory return `pages/all`; } diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index 935e7b380..34967cdf3 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -1,7 +1,6 @@ import type { Plugin as VitePlugin } from 'vite'; import type { AstroAdapter, AstroConfig } from '../../../@types/astro'; import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types'; -import type { BuildInternals } from '../internal.js'; import type { StaticBuildOptions } from '../types'; import glob from 'fast-glob'; @@ -13,15 +12,16 @@ import { joinPaths, prependForwardSlash } from '../../path.js'; import { serializeRouteData } from '../../routing/index.js'; import { addRollupInput } from '../add-rollup-input.js'; import { getOutFile, getOutFolder } from '../common.js'; -import { cssOrder, eachPageData, mergeInlineCss } from '../internal.js'; +import { cssOrder, eachPageData, mergeInlineCss, type BuildInternals } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin'; +import { isHybridOutput } from '../../../prerender/utils.js'; export const virtualModuleId = '@astrojs-ssr-virtual-entry'; const resolvedVirtualModuleId = '\0' + virtualModuleId; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); -export function vitePluginSSR( +function vitePluginSSR( internals: BuildInternals, adapter: AstroAdapter, config: AstroConfig @@ -249,7 +249,8 @@ export function pluginSSR( options: StaticBuildOptions, internals: BuildInternals ): AstroBuildPlugin { - const ssr = options.settings.config.output === 'server'; + const ssr = + options.settings.config.output === 'server' || isHybridOutput(options.settings.config); return { build: 'ssr', hooks: { diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index ff71e80b8..36d4f1e15 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -26,6 +26,7 @@ import { createPluginContainer, type AstroBuildPluginContainer } from './plugin. import { registerAllPlugins } from './plugins/index.js'; import type { PageBuildData, StaticBuildOptions } from './types'; import { getTimeStat } from './util.js'; +import { isHybridOutput } from '../../prerender/utils.js'; export async function viteBuild(opts: StaticBuildOptions) { const { allPages, settings } = opts; @@ -111,15 +112,16 @@ export async function viteBuild(opts: StaticBuildOptions) { export async function staticBuild(opts: StaticBuildOptions, internals: BuildInternals) { const { settings } = opts; - switch (settings.config.output) { - case 'static': { + const hybridOutput = isHybridOutput(settings.config); + switch (true) { + case settings.config.output === 'static': { settings.timer.start('Static generate'); await generatePages(opts, internals); await cleanServerOutput(opts); settings.timer.end('Static generate'); return; } - case 'server': { + case settings.config.output === 'server' || hybridOutput: { settings.timer.start('Server generate'); await generatePages(opts, internals); await cleanStaticOutput(opts, internals); @@ -138,7 +140,7 @@ async function ssrBuild( container: AstroBuildPluginContainer ) { const { settings, viteConfig } = opts; - const ssr = settings.config.output === 'server'; + const ssr = settings.config.output === 'server' || isHybridOutput(settings.config); const out = ssr ? opts.buildConfig.server : getOutDirWithinCwd(settings.config.outDir); const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input); @@ -207,7 +209,7 @@ async function clientBuild( ) { const { settings, viteConfig } = opts; const timer = performance.now(); - const ssr = settings.config.output === 'server'; + const ssr = settings.config.output === 'server' || isHybridOutput(settings.config); const out = ssr ? opts.buildConfig.client : getOutDirWithinCwd(settings.config.outDir); // Nothing to do if there is no client-side JS. @@ -273,7 +275,7 @@ async function runPostBuildHooks( const buildConfig = container.options.settings.config.build; for (const [fileName, mutation] of mutations) { const root = - config.output === 'server' + config.output === 'server' || isHybridOutput(config) ? mutation.build === 'server' ? buildConfig.server : buildConfig.client @@ -294,7 +296,7 @@ async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInter if (pageData.route.prerender) allStaticFiles.add(internals.pageToBundleMap.get(pageData.moduleSpecifier)); } - const ssr = opts.settings.config.output === 'server'; + const ssr = opts.settings.config.output === 'server' || isHybridOutput(opts.settings.config); 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', { diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts index 9915ed162..370912e88 100644 --- a/packages/astro/src/core/config/config.ts +++ b/packages/astro/src/core/config/config.ts @@ -11,6 +11,7 @@ import type { LogOptions } from '../logger/core.js'; import { arraify, isObject, isURL } from '../util.js'; import { createRelativeSchema } from './schema.js'; import { loadConfigWithVite } from './vite-load.js'; +import { isHybridMalconfigured } from '../../prerender/utils.js'; export const LEGACY_ASTRO_CONFIG_KEYS = new Set([ 'projectRoot', @@ -223,6 +224,12 @@ export async function openConfig(configOptions: LoadConfigOptions): Promise { + message: (prefix: string, suffix: string, isHydridOuput: boolean) => { + const defaultExpectedValue = isHydridOuput ? 'false' : 'true'; 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}\`.`; + if (suffix !== 'true') + msg += `\nExpected \`${defaultExpectedValue}\` 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`.', diff --git a/packages/astro/src/core/render/dev/environment.ts b/packages/astro/src/core/render/dev/environment.ts index 6a45f9c36..5577788c8 100644 --- a/packages/astro/src/core/render/dev/environment.ts +++ b/packages/astro/src/core/render/dev/environment.ts @@ -1,4 +1,5 @@ import type { AstroSettings, RuntimeMode } from '../../../@types/astro'; +import { isHybridOutput } from '../../../prerender/utils.js'; import type { LogOptions } from '../../logger/core.js'; import type { ModuleLoader } from '../../module-loader/index'; import type { Environment } from '../index'; @@ -29,7 +30,7 @@ export function createDevelopmentEnvironment( resolve: createResolve(loader, settings.config.root), routeCache: new RouteCache(logging, mode), site: settings.config.site, - ssr: settings.config.output === 'server', + ssr: settings.config.output === 'server' || isHybridOutput(settings.config), streaming: true, telemetry: Boolean(settings.forceDisableTelemetry), }); diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 8c7514969..a729cf707 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -18,6 +18,7 @@ import { warn } from '../../logger/core.js'; import { removeLeadingForwardSlash } from '../../path.js'; import { resolvePages } from '../../util.js'; import { getRouteGenerator } from './generator.js'; +import { isHybridOutput } from '../../../prerender/utils.js'; const require = createRequire(import.meta.url); interface Item { @@ -226,6 +227,7 @@ export function createRouteManifest( ]); const validEndpointExtensions: Set = new Set(['.js', '.ts']); const localFs = fsMod ?? nodeFs; + const isPrenderDefault = isHybridOutput(settings.config); function walk( fs: typeof nodeFs, @@ -322,7 +324,6 @@ export function createRouteManifest( const route = `/${segments .map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content)) .join('/')}`.toLowerCase(); - routes.push({ route, type: item.isPage ? 'page' : 'endpoint', @@ -332,7 +333,7 @@ export function createRouteManifest( component, generate, pathname: pathname || undefined, - prerender: false, + prerender: isPrenderDefault, }); } }); @@ -408,7 +409,7 @@ export function createRouteManifest( component, generate, pathname: pathname || void 0, - prerender: false, + prerender: isPrenderDefault, }); }); diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 593f2fa7d..b7208236f 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -7,6 +7,7 @@ import type { AstroConfig, AstroSettings, RouteType } from '../@types/astro'; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './constants.js'; import type { ModuleLoader } from './module-loader'; import { prependForwardSlash, removeTrailingForwardSlash } from './path.js'; +import { isHybridOutput } from '../prerender/utils.js'; /** Returns true if argument is an object of any prototype/class (but not null). */ export function isObject(value: unknown): value is Record { @@ -138,7 +139,9 @@ export function isEndpoint(file: URL, settings: AstroSettings): boolean { } export function isModeServerWithNoAdapter(settings: AstroSettings): boolean { - return settings.config.output === 'server' && !settings.adapter; + return ( + (settings.config.output === 'server' || isHybridOutput(settings.config)) && !settings.adapter + ); } export function relativeToSrcDir(config: AstroConfig, idOrUrl: URL | string) { diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index 2e00b3a74..01c0fe498 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -18,6 +18,7 @@ import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.j import { mergeConfig } from '../core/config/config.js'; import { info, type LogOptions } from '../core/logger/core.js'; import { mdxContentEntryType } from '../vite-plugin-markdown/content-entry-type.js'; +import { isHybridOutput } from '../prerender/utils.js'; async function withTakingALongTimeMsg({ name, @@ -329,7 +330,8 @@ export async function runHookBuildGenerated({ buildConfig: BuildConfig; logging: LogOptions; }) { - const dir = config.output === 'server' ? buildConfig.client : config.outDir; + const dir = + config.output === 'server' || isHybridOutput(config) ? buildConfig.client : config.outDir; for (const integration of config.integrations) { if (integration?.hooks?.['astro:build:generated']) { @@ -355,7 +357,8 @@ export async function runHookBuildDone({ routes: RouteData[]; logging: LogOptions; }) { - const dir = config.output === 'server' ? buildConfig.client : config.outDir; + const dir = + config.output === 'server' || isHybridOutput(config) ? buildConfig.client : config.outDir; await fs.promises.mkdir(dir, { recursive: true }); for (const integration of config.integrations) { diff --git a/packages/astro/src/prerender/utils.ts b/packages/astro/src/prerender/utils.ts new file mode 100644 index 000000000..40066035c --- /dev/null +++ b/packages/astro/src/prerender/utils.ts @@ -0,0 +1,11 @@ +// TODO: remove after the experimetal phase when + +import type { AstroConfig } from '../@types/astro'; + +export function isHybridMalconfigured(config: AstroConfig) { + return config.experimental.hybridOutput ? config.output !== 'hybrid' : config.output === 'hybrid'; +} + +export function isHybridOutput(config: AstroConfig) { + return config.experimental.hybridOutput && config.output === 'hybrid'; +} diff --git a/packages/astro/src/runtime/server/endpoint.ts b/packages/astro/src/runtime/server/endpoint.ts index 9780d6599..8adc10c72 100644 --- a/packages/astro/src/runtime/server/endpoint.ts +++ b/packages/astro/src/runtime/server/endpoint.ts @@ -25,7 +25,7 @@ export async function renderEndpoint(mod: EndpointHandler, context: APIContext, if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') { // eslint-disable-next-line no-console console.warn(` -${chosenMethod} requests are not available when building a static site. Update your config to output: 'server' to handle ${chosenMethod} requests.`); +${chosenMethod} requests are not available when building a static site. Update your config to \`output: 'server'\` or \`output: 'hybrid'\` with an \`export const prerender = false\` to handle ${chosenMethod} requests.`); } if (!handler || typeof handler !== 'function') { // No handler found, so this should be a 404. Using a custom header diff --git a/packages/astro/src/vite-plugin-astro-server/request.ts b/packages/astro/src/vite-plugin-astro-server/request.ts index 6dbbbb916..738cbfea1 100644 --- a/packages/astro/src/vite-plugin-astro-server/request.ts +++ b/packages/astro/src/vite-plugin-astro-server/request.ts @@ -12,6 +12,7 @@ import { eventError, telemetry } from '../events/index.js'; import { runWithErrorHandling } from './controller.js'; import { handle500Response } from './response.js'; import { handleRoute, matchRoute } from './route.js'; +import { isHybridOutput } from '../prerender/utils.js'; /** The main logic to route dev server requests to pages in Astro. */ export async function handleRequest( @@ -24,7 +25,7 @@ export async function handleRequest( const { settings, loader: moduleLoader } = env; const { config } = settings; const origin = `${moduleLoader.isHttps() ? 'https' : 'http'}://${req.headers.host}`; - const buildingToSSR = config.output === 'server'; + const buildingToSSR = config.output === 'server' || isHybridOutput(config); const url = new URL(origin + req.url); let pathname: string; diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index cb2e76178..79d5d6a6a 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -18,6 +18,7 @@ import { createRequest } from '../core/request.js'; import { matchAllRoutes } from '../core/routing/index.js'; import { log404 } from './common.js'; import { handle404Response, writeSSRResult, writeWebResponse } from './response.js'; +import { isHybridOutput } from '../prerender/utils.js'; type AsyncReturnType Promise> = T extends ( ...args: any @@ -58,7 +59,7 @@ export async function matchRoute( routeCache, pathname: pathname, logging, - ssr: settings.config.output === 'server', + ssr: settings.config.output === 'server' || isHybridOutput(settings.config), }); if (paramsAndPropsRes !== GetParamsAndPropsError.NoMatchingStaticPath) { @@ -131,7 +132,7 @@ export async function handleRoute( const { config } = settings; const filePath: URL | undefined = matchedRoute.filePath; const { route, preloadedComponent, mod } = matchedRoute; - const buildingToSSR = config.output === 'server'; + const buildingToSSR = config.output === 'server' || isHybridOutput(config); // Headers are only available when using SSR. const request = createRequest({ @@ -157,7 +158,7 @@ export async function handleRoute( routeCache: env.routeCache, pathname: pathname, logging, - ssr: config.output === 'server', + ssr: config.output === 'server' || isHybridOutput(config), }); const options: SSROptions = { diff --git a/packages/astro/src/vite-plugin-scanner/index.ts b/packages/astro/src/vite-plugin-scanner/index.ts index 0dc62223c..c7d6595da 100644 --- a/packages/astro/src/vite-plugin-scanner/index.ts +++ b/packages/astro/src/vite-plugin-scanner/index.ts @@ -3,6 +3,7 @@ import type { AstroSettings } from '../@types/astro.js'; import { isEndpoint, isPage } from '../core/util.js'; import { scan } from './scan.js'; +import { isHybridOutput } from '../prerender/utils.js'; export default function astroScannerPlugin({ settings }: { settings: AstroSettings }): VitePlugin { return { @@ -24,7 +25,12 @@ export default function astroScannerPlugin({ settings }: { settings: AstroSettin const fileIsPage = isPage(fileURL, settings); const fileIsEndpoint = isEndpoint(fileURL, settings); if (!(fileIsPage || fileIsEndpoint)) return; - const pageOptions = await scan(code, id); + const hybridOutput = isHybridOutput(settings.config); + const pageOptions = await scan(code, id, hybridOutput); + + if (typeof pageOptions.prerender === 'undefined') { + pageOptions.prerender = hybridOutput ? true : false; + } const { meta = {} } = this.getModuleInfo(id) ?? {}; return { diff --git a/packages/astro/src/vite-plugin-scanner/scan.ts b/packages/astro/src/vite-plugin-scanner/scan.ts index c2bd1284b..afb3585d5 100644 --- a/packages/astro/src/vite-plugin-scanner/scan.ts +++ b/packages/astro/src/vite-plugin-scanner/scan.ts @@ -1,6 +1,7 @@ import * as eslexer from 'es-module-lexer'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import type { PageOptions } from '../vite-plugin-astro/types.js'; +import type { AstroSettings } from '../@types/astro.js'; const BOOLEAN_EXPORTS = new Set(['prerender']); @@ -34,7 +35,7 @@ function isFalsy(value: string) { let didInit = false; -export async function scan(code: string, id: string): Promise { +export async function scan(code: string, id: string, isHybridOutput = false): Promise { if (!includesExport(code)) return {}; if (!didInit) { await eslexer.init; @@ -45,6 +46,7 @@ export async function scan(code: string, id: string): Promise { let pageOptions: PageOptions = {}; for (const _export of exports) { const { n: name, le: endOfLocalName } = _export; + // mark that a `prerender` export was found 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` @@ -61,7 +63,7 @@ export async function scan(code: string, id: string): Promise { if (prefix !== 'const' || !(isTruthy(suffix) || isFalsy(suffix))) { throw new AstroError({ ...AstroErrorData.InvalidPrerenderExport, - message: AstroErrorData.InvalidPrerenderExport.message(prefix, suffix), + message: AstroErrorData.InvalidPrerenderExport.message(prefix, suffix, isHybridOutput), location: { file: id }, }); } else { diff --git a/packages/astro/test/ssr-prerender-integrations.test.js b/packages/astro/test/ssr-prerender-integrations.test.js deleted file mode 100644 index 29b8be0f5..000000000 --- a/packages/astro/test/ssr-prerender-integrations.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import { expect } from 'chai'; -import { loadFixture } from './test-utils.js'; -import testAdapter from './test-adapter.js'; - -describe('Integrations can hook into the prerendering decision', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - - const testIntegration = { - name: 'test prerendering integration', - hooks: { - ['astro:build:setup']({ pages, target }) { - if (target !== 'client') return; - // this page has `export const prerender = true` - pages.get('src/pages/static.astro').route.prerender = false; - - // this page does not - pages.get('src/pages/not-prerendered.astro').route.prerender = true; - }, - }, - }; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/ssr-prerender/', - output: 'server', - integrations: [testIntegration], - adapter: testAdapter(), - }); - await fixture.build(); - }); - - it('An integration can override the prerender flag', async () => { - // test adapter only hosts dynamic routes - // /static is expected to become dynamic - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/static'); - const response = await app.render(request); - expect(response.status).to.equal(200); - }); - - it('An integration can turn a normal page to a prerendered one', async () => { - const app = await fixture.loadTestAdapterApp(); - const request = new Request('http://example.com/not-prerendered'); - const response = await app.render(request); - expect(response.status).to.equal(404); - }); -}); diff --git a/packages/astro/test/ssr-prerender.test.js b/packages/astro/test/ssr-prerender.test.js index 8139c293d..25484d708 100644 --- a/packages/astro/test/ssr-prerender.test.js +++ b/packages/astro/test/ssr-prerender.test.js @@ -63,3 +63,48 @@ describe('SSR: prerender', () => { }); }); }); + +describe('Integrations can hook into the prerendering decision', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + const testIntegration = { + name: 'test prerendering integration', + hooks: { + ['astro:build:setup']({ pages, target }) { + if (target !== 'client') return; + // this page has `export const prerender = true` + pages.get('src/pages/static.astro').route.prerender = false; + + // this page does not + pages.get('src/pages/not-prerendered.astro').route.prerender = true; + }, + }, + }; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-prerender/', + output: 'server', + integrations: [testIntegration], + adapter: testAdapter(), + }); + await fixture.build(); + }); + + it('An integration can override the prerender flag', async () => { + // test adapter only hosts dynamic routes + // /static is expected to become dynamic + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/static'); + const response = await app.render(request); + expect(response.status).to.equal(200); + }); + + it('An integration can turn a normal page to a prerendered one', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/not-prerendered'); + const response = await app.render(request); + expect(response.status).to.equal(404); + }); +}); diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index 9eddb2f60..4991ee196 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -50,6 +50,7 @@ "chai": "^4.3.6", "cheerio": "^1.0.0-rc.11", "mocha": "^9.2.2", + "slash": "^4.0.0", "wrangler": "^2.0.23" } } diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 46deee2f8..2f6b36e87 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -60,7 +60,7 @@ export default function createIntegration(args?: Options): AstroIntegration { if (config.output === 'static') { throw new Error(` - [@astrojs/cloudflare] \`output: "server"\` is required to use this adapter. Otherwise, this adapter is not necessary to deploy a static site to Cloudflare. + [@astrojs/cloudflare] \`output: "server"\` or \`output: "hybrid"\` is required to use this adapter. Otherwise, this adapter is not necessary to deploy a static site to Cloudflare. `); } diff --git a/packages/integrations/cloudflare/test/fixtures/prerender/src/pages/one.astro b/packages/integrations/cloudflare/test/fixtures/prerender/src/pages/one.astro index 30386a625..e11de7add 100644 --- a/packages/integrations/cloudflare/test/fixtures/prerender/src/pages/one.astro +++ b/packages/integrations/cloudflare/test/fixtures/prerender/src/pages/one.astro @@ -1,5 +1,5 @@ --- -export const prerender = true; +export const prerender = import.meta.env.PRERENDER; --- diff --git a/packages/integrations/cloudflare/test/prerender.test.js b/packages/integrations/cloudflare/test/prerender.test.js index a3ce50d08..5d3ff9f10 100644 --- a/packages/integrations/cloudflare/test/prerender.test.js +++ b/packages/integrations/cloudflare/test/prerender.test.js @@ -1,19 +1,60 @@ import { loadFixture } from './test-utils.js'; import { expect } from 'chai'; +import slash from 'slash'; describe('Prerendering', () => { /** @type {import('./test-utils').Fixture} */ let fixture; before(async () => { + process.env.PRERENDER = true; fixture = await loadFixture({ root: './fixtures/prerender/', }); await fixture.build(); }); + after(() => { + delete process.env.PRERENDER; + fixture.clean(); + }); + it('includes prerendered routes in the routes.json config', async () => { - const routes = JSON.parse(await fixture.readFile('/_routes.json')); - expect(routes.exclude).to.include('/one/'); + const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')).exclude.map((r) => + slash(r) + ); + const expectedExcludedRoutes = ['/_worker.js', '/one/index.html', '/one/']; + + expect(foundRoutes.every((element) => expectedExcludedRoutes.includes(element))).to.be.true; + }); +}); + +describe('Hybrid rendering', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + process.env.PRERENDER = false; + fixture = await loadFixture({ + root: './fixtures/prerender/', + output: 'hybrid', + experimental: { + hybridOutput: true, + }, + }); + await fixture.build(); + }); + + after(() => { + delete process.env.PRERENDER; + }); + + it('includes prerendered routes in the routes.json config', async () => { + const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')).exclude.map((r) => + slash(r) + ); + const expectedExcludedRoutes = ['/_worker.js', '/index.html', '/']; + + expect(foundRoutes.every((element) => expectedExcludedRoutes.includes(element))).to.be.true; }); }); diff --git a/packages/integrations/cloudflare/test/test-utils.js b/packages/integrations/cloudflare/test/test-utils.js index 58cb8f9dd..b4628825c 100644 --- a/packages/integrations/cloudflare/test/test-utils.js +++ b/packages/integrations/cloudflare/test/test-utils.js @@ -4,6 +4,10 @@ import { fileURLToPath } from 'url'; export { fixLineEndings } from '../../../astro/test/test-utils.js'; +/** + * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture + */ + export function loadFixture(config) { if (config?.root) { config.root = new URL(config.root, import.meta.url); diff --git a/packages/integrations/deno/src/index.ts b/packages/integrations/deno/src/index.ts index dc3070d53..7e4a520ca 100644 --- a/packages/integrations/deno/src/index.ts +++ b/packages/integrations/deno/src/index.ts @@ -62,7 +62,9 @@ export default function createIntegration(args?: Options): AstroIntegration { _buildConfig = config.build; if (config.output === 'static') { - console.warn(`[@astrojs/deno] \`output: "server"\` is required to use this adapter.`); + console.warn( + `[@astrojs/deno] \`output: "server"\` or \`output: "hybrid"\` is required to use this adapter.` + ); console.warn( `[@astrojs/deno] Otherwise, this adapter is not required to deploy a static site to Deno.` ); diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts index 671faad5c..ce70e3c8a 100644 --- a/packages/integrations/image/src/index.ts +++ b/packages/integrations/image/src/index.ts @@ -4,6 +4,7 @@ import type { ImageService, SSRImageService, TransformOptions } from './loaders/ import type { LoggerLevel } from './utils/logger.js'; import { joinPaths, prependForwardSlash, propsToFilename } from './utils/paths.js'; import { createPlugin } from './vite-plugin-astro-image.js'; +import { isHybridOutput } from './utils/prerender.js'; export { getImage } from './lib/get-image.js'; export { getPicture } from './lib/get-picture.js'; @@ -84,7 +85,7 @@ export default function integration(options: IntegrationOptions = {}): AstroInte vite: getViteConfiguration(command === 'dev'), }); - if (command === 'dev' || config.output === 'server') { + if (command === 'dev' || config.output === 'server' || isHybridOutput(config)) { injectRoute({ pattern: ROUTE_PATTERN, entryPoint: '@astrojs/image/endpoint', diff --git a/packages/integrations/image/src/utils/prerender.ts b/packages/integrations/image/src/utils/prerender.ts new file mode 100644 index 000000000..9265c80bf --- /dev/null +++ b/packages/integrations/image/src/utils/prerender.ts @@ -0,0 +1,5 @@ +import type { AstroConfig } from 'astro'; + +export function isHybridOutput(config: AstroConfig) { + return config.experimental.hybridOutput && config.output === 'hybrid'; +} diff --git a/packages/integrations/netlify/src/integration-edge-functions.ts b/packages/integrations/netlify/src/integration-edge-functions.ts index b11710430..2f65bccda 100644 --- a/packages/integrations/netlify/src/integration-edge-functions.ts +++ b/packages/integrations/netlify/src/integration-edge-functions.ts @@ -134,7 +134,9 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}) entryFile = config.build.serverEntry.replace(/\.m?js/, ''); if (config.output === 'static') { - console.warn(`[@astrojs/netlify] \`output: "server"\` is required to use this adapter.`); + console.warn( + `[@astrojs/netlify] \`output: "server"\` or \`output: "hybrid"\` is required to use this adapter.` + ); console.warn( `[@astrojs/netlify] Otherwise, this adapter is not required to deploy a static site to Netlify.` ); diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts index 609dc2500..348b007f5 100644 --- a/packages/integrations/netlify/src/integration-functions.ts +++ b/packages/integrations/netlify/src/integration-functions.ts @@ -43,7 +43,9 @@ function netlifyFunctions({ entryFile = config.build.serverEntry.replace(/\.m?js/, ''); if (config.output === 'static') { - console.warn(`[@astrojs/netlify] \`output: "server"\` is required to use this adapter.`); + console.warn( + `[@astrojs/netlify] \`output: "server"\` or \`output: "hybrid"\` is required to use this adapter.` + ); console.warn( `[@astrojs/netlify] Otherwise, this adapter is not required to deploy a static site to Netlify.` ); diff --git a/packages/integrations/netlify/test/edge-functions/deps.ts b/packages/integrations/netlify/test/edge-functions/deps.ts index 498b7e09e..c6ced8814 100644 --- a/packages/integrations/netlify/test/edge-functions/deps.ts +++ b/packages/integrations/netlify/test/edge-functions/deps.ts @@ -1,5 +1,11 @@ // @ts-nocheck export { fromFileUrl } from 'https://deno.land/std@0.110.0/path/mod.ts'; -export { assertEquals, assert } from 'https://deno.land/std@0.132.0/testing/asserts.ts'; +export { + assertEquals, + assert, + assertExists, +} from 'https://deno.land/std@0.132.0/testing/asserts.ts'; export * from 'https://deno.land/x/deno_dom/deno-dom-wasm.ts'; export * from 'https://deno.land/std@0.142.0/streams/conversion.ts'; +export * as cheerio from 'https://cdn.skypack.dev/cheerio?dts'; +export * as fs from 'https://deno.land/std/fs/mod.ts'; diff --git a/packages/integrations/netlify/test/edge-functions/dynamic-import.test.js b/packages/integrations/netlify/test/edge-functions/dynamic-import.test.js index ff4adb490..febd689b6 100644 --- a/packages/integrations/netlify/test/edge-functions/dynamic-import.test.js +++ b/packages/integrations/netlify/test/edge-functions/dynamic-import.test.js @@ -4,8 +4,8 @@ import { assertEquals, assert, DOMParser } from './deps.ts'; Deno.test({ name: 'Dynamic imports', async fn() { - let close = await runBuild('./fixtures/dynimport/'); - let stop = await runApp('./fixtures/dynimport/prod.js'); + await runBuild('./fixtures/dynimport/'); + const stop = await runApp('./fixtures/dynimport/prod.js'); try { const response = await fetch('http://127.0.0.1:8085/'); @@ -20,7 +20,6 @@ Deno.test({ // eslint-disable-next-line no-console console.error(err); } finally { - await close(); await stop(); } }, diff --git a/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts b/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts index ecdbda4e0..9f2a7bde3 100644 --- a/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts +++ b/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts @@ -1,4 +1,4 @@ -import { runBuild } from './test-utils.ts'; +import { loadFixture } from './test-utils.ts'; import { assertEquals, assert, DOMParser } from './deps.ts'; Deno.env.set('SECRET_STUFF', 'secret'); @@ -10,7 +10,8 @@ Deno.test({ name: 'Edge Basics', skip: true, async fn() { - let close = await runBuild('./fixtures/edge-basic/'); + const fixture = loadFixture('./fixtures/edge-basic/'); + await fixture.runBuild(); const { default: handler } = await import( './fixtures/edge-basic/.netlify/edge-functions/entry.js' ); @@ -26,6 +27,6 @@ Deno.test({ const envDiv = doc.querySelector('#env'); assertEquals(envDiv?.innerText, 'secret'); - await close(); + await fixture.cleanup(); }, }); diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/prerender/astro.config.mjs b/packages/integrations/netlify/test/edge-functions/fixtures/prerender/astro.config.mjs index cd758352b..c579d74ef 100644 --- a/packages/integrations/netlify/test/edge-functions/fixtures/prerender/astro.config.mjs +++ b/packages/integrations/netlify/test/edge-functions/fixtures/prerender/astro.config.mjs @@ -1,9 +1,23 @@ -import { defineConfig } from 'astro/config'; -import { netlifyEdgeFunctions } from '@astrojs/netlify'; +import { defineConfig } from "astro/config"; +import { netlifyEdgeFunctions } from "@astrojs/netlify"; + +const isHybridMode = process.env.PRERENDER === "false"; + +/** @type {import('astro').AstroConfig} */ +const partialConfig = { + output: isHybridMode ? "hybrid" : "server", + ...(isHybridMode + ? ({ + experimental: { + hybridOutput: true, + }, + }) + : ({})), +}; export default defineConfig({ - adapter: netlifyEdgeFunctions({ - dist: new URL('./dist/', import.meta.url), - }), - output: 'server', -}) + adapter: netlifyEdgeFunctions({ + dist: new URL("./dist/", import.meta.url), + }), + ...partialConfig, +}); diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/prerender/src/pages/index.astro b/packages/integrations/netlify/test/edge-functions/fixtures/prerender/src/pages/index.astro index 075253550..b6b833e53 100644 --- a/packages/integrations/netlify/test/edge-functions/fixtures/prerender/src/pages/index.astro +++ b/packages/integrations/netlify/test/edge-functions/fixtures/prerender/src/pages/index.astro @@ -1,5 +1,5 @@ --- -export const prerender = true +export const prerender = import.meta.env.PRERENDER; --- diff --git a/packages/integrations/netlify/test/edge-functions/prerender.test.ts b/packages/integrations/netlify/test/edge-functions/prerender.test.ts index 5d858ef73..4d4dfc9c6 100644 --- a/packages/integrations/netlify/test/edge-functions/prerender.test.ts +++ b/packages/integrations/netlify/test/edge-functions/prerender.test.ts @@ -1,15 +1,76 @@ -import { runBuild } from './test-utils.ts'; -import { assertEquals } from './deps.ts'; +import { loadFixture } from './test-utils.ts'; +import { assertEquals, assertExists, cheerio, fs } from './deps.ts'; Deno.test({ name: 'Prerender', - async fn() { - let close = await runBuild('./fixtures/prerender/'); - const { default: handler } = await import( - './fixtures/prerender/.netlify/edge-functions/entry.js' - ); - const response = await handler(new Request('http://example.com/index.html')); - assertEquals(response, undefined, 'No response because this is an asset'); - await close(); + async fn(t) { + const environmentVariables = { + PRERENDER: 'true', + }; + const fixture = loadFixture('./fixtures/prerender/', environmentVariables); + await fixture.runBuild(); + + await t.step('Handler can process requests to non-existing routes', async () => { + const { default: handler } = await import( + './fixtures/prerender/.netlify/edge-functions/entry.js' + ); + assertExists(handler); + const response = await handler(new Request('http://example.com/index.html')); + assertEquals(response, undefined, "No response because this route doesn't exist"); + }); + + await t.step('Prerendered route exists', async () => { + let content: string | null = null; + try { + const path = new URL('./fixtures/prerender/dist/index.html', import.meta.url); + content = Deno.readTextFileSync(path); + } catch (e) {} + assertExists(content); + const $ = cheerio.load(content); + assertEquals($('h1').text(), 'testing'); + }); + + Deno.env.delete('PRERENDER'); + await fixture.cleanup(); + }, +}); + +Deno.test({ + name: 'Hybrid rendering', + async fn(t) { + const environmentVariables = { + PRERENDER: 'false', + }; + const fixture = loadFixture('./fixtures/prerender/', environmentVariables); + await fixture.runBuild(); + + const stop = await fixture.runApp('./fixtures/prerender/prod.js'); + await t.step('Can fetch server route', async () => { + const response = await fetch('http://127.0.0.1:8085/'); + assertEquals(response.status, 200); + + const html = await response.text(); + const $ = cheerio.load(html); + assertEquals($('h1').text(), 'testing'); + }); + stop(); + + await t.step('Handler can process requests to non-existing routes', async () => { + const { default: handler } = await import( + './fixtures/prerender/.netlify/edge-functions/entry.js' + ); + const response = await handler(new Request('http://example.com/index.html')); + assertEquals(response, undefined, "No response because this route doesn't exist"); + }); + + await t.step('Has no prerendered route', async () => { + let prerenderedRouteExists = false; + try { + const path = new URL('./fixtures/prerender/dist/index.html', import.meta.url); + prerenderedRouteExists = fs.existsSync(path); + } catch (e) {} + assertEquals(prerenderedRouteExists, false); + }); + await fixture.cleanup(); }, }); diff --git a/packages/integrations/netlify/test/edge-functions/root-dynamic.test.ts b/packages/integrations/netlify/test/edge-functions/root-dynamic.test.ts index c853e2bfc..0e38bc46e 100644 --- a/packages/integrations/netlify/test/edge-functions/root-dynamic.test.ts +++ b/packages/integrations/netlify/test/edge-functions/root-dynamic.test.ts @@ -1,4 +1,4 @@ -import { runBuild } from './test-utils.ts'; +import { loadFixture } from './test-utils.ts'; import { assertEquals, assert, DOMParser } from './deps.ts'; Deno.test({ @@ -6,12 +6,14 @@ Deno.test({ ignore: true, name: 'Assets are preferred over HTML routes', async fn() { - let close = await runBuild('./fixtures/root-dynamic/'); + const fixture = loadFixture('./fixtures/root-dynamic/'); + await fixture.runBuild(); + const { default: handler } = await import( './fixtures/root-dynamic/.netlify/edge-functions/entry.js' ); const response = await handler(new Request('http://example.com/styles.css')); assertEquals(response, undefined, 'No response because this is an asset'); - await close(); + await fixture.cleanup(); }, }); diff --git a/packages/integrations/netlify/test/edge-functions/test-utils.ts b/packages/integrations/netlify/test/edge-functions/test-utils.ts index 2025c45b3..ed6e4c20c 100644 --- a/packages/integrations/netlify/test/edge-functions/test-utils.ts +++ b/packages/integrations/netlify/test/edge-functions/test-utils.ts @@ -1,29 +1,50 @@ import { fromFileUrl, readableStreamFromReader } from './deps.ts'; const dir = new URL('./', import.meta.url); -export async function runBuild(fixturePath: string) { - let proc = Deno.run({ - cmd: ['node', '../../../../../../astro/astro.js', 'build', '--silent'], - cwd: fromFileUrl(new URL(fixturePath, dir)), - }); - await proc.status(); - return async () => await proc.close(); -} - -export async function runApp(entryPath: string) { - const entryUrl = new URL(entryPath, dir); - let proc = Deno.run({ - cmd: ['deno', 'run', '--allow-env', '--allow-net', fromFileUrl(entryUrl)], - //cwd: fromFileUrl(entryUrl), - stderr: 'piped', - }); - const stderr = readableStreamFromReader(proc.stderr); - const dec = new TextDecoder(); - for await (let bytes of stderr) { - let msg = dec.decode(bytes); - if (msg.includes(`Server running`)) { - break; - } +export function loadFixture(fixturePath: string, envionmentVariables?: Record) { + async function runBuild() { + const proc = Deno.run({ + cmd: ['node', '../../../../../../astro/astro.js', 'build'], + env: envionmentVariables, + cwd: fromFileUrl(new URL(fixturePath, dir)), + }); + await proc.status(); + proc.close(); } - return () => proc.close(); + + async function runApp(entryPath: string) { + const entryUrl = new URL(entryPath, dir); + let proc = Deno.run({ + cmd: ['deno', 'run', '--allow-env', '--allow-net', fromFileUrl(entryUrl)], + env: envionmentVariables, + //cwd: fromFileUrl(entryUrl), + stderr: 'piped', + }); + const stderr = readableStreamFromReader(proc.stderr); + const dec = new TextDecoder(); + for await (let bytes of stderr) { + let msg = dec.decode(bytes); + if (msg.includes(`Server running`)) { + break; + } + } + return () => proc.close(); + } + + async function cleanup() { + const netlifyPath = new URL('.netlify', new URL(fixturePath, dir)); + const distPath = new URL('dist', new URL(fixturePath, dir)); + + // remove the netlify folder + await Deno.remove(netlifyPath, { recursive: true }); + + // remove the dist folder + await Deno.remove(distPath, { recursive: true }); + } + + return { + runApp, + runBuild, + cleanup, + }; } diff --git a/packages/integrations/netlify/test/functions/fixtures/prerender/src/pages/one.astro b/packages/integrations/netlify/test/functions/fixtures/prerender/src/pages/one.astro index 12146450e..342e98cfa 100644 --- a/packages/integrations/netlify/test/functions/fixtures/prerender/src/pages/one.astro +++ b/packages/integrations/netlify/test/functions/fixtures/prerender/src/pages/one.astro @@ -1,5 +1,5 @@ --- -export const prerender = true; +export const prerender = import.meta.env.PRERENDER; --- diff --git a/packages/integrations/netlify/test/functions/prerender.test.js b/packages/integrations/netlify/test/functions/prerender.test.js index 324ebc5c5..9718df083 100644 --- a/packages/integrations/netlify/test/functions/prerender.test.js +++ b/packages/integrations/netlify/test/functions/prerender.test.js @@ -1,12 +1,14 @@ import { expect } from 'chai'; import netlifyAdapter from '../../dist/index.js'; import { loadFixture, testIntegration } from './test-utils.js'; +import { after } from 'node:test'; describe('Mixed Prerendering with SSR', () => { /** @type {import('./test-utils').Fixture} */ let fixture; before(async () => { + process.env.PRERENDER = true; fixture = await loadFixture({ root: new URL('./fixtures/prerender/', import.meta.url).toString(), output: 'server', @@ -18,13 +20,56 @@ describe('Mixed Prerendering with SSR', () => { }); await fixture.build(); }); + + after(() => { + delete process.env.PRERENDER; + }); + it('Wildcard 404 is sorted last', async () => { const redir = await fixture.readFile('/_redirects'); const baseRouteIndex = redir.indexOf('/ /.netlify/functions/entry 200'); const oneRouteIndex = redir.indexOf('/one /one/index.html 200'); const fourOhFourWildCardIndex = redir.indexOf('/* /.netlify/functions/entry 404'); + expect(oneRouteIndex).to.not.be.equal(-1); expect(fourOhFourWildCardIndex).to.be.greaterThan(baseRouteIndex); expect(fourOhFourWildCardIndex).to.be.greaterThan(oneRouteIndex); }); }); + +describe('Mixed Hybrid rendering with SSR', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + process.env.PRERENDER = false; + fixture = await loadFixture({ + root: new URL('./fixtures/prerender/', import.meta.url).toString(), + output: 'hybrid', + experimental: { + hybridOutput: true, + }, + adapter: netlifyAdapter({ + dist: new URL('./fixtures/prerender/dist/', import.meta.url), + }), + site: `http://example.com`, + integrations: [testIntegration()], + }); + await fixture.build(); + }); + + after(() => { + delete process.env.PRERENDER; + }); + + it('outputs a correct redirect file', async () => { + const redir = await fixture.readFile('/_redirects'); + const baseRouteIndex = redir.indexOf('/one /.netlify/functions/entry 200'); + const rootRouteIndex = redir.indexOf('/ /index.html 200'); + const fourOhFourIndex = redir.indexOf('/404 /404.html 200'); + + expect(rootRouteIndex).to.not.be.equal(-1); + expect(baseRouteIndex).to.not.be.equal(-1); + expect(fourOhFourIndex).to.not.be.equal(-1); + }); +}); diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index d882f34fb..17a8f4502 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -40,7 +40,9 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr setAdapter(getAdapter(_options)); if (config.output === 'static') { - console.warn(`[@astrojs/node] \`output: "server"\` is required to use this adapter.`); + console.warn( + `[@astrojs/node] \`output: "server"\` or \`output: "hybrid"\` is required to use this adapter.` + ); } }, }, diff --git a/packages/integrations/node/test/fixtures/prerender/src/pages/two.astro b/packages/integrations/node/test/fixtures/prerender/src/pages/two.astro index beb6e8d78..c0e5d07aa 100644 --- a/packages/integrations/node/test/fixtures/prerender/src/pages/two.astro +++ b/packages/integrations/node/test/fixtures/prerender/src/pages/two.astro @@ -1,5 +1,5 @@ --- -export const prerender = true; +export const prerender = import.meta.env.PRERENDER; --- diff --git a/packages/integrations/node/test/prerender.test.js b/packages/integrations/node/test/prerender.test.js index e72e754e2..751ed2ae7 100644 --- a/packages/integrations/node/test/prerender.test.js +++ b/packages/integrations/node/test/prerender.test.js @@ -1,22 +1,27 @@ import nodejs from '../dist/index.js'; -import { loadFixture, createRequestAndResponse } from './test-utils.js'; +import { loadFixture } from './test-utils.js'; import { expect } from 'chai'; import * as cheerio from 'cheerio'; import { fetch } from 'undici'; +/** + * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture + */ + +async function load() { + const mod = await import(`./fixtures/prerender/dist/server/entry.mjs?dropcache=${Date.now()}`); + return mod; +} describe('Prerendering', () => { /** @type {import('./test-utils').Fixture} */ let fixture; let server; - async function load() { - const mod = await import('./fixtures/prerender/dist/server/entry.mjs'); - return mod; - } - - describe('With base', () => { + describe('With base', async () => { before(async () => { process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + process.env.PRERENDER = true; + fixture = await loadFixture({ base: '/some-base', root: './fixtures/prerender/', @@ -31,6 +36,8 @@ describe('Prerendering', () => { after(async () => { await server.stop(); + await fixture.clean(); + delete process.env.PRERENDER; }); it('Can render SSR route', async () => { @@ -68,9 +75,12 @@ describe('Prerendering', () => { expect(res.headers.get('location')).to.equal('/some-base/two/'); }); }); - describe('Without base', () => { + + describe('Without base', async () => { before(async () => { process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + process.env.PRERENDER = true; + fixture = await loadFixture({ root: './fixtures/prerender/', output: 'server', @@ -84,6 +94,8 @@ describe('Prerendering', () => { after(async () => { await server.stop(); + await fixture.clean(); + delete process.env.PRERENDER; }); it('Can render SSR route', async () => { @@ -114,3 +126,121 @@ describe('Prerendering', () => { }); }); }); + +describe('Hybrid rendering', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let server; + + describe('With base', async () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + process.env.PRERENDER = false; + fixture = await loadFixture({ + base: '/some-base', + root: './fixtures/prerender/', + output: 'hybrid', + experimental: { + hybridOutput: true, + }, + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await await load(); + let res = startServer(); + server = res.server; + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + delete process.env.PRERENDER; + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/two`); + const html = await res.text(); + const $ = cheerio.load(html); + expect(res.status).to.equal(200); + expect($('h1').text()).to.equal('Two'); + }); + + it('Can render prerendered route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + expect(res.status).to.equal(200); + expect($('h1').text()).to.equal('One'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + expect(res.status).to.equal(200); + expect($('h1').text()).to.equal('One'); + }); + + it('Omitting the trailing slash results in a redirect that includes the base', async () => { + const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, { + redirect: 'manual', + }); + expect(res.status).to.equal(301); + expect(res.headers.get('location')).to.equal('/some-base/one/'); + }); + }); + + describe('Without base', async () => { + before(async () => { + process.env.ASTRO_NODE_AUTOSTART = 'disabled'; + process.env.PRERENDER = false; + fixture = await loadFixture({ + root: './fixtures/prerender/', + output: 'hybrid', + experimental: { + hybridOutput: true, + }, + adapter: nodejs({ mode: 'standalone' }), + }); + await fixture.build(); + const { startServer } = await await load(); + let res = startServer(); + server = res.server; + }); + + after(async () => { + await server.stop(); + await fixture.clean(); + delete process.env.PRERENDER; + }); + + it('Can render SSR route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/two`); + const html = await res.text(); + const $ = cheerio.load(html); + + expect(res.status).to.equal(200); + expect($('h1').text()).to.equal('Two'); + }); + + it('Can render prerendered route', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one`); + const html = await res.text(); + const $ = cheerio.load(html); + + expect(res.status).to.equal(200); + expect($('h1').text()).to.equal('One'); + }); + + it('Can render prerendered route with query params', async () => { + const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`); + const html = await res.text(); + const $ = cheerio.load(html); + + expect(res.status).to.equal(200); + expect($('h1').text()).to.equal('One'); + }); + }); +}); diff --git a/packages/integrations/vercel/src/edge/adapter.ts b/packages/integrations/vercel/src/edge/adapter.ts index c11d34828..3c2d39116 100644 --- a/packages/integrations/vercel/src/edge/adapter.ts +++ b/packages/integrations/vercel/src/edge/adapter.ts @@ -80,7 +80,7 @@ export default function vercelEdge({ if (config.output === 'static') { throw new Error(` - [@astrojs/vercel] \`output: "server"\` is required to use the edge adapter. + [@astrojs/vercel] \`output: "server"\` or \`output: "hybrid"\` is required to use the edge adapter. `); } diff --git a/packages/integrations/vercel/src/lib/prerender.ts b/packages/integrations/vercel/src/lib/prerender.ts new file mode 100644 index 000000000..9265c80bf --- /dev/null +++ b/packages/integrations/vercel/src/lib/prerender.ts @@ -0,0 +1,5 @@ +import type { AstroConfig } from 'astro'; + +export function isHybridOutput(config: AstroConfig) { + return config.experimental.hybridOutput && config.output === 'hybrid'; +} diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 755f08891..8a1870770 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -75,7 +75,7 @@ export default function vercelServerless({ if (config.output === 'static') { throw new Error(` - [@astrojs/vercel] \`output: "server"\` is required to use the serverless adapter. + [@astrojs/vercel] \`output: "server"\` or \`output: "hybrid"\` is required to use the serverless adapter. `); } diff --git a/packages/integrations/vercel/src/static/adapter.ts b/packages/integrations/vercel/src/static/adapter.ts index cab0b3fc0..a7b7e5f94 100644 --- a/packages/integrations/vercel/src/static/adapter.ts +++ b/packages/integrations/vercel/src/static/adapter.ts @@ -9,6 +9,7 @@ import { import { exposeEnv } from '../lib/env.js'; import { emptyDir, getVercelOutput, writeJson } from '../lib/fs.js'; import { getRedirects } from '../lib/redirects.js'; +import { isHybridOutput } from '../lib/prerender.js'; const PACKAGE_NAME = '@astrojs/vercel/static'; @@ -54,7 +55,7 @@ export default function vercelStatic({ setAdapter(getAdapter()); _config = config; - if (config.output === 'server') { + if (config.output === 'server' || isHybridOutput(config)) { throw new Error(`${PACKAGE_NAME} should be used with output: 'static'`); } }, diff --git a/packages/integrations/vercel/test/fixtures/serverless-prerender/src/pages/index.astro b/packages/integrations/vercel/test/fixtures/serverless-prerender/src/pages/index.astro index 075253550..b6b833e53 100644 --- a/packages/integrations/vercel/test/fixtures/serverless-prerender/src/pages/index.astro +++ b/packages/integrations/vercel/test/fixtures/serverless-prerender/src/pages/index.astro @@ -1,5 +1,5 @@ --- -export const prerender = true +export const prerender = import.meta.env.PRERENDER; --- diff --git a/packages/integrations/vercel/test/serverless-prerender.test.js b/packages/integrations/vercel/test/serverless-prerender.test.js index 491c6d0bd..ec9887bdb 100644 --- a/packages/integrations/vercel/test/serverless-prerender.test.js +++ b/packages/integrations/vercel/test/serverless-prerender.test.js @@ -6,6 +6,7 @@ describe('Serverless prerender', () => { let fixture; before(async () => { + process.env.PRERENDER = true; fixture = await loadFixture({ root: './fixtures/serverless-prerender/', }); @@ -16,3 +17,24 @@ describe('Serverless prerender', () => { expect(await fixture.readFile('../.vercel/output/static/index.html')).to.be.ok; }); }); + +describe('Serverless hybrid rendering', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + process.env.PRERENDER = true; + fixture = await loadFixture({ + root: './fixtures/serverless-prerender/', + output:'hybrid', + experimental:{ + hybridOutput: true + } + }); + }); + + it('build successful', async () => { + await fixture.build(); + expect(await fixture.readFile('../.vercel/output/static/index.html')).to.be.ok; + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4e6aec1c..8415bb973 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3586,6 +3586,9 @@ importers: mocha: specifier: ^9.2.2 version: 9.2.2 + slash: + specifier: ^4.0.0 + version: 4.0.0 wrangler: specifier: ^2.0.23 version: 2.0.23