afterlink 2.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.
- afterlink-2.0.0/.gitignore +31 -0
- afterlink-2.0.0/PKG-INFO +58 -0
- afterlink-2.0.0/README.md +42 -0
- afterlink-2.0.0/afterlink/__init__.py +6 -0
- afterlink-2.0.0/afterlink/client.py +327 -0
- afterlink-2.0.0/afterlink/errors.py +67 -0
- afterlink-2.0.0/afterlink/protocol/__init__.py +1 -0
- afterlink-2.0.0/afterlink/protocol/codec.py +13 -0
- afterlink-2.0.0/afterlink/protocol/compression.py +22 -0
- afterlink-2.0.0/afterlink/protocol/frame.py +134 -0
- afterlink-2.0.0/afterlink/schema.py +19 -0
- afterlink-2.0.0/afterlink/server.py +207 -0
- afterlink-2.0.0/afterlink/sync.py +22 -0
- afterlink-2.0.0/pyproject.toml +29 -0
- afterlink-2.0.0/tests/__init__.py +1 -0
- afterlink-2.0.0/tests/test_client.py +29 -0
- afterlink-2.0.0/tests/test_errors.py +17 -0
- afterlink-2.0.0/tests/test_protocol.py +69 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.env
|
|
4
|
+
*.log
|
|
5
|
+
.DS_Store
|
|
6
|
+
coverage/
|
|
7
|
+
.agents/
|
|
8
|
+
AfterLink-v1.1.0-Phase1-PRD.md
|
|
9
|
+
|
|
10
|
+
# AI Assistant generated/binary data (large files not needed in source control)
|
|
11
|
+
data/afterlink_index/
|
|
12
|
+
data/feedback.json
|
|
13
|
+
packages/ai-assistant/src/model-weights.json
|
|
14
|
+
packages/ai-assistant/src/vocabulary.json
|
|
15
|
+
|
|
16
|
+
# AI upgrade scratch work
|
|
17
|
+
AI-upgrade/
|
|
18
|
+
|
|
19
|
+
# Google site verification (auto-generated, not needed in source)
|
|
20
|
+
googlee7e38f2aff23cac0.html
|
|
21
|
+
|
|
22
|
+
# Throwaway test / scratch files
|
|
23
|
+
test-proof.txt
|
|
24
|
+
test-demos.js
|
|
25
|
+
verify-features.js
|
|
26
|
+
|
|
27
|
+
# AI agent internal lock file
|
|
28
|
+
skills-lock.json
|
|
29
|
+
|
|
30
|
+
# One-time publish utility (not needed by contributors)
|
|
31
|
+
publish.ps1
|
afterlink-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: afterlink
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: AfterLink Protocol SDK - TCP client/server with routing, middleware, and pub/sub
|
|
5
|
+
Author-email: Ajay Myth <ajaymyth@github.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: msgpack>=1.0.8
|
|
12
|
+
Requires-Dist: pydantic>=2.0
|
|
13
|
+
Provides-Extra: uvloop
|
|
14
|
+
Requires-Dist: uvloop>=0.17.0; extra == 'uvloop'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# AfterLink Python SDK
|
|
18
|
+
|
|
19
|
+
Python Client and Server SDK for the binary AfterLink protocol.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install afterlink
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### Server
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import asyncio
|
|
33
|
+
from afterlink import Server
|
|
34
|
+
|
|
35
|
+
server = Server(port=4000)
|
|
36
|
+
|
|
37
|
+
@server.on("users/get")
|
|
38
|
+
async def get_user(req):
|
|
39
|
+
return {"id": 1, "username": "ajay"}
|
|
40
|
+
|
|
41
|
+
asyncio.run(server.listen())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Client
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from afterlink import Client
|
|
49
|
+
|
|
50
|
+
async def main():
|
|
51
|
+
client = Client("tcp://localhost:4000")
|
|
52
|
+
await client.connect()
|
|
53
|
+
res = await client.request("users/get")
|
|
54
|
+
print(res)
|
|
55
|
+
await client.disconnect()
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# AfterLink Python SDK
|
|
2
|
+
|
|
3
|
+
Python Client and Server SDK for the binary AfterLink protocol.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install afterlink
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Server
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
from afterlink import Server
|
|
18
|
+
|
|
19
|
+
server = Server(port=4000)
|
|
20
|
+
|
|
21
|
+
@server.on("users/get")
|
|
22
|
+
async def get_user(req):
|
|
23
|
+
return {"id": 1, "username": "ajay"}
|
|
24
|
+
|
|
25
|
+
asyncio.run(server.listen())
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Client
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
from afterlink import Client
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
client = Client("tcp://localhost:4000")
|
|
36
|
+
await client.connect()
|
|
37
|
+
res = await client.request("users/get")
|
|
38
|
+
print(res)
|
|
39
|
+
await client.disconnect()
|
|
40
|
+
|
|
41
|
+
asyncio.run(main())
|
|
42
|
+
```
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
from typing import Callable, Any, AsyncGenerator
|
|
5
|
+
|
|
6
|
+
from .errors import AfterLinkError
|
|
7
|
+
from .protocol import frame, codec, compression
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("afterlink.client")
|
|
10
|
+
|
|
11
|
+
class Client:
|
|
12
|
+
def __init__(self, url: str, options: dict = None):
|
|
13
|
+
self.url = url
|
|
14
|
+
self.options = options or {}
|
|
15
|
+
|
|
16
|
+
self.host = self.options.get("host", "localhost")
|
|
17
|
+
self.port = self.options.get("port", 4000)
|
|
18
|
+
self.auto_reconnect = self.options.get("auto_reconnect", True)
|
|
19
|
+
self.reconnect_attempts = self.options.get("reconnect_attempts", 5)
|
|
20
|
+
self.reconnect_delay = self.options.get("reconnect_delay", 1.0)
|
|
21
|
+
self.timeout = self.options.get("timeout", 10.0)
|
|
22
|
+
self.auth_token = self.options.get("auth")
|
|
23
|
+
self.protocol_version = self.options.get("protocol", 2)
|
|
24
|
+
|
|
25
|
+
self.reader = None
|
|
26
|
+
self.writer = None
|
|
27
|
+
self.connected = False
|
|
28
|
+
self.session_id = None
|
|
29
|
+
self.version = 1
|
|
30
|
+
|
|
31
|
+
self._message_id = 0
|
|
32
|
+
self._pending_requests = {}
|
|
33
|
+
self._subscriptions = {}
|
|
34
|
+
self._read_task = None
|
|
35
|
+
self._is_closing = False
|
|
36
|
+
|
|
37
|
+
if "://" in url:
|
|
38
|
+
parts = url.split("://")[1].split(":")
|
|
39
|
+
self.host = parts[0]
|
|
40
|
+
if len(parts) > 1:
|
|
41
|
+
self.port = int(parts[1])
|
|
42
|
+
|
|
43
|
+
async def connect(self):
|
|
44
|
+
self._is_closing = False
|
|
45
|
+
logger.info(f"Connecting to AfterLink server at {self.host}:{self.port}")
|
|
46
|
+
|
|
47
|
+
self.reader, self.writer = await asyncio.open_connection(self.host, self.port)
|
|
48
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
49
|
+
|
|
50
|
+
await self._handshake()
|
|
51
|
+
self.connected = True
|
|
52
|
+
logger.info(f"Connected successfully. Session ID: {self.session_id}, Protocol: v{self.version}")
|
|
53
|
+
|
|
54
|
+
async def _handshake(self):
|
|
55
|
+
hello_payload = {
|
|
56
|
+
"version": f"AL/{self.protocol_version}",
|
|
57
|
+
"capabilities": ["json", "msgpack"],
|
|
58
|
+
"auth": self.auth_token
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
msg_id = self._next_message_id()
|
|
62
|
+
payload_bytes = codec.serialize(hello_payload)
|
|
63
|
+
|
|
64
|
+
frame_bytes = frame.encode_v1(frame.HELLO, 0, msg_id, payload_bytes)
|
|
65
|
+
self.writer.write(frame_bytes)
|
|
66
|
+
await self.writer.drain()
|
|
67
|
+
|
|
68
|
+
fut = asyncio.get_running_loop().create_future()
|
|
69
|
+
self._pending_requests[msg_id] = fut
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
ack_frame = await asyncio.wait_for(fut, timeout=self.timeout)
|
|
73
|
+
ack_data = codec.deserialize(ack_frame["payload"])
|
|
74
|
+
|
|
75
|
+
if ack_frame["type"] == frame.ERROR:
|
|
76
|
+
raise AfterLinkError(ack_data.get("code", 1), ack_data.get("message", "Handshake failed"))
|
|
77
|
+
|
|
78
|
+
self.session_id = ack_data.get("session_id")
|
|
79
|
+
accepted_proto = ack_data.get("accepted_protocol", "v1")
|
|
80
|
+
self.version = 2 if accepted_proto == "v2" else 1
|
|
81
|
+
except asyncio.TimeoutError:
|
|
82
|
+
raise AfterLinkError(AfterLinkError.CONNECTION_TIMEOUT, "Handshake timed out")
|
|
83
|
+
finally:
|
|
84
|
+
self._pending_requests.pop(msg_id, None)
|
|
85
|
+
|
|
86
|
+
def _next_message_id(self) -> int:
|
|
87
|
+
self._message_id = (self._message_id + 1) & 0xFFFFFFFF
|
|
88
|
+
return self._message_id
|
|
89
|
+
|
|
90
|
+
async def request(self, route: str, body: Any = None, *, routing_key: str = None, priority: int = None) -> Any:
|
|
91
|
+
if not self.connected:
|
|
92
|
+
raise AfterLinkError(AfterLinkError.CONNECTION_CLOSED, "Client not connected")
|
|
93
|
+
|
|
94
|
+
req_payload = {
|
|
95
|
+
"route": route,
|
|
96
|
+
"body": body or {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
msg_id = self._next_message_id()
|
|
100
|
+
payload_bytes = codec.serialize(req_payload)
|
|
101
|
+
|
|
102
|
+
payload_bytes, compressed = compression.compress(payload_bytes)
|
|
103
|
+
flags = compression.FLAGS_COMPRESSED if compressed else 0
|
|
104
|
+
|
|
105
|
+
if self.version == 2:
|
|
106
|
+
frame_bytes = frame.encode_v2(
|
|
107
|
+
frame.REQUEST, flags, msg_id, payload_bytes,
|
|
108
|
+
priority=priority if priority is not None else 3,
|
|
109
|
+
routing_key=routing_key or ''
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
frame_bytes = frame.encode_v1(frame.REQUEST, flags, msg_id, payload_bytes)
|
|
113
|
+
|
|
114
|
+
self.writer.write(frame_bytes)
|
|
115
|
+
await self.writer.drain()
|
|
116
|
+
|
|
117
|
+
fut = asyncio.get_running_loop().create_future()
|
|
118
|
+
self._pending_requests[msg_id] = fut
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
res_frame = await asyncio.wait_for(fut, timeout=self.timeout)
|
|
122
|
+
|
|
123
|
+
res_flags = res_frame["flags"]
|
|
124
|
+
res_payload = res_frame["payload"]
|
|
125
|
+
|
|
126
|
+
if res_flags & compression.FLAGS_COMPRESSED:
|
|
127
|
+
res_payload = compression.decompress(res_payload)
|
|
128
|
+
|
|
129
|
+
res_data = codec.deserialize(res_payload)
|
|
130
|
+
|
|
131
|
+
if res_frame["type"] == frame.ERROR:
|
|
132
|
+
raise AfterLinkError(
|
|
133
|
+
res_data.get("code", 10),
|
|
134
|
+
res_data.get("message", "Request failed"),
|
|
135
|
+
details=res_data.get("details"),
|
|
136
|
+
retry_after=res_data.get("retry_after"),
|
|
137
|
+
meta=res_data.get("meta")
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return res_data.get("body")
|
|
141
|
+
finally:
|
|
142
|
+
self._pending_requests.pop(msg_id, None)
|
|
143
|
+
|
|
144
|
+
async def subscribe(self, topic: str, callback: Callable[[Any], None]) -> Callable[[], None]:
|
|
145
|
+
if not self.connected:
|
|
146
|
+
raise AfterLinkError(AfterLinkError.CONNECTION_CLOSED, "Client not connected")
|
|
147
|
+
|
|
148
|
+
await self.request("pubsub/subscribe", {"topic": topic})
|
|
149
|
+
|
|
150
|
+
if topic not in self._subscriptions:
|
|
151
|
+
self._subscriptions[topic] = set()
|
|
152
|
+
self._subscriptions[topic].add(callback)
|
|
153
|
+
|
|
154
|
+
async def unsubscribe():
|
|
155
|
+
if topic in self._subscriptions:
|
|
156
|
+
self._subscriptions[topic].discard(callback)
|
|
157
|
+
if len(self._subscriptions[topic]) == 0:
|
|
158
|
+
del self._subscriptions[topic]
|
|
159
|
+
try:
|
|
160
|
+
await self.request("pubsub/unsubscribe", {"topic": topic})
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
return unsubscribe
|
|
164
|
+
|
|
165
|
+
async def publish(self, topic: str, data: Any):
|
|
166
|
+
if not self.connected:
|
|
167
|
+
raise AfterLinkError(AfterLinkError.CONNECTION_CLOSED, "Client not connected")
|
|
168
|
+
await self.request("pubsub/publish", {"topic": topic, "data": data})
|
|
169
|
+
|
|
170
|
+
async def stream(self, route: str, body: Any = None) -> AsyncGenerator[Any, None]:
|
|
171
|
+
if not self.connected:
|
|
172
|
+
raise AfterLinkError(AfterLinkError.CONNECTION_CLOSED, "Client not connected")
|
|
173
|
+
|
|
174
|
+
req_payload = {
|
|
175
|
+
"route": route,
|
|
176
|
+
"body": body or {}
|
|
177
|
+
}
|
|
178
|
+
msg_id = self._next_message_id()
|
|
179
|
+
payload_bytes = codec.serialize(req_payload)
|
|
180
|
+
|
|
181
|
+
# STREAM_START
|
|
182
|
+
start_frame = frame.encode(
|
|
183
|
+
version=self.version,
|
|
184
|
+
frame_type=frame.STREAM_START,
|
|
185
|
+
flags=0,
|
|
186
|
+
message_id=msg_id,
|
|
187
|
+
payload_bytes=payload_bytes
|
|
188
|
+
)
|
|
189
|
+
self.writer.write(start_frame)
|
|
190
|
+
await self.writer.drain()
|
|
191
|
+
|
|
192
|
+
queue = asyncio.Queue()
|
|
193
|
+
self._pending_requests[msg_id] = queue
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
while True:
|
|
197
|
+
item = await asyncio.wait_for(queue.get(), timeout=self.timeout)
|
|
198
|
+
if isinstance(item, Exception):
|
|
199
|
+
raise item
|
|
200
|
+
|
|
201
|
+
ftype = item["type"]
|
|
202
|
+
flags = item["flags"]
|
|
203
|
+
payload = item["payload"]
|
|
204
|
+
|
|
205
|
+
if flags & compression.FLAGS_COMPRESSED:
|
|
206
|
+
payload = compression.decompress(payload)
|
|
207
|
+
|
|
208
|
+
data = codec.deserialize(payload)
|
|
209
|
+
|
|
210
|
+
if ftype == frame.STREAM_DATA:
|
|
211
|
+
yield data.get("body")
|
|
212
|
+
elif ftype == frame.STREAM_END:
|
|
213
|
+
break
|
|
214
|
+
elif ftype == frame.ERROR:
|
|
215
|
+
raise AfterLinkError(data.get("code", 10), data.get("message", "Stream error"))
|
|
216
|
+
finally:
|
|
217
|
+
self._pending_requests.pop(msg_id, None)
|
|
218
|
+
|
|
219
|
+
async def _read_loop(self):
|
|
220
|
+
buffer = bytearray()
|
|
221
|
+
try:
|
|
222
|
+
while not self._is_closing:
|
|
223
|
+
chunk = await self.reader.read(4096)
|
|
224
|
+
if not chunk:
|
|
225
|
+
break
|
|
226
|
+
buffer.extend(chunk)
|
|
227
|
+
|
|
228
|
+
while True:
|
|
229
|
+
decoded = frame.decode_auto(bytes(buffer))
|
|
230
|
+
if decoded is None:
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
del buffer[:decoded["totalSize"]]
|
|
234
|
+
self._process_incoming_frame(decoded)
|
|
235
|
+
except asyncio.CancelledError:
|
|
236
|
+
pass
|
|
237
|
+
except Exception as err:
|
|
238
|
+
logger.error(f"Error in client read loop: {err}")
|
|
239
|
+
finally:
|
|
240
|
+
self.connected = False
|
|
241
|
+
self._cleanup_pending()
|
|
242
|
+
if not self._is_closing and self.auto_reconnect:
|
|
243
|
+
asyncio.create_task(self._reconnect_loop())
|
|
244
|
+
|
|
245
|
+
def _process_incoming_frame(self, f: dict):
|
|
246
|
+
ftype = f["type"]
|
|
247
|
+
msg_id = f["messageId"]
|
|
248
|
+
|
|
249
|
+
if ftype in (frame.RESPONSE, frame.ERROR, frame.STREAM_DATA, frame.STREAM_END):
|
|
250
|
+
dest = self._pending_requests.get(msg_id)
|
|
251
|
+
if dest:
|
|
252
|
+
if isinstance(dest, asyncio.Future):
|
|
253
|
+
if not dest.done():
|
|
254
|
+
dest.set_result(f)
|
|
255
|
+
elif isinstance(dest, asyncio.Queue):
|
|
256
|
+
dest.put_nowait(f)
|
|
257
|
+
elif ftype == frame.PING:
|
|
258
|
+
pong_bytes = frame.encode(version=self.version, frame_type=frame.PONG, flags=0, message_id=msg_id, payload_bytes=b'')
|
|
259
|
+
self.writer.write(pong_bytes)
|
|
260
|
+
asyncio.create_task(self.writer.drain())
|
|
261
|
+
elif ftype in (frame.PUBLISH, frame.BROADCAST):
|
|
262
|
+
try:
|
|
263
|
+
payload = f["payload"]
|
|
264
|
+
if f["flags"] & compression.FLAGS_COMPRESSED:
|
|
265
|
+
payload = compression.decompress(payload)
|
|
266
|
+
data = codec.deserialize(payload)
|
|
267
|
+
topic = data.get("topic")
|
|
268
|
+
msg_data = data.get("data")
|
|
269
|
+
|
|
270
|
+
if topic in self._subscriptions:
|
|
271
|
+
for cb in self._subscriptions[topic]:
|
|
272
|
+
try:
|
|
273
|
+
cb(msg_data)
|
|
274
|
+
except Exception as e:
|
|
275
|
+
logger.error(f"Error in subscriber callback: {e}")
|
|
276
|
+
except Exception as err:
|
|
277
|
+
logger.error(f"Failed to process pubsub broadcast: {err}")
|
|
278
|
+
|
|
279
|
+
def _cleanup_pending(self):
|
|
280
|
+
for dest in list(self._pending_requests.values()):
|
|
281
|
+
if isinstance(dest, asyncio.Future) and not dest.done():
|
|
282
|
+
dest.set_exception(AfterLinkError(AfterLinkError.CONNECTION_CLOSED, "Connection closed"))
|
|
283
|
+
elif isinstance(dest, asyncio.Queue):
|
|
284
|
+
dest.put_nowait(AfterLinkError(AfterLinkError.CONNECTION_CLOSED, "Connection closed"))
|
|
285
|
+
self._pending_requests.clear()
|
|
286
|
+
|
|
287
|
+
async def _reconnect_loop(self):
|
|
288
|
+
attempt = 0
|
|
289
|
+
while not self._is_closing and not self.connected:
|
|
290
|
+
attempt += 1
|
|
291
|
+
if attempt > self.reconnect_attempts:
|
|
292
|
+
logger.error("Max reconnect attempts reached. Giving up.")
|
|
293
|
+
break
|
|
294
|
+
|
|
295
|
+
delay = self.reconnect_delay * (2 ** attempt) + random.uniform(0, 0.5)
|
|
296
|
+
logger.info(f"Reconnecting in {delay:.2f} seconds (attempt {attempt})...")
|
|
297
|
+
await asyncio.sleep(delay)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
await self.connect()
|
|
301
|
+
for topic in list(self._subscriptions.keys()):
|
|
302
|
+
await self.request("pubsub/subscribe", {"topic": topic})
|
|
303
|
+
logger.info("Reconnection successful.")
|
|
304
|
+
break
|
|
305
|
+
except Exception as err:
|
|
306
|
+
logger.error(f"Reconnection attempt failed: {err}")
|
|
307
|
+
|
|
308
|
+
async def disconnect(self):
|
|
309
|
+
self._is_closing = True
|
|
310
|
+
self.connected = False
|
|
311
|
+
|
|
312
|
+
if self._read_task:
|
|
313
|
+
self._read_task.cancel()
|
|
314
|
+
|
|
315
|
+
if self.writer:
|
|
316
|
+
self.writer.close()
|
|
317
|
+
try:
|
|
318
|
+
await self.writer.wait_closed()
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
self._cleanup_pending()
|
|
323
|
+
logger.info("Client disconnected.")
|
|
324
|
+
|
|
325
|
+
async def reconnect(self, **kwargs):
|
|
326
|
+
await self.disconnect()
|
|
327
|
+
await self.connect()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
class AfterLinkError(Exception):
|
|
2
|
+
PROTOCOL_ERROR = 1
|
|
3
|
+
AUTH_REQUIRED = 2
|
|
4
|
+
AUTH_FAILED = 3
|
|
5
|
+
AUTH_EXPIRED = 4
|
|
6
|
+
ROUTE_NOT_FOUND = 5
|
|
7
|
+
VALIDATION_ERROR = 6
|
|
8
|
+
RATE_LIMITED = 7
|
|
9
|
+
CONNECTION_TIMEOUT = 8
|
|
10
|
+
CONNECTION_CLOSED = 9
|
|
11
|
+
INTERNAL_SERVER_ERROR = 10
|
|
12
|
+
MALFORMED_PAYLOAD = 11
|
|
13
|
+
UNKNOWN_FRAME_TYPE = 12
|
|
14
|
+
DECOMPRESSION_FAILED = 13
|
|
15
|
+
TLS_CERT_INVALID = 14
|
|
16
|
+
FRAME_TOO_LARGE = 15
|
|
17
|
+
COMPRESSION_ERROR = 16
|
|
18
|
+
INVALID_FRAME = 17
|
|
19
|
+
SERVER_CLOSING = 18
|
|
20
|
+
PROTOCOL_VERSION_MISMATCH = 19
|
|
21
|
+
|
|
22
|
+
ERROR_NAMES = {
|
|
23
|
+
1: "PROTOCOL_ERROR",
|
|
24
|
+
2: "AUTH_REQUIRED",
|
|
25
|
+
3: "AUTH_FAILED",
|
|
26
|
+
4: "AUTH_EXPIRED",
|
|
27
|
+
5: "ROUTE_NOT_FOUND",
|
|
28
|
+
6: "VALIDATION_ERROR",
|
|
29
|
+
7: "RATE_LIMITED",
|
|
30
|
+
8: "CONNECTION_TIMEOUT",
|
|
31
|
+
9: "CONNECTION_CLOSED",
|
|
32
|
+
10: "INTERNAL_SERVER_ERROR",
|
|
33
|
+
11: "MALFORMED_PAYLOAD",
|
|
34
|
+
12: "UNKNOWN_FRAME_TYPE",
|
|
35
|
+
13: "DECOMPRESSION_FAILED",
|
|
36
|
+
14: "TLS_CERT_INVALID",
|
|
37
|
+
15: "FRAME_TOO_LARGE",
|
|
38
|
+
16: "COMPRESSION_ERROR",
|
|
39
|
+
17: "INVALID_FRAME",
|
|
40
|
+
18: "SERVER_CLOSING",
|
|
41
|
+
19: "PROTOCOL_VERSION_MISMATCH"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def __init__(self, code: int, message: str, details=None, retry_after: int = None, meta: dict = None):
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.code = code
|
|
47
|
+
self.name = self.ERROR_NAMES.get(code, "UNKNOWN")
|
|
48
|
+
self.message = message
|
|
49
|
+
self.details = details
|
|
50
|
+
self.retry_after = retry_after
|
|
51
|
+
self.meta = meta or {}
|
|
52
|
+
|
|
53
|
+
def __str__(self):
|
|
54
|
+
return f"{self.name} ({self.code}): {self.message}"
|
|
55
|
+
|
|
56
|
+
def to_dict(self):
|
|
57
|
+
res = {
|
|
58
|
+
"code": self.code,
|
|
59
|
+
"message": self.message
|
|
60
|
+
}
|
|
61
|
+
if self.details is not None:
|
|
62
|
+
res["details"] = self.details
|
|
63
|
+
if self.retry_after is not None:
|
|
64
|
+
res["retry_after"] = self.retry_after
|
|
65
|
+
if self.meta:
|
|
66
|
+
res["meta"] = self.meta
|
|
67
|
+
return res
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# AfterLink Protocol Module
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import msgpack
|
|
2
|
+
|
|
3
|
+
def serialize(data):
|
|
4
|
+
"""
|
|
5
|
+
Serializes Python data structure to MessagePack bytes.
|
|
6
|
+
"""
|
|
7
|
+
return msgpack.packb(data, use_bin_type=True)
|
|
8
|
+
|
|
9
|
+
def deserialize(data):
|
|
10
|
+
"""
|
|
11
|
+
Deserializes MessagePack bytes back to Python objects.
|
|
12
|
+
"""
|
|
13
|
+
return msgpack.unpackb(data, raw=False)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import zlib
|
|
2
|
+
|
|
3
|
+
FLAGS_COMPRESSED = 0x01
|
|
4
|
+
|
|
5
|
+
def compress(payload: bytes, threshold: int = 1024) -> tuple[bytes, bool]:
|
|
6
|
+
"""
|
|
7
|
+
Compresses payload with zlib if it exceeds the threshold.
|
|
8
|
+
"""
|
|
9
|
+
if len(payload) >= threshold:
|
|
10
|
+
try:
|
|
11
|
+
compressed = zlib.compress(payload, level=6)
|
|
12
|
+
if len(compressed) < len(payload):
|
|
13
|
+
return compressed, True
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
|
16
|
+
return payload, False
|
|
17
|
+
|
|
18
|
+
def decompress(payload: bytes) -> bytes:
|
|
19
|
+
"""
|
|
20
|
+
Decompresses payload with zlib.
|
|
21
|
+
"""
|
|
22
|
+
return zlib.decompress(payload)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
|
|
3
|
+
V1_HEADER_SIZE = 10
|
|
4
|
+
V2_HEADER_SIZE = 16
|
|
5
|
+
MAX_PAYLOAD_SIZE = 16 * 1024 * 1024
|
|
6
|
+
MAX_ROUTING_KEY = 255
|
|
7
|
+
|
|
8
|
+
# Frame Types
|
|
9
|
+
REQUEST = 0x01
|
|
10
|
+
RESPONSE = 0x02
|
|
11
|
+
STREAM_START = 0x03
|
|
12
|
+
STREAM_DATA = 0x04
|
|
13
|
+
STREAM_END = 0x05
|
|
14
|
+
ERROR = 0x06
|
|
15
|
+
PING = 0x07
|
|
16
|
+
PONG = 0x08
|
|
17
|
+
BROADCAST = 0x09
|
|
18
|
+
SUBSCRIBE = 0x0A
|
|
19
|
+
UNSUBSCRIBE = 0x0B
|
|
20
|
+
PUBLISH = 0x0C
|
|
21
|
+
CLOSE = 0x0D
|
|
22
|
+
CLOSE_ACK = 0x0E
|
|
23
|
+
HELLO = 0x0F
|
|
24
|
+
HELLO_ACK = 0x10
|
|
25
|
+
SERVER_CLOSING = 0x11
|
|
26
|
+
ROUTE_REQUEST = 0x12
|
|
27
|
+
PRIORITY_ACK = 0x13
|
|
28
|
+
|
|
29
|
+
# Flags
|
|
30
|
+
FLAGS_COMPRESSED = 0x01
|
|
31
|
+
FLAGS_ENCRYPTED = 0x02
|
|
32
|
+
FLAGS_PRIORITY_SET = 0x04
|
|
33
|
+
FLAGS_HAS_ROUTING_KEY = 0x08
|
|
34
|
+
|
|
35
|
+
def encode_v1(frame_type: int, flags: int, message_id: int, payload_bytes: bytes) -> bytes:
|
|
36
|
+
if len(payload_bytes) > MAX_PAYLOAD_SIZE:
|
|
37
|
+
raise ValueError(f"Payload size {len(payload_bytes)} exceeds maximum {MAX_PAYLOAD_SIZE}")
|
|
38
|
+
header = struct.pack('!BBII', frame_type, flags & 0xFF, message_id, len(payload_bytes))
|
|
39
|
+
return header + payload_bytes
|
|
40
|
+
|
|
41
|
+
def decode_v1(buffer: bytes) -> dict | None:
|
|
42
|
+
if len(buffer) < V1_HEADER_SIZE:
|
|
43
|
+
return None
|
|
44
|
+
frame_type, flags, message_id, payload_len = struct.unpack('!BBII', buffer[:V1_HEADER_SIZE])
|
|
45
|
+
if payload_len > MAX_PAYLOAD_SIZE:
|
|
46
|
+
raise ValueError(f"Payload size {payload_len} exceeds maximum {MAX_PAYLOAD_SIZE}")
|
|
47
|
+
total_size = V1_HEADER_SIZE + payload_len
|
|
48
|
+
if len(buffer) < total_size:
|
|
49
|
+
return None
|
|
50
|
+
return {
|
|
51
|
+
"type": frame_type,
|
|
52
|
+
"flags": flags,
|
|
53
|
+
"messageId": message_id,
|
|
54
|
+
"payload": buffer[V1_HEADER_SIZE:total_size],
|
|
55
|
+
"totalSize": total_size
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def encode_v2(frame_type: int, flags: int, message_id: int, payload_bytes: bytes, priority: int = 3, routing_key: str = '') -> bytes:
|
|
59
|
+
if len(payload_bytes) > MAX_PAYLOAD_SIZE:
|
|
60
|
+
raise ValueError(f"Payload size {len(payload_bytes)} exceeds maximum {MAX_PAYLOAD_SIZE}")
|
|
61
|
+
|
|
62
|
+
routing_key_bytes = routing_key.encode('utf-8')
|
|
63
|
+
if len(routing_key_bytes) > MAX_ROUTING_KEY:
|
|
64
|
+
raise ValueError(f"Routing key length {len(routing_key_bytes)} exceeds maximum {MAX_ROUTING_KEY}")
|
|
65
|
+
|
|
66
|
+
final_flags = flags & 0xFF
|
|
67
|
+
if priority != 3:
|
|
68
|
+
final_flags |= FLAGS_PRIORITY_SET
|
|
69
|
+
if len(routing_key_bytes) > 0:
|
|
70
|
+
final_flags |= FLAGS_HAS_ROUTING_KEY
|
|
71
|
+
|
|
72
|
+
header = struct.pack(
|
|
73
|
+
'!BBBBIHII',
|
|
74
|
+
frame_type,
|
|
75
|
+
final_flags,
|
|
76
|
+
0x02, # version
|
|
77
|
+
priority,
|
|
78
|
+
message_id,
|
|
79
|
+
len(routing_key_bytes),
|
|
80
|
+
len(payload_bytes),
|
|
81
|
+
0 # reserved
|
|
82
|
+
)
|
|
83
|
+
return header + routing_key_bytes + payload_bytes
|
|
84
|
+
|
|
85
|
+
def decode_v2(buffer: bytes) -> dict | None:
|
|
86
|
+
if len(buffer) < V2_HEADER_SIZE:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
frame_type, flags, version, priority, message_id, routing_key_len, payload_len, _ = struct.unpack(
|
|
90
|
+
'!BBBBIHII',
|
|
91
|
+
buffer[:V2_HEADER_SIZE]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if payload_len > MAX_PAYLOAD_SIZE:
|
|
95
|
+
raise ValueError(f"Payload size {payload_len} exceeds maximum {MAX_PAYLOAD_SIZE}")
|
|
96
|
+
if routing_key_len > MAX_ROUTING_KEY:
|
|
97
|
+
raise ValueError(f"Routing key length {routing_key_len} exceeds maximum {MAX_ROUTING_KEY}")
|
|
98
|
+
|
|
99
|
+
total_size = V2_HEADER_SIZE + routing_key_len + payload_len
|
|
100
|
+
if len(buffer) < total_size:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
routing_key = buffer[V2_HEADER_SIZE:V2_HEADER_SIZE + routing_key_len].decode('utf-8')
|
|
104
|
+
payload = buffer[V2_HEADER_SIZE + routing_key_len:total_size]
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"type": frame_type,
|
|
108
|
+
"flags": flags,
|
|
109
|
+
"version": version,
|
|
110
|
+
"priority": priority,
|
|
111
|
+
"messageId": message_id,
|
|
112
|
+
"routingKeyLen": routing_key_len,
|
|
113
|
+
"routingKey": routing_key,
|
|
114
|
+
"payload": payload,
|
|
115
|
+
"totalSize": total_size
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def encode(version: int = 1, **kwargs) -> bytes:
|
|
119
|
+
if version == 2:
|
|
120
|
+
return encode_v2(**kwargs)
|
|
121
|
+
return encode_v1(**kwargs)
|
|
122
|
+
|
|
123
|
+
def decode(buffer: bytes, version: int = 1) -> dict | None:
|
|
124
|
+
if version == 2:
|
|
125
|
+
return decode_v2(buffer)
|
|
126
|
+
return decode_v1(buffer)
|
|
127
|
+
|
|
128
|
+
def decode_auto(buffer: bytes) -> dict | None:
|
|
129
|
+
if len(buffer) < 3:
|
|
130
|
+
return None
|
|
131
|
+
# Offset 2 is version byte in V2
|
|
132
|
+
if buffer[2] == 0x02:
|
|
133
|
+
return decode_v2(buffer)
|
|
134
|
+
return decode_v1(buffer)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pydantic import BaseModel, ValidationError
|
|
2
|
+
from .errors import AfterLinkError
|
|
3
|
+
|
|
4
|
+
def validate_with_pydantic(model_class, data):
|
|
5
|
+
"""
|
|
6
|
+
Validates dictionary data against a Pydantic model class.
|
|
7
|
+
Raises AfterLinkError with VALIDATION_ERROR code on failure.
|
|
8
|
+
"""
|
|
9
|
+
if not issubclass(model_class, BaseModel):
|
|
10
|
+
raise TypeError("model_class must be a subclass of pydantic.BaseModel")
|
|
11
|
+
try:
|
|
12
|
+
return model_class.model_validate(data)
|
|
13
|
+
except ValidationError as err:
|
|
14
|
+
details = {e["loc"][0]: e["msg"] for e in err.errors() if e["loc"]}
|
|
15
|
+
raise AfterLinkError(
|
|
16
|
+
code=AfterLinkError.VALIDATION_ERROR,
|
|
17
|
+
message="Validation failed",
|
|
18
|
+
details=details
|
|
19
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Callable, Any
|
|
4
|
+
|
|
5
|
+
from .errors import AfterLinkError
|
|
6
|
+
from .protocol import frame, codec, compression
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("afterlink.server")
|
|
9
|
+
|
|
10
|
+
class Server:
|
|
11
|
+
def __init__(self, port: int = 4000, options: dict = None):
|
|
12
|
+
self.port = port
|
|
13
|
+
self.options = options or {}
|
|
14
|
+
self.host = self.options.get("host", "0.0.0.0")
|
|
15
|
+
|
|
16
|
+
self.routes = {}
|
|
17
|
+
self.middlewares = []
|
|
18
|
+
self.connections = set()
|
|
19
|
+
|
|
20
|
+
self._server = None
|
|
21
|
+
self._is_closing = False
|
|
22
|
+
|
|
23
|
+
def on(self, route: str, handler: Callable = None, *, schema: Any = None):
|
|
24
|
+
"""
|
|
25
|
+
Decorator or direct method to register a route handler.
|
|
26
|
+
"""
|
|
27
|
+
def decorator(f):
|
|
28
|
+
self.routes[route] = {"handler": f, "schema": schema}
|
|
29
|
+
return f
|
|
30
|
+
if handler is None:
|
|
31
|
+
return decorator
|
|
32
|
+
self.routes[route] = {"handler": handler, "schema": schema}
|
|
33
|
+
return handler
|
|
34
|
+
|
|
35
|
+
def use(self, middleware: Callable):
|
|
36
|
+
"""
|
|
37
|
+
Register a middleware function.
|
|
38
|
+
"""
|
|
39
|
+
self.middlewares.append(middleware)
|
|
40
|
+
return middleware
|
|
41
|
+
|
|
42
|
+
async def listen(self):
|
|
43
|
+
logger.info(f"Starting AfterLink server on {self.host}:{self.port}...")
|
|
44
|
+
self._server = await asyncio.start_server(
|
|
45
|
+
self._handle_incoming_connection,
|
|
46
|
+
self.host,
|
|
47
|
+
self.port
|
|
48
|
+
)
|
|
49
|
+
async with self._server:
|
|
50
|
+
await self._server.serve_forever()
|
|
51
|
+
|
|
52
|
+
async def _handle_incoming_connection(self, reader, writer):
|
|
53
|
+
addr = writer.get_extra_info('peername')
|
|
54
|
+
logger.info(f"New connection from {addr}")
|
|
55
|
+
|
|
56
|
+
conn = ServerConnection(self, reader, writer)
|
|
57
|
+
self.connections.add(conn)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
await conn.handle()
|
|
61
|
+
except Exception as err:
|
|
62
|
+
logger.error(f"Error handling connection from {addr}: {err}")
|
|
63
|
+
finally:
|
|
64
|
+
self.connections.discard(conn)
|
|
65
|
+
logger.info(f"Connection from {addr} closed")
|
|
66
|
+
|
|
67
|
+
async def close(self):
|
|
68
|
+
self._is_closing = True
|
|
69
|
+
if self._server:
|
|
70
|
+
self._server.close()
|
|
71
|
+
await self._server.wait_closed()
|
|
72
|
+
|
|
73
|
+
for conn in list(self.connections):
|
|
74
|
+
await conn.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ServerConnection:
|
|
78
|
+
def __init__(self, server: Server, reader, writer):
|
|
79
|
+
self.server = server
|
|
80
|
+
self.reader = reader
|
|
81
|
+
self.writer = writer
|
|
82
|
+
self.session_id = f"sess_{id(self)}"
|
|
83
|
+
self.version = 1
|
|
84
|
+
self.authenticated = False
|
|
85
|
+
|
|
86
|
+
async def handle(self):
|
|
87
|
+
buffer = bytearray()
|
|
88
|
+
try:
|
|
89
|
+
while not self.server._is_closing:
|
|
90
|
+
chunk = await self.reader.read(4096)
|
|
91
|
+
if not chunk:
|
|
92
|
+
break
|
|
93
|
+
buffer.extend(chunk)
|
|
94
|
+
|
|
95
|
+
while True:
|
|
96
|
+
decoded = frame.decode_auto(bytes(buffer))
|
|
97
|
+
if decoded is None:
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
del buffer[:decoded["totalSize"]]
|
|
101
|
+
await self._process_frame(decoded)
|
|
102
|
+
finally:
|
|
103
|
+
self.writer.close()
|
|
104
|
+
try:
|
|
105
|
+
await self.writer.wait_closed()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
async def _process_frame(self, f: dict):
|
|
110
|
+
ftype = f["type"]
|
|
111
|
+
msg_id = f["messageId"]
|
|
112
|
+
|
|
113
|
+
if ftype == frame.HELLO:
|
|
114
|
+
hello_data = codec.deserialize(f["payload"])
|
|
115
|
+
requested_ver = hello_data.get("version", "AL/1")
|
|
116
|
+
|
|
117
|
+
self.version = 2 if "AL/2" in requested_ver else 1
|
|
118
|
+
|
|
119
|
+
ack_payload = {
|
|
120
|
+
"session_id": self.session_id,
|
|
121
|
+
"server_version": "2.0.0",
|
|
122
|
+
"accepted_protocol": "v2" if self.version == 2 else "v1"
|
|
123
|
+
}
|
|
124
|
+
payload_bytes = codec.serialize(ack_payload)
|
|
125
|
+
ack_bytes = frame.encode(version=1, frame_type=frame.HELLO_ACK, flags=0, message_id=msg_id, payload_bytes=payload_bytes)
|
|
126
|
+
self.writer.write(ack_bytes)
|
|
127
|
+
await self.writer.drain()
|
|
128
|
+
self.authenticated = True
|
|
129
|
+
|
|
130
|
+
elif ftype == frame.REQUEST:
|
|
131
|
+
if not self.authenticated:
|
|
132
|
+
self.send_error(AfterLinkError.AUTH_REQUIRED, "Authentication required", msg_id)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
payload = f["payload"]
|
|
137
|
+
if f["flags"] & compression.FLAGS_COMPRESSED:
|
|
138
|
+
payload = compression.decompress(payload)
|
|
139
|
+
data = codec.deserialize(payload)
|
|
140
|
+
except Exception:
|
|
141
|
+
self.send_error(AfterLinkError.MALFORMED_PAYLOAD, "Malformed request payload", msg_id)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
route = data.get("route")
|
|
145
|
+
body = data.get("body", {})
|
|
146
|
+
|
|
147
|
+
route_config = self.server.routes.get(route)
|
|
148
|
+
if not route_config:
|
|
149
|
+
self.send_error(AfterLinkError.ROUTE_NOT_FOUND, f"Route '{route}' not found", msg_id)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if route_config["schema"]:
|
|
153
|
+
from .schema import validate_with_pydantic
|
|
154
|
+
try:
|
|
155
|
+
validate_with_pydantic(route_config["schema"], body)
|
|
156
|
+
except AfterLinkError as err:
|
|
157
|
+
self.send_error(err.code, err.message, msg_id, details=err.details)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
req = {"body": body, "route": route, "connection": self}
|
|
161
|
+
|
|
162
|
+
async def run_handler():
|
|
163
|
+
return await route_config["handler"](req)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
result = await self._run_middlewares(req, run_handler)
|
|
167
|
+
|
|
168
|
+
res_payload = {"status": "ok", "body": result}
|
|
169
|
+
payload_bytes = codec.serialize(res_payload)
|
|
170
|
+
|
|
171
|
+
payload_bytes, compressed = compression.compress(payload_bytes)
|
|
172
|
+
flags = compression.FLAGS_COMPRESSED if compressed else 0
|
|
173
|
+
|
|
174
|
+
res_bytes = frame.encode(version=self.version, frame_type=frame.RESPONSE, flags=flags, message_id=msg_id, payload_bytes=payload_bytes)
|
|
175
|
+
self.writer.write(res_bytes)
|
|
176
|
+
await self.writer.drain()
|
|
177
|
+
except AfterLinkError as err:
|
|
178
|
+
self.send_error(err.code, err.message, msg_id, details=err.details)
|
|
179
|
+
except Exception as err:
|
|
180
|
+
self.send_error(AfterLinkError.INTERNAL_SERVER_ERROR, str(err), msg_id)
|
|
181
|
+
|
|
182
|
+
async def _run_middlewares(self, req: dict, final_handler: Callable):
|
|
183
|
+
index = 0
|
|
184
|
+
async def run():
|
|
185
|
+
nonlocal index
|
|
186
|
+
if index >= len(self.server.middlewares):
|
|
187
|
+
return await final_handler()
|
|
188
|
+
m = self.server.middlewares[index]
|
|
189
|
+
index += 1
|
|
190
|
+
return await m(req, run)
|
|
191
|
+
return await run()
|
|
192
|
+
|
|
193
|
+
def send_error(self, code: int, message: str, message_id: int, details=None):
|
|
194
|
+
err_payload = {"code": code, "message": message}
|
|
195
|
+
if details is not None:
|
|
196
|
+
err_payload["details"] = details
|
|
197
|
+
payload_bytes = codec.serialize(err_payload)
|
|
198
|
+
err_bytes = frame.encode(version=self.version, frame_type=frame.ERROR, flags=0, message_id=message_id, payload_bytes=payload_bytes)
|
|
199
|
+
self.writer.write(err_bytes)
|
|
200
|
+
asyncio.create_task(self.writer.drain())
|
|
201
|
+
|
|
202
|
+
async def close(self):
|
|
203
|
+
self.writer.close()
|
|
204
|
+
try:
|
|
205
|
+
await self.writer.wait_closed()
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .client import Client
|
|
5
|
+
|
|
6
|
+
class SyncClient:
|
|
7
|
+
"""
|
|
8
|
+
Synchronous wrapper around the async AfterLink Client.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, url: str, options: dict = None):
|
|
11
|
+
self._loop = asyncio.new_event_loop()
|
|
12
|
+
self._client = Client(url, options)
|
|
13
|
+
|
|
14
|
+
def connect(self):
|
|
15
|
+
self._loop.run_until_complete(self._client.connect())
|
|
16
|
+
|
|
17
|
+
def request(self, route: str, body: Any = None) -> Any:
|
|
18
|
+
return self._loop.run_until_complete(self._client.request(route, body))
|
|
19
|
+
|
|
20
|
+
def disconnect(self):
|
|
21
|
+
self._loop.run_until_complete(self._client.disconnect())
|
|
22
|
+
self._loop.close()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "afterlink"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "AfterLink Protocol SDK - TCP client/server with routing, middleware, and pub/sub"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Ajay Myth", email = "ajaymyth@github.com" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"msgpack>=1.0.8",
|
|
22
|
+
"pydantic>=2.0"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
uvloop = ["uvloop>=0.17.0"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["afterlink"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# AfterLink Python SDK Tests
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import asyncio
|
|
3
|
+
from afterlink import Server, Client
|
|
4
|
+
|
|
5
|
+
@pytest.mark.asyncio
|
|
6
|
+
async def test_client_server_integration():
|
|
7
|
+
server = Server(port=5005)
|
|
8
|
+
|
|
9
|
+
@server.on("users/get")
|
|
10
|
+
async def get_user(req):
|
|
11
|
+
return {"id": 42, "username": "python-test"}
|
|
12
|
+
|
|
13
|
+
server_task = asyncio.create_task(server.listen())
|
|
14
|
+
await asyncio.sleep(0.1)
|
|
15
|
+
|
|
16
|
+
client = Client("tcp://localhost:5005")
|
|
17
|
+
try:
|
|
18
|
+
await client.connect()
|
|
19
|
+
res = await client.request("users/get")
|
|
20
|
+
assert res["id"] == 42
|
|
21
|
+
assert res["username"] == "python-test"
|
|
22
|
+
finally:
|
|
23
|
+
await client.disconnect()
|
|
24
|
+
await server.close()
|
|
25
|
+
server_task.cancel()
|
|
26
|
+
try:
|
|
27
|
+
await server_task
|
|
28
|
+
except asyncio.CancelledError:
|
|
29
|
+
pass
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from afterlink import AfterLinkError
|
|
2
|
+
|
|
3
|
+
def test_error_instantiation():
|
|
4
|
+
err = AfterLinkError(AfterLinkError.ROUTE_NOT_FOUND, "Route not found", details="some-detail", retry_after=5)
|
|
5
|
+
assert err.code == 5
|
|
6
|
+
assert err.name == "ROUTE_NOT_FOUND"
|
|
7
|
+
assert err.message == "Route not found"
|
|
8
|
+
assert err.details == "some-detail"
|
|
9
|
+
assert err.retry_after == 5
|
|
10
|
+
assert str(err) == "ROUTE_NOT_FOUND (5): Route not found"
|
|
11
|
+
|
|
12
|
+
def test_error_to_dict():
|
|
13
|
+
err = AfterLinkError(AfterLinkError.AUTH_FAILED, "Auth failed")
|
|
14
|
+
d = err.to_dict()
|
|
15
|
+
assert d["code"] == 3
|
|
16
|
+
assert d["message"] == "Auth failed"
|
|
17
|
+
assert "details" not in d
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from afterlink.protocol import frame
|
|
4
|
+
|
|
5
|
+
def test_v1_encode_decode():
|
|
6
|
+
payload = b"hello"
|
|
7
|
+
encoded = frame.encode_v1(1, 0, 42, payload)
|
|
8
|
+
decoded = frame.decode_v1(encoded)
|
|
9
|
+
|
|
10
|
+
assert decoded is not None
|
|
11
|
+
assert decoded["type"] == 1
|
|
12
|
+
assert decoded["flags"] == 0
|
|
13
|
+
assert decoded["messageId"] == 42
|
|
14
|
+
assert decoded["payload"] == payload
|
|
15
|
+
|
|
16
|
+
def test_v2_encode_decode():
|
|
17
|
+
payload = b"hello"
|
|
18
|
+
encoded = frame.encode_v2(1, 0, 42, payload, priority=5, routing_key="route")
|
|
19
|
+
decoded = frame.decode_v2(encoded)
|
|
20
|
+
|
|
21
|
+
assert decoded is not None
|
|
22
|
+
assert decoded["type"] == 1
|
|
23
|
+
assert decoded["flags"] == 12
|
|
24
|
+
assert decoded["version"] == 2
|
|
25
|
+
assert decoded["priority"] == 5
|
|
26
|
+
assert decoded["messageId"] == 42
|
|
27
|
+
assert decoded["routingKey"] == "route"
|
|
28
|
+
assert decoded["payload"] == payload
|
|
29
|
+
|
|
30
|
+
def test_vectors():
|
|
31
|
+
vectors_path = os.path.join(os.path.dirname(__file__), "../../test/protocol-vectors.json")
|
|
32
|
+
with open(vectors_path, "r") as f:
|
|
33
|
+
data = json.load(f)
|
|
34
|
+
|
|
35
|
+
for vec in data["vectors"]:
|
|
36
|
+
payload = bytes.fromhex(vec["payloadHex"])
|
|
37
|
+
expected = bytes.fromhex(vec["expectedHex"])
|
|
38
|
+
|
|
39
|
+
if vec["version"] == 1:
|
|
40
|
+
encoded = frame.encode(
|
|
41
|
+
version=1,
|
|
42
|
+
frame_type=vec["type"],
|
|
43
|
+
flags=vec["flags"],
|
|
44
|
+
message_id=vec["messageId"],
|
|
45
|
+
payload_bytes=payload
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
encoded = frame.encode(
|
|
49
|
+
version=2,
|
|
50
|
+
frame_type=vec["type"],
|
|
51
|
+
flags=vec["flags"],
|
|
52
|
+
message_id=vec["messageId"],
|
|
53
|
+
payload_bytes=payload,
|
|
54
|
+
priority=vec.get("priority", 3),
|
|
55
|
+
routing_key=vec.get("routingKey", "")
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert encoded == expected
|
|
59
|
+
|
|
60
|
+
decoded = frame.decode_auto(expected)
|
|
61
|
+
assert decoded is not None
|
|
62
|
+
assert decoded["type"] == vec["type"]
|
|
63
|
+
assert decoded["flags"] == vec["flags"]
|
|
64
|
+
assert decoded["messageId"] == vec["messageId"]
|
|
65
|
+
|
|
66
|
+
if vec["version"] == 2:
|
|
67
|
+
assert decoded["version"] == 2
|
|
68
|
+
assert decoded["priority"] == vec.get("priority", 3)
|
|
69
|
+
assert decoded["routingKey"] == vec.get("routingKey", "")
|