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,5 @@
1
+ .*
2
+ !.gitignore
3
+ *.lock
4
+ __pycache__/
5
+ dist/
@@ -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,3 @@
1
+ from .staticfiles import Frontend
2
+
3
+ __all__ = ["Frontend"]
@@ -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"