From 42e292c13aca7cdcff93a7ee27e2075fc2e49f23 Mon Sep 17 00:00:00 2001 From: Alex Good Date: Thu, 28 Aug 2025 10:03:51 +0100 Subject: [PATCH 01/12] Switch from futures::channel::mpsc and std::sync::mpsc::channel to async_channel Currently document actors run on a `rayon` thread pool and use a blocking `std::sync::mpsc::channel` to communicate with the main thread. I want to make the use of a thread pool optional, spawning the actors onto the runtime if there is no thread pool configured. This means that the communication channel between the main thread and the actors must be capable of async operations. `async_channel` is a stable, and widely used async channel implementation that works in both sync and async contexts. For consistency we use it everywhere, replacing also the uses of `futures::channel::mpsc`. --- Cargo.lock | 49 ++++++++++++++++++++++++++++++++++++ samod/Cargo.toml | 1 + samod/src/actor_handle.rs | 2 +- samod/src/doc_actor_inner.rs | 29 +++++++++++---------- samod/src/io_loop.rs | 12 ++++----- samod/src/lib.rs | 27 ++++++++++++-------- samod/tests/smoke.rs | 8 ++++++ 7 files changed, 97 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e92b4e4..dc78d72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,18 @@ dependencies = [ "libc", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -267,6 +279,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -375,6 +396,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eyre" version = "0.6.12" @@ -1091,6 +1133,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1343,6 +1391,7 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" name = "samod" version = "0.3.1" dependencies = [ + "async-channel", "automerge", "axum", "bytes", diff --git a/samod/Cargo.toml b/samod/Cargo.toml index c9fcaf9..1cb062c 100644 --- a/samod/Cargo.toml +++ b/samod/Cargo.toml @@ -32,6 +32,7 @@ tracing = "0.1.41" tungstenite = { version = "0.27.0", optional = true } gio = { version = "0.20.12", optional = true } glib = { version = "0.20.12", optional = true } +async-channel = "2.5.0" [dev-dependencies] eyre = "0.6.12" diff --git a/samod/src/actor_handle.rs b/samod/src/actor_handle.rs index 91b985c..360ff83 100644 --- a/samod/src/actor_handle.rs +++ b/samod/src/actor_handle.rs @@ -5,6 +5,6 @@ use crate::{ActorTask, DocActorInner, DocHandle}; pub(crate) struct ActorHandle { #[allow(dead_code)] pub(crate) inner: Arc>, - pub(crate) tx: std::sync::mpsc::Sender, + pub(crate) tx: async_channel::Sender, pub(crate) doc: DocHandle, } diff --git a/samod/src/doc_actor_inner.rs b/samod/src/doc_actor_inner.rs index 874c316..58d6806 100644 --- a/samod/src/doc_actor_inner.rs +++ b/samod/src/doc_actor_inner.rs @@ -16,8 +16,8 @@ use crate::{ pub(crate) struct DocActorInner { document_id: DocumentId, actor_id: DocumentActorId, - tx_to_core: mpsc::UnboundedSender<(DocumentActorId, DocToHubMsg)>, - tx_io: mpsc::UnboundedSender, + tx_to_core: async_channel::Sender<(DocumentActorId, DocToHubMsg)>, + tx_io: async_channel::Sender, ephemera_listeners: Vec>>, change_listeners: Vec>, actor: DocumentActor, @@ -28,8 +28,8 @@ impl DocActorInner { document_id: DocumentId, actor_id: DocumentActorId, actor: DocumentActor, - tx_to_core: mpsc::UnboundedSender<(DocumentActorId, DocToHubMsg)>, - tx_io: mpsc::UnboundedSender, + tx_to_core: async_channel::Sender<(DocumentActorId, DocToHubMsg)>, + tx_io: async_channel::Sender, ) -> Self { DocActorInner { document_id, @@ -82,18 +82,21 @@ impl DocActorInner { stopped: _, } = results; for task in io_tasks { - let _ = self - .tx_io - // .unbounded_send((task.id, Some(self.actor_id), storage_task)); - .unbounded_send(IoLoopTask { - doc_id: self.document_id.clone(), - task, - actor_id: self.actor_id, - }); + if let Err(_e) = self.tx_io.send_blocking(IoLoopTask { + doc_id: self.document_id.clone(), + task, + actor_id: self.actor_id, + }) { + tracing::error!("io receiver dropped whilst document actor is still running"); + return; + } } for msg in outgoing_messages { - let _ = self.tx_to_core.unbounded_send((self.actor_id, msg)); + if let Err(_e) = self.tx_to_core.send_blocking((self.actor_id, msg)) { + tracing::error!("core receiver dropped whilst document actor is still running"); + return; + } } if !ephemeral_messages.is_empty() { diff --git a/samod/src/io_loop.rs b/samod/src/io_loop.rs index fb12194..ba7f8f3 100644 --- a/samod/src/io_loop.rs +++ b/samod/src/io_loop.rs @@ -1,6 +1,6 @@ use std::sync::{Arc, Mutex}; -use futures::{StreamExt, channel::mpsc, stream::FuturesUnordered}; +use futures::{FutureExt, StreamExt, stream::FuturesUnordered}; use samod_core::{ DocumentActorId, DocumentId, PeerId, actors::document::io::{DocumentIoResult, DocumentIoTask}, @@ -28,14 +28,14 @@ pub(crate) async fn io_loop( inner: Arc>, storage: S, announce_policy: A, - mut rx: mpsc::UnboundedReceiver, + rx: async_channel::Receiver, ) { let mut running_tasks = FuturesUnordered::new(); loop { futures::select! { - next_task = rx.next() => { - let Some(next_task) = next_task else { + next_task = rx.recv().fuse() => { + let Some(next_task) = next_task.ok() else { tracing::trace!("storage loop channel closed, exiting"); break; }; @@ -59,7 +59,7 @@ pub(crate) async fn io_loop( tracing::warn!(?actor_id, "received io result for unknown actor"); continue; }; - let _ = tx.send(ActorTask::IoComplete(result)); + let _ = tx.send_blocking(ActorTask::IoComplete(result)); } } } @@ -70,7 +70,7 @@ pub(crate) async fn io_loop( tracing::warn!(?actor_id, "received io result for unknown actor"); continue; }; - let _ = tx.send(ActorTask::IoComplete(result)); + let _ = tx.send_blocking(ActorTask::IoComplete(result)); } } diff --git a/samod/src/lib.rs b/samod/src/lib.rs index 149d42a..509fb44 100644 --- a/samod/src/lib.rs +++ b/samod/src/lib.rs @@ -249,7 +249,7 @@ //! use std::{ collections::HashMap, - sync::{Arc, Mutex, mpsc as std_mpsc}, + sync::{Arc, Mutex}, }; use automerge::Automerge; @@ -403,10 +403,15 @@ impl Repo { }); }; - let (tx_storage, rx_storage) = mpsc::unbounded(); - let (tx_to_core, rx_from_core) = mpsc::unbounded(); + let (tx_storage, rx_storage) = async_channel::unbounded(); + let (tx_to_core, rx_from_core) = async_channel::unbounded(); let inner = Arc::new(Mutex::new(Inner { - workers: rayon::ThreadPoolBuilder::new().build().unwrap(), + workers: rayon::ThreadPoolBuilder::new() + .panic_handler(|err| { + tracing::error!(err=?err, "panic in document worker"); + }) + .build() + .unwrap(), actors: HashMap::new(), hub: *hub, pending_commands: HashMap::new(), @@ -433,8 +438,8 @@ impl Repo { .spawn({ let inner = inner.clone(); async move { - let mut rx = rx_from_core; - while let Some((actor_id, msg)) = rx.next().await { + let rx = rx_from_core; + while let Ok((actor_id, msg)) = rx.recv().await { let event = HubEvent::actor_message(actor_id, msg); inner.lock().unwrap().handle_event(event); } @@ -731,8 +736,8 @@ struct Inner { hub: Hub, pending_commands: HashMap>, connections: HashMap, - tx_io: mpsc::UnboundedSender, - tx_to_core: mpsc::UnboundedSender<(DocumentActorId, DocToHubMsg)>, + tx_io: async_channel::Sender, + tx_to_core: async_channel::Sender<(DocumentActorId, DocToHubMsg)>, waiting_for_connection: HashMap>>, stop_waiters: Vec>, rng: rand::rngs::StdRng, @@ -797,7 +802,7 @@ impl Inner { for (actor_id, actor_msg) in actor_messages { if let Some(ActorHandle { tx, .. }) = self.actors.get(&actor_id) { - let _ = tx.send(ActorTask::HandleMessage(actor_msg)); + let _ = tx.send_blocking(ActorTask::HandleMessage(actor_msg)); } else { tracing::warn!(?actor_id, "received message for unknown actor"); } @@ -841,7 +846,7 @@ impl Inner { #[tracing::instrument(skip(self, args))] fn spawn_actor(&mut self, args: SpawnArgs) { - let (tx, rx) = std_mpsc::channel(); + let (tx, rx) = async_channel::unbounded(); let actor_id = args.actor_id(); let doc_id = args.document_id().clone(); let (actor, init_results) = DocumentActor::new(UnixTimestamp::now(), args); @@ -868,7 +873,7 @@ impl Inner { let _enter = span.enter(); doc_inner.lock().unwrap().handle_results(init_results); - while let Ok(actor_task) = rx.recv() { + while let Ok(actor_task) = rx.recv_blocking() { let mut inner = doc_inner.lock().unwrap(); inner.handle_task(actor_task); if inner.is_stopped() { diff --git a/samod/tests/smoke.rs b/samod/tests/smoke.rs index cb34500..1d02a11 100644 --- a/samod/tests/smoke.rs +++ b/samod/tests/smoke.rs @@ -75,6 +75,8 @@ async fn basic_sync() { let bob_handle = bob.find(alice_handle.document_id().clone()).await.unwrap(); assert!(bob_handle.is_some()); + bob.stop().await; + alice.stop().await; } #[tokio::test] @@ -120,6 +122,8 @@ async fn non_announcing_peers_dont_sync() { // Bob should not find the document because alice did not announce it let bob_handle = bob.find(alice_handle.document_id().clone()).await.unwrap(); assert!(bob_handle.is_none()); + bob.stop().await; + alice.stop().await; } #[cfg(feature = "tokio")] @@ -170,6 +174,8 @@ async fn ephemera_smoke() { tokio::time::sleep(Duration::from_millis(100)).await; assert_eq!(*bob_received.lock().unwrap(), vec![vec![1, 2, 3]]); + bob.stop().await; + alice.stop().await; } #[cfg(feature = "tokio")] @@ -230,4 +236,6 @@ async fn change_listeners_smoke() { tokio::time::sleep(Duration::from_millis(100)).await; assert_eq!(*bob_received.lock().unwrap(), vec![new_heads]); + bob.stop().await; + alice.stop().await; } From 0be8383b95a16b0e15dca7c5c606e5e14082fa11 Mon Sep 17 00:00:00 2001 From: Alex Good Date: Thu, 28 Aug 2025 10:03:51 +0100 Subject: [PATCH 02/12] Simplify RuntimeHandle The RuntimeHandle trait has a whole bunch of requirements that we don't actually need because we never look at the results of tasks which we spawn, we just fire and forget. --- samod/src/lib.rs | 45 ++++++++++++------------ samod/src/runtime.rs | 19 ++-------- samod/src/runtime/gio.rs | 58 ++----------------------------- samod/src/runtime/localpool.rs | 63 +++------------------------------- samod/src/runtime/tokio.rs | 27 +++------------ 5 files changed, 36 insertions(+), 176 deletions(-) diff --git a/samod/src/lib.rs b/samod/src/lib.rs index 509fb44..7cb7c7e 100644 --- a/samod/src/lib.rs +++ b/samod/src/lib.rs @@ -255,7 +255,7 @@ use std::{ use automerge::Automerge; use conn_handle::ConnHandle; use futures::{ - Sink, SinkExt, Stream, StreamExt, + FutureExt, Sink, SinkExt, Stream, StreamExt, channel::{mpsc, oneshot}, stream::FuturesUnordered, }; @@ -423,29 +423,28 @@ impl Repo { rng: rand::rngs::StdRng::from_os_rng(), })); - // These futures are spawned on the runtime so they run regardless of awaiting - #[allow(clippy::let_underscore_future)] - let _ = runtime.spawn(io_loop::io_loop( - peer_id.clone(), - inner.clone(), - storage, - announce_policy, - rx_storage, - )); - // These futures are spawned on the runtime so they run regardless of awaiting - #[allow(clippy::let_underscore_future)] - let _ = runtime - .spawn({ - let inner = inner.clone(); - async move { - let rx = rx_from_core; - while let Ok((actor_id, msg)) = rx.recv().await { - let event = HubEvent::actor_message(actor_id, msg); - inner.lock().unwrap().handle_event(event); - } + runtime.spawn( + io_loop::io_loop( + peer_id.clone(), + inner.clone(), + storage, + announce_policy, + rx_storage, + ) + .boxed(), + ); + runtime.spawn({ + let inner = inner.clone(); + async move { + let rx = rx_from_core; + while let Ok((actor_id, msg)) = rx.recv().await { + let event = HubEvent::actor_message(actor_id, msg); + inner.lock().unwrap().handle_event(event); } - }) - .instrument(tracing::info_span!("actor_loop", local_peer_id=%peer_id)); + } + .instrument(tracing::info_span!("actor_loop", local_peer_id=%peer_id)) + .boxed() + }); Self { inner } } diff --git a/samod/src/runtime.rs b/samod/src/runtime.rs index d25b75b..6226201 100644 --- a/samod/src/runtime.rs +++ b/samod/src/runtime.rs @@ -1,6 +1,4 @@ -//! An abstraction over async runtimes - -use std::any::Any; +use std::pin::Pin; use futures::Future; @@ -17,18 +15,7 @@ mod tokio; /// [`Storage`](crate::Storage) or pass messages between different document /// threads and the central control loop of the repo. [`RuntimeHandle`] /// represents this ability to spawn tasks. -pub trait RuntimeHandle: Clone + 'static { - type JoinErr: JoinError + std::error::Error; - type JoinFuture: Future> + Unpin; - +pub trait RuntimeHandle: 'static { /// Spawn a task to be run in the background - fn spawn(&self, f: F) -> Self::JoinFuture - where - O: Send + 'static, - F: Future + Send + 'static; -} - -pub trait JoinError { - fn is_panic(&self) -> bool; - fn into_panic(self) -> Box; + fn spawn(&self, f: Pin + Send + 'static>>); } diff --git a/samod/src/runtime/gio.rs b/samod/src/runtime/gio.rs index a30feeb..d2a055f 100644 --- a/samod/src/runtime/gio.rs +++ b/samod/src/runtime/gio.rs @@ -1,8 +1,5 @@ use std::pin::Pin; -use crate::runtime::JoinError; -use futures::FutureExt; - /// A [`RuntimeHandle`](crate::runtime::RuntimeHandle) implementation which usese the `glib` crate to spawn tasks /// /// This runtime will panic if used outside of a `glib` main loop context @@ -22,58 +19,7 @@ impl Default for GioRuntime { } impl crate::runtime::RuntimeHandle for GioRuntime { - type JoinErr = GlibJoinError; - - type JoinFuture = GlibJoinHandle; - - fn spawn(&self, f: F) -> Self::JoinFuture - where - O: Send + 'static, - F: Future + Send + 'static, - { - GlibJoinHandle(glib::spawn_future(f)) - } -} - -pub struct GlibJoinError(glib::JoinError); - -impl std::fmt::Display for GlibJoinError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "GlibJoinError: {}", self.0) - } -} - -impl std::fmt::Debug for GlibJoinError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "GlibJoinError: {}", self.0) - } -} - -impl std::error::Error for GlibJoinError {} - -impl JoinError for GlibJoinError { - fn is_panic(&self) -> bool { - false - } - - fn into_panic(self) -> Box { - panic!("not a panic error") - } -} - -pub struct GlibJoinHandle(glib::JoinHandle); - -impl Future for GlibJoinHandle { - type Output = Result; - - fn poll( - mut self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - match self.0.poll_unpin(cx) { - std::task::Poll::Ready(Ok(value)) => std::task::Poll::Ready(Ok(value)), - std::task::Poll::Ready(Err(err)) => std::task::Poll::Ready(Err(GlibJoinError(err))), - std::task::Poll::Pending => std::task::Poll::Pending, - } + fn spawn(&self, f: Pin + Send + 'static>>) { + glib::spawn_future(f); } } diff --git a/samod/src/runtime/localpool.rs b/samod/src/runtime/localpool.rs index baa9a21..0566295 100644 --- a/samod/src/runtime/localpool.rs +++ b/samod/src/runtime/localpool.rs @@ -1,4 +1,6 @@ -use futures::{FutureExt, executor::LocalSpawner, task::LocalSpawnExt}; +use std::pin::Pin; + +use futures::{executor::LocalSpawner, task::LocalSpawnExt}; use crate::runtime::RuntimeHandle; @@ -6,62 +8,7 @@ use crate::runtime::RuntimeHandle; pub struct LocalPoolRuntime; impl RuntimeHandle for LocalSpawner { - type JoinErr = SpawnError; - type JoinFuture = - futures::future::LocalBoxFuture<'static, Result>; - - fn spawn(&self, f: F) -> Self::JoinFuture - where - O: Send + 'static, - F: Future + 'static, - { - let (tx, rx) = futures::channel::oneshot::channel(); - // Note we can't use `spawn_local_with_handle` because the returned `RemoteHandle` - // cancels the underlying future when it is dropped, which is not the semantics we - // want. - let spawn_result = self.spawn_local(async move { - let res = f.await; - let _ = tx.send(res); - }); - async move { - if let Err(e) = spawn_result { - return Err(SpawnError(e)); - } - let result = rx - .await - .expect("the future was spawned and the last thing we do is send to the channel"); - Ok(result) - } - .boxed_local() - } -} - -pub struct SpawnError(futures::task::SpawnError); - -impl std::fmt::Display for SpawnError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl std::fmt::Debug for SpawnError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl std::error::Error for SpawnError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.0.source() - } -} - -impl super::JoinError for SpawnError { - fn is_panic(&self) -> bool { - false - } - - fn into_panic(self) -> Box { - panic!("not a panic"); + fn spawn(&self, f: Pin + Send + 'static>>) { + self.spawn_local(f).unwrap(); } } diff --git a/samod/src/runtime/tokio.rs b/samod/src/runtime/tokio.rs index ea41eda..a103b92 100644 --- a/samod/src/runtime/tokio.rs +++ b/samod/src/runtime/tokio.rs @@ -1,28 +1,9 @@ -use std::any::Any; +use std::pin::Pin; -use crate::runtime::{JoinError, RuntimeHandle}; - -use tokio::task::JoinError as TokioJoinError; +use crate::runtime::RuntimeHandle; impl RuntimeHandle for tokio::runtime::Handle { - type JoinErr = tokio::task::JoinError; - type JoinFuture = tokio::task::JoinHandle; - - fn spawn(&self, f: F) -> Self::JoinFuture - where - O: Send + 'static, - F: Future + Send + 'static, - { - self.spawn(f) - } -} - -impl JoinError for TokioJoinError { - fn is_panic(&self) -> bool { - self.is_panic() - } - - fn into_panic(self) -> Box { - self.into_panic() + fn spawn(&self, f: Pin + Send + 'static>>) { + self.spawn(f); } } From 6c022636a71c507e014d0dc1c2a15ccebaa6c4d6 Mon Sep 17 00:00:00 2001 From: Alex Good Date: Thu, 28 Aug 2025 10:03:51 +0100 Subject: [PATCH 03/12] Allow running document actors on the async runtime Problem: it is not always desirable or possible to run each document actor on its own thread. For example, in node (via WebAssembly) it's not possible to spawn new threads. Solution: allow document actors to be run on the async runtime provided to `SamodBuilder` --- samod/src/builder.rs | 10 +++ samod/src/doc_runner.rs | 23 +++++++ samod/src/lib.rs | 112 +++++++++++++++++++++++++++------ samod/tests/localpool_smoke.rs | 2 + 4 files changed, 128 insertions(+), 19 deletions(-) create mode 100644 samod/src/doc_runner.rs diff --git a/samod/src/builder.rs b/samod/src/builder.rs index 8b867ce..06f9a77 100644 --- a/samod/src/builder.rs +++ b/samod/src/builder.rs @@ -13,6 +13,7 @@ pub struct RepoBuilder { pub(crate) runtime: R, pub(crate) announce_policy: A, pub(crate) peer_id: Option, + pub(crate) threadpool: Option, } impl RepoBuilder { @@ -22,6 +23,7 @@ impl RepoBuilder { peer_id: self.peer_id, runtime: self.runtime, announce_policy: self.announce_policy, + threadpool: self.threadpool, } } @@ -31,6 +33,7 @@ impl RepoBuilder { peer_id: self.peer_id, storage: self.storage, announce_policy: self.announce_policy, + threadpool: self.threadpool, } } @@ -48,8 +51,14 @@ impl RepoBuilder { peer_id: self.peer_id, storage: self.storage, announce_policy, + threadpool: self.threadpool, } } + + pub fn with_threadpool(mut self, threadpool: Option) -> Self { + self.threadpool = threadpool; + self + } } impl RepoBuilder { @@ -59,6 +68,7 @@ impl RepoBuilder { runtime, peer_id: None, announce_policy: AlwaysAnnounce, + threadpool: None, } } } diff --git a/samod/src/doc_runner.rs b/samod/src/doc_runner.rs new file mode 100644 index 0000000..29da8ab --- /dev/null +++ b/samod/src/doc_runner.rs @@ -0,0 +1,23 @@ +use std::sync::{Arc, Mutex}; + +use samod_core::{DocumentActorId, DocumentId, actors::document::DocActorResult}; + +use crate::{actor_task::ActorTask, doc_actor_inner::DocActorInner}; + +/// Enum representing the two possible ways of running document actors +pub(crate) enum DocRunner { + /// Run the actors on a threadpool + Threadpool(rayon::ThreadPool), + /// Run the actors on an async task which is listening on the other end of `tx` + Async { + tx: async_channel::Sender, + }, +} + +pub(crate) struct SpawnedActor { + pub(crate) doc_id: DocumentId, + pub(crate) actor_id: DocumentActorId, + pub(crate) inner: Arc>, + pub(crate) rx_tasks: async_channel::Receiver, + pub(crate) init_results: DocActorResult, +} diff --git a/samod/src/lib.rs b/samod/src/lib.rs index 7cb7c7e..5387ae7 100644 --- a/samod/src/lib.rs +++ b/samod/src/lib.rs @@ -285,6 +285,7 @@ mod conn_handle; pub use conn_finished_reason::ConnFinishedReason; mod doc_actor_inner; mod doc_handle; +mod doc_runner; mod io_loop; pub use doc_handle::DocHandle; mod peer_connection_info; @@ -294,7 +295,11 @@ pub use stopped::Stopped; pub mod storage; pub use crate::announce_policy::{AlwaysAnnounce, AnnouncePolicy}; use crate::storage::InMemoryStorage; -use crate::{doc_actor_inner::DocActorInner, storage::Storage}; +use crate::{ + doc_actor_inner::DocActorInner, + doc_runner::{DocRunner, SpawnedActor}, + storage::Storage, +}; pub mod runtime; pub mod websocket; @@ -374,6 +379,7 @@ impl Repo { runtime, peer_id, announce_policy, + threadpool, } = builder; let mut rng = rand::rngs::StdRng::from_rng(&mut rand::rng()); let peer_id = peer_id.unwrap_or_else(|| PeerId::new_with_rng(&mut rng)); @@ -405,13 +411,15 @@ impl Repo { let (tx_storage, rx_storage) = async_channel::unbounded(); let (tx_to_core, rx_from_core) = async_channel::unbounded(); + let doc_runner = if let Some(threadpool) = threadpool { + DocRunner::Threadpool(threadpool) + } else { + let (tx, rx) = async_channel::unbounded(); + runtime.spawn(async_actor_runner(rx).boxed()); + DocRunner::Async { tx } + }; let inner = Arc::new(Mutex::new(Inner { - workers: rayon::ThreadPoolBuilder::new() - .panic_handler(|err| { - tracing::error!(err=?err, "panic in document worker"); - }) - .build() - .unwrap(), + doc_runner, actors: HashMap::new(), hub: *hub, pending_commands: HashMap::new(), @@ -730,7 +738,7 @@ impl Repo { } struct Inner { - workers: rayon::ThreadPool, + doc_runner: DocRunner, actors: HashMap, hub: Hub, pending_commands: HashMap>, @@ -868,19 +876,85 @@ impl Inner { ); let span = tracing::Span::current(); - self.workers.spawn(move || { - let _enter = span.enter(); - doc_inner.lock().unwrap().handle_results(init_results); - - while let Ok(actor_task) = rx.recv_blocking() { - let mut inner = doc_inner.lock().unwrap(); - inner.handle_task(actor_task); - if inner.is_stopped() { - tracing::debug!(?doc_id, ?actor_id, "actor stopped"); - break; + match &mut self.doc_runner { + DocRunner::Threadpool(threadpool) => { + threadpool.spawn(move || { + let _enter = span.enter(); + doc_inner.lock().unwrap().handle_results(init_results); + + while let Ok(actor_task) = rx.recv_blocking() { + let mut inner = doc_inner.lock().unwrap(); + inner.handle_task(actor_task); + if inner.is_stopped() { + tracing::debug!(?doc_id, ?actor_id, "actor stopped"); + break; + } + } + }); + } + DocRunner::Async { tx } => { + if tx + .send_blocking(SpawnedActor { + doc_id, + actor_id, + inner: doc_inner, + rx_tasks: rx, + init_results, + }) + .is_err() + { + tracing::error!(?actor_id, "actor spawner is gone"); } } - }); + } + } +} + +/// Spawns a task which listens for new actors to spawn and runs them +/// +/// `samod` has two ways of running document actors, on a rayon threadpool, or +/// on the async runtime which was provided to the `SamodBuilder`. In the latter +/// case we don't actually hold on to a reference to the `RuntimeHandle` because +/// that requires it to be `Send` which is not always the case (e.g. when using +/// futures::executor::LocalPool). Instead, we spawn a task on the runtime which +/// listens on a channel for new actors to spawn and then runs them on a +/// `FuturesUnordered`. This function is that task. +async fn async_actor_runner(rx: async_channel::Receiver) { + let mut running_actors = FuturesUnordered::new(); + + loop { + futures::select! { + spawn_actor = rx.recv().fuse() => { + match spawn_actor { + Err(_e) => { + tracing::trace!("actor spawner task finished"); + break; + } + Ok(SpawnedActor { inner, rx_tasks, init_results, doc_id, actor_id }) => { + running_actors.push(async move { + inner.lock().unwrap().handle_results(init_results); + + while let Ok(actor_task) = rx_tasks.recv().await { + let mut inner = inner.lock().unwrap(); + inner.handle_task(actor_task); + if inner.is_stopped() { + tracing::debug!(?doc_id, ?actor_id, "actor stopped"); + break; + } + } + }); + } + } + }, + _ = running_actors.next() => { + // nothing to do + } + } + } + + // Wait for all actors to stop + while running_actors.next().await.is_some() { + // nothing to do } } diff --git a/samod/tests/localpool_smoke.rs b/samod/tests/localpool_smoke.rs index c32b8fb..fa71422 100644 --- a/samod/tests/localpool_smoke.rs +++ b/samod/tests/localpool_smoke.rs @@ -30,11 +30,13 @@ fn test_localpool() { .spawn_local(async move { let alice = samod::Repo::build_localpool(spawner.clone()) .with_peer_id("alice".into()) + .with_threadpool(None) .load() .await; let bob = samod::Repo::build_localpool(spawner.clone()) .with_peer_id("bob".into()) + .with_threadpool(None) .load() .await; From 15f9d2accc0b0b281d7bc57a00eea0b369ae727d Mon Sep 17 00:00:00 2001 From: "Jack D. Douglas" Date: Tue, 2 Sep 2025 11:04:32 +0100 Subject: [PATCH 04/12] feat: add WASM runtime support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add `wasm` feature flag with dependencies (`wasm-bindgen`, `web-sys`, `js-sys`) │ │ - implement `WasmRuntime` using `wasm_bindgen_futures::spawn_local` │ │ - add time provider abstraction for external time sources in WASM │ │ - update `UnixTimestamp` to use `js_sys::Date::now()` on WASM targets - add `set_time_provider` WASM binding for WASI environments - configure build for WASM target in `.cargo/config.toml` │ │ --- .cargo/config.toml | 6 ++ .envrc | 1 + .gitignore | 1 + Cargo.lock | 10 ++ flake.lock | 100 ++++++++++++++++++ flake.nix | 46 ++++++++ rust-toolchain.toml | 4 + samod-core/Cargo.toml | 17 ++- .../src/actors/document/doc_actor_result.rs | 16 +-- .../src/actors/document/document_actor.rs | 4 +- .../src/actors/hub/connection/connection.rs | 12 +-- samod-core/src/actors/hub/state.rs | 4 +- samod-core/src/lib.rs | 3 + samod-core/src/network/wire_protocol.rs | 6 +- samod-core/src/time_provider.rs | 24 +++++ samod-core/src/unix_timestamp.rs | 42 +++++++- samod-test-harness/Cargo.toml | 3 +- samod/Cargo.toml | 29 ++++- samod/src/doc_actor_inner.rs | 4 +- samod/src/io_loop.rs | 4 +- samod/src/lib.rs | 59 ++++++----- samod/src/runtime.rs | 2 + samod/src/runtime/wasm.rs | 24 +++++ 23 files changed, 360 insertions(+), 61 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rust-toolchain.toml create mode 100644 samod-core/src/time_provider.rs create mode 100644 samod/src/runtime/wasm.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..2d17ada --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +# Configuration for wasm32-unknown-unknown target (browsers, wasm-pack) +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"', '-C', 'target-feature=+bulk-memory,+mutable-globals'] + +[unstable] +build-std = ["panic_abort", "std"] diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 7f232bb..f0394d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ .zed +.direnv diff --git a/Cargo.lock b/Cargo.lock index dc78d72..4686218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,9 +573,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1400,6 +1402,7 @@ dependencies = [ "futures", "gio", "glib", + "js-sys", "rand", "rayon", "reqwest", @@ -1413,6 +1416,9 @@ dependencies = [ "tracing", "tracing-subscriber", "tungstenite 0.27.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -1422,7 +1428,9 @@ dependencies = [ "automerge", "base64 0.21.7", "bs58", + "getrandom", "hex", + "js-sys", "minicbor", "rand", "samod-test-harness", @@ -1433,6 +1441,8 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "wasm-bindgen", + "web-sys", ] [[package]] diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3f3560a --- /dev/null +++ b/flake.lock @@ -0,0 +1,100 @@ +{ + "nodes": { + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1756795219, + "narHash": "sha256-tKBQtz1JLKWrCJUxVkHKR+YKmVpm0KZdJdPWmR2slQ8=", + "owner": "nix-community", + "repo": "fenix", + "rev": "80dbdab137f2809e3c823ed027e1665ce2502d74", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1756542300, + "narHash": "sha256-tlOn88coG5fzdyqz6R93SQL5Gpq+m/DsWpekNFhqPQk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d7600c775f877cd87b4f5a831c28aa94137377aa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "fenix": "fenix", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1756597274, + "narHash": "sha256-wfaKRKsEVQDB7pQtAt04vRgFphkVscGRpSx3wG1l50E=", + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "21614ed2d3279a9aa1f15c88d293e65a98991b30", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..f1a735a --- /dev/null +++ b/flake.nix @@ -0,0 +1,46 @@ +{ + description = "samod"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + fenix = { + url = "github:nix-community/fenix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + fenix, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + fenixPkgs = fenix.packages.${system}; + + rustToolchain = fenixPkgs.fromToolchainFile { + file = ./rust-toolchain.toml; + sha256 = "sha256-Rlv/WRxdeoXfY+5+4K66ERMmZZmeEVuxZb8GvexBh5c="; + }; + in + { + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + # Combined Rust toolchain with WASM targets + rustToolchain + + # WebAssembly tools + wasm-pack + + # LLVM + llvmPackages.bintools + ]; + }; + } + ); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..86e8f38 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly" +components = ["rustfmt", "clippy", "rust-src"] +targets = ["wasm32-unknown-unknown"] diff --git a/samod-core/Cargo.toml b/samod-core/Cargo.toml index a55bd7e..0c2fc90 100644 --- a/samod-core/Cargo.toml +++ b/samod-core/Cargo.toml @@ -10,7 +10,7 @@ license = "MIT" [dependencies] base64 = "0.21" tracing = "0.1.41" -uuid = { version = "1.0", features = ["v4", "serde"] } +uuid = { version = "1.0", features = ["v4", "serde", "js"] } rand = "0.9" automerge = "0.6.1" serde = { version = "1.0", features = ["derive"] } @@ -21,8 +21,21 @@ bs58 = { version = "0.5.1", features = ["check"] } sha2 = "0.10.9" [lib] +crate-type = ["cdylib", "rlib"] + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = { version = "0.3", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +web-sys = { version = "0.3", optional = true } +getrandom = { version = "0.3", features = ["wasm_js"] } + +[features] +default = [] +wasm = ["js-sys", "wasm-bindgen", "web-sys"] [dev-dependencies] tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } samod-test-harness = { path = "../samod-test-harness" } + +[target.'cfg(not(taget_arch = "wasm32"))'.dev-dependencies] +tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } diff --git a/samod-core/src/actors/document/doc_actor_result.rs b/samod-core/src/actors/document/doc_actor_result.rs index d5d2d8f..49ed320 100644 --- a/samod-core/src/actors/document/doc_actor_result.rs +++ b/samod-core/src/actors/document/doc_actor_result.rs @@ -7,7 +7,7 @@ use crate::{ }; /// Result of processing a message or I/O completion. -#[derive(Debug)] +#[derive(Debug, Default)] pub struct DocActorResult { /// Document I/O tasks that need to be executed by the caller. pub io_tasks: Vec>, @@ -24,13 +24,7 @@ pub struct DocActorResult { impl DocActorResult { /// Creates an empty result. pub fn new() -> Self { - Self { - io_tasks: Vec::new(), - outgoing_messages: Vec::new(), - ephemeral_messages: Vec::new(), - change_events: Vec::new(), - stopped: false, - } + Self::default() } pub(crate) fn emit_ephemeral_message(&mut self, msg: Vec) { @@ -65,9 +59,3 @@ impl DocActorResult { task_id } } - -impl Default for DocActorResult { - fn default() -> Self { - Self::new() - } -} diff --git a/samod-core/src/actors/document/document_actor.rs b/samod-core/src/actors/document/document_actor.rs index 71fe8d9..83aa802 100644 --- a/samod-core/src/actors/document/document_actor.rs +++ b/samod-core/src/actors/document/document_actor.rs @@ -146,8 +146,8 @@ impl DocumentActor { &self.document_id } - fn local_peer_id(&self) -> PeerId { - self.local_peer_id.clone() + fn local_peer_id(&self) -> &PeerId { + &self.local_peer_id } /// Provides mutable access to the document with automatic side effect handling. diff --git a/samod-core/src/actors/hub/connection/connection.rs b/samod-core/src/actors/hub/connection/connection.rs index 11536a7..1374118 100644 --- a/samod-core/src/actors/hub/connection/connection.rs +++ b/samod-core/src/actors/hub/connection/connection.rs @@ -5,7 +5,7 @@ use crate::{ actors::{hub::HubResults, messages::SyncMessage}, network::{ ConnDirection, ConnectionInfo, ConnectionState, PeerDocState, PeerMetadata, - wire_protocol::WireMessage, + wire_protocol::{PROTOCOL_VERSION, WireMessage}, }, }; @@ -74,7 +74,7 @@ impl Connection { created_at, WireMessage::Join { sender_id: local_peer_id.clone(), - supported_protocol_versions: vec!["1".to_string()], + supported_protocol_versions: vec![PROTOCOL_VERSION.to_string()], metadata: local_metadata.as_ref().map(|meta| meta.to_wire(None)), }, ); @@ -107,7 +107,7 @@ impl Connection { ?supported_protocol_versions, "received Join message from peer" ); - if !supported_protocol_versions.contains(&"1".to_string()) { + if !supported_protocol_versions.contains(&PROTOCOL_VERSION.to_string()) { tracing::warn!(conn_id=?self.id, "peer does not support protocol version 1"); self.send( out, @@ -125,7 +125,7 @@ impl Connection { now, WireMessage::Peer { sender_id: self.local_peer_id.clone(), - selected_protocol_version: "1".to_string(), + selected_protocol_version: PROTOCOL_VERSION.to_string(), target_id: sender_id.clone(), metadata: self.local_metadata.as_ref().map(|meta| meta.to_wire(None)), }, @@ -133,7 +133,7 @@ impl Connection { self.phase = ConnectionPhase::Established(EstablishedConnection { remote_peer_id: sender_id.clone(), remote_metadata: metadata.map(PeerMetadata::from_wire), - protocol_version: "1".to_string(), + protocol_version: PROTOCOL_VERSION.to_string(), established_at: now, document_subscriptions: HashMap::new(), }); @@ -172,7 +172,7 @@ impl Connection { ?target_id, "received Peer message from peer" ); - if selected_protocol_version != "1" { + if selected_protocol_version != PROTOCOL_VERSION { tracing::warn!(conn_id=?self.id, "peer does not support protocol version 1"); self.send( out, diff --git a/samod-core/src/actors/hub/state.rs b/samod-core/src/actors/hub/state.rs index 69bf5b2..f4c0ece 100644 --- a/samod-core/src/actors/hub/state.rs +++ b/samod-core/src/actors/hub/state.rs @@ -15,7 +15,7 @@ use crate::{ network::{ ConnDirection, ConnectionEvent, ConnectionInfo, ConnectionState, PeerDocState, PeerInfo, PeerMetadata, - wire_protocol::{WireMessage, WireMessageBuilder}, + wire_protocol::{PROTOCOL_VERSION, WireMessage, WireMessageBuilder}, }, }; @@ -579,7 +579,7 @@ impl State { let peer_info = PeerInfo { peer_id: remote_peer_id.clone(), metadata: Some(self.get_local_metadata()), - protocol_version: "1".to_string(), + protocol_version: PROTOCOL_VERSION.to_string(), }; out.emit_connection_event(ConnectionEvent::HandshakeCompleted { connection_id, diff --git a/samod-core/src/lib.rs b/samod-core/src/lib.rs index 9f7777f..4df7957 100644 --- a/samod-core/src/lib.rs +++ b/samod-core/src/lib.rs @@ -23,3 +23,6 @@ pub use unix_timestamp::UnixTimestamp; mod loader; pub use loader::{LoaderState, SamodLoader}; + +#[cfg(feature = "wasm")] +pub mod time_provider; diff --git a/samod-core/src/network/wire_protocol.rs b/samod-core/src/network/wire_protocol.rs index 6c57348..2179106 100644 --- a/samod-core/src/network/wire_protocol.rs +++ b/samod-core/src/network/wire_protocol.rs @@ -1,6 +1,8 @@ use crate::{DocumentId, PeerId, StorageId, actors::messages::SyncMessage}; use std::{collections::HashMap, str::FromStr}; +pub const PROTOCOL_VERSION: &str = "1"; + /// Metadata sent in join or peer messages #[derive(Debug, Clone, PartialEq, Eq)] pub struct PeerMetadata { @@ -734,7 +736,7 @@ mod tests { fn test_join_message_roundtrip() { let msg = WireMessage::Join { sender_id: PeerId::from("test-peer"), - supported_protocol_versions: vec!["1".to_string()], + supported_protocol_versions: vec![PROTOCOL_VERSION.to_string()], metadata: Some(PeerMetadata { storage_id: Some(StorageId::new(&mut rand::rng())), is_ephemeral: false, @@ -750,7 +752,7 @@ mod tests { fn test_peer_message_roundtrip() { let msg = WireMessage::Peer { sender_id: PeerId::from("sender"), - selected_protocol_version: "1".to_string(), + selected_protocol_version: PROTOCOL_VERSION.to_string(), target_id: PeerId::from("target"), metadata: None, }; diff --git a/samod-core/src/time_provider.rs b/samod-core/src/time_provider.rs new file mode 100644 index 0000000..c83100d --- /dev/null +++ b/samod-core/src/time_provider.rs @@ -0,0 +1,24 @@ +use wasm_bindgen::prelude::*; + +thread_local! { + static TIME_PROVIDER: std::cell::RefCell> = std::cell::RefCell::new(None); +} + +#[wasm_bindgen] +pub fn set_time_provider(callback: js_sys::Function) { + TIME_PROVIDER.with(|provider| { + *provider.borrow_mut() = Some(callback); + }); +} + +pub(crate) fn get_external_time() -> Option { + TIME_PROVIDER.with(|provider| { + let provider_ref = provider.borrow(); + if let Some(callback) = provider_ref.as_ref() { + let result = callback.call0(&JsValue::NULL).ok()?; + Some(result.as_f64()? as u128) + } else { + None + } + }) +} diff --git a/samod-core/src/unix_timestamp.rs b/samod-core/src/unix_timestamp.rs index 7782e77..089024e 100644 --- a/samod-core/src/unix_timestamp.rs +++ b/samod-core/src/unix_timestamp.rs @@ -1,8 +1,14 @@ use std::{ ops::{Add, AddAssign, Sub}, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::Duration, }; +#[cfg(not(target_arch = "wasm32"))] +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(target_arch = "wasm32")] +use js_sys; + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct UnixTimestamp { millis: u128, @@ -22,6 +28,40 @@ impl std::fmt::Debug for UnixTimestamp { impl UnixTimestamp { pub fn now() -> Self { + #[cfg(target_arch = "wasm32")] + { + // Try to get external time provider first + if let Some(millis) = crate::time_provider::get_external_time() { + return Self { millis }; + } + + let result = std::panic::catch_unwind(|| js_sys::Date::now()); + + match result { + Ok(millis) => Self { + millis: millis as u128, + }, + Err(_) => { + panic!( + "Cannot access Date.now() in this WASM environment!\n\ + \n\ + If you're using Node.js or another WASI runtime, you need to provide \n\ + a time function. Example for Node.js:\n\ + \n\ + ```javascript\n\ + import init, {{ set_time_provider }} from './samod_core.js';\n\ + \n\ + await init();\n\ + set_time_provider(() => Date.now());\n\ + ```\n\ + \n\ + See https://github.com/alexjg/samod#wasi-environments for more details." + ) + } + } + } + + #[cfg(not(target_arch = "wasm32"))] Self { millis: SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/samod-test-harness/Cargo.toml b/samod-test-harness/Cargo.toml index d248c9f..1cd6305 100644 --- a/samod-test-harness/Cargo.toml +++ b/samod-test-harness/Cargo.toml @@ -10,5 +10,4 @@ samod-core = { path = "../samod-core" } tracing = "0.1.41" [lib] - -pulish = false +publish = false diff --git a/samod/Cargo.toml b/samod/Cargo.toml index 1cb062c..aa6df71 100644 --- a/samod/Cargo.toml +++ b/samod/Cargo.toml @@ -12,6 +12,7 @@ tokio = ["dep:tokio", "dep:tokio-util"] axum = ["dep:axum", "dep:tokio", "dep:tokio-util"] tungstenite = ["dep:tungstenite", "dep:tokio-tungstenite", "tokio"] gio = ["dep:gio", "dep:glib"] +wasm = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:js-sys", "dep:web-sys", "samod-core/wasm"] [dependencies] automerge = "0.6.1" @@ -34,6 +35,27 @@ gio = { version = "0.20.12", optional = true } glib = { version = "0.20.12", optional = true } async-channel = "2.5.0" +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +js-sys = { version = "0.3", optional = true } +web-sys = { version = "0.3", features = [ + "console", + "Window", + "WorkerGlobalScope", + "WebSocket", + "MessageEvent", + "CloseEvent", + "ErrorEvent", + "BinaryType", + "Blob", + "FileReader", + "File", + "FileSystemDirectoryHandle", + "FileSystemFileHandle", + "FileSystemWritableFileStream", +], optional = true } + [dev-dependencies] eyre = "0.6.12" reqwest = { version = "0.12.22", features = [ @@ -41,6 +63,9 @@ reqwest = { version = "0.12.22", features = [ "blocking", ], default-features = false } tempfile = "3.20.0" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +[target.'cfg(not(taget_arch = "wasm32"))'.dev-dependencies] tokio = { version = "1.46.0", features = [ "rt", "time", @@ -51,7 +76,9 @@ tokio = { version = "1.46.0", features = [ tokio-test = { version = "0.4.4" } tokio-stream = { version = "0.1.17", features = ["io-util"] } tokio-util = { version = "0.7.15", features = ["codec", "net"] } -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +[lib] +crate-type = ["cdylib", "rlib"] [[test]] name = "js_interop" diff --git a/samod/src/doc_actor_inner.rs b/samod/src/doc_actor_inner.rs index 58d6806..b3cfa91 100644 --- a/samod/src/doc_actor_inner.rs +++ b/samod/src/doc_actor_inner.rs @@ -82,7 +82,7 @@ impl DocActorInner { stopped: _, } = results; for task in io_tasks { - if let Err(_e) = self.tx_io.send_blocking(IoLoopTask { + if let Err(_e) = self.tx_io.try_send(IoLoopTask { doc_id: self.document_id.clone(), task, actor_id: self.actor_id, @@ -93,7 +93,7 @@ impl DocActorInner { } for msg in outgoing_messages { - if let Err(_e) = self.tx_to_core.send_blocking((self.actor_id, msg)) { + if let Err(_e) = self.tx_to_core.try_send((self.actor_id, msg)) { tracing::error!("core receiver dropped whilst document actor is still running"); return; } diff --git a/samod/src/io_loop.rs b/samod/src/io_loop.rs index ba7f8f3..d6eb671 100644 --- a/samod/src/io_loop.rs +++ b/samod/src/io_loop.rs @@ -59,7 +59,7 @@ pub(crate) async fn io_loop( tracing::warn!(?actor_id, "received io result for unknown actor"); continue; }; - let _ = tx.send_blocking(ActorTask::IoComplete(result)); + let _ = tx.try_send(ActorTask::IoComplete(result)); } } } @@ -70,7 +70,7 @@ pub(crate) async fn io_loop( tracing::warn!(?actor_id, "received io result for unknown actor"); continue; }; - let _ = tx.send_blocking(ActorTask::IoComplete(result)); + let _ = tx.try_send(ActorTask::IoComplete(result)); } } diff --git a/samod/src/lib.rs b/samod/src/lib.rs index 5387ae7..4f184ae 100644 --- a/samod/src/lib.rs +++ b/samod/src/lib.rs @@ -333,8 +333,8 @@ pub struct Repo { } impl Repo { - // Create a new [`RepoBuilder`] which will build a [`Repo`] that spawns its - // tasks onto the provided runtime + /// Create a new [`RepoBuilder`] which will build a [`Repo`] that spawns its + /// tasks onto the provided runtime pub fn builder( runtime: R, ) -> RepoBuilder { @@ -351,14 +351,22 @@ impl Repo { builder::RepoBuilder::new(::tokio::runtime::Handle::current()) } - // Create a new [`RepoBuilder`] which will build a [`Repo`] that spawns it's - // tasks onto a [`futures::executor::LocalPool`] + /// Create a new [`RepoBuilder`] which will build a [`Repo`] that spawns it's + /// tasks onto a [`futures::executor::LocalPool`] pub fn build_localpool( spawner: futures::executor::LocalSpawner, ) -> RepoBuilder { builder::RepoBuilder::new(spawner) } + /// Create a new [`RepoBuilder`] which will build a [`Repo`] that spawns it's + /// tasks using wasm-bindgen-futures for WASM environments + #[cfg(feature = "wasm")] + pub fn build_wasm() + -> RepoBuilder { + builder::RepoBuilder::new(crate::runtime::wasm::WasmRuntime::new()) + } + /// Create a new [`Repo`] instance which will build a [`Repo`] that spawns /// its tasks onto the current gio mainloop /// @@ -636,7 +644,7 @@ impl Repo { .unwrap() .connections .get_mut(&connection_id) - .map(|ConnHandle { rx, .. }| (rx.take())) + .map(|ConnHandle { rx, .. }| rx.take()) .expect("connection not found"); rx.take().expect("receive end not found") }; @@ -809,7 +817,7 @@ impl Inner { for (actor_id, actor_msg) in actor_messages { if let Some(ActorHandle { tx, .. }) = self.actors.get(&actor_id) { - let _ = tx.send_blocking(ActorTask::HandleMessage(actor_msg)); + let _ = tx.try_send(ActorTask::HandleMessage(actor_msg)); } else { tracing::warn!(?actor_id, "received message for unknown actor"); } @@ -880,29 +888,30 @@ impl Inner { DocRunner::Threadpool(threadpool) => { threadpool.spawn(move || { let _enter = span.enter(); - doc_inner.lock().unwrap().handle_results(init_results); - - while let Ok(actor_task) = rx.recv_blocking() { - let mut inner = doc_inner.lock().unwrap(); - inner.handle_task(actor_task); - if inner.is_stopped() { - tracing::debug!(?doc_id, ?actor_id, "actor stopped"); - break; + futures::executor::block_on(async { + doc_inner.lock().unwrap().handle_results(init_results); + + while let Ok(actor_task) = rx.recv().await { + let mut inner = doc_inner.lock().unwrap(); + inner.handle_task(actor_task); + if inner.is_stopped() { + tracing::debug!(?doc_id, ?actor_id, "actor stopped"); + break; + } } - } + }) }); } DocRunner::Async { tx } => { - if tx - .send_blocking(SpawnedActor { - doc_id, - actor_id, - inner: doc_inner, - rx_tasks: rx, - init_results, - }) - .is_err() - { + let spawn_result = SpawnedActor { + doc_id, + actor_id, + inner: doc_inner, + rx_tasks: rx, + init_results, + }; + + if tx.try_send(spawn_result).is_err() { tracing::error!(?actor_id, "actor spawner is gone"); } } diff --git a/samod/src/runtime.rs b/samod/src/runtime.rs index 6226201..712e747 100644 --- a/samod/src/runtime.rs +++ b/samod/src/runtime.rs @@ -7,6 +7,8 @@ pub mod gio; pub mod localpool; #[cfg(feature = "tokio")] mod tokio; +#[cfg(feature = "wasm")] +pub mod wasm; /// An abstraction over the asynchronous runtime the repo is running on /// diff --git a/samod/src/runtime/wasm.rs b/samod/src/runtime/wasm.rs new file mode 100644 index 0000000..0193ac3 --- /dev/null +++ b/samod/src/runtime/wasm.rs @@ -0,0 +1,24 @@ +use std::pin::Pin; + +use crate::runtime::RuntimeHandle; +use std::future::Future; + +pub struct WasmRuntime; + +impl WasmRuntime { + pub fn new() -> Self { + Self + } +} + +impl Default for WasmRuntime { + fn default() -> Self { + Self::new() + } +} + +impl RuntimeHandle for WasmRuntime { + fn spawn(&self, f: Pin + Send + 'static>>) { + wasm_bindgen_futures::spawn_local(f); + } +} From 86af90b7f3ad36f4f1cce551a97202a84377adb4 Mon Sep 17 00:00:00 2001 From: "Jack D. Douglas" Date: Tue, 2 Sep 2025 14:50:12 +0100 Subject: [PATCH 05/12] feat(samod): websocket implementation for wasm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add `WasmWebSocket` struct wrapping browser's native WebSocket API │ │ - implement `Stream` and `Sink` traits for async message handling │ │ - add `connect_wasm_websocket` method to `Repo` for browser connections │ │ - support binary (ArrayBuffer) message format for protocol compatibility │ │ - handle connection lifecycle with proper error propagation │ │ - add comprehensive documentation for WASM WebSocket usage │ │ --- samod/src/conn_finished_reason.rs | 2 + samod/src/websocket.rs | 325 ++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) diff --git a/samod/src/conn_finished_reason.rs b/samod/src/conn_finished_reason.rs index b10a9ca..2858f84 100644 --- a/samod/src/conn_finished_reason.rs +++ b/samod/src/conn_finished_reason.rs @@ -11,4 +11,6 @@ pub enum ConnFinishedReason { ErrorReceiving(String), /// There was some error on the network transport when sending data ErrorSending(String), + /// There was some error configuring a connection + Error(String), } diff --git a/samod/src/websocket.rs b/samod/src/websocket.rs index c6529d4..37cb246 100644 --- a/samod/src/websocket.rs +++ b/samod/src/websocket.rs @@ -1,5 +1,18 @@ use futures::{Future, Sink, SinkExt, Stream, StreamExt}; +#[cfg(feature = "wasm")] +use futures::channel::{mpsc, oneshot}; +#[cfg(feature = "wasm")] +use js_sys::Uint8Array; +#[cfg(feature = "wasm")] +use std::task::Poll; +#[cfg(feature = "wasm")] +use std::{cell::RefCell, pin::Pin, rc::Rc}; +#[cfg(feature = "wasm")] +use wasm_bindgen::{JsCast, prelude::Closure}; +#[cfg(feature = "wasm")] +use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket}; + use crate::{ConnDirection, ConnFinishedReason, Repo}; /// A copy of tungstenite::Message @@ -107,6 +120,54 @@ impl Repo { self.connect_websocket(stream, ConnDirection::Incoming) } + /// Connect to a WebSocket server from a WASM environment + /// + /// This method creates a WebSocket connection using the browser's native WebSocket API + /// and integrates it with samod's synchronization protocol. + /// + /// # Arguments + /// + /// * `url` - The WebSocket URL to connect to (e.g., "ws://localhost:8080" or "wss://example.com") + /// * `direction` - Whether this is an outgoing or incoming connection + /// + /// # Returns + /// + /// A `ConnFinishedReason` indicating how the connection terminated: + /// - `ConnFinishedReason::Shutdown` - The repo was shut down + /// - `ConnFinishedReason::Error(String)` - An error occurred + /// - Other variants as per normal connection lifecycle + /// + /// # Example + /// + /// ```no_run + /// use samod::{Repo, ConnDirection}; + /// + /// let repo = Repo::build_wasm().load().await; + /// let result = repo.connect_wasm_websocket( + /// "ws://localhost:8080/sync", + /// ConnDirection::Outgoing + /// ).await; + /// ``` + /// + /// # Panics + /// + /// This method must be called from within a WASM environment with access to + /// the browser's WebSocket API. It will panic if called outside of a browser context. + #[cfg(feature = "wasm")] + pub async fn connect_wasm_websocket( + &self, + url: &str, + direction: ConnDirection, + ) -> ConnFinishedReason { + match WasmWebSocket::connect(url).await { + Ok(ws) => { + let (sink, stream) = ws.split(); + self.connect(stream, sink, direction).await + } + Err(e) => ConnFinishedReason::Error(format!("Failed to connect WebSocket: {}", e)), + } + } + /// Connect any stream of [`WsMessage`]s /// /// [`WsMessage`] is a copy of `tungstenite::Message` and @@ -174,3 +235,267 @@ impl std::fmt::Display for NetworkError { } } impl std::error::Error for NetworkError {} + +// Hold closures to prevent them from being dropped +#[cfg(feature = "wasm")] +struct ClosureHandlers { + _on_message: Closure, + _on_close: Closure, + _on_error: Closure, + _on_open: Closure, +} + +/// A WebSocket implementation for WASM environments +/// +/// This struct wraps the browser's native WebSocket API and implements +/// the `Stream` and `Sink` traits to work with samod's connection protocol. +/// +/// The WebSocket is configured to: +/// - Use binary messages (ArrayBuffer) for data transfer +/// - Automatically handle connection lifecycle events +/// - Convert browser events into a Stream/Sink interface +/// +/// # Safety +/// +/// This type implements `Send` even though it contains `Rc` and browser objects +/// because WASM is single-threaded. All operations happen on the same thread. +#[cfg(feature = "wasm")] +pub struct WasmWebSocket { + ws: WebSocket, + _closures: ClosureHandlers, + receiver: mpsc::UnboundedReceiver, +} + +#[cfg(feature = "wasm")] +impl WasmWebSocket { + /// Create a new WebSocket connection + /// + /// This method establishes a WebSocket connection to the specified URL and + /// waits for the connection to be fully established before returning. + /// + /// # Arguments + /// + /// * `url` - The WebSocket URL to connect to + /// + /// # Returns + /// + /// * `Ok(WasmWebSocket)` - A connected WebSocket ready for use + /// * `Err(NetworkError)` - If connection fails or is rejected + /// + /// # Connection Process + /// + /// 1. Creates a browser WebSocket instance + /// 2. Sets up event handlers for open, message, error, and close events + /// 3. Waits for either the 'open' event (success) or 'error' event (failure) + /// 4. Returns the connected WebSocket or an error + /// + /// # Example + /// + /// ```no_run + /// let ws = WasmWebSocket::connect("ws://localhost:8080").await?; + /// ``` + pub async fn connect(url: &str) -> Result { + let ws = WebSocket::new(url).map_err(|_| { + NetworkError(format!("error creating websocket connection").to_string()) + })?; + + ws.set_binary_type(web_sys::BinaryType::Arraybuffer); + + // Create channels for messages + let (sender, receiver) = mpsc::unbounded(); + let sender_rc = Rc::new(RefCell::new(sender)); + + // Create a oneshot channel to signal connection status + let (conn_tx, conn_rx) = oneshot::channel::>(); + let conn_tx = Rc::new(RefCell::new(Some(conn_tx))); + + // Set up open handler to signal successful connection + let conn_tx_open = Rc::clone(&conn_tx); + let on_open = Closure::wrap(Box::new(move |_: web_sys::Event| { + if let Some(tx) = conn_tx_open.borrow_mut().take() { + let _ = tx.send(Ok(())); + } + }) as Box); + ws.set_onopen(Some(on_open.as_ref().unchecked_ref())); + + // Set up error handler to signal connection failure + let conn_tx_error = Rc::clone(&conn_tx); + let on_error = Closure::wrap(Box::new(move |e: web_sys::ErrorEvent| { + let error_msg = if e.message().is_empty() { + "WebSocket connection error".to_string() + } else { + e.message() + }; + + if let Some(tx) = conn_tx_error.borrow_mut().take() { + let _ = tx.send(Err(NetworkError(format!( + "WebSocket connection failed: {}", + error_msg + )))); + } + }) as Box); + ws.set_onerror(Some(on_error.as_ref().unchecked_ref())); + + // Set up message handler + let sender_for_msg = Rc::clone(&sender_rc); + let on_message = Closure::wrap(Box::new(move |e: MessageEvent| { + if let Ok(array_buffer) = e.data().dyn_into::() { + let array = Uint8Array::new(&array_buffer); + let bytes = array.to_vec(); + + if let Ok(sender) = sender_for_msg.try_borrow() { + let _ = sender.unbounded_send(WsMessage::Binary(bytes)); + } + } + }) as Box); + ws.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + + // Set up close handler + let sender_for_close = Rc::clone(&sender_rc); + let on_close = Closure::wrap(Box::new(move |_| { + if let Ok(sender) = sender_for_close.try_borrow() { + let _ = sender.unbounded_send(WsMessage::Close); + } + }) as Box); + ws.set_onclose(Some(on_close.as_ref().unchecked_ref())); + + // Wait for connection to complete + let connection_result = conn_rx + .await + .map_err(|_| NetworkError(format!("connection attempt was cancelled").to_string()))?; + + connection_result?; + + let websocket = Self { + ws, + _closures: ClosureHandlers { + _on_message: on_message, + _on_close: on_close, + _on_error: on_error, + _on_open: on_open, + }, + receiver, + }; + + Ok(websocket) + } + + /// Send binary data through the WebSocket + /// + /// # Arguments + /// + /// * `data` - The binary data to send + /// + /// # Returns + /// + /// * `Ok(())` - Data was successfully queued for sending + /// * `Err(NetworkError)` - If the WebSocket is not in a state to send data + /// + /// # Note + /// + /// This method queues data for sending but doesn't guarantee delivery. + /// The actual transmission happens asynchronously. + pub fn send(&self, data: Vec) -> Result<(), NetworkError> { + let array = Uint8Array::from(&data[..]); + self.ws + .send_with_array_buffer(&array.buffer()) + .map_err(|e| NetworkError(format!("failed to send message: {:?}", e)))?; + Ok(()) + } + + /// Close the WebSocket connection + /// + /// This initiates a graceful close of the WebSocket connection. + /// Any pending messages may still be delivered before the connection fully closes. + /// + /// # Returns + /// + /// * `Ok(())` - Close was initiated successfully + /// * `Err(NetworkError)` - If the WebSocket is already closed or in an invalid state + pub fn close(&self) -> Result<(), NetworkError> { + self.ws + .close() + .map_err(|_| NetworkError("Failed to close WebSocket".to_string()))?; + Ok(()) + } +} + +// Safe because WASM in single-threaded +#[cfg(feature = "wasm")] +unsafe impl Send for WasmWebSocket {} + +/// Receives messages from the WebSocket as a Stream +/// +/// # Message Handling +/// +/// - Binary messages: Passed through as-is +/// - Text messages: Converted to errors (protocol violation) +/// - Close messages: Ends the stream +/// - Ping/Pong: Handled internally, not exposed to consumers +#[cfg(feature = "wasm")] +impl Stream for WasmWebSocket { + type Item = Result, NetworkError>; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match Pin::new(&mut self.receiver).poll_next(cx) { + Poll::Ready(Some(WsMessage::Binary(data))) => Poll::Ready(Some(Ok(data))), + Poll::Ready(Some(WsMessage::Text(_))) => Poll::Ready(Some(Err(NetworkError( + "unexpected text message on websocket".to_string(), + )))), + Poll::Ready(Some(WsMessage::Close)) => Poll::Ready(None), + Poll::Ready(Some(WsMessage::Ping(_)) | Some(WsMessage::Pong(_))) => { + // Skip ping/pong messages and poll again + cx.waker().wake_by_ref(); + Poll::Pending + } + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +/// Sends messages to the WebSocket as a Sink +/// +/// This implementation: +/// - Accepts `Vec` binary data +/// - Is always ready to accept messages (buffering handled by browser) +/// - Sends data immediately without internal buffering +/// - Gracefully closes the connection when the sink is closed +/// +/// # Backpressure +/// +/// The browser's WebSocket implementation handles buffering and backpressure. +/// This sink reports as always ready, relying on the browser's internal queue. +#[cfg(feature = "wasm")] +impl Sink> for WasmWebSocket { + type Error = NetworkError; + + fn poll_ready( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, item: Vec) -> Result<(), Self::Error> { + self.as_ref().send(item) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let _ = self.as_ref().close(); + Poll::Ready(Ok(())) + } +} From 5f42b5a8afca4efe4a1f3479cf958eb58c3464dc Mon Sep 17 00:00:00 2001 From: "Jack D. Douglas" Date: Thu, 4 Sep 2025 18:22:52 +0100 Subject: [PATCH 06/12] fix(samod): tokio deps for wasm --- Cargo.lock | 20 +++++++++++++++----- samod/Cargo.toml | 31 ++++++++++++++++--------------- samod/src/storage.rs | 5 +++-- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4686218..d5d548c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -773,7 +773,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1605,6 +1605,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1750,9 +1760,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.46.0" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -1762,9 +1772,9 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.6.0", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/samod/Cargo.toml b/samod/Cargo.toml index aa6df71..67edb24 100644 --- a/samod/Cargo.toml +++ b/samod/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/alexjg/samod" [features] tokio = ["dep:tokio", "dep:tokio-util"] -axum = ["dep:axum", "dep:tokio", "dep:tokio-util"] +axum = ["dep:axum", "tokio"] tungstenite = ["dep:tungstenite", "dep:tokio-tungstenite", "tokio"] gio = ["dep:gio", "dep:glib"] wasm = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:js-sys", "dep:web-sys", "samod-core/wasm"] @@ -23,18 +23,21 @@ futures = "0.3.31" rand = "0.9.1" rayon = "1.10.0" samod-core = { path = "../samod-core", version = "0.3.1" } -tokio = { version = "1.46.0", features = ["rt", "time", "fs"], optional = true } -tokio-tungstenite = { version = "0.27.0", optional = true } -tokio-util = { version = "0.7.15", features = [ - "codec", - "net", -], optional = true } +tokio = { version = "1.46.0", features = ["rt", "time"], optional = true } tracing = "0.1.41" tungstenite = { version = "0.27.0", optional = true } gio = { version = "0.20.12", optional = true } glib = { version = "0.20.12", optional = true } async-channel = "2.5.0" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1.46.0", features = ["fs"], optional = true } +tokio-tungstenite = { version = "0.27.0", optional = true } +tokio-util = { version = "0.7.15", features = [ + "codec", + "net", +], optional = true } + [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = { version = "0.2", optional = true } wasm-bindgen-futures = { version = "0.4", optional = true } @@ -55,6 +58,10 @@ web-sys = { version = "0.3", features = [ "FileSystemFileHandle", "FileSystemWritableFileStream", ], optional = true } +tokio = { version = "1.46.0", features = ["rt", "time"], optional = true } +tokio-util = { version = "0.7.15", features = [ + "codec", +], optional = true } [dev-dependencies] eyre = "0.6.12" @@ -65,14 +72,8 @@ reqwest = { version = "0.12.22", features = [ tempfile = "3.20.0" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -[target.'cfg(not(taget_arch = "wasm32"))'.dev-dependencies] -tokio = { version = "1.46.0", features = [ - "rt", - "time", - "macros", - "process", - "fs", -] } +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { version = "1.46.0", features = ["macros", "process"] } tokio-test = { version = "0.4.4" } tokio-stream = { version = "0.1.17", features = ["io-util"] } tokio-util = { version = "0.7.15", features = ["codec", "net"] } diff --git a/samod/src/storage.rs b/samod/src/storage.rs index abc0d47..fc70d34 100644 --- a/samod/src/storage.rs +++ b/samod/src/storage.rs @@ -4,14 +4,15 @@ use std::collections::HashMap; pub use samod_core::StorageKey; +#[cfg(not(target_arch = "wasm32"))] mod filesystem; mod in_memory; pub use in_memory::InMemoryStorage; -#[cfg(feature = "tokio")] +#[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] pub use filesystem::tokio::FilesystemStorage as TokioFilesystemStorage; -#[cfg(feature = "gio")] +#[cfg(all(feature = "gio", not(target_arch = "wasm32")))] pub use filesystem::gio::FilesystemStorage as GioFilesystemStorage; /// The storage abstraction used by a [`Repo`](crate::Repo) to store document data From 8cb84786c54c8f65059bf4b3a43676cb04460993 Mon Sep 17 00:00:00 2001 From: Alex Good Date: Tue, 9 Sep 2025 10:38:29 +0100 Subject: [PATCH 07/12] samod: Allow non-send tasks Problem: in some cases it's not possible to produce a `Storage` or `AnnouncePolicy` implementation that produces `Send` futures, which means those futures can't be spawned onto runtimes like `tokio` which require `Send` futures to transfer tasks between threads. This constraint is reflected in the `RuntimeHandle::spawn` method, which requires the spawned future to be `Send`. Solution: add "local" variants of `RuntimeHandle`, `Storage`, and `AnnouncePolicy`. These don't require `Send` futures and so can be used with runtimes (such as single threaded ones like `futures::executor::LocalPool`) that don't require `Send` futures. These new variants can be used via the `RepoBuilder::build_local` method. --- samod/src/announce_policy.rs | 13 ++ samod/src/builder.rs | 35 +++- samod/src/io_loop.rs | 9 +- samod/src/lib.rs | 306 ++++++++++++++++++++++++--------- samod/src/runtime.rs | 17 ++ samod/src/runtime/localpool.rs | 8 +- samod/src/storage.rs | 35 ++++ samod/tests/localpool_smoke.rs | 2 +- 8 files changed, 327 insertions(+), 98 deletions(-) diff --git a/samod/src/announce_policy.rs b/samod/src/announce_policy.rs index 9555c20..bb1f2b0 100644 --- a/samod/src/announce_policy.rs +++ b/samod/src/announce_policy.rs @@ -30,6 +30,19 @@ pub trait AnnouncePolicy: Clone + Send + 'static { ) -> impl Future + Send + 'static; } +/// A version of [`AnnouncePolicy`] that can be used with runtimes that don't +/// require `Send` or `'static` bounds. See the [module level documentation on +/// runtimes](./index.html#runtimes) for more details. +pub trait LocalAnnouncePolicy: Clone + 'static { + fn should_announce(&self, doc_id: DocumentId, peer_id: PeerId) -> impl Future; +} + +impl LocalAnnouncePolicy for A { + fn should_announce(&self, doc_id: DocumentId, peer_id: PeerId) -> impl Future { + AnnouncePolicy::should_announce(self, doc_id, peer_id) + } +} + impl AnnouncePolicy for F where F: Fn(DocumentId, PeerId) -> bool + Clone + Send + 'static, diff --git a/samod/src/builder.rs b/samod/src/builder.rs index 06f9a77..b89ee98 100644 --- a/samod/src/builder.rs +++ b/samod/src/builder.rs @@ -2,12 +2,25 @@ use samod_core::PeerId; use crate::{ Repo, - announce_policy::{AlwaysAnnounce, AnnouncePolicy}, - runtime::RuntimeHandle, - storage::{InMemoryStorage, Storage}, + announce_policy::{AlwaysAnnounce, AnnouncePolicy, LocalAnnouncePolicy}, + runtime::{LocalRuntimeHandle, RuntimeHandle}, + storage::{InMemoryStorage, LocalStorage, Storage}, }; /// A struct for configuring a [`Repo`](crate::Repo) +/// +/// ## `Send` and non-`Send` futures +/// +/// Once you've finished configuring the builder, you call either [`load`](Self::load) or +/// [`load_local`](Self::load_local) to create a [`Repo`]. The difference between the two is that +/// [`load`](Self::load) is only available for runtimes which implement `RuntimeHandle` and +/// thus require `Send` futures (such as tokio). [`load_local`](Self::load_local) on the other +/// hand works with runtimes that implement [`LocalRuntimeHandle`] and thus does +/// not require [`Send`] futures. If you want to use +/// [`load_local`](Self::load_local) you need to configure the storage and +/// announce policy implementations to be implementations of [`LocalStorage`] +/// and [`LocalAnnouncePolicy`] respectively. See the [module level +/// documentation on runtimes](./index.html#runtimes) for more details. pub struct RepoBuilder { pub(crate) storage: S, pub(crate) runtime: R, @@ -17,7 +30,7 @@ pub struct RepoBuilder { } impl RepoBuilder { - pub fn with_storage(self, storage: S2) -> RepoBuilder { + pub fn with_storage(self, storage: S2) -> RepoBuilder { RepoBuilder { storage, peer_id: self.peer_id, @@ -27,7 +40,7 @@ impl RepoBuilder { } } - pub fn with_runtime(self, runtime: R2) -> RepoBuilder { + pub fn with_runtime(self, runtime: R2) -> RepoBuilder { RepoBuilder { runtime, peer_id: self.peer_id, @@ -42,10 +55,7 @@ impl RepoBuilder { self } - pub fn with_announce_policy( - self, - announce_policy: A2, - ) -> RepoBuilder { + pub fn with_announce_policy(self, announce_policy: A2) -> RepoBuilder { RepoBuilder { runtime: self.runtime, peer_id: self.peer_id, @@ -79,3 +89,10 @@ impl RepoBuilder { Repo::load(self).await } } + +impl RepoBuilder { + /// Create the repository + pub async fn load_local(self) -> Repo { + Repo::load_local(self).await + } +} diff --git a/samod/src/io_loop.rs b/samod/src/io_loop.rs index ba7f8f3..506b4dd 100644 --- a/samod/src/io_loop.rs +++ b/samod/src/io_loop.rs @@ -8,7 +8,8 @@ use samod_core::{ }; use crate::{ - ActorHandle, Inner, actor_task::ActorTask, announce_policy::AnnouncePolicy, storage::Storage, + ActorHandle, Inner, actor_task::ActorTask, announce_policy::LocalAnnouncePolicy, + storage::LocalStorage, }; pub(crate) struct IoLoopTask { @@ -23,7 +24,7 @@ struct IoLoopResult { } #[tracing::instrument(skip(inner, storage, announce_policy, rx))] -pub(crate) async fn io_loop( +pub(crate) async fn io_loop( local_peer_id: PeerId, inner: Arc>, storage: S, @@ -74,7 +75,7 @@ pub(crate) async fn io_loop( } } -async fn dispatch_document_task( +async fn dispatch_document_task( storage: S, announce: A, document_id: DocumentId, @@ -95,7 +96,7 @@ async fn dispatch_document_task( } #[tracing::instrument(skip(task, storage))] -pub(crate) async fn dispatch_storage_task( +pub(crate) async fn dispatch_storage_task( task: StorageTask, storage: S, ) -> StorageResult { diff --git a/samod/src/lib.rs b/samod/src/lib.rs index 5387ae7..9d465b7 100644 --- a/samod/src/lib.rs +++ b/samod/src/lib.rs @@ -30,9 +30,8 @@ //! //! Typically then, your workflow will look like this: //! -//! * Initialize a `Repo` at application startup, passing it a -//! [`RuntimeHandle`](crate::runtime::RuntimeHandle) implementation and -//! [`Storage`] implementation +//! * Initialize a `Repo` at application startup, passing it a [`RuntimeHandle`] +//! implementation and [`Storage`] implementation //! * Whenever you have connections available (maybe you are connecting to a //! sync server, maybe you are receiving peer-to-peer connections) you call //! [`Repo::connect`] to drive the connection state. @@ -58,18 +57,23 @@ //! }) //! ``` //! -//! The first argument to `builder` is an implementation of -//! [`RuntimeHandle`](crate::runtime::RuntimeHandle). Default implementations -//! are provided for `tokio` and `gio` which can be conveniently used via -//! [`Repo::build_tokio`] and [`Repo::build_gio`] respectively. The -//! [`RuntimeHandle`](crate::runtime::RuntimeHandle) trait is straightforward to -//! implement if you want to use some other async runtime. +//! The first argument to `builder` is an implementation of [`RuntimeHandle`]. +//! Default implementations are provided for `tokio` and `gio` which can be +//! conveniently used via [`Repo::build_tokio`] and [`Repo::build_gio`] +//! respectively. The [`RuntimeHandle`] trait is straightforward to implement if +//! you want to use some other async runtime. //! //! By default `samod` uses an in-memory storage implementation. This is great //! for prototyping but in most cases you do actually want to persist data somewhere. //! In this case you'll need an implementation of [`Storage`] to pass to //! [`RepoBuilder::with_storage`] //! +//! It is possible to use [`Storage`] and [`AnnouncePolicy`] implementations which +//! do not produce `Send` futures. In this case you will also need a runtime which +//! can spawn non-`Send` futures. See the [runtimes](#runtimes) section for more +//! details. +//! +//! //! ### Connecting to peers //! //! Once you have a `Repo` you can connect it to peers using [`Repo::connect`]. @@ -162,6 +166,27 @@ //! # }); //! ``` //! +//! ## Runtimes +//! +//! [`RuntimeHandle`] is a trait which is intended to abstract over the various +//! runtimes available in the rust ecosystem. The most common runtime is `tokio`. +//! `tokio` is a work-stealing runtime which means that the futures spawned on it +//! must be [`Send`], so that they can be moved between threads. This means that +//! [`RuntimeHandle::spawn`] requires [`Send`] futures. This in turn means that +//! the futures returned by the [`Storage`] and [`AnnouncePolicy`] traits are +//! also [`Send`] so that they can be spawned onto the [`RuntimeHandle`]. +//! +//! In many cases though, you may have a runtime which doesn't require [`Send`] +//! futures and you may have storage and announce policy implementations which +//! cannot produce [`Send`] futures. This would often be the case in single +//! threaded runtimes for example. In these cases you can instead implement +//! [`LocalRuntimeHandle`], which doesn't require [`Send`] futures and then +//! you implement [`LocalStorage`] and [`LocalAnnouncePolicy`] traits for +//! your storage and announce policy implementations. You configure all these +//! things via the [`RepoBuilder`] struct. Once you've configured the storage +//! and announce policy implementations to use local variants you can then +//! create a local [`Repo`] using [`RepoBuilder::load_local`]. +//! //! ## Why not just Automerge? //! //! `automerge` is a low level library. It provides routines for manipulating @@ -260,6 +285,7 @@ use futures::{ stream::FuturesUnordered, }; use rand::SeedableRng; +use rayon::ThreadPool; pub use samod_core::{AutomergeUrl, DocumentId, PeerId, network::ConnDirection}; use samod_core::{ CommandId, CommandResult, ConnectionId, DocumentActorId, LoaderState, UnixTimestamp, @@ -293,13 +319,17 @@ pub use peer_connection_info::ConnectionInfo; mod stopped; pub use stopped::Stopped; pub mod storage; -pub use crate::announce_policy::{AlwaysAnnounce, AnnouncePolicy}; -use crate::storage::InMemoryStorage; +pub use crate::announce_policy::{AlwaysAnnounce, AnnouncePolicy, LocalAnnouncePolicy}; use crate::{ doc_actor_inner::DocActorInner, doc_runner::{DocRunner, SpawnedActor}, storage::Storage, }; +use crate::{ + io_loop::IoLoopTask, + runtime::{LocalRuntimeHandle, RuntimeHandle}, + storage::{InMemoryStorage, LocalStorage}, +}; pub mod runtime; pub mod websocket; @@ -381,79 +411,30 @@ impl Repo { announce_policy, threadpool, } = builder; - let mut rng = rand::rngs::StdRng::from_rng(&mut rand::rng()); - let peer_id = peer_id.unwrap_or_else(|| PeerId::new_with_rng(&mut rng)); - let mut loading = Hub::load(peer_id.clone()); - let mut running_tasks = FuturesUnordered::new(); - let hub = loop { - match loading.step(&mut rng, UnixTimestamp::now()) { - LoaderState::NeedIo(items) => { - for IoTask { - task_id, - action: task, - } in items - { - let storage = storage.clone(); - running_tasks.push(async move { - let result = io_loop::dispatch_storage_task(task, storage).await; - (task_id, result) - }) - } - } - LoaderState::Loaded(hub) => break hub, - } - let (task_id, next_result) = running_tasks.select_next_some().await; - loading.provide_io_result(IoResult { - task_id, - payload: next_result, - }); - }; - - let (tx_storage, rx_storage) = async_channel::unbounded(); - let (tx_to_core, rx_from_core) = async_channel::unbounded(); - let doc_runner = if let Some(threadpool) = threadpool { - DocRunner::Threadpool(threadpool) - } else { - let (tx, rx) = async_channel::unbounded(); - runtime.spawn(async_actor_runner(rx).boxed()); - DocRunner::Async { tx } - }; - let inner = Arc::new(Mutex::new(Inner { - doc_runner, - actors: HashMap::new(), - hub: *hub, - pending_commands: HashMap::new(), - connections: HashMap::new(), - tx_io: tx_storage, - tx_to_core, - waiting_for_connection: HashMap::new(), - stop_waiters: Vec::new(), - rng: rand::rngs::StdRng::from_os_rng(), - })); - - runtime.spawn( - io_loop::io_loop( - peer_id.clone(), - inner.clone(), - storage, - announce_policy, - rx_storage, - ) - .boxed(), - ); - runtime.spawn({ - let inner = inner.clone(); - async move { - let rx = rx_from_core; - while let Ok((actor_id, msg)) = rx.recv().await { - let event = HubEvent::actor_message(actor_id, msg); - inner.lock().unwrap().handle_event(event); - } - } - .instrument(tracing::info_span!("actor_loop", local_peer_id=%peer_id)) - .boxed() - }); + let task_setup = TaskSetup::new(storage.clone(), peer_id, threadpool).await; + let inner = task_setup.inner.clone(); + task_setup.spawn_tasks(runtime, storage, announce_policy); + Self { inner } + } + pub(crate) async fn load_local< + 'a, + R: runtime::LocalRuntimeHandle + 'a, + S: LocalStorage + 'a, + A: LocalAnnouncePolicy + 'a, + >( + builder: RepoBuilder, + ) -> Self { + let RepoBuilder { + storage, + runtime, + peer_id, + announce_policy, + threadpool, + } = builder; + let task_setup = TaskSetup::new(storage.clone(), peer_id, threadpool).await; + let inner = task_setup.inner.clone(); + task_setup.spawn_tasks_local(runtime, storage, announce_policy); Self { inner } } @@ -958,6 +939,165 @@ async fn async_actor_runner(rx: async_channel::Receiver) { } } +/// All the information needed to spawn the background tasks +/// +/// When we construct a `Repo` we need to spawn a number of tasks onto the +/// runtime to do things like handle storage tasks. We have to split the +/// spawn process into two stages: +/// +/// * Create the channels which are used to communicate with the background tasks +/// * Spawn the background tasks onto the runtime +/// +/// The reason we have to split into these two stages is so that we can work with +/// runtimes that don't support non-`Send` tasks. This split is represented by the +/// `TaskSetup::spawn_tasks` and `TaskSetup::spawn_tasks_local` methods. +struct TaskSetup { + peer_id: PeerId, + inner: Arc>, + rx_storage: async_channel::Receiver, + rx_from_core: async_channel::Receiver<(DocumentActorId, DocToHubMsg)>, + rx_actor: Option>, +} + +impl TaskSetup { + async fn new( + storage: S, + peer_id: Option, + threadpool: Option, + ) -> TaskSetup { + let mut rng = rand::rngs::StdRng::from_rng(&mut rand::rng()); + let peer_id = peer_id.unwrap_or_else(|| PeerId::new_with_rng(&mut rng)); + let hub = load_hub(storage.clone(), Hub::load(peer_id.clone())).await; + + let (tx_storage, rx_storage) = async_channel::unbounded(); + let (tx_to_core, rx_from_core) = async_channel::unbounded(); + let mut rx_actor = None; + let doc_runner = if let Some(threadpool) = threadpool { + DocRunner::Threadpool(threadpool) + } else { + let (tx, rx) = async_channel::unbounded(); + rx_actor = Some(rx); + // tasks.push(async_actor_runner(rx).boxed_local()); + DocRunner::Async { tx } + }; + let inner = Arc::new(Mutex::new(Inner { + doc_runner, + actors: HashMap::new(), + hub: *hub, + pending_commands: HashMap::new(), + connections: HashMap::new(), + tx_io: tx_storage, + tx_to_core, + waiting_for_connection: HashMap::new(), + stop_waiters: Vec::new(), + rng: rand::rngs::StdRng::from_os_rng(), + })); + + TaskSetup { + peer_id, + inner, + rx_actor, + rx_from_core, + rx_storage, + } + } + fn spawn_tasks_local( + self, + runtime: R, + storage: S, + announce_policy: A, + ) { + runtime.spawn( + io_loop::io_loop( + self.peer_id.clone(), + self.inner.clone(), + storage, + announce_policy, + self.rx_storage, + ) + .boxed_local(), + ); + runtime.spawn({ + let peer_id = self.peer_id.clone(); + let inner = self.inner.clone(); + async move { + let rx = self.rx_from_core; + while let Ok((actor_id, msg)) = rx.recv().await { + let event = HubEvent::actor_message(actor_id, msg); + inner.lock().unwrap().handle_event(event); + } + } + .instrument(tracing::info_span!("actor_loop", local_peer_id=%peer_id)) + .boxed_local() + }); + if let Some(rx_actor) = self.rx_actor { + runtime.spawn(async_actor_runner(rx_actor).boxed_local()); + } + } + + fn spawn_tasks( + self, + runtime: R, + storage: S, + announce_policy: A, + ) { + runtime.spawn( + io_loop::io_loop( + self.peer_id.clone(), + self.inner.clone(), + storage, + announce_policy, + self.rx_storage, + ) + .boxed(), + ); + runtime.spawn({ + let peer_id = self.peer_id.clone(); + let inner = self.inner.clone(); + async move { + let rx = self.rx_from_core; + while let Ok((actor_id, msg)) = rx.recv().await { + let event = HubEvent::actor_message(actor_id, msg); + inner.lock().unwrap().handle_event(event); + } + } + .instrument(tracing::info_span!("actor_loop", local_peer_id=%peer_id)) + .boxed() + }); + if let Some(rx_actor) = self.rx_actor { + runtime.spawn(async_actor_runner(rx_actor).boxed()); + } + } +} + +async fn load_hub(storage: S, mut loading: samod_core::SamodLoader) -> Box { + let mut rng = rand::rngs::StdRng::from_os_rng(); + let mut running_tasks = FuturesUnordered::new(); + loop { + match loading.step(&mut rng, UnixTimestamp::now()) { + LoaderState::NeedIo(items) => { + for IoTask { + task_id, + action: task, + } in items + { + let storage = storage.clone(); + running_tasks.push(async move { + let result = io_loop::dispatch_storage_task(task, storage).await; + (task_id, result) + }) + } + } + LoaderState::Loaded(hub) => break hub, + } + let (task_id, next_result) = running_tasks.select_next_some().await; + loading.provide_io_result(IoResult { + task_id, + payload: next_result, + }); + } +} + #[cfg(test)] mod tests { use std::marker::PhantomData; diff --git a/samod/src/runtime.rs b/samod/src/runtime.rs index 6226201..2782763 100644 --- a/samod/src/runtime.rs +++ b/samod/src/runtime.rs @@ -19,3 +19,20 @@ pub trait RuntimeHandle: 'static { /// Spawn a task to be run in the background fn spawn(&self, f: Pin + Send + 'static>>); } + +/// An abstraction over the asynchronous runtime the repo is running on +/// +/// When a [`Repo`](crate::Repo) starts up it spawns a number of tasks which run +/// until the repo is shutdown. These tasks do things like handle IO using +/// [`Storage`](crate::Storage) or pass messages between different document +/// threads and the central control loop of the repo. [`LocalRuntimeHandle`] +/// represents this ability to spawn tasks. +/// +/// The difference between this trait and the [`RuntimeHandle`] trait is that +/// the `LocalRuntimeHandle` does not have a `Send` or 'static bound, enabling +/// it to be used with runtimes that don't require this. See the [module level +/// documentation on runtimes](../index.html#runtimes) for more details. +pub trait LocalRuntimeHandle { + /// Spawn a task to be run in the background + fn spawn(&self, f: Pin>>); +} diff --git a/samod/src/runtime/localpool.rs b/samod/src/runtime/localpool.rs index 0566295..803e585 100644 --- a/samod/src/runtime/localpool.rs +++ b/samod/src/runtime/localpool.rs @@ -2,7 +2,7 @@ use std::pin::Pin; use futures::{executor::LocalSpawner, task::LocalSpawnExt}; -use crate::runtime::RuntimeHandle; +use crate::runtime::{LocalRuntimeHandle, RuntimeHandle}; #[derive(Clone)] pub struct LocalPoolRuntime; @@ -12,3 +12,9 @@ impl RuntimeHandle for LocalSpawner { self.spawn_local(f).unwrap(); } } + +impl LocalRuntimeHandle for LocalSpawner { + fn spawn(&self, f: Pin>>) { + self.spawn_local(f).unwrap(); + } +} diff --git a/samod/src/storage.rs b/samod/src/storage.rs index abc0d47..c0c60f7 100644 --- a/samod/src/storage.rs +++ b/samod/src/storage.rs @@ -48,3 +48,38 @@ pub trait Storage: Send + Clone + 'static { /// Delete a value from storage fn delete(&self, key: StorageKey) -> impl Future + Send; } + +/// A version of [`Storage`] that can be used with runtimes that don't require +/// `Send` or `'static` bounds. See the [module level documentation on +/// runtimes](../index.html#runtimes) for more details. +pub trait LocalStorage: Clone + 'static { + /// Load a specific key from storage + fn load(&self, key: StorageKey) -> impl Future>>; + /// Load a range of keys from storage, all of which begin with `prefix` + /// + /// Note that you can use [`StorageKey::is_prefix_of`] to implement this + /// in simple cases + fn load_range(&self, prefix: StorageKey) -> impl Future>>; + /// Put a particular value into storage + fn put(&self, key: StorageKey, data: Vec) -> impl Future; + /// Delete a value from storage + fn delete(&self, key: StorageKey) -> impl Future; +} + +impl LocalStorage for S { + fn load(&self, key: StorageKey) -> impl Future>> { + Storage::load(self, key) + } + + fn load_range(&self, prefix: StorageKey) -> impl Future>> { + Storage::load_range(self, prefix) + } + + fn put(&self, key: StorageKey, data: Vec) -> impl Future { + Storage::put(self, key, data) + } + + fn delete(&self, key: StorageKey) -> impl Future { + Storage::delete(self, key) + } +} diff --git a/samod/tests/localpool_smoke.rs b/samod/tests/localpool_smoke.rs index fa71422..90c0ae9 100644 --- a/samod/tests/localpool_smoke.rs +++ b/samod/tests/localpool_smoke.rs @@ -31,7 +31,7 @@ fn test_localpool() { let alice = samod::Repo::build_localpool(spawner.clone()) .with_peer_id("alice".into()) .with_threadpool(None) - .load() + .load_local() .await; let bob = samod::Repo::build_localpool(spawner.clone()) From e1e59738c58d68b472aa9a877031fa9d39ae3a9f Mon Sep 17 00:00:00 2001 From: "Jack D. Douglas" Date: Mon, 8 Sep 2025 16:27:16 +0100 Subject: [PATCH 08/12] feat(samod): TON-1429: indexeddb storage adapter --- Cargo.lock | 194 +++++++++++++++++++-- samod/Cargo.toml | 5 +- samod/src/storage.rs | 6 + samod/src/storage/indexeddb.rs | 305 +++++++++++++++++++++++++++++++++ 4 files changed, 496 insertions(+), 14 deletions(-) create mode 100644 samod/src/storage/indexeddb.rs diff --git a/Cargo.lock b/Cargo.lock index d5d548c..5514b67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accessory" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28e416a3ab45838bac2ab2d81b1088d738d7b2d2c5272a54d39366565a29bd80" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "addr2line" version = "0.24.2" @@ -353,6 +365,41 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "delegate-display" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9926686c832494164c33a36bf65118f4bd6e704000b58c94681bf62e9ad67a74" +dependencies = [ + "impartial-ord", + "itoa", + "macroific", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" @@ -427,6 +474,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy_constructor" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a27643a5d05f3a22f5afd6e0d0e6e354f92d37907006f97b84b9cb79082198" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -588,9 +647,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "gio" -version = "0.20.12" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +checksum = "52b5e3f390d01b79e30da451dd00e27cd1ac2de81658e3abf6c1fc3229b24c5f" dependencies = [ "futures-channel", "futures-core", @@ -605,9 +664,9 @@ dependencies = [ [[package]] name = "gio-sys" -version = "0.20.10" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +checksum = "a03f2234671e5a588cfe1f59c2b22c103f5772ea351be9cc824a9ce0d06d99fd" dependencies = [ "glib-sys", "gobject-sys", @@ -618,9 +677,9 @@ dependencies = [ [[package]] name = "glib" -version = "0.20.12" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +checksum = "60bdc26493257b5794ba9301f7cbaf7ab0d69a570bfbefa4d7d360e781cb5205" dependencies = [ "bitflags", "futures-channel", @@ -639,9 +698,9 @@ dependencies = [ [[package]] name = "glib-macros" -version = "0.20.12" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +checksum = "e772291ebea14c28eb11bb75741f62f4a4894f25e60ce80100797b6b010ef0f9" dependencies = [ "heck", "proc-macro-crate", @@ -652,9 +711,9 @@ dependencies = [ [[package]] name = "glib-sys" -version = "0.20.10" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +checksum = "dc7c43cff6a7dc43821e45ebf172399437acd6716fa2186b6852d2b397bf622d" dependencies = [ "libc", "system-deps", @@ -662,9 +721,9 @@ dependencies = [ [[package]] name = "gobject-sys" -version = "0.20.10" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +checksum = "3e9a190eef2bce144a6aa8434e306974c6062c398e0a33a146d60238f9062d5c" dependencies = [ "glib-sys", "libc", @@ -924,12 +983,57 @@ dependencies = [ "version_check", ] +[[package]] +name = "impartial-ord" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab604ee7085efba6efc65e4ebca0e9533e3aff6cb501d7d77b211e3a781c6d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "indenter" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +[[package]] +name = "indexed_db_futures" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69ff41758cbd104e91033bb53bc449bec7eea65652960c81eddf3fc146ecea19" +dependencies = [ + "accessory", + "cfg-if", + "delegate-display", + "derive_more", + "fancy_constructor", + "indexed_db_futures_macros_internal", + "js-sys", + "sealed", + "smallvec", + "thiserror 2.0.12", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "indexed_db_futures_macros_internal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caeba94923b68f254abef921cea7e7698bf4675fdd89d7c58bf1ed885b49a27d" +dependencies = [ + "macroific", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "indexmap" version = "2.10.0" @@ -1028,6 +1132,54 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "macroific" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "macroific_macro", +] + +[[package]] +name = "macroific_attr_parse" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn", +] + +[[package]] +name = "macroific_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn", +] + +[[package]] +name = "macroific_macro" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b" +dependencies = [ + "macroific_attr_parse", + "macroific_core", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1402,6 +1554,7 @@ dependencies = [ "futures", "gio", "glib", + "indexed_db_futures", "js-sys", "rand", "rayon", @@ -1455,6 +1608,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde" version = "1.0.219" @@ -2049,6 +2213,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.4" diff --git a/samod/Cargo.toml b/samod/Cargo.toml index 67edb24..940ea0c 100644 --- a/samod/Cargo.toml +++ b/samod/Cargo.toml @@ -26,8 +26,8 @@ samod-core = { path = "../samod-core", version = "0.3.1" } tokio = { version = "1.46.0", features = ["rt", "time"], optional = true } tracing = "0.1.41" tungstenite = { version = "0.27.0", optional = true } -gio = { version = "0.20.12", optional = true } -glib = { version = "0.20.12", optional = true } +gio = { version = "0.21.1", optional = true } +glib = { version = "0.21.1", optional = true } async-channel = "2.5.0" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -58,6 +58,7 @@ web-sys = { version = "0.3", features = [ "FileSystemFileHandle", "FileSystemWritableFileStream", ], optional = true } +indexed_db_futures = { version = "0.6.4", features = ["cursors", "async-upgrade"] } tokio = { version = "1.46.0", features = ["rt", "time"], optional = true } tokio-util = { version = "0.7.15", features = [ "codec", diff --git a/samod/src/storage.rs b/samod/src/storage.rs index fc70d34..6c6f2b4 100644 --- a/samod/src/storage.rs +++ b/samod/src/storage.rs @@ -7,8 +7,14 @@ pub use samod_core::StorageKey; #[cfg(not(target_arch = "wasm32"))] mod filesystem; mod in_memory; +#[cfg(target_arch = "wasm32")] +mod indexeddb; + pub use in_memory::InMemoryStorage; +#[cfg(target_arch = "wasm32")] +pub use indexeddb::IndexedDbStorage; + #[cfg(all(feature = "tokio", not(target_arch = "wasm32")))] pub use filesystem::tokio::FilesystemStorage as TokioFilesystemStorage; diff --git a/samod/src/storage/indexeddb.rs b/samod/src/storage/indexeddb.rs new file mode 100644 index 0000000..de2db6e --- /dev/null +++ b/samod/src/storage/indexeddb.rs @@ -0,0 +1,305 @@ +use std::{ + collections::HashMap, + future::Future, + pin::Pin, + task::{Context, Poll}, +}; + +use indexed_db_futures::{database::Database, prelude::*, transaction::TransactionMode}; +use samod_core::StorageKey; +use wasm_bindgen::JsValue; + +use crate::storage::Storage; + +/// A wrapper to make WASM futures Send +struct SendFuture(F); + +unsafe impl Send for SendFuture {} + +impl std::fmt::Debug for SendFuture { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("SendFuture").field(&"").finish() + } +} + +impl Future for SendFuture { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + unsafe { Pin::new_unchecked(&mut self.get_unchecked_mut().0).poll(cx) } + } +} + +/// A [`Storage`] implementation for browser WASM which stores data in IndexedDB +#[derive(Clone)] +pub struct IndexedDbStorage { + db_name: String, + store_name: String, +} + +impl IndexedDbStorage { + pub fn new() -> Self { + Self::with_names("samod_storage", "data") + } + + pub fn with_names(db_name: &str, store_name: &str) -> Self { + Self { + db_name: db_name.to_string(), + store_name: store_name.to_string(), + } + } + + async fn open_db(&self) -> Result { + let store_name = self.store_name.clone(); + Database::open(&self.db_name) + .with_version(1u32) + .with_on_upgrade_needed(move |_event, db| { + // Create object store if it doesn't exist + if !db.object_store_names().any(|name| name == store_name) { + let _ = db.create_object_store(&store_name).build(); + } + Ok(()) + }) + .await + .map_err(|_| JsValue::from_str("Failed to open IndexedDB connection")) + } + + fn storage_key_to_js_value(key: &StorageKey) -> JsValue { + JsValue::from_str(&key.to_string()) + } + + fn js_value_to_storage_key(value: JsValue) -> Result { + let key_parts: Vec = value + .as_string() + .ok_or_else(|| JsValue::from_str("Invalid key format"))? + .split("/") + .map(|s| s.to_string()) + .collect(); + StorageKey::from_parts(key_parts) + .map_err(|_| JsValue::from_str("Failed to create StorageKey")) + } +} + +impl Default for IndexedDbStorage { + fn default() -> Self { + Self::new() + } +} + +impl Storage for IndexedDbStorage { + #[tracing::instrument(skip(self), level = "trace", ret)] + fn load(&self, key: StorageKey) -> impl Future>> + Send { + let self_clone = self.clone(); + SendFuture(async move { + match self_clone.open_db().await { + Ok(db) => { + let tx = match db + .transaction(&self_clone.store_name) + .with_mode(TransactionMode::Readonly) + .build() + { + Ok(tx) => tx, + Err(e) => { + tracing::error!("Failed to create transaction: {:?}", e); + return None; + } + }; + + let store = match tx.object_store(&self_clone.store_name) { + Ok(store) => store, + Err(e) => { + tracing::error!("Failed to get object store: {:?}", e); + return None; + } + }; + + let js_key = Self::storage_key_to_js_value(&key); + + match store.get(js_key).primitive() { + Ok(request) => match request.await { + Ok(Some(result)) => { + let uint8_array = js_sys::Uint8Array::new(&result); + Some(uint8_array.to_vec()) + } + Ok(None) => None, + Err(_) => { + tracing::warn!("Failed to load from IndexedDB"); + None + } + }, + Err(e) => { + tracing::error!("Failed to create get request: {:?}", e); + None + } + } + } + Err(e) => { + tracing::error!("Failed to open database: {:?}", e); + None + } + } + }) + } + + #[tracing::instrument(skip(self), level = "trace", ret)] + fn load_range( + &self, + prefix: StorageKey, + ) -> impl Future>> + Send { + let self_clone = self.clone(); + SendFuture(async move { + let mut result = HashMap::new(); + + match self_clone.open_db().await { + Ok(db) => { + let tx = match db + .transaction(&self_clone.store_name) + .with_mode(TransactionMode::Readonly) + .build() + { + Ok(tx) => tx, + Err(e) => { + tracing::error!("Failed to create transaction: {:?}", e); + return HashMap::new(); + } + }; + + let store = match tx.object_store(&self_clone.store_name) { + Ok(store) => store, + Err(e) => { + tracing::error!("Failed to get object store: {:?}", e); + return HashMap::new(); + } + }; + + match store.open_cursor().await { + Ok(Some(mut cursor)) => loop { + match cursor.next_record::().await { + Ok(Some(js_value)) => { + if let Ok(Some(js_key)) = cursor.key::() { + if let Ok(storage_key) = + Self::js_value_to_storage_key(js_key) + { + if prefix.is_prefix_of(&storage_key) { + let uint8_array = + js_sys::Uint8Array::new(&js_value); + result.insert(storage_key, uint8_array.to_vec()); + } + } + } + } + Ok(None) => break, // end of cursor + Err(_) => break, + } + }, + Ok(None) => {} + Err(e) => { + tracing::error!("Failed to open cursor: {:?}", e); + } + } + } + Err(e) => { + tracing::error!("Failed to open database: {:?}", e); + } + } + + result + }) + } + + #[tracing::instrument(skip(self, data), level = "trace")] + fn put(&self, key: StorageKey, data: Vec) -> impl Future + Send { + let self_clone = self.clone(); + SendFuture(async move { + match self_clone.open_db().await { + Ok(db) => { + let tx = match db + .transaction(&self_clone.store_name) + .with_mode(TransactionMode::Readwrite) + .build() + { + Ok(tx) => tx, + Err(e) => { + tracing::error!("Failed to create transaction: {:?}", e); + return; + } + }; + + let store = match tx.object_store(&self_clone.store_name) { + Ok(store) => store, + Err(e) => { + tracing::error!("Failed to get object store: {:?}", e); + return; + } + }; + + let js_key = Self::storage_key_to_js_value(&key); + let uint8_array = js_sys::Uint8Array::from(&data[..]); + + let request = store.put(uint8_array.to_vec()).with_key(js_key); + match request.await { + Ok(_) => { + // Commit the transaction + if let Err(e) = tx.commit().await { + tracing::error!("Failed to commit transaction: {:?}", e); + } + } + Err(e) => { + tracing::error!("Failed to put data to IndexedDB: {:?}", e); + } + } + } + Err(e) => { + tracing::error!("Failed to open database: {:?}", e); + } + } + }) + } + + #[tracing::instrument(skip(self), level = "trace")] + fn delete(&self, key: StorageKey) -> impl Future + Send { + let self_clone = self.clone(); + SendFuture(async move { + match self_clone.open_db().await { + Ok(db) => { + let tx = match db + .transaction(&self_clone.store_name) + .with_mode(TransactionMode::Readwrite) + .build() + { + Ok(tx) => tx, + Err(e) => { + tracing::error!("Failed to create transaction: {:?}", e); + return; + } + }; + + let store = match tx.object_store(&self_clone.store_name) { + Ok(store) => store, + Err(e) => { + tracing::error!("Failed to get object store: {:?}", e); + return; + } + }; + + let js_key = Self::storage_key_to_js_value(&key); + + match store.delete(js_key).await { + Ok(_) => { + // Commit the transaction + if let Err(e) = tx.commit().await { + tracing::error!("Failed to commit transaction: {:?}", e); + } + } + Err(e) => { + tracing::error!("Failed to delete from IndexedDB: {:?}", e); + } + } + } + Err(e) => { + tracing::error!("Failed to open database: {:?}", e); + } + } + }) + } +} From 3941b0b7e3e36ff61eaa4feb19a402618108ad4a Mon Sep 17 00:00:00 2001 From: Alex Good Date: Wed, 10 Sep 2025 16:03:31 +0100 Subject: [PATCH 09/12] samod: make threadpool usage compile time optional Problem: the use of the rayon threadpool option to run document actors in paralell requires the use of the `async_channel::Sender::blocking_send` and `async_channel::Receiver::recv_blocking` methods, which are behind a conditional compilation flag which is not enabled for `wasm` targets. This means that `samod` cannot be used on `wasm` targets even if the threadpool feature is not being used. Solution: make the threadpool feature compile time optional behind the `threadpool` feature flag. We also put the `blocking_send` and `recv_blocking` methods behind the same flag so that if the `threadpool` feature is not available `samod` can compile on `wasm` just fine. While we're here add a newtype wrapper for unbounded channels to make it clearer that we expect these channels to be unbounded. --- .github/workflows/ci.yml | 6 ++- samod/Cargo.toml | 3 +- samod/src/actor_handle.rs | 4 +- samod/src/builder.rs | 30 ++++++++++---- samod/src/doc_actor_inner.rs | 13 +++--- samod/src/doc_runner.rs | 13 +++--- samod/src/io_loop.rs | 8 ++-- samod/src/lib.rs | 74 ++++++++++++++++++++++------------ samod/src/unbounded.rs | 37 +++++++++++++++++ samod/tests/localpool_smoke.rs | 5 +-- samod/tests/smoke.rs | 49 +++++++++++++++++++++- 11 files changed, 185 insertions(+), 57 deletions(-) create mode 100644 samod/src/unbounded.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b2da30..0559e73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: - name: Build run: cargo build --verbose --features tokio - name: clippy - run: cargo clippy --all-targets --features tokio -- -D warnings + run: cargo clippy --all-targets --features samod/tokio,samod/threadpool,samod/axum,samod/tungstenite -- -D warnings - name: Run tests - run: cargo test --features tokio --verbose + run: cargo test --features samod/tokio,samod/threadpool,samod/axum,samod/tungstenite --verbose + - name: Run tests without features + run: cargo test --verbose --tests diff --git a/samod/Cargo.toml b/samod/Cargo.toml index 1cb062c..d544075 100644 --- a/samod/Cargo.toml +++ b/samod/Cargo.toml @@ -12,6 +12,7 @@ tokio = ["dep:tokio", "dep:tokio-util"] axum = ["dep:axum", "dep:tokio", "dep:tokio-util"] tungstenite = ["dep:tungstenite", "dep:tokio-tungstenite", "tokio"] gio = ["dep:gio", "dep:glib"] +threadpool = ["dep:rayon"] [dependencies] automerge = "0.6.1" @@ -20,7 +21,7 @@ bytes = "1.10.1" chrono = "0.4.41" futures = "0.3.31" rand = "0.9.1" -rayon = "1.10.0" +rayon = { version = "1.10.0", optional = true } samod-core = { path = "../samod-core", version = "0.3.1" } tokio = { version = "1.46.0", features = ["rt", "time", "fs"], optional = true } tokio-tungstenite = { version = "0.27.0", optional = true } diff --git a/samod/src/actor_handle.rs b/samod/src/actor_handle.rs index 360ff83..a668bc2 100644 --- a/samod/src/actor_handle.rs +++ b/samod/src/actor_handle.rs @@ -1,10 +1,10 @@ use std::sync::{Arc, Mutex}; -use crate::{ActorTask, DocActorInner, DocHandle}; +use crate::{ActorTask, DocActorInner, DocHandle, unbounded::UnboundedSender}; pub(crate) struct ActorHandle { #[allow(dead_code)] pub(crate) inner: Arc>, - pub(crate) tx: async_channel::Sender, + pub(crate) tx: UnboundedSender, pub(crate) doc: DocHandle, } diff --git a/samod/src/builder.rs b/samod/src/builder.rs index b89ee98..f5e7574 100644 --- a/samod/src/builder.rs +++ b/samod/src/builder.rs @@ -7,6 +7,18 @@ use crate::{ storage::{InMemoryStorage, LocalStorage, Storage}, }; +/// How to run concurrent documents +/// +/// See the [concurrency section](./index.html#concurrency) of the module level +/// documentation +pub enum ConcurrencyConfig { + /// Run each document in a separate task on the async runtime + AsyncRuntime, + /// Run each document on a rayon threadpool + #[cfg(feature = "threadpool")] + Threadpool(rayon::ThreadPool), +} + /// A struct for configuring a [`Repo`](crate::Repo) /// /// ## `Send` and non-`Send` futures @@ -26,7 +38,7 @@ pub struct RepoBuilder { pub(crate) runtime: R, pub(crate) announce_policy: A, pub(crate) peer_id: Option, - pub(crate) threadpool: Option, + pub(crate) concurrency: ConcurrencyConfig, } impl RepoBuilder { @@ -36,7 +48,7 @@ impl RepoBuilder { peer_id: self.peer_id, runtime: self.runtime, announce_policy: self.announce_policy, - threadpool: self.threadpool, + concurrency: self.concurrency, } } @@ -46,7 +58,7 @@ impl RepoBuilder { peer_id: self.peer_id, storage: self.storage, announce_policy: self.announce_policy, - threadpool: self.threadpool, + concurrency: self.concurrency, } } @@ -61,12 +73,16 @@ impl RepoBuilder { peer_id: self.peer_id, storage: self.storage, announce_policy, - threadpool: self.threadpool, + concurrency: self.concurrency, } } - pub fn with_threadpool(mut self, threadpool: Option) -> Self { - self.threadpool = threadpool; + /// Configure how the repository should process concurrent documents + /// + /// See the [concurrency section](./index.html#concurrency) of the module level + /// documentation + pub fn with_concurrency(mut self, concurrency: ConcurrencyConfig) -> Self { + self.concurrency = concurrency; self } } @@ -78,7 +94,7 @@ impl RepoBuilder { runtime, peer_id: None, announce_policy: AlwaysAnnounce, - threadpool: None, + concurrency: ConcurrencyConfig::AsyncRuntime, } } } diff --git a/samod/src/doc_actor_inner.rs b/samod/src/doc_actor_inner.rs index 58d6806..125d3a5 100644 --- a/samod/src/doc_actor_inner.rs +++ b/samod/src/doc_actor_inner.rs @@ -11,13 +11,14 @@ use samod_core::{ use crate::{ actor_task::ActorTask, io_loop::{self, IoLoopTask}, + unbounded::UnboundedSender, }; pub(crate) struct DocActorInner { document_id: DocumentId, actor_id: DocumentActorId, - tx_to_core: async_channel::Sender<(DocumentActorId, DocToHubMsg)>, - tx_io: async_channel::Sender, + tx_to_core: UnboundedSender<(DocumentActorId, DocToHubMsg)>, + tx_io: UnboundedSender, ephemera_listeners: Vec>>, change_listeners: Vec>, actor: DocumentActor, @@ -28,8 +29,8 @@ impl DocActorInner { document_id: DocumentId, actor_id: DocumentActorId, actor: DocumentActor, - tx_to_core: async_channel::Sender<(DocumentActorId, DocToHubMsg)>, - tx_io: async_channel::Sender, + tx_to_core: UnboundedSender<(DocumentActorId, DocToHubMsg)>, + tx_io: UnboundedSender, ) -> Self { DocActorInner { document_id, @@ -82,7 +83,7 @@ impl DocActorInner { stopped: _, } = results; for task in io_tasks { - if let Err(_e) = self.tx_io.send_blocking(IoLoopTask { + if let Err(_e) = self.tx_io.unbounded_send(IoLoopTask { doc_id: self.document_id.clone(), task, actor_id: self.actor_id, @@ -93,7 +94,7 @@ impl DocActorInner { } for msg in outgoing_messages { - if let Err(_e) = self.tx_to_core.send_blocking((self.actor_id, msg)) { + if let Err(_e) = self.tx_to_core.unbounded_send((self.actor_id, msg)) { tracing::error!("core receiver dropped whilst document actor is still running"); return; } diff --git a/samod/src/doc_runner.rs b/samod/src/doc_runner.rs index 29da8ab..ceda1a3 100644 --- a/samod/src/doc_runner.rs +++ b/samod/src/doc_runner.rs @@ -2,22 +2,25 @@ use std::sync::{Arc, Mutex}; use samod_core::{DocumentActorId, DocumentId, actors::document::DocActorResult}; -use crate::{actor_task::ActorTask, doc_actor_inner::DocActorInner}; +use crate::{ + actor_task::ActorTask, + doc_actor_inner::DocActorInner, + unbounded::{UnboundedReceiver, UnboundedSender}, +}; /// Enum representing the two possible ways of running document actors pub(crate) enum DocRunner { /// Run the actors on a threadpool + #[cfg(feature = "threadpool")] Threadpool(rayon::ThreadPool), /// Run the actors on an async task which is listening on the other end of `tx` - Async { - tx: async_channel::Sender, - }, + Async { tx: UnboundedSender }, } pub(crate) struct SpawnedActor { pub(crate) doc_id: DocumentId, pub(crate) actor_id: DocumentActorId, pub(crate) inner: Arc>, - pub(crate) rx_tasks: async_channel::Receiver, + pub(crate) rx_tasks: UnboundedReceiver, pub(crate) init_results: DocActorResult, } diff --git a/samod/src/io_loop.rs b/samod/src/io_loop.rs index 506b4dd..d0db994 100644 --- a/samod/src/io_loop.rs +++ b/samod/src/io_loop.rs @@ -9,7 +9,7 @@ use samod_core::{ use crate::{ ActorHandle, Inner, actor_task::ActorTask, announce_policy::LocalAnnouncePolicy, - storage::LocalStorage, + storage::LocalStorage, unbounded::UnboundedReceiver, }; pub(crate) struct IoLoopTask { @@ -29,7 +29,7 @@ pub(crate) async fn io_loop( inner: Arc>, storage: S, announce_policy: A, - rx: async_channel::Receiver, + rx: UnboundedReceiver, ) { let mut running_tasks = FuturesUnordered::new(); @@ -60,7 +60,7 @@ pub(crate) async fn io_loop( tracing::warn!(?actor_id, "received io result for unknown actor"); continue; }; - let _ = tx.send_blocking(ActorTask::IoComplete(result)); + let _ = tx.unbounded_send(ActorTask::IoComplete(result)); } } } @@ -71,7 +71,7 @@ pub(crate) async fn io_loop( tracing::warn!(?actor_id, "received io result for unknown actor"); continue; }; - let _ = tx.send_blocking(ActorTask::IoComplete(result)); + let _ = tx.unbounded_send(ActorTask::IoComplete(result)); } } diff --git a/samod/src/lib.rs b/samod/src/lib.rs index 9d465b7..437655e 100644 --- a/samod/src/lib.rs +++ b/samod/src/lib.rs @@ -187,6 +187,20 @@ //! and announce policy implementations to use local variants you can then //! create a local [`Repo`] using [`RepoBuilder::load_local`]. //! +//! ## Concurrency +//! +//! Typically `samod` will be managing many documents. One for each [`DocHandle`] +//! you retrieve via [`Repo::create`] or [`Repo::find`] but also one for any +//! sync messages received about a particular document from remote peers (e.g. +//! a sync server would have no [`DocHandle`]s open but would still be running +//! many document processes). By default document tasks will be handled on the +//! async runtime provided to the [`RepoBuilder`] but this can be undesirable. +//! Document operations can be compute intensive and so responsiveness may +//! benefit from running them on a separate thread pool. This is the purpose +//! of the [`RepoBuilder::with_concurrency`] method, which allows you to +//! configure how document operations are processed. If you want to use the +//! threadpool approach you will need to enable the `threadpool` feature. +//! //! ## Why not just Automerge? //! //! `automerge` is a low level library. It provides routines for manipulating @@ -285,7 +299,6 @@ use futures::{ stream::FuturesUnordered, }; use rand::SeedableRng; -use rayon::ThreadPool; pub use samod_core::{AutomergeUrl, DocumentId, PeerId, network::ConnDirection}; use samod_core::{ CommandId, CommandResult, ConnectionId, DocumentActorId, LoaderState, UnixTimestamp, @@ -320,10 +333,12 @@ mod stopped; pub use stopped::Stopped; pub mod storage; pub use crate::announce_policy::{AlwaysAnnounce, AnnouncePolicy, LocalAnnouncePolicy}; +pub use crate::builder::ConcurrencyConfig; use crate::{ doc_actor_inner::DocActorInner, doc_runner::{DocRunner, SpawnedActor}, storage::Storage, + unbounded::{UnboundedReceiver, UnboundedSender}, }; use crate::{ io_loop::IoLoopTask, @@ -331,6 +346,7 @@ use crate::{ storage::{InMemoryStorage, LocalStorage}, }; pub mod runtime; +mod unbounded; pub mod websocket; /// The entry point to this library @@ -409,9 +425,9 @@ impl Repo { runtime, peer_id, announce_policy, - threadpool, + concurrency, } = builder; - let task_setup = TaskSetup::new(storage.clone(), peer_id, threadpool).await; + let task_setup = TaskSetup::new(storage.clone(), peer_id, concurrency).await; let inner = task_setup.inner.clone(); task_setup.spawn_tasks(runtime, storage, announce_policy); Self { inner } @@ -430,9 +446,9 @@ impl Repo { runtime, peer_id, announce_policy, - threadpool, + concurrency, } = builder; - let task_setup = TaskSetup::new(storage.clone(), peer_id, threadpool).await; + let task_setup = TaskSetup::new(storage.clone(), peer_id, concurrency).await; let inner = task_setup.inner.clone(); task_setup.spawn_tasks_local(runtime, storage, announce_policy); Self { inner } @@ -724,8 +740,8 @@ struct Inner { hub: Hub, pending_commands: HashMap>, connections: HashMap, - tx_io: async_channel::Sender, - tx_to_core: async_channel::Sender<(DocumentActorId, DocToHubMsg)>, + tx_io: UnboundedSender, + tx_to_core: UnboundedSender<(DocumentActorId, DocToHubMsg)>, waiting_for_connection: HashMap>>, stop_waiters: Vec>, rng: rand::rngs::StdRng, @@ -790,7 +806,7 @@ impl Inner { for (actor_id, actor_msg) in actor_messages { if let Some(ActorHandle { tx, .. }) = self.actors.get(&actor_id) { - let _ = tx.send_blocking(ActorTask::HandleMessage(actor_msg)); + let _ = tx.unbounded_send(ActorTask::HandleMessage(actor_msg)); } else { tracing::warn!(?actor_id, "received message for unknown actor"); } @@ -834,7 +850,7 @@ impl Inner { #[tracing::instrument(skip(self, args))] fn spawn_actor(&mut self, args: SpawnArgs) { - let (tx, rx) = async_channel::unbounded(); + let (tx, rx) = unbounded::channel(); let actor_id = args.actor_id(); let doc_id = args.document_id().clone(); let (actor, init_results) = DocumentActor::new(UnixTimestamp::now(), args); @@ -856,9 +872,10 @@ impl Inner { }, ); - let span = tracing::Span::current(); match &mut self.doc_runner { + #[cfg(feature = "threadpool")] DocRunner::Threadpool(threadpool) => { + let span = tracing::Span::current(); threadpool.spawn(move || { let _enter = span.enter(); doc_inner.lock().unwrap().handle_results(init_results); @@ -875,7 +892,7 @@ impl Inner { } DocRunner::Async { tx } => { if tx - .send_blocking(SpawnedActor { + .unbounded_send(SpawnedActor { doc_id, actor_id, inner: doc_inner, @@ -900,7 +917,7 @@ impl Inner { /// futures::executor::LocalPool). Instead, we spawn a task on the runtime which /// listens on a channel for new actors to spawn and then runs them on a /// `FuturesUnordered`. This function is that task. -async fn async_actor_runner(rx: async_channel::Receiver) { +async fn async_actor_runner(rx: UnboundedReceiver) { let mut running_actors = FuturesUnordered::new(); loop { @@ -954,32 +971,37 @@ async fn async_actor_runner(rx: async_channel::Receiver) { struct TaskSetup { peer_id: PeerId, inner: Arc>, - rx_storage: async_channel::Receiver, - rx_from_core: async_channel::Receiver<(DocumentActorId, DocToHubMsg)>, - rx_actor: Option>, + rx_storage: UnboundedReceiver, + rx_from_core: UnboundedReceiver<(DocumentActorId, DocToHubMsg)>, + rx_actor: Option>, } impl TaskSetup { async fn new( storage: S, peer_id: Option, - threadpool: Option, + concurrency: ConcurrencyConfig, ) -> TaskSetup { let mut rng = rand::rngs::StdRng::from_rng(&mut rand::rng()); let peer_id = peer_id.unwrap_or_else(|| PeerId::new_with_rng(&mut rng)); let hub = load_hub(storage.clone(), Hub::load(peer_id.clone())).await; - let (tx_storage, rx_storage) = async_channel::unbounded(); - let (tx_to_core, rx_from_core) = async_channel::unbounded(); - let mut rx_actor = None; - let doc_runner = if let Some(threadpool) = threadpool { - DocRunner::Threadpool(threadpool) - } else { - let (tx, rx) = async_channel::unbounded(); - rx_actor = Some(rx); - // tasks.push(async_actor_runner(rx).boxed_local()); - DocRunner::Async { tx } + let (tx_storage, rx_storage) = unbounded::channel(); + let (tx_to_core, rx_from_core) = unbounded::channel(); + let rx_actor: Option>; + let doc_runner = match concurrency { + #[cfg(feature = "threadpool")] + ConcurrencyConfig::Threadpool(threadpool) => { + rx_actor = None; + DocRunner::Threadpool(threadpool) + } + ConcurrencyConfig::AsyncRuntime => { + let (tx, rx) = unbounded::channel(); + rx_actor = Some(rx); + DocRunner::Async { tx } + } }; + let inner = Arc::new(Mutex::new(Inner { doc_runner, actors: HashMap::new(), diff --git a/samod/src/unbounded.rs b/samod/src/unbounded.rs new file mode 100644 index 0000000..7d09502 --- /dev/null +++ b/samod/src/unbounded.rs @@ -0,0 +1,37 @@ +/// A wrapper around `async_channel::unbounded` which removes the `TrySendError::Full` error. +pub(crate) fn channel() -> (UnboundedSender, UnboundedReceiver) { + let (tx, rx) = async_channel::unbounded(); + (UnboundedSender(tx), UnboundedReceiver(rx)) +} + +pub(crate) struct UnboundedSender(async_channel::Sender); + +impl UnboundedSender { + pub(crate) fn unbounded_send(&self, msg: T) -> Result<(), T> { + self.0.try_send(msg).map_err(|e| match e { + async_channel::TrySendError::Full(_val) => { + unreachable!("this is an unbounded channel") + } + async_channel::TrySendError::Closed(val) => val, + }) + } +} + +impl Clone for UnboundedSender { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +pub(crate) struct UnboundedReceiver(async_channel::Receiver); + +impl UnboundedReceiver { + #[cfg(feature = "threadpool")] + pub(crate) fn recv_blocking(&self) -> Result { + self.0.recv_blocking() + } + + pub(crate) async fn recv(&self) -> Result { + self.0.recv().await + } +} diff --git a/samod/tests/localpool_smoke.rs b/samod/tests/localpool_smoke.rs index 90c0ae9..baa9420 100644 --- a/samod/tests/localpool_smoke.rs +++ b/samod/tests/localpool_smoke.rs @@ -8,7 +8,7 @@ use futures::{ executor::LocalPool, task::{LocalSpawnExt, SpawnExt}, }; -use samod::ConnDirection; +use samod::{ConcurrencyConfig, ConnDirection}; fn init_logging() { let _ = tracing_subscriber::fmt() @@ -30,13 +30,12 @@ fn test_localpool() { .spawn_local(async move { let alice = samod::Repo::build_localpool(spawner.clone()) .with_peer_id("alice".into()) - .with_threadpool(None) .load_local() .await; let bob = samod::Repo::build_localpool(spawner.clone()) .with_peer_id("bob".into()) - .with_threadpool(None) + .with_concurrency(ConcurrencyConfig::AsyncRuntime) .load() .await; diff --git a/samod/tests/smoke.rs b/samod/tests/smoke.rs index 1d02a11..b8bc107 100644 --- a/samod/tests/smoke.rs +++ b/samod/tests/smoke.rs @@ -3,7 +3,7 @@ use std::time::Duration; use automerge::Automerge; -use samod::{PeerId, Repo, storage::InMemoryStorage}; +use samod::{ConcurrencyConfig, PeerId, Repo, storage::InMemoryStorage}; mod tincans; fn init_logging() { @@ -79,6 +79,53 @@ async fn basic_sync() { alice.stop().await; } +#[tokio::test] +#[cfg(feature = "threadpool")] +async fn basic_sync_threadpool() { + use samod::PeerId; + + init_logging(); + + let alice = Repo::build_tokio() + .with_peer_id(PeerId::from("alice")) + .with_concurrency(ConcurrencyConfig::Threadpool( + rayon::ThreadPoolBuilder::new().build().unwrap(), + )) + .load() + .await; + + let bob = Repo::build_tokio() + .with_peer_id(PeerId::from("bob")) + .with_concurrency(ConcurrencyConfig::Threadpool( + rayon::ThreadPoolBuilder::new().build().unwrap(), + )) + .load() + .await; + + tincans::connect_repos(&alice, &bob); + + bob.when_connected(alice.peer_id()).await.unwrap(); + alice.when_connected(bob.peer_id()).await.unwrap(); + + let alice_handle = alice.create(Automerge::new()).await.unwrap(); + alice_handle.with_document(|am| { + use automerge::{AutomergeError, ROOT}; + + am.transact::<_, _, AutomergeError>(|tx| { + use automerge::transaction::Transactable; + + tx.put(ROOT, "foo", "bar")?; + Ok(()) + }) + .unwrap(); + }); + + let bob_handle = bob.find(alice_handle.document_id().clone()).await.unwrap(); + assert!(bob_handle.is_some()); + bob.stop().await; + alice.stop().await; +} + #[tokio::test] async fn non_announcing_peers_dont_sync() { init_logging(); From 58fc7aa561be14772a160e65e0204eaf5c013591 Mon Sep 17 00:00:00 2001 From: "Jack D. Douglas" Date: Thu, 11 Sep 2025 15:27:43 +0100 Subject: [PATCH 10/12] feat: TON-1444: playwright/server wasm tests --- Cargo.lock | 23 + samod-core/Cargo.toml | 2 +- samod/Cargo.toml | 2 + samod/interop-test-server/pnpm-lock.yaml | 1644 ++++++++++++++++++++++ samod/interop-test-server/server.ts | 295 ++-- samod/src/lib.rs | 143 +- samod/src/wasm.rs | 655 +++++++++ samod/src/websocket.rs | 16 +- scripts/build-wasm.sh | 35 + scripts/test-wasm.sh | 40 + wasm-tests/.gitignore | 7 + wasm-tests/.prettierrc.json | 27 + wasm-tests/package-lock.json | 1337 ++++++++++++++++++ wasm-tests/package.json | 34 + wasm-tests/playwright.config.ts | 37 + wasm-tests/pnpm-lock.yaml | 835 +++++++++++ wasm-tests/src/app.ts | 480 +++++++ wasm-tests/src/index.html | 211 +++ wasm-tests/tests/samod-wasm.spec.ts | 576 ++++++++ wasm-tests/tests/server-helper.ts | 121 ++ wasm-tests/tsconfig.json | 20 + wasm-tests/vite.config.js | 18 + 22 files changed, 6459 insertions(+), 99 deletions(-) create mode 100644 samod/interop-test-server/pnpm-lock.yaml create mode 100644 samod/src/wasm.rs create mode 100755 scripts/build-wasm.sh create mode 100755 scripts/test-wasm.sh create mode 100644 wasm-tests/.gitignore create mode 100644 wasm-tests/.prettierrc.json create mode 100644 wasm-tests/package-lock.json create mode 100644 wasm-tests/package.json create mode 100644 wasm-tests/playwright.config.ts create mode 100644 wasm-tests/pnpm-lock.yaml create mode 100644 wasm-tests/src/app.ts create mode 100644 wasm-tests/src/index.html create mode 100644 wasm-tests/tests/samod-wasm.spec.ts create mode 100644 wasm-tests/tests/server-helper.ts create mode 100644 wasm-tests/tsconfig.json create mode 100644 wasm-tests/vite.config.js diff --git a/Cargo.lock b/Cargo.lock index 5514b67..28ae405 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1550,6 +1560,7 @@ dependencies = [ "axum", "bytes", "chrono", + "console_error_panic_hook", "eyre", "futures", "gio", @@ -1568,6 +1579,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "tracing-wasm", "tungstenite 0.27.0", "wasm-bindgen", "wasm-bindgen-futures", @@ -2155,6 +2167,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/samod-core/Cargo.toml b/samod-core/Cargo.toml index 0c2fc90..2c39bfd 100644 --- a/samod-core/Cargo.toml +++ b/samod-core/Cargo.toml @@ -37,5 +37,5 @@ wasm = ["js-sys", "wasm-bindgen", "web-sys"] tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } samod-test-harness = { path = "../samod-test-harness" } -[target.'cfg(not(taget_arch = "wasm32"))'.dev-dependencies] +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } diff --git a/samod/Cargo.toml b/samod/Cargo.toml index 126d99b..2aceac3 100644 --- a/samod/Cargo.toml +++ b/samod/Cargo.toml @@ -60,6 +60,8 @@ web-sys = { version = "0.3", features = [ "FileSystemWritableFileStream", ], optional = true } indexed_db_futures = { version = "0.6.4", features = ["cursors", "async-upgrade"] } +console_error_panic_hook = "0.1" +tracing-wasm = "0.2" tokio = { version = "1.46.0", features = ["rt", "time"], optional = true } tokio-util = { version = "0.7.15", features = [ "codec", diff --git a/samod/interop-test-server/pnpm-lock.yaml b/samod/interop-test-server/pnpm-lock.yaml new file mode 100644 index 0000000..b4a0c2f --- /dev/null +++ b/samod/interop-test-server/pnpm-lock.yaml @@ -0,0 +1,1644 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@automerge/automerge': + specifier: ^2.2.9 + version: 2.2.9 + '@automerge/automerge-repo': + specifier: ^2.0.7 + version: 2.3.0 + '@automerge/automerge-repo-network-websocket': + specifier: ^2.0.7 + version: 2.3.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + cmd-ts: + specifier: ^0.13.0 + version: 0.13.0 + express: + specifier: ^5.1.0 + version: 5.1.0 + ws: + specifier: ^8.13.0 + version: 8.18.3 + devDependencies: + '@types/node': + specifier: ^18.14.0 + version: 18.19.124 + ts-mocha: + specifier: ^10.0.0 + version: 10.1.0(mocha@11.7.2) + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@types/node@18.19.124)(typescript@4.9.5) + typescript: + specifier: ^4.9.5 + version: 4.9.5 + +packages: + + '@automerge/automerge-repo-network-websocket@2.3.0': + resolution: {integrity: sha512-1NddZXFu9Ry2Pfy7p/wbvG8TcsPvv87NPhD7+aiLb8miWrk1TjHYEUbwRQPMUN2lKAcDtnbpMZbJxbwgItxFWA==} + + '@automerge/automerge-repo@2.3.0': + resolution: {integrity: sha512-nZppJl90bK0zVGtByHDvXZ1gC8HCcKnhGkJxMLrPxuFlLdoAfq7x10Tk6AT0eMfx1048kl5zRDH7DpAZD+SvMw==} + + '@automerge/automerge@2.2.9': + resolution: {integrity: sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + resolution: {integrity: sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + resolution: {integrity: sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + resolution: {integrity: sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + resolution: {integrity: sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + resolution: {integrity: sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + resolution: {integrity: sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@18.19.124': + resolution: {integrity: sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + arrify@1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base-x@4.0.1: + resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + + bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + + bs58check@3.0.1: + resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + cbor-extract@2.2.0: + resolution: {integrity: sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==} + hasBin: true + + cbor-x@1.6.0: + resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + cmd-ts@0.13.0: + resolution: {integrity: sha512-nsnxf6wNIM/JAS7T/x/1JmbEsjH0a8tezXqqpaL0O6+eV0/aDEnRxwjxpu0VzDdRcaC1ixGSbRlUuf/IU59I4g==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diff@3.5.0: + resolution: {integrity: sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==} + engines: {node: '>=0.3.1'} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + diff@7.0.0: + resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} + engines: {node: '>=0.3.1'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mocha@11.7.2: + resolution: {integrity: sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.1: + resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} + engines: {node: '>= 0.10'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-mocha@10.1.0: + resolution: {integrity: sha512-T0C0Xm3/WqCuF2tpa0GNGESTBoKZaiqdUP8guNv4ZY316AFXlyidnrzQ1LUrCT0Wb1i3J0zFTgOh/55Un44WdA==} + engines: {node: '>= 6.X.X'} + hasBin: true + peerDependencies: + mocha: ^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X || ^10.X.X || ^11.X.X + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + ts-node@7.0.1: + resolution: {integrity: sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==} + engines: {node: '>=4.2.0'} + hasBin: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + workerpool@9.3.4: + resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.21.0: + resolution: {integrity: sha512-y4wmqxjyAa0tgz4k3m/MgTF1kDOahE5+xLfWt5eh1sk+43DatLhKlI8lQDJZpvihZavjbD3TUgy2PRMphhhqgQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yn@2.0.0: + resolution: {integrity: sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==} + engines: {node: '>=4'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@automerge/automerge-repo-network-websocket@2.3.0': + dependencies: + '@automerge/automerge-repo': 2.3.0 + cbor-x: 1.6.0 + debug: 4.4.1(supports-color@8.1.1) + eventemitter3: 5.0.1 + isomorphic-ws: 5.0.0(ws@8.18.3) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo@2.3.0': + dependencies: + '@automerge/automerge': 2.2.9 + bs58check: 3.0.1 + cbor-x: 1.6.0 + debug: 4.4.1(supports-color@8.1.1) + eventemitter3: 5.0.1 + fast-sha256: 1.3.0 + uuid: 9.0.1 + xstate: 5.21.0 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge@2.2.9': + dependencies: + uuid: 9.0.1 + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@noble/hashes@1.8.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/json5@0.0.29': + optional: true + + '@types/node@18.19.124': + dependencies: + undici-types: 5.26.5 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 18.19.124 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + arg@4.1.3: {} + + argparse@2.0.1: {} + + arrify@1.0.1: {} + + balanced-match@1.0.2: {} + + base-x@4.0.1: {} + + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.1(supports-color@8.1.1) + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.1 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + browser-stdout@1.3.1: {} + + bs58@5.0.0: + dependencies: + base-x: 4.0.1 + + bs58check@3.0.1: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 5.0.0 + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@6.3.0: {} + + cbor-extract@2.2.0: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.0 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.0 + '@cbor-extract/cbor-extract-linux-x64': 2.2.0 + '@cbor-extract/cbor-extract-win32-x64': 2.2.0 + optional: true + + cbor-x@1.6.0: + optionalDependencies: + cbor-extract: 2.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cmd-ts@0.13.0: + dependencies: + chalk: 4.1.2 + debug: 4.4.1(supports-color@8.1.1) + didyoumean: 1.2.2 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - supports-color + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + create-require@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.1(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decamelize@4.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.0.4: + optional: true + + didyoumean@1.2.2: {} + + diff@3.5.0: {} + + diff@4.0.2: {} + + diff@7.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + etag@1.8.1: {} + + eventemitter3@5.0.1: {} + + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.1(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-sha256@1.3.0: {} + + finalhandler@2.1.0: + dependencies: + debug: 4.4.1(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat@5.0.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-plain-obj@2.1.0: {} + + is-promise@4.0.0: {} + + is-unicode-supported@0.1.0: {} + + isexe@2.0.0: {} + + isomorphic-ws@5.0.0(ws@8.18.3): + dependencies: + ws: 8.18.3 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + optional: true + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + lru-cache@10.4.3: {} + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mocha@11.7.2: + dependencies: + browser-stdout: 1.3.1 + chokidar: 4.0.3 + debug: 4.4.1(supports-color@8.1.1) + diff: 7.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 10.4.5 + he: 1.2.0 + js-yaml: 4.1.0 + log-symbols: 4.1.0 + minimatch: 9.0.5 + ms: 2.1.3 + picocolors: 1.1.1 + serialize-javascript: 6.0.2 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 9.3.4 + yargs: 17.7.2 + yargs-parser: 21.1.1 + yargs-unparser: 2.0.0 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.0.4 + optional: true + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@8.3.0: {} + + picocolors@1.1.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: {} + + raw-body@3.0.1: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.7.0 + unpipe: 1.0.0 + + readdirp@4.1.2: {} + + require-directory@2.1.1: {} + + router@2.2.0: + dependencies: + debug: 4.4.1(supports-color@8.1.1) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + send@1.2.0: + dependencies: + debug: 4.4.1(supports-color@8.1.1) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.0 + mime-types: 3.0.1 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + statuses@2.0.1: {} + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: + optional: true + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + toidentifier@1.0.1: {} + + ts-mocha@10.1.0(mocha@11.7.2): + dependencies: + mocha: 11.7.2 + ts-node: 7.0.1 + optionalDependencies: + tsconfig-paths: 3.15.0 + + ts-node@10.9.2(@types/node@18.19.124)(typescript@4.9.5): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 18.19.124 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 4.9.5 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + ts-node@7.0.1: + dependencies: + arrify: 1.0.1 + buffer-from: 1.1.2 + diff: 3.5.0 + make-error: 1.3.6 + minimist: 1.2.8 + mkdirp: 0.5.6 + source-map-support: 0.5.21 + yn: 2.0.0 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + optional: true + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + + typescript@4.9.5: {} + + undici-types@5.26.5: {} + + unpipe@1.0.0: {} + + uuid@9.0.1: {} + + v8-compile-cache-lib@3.0.1: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + workerpool@9.3.4: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + ws@8.18.3: {} + + xstate@5.21.0: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs-unparser@2.0.0: + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@2.0.0: {} + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} diff --git a/samod/interop-test-server/server.ts b/samod/interop-test-server/server.ts index 82ca214..27355c3 100644 --- a/samod/interop-test-server/server.ts +++ b/samod/interop-test-server/server.ts @@ -1,119 +1,224 @@ import express from "express"; import { WebSocketServer } from "ws"; import { - Chunk, - Repo, - RepoConfig, - StorageAdapterInterface, - StorageKey, + Chunk, + Repo, + RepoConfig, + StorageAdapterInterface, + StorageKey, } from "@automerge/automerge-repo"; import { NodeWSServerAdapter } from "@automerge/automerge-repo-network-websocket"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); class Server { - #socket: WebSocketServer; - - #server: ReturnType; - #storage: InMemoryStorageAdapter; - - #repo: Repo; - - constructor(port: number) { - this.#socket = new WebSocketServer({ noServer: true }); - - const PORT = port; - const app = express(); - app.use(express.static("public")); - this.#storage = new InMemoryStorageAdapter(); - - const config: RepoConfig = { - // network: [new NodeWSServerAdapter(this.#socket) as any], - network: [new NodeWSServerAdapter(this.#socket as any)], - storage: this.#storage, - /** @ts-ignore @type {(import("automerge-repo").PeerId)} */ - peerId: `storage-server` as PeerId, - // Since this is a server, we don't share generously — meaning we only sync documents they already - // know about and can ask for by ID. - sharePolicy: async () => false, - }; - const serverRepo = new Repo(config); - this.#repo = serverRepo; - - app.get("/", (req, res) => { - res.send(`👍 @automerge/automerge-repo-sync-server is running`); - }); + #socket: WebSocketServer; + + #server: ReturnType; + #storage: InMemoryStorageAdapter; + + #repo: Repo; + #isWasmMode: boolean; + + constructor(port: number, options: { wasm?: boolean } = {}) { + this.#isWasmMode = options.wasm || false; + this.#socket = new WebSocketServer({ noServer: true }); + + const PORT = port; + const app = express(); + + // Serve static files for WASM mode + if (this.#isWasmMode) { + // Enable CORS for WASM testing + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + next(); + }); + + // Serve WASM test files + app.use('/wasm', express.static(path.join(__dirname, '../../wasm-tests/src'))); + app.use('/pkg', express.static(path.join(__dirname, '../../wasm-tests/pkg'))); + } + + app.use(express.static("public")); + this.#storage = new InMemoryStorageAdapter(); + + const config: RepoConfig = { + // network: [new NodeWSServerAdapter(this.#socket) as any], + network: [new NodeWSServerAdapter(this.#socket as any)], + storage: this.#storage, + /** @ts-ignore @type {(import("automerge-repo").PeerId)} */ + peerId: `storage-server` as PeerId, + // Since this is a server, we don't share generously — meaning we only sync documents they already + // know about and can ask for by ID. + sharePolicy: async () => true, + }; + const serverRepo = new Repo(config); + this.#repo = serverRepo; + + app.get("/", (req, res) => { + const mode = this.#isWasmMode ? ' (WASM mode)' : ''; + res.send(`👍 @automerge/automerge-repo-sync-server is running${mode}`); + }); - this.#server = app.listen(PORT, () => { - console.log(`Listening on port ${this.#server.address().port}`); - }); + // WASM-specific endpoints + if (this.#isWasmMode) { + app.get('/health', (req, res) => { + res.json({ + status: 'ok', + mode: 'wasm', + storage: this.#storage.size(), + connections: this.#socket.clients.size + }); + }); + + app.get('/stats', (req, res) => { + res.json({ + documentsStored: this.#storage.size(), + activeConnections: this.#socket.clients.size, + uptime: process.uptime() + }); + }); + + // Testing endpoints for disconnection + app.post('/test/disconnect-all', (req, res) => { + let disconnectedCount = 0; + for (const ws of this.#socket.clients) { + if (ws.readyState === ws.OPEN) { + ws.close(1000, 'Server-initiated disconnect for testing'); + disconnectedCount++; + } + } + res.json({ disconnected: disconnectedCount }); + }); + + app.post('/test/disconnect-random', (req, res) => { + const clients = Array.from(this.#socket.clients).filter(ws => ws.readyState === ws.OPEN); + if (clients.length === 0) { + res.json({ disconnected: 0, error: 'No active connections' }); + return; + } + + const randomClient = clients[Math.floor(Math.random() * clients.length)]; + randomClient.close(1000, 'Server-initiated disconnect for testing'); + res.json({ disconnected: 1 }); + }); + + // Clear all documents from server storage + app.post('/test/clear-storage', (req, res) => { + const previousSize = this.#storage.size(); + this.#storage.clear(); + res.json({ + previousSize, + currentSize: this.#storage.size(), + cleared: true + }); + }); + } + + this.#server = app.listen(PORT, () => { + const mode = this.#isWasmMode ? ' (WASM mode)' : ''; + console.log(`Listening on port ${this.#server.address().port}${mode}`); + }); - this.#server.on("upgrade", (request, socket, head) => { - console.log("upgrading to websocket"); - this.#socket.handleUpgrade(request, socket, head, (socket) => { - this.#socket.emit("connection", socket, request); - }); - }); - } + this.#server.on("upgrade", (request, socket, head) => { + console.log(`Upgrading to websocket${this.#isWasmMode ? ' (WASM client)' : ''}`); + this.#socket.handleUpgrade(request, socket, head, (socket) => { + this.#socket.emit("connection", socket, request); + }); + }); + } - close() { - this.#storage.log(); - this.#socket.close(); - this.#server.close(); - } + close() { + this.#storage.log(); + this.#socket.close(); + this.#server.close(); + } } class InMemoryStorageAdapter implements StorageAdapterInterface { - #data: Map = new Map(); - - async load(key: StorageKey): Promise { - return this.#data.get(key); - } - async save(key: StorageKey, data: Uint8Array): Promise { - this.#data.set(key, data); - } - async remove(key: StorageKey): Promise { - this.#data.delete(key); - } - async loadRange(keyPrefix: StorageKey): Promise { - let result: Chunk[] = []; - for (const [key, value] of this.#data.entries()) { - if (isPrefixOf(keyPrefix, key)) { - result.push({ - key, - data: value, - }); - } - } - return result; - } + #data: Map = new Map(); - removeRange(keyPrefix: StorageKey): Promise { - for (const [key] of this.#data.entries()) { - if (isPrefixOf(keyPrefix, key)) { + async load(key: StorageKey): Promise { + return this.#data.get(key); + } + async save(key: StorageKey, data: Uint8Array): Promise { + this.#data.set(key, data); + } + async remove(key: StorageKey): Promise { this.#data.delete(key); - } } - return Promise.resolve(); - } + async loadRange(keyPrefix: StorageKey): Promise { + let result: Chunk[] = []; + for (const [key, value] of this.#data.entries()) { + if (isPrefixOf(keyPrefix, key)) { + result.push({ + key, + data: value, + }); + } + } + return result; + } - log() { - console.log(`InMemoryStorageAdapter has ${this.#data.size} items:`); - for (const [key, value] of this.#data.entries()) { - console.log(` ${key.join("/")}: ${value.length} bytes`); + removeRange(keyPrefix: StorageKey): Promise { + for (const [key] of this.#data.entries()) { + if (isPrefixOf(keyPrefix, key)) { + this.#data.delete(key); + } + } + return Promise.resolve(); + } + + log() { + console.log(`InMemoryStorageAdapter has ${this.#data.size} items:`); + for (const [key, value] of this.#data.entries()) { + console.log(` ${key.join("/")}: ${value.length} bytes`); + } + } + + size(): number { + return this.#data.size; + } + + clear(): void { + this.#data.clear(); } - } } function isPrefixOf(prefix: StorageKey, candidate: StorageKey): boolean { - return ( - prefix.length <= candidate.length && - prefix.every((segment, index) => segment === candidate[index]) - ); + return ( + prefix.length <= candidate.length && + prefix.every((segment, index) => segment === candidate[index]) + ); } -const port = process.argv[2] ? parseInt(process.argv[2]) : 8080; -const server = new Server(port); +export { Server }; + +// Only start server if run directly +if (import.meta.url === `file://${process.argv[1]}`) { + // Parse command line arguments + const args = process.argv.slice(2); + const port = args.find(arg => !isNaN(parseInt(arg))) ? parseInt(args.find(arg => !isNaN(parseInt(arg)))!) : 3001; + const isWasmMode = args.includes('--wasm'); -process.on("SIGINT", () => { - server.close(); - process.exit(0); -}); + if (isWasmMode) { + console.log('Starting server in WASM test mode...'); + } + + const server = new Server(port, { wasm: isWasmMode }); + + process.on("SIGINT", () => { + server.close(); + process.exit(0); + }); + + process.on("SIGTERM", () => { + server.close(); + process.exit(0); + }); +} diff --git a/samod/src/lib.rs b/samod/src/lib.rs index 3fb66e5..8b9d6bf 100644 --- a/samod/src/lib.rs +++ b/samod/src/lib.rs @@ -349,6 +349,9 @@ pub mod runtime; mod unbounded; pub mod websocket; +#[cfg(feature = "wasm")] +pub mod wasm; + /// The entry point to this library /// /// A [`Repo`] represents a set of running [`DocHandle`]s, active connections to @@ -614,11 +617,14 @@ impl Repo { Snk: Sink, Error = SendErr> + Send + 'static + Unpin, Str: Stream, RecvErr>> + Send + 'static + Unpin, { + let local_peer_id = self.inner.lock().unwrap().hub.peer_id(); + tracing::info!("Creating connection with direction {:?}, local peer: {}", direction, local_peer_id); tracing::Span::current().record( "local_peer_id", - self.inner.lock().unwrap().hub.peer_id().to_string(), + local_peer_id.to_string(), ); let DispatchedCommand { command_id, event } = HubEvent::create_connection(direction); + tracing::debug!("Created connection command_id: {:?}", command_id); let (tx, rx) = oneshot::channel(); self.inner .lock() @@ -626,6 +632,7 @@ impl Repo { .pending_commands .insert(command_id, tx); self.inner.lock().unwrap().handle_event(event); + tracing::debug!("Handled create_connection event"); let inner = self.inner.clone(); async move { @@ -647,12 +654,14 @@ impl Repo { }; let mut stream = stream.fuse(); + tracing::info!("Starting connection message loop for connection {:?}", connection_id); let result = loop { futures::select! { next_inbound_msg = stream.next() => { if let Some(msg) = next_inbound_msg { match msg { Ok(msg) => { + tracing::debug!("Received {} bytes from stream", msg.len()); let DispatchedCommand { event, .. } = HubEvent::receive(connection_id, msg); inner.lock().unwrap().handle_event(event); } @@ -668,6 +677,7 @@ impl Repo { }, next_outbound = rx.next() => { if let Some(next_outbound) = next_outbound { + tracing::debug!("Sending {} bytes to stream", next_outbound.len()); if let Err(e) = sink.send(next_outbound).await { tracing::error!(err=?e, "error sending, closing connection"); break ConnFinishedReason::ErrorSending(e.to_string()); @@ -679,6 +689,7 @@ impl Repo { } } }; + tracing::info!("Connection message loop ended with result: {:?}", result); if !(result == ConnFinishedReason::WeDisconnected) { let event = HubEvent::connection_lost(connection_id); inner.lock().unwrap().handle_event(event); @@ -690,6 +701,119 @@ impl Repo { } } + /// Connect with an additional close signal for controlled disconnection + pub async fn connect_with_close_signal( + &self, + stream: Str, + mut sink: Snk, + direction: ConnDirection, + close_signal: CloseFuture, + ) -> ConnFinishedReason + where + SendErr: std::error::Error + Send + Sync + 'static, + RecvErr: std::error::Error + Send + Sync + 'static, + Snk: Sink, Error = SendErr> + Send + 'static + Unpin, + Str: Stream, RecvErr>> + Send + 'static + Unpin, + CloseFuture: Future> + Unpin, + { + tracing::Span::current().record( + "local_peer_id", + self.inner.lock().unwrap().hub.peer_id().to_string(), + ); + let DispatchedCommand { command_id, event } = HubEvent::create_connection(direction); + let (tx, rx) = oneshot::channel(); + self.inner + .lock() + .unwrap() + .pending_commands + .insert(command_id, tx); + self.inner.lock().unwrap().handle_event(event); + + let inner = self.inner.clone(); + + let connection_id = match rx.await { + Ok(CommandResult::CreateConnection { connection_id }) => { + #[cfg(target_arch = "wasm32")] + web_sys::console::log_1(&format!("WASM: Got connection_id: {:?}", connection_id).into()); + connection_id + }, + Ok(other) => panic!("unexpected command result for create connection: {other:?}"), + Err(_) => return ConnFinishedReason::Shutdown, + }; + + let mut rx = { + let mut rx = inner + .lock() + .unwrap() + .connections + .get_mut(&connection_id) + .map(|ConnHandle { rx, .. }| rx.take()) + .expect("connection not found"); + rx.take().expect("receive end not found") + }; + + let mut stream = stream.fuse(); + let mut close_signal = close_signal.fuse(); + + tracing::info!("Starting connection message loop for connection {:?}", connection_id); + + #[cfg(target_arch = "wasm32")] + web_sys::console::log_1(&format!("WASM: Starting connection message loop for connection {:?}", connection_id).into()); + + let result = loop { + futures::select! { + _ = close_signal => { + tracing::debug!("close signal received, closing connection"); + break ConnFinishedReason::WeDisconnected; + }, + next_inbound_msg = stream.next() => { + if let Some(msg) = next_inbound_msg { + match msg { + Ok(msg) => { + tracing::debug!("Received {} bytes from stream", msg.len()); + #[cfg(target_arch = "wasm32")] + web_sys::console::log_1(&format!("WASM: Received {} bytes from stream", msg.len()).into()); + let DispatchedCommand { event, .. } = HubEvent::receive(connection_id, msg); + inner.lock().unwrap().handle_event(event); + } + Err(e) => { + tracing::error!(err=?e, "error receiving, closing connection"); + break ConnFinishedReason::ErrorReceiving(e.to_string()); + } + } + } else { + tracing::debug!("stream closed, closing connection"); + break ConnFinishedReason::TheyDisconnected; + } + }, + next_outbound = rx.next() => { + if let Some(next_outbound) = next_outbound { + tracing::debug!("Sending {} bytes to stream", next_outbound.len()); + #[cfg(target_arch = "wasm32")] + web_sys::console::log_1(&format!("WASM: Sending {} bytes to stream", next_outbound.len()).into()); + if let Err(e) = sink.send(next_outbound).await { + tracing::error!(err=?e, "error sending, closing connection"); + break ConnFinishedReason::ErrorSending(e.to_string()); + } + } else { + tracing::debug!(?connection_id, "connection closing"); + #[cfg(target_arch = "wasm32")] + web_sys::console::log_1(&format!("WASM: Connection {:?} closing", connection_id).into()); + break ConnFinishedReason::WeDisconnected; + } + } + } + }; + if !(result == ConnFinishedReason::WeDisconnected) { + let event = HubEvent::connection_lost(connection_id); + inner.lock().unwrap().handle_event(event); + } + if let Err(e) = sink.close().await { + tracing::error!(err=?e, "error closing sink"); + } + result + } + /// Wait for some connection to be established with the given remote peer ID /// /// This will resolve immediately if the peer is already connected, otherwise @@ -723,6 +847,23 @@ impl Repo { self.inner.lock().unwrap().hub.peer_id().clone() } + /// List all document IDs that are stored locally + /// + /// This method returns document IDs of all currently active document handles. + /// For now, this implementation returns the document IDs of documents that are + /// currently loaded in memory. A future enhancement could scan storage for + /// persisted documents, but this would require significant changes to the + /// architecture to access storage from the Repo level. + pub fn list_documents(&self) -> Result, Stopped> { + let inner = self.inner.lock().unwrap(); + let doc_ids: Vec = inner + .actors + .values() + .map(|actor_handle| actor_handle.doc.document_id().clone()) + .collect(); + Ok(doc_ids) + } + /// Stop the `Samod` instance. /// /// This will wait until all storage tasks have completed before stopping all diff --git a/samod/src/wasm.rs b/samod/src/wasm.rs new file mode 100644 index 0000000..301a94e --- /dev/null +++ b/samod/src/wasm.rs @@ -0,0 +1,655 @@ +//! WASM bindings for Samod +//! +//! This module provides JavaScript-friendly bindings for using Samod in web browsers. +//! All the main functionality of the Rust API is exposed through WebAssembly bindings. + +use std::str::FromStr; + +use automerge::{Automerge, transaction::Transactable}; +use futures::{StreamExt, channel::oneshot}; +use js_sys::Promise; +use samod_core::DocumentId; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::future_to_promise; + +use crate::{ConnDirection, ConnFinishedReason, DocHandle, Repo, storage::IndexedDbStorage}; + +/// JavaScript error type for WASM bindings +#[wasm_bindgen] +pub struct WasmError { + message: String, +} + +#[wasm_bindgen] +impl WasmError { + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.message.clone() + } +} + +impl From for WasmError { + fn from(message: String) -> Self { + Self { message } + } +} + +impl From<&str> for WasmError { + fn from(message: &str) -> Self { + Self { + message: message.to_string(), + } + } +} + +impl From for WasmError { + fn from(error: automerge::AutomergeError) -> Self { + Self { + message: format!("Automerge error: {}", error), + } + } +} + +/// WASM wrapper around a Samod repository +#[wasm_bindgen] +pub struct WasmRepo { + inner: Repo, +} + +#[wasm_bindgen] +impl WasmRepo { + /// Create a new WASM repository with IndexedDB storage + #[wasm_bindgen(constructor)] + pub fn new() -> Promise { + // Initialize WASM tracing to forward Rust logs to browser console + console_error_panic_hook::set_once(); + tracing_wasm::set_as_global_default(); + + future_to_promise(async { + tracing::info!("WASM: Initializing Samod repository with IndexedDB storage"); + + // Always use fixed database and store names for consistency + let storage = IndexedDbStorage::with_names("samod_db", "documents"); + let repo = Repo::build_wasm() + .with_storage(storage.clone()) + .load_local() + .await; + + // Create a WasmRepo instance + let wasm_repo = WasmRepo { inner: repo }; + + // Restore documents from storage + if let Err(e) = wasm_repo.restore_documents_from_storage(storage).await { + tracing::warn!("Failed to restore some documents from storage: {}", e); + } + + tracing::info!("WASM: Repository initialized and documents restored"); + Ok(JsValue::from(wasm_repo)) + }) + } + + /// Create a new document with optional initial content + #[wasm_bindgen(js_name = "createDocument")] + pub fn create_document(&self, initial_content: JsValue) -> Promise { + let repo = self.inner.clone(); + + future_to_promise(async move { + let initial_doc = if !initial_content.is_undefined() { + let mut doc = Automerge::new(); + populate_doc_from_js_value(&mut doc, &initial_content).map_err(|e| { + WasmError::from(format!( + "Failed to populate document from initial content: {}", + e + )) + })?; + doc + } else { + Automerge::new() + }; + + match repo.create(initial_doc).await { + Ok(doc_handle) => Ok(JsValue::from(WasmDocHandle::new(doc_handle))), + Err(_) => Err(JsValue::from(WasmError::from("Failed to create document"))), + } + }) + } + + /// Find an existing document by ID + #[wasm_bindgen(js_name = "findDocument")] + pub fn find_document(&self, document_id: &str) -> Promise { + let repo = self.inner.clone(); + let doc_id = document_id.to_string(); + + future_to_promise(async move { + let document_id = match DocumentId::from_str(&doc_id) { + Ok(id) => id, + Err(_) => return Err(JsValue::from(WasmError::from("Invalid document ID format"))), + }; + + match repo.find(document_id).await { + Ok(Some(doc_handle)) => Ok(JsValue::from(WasmDocHandle::new(doc_handle))), + Ok(None) => Ok(JsValue::NULL), + Err(_) => Err(JsValue::from(WasmError::from("Failed to find document"))), + } + }) + } + + /// Get the peer ID of this repository + #[wasm_bindgen(js_name = "peerId")] + pub fn peer_id(&self) -> String { + self.inner.peer_id().to_string() + } + + /// Stop the repository and flush all data to storage + #[wasm_bindgen] + pub fn stop(&self) -> Promise { + let repo = self.inner.clone(); + + future_to_promise(async move { + repo.stop().await; + Ok(JsValue::UNDEFINED) + }) + } + + /// List all document IDs that are stored locally + #[wasm_bindgen(js_name = "listDocuments")] + pub fn list_documents(&self) -> Result { + match self.inner.list_documents() { + Ok(doc_ids) => { + let js_array = js_sys::Array::new(); + for doc_id in doc_ids { + js_array.push(&JsValue::from_str(&doc_id.to_string())); + } + Ok(js_array) + } + Err(_) => Err(WasmError::from("Failed to list documents")), + } + } + + /// Connect to a WebSocket server for real-time synchronization + /// Returns a handle that can be used to close the connection + #[wasm_bindgen(js_name = "connectWebSocket")] + pub fn connect_websocket(&self, url: &str) -> Result { + #[cfg(feature = "wasm")] + { + let repo = self.inner.clone(); + let url = url.to_string(); + + web_sys::console::log_1( + &format!("WASM: Creating WebSocket connection handle for {}", url).into(), + ); + + // Create channels to signal close and receive finish notification + let (close_tx, close_rx) = oneshot::channel::<()>(); + let (finish_tx, finish_rx) = oneshot::channel::(); + + // Spawn the connection task + wasm_bindgen_futures::spawn_local(async move { + web_sys::console::log_1( + &format!("WASM: Starting connection task for {}", url).into(), + ); + + // Create the websocket connection + match crate::websocket::WasmWebSocket::connect(&url).await { + Ok(ws) => { + web_sys::console::log_1( + &"WASM: WebSocket connected, splitting into sink/stream".into(), + ); + let (sink, stream) = StreamExt::split(ws); + + web_sys::console::log_1(&"WASM: About to call connect_with_close_signal - protocol handshake should start".into()); + + // Run the connection with close signal + let result = repo + .connect_with_close_signal( + stream, + sink, + ConnDirection::Outgoing, + close_rx, + ) + .await; + + web_sys::console::log_1( + &format!("WASM: Connection ended with: {:?}", result).into(), + ); + let reason_str = match result { + ConnFinishedReason::WeDisconnected => { + web_sys::console::log_1( + &"WASM: Normal close initiated by client".into(), + ); + "we_disconnected" + } + ConnFinishedReason::TheyDisconnected => { + web_sys::console::log_1( + &"WASM: Connection closed by server".into(), + ); + "server_disconnected" + } + ConnFinishedReason::ErrorReceiving(_) => { + web_sys::console::log_1( + &"WASM: Connection ended due to receive error".into(), + ); + "error_receiving" + } + ConnFinishedReason::ErrorSending(_) => { + web_sys::console::log_1( + &"WASM: Connection ended due to send error".into(), + ); + "error_sending" + } + ConnFinishedReason::Error(_) => { + web_sys::console::log_1( + &"WASM: Connection ended due to generic error".into(), + ); + "error" + } + ConnFinishedReason::Shutdown => { + web_sys::console::log_1( + &"WASM: Connection ended due to shutdown".into(), + ); + "shutdown" + } + }; + + // Notify about the connection ending + let _ = finish_tx.send(reason_str.to_string()); + } + Err(e) => { + web_sys::console::error_1( + &format!("WASM: WebSocket connection failed: {}", e).into(), + ); + // Notify about the connection failure + let _ = finish_tx.send("connection_failed".to_string()); + } + } + }); + + web_sys::console::log_1(&"WASM: Connection task spawned, returning handle".into()); + + Ok(WasmWebSocketHandle { + close_sender: Some(close_tx), + finish_receiver: Some(finish_rx), + }) + } + } + + /// Connect to a WebSocket server and wait for completion (legacy API) + #[wasm_bindgen(js_name = "connectWebSocketAsync")] + pub fn connect_websocket_async(&self, url: &str) -> Promise { + let repo = self.inner.clone(); + let url = url.to_string(); + + future_to_promise(async move { + #[cfg(feature = "wasm")] + { + let result = repo + .connect_wasm_websocket(&url, ConnDirection::Outgoing) + .await; + match result { + ConnFinishedReason::Shutdown => Ok(JsValue::from_str("shutdown")), + ConnFinishedReason::TheyDisconnected => { + Ok(JsValue::from_str("they_disconnected")) + } + ConnFinishedReason::WeDisconnected => Ok(JsValue::from_str("we_disconnected")), + ConnFinishedReason::ErrorReceiving(err) => Err(JsValue::from(WasmError::from( + format!("Error receiving: {}", err), + ))), + ConnFinishedReason::ErrorSending(err) => Err(JsValue::from(WasmError::from( + format!("Error sending: {}", err), + ))), + ConnFinishedReason::Error(err) => Err(JsValue::from(WasmError::from(err))), + } + } + }) + } +} + +impl WasmRepo { + /// Restore documents from IndexedDB storage + /// This scans the storage for all document IDs and pre-loads them + async fn restore_documents_from_storage( + &self, + storage: IndexedDbStorage, + ) -> Result<(), String> { + use crate::storage::LocalStorage; + use samod_core::{DocumentId, StorageKey}; + use std::collections::HashSet; + use std::str::FromStr; + + tracing::info!("WASM: Starting document restoration from storage"); + + // Create a prefix to scan for all documents + // Documents are stored with keys like "{doc_id}/snapshot/{hash}" + // We want to find all unique document IDs + let empty_prefix = StorageKey::from_parts(Vec::::new()) + .map_err(|e| format!("Failed to create empty prefix: {}", e))?; + + // Load all keys that match our prefix (which is everything) + let all_keys = storage.load_range(empty_prefix).await; + + // Extract unique document IDs from the keys + let mut document_ids = HashSet::new(); + for (key, _) in all_keys { + // Get the first component of the key, which should be the document ID + let parts: Vec = key.into_iter().map(|key| key.to_string()).collect(); + if !parts.is_empty() { + if let Ok(doc_id) = DocumentId::from_str(&parts[0]) { + document_ids.insert(doc_id); + } + } + } + + tracing::info!("WASM: Found {} documents in storage", document_ids.len()); + + // Pre-load each document by calling find() + // This will create the document actors and load them into memory + for doc_id in document_ids { + tracing::debug!("WASM: Restoring document {}", doc_id); + match self.inner.find(doc_id.clone()).await { + Ok(Some(_)) => { + tracing::debug!("WASM: Successfully restored document {}", doc_id); + } + Ok(None) => { + tracing::warn!( + "WASM: Document {} not found despite being in storage", + doc_id + ); + } + Err(e) => { + tracing::error!("WASM: Failed to restore document {}: {:?}", doc_id, e); + } + } + } + + tracing::info!("WASM: Document restoration complete"); + Ok(()) + } +} + +/// Handle for managing WebSocket connections in WASM +#[wasm_bindgen] +pub struct WasmWebSocketHandle { + close_sender: Option>, + finish_receiver: Option>, +} + +#[wasm_bindgen] +impl WasmWebSocketHandle { + /// Close the WebSocket connection + #[wasm_bindgen] + pub fn close(&mut self) { + if let Some(sender) = self.close_sender.take() { + let _ = sender.send(()); + } + } + + /// Check if the connection has ended and get the reason + /// Returns a Promise that resolves with the disconnect reason or null if still connected + #[wasm_bindgen(js_name = "waitForDisconnect")] + pub fn wait_for_disconnect(&mut self) -> Promise { + if let Some(receiver) = self.finish_receiver.take() { + future_to_promise(async move { + match receiver.await { + Ok(reason) => Ok(JsValue::from_str(&reason)), + Err(_) => Ok(JsValue::NULL), + } + }) + } else { + future_to_promise(async { Ok(JsValue::NULL) }) + } + } +} + +/// WASM wrapper around a Samod document handle +#[wasm_bindgen] +pub struct WasmDocHandle { + inner: DocHandle, +} + +impl WasmDocHandle { + fn new(inner: DocHandle) -> Self { + Self { inner } + } +} + +#[wasm_bindgen] +impl WasmDocHandle { + /// Get the document ID as a string + #[wasm_bindgen(js_name = "documentId")] + pub fn document_id(&self) -> String { + self.inner.document_id().to_string() + } + + /// Get the document URL (compatible with automerge-repo) + #[wasm_bindgen] + pub fn url(&self) -> String { + self.inner.url().to_string() + } + + /// Get the current state of the document as a JavaScript object + #[wasm_bindgen(js_name = "getDocument")] + pub fn get_document(&self) -> Result { + let mut result = None; + self.inner + .with_document(|doc| { + // Convert the automerge document to a JS-friendly format + let js_doc = automerge_to_js_value(doc)?; + result = Some(js_doc); + Ok::<_, automerge::AutomergeError>(()) + }) + .map_err(|e| WasmError::from(format!("Failed to access document: {}", e)))?; + + Ok(result.unwrap_or(JsValue::NULL)) + } +} + +/// Helper function to convert a JavaScript value to Automerge operations +fn js_value_to_automerge + Clone>( + tx: &mut T, + obj: automerge::ObjId, + key: K, + js_value: &JsValue, +) -> Result<(), automerge::AutomergeError> { + use automerge::ScalarValue; + use js_sys::{Array, Object, Uint8Array}; + use wasm_bindgen::JsCast; + + if js_value.is_null() { + tx.put(obj, key, ScalarValue::Null)?; + } else if js_value.is_undefined() { + // Skip undefined values + return Ok(()); + } else if let Some(bool_val) = js_value.as_bool() { + tx.put(obj, key, bool_val)?; + } else if let Some(num_val) = js_value.as_f64() { + if num_val.fract() == 0.0 && num_val >= i64::MIN as f64 && num_val <= i64::MAX as f64 { + tx.put(obj, key, num_val as i64)?; + } else { + tx.put(obj, key, num_val)?; + } + } else if let Some(str_val) = js_value.as_string() { + tx.put(obj, key, str_val)?; + } else if js_value.is_instance_of::() { + // Handle Uint8Array (binary data) + let uint8_array = js_value.dyn_ref::().unwrap(); + let bytes = uint8_array.to_vec(); + tx.put(obj, key, ScalarValue::Bytes(bytes))?; + } else if Array::is_array(js_value) { + let array = Array::from(js_value); + let list_obj = tx.put_object(obj, key.clone(), automerge::ObjType::List)?; + + for i in 0..array.length() { + let item = array.get(i); + js_value_to_automerge(tx, list_obj.clone(), i as usize, &item)?; + } + } else if js_value.is_object() { + let obj_js = Object::from(js_value.clone()); + let map_obj = tx.put_object(obj, key.clone(), automerge::ObjType::Map)?; + + let keys = Object::keys(&obj_js); + for i in 0..keys.length() { + let key_js = keys.get(i); + if let Some(key_str) = key_js.as_string() { + let value = js_sys::Reflect::get(&obj_js, &key_js) + .map_err(|_| automerge::AutomergeError::InvalidOp(automerge::ObjType::Map))?; + js_value_to_automerge(tx, map_obj.clone(), &key_str, &value)?; + } else { + return Err(automerge::AutomergeError::InvalidOp( + automerge::ObjType::Map, + )); + } + } + } else { + // Handle unsupported JavaScript types + return Err(automerge::AutomergeError::InvalidOp( + automerge::ObjType::Map, + )); + } + + Ok(()) +} + +/// Helper function to recursively populate an entire document from a JavaScript object +fn populate_doc_from_js_value( + doc: &mut Automerge, + js_value: &JsValue, +) -> Result<(), automerge::AutomergeError> { + use js_sys::Object; + + if js_value.is_object() && !js_value.is_null() { + let obj_js = Object::from(js_value.clone()); + let keys = Object::keys(&obj_js); + + doc.transact(|tx| { + for i in 0..keys.length() { + let key_js = keys.get(i); + if let Some(key_str) = key_js.as_string() { + let value = js_sys::Reflect::get(&obj_js, &key_js).map_err(|_| { + automerge::AutomergeError::InvalidOp(automerge::ObjType::Map) + })?; + js_value_to_automerge(tx, automerge::ROOT, &key_str, &value)?; + } + } + Ok(()) + }) + .map_err( + |_: automerge::transaction::Failure| { + automerge::AutomergeError::InvalidOp(automerge::ObjType::Map) + }, + )?; + } + + Ok(()) +} + +/// Helper function to convert an Automerge document to a JavaScript value +fn automerge_to_js_value(doc: &Automerge) -> Result { + automerge_obj_to_js_value(doc, automerge::ROOT) +} + +/// Helper function to recursively convert an Automerge object to a JavaScript value +fn automerge_obj_to_js_value( + doc: &Automerge, + obj: automerge::ObjId, +) -> Result { + use automerge::{ObjType, ReadDoc}; + use js_sys::{Array, Object}; + + match doc.object_type(&obj)? { + ObjType::Map => { + let js_obj = Object::new(); + + // Get all properties from the map object + for item in doc.map_range(&obj, ..) { + let prop = item.key; + let value = item.value; + + let js_value = match &value { + automerge::Value::Object(_) => { + // For object values, we need to handle them using the object ID + // which is available in the item context + automerge_obj_to_js_value(doc, item.id)? + } + _ => automerge_value_to_js_value(doc, &value)?, + }; + + js_sys::Reflect::set(&js_obj, &JsValue::from_str(prop), &js_value) + .map_err(|_| automerge::AutomergeError::InvalidOp(ObjType::Map))?; + } + + Ok(js_obj.into()) + } + ObjType::List => { + let js_array = Array::new(); + let len = doc.length(&obj); + + // Get all items from the list object + for i in 0..len { + if let Ok(Some((value, _))) = doc.get(&obj, i) { + let js_value = automerge_value_to_js_value(doc, &value)?; + js_array.push(&js_value); + } + } + + Ok(js_array.into()) + } + ObjType::Text => { + // Convert text object to string + let text = doc.text(&obj)?; + Ok(JsValue::from_str(&text)) + } + ObjType::Table => { + // Tables are handled similar to maps + let js_obj = Object::new(); + + // Get all properties from the table object + for item in doc.map_range(&obj, ..) { + let prop = item.key; + let value = item.value; + + let js_value = match &value { + automerge::Value::Object(_) => automerge_obj_to_js_value(doc, item.id)?, + _ => automerge_value_to_js_value(doc, &value)?, + }; + + js_sys::Reflect::set(&js_obj, &JsValue::from_str(prop), &js_value) + .map_err(|_| automerge::AutomergeError::InvalidOp(ObjType::Table))?; + } + + Ok(js_obj.into()) + } + } +} + +/// Helper function to convert an Automerge value to a JavaScript value +fn automerge_value_to_js_value( + _doc: &Automerge, + value: &automerge::Value, +) -> Result { + use automerge::Value; + + match value { + Value::Scalar(scalar_value) => match scalar_value.as_ref() { + automerge::ScalarValue::Str(s) => Ok(JsValue::from_str(s)), + automerge::ScalarValue::Int(i) => { + // Convert i64 to f64 to avoid BigInt in JavaScript + Ok(JsValue::from(*i as f64)) + } + automerge::ScalarValue::Uint(u) => { + // Convert u64 to f64 to avoid BigInt in JavaScript + Ok(JsValue::from(*u as f64)) + } + automerge::ScalarValue::F64(f) => Ok(JsValue::from(*f)), + automerge::ScalarValue::Boolean(b) => Ok(JsValue::from(*b)), + automerge::ScalarValue::Bytes(bytes) => Ok(js_sys::Uint8Array::from(&bytes[..]).into()), + automerge::ScalarValue::Null => Ok(JsValue::NULL), + automerge::ScalarValue::Timestamp(t) => { + // Convert timestamp to f64 to avoid BigInt + Ok(JsValue::from(*t as f64)) + } + automerge::ScalarValue::Counter(c) => Ok(JsValue::from(format!("{}", c))), + _ => Ok(JsValue::UNDEFINED), + }, + Value::Object(obj_type) => Err(automerge::AutomergeError::InvalidOp(*obj_type)), + } +} diff --git a/samod/src/websocket.rs b/samod/src/websocket.rs index 37cb246..66fb9fa 100644 --- a/samod/src/websocket.rs +++ b/samod/src/websocket.rs @@ -159,12 +159,24 @@ impl Repo { url: &str, direction: ConnDirection, ) -> ConnFinishedReason { + tracing::info!("WASM: Attempting WebSocket connection to {}", url); + tracing::debug!("WASM: Connection direction: {:?}", direction); + match WasmWebSocket::connect(url).await { Ok(ws) => { + tracing::info!( + "WASM: WebSocket connection established, starting protocol handshake" + ); let (sink, stream) = ws.split(); - self.connect(stream, sink, direction).await + let result = self.connect(stream, sink, direction).await; + tracing::info!("WASM: Connection finished with reason: {:?}", result); + result + } + Err(e) => { + let error_msg = format!("Failed to connect WebSocket: {}", e); + tracing::error!("WASM: {}", error_msg); + ConnFinishedReason::Error(error_msg) } - Err(e) => ConnFinishedReason::Error(format!("Failed to connect WebSocket: {}", e)), } } diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 0000000..1cda64e --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +# Ensure wasm-pack is installed +if ! command -v wasm-pack &>/dev/null; then + echo "Error: wasm-pack is not installed" + echo "Please install wasm-pack: cargo install wasm-pack" + exit 1 +fi + +# Build directory setup +WASM_OUT_DIR="wasm-tests/pkg" +mkdir -p "$WASM_OUT_DIR" + +echo "Building samod WASM package..." + +# Build the WASM package with the wasm feature enabled +cd samod +wasm-pack build \ + --target web \ + --out-dir "../$WASM_OUT_DIR" \ + --features wasm \ + --no-default-features + +cd .. + +echo "WASM build complete! Output in $WASM_OUT_DIR" + +# Generate TypeScript types if requested +if [[ "$3" == "--typescript" ]]; then + echo "Generating TypeScript definitions..." + # The TypeScript definitions are automatically generated by wasm-pack + echo "TypeScript definitions generated in $WASM_OUT_DIR" +fi + diff --git a/scripts/test-wasm.sh b/scripts/test-wasm.sh new file mode 100755 index 0000000..f29cf93 --- /dev/null +++ b/scripts/test-wasm.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Script to run WASM tests for samod +# Builds the WASM package and runs Playwright tests + +set -e + +echo "Running samod WASM tests..." + +# Navigate to wasm-tests directory +cd "$(dirname "$0")/../wasm-tests" + +# Install dependencies if needed +if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + npm install +fi + +# Build WASM package +echo "Building WASM package..." +npm run build-wasm + +# Start the test server in WASM mode +echo "Starting test server in WASM mode..." +npm run server:stop +npm run server:start + +# Wait for server to start +sleep 2 + +# Run Playwright tests +echo "Running Playwright tests..." +npm test + +# Stop the server +echo "Stopping test server..." +npm run server:stop + +echo "✅ WASM tests completed!" + diff --git a/wasm-tests/.gitignore b/wasm-tests/.gitignore new file mode 100644 index 0000000..c8d90d6 --- /dev/null +++ b/wasm-tests/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +pkg/ +test-results/ +playwright-report/ +playwright/.cache/ +.vite/ \ No newline at end of file diff --git a/wasm-tests/.prettierrc.json b/wasm-tests/.prettierrc.json new file mode 100644 index 0000000..7e91fdc --- /dev/null +++ b/wasm-tests/.prettierrc.json @@ -0,0 +1,27 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf", + "overrides": [ + { + "files": "*.md", + "options": { + "printWidth": 100, + "proseWrap": "always" + } + }, + { + "files": "*.json", + "options": { + "printWidth": 120 + } + } + ] +} + diff --git a/wasm-tests/package-lock.json b/wasm-tests/package-lock.json new file mode 100644 index 0000000..817dfaf --- /dev/null +++ b/wasm-tests/package-lock.json @@ -0,0 +1,1337 @@ +{ + "name": "samod-wasm-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "samod-wasm-tests", + "version": "1.0.0", + "dependencies": { + "@automerge/automerge": "^2.2.9", + "@automerge/automerge-repo": "^2.0.7", + "@automerge/automerge-repo-network-websocket": "^2.0.7", + "@automerge/automerge-repo-storage-indexeddb": "^2.0.7" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vite": "^5.4.0" + } + }, + "node_modules/@automerge/automerge": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-2.2.9.tgz", + "integrity": "sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA==", + "license": "MIT", + "dependencies": { + "uuid": "^9.0.0" + } + }, + "node_modules/@automerge/automerge-repo": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.3.0.tgz", + "integrity": "sha512-nZppJl90bK0zVGtByHDvXZ1gC8HCcKnhGkJxMLrPxuFlLdoAfq7x10Tk6AT0eMfx1048kl5zRDH7DpAZD+SvMw==", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "2.2.8 - 3", + "bs58check": "^3.0.1", + "cbor-x": "^1.3.0", + "debug": "^4.3.4", + "eventemitter3": "^5.0.1", + "fast-sha256": "^1.3.0", + "uuid": "^9.0.0", + "xstate": "^5.9.1" + } + }, + "node_modules/@automerge/automerge-repo-network-websocket": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo-network-websocket/-/automerge-repo-network-websocket-2.3.0.tgz", + "integrity": "sha512-1NddZXFu9Ry2Pfy7p/wbvG8TcsPvv87NPhD7+aiLb8miWrk1TjHYEUbwRQPMUN2lKAcDtnbpMZbJxbwgItxFWA==", + "license": "MIT", + "dependencies": { + "@automerge/automerge-repo": "2.3.0", + "cbor-x": "^1.3.0", + "debug": "^4.3.4", + "eventemitter3": "^5.0.1", + "isomorphic-ws": "^5.0.0", + "ws": "^8.7.0" + } + }, + "node_modules/@automerge/automerge-repo-storage-indexeddb": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo-storage-indexeddb/-/automerge-repo-storage-indexeddb-2.3.0.tgz", + "integrity": "sha512-0amBVYn9Y/73fUVPvSUQWlg6kv6BKCEPL2C4omA1Z9L7N1DHUXCKje6WuuoF9eIKoWHA/n1QFMRj88MGILXcRQ==", + "license": "MIT", + "dependencies": { + "@automerge/automerge-repo": "2.3.0" + } + }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", + "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz", + "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz", + "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz", + "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz", + "integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz", + "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", + "integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", + "integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", + "integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", + "integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", + "integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", + "integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", + "integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", + "integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", + "integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", + "integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", + "integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", + "integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", + "integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", + "integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", + "integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", + "integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", + "integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", + "integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", + "integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", + "integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", + "integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.18.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.1.tgz", + "integrity": "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT" + }, + "node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "license": "MIT", + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, + "node_modules/cbor-extract": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz", + "integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.0", + "@cbor-extract/cbor-extract-linux-x64": "2.2.0", + "@cbor-extract/cbor-extract-win32-x64": "2.2.0" + } + }, + "node_modules/cbor-x": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz", + "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.0" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.50.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", + "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.50.1", + "@rollup/rollup-android-arm64": "4.50.1", + "@rollup/rollup-darwin-arm64": "4.50.1", + "@rollup/rollup-darwin-x64": "4.50.1", + "@rollup/rollup-freebsd-arm64": "4.50.1", + "@rollup/rollup-freebsd-x64": "4.50.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.1", + "@rollup/rollup-linux-arm-musleabihf": "4.50.1", + "@rollup/rollup-linux-arm64-gnu": "4.50.1", + "@rollup/rollup-linux-arm64-musl": "4.50.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.50.1", + "@rollup/rollup-linux-ppc64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-gnu": "4.50.1", + "@rollup/rollup-linux-riscv64-musl": "4.50.1", + "@rollup/rollup-linux-s390x-gnu": "4.50.1", + "@rollup/rollup-linux-x64-gnu": "4.50.1", + "@rollup/rollup-linux-x64-musl": "4.50.1", + "@rollup/rollup-openharmony-arm64": "4.50.1", + "@rollup/rollup-win32-arm64-msvc": "4.50.1", + "@rollup/rollup-win32-ia32-msvc": "4.50.1", + "@rollup/rollup-win32-x64-msvc": "4.50.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xstate": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.21.0.tgz", + "integrity": "sha512-y4wmqxjyAa0tgz4k3m/MgTF1kDOahE5+xLfWt5eh1sk+43DatLhKlI8lQDJZpvihZavjbD3TUgy2PRMphhhqgQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + } + } +} diff --git a/wasm-tests/package.json b/wasm-tests/package.json new file mode 100644 index 0000000..33720e2 --- /dev/null +++ b/wasm-tests/package.json @@ -0,0 +1,34 @@ +{ + "name": "samod-wasm-tests", + "version": "1.0.0", + "description": "Browser tests for samod WASM build", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "build-wasm": "cd .. && ./scripts/build-wasm.sh --features wasm --typescript", + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:debug": "playwright test --debug", + "test:headed": "playwright test --headed", + "test:all": "npm run build-wasm && npm run test", + "serve": "vite", + "preview": "vite preview", + "server": "node ../samod/interop-test-server/server.js --wasm", + "server:start": "node ../samod/interop-test-server/server.js --wasm &", + "server:stop": "pkill -f 'interop-test-server/server.js --wasm' || true", + "test:with-server": "npm run server:stop && npm run server:start && sleep 2 && npm run test; npm run server:stop" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vite": "^5.4.0" + }, + "dependencies": { + "@automerge/automerge": "^2.2.9", + "@automerge/automerge-repo": "^2.0.7", + "@automerge/automerge-repo-network-websocket": "^2.0.7", + "@automerge/automerge-repo-storage-indexeddb": "^2.0.7" + } +} diff --git a/wasm-tests/playwright.config.ts b/wasm-tests/playwright.config.ts new file mode 100644 index 0000000..d1bd351 --- /dev/null +++ b/wasm-tests/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + webServer: { + command: 'npm run serve', + port: 3000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/wasm-tests/pnpm-lock.yaml b/wasm-tests/pnpm-lock.yaml new file mode 100644 index 0000000..3770a27 --- /dev/null +++ b/wasm-tests/pnpm-lock.yaml @@ -0,0 +1,835 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@automerge/automerge': + specifier: ^2.2.9 + version: 2.2.9 + '@automerge/automerge-repo': + specifier: ^2.0.7 + version: 2.3.0 + '@automerge/automerge-repo-network-websocket': + specifier: ^2.0.7 + version: 2.3.0 + '@automerge/automerge-repo-storage-indexeddb': + specifier: ^2.0.7 + version: 2.3.0 + devDependencies: + '@playwright/test': + specifier: ^1.48.0 + version: 1.55.0 + '@types/node': + specifier: ^22.0.0 + version: 22.18.1 + typescript: + specifier: ^5.7.0 + version: 5.9.2 + vite: + specifier: ^5.4.0 + version: 5.4.20(@types/node@22.18.1) + +packages: + + '@automerge/automerge-repo-network-websocket@2.3.0': + resolution: {integrity: sha512-1NddZXFu9Ry2Pfy7p/wbvG8TcsPvv87NPhD7+aiLb8miWrk1TjHYEUbwRQPMUN2lKAcDtnbpMZbJxbwgItxFWA==} + + '@automerge/automerge-repo-storage-indexeddb@2.3.0': + resolution: {integrity: sha512-0amBVYn9Y/73fUVPvSUQWlg6kv6BKCEPL2C4omA1Z9L7N1DHUXCKje6WuuoF9eIKoWHA/n1QFMRj88MGILXcRQ==} + + '@automerge/automerge-repo@2.3.0': + resolution: {integrity: sha512-nZppJl90bK0zVGtByHDvXZ1gC8HCcKnhGkJxMLrPxuFlLdoAfq7x10Tk6AT0eMfx1048kl5zRDH7DpAZD+SvMw==} + + '@automerge/automerge@2.2.9': + resolution: {integrity: sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA==} + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + resolution: {integrity: sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + resolution: {integrity: sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + resolution: {integrity: sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + resolution: {integrity: sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + resolution: {integrity: sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + resolution: {integrity: sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==} + cpu: [x64] + os: [win32] + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@playwright/test@1.55.0': + resolution: {integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==} + engines: {node: '>=18'} + hasBin: true + + '@rollup/rollup-android-arm-eabi@4.50.1': + resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.50.1': + resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.50.1': + resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.50.1': + resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.50.1': + resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.50.1': + resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.50.1': + resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.50.1': + resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.50.1': + resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.50.1': + resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.50.1': + resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.50.1': + resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.50.1': + resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.50.1': + resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.50.1': + resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.18.1': + resolution: {integrity: sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==} + + base-x@4.0.1: + resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} + + bs58@5.0.0: + resolution: {integrity: sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==} + + bs58check@3.0.1: + resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} + + cbor-extract@2.2.0: + resolution: {integrity: sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==} + hasBin: true + + cbor-x@1.6.0: + resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + playwright-core@1.55.0: + resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.0: + resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.50.1: + resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + vite@5.4.20: + resolution: {integrity: sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xstate@5.21.0: + resolution: {integrity: sha512-y4wmqxjyAa0tgz4k3m/MgTF1kDOahE5+xLfWt5eh1sk+43DatLhKlI8lQDJZpvihZavjbD3TUgy2PRMphhhqgQ==} + +snapshots: + + '@automerge/automerge-repo-network-websocket@2.3.0': + dependencies: + '@automerge/automerge-repo': 2.3.0 + cbor-x: 1.6.0 + debug: 4.4.1 + eventemitter3: 5.0.1 + isomorphic-ws: 5.0.0(ws@8.18.3) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@automerge/automerge-repo-storage-indexeddb@2.3.0': + dependencies: + '@automerge/automerge-repo': 2.3.0 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge-repo@2.3.0': + dependencies: + '@automerge/automerge': 2.2.9 + bs58check: 3.0.1 + cbor-x: 1.6.0 + debug: 4.4.1 + eventemitter3: 5.0.1 + fast-sha256: 1.3.0 + uuid: 9.0.1 + xstate: 5.21.0 + transitivePeerDependencies: + - supports-color + + '@automerge/automerge@2.2.9': + dependencies: + uuid: 9.0.1 + + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@noble/hashes@1.8.0': {} + + '@playwright/test@1.55.0': + dependencies: + playwright: 1.55.0 + + '@rollup/rollup-android-arm-eabi@4.50.1': + optional: true + + '@rollup/rollup-android-arm64@4.50.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.50.1': + optional: true + + '@rollup/rollup-darwin-x64@4.50.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.50.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.50.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.50.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.50.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.50.1': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.50.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.50.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.50.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.50.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.50.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.50.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.1': + optional: true + + '@types/estree@1.0.8': {} + + '@types/node@22.18.1': + dependencies: + undici-types: 6.21.0 + + base-x@4.0.1: {} + + bs58@5.0.0: + dependencies: + base-x: 4.0.1 + + bs58check@3.0.1: + dependencies: + '@noble/hashes': 1.8.0 + bs58: 5.0.0 + + cbor-extract@2.2.0: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.0 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.0 + '@cbor-extract/cbor-extract-linux-x64': 2.2.0 + '@cbor-extract/cbor-extract-win32-x64': 2.2.0 + optional: true + + cbor-x@1.6.0: + optionalDependencies: + cbor-extract: 2.2.0 + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + detect-libc@2.0.4: + optional: true + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + eventemitter3@5.0.1: {} + + fast-sha256@1.3.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + isomorphic-ws@5.0.0(ws@8.18.3): + dependencies: + ws: 8.18.3 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.0.4 + optional: true + + picocolors@1.1.1: {} + + playwright-core@1.55.0: {} + + playwright@1.55.0: + dependencies: + playwright-core: 1.55.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.50.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.50.1 + '@rollup/rollup-android-arm64': 4.50.1 + '@rollup/rollup-darwin-arm64': 4.50.1 + '@rollup/rollup-darwin-x64': 4.50.1 + '@rollup/rollup-freebsd-arm64': 4.50.1 + '@rollup/rollup-freebsd-x64': 4.50.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 + '@rollup/rollup-linux-arm-musleabihf': 4.50.1 + '@rollup/rollup-linux-arm64-gnu': 4.50.1 + '@rollup/rollup-linux-arm64-musl': 4.50.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 + '@rollup/rollup-linux-ppc64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-gnu': 4.50.1 + '@rollup/rollup-linux-riscv64-musl': 4.50.1 + '@rollup/rollup-linux-s390x-gnu': 4.50.1 + '@rollup/rollup-linux-x64-gnu': 4.50.1 + '@rollup/rollup-linux-x64-musl': 4.50.1 + '@rollup/rollup-openharmony-arm64': 4.50.1 + '@rollup/rollup-win32-arm64-msvc': 4.50.1 + '@rollup/rollup-win32-ia32-msvc': 4.50.1 + '@rollup/rollup-win32-x64-msvc': 4.50.1 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + typescript@5.9.2: {} + + undici-types@6.21.0: {} + + uuid@9.0.1: {} + + vite@5.4.20(@types/node@22.18.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.50.1 + optionalDependencies: + '@types/node': 22.18.1 + fsevents: 2.3.3 + + ws@8.18.3: {} + + xstate@5.21.0: {} diff --git a/wasm-tests/src/app.ts b/wasm-tests/src/app.ts new file mode 100644 index 0000000..ccd5bed --- /dev/null +++ b/wasm-tests/src/app.ts @@ -0,0 +1,480 @@ +import init, { + WasmRepo, + WasmDocHandle, + WasmWebSocketHandle, +} from '../pkg/samod.js'; + +interface AppState { + repo?: WasmRepo; + documents: Map; + isConnected: boolean; + messageCount: number; + connectionHandle?: WasmWebSocketHandle; + reconnectAttempts: number; + reconnectTimeoutId?: number; + autoReconnect: boolean; +} + +class SamodTestApp { + private state: AppState = { + documents: new Map(), + isConnected: false, + messageCount: 0, + reconnectAttempts: 0, + autoReconnect: true, + }; + + private logElement: HTMLElement; + private statusElement: HTMLElement; + private documentListElement: HTMLElement; + + constructor() { + this.logElement = document.getElementById('log')!; + this.statusElement = document.getElementById('status')!; + this.documentListElement = document.getElementById('documentList')!; + + this.initializeApp(); + } + + private async initializeApp() { + try { + this.log('Initializing WASM module...', 'info'); + await init(); + + this.log('WASM module loaded successfully', 'success'); + this.updateStatus('WASM Ready - Not Connected', 'error'); + + // Create samod repository with IndexedDB storage + this.state.repo = await new WasmRepo(); + this.log('Samod repository created with IndexedDB storage', 'success'); + + // Discover any documents that were persisted in IndexedDB + await this.discoverDocuments(); + + // Set up event handlers + this.setupEventHandlers(); + + // Enable buttons + this.enableControls(true); + + // Expose repository and app for testing + (window as any).testRepo = this.state.repo; + (window as any).testApp = this; + } catch (error) { + this.log(`Failed to initialize: ${error}`, 'error'); + this.updateStatus('Initialization Failed', 'error'); + } + } + + private setupEventHandlers() { + // Connection buttons + document + .getElementById('connectBtn')! + .addEventListener('click', () => this.connect()); + document + .getElementById('disconnectBtn')! + .addEventListener('click', () => this.disconnect()); + + // Document operations + document + .getElementById('createDocBtn')! + .addEventListener('click', () => this.createDocument()); + document + .getElementById('syncBtn')! + .addEventListener('click', () => this.syncDocuments()); + } + + private async connect() { + try { + this.log('Connecting to WebSocket server...', 'info'); + this.updateStatus('Connecting...', 'connecting'); + + if (!this.state.repo) { + throw new Error('Repository not initialized'); + } + + // Clear any pending reconnection timeout + if (this.state.reconnectTimeoutId) { + clearTimeout(this.state.reconnectTimeoutId); + this.state.reconnectTimeoutId = undefined; + } + + // Get WebSocket port from URL params or use default + const urlParams = new URLSearchParams(window.location.search); + const wsPort = urlParams.get('wsPort') || '3001'; + const wsUrl = `ws://localhost:${wsPort}`; + this.log( + `Connecting to ${wsUrl} (attempt ${this.state.reconnectAttempts + 1})`, + 'info' + ); + + // Create the WebSocket connection handle + const connectionHandle = this.state.repo.connectWebSocket(wsUrl); + this.state.connectionHandle = connectionHandle; + + // Connection successful - reset reconnect attempts + this.state.reconnectAttempts = 0; + this.state.isConnected = true; + this.updateStatus('Connected', 'connected'); + this.log('WebSocket connection established', 'success'); + + // Monitor for disconnection + this.monitorConnection(connectionHandle); + + // Update button states + document.getElementById('connectBtn')!.setAttribute('disabled', 'true'); + document.getElementById('disconnectBtn')!.removeAttribute('disabled'); + } catch (error) { + this.log(`Connection failed: ${error}`, 'error'); + this.updateStatus('Connection Failed', 'error'); + + // Trigger reconnection with exponential backoff if auto-reconnect is enabled + if (this.state.autoReconnect) { + this.scheduleReconnect(); + } + } + } + + private scheduleReconnect() { + this.state.reconnectAttempts++; + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s + const baseDelay = 1000; + const maxDelay = 30000; + const delay = Math.min( + baseDelay * Math.pow(2, this.state.reconnectAttempts - 1), + maxDelay + ); + + this.log( + `Scheduling reconnection in ${delay / 1000}s (attempt ${this.state.reconnectAttempts})`, + 'info' + ); + this.updateStatus( + `Reconnecting in ${Math.ceil(delay / 1000)}s...`, + 'connecting' + ); + + this.state.reconnectTimeoutId = window.setTimeout(() => { + if (this.state.autoReconnect && !this.state.isConnected) { + this.connect(); + } + }, delay); + } + + private enableAutoReconnect(enable: boolean) { + this.state.autoReconnect = enable; + this.log(`Auto-reconnect ${enable ? 'enabled' : 'disabled'}`, 'info'); + + if (!enable && this.state.reconnectTimeoutId) { + clearTimeout(this.state.reconnectTimeoutId); + this.state.reconnectTimeoutId = undefined; + } + } + + private async disconnect() { + try { + this.log('Disconnecting from WebSocket server...', 'info'); + + // Disable auto-reconnect for manual disconnection + this.enableAutoReconnect(false); + + // Close the WebSocket connection using the handle + if (this.state.connectionHandle) { + this.state.connectionHandle.close(); + this.state.connectionHandle = undefined; + } + + this.state.isConnected = false; + this.state.reconnectAttempts = 0; + this.updateStatus('Disconnected', 'error'); + this.log('Disconnected from WebSocket server', 'info'); + + // Update button states + document.getElementById('connectBtn')!.removeAttribute('disabled'); + document + .getElementById('disconnectBtn')! + .setAttribute('disabled', 'true'); + } catch (error) { + this.log(`Disconnect failed: ${error}`, 'error'); + } + } + + private async createDocument() { + try { + const titleInput = document.getElementById( + 'docTitle' + ) as HTMLInputElement; + const contentInput = document.getElementById( + 'docContent' + ) as HTMLTextAreaElement; + + const title = + titleInput.value || `Document ${this.state.documents.size + 1}`; + const content = contentInput.value || 'Empty document'; + + this.log(`Creating document: ${title}`, 'info'); + + if (!this.state.repo) { + throw new Error('Repository not initialized'); + } + + // Create new document with initial content + const initialContent = { + title: title, + content: content, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const docHandle = await this.state.repo.createDocument(initialContent); + const docId = docHandle.documentId(); + + // Store document reference + this.state.documents.set(docId, docHandle); + + this.log(`Document created: ${docId}`, 'success'); + this.updateDocumentList(); + this.updateMetrics(); + + // Clear inputs + titleInput.value = ''; + contentInput.value = ''; + } catch (error) { + this.log(`Failed to create document: ${error}`, 'error'); + } + } + + private async syncDocuments() { + try { + this.log('Syncing documents...', 'info'); + const startTime = performance.now(); + + if (!this.state.repo) { + throw new Error('Repository not initialized'); + } + + // Discover any new documents from IndexedDB or network sync + await this.discoverDocuments(); + + // Verify all known documents are accessible + let accessibleDocs = 0; + for (const [docId, docHandle] of this.state.documents) { + try { + const doc = docHandle.getDocument(); + if (doc) { + accessibleDocs++; + } + } catch (e) { + this.log(`Document ${docId} not accessible`, 'error'); + } + } + + const syncTime = Math.round(performance.now() - startTime); + this.log( + `Sync completed in ${syncTime}ms - ${accessibleDocs} documents accessible`, + 'success' + ); + + // Always update sync time metrics + const syncTimeElement = document.getElementById('syncTime'); + if (syncTimeElement) { + syncTimeElement.textContent = syncTime.toString(); + } + } catch (error) { + this.log(`Sync failed: ${error}`, 'error'); + } + } + + private updateDocumentList() { + this.documentListElement.innerHTML = ''; + + this.state.documents.forEach((docHandle, id) => { + try { + const doc = docHandle.getDocument(); + const title = doc?.title || 'Untitled Document'; + + const item = document.createElement('li'); + item.className = 'document-item'; + item.innerHTML = ` + ${title} +
+ ID: ${id.substring(0, 8)}... + `; + item.addEventListener('click', () => this.selectDocument(id)); + this.documentListElement.appendChild(item); + } catch (error) { + this.log(`Error displaying document ${id}: ${error}`, 'error'); + } + }); + } + + private selectDocument(docId: string) { + // Update UI to show selected document + const items = this.documentListElement.querySelectorAll('.document-item'); + items.forEach(item => item.classList.remove('active')); + + const selectedIndex = Array.from(this.state.documents.keys()).indexOf( + docId + ); + if (selectedIndex >= 0) { + items[selectedIndex].classList.add('active'); + } + + this.log(`Selected document: ${docId}`, 'info'); + } + + private updateMetrics() { + document.getElementById('docCount')!.textContent = + this.state.documents.size.toString(); + document.getElementById('messageCount')!.textContent = + this.state.messageCount.toString(); + + // Estimate storage size (rough calculation) + const storageSize = this.state.documents.size * 2; // Assume ~2KB per doc + document.getElementById('storageSize')!.textContent = `${storageSize} KB`; + } + + private updateStatus( + message: string, + className: 'connecting' | 'connected' | 'error' + ) { + this.statusElement.textContent = message; + this.statusElement.className = `status ${className}`; + } + + private log(message: string, type: 'info' | 'success' | 'error' = 'info') { + const entry = document.createElement('div'); + entry.className = `log-entry ${type}`; + entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; + this.logElement.appendChild(entry); + this.logElement.scrollTop = this.logElement.scrollHeight; + } + + private enableControls(enabled: boolean) { + const buttons = ['connectBtn', 'createDocBtn', 'syncBtn']; + buttons.forEach(id => { + const btn = document.getElementById(id); + if (btn) { + if (enabled) { + btn.removeAttribute('disabled'); + } else { + btn.setAttribute('disabled', 'true'); + } + } + }); + } + + private async discoverDocuments() { + try { + if (!this.state.repo) { + return; + } + + this.log('Discovering available documents...', 'info'); + + const documentIds = this.state.repo.listDocuments(); + this.log(`Found ${documentIds.length} document(s) in repository`, 'info'); + + let newDocuments = 0; + for (let i = 0; i < documentIds.length; i++) { + const docId = documentIds[i]; + if (!this.state.documents.has(docId)) { + try { + const docHandle = await this.state.repo.findDocument(docId); + if (docHandle) { + this.state.documents.set(docId, docHandle); + newDocuments++; + this.log(`Loaded document: ${docId}`, 'success'); + } + } catch (error) { + this.log(`Failed to load document ${docId}: ${error}`, 'error'); + } + } + } + + if (newDocuments > 0) { + this.log(`Discovered ${newDocuments} new document(s)`, 'success'); + this.updateDocumentList(); + this.updateMetrics(); + } else { + this.log('No new documents found', 'info'); + } + } catch (error) { + this.log(`Document discovery failed: ${error}`, 'error'); + } + } + + private async monitorConnection(connectionHandle: WasmWebSocketHandle) { + try { + // Wait for the connection to end + const disconnectReason = await connectionHandle.waitForDisconnect(); + + if (disconnectReason) { + this.log(`Connection ended: ${disconnectReason}`, 'info'); + + // Update UI state if this wasn't a client-initiated disconnect + if (disconnectReason !== 'we_disconnected' && this.state.isConnected) { + this.state.isConnected = false; + this.updateStatus('Disconnected', 'error'); + this.log('Detected server-side disconnection', 'error'); + + // Update button states + document.getElementById('connectBtn')!.removeAttribute('disabled'); + document + .getElementById('disconnectBtn')! + .setAttribute('disabled', 'true'); + + // Clear connection handle + this.state.connectionHandle = undefined; + + // Re-enable auto-reconnect and schedule reconnection for unexpected disconnections + if (disconnectReason !== 'we_disconnected') { + this.enableAutoReconnect(true); + this.scheduleReconnect(); + } + } + } + } catch (error) { + this.log(`Connection monitoring error: ${error}`, 'error'); + // On monitoring error, also try to reconnect + if (this.state.isConnected && this.state.autoReconnect) { + this.state.isConnected = false; + this.updateStatus('Connection Error', 'error'); + this.scheduleReconnect(); + } + } + } + + // Public methods for testing + public getConnectionState() { + return { + isConnected: this.state.isConnected, + reconnectAttempts: this.state.reconnectAttempts, + autoReconnect: this.state.autoReconnect, + hasReconnectTimeout: !!this.state.reconnectTimeoutId, + }; + } + + public setAutoReconnect(enable: boolean) { + this.enableAutoReconnect(enable); + } + + public forceReconnect() { + if (!this.state.isConnected) { + this.state.reconnectAttempts = 0; + this.connect(); + } + } + + public simulateDisconnection() { + if (this.state.connectionHandle) { + this.state.connectionHandle.close(); + } + } +} + +// Initialize app when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + new SamodTestApp(); +}); diff --git a/wasm-tests/src/index.html b/wasm-tests/src/index.html new file mode 100644 index 0000000..d222b8d --- /dev/null +++ b/wasm-tests/src/index.html @@ -0,0 +1,211 @@ + + + + + + Samod WASM Test Application + + + +

Samod WASM Test Application

+ +
+

Connection Status

+
+ Initializing WASM module... +
+ + +
+ +
+

Document Operations

+ + + + + +

Documents

+
    +
    + +
    +

    Performance Metrics

    +
    +
    +
    0
    +
    Documents
    +
    +
    +
    -
    +
    Last Sync (ms)
    +
    +
    +
    0 KB
    +
    Storage Used
    +
    +
    +
    0
    +
    Messages Sent
    +
    +
    +
    + +
    +

    Activity Log

    +
    +
    + + + + diff --git a/wasm-tests/tests/samod-wasm.spec.ts b/wasm-tests/tests/samod-wasm.spec.ts new file mode 100644 index 0000000..ecc2da8 --- /dev/null +++ b/wasm-tests/tests/samod-wasm.spec.ts @@ -0,0 +1,576 @@ +import { test, expect, Page } from '@playwright/test'; +import { TestServer, createTestServer } from './server-helper'; + +// Helper functions for direct repo API access +async function createDocumentViaAPI(page: Page, content: any): Promise { + return await page.evaluate(async (docContent) => { + const repo = (window as any).testRepo; + if (!repo) throw new Error('Repository not available'); + const handle = await repo.createDocument(docContent); + return handle.documentId(); + }, content); +} + +async function getDocumentViaAPI(page: Page, docId: string): Promise { + return await page.evaluate(async (id) => { + const repo = (window as any).testRepo; + if (!repo) throw new Error('Repository not available'); + const handle = await repo.findDocument(id); + if (!handle) return null; + return handle.getDocument(); + }, docId); +} + +async function documentExistsViaAPI(page: Page, docId: string): Promise { + return await page.evaluate(async (id) => { + const repo = (window as any).testRepo; + if (!repo) return false; + try { + const handle = await repo.findDocument(id); + return handle !== null && handle !== undefined; + } catch (e) { + return false; + } + }, docId); +} + +test.describe('Samod WASM Integration Tests', () => { + let page: Page; + let server: TestServer; + + test.beforeEach(async ({ page: testPage }) => { + page = testPage; + + server = await createTestServer(); + const port = server.getPort(); + + // Navigate to the page with the server port + await page.goto(`/?wsPort=${port}`); + + // Wait for WASM initialization + await page.waitForSelector('.status:not(.connecting)', { timeout: 10000 }); + }); + + test.afterEach(async () => { + if (server) await server.stop(); + }); + + test('WASM module initializes successfully', async () => { + const status = await page.locator('#status').textContent(); + expect(status).toContain('WASM Ready'); + + const connectBtn = await page.locator('#connectBtn'); + expect(await connectBtn.isEnabled()).toBe(true); + + const logEntries = await page.locator('.log-entry.success').count(); + expect(logEntries).toBeGreaterThan(0); + }); + + test('can connect to WebSocket server', async () => { + await page.click('#connectBtn'); + + await page.waitForSelector('.status.connected', { timeout: 5000 }); + + const status = await page.locator('#status').textContent(); + expect(status).toBe('Connected'); + + const disconnectBtn = await page.locator('#disconnectBtn'); + expect(await disconnectBtn.isEnabled()).toBe(true); + }); + + test('can create and list documents', async () => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + await page.fill('#docTitle', 'Test Document'); + await page.fill('#docContent', 'This is test content'); + await page.click('#createDocBtn'); + + // Wait for document to be created + await page.waitForSelector('.document-item', { timeout: 5000 }); + + // Verify document appears in list + const docItem = await page.locator('.document-item').first(); + expect(await docItem.textContent()).toContain('Test Document'); + + // Verify metrics update + const docCount = await page.locator('#docCount').textContent(); + expect(docCount).toBe('1'); + }); + + test('can sync documents between clients', async ({ browser }) => { + // Client A: Connect and create document + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Client B: Open new browser context + const context = await browser.newContext(); + const pageB = await context.newPage(); + const port = server.getPort(); + await pageB.goto(`/?wsPort=${port}`); + await pageB.waitForSelector('.status:not(.connecting)'); + await pageB.click('#connectBtn'); + await pageB.waitForSelector('.status.connected'); + + // Create document in Client A + await page.fill('#docTitle', 'Cross-Client Sync Test'); + await page.fill('#docContent', 'This document should sync between clients'); + await page.click('#createDocBtn'); + await page.waitForSelector('.document-item'); + + // Verify document was created in Client A + const docItemA = await page.locator('.document-item').first(); + expect(await docItemA.textContent()).toContain('Cross-Client Sync Test'); + + // Client B: Poll sync button until document appears + const maxAttempts = 20; // 10 seconds total with 500ms intervals + let found = false; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + await pageB.click('#syncBtn'); + await pageB.waitForTimeout(500); + + const targetDocCount = await pageB + .locator('.document-item') + .filter({ hasText: 'Cross-Client Sync Test' }) + .count(); + + if (targetDocCount > 0) { + found = true; + break; + } + } + + expect(found).toBe(true); + + // Verify the document is visible + const docItems = await pageB + .locator('.document-item') + .filter({ hasText: 'Cross-Client Sync Test' }) + .first(); + await expect(docItems).toBeVisible(); + + // Verify both clients show the same document count + await page.click('#syncBtn'); + await pageB.click('#syncBtn'); + + const countA = await page.locator('#docCount').textContent(); + const countB = await pageB.locator('#docCount').textContent(); + expect(countA).toBe(countB); + + await context.close(); + }); + + test('handles disconnect and reconnect', async () => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Test client-side disconnection + await page.click('#disconnectBtn'); + await page.waitForSelector('.status.error'); + + let status = await page.locator('#status').textContent(); + expect(status).toBe('Disconnected'); + + // Reconnect + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + expect(await page.locator('#status').textContent()).toBe('Connected'); + }); + + test('handles server-side disconnection', async () => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Trigger server-side disconnection + const response = await page.request.post( + `http://localhost:${server.getPort()}/test/disconnect-all` + ); + expect(response.ok()).toBe(true); + + const result = await response.json(); + expect(result.disconnected).toBeGreaterThan(0); + + // Wait for client to detect the disconnection + await page.waitForSelector('.error', { timeout: 5000 }); + + const status = await page.locator('.error').allTextContents(); + expect(status.join(' ')).toContain('Detected server-side disconnection'); + + // Test reconnection after server-side disconnect + await page.waitForSelector('.status.connected'); + expect(await page.locator('#status').textContent()).toBe('Connected'); + }); + + test('handles errors gracefully', async () => { + // Try to create document without required fields + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Clear inputs and try to create + await page.fill('#docTitle', ''); + await page.fill('#docContent', ''); + await page.click('#createDocBtn'); + + // Should create with default values + await page.waitForSelector('.document-item'); + const docText = await page.locator('.document-item').textContent(); + expect(docText).toContain('Document 1'); + }); + + test('metrics update correctly', async () => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Create multiple documents + for (let i = 0; i < 3; i++) { + await page.fill('#docTitle', `Doc ${i + 1}`); + await page.click('#createDocBtn'); + await page.waitForTimeout(100); + } + + // Check metrics + const docCount = await page.locator('#docCount').textContent(); + expect(docCount).toBe('3'); + + const storageSize = await page.locator('#storageSize').textContent(); + expect(storageSize).toContain('KB'); + }); + + test('IndexedDB persistence across page reloads using direct API', async () => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + const testDocuments = [ + { + title: 'Simple Document', + content: 'Simple content with value: 123', + createdAt: new Date().toISOString(), + }, + { + title: 'Large Document', + content: 'Large document content: ' + 'x'.repeat(10000), + createdAt: new Date().toISOString(), + }, + ]; + + // Create documents using direct API and store their IDs + const documentIds: string[] = []; + for (const docData of testDocuments) { + const docId = await createDocumentViaAPI(page, docData); + documentIds.push(docId); + expect(docId).toBeTruthy(); + } + + // Wait for docs to persist + await page.waitForTimeout(500); + + // Verify all documents are created via API + for (let i = 0; i < documentIds.length; i++) { + const docContent = await getDocumentViaAPI(page, documentIds[i]); + expect(docContent).toBeTruthy(); + expect(docContent.title).toBe(testDocuments[i].title); + } + + // Clear server storage to ensure persistence is from IndexedDB + const clearResponse = await page.request.post( + `http://localhost:${server.getPort()}/test/clear-storage` + ); + expect(clearResponse.ok()).toBe(true); + + // Reload the page multiple times to test persistence + for (let reloadCount = 0; reloadCount < 3; reloadCount++) { + // Disconnect before reload to ensure no sync happens during reload + await page.click('#disconnectBtn'); + await page.waitForSelector('.status.error'); + + await page.reload(); + await page.waitForSelector('.status:not(.connecting)'); + + // Wait for discovery to complete during initialization + await page.waitForTimeout(1000); + + // Verify documents exist via API (must come from IndexedDB since server was cleared) + for (let i = 0; i < documentIds.length; i++) { + const exists = await documentExistsViaAPI(page, documentIds[i]); + expect(exists).toBe(true); + + const docContent = await getDocumentViaAPI(page, documentIds[i]); + expect(docContent).toBeTruthy(); + expect(docContent.title).toBe(testDocuments[i].title); + expect(docContent.content).toBe(testDocuments[i].content); + } + + // Now reconnect to server + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Verify documents are still accessible after reconnecting + for (let i = 0; i < documentIds.length; i++) { + const docContent = await getDocumentViaAPI(page, documentIds[i]); + expect(docContent).toBeTruthy(); + expect(docContent.title).toBe(testDocuments[i].title); + expect(docContent.content).toBe(testDocuments[i].content); + } + } + }); + + test('IndexedDB storage size calculations are accurate', async () => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Get initial metrics + let initialDocCount = await page.locator('#docCount').textContent(); + let initialStorageSize = await page.locator('#storageSize').textContent(); + expect(initialDocCount).toBe('0'); + expect(initialStorageSize).toBe('0 KB'); + + // Create a small document + await page.fill('#docTitle', 'Small Document'); + await page.fill('#docContent', 'Small amount of data'); + await page.click('#createDocBtn'); + await page.waitForSelector('.document-item'); + + // Check metrics after small document + await page.click('#syncBtn'); + let docCount = await page.locator('#docCount').textContent(); + let storageSize = await page.locator('#storageSize').textContent(); + expect(docCount).toBe('1'); + expect(storageSize).toContain('KB'); + expect(storageSize).not.toBe('0 KB'); + + // Create a medium document with more content + const mediumContent = Array.from( + { length: 10 }, + (_, i) => + `Field ${i}: This is field ${i} with some content that makes it longer` + ).join('\n'); + + await page.fill('#docTitle', 'Medium Document'); + await page.fill('#docContent', mediumContent); + await page.click('#createDocBtn'); + await page.waitForSelector('.document-item:nth-child(2)'); + + // Check metrics after medium document + await page.click('#syncBtn'); + docCount = await page.locator('#docCount').textContent(); + storageSize = await page.locator('#storageSize').textContent(); + expect(docCount).toBe('2'); + + // Storage should have increased + const mediumStorageValue = parseInt(storageSize!.replace(' KB', '')); + expect(mediumStorageValue).toBeGreaterThan(0); + + // Create a large document with substantial content + const largeContent = Array.from( + { length: 50 }, + (_, i) => + `Entry ${i}: This is a longer piece of text for entry ${i}. It contains more characters to simulate larger documents with substantial content that would take up more storage space in IndexedDB.` + ).join('\n'); + + await page.fill('#docTitle', 'Large Document'); + await page.fill('#docContent', largeContent); + await page.click('#createDocBtn'); + await page.waitForSelector('.document-item:nth-child(3)'); + + // Check final metrics + await page.click('#syncBtn'); + const finalDocCount = await page.locator('#docCount').textContent(); + const finalStorageSize = await page.locator('#storageSize').textContent(); + + expect(finalDocCount).toBe('3'); + expect(finalStorageSize).toContain('KB'); + + // Final storage should be significantly larger than medium + const finalStorageValue = parseInt(finalStorageSize!.replace(' KB', '')); + expect(finalStorageValue).toBeGreaterThan(mediumStorageValue); + + // Verify all documents are visible in the UI + const documentTitles = await page + .locator('.document-item strong') + .allTextContents(); + expect(documentTitles).toContain('Small Document'); + expect(documentTitles).toContain('Medium Document'); + expect(documentTitles).toContain('Large Document'); + }); + + test('IndexedDB persistence without server using direct API', async () => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Create test documents using direct API + const testDocuments = [ + { + title: 'API Document 1', + content: 'This should persist without server', + createdAt: new Date().toISOString(), + }, + { + title: 'API Document 2', + content: 'Another API document', + createdAt: new Date().toISOString(), + }, + ]; + + const documentIds: string[] = []; + for (const docData of testDocuments) { + const docId = await createDocumentViaAPI(page, docData); + documentIds.push(docId); + expect(docId).toBeTruthy(); + } + + // Verify documents exist via API + for (let i = 0; i < documentIds.length; i++) { + const docContent = await getDocumentViaAPI(page, documentIds[i]); + expect(docContent).toBeTruthy(); + expect(docContent.title).toBe(testDocuments[i].title); + expect(docContent.content).toBe(testDocuments[i].content); + } + + // Clear server storage to ensure documents can't come from there + const clearResponse = await page.request.post( + `http://localhost:${server.getPort()}/test/clear-storage` + ); + expect(clearResponse.ok()).toBe(true); + const clearResult = await clearResponse.json(); + expect(clearResult.cleared).toBe(true); + + // Disconnect from server + await page.click('#disconnectBtn'); + await page.waitForSelector('.status.error'); + + // Stop the server completely + await server.stop(); + + // Wait a moment for cleanup + await page.waitForTimeout(1000); + + // Reload the page with server down + await page.reload(); + await page.waitForSelector('.status:not(.connecting)', { timeout: 10000 }); + + // Wait for discovery to complete during initialization + await page.waitForTimeout(1000); + + // Verify documents still exist via API after reload (must come from IndexedDB) + for (let i = 0; i < documentIds.length; i++) { + const exists = await documentExistsViaAPI(page, documentIds[i]); + expect(exists).toBe(true); + + const docContent = await getDocumentViaAPI(page, documentIds[i]); + expect(docContent).toBeTruthy(); + expect(docContent.title).toBe(testDocuments[i].title); + expect(docContent.content).toBe(testDocuments[i].content); + } + + // Restart server for cleanup + server = await createTestServer(); + }); + + test('concurrent IndexedDB access from multiple tabs works', async ({ + browser, + }) => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Create a document in tab 1 + await page.fill('#docTitle', 'Tab 1 Document'); + await page.fill('#docContent', 'Created in first tab'); + await page.click('#createDocBtn'); + await page.waitForSelector('.document-item'); + + // Verify document was created in tab 1 + let tab1DocCount = await page.locator('.document-item').count(); + expect(tab1DocCount).toBe(1); + + const tab1DocTitle = await page + .locator('.document-item strong') + .textContent(); + expect(tab1DocTitle).toBe('Tab 1 Document'); + + // Open a second tab with the same application + const page2 = await browser.newPage(); + await page2.goto(page.url()); + await page2.waitForSelector('.status:not(.connecting)'); + await page2.click('#connectBtn'); + await page2.waitForSelector('.status.connected'); + + // Sync in tab 2 to load documents from IndexedDB + await page2.click('#syncBtn'); + await page2.waitForTimeout(1000); + + // Verify tab 2 can see the document created in tab 1 + const tab2InitialDocCount = await page2.locator('.document-item').count(); + expect(tab2InitialDocCount).toBe(1); + + const tab2SeesTab1Doc = await page2 + .locator('.document-item strong') + .textContent(); + expect(tab2SeesTab1Doc).toBe('Tab 1 Document'); + + // Create a document in tab 2 using UI + await page2.fill('#docTitle', 'Tab 2 Document'); + await page2.fill('#docContent', 'Created in second tab'); + await page2.click('#createDocBtn'); + await page2.waitForSelector('.document-item:nth-child(2)'); + + // Verify both documents are visible in tab 2 + const tab2FinalDocCount = await page2.locator('.document-item').count(); + expect(tab2FinalDocCount).toBe(2); + + const tab2DocTitles = await page2 + .locator('.document-item strong') + .allTextContents(); + expect(tab2DocTitles).toContain('Tab 1 Document'); + expect(tab2DocTitles).toContain('Tab 2 Document'); + + // Sync in tab 1 to see the document created in tab 2 + await page.click('#syncBtn'); + await page.waitForTimeout(1000); + + // Verify both documents are now visible in tab 1 + const tab1FinalDocCount = await page.locator('.document-item').count(); + expect(tab1FinalDocCount).toBe(2); + + const tab1DocTitles = await page + .locator('.document-item strong') + .allTextContents(); + expect(tab1DocTitles).toContain('Tab 1 Document'); + expect(tab1DocTitles).toContain('Tab 2 Document'); + + // Verify metrics are consistent across tabs + const tab1DocCountMetric = await page.locator('#docCount').textContent(); + const tab2DocCountMetric = await page2.locator('#docCount').textContent(); + + expect(tab1DocCountMetric).toBe('2'); + expect(tab2DocCountMetric).toBe('2'); + }); + + test('manual disconnect disables auto-reconnect', async () => { + await page.click('#connectBtn'); + await page.waitForSelector('.status.connected'); + + // Verify auto-reconnect is enabled initially + const connectedState = await page.evaluate(() => { + const app = (window as any).testApp; + return app.getConnectionState(); + }); + expect(connectedState.autoReconnect).toBe(true); + + // Manual disconnect + await page.click('#disconnectBtn'); + await page.waitForSelector('.status.error'); + + // Verify auto-reconnect is disabled after manual disconnect + const disconnectedState = await page.evaluate(() => { + const app = (window as any).testApp; + return app.getConnectionState(); + }); + + expect(disconnectedState.isConnected).toBe(false); + expect(disconnectedState.autoReconnect).toBe(false); + expect(disconnectedState.hasReconnectTimeout).toBe(false); + expect(disconnectedState.reconnectAttempts).toBe(0); + }); +}); diff --git a/wasm-tests/tests/server-helper.ts b/wasm-tests/tests/server-helper.ts new file mode 100644 index 0000000..348ba57 --- /dev/null +++ b/wasm-tests/tests/server-helper.ts @@ -0,0 +1,121 @@ +import { spawn, ChildProcess } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import net from 'net'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export class TestServer { + private process: ChildProcess | null = null; + private port: number = 0; + + async start(): Promise { + // Find an available port + this.port = await this.findAvailablePort(); + + // Start the server + return new Promise((resolve, reject) => { + const serverPath = join( + __dirname, + '../../samod/interop-test-server/server.js' + ); + + this.process = spawn('node', [serverPath, '--wasm', String(this.port)], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'test' }, + }); + + let started = false; + + // Listen for startup message + this.process.stdout?.on('data', data => { + const output = data.toString(); + console.log(`[Server ${this.port}] ${output.trim()}`); + + if (output.includes('Listening on port') && !started) { + started = true; + resolve(this.port); + } + }); + + this.process.stderr?.on('data', data => { + console.error(`[Server ${this.port} Error] ${data.toString().trim()}`); + }); + + this.process.on('error', error => { + reject(new Error(`Failed to start server: ${error.message}`)); + }); + + this.process.on('exit', code => { + if (!started) { + reject(new Error(`Server exited with code ${code} before starting`)); + } + }); + + // Timeout if server doesn't start + setTimeout(() => { + if (!started) { + this.stop(); + reject(new Error('Server failed to start within timeout')); + } + }, 10000); + }); + } + + async stop(): Promise { + if (this.process) { + return new Promise(resolve => { + this.process!.once('exit', () => { + this.process = null; + resolve(); + }); + + this.process!.kill('SIGTERM'); + + // Force kill after timeout + setTimeout(() => { + if (this.process) { + this.process.kill('SIGKILL'); + } + }, 5000); + }); + } + } + + getPort(): number { + return this.port; + } + + getUrl(): string { + return `http://localhost:${this.port}`; + } + + getWsUrl(): string { + return `ws://localhost:${this.port}`; + } + + private async findAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + + server.listen(0, () => { + const address = server.address(); + if (address && typeof address !== 'string') { + const port = address.port; + server.close(() => resolve(port)); + } else { + reject(new Error('Failed to get port')); + } + }); + + server.on('error', reject); + }); + } +} + +// Helper function to create and start a server +export async function createTestServer(): Promise { + const server = new TestServer(); + await server.start(); + return server; +} diff --git a/wasm-tests/tsconfig.json b/wasm-tests/tsconfig.json new file mode 100644 index 0000000..853ddc4 --- /dev/null +++ b/wasm-tests/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "pkg"] +} \ No newline at end of file diff --git a/wasm-tests/vite.config.js b/wasm-tests/vite.config.js new file mode 100644 index 0000000..443527d --- /dev/null +++ b/wasm-tests/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import path from 'path'; + +export default defineConfig({ + root: path.resolve(__dirname, 'src'), + server: { + port: 3000, + proxy: { + '/ws': { + target: 'ws://localhost:3001', + ws: true, + }, + }, + }, + optimizeDeps: { + exclude: ['samod_wasm'], + }, +}); \ No newline at end of file From 4423350d3c55dbc707c097fd1f59fbb5cec1ab7d Mon Sep 17 00:00:00 2001 From: "Jack D. Douglas" Date: Tue, 7 Oct 2025 16:21:39 +0100 Subject: [PATCH 11/12] feat(samod): TON-1519: full websocket lifecycle --- samod/src/lib.rs | 83 ++++++++++++++++++++++++++++++++---------- samod/src/websocket.rs | 67 +++++++++++++++++++++++++++++++--- 2 files changed, 126 insertions(+), 24 deletions(-) diff --git a/samod/src/lib.rs b/samod/src/lib.rs index 8b9d6bf..ff1884c 100644 --- a/samod/src/lib.rs +++ b/samod/src/lib.rs @@ -614,15 +614,16 @@ impl Repo { where SendErr: std::error::Error + Send + Sync + 'static, RecvErr: std::error::Error + Send + Sync + 'static, - Snk: Sink, Error = SendErr> + Send + 'static + Unpin, - Str: Stream, RecvErr>> + Send + 'static + Unpin, + Snk: Sink, Error = SendErr> + 'static + Unpin, + Str: Stream, RecvErr>> + 'static + Unpin, { let local_peer_id = self.inner.lock().unwrap().hub.peer_id(); - tracing::info!("Creating connection with direction {:?}, local peer: {}", direction, local_peer_id); - tracing::Span::current().record( - "local_peer_id", - local_peer_id.to_string(), + tracing::info!( + "Creating connection with direction {:?}, local peer: {}", + direction, + local_peer_id ); + tracing::Span::current().record("local_peer_id", local_peer_id.to_string()); let DispatchedCommand { command_id, event } = HubEvent::create_connection(direction); tracing::debug!("Created connection command_id: {:?}", command_id); let (tx, rx) = oneshot::channel(); @@ -654,7 +655,10 @@ impl Repo { }; let mut stream = stream.fuse(); - tracing::info!("Starting connection message loop for connection {:?}", connection_id); + tracing::info!( + "Starting connection message loop for connection {:?}", + connection_id + ); let result = loop { futures::select! { next_inbound_msg = stream.next() => { @@ -701,6 +705,42 @@ impl Repo { } } + #[cfg(feature = "wasm")] + /// Connect with an additional ready signal for controlled connection + pub fn connect_with_ready_signal( + &self, + stream: Str, + sink: Snk, + direction: ConnDirection, + ready_signal: oneshot::Sender<()>, + ) -> impl Future + 'static + where + SendErr: std::error::Error + Send + Sync + 'static, + RecvErr: std::error::Error + Send + Sync + 'static, + Snk: Sink, Error = SendErr> + 'static + Unpin, + Str: Stream, RecvErr>> + 'static + Unpin, + { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + let ready_sent = Arc::new(AtomicBool::new(false)); + let ready_sent_clone = Arc::clone(&ready_sent); + let mut ready_signal = Some(ready_signal); + + let monitoring_sink = sink.with(move |msg| { + if !ready_sent_clone.load(Ordering::Relaxed) { + ready_sent_clone.store(true, Ordering::Relaxed); + if let Some(signal) = ready_signal.take() { + let _ = signal.send(()); + } + tracing::info!("WASM: Protocol handshake complete (first message sent)"); + } + futures::future::ready(Ok::, SendErr>(msg)) + }); + + self.connect(stream, monitoring_sink, direction) + } + /// Connect with an additional close signal for controlled disconnection pub async fn connect_with_close_signal( &self, @@ -730,13 +770,9 @@ impl Repo { self.inner.lock().unwrap().handle_event(event); let inner = self.inner.clone(); - + let connection_id = match rx.await { - Ok(CommandResult::CreateConnection { connection_id }) => { - #[cfg(target_arch = "wasm32")] - web_sys::console::log_1(&format!("WASM: Got connection_id: {:?}", connection_id).into()); - connection_id - }, + Ok(CommandResult::CreateConnection { connection_id }) => connection_id, Ok(other) => panic!("unexpected command result for create connection: {other:?}"), Err(_) => return ConnFinishedReason::Shutdown, }; @@ -754,12 +790,21 @@ impl Repo { let mut stream = stream.fuse(); let mut close_signal = close_signal.fuse(); - - tracing::info!("Starting connection message loop for connection {:?}", connection_id); - + + tracing::info!( + "Starting connection message loop for connection {:?}", + connection_id + ); + #[cfg(target_arch = "wasm32")] - web_sys::console::log_1(&format!("WASM: Starting connection message loop for connection {:?}", connection_id).into()); - + web_sys::console::log_1( + &format!( + "WASM: Starting connection message loop for connection {:?}", + connection_id + ) + .into(), + ); + let result = loop { futures::select! { _ = close_signal => { @@ -848,7 +893,7 @@ impl Repo { } /// List all document IDs that are stored locally - /// + /// /// This method returns document IDs of all currently active document handles. /// For now, this implementation returns the document IDs of documents that are /// currently loaded in memory. A future enhancement could scan storage for diff --git a/samod/src/websocket.rs b/samod/src/websocket.rs index 66fb9fa..2b72df9 100644 --- a/samod/src/websocket.rs +++ b/samod/src/websocket.rs @@ -1,7 +1,9 @@ use futures::{Future, Sink, SinkExt, Stream, StreamExt}; - #[cfg(feature = "wasm")] -use futures::channel::{mpsc, oneshot}; +use futures::{ + FutureExt, + channel::{mpsc, oneshot}, +}; #[cfg(feature = "wasm")] use js_sys::Uint8Array; #[cfg(feature = "wasm")] @@ -15,6 +17,16 @@ use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket}; use crate::{ConnDirection, ConnFinishedReason, Repo}; +#[cfg(feature = "wasm")] +use futures::future::LocalBoxFuture; + +#[cfg(feature = "wasm")] +pub struct WasmConnectionEvents { + pub on_open: oneshot::Receiver<()>, + pub on_ready: oneshot::Receiver<()>, + pub finished: LocalBoxFuture<'static, ConnFinishedReason>, +} + /// A copy of tungstenite::Message /// /// This is necessary because axum uses tungstenite::Message internally but exposes it's own @@ -180,6 +192,51 @@ impl Repo { } } + #[cfg(feature = "wasm")] + pub fn connect_wasm_websocket_observable( + &self, + url: &str, + direction: ConnDirection, + ) -> WasmConnectionEvents { + let (open_tx, open_rx) = oneshot::channel(); + let (ready_tx, ready_rx) = oneshot::channel(); + + let repo = self.clone(); + let url = url.to_string(); + + let finished = async move { + tracing::info!("WASM: Attempting WebSocket connection to {}", url); + + match WasmWebSocket::connect(&url).await { + Ok(ws) => { + tracing::info!("WASM: WebSocket connection established"); + let _ = open_tx.send(()); + + let (sink, stream) = ws.split(); + + let result = repo + .connect_with_ready_signal(stream, sink, direction, ready_tx) + .await; + + tracing::info!("WASM: Connection finished with reason: {:?}", result); + result + } + Err(e) => { + let error_msg = format!("Failed to connect WebSocket: {}", e); + tracing::error!("WASM: {}", error_msg); + ConnFinishedReason::Error(error_msg) + } + } + } + .boxed_local(); + + WasmConnectionEvents { + on_open: open_rx, + on_ready: ready_rx, + finished, + } + } + /// Connect any stream of [`WsMessage`]s /// /// [`WsMessage`] is a copy of `tungstenite::Message` and @@ -194,8 +251,8 @@ impl Repo { direction: ConnDirection, ) -> impl Future + 'static where - M: Into + From + Send + 'static, - S: Sink + Stream> + Send + 'static, + M: Into + From + 'static, + S: Sink + Stream> + 'static, { let (sink, stream) = stream.split(); @@ -223,7 +280,7 @@ impl Repo { } } }) - .boxed(); + .boxed_local(); let msg_sink = sink .sink_map_err(|e| NetworkError(format!("websocket send error: {e}"))) From fe92f4d6fbb53fe107b1f4d9eea3fe5da7a30322 Mon Sep 17 00:00:00 2001 From: "Jack D. Douglas" Date: Tue, 14 Oct 2025 16:20:33 +0100 Subject: [PATCH 12/12] fix(samod): `connect_websocket` is `Send` outside wasm --- samod/src/websocket.rs | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/samod/src/websocket.rs b/samod/src/websocket.rs index 2b72df9..5632666 100644 --- a/samod/src/websocket.rs +++ b/samod/src/websocket.rs @@ -245,6 +245,7 @@ impl Repo { /// are identical, but not the same type. This function allows us to /// implement the connection logic once and use it for both `tungstenite` /// and `axum`. + #[cfg(target_arch = "wasm32")] pub fn connect_websocket( &self, stream: S, @@ -290,6 +291,61 @@ impl Repo { self.connect(msg_stream, msg_sink, direction) } + + /// Connect any stream of [`WsMessage`]s + /// + /// [`WsMessage`] is a copy of `tungstenite::Message` and + /// `axum::extract::ws::Message` which is reimplemented in this crate + /// because both `tungstenite` and `axum` use their own message types which + /// are identical, but not the same type. This function allows us to + /// implement the connection logic once and use it for both `tungstenite` + /// and `axum`. + #[cfg(not(target_arch = "wasm32"))] + pub fn connect_websocket( + &self, + stream: S, + direction: ConnDirection, + ) -> impl Future + 'static + where + M: Into + From + Send + 'static, + S: Sink + Stream> + Send + 'static, + { + let (sink, stream) = stream.split(); + + let msg_stream = stream + .filter_map::<_, Result, NetworkError>, _>({ + move |msg| async move { + let msg = match msg { + Ok(m) => m, + Err(e) => { + return Some(Err(NetworkError(format!( + "websocket receive error: {e}" + )))); + } + }; + match msg.into() { + WsMessage::Binary(data) => Some(Ok(data)), + WsMessage::Close => { + tracing::debug!("websocket closing"); + None + } + WsMessage::Ping(_) | WsMessage::Pong(_) => None, + WsMessage::Text(_) => Some(Err(NetworkError( + "unexpected string message on websocket".to_string(), + ))), + } + } + }) + .boxed(); + + let msg_sink = sink + .sink_map_err(|e| NetworkError(format!("websocket send error: {e}"))) + .with(|msg| { + futures::future::ready(Ok::<_, NetworkError>(WsMessage::Binary(msg).into())) + }); + + self.connect(msg_stream, msg_sink, direction) + } } pub struct NetworkError(String);