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 +30 -0
- bytegate/_version.py +34 -0
- bytegate/client.py +108 -0
- bytegate/errors.py +26 -0
- bytegate/models.py +68 -0
- bytegate/py.typed +0 -0
- bytegate/router.py +191 -0
- bytegate/server.py +210 -0
- bytegate-0.0.0.dist-info/METADATA +240 -0
- bytegate-0.0.0.dist-info/RECORD +13 -0
- bytegate-0.0.0.dist-info/WHEEL +5 -0
- bytegate-0.0.0.dist-info/licenses/LICENSE +21 -0
- bytegate-0.0.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://pypi.org/project/bytegate/)
|
|
50
|
+
[](https://pypi.org/project/bytegate/)
|
|
51
|
+
[](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,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
|