Skip to content

Commit 9820745

Browse files
committed
Windows dependency matching, including missing-file recovery.
One shared content-root lookup per build, with retry after failure. Configurable image-cache expiry: 30 days unused by default, with atomic publication for concurrent builds. Set cacheMaxAgeDays: 0 to disable expiry.
1 parent a59b4d0 commit 9820745

11 files changed

Lines changed: 532 additions & 28 deletions

File tree

‎packages/ts-images/README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ can override. Settings are also available under typed `extensions.images`:
6464
| `metadata` | `strip`; auto-orient first, convert to sRGB, then omit metadata; `preserve` retains metadata with orientation normalized |
6565
| `include` / `exclude` | `['**/*']` / `[]`; glob patterns on content-root-relative paths using `/` |
6666
| `cacheDir` | OS temporary cache, namespaced by content root; custom paths resolve from `rootDir` and must be outside content and output directories |
67+
| `cacheMaxAgeDays` | `30`; entries unused for this many days expire; `0` disables automatic cleanup |
6768
| `concurrency` | `2`; per-site limit from 1–16, including encoding and output emission |
6869
| `maxInputPixels` | 40 million |
6970
| `maxSourceBytes` | 50 MiB |
@@ -128,6 +129,15 @@ loading, and `sizes` reuse encoded files. Cache contents are verified by hash,
128129
and incomplete or corrupted entries are rebuilt. Concurrent identical work is
129130
shared within a site; writes use atomic replacement.
130131

132+
Cache reads renew an entry's last-use timestamp. During image processing, cleanup
133+
runs at most once an hour per processor and removes entries unused for
134+
`cacheMaxAgeDays`, including those in a custom `cacheDir`. Completed entries are
135+
published atomically so independent builds can safely share the cache. Cleanup
136+
also removes abandoned staging entries after the retention period and leaves
137+
unrelated files and symlinked entries alone. Expiry affects only the encoding
138+
cache, never source images or published assets. No cleanup runs while the plugin
139+
is idle; `cacheMaxAgeDays: 0` keeps entries until you remove them manually.
140+
131141
Optimization emits diagnostics with the referring page, source, cache result,
132142
input bytes, total generated bytes, variant count, and duration. Generated bytes
133143
are the total of all variants, not the bytes a particular browser downloads.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import fs from 'node:fs/promises'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { pruneImageCache } from './cache'
6+
7+
describe('image cache retention', () => {
8+
let root: string
9+
const old = new Date(Date.now() - 60 * 86_400_000)
10+
beforeEach(async () => {
11+
root = await fs.mkdtemp(
12+
path.join(os.tmpdir(), 'purestack-cache-retention-'),
13+
)
14+
})
15+
afterEach(async () => {
16+
vi.restoreAllMocks()
17+
await fs.rm(root, { recursive: true, force: true })
18+
})
19+
async function entry(name: string, date = old) {
20+
const directory = path.join(root, name)
21+
await fs.mkdir(directory)
22+
await fs.writeFile(
23+
path.join(directory, 'manifest.json'),
24+
JSON.stringify({ version: 1, key: name, variants: [] }),
25+
)
26+
await fs.utimes(path.join(directory, 'manifest.json'), date, date)
27+
return directory
28+
}
29+
30+
it('expires unused owned entries while retaining fresh, active, foreign, and symlinked paths', async () => {
31+
const expired = await entry('a'.repeat(64))
32+
await entry('b'.repeat(64), new Date())
33+
await entry('c'.repeat(64))
34+
await entry('notes')
35+
const foreign = await entry('d'.repeat(64))
36+
await fs.writeFile(
37+
path.join(foreign, 'manifest.json'),
38+
'{"key":"unrelated"}',
39+
)
40+
await fs.utimes(path.join(foreign, 'manifest.json'), old, old)
41+
await fs.writeFile(path.join(root, 'keep.txt'), 'Keep')
42+
const alias = path.join(root, 'e'.repeat(64))
43+
await fs.symlink(
44+
path.join(root, 'notes'),
45+
alias,
46+
process.platform === 'win32' ? 'junction' : 'dir',
47+
)
48+
// Two sites/processors can sweep the same shared cache concurrently.
49+
await Promise.all(
50+
Array.from({ length: 2 }, () =>
51+
pruneImageCache(root, 30, new Set(['c'.repeat(64)])),
52+
),
53+
)
54+
await expect(fs.stat(expired)).rejects.toMatchObject({ code: 'ENOENT' })
55+
expect((await fs.readdir(root)).sort()).toEqual(
56+
[
57+
'b'.repeat(64),
58+
'c'.repeat(64),
59+
'd'.repeat(64),
60+
'e'.repeat(64),
61+
'keep.txt',
62+
'notes',
63+
].sort(),
64+
)
65+
expect(await fs.readFile(path.join(root, 'keep.txt'), 'utf8')).toBe('Keep')
66+
})
67+
68+
it('allows automatic expiry to be disabled', async () => {
69+
await entry('a'.repeat(64))
70+
await pruneImageCache(root, 0, new Set())
71+
expect(await fs.readdir(root)).toEqual(['a'.repeat(64)])
72+
})
73+
74+
it('rechecks expiry after reading a manifest to respect concurrent cache hits', async () => {
75+
const directory = await entry('a'.repeat(64))
76+
const read = fs.readFile.bind(fs)
77+
vi.spyOn(fs, 'readFile').mockImplementation(
78+
async (...args: Parameters<typeof fs.readFile>) => {
79+
const value = await read(...args)
80+
const now = new Date()
81+
await fs.utimes(path.join(directory, 'manifest.json'), now, now)
82+
return value
83+
},
84+
)
85+
await pruneImageCache(root, 30, new Set())
86+
expect(await fs.readdir(root)).toEqual(['a'.repeat(64)])
87+
})
88+
89+
it('cleans abandoned staging directories without touching fresh staging work', async () => {
90+
const stale = '.purestack-images-12345678-1234-1234-1234-123456789abc.tmp'
91+
const fresh = '.purestack-images-12345678-1234-1234-1234-123456789abd.tmp'
92+
const retired =
93+
'.purestack-images-12345678-1234-1234-1234-123456789abe.trash'
94+
for (const name of [stale, fresh, retired]) {
95+
await fs.mkdir(path.join(root, name))
96+
await fs.writeFile(path.join(root, name, 'partial.png'), 'Incomplete')
97+
if (name !== fresh) await fs.utimes(path.join(root, name), old, old)
98+
}
99+
await pruneImageCache(root, 30, new Set())
100+
expect(await fs.readdir(root)).toEqual([fresh])
101+
})
102+
})

‎packages/ts-images/src/cache.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { randomUUID } from 'node:crypto'
2+
import fs from 'node:fs/promises'
3+
import path from 'node:path'
4+
import { isPlainObject } from '@purestack/ts-util'
5+
6+
const entryName = /^[a-f0-9]{64}$/
7+
const temporaryName =
8+
/^\.purestack-images-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\.(?:tmp|trash)$/
9+
10+
/** Retire by rename: readers either finish reading or treat a vanished entry as a miss. */
11+
export async function retireCacheEntry(root: string, key: string) {
12+
if (!entryName.test(key)) throw new Error('Invalid image cache key.')
13+
const directory = path.join(root, key)
14+
const stat = await fs.lstat(directory).catch(missing)
15+
if (!stat) return
16+
if (!stat.isDirectory() || stat.isSymbolicLink())
17+
throw new Error('Image cache entries must be directories, not symlinks.')
18+
const retired = path.join(root, `.purestack-images-${randomUUID()}.trash`)
19+
try {
20+
await fs.rename(directory, retired)
21+
} catch (error) {
22+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
23+
// Windows can report EPERM when another process has already moved the directory.
24+
if (
25+
(error as NodeJS.ErrnoException).code === 'EPERM' &&
26+
!(await fs.lstat(directory).catch(missing))
27+
)
28+
return
29+
throw error
30+
}
31+
await fs.rm(retired, { recursive: true, force: true }).catch(cleanupError)
32+
}
33+
34+
/** Only our expired entries are collected; a custom cache directory may contain other files. */
35+
export async function pruneImageCache(
36+
root: string,
37+
maxAgeDays: number,
38+
activeKeys: ReadonlySet<string>,
39+
) {
40+
if (maxAgeDays === 0) return
41+
const cutoff = Date.now() - maxAgeDays * 86_400_000
42+
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
43+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
44+
const directory = path.join(root, entry.name)
45+
try {
46+
if (temporaryName.test(entry.name)) {
47+
const stat = await fs.lstat(directory)
48+
if (stat.mtimeMs < cutoff)
49+
await fs.rm(directory, { recursive: true, force: true })
50+
continue
51+
}
52+
if (!entryName.test(entry.name) || activeKeys.has(entry.name)) continue
53+
const manifest = path.join(directory, 'manifest.json')
54+
const stat = await fs.lstat(manifest)
55+
if (!stat.isFile() || stat.size > 64 * 1024 || stat.mtimeMs >= cutoff)
56+
continue
57+
const value: unknown = JSON.parse(await fs.readFile(manifest, 'utf8'))
58+
if (
59+
!isPlainObject(value) ||
60+
value.version !== 1 ||
61+
value.key !== entry.name ||
62+
!Array.isArray(value.variants)
63+
)
64+
continue
65+
// A concurrent cache hit may have renewed this entry while we read it.
66+
if ((await fs.stat(manifest)).mtimeMs < cutoff)
67+
await retireCacheEntry(root, entry.name)
68+
} catch (error) {
69+
if (error instanceof SyntaxError) continue
70+
cleanupError(error)
71+
}
72+
}
73+
}
74+
75+
function missing(error: NodeJS.ErrnoException): undefined {
76+
if (error.code !== 'ENOENT') throw error
77+
}
78+
79+
function cleanupError(error: unknown) {
80+
// Another process can retire the entry or still hold a Windows file handle.
81+
if (
82+
!['ENOENT', 'EACCES', 'EPERM', 'EBUSY', 'ENOTEMPTY'].includes(
83+
(error as NodeJS.ErrnoException).code ?? '',
84+
)
85+
)
86+
throw error
87+
}

‎packages/ts-images/src/config.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface ImagesOptions extends ImagePreset {
3030
include?: string[]
3131
exclude?: string[]
3232
cacheDir?: string
33+
/** Remove entries unused for this many days; zero disables automatic cleanup. */
34+
cacheMaxAgeDays?: number
3335
concurrency?: number
3436
maxInputPixels?: number
3537
maxSourceBytes?: number
@@ -41,6 +43,7 @@ export interface ImagesConfig extends ResolvedImagePreset {
4143
include: string[]
4244
exclude: string[]
4345
cacheDir: string
46+
cacheMaxAgeDays: number
4447
concurrency: number
4548
maxInputPixels: number
4649
maxSourceBytes: number
@@ -99,6 +102,7 @@ export function resolveImagesConfig(
99102
'include',
100103
'exclude',
101104
'cacheDir',
105+
'cacheMaxAgeDays',
102106
'concurrency',
103107
'maxInputPixels',
104108
'maxSourceBytes',
@@ -149,6 +153,13 @@ export function resolveImagesConfig(
149153
include: patterns(input.include, ['**/*'], 'images.include'),
150154
exclude: patterns(input.exclude, [], 'images.exclude'),
151155
cacheDir,
156+
cacheMaxAgeDays: integer(
157+
input.cacheMaxAgeDays,
158+
30,
159+
0,
160+
36500,
161+
'images.cacheMaxAgeDays',
162+
),
152163
concurrency: integer(input.concurrency, 2, 1, 16, 'images.concurrency'),
153164
maxInputPixels: integer(
154165
input.maxInputPixels,

‎packages/ts-images/src/processor.test.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
22
import os from 'node:os'
33
import path from 'node:path'
44
import sharp from 'sharp'
5-
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66
import { imagePreset, resolveImagesConfig } from './config'
77
import { ImageProcessor } from './processor'
88

@@ -12,6 +12,7 @@ describe('image processor and persistent cache', () => {
1212
root = await fs.mkdtemp(path.join(os.tmpdir(), 'purestack-image-test-'))
1313
})
1414
afterEach(async () => {
15+
vi.restoreAllMocks()
1516
await fs.rm(root, { recursive: true, force: true })
1617
})
1718
const config = (options = {}) =>
@@ -71,7 +72,8 @@ describe('image processor and persistent cache', () => {
7172
first?.variants.map((variant) => variant.hash),
7273
)
7374
const file = path.join(settings.cacheDir, first?.key ?? '', fallback.file)
74-
await fs.writeFile(file, 'Corrupt cache')
75+
// Same-size corruption must still be detected by content verification.
76+
await fs.writeFile(file, Buffer.alloc(fallback.data.length))
7577
expect(
7678
(await new ImageProcessor(settings).process(source(), settings))?.cache,
7779
).toBe('miss')
@@ -234,4 +236,63 @@ describe('image processor and persistent cache', () => {
234236
).rejects.toThrow('including symlink targets')
235237
expect(await fs.readdir(content)).toEqual([])
236238
})
239+
240+
it('renews cache hits and prunes expired variants as source images change', async () => {
241+
const settings = config({ widths: [16], formats: [], cacheMaxAgeDays: 1 })
242+
const write = (background: string) =>
243+
sharp({ create: { width: 32, height: 16, channels: 3, background } })
244+
.png()
245+
.toFile(source().absPath)
246+
await write('red')
247+
const first = await new ImageProcessor(settings).process(source(), settings)
248+
if (!first) throw new Error('Expected image')
249+
const manifest = path.join(settings.cacheDir, first.key, 'manifest.json')
250+
const old = new Date(Date.now() - 3 * 86_400_000)
251+
await fs.utimes(manifest, old, old)
252+
// A requested entry is reused and renewed even if its prior age exceeded the limit.
253+
expect(
254+
(await new ImageProcessor(settings).process(source(), settings))?.cache,
255+
).toBe('hit')
256+
expect((await fs.stat(manifest)).mtimeMs).toBeGreaterThan(old.getTime())
257+
await fs.utimes(manifest, old, old)
258+
await write('blue')
259+
const next = await new ImageProcessor(settings).process(source(), settings)
260+
expect(next?.key).not.toBe(first.key)
261+
await expect(fs.stat(manifest)).rejects.toMatchObject({ code: 'ENOENT' })
262+
expect(
263+
(await new ImageProcessor(settings).process(source(), settings))?.cache,
264+
).toBe('hit')
265+
})
266+
267+
it('publishes complete entries when independent processors share a cache, including corruption repair', async () => {
268+
await sharp({
269+
create: { width: 32, height: 16, channels: 3, background: 'green' },
270+
})
271+
.png()
272+
.toFile(source().absPath)
273+
const settings = config({ widths: [16], formats: ['webp'] })
274+
const processTogether = () =>
275+
Promise.all(
276+
Array.from({ length: 4 }, () =>
277+
new ImageProcessor(settings).process(source(), settings),
278+
),
279+
)
280+
const first = await processTogether()
281+
expect(new Set(first.map((value) => value?.key)).size).toBe(1)
282+
const value = first[0]
283+
if (!value) throw new Error('Expected image')
284+
await fs.writeFile(
285+
path.join(settings.cacheDir, value.key, value.variants[0].file),
286+
Buffer.alloc(value.variants[0].data.length),
287+
)
288+
const repaired = await processTogether()
289+
for (const result of repaired)
290+
expect(result?.variants.map((variant) => variant.hash)).toEqual(
291+
value.variants.map((variant) => variant.hash),
292+
)
293+
expect(
294+
(await new ImageProcessor(settings).process(source(), settings))?.cache,
295+
).toBe('hit')
296+
expect(await fs.readdir(settings.cacheDir)).toEqual([value.key])
297+
})
237298
})

0 commit comments

Comments
 (0)