mytunnelSDK 1.2.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,13 @@
1
+ Custom License
2
+
3
+ You are permitted to use, copy, and modify this software for personal or internal business purposes.
4
+
5
+ You are NOT permitted to redistribute, sub-license, or sell this software or any derivatives of this software in any form, whether modified or unmodified, without explicit prior written permission from the author.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
8
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
9
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
10
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
11
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
12
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
13
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: mytunnelSDK
3
+ Version: 1.2.0
4
+ Summary: A Python SDK for ...
5
+ Author-email: Bhargav Barman <bhargavbprd@gmail.com>
6
+ License: Custom
7
+ Keywords: sdk,api
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: requests>=2.31.0
14
+ Requires-Dist: httpx>=0.24.0
15
+ Requires-Dist: websocket-client>=1.6.0
16
+ Requires-Dist: python-dotenv>=1.0.0
17
+ Dynamic: license-file
18
+
19
+ # Tunnel SDK
20
+
21
+ This is the production-ready Python SDK for the Tunnel Gateway. It allows you to establish secure WebSocket tunnels and seamlessly proxy HTTP traffic to your local development server.
22
+
23
+ ## Features
24
+
25
+ * **Thread-safe**: Runs in the background without blocking your main application loop. No `asyncio` required!
26
+ * **Header Authentication**: Secures your API key in HTTP headers (`Authorization: Bearer`), preventing exposure in URL logs.
27
+ * **Payload Compression**: Transparent zlib compression for data exchange over WebSocket, boosting bandwidth efficiency and speed.
28
+ * **Connection Pooling**: Optimized HTTP connection pooling with keep-alive limits for high-concurrency dispatching.
29
+ * **Automated Reconnection**: Built-in exponential backoff means you don't have to manually manage network hiccups.
30
+ * **Smart Streaming**: Automatically switches to chunked streaming for large files, keeping memory overhead low.
31
+ * **Context Manager**: Start and stop the tunnel cleanly using `with Tunnel.from_env() as tunnel:`.
32
+ * **Events API**: Listen to hooks like `on_connect`, `on_disconnect`, and `on_error`.
33
+
34
+ ## Quick Start
35
+
36
+ Create a `.env` file in your root folder:
37
+ ```env
38
+ TUNNEL_API_KEY=your-secret-key
39
+ TUNNEL_TARGET_PATH=/api
40
+ TUNNEL_PORT=5000
41
+ ```
42
+
43
+ Run the tunnel:
44
+ ```python
45
+ from tunnel_sdk import Tunnel
46
+
47
+ with Tunnel.from_env() as tunnel:
48
+ # This blocks until interrupted!
49
+ tunnel.wait()
50
+ ```
51
+
52
+ When connected, you'll see a success message like:
53
+ ```
54
+ ============================================================
55
+ TUNNEL CONNECTED SUCCESSFULLY!
56
+ Public URL : https://your-gateway.com/api
57
+ Forwarding to : http://127.0.0.1:5000
58
+ ============================================================
59
+ ```
60
+
61
+ ## SDK Information
62
+
63
+ The SDK provides an API to access current connection statistics and configuration info:
64
+
65
+ ```python
66
+ from tunnel_sdk import Tunnel
67
+
68
+ with Tunnel.from_env() as tunnel:
69
+ # Print SDK info as a dictionary
70
+ print(tunnel.sd.info())
71
+
72
+ tunnel.wait()
73
+ ```
74
+
75
+ The dictionary returned by `tunnel.sd.info()` contains information like:
76
+ * `sdk_version`: The SDK version.
77
+ * `protocol_version`: The protocol version.
78
+ * `is_connected`: Whether the tunnel is currently connected.
79
+ * `uptime`: The time in seconds the tunnel has been running.
80
+ * `reconnect_count`: The number of times the tunnel has reconnected.
81
+ * `statistics`: A dictionary containing stats like requests handled, bytes uploaded/downloaded, and active requests.
82
+
83
+ ## Structure
84
+
85
+ * `client.py` - The main `Tunnel` interface.
86
+ * `connection.py` - WebSocket loop and reconnect logic.
87
+ * `dispatcher.py` - HTTP request proxying via `httpx`.
88
+ * `protocol.py` - Data structures and base64 encoders.
89
+ * `config.py` - Environment loader.
90
+
91
+ For full API reference, see `docs/sdk_reference.md` in the project root.
@@ -0,0 +1,73 @@
1
+ # Tunnel SDK
2
+
3
+ This is the production-ready Python SDK for the Tunnel Gateway. It allows you to establish secure WebSocket tunnels and seamlessly proxy HTTP traffic to your local development server.
4
+
5
+ ## Features
6
+
7
+ * **Thread-safe**: Runs in the background without blocking your main application loop. No `asyncio` required!
8
+ * **Header Authentication**: Secures your API key in HTTP headers (`Authorization: Bearer`), preventing exposure in URL logs.
9
+ * **Payload Compression**: Transparent zlib compression for data exchange over WebSocket, boosting bandwidth efficiency and speed.
10
+ * **Connection Pooling**: Optimized HTTP connection pooling with keep-alive limits for high-concurrency dispatching.
11
+ * **Automated Reconnection**: Built-in exponential backoff means you don't have to manually manage network hiccups.
12
+ * **Smart Streaming**: Automatically switches to chunked streaming for large files, keeping memory overhead low.
13
+ * **Context Manager**: Start and stop the tunnel cleanly using `with Tunnel.from_env() as tunnel:`.
14
+ * **Events API**: Listen to hooks like `on_connect`, `on_disconnect`, and `on_error`.
15
+
16
+ ## Quick Start
17
+
18
+ Create a `.env` file in your root folder:
19
+ ```env
20
+ TUNNEL_API_KEY=your-secret-key
21
+ TUNNEL_TARGET_PATH=/api
22
+ TUNNEL_PORT=5000
23
+ ```
24
+
25
+ Run the tunnel:
26
+ ```python
27
+ from tunnel_sdk import Tunnel
28
+
29
+ with Tunnel.from_env() as tunnel:
30
+ # This blocks until interrupted!
31
+ tunnel.wait()
32
+ ```
33
+
34
+ When connected, you'll see a success message like:
35
+ ```
36
+ ============================================================
37
+ TUNNEL CONNECTED SUCCESSFULLY!
38
+ Public URL : https://your-gateway.com/api
39
+ Forwarding to : http://127.0.0.1:5000
40
+ ============================================================
41
+ ```
42
+
43
+ ## SDK Information
44
+
45
+ The SDK provides an API to access current connection statistics and configuration info:
46
+
47
+ ```python
48
+ from tunnel_sdk import Tunnel
49
+
50
+ with Tunnel.from_env() as tunnel:
51
+ # Print SDK info as a dictionary
52
+ print(tunnel.sd.info())
53
+
54
+ tunnel.wait()
55
+ ```
56
+
57
+ The dictionary returned by `tunnel.sd.info()` contains information like:
58
+ * `sdk_version`: The SDK version.
59
+ * `protocol_version`: The protocol version.
60
+ * `is_connected`: Whether the tunnel is currently connected.
61
+ * `uptime`: The time in seconds the tunnel has been running.
62
+ * `reconnect_count`: The number of times the tunnel has reconnected.
63
+ * `statistics`: A dictionary containing stats like requests handled, bytes uploaded/downloaded, and active requests.
64
+
65
+ ## Structure
66
+
67
+ * `client.py` - The main `Tunnel` interface.
68
+ * `connection.py` - WebSocket loop and reconnect logic.
69
+ * `dispatcher.py` - HTTP request proxying via `httpx`.
70
+ * `protocol.py` - Data structures and base64 encoders.
71
+ * `config.py` - Environment loader.
72
+
73
+ For full API reference, see `docs/sdk_reference.md` in the project root.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mytunnelSDK"
7
+ version = "1.2.0"
8
+ description = "A Python SDK for ..."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+
12
+ authors = [
13
+ { name = "Bhargav Barman", email = "bhargavbprd@gmail.com" }
14
+ ]
15
+
16
+ license = { text = "Custom" }
17
+
18
+ dependencies = [
19
+ "requests>=2.31.0",
20
+ "httpx>=0.24.0",
21
+ "websocket-client>=1.6.0",
22
+ "python-dotenv>=1.0.0",
23
+ ]
24
+
25
+ keywords = [
26
+ "sdk",
27
+ "api",
28
+ ]
29
+
30
+ classifiers = [
31
+ "Programming Language :: Python :: 3",
32
+ "Operating System :: OS Independent",
33
+ ]
34
+
35
+ [tool.setuptools]
36
+ package-dir = {"" = "src"}
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: mytunnelSDK
3
+ Version: 1.2.0
4
+ Summary: A Python SDK for ...
5
+ Author-email: Bhargav Barman <bhargavbprd@gmail.com>
6
+ License: Custom
7
+ Keywords: sdk,api
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: requests>=2.31.0
14
+ Requires-Dist: httpx>=0.24.0
15
+ Requires-Dist: websocket-client>=1.6.0
16
+ Requires-Dist: python-dotenv>=1.0.0
17
+ Dynamic: license-file
18
+
19
+ # Tunnel SDK
20
+
21
+ This is the production-ready Python SDK for the Tunnel Gateway. It allows you to establish secure WebSocket tunnels and seamlessly proxy HTTP traffic to your local development server.
22
+
23
+ ## Features
24
+
25
+ * **Thread-safe**: Runs in the background without blocking your main application loop. No `asyncio` required!
26
+ * **Header Authentication**: Secures your API key in HTTP headers (`Authorization: Bearer`), preventing exposure in URL logs.
27
+ * **Payload Compression**: Transparent zlib compression for data exchange over WebSocket, boosting bandwidth efficiency and speed.
28
+ * **Connection Pooling**: Optimized HTTP connection pooling with keep-alive limits for high-concurrency dispatching.
29
+ * **Automated Reconnection**: Built-in exponential backoff means you don't have to manually manage network hiccups.
30
+ * **Smart Streaming**: Automatically switches to chunked streaming for large files, keeping memory overhead low.
31
+ * **Context Manager**: Start and stop the tunnel cleanly using `with Tunnel.from_env() as tunnel:`.
32
+ * **Events API**: Listen to hooks like `on_connect`, `on_disconnect`, and `on_error`.
33
+
34
+ ## Quick Start
35
+
36
+ Create a `.env` file in your root folder:
37
+ ```env
38
+ TUNNEL_API_KEY=your-secret-key
39
+ TUNNEL_TARGET_PATH=/api
40
+ TUNNEL_PORT=5000
41
+ ```
42
+
43
+ Run the tunnel:
44
+ ```python
45
+ from tunnel_sdk import Tunnel
46
+
47
+ with Tunnel.from_env() as tunnel:
48
+ # This blocks until interrupted!
49
+ tunnel.wait()
50
+ ```
51
+
52
+ When connected, you'll see a success message like:
53
+ ```
54
+ ============================================================
55
+ TUNNEL CONNECTED SUCCESSFULLY!
56
+ Public URL : https://your-gateway.com/api
57
+ Forwarding to : http://127.0.0.1:5000
58
+ ============================================================
59
+ ```
60
+
61
+ ## SDK Information
62
+
63
+ The SDK provides an API to access current connection statistics and configuration info:
64
+
65
+ ```python
66
+ from tunnel_sdk import Tunnel
67
+
68
+ with Tunnel.from_env() as tunnel:
69
+ # Print SDK info as a dictionary
70
+ print(tunnel.sd.info())
71
+
72
+ tunnel.wait()
73
+ ```
74
+
75
+ The dictionary returned by `tunnel.sd.info()` contains information like:
76
+ * `sdk_version`: The SDK version.
77
+ * `protocol_version`: The protocol version.
78
+ * `is_connected`: Whether the tunnel is currently connected.
79
+ * `uptime`: The time in seconds the tunnel has been running.
80
+ * `reconnect_count`: The number of times the tunnel has reconnected.
81
+ * `statistics`: A dictionary containing stats like requests handled, bytes uploaded/downloaded, and active requests.
82
+
83
+ ## Structure
84
+
85
+ * `client.py` - The main `Tunnel` interface.
86
+ * `connection.py` - WebSocket loop and reconnect logic.
87
+ * `dispatcher.py` - HTTP request proxying via `httpx`.
88
+ * `protocol.py` - Data structures and base64 encoders.
89
+ * `config.py` - Environment loader.
90
+
91
+ For full API reference, see `docs/sdk_reference.md` in the project root.
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/mytunnelSDK.egg-info/PKG-INFO
5
+ src/mytunnelSDK.egg-info/SOURCES.txt
6
+ src/mytunnelSDK.egg-info/dependency_links.txt
7
+ src/mytunnelSDK.egg-info/requires.txt
8
+ src/mytunnelSDK.egg-info/top_level.txt
9
+ src/tunnel_sdk/__init__.py
10
+ src/tunnel_sdk/client.py
11
+ src/tunnel_sdk/config.py
12
+ src/tunnel_sdk/connection.py
13
+ src/tunnel_sdk/dispatcher.py
14
+ src/tunnel_sdk/events.py
15
+ src/tunnel_sdk/exceptions.py
16
+ src/tunnel_sdk/protocol.py
17
+ src/tunnel_sdk/stats.py
@@ -0,0 +1,4 @@
1
+ requests>=2.31.0
2
+ httpx>=0.24.0
3
+ websocket-client>=1.6.0
4
+ python-dotenv>=1.0.0
@@ -0,0 +1 @@
1
+ tunnel_sdk
@@ -0,0 +1,23 @@
1
+ """
2
+ Tunnel Gateway Python SDK
3
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ A production-quality Python SDK for the Tunnel Gateway.
6
+ """
7
+
8
+ from mysdk.src.tunnel_sdk.client import Tunnel
9
+ from mysdk.src.tunnel_sdk.config import TunnelConfig
10
+ from mysdk.src.tunnel_sdk.exceptions import (
11
+ TunnelError, AuthenticationError, ConnectionError,
12
+ ProtocolError, TimeoutError
13
+ )
14
+
15
+ __all__ = [
16
+ "Tunnel",
17
+ "TunnelConfig",
18
+ "TunnelError",
19
+ "AuthenticationError",
20
+ "ConnectionError",
21
+ "ProtocolError",
22
+ "TimeoutError"
23
+ ]
@@ -0,0 +1,201 @@
1
+ """
2
+ The main Tunnel client class for the SDK.
3
+ """
4
+ import threading
5
+ import time
6
+ import logging
7
+ import os
8
+ from typing import Optional, Callable
9
+
10
+ from mysdk.src.tunnel_sdk.config import TunnelConfig
11
+ from mysdk.src.tunnel_sdk.stats import TunnelStats
12
+ from mysdk.src.tunnel_sdk.events import EventEmitter
13
+ from mysdk.src.tunnel_sdk.connection import TunnelConnection
14
+ from mysdk.src.tunnel_sdk.dispatcher import RequestDispatcher
15
+ from mysdk.src.tunnel_sdk.protocol import PROTOCOL_VERSION
16
+
17
+ __version__ = "1.2.0"
18
+
19
+ logger = logging.getLogger("tunnel_sdk")
20
+
21
+ class SDKInfo:
22
+ def __init__(self, tunnel: 'Tunnel'):
23
+ self._tunnel = tunnel
24
+
25
+ def info(self) -> dict:
26
+ return {
27
+ "sdk_version": self._tunnel.sdk_version,
28
+ "protocol_version": self._tunnel.protocol_version,
29
+ "gateway_version": self._tunnel.gateway_version,
30
+ "is_connected": self._tunnel.is_connected,
31
+ "uptime": self._tunnel.uptime,
32
+ "reconnect_count": self._tunnel.reconnect_count,
33
+ "statistics": self._tunnel.statistics,
34
+ }
35
+
36
+ class Tunnel:
37
+ def __init__(self, api_key: str = None, target_path: str = None, gateway: str = None, port: int = None, local_url: str = None, **kwargs):
38
+ # Support default from env if not provided
39
+ if gateway is None: gateway = os.environ.get("TUNNEL_GATEWAY", "wss://tunnel-g09n.onrender.com")
40
+ if api_key is None: api_key = os.environ.get("TUNNEL_API_KEY")
41
+ if target_path is None: target_path = os.environ.get("TUNNEL_TARGET_PATH")
42
+
43
+ if port is None: port = int(os.environ.get("TUNNEL_PORT", 5000))
44
+ if local_url is None: local_url = os.environ.get("TUNNEL_LOCAL_URL")
45
+
46
+ if not local_url:
47
+ local_url = f"http://127.0.0.1:{port}"
48
+
49
+ if not api_key or not target_path:
50
+ raise ValueError("api_key and target_path must be provided or set in environment variables")
51
+
52
+ self.config = TunnelConfig(
53
+ api_key=api_key,
54
+ target_path=target_path,
55
+ gateway=gateway,
56
+ local_url=local_url,
57
+ port=port,
58
+ **kwargs
59
+ )
60
+ self.stats = TunnelStats()
61
+ self.events = EventEmitter()
62
+
63
+ self._connection = TunnelConnection(self.config, self.stats, self.events)
64
+ self._dispatcher: Optional[RequestDispatcher] = None
65
+ self._main_thread: Optional[threading.Thread] = None
66
+
67
+ # To handle graceful shutdown
68
+ self._is_running = False
69
+
70
+ # SDK Info API
71
+ self.sd = SDKInfo(self)
72
+
73
+ @classmethod
74
+ def from_env(cls, **kwargs) -> "Tunnel":
75
+ config = TunnelConfig.from_env()
76
+ return cls(
77
+ api_key=config.api_key,
78
+ target_path=config.target_path,
79
+ gateway=config.gateway,
80
+ local_url=config.local_url,
81
+ port=config.port,
82
+ **kwargs
83
+ )
84
+
85
+ def on(self, event: str, callback: Callable) -> None:
86
+ """Register an event callback."""
87
+ self.events.on(event, callback)
88
+
89
+ def off(self, event: str, callback: Callable) -> None:
90
+ """Unregister an event callback."""
91
+ self.events.off(event, callback)
92
+
93
+ def start(self, port: Optional[int] = None, local_url: Optional[str] = None) -> None:
94
+ """Starts the tunnel in a background thread."""
95
+ if self._is_running:
96
+ return
97
+
98
+ url_to_use = local_url or self.config.local_url
99
+ if port is not None:
100
+ url_to_use = f"http://127.0.0.1:{port}"
101
+
102
+ if not url_to_use:
103
+ raise ValueError("local_url or port must be provided either in Tunnel() or start()")
104
+
105
+ self._is_running = True
106
+ self._dispatcher = RequestDispatcher(
107
+ local_url=url_to_use,
108
+ ws_send_func=self._connection.send,
109
+ stats=self.stats,
110
+ events=self.events
111
+ )
112
+ self._connection.dispatcher = self._dispatcher
113
+
114
+ self._main_thread = threading.Thread(target=self._connection.start, daemon=True)
115
+ self._main_thread.start()
116
+ logger.info("Tunnel background thread started.")
117
+
118
+ def run(self, port: Optional[int] = None, local_url: Optional[str] = None) -> None:
119
+ """Starts the tunnel and blocks the current thread until stopped."""
120
+ self.start(port=port, local_url=local_url)
121
+ self.wait()
122
+
123
+ def stop(self) -> None:
124
+ """Stops the tunnel and performs graceful shutdown."""
125
+ if not self._is_running:
126
+ return
127
+
128
+ self._is_running = False
129
+ logger.info("Stopping tunnel...")
130
+
131
+ self._connection.stop()
132
+ if self._dispatcher:
133
+ self._dispatcher.shutdown()
134
+
135
+ if self._main_thread and self._main_thread.is_alive():
136
+ self._main_thread.join(timeout=5.0)
137
+
138
+ def restart(self, port: Optional[int] = None, local_url: Optional[str] = None) -> None:
139
+ """Restarts the tunnel connection."""
140
+ self.stop()
141
+ # Wait a moment for resources to clean up
142
+ time.sleep(1)
143
+ self.start(port=port, local_url=local_url)
144
+
145
+ def wait(self) -> None:
146
+ """Blocks until the tunnel is stopped."""
147
+ try:
148
+ while self._is_running and self._main_thread and self._main_thread.is_alive():
149
+ time.sleep(1.0)
150
+ except KeyboardInterrupt:
151
+ logger.info("Keyboard interrupt received.")
152
+ self.stop()
153
+
154
+ def __enter__(self):
155
+ self.start()
156
+ return self
157
+
158
+ def __exit__(self, exc_type, exc_val, exc_tb):
159
+ self.stop()
160
+
161
+ # Properties
162
+ @property
163
+ def is_connected(self) -> bool:
164
+ return self._connection.connected
165
+
166
+ @property
167
+ def gateway_version(self) -> Optional[str]:
168
+ # Would be fetched via admin info or headers, but not required right now
169
+ return None
170
+
171
+ @property
172
+ def protocol_version(self) -> str:
173
+ return PROTOCOL_VERSION
174
+
175
+ @property
176
+ def sdk_version(self) -> str:
177
+ return __version__
178
+
179
+ @property
180
+ def last_error(self) -> Optional[Exception]:
181
+ return None # Could store the last error caught in connection.py
182
+
183
+ @property
184
+ def reconnect_count(self) -> int:
185
+ return self.stats.reconnect_count
186
+
187
+ @property
188
+ def uptime(self) -> float:
189
+ return self.stats.uptime
190
+
191
+ @property
192
+ def statistics(self) -> dict:
193
+ return {
194
+ "requests_handled": self.stats.requests_handled,
195
+ "active_requests": self.stats.active_requests,
196
+ "bytes_uploaded": self.stats.bytes_uploaded,
197
+ "bytes_downloaded": self.stats.bytes_downloaded,
198
+ "reconnect_count": self.stats.reconnect_count,
199
+ "uptime": self.stats.uptime,
200
+ "last_heartbeat_latency": self.stats.last_heartbeat_latency,
201
+ }
@@ -0,0 +1,51 @@
1
+ """
2
+ Configuration models for the Tunnel SDK.
3
+ """
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+ from dotenv import load_dotenv
8
+
9
+ @dataclass
10
+ class TunnelConfig:
11
+ api_key: str
12
+ target_path: str
13
+ gateway: str = "wss://tunnel-g09n.onrender.com"
14
+ local_url: Optional[str] = None
15
+ port: int = 5000
16
+
17
+ # Optional parameters
18
+ max_reconnects: int = -1 # -1 for infinite
19
+ reconnect_base_delay: float = 1.0
20
+ reconnect_max_delay: float = 60.0
21
+ ping_timeout: float = 45.0
22
+ proxy_timeout: float = 30.0
23
+
24
+ @classmethod
25
+ def from_env(cls) -> "TunnelConfig":
26
+ """Loads configuration from environment variables (including .env file)."""
27
+ load_dotenv() # Load variables from .env if present
28
+
29
+ gateway = os.environ.get("TUNNEL_GATEWAY", "wss://tunnel-g09n.onrender.com")
30
+ api_key = os.environ.get("TUNNEL_API_KEY")
31
+ target_path = os.environ.get("TUNNEL_TARGET_PATH")
32
+
33
+ port_str = os.environ.get("TUNNEL_PORT", "5000")
34
+ port = int(port_str)
35
+
36
+ local_url = os.environ.get("TUNNEL_LOCAL_URL")
37
+ if not local_url:
38
+ local_url = f"http://127.0.0.1:{port}"
39
+
40
+ if not api_key:
41
+ raise ValueError("TUNNEL_API_KEY environment variable is missing")
42
+ if not target_path:
43
+ raise ValueError("TUNNEL_TARGET_PATH environment variable is missing")
44
+
45
+ return cls(
46
+ api_key=api_key,
47
+ target_path=target_path,
48
+ gateway=gateway,
49
+ local_url=local_url,
50
+ port=port
51
+ )
@@ -0,0 +1,201 @@
1
+ """
2
+ WebSocket connection manager with reconnection logic.
3
+ """
4
+ import json
5
+ import threading
6
+ import time
7
+ import urllib.parse
8
+ import websocket
9
+ import logging
10
+ from typing import Optional
11
+
12
+ from mysdk.src.tunnel_sdk.config import TunnelConfig
13
+ from mysdk.src.tunnel_sdk.events import EventEmitter
14
+ from mysdk.src.tunnel_sdk.stats import TunnelStats
15
+ from mysdk.src.tunnel_sdk.protocol import PROTOCOL_VERSION, build_pong, parse_message
16
+
17
+ class Colors:
18
+ GREEN = '\033[92m'
19
+ RED = '\033[91m'
20
+ YELLOW = '\033[93m'
21
+ CYAN = '\033[96m'
22
+ RESET = '\033[0m'
23
+
24
+ logger = logging.getLogger("tunnel_sdk.connection")
25
+
26
+ class TunnelConnection:
27
+ def __init__(self, config: TunnelConfig, stats: TunnelStats, events: EventEmitter):
28
+ self.config = config
29
+ self.stats = stats
30
+ self.events = events
31
+ self.ws_app: Optional[websocket.WebSocketApp] = None
32
+ self.ws_thread: Optional[threading.Thread] = None
33
+
34
+ self.dispatcher = None # Injected by client
35
+
36
+ self._stop_event = threading.Event()
37
+ self._reconnect_lock = threading.Lock()
38
+
39
+ self.connected = False
40
+ self.last_ping_time = 0.0
41
+
42
+ def get_url(self) -> str:
43
+ params = {
44
+ "target_path": self.config.target_path,
45
+ "protocol_version": PROTOCOL_VERSION
46
+ }
47
+ query = urllib.parse.urlencode(params)
48
+ base = self.config.gateway
49
+ if not base.endswith("/"):
50
+ base += "/"
51
+ return f"{base}ws/tunnel?{query}"
52
+
53
+ def start(self):
54
+ self._stop_event.clear()
55
+ self._connect_loop()
56
+
57
+ def _connect_loop(self):
58
+ attempt = 0
59
+ while not self._stop_event.is_set():
60
+ url = self.get_url()
61
+
62
+ if attempt == 0:
63
+ self._wake_gateway()
64
+ if self._stop_event.is_set():
65
+ break
66
+
67
+ logger.info(f"Connecting to {self.config.gateway} as {self.config.target_path}")
68
+
69
+ if attempt > 0:
70
+ self.events.emit("on_reconnect_attempt", attempt)
71
+ self.stats.inc_reconnect()
72
+
73
+ headers = [
74
+ f"Authorization: Bearer {self.config.api_key}",
75
+ f"X-API-Key: {self.config.api_key}"
76
+ ]
77
+ self.ws_app = websocket.WebSocketApp(
78
+ url,
79
+ header=headers,
80
+ on_open=self._on_open,
81
+ on_message=self._on_message,
82
+ on_error=self._on_error,
83
+ on_close=self._on_close
84
+ )
85
+
86
+ # This blocks until disconnected
87
+ self.ws_app.run_forever()
88
+
89
+ if self._stop_event.is_set():
90
+ break
91
+
92
+ self.connected = False
93
+
94
+ if self.config.max_reconnects != -1 and attempt >= self.config.max_reconnects:
95
+ self.events.emit("on_reconnect_failed")
96
+ logger.error("Max reconnects reached. Stopping.")
97
+ break
98
+
99
+ # Backoff
100
+ delay = min(self.config.reconnect_base_delay * (2 ** attempt), self.config.reconnect_max_delay)
101
+ logger.info(f"Reconnecting in {delay} seconds...")
102
+ attempt += 1
103
+ time.sleep(delay)
104
+
105
+ def _wake_gateway(self):
106
+ """Sends a proactive HTTP request to wake up the gateway (e.g., if hosted on Render free tier)."""
107
+ base_url = self.config.gateway.replace("wss://", "https://").replace("ws://", "http://").rstrip("/")
108
+ if "localhost" in base_url or "127.0.0.1" in base_url or base_url == "https://test":
109
+ return
110
+ wake_url = f"{base_url}/wake"
111
+
112
+ print("Checking if Gateway is awake (this may take 30-50s if Render is sleeping)...")
113
+ try:
114
+ import httpx
115
+ with httpx.Client(timeout=10.0) as client:
116
+ resp = client.get(wake_url)
117
+ if resp.status_code == 200:
118
+ print("Gateway is awake!")
119
+ except Exception as e:
120
+ logger.warning(f"Wake probe failed (will try websocket anyway): {e}")
121
+
122
+ def stop(self):
123
+ self._stop_event.set()
124
+ if self.ws_app:
125
+ self.ws_app.close()
126
+
127
+ def send(self, data: str):
128
+ if self.ws_app and self.connected:
129
+ self.ws_app.send(data)
130
+
131
+ def _on_open(self, ws):
132
+ self.connected = True
133
+ self.events.emit("on_connect")
134
+
135
+ # Determine the public HTTP URL for user convenience
136
+ base_url = self.config.gateway.replace("wss://", "https://").replace("ws://", "http://").rstrip("/")
137
+ public_url = f"{base_url}{self.config.target_path}"
138
+ local_url = self.config.local_url or "your local server"
139
+
140
+ def print_success():
141
+ time.sleep(0.5)
142
+ if self.connected:
143
+ print(f"\n{Colors.GREEN}" + "="*60)
144
+ print("🚀 TUNNEL CONNECTED SUCCESSFULLY!")
145
+ print(f"🌍 Public URL : {Colors.CYAN}{public_url}{Colors.GREEN}")
146
+ print(f"🏠 Forwarding to : {Colors.CYAN}{local_url}{Colors.GREEN}")
147
+ print("="*60 + f"{Colors.RESET}\n")
148
+ logger.info(f"Tunnel connected successfully. Proxying {public_url} -> {local_url}")
149
+
150
+ threading.Thread(target=print_success, daemon=True).start()
151
+
152
+ def _on_message(self, ws, message):
153
+ try:
154
+ msg = parse_message(message)
155
+ except Exception as e:
156
+ logger.error(f"Error parsing message: {e}")
157
+ return
158
+
159
+ mtype = msg.get("type")
160
+
161
+ # Check for immediate connection error frame
162
+ if "error" in msg:
163
+ error_msg = msg['error']
164
+ logger.error(f"Gateway Error: {error_msg}")
165
+ print(f"\n{Colors.RED}❌ TUNNEL ERROR: {error_msg}{Colors.RESET}\n")
166
+ self.events.emit("on_error", Exception(error_msg))
167
+ return
168
+
169
+ if mtype == "ping":
170
+ if self.last_ping_time > 0:
171
+ latency = time.time() - self.last_ping_time
172
+ self.stats.set_latency(latency)
173
+ self.last_ping_time = time.time()
174
+ self.send(build_pong())
175
+
176
+ elif mtype == "req_single":
177
+ if self.dispatcher:
178
+ self.dispatcher.dispatch_single(msg)
179
+
180
+ elif mtype == "req_start":
181
+ if self.dispatcher:
182
+ self.dispatcher.dispatch_start(msg)
183
+
184
+ elif mtype == "req_chunk":
185
+ if self.dispatcher:
186
+ self.dispatcher.dispatch_chunk(msg)
187
+
188
+ elif mtype == "req_end":
189
+ if self.dispatcher:
190
+ self.dispatcher.dispatch_end(msg)
191
+ else:
192
+ logger.warning(f"Unknown message type: {mtype}")
193
+
194
+ def _on_error(self, ws, error):
195
+ logger.error(f"WebSocket Error: {error}")
196
+ self.events.emit("on_error", error)
197
+
198
+ def _on_close(self, ws, close_status_code, close_msg):
199
+ self.connected = False
200
+ self.events.emit("on_disconnect")
201
+ logger.info(f"Tunnel disconnected: {close_status_code} - {close_msg}")
@@ -0,0 +1,188 @@
1
+ """
2
+ Dispatches incoming WebSocket messages to the local HTTP server.
3
+ """
4
+ import threading
5
+ import queue
6
+ import time
7
+ import httpx
8
+ import logging
9
+ import urllib.parse
10
+ from typing import Dict, Any, Callable
11
+
12
+ from mysdk.src.tunnel_sdk.protocol import (
13
+ decode_base64, decode_payload, build_res_single, build_res_start,
14
+ build_res_chunk, build_res_end
15
+ )
16
+
17
+ logger = logging.getLogger("tunnel_sdk.dispatcher")
18
+
19
+ class RequestDispatcher:
20
+ def __init__(self, local_url: str, ws_send_func: Callable[[str], None], stats, events):
21
+ self.local_url = local_url.rstrip("/")
22
+ self.ws_send_func = ws_send_func
23
+ self.stats = stats
24
+ self.events = events
25
+ limits = httpx.Limits(max_keepalive_connections=20, max_connections=100)
26
+ self.http_client = httpx.Client(timeout=None, limits=limits)
27
+ self.executor = threading.concurrent.futures.ThreadPoolExecutor(max_workers=50) if hasattr(threading, 'concurrent') else None
28
+
29
+ # In Python standard library, ThreadPoolExecutor is in concurrent.futures
30
+ import concurrent.futures
31
+ self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=50)
32
+
33
+ # req_id -> Queue for streaming chunks
34
+ self.streaming_queues: Dict[str, queue.Queue] = {}
35
+ # req_id -> StopEvent to signal end of stream
36
+ self.streaming_events: Dict[str, threading.Event] = {}
37
+
38
+ def shutdown(self):
39
+ self.executor.shutdown(wait=False)
40
+ self.http_client.close()
41
+
42
+ def dispatch_single(self, msg: Dict[str, Any]):
43
+ self.executor.submit(self._handle_single, msg)
44
+
45
+ def dispatch_start(self, msg: Dict[str, Any]):
46
+ req_id = msg["req_id"]
47
+ q = queue.Queue(maxsize=100)
48
+ ev = threading.Event()
49
+ self.streaming_queues[req_id] = q
50
+ self.streaming_events[req_id] = ev
51
+ self.executor.submit(self._handle_stream, msg, q, ev)
52
+
53
+ def dispatch_chunk(self, msg: Dict[str, Any]):
54
+ req_id = msg["req_id"]
55
+ if req_id in self.streaming_queues:
56
+ self.streaming_queues[req_id].put(
57
+ decode_payload(msg["data"], compressed=msg.get("compressed", False))
58
+ )
59
+
60
+ def dispatch_end(self, msg: Dict[str, Any]):
61
+ req_id = msg["req_id"]
62
+ if req_id in self.streaming_events:
63
+ self.streaming_events[req_id].set()
64
+
65
+ def _build_url(self, subpath: str, query: str) -> str:
66
+ url = self.local_url + subpath
67
+ if query:
68
+ url += "?" + query
69
+ return url
70
+
71
+ def _filter_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
72
+ # Filter out headers that shouldn't be forwarded to local
73
+ filtered = {}
74
+ for k, v in headers.items():
75
+ k_lower = k.lower()
76
+ if k_lower not in ("host", "connection", "upgrade"):
77
+ filtered[k] = v
78
+ return filtered
79
+
80
+ def _handle_single(self, msg: Dict[str, Any]):
81
+ req_id = msg["req_id"]
82
+ try:
83
+ self.stats.inc_active_requests()
84
+ self.events.emit("on_request_start", req_id)
85
+
86
+ url = self._build_url(msg["subpath"], msg["query"])
87
+ headers = self._filter_headers(msg.get("headers", {}))
88
+ body_bytes = decode_payload(msg.get("body", ""), compressed=msg.get("compressed", False)) if msg.get("body") else None
89
+
90
+ if body_bytes:
91
+ self.stats.add_bytes_downloaded(len(body_bytes))
92
+
93
+ for attempt in range(6):
94
+ try:
95
+ resp = self.http_client.request(
96
+ method=msg["method"],
97
+ url=url,
98
+ headers=headers,
99
+ content=body_bytes,
100
+ follow_redirects=False
101
+ )
102
+ break
103
+ except (httpx.HTTPError, OSError) as e:
104
+ if attempt == 5:
105
+ raise
106
+ time.sleep(0.5)
107
+
108
+ resp_body = resp.content
109
+ self.stats.add_bytes_uploaded(len(resp_body))
110
+
111
+ res_msg = build_res_single(
112
+ req_id=req_id,
113
+ status=resp.status_code,
114
+ headers=dict(resp.headers),
115
+ body=resp_body
116
+ )
117
+ self.ws_send_func(res_msg)
118
+
119
+ except Exception as e:
120
+ logger.error(f"Error handling single request {req_id}: {e}")
121
+ res_msg = build_res_single(req_id, 502, {}, str(e).encode('utf-8'))
122
+ self.ws_send_func(res_msg)
123
+ finally:
124
+ self.stats.dec_active_requests()
125
+ self.events.emit("on_request_end", req_id)
126
+
127
+ def _handle_stream(self, msg: Dict[str, Any], q: queue.Queue, ev: threading.Event):
128
+ req_id = msg["req_id"]
129
+ try:
130
+ self.stats.inc_active_requests()
131
+ self.events.emit("on_request_start", req_id)
132
+
133
+ url = self._build_url(msg["subpath"], msg["query"])
134
+ headers = self._filter_headers(msg.get("headers", {}))
135
+
136
+ def chunk_generator():
137
+ while True:
138
+ try:
139
+ chunk = q.get(timeout=0.1)
140
+ self.stats.add_bytes_downloaded(len(chunk))
141
+ yield chunk
142
+ except queue.Empty:
143
+ if ev.is_set() and q.empty():
144
+ break
145
+ continue
146
+
147
+ req = self.http_client.build_request(
148
+ method=msg["method"],
149
+ url=url,
150
+ headers=headers,
151
+ content=chunk_generator()
152
+ )
153
+
154
+ for attempt in range(6):
155
+ try:
156
+ resp = self.http_client.send(req, stream=True, follow_redirects=False)
157
+ break
158
+ except (httpx.HTTPError, OSError) as e:
159
+ if attempt == 5:
160
+ raise
161
+ time.sleep(0.5)
162
+
163
+ # Send res_start
164
+ start_msg = build_res_start(
165
+ req_id=req_id,
166
+ status=resp.status_code,
167
+ headers=dict(resp.headers)
168
+ )
169
+ self.ws_send_func(start_msg)
170
+
171
+ # Send chunks
172
+ for chunk in resp.iter_bytes(chunk_size=32768):
173
+ if chunk:
174
+ self.stats.add_bytes_uploaded(len(chunk))
175
+ self.ws_send_func(build_res_chunk(req_id, chunk))
176
+
177
+ # Send res_end
178
+ self.ws_send_func(build_res_end(req_id))
179
+ resp.close()
180
+
181
+ except Exception as e:
182
+ logger.error(f"Error handling streaming request {req_id}: {e}")
183
+ self.ws_send_func(build_res_single(req_id, 502, {}, str(e).encode('utf-8')))
184
+ finally:
185
+ self.stats.dec_active_requests()
186
+ self.events.emit("on_request_end", req_id)
187
+ self.streaming_queues.pop(req_id, None)
188
+ self.streaming_events.pop(req_id, None)
@@ -0,0 +1,31 @@
1
+ """
2
+ Event emitter for SDK callbacks.
3
+ """
4
+ from typing import Callable, Dict, List, Any
5
+
6
+ class EventEmitter:
7
+ def __init__(self):
8
+ self._listeners: Dict[str, List[Callable]] = {}
9
+
10
+ def on(self, event: str, callback: Callable) -> None:
11
+ """Register a callback for an event."""
12
+ if event not in self._listeners:
13
+ self._listeners[event] = []
14
+ self._listeners[event].append(callback)
15
+
16
+ def off(self, event: str, callback: Callable) -> None:
17
+ """Unregister a callback for an event."""
18
+ if event in self._listeners:
19
+ try:
20
+ self._listeners[event].remove(callback)
21
+ except ValueError:
22
+ pass
23
+
24
+ def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
25
+ """Emit an event, calling all registered callbacks synchronously."""
26
+ for callback in self._listeners.get(event, []):
27
+ try:
28
+ callback(*args, **kwargs)
29
+ except Exception:
30
+ # User callbacks shouldn't crash the SDK loop
31
+ pass
@@ -0,0 +1,23 @@
1
+ """
2
+ Custom exceptions for the Tunnel SDK.
3
+ """
4
+
5
+ class TunnelError(Exception):
6
+ """Base exception for all Tunnel SDK errors."""
7
+ pass
8
+
9
+ class AuthenticationError(TunnelError):
10
+ """Raised when the API key is invalid."""
11
+ pass
12
+
13
+ class ConnectionError(TunnelError):
14
+ """Raised when the WebSocket connection fails or drops unexpectedly."""
15
+ pass
16
+
17
+ class ProtocolError(TunnelError):
18
+ """Raised when the gateway sends malformed or incompatible protocol messages."""
19
+ pass
20
+
21
+ class TimeoutError(TunnelError):
22
+ """Raised when heartbeat or proxy requests time out."""
23
+ pass
@@ -0,0 +1,98 @@
1
+ """
2
+ Protocol utilities for encoding, decoding, and parsing Tunnel Gateway messages.
3
+ """
4
+ import base64
5
+ import json
6
+ import zlib
7
+ from typing import Dict, Any, Optional, Tuple
8
+
9
+ from mysdk.src.tunnel_sdk.exceptions import ProtocolError
10
+
11
+ PROTOCOL_VERSION = 1.2
12
+
13
+ def decode_base64(data: str) -> bytes:
14
+ """Decodes a base64 string to bytes."""
15
+ try:
16
+ return base64.b64decode(data)
17
+ except Exception as e:
18
+ raise ProtocolError(f"Failed to decode base64 data: {e}")
19
+
20
+ def encode_base64(data: bytes) -> str:
21
+ """Encodes bytes to a base64 string."""
22
+ try:
23
+ return base64.b64encode(data).decode('ascii')
24
+ except Exception as e:
25
+ raise ProtocolError(f"Failed to encode base64 data: {e}")
26
+
27
+ def encode_payload(data: bytes, compress: bool = True) -> Tuple[str, bool]:
28
+ """Encode raw bytes to a base64 string, optionally compressing with zlib if beneficial."""
29
+ if not data:
30
+ return "", False
31
+ if compress and len(data) >= 64:
32
+ try:
33
+ compressed = zlib.compress(data)
34
+ if len(compressed) < len(data):
35
+ return encode_base64(compressed), True
36
+ except Exception:
37
+ pass
38
+ return encode_base64(data), False
39
+
40
+ def decode_payload(data: str, compressed: bool = False) -> bytes:
41
+ """Decode a base64 string back to raw bytes, decompressing if compressed."""
42
+ if not data:
43
+ return b""
44
+ raw = decode_base64(data)
45
+ if compressed:
46
+ try:
47
+ return zlib.decompress(raw)
48
+ except Exception as e:
49
+ raise ProtocolError(f"Failed to decompress payload: {e}") from e
50
+ return raw
51
+
52
+ def parse_message(raw_msg: str) -> Dict[str, Any]:
53
+ """Parses a raw WebSocket message string into a JSON dictionary."""
54
+ try:
55
+ return json.loads(raw_msg)
56
+ except json.JSONDecodeError as e:
57
+ raise ProtocolError(f"Invalid JSON in message: {e}")
58
+
59
+ def build_pong() -> str:
60
+ return json.dumps({"type": "pong"})
61
+
62
+ def build_res_single(req_id: str, status: int, headers: Dict[str, str], body: bytes) -> str:
63
+ encoded_body, compressed = encode_payload(body, compress=True)
64
+ payload = {
65
+ "type": "res_single",
66
+ "req_id": req_id,
67
+ "status": status,
68
+ "headers": headers,
69
+ "body": encoded_body
70
+ }
71
+ if compressed:
72
+ payload["compressed"] = True
73
+ return json.dumps(payload)
74
+
75
+ def build_res_start(req_id: str, status: int, headers: Dict[str, str]) -> str:
76
+ return json.dumps({
77
+ "type": "res_start",
78
+ "req_id": req_id,
79
+ "status": status,
80
+ "headers": headers
81
+ })
82
+
83
+ def build_res_chunk(req_id: str, chunk: bytes) -> str:
84
+ encoded_data, compressed = encode_payload(chunk, compress=True)
85
+ payload = {
86
+ "type": "res_chunk",
87
+ "req_id": req_id,
88
+ "data": encoded_data
89
+ }
90
+ if compressed:
91
+ payload["compressed"] = True
92
+ return json.dumps(payload)
93
+
94
+ def build_res_end(req_id: str) -> str:
95
+ return json.dumps({
96
+ "type": "res_end",
97
+ "req_id": req_id
98
+ })
@@ -0,0 +1,45 @@
1
+ """
2
+ Thread-safe statistics tracking.
3
+ """
4
+ import threading
5
+ import time
6
+
7
+ class TunnelStats:
8
+ def __init__(self):
9
+ self._lock = threading.Lock()
10
+ self.requests_handled = 0
11
+ self.active_requests = 0
12
+ self.bytes_uploaded = 0
13
+ self.bytes_downloaded = 0
14
+ self.reconnect_count = 0
15
+ self.start_time = time.time()
16
+ self.last_heartbeat_latency = 0.0
17
+
18
+ @property
19
+ def uptime(self) -> float:
20
+ return time.time() - self.start_time
21
+
22
+ def inc_active_requests(self):
23
+ with self._lock:
24
+ self.active_requests += 1
25
+
26
+ def dec_active_requests(self):
27
+ with self._lock:
28
+ self.active_requests = max(0, self.active_requests - 1)
29
+ self.requests_handled += 1
30
+
31
+ def add_bytes_uploaded(self, count: int):
32
+ with self._lock:
33
+ self.bytes_uploaded += count
34
+
35
+ def add_bytes_downloaded(self, count: int):
36
+ with self._lock:
37
+ self.bytes_downloaded += count
38
+
39
+ def inc_reconnect(self):
40
+ with self._lock:
41
+ self.reconnect_count += 1
42
+
43
+ def set_latency(self, latency: float):
44
+ with self._lock:
45
+ self.last_heartbeat_latency = latency