diff --git a/examples/rx_state/Cargo.toml b/examples/rx_state/Cargo.toml index 0a29c9d789..a6f9ed3acb 100644 --- a/examples/rx_state/Cargo.toml +++ b/examples/rx_state/Cargo.toml @@ -6,7 +6,7 @@ edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -perseus = { path = "../../packages/perseus", features = [ "hydrate" ] } +perseus = { path = "../../packages/perseus", features = [ "hydrate", "idb-freezing" ] } sycamore = "0.7" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/examples/rx_state/src/idb.rs b/examples/rx_state/src/idb.rs new file mode 100644 index 0000000000..4c83bb77f4 --- /dev/null +++ b/examples/rx_state/src/idb.rs @@ -0,0 +1,50 @@ +use perseus::state::IdbFrozenStateStore; +use perseus::{Html, RenderFnResultWithCause, Template}; +use sycamore::prelude::*; + +#[perseus::make_rx(TestPropsRx)] +pub struct TestProps { + pub username: String, +} + +// This special macro (normally we'd use `template(IndexProps)`) converts the state we generate elsewhere to a reactive version +#[perseus::template_rx(IdbPage)] +pub fn idb_page(TestPropsRx { username }: TestPropsRx) -> View { + let username_2 = username.clone(); // This is necessary until Sycamore's new reactive primitives are released + // TODO Futures etc. + let _idb_store = IdbFrozenStateStore::new(); + + view! { + p { (format!("Greetings, {}!", username.get())) } + input(bind:value = username_2, placeholder = "Username") + + // When the user visits this and then comes back, they'll still be able to see their username (the previous state will be retrieved from the global state automatically) + a(href = "about") { "About" } + a(href = "") { "Index" } + + // button(on:click = cloned!(frozen_app, render_ctx => move |_| { + // frozen_app.set(render_ctx.freeze()); + // })) { "Freeze!" } + // p { (frozen_app.get()) } + + // button(on:click = cloned!(frozen_app_3, render_ctx => move |_| { + // render_ctx.thaw(&frozen_app_3.get(), perseus::state::ThawPrefs { + // page: perseus::state::PageThawPrefs::IncludeAll, + // global_prefer_frozen: true + // }).unwrap(); + // })) { "Thaw..." } + } +} + +pub fn get_template() -> Template { + Template::new("idb") + .build_state_fn(get_build_state) + .template(idb_page) +} + +#[perseus::autoserde(build_state)] +pub async fn get_build_state(_path: String, _locale: String) -> RenderFnResultWithCause { + Ok(TestProps { + username: "".to_string(), + }) +} diff --git a/examples/rx_state/src/lib.rs b/examples/rx_state/src/lib.rs index ae3120ec3a..b0959eac45 100644 --- a/examples/rx_state/src/lib.rs +++ b/examples/rx_state/src/lib.rs @@ -1,5 +1,6 @@ mod about; mod global_state; +mod idb; mod index; mod test; @@ -9,7 +10,8 @@ define_app! { templates: [ index::get_template::(), test::get_template::(), - about::get_template::() + about::get_template::(), + idb::get_template::() ], error_pages: perseus::ErrorPages::new(|url, status, err, _| { sycamore::view! { diff --git a/packages/perseus/Cargo.toml b/packages/perseus/Cargo.toml index 8b24a94382..e1ae3679bd 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -35,6 +35,7 @@ fluent-bundle = { version = "0.15", optional = true } unic-langid = { version = "0.9", optional = true } intl-memoizer = { version = "0.5", optional = true } tokio = { version = "1", features = [ "fs", "io-util" ] } +rexie = { version = "0.2", optional = true } [features] default = [] @@ -52,3 +53,8 @@ hydrate = [] # This feature enables the preloading of the Wasm bundle for locale redirections, which in theory improves UX # For now, this is experimental until it can be tested in the wild (local testing of this is extremely difficult for UX, we need real world metrics) preload-wasm-on-redirect = [] +# This exposes an API for saving frozen state to IndexedDB simply, with options for making your storage persistent so the browser won't delete it +idb-freezing = [ "rexie", "web-sys/StorageManager" ] +# Switches to expecting the server to provide a JS bundle that's been created from Wasm +# Note that this is highly experimental, and currently blocked by [rustwasm/wasm-bindgen#2735](https://github.com/rustwasm/wasm-bindgen/issues/2735) +wasm2js = [] diff --git a/packages/perseus/src/state/freeze_idb.rs b/packages/perseus/src/state/freeze_idb.rs new file mode 100644 index 0000000000..966f2e44a4 --- /dev/null +++ b/packages/perseus/src/state/freeze_idb.rs @@ -0,0 +1,176 @@ +use rexie::{Direction, Error as RexieError, ObjectStore, Rexie, TransactionMode}; +use std::rc::Rc; +use thiserror::Error; +use wasm_bindgen::JsValue; +use wasm_bindgen_futures::JsFuture; + +#[allow(missing_docs)] +#[derive(Debug, Error)] +pub enum IdbError { + #[error("couldn't build database")] + BuildError { + #[source] + source: RexieError, + }, + // The source of this would be a `JsValue`, which we drop for performance + #[error("persistence check failed")] + PersistenceCheckFailed { + /// Whether or not this persistence check could be retried and might result in a success (this is just an estimation). + retry: bool, + }, + #[error("an error occurred while constructing an IndexedDB transaction")] + TransactionError { + #[source] + source: RexieError, + }, + #[error("an error occured while trying to set a new value")] + SetError { + #[source] + source: RexieError, + }, + #[error("an error occurred while clearing the store of previous values")] + ClearError { + #[source] + source: RexieError, + }, + #[error("an error occurred while trying to get the latest value")] + GetError { + #[source] + source: RexieError, + }, +} + +/// A frozen state store that uses IndexedDB as a backend. This will only store a single frozen state at a time, removing all previously stored states every time a new one is set. +/// +/// TODO Browser compatibility information. +#[derive(Clone)] +pub struct IdbFrozenStateStore { + /// A handle to the database. + db: Rc, +} +impl std::fmt::Debug for IdbFrozenStateStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdbFrozenStateStore").finish() + } +} +impl IdbFrozenStateStore { + /// Creates a new store for this origin. If it already exists from a previous visit, the existing one will be interfaced with. + pub async fn new() -> Result { + // Build the database + let rexie = Rexie::builder("perseus") + // IndexedDB uses versions to track database schema changes + // If the structure of this DB ever changes, this MUST be changed, and this should be considered a non-API-breaking, but app-breaking change! + .version(1) + .add_object_store( + // We'll store many versions of frozen state so that the user can revert to previous states + ObjectStore::new("frozen_state") + .key_path("id") + .auto_increment(true), // IndexedDB doesn't need us to register value types, only things that should be indexed (gotta love JS type safety haven't you!) + ) + .build() + .await + .map_err(|err| IdbError::BuildError { source: err })?; + + Ok(Self { db: Rc::new(rexie) }) + } + /// Gets the stored frozen state. Be warned that the result of this could be arbitrarily old, or it may have been tampered with by the user (in which case Perseus will either return an + /// error that you can handle or it'll fall back to the active state). If no state has been stored yet, this will return `Ok(None)`. + pub async fn get(&self) -> Result, IdbError> { + let transaction = self + .db + .transaction(&["frozen_state"], TransactionMode::ReadOnly) + .map_err(|err| IdbError::TransactionError { source: err })?; + let store = transaction + .store("frozen_state") + .map_err(|err| IdbError::TransactionError { source: err })?; + + // Get the last element from the store by working backwards and getting everything, with a limit of 1 + let frozen_states = store + .get_all(None, Some(1), None, Some(Direction::Prev)) + .await + .map_err(|err| IdbError::GetError { source: err })?; + let frozen_state = match frozen_states.get(0) { + Some((_key, value)) => value, + None => return Ok(None), + }; + // TODO Do this without cloning the whole thing into the Wasm table and then moving it into Rust + let frozen_state = frozen_state.as_string().unwrap(); + transaction + .commit() + .await + .map_err(|err| IdbError::TransactionError { source: err })?; + + Ok(Some(frozen_state)) + } + /// Sets the content to a new frozen state. + pub async fn set(&self, frozen_state: &str) -> Result<(), IdbError> { + let transaction = self + .db + .transaction(&["frozen_state"], TransactionMode::ReadWrite) + .map_err(|err| IdbError::TransactionError { source: err })?; + let store = transaction + .store("frozen_state") + .map_err(|err| IdbError::TransactionError { source: err })?; + + // We only store a single frozen state, and they can be quite large, so we'll remove any that are already in here + store + .clear() + .await + .map_err(|err| IdbError::ClearError { source: err })?; + // We can add the frozen state directly because it's already a serialized string + // This returns the ID, but we don't need to care about that + store + .add(&JsValue::from(frozen_state), None) + .await + .map_err(|err| IdbError::SetError { source: err })?; + transaction + .commit() + .await + .map_err(|err| IdbError::SetError { source: err })?; + + Ok(()) + } + /// Checks if the storage is persistently stored. If it is, the browser isn't allowed to clear it, the user would have to manually. This doesn't provide a guarantee that all users who've + /// been to your site before will have previous state stored, you should assume that they could well have cleared it manually (or with very stringent privacy settings). + /// + /// If this returns an error, a recommendation about whether or not to retry will be attached. You generally shouldn't retry this more than once if there was an error. + /// + /// For more information about persistent storage on the web, see [here](https://web.dev/persistent-storage). + pub async fn is_persistent() -> Result { + let storage_manager = web_sys::window().unwrap().navigator().storage(); + // If we can't access this, we're probably in a very old browser, so retrying isn't worth it in all likelihood + let persisted = storage_manager + .persisted() + .map_err(|_| IdbError::PersistenceCheckFailed { retry: false })?; + let persisted = JsFuture::from(persisted) + .await + .map_err(|_| IdbError::PersistenceCheckFailed { retry: true })?; + let persisted_bool = persisted + .as_bool() + .ok_or(IdbError::PersistenceCheckFailed { retry: true })?; + + Ok(persisted_bool) + } + /// Requests persistent storage from the browser. In Firefox, the user will be prompted, though in Chrome the browser will automatically accept or deny based on your site's level of + /// engagement, whether ot not it's been installed or bookmarked, and whether or not it's been granted the permission to show notifications. In other words, do NOT assume that this will + /// be accepted, even if you ask the user very nicely. That said, especially in Firefox, you should display a custom notification before this with `alert()` or similar that explains why + /// your site needs persistent storage for frozen state. + /// + /// If this returns `false`, the request was rejected, but you can retry in future (for user experience though, it's recommended to only do so very sparingly). If this returns an error, + /// a recommendation about whether or not to retry will be attached. You generally shouldn't retry this more than once if there was an error. + /// + /// For more information about persistent storage on the web, see [here](https://web.dev/persistent-storage). + pub async fn request_persistence() -> Result { + let storage_manager = web_sys::window().unwrap().navigator().storage(); + // If we can't access this, we're probably in a very old browser, so retrying isn't worth it in all likelihood + let res = storage_manager + .persist() + .map_err(|_| IdbError::PersistenceCheckFailed { retry: false })?; + let res = JsFuture::from(res) + .await + .map_err(|_| IdbError::PersistenceCheckFailed { retry: true })?; + let res_bool = res.as_bool().unwrap(); + + Ok(res_bool) + } +} diff --git a/packages/perseus/src/state/mod.rs b/packages/perseus/src/state/mod.rs index 60008c5479..cb56993ba7 100644 --- a/packages/perseus/src/state/mod.rs +++ b/packages/perseus/src/state/mod.rs @@ -7,3 +7,8 @@ pub use freeze::{FrozenApp, PageThawPrefs, ThawPrefs}; pub use global_state::{GlobalState, GlobalStateCreator}; pub use page_state_store::PageStateStore; pub use rx_state::{AnyFreeze, Freeze, MakeRx, MakeUnrx}; + +#[cfg(feature = "idb-freezing")] +mod freeze_idb; +#[cfg(feature = "idb-freezing")] +pub use freeze_idb::*; // TODO Be specific here