The design of the Falcon-Pachinko extension is guided by the core principles of leveraging Falcon's existing strengths, maintaining consistency with its HTTP API, adhering to the principle of least surprise for Falcon developers, and ensuring the extension remains lightweight.
3.1. Core Principles
-
Leverage Falcon's ASGI Foundation: The extension will build upon Falcon's existing
falcon.asgi.Appandfalcon.asgi.WebSocketcomponents[^1], ensuring seamless integration with the ASGI ecosystem. -
Consistency with Falcon HTTP API: The patterns for defining routes and resource handlers for WebSockets will closely mirror those used for HTTP, making the extension intuitive for existing Falcon users.
-
Principle of Least Surprise: Developers familiar with Falcon should find the concepts and API of Falcon-Pachinko familiar and predictable.
-
Lightweight and Minimal Dependencies: The core extension should introduce minimal overhead and dependencies, focusing on in-process functionality for common use cases.
3.2. Key Components
The extension will revolve around three primary components:
-
WebSocketResource: A base class that developers will inherit from to create WebSocket handlers. This class will provide lifecycle methods and a mechanism for dispatching incoming messages to specific handler methods. -
Message Dispatcher: Integrated within the
WebSocketResource, this mechanism will route incoming messages (assumed to be structured, e.g., JSON with a 'type' field) to appropriate asynchronous methods within the resource. -
WebSocketConnectionManager: A central object responsible for tracking active WebSocket connections and managing logical groups or "rooms" of connections. This manager will be the primary interface for background workers to send messages to clients.
3.3. Application Integration
To enable the extension and its shared components, a simple setup step will be required, typically during application initialization:
import falcon.asgi
import falcon_pachinko # The proposed extension library
app = falcon.asgi.App()
falcon_pachinko.install(app) # This would initialize and attach app.ws_connection_manager
This install function would instantiate the WebSocketConnectionManager and
make it accessible via the application instance (e.g.,
app.ws_connection_manager), allowing other parts of the application,
including background workers, to access it.
3.4. Routing WebSocket Connections
Analogous to Falcon's HTTP routing (app.add_route()), the extension
originally provided app.add_websocket_route() to associate a URL path with a
WebSocketResource.
Deprecated: Use :meth:
falcon_pachinko.router.WebSocketRouter.add_routeinstead.
app.add_websocket_route(
'/ws/chat/{room_name}',
ChatRoomResource,
history_size=100,
)
When a WebSocket upgrade request matches this path, Falcon-Pachinko will
instantiate ChatRoomResource with the provided arguments and manage the
connection lifecycle. Path parameters like {room_name} are supplied to the
resource's on_* methods, while options such as history_size are applied
during construction.
sequenceDiagram
actor Developer
participant App
participant WebSocketConnectionManager as Manager
participant RouteSpec
Developer->>App: add_websocket_route(path, resource_cls, *args, **kwargs)
App->>Manager: _add_websocket_route(path, resource_cls, *args, **kwargs)
Manager->>RouteSpec: create RouteSpec(resource_cls, args, kwargs)
Manager->>Manager: Store RouteSpec in _websocket_routes[path]
3.4.1. Programmatic Resource Instantiation
Application code can also create a resource instance directly using
app.create_websocket_resource(path). This helper returns a new object of the
class registered for path or raises ValueError if no such route exists.
Deprecated: A :class:
WebSocketRouterand itsadd_routeAPI should be used to instantiate resources.
chat_resource = app.create_websocket_resource('/ws/chat/{room_name}')
Each call yields a fresh instance so that connection-specific state can be maintained independently.
sequenceDiagram
participant App
participant WebSocketConnectionManager as Manager
participant RouteSpec
participant WebSocketResource as Resource
App->>Manager: create_websocket_resource(path)
Manager->>Manager: Lookup RouteSpec in _websocket_routes[path]
Manager->>RouteSpec: Access resource_cls, args, kwargs
Manager->>Resource: Instantiate resource_cls(*args, **kwargs)
Manager-->>App: Return Resource instance
3.5. The `WebSocketResource` Class
The WebSocketResource class is central to handling WebSocket interactions.
Developers will subclass it to implement their application-specific WebSocket
logic.
-
Lifecycle Methods:
-
async def on_connect(self, req, ws, **params) -> bool: WebSocket connection is established and routed to this resource.reqis the standard FalconRequestobject associated with the initial HTTP upgrade request, andwsis aWebSocketLikeconnection. The protocol defines the minimalsend_media,accept, andclosemethods needed by the resource.paramswill contain any path parameters from the route. It returnsTrueto accept the connection orFalseto reject it. IfFalseis returned, the library will handle sending an appropriate closing handshake (e.g., HTTP 403 or a custom code if supported by an extension like WebSocket Denial Response 12). This boolean return abstracts the directawait ws.accept()orawait ws.close()call, simplifying the resource method to focus on connection logic rather than raw ASGI mechanics. This design aligns with Falcon's higher-level approach for HTTP handlers, where the framework manages response sending. -
async def on_disconnect(self, ws: WebSocketLike, close_code: int): Called when the WebSocket connection is closed, either by the client or the server.close_codeprovides the WebSocket close code. -
async def on_unhandled(self, ws: WebSocketLike, message: Union[str, bytes]): A fallback handler for messages that are not dispatched by the more specific message handlers. This can be used for raw text/binary data or messages that don't conform to the expected structured format. -
State Management:
By default, an instance of a WebSocketResource is created per connection,
allowing self to hold connection-specific state. However, for applications
with very high connection counts (e.g., 10k+ sockets), attaching significant
state to each instance can lead to high memory consumption. To mitigate this,
the resource's self.state attribute will be a swappable, dictionary-like
proxy. Developers can replace it with a proxy to an external session store
(e.g., a Redis hash or an in-memory LRU cache) to manage state efficiently at
scale. The property lazily creates a plain dict but can be reassigned to
a thread-safe mapping backed by Redis or another store when operating at high
concurrency.
3.6. Message Handling and Dispatch
A key feature of Falcon-Pachinko is its ability to dispatch incoming WebSocket
messages to specific handler methods within the WebSocketResource. This
avoids a monolithic receive loop with extensive conditional logic.
-
Message Format Assumption: The dispatch mechanism assumes messages are JSON objects containing a 'type' field that acts as a discriminator, for example:
{"type": "userTyping", "payload": {"isTyping": true}}. -
Dispatch Mechanisms: Two equivalent mechanisms are provided for registering message handlers. The
@handles_messagedecorator is the canonical, recommended approach as it is explicit and robust. The naming convention is offered as a best-effort convenience.
-
By Decorator (Canonical): For explicit and robust registration, the
@handles_messagedecorator should be used. This is the preferred method as it is not subject to potential ambiguities with non-standard tag names. -
By Convention (Convenience): As a convenience, a handler can be defined by creating a method named
on_{type_discriminator}. The framework applies a best-effort conversion that:- Lowercases the discriminator and converts ASCII camelCase/PascalCase to
snake_case. - Replaces any non-alphanumeric characters (including non-ASCII glyphs and dotted segments) with underscores.
When both a decorator registration and a convention match exist, the decorator wins. Convention-based handlers always use strict payload validation; opting into
strict=Falserequires the decorator form. Consequently, the naming convention remains best-effort and is primarily suited to simple ASCII tags. - Lowercases the discriminator and converts ASCII camelCase/PascalCase to
from falcon_pachinko import WebSocketLike, WebSocketResource, handles_message
import msgspec as ms
class NewChatMessage(ms.Struct):
text: str
class ChatMessageHandler(WebSocketResource):
# Dispatched explicitly by decorator (canonical approach)
@handles_message("new-chat-message")
async def on_new_chat_message(
self, ws: WebSocketLike, payload: NewChatMessage
) -> None:
print(f"NEW MESSAGE: {payload.text}")
- Automatic Deserialization and Strictness: For routed messages where the
handler's
payloadparameter is type-annotated with amsgspec.Struct, the library will perform high-performance validation and deserialization. By default,msgspecforbids extra fields in the payload. This strictness is a feature for ensuring contract adherence. Falcon-Pachinko exposes astrictoption on@handles_messageto relax this behaviour when needed:
@handles_message("type", strict=False)
async def on_type(self, ws, payload: SomeStruct) -> None:
...
3.6.1. Descriptor Implementation of handles_message
The decorator is implemented as a descriptor so that handlers are registered when their containing class is created:
import functools
from types import MappingProxyType
import typing as typ
class _MessageHandlerDescriptor:
"""Store the original function and remember its owner class."""
def __init__(self, msg_type: str, func: typ.Callable) -> None:
self.msg_type = msg_type
self.func = func
functools.update_wrapper(self, func)
self.owner = None
self.name = None
def __set_name__(self, owner: type, name: str) -> None:
self.owner = owner
self.name = name
parent_registry = getattr(owner, "_message_handlers", {})
# Each class must get its own registry; otherwise subclasses would
# mutate the parent's handler map and cause cross-talk across the
# hierarchy.
registry: dict[str, typ.Callable] = dict(parent_registry)
if self.msg_type in registry:
raise RuntimeError(
f"Duplicate handler for message type {self.msg_type!r} on {owner.__qualname__}"
)
registry[self.msg_type] = self.func
owner._message_handlers = registry
# Freeze the parent registry to guard against accidental mutation.
base = owner.__bases__[0]
if hasattr(base, "_message_handlers"):
base._message_handlers = MappingProxyType(parent_registry)
def __get__(self, instance: typ.Any, owner: type | None = None) -> typ.Callable:
return self.func.__get__(instance, owner or self.owner)
def handles_message(msg_type: str) -> typ.Callable[[typ.Callable], _MessageHandlerDescriptor]:
"""Decorator factory returning the descriptor wrapper."""
def decorator(func: typ.Callable) -> _MessageHandlerDescriptor:
return _MessageHandlerDescriptor(msg_type, func)
return decorator
3.7. `WebSocketConnectionManager`
The WebSocketConnectionManager is crucial for enabling server-initiated
messages and managing groups of connections. It will be built upon a pluggable
backend interface to support both single-process and distributed deployments.
-
API Design:
-
Awaitable Operations: All methods that perform I/O (e.g.,
broadcast_to_room,send_to_connection) will be coroutines (async def) and will propagate exceptions. This ensures that network failures or backend errors are not silent. -
Bounded Broadcasts:
broadcast_to_roomaccepts an optional per-send timeout (in seconds) to mitigate slow recipients. A value of0forces an immediate timeout. When individual sends fail or time out, the manager aggregates the exceptions, raising the original error or anExceptionGroupwhen multiple recipients fail. Applications should choose a timeout aligned with their backpressure strategy (e.g., smaller values for large rooms paired with a retry queue). -
Async Iterators: For bulk operations, the manager will expose async iterators, making them highly composable.
```python async for ws in conn_mgr.connections(room='general'): await ws.send_media(...)
```
-
Pluggable Backends for Multi-Worker Support:
The manager will be designed with a clear Abstract Base Class (ABC) defining its interface. The initial 1.0 release will ship with a default InProcessBackend suitable for single-worker deployments. However, the architecture is explicitly designed to support distributed systems in the future. A developer could implement a RedisBackend using Redis Pub/Sub to enable broadcasting across multiple server processes. This provides a clear and robust path to scalability without changing the application-level code that interacts with the connection manager.
3.8. Background Worker Integration via ASGI Lifespan
To manage long-running background tasks, this design eschews a bespoke registry in favour of the standard ASGI lifespan protocol. This approach ensures that workers are managed correctly by the ASGI server, provides fault transparency, and simplifies the application's mental model.
3.8.1. Design Objectives
-
ASGI-Native Lifecycle: Workers start after the application's
startupevent and are cancelled automatically duringshutdown, as the ASGI specification intends. -
Explicit Wiring: The application explicitly defines which workers to run and injects any dependencies directly, removing "magic" or hidden state.
-
Fault Transparency: An unhandled exception in a worker will crash the server process immediately, ensuring failures are not silent. Developers can opt-in to supervision for tasks that should be restarted.
-
Framework Agnosticism: The pattern works with any ASGI-compliant server (e.g., Uvicorn, Hypercorn) and in both synchronous and asynchronous Falcon applications.
-
Zero Global State: The design avoids singletons, ensuring multiple Falcon applications can run in the same process without interference.
3.8.2. Public API: The WorkerController
A new module, pachinko.workers, will provide the core components for this
feature.
# pachinko/workers.py (new module)
import asyncio
from contextlib import AsyncExitStack
import collections.abc as cabc
import typing as typ
WorkerFn: typ.TypeAlias = cabc.Callable[[typ.Any], cabc.Awaitable[None]]
class WorkerController:
"""Manages a set of long-running asyncio tasks tied to an ASGI lifespan."""
__slots__: typ.Final = ("_tasks", "_stack")
def __init__(self) -> None:
self._tasks: list[asyncio.Task[None]] = []
self._stack: AsyncExitStack | None = None
async def start(self, *workers: WorkerFn, **context: typ.Any) -> None:
"""Create and supervise tasks. *context is injected into each worker."""
self._stack = AsyncExitStack()
await self._stack.__aenter__()
for fn in workers:
task = asyncio.create_task(fn(**context))
self._tasks.append(task)
async def stop(self) -> None:
"""Cancel tasks and propagate first exception, if any."""
for t in self._tasks:
t.cancel()
await asyncio.gather(*self._tasks, return_exceptions=True)
if self._stack:
await self._stack.__aexit__(None, None, None)
# Optional syntactic sugar
def worker(fn: WorkerFn) -> WorkerFn:
"""Marks *fn* as a valid worker. Purely cosmetic but documents intent."""
fn.__pachinko_worker__ = True
return fn
3.8.3. Application Usage
Developers will instantiate the WorkerController and use Falcon's
@app.lifespan context manager (available since Falcon 4.0) to manage the
worker lifecycle.
# app.py
import falcon.asgi
import pachinko
from pachinko.workers import WorkerController, worker
# Assume conn_mgr is created via falcon_pachinko.install(app)
# and is available as app.ws_connection_manager
app = falcon.asgi.App()
pachinko.install(app)
conn_mgr = app.ws_connection_manager
# 1. Define workers as ordinary async functions with explicit dependencies
@worker
async def announcement_worker(conn_mgr: pachinko.WebSocketConnectionManager) -> None:
"""Broadcasts a heartbeat message to all sockets every 30s."""
while True:
await conn_mgr.broadcast_to_all({"type": "ping"}) # Assuming a broadcast_to_all method
await asyncio.sleep(30)
# 2. Bind the worker lifecycle to the ASGI lifespan
controller = WorkerController()
@app.lifespan
async def lifespan(app_instance):
# Start workers, injecting dependencies as keyword arguments
await controller.start(
announcement_worker,
conn_mgr=conn_mgr,
)
yield
# --- app is live ---
await controller.stop()
This pattern eliminates the need for a bespoke worker registry, making the
entire process explicit and testable. Unhandled exceptions in
announcement_worker will propagate and terminate the server, preventing
silent failures.
3.9. API Overview
The following table summarizes the key components of the proposed Falcon-Pachinko API and their analogies to Falcon's HTTP mechanisms, where applicable. This serves as a quick reference to understand the main abstractions and their intended use. This API structure is designed to be both powerful enough for complex applications and intuitive for developers accustomed to Falcon.
| Component | Key Elements | Purpose | Falcon Analogy |
|---|---|---|---|
| Application Setup | falcon_pachinko.install(app) |
Initializes shared WebSocket components such as the connection manager. | App-level extensions |
| Route Definition | app.add_websocket_route() and WebSocketRouter.add_route() |
Maps a URI path to a WebSocketResource. |
app.add_route() |
| Resource Class | falcon_pachinko.WebSocketResource |
Handles connections and messages for a given route. | Falcon HTTP Resource |
| Connection Lifecycle | on_connect(), on_disconnect() |
Setup and teardown hooks for each connection. | Request/response middleware |
| Message Handling (Typed) | @handles_message() and on_{type} |
Routes incoming JSON messages by type. | on_get, on_post, etc. |
| Message Handling (Generic) | on_unhandled() |
Fallback for unrecognized or non-JSON messages. | N/A |
| Background Worker Integration | WorkerController, @app.lifespan |
Manages long-running tasks within the ASGI lifecycle. | Custom patterns |
| Connection Management (Global) | app.ws_connection_manager |
Tracks connections and enables broadcasting. | N/A |