Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feat: Improved cache handling #10

Merged
merged 12 commits into from
Nov 22, 2023
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
- Can create Avif versions of supported images (jpg/png).
- Skips optimized version if output is larger than original.
- Skips Avif/WebP version if output is larger than original, optimized version or smallest version of an image.
- Uses cache to skip processing of unchanged files
- Uses optional cache by default to skip processing of unchanged files

## Install

Expand Down Expand Up @@ -109,13 +109,6 @@ Default: `true`

> Ignore the optimized output if it is larger than the original file.

### cache

Type: `boolean`<br>
Default: `true`

> Skip optimizing the input if it did not change since the last run.

### makeAvif / makeWebp

Type: `object`<br>
Expand Down Expand Up @@ -147,6 +140,40 @@ Default: `'optimized'`
> - it is not the smallest version of the image (`'smallest'`)
> - never skip (`false`)

### cache

Type: `boolean`<br>
Default: `true`

> Skip optimizing the input if it did not change since the last run.

### cacheDir

Type: `string`<br>
Default: `<packageRoot>/node_modules/.cache/vite-plugin-imagemin/<packageName>/`

> Choose the directory to use for caching.
> - Relative paths will be relative to the Vite project's **root** directory.
> - Absolute paths will be use as-is.
> - Absent/non-string value will use the default location.

### cacheKey

Type: `string`<br>
Default: ``

> Optional string to distinguish build configs and their caches.
>
> The cache identifier is a hash of most used options including this key.
> Note: Because options like `formatFilePath` and `plugins` cannot be stringified, they will have little or no influence on the hash key and its uniqueness.

### clearCache

Type: `boolean`<br>
Default: `false`

> Clears the cache folder before processing.

### root

Type: `string`<br>
Expand Down
3 changes: 1 addition & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
"node": ">=16.0.0"
},
"dependencies": {
"@file-cache/core": "^1.1.4",
"chalk": "^5.2.0",
"fast-glob": "^3.2.12",
"fs-extra": "^11.1.1",
Expand All @@ -79,4 +78,4 @@
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^4.3.9"
}
}
}
246 changes: 246 additions & 0 deletions packages/core/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import crypto, { BinaryLike } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { isAbsolute, resolve } from 'node:path'
import { normalizePath } from 'vite'

import {
getPackageDirectory,
getPackageName,
isString,
smartEnsureDirs,
} from './utils'

import type { CacheValue, ResolvedConfigOptions } from './typings'

let cacheEnabled = false
let cacheDir = ''
let cacheKey = ''
let cacheFile = ''
let fileCacheMap = new Map<string, CacheValue>()
let entryMap = new Map<string, CacheValue>()

function md5(buffer: BinaryLike): string {
return crypto.createHash('md5').update(buffer).digest('hex')
}

export function createCacheKey(options: ResolvedConfigOptions) {
return md5(
Object.entries(options)
.filter(
([k]) =>
// Ignore these options, since they don't influence the files' content.
![
'cache',
'clearCache',
'logByteDivider',
'logger',
'verbose',
].includes(k),
)
.sort(([ka], [kb]) => ka.localeCompare(kb))
.map(([k, v], i) => `${i}_${k}_${v}`)
.join('|'),
)
}

async function initCacheDir(rootDir: string, options: ResolvedConfigOptions) {
cacheKey = createCacheKey(options)

const packageDir = normalizePath(getPackageDirectory())
const packageName = getPackageName(packageDir)

if (isString(options.cacheDir)) {
cacheDir = normalizePath(
isAbsolute(options.cacheDir)
? options.cacheDir
: resolve(rootDir, options.cacheDir),
)
} else {
cacheDir = `${packageDir}/node_modules/.cache/vite-plugin-imagemin`
}

// cacheDir = cacheDir + `/${packageName}/${cacheKey}/`
cacheDir = cacheDir + `/${packageName}/`

if (options.clearCache) {
await rm(cacheDir.slice(0, -1), { recursive: true, force: true })
}

if (!cacheEnabled) {
return
}

await mkdir(cacheDir.slice(0, -1), { recursive: true })
}

async function initCacheMaps() {
cacheFile = `${cacheDir}/contents-${cacheKey}.json`

try {
const json = JSON.parse(await readFile(cacheFile, 'utf-8'))
entryMap = new Map<string, CacheValue>(Object.entries(json))
} catch {
entryMap = new Map<string, CacheValue>()
}

fileCacheMap = new Map<string, CacheValue>(entryMap)
}

async function checkAndUpdate({
fileName,
directory,
stats,
buffer,
restoreTo,
}: {
fileName: string
directory?: string
stats?: Omit<CacheValue, 'hash'>
buffer?: Buffer
restoreTo?: string | false
}): Promise<{
changed?: boolean
value?: CacheValue
error?: Error
}> {
if (cacheEnabled) {
if (!fileName) {
return {
error: new Error('Missing filename'),
}
}

const filePath = (directory ?? cacheDir) + fileName

if (!buffer) {
try {
buffer = await readFile(filePath)
} catch (error) {
return {
error: error as Error,
}
}
}

const hash = md5(buffer)
const cacheValue = fileCacheMap.get(filePath)
if (cacheValue && cacheValue.hash === hash) {
if (restoreTo) {
try {
await writeFile(restoreTo + fileName, buffer)
} catch (error) {
return {
error: error as Error,
}
}
}

return {
changed: false,
value: cacheValue,
}
}

entryMap.set(filePath, {
hash,
oldSize: stats?.oldSize ?? 1,
newSize: stats?.newSize ?? 1,
})
}

return {
changed: true,
}
}

export const FileCache = {
init: async (options: ResolvedConfigOptions, rootDir: string) => {
cacheEnabled = options.cache !== false

await initCacheDir(rootDir, options)

if (!cacheEnabled) {
return
}

await initCacheMaps()
},

prepareDirs: (filePaths: string[]): void => {
if (!cacheEnabled) {
return
}

smartEnsureDirs(filePaths.map(file => cacheDir + file))
},

checkAndUpdate: checkAndUpdate,

update: async ({
fileName,
buffer,
directory,
stats,
}: {
fileName: string
buffer: Buffer
directory?: string
stats?: Omit<CacheValue, 'hash'>
}) => {
if (!cacheEnabled) {
return false
}

if (!fileName) {
return {
error: new Error('Missing filename'),
}
}

if (!buffer) {
return {
error: new Error('Missing content for cache file'),
}
}

const filePath = (directory ?? cacheDir) + fileName

try {
await writeFile(filePath, buffer)
} catch (error) {
return {
error: new Error(
`Could not write cache file [${(error as Error).message}]`,
),
}
}

return await checkAndUpdate({
fileName,
directory,
buffer,
stats,
})
},

reconcile: async () => {
if (!cacheEnabled) {
return true
}

try {
await writeFile(
cacheFile,
JSON.stringify(Object.fromEntries(entryMap)),
'utf-8',
)

fileCacheMap = new Map(entryMap)

return true
} catch (error) {
// console.error('Cache reconcile has failed', error)
return false
}
},
}
20 changes: 12 additions & 8 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,7 +642,7 @@ describe('processFile', () => {
})
})

it('returns error object if OUTPUT PROCESS error (Error & warn)', async () => {
it('returns error object if OUTPUT PROCESS error (Error & string)', async () => {
await expect(
// @ts-expect-error missing properties are used after expected error
processFile({
Expand All @@ -655,9 +655,9 @@ describe('processFile', () => {
// optimization: 4,
// strip: 'safe',
// }),
() => Promise.reject(new Error('Test')),
() => Promise.reject(new Error('Test error processing file')),
// () => {
// throw new Error('Test')
// throw new Error('Test error processing file')
// },
],
skipIfLarger: false,
Expand All @@ -668,7 +668,9 @@ describe('processFile', () => {
).resolves.toContainEqual({
status: 'rejected',
reason: expect.objectContaining({
error: expect.stringMatching(/^Error processing file/),
error: expect.stringMatching(
/^Error processing file:\s+Test error processing file/,
),
}),
})

Expand All @@ -684,9 +686,9 @@ describe('processFile', () => {
// optimization: 4,
// strip: 'safe',
// }),
() => Promise.reject('WARN: Test error processing file'),
() => Promise.reject('Test error processing file'),
// () => {
// throw 'WARN: Test error processing file'
// throw 'Test error processing file'
// },
],
skipIfLarger: false,
Expand All @@ -697,7 +699,9 @@ describe('processFile', () => {
).resolves.toContainEqual({
status: 'rejected',
reason: expect.objectContaining({
error: 'Test error processing file',
error: expect.stringMatching(
/^Error processing file:\s*Test error processing file/,
),
}),
})
})
Expand Down Expand Up @@ -1368,7 +1372,7 @@ describe('logErrors', () => {
* dist/images/opaque-1.png.avif
*/

// TODO: add tests for usage with cache
// TODO: add tests for cache usage and its options
// TODO: expand after-build checks

describe.skipIf(skipBuilds)('viteImagemin', () => {
Expand Down
Loading