Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement hydrated components in the static build #2260

Merged
merged 12 commits into from
Dec 30, 2021
1 change: 1 addition & 0 deletions examples/fast-build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dev": "astro dev --experimental-static-build",
"start": "astro dev",
"build": "astro build --experimental-static-build",
"scan-build": "astro build",
"preview": "astro preview"
},
"devDependencies": {
Expand Down
24 changes: 24 additions & 0 deletions examples/fast-build/src/components/Counter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<template>
<div id="vue" class="counter">
<button @click="subtract()">-</button>
<pre>{{ count }}</pre>
<button @click="add()">+</button>
</div>
</template>

<script>
import { ref } from 'vue';
export default {
setup() {
const count = ref(0);
const add = () => (count.value = count.value + 1);
const subtract = () => (count.value = count.value - 1);

return {
count,
add,
subtract,
};
},
};
</script>
20 changes: 20 additions & 0 deletions examples/fast-build/src/pages/[pokemon].astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
import Greeting from '../components/Greeting.vue';

export async function getStaticPaths() {
const response = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=2000`);
const result = await response.json();
const allPokemon = result.results;
return allPokemon.map(pokemon => ({params: {pokemon: pokemon.name}, props: {pokemon}}));
}
---
<html lang="en">
<head>
<title>Hello</title>
</head>

<body>
<h1>{Astro.props.pokemon.name}</h1>
<Greeting client:load />
</body>
</html>
18 changes: 12 additions & 6 deletions examples/fast-build/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import imgUrl from '../images/penguin.jpg';
import grayscaleUrl from '../images/random.jpg?grayscale=true';
import Greeting from '../components/Greeting.vue';
import Counter from '../components/Counter.vue';
---

<html>
Expand All @@ -26,9 +27,14 @@ import Greeting from '../components/Greeting.vue';
<Greeting />
</section>

<section>
<h1>ImageTools</h1>
<img src={grayscaleUrl} />
</section>
</body>
</html>
<section>
<h1>ImageTools</h1>
<img src={grayscaleUrl} />
</section>

<section>
<h1>Hydrated component</h1>
<Counter client:idle />
</section>
</body>
</html>
2 changes: 1 addition & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"test": "mocha --parallel --timeout 15000"
},
"dependencies": {
"@astrojs/compiler": "^0.6.0",
"@astrojs/compiler": "^0.7.0",
"@astrojs/language-server": "^0.8.2",
"@astrojs/markdown-remark": "^0.6.0",
"@astrojs/prism": "0.4.0",
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,5 +371,6 @@ export interface SSRResult {
scripts: Set<SSRElement>;
links: Set<SSRElement>;
createAstro(Astro: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null): AstroGlobal;
resolve: (s: string) => Promise<string>;
_metadata: SSRMetadata;
}
5 changes: 5 additions & 0 deletions packages/astro/src/core/build/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export interface BuildInternals {

// A mapping to entrypoints (facadeId) to assets (styles) that are added.
facadeIdToAssetsMap: Map<string, string[]>;

// A mapping of specifiers like astro/client/idle.js to the hashed bundled name.
// Used to render pages with the correct specifiers.
entrySpecifierToBundleMap: Map<string, string>;
matthewp marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down Expand Up @@ -41,5 +45,6 @@ export function createBuildInternals(): BuildInternals {
astroStyleMap,
astroPageStyleMap,
facadeIdToAssetsMap,
entrySpecifierToBundleMap: new Map<string, string>(),
};
}
152 changes: 130 additions & 22 deletions packages/astro/src/core/build/static-build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { OutputChunk, PreRenderedChunk, RollupOutput } from 'rollup';
import type { Plugin as VitePlugin } from '../vite';
import type { AstroConfig, RouteCache } from '../../@types/astro';
import type { AstroConfig, RouteCache, SSRElement } from '../../@types/astro';
import type { AllPagesData } from './types';
import type { LogOptions } from '../logger';
import type { ViteConfigWithSSR } from '../create-vite';
Expand All @@ -9,12 +9,16 @@ import type { BuildInternals } from '../../core/build/internal.js';
import type { AstroComponentFactory } from '../../runtime/server';

import fs from 'fs';
import npath from 'path';
import { fileURLToPath } from 'url';
import glob from 'fast-glob';
import vite from '../vite.js';
import { debug, info, error } from '../../core/logger.js';
import { createBuildInternals } from '../../core/build/internal.js';
import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js';
import { renderComponent, getParamsAndProps } from '../ssr/index.js';
import { getParamsAndProps } from '../ssr/index.js';
import { createResult } from '../ssr/result.js';
import { renderPage } from '../../runtime/server/index.js';

export interface StaticBuildOptions {
allPages: AllPagesData;
Expand All @@ -28,35 +32,47 @@ export interface StaticBuildOptions {
export async function staticBuild(opts: StaticBuildOptions) {
const { allPages, astroConfig } = opts;

// The pages to be built for rendering purposes.
const pageInput = new Set<string>();

// The JavaScript entrypoints.
const jsInput: Set<string> = new Set();
const jsInput = new Set<string>();

// A map of each page .astro file, to the PageBuildData which contains information
// about that page, such as its paths.
const facadeIdToPageDataMap = new Map<string, PageBuildData>();

for (const [component, pageData] of Object.entries(allPages)) {
const [renderers, mod] = pageData.preload;
const metadata = mod.$$metadata;

const topLevelImports = new Set([
// Any component that gets hydrated
...metadata.hydratedComponentPaths(),
// Any hydration directive like astro/client/idle.js
...metadata.hydrationDirectiveSpecifiers(),
// The client path for each renderer
...renderers.filter((renderer) => !!renderer.source).map((renderer) => renderer.source!),
]);

// Hydrated components are statically identified.
for (const path of mod.$$metadata.getAllHydratedComponentPaths()) {
// Note that this part is not yet implemented in the static build.
//jsInput.add(path);
for (const specifier of topLevelImports) {
jsInput.add(specifier);
}

let astroModuleId = new URL('./' + component, astroConfig.projectRoot).pathname;
jsInput.add(astroModuleId);
pageInput.add(astroModuleId);
facadeIdToPageDataMap.set(astroModuleId, pageData);
}

// Build internals needed by the CSS plugin
const internals = createBuildInternals();

// Perform the SSR build
const result = (await ssrBuild(opts, internals, jsInput)) as RollupOutput;
// Run the SSR build and client build in parallel
const [ssrResult] = (await Promise.all([ssrBuild(opts, internals, pageInput), clientBuild(opts, internals, jsInput)])) as RollupOutput[];

// Generate each of the pages.
await generatePages(result, opts, internals, facadeIdToPageDataMap);
await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap);
await cleanSsrOutput(opts);
}

async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) {
Expand All @@ -67,7 +83,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
mode: 'production',
build: {
emptyOutDir: true,
minify: false, // 'esbuild', // significantly faster than "terser" but may produce slightly-bigger bundles
minify: false,
outDir: fileURLToPath(astroConfig.dist),
ssr: true,
rollupOptions: {
Expand All @@ -79,7 +95,41 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
target: 'es2020', // must match an esbuild target
},
plugins: [
vitePluginNewBuild(),
vitePluginNewBuild(input, internals, 'mjs'),
rollupPluginAstroBuildCSS({
internals,
}),
...(viteConfig.plugins || []),
],
publicDir: viteConfig.publicDir,
root: viteConfig.root,
envPrefix: 'PUBLIC_',
server: viteConfig.server,
base: astroConfig.buildOptions.site ? new URL(astroConfig.buildOptions.site).pathname : '/',
});
}

async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) {
const { astroConfig, viteConfig } = opts;

return await vite.build({
logLevel: 'error',
mode: 'production',
build: {
emptyOutDir: false,
minify: 'esbuild',
outDir: fileURLToPath(astroConfig.dist),
rollupOptions: {
input: Array.from(input),
output: {
format: 'esm',
},
preserveEntrySignatures: 'exports-only',
},
target: 'es2020', // must match an esbuild target
},
plugins: [
vitePluginNewBuild(input, internals, 'js'),
rollupPluginAstroBuildCSS({
internals,
}),
Expand Down Expand Up @@ -124,6 +174,7 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter

const generationOptions: Readonly<GeneratePathOptions> = {
pageData,
internals,
linkIds,
Component,
};
Expand All @@ -136,13 +187,14 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter

interface GeneratePathOptions {
pageData: PageBuildData;
internals: BuildInternals;
linkIds: string[];
Component: AstroComponentFactory;
}

async function generatePath(path: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) {
async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) {
const { astroConfig, logging, origin, routeCache } = opts;
const { Component, linkIds, pageData } = gopts;
const { Component, internals, linkIds, pageData } = gopts;

const [renderers, mod] = pageData.preload;

Expand All @@ -151,14 +203,36 @@ async function generatePath(path: string, opts: StaticBuildOptions, gopts: Gener
route: pageData.route,
routeCache,
logging,
pathname: path,
pathname,
mod,
});

info(logging, 'generate', `Generating: ${path}`);
debug(logging, 'generate', `Generating: ${pathname}`);

const html = await renderComponent(renderers, Component, astroConfig, path, origin, params, pageProps, linkIds);
const outFolder = new URL('.' + path + '/', astroConfig.dist);
const result = createResult({ astroConfig, origin, params, pathname, renderers });
result.links = new Set<SSRElement>(
linkIds.map((href) => ({
props: {
rel: 'stylesheet',
href,
},
children: '',
}))
);
// Override the `resolve` method so that hydrated components are given the
// hashed filepath to the component.
result.resolve = async (specifier: string) => {
matthewp marked this conversation as resolved.
Show resolved Hide resolved
const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier);
if (typeof hashedFilePath !== 'string') {
throw new Error(`Cannot find the built path for ${specifier}`);
}
const relPath = npath.posix.relative(pathname, '/' + hashedFilePath);
const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath;
return fullyRelativePath;
};

let html = await renderPage(result, Component, pageProps, null);
const outFolder = new URL('.' + pathname + '/', astroConfig.dist);
const outFile = new URL('./index.html', outFolder);
await fs.promises.mkdir(outFolder, { recursive: true });
await fs.promises.writeFile(outFile, html, 'utf-8');
Expand All @@ -167,7 +241,20 @@ async function generatePath(path: string, opts: StaticBuildOptions, gopts: Gener
}
}

export function vitePluginNewBuild(): VitePlugin {
async function cleanSsrOutput(opts: StaticBuildOptions) {
// The SSR output is all .mjs files, the client output is not.
const files = await glob('**/*.mjs', {
cwd: opts.astroConfig.dist.pathname,
});
await Promise.all(
files.map(async (filename) => {
const url = new URL(filename, opts.astroConfig.dist);
await fs.promises.rm(url);
})
);
}

export function vitePluginNewBuild(input: Set<string>, internals: BuildInternals, ext: 'js' | 'mjs'): VitePlugin {
return {
name: '@astro/rollup-plugin-new-build',

Expand All @@ -183,13 +270,34 @@ export function vitePluginNewBuild(): VitePlugin {
outputOptions(outputOptions) {
Object.assign(outputOptions, {
entryFileNames(_chunk: PreRenderedChunk) {
return 'assets/[name].[hash].mjs';
return 'assets/[name].[hash].' + ext;
},
chunkFileNames(_chunk: PreRenderedChunk) {
return 'assets/[name].[hash].mjs';
return 'assets/[name].[hash].' + ext;
},
});
return outputOptions;
},

async generateBundle(_options, bundle) {
const promises = [];
const mapping = new Map<string, string>();
for (const specifier of input) {
promises.push(
this.resolve(specifier).then((result) => {
if (result) {
mapping.set(result.id, specifier);
}
})
);
}
await Promise.all(promises);
for (const [, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk' && chunk.facadeModuleId && mapping.has(chunk.facadeModuleId)) {
const specifier = mapping.get(chunk.facadeModuleId)!;
internals.entrySpecifierToBundleMap.set(specifier, chunk.fileName);
}
}
},
};
}
Loading