Skip to content
Open
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
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

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

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ packages:
- harness/ui
- memory/ui
- pdf/ui
- queue/ui
- state/ui
- iii-directory/ui
- shell/ui
Expand Down
13 changes: 13 additions & 0 deletions queue/Cargo.lock

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

1 change: 1 addition & 0 deletions queue/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
clap = { version = "4", features = ["derive", "env"] }
schemars = "0.8"
uuid = { version = "1", features = ["v4"] }
iii-console-ui = { path = "../crates/console-ui" }
futures = "0.3"
redis = { version = "1.0.1", features = ["tokio-comp", "connection-manager"] }
lapin = { version = "3", optional = true }
Expand Down
189 changes: 189 additions & 0 deletions queue/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//! Build script for the `queue` 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 console worker's `web/` 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");
println!("cargo:rerun-if-env-changed=SKIP_UI_BUILD");
println!("cargo:rerun-if-env-changed=PNPM");

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 {
// The directory's own mtime catches deletions: removing a file bumps the
// parent while every surviving entry stays old.
let Ok(root_modified) = root.metadata().and_then(|m| m.modified()) else {
return false;
};
if root_modified > ceiling {
return false;
}
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 queue/src/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub async fn start(iii: Arc<IIIClient>, config: QueueConfig) -> anyhow::Result<B
runtime.start().await?;

crate::functions::register_all(&iii, adapter.clone(), runtime.clone());
crate::ui::register(&iii);
let _ = iii.register_trigger_type(
RegisterTriggerType::new(
TRIGGER_TYPE,
Expand Down
1 change: 1 addition & 0 deletions queue/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ pub mod runtime;
pub mod store;
pub mod subscriber_config;
pub mod trigger;
pub mod ui;
77 changes: 77 additions & 0 deletions queue/src/ui.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//! The queue worker's injected console UI — the Queues page (topics, stats,
//! publish, dead letters with redrive/discard) shipped over the console's
//! `console:script` / `console:style` trigger types.
//!
//! Registration machinery comes from the shared `iii-console-ui` crate
//! (workers/crates/console-ui); this module only names the assets and embeds
//! the bytes built from `ui/` (see `build.rs`). Dev loop:
//! `cd ui && pnpm watch` + `III_QUEUE_UI_WATCH=1`.

use std::sync::Arc;

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

pub const PAGE_PATH: &str = "queue/page.js";
pub const STYLES_PATH: &str = "queue/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 queue_ui() -> ConsoleUi {
ConsoleUi::new("queue")
.script(PAGE_PATH, PAGE_JS)
.style(STYLES_PATH, STYLES_CSS)
}

/// Register the queue worker's console assets. Ordering never matters: if
/// the console is not up yet the engine parks the registration and delivers
/// it when the console arrives.
pub fn register(iii: &Arc<IIIClient>) {
queue_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 _ = queue_ui();
}

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

/// The page is useless if its id drifts from the route deep links use
/// (`#/ext/queues`), or if it loses the queue functions it drives.
#[test]
fn embedded_page_registers_the_queues_page() {
assert!(PAGE_JS.contains("queues"));
for fn_id in [
"engine::queue::list_topics",
"engine::queue::dlq_messages",
"iii::queue::redrive",
"iii::durable::publish",
] {
assert!(
PAGE_JS.contains(fn_id),
"built page.js no longer calls `{fn_id}`"
);
}
}

#[test]
fn embedded_styles_are_scoped() {
// esbuild prints the attribute selector unquoted ([data-iii-ui=queue]).
assert!(
STYLES_CSS.contains(r#"[data-iii-ui="queue"]"#)
|| STYLES_CSS.contains("[data-iii-ui=queue]"),
"built styles.css must be scoped under the queue worker's data-iii-ui attribute"
);
}
}
37 changes: 37 additions & 0 deletions queue/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_STATE_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)
}
Loading
Loading