Files
hermes-config/skills/software-development/subagent-driven-development/references/spa-proxy-server.md
T
2026-07-12 10:17:17 -04:00

4.8 KiB

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:

#!/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

# 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