Change to consumers taking a single "message" argument

This commit is contained in:
Andrew Godwin 2015-09-08 01:04:36 -05:00
parent 9b92eec43a
commit 48d6f63fb2
11 changed files with 185 additions and 157 deletions

View File

@ -3,7 +3,7 @@ import functools
from django.core.handlers.base import BaseHandler
from django.http import HttpRequest, HttpResponse
from channels import Channel, channel_backends, DEFAULT_CHANNEL_BACKEND
from channels import Channel
class UrlConsumer(object):
@ -15,13 +15,13 @@ class UrlConsumer(object):
self.handler = BaseHandler()
self.handler.load_middleware()
def __call__(self, channel, **kwargs):
request = HttpRequest.channel_decode(kwargs)
def __call__(self, message):
request = HttpRequest.channel_decode(message.content)
try:
response = self.handler.get_response(request)
except HttpResponse.ResponseLater:
return
Channel(request.response_channel).send(**response.channel_encode())
message.reply_channel.send(response.channel_encode())
def view_producer(channel_name):
@ -30,24 +30,19 @@ def view_producer(channel_name):
and abandons the response (with an exception the Worker will catch)
"""
def producing_view(request):
Channel(channel_name).send(**request.channel_encode())
Channel(channel_name).send(request.channel_encode())
raise HttpResponse.ResponseLater()
return producing_view
def view_consumer(channel_name, alias=DEFAULT_CHANNEL_BACKEND):
def view_consumer(func):
"""
Decorates a normal Django view to be a channel consumer.
Does not run any middleware
"""
def inner(func):
@functools.wraps(func)
def consumer(channel, **kwargs):
request = HttpRequest.channel_decode(kwargs)
response = func(request)
Channel(request.response_channel).send(**response.channel_encode())
# Get the channel layer and register
channel_backend = channel_backends[DEFAULT_CHANNEL_BACKEND]
channel_backend.registry.add_consumer(consumer, [channel_name])
return func
return inner
@functools.wraps(func)
def consumer(message):
request = HttpRequest.channel_decode(message.content)
response = func(request)
message.reply_channel.send(response.channel_encode())
return func

View File

@ -26,11 +26,13 @@ class Channel(object):
else:
self.channel_backend = channel_backends[alias]
def send(self, **kwargs):
def send(self, content):
"""
Send a message over the channel, taken from the kwargs.
Send a message over the channel - messages are always dicts.
"""
self.channel_backend.send(self.name, kwargs)
if not isinstance(content, dict):
raise ValueError("You can only send dicts as content on channels.")
self.channel_backend.send(self.name, content)
@classmethod
def new_name(self, prefix):
@ -51,6 +53,9 @@ class Channel(object):
from channels.adapters import view_producer
return view_producer(self.name)
def __str__(self):
return self.name
class Group(object):
"""
@ -66,13 +71,19 @@ class Group(object):
self.channel_backend = channel_backends[alias]
def add(self, channel):
if isinstance(channel, Channel):
channel = channel.name
self.channel_backend.group_add(self.name, channel)
def discard(self, channel):
if isinstance(channel, Channel):
channel = channel.name
self.channel_backend.group_discard(self.name, channel)
def channels(self):
self.channel_backend.group_channels(self.name)
def send(self, **kwargs):
self.channel_backend.send_group(self.name, kwargs)
def send(self, content):
if not isinstance(content, dict):
raise ValueError("You can only send dicts as content on channels.")
self.channel_backend.send_group(self.name, content)

View File

@ -25,27 +25,27 @@ def http_session(func):
be None, rather than an empty session you can write to.
"""
@functools.wraps(func)
def inner(*args, **kwargs):
if "COOKIES" not in kwargs and "GET" not in kwargs:
def inner(message, *args, **kwargs):
if "COOKIES" not in message.content and "GET" not in message.content:
raise ValueError("No COOKIES or GET sent to consumer; this decorator can only be used on messages containing at least one.")
# Make sure there's a session key
session_key = None
if "GET" in kwargs:
if "GET" in message.content:
try:
session_key = kwargs['GET'].get("session_key", [])[0]
session_key = message.content['GET'].get("session_key", [])[0]
except IndexError:
pass
if "COOKIES" in kwargs and session_key is None:
session_key = kwargs['COOKIES'].get(settings.SESSION_COOKIE_NAME)
if "COOKIES" in message.content and session_key is None:
session_key = message.content['COOKIES'].get(settings.SESSION_COOKIE_NAME)
# Make a session storage
if session_key:
session_engine = import_module(settings.SESSION_ENGINE)
session = session_engine.SessionStore(session_key=session_key)
else:
session = None
kwargs['session'] = session
message.session = session
# Run the consumer
result = func(*args, **kwargs)
result = func(message, *args, **kwargs)
# Persist session if needed (won't be saved if error happens)
if session is not None and session.modified:
session.save()
@ -65,46 +65,49 @@ def http_django_auth(func):
"""
@http_session
@functools.wraps(func)
def inner(*args, **kwargs):
def inner(message, *args, **kwargs):
# If we didn't get a session, then we don't get a user
if kwargs['session'] is None:
kwargs['user'] = None
if not hasattr(message, "session"):
raise ValueError("Did not see a session to get auth from")
if message.session is None:
message.user = None
# Otherwise, be a bit naughty and make a fake Request with just
# a "session" attribute (later on, perhaps refactor contrib.auth to
# pass around session rather than request)
else:
fake_request = type("FakeRequest", (object, ), {"session": kwargs['session']})
kwargs['user'] = auth.get_user(fake_request)
fake_request = type("FakeRequest", (object, ), {"session": message.session})
message.user = auth.get_user(fake_request)
# Run the consumer
return func(*args, **kwargs)
return func(message, *args, **kwargs)
return inner
def send_channel_session(func):
def channel_session(func):
"""
Provides a session-like object called "channel_session" to consumers
as a message attribute that will auto-persist across consumers with
the same incoming "send_channel" value.
the same incoming "reply_channel" value.
"""
@functools.wraps(func)
def inner(*args, **kwargs):
# Make sure there's a send_channel in kwargs
if "send_channel" not in kwargs:
raise ValueError("No send_channel sent to consumer; this decorator can only be used on messages containing it.")
# Turn the send_channel into a valid session key length thing.
def inner(message, *args, **kwargs):
# Make sure there's a reply_channel in kwargs
if not message.reply_channel:
raise ValueError("No reply_channel sent to consumer; this decorator can only be used on messages containing it.")
# Turn the reply_channel into a valid session key length thing.
# We take the last 24 bytes verbatim, as these are the random section,
# and then hash the remaining ones onto the start, and add a prefix
# TODO: See if there's a better way of doing this
session_key = "skt" + hashlib.md5(kwargs['send_channel'][:-24]).hexdigest()[:8] + kwargs['send_channel'][-24:]
reply_name = message.reply_channel.name
session_key = "skt" + hashlib.md5(reply_name[:-24]).hexdigest()[:8] + reply_name[-24:]
# Make a session storage
session_engine = import_module(settings.SESSION_ENGINE)
session = session_engine.SessionStore(session_key=session_key)
# If the session does not already exist, save to force our session key to be valid
if not session.exists(session.session_key):
session.save()
kwargs['channel_session'] = session
message.channel_session = session
# Run the consumer
result = func(*args, **kwargs)
result = func(message, *args, **kwargs)
# Persist session if needed (won't be saved if error happens)
if session.modified:
session.save()

View File

@ -23,30 +23,26 @@ class InterfaceProtocol(WebSocketServerProtocol):
def onOpen(self):
# Make sending channel
self.send_channel = Channel.new_name("!django.websocket.send")
self.reply_channel = Channel.new_name("!django.websocket.send")
self.request_info["reply_channel"] = self.reply_channel
self.last_keepalive = time.time()
self.factory.protocols[self.send_channel] = self
self.factory.protocols[self.reply_channel] = self
# Send news that this channel is open
Channel("django.websocket.connect").send(
send_channel = self.send_channel,
**self.request_info
)
Channel("django.websocket.connect").send(self.request_info)
def onMessage(self, payload, isBinary):
if isBinary:
Channel("django.websocket.receive").send(
send_channel = self.send_channel,
Channel("django.websocket.receive").send(dict(
self.request_info,
content = payload,
binary = True,
**self.request_info
)
))
else:
Channel("django.websocket.receive").send(
send_channel = self.send_channel,
Channel("django.websocket.receive").send(dict(
self.request_info,
content = payload.decode("utf8"),
binary = False,
**self.request_info
)
))
def serverSend(self, content, binary=False, **kwargs):
"""
@ -64,21 +60,15 @@ class InterfaceProtocol(WebSocketServerProtocol):
self.sendClose()
def onClose(self, wasClean, code, reason):
if hasattr(self, "send_channel"):
del self.factory.protocols[self.send_channel]
Channel("django.websocket.disconnect").send(
send_channel = self.send_channel,
**self.request_info
)
if hasattr(self, "reply_channel"):
del self.factory.protocols[self.reply_channel]
Channel("django.websocket.disconnect").send(self.request_info)
def sendKeepalive(self):
"""
Sends a keepalive packet on the keepalive channel.
"""
Channel("django.websocket.keepalive").send(
send_channel = self.send_channel,
**self.request_info
)
Channel("django.websocket.keepalive").send(self.request_info)
self.last_keepalive = time.time()
@ -94,7 +84,7 @@ class InterfaceFactory(WebSocketServerFactory):
super(InterfaceFactory, self).__init__(*args, **kwargs)
self.protocols = {}
def send_channels(self):
def reply_channels(self):
return self.protocols.keys()
def dispatch_send(self, channel, message):
@ -128,7 +118,7 @@ class WebsocketTwistedInterface(object):
Run in a separate thread; reads messages from the backend.
"""
while True:
channels = self.factory.send_channels()
channels = self.factory.reply_channels()
# Quit if reactor is stopping
if not reactor.running:
return

View File

@ -15,7 +15,7 @@ class WSGIInterface(WSGIHandler):
super(WSGIInterface, self).__init__(*args, **kwargs)
def get_response(self, request):
request.response_channel = Channel.new_name("django.wsgi.response")
Channel("django.wsgi.request", channel_backend=self.channel_backend).send(**request.channel_encode())
channel, message = self.channel_backend.receive_many_blocking([request.response_channel])
request.reply_channel = Channel.new_name("django.wsgi.response")
Channel("django.wsgi.request", channel_backend=self.channel_backend).send(request.channel_encode())
channel, message = self.channel_backend.receive_many_blocking([request.reply_channel])
return HttpResponse.channel_decode(message)

18
channels/message.py Normal file
View File

@ -0,0 +1,18 @@
from .channel import Channel
class Message(object):
"""
Represents a message sent over a Channel.
The message content is a dict called .content, while
reply_channel is an optional extra attribute representing a channel
to use to reply to this message's end user, if that makes sense.
"""
def __init__(self, content, channel, channel_backend, reply_channel=None):
self.content = content
self.channel = channel
self.channel_backend = channel_backend
if reply_channel:
self.reply_channel = Channel(reply_channel, channel_backend=self.channel_backend)

View File

@ -17,7 +17,7 @@ def encode_request(request):
"path": request.path,
"path_info": request.path_info,
"method": request.method,
"response_channel": request.response_channel,
"reply_channel": request.reply_channel,
}
return value
@ -34,7 +34,7 @@ def decode_request(value):
request.path = value['path']
request.method = value['method']
request.path_info = value['path_info']
request.response_channel = value['response_channel']
request.reply_channel = value['reply_channel']
return request

View File

@ -1,4 +1,5 @@
import traceback
from .message import Message
class Worker(object):
@ -17,12 +18,18 @@ class Worker(object):
"""
channels = self.channel_backend.registry.all_channel_names()
while True:
channel, message = self.channel_backend.receive_many_blocking(channels)
channel, content = self.channel_backend.receive_many_blocking(channels)
message = Message(
content=content,
channel=channel,
channel_backend=self.channel_backend,
reply_channel=content.get("reply_channel", None),
)
# Handle the message
consumer = self.channel_backend.registry.consumer_for_channel(channel)
if self.callback:
self.callback(channel, message)
try:
consumer(channel=channel, **message)
consumer(message)
except:
traceback.print_exc()

View File

@ -100,17 +100,17 @@ message and can write out zero to many other channel messages.
Now, let's make a channel for requests (called ``django.wsgi.request``),
and a channel per client for responses (e.g. ``django.wsgi.response.o4F2h2Fd``),
with the response channel a property (``send_channel``) of the request message.
with the response channel a property (``reply_channel``) of the request message.
Suddenly, a view is merely another example of a consumer::
@consumer("django.wsgi.request")
def my_consumer(send_channel, **request_data):
def my_consumer(reply_channel, **request_data):
# Decode the request from JSON-compat to a full object
django_request = Request.decode(request_data)
# Run view
django_response = view(django_request)
# Encode the response into JSON-compat format
Channel(send_channel).send(django_response.encode())
Channel(reply_channel).send(django_response.encode())
In fact, this is how Channels works. The interface servers transform connections
from the outside world (HTTP, WebSockets, etc.) into messages on channels,
@ -177,16 +177,16 @@ set of channels (here, using Redis) to send updates to::
@receiver(post_save, sender=BlogUpdate)
def send_update(sender, instance, **kwargs):
# Loop through all response channels and send the update
for send_channel in redis_conn.smembers("readers"):
Channel(send_channel).send(
for reply_channel in redis_conn.smembers("readers"):
Channel(reply_channel).send(
id=instance.id,
content=instance.content,
)
@consumer("django.websocket.connect")
def ws_connect(path, send_channel, **kwargs):
def ws_connect(path, reply_channel, **kwargs):
# Add to reader set
redis_conn.sadd("readers", send_channel)
redis_conn.sadd("readers", reply_channel)
While this will work, there's a small problem - we never remove people from
the ``readers`` set when they disconnect. We could add a consumer that
@ -221,9 +221,9 @@ we don't need to; Channels has it built in, as a feature called Groups::
@consumer("django.websocket.connect")
@consumer("django.websocket.keepalive")
def ws_connect(path, send_channel, **kwargs):
def ws_connect(path, reply_channel, **kwargs):
# Add to reader group
Group("liveblog").add(send_channel)
Group("liveblog").add(reply_channel)
Not only do groups have their own ``send()`` method (which backends can provide
an efficient implementation of), they also automatically manage expiry of

View File

@ -26,16 +26,16 @@ Make a new project, a new app, and put this in a ``consumers.py`` file in the ap
from channels import Channel
from django.http import HttpResponse
def http_consumer(response_channel, path, **kwargs):
response = HttpResponse("Hello world! You asked for %s" % path)
Channel(response_channel).send(**response.channel_encode())
def http_consumer(message):
response = HttpResponse("Hello world! You asked for %s" % message.content['path'])
message.reply_channel.send(response.channel_encode())
The most important thing to note here is that, because things we send in
messages must be JSON-serialisable, the request and response messages
are in a key-value format. There are ``channel_decode()`` and
``channel_encode()`` methods on both Django's request and response classes,
but here we just take two of the request variables directly as keyword
arguments for simplicity.
but here we just use the message's ``content`` attribute directly for simplicity
(message content is always a dict).
Now, go into your ``settings.py`` file, and set up a channel backend; by default,
Django will just use a local backend and route HTTP requests to the normal
@ -72,8 +72,8 @@ do any time. Let's try some WebSockets, and make a basic chat server!
Delete that consumer and its routing - we'll want the normal Django view layer to
serve HTTP requests from now on - and make this WebSocket consumer instead::
def ws_add(channel, send_channel, **kwargs):
Group("chat").add(send_channel)
def ws_add(message):
Group("chat").add(message.reply_channel)
Hook it up to the ``django.websocket.connect`` channel like this::
@ -90,7 +90,7 @@ Now, let's look at what this is doing. It's tied to the
``django.websocket.connect`` channel, which means that it'll get a message
whenever a new WebSocket connection is opened by a client.
When it gets that message, it takes the ``send_channel`` key from it, which
When it gets that message, it takes the ``reply_channel`` attribute from it, which
is the unique response channel for that client, and adds it to the ``chat``
group, which means we can send messages to all connected chat clients.
@ -107,8 +107,8 @@ a group it's already in - similarly, it's safe to discard a channel from a
group it's not in)::
# Connected to django.websocket.keepalive
def ws_keepalive(channel, send_channel, **kwargs):
Group("chat").add(send_channel)
def ws_keepalive(message):
Group("chat").add(message.reply_channel)
Of course, this is exactly the same code as the ``connect`` handler, so let's
just route both channels to the same consumer::
@ -125,8 +125,8 @@ handler to clean up as people disconnect (most channels will cleanly disconnect
and get this called)::
# Connected to django.websocket.disconnect
def ws_disconnect(channel, send_channel, **kwargs):
Group("chat").discard(send_channel)
def ws_disconnect(message):
Group("chat").discard(message.reply_channel)
Now, that's taken care of adding and removing WebSocket send channels for the
``chat`` group; all we need to do now is take care of message sending. For now,
@ -136,16 +136,16 @@ any message sent in to all connected clients. Here's all the code::
from channels import Channel, Group
# Connected to django.websocket.connect and django.websocket.keepalive
def ws_add(channel, send_channel, **kwargs):
Group("chat").add(send_channel)
def ws_add(message):
Group("chat").add(message.reply_channel)
# Connected to django.websocket.receive
def ws_message(channel, send_channel, content, **kwargs):
Group("chat").send(content=content)
def ws_message(message):
Group("chat").send(message.content)
# Connected to django.websocket.disconnect
def ws_disconnect(channel, send_channel, **kwargs):
Group("chat").discard(send_channel)
def ws_disconnect(message):
Group("chat").discard(message.reply_channel)
And what our routing should look like in ``settings.py``::
@ -264,7 +264,7 @@ is much more portable, and works in development where you need to run a separate
WebSocket server (by default, on port 9000).
All we need to do is add the ``django_http_auth`` decorator to our views,
and we'll get extra ``session`` and ``user`` keyword arguments we can use;
and we'll get extra ``session`` and ``user`` keyword attributes on ``message`` we can use;
let's make one where users can only chat to people with the same first letter
of their username::
@ -272,16 +272,16 @@ of their username::
from channels.decorators import django_http_auth
@django_http_auth
def ws_add(channel, send_channel, user, **kwargs):
Group("chat-%s" % user.username[0]).add(send_channel)
def ws_add(message):
Group("chat-%s" % message.user.username[0]).add(message.reply_channel)
@django_http_auth
def ws_message(channel, send_channel, content, user, **kwargs):
Group("chat-%s" % user.username[0]).send(content=content)
def ws_message(message):
Group("chat-%s" % message.user.username[0]).send(message.content)
@django_http_auth
def ws_disconnect(channel, send_channel, user, **kwargs):
Group("chat-%s" % user.username[0]).discard(send_channel)
def ws_disconnect(message):
Group("chat-%s" % message.user.username[0]).discard(message.reply_channel)
Now, when we connect to the WebSocket we'll have to remember to provide the
Django session ID as part of the URL, like this::
@ -293,10 +293,6 @@ Note that Channels can't work with signed cookie sessions - since only HTTP
responses can set cookies, it needs a backend it can write to separately to
store state.
(Also note that we always end consumers with ``**kwargs``; this is to save us
from writing out all variables we might get sent and to allow forwards-compatibility
with any additions to the message formats in the future.)
Persisting Data
---------------
@ -307,7 +303,7 @@ should let them send this request in the initial WebSocket connection,
check they're allowed to access it, and then remember which room a socket is
connected to when they send a message in so we know which group to send it to.
The ``send_channel`` is our unique pointer to the open WebSocket - as you've
The ``reply_channel`` is our unique pointer to the open WebSocket - as you've
seen, we do all our operations on it - but it's not something we can annotate
with data; it's just a simple string, and even if we hack around and set
attributes on it that's not going to carry over to other workers.
@ -315,18 +311,18 @@ attributes on it that's not going to carry over to other workers.
Instead, the solution is to persist information keyed by the send channel in
some other data store - sound familiar? This is what Django's session framework
does for HTTP requests, only there it uses cookies as the lookup key rather
than the ``send_channel``.
than the ``reply_channel``.
Now, as you saw above, you can use the ``django_http_auth`` decorator to get
both a ``user`` and a ``session`` variable in your message arguments - and,
indeed, there is a ``websocket_session`` decorator that will just give you
both a ``user`` and a ``session`` attribute on your message - and,
indeed, there is a ``http_session`` decorator that will just give you
the ``session`` attribute.
However, that session is based on cookies, and so follows the user round the
site - it's great for information that should persist across all WebSocket and
HTTP connections, but not great for information that is specific to a single
WebSocket (such as "which chatroom should this socket be connected to"). For
this reason, Channels also provides a ``send_channel_session`` decorator,
this reason, Channels also provides a ``channel_session`` decorator,
which adds a ``channel_session`` attribute to the message; this works just like
the normal ``session`` attribute, and persists to the same storage, but varies
per-channel rather than per-cookie.
@ -335,31 +331,31 @@ Let's use it now to build a chat server that expects you to pass a chatroom
name in the path of your WebSocket request (we'll ignore auth for now)::
from channels import Channel
from channels.decorators import consumer, send_channel_session
from channels.decorators import consumer, channel_session
@consumer("django.websocket.connect")
@send_channel_session
def ws_connect(channel, send_channel, path, channel_session, **kwargs):
@channel_session
def ws_connect(message):
# Work out room name from path (ignore slashes)
room = path.strip("/")
room = message.content['path'].strip("/")
# Save room in session and add us to the group
channel_session['room'] = room
Group("chat-%s" % room).add(send_channel)
message.channel_session['room'] = room
Group("chat-%s" % room).add(message.reply_channel)
@consumer("django.websocket.keepalive")
@send_channel_session
def ws_add(channel, send_channel, channel_session, **kwargs):
Group("chat-%s" % channel_session['room']).add(send_channel)
@channel_session
def ws_add(message):
Group("chat-%s" % message.channel_session['room']).add(message.reply_channel)
@consumer("django.websocket.receive")
@send_channel_session
def ws_message(channel, send_channel, content, channel_session, **kwargs):
Group("chat-%s" % channel_session['room']).send(content=content)
@channel_session
def ws_message(message):
Group("chat-%s" % message.channel_session['room']).send(content)
@consumer("django.websocket.disconnect")
@send_channel_session
def ws_disconnect(channel, send_channel, channel_session, **kwargs):
Group("chat-%s" % channel_session['room']).discard(send_channel)
@channel_session
def ws_disconnect(message):
Group("chat-%s" % message.channel_session['room']).discard(message.reply_channel)
If you play around with it from the console (or start building a simple
JavaScript chat client that appends received messages to a div), you'll see
@ -391,40 +387,48 @@ Let's see what that looks like, assuming we
have a ChatMessage model with ``message`` and ``room`` fields::
from channels import Channel
from channels.decorators import consumer, send_channel_session
from channels.decorators import consumer, channel_session
from .models import ChatMessage
@consumer("chat-messages")
def msg_consumer(channel, room, message):
def msg_consumer(message):
# Save to model
ChatMessage.objects.create(room=room, message=message)
ChatMessage.objects.create(
room=message.content['room'],
message=message.content['message'],
)
# Broadcast to listening sockets
Group("chat-%s" % room).send(message)
Group("chat-%s" % room).send({
"content": message.content['message'],
})
@consumer("django.websocket.connect")
@send_channel_session
def ws_connect(channel, send_channel, path, channel_session, **kwargs):
@channel_session
def ws_connect(message):
# Work out room name from path (ignore slashes)
room = path.strip("/")
room = message.content['path'].strip("/")
# Save room in session and add us to the group
channel_session['room'] = room
Group("chat-%s" % room).add(send_channel)
message.channel_session['room'] = room
Group("chat-%s" % room).add(message.reply_channel)
@consumer("django.websocket.keepalive")
@send_channel_session
def ws_add(channel, send_channel, channel_session, **kwargs):
Group("chat-%s" % channel_session['room']).add(send_channel)
@channel_session
def ws_add(message):
Group("chat-%s" % message.channel_session['room']).add(message.reply_channel)
@consumer("django.websocket.receive")
@send_channel_session
def ws_message(channel, send_channel, content, channel_session, **kwargs):
@channel_session
def ws_message(message):
# Stick the message onto the processing queue
Channel("chat-messages").send(room=channel_session['room'], message=content)
Channel("chat-messages").send({
"room": channel_session['room'],
"message": content,
})
@consumer("django.websocket.disconnect")
@send_channel_session
def ws_disconnect(channel, send_channel, channel_session, **kwargs):
Group("chat-%s" % channel_session['room']).discard(send_channel)
@channel_session
def ws_disconnect(message):
Group("chat-%s" % message.channel_session['room']).discard(message.reply_channel)
Note that we could add messages onto the ``chat-messages`` channel from anywhere;
inside a View, inside another model's ``post_save`` signal, inside a management

View File

@ -62,7 +62,7 @@ Contains the following keys:
* META: Same as ``request.META``
* path: Same as ``request.path``
* path_info: Same as ``request.path_info``
* send_channel: Channel name to send responses on
* reply_channel: Channel name to send responses on
WebSocket Receive
@ -81,7 +81,7 @@ WebSocket Client Close
Sent when the WebSocket is closed by either the client or the server.
Contains the same keys as WebSocket Connection, including send_channel,
Contains the same keys as WebSocket Connection, including reply_channel,
though nothing should be sent on it.