writeonce/docs/plan/exploration/ui/05-per-app-binaries.md

7.2 KiB
Raw Blame History

05 — Per-app static binaries (wo build apps/X)

Context sources: ./00-overview.md §§ "Goal" (L21–23), "Design decisions" 3–4 (L32–33), ./01-htmlx-format-spec.md, ./02-ui-compiler.md, ./03-client-runtime.md, ./04-workspace-layout.md, ./06-shared-db-daemon.md (the wire URL contract this phase consumes), docs/examples/ecommerce/apps/storefront/wo.toml.

Goal

wo build apps/<X> produces a single static binary at target/wo/<X> that contains the app's ##ui / ##app blocks compiled to .htmlx, the imported shared/ dirs the app's wo.toml names, the vanilla-JS client runtime from phase 03, and a thin main() that reads WO_DB, opens a wire connection to the shared daemon, and serves the public HTTP listener declared in [server] listen.

Design decisions (locked)

  1. One Cargo build per app, dynamic Cargo project templating. wo build apps/<X> materialises a Cargo project under target/wo-build/<X>/, fills [bin] name = "<X>", copies/generates app_config.rs, and invokes cargo build --release. The resulting binary is copied to target/wo/<X>.
  2. No per-app Rust source generation beyond config. The same crates/app is linked into every app binary. The only generated Rust file is app_config.rs containing the route table, embedded templates, and embedded runtime asset. Avoids exploding cargo metadata across N apps.
  3. include_bytes! bakes templates + runtime + CSS at compile time. A Cargo build.rs writes app_config.rs enumerating every compiled .htmlx, every .css from apps/<X>/ui/<screen>/<screen>.css and shared/components/*.css, plus the runtime JS via RUNTIME_JS from phase 03.
  4. Connection target precedence: WO_DB env > [database].url from manifest > error. The app refuses to start if neither is set. Anchored in ./00-overview.md "Goal" (L23) and docs/examples/ecommerce/apps/storefront/wo.toml L21–26.
  5. HTTP listener address comes from [server] listen. Different from the database URL — the database URL is what this binary connects to; [server].listen is what the binary itself exposes to browsers. WO_LISTEN env var overrides for ops.

Scope

New files

File Responsibility Port source
crates/app/src/build.rs wo build apps/<X> driver: template Cargo project, run cargo, copy binary new (~250 LOC)
crates/app/build.rs (Cargo build script) Generates app_config.rs with embedded templates + runtime + routes new (~100 LOC)
crates/app/src/main.rs App-binary entrypoint: reads WO_DB, opens wire, starts HTTP new (~150 LOC)
crates/app/src/route_table.rs Compile-time route table from app.wo routes block new (~120 LOC)
crates/rt/src/bin/wo.rs Wire wo build <path> subcommand modify (+40 LOC)

Total: ~660 LOC new + ~40 LOC modified.

Cargo.toml change

crates/app adds itself as a workspace member that produces a binary. No new external deps beyond what phases 01–04 already brought in.

[[bin]]
name = "wo-app"
path = "src/main.rs"

Generated app_config.rs shape

// Generated by crates/app/build.rs — do not edit.
pub const APP_NAME:    &str = "storefront";
pub const APP_LISTEN:  &str = ":8080";
pub const APP_DB_URL:  Option<&str> = Some("wo://127.0.0.1:5555");
pub const APP_API_KEY_ENV: &str = "STOREFRONT_DB_KEY";

pub const TEMPLATES: &[(&str, &[u8])] = &[
    ("home",   include_bytes!("../target/wo/storefront/ui/home.htmlx")),
    ("orders", include_bytes!("../target/wo/storefront/ui/orders.htmlx")),
];

pub const ROUTES: &[(&str, &str, &str)] = &[
    ("GET", "/",         "ui.home"),
    ("GET", "/orders",   "ui.orders"),
];

API shape (target)

// build-time API used by the wo CLI:
use app::build;
let binary_path: PathBuf = build::build(Path::new("docs/examples/ecommerce/apps/storefront"),
                                        Path::new("target/wo"))?;

// runtime: every app binary's main() looks like this
fn main() -> Result<()> {
    let db_url = env::var("WO_DB").ok()
                    .or(app_config::APP_DB_URL.map(str::to_owned))
                    .ok_or(BootError::NoDatabase)?;
    let api_key = env::var(app_config::APP_API_KEY_ENV)?;
    let db   = wire::connect(&db_url, &api_key)?;
    let r    = build_router(&app_config::ROUTES, &app_config::TEMPLATES, db);
    rt::http::serve(app_config::APP_LISTEN, r)
}

Exit criteria

  1. cargo build -p app green; crates/app produces the wo-app library + the wo build driver.
  2. Storefront builds. cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront produces target/wo/storefront. file target/wo/storefront reports an ELF executable.
  3. Storefront boots. WO_DB=wo://127.0.0.1:5555 STOREFRONT_DB_KEY=test ./target/wo/storefront & then curl -fsS http://127.0.0.1:8080/healthz returns 200. (The DB daemon from phase 06 is mocked or stubbed for this test if 06 hasn't landed yet — refuse-to-start without DB is the contract; the test verifies refuse-to-start when WO_DB is unset.)
  4. Admin builds separately. wo build apps/admin produces a different binary with a disjoint route table. Diffing the two app_config.rs files shows different route lists.
  5. Refuse-to-start without DB. ./target/wo/storefront with no WO_DB and no manifest URL exits non-zero with a clear error.
  6. cd .dev/reference/crates && cargo build && cargo test.

Non-scope

  • No cross-compilation. Linux x86_64 only this phase. --target flags are passed through but untested.
  • No musl static linking. glibc-linked binaries are fine for prototype.
  • No container packaging, no systemd unit generation.
  • No binary-size optimisation beyond --release. wo build --strip is a future flag.
  • No incremental compile cache management. target/wo-build/<X>/ is reused across builds but not pruned.

Verification

cargo build -p app

# storefront build + boot
cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront
file  target/wo/storefront                  # ELF 64-bit
ls -la target/wo/storefront/ui/             # home.htmlx, orders.htmlx (compiled in phase 02)

# refuse-to-start without WO_DB
./target/wo/storefront 2>&1 | grep -q "WO_DB"

# admin builds independently
cargo run --bin wo -- build docs/examples/ecommerce/apps/admin
test -x target/wo/admin

# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID

cd .dev/reference/crates && cargo build && cargo test

After this phase

wo build produces shippable per-app binaries; what they connect to is owned by phase 06 (06-shared-db-daemon.md), and the policy gate they enforce on every query is owned by phase 07 (07-per-app-policies.md). After 06 + 05 + 07 land together, the prototype demo path closes: wo db serve + wo build apps/storefront + wo build apps/admin running side by side, sharing one data dir, with admin live updates triggered by storefront commits.