Skip to content

Commit

Permalink
feat: hybrid output (#6991)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Matthew Phillips <[email protected]>
  • Loading branch information
3 people committed May 17, 2023
1 parent 2b9230e commit 719002c
Show file tree
Hide file tree
Showing 57 changed files with 669 additions and 173 deletions.
39 changes: 39 additions & 0 deletions .changeset/mighty-shoes-scream.md
Original file line number Diff line number Diff line change
@@ -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>
```
47 changes: 43 additions & 4 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ export interface AstroUserConfig {
/**
* @docs
* @name output
* @type {('static' | 'server')}
* @type {('static' | 'server' | 'hybrid')}
* @default `'static'`
* @see adapter
* @description
Expand All @@ -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';
Expand All @@ -575,7 +576,7 @@ export interface AstroUserConfig {
* })
* ```
*/
output?: 'static' | 'server';
output?: 'static' | 'server' | 'hybrid';

/**
* @docs
Expand Down Expand Up @@ -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'
* }
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/assets/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
7 changes: 4 additions & 3 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions packages/astro/src/core/build/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -233,7 +238,7 @@ class AstroBuilder {
logging: LogOptions;
timeStart: number;
pageCount: number;
buildMode: 'static' | 'server';
buildMode: AstroConfig['output'];
}) {
const total = getTimeStat(timeStart, performance.now());

Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/core/build/plugins/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
},
Expand Down
6 changes: 2 additions & 4 deletions packages/astro/src/core/build/plugins/plugin-prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand All @@ -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`;
}
Expand Down
9 changes: 5 additions & 4 deletions packages/astro/src/core/build/plugins/plugin-ssr.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
16 changes: 9 additions & 7 deletions packages/astro/src/core/build/static-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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', {
Expand Down
7 changes: 7 additions & 0 deletions packages/astro/src/core/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 719002c

Please sign in to comment.