at main 189 lines 7.2 kB view raw
1//! Web worker for fetching and caching AT Protocol embeds. 2//! 3//! This crate provides: 4//! - `EmbedWorker`: The worker implementation (runs in worker thread) 5//! - `EmbedWorkerHost`: Host-side manager for spawning and communicating with the worker 6//! - `EmbedWorkerInput`/`EmbedWorkerOutput`: Message types for worker communication 7//! 8//! # Usage 9//! 10//! The worker runs off the main thread, fetching and caching AT Protocol embeds. 11//! Use `EmbedWorkerHost` on the main thread to spawn and communicate with it: 12//! 13//! ```ignore 14//! use weaver_embed_worker::{EmbedWorkerHost, EmbedWorkerOutput}; 15//! 16//! let host = EmbedWorkerHost::spawn("/embed_worker.js", |output| { 17//! if let EmbedWorkerOutput::Embeds { results, .. } = output { 18//! // Update UI with fetched embeds 19//! } 20//! }); 21//! 22//! host.fetch_embeds(vec!["at://did:plc:xxx/app.bsky.feed.post/yyy".into()]); 23//! ``` 24 25use serde::{Deserialize, Serialize}; 26use std::collections::HashMap; 27 28/// Input messages to the embed worker. 29#[derive(Serialize, Deserialize, Debug, Clone)] 30pub enum EmbedWorkerInput { 31 /// Request embeds for a list of AT URIs. 32 /// Worker returns cached results immediately and fetches missing ones. 33 FetchEmbeds { 34 /// AT URIs to fetch (e.g., "at://did:plc:xxx/app.bsky.feed.post/yyy") 35 uris: Vec<String>, 36 }, 37 /// Clear the cache (e.g., on session change). 38 ClearCache, 39} 40 41/// Output messages from the embed worker. 42#[derive(Serialize, Deserialize, Debug, Clone)] 43pub enum EmbedWorkerOutput { 44 /// Embed results (may be partial if some failed). 45 Embeds { 46 /// Successfully fetched/cached embeds: uri -> rendered HTML. 47 results: HashMap<String, String>, 48 /// URIs that failed to fetch. 49 errors: HashMap<String, String>, 50 /// Timing info in milliseconds. 51 fetch_ms: f64, 52 }, 53 /// Cache was cleared. 54 CacheCleared, 55} 56 57#[cfg(all(target_family = "wasm", target_os = "unknown"))] 58mod worker_impl { 59 use super::*; 60 use gloo_worker::{HandlerId, Worker, WorkerScope}; 61 use jacquard::IntoStatic; 62 use jacquard::client::UnauthenticatedSession; 63 use jacquard::identity::JacquardResolver; 64 use jacquard::prelude::*; 65 use jacquard::types::string::AtUri; 66 use std::time::Duration; 67 use weaver_common::cache; 68 69 /// Embed worker with persistent cache. 70 pub struct EmbedWorker { 71 /// Cached rendered embeds with TTL and max capacity. 72 cache: cache::Cache<AtUri<'static>, String>, 73 /// Unauthenticated session for public API calls. 74 session: UnauthenticatedSession<JacquardResolver>, 75 } 76 77 impl Worker for EmbedWorker { 78 type Message = (); 79 type Input = EmbedWorkerInput; 80 type Output = EmbedWorkerOutput; 81 82 fn create(_scope: &WorkerScope<Self>) -> Self { 83 Self { 84 // Cache up to 500 embeds, TTL of 1 hour. 85 cache: cache::new_cache(500, Duration::from_secs(3600)), 86 session: UnauthenticatedSession::default(), 87 } 88 } 89 90 fn update(&mut self, _scope: &WorkerScope<Self>, _msg: Self::Message) {} 91 92 fn received(&mut self, scope: &WorkerScope<Self>, msg: Self::Input, id: HandlerId) { 93 match msg { 94 EmbedWorkerInput::FetchEmbeds { uris } => { 95 let mut results = HashMap::new(); 96 let mut errors = HashMap::new(); 97 let mut to_fetch = Vec::new(); 98 99 // Parse URIs and check cache. 100 for uri_str in uris { 101 let at_uri = match AtUri::new_owned(uri_str.clone()) { 102 Ok(u) => u, 103 Err(e) => { 104 errors.insert(uri_str, format!("Invalid AT URI: {e}")); 105 continue; 106 } 107 }; 108 109 if let Some(html) = cache::get(&self.cache, &at_uri) { 110 results.insert(uri_str, html); 111 } else { 112 to_fetch.push((uri_str, at_uri)); 113 } 114 } 115 116 // If nothing to fetch, respond immediately. 117 if to_fetch.is_empty() { 118 scope.respond( 119 id, 120 EmbedWorkerOutput::Embeds { 121 results, 122 errors, 123 fetch_ms: 0.0, 124 }, 125 ); 126 return; 127 } 128 129 // Fetch missing embeds asynchronously. 130 let session = self.session.clone(); 131 let worker_cache = self.cache.clone(); 132 let scope = scope.clone(); 133 134 wasm_bindgen_futures::spawn_local(async move { 135 // Use weaver-index when use-index feature is enabled. 136 #[cfg(feature = "use-index")] 137 { 138 use jacquard::xrpc::XrpcClient; 139 use jacquard::url::Url; 140 if let Ok(url) = Url::parse("https://index.weaver.sh") { 141 session.set_base_uri(url).await; 142 } 143 } 144 145 let fetch_start = weaver_common::perf::now(); 146 147 for (uri_str, at_uri) in to_fetch { 148 match weaver_renderer::atproto::fetch_and_render(&at_uri, &session) 149 .await 150 { 151 Ok(html) => { 152 cache::insert(&worker_cache, at_uri, html.clone()); 153 results.insert(uri_str, html); 154 } 155 Err(e) => { 156 errors.insert(uri_str, format!("{:?}", e)); 157 } 158 } 159 } 160 161 let fetch_ms = weaver_common::perf::now() - fetch_start; 162 scope.respond( 163 id, 164 EmbedWorkerOutput::Embeds { 165 results, 166 errors, 167 fetch_ms, 168 }, 169 ); 170 }); 171 } 172 173 EmbedWorkerInput::ClearCache => { 174 // mini-moka doesn't have a clear method, so we just respond. 175 // The cache will naturally expire entries via TTL. 176 scope.respond(id, EmbedWorkerOutput::CacheCleared); 177 } 178 } 179 } 180 } 181} 182 183#[cfg(all(target_family = "wasm", target_os = "unknown"))] 184pub use worker_impl::EmbedWorker; 185 186#[cfg(all(target_family = "wasm", target_os = "unknown"))] 187mod host; 188#[cfg(all(target_family = "wasm", target_os = "unknown"))] 189pub use host::EmbedWorkerHost;