122 lines
4.8 KiB
Markdown
122 lines
4.8 KiB
Markdown
# Local SPA Proxy Server Pattern
|
|
|
|
When testing a React/Vite SPA locally that needs to reach a backend API on a different port (e.g., PocketBase on 8091, or any API server), use a Python HTTP server that serves static files AND proxies API paths to the real backend.
|
|
|
|
## Problem
|
|
|
|
SPAs built with Vite often reference backend APIs at relative paths like `/pb` or `/api`. When served from a simple `python3 -m http.server`, these requests go to the wrong origin and fail with 404 or CORS errors.
|
|
|
|
## Solution
|
|
|
|
A ~60-line Python script that:
|
|
1. Serves static files from the `dist/` directory
|
|
2. Proxies API paths (`/pb/*`, `/api/*`) to the real backend
|
|
3. Adds CORS headers to all responses
|
|
4. Handles SPA fallback (serves `index.html` for unknown paths)
|
|
5. Supports GET, POST, PUT, PATCH, DELETE, OPTIONS methods
|
|
|
|
## Template
|
|
|
|
Save as `serve-proxy.py` in the project root:
|
|
|
|
```python
|
|
#!/usr/bin/env python3
|
|
"""Serve SPA dist + proxy /pb to backend."""
|
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
import urllib.request, os
|
|
|
|
DIST = './dist'
|
|
BACKEND = 'http://127.0.0.1:8091'
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, directory=DIST, **kwargs)
|
|
|
|
def end_headers(self):
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
|
|
self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
|
|
super().end_headers()
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(204)
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith('/pb') or self.path.startswith('/api'):
|
|
self.proxy()
|
|
else:
|
|
full = os.path.join(DIST, self.path.lstrip('/'))
|
|
if not os.path.exists(full) or os.path.isdir(full) and self.path != '/':
|
|
self.path = '/index.html'
|
|
super().do_GET()
|
|
|
|
def do_POST(self): self.proxy() if self.is_api_path() else self.send_error(405)
|
|
def do_PUT(self): self.proxy()
|
|
def do_PATCH(self): self.proxy()
|
|
def do_DELETE(self): self.proxy()
|
|
|
|
def is_api_path(self):
|
|
return self.path.startswith('/pb') or self.path.startswith('/api')
|
|
|
|
def proxy(self):
|
|
url = BACKEND + self.path
|
|
# Strip proxy prefix if the SDK already includes it
|
|
if self.path.startswith('/pb'):
|
|
url = BACKEND + self.path[3:]
|
|
if not url.startswith(BACKEND + '/api'):
|
|
url = BACKEND + '/api' + self.path[3:]
|
|
body = None
|
|
if self.headers.get('Content-Length'):
|
|
body = self.rfile.read(int(self.headers['Content-Length']))
|
|
req = urllib.request.Request(url, data=body, method=self.command)
|
|
for k, v in self.headers.items():
|
|
if k.lower() not in ('host', 'connection', 'origin', 'referer'):
|
|
req.add_header(k, v)
|
|
try:
|
|
resp = urllib.request.urlopen(req)
|
|
self.send_response(resp.status)
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in ('transfer-encoding', 'connection'):
|
|
self.send_header(k, v)
|
|
self.end_headers()
|
|
self.wfile.write(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
self.send_response(e.code)
|
|
self.end_headers()
|
|
self.wfile.write(e.read())
|
|
|
|
if __name__ == '__main__':
|
|
server = HTTPServer(('0.0.0.0', 4173), Handler)
|
|
print(f'Serving on http://0.0.0.0:4173 → {BACKEND}')
|
|
server.serve_forever()
|
|
```
|
|
|
|
## Common Pitfalls
|
|
|
|
### Double `/api` prefix
|
|
The PocketBase JS SDK constructs paths like `/pb/api/collections/...`. The proxy must strip `/pb` and append the remainder, NOT add another `/api/`. Check what paths the SDK actually builds by inspecting the built JS or testing with curl.
|
|
|
|
### PocketBase SDK adds `/api/` to the base URL
|
|
`new PocketBase('/pb')` builds URLs as: `/pb/api/collections/...`. The SDK always inserts `/api/` between the base URL and the collection path.
|
|
|
|
### Missing CORS on error responses
|
|
If `urllib.error.HTTPError` is caught, the error response body is written but CORS headers from `end_headers()` may not be called in the right order. Ensure `end_headers()` is called before `wfile.write()` in error paths.
|
|
|
|
### Empty browser console errors
|
|
React lazy-loaded chunk failures often produce empty exceptions in browser consoles. If a page renders blank with no visible error, revert to eager imports temporarily to surface the actual error message.
|
|
|
|
## Usage
|
|
|
|
```bash
|
|
# Start server in background
|
|
python3 serve-proxy.py &
|
|
# Access at http://localhost:4173 or http://<lan-ip>:4173
|
|
```
|
|
|
|
## When to Use
|
|
|
|
- Testing a local SPA build that needs API access
|
|
- Debugging frontend-backend integration without nginx reverse proxy
|
|
- Quick demo serving with `python3 -m http.server` + CORS + API proxy
|