···88use super::formatting::{self, FormatAction};
99use weaver_editor_core::SnapDirection;
10101111+// Re-export ListContext from core - the logic is duplicated below for Loro-specific usage,
1212+// but the type itself comes from core.
1313+pub use weaver_editor_core::ListContext;
1414+1515+// Re-export clipboard helpers from browser crate.
1616+#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1717+pub use weaver_editor_browser::{copy_as_html, write_clipboard_with_custom_type};
1818+1119/// Check if we need to intercept this key event.
1220/// Returns true for content-modifying operations, false for navigation.
1321#[allow(unused)]
···478486 {
479487 let _ = (evt, doc); // suppress unused warnings
480488 }
481481-}
482482-483483-/// Copy markdown as rendered HTML to clipboard.
484484-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
485485-pub async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> {
486486- use js_sys::Array;
487487- use wasm_bindgen::JsValue;
488488- use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
489489-490490- // Render markdown to HTML using ClientWriter
491491- let parser = markdown_weaver::Parser::new(markdown).into_offset_iter();
492492- let mut html = String::new();
493493- weaver_renderer::atproto::ClientWriter::<_, _, ()>::new(parser, &mut html, markdown)
494494- .run()
495495- .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?;
496496-497497- let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
498498- let clipboard = window.navigator().clipboard();
499499-500500- // Create blobs for both HTML and plain text (raw HTML for inspection)
501501- let parts = Array::new();
502502- parts.push(&JsValue::from_str(&html));
503503-504504- let mut html_opts = BlobPropertyBag::new();
505505- html_opts.type_("text/html");
506506- let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?;
507507-508508- let mut text_opts = BlobPropertyBag::new();
509509- text_opts.type_("text/plain");
510510- let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?;
511511-512512- // Create ClipboardItem with both types
513513- let item_data = js_sys::Object::new();
514514- js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?;
515515- js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
516516-517517- let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
518518- let items = Array::new();
519519- items.push(&clipboard_item);
520520-521521- wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?;
522522- tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len());
523523- Ok(())
524524-}
525525-526526-/// Write text to clipboard with both text/plain and custom MIME type.
527527-#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
528528-pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> {
529529- use js_sys::{Array, Object, Reflect};
530530- use wasm_bindgen::JsValue;
531531- use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
532532-533533- let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
534534- let navigator = window.navigator();
535535- let clipboard = navigator.clipboard();
536536-537537- // Create blobs for each MIME type
538538- let text_parts = Array::new();
539539- text_parts.push(&JsValue::from_str(text));
540540-541541- let mut text_opts = BlobPropertyBag::new();
542542- text_opts.type_("text/plain");
543543- let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?;
544544-545545- let mut custom_opts = BlobPropertyBag::new();
546546- custom_opts.type_("text/x-weaver-md");
547547- let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?;
548548-549549- // Create ClipboardItem with both types
550550- let item_data = Object::new();
551551- Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
552552- Reflect::set(
553553- &item_data,
554554- &JsValue::from_str("text/x-weaver-md"),
555555- &custom_blob,
556556- )?;
557557-558558- let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
559559- let items = Array::new();
560560- items.push(&clipboard_item);
561561-562562- let promise = clipboard.write(&items);
563563- wasm_bindgen_futures::JsFuture::from(promise).await?;
564564-565565- Ok(())
566566-}
567567-568568-/// Describes what kind of list item the cursor is in, if any.
569569-#[derive(Debug, Clone)]
570570-pub enum ListContext {
571571- /// Unordered list with the given marker char ('-' or '*') and indentation.
572572- Unordered { indent: String, marker: char },
573573- /// Ordered list with the current number and indentation.
574574- Ordered { indent: String, number: usize },
575489}
576490577491/// Detect if cursor is in a list item and return context for continuation.
+4-4
crates/weaver-app/src/components/editor/mod.rs
···9696#[allow(unused_imports)]
9797pub use log_buffer::LogCaptureLayer;
98989999-// Worker
9999+// Worker - EditorReactor stays local, EmbedWorker comes from weaver-embed-worker
100100#[cfg(all(target_family = "wasm", target_os = "unknown"))]
101101-pub use worker::{
102102- EditorReactor, EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput, WorkerInput, WorkerOutput,
103103-};
101101+pub use worker::{EditorReactor, WorkerInput, WorkerOutput};
102102+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
103103+pub use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput};
104104105105// Collab coordinator
106106#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
-165
crates/weaver-app/src/components/editor/worker.rs
···66//!
77//! When the `collab-worker` feature is enabled, also handles iroh P2P
88//! networking for real-time collaboration.
99-//!
1010-//! Also handles embed fetching with a persistent cache to avoid re-fetching.
1191210#[cfg(all(target_family = "wasm", target_os = "unknown"))]
1311use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
1412use serde::{Deserialize, Serialize};
1515-use std::collections::HashMap;
1613use weaver_common::transport::PresenceSnapshot;
17141815#[cfg(all(target_family = "wasm", target_os = "unknown"))]
···772769773770#[cfg(all(target_family = "wasm", target_os = "unknown"))]
774771pub use worker_impl::EditorReactor;
775775-776776-// ============================================================================
777777-// Embed Worker - fetches and caches AT Protocol embeds
778778-// ============================================================================
779779-780780-/// Input messages to the embed worker.
781781-#[derive(Serialize, Deserialize, Debug, Clone)]
782782-pub enum EmbedWorkerInput {
783783- /// Request embeds for a list of AT URIs.
784784- /// Worker returns cached results immediately and fetches missing ones.
785785- FetchEmbeds {
786786- /// AT URIs to fetch (e.g., "at://did:plc:xxx/app.bsky.feed.post/yyy")
787787- uris: Vec<String>,
788788- },
789789- /// Clear the cache (e.g., on session change)
790790- ClearCache,
791791-}
792792-793793-/// Output messages from the embed worker.
794794-#[derive(Serialize, Deserialize, Debug, Clone)]
795795-pub enum EmbedWorkerOutput {
796796- /// Embed results (may be partial if some failed)
797797- Embeds {
798798- /// Successfully fetched/cached embeds: uri -> rendered HTML
799799- results: HashMap<String, String>,
800800- /// URIs that failed to fetch
801801- errors: HashMap<String, String>,
802802- /// Timing info
803803- fetch_ms: f64,
804804- },
805805- /// Cache was cleared
806806- CacheCleared,
807807-}
808808-809809-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
810810-mod embed_worker_impl {
811811- use super::*;
812812- use crate::cache_impl;
813813- use gloo_worker::{HandlerId, Worker, WorkerScope};
814814- use jacquard::IntoStatic;
815815- use jacquard::client::UnauthenticatedSession;
816816- use jacquard::identity::JacquardResolver;
817817- use jacquard::prelude::*;
818818- use jacquard::types::string::AtUri;
819819- use std::time::Duration;
820820-821821- /// Embed worker with persistent cache.
822822- pub struct EmbedWorker {
823823- /// Cached rendered embeds with TTL and max capacity
824824- cache: cache_impl::Cache<AtUri<'static>, String>,
825825- /// Unauthenticated session for public API calls
826826- session: UnauthenticatedSession<JacquardResolver>,
827827- }
828828-829829- impl Worker for EmbedWorker {
830830- type Message = ();
831831- type Input = EmbedWorkerInput;
832832- type Output = EmbedWorkerOutput;
833833-834834- fn create(_scope: &WorkerScope<Self>) -> Self {
835835- Self {
836836- // Cache up to 500 embeds, TTL of 1 hour
837837- cache: cache_impl::new_cache(500, Duration::from_secs(3600)),
838838- session: UnauthenticatedSession::default(),
839839- }
840840- }
841841-842842- fn update(&mut self, _scope: &WorkerScope<Self>, _msg: Self::Message) {}
843843-844844- fn received(&mut self, scope: &WorkerScope<Self>, msg: Self::Input, id: HandlerId) {
845845- match msg {
846846- EmbedWorkerInput::FetchEmbeds { uris } => {
847847- let mut results = HashMap::new();
848848- let mut errors = HashMap::new();
849849- let mut to_fetch = Vec::new();
850850-851851- // Parse URIs and check cache
852852- for uri_str in uris {
853853- let at_uri = match AtUri::new_owned(uri_str.clone()) {
854854- Ok(u) => u,
855855- Err(e) => {
856856- errors.insert(uri_str, format!("Invalid AT URI: {e}"));
857857- continue;
858858- }
859859- };
860860-861861- if let Some(html) = cache_impl::get(&self.cache, &at_uri) {
862862- results.insert(uri_str, html);
863863- } else {
864864- to_fetch.push((uri_str, at_uri));
865865- }
866866- }
867867-868868- // If nothing to fetch, respond immediately
869869- if to_fetch.is_empty() {
870870- scope.respond(
871871- id,
872872- EmbedWorkerOutput::Embeds {
873873- results,
874874- errors,
875875- fetch_ms: 0.0,
876876- },
877877- );
878878- return;
879879- }
880880-881881- // Fetch missing embeds asynchronously
882882- let session = self.session.clone();
883883- let cache = self.cache.clone();
884884- let scope = scope.clone();
885885-886886- wasm_bindgen_futures::spawn_local(async move {
887887- // Use weaver-index when use-index feature is enabled
888888- #[cfg(feature = "use-index")]
889889- {
890890- use jacquard::xrpc::XrpcClient;
891891- use jacquard::url::Url;
892892- if let Ok(url) = Url::parse("https://index.weaver.sh") {
893893- session.set_base_uri(url).await;
894894- }
895895- }
896896-897897- let fetch_start = crate::perf::now();
898898-899899- for (uri_str, at_uri) in to_fetch {
900900- match weaver_renderer::atproto::fetch_and_render(&at_uri, &session)
901901- .await
902902- {
903903- Ok(html) => {
904904- cache_impl::insert(&cache, at_uri, html.clone());
905905- results.insert(uri_str, html);
906906- }
907907- Err(e) => {
908908- errors.insert(uri_str, format!("{:?}", e));
909909- }
910910- }
911911- }
912912-913913- let fetch_ms = crate::perf::now() - fetch_start;
914914- scope.respond(
915915- id,
916916- EmbedWorkerOutput::Embeds {
917917- results,
918918- errors,
919919- fetch_ms,
920920- },
921921- );
922922- });
923923- }
924924-925925- EmbedWorkerInput::ClearCache => {
926926- // mini-moka doesn't have a clear method, so we just recreate
927927- // (this is fine since ClearCache is rarely called)
928928- scope.respond(id, EmbedWorkerOutput::CacheCleared);
929929- }
930930- }
931931- }
932932- }
933933-}
934934-935935-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
936936-pub use embed_worker_impl::EmbedWorker;
+3-56
crates/weaver-app/src/perf.rs
···11-//! Performance timing utilities for instrumentation.
11+//! Re-export perf utilities from weaver-common.
22//!
33-//! Provides a cross-platform wrapper around Performance.now() for WASM
44-//! and a no-op fallback for native builds.
55-66-/// Get the current high-resolution timestamp in milliseconds.
77-///
88-/// On WASM, this uses `Performance.now()` from the Web Performance API.
99-/// On native builds, returns 0.0 (instrumentation is primarily for browser profiling).
1010-#[cfg(all(target_family = "wasm", target_os = "unknown"))]
1111-pub fn now() -> f64 {
1212- web_sys::window()
1313- .and_then(|w| w.performance())
1414- .map(|p| p.now())
1515- .unwrap_or(0.0)
1616-}
1717-1818-#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
1919-pub fn now() -> f64 {
2020- 0.0
2121-}
33+//! This module exists for backwards compatibility during migration.
2242323-/// Measure the execution time of a closure and log it.
2424-///
2525-/// Returns the closure's result and logs the elapsed time via tracing.
2626-#[allow(dead_code)]
2727-pub fn measure<T, F: FnOnce() -> T>(label: &str, f: F) -> T {
2828- let start = now();
2929- let result = f();
3030- let elapsed = now() - start;
3131- tracing::debug!(elapsed_ms = elapsed, "{}", label);
3232- result
3333-}
3434-3535-/// A guard that logs elapsed time when dropped.
3636-///
3737-/// Useful for timing blocks of code without closures.
3838-#[allow(dead_code)]
3939-pub struct TimingGuard {
4040- label: &'static str,
4141- start: f64,
4242-}
4343-4444-impl TimingGuard {
4545- pub fn new(label: &'static str) -> Self {
4646- Self {
4747- label,
4848- start: now(),
4949- }
5050- }
5151-}
5252-5353-impl Drop for TimingGuard {
5454- fn drop(&mut self) {
5555- let elapsed = now() - self.start;
5656- tracing::debug!(elapsed_ms = elapsed, "{}", self.label);
5757- }
5858-}
55+pub use weaver_common::perf::*;
+6
crates/weaver-common/Cargo.toml
···1212use-index = []
1313iroh = ["dep:iroh", "dep:iroh-gossip", "dep:iroh-tickets"]
1414telemetry = ["dep:metrics", "dep:metrics-exporter-prometheus", "dep:tracing-subscriber", "dep:tracing-loki"]
1515+cache = ["dep:mini-moka-wasm"]
1616+perf = []
15171618[dependencies]
1719n0-future = { workspace = true }
···5456getrandom = { version = "0.3", features = [] }
5557ring = { version = "0.17", default-features = false }
56585959+# TTL cache (optional) - mini-moka-wasm works on both native and WASM
6060+mini-moka-wasm = { version = "0.10.99", optional = true }
6161+57625863[target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies]
5964regex = "1.11.1"
···7176wasmworker-proc-macro = "0.1"
7277ring = { version = "0.17", default-features = false, features = ["wasm32_unknown_unknown_js"]}
7378getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] }
7979+web-sys = { version = "0.3", features = ["Window", "Performance"] }
748075817682[dev-dependencies]
···11//! Weaver common library - thin wrapper around jacquard with notebook-specific conveniences
2233pub mod agent;
44+#[cfg(feature = "cache")]
55+pub mod cache;
46pub mod constellation;
57pub mod error;
88+#[cfg(feature = "perf")]
99+pub mod perf;
610pub mod resolve;
711#[cfg(feature = "telemetry")]
812pub mod telemetry;
+61
crates/weaver-common/src/perf.rs
···11+//! Performance timing utilities for instrumentation.
22+//!
33+//! Provides a cross-platform wrapper around Performance.now() for WASM
44+//! and a fallback for native builds.
55+66+/// Get the current high-resolution timestamp in milliseconds.
77+///
88+/// On WASM, this uses `Performance.now()` from the Web Performance API.
99+/// On native builds, uses std::time::Instant for actual timing.
1010+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
1111+pub fn now() -> f64 {
1212+ web_sys::window()
1313+ .and_then(|w| w.performance())
1414+ .map(|p| p.now())
1515+ .unwrap_or(0.0)
1616+}
1717+1818+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
1919+pub fn now() -> f64 {
2020+ use std::time::Instant;
2121+ static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
2222+ let start = START.get_or_init(Instant::now);
2323+ start.elapsed().as_secs_f64() * 1000.0
2424+}
2525+2626+/// Measure the execution time of a closure and log it.
2727+///
2828+/// Returns the closure's result and logs the elapsed time via tracing.
2929+#[allow(dead_code)]
3030+pub fn measure<T, F: FnOnce() -> T>(label: &str, f: F) -> T {
3131+ let start = now();
3232+ let result = f();
3333+ let elapsed = now() - start;
3434+ tracing::debug!(elapsed_ms = elapsed, "{}", label);
3535+ result
3636+}
3737+3838+/// A guard that logs elapsed time when dropped.
3939+///
4040+/// Useful for timing blocks of code without closures.
4141+#[allow(dead_code)]
4242+pub struct TimingGuard {
4343+ label: &'static str,
4444+ start: f64,
4545+}
4646+4747+impl TimingGuard {
4848+ pub fn new(label: &'static str) -> Self {
4949+ Self {
5050+ label,
5151+ start: now(),
5252+ }
5353+ }
5454+}
5555+5656+impl Drop for TimingGuard {
5757+ fn drop(&mut self) {
5858+ let elapsed = now() - self.start;
5959+ tracing::debug!(elapsed_ms = elapsed, "{}", self.label);
6060+ }
6161+}
+46
crates/weaver-editor-browser/src/events.rs
···254254 Ok(result.as_string())
255255}
256256257257+/// Copy markdown as rendered HTML to clipboard.
258258+///
259259+/// Renders the markdown to HTML and writes both text/html and text/plain
260260+/// representations to the clipboard.
261261+pub async fn copy_as_html(markdown: &str) -> Result<(), JsValue> {
262262+ use js_sys::{Array, Object, Reflect};
263263+ use web_sys::{Blob, BlobPropertyBag, ClipboardItem};
264264+265265+ // Render markdown to HTML using ClientWriter.
266266+ let parser = weaver_editor_core::markdown_weaver::Parser::new(markdown).into_offset_iter();
267267+ let mut html = String::new();
268268+ weaver_editor_core::weaver_renderer::atproto::ClientWriter::<_, _, ()>::new(
269269+ parser, &mut html, markdown,
270270+ )
271271+ .run()
272272+ .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?;
273273+274274+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
275275+ let clipboard = window.navigator().clipboard();
276276+277277+ // Create blobs for both HTML and plain text.
278278+ let parts = Array::new();
279279+ parts.push(&JsValue::from_str(&html));
280280+281281+ let html_opts = BlobPropertyBag::new();
282282+ html_opts.set_type("text/html");
283283+ let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?;
284284+285285+ let text_opts = BlobPropertyBag::new();
286286+ text_opts.set_type("text/plain");
287287+ let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?;
288288+289289+ // Create ClipboardItem with both types.
290290+ let item_data = Object::new();
291291+ Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?;
292292+ Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?;
293293+294294+ let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?;
295295+ let items = Array::new();
296296+ items.push(&clipboard_item);
297297+298298+ wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?;
299299+ tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len());
300300+ Ok(())
301301+}
302302+257303// === BeforeInput handler ===
258304259305use weaver_editor_core::{EditorAction, EditorDocument, execute_action};
···11+//! Entry point for the embed web worker.
22+//!
33+//! This binary is compiled separately and loaded by the main app
44+//! to fetch and cache AT Protocol embeds off the main thread.
55+66+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
77+fn main() {
88+ console_error_panic_hook::set_once();
99+1010+ use gloo_worker::Registrable;
1111+ use weaver_embed_worker::EmbedWorker;
1212+1313+ EmbedWorker::registrar().register();
1414+}
1515+1616+#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
1717+fn main() {
1818+ eprintln!("This binary is only meant to run as a WASM web worker");
1919+}
+165
crates/weaver-embed-worker/src/lib.rs
···11+//! Web worker for fetching and caching AT Protocol embeds.
22+//!
33+//! This crate provides a web worker that fetches and renders AT Protocol
44+//! record embeds off the main thread, with TTL-based caching.
55+66+use serde::{Deserialize, Serialize};
77+use std::collections::HashMap;
88+99+/// Input messages to the embed worker.
1010+#[derive(Serialize, Deserialize, Debug, Clone)]
1111+pub enum EmbedWorkerInput {
1212+ /// Request embeds for a list of AT URIs.
1313+ /// Worker returns cached results immediately and fetches missing ones.
1414+ FetchEmbeds {
1515+ /// AT URIs to fetch (e.g., "at://did:plc:xxx/app.bsky.feed.post/yyy")
1616+ uris: Vec<String>,
1717+ },
1818+ /// Clear the cache (e.g., on session change).
1919+ ClearCache,
2020+}
2121+2222+/// Output messages from the embed worker.
2323+#[derive(Serialize, Deserialize, Debug, Clone)]
2424+pub enum EmbedWorkerOutput {
2525+ /// Embed results (may be partial if some failed).
2626+ Embeds {
2727+ /// Successfully fetched/cached embeds: uri -> rendered HTML.
2828+ results: HashMap<String, String>,
2929+ /// URIs that failed to fetch.
3030+ errors: HashMap<String, String>,
3131+ /// Timing info in milliseconds.
3232+ fetch_ms: f64,
3333+ },
3434+ /// Cache was cleared.
3535+ CacheCleared,
3636+}
3737+3838+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
3939+mod worker_impl {
4040+ use super::*;
4141+ use gloo_worker::{HandlerId, Worker, WorkerScope};
4242+ use jacquard::IntoStatic;
4343+ use jacquard::client::UnauthenticatedSession;
4444+ use jacquard::identity::JacquardResolver;
4545+ use jacquard::prelude::*;
4646+ use jacquard::types::string::AtUri;
4747+ use std::time::Duration;
4848+ use weaver_common::cache;
4949+5050+ /// Embed worker with persistent cache.
5151+ pub struct EmbedWorker {
5252+ /// Cached rendered embeds with TTL and max capacity.
5353+ cache: cache::Cache<AtUri<'static>, String>,
5454+ /// Unauthenticated session for public API calls.
5555+ session: UnauthenticatedSession<JacquardResolver>,
5656+ }
5757+5858+ impl Worker for EmbedWorker {
5959+ type Message = ();
6060+ type Input = EmbedWorkerInput;
6161+ type Output = EmbedWorkerOutput;
6262+6363+ fn create(_scope: &WorkerScope<Self>) -> Self {
6464+ Self {
6565+ // Cache up to 500 embeds, TTL of 1 hour.
6666+ cache: cache::new_cache(500, Duration::from_secs(3600)),
6767+ session: UnauthenticatedSession::default(),
6868+ }
6969+ }
7070+7171+ fn update(&mut self, _scope: &WorkerScope<Self>, _msg: Self::Message) {}
7272+7373+ fn received(&mut self, scope: &WorkerScope<Self>, msg: Self::Input, id: HandlerId) {
7474+ match msg {
7575+ EmbedWorkerInput::FetchEmbeds { uris } => {
7676+ let mut results = HashMap::new();
7777+ let mut errors = HashMap::new();
7878+ let mut to_fetch = Vec::new();
7979+8080+ // Parse URIs and check cache.
8181+ for uri_str in uris {
8282+ let at_uri = match AtUri::new_owned(uri_str.clone()) {
8383+ Ok(u) => u,
8484+ Err(e) => {
8585+ errors.insert(uri_str, format!("Invalid AT URI: {e}"));
8686+ continue;
8787+ }
8888+ };
8989+9090+ if let Some(html) = cache::get(&self.cache, &at_uri) {
9191+ results.insert(uri_str, html);
9292+ } else {
9393+ to_fetch.push((uri_str, at_uri));
9494+ }
9595+ }
9696+9797+ // If nothing to fetch, respond immediately.
9898+ if to_fetch.is_empty() {
9999+ scope.respond(
100100+ id,
101101+ EmbedWorkerOutput::Embeds {
102102+ results,
103103+ errors,
104104+ fetch_ms: 0.0,
105105+ },
106106+ );
107107+ return;
108108+ }
109109+110110+ // Fetch missing embeds asynchronously.
111111+ let session = self.session.clone();
112112+ let worker_cache = self.cache.clone();
113113+ let scope = scope.clone();
114114+115115+ wasm_bindgen_futures::spawn_local(async move {
116116+ // Use weaver-index when use-index feature is enabled.
117117+ #[cfg(feature = "use-index")]
118118+ {
119119+ use jacquard::xrpc::XrpcClient;
120120+ use jacquard::url::Url;
121121+ if let Ok(url) = Url::parse("https://index.weaver.sh") {
122122+ session.set_base_uri(url).await;
123123+ }
124124+ }
125125+126126+ let fetch_start = weaver_common::perf::now();
127127+128128+ for (uri_str, at_uri) in to_fetch {
129129+ match weaver_renderer::atproto::fetch_and_render(&at_uri, &session)
130130+ .await
131131+ {
132132+ Ok(html) => {
133133+ cache::insert(&worker_cache, at_uri, html.clone());
134134+ results.insert(uri_str, html);
135135+ }
136136+ Err(e) => {
137137+ errors.insert(uri_str, format!("{:?}", e));
138138+ }
139139+ }
140140+ }
141141+142142+ let fetch_ms = weaver_common::perf::now() - fetch_start;
143143+ scope.respond(
144144+ id,
145145+ EmbedWorkerOutput::Embeds {
146146+ results,
147147+ errors,
148148+ fetch_ms,
149149+ },
150150+ );
151151+ });
152152+ }
153153+154154+ EmbedWorkerInput::ClearCache => {
155155+ // mini-moka doesn't have a clear method, so we just respond.
156156+ // The cache will naturally expire entries via TTL.
157157+ scope.respond(id, EmbedWorkerOutput::CacheCleared);
158158+ }
159159+ }
160160+ }
161161+ }
162162+}
163163+164164+#[cfg(all(target_family = "wasm", target_os = "unknown"))]
165165+pub use worker_impl::EmbedWorker;