Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions deps/cloudxr/webxr_client/webpack.chunkNames.js
Original file line number Diff line number Diff line change
@@ -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,
};
6 changes: 5 additions & 1 deletion deps/cloudxr/webxr_client/webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down
8 changes: 5 additions & 3 deletions deps/cloudxr/webxr_client/webpack.prod.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
17 changes: 17 additions & 0 deletions docs/source/getting_started/build_from_source/webxr.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ From the ``deps/cloudxr/webxr_client/`` directory:
3. Build & Run
--------------

Production build

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I felt the description here is not needed (hidden from robotics/teleop people)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This response was drafted by AI (Claude) on behalf of the author.

Agreed — removed the implementation-detail paragraphs (the two-bundle rationale and asyncChunks warning). The build_from_source doc now just shows the commands.

~~~~~~~~~~~~~~~~

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)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
15 changes: 8 additions & 7 deletions docs/source/getting_started/quick_start.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
32 changes: 22 additions & 10 deletions docs/source/references/oob_teleop_control.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down Expand Up @@ -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/<version>/`` 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,
Expand Down
6 changes: 4 additions & 2 deletions src/core/cloudxr/python/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand All @@ -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. "
Expand Down
35 changes: 25 additions & 10 deletions src/core/cloudxr/python/oob_teleop_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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).
Expand All @@ -195,24 +202,32 @@ 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:
_write_atomic_bytes(dest, data)
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}")
Expand Down
11 changes: 8 additions & 3 deletions src/core/cloudxr/python/wss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
Loading
Loading