From 4eab499e67d7c807d21763f1f9280410e2136843 Mon Sep 17 00:00:00 2001 From: dangreen Date: Mon, 31 Aug 2026 15:57:06 +0400 Subject: [PATCH] feat: configure and prune the disk cache The storage takes a `dir` and a `maxAge`, both exposed by the vite plugin and the loader as `cache: { dir, maxAge }`. Entries unused for longer than the max age - 30 days by default - are removed when a build is over: pruning before it would drop the entries the build is about to hit. The used-at mark lives in the manifest rather than in the file mtime, so a cache restored from an archive keeps it. --- .../bundler-utils/src/placeholder.spec.ts | 4 +- packages/bundler-utils/src/types.ts | 13 ++ packages/core/src/cache.spec.ts | 196 ++++++++++++++++- packages/core/src/cache.ts | 203 +++++++++++++++++- packages/core/src/generator.spec.ts | 4 +- packages/loader/src/cache.ts | 21 +- packages/loader/src/loader.spec.ts | 45 ++++ packages/loader/src/loader.ts | 32 ++- packages/loader/src/types.ts | 24 ++- packages/vite-plugin/src/dev.spec.ts | 4 +- packages/vite-plugin/src/plugin.spec.ts | 41 +++- packages/vite-plugin/src/plugin.ts | 11 +- packages/vite-plugin/src/types.ts | 8 +- 13 files changed, 584 insertions(+), 22 deletions(-) diff --git a/packages/bundler-utils/src/placeholder.spec.ts b/packages/bundler-utils/src/placeholder.spec.ts index 46f3c48..8cea383 100644 --- a/packages/bundler-utils/src/placeholder.spec.ts +++ b/packages/bundler-utils/src/placeholder.spec.ts @@ -100,7 +100,9 @@ describe('bundler-utils', () => { }) it('should reuse the stored placeholder from the cache', async () => { - const cache = new SrcSetCacheStorage(await mkdtemp(path.join(tmpdir(), 'srcset-placeholder-'))) + const cache = new SrcSetCacheStorage({ + dir: await mkdtemp(path.join(tmpdir(), 'srcset-placeholder-')) + }) const { image, metadata diff --git a/packages/bundler-utils/src/types.ts b/packages/bundler-utils/src/types.ts index 8fe67cd..d4b2867 100644 --- a/packages/bundler-utils/src/types.ts +++ b/packages/bundler-utils/src/types.ts @@ -1,3 +1,16 @@ +import type { SrcSetCacheStorageOptions } from '@srcset/core' + +/** + * Disk cache options of a srcset bundler integration. + */ +export interface SrcSetCacheOptions extends Omit { + /** + * Directory of the cache storage. Defaults to a directory + * inside the cache directory of the bundler. + */ + dir?: string +} + /** * Paths of an image emitted on the bundler side. */ diff --git a/packages/core/src/cache.spec.ts b/packages/core/src/cache.spec.ts index 6659ff4..f7aced7 100644 --- a/packages/core/src/cache.spec.ts +++ b/packages/core/src/cache.spec.ts @@ -7,7 +7,10 @@ import { import { mkdtemp, readdir, - rm + readFile, + rm, + utimes, + writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' @@ -49,12 +52,32 @@ function createImage(): SrcSetImage { } } +const dayMs = 24 * 60 * 60 * 1000 + +async function getUsedAt(dir: string, key: string) { + const manifest = await readFile(path.join(dir, `${key}.json`), 'utf8') + + return (JSON.parse(manifest) as { usedAt: number }).usedAt +} + +async function setUsedAt(dir: string, key: string, usedAt: number) { + const manifestPath = path.join(dir, `${key}.json`) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record + + await writeFile(manifestPath, JSON.stringify({ + ...manifest, + usedAt + })) +} + async function createStorage() { const dir = await mkdtemp(path.join(tmpdir(), 'srcset-storage-')) return { dir, - storage: new SrcSetCacheStorage(dir) + storage: new SrcSetCacheStorage({ + dir + }) } } @@ -83,7 +106,9 @@ describe('core', () => { cacheKey: key }) - const cached = await new SrcSetCacheStorage(dir).memo(context, variant, fn) + const cached = await new SrcSetCacheStorage({ + dir + }).memo(context, variant, fn) expect(fn).toHaveBeenCalledTimes(1) expect(cached).toEqual({ @@ -189,6 +214,171 @@ describe('core', () => { }) }) + describe('prune', () => { + it('should remove entries unused longer than the max age', async () => { + const { + dir, + storage + } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + + await storage.memo(context, variant, () => Promise.resolve(createImage())) + + const { key } = storage.getKey(context, variant) + + await setUsedAt(dir, key, Date.now() - 31 * dayMs) + await new SrcSetCacheStorage({ + dir + }).prune() + + expect(await readdir(dir)).toEqual([]) + }) + + it('should keep entries used within the max age', async () => { + const { + dir, + storage + } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + + await storage.memo(context, variant, () => Promise.resolve(createImage())) + await new SrcSetCacheStorage({ + dir + }).prune() + + expect((await readdir(dir)).length).toBe(2) + }) + + it('should respect a custom max age', async () => { + const { + dir, + storage + } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + + await storage.memo(context, variant, () => Promise.resolve(createImage())) + + const { key } = storage.getKey(context, variant) + + await setUsedAt(dir, key, Date.now() - 2 * dayMs) + await new SrcSetCacheStorage({ + dir, + maxAge: dayMs + }).prune() + + expect(await readdir(dir)).toEqual([]) + }) + + it('should keep a file left without its manifest within the grace period', async () => { + const { + dir, + storage + } = await createStorage() + const path = `${'a'.repeat(64)}-image.webp` + + await storage.write(path, Buffer.from('half-written')) + await new SrcSetCacheStorage({ + dir + }).prune() + + expect(await readdir(dir)).toEqual([path]) + }) + + it('should remove a file left without its manifest after the grace period', async () => { + const { + dir, + storage + } = await createStorage() + const name = `${'a'.repeat(64)}-image.webp` + const stale = new Date(Date.now() - 10 * 60 * 1000) + + await storage.write(name, Buffer.from('crash leftover')) + await utimes(path.join(dir, name), stale, stale) + await new SrcSetCacheStorage({ + dir + }).prune() + + expect(await readdir(dir)).toEqual([]) + }) + + it('should remove an entry with a manifest of an older version', async () => { + const { + dir, + storage + } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + + await storage.memo(context, variant, () => Promise.resolve(createImage())) + + const { key } = storage.getKey(context, variant) + const manifestPath = path.join(dir, `${key}.json`) + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record + + delete manifest.usedAt + + await writeFile(manifestPath, JSON.stringify(manifest)) + await new SrcSetCacheStorage({ + dir + }).prune() + + expect(await readdir(dir)).toEqual([]) + }) + + it('should keep files of other tools in the directory', async () => { + const { dir } = await createStorage() + + await writeFile(path.join(dir, 'notes.txt'), 'not ours') + await writeFile(path.join(dir, '.other-tool-cache'), 'not ours either') + await new SrcSetCacheStorage({ + dir + }).prune() + + expect((await readdir(dir)).sort()).toEqual(['.other-tool-cache', 'notes.txt']) + }) + + it('should refresh the used-at mark of a hit entry', async () => { + const { + dir, + storage + } = await createStorage() + const context = createContext() + const variant = { + format: 'webp' as const, + width: 0.5 + } + const fn = vi.fn(() => Promise.resolve(createImage())) + + await storage.memo(context, variant, fn) + + const { key } = storage.getKey(context, variant) + const staleUsedAt = Date.now() - 20 * dayMs + + await setUsedAt(dir, key, staleUsedAt) + await new SrcSetCacheStorage({ + dir + }).memo(context, variant, fn) + + expect(fn).toHaveBeenCalledTimes(1) + expect(await getUsedAt(dir, key)).toBeGreaterThan(staleUsedAt) + }) + }) + describe('getKey', () => { it('should derive both address parts from the inputs', async () => { const { storage } = await createStorage() diff --git a/packages/core/src/cache.ts b/packages/core/src/cache.ts index b0ff5d8..105ce3e 100644 --- a/packages/core/src/cache.ts +++ b/packages/core/src/cache.ts @@ -1,16 +1,20 @@ +import { availableParallelism } from 'node:os' import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { mkdir, + readdir, readFile, rename, rm, + stat, writeFile } from 'node:fs/promises' import { join, parse } from 'node:path' +import pLimit from 'p-limit' import type { ImageFormat } from './formats.ts' import type { GenerateContext, @@ -30,6 +34,21 @@ import { import { environment } from './cache.version.ts' const storedPathSeparator = '-' +const manifestExtension = '.json' +const keyLength = 64 +/* oxlint-disable no-magic-numbers -- a duration reads better than named parts */ +const day = 24 * 60 * 60 * 1000 +const defaultMaxAge = 30 * day +/* oxlint-enable no-magic-numbers */ +// The used-at mark is rewritten at most once per this part of the max age: +// a mark is only needed to tell a used entry from an abandoned one. +const usedAtPrecision = 10 +// A file without a readable manifest is either a leftover of a crash or +// a half-written entry of a build running right now: the grace period tells +// them apart without a lock. +/* oxlint-disable-next-line no-magic-numbers -- a duration reads better than named parts */ +const orphanGrace = 5 * 60 * 1000 +const keyPattern = /^[\da-f]{64}$/ /** * Make the stored file path of a variant: the storage is flat, so the @@ -43,6 +62,30 @@ export function getStoredPath(key: string, name: string) { return `${key}${storedPathSeparator}${name}` } +/** + * Make the manifest path of an entry. + * @param key - Manifest key of the variant. + * @returns Manifest file path. + */ +function getManifestPath(key: string) { + return `${key}${manifestExtension}` +} + +/** + * Get the manifest key a stored file belongs to. Anything that does not + * look like an entry of this storage belongs to no key: the directory + * is configurable, and files of other tools are not ours to remove. + * @param path - Stored file name. + * @returns Manifest key, or `null` for a foreign file. + */ +function getEntryKey(path: string) { + const key = path.endsWith(manifestExtension) + ? path.slice(0, -manifestExtension.length) + : path.slice(0, keyLength) + + return keyPattern.test(key) ? key : null +} + /** * Address of a cached variant: the manifest key and the stored file path. */ @@ -57,8 +100,24 @@ export interface CacheAddress { path: string } +/** + * Options of the cache storage. + */ +export interface SrcSetCacheStorageOptions { + /** + * Directory to store the variants and their manifests in. + */ + dir: string + /** + * Maximum age of an unused entry in milliseconds. Defaults to 30 days. + * Entries older than that are removed when the storage is first used. + */ + maxAge?: number +} + interface CacheEntry { path: string + usedAt: number hash: string format: ImageFormat width: number @@ -80,9 +139,12 @@ interface CacheEntry { */ export class SrcSetCacheStorage { private readonly dir: string + private readonly maxAge: number + private pruning?: Promise - constructor(dir: string) { - this.dir = dir + constructor(options: SrcSetCacheStorageOptions) { + this.dir = options.dir + this.maxAge = options.maxAge ?? defaultMaxAge } /** @@ -124,7 +186,7 @@ export class SrcSetCacheStorage { private async readEntry(address: CacheAddress): Promise { try { const [entry, contents] = await Promise.all([ - this.read(`${address.key}.json`, 'utf8') + this.read(getManifestPath(address.key), 'utf8') .then(manifest => JSON.parse(manifest) as CacheEntry), this.read(address.path) ]) @@ -135,6 +197,8 @@ export class SrcSetCacheStorage { return null } + await this.markUsed(address.key, entry) + return { path: entry.path, cacheKey: address.key, @@ -151,9 +215,33 @@ export class SrcSetCacheStorage { } } + /** + * Keep the used-at mark of a hit entry fresh, so pruning tells + * an entry still in use from an abandoned one. The mark lives in the + * manifest rather than in the file mtime: archives do not always + * carry timestamps, contents always survive. + * @param key - Manifest key of the entry. + * @param entry - Manifest of the entry. + */ + private async markUsed(key: string, entry: CacheEntry) { + const now = Date.now() + + if (now - entry.usedAt < this.maxAge / usedAtPrecision) { + return + } + + try { + await this.write(getManifestPath(key), JSON.stringify({ + ...entry, + usedAt: now + })) + } catch {} + } + private async writeEntry(address: CacheAddress, image: SrcSetImage) { const entry: CacheEntry = { path: image.path, + usedAt: Date.now(), hash: getContentsHash(image.contents), format: image.format, width: image.width, @@ -166,7 +254,7 @@ export class SrcSetCacheStorage { // its file is a read miss with regeneration. await Promise.all([ this.write(address.path, image.contents), - this.write(`${address.key}.json`, JSON.stringify(entry)) + this.write(getManifestPath(address.key), JSON.stringify(entry)) ]) } @@ -204,6 +292,113 @@ export class SrcSetCacheStorage { return image } + /** + * Remove the entries unused for longer than the max age. Call it when + * a build is over: pruning before the reads would drop the entries the + * build is about to hit, whose marks it has not refreshed yet. + * Runs once per storage instance. + * @returns Promise of the removal. + */ + async prune() { + this.pruning ??= this.removeStale() + + return this.pruning + } + + private async removeStale() { + let paths: string[] + + try { + paths = await readdir(this.dir) + } catch { + // No storage directory yet: nothing to remove. + return + } + + const entries = new Map() + + for (const path of paths) { + const key = getEntryKey(path) + + if (!key) { + continue + } + + const entryPaths = entries.get(key) + + if (entryPaths) { + entryPaths.push(path) + } else { + entries.set(key, [path]) + } + } + + const deadline = Date.now() - this.maxAge + const limit = pLimit(availableParallelism()) + + await Promise.all([...entries].map(([key, entryPaths]) => limit(async () => { + if (!await this.isStale(key, entryPaths, deadline)) { + return + } + + // A file another build reads right now is safe to unlink on posix, + // and locked on windows - either way the failure is not ours to handle. + await Promise.all(entryPaths.map(async (path) => { + try { + await rm(join(this.dir, path), { + force: true + }) + } catch {} + })) + }))) + } + + private async isStale(key: string, entryPaths: string[], deadline: number) { + let manifest: string + + try { + manifest = await this.read(getManifestPath(key), 'utf8') + } catch (error) { + // Only a missing manifest makes an entry unusable. A read that failed + // for any other reason says nothing about the entry: keep it. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + return false + } + + return this.isAbandoned(entryPaths) + } + + try { + const { usedAt } = JSON.parse(manifest) as CacheEntry + + // A mark of an older storage version, or a damaged one: the entry + // is unusable either way, and an unusable entry is stale. + return !Number.isFinite(usedAt) || usedAt < deadline + } catch { + return true + } + } + + /** + * Tell a leftover of a crashed build from an entry a running build + * is writing right now: the latter is younger than the grace period. + * @param entryPaths - Stored files of the entry. + * @returns Whether the files are safe to remove. + */ + private async isAbandoned(entryPaths: string[]) { + const deadline = Date.now() - orphanGrace + + try { + const stats = await Promise.all( + entryPaths.map(path => stat(join(this.dir, path))) + ) + + return stats.every(({ mtimeMs }) => mtimeMs < deadline) + } catch { + return false + } + } + /** * Write contents to the storage. An existing file is overwritten: * a repeated write of the same path carries the same contents. diff --git a/packages/core/src/generator.spec.ts b/packages/core/src/generator.spec.ts index 1270d2b..f7a255e 100644 --- a/packages/core/src/generator.spec.ts +++ b/packages/core/src/generator.spec.ts @@ -518,7 +518,9 @@ describe('core', () => { describe('cache', () => { it('should reuse stored variants between generators', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'srcset-generator-cache-')) - const cache = new SrcSetCacheStorage(dir) + const cache = new SrcSetCacheStorage({ + dir + }) const image = await createImage('jpg') const options: GenerateOptions = { width: [1, 0.5], diff --git a/packages/loader/src/cache.ts b/packages/loader/src/cache.ts index 423b302..29b3a47 100644 --- a/packages/loader/src/cache.ts +++ b/packages/loader/src/cache.ts @@ -1,4 +1,5 @@ import { join } from 'node:path' +import type { SrcSetCacheOptions } from '@srcset/bundler-utils' import { SrcSetCacheStorage } from '@srcset/core' const storages = new Map() @@ -8,14 +9,26 @@ const storages = new Map() * Every run of a compilation gets the same storage, so a variant * generated for one module is a hit for the next one. * @param context - Root context directory of the compiler. + * @param cache - Cache option of the loader: `true` for the defaults. * @returns Cache storage. */ -export function getSharedCache(context: string) { - let storage = storages.get(context) +export function getSharedCache( + context: string, + cache: true | SrcSetCacheOptions +) { + const options = cache === true ? {} : cache + const dir = options.dir ?? join(context, 'node_modules', '.cache', 'srcset') + // Two configurations of one directory are two storages: sharing the first + // one would silently apply its max age to both. + const id = `${dir}\n${options.maxAge ?? ''}` + let storage = storages.get(id) if (!storage) { - storage = new SrcSetCacheStorage(join(context, 'node_modules', '.cache', 'srcset')) - storages.set(context, storage) + storage = new SrcSetCacheStorage({ + ...options, + dir + }) + storages.set(id, storage) } return storage diff --git a/packages/loader/src/loader.spec.ts b/packages/loader/src/loader.spec.ts index 11f5793..8bdd222 100644 --- a/packages/loader/src/loader.spec.ts +++ b/packages/loader/src/loader.spec.ts @@ -4,6 +4,12 @@ import { expect, vi } from 'vitest' +import { + mkdir, + readdir, + writeFile +} from 'node:fs/promises' +import path from 'node:path' import sharp from 'sharp' import webpack from 'webpack' import { rspack } from '@rspack/core' @@ -178,6 +184,45 @@ describe('loader', () => { expect(assets.length).toBeGreaterThan(0) }) + it('should cache in the configured directory', async () => { + const dir = await createFixtureProject(defaultEntry) + const cacheDir = path.join(dir, 'custom-cache') + + await compile(createCompiler, dir, { + cache: { + dir: cacheDir + }, + skipOptimization: true + }) + + expect((await readdir(cacheDir)).length).toBeGreaterThan(0) + }) + + it('should prune the cache when the compilation is done', async () => { + const dir = await createFixtureProject(defaultEntry) + const cacheDir = path.join(dir, 'custom-cache') + const key = 'a'.repeat(64) + + await mkdir(cacheDir, { + recursive: true + }) + await writeFile(path.join(cacheDir, `${key}.json`), JSON.stringify({ + usedAt: Date.now() - 31 * 24 * 60 * 60 * 1000 + })) + await writeFile(path.join(cacheDir, `${key}-stale.webp`), 'stale') + await compile(createCompiler, dir, { + cache: { + dir: cacheDir + }, + skipOptimization: true + }) + + const left = await readdir(cacheDir) + + expect(left.some(name => name.startsWith(key))).toBe(false) + expect(left.length).toBeGreaterThan(0) + }) + it('should regenerate without the cache option', async () => { const dir = await createFixtureProject(defaultEntry) const optimize = vi.fn((contents: Buffer) => contents) diff --git a/packages/loader/src/loader.ts b/packages/loader/src/loader.ts index 9a284eb..5109d22 100644 --- a/packages/loader/src/loader.ts +++ b/packages/loader/src/loader.ts @@ -4,7 +4,10 @@ import { parseResourceQuery, generateSrcSetModule } from '@srcset/bundler-utils' -import type { SrcSetLoaderContext } from './types.ts' +import type { + SrcSetLoaderCompiler, + SrcSetLoaderContext +} from './types.ts' import { interpolateName, getDefaultName @@ -16,6 +19,23 @@ import { import { getSharedLimit } from './limit.ts' import { getSharedCache } from './cache.ts' +const tapped = new WeakSet() + +/** + * Tap the end of the compilation once per compiler: the loader runs + * for every module, so tapping on each run would pile up the handlers. + * @param compiler - Compiler of the loader run. + * @param handler - Handler to run when the compilation is done. + */ +function tapOnce(compiler: SrcSetLoaderCompiler, handler: () => Promise) { + if (tapped.has(compiler)) { + return + } + + tapped.add(compiler) + compiler.hooks.done.tapPromise('srcset', handler) +} + async function generateModule(ctx: SrcSetLoaderContext, contents: Buffer) { const { context = ctx.rootContext, @@ -33,6 +53,14 @@ async function generateModule(ctx: SrcSetLoaderContext, contents: Buffer) { } const query = parseResourceQuery(ctx.resourceQuery) const limit = getSharedLimit(concurrency) + const storage = cache ? getSharedCache(ctx.rootContext, cache) : undefined + + if (storage) { + // Pruning before the build would drop the entries it is about to hit, + // whose marks it has not refreshed yet. + tapOnce(ctx._compiler, () => storage.prune()) + } + const emitImage = (image: SrcSetImage) => { const url = interpolateName(name, { contents: image.contents, @@ -60,7 +88,7 @@ async function generateModule(ctx: SrcSetLoaderContext, contents: Buffer) { query, { ...moduleOptions, - cache: cache ? getSharedCache(ctx.rootContext) : undefined + cache: storage }, emitImage, limit diff --git a/packages/loader/src/types.ts b/packages/loader/src/types.ts index a2c7a5c..5b3400f 100644 --- a/packages/loader/src/types.ts +++ b/packages/loader/src/types.ts @@ -1,4 +1,7 @@ -import type { SrcSetModuleOptions } from '@srcset/bundler-utils' +import type { + SrcSetCacheOptions, + SrcSetModuleOptions +} from '@srcset/bundler-utils' /** * Path resolver function. @@ -9,6 +12,17 @@ import type { SrcSetModuleOptions } from '@srcset/bundler-utils' */ export type PathResolver = (url: string, resourcePath: string, context: string) => string +/** + * The part of the compiler the loader uses to prune the cache. + */ +export interface SrcSetLoaderCompiler { + hooks: { + done: { + tapPromise(name: string, handler: () => Promise): void + } + } +} + /** * The part of the webpack and rspack loader context the loader uses. * Declared structurally: both compilers satisfy it, and the published @@ -31,6 +45,11 @@ export interface SrcSetLoaderContext { * Compiler mode. */ mode: string | undefined + /** + * Compiler of the run, to prune the cache when it is done. + */ + /* oxlint-disable-next-line trigen/naming-convention -- the name is the compilers' own */ + _compiler: SrcSetLoaderCompiler /** * Read the loader options. * @returns Loader options. @@ -54,8 +73,9 @@ export interface SrcSetLoaderOptions extends Omit * Cache generated variants on disk, in `node_modules/.cache/srcset`: * repeated builds skip the generation. Disabled by default - the * persistent cache of the bundler covers it, when it is enabled. + * Takes a `dir` and a `maxAge` to configure the storage. */ - cache?: boolean + cache?: boolean | SrcSetCacheOptions /** * Output file name template. * Supports `[name]`, `[postfix]`, `[ext]`, `[path]`, `[sourceext]` and diff --git a/packages/vite-plugin/src/dev.spec.ts b/packages/vite-plugin/src/dev.spec.ts index 4a09cc5..ed8166a 100644 --- a/packages/vite-plugin/src/dev.spec.ts +++ b/packages/vite-plugin/src/dev.spec.ts @@ -31,7 +31,9 @@ async function createStorage() { return { dir, - storage: new SrcSetCacheStorage(dir) + storage: new SrcSetCacheStorage({ + dir + }) } } diff --git a/packages/vite-plugin/src/plugin.spec.ts b/packages/vite-plugin/src/plugin.spec.ts index 69f5e06..1e22a23 100644 --- a/packages/vite-plugin/src/plugin.spec.ts +++ b/packages/vite-plugin/src/plugin.spec.ts @@ -6,7 +6,9 @@ import { } from 'vitest' import { mkdir, - copyFile + copyFile, + readdir, + writeFile } from 'node:fs/promises' import path from 'node:path' import sharp from 'sharp' @@ -151,6 +153,43 @@ export default logo expect(second.assets.length).toBe(first.assets.length) }) + it('should cache in the configured directory', async () => { + const dir = await createFixtureProject(defaultEntry) + const cacheDir = path.join(dir, 'custom-cache') + + await buildFixture(dir, { + cache: { + dir: cacheDir + } + }) + + expect((await readdir(cacheDir)).length).toBeGreaterThan(0) + }) + + it('should prune the cache when the build is over', async () => { + const dir = await createFixtureProject(defaultEntry) + const cacheDir = path.join(dir, 'custom-cache') + const key = 'a'.repeat(64) + + await mkdir(cacheDir, { + recursive: true + }) + await writeFile(path.join(cacheDir, `${key}.json`), JSON.stringify({ + usedAt: Date.now() - 31 * 24 * 60 * 60 * 1000 + })) + await writeFile(path.join(cacheDir, `${key}-stale.webp`), 'stale') + await buildFixture(dir, { + cache: { + dir: cacheDir + } + }) + + const left = await readdir(cacheDir) + + expect(left.some(name => name.startsWith(key))).toBe(false) + expect(left.length).toBeGreaterThan(0) + }) + it('should regenerate with the cache disabled', async () => { const dir = await createFixtureProject(ruleEntry) const optimize = vi.fn((contents: Buffer) => contents) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index df78551..e24474c 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -64,6 +64,7 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { exclude } = options const limit = pLimit(concurrency) + const cacheOptions = typeof cache === 'object' ? cache : {} const loadFilter = createLoadFilter(include, exclude) // Fallback for environments without hook filters, built from the same filter. const matchesLoadFilter = createFilter(loadFilter.id.include, loadFilter.id.exclude) @@ -124,10 +125,18 @@ export function srcset(options: SrcSetVitePluginOptions = {}): Plugin { moduleOptions = { ...options, cache: !isBuild || cache - ? new SrcSetCacheStorage(join(config.cacheDir, 'srcset')) + ? new SrcSetCacheStorage({ + ...cacheOptions, + dir: cacheOptions.dir ?? join(config.cacheDir, 'srcset') + }) : undefined } }, + // Pruning before the build would drop the entries it is about to hit, + // whose marks it has not refreshed yet. + async closeBundle() { + await moduleOptions.cache?.prune() + }, configureServer(server) { if (moduleOptions.cache) { server.middlewares.use(createDevMiddleware( diff --git a/packages/vite-plugin/src/types.ts b/packages/vite-plugin/src/types.ts index decc222..236fb1e 100644 --- a/packages/vite-plugin/src/types.ts +++ b/packages/vite-plugin/src/types.ts @@ -1,12 +1,16 @@ -import type { SrcSetModuleOptions } from '@srcset/bundler-utils' +import type { + SrcSetCacheOptions, + SrcSetModuleOptions +} from '@srcset/bundler-utils' export interface SrcSetVitePluginOptions extends Omit { /** * Cache generated variants on disk in the Vite cache directory: * repeated builds skip the generation. Enabled by default. * The dev server always uses the storage - variants are served from it. + * Takes a `dir` and a `maxAge` to configure the storage. */ - cache?: boolean + cache?: boolean | SrcSetCacheOptions /** * Paths to process, picomatch pattern(s). Defaults to all image imports. */