diff --git a/.changeset/quick-roses-fail.md b/.changeset/quick-roses-fail.md new file mode 100644 index 00000000..60b8b5b0 --- /dev/null +++ b/.changeset/quick-roses-fail.md @@ -0,0 +1,5 @@ +--- +'vite-imagetools': patch +--- + +feat: caching of generated images diff --git a/docs/README.md b/docs/README.md index 39bd156a..4c7223ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,3 +17,4 @@ unclear! - [Extend Imagetools](guide/extending.md) - [Sharp's documentation](https://sharp.pixelplumbing.com) +- [Caching](guide/caching.md) \ No newline at end of file diff --git a/docs/guide/caching.md b/docs/guide/caching.md new file mode 100644 index 00000000..a945741e --- /dev/null +++ b/docs/guide/caching.md @@ -0,0 +1,46 @@ +# Caching + +To speed up a build pipeline with many images, the generated images can be cached on disk. +If the source image changes, the cached images will be regenerated. + +## How to configure caching + +Caching is enabled by default and uses './node_modules/.cache/imagetools' as cache directory. +You can disable caching or change the directory with options. + +``` +// vite.config.js, etc +... + plugins: [ + react(), + imagetools({ + cache: { + enabled: true, + dir: './node_modules/.cache/imagetools' + } + }) + ] +... +``` + +## Cache retention to remove unused images + +When an image is no longer there or the transformation parameters change, the previously +cached images will be removed after a configurable retention period. +The default retention is 1 day (86400 seconds). A value of 0 will disable this mechanism. + +``` +// vite.config.js, etc +... + plugins: [ + react(), + imagetools({ + cache: { + enabled: true, + dir: './node_modules/.cache/imagetools', + retention: 172800 + } + }) + ] +... +``` diff --git a/docs/interfaces/vite_src_types.CacheOptions.md b/docs/interfaces/vite_src_types.CacheOptions.md new file mode 100644 index 00000000..e0f332c9 --- /dev/null +++ b/docs/interfaces/vite_src_types.CacheOptions.md @@ -0,0 +1,63 @@ +[imagetools](../README.md) / [Modules](../modules.md) / [vite/src/types](../modules/vite_src_types.md) / CacheOptions + +# Interface: CacheOptions + +[vite/src/types](../modules/vite_src_types.md).CacheOptions + +## Table of contents + +### Properties + +- [enabled](vite_src_types.CacheOptions.md#enabled) +- [dir](vite_src_types.CacheOptions.md#dir) +- [retention](vite_src_types.CacheOptions.md#retention) + +## Properties + +### enabled + +• **enabled**: `boolean` + +Wether caching of transformed images is enabled. + +**`Default`** + +```ts +true +``` + +#### Defined in + +[packages/vite/src/types.ts:104](https://github.com/JonasKruckenberg/imagetools/blob/4ebc88f/packages/vite/src/types.ts#L104) + +### dir + +• **dir**: `string` + +Where to store generated images on disk as cache. + +**`Default`** + +```ts +'./node_modules/.cache/imagetools' +``` + +#### Defined in + +[packages/vite/src/types.ts:109](https://github.com/JonasKruckenberg/imagetools/blob/4ebc88f/packages/vite/src/types.ts#L109) + +### retention + +• **retention**: `number` + +After what time an unused image will be removed from the cache. + +**`Default`** + +```ts +86400 +``` + +#### Defined in + +[packages/vite/src/types.ts:114](https://github.com/JonasKruckenberg/imagetools/blob/4ebc88f/packages/vite/src/types.ts#L114) diff --git a/docs/interfaces/vite_src_types.VitePluginOptions.md b/docs/interfaces/vite_src_types.VitePluginOptions.md index 68a0e0c1..0f7dd7e8 100644 --- a/docs/interfaces/vite_src_types.VitePluginOptions.md +++ b/docs/interfaces/vite_src_types.VitePluginOptions.md @@ -15,6 +15,8 @@ - [include](vite_src_types.VitePluginOptions.md#include) - [removeMetadata](vite_src_types.VitePluginOptions.md#removemetadata) - [resolveConfigs](vite_src_types.VitePluginOptions.md#resolveconfigs) +- [cacheRetention](vite_src_types.VitePluginOptions.md#cacheretention) +- [cacheDir](vite_src_types.VitePluginOptions.md#cachedir) ## Properties @@ -177,3 +179,19 @@ undefined #### Defined in [packages/vite/src/types.ts:79](https://github.com/JonasKruckenberg/imagetools/blob/4ebc88f/packages/vite/src/types.ts#L79) + +### cache + +• **cache**: [`CacheOptions`](./vite_src_types.CacheOptions.md) + +Options to enable caching of generated images. + +**`Default`** + +```ts +undefined +``` + +#### Defined in + +[packages/vite/src/types.ts:97](https://github.com/JonasKruckenberg/imagetools/blob/4ebc88f/packages/vite/src/types.ts#L97) diff --git a/docs/modules/vite_src_types.md b/docs/modules/vite_src_types.md index 11c079dd..7fa1220e 100644 --- a/docs/modules/vite_src_types.md +++ b/docs/modules/vite_src_types.md @@ -7,6 +7,7 @@ ### Interfaces - [VitePluginOptions](../interfaces/vite_src_types.VitePluginOptions.md) +- [CacheOptions](../interfaces/vite_src_types.CacheOptions.md) ### Type Aliases diff --git a/packages/vite/src/__tests__/__image_snapshots__/main-test-ts-src-tests-main-test-ts-vite-imagetools-import-with-space-in-identifier-and-cache-1-snap.png b/packages/vite/src/__tests__/__image_snapshots__/main-test-ts-src-tests-main-test-ts-vite-imagetools-import-with-space-in-identifier-and-cache-1-snap.png new file mode 100644 index 00000000..bd3de489 --- /dev/null +++ b/packages/vite/src/__tests__/__image_snapshots__/main-test-ts-src-tests-main-test-ts-vite-imagetools-import-with-space-in-identifier-and-cache-1-snap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:895f2fa8cdb7759f75df4b2aff51518cbf1406e1455fdb36268cd129f60736a1 +size 120401 diff --git a/packages/vite/src/__tests__/main.test.ts b/packages/vite/src/__tests__/main.test.ts index 02f7404e..69dd4268 100644 --- a/packages/vite/src/__tests__/main.test.ts +++ b/packages/vite/src/__tests__/main.test.ts @@ -1,4 +1,4 @@ -import { build, createLogger } from 'vite' +import { InlineConfig, build, createLogger } from 'vite' import { imagetools } from '../index' import { join } from 'path' import { getFiles, testEntry } from './util' @@ -8,6 +8,8 @@ import { JSDOM } from 'jsdom' import sharp from 'sharp' import { afterEach, describe, test, expect, it, vi } from 'vitest' import { createBasePath } from '../utils' +import { existsSync } from 'fs' +import { rm, utimes, readdir } from 'fs/promises' expect.extend({ toMatchImageSnapshot }) @@ -235,7 +237,8 @@ describe('vite-imagetools', () => { return (image) => image } ] - } + }, + cache: { enabled: false } }) ] }) @@ -263,7 +266,8 @@ describe('vite-imagetools', () => { return (image) => image } ] - } + }, + cache: { enabled: false } }) ] }) @@ -289,7 +293,8 @@ describe('vite-imagetools', () => { return (image) => image } ] - } + }, + cache: { enabled: false } }) ] }) @@ -327,6 +332,8 @@ describe('vite-imagetools', () => { }) test('false leaves private metadata', async () => { + const dir = './node_modules/.cache/imagetools_test_false_leaves_private_metadata' + await rm(dir, { recursive: true, force: true }) const bundle = (await build({ root: join(__dirname, '__fixtures__'), logLevel: 'warn', @@ -337,7 +344,8 @@ describe('vite-imagetools', () => { window.__IMAGE__ = Image `), imagetools({ - removeMetadata: false + removeMetadata: false, + cache: { dir } }) ] })) as RollupOutput | RollupOutput[] @@ -458,6 +466,59 @@ describe('vite-imagetools', () => { expect(window.__IMAGE__).toHaveProperty('hasAlpha') }) }) + describe('cache.retention', () => { + test('is used to clear cache with default 86400', async () => { + const dir = './node_modules/.cache/imagetools_test_cache_retention' + await rm(dir, { recursive: true, force: true }) + const root = join(__dirname, '__fixtures__') + const config: (width: number) => InlineConfig = (width) => ({ + root, + logLevel: 'warn', + build: { write: false }, + plugins: [ + testEntry(` + import Image from "./pexels-allec-gomes-5195763.png?w=${width}" + export default Image + `), + imagetools({ cache: { dir } }) + ] + }) + await build(config(300)) + const image_300 = (await readdir(dir))[0] + expect(image_300).toBeTypeOf('string') + + await build(config(200)) + const image_200 = (await readdir(dir)).find((name) => name !== image_300)?.[0] + expect(image_200).toBeTypeOf('string') + + const date = new Date(Date.now() - 86400000) + await utimes(`${dir}/${image_300}`, date, date) + await build(config(200)) + expect(existsSync(`${dir}/${image_300}`)).toBe(false) + }) + }) + describe('cache.dir', () => { + test('is used', async () => { + const dir = './node_modules/.cache/imagetools_test_cache_dir' + await rm(dir, { recursive: true, force: true }) + const root = join(__dirname, '__fixtures__') + await build({ + root, + logLevel: 'warn', + build: { write: false }, + plugins: [ + testEntry(` + import Image from "./pexels-allec-gomes-5195763.png?w=300" + export default Image + `), + imagetools({ cache: { dir } }) + ] + }) + + const image = (await readdir(dir))[0] + expect(image).toBeTypeOf('string') + }) + }) }) test('relative import', async () => { @@ -516,6 +577,28 @@ describe('vite-imagetools', () => { expect(files[0].source).toMatchImageSnapshot() }) + test('import with space in identifier and cache', async () => { + const dir = './node_modules/.cache/imagetools_test_import_with_space' + await rm(dir, { recursive: true, force: true }) + const config: InlineConfig = { + root: join(__dirname, '__fixtures__'), + logLevel: 'warn', + build: { write: false }, + plugins: [ + testEntry(` + import Image from "./with space.png?w=300" + export default Image + `), + imagetools({ cache: { dir } }) + ] + } + await build(config) + const bundle = (await build(config)) as RollupOutput | RollupOutput[] + + const files = getFiles(bundle, '**.png') as OutputAsset[] + expect(files[0].source).toMatchImageSnapshot() + }) + test('non existent file', async () => { const p = build({ root: join(__dirname, '__fixtures__'), diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index 111c54a6..884d7dc2 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -1,4 +1,7 @@ import { basename, extname } from 'node:path' +import { join } from 'node:path/posix' +import { existsSync, mkdirSync, createReadStream } from 'node:fs' +import { utimes, writeFile, readFile, opendir, stat, rm } from 'node:fs/promises' import type { Plugin, ResolvedConfig } from 'vite' import { applyTransforms, @@ -12,7 +15,8 @@ import { resolveConfigs, type Logger, type OutputFormat, - type ProcessedImageMetadata + type ProcessedImageMetadata, + type ImageMetadata } from 'imagetools-core' import { createFilter, dataToEsm } from '@rollup/pluginutils' import sharp, { type Metadata, type Sharp } from 'sharp' @@ -40,6 +44,13 @@ export * from 'imagetools-core' export function imagetools(userOptions: Partial = {}): Plugin { const pluginOptions: VitePluginOptions = { ...defaultOptions, ...userOptions } + const cacheOptions = { + enabled: pluginOptions.cache?.enabled ?? true, + dir: pluginOptions.cache?.dir ?? './node_modules/.cache/imagetools', + retention: pluginOptions.cache?.retention ?? 86400 + } + mkdirSync(`${cacheOptions.dir}`, { recursive: true }) + const filter = createFilter(pluginOptions.include, pluginOptions.exclude) const transformFactories = pluginOptions.extendTransforms ? pluginOptions.extendTransforms(builtins) : builtins @@ -51,7 +62,7 @@ export function imagetools(userOptions: Partial = {}): Plugin let viteConfig: ResolvedConfig let basePath: string - const generatedImages = new Map() + const generatedImages = new Map() return { name: 'imagetools', @@ -122,18 +133,39 @@ export function imagetools(userOptions: Partial = {}): Plugin error: (msg) => this.error(msg) } + const imageBuffer = await img.clone().toBuffer() + for (const config of imageConfigs) { - const { transforms } = generateTransforms(config, transformFactories, srcURL.searchParams, logger) - const { image, metadata } = await applyTransforms(transforms, img.clone(), pluginOptions.removeMetadata) + const id = await generateImageID(srcURL, config, imageBuffer) + let image: Sharp | undefined + let metadata: ImageMetadata + + if (cacheOptions.enabled && existsSync(`${cacheOptions.dir}/${id}`)) { + const imagePath = `${cacheOptions.dir}/${id}` + metadata = (await sharp(imagePath).metadata()) as ImageMetadata + metadata.imagePath = imagePath + const date = new Date() + utimes(imagePath, date, date) + } else { + const { transforms } = generateTransforms(config, transformFactories, srcURL.searchParams, logger) + const res = await applyTransforms(transforms, img, pluginOptions.removeMetadata) + metadata = res.metadata + if (cacheOptions.enabled) { + const imagePath = `${cacheOptions.dir}/${id}` + await writeFile(imagePath, await res.image.toBuffer()) + metadata.imagePath = imagePath + } else { + image = res.image + } + } if (viteConfig.command === 'serve') { - const id = await generateImageID(srcURL, config, img) - generatedImages.set(id, image) - metadata.src = path.posix.join(viteConfig?.server?.origin ?? '', basePath) + id + generatedImages.set(id, { image, metadata }) + metadata.src = join(viteConfig?.server?.origin ?? '', basePath) + id } else { const fileHandle = this.emitFile({ name: basename(pathname, extname(pathname)) + `.${metadata.format}`, - source: await image.toBuffer(), + source: image ? await image.toBuffer() : await readFile(metadata.imagePath as string), type: 'asset' }) @@ -167,22 +199,44 @@ export function imagetools(userOptions: Partial = {}): Plugin if (req.url?.startsWith(basePath)) { const [, id] = req.url.split(basePath) - const image = generatedImages.get(id) + const { image, metadata } = generatedImages.get(id) ?? {} - if (!image) + if (!metadata) throw new Error(`vite-imagetools cannot find image with id "${id}" this is likely an internal error`) + res.setHeader('Cache-Control', 'max-age=360000') + + if (!image) { + res.setHeader('Content-Type', `image/${metadata.format}`) + return createReadStream(metadata.imagePath as string).pipe(res) + } + if (pluginOptions.removeMetadata === false) { image.withMetadata() } res.setHeader('Content-Type', `image/${getMetadata(image, 'format')}`) - res.setHeader('Cache-Control', 'max-age=360000') return image.clone().pipe(res) } next() }) + }, + + async buildEnd(error) { + if (!error && cacheOptions.enabled && cacheOptions.retention && viteConfig.command !== 'serve') { + const dir = await opendir(cacheOptions.dir) + for await (const dirent of dir) { + if (dirent.isFile()) { + const imagePath = `${cacheOptions.dir}/${dirent.name}` + const stats = await stat(imagePath) + if (Date.now() - stats.mtimeMs > cacheOptions.retention * 1000) { + console.debug(`deleting stale cached image ${dirent.name}`) + await rm(imagePath) + } + } + } + } } } } diff --git a/packages/vite/src/types.ts b/packages/vite/src/types.ts index e2d600fa..4df752e5 100644 --- a/packages/vite/src/types.ts +++ b/packages/vite/src/types.ts @@ -90,4 +90,26 @@ export interface VitePluginOptions { * @default undefined */ namedExports?: boolean + + /** + * Whether to cache transformed images and options for caching. + */ + cache?: CacheOptions +} + +export interface CacheOptions { + /** + * Should the image cache be enabled. Default is true + */ + enabled?: boolean + + /** + * Where should the cached images be stored. Default is './node_modules/.cache/imagetools' + */ + dir?: string + + /** + * For how many seconds to keep transformed images cached. Default is 86400. To disable cache specify 0. + */ + retention?: number } diff --git a/packages/vite/src/utils.ts b/packages/vite/src/utils.ts index 9eabd787..b53eefa1 100644 --- a/packages/vite/src/utils.ts +++ b/packages/vite/src/utils.ts @@ -2,17 +2,15 @@ import { createHash } from 'node:crypto' import path from 'node:path' import { statSync } from 'node:fs' import type { ImageConfig } from 'imagetools-core' -import type { Sharp } from 'sharp' export const createBasePath = (base?: string) => { return (base?.replace(/\/$/, '') || '') + '/@imagetools/' } -export async function generateImageID(url: URL, config: ImageConfig, originalImage: Sharp) { +export async function generateImageID(url: URL, config: ImageConfig, imageBuffer: Buffer) { if (url.host) { const baseURL = new URL(url.origin + url.pathname) - const buffer = await originalImage.toBuffer() - return hash([baseURL.href, JSON.stringify(config), buffer]) + return hash([baseURL.href, JSON.stringify(config), imageBuffer]) } // baseURL isn't a valid URL, but just a string used for an identifier