diff --git a/crates/browser/src/cache_storage.rs b/crates/browser/src/cache_storage.rs new file mode 100644 index 0000000..05f25cd --- /dev/null +++ b/crates/browser/src/cache_storage.rs @@ -0,0 +1,306 @@ +//! Origin-keyed persistence for the Service Worker Cache API. +//! +//! Stores each origin's [`CacheStorage`] under `~/.we/caches/`. The binary +//! format is owned by [`we_js::cache::CacheStorage`]. + +use std::fs; +use std::path::PathBuf; +use we_js::cache::CacheStorage; +use we_url::Origin; + +/// Whether caches are persisted across sessions or partitioned to a private +/// (incognito) session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoragePartition { + Persistent, + Private(String), +} + +/// Per-origin file persistence for [`CacheStorage`], with an optional byte +/// quota that triggers eviction of oldest entries before saving. +pub struct CacheManager { + storage_dir: PathBuf, + /// Per-origin quota in bytes for the serialized storage. `None` disables + /// quota enforcement. + quota_bytes: Option, +} + +impl Default for CacheManager { + fn default() -> Self { + Self::new() + } +} + +impl CacheManager { + pub fn new() -> Self { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + Self::with_partition( + PathBuf::from(home).join(".we"), + StoragePartition::Persistent, + ) + } + + pub fn with_partition(base_dir: PathBuf, partition: StoragePartition) -> Self { + let storage_dir = match partition { + StoragePartition::Persistent => base_dir.join("caches"), + StoragePartition::Private(session_id) => base_dir + .join("caches-private") + .join(safe_path_component(&session_id)), + }; + Self { + storage_dir, + quota_bytes: None, + } + } + + pub fn with_dir(dir: PathBuf) -> Self { + Self { + storage_dir: dir, + quota_bytes: None, + } + } + + /// Cap per-origin serialized storage at `bytes`. When saving exceeds this + /// limit, oldest entries are evicted until it fits. + pub fn with_quota(mut self, bytes: usize) -> Self { + self.quota_bytes = Some(bytes); + self + } + + fn file_path(&self, origin: &Origin) -> Option { + match origin { + Origin::Opaque => None, + Origin::Tuple(..) => { + let serialized = origin.serialize(); + Some(self.storage_dir.join(safe_path_component(&serialized))) + } + } + } + + /// Load this origin's caches from disk. Returns an empty store on missing + /// or corrupt data. + pub fn load(&self, origin: &Origin) -> CacheStorage { + let path = match self.file_path(origin) { + Some(p) => p, + None => return CacheStorage::new(), + }; + match fs::read(path) { + Ok(data) => CacheStorage::deserialize(&data).unwrap_or_default(), + Err(_) => CacheStorage::new(), + } + } + + /// Persist this origin's caches to disk. Evicts oldest entries to fit + /// the configured quota before serializing. + pub fn save(&self, origin: &Origin, storage: &CacheStorage) { + let path = match self.file_path(origin) { + Some(p) => p, + None => return, + }; + let mut owned = storage.clone(); + if let Some(quota) = self.quota_bytes { + evict_to_quota(&mut owned, origin, quota); + } + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let tmp_path = path.with_extension("tmp"); + if fs::write(&tmp_path, owned.serialize()).is_ok() { + let _ = fs::rename(tmp_path, path); + } + } + + /// Remove every cache for `origin`. Returns true if a file was deleted. + pub fn clear_origin(&self, origin: &Origin) -> bool { + let path = match self.file_path(origin) { + Some(p) => p, + None => return false, + }; + match fs::remove_file(path) { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => false, + } + } + + /// Remove every persisted cache across all origins. Returns true if the + /// directory existed. + pub fn clear_all(&self) -> bool { + match fs::remove_dir_all(&self.storage_dir) { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => false, + } + } +} + +/// Drop oldest entries (and then oldest caches) for `origin` until the +/// serialized storage fits within `quota`. +fn evict_to_quota(storage: &mut CacheStorage, origin: &Origin, quota: usize) { + while storage.serialize().len() > quota { + if !storage.evict_oldest_entry(origin) { + break; + } + } +} + +fn safe_path_component(input: &str) -> String { + input + .chars() + .map(|c| match c { + '/' | ':' | '?' | '#' | '\\' | '*' | '"' | '<' | '>' | '|' => '_', + c => c, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use we_js::cache::{CacheRequest, CacheResponse}; + use we_url::Url; + + fn cleanup(dir: &PathBuf) { + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn round_trip_persists_origin_cache() { + let dir = std::env::temp_dir().join("we_cache_roundtrip"); + cleanup(&dir); + let mgr = CacheManager::with_dir(dir.clone()); + let base = Url::parse("https://example.com/app/page.html").unwrap(); + let origin = base.origin(); + + let mut storage = CacheStorage::new(); + let cache = storage.open(&origin, "v1"); + let request = CacheRequest::get(&Url::parse("https://example.com/a").unwrap()); + let response = CacheResponse::ok_with_body("text/plain", b"hello".to_vec()); + cache.put(request, response).unwrap(); + mgr.save(&origin, &storage); + + let loaded = mgr.load(&origin); + assert!(loaded.has(&origin, "v1")); + let cache = loaded.get(&origin, "v1").unwrap(); + let stored: Vec<_> = cache.entries().collect(); + assert_eq!(stored[0].1.body, b"hello"); + + cleanup(&dir); + } + + #[test] + fn opaque_origin_is_not_persisted() { + let dir = std::env::temp_dir().join("we_cache_opaque"); + cleanup(&dir); + let mgr = CacheManager::with_dir(dir.clone()); + let mut storage = CacheStorage::new(); + // Opaque origin: save is a no-op, load returns empty. + let _ = storage.open(&Origin::Opaque, "v1"); + mgr.save(&Origin::Opaque, &storage); + assert!(mgr.load(&Origin::Opaque).is_empty()); + cleanup(&dir); + } + + #[test] + fn private_partition_is_isolated_from_persistent_storage() { + let dir = std::env::temp_dir().join("we_cache_private_partition"); + cleanup(&dir); + let persistent = CacheManager::with_partition(dir.clone(), StoragePartition::Persistent); + let private = CacheManager::with_partition( + dir.clone(), + StoragePartition::Private("session:one".to_string()), + ); + let origin = Url::parse("https://example.com/").unwrap().origin(); + let request = CacheRequest::get(&Url::parse("https://example.com/a").unwrap()); + + let mut persistent_storage = CacheStorage::new(); + persistent_storage + .open(&origin, "v1") + .put( + request.clone(), + CacheResponse::ok_with_body("text/plain", b"persistent".to_vec()), + ) + .unwrap(); + persistent.save(&origin, &persistent_storage); + + // Private partition cannot see the persistent storage. + assert!(private.load(&origin).is_empty()); + + let mut private_storage = CacheStorage::new(); + private_storage + .open(&origin, "v1") + .put( + request.clone(), + CacheResponse::ok_with_body("text/plain", b"private".to_vec()), + ) + .unwrap(); + private.save(&origin, &private_storage); + + let loaded_persistent = persistent.load(&origin); + let loaded_private = private.load(&origin); + let p_cache = loaded_persistent.get(&origin, "v1").unwrap(); + let r_cache = loaded_private.get(&origin, "v1").unwrap(); + assert_eq!(p_cache.entries().next().unwrap().1.body, b"persistent"); + assert_eq!(r_cache.entries().next().unwrap().1.body, b"private"); + + cleanup(&dir); + } + + #[test] + fn clear_origin_removes_persisted_file() { + let dir = std::env::temp_dir().join("we_cache_clear_origin"); + cleanup(&dir); + let mgr = CacheManager::with_dir(dir.clone()); + let origin = Url::parse("https://example.com/").unwrap().origin(); + let mut storage = CacheStorage::new(); + storage.open(&origin, "v1"); + mgr.save(&origin, &storage); + assert!(mgr.clear_origin(&origin)); + assert!(mgr.load(&origin).is_empty()); + assert!(!mgr.clear_origin(&origin)); + cleanup(&dir); + } + + #[test] + fn load_returns_empty_on_garbage_file() { + let dir = std::env::temp_dir().join("we_cache_garbage"); + cleanup(&dir); + let mgr = CacheManager::with_dir(dir.clone()); + let origin = Url::parse("https://example.com/").unwrap().origin(); + let path = mgr.file_path(&origin).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"not a real cache blob").unwrap(); + assert!(mgr.load(&origin).is_empty()); + cleanup(&dir); + } + + #[test] + fn quota_evicts_oldest_entries_before_save() { + let dir = std::env::temp_dir().join("we_cache_quota"); + cleanup(&dir); + // Pick a quota that fits ~one entry but not multiple. + let mgr = CacheManager::with_dir(dir.clone()).with_quota(128); + let origin = Url::parse("https://example.com/").unwrap().origin(); + let mut storage = CacheStorage::new(); + let cache = storage.open(&origin, "v1"); + for i in 0..10 { + let url = format!("https://example.com/big-resource-{i}"); + let request = CacheRequest::get(&Url::parse(&url).unwrap()); + let response = CacheResponse::ok_with_body("text/plain", vec![b'x'; 32]); + cache.put(request, response).unwrap(); + } + mgr.save(&origin, &storage); + let loaded = mgr.load(&origin); + assert!(loaded.serialize().len() <= 128, "quota not enforced"); + // Newest entries (highest indices) should remain after eviction. + let cache = loaded.get(&origin, "v1").unwrap(); + let urls: Vec = cache.entries().map(|(r, _)| r.url.clone()).collect(); + for url in &urls { + assert!( + url.ends_with("-9") || url.ends_with("-8") || url.ends_with("-7"), + "unexpected surviving entry: {url}" + ); + } + cleanup(&dir); + } +} diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs index 1cf6bfc..996c564 100644 --- a/crates/browser/src/lib.rs +++ b/crates/browser/src/lib.rs @@ -1,6 +1,7 @@ //! Event loop, resource loading, navigation, UI chrome. pub mod browsing_context; +pub mod cache_storage; pub mod csp; pub mod css_loader; pub mod font_loader; diff --git a/crates/browser/src/script_loader.rs b/crates/browser/src/script_loader.rs index dd3be83..4510699 100644 --- a/crates/browser/src/script_loader.rs +++ b/crates/browser/src/script_loader.rs @@ -252,6 +252,7 @@ pub fn execute_scripts_into_vm_with_console( vm.set_document_origin(&origin_str); vm.set_document_url(base_url.clone()); load_service_worker_registrations(&mut vm, document_origin); + load_cache_api_storage(document_origin, base_url); we_js::iframe_bridge::init_window_object(&mut vm, "main", &origin_str); // readyState: parsing done → interactive. vm.set_ready_state("interactive"); @@ -264,6 +265,7 @@ pub fn execute_scripts_into_vm_with_console( false, // not cancelable ); save_service_worker_registrations(&mut vm, document_origin); + save_cache_api_storage(document_origin); return vm; } @@ -282,6 +284,7 @@ pub fn execute_scripts_into_vm_with_console( // Set the document URL so window.location properties work correctly. vm.set_document_url(base_url.clone()); load_service_worker_registrations(&mut vm, document_origin); + load_cache_api_storage(document_origin, base_url); // Initialize the window global object (includes window.location). we_js::iframe_bridge::init_window_object(&mut vm, "main", &origin_str); @@ -344,6 +347,7 @@ pub fn execute_scripts_into_vm_with_console( let _ = vm.pump_event_loop(); save_service_worker_registrations(&mut vm, document_origin); + save_cache_api_storage(document_origin); vm } @@ -359,6 +363,18 @@ fn save_service_worker_registrations(vm: &mut Vm, origin: &Origin) { } } +fn load_cache_api_storage(origin: &Origin, base_url: &Url) { + let storage = crate::cache_storage::CacheManager::new().load(origin); + we_js::cache_api::install_cache_storage(storage); + we_js::cache_api::set_cache_origin(origin.clone()); + we_js::cache_api::set_cache_base_url(base_url.clone()); +} + +fn save_cache_api_storage(origin: &Origin) { + let storage = we_js::cache_api::snapshot_cache_storage(); + crate::cache_storage::CacheManager::new().save(origin, &storage); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/e2e/pages/23_cache_api.html b/crates/e2e/pages/23_cache_api.html new file mode 100644 index 0000000..da92eb4 --- /dev/null +++ b/crates/e2e/pages/23_cache_api.html @@ -0,0 +1,34 @@ + + + + + Cache API e2e + + +

Cache API

+

pending

+ + + diff --git a/crates/e2e/scenarios/cache_api.we b/crates/e2e/scenarios/cache_api.we new file mode 100644 index 0000000..0299125 --- /dev/null +++ b/crates/e2e/scenarios/cache_api.we @@ -0,0 +1,13 @@ +# Cache API end-to-end. Loaded from disk with an HTTPS document URL so the +# origin-keyed cache store is exercised against a real (tuple) origin. + +viewport 800 600 +goto_as https://we.test/app/index.html crates/e2e/pages/23_cache_api.html +dump_console cache_api.console.txt +assert_console_contains "cache-match:200" +assert_console_contains "caches-has:true" +assert_console_contains "caches-keys:shell-v1" +assert_console_contains "caches-delete:true" +dump_dom cache_api.dom.txt +assert_dom_contains "cache-match:200" +screenshot cache_api.png diff --git a/crates/js/src/cache.rs b/crates/js/src/cache.rs new file mode 100644 index 0000000..156b13c --- /dev/null +++ b/crates/js/src/cache.rs @@ -0,0 +1,976 @@ +//! Cache and CacheStorage storage model for the Service Worker Cache API. +//! +//! Implements the spec-level data model behind `caches` on +//! `WindowOrWorkerGlobalScope`. The JS bindings live in +//! [`crate::cache_api`]. Persistence is the responsibility of the embedder +//! (the `browser` crate) which loads/saves an origin's caches between runs. + +use std::collections::HashMap; + +use we_url::{Origin, Url}; + +/// A normalized cache request key. +/// +/// Per the Cache spec, only HTTP `GET` requests can be stored as keys +/// (`cache.put` rejects other methods). URL fragments are stripped during +/// normalization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheRequest { + /// Serialized URL with the fragment component removed. + pub url: String, + /// HTTP method (upper-case ASCII). + pub method: String, + /// Request headers, preserved in insertion order. + pub headers: Vec<(String, String)>, +} + +impl CacheRequest { + /// Build a normalized request from a URL. Method defaults to `GET`, + /// fragment is stripped. + pub fn get(url: &Url) -> Self { + Self { + url: strip_fragment(&url.serialize()), + method: "GET".to_string(), + headers: Vec::new(), + } + } + + /// Normalize an arbitrary request: upper-case the method, strip fragment. + pub fn normalize(mut self) -> Self { + self.url = strip_fragment(&self.url); + self.method = self.method.to_ascii_uppercase(); + self + } +} + +/// A stored response in a cache. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CacheResponse { + pub status: u16, + pub status_text: String, + pub headers: Vec<(String, String)>, + pub body: Vec, + /// Response type per Fetch ("basic", "cors", "opaque", "opaqueredirect", + /// "default"). Stored as a string to keep the type pure. + pub response_type: String, + /// Final URL of the response (after redirects). + pub url: String, + /// True if the response was the result of a redirect chain. + pub redirected: bool, +} + +impl CacheResponse { + /// Build a 200 OK response with the given body and content-type. + pub fn ok_with_body(content_type: &str, body: Vec) -> Self { + Self { + status: 200, + status_text: "OK".to_string(), + headers: vec![("Content-Type".to_string(), content_type.to_string())], + body, + response_type: "basic".to_string(), + url: String::new(), + redirected: false, + } + } +} + +/// Options affecting cache query matching. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CacheQueryOptions { + /// Ignore the query string component when matching URLs. + pub ignore_search: bool, + /// Skip the GET/HEAD method restriction during match. + pub ignore_method: bool, + /// Skip Vary header matching. + pub ignore_vary: bool, +} + +/// A single named cache: an ordered list of `(request, response)` pairs. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Cache { + entries: Vec<(CacheRequest, CacheResponse)>, +} + +impl Cache { + pub fn new() -> Self { + Self::default() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Iterator over the stored `(request, response)` pairs in insertion + /// order. + pub fn entries(&self) -> impl Iterator { + self.entries.iter().map(|(r, p)| (r, p)) + } + + /// Insert or replace an entry. Returns an error if the request method is + /// not `GET` or the response is one of the disallowed kinds (status 206 + /// "Partial Content" is forbidden by the Cache spec). + pub fn put(&mut self, request: CacheRequest, response: CacheResponse) -> Result<(), String> { + let request = request.normalize(); + if request.method != "GET" { + return Err(format!( + "TypeError: Request method '{}' is unsupported", + request.method + )); + } + if response.status == 206 { + return Err( + "TypeError: 206 Partial Content responses cannot be stored in a cache".to_string(), + ); + } + // Replace any prior entry whose request matches by URL + method + // (ignoring Vary): per spec, put atomically deletes and inserts. + self.entries.retain(|(existing, _)| { + !(existing.url == request.url && existing.method == request.method) + }); + self.entries.push((request, response)); + Ok(()) + } + + /// Return the first matching response, or `None`. + pub fn match_request( + &self, + request: &CacheRequest, + options: &CacheQueryOptions, + ) -> Option { + self.find_match(request, options).map(|(_, r)| r.clone()) + } + + /// Return every matching response. + pub fn match_all( + &self, + request: Option<&CacheRequest>, + options: &CacheQueryOptions, + ) -> Vec { + match request { + None => self.entries.iter().map(|(_, r)| r.clone()).collect(), + Some(req) => { + let normalized = req.clone().normalize(); + self.entries + .iter() + .filter(|(cached_req, cached_resp)| { + matches_query(&normalized, cached_req, cached_resp, options) + }) + .map(|(_, r)| r.clone()) + .collect() + } + } + } + + /// Delete all matching entries. Returns true if any were deleted. + pub fn delete(&mut self, request: &CacheRequest, options: &CacheQueryOptions) -> bool { + let normalized = request.clone().normalize(); + let before = self.entries.len(); + self.entries.retain(|(cached_req, cached_resp)| { + !matches_query(&normalized, cached_req, cached_resp, options) + }); + self.entries.len() != before + } + + /// Return every stored request, optionally filtered by `request` / + /// `options`. + pub fn keys( + &self, + request: Option<&CacheRequest>, + options: &CacheQueryOptions, + ) -> Vec { + match request { + None => self.entries.iter().map(|(r, _)| r.clone()).collect(), + Some(req) => { + let normalized = req.clone().normalize(); + self.entries + .iter() + .filter(|(cached_req, cached_resp)| { + matches_query(&normalized, cached_req, cached_resp, options) + }) + .map(|(r, _)| r.clone()) + .collect() + } + } + } + + fn find_match( + &self, + request: &CacheRequest, + options: &CacheQueryOptions, + ) -> Option<(&CacheRequest, &CacheResponse)> { + let normalized = request.clone().normalize(); + self.entries + .iter() + .find(|(cached_req, cached_resp)| { + matches_query(&normalized, cached_req, cached_resp, options) + }) + .map(|(r, p)| (r, p)) + } +} + +/// Top-level storage: each origin owns an ordered list of named caches. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CacheStorage { + origins: HashMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct OriginCaches { + names: Vec, + caches: HashMap, +} + +impl CacheStorage { + pub fn new() -> Self { + Self::default() + } + + /// Return true if no origins have any caches. + pub fn is_empty(&self) -> bool { + self.origins.is_empty() + } + + /// Create (if needed) and return a mutable reference to the named cache. + pub fn open(&mut self, origin: &Origin, name: &str) -> &mut Cache { + let key = origin_key(origin); + let entry = self.origins.entry(key).or_default(); + if !entry.caches.contains_key(name) { + entry.names.push(name.to_string()); + entry.caches.insert(name.to_string(), Cache::new()); + } + entry.caches.get_mut(name).expect("just inserted") + } + + /// Return true if `name` is an existing cache for `origin`. + pub fn has(&self, origin: &Origin, name: &str) -> bool { + self.origins + .get(&origin_key(origin)) + .map(|c| c.caches.contains_key(name)) + .unwrap_or(false) + } + + /// Return all cache names for `origin` in insertion order. + pub fn keys(&self, origin: &Origin) -> Vec { + self.origins + .get(&origin_key(origin)) + .map(|c| c.names.clone()) + .unwrap_or_default() + } + + /// Borrow a named cache, if it exists. + pub fn get(&self, origin: &Origin, name: &str) -> Option<&Cache> { + self.origins + .get(&origin_key(origin)) + .and_then(|c| c.caches.get(name)) + } + + /// Mutably borrow a named cache, if it exists. + pub fn get_mut(&mut self, origin: &Origin, name: &str) -> Option<&mut Cache> { + self.origins + .get_mut(&origin_key(origin)) + .and_then(|c| c.caches.get_mut(name)) + } + + /// Delete a named cache. Returns true if the cache existed. + pub fn delete(&mut self, origin: &Origin, name: &str) -> bool { + let key = origin_key(origin); + if let Some(entry) = self.origins.get_mut(&key) { + entry.names.retain(|n| n != name); + entry.caches.remove(name).is_some() + } else { + false + } + } + + /// Backward-compatible alias for [`Self::delete`]; kept so existing + /// service-worker lifecycle code reads naturally. + pub fn delete_cache(&mut self, origin: &Origin, name: &str) -> bool { + self.delete(origin, name) + } + + /// Drop every cache owned by `origin`. Returns true if anything was + /// removed. + pub fn clear_origin(&mut self, origin: &Origin) -> bool { + self.origins.remove(&origin_key(origin)).is_some() + } + + /// Drop the oldest entry across all of this origin's caches. Used by + /// quota enforcement at persistence time. When a cache becomes empty, + /// the cache itself is *not* removed: only entries are evicted. + /// Returns true if something was evicted. + pub fn evict_oldest_entry(&mut self, origin: &Origin) -> bool { + let entry = match self.origins.get_mut(&origin_key(origin)) { + Some(e) => e, + None => return false, + }; + for name in entry.names.clone() { + if let Some(cache) = entry.caches.get_mut(&name) { + if !cache.entries.is_empty() { + cache.entries.remove(0); + return true; + } + } + } + false + } + + /// Search every cache (or just `cache_name`) for `request`. Returns the + /// first matching response. + pub fn match_request( + &self, + origin: &Origin, + cache_name: Option<&str>, + request: &CacheRequest, + options: &CacheQueryOptions, + ) -> Option { + let entry = self.origins.get(&origin_key(origin))?; + let names: Vec<&str> = match cache_name { + Some(n) => vec![n], + None => entry.names.iter().map(String::as_str).collect(), + }; + for name in names { + if let Some(cache) = entry.caches.get(name) { + if let Some(r) = cache.match_request(request, options) { + return Some(r); + } + } + } + None + } + + /// Backward-compatible put helper used by lifecycle tests: store an + /// opaque blob as a 200 OK response keyed by URL. + pub fn put_bytes( + &mut self, + origin: &Origin, + cache_name: &str, + request_url: &Url, + bytes: Vec, + ) { + let cache = self.open(origin, cache_name); + let request = CacheRequest::get(request_url); + let response = CacheResponse::ok_with_body("application/octet-stream", bytes); + let _ = cache.put(request, response); + } + + /// Backward-compatible match helper used by lifecycle tests. + pub fn match_request_bytes( + &self, + origin: &Origin, + cache_name: &str, + request_url: &Url, + ) -> Option> { + let request = CacheRequest::get(request_url); + let options = CacheQueryOptions::default(); + self.match_request(origin, Some(cache_name), &request, &options) + .map(|r| r.body) + } +} + +// ── Persistence ───────────────────────────────────────────────────────────── + +const PERSIST_MAGIC: &[u8] = b"WECACHE1\0"; + +impl CacheStorage { + /// Serialize to a self-describing binary format. Format: + /// + /// ```text + /// magic "WECACHE1\0" + /// origin_count: u32 + /// for each origin: + /// origin: bytes (u32-len-prefixed) + /// cache_count: u32 + /// for each cache (in insertion order): + /// name: bytes (u32-len-prefixed) + /// entry_count: u32 + /// for each entry: + /// request_url: bytes + /// request_method: bytes + /// header_count: u32 + /// for each header: name bytes, value bytes + /// response: status u16, status_text bytes, response_type bytes, + /// url bytes, redirected u8, header_count u32, + /// headers..., body bytes + /// ``` + pub fn serialize(&self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(PERSIST_MAGIC); + write_u32(&mut out, self.origins.len() as u32); + // Order origins deterministically by serialized origin string. + let mut origins: Vec<(&String, &OriginCaches)> = self.origins.iter().collect(); + origins.sort_by(|a, b| a.0.cmp(b.0)); + for (origin_key, entry) in origins { + write_bytes(&mut out, origin_key.as_bytes()); + write_u32(&mut out, entry.names.len() as u32); + for name in &entry.names { + write_bytes(&mut out, name.as_bytes()); + let cache = entry.caches.get(name).expect("name has cache"); + write_u32(&mut out, cache.entries.len() as u32); + for (req, resp) in &cache.entries { + write_bytes(&mut out, req.url.as_bytes()); + write_bytes(&mut out, req.method.as_bytes()); + write_headers(&mut out, &req.headers); + out.extend_from_slice(&resp.status.to_le_bytes()); + write_bytes(&mut out, resp.status_text.as_bytes()); + write_bytes(&mut out, resp.response_type.as_bytes()); + write_bytes(&mut out, resp.url.as_bytes()); + out.push(u8::from(resp.redirected)); + write_headers(&mut out, &resp.headers); + write_bytes(&mut out, &resp.body); + } + } + } + out + } + + /// Parse a previously serialized blob. Returns `None` if the bytes are + /// truncated or carry the wrong magic; partial parses are discarded. + pub fn deserialize(data: &[u8]) -> Option { + let mut r = Reader::new(data); + let magic = r.bytes_fixed(PERSIST_MAGIC.len())?; + if magic != PERSIST_MAGIC { + return None; + } + let origin_count = r.u32()?; + let mut storage = CacheStorage::new(); + for _ in 0..origin_count { + let origin_key = r.string()?; + let cache_count = r.u32()?; + let mut origin = OriginCaches::default(); + for _ in 0..cache_count { + let cache_name = r.string()?; + let entry_count = r.u32()?; + let mut cache = Cache::new(); + for _ in 0..entry_count { + let url = r.string()?; + let method = r.string()?; + let req_headers = r.headers()?; + let status = r.u16()?; + let status_text = r.string()?; + let response_type = r.string()?; + let response_url = r.string()?; + let redirected = r.u8()? != 0; + let resp_headers = r.headers()?; + let body = r.bytes()?; + let req = CacheRequest { + url, + method, + headers: req_headers, + }; + let resp = CacheResponse { + status, + status_text, + headers: resp_headers, + body, + response_type, + url: response_url, + redirected, + }; + cache.entries.push((req, resp)); + } + origin.names.push(cache_name.clone()); + origin.caches.insert(cache_name, cache); + } + storage.origins.insert(origin_key, origin); + } + Some(storage) + } +} + +// ── Matching helpers ──────────────────────────────────────────────────────── + +fn matches_query( + query: &CacheRequest, + cached_req: &CacheRequest, + cached_resp: &CacheResponse, + options: &CacheQueryOptions, +) -> bool { + if !options.ignore_method && query.method != "GET" && query.method != "HEAD" { + return false; + } + if !urls_match(&query.url, &cached_req.url, options.ignore_search) { + return false; + } + if !options.ignore_vary && !vary_matches(query, cached_req, cached_resp) { + return false; + } + true +} + +fn urls_match(query: &str, cached: &str, ignore_search: bool) -> bool { + let query = strip_fragment(query); + let cached = strip_fragment(cached); + if ignore_search { + strip_query(&query) == strip_query(&cached) + } else { + query == cached + } +} + +fn vary_matches( + query: &CacheRequest, + cached_req: &CacheRequest, + cached_resp: &CacheResponse, +) -> bool { + let Some(vary) = header_value(&cached_resp.headers, "Vary") else { + return true; + }; + for field in vary.split(',') { + let field = field.trim(); + if field.is_empty() { + continue; + } + if field == "*" { + return false; + } + let q = header_value(&query.headers, field); + let c = header_value(&cached_req.headers, field); + if q != c { + return false; + } + } + true +} + +fn header_value(headers: &[(String, String)], name: &str) -> Option { + headers + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.clone()) +} + +fn strip_fragment(url: &str) -> String { + match url.find('#') { + Some(i) => url[..i].to_string(), + None => url.to_string(), + } +} + +fn strip_query(url: &str) -> &str { + match url.find('?') { + Some(i) => &url[..i], + None => url, + } +} + +fn origin_key(origin: &Origin) -> String { + origin.serialize() +} + +// ── Binary helpers ────────────────────────────────────────────────────────── + +fn write_u32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_le_bytes()); +} + +fn write_bytes(out: &mut Vec, b: &[u8]) { + write_u32(out, b.len() as u32); + out.extend_from_slice(b); +} + +fn write_headers(out: &mut Vec, headers: &[(String, String)]) { + write_u32(out, headers.len() as u32); + for (name, value) in headers { + write_bytes(out, name.as_bytes()); + write_bytes(out, value.as_bytes()); + } +} + +struct Reader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn bytes_fixed(&mut self, len: usize) -> Option<&'a [u8]> { + if self.pos + len > self.data.len() { + return None; + } + let out = &self.data[self.pos..self.pos + len]; + self.pos += len; + Some(out) + } + + fn u8(&mut self) -> Option { + let b = self.bytes_fixed(1)?; + Some(b[0]) + } + + fn u16(&mut self) -> Option { + let b = self.bytes_fixed(2)?; + Some(u16::from_le_bytes([b[0], b[1]])) + } + + fn u32(&mut self) -> Option { + let b = self.bytes_fixed(4)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn bytes(&mut self) -> Option> { + let len = self.u32()? as usize; + let b = self.bytes_fixed(len)?; + Some(b.to_vec()) + } + + fn string(&mut self) -> Option { + let bytes = self.bytes()?; + String::from_utf8(bytes).ok() + } + + fn headers(&mut self) -> Option> { + let count = self.u32()? as usize; + let mut out = Vec::with_capacity(count); + for _ in 0..count { + let name = self.string()?; + let value = self.string()?; + out.push((name, value)); + } + Some(out) + } +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn origin_a() -> Origin { + Url::parse("https://example.com/app/").unwrap().origin() + } + + fn origin_b() -> Origin { + Url::parse("https://other.com/").unwrap().origin() + } + + fn req(url: &str) -> CacheRequest { + CacheRequest::get(&Url::parse(url).unwrap()) + } + + fn resp(body: &str) -> CacheResponse { + CacheResponse::ok_with_body("text/plain", body.as_bytes().to_vec()) + } + + #[test] + fn open_creates_named_cache_and_returns_existing_on_second_call() { + let mut storage = CacheStorage::new(); + let origin = origin_a(); + storage.open(&origin, "v1"); + storage.open(&origin, "v2"); + // Second open of "v1" returns the same cache (insertion order kept). + storage.open(&origin, "v1"); + assert_eq!(storage.keys(&origin), vec!["v1", "v2"]); + assert!(storage.has(&origin, "v1")); + assert!(storage.has(&origin, "v2")); + assert!(!storage.has(&origin, "v3")); + } + + #[test] + fn delete_named_cache_removes_only_that_cache() { + let mut storage = CacheStorage::new(); + let origin = origin_a(); + storage.open(&origin, "v1"); + storage.open(&origin, "v2"); + assert!(storage.delete(&origin, "v1")); + assert!(!storage.has(&origin, "v1")); + assert!(storage.has(&origin, "v2")); + // Deleting a missing cache returns false. + assert!(!storage.delete(&origin, "v1")); + } + + #[test] + fn origins_are_isolated() { + let mut storage = CacheStorage::new(); + let a = origin_a(); + let b = origin_b(); + storage.open(&a, "v1"); + storage.open(&b, "v1"); + assert!(storage.has(&a, "v1")); + assert!(storage.has(&b, "v1")); + storage.delete(&a, "v1"); + assert!(!storage.has(&a, "v1")); + assert!(storage.has(&b, "v1")); + } + + #[test] + fn put_rejects_non_get_methods() { + let mut cache = Cache::new(); + let mut request = req("https://example.com/api"); + request.method = "POST".to_string(); + let err = cache.put(request, resp("ignored")).unwrap_err(); + assert!(err.contains("POST"), "got: {err}"); + } + + #[test] + fn put_rejects_partial_content_responses() { + let mut cache = Cache::new(); + let mut response = resp("partial"); + response.status = 206; + let err = cache + .put(req("https://example.com/x"), response) + .unwrap_err(); + assert!(err.contains("206"), "got: {err}"); + } + + #[test] + fn put_strips_url_fragment_during_normalization() { + let mut cache = Cache::new(); + cache + .put(req("https://example.com/x#fragment"), resp("body")) + .unwrap(); + // The fragment should be gone in the stored request. + let stored: Vec<_> = cache.entries().collect(); + assert_eq!(stored[0].0.url, "https://example.com/x"); + // And the request can be looked up without the fragment. + let match_ = + cache.match_request(&req("https://example.com/x"), &CacheQueryOptions::default()); + assert!(match_.is_some()); + } + + #[test] + fn put_replaces_existing_entry_for_same_url() { + let mut cache = Cache::new(); + cache + .put(req("https://example.com/x"), resp("one")) + .unwrap(); + cache + .put(req("https://example.com/x"), resp("two")) + .unwrap(); + assert_eq!(cache.len(), 1); + let response = cache + .match_request(&req("https://example.com/x"), &CacheQueryOptions::default()) + .unwrap(); + assert_eq!(response.body, b"two"); + } + + #[test] + fn match_with_ignore_search_strips_query() { + let mut cache = Cache::new(); + cache + .put(req("https://example.com/x?v=1"), resp("one")) + .unwrap(); + let mut opts = CacheQueryOptions::default(); + // Without ignore_search a different query misses. + assert!(cache + .match_request(&req("https://example.com/x?v=2"), &opts) + .is_none()); + // With ignore_search it hits. + opts.ignore_search = true; + assert!(cache + .match_request(&req("https://example.com/x?v=2"), &opts) + .is_some()); + } + + #[test] + fn match_respects_vary_header() { + let mut cache = Cache::new(); + let mut cached_req = req("https://example.com/x"); + cached_req + .headers + .push(("Accept-Language".to_string(), "en".to_string())); + let mut cached_resp = resp("hello"); + cached_resp + .headers + .push(("Vary".to_string(), "Accept-Language".to_string())); + cache.put(cached_req, cached_resp).unwrap(); + + let opts = CacheQueryOptions::default(); + + // Same Accept-Language matches. + let mut q = req("https://example.com/x"); + q.headers + .push(("Accept-Language".to_string(), "en".to_string())); + assert!(cache.match_request(&q, &opts).is_some()); + + // Different Accept-Language misses. + q.headers.clear(); + q.headers + .push(("Accept-Language".to_string(), "fr".to_string())); + assert!(cache.match_request(&q, &opts).is_none()); + + // Vary: * always misses. + let mut cache = Cache::new(); + let mut r = resp("hello"); + r.headers.push(("Vary".to_string(), "*".to_string())); + cache.put(req("https://example.com/y"), r).unwrap(); + assert!(cache + .match_request(&req("https://example.com/y"), &opts) + .is_none()); + + // ignore_vary opts out of Vary matching. + let opts_ignore = CacheQueryOptions { + ignore_vary: true, + ..Default::default() + }; + assert!(cache + .match_request(&req("https://example.com/y"), &opts_ignore) + .is_some()); + } + + #[test] + fn match_method_filter_rejects_post_by_default() { + let mut cache = Cache::new(); + cache + .put(req("https://example.com/x"), resp("body")) + .unwrap(); + let mut q = req("https://example.com/x"); + q.method = "POST".to_string(); + assert!(cache + .match_request(&q, &CacheQueryOptions::default()) + .is_none()); + // ignore_method opts out of method filtering. + let opts = CacheQueryOptions { + ignore_method: true, + ..Default::default() + }; + assert!(cache.match_request(&q, &opts).is_some()); + } + + #[test] + fn delete_with_options_matches_query_string_independently() { + let mut cache = Cache::new(); + cache + .put(req("https://example.com/x?v=1"), resp("a")) + .unwrap(); + cache + .put(req("https://example.com/x?v=2"), resp("b")) + .unwrap(); + // ignore_search deletes both. + let opts = CacheQueryOptions { + ignore_search: true, + ..Default::default() + }; + assert!(cache.delete(&req("https://example.com/x?v=1"), &opts)); + assert_eq!(cache.len(), 0); + } + + #[test] + fn keys_returns_stored_requests_in_insertion_order() { + let mut cache = Cache::new(); + cache.put(req("https://example.com/a"), resp("1")).unwrap(); + cache.put(req("https://example.com/b"), resp("2")).unwrap(); + cache.put(req("https://example.com/c"), resp("3")).unwrap(); + let urls: Vec = cache + .keys(None, &CacheQueryOptions::default()) + .into_iter() + .map(|r| r.url) + .collect(); + assert_eq!( + urls, + vec![ + "https://example.com/a".to_string(), + "https://example.com/b".to_string(), + "https://example.com/c".to_string(), + ] + ); + } + + #[test] + fn storage_match_searches_all_caches_by_default() { + let mut storage = CacheStorage::new(); + let origin = origin_a(); + storage + .open(&origin, "v1") + .put(req("https://example.com/a"), resp("alpha")) + .unwrap(); + storage + .open(&origin, "v2") + .put(req("https://example.com/b"), resp("beta")) + .unwrap(); + let opts = CacheQueryOptions::default(); + let alpha = storage + .match_request(&origin, None, &req("https://example.com/a"), &opts) + .unwrap(); + let beta = storage + .match_request(&origin, None, &req("https://example.com/b"), &opts) + .unwrap(); + assert_eq!(alpha.body, b"alpha"); + assert_eq!(beta.body, b"beta"); + // Restricting to a single cache returns only its hits. + assert!(storage + .match_request(&origin, Some("v1"), &req("https://example.com/b"), &opts) + .is_none()); + } + + #[test] + fn round_trip_persistence_preserves_caches_and_bodies() { + let mut storage = CacheStorage::new(); + let origin = origin_a(); + let cache = storage.open(&origin, "v1"); + let mut response = resp("hello"); + response + .headers + .push(("Vary".to_string(), "Accept-Language".to_string())); + let mut request = req("https://example.com/x"); + request + .headers + .push(("Accept-Language".to_string(), "en".to_string())); + cache.put(request, response).unwrap(); + // Add an opaque binary body to validate body round-trips. + let binary_body: Vec = (0u8..=255).collect(); + let mut binary_resp = + CacheResponse::ok_with_body("application/octet-stream", binary_body.clone()); + binary_resp.url = "https://example.com/redirected".to_string(); + binary_resp.redirected = true; + cache + .put(req("https://example.com/bin"), binary_resp) + .unwrap(); + + let bytes = storage.serialize(); + let restored = CacheStorage::deserialize(&bytes).expect("deserialize"); + assert_eq!(restored, storage); + + // Vary matching still works after the round trip. + let mut q = req("https://example.com/x"); + q.headers + .push(("Accept-Language".to_string(), "en".to_string())); + let resp = restored + .match_request(&origin, Some("v1"), &q, &CacheQueryOptions::default()) + .unwrap(); + assert_eq!(resp.body, b"hello"); + + // Binary body round-trips. + let bin = restored + .match_request( + &origin, + Some("v1"), + &req("https://example.com/bin"), + &CacheQueryOptions::default(), + ) + .unwrap(); + assert_eq!(bin.body, binary_body); + assert!(bin.redirected); + assert_eq!(bin.url, "https://example.com/redirected"); + } + + #[test] + fn deserialize_rejects_garbage_input() { + assert!(CacheStorage::deserialize(b"not a real cache blob").is_none()); + assert!(CacheStorage::deserialize(b"WECACHE9\0junk").is_none()); + // Truncated mid-record after good magic. + let mut truncated = PERSIST_MAGIC.to_vec(); + truncated.extend_from_slice(&1u32.to_le_bytes()); // origin_count = 1 + truncated.extend_from_slice(&5u32.to_le_bytes()); // claimed length, missing payload + assert!(CacheStorage::deserialize(&truncated).is_none()); + } + + #[test] + fn put_bytes_compatibility_shim_round_trips() { + let mut storage = CacheStorage::new(); + let origin = origin_a(); + let url = Url::parse("https://example.com/x").unwrap(); + storage.put_bytes(&origin, "v1", &url, b"payload".to_vec()); + let body = storage.match_request_bytes(&origin, "v1", &url).unwrap(); + assert_eq!(body, b"payload"); + assert!(storage.delete_cache(&origin, "v1")); + assert!(storage.match_request_bytes(&origin, "v1", &url).is_none()); + } +} diff --git a/crates/js/src/cache_api.rs b/crates/js/src/cache_api.rs new file mode 100644 index 0000000..453b3bd --- /dev/null +++ b/crates/js/src/cache_api.rs @@ -0,0 +1,1122 @@ +//! JavaScript bindings for the Cache API (`caches`, `Cache`). +//! +//! Storage lives in a thread-local [`CacheStorage`] keyed by the document +//! origin currently associated with this VM. The embedder (the `browser` +//! crate) is responsible for loading the origin's persisted caches at +//! navigation time via [`install_cache_storage`] and saving them via +//! [`snapshot_cache_storage`]. +//! +//! Only `data:` URLs are decoded inline for `Cache.add` / `Cache.addAll`; +//! HTTP fetches are deferred to a follow-up issue that wires this module to +//! the network stack. Callers can populate caches today either by decoding a +//! `data:` URL or by constructing a `Response` object themselves and calling +//! `Cache.put`. + +use std::cell::RefCell; + +use crate::builtins::{ + create_promise_object_pub, make_native, reject_promise_internal, resolve_promise_internal, +}; +use crate::cache::{CacheQueryOptions, CacheRequest, CacheResponse, CacheStorage}; +use crate::gc::GcRef; +use crate::vm::{HeapObject, NativeContext, ObjectData, Property, RuntimeError, Value, Vm}; +use we_url::data_url::parse_data_url; +use we_url::{Origin, Url}; + +thread_local! { + static STORAGE: RefCell = RefCell::new(CacheStorage::new()); + static ORIGIN: RefCell> = const { RefCell::new(None) }; + static BASE_URL: RefCell> = const { RefCell::new(None) }; +} + +// ── Embedder API ──────────────────────────────────────────────────────────── + +/// Replace the active cache storage with `storage`. Called when navigating +/// to a new origin so the embedder can swap in that origin's persisted +/// caches. +pub fn install_cache_storage(storage: CacheStorage) { + STORAGE.with(|s| *s.borrow_mut() = storage); +} + +/// Reset the thread-local Cache API state to an empty storage. Useful in +/// tests to avoid bleed-through between cases. +pub fn reset_cache_storage() { + STORAGE.with(|s| *s.borrow_mut() = CacheStorage::new()); + ORIGIN.with(|o| *o.borrow_mut() = None); + BASE_URL.with(|b| *b.borrow_mut() = None); +} + +/// Set the origin associated with the current VM. Cache lookups will be +/// scoped to this origin. +pub fn set_cache_origin(origin: Origin) { + ORIGIN.with(|o| *o.borrow_mut() = Some(origin)); +} + +/// Set the base URL used to resolve relative request URLs passed to the +/// Cache API. Should be set to the current document URL on the main thread, +/// or the service worker script URL inside the worker. +pub fn set_cache_base_url(url: Url) { + BASE_URL.with(|b| *b.borrow_mut() = Some(url)); +} + +/// Snapshot the current cache storage so the embedder can persist it. +pub fn snapshot_cache_storage() -> CacheStorage { + STORAGE.with(|s| s.borrow().clone()) +} + +/// Run `f` with a mutable reference to the thread-local cache storage. Used +/// by tests and embedders to inspect state directly. +pub fn with_cache_storage_mut(f: impl FnOnce(&mut CacheStorage) -> R) -> R { + STORAGE.with(|s| f(&mut s.borrow_mut())) +} + +// ── JS init ───────────────────────────────────────────────────────────────── + +/// Install `caches`, `Request`, and `Response` constructors on the given VM. +/// +/// `caches` and the `Cache` objects it hands out are implemented as a JS +/// preamble (see [`CACHE_API_PREAMBLE`]) that wraps a small set of native +/// helpers. We pass the cache name explicitly through every helper instead +/// of going via `this`, because the compiler's method-call lowering shares +/// the `this` global slot across nested argument evaluation — `cache.put(new +/// Request(...), ...)` would clobber `this` while constructing the Request. +pub fn init_cache_api(vm: &mut Vm) { + register_native_helpers(vm); + install_preamble(vm); +} + +type NativeHelper = fn(&[Value], &mut NativeContext) -> Result; + +fn register_native_helpers(vm: &mut Vm) { + let helpers: [(&str, NativeHelper); 12] = [ + ("__we_caches_open", caches_open), + ("__we_caches_has", caches_has), + ("__we_caches_keys", caches_keys), + ("__we_caches_delete", caches_delete), + ("__we_caches_match", caches_match), + ("__we_cache_match", cache_match), + ("__we_cache_match_all", cache_match_all), + ("__we_cache_put", cache_put), + ("__we_cache_add", cache_add), + ("__we_cache_add_all", cache_add_all), + ("__we_cache_delete", cache_delete), + ("__we_cache_keys", cache_keys), + ]; + for (name, callback) in helpers { + let func = make_native(&mut vm.gc, name, callback); + vm.set_global(name, Value::Function(func)); + } +} + +const CACHE_API_PREAMBLE: &str = r#" +function Request(input, init) { + var r = Object.create(Request.prototype); + init = init || {}; + if (input !== null && typeof input === "object" && input.url !== undefined) { + r.url = String(input.url); + r.method = init.method !== undefined ? String(init.method) : input.method; + r.headers = init.headers !== undefined ? init.headers : input.headers; + r.body = init.body !== undefined ? init.body : input.body; + } else { + r.url = String(input); + r.method = init.method !== undefined ? String(init.method) : "GET"; + r.headers = init.headers || {}; + r.body = init.body; + } + return r; +} +function Response(body, init) { + var r = Object.create(Response.prototype); + init = init || {}; + r.status = init.status !== undefined ? Number(init.status) : 200; + r.statusText = init.statusText !== undefined ? String(init.statusText) : ""; + r.headers = init.headers || {}; + r.body = body !== undefined ? body : null; + r.url = init.url !== undefined ? String(init.url) : ""; + r.type = init.type !== undefined ? String(init.type) : "default"; + r.redirected = init.redirected === true; + r.ok = r.status >= 200 && r.status < 300; + return r; +} + +// Native helpers return array-shaped objects (numeric indices + length) that +// are not real Arrays — they lack Array.prototype methods. Wrap them into a +// proper Array so callers can use .join, .map, .forEach, etc. +function __we_to_array(arrlike) { + var out = []; + var n = arrlike.length; + for (var i = 0; i < n; i++) out.push(arrlike[i]); + return out; +} + +function __we_make_cache(name) { + return { + match: function(req, opts) { return __we_cache_match(name, req, opts); }, + matchAll: function(req, opts) { + return __we_cache_match_all(name, req, opts).then(__we_to_array); + }, + put: function(req, res) { return __we_cache_put(name, req, res); }, + add: function(req) { return __we_cache_add(name, req); }, + addAll: function(reqs) { return __we_cache_add_all(name, reqs); }, + delete: function(req, opts) { return __we_cache_delete(name, req, opts); }, + keys: function(req, opts) { + return __we_cache_keys(name, req, opts).then(__we_to_array); + } + }; +} + +var caches = { + open: function(name) { + return __we_caches_open(String(name)).then(function() { + return __we_make_cache(String(name)); + }); + }, + has: function(name) { return __we_caches_has(String(name)); }, + keys: function() { return __we_caches_keys().then(__we_to_array); }, + delete: function(name) { return __we_caches_delete(String(name)); }, + match: function(req, opts) { return __we_caches_match(req, opts); } +}; +"#; + +fn install_preamble(vm: &mut Vm) { + let ast = match crate::parser::Parser::parse(CACHE_API_PREAMBLE) { + Ok(ast) => ast, + Err(e) => { + eprintln!("cache API preamble parse error: {e}"); + return; + } + }; + let func = match crate::compiler::compile(&ast) { + Ok(f) => f, + Err(e) => { + eprintln!("cache API preamble compile error: {e}"); + return; + } + }; + if let Err(e) = vm.execute(&func) { + eprintln!("cache API preamble runtime error: {e}"); + } +} + +// ── Promise helpers ───────────────────────────────────────────────────────── + +fn promise_resolved(ctx: &mut NativeContext, value: Value) -> Value { + let promise = create_promise_object_pub(ctx.gc, ctx.shapes); + resolve_promise_internal(ctx.gc, ctx.shapes, promise, value); + Value::Object(promise) +} + +fn promise_rejected(ctx: &mut NativeContext, message: String) -> Value { + let promise = create_promise_object_pub(ctx.gc, ctx.shapes); + reject_promise_internal(ctx.gc, ctx.shapes, promise, Value::String(message)); + Value::Object(promise) +} + +// ── caches.* ──────────────────────────────────────────────────────────────── + +fn caches_open(args: &[Value], ctx: &mut NativeContext) -> Result { + let name = match args.first() { + Some(v) => v.to_js_string(ctx.gc), + None => { + return Ok(promise_rejected( + ctx, + "TypeError: caches.open requires a name".to_string(), + )) + } + }; + let origin = match current_origin() { + Some(o) => o, + None => { + return Ok(promise_rejected( + ctx, + "InvalidStateError: no origin associated with this context".to_string(), + )) + } + }; + STORAGE.with(|s| { + s.borrow_mut().open(&origin, &name); + }); + Ok(promise_resolved(ctx, Value::Undefined)) +} + +fn caches_has(args: &[Value], ctx: &mut NativeContext) -> Result { + let name = match args.first() { + Some(v) => v.to_js_string(ctx.gc), + None => return Ok(promise_resolved(ctx, Value::Boolean(false))), + }; + let origin = match current_origin() { + Some(o) => o, + None => return Ok(promise_resolved(ctx, Value::Boolean(false))), + }; + let exists = STORAGE.with(|s| s.borrow().has(&origin, &name)); + Ok(promise_resolved(ctx, Value::Boolean(exists))) +} + +fn caches_keys(_args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => { + let empty = build_array(ctx, Vec::new()); + return Ok(promise_resolved(ctx, empty)); + } + }; + let names: Vec = STORAGE.with(|s| s.borrow().keys(&origin)); + let values: Vec = names.into_iter().map(Value::String).collect(); + let array = build_array(ctx, values); + Ok(promise_resolved(ctx, array)) +} + +fn caches_delete(args: &[Value], ctx: &mut NativeContext) -> Result { + let name = match args.first() { + Some(v) => v.to_js_string(ctx.gc), + None => return Ok(promise_resolved(ctx, Value::Boolean(false))), + }; + let origin = match current_origin() { + Some(o) => o, + None => return Ok(promise_resolved(ctx, Value::Boolean(false))), + }; + let removed = STORAGE.with(|s| s.borrow_mut().delete(&origin, &name)); + Ok(promise_resolved(ctx, Value::Boolean(removed))) +} + +fn caches_match(args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => return Ok(promise_resolved(ctx, Value::Undefined)), + }; + let request = match build_request_from_arg(args.first(), ctx) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let options = parse_query_options(args.get(1), ctx); + let cache_name = match args.get(1) { + Some(Value::Object(obj)) => match ctx.gc.get(*obj) { + Some(HeapObject::Object(data)) => { + data.get_property("cacheName", ctx.shapes) + .and_then(|p| match p.value { + Value::String(s) => Some(s), + Value::Undefined | Value::Null => None, + other => Some(other.to_js_string(ctx.gc)), + }) + } + _ => None, + }, + _ => None, + }; + let result = STORAGE.with(|s| { + s.borrow() + .match_request(&origin, cache_name.as_deref(), &request, &options) + }); + match result { + Some(resp) => { + let value = build_response_object(ctx, &resp); + Ok(promise_resolved(ctx, value)) + } + None => Ok(promise_resolved(ctx, Value::Undefined)), + } +} + +// ── Cache.* (cache name passed explicitly as first argument) ──────────────── + +fn take_cache_name(args: &[Value], ctx: &NativeContext) -> Option { + match args.first()? { + Value::String(s) => Some(s.clone()), + other => Some(other.to_js_string(ctx.gc)), + } +} + +fn cache_match(args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => return Ok(promise_resolved(ctx, Value::Undefined)), + }; + let cache_name = match take_cache_name(args, ctx) { + Some(n) => n, + None => { + return Ok(promise_rejected( + ctx, + "TypeError: Cache.match missing cache name".to_string(), + )) + } + }; + let request = match build_request_from_arg(args.get(1), ctx) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let options = parse_query_options(args.get(2), ctx); + let result = STORAGE.with(|s| { + s.borrow() + .match_request(&origin, Some(&cache_name), &request, &options) + }); + match result { + Some(resp) => { + let value = build_response_object(ctx, &resp); + Ok(promise_resolved(ctx, value)) + } + None => Ok(promise_resolved(ctx, Value::Undefined)), + } +} + +fn cache_match_all(args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => { + let empty = build_array(ctx, Vec::new()); + return Ok(promise_resolved(ctx, empty)); + } + }; + let cache_name = match take_cache_name(args, ctx) { + Some(n) => n, + None => { + return Ok(promise_rejected( + ctx, + "TypeError: Cache.matchAll missing cache name".to_string(), + )) + } + }; + let options = parse_query_options(args.get(2), ctx); + let request_filter = match args.get(1) { + Some(v) if !matches!(v, Value::Undefined | Value::Null) => { + match build_request_from_arg(Some(v), ctx) { + Ok(r) => Some(r), + Err(e) => return Ok(promise_rejected(ctx, e)), + } + } + _ => None, + }; + let responses: Vec = STORAGE.with(|s| { + let storage = s.borrow(); + let cache = match storage.get(&origin, &cache_name) { + Some(c) => c, + None => return Vec::new(), + }; + cache.match_all(request_filter.as_ref(), &options) + }); + let values: Vec = responses + .into_iter() + .map(|r| build_response_object(ctx, &r)) + .collect(); + let array = build_array(ctx, values); + Ok(promise_resolved(ctx, array)) +} + +fn cache_put(args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => { + return Ok(promise_rejected( + ctx, + "InvalidStateError: no origin associated with this context".to_string(), + )) + } + }; + let cache_name = match take_cache_name(args, ctx) { + Some(n) => n, + None => { + return Ok(promise_rejected( + ctx, + "TypeError: Cache.put missing cache name".to_string(), + )) + } + }; + let request = match build_request_from_arg(args.get(1), ctx) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let response = match build_response_from_arg(args.get(2), ctx) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let put_result = STORAGE.with(|s| { + let mut storage = s.borrow_mut(); + storage.open(&origin, &cache_name).put(request, response) + }); + match put_result { + Ok(()) => Ok(promise_resolved(ctx, Value::Undefined)), + Err(e) => Ok(promise_rejected(ctx, e)), + } +} + +fn cache_add(args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => { + return Ok(promise_rejected( + ctx, + "InvalidStateError: no origin associated with this context".to_string(), + )) + } + }; + let cache_name = match take_cache_name(args, ctx) { + Some(n) => n, + None => { + return Ok(promise_rejected( + ctx, + "TypeError: Cache.add missing cache name".to_string(), + )) + } + }; + let request = match build_request_from_arg(args.get(1), ctx) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let response = match fetch_for_cache_add(&request) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let put_result = STORAGE.with(|s| { + let mut storage = s.borrow_mut(); + storage.open(&origin, &cache_name).put(request, response) + }); + match put_result { + Ok(()) => Ok(promise_resolved(ctx, Value::Undefined)), + Err(e) => Ok(promise_rejected(ctx, e)), + } +} + +fn cache_add_all(args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => { + return Ok(promise_rejected( + ctx, + "InvalidStateError: no origin associated with this context".to_string(), + )) + } + }; + let cache_name = match take_cache_name(args, ctx) { + Some(n) => n, + None => { + return Ok(promise_rejected( + ctx, + "TypeError: Cache.addAll missing cache name".to_string(), + )) + } + }; + let array_ref = match args.get(1) { + Some(Value::Object(r)) => *r, + _ => { + return Ok(promise_rejected( + ctx, + "TypeError: Cache.addAll requires an array of requests".to_string(), + )) + } + }; + let items = read_array_values(ctx, array_ref); + let mut staged: Vec<(CacheRequest, CacheResponse)> = Vec::with_capacity(items.len()); + for item in items { + let request = match build_request_from_arg(Some(&item), ctx) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let response = match fetch_for_cache_add(&request) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + staged.push((request, response)); + } + let result = STORAGE.with(|s| { + let mut storage = s.borrow_mut(); + let cache = storage.open(&origin, &cache_name); + for (req, resp) in staged { + cache.put(req, resp)?; + } + Ok(()) + }); + match result { + Ok(()) => Ok(promise_resolved(ctx, Value::Undefined)), + Err(e) => Ok(promise_rejected(ctx, e)), + } +} + +fn cache_delete(args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => return Ok(promise_resolved(ctx, Value::Boolean(false))), + }; + let cache_name = match take_cache_name(args, ctx) { + Some(n) => n, + None => { + return Ok(promise_rejected( + ctx, + "TypeError: Cache.delete missing cache name".to_string(), + )) + } + }; + let request = match build_request_from_arg(args.get(1), ctx) { + Ok(r) => r, + Err(e) => return Ok(promise_rejected(ctx, e)), + }; + let options = parse_query_options(args.get(2), ctx); + let removed = STORAGE.with(|s| { + let mut storage = s.borrow_mut(); + match storage.get_mut(&origin, &cache_name) { + Some(cache) => cache.delete(&request, &options), + None => false, + } + }); + Ok(promise_resolved(ctx, Value::Boolean(removed))) +} + +fn cache_keys(args: &[Value], ctx: &mut NativeContext) -> Result { + let origin = match current_origin() { + Some(o) => o, + None => { + let empty = build_array(ctx, Vec::new()); + return Ok(promise_resolved(ctx, empty)); + } + }; + let cache_name = match take_cache_name(args, ctx) { + Some(n) => n, + None => { + return Ok(promise_rejected( + ctx, + "TypeError: Cache.keys missing cache name".to_string(), + )) + } + }; + let options = parse_query_options(args.get(2), ctx); + let filter = match args.get(1) { + Some(v) if !matches!(v, Value::Undefined | Value::Null) => { + match build_request_from_arg(Some(v), ctx) { + Ok(r) => Some(r), + Err(e) => return Ok(promise_rejected(ctx, e)), + } + } + _ => None, + }; + let requests: Vec = STORAGE.with(|s| { + let storage = s.borrow(); + match storage.get(&origin, &cache_name) { + Some(cache) => cache.keys(filter.as_ref(), &options), + None => Vec::new(), + } + }); + let values: Vec = requests + .into_iter() + .map(|r| build_request_object(ctx, &r)) + .collect(); + let array = build_array(ctx, values); + Ok(promise_resolved(ctx, array)) +} + +// ── Argument coercion ─────────────────────────────────────────────────────── + +fn build_request_from_arg( + value: Option<&Value>, + ctx: &NativeContext, +) -> Result { + let value = match value { + Some(v) => v, + None => return Err("TypeError: request is required".to_string()), + }; + match value { + Value::String(s) => parse_request_url(s), + Value::Object(obj_ref) => extract_request_object(*obj_ref, ctx), + other => parse_request_url(&other.to_js_string(ctx.gc)), + } +} + +fn parse_request_url(url_str: &str) -> Result { + let resolved = match BASE_URL.with(|b| b.borrow().clone()) { + Some(base) => Url::parse_with_base(url_str, &base) + .or_else(|_| Url::parse(url_str)) + .map_err(|e| format!("TypeError: {e}"))?, + None => Url::parse(url_str).map_err(|e| format!("TypeError: {e}"))?, + }; + Ok(CacheRequest::get(&resolved)) +} + +fn extract_request_object(obj_ref: GcRef, ctx: &NativeContext) -> Result { + let data = match ctx.gc.get(obj_ref) { + Some(HeapObject::Object(d)) => d, + _ => return Err("TypeError: invalid request object".to_string()), + }; + let url = data + .get_property("url", ctx.shapes) + .map(|p| p.value.to_js_string(ctx.gc)) + .ok_or_else(|| "TypeError: request object is missing 'url'".to_string())?; + let method = data + .get_property("method", ctx.shapes) + .map(|p| p.value.to_js_string(ctx.gc)) + .unwrap_or_else(|| "GET".to_string()); + let mut headers: Vec<(String, String)> = Vec::new(); + if let Some(prop) = data.get_property("headers", ctx.shapes) { + if let Value::Object(h_ref) = prop.value { + if let Some(HeapObject::Object(h_data)) = ctx.gc.get(h_ref) { + for (key, hprop) in h_data.property_entries(ctx.shapes) { + if hprop.enumerable { + headers.push((key, hprop.value.to_js_string(ctx.gc))); + } + } + } + } + } + let resolved = match BASE_URL.with(|b| b.borrow().clone()) { + Some(base) => Url::parse_with_base(&url, &base) + .or_else(|_| Url::parse(&url)) + .map_err(|e| format!("TypeError: {e}"))?, + None => Url::parse(&url).map_err(|e| format!("TypeError: {e}"))?, + }; + Ok(CacheRequest { + url: resolved.serialize(), + method: method.to_ascii_uppercase(), + headers, + }) +} + +fn build_response_from_arg( + value: Option<&Value>, + ctx: &NativeContext, +) -> Result { + let value = match value { + Some(v) => v, + None => return Err("TypeError: response is required".to_string()), + }; + let obj_ref = match value { + Value::Object(r) => *r, + _ => return Err("TypeError: response must be a Response object".to_string()), + }; + let data = match ctx.gc.get(obj_ref) { + Some(HeapObject::Object(d)) => d, + _ => return Err("TypeError: response must be a Response object".to_string()), + }; + let status = data + .get_property("status", ctx.shapes) + .map(|p| match p.value { + Value::Number(n) => n as u16, + _ => 200, + }) + .unwrap_or(200); + let status_text = data + .get_property("statusText", ctx.shapes) + .map(|p| p.value.to_js_string(ctx.gc)) + .unwrap_or_default(); + let response_type = data + .get_property("type", ctx.shapes) + .map(|p| p.value.to_js_string(ctx.gc)) + .unwrap_or_else(|| "default".to_string()); + let response_url = data + .get_property("url", ctx.shapes) + .map(|p| p.value.to_js_string(ctx.gc)) + .unwrap_or_default(); + let redirected = data + .get_property("redirected", ctx.shapes) + .map(|p| matches!(p.value, Value::Boolean(true))) + .unwrap_or(false); + let mut headers: Vec<(String, String)> = Vec::new(); + if let Some(prop) = data.get_property("headers", ctx.shapes) { + if let Value::Object(h_ref) = prop.value { + if let Some(HeapObject::Object(h_data)) = ctx.gc.get(h_ref) { + for (key, hprop) in h_data.property_entries(ctx.shapes) { + if hprop.enumerable { + headers.push((key, hprop.value.to_js_string(ctx.gc))); + } + } + } + } + } + let body = match data.get_property("body", ctx.shapes).map(|p| p.value) { + Some(Value::String(s)) => s.into_bytes(), + Some(Value::Null) | Some(Value::Undefined) | None => Vec::new(), + Some(other) => other.to_js_string(ctx.gc).into_bytes(), + }; + Ok(CacheResponse { + status, + status_text, + headers, + body, + response_type, + url: response_url, + redirected, + }) +} + +fn parse_query_options(value: Option<&Value>, ctx: &NativeContext) -> CacheQueryOptions { + let mut opts = CacheQueryOptions::default(); + let obj_ref = match value { + Some(Value::Object(r)) => *r, + _ => return opts, + }; + if let Some(HeapObject::Object(data)) = ctx.gc.get(obj_ref) { + if let Some(prop) = data.get_property("ignoreSearch", ctx.shapes) { + opts.ignore_search = is_truthy(&prop.value); + } + if let Some(prop) = data.get_property("ignoreMethod", ctx.shapes) { + opts.ignore_method = is_truthy(&prop.value); + } + if let Some(prop) = data.get_property("ignoreVary", ctx.shapes) { + opts.ignore_vary = is_truthy(&prop.value); + } + } + opts +} + +fn is_truthy(v: &Value) -> bool { + match v { + Value::Boolean(b) => *b, + Value::Number(n) => *n != 0.0 && !n.is_nan(), + Value::String(s) => !s.is_empty(), + Value::Undefined | Value::Null => false, + Value::Object(_) | Value::Function(_) => true, + } +} + +// ── Cache.add helper: only data: URLs supported for now ───────────────────── + +fn fetch_for_cache_add(request: &CacheRequest) -> Result { + if request.method != "GET" { + return Err(format!( + "TypeError: only GET requests can be added to a cache (got {})", + request.method + )); + } + let url = Url::parse(&request.url).map_err(|e| format!("TypeError: {e}"))?; + match url.scheme() { + "data" => { + let parsed = parse_data_url(&request.url) + .map_err(|e| format!("TypeError: data URL: {e}"))?; + let content_type = if parsed.mime_type.is_empty() { + "text/plain".to_string() + } else { + parsed.mime_type.clone() + }; + Ok(CacheResponse { + status: 200, + status_text: "OK".to_string(), + headers: vec![("Content-Type".to_string(), content_type)], + body: parsed.data, + response_type: "basic".to_string(), + url: request.url.clone(), + redirected: false, + }) + } + other => Err(format!( + "NetworkError: Cache.add for {other}: URLs requires a fetch implementation in this context" + )), + } +} + +// ── JS object construction ────────────────────────────────────────────────── + +fn build_request_object(ctx: &mut NativeContext, request: &CacheRequest) -> Value { + let mut data = ObjectData::new(); + data.insert_property( + "url".to_string(), + Property::builtin(Value::String(request.url.clone())), + ctx.shapes, + ); + data.insert_property( + "method".to_string(), + Property::builtin(Value::String(request.method.clone())), + ctx.shapes, + ); + let mut headers = ObjectData::new(); + for (name, value) in &request.headers { + headers.insert_property( + name.clone(), + Property::builtin(Value::String(value.clone())), + ctx.shapes, + ); + } + let headers_ref = ctx.gc.alloc(HeapObject::Object(headers)); + data.insert_property( + "headers".to_string(), + Property::builtin(Value::Object(headers_ref)), + ctx.shapes, + ); + Value::Object(ctx.gc.alloc(HeapObject::Object(data))) +} + +fn build_response_object(ctx: &mut NativeContext, response: &CacheResponse) -> Value { + let mut data = ObjectData::new(); + data.insert_property( + "status".to_string(), + Property::builtin(Value::Number(response.status as f64)), + ctx.shapes, + ); + data.insert_property( + "statusText".to_string(), + Property::builtin(Value::String(response.status_text.clone())), + ctx.shapes, + ); + data.insert_property( + "ok".to_string(), + Property::builtin(Value::Boolean( + response.status >= 200 && response.status < 300, + )), + ctx.shapes, + ); + data.insert_property( + "url".to_string(), + Property::builtin(Value::String(response.url.clone())), + ctx.shapes, + ); + data.insert_property( + "type".to_string(), + Property::builtin(Value::String(response.response_type.clone())), + ctx.shapes, + ); + data.insert_property( + "redirected".to_string(), + Property::builtin(Value::Boolean(response.redirected)), + ctx.shapes, + ); + let body_string = String::from_utf8_lossy(&response.body).into_owned(); + data.insert_property( + "body".to_string(), + Property::builtin(Value::String(body_string)), + ctx.shapes, + ); + let mut headers = ObjectData::new(); + for (name, value) in &response.headers { + headers.insert_property( + name.clone(), + Property::builtin(Value::String(value.clone())), + ctx.shapes, + ); + } + let headers_ref = ctx.gc.alloc(HeapObject::Object(headers)); + data.insert_property( + "headers".to_string(), + Property::builtin(Value::Object(headers_ref)), + ctx.shapes, + ); + Value::Object(ctx.gc.alloc(HeapObject::Object(data))) +} + +fn build_array(ctx: &mut NativeContext, items: Vec) -> Value { + let mut data = ObjectData::new(); + for (idx, val) in items.iter().enumerate() { + data.insert_property(idx.to_string(), Property::data(val.clone()), ctx.shapes); + } + data.insert_property( + "length".to_string(), + Property::builtin(Value::Number(items.len() as f64)), + ctx.shapes, + ); + Value::Object(ctx.gc.alloc(HeapObject::Object(data))) +} + +fn read_array_values(ctx: &NativeContext, array_ref: GcRef) -> Vec { + let data = match ctx.gc.get(array_ref) { + Some(HeapObject::Object(d)) => d, + _ => return Vec::new(), + }; + let length = data + .get_property("length", ctx.shapes) + .map(|p| match p.value { + Value::Number(n) => n as usize, + _ => 0, + }) + .unwrap_or(0); + let mut out = Vec::with_capacity(length); + for i in 0..length { + if let Some(prop) = data.get_property(&i.to_string(), ctx.shapes) { + out.push(prop.value); + } + } + out +} + +fn current_origin() -> Option { + ORIGIN.with(|o| o.borrow().clone()) +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::{compiler, parser}; + + fn setup_vm() -> Vm { + reset_cache_storage(); + let origin = Url::parse("https://example.com/").unwrap().origin(); + set_cache_origin(origin); + set_cache_base_url(Url::parse("https://example.com/app/").unwrap()); + let mut vm = Vm::new(); + init_cache_api(&mut vm); + vm + } + + fn run(vm: &mut Vm, source: &str) { + let ast = parser::Parser::parse(source).expect("parse"); + let func = compiler::compile(&ast).expect("compile"); + vm.execute(&func).expect("execute"); + vm.pump_event_loop().expect("event loop"); + } + + fn result_string(vm: &Vm) -> String { + vm.get_global("result") + .cloned() + .unwrap_or(Value::Undefined) + .to_js_string(&vm.gc) + } + + #[test] + fn caches_open_creates_a_cache() { + let mut vm = setup_vm(); + run( + &mut vm, + r#" + result = ""; + caches.open("v1").then(function(cache) { + result = typeof cache.put + ":" + typeof cache.match; + }); + "#, + ); + assert_eq!(result_string(&vm), "function:function"); + } + + #[test] + fn caches_has_keys_delete_round_trip() { + let mut vm = setup_vm(); + run( + &mut vm, + r#" + result = ""; + caches.open("v1").then(function() { + return caches.open("v2"); + }).then(function() { + return caches.keys(); + }).then(function(names) { + return caches.has("v1").then(function(h1) { + return caches.has("v3").then(function(h3) { + result = names[0] + "," + names[1] + ";" + h1 + "," + h3; + }); + }); + }); + "#, + ); + assert_eq!(result_string(&vm), "v1,v2;true,false"); + + run( + &mut vm, + r#" + caches.delete("v1").then(function(was) { + return caches.keys().then(function(names) { + result = was + ";" + names.length + ";" + names[0]; + }); + }); + "#, + ); + assert_eq!(result_string(&vm), "true;1;v2"); + } + + #[test] + fn cache_put_and_match_round_trip_via_js() { + let mut vm = setup_vm(); + run( + &mut vm, + r#" + result = ""; + caches.open("v1").then(function(cache) { + cache.put( + new Request("https://example.com/data"), + new Response("hello, cache", { status: 200, statusText: "OK" }) + ).then(function() { + cache.match("https://example.com/data").then(function(response) { + result = response.status + ":" + response.body; + }); + }); + }); + "#, + ); + assert_eq!(result_string(&vm), "200:hello, cache"); + } + + #[test] + fn cache_put_rejects_non_get_methods_from_js() { + let mut vm = setup_vm(); + run( + &mut vm, + r#" + result = ""; + caches.open("v1").then(function(cache) { + var req = new Request("https://example.com/data", { method: "POST" }); + var res = new Response("body"); + return cache.put(req, res).then( + function() { result = "resolved"; }, + function(err) { result = String(err); } + ); + }); + "#, + ); + let s = result_string(&vm); + assert!(s.contains("POST"), "got: {s}"); + } + + #[test] + fn cache_add_supports_data_urls_install_pattern() { + let mut vm = setup_vm(); + run( + &mut vm, + r#" + result = ""; + caches.open("install").then(function(cache) { + return cache.addAll([ + "data:text/plain,hello", + "data:text/plain,world" + ]).then(function() { + return cache.keys(); + }); + }).then(function(requests) { + result = requests.length + ":" + requests[0].url + ":" + requests[1].url; + }); + "#, + ); + assert_eq!( + result_string(&vm), + "2:data:text/plain,hello:data:text/plain,world" + ); + } + + #[test] + fn cache_match_supports_ignore_search() { + let mut vm = setup_vm(); + run( + &mut vm, + r#" + result = ""; + caches.open("v1").then(function(cache) { + return cache.put( + new Request("https://example.com/p?v=1"), + new Response("payload") + ).then(function() { + return cache.match("https://example.com/p?v=2", { ignoreSearch: true }); + }); + }).then(function(r) { + result = r ? r.body : "miss"; + }); + "#, + ); + assert_eq!(result_string(&vm), "payload"); + } + + #[test] + fn cache_keys_returns_stored_requests() { + let mut vm = setup_vm(); + run( + &mut vm, + r#" + result = ""; + caches.open("v1").then(function(cache) { + return cache.put(new Request("https://example.com/a"), new Response("1")) + .then(function() { + return cache.put(new Request("https://example.com/b"), new Response("2")); + }) + .then(function() { + return cache.keys(); + }); + }).then(function(keys) { + result = keys.length + ":" + keys[0].url + "," + keys[1].url; + }); + "#, + ); + assert_eq!( + result_string(&vm), + "2:https://example.com/a,https://example.com/b" + ); + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 96eee60..f28284d 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -4,6 +4,8 @@ pub mod ast; pub mod broadcast_channel; pub mod builtins; pub mod bytecode; +pub mod cache; +pub mod cache_api; pub mod compiler; pub mod compression_streams; pub mod dom_bridge; diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index 552c5a6..5cfbcbc 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -138,6 +138,27 @@ impl Parser { } } + /// Per ES5+, an "identifier name" allows any identifier OR any keyword. + /// Used for property access after `.` and similar positions where reserved + /// words are legal property names. + fn expect_identifier_name(&mut self) -> Result { + match self.peek_kind().clone() { + TokenKind::Identifier(name) => { + self.advance(); + Ok(name) + } + kind if is_keyword(&kind) => { + let name = format!("{kind}"); + self.advance(); + Ok(name) + } + _ => Err(self.error(format!( + "expected identifier name, found {:?}", + self.peek_kind() + ))), + } + } + fn start_span(&self) -> SourcePos { self.peek().span.start } @@ -1671,7 +1692,7 @@ impl Parser { TokenKind::Dot => { self.advance(); let prop_start = self.start_span(); - let name = self.expect_identifier()?; + let name = self.expect_identifier_name()?; expr = Expr { kind: ExprKind::Member { object: Box::new(expr), @@ -1746,7 +1767,7 @@ impl Parser { } _ => { let prop_start = self.start_span(); - let name = self.expect_identifier()?; + let name = self.expect_identifier_name()?; expr = Expr { kind: ExprKind::Member { object: Box::new(Expr { @@ -1822,7 +1843,7 @@ impl Parser { TokenKind::Dot => { self.advance(); let prop_start = self.start_span(); - let name = self.expect_identifier()?; + let name = self.expect_identifier_name()?; expr = Expr { kind: ExprKind::Member { object: Box::new(expr), diff --git a/crates/js/src/service_worker.rs b/crates/js/src/service_worker.rs index 7f8bd09..9fd2462 100644 --- a/crates/js/src/service_worker.rs +++ b/crates/js/src/service_worker.rs @@ -8,6 +8,7 @@ use crate::builtins::{ create_promise_object_pub, make_native, reject_promise_internal, resolve_promise_internal, set_builtin_prop, }; +use crate::cache::CacheStorage; use crate::vm::{HeapObject, NativeContext, ObjectData, Property, RuntimeError, Value, Vm}; use we_url::{Origin, Url}; @@ -462,79 +463,6 @@ impl RegistrationStore { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct CacheStorage { - entries: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CacheEntry { - pub origin: String, - pub cache_name: String, - pub request_url: String, - pub response_bytes: Vec, -} - -impl CacheStorage { - pub fn new() -> Self { - Self { - entries: Vec::new(), - } - } - - pub fn put(&mut self, origin: &Origin, cache_name: &str, request_url: &Url, bytes: Vec) { - let origin = origin.serialize(); - let request_url = request_url.serialize(); - if let Some(entry) = self.entries.iter_mut().find(|entry| { - entry.origin == origin - && entry.cache_name == cache_name - && entry.request_url == request_url - }) { - entry.response_bytes = bytes; - return; - } - self.entries.push(CacheEntry { - origin, - cache_name: cache_name.to_string(), - request_url, - response_bytes: bytes, - }); - } - - pub fn match_request( - &self, - origin: &Origin, - cache_name: &str, - request_url: &Url, - ) -> Option<&[u8]> { - let origin = origin.serialize(); - let request_url = request_url.serialize(); - self.entries - .iter() - .find(|entry| { - entry.origin == origin - && entry.cache_name == cache_name - && entry.request_url == request_url - }) - .map(|entry| entry.response_bytes.as_slice()) - } - - pub fn clear_origin(&mut self, origin: &Origin) -> bool { - let origin = origin.serialize(); - let before = self.entries.len(); - self.entries.retain(|entry| entry.origin != origin); - self.entries.len() != before - } - - pub fn delete_cache(&mut self, origin: &Origin, cache_name: &str) -> bool { - let origin = origin.serialize(); - let before = self.entries.len(); - self.entries - .retain(|entry| !(entry.origin == origin && entry.cache_name == cache_name)); - self.entries.len() != before - } -} - fn validate_document_context(url: &Url, options: RegistrationOptions) -> Result<(), String> { match url.scheme() { "https" => Ok(()), @@ -1340,18 +1268,18 @@ mod tests { let beta_doc = Url::parse("https://beta.test/app/page.html").unwrap(); let request = Url::parse("https://alpha.test/app/data.json").unwrap(); - caches.put(&alpha_doc.origin(), "v1", &request, b"alpha".to_vec()); + caches.put_bytes(&alpha_doc.origin(), "v1", &request, b"alpha".to_vec()); assert_eq!( - caches.match_request(&alpha_doc.origin(), "v1", &request), - Some(&b"alpha"[..]) + caches.match_request_bytes(&alpha_doc.origin(), "v1", &request), + Some(b"alpha".to_vec()) ); assert!(caches - .match_request(&beta_doc.origin(), "v1", &request) + .match_request_bytes(&beta_doc.origin(), "v1", &request) .is_none()); assert!(caches.clear_origin(&alpha_doc.origin())); assert!(caches - .match_request(&alpha_doc.origin(), "v1", &request) + .match_request_bytes(&alpha_doc.origin(), "v1", &request) .is_none()); } @@ -1365,11 +1293,11 @@ mod tests { store .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); - caches.put(&base.origin(), "v1", &request, b"cached".to_vec()); + caches.put_bytes(&base.origin(), "v1", &request, b"cached".to_vec()); assert!(store.unregister_with_cache_cleanup(&base.origin(), scope, &mut caches)); assert!(caches - .match_request(&base.origin(), "v1", &request) + .match_request_bytes(&base.origin(), "v1", &request) .is_none()); store @@ -1378,10 +1306,10 @@ mod tests { store .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) .unwrap(); - caches.put(&base.origin(), "v2", &request, b"cached".to_vec()); + caches.put_bytes(&base.origin(), "v2", &request, b"cached".to_vec()); assert!(store.activate_waiting_with_cache_cleanup(&base.origin(), scope, &mut caches)); assert!(caches - .match_request(&base.origin(), "v2", &request) + .match_request_bytes(&base.origin(), "v2", &request) .is_none()); } diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index ce34357..e4f63f7 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -1235,6 +1235,7 @@ impl Vm { crate::dom_bridge::init_image_data_api(self); crate::indexeddb::init_indexeddb(self); crate::service_worker::init_service_worker_api(self); + crate::cache_api::init_cache_api(self); } /// Set the document origin for Same-Origin Policy enforcement.