diff --git a/Cargo.lock b/Cargo.lock index 364149f..b464bb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2936,8 +2936,7 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +source = "git+https://github.com/webbeef/rust-url.git?branch=beaver#2bc19f7b8b57dd5dbab417cc4d050ebf8c0b6468" dependencies = [ "percent-encoding", ] @@ -5047,8 +5046,7 @@ checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" [[package]] name = "idna" version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +source = "git+https://github.com/webbeef/rust-url.git?branch=beaver#2bc19f7b8b57dd5dbab417cc4d050ebf8c0b6468" dependencies = [ "idna_adapter", "smallvec", @@ -7520,8 +7518,7 @@ dependencies = [ [[package]] name = "percent-encoding" version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +source = "git+https://github.com/webbeef/rust-url.git?branch=beaver#2bc19f7b8b57dd5dbab417cc4d050ebf8c0b6468" [[package]] name = "petgraph" @@ -12318,8 +12315,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +source = "git+https://github.com/webbeef/rust-url.git?branch=beaver#2bc19f7b8b57dd5dbab417cc4d050ebf8c0b6468" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index 606df0b..4f737d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -313,3 +313,9 @@ inherits = "release" debug = true lto = "thin" codegen-units = 1 + +[patch.crates-io] +url = { git = "https://github.com/webbeef/rust-url.git", branch = "beaver" } +form_urlencoded = { git = "https://github.com/webbeef/rust-url.git", branch = "beaver" } +percent-encoding = { git = "https://github.com/webbeef/rust-url.git", branch = "beaver" } +idna = { git = "https://github.com/webbeef/rust-url.git", branch = "beaver" } diff --git a/patches/components/net/http_loader.rs.patch b/patches/components/net/http_loader.rs.patch index dd1e878..126a3c8 100644 --- a/patches/components/net/http_loader.rs.patch +++ b/patches/components/net/http_loader.rs.patch @@ -31,16 +31,17 @@ }; use parking_lot::{Mutex, RwLock}; use profile_traits::mem::{Report, ReportKind}; -@@ -91,7 +93,7 @@ +@@ -91,7 +93,8 @@ use crate::http_cache::{ CacheKey, CachedResourcesOrGuard, HttpCache, construct_response, invalidate, refresh, }; -use crate::resource_thread::{AuthCache, AuthCacheEntry}; +use crate::resource_thread::AuthCache; ++use crate::web_tiles::WebTile; use crate::websocket_loader::start_websocket; /// The various states an entry of the HttpCache can be in. -@@ -106,6 +108,7 @@ +@@ -106,6 +109,7 @@ } pub struct HttpState { @@ -48,11 +49,14 @@ pub hsts_list: RwLock, pub cookie_jar: RwLock, pub http_cache: HttpCache, -@@ -114,9 +117,25 @@ +@@ -114,9 +118,28 @@ pub client: ServoClient, pub override_manager: CertificateErrorOverrideManager, pub embedder_proxy: GenericEmbedderProxy, + pub atproto_session: RwLock>, ++ /// The key to identify a Web Tile is (subject, rkey) of the ++ /// at: uri used to fetch the ing.dasl.masl record. ++ pub web_tiles: RwLock>, } impl HttpState { @@ -74,7 +78,36 @@ pub(crate) fn memory_reports(&self, suffix: &str, ops: &mut MallocSizeOfOps) -> Vec { vec![ Report { -@@ -577,14 +596,40 @@ +@@ -156,6 +179,28 @@ + )); + receiver.await.ok()? + } ++ ++ pub fn register_webtile(&self, subject: &str, rkey: &str, json: &[u8]) { ++ if let Some(tile) = WebTile::from_tile_manifest(subject, rkey, json) { ++ if let Some(config_dir) = &self.config_dir { ++ tile.write_to(&config_dir); ++ } ++ println!( ++ "Registered tile, url will be tile://{}.{}/", ++ tile.rkey, tile.subject ++ ); ++ self.web_tiles ++ .write() ++ .insert((subject.into(), rkey.into()), tile); ++ } ++ } ++ ++ pub fn get_webtile(&self, subject: &str, rkey: &str) -> Option { ++ self.web_tiles ++ .read() ++ .get(&(subject.into(), rkey.into())) ++ .cloned() ++ } + } + + /// Step 11 of . +@@ -577,14 +622,40 @@ } } @@ -119,7 +152,7 @@ } else { None } -@@ -1645,15 +1690,15 @@ +@@ -1645,15 +1716,15 @@ authorization_value.is_none() && has_credentials(¤t_url) { @@ -138,7 +171,7 @@ } } } -@@ -1858,7 +1903,7 @@ +@@ -1858,7 +1929,7 @@ }; // Store the credentials as a proxy-authentication entry. @@ -147,7 +180,7 @@ user_name: credentials.username, password: credentials.password, }; -@@ -1865,7 +1910,7 @@ +@@ -1865,7 +1936,7 @@ { let mut auth_cache = context.state.auth_cache.write(); let key = request.current_url().origin().ascii_serialization(); diff --git a/patches/components/net/lib.rs.patch b/patches/components/net/lib.rs.patch index 738afce..38c1a7b 100644 --- a/patches/components/net/lib.rs.patch +++ b/patches/components/net/lib.rs.patch @@ -1,8 +1,10 @@ --- original +++ modified -@@ -24,6 +24,11 @@ +@@ -23,7 +23,13 @@ + pub mod subresource_integrity; #[cfg(feature = "test-util")] pub mod test_util; ++mod web_tiles; mod websocket_loader; +pub mod atproto { + pub mod pds; diff --git a/patches/components/net/protocols/atproto/protocol.rs.patch b/patches/components/net/protocols/atproto/protocol.rs.patch index 7a9183b..6a1c3b5 100644 --- a/patches/components/net/protocols/atproto/protocol.rs.patch +++ b/patches/components/net/protocols/atproto/protocol.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,210 @@ +@@ -0,0 +1,235 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::future::{self, Future}; @@ -13,10 +13,10 @@ +use content_security_policy::percent_encoding::percent_decode_str; +use http::header::CONTENT_TYPE; +use http::{Method, StatusCode}; -+use log::info; ++use log::{error, info}; +use net_traits::fetch::utils::http_response; +use net_traits::request::Request; -+use net_traits::response::Response; ++use net_traits::response::{Response, ResponseBody}; +use servo_url::ServoUrl; +use sync_wrapper::SyncWrapper; + @@ -131,21 +131,46 @@ + let client = XrpcClient::new(endpoint_url, url.clone(), &document.id, context2.clone()); + + if method == Method::GET { -+ let (response, reason) = match (collection, rkey) { ++ let (response, reason, is_web_tile) = match (collection, rkey) { + (None, _) => { + // If we have no collection, send a com.atproto.repo.describeRepo request. -+ (client.describe_repo().await, "Failed to describe repo") ++ ( ++ client.describe_repo().await, ++ "Failed to describe repo", ++ false, ++ ) + }, + (Some(coll), None) => { + // If we have a collection but no rkey, send a com.atproto.repo.listRecords request. -+ (client.list_records(coll).await, "Failed to list records") ++ ( ++ client.list_records(coll).await, ++ "Failed to list records", ++ false, ++ ) + }, + (Some(coll), Some(rkey)) => { + // Both collection and rkey are present, send a com.atproto.repo.getRecord request. -+ (client.get_record(coll, rkey).await, "Failed to get record") ++ ( ++ client.get_record(coll, rkey).await, ++ "Failed to get record", ++ coll == "ing.dasl.masl", ++ ) + }, + }; -+ maybe_bad_request(&url, reason, response) ++ let response = maybe_bad_request(&url, reason, response); ++ if is_web_tile { ++ if response.status == StatusCode::OK { ++ let body = response.body.lock(); ++ if let ResponseBody::Done(ref json) = *body { ++ context2 ++ .state ++ .register_webtile(&subject, &rkey.unwrap(), &json); ++ } else { ++ error!("Unable to get webtile manifest!"); ++ } ++ } ++ } ++ response + } else if method == Method::POST { + let request = request.get_mut(); + diff --git a/patches/components/net/protocols/mod.rs.patch b/patches/components/net/protocols/mod.rs.patch index a0886c0..4f2c640 100644 --- a/patches/components/net/protocols/mod.rs.patch +++ b/patches/components/net/protocols/mod.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -21,13 +21,17 @@ +@@ -21,13 +21,19 @@ use crate::fetch::methods::{DoneChannel, FetchContext, RangeRequestBounds, fetch}; @@ -9,16 +9,18 @@ mod data; mod file; +mod trusted; ++mod web_tiles; +use atproto::protocol::AtProtocolHandler; use blob::BlobProtocolHander; use data::DataProtocolHander; use file::FileProtocolHander; +use trusted::protocol::TrustedProtocolHandler; ++use web_tiles::WebTileProtocolHandler; type FutureResponse<'a> = Pin + Send + 'a>>; -@@ -97,6 +101,12 @@ +@@ -97,6 +103,15 @@ .register("file", FileProtocolHander::default()) .expect("Infallible"); registry @@ -27,6 +29,9 @@ + registry + .register("at", AtProtocolHandler::default()) + .expect("Infallible"); ++ registry ++ .register("tile", WebTileProtocolHandler::default()) ++ .expect("Infallible"); + registry } diff --git a/patches/components/net/protocols/web_tiles.rs.patch b/patches/components/net/protocols/web_tiles.rs.patch new file mode 100644 index 0000000..f3e634e --- /dev/null +++ b/patches/components/net/protocols/web_tiles.rs.patch @@ -0,0 +1,164 @@ +--- original ++++ modified +@@ -0,0 +1,161 @@ ++// SPDX-License-Identifier: AGPL-3.0-or-later ++ ++/// Implementation of the tile:// protocol handler. ++/// Example: tile://bafyreifgp34h3kkv5mohjovxkhj5di2pii3ldmqc6xdw7spsa2wbstybwi/icon.png ++/// ++use std::future::{self, Future}; ++use std::num::NonZeroUsize; ++use std::pin::Pin; ++use std::sync::Arc; ++ ++use atproto_identity::resolve::HickoryDnsResolver; ++use atproto_identity::storage_lru::LruDidDocumentStorage; ++use content_security_policy::percent_encoding::percent_decode_str; ++use http::header::{CONTENT_DISPOSITION, CONTENT_SECURITY_POLICY, CONTENT_TYPE, HeaderValue}; ++use http::{Method, StatusCode}; ++use log::error; ++use net_traits::fetch::utils::http_response; ++use net_traits::request::Request; ++use net_traits::response::Response; ++use servo_url::ServoUrl; ++ ++use crate::atproto::pds::get_endpoint_for_subject; ++use crate::atproto::xrpc::XrpcClient; ++use crate::fetch::methods::{DoneChannel, FetchContext}; ++use crate::protocols::ProtocolHandler; ++ ++const WEB_TILES_CSP: &str = r#"default-src 'self' blob: data:; ++ script-src 'self' blob: data: 'unsafe-inline' 'wasm-unsafe-eval'; ++ style-src 'self' blob: data: 'unsafe-inline'; ++ script-src-attr 'none'; ++ form-src 'self'; ++ manifest-src 'none'; ++ object-src 'none'; ++ base-uri 'none'; ++ sandbox allow-downloads ++ allow-forms ++ allow-modals ++ allow-popups ++ allow-popups-to-escape-sandbox ++ allow-same-origin ++ allow-scripts"#; ++ ++pub struct WebTileProtocolHandler { ++ // TODO: maybe share with the AtProtocolHandler ++ document_storage: LruDidDocumentStorage, ++ dns_resolver: Arc, ++} ++ ++impl Default for WebTileProtocolHandler { ++ fn default() -> Self { ++ Self { ++ document_storage: LruDidDocumentStorage::new(NonZeroUsize::new(1000).unwrap()), ++ dns_resolver: Arc::new(HickoryDnsResolver::create_resolver(Default::default())), ++ } ++ } ++} ++ ++fn maybe_bad_request(url: &ServoUrl, reason: &str, response: Result) -> Response { ++ response.unwrap_or_else(|_| http_response(url.clone(), StatusCode::BAD_REQUEST, reason)) ++} ++ ++impl ProtocolHandler for WebTileProtocolHandler { ++ fn load( ++ &self, ++ request: &mut Request, ++ _done_chan: &mut DoneChannel, ++ context: &FetchContext, ++ ) -> Pin + Send>> { ++ let url = request.current_url(); ++ ++ let method = request.method.clone(); ++ if method != Method::GET { ++ return Box::pin(future::ready(http_response( ++ url, ++ StatusCode::BAD_REQUEST, ++ "Invalid method", ++ ))); ++ } ++ ++ let context2 = context.clone(); ++ let document_storage = self.document_storage.clone(); ++ let dns_resolver = Arc::clone(&self.dns_resolver); ++ Box::pin(async move { ++ // Get the host and the subject from the registered tiles ++ let Some(host) = url.host_str() else { ++ return http_response(url, StatusCode::BAD_REQUEST, "No host"); ++ }; ++ ++ let Ok(rkey_subject) = percent_decode_str(host).decode_utf8() else { ++ return http_response(url, StatusCode::BAD_REQUEST, "Host decoding error"); ++ }; ++ ++ // Split the rkey.subject string. ++ let Some((rkey, subject)) = rkey_subject.split_once('.') else { ++ return http_response(url, StatusCode::BAD_REQUEST, "Invalid rkey.subject format"); ++ }; ++ ++ let Some(tile) = context2.state.get_webtile(subject, rkey) else { ++ return http_response(url, StatusCode::BAD_REQUEST, "No such tile registered"); ++ }; ++ ++ let Ok((endpoint_url, document)) = ++ get_endpoint_for_subject(&tile.subject, Some(document_storage), Some(dns_resolver)) ++ .await ++ else { ++ return http_response( ++ url, ++ StatusCode::BAD_REQUEST, ++ "Failed to resolve endpoint for subject", ++ ); ++ }; ++ ++ let Some(resource) = tile.resources.get(url.path()) else { ++ return http_response(url, StatusCode::NOT_FOUND, "Resource not found"); ++ }; ++ ++ // Fetch the blob for (subject, resource.link) ++ let client = XrpcClient::new(endpoint_url, url.clone(), &document.id, context2.clone()); ++ let response = client ++ .get_record("com.atproto.sync.blob", &resource.link) ++ .await; ++ let mut response = maybe_bad_request(&url, "not found", response) ++ .internal_response ++ .unwrap(); ++ ++ // Adjust the headers of the internal response. ++ if response.status == StatusCode::OK { ++ let headers = &mut response.headers; ++ ++ // Remove the "attachment" content disposition. ++ headers.remove(CONTENT_DISPOSITION); ++ ++ // Substitute application/octet-stream with the resource's real content type. ++ if let Ok(header_value) = HeaderValue::from_str(&resource.content_type) { ++ headers.remove(CONTENT_TYPE); ++ headers.insert(CONTENT_TYPE, header_value); ++ } ++ ++ // Set the proper CSP. ++ headers.remove(CONTENT_SECURITY_POLICY); ++ let csp = WEB_TILES_CSP.replace("\n", ""); ++ match HeaderValue::from_str(&csp) { ++ Ok(csp) => { ++ let _ = headers.insert(CONTENT_SECURITY_POLICY, csp); ++ }, ++ Err(err) => error!("Invalid CSP: {err}"), ++ } ++ } ++ ++ *response ++ }) ++ } ++ ++ fn is_fetchable(&self) -> bool { ++ true ++ } ++ ++ fn is_secure(&self) -> bool { ++ true ++ } ++} diff --git a/patches/components/net/resource_thread.rs.patch b/patches/components/net/resource_thread.rs.patch index 521f92b..0e4dea1 100644 --- a/patches/components/net/resource_thread.rs.patch +++ b/patches/components/net/resource_thread.rs.patch @@ -15,16 +15,26 @@ }; use parking_lot::{Mutex, RwLock}; use profile_traits::mem::{ -@@ -199,14 +199,17 @@ +@@ -70,6 +70,7 @@ + use crate::http_loader::{HttpState, http_redirect_fetch}; + use crate::protocols::ProtocolRegistry; + use crate::request_interceptor::RequestInterceptor; ++use crate::web_tiles::WebTiles; + use crate::websocket_loader::create_handshake_request; + + /// Load a file with CA certificate and produce a RootCertStore with the results. +@@ -199,14 +200,19 @@ let mut hsts_list = HstsList::default(); let mut auth_cache = AuthCache::default(); let mut cookie_jar = CookieStorage::new(150); + let mut atproto_session = None; ++ let mut web_tiles = Default::default(); if let Some(config_dir) = config_dir { servo_base::read_json_from_file(&mut auth_cache, config_dir, "auth_cache.json"); servo_base::read_json_from_file(&mut hsts_list, config_dir, "hsts_list.json"); servo_base::read_json_from_file(&mut cookie_jar, config_dir, "cookie_jar.json"); + atproto_session = AtProtoSessionState::load(config_dir); ++ web_tiles = WebTiles::load(config_dir); } let override_manager = CertificateErrorOverrideManager::new(); @@ -33,11 +43,12 @@ hsts_list: RwLock::new(hsts_list), cookie_jar: RwLock::new(cookie_jar), auth_cache: RwLock::new(auth_cache), -@@ -219,10 +222,12 @@ +@@ -219,10 +225,13 @@ )), override_manager, embedder_proxy: embedder_proxy.clone(), + atproto_session: RwLock::new(atproto_session), ++ web_tiles: RwLock::new(web_tiles), }; let override_manager = CertificateErrorOverrideManager::new(); @@ -46,15 +57,16 @@ hsts_list: RwLock::new(HstsList::default()), cookie_jar: RwLock::new(CookieStorage::new(150)), auth_cache: RwLock::new(AuthCache::default()), -@@ -235,6 +240,7 @@ +@@ -235,6 +244,8 @@ )), override_manager, embedder_proxy, + atproto_session: RwLock::new(None), ++ web_tiles: Default::default(), }; (Arc::new(http_state), Arc::new(private_http_state)) -@@ -592,17 +598,18 @@ +@@ -592,17 +603,18 @@ }, // Ignore this message as we handle it only in the reporter chan CoreResourceMsg::CollectMemoryReport(_) => {}, diff --git a/patches/components/net/web_tiles.rs.patch b/patches/components/net/web_tiles.rs.patch new file mode 100644 index 0000000..1801345 --- /dev/null +++ b/patches/components/net/web_tiles.rs.patch @@ -0,0 +1,129 @@ +--- original ++++ modified +@@ -0,0 +1,126 @@ ++// SPDX-License-Identifier: AGPL-3.0-or-later ++ ++use std::fs::{self, File}; ++use std::path::Path; ++ ++use log::error; ++use rustc_hash::FxHashMap; ++use serde::{Deserialize, Serialize}; ++use serde_json::Value; ++use sha2::{Digest, Sha256}; ++ ++#[derive(Clone, Default, Deserialize, Serialize)] ++pub struct TileResource { ++ pub link: String, ++ pub content_type: String, ++} ++ ++#[derive(Clone, Default, Deserialize, Serialize)] ++pub struct WebTile { ++ pub subject: String, ++ pub rkey: String, ++ pub resources: FxHashMap, ++} ++ ++impl WebTile { ++ pub fn from_tile_manifest(subject: &str, rkey: &str, json: &[u8]) -> Option { ++ let Ok(root) = serde_json::from_slice::(json) else { ++ return None; ++ }; ++ ++ let Value::Object(ref value) = root["value"] else { ++ return None; ++ }; ++ ++ let Value::Object(ref tile) = value["tile"] else { ++ return None; ++ }; ++ ++ let Value::Object(ref doc_resources) = tile["resources"] else { ++ return None; ++ }; ++ ++ let mut resources: FxHashMap = Default::default(); ++ ++ for (path, resource) in doc_resources.iter() { ++ let Value::String(ref content_type) = resource["content-type"] else { ++ continue; ++ }; ++ ++ let Value::Object(ref src) = resource["src"] else { ++ continue; ++ }; ++ ++ let Value::Object(ref tref) = src["ref"] else { ++ continue; ++ }; ++ ++ let Value::String(ref link) = tref["$link"] else { ++ continue; ++ }; ++ ++ resources.insert( ++ path.into(), ++ TileResource { ++ link: link.to_string(), ++ content_type: content_type.to_string(), ++ }, ++ ); ++ } ++ ++ Some(Self { ++ subject: subject.into(), ++ rkey: rkey.into(), ++ resources, ++ }) ++ } ++ ++ /// Write to config_dir/web_tiles/tile_cid.json ++ /// TODO: error management ++ pub fn write_to(&self, config_dir: &Path) { ++ let path = config_dir.join("web_tiles"); ++ let _ = fs::create_dir_all(&path); ++ ++ let mut hasher = Sha256::new(); ++ hasher.update(self.subject.as_bytes()); ++ hasher.update(self.rkey.as_bytes()); ++ let result = hasher.finalize(); ++ ++ let path = path.join(format!("{result:x}.json")); ++ let file = File::create(path).unwrap(); ++ let _ = serde_json::to_writer(file, self); ++ } ++} ++ ++pub struct WebTiles {} ++ ++impl WebTiles { ++ /// Returns a map of tile cid -> WebTile ++ pub fn load(config_dir: &Path) -> FxHashMap<(String, String), WebTile> { ++ let mut result = FxHashMap::default(); ++ let dir = config_dir.join("web_tiles"); ++ ++ let Ok(entries) = fs::read_dir(&dir) else { ++ error!("Failed to read in {}", dir.display()); ++ return Default::default(); ++ }; ++ ++ for entry in entries { ++ let Ok(entry) = entry else { ++ continue; ++ }; ++ let path = entry.path(); ++ if !path.is_dir() { ++ let Ok(file) = File::open(path) else { ++ continue; ++ }; ++ if let Ok(tile) = serde_json::from_reader::(file) { ++ println!("============ Loading tile {} {}", tile.subject, tile.rkey); ++ result.insert((tile.subject.clone(), tile.rkey.clone()), tile); ++ } ++ } ++ } ++ ++ result ++ } ++} diff --git a/patches/components/url/lib.rs.patch b/patches/components/url/lib.rs.patch deleted file mode 100644 index 2e5c0e7..0000000 --- a/patches/components/url/lib.rs.patch +++ /dev/null @@ -1,22 +0,0 @@ ---- original -+++ modified -@@ -88,6 +88,19 @@ - } - - pub fn origin(&self) -> ImmutableOrigin { -+ // We want trusted:// urls to have a tuple origin similar to https:// ones. -+ // TODO: maybe fork the url crate instead... -+ if self.0.scheme() == "trusted" { -+ use url::Origin; -+ -+ let url_str = self.as_str(); -+ let new_url = Url::parse(&url_str.replace("trusted://", "https://")).unwrap(); -+ let origin = match new_url.origin() { -+ Origin::Tuple(_scheme, host, port) => Origin::Tuple("trusted".into(), host, port), -+ Origin::Opaque(val) => Origin::Opaque(val), -+ }; -+ return ImmutableOrigin::new(origin); -+ } - ImmutableOrigin::new(self.0.origin()) - } - diff --git a/ui/shared/lazy_loader.js b/ui/shared/lazy_loader.js new file mode 100644 index 0000000..db03a92 --- /dev/null +++ b/ui/shared/lazy_loader.js @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Cache for loaded resources - stores promises immediately to handle concurrent loads +const cache = new Map(); + +// Core loader functions +const loaders = { + module: (url) => import(url), + + script: (url) => { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = url; + script.onload = () => resolve(url); + script.onerror = () => reject(new Error(`Failed to load script: ${url}`)); + document.head.appendChild(script); + }); + }, + + css: (url) => { + return new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + link.onload = () => resolve(url); + link.onerror = () => reject(new Error(`Failed to load CSS: ${url}`)); + document.head.appendChild(link); + }); + }, + + json: async (url) => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch JSON: ${url} (${response.status})`); + } + return response.json(); + } catch (e) { + console.error(`LazyLoader.json failed for ${url}:`, e); + throw e; + } + }, + + text: async (url) => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch text: ${url} (${response.status})`); + } + return response.text(); + } catch (e) { + console.error(`LazyLoader.text failed for ${url}:`, e); + throw e; + } + }, +}; + +// Parse descriptor: { module: "url" } -> ["module", "url"] +function parseDescriptor(descriptor) { + const entries = Object.entries(descriptor); + if (entries.length !== 1) { + throw new Error("Descriptor must have exactly one key"); + } + return entries[0]; +} + +// Cached loader - stores promise immediately to handle concurrent loads +function cachedLoad(type, url) { + const key = `${type}:${url}`; + if (!cache.has(key)) { + // Store promise immediately, before it resolves + const promise = loaders[type](url); + cache.set(key, promise); + } + return cache.get(key); +} + +// Load from descriptor +function loadDescriptor(descriptor) { + const [type, url] = parseDescriptor(descriptor); + return cachedLoad(type, url); +} + +// Builder class for chained API +class LoaderChain { + constructor() { + this.steps = []; + } + + load(descriptorOrArray) { + this.steps.push(descriptorOrArray); + return this; + } + + then(descriptorOrArray) { + return this.load(descriptorOrArray); + } + + async all() { + const results = []; + for (const step of this.steps) { + if (Array.isArray(step)) { + // Parallel: array of descriptors + const parallel = await Promise.all(step.map(loadDescriptor)); + results.push(parallel); + } else { + // Single descriptor + results.push(await loadDescriptor(step)); + } + } + return results; + } +} + +// Manifest resolver +class ManifestLoader { + constructor(manifest) { + this.manifest = manifest.dependencies || manifest; + } + + async resolve(name) { + const dep = this.manifest[name]; + if (!dep) throw new Error(`Unknown dependency: ${name}`); + + if (dep.type === "group") { + if (dep.parallel) { + return Promise.all(dep.parallel.map((n) => this.resolve(n))); + } else if (dep.sequence) { + const results = []; + for (const n of dep.sequence) { + results.push(await this.resolve(n)); + } + return results; + } + } else { + return cachedLoad(dep.type, dep.url); + } + } +} + +// Public API +export const LazyLoader = { + // Direct loaders + module: (url) => cachedLoad("module", url), + script: (url) => cachedLoad("script", url), + css: (url) => cachedLoad("css", url), + json: (url) => cachedLoad("json", url), + text: (url) => cachedLoad("text", url), + + // Unified load API - single descriptor or array for parallel + load: (descriptorOrArray) => { + if (Array.isArray(descriptorOrArray)) { + return Promise.all(descriptorOrArray.map(loadDescriptor)); + } + // Return a chain for potential .then() calls + return new LoaderChain().load(descriptorOrArray); + }, + + // Manifest API + fromManifest: (manifest) => new ManifestLoader(manifest), + + // Clear cache (for testing/hot reload) + clearCache: () => cache.clear(), +}; diff --git a/ui/shared/search/utils.js b/ui/shared/search/utils.js index 1bbb84f..96bff15 100644 --- a/ui/shared/search/utils.js +++ b/ui/shared/search/utils.js @@ -26,6 +26,7 @@ export function normalizeUrl(url) { if ( !url.startsWith("about:") && !url.startsWith("at:") && + !url.startsWith("tile:") && !url.startsWith("data:") && !url.startsWith("file://") && !url.startsWith("http://") &&