Skip to content

fix: bust stale browser cache for replaced images via content-hash URLs#154

Open
rohilsurana wants to merge 4 commits into
mainfrom
fix/image-cache-busting
Open

fix: bust stale browser cache for replaced images via content-hash URLs#154
rohilsurana wants to merge 4 commits into
mainfrom
fix/image-cache-busting

Conversation

@rohilsurana

Copy link
Copy Markdown
Member

Problem

When an image file is replaced but keeps the same filename, browsers keep showing the old cached image until users hard-refresh.

Two things caused this:

  • /api/image responses were sent with Cache-Control: public, max-age=31536000, immutable. The URL never changed when the file changed, so browsers never asked again for a year.
  • Raw /_content/* responses were sent with max-age=86400 and no ETag or Last-Modified, so they could stay stale for a day with no cheap way to revalidate.

Fix

Stamp a short content hash on every resolved local image URL at MDX transform time, and make cache headers depend on whether the requested version matches the file on disk.

  • remark-resolve-images now appends ?v=<10-char sha256> to resolved /_content/ URLs and threads it into /api/image?...&v= URLs. Replacing an image produces a new URL on the next build, so every browser fetches the new file.
  • /api/image and /_content/* serve public, max-age=31536000, immutable only when the requested v matches the current file hash. Anything else (no version, stale version) gets public, no-cache plus an ETag, so clients revalidate and get cheap 304s. Old HTML self-heals instead of staying stale.
  • The dev server always sends no-cache (via import.meta.dev), since Vite does not re-transform MDX when only an image file changes.
  • The /api/image server-side cache key now uses the content hash instead of mtime, so a fresh checkout (where all mtimes change) no longer invalidates the whole optimizer cache.
  • Static builds get the same treatment: versioned URLs in the compiled MDX bundles and in data/pages/*.json, while files on disk keep their original names, so any static host serves them unchanged.
  • Preload links (SSR), client-side prefetch, and cache warmup now build the same versioned URLs as the rendered <img> tags, so hints hit the same cache entries. The client prefetch also uses the webp variant in static mode, matching what MDXImage renders.

New helpers: asset-version.ts (memoized content hash of a file) and server/utils/asset-cache.ts (cache-control / ETag / conditional request logic).

Behavior

Request Cache-Control
?v= matches file public, max-age=31536000, immutable
?v= stale or missing public, no-cache + ETag (304 on match)
dev server always public, no-cache + ETag

Note for CDN users: edge caches must include the query string in their cache key for versioned URLs to bust correctly.

Testing

  • Unit tests for hashing, URL helpers, the remark plugin (version stamping across md/html/JSX image nodes), and cache-header helpers. 217 tests pass.
  • Verified end to end on the basic example: dev server sends no-cache + working 304s; production build sends immutable for matching v, no-cache for stale/missing v; replacing the image file and rebuilding produced a new v= hash in both server and client bundles; static build copies originals + webp and stamps versioned URLs in page data.

@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chronicle Ready Ready Preview, Comment Jul 3, 2026 5:01am

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@rohilsurana, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e7d961fc-d04a-4fae-9298-5e9c226cd0d9

📥 Commits

Reviewing files that changed from the base of the PR and between 7d5d05b and fc37e70.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • packages/chronicle/package.json
  • packages/chronicle/src/cli/commands/static-generate.ts
  • packages/chronicle/src/lib/asset-version.test.ts
  • packages/chronicle/src/lib/asset-version.ts
  • packages/chronicle/src/lib/remark-resolve-images.test.ts
  • packages/chronicle/src/lib/remark-resolve-images.ts
  • packages/chronicle/src/server/api/image.ts
  • packages/chronicle/src/server/routes/_content/[...path].ts
📝 Walkthrough

Walkthrough

This PR introduces content-hash-based asset versioning across the image pipeline. A new hashContent/getAssetVersion utility computes and memoizes SHA-256 hashes of file content. buildOptimizedUrl, splitVersion, and webpUrl helpers propagate ?v=... query parameters through remark image resolution, static-site generation, MDX/page rendering, entry-server preloads, and the image optimization API. HTTP caching is updated with assetCacheControl, etagFor, and isNotModified utilities enabling immutable/no-cache decisions and 304 responses in both the image API and the _content route.

Changes

Asset versioning and cache-aware image pipeline

Layer / File(s) Summary
Content-hash versioning utility
packages/chronicle/src/lib/asset-version.ts, packages/chronicle/src/lib/asset-version.test.ts
Adds hashContent (SHA-256 truncated hash) and getAssetVersion (memoized per-file hash based on mtime/size, returns null on error), with tests.
Versioned URL helpers
packages/chronicle/src/lib/image-utils.ts, packages/chronicle/src/lib/image-utils.test.ts
Extends buildOptimizedUrl with optional version param; adds splitVersion and webpUrl helpers, with tests.
Remark image resolution stamping
packages/chronicle/src/lib/remark-resolve-images.ts, packages/chronicle/src/lib/remark-resolve-images.test.ts
Introduces finalizeUrl/versionFor/processUrl to append ?v=... to resolved local image URLs across MDAST, HTML, MDX JSX, and HAST nodes, with tests.
Rendering consumes versioned URLs
packages/chronicle/src/components/mdx/image.tsx, packages/chronicle/src/lib/page-context.tsx, packages/chronicle/src/server/entry-server.tsx
Splits image src into base/version and passes version into buildOptimizedUrl/webpUrl for MDX images, docs page images, and preload links.
Static generation versioning
packages/chronicle/src/cli/commands/static-generate.ts
Appends ?v=<assetVersion> when scanning local content images and dedupes/optimizes using the split base URL.
HTTP asset caching utilities
packages/chronicle/src/server/utils/asset-cache.ts, packages/chronicle/src/server/utils/asset-cache.test.ts, packages/chronicle/src/types/globals.d.ts
Adds assetCacheControl, etagFor, isNotModified for cache-control/ETag logic, plus ImportMeta.dev type, with tests.
Image API version-aware caching
packages/chronicle/src/server/api/image.ts, packages/chronicle/src/server/api/image.test.ts
Updates cacheKey to use content version, computes ETag/Cache-Control, supports 304 responses, and versions warmupImageCache deduplication, with tests.
Content route conditional serving
packages/chronicle/src/server/routes/_content/[...path].ts
Computes current/requested version, sets ETag/Cache-Control headers, and returns 304 on matching If-None-Match.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ContentRoute as _content route
  participant AssetVersion as getAssetVersion
  participant FileSystem

  Client->>ContentRoute: GET /_content/path?v=...
  ContentRoute->>AssetVersion: compute currentVersion from file
  AssetVersion-->>ContentRoute: content hash
  ContentRoute->>ContentRoute: compute ETag, Cache-Control
  alt If-None-Match matches ETag
    ContentRoute-->>Client: 304 Not Modified
  else
    ContentRoute->>FileSystem: read file
    FileSystem-->>ContentRoute: file data
    ContentRoute-->>Client: 200 with ETag, Cache-Control
  end
Loading
sequenceDiagram
  participant Content as Markdown/MDX content
  participant Remark as remark-resolve-images
  participant Version as getAssetVersion
  participant Render as MDXImage/page-context

  Content->>Remark: processUrl(src)
  Remark->>Version: versionFor(resolved local path)
  Version-->>Remark: hash or null
  Remark-->>Content: finalizeUrl with ?v=hash
  Render->>Render: splitVersion(src) -> base, version
  Render->>Render: buildOptimizedUrl(base, width, undefined, version)
Loading

Possibly related PRs

  • raystack/chronicle#46: Adds the _content/[...path].ts route and remark-resolve-images plugin that this PR extends with version-aware caching and URL stamping.
  • raystack/chronicle#87: Modifies remark-resolve-images.ts image collection logic (file.data.images) that this PR extends with ?v=... version stamping.
  • raystack/chronicle#110: Modifies image.ts caching/warmup behavior that this PR extends with splitVersion/getAssetVersion-based cache keys.

Suggested reviewers: rohanchkrabrty, paanSinghCoder

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: cache busting replaced images with content-hash URLs.
Description check ✅ Passed The description directly explains the image caching problem, the fix, and the related implementation details.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/image-cache-busting

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

*/
export function getAssetVersion(filePath: string): string | null {
try {
const stat = fs.statSync(filePath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use promise based fs utils

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converted asset-version.ts to fs/promises in 9ffc5fc. The remark plugin now runs as an async transformer (nodes collected in sync visits, then processed with awaits), and the image handler, _content route, warmup, and static scanner all await it.

if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
return cached.hash;
}
const hash = hashContent(fs.readFileSync(filePath));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, use promise-based fs utils

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9ffc5fc — same async conversion covers this call site.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
packages/chronicle/src/lib/asset-version.ts (1)

22-35: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Synchronous fs calls used in request-serving paths.

fs.statSync/fs.readFileSync block the event loop; this was already flagged in a prior review (rsbh: can we use promise based fs utils, rsbh: Same as above, use promise-based fs utils) and remains unaddressed. It's more impactful than a style nit here: downstream evidence shows getAssetVersion is called directly inside request handlers (packages/chronicle/src/server/api/image.ts:104 and packages/chronicle/src/server/routes/_content/[...path].ts:31), so a cache miss triggers a blocking disk read on the request thread, stalling all concurrent requests under load.

Consider converting to fs.promises.stat/readFile (requires making getAssetVersion async and updating its callers).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/lib/asset-version.ts` around lines 22 - 35, The
getAssetVersion helper still uses blocking fs.statSync and fs.readFileSync in a
request-serving path, which can stall the event loop on cache misses. Update
getAssetVersion in asset-version.ts to use promise-based fs APIs
(fs.promises.stat/readFile), make it async, and propagate the async change to
its callers such as the image API handler and the _content route so they await
the version lookup instead of blocking the request thread.
🧹 Nitpick comments (3)
packages/chronicle/src/lib/remark-resolve-images.ts (1)

9-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Relative import instead of path alias.

This new import uses ./asset-version while every other new import added in this PR cohort (image.tsx, page-context.tsx, entry-server.tsx, static-generate.ts) uses the @/lib/... alias.

-import { getAssetVersion } from './asset-version'
+import { getAssetVersion } from '`@/lib/asset-version`'

As per coding guidelines, "Use path alias @/*./src/* configured in tsconfig and vite".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/lib/remark-resolve-images.ts` at line 9, The new
import in remark-resolve-images should use the project path alias instead of a
relative path to match the rest of the PR cohort and the repo’s tsconfig/vite
conventions. Update the getAssetVersion import in remark-resolve-images to use
the `@/lib` alias, consistent with image.tsx, page-context.tsx, entry-server.tsx,
and static-generate.ts, and keep the symbol reference unchanged.

Source: Coding guidelines

packages/chronicle/src/lib/page-context.tsx (1)

19-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Correct, but consider extracting the shared version/optimize branching.

The splitVersionisLocalImage(base) && !isSvg(base) → (webpUrl if static / buildOptimizedUrl otherwise) branching here is nearly identical to the logic in image.tsx (lines 21-27) and entry-server.tsx (lines 158-161). Three independent copies increase the risk of divergence — which has already happened, since entry-server.tsx's copy omits the isStaticMode() branch that this file and image.tsx both have (see comment there). Extracting a shared helper (e.g. resolveImageDisplayUrl(src, isStatic)) into image-utils.ts would remove the duplication and prevent future drift.

Also applies to: 137-144

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/lib/page-context.tsx` at line 19, The image URL
resolution logic in this page-context file is duplicated across `splitVersion`,
`isLocalImage(base) && !isSvg(base)`, and the `webpUrl`/`buildOptimizedUrl`
branching, which should be centralized to avoid divergence. Extract the shared
behavior into a helper in `image-utils.ts` (for example, a resolver like
`resolveImageDisplayUrl`) and update `page-context.tsx`, `image.tsx`, and
`entry-server.tsx` to use it, keeping the `isStaticMode()` handling consistent
wherever `buildOptimizedUrl` is chosen.
packages/chronicle/src/lib/remark-resolve-images.test.ts (1)

80-89: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add regression coverage for path traversal once versionFor is hardened.

Tied to the containment-check fix suggested in remark-resolve-images.ts — consider a test asserting that an image reference like ![alt](../../outside.png) (pointing outside the content root) doesn't get versioned/read from outside contentRoot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/chronicle/src/lib/remark-resolve-images.test.ts` around lines 80 -
89, Add a regression test in remark-resolve-images.test.ts around
transform()/versionFor path handling to cover path traversal. Assert that an
image reference like ../../outside.png does not get versioned or resolved from
outside contentRoot, and that the resulting tree/file.data.images only includes
safe in-root image URLs. Focus the test near the existing img-tag and plain-URL
coverage so the containment behavior is validated alongside version stamping.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/chronicle/src/lib/asset-version.ts`:
- Around line 22-31: getAssetVersion is caching by mtimeMs and size only, which
can return a stale hash after same-size rewrites or timestamp-tick collisions.
Update the invalidation logic in getAssetVersion to use a stronger change signal
than metadata alone, or remove the memoization when metadata cannot reliably
distinguish file content changes; make sure the cache lookup and refresh path in
getAssetVersion always recompute the hash when file contents may have changed.

In `@packages/chronicle/src/lib/remark-resolve-images.ts`:
- Around line 80-97: The version lookup in versionFor is not restricted to
contentRoot, so traversal-style URLs from resolveUrl can escape the content
tree. Update versionFor in remark-resolve-images.ts to verify the decoded rel
path stays contained under contentRoot before calling getAssetVersion, and
return undefined for anything outside that root. Keep the fix localized to
versionFor/path.join handling so processUrl continues to use the sanitized
version value.

In `@packages/chronicle/src/server/api/image.ts`:
- Around line 104-105: The image request path in the API handler is still doing
synchronous filesystem work through getAssetVersion, which blocks the event loop
under load. Update getAssetVersion and its callers, including the image
handler’s cache key path and the warmup flow, to use fs.promises-based async I/O
instead of fs.statSync/fs.readFileSync. Thread the async change through the
relevant functions in image.ts so the currentVersion lookup is awaited before
computing cacheKey.

---

Duplicate comments:
In `@packages/chronicle/src/lib/asset-version.ts`:
- Around line 22-35: The getAssetVersion helper still uses blocking fs.statSync
and fs.readFileSync in a request-serving path, which can stall the event loop on
cache misses. Update getAssetVersion in asset-version.ts to use promise-based fs
APIs (fs.promises.stat/readFile), make it async, and propagate the async change
to its callers such as the image API handler and the _content route so they
await the version lookup instead of blocking the request thread.

---

Nitpick comments:
In `@packages/chronicle/src/lib/page-context.tsx`:
- Line 19: The image URL resolution logic in this page-context file is
duplicated across `splitVersion`, `isLocalImage(base) && !isSvg(base)`, and the
`webpUrl`/`buildOptimizedUrl` branching, which should be centralized to avoid
divergence. Extract the shared behavior into a helper in `image-utils.ts` (for
example, a resolver like `resolveImageDisplayUrl`) and update
`page-context.tsx`, `image.tsx`, and `entry-server.tsx` to use it, keeping the
`isStaticMode()` handling consistent wherever `buildOptimizedUrl` is chosen.

In `@packages/chronicle/src/lib/remark-resolve-images.test.ts`:
- Around line 80-89: Add a regression test in remark-resolve-images.test.ts
around transform()/versionFor path handling to cover path traversal. Assert that
an image reference like ../../outside.png does not get versioned or resolved
from outside contentRoot, and that the resulting tree/file.data.images only
includes safe in-root image URLs. Focus the test near the existing img-tag and
plain-URL coverage so the containment behavior is validated alongside version
stamping.

In `@packages/chronicle/src/lib/remark-resolve-images.ts`:
- Line 9: The new import in remark-resolve-images should use the project path
alias instead of a relative path to match the rest of the PR cohort and the
repo’s tsconfig/vite conventions. Update the getAssetVersion import in
remark-resolve-images to use the `@/lib` alias, consistent with image.tsx,
page-context.tsx, entry-server.tsx, and static-generate.ts, and keep the symbol
reference unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bf6ce132-8d3c-48a9-a591-4a6e95c43324

📥 Commits

Reviewing files that changed from the base of the PR and between f1c3a05 and 7d5d05b.

📒 Files selected for processing (16)
  • packages/chronicle/src/cli/commands/static-generate.ts
  • packages/chronicle/src/components/mdx/image.tsx
  • packages/chronicle/src/lib/asset-version.test.ts
  • packages/chronicle/src/lib/asset-version.ts
  • packages/chronicle/src/lib/image-utils.test.ts
  • packages/chronicle/src/lib/image-utils.ts
  • packages/chronicle/src/lib/page-context.tsx
  • packages/chronicle/src/lib/remark-resolve-images.test.ts
  • packages/chronicle/src/lib/remark-resolve-images.ts
  • packages/chronicle/src/server/api/image.test.ts
  • packages/chronicle/src/server/api/image.ts
  • packages/chronicle/src/server/entry-server.tsx
  • packages/chronicle/src/server/routes/_content/[...path].ts
  • packages/chronicle/src/server/utils/asset-cache.test.ts
  • packages/chronicle/src/server/utils/asset-cache.ts
  • packages/chronicle/src/types/globals.d.ts

Comment thread packages/chronicle/src/lib/asset-version.ts Outdated
Comment thread packages/chronicle/src/lib/remark-resolve-images.ts Outdated
Comment thread packages/chronicle/src/server/api/image.ts Outdated
…ookups to content root, stronger cache invalidation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants