Repository: zauberzeug/nicegui
Pull request #4540 (2025-03-28 02:49:27)

Support ui.run(cache_control_directives=)
https://github.com/zauberzeug/nicegui/discussions/4532#discussioncomment-12643633

TL-DR: 

* Cache the static assets more aggressively, but we don't know how much is enough, and afraid overdo it. 
* Expose `cache_control_directives` argument in `ui.run` and `ui.run_with`, with default being long 1-year cache*
* Better than original solution because: 
  * Centralized management, as @falkoschindler pointed out
  * Ability to set other directives such as `immutable` (not available for Chrome), `stale-while-revalidate` (available for Chrome)

Concerns: 

* Is `ui.run` and `ui.run_with` the best place to add the parameter? 
* *Should we set the default to "public, max-age=3600" to match the default behaviour from before the PR (maximize compatibility), or let the better (?) 1-year-cache be the default (maximize rollout of performance increase)?

Testing: 

```py
ui.run()
```

<img width="689" alt="{28E98406-CA70-42CE-94FA-8C894D1E6E5C}" src="https://github.com/user-attachments/assets/de133236-966a-4fdf-9b22-df442db9962c" />

```py
ui.run(cache_control_directives='no-store')
```

<img width="586" alt="{40FAC44F-07B0-44B8-910D-F0A31875726A}" src="https://github.com/user-attachments/assets/7590b019-f9ab-47df-8883-251a56de91c0" />




evnchn: 1. Sorry for that oversight. Should be "cache control directives for NiceGUI internal files"
2. According to the config, a request made to `http://nginx.server/nicegui/hello` would be relayed to `http://localhost:8080/hello`. 

```
        location ~ ^/nicegui/(.*)$ {
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_set_header Authorization $http_authorization;
            proxy_pass_header Authorization;

            proxy_pass http://app:8080/$1?$args;
            proxy_set_header X-Forwarded-Prefix /nicegui;
            proxy_redirect http://app:8080/ /nicegui/;
```

- It is mentioned that `the entire app is available below "/nicegui" but the code does not need to know that.`, so the only way the line `if request.url.path.startswith(f'/_nicegui/{__version__}/'):` fails, is if it parses `X-Forwarded-Prefix` and mutates the path, which would be a security vulnerability (what's to stop me sending such header directly to a bare NiceGUI server, [cough cough Next.js](https://nextjs.org/blog/cve-2025-29927) 🤡), and just doesn't make any sense. 

evnchn: Final note: If you want to add this PR before cachebusting PR is merged, then set the default parameter of `cache_control_directives` to be exactly what would be served before this PR (`public, max-age=3600`)

But I persoanlly don't recommend rushing it. Seems a bad look if we add a parameter only that the default value changes from one version to another in a minor release. 
evnchn: There seems to be a circular import issue which I can't work around, making it impossible to access the app config: 

```
  File "C:\Users\User\Documents\cachebusting\.venv\Lib\site-packages\nicegui\app\__init__.py", line 1, in <module>
    from .app import App
  File "C:\Users\User\Documents\cachebusting\.venv\Lib\site-packages\nicegui\app\app.py", line 14, in <module>
    from ..client import Client
  File "C:\Users\User\Documents\cachebusting\.venv\Lib\site-packages\nicegui\client.py", line 19, in <module>
    from .dependencies import generate_resources
  File "C:\Users\User\Documents\cachebusting\.venv\Lib\site-packages\nicegui\dependencies.py", line 10, in <module>
    from .helpers import hash_file_path_and_contents
```

So any attempt at importing the app for the config from helpers.py, will lead to circular import. 
evnchn: Unblocked since #4539 merged
evnchn: Since #4778 is merged, I need to modify this to make sure this PR doesn't cache the dynamic response endpoint. 

Patch:
diff --git a/CITATION.cff b/CITATION.cff
index a5d231b2..c080830f 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,8 +8,8 @@ authors:
     given-names: Rodja
     orcid: https://orcid.org/0009-0009-4735-6227
 title: "NiceGUI: Web-based user interfaces with Python. The nice way."
-version: 2.18.0
-date-released: "2025-05-23"
+version: 2.19.0
+date-released: "2025-06-02"
 url: https://github.com/zauberzeug/nicegui
 doi: 10.5281/zenodo.7785516
 identifiers:
diff --git a/development.dockerfile b/development.dockerfile
index c4c17109..67dbf0e5 100644
--- a/development.dockerfile
+++ b/development.dockerfile
@@ -2,11 +2,10 @@ FROM python:3.8-slim
 
 RUN apt update && apt install curl build-essential -y
 
-# We use Poetry for dependency management
-RUN curl -sSL https://install.python-poetry.org | python3 - && \
-    cd /usr/local/bin && \
-    ln -s ~/.local/bin/poetry && \
-    poetry config virtualenvs.create false
+# keep toolchain compatible with Python 3.8
+RUN python -m pip install --no-cache-dir --upgrade "virtualenv<20.31" "pip<25" "setuptools<80" wheel \
+    && python -m pip install --no-cache-dir "poetry~=1.8" \
+    && poetry config virtualenvs.create false
 
 WORKDIR /app
 
diff --git a/nicegui/app/app_config.py b/nicegui/app/app_config.py
index 579d586d..21a5f173 100644
--- a/nicegui/app/app_config.py
+++ b/nicegui/app/app_config.py
@@ -33,6 +33,7 @@ class AppConfig:
     binding_refresh_interval: float = field(init=False)
     reconnect_timeout: float = field(init=False)
     message_history_length: int = field(init=False)
+    cache_control_directives: str = field(init=False)
     tailwind: bool = field(init=False)
     prod_js: bool = field(init=False)
     show_welcome_message: bool = field(init=False)
@@ -49,6 +50,7 @@ class AppConfig:
                        binding_refresh_interval: float,
                        reconnect_timeout: float,
                        message_history_length: int,
+                       cache_control_directives: str = 'public, max-age=31536000, immutable, stale-while-revalidate=31536000',
                        tailwind: bool,
                        prod_js: bool,
                        show_welcome_message: bool,
@@ -63,6 +65,7 @@ class AppConfig:
         self.binding_refresh_interval = binding_refresh_interval
         self.reconnect_timeout = reconnect_timeout
         self.message_history_length = message_history_length
+        self.cache_control_directives = cache_control_directives
         self.tailwind = tailwind
         self.prod_js = prod_js
         self.show_welcome_message = show_welcome_message
diff --git a/nicegui/middlewares.py b/nicegui/middlewares.py
index 608695b3..4f0399cc 100644
--- a/nicegui/middlewares.py
+++ b/nicegui/middlewares.py
@@ -2,6 +2,9 @@ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoin
 from starlette.requests import Request
 from starlette.responses import Response
 
+from . import core
+from .version import __version__
+
 
 class RedirectWithPrefixMiddleware(BaseHTTPMiddleware):
 
@@ -12,3 +15,13 @@ class RedirectWithPrefixMiddleware(BaseHTTPMiddleware):
             new_location = prefix + response.headers['Location']
             response.headers['Location'] = new_location
         return response
+
+
+class SetCacheControlMiddleware(BaseHTTPMiddleware):
+
+    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+        response = await call_next(request)
+        if request.url.path.startswith(f'/_nicegui/{__version__}/') \
+                and not request.url.path.startswith(f'/_nicegui/{__version__}/dynamic_resources/'):
+            response.headers['Cache-Control'] = core.app.config.cache_control_directives
+        return response
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 87ab0f07..42f9582f 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -75,16 +75,14 @@ def _get_library(key: str) -> FileResponse:
         if is_map:
             path = path.with_name(path.name + '.map')
         if path.exists():
-            headers = {'Cache-Control': 'public, max-age=3600'}
-            return FileResponse(path, media_type='text/javascript', headers=headers)
+            return FileResponse(path, media_type='text/javascript')
     raise HTTPException(status_code=404, detail=f'library "{key}" not found')
 
 
 @app.get(f'/_nicegui/{__version__}' + '/components/{key:path}')
 def _get_component(key: str) -> FileResponse:
     if key in js_components and js_components[key].path.exists():
-        headers = {'Cache-Control': 'public, max-age=3600'}
-        return FileResponse(js_components[key].path, media_type='text/javascript', headers=headers)
+        return FileResponse(js_components[key].path, media_type='text/javascript')
     raise HTTPException(status_code=404, detail=f'component "{key}" not found')
 
 
@@ -97,9 +95,8 @@ def _get_resource(key: str, path: str) -> FileResponse:
         except ValueError as e:
             raise HTTPException(status_code=403, detail='forbidden') from e
         if filepath.exists():
-            headers = {'Cache-Control': 'public, max-age=3600'}
             media_type, _ = mimetypes.guess_type(filepath)
-            return FileResponse(filepath, media_type=media_type, headers=headers)
+            return FileResponse(filepath, media_type=media_type)
     raise HTTPException(status_code=404, detail=f'resource "{key}" not found')
 
 
diff --git a/nicegui/ui_run.py b/nicegui/ui_run.py
index 99407d39..24794420 100644
--- a/nicegui/ui_run.py
+++ b/nicegui/ui_run.py
@@ -17,7 +17,7 @@ from .air import Air
 from .client import Client
 from .language import Language
 from .logging import log
-from .middlewares import RedirectWithPrefixMiddleware
+from .middlewares import RedirectWithPrefixMiddleware, SetCacheControlMiddleware
 from .server import CustomServerConfig, Server
 
 APP_IMPORT_STRING = 'nicegui:app'
@@ -56,6 +56,7 @@ def run(*,
         binding_refresh_interval: float = 0.1,
         reconnect_timeout: float = 3.0,
         message_history_length: int = 1000,
+        cache_control_directives: str = 'public, max-age=31536000, immutable, stale-while-revalidate=31536000',
         fastapi_docs: Union[bool, DocsConfig] = False,
         show: bool = True,
         on_air: Optional[Union[str, Literal[True]]] = None,
@@ -90,6 +91,7 @@ def run(*,
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param reconnect_timeout: maximum time the server waits for the browser to reconnect (default: 3.0 seconds)
     :param message_history_length: maximum number of messages that will be stored and resent after a connection interruption (default: 1000, use 0 to disable, *added in version 2.9.0*)
+    :param cache_control_directives: cache control directives for internal static files (default: `'public, max-age=31536000, immutable, stale-while-revalidate=31536000'`)
     :param fastapi_docs: enable FastAPI's automatic documentation with Swagger UI, ReDoc, and OpenAPI JSON (bool or dictionary as described `here <https://fastapi.tiangolo.com/tutorial/metadata/>`_, default: `False`, *updated in version 2.9.0*)
     :param show: automatically open the UI in a browser tab (default: `True`)
     :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation/section_configuration_deployment#nicegui_on_air>`_ if set to `True` (default: disabled)
@@ -119,6 +121,7 @@ def run(*,
         binding_refresh_interval=binding_refresh_interval,
         reconnect_timeout=reconnect_timeout,
         message_history_length=message_history_length,
+        cache_control_directives=cache_control_directives,
         tailwind=tailwind,
         prod_js=prod_js,
         show_welcome_message=show_welcome_message,
@@ -127,6 +130,7 @@ def run(*,
     if not helpers.is_pytest():
         core.app.add_middleware(GZipMiddleware)
     core.app.add_middleware(RedirectWithPrefixMiddleware)
+    core.app.add_middleware(SetCacheControlMiddleware)
 
     for route in core.app.routes:
         if not isinstance(route, Route):
diff --git a/nicegui/ui_run_with.py b/nicegui/ui_run_with.py
index 9c57b3be..f054c1a5 100644
--- a/nicegui/ui_run_with.py
+++ b/nicegui/ui_run_with.py
@@ -8,7 +8,7 @@ from fastapi.middleware.gzip import GZipMiddleware
 from . import core, storage
 from .air import Air
 from .language import Language
-from .middlewares import RedirectWithPrefixMiddleware
+from .middlewares import RedirectWithPrefixMiddleware, SetCacheControlMiddleware
 from .nicegui import _shutdown, _startup
 
 
@@ -22,6 +22,7 @@ def run_with(
     binding_refresh_interval: float = 0.1,
     reconnect_timeout: float = 3.0,
     message_history_length: int = 1000,
+    cache_control_directives: str = 'public, max-age=31536000, immutable, stale-while-revalidate=31536000',
     mount_path: str = '/',
     on_air: Optional[Union[str, Literal[True]]] = None,
     tailwind: bool = True,
@@ -40,6 +41,7 @@ def run_with(
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param reconnect_timeout: maximum time the server waits for the browser to reconnect (default: 3.0 seconds)
     :param message_history_length: maximum number of messages that will be stored and resent after a connection interruption (default: 1000, use 0 to disable, *added in version 2.9.0*)
+    :param cache_control_directives: cache control directives for internal static files (default: `'public, max-age=31536000, immutable, stale-while-revalidate=31536000'`)
     :param mount_path: mount NiceGUI at this path (default: `'/'`)
     :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation/section_configuration_deployment#nicegui_on_air>`_ if set to `True` (default: disabled)
     :param tailwind: whether to use Tailwind CSS (experimental, default: `True`)
@@ -60,10 +62,12 @@ def run_with(
         tailwind=tailwind,
         prod_js=prod_js,
         show_welcome_message=show_welcome_message,
+        cache_control_directives=cache_control_directives,
     )
     storage.set_storage_secret(storage_secret)
     core.app.add_middleware(GZipMiddleware)
     core.app.add_middleware(RedirectWithPrefixMiddleware)
+    core.app.add_middleware(SetCacheControlMiddleware)
 
     if on_air:
         core.air = Air('' if on_air is True else on_air)
diff --git a/pyproject.toml b/pyproject.toml
index 2c1a723d..ce52e632 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.18.0-dev"
+version = "2.19.0-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
diff --git a/website/documentation/content/page_documentation.py b/website/documentation/content/page_documentation.py
index 75425206..4194f990 100644
--- a/website/documentation/content/page_documentation.py
+++ b/website/documentation/content/page_documentation.py
@@ -107,4 +107,4 @@ def api_router_demo():
     #
     # app.include_router(router)
     # END OF DEMO
-    ui.label('Shows up on /sub-path')
+    ui.label('This is content on /sub-path')
diff --git a/website/documentation/content/section_configuration_deployment.py b/website/documentation/content/section_configuration_deployment.py
index 83e85a60..342c02be 100644
--- a/website/documentation/content/section_configuration_deployment.py
+++ b/website/documentation/content/section_configuration_deployment.py
@@ -29,14 +29,14 @@ doc.intro(run_documentation)
     You can enable native mode for NiceGUI by specifying `native=True` in the `ui.run` function.
     To customize the initial window size and display mode, use the `window_size` and `fullscreen` parameters respectively.
     Additionally, you can provide extra keyword arguments via `app.native.window_args` and `app.native.start_args`.
-    Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/guide/api.html)
+    Pick any parameter as it is defined by the internally used [pywebview module](https://pywebview.flowrl.com/api)
     for the `webview.create_window` and `webview.start` functions.
     Note that these keyword arguments will take precedence over the parameters defined in `ui.run`.
 
     Additionally, you can change `webview.settings` via `app.native.settings`.
 
     In native mode the `app.native.main_window` object allows you to access the underlying window.
-    It is an async version of [`Window` from pywebview](https://pywebview.flowrl.com/guide/api.html#window-object).
+    It is an async version of [`Window` from pywebview](https://pywebview.flowrl.com/api/#webview-window).
 ''', tab=lambda: ui.label('NiceGUI'))
 def native_mode_demo():
     from nicegui import app
diff --git a/website/sponsors.json b/website/sponsors.json
index dd552db6..64c029f5 100644
--- a/website/sponsors.json
+++ b/website/sponsors.json
@@ -1,5 +1,5 @@
 {
   "top": [],
-  "total": 20,
-  "contributors": 158
+  "total": 21,
+  "contributors": 162
 }


Test patch:
diff --git a/tests/test_serving_files.py b/tests/test_serving_files.py
index 582e5329..0784f1aa 100644
--- a/tests/test_serving_files.py
+++ b/tests/test_serving_files.py
@@ -55,13 +55,13 @@ def test_adding_single_media_file(screen: Screen):
 
 @pytest.mark.parametrize('url_path', ['/static', '/static/'])
 def test_get_from_static_files_dir(url_path: str, screen: Screen):
-    app.add_static_files(url_path, Path(TEST_DIR).parent)
+    app.add_static_files(url_path, Path(TEST_DIR).parent, max_cache_age=3456)
 
     screen.open('/')
     with httpx.Client() as http_client:
         r = http_client.get(f'http://localhost:{Screen.PORT}/static/examples/slideshow/slides/slide1.jpg')
         assert r.status_code == 200
-        assert 'max-age=' in r.headers['Cache-Control']
+        assert 'max-age=3456' in r.headers['Cache-Control']
 
 
 def test_404_for_non_existing_static_file(screen: Screen):
@@ -76,13 +76,13 @@ def test_404_for_non_existing_static_file(screen: Screen):
 
 
 def test_adding_single_static_file(screen: Screen):
-    url_path = app.add_static_file(local_file=IMAGE_FILE)
+    url_path = app.add_static_file(local_file=IMAGE_FILE, max_cache_age=3456)
 
     screen.open('/')
     with httpx.Client() as http_client:
         r = http_client.get(f'http://localhost:{Screen.PORT}{url_path}')
         assert r.status_code == 200
-        assert 'max-age=' in r.headers['Cache-Control']
+        assert 'max-age=3456' in r.headers['Cache-Control']
 
 
 def test_auto_serving_file_from_image_source(screen: Screen):
@@ -118,3 +118,23 @@ def test_mimetypes_of_static_files(screen: Screen):
     response = requests.get(f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/static/nicegui.css', timeout=5)
     assert response.status_code == 200
     assert response.headers['Content-Type'].startswith('text/css')
+
+
+def test_cache_control_header_of_static_files(screen: Screen):
+    ui.markdown()
+    app.add_static_files('/static', Path(TEST_DIR).parent)
+
+    screen.open('/')
+
+    # resources are served with cache-control headers from `ui.run`
+    response1 = requests.get(f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/static/nicegui.css', timeout=5)
+    assert 'immutable' in response1.headers.get('Cache-Control', '')
+
+    # dynamic resources are _not_ served with cache-control headers from `ui.run`
+    response2 = requests.get(
+        f'http://localhost:{Screen.PORT}/_nicegui/{__version__}/dynamic_resources/codehilite.css', timeout=5)
+    assert 'immutable' not in response2.headers.get('Cache-Control', '')
+
+    # static resources are _not_ served with cache-control headers from `ui.run`
+    response3 = requests.get(f'http://localhost:{Screen.PORT}/static/examples/slideshow/slides/slide1.jpg', timeout=5)
+    assert 'immutable' not in response3.headers.get('Cache-Control', '')