#![forbid(unsafe_code)]
#![deny(missing_docs)]
use actix_files::NamedFile;
use actix_http::header::{ACCEPT, HeaderName, HeaderValue, LOCATION, REFERER};
use actix_web::{
dev::{ServiceFactory, ServiceRequest},
http::header,
test,
web::{Data, Payload, ServiceConfig},
*,
};
use futures::{Stream, StreamExt, stream::once};
use http::StatusCode;
use hydration_context::SsrSharedContext;
use leptos::{
IntoView,
config::LeptosOptions,
context::{provide_context, use_context},
hydration::IslandsRouterNavigation,
prelude::expect_context,
reactive::{computed::ScopedFuture, owner::Owner},
};
use leptos_integration_utils::{
BoxedFnOnce, ExtendResponse, PinnedFuture, PinnedStream,
accept_header_includes_html, build_request_url,
};
use leptos_meta::ServerMetaContext;
use leptos_router::{
ExpandOptionals, Method, PathSegment, RouteList, RouteListing, SsrMode,
components::provide_server_redirect,
location::RequestUrl,
static_routes::{RegenerationFn, ResolvedStaticPath, StaticResponse},
};
use lru::LruCache;
use or_poisoned::OrPoisoned;
use send_wrapper::SendWrapper;
use server_fn::{
error::ServerFnErrorErr, redirect::REDIRECT_HEADER,
request::actix::ActixRequest,
};
use std::{
collections::HashSet,
fmt::{Debug, Display},
future::Future,
num::NonZeroUsize,
ops::{Deref, DerefMut},
path::Path,
sync::{Arc, LazyLock, RwLock},
};
#[derive(Debug, Clone, Default)]
pub struct ResponseParts {
pub status: Option<StatusCode>,
pub headers: header::HeaderMap,
}
impl ResponseParts {
pub fn insert_header(
&mut self,
key: header::HeaderName,
value: header::HeaderValue,
) {
self.headers.insert(key, value);
}
pub fn append_header(
&mut self,
key: header::HeaderName,
value: header::HeaderValue,
) {
self.headers.append(key, value);
}
}
#[derive(Debug, Clone)]
pub struct Request(SendWrapper<HttpRequest>);
impl Request {
pub fn new(req: &HttpRequest) -> Self {
Self(SendWrapper::new(req.clone()))
}
pub fn into_inner(self) -> HttpRequest {
self.0.take()
}
}
impl Deref for Request {
type Target = HttpRequest;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Request {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
#[derive(Debug, Clone, Default)]
pub struct ResponseOptions(pub Arc<RwLock<ResponseParts>>);
impl ResponseOptions {
pub fn overwrite(&self, parts: ResponseParts) {
let mut writable = self.0.write().or_poisoned();
*writable = parts
}
pub fn set_status(&self, status: StatusCode) {
let mut writeable = self.0.write().or_poisoned();
let res_parts = &mut *writeable;
res_parts.status = Some(status);
}
pub fn insert_header(
&self,
key: header::HeaderName,
value: header::HeaderValue,
) {
let mut writeable = self.0.write().or_poisoned();
let res_parts = &mut *writeable;
res_parts.headers.insert(key, value);
}
pub fn append_header(
&self,
key: header::HeaderName,
value: header::HeaderValue,
) {
let mut writeable = self.0.write().or_poisoned();
let res_parts = &mut *writeable;
res_parts.headers.append(key, value);
}
}
struct ActixResponse(HttpResponse);
impl ExtendResponse for ActixResponse {
type ResponseOptions = ResponseOptions;
fn from_stream(
stream: impl Stream<Item = String> + Send + 'static,
) -> Self {
ActixResponse(
HttpResponse::Ok()
.content_type("text/html")
.streaming(stream.map(|chunk| {
Ok(web::Bytes::from(chunk)) as Result<web::Bytes>
})),
)
}
fn extend_response(&mut self, res_options: &Self::ResponseOptions) {
let mut res_options = res_options.0.write().or_poisoned();
let headers = self.0.headers_mut();
for (key, value) in std::mem::take(&mut res_options.headers) {
headers.append(key, value);
}
if let Some(status) = res_options.status {
*self.0.status_mut() = status;
}
}
fn set_default_content_type(&mut self, content_type: &str) {
let headers = self.0.headers_mut();
if !headers.contains_key(header::CONTENT_TYPE) {
if let Ok(value) = HeaderValue::from_str(content_type) {
headers.insert(header::CONTENT_TYPE, value);
} else {
#[cfg(feature = "tracing")]
tracing::warn!(
"skipped default Content-Type: {content_type:?} is not a \
valid header value"
);
}
}
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn redirect(path: &str, permanent: bool) {
if let (Some(req), Some(res)) =
(use_context::<Request>(), use_context::<ResponseOptions>())
{
let location = match header::HeaderValue::from_str(path) {
Ok(location) => location,
Err(_) => {
#[cfg(feature = "tracing")]
tracing::warn!(
"redirect() ignored: target is not a valid header value"
);
#[cfg(not(feature = "tracing"))]
eprintln!(
"redirect() ignored: target is not a valid header value"
);
return;
}
};
res.insert_header(header::LOCATION, location);
let accepts_html = req
.headers()
.get(ACCEPT)
.and_then(|v| v.to_str().ok())
.map(accept_header_includes_html)
.unwrap_or(false);
if accepts_html {
let status_code = if permanent {
StatusCode::MOVED_PERMANENTLY
} else {
StatusCode::FOUND
};
res.set_status(status_code);
} else {
res.insert_header(
HeaderName::from_static(REDIRECT_HEADER),
HeaderValue::from_static(""),
);
}
} else {
let msg = "Couldn't retrieve either Parts or ResponseOptions while \
trying to redirect().";
#[cfg(feature = "tracing")]
tracing::warn!("{}", &msg);
#[cfg(not(feature = "tracing"))]
eprintln!("{}", msg);
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn handle_server_fns() -> Route {
handle_server_fns_with_context(|| {})
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn handle_server_fns_with_context(
additional_context: impl Fn() + 'static + Clone + Send,
) -> Route {
web::to(move |req: HttpRequest, payload: Payload| {
let additional_context = additional_context.clone();
async move {
let path = req.path();
let method = req.method();
if let Some(mut service) =
server_fn::actix::get_server_fn_service(path, method)
{
let owner = Owner::new();
owner
.with(|| {
ScopedFuture::new(async move {
provide_context(Request::new(&req));
let res_options = ResponseOptions::default();
provide_context(res_options.clone());
additional_context();
let accepts_html = req
.headers()
.get(ACCEPT)
.and_then(|v| v.to_str().ok())
.map(accept_header_includes_html)
.unwrap_or(false);
let referrer = req.headers().get(REFERER).cloned();
let mut res = ActixResponse(
service
.run(ActixRequest::from((req, payload)))
.await
.take(),
);
if accepts_html && let Some(referrer) = referrer {
let has_location =
res.0.headers().get(LOCATION).is_some();
if !has_location {
*res.0.status_mut() = StatusCode::FOUND;
res.0
.headers_mut()
.insert(LOCATION, referrer);
}
}
{
let mut res_options =
res_options.0.write().or_poisoned();
let headers = res.0.headers_mut();
for location in
res_options.headers.remove(header::LOCATION)
{
headers.insert(header::LOCATION, location);
}
}
res.extend_response(&res_options);
res.0
})
})
.await
} else {
HttpResponse::BadRequest().body(format!(
"Could not find a server function at the route {:?}. \
\n\nIt's likely that either
1. The API prefix you specify in the `#[server]` \
macro doesn't match the prefix at which your server \
function handler is mounted, or \n2. You are on a \
platform that doesn't support automatic server function \
registration and you need to call \
ServerFn::register_explicit() on the server function \
type, somewhere in your `main` function.",
req.path()
))
}
}
})
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream<IV>(
app_fn: impl Fn() -> IV + Clone + Send + 'static,
method: Method,
) -> Route
where
IV: IntoView + 'static,
{
render_app_to_stream_with_context(|| {}, app_fn, method)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream_in_order<IV>(
app_fn: impl Fn() -> IV + Clone + Send + 'static,
method: Method,
) -> Route
where
IV: IntoView + 'static,
{
render_app_to_stream_in_order_with_context(|| {}, app_fn, method)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_async<IV>(
app_fn: impl Fn() -> IV + Clone + Send + 'static,
method: Method,
) -> Route
where
IV: IntoView + 'static,
{
render_app_async_with_context(|| {}, app_fn, method)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream_with_context<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
method: Method,
) -> Route
where
IV: IntoView + 'static,
{
render_app_to_stream_with_context_and_replace_blocks(
additional_context,
app_fn,
method,
false,
)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream_with_context_and_replace_blocks<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
method: Method,
replace_blocks: bool,
) -> Route
where
IV: IntoView + 'static,
{
_ = replace_blocks; handle_response(
method,
additional_context,
app_fn,
|app, chunks, supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "islands-router") {
if supports_ooo {
app.to_html_stream_out_of_order_branching()
} else {
app.to_html_stream_in_order_branching()
}
} else if supports_ooo {
app.to_html_stream_out_of_order()
} else {
app.to_html_stream_in_order()
};
Box::pin(app.chain(chunks())) as PinnedStream<String>
})
},
)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_to_stream_in_order_with_context<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
method: Method,
) -> Route
where
IV: IntoView + 'static,
{
handle_response(
method,
additional_context,
app_fn,
|app, chunks, _supports_ooo| {
Box::pin(async move {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
};
Box::pin(app.chain(chunks())) as PinnedStream<String>
})
},
)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
pub fn render_app_async_with_context<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
method: Method,
) -> Route
where
IV: IntoView + 'static,
{
handle_response(method, additional_context, app_fn, async_stream_builder)
}
fn async_stream_builder<IV>(
app: IV,
chunks: BoxedFnOnce<PinnedStream<String>>,
_supports_ooo: bool,
) -> PinnedFuture<PinnedStream<String>>
where
IV: IntoView + 'static,
{
Box::pin(async move {
let app = if cfg!(feature = "islands-router") {
app.to_html_stream_in_order_branching()
} else {
app.to_html_stream_in_order()
};
let app = app.collect::<String>().await;
let chunks = chunks();
Box::pin(once(async move { app }).chain(chunks)) as PinnedStream<String>
})
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
fn provide_contexts(
req: Request,
meta_context: &ServerMetaContext,
res_options: &ResponseOptions,
) {
provide_context(request_url(&req));
provide_context(meta_context.clone());
provide_context(res_options.clone());
provide_context(req);
provide_server_redirect(redirect);
leptos::nonce::provide_nonce();
}
fn request_url(req: &HttpRequest) -> RequestUrl {
let conn = req.connection_info();
let url = build_request_url(
conn.scheme(),
conn.host(),
req.path(),
req.query_string(),
);
RequestUrl::new(&url)
}
#[allow(clippy::type_complexity)]
fn handle_response<IV>(
method: Method,
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
stream_builder: fn(
IV,
BoxedFnOnce<PinnedStream<String>>,
bool,
) -> PinnedFuture<PinnedStream<String>>,
) -> Route
where
IV: IntoView + 'static,
{
let handler = move |req: HttpRequest| {
let app_fn = app_fn.clone();
let add_context = additional_context.clone();
async move {
let is_island_router_navigation = cfg!(feature = "islands-router")
&& req.headers().get("Islands-Router").is_some();
let res_options = ResponseOptions::default();
let (meta_context, meta_output) = ServerMetaContext::new();
let additional_context = {
let meta_context = meta_context.clone();
let res_options = res_options.clone();
let req = Request::new(&req);
move || {
provide_contexts(req, &meta_context, &res_options);
add_context();
if is_island_router_navigation {
provide_context(IslandsRouterNavigation);
}
}
};
let res = ActixResponse::from_app(
app_fn,
meta_output,
additional_context,
res_options,
stream_builder,
!is_island_router_navigation,
)
.await;
res.0
}
};
match method {
Method::Get => web::route()
.guard(guard::Any(guard::Get()).or(guard::Head()))
.to(handler),
Method::Post => web::post().to(handler),
Method::Put => web::put().to(handler),
Method::Delete => web::delete().to(handler),
Method::Patch => web::patch().to(handler),
}
}
fn unsupported_ssr_mode_route(method: Method, mode: &SsrMode) -> Route {
#[cfg(feature = "tracing")]
tracing::error!(
"unsupported SSR mode {mode:?} for this route; serving 500"
);
#[cfg(not(feature = "tracing"))]
let _ = mode;
let handler = || async {
HttpResponse::InternalServerError()
.body("This rendering mode is not supported.")
};
match method {
Method::Get => web::get().to(handler),
Method::Post => web::post().to(handler),
Method::Put => web::put().to(handler),
Method::Delete => web::delete().to(handler),
Method::Patch => web::patch().to(handler),
}
}
pub fn generate_route_list<IV>(
app_fn: impl Fn() -> IV + 'static + Send + Clone,
) -> Vec<ActixRouteListing>
where
IV: IntoView + 'static,
{
generate_route_list_with_exclusions_and_ssg(app_fn, None).0
}
pub fn generate_route_list_with_ssg<IV>(
app_fn: impl Fn() -> IV + 'static + Send + Clone,
) -> (Vec<ActixRouteListing>, StaticRouteGenerator)
where
IV: IntoView + 'static,
{
generate_route_list_with_exclusions_and_ssg(app_fn, None)
}
pub fn generate_route_list_with_exclusions<IV>(
app_fn: impl Fn() -> IV + 'static + Send + Clone,
excluded_routes: Option<Vec<String>>,
) -> Vec<ActixRouteListing>
where
IV: IntoView + 'static,
{
generate_route_list_with_exclusions_and_ssg(app_fn, excluded_routes).0
}
pub fn generate_route_list_with_exclusions_and_ssg<IV>(
app_fn: impl Fn() -> IV + 'static + Send + Clone,
excluded_routes: Option<Vec<String>>,
) -> (Vec<ActixRouteListing>, StaticRouteGenerator)
where
IV: IntoView + 'static,
{
generate_route_list_with_exclusions_and_ssg_and_context(
app_fn,
excluded_routes,
|| {},
)
}
trait ActixPath {
fn to_actix_path(&self) -> String;
}
impl ActixPath for Vec<PathSegment> {
fn to_actix_path(&self) -> String {
let mut path = String::new();
for segment in self.iter() {
let raw = segment.as_raw_str();
if !raw.is_empty() && !raw.starts_with('/') {
path.push('/');
}
match segment {
PathSegment::Static(s) => path.push_str(s),
PathSegment::Param(s) => {
path.push('{');
path.push_str(s);
path.push('}');
}
PathSegment::Splat(s) => {
path.push('{');
path.push_str(s);
path.push_str(":.*}");
}
PathSegment::Unit => {}
PathSegment::OptionalParam(_) => {
#[cfg(feature = "tracing")]
tracing::error!(
"to_axum_path should only be called on expanded \
paths, which do not have OptionalParam any longer"
);
Default::default()
}
}
}
path
}
}
#[derive(Clone, Debug, Default)]
pub struct ActixRouteListing {
path: String,
mode: SsrMode,
methods: Vec<leptos_router::Method>,
regenerate: Vec<RegenerationFn>,
exclude: bool,
}
trait IntoRouteListing: Sized {
fn into_route_listing(self) -> Vec<ActixRouteListing>;
}
impl IntoRouteListing for RouteListing {
fn into_route_listing(self) -> Vec<ActixRouteListing> {
self.path()
.to_vec()
.expand_optionals()
.into_iter()
.map(|path| {
let path = path.to_actix_path();
let path = if path.is_empty() {
"/".to_string()
} else {
path
};
let mode = self.mode();
let methods = self.methods().collect();
let regenerate = self.regenerate().into();
ActixRouteListing {
path,
mode: mode.clone(),
methods,
regenerate,
exclude: false,
}
})
.collect()
}
}
impl ActixRouteListing {
pub fn new(
path: String,
mode: SsrMode,
methods: impl IntoIterator<Item = leptos_router::Method>,
regenerate: impl Into<Vec<RegenerationFn>>,
) -> Self {
Self {
path,
mode,
methods: methods.into_iter().collect(),
regenerate: regenerate.into(),
exclude: false,
}
}
pub fn path(&self) -> &str {
&self.path
}
pub fn mode(&self) -> SsrMode {
self.mode.clone()
}
pub fn methods(&self) -> impl Iterator<Item = leptos_router::Method> + '_ {
self.methods.iter().copied()
}
}
pub fn generate_route_list_with_exclusions_and_ssg_and_context<IV>(
app_fn: impl Fn() -> IV + 'static + Send + Clone,
excluded_routes: Option<Vec<String>>,
additional_context: impl Fn() + 'static + Send + Clone,
) -> (Vec<ActixRouteListing>, StaticRouteGenerator)
where
IV: IntoView + 'static,
{
let _ = any_spawner::Executor::init_tokio();
let owner = Owner::new_root(Some(Arc::new(SsrSharedContext::new())));
let (mock_meta, _) = ServerMetaContext::new();
let routes = owner
.with(|| {
provide_context(RequestUrl::new(""));
provide_context(ResponseOptions::default());
provide_context(mock_meta);
additional_context();
RouteList::generate(&app_fn)
})
.unwrap_or_default();
let generator = StaticRouteGenerator::new(
&routes,
app_fn.clone(),
additional_context.clone(),
);
let mut routes = routes
.into_inner()
.into_iter()
.flat_map(IntoRouteListing::into_route_listing)
.collect::<Vec<_>>();
let routes = if routes.is_empty() {
vec![ActixRouteListing::new(
"/".to_string(),
Default::default(),
[leptos_router::Method::Get],
vec![],
)]
} else {
if let Some(excluded_routes) = &excluded_routes {
routes.retain(|p| !excluded_routes.iter().any(|e| e == p.path()))
}
routes
};
let excluded =
excluded_routes
.into_iter()
.flatten()
.map(|path| ActixRouteListing {
path,
mode: Default::default(),
methods: Vec::new(),
regenerate: Vec::new(),
exclude: true,
});
(routes.into_iter().chain(excluded).collect(), generator)
}
#[allow(clippy::type_complexity)]
pub struct StaticRouteGenerator(
#[allow(dead_code)] Owner,
Box<dyn FnOnce(&LeptosOptions) -> PinnedFuture<()> + Send>,
);
impl StaticRouteGenerator {
fn render_route<IV: IntoView + 'static>(
path: String,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + Clone + Send + 'static,
) -> impl Future<Output = (Owner, String)> {
let (meta_context, meta_output) = ServerMetaContext::new();
let additional_context = {
let add_context = additional_context.clone();
move || {
let mock_req = test::TestRequest::with_uri(&path)
.insert_header(("Accept", "text/html"))
.to_http_request();
let res_options = ResponseOptions::default();
provide_contexts(
Request::new(&mock_req),
&meta_context,
&res_options,
);
add_context();
}
};
let (owner, stream) = leptos_integration_utils::build_response(
app_fn.clone(),
additional_context,
async_stream_builder,
false,
);
let sc = owner.shared_context().unwrap();
async move {
let stream = stream.await;
while let Some(pending) = sc.await_deferred() {
pending.await;
}
let html = meta_output
.inject_meta_context(stream)
.await
.collect::<String>()
.await;
(owner, html)
}
}
pub fn new<IV>(
routes: &RouteList,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
additional_context: impl Fn() + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static,
{
let owner = Owner::new();
Self(owner.clone(), {
let routes = routes.clone();
Box::new(move |options| {
let options = options.clone();
let app_fn = app_fn.clone();
let additional_context = additional_context.clone();
owner.with(|| {
additional_context();
Box::pin(ScopedFuture::new(routes.generate_static_files(
move |path: &ResolvedStaticPath| {
Self::render_route(
path.to_string(),
app_fn.clone(),
additional_context.clone(),
)
},
move |path: &ResolvedStaticPath,
owner: &Owner,
html: String| {
let options = options.clone();
let path = path.to_owned();
let response_options = owner.with(use_context);
async move {
write_static_route(
&options,
response_options,
path.as_ref(),
&html,
)
.await
}
},
was_404,
)))
})
})
})
}
pub async fn generate(self, options: &LeptosOptions) {
(self.1)(options).await
}
}
const STATIC_HEADERS_DEFAULT_CAPACITY: NonZeroUsize =
match NonZeroUsize::new(1024) {
Some(capacity) => capacity,
None => unreachable!(),
};
const STATIC_HEADERS_CAPACITY_ENV: &str = "LEPTOS_STATIC_HEADERS_CACHE_SIZE";
static STATIC_HEADERS: LazyLock<RwLock<LruCache<String, ResponseParts>>> =
LazyLock::new(|| {
let capacity = std::env::var(STATIC_HEADERS_CAPACITY_ENV)
.ok()
.and_then(|value| value.parse::<usize>().ok())
.and_then(NonZeroUsize::new)
.unwrap_or(STATIC_HEADERS_DEFAULT_CAPACITY);
RwLock::new(LruCache::new(capacity))
});
fn apply_response_parts(res: &mut HttpResponse, parts: &ResponseParts) {
let headers = res.headers_mut();
for (key, value) in &parts.headers {
headers.append(key.clone(), value.clone());
}
if let Some(status) = parts.status {
*res.status_mut() = status;
}
}
fn was_404(owner: &Owner) -> bool {
let resp = owner.with(|| expect_context::<ResponseOptions>());
let status = resp.0.read().or_poisoned().status;
if let Some(status) = status {
return status == StatusCode::NOT_FOUND;
}
false
}
fn static_path(options: &LeptosOptions, path: &str) -> Option<String> {
use leptos_integration_utils::static_file_path;
if path != "/" && path.ends_with("/") {
static_file_path(options, &format!("{path}index"))
} else {
static_file_path(options, path)
}
}
async fn write_static_route(
options: &LeptosOptions,
response_options: Option<ResponseOptions>,
path: &str,
html: &str,
) -> Result<(), std::io::Error> {
use leptos_integration_utils::write_file_atomic;
let Some(file_path) = static_path(options, path) else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"refusing to write static file for a path-traversal request",
));
};
if let Some(options) = response_options {
STATIC_HEADERS
.write()
.or_poisoned()
.put(path.to_string(), options.0.read().or_poisoned().clone());
}
let path = Path::new(&file_path);
write_file_atomic(path, html.as_bytes()).await
}
fn handle_static_route<IV>(
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
regenerate: Vec<RegenerationFn>,
) -> Route
where
IV: IntoView + 'static,
{
let handler = move |req: HttpRequest, data: Data<LeptosOptions>| {
Box::pin({
let app_fn = app_fn.clone();
let additional_context = additional_context.clone();
let regenerate = regenerate.clone();
async move {
let options = data.into_inner();
let orig_path = req.uri().path();
let Some(file_path) = static_path(&options, orig_path) else {
#[cfg(feature = "tracing")]
tracing::warn!(
"rejected static route request with path traversal: \
{orig_path}"
);
return HttpResponse::NotFound().finish();
};
let path = Path::new(&file_path);
let opened = NamedFile::open_async(path).await;
match opened {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let path = ResolvedStaticPath::new(orig_path);
let (owner, static_response) = path
.build(
move |path: &ResolvedStaticPath| {
StaticRouteGenerator::render_route(
path.to_string(),
app_fn.clone(),
additional_context.clone(),
)
},
move |path: &ResolvedStaticPath,
owner: &Owner,
html: String| {
let options = options.clone();
let path = path.to_owned();
let response_options =
owner.with(use_context);
async move {
write_static_route(
&options,
response_options,
path.as_ref(),
&html,
)
.await
}
},
was_404,
regenerate,
)
.await;
let response_options =
owner.with(use_context::<ResponseOptions>);
let html = match static_response {
StaticResponse::Generated(html)
| StaticResponse::Error(html) => html,
};
let mut res = ActixResponse(
HttpResponse::Ok()
.content_type("text/html")
.body(html),
);
if let Some(options) = response_options {
res.extend_response(&options);
}
res.0
}
opened => {
let response_parts = STATIC_HEADERS
.write()
.or_poisoned()
.get(orig_path)
.cloned();
let mut response = match opened {
Ok(file) => file.into_response(&req),
Err(err) => {
#[cfg(feature = "tracing")]
tracing::warn!(
"failed to serve static file {}: {err}",
path.display()
);
#[cfg(not(feature = "tracing"))]
let _ = &err;
HttpResponse::InternalServerError()
.body("Internal Server Error")
}
};
if let Some(parts) = response_parts {
apply_response_parts(&mut response, &parts);
}
response
}
}
}
})
};
web::get().to(handler)
}
pub trait LeptosRoutes {
fn leptos_routes<IV>(
self,
paths: Vec<ActixRouteListing>,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static;
fn leptos_routes_with_context<IV>(
self,
paths: Vec<ActixRouteListing>,
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static;
}
impl<T> LeptosRoutes for actix_web::App<T>
where
T: ServiceFactory<
ServiceRequest,
Config = (),
Error = Error,
InitError = (),
>,
{
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
fn leptos_routes<IV>(
self,
paths: Vec<ActixRouteListing>,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static,
{
self.leptos_routes_with_context(paths, || {}, app_fn)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
fn leptos_routes_with_context<IV>(
self,
paths: Vec<ActixRouteListing>,
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static,
{
let mut router = self;
let excluded = paths
.iter()
.filter(|&p| p.exclude)
.map(|p| p.path.as_str())
.collect::<HashSet<_>>();
for (path, _) in server_fn::actix::server_fn_paths() {
if !excluded.contains(path) {
let additional_context = additional_context.clone();
let handler =
handle_server_fns_with_context(additional_context);
router = router.route(path, handler);
}
}
for listing in paths.iter().filter(|p| !p.exclude) {
let path = listing.path();
let mode = listing.mode();
for method in listing.methods() {
let additional_context = additional_context.clone();
let additional_context_and_method = move || {
provide_context(method);
additional_context();
};
router = if matches!(listing.mode(), SsrMode::Static(_)) {
router.route(
path,
handle_static_route(
additional_context_and_method.clone(),
app_fn.clone(),
listing.regenerate.clone(),
),
)
} else {
router
.route(
path,
match mode {
SsrMode::OutOfOrder => {
render_app_to_stream_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
)
}
SsrMode::PartiallyBlocked => {
render_app_to_stream_with_context_and_replace_blocks(
additional_context_and_method.clone(),
app_fn.clone(),
method,
true,
)
}
SsrMode::InOrder => {
render_app_to_stream_in_order_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
)
}
SsrMode::Async => render_app_async_with_context(
additional_context_and_method.clone(),
app_fn.clone(),
method,
),
ref mode => {
unsupported_ssr_mode_route(method, mode)
}
},
)
};
}
}
router
}
}
impl LeptosRoutes for &mut ServiceConfig {
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
fn leptos_routes<IV>(
self,
paths: Vec<ActixRouteListing>,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static,
{
self.leptos_routes_with_context(paths, || {}, app_fn)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "trace", fields(error), skip_all)
)]
fn leptos_routes_with_context<IV>(
self,
paths: Vec<ActixRouteListing>,
additional_context: impl Fn() + 'static + Clone + Send,
app_fn: impl Fn() -> IV + Clone + Send + 'static,
) -> Self
where
IV: IntoView + 'static,
{
let mut router = self;
let excluded = paths
.iter()
.filter(|&p| p.exclude)
.map(|p| p.path.as_str())
.collect::<HashSet<_>>();
for (path, _) in server_fn::actix::server_fn_paths() {
if !excluded.contains(path) {
let additional_context = additional_context.clone();
let handler =
handle_server_fns_with_context(additional_context);
router = router.route(path, handler);
}
}
for listing in paths.iter().filter(|p| !p.exclude) {
let path = listing.path();
let mode = listing.mode();
for method in listing.methods() {
if matches!(listing.mode(), SsrMode::Static(_)) {
router = router.route(
path,
handle_static_route(
additional_context.clone(),
app_fn.clone(),
listing.regenerate.clone(),
),
)
} else {
router = router.route(
path,
match mode {
SsrMode::OutOfOrder => {
render_app_to_stream_with_context(
additional_context.clone(),
app_fn.clone(),
method,
)
}
SsrMode::PartiallyBlocked => {
render_app_to_stream_with_context_and_replace_blocks(
additional_context.clone(),
app_fn.clone(),
method,
true,
)
}
SsrMode::InOrder => {
render_app_to_stream_in_order_with_context(
additional_context.clone(),
app_fn.clone(),
method,
)
}
SsrMode::Async => render_app_async_with_context(
additional_context.clone(),
app_fn.clone(),
method,
),
ref mode => {
unsupported_ssr_mode_route(method, mode)
}
},
);
}
}
}
router
}
}
pub async fn extract<T>() -> Result<T, ServerFnErrorErr>
where
T: actix_web::FromRequest,
<T as FromRequest>::Error: Display,
{
let req = use_context::<Request>().ok_or_else(|| {
ServerFnErrorErr::ServerError(
"HttpRequest should have been provided via context".to_string(),
)
})?;
SendWrapper::new(async move {
T::extract(&req)
.await
.map_err(|e| ServerFnErrorErr::ServerError(e.to_string()))
})
.await
}
#[cfg(test)]
mod tests {
use super::{
ActixResponse, ExtendResponse, HttpResponse, LOCATION, LeptosOptions,
Method, OrPoisoned, Owner, Request, ResponseOptions, ResponseParts,
STATIC_HEADERS_DEFAULT_CAPACITY, SsrMode, header, provide_context,
redirect, render_app_to_stream_with_context,
unsupported_ssr_mode_route, write_static_route,
};
use actix_web::test::TestRequest;
use lru::LruCache;
#[test]
fn redirect_ignores_invalid_header_value() {
let owner = Owner::new();
let res = ResponseOptions::default();
owner.with(|| {
let http_req = TestRequest::default().to_http_request();
provide_context(Request::new(&http_req));
provide_context(res.clone());
redirect("/login\r\nSet-Cookie: pwned=1", false);
});
let parts = res.0.read().or_poisoned();
assert!(parts.headers.get(LOCATION).is_none());
assert!(parts.status.is_none());
}
#[test]
fn redirect_sets_location_for_valid_target() {
let owner = Owner::new();
let res = ResponseOptions::default();
owner.with(|| {
let http_req = TestRequest::default().to_http_request();
provide_context(Request::new(&http_req));
provide_context(res.clone());
redirect("/dashboard", false);
});
let parts = res.0.read().or_poisoned();
assert_eq!(
parts.headers.get(LOCATION).map(|v| v.as_bytes()),
Some(&b"/dashboard"[..])
);
}
#[test]
fn set_default_content_type_skips_invalid_value() {
let mut res = ActixResponse(HttpResponse::Ok().finish());
res.set_default_content_type("text/html\0bad");
assert!(res.0.headers().get(header::CONTENT_TYPE).is_none());
}
#[test]
fn set_default_content_type_sets_valid_value() {
let mut res = ActixResponse(HttpResponse::Ok().finish());
res.set_default_content_type("text/html; charset=utf-8");
assert_eq!(
res.0
.headers()
.get(header::CONTENT_TYPE)
.map(|v| v.as_bytes()),
Some(&b"text/html; charset=utf-8"[..])
);
}
#[test]
fn static_headers_cache_is_bounded() {
let mut cache: LruCache<String, ResponseParts> =
LruCache::new(STATIC_HEADERS_DEFAULT_CAPACITY);
let capacity = STATIC_HEADERS_DEFAULT_CAPACITY.get();
for i in 0..(capacity + 10) {
cache.put(format!("/post/{i}"), ResponseParts::default());
}
assert_eq!(cache.len(), capacity);
assert!(cache.get(&"/post/0".to_string()).is_none());
assert!(cache.get(&format!("/post/{}", capacity + 9)).is_some());
}
#[actix_web::test]
async fn head_request_reaches_render_handler() {
use actix_web::{App, http::Method as HttpMethod, test, web::Data};
let _ = any_spawner::Executor::init_tokio();
let options = LeptosOptions::builder().output_name("test").build();
let route =
render_app_to_stream_with_context(|| {}, || "hello", Method::Get);
let app = test::init_service(
App::new().app_data(Data::new(options)).route("/", route),
)
.await;
let get = test::TestRequest::get().uri("/").to_request();
let get_status = test::call_service(&app, get).await.status();
let head = TestRequest::default()
.method(HttpMethod::HEAD)
.uri("/")
.to_request();
let head_status = test::call_service(&app, head).await.status();
assert_ne!(head_status, actix_web::http::StatusCode::NOT_FOUND);
assert_eq!(head_status, get_status);
}
#[actix_web::test]
async fn write_static_route_is_atomic() {
let dir = std::env::temp_dir().join(format!(
"leptos_actix_static_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let options = LeptosOptions::builder()
.output_name("test")
.site_root(dir.to_string_lossy().into_owned())
.build();
let html = "<html><body>hello</body></html>";
write_static_route(&options, None, "/page", html)
.await
.unwrap();
let written = std::fs::read_to_string(dir.join("page.html")).unwrap();
assert_eq!(written, html);
let leftovers = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.count();
assert_eq!(leftovers, 0);
std::fs::remove_dir_all(&dir).ok();
}
#[actix_web::test]
async fn unsupported_ssr_mode_serves_500() {
use actix_web::{App, http::StatusCode, test};
let app = test::init_service(App::new().route(
"/",
unsupported_ssr_mode_route(Method::Get, &SsrMode::Async),
))
.await;
let req = test::TestRequest::get().uri("/").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
}