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,602 @@
|
|
|
1
|
+
"""El objeto WebSocket que recibe cada handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from http.cookies import SimpleCookie
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import parse_qs
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WebSocketDisconnect(Exception):
|
|
13
|
+
"""El cliente cerro la conexion."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, code: int = 1000, reason: str = ""):
|
|
16
|
+
self.code = code
|
|
17
|
+
self.reason = reason
|
|
18
|
+
super().__init__(f"WebSocket cerrado (code={code})")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WebSocketClosed(Exception):
|
|
22
|
+
"""Se intento usar un socket que el servidor ya cerro."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RateLimited(Exception):
|
|
26
|
+
"""El cliente manda mas deprisa de lo permitido."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, espera: float, code: int):
|
|
29
|
+
self.espera = espera
|
|
30
|
+
self.code = code
|
|
31
|
+
super().__init__(f"Rate limit excedido; reintenta en {espera:.1f}s")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidJSON(ValueError):
|
|
35
|
+
"""
|
|
36
|
+
El cliente mando algo que no es JSON valido.
|
|
37
|
+
|
|
38
|
+
Es una ValueError, asi que `except ValueError` de toda la vida sigue
|
|
39
|
+
valiendo. Existe como tipo propio para que el dispatcher la distinga de un
|
|
40
|
+
fallo del servidor: culpa del cliente se cierra con 4400, no con un 1011
|
|
41
|
+
que ademas te llena el log de tracebacks que no son tuyos.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, crudo, motivo=""):
|
|
45
|
+
self.crudo = crudo
|
|
46
|
+
muestra = str(crudo)
|
|
47
|
+
if len(muestra) > 80:
|
|
48
|
+
muestra = muestra[:77] + "..."
|
|
49
|
+
super().__init__(f"JSON invalido del cliente: {muestra!r}"
|
|
50
|
+
+ (f" ({motivo})" if motivo else ""))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_encoder = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_encoder():
|
|
57
|
+
"""
|
|
58
|
+
DjangoJSONEncoder, cargado tarde para no tocar settings al importar.
|
|
59
|
+
|
|
60
|
+
Importa porque es el que sabe de tipos de Django, y sobre todo por las
|
|
61
|
+
fechas. `str(aware)` da "2026-08-26 19:43:30.251057+00:00"; el encoder da
|
|
62
|
+
"2026-08-26T19:43:30.251Z". Dos diferencias que si cuentan:
|
|
63
|
+
|
|
64
|
+
* ISO-8601 es el unico formato que la spec de ECMAScript obliga a `Date`
|
|
65
|
+
a parsear. Lo demas es un fallback de cada motor -- V8 es permisivo y lo
|
|
66
|
+
acepta, otros historicamente no.
|
|
67
|
+
* `str()` emite microsegundos (6 digitos) y `Date` solo entiende
|
|
68
|
+
milisegundos; el encoder trunca a 3, que es lo que JS puede representar.
|
|
69
|
+
|
|
70
|
+
Y de paso `Decimal` sale como cadena para no perder precision, y `UUID` y
|
|
71
|
+
las cadenas lazy de traduccion se serializan solas.
|
|
72
|
+
"""
|
|
73
|
+
global _encoder
|
|
74
|
+
if _encoder is None:
|
|
75
|
+
from django.core.serializers.json import DjangoJSONEncoder
|
|
76
|
+
|
|
77
|
+
class SocketJSONEncoder(DjangoJSONEncoder):
|
|
78
|
+
def default(self, o):
|
|
79
|
+
try:
|
|
80
|
+
return super().default(o)
|
|
81
|
+
except TypeError:
|
|
82
|
+
raise TypeError(
|
|
83
|
+
f"No se puede enviar un {type(o).__name__} por el "
|
|
84
|
+
f"socket. Los tipos de Django habituales (datetime, "
|
|
85
|
+
f"date, time, timedelta, Decimal, UUID, cadenas lazy) "
|
|
86
|
+
f"van solos; el resto conviertelo tu: un modelo a dict, "
|
|
87
|
+
f"un QuerySet a lista. Si prefieres el comportamiento "
|
|
88
|
+
f"antiguo: sock.send_json(dato, default=str)."
|
|
89
|
+
) from None
|
|
90
|
+
|
|
91
|
+
_encoder = SocketJSONEncoder
|
|
92
|
+
return _encoder
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Message:
|
|
96
|
+
"""Un mensaje entrante. Usa `.text`, `.bytes` o `.json()`."""
|
|
97
|
+
|
|
98
|
+
__slots__ = ("text", "bytes")
|
|
99
|
+
|
|
100
|
+
def __init__(self, text: str | None = None, data: bytes | None = None):
|
|
101
|
+
self.text = text
|
|
102
|
+
self.bytes = data
|
|
103
|
+
|
|
104
|
+
def json(self, **kwargs) -> Any:
|
|
105
|
+
"""Parsea el mensaje como JSON. Lanza `InvalidJSON` si no lo es."""
|
|
106
|
+
crudo = self.text
|
|
107
|
+
if crudo is None:
|
|
108
|
+
if self.bytes is None:
|
|
109
|
+
raise InvalidJSON("", "mensaje vacio")
|
|
110
|
+
try:
|
|
111
|
+
crudo = self.bytes.decode()
|
|
112
|
+
except UnicodeDecodeError as exc:
|
|
113
|
+
raise InvalidJSON(self.bytes, "no es UTF-8 valido") from exc
|
|
114
|
+
try:
|
|
115
|
+
return json.loads(crudo, **kwargs)
|
|
116
|
+
except ValueError as exc:
|
|
117
|
+
raise InvalidJSON(crudo, str(exc)) from None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def is_text(self) -> bool:
|
|
121
|
+
return self.text is not None
|
|
122
|
+
|
|
123
|
+
def __eq__(self, other) -> bool:
|
|
124
|
+
"""Permite `if msg == "ping"` sin sacar .text a mano."""
|
|
125
|
+
if isinstance(other, str):
|
|
126
|
+
return self.text == other
|
|
127
|
+
if isinstance(other, (bytes, bytearray)):
|
|
128
|
+
return self.bytes == bytes(other)
|
|
129
|
+
if isinstance(other, Message):
|
|
130
|
+
return self.text == other.text and self.bytes == other.bytes
|
|
131
|
+
return NotImplemented
|
|
132
|
+
|
|
133
|
+
def __hash__(self) -> int:
|
|
134
|
+
return hash((self.text, self.bytes))
|
|
135
|
+
|
|
136
|
+
def __str__(self) -> str:
|
|
137
|
+
return self.text if self.text is not None else repr(self.bytes)
|
|
138
|
+
|
|
139
|
+
def __repr__(self) -> str:
|
|
140
|
+
preview = str(self)
|
|
141
|
+
if len(preview) > 40:
|
|
142
|
+
preview = preview[:37] + "..."
|
|
143
|
+
return f"<Message {preview!r}>"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
CONNECTING, OPEN, CLOSED = "connecting", "open", "closed"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class WebSocket:
|
|
150
|
+
"""
|
|
151
|
+
Envoltura sobre el par (receive, send) de ASGI.
|
|
152
|
+
|
|
153
|
+
No hace falta llamar a `accept()`: el handshake se completa solo la primera
|
|
154
|
+
vez que envias, recibes o iteras. Llamalo a mano solo si necesitas fijar un
|
|
155
|
+
subprotocolo o headers, y llama a `close()` de entrada para rechazar.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(self, scope, receive, send, *, layer=None):
|
|
159
|
+
self.scope = scope
|
|
160
|
+
self._receive = receive
|
|
161
|
+
self._send = send
|
|
162
|
+
self._layer = layer
|
|
163
|
+
self._state = CONNECTING
|
|
164
|
+
self._groups: set[str] = set()
|
|
165
|
+
self.group: str | None = None # destino por defecto de broadcast()
|
|
166
|
+
self._rate = None # cubo de rate limit
|
|
167
|
+
self._outbox: asyncio.Queue | None = None # solo para difusion
|
|
168
|
+
self._outbox_full: str = "close"
|
|
169
|
+
self._writer: asyncio.Task | None = None
|
|
170
|
+
self.close_code: int | None = None
|
|
171
|
+
# Rellenados por el dispatcher antes de invocar el handler.
|
|
172
|
+
self.user = None
|
|
173
|
+
self.session = None
|
|
174
|
+
self.path_params: dict[str, Any] = {}
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------ datos
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def path(self) -> str:
|
|
180
|
+
return self.scope.get("path", "")
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def headers(self) -> dict[str, str]:
|
|
184
|
+
if not hasattr(self, "_headers"):
|
|
185
|
+
self._headers = {
|
|
186
|
+
k.decode("latin-1").lower(): v.decode("latin-1")
|
|
187
|
+
for k, v in self.scope.get("headers", [])
|
|
188
|
+
}
|
|
189
|
+
return self._headers
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def query_params(self) -> dict[str, str]:
|
|
193
|
+
"""Solo el primer valor de cada clave; usa `query_lists` si se repiten."""
|
|
194
|
+
if not hasattr(self, "_qp"):
|
|
195
|
+
raw = self.scope.get("query_string", b"").decode("utf-8", "replace")
|
|
196
|
+
self._ql = parse_qs(raw, keep_blank_values=True)
|
|
197
|
+
self._qp = {k: v[0] for k, v in self._ql.items()}
|
|
198
|
+
return self._qp
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def query_lists(self) -> dict[str, list[str]]:
|
|
202
|
+
self.query_params # fuerza el parseo
|
|
203
|
+
return self._ql
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def cookies(self) -> dict[str, str]:
|
|
207
|
+
if not hasattr(self, "_cookies"):
|
|
208
|
+
jar = SimpleCookie()
|
|
209
|
+
jar.load(self.headers.get("cookie", ""))
|
|
210
|
+
self._cookies = {k: v.value for k, v in jar.items()}
|
|
211
|
+
return self._cookies
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def subprotocols(self) -> list[str]:
|
|
215
|
+
return self.scope.get("subprotocols", [])
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def client(self) -> tuple[str, int] | None:
|
|
219
|
+
c = self.scope.get("client")
|
|
220
|
+
return tuple(c) if c else None
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def connected(self) -> bool:
|
|
224
|
+
return self._state == OPEN
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def groups(self) -> frozenset[str]:
|
|
228
|
+
"""De que grupos eres miembro ahora mismo (vacio tras desconectar).
|
|
229
|
+
|
|
230
|
+
Distinto de `sock.group`, que es el destino por defecto de broadcast()
|
|
231
|
+
y sigue apuntando al mismo sitio despues de la desconexion.
|
|
232
|
+
"""
|
|
233
|
+
return frozenset(self._groups)
|
|
234
|
+
|
|
235
|
+
def __repr__(self) -> str:
|
|
236
|
+
return f"<WebSocket {self.path} {self._state}>"
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------- handshake
|
|
239
|
+
|
|
240
|
+
async def accept(self, subprotocol: str | None = None, headers=None) -> None:
|
|
241
|
+
if self._state != CONNECTING:
|
|
242
|
+
return
|
|
243
|
+
msg = {"type": "websocket.accept", "subprotocol": subprotocol}
|
|
244
|
+
if headers:
|
|
245
|
+
msg["headers"] = [
|
|
246
|
+
(
|
|
247
|
+
k.encode() if isinstance(k, str) else k,
|
|
248
|
+
v.encode() if isinstance(v, str) else v,
|
|
249
|
+
)
|
|
250
|
+
for k, v in headers
|
|
251
|
+
]
|
|
252
|
+
await self._send(msg)
|
|
253
|
+
self._state = OPEN
|
|
254
|
+
|
|
255
|
+
async def _ensure_open(self) -> None:
|
|
256
|
+
if self._state == CONNECTING:
|
|
257
|
+
await self.accept()
|
|
258
|
+
elif self._state == CLOSED:
|
|
259
|
+
raise WebSocketClosed("El socket ya esta cerrado.")
|
|
260
|
+
|
|
261
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
262
|
+
"""
|
|
263
|
+
Cierra entregando `code` y `reason` al cliente.
|
|
264
|
+
|
|
265
|
+
Si el handshake aun no se completo lo completa primero: cerrar sin
|
|
266
|
+
aceptar hace que el servidor conteste un HTTP 403 y el navegador reciba
|
|
267
|
+
un `onclose` con code 1006 y sin motivo. Aceptando y cerrando acto
|
|
268
|
+
seguido, el JS del cliente recibe tu codigo tal cual y puede distinguir
|
|
269
|
+
"falta login" de "sala inexistente". Usa `deny()` si prefieres tumbar
|
|
270
|
+
el handshake.
|
|
271
|
+
"""
|
|
272
|
+
if self._state == CLOSED:
|
|
273
|
+
return
|
|
274
|
+
await self._vaciar_buzon()
|
|
275
|
+
await self._leave_all()
|
|
276
|
+
try:
|
|
277
|
+
if self._state == CONNECTING:
|
|
278
|
+
await self.accept()
|
|
279
|
+
await self._send(
|
|
280
|
+
{"type": "websocket.close", "code": code, "reason": reason}
|
|
281
|
+
)
|
|
282
|
+
except Exception:
|
|
283
|
+
pass # el cliente ya se fue
|
|
284
|
+
self._state = CLOSED
|
|
285
|
+
self.close_code = code
|
|
286
|
+
|
|
287
|
+
async def deny(self, code: int = 403) -> None:
|
|
288
|
+
"""
|
|
289
|
+
Rechaza el handshake sin aceptarlo: el cliente ve un HTTP 403 y nunca
|
|
290
|
+
llega a existir un WebSocket. Mas seguro cuando la conexion no deberia
|
|
291
|
+
haberse intentado siquiera (origen no permitido).
|
|
292
|
+
"""
|
|
293
|
+
if self._state != CONNECTING:
|
|
294
|
+
return await self.close(1008, "Denied")
|
|
295
|
+
try:
|
|
296
|
+
await self._send({"type": "websocket.close", "code": code})
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
self._state = CLOSED
|
|
300
|
+
self.close_code = code
|
|
301
|
+
|
|
302
|
+
# --------------------------------------------------------------- entrada
|
|
303
|
+
|
|
304
|
+
async def receive(self) -> Message:
|
|
305
|
+
"""
|
|
306
|
+
El siguiente mensaje. Lanza `WebSocketDisconnect` cuando ya no hay
|
|
307
|
+
conexion, venga de donde venga.
|
|
308
|
+
|
|
309
|
+
Ese "venga de donde venga" importa: el socket tambien puede cerrarse
|
|
310
|
+
sin que el cliente se vaya -- porque le echamos por no consumir, o
|
|
311
|
+
porque murio su escritor. Antes eso salia como `WebSocketClosed`, que
|
|
312
|
+
no es `WebSocketDisconnect`, asi que reventaba el `async for` del
|
|
313
|
+
handler en vez de terminarlo: traza en el log, cierre con 1011, y el
|
|
314
|
+
codigo de limpieza posterior sin ejecutar. Aparecio con 6000
|
|
315
|
+
conexiones, que es justo cuando empieza a haber expulsiones.
|
|
316
|
+
"""
|
|
317
|
+
if self._state == CLOSED:
|
|
318
|
+
raise WebSocketDisconnect(
|
|
319
|
+
self.close_code if self.close_code is not None else 1006,
|
|
320
|
+
"el socket ya estaba cerrado",
|
|
321
|
+
)
|
|
322
|
+
await self._ensure_open()
|
|
323
|
+
event = await self._receive()
|
|
324
|
+
if event["type"] == "websocket.receive" and self._rate is not None:
|
|
325
|
+
if not self._rate.consumir():
|
|
326
|
+
from .ratelimit import CLOSE_RATE_LIMIT
|
|
327
|
+
|
|
328
|
+
raise RateLimited(self._rate.espera, CLOSE_RATE_LIMIT)
|
|
329
|
+
if event["type"] == "websocket.disconnect":
|
|
330
|
+
self._state = CLOSED
|
|
331
|
+
self.close_code = event.get("code", 1005)
|
|
332
|
+
await self._leave_all()
|
|
333
|
+
raise WebSocketDisconnect(self.close_code, event.get("reason", ""))
|
|
334
|
+
return Message(text=event.get("text"), data=event.get("bytes"))
|
|
335
|
+
|
|
336
|
+
async def receive_text(self) -> str:
|
|
337
|
+
msg = await self.receive()
|
|
338
|
+
if msg.text is None:
|
|
339
|
+
raise TypeError("Se esperaba un frame de texto y llego uno binario.")
|
|
340
|
+
return msg.text
|
|
341
|
+
|
|
342
|
+
async def receive_bytes(self) -> bytes:
|
|
343
|
+
msg = await self.receive()
|
|
344
|
+
if msg.bytes is None:
|
|
345
|
+
raise TypeError("Se esperaba un frame binario y llego uno de texto.")
|
|
346
|
+
return msg.bytes
|
|
347
|
+
|
|
348
|
+
async def receive_json(self, **kwargs) -> Any:
|
|
349
|
+
return (await self.receive()).json(**kwargs)
|
|
350
|
+
|
|
351
|
+
def __aiter__(self):
|
|
352
|
+
return self
|
|
353
|
+
|
|
354
|
+
async def __anext__(self) -> Message:
|
|
355
|
+
try:
|
|
356
|
+
return await self.receive()
|
|
357
|
+
except WebSocketDisconnect:
|
|
358
|
+
raise StopAsyncIteration
|
|
359
|
+
|
|
360
|
+
async def iter_text(self):
|
|
361
|
+
async for msg in self:
|
|
362
|
+
if msg.text is not None:
|
|
363
|
+
yield msg.text
|
|
364
|
+
|
|
365
|
+
async def iter_json(self):
|
|
366
|
+
async for msg in self:
|
|
367
|
+
yield msg.json()
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------- salida
|
|
370
|
+
|
|
371
|
+
async def send(self, data: Any) -> None:
|
|
372
|
+
"""str -> texto, bytes -> binario, cualquier otra cosa -> JSON."""
|
|
373
|
+
if isinstance(data, str):
|
|
374
|
+
await self.send_text(data)
|
|
375
|
+
elif isinstance(data, (bytes, bytearray, memoryview)):
|
|
376
|
+
await self.send_bytes(bytes(data))
|
|
377
|
+
else:
|
|
378
|
+
await self.send_json(data)
|
|
379
|
+
|
|
380
|
+
async def send_text(self, text: str) -> None:
|
|
381
|
+
await self._ensure_open()
|
|
382
|
+
await self._send({"type": "websocket.send", "text": text})
|
|
383
|
+
|
|
384
|
+
async def send_bytes(self, data: bytes) -> None:
|
|
385
|
+
await self._ensure_open()
|
|
386
|
+
await self._send({"type": "websocket.send", "bytes": data})
|
|
387
|
+
|
|
388
|
+
async def send_json(self, data: Any, **kwargs) -> None:
|
|
389
|
+
"""
|
|
390
|
+
Serializa con el codificador de Django: `datetime` sale en ISO-8601,
|
|
391
|
+
`Decimal` como cadena, `UUID` y las cadenas lazy tambien.
|
|
392
|
+
|
|
393
|
+
Un objeto que no sepa serializar lanza un TypeError que dice cual es y
|
|
394
|
+
que hacer, en vez de mandar `"Usuario object (3)"` al navegador y que
|
|
395
|
+
te enteres en produccion.
|
|
396
|
+
"""
|
|
397
|
+
if "default" not in kwargs:
|
|
398
|
+
kwargs.setdefault("cls", _get_encoder())
|
|
399
|
+
await self.send_text(json.dumps(data, **kwargs))
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------- grupos
|
|
402
|
+
|
|
403
|
+
async def join(self, *groups: str) -> None:
|
|
404
|
+
"""El primer grupo al que entras pasa a ser el destino por defecto."""
|
|
405
|
+
for g in groups:
|
|
406
|
+
await self._layer.add(g, self)
|
|
407
|
+
self._groups.add(g)
|
|
408
|
+
if self.group is None:
|
|
409
|
+
self.group = g
|
|
410
|
+
|
|
411
|
+
async def leave(self, *groups: str) -> None:
|
|
412
|
+
for g in groups:
|
|
413
|
+
await self._layer.discard(g, self)
|
|
414
|
+
self._groups.discard(g)
|
|
415
|
+
if self.group == g:
|
|
416
|
+
self.group = next(iter(self._groups), None)
|
|
417
|
+
|
|
418
|
+
async def broadcast(
|
|
419
|
+
self, data: Any, *, to: str | None = None, exclude_self: bool = False
|
|
420
|
+
) -> None:
|
|
421
|
+
"""
|
|
422
|
+
Envia a todos los miembros de un grupo.
|
|
423
|
+
|
|
424
|
+
await sock.broadcast(data) # al grupo por defecto
|
|
425
|
+
await sock.broadcast(data, to="otro:grupo")
|
|
426
|
+
await sock.broadcast(data, exclude_self=True)
|
|
427
|
+
"""
|
|
428
|
+
target = to or self.group
|
|
429
|
+
if target is None:
|
|
430
|
+
raise ValueError(
|
|
431
|
+
"sock.broadcast(data) sin grupo por defecto. Entra en uno con "
|
|
432
|
+
"await sock.join('mi:grupo'), declaralo en la ruta con "
|
|
433
|
+
"@ws(..., group='mi:{param}') o indica el destino con "
|
|
434
|
+
"sock.broadcast(data, to='mi:grupo')."
|
|
435
|
+
)
|
|
436
|
+
await self._layer.send(target, data, exclude=self if exclude_self else None)
|
|
437
|
+
|
|
438
|
+
# -------------------------------------------------------------- fan-out
|
|
439
|
+
|
|
440
|
+
async def enqueue(self, data: Any) -> bool:
|
|
441
|
+
"""
|
|
442
|
+
Encola un mensaje de difusion. `False` si este cliente va tan atrasado
|
|
443
|
+
que hay que echarlo.
|
|
444
|
+
|
|
445
|
+
No espera a que se escriba, y eso es el punto. `sock.send()` si espera
|
|
446
|
+
--ahi el bloqueo es sano: si el cliente no puede seguirte, tu handler
|
|
447
|
+
va mas despacio--. Pero en un broadcast esperar es ruinoso: uno solo
|
|
448
|
+
que no lea deja colgado para siempre al que difunde, y con el su bucle
|
|
449
|
+
de lectura y su limpieza. Medido: uvicorn frena a los ~24 MB, y a
|
|
450
|
+
partir de ahi `send()` no vuelve.
|
|
451
|
+
"""
|
|
452
|
+
if self._state == CLOSED:
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
if self._outbox is None:
|
|
456
|
+
maximo, self._outbox_full = _config_outbox()
|
|
457
|
+
self._outbox = asyncio.Queue(maxsize=maximo)
|
|
458
|
+
if self._writer is None or self._writer.done():
|
|
459
|
+
self._writer = asyncio.create_task(self._drenar())
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
self._outbox.put_nowait(data)
|
|
463
|
+
return True
|
|
464
|
+
except asyncio.QueueFull:
|
|
465
|
+
if self._outbox_full == "drop_oldest":
|
|
466
|
+
# Para flujos que toleran huecos (posiciones, telemetria):
|
|
467
|
+
# mas vale perder lo viejo que echar al cliente.
|
|
468
|
+
try:
|
|
469
|
+
self._outbox.get_nowait()
|
|
470
|
+
except asyncio.QueueEmpty:
|
|
471
|
+
pass
|
|
472
|
+
self._outbox.put_nowait(data)
|
|
473
|
+
return True
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
async def _drenar(self) -> None:
|
|
477
|
+
"""Escribe el buzon en orden. Si el socket muere, se sale de los grupos."""
|
|
478
|
+
try:
|
|
479
|
+
while True:
|
|
480
|
+
data = await self._outbox.get()
|
|
481
|
+
try:
|
|
482
|
+
await self.send(data)
|
|
483
|
+
except Exception:
|
|
484
|
+
self._outbox.task_done()
|
|
485
|
+
self._state = CLOSED
|
|
486
|
+
await self._leave_all()
|
|
487
|
+
return
|
|
488
|
+
self._outbox.task_done()
|
|
489
|
+
except asyncio.CancelledError:
|
|
490
|
+
raise
|
|
491
|
+
|
|
492
|
+
def evict(self, code: int = 1013, reason: str = "Client too slow") -> None:
|
|
493
|
+
"""
|
|
494
|
+
Echa a un cliente que no consume, sin esperarle.
|
|
495
|
+
|
|
496
|
+
No se puede `await close()`: escribir hacia el esta bloqueado, que es
|
|
497
|
+
justo el motivo por el que lo estamos echando. Se cancela el escritor y
|
|
498
|
+
el cierre sale en segundo plano con plazo.
|
|
499
|
+
|
|
500
|
+
El handler de ese cliente sigue parado en `receive()` hasta que el
|
|
501
|
+
servidor ASGI le entregue el `websocket.disconnect`; con uvicorn eso
|
|
502
|
+
llega como mucho en ping_interval + ping_timeout (40 s por defecto).
|
|
503
|
+
Mientras tanto no consume nada y ya esta fuera de todos los grupos.
|
|
504
|
+
"""
|
|
505
|
+
if self._state == CLOSED:
|
|
506
|
+
return
|
|
507
|
+
self._state = CLOSED
|
|
508
|
+
self.close_code = code
|
|
509
|
+
if self._writer is not None:
|
|
510
|
+
self._writer.cancel()
|
|
511
|
+
asyncio.get_event_loop().create_task(self._cerrar_en_diferido(code, reason))
|
|
512
|
+
|
|
513
|
+
async def _cerrar_en_diferido(self, code: int, reason: str) -> None:
|
|
514
|
+
try:
|
|
515
|
+
await asyncio.wait_for(
|
|
516
|
+
self._send({"type": "websocket.close", "code": code, "reason": reason}),
|
|
517
|
+
timeout=5,
|
|
518
|
+
)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
async def drain(self, timeout: float = 1.0) -> None:
|
|
523
|
+
"""
|
|
524
|
+
Espera a que salga todo lo encolado para difusion.
|
|
525
|
+
|
|
526
|
+
En produccion no hace falta: el escritor va solo y `broadcast` no
|
|
527
|
+
espera a nadie a proposito. En un test si, para afirmar sobre lo que ya
|
|
528
|
+
llego en vez de dormir a ciegas y cruzar los dedos.
|
|
529
|
+
"""
|
|
530
|
+
if self._outbox is None:
|
|
531
|
+
return
|
|
532
|
+
try:
|
|
533
|
+
await asyncio.wait_for(self._outbox.join(), timeout=timeout)
|
|
534
|
+
except asyncio.TimeoutError:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
async def _vaciar_buzon(self, plazo: float = 1.0) -> None:
|
|
538
|
+
"""Da al escritor una ultima oportunidad antes de cerrar."""
|
|
539
|
+
if self._outbox is None or self._writer is None or self._writer.done():
|
|
540
|
+
return
|
|
541
|
+
try:
|
|
542
|
+
await asyncio.wait_for(self._outbox.join(), timeout=plazo)
|
|
543
|
+
except Exception:
|
|
544
|
+
pass
|
|
545
|
+
|
|
546
|
+
def _parar_escritor(self) -> None:
|
|
547
|
+
"""
|
|
548
|
+
Cancela la tarea escritora. Idempotente.
|
|
549
|
+
|
|
550
|
+
Va en la limpieza comun y no solo en `close()`, porque el camino
|
|
551
|
+
habitual es el otro: el cliente se desconecta, `receive()` marca el
|
|
552
|
+
socket cerrado, y entonces `close()` sale de vuelta sin llegar a
|
|
553
|
+
cancelar nada. Era una tarea huerfana por cada conexion que terminaba.
|
|
554
|
+
"""
|
|
555
|
+
if self._writer is None or self._writer.done():
|
|
556
|
+
return
|
|
557
|
+
# No te canceles a ti mismo: _drenar() tambien pasa por aqui al fallar.
|
|
558
|
+
try:
|
|
559
|
+
if asyncio.current_task() is self._writer:
|
|
560
|
+
return
|
|
561
|
+
except RuntimeError:
|
|
562
|
+
pass
|
|
563
|
+
self._writer.cancel()
|
|
564
|
+
|
|
565
|
+
async def _leave_all(self) -> None:
|
|
566
|
+
"""
|
|
567
|
+
Saca el socket de sus grupos, pero NO borra `self.group`.
|
|
568
|
+
|
|
569
|
+
`groups` es de que grupos eres miembro; `group` es a donde apunta
|
|
570
|
+
broadcast() por defecto. Al desconectar dejas de ser miembro, pero el
|
|
571
|
+
destino sigue siendo valido: si no, el patron mas comun que existe
|
|
572
|
+
|
|
573
|
+
async for msg in sock:
|
|
574
|
+
...
|
|
575
|
+
await sock.broadcast({"tipo": "sale"}) # <- ya sin grupo
|
|
576
|
+
|
|
577
|
+
se quedaria sin destino justo en la linea en la que hace falta.
|
|
578
|
+
"""
|
|
579
|
+
self._parar_escritor()
|
|
580
|
+
if self._groups and self._layer is not None:
|
|
581
|
+
for g in list(self._groups):
|
|
582
|
+
await self._layer.discard(g, self)
|
|
583
|
+
self._groups.clear()
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _config_outbox() -> tuple[int, str]:
|
|
587
|
+
"""
|
|
588
|
+
Tamaño del buzon de difusion y que hacer cuando se llena.
|
|
589
|
+
|
|
590
|
+
El 256 no es un numero redondo elegido a ojo: medido, un proceso publica
|
|
591
|
+
~1.500 broadcast/s contra Redis, asi que un buzon de 64 se llenaria en
|
|
592
|
+
42 ms a maxima tasa -- menos de lo que dura un hipo de red movil o una
|
|
593
|
+
pausa de GC, y echarias a clientes sanos. Con 256 el margen sube a ~170 ms
|
|
594
|
+
en el peor caso, y a decenas de segundos al ritmo de un chat normal.
|
|
595
|
+
|
|
596
|
+
El coste en memoria solo lo pagan los clientes atascados: uno que consume
|
|
597
|
+
tiene el buzon a cero. Son `atascados x 256 x tamaño_del_mensaje`.
|
|
598
|
+
"""
|
|
599
|
+
from django.conf import settings
|
|
600
|
+
|
|
601
|
+
conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
|
|
602
|
+
return int(conf.get("SEND_QUEUE_MAX", 256)), conf.get("SEND_QUEUE_FULL", "close")
|