django-socket 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_socket/__init__.py +61 -0
- django_socket/apps.py +32 -0
- django_socket/asgi.py +62 -0
- django_socket/auth.py +10 -0
- django_socket/authentication.py +278 -0
- django_socket/checks.py +114 -0
- django_socket/dispatch.py +208 -0
- django_socket/events.py +143 -0
- django_socket/groups.py +287 -0
- django_socket/management/__init__.py +0 -0
- django_socket/management/commands/__init__.py +0 -0
- django_socket/management/commands/runserver.py +81 -0
- django_socket/management/commands/ws.py +64 -0
- django_socket/middleware.py +166 -0
- django_socket/patch.py +54 -0
- django_socket/py.typed +0 -0
- django_socket/ratelimit.py +93 -0
- django_socket/routing.py +148 -0
- django_socket/static/django_socket/client.js +262 -0
- django_socket/templatetags/__init__.py +0 -0
- django_socket/templatetags/django_socket.py +28 -0
- django_socket/testing.py +330 -0
- django_socket/websocket.py +602 -0
- django_socket-0.2.0.dist-info/METADATA +1208 -0
- django_socket-0.2.0.dist-info/RECORD +28 -0
- django_socket-0.2.0.dist-info/WHEEL +5 -0
- django_socket-0.2.0.dist-info/licenses/LICENSE +21 -0
- django_socket-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-socket
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: WebSockets for Django: one decorator, one async function, one line in INSTALLED_APPS.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ramon3198/django-socket
|
|
7
|
+
Project-URL: Documentation, https://github.com/ramon3198/django-socket#readme
|
|
8
|
+
Project-URL: Changelog, https://github.com/ramon3198/django-socket/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/ramon3198/django-socket/issues
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
|
13
|
+
Classifier: Framework :: Django :: 5.0
|
|
14
|
+
Classifier: Framework :: Django :: 5.1
|
|
15
|
+
Classifier: Framework :: Django :: 5.2
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: django>=4.2
|
|
23
|
+
Requires-Dist: asgiref>=3.6
|
|
24
|
+
Provides-Extra: redis
|
|
25
|
+
Requires-Dist: redis>=5.0; extra == "redis"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: uvicorn[standard]>=0.27; extra == "dev"
|
|
28
|
+
Requires-Dist: websockets>=12; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
32
|
+
Requires-Dist: redis>=5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: coverage>=7; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# django_socket
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/django-socket/)
|
|
39
|
+
[](https://github.com/ramon3198/django-socket/actions/workflows/tests.yml)
|
|
40
|
+
[](https://github.com/ramon3198/django-socket)
|
|
41
|
+
[](https://github.com/ramon3198/django-socket)
|
|
42
|
+
[](https://github.com/ramon3198/django-socket/blob/main/LICENSE)
|
|
43
|
+
|
|
44
|
+
**English** · [Español](https://github.com/ramon3198/django-socket/blob/main/README.es.md)
|
|
45
|
+
|
|
46
|
+
**WebSockets for Django.** One decorator, one `async` function, and it runs.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# myapp/sockets.py
|
|
50
|
+
from django_socket import ws
|
|
51
|
+
|
|
52
|
+
@ws("chat/<str:room>/", group="room:{room}")
|
|
53
|
+
async def chat(sock, room):
|
|
54
|
+
async for msg in sock:
|
|
55
|
+
await sock.broadcast({"from": str(sock.user), "text": msg.text})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
That's the whole file. Connection state lives in local variables, disconnect is
|
|
59
|
+
the code after the loop, and Django's user is right where you'd expect it.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Contents
|
|
64
|
+
|
|
65
|
+
**Getting started** ·
|
|
66
|
+
[Install](#install) ·
|
|
67
|
+
[Your first socket in 5 minutes](#your-first-socket-in-5-minutes) ·
|
|
68
|
+
[Why you don't touch `asgi.py`](#why-you-dont-touch-asgipy)
|
|
69
|
+
|
|
70
|
+
**Guide** ·
|
|
71
|
+
[The `@ws` decorator](#the-ws-decorator) ·
|
|
72
|
+
[The `sock` object](#the-sock-object) ·
|
|
73
|
+
[Groups and broadcast](#groups-and-broadcast) ·
|
|
74
|
+
[JSON](#json) ·
|
|
75
|
+
[`Events`](#routing-by-message-type-with-events) ·
|
|
76
|
+
[Authentication](#users-and-authentication) ·
|
|
77
|
+
[JavaScript client](#javascript-client) ·
|
|
78
|
+
[Middleware](#middleware) ·
|
|
79
|
+
[Testing](#testing-your-handlers)
|
|
80
|
+
|
|
81
|
+
**Operations** ·
|
|
82
|
+
[Production](#production) ·
|
|
83
|
+
[Behind a proxy](#behind-a-reverse-proxy) ·
|
|
84
|
+
[Security](#security-origin-validation) ·
|
|
85
|
+
[Rate limiting](#rate-limiting) ·
|
|
86
|
+
[Slow clients](#slow-clients) ·
|
|
87
|
+
[Zombie connections](#zombie-connections) ·
|
|
88
|
+
[Settings](#all-settings) ·
|
|
89
|
+
[Close codes](#close-codes)
|
|
90
|
+
|
|
91
|
+
**Reference** ·
|
|
92
|
+
[How it works inside](#how-it-works-inside) ·
|
|
93
|
+
[Performance](#performance) ·
|
|
94
|
+
[Known limits](#known-limits) ·
|
|
95
|
+
[Development](#development)
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
# Getting started
|
|
100
|
+
|
|
101
|
+
## Install
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install django-socket
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# settings.py
|
|
109
|
+
INSTALLED_APPS = [
|
|
110
|
+
"django_socket",
|
|
111
|
+
...
|
|
112
|
+
]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**That's it. There is no third step.**
|
|
116
|
+
|
|
117
|
+
You don't touch `asgi.py`, you don't declare `ASGI_APPLICATION`, you don't set
|
|
118
|
+
up Redis, you don't switch servers. The `asgi.py` that `startproject` generated
|
|
119
|
+
serves WebSockets as-is, and `manage.py runserver` serves them on the same port
|
|
120
|
+
as HTTP.
|
|
121
|
+
|
|
122
|
+
Requires Python 3.10+ and Django 4.2+. For development you also need
|
|
123
|
+
`pip install "uvicorn[standard]"`.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Your first socket in 5 minutes
|
|
128
|
+
|
|
129
|
+
### 1. Write the handler
|
|
130
|
+
|
|
131
|
+
Create `<your_app>/sockets.py`. It is auto-discovered, the same way `admin.py`
|
|
132
|
+
is:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from django_socket import ws
|
|
136
|
+
|
|
137
|
+
@ws("echo/")
|
|
138
|
+
async def echo(sock):
|
|
139
|
+
async for msg in sock:
|
|
140
|
+
await sock.send(f"you said: {msg.text}")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 2. Check it registered
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
python manage.py ws
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
Rutas WebSocket
|
|
151
|
+
ws:///echo/ myapp.sockets.echo
|
|
152
|
+
|
|
153
|
+
Integracion
|
|
154
|
+
Capa de difusion memory
|
|
155
|
+
asgi.py no hace falta tocarlo (ASGIHandler ampliado)
|
|
156
|
+
Origenes permitidos ALLOWED_HOSTS=[] (DEBUG: localhost)
|
|
157
|
+
Origin ausente aceptado (clientes nativos)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Run this first whenever something doesn't work: it tells you which routes exist,
|
|
161
|
+
with which group, and how the integration is wired.
|
|
162
|
+
|
|
163
|
+
### 3. Start it and try it
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
python manage.py runserver
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
From the browser console, on any page of your site:
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
const s = new WebSocket("ws://localhost:8000/echo/");
|
|
173
|
+
s.onmessage = (e) => console.log(e.data);
|
|
174
|
+
s.onopen = () => s.send("hi");
|
|
175
|
+
// -> you said: hi
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 4. Now a chat room
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
@ws("chat/<str:room>/", group="room:{room}")
|
|
182
|
+
async def chat(sock, room):
|
|
183
|
+
who = sock.user.username if sock.user.is_authenticated else "anonymous"
|
|
184
|
+
|
|
185
|
+
await sock.broadcast({"kind": "join", "who": who}, exclude_self=True)
|
|
186
|
+
|
|
187
|
+
async for msg in sock:
|
|
188
|
+
await sock.broadcast({"kind": "message", "who": who, "text": msg.text})
|
|
189
|
+
|
|
190
|
+
# Reached when the client goes away.
|
|
191
|
+
await sock.broadcast({"kind": "leave", "who": who})
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Everything you need to know is in those lines:
|
|
195
|
+
|
|
196
|
+
- **`<str:room>`** is `django.urls.path` syntax. It reaches the handler already
|
|
197
|
+
converted (`<int:pk>` gives you a real `int`).
|
|
198
|
+
- **`group="room:{room}"`** is filled from that parameter. The socket joins on
|
|
199
|
+
connect and leaves on disconnect, without you calling anything.
|
|
200
|
+
- **`sock.broadcast(...)`** goes to that group by default.
|
|
201
|
+
- **`sock.user`** is the Django user, resolved from the session cookie.
|
|
202
|
+
- **The code after the `for`** runs when the client leaves. That's your
|
|
203
|
+
`disconnect`.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Why you don't touch `asgi.py`
|
|
208
|
+
|
|
209
|
+
Django has been ASGI since 3.0. All its handler does with a WebSocket is this:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
if scope["type"] != "http":
|
|
213
|
+
raise ValueError("Django can only handle ASGI/HTTP connections, not %s.")
|
|
214
|
+
# Django itself leaves a "FIXME: Allow to override this." right there.
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
It isn't that Django *can't* speak WebSocket — it refuses to look at that scope.
|
|
218
|
+
And since `django.setup()` runs every app's `ready()` **before** instantiating
|
|
219
|
+
the handler, our `AppConfig.ready()` gets there in time to widen that door. That
|
|
220
|
+
is why installing is just `INSTALLED_APPS`.
|
|
221
|
+
|
|
222
|
+
If you'd rather nothing of yours be touched, turn it off and declare it
|
|
223
|
+
yourself:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
DJANGO_SOCKET = {"PATCH_ASGI": False}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
# asgi.py
|
|
231
|
+
from django_socket import ASGIApplication
|
|
232
|
+
application = ASGIApplication()
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
# Guide
|
|
238
|
+
|
|
239
|
+
## The `@ws` decorator
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
@ws(route, *, group=None, auth=True, name=None)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
| | |
|
|
246
|
+
|---|---|
|
|
247
|
+
| `route` | `django.urls.path` syntax, with its converters |
|
|
248
|
+
| `group` | Group template; joins on connect, leaves on disconnect |
|
|
249
|
+
| `auth` | `False` skips session resolution (one query less) |
|
|
250
|
+
| `name` | Label for the `manage.py ws` listing |
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
@ws("game/<int:pk>/player/<slug:nick>/")
|
|
254
|
+
async def game(sock, pk, nick): # pk really is an int
|
|
255
|
+
...
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
If `group=` mentions a parameter the route doesn't have, it **fails at import
|
|
259
|
+
time** naming the culprit, not on the first connection.
|
|
260
|
+
|
|
261
|
+
The handler must be `async def`. Pass a regular function and the error explains
|
|
262
|
+
why, and what to use instead.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## The `sock` object
|
|
267
|
+
|
|
268
|
+
### Receiving
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
async for msg in sock: ... # ends when the client closes
|
|
272
|
+
msg.text msg.bytes msg.json()
|
|
273
|
+
if msg == "ping": ... # Message compares against str directly
|
|
274
|
+
|
|
275
|
+
await sock.receive_text() # also receive_json / receive_bytes
|
|
276
|
+
async for txt in sock.iter_text(): ... # and iter_json()
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Sending
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
await sock.send("hi") # str -> text frame
|
|
283
|
+
await sock.send(b"\x00") # bytes -> binary frame
|
|
284
|
+
await sock.send({"a": 1}) # rest -> JSON
|
|
285
|
+
await sock.send_json(obj) # also send_text / send_bytes
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Connection context
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
sock.user # User or AnonymousUser, from the session cookie
|
|
292
|
+
sock.session # Django's SessionStore
|
|
293
|
+
sock.path_params # {"room": "general"}
|
|
294
|
+
sock.query_params # {"token": "abc"} (query_lists for repeated keys)
|
|
295
|
+
sock.headers sock.cookies sock.client sock.subprotocols sock.scope
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Control
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
await sock.accept(subprotocol="graphql-ws") # only if you need to negotiate
|
|
302
|
+
await sock.close(4001, "room full") # the code reaches onclose
|
|
303
|
+
await sock.deny() # kill the handshake with a 403
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
You don't need to call `accept()`: the handshake completes on its own the first
|
|
307
|
+
time you send, receive or iterate.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Groups and broadcast
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
await sock.join("global") # the first one becomes the default target
|
|
315
|
+
await sock.leave("global")
|
|
316
|
+
|
|
317
|
+
await sock.broadcast(data) # to the default group
|
|
318
|
+
await sock.broadcast(data, to="other:group")
|
|
319
|
+
await sock.broadcast(data, exclude_self=True)
|
|
320
|
+
|
|
321
|
+
sock.group # default target for broadcast()
|
|
322
|
+
sock.groups # which groups you're a member of right now
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`sock.group` and `sock.groups` are deliberately different. `groups` is which
|
|
326
|
+
groups you're a **member** of (it empties on disconnect); `group` is where
|
|
327
|
+
`broadcast()` points by **default**, and it survives disconnection so this
|
|
328
|
+
pattern works:
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
async for msg in sock:
|
|
332
|
+
await sock.broadcast(msg.text)
|
|
333
|
+
await sock.broadcast("someone left") # <- you're no longer a member here
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### From outside a handler
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
from django_socket import broadcast, broadcast_sync
|
|
340
|
+
|
|
341
|
+
await broadcast({"notice": "maintenance"}, to="room:1") # async view or task
|
|
342
|
+
broadcast_sync({"notice": "maintenance"}, to="room:1") # sync view, signal, Celery
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
> With the default layer (`memory`), `broadcast_sync` only reaches the current
|
|
346
|
+
> process. With several workers you need Redis — see [Production](#production).
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## JSON
|
|
351
|
+
|
|
352
|
+
It's the common case, so it's frictionless both ways:
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
@ws("echo/")
|
|
356
|
+
async def echo(sock):
|
|
357
|
+
async for data in sock.iter_json(): # already parsed
|
|
358
|
+
await sock.send_json({"got": data}) # dict/list -> JSON
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Django types serialize themselves
|
|
362
|
+
|
|
363
|
+
`DjangoJSONEncoder` is used, so this just works:
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
await sock.send_json({
|
|
367
|
+
"when": timezone.now(), # "2026-08-26T19:43:30.251Z"
|
|
368
|
+
"price": Decimal("9.99"), # "9.99" (string: no precision loss)
|
|
369
|
+
"id": uuid4(), # "0d8f...-..."
|
|
370
|
+
"notice": _("Hello"), # lazy translation strings too
|
|
371
|
+
})
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
<details>
|
|
375
|
+
<summary><b>Why the date matters more than it looks</b></summary>
|
|
376
|
+
|
|
377
|
+
With `str()` you'd get `"2026-08-26 19:43:30.251057+00:00"`, which has two
|
|
378
|
+
problems:
|
|
379
|
+
|
|
380
|
+
1. **ISO-8601 is the only format the ECMAScript spec requires `Date` to
|
|
381
|
+
parse.** Anything else is each engine's fallback: V8 is lenient and accepts
|
|
382
|
+
it, others historically aren't.
|
|
383
|
+
2. **`str()` emits microseconds** (6 digits), which `Date` cannot represent. The
|
|
384
|
+
encoder truncates to milliseconds, which is what JS understands.
|
|
385
|
+
|
|
386
|
+
</details>
|
|
387
|
+
|
|
388
|
+
### An object it can't serialize fails loudly
|
|
389
|
+
|
|
390
|
+
```
|
|
391
|
+
TypeError: No se puede enviar un Usuario por el socket. Los tipos de Django
|
|
392
|
+
habituales (datetime, date, time, timedelta, Decimal, UUID, cadenas lazy) van
|
|
393
|
+
solos; el resto conviértelo tú: un modelo a dict, un QuerySet a lista.
|
|
394
|
+
Si prefieres el comportamiento antiguo: sock.send_json(dato, default=str).
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
That's on purpose. Staying quiet and shipping `"User object (3)"` to the browser
|
|
398
|
+
is worse: you find out in production.
|
|
399
|
+
|
|
400
|
+
### Invalid JSON is the client's fault, not yours
|
|
401
|
+
|
|
402
|
+
If a client sends garbage, the connection closes with **4400 "Invalid JSON"**
|
|
403
|
+
and leaves a `WARNING` in the log. Not a `1011 Internal error`, and not a
|
|
404
|
+
traceback that sends you hunting for a bug of yours that doesn't exist.
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
from django_socket import InvalidJSON # it's a ValueError
|
|
408
|
+
|
|
409
|
+
async for msg in sock:
|
|
410
|
+
try:
|
|
411
|
+
data = msg.json()
|
|
412
|
+
except InvalidJSON:
|
|
413
|
+
await sock.send_json({"error": "that wasn't JSON"})
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## Routing by message type with `Events`
|
|
419
|
+
|
|
420
|
+
Most apps send `{"type": "something", ...}` and end up with a long if/elif.
|
|
421
|
+
`Events` turns that into named functions:
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
from django_socket import Events, ws
|
|
425
|
+
|
|
426
|
+
board = Events()
|
|
427
|
+
|
|
428
|
+
@board.on("draw")
|
|
429
|
+
async def draw(sock, data):
|
|
430
|
+
await sock.broadcast({"type": "draw", **data}, exclude_self=True)
|
|
431
|
+
|
|
432
|
+
@board.on("clear")
|
|
433
|
+
async def clear(sock): # if you don't use the data, don't ask for it
|
|
434
|
+
await sock.broadcast({"type": "clear"})
|
|
435
|
+
|
|
436
|
+
@board.on("join", "leave") # several types at once
|
|
437
|
+
async def move(sock, data): ...
|
|
438
|
+
|
|
439
|
+
@board.on("*") # whatever didn't match anything else
|
|
440
|
+
async def rest(sock, data): ...
|
|
441
|
+
|
|
442
|
+
@ws("board/<str:room>/", group="board:{room}")
|
|
443
|
+
async def handler(sock, room):
|
|
444
|
+
await board.run(sock)
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
- The `type` field does **not** arrive inside `data`.
|
|
448
|
+
- `"*"` is a **fallback**, not a spy: it only runs when nobody else took the
|
|
449
|
+
message.
|
|
450
|
+
- An unhandled type is ignored and leaves a `WARNING` listing the registered
|
|
451
|
+
ones — it's almost always a typo.
|
|
452
|
+
- `Events(strict=True)` closes with 4400 instead.
|
|
453
|
+
- `Events(key="action")` changes the field name.
|
|
454
|
+
|
|
455
|
+
It's entirely optional: `async for msg in sock` is still there.
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
459
|
+
## Users and authentication
|
|
460
|
+
|
|
461
|
+
`sock.user` is always available, resolved from the handshake's session cookie.
|
|
462
|
+
Nothing to wrap in `asgi.py`.
|
|
463
|
+
|
|
464
|
+
```python
|
|
465
|
+
from django_socket import login_required, ws
|
|
466
|
+
|
|
467
|
+
@ws("dashboard/")
|
|
468
|
+
@login_required # closes with 4401 when there's no session
|
|
469
|
+
async def dashboard(sock):
|
|
470
|
+
await sock.send_json({"hello": sock.user.username})
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Token auth, for SPAs and mobile apps
|
|
474
|
+
|
|
475
|
+
The session cookie only works when the browser sends it — same-site frontend.
|
|
476
|
+
A React app on another domain, or a mobile client, has no cookie. For those:
|
|
477
|
+
|
|
478
|
+
```python
|
|
479
|
+
DJANGO_SOCKET = {
|
|
480
|
+
"AUTH": ["session", "token"], # tried in order, first match wins
|
|
481
|
+
"TOKEN_RESOLVER": "myapp.auth.from_jwt",
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
```python
|
|
486
|
+
async def from_jwt(token):
|
|
487
|
+
data = jwt.decode(token, KEY, algorithms=["HS256"])
|
|
488
|
+
return await User.objects.filter(pk=data["sub"]).afirst()
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
The library moves the token; validating it is yours, because it could be a JWT,
|
|
492
|
+
a DRF token, or something of your own. If you already use
|
|
493
|
+
`rest_framework.authtoken` and set no resolver, that one is used.
|
|
494
|
+
|
|
495
|
+
**Where the token travels matters.** Browsers *cannot set headers* on a
|
|
496
|
+
WebSocket — the `new WebSocket(url, protocols)` API only lets you touch the URL
|
|
497
|
+
and `Sec-WebSocket-Protocol`. So:
|
|
498
|
+
|
|
499
|
+
| How | Who can use it | Note |
|
|
500
|
+
|---|---|---|
|
|
501
|
+
| `Sec-WebSocket-Protocol: bearer, <token>` | browsers | **recommended**: not in the URL, so not in your logs |
|
|
502
|
+
| `Authorization: Bearer <token>` | native clients | browsers can't |
|
|
503
|
+
| `?token=<token>` | everyone | **ends up in access logs**, yours and every proxy's |
|
|
504
|
+
|
|
505
|
+
```js
|
|
506
|
+
new WebSocket("wss://api.example.com/feed/", ["bearer", token]);
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
They're read in that order, so the safest one wins if several are present.
|
|
510
|
+
|
|
511
|
+
### Per-route, and your own
|
|
512
|
+
|
|
513
|
+
```python
|
|
514
|
+
@ws("feed/", auth="token") # only token here
|
|
515
|
+
@ws("panel/", auth=["session", "token"]) # either
|
|
516
|
+
@ws("public/", auth=False) # don't even try; sock.user is None
|
|
517
|
+
@ws("iot/", auth=my_authenticator) # async(sock) -> user | None
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
An authenticator is just a function:
|
|
521
|
+
|
|
522
|
+
```python
|
|
523
|
+
async def by_api_key(sock):
|
|
524
|
+
key = sock.query_params.get("k")
|
|
525
|
+
return await Client.objects.filter(api_key=key).afirst()
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
An unknown authenticator name fails **at import time**, not on someone's first
|
|
529
|
+
connection.
|
|
530
|
+
|
|
531
|
+
> With auth active, `sock.user` is always a user object — `AnonymousUser` when
|
|
532
|
+
> nobody recognised the client. It's `None` only with `auth=False`, so
|
|
533
|
+
> `sock.user.is_authenticated` never needs a `None` check first.
|
|
534
|
+
|
|
535
|
+
### The ORM works normally
|
|
536
|
+
|
|
537
|
+
```python
|
|
538
|
+
@ws("users/")
|
|
539
|
+
async def users(sock):
|
|
540
|
+
total = await User.objects.acount() # async API
|
|
541
|
+
names = [u.username async for u in User.objects.all()]
|
|
542
|
+
x = await sync_to_async(lambda: User.objects.first())() # sync ORM
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
The handler runs inside a `ThreadSensitiveContext`, so
|
|
546
|
+
`sync_to_async(thread_sensitive=True)` — the default — shares a thread just like
|
|
547
|
+
it does in a view.
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## JavaScript client
|
|
552
|
+
|
|
553
|
+
Reconnecting properly is one of those things everyone rewrites and almost
|
|
554
|
+
nobody gets right. It ships included:
|
|
555
|
+
|
|
556
|
+
```html
|
|
557
|
+
{% load django_socket %}
|
|
558
|
+
{% ws_client %}
|
|
559
|
+
|
|
560
|
+
<script>
|
|
561
|
+
const sock = djangoSocket("/chat/general/");
|
|
562
|
+
|
|
563
|
+
sock.on("message", (d) => render(d.text));
|
|
564
|
+
sock.on("join", (d) => notify(`${d.who} joined`));
|
|
565
|
+
sock.on("*", (d) => console.log("no handler:", d));
|
|
566
|
+
|
|
567
|
+
sock.send({type: "message", text: "hi"}); // object -> JSON
|
|
568
|
+
</script>
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`ws://` or `wss://` depending on the page protocol, JSON both ways, and routing
|
|
572
|
+
by `type` just like `Events` on the Python side.
|
|
573
|
+
|
|
574
|
+
### What sets it apart
|
|
575
|
+
|
|
576
|
+
**It does not reconnect when the server closed on purpose.** A 4401 (login
|
|
577
|
+
required) or a 4404 (no such route) don't get fixed by retrying: reconnecting
|
|
578
|
+
there is an infinite loop hammering your server. 1000, 1008 and the whole
|
|
579
|
+
4000–4999 range are treated as final; everything else (1006, 1011, network
|
|
580
|
+
drops) is retried.
|
|
581
|
+
|
|
582
|
+
**Exponential backoff with jitter.** 0.5 s, 1 s, 2 s… up to 15 s, each wait
|
|
583
|
+
multiplied by a random factor. Without jitter, a thousand clients that drop
|
|
584
|
+
together all come back in the same millisecond and take the server down again.
|
|
585
|
+
|
|
586
|
+
**It queues what you write while offline** and flushes on reconnect. `send()`
|
|
587
|
+
returns `false` when it had to queue:
|
|
588
|
+
|
|
589
|
+
```js
|
|
590
|
+
if (!sock.send(text)) show("offline: will send on reconnect");
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
**With no network it doesn't burn attempts**: it waits for the browser's
|
|
594
|
+
`online` event. With the tab hidden it **keeps retrying**, on purpose — a chat
|
|
595
|
+
in a background tab that silently stops reconnecting is broken. If your case
|
|
596
|
+
tolerates falling behind, `{pauseWhenHidden: true}`.
|
|
597
|
+
|
|
598
|
+
### Options
|
|
599
|
+
|
|
600
|
+
```js
|
|
601
|
+
djangoSocket("/route/", {
|
|
602
|
+
key: "type", // the routing field
|
|
603
|
+
reconnect: true,
|
|
604
|
+
minDelay: 500, maxDelay: 15000, maxRetries: Infinity,
|
|
605
|
+
queue: true, maxQueue: 100,
|
|
606
|
+
pauseWhenHidden: false,
|
|
607
|
+
protocols: ["graphql-ws"],
|
|
608
|
+
shouldReconnect: (e) => e.code !== 4001, // your own rule
|
|
609
|
+
onOpen(isReconnect) {}, onClose(e, willRetry) {},
|
|
610
|
+
onRetry(attempt, delayMs) {}, onError(e) {}, onMessage(data) {},
|
|
611
|
+
});
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Plus `sock.connected`, `sock.pending`, `sock.close()`, `sock.reconnect()`,
|
|
615
|
+
`sock.on(type, fn)`, `sock.off(type, fn)`.
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## Middleware
|
|
620
|
+
|
|
621
|
+
For what has to happen on every connection: tracing, metrics, reporting errors,
|
|
622
|
+
connection limits.
|
|
623
|
+
|
|
624
|
+
```python
|
|
625
|
+
# myapp/ws.py
|
|
626
|
+
import time, logging
|
|
627
|
+
|
|
628
|
+
log = logging.getLogger("myapp.sockets")
|
|
629
|
+
|
|
630
|
+
async def measure(sock, next_):
|
|
631
|
+
start = time.monotonic()
|
|
632
|
+
try:
|
|
633
|
+
await next_()
|
|
634
|
+
finally:
|
|
635
|
+
log.info("%s took %.1fs", sock.path, time.monotonic() - start)
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
```python
|
|
639
|
+
DJANGO_SOCKET = {"MIDDLEWARE": ["myapp.ws.measure"]}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Applied in order — the first in the list is the outermost, the same as Django's
|
|
643
|
+
`MIDDLEWARE`. It runs **after** authentication, so `sock.user` is already there.
|
|
644
|
+
To reject a connection, close and don't call `next_()`:
|
|
645
|
+
|
|
646
|
+
```python
|
|
647
|
+
async def paid_only(sock, next_):
|
|
648
|
+
if not await is_paid(sock.user):
|
|
649
|
+
await sock.close(4403, "Plan required")
|
|
650
|
+
return
|
|
651
|
+
await next_()
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Two come included:
|
|
655
|
+
|
|
656
|
+
```python
|
|
657
|
+
from django_socket.middleware import max_conexiones_por_usuario, registrar
|
|
658
|
+
|
|
659
|
+
DJANGO_SOCKET = {"MIDDLEWARE": [
|
|
660
|
+
max_conexiones_por_usuario(10), # closes the 11th with 4429
|
|
661
|
+
registrar(), # one log line per connection
|
|
662
|
+
]}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
`max_conexiones_por_usuario` counts per process: with N workers the real ceiling
|
|
666
|
+
is `limit × N`. A global one would need counters in Redis, which is only worth
|
|
667
|
+
it if you actually need that precision.
|
|
668
|
+
|
|
669
|
+
---
|
|
670
|
+
|
|
671
|
+
## Testing your handlers
|
|
672
|
+
|
|
673
|
+
No server, no ports, milliseconds:
|
|
674
|
+
|
|
675
|
+
```python
|
|
676
|
+
from django_socket.testing import WebSocketClient
|
|
677
|
+
|
|
678
|
+
async def test_chat_fans_out():
|
|
679
|
+
async with WebSocketClient("/chat/general/") as a, \
|
|
680
|
+
WebSocketClient("/chat/general/") as b:
|
|
681
|
+
await b.send_json({"type": "message", "text": "hi"})
|
|
682
|
+
assert (await a.receive_json())["text"] == "hi"
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
It goes through the same path a real connection does — route, converters, origin
|
|
686
|
+
validation, session, groups — but speaking ASGI straight to the dispatcher.
|
|
687
|
+
|
|
688
|
+
```python
|
|
689
|
+
# Authenticated user without hand-rolling cookies
|
|
690
|
+
async with WebSocketClient("/dashboard/", user=my_user) as c:
|
|
691
|
+
assert (await c.receive_json())["who"] == "ramon"
|
|
692
|
+
|
|
693
|
+
# Rejections
|
|
694
|
+
async with WebSocketClient("/dashboard/") as c:
|
|
695
|
+
assert await c.wait_closed() == 4401
|
|
696
|
+
assert not c.connected
|
|
697
|
+
|
|
698
|
+
# Room isolation
|
|
699
|
+
async with WebSocketClient("/room/one/") as a, WebSocketClient("/room/two/") as b:
|
|
700
|
+
await b.send("private")
|
|
701
|
+
assert await a.receive_nothing()
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
| | |
|
|
705
|
+
|---|---|
|
|
706
|
+
| `send` / `send_json` / `send_text` / `send_bytes` | `str`→text, `bytes`→binary, rest→JSON |
|
|
707
|
+
| `receive` / `receive_json` / `receive_text` / `receive_bytes` | with timeout, fail fast |
|
|
708
|
+
| `receive_all()` | everything pending right now |
|
|
709
|
+
| `receive_nothing()` | `True` if nothing arrives — to assert isolation |
|
|
710
|
+
| `wait_closed()` | the close code |
|
|
711
|
+
| `connected` `accepted` `close_code` `close_reason` `subprotocol` | state |
|
|
712
|
+
|
|
713
|
+
`WebSocketClient(path, user=, headers=, cookies=, query=, subprotocols=, origin=)`
|
|
714
|
+
|
|
715
|
+
> `connected` and `accepted` are not the same: `accepted` says whether a
|
|
716
|
+
> `websocket.accept` arrived, and a coded rejection looks like accept + close on
|
|
717
|
+
> the wire (so the code reaches the browser). For "did it let me in?" use
|
|
718
|
+
> `connected`.
|
|
719
|
+
|
|
720
|
+
Every `receive` has a 1 s timeout: a test waiting for something that never
|
|
721
|
+
arrives fails in one second, with the path in the message, instead of hanging
|
|
722
|
+
the suite.
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
# Operations
|
|
727
|
+
|
|
728
|
+
## Production
|
|
729
|
+
|
|
730
|
+
Your project's `asgi.py` already works, unchanged:
|
|
731
|
+
|
|
732
|
+
```bash
|
|
733
|
+
uvicorn myproject.asgi:application --host 0.0.0.0 --port 8000 --workers 4
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
```bash
|
|
737
|
+
gunicorn myproject.asgi:application -k uvicorn.workers.UvicornWorker -w 4
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
### Behind a reverse proxy
|
|
741
|
+
|
|
742
|
+
This is where most WebSocket deployments break, and the symptom — a 400 or a 502
|
|
743
|
+
— looks like a library bug when it isn't. The proxy has to be told to let the
|
|
744
|
+
protocol upgrade through:
|
|
745
|
+
|
|
746
|
+
```nginx
|
|
747
|
+
location /ws/ {
|
|
748
|
+
proxy_pass http://app;
|
|
749
|
+
proxy_http_version 1.1;
|
|
750
|
+
proxy_set_header Upgrade $http_upgrade; # <- these two
|
|
751
|
+
proxy_set_header Connection "upgrade"; # <- are the whole thing
|
|
752
|
+
proxy_set_header Host $host;
|
|
753
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
754
|
+
proxy_read_timeout 3600s; # or idle sockets get cut at 60s
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Caddy needs nothing — it proxies WebSockets by default:
|
|
759
|
+
|
|
760
|
+
```
|
|
761
|
+
example.com {
|
|
762
|
+
reverse_proxy app:8000
|
|
763
|
+
}
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
**How to tell if your proxy is eating the upgrade.** A successful handshake is
|
|
767
|
+
an `HTTP/1.1 101 Switching Protocols`. Check it without a browser in the way:
|
|
768
|
+
|
|
769
|
+
```bash
|
|
770
|
+
curl -i -N -o - -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" -H "Origin: https://example.com" https://example.com/ws/echo/
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
- `101` — the proxy is fine, the problem is elsewhere.
|
|
774
|
+
- `400` — it reached Django, but the origin or the route was rejected. Check
|
|
775
|
+
`manage.py ws` and your `ALLOWED_HOSTS`.
|
|
776
|
+
- `200` or `502` — the proxy swallowed the upgrade. Those two headers are
|
|
777
|
+
missing.
|
|
778
|
+
|
|
779
|
+
If you're behind a load balancer, WebSockets are long-lived connections: raise
|
|
780
|
+
the idle timeout (ALB defaults to 60 s) or your sockets will drop every minute
|
|
781
|
+
for no visible reason.
|
|
782
|
+
|
|
783
|
+
### With more than one worker you need Redis
|
|
784
|
+
|
|
785
|
+
The memory layer only knows the sockets in its own process, so a broadcast
|
|
786
|
+
doesn't cross from one worker to another.
|
|
787
|
+
|
|
788
|
+
```python
|
|
789
|
+
DJANGO_SOCKET = {"LAYER": "redis", "REDIS_URL": "redis://localhost:6379/0"}
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
```bash
|
|
793
|
+
pip install "django-socket[redis]"
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
Each process publishes its broadcasts to a pub/sub channel and delivers to its
|
|
797
|
+
local members whatever it receives from the rest. Your handlers don't change:
|
|
798
|
+
`sock.broadcast(...)` is the same call.
|
|
799
|
+
|
|
800
|
+
Verified with two real workers against Redis 7.4: the message crosses both ways,
|
|
801
|
+
rooms stay isolated, and the publishing process doesn't deliver its own echo
|
|
802
|
+
twice.
|
|
803
|
+
|
|
804
|
+
### When Redis goes down
|
|
805
|
+
|
|
806
|
+
A Redis outage **does not take your users' connections down**:
|
|
807
|
+
|
|
808
|
+
- in-process delivery keeps working normally
|
|
809
|
+
- `broadcast()` doesn't raise; it logs an `ERROR` saying the group only got
|
|
810
|
+
local delivery
|
|
811
|
+
- the process retries subscribing with exponential backoff (0.5 s → 10 s)
|
|
812
|
+
- when Redis returns, it resubscribes on its own and fan-out resumes without a
|
|
813
|
+
restart
|
|
814
|
+
|
|
815
|
+
Anything published while Redis is down **is lost**: this is pub/sub, not a
|
|
816
|
+
queue. If you need delivery guarantees you have to persist the message yourself,
|
|
817
|
+
with the database or a real queue.
|
|
818
|
+
|
|
819
|
+
---
|
|
820
|
+
|
|
821
|
+
## Security: origin validation
|
|
822
|
+
|
|
823
|
+
**WebSockets are not subject to the same-origin policy.** Without validating the
|
|
824
|
+
`Origin` header, any website can open a socket against yours and the browser
|
|
825
|
+
will attach the victim's session cookies (*cross-site WebSocket hijacking*).
|
|
826
|
+
That's why the check isn't optional.
|
|
827
|
+
|
|
828
|
+
**It's on by default.** Validation runs against `ALLOWED_HOSTS` +
|
|
829
|
+
`CSRF_TRUSTED_ORIGINS`, and a foreign origin is rejected with a 403 **during the
|
|
830
|
+
handshake**, before anything is accepted.
|
|
831
|
+
|
|
832
|
+
A **missing** `Origin` is accepted, because browsers always send it: only native
|
|
833
|
+
clients omit it (a mobile app, a script). If your endpoint is browser-only, make
|
|
834
|
+
it strict:
|
|
835
|
+
|
|
836
|
+
```python
|
|
837
|
+
DJANGO_SOCKET = {
|
|
838
|
+
"REQUIRE_ORIGIN": True,
|
|
839
|
+
# or an explicit list, which ignores ALLOWED_HOSTS:
|
|
840
|
+
"ALLOWED_ORIGINS": ["https://myapp.com", "https://admin.myapp.com"],
|
|
841
|
+
}
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
`manage.py check` warns if you leave `"*"` with `DEBUG=False`.
|
|
845
|
+
|
|
846
|
+
---
|
|
847
|
+
|
|
848
|
+
## Rate limiting
|
|
849
|
+
|
|
850
|
+
Before you expose a socket publicly, cap how fast a client can send:
|
|
851
|
+
|
|
852
|
+
```python
|
|
853
|
+
@ws("chat/", rate_limit="60/m") # per route
|
|
854
|
+
DJANGO_SOCKET = {"RATE_LIMIT": "60/m"} # or for all of them
|
|
855
|
+
```
|
|
856
|
+
|
|
857
|
+
Formats: `"10/s"`, `"60/m"`, `"100/5m"`, `"1000/h"`. Going over closes with
|
|
858
|
+
**4429**, and the reason says how long to wait.
|
|
859
|
+
|
|
860
|
+
It's a **token bucket, not a per-window counter**, and the difference is what
|
|
861
|
+
makes it usable: a counter rejects message 11 even when the previous 10 were 59
|
|
862
|
+
seconds ago. The bucket refills continuously, so it absorbs the normal burst of
|
|
863
|
+
someone typing fast and only cuts when the *sustained* rate goes over.
|
|
864
|
+
|
|
865
|
+
For streams that legitimately spike, raise the burst without raising the
|
|
866
|
+
sustained rate:
|
|
867
|
+
|
|
868
|
+
```python
|
|
869
|
+
@ws("cursor/", rate_limit="30/s", burst=100)
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
The limit is per socket. It is not a defence against a botnet opening thousands
|
|
873
|
+
of connections — for that you want `max_conexiones_por_usuario` and something in
|
|
874
|
+
front of the app.
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
878
|
+
## Slow clients
|
|
879
|
+
|
|
880
|
+
A client that stops reading — bad network, a phone going to sleep, or someone
|
|
881
|
+
acting in bad faith — cannot drag the rest down with it.
|
|
882
|
+
|
|
883
|
+
<details>
|
|
884
|
+
<summary><b>The problem, measured</b></summary>
|
|
885
|
+
|
|
886
|
+
uvicorn does apply backpressure: it buffers about **24 MB** toward a client
|
|
887
|
+
that isn't reading, and past that `send()` stops returning. With delivery that
|
|
888
|
+
awaited every member, that meant `broadcast()` **never returned**: the
|
|
889
|
+
broadcasting handler hung, stopped reading its own socket, and never ran its
|
|
890
|
+
cleanup. One client on a bad network killed the whole room.
|
|
891
|
+
|
|
892
|
+
</details>
|
|
893
|
+
|
|
894
|
+
**How it's solved.** Each socket has a bounded outbox and a task that writes it.
|
|
895
|
+
`broadcast()` enqueues and returns; it waits for nobody. If someone's outbox
|
|
896
|
+
fills up, that client is too far behind and gets evicted with a **1013 (Try
|
|
897
|
+
Again Later)** instead of being allowed to slow the group down.
|
|
898
|
+
|
|
899
|
+
```python
|
|
900
|
+
DJANGO_SOCKET = {
|
|
901
|
+
"SEND_QUEUE_MAX": 256, # queued messages per socket
|
|
902
|
+
"SEND_QUEUE_FULL": "close", # "close" | "drop_oldest"
|
|
903
|
+
}
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
`drop_oldest` is for streams that tolerate gaps — cursor positions, telemetry, a
|
|
907
|
+
live counter: better to lose an old value than to evict the client. For a chat
|
|
908
|
+
you want `close`.
|
|
909
|
+
|
|
910
|
+
**How to size it.** The outbox is measured in messages, but what matters is
|
|
911
|
+
*time*: `outbox ÷ messages_per_second = seconds of tolerance`. Measured, one
|
|
912
|
+
process publishes ~1,500 broadcasts/s against Redis, so 256 is ~165 ms in the
|
|
913
|
+
worst case and tens of seconds at normal chat rates. Only stuck clients pay the
|
|
914
|
+
memory — one that keeps up has an empty outbox — so the cost is
|
|
915
|
+
`stuck × 256 × message_size`.
|
|
916
|
+
|
|
917
|
+
**`sock.send()` still waits, on purpose.** There, blocking is healthy: in a
|
|
918
|
+
one-to-one stream, if the client can't keep up with you, your handler slowing
|
|
919
|
+
down is the correct outcome. The outbox is only for fan-out, which is where
|
|
920
|
+
waiting does damage.
|
|
921
|
+
|
|
922
|
+
**In a test, `await sock.drain()`** waits for what's queued to go out, so you
|
|
923
|
+
can assert without sleeping blindly. In production you don't need it.
|
|
924
|
+
|
|
925
|
+
---
|
|
926
|
+
|
|
927
|
+
## Zombie connections
|
|
928
|
+
|
|
929
|
+
A laptop whose lid gets closed leaves a TCP connection "open" with nobody on the
|
|
930
|
+
other end: no `close`, no error. Without detection that socket stays in its
|
|
931
|
+
group forever and you broadcast into the void.
|
|
932
|
+
|
|
933
|
+
**You don't have to do anything, and this library doesn't need to add a
|
|
934
|
+
heartbeat.** uvicorn already sends protocol pings and closes what doesn't
|
|
935
|
+
answer. Measured with a client that completes the handshake and then goes
|
|
936
|
+
completely silent:
|
|
937
|
+
|
|
938
|
+
```
|
|
939
|
+
t= 10s in the group: 1
|
|
940
|
+
t= 20s in the group: 1
|
|
941
|
+
t= 30s in the group: 1
|
|
942
|
+
detected and cleaned up after 39s
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
39 seconds: `ws_ping_interval` (20 s) + `ws_ping_timeout` (20 s). The handler
|
|
946
|
+
exits through its `finally`, the socket leaves its groups, everything cleans up
|
|
947
|
+
by itself.
|
|
948
|
+
|
|
949
|
+
If you need it detected sooner:
|
|
950
|
+
|
|
951
|
+
```bash
|
|
952
|
+
uvicorn project.asgi:application --ws-ping-interval 5 --ws-ping-timeout 5
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
> This is uvicorn behaviour, not protocol behaviour. With a different ASGI
|
|
956
|
+
> server, check it.
|
|
957
|
+
|
|
958
|
+
---
|
|
959
|
+
|
|
960
|
+
## All settings
|
|
961
|
+
|
|
962
|
+
Everything is optional. These are the defaults:
|
|
963
|
+
|
|
964
|
+
```python
|
|
965
|
+
DJANGO_SOCKET = {
|
|
966
|
+
"LAYER": "memory", # "memory" | "redis" | callable -> BaseLayer
|
|
967
|
+
"REDIS_URL": "redis://localhost:6379/0",
|
|
968
|
+
"PREFIX": "djws", # Redis channel prefix
|
|
969
|
+
"ALLOWED_ORIGINS": None, # None = ALLOWED_HOSTS + CSRF_TRUSTED_ORIGINS
|
|
970
|
+
"REQUIRE_ORIGIN": False, # True = also reject requests without Origin
|
|
971
|
+
"PATCH_ASGI": True, # False = declare ASGIApplication() yourself
|
|
972
|
+
"SEND_QUEUE_MAX": 256, # messages queued per socket, for fan-out
|
|
973
|
+
"SEND_QUEUE_FULL": "close", # "close" (evict the slow one) | "drop_oldest"
|
|
974
|
+
|
|
975
|
+
"AUTH": ["session"], # authenticators, tried in order
|
|
976
|
+
"TOKEN_RESOLVER": None, # async(token) -> user | None
|
|
977
|
+
"MIDDLEWARE": [], # async(sock, next) wrappers
|
|
978
|
+
"RATE_LIMIT": None, # "60/m" for every route
|
|
979
|
+
"RATE_LIMIT_BURST": None, # allowance above the sustained rate
|
|
980
|
+
}
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
A misspelled key is caught by `manage.py check` (`django_socket.E001`).
|
|
984
|
+
|
|
985
|
+
---
|
|
986
|
+
|
|
987
|
+
## Close codes
|
|
988
|
+
|
|
989
|
+
| Code | Meaning |
|
|
990
|
+
|---|---|
|
|
991
|
+
| `4400` | The client sent something that isn't valid JSON |
|
|
992
|
+
| `4401` | `@login_required` and there's no session |
|
|
993
|
+
| `4404` | No route matches that path |
|
|
994
|
+
| `4429` | Too fast: rate limit, or too many simultaneous connections |
|
|
995
|
+
| `1013` | The client isn't consuming: outbox full, evicted from the group |
|
|
996
|
+
| `1011` | Uncaught exception in the handler (it's in the log) |
|
|
997
|
+
| HTTP 403 | Origin not allowed — the handshake never completes |
|
|
998
|
+
|
|
999
|
+
`sock.close(code, reason)` accepts the handshake before closing if it hadn't
|
|
1000
|
+
been accepted yet, precisely so your code reaches the client's `onclose`.
|
|
1001
|
+
Closing without accepting produces an HTTP 403 and the browser only sees a
|
|
1002
|
+
reasonless `1006`, which helps nobody debug.
|
|
1003
|
+
|
|
1004
|
+
---
|
|
1005
|
+
|
|
1006
|
+
# Reference
|
|
1007
|
+
|
|
1008
|
+
## How it works inside
|
|
1009
|
+
|
|
1010
|
+
Four decisions explain almost all of the library's behaviour.
|
|
1011
|
+
|
|
1012
|
+
### Connection state lives on a coroutine's stack
|
|
1013
|
+
|
|
1014
|
+
A WebSocket is a conversation with a beginning and an end. Modelling it as an
|
|
1015
|
+
`async` function that runs start to finish means state goes in local variables
|
|
1016
|
+
and the flow reads top to bottom:
|
|
1017
|
+
|
|
1018
|
+
```python
|
|
1019
|
+
@ws("game/<int:pk>/", group="game:{pk}")
|
|
1020
|
+
async def game(sock, pk):
|
|
1021
|
+
hand = deal() # state: a local variable
|
|
1022
|
+
turns = 0
|
|
1023
|
+
|
|
1024
|
+
async for move in sock.iter_json():
|
|
1025
|
+
turns += 1
|
|
1026
|
+
hand = apply(hand, move)
|
|
1027
|
+
await sock.broadcast({"turns": turns})
|
|
1028
|
+
|
|
1029
|
+
await record_abandon(pk, turns) # disconnect is just the end
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
There are no instance attributes to keep in sync across callbacks, and no object
|
|
1033
|
+
that outlives the connection. When the coroutine ends, there's nothing left to
|
|
1034
|
+
clean up beyond what its own `finally` already does.
|
|
1035
|
+
|
|
1036
|
+
### Widening Django's door instead of building another one
|
|
1037
|
+
|
|
1038
|
+
Django is already ASGI. Its handler simply refuses to look at the `websocket`
|
|
1039
|
+
scope, and it does so with a `FIXME` in the code. Since `django.setup()` runs
|
|
1040
|
+
every app's `ready()` before instantiating that handler, the `AppConfig` gets
|
|
1041
|
+
there in time to widen it.
|
|
1042
|
+
|
|
1043
|
+
That's where the most visible property comes from: **installing is adding one
|
|
1044
|
+
line to `INSTALLED_APPS`**. No `asgi.py` to rewrite, no nested wrappers, no
|
|
1045
|
+
second routing tree running parallel to Django's. And HTTP traffic never passes
|
|
1046
|
+
through here at all.
|
|
1047
|
+
|
|
1048
|
+
### Fan-out waits for nobody
|
|
1049
|
+
|
|
1050
|
+
Each socket has a bounded outbox and a task that writes it. `broadcast()`
|
|
1051
|
+
enqueues and returns.
|
|
1052
|
+
|
|
1053
|
+
The alternative — awaiting every member's delivery — looks simpler until you
|
|
1054
|
+
measure it: a client that stops reading makes `send()` stop returning, and with
|
|
1055
|
+
that the broadcasting handler hangs forever. It stops reading its own socket and
|
|
1056
|
+
never runs its cleanup. One phone with bad reception takes the whole room down.
|
|
1057
|
+
With an outbox, that client falls behind alone and is eventually evicted without
|
|
1058
|
+
dragging anyone with it.
|
|
1059
|
+
|
|
1060
|
+
`sock.send()` does wait, and that's deliberate: in a one-to-one stream,
|
|
1061
|
+
backpressure is healthy. Only fan-out needs to be decoupled.
|
|
1062
|
+
|
|
1063
|
+
### The fan-out layer is replaceable
|
|
1064
|
+
|
|
1065
|
+
`sock.broadcast(...)` is the same call whether you're on one process or twelve.
|
|
1066
|
+
The only thing that changes is which layer sits underneath: in-memory for one
|
|
1067
|
+
process, Redis pub/sub for several, or your own implementing `BaseLayer`.
|
|
1068
|
+
|
|
1069
|
+
Handlers never find out. There's no code to rewrite in order to scale, and no
|
|
1070
|
+
two paths to maintain depending on the deployment.
|
|
1071
|
+
|
|
1072
|
+
---
|
|
1073
|
+
|
|
1074
|
+
## Performance
|
|
1075
|
+
|
|
1076
|
+
Measured on a laptop, Redis 7.4 in Docker, everything against `localhost`.
|
|
1077
|
+
Reproducible:
|
|
1078
|
+
|
|
1079
|
+
```bash
|
|
1080
|
+
python bench_redis.py # layer cost
|
|
1081
|
+
python bench_carga.py # concurrent connections
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
### Layer cost
|
|
1085
|
+
|
|
1086
|
+
| | |
|
|
1087
|
+
|---|---|
|
|
1088
|
+
| broadcast, memory, 1 member | 0.003 ms |
|
|
1089
|
+
| broadcast, memory, 1000 members | 0.104 ms |
|
|
1090
|
+
| broadcast, Redis | ~0.65 ms → **~1,500/s per process** |
|
|
1091
|
+
| cross-process latency | median 0.59 ms · p95 0.80 ms · p99 0.90 ms |
|
|
1092
|
+
|
|
1093
|
+
With Redis the cost is nearly flat in the number of members: the round trip to
|
|
1094
|
+
Redis dominates, not local delivery. Those ~1,500 broadcasts/s are per process,
|
|
1095
|
+
so they scale with workers.
|
|
1096
|
+
|
|
1097
|
+
### Concurrent connections
|
|
1098
|
+
|
|
1099
|
+
One process, memory layer, a freshly started server per measurement:
|
|
1100
|
+
|
|
1101
|
+
| connections | memory | fan-out p50 | p95 | delivered |
|
|
1102
|
+
|---|---|---|---|---|
|
|
1103
|
+
| 1,000 | 125 MB · 122 KB/conn | 28 ms | 40 ms | 100 % |
|
|
1104
|
+
| 3,000 | 371 MB · 121 KB/conn | 157 ms | 200 ms | 100 % |
|
|
1105
|
+
| 6,000 | 741 MB · 121 KB/conn | 130 ms | 216 ms | 100 % |
|
|
1106
|
+
|
|
1107
|
+
At 6,000 connections: all of them open (1,600/s), and 120,000 broadcast messages
|
|
1108
|
+
arrive **without losing a single one**, at ~24,500 msg/s. With the Redis layer at
|
|
1109
|
+
1,000 connections, fan-out goes from 28 to 58 ms median — the Redis round trip —
|
|
1110
|
+
with the same memory and the same complete delivery.
|
|
1111
|
+
|
|
1112
|
+
**Memory is the binding constraint**: ~121 KB per connection, constant from
|
|
1113
|
+
1,000 to 6,000. That's roughly **8,000 connections per GB**, and that number
|
|
1114
|
+
comes from uvicorn and socket buffers, not from this library.
|
|
1115
|
+
|
|
1116
|
+
### What these figures do NOT say
|
|
1117
|
+
|
|
1118
|
+
- The benchmark client is **a single Python process** reading N websockets, so
|
|
1119
|
+
the latencies include its own scheduler. With real, distributed clients they'd
|
|
1120
|
+
be better.
|
|
1121
|
+
- Everything is `localhost`: no network latency, no loss, no TLS.
|
|
1122
|
+
- Not tested with large messages, a remote Redis, or over hours.
|
|
1123
|
+
|
|
1124
|
+
Re-run the benchmarks in your own environment before sizing anything.
|
|
1125
|
+
|
|
1126
|
+
---
|
|
1127
|
+
|
|
1128
|
+
## Known limits
|
|
1129
|
+
|
|
1130
|
+
- **Everything measured is `localhost`**: there are numbers up to 6,000
|
|
1131
|
+
connections and with Redis, but not against a real network, TLS, large
|
|
1132
|
+
messages or long sessions.
|
|
1133
|
+
- **Django 4.2 → 6.1 and Python 3.10 → 3.13 pass in CI**, including the
|
|
1134
|
+
alternative path for the `aget_user` that doesn't exist before Django 5.0: on
|
|
1135
|
+
4.2 it really executes, not forced with a monkeypatch. What CI doesn't cover
|
|
1136
|
+
is Windows and macOS — it only runs on Linux.
|
|
1137
|
+
- **The ~24 MB uvicorn buffers per connection** sit below this layer: the outbox
|
|
1138
|
+
bounds what piles up on top, not what's underneath. With 100 stuck clients at
|
|
1139
|
+
once that's 2.4 GB that can't be avoided from application level; that gets
|
|
1140
|
+
limited at the server.
|
|
1141
|
+
- **No background task runner.** For deferred work use Celery or whatever runner
|
|
1142
|
+
you already have, and notify over the socket with `broadcast_sync`.
|
|
1143
|
+
- **Synchronous handlers are not supported**, on purpose: `@ws` requires
|
|
1144
|
+
`async def` and says so when it fails. A plain `def` would occupy a thread per
|
|
1145
|
+
open connection.
|
|
1146
|
+
- **Route resolution is linear and first match wins**, same as Django. With
|
|
1147
|
+
hundreds of routes it would want indexing.
|
|
1148
|
+
- `broadcast_sync` with `LAYER="memory"` only reaches the current process — it's
|
|
1149
|
+
what you'd expect, but it's easy to trip over in development and not notice
|
|
1150
|
+
until production.
|
|
1151
|
+
|
|
1152
|
+
---
|
|
1153
|
+
|
|
1154
|
+
## Development
|
|
1155
|
+
|
|
1156
|
+
```bash
|
|
1157
|
+
git clone https://github.com/ramon3198/django-socket.git && cd django-socket
|
|
1158
|
+
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
1159
|
+
pip install -e ".[dev]"
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
### Tests
|
|
1163
|
+
|
|
1164
|
+
```bash
|
|
1165
|
+
pytest # 261 tests, ~10 s, nothing to start
|
|
1166
|
+
node --test tests/js/*.test.js # 35 tests for the JS client, no npm install
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
The Python suite needs no server: it uses the same `WebSocketClient` documented
|
|
1170
|
+
above, plus a fake transport for the lower-level bits. Coverage: **94 %**.
|
|
1171
|
+
|
|
1172
|
+
The JS client uses the test runner and fake timers that ship with Node (≥20), so
|
|
1173
|
+
there's nothing to install. It covers backoff with jitter, which codes are not
|
|
1174
|
+
retried, the offline queue, the no-network pause, and `type` routing.
|
|
1175
|
+
|
|
1176
|
+
### Against real services
|
|
1177
|
+
|
|
1178
|
+
```bash
|
|
1179
|
+
# A real Redis: the tests detect it and tell you which one they used
|
|
1180
|
+
docker run -d --rm -p 6379:6379 redis:7-alpine
|
|
1181
|
+
pytest tests/test_redis_layer.py -s
|
|
1182
|
+
|
|
1183
|
+
# Integration against a running server
|
|
1184
|
+
python manage.py runserver 8000
|
|
1185
|
+
python test_sockets.py 8000 # 24 tests
|
|
1186
|
+
|
|
1187
|
+
# Fan-out across separate processes (with memory, this one MUST hang)
|
|
1188
|
+
DJANGO_SOCKET_LAYER=redis python manage.py runserver 8091
|
|
1189
|
+
DJANGO_SOCKET_LAYER=redis python manage.py runserver 8092
|
|
1190
|
+
python test_multiproceso.py 8091 8092
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
### Demo
|
|
1194
|
+
|
|
1195
|
+
```bash
|
|
1196
|
+
python manage.py migrate
|
|
1197
|
+
python manage.py runserver
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
- `http://127.0.0.1:8000/sala/general/` — chat, open it in two tabs. To see the
|
|
1201
|
+
reconnect, stop the server and start it again.
|
|
1202
|
+
- `chat/sockets.py` — every example in one file.
|
|
1203
|
+
|
|
1204
|
+
---
|
|
1205
|
+
|
|
1206
|
+
## License
|
|
1207
|
+
|
|
1208
|
+
MIT.
|