diff --git a/README.md b/README.md index f511dfbc6..18d909a3d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,18 @@ Grab the one-liner from the [LeLab Space](https://huggingface.co/spaces/lerobot/ A page will automatically open in your browser and you are ready to go. +### Custom host and port + +By default LeLab serves the UI and API on `127.0.0.1:8000`, reachable only from the machine it runs on. Two flags change that: + +```bash +lelab --host 0.0.0.0 # also listen on the network, e.g. for a laptop or tablet next to the arm +lelab --port 9000 # serve on a different port +lelab --host 0.0.0.0 --port 9000 # both at once +``` + +Then open `http://:` from any device on the same network. `lelab --stop` frees the ports again; pass `--port` too if you started on a custom port. In `--dev` mode, `--host` applies to the Vite dev server (`:8080`) as well, and `--port` selects the backend port that the dev frontend talks to. + ## What you can do
diff --git a/frontend/src/components/SingleTabGuard.tsx b/frontend/src/components/SingleTabGuard.tsx index 02b7fb159..8ef804f49 100644 --- a/frontend/src/components/SingleTabGuard.tsx +++ b/frontend/src/components/SingleTabGuard.tsx @@ -7,6 +7,19 @@ const CHANNEL = "lelab-tabs-v1"; const HEARTBEAT_MS = 1000; const PEER_TIMEOUT_MS = 3000; +// `crypto.randomUUID` is only exposed in secure contexts (HTTPS or localhost), +// so plain-HTTP LAN access (e.g. http://:8000) needs a v4 fallback. +const generateTabId = (): string => { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // UUID version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // UUID variant 10xx + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; + const SingleTabGuard = ({ children }: { children: ReactNode }) => { const [isPrimary, setIsPrimary] = useState(true); const peersRef = useRef>(new Map()); @@ -39,7 +52,7 @@ const SingleTabGuard = ({ children }: { children: ReactNode }) => { return; } - myIdRef.current = crypto.randomUUID(); + myIdRef.current = generateTabId(); myOpenedAtRef.current = Date.now(); const channel = new BroadcastChannel(CHANNEL); diff --git a/frontend/src/contexts/ApiContext.tsx b/frontend/src/contexts/ApiContext.tsx index 80346858c..2e12fbd7b 100644 --- a/frontend/src/contexts/ApiContext.tsx +++ b/frontend/src/contexts/ApiContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, useContext, ReactNode, useState, useCallback, useMemo } from "react"; +import { isHostedSpace } from "@/lib/isHostedSpace"; interface ApiContextType { baseUrl: string; @@ -31,7 +32,13 @@ const resolveInitialBaseUrl = (): string => { } } - return DEFAULT_LOCALHOST; + // Production `lelab` serves the UI and the API on the same origin, so the + // page origin is the right default — a hardcoded localhost default breaks + // plain-HTTP LAN access (http://:8000). Dev keeps the API on :8000 + // behind Vite's :8080, and the HF Space serves only the UI and relies on + // ?api=, so both keep the localhost default. + if (import.meta.env.DEV || isHostedSpace()) return DEFAULT_LOCALHOST; + return window.location.origin; }; export const ApiProvider: React.FC<{ children: ReactNode }> = ({ diff --git a/lelab/scripts/lelab.py b/lelab/scripts/lelab.py index 48dca7802..8f6436ef7 100644 --- a/lelab/scripts/lelab.py +++ b/lelab/scripts/lelab.py @@ -82,7 +82,7 @@ def _ensure_port_available(name: str, port: int, host: str = HOST) -> None: ) -def _find_lelab_pids() -> dict[int, str]: +def _find_lelab_pids(ports: set[int] | None = None) -> dict[int, str]: """PIDs that look like a running LeLab dev backend/frontend, with a reason. On Windows the uvicorn --reload worker inherits the listening socket from @@ -93,11 +93,12 @@ def _find_lelab_pids() -> dict[int, str]: 1. cmdline runs `uvicorn ... lelab.server` (the reload supervisor / prod) 2. an orphaned reload worker (`multiprocessing.spawn`) whose cwd is this project - 3. anything actually LISTENING on :8000 / :8080 (per-process scan, which - is more reliable than the global table on Windows) + 3. anything actually LISTENING on the LeLab ports (per-process scan, + which is more reliable than the global table on Windows) """ me = os.getpid() - ports = {BACKEND_PORT, FRONTEND_DEV_PORT} + if ports is None: + ports = {BACKEND_PORT, FRONTEND_DEV_PORT} targets: dict[int, str] = {} for proc in psutil.process_iter(["pid", "cmdline"]): pid = proc.info["pid"] @@ -125,15 +126,15 @@ def _find_lelab_pids() -> dict[int, str]: return targets -def _run_stop() -> None: - """Stop a running LeLab and free :8000 / :8080. +def _run_stop(port: int = BACKEND_PORT) -> None: + """Stop a running LeLab and free the backend port and :8080. The escape hatch for when a previous run left an orphaned Vite or uvicorn process holding the ports. """ - targets = _find_lelab_pids() + targets = _find_lelab_pids({port, FRONTEND_DEV_PORT}) if not targets: - logger.info("Nothing to stop: no LeLab process found on :%d / :%d.", BACKEND_PORT, FRONTEND_DEV_PORT) + logger.info("Nothing to stop: no LeLab process found on :%d / :%d.", port, FRONTEND_DEV_PORT) return for pid, reason in targets.items(): logger.info("Stopping pid %d (%s)...", pid, reason) @@ -330,20 +331,31 @@ def _monitor_processes(processes: Sequence[tuple[str, subprocess.Popen]]) -> Non raise SystemExit(returncode) -def _run_prod(*, no_open: bool = False, rebuild: bool = False) -> None: +def _run_prod( + *, + no_open: bool = False, + rebuild: bool = False, + host: str = HOST, + port: int = BACKEND_PORT, +) -> None: """Serve built frontend from backend on a single port.""" - _ensure_port_available("Backend", BACKEND_PORT) + _ensure_port_available("Backend", port, host=host) if rebuild: _run_frontend_build() _ensure_frontend_dist() - logger.info("Starting LeLab on http://localhost:%d ...", BACKEND_PORT) - threading.Thread(target=_open_browser_when_ready, args=(BACKEND_PORT, no_open), daemon=True).start() + logger.info("Starting LeLab on http://localhost:%d ...", port) + if host not in ("127.0.0.1", "localhost"): + logger.info( + "Bound to %s: the UI and API accept connections from other machines on the network.", + host, + ) + threading.Thread(target=_open_browser_when_ready, args=(port, no_open), daemon=True).start() config = uvicorn.Config( "lelab.server:app", - host=HOST, - port=BACKEND_PORT, + host=host, + port=port, log_level="info", reload=False, timeout_graceful_shutdown=2, @@ -387,22 +399,22 @@ def _shutdown(_signum, _frame) -> None: server.run() -def _run_dev(*, no_open: bool = False) -> None: +def _run_dev(*, no_open: bool = False, host: str = HOST, port: int = BACKEND_PORT) -> None: """Start Vite HMR plus uvicorn reload.""" _ensure_frontend_path() _require_command("node") _require_command("npm") - _ensure_port_available("Backend", BACKEND_PORT) - _ensure_port_available("Frontend", FRONTEND_DEV_PORT) + _ensure_port_available("Backend", port, host=host) + _ensure_port_available("Frontend", FRONTEND_DEV_PORT, host=host) _ensure_frontend_deps() processes: list[tuple[str, subprocess.Popen]] = [] - frontend_url = f"http://localhost:{FRONTEND_DEV_PORT}/?api=http://localhost:{BACKEND_PORT}" + frontend_url = f"http://localhost:{FRONTEND_DEV_PORT}/?api=http://localhost:{port}" try: frontend_process = _start_process( "frontend", - ["npm", "run", "dev", "--", "--host", HOST, "--port", str(FRONTEND_DEV_PORT)], + ["npm", "run", "dev", "--", "--host", host, "--port", str(FRONTEND_DEV_PORT)], FRONTEND_PATH, ) processes.append(("frontend", frontend_process)) @@ -421,9 +433,9 @@ def _run_dev(*, no_open: bool = False) -> None: "uvicorn", "lelab.server:app", "--host", - HOST, + host, "--port", - str(BACKEND_PORT), + str(port), "--reload", # Same as prod: an open /camera-feed stream would otherwise # hold off the shutdown hook that saves an active recording. @@ -434,17 +446,17 @@ def _run_dev(*, no_open: bool = False) -> None: env=os.environ.copy(), ) processes.append(("backend", backend_process)) - if not _wait_for_port(BACKEND_PORT, timeout=15): + if not _wait_for_port(port, timeout=15): if backend_process.poll() is not None: _fail( f"Backend exited early with code {backend_process.returncode}. Check the uvicorn output above." ) - _fail(f"Backend never became ready on http://localhost:{BACKEND_PORT}.") + _fail(f"Backend never became ready on http://localhost:{port}.") _open_browser_url(frontend_url, no_open=no_open) logger.info("Dev mode running. Press Ctrl+C to stop.") logger.info("Frontend: http://localhost:%d", FRONTEND_DEV_PORT) - logger.info("Backend: http://localhost:%d", BACKEND_PORT) + logger.info("Backend: http://localhost:%d", port) _install_signal_handlers() _monitor_processes(processes) except BaseException: @@ -470,10 +482,22 @@ def _build_parser() -> argparse.ArgumentParser: action="store_true", help="Do not open a browser automatically.", ) + parser.add_argument( + "--host", + default=HOST, + help="Address to bind the backend (and, in --dev, the Vite server) to. " + "Use 0.0.0.0 to make LeLab reachable from other machines on the network.", + ) + parser.add_argument( + "--port", + type=int, + default=BACKEND_PORT, + help="Port to serve the backend (UI and API) on.", + ) parser.add_argument( "--stop", action="store_true", - help="Stop a running LeLab (free ports 8000/8080) and exit.", + help="Stop a running LeLab and free its ports. Pass --port to match a custom backend port.", ) return parser @@ -483,16 +507,16 @@ def main(argv: Sequence[str] | None = None) -> None: args = parser.parse_args(argv) if args.stop: - _run_stop() + _run_stop(port=args.port) return if args.dev and args.rebuild: parser.error("--rebuild is for production mode; dev mode serves from Vite.") if args.dev: - _run_dev(no_open=args.no_open) + _run_dev(no_open=args.no_open, host=args.host, port=args.port) else: - _run_prod(no_open=args.no_open, rebuild=args.rebuild) + _run_prod(no_open=args.no_open, rebuild=args.rebuild, host=args.host, port=args.port) if __name__ == "__main__": diff --git a/tests/test_scripts_lelab.py b/tests/test_scripts_lelab.py index c29129d4e..e05d73f9c 100644 --- a/tests/test_scripts_lelab.py +++ b/tests/test_scripts_lelab.py @@ -212,7 +212,7 @@ def fake_config(_app, **kwargs): return MagicMock() monkeypatch.setattr(launcher, "FRONTEND_DIST", dist) - monkeypatch.setattr(launcher, "_ensure_port_available", lambda _name, _port: None) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda *_args, **_kwargs: None) monkeypatch.setattr(launcher.threading, "Thread", _ImmediateThread) monkeypatch.setattr(launcher.uvicorn, "Config", fake_config) monkeypatch.setattr(launcher.uvicorn, "Server", lambda _config: server) @@ -241,7 +241,7 @@ def test_run_prod_rebuilds_before_starting_uvicorn( server.run.side_effect = lambda: order.append("uvicorn") monkeypatch.setattr(launcher, "FRONTEND_DIST", dist) - monkeypatch.setattr(launcher, "_ensure_port_available", lambda _name, _port: None) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda *_args, **_kwargs: None) monkeypatch.setattr(launcher, "_run_frontend_build", lambda: order.append("build")) monkeypatch.setattr(launcher.threading, "Thread", _ImmediateThread) monkeypatch.setattr(launcher.uvicorn, "Config", lambda *_a, **_k: MagicMock()) @@ -274,7 +274,7 @@ def stop_after_start(_processes): monkeypatch.setattr(launcher, "FRONTEND_PATH", frontend) monkeypatch.setattr(launcher, "PROJECT_ROOT", tmp_path) monkeypatch.setattr(launcher, "_require_command", lambda _command: _command) - monkeypatch.setattr(launcher, "_ensure_port_available", lambda _name, _port: None) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda *_args, **_kwargs: None) monkeypatch.setattr(launcher, "_ensure_frontend_deps", lambda: None) monkeypatch.setattr(launcher, "_wait_for_port", lambda _port, timeout=30: True) monkeypatch.setattr(launcher, "_start_process", fake_start_process) @@ -428,7 +428,7 @@ def test_dev_launcher_reports_frontend_exit_before_ready( monkeypatch.setattr(launcher, "FRONTEND_PATH", frontend) monkeypatch.setattr(launcher, "_require_command", lambda _command: _command) - monkeypatch.setattr(launcher, "_ensure_port_available", lambda _name, _port: None) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda *_args, **_kwargs: None) monkeypatch.setattr(launcher, "_ensure_frontend_deps", lambda: None) monkeypatch.setattr(launcher, "_wait_for_port", lambda _port, timeout=30: False) monkeypatch.setattr(launcher, "_start_process", lambda *_args, **_kwargs: FakeProcess(returncode=7)) @@ -438,3 +438,113 @@ def test_dev_launcher_reports_frontend_exit_before_ready( assert "Frontend exited early with code 7" in caplog.text assert "Check the Vite output above" in caplog.text + + +def test_parser_defaults_host_to_loopback() -> None: + import lelab.scripts.lelab as launcher + + args = launcher._build_parser().parse_args([]) + + assert args.host == "127.0.0.1" + assert args.port == 8000 + + +def test_parser_accepts_custom_host() -> None: + import lelab.scripts.lelab as launcher + + args = launcher._build_parser().parse_args(["--host", "0.0.0.0"]) + + assert args.host == "0.0.0.0" + + +def test_parser_accepts_custom_port() -> None: + import lelab.scripts.lelab as launcher + + args = launcher._build_parser().parse_args(["--port", "9000"]) + + assert args.port == 9000 + + +def test_run_prod_binds_requested_host( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + dist = tmp_path / "dist" + dist.mkdir() + (dist / "index.html").write_text("") + captured: dict[str, object] = {} + + def fake_config(_app, **kwargs): + captured.update(kwargs) + return MagicMock() + + monkeypatch.setattr(launcher, "FRONTEND_DIST", dist) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda *_args, **_kwargs: None) + monkeypatch.setattr(launcher.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(launcher.uvicorn, "Config", fake_config) + monkeypatch.setattr(launcher.uvicorn, "Server", lambda _config: MagicMock()) + monkeypatch.setattr(launcher.signal, "signal", lambda *_a, **_k: None) + + launcher._run_prod(no_open=True, host="0.0.0.0", port=9000) + + assert captured["host"] == "0.0.0.0" + assert captured["port"] == 9000 + + +def test_dev_launcher_binds_requested_host( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + import lelab.scripts.lelab as launcher + + frontend = tmp_path / "frontend" + frontend.mkdir() + started: list[tuple[str, list[str], Path]] = [] + browser_open = MagicMock() + + def fake_start_process(name, command, cwd, env=None): + started.append((name, list(command), cwd)) + return FakeProcess() + + monkeypatch.setattr(launcher, "FRONTEND_PATH", frontend) + monkeypatch.setattr(launcher, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(launcher, "_require_command", lambda _command: _command) + monkeypatch.setattr(launcher, "_ensure_port_available", lambda *_args, **_kwargs: None) + monkeypatch.setattr(launcher, "_ensure_frontend_deps", lambda: None) + monkeypatch.setattr(launcher, "_wait_for_port", lambda _port, timeout=30: True) + monkeypatch.setattr(launcher, "_start_process", fake_start_process) + monkeypatch.setattr(launcher, "_install_signal_handlers", lambda: None) + monkeypatch.setattr( + launcher, "_monitor_processes", lambda _processes: (_ for _ in ()).throw(SystemExit(0)) + ) + monkeypatch.setattr(launcher, "_terminate_tree", lambda _pid, _timeout=5: None) + monkeypatch.setattr(launcher.webbrowser, "open", browser_open) + + with pytest.raises(SystemExit): + launcher._run_dev(host="0.0.0.0", port=9000) + + vite_args = started[0][1] + assert vite_args[vite_args.index("--host") + 1] == "0.0.0.0" + uvicorn_args = started[1][1] + assert uvicorn_args[uvicorn_args.index("--host") + 1] == "0.0.0.0" + assert uvicorn_args[uvicorn_args.index("--port") + 1] == "9000" + opened_url = browser_open.call_args.args[0] + assert opened_url == "http://localhost:8080/?api=http://localhost:9000" + + +def test_stop_frees_custom_backend_port(monkeypatch: pytest.MonkeyPatch) -> None: + import lelab.scripts.lelab as launcher + + captured: dict[object, object] = {} + + def fake_find_pids(ports=None): + captured["ports"] = ports + return {} + + monkeypatch.setattr(launcher, "_find_lelab_pids", fake_find_pids) + + launcher._run_stop(port=9000) + + assert captured["ports"] == {9000, launcher.FRONTEND_DEV_PORT}