meshsocket 0.1.2__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.
- {meshsocket-0.1.2 → meshsocket-0.2.0}/PKG-INFO +1 -1
- {meshsocket-0.1.2 → meshsocket-0.2.0}/meshsocket/__init__.py +3 -3
- {meshsocket-0.1.2 → meshsocket-0.2.0}/meshsocket.egg-info/PKG-INFO +1 -1
- {meshsocket-0.1.2 → meshsocket-0.2.0}/pyproject.toml +1 -1
- {meshsocket-0.1.2 → meshsocket-0.2.0}/socketCore.py +197 -27
- meshsocket-0.2.0/socket_server.py +627 -0
- meshsocket-0.1.2/socket_server.py +0 -370
- {meshsocket-0.1.2 → meshsocket-0.2.0}/LICENSE +0 -0
- {meshsocket-0.1.2 → meshsocket-0.2.0}/README.md +0 -0
- {meshsocket-0.1.2 → meshsocket-0.2.0}/meshsocket.egg-info/SOURCES.txt +0 -0
- {meshsocket-0.1.2 → meshsocket-0.2.0}/meshsocket.egg-info/dependency_links.txt +0 -0
- {meshsocket-0.1.2 → meshsocket-0.2.0}/meshsocket.egg-info/requires.txt +0 -0
- {meshsocket-0.1.2 → meshsocket-0.2.0}/meshsocket.egg-info/top_level.txt +0 -0
- {meshsocket-0.1.2 → meshsocket-0.2.0}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
`from socketCore import MeshSocket` continues to work (the module is published alongside)
|
|
5
5
|
so existing code keeps running while it migrates to the package name.
|
|
6
6
|
"""
|
|
7
|
-
from socketCore import MeshSocket, LogColors
|
|
7
|
+
from socketCore import MeshSocket, LogColors, sanitize_identity, valid_ident, IDENT_RE, NAMESPACED_RE
|
|
8
8
|
|
|
9
|
-
__all__ = ["MeshSocket", "LogColors"]
|
|
10
|
-
__version__ = "0.
|
|
9
|
+
__all__ = ["MeshSocket", "LogColors", "sanitize_identity", "valid_ident", "IDENT_RE", "NAMESPACED_RE"]
|
|
10
|
+
__version__ = "0.2.0"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meshsocket"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -3,14 +3,56 @@ import certifi
|
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
5
|
import ssl
|
|
6
|
+
import stat
|
|
6
7
|
import uuid
|
|
7
8
|
import time
|
|
8
9
|
import logging
|
|
9
10
|
import os
|
|
11
|
+
import re
|
|
10
12
|
import websockets
|
|
13
|
+
from urllib.parse import urlsplit
|
|
11
14
|
from collections import deque
|
|
12
15
|
from typing import Callable, Dict, Any, List, Optional
|
|
13
16
|
|
|
17
|
+
# Identity grammar enforced by the relay (see socket_server): a plain identifier,
|
|
18
|
+
# or the carter-relay gateway's `<account digits>.<identifier>` form.
|
|
19
|
+
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
|
20
|
+
NAMESPACED_RE = re.compile(r"^[0-9]{1,32}\.[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
|
21
|
+
_NS_PREFIX_RE = re.compile(r"^([0-9]{1,32})\.(.*)$", re.S)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def valid_ident(value: Any) -> bool:
|
|
25
|
+
return isinstance(value, str) and bool(IDENT_RE.match(value) or NAMESPACED_RE.match(value))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sanitize_identity(value: Any, kind: str = "name") -> str:
|
|
29
|
+
"""Turn a free-form string into a name/channel the relay accepts.
|
|
30
|
+
|
|
31
|
+
The relay itself does NOT sanitize: an identify whose name or channel fails
|
|
32
|
+
the grammar is closed with 1008. Clients call this before identifying (for
|
|
33
|
+
example on a device name such as "Carter's iPhone" -> "Carter-s-iPhone").
|
|
34
|
+
|
|
35
|
+
Rule (identical in relay, gateway and app): `value` must be a str of 1..256
|
|
36
|
+
chars, else ValueError. A leading `<digits>.` namespace prefix is kept and
|
|
37
|
+
only the remainder is sanitized. Every char outside [A-Za-z0-9_-] becomes
|
|
38
|
+
"-", runs of "-" collapse to one, leading/trailing "-" (and a leading "_",
|
|
39
|
+
which the grammar forbids) are stripped, the result is truncated to 64 chars,
|
|
40
|
+
and an empty result becomes "node" (kind="name") or "default"
|
|
41
|
+
(kind="channel"). The output always satisfies `valid_ident`.
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(value, str) or not 1 <= len(value) <= 256:
|
|
44
|
+
raise ValueError(f"{kind} must be a string of 1..256 characters")
|
|
45
|
+
prefix = ""
|
|
46
|
+
m = _NS_PREFIX_RE.match(value)
|
|
47
|
+
if m:
|
|
48
|
+
prefix, value = m.group(1) + ".", m.group(2)
|
|
49
|
+
out = re.sub(r"[^A-Za-z0-9_-]", "-", value)
|
|
50
|
+
out = re.sub(r"-{2,}", "-", out).strip("-").lstrip("_")
|
|
51
|
+
out = out[:64].rstrip("-")
|
|
52
|
+
if not out:
|
|
53
|
+
out = "node" if kind == "name" else "default"
|
|
54
|
+
return prefix + out
|
|
55
|
+
|
|
14
56
|
# ANSI Colors for nicer logs
|
|
15
57
|
class LogColors:
|
|
16
58
|
HEADER = '\033[95m'
|
|
@@ -75,9 +117,22 @@ class MeshSocket:
|
|
|
75
117
|
can_route: bool | None = None,
|
|
76
118
|
can_cross_channel_route: bool | None = None,
|
|
77
119
|
can_monitor: bool | None = None,
|
|
78
|
-
broadcast_scope: str | None = None
|
|
120
|
+
broadcast_scope: str | None = None,
|
|
121
|
+
ping_interval: float | None = 30.0,
|
|
122
|
+
ping_timeout: float | None = 10.0,
|
|
123
|
+
close_timeout: float | None = 5.0):
|
|
79
124
|
|
|
80
125
|
self.url = url
|
|
126
|
+
# Client keepalive: a WebSocket ping every `ping_interval` seconds; if
|
|
127
|
+
# the pong takes longer than `ping_timeout` the socket is closed (1011)
|
|
128
|
+
# and the supervisor reconnects. None disables (library default was an
|
|
129
|
+
# implicit 20/20). The relay pings independently.
|
|
130
|
+
self.ping_interval = ping_interval
|
|
131
|
+
self.ping_timeout = ping_timeout
|
|
132
|
+
# How long to wait for the peer's close frame once WE decide to close
|
|
133
|
+
# (e.g. after a pong timeout). A dead peer never answers, so this bounds
|
|
134
|
+
# how long a detected-dead socket lingers before the supervisor redials.
|
|
135
|
+
self.close_timeout = close_timeout
|
|
81
136
|
self.connection = connection
|
|
82
137
|
self.auth_token = auth_token or os.getenv('MESH_AUTH_TOKEN')
|
|
83
138
|
self.channel = channel or os.getenv("MESH_CHANNEL")
|
|
@@ -93,7 +148,10 @@ class MeshSocket:
|
|
|
93
148
|
else:
|
|
94
149
|
self.name = name
|
|
95
150
|
|
|
96
|
-
|
|
151
|
+
# `id` is a property: the server freezes it once the client is registered
|
|
152
|
+
# (see `freeze_id`) so no later frame can move a node in the routing table.
|
|
153
|
+
self._id = str(uuid.uuid4())
|
|
154
|
+
self._id_frozen = False
|
|
97
155
|
|
|
98
156
|
# Callbacks
|
|
99
157
|
self.on_reconnect_callback = on_reconnect
|
|
@@ -130,10 +188,34 @@ class MeshSocket:
|
|
|
130
188
|
self.handlers: Dict[str, Callable] = {}
|
|
131
189
|
self._pending_requests: Dict[str, asyncio.Future] = {}
|
|
132
190
|
|
|
133
|
-
|
|
191
|
+
# Optional pre-dispatch gate: `authorize(msg_type) -> bool`. The server
|
|
192
|
+
# installs one per connection so unidentified sockets can reach nothing
|
|
193
|
+
# but `identify`; None means every registered handler is reachable.
|
|
194
|
+
self.authorize: Optional[Callable[[str], bool]] = None
|
|
195
|
+
|
|
196
|
+
# `ping` is answered in both modes. The rest are CLIENT-mode handlers:
|
|
197
|
+
# a server-side node must never let its peer rewrite its id via
|
|
198
|
+
# `welcome`, nor answer `handshake`/`status_request` before the server's
|
|
199
|
+
# own auth gate has admitted the socket.
|
|
134
200
|
self.on("ping", self._handle_ping)
|
|
135
|
-
|
|
136
|
-
|
|
201
|
+
if connection is None:
|
|
202
|
+
self.on("handshake", self._handle_handshake)
|
|
203
|
+
self.on("status_request", self._handle_status_request)
|
|
204
|
+
self.on("welcome", self._handle_welcome)
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def id(self) -> str:
|
|
208
|
+
return self._id
|
|
209
|
+
|
|
210
|
+
@id.setter
|
|
211
|
+
def id(self, value: str):
|
|
212
|
+
if self._id_frozen:
|
|
213
|
+
raise AttributeError("MeshSocket.id is immutable once registered")
|
|
214
|
+
self._id = value
|
|
215
|
+
|
|
216
|
+
def freeze_id(self):
|
|
217
|
+
"""Make `id` read-only. The server calls this right after registration."""
|
|
218
|
+
self._id_frozen = True
|
|
137
219
|
|
|
138
220
|
@property
|
|
139
221
|
def connected_event(self) -> asyncio.Event:
|
|
@@ -145,7 +227,25 @@ class MeshSocket:
|
|
|
145
227
|
return self._connected_event
|
|
146
228
|
|
|
147
229
|
def __str__(self):
|
|
148
|
-
return f"{self.name}, {self.
|
|
230
|
+
return f"{self.name}, {self.safe_url}"
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def safe_url(self) -> Optional[str]:
|
|
234
|
+
"""scheme://host[:port] only — never the path or query, which may carry a
|
|
235
|
+
token, and which therefore must never reach a log line."""
|
|
236
|
+
if not self.url:
|
|
237
|
+
return self.url
|
|
238
|
+
try:
|
|
239
|
+
parts = urlsplit(self.url)
|
|
240
|
+
except ValueError:
|
|
241
|
+
return "<unparseable url>"
|
|
242
|
+
if not parts.scheme or not parts.hostname:
|
|
243
|
+
return "<unparseable url>"
|
|
244
|
+
host = parts.hostname
|
|
245
|
+
if ":" in host:
|
|
246
|
+
host = f"[{host}]"
|
|
247
|
+
port = f":{parts.port}" if parts.port else ""
|
|
248
|
+
return f"{parts.scheme}://{host}{port}"
|
|
149
249
|
|
|
150
250
|
def on(self, type: str, func: Callable = None):
|
|
151
251
|
if func is None:
|
|
@@ -271,15 +371,20 @@ class MeshSocket:
|
|
|
271
371
|
listen_task = None
|
|
272
372
|
admitted = False
|
|
273
373
|
try:
|
|
274
|
-
logging.info(f"{LogColors.BLUE}{self.name} connecting to {self.
|
|
374
|
+
logging.info(f"{LogColors.BLUE}{self.name} connecting to {self.safe_url}...{LogColors.ENDC}")
|
|
275
375
|
ssl_context = ssl.create_default_context(cafile=certifi.where()) if self.url.startswith("wss://") else None
|
|
276
|
-
async with websockets.connect(self.url, ssl=ssl_context
|
|
376
|
+
async with websockets.connect(self.url, ssl=ssl_context,
|
|
377
|
+
ping_interval=self.ping_interval,
|
|
378
|
+
ping_timeout=self.ping_timeout,
|
|
379
|
+
close_timeout=self.close_timeout) as ws:
|
|
277
380
|
self.connection = ws
|
|
278
381
|
retry_delay = 2
|
|
279
382
|
|
|
280
383
|
# Arm the admission gate before identify so a welcome delivered
|
|
281
|
-
# by the concurrently-running listen loop can't be missed.
|
|
384
|
+
# by the concurrently-running listen loop can't be missed. Each
|
|
385
|
+
# attempt gets a fresh server-assigned id, so unfreeze it here.
|
|
282
386
|
self._admitted_event = asyncio.Event()
|
|
387
|
+
self._id_frozen = False
|
|
283
388
|
|
|
284
389
|
# Send identify directly on the socket: connected_event is not
|
|
285
390
|
# set yet, so the normal send() path would buffer or raise.
|
|
@@ -361,8 +466,9 @@ class MeshSocket:
|
|
|
361
466
|
return False
|
|
362
467
|
|
|
363
468
|
# --- SEND LOGIC ---
|
|
364
|
-
async def send(self, type: str, payload: Any = None, reply_to: str = None
|
|
365
|
-
|
|
469
|
+
async def send(self, type: str, payload: Any = None, reply_to: str = None,
|
|
470
|
+
msg_id: str = None) -> Optional[str]:
|
|
471
|
+
msg_id = msg_id or str(uuid.uuid4())
|
|
366
472
|
packet = {"id": msg_id, "type": type, "payload": payload, "reply_to": reply_to}
|
|
367
473
|
packet_str = json.dumps(packet)
|
|
368
474
|
|
|
@@ -409,18 +515,46 @@ class MeshSocket:
|
|
|
409
515
|
await loop.run_in_executor(None, self._write_lines_to_file, [packet_str])
|
|
410
516
|
|
|
411
517
|
def _write_lines_to_file(self, lines: List[str]):
|
|
412
|
-
|
|
518
|
+
# Owner-only from the first byte: the buffer holds frames that are
|
|
519
|
+
# replayed verbatim onto an authenticated socket.
|
|
520
|
+
fd = os.open(self.offline_file_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
521
|
+
with os.fdopen(fd, "a") as f:
|
|
413
522
|
for line in lines:
|
|
414
523
|
f.write(line + "\n")
|
|
524
|
+
try:
|
|
525
|
+
os.chmod(self.offline_file_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
526
|
+
except OSError:
|
|
527
|
+
pass
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def _valid_buffered_frame(line: str) -> bool:
|
|
531
|
+
"""A replayable line is one JSON object with a string `type` (a frame this
|
|
532
|
+
client itself produced). Anything else was not written by us."""
|
|
533
|
+
try:
|
|
534
|
+
frame = json.loads(line)
|
|
535
|
+
except (ValueError, TypeError):
|
|
536
|
+
return False
|
|
537
|
+
return (isinstance(frame, dict) and isinstance(frame.get("type"), str)
|
|
538
|
+
and isinstance(frame.get("id"), str)
|
|
539
|
+
and frame.get("type") not in ("identify", "welcome"))
|
|
415
540
|
|
|
416
541
|
async def _flush_offline_queue(self):
|
|
417
542
|
if self.offline_file_path and os.path.exists(self.offline_file_path):
|
|
418
543
|
logging.info(f"{LogColors.WARNING}Flushing disk buffer...{LogColors.ENDC}")
|
|
419
544
|
try:
|
|
545
|
+
dropped = 0
|
|
420
546
|
with open(self.offline_file_path, "r") as f:
|
|
421
547
|
for line in f:
|
|
422
|
-
|
|
423
|
-
|
|
548
|
+
line = line.strip()
|
|
549
|
+
if not line:
|
|
550
|
+
continue
|
|
551
|
+
if not self._valid_buffered_frame(line):
|
|
552
|
+
dropped += 1
|
|
553
|
+
continue
|
|
554
|
+
await self.connection.send(line)
|
|
555
|
+
if dropped:
|
|
556
|
+
logging.warning(f"{LogColors.WARNING}Dropped {dropped} invalid line(s) from the "
|
|
557
|
+
f"disk buffer (not frames this client wrote){LogColors.ENDC}")
|
|
424
558
|
os.remove(self.offline_file_path)
|
|
425
559
|
except Exception as e:
|
|
426
560
|
logging.error(f"Failed to flush disk buffer: {e}")
|
|
@@ -436,16 +570,24 @@ class MeshSocket:
|
|
|
436
570
|
logging.warning(f"Request '{type}' waiting for connection...")
|
|
437
571
|
await self.connected_event.wait()
|
|
438
572
|
|
|
439
|
-
|
|
573
|
+
# Register the pending id BEFORE the frame leaves, so a reply that
|
|
574
|
+
# arrives faster than we can get back here is matched, not dropped.
|
|
575
|
+
msg_id = str(uuid.uuid4())
|
|
440
576
|
loop = asyncio.get_running_loop()
|
|
441
577
|
future = loop.create_future()
|
|
442
578
|
self._pending_requests[msg_id] = future
|
|
579
|
+
try:
|
|
580
|
+
await self.send(type, payload, msg_id=msg_id)
|
|
581
|
+
except BaseException:
|
|
582
|
+
self._pending_requests.pop(msg_id, None)
|
|
583
|
+
raise
|
|
443
584
|
try:
|
|
444
585
|
return await asyncio.wait_for(future, timeout=timeout)
|
|
445
586
|
except asyncio.TimeoutError:
|
|
446
587
|
self._pending_requests.pop(msg_id, None)
|
|
447
588
|
return None
|
|
448
589
|
except asyncio.CancelledError:
|
|
590
|
+
self._pending_requests.pop(msg_id, None)
|
|
449
591
|
return None
|
|
450
592
|
|
|
451
593
|
def _fail_all_pending_requests(self):
|
|
@@ -453,7 +595,20 @@ class MeshSocket:
|
|
|
453
595
|
if not future.done(): future.cancel()
|
|
454
596
|
self._pending_requests.clear()
|
|
455
597
|
|
|
598
|
+
# Inbound handler tasks in flight per connection. When all slots are busy
|
|
599
|
+
# the read loop waits, which lets the websocket's own buffer apply
|
|
600
|
+
# backpressure to the peer instead of spawning unbounded tasks.
|
|
601
|
+
MAX_INFLIGHT = 64
|
|
602
|
+
|
|
456
603
|
async def _listen_loop(self):
|
|
604
|
+
inflight = asyncio.Semaphore(self.MAX_INFLIGHT)
|
|
605
|
+
|
|
606
|
+
async def run(packet):
|
|
607
|
+
try:
|
|
608
|
+
await self._process_packet(packet)
|
|
609
|
+
finally:
|
|
610
|
+
inflight.release()
|
|
611
|
+
|
|
457
612
|
async for message in self.connection:
|
|
458
613
|
if self.rate_limit > 0:
|
|
459
614
|
now = time.time()
|
|
@@ -465,19 +620,31 @@ class MeshSocket:
|
|
|
465
620
|
|
|
466
621
|
try:
|
|
467
622
|
data = json.loads(message)
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
623
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
624
|
+
continue
|
|
625
|
+
await inflight.acquire()
|
|
626
|
+
asyncio.create_task(run(data))
|
|
471
627
|
|
|
472
628
|
async def _process_packet(self, packet: dict):
|
|
629
|
+
if not isinstance(packet, dict):
|
|
630
|
+
return
|
|
473
631
|
msg_id = packet.get('id')
|
|
474
632
|
msg_type = packet.get('type')
|
|
475
633
|
payload = packet.get('payload')
|
|
476
634
|
reply_to = packet.get('reply_to')
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
635
|
+
# Single pre-dispatch gate (server installs it per connection): nothing
|
|
636
|
+
# — not replies, not `ping`, not `node_status` — is processed for a
|
|
637
|
+
# socket the gate has not admitted.
|
|
638
|
+
if self.authorize is not None and not self.authorize(msg_type):
|
|
639
|
+
return
|
|
640
|
+
if reply_to:
|
|
641
|
+
# A reply is only ever a reply. One that matches no pending request
|
|
642
|
+
# (late, duplicate, or forged) is dropped — never re-dispatched as
|
|
643
|
+
# a fresh request of its `type`.
|
|
644
|
+
future = self._pending_requests.pop(reply_to, None)
|
|
645
|
+
if future is not None and not future.done():
|
|
646
|
+
future.set_result(payload)
|
|
647
|
+
return
|
|
481
648
|
if msg_type in self.handlers:
|
|
482
649
|
try:
|
|
483
650
|
response = await self.handlers[msg_type](payload)
|
|
@@ -487,13 +654,16 @@ class MeshSocket:
|
|
|
487
654
|
logging.error(f"Error processing {msg_type}: {e}")
|
|
488
655
|
|
|
489
656
|
async def _handle_welcome(self, payload):
|
|
490
|
-
|
|
491
|
-
|
|
657
|
+
# Client mode only: a server-side node never accepts a peer's `welcome`
|
|
658
|
+
# (the handler is not registered there, and this guard is belt-and-braces).
|
|
659
|
+
if self._admitted_event is None:
|
|
660
|
+
return
|
|
661
|
+
server_id = payload.get("id") if isinstance(payload, dict) else None
|
|
662
|
+
if isinstance(server_id, str) and server_id and not self._id_frozen:
|
|
492
663
|
logging.info(f"{self.name} assigned server ID: {server_id}")
|
|
493
664
|
self.id = server_id
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
self._admitted_event.set()
|
|
665
|
+
self.freeze_id()
|
|
666
|
+
self._admitted_event.set()
|
|
497
667
|
|
|
498
668
|
async def _handle_handshake(self, payload):
|
|
499
669
|
t_remote = float(payload.get('t'))
|