-
-
Save kgriffs/023dcdc39c07c0ec0c749d0ddf29c4da to your computer and use it in GitHub Desktop.
# See also: | |
# | |
# * https://asgi.readthedocs.io/en/latest/specs/www.html#websocket | |
# * https://developer.mozilla.org/en-US/docs/Web/API/Websockets_API | |
# | |
import falcon.asgi | |
import falcon.media | |
class SomeResource: | |
# Get a paginated list of events via a regular HTTP request. | |
# | |
# For small-scale, all-in-one apps, it may make sense to support | |
# both a regular HTTP interface and one based on WebSocket | |
# side-by-side in the same deployment. However, these two | |
# interaction models have very different performance characteristics, | |
# and so larger scale-out deployments may wish to specifically | |
# designate instance groups for one type of traffic vs. the | |
# other (although the actual applications may still be capable | |
# of handling both modes). | |
# | |
async def on_get(self, req: Request, account_id: str): | |
pass | |
# Push event stream to client. Note that the framework will pass | |
# parameters defined in the URI template as with HTTP method | |
# responders. | |
async def on_websocket(self, req: Request, ws: WebSocket, account_id: str): | |
# The HTTP request used to initiate the WebSocket handshake can be | |
# examined as needed. | |
some_header_value = req.get_header('Some-Header') | |
# Reject it? | |
if some_condition: | |
# If close() is called before accept() the code kwarg is | |
# ignored, if present, and the server returns a 403 | |
# HTTP response without upgrading the connection. | |
await ws.close() | |
return | |
# Examine subprotocols advertised by the client. Here let's just | |
# assume we only support wamp, so if the client doesn't advertise | |
# it we reject the connection. | |
if 'wamp' not in ws.subprotocols: | |
# If close() is not called explicitly, the framework will | |
# take care of it automatically with the default code (1000). | |
return | |
# If, after examining the connection info, you would like to accept | |
# it, simply call accept() as follows: | |
try: | |
await ws.accept(subprotocol='wamp') | |
except WebSocketDisconnected: | |
return | |
# Simply start sending messages to the client if this is an event | |
# feed endpoint. | |
while True: | |
try: | |
event = await my_next_event() | |
# Send an instance of str as a WebSocket TEXT (0x01) payload | |
await ws.send_text(event) | |
# Send an instance of bytes, bytearray, or memoryview as a | |
# WebSocket BINARY (0x02) payload. | |
await ws.send_data(event) | |
# Or if you want it to be serialized to JSON (by default; can | |
# be customized via app.ws_options.media_handlers): | |
await ws.send_media(event) # Defaults to WebSocketPayloadType.TEXT | |
except WebSocketDisconnected: | |
# Do any necessary cleanup, then bail out | |
return | |
# ...or loop like this to implement a simple request-response protocol | |
while True: | |
try: | |
# Use this if you expect a WebSocket TEXT (0x01) payload, | |
# decoded from UTF-8 to a Unicode string. | |
payload_str = await ws.receive_text() | |
# Or if you are expecting a WebSocket BINARY (0x02) payload, | |
# in which case you will end up with a byte string result: | |
payload_bytes = await ws.receive_data() | |
# Or if you want to get a serialized media object (defaults to | |
# JSON deserialization of text payloads, and MessagePack | |
# deserialization for BINARY payloads, but this can be | |
# customized via app.ws_options.media_handlers). | |
media_object = await ws.receive_media() | |
except WebSocketDisconnected: | |
# Do any necessary cleanup, then bail out | |
return | |
except TypeError: | |
# The received message payload was not of the expected | |
# type (e.g., got BINARY when TEXT was expected). | |
pass | |
except json.JSONDecodeError: | |
# The default media deserializer uses the json standard | |
# library, so you might see this error raised as well. | |
pass | |
# At any time, you may decide to close the websocket. If the | |
# socket is already closed, this call does nothing (it will | |
# not raise an error.) | |
if we_are_so_done_with_this_conversation(): | |
# https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent | |
await ws.close(code=1000) | |
return | |
try: | |
# Here we are sending as a binary (0x02) payload type, which | |
# will go find the handler configured for that (defaults to | |
# MessagePack which assumes you've also installed that | |
# package, but this can be customized as mentioned above.') | |
await ws.send_media( | |
{'event': 'message'}, | |
payload_type=WebSocketPayloadType.BINARY, | |
) | |
except WebSocketDisconnected: | |
# Do any necessary cleanup, then bail out. If ws.close() was | |
# not already called by the app, the framework will take | |
# care of it. | |
# NOTE: If you do not handle this exception, it will be | |
# bubbled up to a default error handler that simply | |
# logs the message as a warning and then closes the | |
# server side of the connection. This handler can be | |
# overwritten as with any other error handler for the app. | |
# NOTE: If the error handler accepts an 'ws' kwarg, the framework | |
# will pass in the falcon.asgi.WebSocket instance. | |
return | |
# ...or run a couple of different loops in parallel to support | |
# independent bidirectional message streams. | |
messages = collections.deque() | |
async def sink(): | |
while True: | |
try: | |
message = await ws.receive_text() | |
except falcon.WebSocketDisconnected: | |
break | |
messages.append(message) | |
sink_task = falcon.create_task(sink()) | |
while not sink_task.done(): | |
while ws.ready and not messages and not sink_task.done(): | |
await asyncio.sleep(0) | |
try: | |
await ws.send_text(messages.popleft()) | |
except falcon.WebSocketDisconnected: | |
break | |
sink_task.cancel() | |
try: | |
await sink_task | |
except asyncio.CancelledError: | |
pass | |
class SomeMiddleware: | |
async def process_request_ws(self, req: Request): | |
# This will be called for the HTTP request that initiates the | |
# WebSocket handshake before routing. | |
pass | |
async def process_resource_ws(self, req: Request, resource, params): | |
# This will be called for the HTTP request that initiates the | |
# WebSocket handshake after routing (if a route matches the | |
# request). | |
pass | |
app = falcon.asgi.App(middleware=SomeMiddleware()) | |
app.add_route('/{account_id}/messages', SomeResource()) | |
# Let's say we want to use a faster JSON library. You could also use this | |
# pattern to add serialization support for custom types that aren't | |
# normally JSON-serializable out of the box. | |
class RapidJSONHandler(falcon.media.TextBaseHandlerWS): | |
def serialize(self, media: object) -> str: | |
return rapidjson.dumps(media, ensure_ascii=False) | |
# The raw TEXT payload will be passed as a Unicode string | |
def deserialize(self, payload: str) -> object: | |
return rapidjson.loads(payload) | |
# And/or for binary mode we want to use Protocol Buffers: | |
class ProtocolBuffersHandler(falcon.media.BinaryBaseHandlerWS): | |
def serialize(self, media: message_pb2.Message) -> Union[bytes, bytearray, memoryview]: | |
return media.SerializeToString() | |
# The raw BINARY payload will be passed as a byte string | |
def deserialize(self, payload: bytes) -> message_pb2.Message: | |
message = message_pb2.Message() | |
return message.ParseFromString(payload) | |
# Expected to (de)serialize from/to str | |
json_handler = RapidJSONHandler() | |
app.ws_options.media_handlers[falcon.WebSocketPayloadType.TEXT] = json_handler | |
# Expected to (de)serialize from/to bytes, bytearray, or memoryview | |
protobuf_handler = ProtocolBuffersHandler() | |
app.ws_options.media_handlers[falcon.WebSocketPayloadType.BINARY] = protobuf_handler |
This all seems sensical to me. The only edge case I'm wondering about is if you're planning to offer access to HTTP headers (not possible to set these from Javascript when establishing a websocket connection, but they can be passed from other clients).
I updated the gist with one way we might expose the HTTP request from the handshake. Let me know what you think!
That makes sense to me. Only question: would ws.req
expose anything else? If not, ws.get_header()
might be sufficient.
hi :)
I have been following this gist since I really like the idea falcon + websocket. It might be some cases that a websocket connection includes also some query params. Having said that, perhaps it would be nice to include a subset of the existing attributes of http.req
to ws.req
. This subset could be of course very minimal compared to http.req
.
Again it's just an idea.
What is interesting is that the ASGI connection scopes for HTTP vs. WebSocket are nearly identical, and so we could simply expose an instance of the falcon.asgi.Request
class. The only thing we'd have to do is assume GET for the HTTP method since that field isn't explicit in the WebSocket scope. The WebSocket scope may also include a subprotocols
field, but that would probably make more sense to attach as directly to the WebSocket class as a property (e.g., ws.subprotocols
).
Question for everyone: Would it be better to have a separate middleware method (process_request_ws()
) or simply also call process_request()
for WebSocket handshakes and expose a Request property that the app can check as needed (e.g., req.is_ws
)?
I vote for process_request_ws
since there are less chanches of breaking current middlewares.
At worst if a middleware works for websokets but has no explicit support for the new method a simple patch middleware.process_request_ws = middleware.process_request_ws
can be done by an user
Agreed with process_request_ws
being preferable. Particularly since these are potentially long-lived connections that only look like HTTP upon the initial connection, it's probably better to opt into middleware rather than having it implicitly supported.
@CaselIT I don't think there's massive risk of breaking current middlewares, because the absolute majority of those should be targeting the stable WSGI version of Falcon. I.e., they wouldn't be async def
s 🙂 Good point about being able to just do process_request_ws = process_request
in a pinch though.
Nevertheless I can see different middleware methods might make sense due to very different use cases. But I don't have a strong preference here; the same process_request
for both could have advantages too.
so we could simply expose an instance of the falcon.asgi.Request class. The only thing we'd have to do is assume GET for the HTTP method since that field isn't explicit in the WebSocket scope.
👍
Just a side note if we are still planning to also support WebSocket on WSGI for uWSGI users (see a note here: falconry/falcon#321 (comment)). I doesn't look like uWSGI supports closing the socket in the middle of a handler in any meaningful way except from returning.
@CaselIT I don't think there's massive risk of breaking current middlewares, because the absolute majority of those should be targeting the stable WSGI version of Falcon. I.e., they wouldn't be
async def
s slightly_smiling_face Good point about being able to just doprocess_request_ws = process_request
in a pinch though.Nevertheless I can see different middleware methods might make sense due to very different use cases. But I don't have a strong preference here; the same
process_request
for both could have advantages too.
👍
Another way the handshake HTTP request might be presented would be to pass it as a separate argument, in a similar manner as the regular responder methods, e.g.:
async def on_websocket(self, req, ws):
pass
Another way the handshake HTTP request might be presented would be to pass it as a separate argument, in a similar manner as the regular responder methods
Ooh, I actually like that a lot better. In particular, it makes more explicit that the request is a single-time thing that's separate from the websocket connection. Plus it's consistent with how the rest of the framework functions; request is a different object than the response (and ws
fulfills a very similar role to resp
since it handles passing data back to the client).
async def on_websocket(self, req, ws):
pass
My vote goes for this one too
async def on_websocket(self, req, ws):
pass
Yeap, this is even better 👍
I also prefer this one.
Maybe to avoid possible confusion wrt the other on_
methods, we could start using pep484 in the examples.
Like
async def on_get(self, req: Request, res: Response):
pass
async def on_websocket(self, req: Request, ws: WebSokect):
pass
This make clear to the reader what the type are
Gist updated!
OK everyone, I have updated the gist again; please let me know what you think!
After careful consideration, I moved away from reusing the HTTP media handlers in favor of WebSocket-specific ones, since it is common to use the TEXT (0x01) WebSocket payload/frame type to serialize JSON, and ASGI requires passing a payload of type str
to invoke the 0x01 frame type. The HTTP media handlers just always assume you want to go to/from bytes
.
Also, I made send/receive methods more explicit according to the payload/frame type you want or expect. The WebSocket media handlers are keyed off the payload type, allowing you to have two different formats. I took this approach because the WebSocket protocol does not really have a standard way of negotiation Internet media types as with regular HTTP requests and responses. I figured that (1) most people are going to just assume JSON anyway and if they want something else, (2) they will want/need to customize it to their liking anyway.
That all makes sense to me, and the example code doesn't have any red flags standing out.
It's even better than previous proposal. I really like how text and binary get handled!
Here is an alternative proposal for the text/binary interface, modeled after the websockets interface:
# If str, sent as a text frame. If bytes, bytearray, or
# memoryview, sent as a binary frame. Otherwise, raise
# an error. To serialize from an object instead, use
# send_media() instead.
await ws.send(message)
# Will first attempt to get text payload and decode from UTF-8, then
# return as a str. If text frame not present, will decode from binary
# frame and return as byte string. Use receive_media() instead to
# deserialize to an object.
message = await ws.receive()
Thoughts? Anyone have a strong opinion on the above approach vs. send_text(), send_binary() ? We could also support both, essentially making the above convenience functions that wrap the more explicit ones.
I prefer the explicit one, but I think that we could support both quite easly
async def send(self, obj):
if isinstance(obj, str):
return self._sent_text(obj)
if isinstance(obj, (byte, ...)):
return self._send_binary(obj)
raise TypeError(...)
No strong opinion here, but I also prefer the explicit send_
s.
But I'm not opposed to providing convenience shims in the spirit of Response.body
(text, but understands bytes too).
Talking of the response interface. Might it be worth renaming send_binary
to send_data
? OTOH "binary" better reflects the WebSocket binary payload type...
Might it be worth renaming send_binary to send_data?
I've been going back and forth on this in my mind...it would be more consistent with the rest of the framework to go with "data" and so perhaps that is better after all.
I prefer the explicit one
In my mind, one of the benefits of the explicit approach is that we can do more checking and raise an error sooner rather than later if someone accidentally uses the wrong data or frame type. If that is something we want to encourage, then adding the additional convenience methods may not be a good idea after all?
OK everyone, please take another look at the gist... I went ahead and modified the interface to be more consistent with other parts of the Falcon framework, namely using *_data
instead of *_binary
. Also, I'm thinking we will stick with explicit send/receive for now.
Feedback is welcome.
I should also mention that I am testing a prototype of this in my fork...hope to have something to show and tell soon!
Draft PR: falconry/falcon#1741
PR has been merged! Next pre-release will include WS support. Please help test! falconry/falcon#1741
I updated the gist with some ideas re media handlers. Let me know what you think.