devlinker 1.2.4__tar.gz → 1.2.7__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.
- {devlinker-1.2.4/devlinker.egg-info → devlinker-1.2.7}/PKG-INFO +45 -21
- devlinker-1.2.4/PKG-INFO → devlinker-1.2.7/README.md +39 -31
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker/main.py +65 -7
- devlinker-1.2.7/devlinker/proxy.py +237 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker/runner.py +147 -14
- devlinker-1.2.4/README.md → devlinker-1.2.7/devlinker.egg-info/PKG-INFO +55 -19
- devlinker-1.2.7/devlinker.egg-info/requires.txt +8 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/pyproject.toml +6 -2
- devlinker-1.2.4/devlinker/proxy.py +0 -88
- devlinker-1.2.4/devlinker.egg-info/requires.txt +0 -4
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker/__init__.py +0 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker/detector.py +0 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker/tunnel.py +0 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker.egg-info/SOURCES.txt +0 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker.egg-info/dependency_links.txt +0 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker.egg-info/entry_points.txt +0 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/devlinker.egg-info/top_level.txt +0 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/setup.cfg +0 -0
- {devlinker-1.2.4 → devlinker-1.2.7}/setup.py +0 -0
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlinker
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.7
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
-
|
|
92
|
-
|
|
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,
|
|
177
|
-
3.
|
|
178
|
-
4.
|
|
179
|
-
5.
|
|
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
|
|
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
|
-
-
|
|
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
|
-
##
|
|
248
|
+
## Runtime Smoke Test
|
|
231
249
|
|
|
232
|
-
|
|
250
|
+
Run this test to validate proxy behavior end-to-end (frontend HTTP route, backend API forwarding, and WebSocket pass-through):
|
|
233
251
|
|
|
234
|
-
|
|
235
|
-
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
241
|
-
|
|
242
|
-
- Run `
|
|
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
|
|
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 (
|
|
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
|
-
|
|
92
|
-
|
|
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,
|
|
177
|
-
3.
|
|
178
|
-
4.
|
|
179
|
-
5.
|
|
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
|
|
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
|
-
-
|
|
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
|
-
##
|
|
232
|
+
## Runtime Smoke Test
|
|
231
233
|
|
|
232
|
-
|
|
234
|
+
Run this test to validate proxy behavior end-to-end (frontend HTTP route, backend API forwarding, and WebSocket pass-through):
|
|
233
235
|
|
|
234
|
-
|
|
235
|
-
-
|
|
236
|
-
|
|
236
|
+
```bash
|
|
237
|
+
python -m unittest tests.test_proxy_runtime
|
|
238
|
+
```
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
The test spins up lightweight local frontend and backend apps, starts Dev Linker proxy, and verifies:
|
|
239
241
|
|
|
240
|
-
-
|
|
241
|
-
-
|
|
242
|
-
-
|
|
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.
|
|
@@ -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(
|
|
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"
|
|
71
|
+
print(f"Public: {public_url}")
|
|
66
72
|
print("Tip: Press Ctrl+Click to open link")
|
|
67
73
|
else:
|
|
68
|
-
print("Public:
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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.7
|
|
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
|
|
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 (
|
|
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
|
-
|
|
80
|
-
|
|
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,
|
|
165
|
-
3.
|
|
166
|
-
4.
|
|
167
|
-
5.
|
|
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
|
|
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
|
-
-
|
|
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
|
-
##
|
|
248
|
+
## Runtime Smoke Test
|
|
219
249
|
|
|
220
|
-
|
|
250
|
+
Run this test to validate proxy behavior end-to-end (frontend HTTP route, backend API forwarding, and WebSocket pass-through):
|
|
221
251
|
|
|
222
|
-
|
|
223
|
-
-
|
|
224
|
-
|
|
252
|
+
```bash
|
|
253
|
+
python -m unittest tests.test_proxy_runtime
|
|
254
|
+
```
|
|
225
255
|
|
|
226
|
-
|
|
256
|
+
The test spins up lightweight local frontend and backend apps, starts Dev Linker proxy, and verifies:
|
|
227
257
|
|
|
228
|
-
-
|
|
229
|
-
-
|
|
230
|
-
-
|
|
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.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devlinker"
|
|
7
|
-
version = "1.2.
|
|
7
|
+
version = "1.2.7"
|
|
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
|
-
"
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|