//! 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" ); } }