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/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
+ )
@@ -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