Proposed Design for Falcon WebSocket Extension ("Falcon-Pachinko")

Updated Nov 18, 2025

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.App and falcon.asgi.WebSocket components[^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:

  1. 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.

  2. 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.

  3. 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_route instead.

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:WebSocketRouter and its add_route API 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. req is the standard Falcon Request object associated with the initial HTTP upgrade request, and ws is a WebSocketLike connection. The protocol defines the minimal send_media, accept, and close methods needed by the resource. params will contain any path parameters from the route. It returns True to accept the connection or False to reject it. If False is 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 direct await ws.accept() or await 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_code provides 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_message decorator is the canonical, recommended approach as it is explicit and robust. The naming convention is offered as a best-effort convenience.

  1. By Decorator (Canonical): For explicit and robust registration, the @handles_message decorator should be used. This is the preferred method as it is not subject to potential ambiguities with non-standard tag names.

  2. 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=False requires the decorator form. Consequently, the naming convention remains best-effort and is primarily suited to simple ASCII tags.

  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 payload parameter is type-annotated with a msgspec.Struct, the library will perform high-performance validation and deserialization. By default, msgspec forbids extra fields in the payload. This strictness is a feature for ensuring contract adherence. Falcon-Pachinko exposes a strict option on @handles_message to 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_room accepts an optional per-send timeout (in seconds) to mitigate slow recipients. A value of 0 forces an immediate timeout. When individual sends fail or time out, the manager aggregates the exceptions, raising the original error or an ExceptionGroup when 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

  1. ASGI-Native Lifecycle: Workers start after the application's startup event and are cancelled automatically during shutdown, as the ASGI specification intends.

  2. Explicit Wiring: The application explicitly defines which workers to run and injects any dependencies directly, removing "magic" or hidden state.

  3. 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.

  4. Framework Agnosticism: The pattern works with any ASGI-compliant server (e.g., Uvicorn, Hypercorn) and in both synchronous and asynchronous Falcon applications.

  5. 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