diff --git a/docs/assets/overview.md b/docs/assets/overview.md index f4166141bb1..936558b0275 100644 --- a/docs/assets/overview.md +++ b/docs/assets/overview.md @@ -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`: diff --git a/news/6550.feature.md b/news/6550.feature.md new file mode 100644 index 00000000000..e4a369004fb --- /dev/null +++ b/news/6550.feature.md @@ -0,0 +1 @@ +Added content-hash cache busting to `rx.asset` URLs. diff --git a/reflex/assets.py b/reflex/assets.py index 06e4e739b9a..7b23ed89dfd 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -1,5 +1,6 @@ """Helper functions for adding assets to the app.""" +import hashlib import inspect from pathlib import Path from typing import TYPE_CHECKING, overload @@ -8,6 +9,9 @@ from reflex_base.config import get_config from reflex_base.environment import EnvironmentVariables +_HASH_CHUNK_SIZE = 1024 * 1024 +_MAX_HASH_ATTEMPTS = 3 + if TYPE_CHECKING: from typing_extensions import Buffer @@ -25,13 +29,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", @@ -44,6 +56,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. @@ -56,6 +70,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. @@ -72,7 +87,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]: @@ -87,7 +103,57 @@ 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. + """ + digest = hashlib.sha256() + for _ in range(_MAX_HASH_ATTEMPTS): + digest = hashlib.sha256() + start_stat = path.stat() + with path.open("rb") as file: + while chunk := file.read(_HASH_CHUNK_SIZE): + digest.update(chunk) + end_stat = path.stat() + if ( + start_stat.st_size == end_stat.st_size + and start_stat.st_mtime_ns == end_stat.st_mtime_ns + ): + break + return digest.hexdigest()[:8] + + +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)}" + return AssetPathStr(versioned_path, importable_path=relative_path) def remove_stale_external_asset_symlinks(): @@ -176,7 +242,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. @@ -212,4 +281,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, + ) diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 5baa030653f..8e2e7f81d3b 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -1,8 +1,12 @@ import copy +import hashlib +import io import pickle import shutil from collections.abc import Generator +from dataclasses import dataclass from pathlib import Path +from typing import cast import pytest @@ -11,6 +15,11 @@ from reflex.assets import AssetPathStr, remove_stale_external_asset_symlinks +def _asset_hash(path: Path) -> str: + """Return the expected short content hash for an asset.""" + return hashlib.sha256(path.read_bytes()).hexdigest()[:8] + + @pytest.fixture def mock_asset_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Create a mock asset file and patch the current working directory. @@ -32,9 +41,14 @@ def mock_asset_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def test_shared_asset(mock_asset_path: Path) -> None: """Test shared assets.""" + source_file = Path(__file__).parent / "custom_script.js" + expected_hash = _asset_hash(source_file) + # The asset function copies a file to the app's external assets directory. asset = rx.asset(path="custom_script.js", shared=True, subfolder="subfolder") - assert asset == "/external/test_assets/subfolder/custom_script.js" + assert ( + asset == f"/external/test_assets/subfolder/custom_script.js?v={expected_hash}" + ) result_file = Path( mock_asset_path, "assets", @@ -50,7 +64,7 @@ def test_shared_asset(mock_asset_path: Path) -> None: # Test the asset function without a subfolder. asset = rx.asset(path="custom_script.js", shared=True) - assert asset == "/external/test_assets/custom_script.js" + assert asset == f"/external/test_assets/custom_script.js?v={expected_hash}" result_file = Path( mock_asset_path, "assets", "external", "test_assets", "custom_script.js" ) @@ -107,7 +121,90 @@ def test_local_asset(custom_script_in_asset_dir: Path) -> None: """ asset = rx.asset("custom_script.js", shared=False) - assert asset == "/custom_script.js" + assert asset == f"/custom_script.js?v={_asset_hash(custom_script_in_asset_dir)}" + + +def test_local_asset_hash_changes_with_content( + custom_script_in_asset_dir: Path, +) -> None: + """The asset URL changes when the file content changes. + + Args: + custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory. + """ + custom_script_in_asset_dir.write_text("first") + first_asset = rx.asset("custom_script.js", shared=False) + + custom_script_in_asset_dir.write_text("second") + second_asset = rx.asset("custom_script.js", shared=False) + + assert first_asset != second_asset + assert first_asset == ( + f"/custom_script.js?v={hashlib.sha256(b'first').hexdigest()[:8]}" + ) + assert second_asset == ( + f"/custom_script.js?v={hashlib.sha256(b'second').hexdigest()[:8]}" + ) + + +def test_asset_hash_reads_in_chunks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Hashing handles assets larger than one read chunk. + + Args: + tmp_path: A temporary directory provided by pytest. + monkeypatch: A pytest fixture for patching. + """ + import reflex.assets as assets_module + + monkeypatch.setattr(assets_module, "_HASH_CHUNK_SIZE", 3) + asset_file = tmp_path / "large.bin" + asset_file.write_bytes(b"abcdefghi") + + assert ( + assets_module._short_content_hash(asset_file) + == hashlib.sha256(b"abcdefghi").hexdigest()[:8] + ) + + +def test_asset_hash_retries_when_file_changes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hashing retries when the file changes while it is being read. + + Args: + monkeypatch: A pytest fixture for patching. + """ + import reflex.assets as assets_module + + @dataclass + class _Stat: + st_size: int + st_mtime_ns: int + + class _ChangingPath: + content = b"old" + stat_calls = 0 + + def stat(self) -> _Stat: + self.stat_calls += 1 + if self.stat_calls == 2: + self.content = b"final" + return _Stat(st_size=3, st_mtime_ns=1) + return _Stat(st_size=len(self.content), st_mtime_ns=2) + + def open(self, mode: str): + assert mode == "rb" + return io.BytesIO(self.content) + + monkeypatch.setattr(assets_module, "_HASH_CHUNK_SIZE", 2) + changing_path = _ChangingPath() + + assert ( + assets_module._short_content_hash(cast(Path, changing_path)) + == hashlib.sha256(b"final").hexdigest()[:8] + ) def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None: @@ -117,6 +214,7 @@ def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None: custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory. """ asset = rx.asset("custom_script.js", shared=False) + assert asset == f"/custom_script.js?v={_asset_hash(custom_script_in_asset_dir)}" assert isinstance(asset, AssetPathStr) assert asset.importable_path == "$/public/custom_script.js" @@ -124,6 +222,8 @@ def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None: def test_asset_importable_path_shared(mock_asset_path: Path) -> None: """A shared asset path exposes an `importable_path` prefixed with $/public.""" asset = rx.asset(path="custom_script.js", shared=True) + expected_hash = _asset_hash(Path(__file__).parent / "custom_script.js") + assert asset == f"/external/test_assets/custom_script.js?v={expected_hash}" assert isinstance(asset, AssetPathStr) assert asset.importable_path == "$/public/external/test_assets/custom_script.js" @@ -190,6 +290,28 @@ def prepend_frontend_path(path: str) -> str: assert clone.importable_path == "$/public/external/mod/file.js" +def test_versioned_asset_path_pickle_roundtrip( + custom_script_in_asset_dir: Path, +) -> None: + """Pickle/copy round-trips preserve the versioned URL and unversioned import path. + + Args: + custom_script_in_asset_dir: Fixture that creates a custom_script.js file in the app's assets directory. + """ + original = rx.asset("custom_script.js") + assert original == f"/custom_script.js?v={_asset_hash(custom_script_in_asset_dir)}" + assert original.importable_path == "$/public/custom_script.js" + + for clone in ( + pickle.loads(pickle.dumps(original)), + copy.copy(original), + copy.deepcopy(original), + ): + assert isinstance(clone, AssetPathStr) + assert clone == original + assert clone.importable_path == original.importable_path + + def test_remove_stale_external_asset_symlinks(mock_asset_path: Path) -> None: """Test that stale symlinks and empty dirs in assets/external/ are cleaned up.""" external_dir = (