diff --git a/deps/cloudxr/webxr_client/webpack.chunkNames.js b/deps/cloudxr/webxr_client/webpack.chunkNames.js new file mode 100644 index 000000000..37acbe984 --- /dev/null +++ b/deps/cloudxr/webxr_client/webpack.chunkNames.js @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Human-readable chunk names for production builds. + * + * Target layout (two JS artifacts): + * - ``bundle.js`` — main application (UIKit, MSDF text, Lucide icons, runtime) + * - ``bundle.emulator.js`` — desktop XR / IWER code (DevUI, SEM scenes, deps) + * + * MSDF is pulled into the main entry (see ``webpack.common.js``) so in-VR text + * does not depend on extra lazy chunks over OOB. The MSDF web worker is inlined + * via ``asset/inline`` (data URL) so no separate worker file is emitted. + * + * Lazy boundary: ``@pmndrs/xr`` ``import('./emulate.js')`` → ``bundle.emulator.js``. + * OOB/--host-client sync downloads ``index.html``, ``bundle.js``, and + * ``bundle.emulator.js``. + */ + +const path = require('path'); + +/** Only non-main async chunk basename. */ +const EMULATOR_CHUNK = 'emulator'; + +/** + * True when *resource* belongs in the single IWER / desktop-emulator async chunk. + * + * @param {string | undefined} resource Webpack module resource path. + * @returns {boolean} + */ +function isEmulatorAsyncModule(resource = '') { + if (!resource) { + return false; + } + if (/[\\/]@pmndrs[\\/]xr[\\/]dist[\\/]emulate/.test(resource)) { + return true; + } + if (/[\\/]node_modules[\\/]iwer[\\/]lib/.test(resource)) { + return true; + } + if (/[\\/]node_modules[\\/]@iwer[\\/]/.test(resource)) { + return true; + } + if (/[\\/]node_modules[\\/](?:styled-components|stylis|@fortawesome|gl-matrix)/.test(resource)) { + return true; + } + if ( + /[\\/]node_modules[\\/](?:prop-types|@bufbuild[\\/]protobuf|scheduler|webxr-layers-polyfill)/.test( + resource + ) + ) { + return true; + } + return false; +} + +/** @type {import('webpack').Configuration['optimization']} */ +const chunkOptimization = { + chunkIds: 'named', + splitChunks: { + chunks: 'async', + cacheGroups: { + default: false, + defaultVendors: false, + emulator: { + test: module => isEmulatorAsyncModule(module.resource), + name: EMULATOR_CHUNK, + chunks: 'async', + enforce: true, + }, + }, + }, +}; + +/** @type {import('webpack').Configuration['output']['chunkFilename']} */ +function chunkFilename(pathData) { + const name = pathData.chunk?.name; + if (name === EMULATOR_CHUNK) { + return 'bundle.emulator.js'; + } + const id = String(pathData.chunk?.id ?? 'unknown'); + throw new Error( + `Unexpected async chunk "${id}" (name=${name ?? ''}). ` + + 'Only bundle.emulator.js is allowed besides bundle.js. ' + + 'Update webpack.common.js entry or webpack.chunkNames.js.' + ); +} + +/** + * Keep MSDF in ``bundle.js``: ``@pmndrs/uikit`` dynamically imports + * ``@zappar/msdf-generator``, which would otherwise become a third lazy chunk. + */ +const msdfGeneratorEntry = path.resolve( + __dirname, + 'node_modules/@zappar/msdf-generator/dist/index.js' +); + +/** Inline MSDF worker as a data URL inside ``bundle.js`` (no extra worker file). */ +const msdfInlineRules = { + rules: [ + { + test: /[\\/]@zappar[\\/]msdf-generator[\\/]dist[\\/]worker\.js$/, + type: 'asset/inline', + }, + ], +}; + +module.exports = { + chunkFilename, + chunkOptimization, + msdfGeneratorEntry, + msdfInlineRules, +}; diff --git a/deps/cloudxr/webxr_client/webpack.common.js b/deps/cloudxr/webxr_client/webpack.common.js index 6e3314589..64b3ef6c1 100644 --- a/deps/cloudxr/webxr_client/webpack.common.js +++ b/deps/cloudxr/webxr_client/webpack.common.js @@ -21,6 +21,7 @@ const { execSync } = require('child_process'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const webpack = require('webpack'); +const { msdfGeneratorEntry } = require('./webpack.chunkNames.js'); function git(cmd) { try { @@ -59,7 +60,10 @@ if (useLocalWebxrAssets) { } module.exports = { - entry: './src/index.tsx', + // MSDF in the main entry avoids a third lazy chunk (critical for OOB text on headset). + entry: { + main: ['./src/index.tsx', msdfGeneratorEntry], + }, // Enable webpack 5 persistent filesystem caching for faster incremental builds cache: { diff --git a/deps/cloudxr/webxr_client/webpack.prod.js b/deps/cloudxr/webxr_client/webpack.prod.js index 67e050084..1d221ec77 100644 --- a/deps/cloudxr/webxr_client/webpack.prod.js +++ b/deps/cloudxr/webxr_client/webpack.prod.js @@ -17,10 +17,12 @@ const { merge } = require('webpack-merge'); const common = require('./webpack.common.js'); +const { chunkFilename, chunkOptimization, msdfInlineRules } = require('./webpack.chunkNames.js'); module.exports = merge(common, { mode: 'production', - // Inline async chunks (from @pmndrs/xr emulate.js and @pmndrs/uikit msdf-generator) - // into bundle.js so the build produces a single JS file alongside index.html. - output: { asyncChunks: false }, + // Remove stale chunks when dependency graph changes between builds. + output: { clean: true, chunkFilename }, + module: msdfInlineRules, + optimization: chunkOptimization, }); diff --git a/docs/source/getting_started/build_from_source/webxr.rst b/docs/source/getting_started/build_from_source/webxr.rst index c649995ca..5951fc77d 100644 --- a/docs/source/getting_started/build_from_source/webxr.rst +++ b/docs/source/getting_started/build_from_source/webxr.rst @@ -49,6 +49,23 @@ From the ``deps/cloudxr/webxr_client/`` directory: 3. Build & Run -------------- +Production build +~~~~~~~~~~~~~~~~ + +From ``deps/cloudxr/webxr_client/``: + +.. code-block:: bash + + USE_LOCAL_WEBXR_ASSETS=0 npm run build + +Output in ``build/``: + +- ``index.html`` — entry page +- ``bundle.js`` — main application (UIKit, MSDF text, icons; loaded on first paint) +- ``bundle.emulator.js`` — desktop XR / IWER stack (DevUI, synthetic environments, + and related deps); loaded only when emulation is used + + Development build (one-shot) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/getting_started/quick_start.rst b/docs/source/getting_started/quick_start.rst index 8f0e5125b..00fe06493 100644 --- a/docs/source/getting_started/quick_start.rst +++ b/docs/source/getting_started/quick_start.rst @@ -268,13 +268,14 @@ running the CloudXR runtime and wss proxy in containerized environment; or using .. dropdown:: Offline / air-gapped use - On **first run**, the launcher fetches ``index.html`` and ``bundle.js`` from GitHub Pages and - caches them in ``~/.cloudxr/static-client/`` (override with - ``TELEOP_WEB_CLIENT_STATIC_DIR``). Subsequent runs are fully offline. - - For a **true air-gapped machine**, pre-stage the two files before the first run — copy them - from ``https://nvidia.github.io/IsaacTeleop/client/`` on a networked host, then transfer the - ``~/.cloudxr/static-client/`` directory to the air-gapped machine. + On **first run**, the launcher syncs the published web client into + ``~/.cloudxr/static-client/`` (override with ``TELEOP_WEB_CLIENT_STATIC_DIR``): + ``index.html``, ``bundle.js``, and ``bundle.emulator.js``. Subsequent runs + are offline once those files are cached. + + For a **true air-gapped machine**, copy the full ``build/`` output (or the + matching directory from `nvidia.github.io/IsaacTeleop/client`_) into + ``~/.cloudxr/static-client/`` on the air-gapped host before the first run. The source code for the web client is in the :code-dir:`deps/cloudxr/webxr_client/` directory. To build the web client from source, see :doc:`build_from_source/webxr`. diff --git a/docs/source/references/oob_teleop_control.rst b/docs/source/references/oob_teleop_control.rst index 73ca4a30f..5b4493381 100644 --- a/docs/source/references/oob_teleop_control.rst +++ b/docs/source/references/oob_teleop_control.rst @@ -402,8 +402,8 @@ On startup the launcher: traverse the network). 2. Resolves the WebXR static directory from ``TELEOP_WEB_CLIENT_STATIC_DIR`` (default ``~/.cloudxr/static-client``) - and downloads ``index.html`` / ``bundle.js`` from - ``https://nvidia.github.io/IsaacTeleop/client/main/`` if either is missing. + and syncs missing ``index.html``, ``bundle.js``, and ``bundle.emulator.js`` from + the published client (see :doc:`../getting_started/build_from_source/webxr`). 3. Serves that directory over HTTPS on 127.0.0.1:8080 with the same PEM the WSS proxy uses (Python ``http.server`` in a daemon thread). 4. ``adb reverse`` for 8080 (static UI), 48322 (WSS), 49100 (backend), @@ -451,6 +451,19 @@ interface is present. A runtime monitor also watches for mid-session Wi-Fi drops and prints a yellow warning so the cause is obvious without having to puzzle out a frozen WebRTC connection. +Web client UI has no text (OOB / ``--host-client``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +**Cause:** Production webpack emits ``bundle.js`` (main app, including UIKit MSDF text) +and ``bundle.emulator.js`` (desktop emulation only). If the static cache under +``TELEOP_WEB_CLIENT_STATIC_DIR`` is missing or outdated — especially a truncated +``bundle.js`` — in-VR text does not render. + +**Fix:** Ensure ``index.html``, ``bundle.js``, and ``bundle.emulator.js`` are present +under the static dir — run the launcher once with network, copy a full ``npm run build`` +output, or use the GitHub Pages URL directly. See +:doc:`../getting_started/build_from_source/webxr`. + CDP: startButton marked failed / not actionable ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -495,15 +508,14 @@ browser on the headset. WebXR static download fails (offline / proxy) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -**Cause:** The launcher fetches ``index.html`` / ``bundle.js`` from -``https://nvidia.github.io/IsaacTeleop/client/main/`` into the static dir on -first run. Behind a proxy or with no internet, this fails and -``--usb-local`` aborts. +**Cause:** The launcher syncs ``index.html``, ``bundle.js``, and +``bundle.emulator.js`` into the static dir on first run. Behind a proxy or with no +internet, this fails and ``--usb-local`` aborts. -**Fix:** Pre-stage the files (any way you like — ``curl``, container -build step, internal mirror) into the static dir, then re-run. The -launcher only downloads when a file is missing or empty. Override the -target directory via ``TELEOP_WEB_CLIENT_STATIC_DIR``. +**Fix:** Pre-stage the full client ``build/`` tree (or the published +``/client//`` directory) into the static dir, then re-run. The +launcher only downloads missing or empty files. Override the target directory +via ``TELEOP_WEB_CLIENT_STATIC_DIR``. **Fix:** Set the SDK versions in ``deps/cloudxr/.env`` (copy from ``.env.default``) so the download script can resolve the right version, diff --git a/src/core/cloudxr/python/__main__.py b/src/core/cloudxr/python/__main__.py index b11b5b329..c2dd492a2 100644 --- a/src/core/cloudxr/python/__main__.py +++ b/src/core/cloudxr/python/__main__.py @@ -86,7 +86,8 @@ def _parse_args() -> argparse.Namespace: f"and HTTPS static web client on port {usb_ui_port()} " "(override via USB_UI_PORT env). Files live under " "TELEOP_WEB_CLIENT_STATIC_DIR or ~/.cloudxr/static-client; missing " - "index.html / bundle.js are downloaded from the matching versioned " + "client assets (index.html, bundle.js, and bundle.emulator.js) " + "are downloaded from the matching versioned " "client under nvidia.github.io/IsaacTeleop/client/. " "The launcher serves them with the same PEM as the WSS proxy. " "Requirements: `coturn`, `adb` on PATH. WebRTC ICE still needs a " @@ -100,7 +101,8 @@ def _parse_args() -> argparse.Namespace: default=False, help=( "Serve the web client at /client/ on the WSS proxy port (default 48322). " - "Assets (index.html + bundle.js) are fetched once from the matching " + "Assets (index.html, bundle.js, and bundle.emulator.js) " + "are fetched once from the matching " "versioned release on nvidia.github.io/IsaacTeleop into " "TELEOP_WEB_CLIENT_STATIC_DIR or ~/.cloudxr/static-client. " "No separate port, no build step, no adb required. " diff --git a/src/core/cloudxr/python/oob_teleop_env.py b/src/core/cloudxr/python/oob_teleop_env.py index e2b9d1073..e30c616b7 100644 --- a/src/core/cloudxr/python/oob_teleop_env.py +++ b/src/core/cloudxr/python/oob_teleop_env.py @@ -84,8 +84,9 @@ def default_web_client_origin() -> str: TELEOP_CLIENT_ROUTE_ENV = "TELEOP_CLIENT_ROUTE" DEFAULT_TELEOP_CLIENT_ROUTE = "" -# Directory with prebuilt WebXR assets (``index.html`` + ``bundle.js``). Optional for ``--usb-local``: -# defaults to ``~/.cloudxr/static-client``; missing files are fetched from published URLs. +# Directory with prebuilt WebXR assets (``index.html``, ``bundle.js``, +# ``bundle.emulator.js``). Optional for ``--usb-local``: defaults to +# ``~/.cloudxr/static-client``; missing files are fetched from published URLs. TELEOP_WEB_CLIENT_STATIC_DIR_ENV = "TELEOP_WEB_CLIENT_STATIC_DIR" CHROME_INSPECT_DEVICES_URL = "chrome://inspect/#devices" @@ -173,11 +174,17 @@ def _write_atomic_bytes(dest: Path, data: bytes) -> None: raise +_REQUIRED_WEB_CLIENT_ASSETS = ("index.html", "bundle.js") +# Hardcoded — any new async chunk emitted by webpack must be added here manually. +_OPTIONAL_WEB_CLIENT_ASSETS = ("bundle.emulator.js",) + + def require_web_client_static_dir() -> Path: """Ensure web client static assets exist under :func:`resolve_web_client_static_dir`. - Creates the directory if needed. If ``index.html`` or ``bundle.js`` is missing or empty, - downloads from the published Isaac Teleop client URLs. + Creates the directory if needed. If ``index.html``, ``bundle.js``, or + ``bundle.emulator.js`` is missing or empty, downloads from the published + Isaac Teleop client URLs (emulator bundle is optional on older releases). Idempotent: safe to call from both :class:`~.launcher.CloudXRLauncher` and ``wss.run`` (second call skips network when files are present). @@ -195,16 +202,24 @@ def require_web_client_static_dir() -> Path: ) from exc client_origin = default_web_client_origin() - assets = ( - ("index.html", urljoin(client_origin, "index.html")), - ("bundle.js", urljoin(client_origin, "bundle.js")), - ) + assets = [ + (name, urljoin(client_origin, name)) + for name in (*_REQUIRED_WEB_CLIENT_ASSETS, *_OPTIONAL_WEB_CLIENT_ASSETS) + ] for name, url in assets: dest = p / name if dest.is_file() and dest.stat().st_size > 0: continue log.info("web client: fetching %s → %s", url, dest) - data = _fetch_url_bytes(url) + try: + data = _fetch_url_bytes(url) + except RuntimeError as exc: + if name in _OPTIONAL_WEB_CLIENT_ASSETS: + log.warning( + "web client: optional asset %s not available: %s", name, exc + ) + continue + raise if not data: raise RuntimeError(f"Downloaded empty body from {url}") try: @@ -212,7 +227,7 @@ def require_web_client_static_dir() -> Path: except OSError as exc: raise RuntimeError(f"Failed to write {dest}: {exc}") from exc - for name in ("index.html", "bundle.js"): + for name in _REQUIRED_WEB_CLIENT_ASSETS: fp = p / name if not fp.is_file() or fp.stat().st_size == 0: raise RuntimeError(f"Web client file missing or empty after fetch: {fp}") diff --git a/src/core/cloudxr/python/wss.py b/src/core/cloudxr/python/wss.py index 963364e2b..fbb169f91 100755 --- a/src/core/cloudxr/python/wss.py +++ b/src/core/cloudxr/python/wss.py @@ -258,7 +258,10 @@ def _json_response(status: int, phrase: str, body: dict) -> Response: def _make_http_handler(backend_host, backend_port, hub=None, static_dir=None): + """Return the WSS HTTP request handler (OOB hub API, static ``/client/``, cert page).""" + async def handle_http_request(connection, request): + """Dispatch non-WebSocket HTTP: CORS preflight, OOB APIs, static client, or cert HTML.""" if request.headers.get("Upgrade", "").lower() == "websocket": return None @@ -319,12 +322,14 @@ async def handle_http_request(connection, request): b"Not found", ) + # Static web client (--host-client): index.html + two JS bundles. if static_dir is not None and ( path == "/client" or path.startswith("/client/") ): _MIME = { "index.html": "text/html; charset=utf-8", "bundle.js": "application/javascript; charset=utf-8", + "bundle.emulator.js": "application/javascript; charset=utf-8", } tail = path[len("/client") :].lstrip("/") or "index.html" if tail not in _MIME: @@ -338,10 +343,10 @@ async def handle_http_request(connection, request): body = (static_dir / tail).read_bytes() except OSError: return Response( - 503, - "Service Unavailable", + 404, + "Not Found", Headers({"Content-Type": "text/plain", **CORS_HEADERS}), - b"Static file unavailable", + b"Not found", ) return Response( 200, diff --git a/src/core/cloudxr_tests/python/test_oob_teleop_env.py b/src/core/cloudxr_tests/python/test_oob_teleop_env.py index 7b2e4ab8a..ced2b2e46 100644 --- a/src/core/cloudxr_tests/python/test_oob_teleop_env.py +++ b/src/core/cloudxr_tests/python/test_oob_teleop_env.py @@ -336,6 +336,7 @@ def test_require_web_client_static_dir_default_downloads( monkeypatch: pytest.MonkeyPatch, tmp_path, ) -> None: + """Sync index.html, bundle.js, and bundle.emulator.js from the published client.""" # Path.home() consults $HOME on POSIX but %USERPROFILE% on Windows # (ntpath.expanduser ignores $HOME when USERPROFILE is set). Patch both # so the redirect works on every platform. @@ -347,6 +348,8 @@ def fake_fetch(url: str, *, timeout: float = 120.0) -> bytes: return b"t" if url.endswith("bundle.js"): return b"console.log(1);" + if url.endswith("bundle.emulator.js"): + return b"// emulator chunk" raise AssertionError(url) monkeypatch.setattr( @@ -359,6 +362,36 @@ def fake_fetch(url: str, *, timeout: float = 120.0) -> bytes: assert out == expected assert (out / "index.html").read_bytes().startswith(b" None: + """Older published clients without bundle.emulator.js still sync core assets.""" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + def fake_fetch(url: str, *, timeout: float = 120.0) -> bytes: + if url.endswith("index.html"): + return b"t" + if url.endswith("bundle.js"): + return b"console.log(1);" + if url.endswith("bundle.emulator.js"): + raise RuntimeError("Could not download: not found") + raise AssertionError(url) + + monkeypatch.setattr( + oob_teleop_env_under_test, + "_fetch_url_bytes", + fake_fetch, + ) + out = require_web_client_static_dir() + assert (out / "index.html").is_file() + assert (out / "bundle.js").is_file() + assert not (out / "bundle.emulator.js").exists() def test_require_web_client_static_dir_ok(