feat(dev): port SPA asset tooling

This commit is contained in:
Bryan Helmkamp 2026-04-24 16:05:29 -04:00
parent a03c689a44
commit f52ef5aa07
No known key found for this signature in database
12 changed files with 501 additions and 81 deletions

View file

@ -26,7 +26,8 @@ jobs:
with:
no-cache: true
- run: bun install
- run: scripts/refresh-fabro-spa.sh
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- run: cargo dev refresh-spa
- run: git diff --exit-code -- lib/crates/fabro-spa/assets
compile:

View file

@ -71,9 +71,9 @@ jobs:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install --frozen-lockfile
- run: scripts/refresh-fabro-spa.sh
- run: git diff --exit-code -- lib/crates/fabro-spa/assets
- run: scripts/check-fabro-spa-budgets.sh
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- run: cargo dev refresh-spa
- run: git diff --exit-code -- lib/crates/fabro-spa/assets
- run: cargo dev check-spa-budgets
- run: cargo build -p fabro-cli --release
- run: wc -c < target/release/fabro

View file

@ -22,11 +22,11 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
- `cd apps/fabro-web && bun test` — run tests
- `cd apps/fabro-web && bun run typecheck` — type check
- `cd apps/fabro-web && bun run build` — production build (writes to `apps/fabro-web/dist/` only; does NOT update the bundled SPA that ships in the Rust binary)
- `scripts/refresh-fabro-spa.sh` — **run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job reruns this script and then `git diff --exit-code -- lib/crates/fabro-spa/assets` — if the committed bundle drifts from source (e.g. content-hashed filenames like `entry-<hash>.js` change), the check fails. `bun run build` on its own is not enough.
- `cargo dev refresh-spa` — **run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job reruns this command and then `git diff --exit-code -- lib/crates/fabro-spa/assets` — if the committed bundle drifts from source (e.g. content-hashed filenames like `entry-<hash>.js` change), the check fails. `bun run build` on its own is not enough.
### Docker image
- `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag <name>` (default `fabro`), `--compile-only` (stages `docker-context/<arch>/fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout.
- Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `scripts/refresh-fabro-spa.sh` runs the bun build and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle.
- Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `cargo dev refresh-spa` runs the bun build and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle.
### Release automation
- `cargo dev release [nightly]` — creates the next stable release or nightly prerelease tag. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.

View file

@ -702,7 +702,7 @@ Solid arrows are real dependencies. Dashed lines show phases that are independen
---
- [ ] **Unit 5.5: Port `refresh-fabro-spa.sh` and `check-fabro-spa-budgets.sh` to `fabro-dev`**
- [x] **Unit 5.5: Port `refresh-fabro-spa.sh` and `check-fabro-spa-budgets.sh` to `fabro-dev`**
**Goal:** Replace the two SPA-related scripts with `cargo dev refresh-spa` and `cargo dev check-spa-budgets`.

View file

@ -0,0 +1,137 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use anyhow::{Context, Result, bail};
use clap::Args;
use walkdir::WalkDir;
const DEFAULT_ASSET_BUDGET_BYTES: u64 = 15 * 1024 * 1024;
const DEFAULT_PAYLOAD_BUDGET_BYTES: u64 = 5 * 1024 * 1024;
#[derive(Debug, Args)]
pub(crate) struct CheckSpaBudgetsArgs {
/// Repository root containing lib/crates/fabro-spa/assets.
#[arg(long, hide = true)]
root: Option<PathBuf>,
/// Override the raw asset budget.
#[arg(long, hide = true, default_value_t = DEFAULT_ASSET_BUDGET_BYTES)]
asset_budget_bytes: u64,
/// Override the estimated gzip payload budget.
#[arg(long, hide = true, default_value_t = DEFAULT_PAYLOAD_BUDGET_BYTES)]
payload_budget_bytes: u64,
}
#[expect(
clippy::print_stdout,
reason = "dev check-spa-budgets command reports measured budgets directly"
)]
pub(crate) fn check_spa_budgets(args: CheckSpaBudgetsArgs) -> Result<()> {
let root = args.root.unwrap_or_else(workspace_root);
let asset_dir = root.join("lib/crates/fabro-spa/assets");
let report = budget_report(&asset_dir)?;
println!("fabro-spa asset bytes: {}", report.asset_bytes);
println!(
"fabro-spa estimated compressed payload bytes: {}",
report.compressed_payload_bytes
);
if report.asset_bytes > args.asset_budget_bytes {
bail!(
"fabro-spa committed assets exceed budget: {} > {}",
report.asset_bytes,
args.asset_budget_bytes
);
}
if report.compressed_payload_bytes > args.payload_budget_bytes {
bail!(
"fabro-spa compressed payload exceeds budget: {} > {}",
report.compressed_payload_bytes,
args.payload_budget_bytes
);
}
Ok(())
}
struct BudgetReport {
asset_bytes: u64,
compressed_payload_bytes: u64,
}
fn budget_report(asset_dir: &Path) -> Result<BudgetReport> {
if !asset_dir.is_dir() {
bail!(
"fabro-spa assets directory is missing: {}",
asset_dir.display()
);
}
let mut files = Vec::new();
for entry in WalkDir::new(asset_dir) {
let entry = entry.context("walking fabro-spa assets")?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path().to_path_buf();
if path.extension().and_then(|ext| ext.to_str()) == Some("map") {
bail!(
"source map files are not allowed in fabro-spa assets: {}",
path.display()
);
}
files.push(path);
}
files.sort();
let mut asset_bytes = 0;
let mut compressed_payload_bytes = 0;
for file in files {
asset_bytes += file
.metadata()
.with_context(|| format!("reading metadata for {}", file.display()))?
.len();
compressed_payload_bytes += gzip_size(&file)?;
}
Ok(BudgetReport {
asset_bytes,
compressed_payload_bytes,
})
}
#[expect(
clippy::disallowed_methods,
reason = "dev check-spa-budgets intentionally shells out to gzip to match the legacy script"
)]
fn gzip_size(file: &Path) -> Result<u64> {
let output = Command::new("gzip")
.args(["-9", "-n", "-c"])
.arg(file)
.output()
.with_context(|| format!("compressing {}", file.display()))?;
ensure_gzip_success(file, &output)
}
fn ensure_gzip_success(file: &Path, output: &Output) -> Result<u64> {
if !output.status.success() {
bail!(
"gzip failed for {} with {}: {}",
file.display(),
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(output.stdout.len() as u64)
}
fn workspace_root() -> PathBuf {
let mut root = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
root.pop();
root.pop();
root.pop();
root
}

View file

@ -1,28 +1,11 @@
use anyhow::{Result, bail};
use clap::Args;
mod check_boundary;
mod check_spa_budgets;
mod docker_build;
mod refresh_spa;
mod release;
pub(crate) use check_boundary::{CheckBoundaryArgs, check_boundary};
pub(crate) use check_spa_budgets::{CheckSpaBudgetsArgs, check_spa_budgets};
pub(crate) use docker_build::{DockerBuildArgs, docker_build};
pub(crate) use refresh_spa::{RefreshSpaArgs, refresh_spa};
pub(crate) use release::{ReleaseArgs, release};
#[derive(Debug, Args)]
pub(crate) struct RefreshSpaArgs;
#[derive(Debug, Args)]
pub(crate) struct CheckSpaBudgetsArgs;
pub(crate) fn refresh_spa(_args: RefreshSpaArgs) -> Result<()> {
not_yet_implemented("refresh-spa")
}
pub(crate) fn check_spa_budgets(_args: CheckSpaBudgetsArgs) -> Result<()> {
not_yet_implemented("check-spa-budgets")
}
fn not_yet_implemented(command: &str) -> Result<()> {
bail!("{command} is not yet implemented")
}

View file

@ -0,0 +1,111 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use clap::Args;
use walkdir::WalkDir;
#[derive(Debug, Args)]
pub(crate) struct RefreshSpaArgs {
/// Repository root containing apps/fabro-web and lib/crates/fabro-spa.
#[arg(long, hide = true)]
root: Option<PathBuf>,
/// Skip bun run build and only mirror an existing dist directory.
#[arg(long, hide = true)]
skip_build: bool,
}
#[expect(
clippy::print_stdout,
reason = "dev refresh-spa command reports progress directly"
)]
pub(crate) fn refresh_spa(args: RefreshSpaArgs) -> Result<()> {
let root = args.root.unwrap_or_else(workspace_root);
let web_dir = root.join("apps/fabro-web");
let dist_dir = web_dir.join("dist");
let asset_dir = root.join("lib/crates/fabro-spa/assets");
if !args.skip_build {
println!("Running bun run build in apps/fabro-web...");
run_bun_build(&web_dir)?;
}
mirror_dist(&dist_dir, &asset_dir)?;
println!("Refreshed lib/crates/fabro-spa/assets");
Ok(())
}
#[expect(
clippy::disallowed_methods,
reason = "dev refresh-spa intentionally runs a synchronous Bun subprocess"
)]
fn run_bun_build(web_dir: &Path) -> Result<()> {
let status = Command::new("bun")
.args(["run", "build"])
.current_dir(web_dir)
.status()
.with_context(|| format!("running bun run build in {}", web_dir.display()))?;
if !status.success() {
bail!("bun run build failed with {status}");
}
Ok(())
}
#[expect(
clippy::disallowed_methods,
reason = "dev refresh-spa mirrors build output with synchronous filesystem operations"
)]
fn mirror_dist(dist_dir: &Path, asset_dir: &Path) -> Result<()> {
if !dist_dir.is_dir() {
bail!("apps/fabro-web/dist is missing; run `bun run build` before mirroring SPA assets");
}
if asset_dir.exists() {
std::fs::remove_dir_all(asset_dir)
.with_context(|| format!("removing {}", asset_dir.display()))?;
}
std::fs::create_dir_all(asset_dir)
.with_context(|| format!("creating {}", asset_dir.display()))?;
for entry in WalkDir::new(dist_dir) {
let entry = entry.context("walking apps/fabro-web/dist")?;
let source = entry.path();
let relative = source
.strip_prefix(dist_dir)
.with_context(|| format!("{} is not under {}", source.display(), dist_dir.display()))?;
if relative.as_os_str().is_empty() {
continue;
}
let destination = asset_dir.join(relative);
if entry.file_type().is_dir() {
std::fs::create_dir_all(&destination)
.with_context(|| format!("creating {}", destination.display()))?;
continue;
}
if source.extension().and_then(|ext| ext.to_str()) == Some("map") {
continue;
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::copy(source, &destination).with_context(|| {
format!("copying {} to {}", source.display(), destination.display())
})?;
}
Ok(())
}
fn workspace_root() -> PathBuf {
let mut root = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
root.pop();
root.pop();
root.pop();
root
}

View file

@ -254,7 +254,7 @@ impl ReleasePlan {
}
fn refresh_spa_command() -> PlannedCommand {
PlannedCommand::new("scripts/refresh-fabro-spa.sh")
PlannedCommand::new("cargo").arg("dev").arg("refresh-spa")
}
fn spa_assets_diff_command() -> PlannedCommand {

View file

@ -4,6 +4,7 @@ use std::process::{Command, Output};
mod check_boundary;
mod docker_build;
mod release;
mod spa;
fn fabro_dev() -> assert_cmd::Command {
assert_cmd::cargo::cargo_bin_cmd!("fabro-dev")

View file

@ -0,0 +1,239 @@
use std::path::Path;
fn fabro_dev() -> assert_cmd::Command {
assert_cmd::cargo::cargo_bin_cmd!("fabro-dev")
}
fn output_text(bytes: &[u8]) -> String {
String::from_utf8(bytes.to_vec()).expect("command output should be valid utf-8")
}
#[expect(
clippy::disallowed_methods,
reason = "integration tests stage temporary SPA fixture files with sync std::fs::write"
)]
fn write_file(root: &Path, path: &str, contents: &[u8]) {
let path = root.join(path);
std::fs::create_dir_all(path.parent().expect("fixture path should have parent"))
.expect("creating fixture parent directory");
std::fs::write(path, contents).expect("writing fixture file");
}
#[test]
fn refresh_spa_mirrors_dist_and_removes_source_maps() {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(fixture.path(), "apps/fabro-web/dist/index.html", b"index");
write_file(fixture.path(), "apps/fabro-web/dist/assets/app.js", b"app");
write_file(
fixture.path(),
"apps/fabro-web/dist/assets/app.js.map",
b"map",
);
write_file(
fixture.path(),
"lib/crates/fabro-spa/assets/stale.txt",
b"stale",
);
let output = fabro_dev()
.args([
"refresh-spa",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--skip-build",
])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("Refreshed lib/crates/fabro-spa/assets"),
"refresh-spa should report refreshed assets:\n{stdout}"
);
assert!(
fixture
.path()
.join("lib/crates/fabro-spa/assets/index.html")
.is_file()
);
assert!(
fixture
.path()
.join("lib/crates/fabro-spa/assets/assets/app.js")
.is_file()
);
assert!(
!fixture
.path()
.join("lib/crates/fabro-spa/assets/assets/app.js.map")
.exists()
);
assert!(
!fixture
.path()
.join("lib/crates/fabro-spa/assets/stale.txt")
.exists()
);
}
#[test]
fn refresh_spa_missing_dist_errors_cleanly() {
let fixture = tempfile::tempdir().expect("creating fixture");
let output = fabro_dev()
.args([
"refresh-spa",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--skip-build",
])
.assert()
.failure()
.code(1)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains("apps/fabro-web/dist is missing; run `bun run build`"),
"missing dist should explain how to recover:\n{stderr}"
);
}
#[test]
fn check_spa_budgets_passes_fixture_assets() {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
fixture.path(),
"lib/crates/fabro-spa/assets/index.html",
b"hello",
);
let output = fabro_dev()
.args([
"check-spa-budgets",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--asset-budget-bytes",
"100",
"--payload-budget-bytes",
"100",
])
.assert()
.success()
.get_output()
.clone();
let stdout = output_text(&output.stdout);
assert!(
stdout.contains("fabro-spa asset bytes: 5"),
"budget check should print raw bytes:\n{stdout}"
);
assert!(
stdout.contains("fabro-spa estimated compressed payload bytes:"),
"budget check should print compressed payload bytes:\n{stdout}"
);
}
#[test]
fn check_spa_budgets_fails_when_assets_exceed_budget() {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
fixture.path(),
"lib/crates/fabro-spa/assets/index.html",
b"hello",
);
let output = fabro_dev()
.args([
"check-spa-budgets",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
"--asset-budget-bytes",
"4",
"--payload-budget-bytes",
"100",
])
.assert()
.failure()
.code(1)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains("fabro-spa committed assets exceed budget: 5 > 4"),
"budget failure should report raw byte overage:\n{stderr}"
);
}
#[test]
fn check_spa_budgets_fails_when_source_map_is_present() {
let fixture = tempfile::tempdir().expect("creating fixture");
write_file(
fixture.path(),
"lib/crates/fabro-spa/assets/assets/app.js.map",
b"map",
);
let output = fabro_dev()
.args([
"check-spa-budgets",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
])
.assert()
.failure()
.code(1)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains("source map files are not allowed in fabro-spa assets"),
"source maps should fail the budget check:\n{stderr}"
);
}
#[test]
fn check_spa_budgets_missing_assets_errors_cleanly() {
let fixture = tempfile::tempdir().expect("creating fixture");
let output = fabro_dev()
.args([
"check-spa-budgets",
"--root",
fixture
.path()
.to_str()
.expect("fixture path should be utf-8"),
])
.assert()
.failure()
.code(1)
.get_output()
.clone();
let stderr = output_text(&output.stderr);
assert!(
stderr.contains("fabro-spa assets directory is missing"),
"missing assets should be reported clearly:\n{stderr}"
);
}

View file

@ -1,35 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
asset_dir="$repo_root/lib/crates/fabro-spa/assets"
asset_budget_bytes=$((15 * 1024 * 1024))
payload_budget_bytes=$((5 * 1024 * 1024))
if [[ ! -d "$asset_dir" ]]; then
echo "fabro-spa assets directory is missing: $asset_dir" >&2
exit 1
fi
asset_bytes=0
compressed_payload_bytes=0
while IFS= read -r -d '' file; do
file_bytes="$(wc -c < "$file" | tr -d '[:space:]')"
compressed_bytes="$(gzip -9 -n -c "$file" | wc -c | tr -d '[:space:]')"
asset_bytes=$((asset_bytes + file_bytes))
compressed_payload_bytes=$((compressed_payload_bytes + compressed_bytes))
done < <(find "$asset_dir" -type f -print0)
echo "fabro-spa asset bytes: $asset_bytes"
echo "fabro-spa estimated compressed payload bytes: $compressed_payload_bytes"
if (( asset_bytes > asset_budget_bytes )); then
echo "fabro-spa committed assets exceed budget: $asset_bytes > $asset_budget_bytes" >&2
exit 1
fi
if (( compressed_payload_bytes > payload_budget_bytes )); then
echo "fabro-spa compressed payload exceeds budget: $compressed_payload_bytes > $payload_budget_bytes" >&2
exit 1
fi

View file

@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
web_dir="$repo_root/apps/fabro-web"
dist_dir="$web_dir/dist"
asset_dir="$repo_root/lib/crates/fabro-spa/assets"
(
cd "$web_dir"
bun run build
)
rm -rf "$asset_dir"
mkdir -p "$asset_dir"
cp -R "$dist_dir"/. "$asset_dir"/
find "$asset_dir" -type f -name '*.map' -delete