-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(assets): Use entity-tags to revalidate cached remote images #12426
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
Changes from 5 commits
2e2fc9d
e844a93
6955f3f
9981da8
83d007d
c3e60f3
f7a614a
2ebfd63
a806f5c
c63f48d
093dbdb
c3675ac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'astro': minor | ||
--- | ||
|
||
Improve asset caching allowing stale assets to be revalidated with entity tags | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,18 +16,18 @@ import { getConfiguredImageService } from '../internal.js'; | |
import type { LocalImageService } from '../services/service.js'; | ||
import type { AssetsGlobalStaticImagesList, ImageMetadata, ImageTransform } from '../types.js'; | ||
import { isESMImportedImage } from '../utils/imageKind.js'; | ||
import { type RemoteCacheEntry, loadRemoteImage } from './remote.js'; | ||
import { type RemoteCacheEntry, loadRemoteImage, revalidateRemoteImage } from './remote.js'; | ||
|
||
interface GenerationDataUncached { | ||
cached: false; | ||
cached: CacheStatus.Miss; | ||
weight: { | ||
before: number; | ||
after: number; | ||
}; | ||
} | ||
|
||
interface GenerationDataCached { | ||
cached: true; | ||
cached: CacheStatus.Revalidated | CacheStatus.Hit; | ||
ematipico marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
} | ||
|
||
type GenerationData = GenerationDataUncached | GenerationDataCached; | ||
|
@@ -44,7 +44,17 @@ type AssetEnv = { | |
assetsFolder: AstroConfig['build']['assets']; | ||
}; | ||
|
||
type ImageData = { data: Uint8Array; expires: number }; | ||
type ImageData = { | ||
data: Uint8Array; | ||
expires: number; | ||
etag?: string | null; | ||
}; | ||
|
||
enum CacheStatus { | ||
Miss = 0, | ||
Revalidated, | ||
Hit, | ||
} | ||
|
||
oliverlynch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
export async function prepareAssetsGenerationEnv( | ||
pipeline: BuildPipeline, | ||
|
@@ -136,7 +146,9 @@ export async function generateImagesForPath( | |
const timeChange = getTimeStat(timeStart, timeEnd); | ||
const timeIncrease = `(+${timeChange})`; | ||
const statsText = generationData.cached | ||
? `(reused cache entry)` | ||
? generationData.cached === CacheStatus.Hit | ||
? `(reused cache entry)` | ||
: `(revalidated cache entry)` | ||
: `(before: ${generationData.weight.before}kB, after: ${generationData.weight.after}kB)`; | ||
const count = `(${env.count.current}/${env.count.total})`; | ||
env.logger.info( | ||
|
@@ -166,7 +178,7 @@ export async function generateImagesForPath( | |
await fs.promises.copyFile(cachedFileURL, finalFileURL, fs.constants.COPYFILE_FICLONE); | ||
|
||
return { | ||
cached: true, | ||
cached: CacheStatus.Hit, | ||
}; | ||
} else { | ||
const JSONData = JSON.parse(readFileSync(cachedFileURL, 'utf-8')) as RemoteCacheEntry; | ||
|
@@ -184,11 +196,29 @@ export async function generateImagesForPath( | |
await fs.promises.writeFile(finalFileURL, Buffer.from(JSONData.data, 'base64')); | ||
|
||
return { | ||
cached: true, | ||
cached: CacheStatus.Hit, | ||
}; | ||
} else { | ||
await fs.promises.unlink(cachedFileURL); | ||
} | ||
|
||
// Try to revalidate the cache | ||
if (JSONData.etag) { | ||
const revalidatedData = await revalidateRemoteImage(options.src as string, JSONData.etag); | ||
oliverlynch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
if (revalidatedData.data.length) { | ||
// Image cache was stale, update original image to avoid redownload | ||
originalImage = revalidatedData; | ||
} else { | ||
revalidatedData.data = Buffer.from(JSONData.data, 'base64'); | ||
oliverlynch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
// Freshen cache on disk | ||
await writeRemoteCacheFile(cachedFileURL, revalidatedData); | ||
|
||
await fs.promises.writeFile(finalFileURL, revalidatedData.data); | ||
return { cached: CacheStatus.Revalidated }; | ||
} | ||
} | ||
|
||
await fs.promises.unlink(cachedFileURL); | ||
} | ||
} catch (e: any) { | ||
if (e.code !== 'ENOENT') { | ||
|
@@ -209,6 +239,7 @@ export async function generateImagesForPath( | |
let resultData: Partial<ImageData> = { | ||
data: undefined, | ||
expires: originalImage.expires, | ||
etag: originalImage.etag, | ||
}; | ||
|
||
const imageService = (await getConfiguredImageService()) as LocalImageService; | ||
|
@@ -239,13 +270,7 @@ export async function generateImagesForPath( | |
if (isLocalImage) { | ||
await fs.promises.writeFile(cachedFileURL, resultData.data); | ||
} else { | ||
await fs.promises.writeFile( | ||
cachedFileURL, | ||
JSON.stringify({ | ||
data: Buffer.from(resultData.data).toString('base64'), | ||
expires: resultData.expires, | ||
}), | ||
); | ||
await writeRemoteCacheFile(cachedFileURL, resultData as ImageData); | ||
} | ||
} | ||
} catch (e) { | ||
|
@@ -259,7 +284,7 @@ export async function generateImagesForPath( | |
} | ||
|
||
return { | ||
cached: false, | ||
cached: CacheStatus.Miss, | ||
weight: { | ||
// Divide by 1024 to get size in kilobytes | ||
before: Math.trunc(originalImage.data.byteLength / 1024), | ||
|
@@ -269,6 +294,17 @@ export async function generateImagesForPath( | |
} | ||
} | ||
|
||
async function writeRemoteCacheFile(cachedFileURL: URL, resultData: ImageData) { | ||
return await fs.promises.writeFile( | ||
cachedFileURL, | ||
JSON.stringify({ | ||
data: Buffer.from(resultData.data).toString('base64'), | ||
expires: resultData.expires, | ||
etag: resultData.etag, | ||
}), | ||
|
||
); | ||
} | ||
|
||
export function getStaticImageList(): AssetsGlobalStaticImagesList { | ||
if (!globalThis?.astroAsset?.staticImages) { | ||
return new Map(); | ||
|
@@ -279,11 +315,7 @@ export function getStaticImageList(): AssetsGlobalStaticImagesList { | |
|
||
async function loadImage(path: string, env: AssetEnv): Promise<ImageData> { | ||
if (isRemotePath(path)) { | ||
const remoteImage = await loadRemoteImage(path); | ||
return { | ||
data: remoteImage.data, | ||
expires: remoteImage.expires, | ||
}; | ||
Comment on lines
-282
to
-286
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have no idea what happened here, but thank you for cleaning it, ha |
||
return await loadRemoteImage(path); | ||
} | ||
|
||
return { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
import CachePolicy from 'http-cache-semantics'; | ||
|
||
export type RemoteCacheEntry = { data: string; expires: number }; | ||
export type RemoteCacheEntry = { data: string; expires: number; etag?: string }; | ||
|
||
export async function loadRemoteImage(src: string) { | ||
const req = new Request(src); | ||
|
@@ -19,6 +19,40 @@ export async function loadRemoteImage(src: string) { | |
return { | ||
data: Buffer.from(await res.arrayBuffer()), | ||
expires: Date.now() + expires, | ||
etag: res.headers.get('Etag'), | ||
}; | ||
} | ||
|
||
export async function revalidateRemoteImage(src: string, etag: string) { | ||
oliverlynch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
const req = new Request(src, { headers: { 'If-None-Match': etag } }); | ||
|
||
const res = await fetch(req); | ||
|
||
if (!res.ok && res.status != 304) { | ||
oliverlynch marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
throw new Error( | ||
`Failed to revalidate cached remote image ${src}. The request did not return a 200 OK / 304 NOT MODIFIED response. (received ${res.status}))`, | ||
|
||
); | ||
} | ||
|
||
const data = Buffer.from(await res.arrayBuffer()); | ||
|
||
if (res.ok && !data.length) { | ||
// Server did not include body but indicated cache was stale | ||
return await loadRemoteImage(src); | ||
} | ||
|
||
// calculate an expiration date based on the response's TTL | ||
const policy = new CachePolicy( | ||
webToCachePolicyRequest(req), | ||
webToCachePolicyResponse( | ||
res.ok ? res : new Response(null, { status: 200, headers: res.headers }), | ||
), // 304 responses themselves are not cachable, so just pretend to get the refreshed TTL | ||
); | ||
const expires = policy.storable() ? policy.timeToLive() : 0; | ||
|
||
return { | ||
data, | ||
expires: Date.now() + expires, | ||
etag: res.headers.get('Etag'), | ||
}; | ||
} | ||
|
||
|
Uh oh!
There was an error while loading. Please reload this page.