flux-sockets 0.6.4__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.
- flux/__init__.py +5 -0
- flux/client.py +115 -0
- flux/events.py +36 -0
- flux/protocol.py +187 -0
- flux/server.py +287 -0
- flux_sockets-0.6.4.dist-info/METADATA +12 -0
- flux_sockets-0.6.4.dist-info/RECORD +10 -0
- flux_sockets-0.6.4.dist-info/WHEEL +5 -0
- flux_sockets-0.6.4.dist-info/licenses/LICENSE +21 -0
- flux_sockets-0.6.4.dist-info/top_level.txt +1 -0
flux/__init__.py
ADDED
flux/client.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
from .events import Events
|
|
5
|
+
from .protocol import ConnectionClosed, Reader, TCPTransport, decode, encode
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Client(Events):
|
|
9
|
+
"""Python client. Emits made before the connection is up are queued and sent on connect."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, host="localhost", port=5000, token=None, reconnect=True,
|
|
12
|
+
reconnect_delay=1.0, max_delay=15.0, connect_timeout=10.0):
|
|
13
|
+
super().__init__()
|
|
14
|
+
self.host, self.port, self.token = host, port, token
|
|
15
|
+
self.reconnect = reconnect
|
|
16
|
+
self.reconnect_delay, self.max_delay = reconnect_delay, max_delay
|
|
17
|
+
self.connect_timeout = connect_timeout
|
|
18
|
+
self.id = None
|
|
19
|
+
self.connected = False
|
|
20
|
+
self._transport = None
|
|
21
|
+
self._pending = []
|
|
22
|
+
self._lock = threading.RLock()
|
|
23
|
+
self._stop = threading.Event()
|
|
24
|
+
self._ready = threading.Event()
|
|
25
|
+
self._thread = None
|
|
26
|
+
|
|
27
|
+
# public API
|
|
28
|
+
def emit(self, event, data=None):
|
|
29
|
+
text = encode(event, data)
|
|
30
|
+
with self._lock:
|
|
31
|
+
if self.connected and self._transport:
|
|
32
|
+
try:
|
|
33
|
+
self._transport.send(text)
|
|
34
|
+
return True
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
if len(self._pending) < 1000:
|
|
38
|
+
self._pending.append(text)
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
def run(self):
|
|
42
|
+
"""Block, connecting (and reconnecting) until close() is called."""
|
|
43
|
+
self._stop.clear()
|
|
44
|
+
delay = self.reconnect_delay
|
|
45
|
+
while not self._stop.is_set():
|
|
46
|
+
try:
|
|
47
|
+
sock = socket.create_connection((self.host, self.port), self.connect_timeout)
|
|
48
|
+
except OSError as e:
|
|
49
|
+
if not self.reconnect:
|
|
50
|
+
raise ConnectionError(f"could not connect to {self.host}:{self.port}") from e
|
|
51
|
+
if self._stop.wait(delay):
|
|
52
|
+
break
|
|
53
|
+
delay = min(delay * 2, self.max_delay)
|
|
54
|
+
continue
|
|
55
|
+
delay = self.reconnect_delay
|
|
56
|
+
sock.settimeout(None)
|
|
57
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
58
|
+
transport = TCPTransport(sock, Reader(sock))
|
|
59
|
+
self._transport = transport
|
|
60
|
+
try:
|
|
61
|
+
transport.send(encode("__hello"))
|
|
62
|
+
if self.token is not None:
|
|
63
|
+
transport.send(encode("auth", self.token))
|
|
64
|
+
while True:
|
|
65
|
+
event, data = decode(transport.recv())
|
|
66
|
+
self._handle(event, data)
|
|
67
|
+
except (ConnectionClosed, OSError):
|
|
68
|
+
pass
|
|
69
|
+
finally:
|
|
70
|
+
with self._lock:
|
|
71
|
+
was, self.connected = self.connected, False
|
|
72
|
+
self._transport = None
|
|
73
|
+
self._ready.clear()
|
|
74
|
+
transport.close()
|
|
75
|
+
if was:
|
|
76
|
+
self._fire("disconnect")
|
|
77
|
+
if not self.reconnect or self._stop.wait(delay):
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
def start(self):
|
|
81
|
+
"""Run in a background thread."""
|
|
82
|
+
self._thread = threading.Thread(target=self.run, daemon=True)
|
|
83
|
+
self._thread.start()
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def wait_connected(self, timeout=None):
|
|
87
|
+
return self._ready.wait(timeout)
|
|
88
|
+
|
|
89
|
+
def close(self):
|
|
90
|
+
self._stop.set()
|
|
91
|
+
t = self._transport
|
|
92
|
+
if t:
|
|
93
|
+
t.close()
|
|
94
|
+
|
|
95
|
+
# internals
|
|
96
|
+
def _handle(self, event, data):
|
|
97
|
+
if event == "__ping":
|
|
98
|
+
try:
|
|
99
|
+
self._transport.send(encode("__pong"))
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
return
|
|
103
|
+
if event == "welcome":
|
|
104
|
+
self.id = data.get("id") if isinstance(data, dict) else None
|
|
105
|
+
with self._lock:
|
|
106
|
+
while self._pending:
|
|
107
|
+
self._transport.send(self._pending[0])
|
|
108
|
+
self._pending.pop(0)
|
|
109
|
+
self.connected = True
|
|
110
|
+
self._ready.set()
|
|
111
|
+
self._fire("connect")
|
|
112
|
+
elif event == "auth_failed":
|
|
113
|
+
self._stop.set()
|
|
114
|
+
self._fire("*", event, data)
|
|
115
|
+
self._fire(event, data)
|
flux/events.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import traceback
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def arity(fn):
|
|
6
|
+
"""How many positional args a handler accepts, so handlers can take fewer args."""
|
|
7
|
+
try:
|
|
8
|
+
params = inspect.signature(fn).parameters.values()
|
|
9
|
+
except (TypeError, ValueError):
|
|
10
|
+
return 99
|
|
11
|
+
n = 0
|
|
12
|
+
for p in params:
|
|
13
|
+
if p.kind == p.VAR_POSITIONAL:
|
|
14
|
+
return 99
|
|
15
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
|
|
16
|
+
n += 1
|
|
17
|
+
return n
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Events:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self._handlers = {}
|
|
23
|
+
|
|
24
|
+
def on(self, event, fn=None):
|
|
25
|
+
"""Use as @obj.on("event") or obj.on("event", fn)."""
|
|
26
|
+
def register(f):
|
|
27
|
+
self._handlers.setdefault(event, []).append((f, arity(f)))
|
|
28
|
+
return f
|
|
29
|
+
return register(fn) if fn else register
|
|
30
|
+
|
|
31
|
+
def _fire(self, event, *args):
|
|
32
|
+
for fn, n in list(self._handlers.get(event, ())):
|
|
33
|
+
try:
|
|
34
|
+
fn(*args[:n])
|
|
35
|
+
except Exception:
|
|
36
|
+
traceback.print_exc()
|
flux/protocol.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import struct
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
10
|
+
MAX_MESSAGE = 1 << 20 # 1 MiB
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConnectionClosed(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def encode(event, data=None):
|
|
18
|
+
return json.dumps(
|
|
19
|
+
{"event": event, "data": data}, separators=(",", ":"), default=str
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decode(text):
|
|
24
|
+
try:
|
|
25
|
+
obj = json.loads(text)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return "message", text
|
|
28
|
+
if isinstance(obj, dict) and isinstance(obj.get("event"), str):
|
|
29
|
+
return obj["event"], obj.get("data")
|
|
30
|
+
return "message", obj
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Reader:
|
|
34
|
+
def __init__(self, sock):
|
|
35
|
+
self.sock = sock
|
|
36
|
+
self.buf = bytearray()
|
|
37
|
+
self.last_activity = time.monotonic()
|
|
38
|
+
|
|
39
|
+
def _fill(self):
|
|
40
|
+
try:
|
|
41
|
+
chunk = self.sock.recv(65536)
|
|
42
|
+
except OSError as e:
|
|
43
|
+
raise ConnectionClosed(str(e))
|
|
44
|
+
if not chunk:
|
|
45
|
+
raise ConnectionClosed()
|
|
46
|
+
self.last_activity = time.monotonic()
|
|
47
|
+
self.buf += chunk
|
|
48
|
+
|
|
49
|
+
def read_exact(self, n):
|
|
50
|
+
while len(self.buf) < n:
|
|
51
|
+
self._fill()
|
|
52
|
+
out = bytes(self.buf[:n])
|
|
53
|
+
del self.buf[:n]
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
def read_until(self, delim, limit=MAX_MESSAGE):
|
|
57
|
+
while True:
|
|
58
|
+
i = self.buf.find(delim)
|
|
59
|
+
if i != -1:
|
|
60
|
+
out = bytes(self.buf[:i])
|
|
61
|
+
del self.buf[: i + len(delim)]
|
|
62
|
+
return out
|
|
63
|
+
if len(self.buf) > limit:
|
|
64
|
+
raise ConnectionClosed("message too large")
|
|
65
|
+
self._fill()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Transport:
|
|
69
|
+
def __init__(self, sock, reader):
|
|
70
|
+
self.sock = sock
|
|
71
|
+
self.reader = reader
|
|
72
|
+
self._wlock = threading.Lock()
|
|
73
|
+
|
|
74
|
+
def _write(self, data):
|
|
75
|
+
with self._wlock:
|
|
76
|
+
self.sock.sendall(data)
|
|
77
|
+
|
|
78
|
+
def close(self):
|
|
79
|
+
for fn in (lambda: self.sock.shutdown(socket.SHUT_RDWR), self.sock.close):
|
|
80
|
+
try:
|
|
81
|
+
fn()
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TCPTransport(_Transport):
|
|
87
|
+
kind = "tcp"
|
|
88
|
+
|
|
89
|
+
def recv(self):
|
|
90
|
+
while True:
|
|
91
|
+
line = self.reader.read_until(b"\n").strip()
|
|
92
|
+
if line:
|
|
93
|
+
return line.decode("utf-8", "replace")
|
|
94
|
+
|
|
95
|
+
def send(self, text):
|
|
96
|
+
self._write(text.encode() + b"\n")
|
|
97
|
+
|
|
98
|
+
def ping(self):
|
|
99
|
+
self.send(encode("__ping"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _unmask(data, mask):
|
|
103
|
+
if not data:
|
|
104
|
+
return data
|
|
105
|
+
m = (mask * (len(data) // 4 + 1))[: len(data)]
|
|
106
|
+
return (int.from_bytes(data, "big") ^ int.from_bytes(m, "big")).to_bytes(
|
|
107
|
+
len(data), "big"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class WSTransport(_Transport):
|
|
112
|
+
kind = "websocket"
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _frame(opcode, payload=b""):
|
|
116
|
+
n = len(payload)
|
|
117
|
+
head = bytes([0x80 | opcode])
|
|
118
|
+
if n < 126:
|
|
119
|
+
head += bytes([n])
|
|
120
|
+
elif n < 65536:
|
|
121
|
+
head += b"\x7e" + struct.pack(">H", n)
|
|
122
|
+
else:
|
|
123
|
+
head += b"\x7f" + struct.pack(">Q", n)
|
|
124
|
+
return head + payload
|
|
125
|
+
|
|
126
|
+
def send(self, text):
|
|
127
|
+
self._write(self._frame(1, text.encode()))
|
|
128
|
+
|
|
129
|
+
def ping(self):
|
|
130
|
+
self._write(self._frame(9))
|
|
131
|
+
|
|
132
|
+
def recv(self):
|
|
133
|
+
r = self.reader
|
|
134
|
+
message = bytearray()
|
|
135
|
+
while True:
|
|
136
|
+
b1, b2 = r.read_exact(2)
|
|
137
|
+
fin, op = b1 & 0x80, b1 & 0x0F
|
|
138
|
+
n = b2 & 0x7F
|
|
139
|
+
if n == 126:
|
|
140
|
+
n = struct.unpack(">H", r.read_exact(2))[0]
|
|
141
|
+
elif n == 127:
|
|
142
|
+
n = struct.unpack(">Q", r.read_exact(8))[0]
|
|
143
|
+
if n > MAX_MESSAGE:
|
|
144
|
+
raise ConnectionClosed("frame too large")
|
|
145
|
+
mask = r.read_exact(4) if b2 & 0x80 else None
|
|
146
|
+
payload = r.read_exact(n)
|
|
147
|
+
if mask:
|
|
148
|
+
payload = _unmask(payload, mask)
|
|
149
|
+
if op == 8:
|
|
150
|
+
try:
|
|
151
|
+
self._write(self._frame(8, payload[:2]))
|
|
152
|
+
except OSError:
|
|
153
|
+
pass
|
|
154
|
+
raise ConnectionClosed()
|
|
155
|
+
if op == 9:
|
|
156
|
+
self._write(self._frame(10, payload))
|
|
157
|
+
continue
|
|
158
|
+
if op == 10:
|
|
159
|
+
continue
|
|
160
|
+
message += payload
|
|
161
|
+
if len(message) > MAX_MESSAGE:
|
|
162
|
+
raise ConnectionClosed("message too large")
|
|
163
|
+
if fin:
|
|
164
|
+
return message.decode("utf-8", "replace")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def ws_server_handshake(sock, reader):
|
|
168
|
+
head = reader.read_until(b"\r\n\r\n", 16384).decode("latin-1")
|
|
169
|
+
headers = {}
|
|
170
|
+
for line in head.split("\r\n")[1:]:
|
|
171
|
+
if ":" in line:
|
|
172
|
+
k, v = line.split(":", 1)
|
|
173
|
+
headers[k.strip().lower()] = v.strip()
|
|
174
|
+
key = headers.get("sec-websocket-key")
|
|
175
|
+
if not key:
|
|
176
|
+
sock.sendall(
|
|
177
|
+
b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nWebSocket only."
|
|
178
|
+
)
|
|
179
|
+
raise ConnectionClosed("not a websocket request")
|
|
180
|
+
accept = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
|
|
181
|
+
sock.sendall(
|
|
182
|
+
(
|
|
183
|
+
"HTTP/1.1 101 Switching Protocols\r\n"
|
|
184
|
+
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
185
|
+
f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
|
|
186
|
+
).encode()
|
|
187
|
+
)
|
flux/server.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
import traceback
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from .events import Events, arity
|
|
8
|
+
from .protocol import (ConnectionClosed, Reader, TCPTransport, WSTransport,
|
|
9
|
+
decode, encode, ws_server_handshake)
|
|
10
|
+
|
|
11
|
+
_MISSING = object()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ServerClient:
|
|
15
|
+
"""One connected peer (TCP or WebSocket) as seen by the server."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, server, transport, address):
|
|
18
|
+
self.server = server
|
|
19
|
+
self.transport = transport
|
|
20
|
+
self.address = address
|
|
21
|
+
self.id = uuid.uuid4().hex[:8]
|
|
22
|
+
self.name = "client-" + self.id
|
|
23
|
+
self.data = {}
|
|
24
|
+
self.rooms = set()
|
|
25
|
+
self.connected = True
|
|
26
|
+
self.authenticated = False
|
|
27
|
+
self.connected_at = time.monotonic()
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def kind(self):
|
|
31
|
+
return self.transport.kind
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def last_seen(self):
|
|
35
|
+
return self.transport.reader.last_activity
|
|
36
|
+
|
|
37
|
+
def send(self, event, data=_MISSING):
|
|
38
|
+
"""client.send(data) -> "message" event, or client.send("event", data)."""
|
|
39
|
+
if data is _MISSING:
|
|
40
|
+
event, data = "message", event
|
|
41
|
+
return self._send_text(encode(event, data))
|
|
42
|
+
|
|
43
|
+
def emit(self, event, data=None):
|
|
44
|
+
return self._send_text(encode(event, data))
|
|
45
|
+
|
|
46
|
+
def _send_text(self, text):
|
|
47
|
+
if not self.connected:
|
|
48
|
+
return False
|
|
49
|
+
try:
|
|
50
|
+
self.transport.send(text)
|
|
51
|
+
return True
|
|
52
|
+
except OSError:
|
|
53
|
+
self.close()
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def join(self, room):
|
|
57
|
+
return self.server.room(room).join(self)
|
|
58
|
+
|
|
59
|
+
def leave(self, room):
|
|
60
|
+
r = self.server._rooms.get(room)
|
|
61
|
+
if r:
|
|
62
|
+
r.leave(self)
|
|
63
|
+
|
|
64
|
+
def close(self):
|
|
65
|
+
self.connected = False
|
|
66
|
+
self.transport.close()
|
|
67
|
+
|
|
68
|
+
def __repr__(self):
|
|
69
|
+
return f"<Client {self.id} {self.kind} {self.address[0]}>"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Room:
|
|
73
|
+
def __init__(self, server, name):
|
|
74
|
+
self.server = server
|
|
75
|
+
self.name = name
|
|
76
|
+
self._members = {}
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def clients(self):
|
|
80
|
+
with self.server._lock:
|
|
81
|
+
return list(self._members.values())
|
|
82
|
+
|
|
83
|
+
def join(self, client):
|
|
84
|
+
with self.server._lock:
|
|
85
|
+
self._members[client.id] = client
|
|
86
|
+
client.rooms.add(self.name)
|
|
87
|
+
self.server._rooms[self.name] = self
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def leave(self, client):
|
|
91
|
+
with self.server._lock:
|
|
92
|
+
self._members.pop(client.id, None)
|
|
93
|
+
client.rooms.discard(self.name)
|
|
94
|
+
if not self._members and self.server._rooms.get(self.name) is self:
|
|
95
|
+
del self.server._rooms[self.name]
|
|
96
|
+
|
|
97
|
+
def emit(self, event, data=None, exclude=None):
|
|
98
|
+
text = encode(event, data)
|
|
99
|
+
for c in self.clients:
|
|
100
|
+
if exclude is None or c is not exclude:
|
|
101
|
+
c._send_text(text)
|
|
102
|
+
|
|
103
|
+
def __len__(self):
|
|
104
|
+
return len(self._members)
|
|
105
|
+
|
|
106
|
+
def __contains__(self, client):
|
|
107
|
+
return client.id in self._members
|
|
108
|
+
|
|
109
|
+
def __repr__(self):
|
|
110
|
+
return f"<Room {self.name!r} ({len(self)})>"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Server(Events):
|
|
114
|
+
def __init__(self, port=5000, host="0.0.0.0", heartbeat=15, timeout=45,
|
|
115
|
+
sniff_timeout=0.5, auth_timeout=10, quiet=False):
|
|
116
|
+
super().__init__()
|
|
117
|
+
self.host, self.port = host, port
|
|
118
|
+
self.heartbeat, self.timeout = heartbeat, timeout
|
|
119
|
+
self.sniff_timeout, self.auth_timeout = sniff_timeout, auth_timeout
|
|
120
|
+
self.quiet = quiet
|
|
121
|
+
self.clients = {}
|
|
122
|
+
self._rooms = {}
|
|
123
|
+
self._lock = threading.RLock()
|
|
124
|
+
self._stop = threading.Event()
|
|
125
|
+
self._listener = None
|
|
126
|
+
self._auth = None
|
|
127
|
+
|
|
128
|
+
# public API
|
|
129
|
+
def auth(self, fn):
|
|
130
|
+
"""@server.auth def check(client, token): return True/False"""
|
|
131
|
+
self._auth = (fn, arity(fn))
|
|
132
|
+
return fn
|
|
133
|
+
|
|
134
|
+
def room(self, name):
|
|
135
|
+
with self._lock:
|
|
136
|
+
return self._rooms.get(name) or Room(self, name)
|
|
137
|
+
|
|
138
|
+
def broadcast(self, event, data=None, exclude=None):
|
|
139
|
+
text = encode(event, data)
|
|
140
|
+
with self._lock:
|
|
141
|
+
targets = list(self.clients.values())
|
|
142
|
+
for c in targets:
|
|
143
|
+
if c.authenticated and (exclude is None or c is not exclude):
|
|
144
|
+
c._send_text(text)
|
|
145
|
+
|
|
146
|
+
def run(self):
|
|
147
|
+
"""Block and serve until Ctrl+C."""
|
|
148
|
+
self._bind()
|
|
149
|
+
self._log(f"flux listening on {self.host}:{self.port} (tcp + websocket)")
|
|
150
|
+
threading.Thread(target=self._monitor, daemon=True).start()
|
|
151
|
+
try:
|
|
152
|
+
self._accept_loop()
|
|
153
|
+
except KeyboardInterrupt:
|
|
154
|
+
pass
|
|
155
|
+
finally:
|
|
156
|
+
self.stop()
|
|
157
|
+
|
|
158
|
+
def start(self):
|
|
159
|
+
"""Serve in a background thread and return immediately."""
|
|
160
|
+
self._bind()
|
|
161
|
+
threading.Thread(target=self._monitor, daemon=True).start()
|
|
162
|
+
threading.Thread(target=self._accept_loop, daemon=True).start()
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def stop(self):
|
|
166
|
+
self._stop.set()
|
|
167
|
+
if self._listener:
|
|
168
|
+
try:
|
|
169
|
+
self._listener.close()
|
|
170
|
+
except OSError:
|
|
171
|
+
pass
|
|
172
|
+
for c in list(self.clients.values()):
|
|
173
|
+
c.close()
|
|
174
|
+
|
|
175
|
+
# internals
|
|
176
|
+
def _log(self, *a):
|
|
177
|
+
if not self.quiet:
|
|
178
|
+
print(*a)
|
|
179
|
+
|
|
180
|
+
def _bind(self):
|
|
181
|
+
s = socket.socket()
|
|
182
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
183
|
+
s.bind((self.host, self.port))
|
|
184
|
+
s.listen(128)
|
|
185
|
+
s.settimeout(0.5)
|
|
186
|
+
self.port = s.getsockname()[1]
|
|
187
|
+
self._listener = s
|
|
188
|
+
self._stop.clear()
|
|
189
|
+
|
|
190
|
+
def _accept_loop(self):
|
|
191
|
+
while not self._stop.is_set():
|
|
192
|
+
try:
|
|
193
|
+
sock, addr = self._listener.accept()
|
|
194
|
+
except socket.timeout:
|
|
195
|
+
continue
|
|
196
|
+
except OSError:
|
|
197
|
+
break
|
|
198
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
199
|
+
threading.Thread(target=self._serve, args=(sock, addr), daemon=True).start()
|
|
200
|
+
|
|
201
|
+
def _monitor(self):
|
|
202
|
+
while not self._stop.wait(self.heartbeat):
|
|
203
|
+
now = time.monotonic()
|
|
204
|
+
for c in list(self.clients.values()):
|
|
205
|
+
if now - c.last_seen > self.timeout:
|
|
206
|
+
c.close()
|
|
207
|
+
elif not c.authenticated and now - c.connected_at > self.auth_timeout:
|
|
208
|
+
c.close()
|
|
209
|
+
else:
|
|
210
|
+
try:
|
|
211
|
+
c.transport.ping()
|
|
212
|
+
except OSError:
|
|
213
|
+
c.close()
|
|
214
|
+
|
|
215
|
+
def _negotiate(self, sock):
|
|
216
|
+
reader = Reader(sock)
|
|
217
|
+
sock.settimeout(self.sniff_timeout)
|
|
218
|
+
try:
|
|
219
|
+
first = sock.recv(4, socket.MSG_PEEK)
|
|
220
|
+
if not first:
|
|
221
|
+
raise ConnectionClosed()
|
|
222
|
+
except socket.timeout:
|
|
223
|
+
first = b""
|
|
224
|
+
except OSError:
|
|
225
|
+
raise ConnectionClosed()
|
|
226
|
+
sock.settimeout(10)
|
|
227
|
+
if first[:3] == b"GET":
|
|
228
|
+
ws_server_handshake(sock, reader)
|
|
229
|
+
sock.settimeout(None)
|
|
230
|
+
return WSTransport(sock, reader)
|
|
231
|
+
sock.settimeout(None)
|
|
232
|
+
return TCPTransport(sock, reader)
|
|
233
|
+
|
|
234
|
+
def _admit(self, client):
|
|
235
|
+
client.authenticated = True
|
|
236
|
+
client.emit("welcome", {"id": client.id})
|
|
237
|
+
self._fire("connect", client)
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
def _serve(self, sock, address):
|
|
241
|
+
try:
|
|
242
|
+
transport = self._negotiate(sock)
|
|
243
|
+
except (ConnectionClosed, OSError):
|
|
244
|
+
sock.close()
|
|
245
|
+
return
|
|
246
|
+
client = ServerClient(self, transport, address)
|
|
247
|
+
with self._lock:
|
|
248
|
+
self.clients[client.id] = client
|
|
249
|
+
announced = False
|
|
250
|
+
try:
|
|
251
|
+
if self._auth is None:
|
|
252
|
+
announced = self._admit(client)
|
|
253
|
+
else:
|
|
254
|
+
client.emit("auth_required")
|
|
255
|
+
while True:
|
|
256
|
+
event, data = decode(transport.recv())
|
|
257
|
+
if event in ("__pong", "__hello"):
|
|
258
|
+
continue
|
|
259
|
+
if not client.authenticated:
|
|
260
|
+
if event != "auth":
|
|
261
|
+
continue
|
|
262
|
+
fn, n = self._auth
|
|
263
|
+
ok = False
|
|
264
|
+
try:
|
|
265
|
+
ok = bool(fn(*(client, data)[:n]))
|
|
266
|
+
except Exception:
|
|
267
|
+
traceback.print_exc()
|
|
268
|
+
if not ok:
|
|
269
|
+
client.emit("auth_failed")
|
|
270
|
+
break
|
|
271
|
+
announced = self._admit(client)
|
|
272
|
+
continue
|
|
273
|
+
self._fire("*", client, event, data)
|
|
274
|
+
self._fire(event, client, data)
|
|
275
|
+
except ConnectionClosed:
|
|
276
|
+
pass
|
|
277
|
+
except Exception:
|
|
278
|
+
traceback.print_exc()
|
|
279
|
+
finally:
|
|
280
|
+
client.connected = False
|
|
281
|
+
for r in list(client.rooms):
|
|
282
|
+
client.leave(r)
|
|
283
|
+
with self._lock:
|
|
284
|
+
self.clients.pop(client.id, None)
|
|
285
|
+
transport.close()
|
|
286
|
+
if announced:
|
|
287
|
+
self._fire("disconnect", client)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flux-sockets
|
|
3
|
+
Version: 0.6.4
|
|
4
|
+
Summary: Dead-simple sockets: events, rooms, JSON and WebSockets in a few lines.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/TheOrangeCow/flux
|
|
7
|
+
Project-URL: Repository, https://github.com/TheOrangeCow/flux
|
|
8
|
+
Project-URL: Issues, https://github.com/TheOrangeCow/flux/issues
|
|
9
|
+
Keywords: sockets,networking,websockets,events,multiplayer,realtime
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
flux/__init__.py,sha256=slUgf5Krb7wzVIXpx0OWDXj0FkrsMqOwtGFMzlM8vWM,152
|
|
2
|
+
flux/client.py,sha256=xGnhk2-YW60lG-WUg86umBj3Ns_IsD0Ag9xey2wwkss,4088
|
|
3
|
+
flux/events.py,sha256=KCWVXLshzwsKfwWQ0vsH4i2z2qgnJTHjsbr0aoKZf_Y,988
|
|
4
|
+
flux/protocol.py,sha256=7UGXY-YX3eSJA3J8myj-a31ddhEEWmaCcR6G5L6njhw,5131
|
|
5
|
+
flux/server.py,sha256=BMkBDO6DP77Pz5jALIZemUVvyCDkoN0ujam9VkNYLCY,8895
|
|
6
|
+
flux_sockets-0.6.4.dist-info/licenses/LICENSE,sha256=v6-ByutdA9HMF3T0mHfLOHSqi5TsddF6tahFwjNIiLo,1090
|
|
7
|
+
flux_sockets-0.6.4.dist-info/METADATA,sha256=5kVzbaGRa0vnlF6tsjfLaIbsPPhfxL6Bo8fFopCnT9Q,495
|
|
8
|
+
flux_sockets-0.6.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
flux_sockets-0.6.4.dist-info/top_level.txt,sha256=9txNc7btJudkur68qMJ7nT64gBMfFJm2dFvfYLaFBc4,5
|
|
10
|
+
flux_sockets-0.6.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TheOrangeCow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flux
|