fastapi-vue 0.0.1__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.
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-vue
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Serves Vue static files / SPA on a FastAPI app. Use fastapi-vue-setup to install.
|
|
5
|
+
Project-URL: Homepage, https://git.zi.fi/LeoVasanko/fastapi-vue
|
|
6
|
+
Project-URL: Repository, https://github.com/LeoVasanko/fastapi-vue
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: aeg>=0.9.0
|
|
9
|
+
Requires-Dist: fastapi>=0.115.0
|
|
10
|
+
Requires-Dist: zstandard>=0.23.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# fastapi-vue
|
|
14
|
+
|
|
15
|
+
Implements static files routing with caching, compression and SPA support. Can be mounted at site root, unlike the built-in module.
|
|
16
|
+
|
|
17
|
+
- Automatic zstd compression
|
|
18
|
+
- ETag-based caching with immutable headers for hashed assets
|
|
19
|
+
- SPA (Single Page Application) support
|
|
20
|
+
- Favicon handling from hashed assets
|
|
21
|
+
- Dev mode integration with Vite
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv add fastapi-vue
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from contextlib import asynccontextmanager
|
|
33
|
+
from fastapi import FastAPI
|
|
34
|
+
from fastapi_vue import Frontend
|
|
35
|
+
|
|
36
|
+
# Point to your built frontend assets
|
|
37
|
+
frontend = Frontend(
|
|
38
|
+
"path/to/frontend-build",
|
|
39
|
+
spa=True,
|
|
40
|
+
favicon="/assets/favicon.ico",
|
|
41
|
+
cached=["/assets/"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@asynccontextmanager
|
|
45
|
+
async def lifespan(app: FastAPI):
|
|
46
|
+
await frontend.load()
|
|
47
|
+
yield
|
|
48
|
+
|
|
49
|
+
app = FastAPI(lifespan=lifespan)
|
|
50
|
+
|
|
51
|
+
# All your other routes...
|
|
52
|
+
|
|
53
|
+
# Final catch-all route for frontend files (keep at end of file)
|
|
54
|
+
frontend.route(app, "/")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
- `directory`: Path to static files directory
|
|
60
|
+
- `spa`: Enable SPA mode (serve index.html for unknown routes)
|
|
61
|
+
- `cached`: Path prefixes for immutable cache headers (e.g., `["/assets/"]`)
|
|
62
|
+
- `favicon`: Path prefix to serve at `/favicon.ico` (e.g., `"/assets/favicon.webp"`)
|
|
63
|
+
- `zstdlevel`: Compression level (default: 18)
|
|
64
|
+
|
|
65
|
+
## See also
|
|
66
|
+
|
|
67
|
+
Script [fastapi-vue-setup](https://github.com/LeoVasanko/fastapi-vue-setup) to create a project with statically built Vue using this module, and Vite devmode support.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# fastapi-vue
|
|
2
|
+
|
|
3
|
+
Implements static files routing with caching, compression and SPA support. Can be mounted at site root, unlike the built-in module.
|
|
4
|
+
|
|
5
|
+
- Automatic zstd compression
|
|
6
|
+
- ETag-based caching with immutable headers for hashed assets
|
|
7
|
+
- SPA (Single Page Application) support
|
|
8
|
+
- Favicon handling from hashed assets
|
|
9
|
+
- Dev mode integration with Vite
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv add fastapi-vue
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
from fastapi_vue import Frontend
|
|
23
|
+
|
|
24
|
+
# Point to your built frontend assets
|
|
25
|
+
frontend = Frontend(
|
|
26
|
+
"path/to/frontend-build",
|
|
27
|
+
spa=True,
|
|
28
|
+
favicon="/assets/favicon.ico",
|
|
29
|
+
cached=["/assets/"],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(app: FastAPI):
|
|
34
|
+
await frontend.load()
|
|
35
|
+
yield
|
|
36
|
+
|
|
37
|
+
app = FastAPI(lifespan=lifespan)
|
|
38
|
+
|
|
39
|
+
# All your other routes...
|
|
40
|
+
|
|
41
|
+
# Final catch-all route for frontend files (keep at end of file)
|
|
42
|
+
frontend.route(app, "/")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
- `directory`: Path to static files directory
|
|
48
|
+
- `spa`: Enable SPA mode (serve index.html for unknown routes)
|
|
49
|
+
- `cached`: Path prefixes for immutable cache headers (e.g., `["/assets/"]`)
|
|
50
|
+
- `favicon`: Path prefix to serve at `/favicon.ico` (e.g., `"/assets/favicon.webp"`)
|
|
51
|
+
- `zstdlevel`: Compression level (default: 18)
|
|
52
|
+
|
|
53
|
+
## See also
|
|
54
|
+
|
|
55
|
+
Script [fastapi-vue-setup](https://github.com/LeoVasanko/fastapi-vue-setup) to create a project with statically built Vue using this module, and Vite devmode support.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""FastAPI static file serving with zstd compression and SPA support."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path, PurePath, PurePosixPath
|
|
8
|
+
from wsgiref.handlers import format_date_time
|
|
9
|
+
|
|
10
|
+
from aeg.aegis128x2 import mac
|
|
11
|
+
from fastapi import FastAPI, Request, Response
|
|
12
|
+
from fastapi.concurrency import run_in_threadpool
|
|
13
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
14
|
+
from starlette.exceptions import HTTPException
|
|
15
|
+
from zstandard import ZstdCompressor
|
|
16
|
+
|
|
17
|
+
# Dev mode: index files but don't load content, return error responses
|
|
18
|
+
_DEVMODE = os.getenv("FASTAPI_VUE_FRONTEND_URL")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Frontend:
|
|
22
|
+
"""Static file server with automatic zstd compression and caching.
|
|
23
|
+
|
|
24
|
+
Features:
|
|
25
|
+
- Automatic zstd compression for compressible files
|
|
26
|
+
- ETag-based caching with configurable cache headers
|
|
27
|
+
- SPA (Single Page Application) support
|
|
28
|
+
- Favicon handling from hashed assets
|
|
29
|
+
- Dev mode: indexes files but returns error directing to Vite server
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
directory: Path to the directory containing static files
|
|
33
|
+
index: Name of the index file (default: "index.html")
|
|
34
|
+
spa: Enable SPA mode - serve index.html for unknown routes (default: False)
|
|
35
|
+
cached: Path prefixes that should have immutable cache headers
|
|
36
|
+
zstdlevel: Zstd compression level (default: 18)
|
|
37
|
+
favicon: Path to favicon for automatic /favicon.ico handling
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
directory: Path | str,
|
|
43
|
+
*,
|
|
44
|
+
index: str = "index.html",
|
|
45
|
+
spa: bool = False,
|
|
46
|
+
cached: str | list[str] | None = None,
|
|
47
|
+
zstdlevel: int = 18,
|
|
48
|
+
favicon: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
|
51
|
+
self.base: Path = Path(directory)
|
|
52
|
+
self.index = index
|
|
53
|
+
self.spa = spa
|
|
54
|
+
if cached is None:
|
|
55
|
+
self.cached_paths = []
|
|
56
|
+
elif isinstance(cached, str):
|
|
57
|
+
self.cached_paths = [cached]
|
|
58
|
+
else:
|
|
59
|
+
self.cached_paths = cached
|
|
60
|
+
self.zstdlevel = zstdlevel
|
|
61
|
+
self.favicon = favicon
|
|
62
|
+
self.devmode = bool(_DEVMODE)
|
|
63
|
+
|
|
64
|
+
def _index_only(self) -> set[str]:
|
|
65
|
+
"""Index file paths without loading content (for dev mode)."""
|
|
66
|
+
paths: set[str] = set()
|
|
67
|
+
if not self.base.exists():
|
|
68
|
+
return paths
|
|
69
|
+
queue = [PurePath()]
|
|
70
|
+
while queue:
|
|
71
|
+
current = self.base / queue.pop(0)
|
|
72
|
+
for p in current.iterdir():
|
|
73
|
+
rel = p.relative_to(self.base)
|
|
74
|
+
if p.is_dir():
|
|
75
|
+
queue.append(rel)
|
|
76
|
+
continue
|
|
77
|
+
name = "/" + rel.as_posix()
|
|
78
|
+
name = name.removesuffix(self.index)
|
|
79
|
+
paths.add(name)
|
|
80
|
+
if self.favicon:
|
|
81
|
+
p = PurePosixPath(self.favicon)
|
|
82
|
+
base = str(p.with_suffix(""))
|
|
83
|
+
ext = p.suffix
|
|
84
|
+
if any(path.startswith(base) and path.endswith(ext) for path in paths):
|
|
85
|
+
paths.add("/favicon.ico")
|
|
86
|
+
return paths
|
|
87
|
+
|
|
88
|
+
def _load(self, existing: dict):
|
|
89
|
+
"""Load static files from disk with compression."""
|
|
90
|
+
new: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
|
91
|
+
paths = [PurePath()]
|
|
92
|
+
while paths:
|
|
93
|
+
current = self.base / paths.pop(0)
|
|
94
|
+
for p in current.iterdir():
|
|
95
|
+
rel = p.relative_to(self.base)
|
|
96
|
+
if p.is_dir():
|
|
97
|
+
paths.append(rel)
|
|
98
|
+
continue
|
|
99
|
+
# Read file
|
|
100
|
+
name = "/" + rel.as_posix()
|
|
101
|
+
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
102
|
+
name = name.removesuffix(self.index)
|
|
103
|
+
data = p.read_bytes()
|
|
104
|
+
etag = mac(bytes(16), None, data)[:8].hex()
|
|
105
|
+
# Keep existing identical files as they are
|
|
106
|
+
if name in existing and existing[name][2]["etag"] == etag:
|
|
107
|
+
new[name] = existing[name]
|
|
108
|
+
continue
|
|
109
|
+
if mime.startswith("text/"):
|
|
110
|
+
mime += "; charset=UTF-8"
|
|
111
|
+
mtime = p.stat().st_mtime
|
|
112
|
+
cached = any(name.startswith(prefix) for prefix in self.cached_paths)
|
|
113
|
+
headers = {
|
|
114
|
+
"etag": etag,
|
|
115
|
+
"last-modified": format_date_time(mtime),
|
|
116
|
+
"cache-control": (
|
|
117
|
+
"max-age=31536000, immutable" if cached else "no-cache"
|
|
118
|
+
),
|
|
119
|
+
"content-type": mime,
|
|
120
|
+
}
|
|
121
|
+
zstd = ZstdCompressor(self.zstdlevel).compress(data)
|
|
122
|
+
if len(zstd) >= len(data):
|
|
123
|
+
zstd = None
|
|
124
|
+
new[name] = data, zstd, headers
|
|
125
|
+
if self.favicon:
|
|
126
|
+
p = PurePosixPath(self.favicon)
|
|
127
|
+
base = str(p.with_suffix(""))
|
|
128
|
+
ext = p.suffix
|
|
129
|
+
hashed_path = next(
|
|
130
|
+
(path for path in new if path.startswith(base) and path.endswith(ext)),
|
|
131
|
+
None,
|
|
132
|
+
)
|
|
133
|
+
if hashed_path:
|
|
134
|
+
new["/favicon.ico"] = new[hashed_path]
|
|
135
|
+
if not new:
|
|
136
|
+
msg = "Frontend files missing, check your installation.\n"
|
|
137
|
+
new["/"] = (
|
|
138
|
+
msg.encode(),
|
|
139
|
+
None,
|
|
140
|
+
{
|
|
141
|
+
"etag": "error",
|
|
142
|
+
"content-type": "text/plain",
|
|
143
|
+
"cache-control": "no-store",
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
return new
|
|
147
|
+
|
|
148
|
+
async def load(self, *, log=True):
|
|
149
|
+
"""Load or reload static files from disk.
|
|
150
|
+
|
|
151
|
+
In dev mode (FASTAPI_VUE_FRONTEND_URL set), only indexes paths without loading content.
|
|
152
|
+
"""
|
|
153
|
+
start = time.perf_counter()
|
|
154
|
+
if self.devmode:
|
|
155
|
+
# Dev mode: just index paths, no content loading
|
|
156
|
+
paths = await run_in_threadpool(self._index_only)
|
|
157
|
+
self._devmode_paths = paths
|
|
158
|
+
duration = time.perf_counter() - start
|
|
159
|
+
if log and paths:
|
|
160
|
+
print(
|
|
161
|
+
f"{self.base.name}: indexed {len(paths)} paths in {1000 * duration:.1f} ms (dev mode)"
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
self.www = await run_in_threadpool(self._load, self.www)
|
|
166
|
+
duration = time.perf_counter() - start
|
|
167
|
+
if not log:
|
|
168
|
+
return
|
|
169
|
+
compfiles = [(len(d), len(z)) for d, z, _ in self.www.values() if z]
|
|
170
|
+
raw = sum(v[0] for v in compfiles)
|
|
171
|
+
comp = sum(v[1] for v in compfiles)
|
|
172
|
+
ratio = comp / raw * 100 if raw else 100.0
|
|
173
|
+
print(
|
|
174
|
+
f"{self.base.name}: {len(self.www)} files in {1000 * duration:.1f} ms | "
|
|
175
|
+
f"zstd {len(compfiles)} files {1e-6 * raw:.2f}->{1e-6 * comp:.2f} MB ({ratio:.0f} %)"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def refresh_task(self, interval=1.0):
|
|
179
|
+
"""Background task to watch for file changes (for development)."""
|
|
180
|
+
if self.devmode:
|
|
181
|
+
return # No refresh needed in dev mode
|
|
182
|
+
try:
|
|
183
|
+
while True:
|
|
184
|
+
await asyncio.sleep(interval)
|
|
185
|
+
old = self.www
|
|
186
|
+
await self.load(log=False)
|
|
187
|
+
changes = []
|
|
188
|
+
changes += [
|
|
189
|
+
f"{h.get('last-modified', '')} {n}"
|
|
190
|
+
for n, (_, _, h) in sorted(self.www.items())
|
|
191
|
+
if not (n in old and old[n][2] is h)
|
|
192
|
+
]
|
|
193
|
+
deleted = set(old) - set(self.www)
|
|
194
|
+
changes += [f"Deleted {name}" for name in sorted(deleted)]
|
|
195
|
+
if changes:
|
|
196
|
+
print("Updated static files:\n" + "\n".join(changes))
|
|
197
|
+
except asyncio.CancelledError:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
def route(self, app: FastAPI, mount_path="/", *, name="static"):
|
|
201
|
+
"""Register this frontend handler with a FastAPI app.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
app: FastAPI application instance
|
|
205
|
+
mount_path: Path where the frontend should be mounted (default: "/")
|
|
206
|
+
name: Route name for the handler
|
|
207
|
+
"""
|
|
208
|
+
# Skip routing if in dev mode and no files were indexed
|
|
209
|
+
if self.devmode and not getattr(self, "_devmode_paths", None):
|
|
210
|
+
return
|
|
211
|
+
path = mount_path.rstrip("/") + "{path:path}"
|
|
212
|
+
app.api_route(path, methods=["GET", "HEAD"], name=name)(self.handle)
|
|
213
|
+
|
|
214
|
+
def handle(self, request: Request, path: str):
|
|
215
|
+
"""Static file handler. Takes path already stripped of mount_path prefix."""
|
|
216
|
+
name = path.removesuffix(self.index)
|
|
217
|
+
|
|
218
|
+
# Dev mode: return error directing to Vite server
|
|
219
|
+
if self.devmode:
|
|
220
|
+
paths = getattr(self, "_devmode_paths", set())
|
|
221
|
+
if name not in paths:
|
|
222
|
+
if name and f"{name}/" in paths:
|
|
223
|
+
return RedirectResponse(request.url.path + "/")
|
|
224
|
+
if self.spa and "text/html" in request.headers.get("accept", ""):
|
|
225
|
+
name = "/"
|
|
226
|
+
if name not in paths:
|
|
227
|
+
raise HTTPException(status_code=404)
|
|
228
|
+
return JSONResponse(
|
|
229
|
+
status_code=503,
|
|
230
|
+
content={
|
|
231
|
+
"detail": "Frontend assets served by Vite in dev mode. "
|
|
232
|
+
f"Connect via {_DEVMODE} instead."
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if name not in self.www:
|
|
237
|
+
# Friendly redirect for directories missing trailing slash
|
|
238
|
+
if name and f"{name}/" in self.www:
|
|
239
|
+
return RedirectResponse(request.url.path + "/")
|
|
240
|
+
# SPA support: serve / for unknown paths if the browser wants HTML
|
|
241
|
+
if self.spa and "text/html" in request.headers.get("accept", ""):
|
|
242
|
+
name = "/"
|
|
243
|
+
# 404 for everything else
|
|
244
|
+
if name not in self.www:
|
|
245
|
+
raise HTTPException(status_code=404)
|
|
246
|
+
# File found, give the appropriate response
|
|
247
|
+
data, zstd, headers = self.www[name]
|
|
248
|
+
if request.headers.get("if-none-match") == headers["etag"]:
|
|
249
|
+
return Response(status_code=304, headers=headers)
|
|
250
|
+
if zstd and "zstd" in request.headers.get("accept-encoding", ""):
|
|
251
|
+
headers["content-encoding"] = "zstd"
|
|
252
|
+
data = zstd
|
|
253
|
+
return Response(content=data, headers=headers)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-vue"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Serves Vue static files / SPA on a FastAPI app. Use fastapi-vue-setup to install."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.115.0",
|
|
9
|
+
"zstandard>=0.23.0",
|
|
10
|
+
"aeg>=0.9.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://git.zi.fi/LeoVasanko/fastapi-vue"
|
|
15
|
+
Repository = "https://github.com/LeoVasanko/fastapi-vue"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.version]
|
|
22
|
+
source = "vcs"
|