Skip to content

Commit

Permalink
feat: added idb wrapper for state freezing
Browse files Browse the repository at this point in the history
  • Loading branch information
arctic-hen7 committed Jan 24, 2022
1 parent 2d67a40 commit 9d2a729
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 2 deletions.
2 changes: 1 addition & 1 deletion examples/rx_state/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 50 additions & 0 deletions examples/rx_state/src/idb.rs
Original file line number Diff line number Diff line change
@@ -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<G> {
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<G: Html>() -> Template<G> {
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<TestProps> {
Ok(TestProps {
username: "".to_string(),
})
}
4 changes: 3 additions & 1 deletion examples/rx_state/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod about;
mod global_state;
mod idb;
mod index;
mod test;

Expand All @@ -9,7 +10,8 @@ define_app! {
templates: [
index::get_template::<G>(),
test::get_template::<G>(),
about::get_template::<G>()
about::get_template::<G>(),
idb::get_template::<G>()
],
error_pages: perseus::ErrorPages::new(|url, status, err, _| {
sycamore::view! {
Expand Down
6 changes: 6 additions & 0 deletions packages/perseus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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 = []
176 changes: 176 additions & 0 deletions packages/perseus/src/state/freeze_idb.rs
Original file line number Diff line number Diff line change
@@ -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<Rexie>,
}
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<Self, IdbError> {
// 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<Option<String>, 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<bool, IdbError> {
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<bool, IdbError> {
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)
}
}
5 changes: 5 additions & 0 deletions packages/perseus/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 9d2a729

Please sign in to comment.