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..301b7892ae1 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -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 @@ -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", @@ -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. @@ -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. @@ -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]: @@ -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)}" + return AssetPathStr(versioned_path, importable_path=relative_path) def remove_stale_external_asset_symlinks(): @@ -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. @@ -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, + ) diff --git a/tests/units/assets/test_assets.py b/tests/units/assets/test_assets.py index 5baa030653f..22d41af81ad 100644 --- a/tests/units/assets/test_assets.py +++ b/tests/units/assets/test_assets.py @@ -1,8 +1,11 @@ import copy +import hashlib +import io import pickle import shutil from collections.abc import Generator from pathlib import Path +from typing import cast import pytest @@ -11,6 +14,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 +40,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 +63,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 +120,171 @@ 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 + + class _ChangingPath: + open_calls = 0 + + def open(self, mode: str) -> io.BytesIO: + assert mode == "rb" + self.open_calls += 1 + if self.open_calls == 1: + return io.BytesIO(b"old") + return io.BytesIO(b"final") + + 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] + ) + assert changing_path.open_calls == 4 + + +def test_asset_hash_retries_after_atomic_replacement( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hashing retries if the file is replaced with the same size and mtime. + + Args: + monkeypatch: A pytest fixture for patching. + """ + import reflex.assets as assets_module + + class _ReplacingPath: + open_calls = 0 + + def open(self, mode: str) -> io.BytesIO: + assert mode == "rb" + self.open_calls += 1 + if self.open_calls == 1: + return io.BytesIO(b"old") + return io.BytesIO(b"new") + + monkeypatch.setattr(assets_module, "_HASH_CHUNK_SIZE", 2) + replacing_path = _ReplacingPath() + + assert ( + assets_module._short_content_hash(cast(Path, replacing_path)) + == hashlib.sha256(b"new").hexdigest()[:8] + ) + assert replacing_path.open_calls == 4 + + +def test_asset_hash_retries_after_in_place_rewrite( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hashing retries if an in-place rewrite changes bytes but preserves metadata. + + Args: + monkeypatch: A pytest fixture for patching. + """ + import reflex.assets as assets_module + + class _RewritingPath: + open_calls = 0 + reads = [b"oldnew", b"newnew", b"newnew", b"newnew"] + + def open(self, mode: str) -> io.BytesIO: + assert mode == "rb" + self.open_calls += 1 + return io.BytesIO(self.reads[self.open_calls - 1]) + + monkeypatch.setattr(assets_module, "_HASH_CHUNK_SIZE", 3) + rewriting_path = _RewritingPath() + + assert ( + assets_module._short_content_hash(cast(Path, rewriting_path)) + == hashlib.sha256(b"newnew").hexdigest()[:8] + ) + assert rewriting_path.open_calls == 4 + + +def test_asset_hash_uses_timestamp_when_file_never_stabilizes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hashing falls back to a timestamp if every read sees a file change. + + Args: + monkeypatch: A pytest fixture for patching. + """ + import reflex.assets as assets_module + + class _ChangingPath: + open_calls = 0 + + def open(self, mode: str) -> io.BytesIO: + assert mode == "rb" + self.open_calls += 1 + return io.BytesIO(str(self.open_calls).encode()) + + warn_calls: list[str] = [] + monkeypatch.setattr(assets_module.time, "time", lambda: 1234.5) + monkeypatch.setattr(assets_module.console, "warn", warn_calls.append) + + result = assets_module._short_content_hash(cast(Path, _ChangingPath())) + + assert result == "1234.5" + assert len(warn_calls) == 1 + assert warn_calls[0].endswith( + f"was modified {assets_module._MAX_HASH_ATTEMPTS} times while calculating hash." + ) def test_asset_importable_path_local(custom_script_in_asset_dir: Path) -> None: @@ -117,6 +294,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 +302,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 +370,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 = (