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.
@@ -0,0 +1,208 @@
1
+ """El nucleo: atiende los scopes 'websocket' y 'lifespan'.
2
+
3
+ Vive aparte de `asgi.py` porque hay dos caminos que llegan aqui: el parche
4
+ sobre `ASGIHandler` (modo cero-configuracion) y `ASGIApplication` explicito.
5
+ Los dos comparten este codigo.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from urllib.parse import urlparse
12
+
13
+ from asgiref.sync import ThreadSensitiveContext
14
+
15
+ from . import authentication
16
+ from . import middleware as mw
17
+ from . import groups, routing
18
+ from .websocket import InvalidJSON, RateLimited, WebSocket, WebSocketDisconnect
19
+
20
+ logger = logging.getLogger("django_socket")
21
+
22
+ # Codigos de cierre propios (rango privado 4000-4999).
23
+ CLOSE_NO_ROUTE = 4404
24
+ CLOSE_BAD_DATA = 4400 # el cliente mando algo que no se puede parsear
25
+ CLOSE_SERVER_ERROR = 1011
26
+
27
+ _layer_started = False
28
+
29
+
30
+ def _settings():
31
+ from django.conf import settings
32
+
33
+ return getattr(settings, "DJANGO_SOCKET", {}) or {}
34
+
35
+
36
+ async def _start_layer() -> None:
37
+ global _layer_started
38
+ if not _layer_started:
39
+ await groups.get_layer().startup()
40
+ _layer_started = True
41
+
42
+
43
+ async def _stop_layer() -> None:
44
+ global _layer_started
45
+ if _layer_started:
46
+ await groups.get_layer().shutdown()
47
+ _layer_started = False
48
+
49
+
50
+ # ---------------------------------------------------------------- lifespan
51
+
52
+
53
+ async def handle_lifespan(scope, receive, send) -> None:
54
+ while True:
55
+ message = await receive()
56
+ if message["type"] == "lifespan.startup":
57
+ try:
58
+ await _start_layer()
59
+ except Exception as exc:
60
+ logger.exception("django_socket: fallo en el arranque")
61
+ await send({"type": "lifespan.startup.failed", "message": str(exc)})
62
+ return
63
+ await send({"type": "lifespan.startup.complete"})
64
+ elif message["type"] == "lifespan.shutdown":
65
+ try:
66
+ await _stop_layer()
67
+ finally:
68
+ await send({"type": "lifespan.shutdown.complete"})
69
+ return
70
+
71
+
72
+ # --------------------------------------------------------------- websocket
73
+
74
+
75
+ async def handle_websocket(scope, receive, send) -> None:
76
+ # El primer evento del protocolo siempre es websocket.connect.
77
+ event = await receive()
78
+ if event["type"] != "websocket.connect":
79
+ return
80
+
81
+ await _start_layer() # por si el servidor no soporta lifespan
82
+ sock = WebSocket(scope, receive, send, layer=groups.get_layer())
83
+
84
+ if not origin_allowed(sock):
85
+ logger.warning(
86
+ "django_socket: origen rechazado %r para %s",
87
+ sock.headers.get("origin"),
88
+ sock.path,
89
+ )
90
+ # En seco: un origen ajeno no debe tener un socket abierto ni un instante.
91
+ await sock.deny()
92
+ return
93
+
94
+ match = routing.resolve(sock.path)
95
+ if match is None:
96
+ logger.warning(
97
+ "django_socket: ninguna ruta casa con %s. Registradas: %s",
98
+ sock.path,
99
+ ", ".join(f"/{r.route}" for r in routing.get_routes()) or "(ninguna)",
100
+ )
101
+ await sock.close(CLOSE_NO_ROUTE, "No route")
102
+ return
103
+
104
+ route, kwargs = match
105
+ sock.path_params = kwargs
106
+
107
+ async with ThreadSensitiveContext():
108
+ if route.auth is not False:
109
+ await authentication.resolve(sock, route.auth)
110
+
111
+ from . import ratelimit
112
+
113
+ sock._rate = ratelimit.crear(route.rate_limit, route.burst)
114
+
115
+ async def ejecutar():
116
+ if route.group:
117
+ # group="room:{room}" se rellena con los parametros de la ruta.
118
+ await sock.join(route.group.format(**kwargs))
119
+ await route.handler(sock, **kwargs)
120
+
121
+ try:
122
+ # El middleware va por fuera del handler pero por dentro de este
123
+ # try, para que un fallo suyo se trate igual que uno del handler.
124
+ await mw.aplicar(sock, ejecutar)
125
+ except WebSocketDisconnect:
126
+ pass # el cliente se fue; salida normal
127
+ except RateLimited as exc:
128
+ logger.warning(
129
+ "django_socket: %s va demasiado rapido en %s (%s)",
130
+ sock.client, sock.path, exc,
131
+ )
132
+ await sock.close(exc.code, f"Rate limit; retry in {exc.espera:.0f}s")
133
+ return
134
+ except InvalidJSON as exc:
135
+ # Culpa del cliente, no del servidor: un aviso y un codigo que lo
136
+ # diga. Nada de traceback ni de 1011, que harian pensar que el bug
137
+ # es tuyo cada vez que alguien mande basura por el socket.
138
+ logger.warning(
139
+ "django_socket: %s en %s (cliente %s)", exc, sock.path, sock.client
140
+ )
141
+ await sock.close(CLOSE_BAD_DATA, "Invalid JSON")
142
+ return
143
+ except Exception:
144
+ logger.exception("django_socket: excepcion en el handler de %s", sock.path)
145
+ await sock.close(CLOSE_SERVER_ERROR, "Internal error")
146
+ return
147
+ await sock.close()
148
+
149
+
150
+ # ------------------------------------------------------------------ origen
151
+
152
+
153
+ def origin_allowed(sock) -> bool:
154
+ """
155
+ Los WebSockets no estan sujetos a la politica de mismo origen: sin esta
156
+ comprobacion cualquier web podria abrir un socket autenticado contra la
157
+ tuya (cross-site WebSocket hijacking).
158
+
159
+ Un Origin ausente se acepta: los navegadores siempre lo mandan, asi que
160
+ solo lo omiten clientes nativos. Ponlo estricto con REQUIRE_ORIGIN.
161
+ """
162
+ from django.conf import settings
163
+
164
+ conf = _settings()
165
+ origin = sock.headers.get("origin")
166
+ if origin is None:
167
+ return not conf.get("REQUIRE_ORIGIN", False)
168
+
169
+ allowed = conf.get("ALLOWED_ORIGINS")
170
+ if allowed is not None:
171
+ if "*" in allowed:
172
+ return True
173
+ return origin in allowed or _host_of(origin) in allowed
174
+
175
+ # Por defecto: ALLOWED_HOSTS + CSRF_TRUSTED_ORIGINS, como hace Django.
176
+ host = _host_of(origin)
177
+ if not host:
178
+ return False
179
+
180
+ trusted = {
181
+ _host_of(o) for o in getattr(settings, "CSRF_TRUSTED_ORIGINS", []) or []
182
+ }
183
+ if host in trusted:
184
+ return True
185
+
186
+ hosts = list(getattr(settings, "ALLOWED_HOSTS", []) or [])
187
+ if settings.DEBUG and not hosts:
188
+ hosts = ["localhost", "127.0.0.1", "[::1]"]
189
+ return any(_host_matches(host, pattern) for pattern in hosts)
190
+
191
+
192
+ def _host_of(origin: str) -> str:
193
+ """'https://ejemplo.com:8000' -> 'ejemplo.com'."""
194
+ try:
195
+ return (urlparse(origin).hostname or "").lower()
196
+ except ValueError:
197
+ return ""
198
+
199
+
200
+ def _host_matches(host: str, pattern: str) -> bool:
201
+ pattern = pattern.lower()
202
+ if pattern == "*":
203
+ return True
204
+ if pattern.startswith("."): # ".ejemplo.com" cubre subdominios y el apex
205
+ return host == pattern[1:] or host.endswith(pattern)
206
+ if pattern.startswith("*."):
207
+ return host == pattern[2:] or host.endswith(pattern[1:])
208
+ return host == pattern
@@ -0,0 +1,143 @@
1
+ """Enrutado de mensajes JSON por tipo.
2
+
3
+ Casi toda app que habla JSON por el socket manda `{"type": "algo", ...}` y
4
+ acaba con un if/elif largo dentro del bucle. Esto lo convierte en funciones con
5
+ nombre, sin que dejes de ver el flujo:
6
+
7
+ from django_socket import Events, ws
8
+
9
+ chat = Events()
10
+
11
+ @chat.on("mensaje")
12
+ async def mensaje(sock, datos):
13
+ await sock.broadcast({"type": "mensaje", "texto": datos["texto"]})
14
+
15
+ @chat.on("escribiendo")
16
+ async def escribiendo(sock): # si no usas los datos, no los pidas
17
+ await sock.broadcast({"type": "escribiendo"}, exclude_self=True)
18
+
19
+ @ws("chat/<str:room>/", group="room:{room}")
20
+ async def handler(sock, room):
21
+ await chat.run(sock)
22
+
23
+ Es opcional del todo: `async for msg in sock` sigue estando ahi y no necesitas
24
+ saber que esto existe.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import inspect
30
+ import logging
31
+
32
+ from .websocket import InvalidJSON
33
+
34
+ logger = logging.getLogger("django_socket")
35
+
36
+ CUALQUIERA = "*"
37
+
38
+
39
+ class Events:
40
+ """
41
+ Despacha mensajes JSON segun un campo (por defecto `"type"`).
42
+
43
+ `key` -- el campo que decide el tipo. Usa `Events(key="action")` si tu
44
+ protocolo se llama de otra forma.
45
+ `strict` -- que hacer con un tipo que nadie maneja. Por defecto se ignora
46
+ (y se anota en el log); con `strict=True` se cierra con 4400.
47
+ """
48
+
49
+ def __init__(self, key: str = "type", strict: bool = False):
50
+ self.key = key
51
+ self.strict = strict
52
+ self._handlers: dict[str, tuple] = {}
53
+
54
+ def on(self, *tipos: str):
55
+ """
56
+ Registra un handler para uno o varios tipos.
57
+
58
+ @chat.on("mensaje")
59
+ @chat.on("entrar", "salir") # varios de golpe
60
+ @chat.on("*") # lo que no case con nada mas
61
+
62
+ El handler puede pedir los datos o no:
63
+
64
+ async def handler(sock, datos) -> datos = el mensaje sin el campo key
65
+ async def handler(sock) -> te basta con saber que llego
66
+ """
67
+ if not tipos:
68
+ raise TypeError("@on() necesita al menos un tipo: @on('mensaje')")
69
+
70
+ def decorator(fn):
71
+ if not inspect.iscoroutinefunction(fn):
72
+ raise TypeError(
73
+ f"@on espera 'async def', y {fn.__name__} es una funcion "
74
+ f"normal."
75
+ )
76
+ quiere_datos = _quiere_datos(fn)
77
+ for tipo in tipos:
78
+ if tipo in self._handlers:
79
+ anterior = self._handlers[tipo][0].__name__
80
+ raise ValueError(
81
+ f"El tipo {tipo!r} ya lo maneja {anterior}."
82
+ )
83
+ self._handlers[tipo] = (fn, quiere_datos)
84
+ return fn
85
+
86
+ return decorator
87
+
88
+ @property
89
+ def tipos(self) -> list[str]:
90
+ return sorted(self._handlers)
91
+
92
+ async def run(self, sock) -> None:
93
+ """Consume mensajes hasta que el cliente cierra."""
94
+ async for msg in sock:
95
+ await self.handle(sock, msg.json())
96
+
97
+ async def handle(self, sock, datos) -> None:
98
+ """Despacha un mensaje ya parseado. Util para testear un handler suelto."""
99
+ if not isinstance(datos, dict):
100
+ raise InvalidJSON(datos, f"se esperaba un objeto con {self.key!r}")
101
+
102
+ tipo = datos.get(self.key)
103
+ entrada = self._handlers.get(tipo) or self._handlers.get(CUALQUIERA)
104
+
105
+ if entrada is None:
106
+ if self.strict:
107
+ raise InvalidJSON(
108
+ datos, f"tipo {tipo!r} desconocido; hay: {self.tipos}"
109
+ )
110
+ # A nivel WARNING a proposito: casi siempre es una errata en el
111
+ # nombre del tipo, y en DEBUG no la ve nadie. Si tu protocolo manda
112
+ # tipos que de verdad quieres ignorar, registra un @on("*") vacio.
113
+ logger.warning(
114
+ "django_socket: nadie maneja %s=%r en %s (registrados: %s)",
115
+ self.key, tipo, sock.path, self.tipos or "ninguno",
116
+ )
117
+ return
118
+
119
+ handler, quiere_datos = entrada
120
+ if quiere_datos:
121
+ await handler(sock, {k: v for k, v in datos.items() if k != self.key})
122
+ else:
123
+ await handler(sock)
124
+
125
+
126
+ def _quiere_datos(fn) -> bool:
127
+ """
128
+ Mira si el handler pide el payload ademas del socket.
129
+
130
+ Se resuelve una vez al registrar, no en cada mensaje.
131
+ """
132
+ params = [
133
+ p for p in inspect.signature(fn).parameters.values()
134
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
135
+ ]
136
+ if len(params) == 1:
137
+ return False
138
+ if len(params) == 2:
139
+ return True
140
+ raise TypeError(
141
+ f"{fn.__name__} debe aceptar (sock) o (sock, datos), no "
142
+ f"{len(params)} parametros posicionales."
143
+ )
@@ -0,0 +1,287 @@
1
+ """Capa de difusion: grupos y broadcast.
2
+
3
+ `MemoryLayer` sirve para un solo proceso (dev, o un unico worker).
4
+ `RedisLayer` reparte el fan-out entre procesos via pub/sub.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ from typing import Any
13
+
14
+ logger = logging.getLogger("django_socket")
15
+
16
+
17
+ class BaseLayer:
18
+ async def startup(self) -> None: ...
19
+
20
+ async def shutdown(self) -> None: ...
21
+
22
+ async def add(self, group: str, sock) -> None:
23
+ raise NotImplementedError
24
+
25
+ async def discard(self, group: str, sock) -> None:
26
+ raise NotImplementedError
27
+
28
+ async def send(self, group: str, data: Any, *, exclude=None) -> None:
29
+ raise NotImplementedError
30
+
31
+ async def size(self, group: str) -> int:
32
+ raise NotImplementedError
33
+
34
+
35
+ class MemoryLayer(BaseLayer):
36
+ """Grupos en memoria del proceso. Sin dependencias externas."""
37
+
38
+ def __init__(self):
39
+ self._groups: dict[str, set] = {}
40
+
41
+ async def add(self, group: str, sock) -> None:
42
+ self._groups.setdefault(group, set()).add(sock)
43
+
44
+ async def discard(self, group: str, sock) -> None:
45
+ members = self._groups.get(group)
46
+ if members:
47
+ members.discard(sock)
48
+ if not members:
49
+ del self._groups[group]
50
+
51
+ async def size(self, group: str) -> int:
52
+ return len(self._groups.get(group, ()))
53
+
54
+ async def send(self, group: str, data: Any, *, exclude=None) -> None:
55
+ await self._deliver_local(group, data, exclude=exclude)
56
+
57
+ async def _deliver_local(self, group: str, data: Any, *, exclude=None) -> None:
58
+ """
59
+ Reparte encolando, sin esperar a que nadie lea.
60
+
61
+ Esperar seria el bug: un solo miembro que no consume dejaria colgado
62
+ para siempre al que difunde. Cada socket tiene un buzon acotado; si se
63
+ llena, ese cliente va demasiado atrasado y se le echa en vez de dejar
64
+ que arrastre a los demas.
65
+ """
66
+ miembros = [s for s in self._groups.get(group, ()) if s is not exclude]
67
+ if not miembros:
68
+ return
69
+
70
+ lentos = []
71
+ for sock in miembros:
72
+ if not await sock.enqueue(data):
73
+ lentos.append(sock)
74
+
75
+ # Cede el turno una vez por difusion, no una por miembro: es donde
76
+ # corren los escritores. Sin esto, un handler que difunde en bucle
77
+ # (`for fila in lote: await sock.broadcast(fila)`) no soltaria nunca el
78
+ # loop, los buzones se llenarian y acabaria echando a clientes sanos.
79
+ await asyncio.sleep(0)
80
+
81
+ for sock in lentos:
82
+ logger.warning(
83
+ "django_socket: %r no consume (buzon lleno); se le echa del "
84
+ "grupo %r. Sube DJANGO_SOCKET['SEND_QUEUE_MAX'] si tu caso "
85
+ "manda rafagas legitimas.",
86
+ sock, group,
87
+ )
88
+ sock.evict()
89
+ await self.discard(group, sock)
90
+
91
+
92
+ class RedisLayer(MemoryLayer):
93
+ """
94
+ Mantiene los miembros locales igual que MemoryLayer, pero publica cada
95
+ broadcast en Redis para que los demas procesos entreguen a los suyos.
96
+
97
+ Sobrevive a que Redis se caiga: la entrega local sigue funcionando, los
98
+ sockets de los usuarios no se cierran, y al volver Redis el proceso se
99
+ resuscribe solo. Lo que se publique mientras esta caido se pierde -- esto
100
+ es pub/sub, no una cola.
101
+ """
102
+
103
+ ESPERA_MAX = 10.0 # tope del backoff al reconectar
104
+ LATIDO = 15.0 # health check de redis-py, en segundos
105
+
106
+ def __init__(self, url: str = "redis://localhost:6379/0", prefix: str = "djws"):
107
+ super().__init__()
108
+ self.url = url
109
+ self.channel = f"{prefix}:broadcast"
110
+ self._redis = None
111
+ self._pubsub = None
112
+ self._listener: asyncio.Task | None = None
113
+ self._conectado = False
114
+ # Identifica a este proceso para no entregar dos veces lo que ya
115
+ # entregamos localmente.
116
+ self._origin = f"{id(self)}"
117
+
118
+ async def startup(self) -> None:
119
+ try:
120
+ import redis.asyncio as aioredis
121
+ except ImportError as exc: # pragma: no cover
122
+ raise RuntimeError(
123
+ "RedisLayer necesita el paquete 'redis'. Instalalo con: pip install redis"
124
+ ) from exc
125
+ self._redis = aioredis.from_url(
126
+ self.url,
127
+ health_check_interval=self.LATIDO, # sin esto no detecta la caida
128
+ retry_on_timeout=True,
129
+ )
130
+ self._pubsub = self._redis.pubsub()
131
+ await self._pubsub.subscribe(self.channel)
132
+ self._conectado = True
133
+ self._listener = asyncio.create_task(self._listen())
134
+ logger.info("django_socket: RedisLayer conectada a %s", self.url)
135
+
136
+ async def shutdown(self) -> None:
137
+ self._conectado = False
138
+ if self._listener:
139
+ self._listener.cancel()
140
+ try:
141
+ await self._listener
142
+ except asyncio.CancelledError:
143
+ pass
144
+ if self._pubsub:
145
+ try:
146
+ await self._pubsub.unsubscribe(self.channel)
147
+ except Exception:
148
+ pass
149
+ await self._pubsub.aclose()
150
+ if self._redis:
151
+ await self._redis.aclose()
152
+
153
+ async def send(self, group: str, data: Any, *, exclude=None) -> None:
154
+ # Entrega local inmediata (asi `exclude` funciona por identidad)...
155
+ await self._deliver_local(group, data, exclude=exclude)
156
+ # ...y avisa al resto de procesos.
157
+ carga = json.dumps(
158
+ {"group": group, "data": data, "origin": self._origin}, default=str
159
+ )
160
+ try:
161
+ await self._redis.publish(self.channel, carga)
162
+ except Exception as exc:
163
+ # Que Redis falle no puede tumbar la conexion del usuario: la
164
+ # entrega local ya se hizo y el handler debe seguir vivo. Se pierde
165
+ # el fan-out a los demas procesos, y por eso se loguea como error.
166
+ logger.error(
167
+ "django_socket: no se pudo publicar en Redis (%s: %s). "
168
+ "El grupo %r solo recibio la entrega local.",
169
+ type(exc).__name__, exc, group,
170
+ )
171
+
172
+ async def _listen(self) -> None:
173
+ """
174
+ Bucle de escucha resistente.
175
+
176
+ Se usa `get_message(timeout=...)` en vez de `listen()` para tener un
177
+ despertar periodico: ahi es donde redis-py corre su health check y
178
+ donde podemos detectar que la conexion se fue. Con `listen()` a secas
179
+ el proceso se queda sordo para siempre tras una caida.
180
+ """
181
+ espera = 0.5
182
+ while True:
183
+ try:
184
+ mensaje = await self._pubsub.get_message(
185
+ ignore_subscribe_messages=True, timeout=1.0
186
+ )
187
+ espera = 0.5 # todo bien, resetea el backoff
188
+ if mensaje is not None and mensaje.get("type") == "message":
189
+ await self._entregar(mensaje)
190
+ except asyncio.CancelledError:
191
+ raise
192
+ except Exception as exc:
193
+ if not self._conectado:
194
+ return
195
+ logger.warning(
196
+ "django_socket: se perdio la conexion con Redis (%s). "
197
+ "Reintentando en %.1fs.", type(exc).__name__, espera,
198
+ )
199
+ await asyncio.sleep(espera)
200
+ espera = min(espera * 2, self.ESPERA_MAX)
201
+ await self._resuscribir()
202
+
203
+ async def _entregar(self, mensaje) -> None:
204
+ try:
205
+ payload = json.loads(mensaje["data"])
206
+ except (ValueError, KeyError, TypeError):
207
+ logger.warning("django_socket: mensaje ilegible en %s", self.channel)
208
+ return
209
+ if payload.get("origin") == self._origin:
210
+ return # ya lo entregamos localmente
211
+ await self._deliver_local(payload["group"], payload["data"])
212
+
213
+ async def _resuscribir(self) -> None:
214
+ """Vuelve a suscribirse tras un corte. Si Redis sigue caido, lo dira el bucle."""
215
+ try:
216
+ await self._pubsub.aclose()
217
+ except Exception:
218
+ pass
219
+ self._pubsub = self._redis.pubsub()
220
+ await self._pubsub.subscribe(self.channel)
221
+ logger.info("django_socket: resuscrito a %s", self.channel)
222
+
223
+
224
+ # --------------------------------------------------------------- layer global
225
+
226
+ _layer: BaseLayer | None = None
227
+
228
+
229
+ def get_layer() -> BaseLayer:
230
+ global _layer
231
+ if _layer is None:
232
+ _layer = _build_layer()
233
+ return _layer
234
+
235
+
236
+ def set_layer(layer: BaseLayer) -> None:
237
+ global _layer
238
+ _layer = layer
239
+
240
+
241
+ def _build_layer() -> BaseLayer:
242
+ from django.conf import settings
243
+
244
+ conf = getattr(settings, "DJANGO_SOCKET", {}) or {}
245
+ backend = conf.get("LAYER", "memory")
246
+ if backend == "memory":
247
+ return MemoryLayer()
248
+ if backend == "redis":
249
+ return RedisLayer(
250
+ url=conf.get("REDIS_URL", "redis://localhost:6379/0"),
251
+ prefix=conf.get("PREFIX", "djws"),
252
+ )
253
+ if callable(backend):
254
+ return backend()
255
+ raise ValueError(
256
+ f"DJANGO_SOCKET['LAYER'] invalido: {backend!r}. Usa 'memory', 'redis' "
257
+ f"o un callable que devuelva una BaseLayer."
258
+ )
259
+
260
+
261
+ # ------------------------------------------------------------- API de usuario
262
+
263
+
264
+ async def broadcast(data: Any, *, to: str) -> None:
265
+ """
266
+ Envia a todos los miembros de un grupo, desde fuera de un handler.
267
+
268
+ await broadcast({"aviso": "mantenimiento"}, to="room:1")
269
+ """
270
+ await get_layer().send(to, data)
271
+
272
+
273
+ async def group_size(group: str) -> int:
274
+ """Cuantos sockets locales hay en el grupo."""
275
+ return await get_layer().size(group)
276
+
277
+
278
+ def broadcast_sync(data: Any, *, to: str) -> None:
279
+ """
280
+ Igual que `broadcast`, para vistas sincronas, señales o tareas Celery.
281
+
282
+ Con la capa 'memory' solo alcanza a los sockets del mismo proceso; para
283
+ llegar a todos los workers necesitas LAYER='redis'.
284
+ """
285
+ from asgiref.sync import async_to_sync
286
+
287
+ async_to_sync(broadcast)(data, to=to)
File without changes
File without changes