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