patx/wred
Delegate static file serving to application dependencies
Commit cfbb512 · patx · 2026-10-18T16:46:17-04:00
Comments
No comments yet.
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 '/'