fastreact 0.1.0__py3-none-any.whl
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.
- fastreact/__init__.py +15 -0
- fastreact/cli.py +557 -0
- fastreact/core.py +256 -0
- fastreact/flask_core.py +188 -0
- fastreact/utils.py +233 -0
- fastreact-0.1.0.dist-info/METADATA +211 -0
- fastreact-0.1.0.dist-info/RECORD +10 -0
- fastreact-0.1.0.dist-info/WHEEL +5 -0
- fastreact-0.1.0.dist-info/entry_points.txt +2 -0
- fastreact-0.1.0.dist-info/top_level.txt +1 -0
fastreact/core.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI, Request
|
|
6
|
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .utils import build_traceback_html, NOT_FOUND_PAGE, NOT_ALLOWED_PAGE, is_browser_request
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FastReact:
|
|
14
|
+
"""
|
|
15
|
+
FastReact — FastAPI + React unified stack.
|
|
16
|
+
|
|
17
|
+
Route rules:
|
|
18
|
+
- Routes WITH react_prefix (default /api/) → React page routes
|
|
19
|
+
• Browser request → serves index.html → React Router renders
|
|
20
|
+
• Non-browser → 405 Not Allowed
|
|
21
|
+
• Unregistered → 404
|
|
22
|
+
- Routes WITHOUT react_prefix → normal FastAPI routes
|
|
23
|
+
• Work exactly like regular FastAPI (JSON, Jinja, anything)
|
|
24
|
+
• Accessible by everyone (Postman, curl, browser)
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
app = FastReact()
|
|
28
|
+
|
|
29
|
+
# React page route — browser only
|
|
30
|
+
@app.get("/api/users")
|
|
31
|
+
def users_page(): pass
|
|
32
|
+
|
|
33
|
+
# Normal API route — everyone
|
|
34
|
+
@app.get("/data/users")
|
|
35
|
+
def get_users():
|
|
36
|
+
return {"users": [...]}
|
|
37
|
+
|
|
38
|
+
Custom prefix:
|
|
39
|
+
app = FastReact(react_prefix="/ui/")
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
react_dir: str = "frontend",
|
|
45
|
+
build_dir: str = "frontend_build",
|
|
46
|
+
dev: bool = False,
|
|
47
|
+
dev_port: int = 5173,
|
|
48
|
+
traceback_overlay: bool = True,
|
|
49
|
+
title: str = "FastReact App",
|
|
50
|
+
react_prefix: str = "/api/",
|
|
51
|
+
):
|
|
52
|
+
self.react_dir = Path(react_dir)
|
|
53
|
+
self.build_dir = Path(build_dir)
|
|
54
|
+
self.dev = dev
|
|
55
|
+
self.dev_port = dev_port
|
|
56
|
+
self.traceback_overlay = traceback_overlay
|
|
57
|
+
# auto normalize: "ui/" → "/ui/" , "/ui" → "/ui/" , "ui" → "/ui/"
|
|
58
|
+
react_prefix = react_prefix.strip("/")
|
|
59
|
+
self.react_prefix = "/" + react_prefix + "/"
|
|
60
|
+
self._routes_finalized = False
|
|
61
|
+
self._react_page_routes: set[str] = set() # registered React page paths
|
|
62
|
+
|
|
63
|
+
self._app = FastAPI(title=title)
|
|
64
|
+
self._setup_static()
|
|
65
|
+
|
|
66
|
+
if traceback_overlay:
|
|
67
|
+
self._setup_traceback_middleware()
|
|
68
|
+
|
|
69
|
+
def _setup_static(self):
|
|
70
|
+
"""Mount /assets early."""
|
|
71
|
+
build_dir = self.build_dir
|
|
72
|
+
if not self.dev and build_dir.exists():
|
|
73
|
+
assets_dir = build_dir / "assets"
|
|
74
|
+
if assets_dir.exists():
|
|
75
|
+
self._app.mount(
|
|
76
|
+
"/assets",
|
|
77
|
+
StaticFiles(directory=str(assets_dir)),
|
|
78
|
+
name="assets"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _is_react_route(self, path: str) -> bool:
|
|
82
|
+
"""Check if path starts with react_prefix."""
|
|
83
|
+
return path.startswith(self.react_prefix) or path == self.react_prefix.rstrip("/")
|
|
84
|
+
|
|
85
|
+
def _finalize_routes(self):
|
|
86
|
+
"""
|
|
87
|
+
Register catch-all LAST so user API routes are matched first.
|
|
88
|
+
Called on first request via __call__.
|
|
89
|
+
"""
|
|
90
|
+
if self._routes_finalized:
|
|
91
|
+
return
|
|
92
|
+
self._routes_finalized = True
|
|
93
|
+
|
|
94
|
+
app = self._app
|
|
95
|
+
build_dir = self.build_dir
|
|
96
|
+
dev = self.dev
|
|
97
|
+
dev_port = self.dev_port
|
|
98
|
+
react_page_routes = self._react_page_routes
|
|
99
|
+
react_prefix = self.react_prefix
|
|
100
|
+
|
|
101
|
+
# ── Serve root / ─────────────────────────────────────────────
|
|
102
|
+
if not dev and build_dir.exists():
|
|
103
|
+
|
|
104
|
+
@app.get("/", response_class=HTMLResponse)
|
|
105
|
+
async def serve_root():
|
|
106
|
+
return FileResponse(str(build_dir / "index.html"))
|
|
107
|
+
|
|
108
|
+
# ── Catch-all ─────────────────────────────────────────────
|
|
109
|
+
@app.get("/{full_path:path}", response_class=HTMLResponse)
|
|
110
|
+
async def catch_all(request: Request, full_path: str):
|
|
111
|
+
path = "/" + full_path
|
|
112
|
+
|
|
113
|
+
# ── React prefix route (/api/...) ─────────────────────
|
|
114
|
+
if path.startswith(react_prefix) or path == react_prefix.rstrip("/"):
|
|
115
|
+
|
|
116
|
+
# Not registered → 404
|
|
117
|
+
if path not in react_page_routes:
|
|
118
|
+
return HTMLResponse(
|
|
119
|
+
NOT_FOUND_PAGE.format(path=path),
|
|
120
|
+
status_code=404
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Registered — check if browser
|
|
124
|
+
accept = request.headers.get("accept", "")
|
|
125
|
+
if not is_browser_request(accept):
|
|
126
|
+
# Non-browser (Postman/curl) → 405
|
|
127
|
+
return HTMLResponse(
|
|
128
|
+
NOT_ALLOWED_PAGE.format(path=path),
|
|
129
|
+
status_code=405
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Browser + registered → serve React
|
|
133
|
+
return FileResponse(str(build_dir / "index.html"))
|
|
134
|
+
|
|
135
|
+
# ── Normal route (no prefix) ──────────────────────────
|
|
136
|
+
# Serve index.html for React Router sub-paths
|
|
137
|
+
index = build_dir / "index.html"
|
|
138
|
+
if index.exists():
|
|
139
|
+
return FileResponse(str(index))
|
|
140
|
+
|
|
141
|
+
return HTMLResponse(
|
|
142
|
+
NOT_FOUND_PAGE.format(path=path),
|
|
143
|
+
status_code=404
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
elif dev:
|
|
147
|
+
|
|
148
|
+
@app.get("/", response_class=HTMLResponse)
|
|
149
|
+
async def proxy_root():
|
|
150
|
+
return await _proxy_to_vite("/", dev_port)
|
|
151
|
+
|
|
152
|
+
@app.get("/{full_path:path}")
|
|
153
|
+
async def proxy_dev(request: Request, full_path: str):
|
|
154
|
+
path = "/" + full_path
|
|
155
|
+
|
|
156
|
+
if path.startswith(react_prefix):
|
|
157
|
+
if path not in react_page_routes:
|
|
158
|
+
return HTMLResponse(NOT_FOUND_PAGE.format(path=path), status_code=404)
|
|
159
|
+
accept = request.headers.get("accept", "")
|
|
160
|
+
if not is_browser_request(accept):
|
|
161
|
+
return HTMLResponse(NOT_ALLOWED_PAGE.format(path=path), status_code=405)
|
|
162
|
+
|
|
163
|
+
return await _proxy_to_vite(path, dev_port)
|
|
164
|
+
|
|
165
|
+
else:
|
|
166
|
+
@app.get("/", response_class=HTMLResponse)
|
|
167
|
+
async def no_react():
|
|
168
|
+
return HTMLResponse("""
|
|
169
|
+
<html><body style='font-family:monospace;padding:2rem;background:#111;color:#eee'>
|
|
170
|
+
<h2>⚡ FastReact Running</h2>
|
|
171
|
+
<p>No React build found.</p>
|
|
172
|
+
<p>Run: <code>cd frontend && npm run build</code></p>
|
|
173
|
+
</body></html>
|
|
174
|
+
""")
|
|
175
|
+
|
|
176
|
+
def _setup_traceback_middleware(self):
|
|
177
|
+
app = self._app
|
|
178
|
+
|
|
179
|
+
@app.middleware("http")
|
|
180
|
+
async def traceback_middleware(request: Request, call_next):
|
|
181
|
+
try:
|
|
182
|
+
response = await call_next(request)
|
|
183
|
+
return response
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
html = build_traceback_html(
|
|
186
|
+
error_type=type(exc).__name__,
|
|
187
|
+
error_message=str(exc),
|
|
188
|
+
traceback_text=traceback.format_exc(),
|
|
189
|
+
path=request.url.path,
|
|
190
|
+
framework="FastAPI",
|
|
191
|
+
)
|
|
192
|
+
return HTMLResponse(content=html, status_code=500)
|
|
193
|
+
|
|
194
|
+
# ── Decorator delegation ──────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def _register(self, method: str, path: str, **kwargs):
|
|
197
|
+
"""
|
|
198
|
+
Core registration logic.
|
|
199
|
+
Auto-normalizes path — adds leading slash if missing.
|
|
200
|
+
If path starts with react_prefix → track as React page route.
|
|
201
|
+
The actual response is handled by the catch-all.
|
|
202
|
+
"""
|
|
203
|
+
# Auto-normalize: "data/status" → "/data/status"
|
|
204
|
+
if not path.startswith("/"):
|
|
205
|
+
path = "/" + path
|
|
206
|
+
|
|
207
|
+
if self._is_react_route(path):
|
|
208
|
+
# Track this path as an allowed React page route
|
|
209
|
+
self._react_page_routes.add(path)
|
|
210
|
+
# Return a pass-through decorator — catch-all handles the response
|
|
211
|
+
def decorator(f):
|
|
212
|
+
return f
|
|
213
|
+
return decorator
|
|
214
|
+
else:
|
|
215
|
+
# Normal FastAPI route — delegate directly
|
|
216
|
+
return getattr(self._app, method)(path, **kwargs)
|
|
217
|
+
|
|
218
|
+
def get(self, path: str, **kwargs):
|
|
219
|
+
return self._register("get", path, **kwargs)
|
|
220
|
+
|
|
221
|
+
def post(self, path: str, **kwargs):
|
|
222
|
+
return self._register("post", path, **kwargs)
|
|
223
|
+
|
|
224
|
+
def put(self, path: str, **kwargs):
|
|
225
|
+
return self._register("put", path, **kwargs)
|
|
226
|
+
|
|
227
|
+
def delete(self, path: str, **kwargs):
|
|
228
|
+
return self._register("delete", path, **kwargs)
|
|
229
|
+
|
|
230
|
+
def patch(self, path: str, **kwargs):
|
|
231
|
+
return self._register("patch", path, **kwargs)
|
|
232
|
+
|
|
233
|
+
def include_router(self, router, **kwargs):
|
|
234
|
+
return self._app.include_router(router, **kwargs)
|
|
235
|
+
|
|
236
|
+
# ── ASGI entrypoint ───────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
async def __call__(self, scope, receive, send):
|
|
239
|
+
self._finalize_routes()
|
|
240
|
+
await self._app(scope, receive, send)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def _proxy_to_vite(path: str, port: int) -> HTMLResponse:
|
|
244
|
+
try:
|
|
245
|
+
async with httpx.AsyncClient() as client:
|
|
246
|
+
response = await client.get(f"http://localhost:{port}{path}")
|
|
247
|
+
return HTMLResponse(content=response.text, status_code=response.status_code)
|
|
248
|
+
except Exception:
|
|
249
|
+
return HTMLResponse(
|
|
250
|
+
f"""<html><body style='font-family:monospace;padding:2rem;background:#111;color:#eee'>
|
|
251
|
+
<h2>⚡ FastReact Dev Mode</h2>
|
|
252
|
+
<p>Vite dev server not running on port {port}.</p>
|
|
253
|
+
<p>Run: <code>cd frontend && npm run dev</code></p>
|
|
254
|
+
</body></html>""",
|
|
255
|
+
status_code=503
|
|
256
|
+
)
|
fastreact/flask_core.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .utils import build_traceback_html, NOT_FOUND_PAGE, NOT_ALLOWED_PAGE, is_browser_request
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from flask import Flask, send_from_directory, request as flask_request
|
|
8
|
+
except ImportError:
|
|
9
|
+
raise ImportError("Flask is not installed. Run: pip install flask")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FlaskReact:
|
|
13
|
+
"""
|
|
14
|
+
FlaskReact — Flask + React unified stack.
|
|
15
|
+
|
|
16
|
+
Route rules:
|
|
17
|
+
- Routes WITH react_prefix (default /api/) → React page routes
|
|
18
|
+
• Browser request → serves index.html → React Router renders
|
|
19
|
+
• Non-browser → 405 Not Allowed
|
|
20
|
+
• Unregistered → 404
|
|
21
|
+
- Routes WITHOUT react_prefix → normal Flask routes
|
|
22
|
+
• Work exactly like regular Flask
|
|
23
|
+
• Accessible by everyone
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
app = FlaskReact()
|
|
27
|
+
|
|
28
|
+
# React page route — browser only
|
|
29
|
+
@app.route("/api/users")
|
|
30
|
+
def users_page(): pass
|
|
31
|
+
|
|
32
|
+
# Normal route — everyone
|
|
33
|
+
@app.route("/data/users")
|
|
34
|
+
def get_users():
|
|
35
|
+
return {"users": [...]}
|
|
36
|
+
|
|
37
|
+
app.run()
|
|
38
|
+
|
|
39
|
+
Custom prefix:
|
|
40
|
+
app = FlaskReact(react_prefix="/ui/")
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
react_dir: str = "frontend",
|
|
46
|
+
build_dir: str = "frontend_build",
|
|
47
|
+
traceback_overlay: bool = True,
|
|
48
|
+
title: str = "FlaskReact App",
|
|
49
|
+
react_prefix: str = "/api/",
|
|
50
|
+
):
|
|
51
|
+
self.react_dir = Path(react_dir)
|
|
52
|
+
self.build_dir = Path(build_dir)
|
|
53
|
+
self.traceback_overlay = traceback_overlay
|
|
54
|
+
# auto normalize: "ui/" → "/ui/" , "/ui" → "/ui/" , "ui" → "/ui/"
|
|
55
|
+
react_prefix = react_prefix.strip("/")
|
|
56
|
+
self.react_prefix = "/" + react_prefix + "/"
|
|
57
|
+
self._react_page_routes: set[str] = set()
|
|
58
|
+
|
|
59
|
+
self._app = Flask(title)
|
|
60
|
+
|
|
61
|
+
self._setup_react_serving()
|
|
62
|
+
|
|
63
|
+
if traceback_overlay:
|
|
64
|
+
self._setup_traceback_overlay()
|
|
65
|
+
|
|
66
|
+
def _is_react_route(self, path: str) -> bool:
|
|
67
|
+
return path.startswith(self.react_prefix) or path == self.react_prefix.rstrip("/")
|
|
68
|
+
|
|
69
|
+
def _setup_react_serving(self):
|
|
70
|
+
"""Serve React build + handle routing rules."""
|
|
71
|
+
app = self._app
|
|
72
|
+
build_dir = self.build_dir
|
|
73
|
+
react_prefix = self.react_prefix
|
|
74
|
+
react_page_routes = self._react_page_routes
|
|
75
|
+
|
|
76
|
+
if build_dir.exists():
|
|
77
|
+
|
|
78
|
+
@app.route("/")
|
|
79
|
+
def serve_root():
|
|
80
|
+
return send_from_directory(str(build_dir), "index.html")
|
|
81
|
+
|
|
82
|
+
@app.route("/assets/<path:filename>")
|
|
83
|
+
def serve_assets(filename):
|
|
84
|
+
return send_from_directory(str(build_dir / "assets"), filename)
|
|
85
|
+
|
|
86
|
+
@app.route("/<path:full_path>")
|
|
87
|
+
def catch_all(full_path):
|
|
88
|
+
path = "/" + full_path
|
|
89
|
+
|
|
90
|
+
# ── React prefix route (/api/...) ─────────────────────
|
|
91
|
+
if path.startswith(react_prefix) or path == react_prefix.rstrip("/"):
|
|
92
|
+
|
|
93
|
+
# Not registered → 404
|
|
94
|
+
if path not in react_page_routes:
|
|
95
|
+
return NOT_FOUND_PAGE.format(path=path), 404
|
|
96
|
+
|
|
97
|
+
# Registered — check if browser
|
|
98
|
+
accept = flask_request.headers.get("Accept", "")
|
|
99
|
+
if not is_browser_request(accept):
|
|
100
|
+
return NOT_ALLOWED_PAGE.format(path=path), 405
|
|
101
|
+
|
|
102
|
+
# Browser + registered → serve React
|
|
103
|
+
return send_from_directory(str(build_dir), "index.html")
|
|
104
|
+
|
|
105
|
+
# ── Normal path — serve static or index.html ──────────
|
|
106
|
+
# Try static file first
|
|
107
|
+
static_file = build_dir / full_path
|
|
108
|
+
if static_file.exists():
|
|
109
|
+
return send_from_directory(str(build_dir), full_path)
|
|
110
|
+
|
|
111
|
+
# Fallback → index.html for React Router
|
|
112
|
+
return send_from_directory(str(build_dir), "index.html")
|
|
113
|
+
|
|
114
|
+
else:
|
|
115
|
+
@app.route("/")
|
|
116
|
+
def no_react():
|
|
117
|
+
return """
|
|
118
|
+
<html><body style='font-family:monospace;padding:2rem;background:#111;color:#eee'>
|
|
119
|
+
<h2>⚡ FlaskReact Running</h2>
|
|
120
|
+
<p>No React build found.</p>
|
|
121
|
+
<p>Run: <code>cd frontend && npm run build</code></p>
|
|
122
|
+
</body></html>
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def _setup_traceback_overlay(self):
|
|
126
|
+
app = self._app
|
|
127
|
+
|
|
128
|
+
@app.errorhandler(Exception)
|
|
129
|
+
def handle_exception(exc):
|
|
130
|
+
html = build_traceback_html(
|
|
131
|
+
error_type=type(exc).__name__,
|
|
132
|
+
error_message=str(exc),
|
|
133
|
+
traceback_text=traceback.format_exc(),
|
|
134
|
+
path=flask_request.path,
|
|
135
|
+
framework="Flask",
|
|
136
|
+
)
|
|
137
|
+
return html, 500
|
|
138
|
+
|
|
139
|
+
# ── Decorator delegation ──────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def _register(self, path: str, **kwargs):
|
|
142
|
+
"""
|
|
143
|
+
Core registration.
|
|
144
|
+
Auto-normalizes path — adds leading slash if missing.
|
|
145
|
+
React prefix routes → tracked, no-op handler (catch_all serves response).
|
|
146
|
+
Normal routes → delegated to Flask directly.
|
|
147
|
+
"""
|
|
148
|
+
# Auto-normalize: "data/status" → "/data/status"
|
|
149
|
+
if not path.startswith("/"):
|
|
150
|
+
path = "/" + path
|
|
151
|
+
|
|
152
|
+
if self._is_react_route(path):
|
|
153
|
+
self._react_page_routes.add(path)
|
|
154
|
+
# Return a decorator that accepts the function but does nothing
|
|
155
|
+
# catch_all in _setup_react_serving handles the actual response
|
|
156
|
+
def decorator(f):
|
|
157
|
+
return f # return original function unchanged
|
|
158
|
+
return decorator
|
|
159
|
+
else:
|
|
160
|
+
return self._app.route(path, **kwargs)
|
|
161
|
+
|
|
162
|
+
def route(self, path: str, **kwargs):
|
|
163
|
+
return self._register(path, **kwargs)
|
|
164
|
+
|
|
165
|
+
def get(self, path: str, **kwargs):
|
|
166
|
+
kwargs["methods"] = ["GET"]
|
|
167
|
+
return self._register(path, **kwargs)
|
|
168
|
+
|
|
169
|
+
def post(self, path: str, **kwargs):
|
|
170
|
+
kwargs["methods"] = ["POST"]
|
|
171
|
+
return self._register(path, **kwargs)
|
|
172
|
+
|
|
173
|
+
def put(self, path: str, **kwargs):
|
|
174
|
+
kwargs["methods"] = ["PUT"]
|
|
175
|
+
return self._register(path, **kwargs)
|
|
176
|
+
|
|
177
|
+
def delete(self, path: str, **kwargs):
|
|
178
|
+
kwargs["methods"] = ["DELETE"]
|
|
179
|
+
return self._register(path, **kwargs)
|
|
180
|
+
|
|
181
|
+
def run(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs):
|
|
182
|
+
print(f"""
|
|
183
|
+
⚡ FlaskReact running at http://{host}:{port}
|
|
184
|
+
React UI → http://{host}:{port}/
|
|
185
|
+
API → http://{host}:{port}/api/...
|
|
186
|
+
React pages protected by prefix: {self.react_prefix}
|
|
187
|
+
""")
|
|
188
|
+
self._app.run(host=host, port=port, debug=debug, **kwargs)
|
fastreact/utils.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
TRACEBACK_TEMPLATE = """<!DOCTYPE html>
|
|
5
|
+
<html>
|
|
6
|
+
<head>
|
|
7
|
+
<title>FastReact Error</title>
|
|
8
|
+
<style>
|
|
9
|
+
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
10
|
+
body {{
|
|
11
|
+
background: #1a1a1a;
|
|
12
|
+
color: #e0e0e0;
|
|
13
|
+
font-family: 'Fira Code', 'Courier New', monospace;
|
|
14
|
+
padding: 2rem;
|
|
15
|
+
}}
|
|
16
|
+
.header {{
|
|
17
|
+
background: #c0392b;
|
|
18
|
+
color: white;
|
|
19
|
+
padding: 1rem 1.5rem;
|
|
20
|
+
border-radius: 8px 8px 0 0;
|
|
21
|
+
font-size: 1.1rem;
|
|
22
|
+
font-weight: bold;
|
|
23
|
+
}}
|
|
24
|
+
.error-type {{
|
|
25
|
+
background: #2d2d2d;
|
|
26
|
+
border-left: 4px solid #c0392b;
|
|
27
|
+
padding: 1rem 1.5rem;
|
|
28
|
+
font-size: 1rem;
|
|
29
|
+
color: #e74c3c;
|
|
30
|
+
}}
|
|
31
|
+
.traceback {{
|
|
32
|
+
background: #242424;
|
|
33
|
+
padding: 1.5rem;
|
|
34
|
+
white-space: pre-wrap;
|
|
35
|
+
font-size: 0.875rem;
|
|
36
|
+
line-height: 1.6;
|
|
37
|
+
border-radius: 0 0 8px 8px;
|
|
38
|
+
border: 1px solid #333;
|
|
39
|
+
color: #a8b5c8;
|
|
40
|
+
}}
|
|
41
|
+
.footer {{
|
|
42
|
+
margin-top: 1rem;
|
|
43
|
+
font-size: 0.75rem;
|
|
44
|
+
color: #555;
|
|
45
|
+
text-align: center;
|
|
46
|
+
}}
|
|
47
|
+
.pill {{
|
|
48
|
+
display: inline-block;
|
|
49
|
+
background: #2d2d2d;
|
|
50
|
+
border: 1px solid #444;
|
|
51
|
+
padding: 0.2rem 0.8rem;
|
|
52
|
+
border-radius: 999px;
|
|
53
|
+
font-size: 0.75rem;
|
|
54
|
+
margin-right: 0.5rem;
|
|
55
|
+
color: #aaa;
|
|
56
|
+
}}
|
|
57
|
+
.top-bar {{
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 0.5rem;
|
|
61
|
+
margin-bottom: 1rem;
|
|
62
|
+
}}
|
|
63
|
+
</style>
|
|
64
|
+
</head>
|
|
65
|
+
<body>
|
|
66
|
+
<div class="top-bar">
|
|
67
|
+
<span class="pill">⚡ FastReact</span>
|
|
68
|
+
<span class="pill">🐍 Python {python_version}</span>
|
|
69
|
+
<span class="pill">🌐 {path}</span>
|
|
70
|
+
<span class="pill">{framework}</span>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="header">
|
|
73
|
+
<span>🔴</span> {framework} Traceback — {error_type}
|
|
74
|
+
</div>
|
|
75
|
+
<div class="error-type">
|
|
76
|
+
{error_message}
|
|
77
|
+
</div>
|
|
78
|
+
<div class="traceback">{traceback_text}</div>
|
|
79
|
+
<div class="footer">
|
|
80
|
+
Powered by FastReact — Python errors served like React errors 🚀
|
|
81
|
+
</div>
|
|
82
|
+
</body>
|
|
83
|
+
</html>"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_traceback_html(
|
|
87
|
+
error_type: str,
|
|
88
|
+
error_message: str,
|
|
89
|
+
traceback_text: str,
|
|
90
|
+
path: str,
|
|
91
|
+
framework: str = "FastAPI",
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Render the traceback HTML overlay."""
|
|
94
|
+
clean_tb = filter_traceback(traceback_text)
|
|
95
|
+
return TRACEBACK_TEMPLATE.format(
|
|
96
|
+
python_version=sys.version.split()[0],
|
|
97
|
+
path=path,
|
|
98
|
+
framework=framework,
|
|
99
|
+
error_type=error_type,
|
|
100
|
+
error_message=error_message,
|
|
101
|
+
traceback_text=clean_tb,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def filter_traceback(full_tb: str) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Filter traceback to show only user code.
|
|
108
|
+
Removes internal framework lines from:
|
|
109
|
+
- site-packages (starlette, fastapi, anyio, flask, werkzeug...)
|
|
110
|
+
- fastreact internals
|
|
111
|
+
- frozen modules
|
|
112
|
+
"""
|
|
113
|
+
SKIP_PATTERNS = (
|
|
114
|
+
"site-packages",
|
|
115
|
+
"<frozen ",
|
|
116
|
+
"fastreact\\core.py",
|
|
117
|
+
"fastreact/core.py",
|
|
118
|
+
"fastreact\\flask_core.py",
|
|
119
|
+
"fastreact/flask_core.py",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
lines = full_tb.splitlines()
|
|
123
|
+
result = []
|
|
124
|
+
i = 0
|
|
125
|
+
|
|
126
|
+
while i < len(lines):
|
|
127
|
+
line = lines[i]
|
|
128
|
+
|
|
129
|
+
# Always keep header line
|
|
130
|
+
if line.startswith("Traceback"):
|
|
131
|
+
result.append(line)
|
|
132
|
+
i += 1
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# Final error line — no leading whitespace + has colon
|
|
136
|
+
if not line.startswith(" ") and ":" in line:
|
|
137
|
+
result.append(line)
|
|
138
|
+
i += 1
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# File line — decide keep or skip
|
|
142
|
+
if line.strip().startswith("File "):
|
|
143
|
+
is_internal = any(pat in line for pat in SKIP_PATTERNS)
|
|
144
|
+
if is_internal:
|
|
145
|
+
# Skip this File line + all following code/caret lines
|
|
146
|
+
i += 1
|
|
147
|
+
while i < len(lines):
|
|
148
|
+
next_line = lines[i]
|
|
149
|
+
if next_line.strip().startswith("File ") or (
|
|
150
|
+
not next_line.startswith(" ") and ":" in next_line
|
|
151
|
+
):
|
|
152
|
+
break
|
|
153
|
+
i += 1
|
|
154
|
+
continue
|
|
155
|
+
else:
|
|
156
|
+
result.append(line)
|
|
157
|
+
i += 1
|
|
158
|
+
# Keep code + caret lines that follow
|
|
159
|
+
while i < len(lines):
|
|
160
|
+
next_line = lines[i]
|
|
161
|
+
if next_line.strip().startswith("File ") or (
|
|
162
|
+
not next_line.startswith(" ") and ":" in next_line
|
|
163
|
+
):
|
|
164
|
+
break
|
|
165
|
+
result.append(next_line)
|
|
166
|
+
i += 1
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
result.append(line)
|
|
170
|
+
i += 1
|
|
171
|
+
|
|
172
|
+
# Safety: if filter removed everything, return full traceback
|
|
173
|
+
if len(result) <= 2:
|
|
174
|
+
return full_tb
|
|
175
|
+
|
|
176
|
+
return "\n".join(result)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
NOT_ALLOWED_PAGE = """<!DOCTYPE html>
|
|
180
|
+
<html>
|
|
181
|
+
<head><title>405 Not Allowed</title>
|
|
182
|
+
<style>
|
|
183
|
+
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
|
184
|
+
body {{ background:#1a1a1a; color:#e0e0e0; font-family:monospace; display:flex; align-items:center; justify-content:center; min-height:100vh; }}
|
|
185
|
+
.box {{ background:#2d2d2d; border:1px solid #444; border-radius:12px; padding:2rem 3rem; text-align:center; }}
|
|
186
|
+
.code {{ font-size:4rem; color:#e74c3c; }}
|
|
187
|
+
.msg {{ color:#aaa; margin-top:0.5rem; }}
|
|
188
|
+
.pill {{ display:inline-block; background:#1a1a1a; border:1px solid #e74c3c; color:#e74c3c; padding:0.2rem 0.8rem; border-radius:999px; font-size:0.75rem; margin-top:1rem; }}
|
|
189
|
+
</style></head>
|
|
190
|
+
<body>
|
|
191
|
+
<div class="box">
|
|
192
|
+
<div class="code">405</div>
|
|
193
|
+
<h2>Not Allowed</h2>
|
|
194
|
+
<p class="msg">{path} is a React route.<br>It can only be accessed from a browser.</p>
|
|
195
|
+
<div class="pill">⚡ FastReact</div>
|
|
196
|
+
</div>
|
|
197
|
+
</body>
|
|
198
|
+
</html>"""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
NOT_FOUND_PAGE = """<!DOCTYPE html>
|
|
202
|
+
<html>
|
|
203
|
+
<head><title>404 Not Found</title>
|
|
204
|
+
<style>
|
|
205
|
+
* {{ margin:0; padding:0; box-sizing:border-box; }}
|
|
206
|
+
body {{ background:#1a1a1a; color:#e0e0e0; font-family:monospace; display:flex; align-items:center; justify-content:center; min-height:100vh; }}
|
|
207
|
+
.box {{ background:#2d2d2d; border:1px solid #444; border-radius:12px; padding:2rem 3rem; text-align:center; }}
|
|
208
|
+
.code {{ font-size:4rem; color:#888; }}
|
|
209
|
+
.msg {{ color:#aaa; margin-top:0.5rem; }}
|
|
210
|
+
.pill {{ display:inline-block; background:#1a1a1a; border:1px solid #555; color:#888; padding:0.2rem 0.8rem; border-radius:999px; font-size:0.75rem; margin-top:1rem; }}
|
|
211
|
+
a {{ color:#e74c3c; text-decoration:none; display:block; margin-top:1rem; }}
|
|
212
|
+
</style></head>
|
|
213
|
+
<body>
|
|
214
|
+
<div class="box">
|
|
215
|
+
<div class="code">404</div>
|
|
216
|
+
<h2>Page Not Found</h2>
|
|
217
|
+
<p class="msg">{path} is not a registered route.</p>
|
|
218
|
+
<a href="/">← Go Home</a>
|
|
219
|
+
<div class="pill">⚡ FastReact</div>
|
|
220
|
+
</div>
|
|
221
|
+
</body>
|
|
222
|
+
</html>"""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def is_browser_request(accept_header: str) -> bool:
|
|
226
|
+
"""
|
|
227
|
+
Detect if request is from a browser or a service like Postman/curl.
|
|
228
|
+
Browsers send Accept: text/html
|
|
229
|
+
Postman/curl/services send Accept: application/json or */*
|
|
230
|
+
"""
|
|
231
|
+
if not accept_header:
|
|
232
|
+
return False
|
|
233
|
+
return "text/html" in accept_header
|