devlinker 1.2.4__tar.gz → 1.2.8__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.
@@ -1,14 +1,18 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlinker
3
- Version: 1.2.4
3
+ Version: 1.2.8
4
4
  Summary: AI-powered linking and automation tool
5
5
  Author-email: Mani <mani1028@users.noreply.github.com>
6
6
  Requires-Python: >=3.7
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: click
9
- Requires-Dist: flask
9
+ Requires-Dist: docker
10
+ Requires-Dist: fastapi
11
+ Requires-Dist: httpx
10
12
  Requires-Dist: pyngrok
11
13
  Requires-Dist: requests
14
+ Requires-Dist: uvicorn
15
+ Requires-Dist: websockets
12
16
 
13
17
  # Dev Linker
14
18
 
@@ -23,7 +27,7 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
23
27
  - Detects Vite frontend across dynamic fallback ports (5173-5190, plus common alternatives)
24
28
  - Supports Docker backend port auto-detection
25
29
  - Works with dynamic container host ports
26
- - No config needed for standard Flask/Docker flows
30
+ - No config needed for standard FastAPI or Flask plus Docker flows
27
31
  - Serves both through one proxy at http://localhost:8000
28
32
  - Creates a public tunnel for sharing (Cloudflare first, ngrok fallback)
29
33
  - Terminal-first workflow
@@ -70,7 +74,7 @@ Typical startup output:
70
74
  ```text
71
75
  Dev Linker v1.2.2
72
76
 
73
- [INFO] Mode: Auto (Flask + Docker detection)
77
+ [INFO] Mode: Auto (FastAPI async proxy + Docker detection)
74
78
  [INFO] Booting local services...
75
79
  [INFO] Detecting frontend/backend ports...
76
80
  [OK] Frontend -> 5173
@@ -88,8 +92,10 @@ Tip: Press Ctrl+Click to open link
88
92
  DevLinker Ready (in 2.4s)
89
93
  Frontend: http://localhost:5173
90
94
  Backend: http://localhost:5000
91
- Proxy: http://localhost:8000
92
- PUBLIC URL: https://xxxx.trycloudflare.com
95
+ Access Links:
96
+ Local: http://localhost:8000
97
+ WLAN: http://192.168.1.5:8000
98
+ Public: https://xxxx.trycloudflare.com
93
99
  Tip: Press Ctrl+Click to open link
94
100
  ```
95
101
 
@@ -123,6 +129,12 @@ Run local-only mode without tunnel:
123
129
  devlinker --no-tunnel
124
130
  ```
125
131
 
132
+ Disable WLAN URL output:
133
+
134
+ ```bash
135
+ devlinker --no-lan
136
+ ```
137
+
126
138
  Interactive backend selection (when local and Docker are both detected):
127
139
 
128
140
  ```bash
@@ -157,6 +169,7 @@ Frontend detection behavior:
157
169
  - Scans Vite defaults and fallback ports (`5173` through `5190`)
158
170
  - Also checks common alternatives (`3000`, `4173`, `8080`)
159
171
  - Retries during startup to catch slow boot cases
172
+ - Performs readiness gating before proxy startup (waits until frontend looks like Vite and backend responds)
160
173
 
161
174
  ## Important Frontend Rule
162
175
 
@@ -173,10 +186,13 @@ Do not hardcode backend host URLs in frontend code.
173
186
  Backend port detection runs in this order:
174
187
 
175
188
  1. Check localhost port 5000
176
- 2. If not found, parse all Docker host-to-container port mappings
177
- 3. Rank containers by likely backend identity (name hints like backend/api plus project-name hints)
178
- 4. Use the best mapped host port automatically, even when internal port is not 5000
179
- 5. If nothing is found, print next-step guidance and exit
189
+ 2. If not found, query Docker via Docker SDK (`docker.from_env()`) for published host-to-container port mappings
190
+ 3. Prioritize containers using labels when present (`devlinker.role=backend`, optional `devlinker.port=<container-port>`)
191
+ 4. Otherwise rank containers by likely backend identity (name hints like backend/api plus project-name hints)
192
+ 5. Use the best mapped host port automatically, even when internal port is not 5000
193
+ 6. If nothing is found, print next-step guidance and exit
194
+
195
+ If Docker SDK is unavailable, Dev Linker falls back to Docker CLI parsing as a compatibility path.
180
196
 
181
197
  When both Local and Docker backends are available, Dev Linker prompts you to choose one (TTY mode) unless `--no-interactive-backend` is used.
182
198
 
@@ -218,25 +234,33 @@ For containerized Flask backends, ensure:
218
234
 
219
235
  ## Notes
220
236
 
221
- - runner.py expects frontend project in frontend and Flask app in backend/app.py.
237
+ - runner.py expects frontend project in frontend and Python app in backend/app.py.
222
238
  - If those paths do not exist, Dev Linker skips launch and only tries to detect already-running services.
223
239
  - Tunnel selection order is: cloudflared (TryCloudflare), then ngrok.
224
240
  - If cloudflared is unavailable and ngrok is not configured, Dev Linker prints one-time setup guidance.
225
241
  - You may need to set ngrok auth once on your machine using ngrok config add-authtoken <token>.
226
242
  - Dev Linker prints a public URL with `ngrok-skip-browser-warning=true` only when ngrok is used.
227
243
  - Startup output includes selected tunnel provider (`cloudflare` or `ngrok`).
228
- - When Dev Linker launches a Vite frontend, it sets `ONELINK=1` to disable Vite HMR WebSockets for stable tunnel behavior.
244
+ - Proxy layer now supports WebSocket upgrades, including Vite HMR over shared links.
245
+ - Proxy listens on `0.0.0.0` and can print a WLAN URL for same-network sharing.
246
+ - If WLAN access fails on Windows, allow the proxy port in firewall and confirm devices are on the same network.
229
247
 
230
- ## Real-Time Development Modes
248
+ ## Runtime Smoke Test
231
249
 
232
- ### Option 1: Dev Linker sharing mode (recommended)
250
+ Run this test to validate proxy behavior end-to-end (frontend HTTP route, backend API forwarding, and WebSocket pass-through):
233
251
 
234
- - Run `devlinker` to share one combined frontend/backend URL.
235
- - Open local Vite URL yourself for instant HMR updates.
236
- - Share Dev Linker/ngrok URL with others; they can use normal page refresh to see changes.
252
+ ```bash
253
+ python -m unittest tests.test_proxy_runtime
254
+ ```
255
+
256
+ The test spins up lightweight local frontend and backend apps, starts Dev Linker proxy, and verifies:
237
257
 
238
- ### Option 2: Full remote HMR mode (bypass Dev Linker)
258
+ - `GET /` is routed to frontend
259
+ - `POST /api/login` is routed to backend
260
+ - `ws://.../hmr` round-trip works through proxy
239
261
 
240
- - Start frontend and backend manually.
241
- - Configure Vite `server.proxy` for `/api` to backend.
242
- - Run `ngrok http <vite-port>` directly so Vite handles WebSocket HMR traffic.
262
+ ## Real-Time Development
263
+
264
+ - Run `devlinker` to share one combined frontend/backend URL.
265
+ - Vite HMR and other WebSocket flows are proxied end-to-end through Dev Linker.
266
+ - Keep using relative frontend API paths (for example, `/api/endpoint`) so routing stays consistent locally and over tunnel.
@@ -1,15 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: devlinker
3
- Version: 1.2.4
4
- Summary: AI-powered linking and automation tool
5
- Author-email: Mani <mani1028@users.noreply.github.com>
6
- Requires-Python: >=3.7
7
- Description-Content-Type: text/markdown
8
- Requires-Dist: click
9
- Requires-Dist: flask
10
- Requires-Dist: pyngrok
11
- Requires-Dist: requests
12
-
13
1
  # Dev Linker
14
2
 
15
3
  Dev Linker runs frontend and backend dev servers, proxies both through a single local port (8000), and creates a single public URL via Cloudflare or ngrok.
@@ -23,7 +11,7 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
23
11
  - Detects Vite frontend across dynamic fallback ports (5173-5190, plus common alternatives)
24
12
  - Supports Docker backend port auto-detection
25
13
  - Works with dynamic container host ports
26
- - No config needed for standard Flask/Docker flows
14
+ - No config needed for standard FastAPI or Flask plus Docker flows
27
15
  - Serves both through one proxy at http://localhost:8000
28
16
  - Creates a public tunnel for sharing (Cloudflare first, ngrok fallback)
29
17
  - Terminal-first workflow
@@ -70,7 +58,7 @@ Typical startup output:
70
58
  ```text
71
59
  Dev Linker v1.2.2
72
60
 
73
- [INFO] Mode: Auto (Flask + Docker detection)
61
+ [INFO] Mode: Auto (FastAPI async proxy + Docker detection)
74
62
  [INFO] Booting local services...
75
63
  [INFO] Detecting frontend/backend ports...
76
64
  [OK] Frontend -> 5173
@@ -88,8 +76,10 @@ Tip: Press Ctrl+Click to open link
88
76
  DevLinker Ready (in 2.4s)
89
77
  Frontend: http://localhost:5173
90
78
  Backend: http://localhost:5000
91
- Proxy: http://localhost:8000
92
- PUBLIC URL: https://xxxx.trycloudflare.com
79
+ Access Links:
80
+ Local: http://localhost:8000
81
+ WLAN: http://192.168.1.5:8000
82
+ Public: https://xxxx.trycloudflare.com
93
83
  Tip: Press Ctrl+Click to open link
94
84
  ```
95
85
 
@@ -123,6 +113,12 @@ Run local-only mode without tunnel:
123
113
  devlinker --no-tunnel
124
114
  ```
125
115
 
116
+ Disable WLAN URL output:
117
+
118
+ ```bash
119
+ devlinker --no-lan
120
+ ```
121
+
126
122
  Interactive backend selection (when local and Docker are both detected):
127
123
 
128
124
  ```bash
@@ -157,6 +153,7 @@ Frontend detection behavior:
157
153
  - Scans Vite defaults and fallback ports (`5173` through `5190`)
158
154
  - Also checks common alternatives (`3000`, `4173`, `8080`)
159
155
  - Retries during startup to catch slow boot cases
156
+ - Performs readiness gating before proxy startup (waits until frontend looks like Vite and backend responds)
160
157
 
161
158
  ## Important Frontend Rule
162
159
 
@@ -173,10 +170,13 @@ Do not hardcode backend host URLs in frontend code.
173
170
  Backend port detection runs in this order:
174
171
 
175
172
  1. Check localhost port 5000
176
- 2. If not found, parse all Docker host-to-container port mappings
177
- 3. Rank containers by likely backend identity (name hints like backend/api plus project-name hints)
178
- 4. Use the best mapped host port automatically, even when internal port is not 5000
179
- 5. If nothing is found, print next-step guidance and exit
173
+ 2. If not found, query Docker via Docker SDK (`docker.from_env()`) for published host-to-container port mappings
174
+ 3. Prioritize containers using labels when present (`devlinker.role=backend`, optional `devlinker.port=<container-port>`)
175
+ 4. Otherwise rank containers by likely backend identity (name hints like backend/api plus project-name hints)
176
+ 5. Use the best mapped host port automatically, even when internal port is not 5000
177
+ 6. If nothing is found, print next-step guidance and exit
178
+
179
+ If Docker SDK is unavailable, Dev Linker falls back to Docker CLI parsing as a compatibility path.
180
180
 
181
181
  When both Local and Docker backends are available, Dev Linker prompts you to choose one (TTY mode) unless `--no-interactive-backend` is used.
182
182
 
@@ -218,25 +218,33 @@ For containerized Flask backends, ensure:
218
218
 
219
219
  ## Notes
220
220
 
221
- - runner.py expects frontend project in frontend and Flask app in backend/app.py.
221
+ - runner.py expects frontend project in frontend and Python app in backend/app.py.
222
222
  - If those paths do not exist, Dev Linker skips launch and only tries to detect already-running services.
223
223
  - Tunnel selection order is: cloudflared (TryCloudflare), then ngrok.
224
224
  - If cloudflared is unavailable and ngrok is not configured, Dev Linker prints one-time setup guidance.
225
225
  - You may need to set ngrok auth once on your machine using ngrok config add-authtoken <token>.
226
226
  - Dev Linker prints a public URL with `ngrok-skip-browser-warning=true` only when ngrok is used.
227
227
  - Startup output includes selected tunnel provider (`cloudflare` or `ngrok`).
228
- - When Dev Linker launches a Vite frontend, it sets `ONELINK=1` to disable Vite HMR WebSockets for stable tunnel behavior.
228
+ - Proxy layer now supports WebSocket upgrades, including Vite HMR over shared links.
229
+ - Proxy listens on `0.0.0.0` and can print a WLAN URL for same-network sharing.
230
+ - If WLAN access fails on Windows, allow the proxy port in firewall and confirm devices are on the same network.
229
231
 
230
- ## Real-Time Development Modes
232
+ ## Runtime Smoke Test
231
233
 
232
- ### Option 1: Dev Linker sharing mode (recommended)
234
+ Run this test to validate proxy behavior end-to-end (frontend HTTP route, backend API forwarding, and WebSocket pass-through):
233
235
 
234
- - Run `devlinker` to share one combined frontend/backend URL.
235
- - Open local Vite URL yourself for instant HMR updates.
236
- - Share Dev Linker/ngrok URL with others; they can use normal page refresh to see changes.
236
+ ```bash
237
+ python -m unittest tests.test_proxy_runtime
238
+ ```
237
239
 
238
- ### Option 2: Full remote HMR mode (bypass Dev Linker)
240
+ The test spins up lightweight local frontend and backend apps, starts Dev Linker proxy, and verifies:
239
241
 
240
- - Start frontend and backend manually.
241
- - Configure Vite `server.proxy` for `/api` to backend.
242
- - Run `ngrok http <vite-port>` directly so Vite handles WebSocket HMR traffic.
242
+ - `GET /` is routed to frontend
243
+ - `POST /api/login` is routed to backend
244
+ - `ws://.../hmr` round-trip works through proxy
245
+
246
+ ## Real-Time Development
247
+
248
+ - Run `devlinker` to share one combined frontend/backend URL.
249
+ - Vite HMR and other WebSocket flows are proxied end-to-end through Dev Linker.
250
+ - Keep using relative frontend API paths (for example, `/api/endpoint`) so routing stays consistent locally and over tunnel.
@@ -6,15 +6,50 @@ from typing import Iterable, Optional, Tuple
6
6
  import requests
7
7
 
8
8
 
9
- def check_port(port: int, timeout: float = 1.0) -> bool:
10
- """Return True when an HTTP service responds on localhost:port."""
9
+ DEFAULT_BACKEND_PROBE_PATHS = (
10
+ "/health",
11
+ "/api/health",
12
+ "/",
13
+ )
14
+
15
+
16
+ def check_port(
17
+ port: int,
18
+ timeout: float = 1.0,
19
+ probe_paths: Iterable[str] = DEFAULT_BACKEND_PROBE_PATHS,
20
+ ) -> bool:
21
+ """Return True when an HTTP service is reachable on localhost:port.
22
+
23
+ Probe order is health-first (for API-style backends), then root fallback.
24
+ """
25
+ normalized_paths: list[str] = []
26
+ for path in probe_paths:
27
+ cleaned = path.strip()
28
+ if not cleaned:
29
+ continue
30
+ if not cleaned.startswith("/"):
31
+ cleaned = f"/{cleaned}"
32
+ normalized_paths.append(cleaned)
33
+
34
+ if not normalized_paths:
35
+ normalized_paths = ["/"]
36
+
11
37
  for host in ("localhost", "127.0.0.1"):
12
- try:
13
- response = requests.get(f"http://{host}:{port}", timeout=timeout)
14
- if response.status_code < 500:
38
+ for path in normalized_paths:
39
+ try:
40
+ response = requests.get(f"http://{host}:{port}{path}", timeout=timeout)
41
+ except requests.RequestException:
42
+ continue
43
+
44
+ # Health endpoints must return 2xx/3xx to be considered ready.
45
+ if path in {"/health", "/api/health"}:
46
+ if 200 <= response.status_code < 400:
47
+ return True
48
+ continue
49
+
50
+ # Root fallback accepts non-error responses.
51
+ if 200 <= response.status_code < 400:
15
52
  return True
16
- except requests.RequestException:
17
- pass
18
53
  return False
19
54
 
20
55
 
@@ -55,17 +55,55 @@ def _print_summary(
55
55
  backend_port: int,
56
56
  proxy_port: int,
57
57
  public_url: str | None,
58
+ wlan_url: str | None,
58
59
  startup_seconds: float,
59
60
  ) -> None:
60
61
  print(f"\nDevLinker Ready (in {startup_seconds:.1f}s)")
61
62
  print(f"Frontend: http://localhost:{frontend_port}")
62
63
  print(f"Backend: http://localhost:{backend_port}")
63
- print(f"Proxy: http://localhost:{proxy_port}")
64
+ print("Access Links:")
65
+ print(f"Local: http://localhost:{proxy_port}")
66
+ if wlan_url:
67
+ print(f"WLAN: {wlan_url}")
68
+ else:
69
+ print("WLAN: unavailable")
64
70
  if public_url:
65
- print(f"PUBLIC URL: {public_url}")
71
+ print(f"Public: {public_url}")
66
72
  print("Tip: Press Ctrl+Click to open link")
67
73
  else:
68
- print("Public: unavailable (local proxy still active)")
74
+ print("Public: unavailable (local proxy still active)")
75
+
76
+
77
+ def _get_local_ip() -> str | None:
78
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
79
+ try:
80
+ sock.connect(("8.8.8.8", 80))
81
+ ip_address = sock.getsockname()[0]
82
+ if ip_address and not ip_address.startswith("127."):
83
+ return ip_address
84
+ return None
85
+ except OSError:
86
+ return None
87
+ finally:
88
+ sock.close()
89
+
90
+
91
+ def _wait_for_readiness(
92
+ label: str,
93
+ port: int,
94
+ checker,
95
+ retries: int = 15,
96
+ delay_seconds: float = 1.0,
97
+ ) -> bool:
98
+ print(f"[INFO] Waiting for {label} on :{port}...")
99
+ for attempt in range(1, retries + 1):
100
+ if checker(port):
101
+ print(f"[OK] {label} ready on :{port}")
102
+ return True
103
+ if attempt < retries:
104
+ time.sleep(delay_seconds)
105
+ print(f"[WARN] {label} not ready on :{port} after {retries} checks")
106
+ return False
69
107
 
70
108
 
71
109
  @click.command()
@@ -93,6 +131,13 @@ def _print_summary(
93
131
  show_default=True,
94
132
  help="Prompt to choose backend when local and Docker candidates are both available.",
95
133
  )
134
+ @click.option(
135
+ "--lan/--no-lan",
136
+ "lan_enabled",
137
+ default=True,
138
+ show_default=True,
139
+ help="Show WLAN sharing URL for devices on the same network.",
140
+ )
96
141
  @click.option("--debug", is_flag=True, hidden=True, help="Enable debug logging.")
97
142
  def cli(
98
143
  frontend: int | None,
@@ -101,11 +146,12 @@ def cli(
101
146
  auto_start_docker: bool,
102
147
  no_tunnel: bool,
103
148
  interactive_backend: bool,
149
+ lan_enabled: bool,
104
150
  debug: bool,
105
151
  ) -> None:
106
152
  started = time.perf_counter()
107
153
  print(f"\nDev Linker v{__version__}")
108
- print("[INFO] Mode: Auto (Flask + Docker detection)")
154
+ print("[INFO] Mode: Auto (FastAPI async proxy + Docker detection)")
109
155
  print("[INFO] Booting local services...")
110
156
 
111
157
  start_servers(auto_start_docker=auto_start_docker)
@@ -131,13 +177,13 @@ def cli(
131
177
  "Backend not detected on common ports. Start backend first or set --backend (example: 5000)."
132
178
  )
133
179
 
134
- if not is_vite_port(frontend_port):
180
+ if not _wait_for_readiness("Frontend", frontend_port, is_vite_port):
135
181
  raise click.ClickException(
136
182
  f"Frontend port {frontend_port} is reachable but does not look like a Vite dev server. "
137
183
  "Run frontend with Dev Linker or pass the correct --frontend port."
138
184
  )
139
185
 
140
- if not check_port(backend_port):
186
+ if not _wait_for_readiness("Backend", backend_port, check_port):
141
187
  raise click.ClickException(
142
188
  f"Backend port {backend_port} is not reachable. Verify backend is running and listening on localhost."
143
189
  )
@@ -150,9 +196,20 @@ def cli(
150
196
  print(f"[INFO] Starting proxy on :{proxy_port}...")
151
197
  start_proxy(frontend_port, backend_port, proxy_port=proxy_port)
152
198
 
153
- # Allow Flask thread to bind before opening tunnel.
199
+ # Allow proxy thread to bind before opening tunnel.
154
200
  time.sleep(1)
155
201
 
202
+ wlan_url: str | None = None
203
+ if lan_enabled:
204
+ local_ip = _get_local_ip()
205
+ if local_ip:
206
+ wlan_url = f"http://{local_ip}:{proxy_port}"
207
+ print(f"[OK] WLAN URL: {wlan_url}")
208
+ print("[INFO] Share WLAN link with teammates on same WiFi/LAN.")
209
+ else:
210
+ print("[WARN] WLAN URL unavailable (no active LAN interface detected).")
211
+ print("[INFO] If LAN sharing fails, allow proxy port in firewall and use same network.")
212
+
156
213
  print(f"\n[OK] Proxy ready at http://localhost:{proxy_port}\n")
157
214
  warning_free_url: str | None = None
158
215
  if no_tunnel:
@@ -179,6 +236,7 @@ def cli(
179
236
  backend_port,
180
237
  proxy_port,
181
238
  warning_free_url,
239
+ wlan_url,
182
240
  startup_seconds=time.perf_counter() - started,
183
241
  )
184
242
 
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import threading
5
+ from typing import Dict, Optional
6
+ from urllib.parse import urlencode
7
+
8
+ import httpx
9
+ import uvicorn
10
+ import websockets
11
+ from fastapi import FastAPI, Request, Response, WebSocket
12
+ from fastapi.responses import PlainTextResponse
13
+ from starlette.websockets import WebSocketDisconnect
14
+ from websockets.exceptions import ConnectionClosed
15
+
16
+ app = FastAPI()
17
+
18
+ FRONTEND: Optional[int] = None
19
+ BACKEND: Optional[int] = None
20
+ HTTP_CLIENT: Optional[httpx.AsyncClient] = None
21
+
22
+ HOP_BY_HOP_HEADERS = {
23
+ "connection",
24
+ "keep-alive",
25
+ "proxy-authenticate",
26
+ "proxy-authorization",
27
+ "te",
28
+ "trailer",
29
+ "transfer-encoding",
30
+ "upgrade",
31
+ }
32
+
33
+
34
+ @app.on_event("startup")
35
+ async def _on_startup() -> None:
36
+ global HTTP_CLIENT
37
+ HTTP_CLIENT = httpx.AsyncClient(timeout=15.0, follow_redirects=False)
38
+
39
+
40
+ @app.on_event("shutdown")
41
+ async def _on_shutdown() -> None:
42
+ global HTTP_CLIENT
43
+ if HTTP_CLIENT is not None:
44
+ await HTTP_CLIENT.aclose()
45
+ HTTP_CLIENT = None
46
+
47
+
48
+ def _connection_header_tokens(headers: Dict[str, str]) -> set[str]:
49
+ connection = ""
50
+ for key, value in headers.items():
51
+ if key.lower() == "connection":
52
+ connection = value
53
+ break
54
+ return {token.strip().lower() for token in connection.split(",") if token.strip()}
55
+
56
+
57
+ def _filter_request_headers(incoming: Dict[str, str]) -> Dict[str, str]:
58
+ connection_tokens = _connection_header_tokens(incoming)
59
+ excluded = HOP_BY_HOP_HEADERS | connection_tokens | {"host", "content-length"}
60
+ return {k: v for k, v in incoming.items() if k.lower() not in excluded}
61
+
62
+
63
+ def _filter_response_headers(incoming: Dict[str, str]) -> Dict[str, str]:
64
+ connection_tokens = _connection_header_tokens(incoming)
65
+ excluded = HOP_BY_HOP_HEADERS | connection_tokens | {"content-length"}
66
+ return {k: v for k, v in incoming.items() if k.lower() not in excluded}
67
+
68
+
69
+ def _filter_websocket_headers(incoming: Dict[str, str]) -> Dict[str, str]:
70
+ connection_tokens = _connection_header_tokens(incoming)
71
+ excluded = HOP_BY_HOP_HEADERS | connection_tokens | {
72
+ "host",
73
+ "sec-websocket-key",
74
+ "sec-websocket-version",
75
+ "sec-websocket-extensions",
76
+ "sec-websocket-protocol",
77
+ }
78
+ return {k: v for k, v in incoming.items() if k.lower() not in excluded}
79
+
80
+
81
+ def _target_port(path: str) -> Optional[int]:
82
+ if path == "/api" or path.startswith("/api/"):
83
+ return BACKEND
84
+ return FRONTEND
85
+
86
+
87
+ def _build_target_http_url(port: int, path: str, query_params: list[tuple[str, str]]) -> str:
88
+ query_string = urlencode(query_params, doseq=True)
89
+ base_url = f"http://127.0.0.1:{port}{path}"
90
+ if not query_string:
91
+ return base_url
92
+ return f"{base_url}?{query_string}"
93
+
94
+
95
+ def _build_target_ws_url(port: int, path: str, query: str) -> str:
96
+ base_url = f"ws://127.0.0.1:{port}{path}"
97
+ if not query:
98
+ return base_url
99
+ return f"{base_url}?{query}"
100
+
101
+
102
+ async def _forward_http(request: Request) -> Response:
103
+ target_port = _target_port(request.url.path)
104
+ if target_port is None:
105
+ if request.url.path.startswith("/api"):
106
+ return PlainTextResponse("Backend is not configured.", status_code=503)
107
+ return PlainTextResponse("Frontend is not configured.", status_code=503)
108
+
109
+ if HTTP_CLIENT is None:
110
+ return PlainTextResponse("Proxy HTTP client is not ready.", status_code=503)
111
+
112
+ payload = await request.body()
113
+ query_params = list(request.query_params.multi_items())
114
+ target_url = _build_target_http_url(target_port, request.url.path, query_params)
115
+
116
+ try:
117
+ upstream = await HTTP_CLIENT.request(
118
+ method=request.method,
119
+ url=target_url,
120
+ content=payload,
121
+ headers=_filter_request_headers(dict(request.headers)),
122
+ )
123
+ except httpx.RequestError as exc:
124
+ return PlainTextResponse(f"Upstream unavailable: {exc}", status_code=502)
125
+
126
+ return Response(
127
+ content=upstream.content,
128
+ status_code=upstream.status_code,
129
+ headers=_filter_response_headers(dict(upstream.headers)),
130
+ )
131
+
132
+
133
+ async def _proxy_websocket(websocket: WebSocket) -> None:
134
+ target_port = _target_port(websocket.url.path)
135
+ if target_port is None:
136
+ await websocket.close(code=1013)
137
+ return
138
+
139
+ requested_subprotocols = [
140
+ value.strip()
141
+ for value in websocket.headers.get("sec-websocket-protocol", "").split(",")
142
+ if value.strip()
143
+ ]
144
+ forward_headers = _filter_websocket_headers(dict(websocket.headers))
145
+ target_url = _build_target_ws_url(target_port, websocket.url.path, websocket.url.query)
146
+
147
+ try:
148
+ connect_kwargs = {
149
+ "subprotocols": requested_subprotocols or None,
150
+ "open_timeout": 10,
151
+ "ping_interval": 20,
152
+ "ping_timeout": 20,
153
+ }
154
+ try:
155
+ upstream_connect = websockets.connect(
156
+ target_url,
157
+ additional_headers=forward_headers,
158
+ **connect_kwargs,
159
+ )
160
+ except TypeError:
161
+ upstream_connect = websockets.connect(
162
+ target_url,
163
+ extra_headers=forward_headers,
164
+ **connect_kwargs,
165
+ )
166
+
167
+ async with upstream_connect as upstream:
168
+ await websocket.accept(subprotocol=upstream.subprotocol)
169
+
170
+ async def client_to_upstream() -> None:
171
+ while True:
172
+ message = await websocket.receive()
173
+ if message["type"] == "websocket.disconnect":
174
+ break
175
+ text = message.get("text")
176
+ if text is not None:
177
+ await upstream.send(text)
178
+ continue
179
+ binary = message.get("bytes")
180
+ if binary is not None:
181
+ await upstream.send(binary)
182
+
183
+ async def upstream_to_client() -> None:
184
+ while True:
185
+ data = await upstream.recv()
186
+ if isinstance(data, str):
187
+ await websocket.send_text(data)
188
+ else:
189
+ await websocket.send_bytes(data)
190
+
191
+ done, pending = await asyncio.wait(
192
+ {
193
+ asyncio.create_task(client_to_upstream()),
194
+ asyncio.create_task(upstream_to_client()),
195
+ },
196
+ return_when=asyncio.FIRST_COMPLETED,
197
+ )
198
+ for task in pending:
199
+ task.cancel()
200
+ await asyncio.gather(*pending, return_exceptions=True)
201
+ for task in done:
202
+ exception = task.exception()
203
+ if exception and not isinstance(exception, (WebSocketDisconnect, ConnectionClosed)):
204
+ raise exception
205
+ except Exception:
206
+ if websocket.client_state.name != "DISCONNECTED":
207
+ await websocket.close(code=1011)
208
+
209
+
210
+ @app.api_route(
211
+ "/",
212
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
213
+ )
214
+ @app.api_route(
215
+ "/{path:path}",
216
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
217
+ )
218
+ async def http_proxy(path: str, request: Request) -> Response: # noqa: ARG001
219
+ return await _forward_http(request)
220
+
221
+
222
+ @app.websocket("/")
223
+ @app.websocket("/{path:path}")
224
+ async def websocket_proxy(websocket: WebSocket, path: str) -> None: # noqa: ARG001
225
+ await _proxy_websocket(websocket)
226
+
227
+
228
+ def start_proxy(frontend_port: int, backend_port: int, proxy_port: int = 8000) -> None:
229
+ global FRONTEND, BACKEND
230
+ FRONTEND = frontend_port
231
+ BACKEND = backend_port
232
+
233
+ thread = threading.Thread(
234
+ target=lambda: uvicorn.run(app, host="0.0.0.0", port=proxy_port, log_level="warning"),
235
+ daemon=True,
236
+ )
237
+ thread.start()
@@ -9,7 +9,12 @@ import subprocess
9
9
  import sys
10
10
  import time
11
11
  from pathlib import Path
12
- from typing import List
12
+ from typing import Any, List
13
+
14
+ try:
15
+ import docker # type: ignore
16
+ except ImportError: # pragma: no cover - optional fallback path
17
+ docker = None
13
18
 
14
19
  from .detector import check_port, is_vite_port
15
20
 
@@ -82,7 +87,129 @@ def _container_priority(name: str, container_port: int, default_container_port:
82
87
  return score
83
88
 
84
89
 
85
- def get_docker_backend_candidates(
90
+ def _normalize_label_port(value: str | None) -> int | None:
91
+ if not value:
92
+ return None
93
+ try:
94
+ parsed = int(value)
95
+ except ValueError:
96
+ return None
97
+ if 1 <= parsed <= 65535:
98
+ return parsed
99
+ return None
100
+
101
+
102
+ def _extract_port_mappings_from_docker_sdk(container: Any) -> list[tuple[int, int]]:
103
+ mappings: list[tuple[int, int]] = []
104
+ ports = (container.attrs or {}).get("NetworkSettings", {}).get("Ports", {})
105
+ if not isinstance(ports, dict):
106
+ return mappings
107
+
108
+ for container_port_proto, bindings in ports.items():
109
+ if not isinstance(container_port_proto, str):
110
+ continue
111
+ try:
112
+ container_port = int(container_port_proto.split("/", 1)[0])
113
+ except (ValueError, IndexError):
114
+ continue
115
+ if not bindings:
116
+ continue
117
+ if not isinstance(bindings, list):
118
+ continue
119
+
120
+ for binding in bindings:
121
+ if not isinstance(binding, dict):
122
+ continue
123
+ host_port_str = binding.get("HostPort")
124
+ if not host_port_str:
125
+ continue
126
+ try:
127
+ host_port = int(host_port_str)
128
+ except (TypeError, ValueError):
129
+ continue
130
+ mappings.append((host_port, container_port))
131
+ return mappings
132
+
133
+
134
+ def _docker_sdk_backend_candidates(
135
+ default_container_port: int = 5000,
136
+ debug: bool = False,
137
+ ) -> list[tuple[str, int, int]]:
138
+ if docker is None:
139
+ _debug_log(debug, "Docker SDK not installed; falling back to docker CLI parsing")
140
+ return []
141
+
142
+ try:
143
+ client = docker.from_env()
144
+ containers = client.containers.list()
145
+ except Exception as exc:
146
+ _debug_log(debug, f"Docker SDK unavailable ({exc}); falling back to docker CLI parsing")
147
+ return []
148
+
149
+ candidates: list[tuple[int, int, str, int, int, int]] = []
150
+ for index, container in enumerate(containers):
151
+ container_name = str(getattr(container, "name", "unknown"))
152
+ labels = getattr(container, "labels", {}) or {}
153
+ role_label = str(labels.get("devlinker.role", "")).strip().lower()
154
+ preferred_container_port = _normalize_label_port(labels.get("devlinker.port"))
155
+ if preferred_container_port is None:
156
+ preferred_container_port = _normalize_label_port(labels.get("devlinker.backend.port"))
157
+
158
+ mappings = _extract_port_mappings_from_docker_sdk(container)
159
+ if not mappings:
160
+ continue
161
+
162
+ for host_port, container_port in mappings:
163
+ priority = _container_priority(container_name, container_port, default_container_port)
164
+ if role_label == "backend":
165
+ priority += 20
166
+ elif role_label:
167
+ priority -= 2
168
+
169
+ if preferred_container_port is not None:
170
+ if container_port == preferred_container_port:
171
+ priority += 12
172
+ else:
173
+ priority -= 6
174
+
175
+ candidates.append((priority, index, container_name, host_port, container_port, preferred_container_port or 0))
176
+ _debug_log(
177
+ debug,
178
+ (
179
+ f"SDK candidate: container='{container_name}', host={host_port}, "
180
+ f"container={container_port}, role={role_label or '-'}, "
181
+ f"label_port={preferred_container_port if preferred_container_port is not None else '-'}, "
182
+ f"score={priority}"
183
+ ),
184
+ )
185
+
186
+ if not candidates:
187
+ return []
188
+
189
+ ranked = sorted(candidates, key=lambda item: (-item[0], item[1]))
190
+ ordered: list[tuple[str, int, int]] = []
191
+ seen: set[tuple[str, int, int]] = set()
192
+ for _score, _index, name, host_port, container_port, _label_port in ranked:
193
+ key = (name, host_port, container_port)
194
+ if key in seen:
195
+ continue
196
+ seen.add(key)
197
+ ordered.append(key)
198
+
199
+ if ordered:
200
+ first_name, first_host_port, first_container_port = ordered[0]
201
+ _debug_log(
202
+ debug,
203
+ (
204
+ f"Selected Docker SDK container '{first_name}' with host port {first_host_port} "
205
+ f"(container port {first_container_port})"
206
+ ),
207
+ )
208
+
209
+ return ordered
210
+
211
+
212
+ def _docker_cli_backend_candidates(
86
213
  default_container_port: int = 5000,
87
214
  debug: bool = False,
88
215
  ) -> list[tuple[str, int, int]]:
@@ -122,7 +249,7 @@ def get_docker_backend_candidates(
122
249
  _debug_log(
123
250
  debug,
124
251
  (
125
- f"Candidate Docker port mapping: container='{name}', "
252
+ f"CLI candidate Docker mapping: container='{name}', "
126
253
  f"host={host_port}, container={container_port}"
127
254
  ),
128
255
  )
@@ -130,7 +257,6 @@ def get_docker_backend_candidates(
130
257
  if not candidates:
131
258
  return []
132
259
 
133
- # Score candidates by likely backend identity and prefer newest on ties.
134
260
  ranked = sorted(
135
261
  candidates,
136
262
  key=lambda item: (-_container_priority(item[0], item[2], default_container_port), item[3]),
@@ -144,19 +270,26 @@ def get_docker_backend_candidates(
144
270
  seen.add(key)
145
271
  ordered.append(key)
146
272
 
147
- if ordered:
148
- name, host_port, container_port = ordered[0]
149
- _debug_log(
150
- debug,
151
- (
152
- f"Selected Docker container '{name}' with host port {host_port} "
153
- f"(container port {container_port})"
154
- ),
155
- )
156
-
157
273
  return ordered
158
274
 
159
275
 
276
+ def get_docker_backend_candidates(
277
+ default_container_port: int = 5000,
278
+ debug: bool = False,
279
+ ) -> list[tuple[str, int, int]]:
280
+ sdk_candidates = _docker_sdk_backend_candidates(
281
+ default_container_port=default_container_port,
282
+ debug=debug,
283
+ )
284
+ if sdk_candidates:
285
+ return sdk_candidates
286
+
287
+ return _docker_cli_backend_candidates(
288
+ default_container_port=default_container_port,
289
+ debug=debug,
290
+ )
291
+
292
+
160
293
  def _choose_backend_candidate(
161
294
  local_port: int,
162
295
  docker_candidates: list[tuple[str, int, int]],
@@ -1,3 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: devlinker
3
+ Version: 1.2.8
4
+ Summary: AI-powered linking and automation tool
5
+ Author-email: Mani <mani1028@users.noreply.github.com>
6
+ Requires-Python: >=3.7
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click
9
+ Requires-Dist: docker
10
+ Requires-Dist: fastapi
11
+ Requires-Dist: httpx
12
+ Requires-Dist: pyngrok
13
+ Requires-Dist: requests
14
+ Requires-Dist: uvicorn
15
+ Requires-Dist: websockets
16
+
1
17
  # Dev Linker
2
18
 
3
19
  Dev Linker runs frontend and backend dev servers, proxies both through a single local port (8000), and creates a single public URL via Cloudflare or ngrok.
@@ -11,7 +27,7 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
11
27
  - Detects Vite frontend across dynamic fallback ports (5173-5190, plus common alternatives)
12
28
  - Supports Docker backend port auto-detection
13
29
  - Works with dynamic container host ports
14
- - No config needed for standard Flask/Docker flows
30
+ - No config needed for standard FastAPI or Flask plus Docker flows
15
31
  - Serves both through one proxy at http://localhost:8000
16
32
  - Creates a public tunnel for sharing (Cloudflare first, ngrok fallback)
17
33
  - Terminal-first workflow
@@ -58,7 +74,7 @@ Typical startup output:
58
74
  ```text
59
75
  Dev Linker v1.2.2
60
76
 
61
- [INFO] Mode: Auto (Flask + Docker detection)
77
+ [INFO] Mode: Auto (FastAPI async proxy + Docker detection)
62
78
  [INFO] Booting local services...
63
79
  [INFO] Detecting frontend/backend ports...
64
80
  [OK] Frontend -> 5173
@@ -76,8 +92,10 @@ Tip: Press Ctrl+Click to open link
76
92
  DevLinker Ready (in 2.4s)
77
93
  Frontend: http://localhost:5173
78
94
  Backend: http://localhost:5000
79
- Proxy: http://localhost:8000
80
- PUBLIC URL: https://xxxx.trycloudflare.com
95
+ Access Links:
96
+ Local: http://localhost:8000
97
+ WLAN: http://192.168.1.5:8000
98
+ Public: https://xxxx.trycloudflare.com
81
99
  Tip: Press Ctrl+Click to open link
82
100
  ```
83
101
 
@@ -111,6 +129,12 @@ Run local-only mode without tunnel:
111
129
  devlinker --no-tunnel
112
130
  ```
113
131
 
132
+ Disable WLAN URL output:
133
+
134
+ ```bash
135
+ devlinker --no-lan
136
+ ```
137
+
114
138
  Interactive backend selection (when local and Docker are both detected):
115
139
 
116
140
  ```bash
@@ -145,6 +169,7 @@ Frontend detection behavior:
145
169
  - Scans Vite defaults and fallback ports (`5173` through `5190`)
146
170
  - Also checks common alternatives (`3000`, `4173`, `8080`)
147
171
  - Retries during startup to catch slow boot cases
172
+ - Performs readiness gating before proxy startup (waits until frontend looks like Vite and backend responds)
148
173
 
149
174
  ## Important Frontend Rule
150
175
 
@@ -161,10 +186,13 @@ Do not hardcode backend host URLs in frontend code.
161
186
  Backend port detection runs in this order:
162
187
 
163
188
  1. Check localhost port 5000
164
- 2. If not found, parse all Docker host-to-container port mappings
165
- 3. Rank containers by likely backend identity (name hints like backend/api plus project-name hints)
166
- 4. Use the best mapped host port automatically, even when internal port is not 5000
167
- 5. If nothing is found, print next-step guidance and exit
189
+ 2. If not found, query Docker via Docker SDK (`docker.from_env()`) for published host-to-container port mappings
190
+ 3. Prioritize containers using labels when present (`devlinker.role=backend`, optional `devlinker.port=<container-port>`)
191
+ 4. Otherwise rank containers by likely backend identity (name hints like backend/api plus project-name hints)
192
+ 5. Use the best mapped host port automatically, even when internal port is not 5000
193
+ 6. If nothing is found, print next-step guidance and exit
194
+
195
+ If Docker SDK is unavailable, Dev Linker falls back to Docker CLI parsing as a compatibility path.
168
196
 
169
197
  When both Local and Docker backends are available, Dev Linker prompts you to choose one (TTY mode) unless `--no-interactive-backend` is used.
170
198
 
@@ -206,25 +234,33 @@ For containerized Flask backends, ensure:
206
234
 
207
235
  ## Notes
208
236
 
209
- - runner.py expects frontend project in frontend and Flask app in backend/app.py.
237
+ - runner.py expects frontend project in frontend and Python app in backend/app.py.
210
238
  - If those paths do not exist, Dev Linker skips launch and only tries to detect already-running services.
211
239
  - Tunnel selection order is: cloudflared (TryCloudflare), then ngrok.
212
240
  - If cloudflared is unavailable and ngrok is not configured, Dev Linker prints one-time setup guidance.
213
241
  - You may need to set ngrok auth once on your machine using ngrok config add-authtoken <token>.
214
242
  - Dev Linker prints a public URL with `ngrok-skip-browser-warning=true` only when ngrok is used.
215
243
  - Startup output includes selected tunnel provider (`cloudflare` or `ngrok`).
216
- - When Dev Linker launches a Vite frontend, it sets `ONELINK=1` to disable Vite HMR WebSockets for stable tunnel behavior.
244
+ - Proxy layer now supports WebSocket upgrades, including Vite HMR over shared links.
245
+ - Proxy listens on `0.0.0.0` and can print a WLAN URL for same-network sharing.
246
+ - If WLAN access fails on Windows, allow the proxy port in firewall and confirm devices are on the same network.
217
247
 
218
- ## Real-Time Development Modes
248
+ ## Runtime Smoke Test
219
249
 
220
- ### Option 1: Dev Linker sharing mode (recommended)
250
+ Run this test to validate proxy behavior end-to-end (frontend HTTP route, backend API forwarding, and WebSocket pass-through):
221
251
 
222
- - Run `devlinker` to share one combined frontend/backend URL.
223
- - Open local Vite URL yourself for instant HMR updates.
224
- - Share Dev Linker/ngrok URL with others; they can use normal page refresh to see changes.
252
+ ```bash
253
+ python -m unittest tests.test_proxy_runtime
254
+ ```
225
255
 
226
- ### Option 2: Full remote HMR mode (bypass Dev Linker)
256
+ The test spins up lightweight local frontend and backend apps, starts Dev Linker proxy, and verifies:
227
257
 
228
- - Start frontend and backend manually.
229
- - Configure Vite `server.proxy` for `/api` to backend.
230
- - Run `ngrok http <vite-port>` directly so Vite handles WebSocket HMR traffic.
258
+ - `GET /` is routed to frontend
259
+ - `POST /api/login` is routed to backend
260
+ - `ws://.../hmr` round-trip works through proxy
261
+
262
+ ## Real-Time Development
263
+
264
+ - Run `devlinker` to share one combined frontend/backend URL.
265
+ - Vite HMR and other WebSocket flows are proxied end-to-end through Dev Linker.
266
+ - Keep using relative frontend API paths (for example, `/api/endpoint`) so routing stays consistent locally and over tunnel.
@@ -0,0 +1,8 @@
1
+ click
2
+ docker
3
+ fastapi
4
+ httpx
5
+ pyngrok
6
+ requests
7
+ uvicorn
8
+ websockets
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlinker"
7
- version = "1.2.4"
7
+ version = "1.2.8"
8
8
  description = "AI-powered linking and automation tool"
9
9
  authors = [
10
10
  { name = "Mani", email = "mani1028@users.noreply.github.com" }
@@ -13,9 +13,13 @@ readme = "README.md"
13
13
  requires-python = ">=3.7"
14
14
  dependencies = [
15
15
  "click",
16
- "flask",
16
+ "docker",
17
+ "fastapi",
18
+ "httpx",
17
19
  "pyngrok",
18
20
  "requests",
21
+ "uvicorn",
22
+ "websockets",
19
23
  ]
20
24
 
21
25
  [project.scripts]
@@ -1,88 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import threading
4
- from typing import Dict, Optional
5
-
6
- import requests
7
- from flask import Flask, Response, request
8
-
9
- app = Flask(__name__)
10
-
11
- FRONTEND: Optional[int] = None
12
- BACKEND: Optional[int] = None
13
-
14
-
15
- def _filter_headers(incoming: Dict[str, str]) -> Dict[str, str]:
16
- excluded = {
17
- "host",
18
- "content-length",
19
- "connection",
20
- "accept-encoding",
21
- "upgrade",
22
- "sec-websocket-key",
23
- "sec-websocket-version",
24
- "sec-websocket-extensions",
25
- }
26
- return {k: v for k, v in incoming.items() if k.lower() not in excluded}
27
-
28
-
29
- def _forward(target_url: str) -> Response:
30
- if request.headers.get("Upgrade", "").lower() == "websocket":
31
- return Response(
32
- "WebSocket upgrade is not supported by the Dev Linker HTTP proxy. "
33
- "For shared links, run Vite with HMR disabled.",
34
- status=426,
35
- )
36
-
37
- payload = request.get_data() if request.method in {"POST", "PUT", "PATCH"} else None
38
- query_params = list(request.args.items(multi=True))
39
-
40
- try:
41
- upstream = requests.request(
42
- method=request.method,
43
- url=target_url,
44
- params=query_params,
45
- data=payload,
46
- headers=_filter_headers(dict(request.headers)),
47
- cookies=request.cookies,
48
- allow_redirects=False,
49
- timeout=15,
50
- )
51
- except requests.RequestException as exc:
52
- return Response(f"Upstream unavailable: {exc}", status=502)
53
-
54
- response = Response(upstream.content, status=upstream.status_code)
55
- for key, value in upstream.headers.items():
56
- if key.lower() in {"content-length", "transfer-encoding", "connection"}:
57
- continue
58
- response.headers[key] = value
59
- return response
60
-
61
-
62
- @app.route("/api/<path:path>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
63
- def api_proxy(path: str) -> Response:
64
- if BACKEND is None:
65
- return Response("Backend is not configured.", status=503)
66
- return _forward(f"http://localhost:{BACKEND}/api/{path}")
67
-
68
-
69
- @app.route("/", defaults={"path": ""}, methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
70
- @app.route("/<path:path>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
71
- def frontend_proxy(path: str) -> Response:
72
- if FRONTEND is None:
73
- return Response("Frontend is not configured.", status=503)
74
-
75
- target = f"http://localhost:{FRONTEND}/{path}" if path else f"http://localhost:{FRONTEND}/"
76
- return _forward(target)
77
-
78
-
79
- def start_proxy(frontend_port: int, backend_port: int, proxy_port: int = 8000) -> None:
80
- global FRONTEND, BACKEND
81
- FRONTEND = frontend_port
82
- BACKEND = backend_port
83
-
84
- thread = threading.Thread(
85
- target=lambda: app.run(host="0.0.0.0", port=proxy_port, debug=False, use_reloader=False),
86
- daemon=True,
87
- )
88
- thread.start()
@@ -1,4 +0,0 @@
1
- click
2
- flask
3
- pyngrok
4
- requests
File without changes
File without changes
File without changes