finished use_cookie

This commit is contained in:
Maccesch 2024-01-31 16:54:25 +00:00
parent c9381abe26
commit ee62623950
13 changed files with 776 additions and 87 deletions

View file

@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The trait `Codec` has been renamed to `StringCodec` and has been moved to `util::StringCodec`.
- The struct `StringCodec` has been renamed to `FromToStringCodec` and has been moved to `util::FromToStringCodec`.
- The structs `JsonCodec` and `ProstCodec` have been moved to `util` as well.
- The function `use_storage` now requires type parameters for the stored type and the codec like all the other
`...storage...` functions.
### Fixes 🍕

View file

@ -28,6 +28,7 @@ js-sys = "0.3"
lazy_static = "1"
leptos = "0.6"
leptos_axum = { version = "0.6", optional = true }
leptos_actix = { version = "0.6", optional = true }
num = { version = "0.4", optional = true }
paste = "1"
prost = { version = "0.12", optional = true }
@ -44,7 +45,7 @@ features = [
"AddEventListenerOptions",
"BinaryType",
"BroadcastChannel",
"Coordinates",
"Coordinates",
"CloseEvent",
"CssStyleDeclaration",
"CustomEvent",
@ -62,7 +63,7 @@ features = [
"FileList",
"Geolocation",
"HtmlDocument",
"HtmlElement",
"HtmlElement",
"HtmlLinkElement",
"HtmlStyleElement",
"IntersectionObserver",
@ -72,7 +73,7 @@ features = [
"MediaQueryList",
"MediaStream",
"MediaStreamTrack",
"MessageEvent",
"MessageEvent",
"MouseEvent",
"MutationObserver",
"MutationObserverInit",
@ -120,8 +121,12 @@ features = [
"WritableStreamDefaultWriter",
]
[dev-dependencies]
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }
[features]
actix = ["dep:actix-web", "dep:http0_2"]
actix = ["dep:actix-web", "dep:leptos_actix", "dep:http0_2"]
axum = ["dep:leptos_axum", "dep:http1"]
docs = []
math = ["num"]

View file

@ -7,24 +7,24 @@ edition = "2021"
crate-type = ["cdylib", "rlib"]
[dependencies]
axum = { version = "0.6.4", optional = true }
axum = { version = "0.7", optional = true }
console_error_panic_hook = "0.1"
console_log = "1"
cfg-if = "1"
leptos = { version = "0.6", features = ["nightly"] }
leptos_axum = { version = "0.5", optional = true }
leptos_meta = { version = "0.5", features = ["nightly"] }
leptos_router = { version = "0.5", features = ["nightly"] }
leptos_axum = { version = "0.6", optional = true }
leptos_meta = { version = "0.6", features = ["nightly"] }
leptos_router = { version = "0.6", features = ["nightly"] }
leptos-use = { path = "../.." }
log = "0.4"
simple_logger = "4"
tokio = { version = "1.25.0", optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.4.3", features = ["fs"], optional = true }
tokio = { version = "1", features = ["full"], optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
wasm-bindgen = "0.2.88"
thiserror = "1.0.38"
tracing = { version = "0.1.37", optional = true }
http = "0.2.8"
http = "1"
[features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
@ -38,7 +38,8 @@ ssr = [
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:tracing",
"leptos-use/ssr"
"leptos-use/ssr",
"leptos-use/axum",
]
[package.metadata.cargo-all-features]

View file

@ -3,11 +3,12 @@ use leptos::ev::{keypress, KeyboardEvent};
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use leptos_use::storage::{use_local_storage, StringCodec};
use leptos_use::storage::use_local_storage;
use leptos_use::utils::FromToStringCodec;
use leptos_use::{
use_color_mode, use_debounce_fn, use_event_listener, use_interval, use_intl_number_format,
use_preferred_dark, use_timestamp, use_window, ColorMode, UseColorModeReturn,
UseIntervalReturn, UseIntlNumberFormatOptions,
use_color_mode, use_cookie_with_options, use_debounce_fn, use_event_listener, use_interval,
use_intl_number_format, use_preferred_dark, use_timestamp, use_window, ColorMode,
UseColorModeReturn, UseCookieOptions, UseIntervalReturn, UseIntlNumberFormatOptions,
};
#[component]
@ -15,6 +16,11 @@ pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
#[cfg(feature = "ssr")]
{
expect_context::<http::request::Parts>();
}
view! {
<Stylesheet id="leptos" href="/pkg/start-axum.css"/>
@ -38,7 +44,7 @@ pub fn App() -> impl IntoView {
#[component]
fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
let (count, set_count, _) = use_local_storage::<i32, StringCodec>("count-state");
let (count, set_count, _) = use_local_storage::<i32, FromToStringCodec>("count-state");
let on_click = move |_| set_count.update(|count| *count += 1);
let nf = use_intl_number_format(
@ -70,6 +76,13 @@ fn HomePage() -> impl IntoView {
let is_dark_preferred = use_preferred_dark();
let (session_cookie, _) = use_cookie_with_options::<String, FromToStringCodec>(
"session",
UseCookieOptions::<String, _>::default()
.max_age(3600)
.default_value(Some("Bogus session string".to_owned())),
);
view! {
<h1>Leptos-Use SSR Example</h1>
<button on:click=on_click>Click Me: {count}</button>
@ -83,6 +96,7 @@ fn HomePage() -> impl IntoView {
<p>{timestamp}</p>
<p>Dark preferred: {is_dark_preferred}</p>
<LocalStorageTest/>
<p>Session cookie: {session_cookie}</p>
}
}
@ -90,7 +104,7 @@ fn HomePage() -> impl IntoView {
pub fn LocalStorageTest() -> impl IntoView {
let UseIntervalReturn { counter, .. } = use_interval(1000);
logging::log!("test log");
let (state, set_state, ..) = use_local_storage::<String, StringCodec>("test-state");
let (state, set_state, ..) = use_local_storage::<String, FromToStringCodec>("test-state");
view! {
<p>{counter}</p>

View file

@ -2,7 +2,7 @@ use cfg_if::cfg_if;
cfg_if! { if #[cfg(feature = "ssr")] {
use axum::{
body::{boxed, Body, BoxBody},
body::Body,
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
@ -25,12 +25,12 @@ cfg_if! { if #[cfg(feature = "ssr")] {
}
}
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<BoxBody>, (StatusCode, String)> {
async fn get_static_file(uri: Uri, root: &str) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.map(boxed)),
Ok(res) => Ok(res.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),

View file

@ -27,11 +27,9 @@ async fn main() {
.fallback(file_and_error_handler)
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
log!("listening on http://{}", &addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}

View file

@ -9,6 +9,8 @@ console_error_panic_hook = "0.1"
console_log = "1"
log = "0.4"
leptos-use = { path = "../..", features = ["docs"] }
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }
web-sys = "0.3"
[dev-dependencies]

View file

@ -1,15 +1,27 @@
use leptos::*;
use leptos_use::docs::demo_or_body;
use leptos_use::use_cookie;
use leptos_use::utils::FromToStringCodec;
use rand::prelude::*;
#[component]
fn Demo() -> impl IntoView {
if let Some(cookie) = use_cookie("auth") {
view! { <div>"'auth' cookie set to " <code>"`" {cookie.value().to_string()} "`"</code></div> }
.into_view()
} else {
view! { <div>"No 'auth' cookie set"</div> }
.into_view()
let (counter, set_counter) = use_cookie::<u32, FromToStringCodec>("counter");
let reset = move || set_counter(Some(random()));
if counter().is_none() {
reset();
}
let increase = move || {
set_counter(counter().map(|c| c + 1));
};
view! {
<p>Counter: {move || counter().map(|c| c.to_string()).unwrap_or("".to_string())}</p>
<button on:click=move |_| reset()>Reset</button>
<button on:click=move |_| increase()>+</button>
}
}

View file

@ -31,7 +31,7 @@ pub fn use_local_storage_with_options<T, C>(
) -> (Signal<T>, WriteSignal<T>, impl Fn() + Clone)
where
T: Clone + PartialEq,
C: StringCodec<T>,
C: StringCodec<T> + Default,
{
use_storage_with_options(StorageType::Local, key, options)
}

View file

@ -31,7 +31,7 @@ pub fn use_session_storage_with_options<T, C>(
) -> (Signal<T>, WriteSignal<T>, impl Fn() + Clone)
where
T: Clone + PartialEq,
C: StringCodec<T>,
C: StringCodec<T> + Default,
{
use_storage_with_options(StorageType::Session, key, options)
}

View file

@ -1,4 +1,3 @@
use crate::utils::FromToStringCodec;
use crate::{
core::{MaybeRwSignal, StorageType},
utils::{FilterOptions, StringCodec},
@ -29,7 +28,7 @@ const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage";
///
/// ```
/// # use leptos::*;
/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage_with_options, UseStorageOptions};
/// # use leptos_use::storage::{StorageType, use_local_storage, use_session_storage, use_storage};
/// # use serde::{Deserialize, Serialize};
/// # use leptos_use::utils::{FromToStringCodec, JsonCodec, ProstCodec};
/// #
@ -46,7 +45,7 @@ const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage";
/// let (count, set_count, _) = use_session_storage::<i32, JsonCodec>("my-count-kept-in-js");
///
/// // Bind string with SessionStorage stored in ProtoBuf format:
/// let (id, set_id, _) = use_storage_with::<String, ProstCodec>(
/// let (id, set_id, _) = use_storage::<String, ProstCodec>(
/// StorageType::Session,
/// "my-id",
/// );
@ -76,15 +75,15 @@ const INTERNAL_STORAGE_EVENT: &str = "leptos-use-storage";
///
/// All you need to do is to implement the [`StringCodec`] trait together with `Default` and `Clone`.
#[inline(always)]
pub fn use_storage(
pub fn use_storage<T, C>(
storage_type: StorageType,
key: impl AsRef<str>,
) -> (Signal<String>, WriteSignal<String>, impl Fn() + Clone) {
use_storage_with_options::<String, FromToStringCodec>(
storage_type,
key,
UseStorageOptions::default(),
)
) -> (Signal<T>, WriteSignal<T>, impl Fn() + Clone)
where
T: Default + Clone + PartialEq,
C: StringCodec<T> + Default,
{
use_storage_with_options::<T, C>(storage_type, key, UseStorageOptions::default())
}
/// Version of [`use_storage`] that accepts [`UseStorageOptions`].
@ -95,7 +94,7 @@ pub fn use_storage_with_options<T, C>(
) -> (Signal<T>, WriteSignal<T>, impl Fn() + Clone)
where
T: Clone + PartialEq,
C: StringCodec<T>,
C: StringCodec<T> + Default,
{
let UseStorageOptions {
codec,

View file

@ -65,6 +65,10 @@ use wasm_bindgen::JsValue;
/// # view! { }
/// # }
/// ```
///
/// ## Create Your Own Custom Codec
///
/// All you need to do is to implement the [`StringCodec`] trait together with `Default` and `Clone`.
pub fn use_broadcast_channel<T, C>(
name: &str,
) -> UseBroadcastChannelReturn<T, impl Fn(&T) + Clone, impl Fn() + Clone, C::Error>

View file

@ -1,7 +1,14 @@
use cookie::Cookie;
use default_struct_builder::DefaultBuilder;
#![allow(clippy::too_many_arguments)]
/// Get a cookie by name, for both SSR and CSR
use crate::core::now;
use crate::utils::StringCodec;
use cookie::time::{Duration, OffsetDateTime};
use cookie::{Cookie, CookieJar, SameSite};
use default_struct_builder::DefaultBuilder;
use leptos::*;
use std::rc::Rc;
/// SSR-friendly and reactive cookie access.
///
/// ## Demo
///
@ -9,34 +16,69 @@ use default_struct_builder::DefaultBuilder;
///
/// ## Usage
///
/// This provides you with the cookie that has been set. For more details on how to use the cookie provided, refer: https://docs.rs/cookie/0.18/cookie/struct.Cookie.html
/// The example below creates a cookie called `counter`. If the cookie doesn't exist, it is initially set to a random value.
/// Whenever we update the `counter` variable, the cookie will be updated accordingly.
///
/// ```
/// # use leptos::*;
/// # use leptos_use::use_cookie;
/// # use leptos_use::utils::FromToStringCodec;
/// # use rand::prelude::*;
///
/// #
/// # #[component]
/// # fn Demo() -> impl IntoView {
/// if let Some(cookie) = use_cookie("auth") {
/// view! {
/// <div>
/// format!("'auth' cookie set to `{}`", cookie.value())
/// </div>
/// }.into_view()
/// } else {
/// view! {
/// <div>
/// "No 'auth' cookie set"
/// </div>
/// }.into_view()
/// let (counter, set_counter) = use_cookie::<u32, FromToStringCodec>("counter");
///
/// let reset = move || set_counter.set(Some(random()));
///
/// if counter.get().is_none() {
/// reset();
/// }
///
/// let increase = move || {
/// set_counter.set(counter.get().map(|c| c + 1));
/// };
///
/// view! {
/// <p>Counter: {move || counter.get().map(|c| c.to_string()).unwrap_or("—".to_string())}</p>
/// <button on:click=move |_| reset()>Reset</button>
/// <button on:click=move |_| increase()>+</button>
/// }
/// # }
/// ```
///
/// See [`StringCodec`] for details on how to handle versioning — dealing with data that can outlast your code.
///
/// ## Cookie attributes
///
/// As part of the options when you use `use_cookie_with_options` you can specify cookie attributes.
///
/// ```
/// # use cookie::SameSite;
/// # use leptos::*;
/// # use leptos_use::{use_cookie_with_options, UseCookieOptions};
/// # use leptos_use::utils::FromToStringCodec;
/// #
/// # #[component]
/// # fn Demo() -> impl IntoView {
/// let (cookie, set_cookie) = use_cookie_with_options::<bool, FromToStringCodec>(
/// "user_info",
/// UseCookieOptions::default()
/// .max_age(3600_000) // one hour
/// .same_site(SameSite::Lax)
/// );
/// #
/// # view! {}
/// # }
/// ```
///
/// ## Server-Side Rendering
///
/// This works equally well on the server or the client.
/// On the server this function gets the cookie from the HTTP request header.
/// On the server this function reads the cookie from the HTTP request header and writes it back into
/// the HTTP response header according to options (if provided).
/// The returned `WriteSignal` will not affect the cookie headers on the server.
///
/// > If you're using `axum` you have to enable the `"axum"` feature in your Cargo.toml.
/// > In case it's `actix-web` enable the feature `"actix"`.
@ -44,62 +86,379 @@ use default_struct_builder::DefaultBuilder;
/// ### Bring your own header
///
/// In case you're neither using Axum nor Actix, or the default implementation is not to your liking,
/// you can provide your own way of reading the cookie header value.
/// you can provide your own way of reading and writing the cookie header value.
///
/// ```
/// # use cookie::Cookie;
/// # use leptos::*;
/// # use serde::{Deserialize, Serialize};
/// # use leptos_use::{use_cookie_with_options, UseCookieOptions};
/// # use leptos_use::utils::JsonCodec;
/// #
/// # #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
/// # pub struct Auth {
/// # pub username: String,
/// # pub token: String,
/// # }
/// #
/// # #[component]
/// # fn Demo() -> impl IntoView {
/// use_cookie_with_options("auth", UseCookieOptions::default().ssr_cookies_header_getter(|| {
/// #[cfg(feature = "ssr")]
/// {
/// "Somehow get the value of the cookie header as a string".to_owned()
/// }
/// }));
/// use_cookie_with_options::<Auth, JsonCodec>(
/// "auth",
/// UseCookieOptions::default()
/// .ssr_cookies_header_getter(|| {
/// #[cfg(feature = "ssr")]
/// {
/// "Somehow get the value of the cookie header as a string".to_owned()
/// }
/// })
/// .ssr_set_cookie(|cookie: &Cookie| {
/// #[cfg(feature = "ssr")]
/// {
/// // somehow insert the Set-Cookie header for this cookie
/// }
/// }),
/// );
/// # view! {}
/// # }
/// ```
pub fn use_cookie(cookie_name: &str) -> Option<Cookie<'static>> {
use_cookie_with_options(cookie_name, UseCookieOptions::default())
///
/// ## Create Your Own Custom Codec
///
/// All you need to do is to implement the [`StringCodec`] trait together with `Default` and `Clone`.
pub fn use_cookie<T, C>(cookie_name: &str) -> (Signal<Option<T>>, WriteSignal<Option<T>>)
where
C: StringCodec<T> + Default + Clone,
T: Clone,
{
use_cookie_with_options::<T, C>(cookie_name, UseCookieOptions::default())
}
/// Version of [`use_cookie`] that takes [`UseCookieOptions`].
pub fn use_cookie_with_options(
pub fn use_cookie_with_options<T, C>(
cookie_name: &str,
options: UseCookieOptions,
) -> Option<Cookie<'static>> {
options: UseCookieOptions<T, C::Error>,
) -> (Signal<Option<T>>, WriteSignal<Option<T>>)
where
C: StringCodec<T> + Default + Clone,
T: Clone,
{
let UseCookieOptions {
max_age,
expires,
http_only,
secure,
domain,
path,
same_site,
ssr_cookies_header_getter,
ssr_set_cookie,
default_value,
readonly,
on_error,
} = options;
let cookies = read_cookies_string(ssr_cookies_header_getter);
let delay = if let Some(max_age) = max_age {
Some(max_age * 1000)
} else {
expires.map(|expires| expires * 1000 - now() as i64)
};
Cookie::split_parse_encoded(cookies)
.filter_map(|cookie| cookie.ok())
.find(|cookie| cookie.name() == cookie_name)
.map(|cookie| cookie.into_owned())
let has_expired = if let Some(delay) = delay {
delay <= 0
} else {
false
};
let (cookie, set_cookie) = create_signal(None::<T>);
let jar = store_value(CookieJar::new());
let codec = C::default();
if !has_expired {
let ssr_cookies_header_getter = Rc::clone(&ssr_cookies_header_getter);
jar.update_value(|jar| {
*jar = load_and_parse_cookie_jar(ssr_cookies_header_getter);
set_cookie.set(
jar.get(cookie_name)
.and_then(|c| {
codec
.decode(c.value().to_string())
.map_err(|err| on_error(err))
.ok()
})
.or(default_value),
);
});
handle_expiration(delay, set_cookie);
} else {
logging::debug_warn!(
"not setting cookie '{}' because it has already expired",
cookie_name
);
}
#[cfg(not(feature = "ssr"))]
{
use crate::{
use_broadcast_channel, watch_pausable, UseBroadcastChannelReturn, WatchPausableReturn,
};
let UseBroadcastChannelReturn { message, post, .. } =
use_broadcast_channel::<Option<String>, OptionStringCodec>(&format!(
"leptos-use:cookies:{cookie_name}"
));
let on_cookie_change = {
let cookie_name = cookie_name.to_owned();
let ssr_cookies_header_getter = Rc::clone(&ssr_cookies_header_getter);
let codec = codec.clone();
let on_error = Rc::clone(&on_error);
let domain = domain.clone();
let path = path.clone();
move || {
if readonly {
return;
}
let value = cookie.with_untracked(|cookie| {
cookie
.as_ref()
.and_then(|cookie| codec.encode(cookie).map_err(|err| on_error(err)).ok())
});
if value
== jar.with_value(|jar| jar.get(&cookie_name).map(|c| c.value().to_owned()))
{
return;
}
jar.update_value(|jar| {
write_client_cookie(
&cookie_name,
&value,
jar,
max_age,
expires,
&domain,
&path,
same_site,
secure,
http_only,
Rc::clone(&ssr_cookies_header_getter),
);
});
post(&value);
}
};
let WatchPausableReturn {
pause,
resume,
stop,
..
} = watch_pausable(move || cookie.get(), {
let on_cookie_change = on_cookie_change.clone();
move |_, _, _| {
on_cookie_change();
}
});
// listen to cookie changes from the broadcast channel
create_effect({
let ssr_cookies_header_getter = Rc::clone(&ssr_cookies_header_getter);
let cookie_name = cookie_name.to_owned();
move |_| {
if let Some(message) = message.get() {
pause();
if let Some(message) = message {
match codec.decode(message.clone()) {
Ok(value) => {
let ssr_cookies_header_getter =
Rc::clone(&ssr_cookies_header_getter);
jar.update_value(|jar| {
update_client_cookie_jar(
&cookie_name,
&Some(message),
jar,
max_age,
expires,
&domain,
&path,
same_site,
secure,
http_only,
ssr_cookies_header_getter,
);
});
set_cookie.set(Some(value));
}
Err(err) => {
on_error(err);
}
}
} else {
let cookie_name = cookie_name.clone();
jar.update_value(|jar| {
jar.force_remove(cookie_name);
});
set_cookie.set(None);
}
resume();
}
}
});
on_cleanup(move || {
stop();
on_cookie_change();
});
let _ = ssr_set_cookie;
}
#[cfg(feature = "ssr")]
{
if !readonly {
let value = cookie
.with_untracked(|cookie| {
cookie
.as_ref()
.map(|cookie| codec.encode(&cookie).map_err(|err| on_error(err)).ok())
})
.flatten();
jar.update_value(|jar| {
write_server_cookie(
cookie_name,
value,
jar,
max_age,
expires,
domain,
path,
same_site,
secure,
http_only,
ssr_set_cookie,
)
});
}
}
(cookie.into(), set_cookie)
}
/// Options for [`use_cookie_with_options`].
#[derive(DefaultBuilder)]
pub struct UseCookieOptions {
pub struct UseCookieOptions<T, Err> {
/// [`Max-Age` of the cookie](https://tools.ietf.org/html/rfc6265#section-5.2.2) in seconds. The returned signal will turn to `None` after the max age is reached.
/// Default: `None`
///
/// > The [cookie storage model specification](https://tools.ietf.org/html/rfc6265#section-5.3) states
/// > that if both `expires` and `max_age` is set, then `max_age` takes precedence,
/// > but not all clients may obey this, so if both are set, they should point to the same date and time!
///
/// > If neither of `expires` and `max_age` is set, the cookie will be session-only and removed when the user closes their browser.
#[builder(into)]
max_age: Option<i64>,
/// [Expiration date-time of the cookie](https://tools.ietf.org/html/rfc6265#section-5.2.1) as UNIX timestamp in seconds.
/// The signal will turn to `None` after the expiration date-time is reached.
/// Default: `None`
///
/// > The [cookie storage model specification](https://tools.ietf.org/html/rfc6265#section-5.3) states
/// > that if both `expires` and `max_age` is set, then `max_age` takes precedence,
/// > but not all clients may obey this, so if both are set, they should point to the same date and time!
///
/// > If neither of `expires` and `max_age` is set, the cookie will be session-only and removed when the user closes their browser.
#[builder(into)]
expires: Option<i64>,
/// Specifies the [`HttpOnly` cookie attribute](https://tools.ietf.org/html/rfc6265#section-5.2.6).
/// When `true`, the `HttpOnly` attribute is set; otherwise it is not.
/// By default, the `HttpOnly` attribute is not set.
///
/// > Be careful when setting this to `true`, as compliant clients will not allow client-side JavaScript to see the cookie in `document.cookie`.
http_only: bool,
/// Specifies the value for the [`Secure` cookie attribute](https://tools.ietf.org/html/rfc6265#section-5.2.5).
/// When `true`, the `Secure` attribute is set; otherwise it is not.
/// By default, the `Secure` attribute is not set.
///
/// > Be careful when setting this to `true`, as compliant clients will not send the cookie back to the
/// > server in the future if the browser does not have an HTTPS connection. This can lead to hydration errors.
secure: bool,
/// Specifies the value for the [`Domain` cookie attribute](https://tools.ietf.org/html/rfc6265#section-5.2.3).
/// By default, no domain is set, and most clients will consider applying the cookie only to the current domain.
#[builder(into)]
domain: Option<String>,
/// Specifies the value for the [`Path` cookie attribute](https://tools.ietf.org/html/rfc6265#section-5.2.4).
/// By default, the path is considered the ["default path"](https://tools.ietf.org/html/rfc6265#section-5.1.4).
#[builder(into)]
path: Option<String>,
/// Specifies the value for the [`SameSite` cookie attribute](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7).
///
/// - `'Some(SameSite::Lax)'` will set the `SameSite` attribute to `Lax` for lax same-site enforcement.
/// - `'Some(SameSite::None)'` will set the `SameSite` attribute to `None` for an explicit cross-site cookie.
/// - `'Some(SameSite::Strict)'` will set the `SameSite` attribute to `Strict` for strict same-site enforcement.
/// - `None` will not set the `SameSite` attribute (default).
///
/// More information about the different enforcement levels can be found in [the specification](https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7).
#[builder(into)]
same_site: Option<SameSite>,
/// The default cookie value in case the cookie is not set.
/// Defaults to `None`.
default_value: Option<T>,
/// If `true` the returned `WriteSignal` will not affect the actual cookie.
/// Default: `false`
readonly: bool,
/// Getter function to return the string value of the cookie header.
/// When you use one of the features "axum" or "actix" there's a valid default implementation provided.
ssr_cookies_header_getter: Box<dyn Fn() -> String>,
ssr_cookies_header_getter: Rc<dyn Fn() -> String>,
/// Function to add a set cookie header to the response on the server.
/// When you use one of the features "axum" or "actix" there's a valid default implementation provided.
ssr_set_cookie: Rc<dyn Fn(&Cookie)>,
/// Callback for encoding/decoding errors. Defaults to logging the error to the console.
on_error: Rc<dyn Fn(Err)>,
}
impl Default for UseCookieOptions {
impl<T, Err> Default for UseCookieOptions<T, Err> {
#[allow(dead_code)]
fn default() -> Self {
Self {
ssr_cookies_header_getter: Box::new(move || {
max_age: None,
expires: None,
http_only: false,
default_value: None,
readonly: false,
secure: false,
domain: None,
path: None,
same_site: None,
ssr_cookies_header_getter: Rc::new(move || {
#[cfg(feature = "ssr")]
{
#[cfg(any(feature = "axum", feature = "actix"))]
use leptos::expect_context;
#[cfg(all(feature = "actix", feature = "axum"))]
compile_error!("You cannot enable only one of features \"actix\" and \"axum\" at the same time");
@ -142,11 +501,52 @@ impl Default for UseCookieOptions {
#[cfg(not(feature = "ssr"))]
"".to_owned()
}),
ssr_set_cookie: Rc::new(|cookie: &Cookie| {
#[cfg(feature = "ssr")]
{
#[cfg(feature = "actix")]
use leptos_actix::ResponseOptions;
#[cfg(feature = "axum")]
use leptos_axum::ResponseOptions;
#[cfg(feature = "actix")]
const SET_COOKIE: http0_2::HeaderName = http0_2::header::SET_COOKIE;
#[cfg(feature = "axum")]
const SET_COOKIE: http1::HeaderName = http1::header::SET_COOKIE;
#[cfg(feature = "actix")]
type HeaderValue = http0_2::HeaderValue;
#[cfg(feature = "axum")]
type HeaderValue = http1::HeaderValue;
#[cfg(all(not(feature = "axum"), not(feature = "actix")))]
{
let _ = cookie;
leptos::logging::warn!("If you're using use_cookie without the feature `axum` or `actix` enabled, you should provide the option `ssr_set_cookie`");
}
#[cfg(any(feature = "axum", feature = "actix"))]
{
let response_options = expect_context::<ResponseOptions>();
if let Ok(header_value) =
HeaderValue::from_str(&cookie.encoded().to_string())
{
response_options.insert_header(SET_COOKIE, header_value);
}
}
}
let _ = cookie;
}),
on_error: Rc::new(|_| {
logging::error!("cookie (de-/)serialization error");
}),
}
}
}
fn read_cookies_string(ssr_cookies_header_getter: Box<dyn Fn() -> String>) -> String {
fn read_cookies_string(ssr_cookies_header_getter: Rc<dyn Fn() -> String>) -> String {
let cookies;
#[cfg(feature = "ssr")]
@ -167,3 +567,255 @@ fn read_cookies_string(ssr_cookies_header_getter: Box<dyn Fn() -> String>) -> St
cookies
}
fn handle_expiration<T>(delay: Option<i64>, set_cookie: WriteSignal<Option<T>>) {
if let Some(delay) = delay {
#[cfg(not(feature = "ssr"))]
{
use leptos::leptos_dom::helpers::TimeoutHandle;
use std::cell::Cell;
use std::cell::RefCell;
// The maximum value allowed on a timeout delay.
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value
const MAX_TIMEOUT_DELAY: i64 = 2_147_483_647;
let timeout = Rc::new(Cell::new(None::<TimeoutHandle>));
let elapsed = Rc::new(Cell::new(0));
on_cleanup({
let timeout = Rc::clone(&timeout);
move || {
if let Some(timeout) = timeout.take() {
timeout.clear();
}
}
});
let create_expiration_timeout = Rc::new(RefCell::new(None::<Box<dyn Fn()>>));
create_expiration_timeout.replace(Some(Box::new({
let timeout = Rc::clone(&timeout);
let elapsed = Rc::clone(&elapsed);
let create_expiration_timeout = Rc::clone(&create_expiration_timeout);
move || {
if let Some(timeout) = timeout.take() {
timeout.clear();
}
let time_remaining = delay - elapsed.get();
let timeout_length = time_remaining.min(MAX_TIMEOUT_DELAY);
let elapsed = Rc::clone(&elapsed);
let create_expiration_timeout = Rc::clone(&create_expiration_timeout);
timeout.set(
set_timeout_with_handle(
move || {
elapsed.set(elapsed.get() + timeout_length);
if elapsed.get() < delay {
if let Some(create_expiration_timeout) =
&*create_expiration_timeout.borrow()
{
create_expiration_timeout();
}
return;
}
set_cookie.set(None);
},
std::time::Duration::from_millis(timeout_length as u64),
)
.ok(),
);
}
})));
if let Some(create_expiration_timeout) = &*create_expiration_timeout.borrow() {
create_expiration_timeout();
};
}
#[cfg(feature = "ssr")]
{
let _ = set_cookie;
let _ = delay;
}
}
}
#[cfg(not(feature = "ssr"))]
fn write_client_cookie(
name: &str,
value: &Option<String>,
jar: &mut CookieJar,
max_age: Option<i64>,
expires: Option<i64>,
domain: &Option<String>,
path: &Option<String>,
same_site: Option<SameSite>,
secure: bool,
http_only: bool,
ssr_cookies_header_getter: Rc<dyn Fn() -> String>,
) {
use wasm_bindgen::JsCast;
update_client_cookie_jar(
name,
value,
jar,
max_age,
expires,
domain,
path,
same_site,
secure,
http_only,
ssr_cookies_header_getter,
);
let document = document();
let document: &web_sys::HtmlDocument = document.unchecked_ref();
document.set_cookie(&cookie_jar_to_string(jar)).ok();
}
#[cfg(not(feature = "ssr"))]
fn update_client_cookie_jar(
name: &str,
value: &Option<String>,
jar: &mut CookieJar,
max_age: Option<i64>,
expires: Option<i64>,
domain: &Option<String>,
path: &Option<String>,
same_site: Option<SameSite>,
secure: bool,
http_only: bool,
ssr_cookies_header_getter: Rc<dyn Fn() -> String>,
) {
*jar = load_and_parse_cookie_jar(ssr_cookies_header_getter);
if let Some(value) = value {
let cookie = build_cookie_from_options(
name, max_age, expires, http_only, secure, path, same_site, domain, value,
);
jar.add_original(cookie);
} else {
jar.force_remove(name);
}
}
#[cfg(not(feature = "ssr"))]
fn cookie_jar_to_string(jar: &CookieJar) -> String {
jar.iter()
.map(|c| c.encoded().to_string())
.collect::<Vec<_>>()
.join("; ")
}
fn build_cookie_from_options(
name: &str,
max_age: Option<i64>,
expires: Option<i64>,
http_only: bool,
secure: bool,
path: &Option<String>,
same_site: Option<SameSite>,
domain: &Option<String>,
value: &str,
) -> Cookie<'static> {
let mut cookie = Cookie::build((name, value));
if let Some(max_age) = max_age {
cookie = cookie.max_age(Duration::milliseconds(max_age));
}
if let Some(expires) = expires {
match OffsetDateTime::from_unix_timestamp(expires) {
Ok(expires) => {
cookie = cookie.expires(expires);
}
Err(err) => {
logging::debug_warn!("failed to set cookie expiration: {:?}", err);
}
}
}
if http_only {
cookie = cookie.http_only(true);
}
if secure {
cookie = cookie.secure(true);
}
if let Some(domain) = domain {
cookie = cookie.domain(domain);
}
if let Some(path) = path {
cookie = cookie.path(path);
}
if let Some(same_site) = same_site {
cookie = cookie.same_site(same_site);
}
let cookie: Cookie = cookie.into();
cookie.into_owned()
}
#[cfg(feature = "ssr")]
fn write_server_cookie(
name: &str,
value: Option<String>,
jar: &mut CookieJar,
max_age: Option<i64>,
expires: Option<i64>,
domain: Option<String>,
path: Option<String>,
same_site: Option<SameSite>,
secure: bool,
http_only: bool,
ssr_set_cookie: Rc<dyn Fn(&Cookie)>,
) {
if let Some(value) = value {
let cookie: Cookie = build_cookie_from_options(
name, max_age, expires, http_only, secure, &path, same_site, &domain, &value,
)
.into();
jar.add(cookie.into_owned());
} else {
jar.remove(name.to_owned());
}
for cookie in jar.delta() {
ssr_set_cookie(cookie);
}
}
fn load_and_parse_cookie_jar(ssr_cookies_header_getter: Rc<dyn Fn() -> String>) -> CookieJar {
let mut jar = CookieJar::new();
let cookies = read_cookies_string(ssr_cookies_header_getter);
for cookie in Cookie::split_parse_encoded(cookies).flatten() {
jar.add_original(cookie);
}
jar
}
#[derive(Default, Copy, Clone)]
struct OptionStringCodec;
impl StringCodec<Option<String>> for OptionStringCodec {
type Error = ();
fn encode(&self, val: &Option<String>) -> Result<String, Self::Error> {
match val {
Some(val) => Ok(format!("~<|Some|>~{val}")),
None => Ok("~<|None|>~".to_owned()),
}
}
fn decode(&self, str: String) -> Result<Option<String>, Self::Error> {
Ok(str.strip_prefix("~<|Some|>~").map(|v| v.to_owned()))
}
}