django-socket 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (47) hide show
  1. {django_socket-0.1.0/django_socket.egg-info → django_socket-0.2.0}/PKG-INFO +203 -3
  2. django_socket-0.1.0/PKG-INFO → django_socket-0.2.0/README.md +198 -33
  3. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/__init__.py +2 -1
  4. django_socket-0.2.0/django_socket/auth.py +10 -0
  5. django_socket-0.2.0/django_socket/authentication.py +278 -0
  6. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/checks.py +39 -0
  7. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/dispatch.py +22 -5
  8. django_socket-0.2.0/django_socket/middleware.py +166 -0
  9. django_socket-0.2.0/django_socket/ratelimit.py +93 -0
  10. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/routing.py +32 -4
  11. django_socket-0.2.0/django_socket/templatetags/__init__.py +0 -0
  12. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/websocket.py +15 -0
  13. django_socket-0.1.0/README.md → django_socket-0.2.0/django_socket.egg-info/PKG-INFO +233 -2
  14. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket.egg-info/SOURCES.txt +6 -0
  15. {django_socket-0.1.0 → django_socket-0.2.0}/pyproject.toml +8 -2
  16. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_auth.py +25 -4
  17. django_socket-0.2.0/tests/test_authentication.py +252 -0
  18. django_socket-0.2.0/tests/test_middleware_ratelimit.py +349 -0
  19. django_socket-0.1.0/django_socket/auth.py +0 -81
  20. {django_socket-0.1.0 → django_socket-0.2.0}/LICENSE +0 -0
  21. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/apps.py +0 -0
  22. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/asgi.py +0 -0
  23. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/events.py +0 -0
  24. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/groups.py +0 -0
  25. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/management/__init__.py +0 -0
  26. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/management/commands/__init__.py +0 -0
  27. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/management/commands/runserver.py +0 -0
  28. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/management/commands/ws.py +0 -0
  29. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/patch.py +0 -0
  30. /django_socket-0.1.0/django_socket/templatetags/__init__.py → /django_socket-0.2.0/django_socket/py.typed +0 -0
  31. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/static/django_socket/client.js +0 -0
  32. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/templatetags/django_socket.py +0 -0
  33. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket/testing.py +0 -0
  34. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket.egg-info/dependency_links.txt +0 -0
  35. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket.egg-info/requires.txt +0 -0
  36. {django_socket-0.1.0 → django_socket-0.2.0}/django_socket.egg-info/top_level.txt +0 -0
  37. {django_socket-0.1.0 → django_socket-0.2.0}/setup.cfg +0 -0
  38. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_asgi_app.py +0 -0
  39. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_backpressure.py +0 -0
  40. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_dispatch.py +0 -0
  41. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_groups.py +0 -0
  42. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_integracion.py +0 -0
  43. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_json.py +0 -0
  44. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_redis_layer.py +0 -0
  45. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_routing.py +0 -0
  46. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_testing.py +0 -0
  47. {django_socket-0.1.0 → django_socket-0.2.0}/tests/test_websocket.py +0 -0
@@ -1,8 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-socket
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: WebSockets for Django: one decorator, one async function, one line in INSTALLED_APPS.
5
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
6
10
  Classifier: Environment :: Web Environment
7
11
  Classifier: Framework :: Django
8
12
  Classifier: Framework :: Django :: 4.2
@@ -31,6 +35,7 @@ Dynamic: license-file
31
35
 
32
36
  # django_socket
33
37
 
38
+ [![PyPI](https://img.shields.io/pypi/v/django-socket)](https://pypi.org/project/django-socket/)
34
39
  [![tests](https://github.com/ramon3198/django-socket/actions/workflows/tests.yml/badge.svg)](https://github.com/ramon3198/django-socket/actions/workflows/tests.yml)
35
40
  [![python](https://img.shields.io/badge/python-3.10%20%E2%80%93%203.13-blue)](https://github.com/ramon3198/django-socket)
36
41
  [![django](https://img.shields.io/badge/django-4.2%20%E2%80%93%206.1-0C4B33)](https://github.com/ramon3198/django-socket)
@@ -70,11 +75,14 @@ the code after the loop, and Django's user is right where you'd expect it.
70
75
  [`Events`](#routing-by-message-type-with-events) ·
71
76
  [Authentication](#users-and-authentication) ·
72
77
  [JavaScript client](#javascript-client) ·
78
+ [Middleware](#middleware) ·
73
79
  [Testing](#testing-your-handlers)
74
80
 
75
81
  **Operations** ·
76
82
  [Production](#production) ·
83
+ [Behind a proxy](#behind-a-reverse-proxy) ·
77
84
  [Security](#security-origin-validation) ·
85
+ [Rate limiting](#rate-limiting) ·
78
86
  [Slow clients](#slow-clients) ·
79
87
  [Zombie connections](#zombie-connections) ·
80
88
  [Settings](#all-settings) ·
@@ -462,7 +470,67 @@ async def dashboard(sock):
462
470
  await sock.send_json({"hello": sock.user.username})
463
471
  ```
464
472
 
465
- With `@ws(..., auth=False)` you skip the session query on public endpoints.
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.
466
534
 
467
535
  ### The ORM works normally
468
536
 
@@ -548,6 +616,58 @@ Plus `sock.connected`, `sock.pending`, `sock.close()`, `sock.reconnect()`,
548
616
 
549
617
  ---
550
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
+
551
671
  ## Testing your handlers
552
672
 
553
673
  No server, no ports, milliseconds:
@@ -617,6 +737,49 @@ uvicorn myproject.asgi:application --host 0.0.0.0 --port 8000 --workers 4
617
737
  gunicorn myproject.asgi:application -k uvicorn.workers.UvicornWorker -w 4
618
738
  ```
619
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
+
620
783
  ### With more than one worker you need Redis
621
784
 
622
785
  The memory layer only knows the sockets in its own process, so a broadcast
@@ -682,6 +845,36 @@ DJANGO_SOCKET = {
682
845
 
683
846
  ---
684
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
+
685
878
  ## Slow clients
686
879
 
687
880
  A client that stops reading — bad network, a phone going to sleep, or someone
@@ -778,6 +971,12 @@ DJANGO_SOCKET = {
778
971
  "PATCH_ASGI": True, # False = declare ASGIApplication() yourself
779
972
  "SEND_QUEUE_MAX": 256, # messages queued per socket, for fan-out
780
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
781
980
  }
782
981
  ```
783
982
 
@@ -792,6 +991,7 @@ A misspelled key is caught by `manage.py check` (`django_socket.E001`).
792
991
  | `4400` | The client sent something that isn't valid JSON |
793
992
  | `4401` | `@login_required` and there's no session |
794
993
  | `4404` | No route matches that path |
994
+ | `4429` | Too fast: rate limit, or too many simultaneous connections |
795
995
  | `1013` | The client isn't consuming: outbox full, evicted from the group |
796
996
  | `1011` | Uncaught exception in the handler (it's in the log) |
797
997
  | HTTP 403 | Origin not allowed — the handshake never completes |
@@ -962,7 +1162,7 @@ pip install -e ".[dev]"
962
1162
  ### Tests
963
1163
 
964
1164
  ```bash
965
- pytest # 207 tests, ~7 s, nothing to start
1165
+ pytest # 261 tests, ~10 s, nothing to start
966
1166
  node --test tests/js/*.test.js # 35 tests for the JS client, no npm install
967
1167
  ```
968
1168
 
@@ -1,36 +1,6 @@
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
1
  # django_socket
33
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/django-socket)](https://pypi.org/project/django-socket/)
34
4
  [![tests](https://github.com/ramon3198/django-socket/actions/workflows/tests.yml/badge.svg)](https://github.com/ramon3198/django-socket/actions/workflows/tests.yml)
35
5
  [![python](https://img.shields.io/badge/python-3.10%20%E2%80%93%203.13-blue)](https://github.com/ramon3198/django-socket)
36
6
  [![django](https://img.shields.io/badge/django-4.2%20%E2%80%93%206.1-0C4B33)](https://github.com/ramon3198/django-socket)
@@ -70,11 +40,14 @@ the code after the loop, and Django's user is right where you'd expect it.
70
40
  [`Events`](#routing-by-message-type-with-events) ·
71
41
  [Authentication](#users-and-authentication) ·
72
42
  [JavaScript client](#javascript-client) ·
43
+ [Middleware](#middleware) ·
73
44
  [Testing](#testing-your-handlers)
74
45
 
75
46
  **Operations** ·
76
47
  [Production](#production) ·
48
+ [Behind a proxy](#behind-a-reverse-proxy) ·
77
49
  [Security](#security-origin-validation) ·
50
+ [Rate limiting](#rate-limiting) ·
78
51
  [Slow clients](#slow-clients) ·
79
52
  [Zombie connections](#zombie-connections) ·
80
53
  [Settings](#all-settings) ·
@@ -462,7 +435,67 @@ async def dashboard(sock):
462
435
  await sock.send_json({"hello": sock.user.username})
463
436
  ```
464
437
 
465
- With `@ws(..., auth=False)` you skip the session query on public endpoints.
438
+ ### Token auth, for SPAs and mobile apps
439
+
440
+ The session cookie only works when the browser sends it — same-site frontend.
441
+ A React app on another domain, or a mobile client, has no cookie. For those:
442
+
443
+ ```python
444
+ DJANGO_SOCKET = {
445
+ "AUTH": ["session", "token"], # tried in order, first match wins
446
+ "TOKEN_RESOLVER": "myapp.auth.from_jwt",
447
+ }
448
+ ```
449
+
450
+ ```python
451
+ async def from_jwt(token):
452
+ data = jwt.decode(token, KEY, algorithms=["HS256"])
453
+ return await User.objects.filter(pk=data["sub"]).afirst()
454
+ ```
455
+
456
+ The library moves the token; validating it is yours, because it could be a JWT,
457
+ a DRF token, or something of your own. If you already use
458
+ `rest_framework.authtoken` and set no resolver, that one is used.
459
+
460
+ **Where the token travels matters.** Browsers *cannot set headers* on a
461
+ WebSocket — the `new WebSocket(url, protocols)` API only lets you touch the URL
462
+ and `Sec-WebSocket-Protocol`. So:
463
+
464
+ | How | Who can use it | Note |
465
+ |---|---|---|
466
+ | `Sec-WebSocket-Protocol: bearer, <token>` | browsers | **recommended**: not in the URL, so not in your logs |
467
+ | `Authorization: Bearer <token>` | native clients | browsers can't |
468
+ | `?token=<token>` | everyone | **ends up in access logs**, yours and every proxy's |
469
+
470
+ ```js
471
+ new WebSocket("wss://api.example.com/feed/", ["bearer", token]);
472
+ ```
473
+
474
+ They're read in that order, so the safest one wins if several are present.
475
+
476
+ ### Per-route, and your own
477
+
478
+ ```python
479
+ @ws("feed/", auth="token") # only token here
480
+ @ws("panel/", auth=["session", "token"]) # either
481
+ @ws("public/", auth=False) # don't even try; sock.user is None
482
+ @ws("iot/", auth=my_authenticator) # async(sock) -> user | None
483
+ ```
484
+
485
+ An authenticator is just a function:
486
+
487
+ ```python
488
+ async def by_api_key(sock):
489
+ key = sock.query_params.get("k")
490
+ return await Client.objects.filter(api_key=key).afirst()
491
+ ```
492
+
493
+ An unknown authenticator name fails **at import time**, not on someone's first
494
+ connection.
495
+
496
+ > With auth active, `sock.user` is always a user object — `AnonymousUser` when
497
+ > nobody recognised the client. It's `None` only with `auth=False`, so
498
+ > `sock.user.is_authenticated` never needs a `None` check first.
466
499
 
467
500
  ### The ORM works normally
468
501
 
@@ -548,6 +581,58 @@ Plus `sock.connected`, `sock.pending`, `sock.close()`, `sock.reconnect()`,
548
581
 
549
582
  ---
550
583
 
584
+ ## Middleware
585
+
586
+ For what has to happen on every connection: tracing, metrics, reporting errors,
587
+ connection limits.
588
+
589
+ ```python
590
+ # myapp/ws.py
591
+ import time, logging
592
+
593
+ log = logging.getLogger("myapp.sockets")
594
+
595
+ async def measure(sock, next_):
596
+ start = time.monotonic()
597
+ try:
598
+ await next_()
599
+ finally:
600
+ log.info("%s took %.1fs", sock.path, time.monotonic() - start)
601
+ ```
602
+
603
+ ```python
604
+ DJANGO_SOCKET = {"MIDDLEWARE": ["myapp.ws.measure"]}
605
+ ```
606
+
607
+ Applied in order — the first in the list is the outermost, the same as Django's
608
+ `MIDDLEWARE`. It runs **after** authentication, so `sock.user` is already there.
609
+ To reject a connection, close and don't call `next_()`:
610
+
611
+ ```python
612
+ async def paid_only(sock, next_):
613
+ if not await is_paid(sock.user):
614
+ await sock.close(4403, "Plan required")
615
+ return
616
+ await next_()
617
+ ```
618
+
619
+ Two come included:
620
+
621
+ ```python
622
+ from django_socket.middleware import max_conexiones_por_usuario, registrar
623
+
624
+ DJANGO_SOCKET = {"MIDDLEWARE": [
625
+ max_conexiones_por_usuario(10), # closes the 11th with 4429
626
+ registrar(), # one log line per connection
627
+ ]}
628
+ ```
629
+
630
+ `max_conexiones_por_usuario` counts per process: with N workers the real ceiling
631
+ is `limit × N`. A global one would need counters in Redis, which is only worth
632
+ it if you actually need that precision.
633
+
634
+ ---
635
+
551
636
  ## Testing your handlers
552
637
 
553
638
  No server, no ports, milliseconds:
@@ -617,6 +702,49 @@ uvicorn myproject.asgi:application --host 0.0.0.0 --port 8000 --workers 4
617
702
  gunicorn myproject.asgi:application -k uvicorn.workers.UvicornWorker -w 4
618
703
  ```
619
704
 
705
+ ### Behind a reverse proxy
706
+
707
+ This is where most WebSocket deployments break, and the symptom — a 400 or a 502
708
+ — looks like a library bug when it isn't. The proxy has to be told to let the
709
+ protocol upgrade through:
710
+
711
+ ```nginx
712
+ location /ws/ {
713
+ proxy_pass http://app;
714
+ proxy_http_version 1.1;
715
+ proxy_set_header Upgrade $http_upgrade; # <- these two
716
+ proxy_set_header Connection "upgrade"; # <- are the whole thing
717
+ proxy_set_header Host $host;
718
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
719
+ proxy_read_timeout 3600s; # or idle sockets get cut at 60s
720
+ }
721
+ ```
722
+
723
+ Caddy needs nothing — it proxies WebSockets by default:
724
+
725
+ ```
726
+ example.com {
727
+ reverse_proxy app:8000
728
+ }
729
+ ```
730
+
731
+ **How to tell if your proxy is eating the upgrade.** A successful handshake is
732
+ an `HTTP/1.1 101 Switching Protocols`. Check it without a browser in the way:
733
+
734
+ ```bash
735
+ 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/
736
+ ```
737
+
738
+ - `101` — the proxy is fine, the problem is elsewhere.
739
+ - `400` — it reached Django, but the origin or the route was rejected. Check
740
+ `manage.py ws` and your `ALLOWED_HOSTS`.
741
+ - `200` or `502` — the proxy swallowed the upgrade. Those two headers are
742
+ missing.
743
+
744
+ If you're behind a load balancer, WebSockets are long-lived connections: raise
745
+ the idle timeout (ALB defaults to 60 s) or your sockets will drop every minute
746
+ for no visible reason.
747
+
620
748
  ### With more than one worker you need Redis
621
749
 
622
750
  The memory layer only knows the sockets in its own process, so a broadcast
@@ -682,6 +810,36 @@ DJANGO_SOCKET = {
682
810
 
683
811
  ---
684
812
 
813
+ ## Rate limiting
814
+
815
+ Before you expose a socket publicly, cap how fast a client can send:
816
+
817
+ ```python
818
+ @ws("chat/", rate_limit="60/m") # per route
819
+ DJANGO_SOCKET = {"RATE_LIMIT": "60/m"} # or for all of them
820
+ ```
821
+
822
+ Formats: `"10/s"`, `"60/m"`, `"100/5m"`, `"1000/h"`. Going over closes with
823
+ **4429**, and the reason says how long to wait.
824
+
825
+ It's a **token bucket, not a per-window counter**, and the difference is what
826
+ makes it usable: a counter rejects message 11 even when the previous 10 were 59
827
+ seconds ago. The bucket refills continuously, so it absorbs the normal burst of
828
+ someone typing fast and only cuts when the *sustained* rate goes over.
829
+
830
+ For streams that legitimately spike, raise the burst without raising the
831
+ sustained rate:
832
+
833
+ ```python
834
+ @ws("cursor/", rate_limit="30/s", burst=100)
835
+ ```
836
+
837
+ The limit is per socket. It is not a defence against a botnet opening thousands
838
+ of connections — for that you want `max_conexiones_por_usuario` and something in
839
+ front of the app.
840
+
841
+ ---
842
+
685
843
  ## Slow clients
686
844
 
687
845
  A client that stops reading — bad network, a phone going to sleep, or someone
@@ -778,6 +936,12 @@ DJANGO_SOCKET = {
778
936
  "PATCH_ASGI": True, # False = declare ASGIApplication() yourself
779
937
  "SEND_QUEUE_MAX": 256, # messages queued per socket, for fan-out
780
938
  "SEND_QUEUE_FULL": "close", # "close" (evict the slow one) | "drop_oldest"
939
+
940
+ "AUTH": ["session"], # authenticators, tried in order
941
+ "TOKEN_RESOLVER": None, # async(token) -> user | None
942
+ "MIDDLEWARE": [], # async(sock, next) wrappers
943
+ "RATE_LIMIT": None, # "60/m" for every route
944
+ "RATE_LIMIT_BURST": None, # allowance above the sustained rate
781
945
  }
782
946
  ```
783
947
 
@@ -792,6 +956,7 @@ A misspelled key is caught by `manage.py check` (`django_socket.E001`).
792
956
  | `4400` | The client sent something that isn't valid JSON |
793
957
  | `4401` | `@login_required` and there's no session |
794
958
  | `4404` | No route matches that path |
959
+ | `4429` | Too fast: rate limit, or too many simultaneous connections |
795
960
  | `1013` | The client isn't consuming: outbox full, evicted from the group |
796
961
  | `1011` | Uncaught exception in the handler (it's in the log) |
797
962
  | HTTP 403 | Origin not allowed — the handshake never completes |
@@ -962,7 +1127,7 @@ pip install -e ".[dev]"
962
1127
  ### Tests
963
1128
 
964
1129
  ```bash
965
- pytest # 207 tests, ~7 s, nothing to start
1130
+ pytest # 261 tests, ~10 s, nothing to start
966
1131
  node --test tests/js/*.test.js # 35 tests for the JS client, no npm install
967
1132
  ```
968
1133
 
@@ -13,7 +13,7 @@ Instalar es: `pip install django-socket` y añadirlo a INSTALLED_APPS. Ya esta.
13
13
  """
14
14
 
15
15
  from .asgi import ASGIApplication
16
- from .auth import login_required
16
+ from .authentication import extraer_token, login_required
17
17
  from .events import Events
18
18
  from .groups import (
19
19
  BaseLayer,
@@ -42,6 +42,7 @@ __all__ = [
42
42
  "broadcast",
43
43
  "broadcast_sync",
44
44
  "login_required",
45
+ "extraer_token",
45
46
  # Tipos, para anotar
46
47
  "WebSocket",
47
48
  "Message",
@@ -0,0 +1,10 @@
1
+ """Compatibilidad. La implementacion vive ahora en `authentication.py`.
2
+
3
+ Se mantiene porque `django_socket.auth` existia en 0.1.0 y alguien pudo
4
+ importarlo. Lo nuevo (autenticadores conectables, token) esta en
5
+ `django_socket.authentication`.
6
+ """
7
+
8
+ from .authentication import login_required, resolve # noqa: F401
9
+
10
+ __all__ = ["resolve", "login_required"]