feat(images): cache-bust /cms-images URLs by post._updated
Deploy / verify (push) Successful in 40s
Deploy / deploy (push) Successful in 1m1s

Browser caches /cms-images responses immutably for a year. When the
editor changes a focal point or alt text, the underlying transform
output changes too, but the URL stays the same — so the browser keeps
serving the previous bytes. Manual hard-reloads and /api/cache/clear
help once but not for visitors who already have the file.

- RustyImageTransformParams gains an optional `version` that
  serializes as `_v` in the /cms-images URL.
- RustyImage.svelte takes a matching `version` prop and forwards it
  through `baseParams`, so every <picture> source carries the tag.
- The post thumbnail passes `post._updated` (truncated to seconds)
  as version. Each save bumps `_updated`, the URL changes, browsers
  re-fetch.

`_v` is intentionally not consumed by `/cms-images` — its sole job is
to break the browser cache key. The server-side image-cache and the
RustyCMS transform cache key both ignore it, so disk usage doesn't
balloon when the version tag changes.
This commit is contained in:
Peter Meier
2026-04-25 09:43:34 +02:00
parent bb9df1f9bd
commit ecff7b0bbf
3 changed files with 13 additions and 0 deletions
+4
View File
@@ -37,6 +37,8 @@
/** JPEG/AVIF/WebP-Qualität. Default 75. */ /** JPEG/AVIF/WebP-Qualität. Default 75. */
quality?: number; quality?: number;
fit?: "cover" | "contain" | "fill"; fit?: "cover" | "contain" | "fill";
/** Cache-Bust-Tag (z.B. post._updated) → `_v`-Param, bricht Browser-HTTP-Cache. */
version?: string;
} }
let { let {
@@ -56,6 +58,7 @@
formats = ["webp", "jpeg"], formats = ["webp", "jpeg"],
quality = 75, quality = 75,
fit = "cover", fit = "cover",
version,
}: Props = $props(); }: Props = $props();
function parseAr(s: string | undefined): number | null { function parseAr(s: string | undefined): number | null {
@@ -89,6 +92,7 @@
...(arString && fit !== "contain" ...(arString && fit !== "contain"
? { height: Math.round(w / (arValue ?? 1)) } ? { height: Math.round(w / (arValue ?? 1)) }
: {}), : {}),
...(version ? { version } : {}),
}; };
} }
+8
View File
@@ -68,6 +68,13 @@ export interface RustyImageTransformParams {
focalX?: number; focalX?: number;
/** Focal-Y in [0,1] (Default 0.5). */ /** Focal-Y in [0,1] (Default 0.5). */
focalY?: number; focalY?: number;
/**
* Cache-busting tag (e.g. post._updated). Serialized as `_v` and is the
* only param the cms-images endpoint deliberately ignores when forwarding
* to RustyCMS — its sole job is to invalidate the browser HTTP cache when
* the underlying entry (focal, alt, replacement) changes.
*/
version?: string;
} }
function roundFocal(v: number): number { function roundFocal(v: number): number {
@@ -89,6 +96,7 @@ export function getCmsImageUrl(src: string, params: RustyImageTransformParams =
if (params.quality != null) searchParams.set('q', String(params.quality)); if (params.quality != null) searchParams.set('q', String(params.quality));
if (params.focalX != null) searchParams.set('fx', String(roundFocal(params.focalX))); if (params.focalX != null) searchParams.set('fx', String(roundFocal(params.focalX)));
if (params.focalY != null) searchParams.set('fy', String(roundFocal(params.focalY))); if (params.focalY != null) searchParams.set('fy', String(roundFocal(params.focalY)));
if (params.version) searchParams.set('_v', params.version);
return `/cms-images?${searchParams.toString()}`; return `/cms-images?${searchParams.toString()}`;
} }
+1
View File
@@ -45,6 +45,7 @@
class="block object-cover" class="block object-cover"
loading="eager" loading="eager"
fetchpriority="high" fetchpriority="high"
version={(data.post as { _updated?: string })._updated?.slice(0, 19)}
/> />
</div> </div>
{:else if data.postImageUrl} {:else if data.postImageUrl}