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.
Files changed (32) hide show
  1. {devlinker-1.4.1/devlinker.egg-info → devlinker-1.4.2}/PKG-INFO +97 -1
  2. {devlinker-1.4.1 → devlinker-1.4.2}/README.md +96 -0
  3. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/main.py +18 -3
  4. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/proxy.py +196 -16
  5. {devlinker-1.4.1 → devlinker-1.4.2/devlinker.egg-info}/PKG-INFO +97 -1
  6. {devlinker-1.4.1 → devlinker-1.4.2}/pyproject.toml +1 -1
  7. {devlinker-1.4.1 → devlinker-1.4.2}/LICENSE +0 -0
  8. {devlinker-1.4.1 → devlinker-1.4.2}/MANIFEST.in +0 -0
  9. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/__init__.py +0 -0
  10. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/config.py +0 -0
  11. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/detection_state.py +0 -0
  12. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/detector.py +0 -0
  13. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/detector_ai.py +0 -0
  14. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/devlinker_loader_instant.html +0 -0
  15. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/devlinker_loader_snippet.html +0 -0
  16. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/doctor.py +0 -0
  17. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/fix.py +0 -0
  18. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/fixer.py +0 -0
  19. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/global_state.py +0 -0
  20. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/inspect.py +0 -0
  21. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/logger.py +0 -0
  22. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/monitor.py +0 -0
  23. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/runner.py +0 -0
  24. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/share.py +0 -0
  25. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker/tunnel.py +0 -0
  26. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/SOURCES.txt +0 -0
  27. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/dependency_links.txt +0 -0
  28. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/entry_points.txt +0 -0
  29. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/requires.txt +0 -0
  30. {devlinker-1.4.1 → devlinker-1.4.2}/devlinker.egg-info/top_level.txt +0 -0
  31. {devlinker-1.4.1 → devlinker-1.4.2}/setup.cfg +0 -0
  32. {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.1
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({"path": path, "status": status, "target": target})
91
- if len(_recent_requests) > 50:
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 _build_target_http_url(port: int, path: str, query_params: list[tuple[str, str]]) -> str:
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://127.0.0.1:{port}{path}"
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://127.0.0.1:{port}{path}"
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 = await HTTP_CLIENT.request(
346
- method=request.method,
347
- url=target_url,
348
- content=payload,
349
- headers=_filter_request_headers(dict(request.headers)),
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.1
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.1"
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