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 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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/dj-serve.svg)](https://pypi.org/project/dj-serve/)
56
+ [![Python versions](https://img.shields.io/pypi/pyversions/dj-serve.svg)](https://pypi.org/project/dj-serve/)
57
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
58
+ [![Typing: Typed](https://img.shields.io/badge/typing-PEP%20561-brightgreen.svg)](https://peps.python.org/pep-0561/)
59
+ [![CI](https://github.com/rroblf01/dj-serve/actions/workflows/test.yml/badge.svg)](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
@@ -0,0 +1,287 @@
1
+ [![PyPI version](https://img.shields.io/pypi/v/dj-serve.svg)](https://pypi.org/project/dj-serve/)
2
+ [![Python versions](https://img.shields.io/pypi/pyversions/dj-serve.svg)](https://pypi.org/project/dj-serve/)
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![Typing: Typed](https://img.shields.io/badge/typing-PEP%20561-brightgreen.svg)](https://peps.python.org/pep-0561/)
5
+ [![CI](https://github.com/rroblf01/dj-serve/actions/workflows/test.yml/badge.svg)](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