meshsocket 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carter Beaudoin
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,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: meshsocket
3
+ Version: 0.1.0
4
+ Summary: Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol.
5
+ Author: Carter Beaudoin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Mariner10/MeshSocket
8
+ Project-URL: Repository, https://github.com/Mariner10/MeshSocket
9
+ Keywords: websocket,mesh,networking,rpc,pubsub,asyncio
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: System :: Networking
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Operating System :: OS Independent
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: websockets>=12
21
+ Requires-Dist: certifi
22
+ Dynamic: license-file
23
+
24
+ # MeshSocket (Python)
25
+
26
+ A lightweight WebSocket **mesh networking** library. Nodes connect to a central relay and
27
+ communicate by **broadcast**, **direct routing**, and **request/response** — all over a
28
+ simple JSON wire protocol.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install meshsocket
34
+ ```
35
+
36
+ ## Quickstart (client)
37
+
38
+ ```python
39
+ import asyncio
40
+ from meshsocket import MeshSocket
41
+
42
+ async def main():
43
+ sock = MeshSocket(url="ws://localhost:8765", name="my-node",
44
+ channel="demo", role="device", can_broadcast=True)
45
+
46
+ async def on_toggle(payload):
47
+ print("got", payload)
48
+ return {"ok": True} # a returned value is delivered as the reply
49
+
50
+ sock.on("toggle", on_toggle)
51
+
52
+ await sock.start() # connect + identify
53
+ await sock.wait_until_ready()
54
+ await sock.send("reading", {"temp_c": 21.4}) # broadcast to the channel
55
+ await asyncio.sleep(60)
56
+ await sock.stop()
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ ## Wire protocol
62
+
63
+ Every message is a JSON object:
64
+
65
+ ```json
66
+ { "id": "uuid", "type": "message_type", "payload": "<any JSON or null>", "reply_to": "uuid or null" }
67
+ ```
68
+
69
+ If a handler returns a value, the receiver sends a reply with `reply_to` set to the
70
+ original `id`, enabling request/response round-trips.
71
+
72
+ ## Compatibility
73
+
74
+ `from meshsocket import MeshSocket` is the supported import. The historical
75
+ `from socketCore import MeshSocket` still works (that module is published alongside the
76
+ package) so existing code keeps running while it migrates.
77
+
78
+ ## Other language clients
79
+
80
+ Swift (SPM, zero-dependency) and a planned JavaScript client live in the same repo:
81
+ <https://github.com/Mariner10/MeshSocket>
@@ -0,0 +1,58 @@
1
+ # MeshSocket (Python)
2
+
3
+ A lightweight WebSocket **mesh networking** library. Nodes connect to a central relay and
4
+ communicate by **broadcast**, **direct routing**, and **request/response** — all over a
5
+ simple JSON wire protocol.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install meshsocket
11
+ ```
12
+
13
+ ## Quickstart (client)
14
+
15
+ ```python
16
+ import asyncio
17
+ from meshsocket import MeshSocket
18
+
19
+ async def main():
20
+ sock = MeshSocket(url="ws://localhost:8765", name="my-node",
21
+ channel="demo", role="device", can_broadcast=True)
22
+
23
+ async def on_toggle(payload):
24
+ print("got", payload)
25
+ return {"ok": True} # a returned value is delivered as the reply
26
+
27
+ sock.on("toggle", on_toggle)
28
+
29
+ await sock.start() # connect + identify
30
+ await sock.wait_until_ready()
31
+ await sock.send("reading", {"temp_c": 21.4}) # broadcast to the channel
32
+ await asyncio.sleep(60)
33
+ await sock.stop()
34
+
35
+ asyncio.run(main())
36
+ ```
37
+
38
+ ## Wire protocol
39
+
40
+ Every message is a JSON object:
41
+
42
+ ```json
43
+ { "id": "uuid", "type": "message_type", "payload": "<any JSON or null>", "reply_to": "uuid or null" }
44
+ ```
45
+
46
+ If a handler returns a value, the receiver sends a reply with `reply_to` set to the
47
+ original `id`, enabling request/response round-trips.
48
+
49
+ ## Compatibility
50
+
51
+ `from meshsocket import MeshSocket` is the supported import. The historical
52
+ `from socketCore import MeshSocket` still works (that module is published alongside the
53
+ package) so existing code keeps running while it migrates.
54
+
55
+ ## Other language clients
56
+
57
+ Swift (SPM, zero-dependency) and a planned JavaScript client live in the same repo:
58
+ <https://github.com/Mariner10/MeshSocket>
@@ -0,0 +1,10 @@
1
+ """MeshSocket — lightweight WebSocket mesh networking.
2
+
3
+ `from meshsocket import MeshSocket` is the supported public import. The historical
4
+ `from socketCore import MeshSocket` continues to work (the module is published alongside)
5
+ so existing code keeps running while it migrates to the package name.
6
+ """
7
+ from socketCore import MeshSocket, LogColors
8
+
9
+ __all__ = ["MeshSocket", "LogColors"]
10
+ __version__ = "0.1.0"
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: meshsocket
3
+ Version: 0.1.0
4
+ Summary: Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol.
5
+ Author: Carter Beaudoin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Mariner10/MeshSocket
8
+ Project-URL: Repository, https://github.com/Mariner10/MeshSocket
9
+ Keywords: websocket,mesh,networking,rpc,pubsub,asyncio
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: System :: Networking
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Operating System :: OS Independent
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: websockets>=12
21
+ Requires-Dist: certifi
22
+ Dynamic: license-file
23
+
24
+ # MeshSocket (Python)
25
+
26
+ A lightweight WebSocket **mesh networking** library. Nodes connect to a central relay and
27
+ communicate by **broadcast**, **direct routing**, and **request/response** — all over a
28
+ simple JSON wire protocol.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install meshsocket
34
+ ```
35
+
36
+ ## Quickstart (client)
37
+
38
+ ```python
39
+ import asyncio
40
+ from meshsocket import MeshSocket
41
+
42
+ async def main():
43
+ sock = MeshSocket(url="ws://localhost:8765", name="my-node",
44
+ channel="demo", role="device", can_broadcast=True)
45
+
46
+ async def on_toggle(payload):
47
+ print("got", payload)
48
+ return {"ok": True} # a returned value is delivered as the reply
49
+
50
+ sock.on("toggle", on_toggle)
51
+
52
+ await sock.start() # connect + identify
53
+ await sock.wait_until_ready()
54
+ await sock.send("reading", {"temp_c": 21.4}) # broadcast to the channel
55
+ await asyncio.sleep(60)
56
+ await sock.stop()
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ ## Wire protocol
62
+
63
+ Every message is a JSON object:
64
+
65
+ ```json
66
+ { "id": "uuid", "type": "message_type", "payload": "<any JSON or null>", "reply_to": "uuid or null" }
67
+ ```
68
+
69
+ If a handler returns a value, the receiver sends a reply with `reply_to` set to the
70
+ original `id`, enabling request/response round-trips.
71
+
72
+ ## Compatibility
73
+
74
+ `from meshsocket import MeshSocket` is the supported import. The historical
75
+ `from socketCore import MeshSocket` still works (that module is published alongside the
76
+ package) so existing code keeps running while it migrates.
77
+
78
+ ## Other language clients
79
+
80
+ Swift (SPM, zero-dependency) and a planned JavaScript client live in the same repo:
81
+ <https://github.com/Mariner10/MeshSocket>
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ socketCore.py
5
+ socket_server.py
6
+ meshsocket/__init__.py
7
+ meshsocket.egg-info/PKG-INFO
8
+ meshsocket.egg-info/SOURCES.txt
9
+ meshsocket.egg-info/dependency_links.txt
10
+ meshsocket.egg-info/requires.txt
11
+ meshsocket.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ websockets>=12
2
+ certifi
@@ -0,0 +1,3 @@
1
+ meshsocket
2
+ socketCore
3
+ socket_server
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "meshsocket"
7
+ version = "0.1.0"
8
+ description = "Lightweight WebSocket mesh networking — broadcast, direct routing, and request/response over a simple JSON wire protocol."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Carter Beaudoin" }]
12
+ keywords = ["websocket", "mesh", "networking", "rpc", "pubsub", "asyncio"]
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ dependencies = [
16
+ "websockets>=12",
17
+ "certifi",
18
+ ]
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Intended Audience :: Developers",
23
+ "Topic :: System :: Networking",
24
+ "Framework :: AsyncIO",
25
+ "Development Status :: 4 - Beta",
26
+ "Operating System :: OS Independent",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/Mariner10/MeshSocket"
31
+ Repository = "https://github.com/Mariner10/MeshSocket"
32
+
33
+ # Explicit layout (disables flat-layout auto-discovery, which would trip over the
34
+ # test files and the empty package __init__ in this directory).
35
+ [tool.setuptools]
36
+ py-modules = ["socketCore", "socket_server"]
37
+ packages = ["meshsocket"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,473 @@
1
+ import asyncio
2
+ import certifi
3
+ import inspect
4
+ import json
5
+ import ssl
6
+ import uuid
7
+ import time
8
+ import logging
9
+ import os
10
+ import websockets
11
+ from collections import deque
12
+ from typing import Callable, Dict, Any, List, Optional
13
+
14
+ # ANSI Colors for nicer logs
15
+ class LogColors:
16
+ HEADER = '\033[95m'
17
+ BLUE = '\033[94m'
18
+ GREEN = '\033[92m'
19
+ WARNING = '\033[93m'
20
+ FAIL = '\033[91m'
21
+ ENDC = '\033[0m'
22
+
23
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
24
+
25
+ class SocketLoggingHandler(logging.Handler):
26
+ def __init__(self, socket: 'MeshSocket'):
27
+ super().__init__()
28
+ self.socket = socket
29
+ self._is_logging = False # To prevent recursion
30
+
31
+ def emit(self, record):
32
+ if not self.socket.is_running or self._is_logging:
33
+ return
34
+
35
+ log_entry = self.format(record)
36
+ # We use asyncio.create_task because emit is called from non-async contexts
37
+ try:
38
+ loop = asyncio.get_running_loop()
39
+
40
+ async def send_log():
41
+ if self._is_logging: return
42
+ self._is_logging = True
43
+ try:
44
+ await self.socket.send("service_log", {
45
+ "level": record.levelname,
46
+ "msg": log_entry,
47
+ "name": self.socket.name,
48
+ "timestamp": record.created
49
+ })
50
+ except Exception:
51
+ # Silently ignore errors in the logging handler to avoid loops
52
+ pass
53
+ finally:
54
+ self._is_logging = False
55
+
56
+ loop.create_task(send_log())
57
+ except RuntimeError:
58
+ # No running event loop
59
+ pass
60
+
61
+ class MeshSocket:
62
+ def __init__(self,
63
+ url: str = None,
64
+ connection=None,
65
+ name="Node",
66
+ max_offline_buffer: int = 0,
67
+ offline_file_path: str = None,
68
+ on_reconnect: Callable = None,
69
+ on_disconnect: Callable = None,
70
+ auth_token: str = None,
71
+ rate_limit: int = 0,
72
+ channel: str = None,
73
+ role: str = None,
74
+ can_broadcast: bool | None = None,
75
+ can_route: bool | None = None,
76
+ can_cross_channel_route: bool | None = None,
77
+ can_monitor: bool | None = None,
78
+ broadcast_scope: str | None = None):
79
+
80
+ self.url = url
81
+ self.connection = connection
82
+ self.auth_token = auth_token or os.getenv('MESH_AUTH_TOKEN')
83
+ self.channel = channel or os.getenv("MESH_CHANNEL")
84
+ self.role = role or os.getenv("MESH_ROLE")
85
+ self.can_broadcast = can_broadcast
86
+ self.can_route = can_route
87
+ self.can_cross_channel_route = can_cross_channel_route
88
+ self.can_monitor = can_monitor
89
+ self.broadcast_scope = broadcast_scope or os.getenv("MESH_BROADCAST_SCOPE")
90
+
91
+ if name == "Node":
92
+ self.name = os.getenv('CONTAINER_NAME') or os.getenv('HOSTNAME') or "Node"
93
+ else:
94
+ self.name = name
95
+
96
+ self.id = str(uuid.uuid4())
97
+
98
+ # Callbacks
99
+ self.on_reconnect_callback = on_reconnect
100
+ self.on_disconnect_callback = on_disconnect
101
+
102
+ # Buffering state
103
+ self.max_offline_buffer = max_offline_buffer
104
+ self.offline_file_path = offline_file_path
105
+ self._ram_buffer: deque = deque()
106
+
107
+ # Rate limiting — track timestamps of last `rate_limit` messages
108
+ self.rate_limit = rate_limit
109
+ self._msg_times: Optional[deque] = deque(maxlen=rate_limit) if rate_limit > 0 else None
110
+
111
+ # State
112
+ self.is_running = False
113
+
114
+ # Lazy-initialised inside the event loop to avoid DeprecationWarning
115
+ # in Python 3.10+ when constructing outside an async context.
116
+ self._connected_event: Optional[asyncio.Event] = None
117
+ self._server_mode_connected: bool = connection is not None
118
+
119
+ # Admission gate (client mode): set when the server's `welcome` confirms
120
+ # our identify was accepted, so readiness/on_reconnect don't fire for a
121
+ # rejected identify (e.g. a duplicate name) that the server silently closes.
122
+ # Stays None in server mode.
123
+ self._admitted_event: Optional[asyncio.Event] = None
124
+
125
+ # Handlers
126
+ self.handlers: Dict[str, Callable] = {}
127
+ self._pending_requests: Dict[str, asyncio.Future] = {}
128
+
129
+ self.on("handshake", self._handle_handshake)
130
+ self.on("ping", self._handle_ping)
131
+ self.on("status_request", self._handle_status_request)
132
+ self.on("welcome", self._handle_welcome)
133
+
134
+ @property
135
+ def connected_event(self) -> asyncio.Event:
136
+ if self._connected_event is None:
137
+ self._connected_event = asyncio.Event()
138
+ if self._server_mode_connected:
139
+ self._connected_event.set()
140
+ self._server_mode_connected = False
141
+ return self._connected_event
142
+
143
+ def __str__(self):
144
+ return f"{self.name}, {self.url}"
145
+
146
+ def on(self, type: str, func: Callable = None):
147
+ if func is None:
148
+ def wrapper(f):
149
+ self.handlers[type] = f
150
+ return f
151
+ return wrapper
152
+ self.handlers[type] = func
153
+ return func
154
+
155
+ def off(self, type: str):
156
+ self.handlers.pop(type, None)
157
+
158
+ def setup_logging(self, level=logging.INFO):
159
+ """Attaches a SocketLoggingHandler to the root logger."""
160
+ handler = SocketLoggingHandler(self)
161
+ handler.setLevel(level)
162
+ formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
163
+ handler.setFormatter(formatter)
164
+ logging.getLogger().addHandler(handler)
165
+ logging.info(f"Socket logging enabled for {self.name}")
166
+
167
+ def _build_identity_payload(self) -> Dict[str, Any]:
168
+ payload: Dict[str, Any] = {
169
+ "name": self.name,
170
+ "token": self.auth_token
171
+ }
172
+ if self.channel:
173
+ payload["channel"] = self.channel
174
+ if self.role:
175
+ payload["role"] = self.role
176
+ if self.can_broadcast is not None:
177
+ payload["can_broadcast"] = self.can_broadcast
178
+ if self.can_route is not None:
179
+ payload["can_route"] = self.can_route
180
+ if self.can_cross_channel_route is not None:
181
+ payload["can_cross_channel_route"] = self.can_cross_channel_route
182
+ if self.can_monitor is not None:
183
+ payload["can_monitor"] = self.can_monitor
184
+ if self.broadcast_scope:
185
+ payload["broadcast_scope"] = self.broadcast_scope
186
+ return payload
187
+
188
+ async def report_status(self, metrics: Dict[str, Any] = None):
189
+ """Broadcasts the current status/metrics of this node."""
190
+ status_payload = {
191
+ "name": self.name,
192
+ "id": self.id,
193
+ "status": "online",
194
+ "uptime": time.time() - self._start_time if hasattr(self, '_start_time') else 0,
195
+ "metrics": metrics or {}
196
+ }
197
+ await self.send("node_status", status_payload)
198
+
199
+ # --- CLIENT MODE METHODS ---
200
+ async def start(self):
201
+ if not self.url:
202
+ raise ValueError("Cannot use start() without a URL.")
203
+ self._start_time = time.time()
204
+ self.is_running = True
205
+ asyncio.create_task(self._maintain_connection())
206
+
207
+ async def wait_until_ready(self):
208
+ await self.connected_event.wait()
209
+
210
+ async def stop(self):
211
+ self.is_running = False
212
+ if self.connection:
213
+ await self.connection.close()
214
+
215
+ # --- SERVER MODE METHODS ---
216
+ async def listen(self):
217
+ if not self.connection:
218
+ raise ValueError("Server mode requires an existing connection.")
219
+ self._start_time = time.time()
220
+ try:
221
+ await self._listen_loop()
222
+ except websockets.exceptions.ConnectionClosed:
223
+ logging.warning(f"{self.name} connection closed normally.")
224
+ except Exception as e:
225
+ logging.error(f"{self.name} listen error: {e}")
226
+ raise e
227
+
228
+ # --- INTERNAL CORE ---
229
+ async def _maintain_connection(self):
230
+ retry_delay = 2
231
+ while self.is_running:
232
+ listen_task = None
233
+ admitted = False
234
+ try:
235
+ logging.info(f"{LogColors.BLUE}{self.name} connecting to {self.url}...{LogColors.ENDC}")
236
+ ssl_context = ssl.create_default_context(cafile=certifi.where()) if self.url.startswith("wss://") else None
237
+ async with websockets.connect(self.url, ssl=ssl_context) as ws:
238
+ self.connection = ws
239
+ retry_delay = 2
240
+
241
+ # Arm the admission gate before identify so a welcome delivered
242
+ # by the concurrently-running listen loop can't be missed.
243
+ self._admitted_event = asyncio.Event()
244
+
245
+ # Send identify directly on the socket: connected_event is not
246
+ # set yet, so the normal send() path would buffer or raise.
247
+ identify_packet = json.dumps({
248
+ "id": str(uuid.uuid4()), "type": "identify",
249
+ "payload": self._build_identity_payload(), "reply_to": None,
250
+ })
251
+ await ws.send(identify_packet)
252
+
253
+ # Pump messages concurrently so the server's welcome — or a close
254
+ # that rejects the identify (e.g. a duplicate name) — is observed
255
+ # while we wait for admission.
256
+ listen_task = asyncio.create_task(self._listen_loop())
257
+ admitted = await self._await_admission(listen_task, timeout=10)
258
+ if not admitted:
259
+ # Never admitted: surface as a failed attempt and retry with
260
+ # backoff instead of reporting the connection as up.
261
+ listen_task.cancel()
262
+ raise ConnectionError(
263
+ "identify not admitted (no welcome) — likely a duplicate name or rejected token"
264
+ )
265
+
266
+ self.connected_event.set()
267
+ logging.info(f"{LogColors.GREEN}{self.name} Connected!{LogColors.ENDC}")
268
+
269
+ await self._flush_offline_queue()
270
+
271
+ if self.on_reconnect_callback:
272
+ try:
273
+ if inspect.iscoroutinefunction(self.on_reconnect_callback):
274
+ await self.on_reconnect_callback()
275
+ else:
276
+ self.on_reconnect_callback()
277
+ except Exception as e:
278
+ logging.error(f"Error in on_reconnect callback: {e}")
279
+
280
+ await listen_task
281
+
282
+ except Exception as e:
283
+ # Catch everything (not just OSError/ConnectionClosed): a server
284
+ # restart behind Cloudflare surfaces as InvalidStatus (HTTP 521),
285
+ # which previously escaped and killed this task for good.
286
+ logging.warning(f"{LogColors.WARNING}{self.name} disconnected: {e}{LogColors.ENDC}")
287
+
288
+ finally:
289
+ if listen_task and not listen_task.done():
290
+ listen_task.cancel()
291
+ self.connected_event.clear()
292
+ self.connection = None
293
+ self._fail_all_pending_requests()
294
+ # Pair on_disconnect to the admitted up-edge: an unadmitted attempt
295
+ # never reported connected, so it must not report a disconnect.
296
+ if admitted and self.on_disconnect_callback:
297
+ try:
298
+ if inspect.iscoroutinefunction(self.on_disconnect_callback):
299
+ await self.on_disconnect_callback()
300
+ else:
301
+ self.on_disconnect_callback()
302
+ except Exception as err:
303
+ logging.error(f"Error in on_disconnect callback: {err}")
304
+
305
+ if self.is_running:
306
+ logging.info(f"{self.name} retrying in {retry_delay}s...")
307
+ await asyncio.sleep(retry_delay)
308
+ retry_delay = min(retry_delay * 2, 30)
309
+
310
+ async def _await_admission(self, listen_task: asyncio.Task, timeout: float) -> bool:
311
+ """Wait until the server's welcome admits this connection, or the socket
312
+ closes / times out first. Returns True only on a real admission."""
313
+ admit_wait = asyncio.ensure_future(self._admitted_event.wait())
314
+ done, _ = await asyncio.wait(
315
+ {admit_wait, listen_task},
316
+ timeout=timeout,
317
+ return_when=asyncio.FIRST_COMPLETED,
318
+ )
319
+ if admit_wait in done:
320
+ return True
321
+ admit_wait.cancel()
322
+ return False
323
+
324
+ # --- SEND LOGIC ---
325
+ async def send(self, type: str, payload: Any = None, reply_to: str = None) -> Optional[str]:
326
+ msg_id = str(uuid.uuid4())
327
+ packet = {"id": msg_id, "type": type, "payload": payload, "reply_to": reply_to}
328
+ packet_str = json.dumps(packet)
329
+
330
+ # CASE A: Connected - Send immediately
331
+ if self.connection and self.connected_event.is_set():
332
+ try:
333
+ await self.connection.send(packet_str)
334
+ return msg_id
335
+ except websockets.exceptions.ConnectionClosed:
336
+ logging.warning(f"Send failed mid-flight, attempting to buffer '{type}'")
337
+
338
+ # CASE B: Disconnected & Buffering Enabled
339
+ if self.max_offline_buffer > 0:
340
+ await self._handle_offline_buffering(packet_str)
341
+ return msg_id # Return ID even if buffered
342
+
343
+ # CASE C: Disconnected & No Buffering
344
+ raise ConnectionError("Socket is not connected and buffering is disabled.")
345
+
346
+ async def emit(self, type: str, payload: Any = None, reply_to: str = None) -> Optional[str]:
347
+ return await self.send(type, payload, reply_to)
348
+
349
+ async def _handle_offline_buffering(self, packet_str: str):
350
+ if len(self._ram_buffer) >= self.max_offline_buffer:
351
+ if self.offline_file_path:
352
+ await self._dump_ram_to_disk()
353
+ await self._append_to_disk(packet_str)
354
+ else:
355
+ self._ram_buffer.popleft()
356
+ self._ram_buffer.append(packet_str)
357
+ logging.warning("Buffer full (no file path). Dropped oldest message.")
358
+ else:
359
+ self._ram_buffer.append(packet_str)
360
+
361
+ async def _dump_ram_to_disk(self):
362
+ if not self._ram_buffer:
363
+ return
364
+ loop = asyncio.get_running_loop()
365
+ await loop.run_in_executor(None, self._write_lines_to_file, list(self._ram_buffer))
366
+ self._ram_buffer.clear()
367
+
368
+ async def _append_to_disk(self, packet_str: str):
369
+ loop = asyncio.get_running_loop()
370
+ await loop.run_in_executor(None, self._write_lines_to_file, [packet_str])
371
+
372
+ def _write_lines_to_file(self, lines: List[str]):
373
+ with open(self.offline_file_path, "a") as f:
374
+ for line in lines:
375
+ f.write(line + "\n")
376
+
377
+ async def _flush_offline_queue(self):
378
+ if self.offline_file_path and os.path.exists(self.offline_file_path):
379
+ logging.info(f"{LogColors.WARNING}Flushing disk buffer...{LogColors.ENDC}")
380
+ try:
381
+ with open(self.offline_file_path, "r") as f:
382
+ for line in f:
383
+ if line.strip():
384
+ await self.connection.send(line.strip())
385
+ os.remove(self.offline_file_path)
386
+ except Exception as e:
387
+ logging.error(f"Failed to flush disk buffer: {e}")
388
+
389
+ if self._ram_buffer:
390
+ logging.info(f"{LogColors.WARNING}Flushing RAM buffer ({len(self._ram_buffer)} items)...{LogColors.ENDC}")
391
+ while self._ram_buffer:
392
+ await self.connection.send(self._ram_buffer.popleft())
393
+
394
+ # --- REQUEST / RESPONSE ---
395
+ async def request(self, type: str, payload: Any = None, timeout: float = 5.0):
396
+ if self.url and not self.connected_event.is_set():
397
+ logging.warning(f"Request '{type}' waiting for connection...")
398
+ await self.connected_event.wait()
399
+
400
+ msg_id = await self.send(type, payload)
401
+ loop = asyncio.get_running_loop()
402
+ future = loop.create_future()
403
+ self._pending_requests[msg_id] = future
404
+ try:
405
+ return await asyncio.wait_for(future, timeout=timeout)
406
+ except asyncio.TimeoutError:
407
+ self._pending_requests.pop(msg_id, None)
408
+ return None
409
+ except asyncio.CancelledError:
410
+ return None
411
+
412
+ def _fail_all_pending_requests(self):
413
+ for future in self._pending_requests.values():
414
+ if not future.done(): future.cancel()
415
+ self._pending_requests.clear()
416
+
417
+ async def _listen_loop(self):
418
+ async for message in self.connection:
419
+ if self.rate_limit > 0:
420
+ now = time.time()
421
+ if len(self._msg_times) == self.rate_limit and (now - self._msg_times[0]) < 1.0:
422
+ logging.warning(f"Rate limit exceeded for {self.name}, closing connection.")
423
+ await self.connection.close(1008, "Rate limit exceeded")
424
+ return
425
+ self._msg_times.append(now)
426
+
427
+ try:
428
+ data = json.loads(message)
429
+ asyncio.create_task(self._process_packet(data))
430
+ except json.JSONDecodeError:
431
+ pass
432
+
433
+ async def _process_packet(self, packet: dict):
434
+ msg_id = packet.get('id')
435
+ msg_type = packet.get('type')
436
+ payload = packet.get('payload')
437
+ reply_to = packet.get('reply_to')
438
+ if reply_to and reply_to in self._pending_requests:
439
+ future = self._pending_requests.pop(reply_to)
440
+ if not future.done(): future.set_result(payload)
441
+ return
442
+ if msg_type in self.handlers:
443
+ try:
444
+ response = await self.handlers[msg_type](payload)
445
+ if response is not None:
446
+ await self.send(type=msg_type, payload=response, reply_to=msg_id)
447
+ except Exception as e:
448
+ logging.error(f"Error processing {msg_type}: {e}")
449
+
450
+ async def _handle_welcome(self, payload):
451
+ server_id = payload.get("id")
452
+ if server_id:
453
+ logging.info(f"{self.name} assigned server ID: {server_id}")
454
+ self.id = server_id
455
+ # Admit the connection (client mode). None in server mode.
456
+ if self._admitted_event is not None:
457
+ self._admitted_event.set()
458
+
459
+ async def _handle_handshake(self, payload):
460
+ t_remote = float(payload.get('t'))
461
+ return {"server_id": self.id, "l": time.time() - t_remote}
462
+
463
+ async def _handle_ping(self, _):
464
+ return "pong"
465
+
466
+ async def _handle_status_request(self, _):
467
+ return {
468
+ "name": self.name,
469
+ "id": self.id,
470
+ "status": "online",
471
+ "uptime": time.time() - self._start_time if hasattr(self, '_start_time') else 0,
472
+ "memory_usage": "unknown"
473
+ }
@@ -0,0 +1,370 @@
1
+ import asyncio
2
+ import uuid
3
+ import websockets
4
+ from socketCore import MeshSocket, LogColors
5
+ import logging
6
+ import os
7
+ from typing import Any, Callable, Dict, Optional
8
+
9
+ _MAX_PENDING_PER_IP = 10
10
+ _pending_conns: Dict[str, int] = {}
11
+
12
+
13
+ class MeshServer:
14
+ def __init__(self,
15
+ host: str = "0.0.0.0",
16
+ port: int = 8765,
17
+ rate_limit: int = 0,
18
+ max_size: int = 1_048_576,
19
+ auth_handler: Optional[Callable] = None,
20
+ on_startup: Optional[Callable] = None,
21
+ on_authenticated: Optional[Callable] = None):
22
+ self.host = host
23
+ self.port = port
24
+ self.rate_limit = rate_limit
25
+ self.max_size = max_size
26
+
27
+ # auth_handler(token, remote_ip) → bool
28
+ # Default: compare against MESH_AUTH_TOKEN env var.
29
+ self._auth_handler = auth_handler or self._default_auth
30
+ # on_startup() — called once before the server starts accepting connections.
31
+ self._on_startup = on_startup
32
+ # on_authenticated(client, remote_ip, token) — called after a client passes auth.
33
+ self._on_authenticated = on_authenticated
34
+
35
+ self.clients: Dict[str, MeshSocket] = {}
36
+ self.clients_by_name: Dict[str, MeshSocket] = {}
37
+
38
+ @staticmethod
39
+ def _default_auth(token: str, remote_ip: str) -> bool:
40
+ server_token = os.getenv("MESH_AUTH_TOKEN")
41
+ if not server_token:
42
+ return True
43
+ return token == server_token
44
+
45
+ def _parse_bool(self, value: Any, default: bool = False) -> bool:
46
+ if isinstance(value, bool):
47
+ return value
48
+ if isinstance(value, str):
49
+ return value.strip().lower() in {"1", "true", "yes", "on"}
50
+ return default
51
+
52
+ def _same_channel(self, source: MeshSocket, target: MeshSocket) -> bool:
53
+ return getattr(source, "channel", "default") == getattr(target, "channel", "default")
54
+
55
+ def _can_receive_broadcast(self, source: MeshSocket, target: MeshSocket) -> bool:
56
+ if getattr(source, "broadcast_scope", "channel") == "global":
57
+ return True
58
+ return self._same_channel(source, target)
59
+
60
+ def _client_summary(self, client: MeshSocket) -> Dict[str, str]:
61
+ return {
62
+ "id": client.id,
63
+ "name": client.name,
64
+ "channel": getattr(client, "channel", "default"),
65
+ "role": getattr(client, "role", "node"),
66
+ }
67
+
68
+ def _client_ip(self, websocket) -> str:
69
+ request_headers = getattr(websocket, "request_headers", None)
70
+ if request_headers is None and getattr(websocket, "request", None) is not None:
71
+ request_headers = websocket.request.headers
72
+
73
+ if request_headers:
74
+ x_real_ip = request_headers.get("X-Real-IP")
75
+ if x_real_ip:
76
+ return x_real_ip.strip()
77
+
78
+ x_forwarded_for = request_headers.get("X-Forwarded-For")
79
+ if x_forwarded_for:
80
+ first_hop = x_forwarded_for.split(",", 1)[0].strip()
81
+ if first_hop:
82
+ return first_hop
83
+
84
+ if websocket.remote_address:
85
+ return websocket.remote_address[0]
86
+ return "unknown"
87
+
88
+ async def start(self):
89
+ if self._on_startup:
90
+ self._on_startup()
91
+ logging.info(f"{LogColors.HEADER}Starting Server on ws://{self.host}:{self.port}{LogColors.ENDC}")
92
+ async with websockets.serve(
93
+ self._handle_connection,
94
+ self.host,
95
+ self.port,
96
+ max_size=self.max_size,
97
+ ):
98
+ await asyncio.Future()
99
+
100
+ async def _handle_connection(self, websocket):
101
+ remote_ip = self._client_ip(websocket)
102
+ logging.info(f"New connection from {remote_ip}")
103
+
104
+ pending = _pending_conns.get(remote_ip, 0)
105
+ if pending >= _MAX_PENDING_PER_IP:
106
+ logging.warning(
107
+ f"{LogColors.FAIL}Rejected {remote_ip} — "
108
+ f"{pending} pending connections already{LogColors.ENDC}"
109
+ )
110
+ await websocket.close(4429, "Too many pending connections")
111
+ return
112
+
113
+ _pending_conns[remote_ip] = pending + 1
114
+ try:
115
+ client = MeshSocket(
116
+ connection=websocket,
117
+ name=f"Client-{id(websocket)}",
118
+ rate_limit=self.rate_limit,
119
+ )
120
+ client.channel = "default"
121
+ client.role = "unknown"
122
+ client.can_broadcast = False
123
+ client.can_route = False
124
+ client.can_cross_channel_route = False
125
+ client.can_monitor = False
126
+ client.broadcast_scope = "channel"
127
+
128
+ allowed_origins = {
129
+ origin.strip()
130
+ for origin in os.getenv(
131
+ "MESH_ALLOWED_ORIGINS",
132
+ "http://127.0.0.1,http://localhost"
133
+ ).split(",")
134
+ if origin.strip()
135
+ }
136
+ request_headers = getattr(websocket, "request_headers", None)
137
+ if request_headers is None and getattr(websocket, "request", None) is not None:
138
+ request_headers = websocket.request.headers
139
+ origin = request_headers.get("Origin") if request_headers else None
140
+ if origin and not any(origin == allowed or origin.startswith(allowed) for allowed in allowed_origins):
141
+ logging.warning(f"{LogColors.FAIL}Origin rejected: {origin}{LogColors.ENDC}")
142
+ await websocket.close(1008, "origin not allowed")
143
+ return
144
+
145
+ authenticated: asyncio.Future = asyncio.get_running_loop().create_future()
146
+
147
+ @client.on('identify')
148
+ async def handle_identify(payload):
149
+ payload = payload or {}
150
+ requested_name = payload.get('name', client.name)
151
+ client_token = payload.get('token', '')
152
+
153
+ if not self._auth_handler(client_token, remote_ip):
154
+ logging.warning(
155
+ f"{LogColors.FAIL}Auth failed (bad/missing token) for {remote_ip}{LogColors.ENDC}"
156
+ )
157
+ if not authenticated.done():
158
+ authenticated.set_result(False)
159
+ return
160
+
161
+ incumbent = self.clients_by_name.get(requested_name)
162
+ if incumbent is not None and incumbent is not client:
163
+ # Last-writer-wins: a reconnecting peer reclaims its name. The
164
+ # old socket is usually a half-open ghost the server hasn't
165
+ # reaped yet; rejecting the newcomer instead would leave it
166
+ # silently retrying forever. Drop the incumbent from the roster
167
+ # now (so the newcomer can register cleanly below) and close it
168
+ # out of band. The identity-checked cleanup in the connection
169
+ # finally keeps the ghost's teardown from clobbering this entry.
170
+ logging.warning(
171
+ f"{LogColors.WARNING}Name '{requested_name}' reclaimed by {remote_ip} — "
172
+ f"evicting stale client {incumbent.id}{LogColors.ENDC}"
173
+ )
174
+ self.clients.pop(incumbent.id, None)
175
+ self.clients_by_name.pop(requested_name, None)
176
+ asyncio.create_task(incumbent.stop())
177
+
178
+ new_id = str(uuid.uuid4())
179
+ while new_id in self.clients:
180
+ new_id = str(uuid.uuid4())
181
+
182
+ client.id = new_id
183
+ client.name = requested_name
184
+ client.channel = payload.get('channel') or "default"
185
+ client.role = payload.get('role') or "node"
186
+ client.can_broadcast = self._parse_bool(payload.get('can_broadcast'), client.role in {"dashboard", "browser", "mobile", "node"})
187
+ client.can_route = self._parse_bool(payload.get('can_route'), client.role in {"browser", "mobile", "dashboard", "node"})
188
+ client.can_cross_channel_route = self._parse_bool(payload.get('can_cross_channel_route'), False)
189
+ client.can_monitor = self._parse_bool(payload.get('can_monitor'), client.role in {"dashboard", "browser"})
190
+ client.broadcast_scope = payload.get('broadcast_scope') or ("global" if client.can_monitor else "channel")
191
+
192
+ logging.info(
193
+ f"{LogColors.GREEN}Identified: '{client.name}' → {client.id}{LogColors.ENDC}"
194
+ )
195
+
196
+ if self._on_authenticated:
197
+ self._on_authenticated(client, remote_ip, client_token)
198
+
199
+ if not authenticated.done():
200
+ authenticated.set_result(True)
201
+
202
+ await client.send("welcome", {"id": client.id, "name": client.name})
203
+ await self._broadcast_client_list()
204
+
205
+ # Register handlers BEFORE the listen loop starts so the first
206
+ # request after the identify ack can't race past registration and
207
+ # get dropped. Permission flags stay False until identify, so
208
+ # nothing here is reachable pre-auth.
209
+ @client.on("broadcast_request")
210
+ async def on_broadcast(payload):
211
+ if not client.can_broadcast:
212
+ return {"error": "broadcast not allowed", "status": "failed"}
213
+ await self.broadcast("broadcast", payload, sender=client)
214
+ return {"status": "sent"}
215
+
216
+ @client.on("iCloud_data_Broadcast")
217
+ async def on_iCloud_broadcast(payload):
218
+ if not client.can_broadcast:
219
+ return {"error": "broadcast not allowed", "status": "failed"}
220
+ await self.broadcast("iCloudListen", payload, sender=client)
221
+ return {"status": "sent"}
222
+
223
+ @client.on("place_visit_Broadcast")
224
+ async def on_place_visit_broadcast(payload):
225
+ if not client.can_broadcast:
226
+ return {"error": "broadcast not allowed", "status": "failed"}
227
+ await self.broadcast("placeVisitListen", payload, sender=client)
228
+ return {"status": "sent"}
229
+
230
+ @client.on("request_prediction")
231
+ async def on_prediction_request(payload):
232
+ if not client.can_broadcast:
233
+ return {"error": "broadcast not allowed", "status": "failed"}
234
+ await self.broadcast("request_prediction", payload, sender=client)
235
+ return {"status": "forwarded"}
236
+
237
+ @client.on("prediction_result")
238
+ async def on_prediction_result(payload):
239
+ if not client.can_broadcast:
240
+ return {"error": "broadcast not allowed", "status": "failed"}
241
+ await self.broadcast("prediction_result", payload, sender=client)
242
+ return {"status": "forwarded"}
243
+
244
+ @client.on("service_log")
245
+ async def on_service_log(payload):
246
+ if not client.can_broadcast:
247
+ return {"error": "log broadcast not allowed", "status": "failed"}
248
+ await self.broadcast("service_log", payload, sender=client)
249
+ return {"status": "broadcasted"}
250
+
251
+ @client.on("node_status")
252
+ async def on_node_status(payload):
253
+ await self.broadcast("node_status", payload, sender=client)
254
+ return {"status": "broadcasted"}
255
+
256
+ @client.on('route_msg')
257
+ async def on_route(payload):
258
+ target_id = payload.get('target_id')
259
+ msg_type = payload.get('type')
260
+ data = payload.get('payload')
261
+
262
+ target = self.clients.get(target_id)
263
+ if target:
264
+ if not client.can_route:
265
+ return {"error": "routing not allowed", "status": "failed"}
266
+ if not client.can_cross_channel_route and not self._same_channel(client, target):
267
+ return {"error": "cross-channel route denied", "status": "failed"}
268
+ response = await target.request(msg_type, data, timeout=5.0)
269
+ return response
270
+ return {"error": "Target not found", "status": "failed"}
271
+
272
+ @client.on("get_nodes")
273
+ async def on_get_nodes(payload):
274
+ client_list = [
275
+ self._client_summary(c)
276
+ for c in self.clients.values()
277
+ if c.id != client.id and (client.can_monitor and (client.broadcast_scope == "global" or self._same_channel(client, c)))
278
+ ]
279
+ return {"clients": client_list}
280
+
281
+ @client.on('route_msg_noreply')
282
+ async def on_noreply_route(payload):
283
+ target_name = payload.get('target_name')
284
+ msg_type = payload.get('type')
285
+ data = payload.get('payload')
286
+
287
+ target = self.clients_by_name.get(target_name)
288
+ if target:
289
+ if not client.can_route:
290
+ return {"error": "routing not allowed", "status": "failed"}
291
+ if not client.can_cross_channel_route and not self._same_channel(client, target):
292
+ return {"error": "cross-channel route denied", "status": "failed"}
293
+ await target.send(msg_type, data)
294
+ else:
295
+ return {"error": "Target not found", "status": "failed"}
296
+
297
+ listen_task = asyncio.create_task(client.listen())
298
+
299
+ try:
300
+ is_auth = await asyncio.wait_for(authenticated, timeout=5.0)
301
+ except asyncio.TimeoutError:
302
+ logging.warning(
303
+ f"{LogColors.FAIL}Auth timeout for {remote_ip} — closing silently{LogColors.ENDC}"
304
+ )
305
+ listen_task.cancel()
306
+ await client.stop()
307
+ return
308
+ except Exception as e:
309
+ logging.error(f"Auth error for {remote_ip}: {e}")
310
+ listen_task.cancel()
311
+ await client.stop()
312
+ return
313
+
314
+ if not is_auth:
315
+ listen_task.cancel()
316
+ await client.stop()
317
+ return
318
+
319
+ self.clients[client.id] = client
320
+ self.clients_by_name[client.name] = client
321
+ logging.info(
322
+ f"{LogColors.GREEN}'{client.name}' connected. "
323
+ f"Total clients: {len(self.clients)}{LogColors.ENDC}"
324
+ )
325
+
326
+
327
+ try:
328
+ await listen_task
329
+ finally:
330
+ self.clients.pop(client.id, None)
331
+ # Only release the name if it still points at *this* client — an
332
+ # evicted incumbent must not pop the entry of the peer that just
333
+ # reclaimed its name (see the eviction path in handle_identify).
334
+ if self.clients_by_name.get(client.name) is client:
335
+ self.clients_by_name.pop(client.name, None)
336
+ logging.info(
337
+ f"{LogColors.WARNING}'{client.name}' disconnected. "
338
+ f"Remaining: {len(self.clients)}{LogColors.ENDC}"
339
+ )
340
+ await self._broadcast_client_list()
341
+ finally:
342
+ _pending_conns[remote_ip] = max(0, _pending_conns.get(remote_ip, 1) - 1)
343
+
344
+ async def _broadcast_client_list(self):
345
+ for client in self.clients.values():
346
+ if not getattr(client, "can_monitor", False):
347
+ continue
348
+ if getattr(client, "broadcast_scope", "channel") == "global":
349
+ visible_clients = [self._client_summary(peer) for peer in self.clients.values()]
350
+ else:
351
+ visible_clients = [self._client_summary(peer) for peer in self.clients.values() if self._same_channel(client, peer)]
352
+ await client.send('server_client_list', {'clients': visible_clients})
353
+
354
+ async def broadcast(self, type: str, payload: dict, sender: MeshSocket | None = None):
355
+ if not self.clients:
356
+ return
357
+ tasks = []
358
+ for peer in self.clients.values():
359
+ if sender and not self._can_receive_broadcast(sender, peer):
360
+ continue
361
+ tasks.append(peer.send(type, payload))
362
+ await asyncio.gather(*tasks, return_exceptions=True)
363
+
364
+
365
+ if __name__ == "__main__":
366
+ server = MeshServer()
367
+ try:
368
+ asyncio.run(server.start())
369
+ except KeyboardInterrupt:
370
+ print("Server stopped.")