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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Open
}
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 {
astroConfig,
userConfig,

View file

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

View file

@ -8,6 +8,7 @@ import { getDefaultClientDirectives } from '../client-directive/index.js';
import { createDefaultDevConfig } from './config.js';
import { AstroTimer } from './timer.js';
import { loadTSConfig } from './tsconfig.js';
import { isHybridOutput } from '../../prerender/utils.js';
export function createBaseSettings(config: AstroConfig): AstroSettings {
return {
@ -17,7 +18,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
adapter: undefined,
injectedRoutes:
config.experimental.assets && config.output === 'server'
config.experimental.assets && (config.output === 'server' || isHybridOutput(config))
? [{ pattern: '/_image', entryPoint: 'astro/assets/image-endpoint' }]
: [],
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 { callMiddleware } from '../middleware/callMiddleware.js';
import { isValueSerializable } from '../render/core.js';
import { isHybridOutput } from '../../prerender/utils.js';
const clientAddressSymbol = Symbol.for('astro.clientAddress');
const clientLocalsSymbol = Symbol.for('astro.locals');
@ -168,7 +169,7 @@ function isRedirect(statusCode: number) {
}
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);
}
}

View file

@ -48,7 +48,7 @@ export const AstroErrorData = {
title: '`Astro.redirect` is not available in static mode.',
code: 3001,
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.',
},
/**
@ -79,7 +79,7 @@ export const AstroErrorData = {
title: '`Astro.clientAddress` is not available in static mode.',
code: 3003,
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.',
},
/**
@ -390,7 +390,7 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
NoAdapterInstalled: {
title: 'Cannot use Server-side Rendering without an adapter.',
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.',
},
/**
@ -416,10 +416,12 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
InvalidPrerenderExport: {
title: 'Invalid prerender export.',
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.`;
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`.',

View file

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

View file

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

View file

@ -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<string, any> {
@ -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) {

View file

@ -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<T>({
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) {

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') {
// 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

View file

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

View file

@ -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<T extends (...args: any) => Promise<any>> = 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 = {

View file

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

View file

@ -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<PageOptions> {
export async function scan(code: string, id: string, isHybridOutput = false): Promise<PageOptions> {
if (!includesExport(code)) return {};
if (!didInit) {
await eslexer.init;
@ -45,6 +46,7 @@ export async function scan(code: string, id: string): Promise<PageOptions> {
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<PageOptions> {
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 {

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",
"cheerio": "^1.0.0-rc.11",
"mocha": "^9.2.2",
"slash": "^4.0.0",
"wrangler": "^2.0.23"
}
}

View file

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

View file

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

View file

@ -1,19 +1,60 @@
import { loadFixture } from './test-utils.js';
import { expect } from 'chai';
import slash from 'slash';
describe('Prerendering', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
process.env.PRERENDER = true;
fixture = await loadFixture({
root: './fixtures/prerender/',
});
await fixture.build();
});
after(() => {
delete process.env.PRERENDER;
fixture.clean();
});
it('includes prerendered routes in the routes.json config', async () => {
const routes = JSON.parse(await fixture.readFile('/_routes.json'));
expect(routes.exclude).to.include('/one/');
const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')).exclude.map((r) =>
slash(r)
);
const expectedExcludedRoutes = ['/_worker.js', '/one/index.html', '/one/'];
expect(foundRoutes.every((element) => expectedExcludedRoutes.includes(element))).to.be.true;
});
});
describe('Hybrid rendering', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
process.env.PRERENDER = false;
fixture = await loadFixture({
root: './fixtures/prerender/',
output: 'hybrid',
experimental: {
hybridOutput: true,
},
});
await fixture.build();
});
after(() => {
delete process.env.PRERENDER;
});
it('includes prerendered routes in the routes.json config', async () => {
const foundRoutes = JSON.parse(await fixture.readFile('/_routes.json')).exclude.map((r) =>
slash(r)
);
const expectedExcludedRoutes = ['/_worker.js', '/index.html', '/'];
expect(foundRoutes.every((element) => expectedExcludedRoutes.includes(element))).to.be.true;
});
});

View file

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

View file

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

View file

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

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/, '');
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.`
);

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

@ -1,29 +1,50 @@
import { fromFileUrl, readableStreamFromReader } from './deps.ts';
const dir = new URL('./', import.meta.url);
export async function runBuild(fixturePath: string) {
let proc = Deno.run({
cmd: ['node', '../../../../../../astro/astro.js', 'build', '--silent'],
cwd: fromFileUrl(new URL(fixturePath, dir)),
});
await proc.status();
return async () => await proc.close();
}
export async function runApp(entryPath: string) {
const entryUrl = new URL(entryPath, dir);
let proc = Deno.run({
cmd: ['deno', 'run', '--allow-env', '--allow-net', fromFileUrl(entryUrl)],
//cwd: fromFileUrl(entryUrl),
stderr: 'piped',
});
const stderr = readableStreamFromReader(proc.stderr);
const dec = new TextDecoder();
for await (let bytes of stderr) {
let msg = dec.decode(bytes);
if (msg.includes(`Server running`)) {
break;
}
export function loadFixture(fixturePath: string, envionmentVariables?: Record<string, string>) {
async function runBuild() {
const proc = Deno.run({
cmd: ['node', '../../../../../../astro/astro.js', 'build'],
env: envionmentVariables,
cwd: fromFileUrl(new URL(fixturePath, dir)),
});
await proc.status();
proc.close();
}
return () => proc.close();
async function runApp(entryPath: string) {
const entryUrl = new URL(entryPath, dir);
let proc = Deno.run({
cmd: ['deno', 'run', '--allow-env', '--allow-net', fromFileUrl(entryUrl)],
env: envionmentVariables,
//cwd: fromFileUrl(entryUrl),
stderr: 'piped',
});
const stderr = readableStreamFromReader(proc.stderr);
const dec = new TextDecoder();
for await (let bytes of stderr) {
let msg = dec.decode(bytes);
if (msg.includes(`Server running`)) {
break;
}
}
return () => proc.close();
}
async function cleanup() {
const netlifyPath = new URL('.netlify', new URL(fixturePath, dir));
const distPath = new URL('dist', new URL(fixturePath, dir));
// remove the netlify folder
await Deno.remove(netlifyPath, { recursive: true });
// remove the dist folder
await Deno.remove(distPath, { recursive: true });
}
return {
runApp,
runBuild,
cleanup,
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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