On this page
ASGI application factory (create_app/AppConfig) with middleware chain, static-file serving, SPA fallback, lifespan hooks, DI, and WebSocket routing.
#src.fastware.app
#src.fastware.app
ASGI application factory with middleware chain composition, static file serving, SPA fallback routing, async lifespan hooks, and WebSocket support.
#_disconnect_watcher
async def _disconnect_watcher(receive: Callable, request: Request) -> NoneOwn the ASGI receive channel for a request's post-body lifetime.
A single watcher task is the only consumer of receive once the body has been read. It blocks on receive() and, on http.disconnect, records the fact on the request (which also cancels any in-flight stream-driving task) and returns. This makes disconnects observable even when the server never surfaces them as a send() failure -- the failure mode that caused generators to leak forever.
Non-disconnect messages are ignored; a well-behaved ASGI server sends only http.disconnect after the body. The sleep(0) yields control so a test double that replays the same message cannot spin the loop.
#_send_stream
async def _send_stream(send: Callable, resp: StreamResponse, request: Request | None=None) -> NoneSend a streaming HTTP response, driving the generator in a child task.
The generator is iterated in a CHILD task so the request's disconnect watcher can cancel it the instant the client goes away. granian frequently never raises from send() on disconnect, so a generator blocked between yields would otherwise leak forever and its finally cleanup would never run. resp.generator.aclose() is guaranteed in every path: normal completion, generator error, send failure, and disconnect cancellation.
Send failures (client vanished mid-stream) are swallowed cleanly -- the response has already started and cannot be changed. Generator errors are NOT swallowed; they propagate to the caller (which must not attempt a second response after start).
#_accepted_params
def _accepted_params(handler: Callable) -> frozenset[str] | NoneReturn the set of keyword names handler accepts, or None if it takes **kwargs (accepts everything).
#_deep_convert_pydantic
def _deep_convert_pydantic(obj: Any) -> AnyRecursively convert Pydantic models to plain dicts/lists.
Walks dicts and lists, calling .model_dump(mode="json") on any object that has model_dump (i.e. Pydantic BaseModel instances).
#_send_result
async def _send_result(send: Callable, result: Any, request: Request | None=None) -> NoneDispatch a handler return value to the appropriate sender.
#_MountLifespan
Drives the ASGI lifespan protocol for one mounted sub-app.
The sub-app runs in its own task with private message queues. Per the ASGI lifespan spec, state the sub-app sets on the lifespan scope's state dict is carried into every request scope forwarded to it. Sub-apps that finish (raise or return) without sending any lifespan message are treated as not supporting lifespan, matching the server convention (e.g. uvicorn).
#startup
async def startup(self) -> str | NoneSend lifespan.startup; returns an error message on failure, or None on success (including apps that don't support lifespan).
#shutdown
async def shutdown(self) -> str | NoneSend lifespan.shutdown; returns an error message or None.
#_compute_static_build_id
def _compute_static_build_id(static_dir: Path | None) -> strReturn a SHA-256 build id derived from static asset file contents.
Files are visited in sorted relative-path order and their raw bytes folded into a single SHA-256 digest. The relative path is mixed in as well so that moving identical bytes between filenames changes the id. Only file contents and paths participate -- never mtime, size, or inode -- so the id is stable across touch and across machines given identical bytes.
With no static directory (or an empty one) the digest is over the empty set and equals :data:_EMPTY_BUILD_ID.
#_cache_control_for
def _cache_control_for(filename: str) -> strReturn the Cache-Control value for a served static filename.
Hashed Vite-style assets are immutable and cached for a year; everything else (assets with no content hash, index.html, SPA fallback) is no-cache so clients always revalidate.
#_serve_static
async def _serve_static(send: Callable, static_dir: Path, rel_path: str) -> boolServe a static file. Returns True if served, False if not found.
#_serve_spa_fallback
async def _serve_spa_fallback(send: Callable, spa_fallback: Path) -> NoneServe the SPA fallback file (typically index.html).
#AppConfig
Configuration for :func:create_app.
All fields correspond to the keyword arguments of create_app. Pass an AppConfig instance as the config parameter, and/or supply individual keyword arguments. Keyword arguments override matching fields on the config object.
#create_app
def create_app(router: Router, config: AppConfig | None=None, **kwargs: Any) -> CallableCreate an ASGI application callable.
Accepts an optional config (:class:AppConfig) and/or keyword arguments. Keyword arguments override matching fields on the config object. If neither is supplied, defaults from AppConfig are used.
If api_prefix is set (e.g. "/api"), the SPA fallback will not serve index.html for paths that start with the prefix -- they fall through to the 404 handler instead.
Built-in middleware (applied when their parameters are truthy):
trusted_hosts: TrustedHostMiddleware (outermost)vite_dev_port: ViteDevProxycors_origins: CORSMiddlewarerequest_id: RequestIDMiddlewarerequest_timing: RequestTimingMiddleware (innermost)
Custom middleware supplied via middleware wraps after built-in middleware (between the app and the built-in stack).