//! Service Worker registration store and `navigator.serviceWorker` bindings. //! //! This phase records registrations, scope matching, unregister semantics, and //! update detection metadata. Worker execution and fetch interception are added //! by later Phase 21 issues. use std::time::{Duration, Instant}; use crate::builtins::{ create_promise_object_pub, make_native, reject_promise_internal, resolve_promise_internal, set_builtin_prop, }; use crate::cache::CacheStorage; use crate::gc::{Gc, GcRef}; use crate::service_worker_global::{ LifecycleSummary, ServiceWorkerCommand, ServiceWorkerEvent, ServiceWorkerManager, ServiceWorkerSnapshot, ServiceWorkerThread, }; use crate::shape::ShapeTable; use crate::structured_clone::{self, SerializedData}; use crate::vm::{HeapObject, NativeContext, ObjectData, Property, RuntimeError, Value, Vm}; use we_url::{Origin, Url}; const ERR_INSECURE_CONTEXT: &str = "SecurityError: service workers require a secure document context"; const ERR_UNSUPPORTED_SCHEME: &str = "SecurityError: service workers require http or https URLs"; const ERR_FILE_SCHEME_DISABLED: &str = "SecurityError: file URL service workers are disabled"; const ERR_CROSS_ORIGIN_SCRIPT: &str = "SecurityError: service worker script must be same-origin"; const ERR_CROSS_ORIGIN_SCOPE: &str = "SecurityError: service worker scope must be same-origin"; const ERR_SCOPE_OUTSIDE_SCRIPT_DIRECTORY: &str = "SecurityError: service worker scope must be at or below the script directory"; const SW_CONTAINER_MESSAGE_LISTENERS_KEY: &str = "__sw_message_listeners__"; const SW_CONTAINER_CONTROLLERCHANGE_LISTENERS_KEY: &str = "__sw_controllerchange_listeners__"; const SW_CONTROLLER_ORIGIN_KEY: &str = "__sw_controller_origin__"; const SW_CONTROLLER_SCOPE_KEY: &str = "__sw_controller_scope__"; const SW_CONTROLLER_SCRIPT_KEY: &str = "__sw_controller_script__"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ServiceWorkerState { Installing, Waiting, Active, Redundant, } impl ServiceWorkerState { pub fn as_str(&self) -> &'static str { match self { Self::Installing => "installing", Self::Waiting => "waiting", Self::Active => "active", Self::Redundant => "redundant", } } fn from_str(s: &str) -> Option { match s { "installing" => Some(Self::Installing), "waiting" => Some(Self::Waiting), "active" => Some(Self::Active), "redundant" => Some(Self::Redundant), _ => None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RegistrationOptions { pub allow_file_urls_for_tests: bool, } impl RegistrationOptions { pub fn new() -> Self { Self { allow_file_urls_for_tests: false, } } pub fn allow_file_urls_for_tests() -> Self { Self { allow_file_urls_for_tests: true, } } } impl Default for RegistrationOptions { fn default() -> Self { Self::new() } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServiceWorkerSlot { pub script_url: String, pub state: ServiceWorkerState, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServiceWorkerScriptResource { pub url: String, pub bytes: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServiceWorkerClientRecord { pub id: String, pub origin: String, pub url: String, pub visibility_state: String, pub focused: bool, pub controller_scope_url: Option, } impl ServiceWorkerClientRecord { pub fn window( id: String, origin: String, url: String, controller_scope_url: Option, ) -> Self { Self { id, origin, url, visibility_state: "visible".to_string(), focused: true, controller_scope_url, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ServiceWorkerContainerEvent { Message { bytes: Vec, origin: String }, ControllerChange, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServiceWorkerRegistrationRecord { pub origin: String, pub script_url: String, pub scope_url: String, pub lifecycle_state: ServiceWorkerState, pub installing: Option, pub waiting: Option, pub active: Option, pub script_bytes: Vec, pub imported_scripts: Vec, pub waiting_script_bytes: Vec, pub waiting_imported_scripts: Vec, pub updatefound_count: u32, pub controlled_clients: u32, } impl ServiceWorkerRegistrationRecord { fn new( origin: String, script_url: String, scope_url: String, script_bytes: Vec, imported_scripts: Vec, ) -> Self { Self { origin, script_url: script_url.clone(), scope_url, lifecycle_state: ServiceWorkerState::Active, installing: None, waiting: None, active: Some(ServiceWorkerSlot { script_url, state: ServiceWorkerState::Active, }), script_bytes, imported_scripts, waiting_script_bytes: Vec::new(), waiting_imported_scripts: Vec::new(), updatefound_count: 0, controlled_clients: 0, } } pub fn script_changed( &self, next_bytes: &[u8], next_imports: &[ServiceWorkerScriptResource], ) -> bool { self.script_bytes.as_slice() != next_bytes || self.imported_scripts != next_imports } pub fn has_waiting_worker(&self) -> bool { self.waiting.is_some() } } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RegistrationStore { records: Vec, } impl RegistrationStore { pub fn new() -> Self { Self { records: Vec::new(), } } pub fn records(&self) -> &[ServiceWorkerRegistrationRecord] { &self.records } /// Mutable access to the underlying records. Used by the worker /// orchestration layer to flip lifecycle state in place after install / /// activate dispatch. pub fn records_mut(&mut self) -> &mut Vec { &mut self.records } pub fn register( &mut self, document_url: &Url, script: &str, scope: Option<&str>, script_bytes: Vec, ) -> Result { self.register_with_imports(document_url, script, scope, script_bytes, Vec::new()) } pub fn register_with_imports( &mut self, document_url: &Url, script: &str, scope: Option<&str>, script_bytes: Vec, imported_scripts: Vec<(&str, Vec)>, ) -> Result { self.register_with_options( document_url, script, scope, script_bytes, imported_scripts, RegistrationOptions::new(), ) } pub fn register_with_options( &mut self, document_url: &Url, script: &str, scope: Option<&str>, script_bytes: Vec, imported_scripts: Vec<(&str, Vec)>, options: RegistrationOptions, ) -> Result { validate_document_context(document_url, options)?; let script_url = resolve_supported_url(script, document_url, options)?; let document_origin = document_url.origin(); let script_origin = script_url.origin(); if !same_registration_origin( document_url, &document_origin, &script_url, &script_origin, options, ) { return Err(ERR_CROSS_ORIGIN_SCRIPT.to_string()); } let scope_url = match scope { Some(raw) => resolve_supported_url(raw, &script_url, options)?, None => default_scope_for_script(&script_url)?, }; let scope_origin = scope_url.origin(); if !same_registration_origin( document_url, &document_origin, &scope_url, &scope_origin, options, ) { return Err(ERR_CROSS_ORIGIN_SCOPE.to_string()); } if !scope_within_script_directory(&script_url, &scope_url) { return Err(ERR_SCOPE_OUTSIDE_SCRIPT_DIRECTORY.to_string()); } let origin = registration_origin_key(document_url, &document_origin, options); let script_serialized = script_url.serialize(); let scope_serialized = scope_url.serialize(); let imported_scripts = normalize_imported_scripts(&script_url, imported_scripts, options)?; let record = ServiceWorkerRegistrationRecord::new( origin.clone(), script_serialized.clone(), scope_serialized.clone(), script_bytes, imported_scripts.clone(), ); if let Some(existing) = self .records .iter_mut() .find(|r| r.origin == origin && r.scope_url == scope_serialized) { if existing.script_url == script_serialized && !existing.script_changed(&record.script_bytes, &imported_scripts) { return Ok(existing.clone()); } existing.installing = Some(ServiceWorkerSlot { script_url: script_serialized.clone(), state: ServiceWorkerState::Installing, }); existing.waiting = Some(ServiceWorkerSlot { script_url: script_serialized, state: ServiceWorkerState::Waiting, }); existing.lifecycle_state = ServiceWorkerState::Waiting; existing.waiting_script_bytes = record.script_bytes; existing.waiting_imported_scripts = imported_scripts; existing.installing = None; existing.updatefound_count = existing.updatefound_count.saturating_add(1); return Ok(existing.clone()); } self.records.push(record.clone()); self.records.sort_by(|a, b| { a.scope_url .cmp(&b.scope_url) .then(a.script_url.cmp(&b.script_url)) }); Ok(record) } pub fn get_registration( &self, origin: &Origin, client_url: &Url, ) -> Option<&ServiceWorkerRegistrationRecord> { let origin = origin.serialize(); let client = client_url.serialize(); self.records .iter() .filter(|r| r.origin == origin && client.starts_with(&r.scope_url)) .max_by_key(|r| r.scope_url.len()) } pub fn registrations_for_origin( &self, origin: &Origin, ) -> Vec<&ServiceWorkerRegistrationRecord> { let origin = origin.serialize(); self.records.iter().filter(|r| r.origin == origin).collect() } pub fn unregister(&mut self, origin: &Origin, scope_url: &str) -> bool { let origin = origin.serialize(); self.unregister_by_origin_key(&origin, scope_url) } pub fn unregister_by_origin_key(&mut self, origin: &str, scope_url: &str) -> bool { let before = self.records.len(); self.records .retain(|r| !(r.origin == origin && r.scope_url == scope_url)); self.records.len() != before } pub fn unregister_with_cache_cleanup( &mut self, origin: &Origin, scope_url: &str, caches: &mut CacheStorage, ) -> bool { let removed = self.unregister(origin, scope_url); if removed { caches.clear_origin(origin); } removed } pub fn add_controlled_client(&mut self, origin: &Origin, scope_url: &str) -> bool { let origin = origin.serialize(); if let Some(record) = self .records .iter_mut() .find(|r| r.origin == origin && r.scope_url == scope_url) { record.controlled_clients = record.controlled_clients.saturating_add(1); true } else { false } } pub fn release_controlled_client(&mut self, origin: &Origin, scope_url: &str) -> bool { let origin = origin.serialize(); let Some(record) = self .records .iter_mut() .find(|r| r.origin == origin && r.scope_url == scope_url) else { return false; }; record.controlled_clients = record.controlled_clients.saturating_sub(1); if record.controlled_clients == 0 && record.waiting.is_some() { promote_waiting(record); } true } pub fn skip_waiting(&mut self, origin: &Origin, scope_url: &str) -> bool { let origin = origin.serialize(); let Some(record) = self .records .iter_mut() .find(|r| r.origin == origin && r.scope_url == scope_url) else { return false; }; promote_waiting(record) } pub fn activate_waiting_with_cache_cleanup( &mut self, origin: &Origin, scope_url: &str, caches: &mut CacheStorage, ) -> bool { let activated = self.skip_waiting(origin, scope_url); if activated { caches.clear_origin(origin); } activated } pub fn clear_origin(&mut self, origin: &Origin) -> bool { self.clear_origin_key(&origin.serialize()) } pub fn clear_origin_key(&mut self, origin: &str) -> bool { let before = self.records.len(); self.records.retain(|r| r.origin != origin); self.records.len() != before } pub fn serialize(&self) -> Vec { let mut out = Vec::new(); out.extend_from_slice(b"WESW2\0"); write_u32(&mut out, self.records.len() as u32); for record in &self.records { write_string(&mut out, &record.origin); write_string(&mut out, &record.script_url); write_string(&mut out, &record.scope_url); write_string(&mut out, record.lifecycle_state.as_str()); write_slot(&mut out, &record.installing); write_slot(&mut out, &record.waiting); write_slot(&mut out, &record.active); write_bytes(&mut out, &record.script_bytes); write_resources(&mut out, &record.imported_scripts); write_bytes(&mut out, &record.waiting_script_bytes); write_resources(&mut out, &record.waiting_imported_scripts); write_u32(&mut out, record.updatefound_count); write_u32(&mut out, record.controlled_clients); } out } pub fn deserialize(data: &[u8]) -> Option { if !data.starts_with(b"WESW2\0") { return None; } let mut cursor = Cursor { data, pos: 6 }; let count = cursor.read_u32()? as usize; let mut records = Vec::with_capacity(count); for _ in 0..count { let origin = cursor.read_string()?; let script_url = cursor.read_string()?; let scope_url = cursor.read_string()?; let lifecycle_state = ServiceWorkerState::from_str(&cursor.read_string()?)?; let installing = cursor.read_slot()?; let waiting = cursor.read_slot()?; let active = cursor.read_slot()?; let script_bytes = cursor.read_bytes()?; let imported_scripts = cursor.read_resources()?; let waiting_script_bytes = cursor.read_bytes()?; let waiting_imported_scripts = cursor.read_resources()?; let updatefound_count = cursor.read_u32()?; let controlled_clients = cursor.read_u32()?; records.push(ServiceWorkerRegistrationRecord { origin, script_url, scope_url, lifecycle_state, installing, waiting, active, script_bytes, imported_scripts, waiting_script_bytes, waiting_imported_scripts, updatefound_count, controlled_clients, }); } if cursor.pos == data.len() { Some(Self { records }) } else { None } } } fn validate_document_context(url: &Url, options: RegistrationOptions) -> Result<(), String> { match url.scheme() { "https" => Ok(()), "http" if is_loopback_or_localhost(url) => Ok(()), "file" if options.allow_file_urls_for_tests => Ok(()), "file" => Err(ERR_FILE_SCHEME_DISABLED.to_string()), "http" => Err(ERR_INSECURE_CONTEXT.to_string()), _ => Err(ERR_UNSUPPORTED_SCHEME.to_string()), } } fn resolve_supported_url( input: &str, base: &Url, options: RegistrationOptions, ) -> Result { let url = Url::parse_with_base(input, base).map_err(|e| format!("TypeError: {e}"))?; match url.scheme() { "http" | "https" => Ok(url), "file" if options.allow_file_urls_for_tests => Ok(url), "file" => Err(ERR_FILE_SCHEME_DISABLED.to_string()), _ => Err(ERR_UNSUPPORTED_SCHEME.to_string()), } } fn default_scope_for_script(script_url: &Url) -> Result { let path = script_url.path(); let slash = path.rfind('/').unwrap_or(0); let scope_path = &path[..=slash]; Url::parse_with_base(scope_path, script_url).map_err(|e| format!("TypeError: {e}")) } fn same_registration_origin( document_url: &Url, document_origin: &Origin, target_url: &Url, target_origin: &Origin, options: RegistrationOptions, ) -> bool { if options.allow_file_urls_for_tests && document_url.scheme() == "file" && target_url.scheme() == "file" { return true; } document_origin.same_origin(target_origin) } fn normalize_imported_scripts( script_url: &Url, imported_scripts: Vec<(&str, Vec)>, options: RegistrationOptions, ) -> Result, String> { let mut resources = Vec::with_capacity(imported_scripts.len()); for (url, bytes) in imported_scripts { let url = resolve_supported_url(url, script_url, options)?; resources.push(ServiceWorkerScriptResource { url: url.serialize(), bytes, }); } resources.sort_by(|a, b| a.url.cmp(&b.url)); Ok(resources) } fn promote_waiting(record: &mut ServiceWorkerRegistrationRecord) -> bool { let Some(waiting) = record.waiting.take() else { return false; }; record.active = Some(ServiceWorkerSlot { script_url: waiting.script_url.clone(), state: ServiceWorkerState::Active, }); record.script_url = waiting.script_url; record.script_bytes = std::mem::take(&mut record.waiting_script_bytes); record.imported_scripts = std::mem::take(&mut record.waiting_imported_scripts); record.installing = None; record.lifecycle_state = ServiceWorkerState::Active; true } fn registration_origin_key( document_url: &Url, document_origin: &Origin, options: RegistrationOptions, ) -> String { if options.allow_file_urls_for_tests && document_url.scheme() == "file" { document_url.serialize() } else { document_origin.serialize() } } fn script_directory_path(script_url: &Url) -> String { let path = script_url.path(); let slash = path.rfind('/').unwrap_or(0); path[..=slash].to_string() } fn scope_within_script_directory(script_url: &Url, scope_url: &Url) -> bool { scope_url .path() .starts_with(&script_directory_path(script_url)) } fn is_loopback_or_localhost(url: &Url) -> bool { matches!( url.host_str().as_deref(), Some("localhost") | Some("127.0.0.1") | Some("[::1]") ) } fn script_bytes_for_url(url: &Url) -> Vec { let serialized = url.serialize(); if let Some(bytes) = SCRIPT_BODY_RESOLVER.with(|cell| { cell.borrow() .as_ref() .and_then(|resolver| resolver(&serialized)) }) { return bytes; } // No resolver wired up (e.g. unit tests that just exercise registration // bookkeeping): keep recording the URL as a placeholder so update // detection still sees byte-changes when the URL changes. serialized.into_bytes() } type ScriptBodyResolver = Box Option>>; thread_local! { static SCRIPT_BODY_RESOLVER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } /// Install a resolver that converts a service-worker script URL into the /// raw bytes the browser already fetched/decoded. Wiring this up lets /// `navigator.serviceWorker.register(url)` persist real script bytes the /// routing/execution layer can evaluate later. /// /// Resolvers are per-thread because each VM evaluates SW registrations on /// the thread that holds it. pub fn set_script_body_resolver(resolver: F) where F: Fn(&str) -> Option> + 'static, { SCRIPT_BODY_RESOLVER.with(|cell| { *cell.borrow_mut() = Some(Box::new(resolver)); }); } /// Remove any installed script-body resolver. Call when tearing down a /// page so the next page cannot accidentally observe a stale resolver. pub fn clear_script_body_resolver() { SCRIPT_BODY_RESOLVER.with(|cell| { cell.borrow_mut().take(); }); } pub fn client_id_for_url(url: &Url) -> String { format!("window:{}", url.serialize()) } 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) } fn current_document_url(ctx: &NativeContext) -> Result { ctx.dom_bridge .and_then(|b| b.document_url.borrow().clone()) .ok_or_else(|| "InvalidStateError: no document URL is available".to_string()) } fn option_scope_arg(args: &[Value], ctx: &NativeContext) -> Option { let opts_ref = args.get(1)?.gc_ref()?; let obj = match ctx.gc.get(opts_ref) { Some(HeapObject::Object(data)) => data, _ => return None, }; obj.get_property("scope", ctx.shapes) .map(|p| p.value.to_js_string(ctx.gc)) } fn make_slot_value(ctx: &mut NativeContext, slot: &Option) -> Value { let Some(slot) = slot else { return Value::Null; }; let mut data = ObjectData::new(); data.insert_property( "scriptURL".to_string(), Property::builtin(Value::String(slot.script_url.clone())), ctx.shapes, ); data.insert_property( "state".to_string(), Property::builtin(Value::String(slot.state.as_str().to_string())), ctx.shapes, ); Value::Object(ctx.gc.alloc(HeapObject::Object(data))) } fn make_registration_object( ctx: &mut NativeContext, record: &ServiceWorkerRegistrationRecord, ) -> Value { let mut data = ObjectData::new(); data.insert_property( "scope".to_string(), Property::builtin(Value::String(record.scope_url.clone())), ctx.shapes, ); data.insert_property( "scriptURL".to_string(), Property::builtin(Value::String(record.script_url.clone())), ctx.shapes, ); data.insert_property( "installing".to_string(), Property::builtin(make_slot_value(ctx, &record.installing)), ctx.shapes, ); data.insert_property( "waiting".to_string(), Property::builtin(make_slot_value(ctx, &record.waiting)), ctx.shapes, ); data.insert_property( "active".to_string(), Property::builtin(make_slot_value(ctx, &record.active)), ctx.shapes, ); data.insert_property( "updatefoundCount".to_string(), Property::builtin(Value::Number(record.updatefound_count as f64)), ctx.shapes, ); let unregister = make_native(ctx.gc, "unregister", sw_registration_unregister); data.insert_property( "unregister".to_string(), Property::builtin(Value::Function(unregister)), ctx.shapes, ); let skip_waiting = make_native(ctx.gc, "skipWaiting", sw_registration_skip_waiting); data.insert_property( "skipWaiting".to_string(), Property::builtin(Value::Function(skip_waiting)), ctx.shapes, ); Value::Object(ctx.gc.alloc(HeapObject::Object(data))) } fn make_registration_array( ctx: &mut NativeContext, records: &[ServiceWorkerRegistrationRecord], ) -> Value { let mut data = ObjectData::new(); for (idx, record) in records.iter().enumerate() { data.insert_property( idx.to_string(), Property::data(make_registration_object(ctx, record)), ctx.shapes, ); } data.insert_property( "length".to_string(), Property::builtin(Value::Number(records.len() as f64)), ctx.shapes, ); Value::Object(ctx.gc.alloc(HeapObject::Object(data))) } fn make_empty_array(shapes: &mut ShapeTable) -> ObjectData { let mut data = ObjectData::new(); data.insert_property( "length".to_string(), Property::builtin(Value::Number(0.0)), shapes, ); data } fn append_to_array(arr_ref: GcRef, val: Value, ctx: &mut NativeContext) { if let Some(HeapObject::Object(data)) = ctx.gc.get_mut(arr_ref) { let len = data .get_property("length", ctx.shapes) .map(|p| p.value.to_number() as usize) .unwrap_or(0); data.insert_property(len.to_string(), Property::data(val), ctx.shapes); data.insert_property( "length".to_string(), Property::builtin(Value::Number((len + 1) as f64)), ctx.shapes, ); } } fn remove_from_array(arr_ref: GcRef, target: GcRef, ctx: &mut NativeContext) { if let Some(HeapObject::Object(data)) = ctx.gc.get_mut(arr_ref) { let len = data .get_property("length", ctx.shapes) .map(|p| p.value.to_number() as usize) .unwrap_or(0); let mut kept = Vec::with_capacity(len); for i in 0..len { if let Some(prop) = data.get_property(&i.to_string(), ctx.shapes) { if !matches!(prop.value, Value::Function(r) if r == target) { kept.push(prop.value); } } } for i in 0..len { data.insert_property(i.to_string(), Property::data(Value::Undefined), ctx.shapes); } let new_len = kept.len(); for (idx, value) in kept.into_iter().enumerate() { data.insert_property(idx.to_string(), Property::data(value), ctx.shapes); } data.insert_property( "length".to_string(), Property::builtin(Value::Number(new_len as f64)), ctx.shapes, ); } } fn collect_functions(vm: &Vm, arr_ref: GcRef) -> Vec { let mut out = Vec::new(); if let Some(HeapObject::Object(data)) = vm.gc.get(arr_ref) { let len = data .get_property("length", &vm.shapes) .map(|p| p.value.to_number() as usize) .unwrap_or(0); for i in 0..len { if let Some(prop) = data.get_property(&i.to_string(), &vm.shapes) { if let Value::Function(r) = prop.value { out.push(r); } } } } out } fn get_string_prop(ctx: &NativeContext, obj_ref: crate::gc::GcRef, name: &str) -> Option { match ctx.gc.get(obj_ref) { Some(HeapObject::Object(data)) => data .get_property(name, ctx.shapes) .map(|p| p.value.to_js_string(ctx.gc)), _ => None, } } fn get_object_prop(ctx: &NativeContext, obj_ref: GcRef, key: &str) -> Option { match ctx.gc.get(obj_ref) { Some(HeapObject::Object(data)) => data.get_property(key, ctx.shapes).map(|p| p.value), _ => None, } } fn current_client_record( bridge: &crate::vm::DomBridge, controller_scope_url: Option, ) -> Option { let url = bridge.document_url.borrow().clone()?; let id = { let existing = bridge.service_worker_client_id.borrow().clone(); if existing.is_empty() { client_id_for_url(&url) } else { existing } }; Some(ServiceWorkerClientRecord::window( id, url.origin().serialize(), url.serialize(), controller_scope_url, )) } fn active_controller_record( bridge: &crate::vm::DomBridge, ) -> Option { let url = bridge.document_url.borrow().clone()?; let origin = url.origin(); bridge .service_workers .borrow() .get_registration(&origin, &url) .filter(|record| record.active.is_some()) .cloned() } fn build_controller_object( gc: &mut Gc, shapes: &mut ShapeTable, record: &ServiceWorkerRegistrationRecord, ) -> GcRef { let mut data = ObjectData::new(); data.insert_property( "scriptURL".to_string(), Property::builtin(Value::String(record.script_url.clone())), shapes, ); data.insert_property( "state".to_string(), Property::builtin(Value::String( record .active .as_ref() .map(|slot| slot.state.as_str()) .unwrap_or("active") .to_string(), )), shapes, ); data.insert_property( SW_CONTROLLER_ORIGIN_KEY.to_string(), Property::builtin(Value::String(record.origin.clone())), shapes, ); data.insert_property( SW_CONTROLLER_SCOPE_KEY.to_string(), Property::builtin(Value::String(record.scope_url.clone())), shapes, ); data.insert_property( SW_CONTROLLER_SCRIPT_KEY.to_string(), Property::builtin(Value::String(record.script_url.clone())), shapes, ); let post_message = make_native(gc, "postMessage", sw_controller_post_message); data.insert_property( "postMessage".to_string(), Property::builtin(Value::Function(post_message)), shapes, ); gc.alloc(HeapObject::Object(data)) } fn set_container_controller_ctx( ctx: &mut NativeContext, record: Option<&ServiceWorkerRegistrationRecord>, ) { let Value::Object(container_ref) = ctx.this else { return; }; let value = match record { Some(record) => Value::Object(build_controller_object(ctx.gc, ctx.shapes, record)), None => Value::Null, }; set_builtin_prop(ctx.gc, ctx.shapes, container_ref, "controller", value); } fn queue_container_event(bridge: &crate::vm::DomBridge, event: ServiceWorkerContainerEvent) { bridge.service_worker_events.borrow_mut().push(event); } fn wait_for_worker_script(thread: &mut ServiceWorkerThread) -> bool { let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline { match thread.recv_timeout(Duration::from_millis(20)) { Some(ServiceWorkerEvent::ScriptLoaded) => return true, Some(ServiceWorkerEvent::Error(_)) => return false, Some(_) => {} None => {} } } false } fn enqueue_worker_events_for_client( thread: &mut ServiceWorkerThread, bridge: &crate::vm::DomBridge, client_id: &str, origin: &str, timeout: Duration, ) { let deadline = Instant::now() + timeout; let mut saw_event = false; while Instant::now() < deadline { match thread.recv_timeout(Duration::from_millis(20)) { Some(ServiceWorkerEvent::MessageOut(bytes)) => { saw_event = true; queue_container_event( bridge, ServiceWorkerContainerEvent::Message { bytes, origin: origin.to_string(), }, ); } Some(ServiceWorkerEvent::ClientMessageOut { client_id: id, bytes, }) if id == client_id => { saw_event = true; queue_container_event( bridge, ServiceWorkerContainerEvent::Message { bytes, origin: origin.to_string(), }, ); } Some(_) => saw_event = true, None if saw_event => break, None => {} } } } fn run_lifecycle_for_registration( ctx: &mut NativeContext, record: &ServiceWorkerRegistrationRecord, ) -> Option { let script = String::from_utf8(record.script_bytes.clone()).ok()?; if script == record.script_url { return None; } let bridge = ctx.dom_bridge?; let client = current_client_record( bridge, bridge.service_worker_controller_scope.borrow().clone(), ); let clients = client.into_iter().collect::>(); let mut manager = ServiceWorkerManager::new(); let summary = manager.start_from_registration_with_clients( &mut bridge.service_workers.borrow_mut(), record, script, clients, ); for bytes in &summary.messages_out { queue_container_event( bridge, ServiceWorkerContainerEvent::Message { bytes: bytes.clone(), origin: record.origin.clone(), }, ); } for (client_id, bytes) in &summary.client_messages_out { if current_client_record(bridge, None) .map(|client| client.id == *client_id) .unwrap_or(false) { queue_container_event( bridge, ServiceWorkerContainerEvent::Message { bytes: bytes.clone(), origin: record.origin.clone(), }, ); } } Some(summary) } fn sw_container_add_event_listener( args: &[Value], ctx: &mut NativeContext, ) -> Result { let this_ref = match ctx.this { Value::Object(r) => r, _ => return Ok(Value::Undefined), }; let event_type = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let callback = match args.get(1) { Some(Value::Function(r)) => *r, _ => return Ok(Value::Undefined), }; let listeners_key = match event_type.as_str() { "message" => SW_CONTAINER_MESSAGE_LISTENERS_KEY, "controllerchange" => SW_CONTAINER_CONTROLLERCHANGE_LISTENERS_KEY, _ => return Ok(Value::Undefined), }; let listeners_ref = match get_object_prop(ctx, this_ref, listeners_key) { Some(Value::Object(r)) => r, _ => return Ok(Value::Undefined), }; append_to_array(listeners_ref, Value::Function(callback), ctx); Ok(Value::Undefined) } fn sw_container_remove_event_listener( args: &[Value], ctx: &mut NativeContext, ) -> Result { let this_ref = match ctx.this { Value::Object(r) => r, _ => return Ok(Value::Undefined), }; let event_type = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); let callback = match args.get(1) { Some(Value::Function(r)) => *r, _ => return Ok(Value::Undefined), }; let listeners_key = match event_type.as_str() { "message" => SW_CONTAINER_MESSAGE_LISTENERS_KEY, "controllerchange" => SW_CONTAINER_CONTROLLERCHANGE_LISTENERS_KEY, _ => return Ok(Value::Undefined), }; let listeners_ref = match get_object_prop(ctx, this_ref, listeners_key) { Some(Value::Object(r)) => r, _ => return Ok(Value::Undefined), }; remove_from_array(listeners_ref, callback, ctx); Ok(Value::Undefined) } fn sw_controller_post_message( args: &[Value], ctx: &mut NativeContext, ) -> Result { let controller_ref = match ctx.this { Value::Object(r) => r, _ => { return Err(RuntimeError::type_error( "ServiceWorker.postMessage called on a non-ServiceWorker object", )) } }; let origin = get_string_prop(ctx, controller_ref, SW_CONTROLLER_ORIGIN_KEY) .ok_or_else(|| RuntimeError::type_error("missing service worker origin"))?; let scope = get_string_prop(ctx, controller_ref, SW_CONTROLLER_SCOPE_KEY) .ok_or_else(|| RuntimeError::type_error("missing service worker scope"))?; let data = args.first().cloned().unwrap_or(Value::Undefined); let serialized = structured_clone::serialize(&data, ctx.gc, ctx.shapes) .map_err(|e| RuntimeError::type_error(e.to_string()))?; let bridge = ctx .dom_bridge .ok_or_else(|| RuntimeError::type_error("no document attached"))?; let Some(client) = current_client_record(bridge, Some(scope.clone())) else { return Ok(Value::Undefined); }; let record = { let store = bridge.service_workers.borrow(); store .records() .iter() .find(|r| r.origin == origin && r.scope_url == scope && r.active.is_some()) .cloned() }; let Some(record) = record else { return Ok(Value::Undefined); }; let script = match String::from_utf8(record.script_bytes.clone()) { Ok(script) if script != record.script_url => script, _ => return Ok(Value::Undefined), }; let snapshot = ServiceWorkerSnapshot::from_record(&record); let mut thread = ServiceWorkerThread::spawn_with_clients(script, snapshot, vec![client.clone()]); if wait_for_worker_script(&mut thread) { let _ = thread.send(ServiceWorkerCommand::MessageFromClient { client_id: client.id.clone(), bytes: serialized.into_bytes(), }); enqueue_worker_events_for_client( &mut thread, bridge, &client.id, &record.origin, Duration::from_secs(2), ); } thread.terminate(); Ok(Value::Undefined) } fn sw_register(args: &[Value], ctx: &mut NativeContext) -> Result { let script = args .first() .map(|v| v.to_js_string(ctx.gc)) .unwrap_or_default(); if script.is_empty() { return Ok(promise_rejected( ctx, "TypeError: register() requires a script URL".to_string(), )); } let scope = option_scope_arg(args, ctx); let document_url = match current_document_url(ctx) { Ok(url) => url, Err(e) => return Ok(promise_rejected(ctx, e)), }; let script_url = match resolve_supported_url(&script, &document_url, RegistrationOptions::new()) { Ok(url) => url, Err(e) => return Ok(promise_rejected(ctx, e)), }; let bytes = script_bytes_for_url(&script_url); let record = match ctx.dom_bridge { Some(bridge) => bridge.service_workers.borrow_mut().register( &document_url, &script, scope.as_deref(), bytes, ), None => Err("InvalidStateError: service workers need a document".to_string()), }; match record { Ok(record) => { let summary = run_lifecycle_for_registration(ctx, &record); if summary.as_ref().map(|s| s.claim_requested).unwrap_or(false) { if let Some(bridge) = ctx.dom_bridge { let previous = bridge.service_worker_controller_scope.borrow().clone(); if previous.as_deref() != Some(record.scope_url.as_str()) { *bridge.service_worker_controller_scope.borrow_mut() = Some(record.scope_url.clone()); let _ = bridge .service_workers .borrow_mut() .add_controlled_client(&document_url.origin(), &record.scope_url); queue_container_event( bridge, ServiceWorkerContainerEvent::ControllerChange, ); } } set_container_controller_ctx(ctx, Some(&record)); } let value = make_registration_object(ctx, &record); Ok(promise_resolved(ctx, value)) } Err(e) => Ok(promise_rejected(ctx, e)), } } fn sw_get_registration(args: &[Value], ctx: &mut NativeContext) -> Result { let document_url = match current_document_url(ctx) { Ok(url) => url, Err(e) => return Ok(promise_rejected(ctx, e)), }; let client_url = match args.first() { Some(v) if !matches!(v, Value::Undefined) => { match Url::parse_with_base(&v.to_js_string(ctx.gc), &document_url) { Ok(url) => url, Err(e) => return Ok(promise_rejected(ctx, format!("TypeError: {e}"))), } } _ => document_url.clone(), }; let origin = document_url.origin(); let record = ctx.dom_bridge.and_then(|bridge| { bridge .service_workers .borrow() .get_registration(&origin, &client_url) .cloned() }); let value = record .as_ref() .map(|r| make_registration_object(ctx, r)) .unwrap_or(Value::Undefined); Ok(promise_resolved(ctx, value)) } fn sw_get_registrations(_args: &[Value], ctx: &mut NativeContext) -> Result { let document_url = match current_document_url(ctx) { Ok(url) => url, Err(e) => return Ok(promise_rejected(ctx, e)), }; let origin = document_url.origin(); let records = ctx .dom_bridge .map(|bridge| { bridge .service_workers .borrow() .registrations_for_origin(&origin) .into_iter() .cloned() .collect::>() }) .unwrap_or_default(); let value = make_registration_array(ctx, &records); Ok(promise_resolved(ctx, value)) } fn sw_registration_unregister( _args: &[Value], ctx: &mut NativeContext, ) -> Result { let scope = match ctx.this { Value::Object(obj) => get_string_prop(ctx, obj, "scope"), _ => None, }; let document_url = match current_document_url(ctx) { Ok(url) => url, Err(e) => return Ok(promise_rejected(ctx, e)), }; let removed = match (ctx.dom_bridge, scope) { (Some(bridge), Some(scope)) => bridge .service_workers .borrow_mut() .unregister(&document_url.origin(), &scope), _ => false, }; Ok(promise_resolved(ctx, Value::Boolean(removed))) } fn sw_registration_skip_waiting( _args: &[Value], ctx: &mut NativeContext, ) -> Result { let scope = match ctx.this { Value::Object(obj) => get_string_prop(ctx, obj, "scope"), _ => None, }; let document_url = match current_document_url(ctx) { Ok(url) => url, Err(e) => return Ok(promise_rejected(ctx, e)), }; let activated = match (ctx.dom_bridge, scope) { (Some(bridge), Some(scope)) => bridge .service_workers .borrow_mut() .skip_waiting(&document_url.origin(), &scope), _ => false, }; Ok(promise_resolved(ctx, Value::Boolean(activated))) } /// Register `navigator.serviceWorker` on the main-thread VM. pub fn init_service_worker_api(vm: &mut Vm) { let service_worker_ref = { let mut data = ObjectData::new(); data.insert_property( "controller".to_string(), Property::builtin(Value::Null), &mut vm.shapes, ); data.insert_property( "onmessage".to_string(), Property::data(Value::Null), &mut vm.shapes, ); data.insert_property( "oncontrollerchange".to_string(), Property::data(Value::Null), &mut vm.shapes, ); let message_listeners = vm .gc .alloc(HeapObject::Object(make_empty_array(&mut vm.shapes))); data.insert_property( SW_CONTAINER_MESSAGE_LISTENERS_KEY.to_string(), Property::builtin(Value::Object(message_listeners)), &mut vm.shapes, ); let controllerchange_listeners = vm .gc .alloc(HeapObject::Object(make_empty_array(&mut vm.shapes))); data.insert_property( SW_CONTAINER_CONTROLLERCHANGE_LISTENERS_KEY.to_string(), Property::builtin(Value::Object(controllerchange_listeners)), &mut vm.shapes, ); data.insert_property( "register".to_string(), Property::builtin(Value::Function(make_native( &mut vm.gc, "register", sw_register, ))), &mut vm.shapes, ); data.insert_property( "getRegistration".to_string(), Property::builtin(Value::Function(make_native( &mut vm.gc, "getRegistration", sw_get_registration, ))), &mut vm.shapes, ); data.insert_property( "getRegistrations".to_string(), Property::builtin(Value::Function(make_native( &mut vm.gc, "getRegistrations", sw_get_registrations, ))), &mut vm.shapes, ); data.insert_property( "addEventListener".to_string(), Property::builtin(Value::Function(make_native( &mut vm.gc, "addEventListener", sw_container_add_event_listener, ))), &mut vm.shapes, ); data.insert_property( "removeEventListener".to_string(), Property::builtin(Value::Function(make_native( &mut vm.gc, "removeEventListener", sw_container_remove_event_listener, ))), &mut vm.shapes, ); let ready = create_promise_object_pub(&mut vm.gc, &mut vm.shapes); resolve_promise_internal(&mut vm.gc, &mut vm.shapes, ready, Value::Undefined); data.insert_property( "ready".to_string(), Property::builtin(Value::Object(ready)), &mut vm.shapes, ); vm.gc.alloc(HeapObject::Object(data)) }; let navigator_ref = match vm.get_global("navigator").cloned() { Some(Value::Object(r)) => r, _ => { let mut data = ObjectData::new(); data.insert_property( "userAgent".to_string(), Property::builtin(Value::String("we-browser/1.0".to_string())), &mut vm.shapes, ); let r = vm.gc.alloc(HeapObject::Object(data)); vm.set_global("navigator", Value::Object(r)); r } }; set_builtin_prop( &mut vm.gc, &mut vm.shapes, navigator_ref, "serviceWorker", Value::Object(service_worker_ref), ); if let Some(Value::Object(window_ref)) = vm.get_global("window").cloned() { set_builtin_prop( &mut vm.gc, &mut vm.shapes, window_ref, "navigator", Value::Object(navigator_ref), ); } refresh_controller(vm); } fn service_worker_container_ref(vm: &Vm) -> Option { let navigator_ref = match vm.get_global("navigator") { Some(Value::Object(r)) => *r, _ => return None, }; let navigator = match vm.gc.get(navigator_ref) { Some(HeapObject::Object(data)) => data, _ => return None, }; match navigator .get_property("serviceWorker", &vm.shapes) .map(|p| p.value) { Some(Value::Object(r)) => Some(r), _ => None, } } pub fn refresh_controller(vm: &mut Vm) { let Some(bridge) = vm.dom_bridge.clone() else { return; }; let Some(container_ref) = service_worker_container_ref(vm) else { return; }; let record = active_controller_record(&bridge); let controller_value = match record.as_ref() { Some(record) => { *bridge.service_worker_controller_scope.borrow_mut() = Some(record.scope_url.clone()); Value::Object(build_controller_object(&mut vm.gc, &mut vm.shapes, record)) } None => { *bridge.service_worker_controller_scope.borrow_mut() = None; Value::Null } }; set_builtin_prop( &mut vm.gc, &mut vm.shapes, container_ref, "controller", controller_value, ); } fn read_prop(vm: &Vm, obj_ref: GcRef, key: &str) -> Value { match vm.gc.get(obj_ref) { Some(HeapObject::Object(data)) => data .get_property(key, &vm.shapes) .map(|p| p.value) .unwrap_or(Value::Undefined), _ => Value::Undefined, } } fn set_event_target(vm: &mut Vm, event_ref: GcRef, target_ref: GcRef) { set_builtin_prop( &mut vm.gc, &mut vm.shapes, event_ref, "target", Value::Object(target_ref), ); set_builtin_prop( &mut vm.gc, &mut vm.shapes, event_ref, "currentTarget", Value::Object(target_ref), ); } fn build_simple_event(vm: &mut Vm, event_type: &str) -> GcRef { let mut data = ObjectData::new(); data.insert_property( "type".to_string(), Property::data(Value::String(event_type.to_string())), &mut vm.shapes, ); data.insert_property( "bubbles".to_string(), Property::data(Value::Boolean(false)), &mut vm.shapes, ); data.insert_property( "cancelable".to_string(), Property::data(Value::Boolean(false)), &mut vm.shapes, ); data.insert_property( "defaultPrevented".to_string(), Property::data(Value::Boolean(false)), &mut vm.shapes, ); vm.gc.alloc(HeapObject::Object(data)) } fn dispatch_container_event( vm: &mut Vm, container_ref: GcRef, event_ref: GcRef, handler_prop: &str, listeners_key: &str, ) -> Result<(), RuntimeError> { set_event_target(vm, event_ref, container_ref); let event_val = Value::Object(event_ref); if let Value::Function(handler) = read_prop(vm, container_ref, handler_prop) { let old_this = vm.get_global("this").cloned(); vm.set_global("this", Value::Object(container_ref)); let _ = vm.call_function(handler, std::slice::from_ref(&event_val)); match old_this { Some(value) => vm.set_global("this", value), None => vm.remove_global("this"), } } let listeners = match read_prop(vm, container_ref, listeners_key) { Value::Object(r) => collect_functions(vm, r), _ => Vec::new(), }; for listener in listeners { let old_this = vm.get_global("this").cloned(); vm.set_global("this", Value::Object(container_ref)); let _ = vm.call_function(listener, std::slice::from_ref(&event_val)); match old_this { Some(value) => vm.set_global("this", value), None => vm.remove_global("this"), } } vm.drain_microtasks() } pub fn drain_service_worker_events(vm: &mut Vm) -> Result<(), RuntimeError> { let events = match vm.dom_bridge.as_ref() { Some(bridge) => std::mem::take(&mut *bridge.service_worker_events.borrow_mut()), None => Vec::new(), }; if events.is_empty() { return Ok(()); } refresh_controller(vm); let Some(container_ref) = service_worker_container_ref(vm) else { return Ok(()); }; for event in events { match event { ServiceWorkerContainerEvent::Message { bytes, origin } => { let data = structured_clone::deserialize( &SerializedData::from_bytes(bytes), &mut vm.gc, &mut vm.shapes, ); let source = read_prop(vm, container_ref, "controller"); let event_ref = crate::iframe_bridge::create_message_event( &mut vm.gc, &mut vm.shapes, "message", data, &origin, source, ); dispatch_container_event( vm, container_ref, event_ref, "onmessage", SW_CONTAINER_MESSAGE_LISTENERS_KEY, )?; } ServiceWorkerContainerEvent::ControllerChange => { refresh_controller(vm); let event_ref = build_simple_event(vm, "controllerchange"); dispatch_container_event( vm, container_ref, event_ref, "oncontrollerchange", SW_CONTAINER_CONTROLLERCHANGE_LISTENERS_KEY, )?; } } } Ok(()) } pub fn has_pending_service_worker_events(vm: &Vm) -> bool { vm.dom_bridge .as_ref() .map(|bridge| !bridge.service_worker_events.borrow().is_empty()) .unwrap_or(false) } fn write_u32(out: &mut Vec, n: u32) { out.extend_from_slice(&n.to_le_bytes()); } fn write_string(out: &mut Vec, s: &str) { write_bytes(out, s.as_bytes()); } fn write_bytes(out: &mut Vec, bytes: &[u8]) { write_u32(out, bytes.len() as u32); out.extend_from_slice(bytes); } fn write_resources(out: &mut Vec, resources: &[ServiceWorkerScriptResource]) { write_u32(out, resources.len() as u32); for resource in resources { write_string(out, &resource.url); write_bytes(out, &resource.bytes); } } fn write_slot(out: &mut Vec, slot: &Option) { match slot { Some(slot) => { out.push(1); write_string(out, &slot.script_url); write_string(out, slot.state.as_str()); } None => out.push(0), } } struct Cursor<'a> { data: &'a [u8], pos: usize, } impl<'a> Cursor<'a> { fn read_u32(&mut self) -> Option { let bytes = self.data.get(self.pos..self.pos + 4)?; self.pos += 4; Some(u32::from_le_bytes(bytes.try_into().ok()?)) } fn read_bytes(&mut self) -> Option> { let len = self.read_u32()? as usize; let bytes = self.data.get(self.pos..self.pos + len)?.to_vec(); self.pos += len; Some(bytes) } fn read_string(&mut self) -> Option { String::from_utf8(self.read_bytes()?).ok() } fn read_slot(&mut self) -> Option> { let present = *self.data.get(self.pos)?; self.pos += 1; if present == 0 { return Some(None); } let script_url = self.read_string()?; let state = ServiceWorkerState::from_str(&self.read_string()?)?; Some(Some(ServiceWorkerSlot { script_url, state })) } fn read_resources(&mut self) -> Option> { let count = self.read_u32()? as usize; let mut resources = Vec::with_capacity(count); for _ in 0..count { resources.push(ServiceWorkerScriptResource { url: self.read_string()?, bytes: self.read_bytes()?, }); } Some(resources) } } #[cfg(test)] mod tests { use super::*; use crate::{compiler, parser::Parser, vm::Vm}; fn base_url() -> Url { Url::parse("https://example.com/app/page.html").unwrap() } fn file_url() -> Url { Url::parse("file:///tmp/we/app/page.html").unwrap() } #[test] fn normalizes_default_scope_to_script_directory() { let mut store = RegistrationStore::new(); let record = store .register(&base_url(), "sw.js", None, b"one".to_vec()) .unwrap(); assert_eq!(record.script_url, "https://example.com/app/sw.js"); assert_eq!(record.scope_url, "https://example.com/app/"); } #[test] fn longest_prefix_scope_match_wins() { let mut store = RegistrationStore::new(); let base = base_url(); store .register(&base, "/sw-root.js", Some("/"), b"root".to_vec()) .unwrap(); store .register(&base, "/app/sw.js", Some("/app/"), b"app".to_vec()) .unwrap(); let client = Url::parse("https://example.com/app/deep/page.html").unwrap(); let matched = store.get_registration(&base.origin(), &client).unwrap(); assert_eq!(matched.scope_url, "https://example.com/app/"); } #[test] fn rejects_cross_origin_script_and_scope() { let mut store = RegistrationStore::new(); let base = base_url(); assert_eq!( store .register(&base, "https://other.test/sw.js", None, Vec::new()) .unwrap_err(), ERR_CROSS_ORIGIN_SCRIPT ); assert_eq!( store .register(&base, "/app/sw.js", Some("https://other.test/"), Vec::new()) .unwrap_err(), ERR_CROSS_ORIGIN_SCOPE ); } #[test] fn rejects_insecure_documents_and_unsupported_schemes() { let mut store = RegistrationStore::new(); let insecure = Url::parse("http://example.com/app/page.html").unwrap(); assert_eq!( store .register(&insecure, "sw.js", None, Vec::new()) .unwrap_err(), ERR_INSECURE_CONTEXT ); assert_eq!( store .register(&base_url(), "data:text/javascript,0", None, Vec::new()) .unwrap_err(), ERR_UNSUPPORTED_SCHEME ); assert_eq!( store .register(&file_url(), "sw.js", None, Vec::new()) .unwrap_err(), ERR_FILE_SCHEME_DISABLED ); } #[test] fn allows_loopback_http_documents() { let mut store = RegistrationStore::new(); let base = Url::parse("http://localhost/app/page.html").unwrap(); let record = store.register(&base, "sw.js", None, Vec::new()).unwrap(); assert_eq!(record.scope_url, "http://localhost/app/"); } #[test] fn rejects_scope_outside_script_directory() { let mut store = RegistrationStore::new(); let base = base_url(); assert_eq!( store .register(&base, "sw.js", Some("/"), Vec::new()) .unwrap_err(), ERR_SCOPE_OUTSIDE_SCRIPT_DIRECTORY ); assert!(store .register(&base, "sw.js", Some("/app/deeper/"), Vec::new()) .is_ok()); } #[test] fn file_url_registration_requires_explicit_test_hook() { let mut store = RegistrationStore::new(); let record = store .register_with_options( &file_url(), "sw.js", None, b"file-script".to_vec(), Vec::new(), RegistrationOptions::allow_file_urls_for_tests(), ) .unwrap(); assert_eq!(record.origin, "file:///tmp/we/app/page.html"); assert_eq!(record.scope_url, "file:///tmp/we/app/"); } #[test] fn unregister_removes_origin_scoped_record() { let mut store = RegistrationStore::new(); let base = base_url(); let record = store .register(&base, "/sw.js", Some("/app/"), Vec::new()) .unwrap(); assert!(store.unregister(&base.origin(), &record.scope_url)); assert!(!store.unregister(&base.origin(), &record.scope_url)); assert!(store.registrations_for_origin(&base.origin()).is_empty()); } #[test] fn persistence_roundtrip_preserves_slots_and_bytes() { let mut store = RegistrationStore::new(); let base = base_url(); store .register(&base, "/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); let bytes = store.serialize(); let loaded = RegistrationStore::deserialize(&bytes).unwrap(); assert_eq!(loaded, store); assert!(!loaded.records()[0].script_changed(b"body-v1", &[])); assert!(loaded.records()[0].script_changed(b"body-v2", &[])); } #[test] fn no_op_update_keeps_active_worker_without_updatefound() { let mut store = RegistrationStore::new(); let base = base_url(); let first = store .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); let second = store .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); assert_eq!(store.records().len(), 1); assert_eq!(first, second); assert!(store.records()[0].waiting.is_none()); assert_eq!(store.records()[0].updatefound_count, 0); } #[test] fn changed_script_update_enters_waiting_without_replacing_active() { let mut store = RegistrationStore::new(); let base = base_url(); store .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); let updated = store .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) .unwrap(); assert_eq!(store.records().len(), 1); assert_eq!(updated.script_url, "https://example.com/app/sw.js"); assert_eq!(updated.script_bytes, b"body-v1"); assert_eq!(updated.waiting_script_bytes, b"body-v2"); assert!(updated.has_waiting_worker()); assert_eq!(updated.updatefound_count, 1); } #[test] fn imported_script_change_triggers_waiting_update() { let mut store = RegistrationStore::new(); let base = base_url(); store .register_with_imports( &base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec(), vec![("dep.js", b"dep-v1".to_vec())], ) .unwrap(); let updated = store .register_with_imports( &base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec(), vec![("dep.js", b"dep-v2".to_vec())], ) .unwrap(); assert!(updated.has_waiting_worker()); assert_eq!(updated.updatefound_count, 1); assert_eq!(updated.imported_scripts[0].bytes, b"dep-v1"); assert_eq!(updated.waiting_imported_scripts[0].bytes, b"dep-v2"); } #[test] fn waiting_worker_promotes_on_skip_waiting() { let mut store = RegistrationStore::new(); let base = base_url(); let scope = "https://example.com/app/"; store .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); store .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) .unwrap(); assert!(store.skip_waiting(&base.origin(), scope)); let record = &store.records()[0]; assert!(record.waiting.is_none()); assert_eq!(record.script_bytes, b"body-v2"); assert_eq!(record.lifecycle_state, ServiceWorkerState::Active); } #[test] fn active_worker_serves_clients_until_release_promotes_waiting() { let mut store = RegistrationStore::new(); let base = base_url(); let scope = "https://example.com/app/"; store .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); assert!(store.add_controlled_client(&base.origin(), scope)); store .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) .unwrap(); assert_eq!(store.records()[0].script_bytes, b"body-v1"); assert!(store.release_controlled_client(&base.origin(), scope)); assert_eq!(store.records()[0].script_bytes, b"body-v2"); } #[test] fn clear_origin_removes_only_that_origins_registrations() { let mut store = RegistrationStore::new(); let base = base_url(); let other = Url::parse("https://other.test/app/page.html").unwrap(); store .register(&base, "sw.js", None, b"one".to_vec()) .unwrap(); store .register(&other, "sw.js", None, b"two".to_vec()) .unwrap(); assert!(store.clear_origin(&base.origin())); assert!(store.registrations_for_origin(&base.origin()).is_empty()); assert_eq!(store.registrations_for_origin(&other.origin()).len(), 1); } #[test] fn cache_storage_is_origin_isolated_and_clearable() { let mut caches = CacheStorage::new(); let alpha_doc = Url::parse("https://alpha.test/app/page.html").unwrap(); 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_bytes(&alpha_doc.origin(), "v1", &request, b"alpha".to_vec()); assert_eq!( caches.match_request_bytes(&alpha_doc.origin(), "v1", &request), Some(b"alpha".to_vec()) ); assert!(caches .match_request_bytes(&beta_doc.origin(), "v1", &request) .is_none()); assert!(caches.clear_origin(&alpha_doc.origin())); assert!(caches .match_request_bytes(&alpha_doc.origin(), "v1", &request) .is_none()); } #[test] fn unregister_and_activation_cleanup_obsolete_caches() { let mut store = RegistrationStore::new(); let mut caches = CacheStorage::new(); let base = base_url(); let request = Url::parse("https://example.com/app/data.json").unwrap(); let scope = "https://example.com/app/"; store .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); 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_bytes(&base.origin(), "v1", &request) .is_none()); store .register(&base, "/app/sw.js", Some("/app/"), b"body-v1".to_vec()) .unwrap(); store .register(&base, "/app/sw.js", Some("/app/"), b"body-v2".to_vec()) .unwrap(); 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_bytes(&base.origin(), "v2", &request) .is_none()); } fn eval_with_service_worker(source: &str) -> String { let program = Parser::parse(source).expect("parse failed"); let func = compiler::compile(&program).expect("compile failed"); let mut vm = Vm::new(); vm.attach_document(we_dom::Document::new()); vm.set_document_url(base_url()); init_service_worker_api(&mut vm); vm.execute(&func).expect("execute failed"); vm.pump_event_loop().expect("event loop failed"); vm.get_global("result") .cloned() .unwrap_or(Value::Undefined) .to_js_string(&vm.gc) } fn eval_with_script_resolver(source: &str, worker_source: &'static str) -> String { set_script_body_resolver(move |_| Some(worker_source.as_bytes().to_vec())); let result = eval_with_service_worker(source); clear_script_body_resolver(); result } #[test] fn js_register_and_get_registration_resolve() { let result = eval_with_service_worker( r#" result = ""; navigator.serviceWorker.register("sw.js").then(function(reg) { result = reg.scope + "|" + reg.active.state; }); "#, ); assert_eq!(result, "https://example.com/app/|active"); } #[test] fn js_get_registrations_and_ready_resolve() { let result = eval_with_service_worker( r#" result = ""; navigator.serviceWorker.register("/a.js", { scope: "/" }).then(function() { return navigator.serviceWorker.getRegistrations(); }).then(function(regs) { return navigator.serviceWorker.ready.then(function() { result = regs.length + ":" + regs[0].scope; }); }); "#, ); assert_eq!(result, "1:https://example.com/"); } #[test] fn js_rejects_cross_origin_registration() { let result = eval_with_service_worker( r#" result = "pending"; navigator.serviceWorker.register("https://evil.test/sw.js")["catch"](function(err) { result = String(err); }); "#, ); assert_eq!( result, "SecurityError: service worker script must be same-origin" ); } #[test] fn js_update_exposes_waiting_worker_and_skip_waiting() { let result = eval_with_service_worker( r#" result = ""; navigator.serviceWorker.register("sw.js").then(function() { return navigator.serviceWorker.register("sw-v2.js"); }).then(function(reg) { var before = reg.active.scriptURL + "|" + reg.waiting.state + "|" + reg.updatefoundCount; return reg.skipWaiting().then(function(promoted) { return navigator.serviceWorker.getRegistration().then(function(next) { result = before + "|" + promoted + "|" + next.active.scriptURL; }); }); }); "#, ); assert_eq!( result, "https://example.com/app/sw.js|waiting|1|true|https://example.com/app/sw-v2.js" ); } #[test] fn navigator_controller_reflects_matching_active_registration() { let program = Parser::parse( r#" result = navigator.serviceWorker.controller.scriptURL + '|' + navigator.serviceWorker.controller.state; "#, ) .expect("parse failed"); let func = compiler::compile(&program).expect("compile failed"); let mut vm = Vm::new(); vm.attach_document(we_dom::Document::new()); vm.set_document_url(base_url()); let mut store = RegistrationStore::new(); store .register( &base_url(), "sw.js", Some("/app/"), b"self.addEventListener('message', function() {});".to_vec(), ) .unwrap(); vm.set_service_worker_registrations(store); vm.execute(&func).expect("execute failed"); let result = vm .get_global("result") .cloned() .unwrap_or(Value::Undefined) .to_js_string(&vm.gc); assert_eq!(result, "https://example.com/app/sw.js|active"); } #[test] fn clients_claim_sets_controller_and_dispatches_controllerchange() { let worker = r#" self.addEventListener('activate', function(e) { e.waitUntil(self.clients.claim()); }); "#; let result = eval_with_script_resolver( r#" result = "pending"; navigator.serviceWorker.oncontrollerchange = function() { result = navigator.serviceWorker.controller ? "changed:" + navigator.serviceWorker.controller.scriptURL : "changed:null"; }; navigator.serviceWorker.register("sw.js"); "#, worker, ); assert_eq!(result, "changed:https://example.com/app/sw.js"); } #[test] fn document_and_worker_post_message_round_trip_uses_structured_clone() { let worker = r#" self.addEventListener('activate', function(e) { e.waitUntil(self.clients.claim()); }); self.addEventListener('message', function(e) { e.source.postMessage('reply:' + e.data.kind + ':' + e.data.value); }); "#; let result = eval_with_script_resolver( r#" result = ""; navigator.serviceWorker.oncontrollerchange = function() { result = result + "controllerchange|"; }; navigator.serviceWorker.onmessage = function(e) { result = result + e.data; }; navigator.serviceWorker.register("sw.js").then(function() { navigator.serviceWorker.controller.postMessage({ kind: "ping", value: 7 }); }); "#, worker, ); assert_eq!(result, "controllerchange|reply:ping:7"); } #[test] fn controller_post_message_rejects_uncloneable_values() { let worker = r#" self.addEventListener('activate', function(e) { e.waitUntil(self.clients.claim()); }); "#; let result = eval_with_script_resolver( r#" result = "pending"; navigator.serviceWorker.register("sw.js").then(function() { try { navigator.serviceWorker.controller.postMessage(function() {}); } catch (err) { result = String(err.message || err); } }); "#, worker, ); assert_eq!(result, "DataCloneError: function could not be cloned"); } }