diff --git a/README.md b/README.md index b37283e194..aff1c82d20 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ These tasks still need to be done before Perseus can be pushed to v1.0.0. * [ ] Support custom template hierarchies * [ ] Pre-built integrations - [x] Actix Web + - [x] Warp - [ ] AWS Lambda ### Beyond diff --git a/bonnie.toml b/bonnie.toml index 4142c7ab58..6f42e41433 100644 --- a/bonnie.toml +++ b/bonnie.toml @@ -191,7 +191,7 @@ release.cmd = [ "git checkout stable" ] release.desc = "creates a new project release and pushes it to github (cargo version must be manually bumped, needs branch 'stable')" -replace-versions.cmd = "find . \\( -name \"*Cargo.toml\" -or -name \"*Cargo.toml.example\" -or -name \"*.md\" \\) -not -name \"CHANGELOG.md\" -not -path \"./target/*\" -not -path \"./website/*\" -not -path \"*.perseus*\" -exec sed -i -e 's/%old_version/%new_version/g' {} \\;" +replace-versions.cmd = "find . \\( -name \"*Cargo.toml\" -or -name \"*Cargo.toml.example\" -or -name \"*.md\" \\) -not -name \"CHANGELOG.md\" -not -path \"./target/*\" -not -path \"./website/*\" -not -path \"*.perseus*\" -or \\( -name \"*Cargo.toml\" -path \"./examples/basic/.perseus/*\" -not -path \"./examples/basic/.perseus/dist/*\" \\) -exec sed -i -e 's/%old_version/%new_version/g' {} \\;" replace-versions.args = [ "old_version", "new_version" ] # Publishes each package diff --git a/docs/0.3.x/en-US/SUMMARY.md b/docs/0.3.x/en-US/SUMMARY.md index 0c01450bf0..264d957a64 100644 --- a/docs/0.3.x/en-US/SUMMARY.md +++ b/docs/0.3.x/en-US/SUMMARY.md @@ -31,6 +31,7 @@ - [State Amalgamation](/docs/strategies/amalgamation) - [CLI](/docs/cli) - [Ejecting](/docs/ejecting) + - [Snooping](/docs/snooping) - [Testing](/docs/testing/intro) - [Checkpoints](/docs/testing/checkpoints) - [Fantoccini Basics](/docs/testing/fantoccini-basics) @@ -52,6 +53,7 @@ - [Optimizing Code Size](/docs/deploying/size) - [Relative Paths](/docs/deploying/relative-paths) - [Migrating from v0.2.x](/docs/updating) +- [Common Pitfalls and Known Bugs](/docs/pitfalls-and-bugs) --- diff --git a/docs/0.3.x/en-US/advanced/arch.md b/docs/0.3.x/en-US/advanced/arch.md index f6d783fe3b..c77662fe01 100644 --- a/docs/0.3.x/en-US/advanced/arch.md +++ b/docs/0.3.x/en-US/advanced/arch.md @@ -1,9 +1,10 @@ # Architecture -Perseus has five main components: +Perseus has several main components: - `perseus` -- the core module that defines everything necessary to build a Perseus app if you try hard enough - `perseus-actix-web` -- an integration that makes it easy to run Perseus on the [Actix Web](https://actix.rs) framework +- `perseus-warp` -- an integration that makes it easy to run Perseus on the [Warp](https://github.com/seanmonstar/warp) framework - `perseus-cli` -- the command-line interface used to run Perseus apps conveniently - `perseus-engine` -- an internal crate created by the CLI responsible for building an app - `perseus-engine-server` -- an internal crate created by the CLI responsible for serving an app and performing runtime logic diff --git a/docs/0.3.x/en-US/hello-world.md b/docs/0.3.x/en-US/hello-world.md index f7e54c0993..c6a1c53bdf 100644 --- a/docs/0.3.x/en-US/hello-world.md +++ b/docs/0.3.x/en-US/hello-world.md @@ -61,9 +61,7 @@ Finally, we tell Perseus what to do if something in your app fails, like if the -Now, tell Rust that you want to be compiling for WebAssembly by adding that platform as a target. You can do this by running `rustup target add wasm32-unknown-unknown`. - -After that's finished, you can install the Perseus CLI with `cargo install perseus-cli --version 0.3.0-beta.18` (you'll need `wasm-pack` to let Perseus build your app, use `cargo install wasm-pack` to install it) to make your life way easier, and deploy your app to by running `perseus serve` inside the root of your project! This will take a while the first time, because it's got to fetch all your dependencies and build your app. +Now install the Perseus CLI with `cargo install perseus-cli` (you'll need `wasm-pack` to let Perseus build your app, use `cargo install wasm-pack` to install it) to make your life way easier, and deploy your app to by running `perseus serve` inside the root of your project! This will take a while the first time, because it's got to fetch all your dependencies and build your app.
Why do I need a CLI? diff --git a/docs/0.3.x/en-US/pitfalls-and-bugs.md b/docs/0.3.x/en-US/pitfalls-and-bugs.md new file mode 100644 index 0000000000..61b75a4365 --- /dev/null +++ b/docs/0.3.x/en-US/pitfalls-and-bugs.md @@ -0,0 +1,7 @@ +# Common Pitfalls and Known Bugs + +This document is a list of common pitfalls and known bugs in Perseus, and will be updated regularly. If you're having an issue with Perseus, check through this list to see if your problem already has a solution. + +## `perseus serve` fails with no error message on Arch Linux + +If you're running Arch Linux or a derivative (e.g. Manjaro), you're very likely to encounter a bug in which `perseus serve` stops with no error messages whatsoever, and your app doesn't build properly. This is tracked by [issue #78](https://github.com/arctic-hen7/perseus/issues/78), and is due to an issue in OpenSSL that causes a segmentation fault in `wasm-pack` (see [this issue](https://github.com/rustwasm/wasm-pack/issues/1079)). Right now, the only solution to this is to downgrade `wasm-pack` by running `cargo install wasm-pack --version "0.9.1"`, which seems to fix the problem. diff --git a/docs/0.3.x/en-US/snooping.md b/docs/0.3.x/en-US/snooping.md new file mode 100644 index 0000000000..10d35a5262 --- /dev/null +++ b/docs/0.3.x/en-US/snooping.md @@ -0,0 +1,15 @@ +# Snooping on the CLI + +Most of the time, it's fine to run `perseus serve` and enjoy the results, but sometimes you'll need to delve a little deeper and see some more detailed logs. Then you need `perseus snoop`. This command will run one of the lower-level steps the Perseus CLI runs, but in such a way that you can see everything it does. The time you'll use this the most is when you have a `dbg!()` call in the static generation process (e.g. in the *build state* strategy) and you want to actually see its output, which neither `perseus build` nor `perseus serve` will let you do. + +## `perseus snoop build` + +This snoops on the static generation process, which is half of what `perseus build` does. You can use this to see the outputs of `dbg!()` calls in your build-time code. + +## `perseus snoop wasm-build` + +This snoops on the `wasm-pack` call that compiles your app to Wasm. There aren't really any use cases for this outside debugging strange errors, because Perseus calls out to this process without augmenting it in any way, so your code shouldn't impact this really at all (unless you're using some package that can't be compiled to Wasm). + +## `perseus snoop serve` + +This snoops on the server, which is useful if you're hacking on it, or if you're getting errors from it (e.g. panics in the server will only appear if you run this). Crucially though, this expects to be working with a correct build state, which means **you must run `perseus build` before running this command**, otherwise all sorts of things could happen. If such things do happen, you should run `perseus clean --dist`, and that should solve things. diff --git a/docs/0.3.x/en-US/strategies/request-state.md b/docs/0.3.x/en-US/strategies/request-state.md index e8daae7cc7..3eef08344a 100644 --- a/docs/0.3.x/en-US/strategies/request-state.md +++ b/docs/0.3.x/en-US/strategies/request-state.md @@ -17,7 +17,7 @@ Note that, just like _build state_, this strategy generates stringified properti
How do you get the user's request information? -[Actix Web](https://actix.rs) (and any other framework worth its salt) automatically passes this information to handlers like Perseus. The slightly difficult thing is then converting this from Actix's custom format to Perseus' (which is just an alias for the [`http`](https://docs.rs/http) module's). This is done in the [`perseus-actix-web`](https://docs.rs/perseus-actix-web) crate. +The web frameworks Perseus supports automatically pass this information to handlers like Perseus. The slightly difficult thing is then converting this from their custom format to Perseus' (which is just an alias for the [`http`](https://docs.rs/http) module's). This is done in the appropriate integration crate.
diff --git a/docs/0.3.x/en-US/what-is-perseus.md b/docs/0.3.x/en-US/what-is-perseus.md index e01ae0e435..f35be65280 100644 --- a/docs/0.3.x/en-US/what-is-perseus.md +++ b/docs/0.3.x/en-US/what-is-perseus.md @@ -2,7 +2,7 @@ If you're familiar with [NextJS](https://nextjs.org), Perseus is that for Wasm. If you're familiar with [SvelteKit](https://kit.svelte.dev), it's that for [Sycamore](https://github.com/sycamore-rs/sycamore). -If none of that makes any sense, this is the section for you! If you're not in the mood for a lecture, [here's a TL;DR](#summary)! +If none of that makes any sense, this is the section for you! If you're not in the mood for a lecture, there's a TL;DR at the bottom of this page! ### Rust web development @@ -60,7 +60,7 @@ To our knowledge, the only other framework in the world right now that supports ## How fast is it? -[Benchmarks show](https://rawgit.com/krausest/js-framework-benchmark/master/webdriver-ts-results/table.html) that [Sycamore](https://sycamore-rs.netlify.app) is slightly faster than [Svelte](https://svelte.dev) in places, one of the fastest JS frameworks ever. Perseus uses it and [Actix Web](https://actix.rs), one of the fastest web servers in the world. Essentially, Perseus is built on the fastest tech and is itself made to be fast. +[Benchmarks show](https://rawgit.com/krausest/js-framework-benchmark/master/webdriver-ts-results/table.html) that [Sycamore](https://sycamore-rs.netlify.app) is slightly faster than [Svelte](https://svelte.dev) in places, one of the fastest JS frameworks ever. Perseus uses it and [Actix Web](https://actix.rs) or [Warp](https://github.com/seanmonstar/warp) (either is supported), some of the fastest web servers in the world. Essentially, Perseus is built on the fastest tech and is itself made to be fast. The speed of web frameworks is often measured by [Lighthouse](https://developers.google.com/web/tools/lighthouse) scores, which are scores out of 100 (higher is better) that measure a whole host of things, like *total blocking time*, *first contentful paint*, and *time to interactive*. These are then aggregated into a final score and grouped into three brackets: 0-49 (slow), 50-89 (medium), and 90-100 (fast). This website, which is built with Perseus, using [static exporting](:exporting) and [size optimizations](:deploying/size), consistently scores a 100 on desktop and above 90 for mobile. You can see this for yourself [here](https://developers.google.com/speed/pagespeed/insights/?url=https%3A%2F%2Farctic-hen7.github.io%2Fperseus%2Fen-US%2F&tab=desktop) on Google's PageSpeed Insights tool. diff --git a/docs/next/en-US/advanced/arch.md b/docs/next/en-US/advanced/arch.md index f6d783fe3b..c77662fe01 100644 --- a/docs/next/en-US/advanced/arch.md +++ b/docs/next/en-US/advanced/arch.md @@ -1,9 +1,10 @@ # Architecture -Perseus has five main components: +Perseus has several main components: - `perseus` -- the core module that defines everything necessary to build a Perseus app if you try hard enough - `perseus-actix-web` -- an integration that makes it easy to run Perseus on the [Actix Web](https://actix.rs) framework +- `perseus-warp` -- an integration that makes it easy to run Perseus on the [Warp](https://github.com/seanmonstar/warp) framework - `perseus-cli` -- the command-line interface used to run Perseus apps conveniently - `perseus-engine` -- an internal crate created by the CLI responsible for building an app - `perseus-engine-server` -- an internal crate created by the CLI responsible for serving an app and performing runtime logic diff --git a/docs/next/en-US/strategies/request-state.md b/docs/next/en-US/strategies/request-state.md index e8daae7cc7..3eef08344a 100644 --- a/docs/next/en-US/strategies/request-state.md +++ b/docs/next/en-US/strategies/request-state.md @@ -17,7 +17,7 @@ Note that, just like _build state_, this strategy generates stringified properti
How do you get the user's request information? -[Actix Web](https://actix.rs) (and any other framework worth its salt) automatically passes this information to handlers like Perseus. The slightly difficult thing is then converting this from Actix's custom format to Perseus' (which is just an alias for the [`http`](https://docs.rs/http) module's). This is done in the [`perseus-actix-web`](https://docs.rs/perseus-actix-web) crate. +The web frameworks Perseus supports automatically pass this information to handlers like Perseus. The slightly difficult thing is then converting this from their custom format to Perseus' (which is just an alias for the [`http`](https://docs.rs/http) module's). This is done in the appropriate integration crate.
diff --git a/docs/next/en-US/what-is-perseus.md b/docs/next/en-US/what-is-perseus.md index 89bac7100d..f35be65280 100644 --- a/docs/next/en-US/what-is-perseus.md +++ b/docs/next/en-US/what-is-perseus.md @@ -60,7 +60,7 @@ To our knowledge, the only other framework in the world right now that supports ## How fast is it? -[Benchmarks show](https://rawgit.com/krausest/js-framework-benchmark/master/webdriver-ts-results/table.html) that [Sycamore](https://sycamore-rs.netlify.app) is slightly faster than [Svelte](https://svelte.dev) in places, one of the fastest JS frameworks ever. Perseus uses it and [Actix Web](https://actix.rs), one of the fastest web servers in the world. Essentially, Perseus is built on the fastest tech and is itself made to be fast. +[Benchmarks show](https://rawgit.com/krausest/js-framework-benchmark/master/webdriver-ts-results/table.html) that [Sycamore](https://sycamore-rs.netlify.app) is slightly faster than [Svelte](https://svelte.dev) in places, one of the fastest JS frameworks ever. Perseus uses it and [Actix Web](https://actix.rs) or [Warp](https://github.com/seanmonstar/warp) (either is supported), some of the fastest web servers in the world. Essentially, Perseus is built on the fastest tech and is itself made to be fast. The speed of web frameworks is often measured by [Lighthouse](https://developers.google.com/web/tools/lighthouse) scores, which are scores out of 100 (higher is better) that measure a whole host of things, like *total blocking time*, *first contentful paint*, and *time to interactive*. These are then aggregated into a final score and grouped into three brackets: 0-49 (slow), 50-89 (medium), and 90-100 (fast). This website, which is built with Perseus, using [static exporting](:exporting) and [size optimizations](:deploying/size), consistently scores a 100 on desktop and above 90 for mobile. You can see this for yourself [here](https://developers.google.com/speed/pagespeed/insights/?url=https%3A%2F%2Farctic-hen7.github.io%2Fperseus%2Fen-US%2F&tab=desktop) on Google's PageSpeed Insights tool. diff --git a/examples/basic/.perseus/Cargo.toml b/examples/basic/.perseus/Cargo.toml index fa4ec6e3ca..16001d9265 100644 --- a/examples/basic/.perseus/Cargo.toml +++ b/examples/basic/.perseus/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "perseus-engine" -version = "0.3.0-beta.17" +version = "0.3.0-beta.18" edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/basic/.perseus/builder/Cargo.toml b/examples/basic/.perseus/builder/Cargo.toml index 8697344cc4..4c04455f7f 100644 --- a/examples/basic/.perseus/builder/Cargo.toml +++ b/examples/basic/.perseus/builder/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "perseus-engine-builder" -version = "0.3.0-beta.17" +version = "0.3.0-beta.18" edition = "2018" default-run = "perseus-builder" diff --git a/examples/basic/.perseus/server/Cargo.toml b/examples/basic/.perseus/server/Cargo.toml index 16fa96cd62..ef09ec44e9 100644 --- a/examples/basic/.perseus/server/Cargo.toml +++ b/examples/basic/.perseus/server/Cargo.toml @@ -3,14 +3,26 @@ [package] name = "perseus-engine-server" -version = "0.3.0-beta.17" +version = "0.3.0-beta.18" edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] perseus = { path = "../../../../packages/perseus", features = [ "server-side" ] } -perseus-actix-web = { path = "../../../../packages/perseus-actix-web" } +perseus-actix-web = { path = "../../../../packages/perseus-actix-web", optional = true } +perseus-warp = { path = "../../../../packages/perseus-warp", optional = true } perseus-engine = { path = "../" } -actix-web = "3.3" +actix-web = { version = "3.3", optional = true } futures = "0.3" +warp = { version = "0.3", git = "https://github.com/arctic-hen7/warp", branch = "master", optional = true } +# TODO Choose features here +tokio = { version = "1", optional = true, features = [ "macros", "rt-multi-thread" ] } # We don't need this for Actix Web + +# This binary can use any of the server integrations +# Note: because of the way the CLI works, each feature must be an integration +[features] +integration-actix-web = [ "perseus-actix-web", "actix-web" ] +integration-warp = [ "perseus-warp", "warp", "tokio" ] + +default = [ "integration-warp" ] diff --git a/examples/basic/.perseus/server/src/main.rs b/examples/basic/.perseus/server/src/main.rs index 73ee3ca290..a5746df8cf 100644 --- a/examples/basic/.perseus/server/src/main.rs +++ b/examples/basic/.perseus/server/src/main.rs @@ -1,13 +1,13 @@ -use actix_web::{App, HttpServer}; use futures::executor::block_on; +use perseus::internal::i18n::TranslationsManager; +use perseus::internal::serve::{ServerOptions, ServerProps}; use perseus::plugins::PluginAction; +use perseus::stores::MutableStore; use perseus::SsrNode; -use perseus_actix_web::{configurer, Options}; use perseus_engine::app::{ get_app_root, get_error_pages_contained, get_immutable_store, get_locales, get_mutable_store, - get_plugins, get_static_aliases, get_templates_map_contained, get_translations_manager, + get_plugins, get_static_aliases, get_templates_map_atomic_contained, get_translations_manager, }; -use std::collections::HashMap; use std::env; use std::fs; @@ -16,24 +16,69 @@ use std::fs; // prod: as a standalone executable with a `dist/` directory as a sibling // The prod mode can be enabled by setting the `PERSEUS_STANDALONE` environment variable +// Integration: Actix Web +#[cfg(feature = "integration-actix-web")] #[actix_web::main] async fn main() -> std::io::Result<()> { - let plugins = get_plugins::(); + use actix_web::{App, HttpServer}; + use perseus_actix_web::configurer; + + let is_standalone = get_standalone_and_act(); + let (host, port) = get_host_and_port(); + HttpServer::new(move || App::new().configure(block_on(configurer(get_props(is_standalone))))) + .bind((host, port))? + .run() + .await +} + +// Integration: Warp +#[cfg(feature = "integration-warp")] +#[tokio::main] +async fn main() { + use perseus_warp::perseus_routes; + use std::net::SocketAddr; + let is_standalone = get_standalone_and_act(); + let props = get_props(is_standalone); + let (host, port) = get_host_and_port(); + let addr: SocketAddr = format!("{}:{}", host, port) + .parse() + .expect("Invalid address provided to bind to."); + let routes = block_on(perseus_routes(props)); + warp::serve(routes).run(addr).await; +} + +/// Determines whether or not we're operating in standalone mode, and acts accordingly. This MUST be executed in the parent thread, as it switches the current directory. +fn get_standalone_and_act() -> bool { // So we don't have to define a different `FsConfigManager` just for the server, we shift the execution context to the same level as everything else // The server has to be a separate crate because otherwise the dependencies don't work with Wasm bundling // If we're not running as a standalone binary, assume we're running in dev mode under `.perseus/` - let is_standalone; if env::var("PERSEUS_STANDALONE").is_err() { env::set_current_dir("../").unwrap(); - is_standalone = false; + false } else { // If we are running as a standalone binary, we have no idea where we're being executed from (#63), so we should set the working directory to be the same as the binary location let binary_loc = env::current_exe().unwrap(); let binary_dir = binary_loc.parent().unwrap(); // It's a file, there's going to be a parent if we're working on anything close to sanity env::set_current_dir(binary_dir).unwrap(); - is_standalone = true; + true } +} + +/// Gets the host and port to serve on. +fn get_host_and_port() -> (String, u16) { + let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let port = env::var("PORT") + .unwrap_or_else(|_| "8080".to_string()) + .parse::() + .expect("Port must be a number."); + + (host, port) +} + +/// Gets the properties to pass to the server. +fn get_props(is_standalone: bool) -> ServerProps { + let plugins = get_plugins::(); plugins .functional_actions @@ -48,52 +93,38 @@ async fn main() -> std::io::Result<()> { ("../index.html", "../static") }; - let host = env::var("HOST").unwrap_or_else(|_| "localhost".to_string()); - let port = env::var("PORT") - .unwrap_or_else(|_| "8080".to_string()) - .parse::(); - if let Ok(port) = port { - let immutable_store = get_immutable_store(&plugins); - let locales = get_locales(&plugins); - let app_root = get_app_root(&plugins); - let static_aliases = get_static_aliases(&plugins); - HttpServer::new(move || { - // TODO find a way to configure the server with plugins without using `actix-web` in the `perseus` crate (it won't compile to Wasm) - App::new().configure(block_on(configurer( - Options { - // We don't support setting some attributes from `wasm-pack` through plugins/`define_app!` because that would require CLI changes as well (a job for an alternative engine) - index: html_shell_path.to_string(), // The user must define their own `index.html` file - js_bundle: "dist/pkg/perseus_engine.js".to_string(), - // Our crate has the same name, so this will be predictable - wasm_bundle: "dist/pkg/perseus_engine_bg.wasm".to_string(), - // It's a nightmare to get the templates map to take plugins, so we use a self-contained version - // TODO reduce allocations here - templates_map: get_templates_map_contained(), - locales: locales.clone(), - root_id: app_root.to_string(), - snippets: "dist/pkg/snippets".to_string(), - error_pages: get_error_pages_contained(), - // The CLI supports static content in `../static` by default if it exists - // This will be available directly at `/.perseus/static` - static_dirs: if fs::metadata(&static_dir_path).is_ok() { - let mut static_dirs = HashMap::new(); - static_dirs.insert("".to_string(), static_dir_path.to_string()); - static_dirs - } else { - HashMap::new() - }, - static_aliases: static_aliases.clone(), - }, - immutable_store.clone(), - get_mutable_store(), - block_on(get_translations_manager()), - ))) - }) - .bind((host, port))? - .run() - .await - } else { - eprintln!("Port must be a number."); - Ok(()) + let immutable_store = get_immutable_store(&plugins); + let locales = get_locales(&plugins); + let app_root = get_app_root(&plugins); + let static_aliases = get_static_aliases(&plugins); + + let opts = ServerOptions { + // We don't support setting some attributes from `wasm-pack` through plugins/`define_app!` because that would require CLI changes as well (a job for an alternative engine) + index: html_shell_path.to_string(), // The user must define their own `index.html` file + js_bundle: "dist/pkg/perseus_engine.js".to_string(), + // Our crate has the same name, so this will be predictable + wasm_bundle: "dist/pkg/perseus_engine_bg.wasm".to_string(), + // It's a nightmare to get the templates map to take plugins, so we use a self-contained version + // TODO reduce allocations here + templates_map: get_templates_map_atomic_contained(), + locales, + root_id: app_root, + snippets: "dist/pkg/snippets".to_string(), + error_pages: get_error_pages_contained(), + // The CLI supports static content in `../static` by default if it exists + // This will be available directly at `/.perseus/static` + static_dir: if fs::metadata(&static_dir_path).is_ok() { + Some(static_dir_path.to_string()) + } else { + None + }, + static_aliases, + }; + + ServerProps { + opts, + immutable_store, + mutable_store: get_mutable_store(), + translations_manager: block_on(get_translations_manager()), } } diff --git a/examples/basic/.perseus/src/app.rs b/examples/basic/.perseus/src/app.rs index 65780d24ff..89413fc5e9 100644 --- a/examples/basic/.perseus/src/app.rs +++ b/examples/basic/.perseus/src/app.rs @@ -3,10 +3,12 @@ pub use app::get_plugins; use perseus::{ - internal::i18n::Locales, stores::ImmutableStore, templates::TemplateMap, ErrorPages, - GenericNode, PluginAction, Plugins, + internal::i18n::Locales, + stores::ImmutableStore, + templates::{ArcTemplateMap, TemplateMap}, + ErrorPages, GenericNode, PluginAction, Plugins, }; -use std::collections::HashMap; +use std::{collections::HashMap, rc::Rc, sync::Arc}; pub use app::{get_mutable_store, get_translations_manager}; @@ -109,7 +111,24 @@ pub fn get_templates_map(plugins: &Plugins) -> TemplateMap for (_plugin_name, plugin_templates) in extra_templates { // Turn that vector into a template map by extracting the template root paths as keys for template in plugin_templates { - templates.insert(template.get_path(), template); + templates.insert(template.get_path(), Rc::new(template)); + } + } + + templates +} +pub fn get_templates_map_atomic(plugins: &Plugins) -> ArcTemplateMap { + let mut templates = app::get_templates_map_atomic::(); + // This will return a map of plugin name to a vector of templates to add + let extra_templates = plugins + .functional_actions + .settings_actions + .add_templates + .run((), plugins.get_plugin_data()); + for (_plugin_name, plugin_templates) in extra_templates { + // Turn that vector into a template map by extracting the template root paths as keys + for template in plugin_templates { + templates.insert(template.get_path(), Arc::new(template)); } } @@ -138,6 +157,10 @@ pub fn get_templates_map_contained() -> TemplateMap { let plugins = get_plugins::(); get_templates_map(&plugins) } +pub fn get_templates_map_atomic_contained() -> ArcTemplateMap { + let plugins = get_plugins::(); + get_templates_map_atomic(&plugins) +} pub fn get_error_pages_contained() -> ErrorPages { let plugins = get_plugins::(); get_error_pages(&plugins) diff --git a/examples/basic/.perseus/src/lib.rs b/examples/basic/.perseus/src/lib.rs index 2c3646ed9b..7de2d6a760 100644 --- a/examples/basic/.perseus/src/lib.rs +++ b/examples/basic/.perseus/src/lib.rs @@ -109,6 +109,7 @@ pub fn run() -> Result<(), JsValue> { RouteVerdict::LocaleDetection(path) => detect_locale(path.clone(), &locales), // To get a translator here, we'd have to go async and dangerously check the URL // If this is an initial load, there'll already be an error message, so we should only proceed if the declaration is not `error` + // BUG If we have an error in a subsequent load, the error message appears below the current page... RouteVerdict::NotFound => { checkpoint("not_found"); if let InitialState::Error(ErrorPageData { url, status, err }) = get_initial_state() { diff --git a/packages/perseus-actix-web/src/configurer.rs b/packages/perseus-actix-web/src/configurer.rs index 10ab55a016..9160112bc1 100644 --- a/packages/perseus-actix-web/src/configurer.rs +++ b/packages/perseus-actix-web/src/configurer.rs @@ -6,53 +6,24 @@ use actix_web::{web, HttpRequest}; use perseus::{ internal::{ get_path_prefix_server, - i18n::{Locales, TranslationsManager}, - serve::{get_render_cfg, prep_html_shell}, + i18n::TranslationsManager, + serve::{get_render_cfg, prep_html_shell, ServerOptions, ServerProps}, }, - stores::{ImmutableStore, MutableStore}, - templates::TemplateMap, - ErrorPages, SsrNode, + stores::MutableStore, }; -use std::collections::HashMap; use std::fs; +use std::rc::Rc; -/// The options for setting up the Actix Web integration. This should be literally constructed, as nothing is optional. -#[derive(Clone)] -pub struct Options { - /// The location on the filesystem of your JavaScript bundle. - pub js_bundle: String, - /// The location on the filesystem of your Wasm bundle. - pub wasm_bundle: String, - /// The location on the filesystem of your `index.html` file that includes the JS bundle. - pub index: String, - /// A `HashMap` of your app's templates by their paths. - pub templates_map: TemplateMap, - /// The locales information for the app. - pub locales: Locales, - /// The HTML `id` of the element at which to render Perseus. On the server-side, interpolation will be done here in a highly - /// efficient manner by not parsing the HTML, so this MUST be of the form `
` in your markup (double or single - /// quotes, `root_id` replaced by what this property is set to). - pub root_id: String, - /// The location of the JS interop snippets to be served as static files. - pub snippets: String, - /// The error pages for the app. These will be server-rendered if an initial load fails. - pub error_pages: ErrorPages, - /// Directories to serve static content from, mapping URL to folder path. Note that the URL provided will be gated behind - /// `.perseus/static/`, and must have a leading `/`. If you're using a CMS instead, you should set these up outside the Perseus - /// server (but they might still be on the same machine, you can still add more routes after Perseus is configured). - pub static_dirs: HashMap, - /// A map of URLs to act as aliases for certain static resources. These are particularly designed for things like a site manifest or - /// favicons, which should be stored in a static directory, but need to be aliased at a path like `/favicon.ico`. - pub static_aliases: HashMap, -} - -async fn js_bundle(opts: web::Data) -> std::io::Result { +async fn js_bundle(opts: web::Data>) -> std::io::Result { NamedFile::open(&opts.js_bundle) } -async fn wasm_bundle(opts: web::Data) -> std::io::Result { +async fn wasm_bundle(opts: web::Data>) -> std::io::Result { NamedFile::open(&opts.wasm_bundle) } -async fn static_alias(opts: web::Data, req: HttpRequest) -> std::io::Result { +async fn static_alias( + opts: web::Data>, + req: HttpRequest, +) -> std::io::Result { let filename = opts.static_aliases.get(req.path()); let filename = match filename { Some(filename) => filename, @@ -65,11 +36,14 @@ async fn static_alias(opts: web::Data, req: HttpRequest) -> std::io::Re /// Configures an existing Actix Web app for Perseus. This returns a function that does the configuring so it can take arguments. This /// includes a complete wildcard handler (`*`), and so it should be configured after any other routes on your server. pub async fn configurer( - opts: Options, - immutable_store: ImmutableStore, - mutable_store: M, - translations_manager: T, + ServerProps { + opts, + immutable_store, + mutable_store, + translations_manager, + }: ServerProps, ) -> impl Fn(&mut web::ServiceConfig) { + let opts = Rc::new(opts); // TODO Find a more efficient way of doing this let render_cfg = get_render_cfg(&immutable_store) .await .expect("Couldn't get render configuration!"); @@ -108,8 +82,8 @@ pub async fn configurer HttpResponse { - let error_html = error_pages.render_to_string(url, status, err, translator); - // We create a JSON representation of the data necessary to hydrate the error page on the client-side - // Right now, translators are never included in transmitted error pages - let error_page_data = serde_json::to_string(&ErrorPageData { - url: url.to_string(), - status: *status, - err: err.to_string(), - }) - .unwrap(); - // Add a global variable that defines this as an error - let state_var = format!( - "", - error_page_data - // We escape any backslashes to prevent their interfering with JSON delimiters - .replace(r#"\"#, r#"\\"#) - // We escape any backticks, which would interfere with JS's raw strings system - .replace(r#"`"#, r#"\`"#) - // We escape any interpolations into JS's raw string system - .replace(r#"${"#, r#"\${"#) - ); - let html_with_declaration = html.replace("", &format!("{}\n", state_var)); - // Interpolate the error page itself - let html_to_replace_double = format!("
", root_id); - let html_to_replace_single = format!("
", root_id); - let html_replacement = format!( - // We give the content a specific ID so that it can be hydrated properly - "{}
{}
", - &html_to_replace_double, - &error_html - ); - // Now interpolate that HTML into the HTML shell - let final_html = html_with_declaration - .replace(&html_to_replace_double, &html_replacement) - .replace(&html_to_replace_single, &html_replacement); - + let html = build_error_page(url, status, err, translator, error_pages, html, root_id); HttpResponse::build(StatusCode::from_u16(*status).unwrap()) .content_type("text/html") - .body(final_html) + .body(html) } /// The handler for calls to any actual pages (first-time visits), which will render the appropriate HTML and then interpolate it into /// the app shell. pub async fn initial_load( req: HttpRequest, - opts: web::Data, + opts: web::Data>, html_shell: web::Data, render_cfg: web::Data>, immutable_store: web::Data, @@ -84,11 +49,7 @@ pub async fn initial_load( let templates = &opts.templates_map; let error_pages = &opts.error_pages; let path = req.path(); - let path_slice: Vec<&str> = path - .split('/') - // Removing empty elements is particularly important, because the path will have a leading `/` - .filter(|p| !p.is_empty()) - .collect(); + let path_slice = get_path_slice(path); // Create a closure to make returning error pages easier (most have the same data) let html_err = |status: u16, err: &str| { return return_error_page( @@ -103,11 +64,11 @@ pub async fn initial_load( }; // Run the routing algorithms on the path to figure out which template we need - let verdict = match_route(&path_slice, render_cfg.get_ref(), templates, &opts.locales); + let verdict = match_route_atomic(&path_slice, render_cfg.get_ref(), templates, &opts.locales); match verdict { // If this is the outcome, we know that the locale is supported and the like // Given that all this is valid from the client, any errors are 500s - RouteVerdict::Found(RouteInfo { + RouteVerdictAtomic::Found(RouteInfoAtomic { path, // Used for asset fetching, this is what we'd get in `page_data` template, // The actual template to use locale, @@ -126,7 +87,7 @@ pub async fn initial_load( let page_data = get_page_for_template( &path, &locale, - &template, + template, was_incremental_match, http_req, (immutable_store.get_ref(), mutable_store.get_ref()), @@ -153,7 +114,7 @@ pub async fn initial_load( http_res.body(final_html) } // For locale detection, we don't know the user's locale, so there's not much we can do except send down the app shell, which will do the rest and fetch from `.perseus/page/...` - RouteVerdict::LocaleDetection(path) => { + RouteVerdictAtomic::LocaleDetection(path) => { // We use a `302 Found` status code to indicate a redirect // We 'should' generate a `Location` field for the redirect, but it's not RFC-mandated, so we can use the app shell HttpResponse::Found().content_type("text/html").body( @@ -169,6 +130,6 @@ pub async fn initial_load( ), ) } - RouteVerdict::NotFound => html_err(404, "page not found"), + RouteVerdictAtomic::NotFound => html_err(404, "page not found"), } } diff --git a/packages/perseus-actix-web/src/lib.rs b/packages/perseus-actix-web/src/lib.rs index c05b922aef..6d6ac63376 100644 --- a/packages/perseus-actix-web/src/lib.rs +++ b/packages/perseus-actix-web/src/lib.rs @@ -35,4 +35,5 @@ mod initial_load; mod page_data; mod translations; -pub use crate::configurer::{configurer, Options}; +pub use crate::configurer::configurer; +pub use perseus::internal::serve::ServerOptions; diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index 9eef58252e..473e2e730a 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -1,13 +1,16 @@ use crate::conv_req::convert_req; -use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; use fmterr::fmt_err; use perseus::{ errors::err_to_status_code, - internal::{i18n::TranslationsManager, serve::get_page_for_template}, + internal::{ + i18n::TranslationsManager, + serve::{render::get_page_for_template, ServerOptions}, + }, stores::{ImmutableStore, MutableStore}, }; use serde::Deserialize; +use std::rc::Rc; #[derive(Deserialize)] pub struct PageDataReq { @@ -18,7 +21,7 @@ pub struct PageDataReq { /// The handler for calls to `.perseus/page/*`. This will manage returning errors and the like. pub async fn page_data( req: HttpRequest, - opts: web::Data, + opts: web::Data>, immutable_store: web::Data, mutable_store: web::Data, translations_manager: web::Data, diff --git a/packages/perseus-actix-web/src/translations.rs b/packages/perseus-actix-web/src/translations.rs index 898e6122a6..686fac1318 100644 --- a/packages/perseus-actix-web/src/translations.rs +++ b/packages/perseus-actix-web/src/translations.rs @@ -1,13 +1,14 @@ -use crate::Options; use actix_web::{web, HttpRequest, HttpResponse}; use fmterr::fmt_err; use perseus::internal::i18n::TranslationsManager; +use perseus::internal::serve::ServerOptions; +use std::rc::Rc; /// The handler for calls to `.perseus/translations/{locale}`. This will manage returning errors and the like. THe JSON body returned /// from this does NOT include the `locale` key, just a `HashMap` of the translations themselves. pub async fn translations( req: HttpRequest, - opts: web::Data, + opts: web::Data>, translations_manager: web::Data, ) -> HttpResponse { let locale = req.match_info().query("locale"); diff --git a/packages/perseus-cli/Cargo.toml b/packages/perseus-cli/Cargo.toml index 1224b6cf90..24c58afc8a 100644 --- a/packages/perseus-cli/Cargo.toml +++ b/packages/perseus-cli/Cargo.toml @@ -27,7 +27,7 @@ indicatif = "0.17.0-beta.1" # Not stable, but otherwise error handling is just a console = "0.14" serde = "1" serde_json = "1" -clap = { version = "3.0.0-beta.5", features = ["color"] } +clap = { version = "=3.0.0-beta.5", features = ["color"] } fs_extra = "1" [lib] diff --git a/packages/perseus-warp/Cargo.toml b/packages/perseus-warp/Cargo.toml new file mode 100644 index 0000000000..59148b9ea1 --- /dev/null +++ b/packages/perseus-warp/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "perseus-warp" +version = "0.3.0-beta.18" +edition = "2018" +description = "An integration that makes the Perseus framework easy to use with Warp." +authors = ["arctic_hen7 "] +license = "MIT" +repository = "https://github.com/arctic-hen7/perseus" +homepage = "https://arctic-hen7.github.io/perseus" +readme = "./README.md" +keywords = ["wasm", "frontend", "webdev", "ssg", "ssr"] +categories = ["wasm", "web-programming::http-server", "development-tools", "asynchronous", "gui"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +perseus = { path = "../perseus", version = "0.3.0-beta.18" } +tokio = { version = "1", features = [ "rt-multi-thread" ] } +warp = { version = "0.3", git = "https://github.com/arctic-hen7/warp", branch = "master" } # Temporary until Warp #171 is resolved +urlencoding = "2.1" +serde = "1" +serde_json = "1" +thiserror = "1" +fmterr = "0.1" +futures = "0.3" +sycamore = { version = "0.6", features = ["ssr"] } diff --git a/packages/perseus-warp/README.md b/packages/perseus-warp/README.md new file mode 100644 index 0000000000..e01ef549b9 --- /dev/null +++ b/packages/perseus-warp/README.md @@ -0,0 +1,5 @@ +# Perseus Warp Integration + +This is the official [Perseus](https://github.com/arctic-hen7/perseus) integration for making serving your apps on [Warp](https://github.com/seanmonstar/warp) significantly easier! + +If you're new to Perseus, you should check out [the core package](https://github.com/arctic-hen7/perseus) first. diff --git a/packages/perseus-warp/src/conv_req.rs b/packages/perseus-warp/src/conv_req.rs new file mode 100644 index 0000000000..b96a51e831 --- /dev/null +++ b/packages/perseus-warp/src/conv_req.rs @@ -0,0 +1,31 @@ +use perseus::http; +use warp::{path::FullPath, Filter, Rejection}; + +/// A Warp filter for extracting an HTTP request directly, which is slightly different to how the Actix Web integration handles this. Modified from [here](https://github.com/seanmonstar/warp/issues/139#issuecomment-853153712). +pub fn get_http_req() -> impl Filter,), Error = Rejection> + Copy { + warp::any() + .and(warp::method()) + .and(warp::filters::path::full()) + // Warp doesn't permit empty query strings without this extra config (see https://github.com/seanmonstar/warp/issues/905) + .and( + warp::filters::query::raw() + .or_else(|_| async move { Ok::<_, Rejection>((String::new(),)) }), + ) + .and(warp::header::headers_cloned()) + .and_then(|method, path: FullPath, query, headers| async move { + let uri = http::uri::Builder::new() + .path_and_query(format!("{}?{}", path.as_str(), query)) + .build() + .unwrap(); + + let mut request = http::Request::builder() + .method(method) + .uri(uri) + .body(()) // We don't do anything with the body in Perseus, so this is irrelevant + .unwrap(); + + *request.headers_mut() = headers; + + Ok::, Rejection>(request) + }) +} diff --git a/packages/perseus-warp/src/initial_load.rs b/packages/perseus-warp/src/initial_load.rs new file mode 100644 index 0000000000..54bb44846d --- /dev/null +++ b/packages/perseus-warp/src/initial_load.rs @@ -0,0 +1,125 @@ +use fmterr::fmt_err; +use perseus::{ + errors::err_to_status_code, + internal::{ + get_path_prefix_server, + i18n::{TranslationsManager, Translator}, + router::{match_route_atomic, RouteInfoAtomic, RouteVerdictAtomic}, + serve::{ + build_error_page, get_path_slice, interpolate_locale_redirection_fallback, + interpolate_page_data, render::get_page_for_template, ServerOptions, + }, + }, + stores::{ImmutableStore, MutableStore}, + ErrorPages, SsrNode, +}; +use std::{collections::HashMap, rc::Rc, sync::Arc}; +use warp::{http::Response, path::FullPath}; + +/// Builds on the internal Perseus primitives to provide a utility function that returns a `Response` automatically. +fn return_error_page( + url: &str, + status: &u16, + // This should already have been transformed into a string (with a source chain etc.) + err: &str, + translator: Option>, + error_pages: &ErrorPages, + html: &str, + root_id: &str, +) -> Response { + let html = build_error_page(url, status, err, translator, error_pages, html, root_id); + Response::builder().status(*status).body(html).unwrap() +} + +/// The handler for calls to any actual pages (first-time visits), which will render the appropriate HTML and then interpolate it into +/// the app shell. +#[allow(clippy::too_many_arguments)] // As for `page_data_handler`, we don't have a choice +pub async fn initial_load_handler( + path: FullPath, + req: perseus::http::Request<()>, + opts: Arc, + html_shell: Arc, + render_cfg: Arc>, + immutable_store: Arc, + mutable_store: Arc, + translations_manager: Arc, +) -> Response { + let path = path.as_str(); + let templates = &opts.templates_map; + let error_pages = &opts.error_pages; + let path_slice = get_path_slice(path); + // Create a closure to make returning error pages easier (most have the same data) + let html_err = |status: u16, err: &str| { + return return_error_page( + path, + &status, + err, + None, + error_pages, + html_shell.as_ref(), + &opts.root_id, + ); + }; + + // Run the routing algorithms on the path to figure out which template we need + let verdict = match_route_atomic(&path_slice, render_cfg.as_ref(), templates, &opts.locales); + match verdict { + // If this is the outcome, we know that the locale is supported and the like + // Given that all this is valid from the client, any errors are 500s + RouteVerdictAtomic::Found(RouteInfoAtomic { + path, // Used for asset fetching, this is what we'd get in `page_data` + template, // The actual template to use + locale, + was_incremental_match, + }) => { + // Actually render the page as we would if this weren't an initial load + let page_data = get_page_for_template( + &path, + &locale, + template, + was_incremental_match, + req, + (immutable_store.as_ref(), mutable_store.as_ref()), + translations_manager.as_ref(), + ) + .await; + let page_data = match page_data { + Ok(page_data) => page_data, + // We parse the error to return an appropriate status code + Err(err) => { + return html_err(err_to_status_code(&err), &fmt_err(&err)); + } + }; + + let final_html = interpolate_page_data(&html_shell, &page_data, &opts.root_id); + + let mut http_res = Response::builder().status(200); + // http_res.content_type("text/html"); + // Generate and add HTTP headers + for (key, val) in template.get_headers(page_data.state) { + http_res = http_res.header(key.unwrap(), val); + } + + http_res.body(final_html).unwrap() + } + // For locale detection, we don't know the user's locale, so there's not much we can do except send down the app shell, which will do the rest and fetch from `.perseus/page/...` + RouteVerdictAtomic::LocaleDetection(path) => { + // We use a `302 Found` status code to indicate a redirect + // We 'should' generate a `Location` field for the redirect, but it's not RFC-mandated, so we can use the app shell + Response::builder() + .status(200) + .body(interpolate_locale_redirection_fallback( + html_shell.as_ref(), + // We'll redirect the user to the default locale + &format!( + "{}/{}/{}", + get_path_prefix_server(), + opts.locales.default, + path + ), + )) + .unwrap() + } + RouteVerdictAtomic::NotFound => html_err(404, "page not found"), + } +} diff --git a/packages/perseus-warp/src/lib.rs b/packages/perseus-warp/src/lib.rs new file mode 100644 index 0000000000..51b4ef568e --- /dev/null +++ b/packages/perseus-warp/src/lib.rs @@ -0,0 +1,39 @@ +/*! + * Perseus is a blazingly fast frontend web development framework built in Rust with support for major rendering strategies, + * reactivity without a virtual DOM, and extreme customizability. It wraps the lower-level capabilities of [Sycamore](https://github.com/sycamore-rs/sycamore) + * and provides a NextJS-like API! + * + * - ✨ Supports static generation (serving only static resources) + * - ✨ Supports server-side rendering (serving dynamic resources) + * - ✨ Supports revalidation after time and/or with custom logic (updating rendered pages) + * - ✨ Supports incremental regeneration (build on demand) + * - ✨ Open build matrix (use any rendering strategy with anything else, mostly) + * - ✨ CLI harness that lets you build apps with ease and confidence + * + * This is the documentation for the Perseus Warp integration, but there's also [a CLI](https://arctic-hen7.github.io/perseus/cli.html), + * [the core package](https://crates.io/crates/perseus), and other [integrations](https://arctic-hen7.github.io/perseus/serving.html) + * to make serving apps on other platforms easier! + * + * # Resources + * + * These docs will help you as a reference, but [the book](https://arctic-hen7.github.io/perseus) should + * be your first port of call for learning about how to use Perseus and how it works. + * + * - [The Book](https://arctic-hen7.github.io/perseus) + * - [GitHub repository](https://github.com/arctic-hen7/perseus) + * - [Crate page](https://crates.io/crates/perseus) + * - [Gitter chat](https://gitter.im/perseus-framework/community) + * - [Discord server channel](https://discord.com/channels/820400041332179004/883168134331256892) (for Sycamore-related stuff) + */ + +#![deny(missing_docs)] + +mod conv_req; +mod initial_load; +mod page_data; +mod perseus_routes; +mod static_content; +mod translations; + +pub use crate::perseus_routes::perseus_routes; +pub use perseus::internal::serve::ServerOptions; diff --git a/packages/perseus-warp/src/page_data.rs b/packages/perseus-warp/src/page_data.rs new file mode 100644 index 0000000000..8f6e1766f3 --- /dev/null +++ b/packages/perseus-warp/src/page_data.rs @@ -0,0 +1,87 @@ +use fmterr::fmt_err; +use perseus::{ + errors::err_to_status_code, + internal::{ + i18n::TranslationsManager, + serve::{render::get_page_for_template, ServerOptions}, + }, + stores::{ImmutableStore, MutableStore}, +}; +use serde::Deserialize; +use std::sync::Arc; +use warp::http::Response; +use warp::path::Tail; + +// Note: this is the same as for the Actix Web integration, but other frameworks may handle parsing query parameters differntly, so this shouldn't be integrated into the core library +#[derive(Deserialize)] +pub struct PageDataReq { + pub template_name: String, + pub was_incremental_match: bool, +} + +#[allow(clippy::too_many_arguments)] // Because of how Warp filters work, we don't exactly have a choice +pub async fn page_handler( + locale: String, + path: Tail, // This is the path after the locale that was sent + PageDataReq { + template_name, + was_incremental_match, + }: PageDataReq, + http_req: perseus::http::Request<()>, + opts: Arc, + immutable_store: Arc, + mutable_store: Arc, + translations_manager: Arc, +) -> Response { + let templates = &opts.templates_map; + // Check if the locale is supported + if opts.locales.is_supported(&locale) { + // Warp doesn't let us specify that all paths should end in `.json`, so we'll manually strip that + let path = path.as_str().strip_suffix(".json").unwrap(); + // Get the template to use + let template = templates.get(&template_name); + let template = match template { + Some(template) => template, + None => { + // We know the template has been pre-routed and should exist, so any failure here is a 500 + return Response::builder() + .status(500) + .body("template not found".to_string()) + .unwrap(); + } + }; + let page_data = get_page_for_template( + path, + &locale, + template, + was_incremental_match, + http_req, + (immutable_store.as_ref(), mutable_store.as_ref()), + translations_manager.as_ref(), + ) + .await; + match page_data { + Ok(page_data) => { + let mut http_res = Response::builder().status(200); + // http_res.content_type("text/html"); + // Generate and add HTTP headers + for (key, val) in template.get_headers(page_data.state.clone()) { + http_res = http_res.header(key.unwrap(), val); + } + + let page_data_str = serde_json::to_string(&page_data).unwrap(); + http_res.body(page_data_str).unwrap() + } + // We parse the error to return an appropriate status code + Err(err) => Response::builder() + .status(err_to_status_code(&err)) + .body(fmt_err(&err)) + .unwrap(), + } + } else { + Response::builder() + .status(404) + .body("locale not supported".to_string()) + .unwrap() + } +} diff --git a/packages/perseus-warp/src/perseus_routes.rs b/packages/perseus-warp/src/perseus_routes.rs new file mode 100644 index 0000000000..2ee7654ba2 --- /dev/null +++ b/packages/perseus-warp/src/perseus_routes.rs @@ -0,0 +1,115 @@ +use crate::initial_load::initial_load_handler; +use crate::page_data::page_handler; +use crate::{ + conv_req::get_http_req, + page_data::PageDataReq, + static_content::{serve_file, static_aliases_filter}, + translations::translations_handler, +}; +use perseus::internal::serve::{get_render_cfg, ServerProps}; +use perseus::{ + internal::{get_path_prefix_server, i18n::TranslationsManager, serve::prep_html_shell}, + stores::MutableStore, +}; +use std::{fs, sync::Arc}; +use warp::Filter; + +/// The routes for Perseus. These will configure an existing Warp instance to run Perseus, and should be provided after any other routes, as they include a wildcard +/// route. +pub async fn perseus_routes( + ServerProps { + opts, + immutable_store, + mutable_store, + translations_manager, + }: ServerProps, +) -> impl Filter + Clone { + let render_cfg = get_render_cfg(&immutable_store) + .await + .expect("Couldn't get render configuration!"); + let index_file = fs::read_to_string(&opts.index).expect("Couldn't get HTML index file!"); + let index_with_render_cfg = prep_html_shell(index_file, &render_cfg, &get_path_prefix_server()); + + // Handle static files + let js_bundle = warp::path!(".perseus" / "bundle.js") + .and(warp::path::end()) + .and(warp::fs::file(opts.js_bundle.clone())); + let wasm_bundle = warp::path!(".perseus" / "bundle.wasm") + .and(warp::path::end()) + .and(warp::fs::file(opts.wasm_bundle.clone())); + // Handle JS interop snippets (which need to be served as separate files) + let snippets = warp::path!(".perseus" / "snippets").and(warp::fs::dir(opts.snippets.clone())); + // Handle static content in the user-set directories (this will all be under `/.perseus/static`) + // We only set this if the user is using a static content directory + let static_dir_path = Arc::new(opts.static_dir.clone()); + let static_dir_path_filter = warp::any().map(move || static_dir_path.clone()); + let static_dir = warp::path!(".perseus" / "static" / ..) + .and(static_dir_path_filter) + .and_then(|static_dir_path: Arc>| async move { + if static_dir_path.is_some() { + Ok(()) + } else { + Err(warp::reject::not_found()) + } + }) + .untuple_one() // We need this to avoid a ((), File) (which makes the return type fail) + // This alternative will never be served, but if we don't have it we'll get a runtime panic + .and(warp::fs::dir( + opts.static_dir.clone().unwrap_or_else(|| "".to_string()), + )); + // Handle static aliases + let static_aliases = warp::any() + .and(static_aliases_filter(opts.static_aliases.clone())) + .and_then(serve_file); + + // Define some filters to handle all the data we want to pass through + let opts = Arc::new(opts); + let opts = warp::any().map(move || opts.clone()); + let immutable_store = Arc::new(immutable_store); + let immutable_store = warp::any().map(move || immutable_store.clone()); + let mutable_store = Arc::new(mutable_store); + let mutable_store = warp::any().map(move || mutable_store.clone()); + let translations_manager = Arc::new(translations_manager); + let translations_manager = warp::any().map(move || translations_manager.clone()); + let html_shell = Arc::new(index_with_render_cfg); + let html_shell = warp::any().map(move || html_shell.clone()); + let render_cfg = Arc::new(render_cfg); + let render_cfg = warp::any().map(move || render_cfg.clone()); + + // Handle getting translations + let translations = warp::path!(".perseus" / "translations" / String) + .and(opts.clone()) + .and(translations_manager.clone()) + .then(translations_handler); + // Handle getting the static HTML/JSON of a page (used for subsequent loads) + let page_data = warp::path!(".perseus" / "page" / String / ..) + .and(warp::path::tail()) + .and(warp::query::()) + .and(get_http_req()) + .and(opts.clone()) + .and(immutable_store.clone()) + .and(mutable_store.clone()) + .and(translations_manager.clone()) + .then(page_handler); + // Handle initial loads (we use a wildcard for this) + let initial_loads = warp::any() + .and(warp::path::full()) + .and(get_http_req()) + .and(opts) + .and(html_shell) + .and(render_cfg) + .and(immutable_store) + .and(mutable_store) + .and(translations_manager) + .then(initial_load_handler); + + // Now put all those routes together in the final thing (the user will add this to an existing Warp server) + js_bundle + .or(wasm_bundle) + .or(snippets) + .or(static_dir) + .or(static_aliases) + .or(translations) + .or(page_data) + .or(initial_loads) +} diff --git a/packages/perseus-warp/src/static_content.rs b/packages/perseus-warp/src/static_content.rs new file mode 100644 index 0000000000..bb51dd78da --- /dev/null +++ b/packages/perseus-warp/src/static_content.rs @@ -0,0 +1,90 @@ +use std::collections::HashMap; +use std::sync::Arc; +use warp::fs::{file_reply, ArcPath, Conditionals, File}; +use warp::{path::FullPath, Filter, Rejection}; + +/// A filter for static aliases that determines which file to serve. +pub fn static_aliases_filter( + paths: HashMap, +) -> impl Filter + Clone { + warp::any() + .and(warp::path::full()) + .and(warp::any().map(move || paths.clone())) + .and_then( + |path: FullPath, paths: HashMap| async move { + // Match a specific static alias and break on the first match + let mut file_to_serve = String::new(); + for (url, static_dir) in paths.iter() { + if path.as_str() == url { + file_to_serve = static_dir.to_string(); + break; + } + } + + if file_to_serve.is_empty() { + Err(warp::reject::not_found()) + } else { + Ok(file_to_serve) + } + }, + ) +} + +/// Serves the file provided through the filter. +pub async fn serve_file(path: String) -> Result { + let arc_path = ArcPath(Arc::new(path.into())); + let conds = Conditionals::default(); + file_reply(arc_path, conds).await +} + +// /// Serves the file provided through the filter. This returns an error because we assume that the file is supposed to exist at this point (this is used for static +// /// aliases). +// pub async fn serve_file(path: String) -> Result { +// match TkFile::open(path).await { +// Ok(file) => { +// let metadata = file.metadata().await.map_err(|e| warp::reject::not_found())?; +// let stream = file_stream(file, metadata); +// let res = Response::new(Body::wrap_stream(stream)); + +// Ok(res) +// }, +// // If a static alias can't be found, we'll act as if it doesn't exist and proceed to the next handler +// Err(_) => Err(warp::reject::not_found()) +// } +// } + +// // The default chunk size for streaming a file (taken from Warp's internals) +// const DFLT_BUF_SIZE: usize = 8_192; +// #[cfg(unix)] +// fn get_buf_size(metadata: Metadata) -> usize { +// use std::os::unix::prelude::MetadataExt; + +// std::cmp::max(metadata.blksize() as usize, DFLT_BUF_SIZE) +// } +// #[cfg(not(unix))] +// fn get_buf_size(_metadata: Metadata) -> usize { +// DFLT_BUF_SIZE // On Windows, we don't have a blocksize function based on the metadata +// } + +// /// Reserves more space in a buffer if needed +// fn reserve_if_needed(buf: &mut BytesMut, cap: usize) { +// if buf.capacity() - buf.len() < cap { +// buf.reserve(cap); +// } +// } + +// fn file_stream(mut file: TkFile, metadata: Metadata) -> impl Stream> + Send { +// let buf_size = get_buf_size(metadata); +// let stream = file.seek(SeekFrom::Start(0)); + +// let mut buf = BytesMut::new(); +// reserve_if_needed(&mut buf, buf_size); + +// try_stream! { +// for i in 0u8..3 { +// reserve_if_needed(&mut buf, buf_size); +// let n = file.read(&mut buf).await?; +// yield Bytes::from(buf[..n]); +// } +// } +// } diff --git a/packages/perseus-warp/src/translations.rs b/packages/perseus-warp/src/translations.rs new file mode 100644 index 0000000000..24e0d8ca33 --- /dev/null +++ b/packages/perseus-warp/src/translations.rs @@ -0,0 +1,29 @@ +use fmterr::fmt_err; +use perseus::internal::{i18n::TranslationsManager, serve::ServerOptions}; +use std::sync::Arc; +use warp::http::Response; + +pub async fn translations_handler( + locale: String, + opts: Arc, + translations_manager: Arc, +) -> Response { + // Check if the locale is supported + if opts.locales.is_supported(&locale) { + // We know that the locale is supported, so any failure to get translations is a 500 + let translations = translations_manager + .get_translations_str_for_locale(locale.to_string()) + .await; + let translations = match translations { + Ok(translations) => translations, + Err(err) => return Response::builder().status(500).body(fmt_err(&err)).unwrap(), + }; + + Response::new(translations) + } else { + Response::builder() + .status(404) + .body("locale not supported".to_string()) + .unwrap() + } +} diff --git a/packages/perseus/Cargo.toml b/packages/perseus/Cargo.toml index 7d94bcb483..e36313a10a 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -33,10 +33,11 @@ async-trait = "0.1" cfg-if = "1" fluent-bundle = { version = "0.15", optional = true } unic-langid = { version = "0.9", optional = true } +intl-memoizer = { version = "0.5", optional = true } [features] default = [] -translator-fluent = ["fluent-bundle", "unic-langid"] +translator-fluent = ["fluent-bundle", "unic-langid", "intl-memoizer"] # This feature makes tinker-only plugins be registered (this flag is enabled internally in the engine) tinker-plugins = [] # This feature enables server-side-only features, which should be used on both the server and in the builder diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs index 4a8e3a5992..92dda77807 100644 --- a/packages/perseus/src/build.rs +++ b/packages/perseus/src/build.rs @@ -12,7 +12,6 @@ use crate::{ }; use futures::future::try_join_all; use std::collections::HashMap; -use std::rc::Rc; use sycamore::prelude::SsrNode; /// Builds a template, writing static data as appropriate. This should be used as part of a larger build process. This returns both a list @@ -21,7 +20,7 @@ use sycamore::prelude::SsrNode; /// generation). pub async fn build_template( template: &Template, - translator: Rc, + translator: &Translator, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), exporting: bool, ) -> Result<(Vec, bool), ServerError> { @@ -101,11 +100,7 @@ pub async fn build_template( .await?; // Prerender the template using that state let prerendered = sycamore::render_to_string(|| { - template.render_for_template( - Some(initial_state.clone()), - Rc::clone(&translator), - true, - ) + template.render_for_template(Some(initial_state.clone()), translator, true) }); // Write that prerendered HTML to a static file mutable_store @@ -113,7 +108,7 @@ pub async fn build_template( .await?; // Prerender the document `` with that state // If the page also uses request state, amalgamation will be applied as for the normal content - let head_str = template.render_head_str(Some(initial_state), Rc::clone(&translator)); + let head_str = template.render_head_str(Some(initial_state), translator); mutable_store .write( &format!("static/{}.head.html", full_path_encoded), @@ -134,11 +129,7 @@ pub async fn build_template( .await?; // Prerender the template using that state let prerendered = sycamore::render_to_string(|| { - template.render_for_template( - Some(initial_state.clone()), - Rc::clone(&translator), - true, - ) + template.render_for_template(Some(initial_state.clone()), translator, true) }); // Write that prerendered HTML to a static file immutable_store @@ -146,7 +137,7 @@ pub async fn build_template( .await?; // Prerender the document `` with that state // If the page also uses request state, amalgamation will be applied as for the normal content - let head_str = template.render_head_str(Some(initial_state), Rc::clone(&translator)); + let head_str = template.render_head_str(Some(initial_state), translator); immutable_store .write( &format!("static/{}.head.html", full_path_encoded), @@ -177,10 +168,9 @@ pub async fn build_template( // If the template is very basic, prerender without any state // It's safe to add a property to the render options here because `.is_basic()` will only return true if path generation is not being used (or anything else) if template.is_basic() { - let prerendered = sycamore::render_to_string(|| { - template.render_for_template(None, Rc::clone(&translator), true) - }); - let head_str = template.render_head_str(None, Rc::clone(&translator)); + let prerendered = + sycamore::render_to_string(|| template.render_for_template(None, translator, true)); + let head_str = template.render_head_str(None, translator); // Write that prerendered HTML to a static file immutable_store .write(&format!("static/{}.html", full_path_encoded), &prerendered) @@ -199,7 +189,7 @@ pub async fn build_template( async fn build_template_and_get_cfg( template: &Template, - translator: Rc, + translator: &Translator, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), exporting: bool, ) -> Result, ServerError> { @@ -245,11 +235,10 @@ async fn build_template_and_get_cfg( /// for this. You should only build the most commonly used locales here (the rest should be built on demand). pub async fn build_templates_for_locale( templates: &TemplateMap, - translator_raw: Translator, + translator: &Translator, (immutable_store, mutable_store): (&ImmutableStore, &impl MutableStore), exporting: bool, ) -> Result<(), ServerError> { - let translator = Rc::new(translator_raw); // The render configuration stores a list of pages to the root paths of their templates let mut render_cfg: HashMap = HashMap::new(); // Create each of the templates @@ -257,7 +246,7 @@ pub async fn build_templates_for_locale( for template in templates.values() { futs.push(build_template_and_get_cfg( template, - Rc::clone(&translator), + translator, (immutable_store, mutable_store), exporting, )); @@ -290,7 +279,7 @@ async fn build_templates_and_translator_for_locale( .await?; build_templates_for_locale( templates, - translator, + &translator, (immutable_store, mutable_store), exporting, ) diff --git a/packages/perseus/src/client_translations_manager.rs b/packages/perseus/src/client_translations_manager.rs index 84dfdd938f..5577677f71 100644 --- a/packages/perseus/src/client_translations_manager.rs +++ b/packages/perseus/src/client_translations_manager.rs @@ -3,14 +3,13 @@ use crate::locales::Locales; use crate::path_prefix::get_path_prefix_client; use crate::shell::fetch; use crate::translator::Translator; -use std::rc::Rc; /// Manages translations in the app shell. This handles fetching translations from the server as well as caching for performance. /// This is distinct from `TranslationsManager` in that it operates on the client-side rather than on the server. This optimizes for /// users viewing many pages in the same locale, which is by far the most common use of most websites in terms of i18n. pub struct ClientTranslationsManager { /// The cached translator. If the same locale is requested again, this will simply be returned. - cached_translator: Option>, + cached_translator: Option, locales: Locales, } impl ClientTranslationsManager { @@ -22,18 +21,18 @@ impl ClientTranslationsManager { locales: locales.clone(), } } - /// Gets an `Rc` for the given locale. This will use the internally cached `Translator` if possible, and will otherwise + /// Gets an `&'static Translator` for the given locale. This will use the internally cached `Translator` if possible, and will otherwise /// fetch the translations from the server. This needs mutability because it will modify its internal cache if necessary. pub async fn get_translator_for_locale( &mut self, locale: &str, - ) -> Result, ClientError> { + ) -> Result<&Translator, ClientError> { let path_prefix = get_path_prefix_client(); // Check if we've already cached if self.cached_translator.is_some() && self.cached_translator.as_ref().unwrap().get_locale() == locale { - Ok(Rc::clone(self.cached_translator.as_ref().unwrap())) + Ok(self.cached_translator.as_ref().unwrap()) } else { // Check if the locale is supported and we're actually using i18n if self.locales.is_supported(locale) && self.locales.using_i18n { @@ -45,8 +44,7 @@ impl ClientTranslationsManager { Ok(translations_str) => match translations_str { Some(translations_str) => { // All good, turn the translations into a translator - let translator = Translator::new(locale.to_string(), translations_str); - match translator { + match Translator::new(locale.to_string(), translations_str) { Ok(translator) => translator, Err(err) => { return Err(FetchError::SerFailed { @@ -72,16 +70,16 @@ impl ClientTranslationsManager { }, }; // Cache that translator - self.cached_translator = Some(Rc::new(translator)); + self.cached_translator = Some(translator); // Now return that - Ok(Rc::clone(self.cached_translator.as_ref().unwrap())) + Ok(self.cached_translator.as_ref().unwrap()) } else if !self.locales.using_i18n { // If we aren't even using i18n, then it would be pointless to fetch translations let translator = Translator::new("xx-XX".to_string(), "".to_string()).unwrap(); // Cache that translator - self.cached_translator = Some(Rc::new(translator)); + self.cached_translator = Some(translator); // Now return that - Ok(Rc::clone(self.cached_translator.as_ref().unwrap())) + Ok(self.cached_translator.as_ref().unwrap()) } else { Err(ClientError::LocaleNotSupported { locale: locale.to_string(), diff --git a/packages/perseus/src/error_pages.rs b/packages/perseus/src/error_pages.rs index dd81dc4528..cd5983c022 100644 --- a/packages/perseus/src/error_pages.rs +++ b/packages/perseus/src/error_pages.rs @@ -10,10 +10,9 @@ use web_sys::Element; /// problematic asset, and a translator if one is available . Many error pages are generated when a translator is not available or /// couldn't be instantiated, so you'll need to rely on symbols or the like in these cases. pub type ErrorPageTemplate = - Rc>) -> SycamoreTemplate>; + Box>) -> SycamoreTemplate + Send + Sync>; /// A type alias for the `HashMap` the user should provide for error pages. -#[derive(Clone)] pub struct ErrorPages { status_pages: HashMap>, fallback: ErrorPageTemplate, @@ -21,11 +20,14 @@ pub struct ErrorPages { impl ErrorPages { /// Creates a new definition of error pages with just a fallback. pub fn new( - fallback: impl Fn(String, u16, String, Option>) -> SycamoreTemplate + 'static, + fallback: impl Fn(String, u16, String, Option>) -> SycamoreTemplate + + Send + + Sync + + 'static, ) -> Self { Self { status_pages: HashMap::default(), - fallback: Rc::new(fallback), + fallback: Box::new(fallback), } } /// Adds a new page for the given status code. If a page was already defined for the given code, it will be updated by the mechanics of @@ -33,9 +35,12 @@ impl ErrorPages { pub fn add_page( &mut self, status: u16, - page: impl Fn(String, u16, String, Option>) -> SycamoreTemplate + 'static, + page: impl Fn(String, u16, String, Option>) -> SycamoreTemplate + + Send + + Sync + + 'static, ) { - self.status_pages.insert(status, Rc::new(page)); + self.status_pages.insert(status, Box::new(page)); } /// Adds a new page for the given status code. If a page was already defined for the given code, it will be updated by the mechanics of /// the internal `HashMap`. This differs from `.add_page()` in that it takes an `Rc`, which is useful for plugins. diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index f3d343a549..a71bfda19b 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -35,7 +35,7 @@ pub enum ServerError { cause: ErrorCause, // This will be triggered by the user's custom render functions, which should be able to have any error type #[source] - source: Box, + source: Box, }, #[error(transparent)] StoreError(#[from] StoreError), @@ -71,13 +71,13 @@ pub enum StoreError { ReadFailed { name: String, #[source] - source: Box, + source: Box, }, #[error("asset '{name}' couldn't be written to store")] WriteFailed { name: String, #[source] - source: Box, + source: Box, }, } @@ -97,7 +97,7 @@ pub enum FetchError { SerFailed { url: String, #[source] - source: Box, + source: Box, }, } @@ -163,12 +163,12 @@ pub enum ErrorCause { #[derive(Debug)] pub struct GenericErrorWithCause { /// The underlying error. - pub error: Box, + pub error: Box, /// The cause of the error. pub cause: ErrorCause, } // We should be able to convert any error into this easily (e.g. with `?`) with the default being to blame the server -impl From for GenericErrorWithCause { +impl From for GenericErrorWithCause { fn from(error: E) -> Self { Self { error: error.into(), diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs index 9cce700e8b..b511c59000 100644 --- a/packages/perseus/src/export.rs +++ b/packages/perseus/src/export.rs @@ -3,8 +3,8 @@ use crate::html_shell::{ interpolate_locale_redirection_fallback, interpolate_page_data, prep_html_shell, }; use crate::locales::Locales; -use crate::serve::get_render_cfg; -use crate::serve::PageData; +use crate::page_data::PageData; +use crate::server::get_render_cfg; use crate::stores::ImmutableStore; use crate::template::TemplateMap; use crate::translations_manager::TranslationsManager; diff --git a/packages/perseus/src/html_shell.rs b/packages/perseus/src/html_shell.rs index afe5ac9113..d1da2a1597 100644 --- a/packages/perseus/src/html_shell.rs +++ b/packages/perseus/src/html_shell.rs @@ -1,4 +1,4 @@ -use crate::serve::PageData; +use crate::page_data::PageData; use std::collections::HashMap; use std::env; diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index d8fd62e25b..a17f566cae 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -52,9 +52,10 @@ mod locale_detector; mod locales; mod log; mod macros; +mod page_data; mod path_prefix; mod router; -mod serve; +mod server; mod shell; mod template; mod test; @@ -97,7 +98,8 @@ pub mod internal { /// on different platforms. pub mod serve { pub use crate::html_shell::*; - pub use crate::serve::*; + pub use crate::page_data::*; + pub use crate::server::*; } /// Internal utilities for working with the Perseus router. pub mod router { diff --git a/packages/perseus/src/macros.rs b/packages/perseus/src/macros.rs index 08a183a2aa..5f7788c3b1 100644 --- a/packages/perseus/src/macros.rs +++ b/packages/perseus/src/macros.rs @@ -331,6 +331,15 @@ macro_rules! define_app { ] } + /// Gets a map of all the templates in the app by their root paths. This returns a `HashMap` that is plugin-extensible. + /// + /// This is the thread-safe version, which should only be called on the server. + pub fn get_templates_map_atomic() -> $crate::templates::ArcTemplateMap { + $crate::get_templates_map_atomic![ + $($template),+ + ] + } + /// Gets the error pages (done here so the user doesn't have to worry about naming). This is plugin-extensible. pub fn get_error_pages() -> $crate::ErrorPages { $error_pages diff --git a/packages/perseus/src/page_data.rs b/packages/perseus/src/page_data.rs new file mode 100644 index 0000000000..7cdee52c10 --- /dev/null +++ b/packages/perseus/src/page_data.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +/// Represents the data necessary to render a page, including document metadata. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PageData { + /// Prerendered HTML content. + pub content: String, + /// The state for hydration. This is kept as a string for ease of typing. Some pages may not need state or generate it in another way, + /// so this might be `None`. + pub state: Option, + /// The string to interpolate into the document's ``. + pub head: String, +} diff --git a/packages/perseus/src/router.rs b/packages/perseus/src/router.rs index 1525544d7c..0a1ffa5b13 100644 --- a/packages/perseus/src/router.rs +++ b/packages/perseus/src/router.rs @@ -1,9 +1,55 @@ use crate::locales::Locales; use crate::template::TemplateMap; +use crate::templates::ArcTemplateMap; use crate::Template; use std::collections::HashMap; +use std::rc::Rc; use sycamore::prelude::GenericNode; +/// The backend for `get_template_for_path` to avoid code duplication for the `Arc` and `Rc` versions. +macro_rules! get_template_for_path { + ($raw_path:expr, $render_cfg:expr, $templates:expr) => {{ + let mut path = $raw_path; + // If the path is empty, we're looking for the special `index` page + if path.is_empty() { + path = "index"; + } + + let mut was_incremental_match = false; + // Match the path to one of the templates + let mut template_name = String::new(); + // We'll try a direct match first + if let Some(template_root_path) = $render_cfg.get(path) { + template_name = template_root_path.to_string(); + } + // Next, an ISR match (more complex), which we only want to run if we didn't get an exact match above + if template_name.is_empty() { + // We progressively look for more and more specificity of the path, adding each segment + // That way, we're searching forwards rather than backwards, which is more efficient + let path_segments: Vec<&str> = path.split('/').collect(); + for (idx, _) in path_segments.iter().enumerate() { + // Make a path out of this and all the previous segments + let path_to_try = path_segments[0..(idx + 1)].join("/") + "/*"; + + // If we find something, keep going until we don't (maximise specificity) + if let Some(template_root_path) = $render_cfg.get(&path_to_try) { + was_incremental_match = true; + template_name = template_root_path.to_string(); + } else { + break; + } + } + } + // If we still have nothing, then the page doesn't exist + if template_name.is_empty() { + return (None, was_incremental_match); + } + + // Return the necessary info for the caller to get the template in a form it wants (might be an `Rc` of a reference) + (template_name, was_incremental_match) + }}; +} + /// Determines the template to use for the given path by checking against the render configuration., also returning whether we matched /// a simple page or an incrementally-generated one (`true` for incrementally generated). Note that simple pages include those on /// incrementally-generated templates that we pre-rendered with *build paths* at build-time (and are hence in an immutable store rather @@ -12,49 +58,38 @@ use sycamore::prelude::GenericNode; /// This houses the central routing algorithm of Perseus, which is based fully on the fact that we know about every single page except /// those rendered with ISR, and we can infer about them based on template root path domains. If that domain system is violated, this /// routing algorithm will not behave as expected whatsoever (as far as routing goes, it's undefined behaviour)! -pub fn get_template_for_path<'a, G: GenericNode>( +pub fn get_template_for_path( raw_path: &str, render_cfg: &HashMap, - templates: &'a TemplateMap, -) -> (Option<&'a Template>, bool) { - let mut path = raw_path; - // If the path is empty, we're looking for the special `index` page - if path.is_empty() { - path = "index"; - } + templates: &TemplateMap, +) -> (Option>>, bool) { + let (template_name, was_incremental_match) = + get_template_for_path!(raw_path, render_cfg, templates); - let mut was_incremental_match = false; - // Match the path to one of the templates - let mut template_name = String::new(); - // We'll try a direct match first - if let Some(template_root_path) = render_cfg.get(path) { - template_name = template_root_path.to_string(); - } - // Next, an ISR match (more complex), which we only want to run if we didn't get an exact match above - if template_name.is_empty() { - // We progressively look for more and more specificity of the path, adding each segment - // That way, we're searching forwards rather than backwards, which is more efficient - let path_segments: Vec<&str> = path.split('/').collect(); - for (idx, _) in path_segments.iter().enumerate() { - // Make a path out of this and all the previous segments - let path_to_try = path_segments[0..(idx + 1)].join("/") + "/*"; - - // If we find something, keep going until we don't (maximise specificity) - if let Some(template_root_path) = render_cfg.get(&path_to_try) { - was_incremental_match = true; - template_name = template_root_path.to_string(); - } else { - break; - } - } - } - // If we still have nothing, then the page doesn't exist - if template_name.is_empty() { - return (None, was_incremental_match); - } + ( + templates.get(&template_name).cloned(), + was_incremental_match, + ) +} + +/// A version of `get_template_for_path` that accepts an `ArcTemplateMap`. This is used by `match_route_atomic`, which should be used in scenarios in which the +/// template map needs to be passed betgween threads. +/// +/// Warning: this returns a `&Template` rather than a `Rc>`, and thus should only be used independently of the rest of Perseus (through `match_route_atomic`). +pub fn get_template_for_path_atomic<'a, G: GenericNode>( + raw_path: &str, + render_cfg: &HashMap, + templates: &'a ArcTemplateMap, +) -> (Option<&'a Template>, bool) { + let (template_name, was_incremental_match) = + get_template_for_path!(raw_path, render_cfg, templates); - // Get the template to use (the `Option` this returns is perfect) if it exists - (templates.get(&template_name), was_incremental_match) + ( + templates + .get(&template_name) + .map(|pointer| pointer.as_ref()), + was_incremental_match, + ) } /// Matches the given path to a `RouteVerdict`. This takes a `TemplateMap` to match against, the render configuration to index, and it @@ -85,7 +120,7 @@ pub fn match_route( locale: locale.to_string(), // This will be used in asset fetching from the server path: path_without_locale, - template: template.clone(), + template, was_incremental_match, }), None => RouteVerdict::NotFound, @@ -108,7 +143,7 @@ pub fn match_route( locale: locales.default.to_string(), // This will be used in asset fetching from the server path: path_joined, - template: template.clone(), + template, was_incremental_match, }), None => RouteVerdict::NotFound, @@ -118,13 +153,72 @@ pub fn match_route( verdict } +/// A version of `match_route` that accepts an `ArcTemplateMap`. This should be used in multithreaded situations, like on the server. +pub fn match_route_atomic<'a, G: GenericNode>( + path_slice: &[&str], + render_cfg: &HashMap, + templates: &'a ArcTemplateMap, + locales: &Locales, +) -> RouteVerdictAtomic<'a, G> { + let path_vec: Vec<&str> = path_slice.to_vec(); + let path_joined = path_vec.join("/"); // This should not have a leading forward slash, it's used for asset fetching by the app shell + + let verdict; + // There are different logic chains if we're using i18n, so we fork out early + if locales.using_i18n && !path_slice.is_empty() { + let locale = path_slice[0]; + // Check if the 'locale' is supported (otherwise it may be the first section of an uni18ned route) + if locales.is_supported(locale) { + // We'll assume this has already been i18ned (if one of your routes has the same name as a supported locale, ffs) + let path_without_locale = path_slice[1..].to_vec().join("/"); + // Get the template to use + let (template, was_incremental_match) = + get_template_for_path_atomic(&path_without_locale, render_cfg, templates); + verdict = match template { + Some(template) => RouteVerdictAtomic::Found(RouteInfoAtomic { + locale: locale.to_string(), + // This will be used in asset fetching from the server + path: path_without_locale, + template, + was_incremental_match, + }), + None => RouteVerdictAtomic::NotFound, + }; + } else { + // If the locale isn't supported, we assume that it's part of a route that still needs a locale (we'll detect the user's preferred) + // This will result in a redirect, and the actual template to use will be determined after that + // We'll just pass through the path to be redirected to (after it's had a locale placed in front) + verdict = RouteVerdictAtomic::LocaleDetection(path_joined) + } + } else if locales.using_i18n { + // If we're here, then we're using i18n, but we're at the root path, which is a locale detection point + verdict = RouteVerdictAtomic::LocaleDetection(path_joined); + } else { + // Get the template to use + let (template, was_incremental_match) = + get_template_for_path_atomic(&path_joined, render_cfg, templates); + verdict = match template { + Some(template) => RouteVerdictAtomic::Found(RouteInfoAtomic { + locale: locales.default.to_string(), + // This will be used in asset fetching from the server + path: path_joined, + template, + was_incremental_match, + }), + None => RouteVerdictAtomic::NotFound, + }; + } + + verdict +} + /// Information about a route, which, combined with error pages and a client-side translations manager, allows the initialization of /// the app shell and the rendering of a page. pub struct RouteInfo { /// The actual path of the route. pub path: String, - /// The template that will be used. The app shell will derive pros and a translator to pass to the template function. - pub template: Template, + /// The template that will be used. The app shell will derive props and a translator to pass to the template function. + pub template: Rc>, /// Whether or not the matched page was incrementally-generated at runtime (if it has been yet). If this is `true`, the server will /// use a mutable store rather than an immutable one. See the book for more details. pub was_incremental_match: bool, @@ -144,6 +238,37 @@ pub enum RouteVerdict { LocaleDetection(String), } +/// Information about a route, which, combined with error pages and a client-side translations manager, allows the initialization of +/// the app shell and the rendering of a page. +/// +/// This version is designed for multithreaded scenarios, and stores a reference to a template rather than an `Rc>`. That means this is not compatible +/// with Perseus on the client-side, only on the server-side. +pub struct RouteInfoAtomic<'a, G: GenericNode> { + /// The actual path of the route. + pub path: String, + /// The template that will be used. The app shell will derive props and a translator to pass to the template function. + pub template: &'a Template, + /// Whether or not the matched page was incrementally-generated at runtime (if it has been yet). If this is `true`, the server will + /// use a mutable store rather than an immutable one. See the book for more details. + pub was_incremental_match: bool, + /// The locale for the template to be rendered in. + pub locale: String, +} + +/// The possible outcomes of matching a route. This is an alternative implementation of Sycamore's `Route` trait to enable greater +/// control and tighter integration of routing with templates. This can only be used if `Routes` has been defined in context (done +/// automatically by the CLI). +/// +/// This version uses `RouteInfoAtomic`, and is designed for multithreaded scenarios (i.e. on the server). +pub enum RouteVerdictAtomic<'a, G: GenericNode> { + /// The given route was found, and route information is attached. + Found(RouteInfoAtomic<'a, G>), + /// The given route was not found, and a `404 Not Found` page should be shown. + NotFound, + /// The given route maps to the locale detector, which will redirect the user to the attached path (in the appropriate locale). + LocaleDetection(String), +} + /// Creates an app-specific routing `struct`. Sycamore expects an `enum` to do this, so we create a `struct` that behaves similarly. If /// we don't do this, we can't get the information necessary for routing into the `enum` at all (context and global variables don't suit /// this particular case). @@ -160,7 +285,6 @@ macro_rules! create_app_route { impl ::sycamore_router::Route for $name { fn match_route(path: &[&str]) -> Self { let verdict = $crate::internal::router::match_route(path, $render_cfg, $templates, $locales); - // BUG Sycamore doesn't call the route verdict matching logic for some reason, but we get to this point Self(verdict) } } diff --git a/packages/perseus/src/server/build_error_page.rs b/packages/perseus/src/server/build_error_page.rs new file mode 100644 index 0000000000..6fcc63b47c --- /dev/null +++ b/packages/perseus/src/server/build_error_page.rs @@ -0,0 +1,53 @@ +use crate::error_pages::{ErrorPageData, ErrorPages}; +use crate::translator::Translator; +use crate::SsrNode; +use std::rc::Rc; + +/// Prepares an HTMl error page for the client, with injected markers for hydration. In the event of an error, this should be returned to the client (with the appropriate status code) to allow Perseus +/// to hydrate and display the correct error page. Note that this is only for use in initial loads (other systems handle errors in subsequent loads, and the app shell +/// exists then so the server doesn't have to do nearly as much work). +pub fn build_error_page( + url: &str, + status: &u16, + // This should already have been transformed into a string (with a source chain etc.) + err: &str, + translator: Option>, + error_pages: &ErrorPages, + html: &str, + root_id: &str, +) -> String { + let error_html = error_pages.render_to_string(url, status, err, translator); + // We create a JSON representation of the data necessary to hydrate the error page on the client-side + // Right now, translators are never included in transmitted error pages + let error_page_data = serde_json::to_string(&ErrorPageData { + url: url.to_string(), + status: *status, + err: err.to_string(), + }) + .unwrap(); + // Add a global variable that defines this as an error + let state_var = format!( + "", + error_page_data + // We escape any backslashes to prevent their interfering with JSON delimiters + .replace(r#"\"#, r#"\\"#) + // We escape any backticks, which would interfere with JS's raw strings system + .replace(r#"`"#, r#"\`"#) + // We escape any interpolations into JS's raw string system + .replace(r#"${"#, r#"\${"#) + ); + let html_with_declaration = html.replace("", &format!("{}\n", state_var)); + // Interpolate the error page itself + let html_to_replace_double = format!("
", root_id); + let html_to_replace_single = format!("
", root_id); + let html_replacement = format!( + // We give the content a specific ID so that it can be hydrated properly + "{}
{}
", + &html_to_replace_double, + &error_html + ); + // Now interpolate that HTML into the HTML shell + html_with_declaration + .replace(&html_to_replace_double, &html_replacement) + .replace(&html_to_replace_single, &html_replacement) +} diff --git a/packages/perseus/src/server/get_render_cfg.rs b/packages/perseus/src/server/get_render_cfg.rs new file mode 100644 index 0000000000..cf0492d8ef --- /dev/null +++ b/packages/perseus/src/server/get_render_cfg.rs @@ -0,0 +1,17 @@ +use crate::errors::*; +use crate::stores::ImmutableStore; +use std::collections::HashMap; + +/// Gets the configuration of how to render each page using an immutable store. +pub async fn get_render_cfg( + immutable_store: &ImmutableStore, +) -> Result, ServerError> { + let content = immutable_store.read("render_conf.json").await?; + let cfg = serde_json::from_str::>(&content).map_err(|e| { + // We have to convert it into a build error and then into a server error + let build_err: BuildError = e.into(); + build_err + })?; + + Ok(cfg) +} diff --git a/packages/perseus/src/server/mod.rs b/packages/perseus/src/server/mod.rs new file mode 100644 index 0000000000..93eccae1d7 --- /dev/null +++ b/packages/perseus/src/server/mod.rs @@ -0,0 +1,19 @@ +//! This module contains the necessary primitives to run Perseus as a server, regardless of framework. This module aims to provide as many abstractions as possible +//! to minimize work when maintaining multiple server-framework integrations. Apart from building your own integrations, you should never need to use this module. + +mod build_error_page; +mod get_render_cfg; +mod options; +/// Utilities for server-side rendering a page to static HTML and JSON for serving to a client. +pub mod render; + +pub use build_error_page::build_error_page; +pub use get_render_cfg::get_render_cfg; +pub use options::{ServerOptions, ServerProps}; + +/// Removes empty elements from a path, which is important due to double slashes. This returns a vector of the path's components; +pub fn get_path_slice(path: &str) -> Vec<&str> { + let path_slice: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + + path_slice +} diff --git a/packages/perseus/src/server/options.rs b/packages/perseus/src/server/options.rs new file mode 100644 index 0000000000..e7984acaa7 --- /dev/null +++ b/packages/perseus/src/server/options.rs @@ -0,0 +1,48 @@ +use crate::error_pages::ErrorPages; +use crate::locales::Locales; +use crate::stores::{ImmutableStore, MutableStore}; +use crate::template::ArcTemplateMap; +use crate::translations_manager::TranslationsManager; +use crate::SsrNode; +use std::collections::HashMap; + +/// The options for setting up all server integrations. This should be literally constructed, as nothing is optional. If integrations need further properties, +/// they should expose their own options in addition to these. These should be accessed through an `Arc`/`Rc` for integration developers. +pub struct ServerOptions { + /// The location on the filesystem of your JavaScript bundle. + pub js_bundle: String, + /// The location on the filesystem of your Wasm bundle. + pub wasm_bundle: String, + /// The location on the filesystem of your `index.html` file. + // TODO Should this actually be a raw string of HTML so plugins can inject efficiently? + pub index: String, + /// A `HashMap` of your app's templates by their paths. + pub templates_map: ArcTemplateMap, + /// The locales information for the app. + pub locales: Locales, + /// The HTML `id` of the element at which to render Perseus. On the server-side, interpolation will be done here in a highly + /// efficient manner by not parsing the HTML, so this MUST be of the form `
` in your markup (double or single + /// quotes, `root_id` replaced by what this property is set to). + pub root_id: String, + /// The location of the JS interop snippets to be served as static files. + pub snippets: String, + /// The error pages for the app. These will be server-rendered if an initial load fails. + pub error_pages: ErrorPages, + /// The directory to serve static content from, which will be mapped to `/.perseus/static`in the browser. + pub static_dir: Option, + /// A map of URLs to act as aliases for certain static resources. These are particularly designed for things like a site manifest or + /// favicons, which should be stored in a static directory, but need to be aliased at a path like `/favicon.ico`. + pub static_aliases: HashMap, +} + +/// The full set of properties that all server integrations take. +pub struct ServerProps { + /// The options for setting up the server. + pub opts: ServerOptions, + /// An immutable store to use. + pub immutable_store: ImmutableStore, + /// A mutable store to use. + pub mutable_store: M, + /// A translations manager to use. + pub translations_manager: T, +} diff --git a/packages/perseus/src/serve.rs b/packages/perseus/src/server/render.rs similarity index 87% rename from packages/perseus/src/serve.rs rename to packages/perseus/src/server/render.rs index ffe9a98d22..d21b3ee64b 100644 --- a/packages/perseus/src/serve.rs +++ b/packages/perseus/src/server/render.rs @@ -1,43 +1,15 @@ -// This file contains the universal logic for a serving process, regardless of framework - use crate::decode_time_str::decode_time_str; use crate::errors::*; +use crate::page_data::PageData; use crate::stores::{ImmutableStore, MutableStore}; use crate::template::{States, Template, TemplateMap}; use crate::translations_manager::TranslationsManager; use crate::translator::Translator; use crate::Request; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::rc::Rc; use sycamore::prelude::SsrNode; -/// Represents the data necessary to render a page, including document metadata. -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct PageData { - /// Prerendered HTML content. - pub content: String, - /// The state for hydration. This is kept as a string for ease of typing. Some pages may not need state or generate it in another way, - /// so this might be `None`. - pub state: Option, - /// The string to interpolate into the document's ``. - pub head: String, -} - -/// Gets the configuration of how to render each page using an immutable store. -pub async fn get_render_cfg( - immutable_store: &ImmutableStore, -) -> Result, ServerError> { - let content = immutable_store.read("render_conf.json").await?; - let cfg = serde_json::from_str::>(&content).map_err(|e| { - // We have to convert it into a build error and then into a server error - let build_err: BuildError = e.into(); - build_err - })?; - - Ok(cfg) -} +// Note: the reason there are a ton of seemingly useless named lifetimes here is because of [this](https://github.com/rust-lang/rust/issues/63033) /// Renders a template that uses state generated at build-time. This can't be used for pages that revalidate because their data are /// stored in a mutable store. @@ -91,7 +63,7 @@ async fn render_build_state_for_mutable( /// SSR-rendered pages. This does everything at request-time, and so doesn't need a mutable or immutable store. async fn render_request_state( template: &Template, - translator: Rc, + translator: &Translator, path: &str, req: Request, ) -> Result<(String, String, Option), ServerError> { @@ -103,9 +75,9 @@ async fn render_request_state( ); // Use that to render the static HTML let html = sycamore::render_to_string(|| { - template.render_for_template(state.clone(), Rc::clone(&translator), true) + template.render_for_template(state.clone(), translator, true) }); - let head = template.render_head_str(state.clone(), Rc::clone(&translator)); + let head = template.render_head_str(state.clone(), translator); Ok((html, head, state)) } @@ -169,11 +141,11 @@ async fn should_revalidate( } Ok(should_revalidate) } -/// Revalidates a template. All information about templates that revalidate (timestamp, content. head, and state) is stored in a +/// Revalidates a template. All information about templates that revalidate (timestamp, content, head, and state) is stored in a /// mutable store, so that's what this function uses. async fn revalidate( template: &Template, - translator: Rc, + translator: &Translator, path: &str, path_encoded: &str, mutable_store: &impl MutableStore, @@ -188,9 +160,9 @@ async fn revalidate( .await?, ); let html = sycamore::render_to_string(|| { - template.render_for_template(state.clone(), Rc::clone(&translator), true) + template.render_for_template(state.clone(), translator, true) }); - let head = template.render_head_str(state.clone(), Rc::clone(&translator)); + let head = template.render_head_str(state.clone(), translator); // Handle revalidation, we need to parse any given time strings into datetimes // We don't need to worry about revalidation that operates by logic, that's request-time only if template.revalidates_with_time() { @@ -236,11 +208,9 @@ pub async fn get_page_for_template( translations_manager: &impl TranslationsManager, ) -> Result { // Get a translator for this locale (for sanity we hope the manager is caching) - let translator = Rc::new( - translations_manager - .get_translator_for_locale(locale.to_string()) - .await?, - ); + let translator = translations_manager + .get_translator_for_locale(locale.to_string()) + .await?; let mut path = raw_path; // If the path is empty, we're looking for the special `index` page @@ -269,14 +239,9 @@ pub async fn get_page_for_template( Some((html_val, head_val)) => { // Check if we need to revalidate if should_revalidate(template, &path_encoded, mutable_store).await? { - let (html_val, head_val, state) = revalidate( - template, - Rc::clone(&translator), - path, - &path_encoded, - mutable_store, - ) - .await?; + let (html_val, head_val, state) = + revalidate(template, &translator, path, &path_encoded, mutable_store) + .await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has if html.is_empty() { html = html_val; @@ -309,9 +274,9 @@ pub async fn get_page_for_template( .await?, ); let html_val = sycamore::render_to_string(|| { - template.render_for_template(state.clone(), Rc::clone(&translator), true) + template.render_for_template(state.clone(), &translator, true) }); - let head_val = template.render_head_str(state.clone(), Rc::clone(&translator)); + let head_val = template.render_head_str(state.clone(), &translator); // Handle revalidation, we need to parse any given time strings into datetimes // We don't need to worry about revalidation that operates by logic, that's request-time only // Obviously we don't need to revalidate now, we just created it @@ -356,14 +321,8 @@ pub async fn get_page_for_template( // Handle if we need to revalidate // It'll be in the mutable store if we do if should_revalidate(template, &path_encoded, mutable_store).await? { - let (html_val, head_val, state) = revalidate( - template, - Rc::clone(&translator), - path, - &path_encoded, - mutable_store, - ) - .await?; + let (html_val, head_val, state) = + revalidate(template, &translator, path, &path_encoded, mutable_store).await?; // Build-time generated HTML is the lowest priority, so we'll only set it if nothing else already has if html.is_empty() { html = html_val; @@ -397,7 +356,7 @@ pub async fn get_page_for_template( // Handle request state if template.uses_request_state() { let (html_val, head_val, state) = - render_request_state(template, Rc::clone(&translator), path, req).await?; + render_request_state(template, &translator, path, req).await?; // Request-time HTML always overrides anything generated at build-time or incrementally (this has more information) html = html_val; head = head_val; diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index a2a5c870c5..88eb63c2c4 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -1,8 +1,8 @@ use crate::client_translations_manager::ClientTranslationsManager; use crate::error_pages::ErrorPageData; use crate::errors::*; +use crate::page_data::PageData; use crate::path_prefix::get_path_prefix_client; -use crate::serve::PageData; use crate::template::Template; use crate::ErrorPages; use fmterr::fmt_err; @@ -214,7 +214,7 @@ pub enum InitialState { // TODO handle exceptions higher up pub async fn app_shell( path: String, - (template, was_incremental_match): (Template, bool), + (template, was_incremental_match): (Rc>, bool), locale: String, translations_manager: Rc>, error_pages: Rc>, @@ -271,7 +271,7 @@ pub async fn app_shell( // BUG (Sycamore): this will double-render if the component is just text (no nodes) sycamore::hydrate_to( // This function provides translator context as needed - || template.render_for_template(state, Rc::clone(&translator), false), + || template.render_for_template(state, translator, false), &container_rx_elem, ); checkpoint("page_interactive"); @@ -351,10 +351,10 @@ pub async fn app_shell( // BUG (Sycamore): this will double-render if the component is just text (no nodes) sycamore::hydrate_to( // This function provides translator context as needed - || { + move || { template.render_for_template( page_data.state, - Rc::clone(&translator), + translator, false, ) }, diff --git a/packages/perseus/src/stores/mutable.rs b/packages/perseus/src/stores/mutable.rs index 1635097bd8..21b2d1317b 100644 --- a/packages/perseus/src/stores/mutable.rs +++ b/packages/perseus/src/stores/mutable.rs @@ -4,7 +4,7 @@ use std::fs; /// A trait for mutable stores. This is abstracted away so that users can implement a non-filesystem mutable store, which is useful /// for read-only filesystem environments, as on many modern hosting providers. See the book for further details on this subject. #[async_trait::async_trait] -pub trait MutableStore: Clone { +pub trait MutableStore: Clone + Send + Sync { /// Reads data from the named asset. async fn read(&self, name: &str) -> Result; /// Writes data to the named asset. This will create a new asset if one doesn't exist already. diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index 2a82487da3..e4397ffa59 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -10,6 +10,7 @@ use http::header::HeaderMap; use std::collections::HashMap; use std::pin::Pin; use std::rc::Rc; +use std::sync::Arc; use sycamore::context::{ContextProvider, ContextProviderProps}; use sycamore::prelude::{template, GenericNode, Template as SycamoreTemplate}; @@ -21,8 +22,8 @@ pub struct RenderCtx { /// to be run in the browser. pub is_server: bool, /// A translator for templates to use. This will still be present in non-i18n apps, but it will have no message IDs and support for - /// the non-existent locale `xx-XX`. - pub translator: Rc, + /// the non-existent locale `xx-XX`. This uses an `Arc` for thread-safety. + pub translator: Translator, } /// Represents all the different states that can be generated for a single template, allowing amalgamation logic to be run with the knowledge @@ -61,7 +62,7 @@ impl States { } /// A generic error type that can be adapted for any errors the user may want to return from a render function. `.into()` can be used /// to convert most error types into this without further hassle. Otherwise, use `Box::new()` on the type. -pub type RenderFnResult = std::result::Result>; +pub type RenderFnResult = std::result::Result>; /// A generic error type that can be adapted for any errors the user may want to return from a render function, as with `RenderFnResult`. /// However, this also includes a mandatory statement of causation for any errors, which assigns blame for them to either the client /// or the server. In cases where this is ambiguous, this allows returning accurate HTTP status codes. @@ -72,7 +73,7 @@ pub type RenderFnResult = std::result::Result>; pub type RenderFnResultWithCause = std::result::Result; /// A generic return type for asynchronous functions that we need to store in a struct. -type AsyncFnReturn = Pin>>; +type AsyncFnReturn = Pin + Send + Sync>>; /// Creates traits that prevent users from having to pin their functions' return types. We can't make a generic one until desugared function /// types are stabilized (https://github.com/rust-lang/rust/issues/29625). @@ -96,7 +97,7 @@ macro_rules! make_async_trait { $arg, )* ) -> F, - F: Future + 'static, + F: Future + Send + Sync + 'static, { fn call( &self, @@ -135,29 +136,29 @@ make_async_trait!(ShouldRevalidateFnType, RenderFnResultWithCause); // A series of closure types that should not be typed out more than once /// The type of functions that are given a state and render a page. If you've defined state for your page, it's safe to `.unwrap()` the /// given `Option`. If you're using i18n, an `Rc` will also be made available through Sycamore's [context system](https://sycamore-rs.netlify.app/docs/advanced/advanced_reactivity). -pub type TemplateFn = Rc) -> SycamoreTemplate>; +pub type TemplateFn = Box) -> SycamoreTemplate + Send + Sync>; /// A type alias for the function that modifies the document head. This is just a template function that will always be server-side /// rendered in function (it may be rendered on the client, but it will always be used to create an HTML string, rather than a reactive /// template). pub type HeadFn = TemplateFn; /// The type of functions that modify HTTP response headers. -pub type SetHeadersFn = Rc) -> HeaderMap>; +pub type SetHeadersFn = Box) -> HeaderMap + Send + Sync>; /// The type of functions that get build paths. -pub type GetBuildPathsFn = Rc; +pub type GetBuildPathsFn = Box; /// The type of functions that get build state. -pub type GetBuildStateFn = Rc; +pub type GetBuildStateFn = Box; /// The type of functions that get request state. -pub type GetRequestStateFn = Rc; +pub type GetRequestStateFn = Box; /// The type of functions that check if a template sghould revalidate. -pub type ShouldRevalidateFn = Rc; +pub type ShouldRevalidateFn = Box; /// The type of functions that amalgamate build and request states. -pub type AmalgamateStatesFn = Rc RenderFnResultWithCause>>; +pub type AmalgamateStatesFn = + Box RenderFnResultWithCause> + Send + Sync>; /// This allows the specification of all the template templates in an app and how to render them. If no rendering logic is provided at all, /// the template will be prerendered at build-time with no state. All closures are stored on the heap to avoid hellish lifetime specification. /// All properties for templates are passed around as strings to avoid type maps and other horrible things, this only adds one extra /// deserialization call at build time. This only actually owns a two `String`s and a `bool`. -#[derive(Clone)] pub struct Template { /// The path to the root of the template. Any build paths will be inserted under this. path: String, @@ -207,11 +208,11 @@ impl Template { pub fn new(path: impl Into + std::fmt::Display) -> Self { Self { path: path.to_string(), - template: Rc::new(|_: Option| sycamore::template! {}), + template: Box::new(|_: Option| sycamore::template! {}), // Unlike `template`, this may not be set at all (especially in very simple apps) - head: Rc::new(|_: Option| sycamore::template! {}), + head: Box::new(|_: Option| sycamore::template! {}), // We create sensible header defaults here - set_headers: Rc::new(|_: Option| default_headers()), + set_headers: Box::new(|_: Option| default_headers()), get_build_paths: None, incremental_generation: false, get_build_state: None, @@ -227,7 +228,7 @@ impl Template { pub fn render_for_template( &self, props: Option, - translator: Rc, + translator: &Translator, is_server: bool, ) -> SycamoreTemplate { template! { @@ -235,7 +236,7 @@ impl Template { ContextProvider(ContextProviderProps { value: RenderCtx { is_server, - translator: Rc::clone(&translator) + translator: translator.clone() }, children: || (self.template)(props) }) @@ -243,7 +244,7 @@ impl Template { } /// Executes the user-given function that renders the document ``, returning a string to be interpolated manually. Reactivity /// in this function will not take effect due to this string rendering. Note that this function will provide a translator context. - pub fn render_head_str(&self, props: Option, translator: Rc) -> String { + pub fn render_head_str(&self, props: Option, translator: &Translator) -> String { sycamore::render_to_string(|| { template! { // We provide the translator through context, which avoids having to define a separate variable for every translation due to Sycamore's `template!` macro taking ownership with `move` closures @@ -252,7 +253,7 @@ impl Template { // This function renders to a string, so we're effectively always on the server // It's also only ever run on the server is_server: true, - translator: Rc::clone(&translator) + translator: translator.clone() }, children: || (self.head)(props) }) @@ -445,9 +446,9 @@ impl Template { /// Sets the template rendering function to use. pub fn template( mut self, - val: impl Fn(Option) -> SycamoreTemplate + 'static, + val: impl Fn(Option) -> SycamoreTemplate + Send + Sync + 'static, ) -> Template { - self.template = Rc::new(val); + self.template = Box::new(val); self } /// Sets the document head rendering function to use. @@ -455,12 +456,12 @@ impl Template { #[allow(unused_variables)] pub fn head( mut self, - val: impl Fn(Option) -> SycamoreTemplate + 'static, + val: impl Fn(Option) -> SycamoreTemplate + Send + Sync + 'static, ) -> Template { // Headers are always prerendered on the server-side #[cfg(feature = "server-side")] { - self.head = Rc::new(val); + self.head = Box::new(val); } self } @@ -469,21 +470,24 @@ impl Template { #[allow(unused_variables)] pub fn set_headers_fn( mut self, - val: impl Fn(Option) -> HeaderMap + 'static, + val: impl Fn(Option) -> HeaderMap + Send + Sync + 'static, ) -> Template { #[cfg(feature = "server-side")] { - self.set_headers = Rc::new(val); + self.set_headers = Box::new(val); } self } /// Enables the *build paths* strategy with the given function. #[allow(unused_mut)] #[allow(unused_variables)] - pub fn build_paths_fn(mut self, val: impl GetBuildPathsFnType + 'static) -> Template { + pub fn build_paths_fn( + mut self, + val: impl GetBuildPathsFnType + Send + Sync + 'static, + ) -> Template { #[cfg(feature = "server-side")] { - self.get_build_paths = Some(Rc::new(val)); + self.get_build_paths = Some(Box::new(val)); } self } @@ -500,20 +504,26 @@ impl Template { /// Enables the *build state* strategy with the given function. #[allow(unused_mut)] #[allow(unused_variables)] - pub fn build_state_fn(mut self, val: impl GetBuildStateFnType + 'static) -> Template { + pub fn build_state_fn( + mut self, + val: impl GetBuildStateFnType + Send + Sync + 'static, + ) -> Template { #[cfg(feature = "server-side")] { - self.get_build_state = Some(Rc::new(val)); + self.get_build_state = Some(Box::new(val)); } self } /// Enables the *request state* strategy with the given function. #[allow(unused_mut)] #[allow(unused_variables)] - pub fn request_state_fn(mut self, val: impl GetRequestStateFnType + 'static) -> Template { + pub fn request_state_fn( + mut self, + val: impl GetRequestStateFnType + Send + Sync + 'static, + ) -> Template { #[cfg(feature = "server-side")] { - self.get_request_state = Some(Rc::new(val)); + self.get_request_state = Some(Box::new(val)); } self } @@ -522,11 +532,11 @@ impl Template { #[allow(unused_variables)] pub fn should_revalidate_fn( mut self, - val: impl ShouldRevalidateFnType + 'static, + val: impl ShouldRevalidateFnType + Send + Sync + 'static, ) -> Template { #[cfg(feature = "server-side")] { - self.should_revalidate = Some(Rc::new(val)); + self.should_revalidate = Some(Box::new(val)); } self } @@ -546,11 +556,11 @@ impl Template { #[allow(unused_variables)] pub fn amalgamate_states_fn( mut self, - val: impl Fn(States) -> RenderFnResultWithCause> + 'static, + val: impl Fn(States) -> RenderFnResultWithCause> + Send + Sync + 'static, ) -> Template { #[cfg(feature = "server-side")] { - self.amalgamate_states = Some(Rc::new(val)); + self.amalgamate_states = Some(Box::new(val)); } self } @@ -568,7 +578,30 @@ macro_rules! get_templates_map { $( map.insert( $template.get_path(), - $template + ::std::rc::Rc::new($template) + ); + )+ + + map + } + }; +} + +/// Gets a `HashMap` of the given templates by their paths for serving. This should be manually wrapped for the pages your app provides +/// for convenience. +/// +/// This is the thread-safe version, which should only be used on the server. +#[macro_export] +macro_rules! get_templates_map_atomic { + [ + $($template:expr),+ + ] => { + { + let mut map = ::std::collections::HashMap::new(); + $( + map.insert( + $template.get_path(), + ::std::sync::Arc::new($template) ); )+ @@ -577,8 +610,10 @@ macro_rules! get_templates_map { }; } -/// A type alias for a `HashMap` of `Template`s. -pub type TemplateMap = HashMap>; +/// A type alias for a `HashMap` of `Template`s. This uses `Rc`s to make the `Template`s cloneable. In server-side multithreading, `ArcTemplateMap` should be used instead. +pub type TemplateMap = HashMap>>; +/// A type alias for a `HashMap` of `Template`s that uses `Arc`s for thread-safety. If you don't need to share templates between threads, use `TemplateMap` instead. +pub type ArcTemplateMap = HashMap>>; /// Checks if we're on the server or the client. This must be run inside a reactive scope (e.g. a `template!` or `create_effect`), /// because it uses Sycamore context. diff --git a/packages/perseus/src/translations_manager.rs b/packages/perseus/src/translations_manager.rs index 89416c3b19..fe0f92c5eb 100644 --- a/packages/perseus/src/translations_manager.rs +++ b/packages/perseus/src/translations_manager.rs @@ -14,13 +14,13 @@ pub enum TranslationsManagerError { ReadFailed { locale: String, #[source] - source: Box, + source: Box, }, #[error("translations for locale '{locale}' couldn't be serialized into translator")] SerializationFailed { locale: String, #[source] - source: Box, + source: Box, }, } @@ -32,7 +32,7 @@ use std::fs; /// A trait for systems that manage where to put translations. At simplest, we'll just write them to static files, but they might also /// be stored in a CMS. It is **strongly** advised that any implementations use some form of caching, guided by `FsTranslationsManager`. #[async_trait::async_trait] -pub trait TranslationsManager: Clone { +pub trait TranslationsManager: Clone + Send + Sync { /// Gets a translator for the given locale. async fn get_translator_for_locale( &self, diff --git a/packages/perseus/src/translator/dummy.rs b/packages/perseus/src/translator/dummy.rs index 9eba6143b9..34b986ffb3 100644 --- a/packages/perseus/src/translator/dummy.rs +++ b/packages/perseus/src/translator/dummy.rs @@ -8,6 +8,7 @@ pub const DUMMY_TRANSLATOR_FILE_EXT: &str = ""; /// /// If you're using i18n, enable the `translator-fluent` feature flag to replace this with `FluentTranslator`, which will actually translate /// things. +#[derive(Clone)] pub struct DummyTranslator; impl DummyTranslator { /// Creates a new dummy translator, accepting the usual parameters for translators. diff --git a/packages/perseus/src/translator/errors.rs b/packages/perseus/src/translator/errors.rs index 6ad4cafd74..789c871793 100644 --- a/packages/perseus/src/translator/errors.rs +++ b/packages/perseus/src/translator/errors.rs @@ -11,20 +11,20 @@ pub enum TranslatorError { TranslationsStrSerFailed { locale: String, #[source] - source: Box, + source: Box, }, #[error("locale '{locale}' is of invalid form")] InvalidLocale { locale: String, // We have a source here to support different i18n systems' definitions of a locale #[source] - source: Box, + source: Box, }, #[error("translating message '{id}' into '{locale}' failed")] TranslationFailed { id: String, locale: String, - source: Box, + source: Box, }, /// This could be caused by an invalid variant for a compound message. #[error("no translation could be derived for message '{id}' in locale '{locale}'")] diff --git a/packages/perseus/src/translator/fluent.rs b/packages/perseus/src/translator/fluent.rs index 0819c986f0..08f5be8a38 100644 --- a/packages/perseus/src/translator/fluent.rs +++ b/packages/perseus/src/translator/fluent.rs @@ -1,6 +1,7 @@ use crate::translator::errors::*; -use fluent_bundle::{FluentArgs, FluentBundle, FluentResource}; -use std::rc::Rc; +use fluent_bundle::{bundle::FluentBundle, FluentArgs, FluentResource}; +use intl_memoizer::concurrent::IntlLangMemoizer; +use std::sync::Arc; use sycamore::context::use_context; use unic_langid::{LanguageIdentifier, LanguageIdentifierError}; @@ -13,9 +14,13 @@ pub const FLUENT_TRANSLATOR_FILE_EXT: &str = "ftl"; /// /// Fluent supports compound messages, with many variants, which can specified here using the form `[id].[variant]` in a translation ID, /// as a `.` is not valid in an ID anyway, and so can be used as a delimiter. More than one dot will result in an error. +/// +/// Note that this uses the concurrent version of `FluentBundle` to support server-side usage. +#[derive(Clone)] pub struct FluentTranslator { - /// Stores the internal Fluent data for translating. This bundle directly owns its attached resources (translations). - bundle: Rc>, + /// Stores the internal Fluent data for translating. This bundle directly owns its attached resources (translations). This uses an `Arc` to allow cloning, and so + /// the broader translator should be cloned as sparingly as possible to minimize overhead. + bundle: Arc>, /// The locale for which translations are being managed by this instance. locale: String, } @@ -39,7 +44,8 @@ impl FluentTranslator { source: Box::new(err), } })?; - let mut bundle = FluentBundle::new(vec![lang_id]); + let mut bundle: FluentBundle = + FluentBundle::new_concurrent(vec![lang_id]); bundle.add_resource(resource).map_err(|errs| { TranslatorError::TranslationsStrSerFailed { locale: locale.clone(), @@ -50,11 +56,9 @@ impl FluentTranslator { .into(), } })?; + let bundle = Arc::new(bundle); - Ok(Self { - bundle: Rc::new(bundle), - locale, - }) + Ok(Self { bundle, locale }) } /// Gets the path to the given URL in whatever locale the instance is configured for. This also applies the path prefix. pub fn url(&self, url: &str) -> String { @@ -156,8 +160,8 @@ impl FluentTranslator { } } /// Gets the Fluent bundle for more advanced translation requirements. - pub fn get_bundle(&self) -> Rc> { - Rc::clone(&self.bundle) + pub fn get_bundle(&self) -> &FluentBundle { + &self.bundle } }