xuri-rpc-websocket 1.0.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.
- xuri_rpc_websocket-1.0.0/PKG-INFO +11 -0
- xuri_rpc_websocket-1.0.0/pyproject.toml +22 -0
- xuri_rpc_websocket-1.0.0/setup.cfg +4 -0
- xuri_rpc_websocket-1.0.0/xuri_rpc_websocket/__init__.py +15 -0
- xuri_rpc_websocket-1.0.0/xuri_rpc_websocket/t.py +6 -0
- xuri_rpc_websocket-1.0.0/xuri_rpc_websocket/websocket_sender.py +332 -0
- xuri_rpc_websocket-1.0.0/xuri_rpc_websocket.egg-info/PKG-INFO +11 -0
- xuri_rpc_websocket-1.0.0/xuri_rpc_websocket.egg-info/SOURCES.txt +9 -0
- xuri_rpc_websocket-1.0.0/xuri_rpc_websocket.egg-info/dependency_links.txt +1 -0
- xuri_rpc_websocket-1.0.0/xuri_rpc_websocket.egg-info/requires.txt +3 -0
- xuri_rpc_websocket-1.0.0/xuri_rpc_websocket.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xuri-rpc-websocket
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: WebSocket sender for xuri-rpc
|
|
5
|
+
Author: prometheus0017
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: rpc,remote,callback,websocket
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: xuri-rpc>=1.0.0
|
|
10
|
+
Requires-Dist: websockets>=12.0
|
|
11
|
+
Requires-Dist: cbor2>=5.4
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xuri-rpc-websocket"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "WebSocket sender for xuri-rpc"
|
|
9
|
+
license = {text = "MIT"}
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "prometheus0017"},
|
|
12
|
+
]
|
|
13
|
+
keywords = ["rpc", "remote", "callback", "websocket"]
|
|
14
|
+
requires-python = ">=3.9"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"xuri-rpc>=1.0.0",
|
|
17
|
+
"websockets>=12.0",
|
|
18
|
+
"cbor2>=5.4",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
include = ["xuri_rpc_websocket*"]
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket sender and connection helpers using CBOR binary format.
|
|
3
|
+
Requires: pip install websockets cbor2
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Awaitable, Callable, Optional, Tuple, Dict, Union
|
|
9
|
+
|
|
10
|
+
import cbor2
|
|
11
|
+
from xuri_rpc.rpc import debugFlag
|
|
12
|
+
import websockets
|
|
13
|
+
from websockets.exceptions import ConnectionClosed
|
|
14
|
+
from websockets.legacy.protocol import WebSocketCommonProtocol
|
|
15
|
+
from websockets.legacy.server import WebSocketServerProtocol, WebSocketServer
|
|
16
|
+
from websockets.legacy.client import WebSocketClientProtocol
|
|
17
|
+
|
|
18
|
+
from xuri_rpc import Client, MessageReceiver, RpcMessage, ISender
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Sender
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
class WebSocketBinarySender(ISender):
|
|
28
|
+
"""Sends RPC messages as CBOR-encoded binary over a WebSocket connection.
|
|
29
|
+
|
|
30
|
+
*session_id* may be a plain string **or** a zero-argument callable that
|
|
31
|
+
returns the current session id (useful on the client side where the id is
|
|
32
|
+
learned lazily from the server's responses).
|
|
33
|
+
|
|
34
|
+
Reconnection
|
|
35
|
+
------------
|
|
36
|
+
The sender transparently reconnects when the connection is lost.
|
|
37
|
+
Reconnection is triggered from two places:
|
|
38
|
+
|
|
39
|
+
* :meth:`send` — catches ``ConnectionClosed`` and reconnects before retry.
|
|
40
|
+
* :func:`listen` — catches ``ConnectionClosed`` and reconnects, then
|
|
41
|
+
continues the receive loop.
|
|
42
|
+
|
|
43
|
+
Both paths go through :meth:`ensure_connected` which uses an internal lock
|
|
44
|
+
so that only one reconnection happens at a time.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, ws: Optional[WebSocketCommonProtocol], session_id: Optional[Union[str, Callable[[], Optional[str]]]] = None) -> None:
|
|
48
|
+
self.ws: Optional[WebSocketCommonProtocol] = ws
|
|
49
|
+
self._session_id: Optional[Union[str, Callable[[], Optional[str]]]] = session_id
|
|
50
|
+
# sessionId → sender mapping for server-side multi-connection routing
|
|
51
|
+
self.session_sender_map: Dict[str, 'WebSocketBinarySender'] = {}
|
|
52
|
+
|
|
53
|
+
# reconnection config (set by createMain)
|
|
54
|
+
self._uri: Optional[str] = None
|
|
55
|
+
self._max_retries: int = 0
|
|
56
|
+
self._retry_delay: float = 1.0
|
|
57
|
+
self._retry_backoff: float = 2.0
|
|
58
|
+
self._on_reconnect: Optional[Callable[[],Awaitable[Any]]] = None
|
|
59
|
+
self._reconnect_lock: Optional[asyncio.Lock] = None
|
|
60
|
+
self._client: Optional[Client] = None
|
|
61
|
+
|
|
62
|
+
# -- session id ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def session_id(self) -> Optional[str]:
|
|
66
|
+
return self._session_id
|
|
67
|
+
|
|
68
|
+
@session_id.setter
|
|
69
|
+
def session_id(self, value: Optional[str])->None:
|
|
70
|
+
self._session_id = value
|
|
71
|
+
|
|
72
|
+
# -- reconnection -------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _configure_reconnect(
|
|
75
|
+
self,
|
|
76
|
+
uri: str,
|
|
77
|
+
client: Client,
|
|
78
|
+
*,
|
|
79
|
+
max_retries: int = 0,
|
|
80
|
+
retry_delay: float = 1.0,
|
|
81
|
+
retry_backoff: float = 2.0,
|
|
82
|
+
on_reconnect: Optional[Callable[[Client, Optional[Any]], Any]] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Store reconnection parameters (called by :func:`createMain`)."""
|
|
85
|
+
self._uri = uri
|
|
86
|
+
self._client = client
|
|
87
|
+
self._max_retries = max_retries
|
|
88
|
+
self._retry_delay = retry_delay
|
|
89
|
+
self._retry_backoff = retry_backoff
|
|
90
|
+
self._on_reconnect = on_reconnect
|
|
91
|
+
self._reconnect_lock = asyncio.Lock()
|
|
92
|
+
|
|
93
|
+
async def ensure_connected(self) -> None:
|
|
94
|
+
"""Ensure the underlying WebSocket is connected; reconnect if needed.
|
|
95
|
+
|
|
96
|
+
Safe to call from both :meth:`send` and the listen loop concurrently —
|
|
97
|
+
only one actual reconnection will take place; the other callers wait
|
|
98
|
+
and return once the connection is restored.
|
|
99
|
+
"""
|
|
100
|
+
if self.ws is not None and self.ws.close_code is None:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if self._reconnect_lock is None:
|
|
104
|
+
if(debugFlag):
|
|
105
|
+
logger.warning("ws disconnected and no reconnect configured")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
async with self._reconnect_lock:
|
|
109
|
+
# double-check after acquiring the lock
|
|
110
|
+
if self.ws is not None and self.ws.close_code is None:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
delay = self._retry_delay
|
|
114
|
+
attempts = 0
|
|
115
|
+
|
|
116
|
+
while True:
|
|
117
|
+
attempts += 1
|
|
118
|
+
if 0 < self._max_retries < attempts:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"Gave up reconnecting to %s after %d attempts",
|
|
121
|
+
self._uri, attempts - 1,
|
|
122
|
+
)
|
|
123
|
+
raise ConnectionError(
|
|
124
|
+
f"Failed to reconnect after {attempts - 1} attempts"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
logger.info(
|
|
128
|
+
"Reconnecting to %s in %.1fs (attempt %d)",
|
|
129
|
+
self._uri, delay, attempts,
|
|
130
|
+
)
|
|
131
|
+
await asyncio.sleep(delay)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
self.ws = await websockets.connect(self._uri)
|
|
135
|
+
local_addr = self.ws.local_address
|
|
136
|
+
remote_addr = self.ws.remote_address
|
|
137
|
+
print(f"[WebSocket] Reconnected: local port {local_addr[1]}, remote port {remote_addr[1]}")
|
|
138
|
+
|
|
139
|
+
if self._on_reconnect is not None:
|
|
140
|
+
try:
|
|
141
|
+
await self._on_reconnect(self._client, None)
|
|
142
|
+
except Exception:
|
|
143
|
+
logger.exception("on_reconnect callback raised")
|
|
144
|
+
return
|
|
145
|
+
except (OSError, websockets.WebSocketException) as exc:
|
|
146
|
+
print(f"[WebSocket] Reconnect to {self._uri} failed (attempt {attempts}): {exc}")
|
|
147
|
+
logger.warning(
|
|
148
|
+
"Reconnect to %s failed (attempt %d): %s",
|
|
149
|
+
self._uri, attempts, exc,
|
|
150
|
+
)
|
|
151
|
+
delay = min(delay * self._retry_backoff, 60.0)
|
|
152
|
+
|
|
153
|
+
# -- send ---------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
async def send(self, message: RpcMessage) -> None:
|
|
156
|
+
sid = self.session_id
|
|
157
|
+
if sid and 'sessionId' not in message.get('meta', {}):
|
|
158
|
+
message.setdefault('meta', {})['sessionId'] = sid
|
|
159
|
+
broken=False
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
await self.ensure_connected()
|
|
163
|
+
await self.ws.send(cbor2.dumps(message))
|
|
164
|
+
except ConnectionClosed:
|
|
165
|
+
local_addr = self.ws.local_address
|
|
166
|
+
remote_addr = self.ws.remote_address
|
|
167
|
+
print(f"[WebSocket] broken Reconnected: local port {local_addr[1]}, remote port {remote_addr[1]}")
|
|
168
|
+
broken=True
|
|
169
|
+
if broken:
|
|
170
|
+
raise Exception('failed to send message,ws broken')
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Server side
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
async def createServer(
|
|
178
|
+
hostId: str,
|
|
179
|
+
host: str = "localhost",
|
|
180
|
+
port: int = 8765,
|
|
181
|
+
path: str = "/",
|
|
182
|
+
) -> Tuple[Callable[[Any], Any], Any]:
|
|
183
|
+
"""Create a WebSocket-based RPC server.
|
|
184
|
+
|
|
185
|
+
Returns ``(serve, ws_server)`` where ``serve`` is an async function that
|
|
186
|
+
registers the main object and blocks until the server is closed.
|
|
187
|
+
``ws_server`` is the underlying WebSocket server for lifecycle management.
|
|
188
|
+
|
|
189
|
+
Usage::
|
|
190
|
+
|
|
191
|
+
serve, ws_server = await createServer('myServer', 'localhost', 8765)
|
|
192
|
+
await serve(MyService()) # blocks until ws_server is closed
|
|
193
|
+
"""
|
|
194
|
+
serverReceiver: MessageReceiver = MessageReceiver(hostId)
|
|
195
|
+
|
|
196
|
+
async def _onWsConnected(ws: WebSocketServerProtocol, path: Optional[str] = None) -> None:
|
|
197
|
+
"""Handle a single WebSocket client connection."""
|
|
198
|
+
local_addr = ws.local_address
|
|
199
|
+
remote_addr = ws.remote_address
|
|
200
|
+
print(f'[WebSocket] Server connection handled: local port {local_addr[1]}, remote port {remote_addr[1]}, ws_id={id(ws)}')
|
|
201
|
+
|
|
202
|
+
session_id: str = str(uuid.uuid4())
|
|
203
|
+
conn_sender: WebSocketBinarySender = WebSocketBinarySender(ws, session_id)
|
|
204
|
+
# Register sender in session map at connection time
|
|
205
|
+
conn_client: Client = Client(hostId)
|
|
206
|
+
conn_client.getSessionData()[session_id]=conn_sender
|
|
207
|
+
conn_client.setSender(lambda: conn_client.getSessionData()[session_id])
|
|
208
|
+
connReceiver: MessageReceiver = MessageReceiver(hostId)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
async for raw in ws:
|
|
212
|
+
data = cbor2.loads(raw)
|
|
213
|
+
await connReceiver.onReceiveMessage(data, conn_client)
|
|
214
|
+
except ConnectionClosed as e:
|
|
215
|
+
import traceback
|
|
216
|
+
traceback.print_exc()
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
ws_server: WebSocketServer = await websockets.serve(_onWsConnected, host, port)
|
|
220
|
+
|
|
221
|
+
async def serve(mainObject: Any) -> Tuple[MessageReceiver, Callable[[Any], Any]]:
|
|
222
|
+
serverReceiver.setMain(mainObject)
|
|
223
|
+
await ws_server.wait_closed()
|
|
224
|
+
return (serverReceiver, serve)
|
|
225
|
+
|
|
226
|
+
return (serve, ws_server)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Client side (with auto-reconnect)
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
async def createMain(
|
|
234
|
+
hostId: str,
|
|
235
|
+
host: str = "localhost",
|
|
236
|
+
port: int = 8765,
|
|
237
|
+
path: str = "/",
|
|
238
|
+
*,
|
|
239
|
+
max_retries: int = 0,
|
|
240
|
+
retry_delay: float = 1.0,
|
|
241
|
+
retry_backoff: float = 2.0,
|
|
242
|
+
on_reconnect: Optional[Callable[[Client, Optional[Any]], Any]] = None,
|
|
243
|
+
) -> Tuple[Client, Any]:
|
|
244
|
+
"""Connect to a WebSocket-based RPC server and return the main proxy.
|
|
245
|
+
|
|
246
|
+
Returns ``(client, main_proxy)``.
|
|
247
|
+
|
|
248
|
+
If the connection drops later, reconnection is handled transparently:
|
|
249
|
+
|
|
250
|
+
* When :meth:`send <WebSocketBinarySender.send>` detects a closed
|
|
251
|
+
connection, it reconnects and retries.
|
|
252
|
+
* When the background listen loop detects a closed connection, it
|
|
253
|
+
reconnects and continues receiving.
|
|
254
|
+
|
|
255
|
+
Reconnection parameters
|
|
256
|
+
-----------------------
|
|
257
|
+
max_retries : int
|
|
258
|
+
Maximum consecutive reconnection attempts. ``0`` (default) means
|
|
259
|
+
**reconnect forever**.
|
|
260
|
+
retry_delay : float
|
|
261
|
+
Initial delay in seconds between reconnection attempts.
|
|
262
|
+
retry_backoff : float
|
|
263
|
+
Multiplier applied to *retry_delay* after each failed attempt
|
|
264
|
+
(capped at 60 s).
|
|
265
|
+
on_reconnect : callable, optional
|
|
266
|
+
``fn(client, main)`` or ``async def fn(client, main)`` called after
|
|
267
|
+
every successful *re*connection. Useful for re-subscribing or
|
|
268
|
+
re-registering state.
|
|
269
|
+
|
|
270
|
+
Usage::
|
|
271
|
+
|
|
272
|
+
client, main = await createMain('myClient', 'localhost', 8765)
|
|
273
|
+
result = await main.hello('world')
|
|
274
|
+
"""
|
|
275
|
+
uri = f"ws://{host}:{port}{path}"
|
|
276
|
+
|
|
277
|
+
client: Client = Client(hostId)
|
|
278
|
+
messageReceiver: MessageReceiver = MessageReceiver(hostId)
|
|
279
|
+
sender: WebSocketBinarySender = WebSocketBinarySender(None) # ws set below
|
|
280
|
+
client.setSender(lambda: sender)
|
|
281
|
+
|
|
282
|
+
# Configure reconnection parameters on the sender
|
|
283
|
+
sender._configure_reconnect(
|
|
284
|
+
uri, client,
|
|
285
|
+
max_retries=max_retries,
|
|
286
|
+
retry_delay=retry_delay,
|
|
287
|
+
retry_backoff=retry_backoff,
|
|
288
|
+
on_reconnect=on_reconnect,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Initial connection
|
|
292
|
+
try:
|
|
293
|
+
ws: WebSocketClientProtocol = await websockets.connect(uri)
|
|
294
|
+
except (OSError, websockets.WebSocketException) as exc:
|
|
295
|
+
print(f"[WebSocket] Initial connection to {uri} failed: {exc}")
|
|
296
|
+
raise
|
|
297
|
+
sender.ws = ws
|
|
298
|
+
local_addr = ws.local_address
|
|
299
|
+
remote_addr = ws.remote_address
|
|
300
|
+
print(f"[WebSocket] Connected: local port {local_addr[1]}, remote port {remote_addr[1]}")
|
|
301
|
+
|
|
302
|
+
# Start background listen — reconnect is triggered from inside
|
|
303
|
+
asyncio.ensure_future(_listen(sender, messageReceiver, client))
|
|
304
|
+
|
|
305
|
+
main: Any = await client.getMain()
|
|
306
|
+
return (client, main)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def _listen(
|
|
310
|
+
sender: WebSocketBinarySender,
|
|
311
|
+
messageReceiver: MessageReceiver,
|
|
312
|
+
client: Client,
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Read messages from the sender's ws; reconnect and continue on drop."""
|
|
315
|
+
while True:
|
|
316
|
+
try:
|
|
317
|
+
async for raw in sender.ws:
|
|
318
|
+
data = cbor2.loads(raw)
|
|
319
|
+
# Extract sessionId from meta and update sender
|
|
320
|
+
meta = data.get('meta') or {}
|
|
321
|
+
if meta.get('sessionId'):
|
|
322
|
+
sender.session_id = meta['sessionId']
|
|
323
|
+
await messageReceiver.onReceiveMessage(data, client)
|
|
324
|
+
except ConnectionClosed:
|
|
325
|
+
pass
|
|
326
|
+
|
|
327
|
+
# Connection lost — reconnect via sender (shared with send path)
|
|
328
|
+
try:
|
|
329
|
+
await sender.ensure_connected()
|
|
330
|
+
except ConnectionError:
|
|
331
|
+
logger.warning("Reconnect gave up, listen loop exiting")
|
|
332
|
+
return
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xuri-rpc-websocket
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: WebSocket sender for xuri-rpc
|
|
5
|
+
Author: prometheus0017
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: rpc,remote,callback,websocket
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: xuri-rpc>=1.0.0
|
|
10
|
+
Requires-Dist: websockets>=12.0
|
|
11
|
+
Requires-Dist: cbor2>=5.4
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
xuri_rpc_websocket/__init__.py
|
|
3
|
+
xuri_rpc_websocket/t.py
|
|
4
|
+
xuri_rpc_websocket/websocket_sender.py
|
|
5
|
+
xuri_rpc_websocket.egg-info/PKG-INFO
|
|
6
|
+
xuri_rpc_websocket.egg-info/SOURCES.txt
|
|
7
|
+
xuri_rpc_websocket.egg-info/dependency_links.txt
|
|
8
|
+
xuri_rpc_websocket.egg-info/requires.txt
|
|
9
|
+
xuri_rpc_websocket.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xuri_rpc_websocket
|