feat: hybrid output (#6991)

* update config schema

* adapt default route `prerender` value

* adapt error message for hybrid output

* core hybrid output support

* add JSDocs for hybrid output

* dev server hybrid output support

* defer hybrid output check

* update endpoint request warning

* support `output=hybrid` in integrations

* put constant variable out of for loop

* revert: reapply back ssr plugin in ssr mode

* change `prerender` option default

* apply `prerender` by default in hybrid mode

* simplfy conditional

* update config schema

* add `isHybridOutput` helper

* more readable prerender condition

* set default prerender value if no export is found

* only add `pagesVirtualModuleId` ro rollup input in `output=static`

* don't export vite plugin

* remove unneeded check

* don't prerender when it shouldn't

* extract fallback `prerender` meta

Extract the fallback `prerender` module meta out of the `scan` function.
It shouldn't be its responsibility to handle that

* pass missing argument to function

* test: update cloudflare integration tests

* test: update tests of vercel integration

* test: update tests of node integration

* test: update tests of netlify func integration

* test: update tests of netlify edge integration

* throw when `hybrid` mode is malconfigured

* update node integraiton `output` warning

* test(WIP): skip node prerendering tests for now

* remove non-existant import

* test: bring back prerendering tests

* remove outdated comments

* test: refactor test to support windows paths

* remove outdated comments

* apply sarah review

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* docs: `experiment.hybridOutput` jsodcs

* test: prevent import from being cached

* refactor: extract hybrid output check to  function

* add `hybrid` to output warning in adapter hooks

* chore: changeset

* add `.js` extension to import

* chore: use spaces instead of tabs for gh formating

* resolve merge conflict

* chore: move test to another file for consitency

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Matthew Phillips <matthew@skypack.dev>
This commit is contained in:
Happydev 2023-05-17 13:23:20 +00:00 committed by GitHub
parent 2b9230ed22
commit 719002ca5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 671 additions and 175 deletions

View file

@ -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
}
---
<form method="POST">
<input type="text" name="name" />
<input type="email" name="email" />
<button type="submit">Submit</button>
</form>
```

View file

@ -557,7 +557,7 @@ export interface AstroUserConfig {
/** /**
* @docs * @docs
* @name output * @name output
* @type {('static' | 'server')} * @type {('static' | 'server' | 'hybrid')}
* @default `'static'` * @default `'static'`
* @see adapter * @see adapter
* @description * @description
@ -566,6 +566,7 @@ export interface AstroUserConfig {
* *
* - 'static' - Building a static site to be deploy to any static host. * - '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). * - '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 * ```js
* import { defineConfig } from 'astro/config'; * import { defineConfig } from 'astro/config';
@ -575,7 +576,7 @@ export interface AstroUserConfig {
* }) * })
* ``` * ```
*/ */
output?: 'static' | 'server'; output?: 'static' | 'server' | 'hybrid';
/** /**
* @docs * @docs
@ -616,14 +617,14 @@ export interface AstroUserConfig {
* @type {string} * @type {string}
* @default `'./dist/client'` * @default `'./dist/client'`
* @description * @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. * `outDir` controls where the code is built to.
* *
* This value is relative to the `outDir`. * This value is relative to the `outDir`.
* *
* ```js * ```js
* { * {
* output: 'server', * output: 'server', // or 'hybrid'
* build: { * build: {
* client: './client' * client: './client'
* } * }
@ -1121,6 +1122,44 @@ export interface AstroUserConfig {
* ``` * ```
*/ */
middleware?: boolean; 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
* }
* ---
* <form method="POST">
* <input type="text" name="name" />
* <input type="email" name="email" />
* <button type="submit">Submit</button>
* </form>
* ```
*/
hybridOutput?: boolean;
}; };
// Legacy options to be removed // Legacy options to be removed

View file

@ -6,6 +6,7 @@ import { prependForwardSlash } from '../core/path.js';
import { getConfiguredImageService, isESMImportedImage } from './internal.js'; import { getConfiguredImageService, isESMImportedImage } from './internal.js';
import type { LocalImageService } from './services/service.js'; import type { LocalImageService } from './services/service.js';
import type { ImageTransform } from './types.js'; import type { ImageTransform } from './types.js';
import { isHybridOutput } from '../prerender/utils.js';
interface GenerationDataUncached { interface GenerationDataUncached {
cached: false; cached: false;
@ -46,7 +47,7 @@ export async function generateImage(
} }
let serverRoot: URL, clientRoot: URL; 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; serverRoot = buildOpts.settings.config.build.server;
clientRoot = buildOpts.settings.config.build.client; clientRoot = buildOpts.settings.config.build.client;
} else { } else {

View file

@ -1,6 +1,7 @@
import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js';
import { isLocalService, type ImageService } from './services/service.js'; import { isLocalService, type ImageService } from './services/service.js';
import type { GetImageResult, ImageMetadata, ImageTransform } from './types.js'; import type { GetImageResult, ImageMetadata, ImageTransform } from './types.js';
import { isHybridOutput } from '../prerender/utils.js';
export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata { export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
return typeof src === 'object'; return typeof src === 'object';

View file

@ -51,6 +51,7 @@ import type {
StylesheetAsset, StylesheetAsset,
} from './types'; } from './types';
import { getTimeStat } from './util.js'; import { getTimeStat } from './util.js';
import { isHybridOutput } from '../../prerender/utils.js';
function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean { function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean {
return ( return (
@ -89,7 +90,7 @@ export function chunkIsPage(
export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) { export async function generatePages(opts: StaticBuildOptions, internals: BuildInternals) {
const timer = performance.now(); 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 serverEntry = opts.buildConfig.serverEntry;
const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir); const outFolder = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);
@ -227,7 +228,7 @@ async function getPathsForRoute(
route: pageData.route, route: pageData.route,
isValidate: false, isValidate: false,
logging: opts.logging, logging: opts.logging,
ssr: opts.settings.config.output === 'server', ssr: opts.settings.config.output === 'server' || isHybridOutput(opts.settings.config),
}) })
.then((_result) => { .then((_result) => {
const label = _result.staticPaths.length === 1 ? 'page' : 'pages'; 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( const url = getUrlForPath(
pathname, pathname,
opts.settings.config.base, opts.settings.config.base,

View file

@ -1,6 +1,11 @@
import type { AstroTelemetry } from '@astrojs/telemetry'; import type { AstroTelemetry } from '@astrojs/telemetry';
import type { AstroSettings, BuildConfig, ManifestData, RuntimeMode } from '../../@types/astro'; import type {
import type { LogOptions } from '../logger/core'; AstroConfig,
AstroSettings,
BuildConfig,
ManifestData,
RuntimeMode,
} from '../../@types/astro';
import fs from 'fs'; import fs from 'fs';
import * as colors from 'kleur/colors'; import * as colors from 'kleur/colors';
@ -14,7 +19,7 @@ import {
runHookConfigSetup, runHookConfigSetup,
} from '../../integrations/index.js'; } from '../../integrations/index.js';
import { createVite } from '../create-vite.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 { printHelp } from '../messages.js';
import { apply as applyPolyfill } from '../polyfill.js'; import { apply as applyPolyfill } from '../polyfill.js';
import { RouteCache } from '../render/route-cache.js'; import { RouteCache } from '../render/route-cache.js';
@ -233,7 +238,7 @@ class AstroBuilder {
logging: LogOptions; logging: LogOptions;
timeStart: number; timeStart: number;
pageCount: number; pageCount: number;
buildMode: 'static' | 'server'; buildMode: AstroConfig['output'];
}) { }) {
const total = getTimeStat(timeStart, performance.now()); const total = getTimeStat(timeStart, performance.now());

View file

@ -6,12 +6,12 @@ import { eachPageData, hasPrerenderedPages, type BuildInternals } from '../inter
import type { AstroBuildPlugin } from '../plugin'; import type { AstroBuildPlugin } from '../plugin';
import type { StaticBuildOptions } from '../types'; import type { StaticBuildOptions } from '../types';
export function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin { function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
return { return {
name: '@astro/plugin-build-pages', name: '@astro/plugin-build-pages',
options(options) { options(options) {
if (opts.settings.config.output === 'static' || hasPrerenderedPages(internals)) { if (opts.settings.config.output === 'static') {
return addRollupInput(options, [pagesVirtualModuleId]); return addRollupInput(options, [pagesVirtualModuleId]);
} }
}, },

View file

@ -4,10 +4,7 @@ import type { AstroBuildPlugin } from '../plugin.js';
import type { StaticBuildOptions } from '../types'; import type { StaticBuildOptions } from '../types';
import { extendManualChunks } from './util.js'; import { extendManualChunks } from './util.js';
export function vitePluginPrerender( function vitePluginPrerender(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
opts: StaticBuildOptions,
internals: BuildInternals
): VitePlugin {
return { return {
name: 'astro:rollup-plugin-prerender', name: 'astro:rollup-plugin-prerender',
@ -26,6 +23,7 @@ export function vitePluginPrerender(
pageInfo.route.prerender = true; pageInfo.route.prerender = true;
return 'prerender'; return 'prerender';
} }
pageInfo.route.prerender = false;
// dynamic pages should all go in their own chunk in the pages/* directory // dynamic pages should all go in their own chunk in the pages/* directory
return `pages/all`; return `pages/all`;
} }

View file

@ -1,7 +1,6 @@
import type { Plugin as VitePlugin } from 'vite'; import type { Plugin as VitePlugin } from 'vite';
import type { AstroAdapter, AstroConfig } from '../../../@types/astro'; import type { AstroAdapter, AstroConfig } from '../../../@types/astro';
import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types'; import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types';
import type { BuildInternals } from '../internal.js';
import type { StaticBuildOptions } from '../types'; import type { StaticBuildOptions } from '../types';
import glob from 'fast-glob'; import glob from 'fast-glob';
@ -13,15 +12,16 @@ import { joinPaths, prependForwardSlash } from '../../path.js';
import { serializeRouteData } from '../../routing/index.js'; import { serializeRouteData } from '../../routing/index.js';
import { addRollupInput } from '../add-rollup-input.js'; import { addRollupInput } from '../add-rollup-input.js';
import { getOutFile, getOutFolder } from '../common.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 type { AstroBuildPlugin } from '../plugin';
import { isHybridOutput } from '../../../prerender/utils.js';
export const virtualModuleId = '@astrojs-ssr-virtual-entry'; export const virtualModuleId = '@astrojs-ssr-virtual-entry';
const resolvedVirtualModuleId = '\0' + virtualModuleId; const resolvedVirtualModuleId = '\0' + virtualModuleId;
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g'); const replaceExp = new RegExp(`['"](${manifestReplace})['"]`, 'g');
export function vitePluginSSR( function vitePluginSSR(
internals: BuildInternals, internals: BuildInternals,
adapter: AstroAdapter, adapter: AstroAdapter,
config: AstroConfig config: AstroConfig
@ -249,7 +249,8 @@ export function pluginSSR(
options: StaticBuildOptions, options: StaticBuildOptions,
internals: BuildInternals internals: BuildInternals
): AstroBuildPlugin { ): AstroBuildPlugin {
const ssr = options.settings.config.output === 'server'; const ssr =
options.settings.config.output === 'server' || isHybridOutput(options.settings.config);
return { return {
build: 'ssr', build: 'ssr',
hooks: { hooks: {

View file

@ -26,6 +26,7 @@ import { createPluginContainer, type AstroBuildPluginContainer } from './plugin.
import { registerAllPlugins } from './plugins/index.js'; import { registerAllPlugins } from './plugins/index.js';
import type { PageBuildData, StaticBuildOptions } from './types'; import type { PageBuildData, StaticBuildOptions } from './types';
import { getTimeStat } from './util.js'; import { getTimeStat } from './util.js';
import { isHybridOutput } from '../../prerender/utils.js';
export async function viteBuild(opts: StaticBuildOptions) { export async function viteBuild(opts: StaticBuildOptions) {
const { allPages, settings } = opts; const { allPages, settings } = opts;
@ -111,15 +112,16 @@ export async function viteBuild(opts: StaticBuildOptions) {
export async function staticBuild(opts: StaticBuildOptions, internals: BuildInternals) { export async function staticBuild(opts: StaticBuildOptions, internals: BuildInternals) {
const { settings } = opts; const { settings } = opts;
switch (settings.config.output) { const hybridOutput = isHybridOutput(settings.config);
case 'static': { switch (true) {
case settings.config.output === 'static': {
settings.timer.start('Static generate'); settings.timer.start('Static generate');
await generatePages(opts, internals); await generatePages(opts, internals);
await cleanServerOutput(opts); await cleanServerOutput(opts);
settings.timer.end('Static generate'); settings.timer.end('Static generate');
return; return;
} }
case 'server': { case settings.config.output === 'server' || hybridOutput: {
settings.timer.start('Server generate'); settings.timer.start('Server generate');
await generatePages(opts, internals); await generatePages(opts, internals);
await cleanStaticOutput(opts, internals); await cleanStaticOutput(opts, internals);
@ -138,7 +140,7 @@ async function ssrBuild(
container: AstroBuildPluginContainer container: AstroBuildPluginContainer
) { ) {
const { settings, viteConfig } = opts; 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 out = ssr ? opts.buildConfig.server : getOutDirWithinCwd(settings.config.outDir);
const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input); const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input);
@ -207,7 +209,7 @@ async function clientBuild(
) { ) {
const { settings, viteConfig } = opts; const { settings, viteConfig } = opts;
const timer = performance.now(); 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); const out = ssr ? opts.buildConfig.client : getOutDirWithinCwd(settings.config.outDir);
// Nothing to do if there is no client-side JS. // Nothing to do if there is no client-side JS.
@ -273,7 +275,7 @@ async function runPostBuildHooks(
const buildConfig = container.options.settings.config.build; const buildConfig = container.options.settings.config.build;
for (const [fileName, mutation] of mutations) { for (const [fileName, mutation] of mutations) {
const root = const root =
config.output === 'server' config.output === 'server' || isHybridOutput(config)
? mutation.build === 'server' ? mutation.build === 'server'
? buildConfig.server ? buildConfig.server
: buildConfig.client : buildConfig.client
@ -294,7 +296,7 @@ async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInter
if (pageData.route.prerender) if (pageData.route.prerender)
allStaticFiles.add(internals.pageToBundleMap.get(pageData.moduleSpecifier)); 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); const out = ssr ? opts.buildConfig.server : getOutDirWithinCwd(opts.settings.config.outDir);
// The SSR output is all .mjs files, the client output is not. // The SSR output is all .mjs files, the client output is not.
const files = await glob('**/*.mjs', { const files = await glob('**/*.mjs', {

View file

@ -11,6 +11,7 @@ import type { LogOptions } from '../logger/core.js';
import { arraify, isObject, isURL } from '../util.js'; import { arraify, isObject, isURL } from '../util.js';
import { createRelativeSchema } from './schema.js'; import { createRelativeSchema } from './schema.js';
import { loadConfigWithVite } from './vite-load.js'; import { loadConfigWithVite } from './vite-load.js';
import { isHybridMalconfigured } from '../../prerender/utils.js';
export const LEGACY_ASTRO_CONFIG_KEYS = new Set([ export const LEGACY_ASTRO_CONFIG_KEYS = new Set([
'projectRoot', 'projectRoot',
@ -223,6 +224,12 @@ export async function openConfig(configOptions: LoadConfigOptions): Promise<Open
} }
const astroConfig = await resolveConfig(userConfig, root, flags, configOptions.cmd); const astroConfig = await resolveConfig(userConfig, root, flags, configOptions.cmd);
if (isHybridMalconfigured(astroConfig)) {
throw new Error(
`The "output" config option must be set to "hybrid" and "experimental.hybridOutput" must be set to true to use the hybrid output mode. Falling back to "static" output mode.`
);
}
return { return {
astroConfig, astroConfig,
userConfig, userConfig,

View file

@ -38,6 +38,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
legacy: {}, legacy: {},
experimental: { experimental: {
assets: false, assets: false,
hybridOutput: false,
customClientDirecives: false, customClientDirecives: false,
inlineStylesheets: 'never', inlineStylesheets: 'never',
middleware: false, middleware: false,
@ -77,7 +78,7 @@ export const AstroConfigSchema = z.object({
.optional() .optional()
.default(ASTRO_CONFIG_DEFAULTS.trailingSlash), .default(ASTRO_CONFIG_DEFAULTS.trailingSlash),
output: z output: z
.union([z.literal('static'), z.literal('server')]) .union([z.literal('static'), z.literal('server'), z.literal('hybrid')])
.optional() .optional()
.default('static'), .default('static'),
scopedStyleStrategy: z scopedStyleStrategy: z
@ -205,6 +206,7 @@ export const AstroConfigSchema = z.object({
.optional() .optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.inlineStylesheets), .default(ASTRO_CONFIG_DEFAULTS.experimental.inlineStylesheets),
middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware), middleware: z.oboolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.middleware),
hybridOutput: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.hybridOutput),
}) })
.optional() .optional()
.default({}), .default({}),

View file

@ -8,6 +8,7 @@ import { getDefaultClientDirectives } from '../client-directive/index.js';
import { createDefaultDevConfig } from './config.js'; import { createDefaultDevConfig } from './config.js';
import { AstroTimer } from './timer.js'; import { AstroTimer } from './timer.js';
import { loadTSConfig } from './tsconfig.js'; import { loadTSConfig } from './tsconfig.js';
import { isHybridOutput } from '../../prerender/utils.js';
export function createBaseSettings(config: AstroConfig): AstroSettings { export function createBaseSettings(config: AstroConfig): AstroSettings {
return { return {
@ -17,7 +18,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
adapter: undefined, adapter: undefined,
injectedRoutes: injectedRoutes:
config.experimental.assets && config.output === 'server' config.experimental.assets && (config.output === 'server' || isHybridOutput(config))
? [{ pattern: '/_image', entryPoint: 'astro/assets/image-endpoint' }] ? [{ pattern: '/_image', entryPoint: 'astro/assets/image-endpoint' }]
: [], : [],
pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS], pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS],

View file

@ -16,6 +16,7 @@ import { AstroError, AstroErrorData } from '../errors/index.js';
import { warn, type LogOptions } from '../logger/core.js'; import { warn, type LogOptions } from '../logger/core.js';
import { callMiddleware } from '../middleware/callMiddleware.js'; import { callMiddleware } from '../middleware/callMiddleware.js';
import { isValueSerializable } from '../render/core.js'; import { isValueSerializable } from '../render/core.js';
import { isHybridOutput } from '../../prerender/utils.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress'); const clientAddressSymbol = Symbol.for('astro.clientAddress');
const clientLocalsSymbol = Symbol.for('astro.locals'); const clientLocalsSymbol = Symbol.for('astro.locals');
@ -168,7 +169,7 @@ function isRedirect(statusCode: number) {
} }
export function throwIfRedirectNotAllowed(response: Response, config: AstroConfig) { export function throwIfRedirectNotAllowed(response: Response, config: AstroConfig) {
if (config.output !== 'server' && isRedirect(response.status)) { if (config.output !== 'server' && !isHybridOutput(config) && isRedirect(response.status)) {
throw new AstroError(AstroErrorData.StaticRedirectNotAvailable); throw new AstroError(AstroErrorData.StaticRedirectNotAvailable);
} }
} }

View file

@ -48,7 +48,7 @@ export const AstroErrorData = {
title: '`Astro.redirect` is not available in static mode.', title: '`Astro.redirect` is not available in static mode.',
code: 3001, code: 3001,
message: message:
"Redirects are only available when using `output: 'server'`. Update your Astro config if you need SSR features.", "Redirects are only available when using `output: 'server'` or `output: 'hybrid'`. Update your Astro config if you need SSR features.",
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/#enabling-ssr-in-your-project for more information on how to enable SSR.', hint: 'See https://docs.astro.build/en/guides/server-side-rendering/#enabling-ssr-in-your-project for more information on how to enable SSR.',
}, },
/** /**
@ -79,7 +79,7 @@ export const AstroErrorData = {
title: '`Astro.clientAddress` is not available in static mode.', title: '`Astro.clientAddress` is not available in static mode.',
code: 3003, code: 3003,
message: message:
"`Astro.clientAddress` is only available when using `output: 'server'`. Update your Astro config if you need SSR features.", "`Astro.clientAddress` is only available when using `output: 'server'` or `output: 'hybrid'`. Update your Astro config if you need SSR features.",
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/#enabling-ssr-in-your-project for more information on how to enable SSR.', hint: 'See https://docs.astro.build/en/guides/server-side-rendering/#enabling-ssr-in-your-project for more information on how to enable SSR.',
}, },
/** /**
@ -390,7 +390,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
NoAdapterInstalled: { NoAdapterInstalled: {
title: 'Cannot use Server-side Rendering without an adapter.', title: 'Cannot use Server-side Rendering without an adapter.',
code: 3017, code: 3017,
message: `Cannot use \`output: 'server'\` without an adapter. Please install and configure the appropriate server adapter for your final deployment.`, message: `Cannot use \`output: 'server'\` or \`output: 'hybrid'\` without an adapter. Please install and configure the appropriate server adapter for your final deployment.`,
hint: 'See https://docs.astro.build/en/guides/server-side-rendering/ for more information.', hint: 'See https://docs.astro.build/en/guides/server-side-rendering/ for more information.',
}, },
/** /**
@ -416,10 +416,12 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
InvalidPrerenderExport: { InvalidPrerenderExport: {
title: 'Invalid prerender export.', title: 'Invalid prerender export.',
code: 3019, code: 3019,
message: (prefix: string, suffix: string) => { 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.`; 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 (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; return msg;
}, },
hint: 'Mutable values declared at runtime are not supported. Please make sure to use exactly `export const prerender = true`.', hint: 'Mutable values declared at runtime are not supported. Please make sure to use exactly `export const prerender = true`.',

View file

@ -1,4 +1,5 @@
import type { AstroSettings, RuntimeMode } from '../../../@types/astro'; import type { AstroSettings, RuntimeMode } from '../../../@types/astro';
import { isHybridOutput } from '../../../prerender/utils.js';
import type { LogOptions } from '../../logger/core.js'; import type { LogOptions } from '../../logger/core.js';
import type { ModuleLoader } from '../../module-loader/index'; import type { ModuleLoader } from '../../module-loader/index';
import type { Environment } from '../index'; import type { Environment } from '../index';
@ -29,7 +30,7 @@ export function createDevelopmentEnvironment(
resolve: createResolve(loader, settings.config.root), resolve: createResolve(loader, settings.config.root),
routeCache: new RouteCache(logging, mode), routeCache: new RouteCache(logging, mode),
site: settings.config.site, site: settings.config.site,
ssr: settings.config.output === 'server', ssr: settings.config.output === 'server' || isHybridOutput(settings.config),
streaming: true, streaming: true,
telemetry: Boolean(settings.forceDisableTelemetry), telemetry: Boolean(settings.forceDisableTelemetry),
}); });

View file

@ -18,6 +18,7 @@ import { warn } from '../../logger/core.js';
import { removeLeadingForwardSlash } from '../../path.js'; import { removeLeadingForwardSlash } from '../../path.js';
import { resolvePages } from '../../util.js'; import { resolvePages } from '../../util.js';
import { getRouteGenerator } from './generator.js'; import { getRouteGenerator } from './generator.js';
import { isHybridOutput } from '../../../prerender/utils.js';
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
interface Item { interface Item {
@ -226,6 +227,7 @@ export function createRouteManifest(
]); ]);
const validEndpointExtensions: Set<string> = new Set(['.js', '.ts']); const validEndpointExtensions: Set<string> = new Set(['.js', '.ts']);
const localFs = fsMod ?? nodeFs; const localFs = fsMod ?? nodeFs;
const isPrenderDefault = isHybridOutput(settings.config);
function walk( function walk(
fs: typeof nodeFs, fs: typeof nodeFs,
@ -322,7 +324,6 @@ export function createRouteManifest(
const route = `/${segments const route = `/${segments
.map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content)) .map(([{ dynamic, content }]) => (dynamic ? `[${content}]` : content))
.join('/')}`.toLowerCase(); .join('/')}`.toLowerCase();
routes.push({ routes.push({
route, route,
type: item.isPage ? 'page' : 'endpoint', type: item.isPage ? 'page' : 'endpoint',
@ -332,7 +333,7 @@ export function createRouteManifest(
component, component,
generate, generate,
pathname: pathname || undefined, pathname: pathname || undefined,
prerender: false, prerender: isPrenderDefault,
}); });
} }
}); });
@ -408,7 +409,7 @@ export function createRouteManifest(
component, component,
generate, generate,
pathname: pathname || void 0, pathname: pathname || void 0,
prerender: false, prerender: isPrenderDefault,
}); });
}); });

View file

@ -7,6 +7,7 @@ import type { AstroConfig, AstroSettings, RouteType } from '../@types/astro';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './constants.js'; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './constants.js';
import type { ModuleLoader } from './module-loader'; import type { ModuleLoader } from './module-loader';
import { prependForwardSlash, removeTrailingForwardSlash } from './path.js'; 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). */ /** Returns true if argument is an object of any prototype/class (but not null). */
export function isObject(value: unknown): value is Record<string, any> { export function isObject(value: unknown): value is Record<string, any> {
@ -138,7 +139,9 @@ export function isEndpoint(file: URL, settings: AstroSettings): boolean {
} }
export function isModeServerWithNoAdapter(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) { export function relativeToSrcDir(config: AstroConfig, idOrUrl: URL | string) {

View file

@ -18,6 +18,7 @@ import { buildClientDirectiveEntrypoint } from '../core/client-directive/index.j
import { mergeConfig } from '../core/config/config.js'; import { mergeConfig } from '../core/config/config.js';
import { info, type LogOptions } from '../core/logger/core.js'; import { info, type LogOptions } from '../core/logger/core.js';
import { mdxContentEntryType } from '../vite-plugin-markdown/content-entry-type.js'; import { mdxContentEntryType } from '../vite-plugin-markdown/content-entry-type.js';
import { isHybridOutput } from '../prerender/utils.js';
async function withTakingALongTimeMsg<T>({ async function withTakingALongTimeMsg<T>({
name, name,
@ -329,7 +330,8 @@ export async function runHookBuildGenerated({
buildConfig: BuildConfig; buildConfig: BuildConfig;
logging: LogOptions; 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) { for (const integration of config.integrations) {
if (integration?.hooks?.['astro:build:generated']) { if (integration?.hooks?.['astro:build:generated']) {
@ -355,7 +357,8 @@ export async function runHookBuildDone({
routes: RouteData[]; routes: RouteData[];
logging: LogOptions; 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 }); await fs.promises.mkdir(dir, { recursive: true });
for (const integration of config.integrations) { for (const integration of config.integrations) {

View file

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

View file

@ -25,7 +25,7 @@ export async function renderEndpoint(mod: EndpointHandler, context: APIContext,
if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') { if (!ssr && ssr === false && chosenMethod && chosenMethod !== 'get') {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(` 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') { if (!handler || typeof handler !== 'function') {
// No handler found, so this should be a 404. Using a custom header // No handler found, so this should be a 404. Using a custom header

View file

@ -12,6 +12,7 @@ import { eventError, telemetry } from '../events/index.js';
import { runWithErrorHandling } from './controller.js'; import { runWithErrorHandling } from './controller.js';
import { handle500Response } from './response.js'; import { handle500Response } from './response.js';
import { handleRoute, matchRoute } from './route.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. */ /** The main logic to route dev server requests to pages in Astro. */
export async function handleRequest( export async function handleRequest(
@ -24,7 +25,7 @@ export async function handleRequest(
const { settings, loader: moduleLoader } = env; const { settings, loader: moduleLoader } = env;
const { config } = settings; const { config } = settings;
const origin = `${moduleLoader.isHttps() ? 'https' : 'http'}://${req.headers.host}`; 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); const url = new URL(origin + req.url);
let pathname: string; let pathname: string;

View file

@ -18,6 +18,7 @@ import { createRequest } from '../core/request.js';
import { matchAllRoutes } from '../core/routing/index.js'; import { matchAllRoutes } from '../core/routing/index.js';
import { log404 } from './common.js'; import { log404 } from './common.js';
import { handle404Response, writeSSRResult, writeWebResponse } from './response.js'; import { handle404Response, writeSSRResult, writeWebResponse } from './response.js';
import { isHybridOutput } from '../prerender/utils.js';
type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends ( type AsyncReturnType<T extends (...args: any) => Promise<any>> = T extends (
...args: any ...args: any
@ -58,7 +59,7 @@ export async function matchRoute(
routeCache, routeCache,
pathname: pathname, pathname: pathname,
logging, logging,
ssr: settings.config.output === 'server', ssr: settings.config.output === 'server' || isHybridOutput(settings.config),
}); });
if (paramsAndPropsRes !== GetParamsAndPropsError.NoMatchingStaticPath) { if (paramsAndPropsRes !== GetParamsAndPropsError.NoMatchingStaticPath) {
@ -131,7 +132,7 @@ export async function handleRoute(
const { config } = settings; const { config } = settings;
const filePath: URL | undefined = matchedRoute.filePath; const filePath: URL | undefined = matchedRoute.filePath;
const { route, preloadedComponent, mod } = matchedRoute; const { route, preloadedComponent, mod } = matchedRoute;
const buildingToSSR = config.output === 'server'; const buildingToSSR = config.output === 'server' || isHybridOutput(config);
// Headers are only available when using SSR. // Headers are only available when using SSR.
const request = createRequest({ const request = createRequest({
@ -157,7 +158,7 @@ export async function handleRoute(
routeCache: env.routeCache, routeCache: env.routeCache,
pathname: pathname, pathname: pathname,
logging, logging,
ssr: config.output === 'server', ssr: config.output === 'server' || isHybridOutput(config),
}); });
const options: SSROptions = { const options: SSROptions = {

View file

@ -3,6 +3,7 @@ import type { AstroSettings } from '../@types/astro.js';
import { isEndpoint, isPage } from '../core/util.js'; import { isEndpoint, isPage } from '../core/util.js';
import { scan } from './scan.js'; import { scan } from './scan.js';
import { isHybridOutput } from '../prerender/utils.js';
export default function astroScannerPlugin({ settings }: { settings: AstroSettings }): VitePlugin { export default function astroScannerPlugin({ settings }: { settings: AstroSettings }): VitePlugin {
return { return {
@ -24,7 +25,12 @@ export default function astroScannerPlugin({ settings }: { settings: AstroSettin
const fileIsPage = isPage(fileURL, settings); const fileIsPage = isPage(fileURL, settings);
const fileIsEndpoint = isEndpoint(fileURL, settings); const fileIsEndpoint = isEndpoint(fileURL, settings);
if (!(fileIsPage || fileIsEndpoint)) return; 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) ?? {}; const { meta = {} } = this.getModuleInfo(id) ?? {};
return { return {

View file

@ -1,6 +1,7 @@
import * as eslexer from 'es-module-lexer'; import * as eslexer from 'es-module-lexer';
import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js';
import type { PageOptions } from '../vite-plugin-astro/types.js'; import type { PageOptions } from '../vite-plugin-astro/types.js';
import type { AstroSettings } from '../@types/astro.js';
const BOOLEAN_EXPORTS = new Set(['prerender']); const BOOLEAN_EXPORTS = new Set(['prerender']);
@ -34,7 +35,7 @@ function isFalsy(value: string) {
let didInit = false; let didInit = false;
export async function scan(code: string, id: string): Promise<PageOptions> { export async function scan(code: string, id: string, isHybridOutput = false): Promise<PageOptions> {
if (!includesExport(code)) return {}; if (!includesExport(code)) return {};
if (!didInit) { if (!didInit) {
await eslexer.init; await eslexer.init;
@ -45,6 +46,7 @@ export async function scan(code: string, id: string): Promise<PageOptions> {
let pageOptions: PageOptions = {}; let pageOptions: PageOptions = {};
for (const _export of exports) { for (const _export of exports) {
const { n: name, le: endOfLocalName } = _export; const { n: name, le: endOfLocalName } = _export;
// mark that a `prerender` export was found
if (BOOLEAN_EXPORTS.has(name)) { if (BOOLEAN_EXPORTS.has(name)) {
// For a given export, check the value of the local declaration // For a given export, check the value of the local declaration
// Basically extract the `const` from the statement `export const prerender = true` // Basically extract the `const` from the statement `export const prerender = true`
@ -61,7 +63,7 @@ export async function scan(code: string, id: string): Promise<PageOptions> {
if (prefix !== 'const' || !(isTruthy(suffix) || isFalsy(suffix))) { if (prefix !== 'const' || !(isTruthy(suffix) || isFalsy(suffix))) {
throw new AstroError({ throw new AstroError({
...AstroErrorData.InvalidPrerenderExport, ...AstroErrorData.InvalidPrerenderExport,
message: AstroErrorData.InvalidPrerenderExport.message(prefix, suffix), message: AstroErrorData.InvalidPrerenderExport.message(prefix, suffix, isHybridOutput),
location: { file: id }, location: { file: id },
}); });
} else { } else {

View file

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

View file

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

View file

@ -50,6 +50,7 @@
"chai": "^4.3.6", "chai": "^4.3.6",
"cheerio": "^1.0.0-rc.11", "cheerio": "^1.0.0-rc.11",
"mocha": "^9.2.2", "mocha": "^9.2.2",
"slash": "^4.0.0",
"wrangler": "^2.0.23" "wrangler": "^2.0.23"
} }
} }

View file

@ -60,7 +60,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
if (config.output === 'static') { if (config.output === 'static') {
throw new Error(` 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.
`); `);
} }

View file

@ -1,5 +1,5 @@
--- ---
export const prerender = true; export const prerender = import.meta.env.PRERENDER;
--- ---
<html> <html>
<head> <head>

View file

@ -1,19 +1,60 @@
import { loadFixture } from './test-utils.js'; import { loadFixture } from './test-utils.js';
import { expect } from 'chai'; import { expect } from 'chai';
import slash from 'slash';
describe('Prerendering', () => { describe('Prerendering', () => {
/** @type {import('./test-utils').Fixture} */ /** @type {import('./test-utils').Fixture} */
let fixture; let fixture;
before(async () => { before(async () => {
process.env.PRERENDER = true;
fixture = await loadFixture({ fixture = await loadFixture({
root: './fixtures/prerender/', root: './fixtures/prerender/',
}); });
await fixture.build(); await fixture.build();
}); });
after(() => {
delete process.env.PRERENDER;
fixture.clean();
});
it('includes prerendered routes in the routes.json config', async () => { it('includes prerendered routes in the routes.json config', async () => {
const routes = JSON.parse(await fixture.readFile('/_routes.json')); const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')).exclude.map((r) =>
expect(routes.exclude).to.include('/one/'); 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;
}); });
}); });

View file

@ -4,6 +4,10 @@ import { fileURLToPath } from 'url';
export { fixLineEndings } from '../../../astro/test/test-utils.js'; export { fixLineEndings } from '../../../astro/test/test-utils.js';
/**
* @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
*/
export function loadFixture(config) { export function loadFixture(config) {
if (config?.root) { if (config?.root) {
config.root = new URL(config.root, import.meta.url); config.root = new URL(config.root, import.meta.url);

View file

@ -62,7 +62,9 @@ export default function createIntegration(args?: Options): AstroIntegration {
_buildConfig = config.build; _buildConfig = config.build;
if (config.output === 'static') { 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( console.warn(
`[@astrojs/deno] Otherwise, this adapter is not required to deploy a static site to Deno.` `[@astrojs/deno] Otherwise, this adapter is not required to deploy a static site to Deno.`
); );

View file

@ -4,6 +4,7 @@ import type { ImageService, SSRImageService, TransformOptions } from './loaders/
import type { LoggerLevel } from './utils/logger.js'; import type { LoggerLevel } from './utils/logger.js';
import { joinPaths, prependForwardSlash, propsToFilename } from './utils/paths.js'; import { joinPaths, prependForwardSlash, propsToFilename } from './utils/paths.js';
import { createPlugin } from './vite-plugin-astro-image.js'; import { createPlugin } from './vite-plugin-astro-image.js';
import { isHybridOutput } from './utils/prerender.js';
export { getImage } from './lib/get-image.js'; export { getImage } from './lib/get-image.js';
export { getPicture } from './lib/get-picture.js'; export { getPicture } from './lib/get-picture.js';
@ -84,7 +85,7 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
vite: getViteConfiguration(command === 'dev'), vite: getViteConfiguration(command === 'dev'),
}); });
if (command === 'dev' || config.output === 'server') { if (command === 'dev' || config.output === 'server' || isHybridOutput(config)) {
injectRoute({ injectRoute({
pattern: ROUTE_PATTERN, pattern: ROUTE_PATTERN,
entryPoint: '@astrojs/image/endpoint', entryPoint: '@astrojs/image/endpoint',

View file

@ -0,0 +1,5 @@
import type { AstroConfig } from 'astro';
export function isHybridOutput(config: AstroConfig) {
return config.experimental.hybridOutput && config.output === 'hybrid';
}

View file

@ -134,7 +134,9 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {})
entryFile = config.build.serverEntry.replace(/\.m?js/, ''); entryFile = config.build.serverEntry.replace(/\.m?js/, '');
if (config.output === 'static') { 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( console.warn(
`[@astrojs/netlify] Otherwise, this adapter is not required to deploy a static site to Netlify.` `[@astrojs/netlify] Otherwise, this adapter is not required to deploy a static site to Netlify.`
); );

View file

@ -43,7 +43,9 @@ function netlifyFunctions({
entryFile = config.build.serverEntry.replace(/\.m?js/, ''); entryFile = config.build.serverEntry.replace(/\.m?js/, '');
if (config.output === 'static') { 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( console.warn(
`[@astrojs/netlify] Otherwise, this adapter is not required to deploy a static site to Netlify.` `[@astrojs/netlify] Otherwise, this adapter is not required to deploy a static site to Netlify.`
); );

View file

@ -1,5 +1,11 @@
// @ts-nocheck // @ts-nocheck
export { fromFileUrl } from 'https://deno.land/std@0.110.0/path/mod.ts'; 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/x/deno_dom/deno-dom-wasm.ts';
export * from 'https://deno.land/std@0.142.0/streams/conversion.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';

View file

@ -4,8 +4,8 @@ import { assertEquals, assert, DOMParser } from './deps.ts';
Deno.test({ Deno.test({
name: 'Dynamic imports', name: 'Dynamic imports',
async fn() { async fn() {
let close = await runBuild('./fixtures/dynimport/'); await runBuild('./fixtures/dynimport/');
let stop = await runApp('./fixtures/dynimport/prod.js'); const stop = await runApp('./fixtures/dynimport/prod.js');
try { try {
const response = await fetch('http://127.0.0.1:8085/'); const response = await fetch('http://127.0.0.1:8085/');
@ -20,7 +20,6 @@ Deno.test({
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(err); console.error(err);
} finally { } finally {
await close();
await stop(); await stop();
} }
}, },

View file

@ -1,4 +1,4 @@
import { runBuild } from './test-utils.ts'; import { loadFixture } from './test-utils.ts';
import { assertEquals, assert, DOMParser } from './deps.ts'; import { assertEquals, assert, DOMParser } from './deps.ts';
Deno.env.set('SECRET_STUFF', 'secret'); Deno.env.set('SECRET_STUFF', 'secret');
@ -10,7 +10,8 @@ Deno.test({
name: 'Edge Basics', name: 'Edge Basics',
skip: true, skip: true,
async fn() { async fn() {
let close = await runBuild('./fixtures/edge-basic/'); const fixture = loadFixture('./fixtures/edge-basic/');
await fixture.runBuild();
const { default: handler } = await import( const { default: handler } = await import(
'./fixtures/edge-basic/.netlify/edge-functions/entry.js' './fixtures/edge-basic/.netlify/edge-functions/entry.js'
); );
@ -26,6 +27,6 @@ Deno.test({
const envDiv = doc.querySelector('#env'); const envDiv = doc.querySelector('#env');
assertEquals(envDiv?.innerText, 'secret'); assertEquals(envDiv?.innerText, 'secret');
await close(); await fixture.cleanup();
}, },
}); });

View file

@ -1,9 +1,23 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from "astro/config";
import { netlifyEdgeFunctions } from '@astrojs/netlify'; 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({ export default defineConfig({
adapter: netlifyEdgeFunctions({ adapter: netlifyEdgeFunctions({
dist: new URL('./dist/', import.meta.url), dist: new URL("./dist/", import.meta.url),
}), }),
output: 'server', ...partialConfig,
}) });

View file

@ -1,5 +1,5 @@
--- ---
export const prerender = true export const prerender = import.meta.env.PRERENDER;
--- ---
<html> <html>

View file

@ -1,15 +1,76 @@
import { runBuild } from './test-utils.ts'; import { loadFixture } from './test-utils.ts';
import { assertEquals } from './deps.ts'; import { assertEquals, assertExists, cheerio, fs } from './deps.ts';
Deno.test({ Deno.test({
name: 'Prerender', name: 'Prerender',
async fn() { async fn(t) {
let close = await runBuild('./fixtures/prerender/'); const environmentVariables = {
const { default: handler } = await import( PRERENDER: 'true',
'./fixtures/prerender/.netlify/edge-functions/entry.js' };
); const fixture = loadFixture('./fixtures/prerender/', environmentVariables);
const response = await handler(new Request('http://example.com/index.html')); await fixture.runBuild();
assertEquals(response, undefined, 'No response because this is an asset');
await close(); 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();
}, },
}); });

View file

@ -1,4 +1,4 @@
import { runBuild } from './test-utils.ts'; import { loadFixture } from './test-utils.ts';
import { assertEquals, assert, DOMParser } from './deps.ts'; import { assertEquals, assert, DOMParser } from './deps.ts';
Deno.test({ Deno.test({
@ -6,12 +6,14 @@ Deno.test({
ignore: true, ignore: true,
name: 'Assets are preferred over HTML routes', name: 'Assets are preferred over HTML routes',
async fn() { async fn() {
let close = await runBuild('./fixtures/root-dynamic/'); const fixture = loadFixture('./fixtures/root-dynamic/');
await fixture.runBuild();
const { default: handler } = await import( const { default: handler } = await import(
'./fixtures/root-dynamic/.netlify/edge-functions/entry.js' './fixtures/root-dynamic/.netlify/edge-functions/entry.js'
); );
const response = await handler(new Request('http://example.com/styles.css')); const response = await handler(new Request('http://example.com/styles.css'));
assertEquals(response, undefined, 'No response because this is an asset'); assertEquals(response, undefined, 'No response because this is an asset');
await close(); await fixture.cleanup();
}, },
}); });

View file

@ -1,29 +1,50 @@
import { fromFileUrl, readableStreamFromReader } from './deps.ts'; import { fromFileUrl, readableStreamFromReader } from './deps.ts';
const dir = new URL('./', import.meta.url); const dir = new URL('./', import.meta.url);
export async function runBuild(fixturePath: string) { export function loadFixture(fixturePath: string, envionmentVariables?: Record<string, string>) {
let proc = Deno.run({ async function runBuild() {
cmd: ['node', '../../../../../../astro/astro.js', 'build', '--silent'], const proc = Deno.run({
cwd: fromFileUrl(new URL(fixturePath, dir)), cmd: ['node', '../../../../../../astro/astro.js', 'build'],
}); env: envionmentVariables,
await proc.status(); cwd: fromFileUrl(new URL(fixturePath, dir)),
return async () => await proc.close(); });
} 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;
}
} }
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,
};
} }

View file

@ -1,5 +1,5 @@
--- ---
export const prerender = true; export const prerender = import.meta.env.PRERENDER;
--- ---
<html> <html>
<head> <head>

View file

@ -1,12 +1,14 @@
import { expect } from 'chai'; import { expect } from 'chai';
import netlifyAdapter from '../../dist/index.js'; import netlifyAdapter from '../../dist/index.js';
import { loadFixture, testIntegration } from './test-utils.js'; import { loadFixture, testIntegration } from './test-utils.js';
import { after } from 'node:test';
describe('Mixed Prerendering with SSR', () => { describe('Mixed Prerendering with SSR', () => {
/** @type {import('./test-utils').Fixture} */ /** @type {import('./test-utils').Fixture} */
let fixture; let fixture;
before(async () => { before(async () => {
process.env.PRERENDER = true;
fixture = await loadFixture({ fixture = await loadFixture({
root: new URL('./fixtures/prerender/', import.meta.url).toString(), root: new URL('./fixtures/prerender/', import.meta.url).toString(),
output: 'server', output: 'server',
@ -18,13 +20,56 @@ describe('Mixed Prerendering with SSR', () => {
}); });
await fixture.build(); await fixture.build();
}); });
after(() => {
delete process.env.PRERENDER;
});
it('Wildcard 404 is sorted last', async () => { it('Wildcard 404 is sorted last', async () => {
const redir = await fixture.readFile('/_redirects'); const redir = await fixture.readFile('/_redirects');
const baseRouteIndex = redir.indexOf('/ /.netlify/functions/entry 200'); const baseRouteIndex = redir.indexOf('/ /.netlify/functions/entry 200');
const oneRouteIndex = redir.indexOf('/one /one/index.html 200'); const oneRouteIndex = redir.indexOf('/one /one/index.html 200');
const fourOhFourWildCardIndex = redir.indexOf('/* /.netlify/functions/entry 404'); 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(baseRouteIndex);
expect(fourOhFourWildCardIndex).to.be.greaterThan(oneRouteIndex); 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);
});
});

View file

@ -40,7 +40,9 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr
setAdapter(getAdapter(_options)); setAdapter(getAdapter(_options));
if (config.output === 'static') { 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.`
);
} }
}, },
}, },

View file

@ -1,5 +1,5 @@
--- ---
export const prerender = true; export const prerender = import.meta.env.PRERENDER;
--- ---
<html> <html>
<head> <head>

View file

@ -1,22 +1,27 @@
import nodejs from '../dist/index.js'; import nodejs from '../dist/index.js';
import { loadFixture, createRequestAndResponse } from './test-utils.js'; import { loadFixture } from './test-utils.js';
import { expect } from 'chai'; import { expect } from 'chai';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { fetch } from 'undici'; 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', () => { describe('Prerendering', () => {
/** @type {import('./test-utils').Fixture} */ /** @type {import('./test-utils').Fixture} */
let fixture; let fixture;
let server; let server;
async function load() { describe('With base', async () => {
const mod = await import('./fixtures/prerender/dist/server/entry.mjs');
return mod;
}
describe('With base', () => {
before(async () => { before(async () => {
process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.ASTRO_NODE_AUTOSTART = 'disabled';
process.env.PRERENDER = true;
fixture = await loadFixture({ fixture = await loadFixture({
base: '/some-base', base: '/some-base',
root: './fixtures/prerender/', root: './fixtures/prerender/',
@ -31,6 +36,8 @@ describe('Prerendering', () => {
after(async () => { after(async () => {
await server.stop(); await server.stop();
await fixture.clean();
delete process.env.PRERENDER;
}); });
it('Can render SSR route', async () => { it('Can render SSR route', async () => {
@ -68,9 +75,12 @@ describe('Prerendering', () => {
expect(res.headers.get('location')).to.equal('/some-base/two/'); expect(res.headers.get('location')).to.equal('/some-base/two/');
}); });
}); });
describe('Without base', () => {
describe('Without base', async () => {
before(async () => { before(async () => {
process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.ASTRO_NODE_AUTOSTART = 'disabled';
process.env.PRERENDER = true;
fixture = await loadFixture({ fixture = await loadFixture({
root: './fixtures/prerender/', root: './fixtures/prerender/',
output: 'server', output: 'server',
@ -84,6 +94,8 @@ describe('Prerendering', () => {
after(async () => { after(async () => {
await server.stop(); await server.stop();
await fixture.clean();
delete process.env.PRERENDER;
}); });
it('Can render SSR route', async () => { 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');
});
});
});

View file

@ -80,7 +80,7 @@ export default function vercelEdge({
if (config.output === 'static') { if (config.output === 'static') {
throw new Error(` 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.
`); `);
} }

View file

@ -0,0 +1,5 @@
import type { AstroConfig } from 'astro';
export function isHybridOutput(config: AstroConfig) {
return config.experimental.hybridOutput && config.output === 'hybrid';
}

View file

@ -75,7 +75,7 @@ export default function vercelServerless({
if (config.output === 'static') { if (config.output === 'static') {
throw new Error(` 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.
`); `);
} }

View file

@ -9,6 +9,7 @@ import {
import { exposeEnv } from '../lib/env.js'; import { exposeEnv } from '../lib/env.js';
import { emptyDir, getVercelOutput, writeJson } from '../lib/fs.js'; import { emptyDir, getVercelOutput, writeJson } from '../lib/fs.js';
import { getRedirects } from '../lib/redirects.js'; import { getRedirects } from '../lib/redirects.js';
import { isHybridOutput } from '../lib/prerender.js';
const PACKAGE_NAME = '@astrojs/vercel/static'; const PACKAGE_NAME = '@astrojs/vercel/static';
@ -54,7 +55,7 @@ export default function vercelStatic({
setAdapter(getAdapter()); setAdapter(getAdapter());
_config = config; _config = config;
if (config.output === 'server') { if (config.output === 'server' || isHybridOutput(config)) {
throw new Error(`${PACKAGE_NAME} should be used with output: 'static'`); throw new Error(`${PACKAGE_NAME} should be used with output: 'static'`);
} }
}, },

View file

@ -1,5 +1,5 @@
--- ---
export const prerender = true export const prerender = import.meta.env.PRERENDER;
--- ---
<html> <html>

View file

@ -6,6 +6,7 @@ describe('Serverless prerender', () => {
let fixture; let fixture;
before(async () => { before(async () => {
process.env.PRERENDER = true;
fixture = await loadFixture({ fixture = await loadFixture({
root: './fixtures/serverless-prerender/', root: './fixtures/serverless-prerender/',
}); });
@ -16,3 +17,24 @@ describe('Serverless prerender', () => {
expect(await fixture.readFile('../.vercel/output/static/index.html')).to.be.ok; 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;
});
});

3
pnpm-lock.yaml generated
View file

@ -3586,6 +3586,9 @@ importers:
mocha: mocha:
specifier: ^9.2.2 specifier: ^9.2.2
version: 9.2.2 version: 9.2.2
slash:
specifier: ^4.0.0
version: 4.0.0
wrangler: wrangler:
specifier: ^2.0.23 specifier: ^2.0.23
version: 2.0.23 version: 2.0.23