#!/usr/bin/env bash set -euo pipefail bun_path="${1:-bun}" if command -v bazel >/dev/null 2>&1; then bazel_cmd=(bazel) elif command -v bazelisk >/dev/null 2>&1; then bazel_cmd=(bazelisk) else echo "bazel or bazelisk is required on PATH" >&2 exit 1 fi script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" rules_bun_root="$(cd "${script_dir}/../.." && pwd -P)" workdir="$(mktemp -d)" trap 'rm -rf "${workdir}"' EXIT fixture_dir="${workdir}/fixture" plain_dir="${workdir}/plain" bazel_dir="${workdir}/bazel" mkdir -p "${fixture_dir}/packages/pkg-a" "${fixture_dir}/packages/pkg-b" "${fixture_dir}/packages/web" cat >"${fixture_dir}/package.json" <<'JSON' { "name": "workspace-parity-root", "private": true, "workspaces": ["packages/*"] } JSON cat >"${fixture_dir}/packages/pkg-a/package.json" <<'JSON' { "name": "@workspace/pkg-a", "version": "1.0.0", "main": "index.js" } JSON cat >"${fixture_dir}/packages/pkg-a/index.js" <<'JS' module.exports = { value: 42 }; JS cat >"${fixture_dir}/packages/pkg-b/package.json" <<'JSON' { "name": "@workspace/pkg-b", "version": "1.0.0", "dependencies": { "@workspace/pkg-a": "workspace:*", "is-number": "7.0.0" } } JSON cat >"${fixture_dir}/packages/web/package.json" <<'JSON' { "name": "@workspace/web", "private": true, "type": "module", "scripts": { "build": "vite build" }, "devDependencies": { "vite": "5.4.14" } } JSON cat >"${fixture_dir}/packages/web/index.html" <<'HTML' Workspace Parity Web
HTML cat >"${fixture_dir}/packages/web/main.js" <<'JS' import { value } from "./value.js"; const app = document.querySelector("#app"); if (app) { app.textContent = `value=${value}`; } JS cat >"${fixture_dir}/packages/web/value.js" <<'JS' export const value = 42; JS cat >"${fixture_dir}/packages/web/vite.config.js" <<'JS' export default { resolve: { preserveSymlinks: true, }, optimizeDeps: { esbuildOptions: { preserveSymlinks: true, }, }, }; JS "${bun_path}" install --cwd "${fixture_dir}" >/dev/null rm -rf "${fixture_dir}/node_modules" "${fixture_dir}/packages/pkg-b/node_modules" cp -R "${fixture_dir}" "${plain_dir}" cp -R "${fixture_dir}" "${bazel_dir}" "${bun_path}" install --cwd "${plain_dir}" --frozen-lockfile >/dev/null cat >"${bazel_dir}/MODULE.bazel" <"${bazel_dir}/BUILD.bazel" <<'EOF' load("@rules_bun//bun:defs.bzl", "bun_script") load("@rules_shell//shell:sh_test.bzl", "sh_test") exports_files([ "package.json", "bun.lock", "node_modules_smoke_test.sh", ]) bun_script( name = "web_build", script = "build", package_json = "packages/web/package.json", node_modules = "@node_modules//:node_modules", data = [ "packages/web/index.html", "packages/web/main.js", "packages/web/value.js", "packages/web/vite.config.js", ], ) sh_test( name = "node_modules_smoke_test", srcs = ["node_modules_smoke_test.sh"], data = ["@node_modules//:node_modules"], ) EOF cat >"${bazel_dir}/node_modules_smoke_test.sh" <<'EOF' #!/usr/bin/env bash set -euo pipefail runfiles_dir="${RUNFILES_DIR:-$0.runfiles}" if ! find "${runfiles_dir}" -path '*/node_modules/.bin/vite' -print -quit | grep -q .; then echo "vite binary not found in runfiles node_modules/.bin" >&2 exit 1 fi EOF chmod +x "${bazel_dir}/node_modules_smoke_test.sh" ( cd "${bazel_dir}" "${bazel_cmd[@]}" build @node_modules//:node_modules >/dev/null "${bazel_cmd[@]}" test //:node_modules_smoke_test >/dev/null "${bazel_cmd[@]}" run //:web_build -- --emptyOutDir >/dev/null ) output_base="$(cd "${bazel_dir}" && "${bazel_cmd[@]}" info output_base)" bazel_repo_dir="$(find "${output_base}/external" -maxdepth 1 -type d -name '*+node_modules' | head -n 1)" if [[ -z ${bazel_repo_dir} ]]; then echo "Could not locate generated Bazel node_modules repository" >&2 exit 1 fi bazel_node_modules="${bazel_repo_dir}/node_modules" plain_node_modules="${plain_dir}/node_modules" if [[ ! -d ${plain_node_modules} ]]; then echo "Plain Bun install did not produce node_modules" >&2 exit 1 fi if [[ ! -d ${bazel_node_modules} ]]; then echo "Bazel bun_install did not produce node_modules" >&2 exit 1 fi plain_layout_manifest="${workdir}/plain.layout.manifest" bazel_layout_manifest="${workdir}/bazel.layout.manifest" python3 - "${plain_dir}" >"${plain_layout_manifest}" <<'PY' import hashlib import os import stat import sys root = sys.argv[1] def include(rel): if rel == "node_modules" or rel.startswith("node_modules/"): if rel == "node_modules/.rules_bun" or rel.startswith("node_modules/.rules_bun/"): return False return True if rel.startswith("packages/") and "/node_modules" in rel: return True return False for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False): dirnames.sort() filenames.sort() rel_dir = os.path.relpath(dirpath, root) if rel_dir == ".": rel_dir = "" for name in dirnames + filenames: full = os.path.join(dirpath, name) rel = os.path.join(rel_dir, name) if rel_dir else name if not include(rel): continue st = os.lstat(full) mode = st.st_mode if stat.S_ISLNK(mode): print(f"L {rel} -> {os.readlink(full)}") elif stat.S_ISDIR(mode): print(f"D {rel}") elif stat.S_ISREG(mode): h = hashlib.sha256() with open(full, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break h.update(chunk) print(f"F {rel} {h.hexdigest()}") else: print(f"O {rel} {mode}") PY python3 - "${bazel_repo_dir}" >"${bazel_layout_manifest}" <<'PY' import hashlib import os import stat import sys root = sys.argv[1] def include(rel): if rel == "node_modules" or rel.startswith("node_modules/"): if rel == "node_modules/.rules_bun" or rel.startswith("node_modules/.rules_bun/"): return False return True if rel.startswith("packages/") and "/node_modules" in rel: return True return False for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False): dirnames.sort() filenames.sort() rel_dir = os.path.relpath(dirpath, root) if rel_dir == ".": rel_dir = "" for name in dirnames + filenames: full = os.path.join(dirpath, name) rel = os.path.join(rel_dir, name) if rel_dir else name if not include(rel): continue st = os.lstat(full) mode = st.st_mode if stat.S_ISLNK(mode): print(f"L {rel} -> {os.readlink(full)}") elif stat.S_ISDIR(mode): print(f"D {rel}") elif stat.S_ISREG(mode): h = hashlib.sha256() with open(full, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break h.update(chunk) print(f"F {rel} {h.hexdigest()}") else: print(f"O {rel} {mode}") PY if ! diff -u "${plain_layout_manifest}" "${bazel_layout_manifest}"; then echo "Workspace node_modules layout differs between plain bun install and Bazel bun_install" >&2 exit 1 fi plain_manifest="${workdir}/plain.manifest" bazel_manifest="${workdir}/bazel.manifest" python3 - "${plain_node_modules}" >"${plain_manifest}" <<'PY' import hashlib import os import stat import sys root = sys.argv[1] for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False): dirnames.sort() filenames.sort() rel_dir = os.path.relpath(dirpath, root) if rel_dir == ".": rel_dir = "" for name in dirnames + filenames: full = os.path.join(dirpath, name) rel = os.path.join(rel_dir, name) if rel_dir else name if rel == ".rules_bun" or rel.startswith(".rules_bun/"): continue st = os.lstat(full) mode = st.st_mode if stat.S_ISLNK(mode): print(f"L {rel} -> {os.readlink(full)}") elif stat.S_ISDIR(mode): print(f"D {rel}") elif stat.S_ISREG(mode): h = hashlib.sha256() with open(full, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break h.update(chunk) print(f"F {rel} {h.hexdigest()}") else: print(f"O {rel} {mode}") PY python3 - "${bazel_node_modules}" >"${bazel_manifest}" <<'PY' import hashlib import os import stat import sys root = sys.argv[1] for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False): dirnames.sort() filenames.sort() rel_dir = os.path.relpath(dirpath, root) if rel_dir == ".": rel_dir = "" for name in dirnames + filenames: full = os.path.join(dirpath, name) rel = os.path.join(rel_dir, name) if rel_dir else name if rel == ".rules_bun" or rel.startswith(".rules_bun/"): continue st = os.lstat(full) mode = st.st_mode if stat.S_ISLNK(mode): print(f"L {rel} -> {os.readlink(full)}") elif stat.S_ISDIR(mode): print(f"D {rel}") elif stat.S_ISREG(mode): h = hashlib.sha256() with open(full, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break h.update(chunk) print(f"F {rel} {h.hexdigest()}") else: print(f"O {rel} {mode}") PY if ! diff -u "${plain_manifest}" "${bazel_manifest}"; then echo "node_modules trees differ between plain bun install and Bazel bun_install" >&2 exit 1 fi plain_dist_dir="${workdir}/plain-dist" bazel_dist_dir="${workdir}/bazel-dist" rm -rf "${plain_dist_dir}" "${bazel_dist_dir}" "${bun_path}" run --cwd "${plain_dir}/packages/web" build -- --emptyOutDir --outDir "${plain_dist_dir}" >/dev/null ( cd "${bazel_dir}" "${bazel_cmd[@]}" run //:web_build -- --emptyOutDir --outDir "${bazel_dist_dir}" >/dev/null ) if [[ ! -d ${plain_dist_dir} ]]; then echo "Plain Bun Vite build did not produce output" >&2 exit 1 fi if [[ ! -d ${bazel_dist_dir} ]]; then echo "Bazel Vite build did not produce output" >&2 exit 1 fi plain_build_manifest="${workdir}/plain.build.manifest" bazel_build_manifest="${workdir}/bazel.build.manifest" python3 - "${plain_dist_dir}" >"${plain_build_manifest}" <<'PY' import hashlib import os import stat import sys root = sys.argv[1] for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False): dirnames.sort() filenames.sort() rel_dir = os.path.relpath(dirpath, root) if rel_dir == ".": rel_dir = "" for name in dirnames + filenames: full = os.path.join(dirpath, name) rel = os.path.join(rel_dir, name) if rel_dir else name st = os.lstat(full) mode = st.st_mode if stat.S_ISLNK(mode): print(f"L {rel} -> {os.readlink(full)}") elif stat.S_ISDIR(mode): print(f"D {rel}") elif stat.S_ISREG(mode): h = hashlib.sha256() with open(full, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break h.update(chunk) print(f"F {rel} {h.hexdigest()}") else: print(f"O {rel} {mode}") PY python3 - "${bazel_dist_dir}" >"${bazel_build_manifest}" <<'PY' import hashlib import os import stat import sys root = sys.argv[1] for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False): dirnames.sort() filenames.sort() rel_dir = os.path.relpath(dirpath, root) if rel_dir == ".": rel_dir = "" for name in dirnames + filenames: full = os.path.join(dirpath, name) rel = os.path.join(rel_dir, name) if rel_dir else name st = os.lstat(full) mode = st.st_mode if stat.S_ISLNK(mode): print(f"L {rel} -> {os.readlink(full)}") elif stat.S_ISDIR(mode): print(f"D {rel}") elif stat.S_ISREG(mode): h = hashlib.sha256() with open(full, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break h.update(chunk) print(f"F {rel} {h.hexdigest()}") else: print(f"O {rel} {mode}") PY if ! diff -u "${plain_build_manifest}" "${bazel_build_manifest}"; then echo "Vite build outputs differ between plain Bun and Bazel bun_script" >&2 exit 1 fi