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, "", + ), + # This one used to hash the comment away, so the browser refused it. + "a block containing \nwindow.x = 1;\n", + "", + ), + } + for name, (body, template) in cases.items(): + html = template.format(body=body) + check( + f"{name} hashes verbatim", + embed.inline_script_hashes(page(html)) == [expected(body)], + ) + + +def a_block_inside_a_comment_is_still_ignored(): + """Why the comments are masked at all: the reason must survive the fix.""" + body = "\nwindow.real = 1;\n" + html = ( + "" + "" + f"" + "" + ) + check( + "a commented-out block is not hashed", + embed.inline_script_hashes(page(html)) == [expected(body)], + ) + + +def a_sourced_block_has_no_hash(): + html = '' + check("src= blocks are skipped", embed.inline_script_hashes(page(html)) == []) + + +def a_second_page_fails_the_build(): + """One policy is served with every response, so two pages cannot share it.""" + html = "" + assets = page(html) + [ + ("/other.html", "k_other_html", "text/html", html.encode("utf-8")) + ] + check_raises("a second HTML asset", lambda: embed.inline_script_hashes(assets)) + + +def main(): + each_block_hashes_the_bytes_that_are_served() + a_block_inside_a_comment_is_still_ignored() + a_sourced_block_has_no_hash() + a_second_page_fails_the_build() + return report("embed_web_assets.py hashes the bytes it embeds") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/web/test/fetch_packages_test.py b/src/web/test/fetch_packages_test.py new file mode 100755 index 00000000000..dc104a20e4f --- /dev/null +++ b/src/web/test/fetch_packages_test.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, The OpenROAD Authors +# +# fetch_packages.py empties --dest before filling it, and skips the whole fetch +# when a stamp matches. Both are quiet when wrong: the first takes whatever +# directory it is handed, the second serves a stale tree as if it were current. +# Neither needs the network to pin, so no case here reaches it. + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +# Set before the sibling import, so no __pycache__ lands in the source tree. +sys.dont_write_bytecode = True +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from script_test import check, load_module, load_script, report # noqa: E402 + +SOURCE_DIR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "third-party" +) +SCRIPT = os.path.join(SOURCE_DIR, "fetch_packages.py") + +fetch = load_script("third-party/fetch_packages.py") + + +def run(dest, *extra): + """Run the script for real. Every case here stops before the network.""" + return subprocess.run( + [sys.executable, SCRIPT, "--dest", dest, *extra], + capture_output=True, + text=True, + check=False, + ) + + +def a_directory_we_did_not_write_is_refused(): + """The case that would delete the manifest and the script itself.""" + with tempfile.TemporaryDirectory() as work: + dest = os.path.join(work, "third-party") + os.makedirs(dest) + keep = os.path.join(dest, "packages.json") + with open(keep, "w", encoding="utf-8") as f: + f.write("{}\n") + + result = run(dest) + check( + "a directory with no .stamp is refused", + result.returncode != 0, + f"exited {result.returncode}", + ) + check("the refusal says why", ".stamp" in result.stderr, result.stderr.strip()) + check("nothing was deleted", os.path.exists(keep)) + + +def the_source_tree_is_refused(): + """The directory the script lives in, which WEB_THIRD_PARTY invites.""" + result = run(SOURCE_DIR) + check( + "the script's own directory is refused", + result.returncode != 0, + f"exited {result.returncode}", + ) + for name in ("packages.json", "fetch_packages.py"): + check(f"{name} survived", os.path.exists(os.path.join(SOURCE_DIR, name))) + + +def a_matching_stamp_is_reused(): + """A tree a previous run left is kept, and still described to CMake.""" + with tempfile.TemporaryDirectory() as work: + dest = os.path.join(work, "third-party") + os.makedirs(dest) + with open(os.path.join(dest, ".stamp"), "w", encoding="utf-8") as f: + f.write(fetch.stamp_value() + "\n") + marker = os.path.join(dest, "leaflet", "leaflet.js") + os.makedirs(os.path.dirname(marker)) + with open(marker, "w", encoding="utf-8") as f: + f.write("// the tree a previous run left\n") + emitted = os.path.join(work, "web_third_party.cmake") + + result = run(dest, "--emit-cmake", emitted) + check("the tree was not refetched", os.path.exists(marker)) + check( + "a current stamp short-circuits the fetch", + result.returncode == 0, + result.stderr.strip(), + ) + # Written from the manifest, not carried over from the earlier run, so + # there is no stale file to detect. + check("the emitted CMake was still written", os.path.exists(emitted)) + if os.path.exists(emitted): + check( + "it names this tree", marker in open(emitted, encoding="utf-8").read() + ) + + +def the_emitted_paths_come_from_the_manifest(): + """asset_paths() is what both the fetch and the reuse path emit.""" + manifest = fetch.read_manifest() + assets = fetch.asset_paths(manifest, "/somewhere") + expected = sum( + len(s["files"]) + ("bundle" in s) for s in manifest["packages"].values() + ) + check("every served file is listed", len(assets) == expected, f"{len(assets)}") + check( + "the served paths are absolute under /third-party/", + all(p.startswith("/third-party/") for p in assets), + ) + check( + "the file paths are under the given dest", + all(p.startswith("/somewhere/") for p in assets.values()), + ) + + +def the_stamp_covers_its_inputs(): + """A change to what is fetched, or to how, has to invalidate a tree.""" + + def bump_version(text): + manifest = json.loads(text) + name = sorted(manifest["packages"])[0] + manifest["packages"][name]["version"] += "-bumped" + return json.dumps(manifest) + + edits = [ + ("editing the script", "fetch_packages.py", lambda t: t + "\n# a change\n"), + ("bumping a version", "packages.json", bump_version), + ] + with tempfile.TemporaryDirectory() as work: + copy = os.path.join(work, "third-party") + shutil.copytree(SOURCE_DIR, copy, ignore=shutil.ignore_patterns("__pycache__")) + script = os.path.join(copy, "fetch_packages.py") + base = load_module(script).stamp_value() + check("the same inputs give the same stamp", base == fetch.stamp_value()) + + for what, name, edit in edits: + path = os.path.join(copy, name) + with open(path, encoding="utf-8") as f: + original = f.read() + with open(path, "w", encoding="utf-8") as f: + f.write(edit(original)) + check( + f"{what} changes the stamp", load_module(script).stamp_value() != base + ) + with open(path, "w", encoding="utf-8") as f: + f.write(original) + + +def main(): + a_directory_we_did_not_write_is_refused() + the_source_tree_is_refused() + a_matching_stamp_is_reused() + the_emitted_paths_come_from_the_manifest() + the_stamp_covers_its_inputs() + return report("fetch_packages.py guards --dest and stamps its own inputs") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/web/test/js/test-golden-layout-bundle.js b/src/web/test/js/test-golden-layout-bundle.js new file mode 100644 index 00000000000..9898eeeaf9c --- /dev/null +++ b/src/web/test/js/test-golden-layout-bundle.js @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors +// +// golden-layout publishes no browser build, so the build bundles its dist/esm +// tree. A version bump can leave that bundling silently producing a module the +// viewer cannot use; main.js only asks for these two names, so this is what +// catches it. + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const bundle = await import( + '../../third-party/golden-layout/golden-layout.esm.js'); + +describe('the golden-layout bundle', () => { + it('exports what main.js imports', () => { + assert.equal(typeof bundle.GoldenLayout, 'function'); + // A merged TypeScript namespace, not a class: main.js reaches it for + // LayoutConfig.fromResolved(). + assert.equal(typeof bundle.LayoutConfig.fromResolved, 'function'); + }); + + it('resolves its own internal imports', () => { + // A bundle that lost a module still imports; it fails when a class up + // the chain is undefined at `extends` time, which is here. + assert.equal(typeof bundle.GoldenLayout.prototype.loadLayout, 'function'); + }); +}); diff --git a/src/web/test/script_test.py b/src/web/test/script_test.py new file mode 100755 index 00000000000..32f6a2d815a --- /dev/null +++ b/src/web/test/script_test.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, The OpenROAD Authors +# +# Shared scaffolding for the tests that exercise src/web's build scripts. Those +# scripts are executables rather than a package, so each test imports one by +# path and reports every failure it finds instead of stopping at the first. + +import importlib.util +import os +import sys + +# Importing by path would otherwise leave __pycache__ in the source tree. +sys.dont_write_bytecode = True + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) + +failures = [] + + +def load_module(path): + """Import a script by path, without registering it in sys.modules.""" + spec = importlib.util.spec_from_file_location("script_under_test", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def load_script(relative_path): + """Import one of src/web's scripts, named relative to src/web.""" + return load_module(os.path.join(THIS_DIR, "..", relative_path)) + + +def check(name, condition, detail=""): + if not condition: + failures.append(f"{name}: {detail}" if detail else name) + + +def check_raises(name, call): + try: + call() + except SystemExit: + return + failures.append(f"{name}: expected SystemExit, none raised") + + +def report(message): + """Exit code for the test: the failures, or the all-clear.""" + if failures: + print("\n".join(failures), file=sys.stderr) + return 1 + print(message) + return 0 diff --git a/src/web/test/visual/.gitignore b/src/web/test/visual/.gitignore index b4bb1740444..be8a58d5797 100644 --- a/src/web/test/visual/.gitignore +++ b/src/web/test/visual/.gitignore @@ -1,4 +1,5 @@ node_modules/ package-lock.json +third-party/ *.png *.svg diff --git a/src/web/test/visual/README.md b/src/web/test/visual/README.md index 2f300f375c8..cb5fb20314a 100644 --- a/src/web/test/visual/README.md +++ b/src/web/test/visual/README.md @@ -11,8 +11,10 @@ The viewer renders gates natively via netlistsvg: the server tags cells with a skin draws, and netlistsvg lays them out. This tool serves `src/` directly, so it picks up edits to `openroad_skin.svg` and `schematic-widget.js` immediately. -This is a developer tool, not part of CI: it needs a local Chrome and loads the -netlistsvg JS bundle from its CDN (same as the viewer itself). +This is a developer tool, not part of CI: it needs a local Chrome. elk and +netlistsvg are the builds the viewer serves; the first run fetches them into +`./third-party`, or set `WEB_THIRD_PARTY` to a directory a build already +populated (`/src/web/third-party` under CMake). ## Setup (one-time) diff --git a/src/web/test/visual/preview.mjs b/src/web/test/visual/preview.mjs index 9cb6be92778..c1a97b37aa8 100644 --- a/src/web/test/visual/preview.mjs +++ b/src/web/test/visual/preview.mjs @@ -12,20 +12,29 @@ // cd src/web/test/visual && npm i puppeteer-core # one-time // node preview.mjs [outDir] // -// Requires google-chrome (or set CHROME=/path/to/chrome) and network access -// (netlistsvg is loaded from its CDN, same as the real viewer). +// Requires google-chrome (or set CHROME=/path/to/chrome). elk and netlistsvg +// are the same builds the real viewer serves: the build does not keep them +// anywhere this can reach, so they are fetched into ./third-party on first run. +// Set WEB_THIRD_PARTY to a directory a build already populated to skip that. import puppeteer from 'puppeteer-core'; -import { writeFileSync, readFileSync, mkdtempSync, mkdirSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, join, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; import http from 'node:http'; const here = dirname(fileURLToPath(import.meta.url)); const srcDir = join(here, '..', '..', 'src'); const chrome = process.env.CHROME || '/usr/bin/google-chrome'; +const thirdPartyDir = process.env.WEB_THIRD_PARTY || join(here, 'third-party'); +if (!existsSync(join(thirdPartyDir, 'elkjs', 'elk.bundled.js'))) { + execFileSync(join(here, '..', '..', 'third-party', 'fetch_packages.py'), + ['--dest', thirdPartyDir], { stdio: 'inherit' }); +} + // Args: any *.json is a real captured netlist to render; the first other arg is // the output directory. Capture a real netlist from the running viewer via // DevTools -> Network -> WS -> the schematic_cone/schematic_full response frame @@ -74,8 +83,8 @@ const HARNESS = ` - - + +