Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion browser/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion browser/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[package]
name = "browser"
version = "0.1.4"
version = "0.1.5"
edition = "2021"
publish = false

Expand All @@ -16,6 +16,7 @@ path = "src/lib.rs"

[dependencies]
iii-sdk = "=0.21.6"
iii-console-ui = { path = "../crates/console-ui" }
arc-swap = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "process"] }
serde = { version = "1", features = ["derive"] }
Expand Down
173 changes: 173 additions & 0 deletions browser/build.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,179 @@
//! Build script for the `browser` worker.
//!
//! 1. Forwards the build-time target triple to the binary as `env!("TARGET")`
//! (used by `manifest.rs` for the registry `supported_targets` field).
//! 2. Ensures the injected console UI assets exist: `src/ui.rs` embeds
//! `ui/dist/page.js` and `ui/dist/styles.css` via `include_str!`, so if
//! either is missing or stale we run `pnpm install && pnpm build` inside
//! `ui/` first (the state worker's precedent). Set `SKIP_UI_BUILD=1` to use
//! the existing `ui/dist/` outputs as-is.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;

fn main() {
println!(
"cargo:rustc-env=TARGET={}",
std::env::var("TARGET").unwrap()
);

// `dist/` itself is not listed: include_str! reads it directly, and
// listing it would rebuild-loop on our own output.
println!("cargo:rerun-if-changed=ui/page.tsx");
println!("cargo:rerun-if-changed=ui/styles.css");
println!("cargo:rerun-if-changed=ui/src");
println!("cargo:rerun-if-changed=ui/build.mjs");
println!("cargo:rerun-if-changed=ui/package.json");
// The lockfile lives at the workers-repo root (pnpm workspace: the ui
// project links @iii-dev/console-ui from packages/console-ui).
println!("cargo:rerun-if-changed=../pnpm-lock.yaml");
println!("cargo:rerun-if-changed=ui/tsconfig.json");

let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let ui_dir = manifest_dir.join("ui");
let dist_assets = [
ui_dir.join("dist").join("page.js"),
ui_dir.join("dist").join("styles.css"),
];

if dist_assets
.iter()
.all(|a| a.exists() && dist_is_fresh(a, &ui_dir))
{
return;
}

if std::env::var_os("SKIP_UI_BUILD").is_some() {
for asset in &dist_assets {
if !asset.exists() {
panic!(
"SKIP_UI_BUILD set but {} is missing — build the UI manually \
(cd ui && pnpm install && pnpm build) or unset the env var",
asset.display()
);
}
}
return;
}

let pnpm = locate_pnpm();

let status = Command::new(&pnpm)
.args(["install"])
.current_dir(&ui_dir)
.status()
.unwrap_or_else(|e| {
panic!(
"failed to spawn `pnpm install` in {}: {e}",
ui_dir.display()
)
});
if !status.success() {
panic!("`pnpm install` exited with {status} — see logs above");
}

let status = Command::new(&pnpm)
.args(["build"])
.current_dir(&ui_dir)
.status()
.unwrap_or_else(|e| panic!("failed to spawn `pnpm build` in {}: {e}", ui_dir.display()));
if !status.success() {
panic!("`pnpm build` exited with {status} — see logs above");
}

for asset in &dist_assets {
if !asset.exists() {
panic!(
"`pnpm build` finished but {} is still missing — check the esbuild \
output above",
asset.display()
);
}
}
}

/// `true` when the built asset is at least as new as every source that
/// contributes to it. Conservative: any I/O failure forces a rebuild.
fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool {
let Ok(dist_mtime) = dist_asset.metadata().and_then(|m| m.modified()) else {
return false;
};

let watched_files = [
ui_dir.join("page.tsx"),
ui_dir.join("styles.css"),
ui_dir.join("build.mjs"),
ui_dir.join("package.json"),
ui_dir.join("../../pnpm-lock.yaml"),
ui_dir.join("tsconfig.json"),
];
for f in watched_files.iter() {
if !f.exists() {
continue;
}
let Ok(m) = f.metadata().and_then(|m| m.modified()) else {
return false;
};
if m > dist_mtime {
return false;
}
}

for dir in [ui_dir.join("src")] {
if dir.exists() && !subtree_older_than(&dir, dist_mtime) {
return false;
}
}

true
}

fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool {
let Ok(read) = std::fs::read_dir(root) else {
return false;
};
for entry in read.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else {
return false;
};
if meta.is_dir() {
if !subtree_older_than(&path, ceiling) {
return false;
}
} else {
let Ok(m) = meta.modified() else {
return false;
};
if m > ceiling {
return false;
}
}
}
true
}

fn locate_pnpm() -> PathBuf {
if let Ok(explicit) = std::env::var("PNPM") {
return PathBuf::from(explicit);
}
let candidates = if cfg!(windows) {
["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice()
} else {
["pnpm"].as_slice()
};
let path = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path) {
for name in candidates {
let candidate = dir.join(name);
if candidate.is_file() {
return candidate;
}
}
}
panic!(
"pnpm not found on PATH — install Node + pnpm, or set SKIP_UI_BUILD=1 \
after building the UI manually with `cd ui && pnpm install && pnpm build`"
);
}
1 change: 1 addition & 0 deletions browser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ pub mod functions;
pub mod manifest;
pub mod session;
pub mod snapshot;
pub mod ui;
4 changes: 4 additions & 0 deletions browser/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ async fn main() -> Result<()> {
configuration::register_config_trigger(&iii, shared.clone())
.context("registering configuration change trigger")?;

// Injectable console UI — after the browser::* functions so the console
// can attribute the assets.
browser::ui::register(&iii);

// Idle sweep: stop sessions nobody has touched for idle_stop_ms.
let sweep_sessions = sessions.clone();
let sweep = tokio::spawn(async move {
Expand Down
76 changes: 76 additions & 0 deletions browser/src/ui.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Injectable console UI for the browser worker
//! (iii/tech-specs/2026-07-17-injectable-ui; authoring SOP:
//! workers/docs/sops/injectable-console-ui.md).
//!
//! Ships two assets into any running console:
//!
//! - `browser/page.js` (`console:script`) — the `#/ext/browser` page (session
//! rail, screencast-fed live viewport, console/network feeds) AND the
//! function-trigger renderer for every `browser::*` call in chat and the
//! traces span tab.
//! - `browser/styles.css` (`console:style`) — the stylesheet, every rule
//! scoped under `[data-iii-ui="browser"]`; the console mounts it as a
//! `<link>` and link-swaps it on change, styles-before-scripts on boot.
//!
//! The registration machinery (content function `browser::ui-content`, one
//! Message-path trigger per asset, `III_BROWSER_UI_WATCH` hot-reload watcher)
//! lives in the shared `iii-console-ui` crate (path-linked from
//! `workers/crates/console-ui`); this module only names the assets and embeds
//! their bytes.
//!
//! The assets are compiled from `ui/` by esbuild (react + @iii-dev/console-ui
//! external — they resolve through the console's import map at runtime) and
//! embedded at compile time so the worker stays one self-contained binary.
//! For the dev loop, set `III_BROWSER_UI_WATCH` to the build output directory
//! (or `1` for `ui/dist`): the worker polls both files and re-registers a
//! changed asset's trigger — every open console tab hot-swaps it.

use std::sync::Arc;

use iii_console_ui::ConsoleUi;
use iii_sdk::IIIClient;

pub const PAGE_PATH: &str = "browser/page.js";
pub const STYLES_PATH: &str = "browser/styles.css";

/// Built by `build.rs` (esbuild over `ui/`).
const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js"));
const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css"));

fn console_ui() -> ConsoleUi {
ConsoleUi::new("browser")
.script(PAGE_PATH, PAGE_JS)
.style(STYLES_PATH, STYLES_CSS)
}

/// Register the browser worker's console UI. Call after the `browser::*`
/// functions are registered.
pub fn register(iii: &Arc<IIIClient>) {
console_ui().register(iii);
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ui_builder_accepts_the_assets() {
// The builder panics on any path/kind the console would reject.
let _ = console_ui();
}

#[test]
fn embedded_page_is_nonempty_esm() {
assert!(PAGE_JS.contains("export"), "built page.js looks wrong");
}

#[test]
fn embedded_styles_are_scoped() {
// esbuild prints the attribute selector unquoted ([data-iii-ui=browser]).
assert!(
STYLES_CSS.contains(r#"[data-iii-ui="browser"]"#)
|| STYLES_CSS.contains("[data-iii-ui=browser]"),
"built styles.css must be scoped under the worker's data-iii-ui attribute"
);
}
}
37 changes: 37 additions & 0 deletions browser/ui/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Build the worker's two console assets:
*
* page.tsx → dist/page.js (injected over `console:script`)
* styles.css → dist/styles.css (injected over `console:style`)
*
* The five shared specifiers stay EXTERNAL — they resolve at runtime
* through the console's import map (a bundled React copy would surface as
* a cryptic "Invalid hook call"). Everything else the page needs gets
* bundled in. `--watch` pairs with the worker's III_BROWSER_UI_WATCH
* poller for the hot-reload dev loop.
*/

import esbuild from 'esbuild'

const options = {
entryPoints: ['page.tsx', 'styles.css'],
bundle: true,
format: 'esm',
jsx: 'automatic',
outdir: 'dist',
external: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'@iii-dev/console-ui',
],
logLevel: 'info',
}

if (process.argv.includes('--watch')) {
const ctx = await esbuild.context(options)
await ctx.watch()
} else {
await esbuild.build(options)
}
19 changes: 19 additions & 0 deletions browser/ui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@iii-workers/browser-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "tsc --noEmit && node build.mjs",
"watch": "node build.mjs --watch"
},
"dependencies": {
"@iii-dev/console-ui": "workspace:*",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"esbuild": "^0.25.0",
"typescript": "^5.9.2"
}
}
Loading
Loading