dj-serve 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dj_serve-1.0.0/LICENSE +21 -0
- dj_serve-1.0.0/PKG-INFO +341 -0
- dj_serve-1.0.0/README.md +287 -0
- dj_serve-1.0.0/pyproject.toml +57 -0
- dj_serve-1.0.0/src/dj_serve/__init__.py +77 -0
- dj_serve-1.0.0/src/dj_serve/config.py +46 -0
- dj_serve-1.0.0/src/dj_serve/middleware.py +103 -0
- dj_serve-1.0.0/src/dj_serve/py.typed +0 -0
- dj_serve-1.0.0/src/dj_serve/views.py +191 -0
dj_serve-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ricardo Robles Fernández
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
dj_serve-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dj-serve
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Serve SPAs (Vue, React, Angular, vanilla HTML) directly from Django
|
|
5
|
+
Keywords: django,spa,static,vue,react,angular,frontend
|
|
6
|
+
Author: Ricardo Robles
|
|
7
|
+
Author-email: Ricardo Robles <ricardo.r.f@hotmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 4.0
|
|
14
|
+
Classifier: Framework :: Django :: 4.1
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.0
|
|
17
|
+
Classifier: Framework :: Django :: 5.1
|
|
18
|
+
Classifier: Framework :: Django :: 5.2
|
|
19
|
+
Classifier: Framework :: Django :: 6.0
|
|
20
|
+
Classifier: Framework :: Django :: 6.1
|
|
21
|
+
Classifier: Intended Audience :: Developers
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Classifier: Programming Language :: Python
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
29
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
30
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
31
|
+
Requires-Dist: django>=4.0
|
|
32
|
+
Requires-Dist: whitesnout>=1.0 ; extra == 'asgi'
|
|
33
|
+
Requires-Dist: aiofiles>=25.1.0 ; extra == 'async'
|
|
34
|
+
Requires-Dist: pytest>=9.1.1 ; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-django>=4 ; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-asyncio>=1.4.0 ; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff==0.15.20 ; extra == 'dev'
|
|
38
|
+
Requires-Dist: ty==0.0.56 ; extra == 'dev'
|
|
39
|
+
Requires-Dist: django-stubs>=5 ; extra == 'dev'
|
|
40
|
+
Requires-Dist: aiofiles>=25.1.0 ; extra == 'dev'
|
|
41
|
+
Requires-Dist: whitenoise>=6.0 ; extra == 'dev'
|
|
42
|
+
Requires-Dist: whitesnout>=1.0 ; extra == 'dev'
|
|
43
|
+
Requires-Dist: httpx>=0.27.0 ; extra == 'dev'
|
|
44
|
+
Requires-Dist: whitenoise>=6.0 ; extra == 'wsgi'
|
|
45
|
+
Requires-Python: >=3.10
|
|
46
|
+
Project-URL: Homepage, https://github.com/rroblf01/dj-serve
|
|
47
|
+
Project-URL: Repository, https://github.com/rroblf01/dj-serve
|
|
48
|
+
Project-URL: Issues, https://github.com/rroblf01/dj-serve/issues
|
|
49
|
+
Provides-Extra: asgi
|
|
50
|
+
Provides-Extra: async
|
|
51
|
+
Provides-Extra: dev
|
|
52
|
+
Provides-Extra: wsgi
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
[](https://pypi.org/project/dj-serve/)
|
|
56
|
+
[](https://pypi.org/project/dj-serve/)
|
|
57
|
+
[](https://opensource.org/licenses/MIT)
|
|
58
|
+
[](https://peps.python.org/pep-0561/)
|
|
59
|
+
[](https://github.com/rroblf01/dj-serve/actions/workflows/test.yml)
|
|
60
|
+
|
|
61
|
+
# dj-serve
|
|
62
|
+
|
|
63
|
+
Serve SPAs (Vue, React, Angular, Svelte, or vanilla HTML) directly from Django — no separate static server needed.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from dj_serve import dj_serve
|
|
67
|
+
|
|
68
|
+
urlpatterns = [
|
|
69
|
+
dj_serve("/", "dist/", "index.html", "dist/400.html", "dist/500.html"),
|
|
70
|
+
]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Why?
|
|
74
|
+
|
|
75
|
+
Traditional Django + SPA setups require either:
|
|
76
|
+
|
|
77
|
+
- **A separate static file server** (nginx, Apache, CDN) — adds deployment complexity.
|
|
78
|
+
- **`django.contrib.staticfiles`** — not designed for SPAs; no client-side routing fallback, no custom error pages per frontend.
|
|
79
|
+
|
|
80
|
+
`dj-serve` solves both:
|
|
81
|
+
|
|
82
|
+
| Feature | `staticfiles` | `dj-serve` |
|
|
83
|
+
|---------|---------------|----------|
|
|
84
|
+
| SPA fallback (client-side routing) | ❌ | ✅ |
|
|
85
|
+
| Custom 400/500 pages per SPA | ❌ | ✅ |
|
|
86
|
+
| Isolated error handling (no global handlers) | ❌ | ✅ |
|
|
87
|
+
| Path traversal protection | ❌ | ✅ |
|
|
88
|
+
| Correct MIME types | ✅ | ✅ |
|
|
89
|
+
| Works with any prefix | ✅ | ✅ |
|
|
90
|
+
|
|
91
|
+
## Installation
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install dj-serve
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Requires **Django ≥ 4.0** and **Python ≥ 3.10**.
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
### Basic SPA
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from django.urls import include, path
|
|
105
|
+
from dj_serve import dj_serve
|
|
106
|
+
|
|
107
|
+
urlpatterns = [
|
|
108
|
+
path("admin/", admin.site.urls),
|
|
109
|
+
path("api/", include("myapi.urls")),
|
|
110
|
+
dj_serve("/", "dist/"),
|
|
111
|
+
]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`dj_serve("/", "dist/")` will:
|
|
115
|
+
|
|
116
|
+
1. Serve files from `dist/` (e.g., `dist/style.css` at `/style.css`).
|
|
117
|
+
2. For any other route under `/`, serve `dist/index.html` — this enables client-side routing (Vue Router, React Router, etc.).
|
|
118
|
+
3. Return 404 if neither the file nor `index.html` exists.
|
|
119
|
+
|
|
120
|
+
### With custom error pages
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
dj_serve("/", "dist/", error_400="dist/400.html", error_500="dist/500.html")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Error pages are served **only for routes under this prefix** — your API and admin endpoints keep their default error handling.
|
|
127
|
+
|
|
128
|
+
### With a prefix
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
dj_serve("/app", "dist/", "index.html")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Serves the SPA at `/app/`, `/app/about`, `/app/dashboard`, etc.
|
|
135
|
+
|
|
136
|
+
### Vanilla HTML site
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
dj_serve("/", "site/", entry_point="index.html")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Works the same way — SPA fallback just means unknown routes serve `index.html`.
|
|
143
|
+
|
|
144
|
+
### Cache-Control headers
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
dj_serve("/", "dist/", cache_control="public, max-age=3600")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Apply the same value to all responses, or use a dict with glob patterns:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
dj_serve("/", "dist/", cache_control={
|
|
154
|
+
"*.html": "no-cache",
|
|
155
|
+
"*.css": "public, max-age=31536000, immutable",
|
|
156
|
+
"*.js": "public, max-age=31536000, immutable",
|
|
157
|
+
"*": "public, max-age=3600",
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
| `cache_control` | Behaviour |
|
|
162
|
+
|-----------------|-----------|
|
|
163
|
+
| `None` (default) | No `Cache-Control` header |
|
|
164
|
+
| `str` | Same value for every response |
|
|
165
|
+
| `dict[str, str]` | Glob patterns matched against the filename; first match wins |
|
|
166
|
+
|
|
167
|
+
## API
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
def dj_serve(
|
|
171
|
+
prefix: str,
|
|
172
|
+
dist_dir: str,
|
|
173
|
+
entry_point: str = "index.html",
|
|
174
|
+
error_400: str | None = None,
|
|
175
|
+
error_500: str | None = None,
|
|
176
|
+
cache_control: str | dict[str, str] | None = None,
|
|
177
|
+
async_mode: bool = False,
|
|
178
|
+
) -> URLPattern:
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
| Argument | Default | Description |
|
|
182
|
+
|----------|---------|-------------|
|
|
183
|
+
| `prefix` | — | URL prefix (e.g., `/`, `/app`) |
|
|
184
|
+
| `dist_dir` | — | Path to the directory with static files |
|
|
185
|
+
| `entry_point` | `"index.html"` | HTML file to serve for SPA fallback (client-side routing) |
|
|
186
|
+
| `error_400` | `None` | Path to a custom 400 error page |
|
|
187
|
+
| `error_500` | `None` | Path to a custom 500 error page |
|
|
188
|
+
| `cache_control` | `None` | `Cache-Control` header value. `str` for all files, `dict` for per-pattern (glob) |
|
|
189
|
+
| `async_mode` | `False` | Use async I/O with `aiofiles`. Requires `pip install dj-serve[async]` |
|
|
190
|
+
|
|
191
|
+
## How it works
|
|
192
|
+
|
|
193
|
+
1. Django resolves the URL through the `re_path` pattern.
|
|
194
|
+
2. The view looks for the requested file in `dist_dir`.
|
|
195
|
+
3. If found → served with the correct MIME type.
|
|
196
|
+
4. If not found → serves `entry_point` (SPA fallback).
|
|
197
|
+
5. If the entry point is missing → serves the custom `error_400` page, or 404.
|
|
198
|
+
6. If an exception occurs → serves the custom `error_500` page, or 500.
|
|
199
|
+
|
|
200
|
+
All error handling is **contained within the view** — no global `handler400`/`handler500` configuration needed.
|
|
201
|
+
|
|
202
|
+
## ASGI Support
|
|
203
|
+
|
|
204
|
+
dj-serve works with both WSGI and ASGI servers (uvicorn, daphne, hypercorn).
|
|
205
|
+
|
|
206
|
+
### Synchronous mode (default)
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
dj_serve("/", "dist/")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Uses synchronous I/O. Django runs the view in a threadpool under ASGI.
|
|
213
|
+
|
|
214
|
+
### Async mode
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
dj_serve("/", "dist/", async_mode=True)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Uses non-blocking I/O with `aiofiles`. Best for ASGI servers under high concurrency.
|
|
221
|
+
|
|
222
|
+
Install the async extra:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
pip install dj-serve[async]
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Production Deployment
|
|
229
|
+
|
|
230
|
+
For production, dj-serve provides `dj_serve_middleware()` which wraps your Django application with production-grade static file serving:
|
|
231
|
+
|
|
232
|
+
- **WSGI** (gunicorn, waitress, uwsgi, etc.) → uses [WhiteNoise](https://whitenoise.readthedocs.io/)
|
|
233
|
+
- **ASGI** (uvicorn, daphne, hypercorn, etc.) → uses [WhiteSnout](https://github.com/rroblf01/whitesnout)
|
|
234
|
+
|
|
235
|
+
### Installation
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
# For WSGI deployments
|
|
239
|
+
pip install dj-serve[wsgi]
|
|
240
|
+
|
|
241
|
+
# For ASGI deployments
|
|
242
|
+
pip install dj-serve[asgi]
|
|
243
|
+
|
|
244
|
+
# For both
|
|
245
|
+
pip install dj-serve[wsgi,asgi]
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### WSGI Deployment
|
|
249
|
+
|
|
250
|
+
In your `wsgi.py`:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
from django.core.wsgi import get_wsgi_application
|
|
254
|
+
from dj_serve import dj_serve_middleware
|
|
255
|
+
|
|
256
|
+
application = get_wsgi_application()
|
|
257
|
+
application = dj_serve_middleware(application, "dist/")
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### ASGI Deployment
|
|
261
|
+
|
|
262
|
+
In your `asgi.py`:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
from django.core.asgi import get_asgi_application
|
|
266
|
+
from dj_serve import dj_serve_middleware
|
|
267
|
+
|
|
268
|
+
application = get_asgi_application()
|
|
269
|
+
application = dj_serve_middleware(application, "dist/", async_mode=True)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Advanced Configuration
|
|
273
|
+
|
|
274
|
+
Pass additional options to the underlying middleware:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
# WSGI with WhiteNoise options
|
|
278
|
+
application = dj_serve_middleware(
|
|
279
|
+
application,
|
|
280
|
+
"dist/",
|
|
281
|
+
max_age=86400, # 1 day cache
|
|
282
|
+
allow_all_origins=True,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# ASGI with WhiteSnout options
|
|
286
|
+
application = dj_serve_middleware(
|
|
287
|
+
application,
|
|
288
|
+
"dist/",
|
|
289
|
+
async_mode=True,
|
|
290
|
+
cache_max_age=86400,
|
|
291
|
+
security_headers=True,
|
|
292
|
+
)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### How It Works
|
|
296
|
+
|
|
297
|
+
The middleware and URL pattern work together:
|
|
298
|
+
|
|
299
|
+
1. **Middleware** intercepts requests for static files (CSS, JS, images) and serves them with compression, caching, and security headers
|
|
300
|
+
2. **`dj_serve()` URL pattern** handles SPA routing — when a file isn't found, it serves `index.html` for client-side routing
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
# urls.py — SPA fallback (always needed)
|
|
304
|
+
from dj_serve import dj_serve
|
|
305
|
+
|
|
306
|
+
urlpatterns = [
|
|
307
|
+
dj_serve("/", "dist/"),
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
# wsgi.py or asgi.py — production static file serving
|
|
311
|
+
from dj_serve import dj_serve_middleware
|
|
312
|
+
|
|
313
|
+
application = dj_serve_middleware(application, "dist/", async_mode=True)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Backend Comparison
|
|
317
|
+
|
|
318
|
+
| Feature | Builtin | WhiteNoise (WSGI) | WhiteSnout (ASGI) |
|
|
319
|
+
|---------|---------|-------------------|-------------------|
|
|
320
|
+
| **SPA fallback** | ✅ | ❌ (needs dj-serve) | ❌ (needs dj-serve) |
|
|
321
|
+
| **Compression** | ❌ | gzip + brotli | gzip + brotli |
|
|
322
|
+
| **ETag / 304** | ❌ | ✅ | ✅ |
|
|
323
|
+
| **Async native** | ❌ | ❌ (WSGI) | ✅ |
|
|
324
|
+
| **Rust acceleration** | ❌ | ❌ | ✅ |
|
|
325
|
+
| **Best for** | Development | WSGI production | ASGI production |
|
|
326
|
+
|
|
327
|
+
### Development vs Production
|
|
328
|
+
|
|
329
|
+
- **Development** (`DEBUG=True`): Use `dj_serve()` URL pattern only — no warning
|
|
330
|
+
- **Production** (`DEBUG=False`): Configure `dj_serve_middleware()` in `wsgi.py` or `asgi.py` — otherwise a warning is logged
|
|
331
|
+
|
|
332
|
+
## Testing locally
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
pip install -e ".[dev]"
|
|
336
|
+
pytest
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## License
|
|
340
|
+
|
|
341
|
+
MIT
|
dj_serve-1.0.0/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
[](https://pypi.org/project/dj-serve/)
|
|
2
|
+
[](https://pypi.org/project/dj-serve/)
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://peps.python.org/pep-0561/)
|
|
5
|
+
[](https://github.com/rroblf01/dj-serve/actions/workflows/test.yml)
|
|
6
|
+
|
|
7
|
+
# dj-serve
|
|
8
|
+
|
|
9
|
+
Serve SPAs (Vue, React, Angular, Svelte, or vanilla HTML) directly from Django — no separate static server needed.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from dj_serve import dj_serve
|
|
13
|
+
|
|
14
|
+
urlpatterns = [
|
|
15
|
+
dj_serve("/", "dist/", "index.html", "dist/400.html", "dist/500.html"),
|
|
16
|
+
]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Why?
|
|
20
|
+
|
|
21
|
+
Traditional Django + SPA setups require either:
|
|
22
|
+
|
|
23
|
+
- **A separate static file server** (nginx, Apache, CDN) — adds deployment complexity.
|
|
24
|
+
- **`django.contrib.staticfiles`** — not designed for SPAs; no client-side routing fallback, no custom error pages per frontend.
|
|
25
|
+
|
|
26
|
+
`dj-serve` solves both:
|
|
27
|
+
|
|
28
|
+
| Feature | `staticfiles` | `dj-serve` |
|
|
29
|
+
|---------|---------------|----------|
|
|
30
|
+
| SPA fallback (client-side routing) | ❌ | ✅ |
|
|
31
|
+
| Custom 400/500 pages per SPA | ❌ | ✅ |
|
|
32
|
+
| Isolated error handling (no global handlers) | ❌ | ✅ |
|
|
33
|
+
| Path traversal protection | ❌ | ✅ |
|
|
34
|
+
| Correct MIME types | ✅ | ✅ |
|
|
35
|
+
| Works with any prefix | ✅ | ✅ |
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install dj-serve
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires **Django ≥ 4.0** and **Python ≥ 3.10**.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### Basic SPA
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from django.urls import include, path
|
|
51
|
+
from dj_serve import dj_serve
|
|
52
|
+
|
|
53
|
+
urlpatterns = [
|
|
54
|
+
path("admin/", admin.site.urls),
|
|
55
|
+
path("api/", include("myapi.urls")),
|
|
56
|
+
dj_serve("/", "dist/"),
|
|
57
|
+
]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`dj_serve("/", "dist/")` will:
|
|
61
|
+
|
|
62
|
+
1. Serve files from `dist/` (e.g., `dist/style.css` at `/style.css`).
|
|
63
|
+
2. For any other route under `/`, serve `dist/index.html` — this enables client-side routing (Vue Router, React Router, etc.).
|
|
64
|
+
3. Return 404 if neither the file nor `index.html` exists.
|
|
65
|
+
|
|
66
|
+
### With custom error pages
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
dj_serve("/", "dist/", error_400="dist/400.html", error_500="dist/500.html")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Error pages are served **only for routes under this prefix** — your API and admin endpoints keep their default error handling.
|
|
73
|
+
|
|
74
|
+
### With a prefix
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
dj_serve("/app", "dist/", "index.html")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Serves the SPA at `/app/`, `/app/about`, `/app/dashboard`, etc.
|
|
81
|
+
|
|
82
|
+
### Vanilla HTML site
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
dj_serve("/", "site/", entry_point="index.html")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Works the same way — SPA fallback just means unknown routes serve `index.html`.
|
|
89
|
+
|
|
90
|
+
### Cache-Control headers
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
dj_serve("/", "dist/", cache_control="public, max-age=3600")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Apply the same value to all responses, or use a dict with glob patterns:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
dj_serve("/", "dist/", cache_control={
|
|
100
|
+
"*.html": "no-cache",
|
|
101
|
+
"*.css": "public, max-age=31536000, immutable",
|
|
102
|
+
"*.js": "public, max-age=31536000, immutable",
|
|
103
|
+
"*": "public, max-age=3600",
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| `cache_control` | Behaviour |
|
|
108
|
+
|-----------------|-----------|
|
|
109
|
+
| `None` (default) | No `Cache-Control` header |
|
|
110
|
+
| `str` | Same value for every response |
|
|
111
|
+
| `dict[str, str]` | Glob patterns matched against the filename; first match wins |
|
|
112
|
+
|
|
113
|
+
## API
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
def dj_serve(
|
|
117
|
+
prefix: str,
|
|
118
|
+
dist_dir: str,
|
|
119
|
+
entry_point: str = "index.html",
|
|
120
|
+
error_400: str | None = None,
|
|
121
|
+
error_500: str | None = None,
|
|
122
|
+
cache_control: str | dict[str, str] | None = None,
|
|
123
|
+
async_mode: bool = False,
|
|
124
|
+
) -> URLPattern:
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| Argument | Default | Description |
|
|
128
|
+
|----------|---------|-------------|
|
|
129
|
+
| `prefix` | — | URL prefix (e.g., `/`, `/app`) |
|
|
130
|
+
| `dist_dir` | — | Path to the directory with static files |
|
|
131
|
+
| `entry_point` | `"index.html"` | HTML file to serve for SPA fallback (client-side routing) |
|
|
132
|
+
| `error_400` | `None` | Path to a custom 400 error page |
|
|
133
|
+
| `error_500` | `None` | Path to a custom 500 error page |
|
|
134
|
+
| `cache_control` | `None` | `Cache-Control` header value. `str` for all files, `dict` for per-pattern (glob) |
|
|
135
|
+
| `async_mode` | `False` | Use async I/O with `aiofiles`. Requires `pip install dj-serve[async]` |
|
|
136
|
+
|
|
137
|
+
## How it works
|
|
138
|
+
|
|
139
|
+
1. Django resolves the URL through the `re_path` pattern.
|
|
140
|
+
2. The view looks for the requested file in `dist_dir`.
|
|
141
|
+
3. If found → served with the correct MIME type.
|
|
142
|
+
4. If not found → serves `entry_point` (SPA fallback).
|
|
143
|
+
5. If the entry point is missing → serves the custom `error_400` page, or 404.
|
|
144
|
+
6. If an exception occurs → serves the custom `error_500` page, or 500.
|
|
145
|
+
|
|
146
|
+
All error handling is **contained within the view** — no global `handler400`/`handler500` configuration needed.
|
|
147
|
+
|
|
148
|
+
## ASGI Support
|
|
149
|
+
|
|
150
|
+
dj-serve works with both WSGI and ASGI servers (uvicorn, daphne, hypercorn).
|
|
151
|
+
|
|
152
|
+
### Synchronous mode (default)
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
dj_serve("/", "dist/")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Uses synchronous I/O. Django runs the view in a threadpool under ASGI.
|
|
159
|
+
|
|
160
|
+
### Async mode
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
dj_serve("/", "dist/", async_mode=True)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Uses non-blocking I/O with `aiofiles`. Best for ASGI servers under high concurrency.
|
|
167
|
+
|
|
168
|
+
Install the async extra:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
pip install dj-serve[async]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Production Deployment
|
|
175
|
+
|
|
176
|
+
For production, dj-serve provides `dj_serve_middleware()` which wraps your Django application with production-grade static file serving:
|
|
177
|
+
|
|
178
|
+
- **WSGI** (gunicorn, waitress, uwsgi, etc.) → uses [WhiteNoise](https://whitenoise.readthedocs.io/)
|
|
179
|
+
- **ASGI** (uvicorn, daphne, hypercorn, etc.) → uses [WhiteSnout](https://github.com/rroblf01/whitesnout)
|
|
180
|
+
|
|
181
|
+
### Installation
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
# For WSGI deployments
|
|
185
|
+
pip install dj-serve[wsgi]
|
|
186
|
+
|
|
187
|
+
# For ASGI deployments
|
|
188
|
+
pip install dj-serve[asgi]
|
|
189
|
+
|
|
190
|
+
# For both
|
|
191
|
+
pip install dj-serve[wsgi,asgi]
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### WSGI Deployment
|
|
195
|
+
|
|
196
|
+
In your `wsgi.py`:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from django.core.wsgi import get_wsgi_application
|
|
200
|
+
from dj_serve import dj_serve_middleware
|
|
201
|
+
|
|
202
|
+
application = get_wsgi_application()
|
|
203
|
+
application = dj_serve_middleware(application, "dist/")
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### ASGI Deployment
|
|
207
|
+
|
|
208
|
+
In your `asgi.py`:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from django.core.asgi import get_asgi_application
|
|
212
|
+
from dj_serve import dj_serve_middleware
|
|
213
|
+
|
|
214
|
+
application = get_asgi_application()
|
|
215
|
+
application = dj_serve_middleware(application, "dist/", async_mode=True)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Advanced Configuration
|
|
219
|
+
|
|
220
|
+
Pass additional options to the underlying middleware:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
# WSGI with WhiteNoise options
|
|
224
|
+
application = dj_serve_middleware(
|
|
225
|
+
application,
|
|
226
|
+
"dist/",
|
|
227
|
+
max_age=86400, # 1 day cache
|
|
228
|
+
allow_all_origins=True,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# ASGI with WhiteSnout options
|
|
232
|
+
application = dj_serve_middleware(
|
|
233
|
+
application,
|
|
234
|
+
"dist/",
|
|
235
|
+
async_mode=True,
|
|
236
|
+
cache_max_age=86400,
|
|
237
|
+
security_headers=True,
|
|
238
|
+
)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### How It Works
|
|
242
|
+
|
|
243
|
+
The middleware and URL pattern work together:
|
|
244
|
+
|
|
245
|
+
1. **Middleware** intercepts requests for static files (CSS, JS, images) and serves them with compression, caching, and security headers
|
|
246
|
+
2. **`dj_serve()` URL pattern** handles SPA routing — when a file isn't found, it serves `index.html` for client-side routing
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
# urls.py — SPA fallback (always needed)
|
|
250
|
+
from dj_serve import dj_serve
|
|
251
|
+
|
|
252
|
+
urlpatterns = [
|
|
253
|
+
dj_serve("/", "dist/"),
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
# wsgi.py or asgi.py — production static file serving
|
|
257
|
+
from dj_serve import dj_serve_middleware
|
|
258
|
+
|
|
259
|
+
application = dj_serve_middleware(application, "dist/", async_mode=True)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Backend Comparison
|
|
263
|
+
|
|
264
|
+
| Feature | Builtin | WhiteNoise (WSGI) | WhiteSnout (ASGI) |
|
|
265
|
+
|---------|---------|-------------------|-------------------|
|
|
266
|
+
| **SPA fallback** | ✅ | ❌ (needs dj-serve) | ❌ (needs dj-serve) |
|
|
267
|
+
| **Compression** | ❌ | gzip + brotli | gzip + brotli |
|
|
268
|
+
| **ETag / 304** | ❌ | ✅ | ✅ |
|
|
269
|
+
| **Async native** | ❌ | ❌ (WSGI) | ✅ |
|
|
270
|
+
| **Rust acceleration** | ❌ | ❌ | ✅ |
|
|
271
|
+
| **Best for** | Development | WSGI production | ASGI production |
|
|
272
|
+
|
|
273
|
+
### Development vs Production
|
|
274
|
+
|
|
275
|
+
- **Development** (`DEBUG=True`): Use `dj_serve()` URL pattern only — no warning
|
|
276
|
+
- **Production** (`DEBUG=False`): Configure `dj_serve_middleware()` in `wsgi.py` or `asgi.py` — otherwise a warning is logged
|
|
277
|
+
|
|
278
|
+
## Testing locally
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
pip install -e ".[dev]"
|
|
282
|
+
pytest
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## License
|
|
286
|
+
|
|
287
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dj-serve"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Serve SPAs (Vue, React, Angular, vanilla HTML) directly from Django"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
keywords = ["django", "spa", "static", "vue", "react", "angular", "frontend"]
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Ricardo Robles", email = "ricardo.r.f@hotmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["django>=4.0"]
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: Web Environment",
|
|
17
|
+
"Framework :: Django",
|
|
18
|
+
"Framework :: Django :: 4.0",
|
|
19
|
+
"Framework :: Django :: 4.1",
|
|
20
|
+
"Framework :: Django :: 4.2",
|
|
21
|
+
"Framework :: Django :: 5.0",
|
|
22
|
+
"Framework :: Django :: 5.1",
|
|
23
|
+
"Framework :: Django :: 5.2",
|
|
24
|
+
"Framework :: Django :: 6.0",
|
|
25
|
+
"Framework :: Django :: 6.1",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
"Programming Language :: Python",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/rroblf01/dj-serve"
|
|
40
|
+
Repository = "https://github.com/rroblf01/dj-serve"
|
|
41
|
+
Issues = "https://github.com/rroblf01/dj-serve/issues"
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
async = ["aiofiles>=25.1.0"]
|
|
45
|
+
wsgi = ["whitenoise>=6.0"]
|
|
46
|
+
asgi = ["whitesnout>=1.0"]
|
|
47
|
+
dev = ["pytest>=9.1.1", "pytest-django>=4", "pytest-asyncio>=1.4.0", "ruff==0.15.20", "ty==0.0.56", "django-stubs>=5", "aiofiles>=25.1.0", "whitenoise>=6.0", "whitesnout>=1.0", "httpx>=0.27.0"]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.11.15,<0.12.0"]
|
|
51
|
+
build-backend = "uv_build"
|
|
52
|
+
|
|
53
|
+
[dependency-groups]
|
|
54
|
+
dev = ["pytest>=9.1.1", "pytest-django>=4", "pytest-asyncio>=1.4.0", "ruff==0.15.20", "ty==0.0.56", "django-stubs>=5", "aiofiles>=25.1.0", "whitenoise>=6.0", "whitesnout>=1.0", "httpx>=0.27.0"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
__all__ = ["dj_serve", "dj_serve_middleware", "DjServeConfigError"]
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.urls import re_path
|
|
8
|
+
from django.urls.resolvers import URLPattern
|
|
9
|
+
|
|
10
|
+
from .config import DjServeConfigError, validate_config
|
|
11
|
+
from .middleware import dj_serve_middleware
|
|
12
|
+
from .views import CacheControl, async_serve_view, serve_view
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def dj_serve(
|
|
18
|
+
prefix: str,
|
|
19
|
+
dist_dir: str,
|
|
20
|
+
entry_point: str = "index.html",
|
|
21
|
+
error_400: str | None = None,
|
|
22
|
+
error_500: str | None = None,
|
|
23
|
+
cache_control: CacheControl = None,
|
|
24
|
+
async_mode: bool = False,
|
|
25
|
+
) -> URLPattern:
|
|
26
|
+
"""Configure serve serving for Django.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
prefix: URL prefix for the serve (e.g., "/" or "/app").
|
|
30
|
+
dist_dir: Path to the directory containing static files.
|
|
31
|
+
entry_point: Name of the entry point HTML file (default: "index.html").
|
|
32
|
+
error_400: Optional path to custom 400 error page.
|
|
33
|
+
error_500: Optional path to custom 500 error page.
|
|
34
|
+
cache_control: Optional cache control configuration.
|
|
35
|
+
async_mode: If True, use async view for ASGI servers (default: False).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
URLPattern for Django URL configuration.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
DjServeConfigError: If configuration is invalid.
|
|
42
|
+
"""
|
|
43
|
+
logger.debug(f"Configuring dj-serve: prefix={prefix}, dist_dir={dist_dir}")
|
|
44
|
+
validate_config(dist_dir, entry_point, error_400, error_500)
|
|
45
|
+
|
|
46
|
+
# Warn if using builtin in production without static middleware
|
|
47
|
+
if not settings.DEBUG and not _has_static_middleware():
|
|
48
|
+
server_type = "asgi.py" if async_mode else "wsgi.py"
|
|
49
|
+
logger.warning(
|
|
50
|
+
f"dj-serve is using the builtin static file server in production. "
|
|
51
|
+
f"For better performance, configure dj_serve_middleware() in your "
|
|
52
|
+
f"{server_type} with the appropriate backend."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
prefix = prefix.rstrip("/")
|
|
56
|
+
regex = rf"^{prefix}/(?P<path>.*)$"
|
|
57
|
+
view: Callable = async_serve_view if async_mode else serve_view
|
|
58
|
+
return re_path(
|
|
59
|
+
regex,
|
|
60
|
+
view,
|
|
61
|
+
kwargs={
|
|
62
|
+
"dist_dir": dist_dir,
|
|
63
|
+
"entry_point": entry_point,
|
|
64
|
+
"error_400_path": error_400,
|
|
65
|
+
"error_500_path": error_500,
|
|
66
|
+
"cache_control": cache_control,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _has_static_middleware() -> bool:
|
|
72
|
+
"""Check if WhiteNoise or WhiteSnout is configured in Django middleware."""
|
|
73
|
+
middleware = getattr(settings, "MIDDLEWARE", [])
|
|
74
|
+
for mw in middleware:
|
|
75
|
+
if "whitenoise" in mw.lower() or "whitesnout" in mw.lower():
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DjServeConfigError(Exception):
|
|
8
|
+
"""Raised when dj-serve configuration is invalid."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_config(
|
|
12
|
+
dist_dir: str,
|
|
13
|
+
entry_point: str,
|
|
14
|
+
error_400_path: str | None = None,
|
|
15
|
+
error_500_path: str | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Validate dj-serve configuration at startup.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
dist_dir: Path to the directory containing static files.
|
|
21
|
+
entry_point: Name of the entry point HTML file (e.g., "index.html").
|
|
22
|
+
error_400_path: Optional path to custom 400 error page.
|
|
23
|
+
error_500_path: Optional path to custom 500 error page.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
DjServeConfigError: If configuration is invalid.
|
|
27
|
+
"""
|
|
28
|
+
dist_path = Path(dist_dir)
|
|
29
|
+
|
|
30
|
+
if not dist_path.exists():
|
|
31
|
+
raise DjServeConfigError(f"dist_dir does not exist: {dist_dir}")
|
|
32
|
+
|
|
33
|
+
if not dist_path.is_dir():
|
|
34
|
+
raise DjServeConfigError(f"dist_dir is not a directory: {dist_dir}")
|
|
35
|
+
|
|
36
|
+
entry_path = dist_path / entry_point
|
|
37
|
+
if not entry_path.exists():
|
|
38
|
+
raise DjServeConfigError(
|
|
39
|
+
f"entry_point '{entry_point}' not found in dist_dir: {entry_path}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if error_400_path and not Path(error_400_path).exists():
|
|
43
|
+
logger.warning(f"error_400_path does not exist: {error_400_path}")
|
|
44
|
+
|
|
45
|
+
if error_500_path and not Path(error_500_path).exists():
|
|
46
|
+
logger.warning(f"error_500_path does not exist: {error_500_path}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Production static file middleware integration for dj-serve.
|
|
2
|
+
|
|
3
|
+
This module provides dj_serve_middleware() which wraps a Django application
|
|
4
|
+
with WhiteNoise (WSGI) or WhiteSnout (ASGI) for production-grade static
|
|
5
|
+
file serving.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def dj_serve_middleware(
|
|
15
|
+
app: Callable,
|
|
16
|
+
dist_dir: str,
|
|
17
|
+
async_mode: bool = False,
|
|
18
|
+
**kwargs: Any,
|
|
19
|
+
) -> Callable:
|
|
20
|
+
"""Wrap Django app with production static file middleware.
|
|
21
|
+
|
|
22
|
+
This function automatically selects the appropriate backend based on
|
|
23
|
+
async_mode:
|
|
24
|
+
- async_mode=False → WhiteNoise (WSGI)
|
|
25
|
+
- async_mode=True → WhiteSnout (ASGI)
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
app: Django WSGI or ASGI application to wrap
|
|
29
|
+
dist_dir: Path to the directory containing static files
|
|
30
|
+
async_mode: If True, use WhiteSnout (ASGI); if False, use WhiteNoise (WSGI)
|
|
31
|
+
**kwargs: Additional arguments passed to the backend constructor
|
|
32
|
+
(e.g., cache_max_age, security_headers, etc.)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Wrapped application with static file middleware
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ImportError: If the required backend is not installed
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
WSGI (gunicorn, waitress, etc.):
|
|
42
|
+
|
|
43
|
+
from django.core.wsgi import get_wsgi_application
|
|
44
|
+
from dj_serve import dj_serve_middleware
|
|
45
|
+
|
|
46
|
+
application = get_wsgi_application()
|
|
47
|
+
application = dj_serve_middleware(application, "dist/")
|
|
48
|
+
|
|
49
|
+
ASGI (uvicorn, daphne, etc.):
|
|
50
|
+
|
|
51
|
+
from django.core.asgi import get_asgi_application
|
|
52
|
+
from dj_serve import dj_serve_middleware
|
|
53
|
+
|
|
54
|
+
application = get_asgi_application()
|
|
55
|
+
application = dj_serve_middleware(application, "dist/", async_mode=True)
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
if async_mode:
|
|
59
|
+
return _setup_whitesnout(app, dist_dir, **kwargs)
|
|
60
|
+
else:
|
|
61
|
+
return _setup_whitenoise(app, dist_dir, **kwargs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _setup_whitenoise(app: Callable, dist_dir: str, **kwargs: Any) -> Callable:
|
|
65
|
+
"""Setup WhiteNoise middleware for WSGI applications."""
|
|
66
|
+
try:
|
|
67
|
+
from whitenoise import WhiteNoise
|
|
68
|
+
except ImportError as exc:
|
|
69
|
+
raise ImportError(
|
|
70
|
+
"WhiteNoise is required for WSGI static file serving. "
|
|
71
|
+
"Install it with: pip install dj-serve[wsgi]"
|
|
72
|
+
) from exc
|
|
73
|
+
|
|
74
|
+
logger.info(f"Configuring WhiteNoise for WSGI with dist_dir={dist_dir}")
|
|
75
|
+
|
|
76
|
+
# WhiteNoise configuration
|
|
77
|
+
# root: directory to serve files from
|
|
78
|
+
# Additional kwargs are passed through (e.g., max_age, autorefresh)
|
|
79
|
+
whitenoise_kwargs = {"root": dist_dir}
|
|
80
|
+
whitenoise_kwargs.update(kwargs)
|
|
81
|
+
|
|
82
|
+
return WhiteNoise(app, **whitenoise_kwargs) # type: ignore
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _setup_whitesnout(app: Callable, dist_dir: str, **kwargs: Any) -> Callable:
|
|
86
|
+
"""Setup WhiteSnout middleware for ASGI applications."""
|
|
87
|
+
try:
|
|
88
|
+
from whitesnout import WhiteSnout
|
|
89
|
+
except ImportError as exc:
|
|
90
|
+
raise ImportError(
|
|
91
|
+
"WhiteSnout is required for ASGI static file serving. "
|
|
92
|
+
"Install it with: pip install dj-serve[asgi]"
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
logger.info(f"Configuring WhiteSnout for ASGI with dist_dir={dist_dir}")
|
|
96
|
+
|
|
97
|
+
# WhiteSnout configuration
|
|
98
|
+
# directory: directory to serve files from
|
|
99
|
+
# Additional kwargs are passed through (e.g., cache_max_age, security_headers)
|
|
100
|
+
whitesnout_kwargs = {"directory": dist_dir}
|
|
101
|
+
whitesnout_kwargs.update(kwargs)
|
|
102
|
+
|
|
103
|
+
return WhiteSnout(app, **whitesnout_kwargs) # type: ignore
|
|
File without changes
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import logging
|
|
3
|
+
import mimetypes
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from django.http import (
|
|
8
|
+
FileResponse,
|
|
9
|
+
HttpRequest,
|
|
10
|
+
HttpResponseBase,
|
|
11
|
+
HttpResponseNotFound,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
CacheControl = str | dict[str, str] | None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def serve_view(
|
|
20
|
+
request: HttpRequest,
|
|
21
|
+
path: str,
|
|
22
|
+
dist_dir: str,
|
|
23
|
+
entry_point: str,
|
|
24
|
+
error_400_path: str | None = None,
|
|
25
|
+
error_500_path: str | None = None,
|
|
26
|
+
cache_control: CacheControl = None,
|
|
27
|
+
) -> HttpResponseBase:
|
|
28
|
+
try:
|
|
29
|
+
if ".." in path:
|
|
30
|
+
return HttpResponseNotFound("Not Found")
|
|
31
|
+
|
|
32
|
+
dist = Path(dist_dir).resolve()
|
|
33
|
+
|
|
34
|
+
if not path or path.strip("/") == "":
|
|
35
|
+
target = dist / entry_point
|
|
36
|
+
if not target.exists():
|
|
37
|
+
resp = _error_response(error_400_path, 400, cache_control)
|
|
38
|
+
if resp is not None:
|
|
39
|
+
return resp
|
|
40
|
+
return HttpResponseNotFound("Not Found")
|
|
41
|
+
return _file_response(target, "text/html", cache_control)
|
|
42
|
+
|
|
43
|
+
target = (dist / path).resolve()
|
|
44
|
+
if not str(target).startswith(str(dist)):
|
|
45
|
+
return HttpResponseNotFound("Not Found")
|
|
46
|
+
|
|
47
|
+
if target.exists() and target.is_file():
|
|
48
|
+
return _file_response(target, cache_control=cache_control)
|
|
49
|
+
|
|
50
|
+
entry = dist / entry_point
|
|
51
|
+
if entry.exists():
|
|
52
|
+
return _file_response(entry, "text/html", cache_control)
|
|
53
|
+
|
|
54
|
+
resp = _error_response(error_400_path, 400, cache_control)
|
|
55
|
+
if resp is not None:
|
|
56
|
+
return resp
|
|
57
|
+
return HttpResponseNotFound("Not Found")
|
|
58
|
+
except Exception:
|
|
59
|
+
logger.exception("Error serving serve file")
|
|
60
|
+
resp = _error_response(error_500_path, 500, cache_control)
|
|
61
|
+
if resp is not None:
|
|
62
|
+
return resp
|
|
63
|
+
return HttpResponseNotFound("Server Error", status=500)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def async_serve_view(
|
|
67
|
+
request: HttpRequest,
|
|
68
|
+
path: str,
|
|
69
|
+
dist_dir: str,
|
|
70
|
+
entry_point: str,
|
|
71
|
+
error_400_path: str | None = None,
|
|
72
|
+
error_500_path: str | None = None,
|
|
73
|
+
cache_control: CacheControl = None,
|
|
74
|
+
) -> HttpResponseBase:
|
|
75
|
+
try:
|
|
76
|
+
import aiofiles
|
|
77
|
+
import aiofiles.os
|
|
78
|
+
except ImportError as exc:
|
|
79
|
+
raise ImportError(
|
|
80
|
+
"Async mode requires aiofiles. Install it with: pip install dj-serve[async]"
|
|
81
|
+
) from exc
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
if ".." in path:
|
|
85
|
+
return HttpResponseNotFound("Not Found")
|
|
86
|
+
|
|
87
|
+
dist = Path(dist_dir).resolve()
|
|
88
|
+
|
|
89
|
+
if not path or path.strip("/") == "":
|
|
90
|
+
target = dist / entry_point
|
|
91
|
+
if not await aiofiles.os.path.exists(target):
|
|
92
|
+
resp = await _async_error_response(error_400_path, 400, cache_control)
|
|
93
|
+
if resp is not None:
|
|
94
|
+
return resp
|
|
95
|
+
return HttpResponseNotFound("Not Found")
|
|
96
|
+
return await _async_file_response(target, "text/html", cache_control)
|
|
97
|
+
|
|
98
|
+
target = (dist / path).resolve()
|
|
99
|
+
if not str(target).startswith(str(dist)):
|
|
100
|
+
return HttpResponseNotFound("Not Found")
|
|
101
|
+
|
|
102
|
+
if await aiofiles.os.path.exists(target) and target.is_file():
|
|
103
|
+
return await _async_file_response(target, cache_control=cache_control)
|
|
104
|
+
|
|
105
|
+
entry = dist / entry_point
|
|
106
|
+
if await aiofiles.os.path.exists(entry):
|
|
107
|
+
return await _async_file_response(entry, "text/html", cache_control)
|
|
108
|
+
|
|
109
|
+
resp = await _async_error_response(error_400_path, 400, cache_control)
|
|
110
|
+
if resp is not None:
|
|
111
|
+
return resp
|
|
112
|
+
return HttpResponseNotFound("Not Found")
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.exception("Error serving serve file (async)")
|
|
115
|
+
resp = await _async_error_response(error_500_path, 500, cache_control)
|
|
116
|
+
if resp is not None:
|
|
117
|
+
return resp
|
|
118
|
+
return HttpResponseNotFound("Server Error", status=500)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _file_response(
|
|
122
|
+
path: Path, forced_type: str | None = None, cache_control: CacheControl = None
|
|
123
|
+
) -> FileResponse:
|
|
124
|
+
content_type = (
|
|
125
|
+
forced_type or mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
126
|
+
)
|
|
127
|
+
response = FileResponse(open(path, "rb"), content_type=content_type)
|
|
128
|
+
response["X-Content-Type-Options"] = "nosniff"
|
|
129
|
+
_apply_cache_control(response, cache_control, path.name)
|
|
130
|
+
return response
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _async_file_response(
|
|
134
|
+
path: Path, forced_type: str | None = None, cache_control: CacheControl = None
|
|
135
|
+
) -> FileResponse:
|
|
136
|
+
import aiofiles
|
|
137
|
+
|
|
138
|
+
content_type = (
|
|
139
|
+
forced_type or mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
140
|
+
)
|
|
141
|
+
async with aiofiles.open(path, "rb") as f:
|
|
142
|
+
content = await f.read()
|
|
143
|
+
response = FileResponse(BytesIO(content), content_type=content_type)
|
|
144
|
+
response["X-Content-Type-Options"] = "nosniff"
|
|
145
|
+
_apply_cache_control(response, cache_control, path.name)
|
|
146
|
+
return response
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _error_response(
|
|
150
|
+
error_path: str | None, status: int, cache_control: CacheControl = None
|
|
151
|
+
) -> FileResponse | None:
|
|
152
|
+
if error_path and Path(error_path).exists():
|
|
153
|
+
response = FileResponse(
|
|
154
|
+
open(error_path, "rb"), content_type="text/html", status=status
|
|
155
|
+
)
|
|
156
|
+
response["X-Content-Type-Options"] = "nosniff"
|
|
157
|
+
_apply_cache_control(response, cache_control, Path(error_path).name)
|
|
158
|
+
return response
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _async_error_response(
|
|
163
|
+
error_path: str | None, status: int, cache_control: CacheControl = None
|
|
164
|
+
) -> FileResponse | None:
|
|
165
|
+
import aiofiles
|
|
166
|
+
import aiofiles.os
|
|
167
|
+
|
|
168
|
+
if error_path and await aiofiles.os.path.exists(error_path):
|
|
169
|
+
async with aiofiles.open(error_path, "rb") as f:
|
|
170
|
+
content = await f.read()
|
|
171
|
+
response = FileResponse(
|
|
172
|
+
BytesIO(content), content_type="text/html", status=status
|
|
173
|
+
)
|
|
174
|
+
response["X-Content-Type-Options"] = "nosniff"
|
|
175
|
+
_apply_cache_control(response, cache_control, Path(error_path).name)
|
|
176
|
+
return response
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _apply_cache_control(
|
|
181
|
+
response: FileResponse, cache_control: CacheControl, filename: str
|
|
182
|
+
) -> None:
|
|
183
|
+
if cache_control is None:
|
|
184
|
+
return
|
|
185
|
+
if isinstance(cache_control, str):
|
|
186
|
+
response["Cache-Control"] = cache_control
|
|
187
|
+
return
|
|
188
|
+
for pattern, value in cache_control.items():
|
|
189
|
+
if fnmatch.fnmatch(filename, pattern):
|
|
190
|
+
response["Cache-Control"] = value
|
|
191
|
+
return
|