atproto blogging
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;