0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

Refactor prerendering chunk handling (#11245)

This commit is contained in:
Bjorn Lu 2024-06-17 14:23:10 +08:00 committed by GitHub
parent 68f1d0d13e
commit e22be22e57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 692 additions and 193 deletions

View file

@ -0,0 +1,5 @@
---
"astro": patch
---
Refactors prerendering chunk handling to correctly remove unused code during the SSR runtime

View file

@ -176,6 +176,12 @@ Any tests for `astro build` output should use the main `mocha` tests rather than
If a test needs to validate what happens on the page after it's loading in the browser, that's a perfect use for E2E dev server tests, i.e. to verify that hot-module reloading works in `astro dev` or that components were client hydrated and are interactive.
#### Creating tests
When creating new tests, it's best to reference other existing test files and replicate the same setup. Some other tips include:
- When re-using a fixture multiple times with different configurations, you should also configure unique `outDir`, `build.client`, and `build.server` values so the build output runtime isn't cached and shared by ESM between test runs.
### Other useful commands
```shell

View file

@ -113,6 +113,11 @@ export interface BuildInternals {
ssrSplitEntryChunks: Map<string, Rollup.OutputChunk>;
componentMetadata: SSRResult['componentMetadata'];
middlewareEntryPoint?: URL;
/**
* Chunks in the bundle that are only used in prerendering that we can delete later
*/
prerenderOnlyChunks: Rollup.OutputChunk[];
}
/**
@ -151,6 +156,7 @@ export function createBuildInternals(): BuildInternals {
ssrSplitEntryChunks: new Map(),
entryPoints: new Map(),
cacheManifestUsed: false,
prerenderOnlyChunks: [],
};
}

View file

@ -57,7 +57,6 @@ export async function collectPagesData(
moduleSpecifier: '',
styles: [],
hoistedScript: undefined,
hasSharedModules: false,
};
clearInterval(routeCollectionLogTimeout);
@ -80,7 +79,6 @@ export async function collectPagesData(
moduleSpecifier: '',
styles: [],
hoistedScript: undefined,
hasSharedModules: false,
};
}

View file

@ -12,6 +12,15 @@ export function vitePluginChunks(): VitePlugin {
if (id.includes('astro/dist/runtime/server/')) {
return 'astro/server';
}
// Split the Astro runtime into a separate chunk for readability
if (id.includes('astro/dist/runtime')) {
return 'astro';
}
// Place `astro/env/setup` import in its own chunk to prevent Rollup's TLA bug
// https://github.com/rollup/rollup/issues/4708
if (id.includes('astro/dist/env/setup')) {
return 'astro/env-setup';
}
},
});
},

View file

@ -1,87 +1,105 @@
import path from 'node:path';
import type { Plugin as VitePlugin } from 'vite';
import type { Rollup, Plugin as VitePlugin } from 'vite';
import { getPrerenderMetadata } from '../../../prerender/metadata.js';
import type { BuildInternals } from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js';
import type { StaticBuildOptions } from '../types.js';
import { extendManualChunks } from './util.js';
import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugin-pages.js';
import { getPagesFromVirtualModulePageName } from './util.js';
function vitePluginPrerender(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
function vitePluginPrerender(internals: BuildInternals): VitePlugin {
return {
name: 'astro:rollup-plugin-prerender',
outputOptions(outputOptions) {
extendManualChunks(outputOptions, {
after(id, meta) {
// Split the Astro runtime into a separate chunk for readability
if (id.includes('astro/dist/runtime')) {
return 'astro';
}
const pageInfo = internals.pagesByViteID.get(id);
let hasSharedModules = false;
if (pageInfo) {
// prerendered pages should be split into their own chunk
// Important: this can't be in the `pages/` directory!
if (getPrerenderMetadata(meta.getModuleInfo(id)!)) {
const infoMeta = meta.getModuleInfo(id)!;
generateBundle(_, bundle) {
const moduleIds = this.getModuleIds();
for (const id of moduleIds) {
const pageInfo = internals.pagesByViteID.get(id);
if (!pageInfo) continue;
const moduleInfo = this.getModuleInfo(id);
if (!moduleInfo) continue;
// Here, we check if this page is importing modules that are shared among other modules e.g. middleware, other SSR pages, etc.
// we loop the modules that the current page imports
for (const moduleId of infoMeta.importedIds) {
// we retrieve the metadata of the module
const moduleMeta = meta.getModuleInfo(moduleId)!;
if (
// a shared modules should be inside the `src/` folder, at least
moduleMeta.id.startsWith(opts.settings.config.srcDir.pathname) &&
// and has at least two importers: the current page and something else
moduleMeta.importers.length > 1
) {
// Now, we have to trace back the modules imported and analyze them;
// understanding if a module is eventually shared between two pages isn't easy, because a module could
// be imported by a page and a component that is eventually imported by a page.
//
// Given the previous statement, we only check if
// - the module is a page, and it's not pre-rendered
// - the module is the middleware
// If one of these conditions is met, we need a separate chunk
for (const importer of moduleMeta.importedIds) {
// we don't want to analyze the same module again, so we skip it
if (importer !== id) {
const importerModuleMeta = meta.getModuleInfo(importer);
if (importerModuleMeta) {
// if the module is inside the pages
if (importerModuleMeta.id.includes('/pages')) {
// we check if it's not pre-rendered
if (getPrerenderMetadata(importerModuleMeta) === false) {
hasSharedModules = true;
break;
}
}
// module isn't an Astro route/page, it could be a middleware
else if (importerModuleMeta.id.includes('/middleware')) {
hasSharedModules = true;
break;
}
}
}
}
}
}
const prerender = !!getPrerenderMetadata(moduleInfo);
pageInfo.route.prerender = prerender;
}
pageInfo.hasSharedModules = hasSharedModules;
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/${path.basename(pageInfo.component)}`;
}
},
});
// Find all chunks used in the SSR runtime (that aren't used for prerendering only), then use
// the Set to find the inverse, where chunks that are only used for prerendering. It's faster
// to compute `internals.prerenderOnlyChunks` this way. The prerendered chunks will be deleted
// after we finish prerendering.
const nonPrerenderOnlyChunks = getNonPrerenderOnlyChunks(bundle, internals);
internals.prerenderOnlyChunks = Object.values(bundle).filter((chunk) => {
return chunk.type === 'chunk' && !nonPrerenderOnlyChunks.has(chunk);
}) as Rollup.OutputChunk[];
},
};
}
function getNonPrerenderOnlyChunks(bundle: Rollup.OutputBundle, internals: BuildInternals) {
const chunks = Object.values(bundle);
const prerenderOnlyEntryChunks = new Set<Rollup.OutputChunk>();
const nonPrerenderOnlyEntryChunks = new Set<Rollup.OutputChunk>();
for (const chunk of chunks) {
if (chunk.type === 'chunk' && (chunk.isEntry || chunk.isDynamicEntry)) {
// See if this entry chunk is prerendered, if so, skip it
if (chunk.facadeModuleId?.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) {
const pageDatas = getPagesFromVirtualModulePageName(
internals,
ASTRO_PAGE_RESOLVED_MODULE_ID,
chunk.facadeModuleId
);
const prerender = pageDatas.every((pageData) => pageData.route.prerender);
if (prerender) {
prerenderOnlyEntryChunks.add(chunk);
continue;
}
}
// Ideally we should record entries when `functionPerRoute` is enabled, but this breaks some tests
// that expect the entrypoint to still exist even if it should be unused.
// TODO: Revisit this so we can delete additional unused chunks
// else if (chunk.facadeModuleId?.startsWith(RESOLVED_SPLIT_MODULE_ID)) {
// const pageDatas = getPagesFromVirtualModulePageName(
// internals,
// RESOLVED_SPLIT_MODULE_ID,
// chunk.facadeModuleId
// );
// const prerender = pageDatas.every((pageData) => pageData.route.prerender);
// if (prerender) {
// prerenderOnlyEntryChunks.add(chunk);
// continue;
// }
// }
nonPrerenderOnlyEntryChunks.add(chunk);
}
}
// From the `nonPrerenderedEntryChunks`, we crawl all the imports/dynamicImports to find all
// other chunks that are use by the non-prerendered runtime
const nonPrerenderOnlyChunks = new Set(nonPrerenderOnlyEntryChunks);
for (const chunk of nonPrerenderOnlyChunks) {
for (const importFileName of chunk.imports) {
const importChunk = bundle[importFileName];
if (importChunk?.type === 'chunk') {
nonPrerenderOnlyChunks.add(importChunk);
}
}
for (const dynamicImportFileName of chunk.dynamicImports) {
const dynamicImportChunk = bundle[dynamicImportFileName];
// The main server entry (entry.mjs) may import a prerender-only entry chunk, we skip in this case
// to prevent incorrectly marking it as non-prerendered.
if (
dynamicImportChunk?.type === 'chunk' &&
!prerenderOnlyEntryChunks.has(dynamicImportChunk)
) {
nonPrerenderOnlyChunks.add(dynamicImportChunk);
}
}
}
return nonPrerenderOnlyChunks;
}
export function pluginPrerender(
opts: StaticBuildOptions,
internals: BuildInternals
@ -96,7 +114,7 @@ export function pluginPrerender(
hooks: {
'build:before': () => {
return {
vitePlugin: vitePluginPrerender(opts, internals),
vitePlugin: vitePluginPrerender(internals),
};
},
},

View file

@ -27,7 +27,17 @@ function vitePluginSSR(
name: '@astrojs/vite-plugin-astro-ssr-server',
enforce: 'post',
options(opts) {
return addRollupInput(opts, [SSR_VIRTUAL_MODULE_ID]);
const inputs = new Set<string>();
for (const pageData of Object.values(options.allPages)) {
if (routeIsRedirect(pageData.route)) {
continue;
}
inputs.add(getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component));
}
inputs.add(SSR_VIRTUAL_MODULE_ID);
return addRollupInput(opts, Array.from(inputs));
},
resolveId(id) {
if (id === SSR_VIRTUAL_MODULE_ID) {
@ -72,7 +82,6 @@ function vitePluginSSR(
contents.push(...ssrCode.contents);
return [...imports, ...contents, ...exports].join('\n');
}
return void 0;
},
async generateBundle(_opts, bundle) {
// Add assets from this SSR chunk as well.
@ -141,23 +150,20 @@ function vitePluginSSRSplit(
adapter: AstroAdapter,
options: StaticBuildOptions
): VitePlugin {
const functionPerRouteEnabled = isFunctionPerRouteEnabled(options.settings.adapter);
return {
name: '@astrojs/vite-plugin-astro-ssr-split',
enforce: 'post',
options(opts) {
if (functionPerRouteEnabled) {
const inputs = new Set<string>();
const inputs = new Set<string>();
for (const pageData of Object.values(options.allPages)) {
if (routeIsRedirect(pageData.route)) {
continue;
}
inputs.add(getVirtualModulePageName(SPLIT_MODULE_ID, pageData.component));
for (const pageData of Object.values(options.allPages)) {
if (routeIsRedirect(pageData.route)) {
continue;
}
return addRollupInput(opts, Array.from(inputs));
inputs.add(getVirtualModulePageName(SPLIT_MODULE_ID, pageData.component));
}
return addRollupInput(opts, Array.from(inputs));
},
resolveId(id) {
if (id.startsWith(SPLIT_MODULE_ID)) {
@ -185,7 +191,6 @@ function vitePluginSSRSplit(
return [...imports, ...contents, ...exports].join('\n');
}
return void 0;
},
async generateBundle(_opts, bundle) {
// Add assets from this SSR chunk as well.

View file

@ -2,7 +2,6 @@ import fs from 'node:fs';
import path, { extname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { teardown } from '@astrojs/compiler';
import * as eslexer from 'es-module-lexer';
import glob from 'fast-glob';
import { bgGreen, bgMagenta, black, green } from 'kleur/colors';
import * as vite from 'vite';
@ -156,7 +155,7 @@ export async function staticBuild(
case isServerLikeOutput(settings.config): {
settings.timer.start('Server generate');
await generatePages(opts, internals);
await cleanStaticOutput(opts, internals, ssrOutputChunkNames);
await cleanStaticOutput(opts, internals);
opts.logger.info(null, `\n${bgMagenta(black(' finalizing server assets '))}\n`);
await ssrMoveAssets(opts);
settings.timer.end('Server generate');
@ -199,6 +198,8 @@ async function ssrBuild(
copyPublicDir: !ssr,
rollupOptions: {
...viteConfig.build?.rollupOptions,
// Setting as `exports-only` allows us to safely delete inputs that are only used during prerendering
preserveEntrySignatures: 'exports-only',
input: [],
output: {
hoistTransitiveImports: isContentCache,
@ -381,65 +382,35 @@ async function runPostBuildHooks(
}
/**
* For each statically prerendered page, replace their SSR file with a noop.
* This allows us to run the SSR build only once, but still remove dependencies for statically rendered routes.
* If a component is shared between a statically rendered route and a SSR route, it will still be included in the SSR build.
* Remove chunks that are used for prerendering only
*/
async function cleanStaticOutput(
opts: StaticBuildOptions,
internals: BuildInternals,
ssrOutputChunkNames: string[]
) {
const prerenderedFiles = new Set();
const onDemandsFiles = new Set();
for (const pageData of internals.pagesByKeys.values()) {
const { moduleSpecifier } = pageData;
const bundleId =
internals.pageToBundleMap.get(moduleSpecifier) ??
internals.entrySpecifierToBundleMap.get(moduleSpecifier);
if (pageData.route.prerender && !pageData.hasSharedModules && !onDemandsFiles.has(bundleId)) {
prerenderedFiles.add(bundleId);
} else {
onDemandsFiles.add(bundleId);
// Check if the component was not previously added to the static build by a statically rendered route
if (prerenderedFiles.has(bundleId)) {
prerenderedFiles.delete(bundleId);
}
}
}
async function cleanStaticOutput(opts: StaticBuildOptions, internals: BuildInternals) {
const ssr = isServerLikeOutput(opts.settings.config);
const out = ssr
? opts.settings.config.build.server
: getOutDirWithinCwd(opts.settings.config.outDir);
// The SSR output chunks for Astro are all .mjs files
const files = ssrOutputChunkNames.filter((f) => f.endsWith('.mjs'));
if (files.length) {
await eslexer.init;
// Cleanup prerendered chunks.
// This has to happen AFTER the SSR build runs as a final step, because we need the code in order to generate the pages.
// These chunks should only contain prerendering logic, so they are safe to modify.
await Promise.all(
files.map(async (filename) => {
if (!prerenderedFiles.has(filename)) {
return;
await Promise.all(
internals.prerenderOnlyChunks.map(async (chunk) => {
const url = new URL(chunk.fileName, out);
try {
// Entry chunks may be referenced by non-deleted code, so we don't actually delete it
// but only empty its content. These chunks should never be executed in practice, but
// it should prevent broken import paths if adapters do a secondary bundle.
if (chunk.isEntry || chunk.isDynamicEntry) {
await fs.promises.writeFile(
url,
"// Contents removed by Astro as it's used for prerendering only",
'utf-8'
);
} else {
await fs.promises.unlink(url);
}
const url = new URL(filename, out);
const text = await fs.promises.readFile(url, { encoding: 'utf8' });
const [, exports] = eslexer.parse(text);
// Replace exports (only prerendered pages) with a noop
let value = 'const noop = () => {};';
for (const e of exports) {
if (e.n === 'default') value += `\n export default noop;`;
else value += `\nexport const ${e.n} = noop;`;
}
await fs.promises.writeFile(url, value, { encoding: 'utf8' });
})
);
removeEmptyDirs(out);
}
} catch {
// Best-effort only. Sometimes some chunks may be deleted by other plugins, like pure CSS chunks,
// so they may already not exist.
}
})
);
}
async function cleanServerOutput(

View file

@ -29,7 +29,6 @@ export interface PageBuildData {
moduleSpecifier: string;
hoistedScript: HoistedScriptAsset | undefined;
styles: Array<{ depth: number; order: number; sheet: StylesheetAsset }>;
hasSharedModules: boolean;
}
export type AllPagesData = Record<ComponentPath, PageBuildData>;

View file

@ -14,6 +14,11 @@ describe('Assets Prefix - Static', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-assets-prefix/',
outDir: './dist/static',
build: {
client: './dist/static/client',
server: './dist/static/server',
},
});
await fixture.build();
});
@ -72,7 +77,10 @@ describe('Assets Prefix - with path prefix', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-assets-prefix/',
outDir: './dist/server',
build: {
client: './dist/server/client',
server: './dist/server/server',
assetsPrefix: '/starting-slash',
},
});
@ -97,6 +105,11 @@ describe('Assets Prefix, server', () => {
root: './fixtures/astro-assets-prefix/',
output: 'server',
adapter: testAdapter(),
outDir: './dist/server',
build: {
client: './dist/server/client',
server: './dist/server/server',
},
});
await fixture.build();
app = await fixture.loadTestAdapterApp();
@ -154,7 +167,10 @@ describe('Assets Prefix, with path prefix', () => {
root: './fixtures/astro-assets-prefix/',
output: 'server',
adapter: testAdapter(),
outDir: './dist/server-path-prefix',
build: {
client: './dist/server-path-prefix/client',
server: './dist/server-path-prefix/server',
assetsPrefix: '/starting-slash',
},
});

View file

@ -14,6 +14,11 @@ describe('Astro Scripts before-hydration', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/before-hydration/',
outDir: './dist/static-integration',
build: {
client: './dist/static-integration/client',
server: './dist/static-integration/server',
},
integrations: [
preact(),
{
@ -68,6 +73,11 @@ describe('Astro Scripts before-hydration', () => {
before(async () => {
fixture = await loadFixture({
root: './fixtures/before-hydration/',
outDir: './dist/static-no-integration',
build: {
client: './dist/static-no-integration/client',
server: './dist/static-no-integration/server',
},
});
});
@ -115,6 +125,11 @@ describe('Astro Scripts before-hydration', () => {
root: './fixtures/before-hydration/',
output: 'server',
adapter: testAdapter(),
outDir: './dist/server-integration',
build: {
client: './dist/server-integration/client',
server: './dist/server-integration/server',
},
integrations: [
preact(),
{
@ -153,6 +168,11 @@ describe('Astro Scripts before-hydration', () => {
fixture = await loadFixture({
root: './fixtures/before-hydration/',
output: 'server',
outDir: './dist/static-no-integration',
build: {
client: './dist/static-no-integration/client',
server: './dist/static-no-integration/server',
},
adapter: testAdapter(),
});
});

View file

@ -799,6 +799,11 @@ describe('astro:image', () => {
const fixtureWithBase = await loadFixture({
root: './fixtures/core-image-ssr/',
output: 'server',
outDir: './dist/server-base-path',
build: {
client: './dist/server-base-path/client',
server: './dist/server-base-path/server',
},
adapter: testAdapter(),
image: {
service: testImageService(),
@ -1080,6 +1085,11 @@ describe('astro:image', () => {
fixture = await loadFixture({
root: './fixtures/core-image-ssr/',
output: 'server',
outDir: './dist/server-dev',
build: {
client: './dist/server-dev/client',
server: './dist/server-dev/server',
},
adapter: testAdapter(),
base: 'some-base',
image: {
@ -1114,6 +1124,11 @@ describe('astro:image', () => {
fixture = await loadFixture({
root: './fixtures/core-image-ssr/',
output: 'server',
outDir: './dist/server-prod',
build: {
client: './dist/server-prod/client',
server: './dist/server-prod/server',
},
adapter: testAdapter(),
image: {
endpoint: 'astro/assets/endpoint/node',
@ -1127,6 +1142,7 @@ describe('astro:image', () => {
const app = await fixture.loadTestAdapterApp();
let request = new Request('http://example.com/');
let response = await app.render(request);
console.log
assert.equal(response.status, 200);
const html = await response.text();
const $ = cheerio.load(html);

View file

@ -15,7 +15,10 @@ describe('Setting inlineStylesheets to never in static output', () => {
site: 'https://test.dev/',
root: './fixtures/css-inline-stylesheets/',
output: 'static',
outDir: './dist/static-inline-stylesheets-never',
build: {
client: './dist/static-inline-stylesheets-never/client',
server: './dist/static-inline-stylesheets-never/server',
inlineStylesheets: 'never',
},
});
@ -53,7 +56,10 @@ describe('Setting inlineStylesheets to never in server output', () => {
root: './fixtures/css-inline-stylesheets/',
output: 'server',
adapter: testAdapter(),
outDir: './dist/server-inline-stylesheets-never',
build: {
client: './dist/server-inline-stylesheets-never/client',
server: './dist/server-inline-stylesheets-never/server',
inlineStylesheets: 'never',
},
});
@ -92,7 +98,10 @@ describe('Setting inlineStylesheets to auto in static output', () => {
site: 'https://test.info/',
root: './fixtures/css-inline-stylesheets/',
output: 'static',
outDir: './dist/static-inline-stylesheets-auto',
build: {
client: './dist/static-inline-stylesheets-auto/client',
server: './dist/static-inline-stylesheets-auto/server',
inlineStylesheets: 'auto',
},
vite: {
@ -137,7 +146,10 @@ describe('Setting inlineStylesheets to auto in server output', () => {
root: './fixtures/css-inline-stylesheets/',
output: 'server',
adapter: testAdapter(),
outDir: './dist/server-inline-stylesheets-auto',
build: {
client: './dist/server-inline-stylesheets-auto/client',
server: './dist/server-inline-stylesheets-auto/server',
inlineStylesheets: 'auto',
},
vite: {
@ -184,7 +196,10 @@ describe('Setting inlineStylesheets to always in static output', () => {
site: 'https://test.net/',
root: './fixtures/css-inline-stylesheets/',
output: 'static',
outDir: './dist/static-inline-stylesheets-always',
build: {
client: './dist/static-inline-stylesheets-always/client',
server: './dist/static-inline-stylesheets-always/server',
inlineStylesheets: 'always',
},
});
@ -221,7 +236,10 @@ describe('Setting inlineStylesheets to always in server output', () => {
root: './fixtures/css-inline-stylesheets/',
output: 'server',
adapter: testAdapter(),
outDir: './dist/server-inline-stylesheets-always',
build: {
client: './dist/server-inline-stylesheets-always/client',
server: './dist/server-inline-stylesheets-always/server',
inlineStylesheets: 'always',
},
});

View file

@ -59,7 +59,10 @@ describe('Experimental Content Collections cache - inlineStylesheets to never in
root: './fixtures/css-inline-stylesheets/',
output: 'server',
adapter: testAdapter(),
outDir: './dist/inline-stylesheets-never',
build: {
client: './dist/inline-stylesheets-never/client',
server: './dist/inline-stylesheets-never/server',
inlineStylesheets: 'never',
},
experimental: {
@ -103,7 +106,10 @@ describe('Experimental Content Collections cache - inlineStylesheets to auto in
site: 'https://test.info/',
root: './fixtures/css-inline-stylesheets/',
output: 'static',
outDir: './dist/inline-stylesheets-auto',
build: {
client: './dist/inline-stylesheets-auto/client',
server: './dist/inline-stylesheets-auto/server',
inlineStylesheets: 'auto',
},
vite: {
@ -202,7 +208,10 @@ describe('Setting inlineStylesheets to always in server output', () => {
root: './fixtures/css-inline-stylesheets/',
output: 'server',
adapter: testAdapter(),
outDir: './dist/inline-stylesheets-always',
build: {
client: './dist/inline-stylesheets-always/client',
server: './dist/inline-stylesheets-always/server',
inlineStylesheets: 'always',
},
experimental: {

View file

@ -0,0 +1,10 @@
import serverlessAdapter from '@test/ssr-prerender-chunks-test-adapter';
import { defineConfig } from 'astro/config';
import react from "@astrojs/react";
// https://astro.build/config
export default defineConfig({
adapter: serverlessAdapter(),
output: 'server',
integrations: [react()]
})

View file

@ -0,0 +1,85 @@
/**
*
* @returns {import('../src/@types/astro').AstroIntegration}
*/
export default function () {
return {
name: '@test/ssr-prerender-chunks-test-adapter',
hooks: {
'astro:config:setup': ({ updateConfig, config }) => {
updateConfig({
build: {
client: config.outDir,
server: new URL('./_worker.js/', config.outDir),
serverEntry: 'index.js',
redirects: false,
}
});
},
'astro:config:done': ({ setAdapter }) => {
setAdapter({
name: '@test/ssr-prerender-chunks-test-adapter',
serverEntrypoint: '@test/ssr-prerender-chunks-test-adapter/server.js',
exports: ['default'],
supportedAstroFeatures: {
serverOutput: 'stable',
},
});
},
'astro:build:setup': ({ vite, target }) => {
if (target === 'server') {
vite.resolve ||= {};
vite.resolve.alias ||= {};
const aliases = [
{
find: 'react-dom/server',
replacement: 'react-dom/server.browser',
},
];
if (Array.isArray(vite.resolve.alias)) {
vite.resolve.alias = [...vite.resolve.alias, ...aliases];
} else {
for (const alias of aliases) {
(vite.resolve.alias)[alias.find] = alias.replacement;
}
}
vite.resolve.conditions ||= [];
// We need those conditions, previous these conditions where applied at the esbuild step which we removed
// https://github.com/withastro/astro/pull/7092
vite.resolve.conditions.push('workerd', 'worker');
vite.ssr ||= {};
vite.ssr.target = 'webworker';
vite.ssr.noExternal = true;
vite.build ||= {};
vite.build.rollupOptions ||= {};
vite.build.rollupOptions.output ||= {};
vite.build.rollupOptions.output.banner ||=
'globalThis.process ??= {}; globalThis.process.env ??= {};';
// Cloudflare env is only available per request. This isn't feasible for code that access env vars
// in a global way, so we shim their access as `process.env.*`. This is not the recommended way for users to access environment variables. But we'll add this for compatibility for chosen variables. Mainly to support `@astrojs/db`
vite.define = {
'process.env': 'process.env',
...vite.define,
};
}
// we thought that vite config inside `if (target === 'server')` would not apply for client
// but it seems like the same `vite` reference is used for both
// so we need to reset the previous conflicting setting
// in the future we should look into a more robust solution
if (target === 'client') {
vite.resolve ||= {};
vite.resolve.conditions ||= [];
vite.resolve.conditions = vite.resolve.conditions.filter(
(c) => c !== 'workerd' && c !== 'worker'
);
}
},
},
};
}

View file

@ -0,0 +1,10 @@
{
"name": "@test/ssr-prerender-chunks-test-adapter",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./index.js",
"./server.js": "./server.js"
}
}

View file

@ -0,0 +1,65 @@
import { App } from 'astro/app';
export function createExports(manifest) {
const app = new App(manifest);
const fetch = async (
request,
env,
context
) => {
const { pathname } = new URL(request.url);
// static assets fallback, in case default _routes.json is not used
if (manifest.assets.has(pathname)) {
return env.ASSETS.fetch(request.url.replace(/\.html$/, ''));
}
const routeData = app.match(request);
if (!routeData) {
// https://developers.cloudflare.com/pages/functions/api-reference/#envassetsfetch
const asset = await env.ASSETS.fetch(
request.url.replace(/index.html$/, '').replace(/\.html$/, '')
);
if (asset.status !== 404) {
return asset;
}
}
Reflect.set(
request,
Symbol.for('astro.clientAddress'),
request.headers.get('cf-connecting-ip')
);
process.env.ASTRO_STUDIO_APP_TOKEN ??= (() => {
if (typeof env.ASTRO_STUDIO_APP_TOKEN === 'string') {
return env.ASTRO_STUDIO_APP_TOKEN;
}
})();
const locals = {
runtime: {
env: env,
cf: request.cf,
caches,
ctx: {
waitUntil: (promise) => context.waitUntil(promise),
passThroughOnException: () => context.passThroughOnException(),
},
},
};
const response = await app.render(request, { routeData, locals });
if (app.setCookieHeaders) {
for (const setCookieHeader of app.setCookieHeaders(response)) {
response.headers.append('Set-Cookie', setCookieHeader);
}
}
return response;
};
return { default: { fetch } };
}

View file

@ -0,0 +1,14 @@
{
"name": "@test/ssr-prerender-chunks",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/react": "workspace:*",
"@test/ssr-prerender-chunks-test-adapter": "link:./deps/test-adapter",
"@types/react": "^18.2.75",
"@types/react-dom": "^18.2.24",
"astro": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

View file

@ -0,0 +1,26 @@
import React, { useState } from "react";
const Counter: React.FC = () => {
const [count, setCount] = useState<number>(0);
const increment = () => {
setCount((prevCount) => prevCount + 1);
};
const decrement = () => {
setCount((prevCount) => prevCount - 1);
};
return (
<div>
<h2>Counter</h2>
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
</div>
);
};
export default Counter;

View file

@ -0,0 +1,13 @@
---
export const prerender = true;
import Counter from "../components/Counter";
---
<html>
<head>
<title>Static Page</title>
</head>
<body>
<Counter client:load />
</body>
</html>

View file

@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

View file

@ -1336,6 +1336,11 @@ describe('[SSR] i18n routing', () => {
fixture = await loadFixture({
root: './fixtures/i18n-routing-prefix-always/',
output: 'server',
outDir: './dist/pathname-prefix-always-no-redirect',
build: {
client: './dist/pathname-prefix-always-no-redirect/client',
server: './dist/pathname-prefix-always-no-redirect/server',
},
adapter: testAdapter(),
i18n: {
routing: {
@ -1622,6 +1627,11 @@ describe('[SSR] i18n routing', () => {
fixture = await loadFixture({
root: './fixtures/i18n-routing/',
output: 'server',
outDir: './dist/locales-underscore',
build: {
client: './dist/locales-underscore/client',
server: './dist/locales-underscore/server',
},
adapter: testAdapter(),
i18n: {
defaultLocale: 'en',
@ -1891,6 +1901,11 @@ describe('SSR fallback from missing locale index to default locale index', () =>
fixture = await loadFixture({
root: './fixtures/i18n-routing-prefix-other-locales/',
output: 'server',
outDir: './dist/missing-locale-to-default',
build: {
client: './dist/missing-locale-to-default/client',
server: './dist/missing-locale-to-default/server',
},
adapter: testAdapter(),
i18n: {
defaultLocale: 'en',

View file

@ -25,7 +25,14 @@ describe('Hoisted inline scripts in SSR', () => {
describe('without base path', () => {
before(async () => {
fixture = await loadFixture(defaultFixtureOptions);
fixture = await loadFixture({
...defaultFixtureOptions,
outDir: './dist/inline-scripts-without-base-path',
build: {
client: './dist/inline-scripts-without-base-path/client',
server: './dist/inline-scripts-without-base-path/server',
},
});
await fixture.build();
});
@ -42,6 +49,11 @@ describe('Hoisted inline scripts in SSR', () => {
before(async () => {
fixture = await loadFixture({
...defaultFixtureOptions,
outDir: './dist/inline-scripts-with-base-path',
build: {
client: './dist/inline-scripts-with-base-path/client',
server: './dist/inline-scripts-with-base-path/server',
},
base,
});
await fixture.build();
@ -63,6 +75,11 @@ describe('Hoisted external scripts in SSR', () => {
before(async () => {
fixture = await loadFixture({
...defaultFixtureOptions,
outDir: './dist/external-scripts-without-base-path',
build: {
client: './dist/external-scripts-without-base-path/client',
server: './dist/external-scripts-without-base-path/server',
},
vite: {
build: {
assetsInlineLimit: 0,
@ -83,6 +100,11 @@ describe('Hoisted external scripts in SSR', () => {
before(async () => {
fixture = await loadFixture({
...defaultFixtureOptions,
outDir: './dist/external-scripts-with-base-path',
build: {
client: './dist/external-scripts-with-base-path/client',
server: './dist/external-scripts-with-base-path/server',
},
vite: {
build: {
assetsInlineLimit: 0,
@ -104,14 +126,17 @@ describe('Hoisted external scripts in SSR', () => {
before(async () => {
fixture = await loadFixture({
...defaultFixtureOptions,
outDir: './dist/with-assets-prefix',
build: {
client: './dist/with-assets-prefix/client',
server: './dist/with-assets-prefix/server',
assetsPrefix: 'https://cdn.example.com',
},
vite: {
build: {
assetsInlineLimit: 0,
},
},
build: {
assetsPrefix: 'https://cdn.example.com',
},
});
await fixture.build();
});
@ -130,6 +155,11 @@ describe('Hoisted external scripts in SSR', () => {
before(async () => {
fixture = await loadFixture({
...defaultFixtureOptions,
outDir: './dist/with-rollup-output-file-names',
build: {
client: './dist/with-rollup-output-file-names/client',
server: './dist/with-rollup-output-file-names/server',
},
vite: {
build: {
assetsInlineLimit: 0,
@ -157,6 +187,11 @@ describe('Hoisted external scripts in SSR', () => {
before(async () => {
fixture = await loadFixture({
...defaultFixtureOptions,
outDir: './dist/with-rollup-output-file-names-and-base',
build: {
client: './dist/with-rollup-output-file-names-and-base/client',
server: './dist/with-rollup-output-file-names-and-base/server',
},
vite: {
build: {
assetsInlineLimit: 0,
@ -185,6 +220,12 @@ describe('Hoisted external scripts in SSR', () => {
before(async () => {
fixture = await loadFixture({
...defaultFixtureOptions,
outDir: './dist/with-rollup-output-file-names-and-assets-prefix',
build: {
client: './dist/with-rollup-output-file-names-and-assets-prefix/client',
server: './dist/with-rollup-output-file-names-and-assets-prefix/server',
assetsPrefix: 'https://cdn.example.com',
},
vite: {
build: {
assetsInlineLimit: 0,
@ -197,9 +238,6 @@ describe('Hoisted external scripts in SSR', () => {
},
},
},
build: {
assetsPrefix: 'https://cdn.example.com',
},
});
await fixture.build();
});

View file

@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { before, describe, it } from 'node:test';
import { loadFixture } from './test-utils.js';
describe('Chunks', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
root: './fixtures/ssr-prerender-chunks/',
});
await fixture.build();
});
it('does not have wrong chunks', async () => {
const content = await fixture.readFile('_worker.js/renderers.mjs');
const hasImportFromPrerender = !content.includes(`React } from './chunks/prerender`);
assert.ok(hasImportFromPrerender);
});
});

View file

@ -12,6 +12,11 @@ describe('SSR: prerender', () => {
fixture = await loadFixture({
root: './fixtures/ssr-prerender/',
output: 'server',
outDir: './dist/normal',
build: {
client: './dist/normal/client',
server: './dist/normal/server',
},
adapter: testAdapter(),
});
await fixture.build();
@ -61,7 +66,11 @@ describe('SSR: prerender', () => {
});
});
describe('Integrations can hook into the prerendering decision', () => {
// NOTE: This test doesn't make sense as it relies on the fact that on the client build,
// you can change the prerender state of pages from the SSR build, however, the client build
// is not always guaranteed to run. If we want to support this feature, we may want to only allow
// editing `route.prerender` on the `astro:build:done` hook.
describe.skip('Integrations can hook into the prerendering decision', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
@ -83,6 +92,11 @@ describe('Integrations can hook into the prerendering decision', () => {
fixture = await loadFixture({
root: './fixtures/ssr-prerender/',
output: 'server',
outDir: './dist/integration-prerender',
build: {
client: './dist/integration-prerender/client',
server: './dist/integration-prerender/server',
},
integrations: [testIntegration],
adapter: testAdapter(),
});

View file

@ -216,6 +216,10 @@ export async function loadFixture(inlineConfig) {
});
}
},
loadAdapterEntryModule: async () => {
const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir);
return await import(url);
},
loadNodeAdapterHandler: async () => {
const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir);
const { handler } = await import(url);

View file

@ -9,13 +9,6 @@ import { loadFixture, waitServerListen } from './test-utils.js';
* @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
*/
async function load() {
const mod = await import(
`./fixtures/node-middleware/dist/server/entry.mjs?dropcache=${Date.now()}`
);
return mod;
}
describe('behavior from middleware, standalone', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
@ -29,7 +22,7 @@ describe('behavior from middleware, standalone', () => {
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -69,7 +62,7 @@ describe('behavior from middleware, middleware', () => {
adapter: nodejs({ mode: 'middleware' }),
});
await fixture.build();
const { handler } = await load();
const { handler } = await fixture.loadAdapterEntryModule();
const app = express();
app.use(handler);
server = app.listen(8888);

View file

@ -8,13 +8,6 @@ import { loadFixture, waitServerListen } from './test-utils.js';
* @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
*/
async function load() {
const mod = await import(
`./fixtures/prerender-404-500/dist/server/entry.mjs?dropcache=${Date.now()}`
);
return mod;
}
describe('Prerender 404', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
@ -32,10 +25,15 @@ describe('Prerender 404', () => {
base: '/some-base',
root: './fixtures/prerender-404-500/',
output: 'server',
outDir: './dist/server-with-base',
build: {
client: './dist/server-with-base/client',
server: './dist/server-with-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -117,10 +115,15 @@ describe('Prerender 404', () => {
site: 'https://test.info/',
root: './fixtures/prerender-404-500/',
output: 'server',
outDir: './dist/server-without-base',
build: {
client: './dist/server-without-base/client',
server: './dist/server-without-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -181,10 +184,15 @@ describe('Hybrid 404', () => {
base: '/some-base',
root: './fixtures/prerender-404-500/',
output: 'hybrid',
outDir: './dist/hybrid-with-base',
build: {
client: './dist/hybrid-with-base/client',
server: './dist/hybrid-with-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -238,10 +246,15 @@ describe('Hybrid 404', () => {
site: 'https://test.net/',
root: './fixtures/prerender-404-500/',
output: 'hybrid',
outDir: './dist/hybrid-without-base',
build: {
client: './dist/hybrid-without-base/client',
server: './dist/hybrid-without-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);

View file

@ -8,10 +8,6 @@ import { loadFixture, waitServerListen } from './test-utils.js';
* @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;
@ -25,10 +21,15 @@ describe('Prerendering', () => {
base: '/some-base',
root: './fixtures/prerender/',
output: 'server',
outDir: './dist/with-base',
build: {
client: './dist/with-base/client',
server: './dist/with-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -94,10 +95,15 @@ describe('Prerendering', () => {
fixture = await loadFixture({
root: './fixtures/prerender/',
output: 'server',
outDir: './dist/without-base',
build: {
client: './dist/without-base/client',
server: './dist/without-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -155,6 +161,11 @@ describe('Prerendering', () => {
fixture = await loadFixture({
root: './fixtures/prerender/',
output: 'server',
outDir: './dist/dev',
build: {
client: './dist/dev/client',
server: './dist/dev/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
devServer = await fixture.startDevServer();
@ -197,10 +208,15 @@ describe('Hybrid rendering', () => {
base: '/some-base',
root: './fixtures/prerender/',
output: 'hybrid',
outDir: './dist/hybrid-with-base',
build: {
client: './dist/hybrid-with-base/client',
server: './dist/hybrid-with-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -264,10 +280,15 @@ describe('Hybrid rendering', () => {
fixture = await loadFixture({
root: './fixtures/prerender/',
output: 'hybrid',
outDir: './dist/hybrid-without-base',
build: {
client: './dist/hybrid-without-base/client',
server: './dist/hybrid-without-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -323,10 +344,15 @@ describe('Hybrid rendering', () => {
fixture = await loadFixture({
root: './fixtures/prerender/',
output: 'hybrid',
outDir: './dist/hybrid-shared-modules',
build: {
client: './dist/hybrid-shared-modules/client',
server: './dist/hybrid-shared-modules/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);

View file

@ -8,13 +8,6 @@ import { loadFixture, waitServerListen } from './test-utils.js';
* @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
*/
async function load() {
const mod = await import(
`./fixtures/trailing-slash/dist/server/entry.mjs?dropcache=${Date.now()}`
);
return mod;
}
describe('Trailing slash', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
@ -30,10 +23,15 @@ describe('Trailing slash', () => {
base: '/some-base',
output: 'hybrid',
trailingSlash: 'always',
outDir: './dist/always-with-base',
build: {
client: './dist/always-with-base/client',
server: './dist/always-with-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -96,10 +94,15 @@ describe('Trailing slash', () => {
root: './fixtures/trailing-slash/',
output: 'hybrid',
trailingSlash: 'always',
outDir: './dist/always-without-base',
build: {
client: './dist/always-without-base/client',
server: './dist/always-without-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -165,10 +168,15 @@ describe('Trailing slash', () => {
base: '/some-base',
output: 'hybrid',
trailingSlash: 'never',
outDir: './dist/never-with-base',
build: {
client: './dist/never-with-base/client',
server: './dist/never-with-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -224,10 +232,15 @@ describe('Trailing slash', () => {
root: './fixtures/trailing-slash/',
output: 'hybrid',
trailingSlash: 'never',
outDir: './dist/never-without-base',
build: {
client: './dist/never-without-base/client',
server: './dist/never-without-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -286,10 +299,15 @@ describe('Trailing slash', () => {
base: '/some-base',
output: 'hybrid',
trailingSlash: 'ignore',
outDir: './dist/ignore-with-base',
build: {
client: './dist/ignore-with-base/client',
server: './dist/ignore-with-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);
@ -363,10 +381,15 @@ describe('Trailing slash', () => {
root: './fixtures/trailing-slash/',
output: 'hybrid',
trailingSlash: 'ignore',
outDir: './dist/ignore-without-base',
build: {
client: './dist/ignore-without-base/client',
server: './dist/ignore-without-base/server',
},
adapter: nodejs({ mode: 'standalone' }),
});
await fixture.build();
const { startServer } = await load();
const { startServer } = await fixture.loadAdapterEntryModule();
let res = startServer();
server = res.server;
await waitServerListen(server.server);

View file

@ -20,7 +20,7 @@ describe('Serverless prerender', () => {
it('outDir is tree-shaken if not needed', async () => {
const [file] = await fixture.glob(
'../.vercel/output/functions/_render.func/packages/integrations/vercel/test/fixtures/serverless-prerender/.vercel/output/_functions/chunks/pages/generic_*.mjs'
'../.vercel/output/functions/_render.func/packages/integrations/vercel/test/fixtures/serverless-prerender/.vercel/output/_functions/pages/_image.astro.mjs'
);
const contents = await fixture.readFile(file);
assert.ok(!contents.includes('const outDir ='), "outDir is tree-shaken if it's not imported");

View file

@ -3781,6 +3781,32 @@ importers:
specifier: workspace:*
version: link:../../..
packages/astro/test/fixtures/ssr-prerender-chunks:
dependencies:
'@astrojs/react':
specifier: workspace:*
version: link:../../../../integrations/react
'@test/ssr-prerender-chunks-test-adapter':
specifier: link:./deps/test-adapter
version: link:deps/test-adapter
'@types/react':
specifier: ^18.2.75
version: 18.3.3
'@types/react-dom':
specifier: ^18.2.24
version: 18.3.0
astro:
specifier: workspace:*
version: link:../../..
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages/astro/test/fixtures/ssr-prerender-chunks/deps/test-adapter: {}
packages/astro/test/fixtures/ssr-prerender-get-static-paths:
dependencies:
astro: