devlinker 1.4.1__tar.gz → 1.4.2__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.egg-info → devlinker-1.4.2}/PKG-INFO +97 -1
- {devlinker-1.4.1 → devlinker-1.4.2}/README.md +96 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/main.py +18 -3
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/proxy.py +196 -16
- {devlinker-1.4.1 → devlinker-1.4.2/devlinker.egg-info}/PKG-INFO +97 -1
- {devlinker-1.4.1 → devlinker-1.4.2}/pyproject.toml +1 -1
- {devlinker-1.4.1 → devlinker-1.4.2}/LICENSE +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/MANIFEST.in +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/__init__.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/config.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/detection_state.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/detector.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/detector_ai.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/devlinker_loader_instant.html +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/devlinker_loader_snippet.html +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/doctor.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/fix.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/fixer.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/global_state.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/inspect.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/logger.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/monitor.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/runner.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/share.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/tunnel.py +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/SOURCES.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/dependency_links.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/entry_points.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/requires.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/top_level.txt +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/setup.cfg +0 -0
- {devlinker-1.4.1 → devlinker-1.4.2}/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.2
|
|
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.
|
|
@@ -70,6 +142,30 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
70
142
|
- `devlinker --debug` — Enable debug mode (turns on live API request logger)
|
|
71
143
|
- `devlinker --version` — Show version
|
|
72
144
|
|
|
145
|
+
Security token (optional):
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
set DEVLINKER_LINK_TOKEN=your-secret-token
|
|
149
|
+
devlinker --url
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
When enabled, LAN/public requests must include one of:
|
|
153
|
+
- query param `dl_token=...`
|
|
154
|
+
- header `X-DevLinker-Token: ...`
|
|
155
|
+
- header `Authorization: Bearer ...`
|
|
156
|
+
|
|
157
|
+
Built-in API logs dashboard:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
http://localhost:<proxy-port>/__devlinker/dashboard
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
JSON stream endpoint used by the dashboard:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
http://localhost:<proxy-port>/__devlinker/logs
|
|
167
|
+
```
|
|
168
|
+
|
|
73
169
|
## Project Structure
|
|
74
170
|
|
|
75
171
|
```text
|
|
@@ -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.
|
|
@@ -50,6 +122,30 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
50
122
|
- `devlinker --debug` — Enable debug mode (turns on live API request logger)
|
|
51
123
|
- `devlinker --version` — Show version
|
|
52
124
|
|
|
125
|
+
Security token (optional):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
set DEVLINKER_LINK_TOKEN=your-secret-token
|
|
129
|
+
devlinker --url
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
When enabled, LAN/public requests must include one of:
|
|
133
|
+
- query param `dl_token=...`
|
|
134
|
+
- header `X-DevLinker-Token: ...`
|
|
135
|
+
- header `Authorization: Bearer ...`
|
|
136
|
+
|
|
137
|
+
Built-in API logs dashboard:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
http://localhost:<proxy-port>/__devlinker/dashboard
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
JSON stream endpoint used by the dashboard:
|
|
144
|
+
|
|
145
|
+
```text
|
|
146
|
+
http://localhost:<proxy-port>/__devlinker/logs
|
|
147
|
+
```
|
|
148
|
+
|
|
53
149
|
## Project Structure
|
|
54
150
|
|
|
55
151
|
```text
|
|
@@ -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
|
|
@@ -193,6 +194,18 @@ def _with_ngrok_skip_warning(url: str) -> str:
|
|
|
193
194
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
|
194
195
|
|
|
195
196
|
|
|
197
|
+
def _with_link_token(url: str) -> str:
|
|
198
|
+
token = os.getenv("DEVLINKER_LINK_TOKEN", "").strip()
|
|
199
|
+
if not token:
|
|
200
|
+
return url
|
|
201
|
+
|
|
202
|
+
parts = urlsplit(url)
|
|
203
|
+
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
204
|
+
query["dl_token"] = token
|
|
205
|
+
new_query = urlencode(query)
|
|
206
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
|
|
207
|
+
|
|
208
|
+
|
|
196
209
|
|
|
197
210
|
def _print_summary(
|
|
198
211
|
frontend_port: int | None,
|
|
@@ -616,12 +629,14 @@ def _run_proxy(
|
|
|
616
629
|
if lan_enabled:
|
|
617
630
|
local_ips = _get_local_ips()
|
|
618
631
|
if local_ips:
|
|
619
|
-
wlan_url = f"http://{local_ips[0]}:{proxy_port}"
|
|
632
|
+
wlan_url = _with_link_token(f"http://{local_ips[0]}:{proxy_port}")
|
|
620
633
|
_ui_status("✔", f"LAN share: {wlan_url}", style="green")
|
|
621
634
|
if len(local_ips) > 1:
|
|
622
|
-
alternative_urls = ", ".join(f"http://{ip}:{proxy_port}" for ip in local_ips[1:])
|
|
635
|
+
alternative_urls = ", ".join(_with_link_token(f"http://{ip}:{proxy_port}") for ip in local_ips[1:])
|
|
623
636
|
_ui_status("ℹ", f"Alternate LAN URLs: {alternative_urls}", style="blue")
|
|
624
637
|
_ui_status("ℹ", "Share with teammates on the same WiFi/LAN.", style="blue")
|
|
638
|
+
if os.getenv("DEVLINKER_LINK_TOKEN", "").strip():
|
|
639
|
+
_ui_status("🔒", "Token protection is ON for LAN/public traffic.", style="green")
|
|
625
640
|
_ui_status(
|
|
626
641
|
"⚠",
|
|
627
642
|
"Camera/mic may be blocked on HTTP. Use localhost or --url for HTTPS.",
|
|
@@ -653,7 +668,7 @@ def _run_proxy(
|
|
|
653
668
|
try:
|
|
654
669
|
_ui_status("🌍", "Enabling public tunnel...", style="green")
|
|
655
670
|
provider, public_url = start_tunnel(proxy_port)
|
|
656
|
-
warning_free_url = _with_ngrok_skip_warning(public_url)
|
|
671
|
+
warning_free_url = _with_link_token(_with_ngrok_skip_warning(public_url))
|
|
657
672
|
provider_label = "Cloudflare" if provider == "cloudflare" else "ngrok"
|
|
658
673
|
_ui_status("✔", f"Tunnel provider: {provider_label}", style="blue")
|
|
659
674
|
_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,7 @@ _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
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
def _format_request_context(path: str, method: str | None, status: int, target: str) -> str:
|
|
@@ -54,8 +56,34 @@ def _print_live_request_line(method: str, path: str, status: int, elapsed_ms: fl
|
|
|
54
56
|
_printed_live_header = True
|
|
55
57
|
print(f"{method.upper():<6} {path:<24} {status:<3} {elapsed_ms:.0f}ms")
|
|
56
58
|
|
|
59
|
+
|
|
60
|
+
def _configured_link_token() -> str | None:
|
|
61
|
+
token = os.getenv("DEVLINKER_LINK_TOKEN", "").strip()
|
|
62
|
+
return token or None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_presented_token(headers: Dict[str, str], query_params) -> str | None:
|
|
66
|
+
query_token = query_params.get("dl_token")
|
|
67
|
+
if query_token:
|
|
68
|
+
return query_token
|
|
69
|
+
direct_header = headers.get("x-devlinker-token", "").strip()
|
|
70
|
+
if direct_header:
|
|
71
|
+
return direct_header
|
|
72
|
+
auth_header = headers.get("authorization", "").strip()
|
|
73
|
+
bearer_prefix = "Bearer "
|
|
74
|
+
if auth_header.startswith(bearer_prefix):
|
|
75
|
+
return auth_header[len(bearer_prefix):].strip()
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_link_token_valid(expected_token: str | None, headers: Dict[str, str], query_params) -> bool:
|
|
80
|
+
if not expected_token:
|
|
81
|
+
return True
|
|
82
|
+
presented = _extract_presented_token(headers, query_params)
|
|
83
|
+
return bool(presented) and presented == expected_token
|
|
84
|
+
|
|
57
85
|
class RequestInspector:
|
|
58
|
-
def analyze(self, path, status, target, method=None, response_text=None):
|
|
86
|
+
def analyze(self, path, status, target, method=None, response_text=None, elapsed_ms=None):
|
|
59
87
|
warnings = []
|
|
60
88
|
normalized_method = method.upper() if method else ""
|
|
61
89
|
is_root_document_request = path == "/" and normalized_method in {"GET", "HEAD", "OPTIONS"}
|
|
@@ -87,14 +115,24 @@ class RequestInspector:
|
|
|
87
115
|
warnings.append(issue)
|
|
88
116
|
# Log request for inspector
|
|
89
117
|
with _recent_lock:
|
|
90
|
-
_recent_requests.append(
|
|
91
|
-
|
|
118
|
+
_recent_requests.append(
|
|
119
|
+
{
|
|
120
|
+
"ts": int(time.time() * 1000),
|
|
121
|
+
"method": normalized_method or "GET",
|
|
122
|
+
"path": path,
|
|
123
|
+
"status": status,
|
|
124
|
+
"target": target,
|
|
125
|
+
"latency_ms": round(float(elapsed_ms), 1) if elapsed_ms is not None else None,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
if len(_recent_requests) > MAX_RECENT_REQUESTS:
|
|
92
129
|
_recent_requests.pop(0)
|
|
93
130
|
return warnings
|
|
94
131
|
|
|
95
132
|
FRONTEND: Optional[int] = None
|
|
96
133
|
BACKEND: Optional[int] = None
|
|
97
134
|
HTTP_CLIENT: Optional[httpx.AsyncClient] = None
|
|
135
|
+
UPSTREAM_HOST_CANDIDATES: tuple[str, ...] = ("127.0.0.1", "localhost", "::1")
|
|
98
136
|
|
|
99
137
|
HOP_BY_HOP_HEADERS = {
|
|
100
138
|
"connection",
|
|
@@ -188,22 +226,38 @@ def _target_port(path: str) -> Optional[int]:
|
|
|
188
226
|
return FRONTEND
|
|
189
227
|
|
|
190
228
|
|
|
191
|
-
def
|
|
229
|
+
def _format_host_for_url(host: str) -> str:
|
|
230
|
+
if ":" in host and not host.startswith("["):
|
|
231
|
+
return f"[{host}]"
|
|
232
|
+
return host
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _build_target_http_url(
|
|
236
|
+
port: int,
|
|
237
|
+
path: str,
|
|
238
|
+
query_params: list[tuple[str, str]],
|
|
239
|
+
host: str = "127.0.0.1",
|
|
240
|
+
) -> str:
|
|
192
241
|
query_string = urlencode(query_params, doseq=True)
|
|
193
|
-
base_url = f"http://
|
|
242
|
+
base_url = f"http://{_format_host_for_url(host)}:{port}{path}"
|
|
194
243
|
if not query_string:
|
|
195
244
|
return base_url
|
|
196
245
|
return f"{base_url}?{query_string}"
|
|
197
246
|
|
|
198
247
|
|
|
199
|
-
def _build_target_ws_url(port: int, path: str, query: str) -> str:
|
|
200
|
-
base_url = f"ws://
|
|
248
|
+
def _build_target_ws_url(port: int, path: str, query: str, host: str = "127.0.0.1") -> str:
|
|
249
|
+
base_url = f"ws://{_format_host_for_url(host)}:{port}{path}"
|
|
201
250
|
if not query:
|
|
202
251
|
return base_url
|
|
203
252
|
return f"{base_url}?{query}"
|
|
204
253
|
|
|
205
254
|
|
|
206
255
|
async def _forward_http(request: Request) -> Response:
|
|
256
|
+
if request.url.path == "/__devlinker/logs":
|
|
257
|
+
return await logs_dashboard_data()
|
|
258
|
+
if request.url.path == "/__devlinker/dashboard":
|
|
259
|
+
return await logs_dashboard_page()
|
|
260
|
+
|
|
207
261
|
if request.method == "OPTIONS" and request.headers.get("access-control-request-method"):
|
|
208
262
|
return Response(
|
|
209
263
|
status_code=204,
|
|
@@ -267,6 +321,13 @@ async def _forward_http(request: Request) -> Response:
|
|
|
267
321
|
is_lan = mode == "lan"
|
|
268
322
|
is_public = mode == "public"
|
|
269
323
|
is_secure = _is_secure_request(request, host_header)
|
|
324
|
+
required_link_token = _configured_link_token()
|
|
325
|
+
if (is_lan or is_public) and not _is_link_token_valid(required_link_token, dict(request.headers), request.query_params):
|
|
326
|
+
return PlainTextResponse(
|
|
327
|
+
"Unauthorized link: include dl_token query or X-DevLinker-Token header.",
|
|
328
|
+
status_code=401,
|
|
329
|
+
headers=_apply_cors_headers(_apply_security_headers({}), request),
|
|
330
|
+
)
|
|
270
331
|
is_instant = request.headers.get("x-devlinker-instant") == "1"
|
|
271
332
|
accept_header = request.headers.get("accept", "")
|
|
272
333
|
sec_fetch_dest = request.headers.get("sec-fetch-dest", "")
|
|
@@ -338,16 +399,33 @@ async def _forward_http(request: Request) -> Response:
|
|
|
338
399
|
|
|
339
400
|
payload = await request.body()
|
|
340
401
|
query_params = list(request.query_params.multi_items())
|
|
341
|
-
target_url = _build_target_http_url(target_port, request.url.path, query_params)
|
|
342
402
|
started_at = time.perf_counter()
|
|
343
403
|
|
|
344
404
|
try:
|
|
345
|
-
upstream =
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
405
|
+
upstream = None
|
|
406
|
+
last_exc: httpx.RequestError | None = None
|
|
407
|
+
for upstream_host in UPSTREAM_HOST_CANDIDATES:
|
|
408
|
+
target_url = _build_target_http_url(
|
|
409
|
+
target_port,
|
|
410
|
+
request.url.path,
|
|
411
|
+
query_params,
|
|
412
|
+
host=upstream_host,
|
|
413
|
+
)
|
|
414
|
+
try:
|
|
415
|
+
upstream = await HTTP_CLIENT.request(
|
|
416
|
+
method=request.method,
|
|
417
|
+
url=target_url,
|
|
418
|
+
content=payload,
|
|
419
|
+
headers=_filter_request_headers(dict(request.headers)),
|
|
420
|
+
)
|
|
421
|
+
break
|
|
422
|
+
except httpx.RequestError as exc:
|
|
423
|
+
last_exc = exc
|
|
424
|
+
|
|
425
|
+
if upstream is None and last_exc is not None:
|
|
426
|
+
raise last_exc
|
|
427
|
+
if upstream is None:
|
|
428
|
+
raise RuntimeError("Unexpected empty upstream response")
|
|
351
429
|
except httpx.RequestError as exc:
|
|
352
430
|
status = 502
|
|
353
431
|
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
|
@@ -376,7 +454,8 @@ async def _forward_http(request: Request) -> Response:
|
|
|
376
454
|
upstream.status_code,
|
|
377
455
|
target_name,
|
|
378
456
|
method=request.method,
|
|
379
|
-
response_text=upstream.text
|
|
457
|
+
response_text=upstream.text,
|
|
458
|
+
elapsed_ms=elapsed_ms,
|
|
380
459
|
)
|
|
381
460
|
context = _format_request_context(request.url.path, request.method, upstream.status_code, target_name)
|
|
382
461
|
# Only print routing warnings for error responses or /api paths
|
|
@@ -435,6 +514,11 @@ async def _forward_http(request: Request) -> Response:
|
|
|
435
514
|
|
|
436
515
|
|
|
437
516
|
async def _proxy_websocket(websocket: WebSocket) -> None:
|
|
517
|
+
required_link_token = _configured_link_token()
|
|
518
|
+
if required_link_token and not _is_link_token_valid(required_link_token, dict(websocket.headers), websocket.query_params):
|
|
519
|
+
await websocket.close(code=1008)
|
|
520
|
+
return
|
|
521
|
+
|
|
438
522
|
target_port = _target_port(websocket.url.path)
|
|
439
523
|
if target_port is None:
|
|
440
524
|
await websocket.close(code=1013)
|
|
@@ -529,6 +613,102 @@ async def websocket_proxy(websocket: WebSocket, path: str) -> None: # noqa: ARG
|
|
|
529
613
|
await _proxy_websocket(websocket)
|
|
530
614
|
|
|
531
615
|
|
|
616
|
+
@app.get("/__devlinker/logs")
|
|
617
|
+
async def logs_dashboard_data() -> JSONResponse:
|
|
618
|
+
with _recent_lock:
|
|
619
|
+
records = list(_recent_requests[-100:])
|
|
620
|
+
return JSONResponse({"count": len(records), "items": records})
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@app.get("/__devlinker/dashboard")
|
|
624
|
+
async def logs_dashboard_page() -> HTMLResponse:
|
|
625
|
+
html = """<!doctype html>
|
|
626
|
+
<html>
|
|
627
|
+
<head>
|
|
628
|
+
<meta charset=\"utf-8\" />
|
|
629
|
+
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />
|
|
630
|
+
<title>DevLinker API Logs</title>
|
|
631
|
+
<style>
|
|
632
|
+
:root { --bg:#f4f7fb; --card:#ffffff; --ink:#0f172a; --muted:#64748b; --ok:#065f46; --warn:#92400e; --err:#991b1b; --line:#dbe3ee; }
|
|
633
|
+
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); }
|
|
634
|
+
.wrap { max-width: 1100px; margin: 28px auto; padding: 0 16px; }
|
|
635
|
+
.card { background: var(--card); border:1px solid var(--line); border-radius:14px; box-shadow: 0 10px 25px rgba(15,23,42,.06); overflow:hidden; }
|
|
636
|
+
h1 { margin:0; font-size: 1.4rem; }
|
|
637
|
+
.head { padding: 14px 16px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid var(--line); }
|
|
638
|
+
.meta { color:var(--muted); font-size:.9rem; }
|
|
639
|
+
table { width:100%; border-collapse: collapse; }
|
|
640
|
+
th,td { padding:10px 12px; border-bottom:1px solid var(--line); text-align:left; font-size:.9rem; }
|
|
641
|
+
th { color:var(--muted); font-weight:600; }
|
|
642
|
+
.s2 { color: var(--ok); font-weight: 700; }
|
|
643
|
+
.s4 { color: var(--warn); font-weight: 700; }
|
|
644
|
+
.s5 { color: var(--err); font-weight: 700; }
|
|
645
|
+
.path { font-family:Consolas, monospace; }
|
|
646
|
+
.empty { padding: 20px; color: var(--muted); }
|
|
647
|
+
</style>
|
|
648
|
+
</head>
|
|
649
|
+
<body>
|
|
650
|
+
<div class=\"wrap\">
|
|
651
|
+
<div class=\"card\">
|
|
652
|
+
<div class=\"head\">
|
|
653
|
+
<h1>API Logs Dashboard</h1>
|
|
654
|
+
<div class=\"meta\" id=\"meta\">Waiting for traffic...</div>
|
|
655
|
+
</div>
|
|
656
|
+
<div id=\"content\" class=\"empty\">No requests yet.</div>
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
<script>
|
|
660
|
+
function statusClass(code){
|
|
661
|
+
if(code >= 500) return 's5';
|
|
662
|
+
if(code >= 400) return 's4';
|
|
663
|
+
return 's2';
|
|
664
|
+
}
|
|
665
|
+
function ago(ms){
|
|
666
|
+
const d = Date.now() - ms;
|
|
667
|
+
if (d < 1000) return 'now';
|
|
668
|
+
if (d < 60000) return Math.floor(d/1000) + 's ago';
|
|
669
|
+
return Math.floor(d/60000) + 'm ago';
|
|
670
|
+
}
|
|
671
|
+
function render(items){
|
|
672
|
+
const content = document.getElementById('content');
|
|
673
|
+
const meta = document.getElementById('meta');
|
|
674
|
+
if(!items.length){
|
|
675
|
+
content.className = 'empty';
|
|
676
|
+
content.textContent = 'No requests yet.';
|
|
677
|
+
meta.textContent = 'Waiting for traffic...';
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const rows = items.slice().reverse().map(item => {
|
|
681
|
+
const status = Number(item.status || 0);
|
|
682
|
+
const lat = item.latency_ms == null ? '-' : item.latency_ms + 'ms';
|
|
683
|
+
return '<tr>' +
|
|
684
|
+
'<td>' + (item.method || '-') + '</td>' +
|
|
685
|
+
'<td class="path">' + (item.path || '-') + '</td>' +
|
|
686
|
+
'<td><span class="' + statusClass(status) + '">' + status + '</span></td>' +
|
|
687
|
+
'<td>' + (item.target || '-') + '</td>' +
|
|
688
|
+
'<td>' + lat + '</td>' +
|
|
689
|
+
'<td>' + (item.ts ? ago(item.ts) : '-') + '</td>' +
|
|
690
|
+
'</tr>';
|
|
691
|
+
}).join('');
|
|
692
|
+
content.className = '';
|
|
693
|
+
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>';
|
|
694
|
+
meta.textContent = items.length + ' requests captured';
|
|
695
|
+
}
|
|
696
|
+
async function tick(){
|
|
697
|
+
try{
|
|
698
|
+
const resp = await fetch('/__devlinker/logs', {cache:'no-store'});
|
|
699
|
+
const data = await resp.json();
|
|
700
|
+
render(Array.isArray(data.items) ? data.items : []);
|
|
701
|
+
}catch(_){
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
tick();
|
|
705
|
+
setInterval(tick, 1500);
|
|
706
|
+
</script>
|
|
707
|
+
</body>
|
|
708
|
+
</html>"""
|
|
709
|
+
return HTMLResponse(html)
|
|
710
|
+
|
|
711
|
+
|
|
532
712
|
def start_proxy(
|
|
533
713
|
frontend_port: Optional[int],
|
|
534
714
|
backend_port: int,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlinker
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.2
|
|
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.
|
|
@@ -70,6 +142,30 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
70
142
|
- `devlinker --debug` — Enable debug mode (turns on live API request logger)
|
|
71
143
|
- `devlinker --version` — Show version
|
|
72
144
|
|
|
145
|
+
Security token (optional):
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
set DEVLINKER_LINK_TOKEN=your-secret-token
|
|
149
|
+
devlinker --url
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
When enabled, LAN/public requests must include one of:
|
|
153
|
+
- query param `dl_token=...`
|
|
154
|
+
- header `X-DevLinker-Token: ...`
|
|
155
|
+
- header `Authorization: Bearer ...`
|
|
156
|
+
|
|
157
|
+
Built-in API logs dashboard:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
http://localhost:<proxy-port>/__devlinker/dashboard
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
JSON stream endpoint used by the dashboard:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
http://localhost:<proxy-port>/__devlinker/logs
|
|
167
|
+
```
|
|
168
|
+
|
|
73
169
|
## Project Structure
|
|
74
170
|
|
|
75
171
|
```text
|
|
@@ -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.2"
|
|
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" }
|
|
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
|
|
File without changes
|
|
File without changes
|