diff --git a/crates/e2e/pages/24_service_worker_messaging.html b/crates/e2e/pages/24_service_worker_messaging.html new file mode 100644 index 0000000..d11104c --- /dev/null +++ b/crates/e2e/pages/24_service_worker_messaging.html @@ -0,0 +1,45 @@ + + + + + Service worker messaging + + +

Service worker messaging

+

pending

+ + + diff --git a/crates/e2e/pages/24_service_worker_messaging_sw.js b/crates/e2e/pages/24_service_worker_messaging_sw.js new file mode 100644 index 0000000..484339f --- /dev/null +++ b/crates/e2e/pages/24_service_worker_messaging_sw.js @@ -0,0 +1,14 @@ +self.addEventListener("activate", function(event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("message", function(event) { + var payload = event.data.kind + ":" + event.data.value; + self.clients.matchAll().then(function(clients) { + if (clients.length > 0) { + clients[0].postMessage("reply:" + payload + ":" + clients[0].url); + } else { + self.postMessage("reply:" + payload + ":no-client"); + } + }); +}); diff --git a/crates/e2e/scenarios/service_worker_messaging.we b/crates/e2e/scenarios/service_worker_messaging.we new file mode 100644 index 0000000..69a51f3 --- /dev/null +++ b/crates/e2e/scenarios/service_worker_messaging.we @@ -0,0 +1,15 @@ +# Register an active service worker, let activate call clients.claim(), then +# send a structured-cloned message from the document to the worker. The worker +# replies through a WindowClient returned by clients.matchAll(). + +viewport 800 600 +network online + +cache_put sw-source https://swmsg.test/app/message-sw.js ../pages/24_service_worker_messaging_sw.js application/javascript + +goto_as https://swmsg.test/app/index.html crates/e2e/pages/24_service_worker_messaging.html +dump_dom service_worker_messaging.dom.txt +dump_console service_worker_messaging.console.txt +assert_dom_contains "sw-message:reply:ping:7:https://swmsg.test/app/index.html" +assert_console_contains "sw-message:reply:ping:7:https://swmsg.test/app/index.html" +screenshot service_worker_messaging.png diff --git a/crates/js/src/location.rs b/crates/js/src/location.rs index a116c1c..3bfc5c3 100644 --- a/crates/js/src/location.rs +++ b/crates/js/src/location.rs @@ -477,6 +477,9 @@ mod tests { session_storage: RefCell::new(crate::storage::StorageArea::new()), indexeddb: RefCell::new(crate::indexeddb::IndexedDbState::new()), service_workers: RefCell::new(crate::service_worker::RegistrationStore::new()), + service_worker_client_id: RefCell::new(String::new()), + service_worker_controller_scope: RefCell::new(None), + service_worker_events: RefCell::new(Vec::new()), iframe_windows: RefCell::new(std::collections::HashMap::new()), location_object: RefCell::new(None), ready_state: RefCell::new("loading".to_string()), diff --git a/crates/js/src/service_worker.rs b/crates/js/src/service_worker.rs index bf1e320..713f79c 100644 --- a/crates/js/src/service_worker.rs +++ b/crates/js/src/service_worker.rs @@ -4,11 +4,20 @@ //! 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}; @@ -20,6 +29,11 @@ const ERR_CROSS_ORIGIN_SCRIPT: &str = "SecurityError: service worker script must 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 { @@ -87,6 +101,40 @@ pub struct ServiceWorkerScriptResource { 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, @@ -621,6 +669,10 @@ pub fn clear_script_body_resolver() { }); } +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); @@ -737,6 +789,78 @@ fn make_registration_array( 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 @@ -746,6 +870,331 @@ fn get_string_prop(ctx: &NativeContext, obj_ref: crate::gc::GcRef, name: &str) - } } +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() @@ -780,6 +1229,25 @@ fn sw_register(args: &[Value], ctx: &mut NativeContext) -> Result { + 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)) } @@ -886,6 +1354,37 @@ fn sw_registration_skip_waiting( 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( @@ -913,6 +1412,24 @@ pub fn init_service_worker_api(vm: &mut Vm) { ))), &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( @@ -953,6 +1470,200 @@ pub fn init_service_worker_api(vm: &mut Vm) { 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) { @@ -1367,6 +2078,13 @@ mod tests { .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( @@ -1435,4 +2153,109 @@ mod tests { "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"); + } } diff --git a/crates/js/src/service_worker_global.rs b/crates/js/src/service_worker_global.rs index 9bc9d8a..d3d335b 100644 --- a/crates/js/src/service_worker_global.rs +++ b/crates/js/src/service_worker_global.rs @@ -32,12 +32,13 @@ use crate::compiler; use crate::gc::GcRef; use crate::parser; use crate::service_worker::{ - RegistrationStore, ServiceWorkerRegistrationRecord, ServiceWorkerSlot, ServiceWorkerState, + RegistrationStore, ServiceWorkerClientRecord, ServiceWorkerRegistrationRecord, + ServiceWorkerSlot, ServiceWorkerState, }; use crate::structured_clone::{self, SerializedData}; use crate::vm::{HeapObject, NativeContext, ObjectData, Property, RuntimeError, Value, Vm}; use crate::worker::WorkerMessage; -use we_url::Origin; +use we_url::{Origin, Url}; // ── Send-safe snapshot used to brief the worker thread ────────────────────── @@ -83,6 +84,8 @@ pub enum ServiceWorkerCommand { Activate, /// Dispatch a `message` event with the given structured-clone bytes. Message(Vec), + /// Dispatch a `message` event from a known document client. + MessageFromClient { client_id: String, bytes: Vec }, /// Dispatch a `fetch` event. The worker replies with /// [`ServiceWorkerEvent::FetchResponse`] carrying the request id and the /// resolved Response (or `None` for network fallback / error). @@ -111,6 +114,9 @@ pub enum ServiceWorkerEvent { ClaimRequested, /// The worker called `postMessage` (structured-clone bytes). MessageOut(Vec), + /// A ServiceWorker `Client` object posted a message to a controlled + /// document client. + ClientMessageOut { client_id: String, bytes: Vec }, /// The worker reported an uncaught error. Error(String), /// Response to a [`ServiceWorkerCommand::DispatchFetch`]. `response` is @@ -146,6 +152,7 @@ const SW_EVENT_ID_KEY: &str = "__sw_event_id__"; /// pending waitUntil/respondWith promises. Surfaced so tests can assert the /// timeout path without sleeping for the full duration. const EXTEND_PROMISE_TIMEOUT: Duration = Duration::from_secs(30); +const SW_CLIENT_ID_KEY: &str = "__sw_client_id__"; /// Per-event mutable state. Lives in [`EVENT_REGISTRY`], keyed by the event /// id stored on the JS event object. Tracks the bits of the @@ -200,6 +207,8 @@ thread_local! { static SELF_GCREF: std::cell::Cell> = const { std::cell::Cell::new(None) }; /// True when `self.skipWaiting()` has been called this lifetime. static SKIP_WAITING_FLAG: std::cell::Cell = const { std::cell::Cell::new(false) }; + static CURRENT_SNAPSHOT: RefCell> = const { RefCell::new(None) }; + static CLIENTS: RefCell> = const { RefCell::new(Vec::new()) }; } fn emit_event(ev: ServiceWorkerEvent) { @@ -313,12 +322,33 @@ fn sw_self_skip_waiting(_args: &[Value], ctx: &mut NativeContext) -> Result Result { + if let Some(snapshot) = CURRENT_SNAPSHOT.with(|cell| cell.borrow().clone()) { + CLIENTS.with(|cell| { + for client in cell.borrow_mut().iter_mut() { + if client.origin == snapshot.origin && client.url.starts_with(&snapshot.scope_url) { + client.controller_scope_url = Some(snapshot.scope_url.clone()); + } + } + }); + } emit_event(ServiceWorkerEvent::ClaimRequested); let promise = create_promise_object_pub(ctx.gc, ctx.shapes); crate::builtins::resolve_promise_internal(ctx.gc, ctx.shapes, promise, Value::Undefined); Ok(Value::Object(promise)) } +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 sw_add_event_listener(args: &[Value], ctx: &mut NativeContext) -> Result { let event_type = args .first() @@ -513,6 +543,214 @@ fn build_registration_object(vm: &mut Vm, snapshot: &ServiceWorkerSnapshot) -> G vm.gc.alloc(HeapObject::Object(data)) } +fn build_client_object_with_ctx( + ctx: &mut NativeContext, + client: &ServiceWorkerClientRecord, +) -> GcRef { + let mut data = ObjectData::new(); + data.insert_property( + SW_CLIENT_ID_KEY.to_string(), + Property::builtin(Value::String(client.id.clone())), + ctx.shapes, + ); + data.insert_property( + "id".to_string(), + Property::builtin(Value::String(client.id.clone())), + ctx.shapes, + ); + data.insert_property( + "url".to_string(), + Property::builtin(Value::String(client.url.clone())), + ctx.shapes, + ); + data.insert_property( + "type".to_string(), + Property::builtin(Value::String("window".to_string())), + ctx.shapes, + ); + data.insert_property( + "visibilityState".to_string(), + Property::builtin(Value::String(client.visibility_state.clone())), + ctx.shapes, + ); + data.insert_property( + "focused".to_string(), + Property::builtin(Value::Boolean(client.focused)), + ctx.shapes, + ); + let post = make_native(ctx.gc, "postMessage", sw_client_post_message); + data.insert_property( + "postMessage".to_string(), + Property::builtin(Value::Function(post)), + ctx.shapes, + ); + ctx.gc.alloc(HeapObject::Object(data)) +} + +fn build_client_object(vm: &mut Vm, client: &ServiceWorkerClientRecord) -> GcRef { + let mut data = ObjectData::new(); + data.insert_property( + SW_CLIENT_ID_KEY.to_string(), + Property::builtin(Value::String(client.id.clone())), + &mut vm.shapes, + ); + data.insert_property( + "id".to_string(), + Property::builtin(Value::String(client.id.clone())), + &mut vm.shapes, + ); + data.insert_property( + "url".to_string(), + Property::builtin(Value::String(client.url.clone())), + &mut vm.shapes, + ); + data.insert_property( + "type".to_string(), + Property::builtin(Value::String("window".to_string())), + &mut vm.shapes, + ); + data.insert_property( + "visibilityState".to_string(), + Property::builtin(Value::String(client.visibility_state.clone())), + &mut vm.shapes, + ); + data.insert_property( + "focused".to_string(), + Property::builtin(Value::Boolean(client.focused)), + &mut vm.shapes, + ); + let post = make_native(&mut vm.gc, "postMessage", sw_client_post_message); + data.insert_property( + "postMessage".to_string(), + Property::builtin(Value::Function(post)), + &mut vm.shapes, + ); + vm.gc.alloc(HeapObject::Object(data)) +} + +fn get_bool_prop(ctx: &NativeContext, obj_ref: GcRef, key: &str) -> bool { + match ctx.gc.get(obj_ref) { + Some(HeapObject::Object(data)) => data + .get_property(key, ctx.shapes) + .map(|p| p.value.to_boolean()) + .unwrap_or(false), + _ => false, + } +} + +fn get_string_prop_ctx(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.to_js_string(ctx.gc)), + _ => None, + } +} + +fn client_id_from_this(ctx: &NativeContext) -> Option { + let this_ref = match ctx.this { + Value::Object(r) => r, + _ => return None, + }; + get_string_prop_ctx(ctx, this_ref, SW_CLIENT_ID_KEY) +} + +fn sw_client_post_message(args: &[Value], ctx: &mut NativeContext) -> Result { + let client_id = client_id_from_this(ctx) + .ok_or_else(|| RuntimeError::type_error("Client.postMessage called on a non-Client"))?; + 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()))?; + emit_event(ServiceWorkerEvent::ClientMessageOut { + client_id, + bytes: serialized.into_bytes(), + }); + Ok(Value::Undefined) +} + +fn sw_clients_match_all(args: &[Value], ctx: &mut NativeContext) -> Result { + let mut include_uncontrolled = false; + let mut requested_type = "window".to_string(); + if let Some(Value::Object(opts)) = args.first() { + include_uncontrolled = get_bool_prop(ctx, *opts, "includeUncontrolled"); + if let Some(ty) = get_string_prop_ctx(ctx, *opts, "type") { + requested_type = ty; + } + } + let snapshot = CURRENT_SNAPSHOT.with(|cell| cell.borrow().clone()); + let clients = if let Some(snapshot) = snapshot { + CLIENTS.with(|cell| { + cell.borrow() + .iter() + .filter(|client| client.origin == snapshot.origin) + .filter(|_| requested_type == "window" || requested_type == "all") + .filter(|client| { + include_uncontrolled + || client.controller_scope_url.as_deref() + == Some(snapshot.scope_url.as_str()) + }) + .cloned() + .collect::>() + }) + } else { + Vec::new() + }; + + let mut arr = ObjectData::new(); + for (idx, client) in clients.iter().enumerate() { + let client_ref = build_client_object_with_ctx(ctx, client); + arr.insert_property( + idx.to_string(), + Property::data(Value::Object(client_ref)), + ctx.shapes, + ); + } + arr.insert_property( + "length".to_string(), + Property::builtin(Value::Number(clients.len() as f64)), + ctx.shapes, + ); + let arr_ref = ctx.gc.alloc(HeapObject::Object(arr)); + Ok(promise_resolved(ctx, Value::Object(arr_ref))) +} + +fn sw_clients_open_window(args: &[Value], ctx: &mut NativeContext) -> Result { + let raw_url = args + .first() + .map(|value| value.to_js_string(ctx.gc)) + .unwrap_or_default(); + let Some(snapshot) = CURRENT_SNAPSHOT.with(|cell| cell.borrow().clone()) else { + return Ok(promise_rejected( + ctx, + "InvalidStateError: no service worker registration".to_string(), + )); + }; + let base = match Url::parse(&snapshot.scope_url) { + Ok(url) => url, + Err(err) => return Ok(promise_rejected(ctx, format!("TypeError: {err}"))), + }; + let url = match Url::parse_with_base(&raw_url, &base) { + Ok(url) => url, + Err(err) => return Ok(promise_rejected(ctx, format!("TypeError: {err}"))), + }; + let origin = url.origin().serialize(); + if origin != snapshot.origin { + return Ok(promise_rejected( + ctx, + "SecurityError: Clients.openWindow requires a same-origin URL".to_string(), + )); + } + let client = ServiceWorkerClientRecord::window( + format!("window:{}", url.serialize()), + origin, + url.serialize(), + Some(snapshot.scope_url.clone()), + ); + CLIENTS.with(|cell| cell.borrow_mut().push(client.clone())); + let client_ref = build_client_object_with_ctx(ctx, &client); + Ok(promise_resolved(ctx, Value::Object(client_ref))) +} + fn build_clients_object(vm: &mut Vm) -> GcRef { let mut data = ObjectData::new(); let claim_fn = make_native(&mut vm.gc, "claim", sw_clients_claim); @@ -521,6 +759,18 @@ fn build_clients_object(vm: &mut Vm) -> GcRef { Property::builtin(Value::Function(claim_fn)), &mut vm.shapes, ); + let match_all_fn = make_native(&mut vm.gc, "matchAll", sw_clients_match_all); + data.insert_property( + "matchAll".to_string(), + Property::builtin(Value::Function(match_all_fn)), + &mut vm.shapes, + ); + let open_window_fn = make_native(&mut vm.gc, "openWindow", sw_clients_open_window); + data.insert_property( + "openWindow".to_string(), + Property::builtin(Value::Function(open_window_fn)), + &mut vm.shapes, + ); vm.gc.alloc(HeapObject::Object(data)) } @@ -603,6 +853,7 @@ pub fn init_service_worker_globals( vm: &mut Vm, snapshot: &ServiceWorkerSnapshot, outbound_tx: mpsc::Sender, + clients: Vec, ) { OUTBOUND_EVENTS_TX.with(|cell| *cell.borrow_mut() = Some(outbound_tx)); LISTENERS.with(|cell| cell.borrow_mut().clear()); @@ -610,6 +861,8 @@ pub fn init_service_worker_globals( DISPATCHING_EVENT_STACK.with(|cell| cell.borrow_mut().clear()); NEXT_EVENT_ID.with(|c| c.set(1)); SKIP_WAITING_FLAG.with(|c| c.set(false)); + CURRENT_SNAPSHOT.with(|cell| *cell.borrow_mut() = Some(snapshot.clone())); + CLIENTS.with(|cell| *cell.borrow_mut() = clients); let self_ref = build_self_object(vm, snapshot); SELF_GCREF.with(|c| c.set(Some(self_ref))); @@ -964,10 +1217,46 @@ fn build_extendable_event(vm: &mut Vm, type_name: &str, id: u32) -> GcRef { obj } -fn build_extendable_message_event(vm: &mut Vm, data: Value, id: u32) -> GcRef { +fn build_extendable_message_event( + vm: &mut Vm, + data: Value, + id: u32, + source_client_id: Option<&str>, +) -> GcRef { let evt = build_extendable_event(vm, "message", id); + let source_client = source_client_id.and_then(|client_id| { + CLIENTS.with(|cell| { + cell.borrow() + .iter() + .find(|client| client.id == client_id) + .cloned() + }) + }); + let source = source_client + .as_ref() + .map(|client| Value::Object(build_client_object(vm, client))) + .unwrap_or(Value::Null); + let origin = source_client + .as_ref() + .map(|client| client.origin.clone()) + .unwrap_or_default(); if let Some(HeapObject::Object(d)) = vm.gc.get_mut(evt) { d.insert_property("data".to_string(), Property::builtin(data), &mut vm.shapes); + d.insert_property( + "source".to_string(), + Property::builtin(source), + &mut vm.shapes, + ); + d.insert_property( + "origin".to_string(), + Property::builtin(Value::String(origin)), + &mut vm.shapes, + ); + d.insert_property( + "ports".to_string(), + Property::builtin(Value::Null), + &mut vm.shapes, + ); } evt } @@ -1228,11 +1517,15 @@ fn dispatch_extendable(vm: &mut Vm, event_type: &str) -> Result) -> Result<(), RuntimeError> { +fn dispatch_message_event( + vm: &mut Vm, + bytes: Vec, + source_client_id: Option, +) -> Result<(), RuntimeError> { let id = allocate_event_id(false); let serialized = SerializedData::from_bytes(bytes); let data = structured_clone::deserialize(&serialized, &mut vm.gc, &mut vm.shapes); - let event_ref = build_extendable_message_event(vm, data, id); + let event_ref = build_extendable_message_event(vm, data, id, source_client_id.as_deref()); let event_val = Value::Object(event_ref); dispatch_event(vm, "message", event_val, id)?; vm.pump_event_loop()?; @@ -1285,9 +1578,10 @@ pub fn run_service_worker_script( snapshot: ServiceWorkerSnapshot, inbound_rx: mpsc::Receiver, outbound_tx: mpsc::Sender, + clients: Vec, ) { let mut vm = Vm::new(); - init_service_worker_globals(&mut vm, &snapshot, outbound_tx); + init_service_worker_globals(&mut vm, &snapshot, outbound_tx, clients); // Parse + compile. let program = match parser::Parser::parse(script_source) { @@ -1331,7 +1625,12 @@ pub fn run_service_worker_script( } }, ServiceWorkerCommand::Message(bytes) => { - if let Err(e) = dispatch_message_event(&mut vm, bytes) { + if let Err(e) = dispatch_message_event(&mut vm, bytes, None) { + emit_event(ServiceWorkerEvent::Error(e.to_string())); + } + } + ServiceWorkerCommand::MessageFromClient { client_id, bytes } => { + if let Err(e) = dispatch_message_event(&mut vm, bytes, Some(client_id)) { emit_event(ServiceWorkerEvent::Error(e.to_string())); } } @@ -1496,6 +1795,8 @@ fn cleanup_thread_local_state() { NEXT_EVENT_ID.with(|c| c.set(1)); SELF_GCREF.with(|c| c.set(None)); SKIP_WAITING_FLAG.with(|c| c.set(false)); + CURRENT_SNAPSHOT.with(|cell| cell.borrow_mut().take()); + CLIENTS.with(|cell| cell.borrow_mut().clear()); } // ── Main-thread handle ─────────────────────────────────────────────────────── @@ -1516,13 +1817,27 @@ impl ServiceWorkerThread { /// Spawn a new service-worker thread executing `script_source` with /// `snapshot` exposed via `self.registration`. pub fn spawn(script_source: String, snapshot: ServiceWorkerSnapshot) -> Self { + Self::spawn_with_clients(script_source, snapshot, Vec::new()) + } + + pub fn spawn_with_clients( + script_source: String, + snapshot: ServiceWorkerSnapshot, + clients: Vec, + ) -> Self { let (inbound_tx, inbound_rx) = mpsc::channel::(); let (outbound_tx, outbound_rx) = mpsc::channel::(); let thread_snapshot = snapshot.clone(); let thread = thread::Builder::new() .name(format!("we-sw:{}", snapshot.scope_url)) .spawn(move || { - run_service_worker_script(&script_source, thread_snapshot, inbound_rx, outbound_tx); + run_service_worker_script( + &script_source, + thread_snapshot, + inbound_rx, + outbound_tx, + clients, + ); }) .expect("failed to spawn service worker thread"); Self { @@ -1644,6 +1959,7 @@ pub struct LifecycleSummary { pub claim_requested: bool, pub errors: Vec, pub messages_out: Vec>, + pub client_messages_out: Vec<(String, Vec)>, } /// Orchestrates the lifecycle of every registration in a @@ -1702,6 +2018,16 @@ impl ServiceWorkerManager { store: &mut RegistrationStore, record: &ServiceWorkerRegistrationRecord, script_source: String, + ) -> LifecycleSummary { + self.start_from_registration_with_clients(store, record, script_source, Vec::new()) + } + + pub fn start_from_registration_with_clients( + &mut self, + store: &mut RegistrationStore, + record: &ServiceWorkerRegistrationRecord, + script_source: String, + clients: Vec, ) -> LifecycleSummary { let key = key_for(record); @@ -1712,7 +2038,7 @@ impl ServiceWorkerManager { } let snapshot = ServiceWorkerSnapshot::from_record(record); - let mut thread = ServiceWorkerThread::spawn(script_source, snapshot); + let mut thread = ServiceWorkerThread::spawn_with_clients(script_source, snapshot, clients); let mut summary = LifecycleSummary::default(); @@ -1952,6 +2278,9 @@ fn record_event(summary: &mut LifecycleSummary, ev: ServiceWorkerEvent) { ServiceWorkerEvent::SkipWaitingRequested => summary.skip_waiting_requested = true, ServiceWorkerEvent::ClaimRequested => summary.claim_requested = true, ServiceWorkerEvent::MessageOut(bytes) => summary.messages_out.push(bytes), + ServiceWorkerEvent::ClientMessageOut { client_id, bytes } => { + summary.client_messages_out.push((client_id, bytes)); + } ServiceWorkerEvent::Error(msg) => summary.errors.push(msg), // Fetch responses don't surface through LifecycleSummary; the // routing layer reads them via ServiceWorkerThread::dispatch_fetch. @@ -2152,6 +2481,163 @@ mod tests { thread.terminate(); } + #[test] + fn clients_match_all_filters_controlled_window_clients() { + let snapshot = snapshot_for(&url(), "/app/"); + let clients = vec![ + ServiceWorkerClientRecord::window( + "client-controlled".to_string(), + "https://example.com".to_string(), + "https://example.com/app/page.html".to_string(), + Some("https://example.com/app/".to_string()), + ), + ServiceWorkerClientRecord::window( + "client-uncontrolled".to_string(), + "https://example.com".to_string(), + "https://example.com/app/other.html".to_string(), + None, + ), + ServiceWorkerClientRecord::window( + "client-other-origin".to_string(), + "https://other.test".to_string(), + "https://other.test/app/page.html".to_string(), + Some("https://example.com/app/".to_string()), + ), + ]; + let script = r#" + self.addEventListener('message', function() { + self.clients.matchAll().then(function(clients) { + self.postMessage('controlled:' + clients.length + ':' + clients[0].id); + }); + }); + "#; + let mut thread = + ServiceWorkerThread::spawn_with_clients(script.to_string(), snapshot, clients); + let _ = thread.recv_timeout(Duration::from_secs(2)); + + let mut vm = Vm::new(); + let serialized = + structured_clone::serialize(&Value::String("go".to_string()), &vm.gc, &vm.shapes) + .unwrap(); + thread + .send(ServiceWorkerCommand::Message(serialized.into_bytes())) + .unwrap(); + + let mut received = None; + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline && received.is_none() { + if let Some(ServiceWorkerEvent::MessageOut(bytes)) = + thread.recv_timeout(Duration::from_millis(20)) + { + let val = structured_clone::deserialize( + &SerializedData::from_bytes(bytes), + &mut vm.gc, + &mut vm.shapes, + ); + received = Some(val.to_js_string(&vm.gc)); + } + } + assert_eq!(received.as_deref(), Some("controlled:1:client-controlled")); + thread.terminate(); + } + + #[test] + fn clients_claim_controls_eligible_clients_and_client_post_message() { + let mut store = RegistrationStore::new(); + let record = store + .register(&url(), "sw.js", Some("/app/"), b"v1".to_vec()) + .unwrap(); + let clients = vec![ServiceWorkerClientRecord::window( + "client-1".to_string(), + "https://example.com".to_string(), + "https://example.com/app/page.html".to_string(), + None, + )]; + let script = r#" + self.addEventListener('activate', function(e) { + e.waitUntil(self.clients.claim().then(function() { + return self.clients.matchAll().then(function(clients) { + clients[0].postMessage('claimed:' + clients.length + ':' + clients[0].url); + }); + })); + }); + "#; + let mut mgr = ServiceWorkerManager::new(); + let summary = mgr.start_from_registration_with_clients( + &mut store, + &record, + script.to_string(), + clients, + ); + assert!(summary.claim_requested); + assert_eq!(summary.client_messages_out.len(), 1); + assert_eq!(summary.client_messages_out[0].0, "client-1"); + + let mut vm = Vm::new(); + let val = structured_clone::deserialize( + &SerializedData::from_bytes(summary.client_messages_out[0].1.clone()), + &mut vm.gc, + &mut vm.shapes, + ); + assert_eq!( + val.to_js_string(&vm.gc), + "claimed:1:https://example.com/app/page.html" + ); + } + + #[test] + fn client_post_message_reports_structured_clone_errors() { + let snapshot = snapshot_for(&url(), "/app/"); + let clients = vec![ServiceWorkerClientRecord::window( + "client-1".to_string(), + "https://example.com".to_string(), + "https://example.com/app/page.html".to_string(), + Some("https://example.com/app/".to_string()), + )]; + let script = r#" + self.addEventListener('message', function() { + self.clients.matchAll().then(function(clients) { + try { + clients[0].postMessage(function() {}); + } catch (err) { + self.postMessage(String(err.message || err)); + } + }); + }); + "#; + let mut thread = + ServiceWorkerThread::spawn_with_clients(script.to_string(), snapshot, clients); + let _ = thread.recv_timeout(Duration::from_secs(2)); + + let mut vm = Vm::new(); + let serialized = + structured_clone::serialize(&Value::String("go".to_string()), &vm.gc, &vm.shapes) + .unwrap(); + thread + .send(ServiceWorkerCommand::Message(serialized.into_bytes())) + .unwrap(); + + let mut received = None; + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline && received.is_none() { + if let Some(ServiceWorkerEvent::MessageOut(bytes)) = + thread.recv_timeout(Duration::from_millis(20)) + { + let val = structured_clone::deserialize( + &SerializedData::from_bytes(bytes), + &mut vm.gc, + &mut vm.shapes, + ); + received = Some(val.to_js_string(&vm.gc)); + } + } + assert_eq!( + received.as_deref(), + Some("DataCloneError: function could not be cloned") + ); + thread.terminate(); + } + #[test] fn dispatch_fetch_returns_response_for_respond_with() { let snapshot = snapshot_for(&url(), "/app/"); @@ -2597,7 +3083,7 @@ mod tests { let mut vm = Vm::new(); let (tx, _rx) = mpsc::channel::(); let snapshot = snapshot_for(&url(), "/app/"); - init_service_worker_globals(&mut vm, &snapshot, tx); + init_service_worker_globals(&mut vm, &snapshot, tx, Vec::new()); let program = parser::Parser::parse(script).expect("parse"); let func = compiler::compile(&program).expect("compile"); vm.execute(&func).expect("execute"); @@ -2799,7 +3285,7 @@ mod tests { // ourselves with a manually shortened timeout helper. let mut vm = Vm::new(); let (tx, _rx) = mpsc::channel::(); - init_service_worker_globals(&mut vm, &snapshot, tx); + init_service_worker_globals(&mut vm, &snapshot, tx, Vec::new()); let program = parser::Parser::parse(script).expect("parse"); let func = compiler::compile(&program).expect("compile"); vm.execute(&func).expect("execute"); diff --git a/crates/js/src/vm.rs b/crates/js/src/vm.rs index e4f63f7..4328039 100644 --- a/crates/js/src/vm.rs +++ b/crates/js/src/vm.rs @@ -540,6 +540,14 @@ pub struct DomBridge { pub indexeddb: RefCell, /// Service Worker registrations for the current origin. pub service_workers: RefCell, + /// Stable id for this document when it is exposed as a Service Worker + /// WindowClient. + pub service_worker_client_id: RefCell, + /// Scope URL of the Service Worker currently controlling this document. + pub service_worker_controller_scope: RefCell>, + /// Service Worker container events queued for asynchronous delivery to + /// `navigator.serviceWorker`. + pub service_worker_events: RefCell>, /// Window proxies for iframe elements, keyed by the iframe's NodeId index. /// Used to implement `contentWindow` / `contentDocument`. pub iframe_windows: RefCell>, @@ -1223,6 +1231,9 @@ impl Vm { session_storage: RefCell::new(crate::storage::StorageArea::new()), indexeddb: RefCell::new(crate::indexeddb::IndexedDbState::new()), service_workers: RefCell::new(crate::service_worker::RegistrationStore::new()), + service_worker_client_id: RefCell::new(String::new()), + service_worker_controller_scope: RefCell::new(None), + service_worker_events: RefCell::new(Vec::new()), iframe_windows: RefCell::new(HashMap::new()), location_object: RefCell::new(None), ready_state: RefCell::new("loading".to_string()), @@ -1281,8 +1292,11 @@ impl Vm { /// Set the document URL for cookie domain/path matching. pub fn set_document_url(&mut self, url: we_url::Url) { if let Some(bridge) = &self.dom_bridge { + *bridge.service_worker_client_id.borrow_mut() = + crate::service_worker::client_id_for_url(&url); *bridge.document_url.borrow_mut() = Some(url); } + crate::service_worker::refresh_controller(self); } /// Set the cookie jar on the DOM bridge (typically from the HTTP client). @@ -1355,6 +1369,7 @@ impl Vm { if let Some(bridge) = &self.dom_bridge { *bridge.service_workers.borrow_mut() = store; } + crate::service_worker::refresh_controller(self); } /// Take Service Worker registrations from the DOM bridge for persistence. @@ -1935,6 +1950,7 @@ impl Vm { crate::eventsource::drain_eventsource_events(self)?; crate::websocket::drain_websocket_events(self)?; crate::main_thread_worker::drain_worker_messages(self)?; + crate::service_worker::drain_service_worker_events(self)?; crate::broadcast_channel::drain_broadcast_messages(self) } @@ -1951,6 +1967,7 @@ impl Vm { || crate::eventsource::has_live_event_sources() || crate::websocket::has_live_websockets() || crate::main_thread_worker::has_live_workers() + || crate::service_worker::has_pending_service_worker_events(self) || crate::broadcast_channel::has_live_channels() { if max_iterations > 0 && iterations >= max_iterations { @@ -1963,6 +1980,7 @@ impl Vm { crate::eventsource::drain_eventsource_events(self)?; crate::websocket::drain_websocket_events(self)?; crate::main_thread_worker::drain_worker_messages(self)?; + crate::service_worker::drain_service_worker_events(self)?; crate::broadcast_channel::drain_broadcast_messages(self)?; iterations += 1; // If work is still pending, sleep briefly to avoid spinning. @@ -1972,6 +1990,7 @@ impl Vm { || crate::eventsource::has_live_event_sources() || crate::websocket::has_live_websockets() || crate::main_thread_worker::has_live_workers() + || crate::service_worker::has_pending_service_worker_events(self) { std::thread::sleep(std::time::Duration::from_millis(1)); }