bytegate 0.0.0__py3-none-any.whl

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.
bytegate/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ bytegate - Redis-backed WebSocket Gateway
3
+
4
+ A transparent transport layer that relays bytes between API consumers
5
+ and remote WebSocket-connected systems via Redis pub/sub.
6
+
7
+ The gateway does NOT inspect message payloads - it simply moves bytes.
8
+ """
9
+
10
+ from bytegate._version import version as __version__
11
+ from bytegate.client import GatewayClient
12
+ from bytegate.errors import BytegateError, ConnectionNotFound, GatewayTimeout
13
+ from bytegate.models import GatewayEnvelope, GatewayResponse
14
+ from bytegate.server import GatewayServer
15
+
16
+ __all__ = [
17
+ # Version
18
+ "__version__",
19
+ # Client
20
+ "GatewayClient",
21
+ # Server
22
+ "GatewayServer",
23
+ # Models
24
+ "GatewayEnvelope",
25
+ "GatewayResponse",
26
+ # Errors
27
+ "BytegateError",
28
+ "ConnectionNotFound",
29
+ "GatewayTimeout",
30
+ ]
bytegate/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.0.0'
32
+ __version_tuple__ = version_tuple = (0, 0, 0)
33
+
34
+ __commit_id__ = commit_id = None
bytegate/client.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ Bytegate Client
3
+
4
+ Sends messages to remote systems via Redis pub/sub.
5
+ The client is completely content-agnostic - it just moves bytes.
6
+ """
7
+
8
+ import logging
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from redis.asyncio import Redis
13
+
14
+ from bytegate.errors import ConnectionNotFound, GatewayTimeout
15
+ from bytegate.models import GatewayEnvelope, GatewayResponse
16
+
17
+ LOG = logging.getLogger(__name__)
18
+
19
+ # Redis key patterns
20
+ CONNECTIONS_KEY = "bytegate:connections"
21
+ REQUEST_CHANNEL_PATTERN = "bytegate:{connection_id}:request"
22
+ RESPONSE_KEY_PATTERN = "bytegate:response:{request_id}"
23
+
24
+ # Default timeout for waiting on responses
25
+ DEFAULT_TIMEOUT_SECONDS = 30.0
26
+
27
+
28
+ class GatewayClient:
29
+ """
30
+ Client for sending messages through the Redis gateway.
31
+
32
+ Usage:
33
+ client = GatewayClient(redis)
34
+ response = await client.send("my-connection-id", b'{"method": "ping"}')
35
+ """
36
+
37
+ def __init__(self, redis: "Redis") -> None:
38
+ self._redis = redis
39
+
40
+ async def is_connected(self, connection_id: str) -> bool:
41
+ """Check if a connection is registered in the gateway."""
42
+ result = await self._redis.hexists(CONNECTIONS_KEY, connection_id) # type: ignore[misc]
43
+ return bool(result)
44
+
45
+ async def send(
46
+ self,
47
+ connection_id: str,
48
+ payload: bytes,
49
+ *,
50
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
51
+ ) -> GatewayResponse:
52
+ """
53
+ Send a message to a remote system and wait for a response.
54
+
55
+ Args:
56
+ connection_id: Identifier for the remote connection
57
+ payload: Message payload as bytes (opaque - not inspected)
58
+ timeout: Maximum time to wait for response
59
+
60
+ Returns:
61
+ GatewayResponse containing the response payload
62
+
63
+ Raises:
64
+ ConnectionNotFound: If the connection is not registered
65
+ GatewayTimeout: If no response is received within timeout
66
+ """
67
+ if not await self.is_connected(connection_id):
68
+ raise ConnectionNotFound(connection_id)
69
+
70
+ envelope = GatewayEnvelope(connection_id=connection_id, payload=payload)
71
+ request_id = envelope.request_id
72
+
73
+ LOG.debug("Sending request %s to connection %s", request_id, connection_id)
74
+
75
+ channel = REQUEST_CHANNEL_PATTERN.format(connection_id=connection_id)
76
+ await self._redis.publish(channel, envelope.model_dump_json())
77
+
78
+ response_key = RESPONSE_KEY_PATTERN.format(request_id=request_id)
79
+ result = await self._redis.blpop([response_key], timeout=timeout) # type: ignore[misc]
80
+
81
+ if result is None:
82
+ LOG.warning("Timeout waiting for response to request %s", request_id)
83
+ raise GatewayTimeout(connection_id, timeout)
84
+
85
+ _, response_data = result
86
+ response = GatewayResponse.model_validate_json(response_data)
87
+
88
+ LOG.debug("Received response for request %s", request_id)
89
+ return response
90
+
91
+ async def send_no_wait(self, connection_id: str, payload: bytes) -> str:
92
+ """
93
+ Send a message without waiting for a response.
94
+
95
+ Returns the request_id for tracking purposes.
96
+
97
+ Raises:
98
+ ConnectionNotFound: If the connection is not registered
99
+ """
100
+ if not await self.is_connected(connection_id):
101
+ raise ConnectionNotFound(connection_id)
102
+
103
+ envelope = GatewayEnvelope(connection_id=connection_id, payload=payload)
104
+
105
+ channel = REQUEST_CHANNEL_PATTERN.format(connection_id=envelope.connection_id)
106
+ await self._redis.publish(channel, envelope.model_dump_json())
107
+
108
+ return envelope.request_id
bytegate/errors.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ Bytegate errors.
3
+
4
+ Simple hierarchy: one base class, minimal specific exceptions.
5
+ """
6
+
7
+
8
+ class BytegateError(Exception):
9
+ """Base exception for all bytegate errors."""
10
+
11
+
12
+ class ConnectionNotFound(BytegateError):
13
+ """The requested connection is not registered in the gateway."""
14
+
15
+ def __init__(self, connection_id: str) -> None:
16
+ self.connection_id = connection_id
17
+ super().__init__(f"Connection not found: {connection_id}")
18
+
19
+
20
+ class GatewayTimeout(BytegateError):
21
+ """Timed out waiting for a response from the remote system."""
22
+
23
+ def __init__(self, connection_id: str, timeout: float) -> None:
24
+ self.connection_id = connection_id
25
+ self.timeout = timeout
26
+ super().__init__(f"Timeout after {timeout}s waiting for response from: {connection_id}")
bytegate/models.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ Bytegate message models.
3
+
4
+ These models define the envelope format for gateway messages.
5
+ The `payload` field is raw bytes - the gateway does not inspect it.
6
+ """
7
+
8
+ from base64 import b64decode, b64encode
9
+ from uuid import uuid4
10
+
11
+ from pydantic import BaseModel, Field, field_serializer, field_validator
12
+
13
+
14
+ def _generate_request_id() -> str:
15
+ return uuid4().hex
16
+
17
+
18
+ class GatewayEnvelope(BaseModel):
19
+ """
20
+ Envelope for messages sent through the gateway.
21
+
22
+ The gateway only inspects the envelope metadata (request_id, connection_id).
23
+ The payload is passed through as-is without inspection or validation.
24
+
25
+ When serialized to JSON, payload bytes are base64-encoded.
26
+ """
27
+
28
+ request_id: str = Field(default_factory=_generate_request_id)
29
+ connection_id: str
30
+ payload: bytes # Opaque - the gateway does not inspect this
31
+
32
+ @field_serializer("payload")
33
+ def serialize_payload(self, value: bytes) -> str:
34
+ """Encode bytes as base64 for JSON serialization."""
35
+ return b64encode(value).decode("ascii")
36
+
37
+ @field_validator("payload", mode="before")
38
+ @classmethod
39
+ def deserialize_payload(cls, value: str | bytes) -> bytes:
40
+ """Decode base64 string back to bytes during parsing."""
41
+ if isinstance(value, bytes):
42
+ return value
43
+ return b64decode(value)
44
+
45
+
46
+ class GatewayResponse(BaseModel):
47
+ """
48
+ Response envelope returned through the gateway.
49
+
50
+ Like the request envelope, the payload is opaque bytes.
51
+ """
52
+
53
+ request_id: str
54
+ payload: bytes # Opaque - the gateway does not inspect this
55
+ error: str | None = None # Set if the remote system returned an error
56
+
57
+ @field_serializer("payload")
58
+ def serialize_payload(self, value: bytes) -> str:
59
+ """Encode bytes as base64 for JSON serialization."""
60
+ return b64encode(value).decode("ascii")
61
+
62
+ @field_validator("payload", mode="before")
63
+ @classmethod
64
+ def deserialize_payload(cls, value: str | bytes) -> bytes:
65
+ """Decode base64 string back to bytes during parsing."""
66
+ if isinstance(value, bytes):
67
+ return value
68
+ return b64decode(value)
bytegate/py.typed ADDED
File without changes
bytegate/router.py ADDED
@@ -0,0 +1,191 @@
1
+ """
2
+ FastAPI Router for Bytegate WebSocket Endpoint
3
+
4
+ Provides a WebSocket endpoint for remote systems to connect to the gateway.
5
+ All payloads are raw bytes - the gateway does not inspect content.
6
+ """
7
+
8
+ import asyncio
9
+ import contextlib
10
+ import logging
11
+ from collections.abc import AsyncIterator
12
+ from contextlib import asynccontextmanager
13
+ from typing import TYPE_CHECKING
14
+
15
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
16
+ from pydantic import ValidationError
17
+
18
+ if TYPE_CHECKING:
19
+ from redis.asyncio import Redis # noqa: F401
20
+
21
+ from bytegate.models import GatewayEnvelope, GatewayResponse
22
+
23
+ LOG = logging.getLogger(__name__)
24
+
25
+ router = APIRouter(prefix="/bytegate", tags=["bytegate"])
26
+
27
+ # Redis key patterns (must match client.py and server.py)
28
+ CONNECTIONS_KEY = "bytegate:connections"
29
+ REQUEST_CHANNEL_PATTERN = "bytegate:{connection_id}:request"
30
+ RESPONSE_KEY_PATTERN = "bytegate:response:{request_id}"
31
+ RESPONSE_KEY_TTL_SECONDS = 60
32
+ HEARTBEAT_INTERVAL_SECONDS = 10
33
+
34
+
35
+ @router.websocket("/{connection_id}")
36
+ async def bytegate_websocket(websocket: WebSocket, connection_id: str) -> None:
37
+ """
38
+ WebSocket endpoint for remote system connections.
39
+
40
+ The connection_id should be a unique identifier for the remote system.
41
+ All messages are passed through as raw bytes - the gateway does not
42
+ inspect or validate payloads.
43
+ """
44
+ await websocket.accept()
45
+ LOG.info("New bytegate connection: %s", connection_id)
46
+
47
+ redis: Redis = websocket.app.extra["redis"]
48
+ server_id: str = websocket.app.extra.get("server_id", "unknown")
49
+
50
+ try:
51
+ async with _connection_lifecycle(redis, connection_id, server_id):
52
+ await _run_connection(websocket, redis, connection_id)
53
+ except WebSocketDisconnect:
54
+ LOG.info("Bytegate connection disconnected: %s", connection_id)
55
+ except Exception:
56
+ LOG.exception("Error in bytegate connection: %s", connection_id)
57
+
58
+
59
+ @asynccontextmanager
60
+ async def _connection_lifecycle(
61
+ redis: "Redis",
62
+ connection_id: str,
63
+ server_id: str,
64
+ ) -> AsyncIterator[None]:
65
+ """Register/unregister connection in Redis with heartbeat."""
66
+ await redis.hset(CONNECTIONS_KEY, connection_id, server_id) # type: ignore[misc]
67
+ LOG.info("Registered connection %s on server %s", connection_id, server_id)
68
+
69
+ heartbeat_task = asyncio.create_task(_heartbeat(redis, connection_id, server_id))
70
+
71
+ try:
72
+ yield
73
+ finally:
74
+ heartbeat_task.cancel()
75
+ with contextlib.suppress(asyncio.CancelledError):
76
+ await heartbeat_task
77
+
78
+ await redis.hdel(CONNECTIONS_KEY, connection_id) # type: ignore[misc]
79
+ LOG.info("Unregistered connection %s", connection_id)
80
+
81
+
82
+ async def _heartbeat(
83
+ redis: "Redis",
84
+ connection_id: str,
85
+ server_id: str,
86
+ ) -> None:
87
+ """Periodically refresh connection registration."""
88
+ while True:
89
+ await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
90
+ await redis.hset(CONNECTIONS_KEY, connection_id, server_id) # type: ignore[misc]
91
+
92
+
93
+ async def _run_connection(
94
+ websocket: WebSocket,
95
+ redis: "Redis",
96
+ connection_id: str,
97
+ ) -> None:
98
+ """Main connection loop: bridge Redis pub/sub with WebSocket."""
99
+ pending_requests: dict[str, asyncio.Future[bytes]] = {}
100
+
101
+ async with asyncio.TaskGroup() as tg:
102
+ tg.create_task(_redis_to_websocket(websocket, redis, connection_id, pending_requests))
103
+ tg.create_task(_websocket_to_redis(websocket, pending_requests))
104
+
105
+
106
+ async def _redis_to_websocket(
107
+ websocket: WebSocket,
108
+ redis: "Redis",
109
+ connection_id: str,
110
+ pending_requests: dict[str, asyncio.Future[bytes]],
111
+ ) -> None:
112
+ """Subscribe to Redis and forward requests to WebSocket."""
113
+ channel_name = REQUEST_CHANNEL_PATTERN.format(connection_id=connection_id)
114
+ pubsub = redis.pubsub()
115
+
116
+ try:
117
+ await pubsub.subscribe(channel_name)
118
+ LOG.debug("Subscribed to channel %s", channel_name)
119
+
120
+ async for message in pubsub.listen():
121
+ if message["type"] != "message":
122
+ continue
123
+
124
+ await _handle_redis_message(websocket, redis, message["data"], pending_requests)
125
+ finally:
126
+ await pubsub.unsubscribe(channel_name)
127
+ await pubsub.close()
128
+
129
+
130
+ async def _handle_redis_message(
131
+ websocket: WebSocket,
132
+ redis: "Redis",
133
+ data: bytes | str,
134
+ pending_requests: dict[str, asyncio.Future[bytes]],
135
+ ) -> None:
136
+ """Process a request from Redis, forward to WebSocket, wait for response."""
137
+ try:
138
+ if isinstance(data, bytes):
139
+ data = data.decode("utf-8")
140
+
141
+ envelope = GatewayEnvelope.model_validate_json(data)
142
+ request_id = envelope.request_id
143
+
144
+ LOG.debug("Forwarding request %s to WebSocket", request_id)
145
+
146
+ # Track this request
147
+ response_future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future()
148
+ pending_requests[request_id] = response_future
149
+
150
+ # Forward payload to WebSocket (transparent - raw bytes)
151
+ await websocket.send_bytes(envelope.payload)
152
+
153
+ # Wait for response
154
+ try:
155
+ response_payload = await asyncio.wait_for(response_future, timeout=30.0)
156
+ response = GatewayResponse(request_id=request_id, payload=response_payload)
157
+ except TimeoutError:
158
+ response = GatewayResponse(
159
+ request_id=request_id,
160
+ payload=b"",
161
+ error="Timeout waiting for WebSocket response",
162
+ )
163
+ finally:
164
+ pending_requests.pop(request_id, None)
165
+
166
+ # Publish response to Redis
167
+ response_key = RESPONSE_KEY_PATTERN.format(request_id=request_id)
168
+ await redis.lpush(response_key, response.model_dump_json()) # type: ignore[misc]
169
+ await redis.expire(response_key, RESPONSE_KEY_TTL_SECONDS)
170
+
171
+ except ValidationError:
172
+ LOG.exception("Invalid message format from Redis")
173
+ except Exception:
174
+ LOG.exception("Error handling Redis message")
175
+
176
+
177
+ async def _websocket_to_redis(
178
+ websocket: WebSocket,
179
+ pending_requests: dict[str, asyncio.Future[bytes]],
180
+ ) -> None:
181
+ """Receive messages from WebSocket and resolve pending requests."""
182
+ while True:
183
+ # Receive as bytes
184
+ message = await websocket.receive_bytes()
185
+
186
+ # Match response to oldest pending request (FIFO order)
187
+ if pending_requests:
188
+ request_id = next(iter(pending_requests))
189
+ future = pending_requests.get(request_id)
190
+ if future and not future.done():
191
+ future.set_result(message)
bytegate/server.py ADDED
@@ -0,0 +1,210 @@
1
+ """
2
+ Bytegate Server
3
+
4
+ Manages WebSocket connections from remote systems and bridges them with Redis.
5
+ The server is completely content-agnostic - it just moves bytes.
6
+
7
+ This module provides a standalone server for use outside of FastAPI.
8
+ For FastAPI integration, use the router module instead.
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from redis.asyncio import Redis
18
+ from websockets.asyncio.server import ServerConnection
19
+
20
+ from bytegate.models import GatewayEnvelope, GatewayResponse
21
+
22
+ LOG = logging.getLogger(__name__)
23
+
24
+ # Redis key patterns (must match client.py)
25
+ CONNECTIONS_KEY = "bytegate:connections"
26
+ REQUEST_CHANNEL_PATTERN = "bytegate:{connection_id}:request"
27
+ RESPONSE_KEY_PATTERN = "bytegate:response:{request_id}"
28
+ RESPONSE_KEY_TTL_SECONDS = 60
29
+ HEARTBEAT_INTERVAL_SECONDS = 10
30
+
31
+
32
+ class GatewayServer:
33
+ """
34
+ Gateway server for bridging WebSocket connections with Redis.
35
+
36
+ This class manages multiple WebSocket connections and handles the
37
+ Redis pub/sub communication for each.
38
+
39
+ Usage:
40
+ server = GatewayServer(redis, server_id="pod-1")
41
+ await server.handle_connection(connection_id, websocket)
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ redis: "Redis",
47
+ server_id: str,
48
+ *,
49
+ on_connect: Callable[[str], Awaitable[None]] | None = None,
50
+ on_disconnect: Callable[[str], Awaitable[None]] | None = None,
51
+ ) -> None:
52
+ self._redis = redis
53
+ self._server_id = server_id
54
+ self._active_connections: set[str] = set()
55
+ self._on_connect = on_connect
56
+ self._on_disconnect = on_disconnect
57
+
58
+ @property
59
+ def server_id(self) -> str:
60
+ return self._server_id
61
+
62
+ @property
63
+ def active_connections(self) -> list[str]:
64
+ """List of currently active connection IDs."""
65
+ return list(self._active_connections)
66
+
67
+ async def handle_connection(
68
+ self,
69
+ connection_id: str,
70
+ websocket: "ServerConnection",
71
+ ) -> None:
72
+ """
73
+ Handle a WebSocket connection lifecycle.
74
+
75
+ This method blocks until the connection closes.
76
+ """
77
+ self._active_connections.add(connection_id)
78
+
79
+ try:
80
+ await self._register(connection_id)
81
+
82
+ if self._on_connect:
83
+ await self._on_connect(connection_id)
84
+
85
+ await self._run_connection(connection_id, websocket)
86
+
87
+ finally:
88
+ await self._unregister(connection_id)
89
+ self._active_connections.discard(connection_id)
90
+
91
+ if self._on_disconnect:
92
+ await self._on_disconnect(connection_id)
93
+
94
+ async def _register(self, connection_id: str) -> None:
95
+ """Register connection in Redis."""
96
+ await self._redis.hset(CONNECTIONS_KEY, connection_id, self._server_id) # type: ignore[misc]
97
+ LOG.info("Registered connection %s on server %s", connection_id, self._server_id)
98
+
99
+ async def _unregister(self, connection_id: str) -> None:
100
+ """Remove connection from Redis."""
101
+ await self._redis.hdel(CONNECTIONS_KEY, connection_id) # type: ignore[misc]
102
+ LOG.info("Unregistered connection %s", connection_id)
103
+
104
+ async def _run_connection(
105
+ self,
106
+ connection_id: str,
107
+ websocket: "ServerConnection",
108
+ ) -> None:
109
+ """Main loop: bridge Redis pub/sub with WebSocket."""
110
+ pending_requests: dict[str, asyncio.Future[bytes]] = {}
111
+
112
+ async with asyncio.TaskGroup() as tg:
113
+ tg.create_task(self._heartbeat(connection_id))
114
+ tg.create_task(self._redis_to_websocket(connection_id, websocket, pending_requests))
115
+ tg.create_task(self._websocket_to_redis(websocket, pending_requests))
116
+
117
+ async def _heartbeat(self, connection_id: str) -> None:
118
+ """Periodically refresh connection registration."""
119
+ while True:
120
+ await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
121
+ await self._redis.hset(CONNECTIONS_KEY, connection_id, self._server_id) # type: ignore[misc]
122
+
123
+ async def _redis_to_websocket(
124
+ self,
125
+ connection_id: str,
126
+ websocket: "ServerConnection",
127
+ pending_requests: dict[str, asyncio.Future[bytes]],
128
+ ) -> None:
129
+ """Subscribe to Redis and forward requests to WebSocket."""
130
+ channel_name = REQUEST_CHANNEL_PATTERN.format(connection_id=connection_id)
131
+ pubsub = self._redis.pubsub()
132
+
133
+ try:
134
+ await pubsub.subscribe(channel_name)
135
+ LOG.debug("Subscribed to channel %s", channel_name)
136
+
137
+ async for message in pubsub.listen():
138
+ if message["type"] != "message":
139
+ continue
140
+
141
+ await self._handle_redis_message(websocket, message["data"], pending_requests)
142
+ finally:
143
+ await pubsub.unsubscribe(channel_name)
144
+ await pubsub.close()
145
+
146
+ async def _handle_redis_message(
147
+ self,
148
+ websocket: "ServerConnection",
149
+ data: bytes | str,
150
+ pending_requests: dict[str, asyncio.Future[bytes]],
151
+ ) -> None:
152
+ """Process a request from Redis, forward to WebSocket, wait for response."""
153
+ try:
154
+ if isinstance(data, bytes):
155
+ data = data.decode("utf-8")
156
+
157
+ envelope = GatewayEnvelope.model_validate_json(data)
158
+ request_id = envelope.request_id
159
+
160
+ LOG.debug("Forwarding request %s to WebSocket", request_id)
161
+
162
+ # Track this request
163
+ response_future: asyncio.Future[bytes] = asyncio.get_running_loop().create_future()
164
+ pending_requests[request_id] = response_future
165
+
166
+ # Forward payload to WebSocket (transparent - raw bytes)
167
+ await websocket.send(envelope.payload)
168
+
169
+ # Wait for response
170
+ try:
171
+ response_payload = await asyncio.wait_for(response_future, timeout=30.0)
172
+ response = GatewayResponse(request_id=request_id, payload=response_payload)
173
+ except TimeoutError:
174
+ response = GatewayResponse(
175
+ request_id=request_id,
176
+ payload=b"",
177
+ error="Timeout waiting for WebSocket response",
178
+ )
179
+ finally:
180
+ pending_requests.pop(request_id, None)
181
+
182
+ # Publish response to Redis
183
+ response_key = RESPONSE_KEY_PATTERN.format(request_id=request_id)
184
+ await self._redis.lpush(response_key, response.model_dump_json()) # type: ignore[misc]
185
+ await self._redis.expire(response_key, RESPONSE_KEY_TTL_SECONDS)
186
+
187
+ except Exception:
188
+ LOG.exception("Error handling Redis message")
189
+
190
+ async def _websocket_to_redis(
191
+ self,
192
+ websocket: "ServerConnection",
193
+ pending_requests: dict[str, asyncio.Future[bytes]],
194
+ ) -> None:
195
+ """Receive messages from WebSocket and resolve pending requests."""
196
+ async for message in websocket:
197
+ # Ensure we have bytes
198
+ if isinstance(message, str):
199
+ message = message.encode("utf-8")
200
+
201
+ # Match response to oldest pending request (FIFO order)
202
+ if pending_requests:
203
+ request_id = next(iter(pending_requests))
204
+ future = pending_requests.get(request_id)
205
+ if future and not future.done():
206
+ future.set_result(message)
207
+
208
+ async def shutdown(self) -> None:
209
+ """Gracefully shutdown the server."""
210
+ LOG.info("Shutting down bytegate server %s", self._server_id)
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.4
2
+ Name: bytegate
3
+ Version: 0.0.0
4
+ Summary: A transparent Redis-backed WebSocket gateway that relays bytes between distributed systems.
5
+ Author: Jan Bjørge
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/janbjorge/bytegate
8
+ Project-URL: Repository, https://github.com/janbjorge/bytegate
9
+ Project-URL: Documentation, https://github.com/janbjorge/bytegate#readme
10
+ Project-URL: Issues, https://github.com/janbjorge/bytegate/issues
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Natural Language :: English
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Classifier: Topic :: System :: Networking
26
+ Classifier: Topic :: System :: Distributed Computing
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.11
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: redis>=5.0.0
32
+ Requires-Dist: pydantic>=2.0.0
33
+ Requires-Dist: websockets>=12.0
34
+ Provides-Extra: fastapi
35
+ Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
36
+ Provides-Extra: dev
37
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
38
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
39
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
40
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
41
+ Requires-Dist: build>=1.0.0; extra == "dev"
42
+ Requires-Dist: twine>=5.0.0; extra == "dev"
43
+ Dynamic: license-file
44
+
45
+ # bytegate
46
+
47
+ A transparent Redis-backed WebSocket gateway that relays bytes between distributed systems.
48
+
49
+ [![PyPI - Version](https://img.shields.io/pypi/v/bytegate)](https://pypi.org/project/bytegate/)
50
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/bytegate)](https://pypi.org/project/bytegate/)
51
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
52
+
53
+ ## What is bytegate?
54
+
55
+ Bytegate is a **transparent transport layer** that connects WebSocket clients to your backend services through Redis pub/sub. It acts as a relay - it doesn't inspect, validate, or transform your data. It just moves bytes.
56
+
57
+ ```
58
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
59
+ │ Remote System │◄──WS───►│ Bytegate │◄─Redis─►│ Your API │
60
+ │ (WebSocket) │ │ Server │ │ (Client) │
61
+ └─────────────────┘ └─────────────────┘ └─────────────────┘
62
+ ```
63
+
64
+ ### Why bytegate?
65
+
66
+ - **Transparent**: Payloads are raw bytes - encode/decode however you want
67
+ - **Simple**: One concept (connection_id), two components (client/server)
68
+ - **Scalable**: Multiple API pods can send to any connected WebSocket via Redis
69
+ - **Async-native**: Built on `asyncio`, `redis-py`, and `websockets`
70
+ - **Framework-friendly**: FastAPI router included, or use standalone
71
+
72
+ ## Installation
73
+
74
+ ```bash
75
+ pip install bytegate
76
+
77
+ # With FastAPI support
78
+ pip install bytegate[fastapi]
79
+ ```
80
+
81
+ ## Quick Start
82
+
83
+ ### Server Side (WebSocket Handler)
84
+
85
+ The server accepts WebSocket connections and bridges them with Redis:
86
+
87
+ ```python
88
+ from fastapi import FastAPI
89
+ from redis import asyncio as aioredis
90
+
91
+ from bytegate import router
92
+
93
+ app = FastAPI()
94
+ app.include_router(router)
95
+
96
+ @app.on_event("startup")
97
+ async def startup():
98
+ app.extra["redis"] = aioredis.from_url("redis://localhost")
99
+ app.extra["server_id"] = "pod-1" # Unique identifier for this server
100
+ ```
101
+
102
+ Remote systems connect via WebSocket to `/bytegate/{connection_id}`.
103
+
104
+ ### Client Side (Send Messages)
105
+
106
+ Any service can send messages to connected WebSocket clients:
107
+
108
+ ```python
109
+ from redis import asyncio as aioredis
110
+ from bytegate import GatewayClient
111
+
112
+ redis = aioredis.from_url("redis://localhost")
113
+ client = GatewayClient(redis)
114
+
115
+ # Check if a connection exists
116
+ if await client.is_connected("device-123"):
117
+ # Send bytes and wait for response
118
+ response = await client.send("device-123", b'{"command": "ping"}')
119
+ print(response.payload) # Raw bytes from the remote system
120
+
121
+ # Fire-and-forget (no response)
122
+ request_id = await client.send_no_wait("device-123", b"heartbeat")
123
+ ```
124
+
125
+ ## Architecture
126
+
127
+ ### How It Works
128
+
129
+ 1. **Remote system connects** via WebSocket → Server registers `connection_id` in Redis
130
+ 2. **API sends message** → Client publishes to Redis channel `bytegate:{connection_id}:request`
131
+ 3. **Server receives** → Forwards payload bytes to WebSocket, waits for response
132
+ 4. **Response flows back** → Server publishes to Redis list `bytegate:response:{request_id}`
133
+ 5. **API receives response** → Client returns the bytes to caller
134
+
135
+ ### Key Design Decisions
136
+
137
+ | Decision | Rationale |
138
+ |----------|-----------|
139
+ | **Bytes-only payload** | You decide encoding. JSON? Protobuf? MessagePack? Your choice. |
140
+ | **FIFO response matching** | Simple request-response pattern. One request, one response. |
141
+ | **Redis pub/sub + lists** | Pub/sub for fan-out, lists for reliable response delivery. |
142
+ | **Heartbeat registration** | Connections re-register every 10s. Stale entries auto-expire. |
143
+
144
+ ## API Reference
145
+
146
+ ### `GatewayClient`
147
+
148
+ ```python
149
+ client = GatewayClient(redis: aioredis.Redis)
150
+
151
+ # Check connection status
152
+ await client.is_connected(connection_id: str) -> bool
153
+
154
+ # Send and wait for response
155
+ await client.send(
156
+ connection_id: str,
157
+ payload: bytes,
158
+ timeout: float = 30.0
159
+ ) -> GatewayResponse
160
+
161
+ # Send without waiting
162
+ await client.send_no_wait(
163
+ connection_id: str,
164
+ payload: bytes
165
+ ) -> str # Returns request_id
166
+ ```
167
+
168
+ ### `GatewayServer` (Standalone)
169
+
170
+ For non-FastAPI usage:
171
+
172
+ ```python
173
+ from bytegate import GatewayServer
174
+
175
+ server = GatewayServer(
176
+ redis=redis,
177
+ server_id="my-server",
178
+ on_connect=async_callback, # Optional
179
+ on_disconnect=async_callback # Optional
180
+ )
181
+
182
+ # Handle a websocket connection (blocks until disconnect)
183
+ await server.handle_connection(connection_id, websocket)
184
+ ```
185
+
186
+ ### Models
187
+
188
+ ```python
189
+ from bytegate import GatewayEnvelope, GatewayResponse
190
+
191
+ # Request envelope (internal, but useful for testing)
192
+ envelope = GatewayEnvelope(
193
+ connection_id="device-123",
194
+ payload=b"raw bytes here"
195
+ )
196
+ # request_id is auto-generated
197
+
198
+ # Response from remote system
199
+ response = GatewayResponse(
200
+ request_id="abc123",
201
+ payload=b"response bytes",
202
+ error=None # Or error message string
203
+ )
204
+ ```
205
+
206
+ ### Errors
207
+
208
+ ```python
209
+ from bytegate import BytegateError, ConnectionNotFound, GatewayTimeout
210
+
211
+ try:
212
+ response = await client.send("device-123", b"data")
213
+ except ConnectionNotFound as e:
214
+ print(f"Device {e.connection_id} not connected")
215
+ except GatewayTimeout as e:
216
+ print(f"Timeout after {e.timeout}s waiting for {e.connection_id}")
217
+ except BytegateError:
218
+ print("Other bytegate error")
219
+ ```
220
+
221
+ ## Redis Keys
222
+
223
+ | Key Pattern | Type | Purpose |
224
+ |-------------|------|---------|
225
+ | `bytegate:connections` | Hash | Maps `connection_id` → `server_id` |
226
+ | `bytegate:{connection_id}:request` | Pub/Sub | Channel for incoming requests |
227
+ | `bytegate:response:{request_id}` | List | Response queue (TTL: 60s) |
228
+
229
+ ## Requirements
230
+
231
+ - Python 3.11+
232
+ - Redis 5.0+ (for streams/pub-sub)
233
+ - `redis[hiredis]` (recommended for performance)
234
+ - `pydantic` 2.0+
235
+ - `websockets` 12.0+
236
+ - `fastapi` 0.100+ (optional, for router)
237
+
238
+ ## License
239
+
240
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,13 @@
1
+ bytegate/__init__.py,sha256=DeM9zoT0Ec6Ul3Kf6wD8GXT0bV-2tncIlADREmRCUrY,785
2
+ bytegate/_version.py,sha256=ZUZqmAVZt0gh0Pbj4cYM1PmY1fBwaR8CjTz4JqhQ7Us,704
3
+ bytegate/client.py,sha256=RTCQlrMisQF5BPKCdb3qrghMa3Ia20wqQzO5unAmyXI,3603
4
+ bytegate/errors.py,sha256=Wt0TwdSqbf8o1so5deWf7FTQ6jo8kx8f-VFwTlTFO7s,794
5
+ bytegate/models.py,sha256=0An3CSKvotIDQ2Tt22umqNcyucfODUVRUW1f_z50HEM,2135
6
+ bytegate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ bytegate/router.py,sha256=mBZwMU1I6Nbn1NHotnnq0E9dLffHrTRWYysjW_15lMA,6505
8
+ bytegate/server.py,sha256=5l83VVKv-99v1UiEa0RFApnNi_v9Gr0aFNivCr1Utqw,7569
9
+ bytegate-0.0.0.dist-info/licenses/LICENSE,sha256=NAfva4Mdl-fLh0bACrfC-Q10C2mD0J5tlJw0kpv9z8k,1068
10
+ bytegate-0.0.0.dist-info/METADATA,sha256=YowSw3j2tLrxZ2_0yvw2BXvSSeG-2y95cO4KTl3O_7w,7795
11
+ bytegate-0.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ bytegate-0.0.0.dist-info/top_level.txt,sha256=qSElWNN7Sxnn4GCwDCwLL0EveOCvW7W-iHvyma9aoNI,9
13
+ bytegate-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jan Bjørge
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 @@
1
+ bytegate