nodalwire 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nitipit Nontasuwan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: nodalwire
3
+ Version: 0.1.0
4
+ Summary: A bounded native ZeroMQ byte relay for trusted local Python applications.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: cyclopts>=4.11.1,<5
8
+ Requires-Dist: pyzmq>=27.1.0
9
+ Requires-Python: >=3.12
10
+ Project-URL: Repository, https://github.com/keenlycode/nodalwire
11
+ Project-URL: Issues, https://github.com/keenlycode/nodalwire/issues
12
+ Description-Content-Type: text/markdown
13
+
14
+ # NodalWire
15
+
16
+ A local byte relay, designed for AI agents to use and maintain.
17
+
18
+ - Usage: [tests/usage/](tests/usage/)
19
+ - Python: [src/nodalwire/](src/nodalwire/)
20
+ - Node.js: [src/nodalwire/client/node/](src/nodalwire/client/node/)
@@ -0,0 +1,7 @@
1
+ # NodalWire
2
+
3
+ A local byte relay, designed for AI agents to use and maintain.
4
+
5
+ - Usage: [tests/usage/](tests/usage/)
6
+ - Python: [src/nodalwire/](src/nodalwire/)
7
+ - Node.js: [src/nodalwire/client/node/](src/nodalwire/client/node/)
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "nodalwire"
3
+ version = "0.1.0"
4
+ description = "A bounded native ZeroMQ byte relay for trusted local Python applications."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ dependencies = [
10
+ "cyclopts>=4.11.1,<5",
11
+ "pyzmq>=27.1.0",
12
+ ]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/keenlycode/nodalwire"
16
+ Issues = "https://github.com/keenlycode/nodalwire/issues"
17
+
18
+ [project.scripts]
19
+ nodalwire = "nodalwire.cli:app"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=9.0.3,<9.1",
24
+ "ruff>=0.8.0",
25
+ ]
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.11.0,<0.12.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ module-name = "nodalwire"
33
+ source-exclude = ["**/__pycache__/**", "src/nodalwire/client/node"]
34
+ wheel-exclude = ["nodalwire/client/node"]
35
+
36
+ [tool.ruff]
37
+ extend-exclude = ["src/nodalwire/client/node"]
38
+ line-length = 100
39
+ target-version = "py312"
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "I", "UP", "B"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ addopts = ["--import-mode=importlib"]
@@ -0,0 +1,16 @@
1
+ """NodalWire: a central byte relay for trusted local Python applications."""
2
+
3
+ from .client import Message, Node
4
+ from .config import RelayConfig, load_config
5
+ from .protocol import ReceiptTimeout, RelayError
6
+ from .server import Relay
7
+
8
+ __all__ = [
9
+ "Message",
10
+ "Node",
11
+ "ReceiptTimeout",
12
+ "Relay",
13
+ "RelayConfig",
14
+ "RelayError",
15
+ "load_config",
16
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
@@ -0,0 +1,47 @@
1
+ """Small foreground service; configuration is loaded once at startup."""
2
+
3
+ import asyncio
4
+ import signal
5
+ from pathlib import Path
6
+
7
+ from cyclopts import App
8
+
9
+ from .config import RelayConfig, load_config
10
+ from .server import Relay
11
+
12
+ app = App(help="Run the loopback-only byte relay for trusted local applications.")
13
+
14
+
15
+ async def _serve(config: RelayConfig) -> None:
16
+ relay = Relay(config)
17
+ endpoint = await relay.start()
18
+ stop = asyncio.Event()
19
+ loop = asyncio.get_running_loop()
20
+ for sig in (signal.SIGINT, signal.SIGTERM):
21
+ loop.add_signal_handler(sig, stop.set)
22
+ runner = asyncio.create_task(relay.run())
23
+ waiter = asyncio.create_task(stop.wait())
24
+ print(endpoint, flush=True)
25
+ try:
26
+ completed, _ = await asyncio.wait([runner, waiter], return_when=asyncio.FIRST_COMPLETED)
27
+ if runner in completed:
28
+ await runner # Surface a service failure, not a false successful shutdown.
29
+ finally:
30
+ runner.cancel()
31
+ waiter.cancel()
32
+ await asyncio.gather(runner, waiter, return_exceptions=True)
33
+ await relay.close()
34
+ for sig in (signal.SIGINT, signal.SIGTERM):
35
+ loop.remove_signal_handler(sig)
36
+
37
+
38
+ @app.default
39
+ def main(*, config: Path | None = None) -> None:
40
+ """Start the relay; prints the bound endpoint after native bind succeeds.
41
+
42
+ Parameters
43
+ ----------
44
+ config
45
+ Startup cooperative TOML policy. Default: open admission on loopback.
46
+ """
47
+ asyncio.run(_serve(load_config(config) if config is not None else RelayConfig()))
@@ -0,0 +1,5 @@
1
+ """Language-specific clients; Python exports remain available here."""
2
+
3
+ from .python import Message, Node
4
+
5
+ __all__ = ["Message", "Node"]
@@ -0,0 +1,5 @@
1
+ """Async Python byte client."""
2
+
3
+ from .node import Message, Node
4
+
5
+ __all__ = ["Message", "Node"]
@@ -0,0 +1,275 @@
1
+ """Async DEALER node with one bounded inbox and transport-owned receipt correlation."""
2
+
3
+ import asyncio
4
+ import secrets
5
+ from dataclasses import dataclass
6
+
7
+ import zmq
8
+ import zmq.asyncio
9
+
10
+ from ...config import local_endpoint
11
+ from ...protocol import (
12
+ CLOSE,
13
+ DATA,
14
+ ERROR,
15
+ HEARTBEAT,
16
+ HELLO,
17
+ OK,
18
+ SEND,
19
+ VERSION,
20
+ ReceiptTimeout,
21
+ RelayError,
22
+ valid_name,
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Message:
28
+ sender: str
29
+ payload: bytes
30
+
31
+
32
+ class Node:
33
+ """Connect by logical name; send bytes, receive sender+bytes, and close.
34
+
35
+ Native queues, inbox and outstanding requests are bounded. When the inbox is
36
+ full, reading pauses (including receipts); drain it concurrently with sending.
37
+ Socket queue acceptance is not application delivery or execution.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ name: str,
43
+ endpoint: str,
44
+ *,
45
+ timeout: float = 5.0,
46
+ send_hwm: int = 64,
47
+ receive_hwm: int = 64,
48
+ inbox_size: int = 64,
49
+ max_pending: int = 128,
50
+ ) -> None:
51
+ if not valid_name(name):
52
+ raise ValueError("invalid logical node name")
53
+ if timeout <= 0:
54
+ raise ValueError("timeout must be positive")
55
+ for value in (send_hwm, receive_hwm, inbox_size, max_pending):
56
+ if type(value) is not int or value <= 0:
57
+ raise ValueError("queue limits must be positive integers")
58
+ endpoint = local_endpoint(endpoint)
59
+ self.name = name
60
+ self.endpoint = endpoint
61
+ self.timeout = timeout
62
+ self.max_pending = max_pending
63
+ self.context = zmq.asyncio.Context()
64
+ self.socket = self.context.socket(zmq.DEALER)
65
+ self.socket.setsockopt(zmq.LINGER, 0)
66
+ self.socket.setsockopt(zmq.IPV6, int(endpoint.startswith("tcp://[")))
67
+ self.socket.setsockopt(zmq.IDENTITY, secrets.token_bytes(16))
68
+ self.socket.setsockopt(zmq.IMMEDIATE, 1)
69
+ self.socket.setsockopt(zmq.SNDHWM, send_hwm)
70
+ self.socket.setsockopt(zmq.RCVHWM, receive_hwm)
71
+ self._inbox: asyncio.Queue[Message] = asyncio.Queue(maxsize=inbox_size)
72
+ self._pending: dict[bytes, tuple[bytes, asyncio.Future[tuple[bytes, ...]]]] = {}
73
+ self._receiver: asyncio.Task[None] | None = None
74
+ self._heartbeat: asyncio.Task[None] | None = None
75
+ self._token: bytes | None = None
76
+ self._max_payload = 0
77
+ self._closed = asyncio.Event()
78
+ self._cleanup_done = asyncio.Event()
79
+ self._cleanup_task: asyncio.Task[None] | None = None
80
+ self._closing = False
81
+ try:
82
+ self.socket.connect(endpoint)
83
+ except BaseException:
84
+ self.socket.close(linger=0)
85
+ self.context.term()
86
+ raise
87
+
88
+ @classmethod
89
+ async def connect(cls, name: str, endpoint: str = "tcp://127.0.0.1:5555", **options) -> "Node":
90
+ """Await admission; failure closes owned resources. No reconnect or replay."""
91
+ node = cls(name, endpoint, **options)
92
+ node._receiver = asyncio.create_task(node._read(), name=f"relay-inbox-{name}")
93
+ try:
94
+ token, lease, payload_limit = await node._request(
95
+ HELLO, name.encode("ascii"), initial=True
96
+ )
97
+ node._token = token
98
+ node._max_payload = int(payload_limit)
99
+ node.socket.setsockopt(zmq.MAXMSGSIZE, node._max_payload + 4096)
100
+ node._heartbeat = asyncio.create_task(
101
+ node._beat(float(lease) / 3), name=f"relay-heartbeat-{name}"
102
+ )
103
+ return node
104
+ except BaseException:
105
+ await node._cleanup()
106
+ raise
107
+
108
+ async def _request(
109
+ self,
110
+ command: bytes,
111
+ *frames: bytes,
112
+ initial: bool = False,
113
+ timeout: float | None = None,
114
+ ) -> tuple[bytes, ...]:
115
+ if self._closed.is_set():
116
+ raise RelayError("closed")
117
+ if len(self._pending) >= self.max_pending:
118
+ raise RelayError("pending_full")
119
+ request = secrets.token_hex(16).encode("ascii")
120
+ token = b"" if initial else self._token
121
+ assert token is not None
122
+ future = asyncio.get_running_loop().create_future()
123
+ self._pending[request] = (token, future)
124
+ body = [VERSION, command]
125
+ if not initial:
126
+ body.append(token)
127
+ body.extend([request, *frames])
128
+ try:
129
+ async with asyncio.timeout(self.timeout if timeout is None else timeout):
130
+ try:
131
+ await self.socket.send_multipart(body, flags=0 if initial else zmq.DONTWAIT)
132
+ except zmq.Again as exc:
133
+ raise RelayError("local_queue_full") from exc
134
+ return await future
135
+ except TimeoutError as exc:
136
+ raise ReceiptTimeout("routing receipt timed out; effect uncertain") from exc
137
+ finally:
138
+ self._pending.pop(request, None)
139
+ if not future.done():
140
+ future.cancel()
141
+ elif not future.cancelled():
142
+ future.exception() # Consume a concurrent close/receiver failure.
143
+
144
+ async def _read(self) -> None:
145
+ try:
146
+ while True:
147
+ frames = await self.socket.recv_multipart()
148
+ if len(frames) < 4 or frames[0] != VERSION:
149
+ continue
150
+ command, token = frames[1:3]
151
+ if command == DATA and len(frames) == 5 and token == self._token:
152
+ sender = frames[3].decode("ascii")
153
+ if valid_name(sender) and len(frames[4]) <= self._max_payload:
154
+ await self._inbox.put(Message(sender, frames[4]))
155
+ continue
156
+ request = frames[3]
157
+ pending = self._pending.get(request)
158
+ if pending is None or pending[0] != token or pending[1].done():
159
+ continue
160
+ future = pending[1]
161
+ if command == OK:
162
+ extra = tuple(frames[4:])
163
+ if token == b"" and len(extra) == 3:
164
+ # Install incarnation before any DATA already behind HELLO's reply.
165
+ self._token = extra[0]
166
+ self._max_payload = int(extra[2])
167
+ future.set_result(extra)
168
+ elif command == ERROR and len(frames) == 5:
169
+ future.set_exception(RelayError(frames[4].decode("ascii")))
170
+ except asyncio.CancelledError:
171
+ raise
172
+ except Exception:
173
+ self._closed.set()
174
+ self._fail_pending("connection_failed")
175
+
176
+ def _fail_pending(self, code: str) -> None:
177
+ for _, future in self._pending.values():
178
+ if not future.done():
179
+ future.set_exception(RelayError(code))
180
+
181
+ async def _beat(self, interval: float) -> None:
182
+ assert self._token is not None
183
+ while True:
184
+ await asyncio.sleep(interval)
185
+ try:
186
+ await self.socket.send_multipart(
187
+ [VERSION, HEARTBEAT, self._token], flags=zmq.DONTWAIT
188
+ )
189
+ except zmq.Again:
190
+ pass # No heartbeat backlog; lease may expire under local congestion.
191
+
192
+ async def send(self, destination: str, payload: bytes, *, timeout: float | None = None) -> None:
193
+ """Wait for Router queue acceptance or status (never remote execution).
194
+
195
+ Timeout, cancellation and interrupted lifecycle have uncertain effects.
196
+ """
197
+ if self._closing or self._closed.is_set():
198
+ raise RelayError("closed")
199
+ if not valid_name(destination):
200
+ raise ValueError("invalid destination name")
201
+ if not isinstance(payload, bytes):
202
+ raise TypeError("payload must be bytes")
203
+ if len(payload) > self._max_payload:
204
+ raise RelayError("payload_too_large")
205
+ await self._request(SEND, destination.encode("ascii"), payload, timeout=timeout)
206
+
207
+ async def receive(self) -> Message:
208
+ """Read the single inbox; queued data remains readable after close."""
209
+ if not self._inbox.empty():
210
+ return self._inbox.get_nowait()
211
+ if self._closed.is_set():
212
+ raise RelayError("closed")
213
+ message = asyncio.create_task(self._inbox.get())
214
+ closed = asyncio.create_task(self._closed.wait())
215
+ try:
216
+ await asyncio.wait([message, closed], return_when=asyncio.FIRST_COMPLETED)
217
+ if message.done():
218
+ return message.result()
219
+ raise RelayError("closed")
220
+ finally:
221
+ for task in (message, closed):
222
+ task.cancel()
223
+ await asyncio.gather(message, closed, return_exceptions=True)
224
+
225
+ async def _cleanup(self) -> None:
226
+ # Cleanup has independent ownership: cancellation of a close/connect caller
227
+ # must not interrupt joins or leave native resources and tasks behind.
228
+ if self._cleanup_task is None:
229
+ self._cleanup_task = asyncio.create_task(
230
+ self._dispose(), name=f"relay-close-{self.name}"
231
+ )
232
+ cancelled = False
233
+ while True:
234
+ try:
235
+ await asyncio.shield(self._cleanup_task)
236
+ break
237
+ except asyncio.CancelledError:
238
+ cancelled = True
239
+ if cancelled:
240
+ raise asyncio.CancelledError
241
+
242
+ async def _dispose(self) -> None:
243
+ self._closed.set()
244
+ self._fail_pending("closed")
245
+ for task in (self._heartbeat, self._receiver):
246
+ if task is not None:
247
+ task.cancel()
248
+ try:
249
+ await asyncio.gather(
250
+ *(task for task in (self._heartbeat, self._receiver) if task is not None),
251
+ return_exceptions=True,
252
+ )
253
+ finally:
254
+ # A repeated close can return only after this native cleanup boundary.
255
+ self.socket.close(linger=0)
256
+ self.context.term()
257
+ self._cleanup_done.set()
258
+
259
+ async def close(self, *, timeout: float | None = None) -> None:
260
+ """Attempt registration release, always clean up; failed receipt stays uncertain."""
261
+ if self._closing:
262
+ await self._cleanup_done.wait()
263
+ return
264
+ self._closing = True
265
+ try:
266
+ if self._token is not None and not self._closed.is_set():
267
+ await self._request(CLOSE, timeout=timeout)
268
+ finally:
269
+ await self._cleanup()
270
+
271
+ async def __aenter__(self) -> "Node":
272
+ return self
273
+
274
+ async def __aexit__(self, *exc: object) -> None:
275
+ await self.close()
@@ -0,0 +1,121 @@
1
+ """Startup-only relay policy and native transport limits (TOML configuration)."""
2
+
3
+ import ipaddress
4
+ import tomllib
5
+ from dataclasses import dataclass, field, fields
6
+ from pathlib import Path
7
+
8
+ from .protocol import valid_name
9
+
10
+
11
+ def _name_list(names: object) -> bool:
12
+ return isinstance(names, (tuple, list)) and all(valid_name(name) for name in names)
13
+
14
+
15
+ def local_endpoint(endpoint: str, *, bind: bool = False) -> str:
16
+ """Validate before allocating resources; normalize localhost without DNS.
17
+
18
+ Only literal loopback addresses (or localhost) are supported. A wildcard
19
+ port is allowed for binds, never connects; wildcard/nonlocal hosts are not.
20
+ """
21
+ if not isinstance(endpoint, str) or not endpoint.startswith("tcp://"):
22
+ raise ValueError("local relay endpoint must use tcp://")
23
+ host, separator, port = endpoint[6:].rpartition(":")
24
+ if not separator or not (
25
+ (bind and port == "*")
26
+ or (port.isascii() and port.isdecimal() and 1 <= int(port) <= 65535)
27
+ ):
28
+ raise ValueError("invalid TCP endpoint port")
29
+ if host == "localhost":
30
+ host = "127.0.0.1"
31
+ try:
32
+ address = ipaddress.ip_address(host[1:-1] if host.startswith("[") else host)
33
+ except ValueError as exc:
34
+ raise ValueError("endpoint must use a literal loopback address or localhost") from exc
35
+ if not address.is_loopback or "%" in host:
36
+ raise ValueError("endpoint must be loopback-only")
37
+ expected = f"[{address}]" if address.version == 6 else str(address)
38
+ if host != expected:
39
+ raise ValueError("invalid TCP loopback address syntax")
40
+ return f"tcp://{expected}:{port}"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RelayConfig:
45
+ endpoint: str = "tcp://127.0.0.1:5555"
46
+ lease_seconds: float = 10.0
47
+ send_hwm: int = 64
48
+ receive_hwm: int = 64
49
+ max_payload_bytes: int = 1_048_576
50
+ allowed_names: tuple[str, ...] | None = None
51
+ networks: dict[str, tuple[str, ...]] = field(default_factory=dict)
52
+ deny: tuple[tuple[str, str], ...] = ()
53
+
54
+ def __post_init__(self) -> None:
55
+ object.__setattr__(self, "endpoint", local_endpoint(self.endpoint, bind=True))
56
+ if type(self.lease_seconds) not in (float, int) or not 0.1 <= self.lease_seconds <= 3600:
57
+ raise ValueError("lease_seconds must be between 0.1 and 3600")
58
+ for name in ("send_hwm", "receive_hwm", "max_payload_bytes"):
59
+ value = getattr(self, name)
60
+ if type(value) is not int or value <= 0:
61
+ raise ValueError(f"{name} must be a positive integer")
62
+ if self.allowed_names is not None and not _name_list(self.allowed_names):
63
+ raise ValueError("allowed_names must be an array of valid names")
64
+ if not isinstance(self.networks, dict):
65
+ raise ValueError("networks must map group names to member arrays")
66
+ for group, members in self.networks.items():
67
+ if not valid_name(group) or not _name_list(members):
68
+ raise ValueError("invalid network or member name array")
69
+ if not isinstance(self.deny, (tuple, list)):
70
+ raise ValueError("deny must be an array of directional rules")
71
+ for rule in self.deny:
72
+ if (
73
+ not isinstance(rule, (tuple, list))
74
+ or len(rule) != 2
75
+ or any(not self._valid_selector(selector) for selector in rule)
76
+ ):
77
+ raise ValueError("deny entries need two name, @network or * selectors")
78
+
79
+ def _valid_selector(self, selector: str) -> bool:
80
+ return isinstance(selector, str) and (
81
+ selector == "*"
82
+ or valid_name(selector)
83
+ or (selector.startswith("@") and selector[1:] in self.networks)
84
+ )
85
+
86
+ def _matches(self, selector: str, name: str) -> bool:
87
+ if selector == "*":
88
+ return True
89
+ if selector.startswith("@"):
90
+ return name in self.networks[selector[1:]]
91
+ return selector == name
92
+
93
+ def permits(self, source: str, destination: str) -> bool:
94
+ return not any(
95
+ self._matches(left, source) and self._matches(right, destination)
96
+ for left, right in self.deny
97
+ )
98
+
99
+
100
+ def load_config(path: Path) -> RelayConfig:
101
+ """Load cooperative local policy once; obsolete/unknown options are errors."""
102
+ with path.open("rb") as source:
103
+ data = tomllib.load(source)
104
+ obsolete = {"curve", "allow_nonlocal"}.intersection(data)
105
+ if obsolete:
106
+ raise ValueError(
107
+ f"unsupported local-only configuration: {', '.join(sorted(obsolete))}; "
108
+ "security mode and nonlocal endpoints have been removed"
109
+ )
110
+ unknown = data.keys() - {item.name for item in fields(RelayConfig)}
111
+ if unknown:
112
+ raise ValueError(f"unknown relay configuration: {', '.join(sorted(unknown))}")
113
+ config = RelayConfig(**data) # Validate arrays before converting; strings aren't arrays.
114
+ return RelayConfig(
115
+ **{
116
+ **data,
117
+ "allowed_names": None if config.allowed_names is None else tuple(config.allowed_names),
118
+ "networks": {name: tuple(members) for name, members in config.networks.items()},
119
+ "deny": tuple(tuple(rule) for rule in config.deny),
120
+ }
121
+ )
@@ -0,0 +1,33 @@
1
+ """Private wire vocabulary; application payloads remain opaque bytes."""
2
+
3
+ import re
4
+
5
+ VERSION = b"AR1"
6
+ HELLO = b"HELLO"
7
+ SEND = b"SEND"
8
+ HEARTBEAT = b"HEARTBEAT"
9
+ CLOSE = b"CLOSE"
10
+ OK = b"OK"
11
+ ERROR = b"ERROR"
12
+ DATA = b"DATA"
13
+ _NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
14
+
15
+
16
+ def valid_name(name: str) -> bool:
17
+ return isinstance(name, str) and _NAME.fullmatch(name) is not None
18
+
19
+
20
+ class RelayError(Exception):
21
+ """Relay status: Router rejection, local rejection or lifecycle interruption.
22
+
23
+ Lifecycle codes closed/connection_failed can interrupt an already-sent request;
24
+ they do not prove no effect. No status is a remote execution result.
25
+ """
26
+
27
+ def __init__(self, code: str):
28
+ self.code = code
29
+ super().__init__(code)
30
+
31
+
32
+ class ReceiptTimeout(TimeoutError):
33
+ """No receipt arrived in time; the route's effect is uncertain. Never retry blindly."""
@@ -0,0 +1,5 @@
1
+ """Local relay service."""
2
+
3
+ from .relay import Relay
4
+
5
+ __all__ = ["Relay"]
@@ -0,0 +1,207 @@
1
+ """One ROUTER socket: admission, lease ownership and nonblocking opaque forwarding."""
2
+
3
+ import secrets
4
+ import time
5
+ from dataclasses import dataclass
6
+
7
+ import zmq
8
+ import zmq.asyncio
9
+
10
+ from ..config import RelayConfig
11
+ from ..protocol import CLOSE, DATA, ERROR, HEARTBEAT, HELLO, OK, SEND, VERSION, valid_name
12
+
13
+
14
+ @dataclass
15
+ class _Registration:
16
+ name: str
17
+ address: bytes
18
+ token: bytes
19
+ expires: float
20
+
21
+
22
+ class Relay:
23
+ """Owns a local context/socket; call start(), run(), and finally close().
24
+
25
+ Every destination send uses ROUTER_MANDATORY and DONTWAIT. A successful receipt
26
+ means native socket queue acceptance only. There is no Python outbound queue.
27
+ """
28
+
29
+ def __init__(self, config: RelayConfig | None = None) -> None:
30
+ self.config = config or RelayConfig()
31
+ self.context: zmq.Context | None = None
32
+ self.socket: zmq.asyncio.Socket | None = None
33
+ self.endpoint: str | None = None
34
+ self._names: dict[str, _Registration] = {}
35
+ self._addresses: dict[bytes, _Registration] = {}
36
+ self._running = False
37
+
38
+ async def start(self) -> str:
39
+ if self.context is not None:
40
+ raise RuntimeError("relay already started")
41
+ self.context = zmq.Context()
42
+ try:
43
+ self.socket = zmq.asyncio.Socket(self.context, zmq.ROUTER)
44
+ self.socket.setsockopt(zmq.LINGER, 0)
45
+ self.socket.setsockopt(zmq.IPV6, int(self.config.endpoint.startswith("tcp://[")))
46
+ self.socket.setsockopt(zmq.ROUTER_MANDATORY, 1)
47
+ self.socket.setsockopt(zmq.ROUTER_HANDOVER, 0)
48
+ self.socket.setsockopt(zmq.SNDHWM, self.config.send_hwm)
49
+ self.socket.setsockopt(zmq.RCVHWM, self.config.receive_hwm)
50
+ self.socket.setsockopt(zmq.MAXMSGSIZE, self.config.max_payload_bytes + 4096)
51
+ self.socket.bind(self.config.endpoint)
52
+ self.endpoint = self.socket.getsockopt(zmq.LAST_ENDPOINT).decode("utf-8")
53
+ return self.endpoint
54
+ except BaseException:
55
+ await self.close()
56
+ raise
57
+
58
+ def _remove(self, registration: _Registration) -> None:
59
+ # Fence removal by object identity even if a name/address was later reused.
60
+ if self._names.get(registration.name) is registration:
61
+ del self._names[registration.name]
62
+ if self._addresses.get(registration.address) is registration:
63
+ del self._addresses[registration.address]
64
+
65
+ def _expire(self) -> None:
66
+ now = time.monotonic()
67
+ for registration in tuple(self._names.values()):
68
+ if registration.expires <= now:
69
+ self._remove(registration)
70
+
71
+ async def _emit(self, address: bytes, *frames: bytes) -> str | None:
72
+ assert self.socket is not None
73
+ try:
74
+ await self.socket.send_multipart([address, VERSION, *frames], flags=zmq.DONTWAIT)
75
+ except zmq.Again:
76
+ return "destination_full"
77
+ except zmq.ZMQError as exc:
78
+ if exc.errno == zmq.EHOSTUNREACH:
79
+ return "destination_unavailable"
80
+ raise
81
+ return None
82
+
83
+ async def _error(self, address: bytes, token: bytes, request: bytes, code: str) -> None:
84
+ # If a control response cannot be queued, caller sees timeout/uncertain effect.
85
+ await self._emit(address, ERROR, token, request, code.encode("ascii"))
86
+
87
+ async def _hello(self, address: bytes, frames: list[bytes]) -> None:
88
+ if len(frames) != 2 or not 1 <= len(frames[0]) <= 64:
89
+ return
90
+ request, encoded_name = frames
91
+ try:
92
+ name = encoded_name.decode("ascii")
93
+ except UnicodeDecodeError:
94
+ name = ""
95
+ if not valid_name(name):
96
+ await self._error(address, b"", request, "invalid_name")
97
+ return
98
+ if self.config.allowed_names is not None and name not in self.config.allowed_names:
99
+ await self._error(address, b"", request, "admission_denied")
100
+ return
101
+ if name in self._names or address in self._addresses:
102
+ await self._error(address, b"", request, "duplicate_name")
103
+ return
104
+ registration = _Registration(
105
+ name,
106
+ address,
107
+ secrets.token_hex(16).encode("ascii"),
108
+ time.monotonic() + self.config.lease_seconds,
109
+ )
110
+ self._names[name] = registration
111
+ self._addresses[address] = registration
112
+ error = await self._emit(
113
+ address,
114
+ OK,
115
+ b"",
116
+ request,
117
+ registration.token,
118
+ str(self.config.lease_seconds).encode("ascii"),
119
+ str(self.config.max_payload_bytes).encode("ascii"),
120
+ )
121
+ if error is not None:
122
+ self._remove(registration)
123
+
124
+ async def _handle(self, packet: list[zmq.Frame]) -> None:
125
+ if len(packet) < 3 or bytes(packet[1]) != VERSION:
126
+ return
127
+ address, command = bytes(packet[0]), bytes(packet[2])
128
+ frames = [bytes(frame) for frame in packet[3:]]
129
+ if command == HELLO:
130
+ await self._hello(address, frames)
131
+ return
132
+ if not frames:
133
+ return
134
+ token = frames[0]
135
+ request = frames[1] if len(frames) > 1 and 1 <= len(frames[1]) <= 64 else b""
136
+ registration = self._addresses.get(address)
137
+ if registration is None or registration.token != token:
138
+ if request:
139
+ await self._error(address, token, request, "not_registered")
140
+ return
141
+ registration.expires = time.monotonic() + self.config.lease_seconds
142
+ if command == HEARTBEAT and len(frames) == 1:
143
+ return
144
+ if command == CLOSE and len(frames) == 2 and request:
145
+ self._remove(registration)
146
+ await self._emit(address, OK, token, request)
147
+ return
148
+ if command != SEND or len(frames) != 4 or not request:
149
+ if request:
150
+ await self._error(address, token, request, "invalid_command")
151
+ return
152
+ try:
153
+ destination = frames[2].decode("ascii")
154
+ except UnicodeDecodeError:
155
+ destination = ""
156
+ payload = frames[3]
157
+ if not valid_name(destination):
158
+ error = "invalid_destination"
159
+ elif len(payload) > self.config.max_payload_bytes:
160
+ error = "payload_too_large"
161
+ elif not self.config.permits(registration.name, destination):
162
+ error = "route_denied"
163
+ elif destination not in self._names:
164
+ error = "destination_unavailable"
165
+ else:
166
+ target = self._names[destination]
167
+ error = await self._emit(
168
+ target.address, DATA, target.token, registration.name.encode("ascii"), payload
169
+ )
170
+ if error is None:
171
+ await self._emit(address, OK, token, request)
172
+ else:
173
+ await self._error(address, token, request, error)
174
+
175
+ async def run(self) -> None:
176
+ if self.socket is None or self.socket.closed:
177
+ raise RuntimeError("start the relay before run()")
178
+ if self._running:
179
+ raise RuntimeError("relay is already running")
180
+ self._running = True
181
+ try:
182
+ while True:
183
+ self._expire()
184
+ if await self.socket.poll(timeout=max(1, int(self.config.lease_seconds * 100))):
185
+ packet = await self.socket.recv_multipart(copy=False)
186
+ self._expire() # Expiry before handling, not after an old heartbeat.
187
+ await self._handle(packet)
188
+ finally:
189
+ self._running = False
190
+
191
+ async def close(self) -> None:
192
+ """Stop run() before closing; no persistent service or native resources remain."""
193
+ if self._running:
194
+ raise RuntimeError("cancel and await run() before closing the relay")
195
+ if self.socket is not None:
196
+ self.socket.close(linger=0)
197
+ if self.context is not None:
198
+ self.context.term()
199
+ self._names.clear()
200
+ self._addresses.clear()
201
+
202
+ async def __aenter__(self) -> "Relay":
203
+ await self.start()
204
+ return self
205
+
206
+ async def __aexit__(self, *exc: object) -> None:
207
+ await self.close()