devlinker 1.4.1__tar.gz → 1.4.3__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.4.1 → devlinker-1.4.3}/MANIFEST.in +0 -1
- {devlinker-1.4.1/devlinker.egg-info → devlinker-1.4.3}/PKG-INFO +116 -3
- {devlinker-1.4.1 → devlinker-1.4.3}/README.md +115 -2
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/main.py +25 -6
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/proxy.py +204 -16
- devlinker-1.4.3/devlinker/share.py +64 -0
- {devlinker-1.4.1 → devlinker-1.4.3/devlinker.egg-info}/PKG-INFO +116 -3
- {devlinker-1.4.1 → devlinker-1.4.3}/pyproject.toml +1 -1
- devlinker-1.4.1/devlinker/share.py +0 -32
- {devlinker-1.4.1 → devlinker-1.4.3}/LICENSE +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/__init__.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/config.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/detection_state.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/detector.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/detector_ai.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/devlinker_loader_instant.html +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/devlinker_loader_snippet.html +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/doctor.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/fix.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/fixer.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/global_state.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/inspect.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/logger.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/monitor.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/runner.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker/tunnel.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/SOURCES.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/dependency_links.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/entry_points.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/requires.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/devlinker.egg-info/top_level.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/setup.cfg +0 -0
- {devlinker-1.4.1 → devlinker-1.4.3}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlinker
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
4
4
|
Summary: A lightweight proxy that combines your frontend and backend into one link for easy development and sharing.
|
|
5
5
|
Author-email: Mani <mani1028@users.noreply.github.com>
|
|
6
6
|
Requires-Python: >=3.7
|
|
@@ -22,6 +22,76 @@ Dynamic: license-file
|
|
|
22
22
|
|
|
23
23
|
Dev Linker starts your local development stack and routes frontend and backend traffic through one proxy URL, with optional LAN and public sharing.
|
|
24
24
|
|
|
25
|
+
## ⚡ Quick Start (2 Minutes)
|
|
26
|
+
|
|
27
|
+
Install:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install devlinker
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Run your apps:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Backend (example)
|
|
37
|
+
uvicorn main:app --reload
|
|
38
|
+
|
|
39
|
+
# Frontend (example)
|
|
40
|
+
npm run dev
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run DevLinker:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
devlinker
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Open:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
http://localhost:8001
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Done ✅
|
|
56
|
+
|
|
57
|
+
## 🧠 How DevLinker Works
|
|
58
|
+
|
|
59
|
+
Request flow:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
Browser -> DevLinker Proxy -> Frontend or Backend
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Routing rules:
|
|
66
|
+
|
|
67
|
+
- / routes to frontend (React/Vite)
|
|
68
|
+
- /api/* routes to backend (FastAPI/Flask/Node)
|
|
69
|
+
|
|
70
|
+
DevLinker acts as a smart proxy and optional tunnel layer for local, LAN, and public development links.
|
|
71
|
+
|
|
72
|
+
Architecture diagram:
|
|
73
|
+
|
|
74
|
+
```mermaid
|
|
75
|
+
flowchart LR
|
|
76
|
+
B[Browser / Mobile] --> P[DevLinker Proxy]
|
|
77
|
+
P --> F[Frontend Dev Server\nVite/React]
|
|
78
|
+
P --> A[Backend API\nFastAPI/Flask/Node]
|
|
79
|
+
P --> T[Optional Tunnel\nCloudflare/ngrok]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 🎯 Use Cases
|
|
83
|
+
|
|
84
|
+
- Test APIs and UI flows on mobile devices over WLAN
|
|
85
|
+
- Share local work instantly with teammates using one public URL
|
|
86
|
+
- Debug frontend-backend integration from a single entrypoint
|
|
87
|
+
- Reduce CORS/preflight issues during development
|
|
88
|
+
|
|
89
|
+
## 🖼️ Demo & Screenshots
|
|
90
|
+
|
|
91
|
+
- Terminal startup output: add screenshot at docs/images/terminal-startup.png
|
|
92
|
+
- Browser app via proxy: add screenshot at docs/images/browser-proxy.png
|
|
93
|
+
- Public URL share demo: add screenshot at docs/images/public-url.png
|
|
94
|
+
|
|
25
95
|
|
|
26
96
|
## Features
|
|
27
97
|
|
|
@@ -36,6 +106,8 @@ Dev Linker starts your local development stack and routes frontend and backend t
|
|
|
36
106
|
- 🌍 **Public Sharing:** Share your local dev environment instantly with `--url` (startup) or `devlinker share` (runtime, no restart).
|
|
37
107
|
- 🔄 **Dynamic Tunnel Control:** `devlinker unshare` disables public tunnel at runtime.
|
|
38
108
|
- 📡 **WLAN Sharing:** Prints LAN URL for same-network device access.
|
|
109
|
+
- 🔒 **Secure Token Linking:** Optional token gate for LAN/public access with `DEVLINKER_LINK_TOKEN`.
|
|
110
|
+
- 📊 **Browser API Logs Dashboard:** Open `/__devlinker/dashboard` for lightweight live API visibility.
|
|
39
111
|
- 🧑💻 **Interactive CLI:** Modern, colorized, emoji-rich terminal UX for all commands.
|
|
40
112
|
- 🧩 **Zero Config:** Works out-of-the-box for most FastAPI, Flask, Vite, and Docker projects.
|
|
41
113
|
- 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
|
|
@@ -58,6 +130,7 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
58
130
|
- `devlinker support` — Show UPI support QR code in terminal
|
|
59
131
|
- `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
|
|
60
132
|
- `devlinker share` — Enable public tunnel at runtime (no restart)
|
|
133
|
+
- `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
|
|
61
134
|
- `devlinker unshare` — Disable public tunnel at runtime
|
|
62
135
|
- `devlinker doctor` — Diagnose issues, see categorized problems and fixes
|
|
63
136
|
- `devlinker fix` — Auto-fix common issues (env, API paths, config)
|
|
@@ -70,6 +143,30 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
70
143
|
- `devlinker --debug` — Enable debug mode (turns on live API request logger)
|
|
71
144
|
- `devlinker --version` — Show version
|
|
72
145
|
|
|
146
|
+
Security token (optional):
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
set DEVLINKER_LINK_TOKEN=your-secret-token
|
|
150
|
+
devlinker --url
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
When enabled, LAN/public requests must include one of:
|
|
154
|
+
- query param `dl_token=...`
|
|
155
|
+
- header `X-DevLinker-Token: ...`
|
|
156
|
+
- header `Authorization: Bearer ...`
|
|
157
|
+
|
|
158
|
+
Built-in API logs dashboard:
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
http://localhost:<proxy-port>/__devlinker/dashboard
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
JSON stream endpoint used by the dashboard:
|
|
165
|
+
|
|
166
|
+
```text
|
|
167
|
+
http://localhost:<proxy-port>/__devlinker/logs
|
|
168
|
+
```
|
|
169
|
+
|
|
73
170
|
## Project Structure
|
|
74
171
|
|
|
75
172
|
```text
|
|
@@ -182,14 +279,16 @@ devlinker --docker
|
|
|
182
279
|
|
|
183
280
|
## Tunnel and Sharing Modes
|
|
184
281
|
|
|
185
|
-
By default, DevLinker starts **fast local proxy only** (no tunnel).
|
|
282
|
+
By default, DevLinker starts **fast local proxy only** (no tunnel). It prints a LAN URL when it can detect a local network interface, and you can share that link with devices on the same Wi-Fi/LAN.
|
|
283
|
+
|
|
284
|
+
For access from another network, start with a public tunnel using the `--url` flag:
|
|
186
285
|
|
|
187
286
|
|
|
188
287
|
```bash
|
|
189
288
|
devlinker --url
|
|
190
289
|
```
|
|
191
290
|
|
|
192
|
-
This
|
|
291
|
+
This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
|
|
193
292
|
|
|
194
293
|
```text
|
|
195
294
|
🌍 Enabling public tunnel...
|
|
@@ -200,6 +299,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
|
|
|
200
299
|
ℹ Share this link with collaborators.
|
|
201
300
|
```
|
|
202
301
|
|
|
302
|
+
If you already started DevLinker and want to turn on sharing without restarting, use:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
devlinker share
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
If you use a custom proxy port, pass it explicitly:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
devlinker share --proxy-port 18000
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
If your friend is on the same Wi-Fi/LAN, use the printed LAN URL like `http://192.168.x.x:<proxy-port>`. If they are outside your network, use the public tunnel URL instead.
|
|
315
|
+
|
|
203
316
|
To force tunnel off (even if --url is passed):
|
|
204
317
|
|
|
205
318
|
```bash
|
|
@@ -2,6 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
Dev Linker starts your local development stack and routes frontend and backend traffic through one proxy URL, with optional LAN and public sharing.
|
|
4
4
|
|
|
5
|
+
## ⚡ Quick Start (2 Minutes)
|
|
6
|
+
|
|
7
|
+
Install:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install devlinker
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run your apps:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Backend (example)
|
|
17
|
+
uvicorn main:app --reload
|
|
18
|
+
|
|
19
|
+
# Frontend (example)
|
|
20
|
+
npm run dev
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run DevLinker:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
devlinker
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Open:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
http://localhost:8001
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Done ✅
|
|
36
|
+
|
|
37
|
+
## 🧠 How DevLinker Works
|
|
38
|
+
|
|
39
|
+
Request flow:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
Browser -> DevLinker Proxy -> Frontend or Backend
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Routing rules:
|
|
46
|
+
|
|
47
|
+
- / routes to frontend (React/Vite)
|
|
48
|
+
- /api/* routes to backend (FastAPI/Flask/Node)
|
|
49
|
+
|
|
50
|
+
DevLinker acts as a smart proxy and optional tunnel layer for local, LAN, and public development links.
|
|
51
|
+
|
|
52
|
+
Architecture diagram:
|
|
53
|
+
|
|
54
|
+
```mermaid
|
|
55
|
+
flowchart LR
|
|
56
|
+
B[Browser / Mobile] --> P[DevLinker Proxy]
|
|
57
|
+
P --> F[Frontend Dev Server\nVite/React]
|
|
58
|
+
P --> A[Backend API\nFastAPI/Flask/Node]
|
|
59
|
+
P --> T[Optional Tunnel\nCloudflare/ngrok]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 🎯 Use Cases
|
|
63
|
+
|
|
64
|
+
- Test APIs and UI flows on mobile devices over WLAN
|
|
65
|
+
- Share local work instantly with teammates using one public URL
|
|
66
|
+
- Debug frontend-backend integration from a single entrypoint
|
|
67
|
+
- Reduce CORS/preflight issues during development
|
|
68
|
+
|
|
69
|
+
## 🖼️ Demo & Screenshots
|
|
70
|
+
|
|
71
|
+
- Terminal startup output: add screenshot at docs/images/terminal-startup.png
|
|
72
|
+
- Browser app via proxy: add screenshot at docs/images/browser-proxy.png
|
|
73
|
+
- Public URL share demo: add screenshot at docs/images/public-url.png
|
|
74
|
+
|
|
5
75
|
|
|
6
76
|
## Features
|
|
7
77
|
|
|
@@ -16,6 +86,8 @@ Dev Linker starts your local development stack and routes frontend and backend t
|
|
|
16
86
|
- 🌍 **Public Sharing:** Share your local dev environment instantly with `--url` (startup) or `devlinker share` (runtime, no restart).
|
|
17
87
|
- 🔄 **Dynamic Tunnel Control:** `devlinker unshare` disables public tunnel at runtime.
|
|
18
88
|
- 📡 **WLAN Sharing:** Prints LAN URL for same-network device access.
|
|
89
|
+
- 🔒 **Secure Token Linking:** Optional token gate for LAN/public access with `DEVLINKER_LINK_TOKEN`.
|
|
90
|
+
- 📊 **Browser API Logs Dashboard:** Open `/__devlinker/dashboard` for lightweight live API visibility.
|
|
19
91
|
- 🧑💻 **Interactive CLI:** Modern, colorized, emoji-rich terminal UX for all commands.
|
|
20
92
|
- 🧩 **Zero Config:** Works out-of-the-box for most FastAPI, Flask, Vite, and Docker projects.
|
|
21
93
|
- 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
|
|
@@ -38,6 +110,7 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
38
110
|
- `devlinker support` — Show UPI support QR code in terminal
|
|
39
111
|
- `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
|
|
40
112
|
- `devlinker share` — Enable public tunnel at runtime (no restart)
|
|
113
|
+
- `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
|
|
41
114
|
- `devlinker unshare` — Disable public tunnel at runtime
|
|
42
115
|
- `devlinker doctor` — Diagnose issues, see categorized problems and fixes
|
|
43
116
|
- `devlinker fix` — Auto-fix common issues (env, API paths, config)
|
|
@@ -50,6 +123,30 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
50
123
|
- `devlinker --debug` — Enable debug mode (turns on live API request logger)
|
|
51
124
|
- `devlinker --version` — Show version
|
|
52
125
|
|
|
126
|
+
Security token (optional):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
set DEVLINKER_LINK_TOKEN=your-secret-token
|
|
130
|
+
devlinker --url
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
When enabled, LAN/public requests must include one of:
|
|
134
|
+
- query param `dl_token=...`
|
|
135
|
+
- header `X-DevLinker-Token: ...`
|
|
136
|
+
- header `Authorization: Bearer ...`
|
|
137
|
+
|
|
138
|
+
Built-in API logs dashboard:
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
http://localhost:<proxy-port>/__devlinker/dashboard
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
JSON stream endpoint used by the dashboard:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
http://localhost:<proxy-port>/__devlinker/logs
|
|
148
|
+
```
|
|
149
|
+
|
|
53
150
|
## Project Structure
|
|
54
151
|
|
|
55
152
|
```text
|
|
@@ -162,14 +259,16 @@ devlinker --docker
|
|
|
162
259
|
|
|
163
260
|
## Tunnel and Sharing Modes
|
|
164
261
|
|
|
165
|
-
By default, DevLinker starts **fast local proxy only** (no tunnel).
|
|
262
|
+
By default, DevLinker starts **fast local proxy only** (no tunnel). It prints a LAN URL when it can detect a local network interface, and you can share that link with devices on the same Wi-Fi/LAN.
|
|
263
|
+
|
|
264
|
+
For access from another network, start with a public tunnel using the `--url` flag:
|
|
166
265
|
|
|
167
266
|
|
|
168
267
|
```bash
|
|
169
268
|
devlinker --url
|
|
170
269
|
```
|
|
171
270
|
|
|
172
|
-
This
|
|
271
|
+
This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
|
|
173
272
|
|
|
174
273
|
```text
|
|
175
274
|
🌍 Enabling public tunnel...
|
|
@@ -180,6 +279,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
|
|
|
180
279
|
ℹ Share this link with collaborators.
|
|
181
280
|
```
|
|
182
281
|
|
|
282
|
+
If you already started DevLinker and want to turn on sharing without restarting, use:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
devlinker share
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
If you use a custom proxy port, pass it explicitly:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
devlinker share --proxy-port 18000
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
If your friend is on the same Wi-Fi/LAN, use the printed LAN URL like `http://192.168.x.x:<proxy-port>`. If they are outside your network, use the public tunnel URL instead.
|
|
295
|
+
|
|
183
296
|
To force tunnel off (even if --url is passed):
|
|
184
297
|
|
|
185
298
|
```bash
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import socket
|
|
4
5
|
import sys
|
|
5
6
|
import time
|
|
@@ -32,7 +33,7 @@ except ImportError: # pragma: no cover - fallback when rich is unavailable
|
|
|
32
33
|
|
|
33
34
|
from . import __version__
|
|
34
35
|
from .detector import check_port, detect_ports, is_vite_port
|
|
35
|
-
from .proxy import start_proxy
|
|
36
|
+
from .proxy import start_proxy, wait_for_proxy_startup
|
|
36
37
|
from .runner import detect_backend_port, start_servers
|
|
37
38
|
from .tunnel import start_tunnel
|
|
38
39
|
from .doctor import doctor
|
|
@@ -42,6 +43,7 @@ from .share import share, unshare
|
|
|
42
43
|
from .config import load_config
|
|
43
44
|
from .inspect import inspect
|
|
44
45
|
from .monitor import monitor
|
|
46
|
+
from .global_state import STATE
|
|
45
47
|
|
|
46
48
|
SUPPORT_UPI_ID = "devlinker@upi"
|
|
47
49
|
SUPPORT_UPI_LINK = "upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀"
|
|
@@ -193,6 +195,18 @@ def _with_ngrok_skip_warning(url: str) -> str:
|
|
|
193
195
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
|
194
196
|
|
|
195
197
|
|
|
198
|
+
def _with_link_token(url: str) -> str:
|
|
199
|
+
token = os.getenv("DEVLINKER_LINK_TOKEN", "").strip()
|
|
200
|
+
if not token:
|
|
201
|
+
return url
|
|
202
|
+
|
|
203
|
+
parts = urlsplit(url)
|
|
204
|
+
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
205
|
+
query["dl_token"] = token
|
|
206
|
+
new_query = urlencode(query)
|
|
207
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
|
208
|
+
|
|
209
|
+
|
|
196
210
|
|
|
197
211
|
def _print_summary(
|
|
198
212
|
frontend_port: int | None,
|
|
@@ -589,6 +603,7 @@ def _run_proxy(
|
|
|
589
603
|
)
|
|
590
604
|
|
|
591
605
|
proxy_port = _select_proxy_port(proxy_port)
|
|
606
|
+
STATE["proxy_port"] = proxy_port
|
|
592
607
|
_write_frontend_api_env(proxy_port)
|
|
593
608
|
|
|
594
609
|
if not live_status:
|
|
@@ -606,8 +621,10 @@ def _run_proxy(
|
|
|
606
621
|
enable_debug_logs=debug,
|
|
607
622
|
)
|
|
608
623
|
|
|
609
|
-
|
|
610
|
-
|
|
624
|
+
if not wait_for_proxy_startup(timeout=5.0):
|
|
625
|
+
raise click.ClickException(
|
|
626
|
+
f"Proxy failed to start on port {proxy_port}. Check whether the port is already in use."
|
|
627
|
+
)
|
|
611
628
|
|
|
612
629
|
if live_status:
|
|
613
630
|
live_status.update("Proxy", f"✔ Active ({proxy_port})", style="green")
|
|
@@ -616,12 +633,14 @@ def _run_proxy(
|
|
|
616
633
|
if lan_enabled:
|
|
617
634
|
local_ips = _get_local_ips()
|
|
618
635
|
if local_ips:
|
|
619
|
-
wlan_url = f"http://{local_ips[0]}:{proxy_port}"
|
|
636
|
+
wlan_url = _with_link_token(f"http://{local_ips[0]}:{proxy_port}")
|
|
620
637
|
_ui_status("✔", f"LAN share: {wlan_url}", style="green")
|
|
621
638
|
if len(local_ips) > 1:
|
|
622
|
-
alternative_urls = ", ".join(f"http://{ip}:{proxy_port}" for ip in local_ips[1:])
|
|
639
|
+
alternative_urls = ", ".join(_with_link_token(f"http://{ip}:{proxy_port}") for ip in local_ips[1:])
|
|
623
640
|
_ui_status("ℹ", f"Alternate LAN URLs: {alternative_urls}", style="blue")
|
|
624
641
|
_ui_status("ℹ", "Share with teammates on the same WiFi/LAN.", style="blue")
|
|
642
|
+
if os.getenv("DEVLINKER_LINK_TOKEN", "").strip():
|
|
643
|
+
_ui_status("🔒", "Token protection is ON for LAN/public traffic.", style="green")
|
|
625
644
|
_ui_status(
|
|
626
645
|
"⚠",
|
|
627
646
|
"Camera/mic may be blocked on HTTP. Use localhost or --url for HTTPS.",
|
|
@@ -653,7 +672,7 @@ def _run_proxy(
|
|
|
653
672
|
try:
|
|
654
673
|
_ui_status("🌍", "Enabling public tunnel...", style="green")
|
|
655
674
|
provider, public_url = start_tunnel(proxy_port)
|
|
656
|
-
warning_free_url = _with_ngrok_skip_warning(public_url)
|
|
675
|
+
warning_free_url = _with_link_token(_with_ngrok_skip_warning(public_url))
|
|
657
676
|
provider_label = "Cloudflare" if provider == "cloudflare" else "ngrok"
|
|
658
677
|
_ui_status("✔", f"Tunnel provider: {provider_label}", style="blue")
|
|
659
678
|
_ui_status("✔", f"Public URL: {warning_free_url}", style="cyan")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import os
|
|
4
5
|
import time
|
|
5
6
|
from typing import Dict, Optional
|
|
6
7
|
from urllib.parse import urlencode
|
|
@@ -9,7 +10,7 @@ import httpx
|
|
|
9
10
|
import uvicorn
|
|
10
11
|
import websockets
|
|
11
12
|
from fastapi import FastAPI, Request, Response, WebSocket
|
|
12
|
-
from fastapi.responses import PlainTextResponse
|
|
13
|
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
|
13
14
|
from starlette.websockets import WebSocketDisconnect
|
|
14
15
|
from websockets.exceptions import ConnectionClosed
|
|
15
16
|
|
|
@@ -24,6 +25,8 @@ _recent_lock = threading.Lock()
|
|
|
24
25
|
_printed_fixes = set()
|
|
25
26
|
_printed_live_header = False
|
|
26
27
|
LIVE_REQUEST_LOGGING_ENABLED = False
|
|
28
|
+
MAX_RECENT_REQUESTS = 200
|
|
29
|
+
PROXY_READY_EVENT = threading.Event()
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
def _format_request_context(path: str, method: str | None, status: int, target: str) -> str:
|
|
@@ -54,8 +57,34 @@ def _print_live_request_line(method: str, path: str, status: int, elapsed_ms: fl
|
|
|
54
57
|
_printed_live_header = True
|
|
55
58
|
print(f"{method.upper():<6} {path:<24} {status:<3} {elapsed_ms:.0f}ms")
|
|
56
59
|
|
|
60
|
+
|
|
61
|
+
def _configured_link_token() -> str | None:
|
|
62
|
+
token = os.getenv("DEVLINKER_LINK_TOKEN", "").strip()
|
|
63
|
+
return token or None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_presented_token(headers: Dict[str, str], query_params) -> str | None:
|
|
67
|
+
query_token = query_params.get("dl_token")
|
|
68
|
+
if query_token:
|
|
69
|
+
return query_token
|
|
70
|
+
direct_header = headers.get("x-devlinker-token", "").strip()
|
|
71
|
+
if direct_header:
|
|
72
|
+
return direct_header
|
|
73
|
+
auth_header = headers.get("authorization", "").strip()
|
|
74
|
+
bearer_prefix = "Bearer "
|
|
75
|
+
if auth_header.startswith(bearer_prefix):
|
|
76
|
+
return auth_header[len(bearer_prefix):].strip()
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_link_token_valid(expected_token: str | None, headers: Dict[str, str], query_params) -> bool:
|
|
81
|
+
if not expected_token:
|
|
82
|
+
return True
|
|
83
|
+
presented = _extract_presented_token(headers, query_params)
|
|
84
|
+
return bool(presented) and presented == expected_token
|
|
85
|
+
|
|
57
86
|
class RequestInspector:
|
|
58
|
-
def analyze(self, path, status, target, method=None, response_text=None):
|
|
87
|
+
def analyze(self, path, status, target, method=None, response_text=None, elapsed_ms=None):
|
|
59
88
|
warnings = []
|
|
60
89
|
normalized_method = method.upper() if method else ""
|
|
61
90
|
is_root_document_request = path == "/" and normalized_method in {"GET", "HEAD", "OPTIONS"}
|
|
@@ -87,14 +116,24 @@ class RequestInspector:
|
|
|
87
116
|
warnings.append(issue)
|
|
88
117
|
# Log request for inspector
|
|
89
118
|
with _recent_lock:
|
|
90
|
-
_recent_requests.append(
|
|
91
|
-
|
|
119
|
+
_recent_requests.append(
|
|
120
|
+
{
|
|
121
|
+
"ts": int(time.time() * 1000),
|
|
122
|
+
"method": normalized_method or "GET",
|
|
123
|
+
"path": path,
|
|
124
|
+
"status": status,
|
|
125
|
+
"target": target,
|
|
126
|
+
"latency_ms": round(float(elapsed_ms), 1) if elapsed_ms is not None else None,
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
if len(_recent_requests) > MAX_RECENT_REQUESTS:
|
|
92
130
|
_recent_requests.pop(0)
|
|
93
131
|
return warnings
|
|
94
132
|
|
|
95
133
|
FRONTEND: Optional[int] = None
|
|
96
134
|
BACKEND: Optional[int] = None
|
|
97
135
|
HTTP_CLIENT: Optional[httpx.AsyncClient] = None
|
|
136
|
+
UPSTREAM_HOST_CANDIDATES: tuple[str, ...] = ("127.0.0.1", "localhost", "::1")
|
|
98
137
|
|
|
99
138
|
HOP_BY_HOP_HEADERS = {
|
|
100
139
|
"connection",
|
|
@@ -137,6 +176,7 @@ def _apply_cors_headers(headers: Dict[str, str], request: Request) -> Dict[str,
|
|
|
137
176
|
async def _on_startup() -> None:
|
|
138
177
|
global HTTP_CLIENT
|
|
139
178
|
HTTP_CLIENT = httpx.AsyncClient(timeout=15.0, follow_redirects=False)
|
|
179
|
+
PROXY_READY_EVENT.set()
|
|
140
180
|
|
|
141
181
|
|
|
142
182
|
@app.on_event("shutdown")
|
|
@@ -145,6 +185,7 @@ async def _on_shutdown() -> None:
|
|
|
145
185
|
if HTTP_CLIENT is not None:
|
|
146
186
|
await HTTP_CLIENT.aclose()
|
|
147
187
|
HTTP_CLIENT = None
|
|
188
|
+
PROXY_READY_EVENT.clear()
|
|
148
189
|
|
|
149
190
|
|
|
150
191
|
def _connection_header_tokens(headers: Dict[str, str]) -> set[str]:
|
|
@@ -188,22 +229,38 @@ def _target_port(path: str) -> Optional[int]:
|
|
|
188
229
|
return FRONTEND
|
|
189
230
|
|
|
190
231
|
|
|
191
|
-
def
|
|
232
|
+
def _format_host_for_url(host: str) -> str:
|
|
233
|
+
if ":" in host and not host.startswith("["):
|
|
234
|
+
return f"[{host}]"
|
|
235
|
+
return host
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _build_target_http_url(
|
|
239
|
+
port: int,
|
|
240
|
+
path: str,
|
|
241
|
+
query_params: list[tuple[str, str]],
|
|
242
|
+
host: str = "127.0.0.1",
|
|
243
|
+
) -> str:
|
|
192
244
|
query_string = urlencode(query_params, doseq=True)
|
|
193
|
-
base_url = f"http://
|
|
245
|
+
base_url = f"http://{_format_host_for_url(host)}:{port}{path}"
|
|
194
246
|
if not query_string:
|
|
195
247
|
return base_url
|
|
196
248
|
return f"{base_url}?{query_string}"
|
|
197
249
|
|
|
198
250
|
|
|
199
|
-
def _build_target_ws_url(port: int, path: str, query: str) -> str:
|
|
200
|
-
base_url = f"ws://
|
|
251
|
+
def _build_target_ws_url(port: int, path: str, query: str, host: str = "127.0.0.1") -> str:
|
|
252
|
+
base_url = f"ws://{_format_host_for_url(host)}:{port}{path}"
|
|
201
253
|
if not query:
|
|
202
254
|
return base_url
|
|
203
255
|
return f"{base_url}?{query}"
|
|
204
256
|
|
|
205
257
|
|
|
206
258
|
async def _forward_http(request: Request) -> Response:
|
|
259
|
+
if request.url.path == "/__devlinker/logs":
|
|
260
|
+
return await logs_dashboard_data()
|
|
261
|
+
if request.url.path == "/__devlinker/dashboard":
|
|
262
|
+
return await logs_dashboard_page()
|
|
263
|
+
|
|
207
264
|
if request.method == "OPTIONS" and request.headers.get("access-control-request-method"):
|
|
208
265
|
return Response(
|
|
209
266
|
status_code=204,
|
|
@@ -267,6 +324,13 @@ async def _forward_http(request: Request) -> Response:
|
|
|
267
324
|
is_lan = mode == "lan"
|
|
268
325
|
is_public = mode == "public"
|
|
269
326
|
is_secure = _is_secure_request(request, host_header)
|
|
327
|
+
required_link_token = _configured_link_token()
|
|
328
|
+
if (is_lan or is_public) and not _is_link_token_valid(required_link_token, dict(request.headers), request.query_params):
|
|
329
|
+
return PlainTextResponse(
|
|
330
|
+
"Unauthorized link: include dl_token query or X-DevLinker-Token header.",
|
|
331
|
+
status_code=401,
|
|
332
|
+
headers=_apply_cors_headers(_apply_security_headers({}), request),
|
|
333
|
+
)
|
|
270
334
|
is_instant = request.headers.get("x-devlinker-instant") == "1"
|
|
271
335
|
accept_header = request.headers.get("accept", "")
|
|
272
336
|
sec_fetch_dest = request.headers.get("sec-fetch-dest", "")
|
|
@@ -338,16 +402,33 @@ async def _forward_http(request: Request) -> Response:
|
|
|
338
402
|
|
|
339
403
|
payload = await request.body()
|
|
340
404
|
query_params = list(request.query_params.multi_items())
|
|
341
|
-
target_url = _build_target_http_url(target_port, request.url.path, query_params)
|
|
342
405
|
started_at = time.perf_counter()
|
|
343
406
|
|
|
344
407
|
try:
|
|
345
|
-
upstream =
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
408
|
+
upstream = None
|
|
409
|
+
last_exc: httpx.RequestError | None = None
|
|
410
|
+
for upstream_host in UPSTREAM_HOST_CANDIDATES:
|
|
411
|
+
target_url = _build_target_http_url(
|
|
412
|
+
target_port,
|
|
413
|
+
request.url.path,
|
|
414
|
+
query_params,
|
|
415
|
+
host=upstream_host,
|
|
416
|
+
)
|
|
417
|
+
try:
|
|
418
|
+
upstream = await HTTP_CLIENT.request(
|
|
419
|
+
method=request.method,
|
|
420
|
+
url=target_url,
|
|
421
|
+
content=payload,
|
|
422
|
+
headers=_filter_request_headers(dict(request.headers)),
|
|
423
|
+
)
|
|
424
|
+
break
|
|
425
|
+
except httpx.RequestError as exc:
|
|
426
|
+
last_exc = exc
|
|
427
|
+
|
|
428
|
+
if upstream is None and last_exc is not None:
|
|
429
|
+
raise last_exc
|
|
430
|
+
if upstream is None:
|
|
431
|
+
raise RuntimeError("Unexpected empty upstream response")
|
|
351
432
|
except httpx.RequestError as exc:
|
|
352
433
|
status = 502
|
|
353
434
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
|
@@ -376,7 +457,8 @@ async def _forward_http(request: Request) -> Response:
|
|
|
376
457
|
upstream.status_code,
|
|
377
458
|
target_name,
|
|
378
459
|
method=request.method,
|
|
379
|
-
response_text=upstream.text
|
|
460
|
+
response_text=upstream.text,
|
|
461
|
+
elapsed_ms=elapsed_ms,
|
|
380
462
|
)
|
|
381
463
|
context = _format_request_context(request.url.path, request.method, upstream.status_code, target_name)
|
|
382
464
|
# Only print routing warnings for error responses or /api paths
|
|
@@ -435,6 +517,11 @@ async def _forward_http(request: Request) -> Response:
|
|
|
435
517
|
|
|
436
518
|
|
|
437
519
|
async def _proxy_websocket(websocket: WebSocket) -> None:
|
|
520
|
+
required_link_token = _configured_link_token()
|
|
521
|
+
if required_link_token and not _is_link_token_valid(required_link_token, dict(websocket.headers), websocket.query_params):
|
|
522
|
+
await websocket.close(code=1008)
|
|
523
|
+
return
|
|
524
|
+
|
|
438
525
|
target_port = _target_port(websocket.url.path)
|
|
439
526
|
if target_port is None:
|
|
440
527
|
await websocket.close(code=1013)
|
|
@@ -529,6 +616,102 @@ async def websocket_proxy(websocket: WebSocket, path: str) -> None: # noqa: ARG
|
|
|
529
616
|
await _proxy_websocket(websocket)
|
|
530
617
|
|
|
531
618
|
|
|
619
|
+
@app.get("/__devlinker/logs")
|
|
620
|
+
async def logs_dashboard_data() -> JSONResponse:
|
|
621
|
+
with _recent_lock:
|
|
622
|
+
records = list(_recent_requests[-100:])
|
|
623
|
+
return JSONResponse({"count": len(records), "items": records})
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@app.get("/__devlinker/dashboard")
|
|
627
|
+
async def logs_dashboard_page() -> HTMLResponse:
|
|
628
|
+
html = """<!doctype html>
|
|
629
|
+
<html>
|
|
630
|
+
<head>
|
|
631
|
+
<meta charset=\"utf-8\" />
|
|
632
|
+
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />
|
|
633
|
+
<title>DevLinker API Logs</title>
|
|
634
|
+
<style>
|
|
635
|
+
:root { --bg:#f4f7fb; --card:#ffffff; --ink:#0f172a; --muted:#64748b; --ok:#065f46; --warn:#92400e; --err:#991b1b; --line:#dbe3ee; }
|
|
636
|
+
body { margin:0; font-family:\"Segoe UI\",\"Trebuchet MS\",sans-serif; background: radial-gradient(circle at top left,#e7f1ff,transparent 45%), var(--bg); color:var(--ink); }
|
|
637
|
+
.wrap { max-width: 1100px; margin: 28px auto; padding: 0 16px; }
|
|
638
|
+
.card { background: var(--card); border:1px solid var(--line); border-radius:14px; box-shadow: 0 10px 25px rgba(15,23,42,.06); overflow:hidden; }
|
|
639
|
+
h1 { margin:0; font-size: 1.4rem; }
|
|
640
|
+
.head { padding: 14px 16px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--line); }
|
|
641
|
+
.meta { color:var(--muted); font-size:.9rem; }
|
|
642
|
+
table { width:100%; border-collapse: collapse; }
|
|
643
|
+
th,td { padding:10px 12px; border-bottom:1px solid var(--line); text-align:left; font-size:.9rem; }
|
|
644
|
+
th { color:var(--muted); font-weight:600; }
|
|
645
|
+
.s2 { color: var(--ok); font-weight: 700; }
|
|
646
|
+
.s4 { color: var(--warn); font-weight: 700; }
|
|
647
|
+
.s5 { color: var(--err); font-weight: 700; }
|
|
648
|
+
.path { font-family:Consolas, monospace; }
|
|
649
|
+
.empty { padding: 20px; color: var(--muted); }
|
|
650
|
+
</style>
|
|
651
|
+
</head>
|
|
652
|
+
<body>
|
|
653
|
+
<div class=\"wrap\">
|
|
654
|
+
<div class=\"card\">
|
|
655
|
+
<div class=\"head\">
|
|
656
|
+
<h1>API Logs Dashboard</h1>
|
|
657
|
+
<div class=\"meta\" id=\"meta\">Waiting for traffic...</div>
|
|
658
|
+
</div>
|
|
659
|
+
<div id=\"content\" class=\"empty\">No requests yet.</div>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
<script>
|
|
663
|
+
function statusClass(code){
|
|
664
|
+
if(code >= 500) return 's5';
|
|
665
|
+
if(code >= 400) return 's4';
|
|
666
|
+
return 's2';
|
|
667
|
+
}
|
|
668
|
+
function ago(ms){
|
|
669
|
+
const d = Date.now() - ms;
|
|
670
|
+
if (d < 1000) return 'now';
|
|
671
|
+
if (d < 60000) return Math.floor(d/1000) + 's ago';
|
|
672
|
+
return Math.floor(d/60000) + 'm ago';
|
|
673
|
+
}
|
|
674
|
+
function render(items){
|
|
675
|
+
const content = document.getElementById('content');
|
|
676
|
+
const meta = document.getElementById('meta');
|
|
677
|
+
if(!items.length){
|
|
678
|
+
content.className = 'empty';
|
|
679
|
+
content.textContent = 'No requests yet.';
|
|
680
|
+
meta.textContent = 'Waiting for traffic...';
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const rows = items.slice().reverse().map(item => {
|
|
684
|
+
const status = Number(item.status || 0);
|
|
685
|
+
const lat = item.latency_ms == null ? '-' : item.latency_ms + 'ms';
|
|
686
|
+
return '<tr>' +
|
|
687
|
+
'<td>' + (item.method || '-') + '</td>' +
|
|
688
|
+
'<td class="path">' + (item.path || '-') + '</td>' +
|
|
689
|
+
'<td><span class="' + statusClass(status) + '">' + status + '</span></td>' +
|
|
690
|
+
'<td>' + (item.target || '-') + '</td>' +
|
|
691
|
+
'<td>' + lat + '</td>' +
|
|
692
|
+
'<td>' + (item.ts ? ago(item.ts) : '-') + '</td>' +
|
|
693
|
+
'</tr>';
|
|
694
|
+
}).join('');
|
|
695
|
+
content.className = '';
|
|
696
|
+
content.innerHTML = '<table><thead><tr><th>Method</th><th>Path</th><th>Status</th><th>Target</th><th>Latency</th><th>When</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
697
|
+
meta.textContent = items.length + ' requests captured';
|
|
698
|
+
}
|
|
699
|
+
async function tick(){
|
|
700
|
+
try{
|
|
701
|
+
const resp = await fetch('/__devlinker/logs', {cache:'no-store'});
|
|
702
|
+
const data = await resp.json();
|
|
703
|
+
render(Array.isArray(data.items) ? data.items : []);
|
|
704
|
+
}catch(_){
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
tick();
|
|
708
|
+
setInterval(tick, 1500);
|
|
709
|
+
</script>
|
|
710
|
+
</body>
|
|
711
|
+
</html>"""
|
|
712
|
+
return HTMLResponse(html)
|
|
713
|
+
|
|
714
|
+
|
|
532
715
|
def start_proxy(
|
|
533
716
|
frontend_port: Optional[int],
|
|
534
717
|
backend_port: int,
|
|
@@ -540,9 +723,14 @@ def start_proxy(
|
|
|
540
723
|
BACKEND = backend_port
|
|
541
724
|
LIVE_REQUEST_LOGGING_ENABLED = enable_debug_logs
|
|
542
725
|
_printed_live_header = False
|
|
726
|
+
PROXY_READY_EVENT.clear()
|
|
543
727
|
|
|
544
728
|
thread = threading.Thread(
|
|
545
729
|
target=lambda: uvicorn.run(app, host="0.0.0.0", port=proxy_port, log_level="warning"),
|
|
546
730
|
daemon=True,
|
|
547
731
|
)
|
|
548
732
|
thread.start()
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def wait_for_proxy_startup(timeout: float = 5.0) -> bool:
|
|
736
|
+
return PROXY_READY_EVENT.wait(timeout)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import requests
|
|
3
|
+
|
|
4
|
+
from devlinker.global_state import STATE
|
|
5
|
+
from devlinker.tunnel import start_tunnel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_COMMON_PROXY_PORTS = (8000, 8001, 8002, 8003, 8004, 8005, 8006, 8007, 8008, 8009, 8010, 18000)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_devlinker_proxy(port: int) -> bool:
|
|
12
|
+
try:
|
|
13
|
+
response = requests.get(f"http://127.0.0.1:{port}/__devlinker/dashboard", timeout=0.5)
|
|
14
|
+
except requests.RequestException:
|
|
15
|
+
return False
|
|
16
|
+
return response.status_code == 200 and "API Logs Dashboard" in response.text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_proxy_port(requested_port: int | None) -> int:
|
|
20
|
+
if requested_port is not None:
|
|
21
|
+
if _is_devlinker_proxy(requested_port):
|
|
22
|
+
return requested_port
|
|
23
|
+
raise click.ClickException(
|
|
24
|
+
f"No DevLinker proxy is running on port {requested_port}. Start devlinker first, or pass the correct --proxy-port."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
for candidate in _COMMON_PROXY_PORTS:
|
|
28
|
+
if _is_devlinker_proxy(candidate):
|
|
29
|
+
return candidate
|
|
30
|
+
|
|
31
|
+
raise click.ClickException(
|
|
32
|
+
"No running DevLinker proxy was found. Start devlinker first, or pass --proxy-port <port>."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@click.command()
|
|
36
|
+
@click.option("--proxy-port", type=int, default=None, help="Proxy port to tunnel. Auto-detect when omitted.")
|
|
37
|
+
def share(proxy_port: int | None):
|
|
38
|
+
"""Enable public tunnel at runtime (no restart)."""
|
|
39
|
+
if STATE["tunnel"]:
|
|
40
|
+
click.secho("⚠️ Already shared", fg="yellow")
|
|
41
|
+
return
|
|
42
|
+
try:
|
|
43
|
+
resolved_proxy_port = _resolve_proxy_port(proxy_port)
|
|
44
|
+
STATE["proxy_port"] = resolved_proxy_port
|
|
45
|
+
provider, url = start_tunnel(resolved_proxy_port)
|
|
46
|
+
STATE["tunnel"] = url
|
|
47
|
+
click.secho("\n🌍 Public Sharing Enabled\n" + ("─" * 24), fg="green", bold=True)
|
|
48
|
+
click.secho("✔ Tunnel connected", fg="green")
|
|
49
|
+
click.secho(f"\nPublic URL:\n{url}\n", fg="cyan", bold=True)
|
|
50
|
+
click.secho("📤 Share this link with your team", fg="magenta")
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
click.secho(f"[WARN] Tunnel failed: {exc}", fg="red")
|
|
53
|
+
click.secho("[INFO] Next step: install cloudflared or configure ngrok auth.", fg="yellow")
|
|
54
|
+
|
|
55
|
+
@click.command()
|
|
56
|
+
def unshare():
|
|
57
|
+
"""Disable public tunnel at runtime (no restart)."""
|
|
58
|
+
if not STATE["tunnel"]:
|
|
59
|
+
click.secho("⚠️ No active tunnel", fg="yellow")
|
|
60
|
+
return
|
|
61
|
+
from devlinker.tunnel import stop_tunnel
|
|
62
|
+
stop_tunnel()
|
|
63
|
+
STATE["tunnel"] = None
|
|
64
|
+
click.secho("🛑 Sharing stopped", fg="red", bold=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlinker
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
4
4
|
Summary: A lightweight proxy that combines your frontend and backend into one link for easy development and sharing.
|
|
5
5
|
Author-email: Mani <mani1028@users.noreply.github.com>
|
|
6
6
|
Requires-Python: >=3.7
|
|
@@ -22,6 +22,76 @@ Dynamic: license-file
|
|
|
22
22
|
|
|
23
23
|
Dev Linker starts your local development stack and routes frontend and backend traffic through one proxy URL, with optional LAN and public sharing.
|
|
24
24
|
|
|
25
|
+
## ⚡ Quick Start (2 Minutes)
|
|
26
|
+
|
|
27
|
+
Install:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install devlinker
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Run your apps:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Backend (example)
|
|
37
|
+
uvicorn main:app --reload
|
|
38
|
+
|
|
39
|
+
# Frontend (example)
|
|
40
|
+
npm run dev
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run DevLinker:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
devlinker
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Open:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
http://localhost:8001
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Done ✅
|
|
56
|
+
|
|
57
|
+
## 🧠 How DevLinker Works
|
|
58
|
+
|
|
59
|
+
Request flow:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
Browser -> DevLinker Proxy -> Frontend or Backend
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Routing rules:
|
|
66
|
+
|
|
67
|
+
- / routes to frontend (React/Vite)
|
|
68
|
+
- /api/* routes to backend (FastAPI/Flask/Node)
|
|
69
|
+
|
|
70
|
+
DevLinker acts as a smart proxy and optional tunnel layer for local, LAN, and public development links.
|
|
71
|
+
|
|
72
|
+
Architecture diagram:
|
|
73
|
+
|
|
74
|
+
```mermaid
|
|
75
|
+
flowchart LR
|
|
76
|
+
B[Browser / Mobile] --> P[DevLinker Proxy]
|
|
77
|
+
P --> F[Frontend Dev Server\nVite/React]
|
|
78
|
+
P --> A[Backend API\nFastAPI/Flask/Node]
|
|
79
|
+
P --> T[Optional Tunnel\nCloudflare/ngrok]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 🎯 Use Cases
|
|
83
|
+
|
|
84
|
+
- Test APIs and UI flows on mobile devices over WLAN
|
|
85
|
+
- Share local work instantly with teammates using one public URL
|
|
86
|
+
- Debug frontend-backend integration from a single entrypoint
|
|
87
|
+
- Reduce CORS/preflight issues during development
|
|
88
|
+
|
|
89
|
+
## 🖼️ Demo & Screenshots
|
|
90
|
+
|
|
91
|
+
- Terminal startup output: add screenshot at docs/images/terminal-startup.png
|
|
92
|
+
- Browser app via proxy: add screenshot at docs/images/browser-proxy.png
|
|
93
|
+
- Public URL share demo: add screenshot at docs/images/public-url.png
|
|
94
|
+
|
|
25
95
|
|
|
26
96
|
## Features
|
|
27
97
|
|
|
@@ -36,6 +106,8 @@ Dev Linker starts your local development stack and routes frontend and backend t
|
|
|
36
106
|
- 🌍 **Public Sharing:** Share your local dev environment instantly with `--url` (startup) or `devlinker share` (runtime, no restart).
|
|
37
107
|
- 🔄 **Dynamic Tunnel Control:** `devlinker unshare` disables public tunnel at runtime.
|
|
38
108
|
- 📡 **WLAN Sharing:** Prints LAN URL for same-network device access.
|
|
109
|
+
- 🔒 **Secure Token Linking:** Optional token gate for LAN/public access with `DEVLINKER_LINK_TOKEN`.
|
|
110
|
+
- 📊 **Browser API Logs Dashboard:** Open `/__devlinker/dashboard` for lightweight live API visibility.
|
|
39
111
|
- 🧑💻 **Interactive CLI:** Modern, colorized, emoji-rich terminal UX for all commands.
|
|
40
112
|
- 🧩 **Zero Config:** Works out-of-the-box for most FastAPI, Flask, Vite, and Docker projects.
|
|
41
113
|
- 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
|
|
@@ -58,6 +130,7 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
58
130
|
- `devlinker support` — Show UPI support QR code in terminal
|
|
59
131
|
- `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
|
|
60
132
|
- `devlinker share` — Enable public tunnel at runtime (no restart)
|
|
133
|
+
- `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
|
|
61
134
|
- `devlinker unshare` — Disable public tunnel at runtime
|
|
62
135
|
- `devlinker doctor` — Diagnose issues, see categorized problems and fixes
|
|
63
136
|
- `devlinker fix` — Auto-fix common issues (env, API paths, config)
|
|
@@ -70,6 +143,30 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
70
143
|
- `devlinker --debug` — Enable debug mode (turns on live API request logger)
|
|
71
144
|
- `devlinker --version` — Show version
|
|
72
145
|
|
|
146
|
+
Security token (optional):
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
set DEVLINKER_LINK_TOKEN=your-secret-token
|
|
150
|
+
devlinker --url
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
When enabled, LAN/public requests must include one of:
|
|
154
|
+
- query param `dl_token=...`
|
|
155
|
+
- header `X-DevLinker-Token: ...`
|
|
156
|
+
- header `Authorization: Bearer ...`
|
|
157
|
+
|
|
158
|
+
Built-in API logs dashboard:
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
http://localhost:<proxy-port>/__devlinker/dashboard
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
JSON stream endpoint used by the dashboard:
|
|
165
|
+
|
|
166
|
+
```text
|
|
167
|
+
http://localhost:<proxy-port>/__devlinker/logs
|
|
168
|
+
```
|
|
169
|
+
|
|
73
170
|
## Project Structure
|
|
74
171
|
|
|
75
172
|
```text
|
|
@@ -182,14 +279,16 @@ devlinker --docker
|
|
|
182
279
|
|
|
183
280
|
## Tunnel and Sharing Modes
|
|
184
281
|
|
|
185
|
-
By default, DevLinker starts **fast local proxy only** (no tunnel).
|
|
282
|
+
By default, DevLinker starts **fast local proxy only** (no tunnel). It prints a LAN URL when it can detect a local network interface, and you can share that link with devices on the same Wi-Fi/LAN.
|
|
283
|
+
|
|
284
|
+
For access from another network, start with a public tunnel using the `--url` flag:
|
|
186
285
|
|
|
187
286
|
|
|
188
287
|
```bash
|
|
189
288
|
devlinker --url
|
|
190
289
|
```
|
|
191
290
|
|
|
192
|
-
This
|
|
291
|
+
This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
|
|
193
292
|
|
|
194
293
|
```text
|
|
195
294
|
🌍 Enabling public tunnel...
|
|
@@ -200,6 +299,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
|
|
|
200
299
|
ℹ Share this link with collaborators.
|
|
201
300
|
```
|
|
202
301
|
|
|
302
|
+
If you already started DevLinker and want to turn on sharing without restarting, use:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
devlinker share
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
If you use a custom proxy port, pass it explicitly:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
devlinker share --proxy-port 18000
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
If your friend is on the same Wi-Fi/LAN, use the printed LAN URL like `http://192.168.x.x:<proxy-port>`. If they are outside your network, use the public tunnel URL instead.
|
|
315
|
+
|
|
203
316
|
To force tunnel off (even if --url is passed):
|
|
204
317
|
|
|
205
318
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devlinker"
|
|
7
|
-
version = "1.4.
|
|
7
|
+
version = "1.4.3"
|
|
8
8
|
description = "A lightweight proxy that combines your frontend and backend into one link for easy development and sharing."
|
|
9
9
|
authors = [
|
|
10
10
|
{ name = "Mani", email = "mani1028@users.noreply.github.com" }
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
import click
|
|
3
|
-
from devlinker.global_state import STATE
|
|
4
|
-
from devlinker.tunnel import start_tunnel
|
|
5
|
-
|
|
6
|
-
@click.command()
|
|
7
|
-
def share():
|
|
8
|
-
"""Enable public tunnel at runtime (no restart)."""
|
|
9
|
-
if STATE["tunnel"]:
|
|
10
|
-
click.secho("⚠️ Already shared", fg="yellow")
|
|
11
|
-
return
|
|
12
|
-
try:
|
|
13
|
-
provider, url = start_tunnel(STATE["proxy_port"])
|
|
14
|
-
STATE["tunnel"] = url
|
|
15
|
-
click.secho("\n🌍 Public Sharing Enabled\n" + ("─" * 24), fg="green", bold=True)
|
|
16
|
-
click.secho("✔ Tunnel connected", fg="green")
|
|
17
|
-
click.secho(f"\nPublic URL:\n{url}\n", fg="cyan", bold=True)
|
|
18
|
-
click.secho("📤 Share this link with your team", fg="magenta")
|
|
19
|
-
except Exception as exc:
|
|
20
|
-
click.secho(f"[WARN] Tunnel failed: {exc}", fg="red")
|
|
21
|
-
click.secho("[INFO] Next step: install cloudflared or configure ngrok auth.", fg="yellow")
|
|
22
|
-
|
|
23
|
-
@click.command()
|
|
24
|
-
def unshare():
|
|
25
|
-
"""Disable public tunnel at runtime (no restart)."""
|
|
26
|
-
if not STATE["tunnel"]:
|
|
27
|
-
click.secho("⚠️ No active tunnel", fg="yellow")
|
|
28
|
-
return
|
|
29
|
-
from devlinker.tunnel import stop_tunnel
|
|
30
|
-
stop_tunnel()
|
|
31
|
-
STATE["tunnel"] = None
|
|
32
|
-
click.secho("🛑 Sharing stopped", fg="red", bold=True)
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|