124 lines
7.2 KiB
Markdown
124 lines
7.2 KiB
Markdown
# 05 — Per-app static binaries (`wo build apps/X`)
|
||
|
||
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Goal" (L21–23), "Design decisions" 3–4 (L32–33), [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md), [`./02-ui-compiler.md`](./02-ui-compiler.md), [`./03-client-runtime.md`](./03-client-runtime.md), [`./04-workspace-layout.md`](./04-workspace-layout.md), [`./06-shared-db-daemon.md`](./06-shared-db-daemon.md) (the wire URL contract this phase consumes), [`docs/examples/ecommerce/apps/storefront/wo.toml`](../../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`](./00-overview.md) "Goal" (L23) and [`docs/examples/ecommerce/apps/storefront/wo.toml`](../../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.
|
||
|
||
```toml
|
||
[[bin]]
|
||
name = "wo-app"
|
||
path = "src/main.rs"
|
||
```
|
||
|
||
### Generated `app_config.rs` shape
|
||
|
||
```rust
|
||
// 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)
|
||
|
||
```rust
|
||
// 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 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
|
||
|
||
```bash
|
||
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 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.
|