From 54300c95dec21dec02ff131b9dede74c670e92bd Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 6 Jan 2026 13:41:19 -0500 Subject: [PATCH] embed worker extracted, plus other components --- Cargo.lock | 36 +++- crates/weaver-app/Cargo.toml | 5 +- crates/weaver-app/src/cache_impl.rs | 106 +---------- .../weaver-app/src/components/editor/input.rs | 102 +---------- .../weaver-app/src/components/editor/mod.rs | 8 +- .../src/components/editor/worker.rs | 165 ------------------ crates/weaver-app/src/perf.rs | 59 +------ crates/weaver-common/Cargo.toml | 6 + crates/weaver-common/src/cache.rs | 123 +++++++++++++ crates/weaver-common/src/lib.rs | 4 + crates/weaver-common/src/perf.rs | 61 +++++++ crates/weaver-editor-browser/src/events.rs | 46 +++++ crates/weaver-editor-browser/src/lib.rs | 2 +- crates/weaver-editor-core/src/lib.rs | 4 + crates/weaver-embed-worker/Cargo.toml | 27 +++ .../src/bin/embed_worker.rs | 19 ++ crates/weaver-embed-worker/src/lib.rs | 165 ++++++++++++++++++ docs/graph-data.json | 132 ++++++++++++++ 18 files changed, 642 insertions(+), 428 deletions(-) create mode 100644 crates/weaver-common/src/cache.rs create mode 100644 crates/weaver-common/src/perf.rs create mode 100644 crates/weaver-embed-worker/Cargo.toml create mode 100644 crates/weaver-embed-worker/src/bin/embed_worker.rs create mode 100644 crates/weaver-embed-worker/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 483bbb5..06d01a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5849,7 +5849,7 @@ dependencies = [ "jacquard-common", "jacquard-lexicon", "miette 7.6.0", - "mini-moka-wasm", + "mini-moka-wasm 0.10.99 (git+https://tangled.org/@nonbinary.computer/jacquard)", "n0-future 0.1.3", "percent-encoding", "reqwest", @@ -6931,7 +6931,20 @@ dependencies = [ "smallvec", "tagptr", "triomphe", - "web-time", +] + +[[package]] +name = "mini-moka-wasm" +version = "0.10.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0102b9a2ad50fa47ca89eead2316c8222285ecfbd3f69ce99564fbe4253866e8" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap 6.1.0", + "smallvec", + "tagptr", + "triomphe", ] [[package]] @@ -12087,7 +12100,6 @@ dependencies = [ "markdown-weaver", "markdown-weaver-escape", "mime-sniffer", - "mini-moka", "n0-future 0.1.3", "postcard", "regex", @@ -12115,6 +12127,7 @@ dependencies = [ "weaver-common", "weaver-editor-browser", "weaver-editor-core", + "weaver-embed-worker", "weaver-renderer", "web-sys", "web-time", @@ -12162,6 +12175,7 @@ dependencies = [ "metrics-exporter-prometheus", "miette 7.6.0", "mime-sniffer", + "mini-moka-wasm 0.10.99 (registry+https://github.com/rust-lang/crates.io-index)", "n0-future 0.1.3", "pin-project", "postcard", @@ -12184,6 +12198,7 @@ dependencies = [ "wasmworker", "wasmworker-proc-macro", "weaver-api", + "web-sys", "web-time", ] @@ -12222,6 +12237,21 @@ dependencies = [ "web-time", ] +[[package]] +name = "weaver-embed-worker" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "gloo-worker", + "jacquard", + "serde", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "weaver-common", + "weaver-renderer", +] + [[package]] name = "weaver-index" version = "0.1.0" diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index a953ca7..52a17e7 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -47,7 +47,7 @@ collab-worker = ["weaver-common/iroh"] dashmap = "6.1.0" dioxus = { version = "0.7.1", features = ["router"] } -weaver-common = { path = "../weaver-common" } +weaver-common = { path = "../weaver-common", features = ["cache", "perf"] } weaver-editor-core = { path = "../weaver-editor-core" } weaver-editor-browser = { path = "../weaver-editor-browser" } jacquard = { workspace = true}#, features = ["streaming"] } @@ -57,7 +57,6 @@ jacquard-axum = { workspace = true, optional = true } weaver-api = { path = "../weaver-api", features = ["com_whtwnd"] } markdown-weaver = { workspace = true } weaver-renderer = { path = "../weaver-renderer" } -mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d034f32e02197fee9bb5d5af36d3d" } n0-future = { workspace = true } dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, features = ["router"] } axum = {version = "0.8.6", optional = true} @@ -108,7 +107,7 @@ tracing-wasm = "0.2" #sqlite-wasm-rs = { version = "0.4", default-features = false, features = ["precompiled", "relaxed-idb"] } time = { version = "0.3", features = ["wasm-bindgen"] } console_error_panic_hook = "0.1" -mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d034f32e02197fee9bb5d5af36d3d", features = ["js"] } +weaver-embed-worker = { path = "../weaver-embed-worker" } chrono = { version = "0.4", features = ["wasmbind"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" diff --git a/crates/weaver-app/src/cache_impl.rs b/crates/weaver-app/src/cache_impl.rs index 1a4720a..f2eb45a 100644 --- a/crates/weaver-app/src/cache_impl.rs +++ b/crates/weaver-app/src/cache_impl.rs @@ -1,103 +1,5 @@ -//! Platform-specific cache implementations -//! Native uses sync cache (thread-safe, no mutex needed) -//! WASM uses unsync cache wrapped in Arc> (no threads, but need interior mutability) +//! Re-export cache implementation from weaver-common. +//! +//! This module exists for backwards compatibility during migration. -#[cfg(not(target_arch = "wasm32"))] -mod native { - use std::time::Duration; - - pub type Cache = mini_moka::sync::Cache; - - pub fn new_cache(max_capacity: u64, ttl: Duration) -> Cache - where - K: std::hash::Hash + Eq + Send + Sync + 'static, - V: Clone + Send + Sync + 'static, - { - mini_moka::sync::Cache::builder() - .max_capacity(max_capacity) - .time_to_live(ttl) - .build() - } - - pub fn get(cache: &Cache, key: &K) -> Option - where - K: std::hash::Hash + Eq + Send + Sync + 'static, - V: Clone + Send + Sync + 'static, - { - cache.get(key) - } - - pub fn insert(cache: &Cache, key: K, value: V) - where - K: std::hash::Hash + Eq + Send + Sync + 'static, - V: Clone + Send + Sync + 'static, - { - cache.insert(key, value); - } - - #[allow(dead_code)] - pub fn iter(cache: &Cache) -> Vec - where - K: std::hash::Hash + Eq + Send + Sync + 'static, - V: Clone + Send + Sync + 'static, - { - cache.iter().map(|entry| entry.value().clone()).collect() - } -} - -#[cfg(target_arch = "wasm32")] -mod wasm { - use std::sync::{Arc, Mutex}; - use std::time::Duration; - - pub type Cache = Arc>>; - - pub fn new_cache(max_capacity: u64, ttl: Duration) -> Cache - where - K: std::hash::Hash + Eq + 'static, - V: Clone + 'static, - { - Arc::new(Mutex::new( - mini_moka::unsync::Cache::builder() - .max_capacity(max_capacity) - .time_to_live(ttl) - .build(), - )) - } - - pub fn get(cache: &Cache, key: &K) -> Option - where - K: std::hash::Hash + Eq + 'static, - V: Clone + 'static, - { - cache.lock().unwrap().get(key).cloned() - } - - pub fn insert(cache: &Cache, key: K, value: V) - where - K: std::hash::Hash + Eq + 'static, - V: Clone + 'static, - { - cache.lock().unwrap().insert(key, value); - } - - #[allow(dead_code)] - pub fn iter(cache: &Cache) -> Vec - where - K: std::hash::Hash + Eq + 'static, - V: Clone + 'static, - { - cache - .lock() - .unwrap() - .iter() - .map(|(_, v)| v.clone()) - .collect() - } -} - -#[cfg(not(target_arch = "wasm32"))] -pub use native::*; - -#[cfg(target_arch = "wasm32")] -pub use wasm::*; +pub use weaver_common::cache::*; diff --git a/crates/weaver-app/src/components/editor/input.rs b/crates/weaver-app/src/components/editor/input.rs index 5cd9484..19f7ab6 100644 --- a/crates/weaver-app/src/components/editor/input.rs +++ b/crates/weaver-app/src/components/editor/input.rs @@ -8,6 +8,14 @@ use super::document::EditorDocument; use super::formatting::{self, FormatAction}; use weaver_editor_core::SnapDirection; +// Re-export ListContext from core - the logic is duplicated below for Loro-specific usage, +// but the type itself comes from core. +pub use weaver_editor_core::ListContext; + +// Re-export clipboard helpers from browser crate. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +pub use weaver_editor_browser::{copy_as_html, write_clipboard_with_custom_type}; + /// Check if we need to intercept this key event. /// Returns true for content-modifying operations, false for navigation. #[allow(unused)] @@ -480,100 +488,6 @@ pub fn handle_copy(evt: Event, doc: &EditorDocument) { } } -/// Copy markdown as rendered HTML to clipboard. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -pub async fn copy_as_html(markdown: &str) -> Result<(), wasm_bindgen::JsValue> { - use js_sys::Array; - use wasm_bindgen::JsValue; - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; - - // Render markdown to HTML using ClientWriter - let parser = markdown_weaver::Parser::new(markdown).into_offset_iter(); - let mut html = String::new(); - weaver_renderer::atproto::ClientWriter::<_, _, ()>::new(parser, &mut html, markdown) - .run() - .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; - - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; - let clipboard = window.navigator().clipboard(); - - // Create blobs for both HTML and plain text (raw HTML for inspection) - let parts = Array::new(); - parts.push(&JsValue::from_str(&html)); - - let mut html_opts = BlobPropertyBag::new(); - html_opts.type_("text/html"); - let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; - - let mut text_opts = BlobPropertyBag::new(); - text_opts.type_("text/plain"); - let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; - - // Create ClipboardItem with both types - let item_data = js_sys::Object::new(); - js_sys::Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; - js_sys::Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; - - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; - let items = Array::new(); - items.push(&clipboard_item); - - wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; - tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); - Ok(()) -} - -/// Write text to clipboard with both text/plain and custom MIME type. -#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] -pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), wasm_bindgen::JsValue> { - use js_sys::{Array, Object, Reflect}; - use wasm_bindgen::JsValue; - use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; - - let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; - let navigator = window.navigator(); - let clipboard = navigator.clipboard(); - - // Create blobs for each MIME type - let text_parts = Array::new(); - text_parts.push(&JsValue::from_str(text)); - - let mut text_opts = BlobPropertyBag::new(); - text_opts.type_("text/plain"); - let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; - - let mut custom_opts = BlobPropertyBag::new(); - custom_opts.type_("text/x-weaver-md"); - let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; - - // Create ClipboardItem with both types - let item_data = Object::new(); - Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; - Reflect::set( - &item_data, - &JsValue::from_str("text/x-weaver-md"), - &custom_blob, - )?; - - let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; - let items = Array::new(); - items.push(&clipboard_item); - - let promise = clipboard.write(&items); - wasm_bindgen_futures::JsFuture::from(promise).await?; - - Ok(()) -} - -/// Describes what kind of list item the cursor is in, if any. -#[derive(Debug, Clone)] -pub enum ListContext { - /// Unordered list with the given marker char ('-' or '*') and indentation. - Unordered { indent: String, marker: char }, - /// Ordered list with the current number and indentation. - Ordered { indent: String, number: usize }, -} - /// Detect if cursor is in a list item and return context for continuation. /// /// Scans backwards to find start of current line, then checks for list marker. diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 408c504..cbd6a5c 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -96,11 +96,11 @@ pub use visibility::VisibilityState; #[allow(unused_imports)] pub use log_buffer::LogCaptureLayer; -// Worker +// Worker - EditorReactor stays local, EmbedWorker comes from weaver-embed-worker #[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub use worker::{ - EditorReactor, EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput, WorkerInput, WorkerOutput, -}; +pub use worker::{EditorReactor, WorkerInput, WorkerOutput}; +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub use weaver_embed_worker::{EmbedWorker, EmbedWorkerInput, EmbedWorkerOutput}; // Collab coordinator #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] diff --git a/crates/weaver-app/src/components/editor/worker.rs b/crates/weaver-app/src/components/editor/worker.rs index 4c9d771..537cc16 100644 --- a/crates/weaver-app/src/components/editor/worker.rs +++ b/crates/weaver-app/src/components/editor/worker.rs @@ -6,13 +6,10 @@ //! //! When the `collab-worker` feature is enabled, also handles iroh P2P //! networking for real-time collaboration. -//! -//! Also handles embed fetching with a persistent cache to avoid re-fetching. #[cfg(all(target_family = "wasm", target_os = "unknown"))] use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use weaver_common::transport::PresenceSnapshot; #[cfg(all(target_family = "wasm", target_os = "unknown"))] @@ -772,165 +769,3 @@ mod worker_impl { #[cfg(all(target_family = "wasm", target_os = "unknown"))] pub use worker_impl::EditorReactor; - -// ============================================================================ -// Embed Worker - fetches and caches AT Protocol embeds -// ============================================================================ - -/// Input messages to the embed worker. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EmbedWorkerInput { - /// Request embeds for a list of AT URIs. - /// Worker returns cached results immediately and fetches missing ones. - FetchEmbeds { - /// AT URIs to fetch (e.g., "at://did:plc:xxx/app.bsky.feed.post/yyy") - uris: Vec, - }, - /// Clear the cache (e.g., on session change) - ClearCache, -} - -/// Output messages from the embed worker. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EmbedWorkerOutput { - /// Embed results (may be partial if some failed) - Embeds { - /// Successfully fetched/cached embeds: uri -> rendered HTML - results: HashMap, - /// URIs that failed to fetch - errors: HashMap, - /// Timing info - fetch_ms: f64, - }, - /// Cache was cleared - CacheCleared, -} - -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -mod embed_worker_impl { - use super::*; - use crate::cache_impl; - use gloo_worker::{HandlerId, Worker, WorkerScope}; - use jacquard::IntoStatic; - use jacquard::client::UnauthenticatedSession; - use jacquard::identity::JacquardResolver; - use jacquard::prelude::*; - use jacquard::types::string::AtUri; - use std::time::Duration; - - /// Embed worker with persistent cache. - pub struct EmbedWorker { - /// Cached rendered embeds with TTL and max capacity - cache: cache_impl::Cache, String>, - /// Unauthenticated session for public API calls - session: UnauthenticatedSession, - } - - impl Worker for EmbedWorker { - type Message = (); - type Input = EmbedWorkerInput; - type Output = EmbedWorkerOutput; - - fn create(_scope: &WorkerScope) -> Self { - Self { - // Cache up to 500 embeds, TTL of 1 hour - cache: cache_impl::new_cache(500, Duration::from_secs(3600)), - session: UnauthenticatedSession::default(), - } - } - - fn update(&mut self, _scope: &WorkerScope, _msg: Self::Message) {} - - fn received(&mut self, scope: &WorkerScope, msg: Self::Input, id: HandlerId) { - match msg { - EmbedWorkerInput::FetchEmbeds { uris } => { - let mut results = HashMap::new(); - let mut errors = HashMap::new(); - let mut to_fetch = Vec::new(); - - // Parse URIs and check cache - for uri_str in uris { - let at_uri = match AtUri::new_owned(uri_str.clone()) { - Ok(u) => u, - Err(e) => { - errors.insert(uri_str, format!("Invalid AT URI: {e}")); - continue; - } - }; - - if let Some(html) = cache_impl::get(&self.cache, &at_uri) { - results.insert(uri_str, html); - } else { - to_fetch.push((uri_str, at_uri)); - } - } - - // If nothing to fetch, respond immediately - if to_fetch.is_empty() { - scope.respond( - id, - EmbedWorkerOutput::Embeds { - results, - errors, - fetch_ms: 0.0, - }, - ); - return; - } - - // Fetch missing embeds asynchronously - let session = self.session.clone(); - let cache = self.cache.clone(); - let scope = scope.clone(); - - wasm_bindgen_futures::spawn_local(async move { - // Use weaver-index when use-index feature is enabled - #[cfg(feature = "use-index")] - { - use jacquard::xrpc::XrpcClient; - use jacquard::url::Url; - if let Ok(url) = Url::parse("https://index.weaver.sh") { - session.set_base_uri(url).await; - } - } - - let fetch_start = crate::perf::now(); - - for (uri_str, at_uri) in to_fetch { - match weaver_renderer::atproto::fetch_and_render(&at_uri, &session) - .await - { - Ok(html) => { - cache_impl::insert(&cache, at_uri, html.clone()); - results.insert(uri_str, html); - } - Err(e) => { - errors.insert(uri_str, format!("{:?}", e)); - } - } - } - - let fetch_ms = crate::perf::now() - fetch_start; - scope.respond( - id, - EmbedWorkerOutput::Embeds { - results, - errors, - fetch_ms, - }, - ); - }); - } - - EmbedWorkerInput::ClearCache => { - // mini-moka doesn't have a clear method, so we just recreate - // (this is fine since ClearCache is rarely called) - scope.respond(id, EmbedWorkerOutput::CacheCleared); - } - } - } - } -} - -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub use embed_worker_impl::EmbedWorker; diff --git a/crates/weaver-app/src/perf.rs b/crates/weaver-app/src/perf.rs index 818f79a..3b324b3 100644 --- a/crates/weaver-app/src/perf.rs +++ b/crates/weaver-app/src/perf.rs @@ -1,58 +1,5 @@ -//! Performance timing utilities for instrumentation. +//! Re-export perf utilities from weaver-common. //! -//! Provides a cross-platform wrapper around Performance.now() for WASM -//! and a no-op fallback for native builds. +//! This module exists for backwards compatibility during migration. -/// Get the current high-resolution timestamp in milliseconds. -/// -/// On WASM, this uses `Performance.now()` from the Web Performance API. -/// On native builds, returns 0.0 (instrumentation is primarily for browser profiling). -#[cfg(all(target_family = "wasm", target_os = "unknown"))] -pub fn now() -> f64 { - web_sys::window() - .and_then(|w| w.performance()) - .map(|p| p.now()) - .unwrap_or(0.0) -} - -#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] -pub fn now() -> f64 { - 0.0 -} - -/// Measure the execution time of a closure and log it. -/// -/// Returns the closure's result and logs the elapsed time via tracing. -#[allow(dead_code)] -pub fn measure T>(label: &str, f: F) -> T { - let start = now(); - let result = f(); - let elapsed = now() - start; - tracing::debug!(elapsed_ms = elapsed, "{}", label); - result -} - -/// A guard that logs elapsed time when dropped. -/// -/// Useful for timing blocks of code without closures. -#[allow(dead_code)] -pub struct TimingGuard { - label: &'static str, - start: f64, -} - -impl TimingGuard { - pub fn new(label: &'static str) -> Self { - Self { - label, - start: now(), - } - } -} - -impl Drop for TimingGuard { - fn drop(&mut self) { - let elapsed = now() - self.start; - tracing::debug!(elapsed_ms = elapsed, "{}", self.label); - } -} +pub use weaver_common::perf::*; diff --git a/crates/weaver-common/Cargo.toml b/crates/weaver-common/Cargo.toml index e98ba99..ee17559 100644 --- a/crates/weaver-common/Cargo.toml +++ b/crates/weaver-common/Cargo.toml @@ -12,6 +12,8 @@ native = ["jacquard/dns"] use-index = [] iroh = ["dep:iroh", "dep:iroh-gossip", "dep:iroh-tickets"] telemetry = ["dep:metrics", "dep:metrics-exporter-prometheus", "dep:tracing-subscriber", "dep:tracing-loki"] +cache = ["dep:mini-moka-wasm"] +perf = [] [dependencies] n0-future = { workspace = true } @@ -54,6 +56,9 @@ tracing-loki = { version = "0.2", optional = true } getrandom = { version = "0.3", features = [] } ring = { version = "0.17", default-features = false } +# TTL cache (optional) - mini-moka-wasm works on both native and WASM +mini-moka-wasm = { version = "0.10.99", optional = true } + [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] regex = "1.11.1" @@ -71,6 +76,7 @@ wasmworker = "0.1" wasmworker-proc-macro = "0.1" ring = { version = "0.17", default-features = false, features = ["wasm32_unknown_unknown_js"]} getrandom = { version = "0.3", default-features = false, features = ["wasm_js"] } +web-sys = { version = "0.3", features = ["Window", "Performance"] } [dev-dependencies] diff --git a/crates/weaver-common/src/cache.rs b/crates/weaver-common/src/cache.rs new file mode 100644 index 0000000..a511a49 --- /dev/null +++ b/crates/weaver-common/src/cache.rs @@ -0,0 +1,123 @@ +//! Platform-specific TTL cache implementations. +//! +//! Provides a unified API over mini-moka-wasm's sync (native) and unsync (WASM) caches. +//! Native uses the sync cache (thread-safe). +//! WASM uses the unsync cache wrapped in Arc> (single-threaded but needs interior mutability). + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::time::Duration; + + pub type Cache = mini_moka_wasm::sync::Cache; + + pub fn new_cache(max_capacity: u64, ttl: Duration) -> Cache + where + K: std::hash::Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + { + mini_moka_wasm::sync::Cache::builder() + .max_capacity(max_capacity) + .time_to_live(ttl) + .build() + } + + pub fn get(cache: &Cache, key: &K) -> Option + where + K: std::hash::Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + { + cache.get(key) + } + + pub fn insert(cache: &Cache, key: K, value: V) + where + K: std::hash::Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + { + cache.insert(key, value); + } + + #[allow(dead_code)] + pub fn iter(cache: &Cache) -> Vec + where + K: std::hash::Hash + Eq + Send + Sync + 'static, + V: Clone + Send + Sync + 'static, + { + cache.iter().map(|entry| entry.value().clone()).collect() + } +} + +#[cfg(target_arch = "wasm32")] +mod wasm { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + pub type Cache = Arc>>; + + pub fn new_cache(max_capacity: u64, ttl: Duration) -> Cache + where + K: std::hash::Hash + Eq + 'static, + V: Clone + 'static, + { + Arc::new(Mutex::new( + mini_moka_wasm::unsync::Cache::builder() + .max_capacity(max_capacity) + .time_to_live(ttl) + .build(), + )) + } + + pub fn get(cache: &Cache, key: &K) -> Option + where + K: std::hash::Hash + Eq + 'static, + V: Clone + 'static, + { + cache.lock().unwrap().get(key).cloned() + } + + pub fn insert(cache: &Cache, key: K, value: V) + where + K: std::hash::Hash + Eq + 'static, + V: Clone + 'static, + { + cache.lock().unwrap().insert(key, value); + } + + #[allow(dead_code)] + pub fn iter(cache: &Cache) -> Vec + where + K: std::hash::Hash + Eq + 'static, + V: Clone + 'static, + { + cache + .lock() + .unwrap() + .iter() + .map(|(_, v)| v.clone()) + .collect() + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub use native::*; + +#[cfg(target_arch = "wasm32")] +pub use wasm::*; + +/// Create a new cache with the given capacity and TTL. +/// +/// This is a convenience re-export of `new_cache` for documentation purposes. +/// The actual implementation is platform-specific. +/// +/// # Example +/// +/// ```ignore +/// use weaver_common::cache; +/// use std::time::Duration; +/// +/// let cache = cache::new_cache::(100, Duration::from_secs(3600)); +/// cache::insert(&cache, "key".to_string(), "value".to_string()); +/// assert_eq!(cache::get(&cache, &"key".to_string()), Some("value".to_string())); +/// ``` +#[doc(hidden)] +pub fn _doc_example() {} diff --git a/crates/weaver-common/src/lib.rs b/crates/weaver-common/src/lib.rs index 831c378..357597a 100644 --- a/crates/weaver-common/src/lib.rs +++ b/crates/weaver-common/src/lib.rs @@ -1,8 +1,12 @@ //! Weaver common library - thin wrapper around jacquard with notebook-specific conveniences pub mod agent; +#[cfg(feature = "cache")] +pub mod cache; pub mod constellation; pub mod error; +#[cfg(feature = "perf")] +pub mod perf; pub mod resolve; #[cfg(feature = "telemetry")] pub mod telemetry; diff --git a/crates/weaver-common/src/perf.rs b/crates/weaver-common/src/perf.rs new file mode 100644 index 0000000..e0c1d73 --- /dev/null +++ b/crates/weaver-common/src/perf.rs @@ -0,0 +1,61 @@ +//! Performance timing utilities for instrumentation. +//! +//! Provides a cross-platform wrapper around Performance.now() for WASM +//! and a fallback for native builds. + +/// Get the current high-resolution timestamp in milliseconds. +/// +/// On WASM, this uses `Performance.now()` from the Web Performance API. +/// On native builds, uses std::time::Instant for actual timing. +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub fn now() -> f64 { + web_sys::window() + .and_then(|w| w.performance()) + .map(|p| p.now()) + .unwrap_or(0.0) +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +pub fn now() -> f64 { + use std::time::Instant; + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + let start = START.get_or_init(Instant::now); + start.elapsed().as_secs_f64() * 1000.0 +} + +/// Measure the execution time of a closure and log it. +/// +/// Returns the closure's result and logs the elapsed time via tracing. +#[allow(dead_code)] +pub fn measure T>(label: &str, f: F) -> T { + let start = now(); + let result = f(); + let elapsed = now() - start; + tracing::debug!(elapsed_ms = elapsed, "{}", label); + result +} + +/// A guard that logs elapsed time when dropped. +/// +/// Useful for timing blocks of code without closures. +#[allow(dead_code)] +pub struct TimingGuard { + label: &'static str, + start: f64, +} + +impl TimingGuard { + pub fn new(label: &'static str) -> Self { + Self { + label, + start: now(), + } + } +} + +impl Drop for TimingGuard { + fn drop(&mut self) { + let elapsed = now() - self.start; + tracing::debug!(elapsed_ms = elapsed, "{}", self.label); + } +} diff --git a/crates/weaver-editor-browser/src/events.rs b/crates/weaver-editor-browser/src/events.rs index 2c99a5a..6a8f82b 100644 --- a/crates/weaver-editor-browser/src/events.rs +++ b/crates/weaver-editor-browser/src/events.rs @@ -254,6 +254,52 @@ pub async fn read_clipboard_text() -> Result, JsValue> { Ok(result.as_string()) } +/// Copy markdown as rendered HTML to clipboard. +/// +/// Renders the markdown to HTML and writes both text/html and text/plain +/// representations to the clipboard. +pub async fn copy_as_html(markdown: &str) -> Result<(), JsValue> { + use js_sys::{Array, Object, Reflect}; + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; + + // Render markdown to HTML using ClientWriter. + let parser = weaver_editor_core::markdown_weaver::Parser::new(markdown).into_offset_iter(); + let mut html = String::new(); + weaver_editor_core::weaver_renderer::atproto::ClientWriter::<_, _, ()>::new( + parser, &mut html, markdown, + ) + .run() + .map_err(|e| JsValue::from_str(&format!("render error: {e}")))?; + + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; + let clipboard = window.navigator().clipboard(); + + // Create blobs for both HTML and plain text. + let parts = Array::new(); + parts.push(&JsValue::from_str(&html)); + + let html_opts = BlobPropertyBag::new(); + html_opts.set_type("text/html"); + let html_blob = Blob::new_with_str_sequence_and_options(&parts, &html_opts)?; + + let text_opts = BlobPropertyBag::new(); + text_opts.set_type("text/plain"); + let text_blob = Blob::new_with_str_sequence_and_options(&parts, &text_opts)?; + + // Create ClipboardItem with both types. + let item_data = Object::new(); + Reflect::set(&item_data, &JsValue::from_str("text/html"), &html_blob)?; + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; + + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; + let items = Array::new(); + items.push(&clipboard_item); + + wasm_bindgen_futures::JsFuture::from(clipboard.write(&items)).await?; + tracing::info!("[COPY HTML] Success - {} bytes of HTML", html.len()); + Ok(()) +} + // === BeforeInput handler === use weaver_editor_core::{EditorAction, EditorDocument, execute_action}; diff --git a/crates/weaver-editor-browser/src/lib.rs b/crates/weaver-editor-browser/src/lib.rs index f8b50b2..921002c 100644 --- a/crates/weaver-editor-browser/src/lib.rs +++ b/crates/weaver-editor-browser/src/lib.rs @@ -37,7 +37,7 @@ pub use dom_sync::{ // Event handling pub use events::{ - BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event, + BeforeInputContext, BeforeInputResult, StaticRange, copy_as_html, get_data_from_event, get_input_type_from_event, get_target_range_from_event, handle_beforeinput, is_composing, parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type, }; diff --git a/crates/weaver-editor-core/src/lib.rs b/crates/weaver-editor-core/src/lib.rs index 088b096..f198360 100644 --- a/crates/weaver-editor-core/src/lib.rs +++ b/crates/weaver-editor-core/src/lib.rs @@ -57,3 +57,7 @@ pub use render_cache::{ CachedParagraph, IncrementalRenderResult, RenderCache, apply_delta, is_boundary_affecting, render_paragraphs_incremental, }; + +// Re-export dependencies needed by browser crate. +pub use markdown_weaver; +pub use weaver_renderer; diff --git a/crates/weaver-embed-worker/Cargo.toml b/crates/weaver-embed-worker/Cargo.toml new file mode 100644 index 0000000..6e93fdf --- /dev/null +++ b/crates/weaver-embed-worker/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "weaver-embed-worker" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Web worker for fetching and caching AT Protocol embeds" + +[features] +default = [] +use-index = [] + +[dependencies] +weaver-common = { path = "../weaver-common", features = ["cache", "perf"] } +weaver-renderer = { path = "../weaver-renderer" } +jacquard = { workspace = true } +serde = { workspace = true } +tracing = { workspace = true } + +[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] +gloo-worker = "0.5" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +console_error_panic_hook = "0.1" + +[[bin]] +name = "embed_worker" +path = "src/bin/embed_worker.rs" diff --git a/crates/weaver-embed-worker/src/bin/embed_worker.rs b/crates/weaver-embed-worker/src/bin/embed_worker.rs new file mode 100644 index 0000000..7453957 --- /dev/null +++ b/crates/weaver-embed-worker/src/bin/embed_worker.rs @@ -0,0 +1,19 @@ +//! Entry point for the embed web worker. +//! +//! This binary is compiled separately and loaded by the main app +//! to fetch and cache AT Protocol embeds off the main thread. + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +fn main() { + console_error_panic_hook::set_once(); + + use gloo_worker::Registrable; + use weaver_embed_worker::EmbedWorker; + + EmbedWorker::registrar().register(); +} + +#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] +fn main() { + eprintln!("This binary is only meant to run as a WASM web worker"); +} diff --git a/crates/weaver-embed-worker/src/lib.rs b/crates/weaver-embed-worker/src/lib.rs new file mode 100644 index 0000000..750ba6f --- /dev/null +++ b/crates/weaver-embed-worker/src/lib.rs @@ -0,0 +1,165 @@ +//! Web worker for fetching and caching AT Protocol embeds. +//! +//! This crate provides a web worker that fetches and renders AT Protocol +//! record embeds off the main thread, with TTL-based caching. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Input messages to the embed worker. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum EmbedWorkerInput { + /// Request embeds for a list of AT URIs. + /// Worker returns cached results immediately and fetches missing ones. + FetchEmbeds { + /// AT URIs to fetch (e.g., "at://did:plc:xxx/app.bsky.feed.post/yyy") + uris: Vec, + }, + /// Clear the cache (e.g., on session change). + ClearCache, +} + +/// Output messages from the embed worker. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum EmbedWorkerOutput { + /// Embed results (may be partial if some failed). + Embeds { + /// Successfully fetched/cached embeds: uri -> rendered HTML. + results: HashMap, + /// URIs that failed to fetch. + errors: HashMap, + /// Timing info in milliseconds. + fetch_ms: f64, + }, + /// Cache was cleared. + CacheCleared, +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +mod worker_impl { + use super::*; + use gloo_worker::{HandlerId, Worker, WorkerScope}; + use jacquard::IntoStatic; + use jacquard::client::UnauthenticatedSession; + use jacquard::identity::JacquardResolver; + use jacquard::prelude::*; + use jacquard::types::string::AtUri; + use std::time::Duration; + use weaver_common::cache; + + /// Embed worker with persistent cache. + pub struct EmbedWorker { + /// Cached rendered embeds with TTL and max capacity. + cache: cache::Cache, String>, + /// Unauthenticated session for public API calls. + session: UnauthenticatedSession, + } + + impl Worker for EmbedWorker { + type Message = (); + type Input = EmbedWorkerInput; + type Output = EmbedWorkerOutput; + + fn create(_scope: &WorkerScope) -> Self { + Self { + // Cache up to 500 embeds, TTL of 1 hour. + cache: cache::new_cache(500, Duration::from_secs(3600)), + session: UnauthenticatedSession::default(), + } + } + + fn update(&mut self, _scope: &WorkerScope, _msg: Self::Message) {} + + fn received(&mut self, scope: &WorkerScope, msg: Self::Input, id: HandlerId) { + match msg { + EmbedWorkerInput::FetchEmbeds { uris } => { + let mut results = HashMap::new(); + let mut errors = HashMap::new(); + let mut to_fetch = Vec::new(); + + // Parse URIs and check cache. + for uri_str in uris { + let at_uri = match AtUri::new_owned(uri_str.clone()) { + Ok(u) => u, + Err(e) => { + errors.insert(uri_str, format!("Invalid AT URI: {e}")); + continue; + } + }; + + if let Some(html) = cache::get(&self.cache, &at_uri) { + results.insert(uri_str, html); + } else { + to_fetch.push((uri_str, at_uri)); + } + } + + // If nothing to fetch, respond immediately. + if to_fetch.is_empty() { + scope.respond( + id, + EmbedWorkerOutput::Embeds { + results, + errors, + fetch_ms: 0.0, + }, + ); + return; + } + + // Fetch missing embeds asynchronously. + let session = self.session.clone(); + let worker_cache = self.cache.clone(); + let scope = scope.clone(); + + wasm_bindgen_futures::spawn_local(async move { + // Use weaver-index when use-index feature is enabled. + #[cfg(feature = "use-index")] + { + use jacquard::xrpc::XrpcClient; + use jacquard::url::Url; + if let Ok(url) = Url::parse("https://index.weaver.sh") { + session.set_base_uri(url).await; + } + } + + let fetch_start = weaver_common::perf::now(); + + for (uri_str, at_uri) in to_fetch { + match weaver_renderer::atproto::fetch_and_render(&at_uri, &session) + .await + { + Ok(html) => { + cache::insert(&worker_cache, at_uri, html.clone()); + results.insert(uri_str, html); + } + Err(e) => { + errors.insert(uri_str, format!("{:?}", e)); + } + } + } + + let fetch_ms = weaver_common::perf::now() - fetch_start; + scope.respond( + id, + EmbedWorkerOutput::Embeds { + results, + errors, + fetch_ms, + }, + ); + }); + } + + EmbedWorkerInput::ClearCache => { + // mini-moka doesn't have a clear method, so we just respond. + // The cache will naturally expire entries via TTL. + scope.respond(id, EmbedWorkerOutput::CacheCleared); + } + } + } + } +} + +#[cfg(all(target_family = "wasm", target_os = "unknown"))] +pub use worker_impl::EmbedWorker; diff --git a/docs/graph-data.json b/docs/graph-data.json index 0e03ea7..e39a06b 100644 --- a/docs/graph-data.json +++ b/docs/graph-data.json @@ -1528,6 +1528,83 @@ "created_at": "2026-01-06T12:54:14.487538586-05:00", "updated_at": "2026-01-06T12:54:14.487538586-05:00", "metadata_json": "{\"confidence\":95}" + }, + { + "id": 141, + "change_id": "2dee8fd9-9f20-40a5-8ca4-21c5da31b32a", + "node_type": "outcome", + "title": "Unified dom_position_to_text_offset - added tracing to browser crate, app now re-exports from browser. Fixed browser tests for new BeforeInputContext API. All 10 WASM tests pass.", + "description": null, + "status": "pending", + "created_at": "2026-01-06T13:16:55.759301790-05:00", + "updated_at": "2026-01-06T13:16:55.759301790-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 142, + "change_id": "bffbf307-e150-4c9f-a98a-9c5950bf6aab", + "node_type": "outcome", + "title": "Deduplicated input.rs - ListContext from core, write_clipboard_with_custom_type from browser. Loro-specific text helpers remain (different trait interface).", + "description": null, + "status": "pending", + "created_at": "2026-01-06T13:20:00.050902081-05:00", + "updated_at": "2026-01-06T13:20:00.050902081-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 143, + "change_id": "507d7c90-1370-4602-91c3-466d060eadb0", + "node_type": "action", + "title": "Migrate copy_as_html to browser crate", + "description": null, + "status": "pending", + "created_at": "2026-01-06T13:20:26.751565101-05:00", + "updated_at": "2026-01-06T13:20:26.751565101-05:00", + "metadata_json": "{\"confidence\":90,\"prompt\":\"User said: copy_as_html should also be migrated\"}" + }, + { + "id": 144, + "change_id": "2106143f-2b41-4132-a02e-0d5f106f55e7", + "node_type": "outcome", + "title": "copy_as_html migrated to browser crate - core now re-exports markdown_weaver and weaver_renderer", + "description": null, + "status": "pending", + "created_at": "2026-01-06T13:23:19.174527414-05:00", + "updated_at": "2026-01-06T13:23:19.174527414-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 145, + "change_id": "3c662499-c59d-4089-ab9d-052806e9ca12", + "node_type": "action", + "title": "Updated design doc to reflect worker split decision - collab worker to crdt, embed worker separate/stays in app", + "description": null, + "status": "pending", + "created_at": "2026-01-06T13:28:47.660377466-05:00", + "updated_at": "2026-01-06T13:28:47.660377466-05:00", + "metadata_json": "{\"confidence\":95}" + }, + { + "id": 146, + "change_id": "af7d7560-f461-4ba0-9d4d-495d044a6589", + "node_type": "decision", + "title": "Embed worker placement", + "description": null, + "status": "pending", + "created_at": "2026-01-06T13:29:10.272323210-05:00", + "updated_at": "2026-01-06T13:29:10.272323210-05:00", + "metadata_json": "{\"confidence\":100}" + }, + { + "id": 147, + "change_id": "ed95158b-4307-491b-80ca-711020d0cb00", + "node_type": "outcome", + "title": "Embed worker → weaver-embed-worker crate (decided)", + "description": null, + "status": "pending", + "created_at": "2026-01-06T13:29:10.289780508-05:00", + "updated_at": "2026-01-06T13:29:10.289780508-05:00", + "metadata_json": "{\"confidence\":100}" } ], "edges": [ @@ -3180,6 +3257,61 @@ "weight": 1.0, "rationale": "dedup effort", "created_at": "2026-01-06T12:54:14.531458670-05:00" + }, + { + "id": 152, + "from_node_id": 132, + "to_node_id": 142, + "from_change_id": "d70e9274-470a-42f8-b7db-890f9e231cd1", + "to_change_id": "bffbf307-e150-4c9f-a98a-9c5950bf6aab", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "input.rs deduplication outcome", + "created_at": "2026-01-06T13:20:26.610371926-05:00" + }, + { + "id": 153, + "from_node_id": 143, + "to_node_id": 144, + "from_change_id": "507d7c90-1370-4602-91c3-466d060eadb0", + "to_change_id": "2106143f-2b41-4132-a02e-0d5f106f55e7", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "migration completed", + "created_at": "2026-01-06T13:23:19.190956294-05:00" + }, + { + "id": 154, + "from_node_id": 127, + "to_node_id": 145, + "from_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", + "to_change_id": "3c662499-c59d-4089-ab9d-052806e9ca12", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Decision documented", + "created_at": "2026-01-06T13:28:47.676331192-05:00" + }, + { + "id": 155, + "from_node_id": 127, + "to_node_id": 146, + "from_change_id": "5aa17c55-afff-4d4a-8956-8fce165da24a", + "to_change_id": "af7d7560-f461-4ba0-9d4d-495d044a6589", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Final decision made", + "created_at": "2026-01-06T13:29:10.311767876-05:00" + }, + { + "id": 156, + "from_node_id": 146, + "to_node_id": 147, + "from_change_id": "af7d7560-f461-4ba0-9d4d-495d044a6589", + "to_change_id": "ed95158b-4307-491b-80ca-711020d0cb00", + "edge_type": "leads_to", + "weight": 1.0, + "rationale": "Decision outcome", + "created_at": "2026-01-06T13:29:10.392856789-05:00" } ] } \ No newline at end of file -- knotmirror 0.0.1