devlinker 1.3.4__tar.gz → 1.3.6__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 (29) hide show
  1. devlinker-1.3.6/LICENSE +21 -0
  2. {devlinker-1.3.4 → devlinker-1.3.6}/PKG-INFO +3 -1
  3. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/proxy.py +45 -2
  4. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/tunnel.py +38 -11
  5. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker.egg-info/PKG-INFO +3 -1
  6. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker.egg-info/SOURCES.txt +1 -0
  7. {devlinker-1.3.4 → devlinker-1.3.6}/pyproject.toml +1 -1
  8. {devlinker-1.3.4 → devlinker-1.3.6}/README.md +0 -0
  9. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/__init__.py +0 -0
  10. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/config.py +0 -0
  11. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/detection_state.py +0 -0
  12. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/detector.py +0 -0
  13. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/detector_ai.py +0 -0
  14. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/doctor.py +0 -0
  15. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/fix.py +0 -0
  16. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/fixer.py +0 -0
  17. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/global_state.py +0 -0
  18. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/inspect.py +0 -0
  19. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/logger.py +0 -0
  20. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/main.py +0 -0
  21. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/monitor.py +0 -0
  22. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/runner.py +0 -0
  23. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker/share.py +0 -0
  24. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker.egg-info/dependency_links.txt +0 -0
  25. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker.egg-info/entry_points.txt +0 -0
  26. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker.egg-info/requires.txt +0 -0
  27. {devlinker-1.3.4 → devlinker-1.3.6}/devlinker.egg-info/top_level.txt +0 -0
  28. {devlinker-1.3.4 → devlinker-1.3.6}/setup.cfg +0 -0
  29. {devlinker-1.3.4 → devlinker-1.3.6}/setup.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DevLinker Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlinker
3
- Version: 1.3.4
3
+ Version: 1.3.6
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
7
7
  Description-Content-Type: text/markdown
8
+ License-File: LICENSE
8
9
  Requires-Dist: click
9
10
  Requires-Dist: docker
10
11
  Requires-Dist: fastapi
@@ -13,6 +14,7 @@ Requires-Dist: pyngrok
13
14
  Requires-Dist: requests
14
15
  Requires-Dist: uvicorn
15
16
  Requires-Dist: websockets
17
+ Dynamic: license-file
16
18
 
17
19
  # Dev Linker
18
20
 
@@ -142,6 +142,30 @@ def _build_target_ws_url(port: int, path: str, query: str) -> str:
142
142
 
143
143
 
144
144
  async def _forward_http(request: Request) -> Response:
145
+ # Serve instant loader for local/LAN users unless X-DevLinker-Instant header is present
146
+ client_ip = request.client.host if request.client else None
147
+ def is_local_network(ip):
148
+ if not ip:
149
+ return False
150
+ if ip.startswith("127.") or ip == "localhost" or ip == "::1":
151
+ return False
152
+ if ip.startswith("192.168.") or ip.startswith("10."):
153
+ return True
154
+ if ip.startswith("172."):
155
+ try:
156
+ second = int(ip.split(".")[1])
157
+ return 16 <= second <= 31
158
+ except Exception:
159
+ return False
160
+ return False
161
+ is_local = is_local_network(client_ip)
162
+ is_instant = request.headers.get("x-devlinker-instant") == "1"
163
+ if is_local and not is_instant and request.method == "GET":
164
+ import os
165
+ loader_path = os.path.join(os.path.dirname(__file__), "devlinker_loader_instant.html")
166
+ with open(loader_path, encoding="utf-8") as f:
167
+ loader_html = f.read()
168
+ return Response(content=loader_html, status_code=200, media_type="text/html")
145
169
  from devlinker.logger import print_warning, print_fix
146
170
  from devlinker.detector_ai import DevLinkerAI
147
171
 
@@ -207,10 +231,29 @@ async def _forward_http(request: Request) -> Response:
207
231
  for s in ai_suggestions:
208
232
  print_fix(s)
209
233
 
234
+ # Only inject loader for HTML responses, not localhost/loopback, but DO inject for LAN/WiFi clients
235
+ headers = _filter_response_headers(dict(upstream.headers))
236
+ content_type = headers.get("content-type", "")
237
+ is_html = "text/html" in content_type
238
+ is_public = client_ip and not is_local and (not client_ip.startswith("127.") and client_ip != "localhost" and client_ip != "::1")
239
+ content = upstream.content
240
+ if is_html and (is_local or is_public):
241
+ try:
242
+ html = content.decode(upstream.encoding or "utf-8", errors="replace")
243
+ # Only inject if </body> exists
244
+ if "</body>" in html:
245
+ import os
246
+ loader_file = "devlinker_loader_snippet.html" if is_public else "devlinker_loader_minimal.html"
247
+ with open(os.path.join(os.path.dirname(__file__), loader_file), encoding="utf-8") as f:
248
+ loader = f.read()
249
+ html = html.replace("</body>", loader + "</body>")
250
+ content = html.encode(upstream.encoding or "utf-8")
251
+ except Exception:
252
+ pass
210
253
  return Response(
211
- content=upstream.content,
254
+ content=content,
212
255
  status_code=upstream.status_code,
213
- headers=_filter_response_headers(dict(upstream.headers)),
256
+ headers=headers,
214
257
  )
215
258
 
216
259
 
@@ -1,9 +1,13 @@
1
+ from __future__ import annotations
2
+
1
3
  def stop_tunnel():
2
4
  """Stop all active tunnels (Cloudflare/ngrok)."""
3
5
  # Stop ngrok tunnels
4
6
  try:
5
7
  for tunnel in ngrok.get_tunnels():
6
- ngrok.disconnect(tunnel.public_url)
8
+ public_url = getattr(tunnel, "public_url", None)
9
+ if public_url is not None:
10
+ ngrok.disconnect(public_url)
7
11
  except Exception:
8
12
  pass
9
13
  # Stop cloudflared processes
@@ -14,7 +18,6 @@ def stop_tunnel():
14
18
  except Exception:
15
19
  pass
16
20
  _CLOUDFLARED_PROCESSES.clear()
17
- from __future__ import annotations
18
21
 
19
22
  import re
20
23
  import shutil
@@ -58,7 +61,21 @@ def _try_cloudflare(proxy_port: int, startup_timeout: float = 12.0) -> str | Non
58
61
  stdout, _ = process.communicate(timeout=startup_timeout)
59
62
  output = stdout or ""
60
63
  except TimeoutExpired as exc:
61
- output = (exc.stdout or "") + (exc.stderr or "")
64
+ # exc.stdout and exc.stderr may be str, bytes, bytearray, or memoryview; convert to str
65
+ def to_str(val):
66
+ if isinstance(val, str):
67
+ return val
68
+ if isinstance(val, (bytes, bytearray)):
69
+ return val.decode(errors="replace")
70
+ if isinstance(val, memoryview):
71
+ return val.tobytes().decode(errors="replace")
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
79
 
63
80
  url = _extract_trycloudflare_url(output)
64
81
  if url:
@@ -71,22 +88,30 @@ def _try_cloudflare(proxy_port: int, startup_timeout: float = 12.0) -> str | Non
71
88
 
72
89
  def _disconnect_existing_tunnels() -> None:
73
90
  for tunnel in ngrok.get_tunnels():
74
- ngrok.disconnect(tunnel.public_url)
91
+ public_url = getattr(tunnel, "public_url", None)
92
+ if public_url is not None:
93
+ ngrok.disconnect(public_url)
75
94
 
76
95
 
77
- def _start_ngrok_tunnel(proxy_port: int) -> str:
96
+ def _start_ngrok_tunnel(proxy_port: int) -> str | None:
78
97
  try:
79
- tunnel = ngrok.connect(proxy_port)
80
- return tunnel.public_url
98
+ tunnel = ngrok.connect(str(proxy_port))
99
+ public_url = getattr(tunnel, "public_url", None)
100
+ if public_url is not None:
101
+ return public_url
102
+ return None
81
103
  except PyngrokError as exc:
82
104
  message = str(exc).lower()
83
105
 
84
106
  # If a prior endpoint is still active, disconnect and retry once.
85
- if "already online" in message or "endpoint" in message and "online" in message:
107
+ if "already online" in message or ("endpoint" in message and "online" in message):
86
108
  try:
87
109
  _disconnect_existing_tunnels()
88
- tunnel = ngrok.connect(proxy_port)
89
- return tunnel.public_url
110
+ tunnel = ngrok.connect(str(proxy_port))
111
+ public_url = getattr(tunnel, "public_url", None)
112
+ if public_url is not None:
113
+ return public_url
114
+ return None
90
115
  except PyngrokError as retry_exc:
91
116
  raise RuntimeError(
92
117
  f"Failed to start ngrok tunnel after disconnect retry: {retry_exc}"
@@ -113,7 +138,9 @@ def start_tunnel(proxy_port: int = 8000) -> tuple[str, str]:
113
138
 
114
139
  try:
115
140
  ngrok_url = _start_ngrok_tunnel(proxy_port)
116
- return "ngrok", ngrok_url
141
+ if ngrok_url is not None:
142
+ return "ngrok", ngrok_url
143
+ raise RuntimeError("Ngrok tunnel did not return a public URL.")
117
144
  except RuntimeError as ngrok_error:
118
145
  raise RuntimeError(
119
146
  "No tunnel available.\n"
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlinker
3
- Version: 1.3.4
3
+ Version: 1.3.6
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
7
7
  Description-Content-Type: text/markdown
8
+ License-File: LICENSE
8
9
  Requires-Dist: click
9
10
  Requires-Dist: docker
10
11
  Requires-Dist: fastapi
@@ -13,6 +14,7 @@ Requires-Dist: pyngrok
13
14
  Requires-Dist: requests
14
15
  Requires-Dist: uvicorn
15
16
  Requires-Dist: websockets
17
+ Dynamic: license-file
16
18
 
17
19
  # Dev Linker
18
20
 
@@ -1,3 +1,4 @@
1
+ LICENSE
1
2
  README.md
2
3
  pyproject.toml
3
4
  setup.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlinker"
7
- version = "1.3.4"
7
+ version = "1.3.6"
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