From 818252acda3c00499cea51ffa0f26d4c2ccd3a02 Mon Sep 17 00:00:00 2001 From: Erika <3019731+Princesseuh@users.noreply.github.com> Date: Thu, 4 May 2023 17:49:55 +0200 Subject: [PATCH] Add caching for optimized images (#6990) --- .changeset/forty-horses-act.md | 5 ++ packages/astro/src/@types/astro.ts | 17 +++++++ packages/astro/src/assets/internal.ts | 59 ++++++++++++++++++++--- packages/astro/src/core/build/generate.ts | 11 ++--- packages/astro/src/core/config/schema.ts | 10 ++++ packages/astro/test/core-image.test.js | 38 +++++++++++++++ 6 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 .changeset/forty-horses-act.md diff --git a/.changeset/forty-horses-act.md b/.changeset/forty-horses-act.md new file mode 100644 index 0000000000..77eec497c3 --- /dev/null +++ b/.changeset/forty-horses-act.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Generated optimized images are now cached inside the `node_modules/.astro/assets` folder. The cached images will be used to avoid doing extra work and speed up subsequent builds. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 872f2bb56e..5c9f46c2ec 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -430,6 +430,23 @@ export interface AstroUserConfig { */ outDir?: string; + /** + * @docs + * @name cacheDir + * @type {string} + * @default `"./node_modules/.astro"` + * @description Set the directory for caching build artifacts. Files in this directory will be used in subsequent builds to speed up the build time. + * + * The value can be either an absolute file system path or a path relative to the project root. + * + * ```js + * { + * cacheDir: './my-custom-cache-directory' + * } + * ``` + */ + cacheDir?: string; + /** * @docs * @name site diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index c4b8ca751e..365bbcf62d 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -73,13 +73,20 @@ export function getStaticImageList(): Iterable< return globalThis.astroAsset.staticImages?.entries(); } -interface GenerationData { +interface GenerationDataUncached { + cached: false; weight: { before: number; after: number; }; } +interface GenerationDataCached { + cached: true; +} + +type GenerationData = GenerationDataUncached | GenerationDataCached; + export async function generateImage( buildOpts: StaticBuildOptions, options: ImageTransform, @@ -89,7 +96,19 @@ export async function generateImage( return undefined; } - const imageService = (await getConfiguredImageService()) as LocalImageService; + let useCache = true; + const assetsCacheDir = new URL('assets/', buildOpts.settings.config.cacheDir); + + // Ensure that the cache directory exists + try { + await fs.promises.mkdir(assetsCacheDir, { recursive: true }); + } catch (err) { + console.error( + 'An error was encountered while creating the cache directory. Proceeding without caching. Error: ', + err + ); + useCache = false; + } let serverRoot: URL, clientRoot: URL; if (buildOpts.settings.config.output === 'server') { @@ -100,6 +119,20 @@ export async function generateImage( clientRoot = buildOpts.settings.config.outDir; } + const finalFileURL = new URL('.' + filepath, clientRoot); + const finalFolderURL = new URL('./', finalFileURL); + const cachedFileURL = new URL(basename(filepath), assetsCacheDir); + + try { + await fs.promises.copyFile(cachedFileURL, finalFileURL); + + return { + cached: true, + }; + } catch (e) { + // no-op + } + // The original file's path (the `src` attribute of the ESM imported image passed by the user) const originalImagePath = options.src.src; @@ -112,19 +145,33 @@ export async function generateImage( serverRoot ) ); + + const imageService = (await getConfiguredImageService()) as LocalImageService; const resultData = await imageService.transform( fileData, { ...options, src: originalImagePath }, buildOpts.settings.config.image.service.config ); - const finalFileURL = new URL('.' + filepath, clientRoot); - const finalFolderURL = new URL('./', finalFileURL); - await fs.promises.mkdir(finalFolderURL, { recursive: true }); - await fs.promises.writeFile(finalFileURL, resultData.data); + + if (useCache) { + try { + await fs.promises.writeFile(cachedFileURL, resultData.data); + await fs.promises.copyFile(cachedFileURL, finalFileURL); + } catch (e) { + console.error( + `There was an error creating the cache entry for ${filepath}. Attempting to write directly to output directory. Error: `, + e + ); + await fs.promises.writeFile(finalFileURL, resultData.data); + } + } else { + await fs.promises.writeFile(finalFileURL, resultData.data); + } return { + cached: false, weight: { before: Math.trunc(fileData.byteLength / 1024), after: Math.trunc(resultData.data.byteLength / 1024), diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 7d1f852112..8d195bab45 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -146,13 +146,10 @@ async function generateImage(opts: StaticBuildOptions, transform: ImageTransform const timeEnd = performance.now(); const timeChange = getTimeStat(timeStart, timeEnd); const timeIncrease = `(+${timeChange})`; - info( - opts.logging, - null, - ` ${green('▶')} ${path} ${dim( - `(before: ${generationData.weight.before}kb, after: ${generationData.weight.after}kb)` - )} ${dim(timeIncrease)}` - ); + const statsText = generationData.cached + ? `(reused cache entry)` + : `(before: ${generationData.weight.before}kb, after: ${generationData.weight.after}kb)`; + info(opts.logging, null, ` ${green('▶')} ${path} ${dim(statsText)} ${dim(timeIncrease)}`); } async function generatePage( diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 033e55222e..fd8d88c4df 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -13,6 +13,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = { srcDir: './src', publicDir: './public', outDir: './dist', + cacheDir: './node_modules/.astro', base: '/', trailingSlash: 'ignore', build: { @@ -63,6 +64,11 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.outDir) .transform((val) => new URL(val)), + cacheDir: z + .string() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.cacheDir) + .transform((val) => new URL(val)), site: z.string().url().optional(), base: z.string().optional().default(ASTRO_CONFIG_DEFAULTS.base), trailingSlash: z @@ -220,6 +226,10 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) { .string() .default(ASTRO_CONFIG_DEFAULTS.outDir) .transform((val) => new URL(appendForwardSlash(val), fileProtocolRoot)), + cacheDir: z + .string() + .default(ASTRO_CONFIG_DEFAULTS.cacheDir) + .transform((val) => new URL(appendForwardSlash(val), fileProtocolRoot)), build: z .object({ format: z diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js index 57720f0c45..7417b48955 100644 --- a/packages/astro/test/core-image.test.js +++ b/packages/astro/test/core-image.test.js @@ -1,7 +1,9 @@ import { expect } from 'chai'; import * as cheerio from 'cheerio'; +import { basename } from 'node:path'; import { Writable } from 'node:stream'; import { fileURLToPath } from 'node:url'; +import { removeDir } from '../dist/core/fs/index.js'; import testAdapter from './test-adapter.js'; import { loadFixture } from './test-utils.js'; @@ -455,6 +457,9 @@ describe('astro:image', () => { assets: true, }, }); + // Remove cache directory + removeDir(new URL('./fixtures/core-image-ssg/node_modules/.astro', import.meta.url)); + await fixture.build(); }); @@ -569,6 +574,39 @@ describe('astro:image', () => { const $ = cheerio.load(html); expect($('#no-format img').attr('src')).to.not.equal($('#format-avif img').attr('src')); }); + + it('has cache entries', async () => { + const generatedImages = (await fixture.glob('_astro/**/*.webp')).map((path) => + basename(path) + ); + const cachedImages = (await fixture.glob('../node_modules/.astro/assets/**/*.webp')).map( + (path) => basename(path) + ); + + expect(generatedImages).to.deep.equal(cachedImages); + }); + + it('uses cache entries', async () => { + const logs = []; + const logging = { + dest: { + write(chunk) { + logs.push(chunk); + }, + }, + }; + + await fixture.build({ logging }); + const generatingImageIndex = logs.findIndex((logLine) => + logLine.message.includes('generating optimized images') + ); + const relevantLogs = logs.slice(generatingImageIndex + 1, -1); + const isReusingCache = relevantLogs.every((logLine) => + logLine.message.includes('(reused cache entry)') + ); + + expect(isReusingCache).to.be.true; + }); }); describe('prod ssr', () => {