devlinker 1.4.2__tar.gz → 1.4.4__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.2 → devlinker-1.4.4}/MANIFEST.in +0 -1
- {devlinker-1.4.2/devlinker.egg-info → devlinker-1.4.4}/PKG-INFO +20 -3
- {devlinker-1.4.2 → devlinker-1.4.4}/README.md +19 -2
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/detector.py +4 -4
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/main.py +11 -7
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/proxy.py +30 -5
- devlinker-1.4.4/devlinker/share.py +64 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/tunnel.py +44 -21
- {devlinker-1.4.2 → devlinker-1.4.4/devlinker.egg-info}/PKG-INFO +20 -3
- {devlinker-1.4.2 → devlinker-1.4.4}/pyproject.toml +1 -1
- devlinker-1.4.2/devlinker/share.py +0 -32
- {devlinker-1.4.2 → devlinker-1.4.4}/LICENSE +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/__init__.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/config.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/detection_state.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/detector_ai.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/devlinker_loader_instant.html +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/devlinker_loader_snippet.html +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/doctor.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/fix.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/fixer.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/global_state.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/inspect.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/logger.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/monitor.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker/runner.py +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker.egg-info/SOURCES.txt +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker.egg-info/dependency_links.txt +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker.egg-info/entry_points.txt +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker.egg-info/requires.txt +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/devlinker.egg-info/top_level.txt +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/setup.cfg +0 -0
- {devlinker-1.4.2 → devlinker-1.4.4}/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.4
|
|
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
|
|
@@ -130,6 +130,7 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
130
130
|
- `devlinker support` — Show UPI support QR code in terminal
|
|
131
131
|
- `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
|
|
132
132
|
- `devlinker share` — Enable public tunnel at runtime (no restart)
|
|
133
|
+
- `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
|
|
133
134
|
- `devlinker unshare` — Disable public tunnel at runtime
|
|
134
135
|
- `devlinker doctor` — Diagnose issues, see categorized problems and fixes
|
|
135
136
|
- `devlinker fix` — Auto-fix common issues (env, API paths, config)
|
|
@@ -278,14 +279,16 @@ devlinker --docker
|
|
|
278
279
|
|
|
279
280
|
## Tunnel and Sharing Modes
|
|
280
281
|
|
|
281
|
-
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:
|
|
282
285
|
|
|
283
286
|
|
|
284
287
|
```bash
|
|
285
288
|
devlinker --url
|
|
286
289
|
```
|
|
287
290
|
|
|
288
|
-
This
|
|
291
|
+
This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
|
|
289
292
|
|
|
290
293
|
```text
|
|
291
294
|
🌍 Enabling public tunnel...
|
|
@@ -296,6 +299,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
|
|
|
296
299
|
ℹ Share this link with collaborators.
|
|
297
300
|
```
|
|
298
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
|
+
|
|
299
316
|
To force tunnel off (even if --url is passed):
|
|
300
317
|
|
|
301
318
|
```bash
|
|
@@ -110,6 +110,7 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
110
110
|
- `devlinker support` — Show UPI support QR code in terminal
|
|
111
111
|
- `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
|
|
112
112
|
- `devlinker share` — Enable public tunnel at runtime (no restart)
|
|
113
|
+
- `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
|
|
113
114
|
- `devlinker unshare` — Disable public tunnel at runtime
|
|
114
115
|
- `devlinker doctor` — Diagnose issues, see categorized problems and fixes
|
|
115
116
|
- `devlinker fix` — Auto-fix common issues (env, API paths, config)
|
|
@@ -258,14 +259,16 @@ devlinker --docker
|
|
|
258
259
|
|
|
259
260
|
## Tunnel and Sharing Modes
|
|
260
261
|
|
|
261
|
-
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:
|
|
262
265
|
|
|
263
266
|
|
|
264
267
|
```bash
|
|
265
268
|
devlinker --url
|
|
266
269
|
```
|
|
267
270
|
|
|
268
|
-
This
|
|
271
|
+
This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
|
|
269
272
|
|
|
270
273
|
```text
|
|
271
274
|
🌍 Enabling public tunnel...
|
|
@@ -276,6 +279,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
|
|
|
276
279
|
ℹ Share this link with collaborators.
|
|
277
280
|
```
|
|
278
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
|
+
|
|
279
296
|
To force tunnel off (even if --url is passed):
|
|
280
297
|
|
|
281
298
|
```bash
|
|
@@ -15,7 +15,7 @@ DEFAULT_BACKEND_PROBE_PATHS = (
|
|
|
15
15
|
|
|
16
16
|
def check_port(
|
|
17
17
|
port: int,
|
|
18
|
-
timeout: float =
|
|
18
|
+
timeout: float = 0.4,
|
|
19
19
|
probe_paths: Iterable[str] = DEFAULT_BACKEND_PROBE_PATHS,
|
|
20
20
|
) -> bool:
|
|
21
21
|
"""Return True when an HTTP service is reachable on localhost:port.
|
|
@@ -53,7 +53,7 @@ def check_port(
|
|
|
53
53
|
return False
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def is_vite_port(port: int, timeout: float =
|
|
56
|
+
def is_vite_port(port: int, timeout: float = 0.4) -> bool:
|
|
57
57
|
"""Return True when port looks like a Vite dev server."""
|
|
58
58
|
for host in ("localhost", "127.0.0.1"):
|
|
59
59
|
try:
|
|
@@ -95,8 +95,8 @@ def _ordered_unique_ports(*port_groups: Iterable[int]) -> list[int]:
|
|
|
95
95
|
def detect_ports(
|
|
96
96
|
frontend: Optional[int] = None,
|
|
97
97
|
backend: Optional[int] = None,
|
|
98
|
-
retries: int =
|
|
99
|
-
delay_seconds: float =
|
|
98
|
+
retries: int = 16,
|
|
99
|
+
delay_seconds: float = 0.5,
|
|
100
100
|
) -> Tuple[Optional[int], Optional[int]]:
|
|
101
101
|
"""Detect frontend and backend ports with retry support for slow startups."""
|
|
102
102
|
frontend_ports = _ordered_unique_ports(
|
|
@@ -33,7 +33,7 @@ except ImportError: # pragma: no cover - fallback when rich is unavailable
|
|
|
33
33
|
|
|
34
34
|
from . import __version__
|
|
35
35
|
from .detector import check_port, detect_ports, is_vite_port
|
|
36
|
-
from .proxy import start_proxy
|
|
36
|
+
from .proxy import start_proxy, wait_for_proxy_startup
|
|
37
37
|
from .runner import detect_backend_port, start_servers
|
|
38
38
|
from .tunnel import start_tunnel
|
|
39
39
|
from .doctor import doctor
|
|
@@ -43,6 +43,7 @@ from .share import share, unshare
|
|
|
43
43
|
from .config import load_config
|
|
44
44
|
from .inspect import inspect
|
|
45
45
|
from .monitor import monitor
|
|
46
|
+
from .global_state import STATE
|
|
46
47
|
|
|
47
48
|
SUPPORT_UPI_ID = "devlinker@upi"
|
|
48
49
|
SUPPORT_UPI_LINK = "upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀"
|
|
@@ -408,8 +409,8 @@ def _wait_for_readiness(
|
|
|
408
409
|
label: str,
|
|
409
410
|
port: int,
|
|
410
411
|
checker,
|
|
411
|
-
retries: int =
|
|
412
|
-
delay_seconds: float =
|
|
412
|
+
retries: int = 20,
|
|
413
|
+
delay_seconds: float = 0.5,
|
|
413
414
|
) -> bool:
|
|
414
415
|
_ui_status("⏳", f"Waiting for {label} ({port})...", style="cyan")
|
|
415
416
|
for attempt in range(1, retries + 1):
|
|
@@ -427,8 +428,8 @@ def _wait_for_readiness_live(
|
|
|
427
428
|
port: int,
|
|
428
429
|
checker,
|
|
429
430
|
live_status: _LiveStatus,
|
|
430
|
-
retries: int =
|
|
431
|
-
delay_seconds: float =
|
|
431
|
+
retries: int = 20,
|
|
432
|
+
delay_seconds: float = 0.5,
|
|
432
433
|
) -> bool:
|
|
433
434
|
live_status.update(label, f"⏳ Waiting ({port})...", style="cyan")
|
|
434
435
|
for attempt in range(1, retries + 1):
|
|
@@ -602,6 +603,7 @@ def _run_proxy(
|
|
|
602
603
|
)
|
|
603
604
|
|
|
604
605
|
proxy_port = _select_proxy_port(proxy_port)
|
|
606
|
+
STATE["proxy_port"] = proxy_port
|
|
605
607
|
_write_frontend_api_env(proxy_port)
|
|
606
608
|
|
|
607
609
|
if not live_status:
|
|
@@ -619,8 +621,10 @@ def _run_proxy(
|
|
|
619
621
|
enable_debug_logs=debug,
|
|
620
622
|
)
|
|
621
623
|
|
|
622
|
-
|
|
623
|
-
|
|
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
|
+
)
|
|
624
628
|
|
|
625
629
|
if live_status:
|
|
626
630
|
live_status.update("Proxy", f"✔ Active ({proxy_port})", style="green")
|
|
@@ -2,9 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import os
|
|
5
|
+
import sys
|
|
5
6
|
import time
|
|
6
7
|
from typing import Dict, Optional
|
|
7
|
-
from urllib.parse import urlencode
|
|
8
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
10
11
|
import uvicorn
|
|
@@ -26,6 +27,7 @@ _printed_fixes = set()
|
|
|
26
27
|
_printed_live_header = False
|
|
27
28
|
LIVE_REQUEST_LOGGING_ENABLED = False
|
|
28
29
|
MAX_RECENT_REQUESTS = 200
|
|
30
|
+
PROXY_READY_EVENT = threading.Event()
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def _format_request_context(path: str, method: str | None, status: int, target: str) -> str:
|
|
@@ -66,6 +68,19 @@ def _extract_presented_token(headers: Dict[str, str], query_params) -> str | Non
|
|
|
66
68
|
query_token = query_params.get("dl_token")
|
|
67
69
|
if query_token:
|
|
68
70
|
return query_token
|
|
71
|
+
|
|
72
|
+
# WebSocket handshakes may not include dl_token in the WS URL, but the
|
|
73
|
+
# browser Referer usually contains the original page URL query string.
|
|
74
|
+
referer = headers.get("referer", "").strip()
|
|
75
|
+
if referer:
|
|
76
|
+
try:
|
|
77
|
+
referer_query = dict(parse_qsl(urlsplit(referer).query, keep_blank_values=True))
|
|
78
|
+
referer_token = (referer_query.get("dl_token") or "").strip()
|
|
79
|
+
if referer_token:
|
|
80
|
+
return referer_token
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
69
84
|
direct_header = headers.get("x-devlinker-token", "").strip()
|
|
70
85
|
if direct_header:
|
|
71
86
|
return direct_header
|
|
@@ -175,6 +190,7 @@ def _apply_cors_headers(headers: Dict[str, str], request: Request) -> Dict[str,
|
|
|
175
190
|
async def _on_startup() -> None:
|
|
176
191
|
global HTTP_CLIENT
|
|
177
192
|
HTTP_CLIENT = httpx.AsyncClient(timeout=15.0, follow_redirects=False)
|
|
193
|
+
PROXY_READY_EVENT.set()
|
|
178
194
|
|
|
179
195
|
|
|
180
196
|
@app.on_event("shutdown")
|
|
@@ -183,6 +199,7 @@ async def _on_shutdown() -> None:
|
|
|
183
199
|
if HTTP_CLIENT is not None:
|
|
184
200
|
await HTTP_CLIENT.aclose()
|
|
185
201
|
HTTP_CLIENT = None
|
|
202
|
+
PROXY_READY_EVENT.clear()
|
|
186
203
|
|
|
187
204
|
|
|
188
205
|
def _connection_header_tokens(headers: Dict[str, str]) -> set[str]:
|
|
@@ -210,6 +227,7 @@ def _filter_websocket_headers(incoming: Dict[str, str]) -> Dict[str, str]:
|
|
|
210
227
|
connection_tokens = _connection_header_tokens(incoming)
|
|
211
228
|
excluded = HOP_BY_HOP_HEADERS | connection_tokens | {
|
|
212
229
|
"host",
|
|
230
|
+
"origin",
|
|
213
231
|
"sec-websocket-key",
|
|
214
232
|
"sec-websocket-version",
|
|
215
233
|
"sec-websocket-extensions",
|
|
@@ -720,9 +738,16 @@ def start_proxy(
|
|
|
720
738
|
BACKEND = backend_port
|
|
721
739
|
LIVE_REQUEST_LOGGING_ENABLED = enable_debug_logs
|
|
722
740
|
_printed_live_header = False
|
|
741
|
+
PROXY_READY_EVENT.clear()
|
|
723
742
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
743
|
+
def _run_server() -> None:
|
|
744
|
+
if sys.platform.startswith("win") and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"):
|
|
745
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
746
|
+
uvicorn.run(app, host="0.0.0.0", port=proxy_port, log_level="warning")
|
|
747
|
+
|
|
748
|
+
thread = threading.Thread(target=_run_server, daemon=True)
|
|
728
749
|
thread.start()
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def wait_for_proxy_startup(timeout: float = 5.0) -> bool:
|
|
753
|
+
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)
|
|
@@ -20,8 +20,11 @@ def stop_tunnel():
|
|
|
20
20
|
_CLOUDFLARED_PROCESSES.clear()
|
|
21
21
|
|
|
22
22
|
import re
|
|
23
|
+
import queue
|
|
23
24
|
import shutil
|
|
24
25
|
import subprocess
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
25
28
|
from subprocess import TimeoutExpired
|
|
26
29
|
|
|
27
30
|
from pyngrok import ngrok
|
|
@@ -56,33 +59,53 @@ def _try_cloudflare(proxy_port: int, startup_timeout: float = 12.0) -> str | Non
|
|
|
56
59
|
text=True,
|
|
57
60
|
)
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return str(val) if val is not None else ""
|
|
73
|
-
exc_stdout = to_str(exc.stdout)
|
|
74
|
-
exc_stderr = to_str(exc.stderr)
|
|
75
|
-
output = exc_stdout + exc_stderr
|
|
76
|
-
|
|
77
|
-
if not isinstance(output, str):
|
|
78
|
-
output = str(output)
|
|
62
|
+
# Read cloudflared logs incrementally so we can return as soon as the URL appears.
|
|
63
|
+
output_queue: queue.Queue[str | None] = queue.Queue()
|
|
64
|
+
|
|
65
|
+
def _reader() -> None:
|
|
66
|
+
stream = process.stdout
|
|
67
|
+
if stream is None:
|
|
68
|
+
output_queue.put(None)
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
for line in stream:
|
|
72
|
+
output_queue.put(line)
|
|
73
|
+
finally:
|
|
74
|
+
output_queue.put(None)
|
|
79
75
|
|
|
76
|
+
threading.Thread(target=_reader, daemon=True).start()
|
|
77
|
+
|
|
78
|
+
output = ""
|
|
79
|
+
deadline = time.monotonic() + max(startup_timeout, 0.1)
|
|
80
|
+
stream_closed = False
|
|
81
|
+
while time.monotonic() < deadline:
|
|
82
|
+
try:
|
|
83
|
+
chunk = output_queue.get(timeout=0.1)
|
|
84
|
+
except queue.Empty:
|
|
85
|
+
chunk = ""
|
|
86
|
+
|
|
87
|
+
if chunk is None:
|
|
88
|
+
stream_closed = True
|
|
89
|
+
break
|
|
90
|
+
if chunk:
|
|
91
|
+
output += chunk
|
|
92
|
+
url = _extract_trycloudflare_url(output)
|
|
93
|
+
if url:
|
|
94
|
+
_CLOUDFLARED_PROCESSES.append(process)
|
|
95
|
+
return url
|
|
96
|
+
|
|
97
|
+
if process.poll() is not None and output_queue.empty():
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
# One final parse pass before treating startup as failed.
|
|
80
101
|
url = _extract_trycloudflare_url(output)
|
|
81
102
|
if url:
|
|
82
103
|
_CLOUDFLARED_PROCESSES.append(process)
|
|
83
104
|
return url
|
|
84
105
|
|
|
85
|
-
process.
|
|
106
|
+
if not stream_closed and process.poll() is None:
|
|
107
|
+
process.terminate()
|
|
108
|
+
|
|
86
109
|
return None
|
|
87
110
|
|
|
88
111
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devlinker
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
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
|
|
@@ -130,6 +130,7 @@ If DevLinker helps you ship faster, consider supporting the project:
|
|
|
130
130
|
- `devlinker support` — Show UPI support QR code in terminal
|
|
131
131
|
- `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
|
|
132
132
|
- `devlinker share` — Enable public tunnel at runtime (no restart)
|
|
133
|
+
- `devlinker share --proxy-port 18000` — Enable public tunnel for a custom proxy port
|
|
133
134
|
- `devlinker unshare` — Disable public tunnel at runtime
|
|
134
135
|
- `devlinker doctor` — Diagnose issues, see categorized problems and fixes
|
|
135
136
|
- `devlinker fix` — Auto-fix common issues (env, API paths, config)
|
|
@@ -278,14 +279,16 @@ devlinker --docker
|
|
|
278
279
|
|
|
279
280
|
## Tunnel and Sharing Modes
|
|
280
281
|
|
|
281
|
-
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:
|
|
282
285
|
|
|
283
286
|
|
|
284
287
|
```bash
|
|
285
288
|
devlinker --url
|
|
286
289
|
```
|
|
287
290
|
|
|
288
|
-
This
|
|
291
|
+
This starts the proxy and opens a public tunnel (Cloudflare or ngrok). The output will show:
|
|
289
292
|
|
|
290
293
|
```text
|
|
291
294
|
🌍 Enabling public tunnel...
|
|
@@ -296,6 +299,20 @@ This will start the proxy and open a public tunnel (Cloudflare or ngrok). The ou
|
|
|
296
299
|
ℹ Share this link with collaborators.
|
|
297
300
|
```
|
|
298
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
|
+
|
|
299
316
|
To force tunnel off (even if --url is passed):
|
|
300
317
|
|
|
301
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.4"
|
|
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
|