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