app
DigiKey Electronics Search
Public Made by Adomby adom
DigiKey component search in your workspace — millions of parts with real-time stock, quantity pricing and datasheets. CLI verbs, a Hydrogen app, and a shared backend service.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
//! `adom-digikey app` — opens a Hydrogen webview tab with a searchable
//! DigiKey UI. The server here is a thin per-session wrapper that proxies
//! the real work to the shared backend (`adom-digikey serve` on 8777, or
//! a remote DIGIKEY_API URL).
//!
//! Frontend conventions (per app-creator skill):
//! - brand fonts/colors
//! - monochrome white icons inside the UI
//! - Adom teal accent for primary actions
//! - 2-way HTTP comms (every UI action has a server endpoint)
//! - console forwarding + optional eval channel for dev
//! - relative URLs for every fetch/asset (proxy-safe)
//! - custom SVG favicon served at /favicon.svg
mod tab;
use crate::digikey::SearchResult;
use crate::{note, ok, warn, AppArgs};
use serde_json::{json, Value};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tiny_http::{Header, Method, Response, Server};
const UI_HTML: &str = include_str!("ui.html");
const ICON_SVG: &str = include_str!("../../docs/icon.svg");
const CONSOLE_MAX: usize = 500;
struct AppState {
api: String,
dev: bool,
console: Mutex<VecDeque<Value>>,
last_query: Mutex<Option<String>>,
last_results: Mutex<Option<SearchResult>>,
pending_eval: Mutex<Option<(String, String)>>, // (id, code)
eval_results: Mutex<std::collections::HashMap<String, Value>>,
}
fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {
Response::from_string(body.to_string())
.with_status_code(code)
.with_header(Header::from_bytes("Content-Type", "application/json").unwrap())
.with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())
}
fn read_body(req: &mut tiny_http::Request) -> Result<Value, String> {
use std::io::Read;
let mut body = String::new();
req.as_reader()
.read_to_string(&mut body)
.map_err(|e| format!("read body: {}", e))?;
if body.is_empty() {
return Ok(Value::Null);
}
serde_json::from_str(&body).map_err(|e| format!("parse JSON: {}", e))
}
fn backend_post(api: &str, body: Value) -> Result<Value, String> {
let url = if api.ends_with('/') { api.to_string() } else { format!("{}/", api) };
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(30))
.build();
let res = match agent.post(&url).set("Content-Type", "application/json").send_json(body) {
Ok(r) => r,
// ureq treats 4xx/5xx as errors; unwrap the response so we can
// forward the JSON body ({"error":"..."}) to the caller.
Err(ureq::Error::Status(_code, r)) => r,
Err(e) => return Err(format!("backend POST failed: {}", e)),
};
res.into_json().map_err(|e| format!("backend JSON parse failed: {}", e))
}
fn backend_get(url: &str) -> Result<Value, String> {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(10))
.build();
let res = match agent.get(url).call() {
Ok(r) => r,
Err(ureq::Error::Status(_code, r)) => r,
Err(e) => return Err(format!("backend GET failed: {}", e)),
};
res.into_json().map_err(|e| format!("backend JSON parse failed: {}", e))
}
fn resolve_backend(explicit: Option<String>) -> String {
if let Some(s) = explicit.filter(|s| !s.is_empty()) {
return s;
}
"http://127.0.0.1:8777".into()
}
pub fn run(args: AppArgs) -> Result<(), String> {
let api = resolve_backend(args.api.clone());
ok(format!("DigiKey app starting on port {} (backend: {})", args.port, api));
let state = Arc::new(AppState {
api: api.clone(),
dev: args.dev,
console: Mutex::new(VecDeque::with_capacity(CONSOLE_MAX)),
last_query: Mutex::new(None),
last_results: Mutex::new(None),
pending_eval: Mutex::new(None),
eval_results: Mutex::new(std::collections::HashMap::new()),
});
let addr = format!("0.0.0.0:{}", args.port);
let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;
// Verify backend reachable up-front; non-fatal, UI shows the error.
match backend_get(&format!("{}/health", api)) {
Ok(v) => note!(" backend health: {}", v),
Err(e) => warn(format!("backend unreachable at {}: {}", api, e)),
}
// Open the Hydrogen tab unless --no-tab
if !args.no_tab {
let proxy_url = tab::proxy_url(args.port);
match tab::add_webview_tab(&args.tab_name, &proxy_url, ICON_SVG) {
Ok(_) => ok(format!("opened Hydrogen tab '{}' → {}", args.tab_name, proxy_url)),
Err(e) => warn(format!("couldn't open tab via adom-cli: {}. You can still open {} manually.", e, proxy_url)),
}
}
// Ctrl-c cleanup is best-effort via the /shutdown endpoint.
// If the process is killed abruptly, the user can run:
// adom-cli hydrogen workspace remove-tab --name "DigiKey Search"
note!(" {}Press ctrl-c or curl POST /shutdown to stop.{}", crate::DIM, crate::RESET);
for mut req in server.incoming_requests() {
let url = req.url().to_string();
let path = url.split_once('?').map(|(p, _)| p).unwrap_or(&url).to_string();
let method = req.method().clone();
let state_ref = Arc::clone(&state);
let result: (u16, Value, bool) = match (method.clone(), path.as_str()) {
// Static
(Method::Get, "/") | (Method::Get, "/index.html") => {
let _ = req.respond(
Response::from_string(UI_HTML)
.with_status_code(200)
.with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())
.with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap()),
);
continue;
}
(Method::Get, "/favicon.svg") => {
let _ = req.respond(
Response::from_string(ICON_SVG)
.with_status_code(200)
.with_header(Header::from_bytes("Content-Type", "image/svg+xml").unwrap())
.with_header(Header::from_bytes("Cache-Control", "max-age=86400").unwrap()),
);
continue;
}
// State + control
(Method::Get, "/state") => {
let q = state_ref.last_query.lock().unwrap().clone();
let r = state_ref.last_results.lock().unwrap().clone();
(200, json!({
"backend": state_ref.api,
"dev": state_ref.dev,
"last_query": q,
"last_results": r,
}), false)
}
(Method::Post, "/search") => match read_body(&mut req) {
Ok(body) => {
let keyword = body.get("keyword").and_then(|v| v.as_str()).unwrap_or("").to_string();
let limit = body.get("limit").and_then(|v| v.as_u64()).unwrap_or(20);
let in_stock = body.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(false);
if keyword.trim().is_empty() {
(400, json!({"error": "keyword is required"}), false)
} else {
match backend_post(
&state_ref.api,
json!({"action":"search","keyword":keyword,"limit":limit,"inStockOnly":in_stock}),
) {
Ok(v) => {
if let Some(e) = v.get("error").and_then(|v| v.as_str()) {
(502, json!({"error": e}), false)
} else {
*state_ref.last_query.lock().unwrap() = Some(keyword);
if let Ok(sr) = serde_json::from_value::<SearchResult>(v.clone()) {
*state_ref.last_results.lock().unwrap() = Some(sr);
}
(200, v, false)
}
}
Err(e) => (502, json!({"error": e}), false),
}
}
}
Err(e) => (400, json!({"error": e}), false),
},
(Method::Post, "/part") => match read_body(&mut req) {
Ok(body) => {
let pn = body.get("partNumber").and_then(|v| v.as_str()).unwrap_or("").to_string();
if pn.trim().is_empty() {
(400, json!({"error": "partNumber is required"}), false)
} else {
match backend_post(&state_ref.api, json!({"action":"part","partNumber":pn})) {
Ok(v) => {
if let Some(e) = v.get("error").and_then(|v| v.as_str()) {
(502, json!({"error": e}), false)
} else {
(200, v, false)
}
}
Err(e) => (502, json!({"error": e}), false),
}
}
}
Err(e) => (400, json!({"error": e}), false),
},
(Method::Get, "/backend-health") => {
match backend_get(&format!("{}/health", state_ref.api)) {
Ok(v) => (200, v, false),
Err(e) => (502, json!({"error": e, "backend": state_ref.api}), false),
}
}
// Console forwarding (UI → server)
(Method::Post, "/console") => match read_body(&mut req) {
Ok(v) => {
let mut cons = state_ref.console.lock().unwrap();
if cons.len() >= CONSOLE_MAX {
cons.pop_front();
}
cons.push_back(v);
(200, json!({"ok": true}), false)
}
Err(e) => (400, json!({"error": e}), false),
},
(Method::Get, "/console") => {
let cons: Vec<Value> = state_ref.console.lock().unwrap().iter().cloned().collect();
(200, json!({"messages": cons}), false)
}
(Method::Delete, "/console") => {
state_ref.console.lock().unwrap().clear();
(200, json!({"ok": true}), false)
}
// Eval channel (dev only — gated on --dev flag)
(Method::Post, "/eval") if state_ref.dev => match read_body(&mut req) {
Ok(v) => {
let code = v.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();
if code.is_empty() {
(400, json!({"error": "code is required"}), false)
} else {
let id = format!("ev{}", rand_suffix());
*state_ref.pending_eval.lock().unwrap() = Some((id.clone(), code));
(200, json!({"id": id}), false)
}
}
Err(e) => (400, json!({"error": e}), false),
},
(Method::Get, "/eval/pending") if state_ref.dev => {
let pending = state_ref.pending_eval.lock().unwrap().take();
match pending {
Some((id, code)) => (200, json!({"id": id, "code": code}), false),
None => (200, json!({}), false),
}
}
(Method::Post, path) if state_ref.dev && path.starts_with("/eval/") && path.ends_with("/result") => {
let id = path.trim_start_matches("/eval/").trim_end_matches("/result").to_string();
match read_body(&mut req) {
Ok(v) => {
let result = v.get("result").cloned().unwrap_or(Value::Null);
state_ref.eval_results.lock().unwrap().insert(id.clone(), result);
(200, json!({"ok": true}), false)
}
Err(e) => (400, json!({"error": e}), false),
}
}
(Method::Get, path) if state_ref.dev && path.starts_with("/eval/") => {
let id = path.trim_start_matches("/eval/").to_string();
let r = state_ref.eval_results.lock().unwrap().get(&id).cloned();
match r {
Some(v) => (200, json!({"result": v, "done": true}), false),
None => (200, json!({"done": false}), false),
}
}
// Shutdown
(Method::Post, "/shutdown") => {
if !args.no_tab {
let _ = tab::remove_webview_tab(&args.tab_name);
}
let _ = req.respond(json_resp(200, json!({"ok": true, "shutdown": true})));
ok("shutdown requested, exiting");
std::process::exit(0);
}
_ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)}), false),
};
let _ = req.respond(json_resp(result.0, result.1));
}
Ok(())
}
fn rand_suffix() -> String {
// Tiny rand using system time — good enough for per-request ids.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{:x}", now)
}