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.
@@ -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
@@ -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,6 @@
1
+ from .client import Client
2
+ from .server import Server
3
+ from .errors import AfterLinkError
4
+ from .sync import SyncClient
5
+
6
+ __all__ = ["Client", "Server", "AfterLinkError", "SyncClient"]
@@ -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", "")