Compare commits

..

2 Commits

Author SHA1 Message Date
Greg Johnston
fce3e775a2 clarify templating 2024-01-26 17:00:26 -05:00
Greg Johnston
866575d49f Update README.md 2024-01-26 13:34:43 -05:00
22 changed files with 115 additions and 367 deletions

View File

@@ -25,22 +25,22 @@ members = [
exclude = ["benchmarks", "examples"]
[workspace.package]
version = "0.6.3"
version = "0.6.0-rc1"
[workspace.dependencies]
leptos = { path = "./leptos", version = "0.6.3" }
leptos_dom = { path = "./leptos_dom", version = "0.6.3" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.6.3" }
leptos_macro = { path = "./leptos_macro", version = "0.6.3" }
leptos_reactive = { path = "./leptos_reactive", version = "0.6.3" }
leptos_server = { path = "./leptos_server", version = "0.6.3" }
server_fn = { path = "./server_fn", version = "0.6.3" }
server_fn_macro = { path = "./server_fn_macro", version = "0.6.3" }
leptos = { path = "./leptos", version = "0.6.0-rc1" }
leptos_dom = { path = "./leptos_dom", version = "0.6.0-rc1" }
leptos_hot_reload = { path = "./leptos_hot_reload", version = "0.6.0-rc1" }
leptos_macro = { path = "./leptos_macro", version = "0.6.0-rc1" }
leptos_reactive = { path = "./leptos_reactive", version = "0.6.0-rc1" }
leptos_server = { path = "./leptos_server", version = "0.6.0-rc1" }
server_fn = { path = "./server_fn", version = "0.6.0-rc1" }
server_fn_macro = { path = "./server_fn_macro", version = "0.6.0-rc1" }
server_fn_macro_default = { path = "./server_fn/server_fn_macro_default", version = "0.6" }
leptos_config = { path = "./leptos_config", version = "0.6.3" }
leptos_router = { path = "./router", version = "0.6.3" }
leptos_meta = { path = "./meta", version = "0.6.3" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.6.3" }
leptos_config = { path = "./leptos_config", version = "0.6.0-rc1" }
leptos_router = { path = "./router", version = "0.6.0-rc1" }
leptos_meta = { path = "./meta", version = "0.6.0-rc1" }
leptos_integration_utils = { path = "./integrations/utils", version = "0.6.0-rc1" }
[profile.release]
codegen-units = 1

View File

@@ -31,9 +31,6 @@ web-sys = { version = "0.3.67", features = ["FileList", "File"] }
strum = { version = "0.25.0", features = ["strum_macros", "derive"] }
notify = { version = "6.1.1", optional = true }
pin-project-lite = "0.2.13"
dashmap = { version = "5.5.3", optional = true }
once_cell = { version = "1.19.0", optional = true }
async-broadcast = { version = "0.6.0", optional = true }
[features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
@@ -46,10 +43,7 @@ ssr = [
"leptos_meta/ssr",
"leptos_router/ssr",
"dep:leptos_axum",
"dep:notify",
"dep:dashmap",
"dep:once_cell",
"dep:async-broadcast"
"dep:notify"
]
[package.metadata.cargo-all-features]

View File

@@ -55,7 +55,6 @@ pub fn HomePage() -> impl IntoView {
<ServerFnArgumentExample/>
<RkyvExample/>
<FileUpload/>
<FileUploadWithProgress/>
<FileWatcher/>
<CustomEncoding/>
}
@@ -332,8 +331,8 @@ pub fn FileUpload() -> impl IntoView {
///
/// On the server, this uses the `multer` crate, which provides a streaming API.
#[server(
input = MultipartFormData,
)]
input = MultipartFormData,
)]
pub async fn file_length(
data: MultipartData,
) -> Result<usize, ServerFnError> {
@@ -391,168 +390,6 @@ pub fn FileUpload() -> impl IntoView {
}
}
/// This component uses server functions to upload a file, while streaming updates on the upload
/// progress.
#[component]
pub fn FileUploadWithProgress() -> impl IntoView {
/// In theory, you could create a single server function which
/// 1) received multipart form data
/// 2) returned a stream that contained updates on the progress
///
/// In reality, browsers do not actually support duplexing requests in this way. In other
/// words, every existing browser actually requires that the request stream be complete before
/// it begins processing the response stream.
///
/// Instead, we can create two separate server functions:
/// 1) one that receives multipart form data and begins processing the upload
/// 2) a second that returns a stream of updates on the progress
///
/// This requires us to store some global state of all the uploads. In a real app, you probably
/// shouldn't do exactly what I'm doing here in the demo. For example, this map just
/// distinguishes between files by filename, not by user.
#[cfg(feature = "ssr")]
mod progress {
use async_broadcast::{broadcast, Receiver, Sender};
use dashmap::DashMap;
use futures::Stream;
use once_cell::sync::Lazy;
struct File {
total: usize,
tx: Sender<usize>,
rx: Receiver<usize>,
}
static FILES: Lazy<DashMap<String, File>> = Lazy::new(DashMap::new);
pub async fn add_chunk(filename: &str, len: usize) {
println!("[{filename}]\tadding {len}");
let mut entry =
FILES.entry(filename.to_string()).or_insert_with(|| {
println!("[{filename}]\tinserting channel");
let (tx, rx) = broadcast(128);
File { total: 0, tx, rx }
});
entry.total += len;
let new_total = entry.total;
// we're about to do an async broadcast, so we don't want to hold a lock across it
let tx = entry.tx.clone();
drop(entry);
// now we send the message and don't have to worry about it
tx.broadcast(new_total)
.await
.expect("couldn't send a message over channel");
}
pub fn for_file(filename: &str) -> impl Stream<Item = usize> {
let entry =
FILES.entry(filename.to_string()).or_insert_with(|| {
println!("[{filename}]\tinserting channel");
let (tx, rx) = broadcast(128);
File { total: 0, tx, rx }
});
entry.rx.clone()
}
}
#[server(
input = MultipartFormData,
)]
pub async fn upload_file(data: MultipartData) -> Result<(), ServerFnError> {
let mut data = data.into_inner().unwrap();
while let Ok(Some(mut field)) = data.next_field().await {
let name =
field.file_name().expect("no filename on field").to_string();
while let Ok(Some(chunk)) = field.chunk().await {
let len = chunk.len();
println!("[{name}]\t{len}");
progress::add_chunk(&name, len).await;
// in a real server function, you'd do something like saving the file here
}
}
Ok(())
}
#[server(output = StreamingText)]
pub async fn file_progress(
filename: String,
) -> Result<TextStream, ServerFnError> {
println!("getting progress on {filename}");
// get the stream of current length for the file
let progress = progress::for_file(&filename);
// separate each number with a newline
// the HTTP response might pack multiple lines of this into a single chunk
// we need some way of dividing them up
let progress = progress.map(|bytes| Ok(format!("{bytes}\n")));
Ok(TextStream::new(progress))
}
let (filename, set_filename) = create_signal(None);
let (max, set_max) = create_signal(None);
let (current, set_current) = create_signal(None);
let on_submit = move |ev: SubmitEvent| {
ev.prevent_default();
let target = ev.target().unwrap().unchecked_into::<HtmlFormElement>();
let form_data = FormData::new_with_form(&target).unwrap();
let file = form_data
.get("file_to_upload")
.unchecked_into::<web_sys::File>();
let filename = file.name();
let size = file.size() as usize;
set_filename(Some(filename.clone()));
set_max(Some(size));
set_current(None::<usize>);
spawn_local(async move {
let mut progress = file_progress(filename)
.await
.expect("couldn't initialize stream")
.into_inner();
while let Some(Ok(len)) = progress.next().await {
// the TextStream from the server function will be a series of `usize` values
// however, the response itself may pack those chunks into a smaller number of
// chunks, each with more text in it
// so we've padded them with newspace, and will split them out here
// each value is the latest total, so we'll just take the last one
let len = len
.split('\n')
.filter(|n| !n.is_empty())
.last()
.expect(
"expected at least one non-empty value from \
newline-delimited rows",
)
.parse::<usize>()
.expect("invalid length");
set_current(Some(len));
}
});
spawn_local(async move {
upload_file(form_data.into())
.await
.expect("couldn't upload file");
});
};
view! {
<h3>File Upload with Progress</h3>
<p>A file upload with progress can be handled with two separate server functions.</p>
<aside>See the doc comment on the component for an explanation.</aside>
<form on:submit=on_submit>
<input type="file" name="file_to_upload"/>
<input type="submit"/>
</form>
{move || filename().map(|filename| view! { <p>Uploading {filename}</p> })}
{move || max().map(|max| view! {
<progress max=max value=move || current().unwrap_or_default()/>
})}
}
}
#[component]
pub fn FileWatcher() -> impl IntoView {
#[server(input = GetUrl, output = StreamingText)]

View File

@@ -1390,13 +1390,15 @@ impl LeptosRoutes for &mut ServiceConfig {
/// Ok(data)
/// }
/// ```
pub async fn extract<T>() -> Result<T, ServerFnError>
pub async fn extract<T, CustErr>() -> Result<T, ServerFnError<CustErr>>
where
T: actix_web::FromRequest,
<T as FromRequest>::Error: Display,
{
let req = use_context::<HttpRequest>().ok_or_else(|| {
ServerFnError::new("HttpRequest should have been provided via context")
ServerFnError::ServerError(
"HttpRequest should have been provided via context".to_string(),
)
})?;
T::extract(&req)

View File

@@ -7,7 +7,7 @@
//! To run in this environment, you need to disable the default feature set and enable
//! the `wasm` feature on `leptos_axum` in your `Cargo.toml`.
//! ```toml
//! leptos_axum = { version = "0.6.0", default-features = false, features = ["wasm"] }
//! leptos_axum = { version = "0.6.0-rc1", default-features = false, features = ["wasm"] }
//! ```
//!
//! ## Features
@@ -55,7 +55,10 @@ use leptos_router::*;
use once_cell::sync::OnceCell;
use parking_lot::RwLock;
use server_fn::redirect::REDIRECT_HEADER;
use std::{fmt::Debug, io, pin::Pin, sync::Arc, thread::available_parallelism};
use std::{
error::Error, fmt::Debug, io, pin::Pin, sync::Arc,
thread::available_parallelism,
};
use tokio_util::task::LocalPoolHandle;
use tracing::Instrument;
@@ -1769,12 +1772,13 @@ fn get_leptos_pool() -> LocalPoolHandle {
/// Ok(query)
/// }
/// ```
pub async fn extract<T>() -> Result<T, ServerFnError>
pub async fn extract<T, CustErr>() -> Result<T, ServerFnError>
where
T: Sized + FromRequestParts<()>,
T::Rejection: Debug,
CustErr: Error + 'static,
{
extract_with_state::<T, ()>(&()).await
extract_with_state::<T, (), CustErr>(&()).await
}
/// A helper to make it easier to use Axum extractors in server functions. This
@@ -1796,14 +1800,18 @@ where
/// Ok(query)
/// }
/// ```
pub async fn extract_with_state<T, S>(state: &S) -> Result<T, ServerFnError>
pub async fn extract_with_state<T, S, CustErr>(
state: &S,
) -> Result<T, ServerFnError>
where
T: Sized + FromRequestParts<S>,
T::Rejection: Debug,
CustErr: Error + 'static,
{
let mut parts = use_context::<Parts>().ok_or_else(|| {
ServerFnError::new(
"should have had Parts provided by the leptos_axum integration",
ServerFnError::ServerError::<CustErr>(
"should have had Parts provided by the leptos_axum integration"
.to_string(),
)
})?;
T::from_request_parts(&mut parts, state)

View File

@@ -915,16 +915,6 @@ pub fn slot(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream {
/// Whatever encoding is provided to `input` should implement `IntoReq` and `FromReq`. Whatever encoding is provided
/// to `output` should implement `IntoRes` and `FromRes`.
///
/// ## Default Values for Parameters
///
/// Individual function parameters can be annotated with `#[server(default)]`, which will pass
/// through `#[serde(default)]`. This is useful for the empty values of arguments with some
/// encodings. The URL encoding, for example, omits a field entirely if it is an empty `Vec<_>`,
/// but this causes a deserialization error: the correct solution is to add `#[server(default)]`.
/// ```rust,ignore
/// pub async fn with_default_value(#[server(default)] values: Vec<u32>) /* etc. */
/// ```
///
/// ## Important Notes
/// - **Server functions must be `async`.** Even if the work being done inside the function body
/// can run synchronously on the server, from the clients perspective it involves an asynchronous

View File

@@ -385,10 +385,7 @@ where
// client
create_render_effect({
let r = Rc::clone(&r);
move |_| {
source.track();
r.load(false, id)
}
move |_| r.load(false, id)
});
Resource {

View File

@@ -281,7 +281,7 @@ impl Runtime {
let source_map = self.node_sources.borrow();
for effect in subs.borrow().iter() {
if let Some(effect_sources) = source_map.get(*effect) {
effect_sources.borrow_mut().swap_remove(&node);
effect_sources.borrow_mut().remove(&node);
}
}
}
@@ -308,7 +308,7 @@ impl Runtime {
let subs = self.node_subscribers.borrow();
for source in sources.borrow().iter() {
if let Some(source) = subs.get(*source) {
source.borrow_mut().swap_remove(&node_id);
source.borrow_mut().remove(&node_id);
}
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_meta"
version = "0.6.3"
version = "0.6.0-rc1"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"

View File

@@ -158,7 +158,7 @@ impl MetaTagsContext {
move || {
let head = document().head().unwrap_throw();
_ = head.remove_child(&el);
els.borrow_mut().swap_remove(&id);
els.borrow_mut().remove(&id);
}
});

View File

@@ -1,6 +1,6 @@
[package]
name = "leptos_router"
version = "0.6.3"
version = "0.6.0-rc1"
edition = "2021"
authors = ["Greg Johnston"]
license = "MIT"

View File

@@ -28,7 +28,7 @@
//! ## Example
//!
//! ```rust
//!
//!
//! use leptos::*;
//! use leptos_router::*;
//!

View File

@@ -1,4 +1,3 @@
edition = "2021"
imports_granularity = "Crate"
max_width = 80
format_strings = true

View File

@@ -2,14 +2,14 @@
name = "server_fn"
version = { workspace = true }
edition = "2021"
authors = ["Greg Johnston", "Ben Wishovich"]
authors = ["Greg Johnston"]
license = "MIT"
repository = "https://github.com/leptos-rs/leptos"
description = "RPC for any web framework."
readme = "../README.md"
[dependencies]
server_fn_macro_default = { workspace = true }
server_fn_macro_default = "0.6.0-rc1"
# used for hashing paths in #[server] macro
const_format = "0.2"
xxhash-rust = { version = "0.8", features = ["const_xxh64"] }
@@ -18,7 +18,7 @@ serde = { version = "1", features = ["derive"] }
send_wrapper = { version = "0.6", features = ["futures"], optional = true }
# registration system
inventory = { version = "0.3", optional = true }
inventory = {version="0.3",optional=true}
dashmap = "5"
once_cell = "1"
@@ -72,11 +72,11 @@ reqwest = { version = "0.11", default-features = false, optional = true, feature
url = "2"
[features]
default = ["json", "cbor"]
default = [ "json", "cbor"]
form-redirects = []
actix = ["ssr", "dep:actix-web", "dep:send_wrapper"]
axum = [
"ssr",
"ssr",
"dep:axum",
"dep:hyper",
"dep:http-body-util",
@@ -109,21 +109,4 @@ all-features = true
# disables some feature combos for testing in CI
[package.metadata.cargo-all-features]
denylist = ["rustls", "default-tls", "form-redirects"]
skip_feature_sets = [
[
"actix",
"axum",
],
[
"browser",
"actix",
],
[
"browser",
"axum",
],
[
"browser",
"reqwest",
],
]
skip_feature_sets = [["actix", "axum"], ["browser", "actix"], ["browser", "axum"], ["browser", "reqwest"]]

View File

@@ -40,7 +40,7 @@ where
{
async fn from_req(req: Request) -> Result<Self, ServerFnError<CustErr>> {
let string_data = req.as_query().unwrap_or_default();
let args = serde_qs::from_str::<Self>(&string_data)
let args = serde_qs::from_str::<Self>(string_data)
.map_err(|e| ServerFnError::Args(e.to_string()))?;
Ok(args)
}

View File

@@ -175,6 +175,7 @@ impl<E> ViaError<E> for WrapError<E> {
/// This means that other error types can easily be converted into it using the
/// `?` operator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ServerFnError<E = NoCustomError> {
/// A user-defined custom error type, which defaults to [`NoCustomError`].
WrappedServerError(E),

View File

@@ -441,7 +441,6 @@ impl<Req, Res> Clone for ServerFnTraitObj<Req, Res> {
}
#[allow(unused)] // used by server integrations
/// A neat little type that stores our trait representations of your server functions
type LazyServerFnMap<Req, Res> =
Lazy<DashMap<&'static str, ServerFnTraitObj<Req, Res>>>;

View File

@@ -37,8 +37,8 @@ impl<CustErr> Req<CustErr> for ActixRequest
where
CustErr: 'static,
{
fn as_query(&self) -> Option<Cow<'_, str>> {
self.0 .0.uri().query().map(|q| q.into())
fn as_query(&self) -> Option<&str> {
self.0 .0.uri().query()
}
fn to_content_type(&self) -> Option<Cow<'_, str>> {

View File

@@ -12,8 +12,8 @@ impl<CustErr> Req<CustErr> for Request<Body>
where
CustErr: 'static,
{
fn as_query(&self) -> Option<Cow<'_, str>> {
self.uri().query().map(|q| q.into())
fn as_query(&self) -> Option<&str> {
self.uri().query()
}
fn to_content_type(&self) -> Option<Cow<'_, str>> {

View File

@@ -78,7 +78,7 @@ where
Self: Sized,
{
/// Returns the query string of the requests URL, starting after the `?`.
fn as_query(&self) -> Option<Cow<'_, str>>;
fn as_query(&self) -> Option<&str>;
/// Returns the `Content-Type` header, if any.
fn to_content_type(&self) -> Option<Cow<'_, str>>;
@@ -116,7 +116,7 @@ impl<CustErr> Req<CustErr> for BrowserMockReq
where
CustErr: 'static,
{
fn as_query(&self) -> Option<Cow<'_, str>> {
fn as_query(&self) -> Option<&str> {
unreachable!()
}

View File

@@ -1,62 +0,0 @@
use crate::{error::ServerFnError, request::Req};
use axum::body::{Body, Bytes};
use futures::{Stream, StreamExt};
use http::{
header::{ACCEPT, CONTENT_TYPE, REFERER},
Request,
};
use http_body_util::BodyExt;
use std::borrow::Cow;
impl<CustErr> Req<CustErr> for IncomingRequest
where
CustErr: 'static,
{
fn as_query(&self) -> Option<&str> {
self.uri().query()
}
fn to_content_type(&self) -> Option<Cow<'_, str>> {
self.headers()
.get(CONTENT_TYPE)
.map(|h| String::from_utf8_lossy(h.as_bytes()))
}
fn accepts(&self) -> Option<Cow<'_, str>> {
self.headers()
.get(ACCEPT)
.map(|h| String::from_utf8_lossy(h.as_bytes()))
}
fn referer(&self) -> Option<Cow<'_, str>> {
self.headers()
.get(REFERER)
.map(|h| String::from_utf8_lossy(h.as_bytes()))
}
async fn try_into_bytes(self) -> Result<Bytes, ServerFnError<CustErr>> {
let (_parts, body) = self.into_parts();
body.collect()
.await
.map(|c| c.to_bytes())
.map_err(|e| ServerFnError::Deserialization(e.to_string()))
}
async fn try_into_string(self) -> Result<String, ServerFnError<CustErr>> {
let bytes = self.try_into_bytes().await?;
String::from_utf8(bytes.to_vec())
.map_err(|e| ServerFnError::Deserialization(e.to_string()))
}
fn try_into_stream(
self,
) -> Result<
impl Stream<Item = Result<Bytes, ServerFnError>> + Send + 'static,
ServerFnError<CustErr>,
> {
Ok(self.into_body().into_data_stream().map(|chunk| {
chunk.map_err(|e| ServerFnError::Deserialization(e.to_string()))
}))
}
}

View File

@@ -55,53 +55,6 @@ pub fn server_macro_impl(
}
});
let fields = body
.inputs
.iter_mut()
.map(|f| {
let typed_arg =
match f {
FnArg::Receiver(_) => return Err(syn::Error::new(
f.span(),
"cannot use receiver types in server function macro",
)),
FnArg::Typed(t) => t,
};
// strip `mut`, which is allowed in fn args but not in struct fields
if let Pat::Ident(ident) = &mut *typed_arg.pat {
ident.mutability = None;
}
// allow #[server(default)] on fields
let mut default = false;
let mut other_attrs = Vec::new();
for attr in typed_arg.attrs.iter() {
if !attr.path().is_ident("server") {
other_attrs.push(attr.clone());
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("default") && meta.input.is_empty() {
default = true;
Ok(())
} else {
Err(meta.error(
"Unrecognized #[server] attribute, expected \
#[server(default)]",
))
}
})?;
}
typed_arg.attrs = other_attrs;
if default {
Ok(quote! { #[serde(default)] pub #typed_arg })
} else {
Ok(quote! { pub #typed_arg })
}
})
.collect::<Result<Vec<_>>>()?;
let dummy = body.to_dummy_output();
let dummy_name = body.to_dummy_ident();
let args = syn::parse::<ServerFnArgs>(args.into())?;
@@ -177,11 +130,60 @@ pub fn server_macro_impl(
};
// build struct for type
let mut body = body;
let fn_name = &body.ident;
let fn_name_as_str = body.ident.to_string();
let vis = body.vis;
let attrs = body.attrs;
let fields = body
.inputs
.iter_mut()
.map(|f| {
let typed_arg = match f {
FnArg::Receiver(_) => {
return Err(syn::Error::new(
f.span(),
"cannot use receiver types in server function macro",
))
}
FnArg::Typed(t) => t,
};
// strip `mut`, which is allowed in fn args but not in struct fields
if let Pat::Ident(ident) = &mut *typed_arg.pat {
ident.mutability = None;
}
// allow #[server(default)] on fields — TODO is this documented?
let mut default = false;
let mut other_attrs = Vec::new();
for attr in typed_arg.attrs.iter() {
if !attr.path().is_ident("server") {
other_attrs.push(attr.clone());
continue;
}
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("default") && meta.input.is_empty() {
default = true;
Ok(())
} else {
Err(meta.error(
"Unrecognized #[server] attribute, expected \
#[server(default)]",
))
}
})?;
}
typed_arg.attrs = other_attrs;
if default {
Ok(quote! { #[serde(default)] pub #typed_arg })
} else {
Ok(quote! { pub #typed_arg })
}
})
.collect::<Result<Vec<_>>>()?;
let fn_args = body
.inputs
.iter()
@@ -635,20 +637,18 @@ fn err_type(return_ty: &Type) -> Result<Option<&GenericArgument>> {
else if let GenericArgument::Type(Type::Path(pat)) =
&args.args[1]
{
if let Some(segment) = pat.path.segments.last() {
if segment.ident == "ServerFnError" {
let args = &pat.path.segments[0].arguments;
match args {
// Result<T, ServerFnError>
PathArguments::None => return Ok(None),
// Result<T, ServerFnError<E>>
PathArguments::AngleBracketed(args) => {
if args.args.len() == 1 {
return Ok(Some(&args.args[0]));
}
if pat.path.segments[0].ident == "ServerFnError" {
let args = &pat.path.segments[0].arguments;
match args {
// Result<T, ServerFnError>
PathArguments::None => return Ok(None),
// Result<T, ServerFnError<E>>
PathArguments::AngleBracketed(args) => {
if args.args.len() == 1 {
return Ok(Some(&args.args[0]));
}
_ => {}
}
_ => {}
}
}
}