diff --git a/.changeset/mighty-shoes-scream.md b/.changeset/mighty-shoes-scream.md
new file mode 100644
index 000000000000..adacd6ecaa70
--- /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 e20e0e5a8320..19606b0703d1 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 25493753ac13..da602e80f307 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 2d4d18ea7040..aedb3f2fb857 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 5d2cb09ca0e4..2c59218937f7 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 4123b71d9bab..5cd33a73ca2d 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 132d03cf80fd..1162a902bda8 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 449fc2bc527c..7c9f3f7848ae 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 935e7b38059d..34967cdf3ff2 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 ff71e80b8ee7..36d4f1e15f4a 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 9915ed162b31..370912e884e2 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 6a45f9c36e4d..5577788c8f16 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 8c7514969e02..a729cf707963 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 593f2fa7d02a..b7208236fca1 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 2e00b3a74b00..01c0fe4984a7 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 000000000000..40066035cf66
--- /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 9780d6599cb5..8adc10c72c0c 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 6dbbbb9168a7..738cbfea1566 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 cb2e76178d31..79d5d6a6a601 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 0dc62223c12a..c7d6595da053 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 c2bd1284b523..afb3585d598b 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 29b8be0f551f..000000000000
--- 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 8139c293dc66..25484d7083f4 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 9eddb2f60d37..4991ee196a4f 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 46deee2f8141..2f6b36e8718a 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 30386a625ab6..e11de7add53b 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 a3ce50d08c96..5d3ff9f10a18 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 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 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', '/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 58cb8f9dd6e6..b4628825cf66 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 dc3070d53286..7e4a520ca798 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 671faad5c173..ce70e3c8a320 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 000000000000..9265c80bf0fc
--- /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 b11710430f9d..2f65bccda90e 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 609dc2500541..348b007f5d96 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 498b7e09e639..c6ced8814fac 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 ff4adb490c59..febd689b6921 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 ecdbda4e06b8..9f2a7bde3ac7 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 cd758352b5f7..c579d74eff31 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 0752535505ef..b6b833e5358f 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 5d858ef730e5..4d4dfc9c6914 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 c853e2bfcfc1..0e38bc46ed6e 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 2025c45b3a13..ed6e4c20c8de 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 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();
+ }
-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;
+ 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();
}
- 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 12146450e41c..342e98cfaaf1 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 324ebc5c5a10..9718df083c95 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 d882f34fb3ab..17a8f4502a43 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 beb6e8d788bd..c0e5d07aa437 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 e72e754e268f..751ed2ae718f 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 c11d3482864b..3c2d39116e95 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 000000000000..9265c80bf0fc
--- /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 755f08891690..8a18707702bd 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 cab0b3fc04f5..a7b7e5f94dae 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 0752535505ef..b6b833e5358f 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 491c6d0bdbbd..ec9887bdb64d 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 f4e6aec1c0a5..8415bb973464 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