diff --git a/MODULE.bazel b/MODULE.bazel
index 170b614edc5..440384cb9f7 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -364,6 +364,22 @@ npm.npm_translate_lock(
)
use_repo(npm, "npm")
+# The browser libraries the web viewer serves from the binary. Not a
+# dev_dependency: without them the viewer would fall back to a CDN, which is the
+# bug (issue #11065). Versions and digests live in the manifest the extension
+# reads, //src/web/third-party:packages.json.
+web_third_party = use_extension("//bazel:web_third_party.bzl", "web_third_party")
+use_repo(
+ web_third_party,
+ "esbuild",
+ "web_elkjs",
+ "web_golden_layout",
+ "web_leaflet",
+ "web_netlistsvg",
+ "web_third_party",
+ "web_three",
+)
+
orfs = use_extension("@bazel-orfs//:extension.bzl", "orfs_repositories", dev_dependency = True)
orfs.default(
# Use OpenROAD of this repo instead of the one bundled with @orfs
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index 3b2a5b33b80..b273cf30014 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -823,6 +823,90 @@
},
"selectedYankedVersions": {},
"moduleExtensions": {
+ "//bazel:web_third_party.bzl%web_third_party": {
+ "general": {
+ "bzlTransitiveDigest": "U0bamjCpbCfFRSI2m5Bmxk1Kwv01EemrMYkIvTIGci4=",
+ "usagesDigest": "IqaM2iVqafSgDsoybq533abDcZhaLdyhIyV945gLD3U=",
+ "recordedInputs": [
+ "REPO_MAPPING:,bazel_tools bazel_tools",
+ "FILE:@@//src/web/third-party/packages.json 1471b18e3b19892410734962f65e4ffd026bd21a5ac00dbadfea81ab2dedfefd"
+ ],
+ "generatedRepoSpecs": {
+ "web_leaflet": {
+ "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+ "attributes": {
+ "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nexports_files([\n \"LICENSE\",\n \"dist/images/layers-2x.png\",\n \"dist/images/layers.png\",\n \"dist/images/marker-icon-2x.png\",\n \"dist/images/marker-icon.png\",\n \"dist/images/marker-shadow.png\",\n \"dist/leaflet.css\",\n \"dist/leaflet.js\",\n])\n",
+ "sha256": "84c65a256e50657896f54c33bd857b6849ebe94c817803be818bf32a3dde0b77",
+ "strip_prefix": "package",
+ "urls": [
+ "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz"
+ ]
+ }
+ },
+ "web_golden_layout": {
+ "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+ "attributes": {
+ "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nexports_files([\n \"LICENSE\",\n \"dist/css/goldenlayout-base.css\",\n \"dist/css/themes/goldenlayout-dark-theme.css\",\n \"dist/css/themes/goldenlayout-light-theme.css\",\n \"dist/esm/index.js\",\n \"dist/img/lm_close_black.png\",\n \"dist/img/lm_close_tab_white.png\",\n \"dist/img/lm_close_white.png\",\n \"dist/img/lm_maximise_black.png\",\n \"dist/img/lm_maximise_white.png\",\n \"dist/img/lm_minimize_black.png\",\n \"dist/img/lm_minimize_white.png\",\n \"dist/img/lm_popin_black.png\",\n \"dist/img/lm_popin_white.png\",\n \"dist/img/lm_popout_black.png\",\n \"dist/img/lm_popout_white.png\",\n])\n\nfilegroup(\n name = \"bundle_tree\",\n srcs = glob([\"dist/esm/**\"], exclude = [\"**/*.map\"]),\n)\n",
+ "sha256": "2097b74f3d45a4770bc00360276e3251a6a74a88385f8cf7aa37ca6e6395a4bc",
+ "strip_prefix": "package",
+ "urls": [
+ "https://registry.npmjs.org/golden-layout/-/golden-layout-2.6.0.tgz"
+ ]
+ }
+ },
+ "web_three": {
+ "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+ "attributes": {
+ "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nexports_files([\n \"LICENSE\",\n \"build/three.module.min.js\",\n])\n",
+ "sha256": "1ee2f935c4f555814b388e87b5ef78a44856bd2e9d0feb88643a6e193fb42856",
+ "strip_prefix": "package",
+ "urls": [
+ "https://registry.npmjs.org/three/-/three-0.160.0.tgz"
+ ]
+ }
+ },
+ "web_elkjs": {
+ "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+ "attributes": {
+ "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nexports_files([\n \"LICENSE.md\",\n \"lib/elk.bundled.js\",\n])\n",
+ "sha256": "b95b224bd1ab71fd40f6d9a6365c28d989745dff6b0d514407bc1eb68f2f561e",
+ "strip_prefix": "package",
+ "urls": [
+ "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz"
+ ]
+ }
+ },
+ "web_netlistsvg": {
+ "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+ "attributes": {
+ "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nexports_files([\n \"LICENSE\",\n \"built/netlistsvg.bundle.js\",\n])\n",
+ "sha256": "f261a9840e8c244ab6721917081bda04138e500b6d5727e672ba1b635ee9ceec",
+ "strip_prefix": "package",
+ "urls": [
+ "https://registry.npmjs.org/netlistsvg/-/netlistsvg-1.0.2.tgz"
+ ]
+ }
+ },
+ "esbuild": {
+ "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+ "attributes": {
+ "build_file_content": "exports_files([\"bin/esbuild\"], visibility = [\"//visibility:public\"])\n",
+ "sha256": "9573bb2233aab0f9ea7647d5cca9726113cc1768de61d66b17267f4db84488f6",
+ "strip_prefix": "package",
+ "urls": [
+ "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz"
+ ]
+ }
+ },
+ "web_third_party": {
+ "repoRuleId": "@@//bazel:web_third_party.bzl%_assets_repo",
+ "attributes": {
+ "content": "# Generated by //bazel:web_third_party.bzl -- do not edit.\n\n# (served path, label of the file to serve there).\nWEB_THIRD_PARTY_ASSETS = [\n (\"/third-party/elkjs/LICENSE.md\", \"@web_elkjs//:LICENSE.md\"),\n (\"/third-party/elkjs/elk.bundled.js\", \"@web_elkjs//:lib/elk.bundled.js\"),\n (\"/third-party/golden-layout/LICENSE\", \"@web_golden_layout//:LICENSE\"),\n (\"/third-party/golden-layout/css/goldenlayout-base.css\", \"@web_golden_layout//:dist/css/goldenlayout-base.css\"),\n (\"/third-party/golden-layout/css/themes/goldenlayout-dark-theme.css\", \"@web_golden_layout//:dist/css/themes/goldenlayout-dark-theme.css\"),\n (\"/third-party/golden-layout/css/themes/goldenlayout-light-theme.css\", \"@web_golden_layout//:dist/css/themes/goldenlayout-light-theme.css\"),\n (\"/third-party/golden-layout/img/lm_close_black.png\", \"@web_golden_layout//:dist/img/lm_close_black.png\"),\n (\"/third-party/golden-layout/img/lm_close_tab_white.png\", \"@web_golden_layout//:dist/img/lm_close_tab_white.png\"),\n (\"/third-party/golden-layout/img/lm_close_white.png\", \"@web_golden_layout//:dist/img/lm_close_white.png\"),\n (\"/third-party/golden-layout/img/lm_maximise_black.png\", \"@web_golden_layout//:dist/img/lm_maximise_black.png\"),\n (\"/third-party/golden-layout/img/lm_maximise_white.png\", \"@web_golden_layout//:dist/img/lm_maximise_white.png\"),\n (\"/third-party/golden-layout/img/lm_minimize_black.png\", \"@web_golden_layout//:dist/img/lm_minimize_black.png\"),\n (\"/third-party/golden-layout/img/lm_minimize_white.png\", \"@web_golden_layout//:dist/img/lm_minimize_white.png\"),\n (\"/third-party/golden-layout/img/lm_popin_black.png\", \"@web_golden_layout//:dist/img/lm_popin_black.png\"),\n (\"/third-party/golden-layout/img/lm_popin_white.png\", \"@web_golden_layout//:dist/img/lm_popin_white.png\"),\n (\"/third-party/golden-layout/img/lm_popout_black.png\", \"@web_golden_layout//:dist/img/lm_popout_black.png\"),\n (\"/third-party/golden-layout/img/lm_popout_white.png\", \"@web_golden_layout//:dist/img/lm_popout_white.png\"),\n (\"/third-party/leaflet/LICENSE\", \"@web_leaflet//:LICENSE\"),\n (\"/third-party/leaflet/images/layers-2x.png\", \"@web_leaflet//:dist/images/layers-2x.png\"),\n (\"/third-party/leaflet/images/layers.png\", \"@web_leaflet//:dist/images/layers.png\"),\n (\"/third-party/leaflet/images/marker-icon-2x.png\", \"@web_leaflet//:dist/images/marker-icon-2x.png\"),\n (\"/third-party/leaflet/images/marker-icon.png\", \"@web_leaflet//:dist/images/marker-icon.png\"),\n (\"/third-party/leaflet/images/marker-shadow.png\", \"@web_leaflet//:dist/images/marker-shadow.png\"),\n (\"/third-party/leaflet/leaflet.css\", \"@web_leaflet//:dist/leaflet.css\"),\n (\"/third-party/leaflet/leaflet.js\", \"@web_leaflet//:dist/leaflet.js\"),\n (\"/third-party/netlistsvg/LICENSE\", \"@web_netlistsvg//:LICENSE\"),\n (\"/third-party/netlistsvg/netlistsvg.bundle.js\", \"@web_netlistsvg//:built/netlistsvg.bundle.js\"),\n (\"/third-party/three/LICENSE\", \"@web_three//:LICENSE\"),\n (\"/third-party/three/three.module.min.js\", \"@web_three//:build/three.module.min.js\"),\n]\n\n# Packages that publish no browser build and are bundled by the build.\nWEB_THIRD_PARTY_BUNDLES = [\n struct(\n package = \"golden-layout\",\n tree_target = \"@web_golden_layout//:bundle_tree\",\n entry_target = \"@web_golden_layout//:dist/esm/index.js\",\n tree = \"dist/esm\",\n entry = \"dist/esm/index.js\",\n out = \"third-party/golden-layout/golden-layout.esm.js\",\n ),\n]\n"
+ }
+ }
+ }
+ }
+ },
"@@apple_rules_lint+//lint:extensions.bzl%linter": {
"general": {
"bzlTransitiveDigest": "g7izj5kLCmsajh8IospHh4ZQ35dyM0FIrA8D4HapAsM=",
diff --git a/bazel/web_third_party.bzl b/bazel/web_third_party.bzl
new file mode 100644
index 00000000000..43851bba4d9
--- /dev/null
+++ b/bazel/web_third_party.bzl
@@ -0,0 +1,164 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright (c) 2026, The OpenROAD Authors
+
+"""The browser libraries the web viewer serves from the OpenROAD binary.
+
+They are fetched rather than checked in, so third-party/packages.json is the one
+place their versions live; this extension turns each entry there into an
+http_archive, and hands //src/web the served path -> label mapping through a
+generated assets.bzl. The CMake build reads the same manifest through
+third-party/fetch_packages.py.
+"""
+
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+_MANIFEST = Label("//src/web:third-party/packages.json")
+
+# What Bazel calls this host -> what npm calls it. The manifest owns which
+# platforms are pinned; this only names the one we are running on.
+_OS = {
+ "linux": "linux",
+ "mac os x": "darwin",
+}
+
+_ARCH = {
+ "aarch64": "arm64",
+ "amd64": "x64",
+ "arm64": "arm64",
+ "x86_64": "x64",
+}
+
+def _tarball_url(registry, name, version):
+ # Scoped names live at @scope/name/-/name-version.tgz.
+ basename = name.split("/")[-1]
+ return "{}/{}/-/{}-{}.tgz".format(registry, name, basename, version)
+
+def _host_platform(module_ctx):
+ os_name = _OS.get(module_ctx.os.name)
+ arch = _ARCH.get(module_ctx.os.arch)
+ if not os_name or not arch:
+ fail("no esbuild binary is pinned for {}/{}; add the platform to ".format(
+ module_ctx.os.name,
+ module_ctx.os.arch,
+ ) + "esbuild.platforms in packages.json and to the maps in this file")
+ return os_name + "-" + arch
+
+def _build_file(exported, bundle):
+ lines = [
+ 'package(default_visibility = ["//visibility:public"])',
+ "",
+ "exports_files([",
+ ]
+ lines += [' "{}",'.format(path) for path in exported]
+ lines.append("])")
+ if bundle:
+ # The source maps are a third of the tree and esbuild is not asked for
+ # one, so they would only be staged into the action for nothing.
+ lines += [
+ "",
+ "filegroup(",
+ ' name = "bundle_tree",',
+ ' srcs = glob(["{}/**"], exclude = ["**/*.map"]),'.format(bundle["tree"]),
+ ")",
+ ]
+ return "\n".join(lines) + "\n"
+
+def _assets_bzl(assets, bundles):
+ """The manifest as Starlark, so //src/web does not restate it."""
+ lines = [
+ "# Generated by //bazel:web_third_party.bzl -- do not edit.",
+ "",
+ "# (served path, label of the file to serve there).",
+ "WEB_THIRD_PARTY_ASSETS = [",
+ ]
+ lines += [' ("{}", "{}"),'.format(served, label) for served, label in assets]
+ lines += [
+ "]",
+ "",
+ "# Packages that publish no browser build and are bundled by the build.",
+ "WEB_THIRD_PARTY_BUNDLES = [",
+ ]
+ for bundle in bundles:
+ lines += [
+ " struct(",
+ ' package = "{}",'.format(bundle["package"]),
+ ' tree_target = "{}",'.format(bundle["tree_target"]),
+ ' entry_target = "{}",'.format(bundle["entry_target"]),
+ ' tree = "{}",'.format(bundle["tree"]),
+ ' entry = "{}",'.format(bundle["entry"]),
+ ' out = "{}",'.format(bundle["out"]),
+ " ),",
+ ]
+ lines.append("]")
+ return "\n".join(lines) + "\n"
+
+def _assets_repo_impl(repository_ctx):
+ repository_ctx.file("BUILD", "")
+ repository_ctx.file("assets.bzl", repository_ctx.attr.content)
+
+_assets_repo = repository_rule(
+ implementation = _assets_repo_impl,
+ attrs = {"content": attr.string(mandatory = True)},
+)
+
+def _web_third_party_impl(module_ctx):
+ manifest = json.decode(module_ctx.read(_MANIFEST))
+ registry = manifest["registry"]
+
+ assets = []
+ bundles = []
+ for name, spec in manifest["packages"].items():
+ repo = "web_" + name.replace("-", "_")
+ bundle = spec.get("bundle")
+ exported = sorted(spec["files"].keys())
+ if bundle:
+ exported = sorted(exported + [bundle["entry"]])
+
+ http_archive(
+ name = repo,
+ build_file_content = _build_file(exported, bundle),
+ sha256 = spec["sha256"],
+ strip_prefix = "package",
+ urls = [_tarball_url(registry, name, spec["version"])],
+ )
+
+ for source, served in spec["files"].items():
+ assets.append(
+ ("/third-party/" + served, "@{}//:{}".format(repo, source)),
+ )
+ if bundle:
+ bundles.append({
+ "entry": bundle["entry"],
+ "entry_target": "@{}//:{}".format(repo, bundle["entry"]),
+ # A genrule output, so it is a path in //src/web, not a label.
+ "out": "third-party/" + bundle["output"],
+ "package": name,
+ "tree": bundle["tree"],
+ "tree_target": "@{}//:bundle_tree".format(repo),
+ })
+
+ # esbuild runs on whoever is building, so the binary is picked here rather
+ # than through a select() on the target platform.
+ esbuild = manifest["esbuild"]
+ platform = _host_platform(module_ctx)
+ if platform not in esbuild["platforms"]:
+ fail("esbuild {} for {} is not pinned in packages.json".format(
+ esbuild["version"],
+ platform,
+ ))
+ http_archive(
+ name = "esbuild",
+ build_file_content = (
+ 'exports_files(["bin/esbuild"], visibility = ["//visibility:public"])\n'
+ ),
+ sha256 = esbuild["platforms"][platform],
+ strip_prefix = "package",
+ urls = [_tarball_url(registry, "@esbuild/" + platform, esbuild["version"])],
+ )
+
+ _assets_repo(
+ name = "web_third_party",
+ content = _assets_bzl(sorted(assets), bundles),
+ )
+
+web_third_party = module_extension(implementation = _web_third_party_impl)
diff --git a/src/web/BUILD b/src/web/BUILD
index 0d30b4de47b..ebb422716ab 100644
--- a/src/web/BUILD
+++ b/src/web/BUILD
@@ -3,6 +3,7 @@
load("@rules_cc//cc:cc_library.bzl", "cc_library")
load("@rules_python//python:defs.bzl", "py_binary")
+load("@web_third_party//:assets.bzl", "WEB_THIRD_PARTY_ASSETS", "WEB_THIRD_PARTY_BUNDLES")
load("//bazel:tcl_encode_or.bzl", "tcl_encode")
load("//bazel:tcl_wrap_cc.bzl", "tcl_wrap_cc")
@@ -11,6 +12,16 @@ package(
features = ["layering_check"],
)
+# Read by //src/web/test:asset_list_parity_test, which holds the two build
+# systems' asset lists to each other, and by the test of the report bundler.
+exports_files([
+ "BUILD",
+ "CMakeLists.txt",
+ "src/embed_report_assets.py",
+ "third-party/fetch_packages.py",
+ "third-party/packages.json",
+])
+
py_binary(
name = "embed_report_assets",
srcs = ["src/embed_report_assets.py"],
@@ -23,65 +34,76 @@ py_binary(
main = "src/embed_web_assets.py",
)
-genrule(
- name = "report_assets",
+# The CMake build fetches with this; here it is only the esbuild invocation, so
+# that both build systems bundle golden-layout the same way down to the bytes.
+py_binary(
+ name = "fetch_packages",
+ srcs = ["third-party/fetch_packages.py"],
+ data = ["third-party/packages.json"],
+ main = "third-party/fetch_packages.py",
+)
+
+# golden-layout publishes no browser build, and the saved report needs one file:
+# a relative import does not resolve from a data: URI. The paths come from the
+# manifest, so the script needs nothing but what is on the command line.
+[genrule(
+ name = "bundle_" + bundle.package.replace("-", "_"),
srcs = [
- "src/style.css",
- "src/theme.js",
- "src/3d-viewer-widget.js",
- "src/coordinates.js",
- "src/ui-utils.js",
- "src/checkbox-tree-model.js",
- "src/vis-tree.js",
- "src/websocket-manager.js",
- "src/tile-merge.js",
- "src/tile-request.js",
- "src/device-pixels.js",
- "src/websocket-tile-layer.js",
- "src/merged-tile-layer.js",
- "src/display-controls.js",
- "src/inspector.js",
- "src/ruler.js",
- "src/tcl-completer.js",
- "src/hierarchy-browser.js",
- "src/menu-bar.js",
- "src/title.js",
- "src/clock-tree-widget.js",
- "src/schematic-widget.js",
- "src/charts-widget.js",
- "src/timing-widget.js",
- "src/drc-widget.js",
- "src/main.js",
+ bundle.tree_target,
+ bundle.entry_target,
],
+ outs = [bundle.out],
+ cmd = "$(execpath :fetch_packages) --bundle" +
+ " --esbuild $(execpath @esbuild//:bin/esbuild)" +
+ " --tree " + bundle.tree +
+ " --entry " + bundle.entry +
+ " --entry-path $(execpath " + bundle.entry_target + ")" +
+ " --output $@",
+ tools = [
+ ":fetch_packages",
+ "@esbuild//:bin/esbuild",
+ ],
+) for bundle in WEB_THIRD_PARTY_BUNDLES]
+
+# The report inlines these into one ", masked, re.DOTALL):
+ if "src=" in match.group(1):
+ continue
+ start, end = match.span(2)
+ body = html[start:end]
+ digest = hashlib.sha256(body.encode("utf-8")).digest()
+ hashes.append(f"'sha256-{base64.b64encode(digest).decode('ascii')}'")
+ return hashes
+
+
+def parse_asset_arg(arg):
+ served, sep, path = arg.partition("=")
+ if not sep:
+ raise SystemExit(f"expected =, got: {arg}")
+ if not served.startswith("/"):
+ raise SystemExit(f"served path must be absolute: {served}")
+ return served, path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output", "-o", required=True)
- parser.add_argument("files", nargs="+", help="Asset files to embed")
+ parser.add_argument(
+ "assets", nargs="+", help="Assets to embed, as ="
+ )
args = parser.parse_args()
assets = []
- for path in args.files:
- filename = os.path.basename(path)
- ext = os.path.splitext(filename)[1]
- mime = MIME_TYPES.get(ext, "application/octet-stream")
- with open(path, encoding="utf-8") as f:
- content = f.read()
- assets.append((filename, c_identifier(filename), mime, content))
-
- # Use a delimiter unlikely to appear in JS/CSS/HTML content.
- delim = "__WEB_ASSET__"
-
- with open(args.output, "w", encoding="utf-8") as out:
+ seen = {}
+ identifiers = {}
+ for arg in args.assets:
+ served, path = parse_asset_arg(arg)
+ if served in seen:
+ raise SystemExit(
+ f"two assets are served as {served}: {seen[served]} and {path}"
+ )
+ seen[served] = path
+ # c_identifier() folds every non-alphanumeric to _, so two served paths
+ # can collide into one name.
+ ident = c_identifier(served)
+ if ident in identifiers:
+ raise SystemExit(f"{served} and {identifiers[ident]} both generate {ident}")
+ identifiers[ident] = served
+ mime = MIME_TYPES.get(os.path.splitext(served)[1], "application/octet-stream")
+ with open(path, "rb") as f:
+ data = f.read()
+ assets.append((served, ident, mime, data))
+
+ hashes = " ".join(inline_script_hashes(assets))
+
+ # Written aside and renamed: a failure must not leave a truncated .cpp
+ # newer than its inputs, which the next build would keep.
+ partial = args.output + ".tmp"
+ with open(partial, "w", encoding="utf-8") as out:
out.write("// Auto-generated by embed_web_assets.py — do not edit.\n")
out.write('#include "web_assets.h"\n\n')
+ out.write("#include \n")
+ out.write("#include \n")
out.write("#include \n\n")
out.write("namespace web {\n\n")
- # Write each asset as a raw string literal.
- for filename, ident, mime, content in assets:
- out.write(f"// {filename}\n")
- out.write(f'static const char {ident}_data[] = R"{delim}(')
- out.write(content)
- out.write(f'){delim}";\n\n')
-
- # Write the lookup table.
- out.write("static const struct {\n")
- out.write(" const char* path;\n")
- out.write(" EmbeddedAsset asset;\n")
- out.write(f"}} kAssetTable[] = {{\n")
- for filename, ident, mime, _ in assets:
+ for served, ident, _, data in assets:
+ out.write(f"// {served}\n")
+ text = as_text(data)
+ if text is None:
+ write_binary_asset(out, ident, data)
+ else:
+ write_text_asset(out, ident, text)
+
+ # The lookup table. Sizes are the byte counts, not sizeof - 1: a
+ # binary asset may contain a NUL and every literal above is escaped.
+ out.write("static const EmbeddedAssetEntry kAssetTable[] = {\n")
+ for served, ident, mime, data in assets:
out.write(
- f' {{"/{filename}", '
- f'{{{ident}_data, sizeof({ident}_data) - 1, "{mime}"}}}},\n'
+ f' {{"{served}", ' f'{{{ident}_data, {len(data)}, "{mime}"}}}},\n'
)
out.write("};\n\n")
@@ -77,8 +177,25 @@ def main():
out.write(" return nullptr;\n")
out.write("}\n\n")
+ out.write("size_t embeddedAssetCount()\n")
+ out.write("{\n")
+ out.write(" return std::size(kAssetTable);\n")
+ out.write("}\n\n")
+
+ out.write("const EmbeddedAssetEntry& embeddedAssetAt(const size_t index)\n")
+ out.write("{\n")
+ out.write(" return kAssetTable[index];\n")
+ out.write("}\n\n")
+
+ out.write("std::string_view inlineScriptHashes()\n")
+ out.write("{\n")
+ out.write(f' return "{hashes}";\n')
+ out.write("}\n\n")
+
out.write("} // namespace web\n")
+ os.replace(partial, args.output)
+
if __name__ == "__main__":
main()
diff --git a/src/web/src/index.html b/src/web/src/index.html
index 0593dba52d0..44b8b410709 100644
--- a/src/web/src/index.html
+++ b/src/web/src/index.html
@@ -5,19 +5,31 @@
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+
+
diff --git a/src/web/src/main.js b/src/web/src/main.js
index 1a5851f6b16..690ca866378 100644
--- a/src/web/src/main.js
+++ b/src/web/src/main.js
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026, The OpenROAD Authors
-import { GoldenLayout, LayoutConfig } from 'https://esm.sh/golden-layout@2.6.0';
+// Resolved by the import map in index.html to the copy under third-party/
+// that the OpenROAD binary serves (issue #11065).
+import { GoldenLayout, LayoutConfig } from 'golden-layout';
import { latLngToDbu } from './coordinates.js';
import { WebSocketManager } from './websocket-manager.js';
import {
diff --git a/src/web/src/web.cpp b/src/web/src/web.cpp
index 2653fe62d33..bc2c70c48bd 100644
--- a/src/web/src/web.cpp
+++ b/src/web/src/web.cpp
@@ -5,12 +5,14 @@
#include
+#include
#include
#include
#include
#include
#include
#include
+#include
#include
#include
#include
@@ -34,6 +36,7 @@
#include "boost/json/serialize.hpp"
#include "boost/json/value.hpp"
#include "clock_tree_report.h"
+#include "css_inliner.h"
#include "gui/heatMap.h"
#include "hierarchy_report.h"
#include "odb/db.h"
@@ -41,6 +44,7 @@
#include "odb/dbChipCallBackObj.h"
#include "request_dispatcher.h"
#include "request_handler.h"
+#include "sta/StringUtil.hh"
#include "tcl.h"
#include "tile_generator.h"
#include "timing_report.h"
@@ -91,6 +95,7 @@ static http::response handle_request(
res.set(http::field::content_type, "text/plain");
res.keep_alive(req.keep_alive());
res.set(http::field::access_control_allow_origin, "*");
+ res.set("Content-Security-Policy", contentSecurityPolicy());
if (req.method() == http::verb::get) {
const std::string file_path = assetPathFromTarget(req.target());
@@ -1021,25 +1026,180 @@ WebServer::~WebServer()
extern const std::string_view kReportCSS;
extern const std::string_view kReportJS;
-static std::string base64Encode(const std::vector& data)
+static std::string base64Encode(const std::string_view data)
{
static const char kChars[]
= "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ const size_t size = data.size();
+ const auto byte = [data](const size_t i) {
+ return static_cast(static_cast(data[i]));
+ };
std::string result;
- result.reserve((data.size() + 2) / 3 * 4);
- for (size_t i = 0; i < data.size(); i += 3) {
- const unsigned b0 = data[i];
- const unsigned b1 = (i + 1 < data.size()) ? data[i + 1] : 0;
- const unsigned b2 = (i + 2 < data.size()) ? data[i + 2] : 0;
+ result.reserve((size + 2) / 3 * 4);
+ for (size_t i = 0; i < size; i += 3) {
+ const unsigned b0 = byte(i);
+ const unsigned b1 = (i + 1 < size) ? byte(i + 1) : 0;
+ const unsigned b2 = (i + 2 < size) ? byte(i + 2) : 0;
result += kChars[b0 >> 2];
result += kChars[((b0 & 3) << 4) | (b1 >> 4)];
- result
- += (i + 1 < data.size()) ? kChars[((b1 & 0xF) << 2) | (b2 >> 6)] : '=';
- result += (i + 2 < data.size()) ? kChars[b2 & 0x3F] : '=';
+ result += (i + 1 < size) ? kChars[((b1 & 0xF) << 2) | (b2 >> 6)] : '=';
+ result += (i + 2 < size) ? kChars[b2 & 0x3F] : '=';
}
return result;
}
+static std::string base64Encode(const std::vector& data)
+{
+ return base64Encode(std::string_view(
+ reinterpret_cast(data.data()), data.size()));
+}
+
+// ── Inlining the browser libraries into the saved report ──
+// The report is one file, opened with no server behind it, so every asset the
+// viewer loads is inlined here as a data: URI (issue #11065).
+
+std::string resolveAssetPath(const std::string_view base_dir,
+ const std::string_view reference)
+{
+ return (std::filesystem::path(base_dir) / std::filesystem::path(reference))
+ .lexically_normal()
+ .string();
+}
+
+const EmbeddedAsset* ReportAssets::find(const std::string_view path)
+{
+ const EmbeddedAsset* asset = findEmbeddedAsset(path);
+ if (!asset) {
+ // A miss is a binary built with the wrong asset list, not bad input.
+ logger_->warn(utl::WEB, 44, "Missing embedded asset {}.", path);
+ failed_ = true;
+ }
+ return asset;
+}
+
+void ReportAssets::fail(const std::string_view reason)
+{
+ logger_->warn(utl::WEB, 46, "Cannot inline the report assets: {}.", reason);
+ failed_ = true;
+}
+
+// data: URI for an embedded asset, for use as a src or href in the report.
+static std::string assetDataUri(const std::string_view path,
+ ReportAssets& assets)
+{
+ const EmbeddedAsset* asset = assets.find(path);
+ if (!asset) {
+ return "";
+ }
+ return std::string("data:") + asset->content_type + ";base64,"
+ + base64Encode(asset->content());
+}
+
+size_t findUrlToken(const std::string_view css, const size_t from)
+{
+ // Driven off the '(' so the scan is a memchr and not a byte loop.
+ for (size_t paren = from;
+ (paren = css.find('(', paren)) != std::string_view::npos;
+ ++paren) {
+ if (paren < 3) {
+ continue;
+ }
+ const size_t at = paren - 3;
+ if (at < from || !sta::stringBeginEqual(css.substr(at), "url")) {
+ continue;
+ }
+ const char before = at > 0 ? css[at - 1] : ' ';
+ if (std::isalnum(static_cast(before)) == 0 && before != '_'
+ && before != '-') {
+ return at;
+ }
+ }
+ return std::string_view::npos;
+}
+
+std::string inlineStylesheetUrls(const std::string_view css,
+ const std::string_view base_dir,
+ ReportAssets& assets)
+{
+ std::string result;
+ size_t pos = 0;
+ while (true) {
+ const size_t open = findUrlToken(css, pos);
+ if (open == std::string_view::npos) {
+ break;
+ }
+ // Every exit below abandons the scan, so it has to say so: the rest of the
+ // stylesheet is copied through with its references still relative.
+ //
+ // A quoted reference may hold a parenthesis, so its closing quote bounds
+ // the token; an unquoted one ends at the ')'.
+ const size_t first = css.find_first_not_of(" \t\r\n", open + 4);
+ if (first == std::string_view::npos) {
+ assets.fail("a url( token runs to the end of the stylesheet");
+ break;
+ }
+ size_t close = std::string_view::npos;
+ std::string_view reference;
+ if (css[first] == '"' || css[first] == '\'') {
+ const size_t quote = css.find(css[first], first + 1);
+ if (quote == std::string_view::npos) {
+ assets.fail("a quoted url() reference is never closed");
+ break;
+ }
+ close = css.find(')', quote + 1);
+ reference = css.substr(first + 1, quote - first - 1);
+ } else {
+ close = css.find(')', first);
+ if (close == std::string_view::npos) {
+ assets.fail("a url() token is never closed");
+ break;
+ }
+ reference = css.substr(first, close - first);
+ while (!reference.empty()
+ && std::isspace(static_cast(reference.back()))
+ != 0) {
+ reference.remove_suffix(1);
+ }
+ }
+ if (close == std::string_view::npos) {
+ assets.fail("a url() token is never closed");
+ break;
+ }
+
+ // Fragment-only references (url(#default#VML)) and anything already
+ // inlined are left alone.
+ if (reference.empty() || reference.front() == '#'
+ || reference.starts_with("data:")) {
+ result += css.substr(pos, close + 1 - pos);
+ pos = close + 1;
+ continue;
+ }
+
+ result += css.substr(pos, open - pos);
+ result += "url(\"";
+ result += assetDataUri(resolveAssetPath(base_dir, reference), assets);
+ result += "\")";
+ pos = close + 1;
+ }
+ result += css.substr(pos);
+ return result;
+}
+
+// data: URI for an embedded stylesheet, with its own references inlined
+// against the directory it is served from.
+static std::string stylesheetDataUri(const std::string_view path,
+ ReportAssets& assets)
+{
+ const EmbeddedAsset* asset = assets.find(path);
+ if (!asset) {
+ return "";
+ }
+ const std::string_view base_dir = path.substr(0, path.rfind('/') + 1);
+ return "data:text/css;base64,"
+ + base64Encode(
+ inlineStylesheetUrls(asset->content(), base_dir, assets));
+}
+
void WebServer::saveReport(const std::string& filename,
const int max_setup,
const int max_hold)
@@ -1061,6 +1221,7 @@ void WebServer::saveReport(const std::string& filename,
logger_->error(utl::WEB, 31, "Cannot open file: {}", filename);
return;
}
+ ReportAssets assets(logger_);
// ── Serialize JSON cache responses ──
@@ -1177,20 +1338,46 @@ void WebServer::saveReport(const std::string& filename,
// ── Write the HTML ──
- // HTML head — same CDN deps as index.html.
+ // HTML head — leaflet, golden-layout and three, inlined as data: URIs so the
+ // file opens with no server and no network. elk and netlistsvg are left out:
+ // they are 2.8 MB for a schematic panel that needs the server anyway, and the
+ // widget already stands down when it does not find them. The stylesheets
+ // stay elements rather than
@@ -1272,9 +1459,20 @@ window.__STATIC_CACHE__ = {
}
};
+
@@ -1283,6 +1481,16 @@ import * as THREE from 'https://esm.sh/three@0.160.0';
)";
out.close();
+
+ if (assets.failed()) {
+ // The warnings above name what went wrong; no one is told this was saved.
+ std::filesystem::remove(filename);
+ logger_->error(utl::WEB,
+ 45,
+ "Not saving {}: its assets could not all be inlined.",
+ filename);
+ return;
+ }
logger_->info(utl::WEB, 32, "Saved timing report to {}", filename);
}
diff --git a/src/web/src/web_assets.h b/src/web/src/web_assets.h
index f758b796d0f..60a4d0428cf 100644
--- a/src/web/src/web_assets.h
+++ b/src/web/src/web_assets.h
@@ -4,6 +4,7 @@
#pragma once
#include
+#include
#include
namespace web {
@@ -17,8 +18,61 @@ struct EmbeddedAsset
std::string_view content() const { return {data, size}; }
};
+struct EmbeddedAssetEntry
+{
+ std::string_view path;
+ EmbeddedAsset asset;
+};
+
// Returns the embedded asset for the given URL path (e.g. "/index.html"),
// or nullptr if not found.
const EmbeddedAsset* findEmbeddedAsset(std::string_view path);
+// Iteration over the whole asset table, for the test that holds every asset to
+// the rule that none of them loads anything remote.
+size_t embeddedAssetCount();
+const EmbeddedAssetEntry& embeddedAssetAt(size_t index);
+
+// CSP source expressions for the inline ", begin);
+ ASSERT_NE(end, std::string::npos);
+ const std::string module
+ = html.substr(begin + opening.size(), end - begin - opening.size());
+
+ // The generator marks every source, so the first marker is the boundary:
+ // before it the header's imports, after it code with no module syntax.
+ const size_t body = module.find("// ── ");
+ ASSERT_NE(body, std::string::npos);
+
+ int header_imports = 0;
+ for (size_t at = 0; at < module.size();) {
+ const size_t eol = std::min(module.find('\n', at), module.size());
+ std::string_view line(module.data() + at, eol - at);
+ const bool in_header = at < body;
+ at = eol + 1;
+
+ const size_t indent = line.find_first_not_of(" \t");
+ if (indent == std::string_view::npos) {
+ continue;
+ }
+ line.remove_prefix(indent);
+ if (!isKeyword(line, "import") && !isKeyword(line, "export")) {
+ continue;
+ }
+ if (in_header && isKeyword(line, "import")) {
+ ++header_imports;
+ continue;
+ }
+ ADD_FAILURE() << "leftover module syntax: " << line;
+ }
+ // GoldenLayout and THREE, however they are resolved.
+ EXPECT_EQ(header_imports, 2);
+}
+
+// The report opens from file:// with nothing behind it, so nothing in it may
+// point at a remote host (issue #11065).
+TEST_F(SaveReportTest, IsSelfContained)
+{
+ const std::string path = tempHtml("self_contained");
+ generateReport(path);
+ const std::string html = readFile(path);
+
+ // No attribute, url() or specifier may name a remote origin. Bare "http://"
+ // is left alone: the widgets carry XML namespaces, which are identifiers.
+ for (const char* fetch : {"src=\"http",
+ "src='http",
+ "href=\"http",
+ "href='http",
+ "url(http",
+ "url(\"http",
+ "url('http",
+ "from 'http",
+ "from \"http"}) {
+ EXPECT_FALSE(contains(html, fetch)) << fetch;
+ }
+ // leaflet as a classic script, three and golden-layout as ES modules the
+ // import map redirects to their inlined copies.
+ EXPECT_TRUE(
+ contains(html, "