Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/assets/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ rx.image(

The `rx.asset` function provides a more flexible way to reference assets in your app. It supports both local assets (in the app's `assets/` directory) and shared assets (placed next to your Python files).

`rx.asset` appends a short content hash query parameter to the generated URL. For example, `rx.asset("logo.svg")` returns a URL like `/logo.svg?v=a1b2c3d4`, so browsers fetch a new version only when the file content changes.

#### Local Assets

Local assets are stored in the app's `assets/` directory and are referenced using `rx.asset`:
Expand Down
1 change: 1 addition & 0 deletions news/6550.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added content-hash cache busting to `rx.asset` URLs.
96 changes: 91 additions & 5 deletions reflex/assets.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""Helper functions for adding assets to the app."""

import hashlib
import inspect
import time
from pathlib import Path
from typing import TYPE_CHECKING, overload

from reflex_base import constants
from reflex_base.config import get_config
from reflex_base.environment import EnvironmentVariables
from reflex_base.utils import console

_HASH_CHUNK_SIZE = 1024 * 1024
_MAX_HASH_ATTEMPTS = 3

if TYPE_CHECKING:
from typing_extensions import Buffer
Expand All @@ -25,13 +31,21 @@ class AssetPathStr(str):
construction time.
"""

__slots__ = ("importable_path",)
__slots__ = ("_raw_path", "importable_path")

_raw_path: str
importable_path: str

@overload
def __new__(cls, object: object = "") -> "AssetPathStr": ...
@overload
def __new__(
cls,
object: object = "",
*,
importable_path: str | None = None,
) -> "AssetPathStr": ...
@overload
def __new__(
cls,
object: "Buffer",
Expand All @@ -44,6 +58,8 @@ def __new__(
object: object = "",
encoding: str | None = None,
errors: str | None = None,
*,
importable_path: str | None = None,
) -> "AssetPathStr":
"""Construct from an unprefixed, leading-slash asset path.

Expand All @@ -56,6 +72,7 @@ def __new__(
object: The object to stringify (str, bytes, or any object).
encoding: Encoding to decode ``object`` with when it is bytes-like.
errors: Error handler for decoding.
importable_path: Optional unversioned path to use for build-time imports.

Returns:
A new ``AssetPathStr`` instance.
Expand All @@ -72,7 +89,8 @@ def __new__(
instance = super().__new__(
cls, get_config().prepend_frontend_path(relative_path)
)
instance.importable_path = f"$/public{relative_path}"
instance._raw_path = relative_path
instance.importable_path = f"$/public{importable_path or relative_path}"
return instance

def __getnewargs__(self) -> tuple[str]:
Expand All @@ -87,7 +105,69 @@ def __getnewargs__(self) -> tuple[str]:
Returns:
A one-tuple containing the unprefixed asset path.
"""
return (self.importable_path[len("$/public") :],)
return (self._raw_path,)

def __getnewargs_ex__(self) -> tuple[tuple[str], dict[str, str]]:
"""Return constructor args and kwargs for pickle/copy reconstruction.

Returns:
Constructor args and kwargs preserving the unversioned import path.
"""
return (
(self._raw_path,),
{"importable_path": self.importable_path[len("$/public") :]},
)


def _short_content_hash(path: Path) -> str:
"""Get a short content hash for an asset file.

Args:
path: The file to hash.

Returns:
The first 8 hex characters of the file's SHA-256 hash.
"""
for _ in range(_MAX_HASH_ATTEMPTS):
digest = _content_digest(path)
if digest == _content_digest(path):
break
else:
console.warn(
f"{path} was modified {_MAX_HASH_ATTEMPTS} times while calculating hash."
)
return str(time.time())
return digest[:8]


def _content_digest(path: Path) -> str:
"""Get the SHA-256 digest for the current file content.

Args:
path: The file to hash.

Returns:
The full SHA-256 digest for the file content.
"""
digest = hashlib.sha256()
with path.open("rb") as file:
while chunk := file.read(_HASH_CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()


def _versioned_asset_path(relative_path: str, source_file: Path) -> AssetPathStr:
"""Create an asset URL with a content-hash query parameter.

Args:
relative_path: The unprefixed public asset path.
source_file: The source file used to calculate the content hash.

Returns:
A versioned asset URL that preserves an unversioned import path.
"""
versioned_path = f"{relative_path}?v={_short_content_hash(source_file)}"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
masenf marked this conversation as resolved.
return AssetPathStr(versioned_path, importable_path=relative_path)


def remove_stale_external_asset_symlinks():
Expand Down Expand Up @@ -176,7 +256,10 @@ def asset(
if not backend_only and not src_file_local.exists():
msg = f"File not found: {src_file_local}"
raise FileNotFoundError(msg)
return AssetPathStr(f"/{path}")
relative_path = f"/{path}"
if backend_only and not src_file_local.exists():
return AssetPathStr(relative_path)
return _versioned_asset_path(relative_path, src_file_local)

# Shared asset handling
# Determine the file by which the asset is exposed.
Expand Down Expand Up @@ -212,4 +295,7 @@ def asset(
dst_file.unlink()
dst_file.symlink_to(src_file_shared)

return AssetPathStr(f"/{external}/{subfolder}/{path}")
return _versioned_asset_path(
f"/{external}/{subfolder}/{path}",
src_file_shared,
)
Loading
Loading