feat: support SSR

This commit is contained in:
luoxiao 2024-07-29 22:49:01 +08:00
parent 68659411b9
commit 66791d88f2
13 changed files with 74 additions and 326 deletions

View file

@ -23,7 +23,7 @@ pub fn SwitchVersion() -> impl IntoView {
}
});
} else {
let version = RwSignal::new(None::<String>);
// let version = RwSignal::new(None::<String>);
}
}

View file

@ -9,28 +9,23 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
axum = { version = "0.7.4", optional = true }
console_error_panic_hook = "0.1"
console_log = "1"
cfg-if = "1"
leptos = { version = "0.6.10" }
leptos_axum = { version = "0.6.10", optional = true }
leptos_meta = { version = "0.6.10" }
leptos_router = { version = "0.6.10" }
log = "0.4"
simple_logger = "4"
tokio = { version = "1.35.1", features = ["rt-multi-thread"], optional = true }
tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.5.1", features = ["fs"], optional = true }
leptos = { version = "0.7.0-beta" }
leptos_axum = { version = "0.7.0-beta", optional = true }
leptos_meta = { version = "0.7.0-beta" }
leptos_router = { version = "0.7.0-beta" }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
wasm-bindgen = "=0.2.92"
thiserror = "1.0.56"
tracing = { version = "0.1.40", optional = true }
http = "0.2.8"
thiserror = "1"
tracing = { version = "0.1", optional = true }
http = "1"
demo = { path = "../../demo", default-features = false }
[features]
hydrate = [
"leptos/hydrate",
"leptos_meta/hydrate",
"leptos_router/hydrate",
"demo/hydrate",
]
ssr = [

View file

@ -1,74 +0,0 @@
{
"name": "end2end",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "end2end",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.28.0"
}
},
"node_modules/@playwright/test": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz",
"integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==",
"dev": true,
"dependencies": {
"@types/node": "*",
"playwright-core": "1.28.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@types/node": {
"version": "18.11.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz",
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==",
"dev": true
},
"node_modules/playwright-core": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz",
"integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==",
"dev": true,
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
}
}
},
"dependencies": {
"@playwright/test": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz",
"integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==",
"dev": true,
"requires": {
"@types/node": "*",
"playwright-core": "1.28.0"
}
},
"@types/node": {
"version": "18.11.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz",
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==",
"dev": true
},
"playwright-core": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz",
"integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==",
"dev": true
}
}
}

View file

@ -1,13 +0,0 @@
{
"name": "end2end",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.28.0"
}
}

View file

@ -1,107 +0,0 @@
import type { PlaywrightTestConfig } from "@playwright/test";
import { devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: "./tests",
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
},
{
name: "webkit",
use: {
...devices["Desktop Safari"],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
};
export default config;

View file

@ -1,9 +0,0 @@
import { test, expect } from "@playwright/test";
test("homepage has title and links to intro page", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page).toHaveTitle("Welcome to Leptos");
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
});

View file

@ -0,0 +1,21 @@
pub use demo::App;
use leptos::prelude::*;
use leptos_meta::MetaTags;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone() />
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}

View file

@ -1,42 +0,0 @@
use axum::{
body::Body,
extract::State,
http::{Request, Response, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
};
use demo::App;
use leptos::{view, LeptosOptions};
use tower::ServiceExt;
use tower_http::services::ServeDir;
pub async fn file_and_error_handler(
uri: Uri,
State(options): State<LeptosOptions>,
req: Request<Body>,
) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler = leptos_axum::render_app_to_stream(options.to_owned(), move || view! {<App/>});
handler(req).await.into_response()
}
}
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.into_response()),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
}

View file

@ -1,14 +1,9 @@
#[cfg(feature = "ssr")]
pub mod fileserv;
pub mod app;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use demo::App;
// initializes logging using the `log` crate
_ = console_log::init_with_level(log::Level::Debug);
use crate::app::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
leptos::mount::hydrate_body(App);
}

View file

@ -1,34 +1,28 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::{routing::post, Router};
use demo::App;
use leptos::*;
use axum::Router;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use ssr_axum::fileserv::file_and_error_handler;
use ssr_axum::app::*;
simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging");
// Setting get_configuration(None) means we'll be using cargo-leptos's env values
// For deployment these variables are:
// <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain>
// Alternately a file can be specified such as Some("Cargo.toml")
// The file would need to be included with the executable when moved to deployment
let conf = get_configuration(None).await.unwrap();
let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr;
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
// build our application with a route
let app = Router::new()
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))
.leptos_routes(&leptos_options, routes, App)
.fallback(file_and_error_handler)
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
log::info!("listening on http://{}", &addr);
log!("listening on http://{}", &addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app.into_make_service())
.await

View file

@ -1,7 +1,5 @@
#[cfg(not(feature = "ssr"))]
use leptos::prelude::RenderEffect;
use leptos::{
prelude::{MaybeProp, Memo, Oco, RwSignal},
prelude::{MaybeProp, Memo, Oco, RenderEffect, RwSignal},
reactive_graph::traits::{Get, Update, With, WithUntracked},
tachys::renderer::DomRenderer,
};
@ -10,8 +8,11 @@ use std::{collections::HashSet, sync::Arc};
#[derive(Clone, Default)]
pub struct ClassList {
value: RwSignal<HashSet<Oco<'static, str>>>,
#[cfg(not(feature = "ssr"))]
effects_oco: Vec<Arc<RenderEffect<Oco<'static, str>>>>,
#[cfg(not(feature = "ssr"))]
effects_option_oco: Vec<Arc<RenderEffect<Option<Oco<'static, str>>>>>,
#[cfg(not(feature = "ssr"))]
effects_bool: Vec<Arc<RenderEffect<bool>>>,
}
@ -33,7 +34,7 @@ impl ClassList {
#[cfg(feature = "ssr")]
{
let name = f();
self.0.update(|set| {
self.value.update(|set| {
set.insert(name);
});
}
@ -62,7 +63,7 @@ impl ClassList {
#[cfg(feature = "ssr")]
{
if let Some(name) = f() {
self.0.update(|set| {
self.value.update(|set| {
set.insert(name);
});
}
@ -103,11 +104,11 @@ impl ClassList {
#[cfg(feature = "ssr")]
{
let new = f();
self.0.update(|set| {
if new {
if new {
self.value.update(|set| {
set.insert(name);
}
});
});
}
}
#[cfg(not(feature = "ssr"))]
{

View file

@ -3,11 +3,14 @@ use cfg_if::cfg_if;
pub fn mount_style(id: &str, content: &'static str) {
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::html::style;
use leptos_meta::use_head;
let meta = use_head();
let style_el = style().attr("data-thaw-id", id).child(content);
meta.tags.register(format!("leptos-thaw-{id}").into(), style_el.into_any());
use leptos::{tachys::view::Render, view};
use leptos_meta::Style;
let _ = view! {
<Style attr:data-thaw-id=id>
{content}
</Style>
};
} else {
use leptos::prelude::document;
let head = document().head().expect("head no exist");
@ -15,9 +18,6 @@ pub fn mount_style(id: &str, content: &'static str) {
.query_selector(&format!("style[data-thaw-id=\"{id}\"]"))
.expect("query style element error");
#[cfg(feature = "hydrate")]
let _ = leptos::leptos_dom::HydrationCtx::id();
if style.is_some() {
return;
}
@ -35,12 +35,14 @@ pub fn mount_style(id: &str, content: &'static str) {
pub fn mount_dynamic_style<T: Fn() -> String + Send + Sync + 'static>(id: String, f: T) {
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::html::style;
use leptos_meta::use_head;
let meta = use_head();
let content = leptos::untrack(|| f());
let style_el = style().attr("data-thaw-id", id).child(content);
meta.tags.register(format!("leptos-thaw-{id}").into(), style_el.into_any());
use leptos::{tachys::view::Render, view};
use leptos_meta::Style;
let _ = view! {
<Style attr:data-thaw-id=id>
{f()}
</Style>
};
} else {
use leptos::prelude::document;
use send_wrapper::SendWrapper;
@ -58,9 +60,6 @@ pub fn mount_dynamic_style<T: Fn() -> String + Send + Sync + 'static>(id: String
style
});
#[cfg(feature = "hydrate")]
let _ = leptos::leptos_dom::HydrationCtx::id();
let style = SendWrapper::new(style);
leptos::prelude::Effect::new_isomorphic(move |_| {
let content = f();

View file

@ -19,15 +19,3 @@ pub use optional_prop::OptionalProp;
pub use signals::*;
pub use throttle::throttle;
pub use time::now_date;
pub fn with_hydration_off<T>(f: impl FnOnce() -> T) -> T {
#[cfg(feature = "hydrate")]
{
use leptos::leptos_dom::HydrationCtx;
HydrationCtx::with_hydration_off(f)
}
#[cfg(not(feature = "hydrate"))]
{
f()
}
}