Building upon the foundational concepts outlined above, this section details a
more advanced, composable architecture for WebSocket handling. This evolved
design introduces a dedicated WebSocketRouter, support for nested resources,
and a high-performance, schema-driven message dispatch mechanism. Its primary
goal is to achieve true ergonomic parity with Falcon's acclaimed HTTP routing
while enabling highly structured, maintainable, and performant real-time
applications.
The following diagram provides a high-level overview of the main classes and their relationships within this advanced proposal.
classDiagram
class App {
+add_route(path, resource)
}
class WebSocketRouter {
<<Falcon Resource>>
+on_websocket(req, ws)
+add_route(path, resource_cls, *args, **kwargs)
+url_for(name, **params)
}
class WebSocketResource {
+schema: TaggedUnion
+state: dict
+on_<tag>()
+on_unhandled()
+on_connect()
+on_disconnect()
}
class TaggedUnion {
<<msgspec tagged union>>
}
App "1" --o "*" WebSocketRouter : mounts
WebSocketRouter "1" --o "*" WebSocketResource : routes
WebSocketResource ..> TaggedUnion : uses for schema
5.1. The `WebSocketRouter` as a Composable Falcon Resource
To provide a cleaner separation of concerns and a more powerful, composable
API, this proposal treats the WebSocketRouter as a standard Falcon resource.
This allows routers to be mounted on the main application, enabling the
creation of modular, hierarchical WebSocket APIs.
5.1.1. Mounting the Router
Instead of assigning a router to a special application attribute, it is mounted
at a URL prefix using Falcon's standard app.add_route() method. This makes
the WebSocketRouter a first-class citizen in Falcon's routing tree.
import falcon.asgi
from falcon_pachinko.router import WebSocketRouter # New component
app = falcon.asgi.App()
chat_router = WebSocketRouter()
# The WebSocketRouter instance is mounted like any other Falcon resource.
# It will handle all WebSocket connections under the '/ws/chat/' prefix.
app.add_route('/ws/chat', chat_router)
# To achieve this, the WebSocketRouter will implement the on_websocket
# responder method, which will contain the logic to dispatch the connection
# to the correct sub-route defined on the router itself.
This creates a clean architectural boundary and leverages Falcon's existing, well-understood routing mechanism for composition.
5.1.2. add_route API, Factories, and URL Reversal
The WebSocketRouter features its own add_route() method for defining
sub-routes. These paths are relative to the router's mount point.
-
Resource Factories: To mirror Falcon's flexibility,
add_routewill accept not onlyWebSocketResourcesubclasses but also callable factories. A factory is any callable that accepts the same arguments as a resource's constructor and returns a resource instance. This keeps the barrier to entry low for simple, functional use cases. -
Resource Initialization: The method accepts
*argsand**kwargsto be passed to the resource's constructor (or factory), allowing for route-specific configuration. -
URL Reversal: To discourage hard-coding paths, the router will provide a
url_for(name, **params)helper to generate URLs for its routes, similar to other modern web frameworks.
# Continuing the previous example:
chat_router = WebSocketRouter(name='chat') # Give the router a name for reversal
# Add a route to the router, giving it a name for url_for.
chat_router.add_route(
'/{room_id}',
ChatResource,
name='room',
init_args={'history_size': 100}
)
app.add_route('/ws/chat', chat_router)
# Generate a URL:
# url = app.url_for('chat:room', room_id='general') # Hypothetical top-level reversal
# -> '/ws/chat/general'
The router is responsible for matching the incoming connection URI against its
registered routes, parsing any path parameters (like {room_id}),
instantiating the correct WebSocketResource with its specific initialization
arguments, and handing off control of the connection.
5.1.3. Router Flow and Structure
The diagrams below illustrate the flow of a WebSocket connection through the router and the relationships between the main classes.
sequenceDiagram
participant Client
participant FalconApp
participant WebSocketRouter
participant WebSocketResource
Client->>FalconApp: Initiate WebSocket connection (URL)
FalconApp->>WebSocketRouter: on_websocket(req, ws)
WebSocketRouter->>WebSocketRouter: Match URL to route
WebSocketRouter->>WebSocketResource: Instantiate resource
WebSocketRouter->>WebSocketResource: on_connect(req, ws, **params)
alt on_connect returns False
WebSocketRouter->>Client: Close WebSocket
else on_connect returns True
WebSocketRouter->>Client: Accept WebSocket
end
erDiagram
WEBSOCKETROUTER ||--o{ ROUTE : registers
ROUTE {
string path
regex pattern
callable factory
}
WEBSOCKETROUTER {
string name
dict names
}
ROUTE }o--|| WEBSOCKETRESOURCE : instantiates
WEBSOCKETRESOURCE {
method on_connect
}
classDiagram
class WebSocketRouter {
- _routes: list[tuple[str, re.Pattern[str], Callable[..., WebSocketResource]]]
- _names: dict[str, str]
- name: str | None
+ __init__(name: str | None = None)
+ add_route(path: str, resource: type[WebSocketResource] | Callable[..., WebSocketResource], name: str | None = None, args: tuple = (), kwargs: dict | None = None)
+ url_for(name: str, **params: object) str
+ on_websocket(req: falcon.Request, ws: WebSocketLike)
}
class WebSocketResource {
+ on_connect(req: falcon.Request, ws: WebSocketLike, **params)
}
class WebSocketLike
class falcon.Request
WebSocketRouter --> WebSocketResource : creates
WebSocketRouter ..> WebSocketLike : uses
WebSocketRouter ..> falcon.Request : uses
WebSocketResource ..> WebSocketLike : uses
WebSocketResource ..> falcon.Request : uses
5.2. Composable Architecture: Nested Resources
To facilitate the creation of modular and hierarchical applications, this
design introduces the concept of nested resources via an add_subroute method
on a resource instance. This allows developers to organize complex, related
functionality into distinct, reusable classes.
5.2.1. The add_subroute Mechanism
A parent resource can mount a sub-resource at a relative path, promoting a logical code hierarchy.
# --- In project_resource.py ---
class ProjectResource(WebSocketResource):
def __init__(self, req, ws, project_id: str):
super().__init__(req, ws)
# ... project-specific setup using project_id ...
# Mount sub-resources
self.add_subroute('tasks', TasksResource)
self.add_subroute('files', FilesResource)
# --- In the main application ---
# This assumes a router is already defined.
router.add_route('/projects/{project_id}', ProjectResource)
# A connection to "wss://.../projects/123/tasks" would be handled
# by an instance of TasksResource, with the context of project "123".
5.2.2. Path Composition and State Management
The WebSocketRouter must resolve these composite paths. A trie (prefix tree)
is the natural data structure for this task. When resolving a path like
/projects/123/tasks, the router would first match /projects/{project_id}
and instantiate ProjectResource, passing it the project_id. The
ProjectResource constructor would then call add_subroute, registering
TasksResource. The router would then match the tasks segment and
instantiate TasksResource.
A critical aspect is context passing. The router must facilitate passing
state from parent to child. A robust implementation would involve the router
instantiating the entire resource chain, allowing parent resources to pass
relevant state (or self) into the constructors of their children.
sequenceDiagram
participant Client
participant FalconRequest as Falcon Request
participant WebSocketRouter
participant ResourceFactory
Client->>FalconRequest: Initiates WebSocket request
FalconRequest->>WebSocketRouter: on_websocket(req, ws)
WebSocketRouter->>WebSocketRouter: Check req.path_template == _mount_prefix
WebSocketRouter->>WebSocketRouter: Match full request path against compiled routes
alt Match found
WebSocketRouter->>ResourceFactory: Instantiate resource
ResourceFactory-->>WebSocketRouter: Resource instance
WebSocketRouter->>ResourceFactory: Call resource handler
else No match
WebSocketRouter->>FalconRequest: Raise 404
end
5.2.3. Context-Passing for Nested Resources
Explicit context handoff keeps nested resources predictable and mirrors Falcon's philosophy of avoiding hidden state. Each parent resource controls what data flows to its children, allowing shared state without resorting to globals.
- Per-resource context provider
Add an overridable get_child_context() method to WebSocketResource. The
router calls this hook after instantiating the parent to obtain keyword
arguments for the next child. The default implementation returns {}, so
resources opt in to sharing.
def get_child_context(self) -> dict[str, object]:
"""Return kwargs to be forwarded to the immediate child resource."""
return {}
A concrete signature signals that callers can rely on a plain dict and keeps
the hook symmetric with Falcon's HTTP-style get_child_scope() patterns.
- Shared state object
Pass the same connection-scoped state proxy down the chain. The router
sets child.state = parent.state unless the parent supplies an alternative
via get_child_context().
- Router chain instantiation
For each path segment the router will:
- Instantiate the parent with path parameters and static init args.
- Invoke
get_child_context()to obtain context for the child. - Instantiate the child, merging path params with the context kwargs.
- Propagate the shared
stateproxy.
- Convenience API
add_subroute() records the child factory along with any static
positional/keyword args and retains a reference to the parent. This enables
the router to look up subroutes while composing the resource chain.
- Testing and docs
Provide examples where a parent loads a project object and injects it into
TasksResource, verifying that the child receives the object and both
modify the shared state.
The relationships and runtime behavior are illustrated below.
classDiagram
class WebSocketResource {
+get_child_context() kwargs
state
}
class WebSocketRouter {
+instantiate_resource_chain(path_segments, path_params, static_args)
+add_subroute(child_factory, *args, **kwargs)
}
WebSocketResource <|-- ParentResource
WebSocketResource <|-- ChildResource
WebSocketRouter o-- WebSocketResource : instantiates
ParentResource o-- ChildResource : add_subroute
ParentResource --> ChildResource : get_child_context()
ParentResource --> ChildResource : state (shared)
sequenceDiagram
participant Router as WebSocketRouter
participant Parent as ParentResource
participant Child as ChildResource
Router->>Parent: Instantiate with path params, static args
Router->>Parent: get_child_context()
Parent-->>Router: context kwargs
Router->>Child: Instantiate with path params + context kwargs
Router->>Child: Set child.state = parent.state (unless overridden)
5.3. High-Performance Schema-Driven Dispatch with `msgspec`
This proposal elevates the dispatch mechanism using msgspec and its support
for tagged unions. This moves from simple dispatch to a declarative,
schema-driven approach.
5.3.1. Declaring Message Schemas
A resource defines its message schema using a typ.Union of ms.Struct types,
where each Struct represents a distinct message.
import msgspec as ms
import typing as typ
# Define individual message structures
class Join(ms.Struct, tag='join'):
room: str
class SendMessage(ms.Struct, tag='sendMessage'): # Note CamelCase tag
text: str
# Create a tagged union of all possible messages
MessageUnion = typ.Union[Join, SendMessage]
class ChatResource(WebSocketResource):
# Associate the schema with the resource
schema = MessageUnion
# Handler by decorator (canonical) for tag='join'
@handles_message('join')
async def on_join(self, req, ws, msg: Join):
print(f"Joining room: {msg.room}")
# Handler by convention (convenience) for tag='sendMessage'
# The framework converts 'sendMessage' to 'on_send_message'
async def on_send_message(self, req, ws, msg: SendMessage):
print(f"Message received: {msg.text}")
5.3.2. Automated Dispatch Logic
The base WebSocketResource would contain a receive loop that performs the
following steps:
-
Receive a raw message from the WebSocket.
-
Decode the message using
msgspec.json.decode(message, type=self.schema).msgspec's tagged union support automatically inspects a tag field in the JSON to select the correctStructfor decoding. -
Inspect the
tagof the resultingStructinstance. -
Find a handler registered with
@handles_messagefor that tag. If not found, attempt to find a handler by theon_{tag}naming convention. -
Dynamically invoke the handler, e.g.,
await self.on_send_message(req, ws, msg).
This schema-driven design completely eliminates boilerplate dispatch logic and
provides "zero-cost" validation via msgspec.
5.3.3. Error Handling
The framework must gracefully handle dispatch errors.
-
A
msgspec.ValidationErrorraised during decoding should be caught, and a customisable error hook should be invoked. -
If a message has a valid tag that does not correspond to any registered handler, the
on_unhandled(self, req, ws, msg)fallback method on the base resource should be called.
5.4. A Multi-Tiered Middleware and Hook System
To handle cross-cutting concerns like authentication, logging, and metrics, a flexible, multi-layered hook system is proposed.
5.4.1. Global and Per-Resource Hooks
-
Global Hooks: Registered on the
WebSocketRouter(router.global_hooks), these apply to every connection handled by that router. They are ideal for universal concerns like authentication or establishing database connections. -
Per-Resource Hooks: Registered on a
WebSocketResourceclass (resource.hooks), these apply only to that resource and any of its sub-resources. This allows for specific logic, such as permissions checks for a particular channel.
5.4.2. Lifecycle Events and Execution Order
The hooks correspond to the WebSocket lifecycle: before_connect,
after_connect, before_receive, after_receive, and before_disconnect.
The execution order follows a layered, "onion-style" model for any given event,
as illustrated below.
flowchart TD
A[WebSocket Event] --> B[Global Hook]
B --> C[Resource Hook]
C --> D[Handler Logic]
D --> E[After Hooks]
E --> F[Cleanup/Error Handling]
The specific sequence is as follows:
-
Global
before_*hook(s) -
Parent Resource
before_*hook(s) (if nested) -
Resource-specific
before_*hook(s) -
The core handler logic (e.g.,
on_connector anon_<tag>message handler) -
Resource-specific
after_*hook(s) -
Parent Resource
after_*hook(s) (if nested) -
Global
after_*hook(s)
This provides maximum control, allowing outer layers to act as setup/teardown
guards for inner layers. An exception in a before_* hook should terminate the
connection attempt immediately.
5.5. Architectural Implications
5.5.1. Stateful Per-Connection Resources and Dependency Injection
This design solidifies the paradigm of a stateful, per-connection resource instance, a shift from Falcon's traditional stateless HTTP model. The resource instance itself becomes the container for connection-specific state. This raises a critical architectural question: how do these ephemeral resource instances get access to long-lived, shared services (like database connection pools)?
To keep the solution Falcon-idiomatic and testable, we adopt an explicit
router-level resource factory strategy. Rather than hiding DI concerns
behind global registries or magical decorators, the router gains an optional
resource_factory callable that participates in every instantiation.
Problem Statement and Goals
WebSocketResource instances are short-lived by design—they exist for the
duration of a single connection. They frequently require access to long-lived
services (database pools, metrics clients, configuration providers) that should
be created once per process. The DI approach must therefore:
- Make dependencies explicit at the resource boundary.
- Allow the application to decide how shared services are created and reused.
- Support simple manual factories as well as full-featured DI containers.
- Remain easy to override in tests by providing alternate factories.
Router-Level Resource Factory
WebSocketRouter.__init__ accepts an optional resource_factory argument.
When omitted, the router preserves the current behaviour—calling the per-route
factory captured by add_route() with no extra indirection. When provided, the
callable receives that per-route factory (typically a functools.partial
wrapping the resource class and static init_args) and returns a fully
constructed WebSocketResource instance. This indirection allows the
application to inspect the resource signature, resolve dependencies from a DI
container, merge them with static route configuration, and return the
constructed instance. The public :class:ResourceFactory type alias documents
the expected call signature for these factories so applications can annotate
their DI hooks consistently.
from falcon_pachinko.router import ResourceFactory
class WebSocketRouter:
def __init__(
self,
*,
resource_factory: ResourceFactory | None = None,
...,
):
self._resource_factory = resource_factory or (lambda factory: factory())
async def _instantiate_resource(
self,
route_factory: Callable[..., WebSocketResource],
ws: WebSocketLike,
) -> WebSocketResource:
try:
return self._resource_factory(route_factory)
except Exception as exc:
await ws.close()
exc._pachinko_factory_closed = True
raise
Example Container Integration
Applications can wire a custom container by passing the resolver to the router.
The library ships with a small helper, falcon_pachinko.di.ServiceContainer,
that demonstrates one possible integration:
from falcon_pachinko.di import ServiceContainer
container = ServiceContainer()
router = WebSocketRouter(resource_factory=container.create_resource)
router.add_route(
"/{room_id}",
ChatResource,
kwargs={"history_size": 100},
)
ServiceContainer.create_resource() receives the partial generated by
add_route(), inspects the target class, and injects dependencies (for example
db or analytics attributes on the container) before instantiating the
resource. Internally the helper caches each computed inspect.Signature in the
private _signature_cache mapping so repeated instantiation does not redo
reflection. Should a dependency be missing, ServiceContainer.resolve() raises
ServiceNotFoundError (a LookupError subclass) to make failures
explicit. Because the router delegates instantiation, unit tests can supply a
lightweight factory that injects mocks, while production code can reuse
existing DI infrastructure.
Component Relationships
The following class diagram illustrates how the sample ServiceContainer
coordinates dependency injection for both HTTP and WebSocket endpoints in the
random status example:
classDiagram
class ServiceContainer {
-_services: dict[str, object]
+register(name: str, value: object)
+resolve(name: str) -> object
+create_resource(route_factory: Callable[..., WebSocketResource]) -> WebSocketResource
}
class StatusResource {
-_conn_mgr: WebSocketConnectionManager
-_db: aiosqlite.Connection
-_conn_id: str | None
+__init__(conn_mgr: WebSocketConnectionManager, db: aiosqlite.Connection)
+on_connect(ws: WebSocketLike)
+on_disconnect(ws: WebSocketLike, close_code: int)
+update_status(ws: WebSocketLike, payload: StatusPayload)
}
class StatusEndpoint {
-_container: ServiceContainer
+__init__(container: ServiceContainer)
+on_get(req: falcon.Request, resp: falcon.Response)
}
ServiceContainer --> StatusEndpoint : provides dependencies
ServiceContainer --> StatusResource : provides dependencies
StatusEndpoint --> ServiceContainer : resolves 'db'
StatusResource --> WebSocketConnectionManager
StatusResource --> aiosqlite.Connection
The comprehensive reference application under examples/reference_app builds
on this pattern to exercise every advanced feature in one place. Its router is
mounted at /ws and instantiates WorkspaceResource → ProjectResource →
TaskStreamResource chains through the shared ServiceContainer, allowing
hooks to seed per-connection state while message handlers rely on schema-driven
dispatch. The same container also wires the announcement worker and HTTP
helpers, providing a concrete illustration of the stateful,
per-connection resources described in §5.5.1.
Usage Patterns
Practical usage of the resource-factory pattern falls into two complementary scenarios:
-
Application wiring. The
examples/random_status/server.pymodule now demonstrates the sharedServiceContainerthat registers process-wide dependencies at startup and exposes acreate_resource()hook to the router. The container mirrors the behaviour described above—capturing the target callable from the route's partial, merging static kwargs with registered services, reusing cached signatures, and returning the final resource instance. A lightweightRouterEndpointadapter wraps the router so it can be registered viaapp.add_websocket_route()while still delegating connection handling to the router itself. Both WebSocket resources and regular HTTP endpoints resolve dependencies from the same container, ensuring a single source of truth for shared services. -
Test-specific factories. Behavioural and unit tests use lightweight factories to inject fakes without bootstrapping the full container. The
falcon_pachinko.unittests.resource_factories.resource_factory()helper returns a callable compatible withWebSocketRouterthat simply merges a provided dependency into the stored partial's kwargs. Tests can therefore inject spies or mock services while still exercising the router's lifecycle exactly as production code would.
Both patterns emphasize explicit construction and keep the dependency graph outside the framework core, maintaining Falcon's principle of explicitness.
Benefits
- Decoupling: Resource classes simply declare constructor arguments—the router decides how those arguments are provided.
- Flexibility: Any DI approach (manual factories, dataclass-style wiring,
external containers such as
dependency-injector) can participate without additional framework support. - Testability: Tests provide bespoke factories to inject fakes or spies.
- Backward Compatibility: Existing codebases that rely solely on
add_route()behaviour require no changes because the default factory still calls the stored partial directly.
5.5.2. Enabling a "Schema-First" Workflow
The combination of AsyncAPI-aligned routing and msgspec schemas enables a
powerful "Schema-First" development workflow. Teams can define their API
contract in an asyncapi.yaml file, use tooling to generate msgspec.Struct
classes and resource skeletons, and then implement the business logic. The
framework, at runtime, uses these same schemas to enforce the contract,
preventing drift between implementation and documentation. This is a
significant strategic advantage.