roomer-client 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.
- roomer_client-0.1.0/.gitignore +62 -0
- roomer_client-0.1.0/PKG-INFO +297 -0
- roomer_client-0.1.0/README.md +283 -0
- roomer_client-0.1.0/pyproject.toml +45 -0
- roomer_client-0.1.0/roomer.py +565 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Rust build artifacts
|
|
2
|
+
target/
|
|
3
|
+
**/target/
|
|
4
|
+
**/*.rs.bk
|
|
5
|
+
*.rlib
|
|
6
|
+
|
|
7
|
+
# Go build artifacts & binaries
|
|
8
|
+
server_bin
|
|
9
|
+
/app/server_bin
|
|
10
|
+
bin/
|
|
11
|
+
**/bin/
|
|
12
|
+
*.exe
|
|
13
|
+
*.exe~
|
|
14
|
+
*.dll
|
|
15
|
+
*.so
|
|
16
|
+
*.dylib
|
|
17
|
+
*.test
|
|
18
|
+
*.out
|
|
19
|
+
*.prof
|
|
20
|
+
|
|
21
|
+
# Node / Web / Package managers (if any tooling is added)
|
|
22
|
+
node_modules/
|
|
23
|
+
dist/
|
|
24
|
+
.npm
|
|
25
|
+
npm-debug.log*
|
|
26
|
+
yarn-debug.log*
|
|
27
|
+
yarn-error.log*
|
|
28
|
+
.pnpm-debug.log*
|
|
29
|
+
|
|
30
|
+
# IDEs and Text Editors
|
|
31
|
+
.vscode/
|
|
32
|
+
!.vscode/settings.json
|
|
33
|
+
!.vscode/tasks.json
|
|
34
|
+
!.vscode/launch.json
|
|
35
|
+
!.vscode/extensions.json
|
|
36
|
+
.idea/
|
|
37
|
+
*.iml
|
|
38
|
+
*.sublime-workspace
|
|
39
|
+
*.sublime-project
|
|
40
|
+
*.swp
|
|
41
|
+
*.swo
|
|
42
|
+
*~
|
|
43
|
+
|
|
44
|
+
# Operating System Files
|
|
45
|
+
.DS_Store
|
|
46
|
+
.DS_Store?
|
|
47
|
+
._*
|
|
48
|
+
.Spotlight-V100
|
|
49
|
+
.Trashes
|
|
50
|
+
ehthumbs.db
|
|
51
|
+
Thumbs.db
|
|
52
|
+
|
|
53
|
+
# Environment variables & local overrides
|
|
54
|
+
.env
|
|
55
|
+
.env.local
|
|
56
|
+
.env.*.local
|
|
57
|
+
docker-compose.override.yml
|
|
58
|
+
|
|
59
|
+
# TLA+ Model Checker (TLC) generated artifacts
|
|
60
|
+
spec/states/
|
|
61
|
+
MC.*
|
|
62
|
+
*.tla.dump
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: roomer-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: High-performance, room-based WebSocket client for Roomer with binary framing and presence synchronization
|
|
5
|
+
Author: Jon Cody
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: asyncio,binary,clustering,realtime,rooms,websocket
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: websockets>=12.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# `roomer-client` – Python Client SDK
|
|
16
|
+
|
|
17
|
+
[](https://pypi.org/project/roomer-client/)
|
|
18
|
+
[](https://www.python.org/)
|
|
19
|
+
[](https://docs.python.org/3/library/asyncio.html)
|
|
20
|
+
[](https://peps.python.org/pep-0561/)
|
|
21
|
+
[](../../LICENSE)
|
|
22
|
+
|
|
23
|
+
High-performance, asynchronous Python client for the Roomer WebSocket framework with zero-copy binary framing, automatic exponential reconnection with jitter, cluster-wide presence synchronization, and 100% wire protocol parity across Go, Rust, and Node.js servers.
|
|
24
|
+
|
|
25
|
+
> 📖 **For Wire Protocol specifications and Server documentation, see the [Root README](../../README.md).**
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 📦 Scope & Architecture
|
|
30
|
+
|
|
31
|
+
The `roomer-client` library provides an asynchronous, non-blocking interface for Python applications (FastAPI backends, AI/LLM streaming pipelines, data processing workers, CLI tools) to communicate over Roomer clusters.
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
+---------------------------------------------------+
|
|
35
|
+
| Python Application |
|
|
36
|
+
| (FastAPI / LangChain / PyTorch Worker) |
|
|
37
|
+
+-------------------------+-------------------------+
|
|
38
|
+
|
|
|
39
|
+
+-------------------------v-------------------------+
|
|
40
|
+
| Roomer Client SDK |
|
|
41
|
+
| - asyncio / websockets async connection manager |
|
|
42
|
+
| - Async / Sync Dual-Mode Event Emitter |
|
|
43
|
+
+-------------------+-------------------+-----------+
|
|
44
|
+
| |
|
|
45
|
+
+-------------v----+ +-----v-------------+
|
|
46
|
+
| Room Multiplexer | | Binary Wire Frame |
|
|
47
|
+
| (Presence & Acks)| | (struct.pack >I) |
|
|
48
|
+
+------------------+ +-------------------+
|
|
49
|
+
| |
|
|
50
|
+
+-------------------v-------------------v-----------+
|
|
51
|
+
| WebSocket Connection |
|
|
52
|
+
| (Auto-Reconnect with Exponential Jitter) |
|
|
53
|
+
+---------------------------------------------------+
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## ⚡ Key Features
|
|
59
|
+
|
|
60
|
+
- **High-Performance Binary Wire Framing**: Serializes and unpacks 5 big-endian length-prefixed fields via native `struct.pack(">I", ...)` with zero-copy `memoryview` slicing.
|
|
61
|
+
- **Dual-Mode Event Emitter**: Register event listeners as either standard synchronous functions (`def handler(...)`) or native coroutines (`async def handler(...)`).
|
|
62
|
+
- **Async Context Manager**: Native `async with roomer("ws://...") as root:` pattern for deterministic lifecycle management and cleanup.
|
|
63
|
+
- **Automatic Exponential Reconnection**: Recovers from abrupt socket disconnects with randomized jitter backoff while preserving room subscriptions across reconnects.
|
|
64
|
+
- **Cluster Presence Tracking**: Automatic handling of `join_ack` snapshots, `new_member` notifications, and `member_left` presence events.
|
|
65
|
+
- **Direct 1-to-1 Point-to-Point Unicast**: Route messages directly to specific client UUIDs across cluster nodes with $O(1)$ efficiency.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 🚀 Installation
|
|
70
|
+
|
|
71
|
+
Install from PyPI:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install roomer-client
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Or install in editable mode for local development:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
cd client/python
|
|
81
|
+
pip install -e ".[dev]"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 🧠 Quick Start
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import asyncio
|
|
90
|
+
from roomer import roomer
|
|
91
|
+
|
|
92
|
+
async def main():
|
|
93
|
+
# Connect and auto-join the root room
|
|
94
|
+
async with roomer("ws://localhost:8080/ws") as root:
|
|
95
|
+
print(f"Connected to Roomer cluster! Client ID: {root.id}")
|
|
96
|
+
|
|
97
|
+
# Join a named room channel
|
|
98
|
+
lobby = root.join("lobby")
|
|
99
|
+
|
|
100
|
+
@lobby.on("open")
|
|
101
|
+
def on_open():
|
|
102
|
+
print(f"Joined lobby! Active members: {lobby.members()}")
|
|
103
|
+
lobby.send("chat", "Hello from Python!")
|
|
104
|
+
|
|
105
|
+
@lobby.on("chat")
|
|
106
|
+
def on_chat(payload: bytes, sender_id: str):
|
|
107
|
+
print(f"[{sender_id}]: {payload.decode('utf-8')}")
|
|
108
|
+
|
|
109
|
+
@lobby.on("new_member")
|
|
110
|
+
def on_new_member(member_id: str):
|
|
111
|
+
print(f"User joined lobby: {member_id}")
|
|
112
|
+
|
|
113
|
+
@lobby.on("member_left")
|
|
114
|
+
def on_member_left(member_id: str):
|
|
115
|
+
print(f"User left lobby: {member_id}")
|
|
116
|
+
|
|
117
|
+
# Keep running
|
|
118
|
+
await asyncio.Event().wait()
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
asyncio.run(main())
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 🛠️ Real-World Recipes & Patterns
|
|
127
|
+
|
|
128
|
+
### 1. Streaming AI / LLM Tokens into a Room
|
|
129
|
+
Stream token completions from OpenAI, Anthropic, or local HuggingFace/vLLM models in real-time to all clients subscribed to a room:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
import asyncio
|
|
133
|
+
from roomer import roomer
|
|
134
|
+
|
|
135
|
+
async def stream_ai_response(prompt: str, room_name: str):
|
|
136
|
+
async with roomer("ws://localhost:8080/ws") as root:
|
|
137
|
+
ai_room = root.join(room_name)
|
|
138
|
+
|
|
139
|
+
# Simulated token generator (e.g. from vLLM or Ollama)
|
|
140
|
+
tokens = ["The", " future", " of", " real-time", " messaging", " is", " binary."]
|
|
141
|
+
|
|
142
|
+
for token in tokens:
|
|
143
|
+
ai_room.send("ai_token", token)
|
|
144
|
+
await asyncio.sleep(0.040) # 40ms token interval
|
|
145
|
+
|
|
146
|
+
ai_room.send("ai_complete", {"prompt": prompt, "status": "done"})
|
|
147
|
+
|
|
148
|
+
asyncio.run(stream_ai_response("Explain binary framing", "generation-101"))
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
### 2. FastAPI Background Task Bridge
|
|
154
|
+
Publish real-time telemetry, job notifications, or database changes from a FastAPI backend to connected browser clients:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from contextlib import asynccontextmanager
|
|
158
|
+
from fastapi import FastAPI, BackgroundTasks
|
|
159
|
+
from roomer import RoomerClient
|
|
160
|
+
|
|
161
|
+
client = RoomerClient("ws://localhost:8080/ws")
|
|
162
|
+
|
|
163
|
+
@asynccontextmanager
|
|
164
|
+
async def lifespan(app: FastAPI):
|
|
165
|
+
# Connect Roomer client on FastAPI startup
|
|
166
|
+
await client.connect()
|
|
167
|
+
yield
|
|
168
|
+
# Gracefully close on shutdown
|
|
169
|
+
await client.close()
|
|
170
|
+
|
|
171
|
+
app = FastAPI(lifespan=lifespan)
|
|
172
|
+
|
|
173
|
+
@app.post("/notifications/broadcast")
|
|
174
|
+
async def notify_users(message: str, background_tasks: BackgroundTasks):
|
|
175
|
+
def send_broadcast():
|
|
176
|
+
alerts_room = client.get_room("system-alerts")
|
|
177
|
+
alerts_room.send("alert", {"message": message, "severity": "info"})
|
|
178
|
+
|
|
179
|
+
background_tasks.add_task(send_broadcast)
|
|
180
|
+
return {"status": "broadcast scheduled"}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
### 3. Targeted 1-to-1 Direct Unicast
|
|
186
|
+
Send direct private messages targeted at a specific client ID without broadcasting to the entire room:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
import asyncio
|
|
190
|
+
from roomer import roomer
|
|
191
|
+
|
|
192
|
+
async def main():
|
|
193
|
+
async with roomer("ws://localhost:8080/ws") as root:
|
|
194
|
+
target_client_id = "038edeb7-7823-4537-92c0-ba479cc2329c"
|
|
195
|
+
|
|
196
|
+
@root.on("direct_message")
|
|
197
|
+
def on_dm(payload: bytes, sender_id: str):
|
|
198
|
+
print(f"[Private DM from {sender_id}]: {payload.decode('utf-8')}")
|
|
199
|
+
|
|
200
|
+
# Send direct point-to-point packet (dst=target_client_id)
|
|
201
|
+
root.send("direct_message", "Secret private message", dst=target_client_id)
|
|
202
|
+
|
|
203
|
+
await asyncio.sleep(2)
|
|
204
|
+
|
|
205
|
+
asyncio.run(main())
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
### 4. Custom Reconnection Backoff Configuration
|
|
211
|
+
Fine-tune initial delay, backoff multiplier, and max backoff ceiling:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from roomer import roomer
|
|
215
|
+
|
|
216
|
+
# Configured for high-resilience environments
|
|
217
|
+
root_context = roomer(
|
|
218
|
+
"ws://localhost:8080/ws",
|
|
219
|
+
reconnect=True,
|
|
220
|
+
initial_delay=0.250, # Start at 250ms backoff
|
|
221
|
+
max_delay=10.0, # Max backoff ceiling of 10s
|
|
222
|
+
backoff_factor=2.0 # Double backoff duration on consecutive drops
|
|
223
|
+
)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 📚 API Reference
|
|
229
|
+
|
|
230
|
+
### `roomer(url, **kwargs) -> RoomerContext`
|
|
231
|
+
Factory function creating an asynchronous context manager.
|
|
232
|
+
|
|
233
|
+
| Argument | Type | Default | Description |
|
|
234
|
+
|---|---|---|---|
|
|
235
|
+
| `url` | `str` | *Required* | WebSocket endpoint URL (e.g. `ws://localhost:8080/ws`). |
|
|
236
|
+
| `reconnect` | `bool` | `True` | Automatically reconnect on connection drop. |
|
|
237
|
+
| `initial_delay` | `float` | `0.5` | Initial backoff delay in seconds. |
|
|
238
|
+
| `max_delay` | `float` | `5.0` | Maximum reconnection delay ceiling in seconds. |
|
|
239
|
+
| `backoff_factor` | `float` | `1.5` | Backoff multiplier applied on consecutive failures. |
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
### `Room` Instance Properties & Methods
|
|
244
|
+
|
|
245
|
+
#### Properties
|
|
246
|
+
- **`room.name -> str`**: Channel name for this room instance.
|
|
247
|
+
- **`room.id -> str`**: Assigned connection UUID string.
|
|
248
|
+
- **`room.is_open -> bool`**: Returns `True` if room subscription is active.
|
|
249
|
+
|
|
250
|
+
#### Methods
|
|
251
|
+
| Method | Returns | Description |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `room.members()` | `list[str]` | Shallow copy array of active member connection IDs. |
|
|
254
|
+
| `room.join(room_name)` | `Room` | Subscribes to another room channel over the active connection. |
|
|
255
|
+
| `room.leave()` | `Room` | Unsubscribes from the room and notifies the cluster. |
|
|
256
|
+
| `room.send(event, payload=None, dst="")` | `Room` | Sends a message packet to the room or directly to `dst`. |
|
|
257
|
+
| `room.on(event, listener)` | `Callable` | Subscribes a synchronous or asynchronous callback. Supports `@room.on(event)`. |
|
|
258
|
+
| `room.once(event, listener)` | `Callable` | Subscribes a one-time event callback. |
|
|
259
|
+
| `room.off(event, listener)` | `None` | Unsubscribes a registered listener callback. |
|
|
260
|
+
| `room.clear_listeners(exceptions=None)` | `Room` | Clears custom listeners except those listed in `exceptions`. |
|
|
261
|
+
| `room.force_close(is_disconnect=False)` | `Room` | Clears local member state and emits `"close"`. |
|
|
262
|
+
| `root.purge()` *(root only)* | `Room` | Unsubscribes from all non-root rooms simultaneously. |
|
|
263
|
+
| `root.rooms()` *(root only)* | `dict[str, Room]` | Dictionary mapping of all active room handles. |
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
### `Packet` Data Attributes & Helpers
|
|
268
|
+
|
|
269
|
+
Decoded binary packet object passed into event handlers:
|
|
270
|
+
|
|
271
|
+
| Attribute / Helper | Type | Description |
|
|
272
|
+
|---|---|---|
|
|
273
|
+
| `packet.room` | `str` | Target channel / room name. |
|
|
274
|
+
| `packet.event` | `str` | Event descriptor string. |
|
|
275
|
+
| `packet.dst` | `str` | Destination member ID (empty string if room broadcast). |
|
|
276
|
+
| `packet.src` | `str` | Sender client ID. |
|
|
277
|
+
| `packet.payload` | `bytes` | Raw binary payload bytes. |
|
|
278
|
+
| `packet.payload_text()` | `str` | Decodes payload as UTF-8 string. |
|
|
279
|
+
| `packet.payload_json()` | `Any` | Unmarshals binary payload as JSON. |
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## 🧪 Testing & Verification
|
|
284
|
+
|
|
285
|
+
Run the test suite using `pytest`:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
cd client/python
|
|
289
|
+
pip install -e ".[dev]"
|
|
290
|
+
pytest -v
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 📄 License
|
|
296
|
+
|
|
297
|
+
Roomer is open-source software licensed under the [MIT License](../../LICENSE).
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# `roomer-client` – Python Client SDK
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/roomer-client/)
|
|
4
|
+
[](https://www.python.org/)
|
|
5
|
+
[](https://docs.python.org/3/library/asyncio.html)
|
|
6
|
+
[](https://peps.python.org/pep-0561/)
|
|
7
|
+
[](../../LICENSE)
|
|
8
|
+
|
|
9
|
+
High-performance, asynchronous Python client for the Roomer WebSocket framework with zero-copy binary framing, automatic exponential reconnection with jitter, cluster-wide presence synchronization, and 100% wire protocol parity across Go, Rust, and Node.js servers.
|
|
10
|
+
|
|
11
|
+
> 📖 **For Wire Protocol specifications and Server documentation, see the [Root README](../../README.md).**
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 📦 Scope & Architecture
|
|
16
|
+
|
|
17
|
+
The `roomer-client` library provides an asynchronous, non-blocking interface for Python applications (FastAPI backends, AI/LLM streaming pipelines, data processing workers, CLI tools) to communicate over Roomer clusters.
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
+---------------------------------------------------+
|
|
21
|
+
| Python Application |
|
|
22
|
+
| (FastAPI / LangChain / PyTorch Worker) |
|
|
23
|
+
+-------------------------+-------------------------+
|
|
24
|
+
|
|
|
25
|
+
+-------------------------v-------------------------+
|
|
26
|
+
| Roomer Client SDK |
|
|
27
|
+
| - asyncio / websockets async connection manager |
|
|
28
|
+
| - Async / Sync Dual-Mode Event Emitter |
|
|
29
|
+
+-------------------+-------------------+-----------+
|
|
30
|
+
| |
|
|
31
|
+
+-------------v----+ +-----v-------------+
|
|
32
|
+
| Room Multiplexer | | Binary Wire Frame |
|
|
33
|
+
| (Presence & Acks)| | (struct.pack >I) |
|
|
34
|
+
+------------------+ +-------------------+
|
|
35
|
+
| |
|
|
36
|
+
+-------------------v-------------------v-----------+
|
|
37
|
+
| WebSocket Connection |
|
|
38
|
+
| (Auto-Reconnect with Exponential Jitter) |
|
|
39
|
+
+---------------------------------------------------+
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## ⚡ Key Features
|
|
45
|
+
|
|
46
|
+
- **High-Performance Binary Wire Framing**: Serializes and unpacks 5 big-endian length-prefixed fields via native `struct.pack(">I", ...)` with zero-copy `memoryview` slicing.
|
|
47
|
+
- **Dual-Mode Event Emitter**: Register event listeners as either standard synchronous functions (`def handler(...)`) or native coroutines (`async def handler(...)`).
|
|
48
|
+
- **Async Context Manager**: Native `async with roomer("ws://...") as root:` pattern for deterministic lifecycle management and cleanup.
|
|
49
|
+
- **Automatic Exponential Reconnection**: Recovers from abrupt socket disconnects with randomized jitter backoff while preserving room subscriptions across reconnects.
|
|
50
|
+
- **Cluster Presence Tracking**: Automatic handling of `join_ack` snapshots, `new_member` notifications, and `member_left` presence events.
|
|
51
|
+
- **Direct 1-to-1 Point-to-Point Unicast**: Route messages directly to specific client UUIDs across cluster nodes with $O(1)$ efficiency.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 🚀 Installation
|
|
56
|
+
|
|
57
|
+
Install from PyPI:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install roomer-client
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or install in editable mode for local development:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
cd client/python
|
|
67
|
+
pip install -e ".[dev]"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 🧠 Quick Start
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import asyncio
|
|
76
|
+
from roomer import roomer
|
|
77
|
+
|
|
78
|
+
async def main():
|
|
79
|
+
# Connect and auto-join the root room
|
|
80
|
+
async with roomer("ws://localhost:8080/ws") as root:
|
|
81
|
+
print(f"Connected to Roomer cluster! Client ID: {root.id}")
|
|
82
|
+
|
|
83
|
+
# Join a named room channel
|
|
84
|
+
lobby = root.join("lobby")
|
|
85
|
+
|
|
86
|
+
@lobby.on("open")
|
|
87
|
+
def on_open():
|
|
88
|
+
print(f"Joined lobby! Active members: {lobby.members()}")
|
|
89
|
+
lobby.send("chat", "Hello from Python!")
|
|
90
|
+
|
|
91
|
+
@lobby.on("chat")
|
|
92
|
+
def on_chat(payload: bytes, sender_id: str):
|
|
93
|
+
print(f"[{sender_id}]: {payload.decode('utf-8')}")
|
|
94
|
+
|
|
95
|
+
@lobby.on("new_member")
|
|
96
|
+
def on_new_member(member_id: str):
|
|
97
|
+
print(f"User joined lobby: {member_id}")
|
|
98
|
+
|
|
99
|
+
@lobby.on("member_left")
|
|
100
|
+
def on_member_left(member_id: str):
|
|
101
|
+
print(f"User left lobby: {member_id}")
|
|
102
|
+
|
|
103
|
+
# Keep running
|
|
104
|
+
await asyncio.Event().wait()
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
asyncio.run(main())
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 🛠️ Real-World Recipes & Patterns
|
|
113
|
+
|
|
114
|
+
### 1. Streaming AI / LLM Tokens into a Room
|
|
115
|
+
Stream token completions from OpenAI, Anthropic, or local HuggingFace/vLLM models in real-time to all clients subscribed to a room:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import asyncio
|
|
119
|
+
from roomer import roomer
|
|
120
|
+
|
|
121
|
+
async def stream_ai_response(prompt: str, room_name: str):
|
|
122
|
+
async with roomer("ws://localhost:8080/ws") as root:
|
|
123
|
+
ai_room = root.join(room_name)
|
|
124
|
+
|
|
125
|
+
# Simulated token generator (e.g. from vLLM or Ollama)
|
|
126
|
+
tokens = ["The", " future", " of", " real-time", " messaging", " is", " binary."]
|
|
127
|
+
|
|
128
|
+
for token in tokens:
|
|
129
|
+
ai_room.send("ai_token", token)
|
|
130
|
+
await asyncio.sleep(0.040) # 40ms token interval
|
|
131
|
+
|
|
132
|
+
ai_room.send("ai_complete", {"prompt": prompt, "status": "done"})
|
|
133
|
+
|
|
134
|
+
asyncio.run(stream_ai_response("Explain binary framing", "generation-101"))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### 2. FastAPI Background Task Bridge
|
|
140
|
+
Publish real-time telemetry, job notifications, or database changes from a FastAPI backend to connected browser clients:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from contextlib import asynccontextmanager
|
|
144
|
+
from fastapi import FastAPI, BackgroundTasks
|
|
145
|
+
from roomer import RoomerClient
|
|
146
|
+
|
|
147
|
+
client = RoomerClient("ws://localhost:8080/ws")
|
|
148
|
+
|
|
149
|
+
@asynccontextmanager
|
|
150
|
+
async def lifespan(app: FastAPI):
|
|
151
|
+
# Connect Roomer client on FastAPI startup
|
|
152
|
+
await client.connect()
|
|
153
|
+
yield
|
|
154
|
+
# Gracefully close on shutdown
|
|
155
|
+
await client.close()
|
|
156
|
+
|
|
157
|
+
app = FastAPI(lifespan=lifespan)
|
|
158
|
+
|
|
159
|
+
@app.post("/notifications/broadcast")
|
|
160
|
+
async def notify_users(message: str, background_tasks: BackgroundTasks):
|
|
161
|
+
def send_broadcast():
|
|
162
|
+
alerts_room = client.get_room("system-alerts")
|
|
163
|
+
alerts_room.send("alert", {"message": message, "severity": "info"})
|
|
164
|
+
|
|
165
|
+
background_tasks.add_task(send_broadcast)
|
|
166
|
+
return {"status": "broadcast scheduled"}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### 3. Targeted 1-to-1 Direct Unicast
|
|
172
|
+
Send direct private messages targeted at a specific client ID without broadcasting to the entire room:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
import asyncio
|
|
176
|
+
from roomer import roomer
|
|
177
|
+
|
|
178
|
+
async def main():
|
|
179
|
+
async with roomer("ws://localhost:8080/ws") as root:
|
|
180
|
+
target_client_id = "038edeb7-7823-4537-92c0-ba479cc2329c"
|
|
181
|
+
|
|
182
|
+
@root.on("direct_message")
|
|
183
|
+
def on_dm(payload: bytes, sender_id: str):
|
|
184
|
+
print(f"[Private DM from {sender_id}]: {payload.decode('utf-8')}")
|
|
185
|
+
|
|
186
|
+
# Send direct point-to-point packet (dst=target_client_id)
|
|
187
|
+
root.send("direct_message", "Secret private message", dst=target_client_id)
|
|
188
|
+
|
|
189
|
+
await asyncio.sleep(2)
|
|
190
|
+
|
|
191
|
+
asyncio.run(main())
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
### 4. Custom Reconnection Backoff Configuration
|
|
197
|
+
Fine-tune initial delay, backoff multiplier, and max backoff ceiling:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from roomer import roomer
|
|
201
|
+
|
|
202
|
+
# Configured for high-resilience environments
|
|
203
|
+
root_context = roomer(
|
|
204
|
+
"ws://localhost:8080/ws",
|
|
205
|
+
reconnect=True,
|
|
206
|
+
initial_delay=0.250, # Start at 250ms backoff
|
|
207
|
+
max_delay=10.0, # Max backoff ceiling of 10s
|
|
208
|
+
backoff_factor=2.0 # Double backoff duration on consecutive drops
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 📚 API Reference
|
|
215
|
+
|
|
216
|
+
### `roomer(url, **kwargs) -> RoomerContext`
|
|
217
|
+
Factory function creating an asynchronous context manager.
|
|
218
|
+
|
|
219
|
+
| Argument | Type | Default | Description |
|
|
220
|
+
|---|---|---|---|
|
|
221
|
+
| `url` | `str` | *Required* | WebSocket endpoint URL (e.g. `ws://localhost:8080/ws`). |
|
|
222
|
+
| `reconnect` | `bool` | `True` | Automatically reconnect on connection drop. |
|
|
223
|
+
| `initial_delay` | `float` | `0.5` | Initial backoff delay in seconds. |
|
|
224
|
+
| `max_delay` | `float` | `5.0` | Maximum reconnection delay ceiling in seconds. |
|
|
225
|
+
| `backoff_factor` | `float` | `1.5` | Backoff multiplier applied on consecutive failures. |
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
### `Room` Instance Properties & Methods
|
|
230
|
+
|
|
231
|
+
#### Properties
|
|
232
|
+
- **`room.name -> str`**: Channel name for this room instance.
|
|
233
|
+
- **`room.id -> str`**: Assigned connection UUID string.
|
|
234
|
+
- **`room.is_open -> bool`**: Returns `True` if room subscription is active.
|
|
235
|
+
|
|
236
|
+
#### Methods
|
|
237
|
+
| Method | Returns | Description |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| `room.members()` | `list[str]` | Shallow copy array of active member connection IDs. |
|
|
240
|
+
| `room.join(room_name)` | `Room` | Subscribes to another room channel over the active connection. |
|
|
241
|
+
| `room.leave()` | `Room` | Unsubscribes from the room and notifies the cluster. |
|
|
242
|
+
| `room.send(event, payload=None, dst="")` | `Room` | Sends a message packet to the room or directly to `dst`. |
|
|
243
|
+
| `room.on(event, listener)` | `Callable` | Subscribes a synchronous or asynchronous callback. Supports `@room.on(event)`. |
|
|
244
|
+
| `room.once(event, listener)` | `Callable` | Subscribes a one-time event callback. |
|
|
245
|
+
| `room.off(event, listener)` | `None` | Unsubscribes a registered listener callback. |
|
|
246
|
+
| `room.clear_listeners(exceptions=None)` | `Room` | Clears custom listeners except those listed in `exceptions`. |
|
|
247
|
+
| `room.force_close(is_disconnect=False)` | `Room` | Clears local member state and emits `"close"`. |
|
|
248
|
+
| `root.purge()` *(root only)* | `Room` | Unsubscribes from all non-root rooms simultaneously. |
|
|
249
|
+
| `root.rooms()` *(root only)* | `dict[str, Room]` | Dictionary mapping of all active room handles. |
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
### `Packet` Data Attributes & Helpers
|
|
254
|
+
|
|
255
|
+
Decoded binary packet object passed into event handlers:
|
|
256
|
+
|
|
257
|
+
| Attribute / Helper | Type | Description |
|
|
258
|
+
|---|---|---|
|
|
259
|
+
| `packet.room` | `str` | Target channel / room name. |
|
|
260
|
+
| `packet.event` | `str` | Event descriptor string. |
|
|
261
|
+
| `packet.dst` | `str` | Destination member ID (empty string if room broadcast). |
|
|
262
|
+
| `packet.src` | `str` | Sender client ID. |
|
|
263
|
+
| `packet.payload` | `bytes` | Raw binary payload bytes. |
|
|
264
|
+
| `packet.payload_text()` | `str` | Decodes payload as UTF-8 string. |
|
|
265
|
+
| `packet.payload_json()` | `Any` | Unmarshals binary payload as JSON. |
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## 🧪 Testing & Verification
|
|
270
|
+
|
|
271
|
+
Run the test suite using `pytest`:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
cd client/python
|
|
275
|
+
pip install -e ".[dev]"
|
|
276
|
+
pytest -v
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## 📄 License
|
|
282
|
+
|
|
283
|
+
Roomer is open-source software licensed under the [MIT License](../../LICENSE).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "roomer-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "High-performance, room-based WebSocket client for Roomer with binary framing and presence synchronization"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Jon Cody" }
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = [
|
|
16
|
+
"websocket",
|
|
17
|
+
"realtime",
|
|
18
|
+
"rooms",
|
|
19
|
+
"binary",
|
|
20
|
+
"asyncio",
|
|
21
|
+
"clustering"
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"websockets>=12.0"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.0",
|
|
30
|
+
"pytest-asyncio>=0.23"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
include = [
|
|
35
|
+
"roomer.py",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = [
|
|
40
|
+
"roomer.py",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
asyncio_mode = "auto"
|
|
45
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Roomer Python Client SDK.
|
|
3
|
+
|
|
4
|
+
High-performance, room-based WebSocket client with zero-copy binary framing,
|
|
5
|
+
exponential reconnection with jitter, presence synchronization, and full parity
|
|
6
|
+
with Go, Rust, and Node.js servers.
|
|
7
|
+
|
|
8
|
+
License: MIT
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import inspect
|
|
15
|
+
import json
|
|
16
|
+
import random
|
|
17
|
+
import struct
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import websockets
|
|
24
|
+
|
|
25
|
+
HEADER_OVERHEAD: int = 20
|
|
26
|
+
|
|
27
|
+
RESERVED_EVENTS: frozenset[str] = frozenset({
|
|
28
|
+
"close",
|
|
29
|
+
"join",
|
|
30
|
+
"join_ack",
|
|
31
|
+
"leave",
|
|
32
|
+
"leave_ack",
|
|
33
|
+
"member_left",
|
|
34
|
+
"new_member",
|
|
35
|
+
"open"
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
Listener = Callable[..., Any]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ==============================================================================
|
|
42
|
+
# 1. Wire Protocol & Binary Framing
|
|
43
|
+
# ==============================================================================
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Packet:
|
|
47
|
+
"""Represents an immutable decoded Roomer binary wire frame."""
|
|
48
|
+
room: str
|
|
49
|
+
event: str
|
|
50
|
+
dst: str
|
|
51
|
+
src: str
|
|
52
|
+
payload: bytes
|
|
53
|
+
|
|
54
|
+
def payload_text(self, encoding: str = "utf-8") -> str:
|
|
55
|
+
"""Decodes the binary payload as a text string."""
|
|
56
|
+
return self.payload.decode(encoding)
|
|
57
|
+
|
|
58
|
+
def payload_json(self) -> Any:
|
|
59
|
+
"""Parses the binary payload as JSON."""
|
|
60
|
+
if not self.payload:
|
|
61
|
+
raise ValueError("Payload is empty")
|
|
62
|
+
return json.loads(self.payload.decode("utf-8"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def encode_message(
|
|
66
|
+
room: str = "",
|
|
67
|
+
event: str = "",
|
|
68
|
+
dst: str = "",
|
|
69
|
+
src: str = "",
|
|
70
|
+
payload: bytes | bytearray | memoryview | str | dict | list | int | float | bool | None = None
|
|
71
|
+
) -> bytes:
|
|
72
|
+
"""
|
|
73
|
+
Serializes message parameters into a contiguous big-endian length-prefixed binary packet.
|
|
74
|
+
Format: [4B room_len][room][4B event_len][event][4B dst_len][dst][4B src_len][src][4B payload_len][payload]
|
|
75
|
+
"""
|
|
76
|
+
room_bytes = room.encode("utf-8") if isinstance(room, str) else b""
|
|
77
|
+
event_bytes = event.encode("utf-8") if isinstance(event, str) else b""
|
|
78
|
+
dst_bytes = dst.encode("utf-8") if isinstance(dst, str) else b""
|
|
79
|
+
src_bytes = src.encode("utf-8") if isinstance(src, str) else b""
|
|
80
|
+
|
|
81
|
+
if payload is None:
|
|
82
|
+
payload_bytes = b""
|
|
83
|
+
elif isinstance(payload, bytes):
|
|
84
|
+
payload_bytes = payload
|
|
85
|
+
elif isinstance(payload, (bytearray, memoryview)):
|
|
86
|
+
payload_bytes = bytes(payload)
|
|
87
|
+
elif isinstance(payload, str):
|
|
88
|
+
payload_bytes = payload.encode("utf-8")
|
|
89
|
+
elif isinstance(payload, (dict, list)):
|
|
90
|
+
payload_bytes = json.dumps(payload).encode("utf-8")
|
|
91
|
+
elif isinstance(payload, (int, float, bool)):
|
|
92
|
+
payload_bytes = str(payload).encode("utf-8")
|
|
93
|
+
else:
|
|
94
|
+
payload_bytes = b""
|
|
95
|
+
|
|
96
|
+
fmt = f">I{len(room_bytes)}sI{len(event_bytes)}sI{len(dst_bytes)}sI{len(src_bytes)}sI{len(payload_bytes)}s"
|
|
97
|
+
return struct.pack(
|
|
98
|
+
fmt,
|
|
99
|
+
len(room_bytes),
|
|
100
|
+
room_bytes,
|
|
101
|
+
len(event_bytes),
|
|
102
|
+
event_bytes,
|
|
103
|
+
len(dst_bytes),
|
|
104
|
+
dst_bytes,
|
|
105
|
+
len(src_bytes),
|
|
106
|
+
src_bytes,
|
|
107
|
+
len(payload_bytes),
|
|
108
|
+
payload_bytes
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decode_message(data: bytes | bytearray | memoryview) -> Packet | None:
|
|
113
|
+
"""
|
|
114
|
+
Decodes raw binary bytes into a Packet instance. Returns None on malformed input.
|
|
115
|
+
"""
|
|
116
|
+
buf = memoryview(data)
|
|
117
|
+
if len(buf) < HEADER_OVERHEAD:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
offset = 0
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# 1. Room
|
|
124
|
+
(room_len,) = struct.unpack_from(">I", buf, offset)
|
|
125
|
+
offset += 4
|
|
126
|
+
if offset + room_len > len(buf):
|
|
127
|
+
return None
|
|
128
|
+
room = bytes(buf[offset : offset + room_len]).decode("utf-8")
|
|
129
|
+
offset += room_len
|
|
130
|
+
|
|
131
|
+
# 2. Event
|
|
132
|
+
if offset + 4 > len(buf):
|
|
133
|
+
return None
|
|
134
|
+
(event_len,) = struct.unpack_from(">I", buf, offset)
|
|
135
|
+
offset += 4
|
|
136
|
+
if offset + event_len > len(buf):
|
|
137
|
+
return None
|
|
138
|
+
event = bytes(buf[offset : offset + event_len]).decode("utf-8")
|
|
139
|
+
offset += event_len
|
|
140
|
+
|
|
141
|
+
# 3. Dst
|
|
142
|
+
if offset + 4 > len(buf):
|
|
143
|
+
return None
|
|
144
|
+
(dst_len,) = struct.unpack_from(">I", buf, offset)
|
|
145
|
+
offset += 4
|
|
146
|
+
if offset + dst_len > len(buf):
|
|
147
|
+
return None
|
|
148
|
+
dst = bytes(buf[offset : offset + dst_len]).decode("utf-8")
|
|
149
|
+
offset += dst_len
|
|
150
|
+
|
|
151
|
+
# 4. Src
|
|
152
|
+
if offset + 4 > len(buf):
|
|
153
|
+
return None
|
|
154
|
+
(src_len,) = struct.unpack_from(">I", buf, offset)
|
|
155
|
+
offset += 4
|
|
156
|
+
if offset + src_len > len(buf):
|
|
157
|
+
return None
|
|
158
|
+
src = bytes(buf[offset : offset + src_len]).decode("utf-8")
|
|
159
|
+
offset += src_len
|
|
160
|
+
|
|
161
|
+
# 5. Payload
|
|
162
|
+
if offset + 4 > len(buf):
|
|
163
|
+
return None
|
|
164
|
+
(payload_len,) = struct.unpack_from(">I", buf, offset)
|
|
165
|
+
offset += 4
|
|
166
|
+
if offset + payload_len != len(buf):
|
|
167
|
+
return None
|
|
168
|
+
payload = bytes(buf[offset : offset + payload_len])
|
|
169
|
+
|
|
170
|
+
return Packet(room=room, event=event, dst=dst, src=src, payload=payload)
|
|
171
|
+
except (struct.error, UnicodeDecodeError):
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ==============================================================================
|
|
176
|
+
# 2. Async-Compatible Event Emitter
|
|
177
|
+
# ==============================================================================
|
|
178
|
+
|
|
179
|
+
class EventEmitter:
|
|
180
|
+
"""Lightweight event emitter supporting both sync functions and async coroutines."""
|
|
181
|
+
|
|
182
|
+
def __init__(self) -> None:
|
|
183
|
+
self._events: dict[str, list[Listener]] = defaultdict(list)
|
|
184
|
+
|
|
185
|
+
def on(self, event: str, listener: Listener | None = None) -> Listener | Callable[[Listener], Listener]:
|
|
186
|
+
"""Registers an event listener callback or decorator."""
|
|
187
|
+
def decorator(fn: Listener) -> Listener:
|
|
188
|
+
self._events[event].append(fn)
|
|
189
|
+
return fn
|
|
190
|
+
|
|
191
|
+
if listener is not None:
|
|
192
|
+
return decorator(listener)
|
|
193
|
+
return decorator
|
|
194
|
+
|
|
195
|
+
def once(self, event: str, listener: Listener | None = None) -> Listener | Callable[[Listener], Listener]:
|
|
196
|
+
"""Registers a one-time event listener callback."""
|
|
197
|
+
def decorator(fn: Listener) -> Listener:
|
|
198
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
199
|
+
self.off(event, wrapper)
|
|
200
|
+
return fn(*args, **kwargs)
|
|
201
|
+
|
|
202
|
+
setattr(wrapper, "_original_fn", fn)
|
|
203
|
+
self._events[event].append(wrapper)
|
|
204
|
+
return fn
|
|
205
|
+
|
|
206
|
+
if listener is not None:
|
|
207
|
+
return decorator(listener)
|
|
208
|
+
return decorator
|
|
209
|
+
|
|
210
|
+
def off(self, event: str, listener: Listener) -> None:
|
|
211
|
+
"""Removes a registered event listener callback."""
|
|
212
|
+
if event in self._events:
|
|
213
|
+
self._events[event] = [
|
|
214
|
+
fn for fn in self._events[event]
|
|
215
|
+
if fn != listener and getattr(fn, "_original_fn", None) != listener
|
|
216
|
+
]
|
|
217
|
+
if not self._events[event]:
|
|
218
|
+
del self._events[event]
|
|
219
|
+
|
|
220
|
+
def emit(self, event: str, *args: Any, **kwargs: Any) -> bool:
|
|
221
|
+
"""Synchronously invokes listeners, dispatching coroutines to the event loop."""
|
|
222
|
+
listeners = list(self._events.get(event, []))
|
|
223
|
+
if not listeners:
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
for fn in listeners:
|
|
227
|
+
try:
|
|
228
|
+
res = fn(*args, **kwargs)
|
|
229
|
+
if inspect.isawaitable(res):
|
|
230
|
+
try:
|
|
231
|
+
loop = asyncio.get_running_loop()
|
|
232
|
+
loop.create_task(res)
|
|
233
|
+
except RuntimeError:
|
|
234
|
+
pass
|
|
235
|
+
except Exception as err:
|
|
236
|
+
print(f"[Roomer] Unhandled exception in listener for '{event}': {err}")
|
|
237
|
+
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
def remove_all_listeners(self, event: str | None = None) -> None:
|
|
241
|
+
"""Removes all registered listeners, or those for a specific event."""
|
|
242
|
+
if event is None:
|
|
243
|
+
self._events.clear()
|
|
244
|
+
elif event in self._events:
|
|
245
|
+
del self._events[event]
|
|
246
|
+
|
|
247
|
+
def listeners(self, event: str) -> list[Listener]:
|
|
248
|
+
"""Returns a copy of registered listener callbacks for an event."""
|
|
249
|
+
return list(self._events.get(event, []))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ==============================================================================
|
|
253
|
+
# 3. Room Channel Handle
|
|
254
|
+
# ==============================================================================
|
|
255
|
+
|
|
256
|
+
class Room(EventEmitter):
|
|
257
|
+
"""Represents a room channel subscription over the WebSocket connection."""
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
name: str,
|
|
262
|
+
send_packet_fn: Callable[[str, str, str, str, Any], None],
|
|
263
|
+
get_room_fn: Callable[[str], Room],
|
|
264
|
+
is_socket_open_fn: Callable[[], bool],
|
|
265
|
+
) -> None:
|
|
266
|
+
super().__init__()
|
|
267
|
+
self.name = name
|
|
268
|
+
self._send_packet = send_packet_fn
|
|
269
|
+
self._get_room = get_room_fn
|
|
270
|
+
self._is_socket_open = is_socket_open_fn
|
|
271
|
+
|
|
272
|
+
self._member_id: str = ""
|
|
273
|
+
self._is_open: bool = False
|
|
274
|
+
self._members: list[str] = []
|
|
275
|
+
self._custom_events: set[str] = set()
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def id(self) -> str:
|
|
279
|
+
"""Connection UUID string assigned by the server."""
|
|
280
|
+
return self._member_id
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def is_open(self) -> bool:
|
|
284
|
+
"""Whether room membership is currently active."""
|
|
285
|
+
return self._is_open
|
|
286
|
+
|
|
287
|
+
def open(self) -> bool:
|
|
288
|
+
"""Method alias for is_open."""
|
|
289
|
+
return self._is_open
|
|
290
|
+
|
|
291
|
+
def members(self) -> list[str]:
|
|
292
|
+
"""Returns a shallow copy of active member IDs in this room."""
|
|
293
|
+
return list(self._members)
|
|
294
|
+
|
|
295
|
+
def join(self, room_name: str) -> Room:
|
|
296
|
+
"""Subscribes to a new room channel over the existing connection."""
|
|
297
|
+
if not self._is_open:
|
|
298
|
+
raise RuntimeError("Cannot join: current room is closed.")
|
|
299
|
+
if not isinstance(room_name, str):
|
|
300
|
+
raise TypeError("Room name must be a string.")
|
|
301
|
+
return self._get_room(room_name)
|
|
302
|
+
|
|
303
|
+
def leave(self) -> Room:
|
|
304
|
+
"""Leaves the current room and notifies the cluster."""
|
|
305
|
+
if not self._is_open:
|
|
306
|
+
raise RuntimeError("Cannot leave: room is closed.")
|
|
307
|
+
if self._is_socket_open():
|
|
308
|
+
self._send_packet(self.name, "leave", "", "", b"")
|
|
309
|
+
return self
|
|
310
|
+
|
|
311
|
+
def send(self, event: str, payload: Any = None, dst: str = "") -> Room:
|
|
312
|
+
"""Sends an event message to the room or directly to a member ID."""
|
|
313
|
+
if not self._is_open:
|
|
314
|
+
raise RuntimeError("Cannot send: socket is closed.")
|
|
315
|
+
if not isinstance(event, str):
|
|
316
|
+
raise TypeError("Event name must be a string.")
|
|
317
|
+
if event in RESERVED_EVENTS:
|
|
318
|
+
raise ValueError(f"Cannot send reserved event: '{event}'")
|
|
319
|
+
|
|
320
|
+
if self._is_socket_open():
|
|
321
|
+
self._send_packet(self.name, event, dst, self._member_id, payload)
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def clear_listeners(self, exceptions: list[str] | set[str] | None = None) -> Room:
|
|
325
|
+
"""Clears registered event listeners except those listed in exceptions."""
|
|
326
|
+
exc = set(exceptions or [])
|
|
327
|
+
for event_name in list(self._custom_events):
|
|
328
|
+
if event_name not in exc:
|
|
329
|
+
self.remove_all_listeners(event_name)
|
|
330
|
+
self._custom_events.discard(event_name)
|
|
331
|
+
return self
|
|
332
|
+
|
|
333
|
+
def force_close(self, is_disconnect: bool = False) -> Room:
|
|
334
|
+
"""Closes the room locally and clears tracked state."""
|
|
335
|
+
if self._is_open:
|
|
336
|
+
self._is_open = False
|
|
337
|
+
self._members.clear()
|
|
338
|
+
self.emit("close")
|
|
339
|
+
if not is_disconnect:
|
|
340
|
+
self._member_id = ""
|
|
341
|
+
return self
|
|
342
|
+
|
|
343
|
+
def parse(self, packet: Packet) -> None:
|
|
344
|
+
"""Dispatches an incoming parsed packet to room event listeners."""
|
|
345
|
+
match packet.event:
|
|
346
|
+
case "join_ack":
|
|
347
|
+
self._member_id = packet.src
|
|
348
|
+
self._members.clear()
|
|
349
|
+
try:
|
|
350
|
+
parsed = json.loads(packet.payload_text())
|
|
351
|
+
if isinstance(parsed, list):
|
|
352
|
+
self._members.extend(str(m) for m in parsed)
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
self._is_open = True
|
|
356
|
+
self.emit("open")
|
|
357
|
+
|
|
358
|
+
case "new_member":
|
|
359
|
+
member_id = packet.payload_text()
|
|
360
|
+
if member_id and member_id not in self._members:
|
|
361
|
+
self._members.append(member_id)
|
|
362
|
+
self.emit("new_member", member_id)
|
|
363
|
+
|
|
364
|
+
case "leave_ack":
|
|
365
|
+
self.emit("close")
|
|
366
|
+
self._is_open = False
|
|
367
|
+
self._members.clear()
|
|
368
|
+
self._member_id = ""
|
|
369
|
+
|
|
370
|
+
case "member_left":
|
|
371
|
+
member_id = packet.payload_text()
|
|
372
|
+
if member_id in self._members:
|
|
373
|
+
self._members.remove(member_id)
|
|
374
|
+
self.emit("member_left", member_id)
|
|
375
|
+
|
|
376
|
+
case _:
|
|
377
|
+
self.emit(packet.event, packet.payload, packet.src)
|
|
378
|
+
|
|
379
|
+
def on(self, event: str, listener: Listener | None = None) -> Any:
|
|
380
|
+
if event not in RESERVED_EVENTS:
|
|
381
|
+
self._custom_events.add(event)
|
|
382
|
+
return super().on(event, listener)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ==============================================================================
|
|
386
|
+
# 4. Connection Lifecycle & Context Manager
|
|
387
|
+
# ==============================================================================
|
|
388
|
+
|
|
389
|
+
class RoomerClient:
|
|
390
|
+
"""Manages the underlying WebSocket connection and room multiplexing."""
|
|
391
|
+
|
|
392
|
+
def __init__(
|
|
393
|
+
self,
|
|
394
|
+
url: str,
|
|
395
|
+
reconnect: bool = True,
|
|
396
|
+
initial_delay: float = 0.5,
|
|
397
|
+
max_delay: float = 5.0,
|
|
398
|
+
backoff_factor: float = 1.5,
|
|
399
|
+
) -> None:
|
|
400
|
+
if not isinstance(url, str):
|
|
401
|
+
raise TypeError("WebSocket URL must be a string.")
|
|
402
|
+
|
|
403
|
+
self.url = url
|
|
404
|
+
self.reconnect = reconnect
|
|
405
|
+
self.initial_delay = initial_delay
|
|
406
|
+
self.max_delay = max_delay
|
|
407
|
+
self.backoff_factor = backoff_factor
|
|
408
|
+
|
|
409
|
+
self._rooms: dict[str, Room] = {}
|
|
410
|
+
self._ws: Any = None
|
|
411
|
+
self._running: bool = False
|
|
412
|
+
self._manual_close: bool = False
|
|
413
|
+
self._task: asyncio.Task[None] | None = None
|
|
414
|
+
self._reconnect_delay: float = initial_delay
|
|
415
|
+
|
|
416
|
+
self._root = self.get_room("root")
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def root(self) -> Room:
|
|
420
|
+
"""Returns the default 'root' room instance."""
|
|
421
|
+
return self._root
|
|
422
|
+
|
|
423
|
+
def get_room(self, name: str) -> Room:
|
|
424
|
+
"""Retrieves or instantiates a room client interface by name."""
|
|
425
|
+
if not isinstance(name, str):
|
|
426
|
+
raise TypeError("Room name must be a string.")
|
|
427
|
+
if name in self._rooms:
|
|
428
|
+
return self._rooms[name]
|
|
429
|
+
|
|
430
|
+
room = Room(
|
|
431
|
+
name=name,
|
|
432
|
+
send_packet_fn=self._send_packet,
|
|
433
|
+
get_room_fn=self.get_room,
|
|
434
|
+
is_socket_open_fn=self.is_connected,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
if name == "root":
|
|
438
|
+
def purge() -> Room:
|
|
439
|
+
for r_name in list(self._rooms.keys()):
|
|
440
|
+
if r_name != "root":
|
|
441
|
+
self._rooms[r_name].leave()
|
|
442
|
+
return room
|
|
443
|
+
|
|
444
|
+
def rooms_map() -> dict[str, Room]:
|
|
445
|
+
return dict(self._rooms)
|
|
446
|
+
|
|
447
|
+
setattr(room, "purge", purge)
|
|
448
|
+
setattr(room, "rooms", rooms_map)
|
|
449
|
+
|
|
450
|
+
self._rooms[name] = room
|
|
451
|
+
|
|
452
|
+
if name != "root" and self.is_connected():
|
|
453
|
+
self._send_packet(name, "join", "", "", b"")
|
|
454
|
+
|
|
455
|
+
return room
|
|
456
|
+
|
|
457
|
+
def is_connected(self) -> bool:
|
|
458
|
+
"""Returns True if the underlying WebSocket connection is active."""
|
|
459
|
+
return self._ws is not None and not getattr(self._ws, "closed", False)
|
|
460
|
+
|
|
461
|
+
def _send_packet(
|
|
462
|
+
self,
|
|
463
|
+
room: str,
|
|
464
|
+
event: str,
|
|
465
|
+
dst: str,
|
|
466
|
+
src: str,
|
|
467
|
+
payload: Any
|
|
468
|
+
) -> None:
|
|
469
|
+
"""Serializes and transmits a binary frame over the WebSocket."""
|
|
470
|
+
if self.is_connected() and self._ws is not None:
|
|
471
|
+
raw = encode_message(room, event, dst, src, payload)
|
|
472
|
+
try:
|
|
473
|
+
loop = asyncio.get_running_loop()
|
|
474
|
+
loop.create_task(self._ws.send(raw))
|
|
475
|
+
except RuntimeError:
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
async def connect(self) -> Room:
|
|
479
|
+
"""Starts the background event loop and waits for root join_ack."""
|
|
480
|
+
if self._running:
|
|
481
|
+
return self._root
|
|
482
|
+
|
|
483
|
+
self._running = True
|
|
484
|
+
self._manual_close = False
|
|
485
|
+
self._task = asyncio.create_task(self._run_loop())
|
|
486
|
+
|
|
487
|
+
while self._running and not self._root.is_open:
|
|
488
|
+
await asyncio.sleep(0.010)
|
|
489
|
+
|
|
490
|
+
return self._root
|
|
491
|
+
|
|
492
|
+
async def _run_loop(self) -> None:
|
|
493
|
+
"""Background connection supervisor with exponential backoff and jitter."""
|
|
494
|
+
while self._running:
|
|
495
|
+
try:
|
|
496
|
+
async with websockets.connect(self.url) as ws:
|
|
497
|
+
self._ws = ws
|
|
498
|
+
self._reconnect_delay = self.initial_delay
|
|
499
|
+
|
|
500
|
+
# Re-join all non-root active rooms upon reconnection
|
|
501
|
+
for r_name in list(self._rooms.keys()):
|
|
502
|
+
if r_name != "root":
|
|
503
|
+
raw = encode_message(r_name, "join", "", "", b"")
|
|
504
|
+
await ws.send(raw)
|
|
505
|
+
|
|
506
|
+
# Reader loop
|
|
507
|
+
async for raw_message in ws:
|
|
508
|
+
if isinstance(raw_message, (bytes, bytearray, memoryview)):
|
|
509
|
+
packet = decode_message(raw_message)
|
|
510
|
+
if packet is not None and packet.room in self._rooms:
|
|
511
|
+
self._rooms[packet.room].parse(packet)
|
|
512
|
+
|
|
513
|
+
except (websockets.exceptions.WebSocketException, OSError, asyncio.CancelledError):
|
|
514
|
+
pass
|
|
515
|
+
finally:
|
|
516
|
+
self._ws = None
|
|
517
|
+
is_reconnecting = self.reconnect and not self._manual_close and self._running
|
|
518
|
+
for r in list(self._rooms.values()):
|
|
519
|
+
r.force_close(is_disconnect=is_reconnecting)
|
|
520
|
+
|
|
521
|
+
if not self.reconnect or self._manual_close or not self._running:
|
|
522
|
+
break
|
|
523
|
+
|
|
524
|
+
jitter = random.uniform(0, 0.200)
|
|
525
|
+
await asyncio.sleep(self._reconnect_delay + jitter)
|
|
526
|
+
self._reconnect_delay = min(self._reconnect_delay * self.backoff_factor, self.max_delay)
|
|
527
|
+
|
|
528
|
+
async def close(self) -> None:
|
|
529
|
+
"""Gracefully closes all rooms and the WebSocket connection."""
|
|
530
|
+
self._manual_close = True
|
|
531
|
+
self._running = False
|
|
532
|
+
if self._ws is not None:
|
|
533
|
+
await self._ws.close()
|
|
534
|
+
if self._task is not None:
|
|
535
|
+
self._task.cancel()
|
|
536
|
+
try:
|
|
537
|
+
await self._task
|
|
538
|
+
except asyncio.CancelledError:
|
|
539
|
+
pass
|
|
540
|
+
for r in list(self._rooms.values()):
|
|
541
|
+
r.force_close(is_disconnect=False)
|
|
542
|
+
self._rooms.clear()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class RoomerContext:
|
|
546
|
+
"""Async context manager wrapper for Roomer."""
|
|
547
|
+
|
|
548
|
+
def __init__(self, url: str, **kwargs: Any) -> None:
|
|
549
|
+
self.client = RoomerClient(url, **kwargs)
|
|
550
|
+
|
|
551
|
+
async def __aenter__(self) -> Room:
|
|
552
|
+
await self.client.connect()
|
|
553
|
+
return self.client.root
|
|
554
|
+
|
|
555
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
556
|
+
await self.client.close()
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def roomer(url: str, **kwargs: Any) -> RoomerContext:
|
|
560
|
+
"""
|
|
561
|
+
Initializes a Roomer client instance. Supports async context manager:
|
|
562
|
+
async with roomer("ws://localhost:8080/ws") as root:
|
|
563
|
+
lobby = root.join("lobby")
|
|
564
|
+
"""
|
|
565
|
+
return RoomerContext(url, **kwargs)
|