Delegate static file serving to application dependencies

Commit cfbb512 · patx · 2026-10-18T16:46:17-04:00

Changeset
cfbb512a8b708e963745ee03ee5d4e69e8f7c814
Parents
24c7fe52225932ec666c9e38d215a13b326ef376

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index ad53df9..56ff83d 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Your API needs to return some JSON. It probably doesn't need a goddamn cathedral
 
 - Async handlers, explicit HTTP methods, and fixed or parameterized routes.
 - Headers, query parameters, cached body reads, and JSON parsing.
-- JSON responses, custom response tuples, and a small-file helper.
+- JSON responses and custom response tuples.
 - Callable ASGI response support and routing relative to mounted paths.
 - Depends on `weft-server` and `orjson`. You can use any ASGI server.
 
@@ -16,7 +16,7 @@ Your API needs to return some JSON. It probably doesn't need a goddamn cathedral
 
 Auth, sessions, databases, validation schemas, templates, form or multipart
 parsing, CORS, CSRF protection, rate limiting, dependency injection, generated
-docs, background jobs, WebSockets, or built-in streaming.
+docs, background jobs, WebSockets, static file serving, or built-in streaming.
 
 Use existing ASGI middleware for CORS, authentication, sessions, or compression.
 [Starlette's middleware](https://starlette.dev/middleware/#using-middleware-in-other-frameworks)
@@ -141,35 +141,39 @@ body through `(scope, receive, send)`. Reuse external response classes for
 streaming, downloads, or SSE. Use ASGI middleware for behavior across routes.
 For example Starlette's StreamingResponse can be returned directly in a Wred handler.
 
-## Static files
+## Serving the frontend
+
+Use whatever you want to serve your frontend. Wred is an ASGI API backend;
+frontend hosting and build tools are your application's choice. Caddy, Nginx,
+Vite, ServeStatic, and Starlette are some options. Other ASGI middleware, web
+servers, frontend dev servers, CDNs, and static hosting services work too.
+
+Serving the frontend and API from one origin lets browser code use relative
+URLs such as `fetch('/api/hello')` without CORS configuration. If you use
+separate origins, configure CORS in middleware and account for cookie and
+credential rules. Opening HTML directly with `file://` does not avoid those
+browser restrictions.
+
+Starlette's [`FileResponse`](https://starlette.dev/responses/#fileresponse)
+can be returned directly from a Wred handler to stream a particular file,
+including support for HTTP range requests. Install Starlette in your application
+with `pip install starlette`, then use it for a known file path:
 
 ```python
+from pathlib import Path
+from starlette.responses import FileResponse
+
+FRONTEND_ROOT = Path(__file__).resolve().parent / 'frontend'
+
 @app.route('/')
 async def index(request):
-    return await app.static('index.html')
-
[email protected]('/static/{filename}')
-async def asset(request, filename):
-    try:
-        return await app.static(filename)
-    except (OSError, ValueError):
-        return {'error': 'Not Found'}, 'application/json', 404
+    return FileResponse(FRONTEND_ROOT / 'index.html')
 ```
 
-`static(filename, root='static')` returns `(bytes, content_type, status)`.
-Omitting `root`, passing `''`, or passing `None` uses the `static` directory in the
-current working directory. Set `root` to a string or `Path` to use another directory.
-MIME types are guessed. Files are read in a worker thread, entirely into memory.
-All file and path errors propagate to your handler, middleware, or ASGI server.
-Missing files raise `FileNotFoundError`, directories raise `IsADirectoryError`,
-and permission or other filesystem failures retain their original exceptions.
-Resolved paths outside the configured root, including symlink escapes, raise
-`ValueError` before any file is read. Invalid paths can also raise `ValueError`.
-The helper does not turn these errors into HTTP responses; catch exceptions in
-your application or middleware to return a 404 or another status.
-Small files. Don't feed it a movie.
-
-Use Nginx or a similar web server to serve your app's static files in production.
+For a directory of files, Starlette's
+[`StaticFiles`](https://starlette.dev/staticfiles/) can be mounted alongside
+Wred using an external ASGI router, as described under [Routes](#routes).
+The notebook example uses ServeStatic; neither library is required by Wred.
 
 ## Errors
 
diff --git a/docs/README.md b/docs/README.md
index d1960db..ca09b69 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -16,4 +16,16 @@ app = App()
 @app.route("/")
 async def index(request):
     return {"hello": "world"}
-```
\ No newline at end of file
+```
+
+## Serving the frontend
+
+Use whatever you want to serve your frontend. Wred works with ASGI middleware
+such as ServeStatic, Starlette's
+[`FileResponse`](https://starlette.dev/responses/#fileresponse) and
+[`StaticFiles`](https://starlette.dev/staticfiles/), web servers such as Caddy,
+frontend dev servers such as Vite, and other hosting or build tools. These are
+options, not requirements.
+See the [frontend serving examples](https://gitman.io/patx/wred#serving-the-frontend)
+in the full README. The notebook example uses PyMongo and ServeStatic as its
+own dependencies.
diff --git a/example_app/app.py b/example_app/app.py
index 2b4a558..acdf67b 100644
--- a/example_app/app.py
+++ b/example_app/app.py
@@ -12,6 +12,9 @@
 """
 
 import os
+from pathlib import Path
+
+from servestatic import ServeStaticASGI
 
 from wred import App
 
@@ -19,6 +22,8 @@ from . import auth, notes, pages
 from .rate_limit import RateLimiter
 from .storage import Database
 
+STATIC_ROOT = Path(__file__).resolve().parent / 'static'
+
 
 class NotebookApp:
     """Own the example's database lifecycle while keeping Wred HTTP-only."""
@@ -30,6 +35,14 @@ class NotebookApp:
         pages.register_routes(self.http, rate_limit)
         auth.register_routes(self.http, self.database, rate_limit)
         notes.register_routes(self.http, self.database)
+        self.http = ServeStaticASGI(
+            self.http,
+            root=str(STATIC_ROOT),
+            index_file=True,
+            autorefresh=True,
+            max_age=0,
+        )
+        self.http.add_files(str(STATIC_ROOT), prefix='static/')
 
     async def __call__(self, scope, receive, send):
         if scope['type'] != 'lifespan':
diff --git a/example_app/asgi.py b/example_app/asgi.py
index 405da61..6061506 100644
--- a/example_app/asgi.py
+++ b/example_app/asgi.py
@@ -5,11 +5,12 @@
 
     Run the notebook demo with MongoDB listening on localhost:27017::
 
-        python -m pip install -e . pymongo
+        python -m pip install -e . pymongo servestatic
         weft example_app.asgi:app
 
     Open http://127.0.0.1:8000. MongoDB preserves accounts, sessions,
-    and notes.
+    and notes. ServeStatic serves the frontend in the same ASGI process,
+    with file refresh and caching settings configured for local development.
     Set MONGODB_URI and MONGODB_DATABASE to choose another server/database.
     Rate limits are isolated per app and process-local. Use HTTPS outside
     localhost.
diff --git a/example_app/pages.py b/example_app/pages.py
index 4d32a01..813fab6 100644
--- a/example_app/pages.py
+++ b/example_app/pages.py
@@ -3,33 +3,15 @@
     example_app.pages
     ~~~~~~~~~~~~~~~~~
 
-    Public page/assets, redirect, health check, and a rate-limit demonstration.
+    Redirect, health check, and a rate-limit demonstration.
 
     :copyright: (c) 2026 by Harrison Erd.
     :license: BSD-3-Clause, see LICENSE for more details.
 """
 
-from pathlib import Path
-
-STATIC_ROOT = Path(__file__).parent / 'static'
-
 
 def register_routes(app, rate_limit):
-    """Register the browser UI and public demonstration endpoints."""
-
-    @app.route('/')
-    async def home(request):
-        body, content_type, status = await app.static(
-            'index.html', root=STATIC_ROOT
-        )
-        return body, content_type, status, [('Cache-Control', 'no-cache')]
-
-    @app.route('/static/{filename}')
-    async def assets(request, filename):
-        body, content_type, status = await app.static(
-            filename, root=STATIC_ROOT
-        )
-        return body, content_type, status, [('Cache-Control', 'no-cache')]
+    """Register public demonstration endpoints; ServeStatic handles files."""
 
     @app.route('/ui')
     async def old_ui(request):
diff --git a/tests/test_example_app.py b/tests/test_example_app.py
index 1f63187..119b578 100644
--- a/tests/test_example_app.py
+++ b/tests/test_example_app.py
@@ -14,8 +14,66 @@ import sys
 import unittest
 from pathlib import Path
 
+import orjson
+
+from tests.test_wred import call_app
+
 
 class AppFactoryTests(unittest.IsolatedAsyncioTestCase):
+    async def test_public_files_and_api_share_one_application(self):
+        from example_app.app import create_app
+
+        app = create_app(database=object())
+
+        async def request(path):
+            async def receive():
+                raise AssertionError('Public endpoints should not read a body')
+
+            messages = await call_app(
+                app,
+                {
+                    'type': 'http',
+                    'path': path,
+                    'method': 'GET',
+                    'headers': [],
+                    'query_string': b'',
+                },
+                receive,
+            )
+            self.assertEqual(messages[0]['type'], 'http.response.start')
+            self.assertEqual(messages[-1]['type'], 'http.response.body')
+            self.assertFalse(messages[-1].get('more_body', False))
+            return (
+                messages[0]['status'],
+                b''.join(message.get('body', b'') for message in messages[1:]),
+                dict(messages[0]['headers']),
+            )
+
+        for path, content_type, marker in [
+            ('/', b'text/html', b'Wred notebook'),
+            ('/static/app.css', b'text/css', b'.workspace'),
+            ('/static/app.js', b'text/javascript', b'/api/notes'),
+        ]:
+            with self.subTest(path=path):
+                status, body, headers = await request(path)
+                self.assertEqual(status, 200)
+                self.assertEqual(
+                    headers[b'content-type'].split(b';')[0], content_type
+                )
+                self.assertIn(b'max-age=0', headers[b'cache-control'])
+                self.assertIn(marker, body)
+
+        status, body, headers = await request('/ui')
+        self.assertEqual((status, body, headers[b'location']), (303, b'', b'/'))
+        status, body, headers = await request('/api/health')
+        self.assertEqual((status, orjson.loads(body)), (200, {'status': 'ok'}))
+        for path in ['/static/missing.js', '/auth.py', '/static/../auth.py']:
+            with self.subTest(path=path):
+                status, body, _ = await request(path)
+                self.assertEqual(
+                    (status, orjson.loads(body)), (404, {'error': 'Not Found'})
+                )
+
     def test_import_factory_does_not_construct_a_database(self):
         script = """
 from unittest.mock import patch
diff --git a/tests/test_notebook.py b/tests/test_notebook.py
index ffabb21..58b18ef 100644
--- a/tests/test_notebook.py
+++ b/tests/test_notebook.py
@@ -131,28 +131,6 @@ class NotebookTests(unittest.IsolatedAsyncioTestCase):
         self.assertEqual(headers[b'cache-control'], b'no-store')
         return note
 
-    async def test_public_page_assets_health_and_redirect(self):
-        for path, content_type, marker in [
-            ('/', b'text/html', b'Wred notebook'),
-            ('/static/app.css', b'text/css', b'.workspace'),
-            ('/static/app.js', b'text/javascript', b'/api/notes'),
-        ]:
-            with self.subTest(path=path):
-                status, body, headers = await self.request(path)
-                self.assertEqual(status, 200)
-                self.assertEqual(headers[b'content-type'], content_type)
-                self.assertEqual(headers[b'cache-control'], b'no-cache')
-                self.assertIn(marker, body)
-        status, body, headers = await self.request('/ui')
-        self.assertEqual(
-            (status, body, headers[b'location']), (303, b'', b'/')
-        )
-        self.assertEqual(
-            (await self.request('/api/health'))[:2], (200, {'status': 'ok'})
-        )
-        with self.assertRaises(FileNotFoundError):
-            await self.request('/static/missing.js')
-
     async def test_lifespan_starts_and_stops_the_database(self):
         worker = self.new_app()
         messages = iter(
diff --git a/tests/test_wred.py b/tests/test_wred.py
index 66c5a13..4ff04af 100644
--- a/tests/test_wred.py
+++ b/tests/test_wred.py
@@ -9,11 +9,7 @@
     :license: BSD-3-Clause, see LICENSE for more details.
 """
 
-import tempfile
 import unittest
-from contextlib import chdir
-from pathlib import Path
-from unittest.mock import patch
 
 import orjson
 
@@ -163,112 +159,6 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
                 )
                 self.assertEqual(scope, original)
 
-    async def test_file_delivery_and_root_confinement(self):
-        # Exercise the public helper through the actual ASGI response boundary.
-        # This guards MIME/status delivery and accidental exposure outside
-        # root.
-        with tempfile.TemporaryDirectory() as directory:
-            base = Path(directory)
-            root = base / 'static'
-            root.mkdir()
-            (root / 'index.html').write_bytes(b'<p>Hello</p>')
-            (root / 'asset.unknownextension').write_bytes(b'\x00\xff')
-            (base / 'secret.txt').write_text('private')
-            (root / 'escape.txt').symlink_to(base / 'secret.txt')
-            custom_root = base / 'assets'
-            custom_root.mkdir()
-            (custom_root / 'index.html').write_bytes(b'<p>Custom root</p>')
-
-            app = App()
-
-            @app.route('/file', methods=['POST'])
-            async def file(request):
-                data = await request.json()
-                return await app.static(data['filename'], **root_options)
-
-            cases = [
-                ('index.html', None, b'text/html', b'<p>Hello</p>'),
-                (
-                    'asset.unknownextension',
-                    None,
-                    b'application/octet-stream',
-                    b'\x00\xff',
-                ),
-                ('missing.txt', FileNotFoundError, None, None),
-                ('.', IsADirectoryError, None, None),
-                ('../secret.txt', ValueError, None, None),
-                (str(base / 'secret.txt'), ValueError, None, None),
-                ('escape.txt', ValueError, None, None),
-                ('invalid\x00.txt', ValueError, None, None),
-            ]
-            cases = [
-                (options, *case)
-                for options in (
-                    {},
-                    {'root': ''},
-                    {'root': None},
-                    {'root': root},
-                    {'root': str(root)},
-                )
-                for case in cases
-            ]
-            cases.append(
-                (
-                    {'root': custom_root},
-                    'index.html',
-                    None,
-                    b'text/html',
-                    b'<p>Custom root</p>',
-                )
-            )
-            with chdir(base):
-                for (
-                    root_options,
-                    filename,
-                    error,
-                    content_type,
-                    body,
-                ) in cases:
-                    with self.subTest(root=root_options, filename=filename):
-
-                        async def receive():
-                            return {
-                                'type': 'http.request',
-                                'body': orjson.dumps({'filename': filename}),
-                            }
-
-                        scope = {
-                            'type': 'http',
-                            'path': '/file',
-                            'method': 'POST',
-                        }
-                        if error is not None:
-                            messages = []
-
-                            async def send(message):
-                                messages.append(message)
-
-                            with self.assertRaises(error):
-                                await app(scope, receive, send)
-                            self.assertEqual(messages, [])
-                            continue
-                        messages = await call_app(
-                            app, scope, receive
-                        )
-                        self.assertEqual(
-                            messages,
-                            [
-                                {
-                                    'type': 'http.response.start',
-                                    'status': 200,
-                                    'headers': [
-                                        (b'content-type', content_type)
-                                    ],
-                                },
-                                {'type': 'http.response.body', 'body': body},
-                            ],
-                        )
-
     async def test_dictionary_and_list_responses_are_json(self):
         app = App()
 
@@ -696,38 +586,6 @@ class WredTests(unittest.IsolatedAsyncioTestCase):
                     await app({'type': protocol}, receive, send)
                 self.assertEqual(messages, [])
 
-    async def test_file_read_failure_propagates_without_sending(self):
-        app = App()
-        with tempfile.TemporaryDirectory() as directory:
-            (Path(directory) / 'private.txt').write_text('private')
-
-            @app.route('/')
-            async def file(request):
-                return await app.static('private.txt', root=directory)
-
-            async def receive():
-                raise AssertionError(
-                    'File serving should not read the request body'
-                )
-
-            messages = []
-
-            async def send(message):
-                messages.append(message)
-
-            # Simulate the OS refusing a real file read, including when tests
-            # run as root.
-            with patch.object(
-                Path, 'read_bytes', side_effect=PermissionError('denied')
-            ):
-                with self.assertRaisesRegex(PermissionError, 'denied'):
-                    await app(
-                        {'type': 'http', 'path': '/', 'method': 'GET'},
-                        receive,
-                        send,
-                    )
-            self.assertEqual(messages, [])
-
     async def test_raw_and_custom_response_bodies(self):
         app = App()
 
diff --git a/wred.py b/wred.py
index 34b0bb3..573e86f 100644
--- a/wred.py
+++ b/wred.py
@@ -7,10 +7,7 @@ Copyright (c) 2026, Harrison Erd.
 __author__, __email__, __license__ = 'Harrison Erd', '[email protected]', 'BSD-3-Clause'
 __version__ = '0.08'
 from collections.abc import Awaitable, Callable
-from asyncio import to_thread
 from functools import cached_property
-from mimetypes import guess_type
-from pathlib import Path
 from typing import Any
 from urllib.parse import parse_qsl
 import orjson
@@ -115,15 +112,6 @@ class App:
                 return None
         return params
 
-    async def static(self, filename: str | Path, *, root: str | Path | None = 'static') -> tuple[bytes, str, int]:
-        """Read a small file inside root off the event loop; errors propagate."""
-        def read() -> tuple[bytes, str, int]:
-            directory = Path(root or 'static').resolve()
-            path = (directory / filename).resolve()
-            path.relative_to(directory)
-            return path.read_bytes(), guess_type(path.name)[0] or 'application/octet-stream', 200
-        return await to_thread(read)
-
     def route(self, path: str, methods: list[str] | tuple[str, ...] = ('GET',)) -> Callable:
         """Decorator to register routes with specific HTTP methods."""
         path = path.rstrip('/') or '/'