On this page
Pure-ASGI middleware: CORS, request-ID tracing, request timing with error-log integration, trusted-host validation, and backend-first ViteDevProxy.
#src.fastware.middleware
#src.fastware.middleware
Pure ASGI middleware for request tracing, CORS headers, trusted-host validation, and Vite dev proxy routing, all streaming-safe for SSE and WebSocket.
All middleware classes are pure ASGI -- no framework dependency beyond fastware's own send_error helper. This keeps them streaming-safe (SSE, WebSocket) and avoids the response-buffering issues of BaseHTTPMiddleware-style wrappers.
Classes: RequestIDMiddleware — assigns/propagates X-Request-Id per request RequestTimingMiddleware — logs method, path, status, duration; ring buffer CORSMiddleware — preflight OPTIONS + response header injection TrustedHostMiddleware — rejects requests from unlisted Host headers ViteDevProxy — proxies non-API requests to a Vite dev server
#RequestIDMiddleware
Assign or propagate a unique request ID per request.
If the incoming request carries an X-Request-Id header, that value is reused. Otherwise a new UUID4 is generated. The ID is stored in scope["state"]["request_id"] and returned as an X-Request-Id response header.
When structlog is available, contextvars are cleared at the start of each request (preventing context leak from a previous request) and the request ID is bound so all log entries within the request include it.
#RequestTimingMiddleware
Log every HTTP request with method, path, status, and duration.
Wraps send to capture the status code from http.response.start, then uses try/finally so timing fires even for long-lived SSE streams (when the client disconnects the ASGI handler returns).
Args:
app: The inner ASGI application.error_log: OptionalErrorLoginstance. On 5xx responses the
middleware calls error_log.append(...) to persist the failure for dashboard visibility.
exclude_paths: Iterable of path prefixes to exclude from the ring
buffer (e.g. ["/events"]). Excluded requests are still logged, just not stored.
maxlen: Maximum number of entries in the ring buffer (default 10000).
#CORSMiddleware
Add CORS headers to responses and handle preflight OPTIONS requests.
Args:
app: The inner ASGI application.allow_origins: List of allowed origins (e.g.["http://localhost:5173"]).
Use ["*"] to allow any origin.
allow_methods: HTTP methods to advertise. Defaults to common methods.allow_headers: Request headers the client may send. Defaults to
common headers.
allow_credentials: Whether to setAccess-Control-Allow-Credentials.
Raises:
ValueError: ifallow_originscontains"*"while
allow_credentials is True. Because this middleware echoes the request origin, that combination would grant credentialed cross-origin access to any site.
#_cors_headers
def _cors_headers(self, origin: str) -> list[tuple[bytes, bytes]]Build the list of CORS response headers for origin.
#TrustedHostMiddleware
Reject requests whose Host header is not in the allow-list.
Prevents DNS rebinding attacks for servers bound to localhost.
Args:
app: The inner ASGI application.allowed_hosts: List of hostnames (with optional port) to allow.
Use ["*"] to disable the check.
#ViteDevProxy
ASGI middleware that proxies unmatched requests to a Vite dev server.
Uses a backend-first routing strategy for HTTP: requests hit the backend first, streaming the response through message-by-message (SSE-safe). Only the http.response.start message is held back until the status is known; if the backend returns 404 (no route matched), the backend's response is discarded and the request is replayed to Vite instead. This means backend routes like /health or /metrics work without being under an API prefix.
Paths matching api_prefix or backend_prefixes always go straight to the backend with no 404-retry — their 404s belong to the client. WebSocket upgrades cannot be retried, so they use the same prefix rule: matching paths go to the backend; everything else is proxied to Vite (for HMR).
#__init__
def __init__(self, app: Callable, *, vite_port: int, api_prefix: str='/api', backend_prefixes: list[str] | None=None) -> NoneWrap app with backend-first Vite dev proxying.
Args:
app: The inner ASGI application (the fastware app) handled
first for every request.
vite_port: Port the Vite dev server is listening on; unmatched
requests are proxied there.
api_prefix: Path prefix for backend routes that always go
straight to the backend without a 404-retry (default "/api").
backend_prefixes: Additional backend path prefixes routed to
the app, notably for WebSocket upgrades (default ["/events", "/ws"]). /ws is the conventional app WebSocket path; routing it to the backend does not interfere with Vite HMR, whose websocket connects at / (identified by the vite-hmr subprotocol), not /ws.
#_is_api_request
def _is_api_request(self, path: str) -> boolReturn True if this path should go to the app, not be proxied.
The reserved /__fastware/ namespace (version endpoint and any future diagnostics) is always a backend prefix -- never proxied to Vite -- so those endpoints behave identically in dev and prod. This also matters for WebSocket upgrades, which cannot be 404-retried.
#close
async def close(self) -> None#_proxy_http
async def _proxy_http(self, scope: Scope, send: Send, *, body: bytes=b'') -> NoneForward an HTTP request to the Vite dev server.
The body parameter contains the pre-captured request body (already consumed from receive by the backend during the try-first phase).
#_proxy_ws
async def _proxy_ws(self, scope: Scope, receive: Receive, send: Send) -> NoneBidirectional WebSocket proxy to Vite (for HMR).
Uses the websockets library. If it is not installed, or the proxy connection fails, the client connection is closed with code 1011 (internal error) and a reason -- never a clean 1000 close that would hide the failure.